moontide-mcp 2.1.0 → 2.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.
- package/README.md +54 -331
- package/dist/ask.js +59 -12
- package/dist/catalog.js +118 -9
- package/dist/instruments.js +83 -9
- package/dist/mcp.js +146 -20
- package/dist/midi-state.js +43 -1
- package/dist/protocol.js +123 -5
- package/dist/reference.js +12 -0
- package/dist/request-status.js +221 -0
- package/dist/session.js +203 -7
- package/dist/vocabulary.js +420 -0
- package/package.json +3 -2
- package/page/ask-view.js +118 -50
- package/page/ask.js +139 -169
- package/page/bar-model.js +44 -2
- package/page/change-timing.js +35 -0
- package/page/index.html +6 -2
- package/page/instruments-model.js +50 -3
- package/page/instruments-view.js +14 -2
- package/page/jam-analysis.js +68 -23
- package/page/jam-model.js +185 -29
- package/page/jam-resolve.js +271 -0
- package/page/jam-view.js +165 -10
- package/page/lights.js +29 -7
- package/page/main.js +807 -66
- package/page/midi-clock.js +22 -3
- package/page/midi-in.js +407 -0
- package/page/midi-out.js +557 -48
- package/page/midi-plan.js +42 -6
- package/page/midi-rig.js +286 -13
- package/page/midi-settings.js +507 -0
- package/page/mixer-view.js +7 -1
- package/page/note-panel.js +29 -8
- package/page/note-tag.js +19 -4
- package/page/page-rewrite.js +1 -1
- package/page/played-buffer.js +208 -0
- package/page/query-check.js +21 -2
- package/page/request-status-view.js +419 -0
- package/page/request-status.js +686 -0
- package/page/scratch-gesture.js +239 -0
- package/page/scratch-listen.js +260 -0
- package/page/scratch-model.js +1696 -0
- package/page/scratch-view.js +2176 -0
- package/page/song-grid-model.js +15 -0
- package/page/song-grid-view.js +1 -1
- package/page/structure.js +2 -1
- package/page/style.css +1243 -43
- package/page/tempo-glide.js +330 -0
- package/page/tempo-guard.js +62 -10
- package/page/timeline-edit-view.js +1 -1
- package/page/timeline.js +2 -1
- package/page/top-bar-view.js +18 -13
- package/page/views.js +20 -7
- package/reference/midi.md +2 -0
- package/reference/motif.md +83 -0
- package/reference/settings.md +161 -0
- package/reference/transitions.md +13 -1
|
@@ -0,0 +1,686 @@
|
|
|
1
|
+
// What became of each request the user sent from this page. A pure module: no DOM access at import or anywhere
|
|
2
|
+
// else, and every clock is injected (page/main.js passes Date.now and setTimeout, the tests pass a fake time).
|
|
3
|
+
// The server says what it knows (src/request-status.ts, src/protocol.ts: ask-result, ask-received, ask-status and
|
|
4
|
+
// ask-board); this tracker keeps one row per request id, moves a row only forward, and words each state. The view
|
|
5
|
+
// (page/request-status-view.js) draws the model and never decides anything.
|
|
6
|
+
//
|
|
7
|
+
// The page says what it observed, never more: Applied and Waiting for its line come only from a message that links
|
|
8
|
+
// a change to the request, and a request nobody answers is "No change yet", never a failure.
|
|
9
|
+
|
|
10
|
+
import { ASK_CONFIRM_TIMEOUT_MS, refusalText } from './ask.js';
|
|
11
|
+
|
|
12
|
+
/** After this long without news a request that is not at its line and not finished reads "No change yet". */
|
|
13
|
+
export const QUIET_AFTER_MS = 30000;
|
|
14
|
+
|
|
15
|
+
/** A finished request stays in the list this long, then moves to the Recent group. */
|
|
16
|
+
export const FINISHED_SHOWN_MS = 30000;
|
|
17
|
+
|
|
18
|
+
/** How many finished requests the Recent group keeps; the oldest drops off the page. */
|
|
19
|
+
export const RECENT_MAX = 5;
|
|
20
|
+
|
|
21
|
+
/** A request's row text: its first characters (by code point), then an ellipsis. */
|
|
22
|
+
export const ROW_TEXT_MAX = 40;
|
|
23
|
+
|
|
24
|
+
/** A failure reason in a row: its first line, cut here. */
|
|
25
|
+
export const REASON_ROW_MAX = 80;
|
|
26
|
+
|
|
27
|
+
/** A request's text inside a spoken sentence. */
|
|
28
|
+
export const LIVE_TEXT_MAX = 40;
|
|
29
|
+
|
|
30
|
+
/** At most this many sentences are spoken together; the rest are counted. */
|
|
31
|
+
export const LIVE_JOIN_MAX = 3;
|
|
32
|
+
|
|
33
|
+
/** The request text in an opened row's detail. */
|
|
34
|
+
export const DETAIL_TEXT_MAX = 200;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Whether the page shows what only a change that names its request can say: Waiting for its line, Applied as vN,
|
|
38
|
+
* Failed, Replaced and Canceled. True because it was measured in fresh Claude sessions before this shipped: Claude
|
|
39
|
+
* named the request in at least four of five changes made for a request. A state that is not promised is never
|
|
40
|
+
* shown: its message is ignored with one console line and the row stays on its last honest state.
|
|
41
|
+
*/
|
|
42
|
+
export const LINK_PROMISED = true;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Whether the page shows what only the closing tool can say: Answered and Declined with Claude's sentence. True
|
|
46
|
+
* because it was measured in fresh Claude sessions before this shipped: Claude closed at least four of five
|
|
47
|
+
* requests that needed no change to the music.
|
|
48
|
+
*/
|
|
49
|
+
export const CLOSE_PROMISED = true;
|
|
50
|
+
|
|
51
|
+
/** The states the server names, in the order a request moves through them. page/request-status.js mirrors src/request-status.ts by hand. */
|
|
52
|
+
export const SERVER_REQUEST_STATES = Object.freeze([
|
|
53
|
+
'sent',
|
|
54
|
+
'waiting',
|
|
55
|
+
'received',
|
|
56
|
+
'started',
|
|
57
|
+
'line',
|
|
58
|
+
'applied',
|
|
59
|
+
'failed',
|
|
60
|
+
'replaced',
|
|
61
|
+
'canceled',
|
|
62
|
+
'answered',
|
|
63
|
+
'declined',
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
/** The states that only a message linking a change to the request can bring. */
|
|
67
|
+
export const LINK_STATES = Object.freeze(['line', 'applied', 'failed', 'replaced', 'canceled']);
|
|
68
|
+
|
|
69
|
+
/** The states that only the closing tool can bring. */
|
|
70
|
+
export const CLOSE_STATES = Object.freeze(['answered', 'declined']);
|
|
71
|
+
|
|
72
|
+
// Rows end in one of these; nothing follows. notsent and lost are the page's own: the server refused or never
|
|
73
|
+
// confirmed the request, or the connection closed while it was open.
|
|
74
|
+
const FINAL_STATES = Object.freeze(['applied', 'failed', 'replaced', 'canceled', 'answered', 'declined', 'notsent', 'lost']);
|
|
75
|
+
|
|
76
|
+
const ORDER = Object.freeze(['sent', 'waiting', 'received', 'started', 'line']);
|
|
77
|
+
|
|
78
|
+
/** The one copy object of the Requests list. Words in braces are filled by fill(). */
|
|
79
|
+
export const REQUEST_STATUS_COPY = Object.freeze({
|
|
80
|
+
chipWord: 'Requests',
|
|
81
|
+
chipTitle: 'Requests sent from this page',
|
|
82
|
+
chipNameNone: 'Requests, none in flight',
|
|
83
|
+
chipNameFlight: 'Requests, {n} in flight',
|
|
84
|
+
panelName: 'Requests',
|
|
85
|
+
recentHead: 'Recent · {n}',
|
|
86
|
+
recentName: 'Recent requests, {n}',
|
|
87
|
+
recentOpen: '▾',
|
|
88
|
+
recentFolded: '▸',
|
|
89
|
+
notConfirmed: 'Moontide did not confirm the request.',
|
|
90
|
+
startedBasis: 'Claude made a call after this request arrived.',
|
|
91
|
+
basisLead: 'Basis: ',
|
|
92
|
+
phrases: Object.freeze({
|
|
93
|
+
sent: 'Sent',
|
|
94
|
+
received: 'Received',
|
|
95
|
+
waiting: "Waiting for Claude's next action",
|
|
96
|
+
started: 'Claude has started on it',
|
|
97
|
+
line: 'Waiting for its line',
|
|
98
|
+
applied: 'Applied as v{n}',
|
|
99
|
+
appliedNoVersion: 'Applied',
|
|
100
|
+
answered: 'Answered: {sentence}',
|
|
101
|
+
declined: 'Declined: {sentence}',
|
|
102
|
+
failed: 'Failed: {reason}',
|
|
103
|
+
quiet: 'No change yet · {elapsed}',
|
|
104
|
+
replaced: 'Replaced by a newer change',
|
|
105
|
+
canceled: 'Canceled',
|
|
106
|
+
notsent: 'Not sent: {reason}',
|
|
107
|
+
lost: 'Connection lost',
|
|
108
|
+
}),
|
|
109
|
+
elapsedSeconds: '{n} s',
|
|
110
|
+
elapsedMinutes: '{n} min',
|
|
111
|
+
elapsedHours: '{h} h',
|
|
112
|
+
elapsedHoursMinutes: '{h} h {m} min',
|
|
113
|
+
took: 'Took {elapsed}',
|
|
114
|
+
about: 'About: {chip}',
|
|
115
|
+
meanings: Object.freeze({
|
|
116
|
+
sent: 'Moontide accepted the request.',
|
|
117
|
+
received: 'Claude was handed the request.',
|
|
118
|
+
waiting: "The request waits until Claude's next Moontide call.",
|
|
119
|
+
line: "Claude's change is ready and takes over at its line.",
|
|
120
|
+
lineBar: ' Next bar.',
|
|
121
|
+
linePhrase: ' Next phrase.',
|
|
122
|
+
applied: 'Claude linked version v{n} to this request.',
|
|
123
|
+
appliedNoVersion: 'Claude linked a change to this request; the history could not save it, so it has no version number.',
|
|
124
|
+
quiet: 'Claude has not linked a change or an answer to this request.',
|
|
125
|
+
replaced: 'A newer change took its place before it reached its line.',
|
|
126
|
+
canceled: 'The change was canceled before it reached its line.',
|
|
127
|
+
lost: 'Moontide lost the connection, so the page cannot tell what happened to this request.',
|
|
128
|
+
}),
|
|
129
|
+
live: Object.freeze({
|
|
130
|
+
sent: 'Request "{text}": sent.',
|
|
131
|
+
received: 'Request "{text}": received.',
|
|
132
|
+
waiting: 'Request "{text}": waiting for Claude\'s next action.',
|
|
133
|
+
started: 'Request "{text}": Claude has started on it.',
|
|
134
|
+
line: 'Request "{text}": waiting for its line.',
|
|
135
|
+
applied: 'Request "{text}": applied as v{n}.',
|
|
136
|
+
appliedNoVersion: 'Request "{text}": applied.',
|
|
137
|
+
answered: 'Request "{text}" answered: {sentence}',
|
|
138
|
+
declined: 'Request "{text}" declined: {sentence}',
|
|
139
|
+
quiet: 'Request "{text}": no change yet.',
|
|
140
|
+
replaced: 'Request "{text}": replaced by a newer change.',
|
|
141
|
+
canceled: 'Request "{text}": canceled.',
|
|
142
|
+
lostOne: 'Connection lost. The page cannot tell what happened to 1 open request.',
|
|
143
|
+
lostMany: 'Connection lost. The page cannot tell what happened to {n} open requests.',
|
|
144
|
+
moreOne: 'and 1 more update.',
|
|
145
|
+
moreMany: 'and {k} more updates.',
|
|
146
|
+
}),
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
/** Fills the words in braces of `template` from `values`; a word with no value is a loud error. */
|
|
150
|
+
function fill(template, values) {
|
|
151
|
+
return template.replace(/\{(\w+)\}/g, (_, key) => {
|
|
152
|
+
if (!(key in values)) throw new Error(`Moontide: the request wording "${template}" needs ${key}.`);
|
|
153
|
+
return String(values[key]);
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** True for a state a request ends in. */
|
|
158
|
+
export function isFinalState(state) {
|
|
159
|
+
return FINAL_STATES.includes(state);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Where a state stands: sent 0, waiting 1, received 2, started 3, line 4 and every final state 5. A message that
|
|
164
|
+
* does not raise the rank changes nothing. An unknown state is a loud error.
|
|
165
|
+
*/
|
|
166
|
+
export function stateRank(state) {
|
|
167
|
+
if (isFinalState(state)) return ORDER.length;
|
|
168
|
+
const rank = ORDER.indexOf(state);
|
|
169
|
+
if (rank === -1) throw new Error(`Moontide: unknown request state "${state}".`);
|
|
170
|
+
return rank;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** `text` cut to at most `max` code points, the cut marked with an ellipsis; a surrogate pair is never split. */
|
|
174
|
+
export function cutCodePoints(text, max) {
|
|
175
|
+
const points = Array.from(text);
|
|
176
|
+
if (points.length <= max) return text;
|
|
177
|
+
return `${points.slice(0, max).join('')}…`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** The text of a row: whitespace and line breaks collapsed to single spaces, then cut to ROW_TEXT_MAX code points. */
|
|
181
|
+
export function rowText(text) {
|
|
182
|
+
return cutCodePoints(text.replace(/\s+/gu, ' ').trim(), ROW_TEXT_MAX);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** A duration in whole units rounded down: 7 s, 42 s, 1 min, 12 min, 1 h, 1 h 5 min. */
|
|
186
|
+
export function formatElapsed(ms) {
|
|
187
|
+
const seconds = Math.max(0, Math.floor(ms / 1000));
|
|
188
|
+
if (seconds < 60) return fill(REQUEST_STATUS_COPY.elapsedSeconds, { n: seconds });
|
|
189
|
+
const minutes = Math.floor(seconds / 60);
|
|
190
|
+
if (minutes < 60) return fill(REQUEST_STATUS_COPY.elapsedMinutes, { n: minutes });
|
|
191
|
+
const hours = Math.floor(minutes / 60);
|
|
192
|
+
const rest = minutes % 60;
|
|
193
|
+
if (rest === 0) return fill(REQUEST_STATUS_COPY.elapsedHours, { h: hours });
|
|
194
|
+
return fill(REQUEST_STATUS_COPY.elapsedHoursMinutes, { h: hours, m: rest });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function firstLine(text) {
|
|
198
|
+
return String(text).split(/\r\n|\r|\n/u)[0].trim();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The words of a row's state. `row` carries `state` (a server state, notsent, lost or quiet) and what the state
|
|
203
|
+
* needs: `version` for applied, `answer` for answered and declined, `reason` for failed and notsent, `quietFor`
|
|
204
|
+
* (milliseconds since the last news) for quiet. An unknown state throws, as the page's other copy lookups do.
|
|
205
|
+
*/
|
|
206
|
+
export function statePhrase(row) {
|
|
207
|
+
const phrases = REQUEST_STATUS_COPY.phrases;
|
|
208
|
+
switch (row.state) {
|
|
209
|
+
case 'sent':
|
|
210
|
+
case 'received':
|
|
211
|
+
case 'waiting':
|
|
212
|
+
case 'started':
|
|
213
|
+
case 'line':
|
|
214
|
+
case 'replaced':
|
|
215
|
+
case 'canceled':
|
|
216
|
+
case 'lost':
|
|
217
|
+
return phrases[row.state];
|
|
218
|
+
case 'applied':
|
|
219
|
+
return Number.isInteger(row.version) ? fill(phrases.applied, { n: row.version }) : phrases.appliedNoVersion;
|
|
220
|
+
case 'answered':
|
|
221
|
+
case 'declined':
|
|
222
|
+
return fill(phrases[row.state], { sentence: row.answer ?? '' });
|
|
223
|
+
case 'failed':
|
|
224
|
+
return fill(phrases.failed, { reason: cutCodePoints(firstLine(row.reason ?? ''), REASON_ROW_MAX) });
|
|
225
|
+
case 'notsent':
|
|
226
|
+
return fill(phrases.notsent, { reason: row.reason ?? '' });
|
|
227
|
+
case 'quiet':
|
|
228
|
+
return fill(phrases.quiet, { elapsed: formatElapsed(row.quietFor ?? 0) });
|
|
229
|
+
default:
|
|
230
|
+
throw new Error(`Moontide: no wording for the request state "${row.state}".`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** The shape beside a state's words: flight (still going), done, ended (without a change) or bad (did not work). */
|
|
235
|
+
export function stateMark(state) {
|
|
236
|
+
switch (state) {
|
|
237
|
+
case 'sent':
|
|
238
|
+
case 'waiting':
|
|
239
|
+
case 'received':
|
|
240
|
+
case 'started':
|
|
241
|
+
case 'line':
|
|
242
|
+
case 'quiet':
|
|
243
|
+
return 'flight';
|
|
244
|
+
case 'applied':
|
|
245
|
+
case 'answered':
|
|
246
|
+
return 'done';
|
|
247
|
+
case 'declined':
|
|
248
|
+
case 'replaced':
|
|
249
|
+
case 'canceled':
|
|
250
|
+
case 'lost':
|
|
251
|
+
return 'ended';
|
|
252
|
+
case 'failed':
|
|
253
|
+
case 'notsent':
|
|
254
|
+
return 'bad';
|
|
255
|
+
default:
|
|
256
|
+
throw new Error(`Moontide: no mark for the request state "${state}".`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function collapse(text) {
|
|
261
|
+
return String(text).replace(/\s+/gu, ' ').trim();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// What the state means, one line under the request in an opened row.
|
|
265
|
+
function meaningLine(row) {
|
|
266
|
+
const meanings = REQUEST_STATUS_COPY.meanings;
|
|
267
|
+
switch (row.state) {
|
|
268
|
+
case 'sent':
|
|
269
|
+
case 'received':
|
|
270
|
+
case 'waiting':
|
|
271
|
+
case 'quiet':
|
|
272
|
+
case 'replaced':
|
|
273
|
+
case 'canceled':
|
|
274
|
+
case 'lost':
|
|
275
|
+
return { kind: 'meaning', text: meanings[row.state] };
|
|
276
|
+
case 'started':
|
|
277
|
+
return { kind: 'basis', text: `${REQUEST_STATUS_COPY.basisLead}${REQUEST_STATUS_COPY.startedBasis}` };
|
|
278
|
+
case 'line': {
|
|
279
|
+
const when = row.at === 'bar' ? meanings.lineBar : row.at === 'phrase' ? meanings.linePhrase : '';
|
|
280
|
+
return { kind: 'meaning', text: `${meanings.line}${when}` };
|
|
281
|
+
}
|
|
282
|
+
case 'applied':
|
|
283
|
+
return { kind: 'meaning', text: Number.isInteger(row.version) ? fill(meanings.applied, { n: row.version }) : meanings.appliedNoVersion };
|
|
284
|
+
case 'answered':
|
|
285
|
+
case 'declined':
|
|
286
|
+
return row.answer ? { kind: 'meaning', text: row.answer } : null;
|
|
287
|
+
case 'failed':
|
|
288
|
+
return row.reason ? { kind: 'reason', text: row.reason } : null;
|
|
289
|
+
case 'notsent':
|
|
290
|
+
return row.reason ? { kind: 'meaning', text: row.reason } : null;
|
|
291
|
+
default:
|
|
292
|
+
throw new Error(`Moontide: no detail for the request state "${row.state}".`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* The lines of an opened row, in order: the request as written (cut to DETAIL_TEXT_MAX), the context it was sent
|
|
298
|
+
* with, and what the state means. A line with nothing to say is left out. `row` carries `text`, `chip`, `state` and
|
|
299
|
+
* what the state needs (`version`, `at`, `answer`, `reason`). Each line is `{ kind, text }`; the kind (request,
|
|
300
|
+
* about, meaning, basis or reason) tells the view how to set it.
|
|
301
|
+
*/
|
|
302
|
+
export function detailLines(row) {
|
|
303
|
+
const lines = [];
|
|
304
|
+
if (row.text) lines.push({ kind: 'request', text: cutCodePoints(row.text, DETAIL_TEXT_MAX) });
|
|
305
|
+
if (row.chip) lines.push({ kind: 'about', text: fill(REQUEST_STATUS_COPY.about, { chip: row.chip }) });
|
|
306
|
+
const meaning = meaningLine(row);
|
|
307
|
+
if (meaning !== null) lines.push(meaning);
|
|
308
|
+
return lines;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* The sentence the page speaks for one change of a request, or null when the page says nothing: a failure and a
|
|
313
|
+
* refusal already have their own alert. `event` carries `state`, the request's `text` (cut to LIVE_TEXT_MAX) and what
|
|
314
|
+
* the state needs; a lost connection carries `n`, the number of open requests. An unknown state throws.
|
|
315
|
+
*/
|
|
316
|
+
export function liveSentence(event) {
|
|
317
|
+
const copy = REQUEST_STATUS_COPY.live;
|
|
318
|
+
const { state } = event;
|
|
319
|
+
if (state === 'failed' || state === 'notsent') return null;
|
|
320
|
+
if (state === 'lost') return fill(event.n === 1 ? copy.lostOne : copy.lostMany, { n: event.n });
|
|
321
|
+
const text = cutCodePoints(collapse(event.text ?? ''), LIVE_TEXT_MAX);
|
|
322
|
+
switch (state) {
|
|
323
|
+
case 'sent':
|
|
324
|
+
case 'received':
|
|
325
|
+
case 'waiting':
|
|
326
|
+
case 'started':
|
|
327
|
+
case 'line':
|
|
328
|
+
case 'quiet':
|
|
329
|
+
case 'replaced':
|
|
330
|
+
case 'canceled':
|
|
331
|
+
return fill(copy[state], { text });
|
|
332
|
+
case 'applied':
|
|
333
|
+
return Number.isInteger(event.version) ? fill(copy.applied, { text, n: event.version }) : fill(copy.appliedNoVersion, { text });
|
|
334
|
+
case 'answered':
|
|
335
|
+
case 'declined':
|
|
336
|
+
return fill(copy[state], { text, sentence: event.answer ?? '' });
|
|
337
|
+
default:
|
|
338
|
+
throw new Error(`Moontide: no sentence for the request state "${state}".`);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** The sentences spoken together: at most LIVE_JOIN_MAX, joined with a space, then a count of the rest. */
|
|
343
|
+
export function joinLive(sentences) {
|
|
344
|
+
if (sentences.length <= LIVE_JOIN_MAX) return sentences.join(' ');
|
|
345
|
+
const more = sentences.length - LIVE_JOIN_MAX;
|
|
346
|
+
const tail = more === 1 ? REQUEST_STATUS_COPY.live.moreOne : fill(REQUEST_STATUS_COPY.live.moreMany, { k: more });
|
|
347
|
+
return `${sentences.slice(0, LIVE_JOIN_MAX).join(' ')} ${tail}`;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Creates the tracker. `now()` is the clock in milliseconds; `setTimer(fn, ms)` and `clearTimer(timer)` are the page's
|
|
352
|
+
* timers: the tracker keeps at most one, set for the next change that is due (a request nobody confirmed, a request
|
|
353
|
+
* that reached its quiet wait, a finished request that leaves the list) and never a repeating one. `onChange()` runs
|
|
354
|
+
* after every change of the model. `onNotConfirmed(id)` runs when a request the server never confirmed is given up
|
|
355
|
+
* (it was not confirmed in time, or the connection closed first). `promised` says which groups of states the page may
|
|
356
|
+
* show (default: what was measured).
|
|
357
|
+
*/
|
|
358
|
+
export function createRequestTracker({
|
|
359
|
+
now,
|
|
360
|
+
setTimer,
|
|
361
|
+
clearTimer,
|
|
362
|
+
onChange = () => {},
|
|
363
|
+
onNotConfirmed = () => {},
|
|
364
|
+
promised = { link: LINK_PROMISED, close: CLOSE_PROMISED },
|
|
365
|
+
confirmTimeoutMs = ASK_CONFIRM_TIMEOUT_MS,
|
|
366
|
+
}) {
|
|
367
|
+
if (typeof now !== 'function' || typeof setTimer !== 'function' || typeof clearTimer !== 'function') {
|
|
368
|
+
throw new Error('Moontide: the request tracker needs a clock and timers.');
|
|
369
|
+
}
|
|
370
|
+
// id -> row, in the order the rows were opened.
|
|
371
|
+
const rows = new Map();
|
|
372
|
+
// Console lines already said, one per cause.
|
|
373
|
+
const said = new Set();
|
|
374
|
+
// The sentences queued for the spoken line since takeLive() was last called.
|
|
375
|
+
let spoken = [];
|
|
376
|
+
let timer = null;
|
|
377
|
+
let opened = 0;
|
|
378
|
+
|
|
379
|
+
function sayOnce(cause, line) {
|
|
380
|
+
if (said.has(cause)) return;
|
|
381
|
+
said.add(cause);
|
|
382
|
+
console.error(line);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function makeRow(id, { text, chip }, openedAt) {
|
|
386
|
+
opened += 1;
|
|
387
|
+
return {
|
|
388
|
+
id,
|
|
389
|
+
text,
|
|
390
|
+
chip,
|
|
391
|
+
state: 'sent',
|
|
392
|
+
order: opened,
|
|
393
|
+
openedAt,
|
|
394
|
+
newsAt: openedAt,
|
|
395
|
+
finishedAt: null,
|
|
396
|
+
version: null,
|
|
397
|
+
at: null,
|
|
398
|
+
reason: '',
|
|
399
|
+
answer: '',
|
|
400
|
+
// True once the server has said anything about the request.
|
|
401
|
+
confirmed: false,
|
|
402
|
+
// True once the quiet wait was spoken; news makes it speakable again.
|
|
403
|
+
quietSaid: false,
|
|
404
|
+
// True while the pointer, focus or an open detail keeps a finished row where it is.
|
|
405
|
+
held: false,
|
|
406
|
+
inRecent: false,
|
|
407
|
+
movedAt: 0,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const isFinal = (row) => isFinalState(row.state);
|
|
412
|
+
|
|
413
|
+
/** How long a row has been quiet, or null when it is not: not at its line, not finished, 30 s without news. */
|
|
414
|
+
function quietFor(row, at) {
|
|
415
|
+
if (isFinal(row) || row.state === 'line') return null;
|
|
416
|
+
return at - row.newsAt >= QUIET_AFTER_MS ? at - row.newsAt : null;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function speak(row, state) {
|
|
420
|
+
const sentence = liveSentence({ state, text: row.text, version: row.version, answer: row.answer });
|
|
421
|
+
if (sentence !== null) spoken.push(sentence);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function known(id, type) {
|
|
425
|
+
const row = rows.get(id);
|
|
426
|
+
if (row === undefined) sayOnce(`unknown-id:${type}`, `Moontide: a ${type} message names a request this page did not send; it was ignored.`);
|
|
427
|
+
return row;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** Moves a row to `state` when that is higher than where it is; returns whether it moved. */
|
|
431
|
+
function advance(row, state, extra = {}, { news = now(), silent = false } = {}) {
|
|
432
|
+
if (stateRank(state) <= stateRank(row.state)) return false;
|
|
433
|
+
row.state = state;
|
|
434
|
+
Object.assign(row, extra);
|
|
435
|
+
row.newsAt = news;
|
|
436
|
+
row.quietSaid = false;
|
|
437
|
+
if (isFinalState(state)) row.finishedAt = news;
|
|
438
|
+
if (!silent && state !== 'lost') speak(row, state);
|
|
439
|
+
return true;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** True when the page may show `state`; a state it was not measured to hold is said once and left out. */
|
|
443
|
+
function allowed(state) {
|
|
444
|
+
if (LINK_STATES.includes(state) && !promised.link) {
|
|
445
|
+
sayOnce('unpromised:link', 'Moontide: a request state that only a change naming its request can bring was not measured to hold, so it is not shown; the message was ignored.');
|
|
446
|
+
return false;
|
|
447
|
+
}
|
|
448
|
+
if (CLOSE_STATES.includes(state) && !promised.close) {
|
|
449
|
+
sayOnce('unpromised:close', 'Moontide: a request state that only the closing tool can bring was not measured to hold, so it is not shown; the message was ignored.');
|
|
450
|
+
return false;
|
|
451
|
+
}
|
|
452
|
+
return true;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function knownState(state) {
|
|
456
|
+
if (SERVER_REQUEST_STATES.includes(state)) return true;
|
|
457
|
+
sayOnce(`unknown-state:${state}`, `Moontide: the server named the request state "${state}", which this page does not know; it was ignored.`);
|
|
458
|
+
return false;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** What a state of the server brings along on the row. */
|
|
462
|
+
function extraOf(message) {
|
|
463
|
+
const extra = {};
|
|
464
|
+
if (message.state === 'line') extra.at = message.at ?? null;
|
|
465
|
+
if (message.state === 'applied') extra.version = Number.isInteger(message.version) ? message.version : null;
|
|
466
|
+
if (message.state === 'failed') extra.reason = String(message.reason ?? '');
|
|
467
|
+
if (message.state === 'answered' || message.state === 'declined') extra.answer = String(message.answer ?? '');
|
|
468
|
+
return extra;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function recentRows() {
|
|
472
|
+
return [...rows.values()].filter((row) => row.inRecent).sort((a, b) => b.movedAt - a.movedAt || b.order - a.order);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** Keeps the last RECENT_MAX finished requests; returns whether one was dropped. */
|
|
476
|
+
function trimRecent() {
|
|
477
|
+
const dropped = recentRows().slice(RECENT_MAX);
|
|
478
|
+
for (const row of dropped) rows.delete(row.id);
|
|
479
|
+
return dropped.length > 0;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** Applies every change that is due at this moment; returns whether anything changed. */
|
|
483
|
+
function settle() {
|
|
484
|
+
const at = now();
|
|
485
|
+
let changed = false;
|
|
486
|
+
for (const row of rows.values()) {
|
|
487
|
+
if (!row.confirmed && !isFinal(row) && at - row.openedAt >= confirmTimeoutMs) {
|
|
488
|
+
advance(row, 'notsent', { reason: REQUEST_STATUS_COPY.notConfirmed }, { news: at });
|
|
489
|
+
onNotConfirmed(row.id);
|
|
490
|
+
changed = true;
|
|
491
|
+
}
|
|
492
|
+
if (!row.quietSaid && quietFor(row, at) !== null) {
|
|
493
|
+
row.quietSaid = true;
|
|
494
|
+
speak(row, 'quiet');
|
|
495
|
+
changed = true;
|
|
496
|
+
}
|
|
497
|
+
if (isFinal(row) && !row.inRecent && !row.held && at - row.finishedAt >= FINISHED_SHOWN_MS) {
|
|
498
|
+
row.inRecent = true;
|
|
499
|
+
row.movedAt = at;
|
|
500
|
+
changed = true;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
if (trimRecent()) changed = true;
|
|
504
|
+
return changed;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** When the next change is due, or Infinity. */
|
|
508
|
+
function nextDue() {
|
|
509
|
+
let due = Infinity;
|
|
510
|
+
for (const row of rows.values()) {
|
|
511
|
+
if (isFinal(row)) {
|
|
512
|
+
if (!row.inRecent && !row.held) due = Math.min(due, row.finishedAt + FINISHED_SHOWN_MS);
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
if (!row.confirmed) due = Math.min(due, row.openedAt + confirmTimeoutMs);
|
|
516
|
+
if (row.state !== 'line' && !row.quietSaid) due = Math.min(due, row.newsAt + QUIET_AFTER_MS);
|
|
517
|
+
}
|
|
518
|
+
return due;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function schedule() {
|
|
522
|
+
if (timer !== null) {
|
|
523
|
+
clearTimer(timer);
|
|
524
|
+
timer = null;
|
|
525
|
+
}
|
|
526
|
+
const due = nextDue();
|
|
527
|
+
if (due === Infinity) return;
|
|
528
|
+
timer = setTimer(() => {
|
|
529
|
+
timer = null;
|
|
530
|
+
if (settle()) onChange();
|
|
531
|
+
schedule();
|
|
532
|
+
}, Math.max(0, due - now()));
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function viewRow(row, at) {
|
|
536
|
+
const quiet = quietFor(row, at);
|
|
537
|
+
const shown = { ...row, state: quiet === null ? row.state : 'quiet', quietFor: quiet };
|
|
538
|
+
const finished = row.finishedAt !== null;
|
|
539
|
+
const elapsed = (finished ? row.finishedAt : at) - row.openedAt;
|
|
540
|
+
const phrase = statePhrase(shown);
|
|
541
|
+
return {
|
|
542
|
+
id: row.id,
|
|
543
|
+
text: rowText(row.text),
|
|
544
|
+
fullText: row.text,
|
|
545
|
+
chip: row.chip,
|
|
546
|
+
state: shown.state,
|
|
547
|
+
phrase,
|
|
548
|
+
phraseTitle: shown.state === 'started' ? REQUEST_STATUS_COPY.startedBasis : phrase,
|
|
549
|
+
shape: stateMark(shown.state),
|
|
550
|
+
finished,
|
|
551
|
+
finishedAgo: finished ? at - row.finishedAt : null,
|
|
552
|
+
held: row.held,
|
|
553
|
+
time: quiet !== null || (finished && elapsed < 1000) ? '' : formatElapsed(elapsed),
|
|
554
|
+
timeTitle: finished && elapsed >= 1000 ? fill(REQUEST_STATUS_COPY.took, { elapsed: formatElapsed(elapsed) }) : '',
|
|
555
|
+
detail: detailLines(shown),
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** A request the server holds that this page may not know yet (the board a reloaded page gets). */
|
|
560
|
+
function adopt(entry) {
|
|
561
|
+
if (!knownState(entry.state) || !allowed(entry.state)) return false;
|
|
562
|
+
const at = now();
|
|
563
|
+
const newsAt = at - (Number.isFinite(entry.newsAgoMs) ? entry.newsAgoMs : 0);
|
|
564
|
+
const existing = rows.get(entry.id);
|
|
565
|
+
if (existing !== undefined) {
|
|
566
|
+
existing.confirmed = true;
|
|
567
|
+
return advance(existing, entry.state, extraOf(entry), { news: newsAt, silent: true });
|
|
568
|
+
}
|
|
569
|
+
const openedAt = at - (Number.isFinite(entry.openedAgoMs) ? entry.openedAgoMs : 0);
|
|
570
|
+
const row = makeRow(entry.id, { text: String(entry.text ?? ''), chip: String(entry.chip ?? '') }, openedAt);
|
|
571
|
+
row.confirmed = true;
|
|
572
|
+
row.state = entry.state;
|
|
573
|
+
Object.assign(row, extraOf(entry));
|
|
574
|
+
row.newsAt = newsAt;
|
|
575
|
+
if (isFinal(row)) {
|
|
576
|
+
row.finishedAt = newsAt;
|
|
577
|
+
if (at - newsAt >= FINISHED_SHOWN_MS) {
|
|
578
|
+
row.inRecent = true;
|
|
579
|
+
row.movedAt = newsAt + FINISHED_SHOWN_MS;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
// A row that is quiet when it comes back was not quiet "now": nothing is spoken for it.
|
|
583
|
+
row.quietSaid = quietFor(row, at) !== null;
|
|
584
|
+
rows.set(entry.id, row);
|
|
585
|
+
return true;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function changed() {
|
|
589
|
+
schedule();
|
|
590
|
+
onChange();
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
return {
|
|
594
|
+
/** The user sent request `id` with `text` and the context `chip`: a new row at Sent. */
|
|
595
|
+
sent(id, { text, chip }) {
|
|
596
|
+
if (rows.has(id)) return;
|
|
597
|
+
const row = makeRow(id, { text, chip }, now());
|
|
598
|
+
rows.set(id, row);
|
|
599
|
+
speak(row, 'sent');
|
|
600
|
+
changed();
|
|
601
|
+
},
|
|
602
|
+
|
|
603
|
+
/** One server message: ask-result, ask-received, ask-status or ask-board. Anything else is ignored. */
|
|
604
|
+
serverMessage(msg) {
|
|
605
|
+
let moved = false;
|
|
606
|
+
if (msg.type === 'ask-result') {
|
|
607
|
+
const row = known(msg.id, msg.type);
|
|
608
|
+
if (row === undefined) return;
|
|
609
|
+
row.confirmed = true;
|
|
610
|
+
if (msg.status === 'received') moved = advance(row, 'received');
|
|
611
|
+
else if (msg.status === 'waiting') moved = advance(row, 'waiting');
|
|
612
|
+
else if (msg.status === 'refused') moved = advance(row, 'notsent', { reason: refusalText(msg.reason, msg.detail) });
|
|
613
|
+
else sayOnce(`unknown-result:${msg.status}`, `Moontide: the server answered a request with "${msg.status}", which this page does not know; it was ignored.`);
|
|
614
|
+
} else if (msg.type === 'ask-received') {
|
|
615
|
+
for (const id of msg.ids) {
|
|
616
|
+
const row = known(id, msg.type);
|
|
617
|
+
if (row === undefined) continue;
|
|
618
|
+
row.confirmed = true;
|
|
619
|
+
if (advance(row, 'received')) moved = true;
|
|
620
|
+
}
|
|
621
|
+
} else if (msg.type === 'ask-status') {
|
|
622
|
+
const row = known(msg.id, msg.type);
|
|
623
|
+
if (row === undefined) return;
|
|
624
|
+
row.confirmed = true;
|
|
625
|
+
if (knownState(msg.state) && allowed(msg.state)) moved = advance(row, msg.state, extraOf(msg));
|
|
626
|
+
} else if (msg.type === 'ask-board') {
|
|
627
|
+
if (!Array.isArray(msg.requests)) {
|
|
628
|
+
sayOnce('bad-board', 'Moontide: the server sent a request board that is not a list; it was ignored.');
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
for (const entry of msg.requests) {
|
|
632
|
+
if (adopt(entry)) moved = true;
|
|
633
|
+
}
|
|
634
|
+
if (trimRecent()) moved = true;
|
|
635
|
+
}
|
|
636
|
+
if (moved) changed();
|
|
637
|
+
else schedule();
|
|
638
|
+
},
|
|
639
|
+
|
|
640
|
+
/** The socket closed: every request still open reads Connection lost, and none is waited for any more. */
|
|
641
|
+
connectionLost() {
|
|
642
|
+
let open = 0;
|
|
643
|
+
for (const row of rows.values()) {
|
|
644
|
+
if (isFinal(row)) continue;
|
|
645
|
+
const unconfirmed = !row.confirmed;
|
|
646
|
+
advance(row, 'lost');
|
|
647
|
+
open += 1;
|
|
648
|
+
if (unconfirmed) onNotConfirmed(row.id);
|
|
649
|
+
}
|
|
650
|
+
if (open === 0) return;
|
|
651
|
+
spoken.push(liveSentence({ state: 'lost', n: open }));
|
|
652
|
+
changed();
|
|
653
|
+
},
|
|
654
|
+
|
|
655
|
+
/** A finished row stays where it is while the pointer, focus or an open detail holds it; it moves when released. */
|
|
656
|
+
setHeld(id, held) {
|
|
657
|
+
const row = rows.get(id);
|
|
658
|
+
if (row === undefined || row.held === Boolean(held)) return;
|
|
659
|
+
row.held = Boolean(held);
|
|
660
|
+
if (!row.held && settle()) {
|
|
661
|
+
changed();
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
schedule();
|
|
665
|
+
},
|
|
666
|
+
|
|
667
|
+
/** The sentences to speak since the last call (see joinLive for how they are joined). */
|
|
668
|
+
takeLive() {
|
|
669
|
+
const taken = spoken;
|
|
670
|
+
spoken = [];
|
|
671
|
+
return taken;
|
|
672
|
+
},
|
|
673
|
+
|
|
674
|
+
/** What the view draws: the list newest first, Recent with the latest move first, how many are in flight, and whether the board is empty. */
|
|
675
|
+
model() {
|
|
676
|
+
const at = now();
|
|
677
|
+
const list = [...rows.values()].filter((row) => !row.inRecent).sort((a, b) => b.openedAt - a.openedAt || b.order - a.order);
|
|
678
|
+
return {
|
|
679
|
+
empty: rows.size === 0,
|
|
680
|
+
inFlight: list.filter((row) => !isFinal(row)).length,
|
|
681
|
+
rows: list.map((row) => viewRow(row, at)),
|
|
682
|
+
recent: recentRows().map((row) => viewRow(row, at)),
|
|
683
|
+
};
|
|
684
|
+
},
|
|
685
|
+
};
|
|
686
|
+
}
|