diff --git a/agents/mos/work/quick-setup/BUILD.md b/agents/mos/work/quick-setup/BUILD.md
new file mode 100644
index 000000000..4f5dc0dae
--- /dev/null
+++ b/agents/mos/work/quick-setup/BUILD.md
@@ -0,0 +1,13 @@
+# Quick Setup implementation
+
+Baseline: `bc728e592`. The brief is `/mnt/storage/scratch/mosaic-lead/uidev/org-release/QUICK-SETUP-BRIEF.md`.
+
+The optional `orgPresets()` export uses the existing harness and model-provider kinds. This avoids introducing a kind and keeps each preset next to the module it selects, at the cost of discovering both kinds for setup. Duplicate names and malformed descriptions refuse rather than choosing one. The legacy `--fixture local` alias is retained. Claude availability uses the Console process environment and returns a generic refusal without paths or values.
+
+Quick Setup explicitly asks for local-only loss-risk acceptance, because organization checkout setup already requires it even when the project's remote is supplied. The flow does not automatically retry a refused or uncertain step; an owner can open the organization and inspect its receipts. Screenshots use the actual page and organization service with a deliberately refusing bus double. A separate live browser test used a bootstrapped scratch home, the real loopback fixture, the Console, bus, worker and browser: Quick Setup completed all four steps and the conversation received PINEAPPLE. The fixture endpoint ran under a long-lived parent. A short organization ID was needed to fit the Unix socket path limit.
+
+Follow-ups outside this brief: purpose-based role questions, proxy initialization, organization retirement, and theming.
+
+The opt-in live test `packages/webui/tests/org-quick-live.test.mjs` reads the Console address from the scratch home's `orgdev-qconsole.log`; it skips in the ordinary package suite when that log is absent. The live test was run with the brief's clean Node 26 environment and `HOME=/mnt/storage/scratch/tmp/oLGvYu`.
+
+Verification, Node 26 clean environment: business 346 pass/0 fail, plugins 155/0, WebUI 1012 pass/8 fail/1 skip. All eight WebUI failures are `pinned-copy.test.mjs` `ERR_FS_EISDIR` from the brief-required `packages/webui/node_modules` symlink; the skipped case is the separately passing live browser test. Live test: 1 pass/0 fail. The browser test also checks that an organization can be opened after setup, including its repository and the fixture conversation. Screenshots remain untracked.
diff --git a/packages/business/src/org-service.mjs b/packages/business/src/org-service.mjs
index c25c4c48b..848fb9b00 100644
--- a/packages/business/src/org-service.mjs
+++ b/packages/business/src/org-service.mjs
@@ -7,7 +7,7 @@ import { fileURLToPath } from 'node:url';
import { validateBusinessDocument, httpsUrl } from './business.mjs';
import { ID_PATTERN } from './vocabulary.mjs';
import { OrgError, orgRoot, readRegistry, reserveOrg, resolveOrg, withRegistry } from './org-registry.mjs';
-import { FIXTURE_TEMPLATE } from '../../plugins/model-provider/fixture/index.mjs';
+import { loadInstalled } from '../../plugins/src/loader.mjs';
import { BINDING_NAME } from '../../discord/src/binding.mjs';
const rolesDir = resolve(dirname(fileURLToPath(import.meta.url)), '../../../roles');
const owner = Object.freeze({ provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner' });
@@ -49,15 +49,58 @@ function normalizeDoc(ctx, id, input, existing) {
if (existing) for (const [p, entry] of Object.entries(existing.projects)) if (entry.git && !doc.projects?.[p]) fail('org-invalid', 'initialized project cannot be removed', `$.projects.${p}`);
return doc;
}
-export async function templateRender(id, { fixture } = {}) {
- if (fixture !== undefined && fixture !== 'local') fail('org-invalid', 'unknown fixture'); checkId(id);
+// Presets are supplied by installed modules; reject malformed or duplicate names
+// rather than silently choosing a different module's configuration.
+async function discoverPresets() {
+ const presets = new Map();
+ for (const kind of ['harness', 'model-provider']) {
+ const { modules, refused } = await loadInstalled(kind);
+ if (refused.length) fail('org-invalid', 'installed plugin could not be loaded');
+ for (const { module } of modules) {
+ if (module.orgPresets === undefined) continue;
+ if (typeof module.orgPresets !== 'function') fail('org-invalid', 'invalid preset provider');
+ const list = await module.orgPresets();
+ if (!Array.isArray(list)) fail('org-invalid', 'invalid preset provider');
+ for (const p of list) {
+ if (!p || typeof p.name !== 'string' || !/^[a-z][a-z0-9-]*$/.test(p.name) || presets.has(p.name) ||
+ typeof p.label !== 'string' || !p.label || typeof p.description !== 'string' || !p.description ||
+ !Array.isArray(p.thinking?.levels) || !p.thinking.levels.length ||
+ p.thinking.levels.some(v => typeof v !== 'string' || !/^[a-z][a-z0-9-]*$/.test(v)) ||
+ new Set(p.thinking.levels).size !== p.thinking.levels.length || !p.thinking.levels.includes(p.thinking.default) ||
+ typeof p.available !== 'function' || typeof p.overlay !== 'function') fail('org-invalid', 'invalid or duplicate preset');
+ presets.set(p.name, p);
+ }
+ }
+ }
+ return presets;
+}
+export async function orgPresets({ env = process.env } = {}) {
+ const presets = await discoverPresets();
+ return Promise.all([...presets.values()].map(async p => {
+ const availability = await p.available({ env });
+ if (!availability || typeof availability.ok !== 'boolean' || (!availability.ok && (typeof availability.reason !== 'string' || !availability.reason))) fail('org-invalid', 'invalid preset availability');
+ return { name: p.name, label: p.label, description: p.description, thinking: structuredClone(p.thinking), available: availability.ok ? { ok: true } : { ok: false, reason: availability.reason } };
+ }));
+}
+export async function templateRender(id, { fixture, preset, thinking } = {}) {
+ if (fixture !== undefined && fixture !== 'local') fail('org-invalid', 'unknown fixture');
+ if (fixture !== undefined && preset !== undefined) fail('org-invalid', 'choose fixture or preset');
+ if (fixture === 'local') preset = 'fixture';
+ checkId(id);
const doc = JSON.parse(await fs.readFile(new URL('./org-template.json', import.meta.url), 'utf8'));
doc.id = id; doc.projects.work.root = `/__mosaic_org__/${id}/projects/work`;
- if (fixture === 'local') {
- const fixtureDoc = structuredClone(FIXTURE_TEMPLATE);
- doc.plugins = fixtureDoc.plugins; doc.launch = fixtureDoc.launch;
- doc.roles.assistant.vars = fixtureDoc.assistantVars; doc.vars = fixtureDoc.vars;
- }
+ if (preset !== undefined) {
+ if (typeof preset !== 'string') fail('org-invalid', 'unknown preset');
+ const p = (await discoverPresets()).get(preset);
+ if (!p) fail('org-invalid', 'unknown preset');
+ const level = thinking === undefined ? p.thinking.default : thinking;
+ if (!p.thinking.levels.includes(level)) fail('org-invalid', 'unknown thinking level');
+ const overlay = await p.overlay({ thinking: level });
+ if (!overlay || typeof overlay !== 'object' || !overlay.plugins || !overlay.launch || !overlay.assistantVars) fail('org-invalid', 'invalid preset overlay');
+ doc.plugins = structuredClone(overlay.plugins); doc.launch = structuredClone(overlay.launch);
+ doc.roles.assistant.vars = structuredClone(overlay.assistantVars);
+ if (overlay.vars !== undefined) doc.vars = structuredClone(overlay.vars);
+ } else if (thinking !== undefined) fail('org-invalid', 'thinking requires a preset');
return doc;
}
export async function validateOrg(doc) {
diff --git a/packages/business/tests/org-presets.test.mjs b/packages/business/tests/org-presets.test.mjs
new file mode 100644
index 000000000..7b24bbbd6
--- /dev/null
+++ b/packages/business/tests/org-presets.test.mjs
@@ -0,0 +1,32 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import * as service from '../src/org-service.mjs';
+const { templateRender, validateOrg } = service;
+
+test('installed modules supply presets and report availability without credentials', async () => {
+ const presets = await service.orgPresets({ env: { PATH: '/usr/bin:/bin' } });
+ const fixture = presets.find(p => p.name === 'fixture');
+ const claude = presets.find(p => p.name === 'claude-code');
+ assert.equal(fixture.available.ok, true);
+ assert.equal(claude.available.ok, false);
+ assert.ok(claude.available.reason);
+ assert.deepEqual(claude.thinking.levels, ['low', 'medium', 'high', 'xhigh', 'max']);
+});
+
+test('preset overlays produce valid documents and the legacy alias remains identical', async () => {
+ const doc = await templateRender('alpha', { preset: 'fixture' });
+ assert.deepEqual(doc, await templateRender('alpha', { fixture: 'local' }));
+ assert.deepEqual(await validateOrg(doc), { ok: true, errors: [] });
+ assert.equal(doc.vars['bus.tester'], true);
+ const claude = await templateRender('alpha', { preset: 'claude-code', thinking: 'medium' });
+ assert.deepEqual(claude.roles.assistant.vars, { harness: 'claude-code', model: 'opus', thinking: 'medium' });
+ assert.equal(claude.launch.models.opus.model, 'claude-opus-5-5');
+ assert.equal(claude.vars, undefined);
+ assert.deepEqual(await validateOrg(claude), { ok: true, errors: [] });
+});
+
+test('unknown presets and thinking levels refuse', async () => {
+ await assert.rejects(templateRender('alpha', { preset: 'no-such-preset' }), /unknown preset/);
+ await assert.rejects(templateRender('alpha', { preset: 'claude-code', thinking: 'off' }), /thinking/);
+ await assert.rejects(templateRender('alpha', { thinking: 'low' }), /thinking/);
+});
diff --git a/packages/plugins/README.md b/packages/plugins/README.md
index 3185bd95a..c2cf6c031 100644
--- a/packages/plugins/README.md
+++ b/packages/plugins/README.md
@@ -48,6 +48,20 @@ hold booleans. The harness receives it as data. Modules return named refusals fo
they never return credentials, commands or endpoints. The host rejects an
unknown or ambiguous legacy mapping and validates transport data before use.
+### Organization presets
+
+A `harness` or `model-provider` module may export `orgPresets()`, returning an
+array of preset descriptions. Each has a globally unique stable `name`, a
+`label`, a one-line `description`, `thinking: { levels, default }`, an
+`available({ env })` function returning `{ ok: true }` or `{ ok: false,
+reason }`, and `overlay({ thinking })` returning `{ plugins, launch,
+assistantVars, vars? }`. The loader discovers installed modules; the
+organization service rejects duplicate names, invalid metadata, unknown
+presets and unsupported thinking levels. Availability reasons are displayed
+in the Console and must never contain credential values or private paths.
+The fixture provider supplies the legacy `--fixture local` alias; the
+Claude Code harness checks its preflight using the Console's environment.
+
A `search-source` answers the broker's `web.search` (REQ-HARN-2, REQ-PLUG-2, decision 0b81b0c5): `search(settings, query, deps, { signal })` resolves `{ query, total, results: [{ title, url, snippet }] }` or throws a `WebRefusal`; `signal` is an `AbortSignal` the broker fires at its deadline, and a module must stop its request when it does. The kind switches on one module, and the module's name is the source: `searxng-url` queries the operator's SearXNG at the business var `web.searxng`; `searxng-bundled` queries the SearXNG the Stack installs, at a fixed loopback address (the install is REQ-INST-1's first component); `harness-builtin` is served by the harness itself: a granted claude-code role gets WebSearch and WebFetch, the broker serves no web verbs, and `search` only refuses. The choice and every change to it are the `plugins` setting, logged in `changes.jsonl` by the plug-in setter.
Adding a kind, or changing a kind's exports, interface version or switch,
diff --git a/packages/plugins/harness/claude-code/index.mjs b/packages/plugins/harness/claude-code/index.mjs
index 211f5a0f7..e65f38c57 100644
--- a/packages/plugins/harness/claude-code/index.mjs
+++ b/packages/plugins/harness/claude-code/index.mjs
@@ -69,6 +69,19 @@ export function preflight({ env, proxy = null }) {
return null;
}
+export function orgPresets() {
+ return [{ name: 'claude-code', label: "Claude via this machine's sign-in",
+ description: 'Use Claude Code with the credentials available to this Console.',
+ thinking: { levels: [...LEVELS], default: 'low' },
+ available: ({ env }) => {
+ const problem = preflight({ env });
+ return problem ? { ok: false, reason: problem.code === 'no-session-credential' ? 'No session credential is available to the Console.' : 'Claude Code is not available to the Console.' } : { ok: true };
+ },
+ overlay: ({ thinking }) => ({ plugins: { channel: ['cli', 'webui'], harness: ['claude-code'] },
+ launch: { by: 'chief', instances: ['assistant'], models: { opus: { provider: 'anthropic', model: 'claude-opus-5-5' } } },
+ assistantVars: { harness: 'claude-code', model: 'opus', thinking } }) }];
+}
+
// `claude --version` from the binary sessions run, or null when it doesn't answer.
export function version({ env }) {
const r = spawnSync(env.MOSAIC_CLAUDE_BIN || "claude", ["--version"], { encoding: "utf8", timeout: 10000, env });
diff --git a/packages/plugins/model-provider/fixture/index.mjs b/packages/plugins/model-provider/fixture/index.mjs
index 48f0b45e2..07ea977d9 100644
--- a/packages/plugins/model-provider/fixture/index.mjs
+++ b/packages/plugins/model-provider/fixture/index.mjs
@@ -16,6 +16,12 @@ export const FIXTURE_TEMPLATE = Object.freeze({
assistantVars: { harness: 'pi', model: FIXTURE_KEY_NAME, thinking: 'off' },
vars: { 'bus.tester': true },
});
+// Organization setup is supplied by this module, alongside its model route.
+export function orgPresets() {
+ return [{ name: 'fixture', label: 'Test model', description: 'Canned replies, no cost.',
+ thinking: { levels: ['off'], default: 'off' }, available: () => ({ ok: true }),
+ overlay: ({ thinking }) => ({ ...structuredClone(FIXTURE_TEMPLATE), assistantVars: { ...FIXTURE_TEMPLATE.assistantVars, thinking } }) }];
+}
const HARNESSES = Object.freeze(['pi']);
const TRANSPORT = Object.freeze({ routeId: 'fixture', endpoint: 'root', api: 'anthropic-messages', piProvider: 'mosaic-fixture', compat: null });
diff --git a/packages/webui/src/org-admin.mjs b/packages/webui/src/org-admin.mjs
index 7ce18beb2..f03cd34a1 100644
--- a/packages/webui/src/org-admin.mjs
+++ b/packages/webui/src/org-admin.mjs
@@ -291,7 +291,7 @@ export function createOrgAdmin({ service, registry = null, bus = null, context }
// the page's own origin for the write fence.
export async function orgAdminHTTP(req, res, { path, search = '', admin, session = null, owner = false, send = false, origin = null, log = () => {} }) {
const org = path.match(ROUTE);
- const top = ['/api/org-template', '/api/org-validate', '/api/org-admin'].includes(path) || (path === '/api/orgs' && req.method === 'POST');
+ const top = ['/api/org-template', '/api/org-presets', '/api/org-validate', '/api/org-admin'].includes(path) || (path === '/api/orgs' && req.method === 'POST');
if (!org && !top) return false;
if (!owner || !session) { json(res, 403, { error: 'owner-only', message: 'organization setup is the owner\'s' }); return true; }
if (!admin) { json(res, 503, { error: 'not-configured' }); return true; }
@@ -380,12 +380,20 @@ const gitOptions = (body, res, step) => {
};
async function route(req, res, { path, search, org, admin, principal, write, log }) {
+ if (path === '/api/org-presets' && !write) {
+ if (new URLSearchParams(search).size) return json(res, 422, { error: 'invalid', message: 'unexpected query parameter' });
+ const service = await need(admin, 'service', res, 'presets'); if (!service) return;
+ try { return json(res, 200, { presets: await service.orgPresets() }); }
+ catch (e) { return refusal(res, e, { step: 'presets' }); }
+ }
if (path === '/api/org-template' && !write) {
- const id = new URLSearchParams(search).get('id') || 'new-org';
+ const params = new URLSearchParams(search);
+ for (const [key] of params) if (!['id', 'preset', 'thinking'].includes(key) || params.getAll(key).length !== 1) return json(res, 422, { error: 'invalid', message: 'invalid template query' });
+ const id = params.has('id') ? params.get('id') : 'new-org';
if (!ID.test(id)) return json(res, 422, { error: 'invalid', errors: [{ path: '$.id', field: 'id', message: 'invalid organization id' }] });
const service = await need(admin, 'service', res, 'template'); if (!service) return;
let doc;
- try { doc = await service.templateRender(id, { fixture: 'local' }); } catch (e) { return refusal(res, e, { step: 'template' }); }
+ try { doc = await service.templateRender(id, params.has('preset') || params.has('thinking') ? { ...(params.has('preset') ? { preset: params.get('preset') } : {}), ...(params.has('thinking') ? { thinking: params.get('thinking') } : {}) } : { fixture: 'local' }); } catch (e) { return refusal(res, e, { step: 'template' }); }
const shown = forBrowser(doc);
return json(res, 200, { id, doc: shown.doc, held: shown.held, verb: verbFor('create', id) });
}
diff --git a/packages/webui/src/public/display.js b/packages/webui/src/public/display.js
index f80bbd02a..760562943 100644
--- a/packages/webui/src/public/display.js
+++ b/packages/webui/src/public/display.js
@@ -22,7 +22,7 @@ window.MosaicLocalTime = value => Number.isFinite(Date.parse(value)) ? new Date(
}
const route = () => location.hash.replace(/^#\/orgs\/[^/]+/, '') || '/';
window.fetch = (input, init) => {
- if (typeof input === 'string' && input.startsWith('/api/') && !/^\/api\/(?:orgs(?:\/|$)|themes(?:\?|$)|org-template(?:\?|$)|org-admin(?:\?|$)|org-validate(?:\?|$))/.test(input))
+ if (typeof input === 'string' && input.startsWith('/api/') && !/^\/api\/(?:orgs(?:\/|$)|themes(?:\?|$)|org-template(?:\?|$)|org-presets(?:\?|$)|org-admin(?:\?|$)|org-validate(?:\?|$))/.test(input))
input = `/api/orgs/${id}/${input.slice('/api/'.length)}`;
return nativeFetch(input, init);
};
diff --git a/packages/webui/src/public/org-create.css b/packages/webui/src/public/org-create.css
index 7b2cb3dcf..96c849930 100644
--- a/packages/webui/src/public/org-create.css
+++ b/packages/webui/src/public/org-create.css
@@ -74,3 +74,5 @@
.org-step .settings-actions{margin-top:8px}
@media(max-width:959px){.org-columns{grid-template-columns:minmax(0,1fr)}.org-json{position:static}.settings-content .org-json textarea{min-height:280px}}
@media(max-width:759px){.org-start-row{grid-template-columns:minmax(0,1fr);gap:16px}.org-row{grid-template-columns:minmax(0,1fr) auto}.org-row .org-field:nth-child(2){grid-column:1/2}.org-row .org-remove{grid-column:2;grid-row:1}.settings-content .org-admin .settings-actions button{flex:1}}
+
+.org-quick-risk{display:flex;align-items:flex-start;gap:8px;margin:12px 0;line-height:1.5}.org-quick-risk input{margin-top:4px}.settings-content .org-path-actions button{display:inline-flex;align-items:center;justify-content:center}
diff --git a/packages/webui/src/public/org-create.js b/packages/webui/src/public/org-create.js
index ffd202e98..00bd6a687 100644
--- a/packages/webui/src/public/org-create.js
+++ b/packages/webui/src/public/org-create.js
@@ -25,6 +25,21 @@
+
+ Choose how to set up
+
+
+
+
+
@@ -51,7 +66,7 @@
function mount() {
const root = $('org-admin'); if (!root) return () => {};
- let alive = true, canWrite = false, editorDirty = false;
+ let alive = true, canWrite = false, editorDirty = false, presets = [];
const setupDirty = new Set();
window.MosaicOrgCreate.hasDraft = () => editorDirty || setupDirty.size > 0 || !!$('org-new-id')?.value.trim();
// mode: 'create' | 'edit'; doc: the one canonical (browser) document.
@@ -206,19 +221,87 @@
const input = $('org-new-id'), id = input.value.trim();
if (!ID.test(id)) { input.setAttribute('aria-invalid', 'true'); $('org-new-id-help').textContent = 'Organization ID: use 1-64 lowercase letters, digits, dot, dash or underscore, starting with a letter or digit.'; $('org-new-id-help').classList.add('org-field-err'); input.focus(); status('Organization ID is not valid.'); return; }
input.removeAttribute('aria-invalid'); $('org-new-id-help').classList.remove('org-field-err'); $('org-new-id-help').textContent = 'Lowercase letters, digits, dot, dash or underscore.';
- const r = await api('GET', `/api/org-template?id=${encodeURIComponent(id)}`);
+ const r = await api('GET', '/api/org-presets');
if (!alive) return;
- if (r.status !== 200) { banner(r.body.message || `The template could not be read (${r.body.error || r.status}).`); return; }
- banner(''); $('org-receipt').hidden = true; $('org-setup').hidden = true; setQuery(null);
- openEditor('create', id, r.body.doc, { held: r.body.held || [], verb: r.body.verb });
- status(`Template loaded for ${id}. The form and the JSON editor show the same document.`);
+ if (r.status !== 200) { banner(r.body.message || `Presets could not be read (${r.body.error || r.status}).`); return; }
+ presets = r.body.presets;
+ st.id = id; st.mode = 'create'; editorDirty = true;
+ $('org-preset').innerHTML = presets.map(p => ``).join('');
+ $('org-preset').value = presets.find(p => p.available.ok)?.name || '';
+ updatePreset();
+ $('org-paths').hidden = false; $('org-quick').hidden = true; $('org-editor').hidden = true; $('org-receipt').hidden = true; $('org-setup').hidden = true;
+ $('org-quick-steps').innerHTML = ''; $('org-quick-open-org').hidden = true; banner(''); setQuery(null);
+ $('org-preset').focus();
+ }
+ function updatePreset() {
+ const p = presets.find(v => v.name === $('org-preset').value);
+ $('org-preset-help').textContent = p?.description || 'No available presets.';
+ $('org-thinking').innerHTML = (p?.thinking.levels || []).map(v => ``).join('');
+ $('org-quick-open').disabled = !p || !canWrite; $('org-advanced-open').disabled = !p || !canWrite;
+ }
+ async function selectedTemplate() {
+ const q = new URLSearchParams({ id: st.id, preset: $('org-preset').value, thinking: $('org-thinking').value });
+ return api('GET', `/api/org-template?${q}`);
+ }
+ async function openAdvanced() {
+ const r = await selectedTemplate(); if (!alive) return;
+ if (r.status !== 200) { banner(r.body.message || `Template refused (${r.body.error || r.status}).`); return; }
+ $('org-quick').hidden = true; $('org-paths').hidden = true;
+ openEditor('create', st.id, r.body.doc, { held: r.body.held || [], verb: r.body.verb });
root.querySelector('[data-path="$.human"]')?.focus();
}
+ function openQuick() {
+ $('org-quick').hidden = false; $('org-editor').hidden = true;
+ $('org-project').focus();
+ }
+ async function quickRun() {
+ const project = $('org-project').value.trim(), remote = $('org-project-remote').value.trim();
+ if (!ID.test(project)) { $('org-project').setAttribute('aria-invalid', 'true'); banner('Enter a valid project name.'); $('org-project').focus(); return; }
+ $('org-project').removeAttribute('aria-invalid');
+ if (remote && !/^https:\/\/[^\s/]+\/\S+$/.test(remote)) { banner('Enter an https repository URL or leave it blank.'); $('org-project-remote').focus(); return; }
+ if (!$('org-risk-accept').checked) { banner('Accept the risk of local-only repositories to continue.'); $('org-risk-accept').focus(); return; }
+ const id = st.id, enc = encodeURIComponent, local = { mode: 'local-only', acceptLocalLossRisk: true };
+ const steps = [
+ { title: 'Create organization', verb: `mosaic org create ${id} --from ` },
+ { title: 'Initialize organization checkout', verb: `mosaic org queue init ${id} --local-only --accept-local-loss-risk` },
+ { title: `Initialize project repository: ${project}`, verb: `mosaic org project repo init ${id} ${project} ${remote ? `--remote ${remote}` : '--local-only --accept-local-loss-risk'}` },
+ { title: 'Start the bus', verb: `mosaic bus start ${id} --dev-local-owner --detach` },
+ ];
+ const list = $('org-quick-steps');
+ list.innerHTML = steps.map((s, i) => `${txt(s.title)}
Not startedSame as ${txt(s.verb)}
`).join('');
+ const run = $('org-quick-run'); run.disabled = true; $('org-quick-open').disabled = true; $('org-advanced-open').disabled = true; $('org-preset').disabled = true; $('org-thinking').disabled = true; banner('');
+ let current = 0;
+ try {
+ const template = await selectedTemplate();
+ if (template.status !== 200) throw Error(template.body.message || 'Could not load the template.');
+ const doc = template.body.doc; doc.projects = { [project]: {} };
+ const requests = [
+ ['/api/orgs', { id, doc }],
+ [`/api/orgs/${enc(id)}/setup/queue`, local],
+ [`/api/orgs/${enc(id)}/setup/project/${enc(project)}`, remote ? { mode: 'remote', remote } : local],
+ [`/api/orgs/${enc(id)}/setup/bus`, {}],
+ ];
+ for (current = 0; current < requests.length; current++) {
+ const card = list.children[current]; card.querySelector('.org-pill').textContent = 'In progress';
+ const result = await api('POST', ...requests[current]);
+ if (result.status !== 200 && result.status !== 201) throw Error(result.body.message || result.body.errors?.map(e => e.message).join(' ') || `Refused (${result.body.error || result.status}).`);
+ card.querySelector('.org-pill').textContent = 'Done'; card.classList.add('org-step-done');
+ if (result.body.verb) card.querySelector('code').textContent = result.body.verb;
+ }
+ editorDirty = false; $('org-new-id').value = ''; $('org-quick-open-org').hidden = false; status('Organization setup complete.');
+ await refreshList();
+ } catch (e) {
+ const card = list.children[Math.min(current, steps.length - 1)];
+ card.querySelector('.org-pill').textContent = 'Stopped'; card.querySelector('.org-field-err').textContent = `${e.message} Open the organization to inspect and continue; do not repeat a step whose outcome is unknown.`;
+ card.classList.add('org-step-warn'); card.focus(); status('Setup stopped.');
+ await refreshList();
+ }
+ }
async function openExisting(id, { toSetup = false } = {}) {
const r = await api('GET', `/api/orgs/${encodeURIComponent(id)}/admin`);
if (!alive) return;
if (r.status !== 200) { banner(r.status === 404 ? `Organization ${id} is not available.` : r.body.message || `Organization ${id} could not be read (${r.body.error || r.status}).`); return; }
- banner('');
+ banner(''); $('org-paths').hidden = true; $('org-quick').hidden = true;
openEditor('edit', id, r.body.doc, { revision: r.body.revision, held: r.body.held || [], verb: r.body.verb });
setQuery(id, toSetup ? 'setup' : null);
await loadSetup(id);
@@ -393,6 +476,11 @@
// ---- wiring -----------------------------------------------------------
const handlers = [
[$('org-new-form'), 'submit', startNew],
+ [$('org-preset'), 'change', updatePreset],
+ [$('org-quick-open'), 'click', openQuick],
+ [$('org-advanced-open'), 'click', openAdvanced],
+ [$('org-quick-run'), 'click', quickRun],
+ [$('org-quick-open-org'), 'click', () => { history.replaceState(null, '', `#/orgs/${encodeURIComponent(st.id)}/projects`); location.reload(); }],
[$('org-open-form'), 'submit', e => { e.preventDefault(); const id = $('org-open-id').value; if (id) openExisting(id); }],
[$('org-form'), 'submit', save],
[$('org-form'), 'input', onFormInput],
diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs
index c6aea408d..88ce5d6c1 100644
--- a/packages/webui/src/serve.mjs
+++ b/packages/webui/src/serve.mjs
@@ -713,7 +713,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
for (const registerRoutes of routes) registerRoutes(registered);
registered.add('GET', '/api/themes', (req, res) => themesHTTP(req, res, { log }), { publicRead: true });
const adminRoute = (req, res, context) => orgAdminHTTP(req, res, { ...context, admin: orgAdmin ??= defaultOrgAdmin(), log });
- for (const path of ['/api/org-template', '/api/org-validate', '/api/org-admin', /^\/api\/orgs\/[^/]+\/(?:admin|setup)(?:\/(?:queue|bus|project\/[^/]+))?$/]) {
+ for (const path of ['/api/org-template', '/api/org-presets', '/api/org-validate', '/api/org-admin', /^\/api\/orgs\/[^/]+\/(?:admin|setup)(?:\/(?:queue|bus|project\/[^/]+))?$/]) {
for (const method of ['GET', 'POST']) registered.add(method, path, adminRoute);
}
registered.add('POST', '/api/orgs', adminRoute);
diff --git a/packages/webui/tests/org-admin.test.mjs b/packages/webui/tests/org-admin.test.mjs
index 07ec24cce..0e98e866a 100644
--- a/packages/webui/tests/org-admin.test.mjs
+++ b/packages/webui/tests/org-admin.test.mjs
@@ -163,7 +163,7 @@ test('only the owner with --send writes', async t => {
assert.equal(r.status, 403); assert.equal(r.body.error, 'read-only');
// Without sign-in there is no owner: a loopback Console with no sso refuses every route.
const anon = await console_(t, { sso: null, send: null });
- for (const [method, path] of [['GET', '/api/org-template?id=acme'], ['GET', '/api/org-admin'], ['GET', '/api/orgs/acme/setup'], ['POST', '/api/orgs']]) {
+ for (const [method, path] of [['GET', '/api/org-template?id=acme'], ['GET', '/api/org-presets'], ['GET', '/api/org-admin'], ['GET', '/api/orgs/acme/setup'], ['POST', '/api/orgs']]) {
const res = await anon.call(method, path, method === 'POST' ? { id: 'acme', doc: browserDoc('acme') } : undefined);
assert.equal(res.status, 403, path); assert.equal(res.body.error, 'owner-only');
}
@@ -419,3 +419,20 @@ test('against the real C2 service: create, set up, resume and refuse an overwrit
assert.equal(saved.status, 200, saved.raw); assert.equal(saved.body.receipt.op, 'org.update');
assert.doesNotMatch(JSON.stringify([tpl.body, s.body, shown.body]), new RegExp(root.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), 'no server path reaches the browser');
});
+
+test('preset routes expose installed choices and refuse invalid queries', async t => {
+ const service = await import('../../business/src/org-service.mjs');
+ const admin = createOrgAdmin({ service, context: async () => ({ configDir: '/cfg', dataRoot: '/data', instance: 'test' }) });
+ const c = await console_(t, { orgAdmin: admin });
+ const presets = await c.call('GET', '/api/org-presets');
+ assert.equal(presets.status, 200);
+ assert.ok(presets.body.presets.some(p => p.name === 'fixture' && p.available.ok));
+ const result = await c.call('GET', '/api/org-template?id=alpha&preset=fixture&thinking=off');
+ assert.equal(result.status, 200);
+ assert.deepEqual(result.body.doc.projects, { work: {} });
+ assert.equal(result.body.doc.roles.assistant.vars.model, 'fixture-deterministic');
+ for (const query of ['id=alpha&preset=unknown', 'id=alpha&preset=fixture&thinking=high', 'id=alpha&thinking=off', 'id=alpha&id=beta', 'id=alpha&other=x']) {
+ assert.equal((await c.call('GET', `/api/org-template?${query}`)).status, 422, query);
+ }
+ assert.equal((await c.call('GET', '/api/org-presets?extra=x')).status, 422);
+});
diff --git a/packages/webui/tests/org-quick-browser.test.mjs b/packages/webui/tests/org-quick-browser.test.mjs
new file mode 100644
index 000000000..4876880b8
--- /dev/null
+++ b/packages/webui/tests/org-quick-browser.test.mjs
@@ -0,0 +1,54 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { startServer } from '../src/serve.mjs';
+import { createDevOwner } from '../src/dev-owner.mjs';
+import { createOrgAdmin } from '../src/org-admin.mjs';
+import * as service from '../../business/src/org-service.mjs';
+import { browser } from './browser.mjs';
+import { close } from './fixture.mjs';
+
+test('setup paths and preset controls are visible', { timeout: 45000 }, async t => {
+ const admin = createOrgAdmin({ service, registry: { listOrgs: async () => [] }, context: async () => ({ configDir: '/cfg', dataRoot: '/data', instance: 'test' }) });
+ const server = await startServer({ port: 0, sso: createDevOwner(), orgAdmin: admin, send: async () => ({}), board: 'http://127.0.0.1:9', log: () => {} }); t.after(() => close(server));
+ const b = await browser({ machine: false }); t.after(() => b.close());
+ const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`);
+ const shown = selector => b.evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(selector)});return !!e && getComputedStyle(e).display !== 'none' && e.getClientRects().length > 0})()`);
+ await b.navigate(`http://127.0.0.1:${server.address().port}/#/settings/organizations`);
+ await wait('document.querySelector("#org-new-form [data-write]") && !document.querySelector("#org-new-form [data-write]").disabled');
+ await b.evaluate(`document.querySelector('#org-new-id').value='alpha';document.querySelector('#org-new-form [data-write]').click()`);
+ await wait('document.querySelector("#org-paths") && !document.querySelector("#org-paths").hidden');
+ for (const selector of ['#org-quick-open', '#org-advanced-open', '#org-preset', '#org-thinking']) assert.equal(await shown(selector), true, selector);
+ await b.evaluate(`document.querySelector('#org-quick-open').click()`);
+ await wait('!document.querySelector("#org-quick").hidden');
+ for (const selector of ['#org-project', '#org-project-remote', '#org-risk-accept', '#org-quick-run']) assert.equal(await shown(selector), true, selector);
+});
+
+for (const shouldFail of [true, false]) test(`quick setup creates real repositories and ${shouldFail ? 'stops at a bus refusal' : 'completes after bus start'}`, { timeout: 45000 }, async t => {
+ const { mkdtempSync, mkdirSync, rmSync, existsSync } = await import('node:fs');
+ const { tmpdir } = await import('node:os'); const { join } = await import('node:path');
+ const registry = await import('../../business/src/org-registry.mjs');
+ const dir = mkdtempSync(join(tmpdir(), 'quick-')); t.after(() => rmSync(dir, { recursive: true, force: true }));
+ const configDir = join(dir, 'config'), dataRoot = join(dir, 'data'); mkdirSync(configDir, { mode: 0o700 }); mkdirSync(dataRoot, { mode: 0o700 });
+ let starts = 0;
+ const bus = { busStatus: async () => ({ healthy: false }), busStart: async () => { starts++; if (shouldFail) throw Object.assign(Error('bus refused'), { code: 'org-invalid' }); return { healthy: true }; } };
+ const admin = createOrgAdmin({ service, registry, bus, context: async () => ({ configDir, dataRoot, instance: 'test' }) });
+ const server = await startServer({ port: 0, sso: createDevOwner(), orgAdmin: admin, send: async () => ({}), board: 'http://127.0.0.1:9', log: () => {} }); t.after(() => close(server));
+ const b = await browser({ machine: false }); t.after(() => b.close());
+ const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<300;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`);
+ await b.navigate(`http://127.0.0.1:${server.address().port}/#/settings/organizations`);
+ await wait('document.querySelector("#org-new-form [data-write]") && !document.querySelector("#org-new-form [data-write]").disabled');
+ await b.evaluate(`document.querySelector('#org-new-id').value='alpha';document.querySelector('#org-new-form [data-write]').click()`);
+ await wait('document.querySelector("#org-quick-open") && !document.querySelector("#org-paths").hidden');
+ await b.evaluate(`document.querySelector('#org-quick-open').click();document.querySelector('#org-risk-accept').click();document.querySelector('#org-quick-run').click()`);
+ const finalState = shouldFail ? 'Stopped' : 'Done';
+ await wait(`document.querySelector('[data-quick-step="3"] .org-pill')?.textContent === '${finalState}'`);
+ assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-quick-step] .org-pill")].map(e=>e.textContent)'), ['Done', 'Done', 'Done', finalState]);
+ if (shouldFail) {
+ assert.match(await b.evaluate(`document.querySelector('[data-quick-step="3"]').textContent`), /bus refused/);
+ assert.equal(await b.evaluate('document.activeElement.dataset.quickStep'), '3');
+ }
+ assert.equal(await b.evaluate(`document.querySelector('[data-quick-step="3"] code').textContent`), 'mosaic bus start alpha --dev-local-owner --detach');
+ assert.equal(starts, 1);
+ assert.ok(existsSync(join(dataRoot, 'org-runtime/alpha/checkout/.git')));
+ assert.ok(existsSync(join(dataRoot, 'org-runtime/alpha/projects/work/.git')));
+});
diff --git a/packages/webui/tests/org-quick-live.test.mjs b/packages/webui/tests/org-quick-live.test.mjs
new file mode 100644
index 000000000..704589d8f
--- /dev/null
+++ b/packages/webui/tests/org-quick-live.test.mjs
@@ -0,0 +1,57 @@
+// Live scratch-only acceptance. Run with HOME set to a bootstrapped scratch
+// instance whose fixture and Console are already running (see BUILD.md).
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { existsSync, readFileSync } from 'node:fs';
+import { homedir } from 'node:os';
+import { join } from 'node:path';
+import { browser } from './browser.mjs';
+
+const log = join(homedir(), 'orgdev-qconsole.log');
+test('Quick Setup starts the real fixture and a browser message gets its canned reply', { timeout: 150000, skip: !existsSync(log) && 'scratch fixture and Console are not running' }, async t => {
+ const url = /Mosaic Console: (http:\/\/127\.0\.0\.1:(\d+)\/)/.exec(readFileSync(log, 'utf8'));
+ assert.ok(url, 'scratch Console advertised a URL');
+ assert.ok(![7332, 7340, 7341, 33223, 17450, 17443, 44307, 7350].includes(Number(url[2])), 'Console must use an allowed port');
+ const b = await browser({ machine: false }); t.after(() => b.close());
+ const wait = async (expression, timeout = 20000) => { const end = Date.now() + timeout; while (Date.now() < end) { try { if (await b.evaluate(`Boolean(${expression})`)) return; } catch {} await new Promise(r => setTimeout(r, 50)); } throw Error(`Timed out: ${expression}`); };
+ const input = (selector, value) => b.evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(selector)});e.value=${JSON.stringify(value)};e.dispatchEvent(new Event('input',{bubbles:true}));e.dispatchEvent(new Event('change',{bubbles:true}));})()`);
+ const click = selector => b.evaluate(`document.querySelector(${JSON.stringify(selector)}).click()`);
+ const shown = selector => b.evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(selector)});return !!e&&getComputedStyle(e).display!=='none'&&getComputedStyle(e).visibility!=='hidden'&&e.getClientRects().length>0})()`);
+ const existing = await (await fetch(url[1] + 'api/orgs')).json();
+ const id = '0123456789abcdefghijklmnopqrstuvwxyz'.split('').map(c => 'q' + c).find(candidate => !existing.some(o => o.id === candidate));
+ assert.ok(id, 'a short unused scratch organization ID is available');
+ const start = existing.length ? `#/orgs/${encodeURIComponent(existing[0].id)}/settings/organizations` : '#/settings/organizations';
+ await b.navigate(url[1] + start);
+ await wait('document.querySelector("#org-new-form [data-write]") && !document.querySelector("#org-new-form [data-write]").disabled');
+ assert.equal(await shown('#org-new-form [data-write]'), true);
+ await input('#org-new-id', id); await click('#org-new-form [data-write]');
+ await wait('document.querySelector("#org-paths") && !document.querySelector("#org-paths").hidden');
+ assert.equal(await b.evaluate('document.querySelector("#org-preset").value'), 'fixture');
+ for (const selector of ['#org-preset', '#org-thinking', '#org-quick-open', '#org-advanced-open']) assert.equal(await shown(selector), true, selector);
+ await click('#org-quick-open');
+ await wait('document.querySelector("#org-quick") && !document.querySelector("#org-quick").hidden');
+ for (const selector of ['#org-project', '#org-project-remote', '#org-risk-accept', '#org-quick-run']) assert.equal(await shown(selector), true, selector);
+ await click('#org-risk-accept'); await click('#org-quick-run');
+ await wait(`document.querySelector('[data-quick-step="3"] .org-pill')?.textContent === 'Done' || [...document.querySelectorAll('[data-quick-step] .org-pill')].some(e=>e.textContent==='Stopped')`, 90000);
+ console.log('setup reached a terminal step');
+ const states = await b.evaluate('[...document.querySelectorAll("[data-quick-step] .org-pill")].map(e=>e.textContent)');
+ assert.deepEqual(states, ['Done', 'Done', 'Done', 'Done'], await b.evaluate('document.querySelector("#org-quick-steps").innerText'));
+ assert.equal(await shown('#org-quick-open-org'), true, 'the finished organization can be opened');
+ await click('#org-quick-open-org'); console.log('opening organization');
+ await wait(`window.MosaicOrg?.id === ${JSON.stringify(id)} && !!document.querySelector('#chat-repo option[value=work]')`, 30000);
+ await wait('!!document.querySelector("#chat-repo option[value=work]")');
+ console.log('repository offered');
+ await click('.project-create summary'); await input('#chat-project-name', 'Work'); await input('#chat-repo', 'work');
+ await click('#chat-project-form button[type=submit]');
+ await wait('location.hash.match(/\\/projects\\/[0-9a-f-]{36}/i)');
+ console.log('project created');
+ const projectRoute = await b.evaluate('location.hash');
+ await b.evaluate(`location.hash=${JSON.stringify(projectRoute + '/conversations')}`);
+ await wait('!!document.querySelector("#chat-thread-form")');
+ await click('.project-new-conversation summary'); await input('#chat-thread-name', 'Trial'); await input('#chat-instance', 'assistant');
+ await click('#chat-thread-form button[type=submit]'); await wait('!!document.querySelector("#chat-body")', 30000);
+ console.log('conversation created');
+ await input('#chat-body', 'Reply with fixture word'); await click('#chat-send-form button[type=submit]');
+ await wait('document.querySelector(".chat-log")?.textContent.includes("PINEAPPLE")', 90000);
+ assert.match(await b.evaluate('document.querySelector(".chat-log").textContent'), /PINEAPPLE/); console.log('fixture reply received');
+});
diff --git a/packages/webui/tests/org-switch-browser.test.mjs b/packages/webui/tests/org-switch-browser.test.mjs
index aa483a910..b3a5ed83f 100644
--- a/packages/webui/tests/org-switch-browser.test.mjs
+++ b/packages/webui/tests/org-switch-browser.test.mjs
@@ -26,6 +26,7 @@ test('creating another organization opens its setup, receipt, header and theme',
const docs = new Map([['alpha', makeDoc('alpha')]]), records = new Map();
const missing = () => { throw Object.assign(Error('not set up'), { code: 'org-unavailable' }); };
const service = {
+ orgPresets: async () => [{ name: 'fixture', label: 'Test model', description: 'Canned replies', available: { ok: true }, thinking: { levels: ['off'], default: 'off' } }],
templateRender: async id => makeDoc(id), validateOrg: async () => ({ ok: true, errors: [] }),
createOrg: async (_ctx, id, doc) => { docs.set(id, doc); const receipt = { op: 'org.create', org: id, notifications: 'none' }; records.set(id, receipt); return receipt; },
showOrg: async (ctx, id) => ({ id, doc: docs.get(id), revision: 1, root: `/data/${id}`, principal: ctx.principal }),
@@ -46,6 +47,8 @@ test('creating another organization opens its setup, receipt, header and theme',
assert.equal(await b.evaluate('!!document.querySelector(".settings-readonly")'), false, 'org setup is not shown as read only');
assert.equal(await shown('#org-new-form [data-write]'), true, 'New organization is visible, not only clickable');
await b.evaluate(`(()=>{const e=document.querySelector('#org-new-id');e.value='beta';e.dispatchEvent(new Event('input',{bubbles:true}));document.querySelector('#org-new-form [data-write]').click()})()`);
+ await wait('document.querySelector("#org-advanced-open") && !document.querySelector("#org-paths").hidden');
+ await b.evaluate(`document.querySelector('#org-advanced-open').click()`);
await wait('document.querySelector("#org-editor") && !document.querySelector("#org-editor").hidden');
assert.equal(await shown('#org-save'), true, 'Save organization is visible, not only clickable');
await b.evaluate(`document.querySelector('#org-save').click()`);
@@ -186,7 +189,7 @@ test('organization creation draft survives Stay and is discarded on switch', asy
list: async () => [{ id: 'alpha', label: 'Alpha', busHealthy: true }, { id: 'beta', label: 'Beta', busHealthy: true }],
resolve: async id => ({ reads: { business: async () => ({ business: { id } }), settings: async () => ({}), busStatus: async () => ({ live: true, down: false }) }, bus: {} }),
};
- const service = { templateRender: async id => ({ businessVersion: 1, id, human: 'local-dev-owner', arbiters: { delivery: 'chief', technical: 'chief' }, roles: { chief: { definition: 'chief-of-staff' } }, projects: { work: {} } }) };
+ const service = { orgPresets: async () => [{ name: 'fixture', label: 'Test model', description: 'Canned replies', available: { ok: true }, thinking: { levels: ['off'], default: 'off' } }], templateRender: async id => ({ businessVersion: 1, id, human: 'local-dev-owner', arbiters: { delivery: 'chief', technical: 'chief' }, roles: { chief: { definition: 'chief-of-staff' } }, projects: { work: {} } }) };
const orgAdmin = createOrgAdmin({ service, registry: { listOrgs: async () => [] }, context: async () => ({ configDir: '/cfg', dataRoot: '/data', instance: 'test' }) });
const server = await startServer({ port: 0, sso: createDevOwner(), organizations, orgAdmin, send: async () => ({}), board: 'http://127.0.0.1:9', log: () => {} });
t.after(() => close(server));
@@ -197,6 +200,8 @@ test('organization creation draft survives Stay and is discarded on switch', asy
await wait('!!document.querySelector("#org-new-id")');
await wait('!document.querySelector("#org-new-form [data-write]").disabled');
await b.evaluate(`(()=>{const el=document.querySelector('#org-new-id');el.value='draft';el.dispatchEvent(new Event('input',{bubbles:true}));document.querySelector('#org-new-form [data-write]').click()})()`);
+ await wait('document.querySelector("#org-paths") && !document.querySelector("#org-paths").hidden');
+ await b.evaluate(`document.querySelector('#org-advanced-open').click()`);
await wait('!document.querySelector("#org-editor").hidden');
assert.match(await b.evaluate('location.hash'), /^#\/orgs\/alpha\/settings\/organizations/);
await b.evaluate(`(()=>{const el=document.querySelector('[data-path="$.human"]');el.value='unsaved';el.dispatchEvent(new Event('input',{bubbles:true}))})()`);