whose-turn 1.0.0-alpha.8 → 1.0.0-alpha.9

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/lib/feedback.mjs CHANGED
@@ -10,7 +10,8 @@ export const RELAY_URL = 'https://6cd7q2274i726zmnyav22ar2lu0fangt.lambda-url.eu
10
10
  const PRODUCT = 'whose-turn';
11
11
  export const installOf = (appDir) => (appDir.split(sep).includes('node_modules') ? 'npm' : 'source');
12
12
  const MAX_IDS = 50;
13
- const STATE_NAMES = { open: 'Open', done: 'Done', dropped: 'Dropped' };
13
+ export const NEWS_EVERY_MS = 10 * 60 * 1000;
14
+ const STATE_NAMES = { open: 'Open', ready: 'Fixed, coming in the next release', done: 'Done', dropped: 'Dropped' };
14
15
 
15
16
  const call = async (http, url, options) => {
16
17
  try {
@@ -56,46 +57,106 @@ export const clientOf = (client) => {
56
57
 
57
58
  const environmentOf = ({ sources, install, client }) => ({ os: process.platform, osVersion: release(), arch: process.arch, node: process.version, install, sources: [...new Set(sources)].sort(), ...clientOf(client) });
58
59
 
59
- /** What this machine filed: the last hundred reports in <data>/reports.json. states() reads their state from the relay, detail() one report with its answers; one relay read serves both, cached for a minute. RMA-120, RMA-123. */
60
- export const openReports = (dataDir, { http, relayUrl }) => {
60
+ /** What this machine filed: the last hundred reports in <data>/reports.json. states() reads their state from the relay, detail() one report with its answers; one relay read serves both, cached for a minute. When adminToken() yields a token the relay lists every user report instead, each marked `mine` when this machine filed it; no token, or a 401, leaves everything as for any user. News: a report whose state or answer count differs from <data>/reports-seen.json, which markSeen() rewrites when the sheet is read; checkNews() reads the relay at most every ten minutes. RMA-120, RMA-123, RMA-306, RMA-311. */
61
+ export const openReports = (dataDir, { http, relayUrl, adminToken = async () => '' }) => {
61
62
  const file = join(dataDir, 'reports.json');
62
63
  let items = readJson(file, []);
64
+ const seenFile = join(dataDir, 'reports-seen.json');
65
+ let seen = readJson(seenFile, null);
63
66
  let cached = null;
64
- const live = async () => {
65
- if (cached && Date.now() - cached.at < 60000) {
66
- return cached.value;
67
+ let news = false;
68
+ const listeners = new Set();
69
+ /** The admin token is looked up once per run, a miss included (the secret store can cost a process spawn); a 401 drops it so the next read looks again. */
70
+ let lookup = null;
71
+ const readAll = async () => {
72
+ lookup ||= adminToken();
73
+ const token = await lookup;
74
+ if (!token) {
75
+ return null;
67
76
  }
68
- let value = {};
69
- if (items.length) {
70
- try {
71
- const ids = items.slice(0, MAX_IDS).map((report) => report.id).join(',');
72
- const answer = await http.fetchJson(`${relayUrl}?product=${PRODUCT}&ids=${encodeURIComponent(ids)}`, { useEtag: false });
73
- value = Object.fromEntries((answer.issues || []).map((issue) => [issue.identifier, issue]));
74
- } catch {
75
- value = {};
77
+ try {
78
+ const answer = await http.fetchJson(`${relayUrl}?product=${PRODUCT}&all=1`, { useEtag: false, headers: { Authorization: `Bearer ${token}` } });
79
+ return answer.issues || [];
80
+ } catch (error) {
81
+ if (error.upstreamStatus === 401) {
82
+ lookup = null;
76
83
  }
84
+ return null;
85
+ }
86
+ };
87
+ const readOwn = async () => {
88
+ if (!items.length) {
89
+ return [];
90
+ }
91
+ try {
92
+ const ids = items.slice(0, MAX_IDS).map((report) => report.id).join(',');
93
+ return (await http.fetchJson(`${relayUrl}?product=${PRODUCT}&ids=${encodeURIComponent(ids)}`, { useEtag: false })).issues || [];
94
+ } catch {
95
+ return [];
96
+ }
97
+ };
98
+ const live = async (maxAge = 60000) => {
99
+ if (cached && Date.now() - cached.at < maxAge) {
100
+ return cached.value;
101
+ }
102
+ const all = await readAll();
103
+ const issues = all || await readOwn();
104
+ cached = { at: Date.now(), value: { admin: Boolean(all), issues: Object.fromEntries(issues.map((issue) => [issue.identifier, issue])), order: issues.map((issue) => issue.identifier) } };
105
+ return cached.value;
106
+ };
107
+ const shape = (issues) => Object.fromEntries(Object.values(issues).map((issue) => [issue.identifier, { state: issue.outcome, answers: (issue.answers || []).length }]));
108
+ const differs = (now) => Object.entries(now).some(([id, shown]) => !seen[id] || seen[id].state !== shown.state || seen[id].answers !== shown.answers);
109
+ const setNews = (value) => {
110
+ if (value !== news) {
111
+ news = value;
112
+ listeners.forEach((listener) => listener({ report_news: news }));
77
113
  }
78
- cached = { at: Date.now(), value };
79
- return value;
80
114
  };
81
115
  const merged = (report, issue) => (issue ? { ...report, title: issue.title, state: STATE_NAMES[issue.outcome] || issue.outcome, outcome: issue.outcome, changed_at: issue.updatedAt } : report);
116
+ const strangers = (id, issue) => merged({ id, title: issue.title, filed_at: issue.updatedAt }, issue);
82
117
  return {
83
118
  relayUrl,
84
119
  list: () => items.slice(),
120
+ hasNews: () => news,
121
+ subscribe: (listener) => { listeners.add(listener); return () => listeners.delete(listener); },
122
+ /** The background look: reuses a read younger than ten minutes, so a closed sheet costs the relay one call per ten minutes; the first look only records what is there. */
123
+ checkNews: async () => {
124
+ const now = shape((await live(NEWS_EVERY_MS)).issues);
125
+ if (!seen) {
126
+ seen = now;
127
+ writeJson(seenFile, seen);
128
+ }
129
+ setNews(differs(now));
130
+ return news;
131
+ },
132
+ /** The sheet showed these reports: remember their state and answer count, and calm the button down. A failed read keeps what was remembered. */
133
+ markSeen: async () => {
134
+ const next = { ...seen, ...shape((await live()).issues) };
135
+ if (JSON.stringify(next) !== JSON.stringify(seen)) {
136
+ seen = next;
137
+ writeJson(seenFile, seen);
138
+ }
139
+ setNews(false);
140
+ },
85
141
  add: (report) => { items = [report, ...items].slice(0, 100); cached = null; writeJson(file, items); return report; },
86
142
  states: async () => {
87
- const issues = await live();
88
- return items.map((report) => merged(report, issues[report.id]));
143
+ const { admin, issues, order } = await live();
144
+ if (!admin) {
145
+ return items.map((report) => merged(report, issues[report.id]));
146
+ }
147
+ const own = new Map(items.map((report) => [report.id, report]));
148
+ return order.map((id) => ({ ...(own.has(id) ? merged(own.get(id), issues[id]) : strangers(id, issues[id])), mine: own.has(id) }));
89
149
  },
90
150
  detail: async (id) => {
91
- const report = items.find((item) => item.id === id);
151
+ const { admin, issues } = await live();
152
+ const issue = issues[id];
153
+ const report = items.find((item) => item.id === id) || (admin && issue ? strangers(id, issue) : null);
92
154
  if (!report) {
93
155
  return null;
94
156
  }
95
- const issue = (await live())[id];
96
157
  const answers = (issue ? issue.answers : []).map((answer) => ({ at: answer.at, by: answer.author || '', text: answer.body })).sort((a, b) => a.at.localeCompare(b.at));
97
158
  const shown = merged(report, issue);
98
- return { ...shown, answers, unanswered: Boolean(shown.outcome) && shown.outcome !== 'open' && !answers.length };
159
+ return { ...shown, answers, unanswered: (shown.outcome === 'done' || shown.outcome === 'dropped') && !answers.length };
99
160
  },
100
161
  };
101
162
  };
package/lib/notify.mjs CHANGED
@@ -26,13 +26,8 @@ const COMMANDS = {
26
26
  ],
27
27
  };
28
28
 
29
- /** True under a test runner (node:test passes NODE_TEST_CONTEXT to child processes, Vitest sets VITEST) or when WHOSE_TURN_NOTIFY=off. */
30
- export const muted = (env = process.env) => Boolean(env.NODE_TEST_CONTEXT || env.VITEST) || env.WHOSE_TURN_NOTIFY === 'off';
31
-
32
29
  const launch = (file, args, env) => {
33
- if (!muted()) {
34
- execFile(file, args, { env: { ...process.env, ...env } }, () => {});
35
- }
30
+ execFile(file, args, { env: { ...process.env, ...env } }, () => {});
36
31
  };
37
32
 
38
33
  /** Sends one desktop notification; `enabled: false` stays silent. */
@@ -43,5 +38,5 @@ export const notify = (title, body, { platform = process.platform, run = launch,
43
38
  }
44
39
  };
45
40
 
46
- /** A notify function that asks `isOn()` on every call. */
47
- export const notifier = (isOn) => (title, body) => notify(title, body, { enabled: isOn() });
41
+ /** A notify function that asks `isOn()` on every call; `options` pass through to `notify`. */
42
+ export const notifier = (isOn, options = {}) => (title, body) => notify(title, body, { ...options, enabled: isOn() });
package/lib/registry.mjs CHANGED
@@ -1,9 +1,16 @@
1
1
  /** The live set of workspaces: built from the config, rebuilt on save with unchanged workspaces kept, events fanned in for the SSE stream. */
2
2
  import { ApiError } from './errors.mjs';
3
3
 
4
- export const createRegistry = ({ load, save, saveNotifications, build, normalise = (raw) => raw }) => {
4
+ /** A workspace's identity for rebuilding: everything but how it is hidden, which a running workspace reads from the registry (`hiddenOf`) without a rebuild. */
5
+ const signatureOf = (definition) => JSON.stringify({ ...definition, hidden: undefined });
6
+
7
+ /** One raw workspace entry with its hide mode set, or without the key when shown. */
8
+ export const withHidden = ({ hidden: _was, ...entry }, hidden) => (hidden ? { ...entry, hidden } : entry);
9
+
10
+ export const createRegistry = ({ load, save, saveNotifications, saveSettings = /** @type {(settings: object) => unknown} */ (() => {}), build, normalise = (raw) => raw }) => {
5
11
  const listeners = new Set();
6
- const registry = { workspaces: [], rawWorkspaces: [], problems: [], configFile: '', feedback: null, notifications: { desktop: true }, subscribe: (listener) => { listeners.add(listener); return () => listeners.delete(listener); } };
12
+ let hiddenModes = new Map();
13
+ const registry = { workspaces: [], hiddenOf: (id) => hiddenModes.get(id) || '', rawWorkspaces: [], problems: [], configFile: '', feedback: null, notifications: { desktop: true }, settings: {}, subscribe: (listener) => { listeners.add(listener); return () => listeners.delete(listener); } };
7
14
 
8
15
  const apply = (config, prebuilt = new Map()) => {
9
16
  const previous = new Map(registry.workspaces.map((workspace) => [workspace.id, workspace]));
@@ -11,9 +18,13 @@ export const createRegistry = ({ load, save, saveNotifications, build, normalise
11
18
  const created = [];
12
19
  const dropped = [];
13
20
  registry.workspaces = config.workspaces.map((definition) => {
14
- const signature = JSON.stringify(definition);
21
+ const signature = signatureOf(definition);
15
22
  if (signatures.get(definition.id) === signature) {
16
- return previous.get(definition.id);
23
+ const kept = previous.get(definition.id);
24
+ if (registry.hiddenOf(definition.id) && !definition.hidden) {
25
+ created.push(kept);
26
+ }
27
+ return kept;
17
28
  }
18
29
  let workspace;
19
30
  try {
@@ -27,9 +38,11 @@ export const createRegistry = ({ load, save, saveNotifications, build, normalise
27
38
  created.push(workspace);
28
39
  return workspace;
29
40
  }).filter(Boolean);
41
+ hiddenModes = new Map(config.workspaces.map((definition) => [definition.id, definition.hidden || '']));
30
42
  registry.rawWorkspaces = config.rawWorkspaces;
31
43
  registry.feedback = config.feedback;
32
44
  registry.notifications = config.notifications;
45
+ registry.settings = config.settings || {};
33
46
  registry.problems = [...config.problems, ...dropped];
34
47
  registry.configFile = config.configFile;
35
48
  return created;
@@ -42,7 +55,7 @@ export const createRegistry = ({ load, save, saveNotifications, build, normalise
42
55
  const prebuilt = new Map();
43
56
  try {
44
57
  for (const definition of normalise(rawWorkspaces)) {
45
- const signature = JSON.stringify(definition);
58
+ const signature = signatureOf(definition);
46
59
  if (!known.has(signature)) {
47
60
  prebuilt.set(signature, build(definition, config));
48
61
  }
@@ -57,6 +70,15 @@ export const createRegistry = ({ load, save, saveNotifications, build, normalise
57
70
  await save(rawWorkspaces);
58
71
  return apply(load(), prebuilt);
59
72
  };
73
+ /** Parks, pauses or shows one workspace by rewriting its entry in the config; everything else about it stays. */
74
+ registry.setHidden = async (id, hidden) => {
75
+ await save(registry.rawWorkspaces.map((entry) => (entry.id === id ? withHidden(entry, hidden) : entry)));
76
+ return registry.reload();
77
+ };
78
+ registry.saveSettings = async (settings) => {
79
+ await saveSettings(settings);
80
+ registry.reload();
81
+ };
60
82
  registry.saveNotifications = async (notifications) => {
61
83
  await saveNotifications(notifications);
62
84
  registry.reload();
package/lib/rules.mjs CHANGED
@@ -19,11 +19,11 @@ export const compileRules = (rules) => {
19
19
  };
20
20
  };
21
21
 
22
- export const youOr = (name, myName) => (myName && name === myName ? 'you' : name);
22
+ const youOr = (name, myName) => (myName && name === myName ? 'you' : name);
23
23
 
24
24
  const BLANK_ROW = {
25
25
  group: 'you', whose: 'You', done: false, started: false, rank: 3, why: '', hold_url: '', hold_full: '', status_since: '', status_group: '',
26
- pr: '', pr_url: '', pr_state: '', checks: '', comment: '', comment_url: '', ticket_url: '', url: '',
26
+ reviewers: [], pr: '', pr_url: '', pr_state: '', checks: '', comment: '', comment_url: '', ticket_url: '', url: '',
27
27
  };
28
28
 
29
29
  const row = (fields) => {
@@ -80,11 +80,18 @@ const openMentions = (comments, me, rules, cutoff) => {
80
80
  return asks;
81
81
  };
82
82
 
83
- const applyThen = (built, then, facts, reason) => {
83
+ const REVIEW_MARK = { approved: '✓ ', changes: '✗ ' };
84
+
85
+ /** A PR's reviewers as the user knows them: their own login reads `you`. */
86
+ const namedReviewers = (reviewers, myName) => reviewers.map(({ name, state }) => ({ name: youOr(name, myName), state }));
87
+
88
+ const applyThen = (built, then, facts, reason, reviewers) => {
84
89
  const { label, group } = WHOSE[then.whose];
90
+ const listed = then.whose === 'reviewer' ? reviewers : [];
85
91
  Object.assign(built, {
86
92
  group: group || built.group,
87
- whose: label || built.whose,
93
+ whose: listed.length ? listed.map(({ name, state }) => `${REVIEW_MARK[state] || ''}${name}`).join(', ') : label || built.whose,
94
+ reviewers: listed,
88
95
  started: Boolean(then.started),
89
96
  rank: then.rank ?? built.rank,
90
97
  next: then.next ? fillText(then.next, facts) : built.next,
@@ -93,10 +100,10 @@ const applyThen = (built, then, facts, reason) => {
93
100
  });
94
101
  };
95
102
 
96
- const decided = (built, facts, rules, reason) => {
103
+ const decided = (built, facts, rules, reason, reviewers = []) => {
97
104
  const matched = firstMatch(rules.table, facts);
98
105
  if (matched) {
99
- applyThen(built, matched.then, facts, reason);
106
+ applyThen(built, matched.then, facts, reason, reviewers);
100
107
  }
101
108
  return built;
102
109
  };
@@ -112,7 +119,7 @@ const ticketRow = (ticket, pr, context) => {
112
119
  activity_at: [toIsoUtc(ticket.updated), pr ? pr.updated_at : ''].filter(Boolean).sort().pop(),
113
120
  ...(pr ? prCells(pr, open) : {}),
114
121
  });
115
- return decided(built, facts, context.rules, reason);
122
+ return decided(built, facts, context.rules, reason, namedReviewers(pr?.reviewers || [], pr && context.me[pr.source]));
116
123
  };
117
124
 
118
125
  /** A failing workflow run on the default branch, decided by the table; keyed by repository, workflow and branch, and by its streak so a new breakage after a fix is a new move. */
@@ -152,7 +159,7 @@ export const turnRows = ({ tickets = [], prs = [], comments = [], asks = [], run
152
159
  const ticketKeys = new Set(tickets.map((ticket) => ticket.key));
153
160
  const mentions = openMentions(comments, me, rules, daysAgoIso(historyDays));
154
161
  const rows = tickets.map((ticket) => ticketRow(ticket, pickPr(prsByKey.get(ticket.key)), {
155
- myName: me[ticket.source], rules, comments: commentsByKey.get(ticket.key) || [], mentions: mentions.get(ticket.key) || [],
162
+ me, myName: me[ticket.source], rules, comments: commentsByKey.get(ticket.key) || [], mentions: mentions.get(ticket.key) || [],
156
163
  }));
157
164
  for (const [key, list] of prsByKey) {
158
165
  const pr = pickPr(list);
@@ -195,7 +202,7 @@ export const feedRows = ({ activity = [], events = [], tickets = [], comments =
195
202
  text: oneline(comment.body), body: comment.body, url: comment.url,
196
203
  });
197
204
  }),
198
- ...events.map((event) => feedItem({ id: eventId(event), at: event.at, source: event.source, ref: event.key, key: event.key, kind: event.kind, text: event.text, url: event.url })),
205
+ ...events.map((event) => feedItem({ id: eventId(event), at: event.at, source: event.source, ref: event.key, key: event.key, kind: event.kind, text: event.kind === 'reassigned' ? event.text.split(' → ').map((name) => youOr(name.trim(), me[event.source])).join(' → ') : event.text, url: event.url })),
199
206
  ...notes.map((note) => feedItem({ id: note.id, at: note.at, source: note.source, ref: note.key, key: note.key, kind: 'note', who: 'you', text: note.text, body: note.text, url: note.url })),
200
207
  ];
201
208
  const timesByKey = groupBy([...events, ...recentComments], (item) => item.key);
package/lib/table.mjs CHANGED
@@ -6,6 +6,7 @@ export const WHOSE = {
6
6
  you: { label: 'You', group: 'you' },
7
7
  reviewer: { label: 'Reviewer', group: null },
8
8
  qa: { label: 'QA', group: null },
9
+ release: { label: 'Release', group: null },
9
10
  them: { label: null, group: null },
10
11
  hidden: { label: null, group: 'gone' },
11
12
  };
package/lib/trace.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  /** The trace a row leaves in Handled when its turn passes to someone else: one phrase and the feed event behind it. */
2
- import { eventId, youOr } from './rules.mjs';
2
+ import { eventId } from './rules.mjs';
3
3
  import { DAY, oneline } from './text.mjs';
4
4
 
5
5
  const lastName = (text) => String(text).split(' → ').pop().trim();
@@ -14,14 +14,14 @@ export const passedRows = (before, after) => {
14
14
 
15
15
  const mergedNow = ({ before, after }) => before.pr_state !== 'MERGED' && after.pr_state === 'MERGED';
16
16
 
17
- /** The newest comment on the ticket from the day before the move, as `{author, text}`, or null. */
17
+ /** The newest comment on the ticket from the day before the move, as `{id, text}` (the feed id and a short excerpt), or null. */
18
18
  const commentBefore = (feed, key, at) => {
19
19
  const found = feed.find((item) => item.kind === 'comment' && item.key === key && Date.parse(at) - Date.parse(item.at) < DAY);
20
- return found ? { author: found.who, text: oneline(found.body || found.text, 4000) } : null;
20
+ return found ? { id: found.id, text: oneline(found.body || found.text) } : null;
21
21
  };
22
22
 
23
- /** `{outcome, event, detail}` built from what moved the row, or null when no event explains it. `events` are this poll's ticket events, `feed` is newest first, `me` is your name per source. */
24
- export const traceOf = (pair, events, feed, me = {}) => {
23
+ /** `{outcome, event, detail}` built from what moved the row, or null when no event explains it. `events` are this poll's ticket events, `feed` is newest first and already shows your name as you. */
24
+ export const traceOf = (pair, events, feed) => {
25
25
  const own = events.filter((event) => event.key === pair.after.key && event.source === pair.after.source);
26
26
  const reassigned = own.find((event) => event.kind === 'reassigned');
27
27
  const status = own.find((event) => event.kind === 'status');
@@ -38,7 +38,7 @@ export const traceOf = (pair, events, feed, me = {}) => {
38
38
  return {
39
39
  outcome: comment ? `${head} · ${comment.text}` : head,
40
40
  event: eventId(reassigned),
41
- detail: { what: head, assignee: names.map((name) => youOr(name.trim(), me[pair.after.source])).join(' → '), ...(status ? { status: status.text } : {}), ...(comment ? { comment } : {}) },
41
+ detail: { what: head, assignee: feed.find((item) => item.id === eventId(reassigned))?.text ?? reassigned.text, ...(status ? { status: status.text } : {}), ...(comment?.id ? { comment: comment.id } : {}) },
42
42
  };
43
43
  }
44
44
  return status ? { outcome: `Moved to ${lastName(status.text)}`, event: eventId(status), detail: { status: status.text } } : null;
package/lib/workspace.mjs CHANGED
@@ -4,11 +4,11 @@ import { compileRules, turnRows, feedRows, myMoves, isHandled, withHandled, turn
4
4
  import { makeKeyFinder, nowIso, oneline, daysAgoIso, unionCapped, DAY } from './text.mjs';
5
5
  import { passedRows, traceOf } from './trace.mjs';
6
6
  import { buildHandOff, snapshotOf, renderHandOff, frameHandOff } from './handoff.mjs';
7
- import { notify as desktopNotify } from './notify.mjs';
8
7
  import { DEFAULT_LIMITS, nextFails, assess, transition } from './health.mjs';
9
8
  import { paletteFrom, derivedSeed } from './palette.mjs';
10
9
  import { CONNECTORS, connectorSummaries } from '../connectors/index.mjs';
11
10
  import { RateLimited } from './http-client.mjs';
11
+ import { isDue } from './backoff.mjs';
12
12
  const KINDS = ['tickets', 'comments', 'prs', 'activity', 'asks', 'runs'];
13
13
 
14
14
  const stamp = (result, sourceId) => {
@@ -35,8 +35,12 @@ export const mergeLinks = (own, derived) => {
35
35
  return [...own, ...derived.filter((link) => !ownHosts.has(hostOf(link.url)))].filter((link) => !seen.has(link.url) && seen.add(link.url));
36
36
  };
37
37
 
38
- export const createWorkspace = (definition, { dataDir, historyDays, http, secrets, limits = () => DEFAULT_LIMITS, notify = desktopNotify }) => {
38
+ export const createWorkspace = (definition, { dataDir, historyDays, http, secrets, limits = () => DEFAULT_LIMITS, notify, hidden = () => definition.hidden || '', unpark = /** @type {(id: string) => Promise<unknown>} */ (async () => {}) }) => {
39
+ if (typeof notify !== 'function') {
40
+ throw new TypeError('createWorkspace needs a notify function');
41
+ }
39
42
  const store = openStore(dataDir, definition.id);
43
+ const days = () => (typeof historyDays === 'function' ? historyDays() : historyDays);
40
44
  const rules = compileRules(definition.rules);
41
45
  const keyOf = makeKeyFinder(definition.key_pattern);
42
46
  const seed = definition.theme?.seed || definition.derived_seed || derivedSeed(definition.id);
@@ -48,6 +52,7 @@ export const createWorkspace = (definition, { dataDir, historyDays, http, secret
48
52
  let marksVersion = 0;
49
53
  let freshAt = '';
50
54
  const startedAt = nowIso();
55
+ let lastPollAt = Date.parse(store.state.polled_at) || 0;
51
56
 
52
57
  const sources = () => Object.values(store.state.sources);
53
58
  const merged = () => Object.fromEntries(KINDS.map((kind) => [kind, sources().flatMap((source) => source[kind] || [])]));
@@ -66,7 +71,7 @@ export const createWorkspace = (definition, { dataDir, historyDays, http, secret
66
71
  if (!derived || derived.version !== store.state.version || derived.day !== day || derived.marksVersion !== marksVersion) {
67
72
  const sources = merged();
68
73
  const state = { ...sources, events: store.state.events, notes: noteEvents() };
69
- const context = { me: me(), rules, historyDays, ticketUrl };
74
+ const context = { me: me(), rules, historyDays: days(), ticketUrl };
70
75
  derived = { version: store.state.version, marksVersion, day, turn: turnRows(sources, context), feed: feedRows(state, context) };
71
76
  }
72
77
  return derived;
@@ -86,7 +91,7 @@ export const createWorkspace = (definition, { dataDir, historyDays, http, secret
86
91
  };
87
92
 
88
93
  const emit = () => {
89
- const message = { ws: definition.id, version: store.state.version, polled_at: store.state.polled_at, open_moves: myMoves(rows().turn, store.marks).length };
94
+ const message = { ws: definition.id, version: store.state.version, polled_at: store.state.polled_at, open_moves: myMoves(rows().turn, store.marks).length, hidden: self.hidden, returned: returned() };
90
95
  for (const listener of listeners) {
91
96
  listener(message);
92
97
  }
@@ -107,12 +112,18 @@ export const createWorkspace = (definition, { dataDir, historyDays, http, secret
107
112
  store.saveMarks();
108
113
  };
109
114
 
110
- const health = () => assess({ fails: store.state.fails, freshAt: freshAt || startedAt, now: Date.now(), limits: limits(), sourceCount: definition.connectors.length });
115
+ /** The health of a shown workspace; null while it is parked or paused, which is neither watched nor alerted about. */
116
+ const assessed = () => (self.hidden ? null : assess({ fails: store.state.fails, freshAt: freshAt || startedAt, now: Date.now(), limits: limits(), sourceCount: definition.connectors.length }));
117
+ const health = () => assessed() || { ok: true, stale: false, fresh_at: freshAt, failing: [], alerts: [] };
111
118
 
112
119
  let shownStreaks = '';
113
120
 
114
121
  const checkHealth = () => {
115
- const { alerts } = health();
122
+ const watched = assessed();
123
+ if (!watched) {
124
+ return;
125
+ }
126
+ const { alerts } = watched;
116
127
  const { raised, recovered, alerted } = transition(store.marks.health_alerted, alerts);
117
128
  const streaks = alerts.filter((alert) => alert.id.startsWith('failing:')).map((alert) => alert.text).join('\n');
118
129
  if (!raised.length && !recovered.length) {
@@ -142,7 +153,7 @@ export const createWorkspace = (definition, { dataDir, historyDays, http, secret
142
153
  }
143
154
  caches[entry.id] = caches[entry.id] || store.loadCache(entry.id);
144
155
  try {
145
- const result = await connector.poll({ settings: entry, http, secrets, cache: caches[entry.id], sinceIso: daysAgoIso(historyDays), historyDays, keyOf });
156
+ const result = await connector.poll({ settings: entry, http, secrets, cache: caches[entry.id], sinceIso: daysAgoIso(days()), historyDays: days(), keyOf });
146
157
  const errors = Object.fromEntries(Object.entries(result.errors || {}).map(([part, message]) => [`${entry.id}.${part}`, message]));
147
158
  return { data: stamp(result, entry.id), errors };
148
159
  } catch (error) {
@@ -157,12 +168,16 @@ export const createWorkspace = (definition, { dataDir, historyDays, http, secret
157
168
  };
158
169
 
159
170
  const poll = async () => {
171
+ lastPollAt = Date.now();
172
+ const checkedBefore = Boolean(store.state.polled_at);
160
173
  expireHandled();
161
174
  const outcomes = await Promise.all(definition.connectors.map(pollSource));
162
175
  const state = store.state;
163
176
  const errors = {};
164
177
  const ticketsBefore = merged().tickets;
165
178
  const turnBefore = rows().turn;
179
+ const parked = self.hidden === 'parked' && checkedBefore;
180
+ const movesBefore = parked ? new Set(myMoves(turnBefore, store.marks).map((move) => move.row_id)) : null;
166
181
  definition.connectors.forEach((entry, index) => {
167
182
  const outcome = outcomes[index];
168
183
  Object.assign(errors, outcome.errors);
@@ -202,11 +217,39 @@ export const createWorkspace = (definition, { dataDir, historyDays, http, secret
202
217
  if (outcomes.some((outcome) => outcome.data)) {
203
218
  saveCaches();
204
219
  }
220
+ if (parked && outcomes.some((outcome) => outcome.data)) {
221
+ await comeBack(myMoves(rows().turn, store.marks).filter((move) => !movesBefore.has(move.row_id)));
222
+ }
205
223
  checkHealth();
206
224
  emit();
207
225
  return { errors, rateLimited: outcomes.some((outcome) => outcome.rateLimited) };
208
226
  };
209
227
 
228
+ /** A parked workspace whose check found new moves for you goes back into the rail, with a feed line that says why. */
229
+ const comeBack = async (arrived) => {
230
+ if (!arrived.length) {
231
+ return;
232
+ }
233
+ const [first] = arrived;
234
+ const text = arrived.length === 1 ? 'Back in the rail: this needs you' : `Back in the rail: ${arrived.length} items need you`;
235
+ store.state.events = [{ at: nowIso(), source: first.source, key: first.key, kind: 'returned', text, url: first.ticket_url || first.url }, ...store.state.events].slice(0, 500);
236
+ store.state.version += 1;
237
+ store.saveState();
238
+ await unpark(definition.id).catch(() => {});
239
+ };
240
+
241
+ /** The feed items you have not seen: not in the seen marks and newer than the base. */
242
+ const unseen = (items) => {
243
+ const seen = new Set(store.marks.seen);
244
+ return items.filter((item) => !seen.has(item.id) && new Date(item.at).getTime() > store.marks.base);
245
+ };
246
+
247
+ /** Whether the feed holds a line about the workspace coming back that you have not seen yet: the rail's dot. */
248
+ const returned = () => {
249
+ const lines = rows().feed.filter((item) => item.kind === 'returned');
250
+ return lines.length > 0 && unseen(lines).length > 0;
251
+ };
252
+
210
253
  const rowRef = (row) => ({ key: row.key, source: row.source, url: row.ticket_url || row.url });
211
254
 
212
255
  /** Adds the auto Handled mark to rows whose turn passed on in this poll and drops it from rows whose turn came back. */
@@ -216,7 +259,7 @@ export const createWorkspace = (definition, { dataDir, historyDays, http, secret
216
259
  let changed = false;
217
260
  let notesMoved = false;
218
261
  for (const pair of passedRows(turnBefore, turn)) {
219
- const trace = traceOf(pair, fresh, feed, me());
262
+ const trace = traceOf(pair, fresh, feed);
220
263
  if (trace) {
221
264
  const rowId = pair.after.row_id;
222
265
  notesMoved ||= Boolean(info[rowId]?.note);
@@ -256,10 +299,10 @@ export const createWorkspace = (definition, { dataDir, historyDays, http, secret
256
299
  const identity = () => ({
257
300
  id: definition.id, name: definition.name, key_pattern: definition.key_pattern, links: quickLinks(),
258
301
  connectors: connectorSummaries(definition.connectors),
259
- open_moves: myMoves(rows().turn, store.marks).length,
302
+ open_moves: myMoves(rows().turn, store.marks).length, hidden: self.hidden,
260
303
  });
261
304
 
262
- const describe = () => ({ ...identity(), theme, theme_seed: seed, failing_sources: Object.keys(store.state.errors || {}).length });
305
+ const describe = () => ({ ...identity(), theme, theme_seed: seed, backdrop: definition.theme?.backdrop !== false, failing_sources: Object.keys(store.state.errors || {}).length, returned: returned() });
263
306
 
264
307
  const todosWithLinks = () => store.todos.map((todo) => ({ ...todo, ticket_url: todo.key ? ticketUrl(todo.key) : '' }));
265
308
 
@@ -289,9 +332,7 @@ export const createWorkspace = (definition, { dataDir, historyDays, http, secret
289
332
  const handOffSnapshot = (kind, ref) => snapshotOf(kind, ref, handOffSource());
290
333
 
291
334
  const summary = () => {
292
- const seen = new Set(store.marks.seen);
293
- const unread = rows().feed.filter((item) => !seen.has(item.id) && new Date(item.at).getTime() > store.marks.base).length;
294
- return { ...identity(), moves: myMoves(rows().turn, store.marks), new_events: unread, todos: todosWithLinks().filter((todo) => !todo.done), polled_at: store.state.polled_at, errors: store.state.errors, health: health() };
335
+ return { ...identity(), moves: myMoves(rows().turn, store.marks), new_events: unseen(rows().feed).length, todos: todosWithLinks().filter((todo) => !todo.done), polled_at: store.state.polled_at, errors: store.state.errors, health: health() };
295
336
  };
296
337
 
297
338
  const noteOn = (rowId, text) => {
@@ -365,8 +406,15 @@ export const createWorkspace = (definition, { dataDir, historyDays, http, secret
365
406
  emit();
366
407
  };
367
408
 
368
- return {
369
- id: definition.id, name: definition.name, sourceTypes: [...new Set(definition.connectors.map((entry) => entry.type))], rulesTable: rules.table, store, poll, snapshot, summary, describe, checkHealth, handOff, handOffBody, handOffText, handOffSnapshot, updateMarks, addTodo, changeTodo, saveTodos,
409
+ /** Polls when the round is due for this workspace: every round when shown, every 15 minutes when parked, never when paused. */
410
+ const pollIfDue = () => (isDue(self.hidden, lastPollAt, Date.now()) ? poll() : Promise.resolve({ errors: {}, rateLimited: false }));
411
+
412
+ const self = {
413
+ get hidden() {
414
+ return hidden();
415
+ },
416
+ id: definition.id, name: definition.name, sourceTypes: [...new Set(definition.connectors.map((entry) => entry.type))], rulesTable: rules.table, store, poll, pollIfDue, snapshot, summary, describe, checkHealth, handOff, handOffBody, handOffText, handOffSnapshot, updateMarks, addTodo, changeTodo, saveTodos,
370
417
  subscribe: (listener) => { listeners.add(listener); return () => listeners.delete(listener); },
371
418
  };
419
+ return self;
372
420
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "whose-turn",
3
- "version": "1.0.0-alpha.8",
3
+ "version": "1.0.0-alpha.9",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -56,6 +56,7 @@
56
56
  "eslint-config-prettier": "10.1.8",
57
57
  "eslint-plugin-vue": "10.11.1",
58
58
  "happy-dom": "21.0.4",
59
+ "motion-v": "2.9.0",
59
60
  "postcss-html": "2.0.0",
60
61
  "prettier": "3.9.10",
61
62
  "stylelint": "17.16.0",
package/server.mjs CHANGED
@@ -3,14 +3,14 @@
3
3
  import { readFileSync } from 'node:fs';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
- import { loadConfig, saveWorkspaces, saveNotifications, normaliseWorkspaces } from './lib/config.mjs';
6
+ import { loadConfig, saveWorkspaces, saveNotifications, saveSettings, normaliseWorkspaces } from './lib/config.mjs';
7
7
  import { createHttpClient } from './lib/http-client.mjs';
8
8
  import { nextDelay } from './lib/backoff.mjs';
9
9
  import { createSecrets } from './lib/secrets.mjs';
10
10
  import { createWorkspace } from './lib/workspace.mjs';
11
11
  import { createRegistry } from './lib/registry.mjs';
12
12
  import { openInbox } from './lib/inbox.mjs';
13
- import { openReports, RELAY_URL } from './lib/feedback.mjs';
13
+ import { openReports, RELAY_URL, NEWS_EVERY_MS } from './lib/feedback.mjs';
14
14
  import { startApi } from './lib/api.mjs';
15
15
  import { notifier } from './lib/notify.mjs';
16
16
  import { loadChangelog } from './lib/changelog.mjs';
@@ -21,10 +21,10 @@ const http = createHttpClient();
21
21
  const first = loadConfig();
22
22
  const secrets = createSecrets(first.dataDir);
23
23
  const inbox = openInbox(first.dataDir);
24
- const reports = openReports(first.dataDir, { http, relayUrl: process.env.WHOSE_TURN_FEEDBACK_URL || RELAY_URL });
24
+ const reports = openReports(first.dataDir, { http, relayUrl: process.env.WHOSE_TURN_FEEDBACK_URL || RELAY_URL, adminToken: () => secrets.get('whose-turn-feedback-admin', 'relay') });
25
25
  const { version } = JSON.parse(readFileSync(join(appDir, 'package.json'), 'utf8'));
26
26
  let server;
27
- const notify = notifier(() => registry.notifications.desktop);
27
+ const notify = notifier(() => process.env.WHOSE_TURN_NOTIFY !== 'off' && registry.notifications.desktop);
28
28
  const updater = createUpdater({
29
29
  http,
30
30
  dataDir: first.dataDir,
@@ -36,6 +36,7 @@ const updater = createUpdater({
36
36
  restart: () => restartServer({ server, root: appDir, dataDir: first.dataDir }),
37
37
  });
38
38
  let pollSeconds = first.pollSeconds;
39
+ let historyDays = first.historyDays;
39
40
  /** @type {{ stale_minutes: number, failing_polls: number }} */
40
41
  let healthLimits = /** @type {any} */ (first.health);
41
42
 
@@ -43,13 +44,15 @@ const registry = createRegistry({
43
44
  load: () => {
44
45
  const config = loadConfig();
45
46
  pollSeconds = config.pollSeconds;
47
+ historyDays = config.historyDays;
46
48
  healthLimits = /** @type {any} */ (config.health);
47
49
  return config;
48
50
  },
49
51
  save: (rawWorkspaces) => saveWorkspaces(first.configFile, rawWorkspaces),
50
52
  saveNotifications: (notifications) => saveNotifications(first.configFile, notifications),
53
+ saveSettings: (settings) => saveSettings(first.configFile, settings),
51
54
  normalise: (rawWorkspaces) => normaliseWorkspaces(rawWorkspaces).workspaces,
52
- build: (definition, config) => createWorkspace(definition, { dataDir: config.dataDir, historyDays: config.historyDays, http, secrets, limits: () => healthLimits, notify }),
55
+ build: (definition, config) => createWorkspace(definition, { dataDir: config.dataDir, historyDays: () => historyDays, http, secrets, limits: () => healthLimits, notify, hidden: () => registry.hiddenOf(definition.id), unpark: (id) => registry.setHidden(id, '') }),
53
56
  });
54
57
 
55
58
  let wake = null;
@@ -62,7 +65,7 @@ const sleep = (seconds) => new Promise((resolve) => {
62
65
  const pollLoop = async () => {
63
66
  let delay = pollSeconds;
64
67
  for (;;) {
65
- const outcomes = await Promise.all(registry.workspaces.map((workspace) => workspace.poll()));
68
+ const outcomes = await Promise.all(registry.workspaces.map((workspace) => workspace.pollIfDue()));
66
69
  delay = nextDelay(outcomes, delay, pollSeconds);
67
70
  await sleep(delay);
68
71
  }
@@ -70,7 +73,7 @@ const pollLoop = async () => {
70
73
 
71
74
  const saveConfig = async (rawWorkspaces) => {
72
75
  const created = await registry.save(rawWorkspaces);
73
- await Promise.all(created.map((workspace) => workspace.poll()));
76
+ await Promise.all(created.map((workspace) => workspace.pollIfDue()));
74
77
  };
75
78
 
76
79
  try {
@@ -85,6 +88,10 @@ for (const problem of registry.problems) {
85
88
  }
86
89
  pollLoop();
87
90
  updater.start();
91
+ const FIRST_NEWS_MS = 30000;
92
+ const checkReports = () => reports.checkNews().catch(() => {});
93
+ setTimeout(checkReports, FIRST_NEWS_MS).unref();
94
+ setInterval(checkReports, NEWS_EVERY_MS).unref();
88
95
 
89
96
  const HEALTH_TICK = 30000;
90
97
  let lastTick = Date.now();