@sigrun/ext-gateway 0.0.0-stage → 0.1.2

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 (82) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/LICENSE +21 -0
  3. package/README.md +241 -2
  4. package/dist/access/codes.d.ts +18 -0
  5. package/dist/access/codes.js +34 -0
  6. package/dist/access/link-codes.d.ts +33 -0
  7. package/dist/access/link-codes.js +63 -0
  8. package/dist/access/loop-guard.d.ts +17 -0
  9. package/dist/access/loop-guard.js +42 -0
  10. package/dist/access/pairing.d.ts +74 -0
  11. package/dist/access/pairing.js +131 -0
  12. package/dist/commands/chat.d.ts +66 -0
  13. package/dist/commands/chat.js +371 -0
  14. package/dist/commands/link.d.ts +63 -0
  15. package/dist/commands/link.js +77 -0
  16. package/dist/commands/pair.d.ts +72 -0
  17. package/dist/commands/pair.js +110 -0
  18. package/dist/config.d.ts +65 -0
  19. package/dist/config.js +219 -0
  20. package/dist/engine.d.ts +108 -0
  21. package/dist/engine.js +645 -0
  22. package/dist/inbound/albums.d.ts +39 -0
  23. package/dist/inbound/albums.js +62 -0
  24. package/dist/inbound/envelope.d.ts +19 -0
  25. package/dist/inbound/envelope.js +39 -0
  26. package/dist/inbound/groups.d.ts +29 -0
  27. package/dist/inbound/groups.js +55 -0
  28. package/dist/inbound/media.d.ts +20 -0
  29. package/dist/inbound/media.js +88 -0
  30. package/dist/inbound/pipeline.d.ts +144 -0
  31. package/dist/inbound/pipeline.js +600 -0
  32. package/dist/inbound/tokens.d.ts +38 -0
  33. package/dist/inbound/tokens.js +42 -0
  34. package/dist/index.d.ts +86 -0
  35. package/dist/index.js +136 -0
  36. package/dist/interactions/answer.d.ts +42 -0
  37. package/dist/interactions/answer.js +112 -0
  38. package/dist/interactions/chat.d.ts +28 -0
  39. package/dist/interactions/chat.js +199 -0
  40. package/dist/interactions/prompts.d.ts +68 -0
  41. package/dist/interactions/prompts.js +246 -0
  42. package/dist/interactions/receipts.d.ts +7 -0
  43. package/dist/interactions/receipts.js +69 -0
  44. package/dist/interactions/render.d.ts +39 -0
  45. package/dist/interactions/render.js +122 -0
  46. package/dist/jobs.d.ts +34 -0
  47. package/dist/jobs.js +72 -0
  48. package/dist/log.d.ts +21 -0
  49. package/dist/log.js +42 -0
  50. package/dist/notify.d.ts +21 -0
  51. package/dist/notify.js +64 -0
  52. package/dist/outbound/delivery.d.ts +131 -0
  53. package/dist/outbound/delivery.js +404 -0
  54. package/dist/outbound/markdown.d.ts +36 -0
  55. package/dist/outbound/markdown.js +286 -0
  56. package/dist/outbound/render.d.ts +24 -0
  57. package/dist/outbound/render.js +65 -0
  58. package/dist/outbound/settled.d.ts +81 -0
  59. package/dist/outbound/settled.js +271 -0
  60. package/dist/outbound/stream.d.ts +38 -0
  61. package/dist/outbound/stream.js +205 -0
  62. package/dist/section.d.ts +16 -0
  63. package/dist/section.js +55 -0
  64. package/dist/session-meta.d.ts +18 -0
  65. package/dist/session-meta.js +39 -0
  66. package/dist/state/bindings.d.ts +55 -0
  67. package/dist/state/bindings.js +128 -0
  68. package/dist/state/identities.d.ts +65 -0
  69. package/dist/state/identities.js +135 -0
  70. package/dist/state/json-file.d.ts +64 -0
  71. package/dist/state/json-file.js +156 -0
  72. package/dist/state/ledger.d.ts +176 -0
  73. package/dist/state/ledger.js +256 -0
  74. package/dist/state/seen.d.ts +41 -0
  75. package/dist/state/seen.js +77 -0
  76. package/dist/status-route.d.ts +32 -0
  77. package/dist/status-route.js +26 -0
  78. package/dist/supervisor.d.ts +153 -0
  79. package/dist/supervisor.js +273 -0
  80. package/dist/tools.d.ts +27 -0
  81. package/dist/tools.js +207 -0
  82. package/package.json +54 -5
package/dist/log.d.ts ADDED
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Engine logging + id hashing (spec §8.0/§8.12): one scoped console
3
+ * logger with a per-process `once` dedupe (per-key — a repeated warn
4
+ * fires a single line, never one per event), and `hashId` — the
5
+ * non-reversible form sender ids take in status output.
6
+ */
7
+ /** The irreversible form a platform sender id takes in operator-facing
8
+ * output: the sha256 digest's first 8 hex chars (the plan's pinned
9
+ * format — enough to correlate without leaking the raw platform id). */
10
+ export declare function hashId(id: string): string;
11
+ /** One scoped logger: console methods plus a per-key `once`. */
12
+ export interface EngineLog {
13
+ info(msg: string): void;
14
+ warn(msg: string): void;
15
+ error(msg: string, error?: unknown): void;
16
+ debug(msg: string): void;
17
+ /** Emit `msg` at `level` ONCE per process per `key` (per-key dedupe). */
18
+ once(key: string, level: "info" | "warn", msg: string): void;
19
+ }
20
+ /** Build the scoped console logger (the `gateway:` prefix names the engine). */
21
+ export declare function createLog(scope: string): EngineLog;
package/dist/log.js ADDED
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Engine logging + id hashing (spec §8.0/§8.12): one scoped console
3
+ * logger with a per-process `once` dedupe (per-key — a repeated warn
4
+ * fires a single line, never one per event), and `hashId` — the
5
+ * non-reversible form sender ids take in status output.
6
+ */
7
+ import { createHash } from "node:crypto";
8
+ /** The irreversible form a platform sender id takes in operator-facing
9
+ * output: the sha256 digest's first 8 hex chars (the plan's pinned
10
+ * format — enough to correlate without leaking the raw platform id). */
11
+ export function hashId(id) {
12
+ return createHash("sha256").update(id).digest("hex").slice(0, 8);
13
+ }
14
+ /** The emitted-once keys this process has already logged. */
15
+ const onceLogged = new Set();
16
+ /** Build the scoped console logger (the `gateway:` prefix names the engine). */
17
+ export function createLog(scope) {
18
+ const prefix = `gateway: ${scope}`.trim();
19
+ return {
20
+ info: (msg) => console.info(`${prefix} ${msg}`),
21
+ warn: (msg) => console.warn(`${prefix} ${msg}`),
22
+ error: (msg, error) => {
23
+ if (error === undefined)
24
+ console.error(`${prefix} ${msg}`);
25
+ else
26
+ console.error(`${prefix} ${msg}`, error);
27
+ },
28
+ // debug rides the console's debug channel: visible when the
29
+ // environment enables it, silent otherwise.
30
+ debug: (msg) => console.debug(`${prefix} ${msg}`),
31
+ once: (key, level, msg) => {
32
+ const onceKey = `${prefix} ${key}`;
33
+ if (onceLogged.has(onceKey))
34
+ return;
35
+ onceLogged.add(onceKey);
36
+ if (level === "info")
37
+ console.info(`${prefix} ${msg}`);
38
+ else
39
+ console.warn(`${prefix} ${msg}`);
40
+ },
41
+ };
42
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The home-chat notifier (spec §8.8): core chooses the recipients; the
3
+ * engine turns each event into durable ledger entries for that user's
4
+ * home DMs. Handlers only enqueue — channel workers own platform I/O.
5
+ */
6
+ import type { NotifierContribution, SigrunHost } from "@sigrun/sigrun/extension";
7
+ import type { BindingStore } from "./state/bindings.js";
8
+ import type { IdentityStore } from "./state/identities.js";
9
+ import type { Ledger } from "./state/ledger.js";
10
+ import type { GatewayConfig } from "./config.js";
11
+ import type { EngineLog } from "./log.js";
12
+ export interface NotifyDeps {
13
+ readonly host: SigrunHost;
14
+ readonly bindings: BindingStore;
15
+ readonly identities: IdentityStore;
16
+ readonly ledger: Ledger;
17
+ readonly readConfig: () => Promise<GatewayConfig>;
18
+ readonly log: EngineLog;
19
+ }
20
+ /** Build the factory-result notifier over the engine's shared stores. */
21
+ export declare function createNotifier(deps: NotifyDeps): NotifierContribution;
package/dist/notify.js ADDED
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The home-chat notifier (spec §8.8): core chooses the recipients; the
3
+ * engine turns each event into durable ledger entries for that user's
4
+ * home DMs. Handlers only enqueue — channel workers own platform I/O.
5
+ */
6
+ /** Build the factory-result notifier over the engine's shared stores. */
7
+ export function createNotifier(deps) {
8
+ return {
9
+ name: "home",
10
+ notify: async (event) => {
11
+ try {
12
+ await notify(event, deps);
13
+ }
14
+ catch (error) {
15
+ // A notice must never break core's other notifier deliveries;
16
+ // the event is best-effort, but its loss is always visible.
17
+ deps.log.error(`home notification failed (${event.type})`, error);
18
+ }
19
+ },
20
+ };
21
+ }
22
+ async function notify(event, deps) {
23
+ // Task 9 owns chat presentation for interaction records.
24
+ if (event.type === "interaction.pending")
25
+ return;
26
+ if (event.user === undefined)
27
+ return;
28
+ const config = await deps.readConfig();
29
+ if (!config.notify.home)
30
+ return;
31
+ if (event.type === "run.settled") {
32
+ if (event.conversationId === undefined)
33
+ return;
34
+ // The settled fan-out owns bound conversations; a second notice
35
+ // here would duplicate the answer the chat just received. Only
36
+ // LIVE bindings suppress: a dead binding is skipped by the
37
+ // fan-out, so counting it would drop the answer with no log.
38
+ const live = deps.bindings.keysOf(event.conversationId)
39
+ .filter((key) => deps.bindings.get(key)?.dead !== true);
40
+ if (live.length > 0)
41
+ return;
42
+ const meta = await deps.host.conversations.meta(event.conversationId);
43
+ // New-conversation jobs are unbound, but their delivery and
44
+ // job.failed paths own notices. The settled outcome (Task 4) is
45
+ // structured on the event: only an ANSWERED run may read as a
46
+ // ready reply; a failed/aborted run is a truthful non-success
47
+ // notice carrying the run's own reason.
48
+ if (meta.jobId !== undefined)
49
+ return;
50
+ }
51
+ const text = event.type === "run.settled"
52
+ ? event.outcome.status === "answered"
53
+ ? `Reply ready — ${event.body}${event.url === undefined ? "" : `\n${event.url}`}`
54
+ : `Run ended without a reply — ${event.body}${event.outcome.reason === "" ? "" : ` (${event.outcome.reason})`}${event.url === undefined ? "" : `\n${event.url}`}`
55
+ : `${event.title}\n${event.body}${event.url === undefined ? "" : `\n${event.url}`}`;
56
+ for (const home of deps.identities.homesOf(event.user)) {
57
+ await deps.ledger.enqueue({
58
+ channel: home.channel,
59
+ chat: home.chat,
60
+ kind: "notice",
61
+ message: { chat: home.chat, text },
62
+ });
63
+ }
64
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * The per-channel delivery worker (spec §8.6): ONE in-flight send per
3
+ * channel, entries pulled from the ledger in enqueue order.
4
+ *
5
+ * Retry ladder (pinned): a `transient` failure retries at 1000, 5000
6
+ * and 30000 ms, then the entry is `failed`. A `permanent` failure
7
+ * fails at once. A `chat_gone` failure marks the binding dead
8
+ * (future sends to that chat skip; the chat's next inbound message
9
+ * clears it) and drops the entry. A send that exceeds the 60 s bound
10
+ * (spec's Review Focus 2 — an adapter call that never resolves)
11
+ * classifies TRANSIENT: the worker abandons the call, retries, and
12
+ * never wedges the channel's queue.
13
+ *
14
+ * Recovered entries (restart recovery flagged them) are resent with the
15
+ * leading "(resent after restart)" line — the platform may already
16
+ * hold the message, and the marker tells the human what happened.
17
+ *
18
+ * `drain(deadlineMs)` is the shutdown path: send until the ledger is
19
+ * empty or the deadline passes; retries are NOT waited out (a retry
20
+ * timer armed by drain settles after stop() — the entry stays pending
21
+ * and the next boot's recovery resends it).
22
+ */
23
+ import type { ChannelContribution, OutboundMessage } from "@sigrun/sigrun/extension";
24
+ import type { Ledger, LedgerEntry } from "../state/ledger.js";
25
+ import type { BindingStore } from "../state/bindings.js";
26
+ import { type EngineLog } from "../log.js";
27
+ /** The worker's injectable dependencies. */
28
+ export interface DeliveryDeps {
29
+ readonly bindings: BindingStore;
30
+ /** The send bound (a hung adapter call never wedges the queue). */
31
+ readonly timeoutMs: number;
32
+ /** The pinned retry ladder: delays[attempt] before retry N. */
33
+ readonly delays: readonly [number, number, number];
34
+ /** Schedule `fn` at `ms`; the return value cancels it. */
35
+ readonly setTimer: (fn: () => void, ms: number) => () => void;
36
+ readonly log: EngineLog;
37
+ /** Clock (the tests' virtual clock). Default: real. */
38
+ readonly now?: () => number;
39
+ /** Re-read file bytes at send time; ledger entries persist paths only. */
40
+ readonly resolveFiles?: (entry: LedgerEntry) => Promise<OutboundMessage["files"]>;
41
+ /** Terminal delivery outcomes for the channel's status counters. */
42
+ readonly counts?: {
43
+ delivered(): void;
44
+ failed(): void;
45
+ cancelled?(): void;
46
+ };
47
+ /** Persist a delivered interaction prompt's platform refs for edits/replies. */
48
+ readonly onInteractionReceipt?: (entry: LedgerEntry, receipt: {
49
+ readonly messageIds: readonly string[];
50
+ }) => void | Promise<void>;
51
+ }
52
+ /** The pinned resend marker for recovered entries. */
53
+ export declare const RESENT_MARKER = "(resent after restart)";
54
+ /**
55
+ * One channel's delivery worker. `kick()` starts the loop if idle (an
56
+ * enqueue calls it); `drain(deadlineMs)` runs sends until empty or the
57
+ * deadline; `stop()` cancels timers and joins the in-flight send. Not
58
+ * restartable after `stop()` (the engine stops once per boot).
59
+ */
60
+ export declare class DeliveryWorker {
61
+ private readonly channel;
62
+ private readonly ledger;
63
+ private readonly deps;
64
+ private readonly now;
65
+ private busy;
66
+ private stopped;
67
+ /** The in-flight send's settle handle (stop joins it). */
68
+ private inFlight;
69
+ /** The cancel for the armed retry timer. */
70
+ private cancelRetry;
71
+ constructor(channel: ChannelContribution, ledger: Ledger, deps: DeliveryDeps);
72
+ /** Start the loop if idle (the enqueue-side poke). A no-op while a
73
+ * drain owns the queue (the drain IS the loop until it resolves). */
74
+ kick(): void;
75
+ /**
76
+ * Send until the ledger's channel queue is empty or `deadlineMs`
77
+ * (absolute clock ms) passes. The drain TAKES OWNERSHIP of the
78
+ * queue: `busy` is set for its duration (a kick during a drain is a
79
+ * no-op), the armed retry timer is CANCELLED (the drain pulls the
80
+ * re-pended entry directly — no timer-driven second loop races
81
+ * it), and any in-flight loop is joined first. Retries are NOT
82
+ * waited out: a retry-armed entry parks the drain (the entry stays
83
+ * pending; the next boot's recovery resends it).
84
+ */
85
+ drain(deadlineMs: number): Promise<void>;
86
+ /** Cancel the retry timer and join the in-flight send. Idempotent. */
87
+ stop(): Promise<void>;
88
+ /**
89
+ * The steady-state loop: pull, send, repeat; a retry-armed state
90
+ * parks until the timer fires (kick re-arms after each timer hop).
91
+ */
92
+ private runLoop;
93
+ /**
94
+ * Send one entry: attempting → send → delivered (receipt) / the
95
+ * error-classified failure path. Returns false when the entry needs
96
+ * a retry wait (the loop parks until the retry timer re-kicks) —
97
+ * enqueue ORDER holds: no later entry overtakes a retrying one.
98
+ * Never throws.
99
+ */
100
+ private sendOne;
101
+ /**
102
+ * The platform call, abandoned at `timeoutMs`: a send that exceeds
103
+ * the bound classifies TRANSIENT (the worker retries, never wedges).
104
+ */
105
+ private boundedSend;
106
+ /** Apply an edit entry; its ledger row carries the target MessageRef. */
107
+ private boundedEdit;
108
+ /**
109
+ * The classified failure path: transient retries (the ladder),
110
+ * permanent fails at once, chat_gone marks the binding dead.
111
+ * Returns false when a retry wait was armed (the caller parks).
112
+ */
113
+ private onSendError;
114
+ /**
115
+ * A ledger mark with its own error isolation (never throws). Returns
116
+ * whether the transition APPLIED: a refusal means the entry left
117
+ * `pending` behind our back (a settlement cancelled it), so the caller
118
+ * must skip its failure/retry side effects rather than count a failure
119
+ * for a cancelled entry or arm a retry that re-pulls it.
120
+ */
121
+ private markLedger;
122
+ /**
123
+ * The classification read (adapter's `classifyError` when present,
124
+ * transient otherwise) with the timeout error forced transient.
125
+ */
126
+ private classify;
127
+ /** The send-shaped message for one entry (the recovered marker rule). */
128
+ private buildMessage;
129
+ }
130
+ /** The timeout error's name (matched by `classify`, never instanceof). */
131
+ export declare const SEND_TIMEOUT_NAME = "GatewaySendTimeout";
@@ -0,0 +1,404 @@
1
+ /**
2
+ * The per-channel delivery worker (spec §8.6): ONE in-flight send per
3
+ * channel, entries pulled from the ledger in enqueue order.
4
+ *
5
+ * Retry ladder (pinned): a `transient` failure retries at 1000, 5000
6
+ * and 30000 ms, then the entry is `failed`. A `permanent` failure
7
+ * fails at once. A `chat_gone` failure marks the binding dead
8
+ * (future sends to that chat skip; the chat's next inbound message
9
+ * clears it) and drops the entry. A send that exceeds the 60 s bound
10
+ * (spec's Review Focus 2 — an adapter call that never resolves)
11
+ * classifies TRANSIENT: the worker abandons the call, retries, and
12
+ * never wedges the channel's queue.
13
+ *
14
+ * Recovered entries (restart recovery flagged them) are resent with the
15
+ * leading "(resent after restart)" line — the platform may already
16
+ * hold the message, and the marker tells the human what happened.
17
+ *
18
+ * `drain(deadlineMs)` is the shutdown path: send until the ledger is
19
+ * empty or the deadline passes; retries are NOT waited out (a retry
20
+ * timer armed by drain settles after stop() — the entry stays pending
21
+ * and the next boot's recovery resends it).
22
+ */
23
+ import { hashId } from "../log.js";
24
+ /** The pinned resend marker for recovered entries. */
25
+ export const RESENT_MARKER = "(resent after restart)";
26
+ /**
27
+ * One channel's delivery worker. `kick()` starts the loop if idle (an
28
+ * enqueue calls it); `drain(deadlineMs)` runs sends until empty or the
29
+ * deadline; `stop()` cancels timers and joins the in-flight send. Not
30
+ * restartable after `stop()` (the engine stops once per boot).
31
+ */
32
+ export class DeliveryWorker {
33
+ channel;
34
+ ledger;
35
+ deps;
36
+ now;
37
+ busy = false;
38
+ stopped = false;
39
+ /** The in-flight send's settle handle (stop joins it). */
40
+ inFlight;
41
+ /** The cancel for the armed retry timer. */
42
+ cancelRetry;
43
+ constructor(channel, ledger, deps) {
44
+ this.channel = channel;
45
+ this.ledger = ledger;
46
+ this.deps = deps;
47
+ this.now = deps.now ?? Date.now;
48
+ }
49
+ /** Start the loop if idle (the enqueue-side poke). A no-op while a
50
+ * drain owns the queue (the drain IS the loop until it resolves). */
51
+ kick() {
52
+ if (this.stopped || this.busy)
53
+ return;
54
+ const loop = this.runLoop();
55
+ this.inFlight = loop.catch((error) => {
56
+ // A loop failure is engine-side (the ledger or the adapter
57
+ // wrapper): logged loud, never silent — the channel's queue
58
+ // stays honest (the next kick retries).
59
+ this.deps.log.error(`delivery: loop failed (channel "${this.channel.id}")`, error);
60
+ });
61
+ void loop;
62
+ }
63
+ /**
64
+ * Send until the ledger's channel queue is empty or `deadlineMs`
65
+ * (absolute clock ms) passes. The drain TAKES OWNERSHIP of the
66
+ * queue: `busy` is set for its duration (a kick during a drain is a
67
+ * no-op), the armed retry timer is CANCELLED (the drain pulls the
68
+ * re-pended entry directly — no timer-driven second loop races
69
+ * it), and any in-flight loop is joined first. Retries are NOT
70
+ * waited out: a retry-armed entry parks the drain (the entry stays
71
+ * pending; the next boot's recovery resends it).
72
+ */
73
+ async drain(deadlineMs) {
74
+ if (this.stopped)
75
+ return;
76
+ // Cancel the pending retry: the drain owns the queue now.
77
+ this.cancelRetry?.();
78
+ this.cancelRetry = undefined;
79
+ // Join any in-flight loop before taking the busy flag.
80
+ await this.inFlight;
81
+ if (this.stopped)
82
+ return;
83
+ if (this.busy) {
84
+ // A concurrent drain (the engine's stop is idempotent, but a
85
+ // caller may race): join it rather than run a second loop.
86
+ await this.inFlight;
87
+ return;
88
+ }
89
+ this.busy = true;
90
+ try {
91
+ for (;;) {
92
+ if (this.stopped)
93
+ return;
94
+ if (this.now() >= deadlineMs)
95
+ return;
96
+ const entry = this.ledger.nextPending(this.channel.id);
97
+ if (entry === undefined)
98
+ return;
99
+ const progressed = await this.sendOne(entry);
100
+ if (!progressed)
101
+ return;
102
+ }
103
+ }
104
+ finally {
105
+ this.busy = false;
106
+ }
107
+ }
108
+ /** Cancel the retry timer and join the in-flight send. Idempotent. */
109
+ async stop() {
110
+ if (this.stopped)
111
+ return;
112
+ this.stopped = true;
113
+ this.cancelRetry?.();
114
+ this.cancelRetry = undefined;
115
+ const flight = this.inFlight;
116
+ if (flight !== undefined)
117
+ await flight.catch(() => { });
118
+ }
119
+ /**
120
+ * The steady-state loop: pull, send, repeat; a retry-armed state
121
+ * parks until the timer fires (kick re-arms after each timer hop).
122
+ */
123
+ async runLoop() {
124
+ if (this.busy)
125
+ return;
126
+ this.busy = true;
127
+ try {
128
+ for (;;) {
129
+ if (this.stopped)
130
+ return;
131
+ const entry = this.ledger.nextPending(this.channel.id);
132
+ if (entry === undefined)
133
+ return;
134
+ const progressed = await this.sendOne(entry);
135
+ if (!progressed) {
136
+ // A retry is armed (or the entry is terminal-failed): the loop
137
+ // parks — the retry timer's kick() restarts it. Enqueue order
138
+ // holds (no later entry overtakes a retrying one).
139
+ return;
140
+ }
141
+ }
142
+ }
143
+ finally {
144
+ this.busy = false;
145
+ }
146
+ }
147
+ /**
148
+ * Send one entry: attempting → send → delivered (receipt) / the
149
+ * error-classified failure path. Returns false when the entry needs
150
+ * a retry wait (the loop parks until the retry timer re-kicks) —
151
+ * enqueue ORDER holds: no later entry overtakes a retrying one.
152
+ * Never throws.
153
+ */
154
+ async sendOne(entry) {
155
+ try {
156
+ // Resolution belongs inside the classified boundary: a deleted
157
+ // or unreadable file must fail this entry, never wedge the queue.
158
+ const message = await this.buildMessage(entry);
159
+ // The attempting mark rides the same isolation as the rest: a
160
+ // failing ledger write is LOGGED and the send does not run (an
161
+ // unmarked entry must not be sent — the crash window would
162
+ // duplicate it on recovery).
163
+ // A refused claim means the entry is no longer pending (a
164
+ // settlement cancelled it between the loop's read and here).
165
+ // Sending it anyway would deliver a live prompt for a decided
166
+ // interaction, so stop: the cancellation is the barrier.
167
+ if (!(await this.ledger.mark(entry.id, "attempting"))) {
168
+ this.deps.counts?.cancelled?.();
169
+ return true;
170
+ }
171
+ const receipt = entry.kind === "edit"
172
+ ? await this.boundedEdit(entry, message)
173
+ : await this.boundedSend(message);
174
+ await this.ledger.mark(entry.id, "delivered", receipt.messageIds);
175
+ if (entry.kind === "interaction") {
176
+ await this.deps.onInteractionReceipt?.(entry, receipt);
177
+ }
178
+ this.deps.counts?.delivered();
179
+ return true;
180
+ }
181
+ catch (error) {
182
+ return await this.onSendError(entry, error);
183
+ }
184
+ }
185
+ /**
186
+ * The platform call, abandoned at `timeoutMs`: a send that exceeds
187
+ * the bound classifies TRANSIENT (the worker retries, never wedges).
188
+ */
189
+ async boundedSend(message) {
190
+ return await new Promise((resolve, reject) => {
191
+ let settled = false;
192
+ const timer = this.deps.setTimer(() => {
193
+ if (settled)
194
+ return;
195
+ settled = true;
196
+ reject(timeoutError(this.deps.timeoutMs));
197
+ }, this.deps.timeoutMs);
198
+ this.channel
199
+ .send(message)
200
+ .then((receipt) => {
201
+ if (settled)
202
+ return;
203
+ settled = true;
204
+ timer();
205
+ resolve(receipt);
206
+ }, (error) => {
207
+ if (settled)
208
+ return;
209
+ settled = true;
210
+ timer();
211
+ reject(error);
212
+ })
213
+ .catch((error) => {
214
+ // The .then handlers never throw; the catch is the
215
+ // belt-and-braces shape for a misbehaving adapter.
216
+ if (settled)
217
+ return;
218
+ settled = true;
219
+ timer();
220
+ reject(error);
221
+ });
222
+ });
223
+ }
224
+ /** Apply an edit entry; its ledger row carries the target MessageRef. */
225
+ async boundedEdit(entry, message) {
226
+ if (entry.ref === undefined || this.channel.edit === undefined) {
227
+ const error = new Error("gateway: edit delivery requires a ref and adapter edit()");
228
+ error.name = EDIT_PRECONDITION_ERROR;
229
+ throw error;
230
+ }
231
+ await new Promise((resolve, reject) => {
232
+ let settled = false;
233
+ const timer = this.deps.setTimer(() => {
234
+ if (settled)
235
+ return;
236
+ settled = true;
237
+ reject(timeoutError(this.deps.timeoutMs));
238
+ }, this.deps.timeoutMs);
239
+ this.channel.edit(entry.ref, message).then(() => {
240
+ if (settled)
241
+ return;
242
+ settled = true;
243
+ timer();
244
+ resolve();
245
+ }, (error) => {
246
+ if (settled)
247
+ return;
248
+ settled = true;
249
+ timer();
250
+ reject(error);
251
+ });
252
+ });
253
+ return { messageIds: [entry.ref.messageId] };
254
+ }
255
+ /**
256
+ * The classified failure path: transient retries (the ladder),
257
+ * permanent fails at once, chat_gone marks the binding dead.
258
+ * Returns false when a retry wait was armed (the caller parks).
259
+ */
260
+ async onSendError(entry, error) {
261
+ const kind = this.classify(error);
262
+ const detail = error instanceof Error ? error.message : String(error);
263
+ // A ledger-mark failure arrives here too (sendOne's try wraps the
264
+ // attempting mark): it is NOT a send failure — a transient retry of
265
+ // an unmarked entry would double-send after recovery. Mark errors
266
+ // log loud and park the entry as-is (the next boot's recovery owns
267
+ // the resend).
268
+ if (kind === "chat_gone") {
269
+ if (entry.bindingKey !== undefined) {
270
+ try {
271
+ await this.deps.bindings.markDead(entry.bindingKey);
272
+ }
273
+ catch (markError) {
274
+ this.deps.log.error(`delivery: markDead failed for binding "${hashId(entry.bindingKey)}"`, markError);
275
+ this.deps.log.debug(`delivery: markDead failed for binding "${entry.bindingKey}"`);
276
+ }
277
+ }
278
+ this.deps.log.warn(`delivery: chat gone for "${entry.channel}:${hashId(entry.chat.chatId)}" — binding marked dead`);
279
+ this.deps.log.debug(`delivery: chat gone for "${entry.channel}:${entry.chat.chatId}" — binding marked dead`);
280
+ if (await this.markLedger(entry.id, "failed"))
281
+ this.deps.counts?.failed();
282
+ return true;
283
+ }
284
+ if (kind === "permanent") {
285
+ const fileDetail = error instanceof Error && error.name === FILE_RESOLUTION_ERROR
286
+ ? ` (${error.message})`
287
+ : "";
288
+ this.deps.log.error(`delivery: permanent failure ("${entry.kind}" to "${hashId(entry.chat.chatId)}")${fileDetail}`, error);
289
+ this.deps.log.debug(`delivery: permanent failure ("${entry.kind}" to "${entry.chat.chatId}")${fileDetail}`);
290
+ if (await this.markLedger(entry.id, "failed"))
291
+ this.deps.counts?.failed();
292
+ return true;
293
+ }
294
+ // Transient: the retry ladder. attempts was already incremented by
295
+ // the attempting mark; a send is allowed delays.length + 1 tries
296
+ // total (the initial attempt plus one per delay).
297
+ const retriesUsed = entry.attempts;
298
+ if (retriesUsed >= this.deps.delays.length) {
299
+ this.deps.log.error(`delivery: retries exhausted ("${entry.kind}" to "${hashId(entry.chat.chatId)}"): ${detail}`);
300
+ this.deps.log.debug(`delivery: retries exhausted ("${entry.kind}" to "${entry.chat.chatId}"): ${detail}`);
301
+ if (await this.markLedger(entry.id, "failed"))
302
+ this.deps.counts?.failed();
303
+ return true;
304
+ }
305
+ const delay = this.deps.delays[retriesUsed] ?? 0;
306
+ this.deps.log.warn(`delivery: transient failure (attempt ${retriesUsed + 1}, retry in ${delay} ms): ${detail}`);
307
+ // The entry returns to pending (the retry re-pulls it); the timer
308
+ // re-kicks the loop. A stop cancels the timer — the entry stays
309
+ // pending for the next boot's recovery.
310
+ // A refused re-arm means the entry was cancelled while we were
311
+ // classifying the failure: do not arm a retry that would re-pull it.
312
+ if (!(await this.markLedger(entry.id, "pending")))
313
+ return true;
314
+ if (this.stopped)
315
+ return false;
316
+ this.cancelRetry?.();
317
+ this.cancelRetry = this.deps.setTimer(() => {
318
+ this.cancelRetry = undefined;
319
+ this.kick();
320
+ }, delay);
321
+ return false;
322
+ }
323
+ /**
324
+ * A ledger mark with its own error isolation (never throws). Returns
325
+ * whether the transition APPLIED: a refusal means the entry left
326
+ * `pending` behind our back (a settlement cancelled it), so the caller
327
+ * must skip its failure/retry side effects rather than count a failure
328
+ * for a cancelled entry or arm a retry that re-pulls it.
329
+ */
330
+ async markLedger(id, state) {
331
+ try {
332
+ return await this.ledger.mark(id, state);
333
+ }
334
+ catch (markError) {
335
+ this.deps.log.error("delivery: ledger mark failed", markError);
336
+ return false;
337
+ }
338
+ }
339
+ /**
340
+ * The classification read (adapter's `classifyError` when present,
341
+ * transient otherwise) with the timeout error forced transient.
342
+ */
343
+ classify(error) {
344
+ if (error instanceof Error && (error.name === FILE_RESOLUTION_ERROR || error.name === EDIT_PRECONDITION_ERROR)) {
345
+ return "permanent";
346
+ }
347
+ if (error instanceof Error &&
348
+ error.name === "GatewaySendTimeout") {
349
+ // Review Focus 2: a send over the bound is TRANSIENT — never
350
+ // permanent, never chat_gone.
351
+ return "transient";
352
+ }
353
+ if (this.channel.classifyError !== undefined) {
354
+ return this.channel.classifyError(error);
355
+ }
356
+ return "transient";
357
+ }
358
+ /** The send-shaped message for one entry (the recovered marker rule). */
359
+ async buildMessage(entry) {
360
+ // An edit replaces its target; adding a resend prefix would corrupt
361
+ // the already-final outcome text (the marker belongs on fresh sends).
362
+ const text = entry.recovered === true && entry.kind !== "edit"
363
+ ? `${RESENT_MARKER}\n${entry.message.text}`
364
+ : entry.message.text;
365
+ let files;
366
+ if (entry.message.files !== undefined && this.deps.resolveFiles !== undefined) {
367
+ try {
368
+ files = await this.deps.resolveFiles(entry);
369
+ }
370
+ catch (error) {
371
+ const paths = entry.message.files.map((file) => file.path).join(", ");
372
+ this.deps.log.warn(`delivery: file resolution failed for ${paths}`);
373
+ const failure = new Error(`file resolution failed for ${paths}`, {
374
+ cause: error,
375
+ });
376
+ failure.name = FILE_RESOLUTION_ERROR;
377
+ throw failure;
378
+ }
379
+ }
380
+ return {
381
+ chat: entry.message.chat,
382
+ text,
383
+ ...(entry.message.replyTo === undefined
384
+ ? {}
385
+ : { replyTo: entry.message.replyTo }),
386
+ ...(entry.message.buttons === undefined
387
+ ? {}
388
+ : { buttons: entry.message.buttons }),
389
+ ...(files === undefined ? {} : { files }),
390
+ };
391
+ }
392
+ }
393
+ /** A workspace file no longer exists/readable; retrying cannot repair it. */
394
+ const FILE_RESOLUTION_ERROR = "GatewayFileResolutionError";
395
+ /** A structurally undeliverable edit can never succeed on retry. */
396
+ const EDIT_PRECONDITION_ERROR = "GatewayEditPreconditionError";
397
+ /** The timeout error's name (matched by `classify`, never instanceof). */
398
+ export const SEND_TIMEOUT_NAME = "GatewaySendTimeout";
399
+ /** Build the timeout error. */
400
+ function timeoutError(ms) {
401
+ const error = new Error(`gateway: send exceeded ${ms} ms`);
402
+ error.name = SEND_TIMEOUT_NAME;
403
+ return error;
404
+ }