instar 1.3.489 → 1.3.490
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 +979 -28
- package/dist/commands/server.js.map +1 -1
- package/dist/config/ConfigDefaults.d.ts.map +1 -1
- package/dist/config/ConfigDefaults.js +23 -0
- package/dist/config/ConfigDefaults.js.map +1 -1
- package/dist/core/CodexResumeMap.d.ts +95 -0
- package/dist/core/CodexResumeMap.d.ts.map +1 -0
- package/dist/core/CodexResumeMap.js +283 -0
- package/dist/core/CodexResumeMap.js.map +1 -0
- package/dist/core/MeshRpc.d.ts +3 -0
- package/dist/core/MeshRpc.d.ts.map +1 -1
- package/dist/core/MeshRpc.js +5 -0
- package/dist/core/MeshRpc.js.map +1 -1
- package/dist/core/ModelSwapService.d.ts +26 -11
- package/dist/core/ModelSwapService.d.ts.map +1 -1
- package/dist/core/ModelSwapService.js +59 -21
- package/dist/core/ModelSwapService.js.map +1 -1
- package/dist/core/PostUpdateMigrator.d.ts +16 -0
- package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
- package/dist/core/PostUpdateMigrator.js +62 -2
- package/dist/core/PostUpdateMigrator.js.map +1 -1
- package/dist/core/SessionManager.d.ts +4 -0
- package/dist/core/SessionManager.d.ts.map +1 -1
- package/dist/core/SessionManager.js +3 -0
- package/dist/core/SessionManager.js.map +1 -1
- package/dist/core/SessionRefresh.d.ts +36 -7
- package/dist/core/SessionRefresh.d.ts.map +1 -1
- package/dist/core/SessionRefresh.js +90 -29
- package/dist/core/SessionRefresh.js.map +1 -1
- package/dist/core/TopicProfileOrchestrator.d.ts +480 -0
- package/dist/core/TopicProfileOrchestrator.d.ts.map +1 -0
- package/dist/core/TopicProfileOrchestrator.js +1404 -0
- package/dist/core/TopicProfileOrchestrator.js.map +1 -0
- package/dist/core/TopicProfileResolver.d.ts +104 -0
- package/dist/core/TopicProfileResolver.d.ts.map +1 -0
- package/dist/core/TopicProfileResolver.js +231 -0
- package/dist/core/TopicProfileResolver.js.map +1 -0
- package/dist/core/TopicProfileStore.d.ts +308 -0
- package/dist/core/TopicProfileStore.d.ts.map +1 -0
- package/dist/core/TopicProfileStore.js +781 -0
- package/dist/core/TopicProfileStore.js.map +1 -0
- package/dist/core/TopicProfileTransferCarrier.d.ts +227 -0
- package/dist/core/TopicProfileTransferCarrier.d.ts.map +1 -0
- package/dist/core/TopicProfileTransferCarrier.js +533 -0
- package/dist/core/TopicProfileTransferCarrier.js.map +1 -0
- package/dist/core/TopicResumeMap.d.ts +67 -1
- package/dist/core/TopicResumeMap.d.ts.map +1 -1
- package/dist/core/TopicResumeMap.js +114 -1
- package/dist/core/TopicResumeMap.js.map +1 -1
- package/dist/core/classifyProfileChange.d.ts +83 -0
- package/dist/core/classifyProfileChange.d.ts.map +1 -0
- package/dist/core/classifyProfileChange.js +274 -0
- package/dist/core/classifyProfileChange.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/frameworkSessionLaunch.d.ts +26 -0
- package/dist/core/frameworkSessionLaunch.d.ts.map +1 -1
- package/dist/core/frameworkSessionLaunch.js +58 -0
- package/dist/core/frameworkSessionLaunch.js.map +1 -1
- package/dist/core/slackRefreshBinding.d.ts +83 -0
- package/dist/core/slackRefreshBinding.d.ts.map +1 -0
- package/dist/core/slackRefreshBinding.js +54 -0
- package/dist/core/slackRefreshBinding.js.map +1 -0
- package/dist/core/topicProfileIngress.d.ts +128 -0
- package/dist/core/topicProfileIngress.d.ts.map +1 -0
- package/dist/core/topicProfileIngress.js +249 -0
- package/dist/core/topicProfileIngress.js.map +1 -0
- package/dist/core/topicProfileValidation.d.ts +109 -0
- package/dist/core/topicProfileValidation.d.ts.map +1 -0
- package/dist/core/topicProfileValidation.js +247 -0
- package/dist/core/topicProfileValidation.js.map +1 -0
- package/dist/core/topicProfileWriteSurface.d.ts +222 -0
- package/dist/core/topicProfileWriteSurface.d.ts.map +1 -0
- package/dist/core/topicProfileWriteSurface.js +631 -0
- package/dist/core/topicProfileWriteSurface.js.map +1 -0
- 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/lifeline/TelegramLifeline.d.ts.map +1 -1
- package/dist/lifeline/TelegramLifeline.js +6 -0
- package/dist/lifeline/TelegramLifeline.js.map +1 -1
- package/dist/messaging/TelegramAdapter.d.ts +10 -1
- package/dist/messaging/TelegramAdapter.d.ts.map +1 -1
- package/dist/messaging/TelegramAdapter.js +41 -1
- package/dist/messaging/TelegramAdapter.js.map +1 -1
- package/dist/scaffold/templates.d.ts.map +1 -1
- package/dist/scaffold/templates.js +9 -0
- package/dist/scaffold/templates.js.map +1 -1
- package/dist/server/AgentServer.d.ts +32 -0
- package/dist/server/AgentServer.d.ts.map +1 -1
- package/dist/server/AgentServer.js +52 -3
- package/dist/server/AgentServer.js.map +1 -1
- package/dist/server/CapabilityIndex.d.ts.map +1 -1
- package/dist/server/CapabilityIndex.js +1 -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 +271 -1
- package/dist/server/routes.js.map +1 -1
- package/package.json +1 -1
- package/src/data/builtin-manifest.json +66 -66
- package/src/scaffold/templates.ts +9 -0
- package/upgrades/1.3.490.md +34 -0
- package/upgrades/side-effects/topic-profile.md +142 -0
|
@@ -0,0 +1,781 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TopicProfileStore — sticky per-topic execution profile (framework / model /
|
|
3
|
+
* thinking-mode), TOPIC-PROFILE-SPEC §4/§5.1.
|
|
4
|
+
*
|
|
5
|
+
* Modeled on TopicFrameworksStore for the file/atomic-write shape, with the
|
|
6
|
+
* deliberate departures the spec's review required:
|
|
7
|
+
*
|
|
8
|
+
* - Single-writer serialized `mutate(topicKey, patch)` (the CommitmentTracker
|
|
9
|
+
* CAS pattern), NOT a load→overwrite-whole-object `set`. The profile has
|
|
10
|
+
* three independently-writable axes — a whole-object rewrite would clobber
|
|
11
|
+
* a concurrently-set field. `mutate` does a read-modify-write under a
|
|
12
|
+
* per-topic process-local lock, merging only the changed fields.
|
|
13
|
+
* - In-memory cache is AUTHORITATIVE for reads. The file is read once at
|
|
14
|
+
* boot and on explicit external-change invalidation only — per-spawn
|
|
15
|
+
* resolution is O(1) (§5.1).
|
|
16
|
+
* - Durability precedes acknowledgment: `mutate` resolves only after a flush
|
|
17
|
+
* containing its write has durably landed (tmp+rename completed). A failed
|
|
18
|
+
* flush REFUSES the write out loud (throws FlushRefusedError) AND ROLLS
|
|
19
|
+
* BACK the in-memory mutation to the last durably-flushed snapshot — on a
|
|
20
|
+
* failed COALESCED flush, every waiter whose write is not yet durable is
|
|
21
|
+
* refused and rolled back together (§5.1, rounds 5-7).
|
|
22
|
+
* - Queued flush waiters COALESCE: a mutate arriving while a flush is in
|
|
23
|
+
* flight is satisfied by a single trailing flush of the latest cache
|
|
24
|
+
* snapshot — a batch of N writes costs O(1-2) flushes (§5.1, round 6).
|
|
25
|
+
* - The undo snapshot ({ current, previous }) is durable in the same file;
|
|
26
|
+
* `previous` shifts once per delta-carrying disclosure — the ORCHESTRATION
|
|
27
|
+
* layer signals the shift via opts.shiftPrevious (it owns the §8 burst /
|
|
28
|
+
* coalescing-window accounting); the store never guesses the cadence.
|
|
29
|
+
* - Legacy `state/topic-frameworks.json` is a ONE-DIRECTIONAL read-only seed
|
|
30
|
+
* (never overwrites a profile entry) + a profile-store-written mirror so a
|
|
31
|
+
* binary rollback still reads current framework data (§5.1).
|
|
32
|
+
* - The dry-run shadow (`intendedProfile`, §14) is a sibling field that
|
|
33
|
+
* resolution NEVER reads; it has its own lifecycle (flip-clear, skew fates).
|
|
34
|
+
* - §10.4 parked intended-but-unhealthy profiles + breaker counter live on
|
|
35
|
+
* the entry so a revert can re-offer the pin and a new operator pin can
|
|
36
|
+
* atomically supersede the parked state in the same mutate.
|
|
37
|
+
*/
|
|
38
|
+
import fs from 'node:fs';
|
|
39
|
+
import path from 'node:path';
|
|
40
|
+
import { SUPPORTED_FRAMEWORKS } from './TopicFrameworksStore.js';
|
|
41
|
+
import { DegradationReporter } from '../monitoring/DegradationReporter.js';
|
|
42
|
+
import { modelTierMutualExclusionError, validateProfileFields, } from './topicProfileValidation.js';
|
|
43
|
+
export class FlushRefusedError extends Error {
|
|
44
|
+
constructor(cause) {
|
|
45
|
+
super(`topic-profile flush failed — the write is REFUSED and rolled back (durability precedes acknowledgment): ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
46
|
+
this.name = 'FlushRefusedError';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export class ProfileLockTimeoutError extends Error {
|
|
50
|
+
constructor(topicKey) {
|
|
51
|
+
super(`could not acquire the profile lock for topic ${topicKey} — a respawn is in progress`);
|
|
52
|
+
this.name = 'ProfileLockTimeoutError';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export class ProfileValidationRefusal extends Error {
|
|
56
|
+
validation;
|
|
57
|
+
constructor(validation) {
|
|
58
|
+
super(validation.reason);
|
|
59
|
+
this.validation = validation;
|
|
60
|
+
this.name = 'ProfileValidationRefusal';
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const WRITE_LOCK_TIMEOUT_MS = 5_000;
|
|
64
|
+
const PROFILE_FIELDS = ['framework', 'model', 'modelTier', 'escalationOverride', 'thinkingMode'];
|
|
65
|
+
export class TopicProfileStore {
|
|
66
|
+
stateFilePath;
|
|
67
|
+
legacyFrameworksPath;
|
|
68
|
+
isDryRun;
|
|
69
|
+
now;
|
|
70
|
+
/** AUTHORITATIVE in-memory cache (§5.1). */
|
|
71
|
+
topics = {};
|
|
72
|
+
/** Deep snapshot of the last durably-flushed topics map (rollback target). */
|
|
73
|
+
lastFlushed = {};
|
|
74
|
+
mirrorGeneratedAt;
|
|
75
|
+
/** Per-topic process-local locks (promise chains). */
|
|
76
|
+
locks = new Map();
|
|
77
|
+
/** Store-wide serialized flush queue (§5.1 round-4/6). */
|
|
78
|
+
flushInFlight = null;
|
|
79
|
+
trailingFlush = null;
|
|
80
|
+
constructor(options) {
|
|
81
|
+
this.stateFilePath = options.stateFilePath;
|
|
82
|
+
this.legacyFrameworksPath = options.legacyFrameworksPath ?? null;
|
|
83
|
+
this.isDryRun = options.isDryRun ?? (() => false);
|
|
84
|
+
this.now = options.now ?? (() => new Date());
|
|
85
|
+
this.audit = options.audit ?? (() => { });
|
|
86
|
+
this.loadFromDisk();
|
|
87
|
+
}
|
|
88
|
+
audit;
|
|
89
|
+
// ── reads (cache-only, O(1)) ─────────────────────────────────────────────
|
|
90
|
+
/** Entry for a topic, or null. Cache read — never touches disk. */
|
|
91
|
+
get(topicKey) {
|
|
92
|
+
return this.topics[String(topicKey)] ?? null;
|
|
93
|
+
}
|
|
94
|
+
/** The live profile for a topic (resolution input). Never the shadow. */
|
|
95
|
+
resolve(topicKey) {
|
|
96
|
+
return this.topics[String(topicKey)]?.current ?? null;
|
|
97
|
+
}
|
|
98
|
+
/** Undo target (§10.3). Null when nothing to undo yet. */
|
|
99
|
+
previousFor(topicKey) {
|
|
100
|
+
return this.topics[String(topicKey)]?.previous ?? null;
|
|
101
|
+
}
|
|
102
|
+
/** Admin/migration only (§5.1) — read surfaces use get(). */
|
|
103
|
+
all() {
|
|
104
|
+
return { ...this.topics };
|
|
105
|
+
}
|
|
106
|
+
// ── per-topic lock (shared ordering primitive, §5.1/§8) ──────────────────
|
|
107
|
+
/**
|
|
108
|
+
* Run `fn` holding the topic's process-local lock. The §8 RESPAWN phase and
|
|
109
|
+
* escalation swaps serialize through this same lock; `mutate` uses it for
|
|
110
|
+
* the WRITE phase. `timeoutMs` bounds ACQUISITION (a wedged holder causes a
|
|
111
|
+
* ProfileLockTimeoutError, not an unbounded queue); the holder itself is
|
|
112
|
+
* bounded by the caller (§8 RESPAWN-phase TTL).
|
|
113
|
+
*/
|
|
114
|
+
async withTopicLock(topicKey, fn, timeoutMs = WRITE_LOCK_TIMEOUT_MS) {
|
|
115
|
+
const key = String(topicKey);
|
|
116
|
+
const prior = this.locks.get(key) ?? Promise.resolve();
|
|
117
|
+
let release;
|
|
118
|
+
const gate = new Promise((res) => (release = res));
|
|
119
|
+
// Chain our slot regardless of the prior holder's outcome.
|
|
120
|
+
const slot = prior.then(() => gate, () => gate);
|
|
121
|
+
this.locks.set(key, slot);
|
|
122
|
+
// Bounded acquisition: wait for the prior holder or time out.
|
|
123
|
+
let acquired = false;
|
|
124
|
+
await Promise.race([
|
|
125
|
+
prior.then(() => (acquired = true), () => (acquired = true)),
|
|
126
|
+
new Promise((res) => setTimeout(res, timeoutMs).unref?.()),
|
|
127
|
+
]);
|
|
128
|
+
if (!acquired) {
|
|
129
|
+
// Preserve chain integrity: our slot releases only when the still-running
|
|
130
|
+
// prior holder finishes — otherwise the next waiter would run
|
|
131
|
+
// concurrently with it.
|
|
132
|
+
prior.then(() => release(), () => release());
|
|
133
|
+
throw new ProfileLockTimeoutError(key);
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
return await fn();
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
release();
|
|
140
|
+
if (this.locks.get(key) === slot)
|
|
141
|
+
this.locks.delete(key);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
// ── mutate (§5.1 WRITE phase) ────────────────────────────────────────────
|
|
145
|
+
/**
|
|
146
|
+
* Field-merged single-writer write. `null` field values CLEAR the field;
|
|
147
|
+
* `undefined` fields are untouched. Enforces the §4 model/modelTier hard
|
|
148
|
+
* mutual exclusion against the MERGE RESULT. Resolves only after the write
|
|
149
|
+
* is durable; throws FlushRefusedError (with rollback) when it is not.
|
|
150
|
+
*/
|
|
151
|
+
async mutate(topicKey, patch, opts = {}) {
|
|
152
|
+
const key = String(topicKey);
|
|
153
|
+
return this.withTopicLock(key, async () => {
|
|
154
|
+
const entry = this.entryFor(key);
|
|
155
|
+
const base = entry.current ?? {
|
|
156
|
+
updatedAt: this.now().toISOString(),
|
|
157
|
+
updatedBy: patch.updatedBy,
|
|
158
|
+
};
|
|
159
|
+
// Merge only the supplied fields.
|
|
160
|
+
const merged = { ...base };
|
|
161
|
+
let changed = false;
|
|
162
|
+
for (const field of PROFILE_FIELDS) {
|
|
163
|
+
if (patch[field] === undefined)
|
|
164
|
+
continue;
|
|
165
|
+
const next = patch[field];
|
|
166
|
+
if (merged[field] !== next)
|
|
167
|
+
changed = true;
|
|
168
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
169
|
+
merged[field] = next;
|
|
170
|
+
}
|
|
171
|
+
// §4 hard refusal against the merge RESULT (a patch setting model on
|
|
172
|
+
// a tier-pinned topic without clearing the tier is refused too).
|
|
173
|
+
const exclusion = modelTierMutualExclusionError(merged);
|
|
174
|
+
if (exclusion)
|
|
175
|
+
throw new ProfileValidationRefusal(exclusion);
|
|
176
|
+
if (!changed) {
|
|
177
|
+
return { changed: false, entry, supersededParked: false };
|
|
178
|
+
}
|
|
179
|
+
merged.updatedAt = this.now().toISOString();
|
|
180
|
+
merged.updatedBy = patch.updatedBy;
|
|
181
|
+
// §10.4 — a deliberate operator pin atomically supersedes a parked
|
|
182
|
+
// intended-but-unhealthy profile + the breaker counter in the SAME
|
|
183
|
+
// mutate. System writes (breaker revert itself) manage parked state
|
|
184
|
+
// through park()/unparkProfile(), not here.
|
|
185
|
+
const isOperatorWrite = !patch.updatedBy.startsWith('system:');
|
|
186
|
+
let supersededParked = false;
|
|
187
|
+
if (isOperatorWrite && entry.parked) {
|
|
188
|
+
entry.parked = null;
|
|
189
|
+
entry.breakerCount = 0;
|
|
190
|
+
supersededParked = true;
|
|
191
|
+
}
|
|
192
|
+
if (opts.shiftPrevious) {
|
|
193
|
+
entry.previous = entry.current ? { ...entry.current } : null;
|
|
194
|
+
}
|
|
195
|
+
entry.current = merged;
|
|
196
|
+
// An accepted LIVE write clears that topic's stale dry-run shadow
|
|
197
|
+
// (§14 supersession discipline).
|
|
198
|
+
if (entry.intendedProfile)
|
|
199
|
+
entry.intendedProfile = null;
|
|
200
|
+
await this.flushDurably();
|
|
201
|
+
return { changed: true, entry, supersededParked };
|
|
202
|
+
}, opts.lockTimeoutMs ?? WRITE_LOCK_TIMEOUT_MS);
|
|
203
|
+
}
|
|
204
|
+
// ── REPLACE (transfer-apply / restore-apply, §5.3/§12) ───────────────────
|
|
205
|
+
/**
|
|
206
|
+
* Wholesale per-topic REPLACE — NOT a field-merge (§5.3 round-3: a merge
|
|
207
|
+
* would skip nulls and resurrect a pin the operator cleared remotely).
|
|
208
|
+
* `previous` is pinned to the receiving machine's pre-replace `current`
|
|
209
|
+
* (undo means "back to what this machine had") — EXCEPT when the REPLACE
|
|
210
|
+
* produces no effective delta (A→B→A round-trip, duplicate retried pull):
|
|
211
|
+
* then `previous` must NOT shift (§5.1 round-10).
|
|
212
|
+
*
|
|
213
|
+
* Arriving-shadow fate under regime/timing skew (§14 round-13):
|
|
214
|
+
* (i) a NON-dry-run receiver discards the arriving shadow;
|
|
215
|
+
* (ii) an arriving entry with NO shadow never destroys a populated local
|
|
216
|
+
* shadow on a still-dry-run receiver — it is retained.
|
|
217
|
+
*/
|
|
218
|
+
async replaceEntry(topicKey, arriving, opts = {}) {
|
|
219
|
+
const key = String(topicKey);
|
|
220
|
+
// §5.3/§10.2 — receiving-machine revalidation of the ARRIVING entry
|
|
221
|
+
// (closed-enum + framework-compat) BEFORE it can persist or drive a
|
|
222
|
+
// launch. Failing fields fall to null; provenance travels verbatim.
|
|
223
|
+
let arrivingCurrent = arriving.current;
|
|
224
|
+
let arrivingShadow = arriving.intendedProfile;
|
|
225
|
+
const droppedFields = [];
|
|
226
|
+
if (opts.revalidate) {
|
|
227
|
+
const rv = revalidateArrivingProfile(arrivingCurrent, opts.revalidate.fallbackFramework);
|
|
228
|
+
arrivingCurrent = rv.profile;
|
|
229
|
+
droppedFields.push(...rv.dropped);
|
|
230
|
+
if (arrivingShadow != null) {
|
|
231
|
+
const sv = revalidateArrivingShadow(arrivingShadow, opts.revalidate.fallbackFramework);
|
|
232
|
+
arrivingShadow = sv.shadow;
|
|
233
|
+
droppedFields.push(...sv.dropped);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return this.withTopicLock(key, async () => {
|
|
237
|
+
const entry = this.entryFor(key);
|
|
238
|
+
const delta = !profilesEqual(entry.current, arrivingCurrent);
|
|
239
|
+
let discardedArrivingShadow = false;
|
|
240
|
+
let retainedLocalShadow = false;
|
|
241
|
+
const dryRun = this.isDryRun();
|
|
242
|
+
let nextShadow;
|
|
243
|
+
if (arrivingShadow != null) {
|
|
244
|
+
if (dryRun) {
|
|
245
|
+
nextShadow = arrivingShadow;
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
nextShadow = null;
|
|
249
|
+
discardedArrivingShadow = true;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
else if (entry.intendedProfile != null && dryRun) {
|
|
253
|
+
nextShadow = entry.intendedProfile;
|
|
254
|
+
retainedLocalShadow = true;
|
|
255
|
+
}
|
|
256
|
+
else {
|
|
257
|
+
nextShadow = null;
|
|
258
|
+
}
|
|
259
|
+
if (delta) {
|
|
260
|
+
entry.previous = entry.current ? { ...entry.current } : null;
|
|
261
|
+
entry.current = arrivingCurrent ? { ...arrivingCurrent } : null;
|
|
262
|
+
}
|
|
263
|
+
entry.intendedProfile = nextShadow;
|
|
264
|
+
await this.flushDurably();
|
|
265
|
+
return { delta, entry, retainedLocalShadow, discardedArrivingShadow, droppedFields };
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
// ── dry-run shadow lifecycle (§14) ───────────────────────────────────────
|
|
269
|
+
/** Record a dry-run intent. Never read by resolution. */
|
|
270
|
+
async setShadow(topicKey, fields, recordedBy) {
|
|
271
|
+
const key = String(topicKey);
|
|
272
|
+
await this.withTopicLock(key, async () => {
|
|
273
|
+
const entry = this.entryFor(key);
|
|
274
|
+
entry.intendedProfile = {
|
|
275
|
+
fields,
|
|
276
|
+
recordedAt: this.now().toISOString(),
|
|
277
|
+
recordedBy,
|
|
278
|
+
};
|
|
279
|
+
await this.flushDurably();
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* The dryRun true→false flip clears EVERY topic's shadow — intents are
|
|
284
|
+
* NEVER promoted, at the flip or ever (§14 round-12). Returns the cleared
|
|
285
|
+
* intents so the caller can surface the ONE coalesced expired-intents
|
|
286
|
+
* notice.
|
|
287
|
+
*/
|
|
288
|
+
async clearAllShadows() {
|
|
289
|
+
const cleared = [];
|
|
290
|
+
for (const [key, entry] of Object.entries(this.topics)) {
|
|
291
|
+
if (entry.intendedProfile) {
|
|
292
|
+
cleared.push({ topicKey: key, shadow: entry.intendedProfile });
|
|
293
|
+
entry.intendedProfile = null;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
if (cleared.length > 0)
|
|
297
|
+
await this.flushDurably();
|
|
298
|
+
return cleared;
|
|
299
|
+
}
|
|
300
|
+
// ── §10.4 parked-profile + breaker counter ───────────────────────────────
|
|
301
|
+
/** Park the current profile as intended-but-unhealthy and revert. */
|
|
302
|
+
async parkAndRevert(topicKey, reason, revertTo) {
|
|
303
|
+
const key = String(topicKey);
|
|
304
|
+
await this.withTopicLock(key, async () => {
|
|
305
|
+
const entry = this.entryFor(key);
|
|
306
|
+
if (entry.current) {
|
|
307
|
+
entry.parked = {
|
|
308
|
+
profile: { ...entry.current },
|
|
309
|
+
reason,
|
|
310
|
+
parkedAt: this.now().toISOString(),
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
entry.previous = entry.current ? { ...entry.current } : null;
|
|
314
|
+
entry.current = revertTo
|
|
315
|
+
? { ...revertTo, updatedAt: this.now().toISOString(), updatedBy: 'system:circuit-breaker' }
|
|
316
|
+
: null;
|
|
317
|
+
entry.breakerCount = 0;
|
|
318
|
+
await this.flushDurably();
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
/** The parked pin, for the §5.2(b) re-apply recovery write. */
|
|
322
|
+
parkedFor(topicKey) {
|
|
323
|
+
return this.topics[String(topicKey)]?.parked ?? null;
|
|
324
|
+
}
|
|
325
|
+
/** Clear the parked state (after a successful re-apply). */
|
|
326
|
+
async clearParked(topicKey) {
|
|
327
|
+
const key = String(topicKey);
|
|
328
|
+
await this.withTopicLock(key, async () => {
|
|
329
|
+
const entry = this.entryFor(key);
|
|
330
|
+
if (entry.parked) {
|
|
331
|
+
entry.parked = null;
|
|
332
|
+
await this.flushDurably();
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
/** Increment the attributable spawn-failure counter; returns the new count. */
|
|
337
|
+
async incrementBreaker(topicKey) {
|
|
338
|
+
const key = String(topicKey);
|
|
339
|
+
return this.withTopicLock(key, async () => {
|
|
340
|
+
const entry = this.entryFor(key);
|
|
341
|
+
entry.breakerCount += 1;
|
|
342
|
+
await this.flushDurably();
|
|
343
|
+
return entry.breakerCount;
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
/** §10.4 — the counter resets on any successful spawn. */
|
|
347
|
+
async resetBreaker(topicKey) {
|
|
348
|
+
const key = String(topicKey);
|
|
349
|
+
await this.withTopicLock(key, async () => {
|
|
350
|
+
const entry = this.entryFor(key);
|
|
351
|
+
if (entry.breakerCount !== 0) {
|
|
352
|
+
entry.breakerCount = 0;
|
|
353
|
+
await this.flushDurably();
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
// ── external-change invalidation (§5.1 / §12 restore carrier) ────────────
|
|
358
|
+
/**
|
|
359
|
+
* Explicit cache invalidation — the restore route fires this after a
|
|
360
|
+
* snapshot restore lands on disk (the file is otherwise server-owned
|
|
361
|
+
* single-writer; external edits reconcile only here or at boot).
|
|
362
|
+
*/
|
|
363
|
+
invalidateAndReload() {
|
|
364
|
+
this.topics = {};
|
|
365
|
+
this.loadFromDisk();
|
|
366
|
+
}
|
|
367
|
+
// ── boot load + legacy seed (§5.1) ───────────────────────────────────────
|
|
368
|
+
loadFromDisk() {
|
|
369
|
+
try {
|
|
370
|
+
if (fs.existsSync(this.stateFilePath)) {
|
|
371
|
+
const raw = fs.readFileSync(this.stateFilePath, 'utf-8');
|
|
372
|
+
const parsed = JSON.parse(raw);
|
|
373
|
+
if (parsed && typeof parsed === 'object' && parsed.topics && typeof parsed.topics === 'object') {
|
|
374
|
+
for (const [key, value] of Object.entries(parsed.topics)) {
|
|
375
|
+
const entry = sanitizeEntry(value);
|
|
376
|
+
if (entry)
|
|
377
|
+
this.topics[key] = entry;
|
|
378
|
+
}
|
|
379
|
+
this.mirrorGeneratedAt = typeof parsed.mirrorGeneratedAt === 'string' ? parsed.mirrorGeneratedAt : undefined;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
catch (err) {
|
|
384
|
+
// Corrupt/unreadable state — boot must succeed; fall back to empty +
|
|
385
|
+
// legacy seed below. Loud in logs, never a throw.
|
|
386
|
+
console.warn(`[TopicProfileStore] Failed to load ${this.stateFilePath}: ${err}`);
|
|
387
|
+
this.topics = {};
|
|
388
|
+
}
|
|
389
|
+
const seeded = this.seedFromLegacy();
|
|
390
|
+
this.lastFlushed = deepCloneTopics(this.topics);
|
|
391
|
+
this.bootFinalize(seeded);
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Boot persistence + mirror self-heal (§12). Seeded/reconciled entries are
|
|
395
|
+
* flushed so `topic-profiles.json` exists from first boot, and a mirror
|
|
396
|
+
* that diverges from the profile store is regenerated (the flush path does
|
|
397
|
+
* both). Best-effort and async — boot itself never refuses; the cache is
|
|
398
|
+
* authoritative and the seed is reconstructible at every boot.
|
|
399
|
+
*/
|
|
400
|
+
bootFinalize(seededOrReconciled) {
|
|
401
|
+
let needsFlush = seededOrReconciled;
|
|
402
|
+
if (!needsFlush && Object.keys(this.topics).length > 0 && !fs.existsSync(this.stateFilePath)) {
|
|
403
|
+
needsFlush = true;
|
|
404
|
+
}
|
|
405
|
+
if (!needsFlush && this.legacyFrameworksPath) {
|
|
406
|
+
// Mirror divergence check: compare the legacy file's framework view
|
|
407
|
+
// against the profile store's. Divergent → regenerate via flush.
|
|
408
|
+
try {
|
|
409
|
+
const view = {};
|
|
410
|
+
for (const [key, entry] of Object.entries(this.topics)) {
|
|
411
|
+
const fw = entry.current?.framework;
|
|
412
|
+
if (fw)
|
|
413
|
+
view[key] = fw;
|
|
414
|
+
}
|
|
415
|
+
let legacyView = {};
|
|
416
|
+
if (fs.existsSync(this.legacyFrameworksPath)) {
|
|
417
|
+
const parsed = JSON.parse(fs.readFileSync(this.legacyFrameworksPath, 'utf-8'));
|
|
418
|
+
if (parsed?.topics && typeof parsed.topics === 'object')
|
|
419
|
+
legacyView = parsed.topics;
|
|
420
|
+
}
|
|
421
|
+
if (JSON.stringify(view) !== JSON.stringify(legacyView))
|
|
422
|
+
needsFlush = true;
|
|
423
|
+
}
|
|
424
|
+
catch {
|
|
425
|
+
// Unreadable mirror — the flush below rewrites it.
|
|
426
|
+
needsFlush = true;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
if (needsFlush) {
|
|
430
|
+
void this.flushDurably().catch((err) => {
|
|
431
|
+
console.warn(`[TopicProfileStore] Boot flush failed (cache stays authoritative, retried at next write): ${err}`);
|
|
432
|
+
DegradationReporter.getInstance().report({
|
|
433
|
+
feature: 'TopicProfileStore.bootFlush',
|
|
434
|
+
primary: 'Persist topic-profile state and regenerate the legacy mirror at boot',
|
|
435
|
+
fallback: 'In-memory cache stays authoritative; the durable file and legacy mirror stay stale until the next successful write',
|
|
436
|
+
reason: `Boot flush failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
437
|
+
impact: 'A crash before the next successful write loses boot-time seeds/reconciles; a rolled-back binary would read a divergent legacy mirror',
|
|
438
|
+
});
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* One-directional read-only seed from the legacy framework store: seed the
|
|
444
|
+
* `framework` field for any topic ABSENT from the profile store. Never
|
|
445
|
+
* overwrites — EXCEPT the §12 rollback-window reconcile: when the legacy
|
|
446
|
+
* file's `updatedAt` differs from the last mirror-write stamp, the legacy
|
|
447
|
+
* file was written by something other than this store's mirror (a rolled-
|
|
448
|
+
* back binary's live `/route` writes), so topics whose framework VALUE
|
|
449
|
+
* differs re-seed the framework arm only, audited. `previous: null` on
|
|
450
|
+
* seeds (§5.1 — undo with no snapshot is refused plainly upstream).
|
|
451
|
+
*/
|
|
452
|
+
seedFromLegacy() {
|
|
453
|
+
if (!this.legacyFrameworksPath)
|
|
454
|
+
return false;
|
|
455
|
+
try {
|
|
456
|
+
if (!fs.existsSync(this.legacyFrameworksPath))
|
|
457
|
+
return false;
|
|
458
|
+
const raw = fs.readFileSync(this.legacyFrameworksPath, 'utf-8');
|
|
459
|
+
const parsed = JSON.parse(raw);
|
|
460
|
+
if (!parsed?.topics || typeof parsed.topics !== 'object')
|
|
461
|
+
return false;
|
|
462
|
+
// §12 rollback-window predicate: file-level legacy updatedAt vs the
|
|
463
|
+
// mirror-generation stamp. Equal ⇒ the legacy file is our own mirror
|
|
464
|
+
// write — never reconcile values from it (it is read-only output).
|
|
465
|
+
const legacyExternallyWritten = typeof parsed.updatedAt === 'string' && parsed.updatedAt !== this.mirrorGeneratedAt;
|
|
466
|
+
// Retain a one-time migration-time snapshot as the rollback artifact.
|
|
467
|
+
const snapshotPath = `${this.legacyFrameworksPath}.pre-profile-seed`;
|
|
468
|
+
let seededAny = false;
|
|
469
|
+
for (const [key, fw] of Object.entries(parsed.topics)) {
|
|
470
|
+
if (typeof fw !== 'string' || !SUPPORTED_FRAMEWORKS.includes(fw))
|
|
471
|
+
continue;
|
|
472
|
+
const existing = this.topics[key]?.current;
|
|
473
|
+
if (existing) {
|
|
474
|
+
// §12: BOTH predicates — externally written AND file-level legacy
|
|
475
|
+
// updatedAt newer than this entry's profile updatedAt. The second
|
|
476
|
+
// guard keeps the crash-window case (profile flushed, mirror write
|
|
477
|
+
// lost — profile entries are NEWER) from pulling stale legacy
|
|
478
|
+
// values backwards; there the mirror self-heal regenerates instead.
|
|
479
|
+
const legacyNewerThanEntry = typeof parsed.updatedAt === 'string' && parsed.updatedAt > existing.updatedAt;
|
|
480
|
+
if (legacyExternallyWritten && legacyNewerThanEntry && existing.framework !== fw) {
|
|
481
|
+
// Rollback-window reconcile: framework arm only, value-differs only.
|
|
482
|
+
existing.framework = fw;
|
|
483
|
+
existing.updatedAt = this.now().toISOString();
|
|
484
|
+
existing.updatedBy = 'system:rollback-window-reconcile';
|
|
485
|
+
seededAny = true;
|
|
486
|
+
this.audit({
|
|
487
|
+
kind: 'rollback-window-reconcile',
|
|
488
|
+
topicKey: key,
|
|
489
|
+
framework: fw,
|
|
490
|
+
legacyUpdatedAt: parsed.updatedAt,
|
|
491
|
+
mirrorGeneratedAt: this.mirrorGeneratedAt ?? null,
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
const entry = this.entryFor(key);
|
|
497
|
+
entry.current = {
|
|
498
|
+
framework: fw,
|
|
499
|
+
updatedAt: this.now().toISOString(),
|
|
500
|
+
updatedBy: 'system:legacy-seed',
|
|
501
|
+
};
|
|
502
|
+
entry.previous = null;
|
|
503
|
+
seededAny = true;
|
|
504
|
+
}
|
|
505
|
+
if (seededAny && !fs.existsSync(snapshotPath)) {
|
|
506
|
+
try {
|
|
507
|
+
fs.copyFileSync(this.legacyFrameworksPath, snapshotPath);
|
|
508
|
+
}
|
|
509
|
+
catch {
|
|
510
|
+
// @silent-fallback-ok: the §12 rollback snapshot is a best-effort
|
|
511
|
+
// forensic artifact — the seed itself already succeeded (and is
|
|
512
|
+
// audited via the entry writes); a missing snapshot only disables
|
|
513
|
+
// the optional rollback-window reconcile, never live routing.
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
return seededAny;
|
|
517
|
+
}
|
|
518
|
+
catch (err) {
|
|
519
|
+
console.warn(`[TopicProfileStore] Legacy seed from ${this.legacyFrameworksPath} failed: ${err}`);
|
|
520
|
+
DegradationReporter.getInstance().report({
|
|
521
|
+
feature: 'TopicProfileStore.seedFromLegacy',
|
|
522
|
+
primary: 'Seed framework pins from the legacy topic-frameworks file at boot',
|
|
523
|
+
fallback: 'Continue with whatever the profile store already holds — legacy framework routing is not imported',
|
|
524
|
+
reason: `Legacy seed read/parse failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
525
|
+
impact: 'Topics routed by the legacy file may silently resolve to the default framework until re-pinned',
|
|
526
|
+
});
|
|
527
|
+
return false;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
// ── durable flush (serialized + coalescing, §5.1) ────────────────────────
|
|
531
|
+
/**
|
|
532
|
+
* Serialize flushes store-wide. A flush snapshots the authoritative cache
|
|
533
|
+
* AT FLUSH TIME, so a later-completing flush can never persist an older
|
|
534
|
+
* snapshot. Writes arriving mid-flight coalesce onto ONE trailing flush.
|
|
535
|
+
* Failure: every undurable waiter is refused + the cache rolls back to the
|
|
536
|
+
* last durably-flushed snapshot BEFORE the refusals fire.
|
|
537
|
+
*/
|
|
538
|
+
flushDurably() {
|
|
539
|
+
if (this.flushInFlight) {
|
|
540
|
+
// Coalesce: all waiters during an in-flight flush share one trailing
|
|
541
|
+
// flush of the latest snapshot (their writes are already in the cache).
|
|
542
|
+
if (!this.trailingFlush) {
|
|
543
|
+
this.trailingFlush = this.flushInFlight.then(() => {
|
|
544
|
+
this.trailingFlush = null;
|
|
545
|
+
return this.flushDurably();
|
|
546
|
+
}, (err) => {
|
|
547
|
+
// A failed leader already ROLLED BACK the cache — wiping the
|
|
548
|
+
// trailing waiters' writes too. Refusing them together (rather
|
|
549
|
+
// than flushing the rolled-back state and ACKing silently-dropped
|
|
550
|
+
// writes) is the §5.1 coalesced-failure rule.
|
|
551
|
+
this.trailingFlush = null;
|
|
552
|
+
throw err;
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
return this.trailingFlush;
|
|
556
|
+
}
|
|
557
|
+
this.flushInFlight = (async () => {
|
|
558
|
+
const snapshot = deepCloneTopics(this.topics);
|
|
559
|
+
const mirrorStamp = this.mirrorGeneratedAt;
|
|
560
|
+
try {
|
|
561
|
+
await this.writeFileAtomic(snapshot, mirrorStamp);
|
|
562
|
+
this.lastFlushed = snapshot;
|
|
563
|
+
}
|
|
564
|
+
catch (err) {
|
|
565
|
+
// Rollback to the last durably-flushed snapshot, then refuse loudly.
|
|
566
|
+
this.topics = deepCloneTopics(this.lastFlushed);
|
|
567
|
+
throw new FlushRefusedError(err);
|
|
568
|
+
}
|
|
569
|
+
finally {
|
|
570
|
+
this.flushInFlight = null;
|
|
571
|
+
}
|
|
572
|
+
this.regenerateMirror(snapshot);
|
|
573
|
+
})();
|
|
574
|
+
return this.flushInFlight;
|
|
575
|
+
}
|
|
576
|
+
async writeFileAtomic(topics, mirrorStamp) {
|
|
577
|
+
const dir = path.dirname(this.stateFilePath);
|
|
578
|
+
if (!fs.existsSync(dir))
|
|
579
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
580
|
+
const state = {
|
|
581
|
+
updatedAt: this.now().toISOString(),
|
|
582
|
+
...(mirrorStamp ? { mirrorGeneratedAt: mirrorStamp } : {}),
|
|
583
|
+
topics,
|
|
584
|
+
};
|
|
585
|
+
const tmp = `${this.stateFilePath}.${process.pid}.tmp`;
|
|
586
|
+
await fs.promises.writeFile(tmp, JSON.stringify(state, null, 2), 'utf-8');
|
|
587
|
+
await fs.promises.rename(tmp, this.stateFilePath);
|
|
588
|
+
}
|
|
589
|
+
/**
|
|
590
|
+
* §5.1/§12 — the legacy file becomes a read-only mirror regenerated from
|
|
591
|
+
* the profile store (sole writer = this store), so a binary rollback to
|
|
592
|
+
* the prior release still reads current framework data. Best-effort,
|
|
593
|
+
* SECOND (profile file is the source of truth and flushes first), and only
|
|
594
|
+
* rewritten when the framework view actually changed.
|
|
595
|
+
*/
|
|
596
|
+
lastMirrorView = null;
|
|
597
|
+
regenerateMirror(topics) {
|
|
598
|
+
if (!this.legacyFrameworksPath)
|
|
599
|
+
return;
|
|
600
|
+
try {
|
|
601
|
+
const view = {};
|
|
602
|
+
for (const [key, entry] of Object.entries(topics)) {
|
|
603
|
+
const fw = entry.current?.framework;
|
|
604
|
+
if (fw)
|
|
605
|
+
view[key] = fw;
|
|
606
|
+
}
|
|
607
|
+
const serialized = JSON.stringify(view);
|
|
608
|
+
if (serialized === this.lastMirrorView)
|
|
609
|
+
return;
|
|
610
|
+
this.lastMirrorView = serialized;
|
|
611
|
+
const stamp = this.now().toISOString();
|
|
612
|
+
this.mirrorGeneratedAt = stamp;
|
|
613
|
+
const mirror = { updatedAt: stamp, topics: view };
|
|
614
|
+
const tmp = `${this.legacyFrameworksPath}.${process.pid}.tmp`;
|
|
615
|
+
fs.writeFileSync(tmp, JSON.stringify(mirror, null, 2), 'utf-8');
|
|
616
|
+
fs.renameSync(tmp, this.legacyFrameworksPath);
|
|
617
|
+
}
|
|
618
|
+
catch (err) {
|
|
619
|
+
// Mirror divergence self-heals at boot (§12); never fail the write path.
|
|
620
|
+
console.warn(`[TopicProfileStore] Mirror regeneration failed (self-heals at boot): ${err}`);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
/** The mirror-generation stamp (§12 rollback-window reconcile predicate). */
|
|
624
|
+
getMirrorGeneratedAt() {
|
|
625
|
+
return this.mirrorGeneratedAt;
|
|
626
|
+
}
|
|
627
|
+
// ── internals ────────────────────────────────────────────────────────────
|
|
628
|
+
entryFor(key) {
|
|
629
|
+
let entry = this.topics[key];
|
|
630
|
+
if (!entry) {
|
|
631
|
+
entry = { current: null, previous: null, intendedProfile: null, parked: null, breakerCount: 0 };
|
|
632
|
+
this.topics[key] = entry;
|
|
633
|
+
}
|
|
634
|
+
return entry;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* §5.3/§10.2 — clamp an ARRIVING (peer-asserted, untrusted) profile through
|
|
639
|
+
* the closed enums, field by field. Unlike the write-surface refusal
|
|
640
|
+
* (first-error, profile unchanged), transfer-apply degrades PER FIELD: a
|
|
641
|
+
* failing field falls to null (the default for that field at resolution)
|
|
642
|
+
* and is reported for the one-line disclosure; valid sibling fields are
|
|
643
|
+
* kept. Provenance (updatedAt/updatedBy) travels verbatim — a malformed
|
|
644
|
+
* provenance pair means the entry is not a well-formed profile at all and
|
|
645
|
+
* is treated as absent (current: null), reported.
|
|
646
|
+
*
|
|
647
|
+
* The arriving model id validates against the arriving entry's OWN
|
|
648
|
+
* framework when that framework is enum-valid, else `fallbackFramework`
|
|
649
|
+
* (§10.2 framework-compat — a model incompatible with the framework falls).
|
|
650
|
+
* A clamped result holding BOTH model and modelTier drops both (§4 hard
|
|
651
|
+
* mutual exclusion — picking a winner silently is the named anti-pattern).
|
|
652
|
+
*/
|
|
653
|
+
export function revalidateArrivingProfile(arriving, fallbackFramework) {
|
|
654
|
+
if (arriving === null)
|
|
655
|
+
return { profile: null, dropped: [] };
|
|
656
|
+
const dropped = [];
|
|
657
|
+
if (typeof arriving.updatedAt !== 'string' || typeof arriving.updatedBy !== 'string') {
|
|
658
|
+
return {
|
|
659
|
+
profile: null,
|
|
660
|
+
dropped: [
|
|
661
|
+
{
|
|
662
|
+
field: 'updatedAt/updatedBy',
|
|
663
|
+
failure: 'unknown-field',
|
|
664
|
+
reason: 'arriving entry carries malformed provenance — treated as absent',
|
|
665
|
+
},
|
|
666
|
+
],
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
const { fields, fieldDropped } = clampArrivingFields(arriving, fallbackFramework);
|
|
670
|
+
dropped.push(...fieldDropped);
|
|
671
|
+
return {
|
|
672
|
+
profile: {
|
|
673
|
+
...fields,
|
|
674
|
+
updatedAt: arriving.updatedAt,
|
|
675
|
+
updatedBy: arriving.updatedBy,
|
|
676
|
+
},
|
|
677
|
+
dropped,
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
/** §5.3 — the dry-run shadow travels on the pull and is revalidated the same way. */
|
|
681
|
+
export function revalidateArrivingShadow(arriving, fallbackFramework) {
|
|
682
|
+
if (!arriving ||
|
|
683
|
+
typeof arriving !== 'object' ||
|
|
684
|
+
typeof arriving.recordedAt !== 'string' ||
|
|
685
|
+
typeof arriving.recordedBy !== 'string' ||
|
|
686
|
+
!arriving.fields ||
|
|
687
|
+
typeof arriving.fields !== 'object') {
|
|
688
|
+
return {
|
|
689
|
+
shadow: null,
|
|
690
|
+
dropped: [
|
|
691
|
+
{
|
|
692
|
+
field: 'intendedProfile',
|
|
693
|
+
failure: 'unknown-field',
|
|
694
|
+
reason: 'arriving dry-run shadow is malformed — discarded',
|
|
695
|
+
},
|
|
696
|
+
],
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
const { fields, fieldDropped } = clampArrivingFields(arriving.fields, fallbackFramework);
|
|
700
|
+
return {
|
|
701
|
+
shadow: { fields, recordedAt: arriving.recordedAt, recordedBy: arriving.recordedBy },
|
|
702
|
+
dropped: fieldDropped,
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
/** Per-field §10.2 clamp shared by the live-profile and shadow revalidation. */
|
|
706
|
+
function clampArrivingFields(source, fallbackFramework) {
|
|
707
|
+
const fieldDropped = [];
|
|
708
|
+
const fields = {};
|
|
709
|
+
// Framework first — the model arm validates against it (§10.2).
|
|
710
|
+
let effectiveFramework = fallbackFramework;
|
|
711
|
+
if (source.framework != null) {
|
|
712
|
+
const v = validateProfileFields({ framework: String(source.framework) }, fallbackFramework);
|
|
713
|
+
if (v.ok && v.patch.framework) {
|
|
714
|
+
fields.framework = v.patch.framework;
|
|
715
|
+
effectiveFramework = v.patch.framework;
|
|
716
|
+
}
|
|
717
|
+
else if (!v.ok) {
|
|
718
|
+
fieldDropped.push(v.error);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
for (const field of ['model', 'modelTier', 'thinkingMode', 'escalationOverride']) {
|
|
722
|
+
const raw = source[field];
|
|
723
|
+
if (raw == null)
|
|
724
|
+
continue;
|
|
725
|
+
const v = validateProfileFields({ [field]: String(raw) }, effectiveFramework);
|
|
726
|
+
if (v.ok) {
|
|
727
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
728
|
+
fields[field] = v.patch[field];
|
|
729
|
+
}
|
|
730
|
+
else {
|
|
731
|
+
fieldDropped.push(v.error);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
// §4 hard mutual exclusion against the clamped result — both set means a
|
|
735
|
+
// divergent/forged peer entry; dropping BOTH is the only non-silent fate.
|
|
736
|
+
const exclusion = modelTierMutualExclusionError(fields);
|
|
737
|
+
if (exclusion) {
|
|
738
|
+
fields.model = null;
|
|
739
|
+
fields.modelTier = null;
|
|
740
|
+
fieldDropped.push(exclusion);
|
|
741
|
+
}
|
|
742
|
+
return { fields, fieldDropped };
|
|
743
|
+
}
|
|
744
|
+
/** Field-level profile equality over the five profile axes (delta detection). */
|
|
745
|
+
export function profilesEqual(a, b) {
|
|
746
|
+
if (a === null && b === null)
|
|
747
|
+
return true;
|
|
748
|
+
if (a === null || b === null)
|
|
749
|
+
return false;
|
|
750
|
+
for (const field of PROFILE_FIELDS) {
|
|
751
|
+
if ((a[field] ?? null) !== (b[field] ?? null))
|
|
752
|
+
return false;
|
|
753
|
+
}
|
|
754
|
+
return true;
|
|
755
|
+
}
|
|
756
|
+
function deepCloneTopics(topics) {
|
|
757
|
+
return JSON.parse(JSON.stringify(topics));
|
|
758
|
+
}
|
|
759
|
+
function sanitizeEntry(value) {
|
|
760
|
+
if (!value || typeof value !== 'object')
|
|
761
|
+
return null;
|
|
762
|
+
const v = value;
|
|
763
|
+
return {
|
|
764
|
+
current: sanitizeProfile(v.current),
|
|
765
|
+
previous: sanitizeProfile(v.previous),
|
|
766
|
+
intendedProfile: v.intendedProfile && typeof v.intendedProfile === 'object'
|
|
767
|
+
? v.intendedProfile
|
|
768
|
+
: null,
|
|
769
|
+
parked: v.parked && typeof v.parked === 'object' ? v.parked : null,
|
|
770
|
+
breakerCount: typeof v.breakerCount === 'number' && v.breakerCount >= 0 ? v.breakerCount : 0,
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
function sanitizeProfile(value) {
|
|
774
|
+
if (!value || typeof value !== 'object')
|
|
775
|
+
return null;
|
|
776
|
+
const v = value;
|
|
777
|
+
if (typeof v.updatedAt !== 'string' || typeof v.updatedBy !== 'string')
|
|
778
|
+
return null;
|
|
779
|
+
return v;
|
|
780
|
+
}
|
|
781
|
+
//# sourceMappingURL=TopicProfileStore.js.map
|