Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f14a6180e | ||
|
|
b46b301dab | ||
|
|
50be333552 | ||
|
|
3bb0f70acc | ||
|
|
52a82a4e56 | ||
|
|
5f3706e807 | ||
|
|
5f57008aed | ||
|
|
bc3e0a9f49 | ||
|
|
a71da41146 | ||
|
|
4c74e7ea49 | ||
|
|
ac3a863bec | ||
|
|
af7d330101 | ||
|
|
db75994f82 | ||
|
|
ac977d833b |
No files matched your search
@@ -881,6 +881,58 @@ OIDC session or an account. The rule:
|
||||
never on a deployed one.
|
||||
- Dev mode keeps every CLI verb as it is.
|
||||
|
||||
### Host trust boundary
|
||||
|
||||
The host is the trust boundary. The instance uid can read and write the
|
||||
instance's config, data root, bus files and credentials. That makes the uid
|
||||
equivalent to root for this instance, though not root on the host. A CLI
|
||||
token cannot protect the instance from someone who already has that uid:
|
||||
they can read the token file, the bus database and every key, or change the
|
||||
files directly. A token could help a remote admin CLI that has no host
|
||||
access. That is a possible later feature, not part of Phase M.
|
||||
|
||||
Phase M narrows who is assigned the instance uid. In the current managed
|
||||
host setup, both Case 1 and Case 2 use `mosaic`: the host run sheet creates
|
||||
it as a system account with a non-login shell and no supplementary groups
|
||||
or sudo rule (`docs/guides/host-run-sheet.md:19-26`). Case 1 acceptance
|
||||
checks that the Console and bus run as the configured `--user`
|
||||
(`scripts/case1-accept.sh:102-116,457-469`). Case 2's unit renderer uses
|
||||
its `--user` for both services (`scripts/case2-unit.sh:136-145,514`); the
|
||||
host setup runs that path as `mosaic`
|
||||
(`docs/plans/2026-10-20_case2-rows.md:1197-1200`). These are today's
|
||||
configured identities, not an installer guarantee: `case2-unit.sh` accepts
|
||||
any existing non-root user with a matching home. The run sheet, rather than
|
||||
the renderer, selects the dedicated account.
|
||||
|
||||
There is also a separate Console path. `webui-service.sh` installs a
|
||||
systemd user unit, then asks the operator to start it with
|
||||
`systemctl --user` (`scripts/webui-service.sh:1-7,19,413-418`). That Console
|
||||
runs as the account whose user manager starts it, not as the managed
|
||||
`mosaic` service account. It is a single-user development path, not a safe
|
||||
accounts-mode deployment path. M3 follow-up 1 is to refuse a deployed or
|
||||
accounts-mode Console service through this user-unit path.
|
||||
|
||||
The stack's own managed agent sessions run as the instance uid inside a
|
||||
bubblewrap sandbox, not in containers (`scripts/case1-accept.sh:480-496`,
|
||||
`docs/plans/2026-10-20_case2-rows.md:1031-1034,1197-1199`). The sandbox
|
||||
limits their filesystem view. A sandbox escape has the same authority as
|
||||
holding the instance uid. This is different from a development agent seat:
|
||||
Phase M requires the instance uid to differ from every human login and
|
||||
development seat uid. A non-login shell prevents direct login, but root or a
|
||||
human with sudo or equivalent authority can still act as `mosaic`.
|
||||
|
||||
M2's accounts loader refuses a file with the wrong uid or group or other
|
||||
write permission. M2 does not currently specify the config-directory owner
|
||||
check for `mosaic accounts` or an OS-uid receipt. M3 pins both as integration
|
||||
expectations. The recorded uid identifies the process, not
|
||||
necessarily the person who invoked it. The receipt is writable by the
|
||||
instance uid and is not tamper-evident against that uid. Stamped builds and
|
||||
`.deployed` also refuse dev flags in M3.
|
||||
|
||||
The residual risk is accepted: host root, or the instance uid, owns the
|
||||
instance. Accounts protect users from one another through the Console and
|
||||
bus. They do not protect an instance from its host owner.
|
||||
|
||||
### Locks
|
||||
|
||||
Three locks exist after this design: the per-org lifecycle lock
|
||||
@@ -1295,6 +1347,7 @@ Rollback keeps OIDC. Nothing in it removes `--sso` or turns sign-in off.
|
||||
| Identity confusion across providers | Identity is `(provider, sub)` with the issuer from config. Email matches invites only. A subject belongs to one account. |
|
||||
| Local CLI acting outside the check | In accounts mode `mosaic org` and `decide --org` refuse. `mosaic accounts` runs as the instance uid, which can already read every bus file, and has no rights verbs. |
|
||||
| Tampering with accounts.json on disk | The file must belong to the instance uid and not be group or other writable. Anyone who can write it already runs as the instance user and can read every bus. That is outside this design, as it is for access.json. |
|
||||
| Host root or the instance uid bypassing accounts | Accepted residual risk: either can read and change the instance config, data root, bus files and credentials. An administrator who can become the instance uid has the same authority. An OS-uid receipt identifies the process, not the human, and the instance uid can change the receipt. |
|
||||
|
||||
Not covered: a compromised identity provider, a stolen session cookie
|
||||
before its 1 hour idle limit, and a compromised Console process, which holds
|
||||
@@ -1397,6 +1450,21 @@ Acceptance tests, against a fake OIDC provider on port 0:
|
||||
- `--dev-local-owner` from a checkout refuses with accounts.json present,
|
||||
with `console.sso`, and with `.deployed`.
|
||||
- `webui-service.sh render` and `case2-unit.sh` refuse a dev flag.
|
||||
- `case2-unit.sh` accepts the host run sheet's `mosaic` account fixture only
|
||||
when its shell is non-login, its primary group has no other members, and
|
||||
it owns the config directory and data root. Fixtures for a human login and
|
||||
a development seat with interactive shells are refused. Both rendered
|
||||
system units use that same uid.
|
||||
- `case1-accept.sh` F1 continues to verify that the running Console and bus
|
||||
processes have the configured instance uid.
|
||||
- M3 integration tests verify that `mosaic accounts` refuses when its
|
||||
effective uid does not own the config directory and that its receipt
|
||||
records the effective OS uid. The test documents that this is process
|
||||
identity, not human attribution or tamper evidence.
|
||||
- **M3 follow-up 1:** accounts mode refuses the `webui-service.sh` systemd
|
||||
user-unit path. Managed accounts instances start through the system units
|
||||
rendered by `case2-unit.sh`. Tests show the user-unit path cannot start a
|
||||
deployed accounts instance while development mode remains available.
|
||||
- The existing sso and dev-owner suites stay green.
|
||||
|
||||
Grant needed: `stack-copy.sh` writes a new file into every copy, and the
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
// Old path of packages/organization/src/accounts.mjs (rename design r2, stage B).
|
||||
export * from "../../organization/src/accounts.mjs";
|
||||
@@ -1298,3 +1298,32 @@ shows selected, active and inactive ids. The owner replaces the selection with
|
||||
`mosaic project system-types-set PROJECT --types coding,montage --org ID`; an
|
||||
empty `--types ''` clears it. Disabled selections stay recorded but become
|
||||
inactive. Project settings changes are append-only and require human proof.
|
||||
|
||||
## Accounts
|
||||
|
||||
`mosaic accounts` administers the instance-level accounts store locally, as the
|
||||
effective uid owning the config directory. It requires a human shell, rejects `--tester`,
|
||||
and takes no business selector or bus connection. It has no organization rights
|
||||
verbs. Console sign-in, sessions and membership evaluation are later Phase M
|
||||
slices; these commands alone do not turn accounts mode on.
|
||||
|
||||
```sh
|
||||
mosaic accounts init --name Operator
|
||||
mosaic accounts list --json
|
||||
mosaic accounts user add --name Pat --subject pocket:verified-subject --revision 1
|
||||
mosaic accounts user disable u-0123456789ab --revision 2
|
||||
mosaic accounts user enable u-0123456789ab --revision 3
|
||||
mosaic accounts user signout u-0123456789ab --revision 4
|
||||
mosaic accounts user remove u-0123456789ab --revision 5
|
||||
```
|
||||
|
||||
Init creates one operator account containing every configured `console.sso`
|
||||
subject and refuses if accounts.json already exists. Add mints its id and defaults
|
||||
to instance role `user`; `--role operator` creates another operator. Writes require
|
||||
the file revision from list. Disable, enable and signout advance `authGen`, and
|
||||
the last active operator cannot be disabled or removed. Removal scans ownership
|
||||
while holding the registry then accounts locks, refuses if the account owns an
|
||||
organization (including archived organizations) or any registry entry lacks its
|
||||
owner metadata during creation, and removes its memberships in
|
||||
the same write. List reports rollback rows needing repair; its JSON omits invite
|
||||
bindings and hashes. Receipts record effective process uid and verb and omit subjects and secrets.
|
||||
@@ -0,0 +1,109 @@
|
||||
// Phase M2: the local, instance-uid account administration exception. No org
|
||||
// rights commands and no bus transport: account init must work before orgs exist.
|
||||
import { basename, join } from 'node:path';
|
||||
import { configDir } from '../../organization/src/organization-config.mjs';
|
||||
import { accountsRepairs, assertAccountsOwner, loadAccounts, mintAccountId, withAccounts } from '../../organization/src/accounts.mjs';
|
||||
import { withRegistry, readRegistryLocked } from '../../organization/src/org-registry.mjs';
|
||||
import { readJsonFile, rejectUnknownKeys, requireString } from '../../organization/src/util.mjs';
|
||||
import { CliError } from './errors.mjs';
|
||||
import { refuseInsideAgent } from './transport.mjs';
|
||||
|
||||
export const ACCOUNTS_USAGE = [
|
||||
'mosaic accounts init [--name NAME]',
|
||||
'mosaic accounts list [--json]',
|
||||
'mosaic accounts user add --name NAME --subject PROVIDER:SUB [--role operator|user] --revision N',
|
||||
'mosaic accounts user disable|enable|signout|remove ID --revision N',
|
||||
].join('\n');
|
||||
function parse(args) {
|
||||
const words = [], values = {}, flags = new Set();
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
if (!arg.startsWith('--')) { words.push(arg); continue; }
|
||||
if (flags.has(arg) || Object.hasOwn(values, arg)) throw new CliError(`duplicate ${arg}`, 4);
|
||||
if (arg === '--tester') throw new CliError('refused: accounts takes the human shell, not --tester', 3);
|
||||
if (arg === '--json') flags.add(arg);
|
||||
else if (['--name', '--subject', '--role', '--revision'].includes(arg) && args[i + 1] && !args[i + 1].startsWith('--')) values[arg] = args[++i];
|
||||
else throw new CliError(ACCOUNTS_USAGE, 4);
|
||||
}
|
||||
const verb = words[0] === 'user' ? `user.${words[1]}` : words[0];
|
||||
const valid = ['init', 'list'].includes(verb) ? words.length === 1 : verb === 'user.add' ? words.length === 2 : ['user.disable', 'user.enable', 'user.signout', 'user.remove'].includes(verb) && words.length === 3;
|
||||
if (!valid) throw new CliError(ACCOUNTS_USAGE, 4);
|
||||
const allowed = verb === 'init' ? ['--name'] : verb === 'list' ? [] : verb === 'user.add' ? ['--name', '--subject', '--role', '--revision'] : ['--revision'];
|
||||
if (Object.keys(values).some(k => !allowed.includes(k)) || (flags.size && verb !== 'list')) throw new CliError(ACCOUNTS_USAGE, 4);
|
||||
const revision = verb === 'init' ? 0 : Number(values['--revision']);
|
||||
if (verb !== 'list' && verb !== 'init' && (!/^[1-9][0-9]*$/.test(values['--revision'] ?? '') || !Number.isSafeInteger(revision))) throw new CliError('--revision requires the revision from accounts list', 4);
|
||||
return { verb, values, flags, revision, id: words[2] };
|
||||
}
|
||||
function owns(user, owner, providers) {
|
||||
return user.subjects.some(s => s.provider === owner.provider && s.sub === owner.sub && providers.find(p => p.id === s.provider)?.issuer === owner.iss);
|
||||
}
|
||||
function readOwner(root) {
|
||||
const owner = readJsonFile(join(root, 'owner.json'), 'organization owner', st => {
|
||||
if (st.uid !== process.geteuid() || (st.mode & 0o777) !== 0o600) throw new CliError('refused: unsafe organization owner', 3);
|
||||
});
|
||||
rejectUnknownKeys(owner, ['provider', 'iss', 'sub'], 'organization owner');
|
||||
for (const key of ['provider', 'iss', 'sub']) requireString(owner[key], `owner.${key}`, { max: 2048 });
|
||||
return owner;
|
||||
}
|
||||
export async function accountsCommand(args, io, deps) {
|
||||
refuseInsideAgent(io.env);
|
||||
try {
|
||||
const parsed = parse(args);
|
||||
const dir = deps.configDir ?? configDir(io.env);
|
||||
// This is deliberately before withRegistry as well as withAccounts.
|
||||
assertAccountsOwner(dir, { uid: deps.uid ?? process.geteuid() });
|
||||
const providers = deps.system.console?.sso?.providers ?? [];
|
||||
const ctx = { configDir: dir, instance: deps.instance ?? basename(dir).replace(/^mosaic-/, ''), providers };
|
||||
if (parsed.verb === 'list') {
|
||||
const doc = loadAccounts(join(dir, 'accounts.json'), ctx);
|
||||
const repairs = accountsRepairs(doc);
|
||||
// Invites are not administered here. Never expose bindings or token hashes.
|
||||
const listed = { ...doc, invites: doc.invites.map(({ tokenHash, bind, ...invite }) => invite), repairs };
|
||||
io.stdout.write(parsed.flags.has('--json') ? JSON.stringify(listed, null, 2) + '\n' : [`accounts revision ${doc.revision}`, ...doc.users.map(u => `${u.id} ${u.name} ${u.instanceRole} ${u.state} authGen ${u.authGen}`), ...repairs.map(r => `${r.kind} ${r.org} needs repair: ${[r.unknownRole, ...r.unknownRights].filter(Boolean).join(', ')}`)].join('\n') + '\n');
|
||||
return;
|
||||
}
|
||||
let added;
|
||||
const mutate = doc => {
|
||||
if (parsed.verb === 'init') {
|
||||
const subjects = providers.flatMap(p => (p.subjects ?? []).map(s => ({ provider: p.id, sub: s.sub })));
|
||||
if (!subjects.length) throw new CliError('refused: init requires console.sso subjects', 3);
|
||||
added = { id: mintAccountId(), name: parsed.values['--name'] ?? 'Operator', subjects, instanceRole: 'operator', state: 'active', authGen: 1, created: new Date().toISOString() };
|
||||
doc.users.push(added); return;
|
||||
}
|
||||
if (parsed.verb === 'user.add') {
|
||||
const value = parsed.values['--subject'] ?? '', colon = value.indexOf(':');
|
||||
if (!parsed.values['--name'] || colon < 1 || colon === value.length - 1) throw new CliError('user add needs --name and --subject PROVIDER:SUB', 4);
|
||||
added = { id: mintAccountId(), name: parsed.values['--name'], subjects: [{ provider: value.slice(0, colon), sub: value.slice(colon + 1) }], instanceRole: parsed.values['--role'] ?? 'user', state: 'active', authGen: 1, created: new Date().toISOString() };
|
||||
doc.users.push(added); return;
|
||||
}
|
||||
const u = doc.users.find(u => u.id === parsed.id);
|
||||
if (!u) throw new CliError('refused: account not found', 2);
|
||||
if (parsed.verb === 'user.remove') { doc.users = doc.users.filter(x => x !== u); doc.memberships = doc.memberships.filter(m => m.user !== u.id); }
|
||||
else if (parsed.verb === 'user.signout') u.authGen++;
|
||||
else { const state = parsed.verb === 'user.disable' ? 'disabled' : 'active'; if (u.state === state) throw new CliError('refused: account already has that state', 2); u.state = state; }
|
||||
};
|
||||
const opts = { revision: parsed.revision, verb: parsed.verb, init: parsed.verb === 'init' };
|
||||
let result;
|
||||
if (parsed.verb === 'user.remove') {
|
||||
result = await withRegistry(ctx, registry => withAccounts(ctx, { ...opts, registry }, async doc => {
|
||||
const user = doc.users.find(u => u.id === parsed.id);
|
||||
if (!user) throw new CliError('refused: account not found', 2);
|
||||
// Even disabled accounts keep ownership. A real create reserves before
|
||||
// writing owner.json, so any missing owner must block removal too.
|
||||
for (const entry of Object.values((await readRegistryLocked(registry)).orgs)) {
|
||||
let owner;
|
||||
try { owner = readOwner(entry.root); }
|
||||
catch (e) { if (/not found/.test(e.message)) throw new CliError(`refused: org-create-in-progress: ${entry.id} has no owner metadata`, 3); throw e; }
|
||||
if (owns(user, owner, providers)) throw new CliError(`refused: user-owns-org: ${entry.id}`, 3);
|
||||
}
|
||||
mutate(doc);
|
||||
}), { write: false });
|
||||
} else result = await withAccounts(ctx, opts, mutate);
|
||||
io.stdout.write(`accounts ${parsed.verb.replace('.', ' ')}: ${added?.id ?? parsed.id ?? ''}, revision ${result.revision}\n`);
|
||||
} catch (e) {
|
||||
if (e instanceof CliError) throw e;
|
||||
const error = new CliError(`refused: ${e.code ? e.code + ': ' : ''}${e.message}${e.committed ? '; accounts write committed; durability or receipt step failed; inspect before retrying' : ''}`, 3);
|
||||
if (e.committed) error.committed = true;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import { hostsCommand, HOSTS_USAGE } from "./hosts.mjs";
|
||||
import { grantsCommand, GRANTS_USAGE } from "./grants.mjs";
|
||||
import { instancesCommand, orgInstancesCommand, ORG_INSTANCES_USAGE } from "./instances.mjs";
|
||||
import { accessCommand, accessContextFor } from "./access.mjs";
|
||||
import { accountsCommand, ACCOUNTS_USAGE } from "./accounts.mjs";
|
||||
import { createChannelCaller } from "../../plugins/src/channel.mjs";
|
||||
import { PluginError } from "../../plugins/src/kinds.mjs";
|
||||
import { CliError, CliExit } from "./errors.mjs";
|
||||
@@ -52,6 +53,7 @@ import { devCommand, DEV_USAGE, fixtureLaunchEnv, fixtureLauncherOptions } from
|
||||
import { runCommand, RUN_USAGE } from "./run-evidence.mjs";
|
||||
|
||||
export const USAGE = `usage:
|
||||
${ACCOUNTS_USAGE}
|
||||
${ORG_USAGE}
|
||||
${USAGE_USAGE}
|
||||
${LIMITS_USAGE}
|
||||
@@ -745,6 +747,7 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
|
||||
now: deps.now,
|
||||
});
|
||||
}
|
||||
if (verb === 'accounts') return accountsCommand(rest, io, { system: deps.system ?? loadSystem({ env: io.env }), configDir: deps.accountsConfigDir });
|
||||
if (verb === "limits") return limitsCommand(rest,io,p=>callFor(p,io,deps));
|
||||
if (verb === "usage") return usageCommand(rest, io, p => callFor(p, io, deps));
|
||||
if (verb === "plugins") return pluginsCommand(rest, io, (p) => callFor(p, io, deps));
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, chmodSync, writeFileSync, readFileSync, mkdirSync, existsSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { accountsCommand } from '../src/accounts.mjs';
|
||||
import { main } from '../src/cli.mjs';
|
||||
import { withRegistry, reserveOrg } from '../../organization/src/org-registry.mjs';
|
||||
const providers = [{ id: 'pocket', issuer: 'https://id.example', subjects: [{ sub: 'owner' }, { sub: 'other-owner' }] }, { id: 'second', issuer: 'https://second.example', subjects: [{ sub: 'owner' }] }];
|
||||
function setup(t) {
|
||||
const configDir = mkdtempSync(join(tmpdir(), 'a-')); chmodSync(configDir, 0o700); t.after(() => rmSync(configDir, { recursive: true, force: true }));
|
||||
const deps = { configDir, instance: 'test', system: { console: { sso: { providers } } } }; let output = '';
|
||||
const io = { env: {}, stdout: { write: s => output += s }, stderr: { write: () => {} } };
|
||||
return { configDir, deps, io, run: args => accountsCommand(args, io, deps), read: () => JSON.parse(readFileSync(join(configDir, 'accounts.json'))), output: () => output };
|
||||
}
|
||||
test('init makes exactly one operator from all configured subjects, then refuses rerun', async t => {
|
||||
const s = setup(t); await s.run(['init']); const d = s.read(); assert.equal(d.users.length, 1); assert.equal(d.users[0].instanceRole, 'operator'); assert.equal(d.users[0].subjects.length, 3); assert.match(d.users[0].id, /^u-[0-9a-f]{12}$/); assert.equal(d.revision, 1);
|
||||
await assert.rejects(s.run(['init']), /already exists/); await s.run(['list', '--json']); assert.match(s.output(), /authGen/);
|
||||
const r = JSON.parse(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8')); assert.equal(r.uid, process.getuid()); assert.equal(r.verb, 'init'); assert.ok(!JSON.stringify(r).includes('other-owner'));
|
||||
});
|
||||
test('CLI add, disable, enable, signout and remove; last operator guard and stale revisions', async t => {
|
||||
const s = setup(t); await s.run(['init']); const op = s.read().users[0].id;
|
||||
await s.run(['user', 'add', '--name', 'Pat', '--subject', 'pocket:pat:sub', '--revision', '1']); const id = s.read().users[1].id;
|
||||
for (const [verb, revision, generation] of [['disable', 2, 2], ['enable', 3, 3], ['signout', 4, 4]]) { await s.run(['user', verb, id, '--revision', String(revision)]); assert.equal(s.read().users[1].authGen, generation); }
|
||||
await assert.rejects(s.run(['user', 'disable', op, '--revision', '5']), /active operator/);
|
||||
await assert.rejects(s.run(['user', 'signout', id, '--revision', '1']), /revision-conflict/);
|
||||
await s.run(['user', 'remove', id, '--revision', '5']); assert.equal(s.read().users.length, 1); assert.equal(s.read().revision, 6);
|
||||
assert.equal(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8').trim().split('\n').length, 6);
|
||||
});
|
||||
test('host uid refusal happens before any lock, even on list or remove', async t => {
|
||||
const s = setup(t); for (const args of [['list'], ['init'], ['user', 'remove', 'u-000000000001', '--revision', '1']]) {
|
||||
await assert.rejects(accountsCommand(args, s.io, { ...s.deps, uid: process.getuid() + 1 }), /uid/);
|
||||
assert.equal(existsSync(join(s.configDir, 'accounts.lock')), false); assert.equal(existsSync(join(s.configDir, 'org-runtime.lock')), false);
|
||||
}
|
||||
});
|
||||
test('remove holds registry before accounts, refuses ownership of active or archived orgs, including a preceding create', async t => {
|
||||
const s = setup(t); await s.run(['init']); await s.run(['user', 'add', '--name', 'Pat', '--subject', 'pocket:pat', '--revision', '1']); const id = s.read().users[1].id;
|
||||
const root = join(s.configDir, 'acme'); mkdirSync(root, { mode: 0o700 });
|
||||
let release, entered; const ready = new Promise(r => entered = r), hold = new Promise(r => release = r);
|
||||
const create = withRegistry(s.deps, async registry => { entered(); await hold; registry.orgs.acme = { id: 'acme', root, state: 'archived', revision: 1, created: '2026-11-10T00:00:00Z' }; writeFileSync(join(root, 'owner.json'), JSON.stringify({ provider: 'pocket', iss: 'https://id.example', sub: 'pat' }), { mode: 0o600 }); });
|
||||
await ready; const removing = s.run(['user', 'remove', id, '--revision', '2']); release(); await create; await assert.rejects(removing, /user-owns-org/); assert.equal(s.read().revision, 2);
|
||||
});
|
||||
test('remove drops memberships atomically, and malformed ownership metadata refuses', async t => {
|
||||
const s = setup(t); await s.run(['init']); await s.run(['user', 'add', '--name', 'Pat', '--subject', 'pocket:pat', '--revision', '1']); const d = s.read(), id = d.users[1].id;
|
||||
d.memberships.push({ org: 'acme', incarnation: 'a'.repeat(32), user: id, role: 'member', rights: ['org.view'], scope: { projects: ['*'], instances: ['*'] }, rev: 1, by: d.users[0].id, added: '2026-11-10T00:00:00Z', via: 'invite:abc' }); writeFileSync(join(s.configDir, 'accounts.json'), JSON.stringify(d));
|
||||
await s.run(['user', 'remove', id, '--revision', '2']); assert.deepEqual(s.read().memberships, []);
|
||||
const root = join(s.configDir, 'broken'); mkdirSync(root, { mode: 0o700 });
|
||||
await withRegistry(s.deps, async registry => { registry.orgs.broken = { id: 'broken', root, state: 'active', revision: 1, created: 'now' }; writeFileSync(join(root, 'owner.json'), '{}', { mode: 0o600 }); });
|
||||
await assert.rejects(s.run(['user', 'remove', s.read().users[0].id, '--revision', '3']), /owner/);
|
||||
});
|
||||
test('mosaic main wires accounts without a bus; tester, agent and unexpected args refuse', async t => {
|
||||
const s = setup(t); await main(['accounts', 'init'], s.io, { system: s.deps.system, accountsConfigDir: s.configDir }); assert.equal(s.read().users.length, 1);
|
||||
for (const args of [['list', '--tester'], ['list', '--subject', 'pocket:x'], ['user', 'add', 'chosen-id', '--name', 'Pat', '--subject', 'pocket:x', '--revision', '1'], ['list', '--json', '--json']]) await assert.rejects(s.run(args));
|
||||
await assert.rejects(accountsCommand(['list'], { ...s.io, env: { MOSAIC_RUN_ID: 'run' } }, s.deps), /human shell/);
|
||||
});
|
||||
|
||||
|
||||
test('review P2-2: real reservation wins before owner.json exists, and removal refuses', async t => {
|
||||
const s = setup(t); await s.run(['init']); await s.run(['user', 'add', '--name', 'Pat', '--subject', 'pocket:pat', '--revision', '1']); const id = s.read().users[1].id;
|
||||
const dataRoot = join(s.configDir, 'data'); mkdirSync(dataRoot, { mode: 0o700 }); const ctx = { configDir: s.configDir, instance: 'test', dataRoot };
|
||||
const org = await reserveOrg(ctx, 'acme'); assert.equal(existsSync(join(org.root, 'owner.json')), false);
|
||||
await assert.rejects(s.run(['user', 'remove', id, '--revision', '2']), /org-create-in-progress/); assert.equal(s.read().users.length, 2); assert.equal(s.read().revision, 2);
|
||||
});
|
||||
|
||||
|
||||
test('host boundary and receipt use effective uid, not real uid (current design clarification)', async t => {
|
||||
const s = setup(t), realUid = process.getuid, effectiveUid = process.geteuid();
|
||||
process.getuid = () => effectiveUid + 1;
|
||||
try { await s.run(['init']); await s.run(['list']); const receipt = JSON.parse(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8')); assert.equal(receipt.uid, effectiveUid); }
|
||||
finally { process.getuid = realUid; }
|
||||
});
|
||||
@@ -7,8 +7,9 @@ stay as the old names: each module there re-exports the module of the same name
|
||||
here (`src/business.mjs` re-exports `src/organization-config.mjs`), and its
|
||||
`src/cli.mjs` runs this package's `src/cli.mjs`. Brief: `docs/plans/2026-10-04_slice-1.md`,
|
||||
row S1. Design: `agents/darkwing/work/slice1-data-model-2026-10-04.md` and its
|
||||
addenda A and B. The package reads files and refuses on any problem. It
|
||||
never writes, and it never opens a token file.
|
||||
addenda A and B. Configuration readers refuse on any problem. Runtime
|
||||
organization and accounts stores provide explicit locked writes; this package
|
||||
never opens a token file.
|
||||
|
||||
```sh
|
||||
scripts/mosaic org config validate mosaic-stack
|
||||
@@ -583,3 +584,44 @@ The optional `plugins.system-type` list enables installed system-type modules fo
|
||||
an organization. Missing means none. Unknown ids refuse validation. Changes
|
||||
are owner-only; project selections are separate bus settings and a selection
|
||||
whose type is disabled remains recorded as inactive.
|
||||
|
||||
## Instance accounts (Phase M2)
|
||||
|
||||
`src/accounts.mjs` validates `<configDir>/accounts.json` without a dependency or
|
||||
JSON schema engine, as `access.mjs` does. `checkAccounts(document, {providers})`
|
||||
and `loadAccounts(file, {providers})` return frozen documents. Providers are the
|
||||
configured `console.sso.providers` objects (including issuer). Reads refuse an
|
||||
unsafe config directory, unsafe file, unknown field or malformed row. Account
|
||||
ids may be organization humans. `accountsRepairs(document)` reports unknown
|
||||
roles and rights retained on read for rollback; these rows must grant nothing.
|
||||
M4 supplies the authorization evaluator; M2 alone grants no Console or bus access.
|
||||
|
||||
`withAccounts(ctx, {revision, verb, actor?, registry?, init?}, mutate)` takes
|
||||
`accounts.lock`, reloads, checks the expected file revision, awaits `mutate` on a
|
||||
copy, maintains `authGen` and membership `rev`, validates and atomically replaces
|
||||
0600 bytes. Init publishes with a no-clobber hard link; replacements use rename.
|
||||
Both fsync the config directory before the receipt is appended. `ctx` contains `configDir`, `instance`, and `providers`. Init requires
|
||||
revision 0 and an absent file. Other writes require an existing valid file. Writes
|
||||
refuse unknown rights/roles on new or changed rows. Unchanged rollback rows pass
|
||||
through exactly, so they cannot block account revocation or another row's repair. Role templates are never evaluated as authority by this store.
|
||||
|
||||
For a caller already holding accounts.lock,
|
||||
`withAccountsLock(ctx, handle => withAccountsLocked(handle, options, mutate))`
|
||||
reuses that live opaque handle. Never acquire an earlier lock from the callback.
|
||||
The config path must have no symlink ancestors, including the home directory.
|
||||
The order is lifecycle, registry, accounts. Registry-dependent writes pass the
|
||||
live document from `withRegistry`; `readRegistryLocked(registry, ctx?)` verifies
|
||||
the lock and rereads its persisted snapshot without relocking. A changed registry
|
||||
or an instance mismatch refuses before account publication. Callbacks cannot
|
||||
reuse a released handle, or run two writes at once through one handle.
|
||||
|
||||
Each committed write appends and fsyncs one receipt under the accounts lock in
|
||||
`accounts-receipts.jsonl`: effective OS uid, actor id, verb, old/new file revisions, changed
|
||||
account ids with changed field names, and before/after membership rights, scope
|
||||
and revisions. No account
|
||||
name, subject, invite binding, hash, raw token or cookie enters the receipt. The
|
||||
store preflights the receipt destination before replacing accounts. As with
|
||||
other two-file journals, a crash or IO failure between replacement and receipt
|
||||
append can leave committed state without its receipt; an observed append failure
|
||||
has `committed: true` and must be inspected before retrying. It never rolls back
|
||||
committed authority silently or fabricates a success receipt.
|
||||
@@ -0,0 +1,266 @@
|
||||
// Phase M2 (#1873): instance accounts, independent of Console sessions and bus authority.
|
||||
import { constants, lstatSync } from 'node:fs';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import { dirname, join, parse, resolve } from 'node:path';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { OrgError, readRegistryLocked } from './org-registry.mjs';
|
||||
import { readJsonFile, requireObject, rejectUnknownKeys, requireString, requirePositiveInt, requireId, requireDistinctList, deepFreeze, canonicalJson } from './util.mjs';
|
||||
import { RIGHTS, ROLE_RIGHTS, normalizeRights } from './org-rights.mjs';
|
||||
|
||||
const fail = (code, message = code) => { throw new OrgError(code, message); };
|
||||
const bad = message => fail('accounts-invalid', message);
|
||||
const USER_ID = /^u-[0-9a-f]{12}$/;
|
||||
const HEX32 = /^[0-9a-f]{32}$/;
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
const same = (a, b) => canonicalJson(a) === canonicalJson(b);
|
||||
const array = (x, at) => { if (!Array.isArray(x)) bad(`${at} must be an array`); return x; };
|
||||
const text = (x, at) => {
|
||||
requireString(x, at, { max: 255 });
|
||||
if (/[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/.test(x)) bad(`${at} must not contain control or bidi formatting characters`);
|
||||
return x;
|
||||
};
|
||||
const keys = (x, allowed, at) => { requireObject(x, at); rejectUnknownKeys(x, allowed, at); };
|
||||
const accountId = (x, at) => { if (typeof x !== 'string' || !USER_ID.test(x)) bad(`${at} must be u- plus 12 lowercase hex characters`); };
|
||||
function time(x, at) {
|
||||
if (typeof x !== 'string' || !/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d{1,3})?Z$/.test(x) || !Number.isFinite(Date.parse(x)) || new Date(x).toISOString().slice(0, 19) !== x.slice(0, 19)) bad(`${at} must be a real ISO time`);
|
||||
}
|
||||
function providerMap(context) {
|
||||
const providers = new Map();
|
||||
for (const p of array(context?.providers, 'accounts context.providers')) {
|
||||
text(p.id, 'provider.id');
|
||||
if (p.id === 'local-dev' || typeof p.issuer !== 'string' || !p.issuer.startsWith('https://')) bad('provider must have an https issuer and must not be local-dev');
|
||||
try { if (new URL(p.issuer).protocol !== 'https:') bad('invalid issuer'); } catch { bad('invalid issuer'); }
|
||||
if (providers.has(p.id)) bad('duplicate configured provider');
|
||||
providers.set(p.id, p);
|
||||
}
|
||||
return providers;
|
||||
}
|
||||
function subject(s, at, providers) {
|
||||
keys(s, ['provider', 'sub'], at);
|
||||
if (s.provider === 'local-dev' || !providers.has(s.provider)) bad(`${at}.provider must be a configured provider, never local-dev`);
|
||||
text(s.sub, `${at}.sub`);
|
||||
}
|
||||
function scope(s, at) {
|
||||
keys(s, ['projects', 'instances', 'expires'], at);
|
||||
for (const key of ['projects', 'instances']) {
|
||||
const entries = requireDistinctList(s[key], `${at}.${key}`, x => {
|
||||
if (x === '*') return;
|
||||
if (key === 'projects') { if (typeof x !== 'string' || !UUID.test(x)) bad(`${at}.projects must contain UUIDs`); }
|
||||
else requireId(x, `${at}.instances`);
|
||||
}, { nonEmpty: true });
|
||||
if (entries.includes('*') && entries.length !== 1) bad(`${at}.${key} must use "*" alone`);
|
||||
}
|
||||
if (s.expires !== undefined) time(s.expires, `${at}.expires`);
|
||||
}
|
||||
function rowRights(row, at) {
|
||||
requireId(row.role, `${at}.role`);
|
||||
if (row.role === 'owner') bad(`${at}.role is not grantable`);
|
||||
const rights = requireDistinctList(row.rights, `${at}.rights`, r => {
|
||||
text(r, `${at}.rights`);
|
||||
if (r === 'owner' || (Object.hasOwn(RIGHTS, r) && !RIGHTS[r].grantable)) bad(`${at}.rights is not grantable`);
|
||||
if (!/^[a-z][a-z0-9]*(?:\.[a-z][a-z0-9]*)*$/.test(r)) bad(`${at}.rights contains an invalid name`);
|
||||
}, { nonEmpty: true });
|
||||
if (!rights.includes('org.view') || !same(rights, [...rights].sort())) bad(`${at}.rights must be sorted and contain org.view`);
|
||||
scope(row.scope, `${at}.scope`);
|
||||
requireId(row.org, `${at}.org`);
|
||||
if (typeof row.incarnation !== 'string' || !HEX32.test(row.incarnation)) bad(`${at}.incarnation must be 32 lowercase hex characters`);
|
||||
}
|
||||
|
||||
// Pure whole-file validation. Unknown, well-formed rights/roles survive a rollback;
|
||||
// accountsRepairs exposes them without changing the persisted schema.
|
||||
export function checkAccounts(document, context) {
|
||||
const providers = providerMap(context);
|
||||
keys(document, ['accountsVersion', 'revision', 'users', 'memberships', 'invites'], 'accounts file');
|
||||
if (document.accountsVersion !== 1) bad('accountsVersion must be 1');
|
||||
requirePositiveInt(document.revision, 'revision');
|
||||
const users = new Set(), subjects = new Set(); let operators = 0;
|
||||
array(document.users, 'users').forEach((u, i) => {
|
||||
const at = `users[${i}]`;
|
||||
keys(u, ['id', 'subjects', 'name', 'instanceRole', 'state', 'authGen', 'created'], at);
|
||||
accountId(u.id, `${at}.id`); if (users.has(u.id)) bad('duplicate account id'); users.add(u.id);
|
||||
text(u.name, `${at}.name`);
|
||||
if (!['operator', 'user'].includes(u.instanceRole)) bad(`${at}.instanceRole must be operator or user`);
|
||||
if (!['active', 'disabled'].includes(u.state)) bad(`${at}.state must be active or disabled`);
|
||||
requirePositiveInt(u.authGen, `${at}.authGen`); time(u.created, `${at}.created`);
|
||||
if (u.instanceRole === 'operator' && u.state === 'active') operators++;
|
||||
array(u.subjects, `${at}.subjects`).forEach((s, j) => {
|
||||
subject(s, `${at}.subjects[${j}]`, providers); const key = JSON.stringify([s.provider, s.sub]);
|
||||
if (subjects.has(key)) bad('subject appears more than once in the file'); subjects.add(key);
|
||||
});
|
||||
if (!u.subjects.length) bad(`${at}.subjects must not be empty`);
|
||||
});
|
||||
if (!operators) bad('at least one active operator must exist');
|
||||
const memberships = new Set();
|
||||
array(document.memberships, 'memberships').forEach((m, i) => {
|
||||
const at = `memberships[${i}]`;
|
||||
keys(m, ['org', 'incarnation', 'user', 'role', 'rights', 'scope', 'rev', 'added', 'by', 'via'], at);
|
||||
rowRights(m, at); accountId(m.user, `${at}.user`); if (!users.has(m.user)) bad(`${at}.user must name an account`);
|
||||
accountId(m.by, `${at}.by`); text(m.via, `${at}.via`); time(m.added, `${at}.added`); requirePositiveInt(m.rev, `${at}.rev`);
|
||||
const key = JSON.stringify([m.org, m.incarnation, m.user]); if (memberships.has(key)) bad('duplicate membership'); memberships.add(key);
|
||||
});
|
||||
const invites = new Set(), hashes = new Set();
|
||||
array(document.invites, 'invites').forEach((invite, i) => {
|
||||
const at = `invites[${i}]`;
|
||||
keys(invite, ['id', 'org', 'incarnation', 'role', 'rights', 'scope', 'bind', 'tokenHash', 'by', 'created', 'expires', 'state', 'user', 'used'], at);
|
||||
rowRights(invite, at); text(invite.id, `${at}.id`); if (!UUID.test(invite.id)) bad(`${at}.id must be a UUID`);
|
||||
if (invites.has(invite.id)) bad('duplicate invite id'); invites.add(invite.id);
|
||||
if (typeof invite.tokenHash !== 'string' || !/^[0-9a-f]{64}$/.test(invite.tokenHash) || hashes.has(invite.tokenHash)) bad(`${at}.tokenHash must be a unique sha256 hex hash`); hashes.add(invite.tokenHash);
|
||||
accountId(invite.by, `${at}.by`); time(invite.created, `${at}.created`); time(invite.expires, `${at}.expires`);
|
||||
if (Date.parse(invite.expires) <= Date.parse(invite.created)) bad(`${at}.expires must follow created`);
|
||||
const ttl = Date.parse(invite.expires) - Date.parse(invite.created);
|
||||
if (ttl < 3600000 || ttl > 168 * 3600000) bad(`${at} validity must be between 1 and 168 hours`);
|
||||
if (!['open', 'used', 'revoked', 'declined', 'expired'].includes(invite.state)) bad(`${at}.state is invalid`);
|
||||
requireObject(invite.bind, `${at}.bind`);
|
||||
if (Object.hasOwn(invite.bind, 'email')) { keys(invite.bind, ['email'], `${at}.bind`); text(invite.bind.email, `${at}.bind.email`); if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(invite.bind.email)) bad(`${at}.bind.email must be one address`); }
|
||||
else subject(invite.bind, `${at}.bind`, providers);
|
||||
if (invite.state === 'used') { accountId(invite.user, `${at}.user`); time(invite.used, `${at}.used`); }
|
||||
else if (invite.user !== undefined || invite.used !== undefined) bad(`${at} unused invite has usage fields`);
|
||||
});
|
||||
return deepFreeze(structuredClone(document));
|
||||
}
|
||||
export function accountsRepairs(doc) {
|
||||
const result = [];
|
||||
for (const [kind, rows] of [['membership', doc.memberships], ['invite', doc.invites]]) for (const row of rows) {
|
||||
const unknownRole = Object.hasOwn(ROLE_RIGHTS, row.role) ? undefined : row.role;
|
||||
const unknownRights = row.rights.filter(r => !Object.hasOwn(RIGHTS, r));
|
||||
if (unknownRole || unknownRights.length) result.push({ kind, org: row.org, ...(kind === 'membership' ? { user: row.user } : { id: row.id }), ...(unknownRole ? { unknownRole } : {}), unknownRights });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Check the host boundary before any read or lock. Every ancestor must be a real
|
||||
// directory; configDir itself belongs to this uid and is not publicly writable.
|
||||
export function assertAccountsOwner(configDir, { uid = process.geteuid() } = {}) {
|
||||
if (typeof configDir !== 'string' || resolve(configDir) !== configDir) fail('accounts-unsafe', 'config directory must be an absolute normalized path');
|
||||
let current = parse(configDir).root;
|
||||
for (const part of configDir.slice(current.length).split('/').filter(Boolean)) {
|
||||
current = join(current, part); const st = lstatSync(current);
|
||||
if (!st.isDirectory() || st.isSymbolicLink()) fail('accounts-unsafe', 'config path contains a symlink or non-directory');
|
||||
}
|
||||
const st = lstatSync(configDir);
|
||||
if (st.uid !== uid) fail('accounts-unsafe', `config directory must belong to uid ${uid}: ${configDir}`);
|
||||
if (st.mode & 0o022) fail('accounts-unsafe', 'config directory must not be writable by group or other');
|
||||
}
|
||||
export function loadAccounts(file, context, { uid = process.geteuid() } = {}) {
|
||||
assertAccountsOwner(dirname(file), { uid });
|
||||
const doc = readJsonFile(file, 'accounts file', st => {
|
||||
if (st.uid !== uid) fail('accounts-unsafe', `accounts file must belong to uid ${uid}: ${file}`);
|
||||
if (st.mode & 0o022) fail('accounts-unsafe', 'accounts file must not be writable by group or other');
|
||||
});
|
||||
return checkAccounts(doc, context);
|
||||
}
|
||||
export const mintAccountId = () => `u-${randomBytes(6).toString('hex')}`;
|
||||
const heldAccounts = new WeakMap();
|
||||
|
||||
// Low-level helper for callers already holding the earlier lifecycle/registry
|
||||
// locks. A handle is valid only within this callback and serializes all writes.
|
||||
export async function withAccountsLock(ctx, fn) {
|
||||
assertAccountsOwner(ctx.configDir);
|
||||
const lock = join(ctx.configDir, 'accounts.lock'); let fd;
|
||||
for (let n = 0; n < 200; n++) {
|
||||
try { fd = await fs.open(lock, 'wx', 0o600); break; }
|
||||
catch (e) { if (e.code !== 'EEXIST') throw e; await new Promise(r => setTimeout(r, 10)); }
|
||||
}
|
||||
if (!fd) fail('accounts-locked', 'accounts lock is held; inspect it before retrying');
|
||||
const handle = Object.freeze({}); heldAccounts.set(handle, { ctx, writing: false });
|
||||
try { return await fn(handle); }
|
||||
finally { heldAccounts.delete(handle); await fd.close(); await fs.unlink(lock); }
|
||||
}
|
||||
export async function withAccounts(ctx, options, fn) {
|
||||
if (options.registry) await readRegistryLocked(options.registry, ctx);
|
||||
return withAccountsLock(ctx, handle => withAccountsLocked(handle, options, fn));
|
||||
}
|
||||
const VERBS = new Set(['init', 'user.add', 'user.disable', 'user.enable', 'user.signout', 'user.remove', 'user.update', 'membership.add', 'membership.rights', 'membership.remove', 'membership.leave', 'invite.create', 'invite.accept', 'invite.decline', 'invite.revoke', 'org.delete', 'orphans.sweep']);
|
||||
function generations(next, before, verb) {
|
||||
for (const u of next.users) {
|
||||
const old = before?.users.find(x => x.id === u.id);
|
||||
if (!old) { u.authGen = 1; continue; }
|
||||
const changed = u.state !== old.state || u.instanceRole !== old.instanceRole || !same(u.subjects, old.subjects);
|
||||
if (changed) u.authGen = old.authGen + 1;
|
||||
else if (u.authGen !== old.authGen && !(verb === 'user.signout' && u.authGen === old.authGen + 1)) bad('authGen may only advance one generation for signout or an authentication change');
|
||||
if (u.created !== old.created) bad('account created time is immutable');
|
||||
}
|
||||
for (const m of next.memberships) {
|
||||
const old = before?.memberships.find(x => x.org === m.org && x.incarnation === m.incarnation && x.user === m.user);
|
||||
// A rollback row grants nothing, but must not block unrelated account
|
||||
// revocation or the repair of another row. Preserve unchanged rows exactly.
|
||||
if (old && same(m, old)) continue;
|
||||
if (!Object.hasOwn(ROLE_RIGHTS, m.role) || m.role === 'owner') fail('unknown-role', 'unknown or ungrantable membership role');
|
||||
m.rights = [...normalizeRights(m.rights)];
|
||||
m.rev = old ? old.rev + (!same(old.rights, m.rights) || !same(old.scope, m.scope) ? 1 : 0) : 1;
|
||||
}
|
||||
for (const i of next.invites) {
|
||||
const old = before?.invites.find(x => x.id === i.id);
|
||||
if (old && same(i, old)) continue;
|
||||
if (!Object.hasOwn(ROLE_RIGHTS, i.role) || i.role === 'owner') fail('unknown-role', 'unknown or ungrantable invite role');
|
||||
i.rights = [...normalizeRights(i.rights)];
|
||||
}
|
||||
}
|
||||
// Receipt construction has no free-form text: no names, subjects, bindings,
|
||||
// tokens, hashes, caller-supplied objects, or cookies can enter the log.
|
||||
function receipt(before, after, { verb, actor }, at) {
|
||||
if (!VERBS.has(verb)) bad('unsupported receipt verb');
|
||||
if (actor !== undefined && actor !== 'human') accountId(actor, 'receipt actor');
|
||||
const membership = m => ({ org: m.org, incarnation: m.incarnation, user: m.user, role: Object.hasOwn(ROLE_RIGHTS, m.role) ? m.role : 'unknown', rights: m.rights, scope: m.scope, rev: m.rev });
|
||||
return { at, uid: process.geteuid(), actor: actor ?? 'human', verb, revisionBefore: before?.revision ?? 0, revisionAfter: after.revision,
|
||||
users: after.users.filter(u => !before || !same(u, before.users.find(x => x.id === u.id))).map(u => u.id),
|
||||
userChanges: after.users.flatMap(u => {
|
||||
const old = before?.users.find(x => x.id === u.id);
|
||||
const changed = ['subjects', 'name', 'instanceRole', 'state', 'authGen', 'created'].filter(key => !old || !same(u[key], old[key]));
|
||||
return changed.length ? [{ id: u.id, changed }] : [];
|
||||
}),
|
||||
removedUsers: (before?.users ?? []).filter(u => !after.users.some(x => x.id === u.id)).map(u => u.id),
|
||||
memberships: { before: (before?.memberships ?? []).map(membership), after: after.memberships.map(membership) } };
|
||||
}
|
||||
async function syncDirectory(dir) {
|
||||
const fd = await fs.open(dir, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
||||
try { await fd.sync(); } finally { await fd.close(); }
|
||||
}
|
||||
|
||||
// Re-read under the lock, compare expected revision, validate and atomically
|
||||
// replace 0600 bytes, then append and fsync exactly one receipt while still held.
|
||||
// A post-rename receipt failure reports committed=true: retrying is not safe.
|
||||
export async function withAccountsLocked(handle, options, fn) {
|
||||
const held = heldAccounts.get(handle);
|
||||
if (!held || held.writing) fail('accounts-locked', 'a live, unused accounts lock handle is required');
|
||||
held.writing = true;
|
||||
const { ctx } = held; const file = join(ctx.configDir, 'accounts.json');
|
||||
let temp, receiptFd, committed = false;
|
||||
try {
|
||||
if (options.registry) await readRegistryLocked(options.registry, ctx);
|
||||
assertAccountsOwner(ctx.configDir);
|
||||
let before;
|
||||
if (options.init) {
|
||||
try { await fs.lstat(file); fail('accounts-exists', 'accounts.json already exists'); } catch (e) { if (e.code !== 'ENOENT') throw e; }
|
||||
if (options.revision !== 0) fail('revision-conflict');
|
||||
} else before = loadAccounts(file, ctx);
|
||||
if (!Number.isSafeInteger(options.revision) || options.revision !== (before?.revision ?? 0)) fail('revision-conflict', `revision-conflict: accounts revision is ${before?.revision ?? 0}`);
|
||||
const next = before ? structuredClone(before) : { accountsVersion: 1, revision: 0, users: [], memberships: [], invites: [] };
|
||||
await fn(next);
|
||||
generations(next, before, options.verb); next.revision = (before?.revision ?? 0) + 1;
|
||||
const checked = checkAccounts(next, ctx);
|
||||
const line = receipt(before, checked, options, new Date().toISOString());
|
||||
// Refuse unsafe receipt destinations before publishing the account change.
|
||||
receiptFd = await fs.open(join(ctx.configDir, 'accounts-receipts.jsonl'), constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o600);
|
||||
const st = await receiptFd.stat();
|
||||
if (!st.isFile() || st.uid !== process.geteuid() || (st.mode & 0o022) || st.nlink !== 1) fail('accounts-unsafe', 'unsafe accounts receipt file');
|
||||
temp = join(ctx.configDir, `.accounts-${randomBytes(12).toString('hex')}`);
|
||||
const fd = await fs.open(temp, 'wx', 0o600);
|
||||
try { await fd.writeFile(JSON.stringify(checked, null, 2) + '\n'); await fd.sync(); } finally { await fd.close(); }
|
||||
await options.beforeReplace?.();
|
||||
if (options.registry) await readRegistryLocked(options.registry, ctx);
|
||||
// Defend against a non-cooperating writer even though cooperative writers
|
||||
// already serialize through accounts.lock.
|
||||
if (before) { if (!same(loadAccounts(file, ctx), before)) fail('revision-conflict', 'accounts changed before replace'); }
|
||||
// Init must never replace a target, including one created by a writer that
|
||||
// ignores accounts.lock. link publishes the complete inode or fails EEXIST.
|
||||
if (before) await fs.rename(temp, file);
|
||||
else await fs.link(temp, file);
|
||||
committed = true;
|
||||
if (!before) await fs.unlink(temp);
|
||||
await (options.syncDirectory ?? syncDirectory)(ctx.configDir);
|
||||
await receiptFd.writeFile(JSON.stringify(line) + '\n'); await receiptFd.sync();
|
||||
return checked;
|
||||
} catch (e) { if (committed) e.committed = true; throw e; }
|
||||
finally { if (temp) await fs.rm(temp, { force: true }); if (receiptFd) await receiptFd.close(); held.writing = false; }
|
||||
}
|
||||
@@ -41,34 +41,6 @@ function noBoundKeys(entry, at) {
|
||||
}
|
||||
}
|
||||
|
||||
// A var value and every leaf it holds, as a var check's message quotes them (JSON.stringify).
|
||||
function quoted(value, out = []) {
|
||||
if (Array.isArray(value)) value.forEach((v) => quoted(v, out));
|
||||
else if (isPlainObject(value)) Object.values(value).forEach((v) => quoted(v, out));
|
||||
if (value !== undefined) out.push(JSON.stringify(value));
|
||||
return out;
|
||||
}
|
||||
|
||||
// checkVars, but a refusal never carries a value. Var checks quote the value they refuse, a value
|
||||
// can be a secret pasted in the wrong place, and these refusals reach the Console. Each key is
|
||||
// rechecked alone to name the one at fault; a message that quotes its value is replaced by one
|
||||
// that names the key only. A rule across keys (the fallback set) names keys only already.
|
||||
function checkVarsNamingKeys(vars, layer, where) {
|
||||
try { return checkVars(vars, layer, where); }
|
||||
catch (error) {
|
||||
if (error?.name !== "BusinessError" || !isPlainObject(vars)) throw error;
|
||||
for (const [key, value] of Object.entries(vars)) {
|
||||
try { checkVars({ [key]: value }, layer, where); }
|
||||
catch (one) {
|
||||
if (one?.name !== "BusinessError") throw one;
|
||||
if (quoted(value).some((text) => one.message.includes(text))) refuse(`${where}: variable ${key} has a value it can't take (the value is not shown)`);
|
||||
throw one;
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// The root file's document. `where` names it in messages, never with a host path.
|
||||
export function checkRootAgents(document, { rolesDir, where = "root agents" } = {}) {
|
||||
requireObject(document, where);
|
||||
@@ -94,7 +66,7 @@ export function checkRootAgents(document, { rolesDir, where = "root agents" } =
|
||||
agents[name] = {
|
||||
definition: entry.definition,
|
||||
title: definition.title,
|
||||
vars: checkVarsNamingKeys(entry.vars ?? {}, "agent", `${at}.vars`),
|
||||
vars: checkVars(entry.vars ?? {}, "agent", `${at}.vars`),
|
||||
excludeOrgs: entry.excludeOrgs === undefined ? [] : requireDistinctList(entry.excludeOrgs, `${at}.excludeOrgs`, (id) => requireId(id, `${at}.excludeOrgs entry`)),
|
||||
};
|
||||
}
|
||||
@@ -149,7 +121,7 @@ export function checkProjectAgents(agents, { rolesDir, where }) {
|
||||
if (Object.hasOwn(entry, "definition")) {
|
||||
const definition = definitionOf(entry.definition, at, rolesDir, cache);
|
||||
if (entry.definition === CHIEF || entry.definition === ASSISTANT) refuse(`${at}: ${entry.definition} can't be a project agent`);
|
||||
out[name] = { form: "define", definition: entry.definition, title: definition.title, vars: checkVarsNamingKeys(entry.vars ?? {}, "agent", `${at}.vars`) };
|
||||
out[name] = { form: "define", definition: entry.definition, title: definition.title, vars: checkVars(entry.vars ?? {}, "agent", `${at}.vars`) };
|
||||
continue;
|
||||
}
|
||||
if (!isPlainObject(entry.vars) || Object.keys(entry.vars).length === 0) refuse(`${at}: an entry defines (definition), narrows (vars) or excludes (exclude)`);
|
||||
@@ -157,7 +129,7 @@ export function checkProjectAgents(agents, { rolesDir, where }) {
|
||||
for (const key of Object.keys(entry.vars)) {
|
||||
if (!INTERSECT.includes(key)) refuse(`${at}.vars: a project can only narrow ${INTERSECT.join(", ")}, not ${JSON.stringify(key)}`);
|
||||
}
|
||||
out[name] = { form: "narrow", vars: checkVarsNamingKeys(entry.vars, "project", `${at}.vars`) };
|
||||
out[name] = { form: "narrow", vars: checkVars(entry.vars, "project", `${at}.vars`) };
|
||||
}
|
||||
return deepFreeze(out);
|
||||
}
|
||||
|
||||
@@ -128,7 +128,7 @@ export function checkRoutes({ proposed, old = null, levels, catalogue = null })
|
||||
for (const [harness, model, thinking] of ROUTES) {
|
||||
// A missing model refuses no-route at launch, not here.
|
||||
if (proposed.launch && Object.hasOwn(vars, model) && !Object.hasOwn(proposed.launch.models, vars[model])) {
|
||||
throw new OrganizationError("unknown-model", `${instance} ${model} ${JSON.stringify(vars[model] ?? null)} isn't a launch.models key`);
|
||||
throw new OrganizationError("unknown-model", `${instance} variable ${model} isn't a launch.models key`);
|
||||
}
|
||||
const currentEntry = proposed.launch?.models?.[vars[model]];
|
||||
const previousEntry = old?.launch?.models?.[before[model]];
|
||||
@@ -149,12 +149,12 @@ export function checkRoutes({ proposed, old = null, levels, catalogue = null })
|
||||
}
|
||||
if (!Object.hasOwn(vars, harness) || (vars[harness] === before[harness] && vars[thinking] === before[thinking])) continue;
|
||||
const name = vars[harness];
|
||||
if (!on.includes(name)) throw new OrganizationError("harness-off", `${instance} ${harness} ${name} isn't switched on in plugins.harness`);
|
||||
if (!on.includes(name)) throw new OrganizationError("harness-off", `${instance} variable ${harness} isn't switched on in plugins.harness`);
|
||||
if (vars[thinking] === undefined) continue;
|
||||
const applies = levels(name);
|
||||
if (!Array.isArray(applies)) throw new OrganizationError("harness-refused", `harness module ${name} didn't load, so its thinking levels are unknown`);
|
||||
if (!Array.isArray(applies)) throw new OrganizationError("harness-refused", `${instance} variable ${harness}: its harness module didn't load, so its thinking levels are unknown`);
|
||||
if (!applies.includes(vars[thinking])) {
|
||||
throw new OrganizationError("thinking-unsupported", `harness ${name} doesn't apply thinking ${vars[thinking]} (${instance} ${thinking}); it applies ${applies.join(", ")}`);
|
||||
throw new OrganizationError("thinking-unsupported", `${instance} variable ${thinking}: its harness doesn't apply this thinking level; it applies ${applies.join(", ")}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -374,7 +374,7 @@ export function launchSettings({ dir, rolesDir, system, sessions = () => [], now
|
||||
const entry = offered ? { provider: offered.provider, model: offered.model, module: offered.module, upstream: offered.upstream } : null;
|
||||
if (!existing && !offered && catalogue?.reason === 'model-key-collision') throw new OrganizationError('model-key-collision');
|
||||
if (!existing && !offered && catalogue?.status === 'unavailable') throw new OrganizationError('model-catalogue-unavailable');
|
||||
if (!existing && !offered) throw new OrganizationError('unknown-model', `${instance} model isn't a launch.models key`);
|
||||
if (!existing && !offered) throw new OrganizationError('unknown-model', `${instance} variable ${prefix}model isn't a launch.models key`);
|
||||
if (existing && offered && canonicalJson(existing) !== canonicalJson(entry) && !unchanged) throw new OrganizationError('model-key-collision');
|
||||
if (!existing || existing.module) {
|
||||
if (unchanged) continue;
|
||||
|
||||
@@ -36,14 +36,29 @@ export async function readRegistry({ configDir }) {
|
||||
return validateRegistry(doc, file);
|
||||
}
|
||||
export async function listOrgs(ctx) { const doc = await readRegistry(ctx); return doc ? Object.values(doc.orgs) : []; }
|
||||
const heldRegistries = new WeakMap();
|
||||
// Only a live withRegistry document may be reused by a later accounts-lock
|
||||
// holder. Recheck the persisted snapshot without reacquiring this lock.
|
||||
export async function readRegistryLocked(registry, ctx) {
|
||||
const held = heldRegistries.get(registry);
|
||||
if (!held) fail('org-locked', 'a live registry lock document is required');
|
||||
if (ctx && (ctx.configDir !== held.ctx.configDir || ctx.instance !== held.ctx.instance)) fail('org-mismatch', 'registry lock belongs to another instance');
|
||||
const current = await readRegistry(held.ctx);
|
||||
if (JSON.stringify(current) !== held.persisted) fail('registry-changed', 'registry-changed before accounts replace');
|
||||
return validateRegistry(registry, join(held.ctx.configDir, 'org-runtime.json'));
|
||||
}
|
||||
export async function withRegistry(ctx, fn, { write = true } = {}) {
|
||||
await safeDir(ctx.configDir); await privateDir(ctx.configDir); const lock = join(ctx.configDir, 'org-runtime.lock'); let fd;
|
||||
for (let n = 0; n < 200; n++) { try { fd = await fs.open(lock, 'wx', 0o600); break; } catch (e) { if (e.code !== 'EEXIST') throw e; await new Promise(r => setTimeout(r, 10)); } }
|
||||
if (!fd) fail('org-locked', 'registry lock is held', lock);
|
||||
try {
|
||||
const registry = await readRegistry(ctx) ?? { version: 1, instance: ctx.instance, orgs: {} };
|
||||
const persisted = await readRegistry(ctx);
|
||||
const registry = persisted ?? { version: 1, instance: ctx.instance, orgs: {} };
|
||||
if (registry.instance !== ctx.instance) fail('org-mismatch', 'registry instance mismatch');
|
||||
const result = await fn(registry);
|
||||
heldRegistries.set(registry, { ctx, persisted: JSON.stringify(persisted) });
|
||||
let result;
|
||||
try { result = await fn(registry); }
|
||||
finally { heldRegistries.delete(registry); }
|
||||
if (write) {
|
||||
const file = join(ctx.configDir, 'org-runtime.json'); const temp = join(ctx.configDir, `.org-runtime-${randomBytes(12).toString('hex')}`);
|
||||
try { await fs.writeFile(temp, JSON.stringify(registry, null, 2) + '\n', { mode: 0o600, flag: 'wx' }); await fs.rename(temp, file); } finally { await fs.rm(temp, { force: true }); }
|
||||
|
||||
@@ -205,7 +205,7 @@ function checkLaunch(launch, roles, definitions, file) {
|
||||
for (const name of Object.keys(limits.tokens?.daily ?? {})) {
|
||||
if (name !== account?.name) refuse(`${where}.limits.tokens.daily names ${name}, which isn't ${where}.account.name`);
|
||||
}
|
||||
for(const [instance,role] of Object.entries(roles))if(role.vars.model!==undefined&&!Object.hasOwn(models,role.vars.model))refuse(`${where}: instance ${instance} selects unknown model ${role.vars.model}`);
|
||||
for(const [instance,role] of Object.entries(roles))if(role.vars.model!==undefined&&!Object.hasOwn(models,role.vars.model))refuse(`${where}: instance ${instance} variable model selects an unknown model`);
|
||||
// No upper bound: limits have no ceiling in code (Q150).
|
||||
const threadIdleSeconds = launch.threadIdleSeconds === undefined ? THREAD_IDLE_SECONDS : requirePositiveInt(launch.threadIdleSeconds, `${where}.threadIdleSeconds`);
|
||||
// Row 194 (#1692, D2.4, D2.5): the human's per-agent forbid. keep names declared instances the
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
import { refuse } from "./errors.mjs";
|
||||
import { ACTIONS, NETWORKS, TOOLS } from "./vocabulary.mjs";
|
||||
import { isPlainObject, requireDistinctList, requirePositiveInt, requireString } from "./util.mjs";
|
||||
import { isPlainObject, requireString } from "./util.mjs";
|
||||
import { NAME as MODULE_NAME } from "../../plugins/src/kinds.mjs";
|
||||
|
||||
export const LAYERS = Object.freeze(["system", "business", "project", "agent"]);
|
||||
@@ -29,7 +29,7 @@ function string(max = 200) {
|
||||
|
||||
function oneOf(values) {
|
||||
return (v, key) => {
|
||||
if (!values.includes(v)) refuse(`variable ${key} must be one of: ${values.join(", ")} (got ${JSON.stringify(v)})`);
|
||||
if (!values.includes(v)) refuse(`variable ${key} must be one of: ${values.join(", ")}`);
|
||||
return v;
|
||||
};
|
||||
}
|
||||
@@ -37,13 +37,13 @@ function oneOf(values) {
|
||||
// A harness plug-in module's name, in the loader's pattern (row 65, REQ-PLUG-2). Whether the
|
||||
// module exists or is switched on is checked at launch and at a write, never at load.
|
||||
function moduleName(v, key) {
|
||||
if (typeof v !== "string" || !MODULE_NAME.test(v)) refuse(`variable ${key} must be a harness module name (got ${JSON.stringify(v)})`);
|
||||
if (typeof v !== "string" || !MODULE_NAME.test(v)) refuse(`variable ${key} must be a harness module name`);
|
||||
return v;
|
||||
}
|
||||
|
||||
function integer(min) {
|
||||
return (v, key) => {
|
||||
if (!Number.isSafeInteger(v) || v < min) refuse(`variable ${key} must be an integer of at least ${min} (got ${JSON.stringify(v)})`);
|
||||
if (!Number.isSafeInteger(v) || v < min) refuse(`variable ${key} must be an integer of at least ${min}`);
|
||||
return v;
|
||||
};
|
||||
}
|
||||
@@ -55,7 +55,7 @@ function baseUrl(v, key) {
|
||||
try {
|
||||
url = new URL(v);
|
||||
} catch {
|
||||
refuse(`variable ${key} must be an http or https URL (got ${JSON.stringify(v)})`);
|
||||
refuse(`variable ${key} must be an http or https URL`);
|
||||
}
|
||||
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash || v.endsWith("/")) {
|
||||
refuse(`variable ${key} must be an http or https base URL with no credentials, query, fragment or trailing slash`);
|
||||
@@ -70,21 +70,31 @@ function searxngUrl(v, key) {
|
||||
baseUrl(v, key);
|
||||
const url = new URL(v);
|
||||
const loopback = ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname);
|
||||
if (url.protocol === "http:" && !loopback) refuse(`variable ${key} must be https, or http on loopback (got ${JSON.stringify(v)})`);
|
||||
if (url.protocol === "http:" && !loopback) refuse(`variable ${key} must be https, or http on loopback`);
|
||||
return v;
|
||||
}
|
||||
|
||||
function branch(v, key) {
|
||||
if (typeof v !== "string" || !BRANCH.test(v)) refuse(`variable ${key} must be a git branch name (got ${JSON.stringify(v)})`);
|
||||
if (typeof v !== "string" || !BRANCH.test(v)) refuse(`variable ${key} must be a git branch name`);
|
||||
return v;
|
||||
}
|
||||
|
||||
function list(check, { nonEmpty = true } = {}) {
|
||||
return (v, key) => requireDistinctList(v, `variable ${key}`, (item) => check(item, `${key} entry`), { nonEmpty });
|
||||
return (v, key) => {
|
||||
if (!Array.isArray(v)) refuse(`variable ${key} must be an array`);
|
||||
if (nonEmpty && v.length === 0) refuse(`variable ${key} must not be empty`);
|
||||
const seen = new Set();
|
||||
for (const item of v) {
|
||||
check(item, `${key} entry`);
|
||||
if (seen.has(item)) refuse(`duplicate entry in variable ${key}`);
|
||||
seen.add(item);
|
||||
}
|
||||
return [...seen];
|
||||
};
|
||||
}
|
||||
|
||||
function boolean(v, key) {
|
||||
if (typeof v !== "boolean") refuse(`variable ${key} must be true or false (got ${JSON.stringify(v)})`);
|
||||
if (typeof v !== "boolean") refuse(`variable ${key} must be true or false`);
|
||||
return v;
|
||||
}
|
||||
|
||||
@@ -125,7 +135,10 @@ export const REGISTRY = Object.freeze({
|
||||
"bus.tester": def(["business"], boolean, { default: false }),
|
||||
|
||||
// Project.
|
||||
"tracker.project": def(["project"], (v, key) => requirePositiveInt(v, `variable ${key}`)),
|
||||
"tracker.project": def(["project"], (v, key) => {
|
||||
if (!Number.isSafeInteger(v) || v < 1) refuse(`variable ${key} must be a positive integer`);
|
||||
return v;
|
||||
}),
|
||||
// On: the tracker project is the root of a tree, and sync reads its descendants too.
|
||||
"tracker.descendants": def(["project"], boolean, { default: false }),
|
||||
"git.workingBranch": def(["project"], branch),
|
||||
@@ -161,16 +174,17 @@ export const REGISTRY = Object.freeze({
|
||||
// Limits narrow the role definition's ceilings. limits.authority is an
|
||||
// allowlist: a role action it doesn't name becomes gated.
|
||||
"limits.tools": def(["business", "project", "agent"], list((v, key) => {
|
||||
if (!TOOLS.includes(v)) refuse(`variable ${key} names an unsupported tool: ${JSON.stringify(v)}`);
|
||||
if (!TOOLS.includes(v)) refuse(`variable ${key} names an unsupported tool`);
|
||||
}, { nonEmpty: false }), { merge: "intersect" }),
|
||||
"limits.network": def(["business", "project", "agent"], oneOf(NETWORKS), { merge: "intersect" }),
|
||||
"limits.authority": def(["business", "project", "agent"], list((v, key) => {
|
||||
if (!ACTIONS.includes(v)) refuse(`variable ${key} names an unknown action: ${JSON.stringify(v)}`);
|
||||
if (!ACTIONS.includes(v)) refuse(`variable ${key} names an unknown action`);
|
||||
}, { nonEmpty: false }), { merge: "intersect" }),
|
||||
});
|
||||
|
||||
// Check one layer's vars object. `where` names the file and path for the
|
||||
// message. Returns a frozen copy with checked values.
|
||||
// message. Returns a frozen copy with checked values. Validators name the key
|
||||
// and the rule, never a value: refusals can reach the Console and the trail.
|
||||
export function checkVars(vars, layer, where) {
|
||||
if (!LAYERS.includes(layer)) throw new Error(`unknown layer ${layer}`);
|
||||
if (!isPlainObject(vars)) refuse(`${where} must be a JSON object`);
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, chmodSync, symlinkSync, unlinkSync, existsSync, readdirSync, rmSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { checkAccounts, loadAccounts, accountsRepairs, withAccounts, withAccountsLock, withAccountsLocked } from '../src/accounts.mjs';
|
||||
import { templateRender } from '../src/org-service.mjs';
|
||||
import { loadBusiness } from '../src/organization-config.mjs';
|
||||
import { REPO_ROLES } from './helpers.mjs';
|
||||
import { withRegistry, readRegistryLocked } from '../src/org-registry.mjs';
|
||||
|
||||
export const OP = 'u-000000000001', USER = 'u-000000000002';
|
||||
export const context = { providers: [{ id: 'pocket', issuer: 'https://id.example', subjects: [{ sub: 'owner' }] }] };
|
||||
export const user = (id = OP, sub = 'owner', instanceRole = 'operator') => ({ id, subjects: [{ provider: 'pocket', sub }], name: 'Person', instanceRole, state: 'active', authGen: 1, created: '2026-11-10T00:00:00Z' });
|
||||
export const member = () => ({ org: 'acme', incarnation: 'a'.repeat(32), user: USER, role: 'member', rights: ['board.read', 'org.view', 'thread.read'], scope: { projects: ['*'], instances: ['*'] }, rev: 1, added: '2026-11-10T00:00:00Z', by: OP, via: 'invite:abc' });
|
||||
export const document = () => ({ accountsVersion: 1, revision: 1, users: [user(), user(USER, 'member', 'user')], memberships: [], invites: [] });
|
||||
function setup(t, doc = document()) {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'a-')); chmodSync(dir, 0o700); t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||||
const file = join(dir, 'accounts.json'); writeFileSync(file, JSON.stringify(doc), { mode: 0o600 });
|
||||
return { configDir: dir, instance: 'test', ...context, file };
|
||||
}
|
||||
const write = (ctx, revision, fn, rest = {}) => withAccounts(ctx, { revision, verb: 'user.signout', ...rest }, fn);
|
||||
|
||||
test('loader refuses every malformed document as a whole, including nested unknown keys', t => {
|
||||
const cases = [d => d.accountsVersion = 2, d => d.revision = 0, d => d.cookie = 'secret', d => d.users[0].id = 'human', d => d.users[0].authGen = 0, d => d.users[0].authGen = 1.5, d => d.users[0].instanceRole = 'owner', d => d.users[0].state = 'disabled', d => d.users[0].subjects[0].provider = 'local-dev', d => d.users[0].subjects[0].provider = 'missing', d => d.users[1].subjects = d.users[0].subjects, d => d.users[0].subjects[0].iss = 'https://evil', d => d.users[0].created = '2026-02-30T00:00:00Z', d => d.memberships.push({}), d => { const m = member(); m.rights.push('org.setup'); d.memberships.push(m); }, d => { const m = member(); m.rights.reverse(); d.memberships.push(m); }, d => { const m = member(); m.scope.instances = ['*', 'coder']; d.memberships.push(m); }, d => { const m = member(); m.user = 'u-000000000003'; d.memberships.push(m); }];
|
||||
for (const mutate of cases) { const d = document(); mutate(d); assert.throws(() => checkAccounts(d, context), undefined, mutate.toString()); }
|
||||
assert.throws(() => checkAccounts(document(), { providers: [{ id: 'pocket', issuer: 'local-dev' }] }));
|
||||
const s = setup(t); assert.throws(() => loadAccounts(s.file, s, { uid: process.getuid() + 1 }), /uid/);
|
||||
for (const mode of [0o620, 0o602]) { chmodSync(s.file, mode); assert.throws(() => loadAccounts(s.file, s), /writable/); }
|
||||
chmodSync(s.file, 0o600); const target = join(s.configDir, 'real'); writeFileSync(target, JSON.stringify(document()), { mode: 0o600 }); unlinkSync(s.file); symlinkSync(target, s.file); assert.throws(() => loadAccounts(s.file, s), /symbolic/);
|
||||
});
|
||||
|
||||
test('unknown role and right load as repair rows; account may be a business human', () => {
|
||||
const d = document(); const m = member(); m.role = 'future'; m.rights.push('zz.future'); d.memberships.push(m);
|
||||
const loaded = checkAccounts(d, { ...context, businesses: { acme: { human: OP } } });
|
||||
assert.equal(loaded.users[0].id, OP); assert.ok(Object.isFrozen(loaded.users[0]));
|
||||
assert.deepEqual(accountsRepairs(loaded), [{ kind: 'membership', org: 'acme', user: USER, unknownRole: 'future', unknownRights: ['zz.future'] }]);
|
||||
});
|
||||
|
||||
test('writes refuse stale revision, unknown role/right and reserved rights without a receipt', async t => {
|
||||
const s = setup(t); const before = readFileSync(s.file, 'utf8');
|
||||
await assert.rejects(write(s, 9, () => {}), /revision-conflict/);
|
||||
for (const change of [m => m.role = 'future', m => m.rights.push('zz.future'), m => m.rights.push('org.setup')]) {
|
||||
await assert.rejects(write(s, 1, d => { const m = member(); change(m); d.memberships.push(m); }));
|
||||
}
|
||||
assert.equal(readFileSync(s.file, 'utf8'), before); assert.equal(existsSync(join(s.configDir, 'accounts-receipts.jsonl')), false);
|
||||
});
|
||||
|
||||
test('rights/scope revisions and security generations are maintained by the writer', async t => {
|
||||
const d = document(); d.memberships.push(member()); const s = setup(t, d);
|
||||
let rev = 1;
|
||||
for (const change of [u => u.state = 'disabled', u => u.state = 'active', u => u.instanceRole = 'operator', u => u.subjects[0].sub = 'new-sub']) {
|
||||
const result = await write(s, rev++, next => change(next.users[1])); assert.equal(result.users[1].authGen, rev);
|
||||
}
|
||||
let result = await write(s, rev++, next => next.users[1].authGen++); assert.equal(result.users[1].authGen, rev);
|
||||
result = await write(s, rev++, next => next.memberships[0].rights.push('task.write')); assert.equal(result.memberships[0].rev, 2);
|
||||
result = await write(s, rev++, next => next.memberships[0].scope.instances = ['coder']); assert.equal(result.memberships[0].rev, 3);
|
||||
assert.equal(result.users[1].authGen, 6);
|
||||
const lines = readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8').trim().split('\n').map(JSON.parse);
|
||||
assert.equal(lines.length, 7); for (const r of lines) { assert.equal(r.uid, process.getuid()); assert.equal(r.verb, 'user.signout'); }
|
||||
assert.equal(statSync(s.file).mode & 0o777, 0o600);
|
||||
});
|
||||
|
||||
test('concurrent writes serialize and the later stale write changes nothing', async t => {
|
||||
const s = setup(t); let release, entered; const ready = new Promise(r => entered = r); const wait = new Promise(r => release = r);
|
||||
const first = write(s, 1, async d => { entered(); await wait; d.users[1].authGen++; }); await ready;
|
||||
const second = write(s, 1, d => { d.users[1].authGen++; }); release();
|
||||
const results = await Promise.allSettled([first, second]); assert.equal(results[0].status, 'fulfilled'); assert.equal(results[1].status, 'rejected'); assert.match(results[1].reason.message, /revision-conflict/);
|
||||
assert.equal(loadAccounts(s.file, s).revision, 2);
|
||||
});
|
||||
|
||||
test('locked helpers require live handles; registry dependency is rechecked before replace', async t => {
|
||||
const s = setup(t); let held;
|
||||
await assert.rejects(readRegistryLocked({}), /lock/);
|
||||
await assert.rejects(withAccountsLocked({}, { revision: 1, verb: 'user.signout' }, () => {}), /lock/);
|
||||
await withRegistry(s, async registry => {
|
||||
assert.equal(await readRegistryLocked(registry), registry);
|
||||
await withAccountsLock(s, async handle => { held = handle; await withAccountsLocked(handle, { revision: 1, verb: 'user.signout', registry }, d => d.users[1].authGen++); });
|
||||
});
|
||||
await assert.rejects(withAccountsLocked(held, { revision: 2, verb: 'user.signout' }, () => {}), /lock/);
|
||||
const before = readFileSync(s.file, 'utf8');
|
||||
await withRegistry(s, async registry => {
|
||||
await assert.rejects(write(s, 2, d => { d.users[1].authGen++; writeFileSync(join(s.configDir, 'org-runtime.json'), JSON.stringify({ version: 1, instance: 'test', orgs: { changed: { id: 'changed', root: '/elsewhere', state: 'active', revision: 1, created: 'now' } } }), { mode: 0o600 }); }, { registry }), /registry-changed/);
|
||||
}, { write: false });
|
||||
assert.equal(readFileSync(s.file, 'utf8'), before);
|
||||
});
|
||||
|
||||
test('failure before replace leaves old bytes, no receipt line and no temporary file', async t => {
|
||||
const s = setup(t); const before = readFileSync(s.file, 'utf8');
|
||||
await assert.rejects(write(s, 1, d => d.users[1].authGen++, { beforeReplace: () => { throw new Error('injected failure'); } }), /injected failure/);
|
||||
assert.equal(readFileSync(s.file, 'utf8'), before); assert.deepEqual(readdirSync(s.configDir).filter(f => f.startsWith('.accounts-')), []); assert.equal(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8'), '');
|
||||
});
|
||||
|
||||
test('receipts never serialize subject secrets, tokens, cookies, names or arbitrary actor text', async t => {
|
||||
const s = setup(t); const secret = 'subject-secret';
|
||||
await write(s, 1, d => { d.users[1].subjects[0].sub = secret; d.users[1].name = 'cookie-secret'; }, { actor: OP });
|
||||
const raw = readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8'); assert.ok(!raw.includes(secret)); assert.ok(!raw.includes('cookie-secret'));
|
||||
await assert.rejects(write(s, 2, () => {}, { verb: 'token-secret' }));
|
||||
await assert.rejects(write(s, 2, () => {}, { actor: 'cookie-secret' }));
|
||||
});
|
||||
|
||||
test('malformed membership rows refuse all of the file; reserved rights never load', () => {
|
||||
const mutations = [m => m.role = 'owner', m => m.rev = 0, m => m.incarnation = 'old', m => m.rights = ['thread.read'], m => m.rights = ['org.view', 'org.view'], m => m.scope = { projects: [], instances: ['*'] }, m => m.scope.projects = ['not-a-uuid'], m => m.scope.expires = 'not-a-time', m => m.extra = true, m => m.by = 'human', m => m.via = '', m => m.added = 'bad'];
|
||||
for (const mutate of mutations) { const d = document(), m = member(); mutate(m); d.memberships.push(m); assert.throws(() => checkAccounts(d, context), undefined, mutate.toString()); }
|
||||
for (const right of ['members.manage', 'org.setup', 'org.lifecycle', 'request.write', 'owner']) { const d = document(), m = member(); m.rights = ['org.view', right].sort(); d.memberships.push(m); assert.throws(() => checkAccounts(d, context)); }
|
||||
});
|
||||
function invitation() {
|
||||
const { user: ignored, rev, added, via, ...row } = member();
|
||||
return { ...row, id: '11111111-2222-3333-4444-555555555555', bind: { email: '[email protected]' }, tokenHash: 'b'.repeat(64), created: '2026-11-10T00:00:00Z', expires: '2026-11-13T00:00:00Z', state: 'open' };
|
||||
}
|
||||
test('invite schema refuses raw secrets, domain/bearer bindings, malformed hashes and unknown fields', () => {
|
||||
const d = document(); d.invites.push(invitation()); assert.equal(checkAccounts(d, context).invites.length, 1);
|
||||
for (const mutate of [i => i.token = 'raw-token', i => i.cookie = 'raw-cookie', i => i.bind = { domain: 'example.com' }, i => i.bind = {}, i => i.bind = { email: '@example.com' }, i => i.bind.sub = 'extra-binding', i => i.tokenHash = 'raw-token', i => i.state = 'other', i => i.rights = ['org.setup', 'org.view'], i => i.scope.level = 'collaborator']) {
|
||||
const next = structuredClone(d); mutate(next.invites[0]); assert.throws(() => checkAccounts(next, context));
|
||||
}
|
||||
d.invites[0].role = 'future'; d.invites[0].rights.push('zz.future'); assert.equal(accountsRepairs(checkAccounts(d, context))[0].kind, 'invite');
|
||||
});
|
||||
test('unsafe receipt destination refuses before publication and leaves its target intact', async t => {
|
||||
const s = setup(t), target = join(s.configDir, 'target'); writeFileSync(target, 'untouched', { mode: 0o600 }); symlinkSync(target, join(s.configDir, 'accounts-receipts.jsonl'));
|
||||
const before = readFileSync(s.file, 'utf8'); await assert.rejects(write(s, 1, d => d.users[1].authGen++)); assert.equal(readFileSync(s.file, 'utf8'), before); assert.equal(readFileSync(target, 'utf8'), 'untouched');
|
||||
});
|
||||
test('account lock allows sequential writes and registry handles cannot cross instance boundaries', async t => {
|
||||
const s = setup(t), other = setup(t);
|
||||
await withAccountsLock(s, async handle => {
|
||||
await withAccountsLocked(handle, { revision: 1, verb: 'user.signout' }, d => d.users[1].authGen++);
|
||||
await withAccountsLocked(handle, { revision: 2, verb: 'user.signout' }, d => d.users[1].authGen++);
|
||||
});
|
||||
await withRegistry(other, async registry => { await assert.rejects(write(s, 3, () => {}, { registry }), /another instance/); });
|
||||
assert.equal(loadAccounts(s.file, s).revision, 3);
|
||||
});
|
||||
test('invite token hashes and subject bindings never reach receipts', async t => {
|
||||
const s = setup(t); const invite = invitation(); invite.bind = { provider: 'pocket', sub: 'subject-secret' };
|
||||
await write(s, 1, d => d.invites.push(invite), { verb: 'invite.create' });
|
||||
const raw = readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8'); assert.ok(!raw.includes(invite.tokenHash)); assert.ok(!raw.includes('subject-secret')); assert.equal(JSON.parse(raw).verb, 'invite.create');
|
||||
});
|
||||
|
||||
test('a failed rename preserves published bytes and appends no receipt', async t => {
|
||||
const s = setup(t), before = readFileSync(s.file, 'utf8');
|
||||
await assert.rejects(write(s, 1, d => d.users[1].authGen++, { beforeReplace: () => { const temp = readdirSync(s.configDir).find(f => f.startsWith('.accounts-')); unlinkSync(join(s.configDir, temp)); } }), { code: 'ENOENT' });
|
||||
assert.equal(readFileSync(s.file, 'utf8'), before); assert.equal(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8'), ''); assert.ok(!existsSync(join(s.configDir, 'accounts.lock')));
|
||||
});
|
||||
|
||||
|
||||
test('review P2-1: rollback rows survive unrelated disable/signout but cannot be added or edited', async t => {
|
||||
const d = document(), m = member(), invite = invitation(); m.role = 'future'; m.rights.push('zz.future'); invite.rights.push('zz.future'); d.memberships.push(m); d.invites.push(invite); const s = setup(t, d);
|
||||
const disabled = await write(s, 1, next => next.users[1].state = 'disabled', { verb: 'user.disable' });
|
||||
assert.deepEqual(disabled.memberships, d.memberships); assert.deepEqual(disabled.invites, d.invites);
|
||||
const signedOut = await write(s, 2, next => next.users[0].authGen++); assert.deepEqual(signedOut.memberships, d.memberships); assert.equal(signedOut.users[0].authGen, 2);
|
||||
await assert.rejects(write(s, 3, next => next.memberships[0].scope.instances = ['coder']), /unknown/);
|
||||
await assert.rejects(write(s, 3, next => next.invites[0].state = 'revoked'), /unknown/);
|
||||
const repaired = await write(s, 3, next => { next.memberships[0].role = 'member'; next.memberships[0].rights = ['org.view']; }); assert.equal(repaired.memberships[0].rev, 2); assert.deepEqual(repaired.invites, d.invites);
|
||||
});
|
||||
test('review P3-1: control and bidi characters refuse in names, subjects and provenance', () => {
|
||||
for (const control of ['\x1b', '\x07', '\r', '\n', '\x7f', '\u0085', '\u202e', '\u2066']) for (const field of ['name', 'sub', 'via']) {
|
||||
const d = document(); d.memberships.push(member()); if (field === 'via') d.memberships[0].via += control; else if (field === 'sub') d.users[1].subjects[0].sub += control; else d.users[1].name += control;
|
||||
assert.throws(() => checkAccounts(d, context));
|
||||
}
|
||||
});
|
||||
test('review P3-2: invite validity is between one and 168 hours inclusive', () => {
|
||||
for (const hours of [0.5, 169, 87600]) { const d = document(), i = invitation(); i.expires = new Date(Date.parse(i.created) + hours * 3600000).toISOString(); d.invites.push(i); assert.throws(() => checkAccounts(d, context)); }
|
||||
for (const hours of [1, 168]) { const d = document(), i = invitation(); i.expires = new Date(Date.parse(i.created) + hours * 3600000).toISOString(); d.invites.push(i); assert.equal(checkAccounts(d, context).invites.length, 1); }
|
||||
});
|
||||
test('review P3-3: receipts record actual changed field names even for a misleading verb', async t => {
|
||||
const s = setup(t); await write(s, 1, d => d.users[1].instanceRole = 'operator');
|
||||
const r = JSON.parse(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8')); assert.deepEqual(r.userChanges, [{ id: USER, changed: ['instanceRole', 'authGen'] }]);
|
||||
});
|
||||
test('review P3-4: directory durability failure reports committed state and appends no success receipt', async t => {
|
||||
const s = setup(t); let error;
|
||||
try { await write(s, 1, d => d.users[1].authGen++, { syncDirectory: async () => { throw new Error('injected directory sync failure'); } }); } catch (e) { error = e; }
|
||||
assert.match(error?.message ?? '', /injected directory sync failure/); assert.equal(error.committed, true); assert.equal(loadAccounts(s.file, s).revision, 2); assert.equal(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8'), '');
|
||||
});
|
||||
test('review P3-5: init cannot clobber a file created after its last absence check', async t => {
|
||||
const s = setup(t); unlinkSync(s.file); const intruder = 'concurrent file';
|
||||
await assert.rejects(withAccounts(s, { revision: 0, verb: 'init', init: true, beforeReplace: () => writeFileSync(s.file, intruder, { mode: 0o600 }) }, d => d.users.push(user())), { code: 'EEXIST' });
|
||||
assert.equal(readFileSync(s.file, 'utf8'), intruder); assert.equal(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8'), '');
|
||||
});
|
||||
test('review P3-6: a real business file and accounts file load with the same human id', async t => {
|
||||
const s = setup(t), doc = await templateRender('acme'); doc.human = OP; mkdirSync(join(s.configDir, 'businesses')); writeFileSync(join(s.configDir, 'businesses', 'acme.json'), JSON.stringify(doc), { mode: 0o600 });
|
||||
assert.equal(loadBusiness('acme', { dir: s.configDir, rolesDir: REPO_ROLES }).human, OP); assert.equal(loadAccounts(s.file, s).users[0].id, OP);
|
||||
});
|
||||
@@ -123,7 +123,7 @@ test("malformed root definitions refuse", () => {
|
||||
|
||||
test("a refused var names its key, never its value", () => {
|
||||
const secret = "sk-secret-marker-7731";
|
||||
const hides = (fn, key) => assert.throws(fn, (error) => error.name === "BusinessError" && error.message.includes(`variable ${key} has a value it can't take`) && !error.message.includes(secret));
|
||||
const hides = (fn, key) => assert.throws(fn, (error) => error.name === "BusinessError" && error.message.includes(`variable ${key}`) && !error.message.includes(secret));
|
||||
hides(() => root({ x: { definition: "reviewer", vars: { thinking: secret } } }), "thinking");
|
||||
hides(() => root({ x: { definition: "reviewer", vars: { model: "opus-5.5", harness: `${secret}!` } } }), "harness");
|
||||
hides(() => root({ x: { definition: "reviewer", vars: { "limits.tools": ["read", secret] } } }), "limits.tools");
|
||||
|
||||
@@ -251,7 +251,7 @@ test("names that are Object.prototype properties don't count as declared", () =>
|
||||
refuses(() => check((d) => { d.arbiters.delivery = "constructor"; }), /arbiters\.delivery names constructor/);
|
||||
refuses(() => check((d) => { d.launch.by = "constructor"; }), /launch\.by names constructor/);
|
||||
refuses(() => check((d) => { d.launch.instances = ["constructor"]; }), /instances names constructor/);
|
||||
refuses(() => check((d) => { d.roles.pm.vars.model = "constructor"; }), /unknown model constructor/);
|
||||
refuses(() => check((d) => { d.roles.pm.vars.model = "constructor"; }), /variable model selects an unknown model/);
|
||||
refuses(() => check((d) => { d.roles.pm.definition = "constructor"; }), /not found/, 4);
|
||||
const labels = check((d) => { d.tracker.labels = JSON.parse('{"__proto__": 5}'); }).tracker.labels;
|
||||
assert.equal(Object.getPrototypeOf(labels), Object.prototype);
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
BusinessError, checkVars, loadBusiness, loadProject, projectFilePath, systemVars,
|
||||
} from "../src/index.mjs";
|
||||
import { businessDoc, REPO_ROLES, tmp, writeJson } from "./helpers.mjs";
|
||||
import { checkRoutes, launchSettings } from "../src/launch-settings.mjs";
|
||||
import { OrganizationError } from "../src/organization.mjs";
|
||||
|
||||
const SECRET = "sk-test-NOT-A-SECRET-1234";
|
||||
|
||||
function hidesValue(fn, key, reason) {
|
||||
assert.throws(fn, (error) => {
|
||||
assert.ok(error instanceof BusinessError);
|
||||
assert.equal(error.name, "BusinessError");
|
||||
assert.equal(error.exitCode, 2);
|
||||
assert.ok(error.message.includes(`variable ${key}`), error.message);
|
||||
if (reason) assert.match(error.message, reason);
|
||||
assert.ok(!error.message.includes(SECRET), "refusal disclosed a var value");
|
||||
assert.ok(!String(error).includes(SECRET), "rendered error disclosed a var value");
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
for (const [scope, key, value] of [
|
||||
["business", "tracker.kind", SECRET],
|
||||
["agent", "thinking", SECRET],
|
||||
["agent", "model", SECRET],
|
||||
]) {
|
||||
test(`organization file ${scope} ${key} refusal names its key and hides its value`, () => {
|
||||
const root = tmp();
|
||||
const doc = businessDoc(root);
|
||||
if (scope === "agent") doc.roles.coder.vars[key] = value;
|
||||
else doc.vars[key] = value;
|
||||
writeJson(join(root, "businesses/acme.json"), doc);
|
||||
hidesValue(() => loadBusiness("acme", { dir: root, rolesDir: REPO_ROLES }), key, key === "model" ? /unknown model/ : /one of/);
|
||||
});
|
||||
}
|
||||
|
||||
for (const scope of ["project", "role"]) {
|
||||
test(`project file ${scope} var refusal names its key and hides its value`, () => {
|
||||
const root = tmp();
|
||||
const vars = { "tracker.project": SECRET };
|
||||
writeJson(projectFilePath(root), {
|
||||
projectVersion: 1, id: "stack",
|
||||
...(scope === "project" ? { vars } : { roles: { coder: { vars } } }),
|
||||
});
|
||||
hidesValue(() => loadProject(root), "tracker.project", /positive integer/);
|
||||
});
|
||||
}
|
||||
|
||||
test("systemVars accepts user values and refuses malformed ones without quoting them", () => {
|
||||
const config = {
|
||||
environment: "development", dataRoot: "/d",
|
||||
execution: { backend: "docker", provider: "zai", model: "m", adapter: "pi" },
|
||||
};
|
||||
for (const key of ["environment", "dataRoot", "execution.backend", "execution.provider", "execution.model", "execution.adapter"]) {
|
||||
const bad = structuredClone(config);
|
||||
const value = { pasted: SECRET };
|
||||
if (key.startsWith("execution.")) bad.execution[key.slice("execution.".length)] = value;
|
||||
else bad[key] = value;
|
||||
hidesValue(() => systemVars(bad), key);
|
||||
}
|
||||
});
|
||||
|
||||
// Exercise every validator that used to quote a scalar, a nested leaf or a duplicate.
|
||||
// Removing any of their redactions must fail here even if a caller sanitizes its error.
|
||||
for (const [key, layer, value, reason] of [
|
||||
["tracker.kind", "business", SECRET, /one of/],
|
||||
["harness", "agent", `${SECRET}!`, /harness module name/],
|
||||
["fallback.harness", "agent", `${SECRET}!`, /harness module name/],
|
||||
["thinking", "agent", { pasted: [SECRET] }, /one of/],
|
||||
["fallback.thinking", "agent", SECRET, /one of/],
|
||||
["tracker.reconcileMinutes", "business", SECRET, /integer of at least 1/],
|
||||
["tracker.pollSeconds", "project", SECRET, /integer of at least 10/],
|
||||
["tracker.baseUrl", "business", SECRET, /http or https URL/],
|
||||
["gitea.baseUrl", "business", SECRET, /http or https URL/],
|
||||
["web.searxng", "business", `http://${SECRET}.example`, /https, or http on loopback/],
|
||||
["git.workingBranch", "project", `${SECRET}!`, /git branch name/],
|
||||
["git.protectedBranches", "project", [SECRET, `${SECRET}!`], /git branch name/],
|
||||
["git.protectedBranches", "project", [SECRET, SECRET], /duplicate entry/],
|
||||
["suites", "project", [SECRET, SECRET], /duplicate entry/],
|
||||
["tracker.descendants", "project", SECRET, /true or false/],
|
||||
["bus.tester", "business", SECRET, /true or false/],
|
||||
["tracker.project", "project", SECRET, /positive integer/],
|
||||
["limits.tools", "agent", ["read", SECRET], /unsupported tool/],
|
||||
["limits.authority", "agent", ["message.send", SECRET], /unknown action/],
|
||||
["limits.network", "agent", SECRET, /one of/],
|
||||
]) {
|
||||
test(`checkVars hides refused ${key}: ${reason.source}`, () => {
|
||||
hidesValue(() => checkVars({ [key]: value }, layer, "fixture vars"), key, reason);
|
||||
});
|
||||
}
|
||||
|
||||
for (const prefix of ["", "fallback."]) {
|
||||
test(`route writer unknown ${prefix}model names its key and hides its value`, () => {
|
||||
const root = tmp();
|
||||
const doc = businessDoc(root);
|
||||
writeJson(join(root, "businesses/acme.json"), doc);
|
||||
const settings = launchSettings({ dir: root, rolesDir: REPO_ROLES });
|
||||
const route = { harness: "pi", model: Object.keys(doc.launch.models)[0], thinking: "high", fallback: null };
|
||||
if (prefix) route.fallback = { harness: "pi", model: SECRET, thinking: "high" };
|
||||
else route.model = SECRET;
|
||||
assert.throws(() => settings.setRoutes("acme", {
|
||||
revision: settings.writer.read("acme").revision, routes: { coder: route },
|
||||
}, "jason"), error => {
|
||||
assert.ok(error instanceof OrganizationError);
|
||||
assert.equal(error.code, "unknown-model");
|
||||
assert.ok(!String(error).includes(SECRET));
|
||||
assert.ok(error.message.includes(`variable ${prefix}model`), error.message);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
for (const [field, code] of [
|
||||
["model", "unknown-model"], ["harness", "harness-off"],
|
||||
["harness", "harness-refused"], ["thinking", "thinking-unsupported"],
|
||||
]) {
|
||||
test(`route check ${prefix}${field} ${code} names its key and hides its value`, () => {
|
||||
const vars = prefix ? {
|
||||
harness: "pi", model: "listed", thinking: "high",
|
||||
"fallback.harness": "pi", "fallback.model": "listed", "fallback.thinking": "high",
|
||||
} : { harness: "pi", model: "listed", thinking: "high" };
|
||||
const key = `${prefix}${field}`;
|
||||
vars[key] = SECRET;
|
||||
const proposed = {
|
||||
roles: { coder: { vars } }, launch: { models: { listed: {} } },
|
||||
plugins: { harness: code === "harness-refused" ? ["pi", SECRET] : ["pi"] },
|
||||
};
|
||||
assert.throws(() => checkRoutes({ proposed, levels: name => name === SECRET ? null : ["high"] }), error => {
|
||||
assert.ok(error instanceof OrganizationError);
|
||||
assert.equal(error.code, code);
|
||||
assert.ok(!String(error).includes(SECRET), "route refusal disclosed a var value");
|
||||
assert.ok(error.message.includes(`variable ${key}`), error.message);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1449,7 +1449,6 @@
|
||||
if (n === 0 && window.MosaicOrg) return window.MosaicWorkspace.home();
|
||||
if (a === 'projects' && n === 1 && window.MosaicOrg) return window.MosaicWorkspace.projectsPage(q);
|
||||
if (a === 'organization' && n === 1 && window.MosaicOrg) return window.MosaicWorkspace.dashboard();
|
||||
if (a === 'side-by-side' && n === 1 && window.MosaicOrg) return `${head('Side by side', 'Not built yet.')}${empty('Side by side is not built yet.', 'It arrives with a later unified workspace update. Projects and Agents work today.')}`;
|
||||
if (a === 'inbox' && n === 1 && window.MosaicOrg) return window.MosaicWorkspace.inboxPage(q);
|
||||
if (a === 'tasks' && n === 1 && window.MosaicOrg) return window.MosaicTasksBoard.view();
|
||||
if (a === 'inbox' && n === 1) return inboxView(get);
|
||||
@@ -1542,6 +1541,15 @@
|
||||
window.MosaicChat?.leave(chatPath);
|
||||
if (path === '/business') { location.replace('#/settings/business'); return; }
|
||||
if (path === '/settings/display') { location.replace('#/settings/appearance'); return; }
|
||||
// U5: Side by side draws its own two panes (panes.js); every other page stops their polling.
|
||||
if (path !== '/side-by-side') window.MosaicPanes?.unmount();
|
||||
else if (window.MosaicOrg && window.MosaicPanes) {
|
||||
const fresh = navigated || shown !== path;
|
||||
shown = path;
|
||||
if (fresh) { unmountEditor(); await window.MosaicPanes.mount($('bus-view'), query, { navigated }); }
|
||||
else window.MosaicPanes.refresh();
|
||||
return;
|
||||
}
|
||||
if (chatPath === '/chat' || chatPath.startsWith('/chat/') || /^\/projects\/[0-9a-f-]+\/(?:settings|conversations|tasks|requirements|files)$/i.test(chatPath)) {
|
||||
// Re-read the scoped capability as the Console and bus recover. A failed read
|
||||
// withdraws the control; the chat renderer retains the draft across repaint.
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
<link rel="stylesheet" href="/project-archive.css"><link rel="stylesheet" href="/projects.css">
|
||||
<link rel="stylesheet" href="/processing.css"><script src="/processing.js" defer></script>
|
||||
<link rel="stylesheet" href="/org-create.css"><script src="/org-create.js" defer></script>
|
||||
<link rel="stylesheet" href="/onboarding.css"><script src="/onboarding.js" defer></script>
|
||||
<script src="/workspace-data.js" defer></script><script src="/workspace.js" defer></script><script src="/tasks-board.js" defer></script><script src="/console-restart.js" defer></script><script src="/display.js" defer></script><script src="/inbox.js" defer></script><script src="/files.js" defer></script><script src="/project-remote.js" defer></script><script src="/federation.js" defer></script><script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/markdown-preview.js" defer></script><script src="/projects.js" defer></script><script src="/chat.js" defer></script><script src="/project-archive.js" defer></script><script src="/agents.js" defer></script><script src="/bus.js" defer></script><script src="/requests.js" defer></script></head>
|
||||
<link rel="stylesheet" href="/onboarding.css"><script src="/onboarding.js" defer></script><link rel="stylesheet" href="/panes.css">
|
||||
<script src="/workspace-data.js" defer></script><script src="/workspace.js" defer></script><script src="/tasks-board.js" defer></script><script src="/console-restart.js" defer></script><script src="/display.js" defer></script><script src="/inbox.js" defer></script><script src="/files.js" defer></script><script src="/project-remote.js" defer></script><script src="/federation.js" defer></script><script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/markdown-preview.js" defer></script><script src="/projects.js" defer></script><script src="/chat.js" defer></script><script src="/project-archive.js" defer></script><script src="/agents.js" defer></script><script src="/panes.js" defer></script><script src="/bus.js" defer></script><script src="/requests.js" defer></script></head>
|
||||
<body>
|
||||
<div hidden aria-hidden="true"><select id="palette"></select><select id="mode"><option value="system">system</option><option value="light">light</option><option value="dim">dim</option><option value="dark">dark</option></select></div>
|
||||
<a class="skip" href="#main">Skip to content</a>
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/* Side by side (U5, sample 07 "EXT 8"): a bar, then two panes with a divider between them. Each pane
|
||||
has its own header with the organization's mark and edge, and its own scroll. One pane and a
|
||||
shown-pane switch on a phone. Loaded after shell.css; every rule is scoped to the side-by-side page. */
|
||||
html[data-organizations] .content:has(> #bus-view > .sbs){padding:0}
|
||||
html[data-organizations] .sbs{margin:0;display:flex;flex-direction:column;min-height:0;height:calc(100vh - var(--bar-h,53px) - var(--foot-h,33px))}
|
||||
html[data-organizations] .sbs .panebar{display:flex;align-items:center;gap:10px;padding:8px 14px;border-bottom:1px solid var(--line);background:var(--canvas);flex-wrap:wrap}
|
||||
html[data-organizations] .sbs .panebar h1{font-size:16px;font-weight:700;margin:0;line-height:1.4}
|
||||
html[data-organizations] .sbs .panebar h1:focus{outline:none}
|
||||
html[data-organizations] .sbs .panebar .note{font-size:13px;color:var(--muted)}
|
||||
html[data-organizations] .sbs .panebar [data-pane-add]{margin-left:auto}
|
||||
html[data-organizations] .sbs .ws-seg.pane-tabs{display:none;margin-left:0}
|
||||
html[data-organizations] .sbs .pane-tabs>button{gap:5px;max-width:44vw}
|
||||
html[data-organizations] .sbs .pane-tabs>button>span:last-child{overflow:hidden;text-overflow:ellipsis}
|
||||
|
||||
html[data-organizations] .panes{--sbs-a:1fr;--sbs-b:1fr;display:grid;grid-template-columns:minmax(0,1fr);flex:1;min-height:0}
|
||||
html[data-organizations] .panes.two{grid-template-columns:minmax(0,var(--sbs-a)) 1px minmax(0,var(--sbs-b))}
|
||||
html[data-organizations] .pane{display:flex;flex-direction:column;min-width:0;min-height:0}
|
||||
html[data-organizations] .pane[hidden]{display:none}
|
||||
html[data-organizations] .pane-h{display:flex;align-items:center;gap:8px;padding:8px 14px;border-bottom:1px solid var(--line);background:var(--surface);box-shadow:inset 0 3px 0 var(--org,var(--line));min-width:0}
|
||||
html[data-organizations] .pane-h .ttl{font-weight:700;color:var(--strong,var(--text));overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:0 1 auto}
|
||||
html[data-organizations] .pane-h .ttl small{font-weight:500;color:var(--muted);margin-left:6px;font-size:inherit}
|
||||
html[data-organizations] .pane-h [data-pane-state]:empty{display:none}
|
||||
html[data-organizations] .pane-pick{flex:0 1 180px;min-width:0;max-width:220px;margin-left:auto;font:500 12px/1.4 var(--font);color:var(--text);background:var(--canvas);border:1px solid var(--line);border-radius:var(--r);padding:3px 6px;min-height:0}
|
||||
html[data-organizations] .pane-h .tools{display:flex;gap:2px;flex:none}
|
||||
html[data-organizations] .pane-tool{display:inline-grid;place-items:center;width:26px;height:26px;border-radius:var(--r);color:var(--muted);border:0;background:none;padding:0;min-height:0;cursor:pointer}
|
||||
html[data-organizations] .pane-tool:hover{background:var(--raised);color:var(--text)}
|
||||
html[data-organizations] .pane-tool[hidden]{display:none}
|
||||
html[data-organizations] .pane-tool svg.i{width:15px;height:15px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}
|
||||
html[data-organizations] .pane-line{margin:0;padding:6px 14px;border-bottom:1px solid var(--line);font-size:13px;color:var(--text);background:color-mix(in srgb,var(--warn,#b7791f) 12%,var(--surface))}
|
||||
html[data-organizations] .pane-line[hidden]{display:none}
|
||||
html[data-organizations] .pane-body{overflow:auto;padding:14px;min-height:0;flex:1}
|
||||
html[data-organizations] .pane-body:focus{outline:none}
|
||||
html[data-organizations] .pane-body:focus-visible{outline:2px solid var(--focus,var(--action));outline-offset:-2px}
|
||||
/* A page inside a pane is a section of the side-by-side page: smaller headings, no page chrome. */
|
||||
html[data-organizations] .pane-body .ws h1{font-size:20px}
|
||||
html[data-organizations] .pane-body .chat-area.pane-project{display:block;height:auto;min-height:0;overflow:visible;padding:0}
|
||||
/* A project in a pane is compact (sample 07): the pane header already names the project and its
|
||||
organization, so the page heading stays only for assistive technology and focus, the full-page
|
||||
actions give way (North star and PRD are on the Requirements tab, Settings in the full view), and
|
||||
the tabs, measures and current work come first. Project pages size some parts from the window
|
||||
(projects.css); in a pane they size from the pane. */
|
||||
html[data-organizations] .pane-body{container-type:inline-size}
|
||||
html[data-organizations] .pane-body .project-header{display:block;width:auto;max-width:100%;margin:0 0 6px;padding:0}
|
||||
html[data-organizations] .pane-body .project-header .project-heading,html[data-organizations] .pane-body .project-header .project-heading>div{display:block}
|
||||
html[data-organizations] .pane-body .project-header-icon,html[data-organizations] .pane-body .project-actions{display:none}
|
||||
html[data-organizations] .pane-body .project-header h1{position:absolute;width:1px;height:1px;margin:-1px;padding:0;border:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap}
|
||||
html[data-organizations] .pane-body:has(.project-header h1:focus-visible){outline:2px solid var(--focus,var(--action));outline-offset:-2px}
|
||||
html[data-organizations] .pane-body .project-header .project-heading p{margin:0;font-size:13px;color:var(--muted)}
|
||||
html[data-organizations] .pane-body .project-tabs a{min-height:40px;padding:0 12px;font-size:14px}
|
||||
html[data-organizations] .pane-body .project-metrics{grid-template-columns:repeat(2,minmax(0,1fr));margin-top:10px;padding:6px 0}
|
||||
html[data-organizations] .pane-body .project-metrics a{padding:4px 12px;border-right:0;border-bottom:0;line-height:1.3}
|
||||
html[data-organizations] .pane-body .project-metrics a span,html[data-organizations] .pane-body .project-metrics small{font-size:12px;overflow-wrap:anywhere}
|
||||
html[data-organizations] .pane-body .project-metrics strong{font-size:20px}
|
||||
html[data-organizations] .pane-body .project-metrics p{margin:6px 12px 0;padding-top:6px}
|
||||
html[data-organizations] .pane-body .project-columns{grid-template-columns:minmax(0,1fr);gap:14px;margin-top:12px}
|
||||
html[data-organizations] .pane-body .project-columns h2{font-size:16px;margin:12px 0 8px}
|
||||
html[data-organizations] .pane-body .project-columns h3{font-size:14px}
|
||||
html[data-organizations] .pane-body .project-columns .project-section-title h2,html[data-organizations] .pane-body .project-columns aside>h2:first-child{margin-top:0}
|
||||
@container (min-width:560px){
|
||||
html[data-organizations] .pane-body .project-metrics{grid-template-columns:repeat(4,minmax(0,1fr))}
|
||||
html[data-organizations] .pane-body .project-metrics a:not(:nth-child(4)){border-right:1px solid var(--line)}
|
||||
}
|
||||
/* The side-by-side page has its own bar; the page breadcrumb would name only one pane. */
|
||||
body:has(#bus-view > .sbs) #breadcrumbs{display:none}
|
||||
html[data-organizations] .content:has(> #bus-view > .sbs) > .org-cue{margin:8px 14px}
|
||||
|
||||
html[data-organizations] .pane-div{position:relative;background:var(--line);cursor:col-resize;touch-action:none;outline:none}
|
||||
html[data-organizations] .pane-div::before{content:"";position:absolute;inset:0 -4px;z-index:1}
|
||||
html[data-organizations] .pane-div:hover,html[data-organizations] .pane-div.dragging{background:var(--action)}
|
||||
html[data-organizations] .pane-div:focus-visible{background:var(--action);box-shadow:0 0 0 2px var(--action)}
|
||||
html[data-organizations] .pane-div[hidden]{display:none}
|
||||
|
||||
@media (max-width:760px){
|
||||
html[data-organizations] .content:has(> #bus-view > .sbs){padding:0}
|
||||
html[data-organizations] .sbs{height:auto}
|
||||
html[data-organizations] .sbs .ws-seg.pane-tabs{display:inline-flex}
|
||||
html[data-organizations] .sbs .hide-m{display:none}
|
||||
html[data-organizations] .panes,html[data-organizations] .panes.two{grid-template-columns:minmax(0,1fr)}
|
||||
html[data-organizations] .pane.alt,html[data-organizations] .pane-div{display:none}
|
||||
html[data-organizations] .pane-body{overflow:visible}
|
||||
html[data-organizations] .pane-h{flex-wrap:wrap}
|
||||
html[data-organizations] .pane-pick{flex:1 1 100%;max-width:none;order:5;margin-left:0}
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
// Side by side (U5, sample 07): two panes, each showing one page of any organization. A pane holds
|
||||
// the organization dashboard, the Agents levels or a project page (overview, conversations, tasks,
|
||||
// requirements, files), drawn by the same modules as the full view (workspace.js, projects.js) into
|
||||
// the pane instead of #bus-view. Every read names the pane's organization, so the panes never share
|
||||
// state. Pages that keep one global form, dialog or editor (a conversation, the room, settings, the
|
||||
// queue, docs, the inbox) are not offered in a pane; their links open the full view.
|
||||
//
|
||||
// The panes' routes, the divider and the phone's shown pane live in the URL
|
||||
// (#/orgs/<page>/side-by-side?a=<org>/<route>&b=<org>/<route>&w=<percent>&show=a|b), updated with
|
||||
// replaceState so nothing reloads, and the last layout is remembered in this browser.
|
||||
(() => {
|
||||
'use strict';
|
||||
if (!document.documentElement.hasAttribute('data-organizations')) return;
|
||||
const data = window.MosaicWorkspaceData, ws = () => window.MosaicWorkspace;
|
||||
const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
const enc = encodeURIComponent;
|
||||
const STORE = 'mosaic-side-by-side-v1', POLL = 10000, LIST_TTL = 3000, PROJECTS_TTL = 30000;
|
||||
const MIN = 25, MAX = 75, STEP = 5;
|
||||
const ORG = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
||||
const PROJECT = /^\/projects\/([A-Za-z0-9][A-Za-z0-9._-]{0,159})(?:\/(conversations|tasks|requirements|files))?$/;
|
||||
const SECTIONS = { overview: 'Overview', conversations: 'Conversations', tasks: 'Tasks', requirements: 'Requirements', files: 'Files' };
|
||||
const FOCUSABLE = 'a[href],button:not([disabled]),select,textarea,input,summary,[tabindex]:not([tabindex="-1"])';
|
||||
const NAMES = { a: 'left', b: 'right' };
|
||||
const load = () => { try { return localStorage.getItem(STORE); } catch { return null; } };
|
||||
const save = value => { try { localStorage.setItem(STORE, value); } catch {} };
|
||||
const paused = () => document.getElementById('pause')?.getAttribute('aria-pressed') === 'true';
|
||||
const svg = d => `<svg class="i sm" viewBox="0 0 24 24" aria-hidden="true" focusable="false">${d}</svg>`;
|
||||
const FULL = svg('<rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="M12 4.5v15"/>');
|
||||
const CLOSE = svg('<path d="M6 6l12 12M18 6 6 18"/>');
|
||||
const PLUS = svg('<path d="M12 5v14M5 12h14"/>');
|
||||
|
||||
// A pane's page: the routes a pane can draw. Anything else is not pane-safe.
|
||||
function page(route) {
|
||||
if (route === '/organization') return { kind: 'dashboard' };
|
||||
if (route === '/agents') return { kind: 'agents' };
|
||||
const m = PROJECT.exec(route);
|
||||
if (m && m[1] !== 'threads' && m[1] !== 'rooms') return { kind: 'project', id: m[1], section: m[2] || 'overview' };
|
||||
return null;
|
||||
}
|
||||
// "<org>/<route>" from the URL; null when either part is not one a pane draws.
|
||||
function parsePane(value) {
|
||||
if (typeof value !== 'string') return null;
|
||||
const cut = value.indexOf('/');
|
||||
if (cut < 1) return null;
|
||||
const org = value.slice(0, cut), route = value.slice(cut);
|
||||
return ORG.test(org) && page(route) ? { org, route } : null;
|
||||
}
|
||||
const paneValue = p => `${p.org}${p.route}`;
|
||||
const clampW = n => Math.min(MAX, Math.max(MIN, Math.round(n)));
|
||||
|
||||
// ---------- shared reads (one organization list and project lists for both panes) ----------
|
||||
let listAt = 0, listPromise = null, projectReads = new Map();
|
||||
function orgList(fresh = false) {
|
||||
if (!listPromise || fresh || Date.now() - listAt > LIST_TTL) {
|
||||
listAt = Date.now();
|
||||
listPromise = Promise.all([data.organizations(), ws()?.themes?.()]).then(([rows]) => (Array.isArray(rows) ? rows : null)).catch(() => null);
|
||||
}
|
||||
return listPromise;
|
||||
}
|
||||
function projectsOf(org, fresh = false) {
|
||||
const hit = projectReads.get(org);
|
||||
if (hit && !fresh && Date.now() - hit.at < PROJECTS_TTL) return hit.promise;
|
||||
const promise = data.projects(org).then(b => (Array.isArray(b?.rows) ? b.rows.filter(p => p && typeof p.id === 'string') : null)).catch(() => null);
|
||||
projectReads.set(org, { at: Date.now(), promise });
|
||||
return promise;
|
||||
}
|
||||
|
||||
// ---------- state ----------
|
||||
let mounted = null; // { root, panes: [pane], w, show, gen }
|
||||
let mountGen = 0;
|
||||
let listCache = null; // the last organization list read, for the shown-pane tabs
|
||||
|
||||
function readQuery(query) {
|
||||
const q = new URLSearchParams(query || '');
|
||||
return { a: parsePane(q.get('a')), b: parsePane(q.get('b')), w: Number(q.get('w')), show: q.get('show'), named: q.has('a') || q.has('b') };
|
||||
}
|
||||
function queryOf(state) {
|
||||
const q = new URLSearchParams();
|
||||
state.panes.forEach((p, i) => q.set(i ? 'b' : 'a', paneValue(p)));
|
||||
q.set('w', String(state.w));
|
||||
q.set('show', state.show);
|
||||
return q.toString().replace(/%2F/g, '/');
|
||||
}
|
||||
function writeUrl() {
|
||||
if (!mounted) return;
|
||||
const qs = queryOf(mounted);
|
||||
save(qs);
|
||||
const next = `#/orgs/${enc(window.MosaicOrg.id)}/side-by-side?${qs}`;
|
||||
if (location.hash !== next) history.replaceState(null, '', next);
|
||||
}
|
||||
// The first pane is this page's organization; the second is the next organization on the list,
|
||||
// or this one's Agents when there is no other.
|
||||
async function defaults() {
|
||||
const here = window.MosaicOrg.id, list = await orgList();
|
||||
const other = (list || []).find(o => o && !o.invalid && o.id !== here && ORG.test(o.id));
|
||||
return [{ org: here, route: '/organization' }, other ? { org: other.id, route: '/organization' } : { org: here, route: '/agents' }];
|
||||
}
|
||||
|
||||
// ---------- markup ----------
|
||||
function paneShell(key) {
|
||||
const name = NAMES[key];
|
||||
return `<section class="pane" id="pane-${key}" data-pane="${key}" aria-label="${name[0].toUpperCase()}${name.slice(1)} pane">
|
||||
<div class="pane-h ws-o" data-pane-head>
|
||||
<span data-pane-av></span><span class="ttl" data-pane-ttl></span><span data-pane-state></span>
|
||||
<label class="ws-sr" for="pane-${key}-pick">Page in the ${name} pane</label>
|
||||
<select id="pane-${key}-pick" class="pane-pick" data-pane-pick></select>
|
||||
<span class="tools"><a class="pane-tool" data-pane-full href="#" title="Open in full view"><span class="ws-sr" data-pane-full-label>Open in full view</span>${FULL}</a><button type="button" class="pane-tool" data-pane-close title="Close pane" aria-label="Close the ${name} pane">${CLOSE}</button></span>
|
||||
</div>
|
||||
<p class="pane-line" data-pane-line role="status"></p>
|
||||
<div class="pane-body" data-pane-body tabindex="0" aria-label="${name[0].toUpperCase()}${name.slice(1)} pane content"></div>
|
||||
</section>`;
|
||||
}
|
||||
function frame() {
|
||||
return `<section class="sbs" data-side-by-side aria-labelledby="sbs-title">
|
||||
<div class="panebar"><h1 id="sbs-title" tabindex="-1">Side by side</h1><span class="note" data-sbs-note></span>
|
||||
<span class="ws-seg pane-tabs" role="group" aria-label="Shown pane" data-sbs-tabs></span>
|
||||
<button type="button" class="ws-btn hide-m" data-pane-add>${PLUS}Add pane</button></div>
|
||||
<div class="panes" data-sbs-panes>${paneShell('a')}<div class="pane-div" role="separator" tabindex="0" aria-orientation="vertical" aria-label="Resize panes" aria-controls="pane-a pane-b" aria-valuemin="${MIN}" aria-valuemax="${MAX}" data-sbs-divider></div>${paneShell('b')}</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
// Orgs in the picker: every organization on the list. A pane draws a valid one's dashboard, Agents or
|
||||
// any project the organization's project read lists; an invalid one is listed and cannot be chosen.
|
||||
async function pickerHtml(p, list) {
|
||||
const groups = await Promise.all((list || []).filter(o => o && ORG.test(o.id)).map(async org => {
|
||||
const name = ws().label(org);
|
||||
if (org.invalid) return `<optgroup label="${esc(name)} · invalid"><option disabled value="">Organization invalid</option></optgroup>`;
|
||||
const rows = await projectsOf(org.id);
|
||||
const opt = (route, text) => `<option value="${esc(`${org.id}${route}`)}">${esc(text)}</option>`;
|
||||
const projects = rows === null ? '<option disabled value="">Projects unavailable</option>' : rows.filter(r => !r.archived && PROJECT.test(`/projects/${r.id}`)).map(r => opt(`/projects/${r.id}`, `Project: ${r.name || r.id}`)).join('');
|
||||
return `<optgroup label="${esc(name)}${org.busHealthy ? '' : ' · bus unavailable'}">${opt('/organization', 'Dashboard')}${opt('/agents', 'Agents')}${projects}</optgroup>`;
|
||||
}));
|
||||
let html = groups.join('');
|
||||
const value = paneValue(p);
|
||||
// A project section, or a page the list no longer names, is still the pane's page: keep it selectable.
|
||||
if (!html.includes(`value="${esc(value)}"`)) html = `<option value="${esc(value)}">${esc(p.title || value)}</option>${html}`;
|
||||
return html;
|
||||
}
|
||||
|
||||
// ---------- drawing one pane ----------
|
||||
const els = p => ({
|
||||
head: p.el.querySelector('[data-pane-head]'), av: p.el.querySelector('[data-pane-av]'), ttl: p.el.querySelector('[data-pane-ttl]'),
|
||||
state: p.el.querySelector('[data-pane-state]'), pick: p.el.querySelector('[data-pane-pick]'), full: p.el.querySelector('[data-pane-full]'),
|
||||
fullLabel: p.el.querySelector('[data-pane-full-label]'), close: p.el.querySelector('[data-pane-close]'), line: p.el.querySelector('[data-pane-line]'), body: p.el.querySelector('[data-pane-body]'),
|
||||
});
|
||||
const setHtml = (el, html) => { if (el && el.innerHTML !== html) el.innerHTML = html; };
|
||||
const setText = (el, text) => { if (el && el.textContent !== text) el.textContent = text; };
|
||||
|
||||
// Links a page draws are relative to the page's organization. In a pane they name the pane's
|
||||
// organization; reads the page links to (/api/...) name it too.
|
||||
function rewrite(p, root) {
|
||||
const prefix = `#/orgs/${enc(p.org)}`;
|
||||
for (const a of root.querySelectorAll('a[href^="#/"]')) if (!a.getAttribute('href').startsWith('#/orgs/')) a.setAttribute('href', prefix + a.getAttribute('href').slice(1));
|
||||
for (const a of root.querySelectorAll('a[href^="/api/"]')) if (!a.getAttribute('href').startsWith('/api/orgs/')) a.setAttribute('href', `/api/orgs/${enc(p.org)}/${a.getAttribute('href').slice(5)}`);
|
||||
// The pane's title is the page's heading; the side-by-side page keeps the one level-1 heading.
|
||||
for (const h of root.querySelectorAll('h1:not([aria-level])')) h.setAttribute('aria-level', '2');
|
||||
}
|
||||
// Focus and scroll survive a repaint of this pane only. Focus in the other pane is never taken.
|
||||
function capture(p, root) {
|
||||
const a = document.activeElement, inside = !!a && a !== root && root.contains(a);
|
||||
return {
|
||||
top: root.scrollTop,
|
||||
focus: inside ? { key: a.dataset?.focusKey || null, href: a.getAttribute('href'), index: [...root.querySelectorAll(FOCUSABLE)].indexOf(a), h1: a.tagName === 'H1' } : null,
|
||||
};
|
||||
}
|
||||
function restore(p, root, kept) {
|
||||
root.scrollTop = kept.top;
|
||||
const f = kept.focus, a = document.activeElement;
|
||||
if (!f || (a && a !== document.body && !root.contains(a))) return;
|
||||
const target = (f.key && root.querySelector(`[data-focus-key="${CSS.escape(f.key)}"]`))
|
||||
|| (f.href && [...root.querySelectorAll('a[href]')].find(x => x.getAttribute('href') === f.href))
|
||||
|| (f.h1 && root.querySelector('h1'))
|
||||
|| root.querySelectorAll(FOCUSABLE)[f.index];
|
||||
(target || root).focus({ preventScroll: true });
|
||||
}
|
||||
function paintBody(p, html) {
|
||||
const { body } = els(p);
|
||||
if (p.html === html && body.childElementCount) return;
|
||||
const kept = capture(p, body);
|
||||
body.innerHTML = html; p.html = html; p.project = null;
|
||||
rewrite(p, body);
|
||||
restore(p, body, kept);
|
||||
}
|
||||
|
||||
// A focus move that finishes after an asynchronous read happens only if the viewer has not
|
||||
// moved on: focus is still where it was when the action started, nowhere, or inside this pane.
|
||||
function mayFocus(p, before) {
|
||||
const a = document.activeElement;
|
||||
return !a || a === document.body || a === before || p.el.contains(a);
|
||||
}
|
||||
async function drawPane(p, { fresh = false, focusHead = false } = {}) {
|
||||
if (!mounted || !p.el.isConnected) return;
|
||||
const before = document.activeElement;
|
||||
const turn = ++p.turn, gen = mountGen, live = () => mounted && gen === mountGen && turn === p.turn && p.el.isConnected;
|
||||
clearTimeout(p.timer);
|
||||
const e = els(p), what = page(p.route);
|
||||
const list = await orgList(fresh);
|
||||
if (!live()) return;
|
||||
if (list) listCache = list;
|
||||
const org = list?.find(o => o?.id === p.org) || null;
|
||||
const name = org ? ws().label(org) : p.org;
|
||||
// Header: the organization's mark, the page's title, its state, the picker and the tools.
|
||||
if (org) e.head.setAttribute('style', ws().colours(org)); else e.head.removeAttribute('style');
|
||||
setHtml(e.av, org ? ws().av(org, 'md') : '<span class="ws-av md" aria-hidden="true">?</span>');
|
||||
const [scls, sglyph, sword] = org ? ws().state(org) : ['s-unknown', '?', 'not on your list'];
|
||||
setHtml(e.state, org?.busHealthy && !org.invalid ? '' : ws().dot(scls, sglyph, sword, true));
|
||||
const fullHref = `#/orgs/${enc(p.org)}${p.route}`;
|
||||
if (e.full.getAttribute('href') !== fullHref) e.full.setAttribute('href', fullHref);
|
||||
|
||||
let line = '', html = null;
|
||||
if (!list) line = 'The organization list could not be read. This pane shows what its own reads return.';
|
||||
else if (!org) { line = `${p.org} is not on your organization list.`; html = '<p class="ws-line">Nothing is read for an organization that is not on your list.</p>'; }
|
||||
else if (org.invalid) { line = `${name}: organization invalid${org.reason ? ` (${org.reason})` : ''}.`; html = '<p class="ws-line">Nothing is read from an invalid organization. Fix its file under Settings, Organizations.</p>'; }
|
||||
else if (!org.busHealthy) line = `${name}: bus unavailable. Reads from this organization may fail; the other pane is not affected.`;
|
||||
setText(e.line, line); e.line.hidden = !line;
|
||||
|
||||
let title = what?.kind === 'agents' ? 'Agents' : 'Dashboard';
|
||||
if (html === null && what?.kind === 'dashboard') html = await ws().dashboard(p.org, { pane: true, idPrefix: `pane-${p.key}-` }).catch(() => '<p class="ws-line">Dashboard unavailable.</p>');
|
||||
else if (html === null && what?.kind === 'agents') html = await ws().agentGrid('', p.org).catch(() => '<p class="ws-line">Agents unavailable.</p>');
|
||||
if (!live()) return;
|
||||
if (html === null && what?.kind === 'project') {
|
||||
title = await drawProject(p, e, what, fresh, live);
|
||||
if (title === null || !live()) return;
|
||||
} else if (html !== null) paintBody(p, html);
|
||||
p.title = title;
|
||||
setHtml(e.ttl, `${esc(title)}<small>${esc(name)}</small>`);
|
||||
p.el.setAttribute('aria-label', `${NAMES[p.key][0].toUpperCase()}${NAMES[p.key].slice(1)} pane: ${title}, ${name}`);
|
||||
setText(e.fullLabel, `Open ${title}, ${name}, in full view`);
|
||||
// The picker is rebuilt only while it is closed and unfocused, so a choice in progress stays.
|
||||
if (document.activeElement !== e.pick) {
|
||||
const options = await pickerHtml(p, list);
|
||||
if (!live()) return;
|
||||
if (document.activeElement !== e.pick) { setHtml(e.pick, options); e.pick.value = paneValue(p); }
|
||||
}
|
||||
paintBar(); fit();
|
||||
if (focusHead && mayFocus(p, before)) (e.body.querySelector('h1') || e.body).focus({ preventScroll: true });
|
||||
p.timer = setTimeout(() => tick(p), POLL);
|
||||
}
|
||||
function tick(p) {
|
||||
if (!mounted || !p.el.isConnected) return;
|
||||
if (paused()) { p.timer = setTimeout(() => tick(p), POLL); return; }
|
||||
void drawPane(p);
|
||||
}
|
||||
|
||||
// A project page: projects.js's header and content, mounted into the pane with the pane's
|
||||
// organization, read-only (a pane never sends).
|
||||
// Every write below happens only while this draw is still the pane's current one (live()), so a
|
||||
// slow read never paints an old page or organization over the one the viewer has since chosen.
|
||||
async function drawProject(p, e, what, fresh, live) {
|
||||
const org = p.org;
|
||||
const [projects, threadsRead] = await Promise.all([projectsOf(org, fresh), data.threads(org).catch(() => null)]);
|
||||
if (!live() || p.org !== org) return null;
|
||||
const project = projects?.find(r => r.id === what.id);
|
||||
const sectionName = SECTIONS[what.section];
|
||||
if (!project) {
|
||||
paintBody(p, projects === null ? '<p class="ws-line">Projects unavailable. The organization did not answer the project read.</p>' : '<p class="ws-line">This project is not in the organization\'s project list.</p>');
|
||||
return what.id;
|
||||
}
|
||||
const threads = Array.isArray(threadsRead?.rows) ? threadsRead.rows : [];
|
||||
const head = window.MosaicProjects.header(project, what.section, threads);
|
||||
const conv = what.section === 'conversations' ? conversations(project, threads, threadsRead) : null;
|
||||
const key = JSON.stringify([org, project.id, what.section, head, conv]);
|
||||
const { body } = e;
|
||||
if (p.project?.key !== key) {
|
||||
const kept = capture(p, body);
|
||||
body.innerHTML = `<div class="chat-area pane-project">${head}${conv ?? '<div data-project-content>Reading…</div>'}</div>`;
|
||||
p.html = null; p.project = { key };
|
||||
rewrite(p, body);
|
||||
restore(p, body, kept);
|
||||
}
|
||||
const content = body.querySelector('[data-project-content]');
|
||||
if (content) await window.MosaicProjects.mountContent({ container: content, project, threads, section: what.section, canSend: false, org, pane: true, views: viewsOf(p, org) });
|
||||
return what.section === 'overview' ? project.name || project.id : `${project.name || project.id} · ${sectionName}`;
|
||||
}
|
||||
// A pane's request choice and PRD disclosure, per organization, kept on the pane so a repaint
|
||||
// that replaces the project content keeps them.
|
||||
function viewsOf(p, org) {
|
||||
if (!p.views.has(org)) p.views.set(org, { selected: new Map(), expanded: new Set() });
|
||||
return p.views.get(org);
|
||||
}
|
||||
function conversations(project, threads, read) {
|
||||
if (!read) return '<p class="ws-line">Conversations unavailable.</p>';
|
||||
const own = threads.filter(t => t && !t.shared && t.project === project.id && typeof t.id === 'string');
|
||||
const rows = own.map(t => `<a class="project-conversation-row" href="#/projects/threads/${enc(t.id)}" data-focus-key="${esc(`conv:${t.id}`)}"><span class="project-conversation-icon" aria-hidden="true">◌</span><span><strong>${esc(t.name || t.id)}</strong><small>With ${esc(t.instance || 'no agent recorded')}</small></span><span aria-hidden="true">›</span></a>`).join('');
|
||||
return `<div class="project-section-title"><h2>Conversations</h2></div><div data-project-conversations>${rows || '<p>No conversations yet.</p>'}</div><p class="ws-line">A conversation opens in its full view.</p>`;
|
||||
}
|
||||
|
||||
// ---------- the bar, tabs, divider and layout ----------
|
||||
function paintBar() {
|
||||
if (!mounted) return;
|
||||
const { root, panes, show } = mounted, two = panes.length === 2;
|
||||
setText(root.querySelector('[data-sbs-note]'), `${panes.length} ${panes.length === 1 ? 'pane' : 'panes'} · each pane reads its own organization's bus and keeps its own state`);
|
||||
const tabs = root.querySelector('[data-sbs-tabs]'), focusedTab = tabs.contains(document.activeElement) ? document.activeElement.dataset.paneShow : null;
|
||||
const list = listCache;
|
||||
const html = panes.map(p => {
|
||||
const org = list?.find(o => o?.id === p.org);
|
||||
return `<button type="button" data-pane-show="${p.key}" aria-pressed="${show === p.key}" aria-controls="pane-${p.key}">${org ? ws().av(org, 'xs') : ''}<span>${esc(p.title || 'Reading…')}</span></button>`;
|
||||
}).join('');
|
||||
if (tabs.innerHTML !== html) { tabs.innerHTML = html; if (focusedTab) tabs.querySelector(`[data-pane-show="${focusedTab}"]`)?.focus({ preventScroll: true }); }
|
||||
const add = root.querySelector('[data-pane-add]');
|
||||
add.disabled = two; add.title = two ? 'Side by side holds two panes. Close one to choose another.' : 'Open a second pane';
|
||||
for (const p of panes) { const c = p.el.querySelector('[data-pane-close]'); c.hidden = !two; }
|
||||
}
|
||||
function layout() {
|
||||
if (!mounted) return;
|
||||
const { root, panes, w, show } = mounted, two = panes.length === 2;
|
||||
const box = root.querySelector('[data-sbs-panes]'), divider = root.querySelector('[data-sbs-divider]');
|
||||
box.classList.toggle('two', two);
|
||||
box.style.setProperty('--sbs-a', `${w}fr`); box.style.setProperty('--sbs-b', `${100 - w}fr`);
|
||||
divider.hidden = !two;
|
||||
divider.setAttribute('aria-valuenow', String(w));
|
||||
divider.setAttribute('aria-valuetext', `Left pane ${w} percent, right pane ${100 - w} percent`);
|
||||
root.querySelector('#pane-b').hidden = !two;
|
||||
for (const p of panes) p.el.classList.toggle('alt', show !== p.key);
|
||||
fit();
|
||||
}
|
||||
// Wide screens: the panes fill the window below the bar and each scrolls on its own.
|
||||
const wide = window.matchMedia('(min-width: 761px)');
|
||||
function fit() {
|
||||
if (!mounted) return;
|
||||
const { root } = mounted;
|
||||
if (!wide.matches) { root.style.height = ''; return; }
|
||||
const foot = document.querySelector('body > .foot, .foot')?.getBoundingClientRect().height || 0;
|
||||
const top = root.getBoundingClientRect().top;
|
||||
root.style.height = `${Math.max(360, Math.floor(window.innerHeight - Math.max(top, 0) - foot))}px`;
|
||||
}
|
||||
function setWidth(w, persist = true) {
|
||||
if (!mounted) return;
|
||||
const next = clampW(w);
|
||||
if (next === mounted.w) return;
|
||||
mounted.w = next; layout(); if (persist) writeUrl();
|
||||
}
|
||||
|
||||
// ---------- events (bound once to the mount root) ----------
|
||||
function bind(root) {
|
||||
const divider = root.querySelector('[data-sbs-divider]');
|
||||
divider.addEventListener('keydown', e => {
|
||||
const w = mounted?.w; if (w === undefined) return;
|
||||
const next = { ArrowLeft: w - STEP, ArrowDown: w - STEP, ArrowRight: w + STEP, ArrowUp: w + STEP, PageDown: w - 10, PageUp: w + 10, Home: MIN, End: MAX }[e.key];
|
||||
if (next === undefined) return;
|
||||
e.preventDefault(); setWidth(next);
|
||||
});
|
||||
divider.addEventListener('pointerdown', e => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault(); divider.focus({ preventScroll: true }); divider.setPointerCapture(e.pointerId); divider.classList.add('dragging');
|
||||
const box = root.querySelector('[data-sbs-panes]').getBoundingClientRect();
|
||||
const move = ev => setWidth(((ev.clientX - box.left) / box.width) * 100, false);
|
||||
const up = () => { divider.removeEventListener('pointermove', move); divider.removeEventListener('pointerup', up); divider.removeEventListener('pointercancel', up); divider.classList.remove('dragging'); writeUrl(); };
|
||||
divider.addEventListener('pointermove', move); divider.addEventListener('pointerup', up); divider.addEventListener('pointercancel', up);
|
||||
});
|
||||
root.addEventListener('change', e => {
|
||||
const pick = e.target.closest?.('[data-pane-pick]'); if (!pick || !mounted) return;
|
||||
const p = mounted.panes.find(x => x.el.contains(pick)), next = parsePane(pick.value);
|
||||
if (!p || !next) return;
|
||||
go(p, next, false);
|
||||
});
|
||||
root.addEventListener('click', e => {
|
||||
if (!mounted) return;
|
||||
const show = e.target.closest?.('[data-pane-show]');
|
||||
if (show) { mounted.show = show.dataset.paneShow === 'b' && mounted.panes.length === 2 ? 'b' : 'a'; layout(); paintBar(); writeUrl(); return; }
|
||||
if (e.target.closest?.('[data-pane-add]')) { void addPane(); return; }
|
||||
const close = e.target.closest?.('[data-pane-close]');
|
||||
if (close) { closePane(mounted.panes.find(x => x.el.contains(close))); return; }
|
||||
// A plain click on a link to a page a pane draws, in the pane's own organization, opens it in
|
||||
// the pane. Anything else (another organization, a page a pane does not draw, a new tab) is
|
||||
// an ordinary link to the full view.
|
||||
const a = e.target.closest?.('a[href^="#/orgs/"]');
|
||||
if (!a || a.hasAttribute('data-pane-full') || e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
||||
const p = mounted.panes.find(x => x.el.contains(a)); if (!p) return;
|
||||
const m = /^#\/orgs\/([^/?#]+)(\/[^?#]*)$/.exec(a.getAttribute('href'));
|
||||
let org, route;
|
||||
try { org = decodeURIComponent(m?.[1] ?? ''); route = decodeURIComponent(m?.[2] ?? ''); } catch { return; }
|
||||
if (org !== p.org || !page(route)) return;
|
||||
e.preventDefault();
|
||||
go(p, { org, route }, true);
|
||||
});
|
||||
}
|
||||
function go(p, next, focusHead) {
|
||||
p.org = next.org; p.route = next.route; p.html = null; p.project = null; p.title = null;
|
||||
els(p).body.scrollTop = 0;
|
||||
writeUrl();
|
||||
void drawPane(p, { focusHead });
|
||||
}
|
||||
async function addPane() {
|
||||
if (!mounted || mounted.panes.length !== 1) return;
|
||||
const before = document.activeElement;
|
||||
const [a] = mounted.panes, list = await orgList();
|
||||
if (!mounted || mounted.panes.length !== 1) return;
|
||||
const other = (list || []).find(o => o && !o.invalid && o.id !== a.org && ORG.test(o.id));
|
||||
const b = makePane('b', other ? { org: other.id, route: '/organization' } : { org: a.org, route: a.route === '/agents' ? '/organization' : '/agents' });
|
||||
mounted.panes.push(b); mounted.show = 'b';
|
||||
layout(); paintBar(); writeUrl();
|
||||
await drawPane(b);
|
||||
if (mounted?.panes.includes(b) && mayFocus(b, before)) els(b).pick.focus({ preventScroll: true });
|
||||
}
|
||||
function closePane(p) {
|
||||
if (!mounted || !p || mounted.panes.length !== 2) return;
|
||||
clearTimeout(p.timer); p.turn++;
|
||||
const keep = mounted.panes.find(x => x !== p);
|
||||
// The remaining pane becomes the first: its page moves into pane a's element.
|
||||
const a = mounted.panes[0];
|
||||
if (keep !== a) {
|
||||
const b = mounted.panes[1];
|
||||
clearTimeout(b.timer); b.turn++;
|
||||
a.org = keep.org; a.route = keep.route; a.html = null; a.project = null; a.title = keep.title; a.views = keep.views;
|
||||
}
|
||||
setHtml(els(mounted.panes[1]).body, ''); mounted.panes[1].html = null; mounted.panes[1].project = null;
|
||||
mounted.panes = [a]; mounted.show = 'a';
|
||||
layout(); paintBar(); writeUrl();
|
||||
// The close button just left the page; focus goes to the remaining pane's picker now, and again
|
||||
// after its draw only if the viewer has not moved elsewhere meanwhile.
|
||||
els(a).pick.focus({ preventScroll: true });
|
||||
const before = document.activeElement;
|
||||
void drawPane(a).then(() => { if (mounted?.panes[0] === a && mayFocus(a, before)) els(a).pick.focus({ preventScroll: true }); });
|
||||
}
|
||||
function makePane(key, at) {
|
||||
const el = mounted.root.querySelector(`#pane-${key}`);
|
||||
return { key, el, org: at.org, route: at.route, turn: 0, timer: null, html: null, project: null, title: null, views: new Map() };
|
||||
}
|
||||
|
||||
// ---------- public ----------
|
||||
async function mount(container, query, { navigated = false } = {}) {
|
||||
unmount();
|
||||
const gen = ++mountGen;
|
||||
container.innerHTML = frame();
|
||||
const root = container.querySelector('[data-side-by-side]');
|
||||
mounted = { root, panes: [], w: 50, show: 'a', gen, observers: [] };
|
||||
// projects.js paints a project's content after its own reads; its links are rewritten as they
|
||||
// land. One observer per pane element for the life of the mount, applying whichever pane object
|
||||
// holds that element now, so a closed and re-added pane never runs an old organization's rewrite.
|
||||
const m = mounted;
|
||||
for (const key of ['a', 'b']) {
|
||||
const body = root.querySelector(`#pane-${key} [data-pane-body]`);
|
||||
const observer = new MutationObserver(() => {
|
||||
if (mounted !== m) return;
|
||||
const p = m.panes.find(x => x.key === key);
|
||||
if (p) rewrite(p, body);
|
||||
});
|
||||
observer.observe(body, { childList: true, subtree: true });
|
||||
m.observers.push(observer);
|
||||
}
|
||||
bind(root);
|
||||
window.addEventListener('resize', fit);
|
||||
document.title = 'Side by side · Mosaic Console';
|
||||
if (navigated) root.querySelector('h1').focus({ preventScroll: true });
|
||||
let wanted = readQuery(query);
|
||||
if (!wanted.named) { const kept = readQuery(load() || ''); if (kept.a) wanted = { ...kept, named: true }; }
|
||||
let at = [wanted.a, wanted.b].filter(Boolean);
|
||||
if (!at.length) at = await defaults();
|
||||
if (!mounted || gen !== mountGen) return;
|
||||
listCache = await orgList();
|
||||
if (!mounted || gen !== mountGen) return;
|
||||
mounted.panes = at.slice(0, 2).map((x, i) => makePane(i ? 'b' : 'a', x));
|
||||
mounted.w = Number.isFinite(wanted.w) && wanted.w ? clampW(wanted.w) : 50;
|
||||
mounted.show = wanted.show === 'b' && mounted.panes.length === 2 ? 'b' : 'a';
|
||||
layout(); paintBar(); writeUrl();
|
||||
await Promise.all(mounted.panes.map(p => drawPane(p)));
|
||||
}
|
||||
function unmount() {
|
||||
if (!mounted) return;
|
||||
for (const p of mounted.panes) { clearTimeout(p.timer); p.turn++; }
|
||||
for (const o of mounted.observers) o.disconnect();
|
||||
window.removeEventListener('resize', fit);
|
||||
mounted = null; mountGen++;
|
||||
}
|
||||
// The Console's Refresh: both panes read again now, each keeping its own focus and scroll.
|
||||
function refresh() {
|
||||
if (!mounted) return;
|
||||
projectReads = new Map();
|
||||
void orgList(true).then(rows => { if (rows) listCache = rows; });
|
||||
for (const p of mounted.panes) void drawPane(p, { fresh: true });
|
||||
}
|
||||
// The list the tabs draw from follows each fresh read of the organization list.
|
||||
document.addEventListener('mosaic-org-list', e => { if (Array.isArray(e.detail?.rows)) listCache = e.detail.rows; });
|
||||
|
||||
window.MosaicPanes = Object.freeze({ mount, unmount, refresh, page });
|
||||
})();
|
||||
@@ -31,12 +31,15 @@
|
||||
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={}; }
|
||||
// A side-by-side pane reads its own organization: its reads name that organization explicitly.
|
||||
const at=(org,url)=>org && url.startsWith('/api/') ? `/api/orgs/${encodeURIComponent(org)}/${url.slice(5)}` : url;
|
||||
async function read(url) {
|
||||
const response=await fetch(url,{cache:'no-store',redirect:'manual'});
|
||||
if(response.status===401 || response.type==='opaqueredirect') { document.dispatchEvent(new CustomEvent('mosaic-signed-out',{detail:{signIn:true}})); throw Error('signed-out'); }
|
||||
if(!response.ok) { const body=await response.json().catch(()=>null); const error=Error('unavailable'); error.status=response.status; error.code=body?.error; throw error; }
|
||||
return response.json();
|
||||
}
|
||||
const readAt=url=>read(url);
|
||||
function currentProject(route) {
|
||||
const id=route.match(/^\/(?:projects|chat)\/(?:projects\/|rooms\/)?([0-9a-f-]{36})/i)?.[1];
|
||||
if(id) return id;
|
||||
@@ -104,8 +107,9 @@
|
||||
const same=(a,b)=>JSON.stringify(canonical(a))===JSON.stringify(canonical(b));
|
||||
const material=(d,s)=>d && d.candidate && s?.candidate && same(d.candidate,s.candidate) && d.candidate.request===s.request && d.candidate.project===s.project && d.candidate.mode_revision===s.mode_revision && typeof d.prd==='string' && Array.isArray(d.requirements) && Array.isArray(d.candidate.requirements) && d.requirements.length===d.candidate.requirements.length && d.requirements.every((r,i)=>r && r.id===d.candidate.requirements[i]?.id && r.title===d.candidate.requirements[i]?.title && typeof r.text==='string') && d.snapshot && Number.isSafeInteger(d.snapshot.revision) && d.snapshot.revision>=0 && d.snapshot.id===s.snapshot && (d.snapshot.id===null || requestId(d.snapshot.id) && d.snapshot.revision>0);
|
||||
const ownThreads=(project,threads)=>threads.filter(t=>t.project===project.id && !t.shared && UUID.test(t.id));
|
||||
async function gather(project,threads) {
|
||||
const own=ownThreads(project,threads), key=project.id+':'+String(project.repo)+':'+own.map(t=>t.id).join(',');
|
||||
async function gather(project,threads,org=null) {
|
||||
const read=url=>readAt(at(org,url));
|
||||
const own=ownThreads(project,threads), key=(org?org+'|':'')+project.id+':'+String(project.repo)+':'+own.map(t=>t.id).join(',');
|
||||
const prior=cache.get(key); if(prior && Date.now()-prior.at<10000) return prior.promise;
|
||||
const started=epoch;
|
||||
const promise=(async()=>{
|
||||
@@ -133,7 +137,7 @@
|
||||
const [draft,workspace]=await Promise.all([read(base+'/draft').catch(()=>null),read(base+'/workspace').catch(()=>null)]);
|
||||
return {...item,state,draft:material(draft,state)?draft:null,materialFailed:!!state.candidate && !material(draft,state),workspace};
|
||||
}))).filter(Boolean);
|
||||
const [tasks,catalog,room]=await Promise.all([read('/api/bus/tasks').catch(()=>null),read('/api/chat/instances').catch(()=>null),readRoomStatus(project)]);
|
||||
const [tasks,catalog,room]=await Promise.all([read('/api/bus/tasks').catch(()=>null),read('/api/chat/instances').catch(()=>null),readRoomStatus(project,org)]);
|
||||
const members=Array.isArray(catalog?.rows)?catalog.rows.filter(m=>typeof m?.instance==='string' && own.some(t=>t.instance===m.instance && t.name===t.instance)).length:null;
|
||||
let docs=project.repo ? null : {files:[]}, north=null, recordsMissing=false, northMissing=false, northRecordsMissing=false;
|
||||
if(project.repo && /^[A-Za-z0-9][A-Za-z0-9._/-]{0,200}$/.test(project.repo)) {
|
||||
@@ -145,9 +149,9 @@
|
||||
})();
|
||||
cache.set(key,{at:Date.now(),promise}); return promise;
|
||||
}
|
||||
async function readRoomStatus(project) {
|
||||
async function readRoomStatus(project,org=null) {
|
||||
try {
|
||||
const response=await fetch(`/api/chat/rooms/${encodeURIComponent(project.id)}/messages?limit=100`,{cache:'no-store',redirect:'manual'});
|
||||
const response=await fetch(at(org,'/api/chat/rooms/')+`${encodeURIComponent(project.id)}/messages?limit=100`,{cache:'no-store',redirect:'manual'});
|
||||
if(response.status===401 || response.type==='opaqueredirect') { document.dispatchEvent(new CustomEvent('mosaic-signed-out',{detail:{signIn:true}})); return 'Unavailable'; }
|
||||
const body=await response.json();
|
||||
if(response.ok && Array.isArray(body?.rows) && typeof body.hasMore==='boolean') return 'Open';
|
||||
@@ -194,11 +198,18 @@
|
||||
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) {
|
||||
// U5: a project in a Side by side pane keeps its own request choice and PRD disclosure, so two
|
||||
// panes showing the same project (or two organizations' projects with one id) never share them.
|
||||
// The pane passes `views`, kept on the pane per organization, so the state outlives a repaint
|
||||
// that replaces the content element (a new conversation changes the header). Without it, the
|
||||
// state lives as long as the container.
|
||||
const paneViews = new WeakMap();
|
||||
const viewState=(container,pane,views)=>{if(!pane) return {selected:selectedRequests,expanded:expandedPrds}; if(views?.selected instanceof Map && views.expanded instanceof Set) return views; if(!paneViews.has(container)) paneViews.set(container,{selected:new Map(),expanded:new Set()}); return paneViews.get(container);};
|
||||
function requirementView(data,project,selected,expandedSet=expandedPrds) {
|
||||
const options=data.requests.filter(r=>r.draft), r=options.find(x=>x.id===selected)||options[0], d=r?.draft;
|
||||
const north=typeof data.north?.content==='string'?data.north.content:null;
|
||||
const select=options.length>1?`<label>Request <select id="project-request-select">${options.map(x=>`<option value="${clean(x.id)}"${x===r?' selected':''}>${clean(x.id)} · ${clean(x.thread.name)}</option>`).join('')}</select></label>`:'';
|
||||
const candidate=d?.candidate, expanded=r && expandedPrds.has(project.id+':'+r.id);
|
||||
const candidate=d?.candidate, expanded=r && expandedSet.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>${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>`;
|
||||
@@ -241,31 +252,32 @@
|
||||
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];
|
||||
const focus=f.id ? root.querySelector('#'+CSS.escape(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}) {
|
||||
async function mountContent({container,project,threads,section,canSend,restore,roomReceipt,org=null,pane=false,views:paneState=null}) {
|
||||
if(!container || !project) return;
|
||||
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);
|
||||
const data=await gather(project,threads,org).catch(()=>null);
|
||||
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 views=viewState(container,pane,paneState), selectedRequests=views.selected, expandedPrds=views.expanded;
|
||||
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 view = section==='requirements'?requirementView(data,project,selection,expandedPrds):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, ['requirements','overview'].includes(section) ? 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>`);}
|
||||
if(header){const tab=key=>header.querySelector(`a[href$="${path(project.id,key).slice(1)}"]`), tasks=tab('tasks'), files=tab('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});
|
||||
window.MosaicOnboarding?.offer(container,{project,section,north:data.north,docs:data.docs,threads:data.threads,canSend}); // F2: its panel sits before the container, outside draw().
|
||||
if(!pane) window.MosaicOnboarding?.offer(container,{project,section,north:data.north,docs:data.docs,threads:data.threads,canSend}); // F2: its panel sits before the container, outside draw().
|
||||
if (contentPaints.get(container) === signature) return;
|
||||
contentPaints.set(container, signature);
|
||||
// Capture at commit time: the user may have interacted while the read was pending.
|
||||
@@ -273,7 +285,7 @@
|
||||
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);
|
||||
container.innerHTML=section==='requirements'?requirementView(data,project,selected,expandedPrds):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));
|
||||
const md=container.querySelector('[data-prd]'); if(md && selectedRequest?.draft) { md.replaceChildren(window.MosaicMarkdown?.render(selectedRequest.draft.prd) || document.createTextNode(selectedRequest.draft.prd)); const toggle=container.querySelector('.project-prd-toggle'); toggle.hidden=!md.classList.contains('expanded') && md.scrollHeight<=md.clientHeight; toggle.addEventListener('click',()=>{const key=project.id+':'+selectedRequest.id; if(expandedPrds.has(key)) expandedPrds.delete(key); else expandedPrds.add(key); const open=expandedPrds.has(key); md.classList.toggle('expanded',open); toggle.setAttribute('aria-expanded',String(open));toggle.textContent=open?'Show less':'Show full PRD';}); }
|
||||
@@ -285,7 +297,7 @@
|
||||
if (restore) {
|
||||
restoreView(container,restore);
|
||||
}
|
||||
const sectionTarget=new URLSearchParams(location.hash.split('?')[1] || '').get('section');
|
||||
const sectionTarget=pane ? null : new URLSearchParams(location.hash.split('?')[1] || '').get('section');
|
||||
if(section==='requirements' && ['prd','north-star'].includes(sectionTarget)) container.querySelector('#project-'+sectionTarget)?.scrollIntoView({block:'start'});
|
||||
}
|
||||
// Cut at a word boundary, unless that would leave only a markdown marker (a long heading).
|
||||
@@ -334,7 +346,7 @@
|
||||
}
|
||||
const palette=()=>treeRows.filter(p=>!p.archived && UUID.test(p.id)).flatMap(p=>sections.map(([key,label])=>({href:path(p.id,key),label:key==='overview'?p.name:`${p.name}: ${label}`,group:'project'})));
|
||||
function breadcrumb(box, view) {
|
||||
const h=view.querySelector('.project-header'); if(!h)return false;
|
||||
const h=view.querySelector('.project-header'); if(!h||h.closest('[data-pane]'))return false;
|
||||
const id=h.dataset.projectId, name=h.dataset.projectName, section=h.dataset.projectSection, conversation=h.dataset.projectConversation==='true';
|
||||
const title=sections.find(([key])=>key===section)?.[1] || (section==='settings'?'Settings':'Overview');
|
||||
const leaf=conversation?view.querySelector('h1')?.textContent:title;
|
||||
|
||||
@@ -191,8 +191,10 @@
|
||||
}).join('')}</ul>`;
|
||||
}
|
||||
|
||||
async function dashboard() {
|
||||
const id = window.MosaicOrg.id;
|
||||
// A side-by-side pane passes its own organization and { pane: true, idPrefix }: the pane has no bus
|
||||
// restart (that stays in the organization's full view) and its ids carry the pane's prefix.
|
||||
async function dashboard(id = window.MosaicOrg.id, opts = {}) {
|
||||
const pane = opts.pane === true, why = `${opts.idPrefix || ''}ws-reload-why`;
|
||||
// Only an organization the membership list names is read; anything else reads nothing more.
|
||||
const [listRead] = await Promise.all([data.organizations().catch(() => null), ensureThemes()]);
|
||||
const list = Array.isArray(listRead) ? listRead : null;
|
||||
@@ -210,11 +212,12 @@
|
||||
|
||||
// Header: the bus restart is console-restart.js's button where the Console allows it, otherwise
|
||||
// the same control, present and disabled, with the reason.
|
||||
const restart = window.MosaicConsole?.busButton?.('Restart bus') || '';
|
||||
const bus = restart ? `<span class="ws-busctl" data-dashboard-bus>${icon('restart', 'ws-busicon')}${restart}</span>` : `<span data-dashboard-bus><button type="button" class="ws-btn" data-bus-restart-off data-focus-key="bus-restart-off" disabled title="Restarting the bus is the owner's trailed action, on a Console started with send and the action trail">${icon('restart')}Restart bus</button></span>`;
|
||||
const reload = `<button type="button" class="ws-btn" data-org-reload data-focus-key="org-reload" disabled aria-describedby="ws-reload-why" title="Reload arrives with the cross-org update">${icon('doc')}Reload organization file</button>`;
|
||||
const restart = pane ? '' : window.MosaicConsole?.busButton?.('Restart bus') || '';
|
||||
const offTitle = pane ? "Restart the bus from this organization's full view" : "Restarting the bus is the owner's trailed action, on a Console started with send and the action trail";
|
||||
const bus = restart ? `<span class="ws-busctl" data-dashboard-bus>${icon('restart', 'ws-busicon')}${restart}</span>` : `<span data-dashboard-bus><button type="button" class="ws-btn" data-bus-restart-off data-focus-key="bus-restart-off" disabled title="${esc(offTitle)}">${icon('restart')}Restart bus</button></span>`;
|
||||
const reload = `<button type="button" class="ws-btn" data-org-reload data-focus-key="org-reload" disabled aria-describedby="${esc(why)}" title="Reload arrives with the cross-org update">${icon('doc')}Reload organization file</button>`;
|
||||
const [bcls, bglyph] = state(org);
|
||||
const meta = `<p class="ws-meta"><span>Kind not recorded</span><span>id <code>${esc(id)}</code></span><span>Tracker: ${tracker ? esc(titleCase(tracker)) : 'not set'}</span><span>${dot(bcls, bglyph, org.busHealthy ? `bus running, read ${clock()}` : `bus unavailable, read ${clock()}`, true)}</span><span id="ws-reload-why">Restart is a trailed human action; reload arrives with the cross-org update.</span></p>`;
|
||||
const meta = `<p class="ws-meta"><span>Kind not recorded</span><span>id <code>${esc(id)}</code></span><span>Tracker: ${tracker ? esc(titleCase(tracker)) : 'not set'}</span><span>${dot(bcls, bglyph, org.busHealthy ? `bus running, read ${clock()}` : `bus unavailable, read ${clock()}`, true)}</span><span id="${esc(why)}">Restart is a trailed human action; reload arrives with the cross-org update.</span></p>`;
|
||||
|
||||
const projectName = pid => projectRows?.find(p => p.id === pid)?.name || pid;
|
||||
const typechips = '<div class="ws-typechips ws-chips"><span class="ws-label">System types</span><span class="note">No system types yet. They arrive with the system types update.</span></div>';
|
||||
@@ -269,7 +272,9 @@
|
||||
const LEVEL_CLS = { root: 'root', org: 'org', project: 'proj' };
|
||||
const projectName = (rows, id) => { const p = Array.isArray(rows) ? rows.find(r => r?.id === id) : null; return p?.name || id; };
|
||||
let agentsPick = null; // the project chosen in "Available in a project", kept across repaints
|
||||
let agentsAvailable = null; // repaints that section alone when the choice changes
|
||||
// Repaints that section alone when the choice changes, one renderer per organization: a side-by-side
|
||||
// pane can show another organization's agents next to the page's own.
|
||||
let agentsAvailable = {};
|
||||
|
||||
function levelsTable(ctx, projectId) {
|
||||
const { selOrg, lv, mine, levels, modelOf, names, others } = ctx;
|
||||
@@ -322,9 +327,8 @@
|
||||
return `<div class="ws-tbl"><table><thead><tr><th scope="col">Agent</th><th scope="col">Level</th><th scope="col" class="hide-m">Defined in</th><th scope="col" class="hide-m">Memory</th><th scope="col">Status</th><th scope="col" class="hide-m">Model</th></tr></thead><tbody>${body}</tbody></table></div>${note}`;
|
||||
}
|
||||
|
||||
async function agentGrid(note = '') {
|
||||
async function agentGrid(note = '', selected = window.MosaicOrg?.id) {
|
||||
const [list, levels] = await Promise.all([data.organizations().catch(() => null), data.agentLevels().catch(() => null), ensureThemes()]);
|
||||
const selected = window.MosaicOrg?.id;
|
||||
const head = `<div class="ws-vh"><h1 tabindex="-1">Agents</h1><div class="ws-right"><a class="ws-btn" href="#/settings/agents" data-focus-key="agents-edit">${icon('settings')}Edit agents</a></div><p class="sub">Agents are defined at three levels: root, organization and project. Root and project agents are read from their files and are not launched yet.${note ? ` ${note}` : ''}</p></div>`;
|
||||
if (!Array.isArray(list)) return `<section class="ws ws-agents" data-org-agents>${head}<p class="ws-panel tight note">Organizations unavailable.</p></section>`;
|
||||
const valid = list.filter(org => !org.invalid);
|
||||
@@ -372,8 +376,8 @@
|
||||
const projectIds = lv?.state === 'ok' ? Object.keys(lv.projects) : [];
|
||||
const pick = projectIds.includes(agentsPick?.[selected]) ? agentsPick[selected] : projectIds[0] ?? null;
|
||||
const ctx = { selOrg, lv, mine, levels, modelOf, names: id => projectName(mine?.rows, id), others: valid.filter(o => o.id !== selected) };
|
||||
agentsAvailable = projectId => levelsTable(ctx, projectId);
|
||||
const chooser = projectIds.length ? `<label class="ws-psel">${av(selOrg, 'xs')}<span class="ws-sr">Project</span><select data-agents-project data-focus-key="agents-project">${projectIds.map(id => `<option value="${esc(id)}"${id === pick ? ' selected' : ''}>${esc(ctx.names(id))}</option>`).join('')}</select></label>` : '';
|
||||
agentsAvailable = { ...agentsAvailable, [selected]: projectId => levelsTable(ctx, projectId) };
|
||||
const chooser = projectIds.length ? `<label class="ws-psel">${av(selOrg, 'xs')}<span class="ws-sr">Project</span><select data-agents-project="${esc(selected)}" data-focus-key="agents-project">${projectIds.map(id => `<option value="${esc(id)}"${id === pick ? ' selected' : ''}>${esc(ctx.names(id))}</option>`).join('')}</select></label>` : '';
|
||||
const title = projectIds.length ? 'Available in a project' : `Agents in ${esc(label(selOrg))}`;
|
||||
const table = !mine ? '<p class="ws-panel tight note">The selected organization is not on the list.</p>' : levelsTable(ctx, pick);
|
||||
const rules = `<div class="ws-panel tight ws-rules"><p><span class="ws-lvl root">root</span>You define root agents in agents.json in your configuration directory. The chief cannot change them.</p><p><span class="ws-lvl org">org</span>Agents come from the organization file. Add, change or remove them under Settings, Agents.</p><p><span class="ws-lvl proj">project</span>A project in the organization file can add an agent, narrow an inherited agent's limits or exclude one. It never widens them.</p><p><span class="ws-lvl proj">override</span>The most specific level wins. A row says which definition it replaces.</p><p class="muted">An agent's harness, model and thinking are set under Settings, Routing.</p></div>`;
|
||||
@@ -383,10 +387,11 @@
|
||||
}
|
||||
document.addEventListener('change', e => {
|
||||
const select = e.target.closest?.('[data-agents-project]');
|
||||
const box = select && document.querySelector('[data-agents-available]');
|
||||
if (!box || !agentsAvailable || !window.MosaicOrg) return;
|
||||
agentsPick = { ...agentsPick, [window.MosaicOrg.id]: select.value };
|
||||
box.innerHTML = agentsAvailable(select.value);
|
||||
const org = select?.dataset.agentsProject, draw = org && Object.hasOwn(agentsAvailable, org) ? agentsAvailable[org] : null;
|
||||
const box = draw && select.closest('[data-org-agents]')?.querySelector('[data-agents-available]');
|
||||
if (!box) return;
|
||||
agentsPick = { ...agentsPick, [org]: select.value };
|
||||
box.innerHTML = draw(select.value);
|
||||
});
|
||||
|
||||
// ---------- across organizations (U3: samples 02, 04 and 05) ----------
|
||||
@@ -737,6 +742,6 @@
|
||||
|
||||
// U4b: the org mark (colour and initials) for the task board (tasks-board.js).
|
||||
const orgMark = Object.freeze({ ready: ensureThemes, av, colours, initials });
|
||||
window.MosaicWorkspace = Object.freeze({ dashboard, agentGrid, icon, orgMark, refresh: () => refresh(), home, projectsPage, inboxPage, setGrouping, get pollMs() { return pollMs; } });
|
||||
window.MosaicWorkspace = Object.freeze({ dashboard, agentGrid, icon, orgMark, refresh: () => refresh(), av, colours, label, state, dot, themes: ensureThemes, home, projectsPage, inboxPage, setGrouping, get pollMs() { return pollMs; } });
|
||||
refresh();
|
||||
})();
|
||||
@@ -60,12 +60,12 @@ const files = new Map([
|
||||
['/', ['index.html', 'text/html; charset=utf-8']],
|
||||
['/proxy.js', ['proxy.js', 'text/javascript; charset=utf-8']],
|
||||
['/project-remote.js', ['project-remote.js', 'text/javascript; charset=utf-8']],
|
||||
...['app.js', 'brand.js', 'bus.js', 'markdown-preview.js', 'chat.js', 'project-archive.js', 'processing.js', 'agents.js', 'federation.js', 'requests.js', 'org-create.js', 'console-restart.js', 'onboarding.js', 'workspace-data.js', 'workspace.js', 'tasks-board.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
|
||||
...['app.js', 'brand.js', 'bus.js', 'markdown-preview.js', 'chat.js', 'project-archive.js', 'processing.js', 'agents.js', 'federation.js', 'requests.js', 'org-create.js', 'console-restart.js', 'onboarding.js', 'workspace-data.js', 'workspace.js', 'tasks-board.js', 'panes.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
|
||||
['/files.js', ['files.js', 'text/javascript; charset=utf-8']],
|
||||
['/projects.js', ['projects.js', 'text/javascript; charset=utf-8']],
|
||||
['/projects.css', ['projects.css', 'text/css; charset=utf-8']],
|
||||
['/files.css', ['files.css', 'text/css; charset=utf-8']],
|
||||
...['shared/app.css', 'tokens.css', 'console.css', 'live.css', 'bus.css', 'shell.css', 'chat.css', 'project-archive.css', 'processing.css', 'org-create.css', 'onboarding.css', 'tasks-board.css'].map(f => ['/' + f, [f, 'text/css; charset=utf-8']]),
|
||||
...['shared/app.css', 'tokens.css', 'console.css', 'live.css', 'bus.css', 'shell.css', 'chat.css', 'project-archive.css', 'processing.css', 'org-create.css', 'onboarding.css', 'tasks-board.css', 'panes.css'].map(f => ['/' + f, [f, 'text/css; charset=utf-8']]),
|
||||
['/display.js', ['display.js', 'text/javascript; charset=utf-8']],
|
||||
['/inbox.js', ['inbox.js', 'text/javascript; charset=utf-8']],
|
||||
['/inbox.css', ['inbox.css', 'text/css; charset=utf-8']],
|
||||
|
||||
@@ -88,6 +88,6 @@ test('a refused root file hides the root level only, and says why without a host
|
||||
writeFileSync(join(dir, 'agents.json'), JSON.stringify({ agentsVersion: 1, agents: { x: { definition: 'reviewer', vars: { thinking: 'sk-secret-marker-7731' } } } }));
|
||||
const badVar = await call({ owner: true, session: {}, organizations: { members }, levels: { ...levels, loadRoot: () => loadRootAgents(dir, { rolesDir }) } });
|
||||
assert.equal(badVar.body.root.state, 'invalid');
|
||||
assert.match(badVar.body.root.reason, /variable thinking has a value it can't take/);
|
||||
assert.match(badVar.body.root.reason, /variable thinking must be one of/);
|
||||
assert.doesNotMatch(JSON.stringify(badVar.body), /secret-marker/);
|
||||
});
|
||||
@@ -18,8 +18,8 @@ test('served Console browser: real board fixtures, keyboard, drafts, receipts, t
|
||||
assert.equal(await b.evaluate('document.querySelector("#session-count").textContent'), '3 of 4');
|
||||
await b.evaluate('document.querySelector("#hide-offline").click()');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("table.sessions tbody tr").length'), 4);
|
||||
// Rows 129, 154, 191, 194 and 294: chat.js, editor.js, processing.js, agents.js and federation.js are the scripts added, and #1839 adds requests.js (the intake panel under an owner's chat message), all same origin; the list is the pin, so another script is a visible change. #797 adds project-archive.js immediately after chat.js. F2 adds onboarding.js after org-create.js, and F3 adds console-restart.js after it. U2 (#1873) adds workspace-data.js and workspace.js before console-restart.js, and U4b adds tasks-board.js after workspace.js.
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/processing.js', '/org-create.js', '/onboarding.js', '/workspace-data.js', '/workspace.js', '/tasks-board.js', '/console-restart.js', '/display.js', '/inbox.js', '/files.js', '/project-remote.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/projects.js', '/chat.js', '/project-archive.js', '/agents.js', '/bus.js', '/requests.js']);
|
||||
// Rows 129, 154, 191, 194 and 294: chat.js, editor.js, processing.js, agents.js and federation.js are the scripts added, and #1839 adds requests.js (the intake panel under an owner's chat message), all same origin; the list is the pin, so another script is a visible change. #797 adds project-archive.js immediately after chat.js. F2 adds onboarding.js after org-create.js, and F3 adds console-restart.js after it. U2 (#1873) adds workspace-data.js and workspace.js before console-restart.js, and U4b adds tasks-board.js after workspace.js. U5 (#1873) adds panes.js (Side by side) just before bus.js, which mounts it.
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/processing.js', '/org-create.js', '/onboarding.js', '/workspace-data.js', '/workspace.js', '/tasks-board.js', '/console-restart.js', '/display.js', '/inbox.js', '/files.js', '/project-remote.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/projects.js', '/chat.js', '/project-archive.js', '/agents.js', '/panes.js', '/bus.js', '/requests.js']);
|
||||
assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/processing.js"]').length`), 1);
|
||||
assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/chat.js"]').length`), 1);
|
||||
assert.equal(await b.evaluate('!!window.injected'), false);
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
// U5 (#1873, sample 07): Side by side shows two panes, each holding one page of any organization.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { createDevOwner } from '../src/dev-owner.mjs';
|
||||
import { browser } from './browser.mjs';
|
||||
import { close } from './fixture.mjs';
|
||||
|
||||
const waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`);
|
||||
|
||||
// Three organizations: two running buses and one stopped (home). `state.flip` changes what the
|
||||
// running organizations report, so a refresh repaints both panes.
|
||||
function orgs(state = { flip: false, seen: [] }) {
|
||||
const ORGS = [
|
||||
{ id: 'ddigital', label: 'Distinctly Digital', theme: 'rosewood', busHealthy: true },
|
||||
{ id: 'forge', label: 'Forge', theme: 'cobalt', busHealthy: true },
|
||||
{ id: 'home', label: 'Home', theme: 'grove', busHealthy: false },
|
||||
];
|
||||
const PROJECTS = { ddigital: [{ id: 'spring-launch', name: 'Spring launch' }, { id: 'brand-refresh', name: 'Brand refresh' }], forge: [{ id: 'api-gateway', name: 'API gateway' }], home: [] };
|
||||
const INSTANCES = { ddigital: ['chief', 'copywriter', 'producer', 'editor', 'researcher', 'designer', 'analyst', 'scheduler'], forge: ['chief', 'builder', 'tester', 'reviewer', 'operator', 'scribe'] };
|
||||
const THREADS = { ddigital: [{ id: 't0', name: 'Kickoff notes', project: 'spring-launch', instance: 'chief' }, { id: 't1', name: 'Launch email', project: 'spring-launch', instance: 'copywriter' }], forge: [{ id: 'f0', name: 'Gateway auth', project: 'api-gateway', instance: 'chief' }] };
|
||||
const instances = id => ({ business: id, revision: 'a'.repeat(64), launch: true, noHire: [], retired: [], definitions: {},
|
||||
instances: Object.fromEntries(INSTANCES[id].map((n, i) => [n, { definition: n, vars: {}, launched: true, launcher: false, protected: false, kept: false, running: [`r${i}`], alive: [] }])) });
|
||||
const agents = id => INSTANCES[id].map((role, i) => ({ role, holder_run: `r${i}`, state: (i % 2 === 0) === state.flip ? 'working' : 'waiting' }));
|
||||
const business = id => ({ business: { id, human: 'jason', arbiters: ['chief'], projects: PROJECTS[id].map(p => p.id), vars: {}, instances: INSTANCES[id].map(n => ({ instance: n, vars: {} })), appearance: { theme: ORGS.find(o => o.id === id).theme } } });
|
||||
return {
|
||||
list: async () => ORGS,
|
||||
resolve: async id => {
|
||||
state.seen.push(id);
|
||||
const org = ORGS.find(o => o.id === id); if (!org) return null;
|
||||
if (!org.busHealthy) return { busHealthy: false, reads: null, bus: {}, chat: { projects: async () => [], threads: async () => [] } };
|
||||
return {
|
||||
busHealthy: true,
|
||||
reads: { business: async () => business(id), settings: async () => ({ release: '0.0.42', launch: { limits: { total: 4 } } }), queue: async () => ({ rows: [] }), busStatus: async () => ({}) },
|
||||
bus: { instances: async () => instances(id), messages: async () => ({ messages: [], unread: 0 }), agents: async () => agents(id), 'thread.claims': async () => [], inbox: async () => [], tasks: async () => [] },
|
||||
chat: { projects: async () => PROJECTS[id], threads: async () => THREADS[id] },
|
||||
projectFiles: async () => ({ head: 'a'.repeat(40), files: [] }),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function open(t, { width = 1440, height = 900, hash = '#/orgs/ddigital/side-by-side', state } = {}) {
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), organizations: orgs(state), log: () => {} }); t.after(() => close(server));
|
||||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||
await b.viewport(width, height);
|
||||
const base = `http://127.0.0.1:${server.address().port}/`;
|
||||
await b.navigate(base + hash);
|
||||
return { b, base, wait: waitFor(b) };
|
||||
}
|
||||
const text = key => `(document.querySelector("#pane-${key} [data-pane-body]")?.innerText || "")`;
|
||||
const title = key => `(document.querySelector("#pane-${key} [data-pane-ttl]")?.textContent || "")`;
|
||||
const query = '(() => Object.fromEntries(new URLSearchParams(location.hash.split("?")[1] || "")))()';
|
||||
const pick = (b, key, value) => b.evaluate(`(() => { const s = document.querySelector("#pane-${key}-pick"); s.value = ${JSON.stringify(value)}; if (s.value !== ${JSON.stringify(value)}) throw Error("no option " + ${JSON.stringify(value)}); s.dispatchEvent(new Event("change", { bubbles: true })); })()`);
|
||||
|
||||
test('the Side by side nav item opens two panes with different organizations, and the URL restores both after a reload', async t => {
|
||||
const state = { flip: false, seen: [] };
|
||||
const { b, base, wait } = await open(t, { hash: '#/orgs/ddigital/organization', state });
|
||||
await wait('document.querySelector("[data-org-dashboard]")');
|
||||
await b.evaluate('[...document.querySelectorAll("#sections > li > a")].find(a => a.textContent.trim() === "Side by side").click()');
|
||||
await wait(`document.querySelectorAll(".panes > .pane:not([hidden])").length === 2 && ${title('a')}.includes("Distinctly Digital") && ${title('b')}.includes("Forge")`);
|
||||
assert.doesNotMatch(await b.evaluate('document.getElementById("bus-view").innerText'), /Side by side is not built yet/i);
|
||||
assert.equal(await b.evaluate('document.querySelector("#bus-view h1").textContent'), 'Side by side');
|
||||
assert.equal(await b.evaluate('document.title'), 'Side by side · Mosaic Console');
|
||||
assert.deepEqual(await b.evaluate(query), { a: 'ddigital/organization', b: 'forge/organization', w: '50', show: 'a' });
|
||||
await wait(`${text('a')}.includes("Distinctly Digital") && ${text('b')}.includes("Forge")`);
|
||||
// Each pane's links name its own organization.
|
||||
assert.equal(await b.evaluate('[...document.querySelectorAll("#pane-b [data-pane-body] a[href^=\\"#/\\"]")].every(a => a.getAttribute("href").startsWith("#/orgs/forge/"))'), true);
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#pane-a [data-pane-body] h1[aria-level=\\"2\\"]").length > 0'), true);
|
||||
// The picker lists every organization and its projects.
|
||||
const groups = await b.evaluate('[...document.querySelectorAll("#pane-b-pick optgroup")].map(g => g.label)');
|
||||
assert.deepEqual(groups, ['Distinctly Digital', 'Forge', 'Home · bus unavailable']);
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#pane-b-pick option")].map(o => o.value).filter(Boolean)'),
|
||||
['ddigital/organization', 'ddigital/agents', 'ddigital/projects/spring-launch', 'ddigital/projects/brand-refresh', 'forge/organization', 'forge/agents', 'forge/projects/api-gateway', 'home/organization', 'home/agents']);
|
||||
// Pick a project in the right pane; the left keeps its page.
|
||||
await pick(b, 'b', 'forge/projects/api-gateway');
|
||||
await wait(`${title('b')}.startsWith("API gateway") && ${text('b')}.includes("API gateway") && location.hash.includes("b=forge/projects/api-gateway")`);
|
||||
assert.match(await b.evaluate(title('a')), /^Dashboard/);
|
||||
// A project tab opens in the pane, not the full view.
|
||||
await b.evaluate('[...document.querySelectorAll("#pane-b nav.project-tabs a")].find(a => /Tasks/.test(a.textContent)).click()');
|
||||
await wait(`location.hash.includes("b=forge/projects/api-gateway/tasks") && ${title('b')}.includes("Tasks")`);
|
||||
assert.match(await b.evaluate('location.hash'), /^#\/orgs\/ddigital\/side-by-side\?/);
|
||||
// Reload: both panes come back from the URL.
|
||||
await b.call('Page.reload');
|
||||
await wait('document.readyState === "complete"');
|
||||
await wait(`${title('a')}.startsWith("Dashboard") && ${title('a')}.includes("Distinctly Digital") && ${title('b')}.includes("API gateway") && ${title('b')}.includes("Tasks")`);
|
||||
assert.deepEqual(await b.evaluate(query), { a: 'ddigital/organization', b: 'forge/projects/api-gateway/tasks', w: '50', show: 'a' });
|
||||
// A shared link opens exactly what it names, whatever this browser remembered.
|
||||
await b.navigate(`${base}#/orgs/forge/side-by-side?a=forge/agents&b=ddigital/projects/spring-launch&w=40&show=b`);
|
||||
await wait(`${title('a')}.startsWith("Agents") && ${title('a')}.includes("Forge") && ${title('b')}.startsWith("Spring launch")`);
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-sbs-divider]").getAttribute("aria-valuenow")'), '40');
|
||||
// A link with a route a pane does not draw falls back to the remembered layout or the default.
|
||||
await b.navigate(`${base}#/orgs/ddigital/side-by-side?a=ddigital/settings&b=nope!/organization`);
|
||||
await wait(`document.querySelectorAll(".panes > .pane:not([hidden])").length === 2 && ${title('a')}.length > 0 && !location.hash.includes("settings")`);
|
||||
// Leaving the page stops both panes reading.
|
||||
await b.evaluate('location.hash = "#/orgs/ddigital/agents"');
|
||||
await wait('!document.querySelector("[data-side-by-side]") && [...document.querySelectorAll("#bus-view h1")].some(h => h.textContent.trim() === "Agents")');
|
||||
assert.ok(state.seen.includes('forge') && state.seen.includes('ddigital'));
|
||||
});
|
||||
|
||||
test('the divider resizes the panes by mouse and by keyboard', async t => {
|
||||
const { b, wait } = await open(t);
|
||||
await wait(`${title('a')}.length > 0 && ${title('b')}.length > 0`);
|
||||
const sep = '[role=separator][data-sbs-divider]';
|
||||
assert.deepEqual(await b.evaluate(`(() => { const d = document.querySelector("${sep}"); return [d.getAttribute("aria-valuenow"), d.getAttribute("aria-valuemin"), d.getAttribute("aria-valuemax"), d.getAttribute("aria-orientation"), d.tabIndex]; })()`), ['50', '25', '75', 'vertical', 0]);
|
||||
const ratio = `(() => { const a = document.getElementById("pane-a").getBoundingClientRect().width, b = document.getElementById("pane-b").getBoundingClientRect().width; return Math.round(a / (a + b) * 100); })()`;
|
||||
// Mouse: drag the divider to 30% of the panes' width.
|
||||
const box = await b.evaluate(`(() => { const p = document.querySelector("[data-sbs-panes]").getBoundingClientRect(), d = document.querySelector("${sep}").getBoundingClientRect(); return { left: p.left, width: p.width, x: d.left + d.width / 2, y: d.top + 200 }; })()`);
|
||||
const target = box.left + box.width * 0.3;
|
||||
await b.call('Input.dispatchMouseEvent', { type: 'mouseMoved', x: box.x, y: box.y });
|
||||
await b.call('Input.dispatchMouseEvent', { type: 'mousePressed', x: box.x, y: box.y, button: 'left', buttons: 1, clickCount: 1 });
|
||||
for (const step of [0.25, 0.5, 0.75, 1]) await b.call('Input.dispatchMouseEvent', { type: 'mouseMoved', x: box.x + (target - box.x) * step, y: box.y, button: 'left', buttons: 1 });
|
||||
await b.call('Input.dispatchMouseEvent', { type: 'mouseReleased', x: target, y: box.y, button: 'left', buttons: 0, clickCount: 1 });
|
||||
await wait(`document.querySelector("${sep}").getAttribute("aria-valuenow") === "30" && location.hash.includes("w=30")`);
|
||||
assert.ok(Math.abs(await b.evaluate(ratio) - 30) <= 1);
|
||||
// Keyboard: the separator has focus after the drag; arrows step by 5, Home and End reach the limits.
|
||||
assert.equal(await b.evaluate('document.activeElement.getAttribute("role")'), 'separator');
|
||||
await b.call('Input.dispatchKeyEvent', { type: 'keyDown', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 });
|
||||
await b.call('Input.dispatchKeyEvent', { type: 'keyUp', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 });
|
||||
await wait(`document.querySelector("${sep}").getAttribute("aria-valuenow") === "35"`);
|
||||
await b.call('Input.dispatchKeyEvent', { type: 'keyDown', key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 });
|
||||
await b.call('Input.dispatchKeyEvent', { type: 'keyUp', key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 });
|
||||
await b.call('Input.dispatchKeyEvent', { type: 'keyDown', key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 });
|
||||
await b.call('Input.dispatchKeyEvent', { type: 'keyUp', key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 });
|
||||
await wait(`document.querySelector("${sep}").getAttribute("aria-valuenow") === "25"`);
|
||||
await b.call('Input.dispatchKeyEvent', { type: 'keyDown', key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 });
|
||||
await b.call('Input.dispatchKeyEvent', { type: 'keyUp', key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 });
|
||||
assert.equal(await b.evaluate(`document.querySelector("${sep}").getAttribute("aria-valuenow")`), '25');
|
||||
await b.call('Input.dispatchKeyEvent', { type: 'keyDown', key: 'End', code: 'End', windowsVirtualKeyCode: 35 });
|
||||
await b.call('Input.dispatchKeyEvent', { type: 'keyUp', key: 'End', code: 'End', windowsVirtualKeyCode: 35 });
|
||||
await wait(`document.querySelector("${sep}").getAttribute("aria-valuenow") === "75" && location.hash.includes("w=75")`);
|
||||
assert.ok(Math.abs(await b.evaluate(ratio) - 75) <= 1);
|
||||
// Closing a pane goes back to one; the divider leaves with it. Add pane brings a second back.
|
||||
await b.evaluate('document.querySelector("#pane-a [data-pane-close]").click()');
|
||||
await wait(`document.getElementById("pane-b").hidden && document.querySelector("${sep}").hidden && !new URLSearchParams(location.hash.split("?")[1]).has("b") && ${title('a')}.includes("Forge")`);
|
||||
assert.equal(await b.evaluate('document.activeElement.id'), 'pane-a-pick');
|
||||
assert.equal(await b.evaluate('document.querySelector("#pane-a [data-pane-close]").hidden'), true);
|
||||
assert.match(await b.evaluate('document.querySelector("[data-sbs-note]").textContent'), /^1 pane ·/);
|
||||
await b.evaluate('document.querySelector("[data-pane-add]").click()');
|
||||
await wait(`!document.getElementById("pane-b").hidden && ${title('b')}.length > 0 && new URLSearchParams(location.hash.split("?")[1]).has("b")`);
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-pane-add]").disabled'), true);
|
||||
});
|
||||
|
||||
test('each pane keeps its own focus and scroll through a refresh', async t => {
|
||||
const state = { flip: false, seen: [] };
|
||||
const { b, wait } = await open(t, { height: 520, state, hash: '#/orgs/ddigital/side-by-side?a=ddigital/agents&b=forge/agents' });
|
||||
await wait(`${text('a')}.includes("copywriter") && ${text('b')}.includes("builder")`);
|
||||
const scrollable = await b.evaluate('[...document.querySelectorAll("[data-pane-body]")].map(el => el.scrollHeight - el.clientHeight)');
|
||||
assert.ok(scrollable.every(n => n > 60), `both panes scroll: ${scrollable}`);
|
||||
const focused = await b.evaluate('(() => { const a = document.querySelector("#pane-a [data-pane-body] a[href]"); a.focus(); return a.getAttribute("href"); })()');
|
||||
await b.evaluate('document.querySelector("#pane-a [data-pane-body]").scrollTop = 40; document.querySelector("#pane-b [data-pane-body]").scrollTop = 60;');
|
||||
const before = await b.evaluate(`[${text('a')}, ${text('b')}]`);
|
||||
state.flip = true;
|
||||
await b.evaluate('document.getElementById("refresh").click()');
|
||||
await wait(`${text('a')} !== ${JSON.stringify(before[0])} && ${text('b')} !== ${JSON.stringify(before[1])}`);
|
||||
assert.equal(await b.evaluate('document.querySelector("#pane-a").contains(document.activeElement) && document.activeElement.getAttribute("href")'), focused);
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-pane-body]")].map(el => el.scrollTop)'), [40, 60]);
|
||||
// Focus in the right pane stays there while the left repaints.
|
||||
const right = await b.evaluate('(() => { const a = document.querySelector("#pane-b [data-pane-body] a[href]"); a.focus({ preventScroll: true }); return a.getAttribute("href"); })()');
|
||||
state.flip = false;
|
||||
await b.evaluate('document.getElementById("refresh").click()');
|
||||
await wait(`${text('a')} === ${JSON.stringify(before[0])}`);
|
||||
assert.equal(await b.evaluate('document.querySelector("#pane-b").contains(document.activeElement) && document.activeElement.getAttribute("href")'), right);
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-pane-body]")].map(el => el.scrollTop)'), [40, 60]);
|
||||
});
|
||||
|
||||
test('a pane whose organization is down shows its state and the other pane still works', async t => {
|
||||
const { b, wait } = await open(t, { hash: '#/orgs/ddigital/side-by-side?a=home/organization&b=forge/organization' });
|
||||
await wait(`${title('a')}.includes("Home") && ${title('b')}.includes("Forge") && ${text('b')}.includes("Forge")`);
|
||||
await wait('/Home: bus unavailable/.test(document.querySelector("#pane-a [data-pane-line]").textContent)');
|
||||
assert.equal(await b.evaluate('document.querySelector("#pane-a [data-pane-line]").getAttribute("role")'), 'status');
|
||||
assert.equal(await b.evaluate('document.querySelector("#pane-a [data-pane-state]").innerText.trim().length > 0'), true);
|
||||
assert.equal(await b.evaluate('document.querySelector("#pane-b [data-pane-line]").hidden'), true);
|
||||
await pick(b, 'b', 'forge/agents');
|
||||
await wait(`${title('b')}.startsWith("Agents") && ${text('b')}.includes("builder")`);
|
||||
await pick(b, 'b', 'forge/projects/api-gateway');
|
||||
await wait(`${title('b')}.startsWith("API gateway") && ${text('b')}.includes("API gateway")`);
|
||||
assert.match(await b.evaluate(title('a')), /Home/);
|
||||
// An organization that is not on the list is one honest line; nothing is read for it.
|
||||
await b.evaluate('location.hash = "#/orgs/ddigital/side-by-side?a=ghost/organization&b=forge/agents"');
|
||||
await wait(`/ghost is not on your organization list/.test(document.querySelector("#pane-a [data-pane-line]").textContent) && ${text('b')}.includes("builder")`);
|
||||
});
|
||||
|
||||
test('on a phone one pane shows at a time, switched from the bar, with no horizontal scroll', async t => {
|
||||
const { b, wait } = await open(t, { width: 390, height: 844 });
|
||||
await wait(`${title('a')}.length > 0 && ${title('b')}.length > 0 && ${text('a')}.length > 0`);
|
||||
const visible = '[...document.querySelectorAll(".panes > .pane")].filter(p => p.getBoundingClientRect().height > 0).map(p => p.id)';
|
||||
assert.deepEqual(await b.evaluate(visible), ['pane-a']);
|
||||
assert.notEqual(await b.evaluate('getComputedStyle(document.querySelector("[data-sbs-tabs]")).display'), 'none');
|
||||
assert.equal(await b.evaluate('getComputedStyle(document.querySelector("[data-sbs-divider]")).display'), 'none');
|
||||
assert.equal(await b.evaluate('getComputedStyle(document.querySelector("[data-pane-add]")).display'), 'none');
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-sbs-tabs] button")].map(x => x.getAttribute("aria-pressed"))'), ['true', 'false']);
|
||||
assert.ok(await b.evaluate('document.documentElement.scrollWidth <= innerWidth'), 'no horizontal scroll');
|
||||
await b.evaluate('document.querySelector("[data-sbs-tabs] [data-pane-show=b]").click()');
|
||||
await wait(`JSON.stringify(${visible}) === '["pane-b"]' && location.hash.includes("show=b")`);
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-sbs-tabs] button")].map(x => x.getAttribute("aria-pressed"))'), ['false', 'true']);
|
||||
assert.ok(await b.evaluate('document.documentElement.scrollWidth <= innerWidth'), 'no horizontal scroll on the second pane');
|
||||
await pick(b, 'b', 'ddigital/projects/spring-launch');
|
||||
await wait(`${text('b')}.includes("Spring launch")`);
|
||||
assert.ok(await b.evaluate('document.documentElement.scrollWidth <= innerWidth'), 'no horizontal scroll on a project page');
|
||||
});
|
||||
|
||||
// Review fixes (#1873 U5 review, findings 1-4). `hold(url)` makes the page's next read of that URL
|
||||
// wait until `release()`; the test then changes the pane while the read is pending.
|
||||
const hold = url => `(() => { const real = window.fetch; window.held = { url: ${JSON.stringify(url)}, pending: false };
|
||||
window.fetch = async (u, o) => { if (window.held?.url === u && !window.held.done) { window.held.pending = true; await new Promise(r => { window.held.release = () => { window.held.done = true; r(); }; }); } return real(u, o); }; })()`;
|
||||
const settled = b => b.evaluate('new Promise(r => setTimeout(r, 400))');
|
||||
|
||||
test('a pane closed and opened again rewrites new links for its current organization only', async t => {
|
||||
const { b, wait } = await open(t);
|
||||
await wait(`${title('b')}.includes("Forge") && document.querySelector("#pane-b-pick")?.options.length > 4`);
|
||||
await b.evaluate('document.querySelector("#pane-b [data-pane-close]").click()');
|
||||
await wait('document.querySelector("#pane-b").hidden && !document.querySelector("[data-pane-add]").disabled');
|
||||
await b.evaluate('document.querySelector("[data-pane-add]").click()');
|
||||
await wait(`!document.querySelector("#pane-b").hidden && ${title('b')}.length > 0`);
|
||||
await pick(b, 'b', 'ddigital/organization');
|
||||
await wait(`${title('b')}.includes("Distinctly Digital") && location.hash.includes("b=ddigital/organization")`);
|
||||
// Content a page appends after its own reads (projects.js does this) gets the pane's organization.
|
||||
await b.evaluate(`document.querySelector("#pane-b [data-pane-body]").insertAdjacentHTML("beforeend", '<a data-late href="#/docs/example">late</a><a data-late-api href="/api/files/x">file</a>')`);
|
||||
await wait('document.querySelector("#pane-b [data-late]").getAttribute("href").startsWith("#/orgs/")');
|
||||
assert.equal(await b.evaluate('document.querySelector("#pane-b [data-late]").getAttribute("href")'), '#/orgs/ddigital/docs/example');
|
||||
assert.equal(await b.evaluate('document.querySelector("#pane-b [data-late-api]").getAttribute("href")'), '/api/orgs/ddigital/files/x');
|
||||
// Leaving the page stops every pane observer: later content in the old element is left alone.
|
||||
await b.evaluate('location.hash = "#/orgs/ddigital/agents"');
|
||||
await wait('!document.querySelector("[data-side-by-side]")');
|
||||
});
|
||||
|
||||
test('a slow project read never paints over the page or organization chosen since', async t => {
|
||||
const { b, wait } = await open(t);
|
||||
await wait(`${title('b')}.includes("Forge") && document.querySelector("#pane-b-pick")?.options.length > 4`);
|
||||
// Page change while the project's thread read is pending.
|
||||
await b.evaluate(hold('/api/orgs/forge/chat/threads'));
|
||||
await pick(b, 'b', 'forge/projects/api-gateway');
|
||||
await wait('window.held.pending');
|
||||
await pick(b, 'b', 'forge/agents');
|
||||
await wait(`${title('b')}.startsWith("Agents") && ${text('b')}.includes("builder")`);
|
||||
await b.evaluate('window.held.release()');
|
||||
await settled(b);
|
||||
assert.equal(await b.evaluate(`${text('b')}.includes("API gateway")`), false, 'the old project did not replace Agents');
|
||||
assert.match(await b.evaluate(title('b')), /^Agents/);
|
||||
// Organization change while the read is pending: the old organization's project is not painted.
|
||||
await b.evaluate(hold('/api/orgs/forge/chat/threads'));
|
||||
await pick(b, 'b', 'forge/projects/api-gateway');
|
||||
await wait('window.held.pending');
|
||||
await pick(b, 'b', 'ddigital/projects/spring-launch');
|
||||
await wait(`${title('b')}.startsWith("Spring launch") && ${text('b')}.includes("Spring launch")`);
|
||||
await b.evaluate('window.held.release()');
|
||||
await settled(b);
|
||||
assert.equal(await b.evaluate(`${text('b')}.includes("API gateway")`), false, 'the old organization did not replace Spring launch');
|
||||
assert.equal(await b.evaluate('[...document.querySelectorAll("#pane-b [data-pane-body] a[href^=\\"#/orgs/\\"]")].every(a => a.getAttribute("href").startsWith("#/orgs/ddigital/"))'), true);
|
||||
});
|
||||
|
||||
test('the same project in both panes keeps a request choice per pane', async t => {
|
||||
const { b, wait } = await open(t);
|
||||
await wait(`${title('b')}.includes("Forge")`);
|
||||
const { stub, P } = await import('./helpers/projects-review-fixture.mjs');
|
||||
// The fixture answers the project reads of both organizations; the organization list stays real.
|
||||
await b.evaluate(stub.replace('const path=String(url),method', `if(url==='/api/orgs'||url==='/api/themes')return real(url,init);const path=String(url).replace(/^\\/api\\/orgs\\/(?:ddigital|forge)\\//,'/api/'),method`));
|
||||
await b.evaluate('window.MosaicPanes.refresh()');
|
||||
await wait(`document.querySelector('#pane-a-pick option[value="ddigital/projects/${P}"]')`);
|
||||
await b.evaluate(`location.hash = '#/orgs/ddigital/side-by-side?a=ddigital/projects/${P}/requirements&b=forge/projects/${P}/requirements'`);
|
||||
const values = `[...document.querySelectorAll('[data-pane-body] #project-request-select')].map(e => e.value)`;
|
||||
await wait(`document.querySelectorAll('[data-pane-body] #project-request-select').length === 2`);
|
||||
assert.deepEqual(await b.evaluate(values), ['request-1', 'request-1']);
|
||||
await b.evaluate(`(() => { const e = document.querySelector('#pane-a #project-request-select'); e.value = 'request-2'; e.dispatchEvent(new Event('change', { bubbles: true })); })()`);
|
||||
await wait(`${values}[0] === 'request-2'`);
|
||||
await b.evaluate('window.MosaicPanes.refresh()');
|
||||
await settled(b);
|
||||
await wait(`document.querySelectorAll('[data-pane-body] #project-request-select').length === 2`);
|
||||
assert.deepEqual(await b.evaluate(values), ['request-2', 'request-1']);
|
||||
});
|
||||
|
||||
test('a pane keeps its request choice and PRD disclosure when a new conversation repaints the project', async t => {
|
||||
const { b, wait } = await open(t);
|
||||
await wait(`${title('b')}.includes("Forge")`);
|
||||
const { stub, P } = await import('./helpers/projects-review-fixture.mjs');
|
||||
await b.evaluate(stub.replace('const path=String(url),method', `if(url==='/api/orgs'||url==='/api/themes')return real(url,init);const path=String(url).replace(/^\\/api\\/orgs\\/(?:ddigital|forge)\\//,'/api/'),method`));
|
||||
await b.evaluate('window.MosaicPanes.refresh()');
|
||||
await wait(`document.querySelector('#pane-a-pick option[value="ddigital/projects/${P}"]')`);
|
||||
// The same organization and project in both panes.
|
||||
await b.evaluate(`location.hash = '#/orgs/ddigital/side-by-side?a=ddigital/projects/${P}/requirements&b=ddigital/projects/${P}/requirements'`);
|
||||
await wait(`document.querySelectorAll('[data-pane-body] #project-request-select').length === 2 && document.querySelectorAll('[data-pane-body] .project-prd-toggle').length === 2`);
|
||||
const state = `({ selected: [...document.querySelectorAll('[data-pane-body] #project-request-select')].map(e => e.value), expanded: [...document.querySelectorAll('[data-pane-body] [data-prd]')].map(e => e.classList.contains('expanded')) })`;
|
||||
await b.evaluate(`(() => { const e = document.querySelector('#pane-a #project-request-select'); e.value = 'request-2'; e.dispatchEvent(new Event('change', { bubbles: true })); })()`);
|
||||
await wait(`document.querySelector('#pane-a #project-request-select').value === 'request-2'`);
|
||||
await b.evaluate(`document.querySelector('#pane-a .project-prd-toggle').click()`);
|
||||
await wait(`document.querySelector('#pane-a [data-prd]')?.classList.contains('expanded')`);
|
||||
// A new conversation changes the header's count, so each pane replaces its project content.
|
||||
const before = await b.evaluate(`document.querySelector('#pane-a .project-header p')?.textContent || ''`);
|
||||
await b.evaluate(`window.fixtureThreads.push({ ...window.fixtureThreads[0], id: '20000000-0000-4000-8000-000000000004', name: 'A new conversation' }); window.MosaicPanes.refresh()`);
|
||||
await wait(`(() => { const t = document.querySelector('#pane-a .project-header p')?.textContent || ''; return t !== ${JSON.stringify(before)} && /4 conversations/.test(t) && /4 conversations/.test(document.querySelector('#pane-b .project-header p')?.textContent || ''); })()`);
|
||||
await settled(b);
|
||||
await wait(`document.querySelectorAll('[data-pane-body] #project-request-select').length === 2 && document.querySelectorAll('[data-pane-body] [data-prd]').length === 2`);
|
||||
assert.deepEqual(await b.evaluate(state), { selected: ['request-2', 'request-1'], expanded: [true, false] });
|
||||
});
|
||||
|
||||
test('a pane navigation that finishes late does not take focus from the other pane', async t => {
|
||||
const { b, wait } = await open(t);
|
||||
await wait(`document.querySelector('#pane-a [data-focus-key="agents-all"]') && document.querySelector('#pane-b-pick')?.options.length > 4`);
|
||||
await b.evaluate(hold('/api/orgs/ddigital/bus/instances'));
|
||||
await b.evaluate(`document.querySelector('#pane-a [data-focus-key="agents-all"]').click()`);
|
||||
await wait('window.held.pending');
|
||||
await b.evaluate(`document.querySelector('#pane-b-pick').focus(); window.held.release()`);
|
||||
await wait(`${title('a')}.startsWith('Agents')`);
|
||||
await settled(b);
|
||||
assert.equal(await b.evaluate('document.activeElement.id'), 'pane-b-pick');
|
||||
// Without a move elsewhere, the same navigation still lands on the new page's heading.
|
||||
await pick(b, 'a', 'ddigital/organization');
|
||||
await wait(`document.querySelector('#pane-a [data-focus-key="agents-all"]')`);
|
||||
await b.evaluate(`document.querySelector('#pane-a [data-focus-key="agents-all"]').click()`);
|
||||
await wait(`${title('a')}.startsWith('Agents') && document.activeElement?.tagName === 'H1' && document.querySelector('#pane-a').contains(document.activeElement)`);
|
||||
});
|
||||
|
||||
// A1's "Available in a project" chooser, with an Agents pane per organization: each pane's choice
|
||||
// redraws its own table and survives a refresh, keyed by that pane's organization.
|
||||
test('each Agents pane keeps its own project choice for its organization', async t => {
|
||||
const { b, wait } = await open(t, { hash: '#/orgs/ddigital/side-by-side?a=ddigital/agents&b=forge/agents' });
|
||||
const a = (name, level = 'org', definition = name) => ({ name, level, definition, model: null, scope: { kind: 'org' }, overrides: [], narrowed: [] });
|
||||
const set = agents => ({ state: 'ok', agents, excluded: [] });
|
||||
const levels = { root: { state: 'ok', assistant: null, agents: [] }, orgs: [
|
||||
{ id: 'ddigital', state: 'ok', base: set([a('chief')]), defined: { 'spring-launch': [{ name: 'social-scheduler', form: 'define' }] },
|
||||
projects: { 'spring-launch': set([a('chief'), a('social-scheduler', 'project', 'coder')]), 'brand-refresh': set([a('chief')]) } },
|
||||
{ id: 'forge', state: 'ok', base: set([a('chief')]), defined: { 'api-gateway': [{ name: 'load-tester', form: 'define' }] },
|
||||
projects: { 'api-gateway': set([a('chief'), a('load-tester', 'project', 'coder')]), ops: set([a('chief')]) } },
|
||||
] };
|
||||
await b.evaluate(`(() => { const real = window.fetch; window.fetch = (url, init) => String(url).endsWith('/api/agents/levels') ? Promise.resolve(new Response(${JSON.stringify(JSON.stringify(levels))}, { headers: { 'content-type': 'application/json' } })) : real(url, init); })()`);
|
||||
await b.evaluate('window.MosaicPanes.refresh()');
|
||||
const select = key => `document.querySelector('#pane-${key} [data-agents-project]')`;
|
||||
const rows = key => `[...document.querySelectorAll('#pane-${key} [data-agents-available] tr[data-agent]')].map(r => r.dataset.agent)`;
|
||||
await wait(`${select('a')} && ${select('b')} && ${rows('a')}.includes('social-scheduler') && ${rows('b')}.includes('load-tester')`);
|
||||
await b.evaluate(`(() => { const s = ${select('b')}; s.value = 'ops'; s.dispatchEvent(new Event('change', { bubbles: true })); })()`);
|
||||
await wait(`!${rows('b')}.includes('load-tester')`);
|
||||
assert.equal(await b.evaluate(`${rows('a')}.includes('social-scheduler')`), true);
|
||||
assert.equal(await b.evaluate(`${rows('a')}.includes('load-tester')`), false);
|
||||
await b.evaluate('window.MosaicPanes.refresh()');
|
||||
await settled(b);
|
||||
await wait(`${select('a')} && ${select('b')}`);
|
||||
assert.deepEqual(await b.evaluate(`[${select('a')}.value, ${select('b')}.value]`), ['spring-launch', 'ops']);
|
||||
assert.equal(await b.evaluate(`!${rows('b')}.includes('load-tester') && ${rows('a')}.includes('social-scheduler')`), true);
|
||||
});
|
||||
@@ -110,13 +110,8 @@ test('sidebar follows sample 01 and Queue opens from the organization dashboard,
|
||||
assert.equal(queue, '#/queue');
|
||||
await b.evaluate('document.querySelector("[data-org-queue]").click()');
|
||||
await wait('location.hash === "#/orgs/ddigital/queue" && [...document.querySelectorAll("#bus-view h1")].some(h => h.textContent.trim() === "Queue")');
|
||||
// Views not built yet open and say so.
|
||||
// Inbox (U3, workspace-pages-browser.test.mjs) and Tasks (U4b, tasks-board-browser.test.mjs) are built.
|
||||
for (const [route, title] of [['side-by-side', 'Side by side']]) {
|
||||
await b.evaluate(`location.hash = "#/orgs/ddigital/${route}"`);
|
||||
await wait(`document.getElementById("bus-view").innerText.includes("not built yet") && [...document.querySelectorAll("#bus-view h1")].some(h => h.textContent.trim() === ${JSON.stringify(title)})`);
|
||||
assert.doesNotMatch(await b.evaluate('document.getElementById("bus-view").innerText'), /error|failed/i, route);
|
||||
}
|
||||
// Every sidebar view is built: Inbox (U3, workspace-pages-browser.test.mjs), Tasks (U4b,
|
||||
// tasks-board-browser.test.mjs) and Side by side (U5, side-by-side-browser.test.mjs).
|
||||
});
|
||||
|
||||
test('organization dashboard renders every section from the per-organization reads', async t => {
|
||||
|
||||
@@ -172,7 +172,8 @@ test('Projects sorts and filters by each key, remembers the grouping, and rows o
|
||||
await b.evaluate('location.reload()');
|
||||
await wait('document.querySelector("[data-ws-table]")?.dataset.grouping === "flat" && document.querySelectorAll("[data-ws-project]").length === 1 && document.querySelector("#workspace-nav [data-group=flat]")?.getAttribute("aria-pressed") === "true"');
|
||||
// The sidebar's toggle switches this page back.
|
||||
await b.evaluate('document.querySelector("#workspace-nav [data-group=org]").click()');
|
||||
// The nav repaints after async reads, so the click retries until the page switches.
|
||||
await wait('(document.querySelector("#workspace-nav [data-group=org]")?.click(), document.querySelector("[data-ws-table]")?.dataset.grouping === "org")');
|
||||
await wait('document.querySelector("[data-ws-table]")?.dataset.grouping === "org" && document.querySelector("[data-ws-group=org]").getAttribute("aria-pressed") === "true"');
|
||||
});
|
||||
const location = hash => decodeURIComponent(hash);
|
||||
|
||||
Reference in new issue
Block a user