instar 1.3.731 → 1.3.733
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/dist/commands/server.d.ts.map +1 -1
- package/dist/commands/server.js +208 -2
- package/dist/commands/server.js.map +1 -1
- package/dist/config/ConfigDefaults.d.ts.map +1 -1
- package/dist/config/ConfigDefaults.js +26 -0
- package/dist/config/ConfigDefaults.js.map +1 -1
- package/dist/core/ConversationRegistry.d.ts +355 -0
- package/dist/core/ConversationRegistry.d.ts.map +1 -0
- package/dist/core/ConversationRegistry.js +1438 -0
- package/dist/core/ConversationRegistry.js.map +1 -0
- package/dist/core/PostUpdateMigrator.d.ts +12 -0
- package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
- package/dist/core/PostUpdateMigrator.js +80 -0
- package/dist/core/PostUpdateMigrator.js.map +1 -1
- package/dist/core/SessionManager.d.ts +15 -0
- package/dist/core/SessionManager.d.ts.map +1 -1
- package/dist/core/SessionManager.js +28 -0
- package/dist/core/SessionManager.js.map +1 -1
- package/dist/core/conversationBindToken.d.ts +36 -0
- package/dist/core/conversationBindToken.d.ts.map +1 -0
- package/dist/core/conversationBindToken.js +144 -0
- package/dist/core/conversationBindToken.js.map +1 -0
- package/dist/core/conversationIdentity.d.ts +126 -0
- package/dist/core/conversationIdentity.d.ts.map +1 -0
- package/dist/core/conversationIdentity.js +160 -0
- package/dist/core/conversationIdentity.js.map +1 -0
- package/dist/core/deliverToConversation.d.ts +105 -0
- package/dist/core/deliverToConversation.d.ts.map +1 -0
- package/dist/core/deliverToConversation.js +374 -0
- package/dist/core/deliverToConversation.js.map +1 -0
- package/dist/core/devGatedFeatures.d.ts.map +1 -1
- package/dist/core/devGatedFeatures.js +6 -0
- package/dist/core/devGatedFeatures.js.map +1 -1
- package/dist/core/slackRefreshBinding.d.ts +8 -10
- package/dist/core/slackRefreshBinding.d.ts.map +1 -1
- package/dist/core/slackRefreshBinding.js +8 -16
- package/dist/core/slackRefreshBinding.js.map +1 -1
- package/dist/core/types.d.ts +46 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/monitoring/CommitmentTracker.d.ts +64 -0
- package/dist/monitoring/CommitmentTracker.d.ts.map +1 -1
- package/dist/monitoring/CommitmentTracker.js +58 -0
- package/dist/monitoring/CommitmentTracker.js.map +1 -1
- package/dist/monitoring/PromiseBeacon.d.ts +70 -0
- package/dist/monitoring/PromiseBeacon.d.ts.map +1 -1
- package/dist/monitoring/PromiseBeacon.js +221 -24
- package/dist/monitoring/PromiseBeacon.js.map +1 -1
- package/dist/scaffold/templates.d.ts.map +1 -1
- package/dist/scaffold/templates.js +10 -0
- package/dist/scaffold/templates.js.map +1 -1
- package/dist/server/AgentServer.d.ts +12 -0
- package/dist/server/AgentServer.d.ts.map +1 -1
- package/dist/server/AgentServer.js +20 -0
- package/dist/server/AgentServer.js.map +1 -1
- package/dist/server/CapabilityIndex.d.ts.map +1 -1
- package/dist/server/CapabilityIndex.js +14 -0
- package/dist/server/CapabilityIndex.js.map +1 -1
- package/dist/server/routes.d.ts +17 -0
- package/dist/server/routes.d.ts.map +1 -1
- package/dist/server/routes.js +206 -10
- package/dist/server/routes.js.map +1 -1
- package/package.json +1 -1
- package/src/data/builtin-manifest.json +65 -65
- package/src/scaffold/templates.ts +10 -0
- package/upgrades/1.3.732.md +107 -0
- package/upgrades/1.3.733.md +108 -0
- package/upgrades/side-effects/durable-conversation-identity-increment1.md +89 -0
- package/upgrades/side-effects/durable-conversation-identity-increment2.md +88 -0
|
@@ -0,0 +1,1438 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ConversationRegistry — durable, channel-agnostic conversation identity
|
|
3
|
+
* (docs/specs/durable-conversation-identity.md §3, increment 1: registry +
|
|
4
|
+
* crash-proof journal + eager mint foundation).
|
|
5
|
+
*
|
|
6
|
+
* The registry is the JOIN TABLE between canonical key (string), structured
|
|
7
|
+
* tuple, and minted NEGATIVE numeric id (§2). It is SPARSE: Telegram positive
|
|
8
|
+
* ids are NEVER registered (a positive id IS its own identity, forever);
|
|
9
|
+
* only minted (non-Telegram) conversations live here.
|
|
10
|
+
*
|
|
11
|
+
* Durability model (§3.3/§3.4 — the WAL rule):
|
|
12
|
+
* - The id is assigned SYNCHRONOUSLY in-memory (probe included) against the
|
|
13
|
+
* authoritative cache + reverse index, so the id RETURNED always equals
|
|
14
|
+
* the id that will PERSIST — no misdelivery window.
|
|
15
|
+
* - A PROBED or durable-binding-forced mint append+fsyncs ONE journal line
|
|
16
|
+
* to `<stateDir>/conversation-registry.jsonl` (the §3.4 journal-path PIN:
|
|
17
|
+
* stateDir ROOT, beside shared-state.jsonl — the one shape the deployed
|
|
18
|
+
* BackupManager.expandGlob actually expands, R3-C4) BEFORE the id is
|
|
19
|
+
* handed to the caller.
|
|
20
|
+
* - A pure speculative non-probed mint appends its audit line WITHOUT fsync
|
|
21
|
+
* (§3.4 fsync discipline; §8 audit completeness) — its candidate re-mints
|
|
22
|
+
* deterministically for free after a crash.
|
|
23
|
+
* - The O(N) full-store JSON snapshot (`<stateDir>/state/conversation-registry.json`)
|
|
24
|
+
* is BATCHED off the hot path with a SIZE-ADAPTIVE interval (§3.4 —
|
|
25
|
+
* the CommitmentTracker 2026-06-21 freeze precedent), and moves to an
|
|
26
|
+
* off-loop write past the pinned trigger (>20k entries / >2MB).
|
|
27
|
+
*
|
|
28
|
+
* Increment scope note: the §3.5/§3.5.1 replication merge, the §3.5.2 bind-pin
|
|
29
|
+
* overlay WRITERS, and the §5.0(a) E1 dedup WRITERS land in their own §6.1
|
|
30
|
+
* increments. The journal op ENUM, replay application, and snapshot
|
|
31
|
+
* completeness for those ops ship NOW (§3.4 record framing is frozen), so a
|
|
32
|
+
* later increment's records — and a rollback across one — replay correctly.
|
|
33
|
+
*/
|
|
34
|
+
import fs from 'node:fs';
|
|
35
|
+
import path from 'node:path';
|
|
36
|
+
import { SafeFsExecutor } from './SafeFsExecutor.js';
|
|
37
|
+
import { candidateIdForRoutingKey, canonicalKeyFor, parseCanonicalKey, routingKeyForTuple, SLACK_WORKSPACE_ID_RE, tupleForRoutingKey, tupleKeyFor, walkDisplacement, WORKSPACE_PLACEHOLDER, } from './conversationIdentity.js';
|
|
38
|
+
/** §3.4 record framing — ONE self-contained JSON object per line. FROZEN op enum. */
|
|
39
|
+
export const JOURNAL_OPS = [
|
|
40
|
+
'mint',
|
|
41
|
+
'alias',
|
|
42
|
+
'reachability',
|
|
43
|
+
'bind-pin',
|
|
44
|
+
'bind-release',
|
|
45
|
+
'ambiguous-send',
|
|
46
|
+
'send-retire',
|
|
47
|
+
'send-intent',
|
|
48
|
+
'send-intent-resolved',
|
|
49
|
+
];
|
|
50
|
+
/** §5.0(a) pinned E1 constants (R3-M1/R7-M2). Exported for the §10 tests. */
|
|
51
|
+
export const AMBIGUOUS_DEDUP_TTL_MS = 604800000; // 7 days — safety bound ONLY, never the suppression mechanism (logical lane)
|
|
52
|
+
export const CONTENT_HASH_DEDUP_WINDOW_MS = 900000; // 15 min — the fallback lane's WINDOW (mirrors the Telegram exact-duplicate window)
|
|
53
|
+
/** §5.0(a) pathological TRIPWIRE, not a correctness bound (R4-M2/R5-minor-1):
|
|
54
|
+
* reached-with-all-live raises ONE aggregated attention item while the new
|
|
55
|
+
* entry is STILL journaled + retained — nothing is ever dropped. */
|
|
56
|
+
export const AMBIGUOUS_SENDS_HARD_CAP = 1000;
|
|
57
|
+
/** §5.1 reachability flap dampening (R3-minor). */
|
|
58
|
+
export const REACHABILITY_FLAP_THRESHOLD = 3;
|
|
59
|
+
export const REACHABILITY_FLAP_WINDOW_MS = 86400000; // 24h
|
|
60
|
+
const ENTRY_CEILING = 50000; // §3.4 JSON-store design ceiling
|
|
61
|
+
const ENTRY_THRESHOLD = 40000; // 80% of ceiling — the pinned tripwire (R3-minor)
|
|
62
|
+
const FILE_BYTES_THRESHOLD = 8 * 1024 * 1024; // 80% of ~10MB
|
|
63
|
+
const OFFLOOP_ENTRY_TRIGGER = 20000; // §3.4 pinned off-loop flush trigger
|
|
64
|
+
const OFFLOOP_BYTES_TRIGGER = 2 * 1024 * 1024;
|
|
65
|
+
const PENDING_MINT_MAX = 1000; // §3.6 pinned
|
|
66
|
+
const BREAKER_MAX_TRACKED_CHANNELS = 512; // bounded budget-state map (§3.3, R3-minor)
|
|
67
|
+
export class ConversationRegistry {
|
|
68
|
+
d;
|
|
69
|
+
snapshotPath;
|
|
70
|
+
journalPath;
|
|
71
|
+
// ── Authoritative in-memory cache + synchronous indexes (§3.4 1–2) ──
|
|
72
|
+
conversations = new Map(); // canonical key → entry
|
|
73
|
+
byId = new Map(); // id→entry reverse index
|
|
74
|
+
byTuple = new Map(); // tupleKey → entry
|
|
75
|
+
aliases = new Map(); // loserId → winnerId (one hop)
|
|
76
|
+
// ── Derived indexes (§3.4 3–5) — rebuilt at boot, maintained at assign time ──
|
|
77
|
+
reservedCanonicals = new Map(); // cand → owning tupleKey
|
|
78
|
+
displacedAssignments = new Map(); // offset → owning tupleKey (GLOBAL — R4-C1)
|
|
79
|
+
candClaimants = new Map(); // cand → claimant tupleKeys
|
|
80
|
+
// ── §5.0(a)/§3.5.2 journal-applied state (writers: §6.1 increment 2) ──
|
|
81
|
+
bindPins = new Map();
|
|
82
|
+
ambiguousSends = new Map();
|
|
83
|
+
sendIntents = new Map();
|
|
84
|
+
/** R9-minor-1: parseable send-intent lines with the lane field STRIPPED —
|
|
85
|
+
* resolved toward RETRY at replay, surfaced once (never a suppressor). */
|
|
86
|
+
missingLaneIntents = 0;
|
|
87
|
+
/** §5.1 reachability flap tracking (in-memory, advisory — R3-minor). */
|
|
88
|
+
reachabilityFlips = new Map();
|
|
89
|
+
workspacePin = null;
|
|
90
|
+
// ── Journal state ──
|
|
91
|
+
seqCounter = 0;
|
|
92
|
+
snapshotHighWaterSeq = 0;
|
|
93
|
+
journalFd = null;
|
|
94
|
+
journalBytes = 0;
|
|
95
|
+
journalLines = 0;
|
|
96
|
+
/** Unknown-op skip-and-preserve set (R8-minor-2) → snapshot-flush SUSPENSION (R9-M1/R10-M1). */
|
|
97
|
+
unappliedUnknownOps = [];
|
|
98
|
+
// ── Snapshot batching ──
|
|
99
|
+
snapshotTimer = null;
|
|
100
|
+
snapshotDirty = false;
|
|
101
|
+
lastSnapshotBytes = 0;
|
|
102
|
+
batchDepth = 0; // adoption-pass batched-save window (§6.2)
|
|
103
|
+
flushInFlight = false;
|
|
104
|
+
// ── Breaker + degradation state ──
|
|
105
|
+
breakerWindows = new Map();
|
|
106
|
+
breakerEpisodes = 0;
|
|
107
|
+
pendingMints = new Map();
|
|
108
|
+
pendingMintDrops = 0;
|
|
109
|
+
// ── Observability ──
|
|
110
|
+
lastMintAt = null;
|
|
111
|
+
durabilityIncidents = 0;
|
|
112
|
+
snapshotQuarantinedAt = null;
|
|
113
|
+
journalQuarantinedAt = null;
|
|
114
|
+
adoptionState = null;
|
|
115
|
+
loaded = false;
|
|
116
|
+
constructor(deps) {
|
|
117
|
+
this.d = deps;
|
|
118
|
+
this.snapshotPath = path.join(deps.stateDir, 'state', 'conversation-registry.json');
|
|
119
|
+
this.journalPath = path.join(deps.stateDir, 'conversation-registry.jsonl');
|
|
120
|
+
}
|
|
121
|
+
now() {
|
|
122
|
+
return (this.d.now ?? (() => new Date()))();
|
|
123
|
+
}
|
|
124
|
+
log(line) {
|
|
125
|
+
try {
|
|
126
|
+
this.d.log?.(line);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
/* observability never gates */
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
attention(dedupeKey, title, body) {
|
|
133
|
+
try {
|
|
134
|
+
this.d.onAttention?.(dedupeKey, title, body);
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
/* attention is observability — never gates identity or delivery */
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
recordingEnabled() {
|
|
141
|
+
try {
|
|
142
|
+
return this.d.isRecordingEnabled ? this.d.isRecordingEnabled() !== false : true;
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
149
|
+
// Boot: snapshot load + journal replay (§3.4 WAL crash-consistency contract)
|
|
150
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
151
|
+
/** Idempotent boot-time load: snapshot, then journal tail replay in global
|
|
152
|
+
* `seq` order across rotated files (§6.2 recovery order 1–2). */
|
|
153
|
+
load() {
|
|
154
|
+
if (this.loaded)
|
|
155
|
+
return;
|
|
156
|
+
this.loaded = true;
|
|
157
|
+
try {
|
|
158
|
+
fs.mkdirSync(path.join(this.d.stateDir, 'state'), { recursive: true });
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
/* exists */
|
|
162
|
+
}
|
|
163
|
+
this.loadSnapshot();
|
|
164
|
+
this.replayJournal();
|
|
165
|
+
this.rebuildDerivedIndexes();
|
|
166
|
+
this.applyAliasAssignmentFilter(); // R6-M3 pin 2: the disjointness invariant holds at every BOOT fixpoint
|
|
167
|
+
this.convertOrphanedSendIntents(); // R8-M1 lane-scoped boot conversion (staged post-compose, fsynced before serving — R9-low-3/R10-minor-1)
|
|
168
|
+
this.pruneSendGuardState();
|
|
169
|
+
if (this.missingLaneIntents > 0) {
|
|
170
|
+
this.attention('conversation-registry:missing-lane-intent', 'Conversation registry: send-intent record(s) with a stripped lane field', `${this.missingLaneIntents} parseable send-intent journal record(s) carried no lane discriminator (R9-minor-1). Each was resolved toward RETRY (content-hash treatment — loss is never silent, never a suppressor). This indicates a serialization bug worth a look.`);
|
|
171
|
+
}
|
|
172
|
+
if (this.unappliedUnknownOps.length > 0) {
|
|
173
|
+
const first = this.unappliedUnknownOps[0];
|
|
174
|
+
const kinds = [...new Set(this.unappliedUnknownOps.map((u) => u.op))].join(', ');
|
|
175
|
+
this.attention('conversation-registry:unknown-op', 'Conversation registry journal holds records from a newer version', `${this.unappliedUnknownOps.length} journal record(s) with unrecognized op kind(s) [${kinds}] were skipped-and-preserved (version skew, not corruption — R8-minor-2). SNAPSHOT FLUSHING IS SUSPENDED (R9-M1/R10-M1): the on-disk snapshot stays the pre-skew one (high-water ${this.snapshotHighWaterSeq}, first unapplied seq ${first.seq}) until a recognizing version re-upgrades and replays them in position. Journal retention grows for the suspension's duration.`);
|
|
176
|
+
}
|
|
177
|
+
// Resolve the config workspace pin (source 1 — authoritative when present).
|
|
178
|
+
const configPin = this.readConfigPin();
|
|
179
|
+
if (configPin) {
|
|
180
|
+
this.workspacePin = { value: configPin, source: 'config', confirmedLocally: true };
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
readConfigPin() {
|
|
184
|
+
try {
|
|
185
|
+
const v = this.d.getConfigWorkspacePin?.();
|
|
186
|
+
return v && SLACK_WORKSPACE_ID_RE.test(v) ? v : undefined;
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
return undefined;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
loadSnapshot() {
|
|
193
|
+
if (!fs.existsSync(this.snapshotPath))
|
|
194
|
+
return;
|
|
195
|
+
try {
|
|
196
|
+
const raw = JSON.parse(fs.readFileSync(this.snapshotPath, 'utf-8'));
|
|
197
|
+
if (!raw || typeof raw !== 'object' || raw.version !== 1)
|
|
198
|
+
throw new Error('unrecognized snapshot shape');
|
|
199
|
+
this.snapshotHighWaterSeq = typeof raw.snapshotHighWaterSeq === 'number' ? raw.snapshotHighWaterSeq : 0;
|
|
200
|
+
this.seqCounter = this.snapshotHighWaterSeq;
|
|
201
|
+
if (raw.workspacePin && typeof raw.workspacePin.value === 'string')
|
|
202
|
+
this.workspacePin = raw.workspacePin;
|
|
203
|
+
for (const [key, entry] of Object.entries(raw.conversations ?? {})) {
|
|
204
|
+
if (!entry || typeof entry.id !== 'number' || !(entry.id < 0) || !Number.isSafeInteger(entry.id))
|
|
205
|
+
continue; // id<0 clamp on every write (§3.3)
|
|
206
|
+
this.indexEntry(key, entry);
|
|
207
|
+
}
|
|
208
|
+
for (const [loser, winner] of Object.entries(raw.aliases ?? {})) {
|
|
209
|
+
const l = Number(loser);
|
|
210
|
+
if (Number.isSafeInteger(l) && l < 0 && typeof winner === 'number' && winner < 0)
|
|
211
|
+
this.aliases.set(l, winner);
|
|
212
|
+
}
|
|
213
|
+
for (const [id, pin] of Object.entries(raw.bindPins ?? {})) {
|
|
214
|
+
const n = Number(id);
|
|
215
|
+
if (Number.isSafeInteger(n) && pin && Array.isArray(pin.tuple) && typeof pin.refcount === 'number') {
|
|
216
|
+
this.bindPins.set(n, { tuple: pin.tuple, refcount: pin.refcount });
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
for (const [k, v] of Object.entries(raw.ambiguousSends ?? {})) {
|
|
220
|
+
if (v && typeof v.recordedAt === 'string') {
|
|
221
|
+
this.ambiguousSends.set(k, {
|
|
222
|
+
recordedAt: v.recordedAt,
|
|
223
|
+
...(v.lane === 'logical' || v.lane === 'content-hash' ? { lane: v.lane } : {}),
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
for (const [k, v] of Object.entries(raw.sendIntents ?? {})) {
|
|
228
|
+
if (v && (v.lane === 'logical' || v.lane === 'content-hash') && typeof v.seq === 'number')
|
|
229
|
+
this.sendIntents.set(k, v);
|
|
230
|
+
}
|
|
231
|
+
this.lastSnapshotBytes = fs.statSync(this.snapshotPath).size;
|
|
232
|
+
}
|
|
233
|
+
catch (err) {
|
|
234
|
+
// Corrupt-file quarantine-aside (§3.6 — the TopicPlacementPinStore
|
|
235
|
+
// pattern): preserve aside, ONE deduped attention item, rebuild from the
|
|
236
|
+
// journal (a journal-only rebuild replays every retained file from empty
|
|
237
|
+
// state in global seq order — §3.4 rotation note).
|
|
238
|
+
const aside = `${this.snapshotPath}.corrupt-${Date.now()}`;
|
|
239
|
+
try {
|
|
240
|
+
fs.renameSync(this.snapshotPath, aside);
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
/* best-effort — the report below still fires */
|
|
244
|
+
}
|
|
245
|
+
this.snapshotQuarantinedAt = this.now().toISOString();
|
|
246
|
+
this.snapshotHighWaterSeq = 0;
|
|
247
|
+
this.seqCounter = 0;
|
|
248
|
+
this.attention('conversation-registry:snapshot-corrupt', 'Conversation registry snapshot was corrupt — quarantined aside', `state/conversation-registry.json failed to parse (${err instanceof Error ? err.message : String(err)}) and was preserved at ${aside}. Rebuilding from the journal (§6.2 recovery order: backup restore is the PRIMARY path if the journal is also damaged).`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
/** Retained journal files in replay order: rotated (ascending epoch) then live. */
|
|
252
|
+
journalFiles() {
|
|
253
|
+
const dir = this.d.stateDir;
|
|
254
|
+
const base = path.basename(this.journalPath);
|
|
255
|
+
let names = [];
|
|
256
|
+
try {
|
|
257
|
+
names = fs.readdirSync(dir).filter((n) => n.startsWith(`${base}.`) && /\.\d+$/.test(n));
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
names = [];
|
|
261
|
+
}
|
|
262
|
+
const rotated = names
|
|
263
|
+
.map((n) => ({ n, epoch: Number(n.slice(base.length + 1)) }))
|
|
264
|
+
.sort((a, b) => a.epoch - b.epoch)
|
|
265
|
+
.map((x) => path.join(dir, x.n));
|
|
266
|
+
return fs.existsSync(this.journalPath) ? [...rotated, this.journalPath] : rotated;
|
|
267
|
+
}
|
|
268
|
+
replayJournal() {
|
|
269
|
+
const files = this.journalFiles();
|
|
270
|
+
if (files.length === 0)
|
|
271
|
+
return;
|
|
272
|
+
// Torn-tail handling (§3.4): a crash mid-append leaves the LIVE file's last
|
|
273
|
+
// line unterminated — only a fully-written, newline-terminated line is a
|
|
274
|
+
// committed record. The torn tail is DISCARDED (truncated) so later appends
|
|
275
|
+
// can never fuse onto an uncommitted fragment.
|
|
276
|
+
if (fs.existsSync(this.journalPath)) {
|
|
277
|
+
try {
|
|
278
|
+
const buf = fs.readFileSync(this.journalPath);
|
|
279
|
+
if (buf.length > 0 && buf[buf.length - 1] !== 0x0a) {
|
|
280
|
+
const lastNl = buf.lastIndexOf(0x0a);
|
|
281
|
+
fs.truncateSync(this.journalPath, lastNl === -1 ? 0 : lastNl + 1);
|
|
282
|
+
this.log('[conversation-registry] discarded torn journal tail (uncommitted record)');
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
/* read failure falls through to the per-line handling below */
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
const records = [];
|
|
290
|
+
let halted = false;
|
|
291
|
+
for (const file of files) {
|
|
292
|
+
if (halted)
|
|
293
|
+
break;
|
|
294
|
+
let content;
|
|
295
|
+
try {
|
|
296
|
+
content = fs.readFileSync(file, 'utf-8');
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
const lines = content.split('\n');
|
|
302
|
+
for (let i = 0; i < lines.length; i++) {
|
|
303
|
+
const line = lines[i];
|
|
304
|
+
if (line.length === 0)
|
|
305
|
+
continue;
|
|
306
|
+
let rec;
|
|
307
|
+
try {
|
|
308
|
+
rec = JSON.parse(line);
|
|
309
|
+
if (!rec || typeof rec.seq !== 'number' || typeof rec.op !== 'string')
|
|
310
|
+
throw new Error('malformed record');
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
// NON-tail corruption fails CLOSED (R7-minor-3): a newline-TERMINATED
|
|
314
|
+
// line failing parse is a storage lie, never skipped — HALT the replay
|
|
315
|
+
// into the quarantine-aside path + attention + durability incident.
|
|
316
|
+
const aside = `${file}.corrupt-${Date.now()}`;
|
|
317
|
+
try {
|
|
318
|
+
fs.renameSync(file, aside);
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
/* preserve best-effort */
|
|
322
|
+
}
|
|
323
|
+
this.journalQuarantinedAt = this.now().toISOString();
|
|
324
|
+
this.durabilityIncidents++; // §3.7 broadened SQLite-migration trigger input
|
|
325
|
+
this.attention('conversation-registry:journal-corrupt', 'Conversation registry journal corruption — replay halted', `A committed (newline-terminated) journal record in ${path.basename(file)} failed to parse at line ${i + 1}. The file was preserved aside at ${aside}; replay halted at that point (records before it are applied). This counts as a DURABILITY INCIDENT (§3.7 — evaluate the SQLite migration). Backup restore is the primary recovery path (§6.2).`);
|
|
326
|
+
halted = true;
|
|
327
|
+
break;
|
|
328
|
+
}
|
|
329
|
+
records.push({ rec, file });
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
records.sort((a, b) => a.rec.seq - b.rec.seq); // single global seq order (R3-M14)
|
|
333
|
+
let maxSeq = this.snapshotHighWaterSeq;
|
|
334
|
+
for (const { rec } of records) {
|
|
335
|
+
if (rec.seq > maxSeq)
|
|
336
|
+
maxSeq = rec.seq;
|
|
337
|
+
if (rec.seq <= this.snapshotHighWaterSeq)
|
|
338
|
+
continue; // snapshot already incorporates it
|
|
339
|
+
this.applyJournalRecord(rec);
|
|
340
|
+
}
|
|
341
|
+
// Boot counter resumes from the max seen — never 0/1 (R3-M14).
|
|
342
|
+
this.seqCounter = Math.max(this.seqCounter, maxSeq);
|
|
343
|
+
this.journalBytes = fs.existsSync(this.journalPath) ? fs.statSync(this.journalPath).size : 0;
|
|
344
|
+
this.journalLines = 0;
|
|
345
|
+
if (fs.existsSync(this.journalPath)) {
|
|
346
|
+
try {
|
|
347
|
+
const content = fs.readFileSync(this.journalPath, 'utf-8');
|
|
348
|
+
this.journalLines = content.length === 0 ? 0 : content.split('\n').filter((l) => l.length > 0).length;
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
/* count is rotation bookkeeping only */
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
/** Idempotent, seq-ordered application (§3.4) — safe to re-run any number of times. */
|
|
356
|
+
applyJournalRecord(rec) {
|
|
357
|
+
if (!JOURNAL_OPS.includes(rec.op)) {
|
|
358
|
+
// UNKNOWN-op tolerance (R8-minor-2): version skew, not corruption — skip
|
|
359
|
+
// the application, PRESERVE the line (append-only; never rewritten), and
|
|
360
|
+
// suspend snapshot flushing (R9-M1/R10-M1) until a recognizing version
|
|
361
|
+
// applies it. The deduped attention item is raised once after replay.
|
|
362
|
+
this.unappliedUnknownOps.push({ seq: rec.seq, op: rec.op });
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
switch (rec.op) {
|
|
366
|
+
case 'mint': {
|
|
367
|
+
if (typeof rec.id !== 'number' || !(rec.id < 0) || !Array.isArray(rec.tuple))
|
|
368
|
+
return;
|
|
369
|
+
const tuple = { platform: 'slack', channelId: rec.tuple[1], threadTs: rec.tuple[2] ?? null };
|
|
370
|
+
const tKey = tupleKeyFor(tuple);
|
|
371
|
+
const existing = this.byTuple.get(tKey);
|
|
372
|
+
if (existing) {
|
|
373
|
+
// Re-apply / metadata upgrade (the `_`→teamId upgrade is journaled as
|
|
374
|
+
// op:"mint" with the rewritten key — §3.1): same tuple, refreshed key.
|
|
375
|
+
if (rec.key && rec.key !== canonicalKeyFor(tuple, existing.workspaceId)) {
|
|
376
|
+
const parsed = parseCanonicalKey(rec.key);
|
|
377
|
+
if (parsed && parsed.workspaceId !== existing.workspaceId) {
|
|
378
|
+
this.conversations.delete(canonicalKeyFor(tuple, existing.workspaceId));
|
|
379
|
+
existing.workspaceId = parsed.workspaceId;
|
|
380
|
+
this.conversations.set(rec.key, existing);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return; // idempotent
|
|
384
|
+
}
|
|
385
|
+
const parsed = rec.key ? parseCanonicalKey(rec.key) : null;
|
|
386
|
+
const entry = {
|
|
387
|
+
id: rec.id,
|
|
388
|
+
platform: 'slack',
|
|
389
|
+
workspaceId: parsed?.workspaceId ?? WORKSPACE_PLACEHOLDER,
|
|
390
|
+
channelId: tuple.channelId,
|
|
391
|
+
threadTs: tuple.threadTs,
|
|
392
|
+
mintedAt: rec.ts,
|
|
393
|
+
mintedBy: rec.hlc?.node ?? 'unknown',
|
|
394
|
+
origin: rec.origin ?? 'adopted-legacy-hash',
|
|
395
|
+
reachability: 'ok',
|
|
396
|
+
hlc: rec.hlc ?? { physical: Date.parse(rec.ts) || 0, logical: 0, node: 'unknown' },
|
|
397
|
+
};
|
|
398
|
+
this.indexEntry(canonicalKeyFor(tuple, entry.workspaceId), entry);
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
case 'alias': {
|
|
402
|
+
if (typeof rec.id === 'number' && typeof rec.target === 'number')
|
|
403
|
+
this.aliases.set(rec.id, rec.target);
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
case 'reachability': {
|
|
407
|
+
if (typeof rec.id !== 'number' || !rec.reachability)
|
|
408
|
+
return;
|
|
409
|
+
const entry = this.byId.get(rec.id);
|
|
410
|
+
if (entry)
|
|
411
|
+
entry.reachability = rec.reachability;
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
case 'bind-pin': {
|
|
415
|
+
if (typeof rec.id !== 'number' || !Array.isArray(rec.tuple))
|
|
416
|
+
return;
|
|
417
|
+
const existing = this.bindPins.get(rec.id);
|
|
418
|
+
if (existing)
|
|
419
|
+
existing.refcount = rec.refcount ?? existing.refcount + 1;
|
|
420
|
+
else
|
|
421
|
+
this.bindPins.set(rec.id, { tuple: rec.tuple, refcount: rec.refcount ?? 1 });
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
case 'bind-release': {
|
|
425
|
+
if (typeof rec.id !== 'number')
|
|
426
|
+
return;
|
|
427
|
+
const pin = this.bindPins.get(rec.id);
|
|
428
|
+
if (!pin)
|
|
429
|
+
return;
|
|
430
|
+
pin.refcount = rec.refcount ?? pin.refcount - 1;
|
|
431
|
+
if (pin.refcount <= 0)
|
|
432
|
+
this.bindPins.delete(rec.id);
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
case 'ambiguous-send': {
|
|
436
|
+
if (typeof rec.conversationId !== 'number' || !rec.logicalSendId)
|
|
437
|
+
return;
|
|
438
|
+
this.ambiguousSends.set(`${rec.conversationId}|${rec.logicalSendId}`, {
|
|
439
|
+
recordedAt: rec.ts,
|
|
440
|
+
...(rec.lane === 'logical' || rec.lane === 'content-hash' ? { lane: rec.lane } : {}),
|
|
441
|
+
});
|
|
442
|
+
this.sendIntents.delete(`${rec.conversationId}|${rec.logicalSendId}`);
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
case 'send-retire': {
|
|
446
|
+
if (typeof rec.conversationId !== 'number' || !rec.logicalSendId)
|
|
447
|
+
return;
|
|
448
|
+
this.ambiguousSends.delete(`${rec.conversationId}|${rec.logicalSendId}`);
|
|
449
|
+
this.sendIntents.delete(`${rec.conversationId}|${rec.logicalSendId}`);
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
case 'send-intent': {
|
|
453
|
+
if (typeof rec.conversationId !== 'number' || !rec.logicalSendId)
|
|
454
|
+
return;
|
|
455
|
+
// Malformed/unknown lane resolves toward RETRY (content-hash treatment)
|
|
456
|
+
// — R9-minor-1/R10-low-2 (loss-is-never-silent picks retry). Counted so
|
|
457
|
+
// the post-replay boot pass can raise ONE deduped attention item.
|
|
458
|
+
if (rec.lane !== 'logical' && rec.lane !== 'content-hash')
|
|
459
|
+
this.missingLaneIntents++;
|
|
460
|
+
const lane = rec.lane === 'logical' ? 'logical' : 'content-hash';
|
|
461
|
+
this.sendIntents.set(`${rec.conversationId}|${rec.logicalSendId}`, { lane, seq: rec.seq });
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
case 'send-intent-resolved': {
|
|
465
|
+
if (typeof rec.conversationId !== 'number' || !rec.logicalSendId)
|
|
466
|
+
return;
|
|
467
|
+
this.sendIntents.delete(`${rec.conversationId}|${rec.logicalSendId}`);
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
indexEntry(key, entry) {
|
|
473
|
+
this.conversations.set(key, entry);
|
|
474
|
+
this.byId.set(entry.id, entry);
|
|
475
|
+
this.byTuple.set(tupleKeyFor({ platform: 'slack', channelId: entry.channelId, threadTs: entry.threadTs }), entry);
|
|
476
|
+
}
|
|
477
|
+
/** Derived indexes 3–5 (§3.4) — pure functions of the entry set, rebuilt at boot. */
|
|
478
|
+
rebuildDerivedIndexes() {
|
|
479
|
+
this.reservedCanonicals.clear();
|
|
480
|
+
this.displacedAssignments.clear();
|
|
481
|
+
this.candClaimants.clear();
|
|
482
|
+
for (const entry of this.conversations.values()) {
|
|
483
|
+
const tuple = { platform: 'slack', channelId: entry.channelId, threadTs: entry.threadTs };
|
|
484
|
+
const cand = candidateIdForRoutingKey(routingKeyForTuple(tuple));
|
|
485
|
+
const tKey = tupleKeyFor(tuple);
|
|
486
|
+
let claimants = this.candClaimants.get(cand);
|
|
487
|
+
if (!claimants) {
|
|
488
|
+
claimants = new Set();
|
|
489
|
+
this.candClaimants.set(cand, claimants);
|
|
490
|
+
}
|
|
491
|
+
claimants.add(tKey);
|
|
492
|
+
if (entry.id === cand)
|
|
493
|
+
this.reservedCanonicals.set(cand, tKey);
|
|
494
|
+
else
|
|
495
|
+
this.displacedAssignments.set(entry.id, tKey);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
/** R5-C1/R6-M3: the assignment-beats-alias filter re-run over composed state —
|
|
499
|
+
* an alias shadowing a reserved canonical or an assigned displacement offset
|
|
500
|
+
* is dropped exactly as it would be at ingest. */
|
|
501
|
+
applyAliasAssignmentFilter() {
|
|
502
|
+
for (const aliasId of [...this.aliases.keys()]) {
|
|
503
|
+
if (this.reservedCanonicals.has(aliasId) || this.displacedAssignments.has(aliasId)) {
|
|
504
|
+
this.aliases.delete(aliasId);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
509
|
+
// Journal append (§3.4 — dedicated single-writer discipline, G3)
|
|
510
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
511
|
+
/**
|
|
512
|
+
* Append ONE newline-terminated record. `seq` assignment and the byte-write
|
|
513
|
+
* happen atomically per record under a synchronous append (Node's
|
|
514
|
+
* single-threaded sync I/O IS the append mutex — no interleaving is
|
|
515
|
+
* possible), satisfying the §3.4 G3 single-writer contract.
|
|
516
|
+
*/
|
|
517
|
+
appendJournal(rec, opts) {
|
|
518
|
+
const seq = ++this.seqCounter;
|
|
519
|
+
const line = `${JSON.stringify({ seq, ...rec, ts: this.now().toISOString() })}\n`;
|
|
520
|
+
this.rotateJournalIfNeeded(Buffer.byteLength(line));
|
|
521
|
+
if (this.journalFd === null) {
|
|
522
|
+
const created = !fs.existsSync(this.journalPath);
|
|
523
|
+
this.journalFd = fs.openSync(this.journalPath, 'a');
|
|
524
|
+
if (created)
|
|
525
|
+
this.fsyncDir(); // directory entry durable on file creation (§3.4)
|
|
526
|
+
}
|
|
527
|
+
fs.writeSync(this.journalFd, line);
|
|
528
|
+
this.journalBytes += Buffer.byteLength(line);
|
|
529
|
+
this.journalLines += 1;
|
|
530
|
+
if (opts.fsync && !(this.d.isJournalFsyncDisabled?.() ?? false)) {
|
|
531
|
+
try {
|
|
532
|
+
fs.fsyncSync(this.journalFd);
|
|
533
|
+
}
|
|
534
|
+
catch {
|
|
535
|
+
/* macOS platform footnote (§3.4): cheap-fsync is the deliberate choice */
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
return seq;
|
|
539
|
+
}
|
|
540
|
+
fsyncDir() {
|
|
541
|
+
try {
|
|
542
|
+
const dirFd = fs.openSync(this.d.stateDir, 'r');
|
|
543
|
+
try {
|
|
544
|
+
fs.fsyncSync(dirFd);
|
|
545
|
+
}
|
|
546
|
+
finally {
|
|
547
|
+
fs.closeSync(dirFd);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
catch {
|
|
551
|
+
/* @silent-fallback-ok — directory fsync is best-effort, platform-dependent (§3.4 macOS footnote); the record append itself is the durability authority */
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
rotateJournalIfNeeded(incomingBytes) {
|
|
555
|
+
const rotateBytes = this.d.journalRotateBytes ?? 8388608;
|
|
556
|
+
const rotateLines = this.d.journalRotateLines ?? 50000;
|
|
557
|
+
if (this.journalBytes + incomingBytes <= rotateBytes && this.journalLines < rotateLines)
|
|
558
|
+
return;
|
|
559
|
+
if (!fs.existsSync(this.journalPath))
|
|
560
|
+
return;
|
|
561
|
+
if (this.journalFd !== null) {
|
|
562
|
+
try {
|
|
563
|
+
fs.fsyncSync(this.journalFd);
|
|
564
|
+
}
|
|
565
|
+
catch {
|
|
566
|
+
/* @silent-fallback-ok — pre-rotation fsync is best-effort; every durable record already fsynced at append time (§3.3 WAL rule) */
|
|
567
|
+
}
|
|
568
|
+
fs.closeSync(this.journalFd);
|
|
569
|
+
this.journalFd = null;
|
|
570
|
+
}
|
|
571
|
+
// Unique all-digit suffix (two rotations can land in one millisecond; the
|
|
572
|
+
// suffix must keep matching the retained-file scan + the backup glob).
|
|
573
|
+
let suffix = Date.now();
|
|
574
|
+
while (fs.existsSync(`${this.journalPath}.${suffix}`))
|
|
575
|
+
suffix++;
|
|
576
|
+
const rotatedPath = `${this.journalPath}.${suffix}`;
|
|
577
|
+
fs.renameSync(this.journalPath, rotatedPath);
|
|
578
|
+
this.fsyncDir();
|
|
579
|
+
this.journalBytes = 0;
|
|
580
|
+
this.journalLines = 0;
|
|
581
|
+
this.pruneRotatedJournals();
|
|
582
|
+
}
|
|
583
|
+
/**
|
|
584
|
+
* Prune ONLY fully-superseded rotated files: every record ≤ the PERSISTED
|
|
585
|
+
* snapshot high-water, older than the 7-day retention floor (§8 — a recovery
|
|
586
|
+
* requirement), and containing no unapplied unknown-op record (R8-minor-2).
|
|
587
|
+
* Under snapshot suspension the persisted high-water stays pre-skew, so every
|
|
588
|
+
* file the eventual re-upgrade needs is retained MECHANICALLY (R10-M1).
|
|
589
|
+
*/
|
|
590
|
+
pruneRotatedJournals() {
|
|
591
|
+
const persistedHighWater = this.readPersistedHighWater();
|
|
592
|
+
const retentionFloorMs = 7 * 24 * 60 * 60 * 1000;
|
|
593
|
+
const unknownSeqs = new Set(this.unappliedUnknownOps.map((u) => u.seq));
|
|
594
|
+
for (const file of this.journalFiles()) {
|
|
595
|
+
if (file === this.journalPath)
|
|
596
|
+
continue;
|
|
597
|
+
try {
|
|
598
|
+
const stat = fs.statSync(file);
|
|
599
|
+
if (this.now().getTime() - stat.mtimeMs < retentionFloorMs)
|
|
600
|
+
continue;
|
|
601
|
+
const content = fs.readFileSync(file, 'utf-8');
|
|
602
|
+
let maxSeq = 0;
|
|
603
|
+
let hasUnknown = false;
|
|
604
|
+
for (const line of content.split('\n')) {
|
|
605
|
+
if (!line)
|
|
606
|
+
continue;
|
|
607
|
+
try {
|
|
608
|
+
const rec = JSON.parse(line);
|
|
609
|
+
if (typeof rec.seq === 'number' && rec.seq > maxSeq)
|
|
610
|
+
maxSeq = rec.seq;
|
|
611
|
+
if (!JOURNAL_OPS.includes(rec.op) || unknownSeqs.has(rec.seq))
|
|
612
|
+
hasUnknown = true;
|
|
613
|
+
}
|
|
614
|
+
catch {
|
|
615
|
+
hasUnknown = true; // @silent-fallback-ok — fails toward RETENTION: a file we cannot fully read is never pruned (§3.4)
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
if (!hasUnknown && maxSeq > 0 && maxSeq <= persistedHighWater) {
|
|
619
|
+
SafeFsExecutor.safeUnlinkSync(file, { operation: 'conversation-registry journal rotation prune (fully-superseded rotated file — §3.4)' });
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
catch {
|
|
623
|
+
/* @silent-fallback-ok — prune is hygiene; a failed prune retains the file (the safe direction), never gates identity */
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
readPersistedHighWater() {
|
|
628
|
+
try {
|
|
629
|
+
const raw = JSON.parse(fs.readFileSync(this.snapshotPath, 'utf-8'));
|
|
630
|
+
return typeof raw?.snapshotHighWaterSeq === 'number' ? raw.snapshotHighWaterSeq : 0;
|
|
631
|
+
}
|
|
632
|
+
catch {
|
|
633
|
+
// @silent-fallback-ok — an unreadable snapshot reads as high-water 0, so the
|
|
634
|
+
// prune rule supersedes NOTHING (fails toward retaining every journal file).
|
|
635
|
+
return 0;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
639
|
+
// Snapshot (batched, size-adaptive — §3.4)
|
|
640
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
641
|
+
scheduleSnapshot() {
|
|
642
|
+
this.snapshotDirty = true;
|
|
643
|
+
if (this.batchDepth > 0)
|
|
644
|
+
return; // batched-save window (§6.2 adoption pass)
|
|
645
|
+
if (this.unappliedUnknownOps.length > 0)
|
|
646
|
+
return; // SUSPENSION (R9-M1/R10-M1)
|
|
647
|
+
if (this.snapshotTimer)
|
|
648
|
+
return;
|
|
649
|
+
const base = this.d.snapshotBaseIntervalMs ?? 2000;
|
|
650
|
+
const step = this.d.snapshotAdaptiveStep ?? 5000;
|
|
651
|
+
const max = this.d.snapshotMaxIntervalMs ?? 60000;
|
|
652
|
+
const interval = Math.min(Math.max(base * Math.ceil(Math.max(this.conversations.size, 1) / step), base), max);
|
|
653
|
+
this.snapshotTimer = setTimeout(() => {
|
|
654
|
+
this.snapshotTimer = null;
|
|
655
|
+
void this.flushSnapshot();
|
|
656
|
+
}, interval);
|
|
657
|
+
this.snapshotTimer.unref?.();
|
|
658
|
+
}
|
|
659
|
+
/** Force a flush now (shutdown/tests). Honors the unknown-op suspension. */
|
|
660
|
+
async flushSnapshot() {
|
|
661
|
+
if (this.flushInFlight)
|
|
662
|
+
return;
|
|
663
|
+
if (this.unappliedUnknownOps.length > 0)
|
|
664
|
+
return; // pre-skew snapshot stays put (R10-M1)
|
|
665
|
+
this.flushInFlight = true;
|
|
666
|
+
try {
|
|
667
|
+
const shape = {
|
|
668
|
+
version: 1,
|
|
669
|
+
snapshotHighWaterSeq: this.seqCounter,
|
|
670
|
+
...(this.workspacePin ? { workspacePin: this.workspacePin } : {}),
|
|
671
|
+
conversations: Object.fromEntries(this.conversations),
|
|
672
|
+
aliases: Object.fromEntries([...this.aliases].map(([k, v]) => [String(k), v])),
|
|
673
|
+
bindPins: Object.fromEntries([...this.bindPins].map(([k, v]) => [String(k), v])),
|
|
674
|
+
ambiguousSends: Object.fromEntries(this.ambiguousSends),
|
|
675
|
+
sendIntents: Object.fromEntries(this.sendIntents),
|
|
676
|
+
};
|
|
677
|
+
const serialized = JSON.stringify(shape, null, 2);
|
|
678
|
+
const tmp = `${this.snapshotPath}.tmp`;
|
|
679
|
+
if (this.conversations.size > OFFLOOP_ENTRY_TRIGGER || this.lastSnapshotBytes > OFFLOOP_BYTES_TRIGGER) {
|
|
680
|
+
// §3.4 pinned off-loop trigger: async write of the pre-serialized buffer.
|
|
681
|
+
await fs.promises.writeFile(tmp, serialized);
|
|
682
|
+
await fs.promises.rename(tmp, this.snapshotPath);
|
|
683
|
+
}
|
|
684
|
+
else {
|
|
685
|
+
fs.writeFileSync(tmp, serialized);
|
|
686
|
+
fs.renameSync(tmp, this.snapshotPath);
|
|
687
|
+
}
|
|
688
|
+
this.snapshotHighWaterSeq = shape.snapshotHighWaterSeq;
|
|
689
|
+
this.lastSnapshotBytes = Buffer.byteLength(serialized);
|
|
690
|
+
this.snapshotDirty = false;
|
|
691
|
+
this.maybeRaiseGrowthTripwire();
|
|
692
|
+
}
|
|
693
|
+
catch (err) {
|
|
694
|
+
this.log(`[conversation-registry] snapshot flush failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
695
|
+
}
|
|
696
|
+
finally {
|
|
697
|
+
this.flushInFlight = false;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
maybeRaiseGrowthTripwire() {
|
|
701
|
+
if (this.conversations.size >= ENTRY_THRESHOLD || this.lastSnapshotBytes >= FILE_BYTES_THRESHOLD) {
|
|
702
|
+
this.attention('conversation-registry:growth-threshold', 'Conversation registry approaching its JSON-store ceiling', `entryCount=${this.conversations.size} (ceiling ~${ENTRY_CEILING}) / fileSizeBytes=${this.lastSnapshotBytes}. The §11.10 append-journal-as-primary / SQLite migration must land BEFORE the ceiling (scalability-G2).`);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
/** Batched-save window for the adoption pass / bursts (§3.4/§6.2): one flush. */
|
|
706
|
+
beginBatch() {
|
|
707
|
+
this.batchDepth++;
|
|
708
|
+
}
|
|
709
|
+
endBatch() {
|
|
710
|
+
this.batchDepth = Math.max(0, this.batchDepth - 1);
|
|
711
|
+
if (this.batchDepth === 0 && this.snapshotDirty)
|
|
712
|
+
this.scheduleSnapshot();
|
|
713
|
+
}
|
|
714
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
715
|
+
// Mint (§3.3)
|
|
716
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
717
|
+
/** §3.3 `candidateCollides` — pure O(1) lookups, never a live-tuple scan:
|
|
718
|
+
* (a) reserved canonical of a DIFFERENT tuple; (b) alias table; (c) the
|
|
719
|
+
* GLOBAL displaced-assignment set (R4-C1). */
|
|
720
|
+
candidateCollides(id, tKey) {
|
|
721
|
+
const reserved = this.reservedCanonicals.get(id);
|
|
722
|
+
if (reserved !== undefined && reserved !== tKey)
|
|
723
|
+
return true;
|
|
724
|
+
if (this.aliases.has(id))
|
|
725
|
+
return true;
|
|
726
|
+
const displaced = this.displacedAssignments.get(id);
|
|
727
|
+
if (displaced !== undefined && displaced !== tKey)
|
|
728
|
+
return true;
|
|
729
|
+
return false;
|
|
730
|
+
}
|
|
731
|
+
/** Resolve the fleet workspace pin (§3.1 order: config → stored candidate). */
|
|
732
|
+
resolvedWorkspacePin() {
|
|
733
|
+
const configPin = this.readConfigPin();
|
|
734
|
+
if (configPin)
|
|
735
|
+
return { value: configPin, confirmedLocally: true };
|
|
736
|
+
return this.workspacePin;
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* Workspace admission for a mint (§3.1). Returns the workspaceId to record
|
|
740
|
+
* (concrete or `_`), or 'refused' on the per-machine multi-workspace gate.
|
|
741
|
+
*/
|
|
742
|
+
admitWorkspace() {
|
|
743
|
+
let observed;
|
|
744
|
+
try {
|
|
745
|
+
observed = this.d.getLocalWorkspaceId?.();
|
|
746
|
+
}
|
|
747
|
+
catch {
|
|
748
|
+
// @silent-fallback-ok — a throwing workspace source degrades to the `_`
|
|
749
|
+
// placeholder (upgrades in place later, §3.1) — never a blocked mint.
|
|
750
|
+
observed = undefined;
|
|
751
|
+
}
|
|
752
|
+
if (!observed || !SLACK_WORKSPACE_ID_RE.test(observed))
|
|
753
|
+
return WORKSPACE_PLACEHOLDER;
|
|
754
|
+
const pin = this.resolvedWorkspacePin();
|
|
755
|
+
if (!pin) {
|
|
756
|
+
// First concrete LOCAL observation writes the candidate and is immediately
|
|
757
|
+
// confirmed for it — self-corroboration is the designed single-machine /
|
|
758
|
+
// first-machine path (R6-minor-5).
|
|
759
|
+
this.workspacePin = { value: observed, source: 'local-observed', confirmedLocally: true };
|
|
760
|
+
this.scheduleSnapshot();
|
|
761
|
+
return observed;
|
|
762
|
+
}
|
|
763
|
+
if (pin.value !== observed && pin.confirmedLocally) {
|
|
764
|
+
this.attention('conversation-registry:multi-workspace', 'Second Slack workspace refused (multi-workspace-unsupported)', `A mint arrived authenticated to workspace ${observed}, but this fleet's pin is ${pin.value}. Phase 1 supports exactly ONE Slack workspace (§3.1); Slack Connect / multi-workspace identity is Phase 7.1. Check conversationIdentity.workspacePin.`);
|
|
765
|
+
return 'refused';
|
|
766
|
+
}
|
|
767
|
+
return observed;
|
|
768
|
+
}
|
|
769
|
+
/** Breaker window state for a channel — bounded map with stale-window eviction (§3.3, R3-minor). */
|
|
770
|
+
breakerWindow(channelId) {
|
|
771
|
+
const windowMs = this.d.breaker?.windowMs ?? 600000;
|
|
772
|
+
const nowMs = this.now().getTime();
|
|
773
|
+
let w = this.breakerWindows.get(channelId);
|
|
774
|
+
if (!w || nowMs - w.windowStartMs >= windowMs) {
|
|
775
|
+
w = { windowStartMs: nowMs, speculative: 0, durable: 0, episodeNotified: false };
|
|
776
|
+
if (!this.breakerWindows.has(channelId) && this.breakerWindows.size >= BREAKER_MAX_TRACKED_CHANNELS) {
|
|
777
|
+
// Evict the stalest window — never a monotonic map (Bounded Blast Radius).
|
|
778
|
+
let oldest = null;
|
|
779
|
+
let oldestStart = Infinity;
|
|
780
|
+
for (const [k, v] of this.breakerWindows) {
|
|
781
|
+
if (v.windowStartMs < oldestStart) {
|
|
782
|
+
oldestStart = v.windowStartMs;
|
|
783
|
+
oldest = k;
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
if (oldest)
|
|
787
|
+
this.breakerWindows.delete(oldest);
|
|
788
|
+
}
|
|
789
|
+
this.breakerWindows.set(channelId, w);
|
|
790
|
+
}
|
|
791
|
+
return w;
|
|
792
|
+
}
|
|
793
|
+
/**
|
|
794
|
+
* Speculative (inbound-triggered) get-or-create mint (§6.3 eager mint).
|
|
795
|
+
* NEVER throws and never blocks delivery — every failure degrades toward
|
|
796
|
+
* "no durable id" or a collision-checked read (§3.6).
|
|
797
|
+
*/
|
|
798
|
+
mintForInbound(routingKey, opts) {
|
|
799
|
+
this.load();
|
|
800
|
+
const tuple = tupleForRoutingKey(routingKey);
|
|
801
|
+
if (!tuple)
|
|
802
|
+
return { id: null, created: false, registered: false, degraded: 'unparseable-key' };
|
|
803
|
+
return this.mintTuple(tuple, { durableBinding: false, label: opts?.label, origin: undefined });
|
|
804
|
+
}
|
|
805
|
+
/**
|
|
806
|
+
* Durable-binding-forced mint (§3.3 breaker carve-out): registered REGARDLESS
|
|
807
|
+
* of the speculative budget, journal line fsynced BEFORE the id returns (the
|
|
808
|
+
* WAL rule). Its OWN higher budget yields a typed capacity refusal at the
|
|
809
|
+
* cap — never a silent drop (adversarial-B).
|
|
810
|
+
*/
|
|
811
|
+
mintForDurableBinding(routingKey, opts) {
|
|
812
|
+
this.load();
|
|
813
|
+
const tuple = tupleForRoutingKey(routingKey);
|
|
814
|
+
if (!tuple)
|
|
815
|
+
return { ok: false, error: 'unparseable-key' };
|
|
816
|
+
if (!this.recordingEnabled()) {
|
|
817
|
+
// R2-integration-§9: an unjournaled bind would be unresolvable after a
|
|
818
|
+
// restart — refuse, typed + loud. Positive Telegram binds are unaffected
|
|
819
|
+
// (they never reach the registry).
|
|
820
|
+
this.attention('conversation-registry:recording-disabled-bind', 'Durable bind on a minted conversation refused — recording is disabled', 'conversationIdentity.recording.enabled is false (the emergency kill-switch). A durable-state open on a MINTED id is refused while recording is off (typed conversation-recording-disabled): an unjournaled bind would silently die on restart. Re-enable recording to restore minted binds.');
|
|
821
|
+
return { ok: false, error: 'conversation-recording-disabled' };
|
|
822
|
+
}
|
|
823
|
+
const existing = this.byTuple.get(tupleKeyFor(tuple));
|
|
824
|
+
if (existing) {
|
|
825
|
+
this.maybeUpgradeWorkspace(existing);
|
|
826
|
+
// WAL rule at durable-bind time (§3.3): a pre-existing SPECULATIVE entry
|
|
827
|
+
// rode the batched snapshot only — its journal line may not exist yet. A
|
|
828
|
+
// durable binding is not re-derivable after a crash, so ensure the entry
|
|
829
|
+
// is durably journaled NOW (idempotent op:"mint" re-apply; fsynced)
|
|
830
|
+
// BEFORE the id is handed to the binding.
|
|
831
|
+
this.appendJournal({
|
|
832
|
+
op: 'mint',
|
|
833
|
+
key: canonicalKeyFor(tuple, existing.workspaceId),
|
|
834
|
+
tuple: [tuple.platform, tuple.channelId, tuple.threadTs],
|
|
835
|
+
id: existing.id,
|
|
836
|
+
origin: existing.origin,
|
|
837
|
+
hlc: existing.hlc,
|
|
838
|
+
}, { fsync: true });
|
|
839
|
+
return { ok: true, id: existing.id, created: false };
|
|
840
|
+
}
|
|
841
|
+
const w = this.breakerWindow(tuple.channelId);
|
|
842
|
+
const durableCap = this.d.breaker?.durableBindingPerWindow ?? 50;
|
|
843
|
+
if (w.durable >= durableCap) {
|
|
844
|
+
this.attention('conversation-registry:durable-capacity', 'Durable-binding mint budget exhausted for a channel', `Channel ${tuple.channelId} exceeded ${durableCap} durable-binding registrations in the window. The binding-open is refused with a typed conversation-registration-capacity error — never a silent drop (§3.3 adversarial-B).`);
|
|
845
|
+
return { ok: false, error: 'conversation-registration-capacity' };
|
|
846
|
+
}
|
|
847
|
+
const res = this.mintTuple(tuple, { durableBinding: true, label: opts?.label });
|
|
848
|
+
if (res.id === null || !res.registered) {
|
|
849
|
+
if (res.degraded === 'multi-workspace-unsupported')
|
|
850
|
+
return { ok: false, error: 'multi-workspace-unsupported' };
|
|
851
|
+
return { ok: false, error: 'mint-failure' };
|
|
852
|
+
}
|
|
853
|
+
w.durable++;
|
|
854
|
+
return { ok: true, id: res.id, created: res.created };
|
|
855
|
+
}
|
|
856
|
+
/** §3.1 in-place `_`→teamId upgrade — LOCAL authenticated source ONLY. */
|
|
857
|
+
maybeUpgradeWorkspace(entry) {
|
|
858
|
+
if (entry.workspaceId !== WORKSPACE_PLACEHOLDER)
|
|
859
|
+
return;
|
|
860
|
+
const admitted = this.admitWorkspace();
|
|
861
|
+
if (admitted === 'refused' || admitted === WORKSPACE_PLACEHOLDER)
|
|
862
|
+
return;
|
|
863
|
+
const tuple = { platform: 'slack', channelId: entry.channelId, threadTs: entry.threadTs };
|
|
864
|
+
const oldKey = canonicalKeyFor(tuple, entry.workspaceId);
|
|
865
|
+
this.conversations.delete(oldKey);
|
|
866
|
+
entry.workspaceId = admitted;
|
|
867
|
+
const newKey = canonicalKeyFor(tuple, admitted);
|
|
868
|
+
this.conversations.set(newKey, entry);
|
|
869
|
+
// Journaled (§3.1) as an idempotent op:"mint" re-apply with the rewritten
|
|
870
|
+
// key (the op enum has no distinct upgrade op — §3.4/R6-low-2 precedent).
|
|
871
|
+
this.appendJournal({
|
|
872
|
+
op: 'mint',
|
|
873
|
+
key: newKey,
|
|
874
|
+
tuple: [tuple.platform, tuple.channelId, tuple.threadTs],
|
|
875
|
+
id: entry.id,
|
|
876
|
+
origin: entry.origin,
|
|
877
|
+
hlc: entry.hlc,
|
|
878
|
+
}, { fsync: false });
|
|
879
|
+
this.scheduleSnapshot();
|
|
880
|
+
this.log(`[conversation-registry] workspace upgraded in place: ${oldKey} → ${newKey} (id ${entry.id})`);
|
|
881
|
+
}
|
|
882
|
+
mintTuple(tuple, opts) {
|
|
883
|
+
const tKey = tupleKeyFor(tuple);
|
|
884
|
+
const existing = this.byTuple.get(tKey);
|
|
885
|
+
if (existing) {
|
|
886
|
+
this.maybeUpgradeWorkspace(existing);
|
|
887
|
+
if (opts.label && opts.label !== existing.label) {
|
|
888
|
+
existing.label = opts.label; // write-on-change (§3.4 G4)
|
|
889
|
+
this.scheduleSnapshot();
|
|
890
|
+
}
|
|
891
|
+
// §5.1 reachability auto-clear: an authenticated inbound on the tuple is
|
|
892
|
+
// positive evidence the conversation is reachable again (re-invited /
|
|
893
|
+
// un-archived channel is never stuck).
|
|
894
|
+
if (existing.reachability === 'unreachable')
|
|
895
|
+
this.setReachability(existing.id, 'ok');
|
|
896
|
+
return { id: existing.id, created: false, registered: true };
|
|
897
|
+
}
|
|
898
|
+
const routingKey = routingKeyForTuple(tuple);
|
|
899
|
+
const candidate = candidateIdForRoutingKey(routingKey);
|
|
900
|
+
const workspace = this.admitWorkspace();
|
|
901
|
+
if (workspace === 'refused') {
|
|
902
|
+
return { id: null, created: false, registered: false, degraded: 'multi-workspace-unsupported' };
|
|
903
|
+
}
|
|
904
|
+
if (!this.recordingEnabled()) {
|
|
905
|
+
// D1 degradation (§3.6): behavior-identical to legacy hashing — candidate
|
|
906
|
+
// + collision-checked read (B6), NO durable write, NO journal fsync.
|
|
907
|
+
return { id: this.collisionCheckedRead(candidate, tKey), created: false, registered: false, degraded: 'recording-disabled' };
|
|
908
|
+
}
|
|
909
|
+
if (!opts.durableBinding) {
|
|
910
|
+
const w = this.breakerWindow(tuple.channelId);
|
|
911
|
+
const cap = this.d.breaker?.speculativePerWindow ?? 200;
|
|
912
|
+
if (w.speculative >= cap) {
|
|
913
|
+
// The breaker DROPS speculative registrations to NOWHERE (zero pending
|
|
914
|
+
// state) — the candidate re-mints for free on a later inbound. Delivery
|
|
915
|
+
// still proceeds on a collision-checked read (B6).
|
|
916
|
+
this.breakerEpisodes++;
|
|
917
|
+
if (!w.episodeNotified) {
|
|
918
|
+
w.episodeNotified = true;
|
|
919
|
+
this.attention('conversation-registry:mint-breaker', 'Mint-rate breaker engaged for a channel', `Channel ${tuple.channelId} exceeded ${cap} new speculative conversation registrations in the window. Further inbound registrations this window are dropped (they re-mint later); delivery is unaffected (§3.3 Bounded Blast Radius).`);
|
|
920
|
+
}
|
|
921
|
+
return { id: this.collisionCheckedRead(candidate, tKey), created: false, registered: false, degraded: 'breaker-dropped' };
|
|
922
|
+
}
|
|
923
|
+
w.speculative++;
|
|
924
|
+
}
|
|
925
|
+
const walk = walkDisplacement(candidate, (id) => this.candidateCollides(id, tKey));
|
|
926
|
+
if (!walk.ok) {
|
|
927
|
+
// Probe overflow (astronomically unlikely) degrades to the §3.6
|
|
928
|
+
// pending-mint path — never a silently-un-ingestable id.
|
|
929
|
+
const key = canonicalKeyFor(tuple, workspace);
|
|
930
|
+
if (!this.pendingMints.has(key)) {
|
|
931
|
+
if (this.pendingMints.size >= PENDING_MINT_MAX)
|
|
932
|
+
this.pendingMintDrops++;
|
|
933
|
+
else
|
|
934
|
+
this.pendingMints.set(key, { firstAt: this.now().toISOString() });
|
|
935
|
+
}
|
|
936
|
+
return { id: null, created: false, registered: false, degraded: 'probe-overflow' };
|
|
937
|
+
}
|
|
938
|
+
const probed = walk.probes > 0;
|
|
939
|
+
const nowIso = this.now().toISOString();
|
|
940
|
+
const machineId = this.safeMachineId();
|
|
941
|
+
const entry = {
|
|
942
|
+
id: walk.id,
|
|
943
|
+
platform: 'slack',
|
|
944
|
+
workspaceId: workspace,
|
|
945
|
+
channelId: tuple.channelId,
|
|
946
|
+
threadTs: tuple.threadTs,
|
|
947
|
+
mintedAt: nowIso,
|
|
948
|
+
mintedBy: machineId,
|
|
949
|
+
// A fresh non-probed local mint's id IS the legacy-hash id — the honest
|
|
950
|
+
// origin within the frozen §3.4 enum; probed mints are 'minted-probed'.
|
|
951
|
+
origin: opts.origin ?? (probed ? 'minted-probed' : 'adopted-legacy-hash'),
|
|
952
|
+
reachability: 'ok',
|
|
953
|
+
hlc: { physical: this.now().getTime(), logical: 0, node: machineId },
|
|
954
|
+
...(opts.label ? { label: opts.label } : {}),
|
|
955
|
+
};
|
|
956
|
+
const key = canonicalKeyFor(tuple, workspace);
|
|
957
|
+
// Synchronous assignment: authoritative cache + reverse + tuple index +
|
|
958
|
+
// derived occupancy — the id RETURNED equals the id that will PERSIST.
|
|
959
|
+
this.indexEntry(key, entry);
|
|
960
|
+
let claimants = this.candClaimants.get(candidate);
|
|
961
|
+
if (!claimants) {
|
|
962
|
+
claimants = new Set();
|
|
963
|
+
this.candClaimants.set(candidate, claimants);
|
|
964
|
+
}
|
|
965
|
+
claimants.add(tKey);
|
|
966
|
+
if (!probed)
|
|
967
|
+
this.reservedCanonicals.set(candidate, tKey);
|
|
968
|
+
else
|
|
969
|
+
this.displacedAssignments.set(walk.id, tKey);
|
|
970
|
+
// WAL rule (§3.3): a PROBED or durable-binding-forced mint append+fsyncs
|
|
971
|
+
// ONE journal line BEFORE the id is returned (not re-derivable after a
|
|
972
|
+
// crash). A pure SPECULATIVE non-probed mint performs NO synchronous
|
|
973
|
+
// journal write — it rides the batched snapshot only (§3.4 fsync
|
|
974
|
+
// discipline; its candidate re-mints deterministically for free).
|
|
975
|
+
if (probed || opts.durableBinding) {
|
|
976
|
+
this.appendJournal({
|
|
977
|
+
op: 'mint',
|
|
978
|
+
key,
|
|
979
|
+
tuple: [tuple.platform, tuple.channelId, tuple.threadTs],
|
|
980
|
+
id: entry.id,
|
|
981
|
+
origin: entry.origin,
|
|
982
|
+
hlc: entry.hlc,
|
|
983
|
+
}, { fsync: true });
|
|
984
|
+
}
|
|
985
|
+
this.pendingMints.delete(key);
|
|
986
|
+
this.lastMintAt = nowIso;
|
|
987
|
+
this.scheduleSnapshot();
|
|
988
|
+
return { id: entry.id, created: true, registered: true };
|
|
989
|
+
}
|
|
990
|
+
safeMachineId() {
|
|
991
|
+
try {
|
|
992
|
+
return this.d.machineId() || 'unknown';
|
|
993
|
+
}
|
|
994
|
+
catch {
|
|
995
|
+
return 'unknown';
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
/**
|
|
999
|
+
* B6 collision-checked read for degraded paths (breaker-drop / recording-off):
|
|
1000
|
+
* never delivers on a raw candidate occupied by a DIFFERENT tuple — resolves
|
|
1001
|
+
* via the same key-derived probe for the READ ONLY (no registration).
|
|
1002
|
+
*/
|
|
1003
|
+
collisionCheckedRead(candidate, tKey) {
|
|
1004
|
+
const walk = walkDisplacement(candidate, (id) => this.candidateCollides(id, tKey));
|
|
1005
|
+
return walk.ok ? walk.id : candidate;
|
|
1006
|
+
}
|
|
1007
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1008
|
+
// Resolution (§2/§8)
|
|
1009
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1010
|
+
/** `resolve(id)`: positive → Telegram pass-through; negative → registry
|
|
1011
|
+
* lookup, aliases followed exactly ONE hop; unknown → null. */
|
|
1012
|
+
resolve(id) {
|
|
1013
|
+
this.load();
|
|
1014
|
+
if (!Number.isSafeInteger(id) || id === 0)
|
|
1015
|
+
return null;
|
|
1016
|
+
if (id > 0)
|
|
1017
|
+
return { platform: 'telegram', topicId: id, passThrough: true };
|
|
1018
|
+
const aliasTarget = this.aliases.get(id);
|
|
1019
|
+
const entry = this.byId.get(aliasTarget ?? id);
|
|
1020
|
+
if (!entry)
|
|
1021
|
+
return null;
|
|
1022
|
+
return {
|
|
1023
|
+
platform: 'slack',
|
|
1024
|
+
id: entry.id,
|
|
1025
|
+
key: canonicalKeyFor({ platform: 'slack', channelId: entry.channelId, threadTs: entry.threadTs }, entry.workspaceId),
|
|
1026
|
+
channelId: entry.channelId,
|
|
1027
|
+
threadTs: entry.threadTs,
|
|
1028
|
+
workspaceId: entry.workspaceId,
|
|
1029
|
+
origin: entry.origin,
|
|
1030
|
+
reachability: entry.reachability,
|
|
1031
|
+
...(entry.label !== undefined ? { label: entry.label } : {}),
|
|
1032
|
+
...(aliasTarget !== undefined ? { aliasOf: aliasTarget } : {}),
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
1035
|
+
/** Forward lookup by canonical key OR transport sessionKey — mints NOTHING (§8). */
|
|
1036
|
+
resolveByKey(keyOrSessionKey) {
|
|
1037
|
+
this.load();
|
|
1038
|
+
if (/^\d+$/.test(keyOrSessionKey)) {
|
|
1039
|
+
return { platform: 'telegram', topicId: Number(keyOrSessionKey), passThrough: true };
|
|
1040
|
+
}
|
|
1041
|
+
const parsed = parseCanonicalKey(keyOrSessionKey);
|
|
1042
|
+
const tuple = parsed?.tuple ?? tupleForRoutingKey(keyOrSessionKey);
|
|
1043
|
+
if (!tuple)
|
|
1044
|
+
return null;
|
|
1045
|
+
const entry = this.byTuple.get(tupleKeyFor(tuple));
|
|
1046
|
+
return entry ? this.resolve(entry.id) : null;
|
|
1047
|
+
}
|
|
1048
|
+
/**
|
|
1049
|
+
* §4 READ-ONLY id for a routing key (integration-nit): the existing entry's
|
|
1050
|
+
* id, else the B6 collision-checked candidate — NEVER a registration. A pure
|
|
1051
|
+
* comparison callsite must not acquire a get-or-create WRITE side-effect.
|
|
1052
|
+
*/
|
|
1053
|
+
readIdForRoutingKey(routingKey) {
|
|
1054
|
+
this.load();
|
|
1055
|
+
const tuple = tupleForRoutingKey(routingKey);
|
|
1056
|
+
if (!tuple)
|
|
1057
|
+
return null;
|
|
1058
|
+
const tKey = tupleKeyFor(tuple);
|
|
1059
|
+
const existing = this.byTuple.get(tKey);
|
|
1060
|
+
if (existing)
|
|
1061
|
+
return existing.id;
|
|
1062
|
+
return this.collisionCheckedRead(candidateIdForRoutingKey(routingKey), tKey);
|
|
1063
|
+
}
|
|
1064
|
+
/**
|
|
1065
|
+
* `idForSessionKey` — GET-OR-CREATE (§6.0 #12, a named mint chokepoint).
|
|
1066
|
+
* Positive numeric session keys pass through; Slack routing keys mint.
|
|
1067
|
+
*/
|
|
1068
|
+
idForSessionKey(sessionKey) {
|
|
1069
|
+
this.load();
|
|
1070
|
+
if (/^\d+$/.test(sessionKey))
|
|
1071
|
+
return Number(sessionKey);
|
|
1072
|
+
return this.mintForInbound(sessionKey).id;
|
|
1073
|
+
}
|
|
1074
|
+
/** Read-only inventory for GET /conversations (§8). */
|
|
1075
|
+
list(opts) {
|
|
1076
|
+
this.load();
|
|
1077
|
+
const limit = Math.max(1, Math.min(opts?.limit ?? 500, 5000));
|
|
1078
|
+
const out = [];
|
|
1079
|
+
for (const [key, entry] of this.conversations) {
|
|
1080
|
+
if (opts?.platform && entry.platform !== opts.platform)
|
|
1081
|
+
continue;
|
|
1082
|
+
out.push({ key, entry });
|
|
1083
|
+
if (out.length >= limit)
|
|
1084
|
+
break;
|
|
1085
|
+
}
|
|
1086
|
+
return out;
|
|
1087
|
+
}
|
|
1088
|
+
aliasTable() {
|
|
1089
|
+
this.load();
|
|
1090
|
+
return Object.fromEntries([...this.aliases].map(([k, v]) => [String(k), v]));
|
|
1091
|
+
}
|
|
1092
|
+
entryCount() {
|
|
1093
|
+
this.load();
|
|
1094
|
+
return this.conversations.size;
|
|
1095
|
+
}
|
|
1096
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1097
|
+
// Bind-pin overlay (§3.5.2 — increment 2 writers)
|
|
1098
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1099
|
+
/**
|
|
1100
|
+
* Record a durable bind on a minted id (§3.5.2 property 2/4): the pin binds
|
|
1101
|
+
* the id to the TUPLE it resolves to AT BIND TIME, refcounted per live
|
|
1102
|
+
* binding, journaled (`op:"bind-pin"`, fsynced — the bind's own WAL line).
|
|
1103
|
+
* Returns the bind-time tuple (the caller denormalizes it as `boundTuple`
|
|
1104
|
+
* onto the binding record — property 5), or null when the id is unresolvable.
|
|
1105
|
+
*/
|
|
1106
|
+
bindPin(id) {
|
|
1107
|
+
this.load();
|
|
1108
|
+
const entry = this.byId.get(this.aliases.get(id) ?? id);
|
|
1109
|
+
if (!entry)
|
|
1110
|
+
return null;
|
|
1111
|
+
const tuple = ['slack', entry.channelId, entry.threadTs];
|
|
1112
|
+
const pin = this.bindPins.get(id);
|
|
1113
|
+
const refcount = pin ? pin.refcount + 1 : 1;
|
|
1114
|
+
if (pin)
|
|
1115
|
+
pin.refcount = refcount;
|
|
1116
|
+
else
|
|
1117
|
+
this.bindPins.set(id, { tuple, refcount });
|
|
1118
|
+
this.appendJournal({ op: 'bind-pin', id, tuple, refcount }, { fsync: true });
|
|
1119
|
+
this.scheduleSnapshot();
|
|
1120
|
+
return { platform: 'slack', channelId: entry.channelId, threadTs: entry.threadTs };
|
|
1121
|
+
}
|
|
1122
|
+
/** Release one refcount on a bind-pin (`op:"bind-release"`, §3.5.2 property 4).
|
|
1123
|
+
* The pin is released only at ZERO — a commitment closing never strands a
|
|
1124
|
+
* still-live sibling binding on the same id. No-op on a missing pin. */
|
|
1125
|
+
bindRelease(id) {
|
|
1126
|
+
this.load();
|
|
1127
|
+
const pin = this.bindPins.get(id);
|
|
1128
|
+
if (!pin)
|
|
1129
|
+
return;
|
|
1130
|
+
const refcount = pin.refcount - 1;
|
|
1131
|
+
this.appendJournal({ op: 'bind-release', id, refcount }, { fsync: true });
|
|
1132
|
+
if (refcount <= 0)
|
|
1133
|
+
this.bindPins.delete(id);
|
|
1134
|
+
else
|
|
1135
|
+
pin.refcount = refcount;
|
|
1136
|
+
this.scheduleSnapshot();
|
|
1137
|
+
}
|
|
1138
|
+
/** The live bind-pin tuple for an id (delivery-time overlay read — §3.5.2 property 3). */
|
|
1139
|
+
getBindPinTuple(id) {
|
|
1140
|
+
this.load();
|
|
1141
|
+
const pin = this.bindPins.get(id);
|
|
1142
|
+
if (!pin)
|
|
1143
|
+
return null;
|
|
1144
|
+
return { platform: 'slack', channelId: pin.tuple[1], threadTs: pin.tuple[2] ?? null };
|
|
1145
|
+
}
|
|
1146
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1147
|
+
// E1 ambiguous-outcome idempotency guard state (§5.0(a) — increment 2 writers)
|
|
1148
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1149
|
+
sendGuardKey(conversationId, logicalSendId) {
|
|
1150
|
+
return `${conversationId}|${logicalSendId}`;
|
|
1151
|
+
}
|
|
1152
|
+
/**
|
|
1153
|
+
* Durable SEND-INTENT (R6-M1): appended+fsynced BEFORE the transport send,
|
|
1154
|
+
* stamped with the caller's lane (R8-M1 — the boot-conversion discriminator,
|
|
1155
|
+
* never inferred from the id's shape at boot).
|
|
1156
|
+
*/
|
|
1157
|
+
recordSendIntent(conversationId, logicalSendId, lane) {
|
|
1158
|
+
this.load();
|
|
1159
|
+
const seq = this.appendJournal({ op: 'send-intent', conversationId, logicalSendId, lane }, { fsync: true });
|
|
1160
|
+
this.sendIntents.set(this.sendGuardKey(conversationId, logicalSendId), { lane, seq });
|
|
1161
|
+
this.scheduleSnapshot();
|
|
1162
|
+
}
|
|
1163
|
+
/**
|
|
1164
|
+
* Record the E1 suppressor entry — ONLY on a likely-posted outcome (success
|
|
1165
|
+
* or ambiguous/ack-lost; NEVER a clean transient failure — R2-security-NEW-3).
|
|
1166
|
+
* Durable (`op:"ambiguous-send"`, fsynced — R4-M2); resolves the pair's
|
|
1167
|
+
* send-intent. The in-memory map is the loaded image of this durable state.
|
|
1168
|
+
*/
|
|
1169
|
+
recordLikelyPosted(conversationId, logicalSendId, lane) {
|
|
1170
|
+
this.load();
|
|
1171
|
+
this.pruneSendGuardState();
|
|
1172
|
+
const key = this.sendGuardKey(conversationId, logicalSendId);
|
|
1173
|
+
this.appendJournal({ op: 'ambiguous-send', conversationId, logicalSendId, lane }, { fsync: true });
|
|
1174
|
+
this.ambiguousSends.set(key, { recordedAt: this.now().toISOString(), lane });
|
|
1175
|
+
this.sendIntents.delete(key);
|
|
1176
|
+
if (this.ambiguousSends.size > AMBIGUOUS_SENDS_HARD_CAP) {
|
|
1177
|
+
// Pathological tripwire (R4-M2 scoped per R5-minor-1): loud, aggregated,
|
|
1178
|
+
// and the new entry is STILL journaled + retained — nothing dropped, so a
|
|
1179
|
+
// silent eviction can never re-open the double-post.
|
|
1180
|
+
this.attention('conversation-registry:send-guard-cap', 'E1 send-guard entry population exceeded its pathological tripwire', `${this.ambiguousSends.size} unretired/unexpired ambiguous-send entries exceed the ${AMBIGUOUS_SENDS_HARD_CAP} cap (§5.0(a)). Live entries are bounded by open beacon commitments (~maxActiveBeacons) by construction — this population means an upstream cap was raised or a retirement path is broken. No entry was evicted or dropped.`);
|
|
1181
|
+
}
|
|
1182
|
+
this.scheduleSnapshot();
|
|
1183
|
+
}
|
|
1184
|
+
/** Retire a logical send (`op:"send-retire"` — delivered outcome advanced the
|
|
1185
|
+
* seq, or the commitment closed). Idempotent; also resolves the intent. */
|
|
1186
|
+
retireSend(conversationId, logicalSendId) {
|
|
1187
|
+
this.load();
|
|
1188
|
+
const key = this.sendGuardKey(conversationId, logicalSendId);
|
|
1189
|
+
if (!this.ambiguousSends.has(key) && !this.sendIntents.has(key))
|
|
1190
|
+
return;
|
|
1191
|
+
this.appendJournal({ op: 'send-retire', conversationId, logicalSendId }, { fsync: true });
|
|
1192
|
+
this.ambiguousSends.delete(key);
|
|
1193
|
+
this.sendIntents.delete(key);
|
|
1194
|
+
this.scheduleSnapshot();
|
|
1195
|
+
}
|
|
1196
|
+
/** Resolve a send-intent WITHOUT a suppressor (`op:"send-intent-resolved"`) —
|
|
1197
|
+
* the clean-transient-failure path (positive evidence the message never
|
|
1198
|
+
* posted), so the retry is NOT suppressed (R2-security-NEW-3/R6-M1). */
|
|
1199
|
+
resolveSendIntent(conversationId, logicalSendId) {
|
|
1200
|
+
this.load();
|
|
1201
|
+
const key = this.sendGuardKey(conversationId, logicalSendId);
|
|
1202
|
+
if (!this.sendIntents.has(key))
|
|
1203
|
+
return;
|
|
1204
|
+
this.appendJournal({ op: 'send-intent-resolved', conversationId, logicalSendId }, { fsync: true });
|
|
1205
|
+
this.sendIntents.delete(key);
|
|
1206
|
+
this.scheduleSnapshot();
|
|
1207
|
+
}
|
|
1208
|
+
/**
|
|
1209
|
+
* The E1 suppression read (§5.0(a) R3-M1/R7-M2): the LOGICAL lane is
|
|
1210
|
+
* retirement-based (an unretired entry suppresses whenever the re-fire
|
|
1211
|
+
* arrives, bounded only by the 7-day TTL backstop); the CONTENT-HASH lane is
|
|
1212
|
+
* WINDOW-based (a windowless caller has nothing to retire — outside the
|
|
1213
|
+
* 15-min window the same text delivers again).
|
|
1214
|
+
*/
|
|
1215
|
+
isSendSuppressed(conversationId, logicalSendId, lane) {
|
|
1216
|
+
this.load();
|
|
1217
|
+
const entry = this.ambiguousSends.get(this.sendGuardKey(conversationId, logicalSendId));
|
|
1218
|
+
if (!entry)
|
|
1219
|
+
return false;
|
|
1220
|
+
const age = this.now().getTime() - Date.parse(entry.recordedAt);
|
|
1221
|
+
if (!Number.isFinite(age))
|
|
1222
|
+
return false;
|
|
1223
|
+
return lane === 'content-hash' ? age < CONTENT_HASH_DEDUP_WINDOW_MS : age < AMBIGUOUS_DEDUP_TTL_MS;
|
|
1224
|
+
}
|
|
1225
|
+
/** Prune RETIRED/EXPIRED stragglers only (never a live unretired logical
|
|
1226
|
+
* entry below its TTL — R4-M2 second arm). An expired content-hash entry
|
|
1227
|
+
* prunes exactly like a retired one (R7-M2). */
|
|
1228
|
+
pruneSendGuardState() {
|
|
1229
|
+
const nowMs = this.now().getTime();
|
|
1230
|
+
let pruned = false;
|
|
1231
|
+
for (const [key, entry] of this.ambiguousSends) {
|
|
1232
|
+
const age = nowMs - Date.parse(entry.recordedAt);
|
|
1233
|
+
if (!Number.isFinite(age))
|
|
1234
|
+
continue;
|
|
1235
|
+
const horizon = entry.lane === 'content-hash' ? CONTENT_HASH_DEDUP_WINDOW_MS : AMBIGUOUS_DEDUP_TTL_MS;
|
|
1236
|
+
if (age > horizon) {
|
|
1237
|
+
this.ambiguousSends.delete(key);
|
|
1238
|
+
pruned = true;
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
if (pruned)
|
|
1242
|
+
this.scheduleSnapshot();
|
|
1243
|
+
}
|
|
1244
|
+
/**
|
|
1245
|
+
* R8-M1 lane-scoped boot conversion of crash-orphaned send-intents: an intent
|
|
1246
|
+
* that is the LAST word for its (conversationId, logicalSendId) PAIR (R7-M3
|
|
1247
|
+
* composite keying — replay already resolved every superseded pair) resolves
|
|
1248
|
+
* BY ITS RECORDED LANE: `logical` (beacon) converts into an ambiguous-send
|
|
1249
|
+
* suppressor (the outcome is genuinely unknown and the beacon has a next
|
|
1250
|
+
* tick); `content-hash` resolves toward RETRY (the suppressed send IS the
|
|
1251
|
+
* message — replay appends the missing send-intent-resolved durably, so the
|
|
1252
|
+
* verdict is decided once, not re-decided every boot). Staged appends land
|
|
1253
|
+
* POST-compose, fsynced before the registry begins serving (R9-low-3 /
|
|
1254
|
+
* R10-minor-1).
|
|
1255
|
+
*/
|
|
1256
|
+
convertOrphanedSendIntents() {
|
|
1257
|
+
if (this.sendIntents.size === 0)
|
|
1258
|
+
return;
|
|
1259
|
+
for (const [key, intent] of [...this.sendIntents]) {
|
|
1260
|
+
const sep = key.indexOf('|'); // conversationId prefix is numeric — the FIRST '|' always delimits (§3.4)
|
|
1261
|
+
if (sep <= 0) {
|
|
1262
|
+
this.sendIntents.delete(key);
|
|
1263
|
+
continue;
|
|
1264
|
+
}
|
|
1265
|
+
const conversationId = Number(key.slice(0, sep));
|
|
1266
|
+
const logicalSendId = key.slice(sep + 1);
|
|
1267
|
+
if (!Number.isSafeInteger(conversationId) || !logicalSendId) {
|
|
1268
|
+
this.sendIntents.delete(key);
|
|
1269
|
+
continue;
|
|
1270
|
+
}
|
|
1271
|
+
if (intent.lane === 'logical') {
|
|
1272
|
+
this.appendJournal({ op: 'ambiguous-send', conversationId, logicalSendId, lane: 'logical' }, { fsync: true });
|
|
1273
|
+
this.ambiguousSends.set(key, { recordedAt: this.now().toISOString(), lane: 'logical' });
|
|
1274
|
+
}
|
|
1275
|
+
else {
|
|
1276
|
+
this.appendJournal({ op: 'send-intent-resolved', conversationId, logicalSendId }, { fsync: true });
|
|
1277
|
+
}
|
|
1278
|
+
this.sendIntents.delete(key);
|
|
1279
|
+
}
|
|
1280
|
+
this.scheduleSnapshot();
|
|
1281
|
+
}
|
|
1282
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1283
|
+
// Reachability (§5.1 — increment 2 writer)
|
|
1284
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1285
|
+
/**
|
|
1286
|
+
* Flip an entry's LOCAL-authoritative reachability (§5.1). IDEMPOTENT
|
|
1287
|
+
* (already-in-state → no write) and journaled WITHOUT fsync — it rides the
|
|
1288
|
+
* same batched flush, so a mass event coalesces into one write, never an
|
|
1289
|
+
* O(N) write storm on the failure path (scalability-F2). Returns whether the
|
|
1290
|
+
* state changed and whether the conversation is in the R3-minor FLAP-DAMPENED
|
|
1291
|
+
* state (>3 flips in 24h — the caller coalesces its dead-letter attention
|
|
1292
|
+
* into one per-window flap item instead of one per episode).
|
|
1293
|
+
*/
|
|
1294
|
+
setReachability(id, state) {
|
|
1295
|
+
this.load();
|
|
1296
|
+
const entry = this.byId.get(this.aliases.get(id) ?? id);
|
|
1297
|
+
if (!entry)
|
|
1298
|
+
return { changed: false, dampened: false };
|
|
1299
|
+
const nowMs = this.now().getTime();
|
|
1300
|
+
const cutoff = nowMs - REACHABILITY_FLAP_WINDOW_MS;
|
|
1301
|
+
const flips = (this.reachabilityFlips.get(entry.id) ?? []).filter((t) => t > cutoff);
|
|
1302
|
+
if (entry.reachability === state) {
|
|
1303
|
+
this.reachabilityFlips.set(entry.id, flips);
|
|
1304
|
+
return { changed: false, dampened: flips.length > REACHABILITY_FLAP_THRESHOLD };
|
|
1305
|
+
}
|
|
1306
|
+
flips.push(nowMs);
|
|
1307
|
+
this.reachabilityFlips.set(entry.id, flips);
|
|
1308
|
+
entry.reachability = state;
|
|
1309
|
+
this.appendJournal({ op: 'reachability', id: entry.id, reachability: state }, { fsync: false });
|
|
1310
|
+
this.scheduleSnapshot();
|
|
1311
|
+
return { changed: true, dampened: flips.length > REACHABILITY_FLAP_THRESHOLD };
|
|
1312
|
+
}
|
|
1313
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1314
|
+
// Adoption pass (§6.2)
|
|
1315
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1316
|
+
/**
|
|
1317
|
+
* Idempotent boot-time ensure: pre-register channel-level conversations from
|
|
1318
|
+
* the channel registry with their legacy-hash ids, inside ONE batched-save
|
|
1319
|
+
* window. GATED to channels with ≥1 authorized-sender message on record
|
|
1320
|
+
* (security-B8) — an auto-join channel mints lazily on its first authorized
|
|
1321
|
+
* inbound instead. A pre-population CONVENIENCE, not a recovery requirement.
|
|
1322
|
+
*/
|
|
1323
|
+
runAdoptionPass(channels, hasAuthorizedTraffic) {
|
|
1324
|
+
this.load();
|
|
1325
|
+
let adopted = 0;
|
|
1326
|
+
let skippedUnauthorized = 0;
|
|
1327
|
+
this.beginBatch();
|
|
1328
|
+
try {
|
|
1329
|
+
for (const ch of channels) {
|
|
1330
|
+
let authorized = false;
|
|
1331
|
+
try {
|
|
1332
|
+
authorized = hasAuthorizedTraffic(ch.channelId);
|
|
1333
|
+
}
|
|
1334
|
+
catch {
|
|
1335
|
+
authorized = false; // fail toward not-pre-minting (it mints lazily later)
|
|
1336
|
+
}
|
|
1337
|
+
if (!authorized) {
|
|
1338
|
+
skippedUnauthorized++;
|
|
1339
|
+
continue;
|
|
1340
|
+
}
|
|
1341
|
+
const res = this.mintForInbound(ch.channelId, ch.name ? { label: ch.name } : undefined);
|
|
1342
|
+
if (res.created)
|
|
1343
|
+
adopted++;
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
finally {
|
|
1347
|
+
this.endBatch();
|
|
1348
|
+
}
|
|
1349
|
+
this.adoptionState = { ranAt: this.now().toISOString(), adopted, skippedUnauthorized };
|
|
1350
|
+
this.log(`[conversation-registry] adoption pass: ${adopted} adopted, ${skippedUnauthorized} skipped (no authorized traffic)`);
|
|
1351
|
+
return { adopted, skippedUnauthorized };
|
|
1352
|
+
}
|
|
1353
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1354
|
+
// Health (§8 — the e2e "feature is alive" target)
|
|
1355
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1356
|
+
health() {
|
|
1357
|
+
this.load();
|
|
1358
|
+
const byOrigin = {};
|
|
1359
|
+
const byPlatform = {};
|
|
1360
|
+
for (const entry of this.conversations.values()) {
|
|
1361
|
+
byOrigin[entry.origin] = (byOrigin[entry.origin] ?? 0) + 1;
|
|
1362
|
+
byPlatform[entry.platform] = (byPlatform[entry.platform] ?? 0) + 1;
|
|
1363
|
+
}
|
|
1364
|
+
let fileSizeBytes = 0;
|
|
1365
|
+
try {
|
|
1366
|
+
fileSizeBytes = fs.existsSync(this.snapshotPath) ? fs.statSync(this.snapshotPath).size : 0;
|
|
1367
|
+
}
|
|
1368
|
+
catch {
|
|
1369
|
+
/* observability */
|
|
1370
|
+
}
|
|
1371
|
+
let retainedJournalBytes = 0;
|
|
1372
|
+
try {
|
|
1373
|
+
for (const f of this.journalFiles())
|
|
1374
|
+
retainedJournalBytes += fs.statSync(f).size;
|
|
1375
|
+
}
|
|
1376
|
+
catch {
|
|
1377
|
+
/* observability */
|
|
1378
|
+
}
|
|
1379
|
+
return {
|
|
1380
|
+
// Resident-heap honesty (§3.4): entryCount is the heap axis — resident
|
|
1381
|
+
// heap is plausibly 5–10× fileSizeBytes at the 100k envelope.
|
|
1382
|
+
entryCount: this.conversations.size,
|
|
1383
|
+
fileSizeBytes,
|
|
1384
|
+
byPlatform,
|
|
1385
|
+
byOrigin,
|
|
1386
|
+
aliasCount: this.aliases.size,
|
|
1387
|
+
lastMintAt: this.lastMintAt,
|
|
1388
|
+
adoptionPass: this.adoptionState,
|
|
1389
|
+
recordingEnabled: this.recordingEnabled(),
|
|
1390
|
+
workspacePin: this.workspacePin ? { value: this.workspacePin.value, source: this.workspacePin.source } : null,
|
|
1391
|
+
mintBudget: {
|
|
1392
|
+
channelsTracked: this.breakerWindows.size,
|
|
1393
|
+
breakerEpisodes: this.breakerEpisodes,
|
|
1394
|
+
},
|
|
1395
|
+
pendingMints: { count: this.pendingMints.size, dropped: this.pendingMintDrops },
|
|
1396
|
+
quarantine: {
|
|
1397
|
+
snapshotQuarantinedAt: this.snapshotQuarantinedAt,
|
|
1398
|
+
journalQuarantinedAt: this.journalQuarantinedAt,
|
|
1399
|
+
durabilityIncidents: this.durabilityIncidents,
|
|
1400
|
+
},
|
|
1401
|
+
// §5.0(a)/§3.5.2 increment-2 observability.
|
|
1402
|
+
sendGuard: {
|
|
1403
|
+
unretiredEntries: this.ambiguousSends.size,
|
|
1404
|
+
unresolvedIntents: this.sendIntents.size,
|
|
1405
|
+
bindPins: this.bindPins.size,
|
|
1406
|
+
},
|
|
1407
|
+
// Snapshot-suspension observability (R11-low-1).
|
|
1408
|
+
snapshotSuspended: this.unappliedUnknownOps.length > 0,
|
|
1409
|
+
firstUnappliedUnknownSeq: this.unappliedUnknownOps[0]?.seq ?? null,
|
|
1410
|
+
unappliedUnknownCount: this.unappliedUnknownOps.length,
|
|
1411
|
+
retainedJournalBytes,
|
|
1412
|
+
ceiling: {
|
|
1413
|
+
entryCeiling: ENTRY_CEILING,
|
|
1414
|
+
thresholdReached: this.conversations.size >= ENTRY_THRESHOLD || fileSizeBytes >= FILE_BYTES_THRESHOLD,
|
|
1415
|
+
},
|
|
1416
|
+
seq: { counter: this.seqCounter, snapshotHighWaterSeq: this.snapshotHighWaterSeq },
|
|
1417
|
+
};
|
|
1418
|
+
}
|
|
1419
|
+
/** Close file handles + flush (shutdown/tests). */
|
|
1420
|
+
async close() {
|
|
1421
|
+
if (this.snapshotTimer) {
|
|
1422
|
+
clearTimeout(this.snapshotTimer);
|
|
1423
|
+
this.snapshotTimer = null;
|
|
1424
|
+
}
|
|
1425
|
+
await this.flushSnapshot();
|
|
1426
|
+
if (this.journalFd !== null) {
|
|
1427
|
+
try {
|
|
1428
|
+
fs.fsyncSync(this.journalFd);
|
|
1429
|
+
fs.closeSync(this.journalFd);
|
|
1430
|
+
}
|
|
1431
|
+
catch {
|
|
1432
|
+
/* @silent-fallback-ok — shutdown teardown; durable records were fsynced at append time */
|
|
1433
|
+
}
|
|
1434
|
+
this.journalFd = null;
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
//# sourceMappingURL=ConversationRegistry.js.map
|