allaboard 0.0.0-stage → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +43 -2
  3. package/dist/agent/claude/backend.js +124 -0
  4. package/dist/agent/claude/capabilities.js +30 -0
  5. package/dist/agent/claude/chrome.js +42 -0
  6. package/dist/agent/claude/hooks.js +37 -0
  7. package/dist/agent/claude/info.js +61 -0
  8. package/dist/agent/claude/listing.js +230 -0
  9. package/dist/agent/claude/module.js +82 -0
  10. package/dist/agent/claude/prompt.js +24 -0
  11. package/dist/agent/claude/record.js +9 -0
  12. package/dist/agent/claude/requests.js +122 -0
  13. package/dist/agent/claude/session.js +441 -0
  14. package/dist/agent/claude/translate.js +581 -0
  15. package/dist/agent/claude/trust.js +576 -0
  16. package/dist/agent/claude/usage-probe.js +103 -0
  17. package/dist/agent/claude/usage.js +110 -0
  18. package/dist/agent/claude/wire.js +22 -0
  19. package/dist/agent/seam.js +21 -0
  20. package/dist/chat/seam.js +10 -0
  21. package/dist/chat/slack/app/answers.js +209 -0
  22. package/dist/chat/slack/app/app.js +262 -0
  23. package/dist/chat/slack/app/clip.js +191 -0
  24. package/dist/chat/slack/app/guards.js +162 -0
  25. package/dist/chat/slack/app/home-controls.js +91 -0
  26. package/dist/chat/slack/app/messages.js +181 -0
  27. package/dist/chat/slack/app/open.js +392 -0
  28. package/dist/chat/slack/app/order.js +37 -0
  29. package/dist/chat/slack/app/requests.js +482 -0
  30. package/dist/chat/slack/app/resume-bind.js +71 -0
  31. package/dist/chat/slack/app/wire.js +83 -0
  32. package/dist/chat/slack/attachments.js +348 -0
  33. package/dist/chat/slack/bind.js +67 -0
  34. package/dist/chat/slack/channel.js +67 -0
  35. package/dist/chat/slack/channels.js +43 -0
  36. package/dist/chat/slack/delete.js +278 -0
  37. package/dist/chat/slack/footer.js +43 -0
  38. package/dist/chat/slack/hold.js +37 -0
  39. package/dist/chat/slack/home.js +1069 -0
  40. package/dist/chat/slack/module.js +174 -0
  41. package/dist/chat/slack/notice.js +64 -0
  42. package/dist/chat/slack/openfile/modal.js +362 -0
  43. package/dist/chat/slack/openfile/read.js +29 -0
  44. package/dist/chat/slack/quiet-logger.js +52 -0
  45. package/dist/chat/slack/repair.js +123 -0
  46. package/dist/chat/slack/reply/blocks.js +372 -0
  47. package/dist/chat/slack/reply/chars.js +59 -0
  48. package/dist/chat/slack/reply/clients.js +171 -0
  49. package/dist/chat/slack/reply/errors.js +125 -0
  50. package/dist/chat/slack/reply/escape.js +25 -0
  51. package/dist/chat/slack/reply/limiter.js +86 -0
  52. package/dist/chat/slack/reply/markdown.js +210 -0
  53. package/dist/chat/slack/reply/places.js +262 -0
  54. package/dist/chat/slack/reply/sinks.js +2403 -0
  55. package/dist/chat/slack/reply/status.js +433 -0
  56. package/dist/chat/slack/reply/tasks.js +11 -0
  57. package/dist/chat/slack/requests.js +552 -0
  58. package/dist/chat/slack/resume.js +127 -0
  59. package/dist/chat/slack/setup.js +114 -0
  60. package/dist/chat/slack/texts.js +185 -0
  61. package/dist/chat/slack/thread.js +537 -0
  62. package/dist/chat/slack/update.js +71 -0
  63. package/dist/chat/slack/voice.js +84 -0
  64. package/dist/cli.js +326 -0
  65. package/dist/clock.js +21 -0
  66. package/dist/core/cleanup.js +145 -0
  67. package/dist/core/commands.js +172 -0
  68. package/dist/core/config.js +174 -0
  69. package/dist/core/files/changed.js +125 -0
  70. package/dist/core/files/listing.js +382 -0
  71. package/dist/core/files/paths.js +9 -0
  72. package/dist/core/files/read.js +191 -0
  73. package/dist/core/files/waiting.js +34 -0
  74. package/dist/core/flows/answers.js +133 -0
  75. package/dist/core/flows/ask-owner.js +95 -0
  76. package/dist/core/flows/asking.js +14 -0
  77. package/dist/core/flows/bind.js +95 -0
  78. package/dist/core/flows/contract.js +1 -0
  79. package/dist/core/flows/flows.js +35 -0
  80. package/dist/core/flows/hold.js +22 -0
  81. package/dist/core/flows/inbound.js +24 -0
  82. package/dist/core/flows/lists.js +21 -0
  83. package/dist/core/flows/messages.js +293 -0
  84. package/dist/core/flows/notices.js +51 -0
  85. package/dist/core/flows/repair.js +106 -0
  86. package/dist/core/flows/resume.js +163 -0
  87. package/dist/core/flows/setup.js +130 -0
  88. package/dist/core/flows/status.js +98 -0
  89. package/dist/core/flows/talk.js +207 -0
  90. package/dist/core/flows/update.js +370 -0
  91. package/dist/core/flows/words.js +171 -0
  92. package/dist/core/folders.js +93 -0
  93. package/dist/core/footer.js +313 -0
  94. package/dist/core/guide.js +50 -0
  95. package/dist/core/hold.js +126 -0
  96. package/dist/core/instance.js +46 -0
  97. package/dist/core/lock.js +412 -0
  98. package/dist/core/models.js +10 -0
  99. package/dist/core/paths.js +75 -0
  100. package/dist/core/port/file-flows.js +1 -0
  101. package/dist/core/port/home.js +1 -0
  102. package/dist/core/port/requests.js +1 -0
  103. package/dist/core/port/threads.js +1 -0
  104. package/dist/core/port/updates.js +1 -0
  105. package/dist/core/port.js +17 -0
  106. package/dist/core/reply/fold.js +175 -0
  107. package/dist/core/reply/previews.js +258 -0
  108. package/dist/core/reply/renderer.js +846 -0
  109. package/dist/core/reply/tail.js +24 -0
  110. package/dist/core/reply/words.js +32 -0
  111. package/dist/core/requests.js +158 -0
  112. package/dist/core/sessions/constants.js +61 -0
  113. package/dist/core/sessions/deps.js +1 -0
  114. package/dist/core/sessions/directory.js +86 -0
  115. package/dist/core/sessions/errors.js +79 -0
  116. package/dist/core/sessions/manager.js +378 -0
  117. package/dist/core/sessions/session.js +3297 -0
  118. package/dist/core/sessions/tasks.js +266 -0
  119. package/dist/core/sessions/turn.js +45 -0
  120. package/dist/core/setup.js +28 -0
  121. package/dist/core/state.js +724 -0
  122. package/dist/core/texts.js +451 -0
  123. package/dist/core/updates.js +332 -0
  124. package/dist/daemon.js +566 -0
  125. package/dist/host/npm.js +134 -0
  126. package/dist/host/program.js +49 -0
  127. package/dist/host/run.js +52 -0
  128. package/dist/host/service/launchd.js +385 -0
  129. package/dist/host/service/listing.js +42 -0
  130. package/dist/host/service/seam.js +1 -0
  131. package/dist/log.js +37 -0
  132. package/dist/main.js +29 -0
  133. package/dist/node-version.js +40 -0
  134. package/dist/registry.js +12 -0
  135. package/dist/semver.js +53 -0
  136. package/dist/text.js +89 -0
  137. package/dist/updates-host.js +113 -0
  138. package/dist/values.js +30 -0
  139. package/dist/version.js +54 -0
  140. package/package.json +32 -4
  141. package/slack-app-manifest.json +50 -0
@@ -0,0 +1,392 @@
1
+ /**
2
+ * `!open`: a file of a session's folder shared into its thread, by name or through the picker's
3
+ * modal. The logic is the core's (`core/files/`) and `openfile/`; these are its handlers.
4
+ *
5
+ * The listing a click starts is a promise that began with the click, and a signal that gives it
6
+ * up when the click is done: a walk or a git call in flight is never cut, the signal only stops
7
+ * what would follow it.
8
+ */
9
+ import { Cancelled, changedIn, fill, NotAFile, named, newestFirst, readOpenable, regularFiles, TooLarge, texts, } from "../../../core/port.js";
10
+ import { strip } from "../../../text.js";
11
+ import { errorCode } from "../../../values.js";
12
+ import { BadTarget, choiceBlock, chosenIn, ModalUpdates, matchesBlocks, modalView, OPEN_WAIT, pickerBlocks, QUERY_BLOCK, QUERY_LIMIT, ROW_LIMIT, Target, typedIn, } from "../openfile/modal.js";
13
+ import { SNIPPET_LIMIT, uploadFile } from "../openfile/read.js";
14
+ import { take } from "../reply/chars.js";
15
+ import { describe } from "../reply/errors.js";
16
+ import { shownAsWritten } from "../reply/escape.js";
17
+ import * as slackTexts from "../texts.js";
18
+ import { isCancelled, logger } from "./answers.js";
19
+ import { interactionActor, isOwner } from "./guards.js";
20
+ import { actionKey, clickThread, firstAction, record, text, } from "./wire.js";
21
+ /** The words of a search: what was typed, stripped (`text.ts`), cut at `QUERY_LIMIT` characters. */
22
+ function searched(typed) {
23
+ return take(strip(typed), QUERY_LIMIT);
24
+ }
25
+ // A thread's ts is its root's post time: `<epoch seconds>.<sequence>`.
26
+ const EPOCH_SECONDS = /^(\d+)(?:\.\d+)?\n?$/;
27
+ /** The epoch second a thread's ts names, or null when the ts is no time. */
28
+ export function startedAt(threadTs) {
29
+ const seconds = EPOCH_SECONDS.exec(threadTs);
30
+ if (seconds === null)
31
+ return null;
32
+ const when = Number(seconds[1]);
33
+ return Number.isSafeInteger(when) ? when : null;
34
+ }
35
+ export class Open {
36
+ parts;
37
+ answers;
38
+ /** The open picker modals, so that an older update of one never overwrites a newer one. */
39
+ modals = new ModalUpdates();
40
+ constructor(parts, answers) {
41
+ this.parts = parts;
42
+ this.answers = answers;
43
+ }
44
+ /**
45
+ * The folder of the session that owns a thread, from its entry; null when the thread is no
46
+ * session (or is being deleted). What a click or a typed query names is resolved here and
47
+ * never against the channel's folder, which a later `!bind` may have moved.
48
+ */
49
+ threadFolder(channel, threadTs) {
50
+ const { state, sessions } = this.parts;
51
+ const thread = state.thread(channel, threadTs);
52
+ if (thread === null || sessions.held(channel, threadTs))
53
+ return null;
54
+ return thread.directory;
55
+ }
56
+ /**
57
+ * `!open` typed in a session's thread: a picker alone; with words, the file they name as a
58
+ * path, else the file whose name contains them, else the files that do. A file is opened on
59
+ * its own only when it is the one match of a listing that is complete.
60
+ */
61
+ async openWord(channel, threadTs, folder, words) {
62
+ if (!words) {
63
+ await this.answers.deliver(channel, threadTs, slackTexts.OPEN_FALLBACK, pickerBlocks(), {
64
+ ephemeral: false,
65
+ });
66
+ return;
67
+ }
68
+ if ((await regularFiles(folder, [words])).length > 0) {
69
+ await this.openFile(channel, threadTs, folder, words);
70
+ return;
71
+ }
72
+ const found = await this.foundFiles(folder, threadTs, words);
73
+ const [only] = found.paths;
74
+ if (only === undefined) {
75
+ const shown = shownAsWritten(words);
76
+ const noMatch = found.complete ? slackTexts.OPEN_NO_MATCH : slackTexts.OPEN_NO_MATCH_PARTIAL;
77
+ await this.answers.tellOwner(channel, threadTs, fill(noMatch, { words: shown }));
78
+ }
79
+ else if (found.paths.length === 1 && found.complete) {
80
+ await this.openFile(channel, threadTs, folder, only);
81
+ }
82
+ else {
83
+ await this.answers.deliver(channel, threadTs, slackTexts.OPEN_FALLBACK, matchesBlocks(words, found.paths, found.complete), { ephemeral: false });
84
+ }
85
+ }
86
+ /**
87
+ * What the picker's rows are chosen from, as paths from `folder`: with no `words`, the files
88
+ * changed in the session, newest first (the union over the folder's repositories, none when
89
+ * there is no repository or nothing changed); else the files whose path contains them, in
90
+ * `rank`'s order, of which at most `limit` are checked on disk (`Listings.search`). Never
91
+ * rejects but for the signal: a listing that fails is empty and incomplete.
92
+ */
93
+ async foundFiles(folder, threadTs, words, options = {}) {
94
+ const { listings } = this.parts;
95
+ const { limit, signal } = options;
96
+ try {
97
+ if (!words) {
98
+ const repositories = await listings.repositories(folder, { ...(signal && { signal }) });
99
+ signal?.throwIfAborted();
100
+ const names = await changedIn(folder, repositories, startedAt(threadTs));
101
+ signal?.throwIfAborted();
102
+ const paths = await newestFirst(folder, names);
103
+ return { paths, count: paths.length, complete: true };
104
+ }
105
+ return await listings.search(folder, words, {
106
+ ...(limit !== undefined && { limit }),
107
+ ...(signal && { signal }),
108
+ });
109
+ }
110
+ catch (error) {
111
+ if (signal?.aborted || isCancelled(error))
112
+ throw error;
113
+ logger.warning(`could not list files for a search: ${describe(error)}`);
114
+ return { paths: [], count: 0, complete: false };
115
+ }
116
+ }
117
+ /**
118
+ * Share the file `relative` names under `folder` into the thread, which Slack shows in its own
119
+ * viewer; no line of ours on success. `relative` is untrusted.
120
+ */
121
+ async openFile(channel, threadTs, folder, relative) {
122
+ const { slack, sessions } = this.parts;
123
+ const shown = shownAsWritten(relative);
124
+ let content;
125
+ try {
126
+ content = await readOpenable(folder, relative, SNIPPET_LIMIT);
127
+ }
128
+ catch (error) {
129
+ if (error instanceof NotAFile) {
130
+ // The owner reads one sentence whatever the cause; the log says whether the daemon could
131
+ // not read the file (the code only: never the path, which is the owner's).
132
+ const cause = errorCode(error.cause);
133
+ if (cause !== undefined) {
134
+ logger.info(`could not open a file for ${channel}/${threadTs}: ${cause}`);
135
+ }
136
+ await this.answers.tellOwner(channel, threadTs, fill(slackTexts.OPEN_NOT_A_FILE, { path: shown }));
137
+ return;
138
+ }
139
+ if (error instanceof TooLarge) {
140
+ await this.answers.tellOwner(channel, threadTs, fill(named(slackTexts.OPEN_TOO_LARGE, this.parts.names), { path: shown }));
141
+ return;
142
+ }
143
+ throw error;
144
+ }
145
+ if (content.length === 0) {
146
+ // Refused here, not left to whatever Slack answers to an upload of length 0.
147
+ await this.answers.tellOwner(channel, threadTs, fill(slackTexts.OPEN_EMPTY, { path: shown }));
148
+ return;
149
+ }
150
+ if (this.answers.stopped)
151
+ return;
152
+ try {
153
+ await uploadFile(slack, channel, threadTs, content, relative);
154
+ }
155
+ catch (error) {
156
+ if (isCancelled(error))
157
+ throw error;
158
+ const code = describe(error);
159
+ // Slack's own code, never the file or its path.
160
+ logger.warning(`could not share a file in ${channel}: ${code}`);
161
+ let told;
162
+ if (code === "missing_scope")
163
+ told = slackTexts.OPEN_NO_SCOPE;
164
+ else if (code === "snippet_too_large")
165
+ told = fill(named(slackTexts.OPEN_TOO_LARGE, this.parts.names), { path: shown });
166
+ else
167
+ told = fill(slackTexts.OPEN_FAILED, { path: shown, error: code });
168
+ await this.answers.tellOwner(channel, threadTs, told);
169
+ return;
170
+ }
171
+ // Slack clears a thread's status line when the app shares into it.
172
+ sessions.wrote(channel, threadTs);
173
+ }
174
+ /**
175
+ * Put the rows for `words` into the open modal `viewId`, unless a newer update has been taken
176
+ * meanwhile (`ModalUpdates`): `key` orders them. No `hash` goes with it: this daemon is the
177
+ * only writer of its modals, and `modals` makes the newest update the last.
178
+ */
179
+ async updateModal(viewId, key, target, words, found) {
180
+ const { slack } = this.parts;
181
+ // (sync) Taken as the event comes in: the characters of one search are handled side by
182
+ // side, and what orders their updates is the key each one claimed, not when it finishes.
183
+ if (!this.modals.claim(viewId, key))
184
+ return;
185
+ await this.modals.lock(viewId).run(async () => {
186
+ if (!this.modals.current(viewId, key))
187
+ return;
188
+ const rows = await found();
189
+ if (!this.modals.current(viewId, key) || this.answers.stopped)
190
+ return;
191
+ const view = modalView(target, words, rows.paths, {
192
+ count: rows.count,
193
+ complete: rows.complete,
194
+ });
195
+ try {
196
+ await slack.views.update({ view_id: viewId, view });
197
+ }
198
+ catch (error) {
199
+ if (isCancelled(error))
200
+ throw error;
201
+ logger.warning(`could not update the file picker: ${describe(error)}`);
202
+ }
203
+ });
204
+ }
205
+ /**
206
+ * `finding`'s answer when it has one within `seconds` of the clock, else null. A listing that
207
+ * had already ended when no time was left is taken too.
208
+ */
209
+ async awaited(finding, seconds) {
210
+ if (seconds > 0 && finding.found === null) {
211
+ const waited = new AbortController();
212
+ await Promise.race([
213
+ finding.promise.then(() => { }, () => { }),
214
+ this.parts.clock.sleep(seconds, waited.signal).catch(() => { }),
215
+ ]);
216
+ waited.abort(new Cancelled());
217
+ }
218
+ // One turn of the event loop: a listing that ended as the time ran out is still taken.
219
+ await new Promise((resolve) => setImmediate(resolve));
220
+ return finding.found;
221
+ }
222
+ /**
223
+ * Open the picker's modal on the click `triggerId` came with, which lives 3 seconds: with the
224
+ * rows when `finding` has them by OPEN_WAIT after the click came in (`entered`, on the
225
+ * handlers' clock), else without them and filled by an update.
226
+ */
227
+ async openModal(channel, threadTs, finding, triggerId, words, entered) {
228
+ const { slack, clock } = this.parts;
229
+ const target = new Target(channel, threadTs);
230
+ const left = OPEN_WAIT - (clock.time() - entered);
231
+ const found = await this.awaited(finding, Math.max(left, 0));
232
+ const first = found === null
233
+ ? modalView(target, words, null, { opening: true })
234
+ : modalView(target, words, found.paths, {
235
+ count: found.count,
236
+ complete: found.complete,
237
+ opening: true,
238
+ });
239
+ if (this.answers.stopped)
240
+ return;
241
+ let viewId;
242
+ try {
243
+ if (triggerId === null)
244
+ throw new TypeError("the click carries no trigger_id");
245
+ const opened = await slack.views.open({ trigger_id: triggerId, view: first });
246
+ viewId = opened.view?.id;
247
+ }
248
+ catch (error) {
249
+ if (isCancelled(error))
250
+ throw error;
251
+ // an expired trigger_id, say
252
+ const message = fill(slackTexts.OPEN_FORM_NOT_OPENED, { error: describe(error) });
253
+ await this.answers.tellOwner(channel, threadTs, message);
254
+ return;
255
+ }
256
+ if (found === null && typeof viewId === "string") {
257
+ // Key 0: any keystroke typed meanwhile is newer.
258
+ await this.updateModal(viewId, 0.0, target, words, () => finding.promise);
259
+ }
260
+ }
261
+ /**
262
+ * The `Choose a file` button, of `!open` or of its several matches: opens the modal. The
263
+ * click's `trigger_id` lives 3 seconds, and the checks, the wait for the rows and `views.open`
264
+ * all come out of them: the listing starts at once, beside the channel check and counted from
265
+ * here.
266
+ */
267
+ onOpenChoose = async (ack, body) => {
268
+ await ack();
269
+ const { identity, clock } = this.parts;
270
+ const entered = clock.time();
271
+ const [user, team] = interactionActor(body);
272
+ const channel = text(record(body.channel).id);
273
+ const threadTs = clickThread(body);
274
+ let words = "";
275
+ let finding = null;
276
+ const given = new AbortController();
277
+ // What is local comes first: only the owner's click, in a session's thread, lists anything.
278
+ if (channel && isOwner(identity, user, team)) {
279
+ // The words only fill the field: what is listed is what they match in this thread's
280
+ // folder.
281
+ const typed = firstAction(body).value;
282
+ words = typeof typed === "string" ? searched(typed) : "";
283
+ const folder = this.threadFolder(channel, threadTs);
284
+ if (folder !== null) {
285
+ const listing = {
286
+ promise: this.foundFiles(folder, threadTs, words, {
287
+ limit: ROW_LIMIT,
288
+ signal: given.signal,
289
+ }),
290
+ found: null,
291
+ };
292
+ listing.promise.then((found) => {
293
+ listing.found = found;
294
+ }, () => { });
295
+ finding = listing;
296
+ }
297
+ }
298
+ try {
299
+ if (!(await this.answers.admitted(user, team, channel, threadTs)) || channel === null)
300
+ return;
301
+ if (finding === null) {
302
+ await this.answers.tellOwner(channel, threadTs, texts.NOT_A_SESSION);
303
+ return;
304
+ }
305
+ const listed = finding;
306
+ await this.answers.replyOnFailure(channel, threadTs, () => this.openModal(channel, threadTs, listed, text(body.trigger_id), words, entered), (told) => this.answers.tellOwner(channel, threadTs, told));
307
+ }
308
+ finally {
309
+ given.abort(new Cancelled());
310
+ }
311
+ };
312
+ /**
313
+ * A character typed in the modal's search field: its rows follow what is typed. Checked for
314
+ * the owner and the workspace alone, with no call to Slack for the channel: each character
315
+ * comes here, and the rows reach only the owner's own modal.
316
+ */
317
+ onOpenQuery = async (ack, body) => {
318
+ await ack();
319
+ const { identity, now } = this.parts;
320
+ if (this.answers.stopped)
321
+ return;
322
+ const [user, team] = interactionActor(body);
323
+ if (!isOwner(identity, user, team)) {
324
+ logger.info("ignored an inbound event from someone other than the owner");
325
+ return;
326
+ }
327
+ const view = record(body.view);
328
+ let target;
329
+ try {
330
+ target = Target.load(view.private_metadata);
331
+ }
332
+ catch (error) {
333
+ if (error instanceof BadTarget)
334
+ return;
335
+ throw error;
336
+ }
337
+ const viewId = view.id;
338
+ const folder = this.threadFolder(target.channel, target.threadTs);
339
+ if (folder === null || typeof viewId !== "string")
340
+ return;
341
+ const action = firstAction(body);
342
+ const typed = typeof action.value === "string" ? action.value : typedIn(record(view.state).values);
343
+ const words = searched(typed);
344
+ await this.updateModal(viewId, actionKey(action, now), target, words, () => this.foundFiles(folder, target.threadTs, words, { limit: ROW_LIMIT }));
345
+ };
346
+ /**
347
+ * Open, in the picker's modal: the row chosen is shared into the thread and the modal closes.
348
+ * The answer to Slack comes first and makes no call to it; the value and the metadata are
349
+ * untrusted, and the file is read from the folder of the thread's own session.
350
+ */
351
+ onOpenSubmit = async (ack, body) => {
352
+ const { identity } = this.parts;
353
+ const [user, team] = interactionActor(body);
354
+ const view = record(body.view);
355
+ let target;
356
+ try {
357
+ target = Target.load(view.private_metadata);
358
+ }
359
+ catch (error) {
360
+ if (!(error instanceof BadTarget))
361
+ throw error;
362
+ await ack();
363
+ return;
364
+ }
365
+ if (!isOwner(identity, user, team)) {
366
+ await ack();
367
+ logger.info("ignored an inbound event from someone other than the owner");
368
+ return;
369
+ }
370
+ const relative = chosenIn(view);
371
+ if (relative === null) {
372
+ // The error goes on the rows the view shows, whatever the state holds. With no row
373
+ // there is no such block to carry it: the search field does.
374
+ const rows = choiceBlock(view);
375
+ const block = rows !== null && typeof rows.block_id === "string" ? rows.block_id : QUERY_BLOCK;
376
+ await ack({ response_action: "errors", errors: { [block]: slackTexts.OPEN_NONE_CHOSEN } });
377
+ return;
378
+ }
379
+ await ack();
380
+ if (typeof view.id === "string")
381
+ this.modals.forget(view.id);
382
+ const { channel, threadTs } = target;
383
+ if (!(await this.answers.admitted(user, team, channel, threadTs)))
384
+ return;
385
+ const folder = this.threadFolder(channel, threadTs);
386
+ if (folder === null) {
387
+ await this.answers.tellOwner(channel, threadTs, texts.NOT_A_SESSION);
388
+ return;
389
+ }
390
+ await this.answers.replyOnFailure(channel, threadTs, () => this.openFile(channel, threadTs, folder, relative), (told) => this.answers.tellOwner(channel, threadTs, told));
391
+ };
392
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The places of the messages of one thread, in the order their listeners were called. A listener
3
+ * takes its place (`enter`) before its first await, so the channel check that follows, which
4
+ * takes longer for one message than for the next, cannot reorder them; it leaves the place when
5
+ * it has handed its message to the core, whose own ordering holds from there (`arrival` in
6
+ * `core/flows/messages.ts`).
7
+ */
8
+ /** The key of a thread among the maps of a provider: its channel and its root. */
9
+ export function threadKey(channel, thread) {
10
+ return `${channel}\n${thread}`;
11
+ }
12
+ export class ThreadOrder {
13
+ // The last place entered, by thread. A thread is in the map only while a place of it is held
14
+ // or waits: a channel keeps opening threads for as long as it runs.
15
+ #last = new Map();
16
+ /** The threads that hold or await a place. */
17
+ get size() {
18
+ return this.#last.size;
19
+ }
20
+ /** The next place of `key`, taken now. */
21
+ enter(key) {
22
+ const before = this.#last.get(key) ?? Promise.resolve();
23
+ let left;
24
+ const mine = new Promise((resolve) => {
25
+ left = resolve;
26
+ });
27
+ this.#last.set(key, mine);
28
+ return {
29
+ turn: before,
30
+ leave: () => {
31
+ if (this.#last.get(key) === mine)
32
+ this.#last.delete(key);
33
+ left();
34
+ },
35
+ };
36
+ }
37
+ }