@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.
- package/CHANGELOG.md +69 -0
- package/LICENSE +21 -0
- package/README.md +241 -2
- package/dist/access/codes.d.ts +18 -0
- package/dist/access/codes.js +34 -0
- package/dist/access/link-codes.d.ts +33 -0
- package/dist/access/link-codes.js +63 -0
- package/dist/access/loop-guard.d.ts +17 -0
- package/dist/access/loop-guard.js +42 -0
- package/dist/access/pairing.d.ts +74 -0
- package/dist/access/pairing.js +131 -0
- package/dist/commands/chat.d.ts +66 -0
- package/dist/commands/chat.js +371 -0
- package/dist/commands/link.d.ts +63 -0
- package/dist/commands/link.js +77 -0
- package/dist/commands/pair.d.ts +72 -0
- package/dist/commands/pair.js +110 -0
- package/dist/config.d.ts +65 -0
- package/dist/config.js +219 -0
- package/dist/engine.d.ts +108 -0
- package/dist/engine.js +645 -0
- package/dist/inbound/albums.d.ts +39 -0
- package/dist/inbound/albums.js +62 -0
- package/dist/inbound/envelope.d.ts +19 -0
- package/dist/inbound/envelope.js +39 -0
- package/dist/inbound/groups.d.ts +29 -0
- package/dist/inbound/groups.js +55 -0
- package/dist/inbound/media.d.ts +20 -0
- package/dist/inbound/media.js +88 -0
- package/dist/inbound/pipeline.d.ts +144 -0
- package/dist/inbound/pipeline.js +600 -0
- package/dist/inbound/tokens.d.ts +38 -0
- package/dist/inbound/tokens.js +42 -0
- package/dist/index.d.ts +86 -0
- package/dist/index.js +136 -0
- package/dist/interactions/answer.d.ts +42 -0
- package/dist/interactions/answer.js +112 -0
- package/dist/interactions/chat.d.ts +28 -0
- package/dist/interactions/chat.js +199 -0
- package/dist/interactions/prompts.d.ts +68 -0
- package/dist/interactions/prompts.js +246 -0
- package/dist/interactions/receipts.d.ts +7 -0
- package/dist/interactions/receipts.js +69 -0
- package/dist/interactions/render.d.ts +39 -0
- package/dist/interactions/render.js +122 -0
- package/dist/jobs.d.ts +34 -0
- package/dist/jobs.js +72 -0
- package/dist/log.d.ts +21 -0
- package/dist/log.js +42 -0
- package/dist/notify.d.ts +21 -0
- package/dist/notify.js +64 -0
- package/dist/outbound/delivery.d.ts +131 -0
- package/dist/outbound/delivery.js +404 -0
- package/dist/outbound/markdown.d.ts +36 -0
- package/dist/outbound/markdown.js +286 -0
- package/dist/outbound/render.d.ts +24 -0
- package/dist/outbound/render.js +65 -0
- package/dist/outbound/settled.d.ts +81 -0
- package/dist/outbound/settled.js +271 -0
- package/dist/outbound/stream.d.ts +38 -0
- package/dist/outbound/stream.js +205 -0
- package/dist/section.d.ts +16 -0
- package/dist/section.js +55 -0
- package/dist/session-meta.d.ts +18 -0
- package/dist/session-meta.js +39 -0
- package/dist/state/bindings.d.ts +55 -0
- package/dist/state/bindings.js +128 -0
- package/dist/state/identities.d.ts +65 -0
- package/dist/state/identities.js +135 -0
- package/dist/state/json-file.d.ts +64 -0
- package/dist/state/json-file.js +156 -0
- package/dist/state/ledger.d.ts +176 -0
- package/dist/state/ledger.js +256 -0
- package/dist/state/seen.d.ts +41 -0
- package/dist/state/seen.js +77 -0
- package/dist/status-route.d.ts +32 -0
- package/dist/status-route.js +26 -0
- package/dist/supervisor.d.ts +153 -0
- package/dist/supervisor.js +273 -0
- package/dist/tools.d.ts +27 -0
- package/dist/tools.js +207 -0
- package/package.json +54 -5
|
@@ -0,0 +1,176 @@
|
|
|
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" | "cancelled";
|
|
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
|
+
/**
|
|
127
|
+
* Cancel an entry that has not started delivery. The transition is
|
|
128
|
+
* atomic with the ledger write: an attempting/delivered entry cannot
|
|
129
|
+
* be cancelled after the platform may already have received it.
|
|
130
|
+
* Returns whether this call cancelled the pending entry.
|
|
131
|
+
*/
|
|
132
|
+
cancel(id: string): Promise<boolean>;
|
|
133
|
+
/**
|
|
134
|
+
* Whether a transition may leave the entry's current state. The rule
|
|
135
|
+
* this exists for is narrow and deliberate: `cancelled` is TERMINAL,
|
|
136
|
+
* so a cancelled entry can never be resurrected by a late `mark` —
|
|
137
|
+
* that is what makes `cancel` a barrier rather than a request, and it
|
|
138
|
+
* is what stops a settlement-cancelled interaction prompt from being
|
|
139
|
+
* delivered anyway. Every other edge stays permissive, because the
|
|
140
|
+
* ledger is a state MACHINE with legitimate direct transitions
|
|
141
|
+
* (`pending → delivered` for a single-attempt success,
|
|
142
|
+
* `attempting → pending` for the retry ladder, `pending → failed`).
|
|
143
|
+
*/
|
|
144
|
+
private static canLeave;
|
|
145
|
+
/**
|
|
146
|
+
* Transition one entry's state (attempting/delivered/failed). A
|
|
147
|
+
* transition out of `cancelled` is refused, and a refused transition
|
|
148
|
+
* reports `false` so the caller can stop before acting on the entry
|
|
149
|
+
* (the delivery worker must not send an entry it failed to claim).
|
|
150
|
+
*/
|
|
151
|
+
mark(id: string, state: LedgerState, receipt?: readonly string[]): Promise<boolean>;
|
|
152
|
+
/**
|
|
153
|
+
* Restart recovery: every `attempting` entry becomes `pending` with
|
|
154
|
+
* the `recovered` flag (the worker prefixes the marker at send time).
|
|
155
|
+
* `pending` and terminal entries are untouched.
|
|
156
|
+
*/
|
|
157
|
+
recoverAfterRestart(): Promise<void>;
|
|
158
|
+
/** How many entries are `failed` having FAILED within the window —
|
|
159
|
+
* the state transition's clock, not creation: an old entry failing
|
|
160
|
+
* NOW still degrades health. */
|
|
161
|
+
failedSince(ts: number): number;
|
|
162
|
+
/**
|
|
163
|
+
* Prune DELIVERED entries older than `PRUNE_AFTER_MS` (7 days), except
|
|
164
|
+
* job tags: those are the permanent one-shot delivery record, so age
|
|
165
|
+
* must never re-enable delivery. Failed and pending entries are never
|
|
166
|
+
* pruned — a failed entry is the health signal (§8.6: "nothing is
|
|
167
|
+
* dropped silently"), a pending entry is undelivered work.
|
|
168
|
+
*/
|
|
169
|
+
prune(now: number): Promise<void>;
|
|
170
|
+
/**
|
|
171
|
+
* Re-read one entry's file bytes at send time (the `files` path →
|
|
172
|
+
* `readFile` rule). Returns the send-shaped files array, or undefined
|
|
173
|
+
* when the entry has none.
|
|
174
|
+
*/
|
|
175
|
+
resolveFiles(host: SigrunHost, entry: LedgerEntry): Promise<OutboundMessage["files"] | undefined>;
|
|
176
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
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
|
+
/**
|
|
87
|
+
* Cancel an entry that has not started delivery. The transition is
|
|
88
|
+
* atomic with the ledger write: an attempting/delivered entry cannot
|
|
89
|
+
* be cancelled after the platform may already have received it.
|
|
90
|
+
* Returns whether this call cancelled the pending entry.
|
|
91
|
+
*/
|
|
92
|
+
async cancel(id) {
|
|
93
|
+
let cancelled = false;
|
|
94
|
+
const state = await this.file.update((draft) => {
|
|
95
|
+
const index = draft.entries.findIndex((row) => row.id === id);
|
|
96
|
+
if (index === -1)
|
|
97
|
+
return;
|
|
98
|
+
const row = draft.entries[index];
|
|
99
|
+
if (row.state !== "pending")
|
|
100
|
+
return;
|
|
101
|
+
draft.entries[index] = {
|
|
102
|
+
...row,
|
|
103
|
+
state: "cancelled",
|
|
104
|
+
stateSince: this.now(),
|
|
105
|
+
};
|
|
106
|
+
cancelled = true;
|
|
107
|
+
});
|
|
108
|
+
this.entries = state.entries;
|
|
109
|
+
return cancelled;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Whether a transition may leave the entry's current state. The rule
|
|
113
|
+
* this exists for is narrow and deliberate: `cancelled` is TERMINAL,
|
|
114
|
+
* so a cancelled entry can never be resurrected by a late `mark` —
|
|
115
|
+
* that is what makes `cancel` a barrier rather than a request, and it
|
|
116
|
+
* is what stops a settlement-cancelled interaction prompt from being
|
|
117
|
+
* delivered anyway. Every other edge stays permissive, because the
|
|
118
|
+
* ledger is a state MACHINE with legitimate direct transitions
|
|
119
|
+
* (`pending → delivered` for a single-attempt success,
|
|
120
|
+
* `attempting → pending` for the retry ladder, `pending → failed`).
|
|
121
|
+
*/
|
|
122
|
+
static canLeave(from, to) {
|
|
123
|
+
return from !== "cancelled" || to === "cancelled";
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Transition one entry's state (attempting/delivered/failed). A
|
|
127
|
+
* transition out of `cancelled` is refused, and a refused transition
|
|
128
|
+
* reports `false` so the caller can stop before acting on the entry
|
|
129
|
+
* (the delivery worker must not send an entry it failed to claim).
|
|
130
|
+
*/
|
|
131
|
+
async mark(id, state, receipt) {
|
|
132
|
+
let applied = false;
|
|
133
|
+
await this.file.update((draft) => {
|
|
134
|
+
const index = draft.entries.findIndex((row) => row.id === id);
|
|
135
|
+
if (index === -1)
|
|
136
|
+
return;
|
|
137
|
+
const row = draft.entries[index];
|
|
138
|
+
if (!Ledger.canLeave(row.state, state))
|
|
139
|
+
return;
|
|
140
|
+
applied = true;
|
|
141
|
+
const next = {
|
|
142
|
+
...row,
|
|
143
|
+
state,
|
|
144
|
+
// An attempt transition counts the attempt; delivered/failed
|
|
145
|
+
// keep the count the attempts produced.
|
|
146
|
+
attempts: state === "attempting" ? row.attempts + 1 : row.attempts,
|
|
147
|
+
// The state transition stamps its own clock (the failure
|
|
148
|
+
// window reads it — an old entry failing NOW is fresh).
|
|
149
|
+
stateSince: this.now(),
|
|
150
|
+
...(receipt === undefined ? {} : { receipt: [...receipt] }),
|
|
151
|
+
};
|
|
152
|
+
draft.entries[index] = next;
|
|
153
|
+
this.entries = draft.entries;
|
|
154
|
+
});
|
|
155
|
+
return applied;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Restart recovery: every `attempting` entry becomes `pending` with
|
|
159
|
+
* the `recovered` flag (the worker prefixes the marker at send time).
|
|
160
|
+
* `pending` and terminal entries are untouched.
|
|
161
|
+
*/
|
|
162
|
+
async recoverAfterRestart() {
|
|
163
|
+
const recovered = await this.file.update((draft) => {
|
|
164
|
+
for (let index = 0; index < draft.entries.length; index++) {
|
|
165
|
+
const row = draft.entries[index];
|
|
166
|
+
if (row.state !== "attempting")
|
|
167
|
+
continue;
|
|
168
|
+
draft.entries[index] = { ...row, state: "pending", recovered: true };
|
|
169
|
+
}
|
|
170
|
+
return;
|
|
171
|
+
});
|
|
172
|
+
if (recovered.entries.some((row) => row.recovered === true)) {
|
|
173
|
+
this.log?.info("ledger: recovered attempting entries after restart");
|
|
174
|
+
}
|
|
175
|
+
this.entries = recovered.entries;
|
|
176
|
+
}
|
|
177
|
+
/** How many entries are `failed` having FAILED within the window —
|
|
178
|
+
* the state transition's clock, not creation: an old entry failing
|
|
179
|
+
* NOW still degrades health. */
|
|
180
|
+
failedSince(ts) {
|
|
181
|
+
return this.entries.filter((entry) => entry.state === "failed" &&
|
|
182
|
+
(entry.stateSince ?? entry.createdAt) >= ts).length;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Prune DELIVERED entries older than `PRUNE_AFTER_MS` (7 days), except
|
|
186
|
+
* job tags: those are the permanent one-shot delivery record, so age
|
|
187
|
+
* must never re-enable delivery. Failed and pending entries are never
|
|
188
|
+
* pruned — a failed entry is the health signal (§8.6: "nothing is
|
|
189
|
+
* dropped silently"), a pending entry is undelivered work.
|
|
190
|
+
*/
|
|
191
|
+
async prune(now) {
|
|
192
|
+
const pruned = await this.file.update((draft) => {
|
|
193
|
+
const before = draft.entries.length;
|
|
194
|
+
const kept = draft.entries.filter((entry) => entry.state !== "delivered" ||
|
|
195
|
+
entry.tag?.startsWith("job:") === true ||
|
|
196
|
+
now - entry.createdAt < PRUNE_AFTER_MS);
|
|
197
|
+
// In-place array replacement keeps the draft object identity.
|
|
198
|
+
draft.entries.length = 0;
|
|
199
|
+
draft.entries.push(...kept);
|
|
200
|
+
if (kept.length !== before) {
|
|
201
|
+
this.log?.debug(`ledger: pruned ${before - kept.length} delivered entries`);
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
this.entries = pruned.entries;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Re-read one entry's file bytes at send time (the `files` path →
|
|
208
|
+
* `readFile` rule). Returns the send-shaped files array, or undefined
|
|
209
|
+
* when the entry has none.
|
|
210
|
+
*/
|
|
211
|
+
async resolveFiles(host, entry) {
|
|
212
|
+
const specs = entry.message.files;
|
|
213
|
+
if (specs === undefined || specs.length === 0)
|
|
214
|
+
return undefined;
|
|
215
|
+
const files = [];
|
|
216
|
+
for (const spec of specs) {
|
|
217
|
+
const read = await host.conversations.readFile(spec.conversationId, spec.path);
|
|
218
|
+
files.push({
|
|
219
|
+
name: spec.name,
|
|
220
|
+
...(spec.mime === undefined ? {} : { mime: spec.mime }),
|
|
221
|
+
bytes: read.bytes,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
return files;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Strip the bytes out of an outbound message for persistence: the
|
|
229
|
+
* `files` array is replaced by the workspace-path records the worker
|
|
230
|
+
* re-reads through `resolveFiles`. A message carrying bytes WITHOUT a
|
|
231
|
+
* file spec (the caller sent ephemeral buffers) drops them — the ledger
|
|
232
|
+
* never persists bytes, and the send would need the path spec anyway.
|
|
233
|
+
*/
|
|
234
|
+
function serializeMessage(message, files) {
|
|
235
|
+
if (files === undefined || files.length === 0) {
|
|
236
|
+
return { chat: message.chat, text: message.text, ...pickOptional(message) };
|
|
237
|
+
}
|
|
238
|
+
return {
|
|
239
|
+
chat: message.chat,
|
|
240
|
+
text: message.text,
|
|
241
|
+
...pickOptional(message),
|
|
242
|
+
files: files.map((file) => ({
|
|
243
|
+
conversationId: file.conversationId,
|
|
244
|
+
path: file.path,
|
|
245
|
+
name: file.name,
|
|
246
|
+
...(file.mime === undefined ? {} : { mime: file.mime }),
|
|
247
|
+
})),
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
/** The optional outbound fields, copied when present. */
|
|
251
|
+
function pickOptional(message) {
|
|
252
|
+
return {
|
|
253
|
+
...(message.replyTo === undefined ? {} : { replyTo: message.replyTo }),
|
|
254
|
+
...(message.buttons === undefined ? {} : { buttons: message.buttons }),
|
|
255
|
+
};
|
|
256
|
+
}
|
|
@@ -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
|
+
}
|