whose-turn 0.0.0-stage → 1.0.0-alpha.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.
Files changed (55) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/LICENSE +21 -0
  3. package/README.md +244 -2
  4. package/config.example.json +144 -0
  5. package/connectors/demo.mjs +146 -0
  6. package/connectors/github.mjs +316 -0
  7. package/connectors/index.mjs +38 -0
  8. package/connectors/jira.mjs +155 -0
  9. package/connectors/linear.mjs +113 -0
  10. package/dist/assets/_plugin-vue_export-helper-BKveAJb2.css +1 -0
  11. package/dist/assets/_plugin-vue_export-helper-DHr5RHTx.js +18 -0
  12. package/dist/assets/bem-COKTjsky.js +1 -0
  13. package/dist/assets/index-CuyWocyb.js +3 -0
  14. package/dist/assets/index-p8D7awn0.css +1 -0
  15. package/dist/assets/report-BtGnm9ax.js +1 -0
  16. package/dist/assets/report-xVOo7qip.css +1 -0
  17. package/dist/assets/scheme-DJ3WNw60.js +1 -0
  18. package/dist/assets/sheet-CBYGM-FG.js +1 -0
  19. package/dist/assets/sheet-DYpaJaXO.css +1 -0
  20. package/dist/index.html +19 -0
  21. package/dist/report.html +16 -0
  22. package/dist/sheet.html +16 -0
  23. package/dist/vendor/popper.min.js +6 -0
  24. package/dist/vendor/tippy-bundle.umd.min.js +2 -0
  25. package/install.sh +69 -0
  26. package/lib/api.mjs +406 -0
  27. package/lib/backoff.mjs +2 -0
  28. package/lib/compose.mjs +67 -0
  29. package/lib/config.mjs +290 -0
  30. package/lib/default-table.mjs +58 -0
  31. package/lib/errors.mjs +55 -0
  32. package/lib/facts.mjs +128 -0
  33. package/lib/feedback.mjs +148 -0
  34. package/lib/handoff.mjs +127 -0
  35. package/lib/health.mjs +35 -0
  36. package/lib/http-client.mjs +44 -0
  37. package/lib/inbox.mjs +60 -0
  38. package/lib/linear-api.mjs +12 -0
  39. package/lib/notify.mjs +36 -0
  40. package/lib/palette.mjs +213 -0
  41. package/lib/platform/credential.ps1 +84 -0
  42. package/lib/registry.mjs +61 -0
  43. package/lib/rules.mjs +241 -0
  44. package/lib/secrets.mjs +69 -0
  45. package/lib/static.mjs +36 -0
  46. package/lib/store.mjs +61 -0
  47. package/lib/table.mjs +148 -0
  48. package/lib/text.mjs +46 -0
  49. package/lib/token-cache.mjs +12 -0
  50. package/lib/update.mjs +134 -0
  51. package/lib/window.mjs +33 -0
  52. package/lib/workspace.mjs +294 -0
  53. package/open.sh +40 -0
  54. package/package.json +67 -4
  55. package/server.mjs +96 -0
@@ -0,0 +1,294 @@
1
+ /** One workspace: polls its connector entries, stores each result by source, merges them by kind, evaluates the rules once per version and notifies. */
2
+ import { openStore } from './store.mjs';
3
+ import { compileRules, turnRows, feedRows, myMoves, isHandled, withHandled, turnCounts, ticketEvents } from './rules.mjs';
4
+ import { makeKeyFinder, nowIso, daysAgoIso, unionCapped, DAY } from './text.mjs';
5
+ import { buildHandOff, snapshotOf, renderHandOff, frameHandOff } from './handoff.mjs';
6
+ import { notify as desktopNotify } from './notify.mjs';
7
+ import { DEFAULT_LIMITS, nextFails, assess, transition } from './health.mjs';
8
+ import { paletteFrom, derivedSeed } from './palette.mjs';
9
+ import { CONNECTORS, connectorSummaries } from '../connectors/index.mjs';
10
+ import { RateLimited } from './http-client.mjs';
11
+ const KINDS = ['tickets', 'comments', 'prs', 'activity', 'asks', 'runs'];
12
+
13
+ const stamp = (result, sourceId) => {
14
+ const stamped = { ...result };
15
+ for (const kind of KINDS) {
16
+ stamped[kind] = (result[kind] || []).map((item) => ({ ...item, source: sourceId }));
17
+ }
18
+ return stamped;
19
+ };
20
+
21
+ export const createWorkspace = (definition, { dataDir, historyDays, http, secrets, limits = () => DEFAULT_LIMITS, notify = desktopNotify }) => {
22
+ const store = openStore(dataDir, definition.id);
23
+ const rules = compileRules(definition.rules);
24
+ const keyOf = makeKeyFinder(definition.key_pattern);
25
+ const seed = definition.theme?.seed || definition.derived_seed || derivedSeed(definition.id);
26
+ const theme = paletteFrom(seed, definition.theme?.accent);
27
+ const caches = {};
28
+ const listeners = new Set();
29
+ let digest = '';
30
+ let derived = null;
31
+ let freshAt = '';
32
+ const startedAt = nowIso();
33
+
34
+ const sources = () => Object.values(store.state.sources);
35
+ const merged = () => Object.fromEntries(KINDS.map((kind) => [kind, sources().flatMap((source) => source[kind] || [])]));
36
+ const me = () => Object.fromEntries(Object.entries(store.state.sources).map(([id, source]) => [id, source.me || '']));
37
+ const ticketUrl = (key) => {
38
+ const base = sources().map((source) => source.ticket_base).find(Boolean);
39
+ return base ? `${base}${key}` : '';
40
+ };
41
+
42
+ const rows = () => {
43
+ const day = nowIso().slice(0, 10);
44
+ if (!derived || derived.version !== store.state.version || derived.day !== day) {
45
+ const sources = merged();
46
+ const state = { ...sources, events: store.state.events };
47
+ const context = { me: me(), rules, historyDays, ticketUrl };
48
+ derived = { version: store.state.version, day, turn: turnRows(sources, context), feed: feedRows(state, context) };
49
+ }
50
+ return derived;
51
+ };
52
+
53
+ const emit = () => {
54
+ const message = { ws: definition.id, version: store.state.version, polled_at: store.state.polled_at, open_moves: myMoves(rows().turn, store.marks).length };
55
+ for (const listener of listeners) {
56
+ listener(message);
57
+ }
58
+ };
59
+
60
+ const notifyNewMoves = () => {
61
+ const moves = myMoves(rows().turn, store.marks);
62
+ const known = new Set(store.marks.notified);
63
+ const fresh = moves.filter((move) => !known.has(move.row_id));
64
+ if (!fresh.length) {
65
+ return;
66
+ }
67
+ if (store.marks.notified_seeded) {
68
+ const title = fresh.length === 1 ? `${definition.name}: your move on ${fresh[0].key}` : `${definition.name}: ${fresh.length} new items are your move`;
69
+ notify(title, fresh.length === 1 ? fresh[0].next : fresh.slice(0, 3).map((move) => `${move.key} — ${move.next}`).join('; '));
70
+ }
71
+ store.marks.notified = unionCapped(store.marks.notified, fresh.map((move) => move.row_id));
72
+ store.saveMarks();
73
+ };
74
+
75
+ const health = () => assess({ fails: store.state.fails, freshAt: freshAt || startedAt, now: Date.now(), limits: limits(), sourceCount: definition.connectors.length });
76
+
77
+ let shownStreaks = '';
78
+
79
+ const checkHealth = () => {
80
+ const { alerts } = health();
81
+ const { raised, recovered, alerted } = transition(store.marks.health_alerted, alerts);
82
+ const streaks = alerts.filter((alert) => alert.id.startsWith('failing:')).map((alert) => alert.text).join('\n');
83
+ if (!raised.length && !recovered.length) {
84
+ if (streaks !== shownStreaks) {
85
+ shownStreaks = streaks;
86
+ store.state.version += 1;
87
+ emit();
88
+ }
89
+ return;
90
+ }
91
+ shownStreaks = streaks;
92
+ if (raised.length) {
93
+ notify(`${definition.name}: monitor needs attention`, raised.map((alert) => alert.text).join('; '));
94
+ } else if (!alerted.length) {
95
+ notify(`${definition.name}: monitor recovered`, 'Data is flowing again');
96
+ }
97
+ store.marks.health_alerted = alerted;
98
+ store.saveMarks();
99
+ store.state.version += 1;
100
+ emit();
101
+ };
102
+
103
+ const pollSource = async (entry) => {
104
+ const connector = CONNECTORS[entry.type];
105
+ if (!connector) {
106
+ return { errors: { [entry.id]: `Unknown connector type "${entry.type}"` } };
107
+ }
108
+ caches[entry.id] = caches[entry.id] || store.loadCache(entry.id);
109
+ try {
110
+ const result = await connector.poll({ settings: entry, http, secrets, cache: caches[entry.id], sinceIso: daysAgoIso(historyDays), historyDays, keyOf });
111
+ const errors = Object.fromEntries(Object.entries(result.errors || {}).map(([part, message]) => [`${entry.id}.${part}`, message]));
112
+ return { data: stamp(result, entry.id), errors };
113
+ } catch (error) {
114
+ const rateLimited = error instanceof RateLimited;
115
+ return { errors: { [entry.id]: `${rateLimited ? 'rate limited' : error.name}: ${error.message}` }, rateLimited };
116
+ }
117
+ };
118
+
119
+ const saveCaches = () => {
120
+ const keysByEntry = Object.fromEntries(definition.connectors.map((entry) => [entry.id, CONNECTORS[entry.type]?.persistedCache || []]));
121
+ store.saveCaches(caches, keysByEntry);
122
+ };
123
+
124
+ const poll = async () => {
125
+ expireHandled();
126
+ const outcomes = await Promise.all(definition.connectors.map(pollSource));
127
+ const state = store.state;
128
+ const errors = {};
129
+ const ticketsBefore = merged().tickets;
130
+ definition.connectors.forEach((entry, index) => {
131
+ const outcome = outcomes[index];
132
+ Object.assign(errors, outcome.errors);
133
+ if (outcome.data) {
134
+ state.sources[entry.id] = outcome.data;
135
+ }
136
+ });
137
+ for (const id of Object.keys(state.sources)) {
138
+ if (!definition.connectors.some((entry) => entry.id === id)) {
139
+ delete state.sources[id];
140
+ }
141
+ }
142
+ if (ticketsBefore.length) {
143
+ state.events = [...ticketEvents(ticketsBefore, merged().tickets), ...state.events].slice(0, 500);
144
+ }
145
+ state.errors = errors;
146
+ state.polled_at = nowIso();
147
+ if (outcomes.some((outcome) => outcome.data)) {
148
+ freshAt = state.polled_at;
149
+ }
150
+ const fails = nextFails(state.fails, definition.connectors.map((entry, index) => ({ id: entry.id, failed: Object.keys(outcomes[index].errors).length > 0 })));
151
+ const failsChanged = JSON.stringify(fails) !== JSON.stringify(state.fails);
152
+ state.fails = fails;
153
+ const next = JSON.stringify([state.sources, state.errors]);
154
+ if (next !== digest) {
155
+ digest = next;
156
+ state.version += 1;
157
+ store.saveState();
158
+ if (outcomes.some((outcome) => outcome.data)) {
159
+ notifyNewMoves();
160
+ store.marks.notified_seeded = true;
161
+ store.saveMarks();
162
+ }
163
+ } else if (failsChanged) {
164
+ store.saveState();
165
+ }
166
+ if (outcomes.some((outcome) => outcome.data)) {
167
+ saveCaches();
168
+ }
169
+ checkHealth();
170
+ emit();
171
+ return { errors, rateLimited: outcomes.some((outcome) => outcome.rateLimited) };
172
+ };
173
+
174
+ const quickLinks = () => {
175
+ const derived = definition.connectors.flatMap((entry) => (CONNECTORS[entry.type]?.links ? CONNECTORS[entry.type].links(entry) : []));
176
+ const own = definition.links || [];
177
+ const seen = new Set();
178
+ return [...own, ...derived].filter((link) => !seen.has(link.url) && seen.add(link.url));
179
+ };
180
+
181
+ const expireHandled = () => {
182
+ const limit = Date.now() - 7 * DAY;
183
+ let changed = false;
184
+ for (const [rowId, at] of Object.entries(store.marks.handled)) {
185
+ if (new Date(at).getTime() < limit) {
186
+ delete store.marks.handled[rowId];
187
+ changed = true;
188
+ }
189
+ }
190
+ if (changed) {
191
+ store.saveMarks();
192
+ }
193
+ };
194
+
195
+ const identity = () => ({
196
+ id: definition.id, name: definition.name, key_pattern: definition.key_pattern, links: quickLinks(),
197
+ connectors: connectorSummaries(definition.connectors),
198
+ open_moves: myMoves(rows().turn, store.marks).length,
199
+ });
200
+
201
+ const describe = () => ({ ...identity(), theme, theme_seed: seed });
202
+
203
+ const todosWithLinks = () => store.todos.map((todo) => ({ ...todo, ticket_url: todo.key ? ticketUrl(todo.key) : '' }));
204
+
205
+ /** The part of the snapshot a marks write changes: the marks, the rows with their handled flags and the counts. */
206
+ const marked = () => ({ marks: store.marks, turn: withHandled(rows().turn, store.marks), counts: turnCounts(rows().turn, store.marks) });
207
+
208
+ const snapshot = (full) => ({
209
+ version: store.state.version, polled_at: store.state.polled_at, errors: store.state.errors, health: health(), me: me(),
210
+ ...marked(), feed: rows().feed, todos: todosWithLinks(), workspace: describe(),
211
+ ...(full ? { ...merged(), events: store.state.events } : {}),
212
+ });
213
+
214
+ const handOffSource = () => ({
215
+ data: merged(), turn: rows().turn, feed: rows().feed, workspace: describe(), rules: definition.rules,
216
+ moves: myMoves(rows().turn, store.marks), todos: todosWithLinks().filter((todo) => !todo.done), errors: store.state.errors,
217
+ handled: (row) => isHandled(row, store.marks),
218
+ });
219
+ const handOff = (kind, ref) => buildHandOff(kind, ref, handOffSource());
220
+ const handOffBody = (kind, ref) => {
221
+ const context = handOff(kind, ref);
222
+ return context ? renderHandOff(kind, context) : null;
223
+ };
224
+ const handOffText = (kind, ref) => {
225
+ const body = handOffBody(kind, ref);
226
+ return body && frameHandOff(definition.name, [body]);
227
+ };
228
+ const handOffSnapshot = (kind, ref) => snapshotOf(kind, ref, handOffSource());
229
+
230
+ const summary = () => {
231
+ const seen = new Set(store.marks.seen);
232
+ const unread = rows().feed.filter((item) => !seen.has(item.id) && new Date(item.at).getTime() > store.marks.base).length;
233
+ 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() };
234
+ };
235
+
236
+ const updateMarks = (body) => {
237
+ const marks = store.marks;
238
+ if (body.seen) {
239
+ marks.seen = unionCapped(marks.seen, body.seen);
240
+ }
241
+ if ('base' in body) {
242
+ marks.base = body.base;
243
+ }
244
+ for (const [rowId, value] of Object.entries(body.handled || {})) {
245
+ if (value === null) {
246
+ delete marks.handled[rowId];
247
+ } else {
248
+ marks.handled[rowId] = value;
249
+ }
250
+ }
251
+ store.saveMarks();
252
+ emit();
253
+ return marked();
254
+ };
255
+
256
+ const addTodo = (body) => {
257
+ const text = String(body.text).trim().slice(0, 500);
258
+ store.todos.unshift({ id: `t${Date.now()}`, text, key: body.key || keyOf(text), created: nowIso(), by: String(body.by || 'you').slice(0, 20), done: null });
259
+ };
260
+
261
+ const changeTodo = (todo, body) => {
262
+ switch (body.action) {
263
+ case 'done':
264
+ todo.done = nowIso();
265
+ return true;
266
+ case 'undo':
267
+ todo.done = null;
268
+ return true;
269
+ case 'delete':
270
+ store.todos.splice(store.todos.indexOf(todo), 1);
271
+ return true;
272
+ case 'edit':
273
+ if (!String(body.text || '').trim()) {
274
+ return false;
275
+ }
276
+ todo.text = String(body.text).trim().slice(0, 500);
277
+ todo.key = keyOf(todo.text) || todo.key || '';
278
+ return true;
279
+ default:
280
+ return false;
281
+ }
282
+ };
283
+
284
+ const saveTodos = () => {
285
+ store.saveTodos();
286
+ store.state.version += 1;
287
+ emit();
288
+ };
289
+
290
+ return {
291
+ id: definition.id, name: definition.name, rulesTable: rules.table, store, poll, snapshot, summary, describe, checkHealth, handOff, handOffBody, handOffText, handOffSnapshot, updateMarks, addTodo, changeTodo, saveTodos,
292
+ subscribe: (listener) => { listeners.add(listener); return () => listeners.delete(listener); },
293
+ };
294
+ };
package/open.sh ADDED
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env bash
2
+ # Opens the monitor as its own Chrome or Edge app window at the remembered size, or in a browser tab with --browser or when neither is installed.
3
+ port=${WHOSE_TURN_PORT:-8765}
4
+ base=http://127.0.0.1:$port
5
+ width=1500
6
+ height=950
7
+
8
+ size=$(curl -fs --max-time 1 "$base/api/window" 2>/dev/null)
9
+ [[ $size =~ \"width\":([0-9]+) ]] && width=${BASH_REMATCH[1]}
10
+ [[ $size =~ \"height\":([0-9]+) ]] && height=${BASH_REMATCH[1]}
11
+
12
+ tab() {
13
+ if [[ $(uname) == Darwin ]]; then open "$base/"; else xdg-open "$base/"; fi
14
+ }
15
+
16
+ if [[ $1 == --browser ]]; then
17
+ tab
18
+ exit
19
+ fi
20
+
21
+ url="$base/?mode=app"
22
+ size_flag=--window-size=$width,$height
23
+
24
+ if [[ $(uname) == Darwin ]]; then
25
+ if open -Ra "Google Chrome" 2>/dev/null; then
26
+ open -na "Google Chrome" --args --app="$url" "$size_flag"
27
+ else
28
+ tab
29
+ fi
30
+ exit
31
+ fi
32
+
33
+ for browser in google-chrome chromium chromium-browser microsoft-edge; do
34
+ if command -v "$browser" >/dev/null; then
35
+ "$browser" --app="$url" "$size_flag" >/dev/null 2>&1 &
36
+ disown
37
+ exit
38
+ fi
39
+ done
40
+ tab
package/package.json CHANGED
@@ -1,6 +1,69 @@
1
1
  {
2
2
  "name": "whose-turn",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0-alpha.3",
4
+ "license": "MIT",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/aquivalabs/whose-turn.git"
8
+ },
9
+ "files": [
10
+ "server.mjs",
11
+ "lib/",
12
+ "connectors/",
13
+ "dist/",
14
+ "install.sh",
15
+ "open.sh",
16
+ "config.example.json",
17
+ "CHANGELOG.md"
18
+ ],
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "description": "Local monitor that answers what is waiting on you across Jira, GitHub and Linear.",
23
+ "type": "module",
24
+ "engines": {
25
+ "node": ">=22"
26
+ },
27
+ "scripts": {
28
+ "prepare": "git config core.hooksPath .githooks 2>/dev/null || true",
29
+ "start": "node server.mjs",
30
+ "dev": "WHOSE_TURN_DATA=.users-files/dev WHOSE_TURN_PORT=8766 node server.mjs",
31
+ "test": "node --experimental-strip-types --test \"test/**/*.test.mjs\"",
32
+ "dev:web": "vite",
33
+ "build": "vue-tsc --noEmit && vite build",
34
+ "typecheck": "vue-tsc --noEmit && tsc -p tsconfig.server.json",
35
+ "lint": "eslint web/src lib connectors server.mjs test",
36
+ "lint:style": "stylelint \"web/src/**/*.{vue,css}\"",
37
+ "format": "prettier --write web/src",
38
+ "check": "npm run typecheck && npm run lint && npm run lint:style && npm test && npm run test:web",
39
+ "test:web": "vitest run"
40
+ },
41
+ "bin": {
42
+ "whose-turn": "server.mjs"
43
+ },
44
+ "devDependencies": {
45
+ "@eslint/js": "10.0.1",
46
+ "@testing-library/dom": "10.4.2",
47
+ "@testing-library/vue": "8.1.0",
48
+ "@types/node": "22.19.19",
49
+ "@vitejs/plugin-vue": "^5.2.0",
50
+ "@vue/test-utils": "2.5.1",
51
+ "@vueuse/core": "15.0.0",
52
+ "eslint": "10.13.0",
53
+ "eslint-config-prettier": "10.1.8",
54
+ "eslint-plugin-vue": "10.11.1",
55
+ "happy-dom": "21.0.4",
56
+ "postcss-html": "2.0.0",
57
+ "prettier": "3.9.10",
58
+ "stylelint": "17.16.0",
59
+ "stylelint-config-html": "2.0.0",
60
+ "stylelint-config-standard": "40.0.0",
61
+ "typescript": "6.0.3",
62
+ "typescript-eslint": "8.71.2",
63
+ "vite": "^6.0.0",
64
+ "vitest": "5.1.0",
65
+ "vue": "^3.5.0",
66
+ "vue-eslint-parser": "10.4.1",
67
+ "vue-tsc": "3.3.13"
68
+ }
69
+ }
package/server.mjs ADDED
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env node
2
+ /** Whose Turn server: loads the workspace config, polls every workspace on one loop with rate-limit backoff, checks data health on a timer, serves the window and the local API. */
3
+ import { readFileSync } from 'node:fs';
4
+ import { dirname, join } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { loadConfig, saveWorkspaces, normaliseWorkspaces } from './lib/config.mjs';
7
+ import { createHttpClient } from './lib/http-client.mjs';
8
+ import { nextDelay } from './lib/backoff.mjs';
9
+ import { createSecrets } from './lib/secrets.mjs';
10
+ import { createWorkspace } from './lib/workspace.mjs';
11
+ import { createRegistry } from './lib/registry.mjs';
12
+ import { openInbox } from './lib/inbox.mjs';
13
+ import { openReports } from './lib/feedback.mjs';
14
+ import { startApi } from './lib/api.mjs';
15
+ import { notify } from './lib/notify.mjs';
16
+ import { createUpdater, restartServer, runsInstalled } from './lib/update.mjs';
17
+
18
+ const appDir = dirname(fileURLToPath(import.meta.url));
19
+ const http = createHttpClient();
20
+ const first = loadConfig();
21
+ const secrets = createSecrets(first.dataDir);
22
+ const inbox = openInbox(first.dataDir);
23
+ const reports = openReports(first.dataDir);
24
+ const { version } = JSON.parse(readFileSync(join(appDir, 'package.json'), 'utf8'));
25
+ let server;
26
+ const updater = createUpdater({
27
+ http,
28
+ dataDir: first.dataDir,
29
+ version,
30
+ installed: runsInstalled(appDir, first.dataDir),
31
+ notify,
32
+ registry: process.env.WHOSE_TURN_REGISTRY,
33
+ npm: process.env.WHOSE_TURN_NPM,
34
+ restart: () => restartServer({ server, root: appDir, dataDir: first.dataDir }),
35
+ });
36
+ let pollSeconds = first.pollSeconds;
37
+ /** @type {{ stale_minutes: number, failing_polls: number }} */
38
+ let healthLimits = /** @type {any} */ (first.health);
39
+
40
+ const registry = createRegistry({
41
+ load: () => {
42
+ const config = loadConfig();
43
+ pollSeconds = config.pollSeconds;
44
+ healthLimits = /** @type {any} */ (config.health);
45
+ return config;
46
+ },
47
+ save: (rawWorkspaces) => saveWorkspaces(first.configFile, rawWorkspaces),
48
+ normalise: (rawWorkspaces) => normaliseWorkspaces(rawWorkspaces).workspaces,
49
+ build: (definition, config) => createWorkspace(definition, { dataDir: config.dataDir, historyDays: config.historyDays, http, secrets, limits: () => healthLimits }),
50
+ });
51
+
52
+ let wake = null;
53
+ const requestPoll = () => wake?.();
54
+ const sleep = (seconds) => new Promise((resolve) => {
55
+ const timer = setTimeout(resolve, seconds * 1000);
56
+ wake = () => { clearTimeout(timer); resolve(); };
57
+ });
58
+
59
+ const pollLoop = async () => {
60
+ let delay = pollSeconds;
61
+ for (;;) {
62
+ const outcomes = await Promise.all(registry.workspaces.map((workspace) => workspace.poll()));
63
+ delay = nextDelay(outcomes, delay, pollSeconds);
64
+ await sleep(delay);
65
+ }
66
+ };
67
+
68
+ const saveConfig = async (rawWorkspaces) => {
69
+ const created = await registry.save(rawWorkspaces);
70
+ await Promise.all(created.map((workspace) => workspace.poll()));
71
+ };
72
+
73
+ try {
74
+ server = await startApi({ port: first.port, appDir, registry, requestPoll, saveConfig, secrets, inbox, http, reports, dataDir: first.dataDir, updater });
75
+ } catch (error) {
76
+ console.error(error.message);
77
+ process.exit(1);
78
+ }
79
+ console.log(`whose-turn ${version} on http://127.0.0.1:${first.port} · data ${first.dataDir} · workspaces ${registry.workspaces.map((workspace) => workspace.id).join(', ') || 'none'}`);
80
+ for (const problem of registry.problems) {
81
+ console.error(problem);
82
+ }
83
+ pollLoop();
84
+ updater.start();
85
+
86
+ const HEALTH_TICK = 30000;
87
+ let lastTick = Date.now();
88
+ setInterval(() => {
89
+ const slept = Date.now() - lastTick > 3 * HEALTH_TICK;
90
+ lastTick = Date.now();
91
+ if (slept) {
92
+ requestPoll();
93
+ return;
94
+ }
95
+ registry.workspaces.forEach((workspace) => workspace.checkHealth());
96
+ }, HEALTH_TICK).unref();