git mv packages/business -> packages/organization (@mosaic/organization). The old package stays as one-line re-export shims of each moved module, with src/business.mjs re-exporting src/organization-config.mjs and src/cli.mjs forwarding to the moved executable. BusinessError is OrganizationConfigError (one constructor, name kept BusinessError); OrganizationError stays distinct. The bus adapter gains organization-context.mjs (busOrganization), with business.mjs (busBusiness) as the same function. Import consumers, manifests, the lockfile, scripts/mosaic, stack-copy.sh COPY_PATHS (both roots), seat sandbox RUNNER_FILES and the copy closure walker (organization-imports.mjs, both roots required) move to the new path. v1 serialization, v1 writers and the stage A fixtures are unchanged; the stage A contract tests stay where they were and pass as before.
112 lines
8.8 KiB
JavaScript
112 lines
8.8 KiB
JavaScript
// Opt-in live acceptance observer/submission. Never starts a host, selects a mode,
|
|
// approves a PRD, creates a service resource, or substitutes a scripted role.
|
|
import { readFileSync, writeFileSync, mkdirSync, lstatSync } from 'node:fs';
|
|
import { dirname, isAbsolute, join, resolve, relative } from 'node:path';
|
|
import { pathToFileURL } from 'node:url';
|
|
import { createHash, randomUUID } from 'node:crypto';
|
|
import { loadSystem, bootConfig, rolesDir } from '../src/config.mjs';
|
|
import { loadBusiness } from '../../organization/src/index.mjs';
|
|
import { readHostState } from '../src/host.mjs';
|
|
import { humanTransport, refuseInsideAgent } from '../src/transport.mjs';
|
|
import { staffFor } from '../../bus/src/request-host.mjs';
|
|
|
|
const fail = message => { throw new Error(message); };
|
|
export function parseOptions(argv) {
|
|
const result = {};
|
|
const flags = { '--authorize-isolated-run': 'authorized', '--submit': 'submit' };
|
|
const values = { '--instance':'instance', '--evidence':'evidence', '--config':'config', '--business':'business', '--owner':'owner',
|
|
'--request-file':'requestFile', '--request':'request', '--thread':'thread', '--artifact':'artifact', '--timeout-ms':'timeout' };
|
|
for (let n = 0; n < argv.length; n++) {
|
|
const key = flags[argv[n]] ?? values[argv[n]];
|
|
if (!key || key in result) fail('unknown or duplicate option');
|
|
result[key] = argv[n] in flags ? true : argv[++n];
|
|
if (result[key] === undefined) fail('missing option value');
|
|
}
|
|
if (!/^[a-z][a-z0-9]{1,15}$/.test(result.instance ?? '') || ['dev','prod','production'].includes(result.instance)) fail('explicit named isolated --instance required');
|
|
if (!result.authorized) fail('explicit owner authorization --authorize-isolated-run required');
|
|
for (const key of ['config','evidence','requestFile']) if (!isAbsolute(result[key] ?? '')) fail('explicit absolute --' + key + ' required');
|
|
if (!result.business || !result.owner) fail('explicit business and expected owner required');
|
|
if (result.submit && (!result.thread || result.request)) fail('--submit requires an existing owner --thread and no --request');
|
|
result.timeout = result.timeout === undefined ? 300000 : Number(result.timeout);
|
|
if (!Number.isSafeInteger(result.timeout) || result.timeout < 1000 || result.timeout > 3600000) fail('timeout must be 1000..3600000 ms');
|
|
return result;
|
|
}
|
|
export function checkResources({ options, system, business, marker, host, tracker }) {
|
|
if (system.environment !== 'development') fail('isolated development instance required');
|
|
if (marker?.instanceVersion !== 1 || marker.name !== options.instance) fail('instance marker mismatch');
|
|
if (business.id !== options.business || business.human !== options.owner) fail('owner/business mismatch');
|
|
if (!host?.live || (host.business && host.business !== business.id)) fail('configured live host required; runner does not start one');
|
|
if (!Number.isSafeInteger(tracker?.project) || tracker.project < 1) fail('tracker-install: declared installed tracker root required');
|
|
const pms = Object.keys(business.roles).filter(r => business.roles[r].definition === 'pm');
|
|
if (pms.length !== 1 || !staffFor(business, pms[0])) fail('configured PM, independent technical arbiter, chief, coder and reviewer staff required');
|
|
if (!Object.values(business.projects).some(p => p.kind !== 'library')) fail('declared factory source project required');
|
|
}
|
|
function json(file) {
|
|
const stat = lstatSync(file);
|
|
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1024 * 1024) fail('bounded regular input required');
|
|
return JSON.parse(readFileSync(file, 'utf8'));
|
|
}
|
|
export async function main(argv, env = process.env) {
|
|
const options = parseOptions(argv);
|
|
refuseInsideAgent(env); // --owner is an expectation, never authentication or a capability.
|
|
const selected = { ...env, MOSAIC_CONFIG: options.config };
|
|
const system = loadSystem({ env: selected }), config = bootConfig({ system, businessId: options.business, env: selected });
|
|
const business = loadBusiness(options.business, { dir: dirname(options.config), rolesDir: rolesDir(selected) });
|
|
const host = readHostState(system.dataRoot);
|
|
checkResources({ options, system, business, marker: json(join(system.dataRoot, 'instance.json')), host, tracker: config.trackers?.[options.business] });
|
|
for (const [name, role] of Object.entries(business.roles)) {
|
|
if (!role.vars?.harness || !role.vars?.model || !role.vars?.thinking) fail('model route missing for ' + name);
|
|
}
|
|
const source = json(options.requestFile);
|
|
if (typeof source.text !== 'string' || !source.text || source.provenance?.raw_runtime_event_exported !== false ||
|
|
createHash('sha256').update(source.text).digest('hex') !== source.source_sha256) fail('verified owner-supplied original request quotation required');
|
|
const call = humanTransport({ socket: host.socket ?? join(system.dataRoot, 'bus/broker.sock'), business: business.id, env: selected });
|
|
await call('instances'); // Existing owner process proof succeeds before any submission.
|
|
const credentials = await call('credentials');
|
|
if (Array.isArray(credentials) && credentials.some(c => c.service === 'vikunja' && !['valid','ready'].includes(c.state))) fail('tracker credential setup wait; inspect Console credentials');
|
|
const rel = relative(system.dataRoot, resolve(options.evidence));
|
|
if (rel === '' || (!rel.startsWith('..') && !isAbsolute(rel))) fail('evidence output must be outside the instance data root');
|
|
mkdirSync(options.evidence, { recursive: true, mode: 0o700 });
|
|
if (!lstatSync(options.evidence).isDirectory() || lstatSync(options.evidence).isSymbolicLink()) fail('plain evidence output directory required');
|
|
const record = (kind, value) => {
|
|
const file = join(options.evidence, `${kind}-${randomUUID()}.json`);
|
|
writeFileSync(file, JSON.stringify(value, null, 2) + '\n', { flag: 'wx', mode: 0o600 }); return file;
|
|
};
|
|
record('preflight', { instance: options.instance, business: business.id, owner: business.human, source,
|
|
qualification: 'Owner-supplied incident quote; new runtime submission IDs are recorded separately. Fixture success is not live model acceptance.' });
|
|
let request = options.request;
|
|
if (options.submit) {
|
|
const threads = await call('thread.list');
|
|
if (!threads.some(t => t.id === options.thread)) fail('the explicitly selected thread is not owned by this user');
|
|
const sent = await call('thread.send', { thread: options.thread, body: source.text });
|
|
request = sent.request;
|
|
record('submission', { request, message: sent.id, original: { source_event: source.source_event, source_message: source.source_message, source_sha256: source.source_sha256 } });
|
|
process.stdout.write(`Request ${request}. Select and confirm the exact plan in the signed-in Console.\n`);
|
|
}
|
|
if (!request) fail('--submit or an existing --request is required');
|
|
const end = Date.now() + options.timeout;
|
|
let state, revision = null;
|
|
while (Date.now() < end) {
|
|
try { state = await call('request.get', { request }); }
|
|
catch (e) { if (e.code !== 'request-not-found') throw e; }
|
|
if (state && state.revision !== revision) { record('request', state); revision = state.revision; }
|
|
if (state?.phase === 'cancelled') fail('request cancelled; receipts retained');
|
|
if (state?.phase === 'complete' && state.delivery_evidence) break;
|
|
await new Promise(resolve => setTimeout(resolve, 500));
|
|
}
|
|
if (!state?.delivery_evidence || state.phase !== 'complete') fail('live acceptance incomplete: exact current wait is retained in request evidence');
|
|
const delivery = state.delivery_evidence;
|
|
const trails = [];
|
|
for (const subject of [request, state.source_message, ...state.tasks, delivery.review_request, delivery.verdict]) trails.push({ subject, rows: await call('trail', { subject }) });
|
|
record('delivery', { state, trails, verified_local_delivery_only: true });
|
|
if (!options.artifact) fail(`download required from signed-in Console /api/requests/${request}/artifact/${delivery.id}; rerun observation with --artifact PATH`);
|
|
const bytes = readFileSync(options.artifact);
|
|
if (bytes.length !== delivery.artifact.bytes || createHash('sha256').update(bytes).digest('hex') !== delivery.artifact.sha256) fail('download differs from reviewed artifact');
|
|
writeFileSync(join(options.evidence, 'reviewed-' + delivery.id + '.html'), bytes, { flag: 'wx', mode: 0o600 });
|
|
record('download', { request, delivery: delivery.id, artifact: delivery.artifact, qualification: 'Hash-verified live delivery; lead must run offline behavior checks and inspect real independent role/test receipts before acceptance.' });
|
|
process.stdout.write('Live delivery and downloaded bytes retained. Offline browser verification and lead acceptance remain explicit.\n');
|
|
}
|
|
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
|
main(process.argv.slice(2)).catch(e => { process.stderr.write(e.message + '\n'); process.exitCode = 2; });
|
|
}
|