98 lines
7.5 KiB
JavaScript
98 lines
7.5 KiB
JavaScript
// Row 272 / 264e: logical thread sessions, through the verified human channel only.
|
|
import { CliError } from './errors.mjs';
|
|
import { parseArgs, resolveThread } from './thread.mjs';
|
|
|
|
export const SESSION_USAGE = [
|
|
'mosaic session list <thread> [--json] [--business <id>]',
|
|
'mosaic session show|open <thread> <session> [--json] [--business <id>]',
|
|
'mosaic session create <thread> <name> --op <key> [--json] [--business <id>]',
|
|
'mosaic session clear <thread> <session> --yes --op <key> [--json] [--business <id>]',
|
|
'mosaic session cancel <thread> <key> --yes [--json] [--business <id>]',
|
|
'mosaic session operation <thread> <key> [--json] [--business <id>]',
|
|
].join('\n ');
|
|
const KEY = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,159}$/;
|
|
const json = (io, value) => io.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
export const compactionLabel = 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'})`;
|
|
const missing = e => e.code === 'unknown-verb' || e.code === 'channel-operation';
|
|
async function operation(call, verb, args) {
|
|
try { return await call(verb, args); }
|
|
catch (e) {
|
|
if (missing(e)) throw new CliError('session operation unavailable: its dependency is not installed; nothing was retried', 3);
|
|
throw e;
|
|
}
|
|
}
|
|
// Never resolve a foreign full ID by falling back to the thread default.
|
|
export function selectSession(rows, ref) {
|
|
const exact = rows.find(r => r.id === ref) ?? rows.find(r => r.name === ref);
|
|
if (exact) return exact;
|
|
const hits = /^[a-f0-9-]{8,}$/i.test(ref) ? rows.filter(r => r.id.startsWith(ref)) : [];
|
|
if (hits.length === 1) return hits[0];
|
|
throw new CliError(hits.length > 1 ? 'ambiguous session; use the full id' : 'session not found in this thread; use mosaic session list', 2);
|
|
}
|
|
const validReceipt = (v, thread, op, session) => v && v.thread === thread && v.op === op &&
|
|
(session === undefined || v.session === session) && typeof v.successor === 'string' &&
|
|
['clearing', 'uncertain', 'retired', 'failed', 'cancelled'].includes(v.state) && [null, 'held', 'started', 'refused'].includes(v.start) &&
|
|
(v.state !== 'cancelled' || v.retired === null || v.retired === true);
|
|
const receipt = (value, thread, op, session) => {
|
|
if (session !== undefined && validReceipt(value, thread, op) && value.session !== session) {
|
|
throw new CliError('operation key already used for another session; choose a new key or query the recorded operation', 2);
|
|
}
|
|
if (!validReceipt(value, thread, op, session)) throw new CliError(`outcome unknown; check mosaic session operation ${thread} ${op}; nothing was retried`, 1);
|
|
return value;
|
|
};
|
|
const terminal = value => ['failed', 'retired', 'cancelled'].includes(value.state);
|
|
export async function sessionCommand(args, io, callFor, { now = Date.now, sleep = ms => new Promise(r => setTimeout(r, ms)), waitMs = 30000 } = {}) {
|
|
const [sub, ...rest] = args;
|
|
const usage = why => new CliError(`${why}\n${SESSION_USAGE}`, 4);
|
|
if (!['list', 'show', 'create', 'open', 'clear', 'cancel', 'operation'].includes(sub)) throw usage('choose a session verb');
|
|
const p = parseArgs(rest, SESSION_USAGE, { flags: ['--json', '--yes'], values: ['--business', '--op'] });
|
|
if (p.positional.length !== (sub === 'list' ? 1 : 2)) throw usage('wrong number of session arguments');
|
|
if (p.flags.has('--yes') && !['clear', 'cancel'].includes(sub) || p.values['--op'] !== undefined && !['create', 'clear'].includes(sub)) throw usage('option does not apply to this verb');
|
|
if (['create', 'clear'].includes(sub) && !KEY.test(p.values['--op'] ?? '')) throw usage('--op needs a reusable operation key');
|
|
if (sub === 'clear' && !p.flags.has('--yes')) throw usage('clear needs --yes: it ends this context, preserves historical work and uses a fresh approved checkout');
|
|
if (sub === 'cancel' && !p.flags.has('--yes')) throw usage('cancel needs --yes: it stops replacement before retirement, or stops successor startup after retirement; nothing is un-retired or replayed');
|
|
if (['operation', 'cancel'].includes(sub) && !KEY.test(p.positional[1])) throw usage('invalid operation key');
|
|
const call = callFor(p), thread = await resolveThread(call, p.positional[0]);
|
|
let result;
|
|
if (sub === 'create') result = await operation(call, 'session.create', { thread: thread.id, name: p.positional[1], op: p.values['--op'] });
|
|
else if (sub === 'cancel') result = receipt(await operation(call, 'session.cancel', { thread: thread.id, op: p.positional[1], confirm: true }), thread.id, p.positional[1]);
|
|
else if (sub === 'operation') result = receipt(await operation(call, 'session.operation', { thread: thread.id, op: p.positional[1] }), thread.id, p.positional[1]);
|
|
else {
|
|
const rows = await call('session.list', { thread: thread.id });
|
|
if (sub === 'list') result = rows;
|
|
else {
|
|
const selected = selectSession(rows, p.positional[1]);
|
|
if (sub === 'show') result = selected;
|
|
else if (sub === 'open') result = await call('thread.open', { thread: thread.id, session: selected.id });
|
|
else {
|
|
if (!Number.isSafeInteger(selected.revision)) throw new CliError('clear unavailable: the safe-clear dependency is not installed', 3);
|
|
// Print recovery coordinates before the write, including when its answer is lost.
|
|
io.stderr.write(`clear operation ${p.values['--op']}; check mosaic session operation ${thread.id} ${p.values['--op']}\n`);
|
|
const op = p.values['--op'];
|
|
// Repeated CLI invocation resumes the recorded operation even after revision changes.
|
|
// Only a definitive not-found permits the first write. Transport uncertainty refuses.
|
|
try { result = receipt(await operation(call, 'session.operation', { thread: thread.id, op }), thread.id, op, selected.id); }
|
|
catch (e) { if (e.code !== 'operation-not-found') throw e; }
|
|
if (!result) result = receipt(await operation(call, 'session.clear', { thread: thread.id, session: selected.id, revision: selected.revision, confirm: true, op }), thread.id, op, selected.id);
|
|
const until = now() + waitMs;
|
|
while (!terminal(result) && now() < until) {
|
|
await sleep(Math.min(1000, Math.max(0, until - now())));
|
|
result = receipt(await operation(call, 'session.operation', { thread: thread.id, op }), thread.id, op, selected.id);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (sub === 'cancel' && result.state !== 'cancelled') throw new CliError('outcome unknown: query the operation; cancel was not retried', 1);
|
|
if (p.flags.has('--json')) return json(io, result);
|
|
if (sub === 'list' || sub === 'show') {
|
|
const rows = sub === 'list' ? result : [result];
|
|
return io.stdout.write(rows.length ? rows.map(r => `${r.id} ${r.name}${r.default ? ' (default)' : ''} ${r.state ?? 'status unavailable'} Compactions: ${compactionLabel(r.compactions)}\n`).join('') : 'no sessions\n');
|
|
}
|
|
if (sub === 'create') return io.stdout.write(`created session ${result.id}; no run started\n`);
|
|
if (sub === 'open') return io.stdout.write(`resume requested for ${p.positional[1]} in ${thread.label}\n`);
|
|
const cancellation = result.state !== 'cancelled' ? '' : result.retired === true ? '; startup cancelled; old session remains retired' : '; replacement cancelled; old session active; cancelled inputs not replayed';
|
|
io.stdout.write(`operation ${result.op}: ${result.state}${terminal(result) ? '' : ' (pending; no stop forced)'}${cancellation}; successor ${result.successor ?? 'pending'}; start ${result.start ?? 'pending'}${result.refusal ? `; ${result.refusal}` : ''}\n`);
|
|
}
|