toolroll 0.9.1 → 0.9.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +37 -0
- package/dist/agent-fence.d.ts +11 -2
- package/dist/agent-fence.js +29 -3
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +3 -3
- package/dist/builder.js +21 -2
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +3 -0
- package/dist/desktop-update.d.ts +1 -1
- package/dist/flow-engine.d.ts +7 -0
- package/dist/flow-engine.js +47 -15
- package/dist/flow-insights.d.ts +9 -0
- package/dist/flow-insights.js +16 -0
- package/dist/flow-replies.js +1 -1
- package/dist/flow-starters.d.ts +6 -1
- package/dist/flow-starters.js +46 -3
- package/dist/flow-triggers.d.ts +20 -1
- package/dist/flow-triggers.js +105 -6
- package/dist/flows-cli.d.ts +228 -0
- package/dist/flows-cli.js +403 -0
- package/dist/flows-ui.js +1 -1
- package/dist/flows.d.ts +25 -4
- package/dist/flows.js +75 -2
- package/dist/guides.js +23 -0
- package/dist/keys.js +4 -1
- package/dist/mate-contract.js +1 -1
- package/dist/mate-tools.js +4 -3
- package/dist/operate.d.ts +1 -1
- package/dist/operate.js +39 -4
- package/dist/plane-review.d.ts +39 -0
- package/dist/plane-review.js +233 -0
- package/dist/store.d.ts +2 -0
- package/dist/store.js +5 -0
- package/dist/surface.js +4 -0
- package/package.json +1 -1
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
/** `toolroll flows …`: set up and inspect flows from a terminal, through the
|
|
2
|
+
* same helpers the console and the lead's flow tool use — flowFromSteps,
|
|
3
|
+
* FLOW_TEMPLATES, the starter flows, addFlowTriggerTo, saveScript and
|
|
4
|
+
* addCardToFlow — so a flow made here is checked exactly as one drawn there.
|
|
5
|
+
* Reads need no login; every write is an approver's, proved by --as/--token
|
|
6
|
+
* (or the remembered login), and lands in the ledger as a console request
|
|
7
|
+
* does. create, edit, archive and trigger add preview until --yes. */
|
|
8
|
+
import { readFileSync } from 'node:fs';
|
|
9
|
+
import { resolve } from 'node:path';
|
|
10
|
+
import { envelopeJson } from './envelope.js';
|
|
11
|
+
import { addCardToFlow, advanceFlows, flowDefinitionOf } from './flow-engine.js';
|
|
12
|
+
import { saveScript } from './flow-scripts.js';
|
|
13
|
+
import { STARTER_FLOWS, starterFlowOf, starterOf, startersFor, starterTerms, switchOnStarter } from './flow-starters.js';
|
|
14
|
+
import { addFlowTriggerTo, checkFlowTriggerNow, describeTrigger, removeFlowTrigger, triggerConfigOf, validateTriggerConfig } from './flow-triggers.js';
|
|
15
|
+
import { deciderOf, FLOW_KIND_WORDS, FLOW_TEMPLATES, flowDigest, flowFromSteps, flowTerms, stepsFor } from './flows.js';
|
|
16
|
+
import { verifyApproverByPassword } from './principal.js';
|
|
17
|
+
import { projectName } from './project.js';
|
|
18
|
+
const EXIT = { ok: 0, failed: 1, usage: 2, refused: 3 };
|
|
19
|
+
const json = { name: 'json', takesValue: false, meaning: 'answer with one machine envelope on stdout' };
|
|
20
|
+
const db = { name: 'db', takesValue: true, meaning: 'path to the database file (defaults to the installation\'s)' };
|
|
21
|
+
const as = { name: 'as', takesValue: true, meaning: 'the approver making the change (default: the remembered login)' };
|
|
22
|
+
const token = { name: 'token', takesValue: true, meaning: "that approver's password" };
|
|
23
|
+
const yes = { name: 'yes', takesValue: false, meaning: 'make the previewed change' };
|
|
24
|
+
const repo = { name: 'repo', takesValue: true, meaning: 'the project checkout root' };
|
|
25
|
+
const steps = { name: 'steps', takesValue: true, meaning: "a JSON file (or - for stdin) with the steps in order, as the lead's flow tool takes them" };
|
|
26
|
+
const read = [json, db];
|
|
27
|
+
const write = [json, db, as, token];
|
|
28
|
+
export const FLOWS_DESCRIPTORS = [
|
|
29
|
+
{ action: 'list', synopsis: 'list flows: id, name, project, zones, triggers and cards waiting', mutation: 'none', flags: [...read, repo] },
|
|
30
|
+
{ action: 'show', synopsis: 'one flow: zones with their next and failure paths, triggers and recent cards', mutation: 'none', positionals: ['flow'], flags: read },
|
|
31
|
+
{ action: 'create', synopsis: 'create a flow from --template <id> (a template or starter flow) or --steps <file|->; previews until --yes', mutation: 'unkeyed', flags: [...write, yes, repo,
|
|
32
|
+
{ name: 'name', takesValue: true, meaning: "the flow's name (a template's label when left out)" },
|
|
33
|
+
{ name: 'template', takesValue: true, meaning: `one of ${[...FLOW_TEMPLATES.map(one => one.id), ...STARTER_FLOWS.map(one => one.id)].join(', ')}` }, steps] },
|
|
34
|
+
{ action: 'edit', synopsis: "replace a flow's steps (kept steps by id) and optionally rename it; previews until --yes", mutation: 'unkeyed', positionals: ['flow'], flags: [...write, yes, steps, { name: 'name', takesValue: true, meaning: 'a new name' }] },
|
|
35
|
+
{ action: 'trigger add', synopsis: 'add a trigger from JSON or a JSON file (any kind: button, schedule, github, linear, flow, webhook, email, chat); previews until --yes', mutation: 'unkeyed', positionals: ['flow', 'trigger'], flags: [...write, yes] },
|
|
36
|
+
{ action: 'trigger pause', synopsis: 'pause a trigger', mutation: 'identity-idempotent', positionals: ['flow', 'trigger'], flags: write },
|
|
37
|
+
{ action: 'trigger resume', synopsis: 'turn a paused trigger on again', mutation: 'identity-idempotent', positionals: ['flow', 'trigger'], flags: write },
|
|
38
|
+
{ action: 'trigger remove', synopsis: 'remove a trigger; cards it added stay', mutation: 'identity-idempotent', positionals: ['flow', 'trigger'], flags: write },
|
|
39
|
+
{ action: 'trigger check', synopsis: 'run a checked trigger (or a schedule\'s script) now, like Check now', mutation: 'unkeyed', positionals: ['flow', 'trigger'], flags: write },
|
|
40
|
+
{ action: 'script save', synopsis: "save a project script (a new version when it exists)", mutation: 'identity-idempotent', flags: [...write, repo,
|
|
41
|
+
{ name: 'name', takesValue: true, meaning: 'lowercase letters, numbers and dashes' },
|
|
42
|
+
{ name: 'file', takesValue: true, meaning: 'run this path in the project' },
|
|
43
|
+
{ name: 'body', takesValue: true, meaning: 'a local file holding the script itself' },
|
|
44
|
+
{ name: 'about', takesValue: true, meaning: 'one line: what it checks or does' },
|
|
45
|
+
{ name: 'language', takesValue: true, meaning: 'shell (default), python or node' },
|
|
46
|
+
{ name: 'timeout-minutes', takesValue: true, meaning: '1 to 60 (default 15)' }] },
|
|
47
|
+
{ action: 'card add', synopsis: 'add a card, in the first zone unless --zone names one', mutation: 'unkeyed', positionals: ['flow'], flags: [...write,
|
|
48
|
+
{ name: 'title', takesValue: true, meaning: "the card's title" },
|
|
49
|
+
{ name: 'description', takesValue: true, meaning: 'its details' },
|
|
50
|
+
{ name: 'zone', takesValue: true, meaning: 'a zone id or name' }] },
|
|
51
|
+
{ action: 'archive', synopsis: 'archive a flow: its cards stop moving and its triggers stop; previews until --yes', mutation: 'identity-idempotent', positionals: ['flow'], flags: [...write, yes] },
|
|
52
|
+
];
|
|
53
|
+
/** `--file` is a value here (a path in the project), where elsewhere it is a switch. */
|
|
54
|
+
export const FLOWS_VALUE_FLAGS = new Set(['file']);
|
|
55
|
+
export async function runFlowsCommand(positional, flags, context) {
|
|
56
|
+
const [first, second] = positional;
|
|
57
|
+
const action = first === 'trigger' || first === 'script' || first === 'card' ? `${first} ${second ?? ''}`.trim() : first ?? 'list';
|
|
58
|
+
const command = `flows ${action}`;
|
|
59
|
+
const args = positional.slice(action.split(' ').length);
|
|
60
|
+
const fail = (reason, message, code = EXIT.refused, extra = {}) => {
|
|
61
|
+
context.write(context.json ? envelopeJson({ ok: false, command, reason, message, ...extra }) : message);
|
|
62
|
+
return code;
|
|
63
|
+
};
|
|
64
|
+
const ok = (result, lines) => { context.write(context.json ? envelopeJson({ ok: true, command, ...result }) : lines.join('\n')); return EXIT.ok; };
|
|
65
|
+
const descriptor = FLOWS_DESCRIPTORS.find(one => one.action === action);
|
|
66
|
+
if (descriptor === undefined)
|
|
67
|
+
return fail('usage', `Use flows ${FLOWS_DESCRIPTORS.map(one => one.action).join(' | ')}.`, EXIT.usage);
|
|
68
|
+
const known = new Set(descriptor.flags.map(one => one.name));
|
|
69
|
+
for (const name of flags.keys())
|
|
70
|
+
if (!known.has(name) && name !== 'help')
|
|
71
|
+
return fail('usage', `--${name} is not a flows ${action} option.`, EXIT.usage);
|
|
72
|
+
const text = (key) => { const value = flags.get(key); return typeof value === 'string' ? value : null; };
|
|
73
|
+
const readInput = context.readInput ?? ((path) => readFileSync(path === '-' ? 0 : resolve(path), 'utf8'));
|
|
74
|
+
const { store } = context;
|
|
75
|
+
// ---- reads: what the installation holds, no login needed
|
|
76
|
+
const projectOf = (given) => given === null ? null : context.projects.find(one => one === given || one === resolve(given)) ?? null;
|
|
77
|
+
const flowOf = (value, repos) => {
|
|
78
|
+
const flow = value !== undefined && /^[1-9][0-9]{0,9}$/.test(value) ? store.getFlow(Number(value)) : null;
|
|
79
|
+
return flow !== null && flow.state === 'active' && repos.includes(flow.repo) ? flow : null;
|
|
80
|
+
};
|
|
81
|
+
if (action === 'list') {
|
|
82
|
+
const given = text('repo');
|
|
83
|
+
const project = projectOf(given);
|
|
84
|
+
if (given !== null && project === null)
|
|
85
|
+
return fail('unknown-project', 'That isn\'t a project Toolroll knows.');
|
|
86
|
+
const flows = store.listFlows(project === null ? context.projects : [project]).map(flow => listed(store, flow));
|
|
87
|
+
return ok({ flows }, flows.length === 0 ? ['No flows yet. Create one: toolroll flows create --repo <path> --name <name> --template coding'] : flows.map(one => `#${one.id} ${one.name} · ${one.project} · ${one.zones.length} zone${one.zones.length === 1 ? '' : 's'} · ${one.triggers} trigger${one.triggers === 1 ? '' : 's'} · ${one.cardsWaiting} card${one.cardsWaiting === 1 ? '' : 's'} waiting`));
|
|
88
|
+
}
|
|
89
|
+
if (action === 'show') {
|
|
90
|
+
const flow = flowOf(args[0], context.projects);
|
|
91
|
+
if (flow === null)
|
|
92
|
+
return fail('unknown-flow', 'No such flow in your projects. toolroll flows list names them.');
|
|
93
|
+
const shown = describeFlow(store, flow);
|
|
94
|
+
return ok({ flow: shown }, showLines(shown));
|
|
95
|
+
}
|
|
96
|
+
// ---- writes: an approver's, on a project they can reach
|
|
97
|
+
if (context.credentials === null)
|
|
98
|
+
return fail('unauthenticated', 'Changing a flow needs an approver: pass --as <you> --token <your password> (or sign in once with toolroll up).');
|
|
99
|
+
const acting = context.credentials;
|
|
100
|
+
const reachable = context.projects.filter(one => store.accountCanAccess(acting.name, one));
|
|
101
|
+
const verified = verifyApproverByPassword(store, acting.name, acting.token, reachable);
|
|
102
|
+
if (!verified.ok)
|
|
103
|
+
return fail('unauthenticated', 'That name and password aren\'t an approver\'s. Nothing was changed.');
|
|
104
|
+
const who = verified.who.name;
|
|
105
|
+
const now = context.clock();
|
|
106
|
+
// Each write, taken or refused, is a line in the ledger, like a console request.
|
|
107
|
+
const record = (project, outcome, detail = null) => {
|
|
108
|
+
store.recordAction({ at: context.clock().toISOString(), actor: who, repo: project, taskId: null, runId: null, action: command, outcome, source: 'request', detail: `command line${detail === null ? '' : ` · ${detail}`}` });
|
|
109
|
+
};
|
|
110
|
+
const refuse = (project, reason, message, code = EXIT.refused) => { record(project, 'refused', reason); return fail(reason, message, code); };
|
|
111
|
+
const settle = (project) => { try {
|
|
112
|
+
advanceFlows(store, project, context.clock(), { evidenceRoot: context.evidenceRoot });
|
|
113
|
+
}
|
|
114
|
+
catch { /* the next worker pass retries */ } };
|
|
115
|
+
const preview = (project, title, terms, extra) => ok({ applied: false, repo: project, title, terms, ...extra }, [title, ...terms.map(one => ` ${one.replace(/\n/g, '\n ')}`), '', 'Nothing has changed. Add --yes to make this change.']);
|
|
116
|
+
const readJson = (value, what) => {
|
|
117
|
+
let raw;
|
|
118
|
+
try {
|
|
119
|
+
raw = value.trim().startsWith('{') || value.trim().startsWith('[') ? value : readInput(value);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return { ok: false, message: `The ${what} file couldn't be read.` };
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
return { ok: true, value: JSON.parse(raw) };
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return { ok: false, message: `The ${what} aren't valid JSON.` };
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
const stepsOf = (value) => value !== null && typeof value === 'object' && !Array.isArray(value) && 'steps' in value ? value.steps : value;
|
|
132
|
+
if (action === 'create') {
|
|
133
|
+
const given = text('repo');
|
|
134
|
+
if (given === null)
|
|
135
|
+
return fail('usage', 'Use flows create --repo <path> --name <name> (--template <id> | --steps <file|->).', EXIT.usage);
|
|
136
|
+
const project = projectOf(given);
|
|
137
|
+
if (project === null || !reachable.includes(project))
|
|
138
|
+
return refuse(null, 'unknown-project', 'That isn\'t one of your projects. toolroll repos lists them.');
|
|
139
|
+
const templateId = text('template'), stepsFile = text('steps');
|
|
140
|
+
if ((templateId === null) === (stepsFile === null))
|
|
141
|
+
return fail('usage', 'Give --template or --steps, not both.', EXIT.usage);
|
|
142
|
+
const name = (text('name') ?? '').replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, 80);
|
|
143
|
+
const starter = templateId === null ? null : starterOf(templateId);
|
|
144
|
+
if (starter !== null) {
|
|
145
|
+
// A starter flow: its zones and its trigger, together, under its own name.
|
|
146
|
+
if (name !== '' && name !== starter.name)
|
|
147
|
+
return fail('usage', `A starter flow keeps its name, ${starter.name}.`, EXIT.usage);
|
|
148
|
+
if (starterFlowOf(store, starter, project) !== null)
|
|
149
|
+
return refuse(project, 'already-on', `${starter.name} is already on in ${projectName(project)}.`);
|
|
150
|
+
const blocked = startersFor(store, project).find(one => one.id === starter.id)?.blocked ?? null;
|
|
151
|
+
if (blocked !== null)
|
|
152
|
+
return refuse(project, 'blocked', blocked);
|
|
153
|
+
if (!flags.has('yes'))
|
|
154
|
+
return preview(project, `Switch on ${starter.name} in ${projectName(project)}`, starterTerms(store, starter, project), { starter: starter.id });
|
|
155
|
+
const switched = switchOnStarter(store, starter, project, who, now, context.configDir);
|
|
156
|
+
if (!switched.ok)
|
|
157
|
+
return refuse(project, 'refused', switched.said);
|
|
158
|
+
record(project, 'accepted', `flow #${switched.flow}`);
|
|
159
|
+
return ok({ applied: true, flow: describeFlow(store, store.getFlow(switched.flow)) }, [`${switched.said} Flow #${switched.flow}.`]);
|
|
160
|
+
}
|
|
161
|
+
const template = templateId === null ? null : FLOW_TEMPLATES.find(one => one.id === templateId) ?? null;
|
|
162
|
+
if (templateId !== null && template === null)
|
|
163
|
+
return fail('usage', `Choose a template: ${[...FLOW_TEMPLATES.map(one => one.id), ...STARTER_FLOWS.map(one => one.id)].join(', ')}.`, EXIT.usage);
|
|
164
|
+
let definition;
|
|
165
|
+
if (template !== null)
|
|
166
|
+
definition = structuredClone(template.definition);
|
|
167
|
+
else {
|
|
168
|
+
const input = readJson(stepsFile, 'steps');
|
|
169
|
+
if (!input.ok)
|
|
170
|
+
return refuse(project, 'invalid-steps', input.message);
|
|
171
|
+
try {
|
|
172
|
+
definition = flowFromSteps(stepsFor(stepsOf(input.value), who), null);
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
return refuse(project, 'invalid-steps', error instanceof Error ? error.message : 'Those steps aren\'t a flow.');
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
const flowName = name || template?.label || '';
|
|
179
|
+
if (flowName === '')
|
|
180
|
+
return fail('usage', 'Name the flow with --name.', EXIT.usage);
|
|
181
|
+
const terms = flowTerms(definition, null);
|
|
182
|
+
if (template?.trigger !== undefined) {
|
|
183
|
+
const draft = { id: 0, repo: project, name: flowName, definitionJson: JSON.stringify(definition), revision: 1, state: 'active', createdBy: who, createdAt: now.toISOString(), updatedBy: who, updatedAt: now.toISOString(), owner: who };
|
|
184
|
+
try {
|
|
185
|
+
terms.push(`Starts cards from: ${describeTrigger(validateTriggerConfig(template.trigger, { store, flow: draft, definition, actor: who }), store)}. Only what happens from now on counts.`);
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
return refuse(project, 'invalid-trigger', error instanceof Error ? error.message : 'Its trigger isn\'t valid.');
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (!flags.has('yes'))
|
|
192
|
+
return preview(project, `Create the ${flowName} flow in ${projectName(project)}`, terms, { definition });
|
|
193
|
+
let id;
|
|
194
|
+
try {
|
|
195
|
+
id = store.transact(() => {
|
|
196
|
+
const made = store.createFlow({ repo: project, name: flowName, definitionJson: JSON.stringify(definition), by: who }, now);
|
|
197
|
+
if (template?.trigger !== undefined) {
|
|
198
|
+
const trigger = addFlowTriggerTo(store, store.getFlow(made), template.trigger, who, now, context.configDir);
|
|
199
|
+
if (!trigger.ok)
|
|
200
|
+
throw new Error(trigger.message);
|
|
201
|
+
}
|
|
202
|
+
return made;
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
return refuse(project, 'invalid-trigger', error instanceof Error ? error.message : 'Its trigger isn\'t valid.');
|
|
207
|
+
}
|
|
208
|
+
record(project, 'accepted', `flow #${id}`);
|
|
209
|
+
return ok({ applied: true, flow: describeFlow(store, store.getFlow(id)) }, [`Created ${flowName}, flow #${id}. Add cards with toolroll flows card add ${id} --title "…".`]);
|
|
210
|
+
}
|
|
211
|
+
if (action === 'script save') {
|
|
212
|
+
const given = text('repo');
|
|
213
|
+
if (given === null || text('name') === null)
|
|
214
|
+
return fail('usage', 'Use flows script save --repo <path> --name <name> (--file <path in project> | --body <file>) --about "<one line>".', EXIT.usage);
|
|
215
|
+
const project = projectOf(given);
|
|
216
|
+
if (project === null || !reachable.includes(project))
|
|
217
|
+
return refuse(null, 'unknown-project', 'That isn\'t one of your projects. toolroll repos lists them.');
|
|
218
|
+
let body;
|
|
219
|
+
const bodyFile = text('body');
|
|
220
|
+
if (bodyFile !== null) {
|
|
221
|
+
try {
|
|
222
|
+
body = readInput(bodyFile);
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
return refuse(project, 'unreadable', 'The --body file couldn\'t be read.');
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const saved = saveScript(store, project, { name: text('name'), about: text('about') ?? undefined, body, file: text('file') ?? undefined, language: text('language') ?? undefined, timeoutMinutes: text('timeout-minutes') ?? undefined }, who, now);
|
|
229
|
+
if (!saved.ok)
|
|
230
|
+
return refuse(project, 'invalid-script', saved.message);
|
|
231
|
+
record(project, 'accepted', `script ${String(text('name')).trim().toLowerCase()} v${saved.version}`);
|
|
232
|
+
settle(project);
|
|
233
|
+
return ok({ applied: true, repo: project, script: store.flowScript(project, String(text('name')).trim().toLowerCase()), said: saved.said }, [saved.said]);
|
|
234
|
+
}
|
|
235
|
+
// Everything else names a flow in one of this person's projects.
|
|
236
|
+
const flow = flowOf(args[0], reachable);
|
|
237
|
+
if (flow === null)
|
|
238
|
+
return refuse(null, 'unknown-flow', 'No such flow in your projects. toolroll flows list names them.');
|
|
239
|
+
const definition = flowDefinitionOf(flow);
|
|
240
|
+
if (action === 'archive') {
|
|
241
|
+
const cards = store.flowCards(flow.id, false).length, triggers = store.flowTriggers(flow.id).filter(one => one.state === 'active').length;
|
|
242
|
+
if (!flags.has('yes'))
|
|
243
|
+
return preview(flow.repo, `Archive the ${flow.name} flow`, [
|
|
244
|
+
`It leaves the flows list. ${cards === 0 ? 'No cards are in it.' : `Its ${cards} active card${cards === 1 ? '' : 's'} stop moving.`}${triggers === 0 ? '' : ` Its ${triggers} trigger${triggers === 1 ? '' : 's'} stop adding cards.`}`,
|
|
245
|
+
'Tasks its cards filed stay as they are.',
|
|
246
|
+
], { flowId: flow.id });
|
|
247
|
+
if (!store.archiveFlow(flow.id, who, now))
|
|
248
|
+
return refuse(flow.repo, 'stale', 'Someone archived it just now.');
|
|
249
|
+
record(flow.repo, 'accepted', `flow #${flow.id}`);
|
|
250
|
+
return ok({ applied: true, flowId: flow.id }, [`Archived ${flow.name}.`]);
|
|
251
|
+
}
|
|
252
|
+
if (definition === null)
|
|
253
|
+
return refuse(flow.repo, 'unreadable', 'This flow\'s drawing can\'t be read. Save it again from the editor.');
|
|
254
|
+
if (action === 'edit') {
|
|
255
|
+
const stepsFile = text('steps');
|
|
256
|
+
if (stepsFile === null && text('name') === null)
|
|
257
|
+
return fail('usage', 'Use flows edit <flow> --steps <file|-> [--name <name>].', EXIT.usage);
|
|
258
|
+
let next = definition;
|
|
259
|
+
if (stepsFile !== null) {
|
|
260
|
+
const input = readJson(stepsFile, 'steps');
|
|
261
|
+
if (!input.ok)
|
|
262
|
+
return refuse(flow.repo, 'invalid-steps', input.message);
|
|
263
|
+
try {
|
|
264
|
+
next = flowFromSteps(stepsFor(stepsOf(input.value), who), definition);
|
|
265
|
+
}
|
|
266
|
+
catch (error) {
|
|
267
|
+
return refuse(flow.repo, 'invalid-steps', error instanceof Error ? error.message : 'Those steps aren\'t a flow.');
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
const name = (text('name') ?? flow.name).replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, 80) || flow.name;
|
|
271
|
+
const redrawn = flowDigest(next) !== flowDigest(definition);
|
|
272
|
+
if (!redrawn && name === flow.name)
|
|
273
|
+
return fail('no-change', 'That\'s the flow as it is now.');
|
|
274
|
+
if (!flags.has('yes'))
|
|
275
|
+
return preview(flow.repo, `Change the ${flow.name} flow`, [...(name === flow.name ? [] : [`Renames it to ${name}.`]), ...(redrawn ? flowTerms(next, definition) : [])], { flowId: flow.id, revision: flow.revision, definition: next });
|
|
276
|
+
if (!store.saveFlow(flow.id, { name, definitionJson: JSON.stringify(next), sawRevision: flow.revision, by: who }, now))
|
|
277
|
+
return refuse(flow.repo, 'stale', 'Someone else changed this flow. Look again, then make yours.');
|
|
278
|
+
record(flow.repo, 'accepted', `flow #${flow.id}`);
|
|
279
|
+
settle(flow.repo);
|
|
280
|
+
return ok({ applied: true, flow: describeFlow(store, store.getFlow(flow.id)) }, [`Saved ${name}.`]);
|
|
281
|
+
}
|
|
282
|
+
if (action === 'card add') {
|
|
283
|
+
const title = text('title');
|
|
284
|
+
if (title === null)
|
|
285
|
+
return fail('usage', 'Use flows card add <flow> --title "<title>" [--description "<details>"] [--zone <zone>].', EXIT.usage);
|
|
286
|
+
const named = text('zone');
|
|
287
|
+
const stage = named === null ? null : definition.stages.find(one => one.id === named.trim() || one.title.toLowerCase() === named.trim().toLowerCase()) ?? null;
|
|
288
|
+
if (named !== null && stage === null)
|
|
289
|
+
return refuse(flow.repo, 'unknown-zone', `This flow has no zone called ${named}.`);
|
|
290
|
+
const added = addCardToFlow(store, flow, { title, description: text('description'), stage: stage?.id ?? null }, who, now);
|
|
291
|
+
if (!added.ok)
|
|
292
|
+
return refuse(flow.repo, 'invalid-card', added.message);
|
|
293
|
+
record(flow.repo, 'accepted', `flow #${flow.id} card #${added.card}`);
|
|
294
|
+
settle(flow.repo);
|
|
295
|
+
const card = store.getFlowCard(added.card);
|
|
296
|
+
return ok({ applied: true, card: card === null ? { id: added.card } : cardOf(definition, card), said: added.said }, [`${added.said} Card #${added.card}.`]);
|
|
297
|
+
}
|
|
298
|
+
if (action === 'trigger add') {
|
|
299
|
+
if (args[1] === undefined)
|
|
300
|
+
return fail('usage', 'Use flows trigger add <flow> <json|file|->, like \'{"kind":"schedule","schedule":"daily 09:00","title":"Standup"}\'.', EXIT.usage);
|
|
301
|
+
const input = readJson(args[1], 'trigger settings');
|
|
302
|
+
if (!input.ok)
|
|
303
|
+
return refuse(flow.repo, 'invalid-trigger', input.message);
|
|
304
|
+
let config;
|
|
305
|
+
try {
|
|
306
|
+
config = validateTriggerConfig(input.value, { store, flow, definition, actor: who });
|
|
307
|
+
}
|
|
308
|
+
catch (error) {
|
|
309
|
+
return refuse(flow.repo, 'invalid-trigger', error instanceof Error ? error.message : 'That trigger isn\'t valid.');
|
|
310
|
+
}
|
|
311
|
+
const words = describeTrigger(config, store);
|
|
312
|
+
if (!flags.has('yes')) {
|
|
313
|
+
const zone = definition.stages.find(one => one.id === (config.zone ?? definition.start))?.title ?? 'the first zone';
|
|
314
|
+
return preview(flow.repo, `Add a trigger to ${flow.name}`, [words, `Cards start in ${zone}.`, 'Work a card starts still waits for your usual approvals.'], { flowId: flow.id, trigger: config });
|
|
315
|
+
}
|
|
316
|
+
const made = addFlowTriggerTo(store, flow, input.value, who, now, context.configDir);
|
|
317
|
+
if (!made.ok)
|
|
318
|
+
return refuse(flow.repo, 'invalid-trigger', made.message);
|
|
319
|
+
record(flow.repo, 'accepted', `flow #${flow.id} trigger #${made.id}`);
|
|
320
|
+
settle(flow.repo);
|
|
321
|
+
// A webhook's address (and GitHub's signing secret) is shown this once, never stored readable.
|
|
322
|
+
return ok({ applied: true, triggerId: made.id, said: made.said, ...(made.reveal === null ? {} : { reveal: made.reveal }) }, [
|
|
323
|
+
`${made.said} Trigger #${made.id}: ${words}`,
|
|
324
|
+
...(made.reveal === null ? [] : [` address: ${made.reveal.address ?? `<your public address>${made.reveal.path}`}`, ...(made.reveal.secret === null ? [] : [` signing secret: ${made.reveal.secret}`])]),
|
|
325
|
+
]);
|
|
326
|
+
}
|
|
327
|
+
// trigger pause | resume | remove | check <flow> <trigger>
|
|
328
|
+
const trigger = args[1] !== undefined && /^[1-9][0-9]{0,9}$/.test(args[1]) ? store.getFlowTrigger(Number(args[1])) : null;
|
|
329
|
+
if (trigger === null || trigger.flow !== flow.id || trigger.state === 'removed')
|
|
330
|
+
return refuse(flow.repo, 'unknown-trigger', 'That trigger isn\'t on this flow. toolroll flows show <flow> names them.');
|
|
331
|
+
if (action === 'trigger check') {
|
|
332
|
+
const checked = await checkFlowTriggerNow(store, trigger, now, context.triggerIo);
|
|
333
|
+
record(flow.repo, checked.ok ? 'accepted' : 'refused', `flow #${flow.id} trigger #${trigger.id}`);
|
|
334
|
+
settle(flow.repo);
|
|
335
|
+
return checked.ok ? ok({ applied: true, triggerId: trigger.id, said: checked.said }, [checked.said]) : fail('check-failed', checked.said);
|
|
336
|
+
}
|
|
337
|
+
if (action === 'trigger remove')
|
|
338
|
+
removeFlowTrigger(store, trigger, now, context.configDir);
|
|
339
|
+
else
|
|
340
|
+
store.updateFlowTrigger(trigger.id, { state: action === 'trigger pause' ? 'paused' : 'active' }, now);
|
|
341
|
+
record(flow.repo, 'accepted', `flow #${flow.id} trigger #${trigger.id}`);
|
|
342
|
+
settle(flow.repo);
|
|
343
|
+
const said = action === 'trigger remove' ? 'Trigger removed.' : action === 'trigger pause' ? 'Trigger paused.' : 'Trigger on again.';
|
|
344
|
+
return ok({ applied: true, triggerId: trigger.id, said }, [said]);
|
|
345
|
+
}
|
|
346
|
+
function listed(store, flow) {
|
|
347
|
+
const definition = flowDefinitionOf(flow);
|
|
348
|
+
const cards = store.flowCards(flow.id, false);
|
|
349
|
+
return { id: flow.id, name: flow.name, repo: flow.repo, project: projectName(flow.repo), zones: definition?.stages.map(one => one.title) ?? [],
|
|
350
|
+
triggers: store.flowTriggers(flow.id).filter(one => one.state === 'active').length, cardsWaiting: cards.length,
|
|
351
|
+
needDecision: cards.filter(card => definition?.stages.find(one => one.id === card.stage)?.kind === 'approval').length };
|
|
352
|
+
}
|
|
353
|
+
/** One zone and every path out of it, by zone id. */
|
|
354
|
+
function zoneOf(stage, flow) {
|
|
355
|
+
return {
|
|
356
|
+
id: stage.id, title: stage.title, kind: stage.kind, does: FLOW_KIND_WORDS[stage.kind].label,
|
|
357
|
+
next: stage.next, ifFails: stage.onFail,
|
|
358
|
+
...(stage.sort === null ? {} : { answers: stage.sort.answers.map(one => ({ answer: one.answer, to: one.to })), sureAt: stage.sort.sureAt }),
|
|
359
|
+
...(stage.routes === undefined || stage.routes.length === 0 ? {} : { routes: stage.routes.map(one => ({ answer: one.answer, to: one.to })) }),
|
|
360
|
+
...(stage.limit === undefined ? {} : { remindAfterMinutes: stage.limit.minutes, thenMoveTo: stage.limit.to }),
|
|
361
|
+
...(stage.kind === 'approval' ? { decider: deciderOf(stage, flow) } : {}),
|
|
362
|
+
...(stage.script === null ? {} : { script: stage.script }),
|
|
363
|
+
...(stage.merge === undefined ? {} : { merge: stage.merge }),
|
|
364
|
+
...(stage.teammate === undefined ? {} : { teammate: stage.teammate }),
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
function cardOf(definition, card) {
|
|
368
|
+
return { id: card.id, title: card.title, zone: card.stage, zoneTitle: definition?.stages.find(one => one.id === card.stage)?.title ?? card.stage, state: card.state, waiting: card.waiting, task: card.task ?? card.primaryTask, updatedAt: card.updatedAt };
|
|
369
|
+
}
|
|
370
|
+
function describeFlow(store, flow) {
|
|
371
|
+
const definition = flowDefinitionOf(flow);
|
|
372
|
+
return {
|
|
373
|
+
id: flow.id, name: flow.name, repo: flow.repo, project: projectName(flow.repo), owner: flow.owner, revision: flow.revision, readable: definition !== null,
|
|
374
|
+
start: definition?.start ?? null,
|
|
375
|
+
zones: definition?.stages.map(stage => zoneOf(stage, flow)) ?? [],
|
|
376
|
+
triggers: store.flowTriggers(flow.id).map(trigger => {
|
|
377
|
+
const config = triggerConfigOf(trigger);
|
|
378
|
+
return { id: trigger.id, kind: trigger.kind, state: trigger.state, words: config === null ? 'This trigger can\'t be read.' : describeTrigger(config, store),
|
|
379
|
+
zone: config?.zone ?? definition?.start ?? null, nextAt: trigger.nextAt, lastAt: trigger.lastAt, lastOutcome: trigger.lastOutcome, failing: trigger.failures > 0 };
|
|
380
|
+
}),
|
|
381
|
+
cards: store.flowCards(flow.id, true).slice(0, 10).map(card => cardOf(definition, card)),
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
function showLines(flow) {
|
|
385
|
+
const title = (id) => id === null ? null : flow.zones.find(one => one.id === id)?.title ?? id;
|
|
386
|
+
if (!flow.readable)
|
|
387
|
+
return [`#${flow.id} ${flow.name} · ${flow.project}`, 'This flow\'s drawing can\'t be read. Save it again from the editor.'];
|
|
388
|
+
return [
|
|
389
|
+
`#${flow.id} ${flow.name} · ${flow.project} · owner ${flow.owner}`,
|
|
390
|
+
'', 'Zones',
|
|
391
|
+
...flow.zones.flatMap((zone, index) => [
|
|
392
|
+
` ${index + 1}. ${zone.title} (${zone.id}) — ${zone.does}${zone.id === flow.start ? ' · start' : ''}`,
|
|
393
|
+
...('answers' in zone && zone.answers !== undefined ? zone.answers.map(one => ` ${one.answer} → ${title(one.to)}`) : []),
|
|
394
|
+
...('routes' in zone && zone.routes !== undefined ? zone.routes.map(one => ` ${one.answer} → ${title(one.to)}`) : []),
|
|
395
|
+
...(zone.next === null ? [] : [` next → ${title(zone.next)}`]),
|
|
396
|
+
...(zone.ifFails === null ? [] : [` ${zone.kind === 'approval' ? 'sent back' : zone.kind === 'sort' ? 'not sure' : zone.kind === 'wait' ? 'no reply' : 'if it fails'} → ${title(zone.ifFails)}`]),
|
|
397
|
+
]),
|
|
398
|
+
'', 'Triggers',
|
|
399
|
+
...(flow.triggers.length === 0 ? [' none — add one with toolroll flows trigger add'] : flow.triggers.map(one => ` #${one.id} ${one.state === 'active' ? '' : `(${one.state}) `}${one.words} → ${title(one.zone)}${one.lastOutcome === null ? '' : ` · last: ${one.lastOutcome}`}`)),
|
|
400
|
+
'', 'Recent cards',
|
|
401
|
+
...(flow.cards.length === 0 ? [' none'] : flow.cards.map(one => ` #${one.id} ${one.title} · ${one.state === 'active' ? one.zoneTitle : one.state}${one.waiting === null ? '' : ` · ${one.waiting}`}`)),
|
|
402
|
+
];
|
|
403
|
+
}
|
package/dist/flows-ui.js
CHANGED
|
@@ -130,7 +130,7 @@ export function flowView(store, flow, viewer, selectedCard, setup = { dir: null,
|
|
|
130
130
|
zone: title(config?.zone ?? definition?.start ?? ""), zoneId: config?.zone ?? definition?.start ?? "", state: trigger.state, status: trigger.lastOutcome, statusAt: trigger.lastAt, failing: trigger.failures > 0,
|
|
131
131
|
button: config?.kind === "button" ? { label: config.label, questions: config.questions } : null,
|
|
132
132
|
hook: config !== null && takesDeliveries(config) ? { ready: hookReady(trigger, setup.dir), needsSecret: config.kind === "linear" } : null,
|
|
133
|
-
checkable: ((config?.kind === "github" || config?.kind === "linear") && config.delivery === "poll") || config?.kind === "email" || (config?.kind === "schedule" && config.script !== undefined),
|
|
133
|
+
checkable: ((config?.kind === "github" || config?.kind === "linear") && config.delivery === "poll") || config?.kind === "email" || (config?.kind === "schedule" && config.script !== undefined) || config?.kind === "plane-review",
|
|
134
134
|
shared: config?.kind === "button" && trigger.hookHash !== null,
|
|
135
135
|
};
|
|
136
136
|
});
|
package/dist/flows.d.ts
CHANGED
|
@@ -150,18 +150,36 @@ export declare function deciderOf(stage: Pick<FlowStage, "approver" | "toOwner">
|
|
|
150
150
|
owner: string;
|
|
151
151
|
}): string | null;
|
|
152
152
|
/** A flow as drawn on the canvas, checked whole. Throws in plain words. */
|
|
153
|
-
|
|
153
|
+
/** `stored`: reading a flow saved earlier, which keeps working under rules added since (its cards say what's wrong). */
|
|
154
|
+
export declare function validateFlowDefinition(input: unknown, options?: {
|
|
155
|
+
stored?: boolean;
|
|
156
|
+
}): FlowDefinition;
|
|
154
157
|
/** The zones a card can reach from `start` without passing through a zone `stop` picks (those zones aren't included). */
|
|
155
158
|
export declare function reachableWithout(stages: readonly FlowStage[], start: string, stop: (stage: FlowStage) => boolean): Set<string>;
|
|
156
159
|
/** What a card's work is held to: the zones' steps and paths, never where they sit on the canvas. */
|
|
157
160
|
export declare function flowDigest(definition: FlowDefinition): string;
|
|
158
|
-
|
|
159
|
-
export declare function fillFlowText(template: string, card: {
|
|
161
|
+
type FlowFillCard = {
|
|
160
162
|
title: string;
|
|
161
163
|
description: string | null;
|
|
162
164
|
note: string | null;
|
|
163
165
|
outputs: Record<string, string>;
|
|
164
|
-
}
|
|
166
|
+
};
|
|
167
|
+
/** Fill a zone's text from the card: title, description, the latest note and earlier zones' reports. */
|
|
168
|
+
export declare function fillFlowText(template: string, card: FlowFillCard, encode?: (value: string) => string): string;
|
|
169
|
+
/** The task door's goal limit (task-text.ts). A work zone's goal is kept under it. */
|
|
170
|
+
export declare const FLOW_GOAL_LIMIT = 2000;
|
|
171
|
+
/** What stands in for the middle of a filled-in value the goal had no room for. */
|
|
172
|
+
export declare const FLOW_CUT = "\u2026 (cut; the full text is on the card)";
|
|
173
|
+
/** A work zone's words, plus the card and any send-back note when the words leave them out. "Fix the bug on the card"
|
|
174
|
+
* says nothing on its own (found in the real e2e: the builder saw only the branch name). */
|
|
175
|
+
export declare function flowWorkTemplate(instructions: string, card: {
|
|
176
|
+
description: string | null;
|
|
177
|
+
note: string | null;
|
|
178
|
+
}): string;
|
|
179
|
+
/** A work zone's goal, filled from the card and kept under FLOW_GOAL_LIMIT: the zone's own words stay whole, and only the
|
|
180
|
+
* filled-in values are shortened (the longest first), each keeping its start and end. Null when the words alone leave no
|
|
181
|
+
* room, which a saved flow can't have (validateFlowDefinition). */
|
|
182
|
+
export declare function fitFlowText(template: string, card: FlowFillCard, limit?: number): string | null;
|
|
165
183
|
/** The first email address a card mentions, or "" — what {{card.email}} holds. */
|
|
166
184
|
export declare function cardEmailOf(card: {
|
|
167
185
|
title: string;
|
|
@@ -231,6 +249,8 @@ export type FlowStepInput = {
|
|
|
231
249
|
};
|
|
232
250
|
/** What a draft zone asks for when the steps leave it out. */
|
|
233
251
|
export declare const DRAFT_DEFAULT = "Write a short, friendly reply to the person who sent this card, in plain words.";
|
|
252
|
+
/** Steps as the lead's flow tool and `toolroll flows` take them: "me" as a decider is the person asking, stored by name. */
|
|
253
|
+
export declare function stepsFor(steps: unknown, name: string): unknown;
|
|
234
254
|
/**
|
|
235
255
|
* A flow from an ordered list of steps: ids from names, each step leading
|
|
236
256
|
* to the next, a Done zone at the end when none is listed, and a decision
|
|
@@ -251,3 +271,4 @@ export declare const FLOW_TEMPLATES: readonly {
|
|
|
251
271
|
definition: FlowDefinition;
|
|
252
272
|
trigger?: Record<string, unknown>;
|
|
253
273
|
}[];
|
|
274
|
+
export {};
|
package/dist/flows.js
CHANGED
|
@@ -273,7 +273,8 @@ export function deciderOf(stage, flow) {
|
|
|
273
273
|
return stage.toOwner === true ? flow.owner : stage.approver;
|
|
274
274
|
}
|
|
275
275
|
/** A flow as drawn on the canvas, checked whole. Throws in plain words. */
|
|
276
|
-
|
|
276
|
+
/** `stored`: reading a flow saved earlier, which keeps working under rules added since (its cards say what's wrong). */
|
|
277
|
+
export function validateFlowDefinition(input, options = {}) {
|
|
277
278
|
const raw = input;
|
|
278
279
|
if (raw === null || typeof raw !== "object" || !Array.isArray(raw.stages))
|
|
279
280
|
throw new Error("A flow is a list of zones.");
|
|
@@ -336,6 +337,11 @@ export function validateFlowDefinition(input) {
|
|
|
336
337
|
throw new Error(`Zone ${stage.title}: an answer can't send cards back into the same zone.`);
|
|
337
338
|
if ((stage.kind === "task" || stage.kind === "report") && stage.instructions === null)
|
|
338
339
|
throw new Error(`Zone ${stage.title}: say what the agent should do.`);
|
|
340
|
+
// A work zone's goal must fit a task: its own words whole, with room left for the card's details. Refused here, when the flow
|
|
341
|
+
// is saved, never when a card arrives. A flow saved before this rule is still read, and its card says why it can't file.
|
|
342
|
+
const over = !options.stored && (stage.kind === "task" || stage.kind === "report") && stage.instructions !== null ? flowGoalOverflow(stage.instructions) : 0;
|
|
343
|
+
if (over > 0)
|
|
344
|
+
throw new Error(`Zone ${stage.title}: its instructions leave no room for the card's details. Shorten them by ${over} characters.`);
|
|
339
345
|
if (stage.kind === "draft" && stage.instructions === null)
|
|
340
346
|
throw new Error(`Zone ${stage.title}: say what Claude should write.`);
|
|
341
347
|
if (stage.kind === "notify" && stage.message === null)
|
|
@@ -434,9 +440,72 @@ export function flowDigest(definition) {
|
|
|
434
440
|
const terms = definition.stages.map(({ zone: _zone, ...rest }) => rest);
|
|
435
441
|
return createHash("sha256").update(JSON.stringify({ start: definition.start, terms })).digest("hex").slice(0, 32);
|
|
436
442
|
}
|
|
443
|
+
const FILLED = /\{\{\s*(card\.title|card\.description|card\.email|note|stage\.([a-z0-9-]+))\s*\}\}/g;
|
|
437
444
|
/** Fill a zone's text from the card: title, description, the latest note and earlier zones' reports. */
|
|
438
445
|
export function fillFlowText(template, card, encode = value => value) {
|
|
439
|
-
return template.replace(
|
|
446
|
+
return template.replace(FILLED, (_match, key, stage) => encode(fillOne(key, stage, card))).trim();
|
|
447
|
+
}
|
|
448
|
+
/** The task door's goal limit (task-text.ts). A work zone's goal is kept under it. */
|
|
449
|
+
export const FLOW_GOAL_LIMIT = 2000;
|
|
450
|
+
/** What stands in for the middle of a filled-in value the goal had no room for. */
|
|
451
|
+
export const FLOW_CUT = "… (cut; the full text is on the card)";
|
|
452
|
+
/** The least room each filled-in value keeps: the cut mark and 20 characters from each end. */
|
|
453
|
+
const VALUE_ROOM = FLOW_CUT.length + 40;
|
|
454
|
+
/** A work zone's words, plus the card and any send-back note when the words leave them out. "Fix the bug on the card"
|
|
455
|
+
* says nothing on its own (found in the real e2e: the builder saw only the branch name). */
|
|
456
|
+
export function flowWorkTemplate(instructions, card) {
|
|
457
|
+
const names = (key) => new RegExp(`\\{\\{\\s*${key.replace(".", "\\.")}\\s*\\}\\}`).test(instructions);
|
|
458
|
+
const extra = [
|
|
459
|
+
...(names("card.title") || names("card.description") ? [] : [`The card: {{card.title}}${card.description ? "\n\n{{card.description}}" : ""}`]),
|
|
460
|
+
...(names("card.title") && !names("card.description") && card.description ? ["Details on the card:\n{{card.description}}"] : []),
|
|
461
|
+
...(names("note") || !card.note ? [] : ["Changes asked for: {{note}}"]),
|
|
462
|
+
];
|
|
463
|
+
return [instructions, ...extra].join("\n\n");
|
|
464
|
+
}
|
|
465
|
+
/** Characters a work zone's instructions leave no room for, with every filled-in value kept to its least room. 0 when they fit. */
|
|
466
|
+
function flowGoalOverflow(instructions) {
|
|
467
|
+
const widest = flowWorkTemplate(instructions, { description: "x", note: "x" });
|
|
468
|
+
const values = [...widest.matchAll(FILLED)].length;
|
|
469
|
+
return Math.max(0, widest.replace(FILLED, "").length + values * VALUE_ROOM - (FLOW_GOAL_LIMIT - 1));
|
|
470
|
+
}
|
|
471
|
+
/** Script output can carry carriage returns, colour codes and hidden marks a task goal refuses; the goal drops them. */
|
|
472
|
+
const goalSafe = (value) => value.replace(/\r\n?/g, "\n").replace(/[\u0000-\u0008\u000B-\u001F\u007F-\u009F\u2028\u2029\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]/g, "");
|
|
473
|
+
/** A value kept to `room` characters: its start and end, with the cut mark between. */
|
|
474
|
+
function cutToRoom(value, room) {
|
|
475
|
+
if (value.length <= room)
|
|
476
|
+
return value;
|
|
477
|
+
const keep = room - FLOW_CUT.length;
|
|
478
|
+
let head = Math.ceil(keep / 2), from = value.length - (keep - head);
|
|
479
|
+
if (/[\uD800-\uDBFF]/.test(value[head - 1] ?? ""))
|
|
480
|
+
head--;
|
|
481
|
+
if (/[\uDC00-\uDFFF]/.test(value[from] ?? ""))
|
|
482
|
+
from++;
|
|
483
|
+
return value.slice(0, head) + FLOW_CUT + value.slice(from);
|
|
484
|
+
}
|
|
485
|
+
/** A work zone's goal, filled from the card and kept under FLOW_GOAL_LIMIT: the zone's own words stay whole, and only the
|
|
486
|
+
* filled-in values are shortened (the longest first), each keeping its start and end. Null when the words alone leave no
|
|
487
|
+
* room, which a saved flow can't have (validateFlowDefinition). */
|
|
488
|
+
export function fitFlowText(template, card, limit = FLOW_GOAL_LIMIT) {
|
|
489
|
+
const values = [...template.matchAll(FILLED)].map(match => goalSafe(fillOne(match[1], match[2], card)));
|
|
490
|
+
const room = limit - 1 - template.replace(FILLED, "").length;
|
|
491
|
+
let each = Math.max(0, ...values.map(one => one.length));
|
|
492
|
+
const used = (cap) => values.reduce((sum, one) => sum + Math.min(one.length, cap), 0);
|
|
493
|
+
if (used(each) > room) {
|
|
494
|
+
// The widest cap every value can share: the shortest values stay whole, the longest are cut to it.
|
|
495
|
+
let low = 0, high = each;
|
|
496
|
+
while (low < high) {
|
|
497
|
+
const mid = Math.ceil((low + high) / 2);
|
|
498
|
+
if (used(mid) <= room)
|
|
499
|
+
low = mid;
|
|
500
|
+
else
|
|
501
|
+
high = mid - 1;
|
|
502
|
+
}
|
|
503
|
+
each = low;
|
|
504
|
+
if (each < FLOW_CUT.length + 2)
|
|
505
|
+
return null;
|
|
506
|
+
}
|
|
507
|
+
let at = 0;
|
|
508
|
+
return template.replace(FILLED, () => cutToRoom(values[at++], each)).trim();
|
|
440
509
|
}
|
|
441
510
|
/** The first email address a card mentions, or "" — what {{card.email}} holds. */
|
|
442
511
|
export function cardEmailOf(card) {
|
|
@@ -493,6 +562,10 @@ function defaultInstructions(kind, earlier) {
|
|
|
493
562
|
? `{{card.title}}\n\n{{card.description}}${notes}\n\nChanges asked for (if any): {{note}}`
|
|
494
563
|
: `Investigate this and write a short, clear report with a summary first: {{card.title}}\n\n{{card.description}}${notes}\n\nFeedback to address (if any): {{note}}`;
|
|
495
564
|
}
|
|
565
|
+
/** Steps as the lead's flow tool and `toolroll flows` take them: "me" as a decider is the person asking, stored by name. */
|
|
566
|
+
export function stepsFor(steps, name) {
|
|
567
|
+
return Array.isArray(steps) ? steps.map(step => step !== null && typeof step === "object" && typeof step.decider === "string" && /^(me|myself|i|you|the operator)$/i.test(step.decider.trim()) ? { ...step, decider: name } : step) : steps;
|
|
568
|
+
}
|
|
496
569
|
/**
|
|
497
570
|
* A flow from an ordered list of steps: ids from names, each step leading
|
|
498
571
|
* to the next, a Done zone at the end when none is listed, and a decision
|
package/dist/guides.js
CHANGED
|
@@ -79,6 +79,29 @@ ${AUTHORITY_LINE}
|
|
|
79
79
|
detail says why; \`toolroll sync\` refreshes trackers.
|
|
80
80
|
- \`contest-open\`: a tournament is running on the task; a person picks.
|
|
81
81
|
|
|
82
|
+
## Flows
|
|
83
|
+
|
|
84
|
+
A flow is a process cards move through, drawn as zones. The terminal
|
|
85
|
+
applies the console's rules exactly.
|
|
86
|
+
|
|
87
|
+
- Read: \`toolroll flows list [--repo PATH] --json\` (id, name, project,
|
|
88
|
+
zones, triggers, cards waiting) and \`flows show <id> --json\` (each
|
|
89
|
+
zone's \`next\` and \`ifFails\` paths, triggers, recent cards).
|
|
90
|
+
- Change, as an approver (\`--as <you> --token <t>\`, or the remembered
|
|
91
|
+
login): \`flows create --repo PATH --name <n> (--template <id> |
|
|
92
|
+
--steps <file|->)\` — steps in the lead's flow-tool format, in order;
|
|
93
|
+
\`flows edit <id> --steps <file|->\`; \`flows trigger add <id> <json|file>\`;
|
|
94
|
+
\`flows trigger pause|resume|remove|check <id> <trigger>\`; \`flows script
|
|
95
|
+
save --repo PATH --name <n> (--file <path in project> | --body <file>)
|
|
96
|
+
--about "<line>"\`; \`flows card add <id> --title <t> [--zone <z>]\`;
|
|
97
|
+
\`flows archive <id>\`.
|
|
98
|
+
- create, edit, archive and trigger add answer with a preview
|
|
99
|
+
(\`applied: false\`, \`terms\`) until \`--yes\`. Show the person the terms
|
|
100
|
+
and run \`--yes\` only when they asked for exactly that change.
|
|
101
|
+
- Refusals: \`unknown-project\`, \`unknown-flow\`, \`invalid-steps\` (such as a
|
|
102
|
+
pull request that merges without a decision before it), \`invalid-trigger\`,
|
|
103
|
+
\`invalid-script\`, \`unauthenticated\`, \`stale\`.
|
|
104
|
+
|
|
82
105
|
## What you may never do
|
|
83
106
|
|
|
84
107
|
- Never approve scopes, answer decisions, or acquire approver tokens —
|
package/dist/keys.js
CHANGED
|
@@ -87,7 +87,10 @@ export function readAuthModeStrict(provider, home = homedir()) {
|
|
|
87
87
|
raw = readFileSync(authModeFileFor(provider, home), "utf8");
|
|
88
88
|
}
|
|
89
89
|
catch (error) {
|
|
90
|
-
|
|
90
|
+
// Inside the agents' fence the keys folder is out of reach: Linux masks it (the file reads as absent), and macOS
|
|
91
|
+
// refuses it (EPERM), which reads the same way — a Toolroll a flow's check zone starts has only its CLIs' own sign-ins.
|
|
92
|
+
// Any other unreadable file (EACCES: its permissions) is still a stated problem.
|
|
93
|
+
if (error.code === "ENOENT" || error.code === "EPERM")
|
|
91
94
|
return { ok: true, mode: DEFAULT_AUTH_MODE[provider] };
|
|
92
95
|
return { ok: false, problem: `the ${provider} auth-mode file cannot be read (${error.code ?? "error"}) — run \`keys auth ${provider} subscription|api-key\` to restate it` };
|
|
93
96
|
}
|