@rfany/rf 0.3.0 → 0.4.0

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/rf.mjs +97 -9
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rfany/rf",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "rollforward's command line for people: what its page does, from a shell or a desktop agent, as you",
5
5
  "license": "MIT",
6
6
  "repository": { "type": "git", "url": "git+https://github.com/spoj/rollforward.git", "directory": "cli" },
package/rf.mjs CHANGED
@@ -17,6 +17,14 @@ rf wake PATH AT REASON [--label LABEL] AT now, +<n>m|h|d or a date; PATH an op
17
17
  rf agents [WORKSPACE] [--set FILE|-] the company's AGENTS.md, or a workspace's; setting the company's is for admins
18
18
  rf skills
19
19
  rf websites
20
+ rf status [--long MIN] [--json] what goes on and what's stuck: runs going, flagged past MIN minutes (30), and queued, with why;
21
+ what waits on whom since when; open instances whose last run failed
22
+ rf runs [PATH] [--since DATE] [--failed] [--running] [--long MIN] [--json]
23
+ a line per run: start, path, run, outcome, time, cost, what started it, summary or error
24
+ rf costs [PATH] [--since DATE] [--by workspace|series|person|day] [--json] totals, runs and cost per run; person is the runner
25
+ rf people WORKSPACE [--set FILE|-] [-n] [--force] its owners and admins: its people as lines of owner|member EMAIL-OR-GROUP,
26
+ each with a comment; --set replaces them, -n shows the changes, --force hides open instances
27
+ from their runners
20
28
 
21
29
  Publishers and admins:
22
30
  rf skill push DIR DIR/SKILL.md and the rest become the newest version of the skill named DIR's name, live at once
@@ -24,8 +32,9 @@ rf skill versions ID
24
32
  rf skill rollback ID N pushes version N again, as the newest
25
33
 
26
34
  Admins:
27
- rf users
28
- rf user add EMAIL [admin|publisher] | role EMAIL admin|publisher|user | disable EMAIL | enable EMAIL | signout EMAIL | remove EMAIL
35
+ rf people [--set FILE|-] [-n] [--force] everyone as lines of ROLE EMAIL, each with a comment; --set sets the roles of the people
36
+ listed and adds new ones, removing no one
37
+ rf user add EMAIL [admin|publisher] | role EMAIL admin|publisher|user [--force] | disable EMAIL | enable EMAIL | signout EMAIL | remove EMAIL
29
38
  rf website set ID URL [--name NAME] [--hosts HOST,...] a website people sign in to at URL, and the other hosts its pages need,
30
39
  such as sso.example.com or *.example.com; a run's browser on it reaches only those
31
40
  rf website remove ID`;
@@ -35,7 +44,7 @@ that does each period's work and people who check it. rf does what its page does
35
44
 
36
45
  What there is
37
46
  - A workspace holds series, and its people. A series is one recurring job; an instance is one period of it, labelled such as
38
- 2026-08-23. PATH is workspace/series/label, as rf ls prints it; for rf pull it may go deeper, into an instance's files.
47
+ 2026-08-23. A task is a one-off: a series of one instance, named by its label, until someone makes it a series. PATH is workspace/series/label, as rf ls prints it; for rf pull it may go deeper, into an instance's files.
39
48
  - An instance's screen is its items: inputs, steps and outputs, each pointing at a file, with what it's made from. People upload
40
49
  files and comment; each comment or upload starts a run of the agent, which reads it and carries on. The runner is the person whose
41
50
  logins the runs use, and who closes the instance once they stand by it.
@@ -62,7 +71,21 @@ Usual work
62
71
  - Give an input or a corrected output: rf upload PATH FILE [--onto ITEM] [--output].
63
72
  - Start an instance: rf wake SERIES now REASON --label LABEL; take one up later: rf wake PATH +2h REASON.
64
73
  - Read what a run did: rf pull PATH/ DIR, then its report and done entries in log.jsonl, the files in working/, and session/ for
65
- every step.`;
74
+ every step.
75
+
76
+ Watching over it, for admins and power users; each sees what the caller sees, and takes --json
77
+ - What's stuck: rf status lists runs going, flagging those past --long MIN (30); queued runs, since when and why: their series is
78
+ running, the RF_MAX_RUNS cap, or memory; what waits on whom since when: asks, notes for runners, expired logins and the instances
79
+ they hold, requests to start, and shared series with nothing open and no wake set; and open instances whose last run failed.
80
+ - History: rf runs [PATH] [--since DATE] [--failed] [--running] [--long MIN] prints a line per run: its start, path, run number,
81
+ outcome, time taken, cost, what started it and by whom, and its summary or error. rf costs [PATH] [--since DATE] [--by
82
+ workspace|series|person|day] totals them, by the runner for person.
83
+ - People: rf people (admins) prints everyone as ROLE EMAIL lines, and rf people WORKSPACE (its owners and admins) its owners and
84
+ members as owner|member lines; a comment after each says when they last signed in and which open instances they run, and for
85
+ everyone those they run in workspaces they aren't in. Edit the lines, see the changes with --set FILE -n (FILE may be - for
86
+ stdin), then --set FILE. For everyone it sets the roles of the people listed and adds new ones, removing no one (rf user remove
87
+ does); for a workspace it replaces its people. A change that would hide an open instance from the person who runs it is refused,
88
+ naming them, unless --force: have someone who stays take those over first.`;
66
89
 
67
90
  const config = join(homedir(), '.config', 'rollforward.json');
68
91
  const [cmd, ...argv] = process.argv.slice(2);
@@ -71,7 +94,7 @@ for (let i = 0; i < argv.length; i++) {
71
94
  const flag = argv[i] === '-n' ? 'dry-run' : argv[i].match(/^--(.+)$/)?.[1];
72
95
  if (!flag) args.push(argv[i]);
73
96
  else if (flag === 'exclude') excludes.push(argv[++i]);
74
- else flags[flag] = ['wait', 'output', 'delete', 'dry-run'].includes(flag) ? true : argv[++i];
97
+ else flags[flag] = ['wait', 'output', 'delete', 'dry-run', 'force', 'json', 'failed', 'running'].includes(flag) ? true : argv[++i];
75
98
  }
76
99
  if (cmd === 'help') {
77
100
  console.log(GUIDE);
@@ -98,6 +121,13 @@ async function find(path ) {
98
121
  return { series, inst, at: inst ? `/api/instances/${inst.id}` : '' };
99
122
  }
100
123
 
124
+ // Times as this machine's clock reads them, and lengths of time and costs, for the views.
125
+ const when = (iso ) => new Date(iso).toLocaleString('sv-SE', { dateStyle: 'short', timeStyle: 'short' });
126
+ const took = (s ) => s < 60 ? `${Math.round(s)}s` : s < 3600 ? `${Math.round(s / 60)}m` : `${Math.floor(s / 3600)}h${String(Math.round((s % 3600) / 60)).padStart(2, '0')}`;
127
+ const usd = (n ) => `$${n.toFixed(2)}`;
128
+ const runsIn = async (path = '') => (await get(`/api/runs?since=${encodeURIComponent(String(flags.since ?? ''))}`))
129
+ .filter(r => !path || r.path === path.replace(/\/+$/, '') || r.path.startsWith(path.replace(/\/+$/, '') + '/'));
130
+
101
131
  function write(path , data , mtime ) {
102
132
  mkdirSync(dirname(path), { recursive: true });
103
133
  rmSync(path, { force: true });
@@ -219,12 +249,70 @@ else if (cmd === 'comment' && args.length === 2) {
219
249
  } else if (cmd === 'website' && args[0] === 'remove' && args[1]) {
220
250
  await api(`/websites/${encodeURIComponent(args[1])}`, { method: 'DELETE' });
221
251
  console.log('removed');
222
- } else if (cmd === 'users') {
223
- for (const u of await get('/users')) console.log([u.email, u.role, u.disabled ? 'disabled' : '', u.last_login ? `last in ${u.last_login}` : 'never in', u.workspaces.join(', ')].filter(Boolean).join('\t'));
252
+ } else if (cmd === 'status' && !args.length) {
253
+ const s = await get('/api/status'), long = Number(flags.long ?? 30);
254
+ if (flags.json) console.log(JSON.stringify(s, null, 1));
255
+ else {
256
+ const sections = [
257
+ [`Running`, s.running.map(r => `${r.path} run ${r.run} for ${took((Date.now() - Date.parse(r.at)) / 1000)} ${usd(r.cost)}${Date.now() - Date.parse(r.at) > long * 60e3 ? ` LONG, past ${long}m` : ''}`)],
258
+ [`Queued`, s.queued.map(q => `${q.path} since ${when(q.since)}: ${q.why}`)],
259
+ [`Waiting`, [
260
+ ...s.asks.sort((a, b) => a.who.localeCompare(b.who) || a.since.localeCompare(b.since)).map(a => `${a.who} ${a.note ? 'a note' : 'an ask'} in ${a.path} since ${when(a.since)}: ${a.text.split('\n')[0].slice(0, 80)}`),
261
+ ...s.expired.map(e => `${e.who} their ${e.login} login expired${e.holding.length ? `, holding ${e.holding.join(', ')}` : ''}`),
262
+ ...s.requests.map(r => `${r.path} ${r.from} asks to start ${r.label}, since ${when(r.at)}: ${r.reason}`),
263
+ ...s.broken.map(b => `${b.path} shared by ${b.shared_by}, but nothing is open and no wake is set`),
264
+ ]],
265
+ [`Last run failed`, s.failed.map(f => `${f.path} run ${f.run} at ${when(f.at)}: ${f.error}`)],
266
+ ].filter(([, rows]) => rows.length);
267
+ console.log(sections.map(([title, rows]) => `${title} (${rows.length})\n${rows.map(r => ` ${r}`).join('\n')}`).join('\n\n') || 'Nothing is running, queued, waiting or failed.');
268
+ }
269
+ } else if (cmd === 'runs' && args.length <= 1) {
270
+ const rows = (await runsIn(args[0])).filter(r => (!flags.failed || r.outcome === 'failed') && (!flags.running || r.outcome === 'running') && (!flags.long || r.seconds >= Number(flags.long) * 60));
271
+ if (flags.json) console.log(JSON.stringify(rows, null, 1));
272
+ else for (const r of rows) console.log(`${when(r.at)} ${r.path} run ${r.run} ${r.outcome} ${took(r.seconds)} ${usd(r.cost)} ${r.started ? `${r.started.op} by ${r.started.by}` : '-'} ${r.error ?? r.summary ?? ''}`);
273
+ } else if (cmd === 'costs' && args.length <= 1) {
274
+ const by = String(flags.by ?? 'series'), key = { workspace: r => r.path.split('/')[0], series: r => r.path.split('/').slice(0, 2).join('/'), person: r => r.runner, day: r => when(r.at).slice(0, 10) }[by];
275
+ if (!key) throw new Error(USAGE);
276
+ const sums = new Map (), rows = await runsIn(args[0]);
277
+ for (const r of rows) sums.set(key(r), { runs: (sums.get(key(r))?.runs ?? 0) + 1, cost: (sums.get(key(r))?.cost ?? 0) + r.cost });
278
+ const out = [...sums].map(([k, v]) => ({ [by]: k, runs: v.runs, cost: +v.cost.toFixed(4), per_run: +(v.cost / v.runs).toFixed(4) }))
279
+ .sort((a, b) => by === 'day' ? a.day.localeCompare(b.day) : b.cost - a.cost);
280
+ if (flags.json) console.log(JSON.stringify(out, null, 1));
281
+ else {
282
+ for (const o of out) console.log(`${usd(o.cost).padStart(10)} ${String(o.runs).padStart(5)} runs ${usd(o.per_run).padStart(8)} a run ${o[by]}`);
283
+ console.log(`${usd(rows.reduce((t, r) => t + r.cost, 0)).padStart(10)} ${String(rows.length).padStart(5)} runs in all`);
284
+ }
285
+ } else if (cmd === 'people' && args.length <= 1) {
286
+ // People as lines to edit and set: ROLE EMAIL for everyone, owner|member EMAIL-OR-GROUP for a workspace, each with a comment.
287
+ const ws = args[0] === undefined ? undefined : (await get('/api')).find(w => w.name === args[0]);
288
+ if (args[0] !== undefined && !ws) throw new Error(`no workspace ${args[0]}`);
289
+ const last = (iso ) => iso ? `last in ${when(iso)}` : 'never in';
290
+ const now = ws ? (await get(`/api/workspaces/${ws.id}/people`)).map(p => ({ kind: p.as, who: p.who, note: [p.who.includes('@') ? last(p.last_login) : 'a group', p.runs.length && `runs ${p.runs.join(', ')}`] }))
291
+ : (await get('/users')).map(u => ({ kind: u.role, who: u.email, note: [u.disabled && 'disabled', last(u.last_login), `in ${u.workspaces.join(', ') || 'none'}`, u.outside.length && `runs outside them: ${u.outside.join(', ')}`] }));
292
+ if (typeof flags.set !== 'string') {
293
+ const w = Math.max(0, ...now.map(p => p.who.length));
294
+ for (const p of now) console.log(`${p.kind.padEnd(9)} ${p.who.padEnd(w)} # ${p.note.filter(Boolean).join('; ')}`);
295
+ if (!now.length) console.log('# no one is in it: only admins see it');
296
+ } else {
297
+ const kinds = ws ? ['owner', 'member'] : ['admin', 'publisher', 'user'];
298
+ const want = readFileSync(flags.set === '-' ? 0 : flags.set, 'utf8').split('\n').map(l => l.replace(/#.*/, '').trim()).filter(Boolean).map(l => l.split(/\s+/));
299
+ const bad = want.find(([kind, who, more]) => !kinds.includes(kind) || !who || more);
300
+ if (bad) throw new Error(`each line is ${kinds.join('|')} ${ws ? 'EMAIL-OR-GROUP' : 'EMAIL'}, not: ${bad.join(' ')}`);
301
+ const had = new Map(now.map(p => [p.who, p.kind])), wanted = new Map(want.map(([kind, who]) => [who.toLowerCase(), kind]));
302
+ const set = [...wanted].filter(([who, kind]) => had.get(who) !== kind), gone = ws ? [...had].filter(([who]) => !wanted.has(who)) : [];
303
+ const changes = [...set.map(([who, kind]) => had.has(who) ? `${who}: ${had.get(who)} -> ${kind}` : `+ ${kind} ${who}`), ...gone.map(([who, kind]) => `- ${kind} ${who}`)];
304
+ console.log(changes.join('\n') || 'no changes');
305
+ if (changes.length && !flags['dry-run']) {
306
+ const ofKind = (kind ) => want.filter(([k]) => k === kind).map(([, who]) => who);
307
+ await (ws ? post(`/api/workspaces/${ws.id}`, { owners: ofKind('owner'), members: ofKind('member'), force: !!flags.force }, 'PUT')
308
+ : post('/users', { roles: Object.fromEntries(set), force: !!flags.force }, 'PUT'));
309
+ console.log('saved');
310
+ }
311
+ }
224
312
  } else if (cmd === 'user' && args.length >= 2) {
225
313
  const [what, email, role] = args, at = `/users/${encodeURIComponent(email)}`;
226
- if (what === 'add') await post('/users', { email, role: role ?? 'user' });
227
- else if (what === 'role' && role) await post(at, { role }, 'PUT');
314
+ if (what === 'add') await post('/users', { roles: { [email]: role ?? 'user' } }, 'PUT');
315
+ else if (what === 'role' && role) await post('/users', { roles: { [email]: role }, force: !!flags.force }, 'PUT');
228
316
  else if (what === 'disable' || what === 'enable') await post(at, { disabled: what === 'disable' }, 'PUT');
229
317
  else if (what === 'signout') await post(at, { signout: true }, 'PUT');
230
318
  else if (what === 'remove') await api(at, { method: 'DELETE' });