Merge integrate/w15-slices (70443895e) into integrate/mvp3-ux-1106
This commit is contained in:
commit
5a346825b7
58 files changed
+2524
-92
No files matched your search
@@ -160,7 +160,7 @@ export const USER_VERBS = Object.freeze(['project.list', 'thread.create', 'threa
|
||||
'thread.share', 'thread.unshare', 'thread.shares']);
|
||||
// Row 252 (#1753, design sections 2-3): the only verbs a Console owner cap reaches. Every other verb
|
||||
// refuses it human-required before its own checks, and these refuse every other cap.
|
||||
export const CONSOLE_VERBS = Object.freeze(['request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
|
||||
export const CONSOLE_VERBS = Object.freeze(['project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
|
||||
// Task 3 (#1839): and the owner's intake controls. The reads and writes are the same lifecycle methods the PM's
|
||||
// unsuffixed verbs reach, with the owner's verified (provider, iss, sub) as the only caller; the PM-only
|
||||
// request.question and the PM's own reads keep their unsuffixed names and refuse a console cap.
|
||||
@@ -441,6 +441,7 @@ function checkBusiness(name, b) {
|
||||
export class Broker {
|
||||
#store;
|
||||
#requests;
|
||||
#workspaceRead;
|
||||
#requestRecoveryPending = false;
|
||||
#businesses;
|
||||
#sessions = new Map();
|
||||
@@ -522,7 +523,7 @@ export class Broker {
|
||||
#clock = 0;
|
||||
#processing;
|
||||
// credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use().
|
||||
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, proxyInit = null, proxyPage = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, grantScope = null, roleFile = null, giteaGrant = null, giteaKeys = null, access = null, accessStamp = null, compactions = null, requestLifecycle = null, requirementsHost = null }) {
|
||||
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, proxyInit = null, proxyPage = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, grantScope = null, roleFile = null, giteaGrant = null, giteaKeys = null, access = null, accessStamp = null, compactions = null, requestLifecycle = null, requirementsHost = null, workspaceRead = null }) {
|
||||
if (!object(businesses) || !Object.keys(businesses).length) fail('invalid-business');
|
||||
// Row 194 b15 (sage-2501): the function-argument checks come before anything reads the store.
|
||||
if (harnessOn !== null && typeof harnessOn !== 'function') throw new TypeError('harnessOn must be a function');
|
||||
@@ -577,6 +578,7 @@ export class Broker {
|
||||
this.#requirements = loadRequirements();
|
||||
// These are host capabilities, like project/library; no filesystem module enters the client closure.
|
||||
this.#requestJournal = requirementsHost?.journal;
|
||||
this.#workspaceRead = workspaceRead;
|
||||
this.#builtinBinding = requirementsHost?.binding;
|
||||
this.#builtin = requirementsHost?.builtin;
|
||||
this.#catalog = requirementsHost?.catalog;
|
||||
@@ -5432,6 +5434,32 @@ export class Broker {
|
||||
return { ended: ended.map((h) => ({ thread: h.thread, session: h.session, run: h.holder_run })) };
|
||||
}
|
||||
this.#processingGate(s, request.verb);
|
||||
if (request.verb === 'request.workspace.console') {
|
||||
if (!s.console || s.human !== this.#business(s.business).human) fail('human-required');
|
||||
keys(args, ['request'], ['request']);
|
||||
if (!this.#requests || !this.#workspaceRead) fail('request-not-configured');
|
||||
return Promise.resolve().then(async () => {
|
||||
const before = await this.#requests.get(s, structuredClone(args));
|
||||
this.#requestAuthorize(s, before, 'draft');
|
||||
const result = await this.#workspaceRead(before);
|
||||
const after = await this.#requests.get(s, structuredClone(args));
|
||||
this.#requestAuthorize(s, after, 'draft');
|
||||
if (before.revision !== after.revision) fail('request-revision-conflict');
|
||||
this.#secretCheck(result);
|
||||
return result;
|
||||
}).catch(e => { throw this.#refused(s, e instanceof BusError ? e : new BusError(e.code ?? 'request-failed'), request); });
|
||||
}
|
||||
if (request.verb === 'project.remote.get.console' || request.verb === 'project.remote.set.console') {
|
||||
if (!s.console || s.human !== this.#business(s.business).human) fail('human-required');
|
||||
if (!this.#organization) fail('not-configured');
|
||||
const write = request.verb === 'project.remote.set.console';
|
||||
keys(args, write ? ['project', 'revision', 'remote'] : ['project'], write ? ['project', 'revision', 'remote'] : ['project']);
|
||||
try { return this.#organization(s.business, request.verb, args, s.human); }
|
||||
catch (e) {
|
||||
const allowed = ['project-remote-unknown', 'project-remote-library', 'project-remote-invalid', 'project-remote-configured', 'project-remote-protected', 'revision-conflict', 'organization-busy', 'human-required'];
|
||||
fail(allowed.includes(e.code) ? e.code : 'outcome-unknown');
|
||||
}
|
||||
}
|
||||
if (['prd.revoke.console','request.mode.console','request.draft.console'].includes(request.verb)) {
|
||||
if (!s.console || s.human !== this.#business(s.business).human) fail('human-required');
|
||||
if (!this.#requests?.catalog) fail('request-not-configured');
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// Resolve only a confirmed request's assigned coder and a host-recorded checkout.
|
||||
import { constants, openSync, closeSync, fstatSync, readFileSync } from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { isAbsolute, join } from 'node:path';
|
||||
import { workspaceDir } from '../../seat/src/session.mjs';
|
||||
import { workspaceFiles } from '../../seat/src/workspace-files.mjs';
|
||||
|
||||
const id = s => typeof s === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,159}$/.test(s) && s !== '..';
|
||||
const unavailable = () => ({ available: false, entries: [], truncated: false, contentAvailable: false, observedAt: new Date().toISOString() });
|
||||
const flags = constants.O_RDONLY | constants.O_NOFOLLOW;
|
||||
function proofFile(path) {
|
||||
// Evidence directories are host-owned, but do not let a substituted ancestor redirect a read.
|
||||
const fds = [];
|
||||
try {
|
||||
let dir = openSync('/', flags | constants.O_DIRECTORY); fds.push(dir);
|
||||
const parts = path.split('/').filter(Boolean), file = parts.pop();
|
||||
if (!file || parts.some(p => p === '.' || p === '..') || file === '.' || file === '..') throw new Error('unsafe proof');
|
||||
for (const part of parts) { dir = openSync(`/proc/self/fd/${dir}/${part}`, flags | constants.O_DIRECTORY); fds.push(dir); }
|
||||
const fd = openSync(`/proc/self/fd/${dir}/${file}`, flags); fds.push(fd);
|
||||
const st = fstatSync(fd); if (!st.isFile() || st.size > 1024 * 1024 || st.nlink !== 1) throw new Error('unsafe proof');
|
||||
return readFileSync(fd, 'utf8');
|
||||
} finally { for (const fd of fds.reverse()) closeSync(fd); }
|
||||
}
|
||||
|
||||
export function requestWorkspace({ dataRoot, state, events }) {
|
||||
if (typeof dataRoot !== 'string' || !isAbsolute(dataRoot) || dataRoot.split('/').includes('..')) return unavailable();
|
||||
if (!state || !id(state.business) || !id(state.request) || !id(state.project) || !id(state.pm) || !state.snapshot || !Array.isArray(state.tasks) || !state.tasks.length) return unavailable();
|
||||
const binding = state.selection?.bindings?.filter(b => b.digest === state.plan_digest) ?? [];
|
||||
if (binding.length !== 1 || binding[0].plan.business !== state.business || binding[0].plan.request !== state.request || binding[0].plan.project !== state.project || binding[0].plan.staff?.pm !== state.pm || !id(binding[0].plan.staff?.coder)) return unavailable();
|
||||
const coder = binding[0].plan.staff.coder;
|
||||
if (!Array.isArray(events) || events.length > 10000) return unavailable();
|
||||
const rows = events.map(e => { try { return { ...e, body: typeof e.body === 'string' ? JSON.parse(e.body) : e.body }; } catch { return null; } }).filter(Boolean);
|
||||
for (const task of state.tasks) {
|
||||
if (typeof task !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_.:/-]{0,159}$/.test(task)) return unavailable();
|
||||
const created = rows.find(e => e.kind === 'task.created' && e.actor_role === state.pm && e.subject === task && e.body?.request === state.request && e.body?.project_key === state.project && e.body?.requirement_snapshot === state.snapshot);
|
||||
const assigned = rows.findLast(e => e.kind === 'task.assigned' && e.subject === task);
|
||||
if (!created || assigned?.actor_role !== state.pm || assigned.body?.role !== coder) return unavailable();
|
||||
}
|
||||
const root = workspaceDir(dataRoot, state.business, coder), checkout = join(root, state.project);
|
||||
if (/[\n\r;,]/.test(checkout)) return unavailable();
|
||||
const launches = rows.filter(e => e.kind === 'session.launched' && e.actor_role === coder);
|
||||
if (launches.some(e => e.body?.thread)) return unavailable();
|
||||
let proven = false;
|
||||
for (const e of launches.slice(-32)) {
|
||||
if (!id(e.subject)) continue;
|
||||
try {
|
||||
const bundle = join(dataRoot, 'launches', state.business, e.subject, 'bundle');
|
||||
const manifest = JSON.parse(proofFile(join(bundle, 'manifest.json')));
|
||||
if (manifest.business !== state.business || manifest.instance !== coder || manifest.run !== e.subject || !Array.isArray(manifest.projects) || !manifest.projects.includes(state.project)) continue;
|
||||
// The manifest projects field includes unsafe, kept entries. The host prompt records that
|
||||
// outcome; do not treat a kept entry as a verified checkout.
|
||||
const prompt = proofFile(join(bundle, 'prompt.md'));
|
||||
if (createHash('sha256').update(prompt).digest('hex') !== manifest.files?.['prompt.md']) continue;
|
||||
const lines = prompt.split('\n').filter(x => x.startsWith('- Project clones: '));
|
||||
if (lines.length !== 1) continue;
|
||||
const items = lines[0].slice('- Project clones: '.length).split('; ');
|
||||
const item = items.find(x => x.startsWith(`${state.project} at ${checkout}, `));
|
||||
if (item && (item.startsWith(`${state.project} at ${checkout}, on branch `) || item.startsWith(`${state.project} at ${checkout}, on no branch `))) proven = true;
|
||||
} catch { /* missing or unsafe evidence never selects a workspace */ }
|
||||
}
|
||||
if (!proven) return unavailable();
|
||||
try { return workspaceFiles(checkout); } catch { return unavailable(); }
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { fileURLToPath } from 'node:url';
|
||||
import { Store } from './store.mjs';
|
||||
import { createRequestStore } from './request-store.mjs';
|
||||
import { createRequestLifecycle } from './request-lifecycle.mjs';
|
||||
import { requestWorkspace } from './request-workspace.mjs';
|
||||
import { readReviewedArtifact } from '../../seat/src/artifact.mjs';
|
||||
import { executeBootstrap } from './request-bootstrap.mjs';
|
||||
import { hostPreparation } from './request-host.mjs';
|
||||
@@ -29,6 +30,7 @@ import { initializeProxy } from '../../model-proxy/src/settings.mjs';
|
||||
import { ModelKeys, modelOrganization, stopModelRuns, MODEL_STOP_TIMEOUT_MS } from './model-keys.mjs';
|
||||
import { instanceSettings } from '../../business/src/instance-settings.mjs';
|
||||
import { organizationWriter, OrganizationError, setReplyLimits } from '../../business/src/organization.mjs';
|
||||
import { getProjectRemote } from '../../business/src/project-remotes.mjs';
|
||||
import { credentialVerbs } from './credential-verbs.mjs';
|
||||
import { loadBusiness } from '../../business/src/business.mjs';
|
||||
import { loadRole } from '../../business/src/role.mjs';
|
||||
@@ -585,6 +587,7 @@ export async function startBroker({
|
||||
}
|
||||
const requirementsHost = createRequirementsHost({ store, businesses, builtinBinding, journal: requestJournal });
|
||||
broker = new Broker({
|
||||
workspaceRead: state => requestWorkspace({ dataRoot, state, events: store.all("SELECT kind,actor_role,subject,body FROM events WHERE business=? AND kind IN ('task.created','task.assigned','session.launched') ORDER BY seq LIMIT 10001", state.business) }),
|
||||
requirementsHost,
|
||||
requestLifecycle: services => {
|
||||
const readCandidate = (candidate, business) => {
|
||||
@@ -664,6 +667,22 @@ export async function startBroker({
|
||||
// limits and limits.set (organization.set is limits.set) carry each token limit's count; a
|
||||
// plugins.set reloads the business's limit checks in the background.
|
||||
organization:settings?(proxyOrganization?.wrap ?? (operation => operation))((id,verb,args,actor,context)=>{
|
||||
if (verb === 'project.remote.get.console' || verb === 'project.remote.set.console') {
|
||||
const current = loadBusiness(id, { dir: organization.dir, rolesDir: organization.rolesDir });
|
||||
const resolved = {};
|
||||
for (const instance of Object.keys(current.roles)) resolved[instance] = resolveInstance({ system: organization.system, business: current, instance });
|
||||
const live = withoutPending(id, busBusiness(current, resolved));
|
||||
if (!sameFixed(booted.get(id), live) || canonicalJson(live.projects) !== known.get(id)) throw new BusError('project-remote-protected');
|
||||
if (!Object.hasOwn(live.projects, args.project)) throw new BusError('project-remote-unknown');
|
||||
if (verb === 'project.remote.get.console') return getProjectRemote(writer, id, args.project);
|
||||
writer.setProjectRemote(id, args, actor);
|
||||
const next = structuredClone(live);
|
||||
next.projects[args.project].remote = args.remote;
|
||||
broker.reloadBusiness(id, next);
|
||||
businesses[id] = next;
|
||||
known.set(id, canonicalJson(next.projects));
|
||||
return getProjectRemote(writer, id, args.project);
|
||||
}
|
||||
if(verb==='limits')return withTokens(id,settings.read(id));
|
||||
if(verb==='plugins')return plugins.read(id);
|
||||
// Row 256 (#1761): the actor is the reader, so endProtected is the delete check for that reader.
|
||||
|
||||
@@ -89,7 +89,7 @@ function setup(t) {
|
||||
// #797 / #1839 adds the owner-only artifact read to the existing decision and request controls;
|
||||
// no unsuffixed verb is ever a console verb.
|
||||
test('N4: owner Console verbs have an explicit immutable whitelist and exclude decision.resolve', () => {
|
||||
assert.deepEqual([...CONSOLE_VERBS], ['request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
|
||||
assert.deepEqual([...CONSOLE_VERBS], ['project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
|
||||
'request.get.console', 'request.projects.console', 'request.answer.console', 'request.attach.console', 'request.cancel.console']);
|
||||
assert.ok(CONSOLE_VERBS.every(verb => verb.endsWith('.console')));
|
||||
assert.equal(CONSOLE_VERBS.includes('decision.resolve'), false);
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { Broker } from '../src/broker.mjs';
|
||||
|
||||
test('only the owner Console reads and writes', t => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'project-remote-bus-')), store = new Store(root), calls = [];
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const business = { id: 'demo', human: 'jason', vars: { 'bus.tester': true }, arbiters: { technical: 'cto', delivery: 'pm' },
|
||||
roles: { pm: { definition: 'pm', authority: { withinRole: [], crossRole: [] } }, cto: { definition: 'cto', authority: { withinRole: [], crossRole: [] } } },
|
||||
projects: { stack: { root } } };
|
||||
const broker = new Broker({ store, businesses: { demo: business }, organization: (...args) => { calls.push(args); return { project: 'stack', revision: 'a'.repeat(64), remote: null, firstSaveAllowed: true }; } });
|
||||
const owner = broker.bindConsole({ business: 'demo', human: 'jason', subject: { provider: 'oidc', iss: 'https://id.test', sub: 'owner' } });
|
||||
const cli = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const reader = broker.bindReader({ business: 'demo' }), tester = broker.bindTester({ business: 'demo' });
|
||||
const get = { verb: 'project.remote.get.console', args: { project: 'stack' } };
|
||||
assert.equal(broker.request(owner, get).project, 'stack');
|
||||
for (const cap of [cli, reader, tester]) assert.throws(() => broker.request(cap, get), /human-required|read-only|tester-refused/);
|
||||
assert.throws(() => broker.request(owner, { ...get, args: { project: 'stack', business: 'other' } }), /invalid-request/);
|
||||
assert.equal(broker.request(owner, { verb: 'project.remote.set.console', args: { project: 'stack', revision: 'a'.repeat(64), remote: 'https://git.example/a/b' } }).project, 'stack');
|
||||
assert.equal(calls.length, 2);
|
||||
assert.throws(() => broker.bindConsole({ business: 'demo', human: 'other', subject: { provider: 'oidc', iss: 'https://id.test', sub: 'owner' } }), /human-required/);
|
||||
});
|
||||
|
||||
test('socket refuses tester, viewer, collaborator, agent and CLI human for both remote verbs', async t => {
|
||||
const { serve } = await import('../src/server.mjs');
|
||||
const { Client } = await import('../src/client.mjs');
|
||||
const root = mkdtempSync(join(tmpdir(), 'remote-socket-')), store = new Store(root);
|
||||
const access = { accessVersion: 1, revision: 1, users: [], grants: [
|
||||
{ id: 'g-1', user: 'viewer', business: 'demo', projects: ['*'], instances: ['pm'], level: 'viewer' },
|
||||
{ id: 'g-2', user: 'collab', business: 'demo', projects: ['*'], instances: ['pm'], level: 'collaborator' },
|
||||
] };
|
||||
const business = { id: 'demo', human: 'jason', vars: { 'bus.tester': true }, arbiters: { technical: 'pm', delivery: 'pm' },
|
||||
roles: { pm: { definition: 'pm', authority: { withinRole: [], crossRole: [] } } }, projects: { stack: { root } } };
|
||||
const broker = new Broker({ store, businesses: { demo: business }, access: () => access, organization: () => { throw Error('unexpected writer'); } });
|
||||
const caps = {
|
||||
human: broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }),
|
||||
tester: broker.bindTester({ business: 'demo' }),
|
||||
viewer: broker.bindUser({ business: 'demo', user: 'viewer' }),
|
||||
collaborator: broker.bindUser({ business: 'demo', user: 'collab' }),
|
||||
agent: broker.bindLaunch({ business: 'demo', role: 'pm', run: 'remote-test', harness: 'pi' }),
|
||||
};
|
||||
const path = join(root, 'bus.sock'), server = await serve({ broker, path });
|
||||
t.after(async () => { await server.close(); store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
for (const [who, cap] of Object.entries(caps)) for (const [verb, args] of [
|
||||
['project.remote.get.console', { project: 'stack' }],
|
||||
['project.remote.set.console', { project: 'stack', revision: 'a'.repeat(64), remote: 'https://git.example/a/b' }],
|
||||
]) await assert.rejects(new Client({ path, cap }).call(verb, args), e => ['human-required', 'read-only', 'tester-refused', 'access-refused'].includes(e.code), `${who} ${verb}`);
|
||||
});
|
||||
@@ -90,13 +90,13 @@ const ask = (x, state, extra = {}) => x.call('pm', 'request.question', { request
|
||||
options: [{ key: 'web', text: 'Web' }, { key: 'cli', text: 'CLI' }], ...extra });
|
||||
|
||||
test('the console verb list includes the artifact read and existing owner controls', () => {
|
||||
assert.deepEqual([...CONSOLE_VERBS], ['request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
|
||||
assert.deepEqual([...CONSOLE_VERBS], ['project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
|
||||
'request.get.console', 'request.projects.console', 'request.answer.console', 'request.attach.console', 'request.cancel.console']);
|
||||
});
|
||||
|
||||
test('only the signed-in owner capability reaches the console verbs and it reaches nothing else', async t => {
|
||||
const x = await atSelection(t), request = x.source.request;
|
||||
const args = { 'request.artifact.console': { request, delivery: 'not-a-delivery' }, 'request.get.console': { request }, 'request.projects.console': { request },
|
||||
const args = { 'request.workspace.console': { request }, 'request.artifact.console': { request, delivery: 'not-a-delivery' }, 'request.get.console': { request }, 'request.projects.console': { request },
|
||||
'request.answer.console': { request, revision: x.state.revision, question: 'q', op: 'o1', text: 'hi' },
|
||||
'request.attach.console': { request, revision: x.state.revision, project: 'old', op: 'o2' },
|
||||
'request.mode.console': { request, revision: x.state.revision, mode_revision: 1, mode: 'guided', op: 'o3' },
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { requestWorkspace } from '../src/request-workspace.mjs';
|
||||
import { requestFixture } from './request-fixture.mjs';
|
||||
|
||||
function fixture(t) {
|
||||
const dataRoot = mkdtempSync(join(tmpdir(), 'workspace-request-')); t.after(() => rmSync(dataRoot, { recursive: true, force: true }));
|
||||
const checkout = join(dataRoot, 'workspaces', 'demo', 'builder', 'timer'); mkdirSync(checkout, { recursive: true }); writeFileSync(join(checkout, 'draft.txt'), 'not read');
|
||||
const state = { business: 'demo', request: 'req1', pm: 'pm', project: 'timer', snapshot: 'snap1', tasks: ['vikunja:2/41'], plan_digest: 'plan', selection: { bindings: [{ digest: 'plan', plan: { business: 'demo', request: 'req1', project: 'timer', staff: { pm: 'pm', coder: 'builder' } } }] } };
|
||||
const events = [
|
||||
{ kind: 'task.created', actor_role: 'pm', subject: state.tasks[0], body: { request: 'req1', project_key: 'timer', requirement_snapshot: 'snap1' } },
|
||||
{ kind: 'task.assigned', actor_role: 'pm', subject: state.tasks[0], body: { role: 'builder' } },
|
||||
{ kind: 'session.launched', actor_role: 'builder', subject: 'run1', body: {} },
|
||||
];
|
||||
const dir = join(dataRoot, 'launches', 'demo', 'run1', 'bundle'); mkdirSync(dir, { recursive: true });
|
||||
const proof = (outcome = 'on branch main at commit abc when this session started') => {
|
||||
const prompt = `- Project clones: timer at ${checkout}, ${outcome}.\n`;
|
||||
writeFileSync(join(dir, 'prompt.md'), prompt);
|
||||
writeFileSync(join(dir, 'manifest.json'), JSON.stringify({ business: 'demo', instance: 'builder', run: 'run1', projects: ['timer'], files: { 'prompt.md': createHash('sha256').update(prompt).digest('hex') } }));
|
||||
};
|
||||
proof(); return { dataRoot, checkout, state, events, proof, dir };
|
||||
}
|
||||
test('resolves only a confirmed request and host-proven coder checkout', t => {
|
||||
const f = fixture(t), read = () => requestWorkspace(f);
|
||||
assert.deepEqual(read().entries, [{ name: 'draft.txt' }]);
|
||||
f.state.request = 'other'; assert.equal(read().available, false); f.state.request = '../req1'; assert.equal(read().available, false); f.state.request = 'req1';
|
||||
f.events[1].body.role = 'other'; assert.equal(read().available, false); f.events[1].body.role = 'builder';
|
||||
f.proof('not updated at this launch (not-a-clone)'); assert.equal(read().available, false);
|
||||
f.proof(); writeFileSync(join(f.dir, 'prompt.md'), 'tampered'); assert.equal(read().available, false);
|
||||
f.events.pop(); assert.equal(read().available, false);
|
||||
});
|
||||
|
||||
test('console capability is required before the workspace read and other requests are refused', async t => {
|
||||
const f = await requestFixture(t);
|
||||
const args = { request: f.request };
|
||||
const result = await f.call('console', 'request.workspace.console', args);
|
||||
assert.equal(result.available, false);
|
||||
for (const principal of ['human', 'pm', 'cto', 'coder', 'reader'])
|
||||
await assert.rejects(async () => f.call(principal, 'request.workspace.console', args), /human-required|read-only|tester-refused/);
|
||||
await assert.rejects(async () => f.call('console', 'request.workspace.console', { request: 'someone-else' }), /request-not-found/);
|
||||
await assert.rejects(async () => f.call('console', 'request.workspace.console', { request: '../other' }), /invalid-request|request-not-found/);
|
||||
await assert.rejects(async () => f.call('console', 'request.workspace.console', { ...args, path: '/etc' }), /invalid-request/);
|
||||
assert.throws(() => f.bus.broker.bindConsole({ business: 'demo', human: 'other', subject: { provider: 'test', iss: 'test', sub: 'other' } }), /human-required/);
|
||||
});
|
||||
|
||||
test('real broker derives a workspace only after request and coder assignment evidence', async t => {
|
||||
const f = await requestFixture(t), sub = await f.submit(); f.approve(sub); await f.activate(sub);
|
||||
const { createDigest, createMarker } = await import('../src/broker.mjs');
|
||||
const state = f.journal.readRequest('demo', f.request), task = 'vikunja:2/41';
|
||||
const prepared = { op: 'timer-task', request: f.request, requirement: 'REQ-POMO-1', project_key: 'timer', requirement_snapshot: state.snapshot,
|
||||
project: 2, creator: 3, decision: null, view: { ids: { todo: 1, review: 2, done: 3 } }, labels: [], relations: [] };
|
||||
prepared.body = { title: 'Timer', description: createMarker(prepared) }; prepared.body_digest = createDigest(prepared.body);
|
||||
const { receipt } = f.bus.broker.prepareTaskCreate(f.caps.pm, prepared);
|
||||
let fields = { ...prepared.body, project_id: 2, bucket: 1, done: false, assignees: [], due_date: null, priority: 0 };
|
||||
const snapshot = () => ({ task_ref: task, fields, digest: createDigest(fields), updated: new Date().toISOString() });
|
||||
f.bus.broker.recordRecoveredCreate({ receipt, task_ref: task, snapshot: { ...snapshot(), creator: 3 } });
|
||||
fields = { ...fields, assignees: [5] };
|
||||
f.bus.broker.recordTask({ cap: f.caps.pm, business: 'demo', snapshots: [snapshot()], events: [{ kind: 'task.assigned', subject: task, body: { role: 'coder', previous: [] } }] });
|
||||
await f.call('pm', 'request.advance', { request: f.request, revision: state.revision, op: 'start' });
|
||||
const checkout = join(f.f.dataRoot, 'workspaces', 'demo', 'coder', 'timer'); mkdirSync(checkout, { recursive: true }); writeFileSync(join(checkout, 'draft.js'), 'no contents read');
|
||||
const bundle = join(f.f.dataRoot, 'launches', 'demo', 'coder-run', 'bundle'); mkdirSync(bundle, { recursive: true });
|
||||
const prompt = `- Project clones: timer at ${checkout}, on branch main at commit abc when this session started.\n`;
|
||||
writeFileSync(join(bundle, 'prompt.md'), prompt);
|
||||
writeFileSync(join(bundle, 'manifest.json'), JSON.stringify({ business: 'demo', instance: 'coder', run: 'coder-run', projects: ['timer'], files: { 'prompt.md': createHash('sha256').update(prompt).digest('hex') } }));
|
||||
const result = await f.call('console', 'request.workspace.console', { request: f.request });
|
||||
assert.equal(result.available, true); assert.deepEqual(result.entries, [{ name: 'draft.js' }]);
|
||||
});
|
||||
|
||||
test('a different request owner is refused before any workspace access', async t => {
|
||||
const { Store } = await import('../src/store.mjs'); const { Broker } = await import('../src/broker.mjs');
|
||||
const root = mkdtempSync(join(tmpdir(), 'workspace-owner-')), store = new Store(root);
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const roles = { pm: { definition: 'pm', authority: { withinRole: ['message.send'], crossRole: [] } } };
|
||||
let reads = 0;
|
||||
const state = { business: 'demo', request: 'r1', owner: 'someone-else', pm: 'pm', revision: 1 };
|
||||
const lifecycle = { get: async () => state };
|
||||
for (const key of ['open', 'projects', 'propose', 'question', 'answer', 'attach', 'cancel', 'modeConsole', 'draftConsole', 'resume', 'recover']) lifecycle[key] = () => {};
|
||||
const broker = new Broker({ store, businesses: { demo: { id: 'demo', human: 'jason', roles, arbiters: { technical: 'pm', delivery: 'pm' }, projects: {} } }, requestLifecycle: () => lifecycle, workspaceRead: () => { reads++; return {}; } });
|
||||
const cap = broker.bindConsole({ business: 'demo', human: 'jason', subject: { provider: 'test', iss: 'test', sub: 'owner' } });
|
||||
await assert.rejects(async () => broker.request(cap, { verb: 'request.workspace.console', args: { request: 'r1' } }), /request-access-refused/);
|
||||
assert.equal(reads, 0);
|
||||
});
|
||||
@@ -371,6 +371,12 @@ torn evidence refuses organization writes. Bus startup, Settings reads and
|
||||
launches keep using the whole validated file on disk, without consulting receipts. A stale writer lock also refuses: an operator must
|
||||
confirm that its old writer is gone before clearing it; the host does not
|
||||
race another recovering process by deleting locks automatically. Settings reads never repair evidence themselves.
|
||||
The narrow `setProjectRemote` host method can fill only a missing remote on an
|
||||
existing software project. It checks the actor, revision, normalized HTTPS
|
||||
repository URL and the exact document delta under the same lock. It refuses
|
||||
libraries, credentials in URLs, replacement and clearing. It does not contact
|
||||
the remote or grant publishing authority. The Console setter is currently
|
||||
closed until session liveness can be verified at the commit boundary.
|
||||
`launch-settings.mjs` restricts human edits to limits, model table, instance
|
||||
model selections, routes and assistant settings, and checks every route before publication. `plugin-settings.mjs`
|
||||
is the only writer of `plugins`: the host calls it, and no broker verb, `mosaic`
|
||||
|
||||
@@ -11,6 +11,7 @@ import { requireId, sha256, canonicalJson } from "./util.mjs";
|
||||
import { isDailyModelQuota } from "../../plugins/src/model-quota.mjs";
|
||||
import { checkReplyLimits } from "../../library/src/limits.mjs";
|
||||
import { ADDITION_VERB, additionProblem } from "./project-bootstrap.mjs";
|
||||
import { remoteProblem, remoteTarget } from "./project-remotes.mjs";
|
||||
|
||||
export class OrganizationError extends Error {
|
||||
constructor(code, message = code) {
|
||||
@@ -345,6 +346,21 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
});
|
||||
}
|
||||
|
||||
// The only exception to the projects fence for an existing software mapping.
|
||||
// Build from the locked document and check both the candidate and serialized bytes.
|
||||
function setProjectRemote(id, { revision, project, remote }, actor) {
|
||||
const guard = (old, document) => {
|
||||
const agents = Object.entries(old.roles ?? {}).flatMap(([name, role]) => [name, `${role?.definition}.${name}`]);
|
||||
if (actor !== old.human || actor === "tester" || agents.includes(actor)) fail("human-required");
|
||||
const problem = remoteProblem(old, document, project, remote);
|
||||
if (problem) fail(problem);
|
||||
};
|
||||
return commit(id, old => {
|
||||
remoteTarget(old, project, remote);
|
||||
return { ...old, projects: { ...old.projects, [project]: { ...old.projects[project], remote } } };
|
||||
}, { revision, actor, guard, detail: { verb: "project.remote.set", project } });
|
||||
}
|
||||
|
||||
// Runs `fn({ revision, document })` under the writer lock, after recovery, and nothing else may
|
||||
// write this business meanwhile. `fn` must be synchronous: the lock is a directory held for its
|
||||
// duration, and an awaited call would leave a half-checked world to whoever finds the lock gone.
|
||||
@@ -412,5 +428,5 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
});
|
||||
}
|
||||
|
||||
return { logFile, read, readRaw, write, addProject, hold, holdAsync, findAddition, record, recover: id => withLock(id, () => recover(id)) };
|
||||
return { logFile, read, readRaw, write, addProject, setProjectRemote, hold, holdAsync, findAddition, record, recover: id => withLock(id, () => recover(id)) };
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Narrow, offline validation for a first repository URL. Never include the supplied
|
||||
// URL in a refusal: it may contain credentials.
|
||||
import { parseGiteaUrl } from './vocabulary.mjs';
|
||||
import { canonicalJson } from './util.mjs';
|
||||
|
||||
export function remoteTarget(document, project, remote) {
|
||||
const reject = code => { const error = new Error(code); error.code = code; throw error; };
|
||||
if (typeof project !== 'string' || !Object.hasOwn(document.projects ?? {}, project)) reject('project-remote-unknown');
|
||||
const entry = document.projects[project];
|
||||
if (entry.kind === 'library') reject('project-remote-library');
|
||||
if (Object.hasOwn(entry, 'remote')) reject('project-remote-configured');
|
||||
if (!parseGiteaUrl(remote, { repo: true }).url) reject('project-remote-invalid');
|
||||
}
|
||||
|
||||
export function remoteProblem(old, next, project, remote) {
|
||||
try { remoteTarget(old, project, remote); } catch (error) { return error.code; }
|
||||
const expected = { ...old, projects: { ...old.projects, [project]: { ...old.projects[project], remote } } };
|
||||
return canonicalJson(expected) === canonicalJson(next) ? null : 'project-remote-protected';
|
||||
}
|
||||
|
||||
export function getProjectRemote(writer, business, project) {
|
||||
const { document, revision } = writer.read(business);
|
||||
if (typeof project !== 'string' || !Object.hasOwn(document.projects ?? {}, project)) {
|
||||
const error = new Error('project-remote-unknown'); error.code = error.message; throw error;
|
||||
}
|
||||
const entry = document.projects[project];
|
||||
if (entry.kind === 'library') {
|
||||
const error = new Error('project-remote-library'); error.code = error.message; throw error;
|
||||
}
|
||||
return { project, remote: entry.remote ?? null, revision, firstSaveAllowed: !Object.hasOwn(entry, 'remote') };
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { organizationWriter } from '../src/organization.mjs';
|
||||
import { getProjectRemote, remoteProblem } from '../src/project-remotes.mjs';
|
||||
import { businessDoc, rolesCopy, tmp, writeJson } from './helpers.mjs';
|
||||
import { mappingEntry } from '../../library/tests/helpers.mjs';
|
||||
|
||||
function fixture(t) {
|
||||
const root = tmp(); t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const doc = businessDoc(root);
|
||||
doc.projects.books = mappingEntry();
|
||||
doc.roles.chief.credentials = { gitea: { ...doc.roles.coder.credentials.gitea } };
|
||||
const dir = join(root, 'config'), file = writeJson(join(dir, 'businesses/acme.json'), doc);
|
||||
return { file, writer: organizationWriter({ dir, rolesDir: rolesCopy(root) }) };
|
||||
}
|
||||
const url = 'https://git.example/owner/repo.git';
|
||||
test('first save changes only the remote and survives a fresh reader', t => {
|
||||
const { file, writer } = fixture(t), before = writer.read('acme');
|
||||
assert.deepEqual(getProjectRemote(writer, 'acme', 'stack'), { project: 'stack', remote: null, revision: before.revision, firstSaveAllowed: true });
|
||||
const result = writer.setProjectRemote('acme', { revision: before.revision, project: 'stack', remote: url }, 'jason');
|
||||
const after = writer.read('acme');
|
||||
const expected = structuredClone(before.document); expected.projects.stack.remote = url;
|
||||
assert.deepEqual(after.document, expected);
|
||||
assert.equal(result.revision, after.revision);
|
||||
assert.deepEqual(getProjectRemote(writer, 'acme', 'stack'), { project: 'stack', remote: url, revision: after.revision, firstSaveAllowed: false });
|
||||
const log = readFileSync(writer.logFile('acme'), 'utf8').trim().split('\n').map(JSON.parse);
|
||||
assert.deepEqual(log.at(-1).changes.map(x => x.path), ['projects.stack.remote']);
|
||||
assert.equal(JSON.parse(readFileSync(file, 'utf8')).projects.stack.remote, url);
|
||||
});
|
||||
|
||||
test('refusals do not change the business and never echo a supplied credential', t => {
|
||||
const { file, writer } = fixture(t), before = writer.read('acme'), bytes = readFileSync(file);
|
||||
const refuse = (args, actor, code) => {
|
||||
assert.throws(() => writer.setProjectRemote('acme', { revision: before.revision, project: 'stack', remote: url, ...args }, actor), e => e.code === code && !e.message.includes('secret'));
|
||||
assert.deepEqual(readFileSync(file), bytes);
|
||||
};
|
||||
for (const value of ['http://git.example/a/b', 'https://user:[email protected]/a/b', 'https://git.example/a/b?secret=x', 'https://git.example/a/b#secret', 'https://git.example/a/../b', 'https://GIT.example/a/b', '', null])
|
||||
refuse({ remote: value }, 'jason', 'project-remote-invalid');
|
||||
refuse({ project: 'missing' }, 'jason', 'project-remote-unknown');
|
||||
refuse({ project: 'books' }, 'jason', 'project-remote-library');
|
||||
refuse({ revision: 'stale' }, 'jason', 'revision-conflict');
|
||||
refuse({}, 'tester', 'human-required');
|
||||
refuse({}, 'coder', 'human-required');
|
||||
const next = structuredClone(before.document); next.projects.stack.remote = url;
|
||||
assert.throws(() => writer.write('acme', next, { revision: before.revision, actor: 'jason' }), { code: 'organization-projects-protected' });
|
||||
next.vars['tracker.pollSeconds']++;
|
||||
assert.equal(remoteProblem(before.document, next, 'stack', url), 'project-remote-protected');
|
||||
writer.setProjectRemote('acme', { revision: before.revision, project: 'stack', remote: url }, 'jason');
|
||||
const saved = readFileSync(file), revision = writer.read('acme').revision;
|
||||
assert.throws(() => writer.setProjectRemote('acme', { revision, project: 'stack', remote: 'https://git.example/other/repo' }, 'jason'), { code: 'project-remote-configured' });
|
||||
assert.deepEqual(readFileSync(file), saved);
|
||||
});
|
||||
|
||||
test('the writer lock refuses competing remote writes', t => {
|
||||
const { writer } = fixture(t), revision = writer.read('acme').revision;
|
||||
writer.hold('acme', () => assert.throws(() => writer.setProjectRemote('acme', { revision, project: 'stack', remote: url }, 'jason'), { code: 'organization-busy' }));
|
||||
assert.equal(writer.read('acme').revision, revision);
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
// A bounded names-only walk. Directory descriptors anchor every lookup; no file is opened.
|
||||
import { constants, openSync, closeSync, fstatSync, lstatSync, opendirSync } from 'node:fs';
|
||||
import { isAbsolute } from 'node:path';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
|
||||
const flags = constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW;
|
||||
const nameOK = s => s && s !== '.' && s !== '..' && !s.startsWith('.') && !/[\x00-\x1f\x7f/\\]/.test(s);
|
||||
|
||||
export function workspaceFiles(root, { maxEntries = 100, maxDepth = 5, maxDirectories = 1000, maxMs = 250, clock = () => performance.now() } = {}) {
|
||||
if (typeof root !== 'string' || !isAbsolute(root) || root.split('/').includes('..') || root.includes('\0')) throw new Error('workspace-unsafe');
|
||||
const start = clock(), fds = [];
|
||||
const open = path => { const fd = openSync(path, flags); fds.push(fd); return fd; };
|
||||
try {
|
||||
// Anchor each ancestor too, so neither the root nor an ancestor may be a symlink.
|
||||
let fd = open('/');
|
||||
for (const part of root.split('/').filter(Boolean)) {
|
||||
if (part === '.' || part === '..') throw new Error('workspace-unsafe');
|
||||
fd = open(`/proc/self/fd/${fd}/${part}`);
|
||||
}
|
||||
const device = fstatSync(fd).dev, entries = [];
|
||||
let directories = 0, truncated = false;
|
||||
const expired = () => clock() - start >= maxMs;
|
||||
function walk(dirfd, prefix, depth) {
|
||||
if (expired() || directories >= maxDirectories) { truncated = true; return; }
|
||||
directories++;
|
||||
const dir = opendirSync(`/proc/self/fd/${dirfd}`);
|
||||
try {
|
||||
for (;;) {
|
||||
if (expired()) { truncated = true; break; }
|
||||
const item = dir.readSync();
|
||||
if (!item) break;
|
||||
if (!nameOK(item.name)) continue;
|
||||
const p = `/proc/self/fd/${dirfd}/${item.name}`, display = prefix ? `${prefix}/${item.name}` : item.name;
|
||||
let st;
|
||||
try { st = lstatSync(p); } catch { truncated = true; continue; }
|
||||
if (st.dev !== device || st.isSymbolicLink()) continue;
|
||||
if (st.isDirectory()) {
|
||||
if (depth >= maxDepth) { truncated = true; continue; }
|
||||
let child;
|
||||
try { child = open(p); } catch { truncated = true; continue; }
|
||||
try {
|
||||
const opened = fstatSync(child);
|
||||
if (opened.dev !== st.dev || opened.ino !== st.ino) { truncated = true; continue; }
|
||||
walk(child, display, depth + 1);
|
||||
} finally { closeSync(child); fds.splice(fds.indexOf(child), 1); }
|
||||
if (truncated && (expired() || directories >= maxDirectories || entries.length >= maxEntries)) break;
|
||||
} else if (st.isFile() && st.nlink === 1) {
|
||||
if (entries.length >= maxEntries) { truncated = true; break; }
|
||||
entries.push({ name: display });
|
||||
}
|
||||
}
|
||||
} finally { dir.closeSync(); }
|
||||
}
|
||||
walk(fd, '', 0);
|
||||
return { available: true, entries, truncated, contentAvailable: false, observedAt: new Date().toISOString() };
|
||||
} finally { for (const fd of fds.reverse()) closeSync(fd); }
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, linkSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { workspaceFiles } from '../src/workspace-files.mjs';
|
||||
|
||||
test('lists only safe relative names and never follows links', t => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'files-')); t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
mkdirSync(join(root, 'dir')); writeFileSync(join(root, 'dir', 'ok'), ''); writeFileSync(join(root, '.secret'), '');
|
||||
writeFileSync(join(root, 'hard'), ''); linkSync(join(root, 'hard'), join(root, 'hard2'));
|
||||
symlinkSync('/etc', join(root, 'outside')); symlinkSync(join(root, 'dir'), join(root, 'alias'));
|
||||
assert.deepEqual(workspaceFiles(root).entries, [{ name: 'dir/ok' }]);
|
||||
assert.throws(() => workspaceFiles(join(root, 'alias')));
|
||||
assert.throws(() => workspaceFiles(`${root}/../other`));
|
||||
});
|
||||
test('each walk cap reports truncation', t => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'files-')); t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
mkdirSync(join(root, 'a')); mkdirSync(join(root, 'a', 'b')); writeFileSync(join(root, 'a', 'b', 'x'), ''); writeFileSync(join(root, 'y'), '');
|
||||
for (const options of [{ maxEntries: 0 }, { maxDepth: 0 }, { maxDirectories: 1 }, { maxMs: 0 }]) assert.equal(workspaceFiles(root, options).truncated, true);
|
||||
});
|
||||
|
||||
test('the production limits truncate wide, deep, directory-heavy and slow walks', t => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'files-caps-')); t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const wide = join(root, 'wide'); mkdirSync(wide);
|
||||
for (let n = 0; n < 101; n++) writeFileSync(join(wide, `f${n}`), '');
|
||||
const listed = workspaceFiles(wide); assert.equal(listed.entries.length, 100); assert.equal(listed.truncated, true);
|
||||
const deep = join(root, 'deep'); mkdirSync(deep); let current = deep;
|
||||
for (let n = 0; n < 6; n++) { current = join(current, `d${n}`); mkdirSync(current); }
|
||||
writeFileSync(join(current, 'hidden-by-depth'), ''); assert.equal(workspaceFiles(deep).truncated, true);
|
||||
const many = join(root, 'many'); mkdirSync(many);
|
||||
for (let n = 0; n < 1001; n++) mkdirSync(join(many, `d${n}`));
|
||||
assert.equal(workspaceFiles(many, { maxMs: Infinity }).truncated, true);
|
||||
let ticks = 0; assert.equal(workspaceFiles(wide, { clock: () => ticks++ * 251 }).truncated, true);
|
||||
});
|
||||
@@ -360,6 +360,11 @@ note is `agents/dewey/work/wui/SLICE1-VIEWS.md`.
|
||||
under `--sso` without `--send` it says the Console is read-only for decisions.
|
||||
The tester listener has no decision routes. Decisions routed to agent roles
|
||||
remain with those roles, using their real role/run attribution.
|
||||
- The project remote module can display the configured HTTPS repository URL for
|
||||
an owner session and save a missing URL when writes are enabled. A configured
|
||||
URL is not verified and does not publish, set a Git
|
||||
origin, grant credentials or contact a forge. Project information integration
|
||||
supplies the authorized chat project to repository-key mapping.
|
||||
- #797 / Gitea #1839 Task 3, the request intake panel (`src/public/requests.js`,
|
||||
`src/request-http.mjs`): under each of the signed-in owner's latest three chat
|
||||
messages that opened a request, the thread shows the request's phase, mode,
|
||||
@@ -1047,6 +1052,24 @@ a scheduled poll accelerated in the browser test. It proves literal
|
||||
content, late unsolicited arrival, pagination, full unread count and
|
||||
unchanged delivery receipts. No live bus data or service is used.
|
||||
|
||||
## Projects, requests and files (#797)
|
||||
|
||||
Projects opens the project overview, its conversations and project information.
|
||||
A conversation retains its exact session in the URL. The navigation drawer shows
|
||||
context from the current authorized read. Sidebar display preferences stay in
|
||||
this browser. Inbox cards show the recorded decision origin and receipt details.
|
||||
|
||||
Request panels show known request state and reviewed delivery evidence. Their
|
||||
Files area and the conversation Files area read only explicitly observed request
|
||||
IDs. Draft workspace files are names only; reviewed delivery files retain their
|
||||
existing open and download controls. Missing or refused reads remain unavailable.
|
||||
|
||||
Project information can show a linked repository's configured URL or, when the
|
||||
owner has write access, save a missing HTTPS URL. Saving configures the URL only;
|
||||
it does not verify or publish it. The server rechecks authorization and refuses
|
||||
replacement of an existing URL. Granted users and tester transports do not gain
|
||||
these owner controls.
|
||||
|
||||
## Chat projects and threads (row 129, #1623)
|
||||
|
||||
Open **Chat** in the section navigation or command palette. The business project
|
||||
|
||||
@@ -81,7 +81,7 @@ try {
|
||||
if (ssoOn && sendOn) decisionWrite = consoleCall({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null });
|
||||
// #797 / #1839 Task 3: the request intake controls use the same console wrapper (the signed-in
|
||||
// (provider, iss, sub) is the session's, never the client's). Reads need --sso, writes --sso and
|
||||
// --send. consoleCall refuses any verb that is not a console verb.
|
||||
// --send. Workspace reads and project remote operations use this owner-only, verb-fenced transport.
|
||||
if (ssoOn) requestRead = consoleCall({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null });
|
||||
if (ssoOn && sendOn) requestWrite = requestRead;
|
||||
busLine = `Bus: ${options.business ?? 'the running bus host\'s business'}`;
|
||||
|
||||
@@ -19,7 +19,9 @@ export const agentCaller = (call, verbs) => async (verb, args) => {
|
||||
|
||||
// `send` is the --send flag: without it every write is null. `owner` is the owner's transport for
|
||||
// a granted user's options; it goes to createChat, which sends it only the `plugins` read.
|
||||
// Request workspace reads use the owner console transport, never this shared user transport.
|
||||
export function consoleBus(call, { send: sendOn = false, owner = null } = {}) {
|
||||
// Project remote operations are deliberately absent: this object is also used by granted users.
|
||||
const o = {
|
||||
proxyCall: agentCaller(call, PROXY_VERBS),
|
||||
processing: { read: () => call('processing.status'), halt: args => call('processing.halt', args), resume: args => call('processing.resume', args) },
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// Owner Console transport only. No caller-supplied business, actor or subject.
|
||||
import { parseGiteaUrl } from '../../business/src/vocabulary.mjs';
|
||||
export const PROJECT_REMOTE_ROUTE = /^\/api\/project-remotes\/([^/]+)$/;
|
||||
const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,159}$/;
|
||||
const one = (req, key) => req.headersDistinct[key]?.length === 1 ? req.headersDistinct[key][0] : null;
|
||||
const codes = {
|
||||
'project-remote-unknown': 404, 'project-remote-library': 403, 'project-remote-invalid': 400,
|
||||
'project-remote-configured': 409, 'project-remote-protected': 409, 'revision-conflict': 409,
|
||||
'organization-busy': 409, 'human-required': 403,
|
||||
};
|
||||
function json(res, status, value) {
|
||||
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
||||
res.end(JSON.stringify(value));
|
||||
}
|
||||
export async function projectRemoteHTTP(req, res, { path, read, write, session, live, origin }) {
|
||||
const refuse = (status, error) => json(res, status, { error });
|
||||
if (!['GET', 'POST'].includes(req.method)) return refuse(405, 'method-not-allowed');
|
||||
if (!session) return refuse(401, 'sign-in-required');
|
||||
if (!(req.method === 'GET' ? read : write)) return refuse(403, 'read-only');
|
||||
const site = one(req, 'sec-fetch-site'), from = one(req, 'origin');
|
||||
if (req.method === 'POST' ? from !== origin || site !== 'same-origin'
|
||||
: (req.headersDistinct.origin && from !== origin) || (req.headersDistinct['sec-fetch-site'] && !['same-origin', 'none'].includes(site)))
|
||||
return refuse(403, 'cross-origin');
|
||||
let project;
|
||||
try { project = decodeURIComponent(PROJECT_REMOTE_ROUTE.exec(path)?.[1] ?? ''); } catch { return refuse(404, 'project-remote-unknown'); }
|
||||
if (!ID.test(project)) return refuse(404, 'project-remote-unknown');
|
||||
let args = { project };
|
||||
if (req.method === 'POST') {
|
||||
if (one(req, 'content-type')?.split(';')[0].trim().toLowerCase() !== 'application/json') return refuse(400, 'invalid-request');
|
||||
const chunks = []; let size = 0;
|
||||
for await (const chunk of req) { size += chunk.length; if (size > 4096) return refuse(413, 'invalid-request'); chunks.push(chunk); }
|
||||
let body;
|
||||
try { body = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { return refuse(400, 'invalid-request'); }
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body) || Object.keys(body).length !== 2 || !Object.hasOwn(body, 'revision') || !Object.hasOwn(body, 'remote') ||
|
||||
typeof body.revision !== 'string' || !/^[a-f0-9]{64}$/.test(body.revision) || typeof body.remote !== 'string' || body.remote.length > 2048)
|
||||
return refuse(400, 'invalid-request');
|
||||
args = { ...args, revision: body.revision, remote: body.remote };
|
||||
}
|
||||
if (live() !== session) return refuse(401, 'sign-in-required');
|
||||
const subject = { provider: session.provider, iss: session.iss, sub: session.sub };
|
||||
let result;
|
||||
try { result = await (req.method === 'GET' ? read : write)(req.method === 'GET' ? 'project.remote.get.console' : 'project.remote.set.console', args, { subject }); }
|
||||
catch (error) { const code = codes[error.code] ? error.code : 'outcome-unknown'; return refuse(codes[code] ?? 503, code); }
|
||||
// A write admitted at spawn may already have committed. Return its receipt rather than
|
||||
// reporting a sign-out as if the operation had not happened. Reads can still be withheld.
|
||||
if (req.method === 'GET' && live() !== session) return refuse(401, 'sign-in-required');
|
||||
if (!result || result.project !== project || !/^[a-f0-9]{64}$/.test(result.revision ?? '') || !(result.remote === null || parseGiteaUrl(result.remote, { repo: true }).url) || typeof result.firstSaveAllowed !== 'boolean' || result.firstSaveAllowed !== (result.remote === null))
|
||||
return refuse(503, 'outcome-unknown');
|
||||
return json(res, 200, { project: result.project, remote: result.remote, revision: result.revision, firstSaveAllowed: result.firstSaveAllowed });
|
||||
}
|
||||
@@ -247,9 +247,9 @@
|
||||
// ---------------------------------------------------------------- views
|
||||
let inboxCount = null, messageCount = null;
|
||||
function sections(route) {
|
||||
const items = [['/', 'Board', '', 'board'], ['/inbox', 'Inbox', inboxCount ? `<span class="count" aria-label="${inboxCount.open} open for you">${inboxCount.open}</span>${inboxCount.blocking ? ` ${blockChip(`${inboxCount.blocking} blocking`)}` : ''}` : '', 'inbox'], ['/chat', 'Chat', '', 'inbox'], ['/messages', 'Messages', messageCount === null ? '' : `<span class="count" aria-label="${messageCount} unread messages">${messageCount}</span>`, 'inbox'], ['/tasks', 'Tasks', '', 'tasks'], ['/agents', 'Agents', '', 'agents'], ['/queue', 'Queue', '', 'queue'], ['/business', 'Business', '', 'business'], ['/docs', 'Docs', '', 'library'], ['/settings', 'Settings', '', 'settings']];
|
||||
const items = [['/', 'Board', '', 'board'], ['/inbox', 'Inbox', inboxCount ? `<span class="count" aria-label="${inboxCount.open} open for you">${inboxCount.open}</span>${inboxCount.blocking ? ` ${blockChip(`${inboxCount.blocking} blocking`)}` : ''}` : '', 'inbox'], ['/projects', 'Projects', '', 'inbox'], ['/messages', 'Messages', messageCount === null ? '' : `<span class="count" aria-label="${messageCount} unread messages">${messageCount}</span>`, 'inbox'], ['/tasks', 'Tasks', '', 'tasks'], ['/agents', 'Agents', '', 'agents'], ['/queue', 'Queue', '', 'queue'], ['/business', 'Business', '', 'business'], ['/docs', 'Docs', '', 'library'], ['/settings', 'Settings', '', 'settings']];
|
||||
$('sections').innerHTML = items.map(([h, l, extra, icon]) => {
|
||||
const cur = h === '/' ? route === '/' : route.startsWith(h) || (h === '/tasks' && route.startsWith('/trail/task')) || (h === '/inbox' && (route.startsWith('/trail/decision') || route.startsWith('/trail/message')));
|
||||
const cur = h === '/' ? route === '/' : route.startsWith(h) || (h === '/projects' && route.startsWith('/chat')) || (h === '/tasks' && route.startsWith('/trail/task')) || (h === '/inbox' && (route.startsWith('/trail/decision') || route.startsWith('/trail/message')));
|
||||
return `<li><a href="#${h}" class="s1-section"${cur ? ' aria-current="page"' : ''}><svg class="i" aria-hidden="true"><use href="/icons.svg#i-${icon}"/></svg>${l}${extra ? ` ${extra}` : ''}</a></li>`;
|
||||
}).join('');
|
||||
}
|
||||
@@ -260,7 +260,7 @@
|
||||
return `<li class="wait-item s1-dec${d.blocking ? ' is-attn' : ''}"><div class="wait-head"><a href="#/inbox/${enc(d.id)}" class="s1-dec-open">${txt(first(d.question))}</a></div>
|
||||
<p class="s1-badges">${d.blocking ? blockChip('blocking') : ''}${chip(d.class, d.action)}${canDecide && d.console ? ` ${badge(d.console.kind === 'decision' ? 'Answer here' : 'Approve or Deny here', 'ok')}` : ''}</p>
|
||||
<p class="small s1-auth">${approves(d.authorization)}</p>
|
||||
<p class="small muted">Raised by ${txt(d.raised_by_role)} ${when(d.at)}${d.task_ref ? ` · task ${taskLink(d.task_ref)}` : ''}${d.requirement_ref ? ` · ${txt(d.requirement_ref)}` : ''}</p></li>`;
|
||||
${window.MosaicInbox?.cardMeta?.(d) ?? `<p class="small muted">Raised by ${txt(d.raised_by_role)} ${when(d.at)}</p>`}<p class="small muted">${d.task_ref ? ` · task ${taskLink(d.task_ref)}` : ''}${d.requirement_ref ? ` · ${txt(d.requirement_ref)}` : ''}</p></li>`;
|
||||
}
|
||||
// Row 76 (#1570, lead decision 95): a human message to a held role, only when the server says
|
||||
// a send can pass here (`send` in /api/config). The draft lives in `draft`, not the DOM, so the
|
||||
@@ -337,11 +337,11 @@
|
||||
let body;
|
||||
if (a.result) {
|
||||
const r = a.result;
|
||||
body = `<p class="receipt" id="decide-status" tabindex="-1">${r.kind === 'decision' ? 'Recorded' : r.action === 'approve' ? 'Approved' : 'Denied'}: you chose “${txt(r.choice)}”, ${txt(r.text)}.${r.kind === 'decision' ? '' : ` ${r.grant === 'started' ? 'The grant executor started.' : r.action === 'approve' ? 'The bus started no grant for it.' : 'Nothing starts for a denial.'}`} History below shows it once the bus is read again.</p>`;
|
||||
body = `<p class="receipt" id="decide-status" tabindex="-1">${r.kind === 'decision' ? 'Recorded' : r.action === 'approve' ? 'Approved' : 'Denied'}: you chose “${txt(r.choice)}”, ${txt(r.text)}.${r.kind === 'decision' ? '' : ` ${r.grant === 'started' ? 'The grant executor started.' : r.action === 'approve' ? 'The bus started no grant for it.' : 'Nothing starts for a denial.'}`} History below shows it once the bus is read again.</p>${window.MosaicInbox?.receipt?.(r, a.decision) ?? ''}`;
|
||||
} else if (a.confirm) {
|
||||
const c = a.confirm, label = `Confirm ${c.action}`;
|
||||
body = `<div class="s1-confirm" role="group" aria-labelledby="b-confirm"><h3 id="b-confirm" class="small">${label}</h3>
|
||||
<dl class="kv"><dt>Id</dt><dd><code>${txt(c.id)}</code></dd><dt>Question</dt><dd class="s1-q">${txt(c.question)}</dd><dt>Answer</dt><dd><b class="s1-key">${txt(c.choice)}</b> ${txt(c.text)}</dd>${c.kind === 'decision' ? '' : `<dt>Grant</dt><dd>${grantText(c.grant)}</dd>`}</dl>
|
||||
${window.MosaicInbox?.detailMeta?.(a.decision) ?? ''}${window.MosaicInbox?.receipt?.(c, a.decision) ?? ''}<dl class="kv"><dt>Id</dt><dd><code>${txt(c.id)}</code></dd><dt>Question</dt><dd class="s1-q">${txt(c.question)}</dd><dt>Answer</dt><dd><b class="s1-key">${txt(c.choice)}</b> ${txt(c.text)}</dd>${c.kind === 'decision' ? '' : `<dt>Grant</dt><dd>${grantText(c.grant)}</dd>`}</dl>
|
||||
<p class="small muted">${c.kind === 'decision' ? 'Confirm to record this choice.' : c.action === 'approve' ? 'Approving starts this grant.' : 'Denying starts nothing.'} This confirmation works once and lasts two minutes.</p>
|
||||
<div class="s1-cmd"><button type="button" class="btn primary" data-decide="confirm"${off}>${a.pending ? 'Answering…' : label}</button><button type="button" class="btn" data-decide="cancel"${off}>Cancel</button></div></div>`;
|
||||
} else {
|
||||
@@ -406,7 +406,7 @@
|
||||
if (answer !== a) { if (res?.type === 'opaqueredirect') signOut(); else render(false); return; }
|
||||
if (res?.type === 'opaqueredirect') { a.error = { code: 'sign-in-required', message: 'Sign in again before answering. The sign-in proxy redirected the answer, so it never reached the Console.' }; paintDecide('#decide-status'); signOut(); return; }
|
||||
if (res?.ok && json?.id === a.id && json.op === 'resolved' && json.choice === c.choice) {
|
||||
a.result = { kind: c.kind, action: c.action, choice: c.choice, text: c.text, grant: json.grant ?? null };
|
||||
a.result = { kind: c.kind, action: c.action, choice: c.choice, text: c.text, grant: json.grant ?? null, grantDetails: c.grant ?? null };
|
||||
paintDecide('#decide-status'); announce(c.kind === 'decision' ? 'Recorded' : c.action === 'approve' ? 'Approved' : 'Denied'); render(false);
|
||||
} else { a.error = decideRefusal(res, json, 'outcome-unknown'); paintDecide('#decide-status'); announce(`Not answered: ${a.error.message}`); }
|
||||
}
|
||||
@@ -419,7 +419,7 @@
|
||||
: 'Decisions are answered in a terminal with <code>mosaic decide</code>; Console only shows them.';
|
||||
return `${head('Inbox', `${readNote()} ${answered}`)}
|
||||
${await composer(get)}
|
||||
<h2 id="b-for-you">For you <span class="count">${rows.length}</span></h2>${body}
|
||||
<h2 id="b-for-you">Open decisions <span class="count">${rows.length}</span></h2>${body}
|
||||
<p class="small muted">Not shown here yet: ${gap('Decisions routed to roles')} ${gap('Closed decisions')} ${gap('Seen and DM state per row')} Open a decision for its seen, DM and closing history.</p>`;
|
||||
}
|
||||
async function messagesView(get, q) {
|
||||
@@ -453,6 +453,7 @@
|
||||
answer.console = eligible;
|
||||
}
|
||||
else if (!eligible && !answer.pending && !answer.result) answer = null;
|
||||
if (answer?.id === d.id) answer.decision = d;
|
||||
const options = `<ol class="s1-opts">${d.options.map(o => {
|
||||
const cmd = `mosaic decide ${d.id} ${o.key}`, chosen = c?.choice === o.key, approving = d.authorization?.approvalChoice === o.key;
|
||||
const copy = !answerable ? '' : SAFE.test(String(d.id)) && SAFE.test(String(o.key))
|
||||
@@ -471,7 +472,7 @@
|
||||
: answerable ? `<p class="small muted">To answer, run one of these in a terminal. Copy only puts the command on your clipboard. Console sends nothing.${why}</p>`
|
||||
: !c ? `<p class="small muted">This decision is routed to ${txt(d.routeTo)}, not to you, so Console offers no command for it.</p>` : '';
|
||||
return `${head(`Decision ${txt(short(d.id))}`, status)}
|
||||
<section aria-labelledby="b-q" class="panel"><h2 id="b-q">Question</h2><p class="s1-q">${txt(d.question)}</p>
|
||||
<section aria-labelledby="b-q" class="panel"><h2 id="b-q">Question</h2><p class="s1-q">${txt(d.question)}</p>${window.MosaicInbox?.detailMeta?.(d) ?? ''}
|
||||
<dl class="kv"><dt>Class</dt><dd>${chip(d.class, d.action)}${d.blocking ? ` ${blockChip('blocking')}` : ''}</dd><dt>Approval</dt><dd class="s1-auth">${approves(d.authorization)}</dd><dt>Routed to</dt><dd>${txt(d.routeTo)}</dd><dt>Raised by</dt><dd>${txt(d.raisedBy.role)} <span class="source">${txt(d.raisedBy.run)}</span></dd><dt>Raised</dt><dd>${when(d.at)}</dd>${d.taskRef ? `<dt>Task</dt><dd>${taskLink(d.taskRef)} <span class="source">${txt(d.taskRef)}</span></dd>` : ''}${d.requirementRef ? `<dt>Requirement</dt><dd>${txt(d.requirementRef)}</dd>` : ''}<dt>DM</dt><dd>${d.dm ? `sent ${when(d.dm.at)}` : d.blocking ? 'not sent' : 'not needed (not blocking)'}</dd><dt>Id</dt><dd><code>${txt(d.id)}</code></dd></dl></section>
|
||||
${answer?.id === d.id ? decideHtml() : ''}
|
||||
<section aria-labelledby="b-o"><h2 id="b-o">Options</h2>${help}${options}<p class="small muted" id="copy-status"></p></section>
|
||||
@@ -972,7 +973,7 @@
|
||||
? `<dl class="kv"><dt>Binding</dt><dd><code>${txt(n.binding)}</code></dd></dl><p class="small muted">The binding name only. Its channels, server and user ids stay in the binding file.</p>`
|
||||
: empty('No notifier binding to show.', `${n.refused ? `<code>${txt(n.refused)}</code>: ` : ''}${txt(n.message || 'the notifier settings could not be read')}`);
|
||||
return `${head('Settings', `${readLine()} Agent limits and appearance.`)}${appearance()}${launchForm(s.launch)}${routesSection(routes)}${assistantSection(s.launch, names)}${instancesSection(instances)}${agentsSection(instances)}${tokenLimits(limitRows, limitsRefused)}${tokenUsage(usage)}
|
||||
<section class="panel" aria-labelledby="st-model-proxy"><h2 id="st-model-proxy">Model proxy</h2><p>Manage this instance’s model accounts, priorities and client key IDs.</p><a href="/settings/model-proxy">Open model proxy settings</a></section>
|
||||
<section class="panel"><h2>Sidebar display</h2><p>Choose how this browser shows the sidebar.</p><a href="#/settings/display">Sidebar display</a></section><section class="panel" aria-labelledby="st-model-proxy"><h2 id="st-model-proxy">Model proxy</h2><p>Manage this instance’s model accounts, priorities and client key IDs.</p><a href="/settings/model-proxy">Open model proxy settings</a></section>
|
||||
<section aria-labelledby="st-notify"><h2 id="st-notify">Notifications</h2>${notify}</section>
|
||||
<section aria-labelledby="st-about" class="panel"><h2 id="st-about">About</h2><dl class="kv"><dt>Address</dt><dd><code>${txt(s.address) || 'unknown'}</code></dd><dt>Release</dt><dd>${s.release ? `<code>${txt(s.release)}</code>` : '<span class="muted">unknown: no RELEASE file was read</span>'}</dd><dt>Business</dt><dd>${txt(s.business) || none}</dd><dt>Board</dt><dd><code>${txt(s.board) || 'unknown'}</code></dd></dl></section>`;
|
||||
}
|
||||
@@ -1251,11 +1252,22 @@
|
||||
document.body.classList.toggle('on-bus', !board);
|
||||
$('bus-view').hidden = board;
|
||||
sections(path);
|
||||
window.MosaicChat?.leave(path);
|
||||
if (path === '/chat' || path.startsWith('/chat/')) {
|
||||
// Conversations owns the project tree. The optional mount receives only the route,
|
||||
// and must obtain authorized project data itself. It may be absent during integration.
|
||||
window.MosaicProjectTree?.mount?.({ container: $('project-tree'), path });
|
||||
document.querySelectorAll('.rail a').forEach(a => { const target = a.getAttribute('href').slice(1); const active = target === '/projects' ? /^\/(projects|chat)(\/|$)/.test(path) : target === '/' ? path === '/' : path === target; if (active) a.setAttribute('aria-current', 'page'); else a.removeAttribute('aria-current'); });
|
||||
const chatPath = path === '/projects' ? '/chat' : /^\/projects\/(threads|rooms)\//.test(path) ? '/chat' + path.slice('/projects'.length) : /^\/projects\/[^/]+$/.test(path) ? '/chat/projects/' + path.slice('/projects/'.length) : path;
|
||||
window.MosaicChat?.leave(chatPath);
|
||||
if (path === '/settings/display' && window.MosaicDisplay) {
|
||||
window.MosaicDisplay.render($('bus-view'), navigated); shown = route; return;
|
||||
}
|
||||
if (chatPath === '/chat' || chatPath.startsWith('/chat/') || /^\/projects\/[0-9a-f-]+\/settings$/i.test(chatPath)) {
|
||||
// Row 177 (#1675): the tester Console writes to its own threads.
|
||||
await window.MosaicChat.render({ path, container: $('bus-view'), canSend: canSend || isTester, tester: isTester, navigated });
|
||||
const focusedHeading = document.activeElement === $('bus-view').querySelector('h1');
|
||||
await window.MosaicChat.render({ path: chatPath, container: $('bus-view'), canSend: canSend || isTester, tester: isTester, navigated });
|
||||
if ((focusedHeading || shown === null) && document.activeElement === document.body) $('bus-view').querySelector('h1')?.focus({ preventScroll: true });
|
||||
if (g !== gen || out()) return;
|
||||
if (chatPath === '/chat') { const h = $('bus-view').querySelector('h1'); if (h) { h.textContent = 'Projects'; document.title = 'Projects · Mosaic Console'; } }
|
||||
shown = route; schedule(); return;
|
||||
}
|
||||
if (board) {
|
||||
@@ -1355,7 +1367,7 @@
|
||||
// Ctrl+K opens it. It lists the views, and the decisions and tasks from the last inbox and
|
||||
// tasks reads (opening it reads both again, GET only). Enter goes; Esc closes and puts focus
|
||||
// back where it was. It only navigates.
|
||||
const VIEWS = [['#/', 'Board'], ['#/chat', 'Chat'], ['#/inbox', 'Inbox'], ['#/messages', 'Messages'], ['#/tasks', 'Tasks'], ['#/agents', 'Agents'], ['#/queue', 'Queue'], ['#/business', 'Business'], ['#/docs', 'Docs'], ['#/settings', 'Settings']];
|
||||
const VIEWS = [['#/', 'Board'], ['#/projects', 'Projects'], ['#/inbox', 'Inbox'], ['#/messages', 'Messages'], ['#/tasks', 'Tasks'], ['#/agents', 'Agents'], ['#/queue', 'Queue'], ['#/business', 'Business'], ['#/docs', 'Docs'], ['#/settings', 'Settings'], ['#/settings/display', 'Sidebar display']];
|
||||
let pkAll = [], pkShown = [], pkAt = 0, pkBack = null;
|
||||
function pkItems() {
|
||||
const decisions = (last.get('inbox')?.rows || []).map(d => ({ href: `#/inbox/${enc(d.id)}`, label: first(d.question), group: `decision ${short(d.id)}` }));
|
||||
@@ -1461,8 +1473,86 @@
|
||||
}
|
||||
if (!canSend && !isTester && !ssoOn) return;
|
||||
const path = routeOf().split('?')[0];
|
||||
if (path.startsWith('/chat')||path.startsWith('/inbox')||path==='/settings'||path.startsWith('/docs/')) render(false); else if (path === '/' || path === '') paintHeld(heldErr);
|
||||
if (path.startsWith('/chat')||path.startsWith('/projects')||path.startsWith('/inbox')||path==='/settings'||path.startsWith('/docs/')) render(false); else if (path === '/' || path === '') paintHeld(heldErr);
|
||||
}).catch(() => {});
|
||||
// This context comes from the conversations renderer after its authorized read.
|
||||
let projectContext = null;
|
||||
document.addEventListener('mosaic-project-context', event => {
|
||||
const detail = event.detail;
|
||||
if (detail && typeof detail.projectId === 'string') projectContext = detail;
|
||||
});
|
||||
document.addEventListener('mosaic-signed-out', () => { projectContext = null; $('project-context').replaceChildren(); });
|
||||
// Normalize the renderer's legacy project links without changing the chat API.
|
||||
function projectChrome() {
|
||||
const path = routeOf().split('?')[0];
|
||||
if (!(path === '/chat' || path.startsWith('/chat/') || path === '/projects' || path.startsWith('/projects/'))) { $('project-context').replaceChildren(); $('project-context').dataset.signature = ''; return; }
|
||||
const view = $('bus-view');
|
||||
const context = $('project-context');
|
||||
const conversation = view.querySelector('.chat-conversation, .chat-room');
|
||||
const projectLink = conversation?.querySelector('p a[href^="#/chat/projects/"], p a[href^="#/projects/"]');
|
||||
const overview = /^\/(?:projects\/|chat\/projects\/)([0-9a-f-]+)$/i.exec(path);
|
||||
const validContext = projectContext && (overview?.[1] === projectContext.projectId || conversation?.dataset.thread === projectContext.thread || path.includes('/rooms/' + projectContext.projectId) || path === '/projects/' + projectContext.projectId + '/settings');
|
||||
const project = validContext ? (projectLink?.textContent || view.querySelector('.chat-area h1')?.textContent || projectContext.projectId) : '';
|
||||
const name = conversation?.querySelector('h1')?.textContent || '';
|
||||
const href = validContext ? '#/projects/' + encodeURIComponent(projectContext.projectId) : '';
|
||||
const uuid = value => typeof value === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
|
||||
const threadHref = validContext && uuid(projectContext.thread) ? '#/projects/threads/' + encodeURIComponent(projectContext.thread) + (uuid(projectContext.session) ? '/sessions/' + encodeURIComponent(projectContext.session) : '') : '';
|
||||
const signature = JSON.stringify([project || '', name, href, threadHref]);
|
||||
if (context.dataset.signature !== signature) {
|
||||
context.dataset.signature = signature; context.replaceChildren();
|
||||
if (project && href) { const label = document.createElement('p'); label.className = 'tree-title'; label.textContent = 'Current project'; const link = document.createElement('a'); link.href = href; link.textContent = project; link.className = 'context-project'; context.append(label, link); if (name) { const title = document.createElement(threadHref ? 'a' : 'p'); title.className = 'context-conversation'; title.textContent = name; if (threadHref) title.href = threadHref; context.append(title); } }
|
||||
}
|
||||
for (const a of view.querySelectorAll('a[href^="#/chat"]')) {
|
||||
const h = a.getAttribute('href');
|
||||
const canonical = h === '#/chat' ? '#/projects' : h.startsWith('#/chat/projects/') ? '#/projects/' + h.slice('#/chat/projects/'.length) : h.replace(/^#\/chat\/(threads|rooms)\//, '#/projects/$1/');
|
||||
if (canonical !== h) a.setAttribute('href', canonical);
|
||||
if (a.textContent === 'Chat projects') a.textContent = 'Projects';
|
||||
}
|
||||
if (path === '/chat' || path === '/projects') {
|
||||
const h = view.querySelector('h1');
|
||||
if (h && h.textContent === 'Chat') { h.textContent = 'Projects'; document.title = 'Projects · Mosaic Console'; }
|
||||
}
|
||||
}
|
||||
let lastViewFocus = null;
|
||||
$('bus-view').addEventListener('focusin', e => { lastViewFocus = e.target; });
|
||||
new MutationObserver(() => {
|
||||
projectChrome();
|
||||
if (lastViewFocus && !lastViewFocus.isConnected && document.activeElement === document.body) {
|
||||
const replacement = lastViewFocus.id && document.getElementById(lastViewFocus.id);
|
||||
(replacement && !replacement.disabled ? replacement : $('bus-view').querySelector('h1'))?.focus({ preventScroll: true });
|
||||
}
|
||||
}).observe($('bus-view'), { childList: true, subtree: true });
|
||||
// The drawer keeps the background inert and returns focus to its opener.
|
||||
const nav = $('console-nav'), opener = $('nav-open'), closer = $('nav-close'), scrim = $('nav-scrim');
|
||||
const processing = $('processing-controls'), mobileProcessing = $('processing-mobile');
|
||||
function placeProcessing() {
|
||||
if (innerWidth < 760 && processing.parentElement !== mobileProcessing) mobileProcessing.append(processing);
|
||||
else if (innerWidth >= 760 && processing.parentElement !== nav) nav.insertBefore(processing, nav.querySelector(':scope > .tree-title'));
|
||||
}
|
||||
placeProcessing();
|
||||
let drawerOpen = false;
|
||||
const background = [document.querySelector('.cmdbar'), document.querySelector('.rail'), mobileProcessing, $('main'), $('inspector'), document.querySelector('.foot')];
|
||||
function drawer(open, restore = true) {
|
||||
drawerOpen = open; document.body.classList.toggle('nav-open', open);
|
||||
opener.setAttribute('aria-expanded', String(open)); scrim.hidden = !open;
|
||||
background.forEach(el => { if (el) el.inert = open; });
|
||||
if (open) { $('nav-overflow').hidden = nav.scrollHeight <= nav.clientHeight + 1; closer.focus(); }
|
||||
else if (restore) opener.focus();
|
||||
}
|
||||
opener.addEventListener('click', () => drawer(true));
|
||||
closer.addEventListener('click', () => drawer(false));
|
||||
scrim.addEventListener('click', () => drawer(false));
|
||||
document.addEventListener('keydown', e => {
|
||||
if (!drawerOpen) return;
|
||||
if (e.key === 'Escape') { e.preventDefault(); drawer(false); return; }
|
||||
if (e.key !== 'Tab') return;
|
||||
const controls = [...nav.querySelectorAll('a[href],button:not(:disabled),input:not(:disabled),select:not(:disabled),[tabindex="0"]')].filter(el => el.getClientRects().length);
|
||||
if (!controls.length) return;
|
||||
if (e.shiftKey && document.activeElement === controls[0]) { e.preventDefault(); controls.at(-1).focus(); }
|
||||
else if (!e.shiftKey && document.activeElement === controls.at(-1)) { e.preventDefault(); controls[0].focus(); }
|
||||
});
|
||||
nav.addEventListener('click', e => { if (drawerOpen && e.target.closest('a[href^="#/"]')) drawer(false, false); });
|
||||
window.addEventListener('resize', () => { if (drawerOpen && innerWidth >= 760) drawer(false, false); placeProcessing(); if (drawerOpen) $('nav-overflow').hidden = nav.scrollHeight <= nav.clientHeight + 1; });
|
||||
window.addEventListener('hashchange', () => render(true));
|
||||
$('refresh').addEventListener('click', () => render(false));
|
||||
$('pause').addEventListener('click', () => setTimeout(schedule)); // after app.js flips aria-pressed
|
||||
|
||||
@@ -65,3 +65,13 @@
|
||||
.request-prd { margin-block: .75rem; border-block: 1px solid var(--border); padding-block: .5rem; }
|
||||
.request-prd pre { white-space: pre-wrap; overflow-wrap: anywhere; font-family: var(--mono); }
|
||||
.request-requirement { margin-block: .75rem; }
|
||||
|
||||
/* Project conversation layout, scoped to this surface. */
|
||||
.chat-area { overflow-wrap: anywhere; }
|
||||
.chat-area h1 { font-size: clamp(1.375rem, 2vw, 2rem); }
|
||||
.chat-area .btn, .chat-area summary { min-height: 44px; }
|
||||
.chat-details, .chat-files, .chat-room-picker { margin-block: 1rem; padding: .8rem; border: 1px solid var(--line); border-radius: 4px; }
|
||||
.chat-details summary { cursor: pointer; }
|
||||
.chat-room-picker label { display: block; padding-block: .45rem; }
|
||||
.chat-audience { margin: .25rem 0; }
|
||||
.chat-projects .panel { border-radius: 4px; }
|
||||
@@ -6,7 +6,7 @@
|
||||
const inert = value => String(value ?? '').replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f\u202a-\u202e\u2066-\u2069]/g,
|
||||
c => '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0'));
|
||||
const txt = value => esc(inert(value));
|
||||
const states = new Map(), forms = new Map();
|
||||
const states = new Map(), forms = new Map(), knownRequests = new Map();
|
||||
let current = null, controller = null, generation = 0, signedOut = false;
|
||||
const out = () => signedOut || document.body.classList.contains('signed-out');
|
||||
const state = id => {
|
||||
@@ -33,7 +33,7 @@
|
||||
}
|
||||
const channelMessage = 'The chat channel is disabled for this business. Ask the person who manages it to enable WebUI chat, then refresh.';
|
||||
function forget() {
|
||||
generation++; controller?.abort(); states.clear(); forms.clear(); libraries.clear(); reverts.clear();
|
||||
generation++; controller?.abort(); states.clear(); forms.clear(); knownRequests.clear(); libraries.clear(); reverts.clear();
|
||||
window.MosaicRequests?.forget();
|
||||
if (current) current.container.replaceChildren();
|
||||
current = null;
|
||||
@@ -64,7 +64,11 @@
|
||||
}
|
||||
return data;
|
||||
}
|
||||
const link = (type, id, label) => UUID.test(id) ? `<a href="#/chat/${type}/${id}">${txt(label)}</a>` : txt(label);
|
||||
const legacyRoute = () => location.hash.startsWith('#/chat');
|
||||
const projectPath = id => legacyRoute() ? `#/chat/projects/${id}` : `#/projects/${id}`;
|
||||
const threadPath = id => legacyRoute() ? `#/chat/threads/${id}` : `#/projects/threads/${id}`;
|
||||
const roomPath = id => legacyRoute() ? `#/chat/rooms/${id}` : `#/projects/rooms/${id}`;
|
||||
const link = (type, id, label) => UUID.test(id) ? `<a href="${type === 'projects' ? projectPath(id) : threadPath(id)}">${txt(label)}</a>` : txt(label);
|
||||
const heading = text => `<h1 tabindex="-1">${txt(text)}</h1>`;
|
||||
const selectedInactive = () => { const s = current?.sessions?.find(r => r.id === current.session); return !!s?.state && s.state !== 'active'; };
|
||||
const disabledSession = pending => disabled(pending || selectedInactive());
|
||||
@@ -93,7 +97,7 @@
|
||||
function threads(rows) {
|
||||
return rows.length ? `<ul class="chat-thread-list">${rows.map(t => `<li>${link('threads', t.id, t.name)} <span class="muted">${txt(t.instance)}${t.shared ? ` · Shared by ${txt(t.owner)}` : ''}</span></li>`).join('')}</ul>` : current.readFailed ? '<p>Threads unavailable.</p>' : '<p>No threads yet.</p>';
|
||||
}
|
||||
const sessionPath = (thread, session) => `#/chat/threads/${thread}/sessions/${session}`;
|
||||
const sessionPath = (thread, session) => `${threadPath(thread)}/sessions/${session}`;
|
||||
const countLabel = value => !value ? 'unavailable (count collector not installed)' :
|
||||
value.known === true && Number.isSafeInteger(value.count) && value.count >= 0 ? String(value.count) :
|
||||
`unknown (${value.reason ?? 'not-collected'})`;
|
||||
@@ -205,7 +209,7 @@
|
||||
const withRequest = requestRows(s.rows);
|
||||
const messages = s.rows.map(m => `<li data-message="${esc(m.id)}"><div class="chat-message-meta"><strong>${txt(m.from_role)}</strong> ${stamp(m.at)}</div>${messageBody(m)}${withRequest.has(m.id) ? `<div class="request-mount" data-request="${esc(m.request)}"></div>` : ''}</li>`).join('');
|
||||
const receipt = s.receipt;
|
||||
return `<section class="chat-conversation" data-thread="${esc(t.id)}" data-session="${esc(current.session)}"><p>${link('projects', t.project, current.projects.find(p => p.id === t.project)?.name ?? 'Project')} / ${txt(t.instance)}</p>${heading(t.name)}${libraryLine(libraryOf(current, t))}${sessionControls(t)}${sharing(t)}<p class="muted">Reading never wakes the assistant. An accepted message is not an answer.</p>${threadWrites() ? `<button class="btn" type="button" id="chat-resume"${disabledSession(s.pending || current.channelDisabled)}>Resume assistant</button>` : ''}<ol class="chat-log" tabindex="0" aria-label="Thread conversation">${messages || (current.readFailed ? '<li class="muted">Messages unavailable.</li>' : '<li class="muted">No messages yet.</li>')}</ol><div class="chat-paging">${current.readFailed ? '' : s.more ? '<p>Showing earlier history. Read forward to reach the current conversation.</p><button class="btn" type="button" id="chat-next">Read next messages</button>' : '<p class="muted">Caught up to the last read.</p>'}<button class="btn" type="button" id="chat-new"${s.follow ? ' hidden' : ''}>New messages — go to end</button></div>${threadWrites() ? `<form id="chat-send-form"><label for="chat-body">Message this thread</label><textarea id="chat-body" rows="4"${disabledSession(s.pending)}>${esc(s.draft)}</textarea><button class="btn primary" type="submit"${disabledSession(s.pending || current.channelDisabled)}>Send</button></form>` : ''}<p id="chat-receipt" role="status">${txt(receipt)}</p></section>`;
|
||||
return `<section class="chat-conversation" data-thread="${esc(t.id)}" data-session="${esc(current.session)}"><p>${link('projects', t.project, current.projects.find(p => p.id === t.project)?.name ?? 'Project')} / ${txt(t.instance)}</p>${heading(t.name)}${libraryLine(libraryOf(current, t))}<details class="chat-details"><summary id="chat-details-toggle">Conversation details</summary>${sessionControls(t)}${sharing(t)}</details><p class="muted">Reading never wakes the assistant. An accepted message is not an answer.</p>${threadWrites() ? `<button class="btn" type="button" id="chat-resume"${disabledSession(s.pending || current.channelDisabled)}>Resume assistant</button>` : ''}<ol class="chat-log" tabindex="0" aria-label="Thread conversation">${messages || (current.readFailed ? '<li class="muted">Messages unavailable.</li>' : '<li class="muted">No messages yet.</li>')}</ol><div class="chat-paging">${current.readFailed ? '' : s.more ? '<p>Showing earlier history. Read forward to reach the current conversation.</p><button class="btn" type="button" id="chat-next">Read next messages</button>' : '<p class="muted">Caught up to the last read.</p>'}<button class="btn" type="button" id="chat-new"${s.follow ? ' hidden' : ''}>New messages — go to end</button></div>${threadWrites() ? `<form id="chat-send-form"><label for="chat-body">Message this thread</label><textarea id="chat-body" rows="4"${disabledSession(s.pending)}>${esc(s.draft)}</textarea><p class="chat-audience">To: ${txt(t.instance)} · Private conversation</p><button class="btn primary" type="submit"${disabledSession(s.pending || current.channelDisabled)}>Send</button></form>` : ''}<p id="chat-receipt" role="status">${txt(receipt)}</p><section class="chat-files"><h2>Files</h2><div data-chat-files>Files unavailable.</div></section><div data-chat-summary></div></section>`;
|
||||
}
|
||||
// C2-10c: rooms reuse the same state, renderer and generation fences as threads.
|
||||
const ROOM_LINES = Object.freeze({
|
||||
@@ -232,7 +236,7 @@
|
||||
function roomOpenControl(project) {
|
||||
if (!roomOwner()) return '';
|
||||
const f = form('room-open:' + project);
|
||||
return `<section class="chat-room-entry" aria-label="Project room"><button class="btn" type="button" id="chat-room-open"${disabled(f.pending || current.channelDisabled)}>Open room</button> <a href="#/chat/rooms/${esc(project)}">Read room</a><p id="chat-room-open-receipt" role="status">${txt(f.receipt)}</p></section>`;
|
||||
return `<section class="chat-room-entry" aria-label="Project room"><button class="btn" type="button" id="chat-room-open"${disabled(f.pending || current.channelDisabled)}>Open room</button> <a href="${roomPath(esc(project))}">Read room</a><p id="chat-room-open-receipt" role="status">${txt(f.receipt)}</p></section>`;
|
||||
}
|
||||
function roomConversation() {
|
||||
const c = current, s = state(c.key), p = c.projects.find(p => p.id === c.id);
|
||||
@@ -242,7 +246,7 @@
|
||||
return `<li data-message="${esc(m.id)}"><div class="chat-message-meta"><strong>${txt(speaker)}</strong>${recipients} ${stamp(m.at)}</div>${messageBody({ ...m, from_role: m.from_role === 'human' ? m.from_role : speaker })}</li>`;
|
||||
}).join('');
|
||||
const off = !c.roomReady || c.channelDisabled || s.pending || !roomOwner();
|
||||
return `<section class="chat-room" data-project="${esc(c.id)}"><p>${link('projects', c.id, p?.name ?? 'Project')}</p>${heading((p?.name ?? 'Project') + ' room')}${roomOpenControl(c.id)}${libraryLine(libraryOf(c))}<p>Room members: ${(c.members ?? []).map(m => `${txt(m.name)} (@${txt(m.instance)})`).join(', ') || 'unavailable'}</p><ol class="chat-log chat-room-log" tabindex="0" aria-label="Room conversation">${messages || (c.readFailed ? '<li>Messages unavailable.</li>' : '<li>No messages yet.</li>')}</ol><div class="chat-paging">${c.readFailed ? '' : s.more ? '<button class="btn" type="button" id="chat-next">Read next messages</button>' : '<p class="muted">Caught up to the last read.</p>'}<button class="btn" type="button" id="chat-new"${s.follow ? ' hidden' : ''}>New messages — go to end</button></div><form id="chat-room-send-form"><label for="chat-room-body">Message the room</label><p id="chat-room-help">Without leading @tags, your message goes to every member. The broker checks the addressees.</p><textarea id="chat-room-body" rows="4" aria-describedby="chat-room-help" aria-autocomplete="list" aria-controls="chat-room-completion" aria-expanded="false"${disabled(off)}>${esc(s.draft)}</textarea><div id="chat-room-tags"></div><ul id="chat-room-completion" class="chat-room-completion" role="listbox" aria-label="Room members" hidden></ul><button class="btn primary" type="submit"${disabled(off)}>Send to room</button></form><p id="chat-room-receipt" role="status">${txt(s.receipt)}</p></section>`;
|
||||
return `<section class="chat-room" data-project="${esc(c.id)}"><p>${link('projects', c.id, p?.name ?? 'Project')}</p>${heading((p?.name ?? 'Project') + ' room')}${roomOpenControl(c.id)}${libraryLine(libraryOf(c))}<p>Messages are copied to selected contacts. Their replies are private.</p><p>Room members: ${(c.members ?? []).map(m => `${txt(m.name)} (@${txt(m.instance)})`).join(', ') || 'unavailable'}</p><ol class="chat-log chat-room-log" tabindex="0" aria-label="Room conversation">${messages || (c.readFailed ? '<li>Messages unavailable.</li>' : '<li>No messages yet.</li>')}</ol><div class="chat-paging">${c.readFailed ? '' : s.more ? '<button class="btn" type="button" id="chat-next">Read next messages</button>' : '<p class="muted">Caught up to the last read.</p>'}<button class="btn" type="button" id="chat-new"${s.follow ? ' hidden' : ''}>New messages — go to end</button></div><form id="chat-room-send-form"><fieldset class="chat-room-picker"${off ? ' disabled' : ''}><legend>Choose recipients</legend>${(c.members ?? []).map(m => `<label><input type="checkbox" id="chat-member-${esc(m.instance)}" data-room-member="${esc(m.instance)}"> ${txt(m.name)} (@${txt(m.instance)})</label>`).join('')}</fieldset><label for="chat-room-body">Message the room</label><p id="chat-room-help">Choose contacts above, or use leading @tags. Without tags, the message goes to all room members. Mentions in the message do not add recipients. The broker checks every address.</p><textarea id="chat-room-body" rows="4" aria-describedby="chat-room-help" aria-autocomplete="list" aria-controls="chat-room-completion" aria-expanded="false"${disabled(off)}>${esc(s.draft)}</textarea><div id="chat-room-offer"></div><div id="chat-room-tags"></div><ul id="chat-room-completion" class="chat-room-completion" role="listbox" aria-label="Room members" hidden></ul><button class="btn primary" type="submit"${disabled(off)}>Send to room</button></form><p id="chat-room-receipt" role="status">${txt(s.receipt)}</p></section>`;
|
||||
}
|
||||
function drawRoomCompletion() {
|
||||
const list = document.getElementById('chat-room-completion'), field = document.getElementById('chat-room-body');
|
||||
@@ -257,8 +261,12 @@
|
||||
if (current?.kind !== 'room') return;
|
||||
const field = document.getElementById('chat-room-body'), preview = document.getElementById('chat-room-tags');
|
||||
if (!field || !preview) return;
|
||||
const { tags } = roomAddress(field.value);
|
||||
preview.innerHTML = tags.length ? 'Typed addressees: ' + tags.map(tag => `<span data-room-tag="${esc(tag)}">@${txt(tag)}</span>`).join(' ') : 'All room members';
|
||||
const { tags, body } = roomAddress(field.value);
|
||||
const offer = document.getElementById('chat-room-offer');
|
||||
const mentioned = tags.length ? [...body.matchAll(/@([A-Za-z0-9][A-Za-z0-9_.-]*)/g)].map(m => m[1]).find(name => !tags.includes(name) && current.members.some(m => m.instance === name)) : null;
|
||||
if (offer) offer.innerHTML = mentioned ? `<button class="btn" type="button" data-room-offer="${esc(mentioned)}">Add ${txt(roomName(mentioned))} to recipients</button>` : '';
|
||||
preview.innerHTML = tags.length ? 'To: ' + tags.map(tag => `<span data-room-tag="${esc(tag)}">@${txt(tag)}</span>`).join(' ') : 'To: all room members';
|
||||
for (const box of document.querySelectorAll('[data-room-member]')) box.checked = tags.length ? tags.includes(box.dataset.roomMember) : true;
|
||||
const prefix = field.value.slice(0, field.selectionStart), token = /(?:^|\s)@([^\s@]*)$/u.exec(prefix);
|
||||
current.completion = null;
|
||||
if (document.activeElement === field && !field.disabled && token && !roomAddress(prefix.slice(0, token.index)).body.trim()) {
|
||||
@@ -328,8 +336,8 @@
|
||||
if (data.project !== id || !Array.isArray(data.threads) || !data.threads.length || data.threads.some(t => !UUID.test(t?.id) || typeof t.instance !== 'string' || t.name !== t.instance)) throw new ChatError('outcome-unknown');
|
||||
f.receipt = 'Room opened.';
|
||||
if (current?.id === id) {
|
||||
if (location.hash === '#/chat/rooms/' + id) void refresh();
|
||||
else location.hash = '#/chat/rooms/' + id;
|
||||
if (current?.kind === 'room' && current.id === id) void refresh();
|
||||
else location.hash = roomPath(id);
|
||||
}
|
||||
} catch (err) {
|
||||
if (out() || err.code === 'signed-out') return;
|
||||
@@ -372,12 +380,12 @@
|
||||
if (!current) return null;
|
||||
const active = document.activeElement, log = current.container.querySelector('.chat-log');
|
||||
const field = active?.id?.startsWith('chat-') ? { id: active.id, start: active.selectionStart, end: active.selectionEnd } : null;
|
||||
return { field, top: log?.scrollTop, tail: log ? log.scrollHeight - log.scrollTop - log.clientHeight < 40 : true };
|
||||
return { field, detailsOpen: current.container.querySelector('.chat-details')?.open, top: log?.scrollTop, tail: log ? log.scrollHeight - log.scrollTop - log.clientHeight < 40 : true };
|
||||
}
|
||||
function paint(navigated = false) {
|
||||
if (!current || out()) return;
|
||||
const keep = capture(), c = current;
|
||||
let html = '<div class="chat-area"><p><a href="#/chat">Chat projects</a></p>';
|
||||
let html = '<div class="chat-area"><p><a href="#/projects">Projects</a></p>';
|
||||
if (c.error) html += `<p class="chat-error banner err" role="alert">${txt(c.error)}</p>`;
|
||||
if (c.readFailed && (c.projects.length || c.threads.length || c.kind === 'thread' && state(c.key).loaded)) html += '<p role="status">Showing previously read chat content; it may be out of date.</p>';
|
||||
if (c.kind === 'room') {
|
||||
@@ -389,15 +397,36 @@
|
||||
} else if (c.kind === 'project') {
|
||||
const p = c.projects.find(p => p.id === c.id);
|
||||
html += heading(p?.name ?? 'Project unavailable');
|
||||
if (p && !c.refused) html += roomOpenControl(c.id) + threads(c.threads.filter(t => !t.shared)) + '<h2>Shared with me</h2>' + threads(c.threads.filter(t => t.shared)) + creation('thread', c.id);
|
||||
if (p && !c.refused) {
|
||||
if (c.settings) html += `<p><a href="#/projects/${esc(c.id)}">Project overview</a></p><h2>Project information</h2><p>Repository: ${txt(p.repo || 'Not linked')}</p><div data-chat-remote>Repository setting unavailable.</div>`;
|
||||
else html += `<p><a href="#/projects/${esc(c.id)}/settings">Project information</a></p>${knownRequests.get(c.id)?.repo === p.repo && knownRequests.get(c.id)?.ids.size ? '<p>Request status from conversations opened in this session. Other work may not be shown.</p><div data-chat-summary></div>' : '<p>Request status is not loaded. Open a conversation to see its requests.</p>'}<h2>Conversations</h2>` + roomOpenControl(c.id) + threads(c.threads.filter(t => !t.shared)) + '<h2>Shared with me</h2>' + threads(c.threads.filter(t => t.shared)) + creation('thread', c.id);
|
||||
}
|
||||
} else {
|
||||
html += heading('Chat') + '<p>Business projects and your named assistant threads.</p><div class="chat-projects">' +
|
||||
html += heading('Projects') + '<p>Projects and their conversations.</p><div class="chat-projects">' +
|
||||
c.projects.map(p => `<section class="panel"><h2>${link('projects', p.id, p.name)}</h2>${p.repo ? `<p class="muted">Repository: ${txt(p.repo)}</p>` : ''}${threads(c.threads.filter(t => t.project === p.id && !t.shared))}</section>`).join('') + (c.projects.length ? '' : c.readFailed ? '<p>Projects unavailable.</p>' : '<p>No projects yet.</p>') + '</div><h2>Shared with me</h2>' + threads(c.threads.filter(t => t.shared)) + creation('project', 'projects');
|
||||
}
|
||||
if (!c.canSend) html += '<p class="muted">Creating and sending require signed-in send mode. This Console only reads.</p>';
|
||||
c.container.innerHTML = html + '</div>';
|
||||
window.MosaicRequests?.mount(c.container);
|
||||
window.MosaicRequests?.mount?.(c.container);
|
||||
const t = c.kind === 'thread' ? c.thread : null;
|
||||
const repo = c.kind === 'project' ? c.projects.find(p => p.id === c.id)?.repo : libraryOf(c, t);
|
||||
const cached = knownRequests.get(c.id);
|
||||
const remembered = c.kind === 'project' && cached && cached.repo === repo ? cached.ids : [];
|
||||
const selected = t ? requestRows(state(c.key).rows) : new Set();
|
||||
const ids = !roomOwner() ? [] : t ? [...new Set(state(c.key).rows.filter(m => selected.has(m.id)).map(m => m.request))] : [...remembered].slice(-REQUEST_ROWS);
|
||||
const files = c.container.querySelector('[data-chat-files]');
|
||||
if (files && typeof window.MosaicFiles?.mount === 'function') { files.replaceChildren(); window.MosaicFiles.mount({ container: files, requestIds: ids }); }
|
||||
const summary = c.container.querySelector('[data-chat-summary]');
|
||||
if (summary && repo && ids.length) {
|
||||
if (typeof window.MosaicRequests?.projectSummary === 'function') window.MosaicRequests.projectSummary({ container: summary, requestIds: ids, project: repo });
|
||||
else summary.textContent = 'Request summary unavailable.';
|
||||
}
|
||||
const remote = c.container.querySelector('[data-chat-remote]');
|
||||
const project = c.projects.find(p => p.id === c.id);
|
||||
if (remote && roomOwner() && typeof window.MosaicProjectRemote?.mount === 'function') { remote.replaceChildren(); window.MosaicProjectRemote.mount({ container: remote, projectId: c.id, repositoryKey: project?.repo ?? null }); }
|
||||
document.title = `${c.container.querySelector('h1')?.textContent ?? 'Chat'} · Mosaic Console`;
|
||||
const details = c.container.querySelector('.chat-details');
|
||||
if (details && keep?.detailsOpen) details.open = true;
|
||||
const log = c.container.querySelector('.chat-log');
|
||||
if (log) {
|
||||
const s = state(c.key ?? c.id);
|
||||
@@ -421,6 +450,12 @@
|
||||
}
|
||||
async function render({ path, container, canSend, user = false, tester = false, navigated = false, next = false }) {
|
||||
if (out()) return;
|
||||
const originalPath = path;
|
||||
const settings = /^\/projects\/([0-9a-f-]+)\/settings$/i.exec(path);
|
||||
if (settings) path = '/chat/projects/' + settings[1];
|
||||
else if (path === '/projects') path = '/chat';
|
||||
else if (/^\/projects\/(threads|rooms)\//.test(path)) path = '/chat/' + path.slice('/projects/'.length);
|
||||
else if (/^\/projects\/[0-9a-f-]+$/i.test(path)) path = '/chat/projects/' + path.slice('/projects/'.length);
|
||||
if (/^\/chat\/rooms(?:\/|$)/.test(path)) return renderRoom({ path, container, canSend, user, tester, navigated, next });
|
||||
const match = /^\/chat(?:\/(projects|threads)\/([0-9a-f-]+)(?:\/sessions\/([0-9a-f-]+))?)?$/.exec(path);
|
||||
controller?.abort(); controller = new AbortController();
|
||||
@@ -432,7 +467,7 @@
|
||||
paint(); return;
|
||||
}
|
||||
const old = current;
|
||||
current = { container, kind, id, canSend, user, tester, chatGrants: [], thread: old && old.id === id ? old.thread : null, shared: old && old.id === id ? old.shared : false, projects: old?.projects ?? [], threads: old?.threads ?? [], repos: old?.repos ?? [], instances: old?.instances ?? [], instanceError: kind === 'project' ? 'Reading thread instances…' : '', error: '', readFailed: false, channelDisabled: old?.channelDisabled ?? false, refused: false, legacyDefault: old && old.id === id ? old.legacyDefault : false, sessions: old && old.id === id ? old.sessions ?? [] : [], session: match[3] ?? (old && old.id === id ? old.session : null), key: match[3] ? id + '/' + match[3] : old && old.id === id ? old.key : id };
|
||||
current = { container, kind, id, settings: !!settings, path: originalPath, canSend, user, tester, chatGrants: [], thread: old && old.id === id ? old.thread : null, shared: old && old.id === id ? old.shared : false, projects: old?.projects ?? [], threads: old?.threads ?? [], repos: old?.repos ?? [], instances: old?.instances ?? [], instanceError: kind === 'project' ? 'Reading thread instances…' : '', error: '', readFailed: false, channelDisabled: old?.channelDisabled ?? false, refused: false, legacyDefault: old && old.id === id ? old.legacyDefault : false, sessions: old && old.id === id ? old.sessions ?? [] : [], session: match[3] ?? (old && old.id === id ? old.session : null), key: match[3] ? id + '/' + match[3] : old && old.id === id ? old.key : id };
|
||||
if (navigated || !old) container.innerHTML = '<p class="chat-loading" role="status">Reading chat…</p>';
|
||||
try {
|
||||
if (user) {
|
||||
@@ -548,6 +583,17 @@
|
||||
} catch (err) { if (err.name === 'AbortError') return; }
|
||||
}
|
||||
if (g === generation) {
|
||||
if ((kind === 'thread' && current.thread || kind === 'project') && !current.refused) {
|
||||
const projectId = kind === 'thread' ? current.thread.project : id;
|
||||
const project = current.projects.find(p => p.id === projectId);
|
||||
if (project && kind === 'thread' && roomOwner() && typeof project.repo === 'string') {
|
||||
const prior = knownRequests.get(projectId);
|
||||
const ids = prior?.repo === project.repo ? prior.ids : new Set();
|
||||
for (const row of state(current.key).rows) if (row.from_role === 'human' && typeof row.request === 'string' && REQUEST_ID.test(row.request)) ids.add(row.request);
|
||||
knownRequests.set(projectId, { repo: project.repo, ids });
|
||||
}
|
||||
if (project) document.dispatchEvent(new CustomEvent('mosaic-project-context', { detail: { projectId, repositoryKey: project.repo ?? null, thread: kind === 'thread' ? id : null, session: kind === 'thread' ? current.session : null } }));
|
||||
}
|
||||
paint(navigated);
|
||||
const t = kind === 'thread' ? current.threads.find(t => t.id === id) : null;
|
||||
if (t) void loadLibrary(libraryOf(current, t), g, signal);
|
||||
@@ -560,6 +606,7 @@
|
||||
// A bad session selector is local to that read. Only a thread-level refusal
|
||||
// invalidates all sibling drafts, receipts and recovery operation keys.
|
||||
if (!err.sharedRead && ['thread-not-owned', 'thread-not-found', 'access-refused'].includes(err.code)) {
|
||||
knownRequests.clear();
|
||||
for (const key of states.keys()) if (key === id || key.startsWith(id + '/')) states.delete(key);
|
||||
for (const key of forms.keys()) if (key.includes(id)) forms.delete(key);
|
||||
}
|
||||
@@ -573,7 +620,7 @@
|
||||
if (current) return render({ ...current, path: location.hash.slice(1).split('?')[0], next });
|
||||
}
|
||||
function leave(path) {
|
||||
if (!path.startsWith('/chat')) { generation++; controller?.abort(); current = null; }
|
||||
if (!path.startsWith('/chat') && !path.startsWith('/projects')) { generation++; controller?.abort(); current = null; }
|
||||
}
|
||||
document.addEventListener('input', event => {
|
||||
if (!current || out()) return;
|
||||
@@ -619,7 +666,7 @@
|
||||
} else if (action === 'open') s.receipt = 'Assistant resume requested.';
|
||||
else {
|
||||
s.name = ''; s.repo = ''; s.op = null; s.payload = null;
|
||||
if (location.hash === startedRoute) location.hash = action === 'createSession' ? sessionPath(targetThread, result.id) : '#/chat/' + (action === 'createProject' ? 'projects/' : 'threads/') + result.id;
|
||||
if (location.hash === startedRoute) location.hash = action === 'createSession' ? sessionPath(targetThread, result.id) : (action === 'createProject' ? projectPath(result.id) : threadPath(result.id));
|
||||
}
|
||||
} catch (err) {
|
||||
if (out() || err.code === 'signed-out') return;
|
||||
@@ -666,6 +713,14 @@
|
||||
document.addEventListener('click', event => {
|
||||
if (!current || out()) return;
|
||||
if (event.target.id === 'chat-room-open') void openRoom();
|
||||
const offer = event.target.closest?.('[data-room-offer]');
|
||||
if (offer && current.kind === 'room') {
|
||||
const field = document.getElementById('chat-room-body'), parsed = roomAddress(field.value), name = offer.dataset.roomOffer;
|
||||
if (parsed.tags.length && current.members.some(m => m.instance === name) && !parsed.tags.includes(name)) {
|
||||
state(current.key).draft = [...parsed.tags, name].map(tag => '@' + tag).join(' ') + ' ' + parsed.body;
|
||||
paint();
|
||||
}
|
||||
}
|
||||
const option = event.target.closest?.('[data-room-complete]');
|
||||
if (option) completeRoom(option.dataset.roomComplete);
|
||||
if (event.target.dataset.unshare) void changeShare('unshare', event.target.dataset.unshare);
|
||||
@@ -678,6 +733,17 @@
|
||||
if (event.target.id === 'chat-new') { state(current.key ?? current.id).tailRequested = true; state(current.key ?? current.id).follow = true; paint(); }
|
||||
});
|
||||
document.addEventListener('change', event => {
|
||||
if (event.target.matches?.('[data-room-member]') && current?.kind === 'room') {
|
||||
const field = document.getElementById('chat-room-body'), s = state(current.key);
|
||||
const parsed = roomAddress(field.value);
|
||||
if (parsed.tags.some(tag => !current.members.some(m => m.instance === tag))) {
|
||||
s.receipt = 'Repair the unresolved address before changing recipients. Your draft is kept.'; paint(); return;
|
||||
}
|
||||
const chosen = [...document.querySelectorAll('[data-room-member]:checked')].map(el => el.dataset.roomMember);
|
||||
if (!chosen.length) { s.receipt = 'Choose at least one recipient. Your draft is kept.'; paint(); return; }
|
||||
s.draft = chosen.map(name => '@' + name).join(' ') + (parsed.body ? ' ' + parsed.body : ' ');
|
||||
s.receipt = ''; paint(); return;
|
||||
}
|
||||
if (event.target.id === 'chat-session' && current && UUID.test(event.target.value)) {
|
||||
location.hash = sessionPath(current.id, event.target.value);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/* Browser-local sidebar display preferences. No project or authority data is stored here. */
|
||||
(() => {
|
||||
const key = 'mosaic-sidebar-display-v1';
|
||||
const defaults = () => ({ version: 1, density: 'comfortable', order: 'title-first' });
|
||||
const valid = x => x && x.version === 1 && ['comfortable', 'compact'].includes(x.density) && ['title-first', 'contact-first'].includes(x.order) && Object.keys(x).sort().join(',') === 'density,order,version';
|
||||
let saved = defaults();
|
||||
try { const value = JSON.parse(localStorage.getItem(key)); if (valid(value)) saved = value; } catch { /* inaccessible or invalid storage uses defaults */ }
|
||||
let draft = { ...saved }, pending = null, dirty = false;
|
||||
const apply = () => { document.documentElement.dataset.sidebarDensity = saved.density; document.documentElement.dataset.sidebarOrder = saved.order; document.dispatchEvent(new CustomEvent('mosaic-display-change', { detail: { ...saved } })); };
|
||||
apply();
|
||||
function save() {
|
||||
try { localStorage.setItem(key, JSON.stringify(draft)); saved = { ...draft }; dirty = false; apply(); return true; }
|
||||
catch { return false; }
|
||||
}
|
||||
function status(message) { const el = document.getElementById('display-status'); if (el) el.textContent = message; }
|
||||
function render(container, navigated) {
|
||||
container.innerHTML = `<div class="page-head"><div><h1 tabindex="-1">Sidebar display</h1><p>These choices apply in this browser.</p></div></div><section class="panel display-settings"><form id="display-form"><fieldset><legend>Density</legend><label><input type="radio" name="density" value="comfortable"> Comfortable</label><label><input type="radio" name="density" value="compact"> Compact</label></fieldset><fieldset><legend>Sidebar order</legend><label><input type="radio" name="order" value="title-first"> Title first</label><label><input type="radio" name="order" value="contact-first"> Contact first</label></fieldset><p class="small muted">Preview uses the current sidebar destinations.</p><div id="display-preview" class="display-preview"></div><button class="btn primary" type="submit">Save</button><p id="display-status" role="status"></p></form></section>`;
|
||||
const form = document.getElementById('display-form');
|
||||
for (const name of ['density', 'order']) form.elements[name].value = draft[name];
|
||||
const preview = document.getElementById('display-preview');
|
||||
preview.textContent = [...document.querySelectorAll('#sections .s1-section')].map(el => el.textContent.trim()).join(' · ');
|
||||
form.addEventListener('change', () => { draft = { version: 1, density: form.elements.density.value, order: form.elements.order.value }; dirty = JSON.stringify(draft) !== JSON.stringify(saved); status(dirty ? 'Unsaved changes.' : ''); });
|
||||
form.addEventListener('submit', e => { e.preventDefault(); status(save() ? 'Sidebar display saved.' : 'Could not save to local browser storage. Your choices are kept. Try Save again, edit, or discard when leaving.'); });
|
||||
document.title = 'Sidebar display · Mosaic Console';
|
||||
if (navigated) container.querySelector('h1').focus();
|
||||
}
|
||||
const dialog = document.createElement('dialog'); dialog.className = 'display-dialog';
|
||||
dialog.setAttribute('aria-labelledby', 'display-leave-title');
|
||||
dialog.innerHTML = '<h2 id="display-leave-title">Unsaved sidebar display</h2><p>Your choices are kept while you decide.</p><p id="display-leave-status" role="status"></p><button type="button" data-choice="save">Save and continue</button> <button type="button" data-choice="edit">Keep editing</button> <button type="button" data-choice="discard">Discard and continue</button>';
|
||||
document.body.append(dialog);
|
||||
let current = location.hash || '#/';
|
||||
window.addEventListener('hashchange', e => {
|
||||
const destination = location.hash || '#/';
|
||||
if (current === '#/settings/display' && destination !== current && dirty) {
|
||||
e.stopImmediatePropagation(); pending = destination; history.replaceState(null, '', current); document.getElementById('display-leave-status').textContent = ''; dialog.showModal(); dialog.querySelector('[data-choice=save]').focus(); return;
|
||||
}
|
||||
current = destination;
|
||||
});
|
||||
dialog.addEventListener('cancel', e => { e.preventDefault(); dialog.close(); pending = null; document.querySelector('#display-form input')?.focus(); });
|
||||
dialog.addEventListener('click', e => {
|
||||
const choice = e.target.dataset.choice; if (!choice) return;
|
||||
if (choice === 'save' && !save()) { document.getElementById('display-leave-status').textContent = 'Could not save to local browser storage. Your choices are kept. Retry, keep editing, or discard.'; return; }
|
||||
if (choice === 'discard') { draft = { ...saved }; dirty = false; }
|
||||
dialog.close();
|
||||
if (choice === 'edit') { pending = null; document.querySelector('#display-form input')?.focus(); return; }
|
||||
const destination = pending; pending = null; if (choice === 'save') sessionStorageNotice(); location.hash = destination;
|
||||
});
|
||||
function sessionStorageNotice() { const a = document.getElementById('display-announcement'); if (a) a.textContent = 'Sidebar display saved.'; }
|
||||
document.addEventListener('mosaic-signed-out', () => { draft = { ...saved }; dirty = false; pending = null; if (dialog.open) dialog.close(); });
|
||||
window.MosaicDisplay = { render };
|
||||
})();
|
||||
@@ -0,0 +1,4 @@
|
||||
.mosaic-files { min-width: 0; }
|
||||
.mosaic-files-request { border: 1px solid var(--border); border-radius: 6px; padding: 1rem; margin-block: 1rem; overflow-wrap: anywhere; }
|
||||
.mosaic-files-actions { display: flex; flex-wrap: wrap; align-items: center; gap: .75rem; }
|
||||
.mosaic-files-actions a { display: inline-flex; align-items: center; min-height: 44px; }
|
||||
@@ -0,0 +1,93 @@
|
||||
/* The caller supplies request IDs, never paths or authority. Every mount reads the owner API. */
|
||||
(() => {
|
||||
'use strict';
|
||||
const id = value => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,159}$/.test(value);
|
||||
const generations = new WeakMap();
|
||||
let signedOut = false;
|
||||
document.addEventListener('mosaic-signed-out', () => { signedOut = true; document.querySelectorAll('.mosaic-files').forEach(el => el.replaceChildren()); });
|
||||
function element(tag, content, className) {
|
||||
const node = document.createElement(tag);
|
||||
if (content !== undefined) node.textContent = content;
|
||||
if (className) node.className = className;
|
||||
return node;
|
||||
}
|
||||
async function read(url, errorState = false) {
|
||||
try {
|
||||
const response = await fetch(url, { cache: 'no-store', redirect: 'manual' });
|
||||
if (response.status === 401 || response.type === 'opaqueredirect') {
|
||||
if (!signedOut) document.dispatchEvent(new CustomEvent('mosaic-signed-out', { detail: { signIn: true } }));
|
||||
return null;
|
||||
}
|
||||
return response.ok ? await response.json() : (errorState ? { error: true } : null);
|
||||
} catch { return errorState ? { error: true } : null; }
|
||||
}
|
||||
function link(label, href, download) {
|
||||
const a = element('a', label);
|
||||
a.href = href;
|
||||
if (download) a.setAttribute('download', '');
|
||||
else { a.target = '_blank'; a.rel = 'noopener noreferrer'; }
|
||||
return a;
|
||||
}
|
||||
async function mount({ container, requestIds }) {
|
||||
if (!(container instanceof Element)) return;
|
||||
const generation = (generations.get(container) || 0) + 1;
|
||||
generations.set(container, generation);
|
||||
container.classList.add('mosaic-files');
|
||||
container.replaceChildren(element('h3', 'Files'), element('p', 'Reading files…'));
|
||||
const ids = Array.isArray(requestIds) ? [...new Set(requestIds.filter(id))] : [];
|
||||
if (ids.length) {
|
||||
const config = await read('/api/config');
|
||||
if (signedOut || generations.get(container) !== generation) return;
|
||||
if (config?.requests !== true) {
|
||||
container.replaceChildren(element('h3', 'Files'), element('p', 'Files are unavailable.'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
const results = await Promise.all(ids.map(async request => {
|
||||
const base = `/api/requests/${encodeURIComponent(request)}`;
|
||||
const [state, workspace] = await Promise.all([read(base), read(`${base}/workspace`, true)]);
|
||||
return { request, base, state, workspace };
|
||||
}));
|
||||
if (signedOut || generations.get(container) !== generation || document.body.classList.contains('signed-out')) return;
|
||||
container.replaceChildren(element('h3', 'Files'));
|
||||
if (!ids.length) { container.append(element('p', 'No request files are available.')); return; }
|
||||
for (const { request, base, state, workspace } of results) {
|
||||
const section = element('section', undefined, 'mosaic-files-request');
|
||||
section.append(element('h4', `Request ${request}`), element('h5', 'Reviewed delivery'));
|
||||
const d = state?.delivery_evidence;
|
||||
if (state?.version === 1 && state.request === request && Number.isSafeInteger(state.revision) && state.revision > 0 && typeof state.phase === 'string' && id(state.delivery) && d?.id === state.delivery && d.request === request && typeof d.artifact?.path === 'string') {
|
||||
const filename = d.artifact.path.split('/').at(-1);
|
||||
if (/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(filename)) {
|
||||
const row = element('p', undefined, 'mosaic-files-actions');
|
||||
row.append(element('span', filename), link('Open as text', `${base}/artifact/${encodeURIComponent(d.id)}/text`, false), link('Download', `${base}/artifact/${encodeURIComponent(d.id)}`, true));
|
||||
section.append(row, element('p', 'Open shows plain text in a new tab. Downloaded HTML can be opened locally after saving.'));
|
||||
} else section.append(element('p', 'Reviewed file is unavailable.'));
|
||||
} else section.append(element('p', state ? 'No reviewed file is available.' : 'Reviewed files are unavailable.'));
|
||||
section.append(element('p', 'No request-bound pull request link is available.'));
|
||||
const draft = element('div', undefined, 'mosaic-files-draft');
|
||||
draft.tabIndex = 0;
|
||||
draft.setAttribute('role', 'region');
|
||||
draft.setAttribute('aria-label', 'Product workspace, draft');
|
||||
section.append(draft);
|
||||
draft.append(element('h5', 'Product workspace, draft'));
|
||||
if (workspace?.error) {
|
||||
draft.append(element('p', 'Could not read draft workspace. Try again later.'));
|
||||
} else if (!workspace || workspace.available !== true || !Array.isArray(workspace.entries)) {
|
||||
draft.append(element('p', 'Draft workspace is unavailable.'));
|
||||
} else {
|
||||
draft.append(element('p', 'Names only. Draft files may change and are not reviewed deliveries.'));
|
||||
if (!workspace.entries.length) draft.append(element('p', 'No draft files found.'));
|
||||
else {
|
||||
const list = element('ul');
|
||||
for (const entry of workspace.entries.slice(0, 100)) {
|
||||
if (typeof entry?.name === 'string') list.append(element('li', entry.name));
|
||||
}
|
||||
draft.append(list);
|
||||
}
|
||||
if (workspace.truncated) draft.append(element('p', 'The draft list was truncated.'));
|
||||
}
|
||||
container.append(section);
|
||||
}
|
||||
}
|
||||
window.MosaicFiles = Object.freeze({ mount });
|
||||
})();
|
||||
@@ -0,0 +1,6 @@
|
||||
/* Scoped decision evidence; colors inherit the active appearance. */
|
||||
.inbox-meta, .inbox-receipt-meta { font-size: .875rem; line-height: 1.5; overflow-wrap: anywhere; }
|
||||
.inbox-meta { margin: .75rem 0; }
|
||||
.inbox-meta p { margin: .2rem 0; }
|
||||
.inbox-receipt-meta { margin: .75rem 0; }
|
||||
.s1-dec-open { display: inline-block; padding: .5rem 0; min-height: 44px; overflow-wrap: anywhere; }
|
||||
@@ -0,0 +1,21 @@
|
||||
// Presentation of decision evidence. No request binding is inferred from a decision row.
|
||||
(() => {
|
||||
'use strict';
|
||||
const safe = value => String(value ?? '').replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, c => c === '\u007f' ? '␡' : String.fromCharCode(0x2400 + c.charCodeAt(0))).replace(/[\u0080-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, c => `[U+${c.charCodeAt(0).toString(16).toUpperCase().padStart(4, '0')}]`).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
const fact = v => typeof v === 'string' && v.trim() ? safe(v) : 'unknown';
|
||||
function metadata(d) {
|
||||
const raw = d?.at, ms = typeof raw === 'string' ? Date.parse(raw) : NaN;
|
||||
const date = Number.isFinite(ms) ? new Date(ms).toISOString() : null;
|
||||
const seconds = Math.max(0, Math.floor((Date.now() - ms) / 1000));
|
||||
const age = !date ? 'unknown' : seconds < 60 ? `${seconds} s ago` : seconds < 3600 ? `${Math.floor(seconds / 60)} min ago` : seconds < 86400 ? `${Math.floor(seconds / 3600)} h ago` : `${Math.floor(seconds / 86400)} d ago`;
|
||||
const role = d?.raisedBy?.role ?? d?.raised_by_role, run = d?.raisedBy?.run ?? d?.raised_by_run;
|
||||
return `<div class="inbox-meta"><p>Raised ${date ? `<time datetime="${safe(date)}">${safe(date)}</time>` : 'date unknown'} · ${age}</p><p>Raised by ${fact(role)} · run ${fact(run)}</p><p>Not linked to a request</p></div>`;
|
||||
}
|
||||
function receipt(result, decision) {
|
||||
const target = decision?.authorization?.target;
|
||||
const grant = result?.grantDetails ?? result?.grant;
|
||||
const destination = typeof grant?.host === 'string' && grant.host.trim() ? grant.host : Array.isArray(grant?.projects) && grant.projects.length ? grant.projects.join(', ') : null;
|
||||
return `<p class="inbox-receipt-meta">Affected object: ${typeof target === 'string' && target.trim() ? safe(target) : 'unavailable'}. Decision routed to: ${fact(decision?.routeTo)}. Destination: ${typeof destination === 'string' && destination.trim() ? safe(destination) : 'unavailable'}. ${result?.kind === 'decision' ? 'Execution or publication is unknown until separately evidenced.' : 'Grant execution or publication requires separate evidence.'}</p>`;
|
||||
}
|
||||
window.MosaicInbox = { cardMeta: metadata, detailMeta: metadata, receipt };
|
||||
})();
|
||||
@@ -1,26 +1,27 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-design="console" data-palette="harbor">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Mosaic Console</title>
|
||||
<link rel="stylesheet" href="/shared/app.css"><link rel="stylesheet" href="/tokens.css"><link rel="stylesheet" href="/console.css"><link rel="stylesheet" href="/live.css"><link rel="stylesheet" href="/bus.css"><link rel="stylesheet" href="/chat.css"><link rel="stylesheet" href="/shell.css">
|
||||
<link rel="stylesheet" href="/shared/app.css"><link rel="stylesheet" href="/tokens.css"><link rel="stylesheet" href="/console.css"><link rel="stylesheet" href="/live.css"><link rel="stylesheet" href="/bus.css"><link rel="stylesheet" href="/chat.css"><link rel="stylesheet" href="/inbox.css"><link rel="stylesheet" href="/requests.css"><link rel="stylesheet" href="/files.css"><link rel="stylesheet" href="/project-remote.css"><link rel="stylesheet" href="/shell.css">
|
||||
<link rel="stylesheet" href="/editor.css"><script src="/editor.js" defer></script>
|
||||
<link rel="stylesheet" href="/processing.css"><script src="/processing.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="/chat.js" defer></script><script src="/agents.js" defer></script><script src="/bus.js" defer></script><script src="/requests.js" defer></script></head>
|
||||
<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="/chat.js" defer></script><script src="/agents.js" defer></script><script src="/bus.js" defer></script><script src="/requests.js" defer></script></head>
|
||||
<body>
|
||||
<a class="skip" href="#main">Skip to content</a>
|
||||
<header class="cmdbar"><a href="/" class="wordmark"><svg class="mark" viewBox="0 0 16 17" aria-hidden="true"><use href="/icons.svg#mark"/></svg>Mosaic<small>Console</small></a>
|
||||
<header class="cmdbar"><button type="button" id="nav-open" class="btn" aria-controls="console-nav" aria-expanded="false">Menu</button><a href="/" class="wordmark"><svg class="mark" viewBox="0 0 16 17" aria-hidden="true"><use href="/icons.svg#mark"/></svg>Mosaic<small>Console</small></a>
|
||||
<button type="button" class="cmdk" id="cmdk" aria-haspopup="dialog" aria-controls="cmdk-dialog"><svg class="i" aria-hidden="true"><use href="/icons.svg#i-search"/></svg><span>Go to a view, decision or task</span><kbd>Ctrl K</kbd></button>
|
||||
<nav id="federation" class="federation small" aria-label="Consoles" hidden></nav>
|
||||
<p class="fresh" id="fresh"><span id="fresh-board"></span><span id="fresh-bus"></span></p>
|
||||
<div class="cmd-right"><label>Palette<select id="palette"></select></label><label>Appearance<select id="mode"><option value="system">system</option><option value="light">light</option><option value="dim">dim</option><option value="dark">dark</option></select></label><button class="btn" id="refresh" type="button">Refresh</button><button class="btn" id="pause" type="button" aria-pressed="false">Pause</button><form id="sign-out" method="post" action="/auth/logout" hidden><button class="btn" type="submit">Sign out</button></form></div></header>
|
||||
<div id="processing-banner" class="processing-banner" role="alert" hidden></div>
|
||||
<div id="processing-mobile"></div>
|
||||
<div class="frame">
|
||||
<nav class="tree" aria-label="Console"><div id="processing-controls" class="processing-controls" role="region" aria-label="Instance processing">
|
||||
<div id="nav-scrim" hidden></div><nav class="rail" aria-label="Primary destinations"><a href="#/projects" aria-label="Projects" title="Projects"><svg class="i" aria-hidden="true"><use href="/icons.svg#i-inbox"/></svg><span>Projects</span></a><a href="#/inbox" aria-label="Inbox" title="Inbox"><svg class="i" aria-hidden="true"><use href="/icons.svg#i-inbox"/></svg><span>Inbox</span></a><a href="#/" aria-label="Board" title="Board"><svg class="i" aria-hidden="true"><use href="/icons.svg#i-board"/></svg><span>Board</span></a><a href="#/settings/display" aria-label="Sidebar display" title="Sidebar display"><svg class="i" aria-hidden="true"><use href="/icons.svg#i-settings"/></svg><span>Display</span></a></nav><nav class="tree" id="console-nav" aria-label="Console"><div class="drawer-head"><strong>Navigation</strong><button type="button" class="btn" id="nav-close">Close</button></div><p id="nav-overflow" class="small muted" hidden>Scroll for more destinations</p><div id="project-context" aria-label="Current project and conversation"></div><!-- Conversations slice owns this mount and its contents. --><div id="project-tree" aria-label="Project conversations"></div><div id="processing-controls" class="processing-controls" role="region" aria-label="Instance processing">
|
||||
<strong>Processing</strong><p id="processing-state" role="status">Reading processing state.</p>
|
||||
<button id="processing-halt" type="button" class="btn" disabled>Stop processing</button>
|
||||
<button id="processing-resume" type="button" class="btn" disabled hidden>Resume processing</button>
|
||||
<p id="processing-unavailable" class="processing-note small muted">Processing controls are unavailable here. Use mosaic halt or mosaic resume from the instance CLI.</p>
|
||||
<p id="processing-receipt" class="processing-receipt small" aria-live="polite"></p></div>
|
||||
<h2 class="tree-title">Console</h2><ul class="s1-sections" id="sections"></ul><div id="board-tree"><h2 class="tree-title">Projects</h2><div id="projects"><p class="muted">Loading projects…</p></div><p class="small muted">Select a project to filter sessions. Waiting on you always shows all projects.</p></div></nav>
|
||||
<h2 class="tree-title">Console</h2><ul class="s1-sections" id="sections"></ul><div id="board-tree"><h2 class="tree-title">Board project filter</h2><div id="projects"><p class="muted">Loading projects…</p></div><p class="small muted">Select a project to filter sessions. Waiting on you always shows all projects.</p></div></nav>
|
||||
<main id="main" class="content" tabindex="-1"><div id="signed-out" class="banner ref" role="alert" tabindex="-1" hidden></div><div id="bus-down" class="banner err" role="alert" hidden></div><div class="page-head" id="board-head"><div><h1>Control board</h1><p id="status" class="small muted" role="status">Loading board…</p></div><div class="filters"><label><input id="show-machine" type="checkbox">Show machine sessions</label></div></div>
|
||||
<section id="held-section" aria-labelledby="held-title"><h2 id="held-title">Held roles <span id="held-count" class="count">–</span></h2><p class="small muted">The stack's seats: each role a run holds on the bus, from the bus <code>agents</code> read. Machine sessions are under Show machine sessions.</p><div id="held"><div class="skel-rows" aria-busy="true"><span class="sr-only">Reading the held roles.</span><span class="skel"></span><span class="skel"></span></div></div></section>
|
||||
<div id="error" class="banner err" role="alert" hidden></div>
|
||||
@@ -34,7 +35,7 @@
|
||||
<div id="bus-view" hidden></div>
|
||||
</main>
|
||||
<aside class="inspector" id="inspector" aria-labelledby="inspector-title" hidden><div class="inspector-head"><h2 id="inspector-title" tabindex="-1">Inspector</h2><button type="button" class="btn" id="close">Close</button></div><div id="inspection"></div></aside>
|
||||
</div><footer class="foot"><span id="footer">Mosaic Stack</span><span id="board-url"></span></footer><div id="announce" class="sr-only" aria-live="polite"></div>
|
||||
</div><footer class="foot"><span id="footer">Mosaic Stack</span><span id="board-url"></span></footer><div id="announce" class="sr-only" aria-live="polite"></div><div id="display-announcement" class="sr-only" role="status" aria-live="polite"></div>
|
||||
<div id="toast" class="toast" role="status" aria-live="polite"></div>
|
||||
<dialog class="pk" id="cmdk-dialog" aria-label="Go to"><div class="pk-in"><svg class="i" aria-hidden="true"><use href="/icons.svg#i-search"/></svg><input id="cmdk-input" type="text" role="combobox" aria-expanded="true" aria-controls="cmdk-list" aria-autocomplete="list" aria-label="Go to a view, decision or task" autocomplete="off" spellcheck="false"></div><ul id="cmdk-list" class="pk-list" role="listbox" aria-label="Results"></ul><p class="pk-foot small"><kbd>↑</kbd><kbd>↓</kbd> move · <kbd>Enter</kbd> open · <kbd>Esc</kbd> close</p></dialog>
|
||||
</body></html>
|
||||
@@ -0,0 +1,5 @@
|
||||
.project-remote { border: 1px solid var(--border, #aaa); border-radius: 4px; padding: 1rem; overflow-wrap: anywhere; }
|
||||
.project-remote form { display: grid; gap: .5rem; max-width: 42rem; }
|
||||
.project-remote input { box-sizing: border-box; width: 100%; min-height: 44px; }
|
||||
.project-remote button { min-height: 44px; justify-self: start; }
|
||||
.project-remote [role="status"] { overflow-wrap: anywhere; }
|
||||
@@ -0,0 +1,93 @@
|
||||
/* Owner project information mount. The server rechecks the repository key and owner. */
|
||||
(() => {
|
||||
'use strict';
|
||||
const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,159}$/;
|
||||
let signedOut = false;
|
||||
document.addEventListener('mosaic-signed-out', () => { signedOut = true; });
|
||||
function mount({ container, projectId, repositoryKey }) {
|
||||
if (!(container instanceof Element)) return;
|
||||
let generation = 0, data = null, pending = false, draft = '', error = '', stale = false, canWrite = false;
|
||||
const section = document.createElement('section');
|
||||
section.className = 'project-remote';
|
||||
container.replaceChildren(section);
|
||||
document.addEventListener('mosaic-signed-out', () => { generation++; draft = ''; data = null; section.replaceChildren(); }, { once: true });
|
||||
const element = (tag, content, attrs = {}) => {
|
||||
const node = document.createElement(tag);
|
||||
if (content !== null) node.textContent = content;
|
||||
for (const [key, value] of Object.entries(attrs)) node.setAttribute(key, value);
|
||||
return node;
|
||||
};
|
||||
function paint() {
|
||||
section.replaceChildren(element('h2', 'Repository URL', { tabindex: '-1' }));
|
||||
if (signedOut) return;
|
||||
if (!projectId || !ID.test(repositoryKey ?? '')) {
|
||||
section.append(element('p', 'This project has no linked repository.')); return;
|
||||
}
|
||||
if (!data) {
|
||||
section.append(element('p', error || 'Loading repository setting.', { role: 'status' })); return;
|
||||
}
|
||||
if (data.remote !== null) {
|
||||
section.append(element('p', 'Configured, not verified. Saving does not publish.', { role: 'status', tabindex: '-1' }));
|
||||
section.append(element('p', data.remote)); return;
|
||||
}
|
||||
section.append(element('p', 'Saving sets this repository URL only. Saving does not publish.'));
|
||||
if (!canWrite || !data.firstSaveAllowed) {
|
||||
section.append(element('p', 'This setting is read-only.')); return;
|
||||
}
|
||||
const form = element('form', null), label = element('label', 'Existing HTTPS repository URL', { for: 'project-remote-url' });
|
||||
const input = element('input', null, { id: 'project-remote-url', name: 'remote', type: 'url', required: '', maxlength: '2048', 'aria-describedby': 'project-remote-message' });
|
||||
input.value = draft; input.disabled = pending;
|
||||
input.addEventListener('input', () => { draft = input.value; });
|
||||
const button = element('button', pending ? 'Saving' : 'Save', { type: 'submit' });
|
||||
button.disabled = pending || stale;
|
||||
const message = element('p', error, { id: 'project-remote-message', role: 'status', 'aria-live': 'polite' });
|
||||
form.append(label, input, button, message);
|
||||
if (stale) {
|
||||
const refresh = element('button', 'Read current setting', { type: 'button' });
|
||||
refresh.disabled = pending;
|
||||
refresh.addEventListener('click', () => read(true)); form.append(refresh);
|
||||
}
|
||||
form.addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
if (pending || stale || signedOut) return;
|
||||
let parsed;
|
||||
try { parsed = new URL(input.value); } catch { /* invalid input */ }
|
||||
if (parsed?.username || parsed?.password) { draft = ''; input.value = ''; error = 'Repository URL must not contain credentials.'; paint(); section.querySelector('input')?.focus(); return; }
|
||||
if (!parsed || parsed.protocol !== 'https:' || parsed.href !== input.value || parsed.search || parsed.hash || !/^\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(parsed.pathname)) {
|
||||
error = 'Enter a valid HTTPS repository URL without credentials, query or fragment.'; paint(); section.querySelector('input')?.focus(); return;
|
||||
}
|
||||
pending = true; error = ''; paint();
|
||||
const here = ++generation;
|
||||
try {
|
||||
const res = await fetch('/api/project-remotes/' + encodeURIComponent(repositoryKey), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ revision: data.revision, remote: draft }), cache: 'no-store' });
|
||||
const result = await res.json();
|
||||
if (here !== generation || signedOut) return;
|
||||
if (!res.ok) {
|
||||
stale = ['revision-conflict', 'project-remote-configured', 'outcome-unknown'].includes(result.error);
|
||||
error = result.error === 'outcome-unknown' ? 'Save outcome is unknown. Read the current setting before trying again.' : stale ? 'The setting changed. Read the current setting before deciding what to do.' : result.error === 'project-remote-invalid' ? 'Enter a valid HTTPS repository URL without credentials, query or fragment.' : 'Could not save the repository URL. Your entry is retained.';
|
||||
} else { data = result; draft = ''; }
|
||||
} catch { if (here === generation && !signedOut) { stale = true; error = 'Save outcome is unknown. Read the current setting before trying again.'; } }
|
||||
if (here === generation && !signedOut) { pending = false; paint(); (section.querySelector('button') || section.querySelector('[role=status]'))?.focus(); }
|
||||
});
|
||||
section.append(form);
|
||||
}
|
||||
async function read(reconcile = false) {
|
||||
const here = ++generation; pending = true;
|
||||
try {
|
||||
const configRes = await fetch('/api/config', { cache: 'no-store' });
|
||||
const config = await configRes.json();
|
||||
if (!config.projectRemotes) throw new Error('unavailable');
|
||||
const res = await fetch('/api/project-remotes/' + encodeURIComponent(repositoryKey), { cache: 'no-store' });
|
||||
const result = await res.json();
|
||||
if (!res.ok) throw new Error(result.error);
|
||||
if (here !== generation || signedOut) return;
|
||||
data = result; canWrite = !!config.projectRemoteWrites; stale = false;
|
||||
error = reconcile && data.remote === null ? 'Current setting read. Review your entry before saving.' : '';
|
||||
} catch { if (here === generation && !signedOut) { data = null; error = 'Repository setting is unavailable.'; } }
|
||||
if (here === generation && !signedOut) { pending = false; paint(); if (reconcile) (section.querySelector('input') || section.querySelector('h2'))?.focus(); }
|
||||
}
|
||||
paint();
|
||||
if (projectId && ID.test(repositoryKey ?? '') && !signedOut) read();
|
||||
}
|
||||
window.MosaicProjectRemote = { mount };
|
||||
})();
|
||||
@@ -0,0 +1,15 @@
|
||||
/* Request presentation. Shell loads this after the shared styles. */
|
||||
.request-panel { border-radius: 6px; padding: 1rem; overflow-wrap: anywhere; }
|
||||
.request-panel .request-head { display: flex; flex-wrap: wrap; gap: .5rem 1rem; align-items: baseline; }
|
||||
.request-panel .request-head b { font-size: 1.1rem; }
|
||||
.request-panel .request-phase, .request-panel .request-mode, .request-observed { font-size: .875rem; }
|
||||
.request-panel .request-question, .request-panel .request-delivery, .request-panel .request-files { border: 1px solid var(--line); border-radius: 4px; padding: .75rem; margin-block: .75rem; }
|
||||
.request-panel .request-question-text { font-family: inherit; font-weight: 600; }
|
||||
.request-panel .request-choices { display: grid; gap: .5rem; }
|
||||
.request-panel .request-choices .btn { text-align: start; }
|
||||
.request-panel .btn, .request-panel summary, .request-return { min-height: 44px; }
|
||||
.request-panel summary, .request-return { display: flex; align-items: center; cursor: pointer; }
|
||||
.request-panel pre { max-width: 100%; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.request-panel details { margin-block: .5rem; }
|
||||
.request-panel .request-foot { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; }
|
||||
.request-panel h3 { margin: 0 0 .5rem; font-size: 1rem; }
|
||||
@@ -46,7 +46,7 @@
|
||||
};
|
||||
const prdDecision = s => s.phase === 'prd-review' && s.wait?.kind === 'decision' && s.wait.code === 'prd-approval' && text(s.wait.subject);
|
||||
|
||||
const panels = new Map();
|
||||
const panels = new Map(), summaryRuns = new WeakMap(), summaries = new Set();
|
||||
let signedOut = false, timer = null, config = null, loading = null;
|
||||
const out = () => signedOut || document.body.classList.contains('signed-out');
|
||||
const opId = prefix => prefix + '-' + [...crypto.getRandomValues(new Uint8Array(12))].map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
@@ -73,6 +73,8 @@
|
||||
function purge() {
|
||||
forget(); dropAll(); config = null; loading = null;
|
||||
for (const el of document.querySelectorAll('.request-mount')) el.replaceChildren();
|
||||
for (const el of summaries) el.replaceChildren();
|
||||
summaries.clear();
|
||||
}
|
||||
const signOut = () => { if (!out()) document.dispatchEvent(new CustomEvent('mosaic-signed-out', { detail: { signIn: true } })); };
|
||||
document.addEventListener('mosaic-signed-out', () => { signedOut = true; purge(); });
|
||||
@@ -97,7 +99,7 @@
|
||||
let p = panels.get(id);
|
||||
if (!p) {
|
||||
const stored = load(id);
|
||||
p = { id, state: null, missing: false, failed: false, stale: false, inbox: null, projects: null, material: null, materialError: '', writes: false, at: 0, reading: null, stored, sig: '',
|
||||
p = { id, state: null, missing: false, failed: false, stale: false, inbox: null, projects: null, material: null, materialError: '', writes: false, at: 0, observedAt: null, reading: null, stored, sig: '',
|
||||
ui: { pending: null, receipt: '', confirm: null, cancelAsk: false, revokeAsk: false, reason: '', draft: stored?.text ?? '' } };
|
||||
panels.set(id, p);
|
||||
}
|
||||
@@ -120,15 +122,22 @@
|
||||
if (p.ui.draft === rec.text) p.ui.draft = '';
|
||||
if (!p.ui.receipt) p.ui.receipt = q && q.id === rec.question && q.answer !== null ? 'The question was answered. Your earlier answer is not sent again.' : 'That question is closed. Your unsent answer was discarded.';
|
||||
}
|
||||
function adopt(p, s) {
|
||||
function adopt(p, s, observedAt = null) {
|
||||
if (p.state && s.revision < p.state.revision) return false;
|
||||
if (p.state && (s.revision !== p.state.revision || !same(s.candidate, p.state.candidate))) p.ui.confirm = null;
|
||||
if (p.state && s.snapshot !== p.state.snapshot) p.ui.revokeAsk = false;
|
||||
if (p.state && (s.snapshot !== p.state.snapshot || !same(s.candidate, p.state.candidate))) {
|
||||
p.material = null; p.materialError = '';
|
||||
}
|
||||
if (!observedAt && p.state && !same(s, p.state)) p.observedAt = null;
|
||||
if (!p.state || s.revision >= p.state.revision) p.state = s;
|
||||
p.missing = false; p.failed = false; p.stale = false;
|
||||
reconcile(p);
|
||||
if (observedAt && p.state === s) {
|
||||
p.observedAt = observedAt;
|
||||
document.dispatchEvent(new CustomEvent('mosaic-request-state', { detail: { state: structuredClone(s), observedAt } }));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const WAITS = {
|
||||
@@ -153,9 +162,9 @@
|
||||
if (!m.candidate) return '<p class="request-prd-empty">No current PRD candidate or approved snapshot. Waiting for a PRD submission.</p>';
|
||||
const c = m.candidate;
|
||||
let h = `<section class="request-prd" aria-label="Exact product PRD"><p><b>Product PRD ${txt(c.prd_version)}</b></p>
|
||||
<p class="small">Records commit: <code>${txt(c.records_commit)}</code><br>PRD: <a href="#/docs/${enc(c.project)}/${enc(c.prd_path)}">${txt(c.prd_path)}</a><br>PRD blob: <code>${txt(c.prd_blob)}</code><br>Confirmed intent commit: <code>${txt(c.spec_commit)}</code><br>Mode revision: ${txt(c.mode_revision)}</p>
|
||||
<details><summary>PRD identity and provenance</summary><p class="small">Records commit: <code>${txt(c.records_commit)}</code><br>PRD: <a href="#/docs/${enc(c.project)}/${enc(c.prd_path)}">${txt(c.prd_path)}</a><br>PRD blob: <code>${txt(c.prd_blob)}</code><br>Confirmed intent commit: <code>${txt(c.spec_commit)}</code><br>Mode revision: ${txt(c.mode_revision)}</p></details>
|
||||
<pre class="request-prd-text">${txt(m.prd)}</pre><div class="request-requirements" aria-label="Requirements">${m.requirements.map((r, i) =>
|
||||
`<section class="request-requirement"><p><b>${txt(r.id)} — ${txt(r.title)}</b></p><p class="small"><code>${txt(c.requirements[i].path)}</code><br>Blob: <code>${txt(c.requirements[i].blob)}</code></p><pre>${txt(r.text)}</pre></section>`).join('')}</div>`;
|
||||
`<section class="request-requirement"><p><b>${txt(r.id)} — ${txt(r.title)}</b></p><details><summary>Requirement identity</summary><p class="small"><code>${txt(c.requirements[i].path)}</code><br>Blob: <code>${txt(c.requirements[i].blob)}</code></p></details><pre>${txt(r.text)}</pre></section>`).join('')}</div>`;
|
||||
if (m.snapshot.id) {
|
||||
h += `<p class="request-snapshot">Approved snapshot: <code>${txt(m.snapshot.id)}</code></p>`;
|
||||
if (can) h += `<div class="request-revoke">${p.ui.revokeAsk
|
||||
@@ -173,26 +182,35 @@
|
||||
if (!s) return p.failed ? `<section class="request-panel" data-request="${esc(p.id)}"><p class="request-unavailable">This request could not be read. <button type="button" class="btn" data-request-reload>Read again</button></p></section>` : '';
|
||||
const busy = ui.pending || p.reading ? ' disabled' : '', can = p.writes && !p.stale;
|
||||
const q = s.question && text(s.question.id) && text(s.question.text) ? s.question : null;
|
||||
let body = `<p class="request-head"><b>Request</b> <span class="request-phase">Phase: ${txt(s.phase)}</span>${s.mode ? ` <span class="request-mode">Mode: ${txt(s.mode)}</span>` : ''}</p>`;
|
||||
const returnHash = /^#\/chat\/threads\//.test(location.hash) || /^#\/projects\/threads\//.test(location.hash) ? location.hash : '';
|
||||
let body = `${returnHash ? `<a class="request-return" href="${esc(returnHash)}">Return to conversation</a>` : ''}<p class="request-head"><b>Request</b> <span class="request-phase">Phase: ${txt(s.phase)}</span>${s.mode ? ` <span class="request-mode">Mode: ${txt(s.mode)}</span>` : ''}</p>`;
|
||||
if (s.project) body += `<p class="request-project">Project: <code>${txt(s.project)}</code>${s.phase === 'cancelled' ? ' (kept)' : ''}</p>`;
|
||||
if (p.observedAt) body += `<p class="request-observed small">Observed: <time datetime="${esc(p.observedAt)}">${txt(p.observedAt)}</time></p>`;
|
||||
body += waitHtml(s);
|
||||
if (Array.isArray(s.tasks) && s.tasks.length) body += `<p class="request-tasks">Tasks: ${s.tasks.filter(text).map(t => `<a href="#/tasks/${enc(t)}">${txt(t)}</a>`).join(', ')}</p>`;
|
||||
if (s.phase === 'complete' && !s.delivery) body += '<p class="request-legacy-complete">Completed before delivery evidence.</p>';
|
||||
const d = s.delivery_evidence;
|
||||
if (s.phase === 'complete' && d && d.id === s.delivery && d.request === s.request && d.artifact && d.verification) {
|
||||
if (s.phase === 'complete' && d && d.id === s.delivery && d.request === s.request && d.project === s.project && d.snapshot === s.snapshot && d.artifact && d.verification) {
|
||||
const pr = d.pull_request;
|
||||
let prUrl = null;
|
||||
try {
|
||||
const u = new URL(pr?.url);
|
||||
if (pr.request === s.request && pr.project === s.project && u.protocol === 'https:' && !u.username && !u.password) prUrl = u.href;
|
||||
} catch { /* No valid bound PR URL. */ }
|
||||
body += `<section class="request-delivery" aria-label="Reviewed delivery"><p><b>Reviewed delivery</b> — not landed or deployed.</p>
|
||||
<p><a href="/api/requests/${enc(s.request)}/artifact/${enc(d.id)}" download>Download reviewed HTML</a></p>
|
||||
<p class="small">Approved snapshot: <code>${txt(d.snapshot)}</code><br>Task: <a href="#/tasks/${enc(d.task_ref)}">${txt(d.task_ref)}</a><br>
|
||||
<p>${prUrl ? `<a href="${esc(prUrl)}" target="_blank" rel="noopener noreferrer">Open pull request</a>` : 'PR link unavailable.'}</p><details><summary>Delivery evidence</summary><p class="small">Approved snapshot: <code>${txt(d.snapshot)}</code><br>Task: <a href="#/tasks/${enc(d.task_ref)}">${txt(d.task_ref)}</a><br>
|
||||
Code commit: <a href="#/tasks/${enc(d.task_ref)}"><code>${txt(d.commit)}</code></a><br>Review request: <a href="#/tasks/${enc(d.task_ref)}"><code>${txt(d.review_request)}</code></a><br>Verdict: <a href="#/tasks/${enc(d.task_ref)}"><code>${txt(d.verdict)}</code></a><br>
|
||||
Verification record: <a href="#/docs/${enc(d.project)}/${enc(d.verification.record_path)}">${txt(d.verification.record_path)}</a> at <code>${txt(d.verification.record_commit)}</code><br>
|
||||
Blob: <code>${txt(d.artifact.blob)}</code><br>SHA-256: <code>${txt(d.artifact.sha256)}</code></p></section>`;
|
||||
Blob: <code>${txt(d.artifact.blob)}</code><br>SHA-256: <code>${txt(d.artifact.sha256)}</code></p></details></section>`;
|
||||
}
|
||||
body += prdHtml(p, busy, can);
|
||||
if (q) {
|
||||
body += q.answer === null
|
||||
? `<div class="request-question"><p>The project manager asks:</p><p class="request-question-text">${txt(q.text)}</p></div>`
|
||||
: `<p class="request-question answered">Answered: ${txt(q.text)}</p>`;
|
||||
: `<p class="request-question answered">Answer received: ${txt(q.text)}. Application is not established by this receipt.</p>`;
|
||||
}
|
||||
if (!can) body += '<p class="request-readonly small muted">Read-only: this Console cannot answer or change requests.</p>';
|
||||
if (!can) body += p.stale ? '<p class="request-readonly small muted">Writes are unavailable until the request is read again.</p>' : '<p class="request-readonly small muted">Read-only: this Console cannot answer or change requests.</p>';
|
||||
if (can && ui.confirm) {
|
||||
const c = ui.confirm;
|
||||
body += `<div class="request-confirm" role="group" aria-label="Confirm your choice"><p>${txt(c.question)}</p><p>Your choice: <b>${txt(c.text)}</b></p>
|
||||
@@ -202,7 +220,7 @@
|
||||
<button type="button" class="btn" data-request-unconfirm${busy}>Back</button></div>`;
|
||||
} else if (can && (s.phase === 'selection' || prdDecision(s) && p.material?.candidate) && p.inbox) {
|
||||
body += `<div class="request-choices" role="group" aria-label="${prdDecision(s) ? 'Review this exact product PRD version' : 'Choose how to run this request'}"><p class="small muted">Each button opens a confirmation first. Nothing is answered until you confirm.</p>${p.inbox.console.options.map(o =>
|
||||
`<button type="button" class="btn" data-request-choice="${esc(o.key)}"${busy}>${txt(o.text)}</button>`).join(' ')}</div>`;
|
||||
`<button type="button" class="btn" data-request-choice="${esc(o.key)}"${busy}>${s.phase === 'selection' && ['guided', 'autonomous', 'existing'].includes(o.key) ? `<b>${o.key === 'existing' ? 'Existing project' : o.key === 'guided' ? 'Guided' : 'Autonomous'}</b>: ` : ''}${txt(o.text)}</button>`).join(' ')}</div>`;
|
||||
}
|
||||
if (can && pickerOpen(s)) {
|
||||
const picked = s.wait.code === 'request-attachment-selected' ? s.wait.subject : null;
|
||||
@@ -211,6 +229,7 @@
|
||||
`<button type="button" class="btn" data-request-project="${esc(x.project)}" aria-pressed="${x.project === picked}"${busy}>${txt(x.name)} <small>(${txt(x.project)})</small></button>`).join(' ')}` : '<p>No project is available to you for this request.</p>'}</div>`
|
||||
: '<p class="request-picker-note">The projects you may use could not be read. Use Read again.</p>';
|
||||
}
|
||||
if (p.stale && openQuestion(s) && ui.draft) body += `<p>Unsent answer retained:</p><pre class="request-retained-draft">${txt(ui.draft)}</pre>`;
|
||||
if (can && openQuestion(s)) {
|
||||
const lost = p.stored && p.stored.question === s.question.id;
|
||||
body += `<form class="request-answer" novalidate><label for="chat-request-answer-${esc(p.id)}">Your answer</label>
|
||||
@@ -231,8 +250,9 @@
|
||||
<button type="button" class="btn" data-request-cancel-confirm${busy}>Yes, cancel the request</button> <button type="button" class="btn" data-request-cancel-keep${busy}>Keep it going</button>`
|
||||
: `<button type="button" class="btn" data-request-cancel${busy}>Cancel this request</button>`}</div>`;
|
||||
}
|
||||
body += `<section class="request-files" aria-label="Files"><h3>Files</h3><div data-request-files><p>Files unavailable.</p></div></section>`;
|
||||
body += `<p class="request-receipt small" role="status" tabindex="-1">${txt(ui.receipt)}</p>`;
|
||||
body += `<p class="request-foot small">${p.stale ? 'Could not refresh; showing the last state. ' : ''}<button type="button" class="btn" data-request-reload${busy}>Read again</button></p>`;
|
||||
body += `<p class="request-foot small">${p.stale ? 'Could not refresh; showing the last state. ' : ''}${p.reading ? '<span role="status">Refreshing request…</span>' : `<button type="button" class="btn" data-request-reload${busy}>Read again</button>`}</p>`;
|
||||
return `<section class="request-panel" data-request="${esc(p.id)}" aria-label="Request">${body}</section>`;
|
||||
}
|
||||
|
||||
@@ -246,6 +266,8 @@
|
||||
const area = inside && a.matches('textarea') ? { id: a.id, start: a.selectionStart, end: a.selectionEnd } : null;
|
||||
const mark = inside && !area ? [...a.attributes].find(x => x.name.startsWith('data-request-')) : null;
|
||||
el.innerHTML = html(p);
|
||||
const files = el.querySelector('[data-request-files]');
|
||||
if (files && typeof window.MosaicFiles?.mount === 'function') window.MosaicFiles.mount({ container: files, requestIds: [p.id] });
|
||||
if (!inside) continue;
|
||||
let focus = null;
|
||||
if (area) {
|
||||
@@ -294,9 +316,9 @@
|
||||
try {
|
||||
const { res, json } = await call(`/api/requests/${enc(p.id)}`);
|
||||
if (out() || panels.get(p.id) !== p) return;
|
||||
if (res?.ok && isState(json, p.id)) { adopt(p, json); await side(p); }
|
||||
else if (res?.status === 404) { p.state = null; p.missing = true; p.failed = false; }
|
||||
else if (p.state) { p.stale = true; p.material = null; p.inbox = null; p.ui.confirm = null; p.ui.revokeAsk = false; }
|
||||
if (res?.ok && isState(json, p.id) && adopt(p, json, new Date().toISOString())) { await side(p); }
|
||||
else if (res?.status === 404 && !p.state) { p.state = null; p.missing = true; p.failed = false; }
|
||||
else if (p.state) { p.stale = true; p.inbox = null; p.ui.confirm = null; p.ui.revokeAsk = false; }
|
||||
else p.failed = true;
|
||||
p.at = Date.now();
|
||||
if (out() || panels.get(p.id) !== p) return;
|
||||
@@ -426,7 +448,7 @@
|
||||
// One use, whatever came back.
|
||||
ui.pending = null; ui.confirm = null;
|
||||
ui.receipt = res?.ok && json?.id === c.id && json.op === 'resolved' && json.choice === c.choice
|
||||
? `Recorded: you chose “${c.text}”. Reading the request.` : notAnswered(refusal(res, json, 'outcome-unknown'));
|
||||
? `Recorded: you chose “${c.text}” for decision ${c.id}${c.candidate ? `, product PRD ${c.candidate.prd_version}` : ''}. Execution owner unavailable. Reading the request.` : notAnswered(refusal(res, json, 'outcome-unknown'));
|
||||
draw(p);
|
||||
void read(p);
|
||||
}
|
||||
@@ -436,7 +458,7 @@
|
||||
const el = button?.closest('.request-panel');
|
||||
if (!button || !el || out()) return;
|
||||
const p = panels.get(el.dataset.request);
|
||||
if (!p || button.disabled || p.ui.pending || p.reading) return;
|
||||
if (!p || button.disabled || p.ui.pending || p.reading || (p.stale && !('requestReload' in button.dataset))) return;
|
||||
const d = button.dataset;
|
||||
if ('requestReload' in d) { p.ui.receipt = ''; void read(p); draw(p); }
|
||||
else if ('requestChoice' in d) void choose(p, d.requestChoice);
|
||||
@@ -459,7 +481,7 @@
|
||||
if (!form) return;
|
||||
event.preventDefault();
|
||||
const p = panels.get(form.closest('.request-panel')?.dataset.request);
|
||||
if (p && !out()) { if (form.matches('.request-revoke-form')) void revoke(p); else void answer(p); }
|
||||
if (p && !out() && !p.stale && !p.reading) { if (form.matches('.request-revoke-form')) void revoke(p); else void answer(p); }
|
||||
});
|
||||
document.addEventListener('input', event => {
|
||||
const area = event.target.closest?.('.request-answer textarea');
|
||||
@@ -506,5 +528,22 @@
|
||||
else void settings().then(run);
|
||||
}
|
||||
|
||||
window.MosaicRequests = Object.freeze({ mount, forget });
|
||||
// The project page supplies known IDs; each is reread through the existing authorized boundary.
|
||||
async function projectSummary({ container, requestIds, project } = {}) {
|
||||
if (!container || out()) return;
|
||||
container.replaceChildren();
|
||||
const run = {}; summaryRuns.set(container, run); summaries.add(container);
|
||||
if (!text(project) || !ID.test(project) || !Array.isArray(requestIds)) return;
|
||||
const ids = [...new Set(requestIds.filter(id => text(id) && ID.test(id)))];
|
||||
const cfg = config || await settings();
|
||||
if (out() || !cfg?.on) return;
|
||||
const rows = await Promise.all(ids.map(async id => {
|
||||
const { res, json } = await call(`/api/requests/${enc(id)}`);
|
||||
return res?.ok && isState(json, id) && json.project === project ? json : null;
|
||||
}));
|
||||
if (out() || !container.isConnected || summaryRuns.get(container) !== run) return;
|
||||
const valid = rows.filter(Boolean);
|
||||
container.innerHTML = valid.length ? `<section class="request-project-summary" aria-label="Requests"><h3>Requests</h3>${valid.map(s => `<p>Request <code>${txt(s.request)}</code> · Phase: ${txt(s.phase)}${s.snapshot ? ' · PRD approved' : ''}</p>`).join('')}</section>` : '<p>Request information unavailable.</p>';
|
||||
}
|
||||
window.MosaicRequests = Object.freeze({ mount, forget, projectSummary });
|
||||
})();
|
||||
@@ -136,7 +136,7 @@ body.on-bus #board-refused{display:none}
|
||||
.tree,.inspector{top:var(--bar-h);height:calc(100vh - var(--bar-h) - var(--foot-h))}
|
||||
}
|
||||
/* The live region after the footer sits at its static place, one pixel past the screen; pin it so it adds no scroll. */
|
||||
#announce{top:0;left:0}
|
||||
#announce,#display-announcement{position:fixed;top:0;left:0}
|
||||
|
||||
/* Breakpoints. 760 to 959 px keeps the side column (console.css drops it at 959). */
|
||||
@media (min-width:760px) and (max-width:959px){
|
||||
@@ -175,3 +175,53 @@ body.on-bus #board-refused{display:none}
|
||||
@media (max-width:359px){.cmd-right{flex-wrap:wrap}.cmd-right label{flex:1 1 110px}}
|
||||
@media (prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;transition:none!important;scroll-behavior:auto!important}}
|
||||
@media (forced-colors:active){.tag,.empty,.banner,.toast,.pk{border:1px solid CanvasText}.s1-section[aria-current=page]{outline:2px solid Highlight}table.sessions tbody tr.is-selected{outline:2px solid Highlight}}
|
||||
|
||||
/* W15 shell. Archive integration may style .project-archive-control and
|
||||
.project-archived-filter with these shared button and filter treatments. */
|
||||
.project-archive-control,.project-archived-filter{min-height:44px;border-radius:var(--r)}
|
||||
#nav-open,.drawer-head{display:none}
|
||||
.s1-section{min-height:44px}
|
||||
.content .btn.primary{min-height:var(--action-target)}
|
||||
[data-sidebar-density=compact] .s1-section{min-height:44px;padding-block:3px}
|
||||
.display-settings fieldset{border:1px solid var(--line);border-radius:var(--r);margin:0 0 16px;padding:12px}
|
||||
.display-settings label{display:block;min-height:44px;padding:10px 0}
|
||||
.display-preview{padding:12px;border:1px solid var(--line);border-radius:var(--r);margin-bottom:16px;overflow-wrap:anywhere}
|
||||
.display-dialog{max-width:min(32rem,calc(100% - 24px));border:1px solid var(--line);border-radius:var(--r);background:var(--surface);color:var(--text);padding:20px}
|
||||
.display-dialog::backdrop{background:rgb(0 0 0 / .5)}
|
||||
.display-dialog button{min-height:44px;margin:4px}
|
||||
@media (min-width:960px){.frame,body.on-bus .frame{grid-template-columns:226px minmax(0,1fr)}.content{padding:22px}}
|
||||
@media (max-width:759px){
|
||||
#nav-open{display:inline-flex;min-height:44px;align-items:center}
|
||||
.drawer-head{display:flex;align-items:center;justify-content:space-between;position:sticky;top:-12px;z-index:2;background:var(--surface);padding:8px 0}
|
||||
#nav-close{min-height:44px}
|
||||
.tree{display:flex;visibility:hidden;pointer-events:none;position:fixed;inset:0 auto 0 0;z-index:100;width:min(330px,calc(100vw - 32px));height:100dvh;max-height:none;overflow-y:auto;flex-direction:column;flex-wrap:nowrap;align-items:stretch;padding:12px;background:var(--surface);box-shadow:var(--shadow)}
|
||||
body.nav-open .tree{visibility:visible;pointer-events:auto}
|
||||
#nav-scrim:not([hidden]){display:block;position:fixed;inset:0;background:rgb(0 0 0 / .5);z-index:99}
|
||||
.s1-sections{display:grid;overflow:visible;flex-basis:auto}
|
||||
.s1-sections>li{width:100%}
|
||||
.s1-section{white-space:normal}
|
||||
.content h1{font-size:22px}
|
||||
}
|
||||
|
||||
/* W15 navigation: the rail is a sibling of the project/sidebar column. */
|
||||
.rail{display:none}
|
||||
@media (min-width:960px){
|
||||
.frame,body.on-bus .frame,body.has-inspector .frame,body.on-bus.has-inspector .frame{grid-template-columns:var(--rail-width) var(--sidebar-width) minmax(0,1fr)}
|
||||
body.has-inspector .frame,body.on-bus.has-inspector .frame{grid-template-columns:var(--rail-width) var(--sidebar-width) minmax(0,1fr) 340px}
|
||||
.frame.frame > .tree{position:sticky;width:auto}
|
||||
.frame.frame > .content{grid-column:3}
|
||||
body{--processing-nav:calc(var(--rail-width) + var(--sidebar-width))}
|
||||
.rail{position:sticky;top:var(--bar-h);height:calc(100vh - var(--bar-h) - var(--foot-h));display:flex;flex-direction:column;gap:4px;padding:10px 6px;border-right:1px solid var(--line);background:var(--surface)}
|
||||
.rail a{display:flex;min-height:58px;align-items:center;justify-content:center;flex-direction:column;gap:3px;border-radius:var(--r);color:var(--text);font-size:11px;text-decoration:none}
|
||||
.rail a:hover,.rail a[aria-current=page]{background:var(--raised);color:var(--strong)}
|
||||
.rail a[aria-current=page]{box-shadow:inset 3px 0 var(--action)}
|
||||
}
|
||||
#project-context:not(:empty){border-bottom:1px solid var(--line);padding:0 4px 10px;overflow-wrap:anywhere}
|
||||
.context-project{display:block;min-height:var(--action-target);padding:10px 0;color:var(--action);font-weight:700}
|
||||
.context-conversation{display:block;min-height:var(--action-target);padding:10px 0;margin:0;color:var(--strong);overflow-wrap:anywhere}
|
||||
#project-tree:not(:empty){border-bottom:1px solid var(--line);padding-bottom:10px}
|
||||
.project-archive-control,.project-archived-filter{border:1px solid var(--border);background:var(--surface);color:var(--text);padding:8px 12px}
|
||||
.project-archive-control:hover,.project-archived-filter:hover{background:var(--raised)}
|
||||
a{ text-underline-offset: .15em }
|
||||
button:disabled,[aria-disabled=true]{cursor:not-allowed}
|
||||
@media (max-width:759px){.tree{width:100%;overscroll-behavior:contain;scrollbar-gutter:stable}#console-nav>*{flex:0 0 auto}}
|
||||
@@ -6,10 +6,14 @@
|
||||
--font: "Manrope", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
--mono: "JetBrains Mono", ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
|
||||
--r: 6px;
|
||||
--r-lg: 10px;
|
||||
--r-table: 8px;
|
||||
--r-lg: 6px;
|
||||
--r-table: 6px;
|
||||
--pad: 16px;
|
||||
--gap: 12px;
|
||||
/* Shared W15 geometry and interaction tokens. */
|
||||
--rail-width: 88px;
|
||||
--sidebar-width: 226px;
|
||||
--action-target: 44px;
|
||||
}
|
||||
|
||||
/* Harbor: the default palette */
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
let canSend = false, ready = false;
|
||||
const route = () => {
|
||||
const path = location.hash.slice(1).split('?')[0];
|
||||
return path.startsWith('/chat') ? path : '/chat';
|
||||
return path.startsWith('/chat') || path.startsWith('/projects') ? path : '/projects';
|
||||
};
|
||||
function show(navigated) {
|
||||
if (ready && window.MosaicChat) void window.MosaicChat.render({ path: route(), container: $('chat'), canSend, user: true, navigated });
|
||||
|
||||
@@ -20,11 +20,11 @@ import { BusReadError } from './bus.mjs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { CONSOLE_VERBS } from '../../bus/src/broker.mjs';
|
||||
|
||||
export const REQUEST_ROUTE = /^\/api\/requests\/([^/]+)(?:\/(projects|draft|answer|attach|mode|cancel|revoke)|\/(artifact)\/([^/]+))?$/;
|
||||
export const REQUEST_ROUTE = /^\/api\/requests\/([^/]+)(?:\/(projects|draft|workspace|answer|attach|mode|cancel|revoke)|\/(artifact)\/([^/]+)(\/(?:text))?)?$/;
|
||||
const WRITE_VERBS = Object.freeze({
|
||||
answer: 'request.answer.console', attach: 'request.attach.console', mode: 'request.mode.console', cancel: 'request.cancel.console', revoke: 'prd.revoke.console',
|
||||
});
|
||||
const READ_VERBS = Object.freeze({ get: 'request.get.console', projects: 'request.projects.console', draft: 'request.draft.console' });
|
||||
const READ_VERBS = Object.freeze({ get: 'request.get.console', projects: 'request.projects.console', draft: 'request.draft.console', workspace: 'request.workspace.console' });
|
||||
// The exact key set of each write body. A key outside it, such as `request`, `business` or `subject`,
|
||||
// is refused: the route and the session say who and what.
|
||||
const FIELDS = Object.freeze({
|
||||
@@ -147,7 +147,7 @@ const picker = v => Array.isArray(v) && v.every(p => p && typeof p === 'object'
|
||||
// transports; either is null when the Console has no such transport. `verbs` is the console verb
|
||||
// list: the draft read exists only while it names `request.draft.console`.
|
||||
export async function requestHTTP(req, res, { path, requestRead, requestWrite, session, live, origin, log, verbs = CONSOLE_VERBS }) {
|
||||
const [, raw, suffix, artifactSuffix, rawDelivery] = REQUEST_ROUTE.exec(path);
|
||||
const [, raw, suffix, artifactSuffix, rawDelivery, textSuffix] = REQUEST_ROUTE.exec(path);
|
||||
const action = suffix ?? artifactSuffix ?? 'get';
|
||||
const write = Object.hasOwn(WRITE_VERBS, action);
|
||||
let id = null;
|
||||
@@ -165,6 +165,7 @@ export async function requestHTTP(req, res, { path, requestRead, requestWrite, s
|
||||
const line = tail => log(`${new Date().toISOString()} request user=${user} request=${JSON.stringify(String(id ?? '').slice(0, 64))} action=${action}${tail}`);
|
||||
const refuse = (code, extra = '') => { line(`${extra} error=${code}`); return requestRefuse(res, code); };
|
||||
if (id === null || !ID.test(id) || (action !== 'artifact' && rawDelivery !== undefined)) return refuse('request-not-found');
|
||||
if (action === 'workspace' && (req.url?.includes('?') || Number(req.headersDistinct['content-length']?.[0] ?? 0) !== 0 || req.headersDistinct['transfer-encoding'])) return refuse('invalid-request');
|
||||
let delivery = null;
|
||||
if (action === 'artifact') {
|
||||
try { delivery = decodeURIComponent(rawDelivery ?? ''); } catch {}
|
||||
@@ -189,14 +190,26 @@ export async function requestHTTP(req, res, { path, requestRead, requestWrite, s
|
||||
const bytes = Buffer.from(artifact.base64, 'base64');
|
||||
if (bytes.length > 1024 * 1024 || bytes.toString('base64') !== artifact.base64 || createHash('sha256').update(bytes).digest('hex') !== artifact.sha256) return refuse('unavailable');
|
||||
line('');
|
||||
res.writeHead(200, { 'content-type': artifact.mime, 'content-disposition': `attachment; filename="${artifact.filename}"`, 'x-content-type-options': 'nosniff', 'cache-control': 'private, no-store' });
|
||||
res.writeHead(200, textSuffix
|
||||
? { 'content-type': 'text/plain; charset=utf-8', 'content-security-policy': "sandbox; default-src 'none'", 'x-content-type-options': 'nosniff', 'cache-control': 'private, no-store' }
|
||||
: { 'content-type': artifact.mime, 'content-disposition': `attachment; filename="${artifact.filename}"`, 'x-content-type-options': 'nosniff', 'cache-control': 'private, no-store' });
|
||||
return res.end(bytes);
|
||||
}
|
||||
const verb = READ_VERBS[action];
|
||||
if (action === 'draft' && !verbs.includes(verb)) return refuse('draft-unavailable');
|
||||
if (action === 'workspace' && !verbs.includes(verb)) return refuse('unavailable');
|
||||
if (live() !== session) return refuse('sign-in-required');
|
||||
let reply;
|
||||
try { reply = await requestRead(verb, { request: id }, { subject }); } catch (err) { return refuse(busCode(err, 'unavailable')); }
|
||||
if (action === 'workspace') {
|
||||
if (live() !== session) return refuse('sign-in-required');
|
||||
const validName = name => typeof name === 'string' && name.length <= 4096 && name.split('/').every(p => p && p !== '.' && p !== '..' && !p.startsWith('.') && !/[\x00-\x1f\x7f\\]/.test(p));
|
||||
if (!reply || Object.keys(reply).sort().join(',') !== 'available,contentAvailable,entries,observedAt,truncated' || typeof reply.available !== 'boolean' || typeof reply.truncated !== 'boolean' || reply.contentAvailable !== false ||
|
||||
typeof reply.observedAt !== 'string' || !Number.isFinite(Date.parse(reply.observedAt)) || !Array.isArray(reply.entries) || reply.entries.length > 100 ||
|
||||
(!reply.available && (reply.entries.length || reply.truncated)) || reply.entries.some(e => !e || Object.keys(e).join(',') !== 'name' || !validName(e.name))) return refuse('unavailable');
|
||||
line('');
|
||||
return json(res, 200, reply);
|
||||
}
|
||||
if (action === 'projects') {
|
||||
if (!picker(reply)) return refuse('unavailable');
|
||||
line('');
|
||||
|
||||
@@ -14,6 +14,7 @@ import { chatHTTP } from './chat-http.mjs';
|
||||
import { processingHTTP } from './processing-http.mjs';
|
||||
import { DECISION_ROUTE, decisionHTTP, decisionRefuse, createConfirmations } from './decision-http.mjs';
|
||||
import { REQUEST_ROUTE, requestHTTP, requestRefuse } from './request-http.mjs';
|
||||
import { PROJECT_REMOTE_ROUTE, projectRemoteHTTP } from './project-remote-http.mjs';
|
||||
|
||||
export const DEFAULT_BOARD = 'http://127.0.0.1:7331';
|
||||
export function isLoopback(host) {
|
||||
@@ -33,13 +34,21 @@ const files = new Map([
|
||||
['/', ['index.html', 'text/html; charset=utf-8']],
|
||||
['/settings/model-proxy', ['proxy.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', 'processing.js', 'agents.js', 'federation.js', 'requests.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
|
||||
['/files.js', ['files.js', 'text/javascript; 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', 'processing.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']],
|
||||
['/requests.css', ['requests.css', 'text/css; charset=utf-8']],
|
||||
['/editor.js', ['editor.js', 'text/javascript; charset=utf-8']],
|
||||
// Row 225 (C2-10b): the one reply renderer, shared with the Discord relay, imported by chat.js.
|
||||
['/render.mjs', ['../../../library/src/render.mjs', 'text/javascript; charset=utf-8']],
|
||||
['/editor.css', ['editor.css', 'text/css; charset=utf-8']],
|
||||
['/icons.svg', ['icons.svg', 'image/svg+xml']],
|
||||
['/project-remote.css', ['project-remote.css', 'text/css; charset=utf-8']],
|
||||
...[400, 500, 600, 700].map(w => [`/assets/fonts/manrope-${w}.woff2`, [`assets/fonts/manrope-${w}.woff2`, 'font/woff2']]),
|
||||
...[400, 500, 700].map(w => [`/assets/fonts/jetbrains-mono-${w}.woff2`, [`assets/fonts/jetbrains-mono-${w}.woff2`, 'font/woff2']]),
|
||||
]);
|
||||
@@ -729,7 +738,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
// alone stays open on a loopback Host for the status and release gates (Q-C).
|
||||
if (sso !== null && !named && !(path === '/healthz' && req.method === 'GET')) return json(res, 403, { error: 'public Host required' });
|
||||
// No CORS. JSON content type and same-origin checks keep browser forms out.
|
||||
const decisionPath = DECISION_ROUTE.test(path), requestPath = REQUEST_ROUTE.test(path);
|
||||
const decisionPath = DECISION_ROUTE.test(path), requestPath = REQUEST_ROUTE.test(path), remotePath = PROJECT_REMOTE_ROUTE.test(path);
|
||||
if (req.headers.origin && req.headers.origin !== (named ? (sso ? sso.publicOrigin : `https://${preview.host}`) : `http://${req.headers.host}`))
|
||||
return decisionPath ? decisionRefuse(res, 'cross-origin') : requestPath ? requestRefuse(res, 'cross-origin') : json(res, 403, { error: 'cross-origin request refused' });
|
||||
let signer = preview, ssoSession = null;
|
||||
@@ -740,6 +749,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
if (!session && path !== '/healthz') {
|
||||
if (decisionPath) return decisionRefuse(res, 'sign-in-required');
|
||||
if (requestPath) return requestRefuse(res, 'sign-in-required');
|
||||
if (remotePath) return json(res, 401, { error: 'sign-in-required' });
|
||||
if (path.startsWith('/api/') || req.method !== 'GET') return json(res, 401, { error: 'sign-in required' });
|
||||
res.writeHead(303, { location: `/auth/login?return=${encodeURIComponent(path + search)}`, 'cache-control': 'no-store' });
|
||||
return res.end();
|
||||
@@ -794,6 +804,12 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
return requestHTTP(req, res, { path, requestRead, requestWrite: send ? requestWrite : null,
|
||||
session: ssoSession, live: () => sso.session(req), origin: sso.publicOrigin, log });
|
||||
}
|
||||
if (remotePath) {
|
||||
if (tester || granted || !named || sso === null) return json(res, 403, { error: 'owner-only' });
|
||||
return projectRemoteHTTP(req, res, { path, read: requestRead, write: send ? requestWrite : null,
|
||||
session: ssoSession, live: () => sso.session(req), origin: sso.publicOrigin });
|
||||
}
|
||||
if (tester && ['/project-remote.js', '/project-remote.css', '/files.js', '/files.css'].includes(path)) return json(res, 403, { error: 'owner-only' });
|
||||
// Row 194 (#1692, D4.1): the tester also launches and terminates.
|
||||
if (tester && !['GET', 'HEAD'].includes(req.method) && !['/api/instances', '/api/routes', '/api/name', '/api/agent/launch', '/api/agent/terminate'].includes(path) && !TESTER_CHAT_WRITE.test(path))
|
||||
return json(res, 403, { error: 'read-only', sent: false });
|
||||
@@ -927,7 +943,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
// `send`: the page shows the composer only where a send can pass (the public name, --send on).
|
||||
// `edit`: the editor shows Save only where a save can pass, the same test as the route's.
|
||||
// `user` and `federation` (FED R6) only when there is one, so the owner's config is unchanged.
|
||||
if (path === '/api/config') return json(res, 200, { board: upstream, ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), ...(granted ? { user: true, chatGrants } : {}), ...(!tester && !granted && named && sso !== null && requestRead ? { requests: true, requestWrites: Boolean(send && requestWrite) } : {}), ...(hub ? { federation: hub } : {}), send: !tester && Boolean(send) && named, decide: !tester && !granted && Boolean(send) && Boolean(decisionWrite) && named && sso !== null, edit: !tester && !granted && Boolean(send) && Boolean(projectSave) && named });
|
||||
if (path === '/api/config') return json(res, 200, { board: upstream, ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), ...(granted ? { user: true, chatGrants } : {}), ...(!tester && !granted && named && sso !== null && requestRead ? { requests: true, requestWrites: Boolean(send && requestWrite), workspaceFiles: true, projectRemotes: true, projectRemoteWrites: Boolean(send && requestWrite) } : {}), ...(hub ? { federation: hub } : {}), send: !tester && Boolean(send) && named, decide: !tester && !granted && Boolean(send) && Boolean(decisionWrite) && named && sso !== null, edit: !tester && !granted && Boolean(send) && Boolean(projectSave) && named });
|
||||
if (path === '/healthz') return json(res, 200, { ok: true, ...(sso ? { sso: sso.health() } : {}) });
|
||||
if (path === '/favicon.ico') { res.writeHead(204); return res.end(); }
|
||||
const file = files.get(path);
|
||||
|
||||
@@ -37,3 +37,31 @@ test('artifact access refuses foreign ownership, tampered bytes and expired sess
|
||||
assert.equal((await attempt(async verb => verb === 'request.get.console' ? state : { mime: 'text/html', filename: 'timer.html', base64: 'eA==', sha256: '0'.repeat(64) })).status, 503);
|
||||
assert.equal((await attempt(async () => state, () => null)).status, 401);
|
||||
});
|
||||
|
||||
test('text view is inert and uses the same current delivery and digest checks', async () => {
|
||||
const path = '/api/requests/r1/artifact/d1/text';
|
||||
assert.match(path, REQUEST_ROUTE);
|
||||
const bytes = Buffer.from('<script>window.executed = true</script>');
|
||||
const session = { name: 'owner', provider: 'test', iss: 'test', sub: 'owner' };
|
||||
const req = { method: 'GET', headersDistinct: { 'sec-fetch-site': ['same-origin'] } };
|
||||
const state = { version: 1, request: 'r1', revision: 1, phase: 'complete', delivery: 'd1' };
|
||||
const artifact = { mime: 'text/html', filename: 'timer.html', base64: bytes.toString('base64'), sha256: createHash('sha256').update(bytes).digest('hex') };
|
||||
const attempt = async (read, live = () => session) => {
|
||||
const res = { writeHead(status, headers) { this.status = status; this.headers = headers; }, end(data) { this.data = data; } };
|
||||
await requestHTTP(req, res, { path, session, live, origin: 'https://console.test', log() {}, requestRead: read });
|
||||
return res;
|
||||
};
|
||||
const good = await attempt(async verb => verb === 'request.get.console' ? state : artifact);
|
||||
assert.equal(good.status, 200);
|
||||
assert.deepEqual(good.data, bytes);
|
||||
assert.equal(good.headers['content-type'], 'text/plain; charset=utf-8');
|
||||
assert.equal(good.headers['x-content-type-options'], 'nosniff');
|
||||
assert.equal(good.headers['content-security-policy'], "sandbox; default-src 'none'");
|
||||
assert.equal(good.headers['cache-control'], 'private, no-store');
|
||||
assert.equal((await attempt(async verb => verb === 'request.get.console' ? { ...state, delivery: 'd2' } : artifact)).status, 404);
|
||||
assert.equal((await attempt(async verb => verb === 'request.get.console' ? state : { ...artifact, sha256: '0'.repeat(64) })).status, 503);
|
||||
let reads = 0;
|
||||
assert.equal((await attempt(async verb => { reads++; return verb === 'request.get.console' ? state : artifact; }, () => reads < 2 ? session : null)).status, 401);
|
||||
assert.doesNotMatch('/api/requests/r1/artifact/d1/text/extra', REQUEST_ROUTE);
|
||||
assert.doesNotMatch('/api/requests/r1/artifact/d1/html', REQUEST_ROUTE);
|
||||
});
|
||||
@@ -19,7 +19,7 @@ test('served Console browser: real board fixtures, keyboard, drafts, receipts, t
|
||||
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.
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/processing.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/chat.js', '/agents.js', '/bus.js', '/requests.js']);
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/processing.js', '/display.js', '/inbox.js', '/files.js', '/project-remote.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/chat.js', '/agents.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);
|
||||
|
||||
@@ -221,10 +221,10 @@ test('chat browser: list/create named projects and threads, hostile labels inert
|
||||
const projectText = await b.evaluate('document.querySelector(".chat-projects").textContent');
|
||||
assert.doesNotMatch(projectText, /[\u202a-\u202e\u2066-\u2069]/u);
|
||||
assert.match(projectText, /\\u202e/);
|
||||
assert.equal(await b.evaluate(`!!document.querySelector('#sections a[href="#/chat"]')`), true);
|
||||
assert.equal(await b.evaluate(`!!document.querySelector('#sections a[href="#/projects"]')`), true);
|
||||
await b.key('k', 'KeyK', 2);
|
||||
await wait('document.querySelector("#cmdk-dialog").open');
|
||||
assert.equal(await b.evaluate(`!!document.querySelector('#cmdk-list [data-href="#/chat"]')`), true);
|
||||
assert.equal(await b.evaluate(`!!document.querySelector('#cmdk-list [data-href="#/projects"]')`), true);
|
||||
await b.key('Escape');
|
||||
await set('#chat-project-name', 'new project');
|
||||
await set('#chat-repo', 'stack');
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { writeFile, mkdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { fixture } from '../fixture.mjs';
|
||||
import { browser } from '../browser.mjs';
|
||||
export const P = '10000000-0000-4000-8000-000000000001';
|
||||
export const T = '20000000-0000-4000-8000-000000000001';
|
||||
export const S = '30000000-0000-4000-8000-000000000001';
|
||||
export const S2 = '30000000-0000-4000-8000-000000000002';
|
||||
const stub = `(() => {
|
||||
const real = fetch.bind(window), P=${JSON.stringify(P)}, T=${JSON.stringify(T)}, S=${JSON.stringify(S)}, S2=${JSON.stringify(S2)};
|
||||
const json=(v,status=200)=>new Response(JSON.stringify(v),{status,headers:{'content-type':'application/json'}});
|
||||
window.__writes=[]; window.__unexpected=[];window.__roomRows=[];window.confirm=()=>true; window.__mounts=[]; window.__contexts=[];
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
window.MosaicFiles={mount:o=>{window.__mounts.push(['files',o.requestIds]);o.container.textContent='Fixture files';}};
|
||||
window.MosaicProjectRemote={mount:o=>{window.__mounts.push(['remote',o.projectId,o.repositoryKey]);o.container.textContent='Fixture remote';}};
|
||||
});
|
||||
document.addEventListener('mosaic-project-context',e=>window.__contexts.push(e.detail));
|
||||
window.fetch=async (url,init={})=>{
|
||||
const path=String(url), method=init.method||'GET'; if(!path.startsWith('/api/')) return real(url,init);
|
||||
if(method!=='GET'){window.__writes.push({path,body:JSON.parse(init.body||'{}')}); if(window.__holdClear && path.endsWith('/clear'))return new Promise(r=>window.__releaseClear=()=>r(json({error:'fixture-refusal',sent:false},409))); if(window.__roomAccept && path.includes('/rooms/') && path.endsWith('/messages')){const id='50000000-0000-4000-8000-000000000001';window.__roomRows.push({id,ids:[id],from_role:'human',to:['alice'],body:JSON.parse(init.body).body,at:'2026-11-05T12:00:00Z'});return json({ids:[id],to:['alice']});} return json({error:'fixture-refusal',sent:false},409);}
|
||||
if(path==='/api/config')return json({send:true,chatGrants:[]});
|
||||
if(path.startsWith('/api/bus/'))return json({rows:[],unread:0,at:'2026-11-05T12:00:00Z'});
|
||||
if(path==='/api/business')return json({projects:['repo-key']});
|
||||
if(path.startsWith('/api/library/status'))return json({error:'not-configured'},404);
|
||||
if(path==='/api/chat/projects')return json({rows:[{id:P,name:'Active project',repo:'repo-key'},{id:'10000000-0000-4000-8000-000000000002',name:'Empty project',repo:null},{id:'10000000-0000-4000-8000-000000000003',name:'Archived project',repo:null,archived:true}]});
|
||||
if(path==='/api/chat/instances')return json({rows:[{instance:'alice',name:'Alice'},{instance:'bob',name:'Bob'}]});
|
||||
if(path.includes('/shares'))return json({rows:[]});
|
||||
if(path.endsWith('/sessions'))return json({rows:[{id:S,name:'Planning',default:true,state:'active',revision:1},{id:S2,name:'Review',state:'active',revision:1}]});
|
||||
if(path.includes('/rooms/') && path.includes('/messages'))return json({rows:window.__roomRows,after:null,hasMore:false});
|
||||
if(path.includes('/messages')) { if(window.__failRead)return json({error:'offline'},503); return json({rows:[{id:'40000000-0000-4000-8000-000000000001',from_role:'human',body:'A fixture message',request:'request-1',at:'2026-11-05T12:00:00Z'}],after:null,hasMore:false}); }
|
||||
if(path.startsWith('/api/chat/threads'))return json({rows:path.includes('shared=true')?(window.__shared?[{id:T,project:P,name:'Planning conversation',instance:'alice',owner:'Owner'}]:[]):window.__shared?[]:[{id:T,project:P,name:'Planning conversation',instance:'alice'},{id:'20000000-0000-4000-8000-000000000002',project:P,name:'alice',instance:'alice'},{id:'20000000-0000-4000-8000-000000000003',project:P,name:'bob',instance:'bob'}]});
|
||||
window.__unexpected.push(path);return json({error:'unexpected-fixture-api'},403);
|
||||
};
|
||||
})();`;
|
||||
export async function setup(t) {
|
||||
const f=await fixture(); t.after(()=>f.close());
|
||||
const b=await browser({machine:false}); t.after(()=>b.close());
|
||||
const errors=[];
|
||||
b.on('Runtime.exceptionThrown', p=>errors.push(p.exceptionDetails.text));
|
||||
await b.call('Fetch.enable',{patterns:[{urlPattern:'*'}]});
|
||||
b.on('Fetch.requestPaused', p=>{void b.call(p.request.url.startsWith(f.base+'/')?'Fetch.continueRequest':'Fetch.failRequest',p.request.url.startsWith(f.base+'/')?{requestId:p.requestId}:{requestId:p.requestId,errorReason:'BlockedByClient'}).catch(()=>{});});
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:stub});
|
||||
await b.viewport(1440,1000); await b.navigate(f.base+'/#/chat');
|
||||
const wait=expr=>b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,30));}throw new Error(${JSON.stringify(expr)});})()`);
|
||||
await wait('document.querySelector(".chat-projects")');
|
||||
// The shell owns the canonical route dispatcher and arrives at integration. Exercise the actual
|
||||
// conversation renderer with canonical paths without changing the unowned baseline shell.
|
||||
const render=async path=>{await b.evaluate(`MosaicChat.render({path:${JSON.stringify(path)},container:document.getElementById('bus-view'),canSend:true,navigated:false})`);};
|
||||
const shot=async (name,width)=>{
|
||||
if(!process.env.MOSAIC_UX_SHOTS)return;
|
||||
await mkdir(process.env.MOSAIC_UX_SHOTS,{recursive:true});await b.viewport(width,1000);await b.evaluate('document.fonts.ready');
|
||||
if (await b.evaluate('document.documentElement.scrollWidth > document.documentElement.clientWidth')) throw new Error('Document overflows at '+width);
|
||||
const file=join(process.env.MOSAIC_UX_SHOTS,`conversations-${name}-${width}.png`);await b.screenshot(file);
|
||||
const {cssContentSize:s}=await b.call('Page.getLayoutMetrics');
|
||||
const {data}=await b.call('Page.captureScreenshot',{format:'png',captureBeyondViewport:true,clip:{x:0,y:0,width:Math.ceil(s.width),height:Math.ceil(s.height),scale:1}});
|
||||
await writeFile(file.replace('.png','-full.png'),Buffer.from(data,'base64'));
|
||||
};
|
||||
return {f,b,wait,render,shot,errors};
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { fixture } from '../fixture.mjs';
|
||||
import { browser } from '../browser.mjs';
|
||||
|
||||
// This isolated slice has no shell mount yet. Attach the actual feature to the local Console fixture.
|
||||
export async function filesFixture(t) {
|
||||
const f = await fixture(); t.after(() => f.close());
|
||||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||
const errors = [];
|
||||
b.on('Runtime.exceptionThrown', event => errors.push(event.exceptionDetails.text));
|
||||
await b.call('Fetch.enable', { patterns: [{ urlPattern: '*' }] });
|
||||
b.on('Fetch.requestPaused', e => {
|
||||
const same = e.request.url.startsWith(f.base + '/') || e.request.url === 'about:blank';
|
||||
b.call(same ? 'Fetch.continueRequest' : 'Fetch.failRequest', same ? { requestId: e.requestId } : { requestId: e.requestId, errorReason: 'BlockedByClient' }).catch(error => errors.push(error.message));
|
||||
});
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: `(() => {
|
||||
const real = window.fetch.bind(window);
|
||||
window.fixtureState = null; window.fixtureWorkspace = null;
|
||||
window.fetch = async (url, init) => {
|
||||
const path = String(url);
|
||||
if (path === '/api/config') return new Response(JSON.stringify({requests:true}), {status: 200, headers: {'content-type':'application/json'}});
|
||||
if (path === '/api/requests/request-1') return new Response(JSON.stringify(window.fixtureState), {status: 200, headers: {'content-type':'application/json'}});
|
||||
if (path === '/api/requests/request-1/workspace') return window.fixtureWorkspace === null ? new Response('{}', {status: 503}) : new Response(JSON.stringify(window.fixtureWorkspace), {status: 200, headers: {'content-type':'application/json'}});
|
||||
if (path.startsWith('/api/')) return new Response('{}', {status: 503});
|
||||
return real(url, init);
|
||||
};
|
||||
})();` });
|
||||
return { f, b, errors };
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { readFile, mkdir, writeFile } from 'node:fs/promises';
|
||||
import { fixture } from '../fixture.mjs';
|
||||
import { browser } from '../browser.mjs';
|
||||
|
||||
export const oldId = 'ec3c0ac1-030b-495c-b2de-3711acc0646d';
|
||||
export const requestId = 'b1869a13-b328-408a-b4f7-50ded79aeb15';
|
||||
export const currentId = '89cc5c95-31e8-4d0d-a2ca-25f5c3f3253e';
|
||||
export const old = { id: oldId, at: '2026-11-03T22:17:22.717Z', raised_by_role: 'pm', raised_by_run: 'rcfdc22434131', class: 'within-role', action: 'prd.approve', route_to: 'human', question: 'Historic decision: choose a path', options: [{ key: 'keep', text: 'Keep original' }], blocking: false, console: { kind: 'decision', digest: 'a'.repeat(64), options: [{ key: 'keep', text: 'Keep original' }] } };
|
||||
export const current = { ...old, id: currentId, at: '2026-11-06T12:00:00.000Z', raised_by_role: 'chief', raised_by_run: 'current-run', question: 'Current request: choose a mode', options: [{ key: 'guided', text: 'Guided' }], console: { kind: 'decision', digest: 'b'.repeat(64), options: [{ key: 'guided', text: 'Guided' }] } };
|
||||
export async function setup(t) {
|
||||
const f = await fixture(), b = await browser({ machine: false });
|
||||
t.after(async () => { await b.close(); await f.close(); });
|
||||
const script = await readFile(new URL('../../src/public/inbox.js', import.meta.url), 'utf8');
|
||||
const css = await readFile(new URL('../../src/public/inbox.css', import.meta.url), 'utf8');
|
||||
const source = `(() => {
|
||||
${script}
|
||||
document.addEventListener('DOMContentLoaded', () => { const style = document.createElement('style'); style.textContent = ${JSON.stringify(css)}; document.head.append(style); });
|
||||
const rows = ${JSON.stringify([old, current])};
|
||||
window.__request = { id: ${JSON.stringify(requestId)}, currentWaitSubject: ${JSON.stringify(currentId)} };
|
||||
window.__posts = []; window.__outcome = 'success';
|
||||
const json = (body, status = 200) => new Response(JSON.stringify(body), { status, headers: {'content-type':'application/json'} });
|
||||
const real = window.fetch.bind(window);
|
||||
window.fetch = async (url, init = {}) => {
|
||||
const u = new URL(String(url), location.href);
|
||||
if (u.origin !== location.origin) throw new Error('External network refused');
|
||||
if (!u.pathname.startsWith('/api/')) return real(url, init);
|
||||
if (u.pathname === '/api/processing' || u.pathname === '/api/bus-status') return json({error:'unavailable', message:'Fixture service unavailable'}, 503);
|
||||
if (u.pathname === '/api/config') return json({sso:true, send:true, decide:true});
|
||||
if (u.pathname === '/api/bus/inbox') return json({rows, at:'2026-11-06T13:00:00.000Z'});
|
||||
if (u.pathname === '/api/bus/agents' || u.pathname === '/api/bus/messages') return json({rows:[], unread:0, at:'2026-11-06T13:00:00.000Z'});
|
||||
if (u.pathname === '/api/bus/trail') { const row = rows.find(x => x.id === u.searchParams.get('subject')); return json({ rows: row ? [{ ...row, table:'decisions' }] : [], at:'2026-11-06T13:00:00.000Z' }); }
|
||||
if (u.pathname.startsWith('/api/decisions/')) {
|
||||
window.__posts.push({path:u.pathname, body:JSON.parse(init.body)});
|
||||
const id = u.pathname.split('/')[3], row = rows.find(x => x.id === id), choice = JSON.parse(init.body).choice, option = row?.options.find(x => x.key === choice);
|
||||
if (!option) return json({error:'refused',message:'Unknown choice'}, 403);
|
||||
if (u.pathname.endsWith('/confirmation')) return json({id,kind:'decision',action:'answer',question:row.question,choice,text:option.text,confirmation:'c'.repeat(64)});
|
||||
if (window.__outcome === 'unknown') return json({error:'outcome-unknown',message:'The answer may have reached the bus. Refresh before trying again.'}, 503);
|
||||
return json({id,op:'resolved',choice});
|
||||
}
|
||||
window.__unexpected = (window.__unexpected || []).concat(u.pathname); return json({error:'unexpected',message:'Fixture refused'}, 403);
|
||||
};
|
||||
})();`;
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument', { source });
|
||||
await b.call('Network.enable');
|
||||
await b.call('Fetch.enable', { patterns: [{ urlPattern: '*' }] });
|
||||
const failures = [], consoleErrors = [];
|
||||
b.on('Runtime.exceptionThrown', p => consoleErrors.push(p.exceptionDetails.text));
|
||||
b.on('Runtime.consoleAPICalled', p => { if (p.type === 'error') consoleErrors.push(p.args.map(a => a.value ?? a.description ?? '').join(' ')); });
|
||||
b.on('Fetch.requestPaused', p => { const url = p.request.url; const allowed = url.startsWith(f.base + '/') || url === 'about:blank' || url.startsWith('data:'); if (!allowed) failures.push(url); b.call(allowed ? 'Fetch.continueRequest' : 'Fetch.failRequest', allowed ? {requestId:p.requestId} : {requestId:p.requestId,errorReason:'BlockedByClient'}).catch(() => {}); });
|
||||
const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw Error('Timed out: '+${JSON.stringify(expr)})})()`);
|
||||
async function page(route, width) { await b.viewport(width,1000); await b.navigate(f.base + '/#' + route); await wait('document.querySelector("#bus-view h1") && !document.querySelector("#bus-view [aria-busy]")'); await b.evaluate('document.fonts.ready'); }
|
||||
async function shot(state, width) { if (!process.env.MOSAIC_UX_SHOTS) return; const dir = process.env.MOSAIC_UX_SHOTS; await mkdir(dir,{recursive:true}); const name = `inbox-${state}-${width}`; await b.screenshot(`${dir}/${name}.png`); const {contentSize} = await b.call('Page.getLayoutMetrics'); const {data} = await b.call('Page.captureScreenshot',{format:'png',captureBeyondViewport:true,clip:{x:0,y:0,width:Math.ceil(contentSize.width),height:Math.ceil(contentSize.height),scale:1}}); await writeFile(`${dir}/${name}-full.png`,Buffer.from(data,'base64')); }
|
||||
return { f,b,wait,page,shot,failures,consoleErrors };
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { mkdtempSync, mkdirSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../../business/tests/helpers.mjs';
|
||||
import { loadBusiness } from '../../../business/src/business.mjs';
|
||||
import { resolveInstance } from '../../../business/src/resolve.mjs';
|
||||
import { busBusiness } from '../../../bus/src/business.mjs';
|
||||
import { startBroker } from '../../../bus/src/runtime.mjs';
|
||||
import { serve } from '../../../bus/src/server.mjs';
|
||||
import { Client } from '../../../bus/src/client.mjs';
|
||||
import { startServer } from '../../src/serve.mjs';
|
||||
import { close } from '../fixture.mjs';
|
||||
|
||||
// Ephemeral organization, real runtime writer, broker socket and HTTP listener. The proof
|
||||
// verifier and SSO session are fixture authorities; no live service or network is involved.
|
||||
export async function realRemoteFixture(t, mutate = () => {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'remote-real-'));
|
||||
const rolesDir = rolesCopy(root), system = systemFor(root);
|
||||
const doc = businessDoc(root, 'demo'); mutate(doc);
|
||||
const file = writeJson(join(root, 'businesses/demo.json'), doc);
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
const form = () => {
|
||||
const business = loadBusiness('demo', { dir: root, rolesDir });
|
||||
const resolved = Object.fromEntries(Object.keys(business.roles).map(instance => [instance, resolveInstance({ system, business, instance })]));
|
||||
return busBusiness(business, resolved);
|
||||
};
|
||||
const start = () => startBroker({ dataRoot: join(root, 'data'), businesses: { demo: form() }, organization: { dir: root, rolesDir, system } });
|
||||
let runtime = await start();
|
||||
let socketServer;
|
||||
const path = join(root, 'fixture.sock');
|
||||
const attach = async () => {
|
||||
socketServer = await serve({ broker: runtime.broker, path, peerReader: { read: async () => ({}), close: () => {} },
|
||||
authenticateConsole: proof => runtime.broker.bindConsole({ business: 'demo', human: 'jason', subject: proof.subject }) });
|
||||
};
|
||||
await attach();
|
||||
const subject = { provider: 'fixture', iss: 'https://issuer.example', sub: 'owner' };
|
||||
const session = { ...subject, name: 'jason' };
|
||||
let active = true;
|
||||
const sso = { publicOrigin: '', publicHost: '', formTargets: () => [], session: () => active ? session : null, handle: () => {}, health: () => ({}) };
|
||||
const transport = (verb, args, ctx) => new Client({ path, console: { subject: ctx.subject } }).call(verb, args);
|
||||
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso, send: async () => ({}), requestRead: transport, requestWrite: transport, log: () => {} });
|
||||
const base = `http://127.0.0.1:${web.address().port}`;
|
||||
sso.publicOrigin = base; sso.publicHost = new URL(base).host;
|
||||
t.after(async () => { await close(web); await socketServer.close(); await runtime.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const restart = async () => { await socketServer.close(); await runtime.close(); runtime = await start(); await attach(); };
|
||||
return { root, file, base, path, session, subject, restart, signOut: () => { active = false; }, broker: () => runtime.broker };
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { fixture } from '../fixture.mjs';
|
||||
import { browser } from '../browser.mjs';
|
||||
|
||||
// The conversations slice supplies the real mount point at integration. This fixture
|
||||
// installs one in the actual Console document and stubs only its network boundary.
|
||||
export async function remoteFixture(t, width) {
|
||||
const f = await fixture(); t.after(() => f.close());
|
||||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||
const errors = [], network = [];
|
||||
b.on('Runtime.exceptionThrown', p => errors.push(p.exceptionDetails?.exception?.description ?? p.exceptionDetails?.text ?? 'exception'));
|
||||
b.on('Fetch.requestPaused', p => {
|
||||
if (p.request.url.startsWith(f.base + '/')) b.call('Fetch.continueRequest', { requestId: p.requestId }).catch(() => {});
|
||||
else { network.push(p.request.url); b.call('Fetch.failRequest', { requestId: p.requestId, errorReason: 'BlockedByClient' }).catch(() => {}); }
|
||||
});
|
||||
await b.call('Fetch.enable', { patterns: [{ urlPattern: '*' }] });
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: `(() => {
|
||||
window.__remote = { project: 'stack', remote: null, revision: '${'a'.repeat(64)}', firstSaveAllowed: true };
|
||||
window.__writes = []; window.__mode = 'success'; window.__writeEnabled = true;
|
||||
window.fetch = async (url, options = {}) => {
|
||||
const path = String(url), method = options.method || 'GET';
|
||||
const reply = (data, status = 200) => new Response(JSON.stringify(data), { status, headers: { 'content-type': 'application/json' } });
|
||||
if (path === '/api/config') return reply({ projectRemotes: true, projectRemoteWrites: window.__writeEnabled });
|
||||
if (path === '/api/project-remotes/stack') {
|
||||
if (method === 'GET') return reply(window.__remote);
|
||||
window.__writes.push(JSON.parse(options.body));
|
||||
if (window.__mode === 'hold') await new Promise(resolve => window.__release = resolve);
|
||||
if (window.__mode === 'stale') return reply({ error: 'revision-conflict' }, 409);
|
||||
if (window.__mode === 'unknown') return reply({ error: 'outcome-unknown' }, 503);
|
||||
if (window.__mode === 'fail') return reply({ error: 'project-remote-invalid' }, 400);
|
||||
window.__remote = { ...window.__remote, remote: window.__writes.at(-1).remote, revision: '${'b'.repeat(64)}', firstSaveAllowed: false };
|
||||
return reply(window.__remote);
|
||||
}
|
||||
return reply({ error: 'unexpected-api' }, 503);
|
||||
};
|
||||
})();` });
|
||||
await b.viewport(width, 1000);
|
||||
await b.navigate(f.base + '/#/projects/10000000-0000-4000-8000-000000000001/settings');
|
||||
await b.evaluate(`new Promise((resolve,reject)=>{const link=document.createElement('link');link.rel='stylesheet';link.href='/project-remote.css';document.head.append(link);const script=document.createElement('script');script.src='/project-remote.js';script.onload=resolve;script.onerror=reject;document.head.append(script)})`);
|
||||
await b.evaluate(`(()=>{const main=document.querySelector('#main');for(const child of main.children)child.style.display='none';main.hidden=false;const h=document.createElement('h1');h.textContent='Project information';const slot=document.createElement('div');slot.id='remote-fixture';main.append(h,slot);return true})()`);
|
||||
const mount = async (repositoryKey = 'stack') => b.evaluate(`MosaicProjectRemote.mount({container:document.querySelector('#remote-fixture'),projectId:'10000000-0000-4000-8000-000000000001',repositoryKey:${JSON.stringify(repositoryKey)}}),true`);
|
||||
const wait = expression => b.evaluate(`(async()=>{const until=Date.now()+10000;while(!(${expression})){if(Date.now()>until)throw Error('timed out');await new Promise(r=>setTimeout(r,20))}return true})()`);
|
||||
return { b, f, mount, wait, errors, network };
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { fixture } from '../fixture.mjs';
|
||||
import { browser } from '../browser.mjs';
|
||||
|
||||
export const THREAD = '20000000-0000-4000-8000-000000000002';
|
||||
export const MESSAGE = '40000000-0000-4000-8000-000000000004';
|
||||
export const REQUEST = '50000000-0000-4000-8000-000000000005';
|
||||
export const DECISION = '60000000-0000-4000-8000-000000000006';
|
||||
export const state = extra => ({ version: 1, request: REQUEST, revision: 7, phase: 'discovery', project: 'factory', mode: 'guided', mode_revision: 1, snapshot: null, candidate: null, tasks: [], delivery: null, question: null, wait: null, ...extra });
|
||||
export const candidate = { request: REQUEST, project: 'factory', prd_version: '1.2', records_commit: 'a'.repeat(40), prd_path: 'docs/prd.md', prd_blob: 'b'.repeat(40), spec_commit: 'c'.repeat(40), mode_revision: 1, requirements: [{ id: 'REQ-1', title: 'Timer', path: 'req.json', blob: 'd'.repeat(40) }] };
|
||||
export const draft = { candidate, prd: 'A timer that counts down.', requirements: [{ id: 'REQ-1', title: 'Timer', text: 'Count down.' }], snapshot: { id: null, revision: 2 } };
|
||||
export const evidence = { id: 'delivery-1', request: REQUEST, project: 'factory', snapshot: 'snapshot-1', task_ref: 'vikunja:2/41', commit: 'a'.repeat(40), review_request: 'review-1', verdict: 'verdict-1', artifact: { blob: 'b'.repeat(40), sha256: 'c'.repeat(64) }, verification: { record_path: 'docs/verification.md', record_commit: 'd'.repeat(40) } };
|
||||
export const wait = (kind, code, subject) => ({ kind, code, subject });
|
||||
export const question = { id: 'q-1', text: 'Which platform?', answer: null, options: [] };
|
||||
|
||||
export async function setup(t, boot) {
|
||||
const f = await fixture(); t.after(() => f.close());
|
||||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||
const css = await readFile(new URL('../../src/public/requests.css', import.meta.url), 'utf8');
|
||||
const source = `(() => {
|
||||
const RealDate = Date; window.Date = class extends RealDate { constructor(...args) { super(...(args.length ? args : ['2026-11-05T12:00:00Z'])); } };
|
||||
const boot = ${JSON.stringify(boot)}; window.__states = boot.states; window.__failure = false; window.__writes = []; window.__unexpected = []; window.__events = []; window.__fileCalls = [];
|
||||
document.addEventListener('mosaic-request-state', e => window.__events.push(e.detail));
|
||||
document.addEventListener('DOMContentLoaded', () => { window.MosaicFiles = { mount({container, requestIds}) { window.__fileCalls.push(requestIds); container.textContent = 'Fixture Files mount'; } }; });
|
||||
document.addEventListener('DOMContentLoaded', () => { const s=document.createElement('style'); s.textContent=${JSON.stringify(css)}; document.head.append(s); });
|
||||
window.fetch = async (url, init = {}) => {
|
||||
const path = String(url), method = init.method || 'GET';
|
||||
const json = (v, status=200) => new Response(JSON.stringify(v), {status, headers:{'content-type':'application/json'}});
|
||||
if (method !== 'GET') { window.__writes.push({path, method}); if (path === '/api/decisions/${DECISION}/confirmation' && boot.allowConfirmation) return json({id:'${DECISION}',action:'answer',kind:'decision',choice:'yes',text:'Approve exact PRD',question:'Approve the exact PRD?',confirmation:'fixture-token'}); return json({error:'fixture-refusal'}, 403); }
|
||||
if (path === '/api/config') return json({send:true, requests:true, requestWrites:true});
|
||||
if (path === '/api/bus/inbox') return json({rows:boot.inbox || [], unread:0, at:'2026-11-05T12:00:00Z'});
|
||||
if (path.startsWith('/api/bus/')) return json({rows:[], unread:0, at:'2026-11-05T12:00:00Z'});
|
||||
if (path === '/api/business') return json({projects:[]});
|
||||
if (path === '/api/chat/instances') return json({rows:[{instance:'assistant',name:'assistant'}]});
|
||||
if (path === '/api/chat/projects') return json({rows:[]});
|
||||
if (path.startsWith('/api/chat/threads') && path.endsWith('/sessions')) return json({rows:[{id:'${THREAD}',name:'default',default:true}]});
|
||||
if (path.startsWith('/api/chat/threads') && !path.includes('/messages')) return json({rows:[{id:'${THREAD}',project:'${THREAD}',name:'Project',instance:'assistant'}]});
|
||||
if (path.startsWith('/api/chat/threads') && path.includes('/messages')) return json({rows:[{id:'${MESSAGE}',body:'Build a countdown timer',from_role:'human',at:'2026-11-05T12:00:00Z',request:'${REQUEST}'}],after:'${MESSAGE}',hasMore:false});
|
||||
const m=/^\\/api\\/requests\\/([^/]+)(?:\\/(projects|draft))?$/.exec(path);
|
||||
if (m) { if (m[2] === 'projects') return json([]); if (m[2] === 'draft') return json(boot.draft || {candidate:null,prd:null,requirements:[],snapshot:{id:null,revision:0}}); if(window.__failure) return json({error:'unavailable'},503); return window.__states[m[1]] ? json(window.__states[m[1]]) : json({error:'not-found'},404); }
|
||||
window.__unexpected.push(path); return json({error:'unexpected fixture API'}, 403);
|
||||
};
|
||||
})();`;
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument', { source });
|
||||
await b.call('Fetch.enable', { patterns: [{urlPattern:'*'}] });
|
||||
const network = [], consoleErrors = [];
|
||||
b.on('Runtime.exceptionThrown', e => consoleErrors.push(e.exceptionDetails?.text || 'Runtime exception'));
|
||||
b.on('Runtime.consoleAPICalled', e => { if (e.type === 'error') consoleErrors.push(e.args.map(a => a.value || a.description || '').join(' ')); });
|
||||
b.on('Fetch.requestPaused', e => {
|
||||
const url = e.request.url;
|
||||
if (url.startsWith(f.base + '/') || url === 'about:blank' || url.startsWith('data:')) void b.call('Fetch.continueRequest', {requestId:e.requestId}).catch(() => {});
|
||||
else { network.push(url); void b.call('Fetch.failRequest', {requestId:e.requestId,errorReason:'BlockedByClient'}).catch(() => {}); }
|
||||
});
|
||||
const route = `#/projects/threads/${THREAD}`;
|
||||
const navigate = async width => {
|
||||
await b.viewport(width, 1000); await b.evaluate('window.__beforeNavigate=true;true'); await b.navigate(f.base + '/?width=' + width + route);
|
||||
await b.evaluate(`(async()=>{const end=Date.now()+10000;while(window.__beforeNavigate || !document.querySelector('.request-panel')){if(Date.now()>end)throw Error('request panel missing');await new Promise(r=>setTimeout(r,30))}return true})()`);
|
||||
};
|
||||
const shot = async (name, width) => {
|
||||
if (!process.env.MOSAIC_UX_SHOTS) return [];
|
||||
await mkdir(process.env.MOSAIC_UX_SHOTS, {recursive:true});
|
||||
const viewport = join(process.env.MOSAIC_UX_SHOTS, `requests-${name}-${width}.png`);
|
||||
const full = join(process.env.MOSAIC_UX_SHOTS, `requests-${name}-${width}-full.png`);
|
||||
await b.evaluate("document.querySelector('.request-panel').scrollIntoView({block:'start'});window.scrollBy(0,-90);true");
|
||||
await b.screenshot(viewport);
|
||||
const {cssContentSize} = await b.call('Page.getLayoutMetrics');
|
||||
const {data} = await b.call('Page.captureScreenshot', {format:'png',captureBeyondViewport:true,clip:{x:0,y:0,width:Math.ceil(cssContentSize.width),height:Math.ceil(cssContentSize.height),scale:1}});
|
||||
await writeFile(full, Buffer.from(data,'base64'));
|
||||
const continuation = join(process.env.MOSAIC_UX_SHOTS, `requests-${name}-${width}-continuation.png`);
|
||||
await b.evaluate("(()=>{let e=document.querySelector('.request-panel').parentElement;while(e && !(e.scrollHeight > e.clientHeight && /(auto|scroll)/.test(getComputedStyle(e).overflowY))) e=e.parentElement;if(e)e.scrollTop=e.scrollHeight;return true})()");
|
||||
await b.screenshot(continuation);
|
||||
return [viewport,full,continuation];
|
||||
};
|
||||
return {f,b,route,navigate,shot,network,consoleErrors};
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { projectRemoteHTTP } from '../src/project-remote-http.mjs';
|
||||
|
||||
const origin = 'https://console.test', revision = 'a'.repeat(64);
|
||||
const subject = { provider: 'oidc', iss: 'https://id.test', sub: 'owner' };
|
||||
function setup() {
|
||||
const session = { ...subject, name: 'owner' }, calls = [];
|
||||
let current = session;
|
||||
const transport = async (verb, args, context) => {
|
||||
calls.push({ verb, args, context });
|
||||
return { project: args.project, remote: verb.includes('.set.') ? args.remote : null, revision, firstSaveAllowed: !verb.includes('.set.') };
|
||||
};
|
||||
async function run({ method = 'GET', body = {}, headers = {}, read = transport, write = transport, live = () => current } = {}) {
|
||||
const h = { origin: [origin], 'sec-fetch-site': ['same-origin'], 'content-type': ['application/json'], ...headers };
|
||||
for (const k of Object.keys(h)) if (h[k] === null) delete h[k];
|
||||
const req = { method, headersDistinct: h, async *[Symbol.asyncIterator]() { yield Buffer.from(JSON.stringify(body)); } };
|
||||
const res = { writeHead(status, headers) { this.status = status; this.headers = headers; }, end(value) { this.value = JSON.parse(value); } };
|
||||
await projectRemoteHTTP(req, res, { path: '/api/project-remotes/stack', read, write, session, live, origin });
|
||||
return res;
|
||||
}
|
||||
return { calls, run, logout: () => { current = null; } };
|
||||
}
|
||||
test('only the trusted session subject and exact route fields reach the Console transport', async () => {
|
||||
const s = setup();
|
||||
assert.equal((await s.run()).status, 200);
|
||||
const result = await s.run({ method: 'POST', body: { revision, remote: 'https://git.example/owner/repo' } });
|
||||
assert.equal(result.status, 200);
|
||||
assert.deepEqual(s.calls, [
|
||||
{ verb: 'project.remote.get.console', args: { project: 'stack' }, context: { subject } },
|
||||
{ verb: 'project.remote.set.console', args: { project: 'stack', revision, remote: 'https://git.example/owner/repo' }, context: { subject } },
|
||||
]);
|
||||
});
|
||||
test('method, origin, payload and expired session fail before transport', async () => {
|
||||
const s = setup(), body = { revision, remote: 'https://git.example/owner/repo' };
|
||||
assert.equal((await s.run({ method: 'PUT' })).status, 405);
|
||||
for (const headers of [{ origin: ['https://other.test'] }, { origin: null }, { 'sec-fetch-site': ['same-site'] }, { 'sec-fetch-site': null }, { 'content-type': ['text/plain'] }])
|
||||
assert.notEqual((await s.run({ method: 'POST', body, headers })).status, 200);
|
||||
for (const bad of [{ revision }, { remote: body.remote }, { ...body, business: 'other' }, { ...body, projects: {} }, { ...body, credentials: 'secret' }, { ...body, publish: true }, { ...body, revision: 'bad' }])
|
||||
assert.equal((await s.run({ method: 'POST', body: bad })).status, 400);
|
||||
s.logout(); assert.equal((await s.run({ method: 'POST', body })).status, 401);
|
||||
assert.deepEqual(s.calls, []);
|
||||
});
|
||||
test('refusals and malformed replies never echo credential-bearing URLs', async () => {
|
||||
const s = setup(), secret = 'https://name:[email protected]/owner/repo';
|
||||
const write = async () => { const e = new Error(secret); e.code = 'project-remote-invalid'; throw e; };
|
||||
const refused = await s.run({ method: 'POST', body: { revision, remote: secret }, write });
|
||||
assert.equal(refused.status, 400); assert.ok(!JSON.stringify(refused.value).includes('secret'));
|
||||
const read = async () => ({ project: 'stack', revision, remote: secret, firstSaveAllowed: false });
|
||||
const invalid = await s.run({ read });
|
||||
assert.equal(invalid.status, 503); assert.ok(!JSON.stringify(invalid.value).includes('secret'));
|
||||
});
|
||||
test('an expiry during the body read prevents the write from starting', async () => {
|
||||
const s = setup(); let checks = 0;
|
||||
const out = await s.run({ method: 'POST', body: { revision, remote: 'https://git.example/owner/repo' }, live: () => { checks++; return null; } });
|
||||
assert.equal(out.status, 401); assert.equal(checks, 1); assert.deepEqual(s.calls, []);
|
||||
});
|
||||
|
||||
test('a write admitted before sign-out returns its receipt if the session ends during the call', async () => {
|
||||
const s = setup();
|
||||
const write = async (verb, args) => {
|
||||
s.logout();
|
||||
return { project: args.project, revision, remote: args.remote, firstSaveAllowed: false };
|
||||
};
|
||||
const out = await s.run({ method: 'POST', body: { revision, remote: 'https://git.example/owner/repo' }, write });
|
||||
assert.equal(out.status, 200); assert.equal(out.value.remote, 'https://git.example/owner/repo');
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { request } from 'node:http';
|
||||
import childProcess from 'node:child_process';
|
||||
import https from 'node:https';
|
||||
import { syncBuiltinESMExports } from 'node:module';
|
||||
import { Credentials } from '../../bus/src/credentials.mjs';
|
||||
import { Broker } from '../../bus/src/broker.mjs';
|
||||
import { Client } from '../../bus/src/client.mjs';
|
||||
import { realRemoteFixture } from './helpers/w15-project-remote-real.mjs';
|
||||
|
||||
const url = 'https://git.example/owner/repo.git';
|
||||
function call(f, method, body, headers = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const data = body === undefined ? undefined : JSON.stringify(body);
|
||||
const req = request(f.base + '/api/project-remotes/stack', { method, headers: { ...(data ? { origin: f.base, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', 'content-length': Buffer.byteLength(data) } : {}), ...headers } }, res => {
|
||||
let text = ''; res.on('data', chunk => { text += chunk; }); res.on('end', () => resolve([res.statusCode, JSON.parse(text)]));
|
||||
});
|
||||
req.on('error', reject); req.setTimeout(10000, () => req.destroy(new Error('fixture request timed out'))); req.end(data);
|
||||
});
|
||||
}
|
||||
const get = f => call(f, 'GET');
|
||||
const post = (f, body, headers) => call(f, 'POST', body, headers);
|
||||
test('owner saves over HTTP and the broker socket, then reads after runtime restart', async t => {
|
||||
const f = await realRemoteFixture(t), [status, before] = await get(f);
|
||||
assert.equal(status, 200); assert.equal(before.remote, null);
|
||||
// Fail the save if it attempts a forge call, process/Git command, credential mutation,
|
||||
// or agent launch. Install only after fixture startup and restore before shutdown.
|
||||
const effects = { process: 0, forge: 0, credential: 0, launch: 0 };
|
||||
const originals = [];
|
||||
const fence = (object, key, kind) => {
|
||||
originals.push([object, key, object[key]]);
|
||||
object[key] = () => { effects[kind]++; throw Error('unexpected ' + kind); };
|
||||
};
|
||||
for (const key of ['spawn', 'spawnSync', 'exec', 'execSync', 'execFile', 'execFileSync', 'fork']) fence(childProcess, key, 'process');
|
||||
for (const key of ['request', 'get']) fence(https, key, 'forge');
|
||||
for (const key of ['issue', 'revoke', 'remove', 'unlinkStore', 'writeModel', 'register', 'unregister', 'registerModel']) fence(Credentials.prototype, key, 'credential');
|
||||
fence(Broker.prototype, 'bindLaunch', 'launch');
|
||||
fence(globalThis, 'fetch', 'forge'); syncBuiltinESMExports();
|
||||
let savedStatus, saved;
|
||||
try { [savedStatus, saved] = await post(f, { revision: before.revision, remote: url }); }
|
||||
finally { for (const [object, key, original] of originals) object[key] = original; syncBuiltinESMExports(); }
|
||||
assert.deepEqual(effects, { process: 0, forge: 0, credential: 0, launch: 0 });
|
||||
assert.equal(savedStatus, 200, JSON.stringify(saved)); assert.equal(saved.remote, url);
|
||||
assert.equal(JSON.parse(readFileSync(f.file)).projects.stack.remote, url);
|
||||
await f.restart();
|
||||
const [reloadStatus, reloaded] = await get(f);
|
||||
assert.equal(reloadStatus, 200); assert.deepEqual(reloaded, saved);
|
||||
const [existingStatus, existing] = await post(f, { revision: reloaded.revision, remote: 'https://git.example/other/repo' });
|
||||
assert.equal(existingStatus, 409); assert.equal(existing.error, 'project-remote-configured');
|
||||
});
|
||||
test('sign-out, stale, credentials, extra fields and cross-origin do not write', async t => {
|
||||
const f = await realRemoteFixture(t), [, before] = await get(f), original = readFileSync(f.file);
|
||||
const body = { revision: before.revision, remote: url };
|
||||
for (const [b, headers, expected] of [
|
||||
[{ ...body, revision: 'a'.repeat(64) }, {}, 'revision-conflict'],
|
||||
[{ ...body, remote: 'https://user:[email protected]/owner/repo' }, {}, 'project-remote-invalid'],
|
||||
[{ revision: body.revision }, {}, 'invalid-request'], [{ ...body, publish: true }, {}, 'invalid-request'],
|
||||
[body, { origin: 'https://other.example' }, 'cross-origin request refused'],
|
||||
]) {
|
||||
const [status, result] = await post(f, b, headers);
|
||||
assert.ok(status >= 400); assert.equal(result.error, expected); assert.ok(!JSON.stringify(result).includes('secret')); assert.deepEqual(readFileSync(f.file), original);
|
||||
}
|
||||
f.signOut(); const [status, result] = await post(f, body);
|
||||
assert.equal(status, 401); assert.equal(result.error, 'sign-in-required'); assert.deepEqual(readFileSync(f.file), original);
|
||||
});
|
||||
|
||||
test('real SSO refuses viewer and collaborator and sign-out prevents the saved write', async t => {
|
||||
const { fakeOidc } = await import('./helpers/fake-oidc.mjs');
|
||||
const { createSso, SESSION_COOKIE } = await import('../src/sso.mjs');
|
||||
const { consoleAccess } = await import('../src/access.mjs');
|
||||
const { startServer } = await import('../src/serve.mjs');
|
||||
const { close } = await import('./fixture.mjs');
|
||||
const op = await fakeOidc(); t.after(() => op.close());
|
||||
const issuer = op.issuer('a'), origin = 'https://console.example.test';
|
||||
const access = { accessVersion: 1, revision: 1, users: [
|
||||
{ id: 'alice', home: 'local', subjects: [{ provider: 'a', sub: 'alice-sub' }] },
|
||||
{ id: 'bob', home: 'local', subjects: [{ provider: 'a', sub: 'bob-sub' }] },
|
||||
], grants: [
|
||||
{ id: 'g-1', user: 'alice', business: 'demo', projects: ['*'], instances: ['assistant'], level: 'viewer' },
|
||||
{ id: 'g-2', user: 'bob', business: 'demo', projects: ['*'], instances: ['assistant'], level: 'collaborator' },
|
||||
] };
|
||||
const config = { publicOrigin: origin, providers: [{ id: 'a', issuer: issuer.issuer, clientId: issuer.clientId, clientSecret: { slot: 'a' }, algs: ['RS256'], subjects: [{ sub: 'owner-sub', name: 'jason' }] }] };
|
||||
const sso = await createSso(config, { secrets: new Map([['a', issuer.clientSecret]]), fetch: op.fetch, users: consoleAccess({ load: () => access, business: () => 'demo' }), log: () => {} });
|
||||
t.after(() => sso.close());
|
||||
const f = await realRemoteFixture(t), original = readFileSync(f.file);
|
||||
let writes = 0;
|
||||
const transport = (verb, args, ctx) => new Client({ path: f.path, console: { subject: ctx.subject } }).call(verb, args);
|
||||
const read = transport;
|
||||
const write = (verb, args, ctx) => { writes++; return transport(verb, args, ctx); };
|
||||
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso, send: async () => ({}), requestRead: read, requestWrite: write, log: () => {} });
|
||||
t.after(() => close(web));
|
||||
const send = (method, path, cookie, body, extraHeaders = {}) => new Promise((resolve, reject) => {
|
||||
const data = body === undefined ? undefined : JSON.stringify(body);
|
||||
const req = request({ host: '127.0.0.1', port: web.address().port, method, path, headers: { host: 'console.example.test', ...(cookie ? { cookie } : {}), ...(data ? { origin, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', 'content-length': Buffer.byteLength(data) } : {}), ...extraHeaders } }, res => {
|
||||
let text = ''; res.on('data', chunk => { text += chunk; }); res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: text && (res.headers['content-type'] ?? '').startsWith('application/json') ? JSON.parse(text) : text }));
|
||||
}); req.on('error', reject); req.setTimeout(10000, () => req.destroy(Error('request timed out'))); req.end(data);
|
||||
});
|
||||
const signIn = async sub => {
|
||||
const start = await send('GET', '/auth/login');
|
||||
const cookie = start.headers['set-cookie'][0].split(';')[0];
|
||||
const grant = op.grant(issuer, start.headers.location, { sub });
|
||||
const q = new URLSearchParams({ code: grant.code, state: new URL(start.headers.location).searchParams.get('state') });
|
||||
const result = await send('GET', '/auth/callback?' + q, cookie);
|
||||
return result.headers['set-cookie'].find(c => c.startsWith(SESSION_COOKIE + '=') && !c.includes('Max-Age=0')).split(';')[0];
|
||||
};
|
||||
const body = { revision: 'a'.repeat(64), remote: url };
|
||||
for (const sub of ['alice-sub', 'bob-sub']) {
|
||||
const cookie = await signIn(sub);
|
||||
for (const method of ['GET', 'POST']) {
|
||||
const response = await send(method, '/api/project-remotes/stack', cookie, method === 'POST' ? body : undefined);
|
||||
assert.equal(response.status, 403); assert.equal(response.body.error, 'owner-only');
|
||||
}
|
||||
}
|
||||
// A CLI human or agent cannot turn an unauthenticated HTTP request into an owner session.
|
||||
for (const headers of [{ 'x-user': 'jason' }, { 'x-agent': 'pm' }]) for (const method of ['GET', 'POST']) {
|
||||
const response = await send(method, '/api/project-remotes/stack', null, method === 'POST' ? body : undefined, headers);
|
||||
assert.equal(response.status, 401); assert.equal(response.body.error, 'sign-in-required');
|
||||
}
|
||||
const owner = await signIn('owner-sub');
|
||||
const readOwner = await send('GET', '/api/project-remotes/stack', owner);
|
||||
assert.equal(readOwner.status, 200); assert.equal(readOwner.body.remote, null);
|
||||
assert.equal((await send('POST', '/auth/logout', owner, {})).status, 303);
|
||||
const after = await send('POST', '/api/project-remotes/stack', owner, { ...body, revision: readOwner.body.revision });
|
||||
assert.equal(after.status, 401); assert.equal(after.body.error, 'sign-in-required'); assert.equal(writes, 0);
|
||||
assert.deepEqual(readFileSync(f.file), original);
|
||||
});
|
||||
|
||||
test('tester listener refuses both remote methods', async t => {
|
||||
const { mkdtempSync, rmSync } = await import('node:fs');
|
||||
const { tmpdir } = await import('node:os');
|
||||
const { join } = await import('node:path');
|
||||
const { startServer } = await import('../src/serve.mjs');
|
||||
const { close } = await import('./fixture.mjs');
|
||||
const root = mkdtempSync(join(tmpdir(), 'remote-tester-')), socket = join(root, 'tester.sock');
|
||||
const server = await startServer({ tester: true, testerSocket: socket, board: 'http://127.0.0.1:9', log: () => {} });
|
||||
t.after(async () => { await close(server); rmSync(root, { recursive: true, force: true }); });
|
||||
for (const method of ['GET', 'POST']) {
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
const req = request({ socketPath: socket, path: '/api/project-remotes/stack', method, headers: { host: 'mosaic-tester.invalid' } }, res => { res.resume(); res.on('end', () => resolve(res.statusCode)); });
|
||||
req.on('error', reject); req.end();
|
||||
});
|
||||
assert.equal(result, 403);
|
||||
}
|
||||
for (const path of ['/project-remote.js', '/project-remote.css', '/files.js', '/files.css']) {
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
const req = request({ socketPath: socket, path, headers: { host: 'mosaic-tester.invalid' } }, res => { res.resume(); res.on('end', () => resolve(res.statusCode)); });
|
||||
req.on('error', reject); req.end();
|
||||
});
|
||||
assert.equal(result, 403, path);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
test('library targets and unrelated project mapping drift are refused', async t => {
|
||||
const { mappingEntry } = await import('../../library/tests/helpers.mjs');
|
||||
const { writeFileSync } = await import('node:fs');
|
||||
const f = await realRemoteFixture(t, doc => { doc.projects.books = mappingEntry(); doc.roles.chief.credentials = { gitea: { ...doc.roles.coder.credentials.gitea } }; });
|
||||
const library = await new Promise((resolve, reject) => {
|
||||
const req = request(f.base + '/api/project-remotes/books', res => { let text = ''; res.on('data', chunk => { text += chunk; }); res.on('end', () => resolve([res.statusCode, JSON.parse(text)])); });
|
||||
req.on('error', reject); req.end();
|
||||
});
|
||||
assert.deepEqual(library, [403, { error: 'project-remote-library' }]);
|
||||
const [, before] = await get(f), document = JSON.parse(readFileSync(f.file));
|
||||
document.projects.stack.root += '-other'; writeFileSync(f.file, JSON.stringify(document));
|
||||
const bytes = readFileSync(f.file), [status, result] = await post(f, { revision: before.revision, remote: url });
|
||||
assert.equal(status, 409); assert.equal(result.error, 'project-remote-protected'); assert.deepEqual(readFileSync(f.file), bytes);
|
||||
});
|
||||
@@ -147,7 +147,7 @@ test('request panel: only the latest three owner messages get a panel, and a req
|
||||
const { b, w, count } = await setup(t, { boot: { pages: [{ rows, after: rows.at(-1).id, hasMore: false }], states } });
|
||||
await w('document.querySelectorAll(".request-panel").length === 3');
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll(".request-mount")].map(e => e.dataset.request)'), ids.slice(2));
|
||||
assert.equal(await b.evaluate('window.__rreads.filter(p => /^\\/api\\/requests\\/[^/]+$/.test(p)).length'), 3);
|
||||
assert.deepEqual(await b.evaluate('[...new Set(window.__rreads.filter(p => /^\\/api\\/requests\\/[^/]+$/.test(p)).map(p => p.split("/").at(-1)))].sort()'), ids.slice(2).sort(), 'the panel, Files, and summary only read the three latest request IDs');
|
||||
// The third newest is unknown to the bus: no panel, no error banner.
|
||||
const gone = await setup(t, { boot: { pages: [{ rows: [ROW], after: M, hasMore: false }], states: {} } });
|
||||
await gone.w(`window.__rreads.some(p => p === "/api/requests/${R}")`);
|
||||
@@ -578,3 +578,17 @@ test('reviewed delivery shows a download and bound evidence separately from depl
|
||||
for (const value of [evidence.snapshot, evidence.task_ref, evidence.commit, evidence.review_request, evidence.verdict, evidence.verification.record_commit, evidence.artifact.sha256]) assert.ok(panel.includes(value));
|
||||
assert.equal(await b.evaluate(`document.querySelector('${P} .request-delivery a').getAttribute('href')`), `/api/requests/${R}/artifact/delivery-1`);
|
||||
});
|
||||
|
||||
test('reviewed delivery refuses evidence bound to another project or snapshot', async t => {
|
||||
const evidence = { id: 'delivery-1', request: R, project: 'other', snapshot: 'prd-approved', task_ref: 'vikunja:2/41',
|
||||
artifact: { blob: 'b'.repeat(40), sha256: 'c'.repeat(64) }, verification: { record_path: 'docs/verification.md', record_commit: 'd'.repeat(40) } };
|
||||
const complete = state({ ...APPROVED, phase: 'complete', delivery: evidence.id, delivery_evidence: evidence });
|
||||
const e = await setup(t, { boot: { states: { [R]: complete }, drafts: { [R]: ACTIVE_DRAFT } } });
|
||||
await e.w(`document.querySelector('${P} .request-snapshot')`);
|
||||
assert.equal(await e.count(`${P} .request-delivery`), 0);
|
||||
await e.b.evaluate(`window.__states['${R}']=${JSON.stringify({ ...complete, revision: 15, delivery_evidence: { ...evidence, project: 'factory', snapshot: 'other' } })};true`);
|
||||
await e.click(`${P} [data-request-reload]`);
|
||||
await e.w(`window.__rreads.filter(p => p === '/api/requests/${R}').length >= 2 && document.querySelector('${P} [data-request-reload]')`);
|
||||
assert.equal(await e.count(`${P} .request-delivery`), 0);
|
||||
assert.deepEqual(await e.writes(), []);
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
// #1542 (row 53): the Console shell restyle. Static checks: the design copies match
|
||||
// docs/design byte for byte, the mono fonts match their recorded digests, and the shipped
|
||||
// icons match docs/design, shared tokens remain available, mono fonts match their digests, and the shipped
|
||||
// strings are unchanged. Browser checks: System is the default appearance, the board and
|
||||
// bus states (empty, a failed read over kept data, a refusal), no sideways scroll at 400px,
|
||||
// visible keyboard focus, the Ctrl+K palette, and each view in its five states. Nothing here
|
||||
@@ -18,8 +18,10 @@ import { BusReadError } from '../src/bus.mjs';
|
||||
const pub = new URL('../src/public/', import.meta.url), design = new URL('../../../docs/design/', import.meta.url);
|
||||
const read = (name, base = pub) => readFileSync(new URL(name, base));
|
||||
|
||||
test('tokens.css and icons.svg are byte copies of docs/design', () => {
|
||||
for (const name of ['tokens.css', 'icons.svg']) assert.ok(read(name).equals(read(name, design)), `${name} differs from docs/design/${name}; copy it again`);
|
||||
test('icons retain the design source and W15 geometry tokens are present', () => {
|
||||
assert.ok(read('icons.svg').equals(read('icons.svg', design)));
|
||||
const css = read('tokens.css').toString();
|
||||
for (const token of ['--rail-width: 88px', '--sidebar-width: 226px', '--action-target: 44px']) assert.ok(css.includes(token), token);
|
||||
});
|
||||
|
||||
test('JetBrains Mono files match the digests in their sources file, with the OFL text', () => {
|
||||
@@ -135,7 +137,7 @@ test('shell in a browser: System default, seeded states, 400px, focus and the Ct
|
||||
await b.call('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-color-scheme', value: 'light' }] });
|
||||
assert.ok(await b.evaluate('(()=>{const [r,g,bb]=getComputedStyle(document.body).backgroundColor.match(/[\\d.]+/g).map(Number);return (r+g+bb)/3})()') > 180, 'light OS gives a light page');
|
||||
assert.equal(await b.evaluate('getComputedStyle(document.documentElement).getPropertyValue("--r").trim()'), '6px');
|
||||
assert.equal(await b.evaluate('getComputedStyle(document.documentElement).getPropertyValue("--r-lg").trim()'), '10px');
|
||||
assert.equal(await b.evaluate('getComputedStyle(document.documentElement).getPropertyValue("--r-lg").trim()'), '6px');
|
||||
assert.equal(await b.evaluate('document.fonts.check("12px \\"JetBrains Mono\\"")'), true);
|
||||
await b.evaluate('document.querySelector("#mode").value="dark";document.querySelector("#mode").dispatchEvent(new Event("change"))');
|
||||
assert.equal(await b.evaluate('document.documentElement.dataset.mode'), 'dark');
|
||||
@@ -273,17 +275,18 @@ test('shell in a browser: System default, seeded states, 400px, focus and the Ct
|
||||
}
|
||||
assert.equal(await b.evaluate('getComputedStyle(document.querySelector("#fresh-board")).display'), 'none');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#sections svg use").length'), 10);
|
||||
assert.deepEqual(await b.evaluate(`(() => { const a = document.querySelector('#sections a[href="#/chat"]'); return [a?.textContent.trim(), a?.querySelector('svg use')?.getAttribute('href')]; })()`), ['Chat', '/icons.svg#i-inbox']);
|
||||
assert.deepEqual(await b.evaluate(`(() => { const a = document.querySelector('#sections a[href="#/projects"]'); return [a?.textContent.trim(), a?.querySelector('svg use')?.getAttribute('href')]; })()`), ['Projects', '/icons.svg#i-inbox']);
|
||||
for (const sel of ['#fresh', '#session-count']) assert.equal(await b.evaluate(`getComputedStyle(document.querySelector(${JSON.stringify(sel)})).fontVariantNumeric`), 'tabular-nums', sel);
|
||||
// Before any copy the toast is an empty live region still in the accessibility tree, out of sight.
|
||||
assert.deepEqual(await b.evaluate('(()=>{const t=document.querySelector("#toast"),c=getComputedStyle(t),r=t.getBoundingClientRect();return [c.display!=="none",c.visibility,t.textContent,r.width<=1&&r.height<=1]})()'), [true, 'visible', '', true]);
|
||||
// Below 760px the section list is one strip that scrolls inside itself: every link on one
|
||||
// line, no label wrapped, and still no sideways scroll on the page. 360px, then back to 400px.
|
||||
// At narrow widths the full navigation is in a scrollable drawer.
|
||||
for (const w of [360, 400]) {
|
||||
await b.viewport(w, 900);
|
||||
assert.equal(await b.evaluate('new Set([...document.querySelectorAll("#sections a")].map(a=>a.offsetTop)).size'), 1, `one row at ${w}px`);
|
||||
assert.match(await b.evaluate('getComputedStyle(document.querySelector("#sections")).overflowX'), /^(auto|scroll)$/, `strip scrolls at ${w}px`);
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#sections a")].filter(a=>a.getClientRects().length>1||a.scrollWidth>a.clientWidth+1).map(a=>a.textContent)'), [], `no wrapped label at ${w}px`);
|
||||
await b.evaluate('document.querySelector("#nav-open").click()');
|
||||
assert.equal(await b.evaluate('document.querySelector("#nav-close").getClientRects().length > 0'), true);
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#sections a").length'), 10);
|
||||
await b.key('Escape');
|
||||
assert.equal(await b.evaluate('document.activeElement.id'), 'nav-open');
|
||||
await flat();
|
||||
}
|
||||
|
||||
@@ -491,7 +494,7 @@ test('five states on Board, Inbox, Tasks, Agents and Trail', { timeout: 180000 }
|
||||
}
|
||||
|
||||
// After a refusal the palette lists only the views: no kept decision or task.
|
||||
const views = ['#/', '#/chat', '#/inbox', '#/messages', '#/tasks', '#/agents', '#/queue', '#/business', '#/docs', '#/settings'];
|
||||
const views = ['#/', '#/projects', '#/inbox', '#/messages', '#/tasks', '#/agents', '#/queue', '#/business', '#/docs', '#/settings', '#/settings/display'];
|
||||
const palette = '[...document.querySelectorAll("#cmdk-list [data-href]")].map(e=>e.dataset.href)';
|
||||
await b.evaluate('location.hash="#/inbox"'); await wait('document.querySelectorAll("#sections .count").length===1');
|
||||
assert.match(await text('#sections'), /Inbox 1 1 blocking/);
|
||||
|
||||
@@ -56,7 +56,7 @@ async function page(b, base) {
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: PROBE });
|
||||
await b.viewport(400, 900);
|
||||
await b.navigate(base);
|
||||
await wait(`window.__stubReady === true && document.querySelectorAll("#sections a").length===10 && document.querySelector('#sections a[href="#/chat"]')?.textContent.trim()==='Chat'`);
|
||||
await wait(`window.__stubReady === true && document.querySelectorAll("#sections a").length===10 && document.querySelector('#sections a[href="#/projects"]')?.textContent.trim()==='Projects'`);
|
||||
return { wait, text, count, flat, refresh, go };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { setup, P, T, S, S2 } from './helpers/w15-conversations.mjs';
|
||||
|
||||
test('canonical project and session routes preserve identity, drafts and optional mounts', async t=>{
|
||||
const {b,render,shot}=await setup(t);
|
||||
await b.evaluate(`window.MosaicRequests={mount(){},projectSummary(o){window.__mounts.push(['summary',o.requestIds,o.project]);o.container.textContent='Fixture request summary';}}`);
|
||||
await render(`/projects/${P}`);
|
||||
assert.match(await b.evaluate('document.querySelector(".chat-area").textContent'),/Request status is not loaded/);
|
||||
for(const w of [1440,390]) await shot('project-overview-unloaded',w);
|
||||
await render(`/projects/${P}/settings`);
|
||||
assert.deepEqual(await b.evaluate('window.__mounts.at(-1)'),['remote',P,'repo-key']);
|
||||
await render(`/projects/threads/${T}/sessions/${S}`);
|
||||
assert.equal(await b.evaluate('document.querySelector(".chat-conversation").dataset.session'),S);
|
||||
assert.deepEqual(await b.evaluate('window.__contexts.at(-1)'),{projectId:P,repositoryKey:'repo-key',thread:T,session:S});
|
||||
assert.ok(await b.evaluate(`window.__mounts.some(x=>x[0]==='files' && x[1][0]==='request-1')`));
|
||||
assert.deepEqual(await b.evaluate('window.__mounts.at(-1)'),['summary',['request-1'],'repo-key']);
|
||||
await b.evaluate('document.querySelector("#chat-body").value="first draft";document.querySelector("#chat-body").dispatchEvent(new Event("input",{bubbles:true}))');
|
||||
for(const w of [1440,390]) await shot('conversation-progress',w);
|
||||
await b.evaluate('document.querySelector(".chat-details").open=true');
|
||||
for(const w of [1440,390]) await shot('conversation-details',w);
|
||||
await render(`/projects/threads/${T}/sessions/${S2}`);
|
||||
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'),'');
|
||||
await b.evaluate('document.querySelector("#chat-body").value="second draft";document.querySelector("#chat-body").dispatchEvent(new Event("input",{bubbles:true}))');
|
||||
for(const w of [1440,390]) await shot('two-named-sessions',w);
|
||||
await render(`/projects/threads/${T}/sessions/${S}`);
|
||||
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'),'first draft');
|
||||
await render(`/projects/${P}`);
|
||||
assert.match(await b.evaluate('document.querySelector(".chat-area").textContent'),/Other work may not be shown/);
|
||||
assert.deepEqual(await b.evaluate('window.__mounts.at(-1)'),['summary',['request-1'],'repo-key']);
|
||||
for(const w of [1440,390]) await shot('project-overview-known',w);
|
||||
await b.evaluate('delete window.MosaicRequests'); await render(`/projects/${P}`);
|
||||
assert.match(await b.evaluate('document.querySelector("[data-chat-summary]").textContent'),/unavailable/);
|
||||
await render(`/projects/threads/${T}/sessions/${S}`);
|
||||
await b.evaluate('window.__failRead=true');await render(`/projects/threads/${T}/sessions/${S}`);
|
||||
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'),'first draft');
|
||||
for(const w of [1440,390]) await shot('read-failed-draft-kept',w);
|
||||
assert.equal(await b.evaluate('window.__writes.length'),0);
|
||||
});
|
||||
|
||||
test('room picker serializes selected instance and retains unresolved drafts', async t=>{
|
||||
const {b,render,shot}=await setup(t);await render(`/projects/rooms/${P}`);
|
||||
assert.match(await b.evaluate('document.querySelector(".chat-room").textContent'),/replies are private/);
|
||||
for(const w of [1440,390]) await shot('room-picker',w);
|
||||
await b.evaluate('document.querySelector("[data-room-member=bob]").click()');
|
||||
assert.match(await b.evaluate('document.querySelector("#chat-room-body").value'),/^@alice /);
|
||||
assert.match(await b.evaluate('document.querySelector("#chat-room-tags").textContent'),/To:.*alice/);
|
||||
await b.evaluate('document.querySelector("#chat-room-body").value="@alice Please ask @bob too";document.querySelector("#chat-room-body").dispatchEvent(new Event("input",{bubbles:true}))');
|
||||
assert.match(await b.evaluate('document.querySelector("#chat-room-tags").textContent'),/To:.*alice/);
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-room-offer=bob]") !== null'),true);
|
||||
await b.evaluate('document.querySelector("[data-room-offer=bob]").click()');
|
||||
assert.match(await b.evaluate('document.querySelector("#chat-room-tags").textContent'),/alice.*bob/);
|
||||
await b.evaluate('document.querySelector("#chat-room-body").value="@unknown message";document.querySelector("#chat-room-body").dispatchEvent(new Event("input",{bubbles:true}));document.querySelector("[data-room-member=bob]").click()');
|
||||
assert.equal(await b.evaluate('document.querySelector("#chat-room-body").value'),'@unknown message');
|
||||
assert.match(await b.evaluate('document.querySelector("#chat-room-receipt").textContent'),/Repair/);
|
||||
for(const w of [1440,390]) await shot('room-invalid',w);
|
||||
assert.equal(await b.evaluate('window.__writes.length'),0);
|
||||
});
|
||||
|
||||
|
||||
test('granted routes do not expose owner room or remote controls', async t=>{
|
||||
const {b}=await setup(t);
|
||||
await b.evaluate(`MosaicChat.render({path:'/projects/rooms/${P}',container:document.getElementById('bus-view'),canSend:true,user:true})`);
|
||||
assert.match(await b.evaluate('document.querySelector(".chat-area").textContent'),/Rooms require the business owner/);
|
||||
assert.equal(await b.evaluate('!!document.querySelector("#chat-room-send-form")'),false);
|
||||
await b.evaluate(`MosaicChat.render({path:'/projects/${P}/settings',container:document.getElementById('bus-view'),canSend:true,user:true})`);
|
||||
assert.equal(await b.evaluate('window.__mounts.some(x=>x[0]==="remote")'),false);
|
||||
assert.equal(await b.evaluate('window.__writes.length'),0);
|
||||
});
|
||||
|
||||
|
||||
test('pending clear, room receipt and shared history retain truthful state', async t=>{
|
||||
const {b,render,wait,shot}=await setup(t);
|
||||
await render(`/projects/threads/${T}/sessions/${S}`);
|
||||
await b.evaluate('document.querySelector(".chat-details").open=true;window.__holdClear=true;document.querySelector("#chat-clear").click()');
|
||||
await wait('window.__writes.some(w=>w.path.endsWith("/clear"))');
|
||||
assert.match(await b.evaluate('document.querySelector(".chat-log").textContent'),/fixture message/);
|
||||
assert.match(await b.evaluate('document.querySelector("#chat-clear-receipt").textContent'),/requesting clear/);
|
||||
for(const w of [1440,390]) await shot('clear-pending',w);
|
||||
await b.evaluate('window.__releaseClear()');
|
||||
await render(`/projects/rooms/${P}`);
|
||||
await b.evaluate('window.__roomAccept=true;document.querySelector("[data-room-member=bob]").click();const e=document.querySelector("#chat-room-body");e.value="@alice hello";e.dispatchEvent(new Event("input",{bubbles:true}));document.querySelector("#chat-room-send-form").dispatchEvent(new Event("submit",{bubbles:true,cancelable:true}))');
|
||||
await wait('document.querySelector("#chat-room-receipt").textContent.includes("Accepted")');
|
||||
for(const w of [1440,390]) await shot('room-sent',w);
|
||||
await b.evaluate('window.__shared=true');
|
||||
await render(`/projects/threads/${T}`);
|
||||
assert.match(await b.evaluate('document.querySelector(".chat-area").textContent'),/Read-only thread/);
|
||||
assert.equal(await b.evaluate('!!document.querySelector("#chat-send-form")'),false);
|
||||
for(const w of [1440,390]) await shot('shared-readonly',w);
|
||||
});
|
||||
|
||||
test('room recipient controls accept keyboard and pointer at both widths', async t=>{
|
||||
const {b,render}=await setup(t);
|
||||
for(const width of [1440,390]) {
|
||||
await b.viewport(width,1000); await render(`/projects/rooms/${P}`);
|
||||
await b.evaluate('document.querySelector("[data-room-member=bob]").focus()');
|
||||
await b.key(' ');
|
||||
assert.match(await b.evaluate('document.querySelector("#chat-room-tags").textContent'),/alice/);
|
||||
assert.equal(await b.evaluate('document.activeElement.dataset.roomMember'),'bob');
|
||||
await b.key('Tab'); await b.key('Tab','Tab',8); await b.key('Escape');
|
||||
assert.notEqual(await b.evaluate('document.activeElement.tagName'),'BODY');
|
||||
const box=await b.evaluate('(()=>{const r=document.querySelector("[data-room-member=bob]").getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2}})()');
|
||||
await b.call('Input.dispatchMouseEvent',{type:'mousePressed',x:box.x,y:box.y,button:'left',clickCount:1});
|
||||
await b.call('Input.dispatchMouseEvent',{type:'mouseReleased',x:box.x,y:box.y,button:'left',clickCount:1});
|
||||
assert.match(await b.evaluate('document.querySelector("#chat-room-tags").textContent'),/bob/);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { join } from 'node:path';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { filesFixture } from './helpers/w15-files.mjs';
|
||||
|
||||
test('Files presents reviewed delivery and every names-only draft state at both widths', async t => {
|
||||
const { f, b, errors } = await filesFixture(t);
|
||||
const shots = process.env.MOSAIC_UX_SHOTS;
|
||||
const state = { version: 1, revision: 1, phase: 'complete', request: 'request-1', delivery: 'delivery-1', delivery_evidence: { id: 'delivery-1', request: 'request-1', artifact: { path: 'output/timer.html' } } };
|
||||
const base = { available: true, entries: [], truncated: false, contentAvailable: false, observedAt: '2026-11-05T00:00:00.000Z' };
|
||||
const scenarios = [
|
||||
['draft-list', { ...base, entries: [{ name: 'src/timer.js' }, { name: 'README.md' }] }, /Names only.*src\/timer.js.*README.md/s],
|
||||
['draft-empty', base, /No draft files found/],
|
||||
['draft-unavailable', { ...base, available: false }, /Draft workspace is unavailable/],
|
||||
['draft-truncated', { ...base, entries: [{ name: 'src/timer.js' }], truncated: true }, /src\/timer.js.*truncated/s],
|
||||
['draft-error', null, /Could not read draft workspace/],
|
||||
];
|
||||
for (const width of [1440, 390]) {
|
||||
await b.viewport(width, 1000);
|
||||
await b.navigate(f.base + '/#/');
|
||||
await b.evaluate(`(async () => {
|
||||
const css = document.createElement('link'); css.rel = 'stylesheet'; css.href = '/files.css'; document.head.append(css);
|
||||
await new Promise((resolve, reject) => { const s = document.createElement('script'); s.src = '/files.js'; s.onload = resolve; s.onerror = reject; document.head.append(s); });
|
||||
window.fixtureState = ${JSON.stringify(state)};
|
||||
const main = document.querySelector('#main'); main.replaceChildren(); const mount = document.createElement('div'); mount.id = 'slice-fixture'; main.append(mount);
|
||||
})()`);
|
||||
for (const [name, workspace, expected] of scenarios) {
|
||||
await b.evaluate(`(async () => { window.fixtureWorkspace = ${JSON.stringify(workspace)}; await MosaicFiles.mount({container: document.querySelector('#slice-fixture'), requestIds:['request-1']}); })()`);
|
||||
const text = await b.evaluate("document.querySelector('#slice-fixture').textContent");
|
||||
assert.match(text, /Reviewed delivery.*timer.html.*Open as text.*Download.*Product workspace, draft/s);
|
||||
assert.match(text, expected);
|
||||
assert.equal(await b.evaluate("document.querySelector('#slice-fixture a').getAttribute('href')"), '/api/requests/request-1/artifact/delivery-1/text');
|
||||
assert.equal(await b.evaluate("document.querySelector('#slice-fixture a').rel"), 'noopener noreferrer');
|
||||
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= innerWidth'), true);
|
||||
await b.evaluate("document.querySelector('#slice-fixture a[download]').focus()");
|
||||
await b.key('Tab');
|
||||
assert.equal(await b.evaluate("document.activeElement.getAttribute('aria-label')"), 'Product workspace, draft');
|
||||
await b.key('Tab', 'Tab', 8);
|
||||
assert.equal(await b.evaluate("document.activeElement.hasAttribute('download')"), true);
|
||||
await b.call('Input.dispatchMouseEvent', { type: 'mousePressed', x: 2, y: 2, button: 'left', clickCount: 1 });
|
||||
await b.call('Input.dispatchMouseEvent', { type: 'mouseReleased', x: 2, y: 2, button: 'left', clickCount: 1 });
|
||||
if (shots) {
|
||||
await b.screenshot(join(shots, `files-${name}-${width}.png`));
|
||||
const { cssContentSize } = await b.call('Page.getLayoutMetrics');
|
||||
const { data } = await b.call('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true, clip: { x: 0, y: 0, width: cssContentSize.width, height: cssContentSize.height, scale: 1 } });
|
||||
await writeFile(join(shots, `files-${name}-${width}-full.png`), Buffer.from(data, 'base64'));
|
||||
}
|
||||
}
|
||||
}
|
||||
await b.evaluate(`(async () => { window.fixtureState.request = 'foreign'; await MosaicFiles.mount({container: document.querySelector('#slice-fixture'), requestIds:['request-1']}); })()`);
|
||||
assert.equal(await b.evaluate("document.querySelectorAll('#slice-fixture a').length"), 0);
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { fixture } from './fixture.mjs';
|
||||
import { browser } from './browser.mjs';
|
||||
|
||||
export const ids = Object.freeze({ project: '10000000-0000-4000-8000-000000000001', empty: '10000000-0000-4000-8000-000000000002', archived: '10000000-0000-4000-8000-000000000003', repository: 'fixture-repository', thread: '20000000-0000-4000-8000-000000000001', session: '30000000-0000-4000-8000-000000000001', observed: '2026-11-05T12:00:00.000Z' });
|
||||
|
||||
// All API responses are explicit, and all network destinations outside the fixture
|
||||
// origin are refused. Tests may override a response by assigning window.__scenario.
|
||||
export async function w15Fixture(t, { scenario = {}, slice = 'shell' } = {}) {
|
||||
const f = await fixture(); t.after(() => f.close());
|
||||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||
const errors = []; const networkErrors = [];
|
||||
b.on('Network.responseReceived', ({response}) => { if (response.status >= 400) networkErrors.push({url:response.url,status:response.status}); });
|
||||
b.on('Network.loadingFailed', e => networkErrors.push({error:e.errorText}));
|
||||
await b.call('Network.enable');
|
||||
b.on('Fetch.requestPaused', ({ requestId, request }) => {
|
||||
const url = new URL(request.url);
|
||||
const allowed = url.origin === f.base;
|
||||
void b.call(allowed ? 'Fetch.continueRequest' : 'Fetch.failRequest', allowed ? { requestId } : { requestId, errorReason: 'BlockedByClient' }).catch(e => errors.push(String(e)));
|
||||
});
|
||||
await b.call('Fetch.enable', { patterns: [{ urlPattern: '*' }] });
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: `(() => {
|
||||
const ids = ${JSON.stringify(ids)}; window.__writes=[]; window.__unexpected=[]; window.__errors=[]; window.__scenario=${JSON.stringify(scenario)};
|
||||
addEventListener('error', e => __errors.push(e.message)); addEventListener('unhandledrejection', e => __errors.push(String(e.reason)));
|
||||
const data = (x,status=200) => new Response(JSON.stringify({at:ids.observed,...x}), {status,headers:{'content-type':'application/json'}});
|
||||
const real = fetch.bind(window);
|
||||
window.fetch = async (input, init={}) => {
|
||||
const url=new URL(typeof input==='string' ? input : input.url, location.href), path=url.pathname+url.search, method=(init.method||'GET').toUpperCase();
|
||||
if (url.origin!==location.origin) { __unexpected.push(method+' '+url.href); return new Response('refused',{status:403}); }
|
||||
if (!url.pathname.startsWith('/api/')) return real(input,init);
|
||||
if(method!=='GET') __writes.push({method,path,body:init.body||null});
|
||||
const key = method+' '+path;
|
||||
if (Object.hasOwn(__scenario,key)) { const response=__scenario[key]; return data(response.body ?? {},response.status ?? 200); }
|
||||
if(method!=='GET') { __unexpected.push(key); return data({error:'fixture-write-refused'},403); }
|
||||
if (Object.hasOwn(__scenario,path)) return data(__scenario[path]);
|
||||
if(/^\\/api\\/library\\/status\\?project=[^&]+$/.test(path) || path==='/api/processing' || path==='/api/bus-status') return new Response(JSON.stringify({error:'fixture-unavailable'}),{status:503,headers:{'content-type':'application/json'}});
|
||||
if(path==='/api/config') return data({send:true,edit:false,decide:false});
|
||||
if(path==='/api/business') return data({projects:[ids.repository]});
|
||||
if(path==='/api/chat/projects') return data({rows:[{id:ids.project,name:'Active project',repo:ids.repository},{id:ids.empty,name:'Empty project',repo:null},{id:ids.archived,name:'Archived project',archived:true,repo:null}]});
|
||||
if(path==='/api/chat/instances') return data({rows:[{instance:'assistant',name:'Assistant'}]});
|
||||
if(/^\\/api\\/chat\\/threads\\/[^/]+\\/shares$/.test(path)) return data({rows:[]});
|
||||
if(/^\\/api\\/chat\\/threads\\/[^/]+\\/sessions$/.test(path)) return data({rows:[{id:ids.session,name:'Default',default:true}]});
|
||||
if(/^\\/api\\/chat\\/threads\\/[^/]+\\/messages(?:\\?.*)?$/.test(path)) return data({rows:[],after:null,hasMore:false});
|
||||
if(path==='/api/chat/threads' || /^\\/api\\/chat\\/threads\\?(shared=true|project=)/.test(path)) return data({rows:[{id:ids.thread,project:ids.project,name:'Project conversation',instance:'assistant'}]});
|
||||
if(/^\\/api\\/bus\\/(inbox|messages|agents|tasks|queue|trail)(\\?|$)/.test(path)) return data({rows:[],unread:0});
|
||||
__unexpected.push(method+' '+path); return new Response(JSON.stringify({error:'fixture-unexpected-api'}),{status:403,headers:{'content-type':'application/json'}});
|
||||
};
|
||||
})();` });
|
||||
const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(${expression})return true;await new Promise(r=>setTimeout(r,40));}throw new Error('Timed out: '+${JSON.stringify(expression)})})()`);
|
||||
async function capture(state, width, route) {
|
||||
if (!process.env.MOSAIC_UX_SHOTS) return;
|
||||
const dir = process.env.MOSAIC_UX_SHOTS; await mkdir(dir, { recursive: true });
|
||||
await b.evaluate('document.fonts.ready');
|
||||
const prefix = `${slice}-${state}-${width}`;
|
||||
await b.screenshot(join(dir, `${prefix}.png`));
|
||||
const metrics = await b.call('Page.getLayoutMetrics');
|
||||
const size = metrics.cssContentSize || metrics.contentSize;
|
||||
const { data } = await b.call('Page.captureScreenshot', { format:'png', captureBeyondViewport:true, clip:{x:0,y:0,width:Math.ceil(size.width),height:Math.ceil(size.height),scale:1} });
|
||||
await writeFile(join(dir, `${prefix}-full.png`), Buffer.from(data,'base64'));
|
||||
return {state,route,width,height:1000,observation:ids.observed,viewport:`${prefix}.png`,full:`${prefix}-full.png`,errors:[...errors,...await b.evaluate('window.__errors')],unexpected:await b.evaluate('window.__unexpected'),networkErrors:[...networkErrors]};
|
||||
}
|
||||
return { f, b, wait, capture, errors };
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { setup, oldId, currentId, requestId } from './helpers/w15-inbox.mjs';
|
||||
|
||||
for (const width of [1440, 390]) test(`decision evidence and two-step answer at ${width}px`, { timeout: 120000 }, async t => {
|
||||
const s = await setup(t), { b, wait } = s;
|
||||
const text = sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.textContent ?? ''`);
|
||||
await s.page('/inbox', width);
|
||||
await wait('document.querySelectorAll(".s1-decs > li").length === 2');
|
||||
assert.match(await text('#b-for-you'), /Open decisions/);
|
||||
const cards = await b.evaluate('[...document.querySelectorAll(".s1-decs > li")].map(e => e.textContent)');
|
||||
assert.match(cards[0], /2026-11-03T22:17:22\.717Z.*\d+ d ago.*pm.*rcfdc22434131.*Not linked to a request/s);
|
||||
assert.match(cards[1], /current-run/);
|
||||
assert.doesNotMatch(cards[0], /current-run|b1869a13/);
|
||||
assert.deepEqual(await b.evaluate('window.__request'), { id: requestId, currentWaitSubject: currentId });
|
||||
assert.notEqual(currentId, requestId);
|
||||
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= innerWidth'), true);
|
||||
await s.shot('open-decisions-old-and-current', width);
|
||||
await s.page(`/inbox/${oldId}`, width);
|
||||
await wait('document.querySelector("[data-decide=option-0]")');
|
||||
assert.match(await text('#bus-view'), /rcfdc22434131/);
|
||||
await s.shot('historic-decision-detail', width);
|
||||
await b.evaluate('document.querySelector("[data-decide=option-0]").click()');
|
||||
await wait('document.querySelector("[data-decide=confirm]")');
|
||||
assert.match(await text('.s1-confirm'), /2026-11-03T22:17:22\.717Z.*rcfdc22434131.*Not linked to a request.*Affected object: unavailable.*Decision routed to: human.*Destination: unavailable.*keep.*Keep original/s);
|
||||
assert.equal(await b.evaluate('window.__posts.length'), 1);
|
||||
assert.equal(await b.evaluate('document.activeElement?.dataset.decide'), 'confirm');
|
||||
await b.key('Tab', 'Tab');
|
||||
assert.equal(await b.evaluate('document.activeElement?.dataset.decide'), 'cancel');
|
||||
await b.key('Tab', 'Tab', 8);
|
||||
assert.equal(await b.evaluate('document.activeElement?.dataset.decide'), 'confirm');
|
||||
await b.key('Escape');
|
||||
assert.equal(await b.evaluate('window.__posts.length'), 1);
|
||||
await s.shot('historic-confirmation', width);
|
||||
await b.evaluate('document.querySelector("[data-decide=confirm]").click()');
|
||||
await wait('document.querySelector("#decide-status")?.textContent.startsWith("Recorded")');
|
||||
assert.match(await text('#decide-section'), /Recorded: you chose “keep”, Keep original.*Affected object: unavailable.*Decision routed to: human.*Destination: unavailable.*unknown/s);
|
||||
assert.equal(await b.evaluate('window.__posts.length'), 2);
|
||||
await s.shot('decision-recorded', width);
|
||||
await s.page(`/inbox/${currentId}`, width);
|
||||
await wait('document.querySelector("[data-decide=option-0]")');
|
||||
assert.match(await text('#bus-view'), /Current request: choose a mode/);
|
||||
assert.doesNotMatch(await text('#bus-view'), /rcfdc22434131/);
|
||||
await s.shot('current-decision', width);
|
||||
await b.evaluate('window.__outcome = "unknown"; document.querySelector("[data-decide=option-0]").click()');
|
||||
await wait('document.querySelector("[data-decide=confirm]")');
|
||||
await b.evaluate('document.querySelector("[data-decide=confirm]").click()');
|
||||
await wait('document.querySelector("#decide-status code")?.textContent === "outcome-unknown"');
|
||||
assert.match(await text('#decide-status'), /Nothing was sent again/);
|
||||
await s.shot('decision-outcome-unknown', width);
|
||||
assert.deepEqual(await b.evaluate('window.__unexpected || []'), []);
|
||||
assert.deepEqual(s.failures, []);
|
||||
assert.deepEqual(s.consoleErrors, []);
|
||||
});
|
||||
|
||||
test('metadata handles missing and hostile origins without HTML or bidi injection', { timeout: 120000 }, async t => {
|
||||
const { b, page } = await setup(t);
|
||||
await page('/inbox', 1440);
|
||||
const result = await b.evaluate(`(() => { const host = document.createElement('div'); host.innerHTML = window.MosaicInbox.cardMeta({at:'bad', raised_by_role:'<img src=x onerror=alert(1)>\\u202e', raised_by_run:''}); return {text:host.textContent, images:host.querySelectorAll('img').length}; })()`);
|
||||
assert.match(result.text, /date unknown.*unknown.*\[U\+202E\].*run unknown.*Not linked to a request/s);
|
||||
assert.equal(result.images, 0);
|
||||
});
|
||||
|
||||
|
||||
test('receipt uses recorded target and destination without interpreting hostile text', { timeout: 120000 }, async t => {
|
||||
const { b, page } = await setup(t);
|
||||
await page('/inbox', 1440);
|
||||
const result = await b.evaluate(`(() => { const host = document.createElement('div'); host.innerHTML = window.MosaicInbox.receipt({kind:'decision',grantDetails:{host:'web1'}}, {authorization:{target:'workspace <img src=x>\\u202e'},routeTo:'human'}); return {text:host.textContent, images:host.querySelectorAll('img').length}; })()`);
|
||||
assert.match(result.text, /Affected object: workspace <img src=x>\[U\+202E\].*Decision routed to: human.*Destination: web1.*unknown/s);
|
||||
assert.equal(result.images, 0);
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { w15Fixture, ids } from './w15-fixture.mjs';
|
||||
|
||||
test('integrated project settings and conversation mounts use authorized context', { timeout: 90000 }, async t => {
|
||||
const scenario = {
|
||||
'/api/config': { send: true, requests: true, projectRemotes: true, projectRemoteWrites: true },
|
||||
'/api/project-remotes/fixture-repository': { project: ids.repository, remote: null, revision: 'a'.repeat(64), firstSaveAllowed: true },
|
||||
};
|
||||
const { f, b, wait } = await w15Fixture(t, { scenario });
|
||||
await b.viewport(390, 1000);
|
||||
await b.navigate(f.base + `/#/projects/${ids.project}/settings`);
|
||||
await wait('!!document.querySelector("#project-remote-url")');
|
||||
assert.equal(await b.evaluate('document.querySelector("#project-context .context-project")?.getAttribute("href")'), `#/projects/${ids.project}`);
|
||||
assert.equal(await b.evaluate('[...document.querySelectorAll("link")].some(x => x.getAttribute("href") === "/requests.css") && typeof MosaicFiles.mount === "function"'), true);
|
||||
const route = `#/projects/threads/${ids.thread}/sessions/${ids.session}`;
|
||||
await b.evaluate(`location.hash=${JSON.stringify(route)}`);
|
||||
await wait(`document.querySelector('.chat-conversation')?.dataset.session===${JSON.stringify(ids.session)}`);
|
||||
await wait('!!document.querySelector("[data-chat-files] .request-files") || document.querySelector("[data-chat-files]")?.textContent.includes("No")');
|
||||
assert.match(await b.evaluate('document.querySelector("#project-context").textContent'), /Active project.*Project conversation/s);
|
||||
assert.equal(await b.evaluate('document.querySelector("#project-context .context-conversation").getAttribute("href")'), route);
|
||||
await b.viewport(1440, 1000);
|
||||
await wait('document.querySelector("#processing-controls").parentElement.id === "console-nav"');
|
||||
assert.equal(await b.evaluate('document.querySelector("#processing-controls").parentElement.id'), 'console-nav');
|
||||
assert.deepEqual(await b.evaluate('window.__errors'), []);
|
||||
assert.deepEqual(await b.evaluate('window.__writes'), []);
|
||||
assert.deepEqual(await b.evaluate('window.__unexpected'), []);
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { remoteFixture } from './helpers/w15-project-remote.mjs';
|
||||
|
||||
for (const width of [1440, 390]) test(`project remote fixture at ${width}px`, async t => {
|
||||
const { b, mount, wait, errors, network } = await remoteFixture(t, width);
|
||||
const shots = process.env.MOSAIC_UX_SHOTS;
|
||||
if (shots) mkdirSync(shots, { recursive: true });
|
||||
async function shot(name) {
|
||||
if (!shots) return;
|
||||
await b.screenshot(join(shots, `project-remote-${name}-${width}.png`));
|
||||
const { contentSize } = await b.call('Page.getLayoutMetrics');
|
||||
const { data } = await b.call('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true, clip: { x: 0, y: 0, width: contentSize.width, height: contentSize.height, scale: 1 } });
|
||||
const { writeFile } = await import('node:fs/promises');
|
||||
await writeFile(join(shots, `project-remote-${name}-${width}-full.png`), Buffer.from(data, 'base64'));
|
||||
}
|
||||
async function input(value) {
|
||||
await b.evaluate(`(()=>{const e=document.querySelector('#project-remote-url');e.value=${JSON.stringify(value)};e.dispatchEvent(new Event('input',{bubbles:true}));return true})()`);
|
||||
}
|
||||
async function submit() {
|
||||
const rect = await b.evaluate(`(()=>{const r=document.querySelector('.project-remote button[type=submit]').getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2}})()`);
|
||||
await b.call('Input.dispatchMouseEvent', { type: 'mousePressed', button: 'left', clickCount: 1, ...rect });
|
||||
await b.call('Input.dispatchMouseEvent', { type: 'mouseReleased', button: 'left', clickCount: 1, ...rect });
|
||||
}
|
||||
await mount(); await wait(`document.querySelector('#project-remote-url')`);
|
||||
for (const mode of ['light', 'dark']) {
|
||||
await b.evaluate(`(()=>{const e=document.querySelector('#mode');e.value='${mode}';e.dispatchEvent(new Event('change',{bubbles:true}));return true})()`);
|
||||
assert.equal(await b.evaluate(`document.documentElement.dataset.mode`), mode);
|
||||
assert.equal(await b.evaluate(`getComputedStyle(document.querySelector('#project-remote-url')).visibility`), 'visible');
|
||||
}
|
||||
await b.evaluate(`(()=>{const e=document.querySelector('#mode');e.value='system';e.dispatchEvent(new Event('change',{bubbles:true}));return true})()`);
|
||||
const ax = await b.call('Accessibility.getFullAXTree');
|
||||
assert.ok(ax.nodes.some(n => n.role?.value === 'textbox' && n.name?.value === 'Existing HTTPS repository URL'));
|
||||
assert.ok(ax.nodes.some(n => n.role?.value === 'button' && n.name?.value === 'Save'));
|
||||
assert.equal(await b.evaluate(`document.querySelector('.project-remote button[type=submit]').getBoundingClientRect().height >= 44`), true);
|
||||
await b.evaluate(`document.querySelector('#project-remote-url').focus(),true`);
|
||||
await b.key('Tab'); assert.equal(await b.evaluate(`document.activeElement.textContent`), 'Save');
|
||||
await b.key('Tab', 'Tab', 8); assert.equal(await b.evaluate(`document.activeElement.id`), 'project-remote-url');
|
||||
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= innerWidth'), true);
|
||||
// Halving the CSS viewport models the reflow available at 200% browser zoom.
|
||||
await b.viewport(width / 2, 500);
|
||||
assert.equal(await b.evaluate(`document.querySelector('.project-remote').scrollWidth <= document.querySelector('.project-remote').clientWidth`), true);
|
||||
await b.viewport(width, 1000);
|
||||
await shot('remote-missing');
|
||||
await b.key('Tab'); await b.key('Tab', 'Tab', 8); await b.key('Escape');
|
||||
await input('https://git.example/owner/repo.git'); await shot('remote-editing');
|
||||
await b.evaluate(`window.__mode='hold',true`); await submit();
|
||||
await wait(`window.__writes.length===1 && !!window.__release`);
|
||||
assert.equal(await b.evaluate(`document.querySelector('.project-remote button[type=submit]').disabled`), true);
|
||||
await b.evaluate(`document.querySelector('.project-remote form').dispatchEvent(new Event('submit',{bubbles:true,cancelable:true})),window.__release(),true`);
|
||||
await wait(`document.querySelector('.project-remote').textContent.includes('Configured, not verified')`);
|
||||
assert.equal(await b.evaluate('window.__writes.length'), 1); await shot('remote-saved-not-published');
|
||||
await b.evaluate(`window.__remote={project:'stack',remote:null,revision:'${'a'.repeat(64)}',firstSaveAllowed:true};window.__mode='stale';true`);
|
||||
await mount(); await wait(`document.querySelector('#project-remote-url')`); await input('https://git.example/owner/repo.git'); await submit();
|
||||
await wait(`document.querySelector('.project-remote').textContent.includes('setting changed')`); await shot('remote-stale');
|
||||
assert.equal(await b.evaluate(`document.querySelector('#project-remote-url').value`), 'https://git.example/owner/repo.git');
|
||||
await b.evaluate(`document.querySelector('.project-remote button[type=button]').click(),true`);
|
||||
await wait(`document.querySelector('.project-remote').textContent.includes('Review your entry')`);
|
||||
assert.equal(await b.evaluate(`document.querySelector('#project-remote-url').value`), 'https://git.example/owner/repo.git');
|
||||
await input('https://name:[email protected]/owner/repo'); await submit();
|
||||
await wait(`document.querySelector('.project-remote').textContent.includes('must not contain credentials')`);
|
||||
assert.equal(await b.evaluate(`document.querySelector('#project-remote-url').value`), ''); await shot('remote-invalid');
|
||||
await b.evaluate(`window.__mode='fail',true`); await input('https://git.example/owner/invalid'); await submit();
|
||||
await wait(`document.querySelector('.project-remote').textContent.includes('Enter a valid HTTPS')`);
|
||||
assert.equal(await b.evaluate(`document.querySelector('#project-remote-url').value`), 'https://git.example/owner/invalid');
|
||||
await b.evaluate(`window.__mode='unknown',true`); await input('https://git.example/owner/unknown'); await submit();
|
||||
await wait(`document.querySelector('.project-remote').textContent.includes('Save outcome is unknown')`);
|
||||
assert.equal(await b.evaluate(`document.querySelector('.project-remote button[type=submit]').disabled`), true);
|
||||
assert.equal(await b.evaluate(`document.querySelector('#project-remote-url').value`), 'https://git.example/owner/unknown');
|
||||
await b.evaluate(`window.__writeEnabled=false,true`); await mount();
|
||||
await wait(`document.querySelector('.project-remote').textContent.includes('read-only')`);
|
||||
assert.equal(await b.evaluate(`document.querySelectorAll('.project-remote form').length`), 0);
|
||||
await mount(null); await shot('remote-readonly-or-unlinked');
|
||||
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= innerWidth'), true);
|
||||
await b.evaluate(`document.dispatchEvent(new Event('mosaic-signed-out')),true`);
|
||||
assert.equal(await b.evaluate(`document.querySelector('.project-remote').textContent`), '');
|
||||
assert.deepEqual(network, []); assert.deepEqual(errors, []);
|
||||
});
|
||||
|
||||
for (const width of [1440, 390]) test(`real fixture saves the project remote at ${width}px`, async t => {
|
||||
const { realRemoteFixture } = await import('./helpers/w15-project-remote-real.mjs');
|
||||
const { browser } = await import('./browser.mjs');
|
||||
const { readFileSync } = await import('node:fs');
|
||||
const f = await realRemoteFixture(t), b = await browser({ machine: false });
|
||||
t.after(() => b.close());
|
||||
const errors = [], blocked = [];
|
||||
b.on('Runtime.exceptionThrown', p => errors.push(p.exceptionDetails?.text));
|
||||
b.on('Fetch.requestPaused', p => {
|
||||
if (p.request.url.startsWith(f.base + '/')) b.call('Fetch.continueRequest', { requestId: p.requestId }).catch(() => {});
|
||||
else { blocked.push(p.request.url); b.call('Fetch.failRequest', { requestId: p.requestId, errorReason: 'BlockedByClient' }).catch(() => {}); }
|
||||
});
|
||||
await b.call('Fetch.enable', { patterns: [{ urlPattern: '*' }] });
|
||||
await b.viewport(width, 1000); await b.navigate(f.base + '/#/projects/10000000-0000-4000-8000-000000000001/settings');
|
||||
await b.evaluate(`new Promise((resolve,reject)=>{const link=document.createElement('link');link.rel='stylesheet';link.href='/project-remote.css';document.head.append(link);const script=document.createElement('script');script.src='/project-remote.js';script.onload=resolve;script.onerror=reject;document.head.append(script)})`);
|
||||
await b.evaluate(`(()=>{const main=document.querySelector('#main');for(const child of main.children)child.style.display='none';main.hidden=false;const h=document.createElement('h1');h.textContent='Project information';const slot=document.createElement('div');main.append(h,slot);MosaicProjectRemote.mount({container:slot,projectId:'10000000-0000-4000-8000-000000000001',repositoryKey:'stack'});return true})()`);
|
||||
const wait = async expression => b.evaluate(`(async()=>{const until=Date.now()+10000;while(!(${expression})){if(Date.now()>until)throw Error('timed out');await new Promise(r=>setTimeout(r,20))}return true})()`);
|
||||
await wait(`document.querySelector('#project-remote-url')`);
|
||||
await b.evaluate(`(()=>{const e=document.querySelector('#project-remote-url');e.value='https://git.example/owner/repo.git';e.dispatchEvent(new Event('input',{bubbles:true}));return true})()`);
|
||||
const rect = await b.evaluate(`(()=>{const r=document.querySelector('.project-remote button[type=submit]').getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2}})()`);
|
||||
await b.call('Input.dispatchMouseEvent', { type: 'mousePressed', button: 'left', clickCount: 1, ...rect });
|
||||
await b.call('Input.dispatchMouseEvent', { type: 'mouseReleased', button: 'left', clickCount: 1, ...rect });
|
||||
await wait(`document.querySelector('.project-remote').textContent.includes('Configured, not verified')`);
|
||||
assert.equal(JSON.parse(readFileSync(f.file)).projects.stack.remote, 'https://git.example/owner/repo.git');
|
||||
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= innerWidth'), true);
|
||||
if (process.env.MOSAIC_UX_SHOTS) {
|
||||
mkdirSync(process.env.MOSAIC_UX_SHOTS, { recursive: true });
|
||||
await b.screenshot(join(process.env.MOSAIC_UX_SHOTS, `project-remote-real-saved-${width}.png`));
|
||||
const { contentSize } = await b.call('Page.getLayoutMetrics');
|
||||
const { data } = await b.call('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true, clip: { x: 0, y: 0, width: contentSize.width, height: contentSize.height, scale: 1 } });
|
||||
const { writeFile } = await import('node:fs/promises');
|
||||
await writeFile(join(process.env.MOSAIC_UX_SHOTS, `project-remote-real-saved-${width}-full.png`), Buffer.from(data, 'base64'));
|
||||
}
|
||||
assert.deepEqual(blocked, []); assert.deepEqual(errors, []);
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { setup, state, candidate, draft, evidence, wait, question, REQUEST, DECISION } from './helpers/w15-requests.mjs';
|
||||
|
||||
const choices = [{key:'guided',text:'New project guided'}, {key:'autonomous',text:'New project autonomous'}, {key:'existing',text:'Add to existing project'}];
|
||||
const activeDraft = {...draft, snapshot:{id:'snapshot-1',revision:2}};
|
||||
const inbox = [{id:DECISION,question:'Choose a mode',console:{kind:'decision',options:choices}}];
|
||||
const scenarios = [
|
||||
['mode-choice', state({project:null,phase:'selection',selection:{decision:DECISION},wait:wait('decision','request-mode',DECISION)}), {inbox}],
|
||||
['project-picker-empty', state({project:null,phase:'selection',route:'existing',wait:wait('setup','request-existing-project',REQUEST)}), {}],
|
||||
['pm-question', state({question,wait:wait('question','question','q-1')}), {}],
|
||||
['prd-confirmation', state({phase:'prd-review',candidate,wait:wait('decision','prd-approval',DECISION)}), {draft,allowConfirmation:true,inbox:[{id:DECISION,question:'Approve exact PRD?',console:{kind:'decision',options:[{key:'yes',text:'Approve exact PRD'},{key:'no',text:'Request changes'}]}}]}],
|
||||
['request-progress', state({phase:'implementation',snapshot:'snapshot-1',candidate,question:{...question,answer:'answer-1'},tasks:['vikunja:2/41']}), {draft:activeDraft}],
|
||||
['request-refresh-failed', state({question,wait:wait('question','question','q-1')}), {}],
|
||||
['reviewed-result', state({phase:'complete',snapshot:'snapshot-1',candidate,delivery:evidence.id,delivery_evidence:evidence}), {draft:activeDraft}],
|
||||
['result-evidence', state({phase:'complete',snapshot:'snapshot-1',candidate,delivery:evidence.id,delivery_evidence:evidence}), {draft:activeDraft}],
|
||||
['legacy-complete', state({phase:'complete'}), {}],
|
||||
['pr-link-present-and-absent', state({phase:'complete',snapshot:'snapshot-1',candidate,delivery:evidence.id,delivery_evidence:{...evidence,pull_request:{request:REQUEST,project:'factory',url:'https://example.org/repo/pull/1'}}}), {draft:activeDraft}],
|
||||
];
|
||||
|
||||
test('W15 requests fixture scenarios and screenshots', async t => {
|
||||
const manifest = [];
|
||||
for (const [name, current, extra] of scenarios) {
|
||||
await t.test(name, async t => {
|
||||
const {b, route, navigate, shot, network, consoleErrors} = await setup(t, {states:{[REQUEST]:current}, ...extra});
|
||||
for (const width of [1440,390]) {
|
||||
await navigate(width);
|
||||
const waitFor = async expression => b.evaluate(`(async()=>{const end=Date.now()+10000;while(!(${expression})){if(Date.now()>end)throw Error('state missing');await new Promise(r=>setTimeout(r,30))}return true})()`);
|
||||
if (name === 'mode-choice') { await waitFor("document.querySelector('[data-request-choice]')"); assert.match(await b.evaluate("document.querySelector('[data-request-choice=guided]').textContent"), /^Guided/); }
|
||||
if (name === 'project-picker-empty') { await waitFor("document.querySelector('.request-picker')"); assert.match(await b.evaluate("document.querySelector('.request-picker').textContent"), /No project/); }
|
||||
if (name === 'prd-confirmation') { await waitFor("document.querySelector('[data-request-choice=yes]')"); await b.evaluate("document.querySelector('[data-request-choice=yes]').click();true"); await waitFor("document.querySelector('[data-request-confirm]')"); }
|
||||
if (name === 'request-progress') assert.match(await b.evaluate("document.querySelector('.request-panel').textContent"), /Answer received.*not established/s);
|
||||
if (name === 'legacy-complete') assert.match(await b.evaluate("document.querySelector('.request-panel').textContent"), /Completed before delivery evidence/);
|
||||
if (name === 'reviewed-result') assert.match(await b.evaluate("document.querySelector('.request-delivery').textContent"), /not landed or deployed.*PR link unavailable/s);
|
||||
if (name === 'result-evidence') await b.evaluate("document.querySelector('.request-delivery details').open=true;true");
|
||||
if (name === 'pr-link-present-and-absent') assert.equal(await b.evaluate("document.querySelector('.request-delivery a[target=_blank]').href"), 'https://example.org/repo/pull/1');
|
||||
if (name === 'request-refresh-failed') {
|
||||
const observed = await b.evaluate("document.querySelector('.request-observed time').dateTime");
|
||||
await b.evaluate("(()=>{window.__failure=true;const a=document.querySelector('.request-answer textarea');a.value='Draft kept';a.dispatchEvent(new Event('input',{bubbles:true}));document.querySelector('[data-request-reload]').click();return true})()");
|
||||
await waitFor("document.querySelector('.request-retained-draft')");
|
||||
assert.match(await b.evaluate("document.querySelector('.request-panel').textContent"), /Could not refresh.*last state/s);
|
||||
assert.equal(await b.evaluate("document.querySelector('.request-retained-draft').textContent"), 'Draft kept');
|
||||
assert.equal(await b.evaluate("document.querySelector('.request-observed time').dateTime"), observed);
|
||||
assert.equal(await b.evaluate('window.__events.length'), 1);
|
||||
assert.equal(await b.evaluate("document.querySelectorAll('.request-panel form').length"), 0);
|
||||
}
|
||||
assert.equal(await b.evaluate("document.documentElement.scrollWidth <= document.documentElement.clientWidth"), true, 'no document overflow');
|
||||
assert.equal(await b.evaluate("document.querySelector('.request-return').getAttribute('href')"), route);
|
||||
assert.deepEqual(await b.evaluate('window.__fileCalls.at(-1)'), [REQUEST]);
|
||||
const files = await shot(name, width);
|
||||
manifest.push({scenario:name, route, width, height:1000, observation:'Fixture read at 2026-11-05T12:00:00.000Z', files, networkErrors:[...network], consoleErrors:[...consoleErrors], refusedFixtureRequests:await b.evaluate('window.__unexpected')});
|
||||
}
|
||||
});
|
||||
}
|
||||
if (process.env.MOSAIC_UX_SHOTS) await writeFile(join(process.env.MOSAIC_UX_SHOTS,'manifest.json'), JSON.stringify({candidate:'set after commit', command:'node --test --test-concurrency=1 packages/webui/tests/w15-requests-browser.test.mjs', result:'see w15-tests.txt', scenarios:manifest}, null, 2));
|
||||
});
|
||||
|
||||
test('project summary rereads known IDs, refuses foreign project, and sign-out clears it', async t => {
|
||||
const {b,navigate} = await setup(t,{states:{[REQUEST]:state({})}}); await navigate(1440);
|
||||
assert.deepEqual(await b.evaluate(`(async()=>{const el=document.createElement('div');document.body.append(el);window.__summary=el;await MosaicRequests.projectSummary({container:el,requestIds:['${REQUEST}'],project:'other'});return [el.textContent,window.__events.length]})()`), ['Request information unavailable.',1]);
|
||||
assert.match(await b.evaluate(`(async()=>{await MosaicRequests.projectSummary({container:window.__summary,requestIds:['${REQUEST}'],project:'factory'});return window.__summary.textContent})()`), /factory|discovery/);
|
||||
await b.evaluate("document.dispatchEvent(new Event('mosaic-signed-out'));true");
|
||||
assert.equal(await b.evaluate('window.__summary.textContent'), '');
|
||||
});
|
||||
|
||||
test('PR URLs require exact request and project binding and safe HTTPS', async t => {
|
||||
const {b,navigate} = await setup(t,{states:{[REQUEST]:state({phase:'complete',snapshot:'snapshot-1',delivery:evidence.id,delivery_evidence:{...evidence,pull_request:{request:REQUEST,project:'factory',url:'javascript:alert(1)'}}})}}); await navigate(1440);
|
||||
assert.equal(await b.evaluate("document.querySelectorAll('.request-delivery a[target=_blank]').length"),0);
|
||||
assert.match(await b.evaluate("document.querySelector('.request-delivery').textContent"), /PR link unavailable/);
|
||||
});
|
||||
|
||||
|
||||
test('optional Files module can be absent and keyboard refresh preserves focus', async t => {
|
||||
const {b,navigate} = await setup(t,{states:{[REQUEST]:state({question,wait:wait('question','question','q-1')})}}); await navigate(390);
|
||||
await b.evaluate("delete window.MosaicFiles;document.querySelector('[data-request-reload]').focus();true");
|
||||
await b.key('Enter');
|
||||
await b.evaluate(`(async()=>{const end=Date.now()+10000;while(!document.querySelector('[data-request-reload]') || document.querySelector('[data-request-reload]').disabled){if(Date.now()>end)throw Error('refresh did not finish');await new Promise(r=>setTimeout(r,30))}return true})()`);
|
||||
assert.match(await b.evaluate("document.querySelector('[data-request-files]').textContent"), /unavailable/i);
|
||||
assert.notEqual(await b.evaluate('document.activeElement.tagName'), 'BODY');
|
||||
await b.key('Tab'); await b.key('Tab','Tab',8); await b.key('Escape');
|
||||
assert.equal(await b.evaluate("document.querySelector('.request-panel button[type=submit]').getBoundingClientRect().height >= 44"), true);
|
||||
await b.evaluate("document.documentElement.dataset.mode='dark';true");
|
||||
await b.call('Emulation.setPageScaleFactor',{pageScaleFactor:2});
|
||||
assert.equal(await b.evaluate("document.documentElement.scrollWidth <= document.documentElement.clientWidth"),true);
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
import test, { after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { w15Fixture, ids } from './w15-fixture.mjs';
|
||||
const captures = [];
|
||||
after(async () => { if (process.env.MOSAIC_UX_SHOTS) await writeFile(join(process.env.MOSAIC_UX_SHOTS,'manifest.json'),JSON.stringify({candidate:'uncommitted; see report',command:'node --test --test-concurrency=1 packages/webui/tests/w15-shell-browser.test.mjs',result:'see saved test output',captures:captures.filter(Boolean)},null,2)+'\n'); });
|
||||
|
||||
test('shell fixture: project navigation and narrow drawer', { timeout: 90000 }, async t => {
|
||||
const { f,b,wait,capture } = await w15Fixture(t, {scenario:{'/api/processing':{version:1,generation:'12345678-1234-4234-8234-123456789abc',halted:false,quiescent:true,remaining:0,stopped:0}}});
|
||||
for (const width of [1440,390]) {
|
||||
await b.viewport(width,1000); await b.navigate(f.base+'/#/projects');
|
||||
await wait('document.querySelector(".chat-area h1")?.textContent==="Projects"');
|
||||
await wait('!document.querySelector("#processing-halt").disabled');
|
||||
assert.equal(await b.evaluate(`(() => { const s=document.querySelector('#processing-halt'), r=s.getBoundingClientRect(), bar=document.querySelector('.cmdbar').getBoundingClientRect(); s.focus(); return document.querySelectorAll('#processing-halt').length===1 && !document.body.classList.contains('nav-open') && r.top>=bar.bottom && r.bottom<=innerHeight && getComputedStyle(s).visibility==='visible' && getComputedStyle(s).order==='0' && document.activeElement===s; })()`), true);
|
||||
assert.match(await b.evaluate('document.querySelector("#sections").textContent'), /Projects/);
|
||||
assert.equal(await b.evaluate('document.querySelector("#sections [aria-current=page]").textContent.trim()'), 'Projects');
|
||||
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'), true);
|
||||
if (width === 1440) assert.deepEqual(await b.evaluate('[Math.round(document.querySelector(".rail").getBoundingClientRect().width),Math.round(document.querySelector(".tree").getBoundingClientRect().x),Math.round(document.querySelector(".tree").getBoundingClientRect().width),Math.round(document.querySelector("#main").getBoundingClientRect().x)]'), [88,88,226,314]);
|
||||
captures.push(await capture('projects-list',width,'#/projects'));
|
||||
if(width===390) {
|
||||
await b.evaluate('document.querySelector("#nav-open").focus();document.querySelector("#nav-open").click()');
|
||||
assert.equal(await b.evaluate('document.activeElement.id'), 'nav-close');
|
||||
assert.equal(await b.evaluate('document.querySelector("#main").inert'), true);
|
||||
assert.equal(await b.evaluate('document.querySelector("#processing-mobile").inert'), true);
|
||||
assert.equal(await b.evaluate('document.querySelector("#sections [aria-current]").getBoundingClientRect().top < innerHeight'), true);
|
||||
captures.push(await capture('navigation-open',width,'#/projects'));
|
||||
await b.key('Tab','Tab',8); assert.notEqual(await b.evaluate('document.activeElement.tagName'), 'BODY');
|
||||
await b.key('Escape'); assert.equal(await b.evaluate('document.activeElement.id'), 'nav-open');
|
||||
assert.equal(await b.evaluate('document.querySelector("#main").inert'), false);
|
||||
const point = await b.evaluate('(()=>{const r=document.querySelector("#nav-open").getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2}})()');
|
||||
await b.call('Input.dispatchMouseEvent',{type:'mousePressed',...point,button:'left',clickCount:1});
|
||||
await b.call('Input.dispatchMouseEvent',{type:'mouseReleased',...point,button:'left',clickCount:1});
|
||||
assert.equal(await b.evaluate('document.activeElement.id'), 'nav-close');
|
||||
await b.key('Enter'); assert.equal(await b.evaluate('document.activeElement.id'), 'nav-open');
|
||||
} else captures.push(await capture('navigation-open',width,'#/projects'));
|
||||
const sessionRoute = `#/projects/threads/${ids.thread}/sessions/${ids.session}`;
|
||||
await b.evaluate(`location.hash=${JSON.stringify(sessionRoute)}`);
|
||||
await wait(`document.querySelector('.chat-conversation')?.dataset.session===${JSON.stringify(ids.session)}`);
|
||||
captures.push(await capture('conversation-shell',width,sessionRoute));
|
||||
await b.evaluate(`location.hash=${JSON.stringify(sessionRoute.replace('#/projects/','#/chat/'))}`);
|
||||
await wait(`document.querySelector('.chat-conversation')?.dataset.session===${JSON.stringify(ids.session)}`);
|
||||
assert.deepEqual(await b.evaluate('window.__writes'), []);
|
||||
assert.deepEqual(await b.evaluate('window.__unexpected'), []);
|
||||
}
|
||||
await b.viewport(400,1000); await b.navigate(f.base+'/#/projects');
|
||||
await wait('document.querySelector(".chat-area h1")?.textContent==="Projects" && !document.querySelector("#processing-halt").disabled');
|
||||
captures.push(await capture('projects-list',400,'#/projects'));
|
||||
});
|
||||
|
||||
test('display module on the real fixture server: save, failure and destination', {timeout:90000}, async t => {
|
||||
const { f,b,wait } = await w15Fixture(t);
|
||||
await b.viewport(390,1000); await b.navigate(f.base+'/#/settings/display');
|
||||
await wait('!!document.querySelector("#display-form")');
|
||||
await b.evaluate('document.querySelector("[name=density][value=compact]").click()');
|
||||
await b.evaluate('location.hash="#/tasks"');
|
||||
await wait('document.querySelector(".display-dialog").open');
|
||||
await b.evaluate('document.querySelector("[data-choice=save]").click()');
|
||||
await wait('location.hash==="#/tasks"');
|
||||
assert.match(await b.evaluate('document.querySelector("#display-announcement").textContent'), /Sidebar display saved/);
|
||||
assert.equal(await b.evaluate('JSON.parse(localStorage.getItem("mosaic-sidebar-display-v1")).density'), 'compact');
|
||||
await b.navigate(f.base+'/#/settings/display');
|
||||
await wait('!!document.querySelector("#display-form")');
|
||||
assert.equal(await b.evaluate('document.querySelector("[name=density]:checked").value'), 'compact');
|
||||
await b.evaluate('document.querySelector("[name=order][value=contact-first]").click();Storage.prototype.setItem=function(){throw new Error("blocked")};location.hash="#/inbox"');
|
||||
await wait('document.querySelector(".display-dialog").open');
|
||||
await b.evaluate('document.querySelector("[data-choice=save]").click()');
|
||||
assert.match(await b.evaluate('document.querySelector("#display-leave-status").textContent'), /local browser storage/);
|
||||
assert.equal(await b.evaluate('location.hash'), '#/settings/display');
|
||||
await b.evaluate('document.querySelector("[data-choice=discard]").click()');
|
||||
await wait('location.hash==="#/inbox"');
|
||||
assert.deepEqual(await b.evaluate('window.__writes'), []);
|
||||
});
|
||||
|
||||
test('canonical routes survive reload and history without writes', { timeout: 90000 }, async t => {
|
||||
const { f,b,wait } = await w15Fixture(t);
|
||||
const paths = [
|
||||
['#/projects', '.chat-projects'],
|
||||
[`#/projects/${ids.project}`, '.chat-area h1'],
|
||||
[`#/projects/threads/${ids.thread}/sessions/${ids.session}`, `.chat-conversation[data-session="${ids.session}"]`],
|
||||
[`#/projects/rooms/${ids.project}`, '.chat-room'],
|
||||
];
|
||||
for (const [route,selector] of paths) {
|
||||
await b.navigate(f.base + '/' + route);
|
||||
await wait(`!!document.querySelector(${JSON.stringify(selector)})`);
|
||||
await b.evaluate('window.__beforeReload=true');
|
||||
await b.call('Page.reload');
|
||||
await wait(`!window.__beforeReload && document.readyState==='complete' && !!document.querySelector(${JSON.stringify(selector)})`);
|
||||
assert.equal(await b.evaluate('location.hash'), route);
|
||||
try { await wait('document.activeElement.tagName !== "BODY"'); } catch (e) { throw new Error(route + ': ' + e.message); }
|
||||
await b.evaluate('location.hash="#/tasks"');
|
||||
await wait('location.hash==="#/tasks"');
|
||||
await b.evaluate('history.back()');
|
||||
await wait(`location.hash===${JSON.stringify(route)} && !!document.querySelector(${JSON.stringify(selector)})`);
|
||||
await b.evaluate('history.forward()');
|
||||
await wait('location.hash==="#/tasks"');
|
||||
}
|
||||
assert.deepEqual(await b.evaluate('window.__writes'), []);
|
||||
});
|
||||
|
||||
test('drawer preserves conversation draft, displays known context and contains focus', { timeout: 90000 }, async t => {
|
||||
const { f,b,wait,capture } = await w15Fixture(t);
|
||||
for (const width of [1440,390]) {
|
||||
await b.viewport(width,1000);
|
||||
const route = `#/projects/threads/${ids.thread}/sessions/${ids.session}`;
|
||||
await b.navigate(f.base + '/' + route);
|
||||
await wait('!!document.querySelector("#chat-body") && !!document.querySelector("#project-context .context-conversation")');
|
||||
await b.evaluate('document.querySelector("#chat-body").value="Unsent fixture draft";document.querySelector("#chat-body").dispatchEvent(new Event("input",{bubbles:true}))');
|
||||
assert.match(await b.evaluate('document.querySelector("#project-context").textContent'), /Active project.*Project conversation/s);
|
||||
if (width===390) {
|
||||
await b.evaluate('document.querySelector("#nav-open").click()');
|
||||
const ax = await b.call('Accessibility.getFullAXTree');
|
||||
assert.ok(ax.nodes.some(n => n.role?.value==='navigation' && n.name?.value==='Console'));
|
||||
assert.ok(ax.nodes.some(n => n.name?.value==='Current project and conversation'));
|
||||
await b.key('Tab','Tab',8);
|
||||
assert.equal(await b.evaluate('document.activeElement.closest("#console-nav") !== null'), true);
|
||||
await b.key('Escape');
|
||||
assert.equal(await b.evaluate('document.activeElement.id'), 'nav-open');
|
||||
}
|
||||
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), 'Unsent fixture draft');
|
||||
captures.push(await capture('navigation-closed-with-draft',width,route));
|
||||
await b.evaluate('document.querySelector("#refresh").focus();document.querySelector("#refresh").click()');
|
||||
await wait('document.querySelector("#chat-body")?.value==="Unsent fixture draft"');
|
||||
assert.notEqual(await b.evaluate('document.activeElement.tagName'), 'BODY');
|
||||
}
|
||||
assert.deepEqual(await b.evaluate('window.__writes'), []);
|
||||
assert.deepEqual(await b.evaluate('window.__unexpected'), []);
|
||||
});
|
||||
|
||||
test('display screenshots and zoom, dark mode, hostile labels', { timeout: 90000 }, async t => {
|
||||
const hostile = '<img src=x onerror=alert(1)> ' + 'Long project '.repeat(35) + '\u202eevil';
|
||||
const { f,b,wait,capture } = await w15Fixture(t, {scenario:{'/api/chat/projects':{rows:[{id:ids.project,name:hostile,repo:ids.repository}]}}});
|
||||
for (const width of [1440,390]) {
|
||||
await b.viewport(width,1000); await b.navigate(f.base+'/#/settings/display');
|
||||
await wait('!!document.querySelector("#display-form")');
|
||||
assert.ok(await b.evaluate('document.querySelector("#display-form button[type=submit]").getBoundingClientRect().height >= 44'));
|
||||
await b.evaluate('document.documentElement.style.zoom="";document.documentElement.removeAttribute("data-mode")');
|
||||
await b.evaluate('document.querySelector("[name=density][value=compact]").click();document.querySelector("#display-form").requestSubmit()');
|
||||
await wait('document.querySelector("#display-status").textContent.includes("saved")');
|
||||
captures.push(await capture('display-saved',width,'#/settings/display'));
|
||||
await b.evaluate('document.querySelector("[name=order][value=contact-first]").click();location.hash="#/projects"');
|
||||
await wait('document.querySelector(".display-dialog").open');
|
||||
assert.equal(await b.evaluate('document.querySelector("#display-leave-status").textContent'), '');
|
||||
captures.push(await capture('display-unsaved',width,'#/settings/display'));
|
||||
await b.evaluate('window.__setItem=Storage.prototype.setItem;Storage.prototype.setItem=function(){throw new Error("blocked")};document.querySelector("[data-choice=save]").click()');
|
||||
await wait('document.querySelector("#display-leave-status").textContent.includes("local browser storage")');
|
||||
captures.push(await capture('display-save-failed',width,'#/settings/display'));
|
||||
await b.evaluate('Storage.prototype.setItem=window.__setItem;document.querySelector("[data-choice=discard]").click()');
|
||||
await wait('location.hash==="#/projects" && !!document.querySelector(".chat-projects")');
|
||||
assert.equal(await b.evaluate('!!document.querySelector(".chat-projects img")'),false);
|
||||
await b.evaluate('document.documentElement.dataset.mode="dark"; document.documentElement.style.zoom="2"');
|
||||
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'),true);
|
||||
assert.equal(await b.evaluate('getComputedStyle(document.documentElement).colorScheme'), 'dark');
|
||||
}
|
||||
});
|
||||
|
||||
test('optional project tree mount, storage refusal and sign-out clear transient choices', {timeout:90000}, async t => {
|
||||
const { f,b,wait } = await w15Fixture(t);
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument', {source:`window.MosaicProjectTree={mount({container,path}){container.textContent='Stub tree: '+path}};`});
|
||||
await b.navigate(f.base+'/#/settings/display');
|
||||
await wait('!!document.querySelector("#display-form")');
|
||||
assert.match(await b.evaluate('document.querySelector("#project-tree").textContent'), /Stub tree: \/settings\/display/);
|
||||
await b.evaluate('document.querySelector("[name=order][value=contact-first]").click(); document.dispatchEvent(new Event("mosaic-signed-out")); location.hash="#/projects"');
|
||||
await wait('location.hash==="#/projects"');
|
||||
assert.equal(await b.evaluate('document.querySelector(".display-dialog").open'), false);
|
||||
assert.equal(await b.evaluate('localStorage.getItem("mosaic-sidebar-display-v1")'), null);
|
||||
assert.deepEqual(await b.evaluate('window.__writes'), []);
|
||||
});
|
||||
|
||||
test('owner display asset is not exposed through the limited user route', async () => {
|
||||
const {userRoute} = await import('../src/serve.mjs');
|
||||
assert.equal(userRoute('/display.js','GET'), false);
|
||||
assert.equal(userRoute('/display.js','HEAD'), false);
|
||||
});
|
||||
|
||||
test('shared fixture refuses unknown APIs and permits only explicit scenario writes', {timeout:90000}, async t => {
|
||||
const { f,b,wait } = await w15Fixture(t, {scenario:{'POST /api/fixture/allowed':{status:202,body:{accepted:true}}}});
|
||||
await b.navigate(f.base+'/#/projects'); await wait('!!document.querySelector(".chat-projects")');
|
||||
const result = await b.evaluate(`(async()=>{const a=await fetch('/api/unknown'),b=await fetch('/api/fixture/unknown',{method:'POST'}),c=await fetch('/api/fixture/allowed',{method:'POST'}),d=await fetch('https://example.invalid/');return [a.status,b.status,c.status,d.status]})()`);
|
||||
assert.deepEqual(result,[403,403,202,403]);
|
||||
assert.deepEqual(await b.evaluate('window.__writes.map(x=>x.path)'), ['/api/fixture/unknown','/api/fixture/allowed']);
|
||||
assert.ok((await b.evaluate('window.__unexpected')).includes('POST /api/fixture/unknown'));
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { requestHTTP, REQUEST_ROUTE } from '../src/request-http.mjs';
|
||||
|
||||
const session = { name: 'owner', provider: 'test', iss: 'test', sub: 'owner' };
|
||||
const reply = { available: true, entries: [{ name: 'src/app.js' }], truncated: false, contentAvailable: false, observedAt: '2026-11-05T00:00:00.000Z' };
|
||||
async function attempt({ path = '/api/requests/r1/workspace', url = path, method = 'GET', read = async () => reply, live = () => session, headersDistinct = {} } = {}) {
|
||||
const req = { url, method, headersDistinct }, res = { writeHead(status, headers) { this.status = status; this.headers = headers; }, end(data) { this.body = data; } };
|
||||
await requestHTTP(req, res, { path, session, live, requestRead: read, origin: 'https://console.test', log() {} }); return res;
|
||||
}
|
||||
test('owner workspace GET sends only the request identifier and validates result', async () => {
|
||||
let call;
|
||||
const res = await attempt({ read: async (...args) => { call = args; return reply; } });
|
||||
assert.equal(res.status, 200); assert.deepEqual(call[0], 'request.workspace.console'); assert.deepEqual(call[1], { request: 'r1' });
|
||||
assert.equal(res.headers['cache-control'], 'no-store');
|
||||
assert.equal((await attempt({ read: async () => ({ ...reply, entries: [{ name: '../secret' }] }) })).status, 503);
|
||||
assert.equal((await attempt({ read: async () => ({ ...reply, entries: Array(101).fill({ name: 'x' }) }) })).status, 503);
|
||||
assert.equal((await attempt({ read: async () => ({ ...reply, path: '/etc' }) })).status, 503);
|
||||
});
|
||||
test('workspace route refuses traversal, other modes, query, foreign origin and stale session', async () => {
|
||||
assert.equal((await attempt({ path: '/api/requests/%2e%2e/workspace' })).status, 404);
|
||||
assert.equal((await attempt({ url: '/api/requests/r1/workspace?path=..' })).status, 400);
|
||||
assert.equal((await attempt({ method: 'POST' })).status, 405);
|
||||
assert.equal((await attempt({ headersDistinct: { origin: ['https://evil.test'] } })).status, 403);
|
||||
let reads = 0;
|
||||
assert.equal((await attempt({ read: async () => { reads++; return reply; }, live: () => reads ? null : session })).status, 401);
|
||||
assert.doesNotMatch('/api/requests/r1/workspace/a/text', REQUEST_ROUTE);
|
||||
assert.doesNotMatch('/api/requests/r1/workspace/a/download', REQUEST_ROUTE);
|
||||
});
|
||||
Reference in new issue
Block a user