// 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 [--json] [--business ]', 'mosaic session show|open [--json] [--business ]', 'mosaic session create --op [--json] [--business ]', 'mosaic session clear --yes --op [--json] [--business ]', 'mosaic session cancel --yes [--json] [--business ]', 'mosaic session operation [--json] [--business ]', ].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`); }