@sigrun/ext-gateway 0.1.1 → 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 +15 -0
- package/README.md +28 -5
- package/dist/engine.js +3 -3
- package/dist/interactions/answer.js +4 -3
- package/dist/interactions/chat.d.ts +8 -0
- package/dist/interactions/chat.js +99 -55
- package/dist/interactions/prompts.d.ts +31 -5
- package/dist/interactions/prompts.js +170 -20
- package/dist/interactions/receipts.d.ts +2 -1
- package/dist/interactions/receipts.js +35 -2
- package/dist/notify.js +13 -6
- package/dist/outbound/delivery.d.ts +8 -1
- package/dist/outbound/delivery.js +27 -10
- package/dist/outbound/settled.d.ts +8 -7
- package/dist/session-meta.d.ts +11 -1
- package/dist/session-meta.js +19 -1
- package/dist/state/identities.d.ts +5 -1
- package/dist/state/identities.js +6 -2
- package/dist/state/ledger.d.ts +27 -3
- package/dist/state/ledger.js +50 -1
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.1.2] - 2026-10-28
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- The interaction notifier and chat answer correlation now inspect
|
|
14
|
+
records through core's non-consuming `peekInteraction` instead of the
|
|
15
|
+
one-shot delivery read. The early-answer race is closed: a secret
|
|
16
|
+
interaction answered in the webui before the notifier's first pass no
|
|
17
|
+
longer loses the owning extension's raw-secret delivery (the gateway
|
|
18
|
+
owns no delivery; regression-tested with a fixture owner).
|
|
19
|
+
- Home-DM settled notices report failed/aborted runs truthfully:
|
|
20
|
+
`Reply ready` only for answered runs; unanswered runs read `Run ended
|
|
21
|
+
without a reply` with the settle reason (empty reasons included).
|
|
22
|
+
Recipient, dedup, bound-conversation suppression, and `notify.home`
|
|
23
|
+
gating are unchanged.
|
|
24
|
+
|
|
10
25
|
## [0.1.1] - 2026-10-27
|
|
11
26
|
### Fixed
|
|
12
27
|
- First real publish. `0.1.0` was registered with the npm name bootstrap but
|
package/README.md
CHANGED
|
@@ -179,11 +179,34 @@ buttons; a later tap reports that outcome rather than answering again.
|
|
|
179
179
|
Unbound conversations notify each write-access user's home DMs, while
|
|
180
180
|
bound conversations prompt each bound chat once.
|
|
181
181
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
read.
|
|
186
|
-
|
|
182
|
+
Every gateway interaction read is a non-consuming `peekInteraction`:
|
|
183
|
+
the gateway owns no secret delivery (secret records are webui-plan), so
|
|
184
|
+
inspection can never eat the owning extension's one-shot raw-secret
|
|
185
|
+
read. A secret interaction answered in the webui before the notifier's
|
|
186
|
+
first pass is safe — the notifier peeks masked data and the owner still
|
|
187
|
+
receives the secret exactly once.
|
|
188
|
+
|
|
189
|
+
Home-DM settled notices are truthful about the outcome: an answered run
|
|
190
|
+
reads `Reply ready`; a failed or aborted run reads `Run ended without a
|
|
191
|
+
reply` with the settle reason, never reply-ready language.
|
|
192
|
+
|
|
193
|
+
### Prompt retention
|
|
194
|
+
|
|
195
|
+
Platform prompt messages are never deleted by the gateway. What the
|
|
196
|
+
gateway retains locally is the correlation/edit metadata in
|
|
197
|
+
`prompts.json`: message refs and answer plans expire seven days after
|
|
198
|
+
the interaction settles, so a settlement edit (or a late delivery
|
|
199
|
+
receipt's self-heal edit) is only possible inside that window. Compact
|
|
200
|
+
settled tombstones — recipient identity, settlement time, and outcome
|
|
201
|
+
text — persist permanently for replay suppression: a delayed receipt
|
|
202
|
+
or replay after the row expired recreates nothing and can never make
|
|
203
|
+
an answered prompt answerable again. Pending, unsettled prompts are
|
|
204
|
+
never age-pruned. Refs/plans are therefore bounded by active prompts
|
|
205
|
+
plus seven days of settled history; the small tombstone set grows with
|
|
206
|
+
interaction history by design (the file as a whole is NOT bounded).
|
|
207
|
+
Legacy unversioned state migrates on startup without losing refs or
|
|
208
|
+
re-opening old prompts; a wholly malformed `prompts.json` fails loudly
|
|
209
|
+
rather than silently resetting interaction state.
|
|
187
210
|
|
|
188
211
|
## Delivery, state, and shutdown
|
|
189
212
|
|
package/dist/engine.js
CHANGED
|
@@ -39,7 +39,7 @@ import { Ledger } from "./state/ledger.js";
|
|
|
39
39
|
import { DeliveryWorker } from "./outbound/delivery.js";
|
|
40
40
|
import { onRunSettled } from "./outbound/settled.js";
|
|
41
41
|
import { createNotifier } from "./notify.js";
|
|
42
|
-
import { SessionMetaDoc } from "./session-meta.js";
|
|
42
|
+
import { SessionMetaDoc, readJobMeta } from "./session-meta.js";
|
|
43
43
|
import { StreamRegistry } from "./outbound/stream.js";
|
|
44
44
|
import { hashId } from "./log.js";
|
|
45
45
|
import { collectAttachments } from "./inbound/media.js";
|
|
@@ -272,7 +272,7 @@ export function createEngine(host, deps = {}) {
|
|
|
272
272
|
// The event bus's narrowed snapshot API works in harness-only
|
|
273
273
|
// embedders; host.conversations.meta would wait for a server
|
|
274
274
|
// bridge that those embedders never create.
|
|
275
|
-
readMeta: async (conversationId) => await api.document(SessionMetaDoc, conversationId, context),
|
|
275
|
+
readMeta: async (conversationId) => await readJobMeta(async () => await api.document(SessionMetaDoc, conversationId, context), (message) => { log.once("session-meta-read", "warn", message); }),
|
|
276
276
|
});
|
|
277
277
|
const notifyDeps = () => ({
|
|
278
278
|
host,
|
|
@@ -511,7 +511,7 @@ export function createEngine(host, deps = {}) {
|
|
|
511
511
|
},
|
|
512
512
|
resolveFiles: (entry) => ledger.resolveFiles(host, entry),
|
|
513
513
|
onInteractionReceipt: async (entry, receipt) => {
|
|
514
|
-
await receiptForInteraction(prompts, entry, receipt);
|
|
514
|
+
await receiptForInteraction(prompts, entry, receipt, interactionDeps());
|
|
515
515
|
},
|
|
516
516
|
});
|
|
517
517
|
workers.set(id, worker);
|
|
@@ -15,9 +15,10 @@ export async function answerFromChat(deps, answer) {
|
|
|
15
15
|
if (access !== "write")
|
|
16
16
|
return "You do not have write access to this conversation.";
|
|
17
17
|
// Terminal-snapshot invariant: secret-bearing records are webui-plan,
|
|
18
|
-
// so no chat answer reaches
|
|
19
|
-
//
|
|
20
|
-
|
|
18
|
+
// so no chat answer reaches a secret record. The gateway owns NO
|
|
19
|
+
// secret delivery regardless — this correlation/terminal read only
|
|
20
|
+
// INSPECTS, so it is a peek (non-consuming, masked) by construction.
|
|
21
|
+
const record = await deps.host.peekInteraction(answer.conversationId, answer.callId, deps.context ?? { abortSignal: new AbortController().signal });
|
|
21
22
|
if (record !== undefined && TERMINAL.has(record.status)) {
|
|
22
23
|
return `Already ${outcomeOf(record)}.`;
|
|
23
24
|
}
|
|
@@ -18,3 +18,11 @@ export interface InteractionChatDeps extends AnswerDeps {
|
|
|
18
18
|
export declare function createInteractionNotifier(deps: InteractionChatDeps): NotifierContribution;
|
|
19
19
|
/** Enqueue outcome edits/notices and retire numbered/reply-to matching. */
|
|
20
20
|
export declare function onInteractionSettled(event: ExtensionLifecyclePayload["interactionSettled"], deps: InteractionChatDeps, context: HandlerContext): Promise<void>;
|
|
21
|
+
/**
|
|
22
|
+
* Enqueue settlement edits and per-binding fallback outcome notices
|
|
23
|
+
* (a binding without an editable ref gets a notice). Called at settlement AND from the receipt
|
|
24
|
+
* path when a real delivery ref arrives for an ALREADY-settled prompt:
|
|
25
|
+
* settlement that beats the receipt leaves no editable ref, so the
|
|
26
|
+
* late receipt is the last chance to replace the live buttons.
|
|
27
|
+
*/
|
|
28
|
+
export declare function enqueueSettlementEdits(deps: InteractionChatDeps, conversationId: number, callId: string, outcome: string): Promise<void>;
|
|
@@ -39,24 +39,32 @@ async function notifyInteraction(event, deps) {
|
|
|
39
39
|
targets.push({ key: `user:${event.user}`, channel: home.channel, chat: home.chat });
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
|
-
// Resolve and dedupe targets BEFORE reading the record.
|
|
43
|
-
//
|
|
44
|
-
//
|
|
42
|
+
// Resolve and dedupe targets BEFORE reading the record. The read is a
|
|
43
|
+
// PEEK: the gateway owns NO secret delivery (secret records are
|
|
44
|
+
// webui-plan), so it must never consume one — peekInteraction is the
|
|
45
|
+
// non-consuming inspection read (a settled secret's one-shot delivery
|
|
46
|
+
// belongs to the owning extension's snapshotInteraction alone).
|
|
45
47
|
const pendingTargets = targets.filter((target) => !deps.prompts.has(event.conversationId, event.callId, target.key));
|
|
46
48
|
if (pendingTargets.length === 0)
|
|
47
49
|
return;
|
|
48
50
|
const context = deps.context ?? { abortSignal: new AbortController().signal };
|
|
49
|
-
const record = await deps.host.
|
|
51
|
+
const record = await deps.host.peekInteraction(event.conversationId, event.callId, context);
|
|
50
52
|
if (record === undefined || record.status !== "pending")
|
|
51
53
|
return;
|
|
54
|
+
// A settle can land while the render above awaited: re-check BEFORE
|
|
55
|
+
// the first enqueue so an already-settled interaction never gets a
|
|
56
|
+
// live prompt (the buttons would only answer "Already answered.").
|
|
57
|
+
const still = await deps.host.peekInteraction(event.conversationId, event.callId, context);
|
|
58
|
+
if (still === undefined || still.status !== "pending")
|
|
59
|
+
return;
|
|
52
60
|
for (const target of pendingTargets) {
|
|
53
61
|
const channel = deps.channelOf(target.channel);
|
|
54
62
|
if (channel === undefined) {
|
|
55
63
|
deps.log.warn(`interaction prompt skipped: no channel "${target.channel}"`);
|
|
56
64
|
continue;
|
|
57
65
|
}
|
|
58
|
-
const plan = planInteraction(
|
|
59
|
-
const rendered = renderInteraction(
|
|
66
|
+
const plan = planInteraction(still, channel.capabilities);
|
|
67
|
+
const rendered = renderInteraction(still, channel.capabilities, {
|
|
60
68
|
conversationId: event.conversationId,
|
|
61
69
|
conversationName: (await deps.host.conversations.meta(event.conversationId)).name ?? `Conversation ${event.conversationId}`,
|
|
62
70
|
...(event.url === undefined ? {} : { link: event.url }),
|
|
@@ -64,8 +72,17 @@ async function notifyInteraction(event, deps) {
|
|
|
64
72
|
? { ...action, conversationId: event.conversationId }
|
|
65
73
|
: action),
|
|
66
74
|
});
|
|
67
|
-
|
|
68
|
-
|
|
75
|
+
// The ledger enqueue precedes the prompt row: the ledger tag is
|
|
76
|
+
// the durable dedupe, so a throwing enqueue must NOT leave a row
|
|
77
|
+
// that would suppress this target on every replay.
|
|
78
|
+
// Re-check per target immediately before its enqueue: rendering
|
|
79
|
+
// and meta lookups await, and a settle in that window must not
|
|
80
|
+
// deliver another live prompt. markEnqueued independently checks
|
|
81
|
+
// the durable call-level settlement marker as the final guard.
|
|
82
|
+
const current = await deps.host.peekInteraction(event.conversationId, event.callId, context);
|
|
83
|
+
if (current === undefined || current.status !== "pending")
|
|
84
|
+
return;
|
|
85
|
+
const enqueued = await deps.ledger.enqueue({
|
|
69
86
|
channel: target.channel,
|
|
70
87
|
...(target.bindingKey === undefined ? {} : { bindingKey: target.bindingKey }),
|
|
71
88
|
chat: target.chat,
|
|
@@ -73,6 +90,15 @@ async function notifyInteraction(event, deps) {
|
|
|
73
90
|
message: { chat: target.chat, ...rendered },
|
|
74
91
|
tag: `interaction:${event.conversationId}:${event.callId}:${target.key}`,
|
|
75
92
|
});
|
|
93
|
+
await deps.prompts.markEnqueued(event.conversationId, event.callId, target.key, plan);
|
|
94
|
+
// A settlement between the pending peek and markEnqueued writes the
|
|
95
|
+
// call-level tombstone, so no row was reserved. Compensate the lost
|
|
96
|
+
// reservation by cancelling the still-pending prompt: otherwise its
|
|
97
|
+
// delivery would leave live buttons and duplicate the outcome notice.
|
|
98
|
+
if (enqueued !== undefined && !deps.prompts.has(event.conversationId, event.callId, target.key)) {
|
|
99
|
+
await deps.ledger.cancel(enqueued.id);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
76
102
|
}
|
|
77
103
|
}
|
|
78
104
|
/** Enqueue outcome edits/notices and retire numbered/reply-to matching. */
|
|
@@ -84,58 +110,76 @@ export async function onInteractionSettled(event, deps, context) {
|
|
|
84
110
|
// the extension's only chance to receive the secret. The widened C6
|
|
85
111
|
// event carries everything chat rendering needs (the stored answer
|
|
86
112
|
// is masked by construction — secrets never persist).
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
// the contract cannot import the json module's runtime shape.
|
|
91
|
-
const answerValues = (event.answer ?? {});
|
|
92
|
-
const outcome = event.status === "answered"
|
|
93
|
-
? `answered${who}: ${summarizeValues(answerValues)}`
|
|
94
|
-
: `${event.status}${who}`;
|
|
95
|
-
const editableChannels = new Set();
|
|
96
|
-
const refs = deps.prompts.refsOf(event.conversationId, event.callId);
|
|
97
|
-
for (const ref of refs) {
|
|
98
|
-
const id = channelForRef(deps, event.conversationId, event.callId, ref);
|
|
99
|
-
const channel = id === undefined ? undefined : deps.channelOf(id);
|
|
100
|
-
if (channel?.capabilities.edit !== undefined && channel.edit !== undefined && ref.messageId !== "") {
|
|
101
|
-
editableChannels.add(channel.id);
|
|
102
|
-
const bindingKey = deps.bindings.keysOf(event.conversationId).find((key) => {
|
|
103
|
-
const binding = deps.bindings.get(key);
|
|
104
|
-
return binding?.chat.chatId === ref.chat.chatId && binding.chat.threadId === ref.chat.threadId;
|
|
105
|
-
});
|
|
106
|
-
await deps.ledger.enqueue({
|
|
107
|
-
channel: channel.id,
|
|
108
|
-
...(bindingKey === undefined ? {} : { bindingKey }),
|
|
109
|
-
chat: ref.chat,
|
|
110
|
-
kind: "edit",
|
|
111
|
-
ref,
|
|
112
|
-
message: { chat: ref.chat, text: truncateInteractionText(`Interaction ${outcome}.`, channel.capabilities.maxMessageLength) },
|
|
113
|
-
tag: `interaction-settled:${event.conversationId}:${event.callId}:${channel.id}:${ref.messageId}`,
|
|
114
|
-
});
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
if (editableChannels.size === 0) {
|
|
118
|
-
for (const key of deps.bindings.keysOf(event.conversationId)) {
|
|
119
|
-
const binding = deps.bindings.get(key);
|
|
120
|
-
const channelId = key.split(":")[0];
|
|
121
|
-
if (binding === undefined || binding.dead === true)
|
|
122
|
-
continue;
|
|
123
|
-
await deps.ledger.enqueue({
|
|
124
|
-
channel: channelId,
|
|
125
|
-
bindingKey: key,
|
|
126
|
-
chat: binding.chat,
|
|
127
|
-
kind: "notice",
|
|
128
|
-
message: { chat: binding.chat, text: `Interaction ${outcome}.` },
|
|
129
|
-
tag: `interaction-settled-notice:${event.conversationId}:${event.callId}:${key}`,
|
|
130
|
-
});
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
await deps.prompts.settle(event.conversationId, event.callId);
|
|
113
|
+
const outcome = outcomeText(event);
|
|
114
|
+
await enqueueSettlementEdits(deps, event.conversationId, event.callId, outcome);
|
|
115
|
+
await deps.prompts.settle(event.conversationId, event.callId, outcome);
|
|
134
116
|
}
|
|
135
117
|
catch (error) {
|
|
136
118
|
deps.log.error(`interaction settlement delivery failed (${String(event.conversationId)}:${event.callId})`, error);
|
|
137
119
|
}
|
|
138
120
|
}
|
|
121
|
+
/** The outcome summary the settlement edit/notice carries. */
|
|
122
|
+
function outcomeText(event) {
|
|
123
|
+
const who = event.answeredByUser === undefined ? "" : ` by ${event.answeredByUser}`;
|
|
124
|
+
// SAFETY: the stored answer's values are validated JsonValue (the
|
|
125
|
+
// record's answer shape); the payload types them loosely because
|
|
126
|
+
// the contract cannot import the json module's runtime shape.
|
|
127
|
+
const answerValues = (event.answer ?? {});
|
|
128
|
+
return event.status === "answered"
|
|
129
|
+
? `answered${who}: ${summarizeValues(answerValues)}`
|
|
130
|
+
: `${event.status}${who}`;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Enqueue settlement edits and per-binding fallback outcome notices
|
|
134
|
+
* (a binding without an editable ref gets a notice). Called at settlement AND from the receipt
|
|
135
|
+
* path when a real delivery ref arrives for an ALREADY-settled prompt:
|
|
136
|
+
* settlement that beats the receipt leaves no editable ref, so the
|
|
137
|
+
* late receipt is the last chance to replace the live buttons.
|
|
138
|
+
*/
|
|
139
|
+
export async function enqueueSettlementEdits(deps, conversationId, callId, outcome) {
|
|
140
|
+
const editableBindings = new Set();
|
|
141
|
+
const refs = deps.prompts.refsOf(conversationId, callId);
|
|
142
|
+
for (const ref of refs) {
|
|
143
|
+
const id = channelForRef(deps, conversationId, callId, ref);
|
|
144
|
+
const channel = id === undefined ? undefined : deps.channelOf(id);
|
|
145
|
+
const bindingKey = deps.bindings.keysOf(conversationId).find((key) => {
|
|
146
|
+
const binding = deps.bindings.get(key);
|
|
147
|
+
return binding?.chat.chatId === ref.chat.chatId && binding.chat.threadId === ref.chat.threadId;
|
|
148
|
+
});
|
|
149
|
+
if (channel?.capabilities.edit !== undefined && channel.edit !== undefined && ref.messageId !== "") {
|
|
150
|
+
if (bindingKey !== undefined)
|
|
151
|
+
editableBindings.add(bindingKey);
|
|
152
|
+
await deps.ledger.enqueue({
|
|
153
|
+
channel: channel.id,
|
|
154
|
+
...(bindingKey === undefined ? {} : { bindingKey }),
|
|
155
|
+
chat: ref.chat,
|
|
156
|
+
kind: "edit",
|
|
157
|
+
ref,
|
|
158
|
+
message: { chat: ref.chat, text: truncateInteractionText(`Interaction ${outcome}.`, channel.capabilities.maxMessageLength) },
|
|
159
|
+
tag: `interaction-settled:${conversationId}:${callId}:${channel.id}:${ref.messageId}`,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
// Fallback eligibility is PER BINDING: an editable ref on one channel
|
|
164
|
+
// must not suppress the outcome notice owed to another binding whose
|
|
165
|
+
// channel cannot edit (or whose prompt has no editable ref).
|
|
166
|
+
for (const key of deps.bindings.keysOf(conversationId)) {
|
|
167
|
+
if (editableBindings.has(key))
|
|
168
|
+
continue;
|
|
169
|
+
const binding = deps.bindings.get(key);
|
|
170
|
+
const channelId = key.split(":")[0];
|
|
171
|
+
if (binding === undefined || binding.dead === true)
|
|
172
|
+
continue;
|
|
173
|
+
await deps.ledger.enqueue({
|
|
174
|
+
channel: channelId,
|
|
175
|
+
bindingKey: key,
|
|
176
|
+
chat: binding.chat,
|
|
177
|
+
kind: "notice",
|
|
178
|
+
message: { chat: binding.chat, text: `Interaction ${outcome}.` },
|
|
179
|
+
tag: `interaction-settled-notice:${conversationId}:${callId}:${key}`,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
139
183
|
/** Recover the channel from the binding key/home prompt target. */
|
|
140
184
|
function channelForRef(deps, conversationId, callId, ref) {
|
|
141
185
|
for (const key of deps.bindings.keysOf(conversationId)) {
|
|
@@ -1,21 +1,40 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Durable chat-prompt bookkeeping. Ledger delivery receipts become
|
|
3
3
|
* MessageRefs here; pending reads stay synchronous for inbound stage 8.
|
|
4
|
-
* Settled prompts stop matching numbered/reply-to answers
|
|
5
|
-
*
|
|
4
|
+
* Settled prompts stop matching numbered/reply-to answers.
|
|
5
|
+
*
|
|
6
|
+
* Retention (release-readiness spec 4 §§2–4): the persisted file is
|
|
7
|
+
* version 2 — `prompts` rows plus a `settled` tombstone map carrying
|
|
8
|
+
* the settlement timestamp and outcome text. Seven days after
|
|
9
|
+
* settlement the bulky row (refs/plan) expires; the compact tombstone
|
|
10
|
+
* persists PERMANENTLY so a delayed receipt or replay can never
|
|
11
|
+
* resurrect an answerable prompt. Pending rows are never age-pruned.
|
|
12
|
+
* Pruning runs at store initialization and at most once per write (no
|
|
13
|
+
* timer), through the one `prune` helper and its boundary predicate.
|
|
14
|
+
* Refs/plans are therefore bounded by active prompts plus seven days
|
|
15
|
+
* of settled history; the tombstone set is deliberately unbounded.
|
|
6
16
|
*/
|
|
7
17
|
import type { MessageRef } from "@sigrun/sigrun/extension";
|
|
18
|
+
import type { EngineLog } from "../log.js";
|
|
8
19
|
import type { ChatAnswerPlan } from "./render.js";
|
|
20
|
+
/** Settled-row retention (matches the delivery ledger's seven days). */
|
|
21
|
+
export declare const PROMPT_RETENTION_MS = 604800000;
|
|
9
22
|
export interface PendingPrompt {
|
|
10
23
|
readonly conversationId: number;
|
|
11
24
|
readonly callId: string;
|
|
12
25
|
readonly plan: ChatAnswerPlan;
|
|
13
26
|
}
|
|
27
|
+
export interface PromptStoreOptions {
|
|
28
|
+
readonly now?: () => number;
|
|
29
|
+
readonly log?: EngineLog;
|
|
30
|
+
}
|
|
14
31
|
export declare class PromptStore {
|
|
15
32
|
private readonly file;
|
|
33
|
+
private readonly now;
|
|
34
|
+
private readonly log;
|
|
16
35
|
private rows;
|
|
17
36
|
private settled;
|
|
18
|
-
constructor(path: string);
|
|
37
|
+
constructor(path: string, options?: PromptStoreOptions);
|
|
19
38
|
/** Record a delivered prompt ref (idempotent for one recipient key). */
|
|
20
39
|
record(conversationId: number, callId: string, key: string, ref: MessageRef, plan: ChatAnswerPlan): Promise<void>;
|
|
21
40
|
/** All delivered message refs for settlement edits (including settled). */
|
|
@@ -37,6 +56,13 @@ export declare class PromptStore {
|
|
|
37
56
|
markEnqueued(conversationId: number, callId: string, key: string, plan: ChatAnswerPlan): Promise<void>;
|
|
38
57
|
/** Install the real refs after the interaction ledger entry is delivered. */
|
|
39
58
|
recordReceipt(conversationId: number, callId: string, key: string, chat: MessageRef["chat"], messageIds: readonly string[]): Promise<void>;
|
|
40
|
-
/** Retire pending answers
|
|
41
|
-
settle(conversationId: number, callId: string): Promise<void>;
|
|
59
|
+
/** Retire pending answers; the refs stay until retention expires them. */
|
|
60
|
+
settle(conversationId: number, callId: string, outcome?: string): Promise<void>;
|
|
61
|
+
/**
|
|
62
|
+
* The outcome text recorded at settlement, when this identity is
|
|
63
|
+
* settled. The receipt path reads it to self-heal the prompt edit;
|
|
64
|
+
* the tombstone outlives the bulky row, so the answer survives
|
|
65
|
+
* pruning even when no editable ref remains.
|
|
66
|
+
*/
|
|
67
|
+
settledOutcome(conversationId: number, callId: string, key: string): string | undefined;
|
|
42
68
|
}
|
|
@@ -1,37 +1,90 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Durable chat-prompt bookkeeping. Ledger delivery receipts become
|
|
3
3
|
* MessageRefs here; pending reads stay synchronous for inbound stage 8.
|
|
4
|
-
* Settled prompts stop matching numbered/reply-to answers
|
|
5
|
-
*
|
|
4
|
+
* Settled prompts stop matching numbered/reply-to answers.
|
|
5
|
+
*
|
|
6
|
+
* Retention (release-readiness spec 4 §§2–4): the persisted file is
|
|
7
|
+
* version 2 — `prompts` rows plus a `settled` tombstone map carrying
|
|
8
|
+
* the settlement timestamp and outcome text. Seven days after
|
|
9
|
+
* settlement the bulky row (refs/plan) expires; the compact tombstone
|
|
10
|
+
* persists PERMANENTLY so a delayed receipt or replay can never
|
|
11
|
+
* resurrect an answerable prompt. Pending rows are never age-pruned.
|
|
12
|
+
* Pruning runs at store initialization and at most once per write (no
|
|
13
|
+
* timer), through the one `prune` helper and its boundary predicate.
|
|
14
|
+
* Refs/plans are therefore bounded by active prompts plus seven days
|
|
15
|
+
* of settled history; the tombstone set is deliberately unbounded.
|
|
6
16
|
*/
|
|
7
17
|
import { JsonFile } from "../state/json-file.js";
|
|
18
|
+
/** Settled-row retention (matches the delivery ledger's seven days). */
|
|
19
|
+
export const PROMPT_RETENTION_MS = 604_800_000;
|
|
20
|
+
/** The persisted schema version (v1 = unversioned, `settled: string[]`). */
|
|
21
|
+
const PROMPT_FILE_VERSION = 2;
|
|
8
22
|
export class PromptStore {
|
|
9
23
|
file;
|
|
24
|
+
now;
|
|
25
|
+
log;
|
|
10
26
|
rows;
|
|
11
27
|
settled;
|
|
12
|
-
constructor(path) {
|
|
13
|
-
this.
|
|
28
|
+
constructor(path, options = {}) {
|
|
29
|
+
this.now = options.now ?? (() => Date.now());
|
|
30
|
+
this.log = options.log;
|
|
31
|
+
this.file = new JsonFile(path, () => ({
|
|
32
|
+
version: PROMPT_FILE_VERSION,
|
|
33
|
+
prompts: [],
|
|
34
|
+
settled: {},
|
|
35
|
+
}));
|
|
14
36
|
const state = this.file.readSync();
|
|
15
|
-
|
|
16
|
-
|
|
37
|
+
const migrated = migrate(state, this.now(), this.log);
|
|
38
|
+
// Initialization pruning rides the same helper the writes use.
|
|
39
|
+
prune(migrated, this.now());
|
|
40
|
+
// Persist the normalized/pruned shape ONLY when something changed:
|
|
41
|
+
// a legacy or repaired file must not re-migrate on every boot,
|
|
42
|
+
// while a healthy v2 file needs no rewrite at construction.
|
|
43
|
+
if (JSON.stringify(state) !== JSON.stringify(migrated)) {
|
|
44
|
+
this.file.updateSync((draft) => {
|
|
45
|
+
draft.version = migrated.version;
|
|
46
|
+
draft.prompts = migrated.prompts;
|
|
47
|
+
draft.settled = migrated.settled;
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
this.rows = migrated.prompts;
|
|
51
|
+
this.settled = migrated.settled;
|
|
17
52
|
}
|
|
18
53
|
/** Record a delivered prompt ref (idempotent for one recipient key). */
|
|
19
54
|
async record(conversationId, callId, key, ref, plan) {
|
|
20
55
|
const state = await this.file.update((draft) => {
|
|
21
56
|
const identity = `${conversationId}:${callId}:${key}`;
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
57
|
+
const tombstone = draft.settled[identity];
|
|
58
|
+
const existing = draft.prompts.find((row) => identityOf(row) === identity);
|
|
59
|
+
// Settlement is CALL-scoped, not only row-scoped. A settlement
|
|
60
|
+
// landing before the first row exists still records a marker;
|
|
61
|
+
// otherwise a later fan-out/receipt could create an answerable
|
|
62
|
+
// prompt for an already-settled interaction. A real receipt may
|
|
63
|
+
// still install refs on a surviving row for the settlement edit.
|
|
64
|
+
if (existing === undefined && draft.settled[`${conversationId}:${callId}:`] !== undefined)
|
|
25
65
|
return;
|
|
26
|
-
|
|
66
|
+
if (tombstone !== undefined) {
|
|
67
|
+
// A settlement raced this record. An EMPTY ref never replaces
|
|
68
|
+
// a delivered one; a REAL ref still installs while the row
|
|
69
|
+
// survives so the late receipt can self-heal the edit. Once
|
|
70
|
+
// the row is pruned, BOTH shapes are ignored — no resurrection.
|
|
71
|
+
const existing = draft.prompts.find((row) => identityOf(row) === identity);
|
|
72
|
+
if (existing === undefined)
|
|
73
|
+
return;
|
|
74
|
+
if (ref.messageId === "")
|
|
75
|
+
return;
|
|
76
|
+
if (existing.ref.messageId !== "")
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
27
79
|
const next = { conversationId, callId, key, ref, plan };
|
|
28
80
|
if (existing === undefined)
|
|
29
81
|
draft.prompts.push(next);
|
|
30
82
|
else
|
|
31
83
|
draft.prompts[draft.prompts.indexOf(existing)] = next;
|
|
84
|
+
prune(draft, this.now());
|
|
32
85
|
});
|
|
33
86
|
this.rows = state.prompts;
|
|
34
|
-
this.settled =
|
|
87
|
+
this.settled = state.settled;
|
|
35
88
|
}
|
|
36
89
|
/** All delivered message refs for settlement edits (including settled). */
|
|
37
90
|
refsOf(conversationId, callId) {
|
|
@@ -58,14 +111,14 @@ export class PromptStore {
|
|
|
58
111
|
/** Unsettled choice/text prompts in one chat (bound key or user home key). */
|
|
59
112
|
pendingInChat(bindingKey) {
|
|
60
113
|
return this.rows
|
|
61
|
-
.filter((row) => row.key === bindingKey &&
|
|
114
|
+
.filter((row) => row.key === bindingKey && this.settled[identityOf(row)] === undefined)
|
|
62
115
|
.map((row) => ({ conversationId: row.conversationId, callId: row.callId, plan: row.plan }));
|
|
63
116
|
}
|
|
64
117
|
/** Find the prompt behind a reply-to platform message id. */
|
|
65
118
|
byReplyTo(bindingKey, messageId) {
|
|
66
119
|
const row = this.rows.find((candidate) => candidate.key === bindingKey &&
|
|
67
120
|
candidate.ref.messageId === messageId &&
|
|
68
|
-
|
|
121
|
+
this.settled[identityOf(candidate)] === undefined);
|
|
69
122
|
return row === undefined
|
|
70
123
|
? undefined
|
|
71
124
|
: { conversationId: row.conversationId, callId: row.callId, plan: row.plan };
|
|
@@ -79,18 +132,115 @@ export class PromptStore {
|
|
|
79
132
|
const existing = this.rows.find((row) => row.conversationId === conversationId && row.callId === callId && row.key === key);
|
|
80
133
|
await this.record(conversationId, callId, key, { chat, messageId: messageIds[0] ?? "" }, existing?.plan ?? { kind: "webui" });
|
|
81
134
|
}
|
|
82
|
-
/** Retire pending answers
|
|
83
|
-
async settle(conversationId, callId) {
|
|
135
|
+
/** Retire pending answers; the refs stay until retention expires them. */
|
|
136
|
+
async settle(conversationId, callId, outcome = "answered") {
|
|
84
137
|
const state = await this.file.update((draft) => {
|
|
85
|
-
const keys =
|
|
86
|
-
|
|
87
|
-
.
|
|
88
|
-
|
|
138
|
+
const keys = [
|
|
139
|
+
`${conversationId}:${callId}:`,
|
|
140
|
+
...draft.prompts
|
|
141
|
+
.filter((row) => row.conversationId === conversationId && row.callId === callId)
|
|
142
|
+
.map(identityOf),
|
|
143
|
+
];
|
|
144
|
+
for (const identity of keys) {
|
|
145
|
+
// Re-settling an already-settled interaction keeps the FIRST
|
|
146
|
+
// settlement's timestamp and outcome: retention is measured
|
|
147
|
+
// from settlement, never extended by later writes.
|
|
148
|
+
if (draft.settled[identity] !== undefined)
|
|
149
|
+
continue;
|
|
150
|
+
draft.settled[identity] = { at: this.now(), outcome };
|
|
151
|
+
}
|
|
152
|
+
prune(draft, this.now());
|
|
89
153
|
});
|
|
90
154
|
this.rows = state.prompts;
|
|
91
|
-
this.settled =
|
|
155
|
+
this.settled = state.settled;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* The outcome text recorded at settlement, when this identity is
|
|
159
|
+
* settled. The receipt path reads it to self-heal the prompt edit;
|
|
160
|
+
* the tombstone outlives the bulky row, so the answer survives
|
|
161
|
+
* pruning even when no editable ref remains.
|
|
162
|
+
*/
|
|
163
|
+
settledOutcome(conversationId, callId, key) {
|
|
164
|
+
return this.settled[`${conversationId}:${callId}:${key}`]?.outcome
|
|
165
|
+
?? this.settled[`${conversationId}:${callId}:`]?.outcome;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* THE retention helper (init and every write call this, at most once
|
|
170
|
+
* per write): a SETTLED row expires at `settledAt + RETENTION_MS <= now`.
|
|
171
|
+
* The tombstone itself is never pruned — it is what stops a delayed
|
|
172
|
+
* receipt from recreating an answerable prompt. Pending rows are
|
|
173
|
+
* never age-pruned.
|
|
174
|
+
*/
|
|
175
|
+
function prune(draft, now) {
|
|
176
|
+
draft.prompts = draft.prompts.filter((row) => {
|
|
177
|
+
const tombstone = draft.settled[identityOf(row)];
|
|
178
|
+
if (tombstone === undefined)
|
|
179
|
+
return true;
|
|
180
|
+
return tombstone.at + PROMPT_RETENTION_MS > now;
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Normalize whatever was on disk to the v2 shape. An unversioned v1
|
|
185
|
+
* file keeps every prompt row and stamps its settled identities with
|
|
186
|
+
* the MIGRATION time (one cohort, expiring together seven days later).
|
|
187
|
+
* Individually malformed tombstones are dropped with a log line each;
|
|
188
|
+
* valid unrelated rows are never discarded. A wholly malformed JSON
|
|
189
|
+
* document never reaches here — JsonFile throws naming the path.
|
|
190
|
+
*/
|
|
191
|
+
function migrate(state, now, log) {
|
|
192
|
+
const prompts = Array.isArray(state.prompts) ? state.prompts : [];
|
|
193
|
+
const settled = {};
|
|
194
|
+
const raw = state.settled;
|
|
195
|
+
if (Array.isArray(raw)) {
|
|
196
|
+
// v1: bare identities, age unknown — the migration timestamp is
|
|
197
|
+
// the conservative choice (one further retention window).
|
|
198
|
+
for (const identity of raw) {
|
|
199
|
+
if (typeof identity !== "string")
|
|
200
|
+
continue;
|
|
201
|
+
settled[identity] = { at: now, outcome: "answered" };
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
else if (raw !== null && typeof raw === "object") {
|
|
205
|
+
for (const [identity, value] of Object.entries(raw)) {
|
|
206
|
+
const tombstone = value;
|
|
207
|
+
if (tombstone === null || typeof tombstone !== "object" ||
|
|
208
|
+
typeof tombstone.at !== "number" || !Number.isFinite(tombstone.at) ||
|
|
209
|
+
typeof tombstone.outcome !== "string") {
|
|
210
|
+
log?.warn(`prompts: dropping malformed settled tombstone "${identity}" (its row, if any, stays live)`);
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (!validIdentity(identity)) {
|
|
214
|
+
log?.warn(`prompts: dropping settled tombstone with a malformed identity "${identity}"`);
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
settled[identity] = { at: tombstone.at, outcome: tombstone.outcome };
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
else if (raw !== undefined) {
|
|
221
|
+
log?.warn("prompts: ignoring malformed settled container (expected an array or map)");
|
|
92
222
|
}
|
|
223
|
+
return { version: PROMPT_FILE_VERSION, prompts, settled };
|
|
93
224
|
}
|
|
94
225
|
function identityOf(row) {
|
|
95
226
|
return `${row.conversationId}:${row.callId}:${row.key}`;
|
|
96
227
|
}
|
|
228
|
+
/**
|
|
229
|
+
* An identity is `<conversationId>:<callId>:<key>` — a positive integer
|
|
230
|
+
* conversation id, a non-empty callId without colons, and a non-empty
|
|
231
|
+
* key (opaque binding/home keys may themselves contain colons).
|
|
232
|
+
*/
|
|
233
|
+
function validIdentity(identity) {
|
|
234
|
+
const separator = identity.indexOf(":");
|
|
235
|
+
if (separator <= 0)
|
|
236
|
+
return false;
|
|
237
|
+
if (!/^[1-9][0-9]*$/.test(identity.slice(0, separator)))
|
|
238
|
+
return false;
|
|
239
|
+
const rest = identity.slice(separator + 1);
|
|
240
|
+
const second = rest.indexOf(":");
|
|
241
|
+
// The empty key is the call-level settlement marker, written when
|
|
242
|
+
// settlement precedes prompt-row creation.
|
|
243
|
+
if (second === rest.length - 1)
|
|
244
|
+
return true;
|
|
245
|
+
return second > 0;
|
|
246
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** Map a delivered interaction ledger row back to its recipient key. */
|
|
2
2
|
import type { LedgerEntry } from "../state/ledger.js";
|
|
3
3
|
import type { PromptStore } from "./prompts.js";
|
|
4
|
+
import { type InteractionChatDeps } from "./chat.js";
|
|
4
5
|
export declare function receiptForInteraction(prompts: PromptStore, entry: LedgerEntry, receipt: {
|
|
5
6
|
readonly messageIds: readonly string[];
|
|
6
|
-
}): Promise<void>;
|
|
7
|
+
}, deps?: InteractionChatDeps): Promise<void>;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** Map a delivered interaction ledger row back to its recipient key. */
|
|
2
|
-
|
|
2
|
+
import { enqueueSettlementEdits } from "./chat.js";
|
|
3
|
+
export async function receiptForInteraction(prompts, entry, receipt, deps) {
|
|
3
4
|
// The receipt is authoritative: an early settlement may have removed
|
|
4
5
|
// the pending row, but the delivered message still needs an edit ref.
|
|
5
6
|
if (entry.bindingKey !== undefined) {
|
|
@@ -10,7 +11,9 @@ export async function receiptForInteraction(prompts, entry, receipt) {
|
|
|
10
11
|
if (!match[2].endsWith(suffix))
|
|
11
12
|
return;
|
|
12
13
|
const callId = match[2].slice(0, -suffix.length);
|
|
13
|
-
|
|
14
|
+
const conversationId = Number(match[1]);
|
|
15
|
+
await prompts.recordReceipt(conversationId, callId, entry.bindingKey, entry.chat, receipt.messageIds);
|
|
16
|
+
await healSettledPrompt(prompts, deps, conversationId, callId, entry.bindingKey, receipt.messageIds);
|
|
14
17
|
return;
|
|
15
18
|
}
|
|
16
19
|
const tag = entry.tag;
|
|
@@ -31,6 +34,36 @@ export async function receiptForInteraction(prompts, entry, receipt) {
|
|
|
31
34
|
if (afterConversation !== suffix)
|
|
32
35
|
continue;
|
|
33
36
|
await prompts.recordReceipt(conversationId, row.callId, row.key, entry.chat, receipt.messageIds);
|
|
37
|
+
await healSettledPrompt(prompts, deps, conversationId, row.callId, row.key, receipt.messageIds);
|
|
34
38
|
return;
|
|
35
39
|
}
|
|
36
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* A real receipt for an ALREADY-settled prompt self-heals the live
|
|
43
|
+
* buttons: settlement ran with an empty ref (no edit possible, so a
|
|
44
|
+
* notice went out), and this delivered message is the one that now
|
|
45
|
+
* needs the outcome edit. The edit tag is the same the settlement
|
|
46
|
+
* path would have used, so a repeat receipt stays a no-op.
|
|
47
|
+
*/
|
|
48
|
+
async function healSettledPrompt(prompts, deps, conversationId, callId, key, messageIds) {
|
|
49
|
+
if (deps === undefined)
|
|
50
|
+
return;
|
|
51
|
+
if (messageIds.length === 0 || messageIds[0] === "")
|
|
52
|
+
return;
|
|
53
|
+
const outcome = prompts.settledOutcome(conversationId, callId, key);
|
|
54
|
+
if (outcome === undefined)
|
|
55
|
+
return;
|
|
56
|
+
// Tombstones outlive their bulky rows. A receipt AFTER pruning must
|
|
57
|
+
// be a true no-op: with no surviving row/ref, the settlement helper
|
|
58
|
+
// would otherwise take its fallback and enqueue duplicate notices.
|
|
59
|
+
const ref = prompts.refsOf(conversationId, callId)
|
|
60
|
+
.find((candidate) => candidate.messageId === messageIds[0]);
|
|
61
|
+
if (ref === undefined)
|
|
62
|
+
return;
|
|
63
|
+
try {
|
|
64
|
+
await enqueueSettlementEdits(deps, conversationId, callId, outcome);
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
deps.log.error(`settled prompt receipt heal failed (${conversationId}:${callId})`, error);
|
|
68
|
+
}
|
|
69
|
+
}
|
package/dist/notify.js
CHANGED
|
@@ -32,19 +32,26 @@ async function notify(event, deps) {
|
|
|
32
32
|
if (event.conversationId === undefined)
|
|
33
33
|
return;
|
|
34
34
|
// The settled fan-out owns bound conversations; a second notice
|
|
35
|
-
// here would duplicate the answer the chat just received.
|
|
36
|
-
|
|
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)
|
|
37
41
|
return;
|
|
38
42
|
const meta = await deps.host.conversations.meta(event.conversationId);
|
|
39
43
|
// New-conversation jobs are unbound, but their delivery and
|
|
40
|
-
// job.failed paths own notices.
|
|
41
|
-
//
|
|
42
|
-
//
|
|
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.
|
|
43
48
|
if (meta.jobId !== undefined)
|
|
44
49
|
return;
|
|
45
50
|
}
|
|
46
51
|
const text = event.type === "run.settled"
|
|
47
|
-
?
|
|
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}`}`
|
|
48
55
|
: `${event.title}\n${event.body}${event.url === undefined ? "" : `\n${event.url}`}`;
|
|
49
56
|
for (const home of deps.identities.homesOf(event.user)) {
|
|
50
57
|
await deps.ledger.enqueue({
|
|
@@ -42,6 +42,7 @@ export interface DeliveryDeps {
|
|
|
42
42
|
readonly counts?: {
|
|
43
43
|
delivered(): void;
|
|
44
44
|
failed(): void;
|
|
45
|
+
cancelled?(): void;
|
|
45
46
|
};
|
|
46
47
|
/** Persist a delivered interaction prompt's platform refs for edits/replies. */
|
|
47
48
|
readonly onInteractionReceipt?: (entry: LedgerEntry, receipt: {
|
|
@@ -110,7 +111,13 @@ export declare class DeliveryWorker {
|
|
|
110
111
|
* Returns false when a retry wait was armed (the caller parks).
|
|
111
112
|
*/
|
|
112
113
|
private onSendError;
|
|
113
|
-
/**
|
|
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
|
+
*/
|
|
114
121
|
private markLedger;
|
|
115
122
|
/**
|
|
116
123
|
* The classification read (adapter's `classifyError` when present,
|
|
@@ -160,7 +160,14 @@ export class DeliveryWorker {
|
|
|
160
160
|
// failing ledger write is LOGGED and the send does not run (an
|
|
161
161
|
// unmarked entry must not be sent — the crash window would
|
|
162
162
|
// duplicate it on recovery).
|
|
163
|
-
|
|
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
|
+
}
|
|
164
171
|
const receipt = entry.kind === "edit"
|
|
165
172
|
? await this.boundedEdit(entry, message)
|
|
166
173
|
: await this.boundedSend(message);
|
|
@@ -270,8 +277,8 @@ export class DeliveryWorker {
|
|
|
270
277
|
}
|
|
271
278
|
this.deps.log.warn(`delivery: chat gone for "${entry.channel}:${hashId(entry.chat.chatId)}" — binding marked dead`);
|
|
272
279
|
this.deps.log.debug(`delivery: chat gone for "${entry.channel}:${entry.chat.chatId}" — binding marked dead`);
|
|
273
|
-
await this.markLedger(entry.id, "failed")
|
|
274
|
-
|
|
280
|
+
if (await this.markLedger(entry.id, "failed"))
|
|
281
|
+
this.deps.counts?.failed();
|
|
275
282
|
return true;
|
|
276
283
|
}
|
|
277
284
|
if (kind === "permanent") {
|
|
@@ -280,8 +287,8 @@ export class DeliveryWorker {
|
|
|
280
287
|
: "";
|
|
281
288
|
this.deps.log.error(`delivery: permanent failure ("${entry.kind}" to "${hashId(entry.chat.chatId)}")${fileDetail}`, error);
|
|
282
289
|
this.deps.log.debug(`delivery: permanent failure ("${entry.kind}" to "${entry.chat.chatId}")${fileDetail}`);
|
|
283
|
-
await this.markLedger(entry.id, "failed")
|
|
284
|
-
|
|
290
|
+
if (await this.markLedger(entry.id, "failed"))
|
|
291
|
+
this.deps.counts?.failed();
|
|
285
292
|
return true;
|
|
286
293
|
}
|
|
287
294
|
// Transient: the retry ladder. attempts was already incremented by
|
|
@@ -291,8 +298,8 @@ export class DeliveryWorker {
|
|
|
291
298
|
if (retriesUsed >= this.deps.delays.length) {
|
|
292
299
|
this.deps.log.error(`delivery: retries exhausted ("${entry.kind}" to "${hashId(entry.chat.chatId)}"): ${detail}`);
|
|
293
300
|
this.deps.log.debug(`delivery: retries exhausted ("${entry.kind}" to "${entry.chat.chatId}"): ${detail}`);
|
|
294
|
-
await this.markLedger(entry.id, "failed")
|
|
295
|
-
|
|
301
|
+
if (await this.markLedger(entry.id, "failed"))
|
|
302
|
+
this.deps.counts?.failed();
|
|
296
303
|
return true;
|
|
297
304
|
}
|
|
298
305
|
const delay = this.deps.delays[retriesUsed] ?? 0;
|
|
@@ -300,7 +307,10 @@ export class DeliveryWorker {
|
|
|
300
307
|
// The entry returns to pending (the retry re-pulls it); the timer
|
|
301
308
|
// re-kicks the loop. A stop cancels the timer — the entry stays
|
|
302
309
|
// pending for the next boot's recovery.
|
|
303
|
-
|
|
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;
|
|
304
314
|
if (this.stopped)
|
|
305
315
|
return false;
|
|
306
316
|
this.cancelRetry?.();
|
|
@@ -310,13 +320,20 @@ export class DeliveryWorker {
|
|
|
310
320
|
}, delay);
|
|
311
321
|
return false;
|
|
312
322
|
}
|
|
313
|
-
/**
|
|
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
|
+
*/
|
|
314
330
|
async markLedger(id, state) {
|
|
315
331
|
try {
|
|
316
|
-
await this.ledger.mark(id, state);
|
|
332
|
+
return await this.ledger.mark(id, state);
|
|
317
333
|
}
|
|
318
334
|
catch (markError) {
|
|
319
335
|
this.deps.log.error("delivery: ledger mark failed", markError);
|
|
336
|
+
return false;
|
|
320
337
|
}
|
|
321
338
|
}
|
|
322
339
|
/**
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
*/
|
|
24
24
|
import type { ChannelContribution, RunSettledEvent, SigrunHost } from "@sigrun/sigrun/extension";
|
|
25
25
|
import type { SubmissionId } from "@earendil-works/pi-durable";
|
|
26
|
+
import type { SessionMeta } from "@sigrun/shared/session-meta";
|
|
26
27
|
import type { ChannelConfig } from "../config.js";
|
|
27
28
|
import type { BindingStore } from "../state/bindings.js";
|
|
28
29
|
import type { Ledger } from "../state/ledger.js";
|
|
@@ -44,17 +45,17 @@ export interface Streams {
|
|
|
44
45
|
finalize(conversationId: number, chunks: string[]): Promise<boolean>;
|
|
45
46
|
abandon(conversationId: number): void;
|
|
46
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* The job-delivery metadata the settled fan-out reads. Derived from the
|
|
50
|
+
* canonical shared session-meta wire type (type-only: no runtime core
|
|
51
|
+
* dependency) so field drift fails the contract fixture's typecheck.
|
|
52
|
+
*/
|
|
53
|
+
export type GatewayJobMeta = Pick<SessionMeta, "owner" | "name" | "autoName" | "jobId" | "deliver">;
|
|
47
54
|
/** onRunSettled's injectable dependencies. */
|
|
48
55
|
export interface SettledDeps {
|
|
49
56
|
readonly host: SigrunHost;
|
|
50
57
|
/** The event bus's bridge-free document read (factory-supplied). */
|
|
51
|
-
readonly readMeta?: (conversationId: number) => Promise<
|
|
52
|
-
readonly owner?: string;
|
|
53
|
-
readonly name?: string;
|
|
54
|
-
readonly autoName?: string;
|
|
55
|
-
readonly jobId?: string;
|
|
56
|
-
readonly deliver?: string[];
|
|
57
|
-
} | undefined>;
|
|
58
|
+
readonly readMeta?: (conversationId: number) => Promise<GatewayJobMeta | undefined>;
|
|
58
59
|
/** The engine-wide bindings (the delivery fan-out read). */
|
|
59
60
|
readonly bindings: BindingStore;
|
|
60
61
|
/** The engine-wide identity links (the binding's user resolution). */
|
package/dist/session-meta.d.ts
CHANGED
|
@@ -2,7 +2,17 @@
|
|
|
2
2
|
* The core session-meta document token, re-declared structurally from
|
|
3
3
|
* the shared wire type. Standalone doc tokens compare by kind/scope;
|
|
4
4
|
* declaring it here avoids a runtime import of core's server modules.
|
|
5
|
-
*
|
|
5
|
+
* Version drift is real: pi-durable's checkRecordVersion throws BOTH
|
|
6
|
+
* directions (a newer stored doc, or an older one with no migration),
|
|
7
|
+
* so the engine reads job meta through readJobMeta — a throwing read
|
|
8
|
+
* degrades to absent-meta (logged once) rather than failing the
|
|
9
|
+
* settled fan-out and losing the job delivery.
|
|
6
10
|
*/
|
|
7
11
|
import type { SessionMeta } from "@sigrun/shared/views";
|
|
8
12
|
export declare const SessionMetaDoc: import("@earendil-works/pi-durable").ConversationDocToken<SessionMeta>;
|
|
13
|
+
/**
|
|
14
|
+
* Read job meta conservatively: a version-drift throw (or any other
|
|
15
|
+
* document-read failure) logs the cause ONCE and returns undefined,
|
|
16
|
+
* so job delivery skips cleanly instead of breaking the fan-out.
|
|
17
|
+
*/
|
|
18
|
+
export declare function readJobMeta<T>(read: () => Promise<T>, warn: (message: string) => void): Promise<T | undefined>;
|
package/dist/session-meta.js
CHANGED
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
* The core session-meta document token, re-declared structurally from
|
|
3
3
|
* the shared wire type. Standalone doc tokens compare by kind/scope;
|
|
4
4
|
* declaring it here avoids a runtime import of core's server modules.
|
|
5
|
-
*
|
|
5
|
+
* Version drift is real: pi-durable's checkRecordVersion throws BOTH
|
|
6
|
+
* directions (a newer stored doc, or an older one with no migration),
|
|
7
|
+
* so the engine reads job meta through readJobMeta — a throwing read
|
|
8
|
+
* degrades to absent-meta (logged once) rather than failing the
|
|
9
|
+
* settled fan-out and losing the job delivery.
|
|
6
10
|
*/
|
|
7
11
|
import { defineDoc } from "@earendil-works/pi-durable";
|
|
8
12
|
export const SessionMetaDoc = defineDoc({
|
|
@@ -19,3 +23,17 @@ export const SessionMetaDoc = defineDoc({
|
|
|
19
23
|
source: "user",
|
|
20
24
|
}),
|
|
21
25
|
});
|
|
26
|
+
/**
|
|
27
|
+
* Read job meta conservatively: a version-drift throw (or any other
|
|
28
|
+
* document-read failure) logs the cause ONCE and returns undefined,
|
|
29
|
+
* so job delivery skips cleanly instead of breaking the fan-out.
|
|
30
|
+
*/
|
|
31
|
+
export async function readJobMeta(read, warn) {
|
|
32
|
+
try {
|
|
33
|
+
return await read();
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
warn(`session-meta read failed; job meta treated as absent: ${String(error)}`);
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -14,7 +14,11 @@ import type { EngineLog } from "../log.js";
|
|
|
14
14
|
export type LinkVia = "pairing" | "self";
|
|
15
15
|
/** Core's one provider-key convention for platform senders. */
|
|
16
16
|
export declare function chatProviderOf(channel: string): string;
|
|
17
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* Core admits only this channel-id shape inside a chat provider. This
|
|
19
|
+
* matches the loader's grammar exactly: every binding-key and target
|
|
20
|
+
* parse splits on ":", so a wider alphabet would mis-attribute chats.
|
|
21
|
+
*/
|
|
18
22
|
export declare const CHANNEL_ID_PATTERN: RegExp;
|
|
19
23
|
/** The result of a `userFor` resolution. */
|
|
20
24
|
export interface ResolvedUser {
|
package/dist/state/identities.js
CHANGED
|
@@ -12,8 +12,12 @@ import { JsonFile } from "./json-file.js";
|
|
|
12
12
|
export function chatProviderOf(channel) {
|
|
13
13
|
return `chat:${channel}`;
|
|
14
14
|
}
|
|
15
|
-
/**
|
|
16
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Core admits only this channel-id shape inside a chat provider. This
|
|
17
|
+
* matches the loader's grammar exactly: every binding-key and target
|
|
18
|
+
* parse splits on ":", so a wider alphabet would mis-attribute chats.
|
|
19
|
+
*/
|
|
20
|
+
export const CHANNEL_ID_PATTERN = /^[a-z][a-z0-9-]*$/;
|
|
17
21
|
/** Core-backed sender resolution and engine-owned home-chat persistence. */
|
|
18
22
|
export class IdentityStore {
|
|
19
23
|
host;
|
package/dist/state/ledger.d.ts
CHANGED
|
@@ -36,7 +36,7 @@ export interface SerializedOutbound {
|
|
|
36
36
|
}[];
|
|
37
37
|
}
|
|
38
38
|
/** One ledger row's lifecycle. */
|
|
39
|
-
export type LedgerState = "pending" | "attempting" | "delivered" | "failed";
|
|
39
|
+
export type LedgerState = "pending" | "attempting" | "delivered" | "failed" | "cancelled";
|
|
40
40
|
/** What the entry is for (the worker's send-shape discrimination). */
|
|
41
41
|
export type LedgerKind = "final" | "notice" | "interaction" | "edit";
|
|
42
42
|
/**
|
|
@@ -123,8 +123,32 @@ export declare class Ledger {
|
|
|
123
123
|
* (the worker's loop is a tick, not an await point).
|
|
124
124
|
*/
|
|
125
125
|
nextPending(channel: string): LedgerEntry | undefined;
|
|
126
|
-
/**
|
|
127
|
-
|
|
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>;
|
|
128
152
|
/**
|
|
129
153
|
* Restart recovery: every `attempting` entry becomes `pending` with
|
|
130
154
|
* the `recovered` flag (the worker prefixes the marker at send time).
|
package/dist/state/ledger.js
CHANGED
|
@@ -83,13 +83,61 @@ export class Ledger {
|
|
|
83
83
|
nextPending(channel) {
|
|
84
84
|
return this.entries.find((entry) => entry.channel === channel && entry.state === "pending");
|
|
85
85
|
}
|
|
86
|
-
/**
|
|
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
|
+
*/
|
|
87
131
|
async mark(id, state, receipt) {
|
|
132
|
+
let applied = false;
|
|
88
133
|
await this.file.update((draft) => {
|
|
89
134
|
const index = draft.entries.findIndex((row) => row.id === id);
|
|
90
135
|
if (index === -1)
|
|
91
136
|
return;
|
|
92
137
|
const row = draft.entries[index];
|
|
138
|
+
if (!Ledger.canLeave(row.state, state))
|
|
139
|
+
return;
|
|
140
|
+
applied = true;
|
|
93
141
|
const next = {
|
|
94
142
|
...row,
|
|
95
143
|
state,
|
|
@@ -104,6 +152,7 @@ export class Ledger {
|
|
|
104
152
|
draft.entries[index] = next;
|
|
105
153
|
this.entries = draft.entries;
|
|
106
154
|
});
|
|
155
|
+
return applied;
|
|
107
156
|
}
|
|
108
157
|
/**
|
|
109
158
|
* Restart recovery: every `attempting` entry becomes `pending` with
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sigrun/ext-gateway",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Gateway engine extension for sigrun (pi-durable): chat-transport supervision — channel adapters, inbound pipeline, delivery",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"peerDependencies": {
|
|
28
28
|
"@earendil-works/chord": "1.3.0",
|
|
29
29
|
"@earendil-works/pi-durable": "1.3.0",
|
|
30
|
-
"@sigrun/shared": "0.2.
|
|
30
|
+
"@sigrun/shared": "0.2.1"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
33
|
"@earendil-works/chord": "1.3.0",
|