instar 1.3.731 → 1.3.732
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 +82 -1
- 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 +260 -0
- package/dist/core/ConversationRegistry.d.ts.map +1 -0
- package/dist/core/ConversationRegistry.js +1151 -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/conversationIdentity.d.ts +112 -0
- package/dist/core/conversationIdentity.d.ts.map +1 -0
- package/dist/core/conversationIdentity.js +141 -0
- package/dist/core/conversationIdentity.js.map +1 -0
- package/dist/core/deliverToConversation.d.ts +62 -0
- package/dist/core/deliverToConversation.d.ts.map +1 -0
- package/dist/core/deliverToConversation.js +85 -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/types.d.ts +37 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.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 +3 -0
- package/dist/server/AgentServer.d.ts.map +1 -1
- package/dist/server/AgentServer.js +12 -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 +5 -0
- package/dist/server/routes.d.ts.map +1 -1
- package/dist/server/routes.js +78 -0
- package/dist/server/routes.js.map +1 -1
- package/package.json +1 -1
- package/src/data/builtin-manifest.json +64 -64
- package/src/scaffold/templates.ts +10 -0
- package/upgrades/1.3.732.md +107 -0
- package/upgrades/side-effects/durable-conversation-identity-increment1.md +89 -0
|
@@ -0,0 +1,1151 @@
|
|
|
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
|
+
const ENTRY_CEILING = 50000; // §3.4 JSON-store design ceiling
|
|
51
|
+
const ENTRY_THRESHOLD = 40000; // 80% of ceiling — the pinned tripwire (R3-minor)
|
|
52
|
+
const FILE_BYTES_THRESHOLD = 8 * 1024 * 1024; // 80% of ~10MB
|
|
53
|
+
const OFFLOOP_ENTRY_TRIGGER = 20000; // §3.4 pinned off-loop flush trigger
|
|
54
|
+
const OFFLOOP_BYTES_TRIGGER = 2 * 1024 * 1024;
|
|
55
|
+
const PENDING_MINT_MAX = 1000; // §3.6 pinned
|
|
56
|
+
const BREAKER_MAX_TRACKED_CHANNELS = 512; // bounded budget-state map (§3.3, R3-minor)
|
|
57
|
+
export class ConversationRegistry {
|
|
58
|
+
d;
|
|
59
|
+
snapshotPath;
|
|
60
|
+
journalPath;
|
|
61
|
+
// ── Authoritative in-memory cache + synchronous indexes (§3.4 1–2) ──
|
|
62
|
+
conversations = new Map(); // canonical key → entry
|
|
63
|
+
byId = new Map(); // id→entry reverse index
|
|
64
|
+
byTuple = new Map(); // tupleKey → entry
|
|
65
|
+
aliases = new Map(); // loserId → winnerId (one hop)
|
|
66
|
+
// ── Derived indexes (§3.4 3–5) — rebuilt at boot, maintained at assign time ──
|
|
67
|
+
reservedCanonicals = new Map(); // cand → owning tupleKey
|
|
68
|
+
displacedAssignments = new Map(); // offset → owning tupleKey (GLOBAL — R4-C1)
|
|
69
|
+
candClaimants = new Map(); // cand → claimant tupleKeys
|
|
70
|
+
// ── §5.0(a)/§3.5.2 journal-applied state (writers land in later increments) ──
|
|
71
|
+
bindPins = new Map();
|
|
72
|
+
ambiguousSends = new Map();
|
|
73
|
+
sendIntents = new Map();
|
|
74
|
+
workspacePin = null;
|
|
75
|
+
// ── Journal state ──
|
|
76
|
+
seqCounter = 0;
|
|
77
|
+
snapshotHighWaterSeq = 0;
|
|
78
|
+
journalFd = null;
|
|
79
|
+
journalBytes = 0;
|
|
80
|
+
journalLines = 0;
|
|
81
|
+
/** Unknown-op skip-and-preserve set (R8-minor-2) → snapshot-flush SUSPENSION (R9-M1/R10-M1). */
|
|
82
|
+
unappliedUnknownOps = [];
|
|
83
|
+
// ── Snapshot batching ──
|
|
84
|
+
snapshotTimer = null;
|
|
85
|
+
snapshotDirty = false;
|
|
86
|
+
lastSnapshotBytes = 0;
|
|
87
|
+
batchDepth = 0; // adoption-pass batched-save window (§6.2)
|
|
88
|
+
flushInFlight = false;
|
|
89
|
+
// ── Breaker + degradation state ──
|
|
90
|
+
breakerWindows = new Map();
|
|
91
|
+
breakerEpisodes = 0;
|
|
92
|
+
pendingMints = new Map();
|
|
93
|
+
pendingMintDrops = 0;
|
|
94
|
+
// ── Observability ──
|
|
95
|
+
lastMintAt = null;
|
|
96
|
+
durabilityIncidents = 0;
|
|
97
|
+
snapshotQuarantinedAt = null;
|
|
98
|
+
journalQuarantinedAt = null;
|
|
99
|
+
adoptionState = null;
|
|
100
|
+
loaded = false;
|
|
101
|
+
constructor(deps) {
|
|
102
|
+
this.d = deps;
|
|
103
|
+
this.snapshotPath = path.join(deps.stateDir, 'state', 'conversation-registry.json');
|
|
104
|
+
this.journalPath = path.join(deps.stateDir, 'conversation-registry.jsonl');
|
|
105
|
+
}
|
|
106
|
+
now() {
|
|
107
|
+
return (this.d.now ?? (() => new Date()))();
|
|
108
|
+
}
|
|
109
|
+
log(line) {
|
|
110
|
+
try {
|
|
111
|
+
this.d.log?.(line);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
/* observability never gates */
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
attention(dedupeKey, title, body) {
|
|
118
|
+
try {
|
|
119
|
+
this.d.onAttention?.(dedupeKey, title, body);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
/* attention is observability — never gates identity or delivery */
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
recordingEnabled() {
|
|
126
|
+
try {
|
|
127
|
+
return this.d.isRecordingEnabled ? this.d.isRecordingEnabled() !== false : true;
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
134
|
+
// Boot: snapshot load + journal replay (§3.4 WAL crash-consistency contract)
|
|
135
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
136
|
+
/** Idempotent boot-time load: snapshot, then journal tail replay in global
|
|
137
|
+
* `seq` order across rotated files (§6.2 recovery order 1–2). */
|
|
138
|
+
load() {
|
|
139
|
+
if (this.loaded)
|
|
140
|
+
return;
|
|
141
|
+
this.loaded = true;
|
|
142
|
+
try {
|
|
143
|
+
fs.mkdirSync(path.join(this.d.stateDir, 'state'), { recursive: true });
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
/* exists */
|
|
147
|
+
}
|
|
148
|
+
this.loadSnapshot();
|
|
149
|
+
this.replayJournal();
|
|
150
|
+
this.rebuildDerivedIndexes();
|
|
151
|
+
this.applyAliasAssignmentFilter(); // R6-M3 pin 2: the disjointness invariant holds at every BOOT fixpoint
|
|
152
|
+
if (this.unappliedUnknownOps.length > 0) {
|
|
153
|
+
const first = this.unappliedUnknownOps[0];
|
|
154
|
+
const kinds = [...new Set(this.unappliedUnknownOps.map((u) => u.op))].join(', ');
|
|
155
|
+
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.`);
|
|
156
|
+
}
|
|
157
|
+
// Resolve the config workspace pin (source 1 — authoritative when present).
|
|
158
|
+
const configPin = this.readConfigPin();
|
|
159
|
+
if (configPin) {
|
|
160
|
+
this.workspacePin = { value: configPin, source: 'config', confirmedLocally: true };
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
readConfigPin() {
|
|
164
|
+
try {
|
|
165
|
+
const v = this.d.getConfigWorkspacePin?.();
|
|
166
|
+
return v && SLACK_WORKSPACE_ID_RE.test(v) ? v : undefined;
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
loadSnapshot() {
|
|
173
|
+
if (!fs.existsSync(this.snapshotPath))
|
|
174
|
+
return;
|
|
175
|
+
try {
|
|
176
|
+
const raw = JSON.parse(fs.readFileSync(this.snapshotPath, 'utf-8'));
|
|
177
|
+
if (!raw || typeof raw !== 'object' || raw.version !== 1)
|
|
178
|
+
throw new Error('unrecognized snapshot shape');
|
|
179
|
+
this.snapshotHighWaterSeq = typeof raw.snapshotHighWaterSeq === 'number' ? raw.snapshotHighWaterSeq : 0;
|
|
180
|
+
this.seqCounter = this.snapshotHighWaterSeq;
|
|
181
|
+
if (raw.workspacePin && typeof raw.workspacePin.value === 'string')
|
|
182
|
+
this.workspacePin = raw.workspacePin;
|
|
183
|
+
for (const [key, entry] of Object.entries(raw.conversations ?? {})) {
|
|
184
|
+
if (!entry || typeof entry.id !== 'number' || !(entry.id < 0) || !Number.isSafeInteger(entry.id))
|
|
185
|
+
continue; // id<0 clamp on every write (§3.3)
|
|
186
|
+
this.indexEntry(key, entry);
|
|
187
|
+
}
|
|
188
|
+
for (const [loser, winner] of Object.entries(raw.aliases ?? {})) {
|
|
189
|
+
const l = Number(loser);
|
|
190
|
+
if (Number.isSafeInteger(l) && l < 0 && typeof winner === 'number' && winner < 0)
|
|
191
|
+
this.aliases.set(l, winner);
|
|
192
|
+
}
|
|
193
|
+
for (const [id, pin] of Object.entries(raw.bindPins ?? {})) {
|
|
194
|
+
const n = Number(id);
|
|
195
|
+
if (Number.isSafeInteger(n) && pin && Array.isArray(pin.tuple) && typeof pin.refcount === 'number') {
|
|
196
|
+
this.bindPins.set(n, { tuple: pin.tuple, refcount: pin.refcount });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
for (const [k, v] of Object.entries(raw.ambiguousSends ?? {})) {
|
|
200
|
+
if (v && typeof v.recordedAt === 'string')
|
|
201
|
+
this.ambiguousSends.set(k, v);
|
|
202
|
+
}
|
|
203
|
+
for (const [k, v] of Object.entries(raw.sendIntents ?? {})) {
|
|
204
|
+
if (v && (v.lane === 'logical' || v.lane === 'content-hash') && typeof v.seq === 'number')
|
|
205
|
+
this.sendIntents.set(k, v);
|
|
206
|
+
}
|
|
207
|
+
this.lastSnapshotBytes = fs.statSync(this.snapshotPath).size;
|
|
208
|
+
}
|
|
209
|
+
catch (err) {
|
|
210
|
+
// Corrupt-file quarantine-aside (§3.6 — the TopicPlacementPinStore
|
|
211
|
+
// pattern): preserve aside, ONE deduped attention item, rebuild from the
|
|
212
|
+
// journal (a journal-only rebuild replays every retained file from empty
|
|
213
|
+
// state in global seq order — §3.4 rotation note).
|
|
214
|
+
const aside = `${this.snapshotPath}.corrupt-${Date.now()}`;
|
|
215
|
+
try {
|
|
216
|
+
fs.renameSync(this.snapshotPath, aside);
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
/* best-effort — the report below still fires */
|
|
220
|
+
}
|
|
221
|
+
this.snapshotQuarantinedAt = this.now().toISOString();
|
|
222
|
+
this.snapshotHighWaterSeq = 0;
|
|
223
|
+
this.seqCounter = 0;
|
|
224
|
+
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).`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/** Retained journal files in replay order: rotated (ascending epoch) then live. */
|
|
228
|
+
journalFiles() {
|
|
229
|
+
const dir = this.d.stateDir;
|
|
230
|
+
const base = path.basename(this.journalPath);
|
|
231
|
+
let names = [];
|
|
232
|
+
try {
|
|
233
|
+
names = fs.readdirSync(dir).filter((n) => n.startsWith(`${base}.`) && /\.\d+$/.test(n));
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
names = [];
|
|
237
|
+
}
|
|
238
|
+
const rotated = names
|
|
239
|
+
.map((n) => ({ n, epoch: Number(n.slice(base.length + 1)) }))
|
|
240
|
+
.sort((a, b) => a.epoch - b.epoch)
|
|
241
|
+
.map((x) => path.join(dir, x.n));
|
|
242
|
+
return fs.existsSync(this.journalPath) ? [...rotated, this.journalPath] : rotated;
|
|
243
|
+
}
|
|
244
|
+
replayJournal() {
|
|
245
|
+
const files = this.journalFiles();
|
|
246
|
+
if (files.length === 0)
|
|
247
|
+
return;
|
|
248
|
+
// Torn-tail handling (§3.4): a crash mid-append leaves the LIVE file's last
|
|
249
|
+
// line unterminated — only a fully-written, newline-terminated line is a
|
|
250
|
+
// committed record. The torn tail is DISCARDED (truncated) so later appends
|
|
251
|
+
// can never fuse onto an uncommitted fragment.
|
|
252
|
+
if (fs.existsSync(this.journalPath)) {
|
|
253
|
+
try {
|
|
254
|
+
const buf = fs.readFileSync(this.journalPath);
|
|
255
|
+
if (buf.length > 0 && buf[buf.length - 1] !== 0x0a) {
|
|
256
|
+
const lastNl = buf.lastIndexOf(0x0a);
|
|
257
|
+
fs.truncateSync(this.journalPath, lastNl === -1 ? 0 : lastNl + 1);
|
|
258
|
+
this.log('[conversation-registry] discarded torn journal tail (uncommitted record)');
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
/* read failure falls through to the per-line handling below */
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
const records = [];
|
|
266
|
+
let halted = false;
|
|
267
|
+
for (const file of files) {
|
|
268
|
+
if (halted)
|
|
269
|
+
break;
|
|
270
|
+
let content;
|
|
271
|
+
try {
|
|
272
|
+
content = fs.readFileSync(file, 'utf-8');
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
const lines = content.split('\n');
|
|
278
|
+
for (let i = 0; i < lines.length; i++) {
|
|
279
|
+
const line = lines[i];
|
|
280
|
+
if (line.length === 0)
|
|
281
|
+
continue;
|
|
282
|
+
let rec;
|
|
283
|
+
try {
|
|
284
|
+
rec = JSON.parse(line);
|
|
285
|
+
if (!rec || typeof rec.seq !== 'number' || typeof rec.op !== 'string')
|
|
286
|
+
throw new Error('malformed record');
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
// NON-tail corruption fails CLOSED (R7-minor-3): a newline-TERMINATED
|
|
290
|
+
// line failing parse is a storage lie, never skipped — HALT the replay
|
|
291
|
+
// into the quarantine-aside path + attention + durability incident.
|
|
292
|
+
const aside = `${file}.corrupt-${Date.now()}`;
|
|
293
|
+
try {
|
|
294
|
+
fs.renameSync(file, aside);
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
/* preserve best-effort */
|
|
298
|
+
}
|
|
299
|
+
this.journalQuarantinedAt = this.now().toISOString();
|
|
300
|
+
this.durabilityIncidents++; // §3.7 broadened SQLite-migration trigger input
|
|
301
|
+
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).`);
|
|
302
|
+
halted = true;
|
|
303
|
+
break;
|
|
304
|
+
}
|
|
305
|
+
records.push({ rec, file });
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
records.sort((a, b) => a.rec.seq - b.rec.seq); // single global seq order (R3-M14)
|
|
309
|
+
let maxSeq = this.snapshotHighWaterSeq;
|
|
310
|
+
for (const { rec } of records) {
|
|
311
|
+
if (rec.seq > maxSeq)
|
|
312
|
+
maxSeq = rec.seq;
|
|
313
|
+
if (rec.seq <= this.snapshotHighWaterSeq)
|
|
314
|
+
continue; // snapshot already incorporates it
|
|
315
|
+
this.applyJournalRecord(rec);
|
|
316
|
+
}
|
|
317
|
+
// Boot counter resumes from the max seen — never 0/1 (R3-M14).
|
|
318
|
+
this.seqCounter = Math.max(this.seqCounter, maxSeq);
|
|
319
|
+
this.journalBytes = fs.existsSync(this.journalPath) ? fs.statSync(this.journalPath).size : 0;
|
|
320
|
+
this.journalLines = 0;
|
|
321
|
+
if (fs.existsSync(this.journalPath)) {
|
|
322
|
+
try {
|
|
323
|
+
const content = fs.readFileSync(this.journalPath, 'utf-8');
|
|
324
|
+
this.journalLines = content.length === 0 ? 0 : content.split('\n').filter((l) => l.length > 0).length;
|
|
325
|
+
}
|
|
326
|
+
catch {
|
|
327
|
+
/* count is rotation bookkeeping only */
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
/** Idempotent, seq-ordered application (§3.4) — safe to re-run any number of times. */
|
|
332
|
+
applyJournalRecord(rec) {
|
|
333
|
+
if (!JOURNAL_OPS.includes(rec.op)) {
|
|
334
|
+
// UNKNOWN-op tolerance (R8-minor-2): version skew, not corruption — skip
|
|
335
|
+
// the application, PRESERVE the line (append-only; never rewritten), and
|
|
336
|
+
// suspend snapshot flushing (R9-M1/R10-M1) until a recognizing version
|
|
337
|
+
// applies it. The deduped attention item is raised once after replay.
|
|
338
|
+
this.unappliedUnknownOps.push({ seq: rec.seq, op: rec.op });
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
switch (rec.op) {
|
|
342
|
+
case 'mint': {
|
|
343
|
+
if (typeof rec.id !== 'number' || !(rec.id < 0) || !Array.isArray(rec.tuple))
|
|
344
|
+
return;
|
|
345
|
+
const tuple = { platform: 'slack', channelId: rec.tuple[1], threadTs: rec.tuple[2] ?? null };
|
|
346
|
+
const tKey = tupleKeyFor(tuple);
|
|
347
|
+
const existing = this.byTuple.get(tKey);
|
|
348
|
+
if (existing) {
|
|
349
|
+
// Re-apply / metadata upgrade (the `_`→teamId upgrade is journaled as
|
|
350
|
+
// op:"mint" with the rewritten key — §3.1): same tuple, refreshed key.
|
|
351
|
+
if (rec.key && rec.key !== canonicalKeyFor(tuple, existing.workspaceId)) {
|
|
352
|
+
const parsed = parseCanonicalKey(rec.key);
|
|
353
|
+
if (parsed && parsed.workspaceId !== existing.workspaceId) {
|
|
354
|
+
this.conversations.delete(canonicalKeyFor(tuple, existing.workspaceId));
|
|
355
|
+
existing.workspaceId = parsed.workspaceId;
|
|
356
|
+
this.conversations.set(rec.key, existing);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return; // idempotent
|
|
360
|
+
}
|
|
361
|
+
const parsed = rec.key ? parseCanonicalKey(rec.key) : null;
|
|
362
|
+
const entry = {
|
|
363
|
+
id: rec.id,
|
|
364
|
+
platform: 'slack',
|
|
365
|
+
workspaceId: parsed?.workspaceId ?? WORKSPACE_PLACEHOLDER,
|
|
366
|
+
channelId: tuple.channelId,
|
|
367
|
+
threadTs: tuple.threadTs,
|
|
368
|
+
mintedAt: rec.ts,
|
|
369
|
+
mintedBy: rec.hlc?.node ?? 'unknown',
|
|
370
|
+
origin: rec.origin ?? 'adopted-legacy-hash',
|
|
371
|
+
reachability: 'ok',
|
|
372
|
+
hlc: rec.hlc ?? { physical: Date.parse(rec.ts) || 0, logical: 0, node: 'unknown' },
|
|
373
|
+
};
|
|
374
|
+
this.indexEntry(canonicalKeyFor(tuple, entry.workspaceId), entry);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
case 'alias': {
|
|
378
|
+
if (typeof rec.id === 'number' && typeof rec.target === 'number')
|
|
379
|
+
this.aliases.set(rec.id, rec.target);
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
case 'reachability': {
|
|
383
|
+
if (typeof rec.id !== 'number' || !rec.reachability)
|
|
384
|
+
return;
|
|
385
|
+
const entry = this.byId.get(rec.id);
|
|
386
|
+
if (entry)
|
|
387
|
+
entry.reachability = rec.reachability;
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
case 'bind-pin': {
|
|
391
|
+
if (typeof rec.id !== 'number' || !Array.isArray(rec.tuple))
|
|
392
|
+
return;
|
|
393
|
+
const existing = this.bindPins.get(rec.id);
|
|
394
|
+
if (existing)
|
|
395
|
+
existing.refcount = rec.refcount ?? existing.refcount + 1;
|
|
396
|
+
else
|
|
397
|
+
this.bindPins.set(rec.id, { tuple: rec.tuple, refcount: rec.refcount ?? 1 });
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
case 'bind-release': {
|
|
401
|
+
if (typeof rec.id !== 'number')
|
|
402
|
+
return;
|
|
403
|
+
const pin = this.bindPins.get(rec.id);
|
|
404
|
+
if (!pin)
|
|
405
|
+
return;
|
|
406
|
+
pin.refcount = rec.refcount ?? pin.refcount - 1;
|
|
407
|
+
if (pin.refcount <= 0)
|
|
408
|
+
this.bindPins.delete(rec.id);
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
case 'ambiguous-send': {
|
|
412
|
+
if (typeof rec.conversationId !== 'number' || !rec.logicalSendId)
|
|
413
|
+
return;
|
|
414
|
+
this.ambiguousSends.set(`${rec.conversationId}|${rec.logicalSendId}`, { recordedAt: rec.ts });
|
|
415
|
+
this.sendIntents.delete(`${rec.conversationId}|${rec.logicalSendId}`);
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
case 'send-retire': {
|
|
419
|
+
if (typeof rec.conversationId !== 'number' || !rec.logicalSendId)
|
|
420
|
+
return;
|
|
421
|
+
this.ambiguousSends.delete(`${rec.conversationId}|${rec.logicalSendId}`);
|
|
422
|
+
this.sendIntents.delete(`${rec.conversationId}|${rec.logicalSendId}`);
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
case 'send-intent': {
|
|
426
|
+
if (typeof rec.conversationId !== 'number' || !rec.logicalSendId)
|
|
427
|
+
return;
|
|
428
|
+
// Malformed/unknown lane resolves toward RETRY (content-hash treatment)
|
|
429
|
+
// — R9-minor-1/R10-low-2 (loss-is-never-silent picks retry).
|
|
430
|
+
const lane = rec.lane === 'logical' ? 'logical' : 'content-hash';
|
|
431
|
+
this.sendIntents.set(`${rec.conversationId}|${rec.logicalSendId}`, { lane, seq: rec.seq });
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
case 'send-intent-resolved': {
|
|
435
|
+
if (typeof rec.conversationId !== 'number' || !rec.logicalSendId)
|
|
436
|
+
return;
|
|
437
|
+
this.sendIntents.delete(`${rec.conversationId}|${rec.logicalSendId}`);
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
indexEntry(key, entry) {
|
|
443
|
+
this.conversations.set(key, entry);
|
|
444
|
+
this.byId.set(entry.id, entry);
|
|
445
|
+
this.byTuple.set(tupleKeyFor({ platform: 'slack', channelId: entry.channelId, threadTs: entry.threadTs }), entry);
|
|
446
|
+
}
|
|
447
|
+
/** Derived indexes 3–5 (§3.4) — pure functions of the entry set, rebuilt at boot. */
|
|
448
|
+
rebuildDerivedIndexes() {
|
|
449
|
+
this.reservedCanonicals.clear();
|
|
450
|
+
this.displacedAssignments.clear();
|
|
451
|
+
this.candClaimants.clear();
|
|
452
|
+
for (const entry of this.conversations.values()) {
|
|
453
|
+
const tuple = { platform: 'slack', channelId: entry.channelId, threadTs: entry.threadTs };
|
|
454
|
+
const cand = candidateIdForRoutingKey(routingKeyForTuple(tuple));
|
|
455
|
+
const tKey = tupleKeyFor(tuple);
|
|
456
|
+
let claimants = this.candClaimants.get(cand);
|
|
457
|
+
if (!claimants) {
|
|
458
|
+
claimants = new Set();
|
|
459
|
+
this.candClaimants.set(cand, claimants);
|
|
460
|
+
}
|
|
461
|
+
claimants.add(tKey);
|
|
462
|
+
if (entry.id === cand)
|
|
463
|
+
this.reservedCanonicals.set(cand, tKey);
|
|
464
|
+
else
|
|
465
|
+
this.displacedAssignments.set(entry.id, tKey);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
/** R5-C1/R6-M3: the assignment-beats-alias filter re-run over composed state —
|
|
469
|
+
* an alias shadowing a reserved canonical or an assigned displacement offset
|
|
470
|
+
* is dropped exactly as it would be at ingest. */
|
|
471
|
+
applyAliasAssignmentFilter() {
|
|
472
|
+
for (const aliasId of [...this.aliases.keys()]) {
|
|
473
|
+
if (this.reservedCanonicals.has(aliasId) || this.displacedAssignments.has(aliasId)) {
|
|
474
|
+
this.aliases.delete(aliasId);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
479
|
+
// Journal append (§3.4 — dedicated single-writer discipline, G3)
|
|
480
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
481
|
+
/**
|
|
482
|
+
* Append ONE newline-terminated record. `seq` assignment and the byte-write
|
|
483
|
+
* happen atomically per record under a synchronous append (Node's
|
|
484
|
+
* single-threaded sync I/O IS the append mutex — no interleaving is
|
|
485
|
+
* possible), satisfying the §3.4 G3 single-writer contract.
|
|
486
|
+
*/
|
|
487
|
+
appendJournal(rec, opts) {
|
|
488
|
+
const seq = ++this.seqCounter;
|
|
489
|
+
const line = `${JSON.stringify({ seq, ...rec, ts: this.now().toISOString() })}\n`;
|
|
490
|
+
this.rotateJournalIfNeeded(Buffer.byteLength(line));
|
|
491
|
+
if (this.journalFd === null) {
|
|
492
|
+
const created = !fs.existsSync(this.journalPath);
|
|
493
|
+
this.journalFd = fs.openSync(this.journalPath, 'a');
|
|
494
|
+
if (created)
|
|
495
|
+
this.fsyncDir(); // directory entry durable on file creation (§3.4)
|
|
496
|
+
}
|
|
497
|
+
fs.writeSync(this.journalFd, line);
|
|
498
|
+
this.journalBytes += Buffer.byteLength(line);
|
|
499
|
+
this.journalLines += 1;
|
|
500
|
+
if (opts.fsync && !(this.d.isJournalFsyncDisabled?.() ?? false)) {
|
|
501
|
+
try {
|
|
502
|
+
fs.fsyncSync(this.journalFd);
|
|
503
|
+
}
|
|
504
|
+
catch {
|
|
505
|
+
/* macOS platform footnote (§3.4): cheap-fsync is the deliberate choice */
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
return seq;
|
|
509
|
+
}
|
|
510
|
+
fsyncDir() {
|
|
511
|
+
try {
|
|
512
|
+
const dirFd = fs.openSync(this.d.stateDir, 'r');
|
|
513
|
+
try {
|
|
514
|
+
fs.fsyncSync(dirFd);
|
|
515
|
+
}
|
|
516
|
+
finally {
|
|
517
|
+
fs.closeSync(dirFd);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
catch {
|
|
521
|
+
/* @silent-fallback-ok — directory fsync is best-effort, platform-dependent (§3.4 macOS footnote); the record append itself is the durability authority */
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
rotateJournalIfNeeded(incomingBytes) {
|
|
525
|
+
const rotateBytes = this.d.journalRotateBytes ?? 8388608;
|
|
526
|
+
const rotateLines = this.d.journalRotateLines ?? 50000;
|
|
527
|
+
if (this.journalBytes + incomingBytes <= rotateBytes && this.journalLines < rotateLines)
|
|
528
|
+
return;
|
|
529
|
+
if (!fs.existsSync(this.journalPath))
|
|
530
|
+
return;
|
|
531
|
+
if (this.journalFd !== null) {
|
|
532
|
+
try {
|
|
533
|
+
fs.fsyncSync(this.journalFd);
|
|
534
|
+
}
|
|
535
|
+
catch {
|
|
536
|
+
/* @silent-fallback-ok — pre-rotation fsync is best-effort; every durable record already fsynced at append time (§3.3 WAL rule) */
|
|
537
|
+
}
|
|
538
|
+
fs.closeSync(this.journalFd);
|
|
539
|
+
this.journalFd = null;
|
|
540
|
+
}
|
|
541
|
+
// Unique all-digit suffix (two rotations can land in one millisecond; the
|
|
542
|
+
// suffix must keep matching the retained-file scan + the backup glob).
|
|
543
|
+
let suffix = Date.now();
|
|
544
|
+
while (fs.existsSync(`${this.journalPath}.${suffix}`))
|
|
545
|
+
suffix++;
|
|
546
|
+
const rotatedPath = `${this.journalPath}.${suffix}`;
|
|
547
|
+
fs.renameSync(this.journalPath, rotatedPath);
|
|
548
|
+
this.fsyncDir();
|
|
549
|
+
this.journalBytes = 0;
|
|
550
|
+
this.journalLines = 0;
|
|
551
|
+
this.pruneRotatedJournals();
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* Prune ONLY fully-superseded rotated files: every record ≤ the PERSISTED
|
|
555
|
+
* snapshot high-water, older than the 7-day retention floor (§8 — a recovery
|
|
556
|
+
* requirement), and containing no unapplied unknown-op record (R8-minor-2).
|
|
557
|
+
* Under snapshot suspension the persisted high-water stays pre-skew, so every
|
|
558
|
+
* file the eventual re-upgrade needs is retained MECHANICALLY (R10-M1).
|
|
559
|
+
*/
|
|
560
|
+
pruneRotatedJournals() {
|
|
561
|
+
const persistedHighWater = this.readPersistedHighWater();
|
|
562
|
+
const retentionFloorMs = 7 * 24 * 60 * 60 * 1000;
|
|
563
|
+
const unknownSeqs = new Set(this.unappliedUnknownOps.map((u) => u.seq));
|
|
564
|
+
for (const file of this.journalFiles()) {
|
|
565
|
+
if (file === this.journalPath)
|
|
566
|
+
continue;
|
|
567
|
+
try {
|
|
568
|
+
const stat = fs.statSync(file);
|
|
569
|
+
if (this.now().getTime() - stat.mtimeMs < retentionFloorMs)
|
|
570
|
+
continue;
|
|
571
|
+
const content = fs.readFileSync(file, 'utf-8');
|
|
572
|
+
let maxSeq = 0;
|
|
573
|
+
let hasUnknown = false;
|
|
574
|
+
for (const line of content.split('\n')) {
|
|
575
|
+
if (!line)
|
|
576
|
+
continue;
|
|
577
|
+
try {
|
|
578
|
+
const rec = JSON.parse(line);
|
|
579
|
+
if (typeof rec.seq === 'number' && rec.seq > maxSeq)
|
|
580
|
+
maxSeq = rec.seq;
|
|
581
|
+
if (!JOURNAL_OPS.includes(rec.op) || unknownSeqs.has(rec.seq))
|
|
582
|
+
hasUnknown = true;
|
|
583
|
+
}
|
|
584
|
+
catch {
|
|
585
|
+
hasUnknown = true; // @silent-fallback-ok — fails toward RETENTION: a file we cannot fully read is never pruned (§3.4)
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
if (!hasUnknown && maxSeq > 0 && maxSeq <= persistedHighWater) {
|
|
589
|
+
SafeFsExecutor.safeUnlinkSync(file, { operation: 'conversation-registry journal rotation prune (fully-superseded rotated file — §3.4)' });
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
catch {
|
|
593
|
+
/* @silent-fallback-ok — prune is hygiene; a failed prune retains the file (the safe direction), never gates identity */
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
readPersistedHighWater() {
|
|
598
|
+
try {
|
|
599
|
+
const raw = JSON.parse(fs.readFileSync(this.snapshotPath, 'utf-8'));
|
|
600
|
+
return typeof raw?.snapshotHighWaterSeq === 'number' ? raw.snapshotHighWaterSeq : 0;
|
|
601
|
+
}
|
|
602
|
+
catch {
|
|
603
|
+
// @silent-fallback-ok — an unreadable snapshot reads as high-water 0, so the
|
|
604
|
+
// prune rule supersedes NOTHING (fails toward retaining every journal file).
|
|
605
|
+
return 0;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
609
|
+
// Snapshot (batched, size-adaptive — §3.4)
|
|
610
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
611
|
+
scheduleSnapshot() {
|
|
612
|
+
this.snapshotDirty = true;
|
|
613
|
+
if (this.batchDepth > 0)
|
|
614
|
+
return; // batched-save window (§6.2 adoption pass)
|
|
615
|
+
if (this.unappliedUnknownOps.length > 0)
|
|
616
|
+
return; // SUSPENSION (R9-M1/R10-M1)
|
|
617
|
+
if (this.snapshotTimer)
|
|
618
|
+
return;
|
|
619
|
+
const base = this.d.snapshotBaseIntervalMs ?? 2000;
|
|
620
|
+
const step = this.d.snapshotAdaptiveStep ?? 5000;
|
|
621
|
+
const max = this.d.snapshotMaxIntervalMs ?? 60000;
|
|
622
|
+
const interval = Math.min(Math.max(base * Math.ceil(Math.max(this.conversations.size, 1) / step), base), max);
|
|
623
|
+
this.snapshotTimer = setTimeout(() => {
|
|
624
|
+
this.snapshotTimer = null;
|
|
625
|
+
void this.flushSnapshot();
|
|
626
|
+
}, interval);
|
|
627
|
+
this.snapshotTimer.unref?.();
|
|
628
|
+
}
|
|
629
|
+
/** Force a flush now (shutdown/tests). Honors the unknown-op suspension. */
|
|
630
|
+
async flushSnapshot() {
|
|
631
|
+
if (this.flushInFlight)
|
|
632
|
+
return;
|
|
633
|
+
if (this.unappliedUnknownOps.length > 0)
|
|
634
|
+
return; // pre-skew snapshot stays put (R10-M1)
|
|
635
|
+
this.flushInFlight = true;
|
|
636
|
+
try {
|
|
637
|
+
const shape = {
|
|
638
|
+
version: 1,
|
|
639
|
+
snapshotHighWaterSeq: this.seqCounter,
|
|
640
|
+
...(this.workspacePin ? { workspacePin: this.workspacePin } : {}),
|
|
641
|
+
conversations: Object.fromEntries(this.conversations),
|
|
642
|
+
aliases: Object.fromEntries([...this.aliases].map(([k, v]) => [String(k), v])),
|
|
643
|
+
bindPins: Object.fromEntries([...this.bindPins].map(([k, v]) => [String(k), v])),
|
|
644
|
+
ambiguousSends: Object.fromEntries(this.ambiguousSends),
|
|
645
|
+
sendIntents: Object.fromEntries(this.sendIntents),
|
|
646
|
+
};
|
|
647
|
+
const serialized = JSON.stringify(shape, null, 2);
|
|
648
|
+
const tmp = `${this.snapshotPath}.tmp`;
|
|
649
|
+
if (this.conversations.size > OFFLOOP_ENTRY_TRIGGER || this.lastSnapshotBytes > OFFLOOP_BYTES_TRIGGER) {
|
|
650
|
+
// §3.4 pinned off-loop trigger: async write of the pre-serialized buffer.
|
|
651
|
+
await fs.promises.writeFile(tmp, serialized);
|
|
652
|
+
await fs.promises.rename(tmp, this.snapshotPath);
|
|
653
|
+
}
|
|
654
|
+
else {
|
|
655
|
+
fs.writeFileSync(tmp, serialized);
|
|
656
|
+
fs.renameSync(tmp, this.snapshotPath);
|
|
657
|
+
}
|
|
658
|
+
this.snapshotHighWaterSeq = shape.snapshotHighWaterSeq;
|
|
659
|
+
this.lastSnapshotBytes = Buffer.byteLength(serialized);
|
|
660
|
+
this.snapshotDirty = false;
|
|
661
|
+
this.maybeRaiseGrowthTripwire();
|
|
662
|
+
}
|
|
663
|
+
catch (err) {
|
|
664
|
+
this.log(`[conversation-registry] snapshot flush failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
665
|
+
}
|
|
666
|
+
finally {
|
|
667
|
+
this.flushInFlight = false;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
maybeRaiseGrowthTripwire() {
|
|
671
|
+
if (this.conversations.size >= ENTRY_THRESHOLD || this.lastSnapshotBytes >= FILE_BYTES_THRESHOLD) {
|
|
672
|
+
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).`);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
/** Batched-save window for the adoption pass / bursts (§3.4/§6.2): one flush. */
|
|
676
|
+
beginBatch() {
|
|
677
|
+
this.batchDepth++;
|
|
678
|
+
}
|
|
679
|
+
endBatch() {
|
|
680
|
+
this.batchDepth = Math.max(0, this.batchDepth - 1);
|
|
681
|
+
if (this.batchDepth === 0 && this.snapshotDirty)
|
|
682
|
+
this.scheduleSnapshot();
|
|
683
|
+
}
|
|
684
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
685
|
+
// Mint (§3.3)
|
|
686
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
687
|
+
/** §3.3 `candidateCollides` — pure O(1) lookups, never a live-tuple scan:
|
|
688
|
+
* (a) reserved canonical of a DIFFERENT tuple; (b) alias table; (c) the
|
|
689
|
+
* GLOBAL displaced-assignment set (R4-C1). */
|
|
690
|
+
candidateCollides(id, tKey) {
|
|
691
|
+
const reserved = this.reservedCanonicals.get(id);
|
|
692
|
+
if (reserved !== undefined && reserved !== tKey)
|
|
693
|
+
return true;
|
|
694
|
+
if (this.aliases.has(id))
|
|
695
|
+
return true;
|
|
696
|
+
const displaced = this.displacedAssignments.get(id);
|
|
697
|
+
if (displaced !== undefined && displaced !== tKey)
|
|
698
|
+
return true;
|
|
699
|
+
return false;
|
|
700
|
+
}
|
|
701
|
+
/** Resolve the fleet workspace pin (§3.1 order: config → stored candidate). */
|
|
702
|
+
resolvedWorkspacePin() {
|
|
703
|
+
const configPin = this.readConfigPin();
|
|
704
|
+
if (configPin)
|
|
705
|
+
return { value: configPin, confirmedLocally: true };
|
|
706
|
+
return this.workspacePin;
|
|
707
|
+
}
|
|
708
|
+
/**
|
|
709
|
+
* Workspace admission for a mint (§3.1). Returns the workspaceId to record
|
|
710
|
+
* (concrete or `_`), or 'refused' on the per-machine multi-workspace gate.
|
|
711
|
+
*/
|
|
712
|
+
admitWorkspace() {
|
|
713
|
+
let observed;
|
|
714
|
+
try {
|
|
715
|
+
observed = this.d.getLocalWorkspaceId?.();
|
|
716
|
+
}
|
|
717
|
+
catch {
|
|
718
|
+
// @silent-fallback-ok — a throwing workspace source degrades to the `_`
|
|
719
|
+
// placeholder (upgrades in place later, §3.1) — never a blocked mint.
|
|
720
|
+
observed = undefined;
|
|
721
|
+
}
|
|
722
|
+
if (!observed || !SLACK_WORKSPACE_ID_RE.test(observed))
|
|
723
|
+
return WORKSPACE_PLACEHOLDER;
|
|
724
|
+
const pin = this.resolvedWorkspacePin();
|
|
725
|
+
if (!pin) {
|
|
726
|
+
// First concrete LOCAL observation writes the candidate and is immediately
|
|
727
|
+
// confirmed for it — self-corroboration is the designed single-machine /
|
|
728
|
+
// first-machine path (R6-minor-5).
|
|
729
|
+
this.workspacePin = { value: observed, source: 'local-observed', confirmedLocally: true };
|
|
730
|
+
this.scheduleSnapshot();
|
|
731
|
+
return observed;
|
|
732
|
+
}
|
|
733
|
+
if (pin.value !== observed && pin.confirmedLocally) {
|
|
734
|
+
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.`);
|
|
735
|
+
return 'refused';
|
|
736
|
+
}
|
|
737
|
+
return observed;
|
|
738
|
+
}
|
|
739
|
+
/** Breaker window state for a channel — bounded map with stale-window eviction (§3.3, R3-minor). */
|
|
740
|
+
breakerWindow(channelId) {
|
|
741
|
+
const windowMs = this.d.breaker?.windowMs ?? 600000;
|
|
742
|
+
const nowMs = this.now().getTime();
|
|
743
|
+
let w = this.breakerWindows.get(channelId);
|
|
744
|
+
if (!w || nowMs - w.windowStartMs >= windowMs) {
|
|
745
|
+
w = { windowStartMs: nowMs, speculative: 0, durable: 0, episodeNotified: false };
|
|
746
|
+
if (!this.breakerWindows.has(channelId) && this.breakerWindows.size >= BREAKER_MAX_TRACKED_CHANNELS) {
|
|
747
|
+
// Evict the stalest window — never a monotonic map (Bounded Blast Radius).
|
|
748
|
+
let oldest = null;
|
|
749
|
+
let oldestStart = Infinity;
|
|
750
|
+
for (const [k, v] of this.breakerWindows) {
|
|
751
|
+
if (v.windowStartMs < oldestStart) {
|
|
752
|
+
oldestStart = v.windowStartMs;
|
|
753
|
+
oldest = k;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
if (oldest)
|
|
757
|
+
this.breakerWindows.delete(oldest);
|
|
758
|
+
}
|
|
759
|
+
this.breakerWindows.set(channelId, w);
|
|
760
|
+
}
|
|
761
|
+
return w;
|
|
762
|
+
}
|
|
763
|
+
/**
|
|
764
|
+
* Speculative (inbound-triggered) get-or-create mint (§6.3 eager mint).
|
|
765
|
+
* NEVER throws and never blocks delivery — every failure degrades toward
|
|
766
|
+
* "no durable id" or a collision-checked read (§3.6).
|
|
767
|
+
*/
|
|
768
|
+
mintForInbound(routingKey, opts) {
|
|
769
|
+
this.load();
|
|
770
|
+
const tuple = tupleForRoutingKey(routingKey);
|
|
771
|
+
if (!tuple)
|
|
772
|
+
return { id: null, created: false, registered: false, degraded: 'unparseable-key' };
|
|
773
|
+
return this.mintTuple(tuple, { durableBinding: false, label: opts?.label, origin: undefined });
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* Durable-binding-forced mint (§3.3 breaker carve-out): registered REGARDLESS
|
|
777
|
+
* of the speculative budget, journal line fsynced BEFORE the id returns (the
|
|
778
|
+
* WAL rule). Its OWN higher budget yields a typed capacity refusal at the
|
|
779
|
+
* cap — never a silent drop (adversarial-B).
|
|
780
|
+
*/
|
|
781
|
+
mintForDurableBinding(routingKey, opts) {
|
|
782
|
+
this.load();
|
|
783
|
+
const tuple = tupleForRoutingKey(routingKey);
|
|
784
|
+
if (!tuple)
|
|
785
|
+
return { ok: false, error: 'unparseable-key' };
|
|
786
|
+
if (!this.recordingEnabled()) {
|
|
787
|
+
// R2-integration-§9: an unjournaled bind would be unresolvable after a
|
|
788
|
+
// restart — refuse, typed + loud. Positive Telegram binds are unaffected
|
|
789
|
+
// (they never reach the registry).
|
|
790
|
+
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.');
|
|
791
|
+
return { ok: false, error: 'conversation-recording-disabled' };
|
|
792
|
+
}
|
|
793
|
+
const existing = this.byTuple.get(tupleKeyFor(tuple));
|
|
794
|
+
if (existing) {
|
|
795
|
+
this.maybeUpgradeWorkspace(existing);
|
|
796
|
+
return { ok: true, id: existing.id, created: false };
|
|
797
|
+
}
|
|
798
|
+
const w = this.breakerWindow(tuple.channelId);
|
|
799
|
+
const durableCap = this.d.breaker?.durableBindingPerWindow ?? 50;
|
|
800
|
+
if (w.durable >= durableCap) {
|
|
801
|
+
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).`);
|
|
802
|
+
return { ok: false, error: 'conversation-registration-capacity' };
|
|
803
|
+
}
|
|
804
|
+
const res = this.mintTuple(tuple, { durableBinding: true, label: opts?.label });
|
|
805
|
+
if (res.id === null || !res.registered) {
|
|
806
|
+
if (res.degraded === 'multi-workspace-unsupported')
|
|
807
|
+
return { ok: false, error: 'multi-workspace-unsupported' };
|
|
808
|
+
return { ok: false, error: 'mint-failure' };
|
|
809
|
+
}
|
|
810
|
+
w.durable++;
|
|
811
|
+
return { ok: true, id: res.id, created: res.created };
|
|
812
|
+
}
|
|
813
|
+
/** §3.1 in-place `_`→teamId upgrade — LOCAL authenticated source ONLY. */
|
|
814
|
+
maybeUpgradeWorkspace(entry) {
|
|
815
|
+
if (entry.workspaceId !== WORKSPACE_PLACEHOLDER)
|
|
816
|
+
return;
|
|
817
|
+
const admitted = this.admitWorkspace();
|
|
818
|
+
if (admitted === 'refused' || admitted === WORKSPACE_PLACEHOLDER)
|
|
819
|
+
return;
|
|
820
|
+
const tuple = { platform: 'slack', channelId: entry.channelId, threadTs: entry.threadTs };
|
|
821
|
+
const oldKey = canonicalKeyFor(tuple, entry.workspaceId);
|
|
822
|
+
this.conversations.delete(oldKey);
|
|
823
|
+
entry.workspaceId = admitted;
|
|
824
|
+
const newKey = canonicalKeyFor(tuple, admitted);
|
|
825
|
+
this.conversations.set(newKey, entry);
|
|
826
|
+
// Journaled (§3.1) as an idempotent op:"mint" re-apply with the rewritten
|
|
827
|
+
// key (the op enum has no distinct upgrade op — §3.4/R6-low-2 precedent).
|
|
828
|
+
this.appendJournal({
|
|
829
|
+
op: 'mint',
|
|
830
|
+
key: newKey,
|
|
831
|
+
tuple: [tuple.platform, tuple.channelId, tuple.threadTs],
|
|
832
|
+
id: entry.id,
|
|
833
|
+
origin: entry.origin,
|
|
834
|
+
hlc: entry.hlc,
|
|
835
|
+
}, { fsync: false });
|
|
836
|
+
this.scheduleSnapshot();
|
|
837
|
+
this.log(`[conversation-registry] workspace upgraded in place: ${oldKey} → ${newKey} (id ${entry.id})`);
|
|
838
|
+
}
|
|
839
|
+
mintTuple(tuple, opts) {
|
|
840
|
+
const tKey = tupleKeyFor(tuple);
|
|
841
|
+
const existing = this.byTuple.get(tKey);
|
|
842
|
+
if (existing) {
|
|
843
|
+
this.maybeUpgradeWorkspace(existing);
|
|
844
|
+
if (opts.label && opts.label !== existing.label) {
|
|
845
|
+
existing.label = opts.label; // write-on-change (§3.4 G4)
|
|
846
|
+
this.scheduleSnapshot();
|
|
847
|
+
}
|
|
848
|
+
return { id: existing.id, created: false, registered: true };
|
|
849
|
+
}
|
|
850
|
+
const routingKey = routingKeyForTuple(tuple);
|
|
851
|
+
const candidate = candidateIdForRoutingKey(routingKey);
|
|
852
|
+
const workspace = this.admitWorkspace();
|
|
853
|
+
if (workspace === 'refused') {
|
|
854
|
+
return { id: null, created: false, registered: false, degraded: 'multi-workspace-unsupported' };
|
|
855
|
+
}
|
|
856
|
+
if (!this.recordingEnabled()) {
|
|
857
|
+
// D1 degradation (§3.6): behavior-identical to legacy hashing — candidate
|
|
858
|
+
// + collision-checked read (B6), NO durable write, NO journal fsync.
|
|
859
|
+
return { id: this.collisionCheckedRead(candidate, tKey), created: false, registered: false, degraded: 'recording-disabled' };
|
|
860
|
+
}
|
|
861
|
+
if (!opts.durableBinding) {
|
|
862
|
+
const w = this.breakerWindow(tuple.channelId);
|
|
863
|
+
const cap = this.d.breaker?.speculativePerWindow ?? 200;
|
|
864
|
+
if (w.speculative >= cap) {
|
|
865
|
+
// The breaker DROPS speculative registrations to NOWHERE (zero pending
|
|
866
|
+
// state) — the candidate re-mints for free on a later inbound. Delivery
|
|
867
|
+
// still proceeds on a collision-checked read (B6).
|
|
868
|
+
this.breakerEpisodes++;
|
|
869
|
+
if (!w.episodeNotified) {
|
|
870
|
+
w.episodeNotified = true;
|
|
871
|
+
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).`);
|
|
872
|
+
}
|
|
873
|
+
return { id: this.collisionCheckedRead(candidate, tKey), created: false, registered: false, degraded: 'breaker-dropped' };
|
|
874
|
+
}
|
|
875
|
+
w.speculative++;
|
|
876
|
+
}
|
|
877
|
+
const walk = walkDisplacement(candidate, (id) => this.candidateCollides(id, tKey));
|
|
878
|
+
if (!walk.ok) {
|
|
879
|
+
// Probe overflow (astronomically unlikely) degrades to the §3.6
|
|
880
|
+
// pending-mint path — never a silently-un-ingestable id.
|
|
881
|
+
const key = canonicalKeyFor(tuple, workspace);
|
|
882
|
+
if (!this.pendingMints.has(key)) {
|
|
883
|
+
if (this.pendingMints.size >= PENDING_MINT_MAX)
|
|
884
|
+
this.pendingMintDrops++;
|
|
885
|
+
else
|
|
886
|
+
this.pendingMints.set(key, { firstAt: this.now().toISOString() });
|
|
887
|
+
}
|
|
888
|
+
return { id: null, created: false, registered: false, degraded: 'probe-overflow' };
|
|
889
|
+
}
|
|
890
|
+
const probed = walk.probes > 0;
|
|
891
|
+
const nowIso = this.now().toISOString();
|
|
892
|
+
const machineId = this.safeMachineId();
|
|
893
|
+
const entry = {
|
|
894
|
+
id: walk.id,
|
|
895
|
+
platform: 'slack',
|
|
896
|
+
workspaceId: workspace,
|
|
897
|
+
channelId: tuple.channelId,
|
|
898
|
+
threadTs: tuple.threadTs,
|
|
899
|
+
mintedAt: nowIso,
|
|
900
|
+
mintedBy: machineId,
|
|
901
|
+
// A fresh non-probed local mint's id IS the legacy-hash id — the honest
|
|
902
|
+
// origin within the frozen §3.4 enum; probed mints are 'minted-probed'.
|
|
903
|
+
origin: opts.origin ?? (probed ? 'minted-probed' : 'adopted-legacy-hash'),
|
|
904
|
+
reachability: 'ok',
|
|
905
|
+
hlc: { physical: this.now().getTime(), logical: 0, node: machineId },
|
|
906
|
+
...(opts.label ? { label: opts.label } : {}),
|
|
907
|
+
};
|
|
908
|
+
const key = canonicalKeyFor(tuple, workspace);
|
|
909
|
+
// Synchronous assignment: authoritative cache + reverse + tuple index +
|
|
910
|
+
// derived occupancy — the id RETURNED equals the id that will PERSIST.
|
|
911
|
+
this.indexEntry(key, entry);
|
|
912
|
+
let claimants = this.candClaimants.get(candidate);
|
|
913
|
+
if (!claimants) {
|
|
914
|
+
claimants = new Set();
|
|
915
|
+
this.candClaimants.set(candidate, claimants);
|
|
916
|
+
}
|
|
917
|
+
claimants.add(tKey);
|
|
918
|
+
if (!probed)
|
|
919
|
+
this.reservedCanonicals.set(candidate, tKey);
|
|
920
|
+
else
|
|
921
|
+
this.displacedAssignments.set(walk.id, tKey);
|
|
922
|
+
// WAL rule (§3.3): a PROBED or durable-binding-forced mint append+fsyncs
|
|
923
|
+
// ONE journal line BEFORE the id is returned (not re-derivable after a
|
|
924
|
+
// crash). A pure SPECULATIVE non-probed mint performs NO synchronous
|
|
925
|
+
// journal write — it rides the batched snapshot only (§3.4 fsync
|
|
926
|
+
// discipline; its candidate re-mints deterministically for free).
|
|
927
|
+
if (probed || opts.durableBinding) {
|
|
928
|
+
this.appendJournal({
|
|
929
|
+
op: 'mint',
|
|
930
|
+
key,
|
|
931
|
+
tuple: [tuple.platform, tuple.channelId, tuple.threadTs],
|
|
932
|
+
id: entry.id,
|
|
933
|
+
origin: entry.origin,
|
|
934
|
+
hlc: entry.hlc,
|
|
935
|
+
}, { fsync: true });
|
|
936
|
+
}
|
|
937
|
+
this.pendingMints.delete(key);
|
|
938
|
+
this.lastMintAt = nowIso;
|
|
939
|
+
this.scheduleSnapshot();
|
|
940
|
+
return { id: entry.id, created: true, registered: true };
|
|
941
|
+
}
|
|
942
|
+
safeMachineId() {
|
|
943
|
+
try {
|
|
944
|
+
return this.d.machineId() || 'unknown';
|
|
945
|
+
}
|
|
946
|
+
catch {
|
|
947
|
+
return 'unknown';
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
/**
|
|
951
|
+
* B6 collision-checked read for degraded paths (breaker-drop / recording-off):
|
|
952
|
+
* never delivers on a raw candidate occupied by a DIFFERENT tuple — resolves
|
|
953
|
+
* via the same key-derived probe for the READ ONLY (no registration).
|
|
954
|
+
*/
|
|
955
|
+
collisionCheckedRead(candidate, tKey) {
|
|
956
|
+
const walk = walkDisplacement(candidate, (id) => this.candidateCollides(id, tKey));
|
|
957
|
+
return walk.ok ? walk.id : candidate;
|
|
958
|
+
}
|
|
959
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
960
|
+
// Resolution (§2/§8)
|
|
961
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
962
|
+
/** `resolve(id)`: positive → Telegram pass-through; negative → registry
|
|
963
|
+
* lookup, aliases followed exactly ONE hop; unknown → null. */
|
|
964
|
+
resolve(id) {
|
|
965
|
+
this.load();
|
|
966
|
+
if (!Number.isSafeInteger(id) || id === 0)
|
|
967
|
+
return null;
|
|
968
|
+
if (id > 0)
|
|
969
|
+
return { platform: 'telegram', topicId: id, passThrough: true };
|
|
970
|
+
const aliasTarget = this.aliases.get(id);
|
|
971
|
+
const entry = this.byId.get(aliasTarget ?? id);
|
|
972
|
+
if (!entry)
|
|
973
|
+
return null;
|
|
974
|
+
return {
|
|
975
|
+
platform: 'slack',
|
|
976
|
+
id: entry.id,
|
|
977
|
+
key: canonicalKeyFor({ platform: 'slack', channelId: entry.channelId, threadTs: entry.threadTs }, entry.workspaceId),
|
|
978
|
+
channelId: entry.channelId,
|
|
979
|
+
threadTs: entry.threadTs,
|
|
980
|
+
workspaceId: entry.workspaceId,
|
|
981
|
+
origin: entry.origin,
|
|
982
|
+
reachability: entry.reachability,
|
|
983
|
+
...(entry.label !== undefined ? { label: entry.label } : {}),
|
|
984
|
+
...(aliasTarget !== undefined ? { aliasOf: aliasTarget } : {}),
|
|
985
|
+
};
|
|
986
|
+
}
|
|
987
|
+
/** Forward lookup by canonical key OR transport sessionKey — mints NOTHING (§8). */
|
|
988
|
+
resolveByKey(keyOrSessionKey) {
|
|
989
|
+
this.load();
|
|
990
|
+
if (/^\d+$/.test(keyOrSessionKey)) {
|
|
991
|
+
return { platform: 'telegram', topicId: Number(keyOrSessionKey), passThrough: true };
|
|
992
|
+
}
|
|
993
|
+
const parsed = parseCanonicalKey(keyOrSessionKey);
|
|
994
|
+
const tuple = parsed?.tuple ?? tupleForRoutingKey(keyOrSessionKey);
|
|
995
|
+
if (!tuple)
|
|
996
|
+
return null;
|
|
997
|
+
const entry = this.byTuple.get(tupleKeyFor(tuple));
|
|
998
|
+
return entry ? this.resolve(entry.id) : null;
|
|
999
|
+
}
|
|
1000
|
+
/**
|
|
1001
|
+
* `idForSessionKey` — GET-OR-CREATE (§6.0 #12, a named mint chokepoint).
|
|
1002
|
+
* Positive numeric session keys pass through; Slack routing keys mint.
|
|
1003
|
+
*/
|
|
1004
|
+
idForSessionKey(sessionKey) {
|
|
1005
|
+
this.load();
|
|
1006
|
+
if (/^\d+$/.test(sessionKey))
|
|
1007
|
+
return Number(sessionKey);
|
|
1008
|
+
return this.mintForInbound(sessionKey).id;
|
|
1009
|
+
}
|
|
1010
|
+
/** Read-only inventory for GET /conversations (§8). */
|
|
1011
|
+
list(opts) {
|
|
1012
|
+
this.load();
|
|
1013
|
+
const limit = Math.max(1, Math.min(opts?.limit ?? 500, 5000));
|
|
1014
|
+
const out = [];
|
|
1015
|
+
for (const [key, entry] of this.conversations) {
|
|
1016
|
+
if (opts?.platform && entry.platform !== opts.platform)
|
|
1017
|
+
continue;
|
|
1018
|
+
out.push({ key, entry });
|
|
1019
|
+
if (out.length >= limit)
|
|
1020
|
+
break;
|
|
1021
|
+
}
|
|
1022
|
+
return out;
|
|
1023
|
+
}
|
|
1024
|
+
aliasTable() {
|
|
1025
|
+
this.load();
|
|
1026
|
+
return Object.fromEntries([...this.aliases].map(([k, v]) => [String(k), v]));
|
|
1027
|
+
}
|
|
1028
|
+
entryCount() {
|
|
1029
|
+
this.load();
|
|
1030
|
+
return this.conversations.size;
|
|
1031
|
+
}
|
|
1032
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1033
|
+
// Adoption pass (§6.2)
|
|
1034
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1035
|
+
/**
|
|
1036
|
+
* Idempotent boot-time ensure: pre-register channel-level conversations from
|
|
1037
|
+
* the channel registry with their legacy-hash ids, inside ONE batched-save
|
|
1038
|
+
* window. GATED to channels with ≥1 authorized-sender message on record
|
|
1039
|
+
* (security-B8) — an auto-join channel mints lazily on its first authorized
|
|
1040
|
+
* inbound instead. A pre-population CONVENIENCE, not a recovery requirement.
|
|
1041
|
+
*/
|
|
1042
|
+
runAdoptionPass(channels, hasAuthorizedTraffic) {
|
|
1043
|
+
this.load();
|
|
1044
|
+
let adopted = 0;
|
|
1045
|
+
let skippedUnauthorized = 0;
|
|
1046
|
+
this.beginBatch();
|
|
1047
|
+
try {
|
|
1048
|
+
for (const ch of channels) {
|
|
1049
|
+
let authorized = false;
|
|
1050
|
+
try {
|
|
1051
|
+
authorized = hasAuthorizedTraffic(ch.channelId);
|
|
1052
|
+
}
|
|
1053
|
+
catch {
|
|
1054
|
+
authorized = false; // fail toward not-pre-minting (it mints lazily later)
|
|
1055
|
+
}
|
|
1056
|
+
if (!authorized) {
|
|
1057
|
+
skippedUnauthorized++;
|
|
1058
|
+
continue;
|
|
1059
|
+
}
|
|
1060
|
+
const res = this.mintForInbound(ch.channelId, ch.name ? { label: ch.name } : undefined);
|
|
1061
|
+
if (res.created)
|
|
1062
|
+
adopted++;
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
finally {
|
|
1066
|
+
this.endBatch();
|
|
1067
|
+
}
|
|
1068
|
+
this.adoptionState = { ranAt: this.now().toISOString(), adopted, skippedUnauthorized };
|
|
1069
|
+
this.log(`[conversation-registry] adoption pass: ${adopted} adopted, ${skippedUnauthorized} skipped (no authorized traffic)`);
|
|
1070
|
+
return { adopted, skippedUnauthorized };
|
|
1071
|
+
}
|
|
1072
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1073
|
+
// Health (§8 — the e2e "feature is alive" target)
|
|
1074
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1075
|
+
health() {
|
|
1076
|
+
this.load();
|
|
1077
|
+
const byOrigin = {};
|
|
1078
|
+
const byPlatform = {};
|
|
1079
|
+
for (const entry of this.conversations.values()) {
|
|
1080
|
+
byOrigin[entry.origin] = (byOrigin[entry.origin] ?? 0) + 1;
|
|
1081
|
+
byPlatform[entry.platform] = (byPlatform[entry.platform] ?? 0) + 1;
|
|
1082
|
+
}
|
|
1083
|
+
let fileSizeBytes = 0;
|
|
1084
|
+
try {
|
|
1085
|
+
fileSizeBytes = fs.existsSync(this.snapshotPath) ? fs.statSync(this.snapshotPath).size : 0;
|
|
1086
|
+
}
|
|
1087
|
+
catch {
|
|
1088
|
+
/* observability */
|
|
1089
|
+
}
|
|
1090
|
+
let retainedJournalBytes = 0;
|
|
1091
|
+
try {
|
|
1092
|
+
for (const f of this.journalFiles())
|
|
1093
|
+
retainedJournalBytes += fs.statSync(f).size;
|
|
1094
|
+
}
|
|
1095
|
+
catch {
|
|
1096
|
+
/* observability */
|
|
1097
|
+
}
|
|
1098
|
+
return {
|
|
1099
|
+
// Resident-heap honesty (§3.4): entryCount is the heap axis — resident
|
|
1100
|
+
// heap is plausibly 5–10× fileSizeBytes at the 100k envelope.
|
|
1101
|
+
entryCount: this.conversations.size,
|
|
1102
|
+
fileSizeBytes,
|
|
1103
|
+
byPlatform,
|
|
1104
|
+
byOrigin,
|
|
1105
|
+
aliasCount: this.aliases.size,
|
|
1106
|
+
lastMintAt: this.lastMintAt,
|
|
1107
|
+
adoptionPass: this.adoptionState,
|
|
1108
|
+
recordingEnabled: this.recordingEnabled(),
|
|
1109
|
+
workspacePin: this.workspacePin ? { value: this.workspacePin.value, source: this.workspacePin.source } : null,
|
|
1110
|
+
mintBudget: {
|
|
1111
|
+
channelsTracked: this.breakerWindows.size,
|
|
1112
|
+
breakerEpisodes: this.breakerEpisodes,
|
|
1113
|
+
},
|
|
1114
|
+
pendingMints: { count: this.pendingMints.size, dropped: this.pendingMintDrops },
|
|
1115
|
+
quarantine: {
|
|
1116
|
+
snapshotQuarantinedAt: this.snapshotQuarantinedAt,
|
|
1117
|
+
journalQuarantinedAt: this.journalQuarantinedAt,
|
|
1118
|
+
durabilityIncidents: this.durabilityIncidents,
|
|
1119
|
+
},
|
|
1120
|
+
// Snapshot-suspension observability (R11-low-1).
|
|
1121
|
+
snapshotSuspended: this.unappliedUnknownOps.length > 0,
|
|
1122
|
+
firstUnappliedUnknownSeq: this.unappliedUnknownOps[0]?.seq ?? null,
|
|
1123
|
+
unappliedUnknownCount: this.unappliedUnknownOps.length,
|
|
1124
|
+
retainedJournalBytes,
|
|
1125
|
+
ceiling: {
|
|
1126
|
+
entryCeiling: ENTRY_CEILING,
|
|
1127
|
+
thresholdReached: this.conversations.size >= ENTRY_THRESHOLD || fileSizeBytes >= FILE_BYTES_THRESHOLD,
|
|
1128
|
+
},
|
|
1129
|
+
seq: { counter: this.seqCounter, snapshotHighWaterSeq: this.snapshotHighWaterSeq },
|
|
1130
|
+
};
|
|
1131
|
+
}
|
|
1132
|
+
/** Close file handles + flush (shutdown/tests). */
|
|
1133
|
+
async close() {
|
|
1134
|
+
if (this.snapshotTimer) {
|
|
1135
|
+
clearTimeout(this.snapshotTimer);
|
|
1136
|
+
this.snapshotTimer = null;
|
|
1137
|
+
}
|
|
1138
|
+
await this.flushSnapshot();
|
|
1139
|
+
if (this.journalFd !== null) {
|
|
1140
|
+
try {
|
|
1141
|
+
fs.fsyncSync(this.journalFd);
|
|
1142
|
+
fs.closeSync(this.journalFd);
|
|
1143
|
+
}
|
|
1144
|
+
catch {
|
|
1145
|
+
/* @silent-fallback-ok — shutdown teardown; durable records were fsynced at append time */
|
|
1146
|
+
}
|
|
1147
|
+
this.journalFd = null;
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
//# sourceMappingURL=ConversationRegistry.js.map
|