allaboard 0.0.0-stage → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +43 -2
- package/dist/agent/claude/backend.js +124 -0
- package/dist/agent/claude/capabilities.js +30 -0
- package/dist/agent/claude/chrome.js +42 -0
- package/dist/agent/claude/hooks.js +37 -0
- package/dist/agent/claude/info.js +61 -0
- package/dist/agent/claude/listing.js +230 -0
- package/dist/agent/claude/module.js +82 -0
- package/dist/agent/claude/prompt.js +24 -0
- package/dist/agent/claude/record.js +9 -0
- package/dist/agent/claude/requests.js +122 -0
- package/dist/agent/claude/session.js +441 -0
- package/dist/agent/claude/translate.js +581 -0
- package/dist/agent/claude/trust.js +576 -0
- package/dist/agent/claude/usage-probe.js +103 -0
- package/dist/agent/claude/usage.js +110 -0
- package/dist/agent/claude/wire.js +22 -0
- package/dist/agent/seam.js +21 -0
- package/dist/chat/seam.js +10 -0
- package/dist/chat/slack/app/answers.js +209 -0
- package/dist/chat/slack/app/app.js +262 -0
- package/dist/chat/slack/app/clip.js +191 -0
- package/dist/chat/slack/app/guards.js +162 -0
- package/dist/chat/slack/app/home-controls.js +91 -0
- package/dist/chat/slack/app/messages.js +181 -0
- package/dist/chat/slack/app/open.js +392 -0
- package/dist/chat/slack/app/order.js +37 -0
- package/dist/chat/slack/app/requests.js +482 -0
- package/dist/chat/slack/app/resume-bind.js +71 -0
- package/dist/chat/slack/app/wire.js +83 -0
- package/dist/chat/slack/attachments.js +348 -0
- package/dist/chat/slack/bind.js +67 -0
- package/dist/chat/slack/channel.js +67 -0
- package/dist/chat/slack/channels.js +43 -0
- package/dist/chat/slack/delete.js +278 -0
- package/dist/chat/slack/footer.js +43 -0
- package/dist/chat/slack/hold.js +37 -0
- package/dist/chat/slack/home.js +1069 -0
- package/dist/chat/slack/module.js +174 -0
- package/dist/chat/slack/notice.js +64 -0
- package/dist/chat/slack/openfile/modal.js +362 -0
- package/dist/chat/slack/openfile/read.js +29 -0
- package/dist/chat/slack/quiet-logger.js +52 -0
- package/dist/chat/slack/repair.js +123 -0
- package/dist/chat/slack/reply/blocks.js +372 -0
- package/dist/chat/slack/reply/chars.js +59 -0
- package/dist/chat/slack/reply/clients.js +171 -0
- package/dist/chat/slack/reply/errors.js +125 -0
- package/dist/chat/slack/reply/escape.js +25 -0
- package/dist/chat/slack/reply/limiter.js +86 -0
- package/dist/chat/slack/reply/markdown.js +210 -0
- package/dist/chat/slack/reply/places.js +262 -0
- package/dist/chat/slack/reply/sinks.js +2403 -0
- package/dist/chat/slack/reply/status.js +433 -0
- package/dist/chat/slack/reply/tasks.js +11 -0
- package/dist/chat/slack/requests.js +552 -0
- package/dist/chat/slack/resume.js +127 -0
- package/dist/chat/slack/setup.js +114 -0
- package/dist/chat/slack/texts.js +185 -0
- package/dist/chat/slack/thread.js +537 -0
- package/dist/chat/slack/update.js +71 -0
- package/dist/chat/slack/voice.js +84 -0
- package/dist/cli.js +326 -0
- package/dist/clock.js +21 -0
- package/dist/core/cleanup.js +145 -0
- package/dist/core/commands.js +172 -0
- package/dist/core/config.js +174 -0
- package/dist/core/files/changed.js +125 -0
- package/dist/core/files/listing.js +382 -0
- package/dist/core/files/paths.js +9 -0
- package/dist/core/files/read.js +191 -0
- package/dist/core/files/waiting.js +34 -0
- package/dist/core/flows/answers.js +133 -0
- package/dist/core/flows/ask-owner.js +95 -0
- package/dist/core/flows/asking.js +14 -0
- package/dist/core/flows/bind.js +95 -0
- package/dist/core/flows/contract.js +1 -0
- package/dist/core/flows/flows.js +35 -0
- package/dist/core/flows/hold.js +22 -0
- package/dist/core/flows/inbound.js +24 -0
- package/dist/core/flows/lists.js +21 -0
- package/dist/core/flows/messages.js +293 -0
- package/dist/core/flows/notices.js +51 -0
- package/dist/core/flows/repair.js +106 -0
- package/dist/core/flows/resume.js +163 -0
- package/dist/core/flows/setup.js +130 -0
- package/dist/core/flows/status.js +98 -0
- package/dist/core/flows/talk.js +207 -0
- package/dist/core/flows/update.js +370 -0
- package/dist/core/flows/words.js +171 -0
- package/dist/core/folders.js +93 -0
- package/dist/core/footer.js +313 -0
- package/dist/core/guide.js +50 -0
- package/dist/core/hold.js +126 -0
- package/dist/core/instance.js +46 -0
- package/dist/core/lock.js +412 -0
- package/dist/core/models.js +10 -0
- package/dist/core/paths.js +75 -0
- package/dist/core/port/file-flows.js +1 -0
- package/dist/core/port/home.js +1 -0
- package/dist/core/port/requests.js +1 -0
- package/dist/core/port/threads.js +1 -0
- package/dist/core/port/updates.js +1 -0
- package/dist/core/port.js +17 -0
- package/dist/core/reply/fold.js +175 -0
- package/dist/core/reply/previews.js +258 -0
- package/dist/core/reply/renderer.js +846 -0
- package/dist/core/reply/tail.js +24 -0
- package/dist/core/reply/words.js +32 -0
- package/dist/core/requests.js +158 -0
- package/dist/core/sessions/constants.js +61 -0
- package/dist/core/sessions/deps.js +1 -0
- package/dist/core/sessions/directory.js +86 -0
- package/dist/core/sessions/errors.js +79 -0
- package/dist/core/sessions/manager.js +378 -0
- package/dist/core/sessions/session.js +3297 -0
- package/dist/core/sessions/tasks.js +266 -0
- package/dist/core/sessions/turn.js +45 -0
- package/dist/core/setup.js +28 -0
- package/dist/core/state.js +724 -0
- package/dist/core/texts.js +451 -0
- package/dist/core/updates.js +332 -0
- package/dist/daemon.js +566 -0
- package/dist/host/npm.js +134 -0
- package/dist/host/program.js +49 -0
- package/dist/host/run.js +52 -0
- package/dist/host/service/launchd.js +385 -0
- package/dist/host/service/listing.js +42 -0
- package/dist/host/service/seam.js +1 -0
- package/dist/log.js +37 -0
- package/dist/main.js +29 -0
- package/dist/node-version.js +40 -0
- package/dist/registry.js +12 -0
- package/dist/semver.js +53 -0
- package/dist/text.js +89 -0
- package/dist/updates-host.js +113 -0
- package/dist/values.js +30 -0
- package/dist/version.js +54 -0
- package/package.json +32 -4
- package/slack-app-manifest.json +50 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The words that post a list in a channel, and what follows a pick from one: `!resume` with a
|
|
3
|
+
* folder's sessions, `!bind` with the folders a session can start in. The core decides what is
|
|
4
|
+
* offered, which rows the list holds and what a pick does; the chat provider draws the list and
|
|
5
|
+
* checks that it was the owner who picked.
|
|
6
|
+
*/
|
|
7
|
+
import { binding } from "./bind.js";
|
|
8
|
+
import { resuming } from "./resume.js";
|
|
9
|
+
export function lists(deps, talk) {
|
|
10
|
+
const bind = binding(deps, talk);
|
|
11
|
+
const resume = resuming(deps, talk);
|
|
12
|
+
return {
|
|
13
|
+
resume: { word: resume.word },
|
|
14
|
+
bind: { to: bind.to, list: bind.list },
|
|
15
|
+
picks: {
|
|
16
|
+
// A failure after the provider's checks reaches the owner in the channel, where the list
|
|
17
|
+
// was posted. The thread of the guard is the list's own: it names a message of the channel.
|
|
18
|
+
picked: (pick) => talk.replyOnFailure({ channel: pick.channel, thread: pick.list }, () => (pick.type === "folder" ? bind.picked(pick) : resume.picked(pick)), (told) => talk.inChannel(pick.channel, told)),
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A message the owner wrote: read as a word of the daemon's, a command of the agent's or a
|
|
3
|
+
* prompt, and sent to its thread's session after the setup and the hold when either is due.
|
|
4
|
+
*
|
|
5
|
+
* Ordering. Prompts, messages with files and commands enter a thread's queue in the order they
|
|
6
|
+
* were sent although files take a while to read. That holds by construction: from the moment a
|
|
7
|
+
* provider hands over a message (`message` is called) to the moment its place in line is taken
|
|
8
|
+
* (`Mutex.acquire` inside `arrival`), nothing is awaited except a call to the chat (the
|
|
9
|
+
* old-folder notice). The stretches are marked `(sync)`; the provider keeps its side of it: it
|
|
10
|
+
* hands the messages of a thread over in the order its listeners were called, whatever its
|
|
11
|
+
* checks take, with nothing awaited between its last check and the call.
|
|
12
|
+
*/
|
|
13
|
+
import { hostOnly, parseBang, refusedInThread, } from "../commands.js";
|
|
14
|
+
import { SessionClosed, SessionGone } from "../sessions/errors.js";
|
|
15
|
+
import { Mutex } from "../sessions/tasks.js";
|
|
16
|
+
import * as texts from "../texts.js";
|
|
17
|
+
import { fill, named } from "../texts.js";
|
|
18
|
+
import { wordFlow } from "./words.js";
|
|
19
|
+
/** The first word of a passthrough, as typed: the text up to its first space. */
|
|
20
|
+
function firstWord(command) {
|
|
21
|
+
return command.text.split(" ", 1)[0] ?? "";
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Whether a passthrough starts a new session under any of the agent's names (`refusedInThread`,
|
|
25
|
+
* given its `newSession` names and the session's `commands`): refused inside a thread (one thread
|
|
26
|
+
* is one session), left as an ordinary passthrough everywhere else.
|
|
27
|
+
*/
|
|
28
|
+
export function isClear(command, commands, newSession, agentCommands = true) {
|
|
29
|
+
return refusedInThread(commands, newSession, agentCommands).has(firstWord(command).toLowerCase());
|
|
30
|
+
}
|
|
31
|
+
export function messages(deps, talk, others) {
|
|
32
|
+
const { chat, agent, sessions, state, names } = deps;
|
|
33
|
+
const word = wordFlow(deps, talk, others);
|
|
34
|
+
// One lock per thread, alive only while someone holds or waits on it (a channel keeps
|
|
35
|
+
// opening new threads for as long as it runs; a lock kept forever would leak).
|
|
36
|
+
const arrivalOrder = new Map();
|
|
37
|
+
const arrivalWaiters = new Map();
|
|
38
|
+
/** `work` in its turn among the messages of one thread: the turns are taken in call order. */
|
|
39
|
+
async function arrival(ref, work) {
|
|
40
|
+
const key = `${ref.channel}\n${ref.thread}`;
|
|
41
|
+
// (sync) The place in line is taken inside this call, before its first await.
|
|
42
|
+
arrivalWaiters.set(key, (arrivalWaiters.get(key) ?? 0) + 1);
|
|
43
|
+
let lock = arrivalOrder.get(key);
|
|
44
|
+
if (lock === undefined) {
|
|
45
|
+
lock = new Mutex();
|
|
46
|
+
arrivalOrder.set(key, lock);
|
|
47
|
+
}
|
|
48
|
+
const turn = lock.acquire();
|
|
49
|
+
try {
|
|
50
|
+
const release = await turn;
|
|
51
|
+
try {
|
|
52
|
+
await work();
|
|
53
|
+
}
|
|
54
|
+
finally {
|
|
55
|
+
release();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
const left = (arrivalWaiters.get(key) ?? 1) - 1;
|
|
60
|
+
if (left === 0) {
|
|
61
|
+
arrivalWaiters.delete(key);
|
|
62
|
+
arrivalOrder.delete(key);
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
arrivalWaiters.set(key, left);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/** What the old-folder notice says of this thread, or null when its folder is the channel's. */
|
|
70
|
+
function oldFolderText(channel, session) {
|
|
71
|
+
const record = state.channel(channel);
|
|
72
|
+
if (record === null || session.directory === record.directory)
|
|
73
|
+
return null;
|
|
74
|
+
return fill(named(texts.OLD_THREAD_FOLDER, names), {
|
|
75
|
+
old: chat.escape(session.directory, "notice"),
|
|
76
|
+
new: chat.escape(record.directory, "notice"),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Whether a message for `session` must be refused now: the daemon is draining, or an install
|
|
81
|
+
* is replacing its copy and the message would start an agent process (a session already
|
|
82
|
+
* connected goes on).
|
|
83
|
+
*/
|
|
84
|
+
function refusing(session) {
|
|
85
|
+
return sessions.draining || (sessions.startsBlocked && !session.connected);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Send a prompt, a message with files or a command to its thread's session, in the order the
|
|
89
|
+
* thread's messages were sent, after the session's setup and the same-folder hold when either
|
|
90
|
+
* is due.
|
|
91
|
+
*/
|
|
92
|
+
async function submitToSession(message, opened, command, options) {
|
|
93
|
+
const { ref } = message;
|
|
94
|
+
const { inThread } = options;
|
|
95
|
+
if (talk.stopped)
|
|
96
|
+
return;
|
|
97
|
+
if (inThread &&
|
|
98
|
+
command !== null &&
|
|
99
|
+
isClear(command, opened.commands, agent.words.newSession, agent.capabilities.commands)) {
|
|
100
|
+
await talk.tell(ref, texts.CLEAR_IN_THREAD);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
// Prompts and commands for the agent enter the queue in the order they were sent, although
|
|
104
|
+
// files take a while to read. Every reply goes to the thread.
|
|
105
|
+
await arrival(ref, async () => {
|
|
106
|
+
let session = opened;
|
|
107
|
+
let prompt = await message.content();
|
|
108
|
+
if (prompt === null || talk.stopped)
|
|
109
|
+
return;
|
|
110
|
+
// Checked before the hold too: a drain already cancelled every hold open when it
|
|
111
|
+
// started (`SessionManager.drain`) and will never cancel one opened after, so a message
|
|
112
|
+
// that arrives once draining has begun must never open a new one (it would wait
|
|
113
|
+
// forever).
|
|
114
|
+
if (refusing(session)) {
|
|
115
|
+
await talk.refuseRestarting(ref);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
// The same-folder hold: this message would wake `session` (busy just queues behind what
|
|
119
|
+
// is already running); ask first when another live session, of any channel, is already
|
|
120
|
+
// busy in the same resolved folder. Later messages of this thread queue behind the wait,
|
|
121
|
+
// since it runs inside the arrival lock. `held` is the object a Continue's mark was left
|
|
122
|
+
// standing on (`holdEnd({ continued: true })` bets on the `submit()` below to replace it
|
|
123
|
+
// with the working one): kept apart from `session`, which a `SessionClosed` retry below
|
|
124
|
+
// can reassign, so a non-submit exit always restores the mark on the object that
|
|
125
|
+
// actually shows it.
|
|
126
|
+
let held = null;
|
|
127
|
+
let askedSetup = false;
|
|
128
|
+
if (!inThread || session.neverRan) {
|
|
129
|
+
// A session's first prompt: its setup comes before anything else, the hold's question
|
|
130
|
+
// included. A reply in a thread whose setup was cancelled, or whose hold was, is a
|
|
131
|
+
// first prompt too: nothing was ever sent in it.
|
|
132
|
+
const ready = await others.setup.beforeSending(ref, session);
|
|
133
|
+
if (ready === null)
|
|
134
|
+
return;
|
|
135
|
+
session = ready;
|
|
136
|
+
held = ready;
|
|
137
|
+
askedSetup = true;
|
|
138
|
+
}
|
|
139
|
+
if (!session.busy) {
|
|
140
|
+
const other = sessions.workingIn({ besides: session });
|
|
141
|
+
if (other !== null) {
|
|
142
|
+
if (!(await others.hold.beforeSending(ref, session, other))) {
|
|
143
|
+
// the Start that nothing was sent for
|
|
144
|
+
if (askedSetup && !talk.stopped)
|
|
145
|
+
await session.forgetSetup();
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
held = session;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// Retried once against a freshly looked-up session: the one this call was handed can
|
|
152
|
+
// still close under it (most likely the idle close, though `touch()` at the lookup
|
|
153
|
+
// already guards the common case) during the read above or the steps below.
|
|
154
|
+
let submitted = false;
|
|
155
|
+
let failed = false;
|
|
156
|
+
try {
|
|
157
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
158
|
+
try {
|
|
159
|
+
if (command !== null) {
|
|
160
|
+
await session.ensureConnected();
|
|
161
|
+
const known = new Set(session.commands.map((offered) => offered.name));
|
|
162
|
+
prompt = known.has(firstWord(command)) ? `/${command.text}` : message.text;
|
|
163
|
+
}
|
|
164
|
+
// (sync) Checked last, with no await before the submit: a stop can start while the
|
|
165
|
+
// files are read. The daemon's words still work meanwhile (`!stop` shortens the
|
|
166
|
+
// wait); a new turn would not finish, and the chat does not send this message
|
|
167
|
+
// again. A stop that ended meanwhile (the daemon is gone) sends nothing and says
|
|
168
|
+
// nothing.
|
|
169
|
+
if (talk.stopped)
|
|
170
|
+
return;
|
|
171
|
+
if (refusing(session)) {
|
|
172
|
+
await talk.refuseRestarting(ref);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
await session.submit(prompt);
|
|
176
|
+
submitted = true;
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
if (!(error instanceof SessionClosed) || attempt > 0)
|
|
181
|
+
throw error;
|
|
182
|
+
const fresh = sessions.get(ref.channel, ref.thread);
|
|
183
|
+
// Not just closed: its thread's own entry is gone too (a `SessionGone` close), so
|
|
184
|
+
// a retry would find nothing here again either.
|
|
185
|
+
if (fresh === null)
|
|
186
|
+
throw new SessionGone(names);
|
|
187
|
+
session = fresh;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
failed = true;
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
195
|
+
finally {
|
|
196
|
+
// `submitted` alone, not a fixed set of error types: `ensureConnected` can also reject
|
|
197
|
+
// with the agent's own failure (a logged-out CLI, most likely) or anything a stray bug
|
|
198
|
+
// throws; none of them may ever leave a Continue's mark standing forever. A failure
|
|
199
|
+
// gets the mark a turn that reached the queue and then failed gets; a plain return (the
|
|
200
|
+
// drain notice posted fine) restores a Cancel's own mark instead.
|
|
201
|
+
if (held !== null && !submitted)
|
|
202
|
+
await held.reactHoldAbandoned({ error: failed });
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Where a word's failure is told: in the channel for a word that acts top-level, under the
|
|
208
|
+
* owner's message for one typed inside a session's thread.
|
|
209
|
+
*/
|
|
210
|
+
function wordReport(ref, session) {
|
|
211
|
+
return async (told) => {
|
|
212
|
+
if (session === null)
|
|
213
|
+
await talk.inChannel(ref.channel, told);
|
|
214
|
+
else
|
|
215
|
+
await talk.tell(ref, told);
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* One message that passed its provider's checks, routed by where it was written: a known
|
|
220
|
+
* session's thread, a thread that holds none, or the channel.
|
|
221
|
+
*/
|
|
222
|
+
async function route(message) {
|
|
223
|
+
const { ref, topLevel } = message;
|
|
224
|
+
const { channel, thread } = ref;
|
|
225
|
+
// (sync) From here to the turn `submitToSession` takes, unless a call to the chat is due or
|
|
226
|
+
// the message opens a thread: that one first asks whether another process updates allaboard
|
|
227
|
+
// (a read of the lock file), so the thread exists a few milliseconds later than the message
|
|
228
|
+
// was routed. An event of that thread cannot arrive sooner: it is written under a message
|
|
229
|
+
// that is already shown. A stop that closes the sessions meanwhile makes `open` throw
|
|
230
|
+
// `SessionClosed`, which `replyOnFailure` tells.
|
|
231
|
+
// A message with files is a prompt: no daemon word or command takes a file. A word is a
|
|
232
|
+
// word however it is formatted: the typed text carries the chat's marks (a backtick before
|
|
233
|
+
// the `!` of a message in inline code), which `plain` has removed. Nor is a message that
|
|
234
|
+
// was spoken: what the chat heard never turns bypass on, stops a session or becomes a
|
|
235
|
+
// command, whatever its first character. Both are `literal`.
|
|
236
|
+
let command = null;
|
|
237
|
+
if (!message.literal)
|
|
238
|
+
command = parseBang(message.text) ?? parseBang(message.plain);
|
|
239
|
+
// A known session's thread routes a word to it; anywhere else (truly top-level, or a
|
|
240
|
+
// thread that is not a session) a word acts exactly as a top-level one would.
|
|
241
|
+
const session = topLevel ? null : sessions.get(channel, thread);
|
|
242
|
+
if (command !== null && command.kind !== "passthrough") {
|
|
243
|
+
const typed = command;
|
|
244
|
+
await talk.replyOnFailure(ref, () => word(ref, message.id, typed, { session, outside: !topLevel && session === null }), wordReport(ref, session));
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
if (command !== null) {
|
|
248
|
+
const answer = hostOnly(command, names, agent.capabilities.commands);
|
|
249
|
+
if (answer !== null) {
|
|
250
|
+
// Answered where a word is: under the owner's message in a session's thread, for the
|
|
251
|
+
// owner alone, and as a post in the channel anywhere else. No session starts.
|
|
252
|
+
if (session !== null)
|
|
253
|
+
await talk.tell(ref, answer);
|
|
254
|
+
else
|
|
255
|
+
await talk.inChannel(channel, answer);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if (session !== null) {
|
|
260
|
+
// A prompt sent in a thread whose folder differs from the channel's current one is told
|
|
261
|
+
// so, on every prompt: a line for the owner alone can vanish on reload, so a once-only
|
|
262
|
+
// notice could mean never. `tell` never rejects, so a failed notice cannot drop the
|
|
263
|
+
// prompt that follows it.
|
|
264
|
+
const moved = oldFolderText(channel, session);
|
|
265
|
+
if (moved !== null)
|
|
266
|
+
await talk.tell(ref, moved);
|
|
267
|
+
await submitToSession(message, session, command, { inThread: true });
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (!topLevel) {
|
|
271
|
+
// Anything but a daemon word, in a thread that holds no session: nowhere to send it.
|
|
272
|
+
await talk.tell(ref, texts.NOT_A_SESSION);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
// Before the thread's entry is written: an install under way starts no process, and a message
|
|
276
|
+
// that would only start one changes nothing.
|
|
277
|
+
if (state.channel(channel) !== null && (await sessions.refusesStarts())) {
|
|
278
|
+
await talk.refuseRestarting(ref);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const opened = sessions.open(channel, thread);
|
|
282
|
+
if (opened === null) {
|
|
283
|
+
await talk.unbound(channel);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
await submitToSession(message, opened, command, { inThread: false });
|
|
287
|
+
}
|
|
288
|
+
return {
|
|
289
|
+
// A failure of a prompt itself (or of the session it starts) lands in that session's
|
|
290
|
+
// thread: it is the turn's one push. A word answers where it was typed instead.
|
|
291
|
+
message: (message) => talk.replyOnFailure(message.ref, () => route(message)),
|
|
292
|
+
};
|
|
293
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { getLogger } from "../../log.js";
|
|
2
|
+
import { errorName } from "../../values.js";
|
|
3
|
+
import * as texts from "../texts.js";
|
|
4
|
+
import { fill } from "../texts.js";
|
|
5
|
+
import { clockTime } from "./update.js";
|
|
6
|
+
export const logger = getLogger("allaboard.core.flows.notices");
|
|
7
|
+
/**
|
|
8
|
+
* The v1-to-v2 migration notice, once per channel, top-level: not a reply to any message. A
|
|
9
|
+
* channel whose post failed keeps its flag for the next start.
|
|
10
|
+
*/
|
|
11
|
+
export async function postUpgradeNotices(chat, state, names) {
|
|
12
|
+
for (const channelId of state.pendingNotices()) {
|
|
13
|
+
try {
|
|
14
|
+
await chat.channel(channelId).notice(texts.named(texts.UPGRADE_NOTICE, names));
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
logger.warning(`could not post the upgrade notice in ${channelId}: ${errorName(error)}`);
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
state.clearNotice(channelId);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The message a restart asked from the chat left (`state.json`'s `restart_notice`), rewritten in
|
|
25
|
+
* place into what happened: the daemon is back, on the version asked or not. `notice` is what the
|
|
26
|
+
* file held before the chat could hear the owner (a restart asked since is another's to rewrite),
|
|
27
|
+
* `installed` the version that runs and `now` the wall-clock seconds. The key is forgotten
|
|
28
|
+
* whatever the outcome (the message deleted, the chat refusing), so a start never rewrites twice,
|
|
29
|
+
* and only while it is still `notice`.
|
|
30
|
+
*/
|
|
31
|
+
export async function rewriteRestartNotice(chat, state, notice, installed, now) {
|
|
32
|
+
if (notice === null)
|
|
33
|
+
return;
|
|
34
|
+
const text = notice.version === null
|
|
35
|
+
? fill(texts.UPDATE_RESTARTED, { time: clockTime(now) })
|
|
36
|
+
: notice.version === installed
|
|
37
|
+
? fill(texts.UPDATE_DONE, { version: installed })
|
|
38
|
+
: fill(texts.UPDATE_NOT_TAKEN, { installed, asked: notice.version });
|
|
39
|
+
try {
|
|
40
|
+
await chat.channel(notice.channel).rewrite(notice.message, text);
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
logger.warning(`could not rewrite the restart notice in ${notice.channel}: ${errorName(error)}`);
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
state.clearRestartNotice(notice);
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
logger.warning(`could not forget the restart notice: ${errorName(error)}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { getLogger } from "../../log.js";
|
|
2
|
+
import { errorName } from "../../values.js";
|
|
3
|
+
import * as texts from "../texts.js";
|
|
4
|
+
export const logger = getLogger("allaboard.core.flows.repair");
|
|
5
|
+
/**
|
|
6
|
+
* Repair every thread `state` still shows as left open. One thread's failure (a deleted
|
|
7
|
+
* message, a chat error) is logged and does not stop the others. An entry of a thread's three
|
|
8
|
+
* fields is cleared when the chat repaired it or said its message is gone, and kept when the
|
|
9
|
+
* chat did not: the next start asks again, once, and so does every start after it for as long
|
|
10
|
+
* as the chat keeps refusing and the thread is in the state.
|
|
11
|
+
*/
|
|
12
|
+
export async function repair(chat, state) {
|
|
13
|
+
for (const [channelId, threadId, thread] of state.repairsPending()) {
|
|
14
|
+
try {
|
|
15
|
+
const chatThread = chat.thread({ channel: channelId, thread: threadId });
|
|
16
|
+
// Read before the replies are repaired: a turn with no reply message (a question or an
|
|
17
|
+
// approval opened it, and a reply starts on its first write) leaves its request as the
|
|
18
|
+
// only trace in the thread.
|
|
19
|
+
const hadReply = thread.openReplies.length > 0;
|
|
20
|
+
for (const messageId of thread.openReplies) {
|
|
21
|
+
if (await chatThread.endReply(messageId, texts.STOPPED_BEFORE_ANSWER)) {
|
|
22
|
+
safe(() => state.replaceOpenReply(channelId, threadId, messageId, null));
|
|
23
|
+
}
|
|
24
|
+
else
|
|
25
|
+
left(channelId, threadId, "a reply");
|
|
26
|
+
}
|
|
27
|
+
// One stopped line per thread: the first request rewritten says it, the others go.
|
|
28
|
+
let lined = hadReply;
|
|
29
|
+
for (const messageId of thread.requests) {
|
|
30
|
+
const asksForLine = !lined && isTurnRequest(thread.requestKinds[messageId]);
|
|
31
|
+
const outcome = await settleRequest(chatThread, messageId, asksForLine);
|
|
32
|
+
if (outcome === "kept")
|
|
33
|
+
left(channelId, threadId, "a request");
|
|
34
|
+
else {
|
|
35
|
+
if (outcome === "rewritten")
|
|
36
|
+
lined = true;
|
|
37
|
+
safe(() => state.removeRequest(channelId, threadId, messageId));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (thread.status !== null) {
|
|
41
|
+
const { status } = thread;
|
|
42
|
+
const ended = endedWord(status);
|
|
43
|
+
// Shown through a thread object of its own: its first status also strips every other
|
|
44
|
+
// stray status from the root, more than removing the one name the state recorded.
|
|
45
|
+
const shown = ended === null || !chat.capabilities.reactions || (await chatThread.showStatus("error"));
|
|
46
|
+
// Not shown: the word stays for the next start, and the ended one says meanwhile, to
|
|
47
|
+
// whoever lists the sessions, that this one does not run.
|
|
48
|
+
safe(() => state.setStatusPending(channelId, threadId, shown ? null : status, ended));
|
|
49
|
+
if (!shown)
|
|
50
|
+
left(channelId, threadId, "the status");
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
logger.error(`could not repair a crashed thread's state in ${channelId}/${threadId}: ${errorName(error)}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Only a question or an approval stands for a turn the owner's message started; the setup form
|
|
60
|
+
* and the hold wait before that message is sent, and an entry with no kind (a file written
|
|
61
|
+
* before the kinds) is taken for neither.
|
|
62
|
+
*/
|
|
63
|
+
function isTurnRequest(kind) {
|
|
64
|
+
return kind === "question" || kind === "approval";
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* A request the crash left open. With `line` the request is rewritten into the stopped line (no
|
|
68
|
+
* controls, and no notification: an edit); it goes when `line` is false or the chat refuses the
|
|
69
|
+
* rewrite. "kept" when it still shows.
|
|
70
|
+
*/
|
|
71
|
+
async function settleRequest(chatThread, messageId, line) {
|
|
72
|
+
if (line) {
|
|
73
|
+
try {
|
|
74
|
+
await chatThread.rewrite(messageId, texts.STOPPED_BEFORE_ANSWER);
|
|
75
|
+
return "rewritten";
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
logger.warning(`could not rewrite a stale request: ${errorName(error)}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return (await chatThread.withdraw(messageId)) ? "withdrawn" : "kept";
|
|
82
|
+
}
|
|
83
|
+
/** One line for what stays in the state for the next start: ids and the kind only. */
|
|
84
|
+
function left(channelId, threadId, what) {
|
|
85
|
+
logger.warning(`${what} of ${channelId}/${threadId} was not repaired: the next start tries again`);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* A `StateStore` write is a plain synchronous file write, so it can throw like any other (disk
|
|
89
|
+
* full, a permission problem): best-effort here, since repair itself must never crash the
|
|
90
|
+
* daemon's startup over its own bookkeeping. Logged with the error name only.
|
|
91
|
+
*/
|
|
92
|
+
function safe(write) {
|
|
93
|
+
try {
|
|
94
|
+
write();
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
logger.warning(`could not update state.json during crash repair: ${errorName(error)}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* The stored word a root left working or waiting ends on, the error one; null for a word that
|
|
102
|
+
* is neither, which is cleared with nothing shown.
|
|
103
|
+
*/
|
|
104
|
+
function endedWord(stored) {
|
|
105
|
+
return stored === "working" || stored === "waiting" ? "error" : null;
|
|
106
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { errorName } from "../../values.js";
|
|
2
|
+
import { oneLine, TITLE_LIMIT } from "../reply/words.js";
|
|
3
|
+
import * as texts from "../texts.js";
|
|
4
|
+
import { fill, named } from "../texts.js";
|
|
5
|
+
import { isCancelled, logger } from "./talk.js";
|
|
6
|
+
/**
|
|
7
|
+
* The fewest characters of a session's id that `!resume` reads as an id and not as a name: what
|
|
8
|
+
* a list shows of each row, so that a short title such as "add" is not read as one.
|
|
9
|
+
*/
|
|
10
|
+
export const ID_PREFIX_MIN = 8;
|
|
11
|
+
// The words that label a link to a thread inside a notice.
|
|
12
|
+
const HOLDER_LABEL = "Session";
|
|
13
|
+
const THIS_THREAD = "this thread";
|
|
14
|
+
/**
|
|
15
|
+
* The sessions `!resume <target>` names: those whose id is `target` or starts with it, when it
|
|
16
|
+
* is at least `ID_PREFIX_MIN` characters long, else those whose title (set with a rename or
|
|
17
|
+
* generated, the agent's `customTitle`) is `target`.
|
|
18
|
+
*/
|
|
19
|
+
export function matching(sessions, target) {
|
|
20
|
+
const byId = sessions.filter((s) => Array.from(target).length >= ID_PREFIX_MIN && s.id.startsWith(target));
|
|
21
|
+
return byId.length > 0 ? byId : sessions.filter((s) => s.customTitle === target);
|
|
22
|
+
}
|
|
23
|
+
export function resuming(deps, talk) {
|
|
24
|
+
const { agent, chat, sessions, state, names } = deps;
|
|
25
|
+
const inNotice = (text) => chat.escape(text, "notice");
|
|
26
|
+
/**
|
|
27
|
+
* The list has done its job once a session is resumed from it. With `remove`, the
|
|
28
|
+
* confirmation is in the thread and is the one record of what was resumed: the list is
|
|
29
|
+
* removed (issue #70). Without it, or when the removal fails, the list is rewritten into a
|
|
30
|
+
* line that says what was resumed and where, so its controls never stay live.
|
|
31
|
+
*/
|
|
32
|
+
const showResumed = async (ref, list, chosen, options) => {
|
|
33
|
+
if (list === null || talk.stopped)
|
|
34
|
+
return;
|
|
35
|
+
const picker = chat.channel(ref.channel);
|
|
36
|
+
if (options.remove && (await picker.withdraw(list)))
|
|
37
|
+
return;
|
|
38
|
+
const title = inNotice(oneLine(chosen.title, TITLE_LIMIT)) || chosen.id;
|
|
39
|
+
const link = await talk.noticeLink(ref, THIS_THREAD);
|
|
40
|
+
try {
|
|
41
|
+
await picker.rewrite(list, fill(texts.RESUME_LISTED, { title, link }));
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
if (isCancelled(error))
|
|
45
|
+
throw error;
|
|
46
|
+
// The session is resumed either way; a list that keeps its controls only refuses.
|
|
47
|
+
logger.warning(`could not update the resume list in ${ref.channel}: ${errorName(error)}`);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* Point the thread `ref` at `chosen`, a session read from `directory`; false, after telling
|
|
52
|
+
* the owner why in the channel, when nothing changed: that thread already holds a session (a
|
|
53
|
+
* resume is never a swap), `chosen` is already held by some other thread (one session lives
|
|
54
|
+
* in one thread), or the channel was bound to another folder while `chosen` was read from
|
|
55
|
+
* `directory`. Every check runs with no `await` before the `resume` they guard, so nothing
|
|
56
|
+
* can change between the checks and the call they protect. `list`: the list a pick came from,
|
|
57
|
+
* removed once the confirmation is posted or has failed, so its controls never outlive a
|
|
58
|
+
* resume.
|
|
59
|
+
*/
|
|
60
|
+
const resumeInto = async (ref, directory, chosen, list = null) => {
|
|
61
|
+
const { channel, thread } = ref;
|
|
62
|
+
// (sync) From here to `sessions.resume`, which opens the thread's entry inside the call:
|
|
63
|
+
// each refusal below leaves through its own `await`, and nothing is awaited on the way
|
|
64
|
+
// to the resume itself.
|
|
65
|
+
// A thread being deleted still holds its session's entry, and hands out no session.
|
|
66
|
+
if (sessions.get(channel, thread) !== null || sessions.held(channel, thread)) {
|
|
67
|
+
await talk.inChannel(channel, texts.RESUME_HELD);
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
const holder = state.holder(chosen.id);
|
|
71
|
+
if (holder !== null) {
|
|
72
|
+
const link = await talk.noticeLink(holder, HOLDER_LABEL);
|
|
73
|
+
await talk.inChannel(channel, fill(texts.RESUME_ELSEWHERE, { link }));
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
const record = state.channel(channel);
|
|
77
|
+
if (record === null || record.directory !== directory) {
|
|
78
|
+
await talk.inChannel(channel, texts.RESUME_GONE);
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
const session = await sessions.resume(channel, thread, chosen.id);
|
|
82
|
+
// just confirmed the channel is bound to `directory`
|
|
83
|
+
if (session === null)
|
|
84
|
+
throw new Error("a bound channel resumed no session");
|
|
85
|
+
// A reference, not a notice: the title is escaped so it cannot close or open the bold.
|
|
86
|
+
const title = chat.escape(oneLine(chosen.title, TITLE_LIMIT), "reference") || chosen.id;
|
|
87
|
+
let confirmed = false;
|
|
88
|
+
try {
|
|
89
|
+
await talk.say(channel, thread, fill(texts.RESUME_OK, { title }));
|
|
90
|
+
confirmed = true;
|
|
91
|
+
}
|
|
92
|
+
finally {
|
|
93
|
+
// Independent of the confirmation: a failing one still takes the list's controls away,
|
|
94
|
+
// and a failure there (swallowed in `showResumed`) never blocks it. With no
|
|
95
|
+
// confirmation in the thread the list is kept, rewritten, as the record.
|
|
96
|
+
await showResumed(ref, list, chosen, { remove: confirmed });
|
|
97
|
+
}
|
|
98
|
+
return true;
|
|
99
|
+
};
|
|
100
|
+
return {
|
|
101
|
+
async word(ref, target) {
|
|
102
|
+
const { channel } = ref;
|
|
103
|
+
const record = state.channel(channel);
|
|
104
|
+
if (record === null) {
|
|
105
|
+
await talk.unbound(channel);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (!agent.capabilities.sessionListing) {
|
|
109
|
+
await talk.inChannel(channel, named(texts.RESUME_UNLISTED, names));
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const directory = record.directory;
|
|
113
|
+
const stored = await sessions.sessionsIn(directory);
|
|
114
|
+
if (!target) {
|
|
115
|
+
// A session a thread already holds cannot be resumed: it takes no row, so the rows are
|
|
116
|
+
// the ones the owner can pick (issue #69). Only the list shows dates, and dating reads
|
|
117
|
+
// files: the sessions left out are not dated.
|
|
118
|
+
const free = stored.filter((session) => state.holder(session.id) === null);
|
|
119
|
+
await chat.channel(channel).offer({
|
|
120
|
+
type: "sessions",
|
|
121
|
+
folder: directory,
|
|
122
|
+
sessions: await sessions.dated(directory, free),
|
|
123
|
+
held: stored.length - free.length,
|
|
124
|
+
thread: ref,
|
|
125
|
+
}, fill(texts.RESUME_LIST, { directory }));
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const found = matching(stored, target);
|
|
129
|
+
const [only] = found;
|
|
130
|
+
if (found.length !== 1 || only === undefined) {
|
|
131
|
+
const template = found.length > 0 ? texts.RESUME_AMBIGUOUS : texts.RESUME_NONE;
|
|
132
|
+
await talk.inChannel(channel, fill(template, { directory: inNotice(directory), target: inNotice(target) }));
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
await resumeInto(ref, directory, only);
|
|
136
|
+
},
|
|
137
|
+
async picked(pick) {
|
|
138
|
+
const { channel } = pick;
|
|
139
|
+
const record = state.channel(channel);
|
|
140
|
+
if (record === null) {
|
|
141
|
+
await talk.unbound(channel);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
// The pick is not trusted: the session must still belong to the channel's folder, and
|
|
145
|
+
// the thread it names must be well formed (`resumeInto` refuses one that already holds a
|
|
146
|
+
// session).
|
|
147
|
+
if (pick.thread === null) {
|
|
148
|
+
// A list posted before its controls carried their thread.
|
|
149
|
+
await talk.inChannel(channel, texts.RESUME_STALE);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const stored = await sessions.sessionsIn(record.directory);
|
|
153
|
+
const chosen = stored.find((session) => session.id === pick.sessionId);
|
|
154
|
+
if (chosen === undefined) {
|
|
155
|
+
await talk.inChannel(channel, texts.RESUME_GONE);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
// The channel is the one checked above: the thread names only where in it.
|
|
159
|
+
const ref = { channel, thread: pick.thread.thread };
|
|
160
|
+
await resumeInto(ref, record.directory, chosen, pick.list);
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|