pi-roundtable 0.29.0 → 0.29.1

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/CHANGELOG.md CHANGED
@@ -5,6 +5,12 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.29.1] - 2026-10-31
9
+
10
+ ### Added
11
+
12
+ - Queued messages can be listed, edited and cancelled. A message sent while a turn runs that the turn did not take waits in the channel's queue (`QUEUED_MARK`); until its own turn starts, `ConversationPort.queued(channel)` lists it (`QueuedMessage`: `messageId`, `principalId`, `text`, `queuedAt`), `cancelQueued(channel, messageId, by)` makes its turn never run (the claim's `Admission.dropped` is called and the mark comes off), and `editQueued(channel, messageId, by, text)` admits it again with the new text, so the turn runs on that. `by` is the author's principal (`QueuedBy`); anyone else gets `forbidden`, and a message that started, was steered or was cancelled is `unknown` (`QueuedChange`). The three are optional on the port type, as `messageDeleted` is. A surface that reports a deleted queued message cancels it, and one that reports an edited one changes its text, through `SurfaceMessageEvents` as before: on Discord, deleting a queued message cancels it and editing it changes what its turn uses. `QUEUED_MARK` and `STEERED_MARK` are exported from the main entry.
13
+
8
14
  ## [0.29.0] - 2026-10-31
9
15
 
10
16
  ### Added
package/docs/plugins.md CHANGED
@@ -2436,6 +2436,8 @@ When that record finds the author someone else than the claim admitted, such as
2436
2436
 
2437
2437
  A claim may also hear that a message it owns changed, through `onMessageDeleted(channel, messageId)` and `onMessageEdited(message)`, which a surface reports with the second argument of its `start` (`{ deleted, edited }`, a `SurfaceMessageEvents`). Discord reports both, including for a message the bot had not cached; a surface that cannot tell never calls them. The router delivers each as it comes, to the claim that owns the channel and no other, outside the channel's queue, so a running turn does not hold it up; a throw or rejection is logged and never fails the router. A delete carries only ids, and Discord's delete event does not say who wrote the message, so the claim hears the deletion of the bot's own messages too and ignores an id it does not know. An edit carries the message as it reads now, as an `InboundMessage`, without `speaker` or `peerBot` because nobody is resolved or admitted for it; the bot's own edits are not reported, nor is a change that leaves the text, the embeds and the attachments as they were, such as a pin. A bulk delete is reported as one delete per message.
2438
2438
 
2439
+ A message sent while a turn runs is first offered to that turn (`busy.steer`); one the turn does not take waits in the channel's queue with `QUEUED_MARK`, and until its own turn starts the conversation port can list, cancel and edit it: `queued(channel)` returns its waiting messages oldest first (`messageId`, the author's `principalId`, the `text` its turn will use, `queuedAt`); `cancelQueued(channel, messageId, { principalId })` makes its turn never run, calling the admission's `dropped` and removing the mark; `editQueued(channel, messageId, { principalId }, text)` admits the message again with the new text through the claim's `admit`, drops the old admission, and the turn runs on the new one. Only the message's author may (a different principal gets `forbidden`, and a message with no resolved author can be changed by no one). A message whose turn has started, was steered into the running turn, or was cancelled is `unknown`; an edit the claim would no longer admit is `refused` and the old text stays. The decision is made in one synchronous step, so a cancel or an edit and the start of the turn never both win. The surface's own copy of the message is the caller's to change. When a surface reports a queued message deleted or edited (`onMessageDeleted`, `onMessageEdited` above), the router cancels it or changes its text itself, without the author check since the surface says it happened: that is how deleting a queued Discord message cancels it. The three methods are optional on `ConversationPort`, as `messageDeleted` is.
2440
+
2439
2441
  A claim may have `stop(channel)`, which stops the channel's running turn and returns whether one was running; the Stop button, `conversations.stop`, and every other stop go through it.
2440
2442
  The router calls only the owning claim's `stop`, returning `false` when that method is absent.
2441
2443
  Give `stop` to a claim whose conversations run turns that can be interrupted.
@@ -4566,6 +4568,11 @@ Import from the entries listed below; source area files are internal.
4566
4568
  | `Provisioning` | `pi-roundtable` | type |
4567
4569
  | `ProvisioningRule` | `pi-roundtable` | type |
4568
4570
  | `QueuePort` | `pi-roundtable` | type |
4571
+ | `QueuedBy` | `pi-roundtable` | type |
4572
+ | `QueuedChange` | `pi-roundtable` | type |
4573
+ | `QueuedMessage` | `pi-roundtable` | type |
4574
+ | `QUEUED_MARK` | `pi-roundtable` | value |
4575
+ | `STEERED_MARK` | `pi-roundtable` | value |
4569
4576
  | `QUESTIONNAIRE_LIMITS` | `pi-roundtable` | value |
4570
4577
  | `Questionnaire` | `pi-roundtable` | type |
4571
4578
  | `QuestionnaireAnswer` | `pi-roundtable` | type |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable",
3
- "version": "0.29.0",
3
+ "version": "0.29.1",
4
4
  "description": "A plugin-driven Pi agent server for one owner, reached through Discord or the web",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -109,6 +109,31 @@ export interface InboundMessage {
109
109
  export const STEERED_MARK = "↪️";
110
110
  export const QUEUED_MARK = "⏳";
111
111
 
112
+ /** A message waiting in a channel's queue for its own turn (`ConversationPort.queued`). */
113
+ export interface QueuedMessage {
114
+ /** The surface's id of the message, as `InboundMessage.messageId`. */
115
+ messageId: string;
116
+ /** The principal of the person who wrote it; absent when the router resolved none. */
117
+ principalId?: string;
118
+ /** What its turn will use: the text as last edited. */
119
+ text: string;
120
+ /** Epoch milliseconds when it was queued. */
121
+ queuedAt: number;
122
+ }
123
+
124
+ /** Who asks to change a queued message; only its author may. */
125
+ export interface QueuedBy {
126
+ principalId: string;
127
+ }
128
+
129
+ /**
130
+ * What cancelling or editing a queued message came to. `unknown`: none is queued under that id,
131
+ * such as one whose turn has started, been cancelled, or been steered into the running turn.
132
+ * `forbidden`: it is queued, but not by that principal. `refused` (edit only): the claim would no
133
+ * longer take the message with the new text.
134
+ */
135
+ export type QueuedChange = "done" | "unknown" | "forbidden" | "refused";
136
+
112
137
  /** How a turn nobody wrote in the channel ended. */
113
138
  export type ScheduledOutcome =
114
139
  | { status: "ran" }
@@ -205,8 +230,8 @@ export type Admission =
205
230
  run(): Promise<void>;
206
231
  /**
207
232
  * Called instead of `run` when the router drops the message the claim admitted, because
208
- * its author, once recorded, was not who they were when admitted; frees what the claim
209
- * holds for it.
233
+ * its author, once recorded, was not who they were when admitted, or because it was
234
+ * cancelled or replaced by an edit while it waited; frees what the claim holds for it.
210
235
  */
211
236
  dropped?(): void;
212
237
  /** The log line when the turn throws. */
@@ -327,6 +352,34 @@ export interface ConversationPort {
327
352
  ): Promise<void>;
328
353
  /** Tells the claim that owns the channel a message was edited (`ChannelClaim.onMessageEdited`); never rejects. */
329
354
  messageEdited?(message: InboundMessage): Promise<void>;
355
+ /**
356
+ * The messages waiting in the channel for their own turn, oldest first: a message sent during
357
+ * a turn that the running turn did not take (`QUEUED_MARK`). One that started, was steered
358
+ * into the running turn, or was cancelled is not listed. Optional so a port written before 0.28
359
+ * still type-checks; the host's has it.
360
+ */
361
+ queued?(channel: ChannelKey): readonly QueuedMessage[];
362
+ /**
363
+ * Cancels a queued message so its turn never starts, for its author only. `unknown` once the
364
+ * turn has started, so a caller can tell the person it is too late. The surface is told only
365
+ * through the message's `QUEUED_MARK` being removed.
366
+ */
367
+ cancelQueued?(
368
+ channel: ChannelKey,
369
+ messageId: string,
370
+ by: QueuedBy,
371
+ ): QueuedChange;
372
+ /**
373
+ * Replaces the text a queued message's turn will use, for its author only: the claim that
374
+ * admitted the message admits it again with the new text, and the turn runs on that. The
375
+ * surface's own copy of the message is the caller's to change.
376
+ */
377
+ editQueued?(
378
+ channel: ChannelKey,
379
+ messageId: string,
380
+ by: QueuedBy,
381
+ text: string,
382
+ ): QueuedChange;
330
383
  }
331
384
 
332
385
  /**
@@ -274,6 +274,10 @@ export class Linkage {
274
274
  messageDeleted: (channel, id, space) =>
275
275
  router().messageDeleted(channel, id, space),
276
276
  messageEdited: (message) => router().messageEdited(message),
277
+ queued: (channel) => router().queued(channel),
278
+ cancelQueued: (channel, id, by) => router().cancelQueued(channel, id, by),
279
+ editQueued: (channel, id, by, text) =>
280
+ router().editQueued(channel, id, by, text),
277
281
  background: (turn) => router().background(turn),
278
282
  runsAs: (turn) => router().runsAs(turn),
279
283
  target: (name) => router().target(name),
@@ -0,0 +1,40 @@
1
+ import type { InboundMessage } from "../contract/channels.ts";
2
+ import { parseChannelKey } from "../contract/surface.ts";
3
+ import type { ActorFacts } from "../identity/actor-facts.ts";
4
+ import type { Logger } from "../log.ts";
5
+
6
+ /**
7
+ * Who wrote the message, as its surface reports them; a surface that reports no `actor` has it
8
+ * read from the author fields under its own name, once warned (`warned` holds the surfaces
9
+ * already warned). None for a bot or an integration, unless the host lists the bot as a peer.
10
+ */
11
+ export function actorOf(
12
+ message: InboundMessage,
13
+ warned: Set<string>,
14
+ logger: Logger,
15
+ ): ActorFacts | undefined {
16
+ if ((message.authorIsBot && !message.peerBot) || message.integration)
17
+ return undefined;
18
+ if (message.actor) return message.actor;
19
+ const { surface } = parseChannelKey(message.channel);
20
+ if (!warned.has(surface)) {
21
+ warned.add(surface);
22
+ logger.warn(
23
+ { surface },
24
+ `deprecated: the ${surface} surface reports no InboundMessage.actor, so its authors are read from authorId, authorName, and authorRoleIds as ${surface} identities; set actor, as this goes away in 1.0`,
25
+ );
26
+ }
27
+ return {
28
+ provider: surface,
29
+ subject: message.authorId,
30
+ name: message.authorName,
31
+ surface,
32
+ ...(message.authorRoleIds
33
+ ? {
34
+ roles: message.authorRoleIds.map((role) => `${surface}:role:${role}`),
35
+ }
36
+ : {}),
37
+ ...(message.space ? { space: message.space } : {}),
38
+ legacyId: message.authorId,
39
+ };
40
+ }
@@ -0,0 +1,50 @@
1
+ import type { BackgroundRunsAs, BackgroundTurn } from "../contract/channels.ts";
2
+ import { IdentityError } from "../domain/errors.ts";
3
+ import {
4
+ type IdentityService,
5
+ systemSpeaker,
6
+ } from "../identity/identity-service.ts";
7
+ import { SYSTEM_PRINCIPAL } from "../identity/principal-store.ts";
8
+ import { isSystemTurn } from "./system-turns.ts";
9
+
10
+ /**
11
+ * Who a background turn runs as, before its claim has the last word: the system principal for
12
+ * the host's own turn, at its tier; otherwise its author's principal, which the identity service
13
+ * checks and caps at the lower of the turn's tier and theirs. Why not, when it may not run;
14
+ * throws when the identity service cannot tell.
15
+ */
16
+ export async function speakerOf(
17
+ turn: BackgroundTurn,
18
+ principals: Pick<IdentityService, "speakerFor"> | undefined,
19
+ ): Promise<BackgroundRunsAs> {
20
+ const { author, tier } = turn;
21
+ if (!author?.principalId || !tier)
22
+ return {
23
+ skipped:
24
+ "a background turn names its author's principal (author.principalId) and the tier it runs at (tier); this one does not",
25
+ };
26
+ if (author.principalId === SYSTEM_PRINCIPAL)
27
+ return isSystemTurn(turn)
28
+ ? { speaker: { ...systemSpeaker(tier), name: author.name } }
29
+ : {
30
+ skipped: `only the host itself starts a turn as the system principal "${SYSTEM_PRINCIPAL}"`,
31
+ };
32
+ if (!principals)
33
+ return {
34
+ skipped: "the host has no identity service to check whom it runs as",
35
+ };
36
+ try {
37
+ const checked = await principals.speakerFor(author.principalId, tier);
38
+ return {
39
+ speaker: {
40
+ id: author.id,
41
+ name: author.name,
42
+ tier: checked.tier,
43
+ principalId: checked.principalId,
44
+ },
45
+ };
46
+ } catch (error) {
47
+ if (!(error instanceof IdentityError)) throw error;
48
+ return { skipped: error.message };
49
+ }
50
+ }
@@ -7,28 +7,27 @@ import {
7
7
  type ConversationPort,
8
8
  type InboundMessage,
9
9
  QUEUED_MARK,
10
+ type QueuedBy,
11
+ type QueuedChange,
12
+ type QueuedMessage,
10
13
  type ScheduledOutcome,
11
14
  STEERED_MARK,
12
15
  } from "../contract/channels.ts";
13
- import { parseChannelKey, type SurfacePort } from "../contract/surface.ts";
14
- import { IdentityError } from "../domain/errors.ts";
16
+ import type { SurfacePort } from "../contract/surface.ts";
15
17
  import { HostStoppingError, PluginError } from "../errors.ts";
16
18
  import { messages } from "../i18n/index.ts";
17
- import type { ActorFacts } from "../identity/actor-facts.ts";
18
19
  import type { Contact, ContactAssessor } from "../identity/contact.ts";
19
- import {
20
- type IdentityService,
21
- systemSpeaker,
22
- } from "../identity/identity-service.ts";
23
- import { SYSTEM_PRINCIPAL } from "../identity/principal-store.ts";
20
+ import type { IdentityService } from "../identity/identity-service.ts";
24
21
  import type { Logger } from "../log.ts";
25
22
  import type { ChannelKey } from "../sessions.ts";
23
+ import { actorOf } from "./actor-of.ts";
24
+ import { speakerOf } from "./background-speaker.ts";
26
25
  import type { ChannelQueue } from "./channel-queue.ts";
27
26
  import { tellDeleted, tellEdited } from "./claim-events.ts";
28
27
  import { ForwardJoin } from "./forward-join.ts";
29
28
  import type { MessageObservers } from "./message-observers.ts";
30
29
  import { type PeerBots, peerFacts } from "./peer-bots.ts";
31
- import { isSystemTurn } from "./system-turns.ts";
30
+ import { type QueuedEntry, QueuedMessages } from "./queued-messages.ts";
32
31
 
33
32
  export interface ChannelRouterOptions {
34
33
  /** Claims in registration order; the router orders them by priority. */
@@ -82,6 +81,8 @@ export class ChannelRouter implements ConversationPort {
82
81
  readonly #options: ChannelRouterOptions;
83
82
  readonly #claims: readonly ChannelClaim[];
84
83
  readonly #forwards: ForwardJoin;
84
+ /** The messages waiting for their own turn, so one can be cancelled or edited until it starts. */
85
+ readonly #queued: QueuedMessages;
85
86
  /** Each channel's messages being admitted, so they reach their claim and the queue in the order they came. */
86
87
  readonly #admitting = new Map<ChannelKey, Promise<unknown>>();
87
88
  /** The surfaces already warned that their messages carry no actor. */
@@ -93,6 +94,7 @@ export class ChannelRouter implements ConversationPort {
93
94
  this.#options = options;
94
95
  this.#claims = orderClaims(options.claims);
95
96
  this.#forwards = new ForwardJoin(options.forwardJoinMs);
97
+ this.#queued = new QueuedMessages(options.logger);
96
98
  }
97
99
 
98
100
  #owner(channel: ChannelKey, space?: string): ChannelClaim | undefined {
@@ -137,15 +139,16 @@ export class ChannelRouter implements ConversationPort {
137
139
  );
138
140
  return undefined;
139
141
  }
140
- const admission = await this.#admit(claim, message);
141
- if (!admission) return undefined;
142
+ const admitted = await this.#admit(claim, message);
143
+ if (!admitted) return undefined;
144
+ const { admission } = admitted;
142
145
  if (listed) peers?.answered(message.channel);
143
146
  // Once the host shuts down only the turns already running finish: this one starts nothing.
144
147
  if (this.#options.queue.closed) {
145
148
  await this.#refuse(message, admission);
146
149
  return undefined;
147
150
  }
148
- return this.#start(message, admission);
151
+ return this.#start(claim, admitted.message, admission);
149
152
  });
150
153
  await started?.done;
151
154
  }
@@ -177,10 +180,10 @@ export class ChannelRouter implements ConversationPort {
177
180
  async #admit(
178
181
  claim: ChannelClaim,
179
182
  message: InboundMessage,
180
- ): Promise<Admission | undefined> {
183
+ ): Promise<{ admission: Admission; message: InboundMessage } | undefined> {
181
184
  const { logger, contacts } = this.#options;
182
185
  const { speaker: _given, ...facts } = message;
183
- const actor = this.#actorOf(message);
186
+ const actor = actorOf(message, this.#warned, logger);
184
187
  let contact: Contact | undefined;
185
188
  if (actor && contacts)
186
189
  try {
@@ -193,15 +196,16 @@ export class ChannelRouter implements ConversationPort {
193
196
  "could not resolve who wrote a message; it reaches its claim as no one",
194
197
  );
195
198
  }
196
- const admission = claim.admit(
197
- contact
198
- ? { ...facts, speaker: Object.freeze({ ...contact.speaker }) }
199
- : facts,
200
- );
201
- if (!admission || !contact) return admission;
199
+ const admittedAs: InboundMessage = contact
200
+ ? { ...facts, speaker: Object.freeze({ ...contact.speaker }) }
201
+ : facts;
202
+ const admission = claim.admit(admittedAs);
203
+ if (!admission) return undefined;
204
+ if (!contact) return { admission, message: admittedAs };
202
205
  try {
203
206
  const taken = await contact.take();
204
- if (taken?.principalId === contact.speaker.principalId) return admission;
207
+ if (taken?.principalId === contact.speaker.principalId)
208
+ return { admission, message: admittedAs };
205
209
  logger.warn(
206
210
  { channel: message.channel },
207
211
  "the author of a message was linked elsewhere, or refused, while it was admitted; it does not run",
@@ -221,40 +225,6 @@ export class ChannelRouter implements ConversationPort {
221
225
  return undefined;
222
226
  }
223
227
 
224
- /**
225
- * Who wrote the message, as its surface reports them; a surface that reports no `actor` has it
226
- * read from the author fields under its own name, once warned. None for a bot or an
227
- * integration, unless the host lists the bot as a peer.
228
- */
229
- #actorOf(message: InboundMessage): ActorFacts | undefined {
230
- if ((message.authorIsBot && !message.peerBot) || message.integration)
231
- return undefined;
232
- if (message.actor) return message.actor;
233
- const { surface } = parseChannelKey(message.channel);
234
- if (!this.#warned.has(surface)) {
235
- this.#warned.add(surface);
236
- this.#options.logger.warn(
237
- { surface },
238
- `deprecated: the ${surface} surface reports no InboundMessage.actor, so its authors are read from authorId, authorName, and authorRoleIds as ${surface} identities; set actor, as this goes away in 1.0`,
239
- );
240
- }
241
- return {
242
- provider: surface,
243
- subject: message.authorId,
244
- name: message.authorName,
245
- surface,
246
- ...(message.authorRoleIds
247
- ? {
248
- roles: message.authorRoleIds.map(
249
- (role) => `${surface}:role:${role}`,
250
- ),
251
- }
252
- : {}),
253
- ...(message.space ? { space: message.space } : {}),
254
- legacyId: message.authorId,
255
- };
256
- }
257
-
258
228
  /**
259
229
  * Drops a message the host no longer starts a turn for because it is shutting down, and tells
260
230
  * its channel once that it restarts; no one is told for a bot's or an integration's message, such
@@ -287,6 +257,7 @@ export class ChannelRouter implements ConversationPort {
287
257
  * during a turn is first offered to it, then marked as waiting.
288
258
  */
289
259
  async #start(
260
+ claim: ChannelClaim,
290
261
  message: InboundMessage,
291
262
  admission: Admission,
292
263
  ): Promise<{ done: Promise<void> }> {
@@ -298,39 +269,71 @@ export class ChannelRouter implements ConversationPort {
298
269
  return { done };
299
270
  }
300
271
  const { busy } = admission;
301
- let queued = false;
272
+ let waiting: QueuedEntry | undefined;
302
273
  // A message during a turn is added to it when the turn takes it, otherwise marked as waiting.
303
274
  if (busy && queue.size(message.channel) > 0) {
304
275
  if (await busy.steer?.()) return { done: busy.react(STEERED_MARK) };
276
+ // Listed before the mark is placed, so a delete or cancel during that wait reaches it.
277
+ waiting = this.#queued.add(claim, message, admission);
305
278
  await busy.react(QUEUED_MARK);
306
- queued = true;
279
+ if (waiting.cancelled) return { done: Promise.resolve() };
307
280
  }
308
281
  const done = queue
309
282
  .run(message.channel, () => {
310
- if (queued) void busy?.unreact(QUEUED_MARK);
311
- return admission.run();
283
+ if (!waiting) return admission.run();
284
+ // From here no cancel or edit reaches it: the turn has started or it was cancelled before.
285
+ if (!this.#queued.remove(waiting)) return Promise.resolve();
286
+ this.#queued.unmark(waiting);
287
+ return waiting.admission.run();
312
288
  })
313
289
  // pi-lens-ignore: no-unknown-parameters
314
290
  .catch(async (error: unknown) => {
315
291
  // The host began shutting down while this message waited behind a running turn.
316
292
  if (error instanceof HostStoppingError) {
317
- if (queued) void busy?.unreact(QUEUED_MARK);
318
- return this.#refuse(message, admission);
293
+ if (waiting && !this.#queued.remove(waiting)) return undefined;
294
+ if (waiting) this.#queued.unmark(waiting);
295
+ return this.#refuse(
296
+ waiting?.message ?? message,
297
+ waiting?.admission ?? admission,
298
+ );
319
299
  }
320
300
  logger.error(
321
301
  { channel: message.channel, err: error },
322
- admission.failure,
302
+ (waiting?.admission ?? admission).failure,
323
303
  );
324
304
  });
325
305
  return { done };
326
306
  }
327
307
 
308
+ queued(channel: ChannelKey): readonly QueuedMessage[] {
309
+ return this.#queued.list(channel);
310
+ }
311
+
312
+ cancelQueued(
313
+ channel: ChannelKey,
314
+ messageId: string,
315
+ by: QueuedBy,
316
+ ): QueuedChange {
317
+ return this.#queued.cancel(channel, messageId, by, true);
318
+ }
319
+
320
+ editQueued(
321
+ channel: ChannelKey,
322
+ messageId: string,
323
+ by: QueuedBy,
324
+ text: string,
325
+ ): QueuedChange {
326
+ return this.#queued.edit(channel, messageId, by, text);
327
+ }
328
+
328
329
  /** Tells the claim that owns the channel a message was deleted; never rejects, never waits for the channel's queue. */
329
330
  messageDeleted(
330
331
  channel: ChannelKey,
331
332
  messageId: string,
332
333
  space?: string,
333
334
  ): Promise<void> {
335
+ // The surface says it is gone: no author check, and no mark to remove from a deleted message.
336
+ this.#queued.cancel(channel, messageId, undefined, false);
334
337
  return tellDeleted(
335
338
  this.#owner(channel, space),
336
339
  channel,
@@ -341,6 +344,13 @@ export class ChannelRouter implements ConversationPort {
341
344
 
342
345
  /** Tells the claim that owns the channel a message was edited; never rejects, never waits for the channel's queue. */
343
346
  messageEdited(message: InboundMessage): Promise<void> {
347
+ // Only the author edits a message, so a queued one takes the new text without a check.
348
+ this.#queued.edit(
349
+ message.channel,
350
+ message.messageId,
351
+ undefined,
352
+ message.text,
353
+ );
344
354
  return tellEdited(
345
355
  this.#owner(message.channel, message.space),
346
356
  message,
@@ -392,7 +402,7 @@ export class ChannelRouter implements ConversationPort {
392
402
  * service cannot tell.
393
403
  */
394
404
  async runsAs(turn: BackgroundTurn): Promise<BackgroundRunsAs> {
395
- const runs = await this.#speakerOf(turn);
405
+ const runs = await speakerOf(turn, this.#options.principals);
396
406
  if ("skipped" in runs) return runs;
397
407
  // The claim that owns the channel has the last word, so work that must not start for a turn it
398
408
  // would skip, such as a schedule's precheck, does not.
@@ -403,40 +413,6 @@ export class ChannelRouter implements ConversationPort {
403
413
  return refusal === undefined ? runs : { skipped: refusal };
404
414
  }
405
415
 
406
- async #speakerOf(turn: BackgroundTurn): Promise<BackgroundRunsAs> {
407
- const { author, tier } = turn;
408
- if (!author?.principalId || !tier)
409
- return {
410
- skipped:
411
- "a background turn names its author's principal (author.principalId) and the tier it runs at (tier); this one does not",
412
- };
413
- if (author.principalId === SYSTEM_PRINCIPAL)
414
- return isSystemTurn(turn)
415
- ? { speaker: { ...systemSpeaker(tier), name: author.name } }
416
- : {
417
- skipped: `only the host itself starts a turn as the system principal "${SYSTEM_PRINCIPAL}"`,
418
- };
419
- const { principals } = this.#options;
420
- if (!principals)
421
- return {
422
- skipped: "the host has no identity service to check whom it runs as",
423
- };
424
- try {
425
- const checked = await principals.speakerFor(author.principalId, tier);
426
- return {
427
- speaker: {
428
- id: author.id,
429
- name: author.name,
430
- tier: checked.tier,
431
- principalId: checked.principalId,
432
- },
433
- };
434
- } catch (error) {
435
- if (!(error instanceof IdentityError)) throw error;
436
- return { skipped: error.message };
437
- }
438
- }
439
-
440
416
  startFresh(channel: ChannelKey): Promise<string> {
441
417
  return this.#options.queue.run(channel, async () => {
442
418
  const claim = this.#owner(channel);
@@ -0,0 +1,151 @@
1
+ import {
2
+ type Admission,
3
+ type ChannelClaim,
4
+ type InboundMessage,
5
+ QUEUED_MARK,
6
+ type QueuedBy,
7
+ type QueuedChange,
8
+ type QueuedMessage,
9
+ } from "../contract/channels.ts";
10
+ import type { Logger } from "../log.ts";
11
+ import type { ChannelKey } from "../sessions.ts";
12
+
13
+ type TurnAdmission = Extract<Admission, { kind: "turn" }>;
14
+
15
+ /** A message waiting for its turn, with what runs it; `admission` and `message` follow its edits. */
16
+ export interface QueuedEntry {
17
+ readonly claim: ChannelClaim;
18
+ /** The message as the claim last admitted it: the speaker the router resolved, the text as edited. */
19
+ message: InboundMessage;
20
+ admission: TurnAdmission;
21
+ readonly queuedAt: number;
22
+ /** Cancelled while it waited: its turn does not run. */
23
+ cancelled: boolean;
24
+ }
25
+
26
+ /**
27
+ * The messages waiting for their own turn, by channel and message id: the router adds one when
28
+ * the running turn did not take it and takes it out when its turn starts, so a cancel or an edit
29
+ * can only reach a message that has not started. Everything is synchronous, so no cancel or edit
30
+ * races the start of a turn: whichever runs first wins.
31
+ */
32
+ export class QueuedMessages {
33
+ readonly #entries = new Map<ChannelKey, Map<string, QueuedEntry>>();
34
+ readonly #logger: Logger;
35
+ readonly #now: () => number;
36
+
37
+ constructor(logger: Logger, now: () => number = Date.now) {
38
+ this.#logger = logger;
39
+ this.#now = now;
40
+ }
41
+
42
+ add(
43
+ claim: ChannelClaim,
44
+ message: InboundMessage,
45
+ admission: TurnAdmission,
46
+ ): QueuedEntry {
47
+ const entry: QueuedEntry = {
48
+ claim,
49
+ message,
50
+ admission,
51
+ queuedAt: this.#now(),
52
+ cancelled: false,
53
+ };
54
+ const channel = this.#entries.get(message.channel) ?? new Map();
55
+ channel.set(message.messageId, entry);
56
+ this.#entries.set(message.channel, channel);
57
+ return entry;
58
+ }
59
+
60
+ /** Takes the entry out of the queue's books as its turn starts or the host drops it; false when already out. */
61
+ remove(entry: QueuedEntry): boolean {
62
+ const { channel, messageId } = entry.message;
63
+ const held = this.#entries.get(channel);
64
+ if (held?.get(messageId) !== entry) return false;
65
+ held.delete(messageId);
66
+ if (held.size === 0) this.#entries.delete(channel);
67
+ return true;
68
+ }
69
+
70
+ list(channel: ChannelKey): readonly QueuedMessage[] {
71
+ return [...(this.#entries.get(channel)?.values() ?? [])].map((entry) => ({
72
+ messageId: entry.message.messageId,
73
+ ...(entry.message.speaker
74
+ ? { principalId: entry.message.speaker.principalId }
75
+ : {}),
76
+ text: entry.message.text,
77
+ queuedAt: entry.queuedAt,
78
+ }));
79
+ }
80
+
81
+ /**
82
+ * Cancels the message so its turn never starts. `by` is checked against its author, and absent
83
+ * when the surface itself says the message is gone, such as a deleted one. `unmark` removes the
84
+ * waiting mark from the message, which a deleted message cannot take.
85
+ */
86
+ cancel(
87
+ channel: ChannelKey,
88
+ messageId: string,
89
+ by: QueuedBy | undefined,
90
+ unmark: boolean,
91
+ ): QueuedChange {
92
+ const entry = this.#entries.get(channel)?.get(messageId);
93
+ if (!entry) return "unknown";
94
+ if (by && !this.#authored(entry, by)) return "forbidden";
95
+ this.remove(entry);
96
+ entry.cancelled = true;
97
+ entry.admission.dropped?.();
98
+ if (unmark) this.unmark(entry);
99
+ return "done";
100
+ }
101
+
102
+ /**
103
+ * Gives the message's turn the new text: the claim admits the message again with it. When the
104
+ * claim no longer takes it, the old admission stays and the edit is refused.
105
+ */
106
+ edit(
107
+ channel: ChannelKey,
108
+ messageId: string,
109
+ by: QueuedBy | undefined,
110
+ text: string,
111
+ ): QueuedChange {
112
+ const entry = this.#entries.get(channel)?.get(messageId);
113
+ if (!entry) return "unknown";
114
+ if (by && !this.#authored(entry, by)) return "forbidden";
115
+ if (entry.message.text === text) return "done";
116
+ const message = { ...entry.message, text };
117
+ let next: Admission | undefined;
118
+ try {
119
+ next = entry.claim.admit(message);
120
+ } catch (error) {
121
+ this.#logger.warn(
122
+ { channel, err: error },
123
+ "a claim failed to admit an edited queued message; the old text stays",
124
+ );
125
+ }
126
+ if (next?.kind !== "turn") return "refused";
127
+ const old = entry.admission;
128
+ entry.message = message;
129
+ entry.admission = next;
130
+ old.dropped?.();
131
+ return "done";
132
+ }
133
+
134
+ /** Removes the waiting mark; a failure is logged, since the message may be gone. */
135
+ unmark(entry: QueuedEntry): void {
136
+ const { channel } = entry.message;
137
+ entry.admission.busy
138
+ ?.unreact(QUEUED_MARK)
139
+ .catch((error: unknown) =>
140
+ this.#logger.warn(
141
+ { channel, err: error },
142
+ "the waiting mark of a queued message was not removed",
143
+ ),
144
+ );
145
+ }
146
+
147
+ #authored(entry: QueuedEntry, by: QueuedBy): boolean {
148
+ const author = entry.message.speaker?.principalId;
149
+ return author !== undefined && author === by.principalId;
150
+ }
151
+ }
package/src/index.ts CHANGED
@@ -93,9 +93,13 @@ export type {
93
93
  InboundMessage,
94
94
  MessageObserver,
95
95
  ObserveFilter,
96
+ QueuedBy,
97
+ QueuedChange,
98
+ QueuedMessage,
96
99
  QueuePort,
97
100
  ScheduledOutcome,
98
101
  } from "./core/contract/channels.ts";
102
+ export { QUEUED_MARK, STEERED_MARK } from "./core/contract/channels.ts";
99
103
  export type {
100
104
  ImageDrawer,
101
105
  Judge,