- autonomy set: an empty --authority-resolver clears the resolver as the panel does; --reverse-window-hours takes 1 to 168, as the page and bus do. - The default instance gets a one-line refusal (exit 3) naming the named instance and --dev-local-owner, not a stack trace. - tasks create|edit|archive: a value starting with -- is a usage error; --business is checked before the agent guard, and the guard also reads process.env. - Drop the unused consoleRefusal export. - Docs: --business is required, resolver clearing, window range, and the named-instance requirement.
105 lines
6.8 KiB
JavaScript
105 lines
6.8 KiB
JavaScript
// E2 (#1873, P1-3, org ruling 6): the task board's writes from a terminal. `mosaic tasks create`,
|
|
// `edit` and `archive` send the board's three ops (workspace-tasks.mjs) to the organization's bus as
|
|
// the person, through the human transport (U4a: the bus's tasks.create, tasks.edit and tasks.archive
|
|
// are the person's writes "from the terminal (human-cli)"). The arguments pass the board's own
|
|
// check, parseTaskWrite, before anything is sent, so the terminal takes exactly what the board
|
|
// takes; the bus then runs its own human, field, digest and archive checks, as it does for the
|
|
// board. Writes take no --tester and refuse inside an agent run (callFor), and the bus refuses any
|
|
// caller that is not the person (human-required).
|
|
import { parseTaskWrite } from '../../webui/src/workspace-tasks.mjs';
|
|
import { CliError } from './errors.mjs';
|
|
import { orgSelector } from './org-selector.mjs';
|
|
import { refuseInsideAgent } from './transport.mjs';
|
|
|
|
export const TASKS_WRITE_OPS = Object.freeze(['create', 'edit', 'archive']);
|
|
export const TASKS_WRITE_USAGE = 'mosaic tasks create --title TEXT --requirement REQ-X-N [--project NAME] [--state STATE] [--assignees ROLE,...] [--priority 0-5] [--due ISO|none] --business <id> [--json]\n' +
|
|
' mosaic tasks edit task:N --expect DIGEST [--title TEXT] [--state STATE] [--assignees ROLE,...] [--priority 0-5] [--due ISO|none] --business <id> [--json]\n' +
|
|
' mosaic tasks archive task:N --expect DIGEST --business <id> [--json]';
|
|
|
|
const usage = why => new CliError(`${why}\nusage: ${TASKS_WRITE_USAGE}`, 4);
|
|
// Each op's options, as the board's op takes its keys (workspace-tasks.mjs OPS).
|
|
const OPTIONS = {
|
|
create: ['--title', '--requirement', '--project', '--state', '--assignees', '--priority', '--due'],
|
|
edit: ['--expect', '--title', '--state', '--assignees', '--priority', '--due'],
|
|
archive: ['--expect'],
|
|
};
|
|
const KEY = { '--title': 'title', '--requirement': 'requirement', '--project': 'group', '--state': 'state', '--assignees': 'assignees', '--priority': 'priority', '--due': 'due_date', '--expect': 'expect' };
|
|
// The board's answer when the bus refuses, by code (workspace-tasks.mjs REFUSALS), so the terminal
|
|
// reads the same next step.
|
|
const GUIDANCE = {
|
|
'human-required': 'the bus takes task changes only from a person; nothing was written',
|
|
'tracker-not-store': 'this organization keeps its tasks in Vikunja; nothing was written',
|
|
'task-conflict': 'the task changed after you read it; nothing was written. Read it again with mosaic tasks --json',
|
|
'task-archived': 'the task is archived; nothing was written',
|
|
'task-already-archived': 'the task was already archived; nothing was written',
|
|
'task-not-found': 'the task is not on this organization\'s bus; nothing was written',
|
|
'task-position-full': 'this project has no room for another task position; nothing was written',
|
|
'invalid-state': 'the bus does not have that state; nothing was written',
|
|
'unknown-role': 'an assignee is not a role in this organization; nothing was written',
|
|
'invalid-request': 'the bus refused the change as invalid; nothing was written',
|
|
'credential-leak': 'the change holds part of a stored credential, so the bus refused it; nothing was written',
|
|
};
|
|
|
|
export function parseTasksWrite(args) {
|
|
const op = args[0];
|
|
if (!TASKS_WRITE_OPS.includes(op)) throw usage(`tasks takes create, edit or archive, not ${JSON.stringify(op)}`);
|
|
const selector = orgSelector(usage);
|
|
const values = {}, positional = [];
|
|
let json = false;
|
|
for (let i = 1; i < args.length; i++) {
|
|
const a = args[i];
|
|
if (selector.owns(a)) { selector.take(a, args[i + 1]); i++; }
|
|
else if (a === '--json') { if (json) throw usage('duplicate --json'); json = true; }
|
|
else if (OPTIONS[op].includes(a)) {
|
|
if (Object.hasOwn(values, a)) throw usage(`duplicate ${a}`);
|
|
if (i + 1 >= args.length || args[i + 1].startsWith('--')) throw usage(`${a} needs one value`);
|
|
values[a] = args[++i];
|
|
} else if (a.startsWith('--')) throw usage(`tasks ${op} does not take ${a}`);
|
|
else positional.push(a);
|
|
}
|
|
// The organization is named before anything else is checked, so a missing --business is a usage
|
|
// error (exit 4) inside an agent run too, as for the org owner verbs.
|
|
if (!selector.given) throw usage('name the organization with --business');
|
|
if (positional.length !== (op === 'create' ? 0 : 1)) throw usage(op === 'create' ? 'tasks create takes no task' : `tasks ${op} takes one task, task:N`);
|
|
const body = op === 'create' ? {} : { task_ref: positional[0] };
|
|
for (const [flag, raw] of Object.entries(values)) {
|
|
const key = KEY[flag];
|
|
if (key === 'assignees') body[key] = raw === '' ? [] : raw.split(',').map(r => r.trim());
|
|
else if (key === 'priority') body[key] = /^[0-9]$/.test(raw) ? Number(raw) : raw;
|
|
else if (key === 'due_date') body[key] = raw === 'none' ? null : raw;
|
|
else body[key] = raw;
|
|
}
|
|
// The board's own check. Its org is the page's; here the organization is checked by the selector
|
|
// and the bus, so a fixed valid id stands in for it.
|
|
let write;
|
|
try { write = parseTaskWrite({ op, org: 'terminal', ...body }); }
|
|
catch (e) { if (typeof e?.status === 'number') throw usage(`tasks ${op}: ${e.message}`); throw e; }
|
|
return { op, verb: write.verb, args: write.args, json, business: selector.given ? selector.value : undefined };
|
|
}
|
|
|
|
export async function tasksWriteCommand(args, io, callFor) {
|
|
const write = parseTasksWrite(args);
|
|
// agent-guard (#1873): this process's own environment as well as io.env (callFor checks io.env),
|
|
// as the org owner verbs check.
|
|
refuseInsideAgent(process.env);
|
|
const parsed = { positional: [], flags: new Set(), values: write.business === undefined ? {} : { '--business': write.business } };
|
|
const call = callFor(parsed);
|
|
let result;
|
|
try { result = await call(write.verb, write.args); }
|
|
catch (e) {
|
|
if (e?.code === 'outcome-unknown' || e?.exitCode === 1) throw new CliError(`outcome unknown: the task change may or may not have been written; read it with mosaic tasks --json before trying again`, 1);
|
|
if (typeof e?.code === 'string' && Object.hasOwn(GUIDANCE, e.code)) {
|
|
const refused = new CliError(`tasks ${write.op} refused (${e.code}): ${GUIDANCE[e.code]}`, e.exitCode ?? 2);
|
|
refused.code = e.code;
|
|
throw refused;
|
|
}
|
|
throw e;
|
|
}
|
|
const taskRef = typeof result?.task_ref === 'string' ? result.task_ref : write.args.task_ref ?? null;
|
|
const digest = typeof result?.digest === 'string' ? result.digest : null;
|
|
// The board's answer shape (workspace-tasks.mjs), with the organization the call went to.
|
|
const answer = { ok: true, org: parsed.business ?? write.business ?? null, op: write.op, task_ref: taskRef, digest };
|
|
const done = { create: 'created', edit: 'changed', archive: 'archived' }[write.op];
|
|
io.stdout.write(write.json ? `${JSON.stringify(answer, null, 2)}\n` : `${done} ${taskRef}${digest ? `, digest ${digest}` : ''}\n`);
|
|
}
|