@sigrun/ext-gateway 0.0.0-stage → 0.1.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.
Files changed (82) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/LICENSE +21 -0
  3. package/README.md +218 -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 +111 -0
  38. package/dist/interactions/chat.d.ts +20 -0
  39. package/dist/interactions/chat.js +155 -0
  40. package/dist/interactions/prompts.d.ts +42 -0
  41. package/dist/interactions/prompts.js +96 -0
  42. package/dist/interactions/receipts.d.ts +6 -0
  43. package/dist/interactions/receipts.js +36 -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 +57 -0
  52. package/dist/outbound/delivery.d.ts +124 -0
  53. package/dist/outbound/delivery.js +387 -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 +80 -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 +8 -0
  65. package/dist/session-meta.js +21 -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 +61 -0
  69. package/dist/state/identities.js +131 -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 +152 -0
  73. package/dist/state/ledger.js +207 -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
@@ -0,0 +1,152 @@
1
+ /**
2
+ * The durable delivery ledger (spec §8.6): every outbound send's state
3
+ * machine — `pending` → `attempting` → `delivered` (receipt) or
4
+ * `failed` — persisted at `<stateDir>/gateway/ledger.json` through
5
+ * {@link JsonFile} (0600, atomic, serialized updates).
6
+ *
7
+ * File bytes are NEVER ledgered: an entry whose message carries `files`
8
+ * stores a WORKSPACE PATH + conversation id instead, and the delivery
9
+ * worker re-reads the bytes through `host.conversations.readFile` at
10
+ * send time (the brief's rule — `files` bytes would bloat the ledger
11
+ * and pin upload bytes into engine state).
12
+ *
13
+ * Restart recovery: `pending` entries are sent as-is; `attempting`
14
+ * entries become `pending` with the `recovered` flag (the platform may
15
+ * already hold them — the worker prefixes the marker). Tag dedupe:
16
+ * enqueueing a tag that already exists returns undefined (the
17
+ * re-delivery no-op).
18
+ */
19
+ import type { ChatRef, MessageRef, OutboundMessage, SigrunHost } from "@sigrun/sigrun/extension";
20
+ import type { EngineLog } from "../log.js";
21
+ /** The persisted (JSON-safe) outbound message: `files` become a path. */
22
+ export interface SerializedOutbound {
23
+ readonly chat: ChatRef;
24
+ readonly text: string;
25
+ readonly replyTo?: string;
26
+ readonly buttons?: OutboundMessage["buttons"];
27
+ /**
28
+ * The workspace path + conversation id of each file (the send-time
29
+ * re-read's `readFile` arguments) — never the bytes.
30
+ */
31
+ readonly files?: readonly {
32
+ readonly conversationId: number;
33
+ readonly path: string;
34
+ readonly name: string;
35
+ readonly mime?: string;
36
+ }[];
37
+ }
38
+ /** One ledger row's lifecycle. */
39
+ export type LedgerState = "pending" | "attempting" | "delivered" | "failed";
40
+ /** What the entry is for (the worker's send-shape discrimination). */
41
+ export type LedgerKind = "final" | "notice" | "interaction" | "edit";
42
+ /**
43
+ * One ledger entry. `tag` is the dedupe key (e.g.
44
+ * `final:<conversationId>:<answerEntryId>:<bindingKey>`) — a replayed
45
+ * settle (the platform redelivered, a restart re-ran a handler)
46
+ * enqueues nothing.
47
+ */
48
+ export interface LedgerEntry {
49
+ readonly id: string;
50
+ /** The channel id (the per-channel worker's queue key). */
51
+ readonly channel: string;
52
+ /** The binding key the entry rides (dead-marking on chat_gone). */
53
+ readonly bindingKey?: string;
54
+ readonly chat: ChatRef;
55
+ readonly kind: LedgerKind;
56
+ readonly message: SerializedOutbound;
57
+ /** The edit target (kind "edit" only). */
58
+ readonly ref?: MessageRef;
59
+ readonly state: LedgerState;
60
+ readonly attempts: number;
61
+ readonly createdAt: number;
62
+ /** When the entry entered its CURRENT state (clock ms) — the failure
63
+ * window reads THIS, never `createdAt`: an old entry failing NOW is
64
+ * a fresh health signal, not yesterday's. Absent on entries written
65
+ * before the field existed (created-at is the fallback). */
66
+ readonly stateSince?: number;
67
+ /** Platform message ids of the delivered send. */
68
+ readonly receipt?: string[];
69
+ readonly tag?: string;
70
+ /** Set by restart recovery: the platform may already hold this entry. */
71
+ readonly recovered?: boolean;
72
+ }
73
+ /**
74
+ * Enqueue's input: everything the caller owns (id, state, attempts,
75
+ * createdAt, recovered are the ledger's to stamp).
76
+ */
77
+ export type LedgerInput = Omit<LedgerEntry, "id" | "state" | "attempts" | "createdAt" | "recovered" | "message"> & {
78
+ /** The full (bytes-bearing) message — the ledger serializes it. */
79
+ readonly message: OutboundMessage;
80
+ /**
81
+ * The workspace file records backing the message's files: the
82
+ * conversation id + workspace path the worker re-reads at send time
83
+ * through `resolveFiles`. Absent when the entry has no files.
84
+ */
85
+ readonly files?: readonly {
86
+ readonly conversationId: number;
87
+ readonly path: string;
88
+ readonly name: string;
89
+ readonly mime?: string;
90
+ }[];
91
+ };
92
+ /** The ledger's injectable options (tests inject a fake clock). */
93
+ export interface LedgerOptions {
94
+ /** Clock ms for createdAt/prune decisions. Default: real. */
95
+ readonly now?: () => number;
96
+ readonly log?: EngineLog;
97
+ }
98
+ /** Delivered-entry retention (spec §8.6: pruned after 7 days). */
99
+ export declare const PRUNE_AFTER_MS = 604800000;
100
+ /**
101
+ * The delivery ledger, one instance per engine. Reads
102
+ * (`nextPending`/`failedSince`) serve the in-memory mirror loaded at
103
+ * construction and refreshed inside every queued mutation — the
104
+ * synchronous read contract the delivery worker's loop needs (its
105
+ * `nextPending` call cannot await inside its tick).
106
+ */
107
+ export declare class Ledger {
108
+ private readonly file;
109
+ private readonly now;
110
+ private readonly log;
111
+ private entries;
112
+ constructor(path: string, opts?: LedgerOptions);
113
+ /** A snapshot of the entries (dedupe and status reads). */
114
+ list(): readonly LedgerEntry[];
115
+ /**
116
+ * Enqueue one entry (state `pending`, stamped id/createdAt). Returns
117
+ * undefined when `tag` is already present (the dedupe no-op) — the
118
+ * caller logs nothing; the replay was harmless.
119
+ */
120
+ enqueue(entry: LedgerInput): Promise<LedgerEntry | undefined>;
121
+ /**
122
+ * The oldest pending entry for `channel`, or undefined. Synchronous
123
+ * (the worker's loop is a tick, not an await point).
124
+ */
125
+ nextPending(channel: string): LedgerEntry | undefined;
126
+ /** Transition one entry's state (attempting/delivered/failed). */
127
+ mark(id: string, state: LedgerState, receipt?: readonly string[]): Promise<void>;
128
+ /**
129
+ * Restart recovery: every `attempting` entry becomes `pending` with
130
+ * the `recovered` flag (the worker prefixes the marker at send time).
131
+ * `pending` and terminal entries are untouched.
132
+ */
133
+ recoverAfterRestart(): Promise<void>;
134
+ /** How many entries are `failed` having FAILED within the window —
135
+ * the state transition's clock, not creation: an old entry failing
136
+ * NOW still degrades health. */
137
+ failedSince(ts: number): number;
138
+ /**
139
+ * Prune DELIVERED entries older than `PRUNE_AFTER_MS` (7 days), except
140
+ * job tags: those are the permanent one-shot delivery record, so age
141
+ * must never re-enable delivery. Failed and pending entries are never
142
+ * pruned — a failed entry is the health signal (§8.6: "nothing is
143
+ * dropped silently"), a pending entry is undelivered work.
144
+ */
145
+ prune(now: number): Promise<void>;
146
+ /**
147
+ * Re-read one entry's file bytes at send time (the `files` path →
148
+ * `readFile` rule). Returns the send-shaped files array, or undefined
149
+ * when the entry has none.
150
+ */
151
+ resolveFiles(host: SigrunHost, entry: LedgerEntry): Promise<OutboundMessage["files"] | undefined>;
152
+ }
@@ -0,0 +1,207 @@
1
+ /**
2
+ * The durable delivery ledger (spec §8.6): every outbound send's state
3
+ * machine — `pending` → `attempting` → `delivered` (receipt) or
4
+ * `failed` — persisted at `<stateDir>/gateway/ledger.json` through
5
+ * {@link JsonFile} (0600, atomic, serialized updates).
6
+ *
7
+ * File bytes are NEVER ledgered: an entry whose message carries `files`
8
+ * stores a WORKSPACE PATH + conversation id instead, and the delivery
9
+ * worker re-reads the bytes through `host.conversations.readFile` at
10
+ * send time (the brief's rule — `files` bytes would bloat the ledger
11
+ * and pin upload bytes into engine state).
12
+ *
13
+ * Restart recovery: `pending` entries are sent as-is; `attempting`
14
+ * entries become `pending` with the `recovered` flag (the platform may
15
+ * already hold them — the worker prefixes the marker). Tag dedupe:
16
+ * enqueueing a tag that already exists returns undefined (the
17
+ * re-delivery no-op).
18
+ */
19
+ import { randomUUID } from "node:crypto";
20
+ import { JsonFile } from "./json-file.js";
21
+ /** Delivered-entry retention (spec §8.6: pruned after 7 days). */
22
+ export const PRUNE_AFTER_MS = 604_800_000;
23
+ /**
24
+ * The delivery ledger, one instance per engine. Reads
25
+ * (`nextPending`/`failedSince`) serve the in-memory mirror loaded at
26
+ * construction and refreshed inside every queued mutation — the
27
+ * synchronous read contract the delivery worker's loop needs (its
28
+ * `nextPending` call cannot await inside its tick).
29
+ */
30
+ export class Ledger {
31
+ file;
32
+ now;
33
+ log;
34
+ entries = [];
35
+ constructor(path, opts = {}) {
36
+ this.file = new JsonFile(path, () => ({ entries: [] }));
37
+ this.now = opts.now ?? Date.now;
38
+ this.log = opts.log;
39
+ // The mirror loads SYNCHRONOUSLY at construction (readSync): the
40
+ // queue is empty here, so nothing can interleave; a second instance
41
+ // over the same file (the restart path) observes the persisted
42
+ // entries from its first read.
43
+ this.entries = this.file.readSync().entries;
44
+ }
45
+ /** A snapshot of the entries (dedupe and status reads). */
46
+ list() {
47
+ return [...this.entries];
48
+ }
49
+ /**
50
+ * Enqueue one entry (state `pending`, stamped id/createdAt). Returns
51
+ * undefined when `tag` is already present (the dedupe no-op) — the
52
+ * caller logs nothing; the replay was harmless.
53
+ */
54
+ async enqueue(entry) {
55
+ const serialized = serializeMessage(entry.message, entry.files);
56
+ const { message: _message, files: _files, ...rest } = entry;
57
+ const stored = {
58
+ ...rest,
59
+ message: serialized,
60
+ id: randomUUID(),
61
+ state: "pending",
62
+ attempts: 0,
63
+ createdAt: this.now(),
64
+ stateSince: this.now(),
65
+ };
66
+ const state = await this.file.update((draft) => {
67
+ if (entry.tag !== undefined &&
68
+ draft.entries.some((row) => row.tag === entry.tag)) {
69
+ return;
70
+ }
71
+ draft.entries.push(stored);
72
+ this.entries = draft.entries;
73
+ });
74
+ // The update's draft mutates in place; the tag-dedupe path left the
75
+ // entry out (detect by id presence in the written state).
76
+ const written = state.entries.some((row) => row.id === stored.id);
77
+ return written ? stored : undefined;
78
+ }
79
+ /**
80
+ * The oldest pending entry for `channel`, or undefined. Synchronous
81
+ * (the worker's loop is a tick, not an await point).
82
+ */
83
+ nextPending(channel) {
84
+ return this.entries.find((entry) => entry.channel === channel && entry.state === "pending");
85
+ }
86
+ /** Transition one entry's state (attempting/delivered/failed). */
87
+ async mark(id, state, receipt) {
88
+ await this.file.update((draft) => {
89
+ const index = draft.entries.findIndex((row) => row.id === id);
90
+ if (index === -1)
91
+ return;
92
+ const row = draft.entries[index];
93
+ const next = {
94
+ ...row,
95
+ state,
96
+ // An attempt transition counts the attempt; delivered/failed
97
+ // keep the count the attempts produced.
98
+ attempts: state === "attempting" ? row.attempts + 1 : row.attempts,
99
+ // The state transition stamps its own clock (the failure
100
+ // window reads it — an old entry failing NOW is fresh).
101
+ stateSince: this.now(),
102
+ ...(receipt === undefined ? {} : { receipt: [...receipt] }),
103
+ };
104
+ draft.entries[index] = next;
105
+ this.entries = draft.entries;
106
+ });
107
+ }
108
+ /**
109
+ * Restart recovery: every `attempting` entry becomes `pending` with
110
+ * the `recovered` flag (the worker prefixes the marker at send time).
111
+ * `pending` and terminal entries are untouched.
112
+ */
113
+ async recoverAfterRestart() {
114
+ const recovered = await this.file.update((draft) => {
115
+ for (let index = 0; index < draft.entries.length; index++) {
116
+ const row = draft.entries[index];
117
+ if (row.state !== "attempting")
118
+ continue;
119
+ draft.entries[index] = { ...row, state: "pending", recovered: true };
120
+ }
121
+ return;
122
+ });
123
+ if (recovered.entries.some((row) => row.recovered === true)) {
124
+ this.log?.info("ledger: recovered attempting entries after restart");
125
+ }
126
+ this.entries = recovered.entries;
127
+ }
128
+ /** How many entries are `failed` having FAILED within the window —
129
+ * the state transition's clock, not creation: an old entry failing
130
+ * NOW still degrades health. */
131
+ failedSince(ts) {
132
+ return this.entries.filter((entry) => entry.state === "failed" &&
133
+ (entry.stateSince ?? entry.createdAt) >= ts).length;
134
+ }
135
+ /**
136
+ * Prune DELIVERED entries older than `PRUNE_AFTER_MS` (7 days), except
137
+ * job tags: those are the permanent one-shot delivery record, so age
138
+ * must never re-enable delivery. Failed and pending entries are never
139
+ * pruned — a failed entry is the health signal (§8.6: "nothing is
140
+ * dropped silently"), a pending entry is undelivered work.
141
+ */
142
+ async prune(now) {
143
+ const pruned = await this.file.update((draft) => {
144
+ const before = draft.entries.length;
145
+ const kept = draft.entries.filter((entry) => entry.state !== "delivered" ||
146
+ entry.tag?.startsWith("job:") === true ||
147
+ now - entry.createdAt < PRUNE_AFTER_MS);
148
+ // In-place array replacement keeps the draft object identity.
149
+ draft.entries.length = 0;
150
+ draft.entries.push(...kept);
151
+ if (kept.length !== before) {
152
+ this.log?.debug(`ledger: pruned ${before - kept.length} delivered entries`);
153
+ }
154
+ });
155
+ this.entries = pruned.entries;
156
+ }
157
+ /**
158
+ * Re-read one entry's file bytes at send time (the `files` path →
159
+ * `readFile` rule). Returns the send-shaped files array, or undefined
160
+ * when the entry has none.
161
+ */
162
+ async resolveFiles(host, entry) {
163
+ const specs = entry.message.files;
164
+ if (specs === undefined || specs.length === 0)
165
+ return undefined;
166
+ const files = [];
167
+ for (const spec of specs) {
168
+ const read = await host.conversations.readFile(spec.conversationId, spec.path);
169
+ files.push({
170
+ name: spec.name,
171
+ ...(spec.mime === undefined ? {} : { mime: spec.mime }),
172
+ bytes: read.bytes,
173
+ });
174
+ }
175
+ return files;
176
+ }
177
+ }
178
+ /**
179
+ * Strip the bytes out of an outbound message for persistence: the
180
+ * `files` array is replaced by the workspace-path records the worker
181
+ * re-reads through `resolveFiles`. A message carrying bytes WITHOUT a
182
+ * file spec (the caller sent ephemeral buffers) drops them — the ledger
183
+ * never persists bytes, and the send would need the path spec anyway.
184
+ */
185
+ function serializeMessage(message, files) {
186
+ if (files === undefined || files.length === 0) {
187
+ return { chat: message.chat, text: message.text, ...pickOptional(message) };
188
+ }
189
+ return {
190
+ chat: message.chat,
191
+ text: message.text,
192
+ ...pickOptional(message),
193
+ files: files.map((file) => ({
194
+ conversationId: file.conversationId,
195
+ path: file.path,
196
+ name: file.name,
197
+ ...(file.mime === undefined ? {} : { mime: file.mime }),
198
+ })),
199
+ };
200
+ }
201
+ /** The optional outbound fields, copied when present. */
202
+ function pickOptional(message) {
203
+ return {
204
+ ...(message.replyTo === undefined ? {} : { replyTo: message.replyTo }),
205
+ ...(message.buttons === undefined ? {} : { buttons: message.buttons }),
206
+ };
207
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The seen set (spec §8.4 step 2): the 24-hour message dedupe, persisted
3
+ * at `<stateDir>/gateway/seen.json`. IN-FLIGHT-AWARE: a claimed id
4
+ * stays unclaimable until its admission settles (commit on success,
5
+ * release on engine failure), so a platform webhook retry racing the
6
+ * first `receive` resolves `"dropped"` — never a double submission
7
+ * (Review Focus 1). Entries older than 86 400 000 ms are dropped on
8
+ * every write (the window sweep rides the write; no timers).
9
+ */
10
+ /**
11
+ * The dedupe set, one instance per engine. `claim`/`release` are
12
+ * synchronous (the in-flight set is memory-only); `commit` persists.
13
+ * The window prune runs inside every `commit`'s queued write, so the
14
+ * file never grows past a day of ids even on a long-lived engine.
15
+ */
16
+ export declare class SeenSet {
17
+ private readonly file;
18
+ private readonly inFlight;
19
+ constructor(path: string);
20
+ /**
21
+ * Try to claim `id` as of `now`: true when neither in flight nor
22
+ * committed inside the window; false otherwise. A true claim marks
23
+ * the id in flight until `commit` or `release`.
24
+ */
25
+ claim(id: string, now: number): boolean;
26
+ /** Release an in-flight claim (the admission failed — the platform
27
+ * redelivers, so the id must be claimable again). */
28
+ release(id: string): void;
29
+ /** Durably mark `id` seen at `now` (the admission settled). */
30
+ commit(id: string, now: number): Promise<void>;
31
+ /** Is `id` committed within the window of `now`? (In-memory read of
32
+ * the last observed state; the file is re-read inside `commit`'s
33
+ * queue — the constructor cannot await, so the first commit loads.) */
34
+ private committedWithin;
35
+ /**
36
+ * The committed view: loaded at construction (readSync) and refreshed
37
+ * after every queued `commit` (the update's written draft IS the new
38
+ * committed state).
39
+ */
40
+ private observed;
41
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * The seen set (spec §8.4 step 2): the 24-hour message dedupe, persisted
3
+ * at `<stateDir>/gateway/seen.json`. IN-FLIGHT-AWARE: a claimed id
4
+ * stays unclaimable until its admission settles (commit on success,
5
+ * release on engine failure), so a platform webhook retry racing the
6
+ * first `receive` resolves `"dropped"` — never a double submission
7
+ * (Review Focus 1). Entries older than 86 400 000 ms are dropped on
8
+ * every write (the window sweep rides the write; no timers).
9
+ */
10
+ import { JsonFile } from "./json-file.js";
11
+ /** The dedupe window (ms) — the plan's pinned 24 h. */
12
+ const WINDOW_MS = 86_400_000;
13
+ /**
14
+ * The dedupe set, one instance per engine. `claim`/`release` are
15
+ * synchronous (the in-flight set is memory-only); `commit` persists.
16
+ * The window prune runs inside every `commit`'s queued write, so the
17
+ * file never grows past a day of ids even on a long-lived engine.
18
+ */
19
+ export class SeenSet {
20
+ file;
21
+ inFlight = new Set();
22
+ constructor(path) {
23
+ this.file = new JsonFile(path, () => ({}));
24
+ // The committed view loads SYNCHRONOUSLY at construction: a second
25
+ // instance over the same file (the restart path) must reject a
26
+ // redelivered id from its first `claim` — the constructor cannot
27
+ // await, and the queue is empty here so nothing can interleave.
28
+ this.observed = this.file.readSync();
29
+ }
30
+ /**
31
+ * Try to claim `id` as of `now`: true when neither in flight nor
32
+ * committed inside the window; false otherwise. A true claim marks
33
+ * the id in flight until `commit` or `release`.
34
+ */
35
+ claim(id, now) {
36
+ if (this.inFlight.has(id))
37
+ return false;
38
+ if (this.committedWithin(id, now))
39
+ return false;
40
+ this.inFlight.add(id);
41
+ return true;
42
+ }
43
+ /** Release an in-flight claim (the admission failed — the platform
44
+ * redelivers, so the id must be claimable again). */
45
+ release(id) {
46
+ this.inFlight.delete(id);
47
+ }
48
+ /** Durably mark `id` seen at `now` (the admission settled). */
49
+ async commit(id, now) {
50
+ this.inFlight.delete(id);
51
+ const draft = await this.file.update((state) => {
52
+ state[id] = now;
53
+ // The window sweep: entries older than the window are dropped on
54
+ // every write, so the file carries at most one day of ids.
55
+ for (const [key, at] of Object.entries(state)) {
56
+ if (now - at > WINDOW_MS)
57
+ delete state[key];
58
+ }
59
+ });
60
+ // The post-write state IS the committed view a later claim reads
61
+ // (the update returned the written draft).
62
+ this.observed = draft;
63
+ }
64
+ /** Is `id` committed within the window of `now`? (In-memory read of
65
+ * the last observed state; the file is re-read inside `commit`'s
66
+ * queue — the constructor cannot await, so the first commit loads.) */
67
+ committedWithin(id, now) {
68
+ const at = this.observed[id];
69
+ return at !== undefined && now - at <= WINDOW_MS;
70
+ }
71
+ /**
72
+ * The committed view: loaded at construction (readSync) and refreshed
73
+ * after every queued `commit` (the update's written draft IS the new
74
+ * committed state).
75
+ */
76
+ observed = {};
77
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The admin status route (spec §8.12): `GET /v1/ext/gateway/status` →
3
+ * `{ channels, pairing }`. Auth is declared `admin` — core's route
4
+ * mount enforces the principal's admin flag (403 otherwise); this
5
+ * module only renders the read it is handed.
6
+ *
7
+ * `pairing` ships empty until Task 4 (the pairing store lands there);
8
+ * the field is present from day one so clients see a stable shape.
9
+ * Sender ids in the pairing rows are HASHED (8-hex, spec §8.12's log
10
+ * rule generalized to operator output) — Task 4 fills the rows.
11
+ */
12
+ import type { ExtensionRouteContribution } from "@sigrun/sigrun/extension";
13
+ import type { ChannelStatus } from "./supervisor.js";
14
+ /** One pending pairing request (admin view, WITHOUT the code). */
15
+ export interface StatusPairingRow {
16
+ readonly channel: string;
17
+ /** The hashed sender id — never the raw platform id. */
18
+ readonly sender: string;
19
+ readonly createdAt: number;
20
+ }
21
+ /** The status route's response body. */
22
+ export interface GatewayStatus {
23
+ readonly channels: Readonly<Record<string, ChannelStatus>>;
24
+ readonly pairing: readonly StatusPairingRow[];
25
+ }
26
+ /** The engine-side read the route renders (fresh per request). */
27
+ export type StatusRead = () => GatewayStatus;
28
+ /**
29
+ * Build the status route over a status read. The handler is a pure
30
+ * render: the engine owns the rows, the route never touches the host.
31
+ */
32
+ export declare function createStatusRoute(read: StatusRead): ExtensionRouteContribution;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The admin status route (spec §8.12): `GET /v1/ext/gateway/status` →
3
+ * `{ channels, pairing }`. Auth is declared `admin` — core's route
4
+ * mount enforces the principal's admin flag (403 otherwise); this
5
+ * module only renders the read it is handed.
6
+ *
7
+ * `pairing` ships empty until Task 4 (the pairing store lands there);
8
+ * the field is present from day one so clients see a stable shape.
9
+ * Sender ids in the pairing rows are HASHED (8-hex, spec §8.12's log
10
+ * rule generalized to operator output) — Task 4 fills the rows.
11
+ */
12
+ /**
13
+ * Build the status route over a status read. The handler is a pure
14
+ * render: the engine owns the rows, the route never touches the host.
15
+ */
16
+ export function createStatusRoute(read) {
17
+ return {
18
+ method: "GET",
19
+ path: "status",
20
+ auth: "admin",
21
+ handler: async () => new Response(`${JSON.stringify(read())}\n`, {
22
+ status: 200,
23
+ headers: { "content-type": "application/json" },
24
+ }),
25
+ };
26
+ }