@tpsdev-ai/flair 0.21.0 → 0.22.1
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/README.md +10 -7
- package/SECURITY.md +24 -2
- package/config.yaml +32 -5
- package/dist/cli.js +1811 -221
- package/dist/deploy.js +3 -4
- package/dist/fleet-presence.js +98 -0
- package/dist/fleet-verify.js +291 -0
- package/dist/mcp-client-assertion.js +364 -0
- package/dist/probe.js +97 -0
- package/dist/rem/runner.js +82 -12
- package/dist/resources/AttentionQuery.js +356 -0
- package/dist/resources/Credential.js +11 -1
- package/dist/resources/Federation.js +23 -0
- package/dist/resources/MCPClientMetadata.js +88 -0
- package/dist/resources/Memory.js +52 -53
- package/dist/resources/MemoryBootstrap.js +422 -76
- package/dist/resources/MemoryReflect.js +105 -49
- package/dist/resources/MemoryUsage.js +104 -0
- package/dist/resources/OAuth.js +8 -1
- package/dist/resources/OrgEvent.js +11 -0
- package/dist/resources/Presence.js +218 -19
- package/dist/resources/RecordUsage.js +230 -0
- package/dist/resources/Relationship.js +98 -25
- package/dist/resources/SemanticSearch.js +85 -317
- package/dist/resources/SkillScan.js +15 -0
- package/dist/resources/WorkspaceState.js +11 -0
- package/dist/resources/agent-auth.js +60 -2
- package/dist/resources/auth-middleware.js +18 -9
- package/dist/resources/bm25.js +12 -6
- package/dist/resources/collision-lib.js +157 -0
- package/dist/resources/embeddings-boot.js +149 -0
- package/dist/resources/embeddings-provider.js +364 -106
- package/dist/resources/entity-vocab.js +139 -0
- package/dist/resources/health.js +97 -6
- package/dist/resources/mcp-client-metadata-fields.js +112 -0
- package/dist/resources/mcp-handler.js +1 -1
- package/dist/resources/mcp-tools.js +88 -10
- package/dist/resources/memory-reflect-lib.js +289 -0
- package/dist/resources/migration-boot.js +143 -0
- package/dist/resources/migrations/dir-safety.js +71 -0
- package/dist/resources/migrations/embedding-stamp.js +178 -0
- package/dist/resources/migrations/envelope.js +43 -0
- package/dist/resources/migrations/export.js +38 -0
- package/dist/resources/migrations/ledger.js +55 -0
- package/dist/resources/migrations/lock.js +154 -0
- package/dist/resources/migrations/progress.js +31 -0
- package/dist/resources/migrations/registry.js +36 -0
- package/dist/resources/migrations/risk-policy.js +23 -0
- package/dist/resources/migrations/runner.js +455 -0
- package/dist/resources/migrations/snapshot.js +127 -0
- package/dist/resources/migrations/source-fields.js +93 -0
- package/dist/resources/migrations/space.js +167 -0
- package/dist/resources/migrations/state.js +64 -0
- package/dist/resources/migrations/status.js +39 -0
- package/dist/resources/migrations/synthetic-test-migration.js +93 -0
- package/dist/resources/migrations/types.js +9 -0
- package/dist/resources/presence-internal.js +29 -0
- package/dist/resources/provenance.js +50 -0
- package/dist/resources/rate-limiter.js +8 -1
- package/dist/resources/scoring.js +124 -5
- package/dist/resources/semantic-retrieval-core.js +317 -0
- package/dist/version-handshake.js +122 -0
- package/package.json +4 -5
- package/schemas/event.graphql +5 -0
- package/schemas/memory.graphql +66 -0
- package/schemas/schema.graphql +5 -43
- package/schemas/workspace.graphql +3 -0
- package/dist/resources/IngestEvents.js +0 -162
- package/dist/resources/IssueTokens.js +0 -19
- package/dist/resources/ObsAgentSnapshot.js +0 -13
- package/dist/resources/ObsEventFeed.js +0 -13
- package/dist/resources/ObsOffice.js +0 -19
- package/dist/resources/ObservationCenter.js +0 -23
- package/ui/observation-center.html +0 -385
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* runner.ts — the migration cycle orchestrator. One call to
|
|
3
|
+
* `runMigrationCycle()` = one full pass: detect pending migrations → (if
|
|
4
|
+
* any) one shared async pre-hash → per-migration pre-flight ladder →
|
|
5
|
+
* risk-scoped snapshot → throttled batches with per-row markers →
|
|
6
|
+
* risk-scoped completion gate → post-hash comparison → ledger OrgEvent →
|
|
7
|
+
* state-file update → snapshot prune.
|
|
8
|
+
*
|
|
9
|
+
* Halt-don't-brick everywhere (flair#695 invariant
|
|
10
|
+
* II): this function NEVER throws — every failure mode (space-blocked,
|
|
11
|
+
* snapshot-failed, pre-hash-failed, gate-failed, an unexpected exception)
|
|
12
|
+
* resolves to a halted/failed progress entry + (where applicable) a ledger
|
|
13
|
+
* event, and the loop moves on to the next migration rather than
|
|
14
|
+
* propagating. The caller (resources/MigrationBoot.ts) is the boot path —
|
|
15
|
+
* an exception escaping this function would risk destabilizing an
|
|
16
|
+
* already-serving process, which is exactly what invariant II forbids.
|
|
17
|
+
*/
|
|
18
|
+
import { acquireMigrationLock } from "./lock.js";
|
|
19
|
+
import { checkSpace, defaultSpaceProbe } from "./space.js";
|
|
20
|
+
import { createMigrationSnapshot, pruneMigrationSnapshots } from "./snapshot.js";
|
|
21
|
+
import { createContentOnlyExport } from "./export.js";
|
|
22
|
+
import { computeCorpusEnvelope } from "./envelope.js";
|
|
23
|
+
import { hashSourceFields, sourceFieldsFor } from "./source-fields.js";
|
|
24
|
+
import { postureFor } from "./risk-policy.js";
|
|
25
|
+
import { readMigrationState, writeMigrationStateEntry, isShortCircuited, defaultStatePath } from "./state.js";
|
|
26
|
+
import { writeLedgerEvent } from "./ledger.js";
|
|
27
|
+
import { setCyclePhase, setMigrationProgress, seedIdleProgress } from "./progress.js";
|
|
28
|
+
import { shouldRegisterSyntheticMigration } from "./synthetic-test-migration.js";
|
|
29
|
+
import { join } from "node:path";
|
|
30
|
+
/**
|
|
31
|
+
* Test-only batch-throttle override, mirroring space.ts's
|
|
32
|
+
* FLAIR_MIGRATION_TEST_FREE_BYTES pattern (real Harper is a spawned child
|
|
33
|
+
* process in the integration tests — an env var propagated at spawn time is
|
|
34
|
+
* the only injection lever available there). Used by
|
|
35
|
+
* test/integration/migrations-resume-after-kill.test.ts to WIDEN the
|
|
36
|
+
* per-batch delay so the migration's running phase spans seconds instead of
|
|
37
|
+
* a few hundred milliseconds, making "kill it mid-flight" deterministic
|
|
38
|
+
* rather than a poll-timing race (the CI failure mode this fixes: on a slow
|
|
39
|
+
* shared runner, the whole running phase fit between two health polls).
|
|
40
|
+
*
|
|
41
|
+
* DOUBLE-GATED: honored ONLY when the synthetic CI-migration gate
|
|
42
|
+
* (FLAIR_ENABLE_TEST_MIGRATIONS === "1", the same exact-match opt-in that
|
|
43
|
+
* admits the synthetic migration itself) is active — a stray env var on a
|
|
44
|
+
* production deployment (gate off) can never alter the real 100ms throttle.
|
|
45
|
+
*/
|
|
46
|
+
export const TEST_BATCH_DELAY_ENV = "FLAIR_MIGRATION_TEST_BATCH_DELAY_MS";
|
|
47
|
+
function resolveTestBatchDelayMs(env = process.env) {
|
|
48
|
+
if (!shouldRegisterSyntheticMigration(env))
|
|
49
|
+
return undefined;
|
|
50
|
+
const raw = env[TEST_BATCH_DELAY_ENV];
|
|
51
|
+
if (raw === undefined)
|
|
52
|
+
return undefined;
|
|
53
|
+
const n = Number(raw);
|
|
54
|
+
return Number.isFinite(n) && n >= 0 ? n : undefined;
|
|
55
|
+
}
|
|
56
|
+
function resolveDeps(deps) {
|
|
57
|
+
const dataDir = deps.dataDir;
|
|
58
|
+
return {
|
|
59
|
+
registry: deps.registry,
|
|
60
|
+
getTable: deps.getTable,
|
|
61
|
+
dataDir,
|
|
62
|
+
runningVersion: deps.runningVersion,
|
|
63
|
+
statePath: deps.statePath ?? defaultStatePath(dataDir),
|
|
64
|
+
lockPath: deps.lockPath ?? join(dataDir, ".migrations", "lock"),
|
|
65
|
+
snapshotRoot: deps.snapshotRoot ?? join(dataDir, ".migrations", "snapshots"),
|
|
66
|
+
exportRoot: deps.exportRoot ?? join(dataDir, ".migrations", "exports"),
|
|
67
|
+
spaceProbe: deps.spaceProbe ?? defaultSpaceProbe,
|
|
68
|
+
now: deps.now ?? (() => new Date()),
|
|
69
|
+
sleep: deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms))),
|
|
70
|
+
ledgerDeps: deps.ledgerDeps ?? {},
|
|
71
|
+
batchDelayMs: deps.batchDelayMs ?? resolveTestBatchDelayMs() ?? 100,
|
|
72
|
+
initiator: deps.initiator ?? "auto",
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
// ─── space/snapshot sizing heuristics (overridable via RunnerDeps in future if ever needed) ──
|
|
76
|
+
function estimateSnapshotBytes(scope, pendingCount) {
|
|
77
|
+
switch (scope) {
|
|
78
|
+
case "metadata-only":
|
|
79
|
+
return 4096;
|
|
80
|
+
case "schema+metadata":
|
|
81
|
+
return 8192;
|
|
82
|
+
case "pointers+metadata":
|
|
83
|
+
return 256 * Math.max(pendingCount, 1);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Kern verdict: "Space estimate includes HNSW rebuild overhead (re-embed
|
|
88
|
+
* rewrites every vector → index rebuild can be 2–3× raw vector size)." Uses
|
|
89
|
+
* a fixed per-row estimate (~6KB raw nomic-embed-text vector * 3) — a real
|
|
90
|
+
* per-corpus vector-size probe would be more precise but isn't needed for a
|
|
91
|
+
* conservative pre-flight gate; overestimating just makes the halt fire
|
|
92
|
+
* slightly earlier, which is the safe direction.
|
|
93
|
+
*/
|
|
94
|
+
function estimateWorkingSetBytes(riskClass, pendingCount) {
|
|
95
|
+
switch (riskClass) {
|
|
96
|
+
case "derived-only":
|
|
97
|
+
return 18 * 1024 * pendingCount; // ~3x a 768-dim float32 vector (~6KB) per touched row
|
|
98
|
+
case "schema-additive":
|
|
99
|
+
return 0; // additive-only — no row rewrites of source fields
|
|
100
|
+
case "content-transform":
|
|
101
|
+
return 4 * 1024 * pendingCount; // old+new temporarily coexist for touched rows
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Runs one full migration cycle. Safe to call repeatedly (e.g. once per
|
|
106
|
+
* boot) — a cycle with nothing pending returns immediately without taking
|
|
107
|
+
* the lock's file-write cost or computing the envelope.
|
|
108
|
+
*/
|
|
109
|
+
export async function runMigrationCycle(rawDeps) {
|
|
110
|
+
const deps = resolveDeps(rawDeps);
|
|
111
|
+
const allIds = deps.registry.list().map((m) => m.id);
|
|
112
|
+
seedIdleProgress(allIds);
|
|
113
|
+
let lock;
|
|
114
|
+
try {
|
|
115
|
+
lock = acquireMigrationLock({ lockPath: deps.lockPath });
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
// Even the lock's own filesystem calls must never crash the boot path.
|
|
119
|
+
return { ran: false, reason: `lock error: ${err?.message ?? String(err)}` };
|
|
120
|
+
}
|
|
121
|
+
if (!lock.acquired) {
|
|
122
|
+
return { ran: false, reason: `single-flight: ${lock.reason}` };
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
return await runCycleLocked(deps, lock);
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
// Belt-and-suspenders: runCycleLocked already halts individual
|
|
129
|
+
// migrations internally, but an error escaping it entirely (e.g. a bug)
|
|
130
|
+
// must still never propagate out of the boot path.
|
|
131
|
+
setCyclePhase("done", `unexpected cycle error: ${err?.message ?? String(err)}`);
|
|
132
|
+
return { ran: false, reason: `unexpected error: ${err?.message ?? String(err)}` };
|
|
133
|
+
}
|
|
134
|
+
finally {
|
|
135
|
+
lock.release();
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
async function runCycleLocked(deps, lock) {
|
|
139
|
+
const state = readMigrationState(deps.statePath);
|
|
140
|
+
const candidates = [];
|
|
141
|
+
for (const migration of deps.registry.list()) {
|
|
142
|
+
if (isShortCircuited(state, migration.id, deps.runningVersion)) {
|
|
143
|
+
setMigrationProgress({ id: migration.id, rowsDone: 0, rowsRemaining: 0, state: "completed" });
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
setMigrationProgress({ id: migration.id, rowsDone: 0, rowsRemaining: 0, state: "checking" });
|
|
147
|
+
let pending;
|
|
148
|
+
try {
|
|
149
|
+
pending = await migration.detect();
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
setMigrationProgress({
|
|
153
|
+
id: migration.id,
|
|
154
|
+
rowsDone: 0,
|
|
155
|
+
rowsRemaining: 0,
|
|
156
|
+
state: "failed",
|
|
157
|
+
reason: `detect() threw: ${err?.message ?? String(err)}`,
|
|
158
|
+
});
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (!pending) {
|
|
162
|
+
setMigrationProgress({ id: migration.id, rowsDone: 0, rowsRemaining: 0, state: "completed" });
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
candidates.push(migration);
|
|
166
|
+
}
|
|
167
|
+
if (candidates.length === 0) {
|
|
168
|
+
return { ran: false, reason: "nothing pending" };
|
|
169
|
+
}
|
|
170
|
+
// ── ONE shared async pre-hash for the whole cycle (K&S: computed once,
|
|
171
|
+
// after ready, before any migration's first write; failure → halt all
|
|
172
|
+
// pending candidates rather than guessing which ones are safe). ──
|
|
173
|
+
setCyclePhase("pre-hash");
|
|
174
|
+
let envelope;
|
|
175
|
+
try {
|
|
176
|
+
envelope = await computeCorpusEnvelope(deps.getTable, deps.now);
|
|
177
|
+
}
|
|
178
|
+
catch (err) {
|
|
179
|
+
const reason = `pre-flight integrity check failed: ${err?.message ?? String(err)}`;
|
|
180
|
+
for (const migration of candidates) {
|
|
181
|
+
setMigrationProgress({ id: migration.id, rowsDone: 0, rowsRemaining: 0, state: "halted", reason });
|
|
182
|
+
}
|
|
183
|
+
setCyclePhase("done", reason);
|
|
184
|
+
return { ran: false, reason };
|
|
185
|
+
}
|
|
186
|
+
setCyclePhase("running");
|
|
187
|
+
for (const migration of candidates) {
|
|
188
|
+
await runOneMigration(migration, envelope, deps, lock);
|
|
189
|
+
}
|
|
190
|
+
setCyclePhase("done");
|
|
191
|
+
return { ran: true };
|
|
192
|
+
}
|
|
193
|
+
async function haltMigration(migration, reason, ctx) {
|
|
194
|
+
const { deps, startedAt, rowsDone, rowsRemaining, hashEnvelopeMatch, state } = ctx;
|
|
195
|
+
const endedAt = deps.now().toISOString();
|
|
196
|
+
setMigrationProgress({ id: migration.id, rowsDone, rowsRemaining, state: "halted", reason });
|
|
197
|
+
const fromVersion = state[migration.id]?.completedAtVersion ?? "unknown";
|
|
198
|
+
const evt = {
|
|
199
|
+
migrationId: migration.id,
|
|
200
|
+
initiator: deps.initiator,
|
|
201
|
+
fromVersion,
|
|
202
|
+
toVersion: deps.runningVersion,
|
|
203
|
+
scope: "full",
|
|
204
|
+
startedAt,
|
|
205
|
+
endedAt,
|
|
206
|
+
outcome: "halted",
|
|
207
|
+
rowsProcessed: rowsDone,
|
|
208
|
+
rowsRemaining,
|
|
209
|
+
hashEnvelopeMatch,
|
|
210
|
+
error: reason,
|
|
211
|
+
};
|
|
212
|
+
try {
|
|
213
|
+
await writeLedgerEvent(evt, deps.ledgerDeps);
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
// A ledger-write failure must not compound the halt — the migration is
|
|
217
|
+
// already safely stopped on the pre-migration shape either way.
|
|
218
|
+
}
|
|
219
|
+
// Deliberately NOT calling writeMigrationStateEntry with lastOutcome
|
|
220
|
+
// "halted" as completedAtVersion-bearing — isShortCircuited() only fires
|
|
221
|
+
// on lastOutcome === "success", so a halted migration is retried on every
|
|
222
|
+
// subsequent boot until it clears, never permanently stuck.
|
|
223
|
+
try {
|
|
224
|
+
writeMigrationStateEntry(deps.statePath, migration.id, {
|
|
225
|
+
lastOutcome: "halted",
|
|
226
|
+
reason,
|
|
227
|
+
rowsProcessed: rowsDone,
|
|
228
|
+
rowsRemaining,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
/* best-effort — the in-memory progress + ledger event already recorded the halt */
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
async function runOneMigration(migration, envelope, deps, lock) {
|
|
236
|
+
const startedAt = deps.now().toISOString();
|
|
237
|
+
const state = readMigrationState(deps.statePath);
|
|
238
|
+
const posture = postureFor(migration.riskClass);
|
|
239
|
+
setMigrationProgress({ id: migration.id, rowsDone: 0, rowsRemaining: 0, state: "preflight" });
|
|
240
|
+
// ── Ladder step 1: space check (with HNSW/working-set overhead) ──
|
|
241
|
+
let initialRemaining;
|
|
242
|
+
try {
|
|
243
|
+
initialRemaining = await migration.countPending();
|
|
244
|
+
}
|
|
245
|
+
catch (err) {
|
|
246
|
+
await haltMigration(migration, `countPending() threw: ${err?.message ?? String(err)}`, {
|
|
247
|
+
deps,
|
|
248
|
+
startedAt,
|
|
249
|
+
rowsDone: 0,
|
|
250
|
+
rowsRemaining: 0,
|
|
251
|
+
hashEnvelopeMatch: null,
|
|
252
|
+
state,
|
|
253
|
+
});
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
const estSnapshot = estimateSnapshotBytes(posture.snapshotScope, initialRemaining);
|
|
257
|
+
const estWorkingSet = estimateWorkingSetBytes(migration.riskClass, initialRemaining);
|
|
258
|
+
let space = checkSpace({ dataDir: deps.dataDir, estimatedSnapshotBytes: estSnapshot, estimatedWorkingSetBytes: estWorkingSet }, deps.spaceProbe);
|
|
259
|
+
if (!space.ok) {
|
|
260
|
+
// ── Ladder step 2: prune snapshots, then retry ──
|
|
261
|
+
pruneMigrationSnapshots(deps.snapshotRoot);
|
|
262
|
+
space = checkSpace({ dataDir: deps.dataDir, estimatedSnapshotBytes: estSnapshot, estimatedWorkingSetBytes: estWorkingSet }, deps.spaceProbe);
|
|
263
|
+
}
|
|
264
|
+
if (!space.ok) {
|
|
265
|
+
// ── Ladder step 5: no safe path → halt-don't-brick ──
|
|
266
|
+
await haltMigration(migration, `blocked on disk: ${space.reason}`, {
|
|
267
|
+
deps,
|
|
268
|
+
startedAt,
|
|
269
|
+
rowsDone: 0,
|
|
270
|
+
rowsRemaining: initialRemaining,
|
|
271
|
+
hashEnvelopeMatch: null,
|
|
272
|
+
state,
|
|
273
|
+
});
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
// ── Ladder step 3 (+ step 4 fallback): risk-scoped snapshot, or a
|
|
277
|
+
// content-only export if the snapshot mechanism itself fails. ──
|
|
278
|
+
setMigrationProgress({ id: migration.id, rowsDone: 0, rowsRemaining: initialRemaining, state: "snapshotting" });
|
|
279
|
+
try {
|
|
280
|
+
createMigrationSnapshot({
|
|
281
|
+
migrationId: migration.id,
|
|
282
|
+
scope: posture.snapshotScope,
|
|
283
|
+
rowCounts: { [migration.affectsTables[0] ?? "Memory"]: initialRemaining },
|
|
284
|
+
fromVersion: state[migration.id]?.completedAtVersion ?? "unknown",
|
|
285
|
+
toVersion: deps.runningVersion,
|
|
286
|
+
}, { snapshotRoot: deps.snapshotRoot, now: deps.now });
|
|
287
|
+
}
|
|
288
|
+
catch (snapErr) {
|
|
289
|
+
try {
|
|
290
|
+
const table = migration.affectsTables[0] ?? "Memory";
|
|
291
|
+
const accessor = deps.getTable(table);
|
|
292
|
+
const rows = [];
|
|
293
|
+
for await (const row of accessor.search({}))
|
|
294
|
+
rows.push(row);
|
|
295
|
+
createContentOnlyExport({ migrationId: migration.id, table, rows, fromVersion: state[migration.id]?.completedAtVersion ?? "unknown" }, { exportRoot: deps.exportRoot, now: deps.now });
|
|
296
|
+
}
|
|
297
|
+
catch (expErr) {
|
|
298
|
+
await haltMigration(migration, `snapshot failed (${snapErr?.message ?? String(snapErr)}) and the content-only export fallback also failed (${expErr?.message ?? String(expErr)})`, { deps, startedAt, rowsDone: 0, rowsRemaining: initialRemaining, hashEnvelopeMatch: null, state });
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
// ── Execution: throttled batches, resumable via per-row markers ──
|
|
303
|
+
setMigrationProgress({ id: migration.id, rowsDone: 0, rowsRemaining: initialRemaining, state: "running" });
|
|
304
|
+
let rowsDone = 0;
|
|
305
|
+
const oldRowHashes = new Map();
|
|
306
|
+
const newRowIds = [];
|
|
307
|
+
try {
|
|
308
|
+
for (;;) {
|
|
309
|
+
const result = await migration.run(posture.batchSize);
|
|
310
|
+
rowsDone += result.processed;
|
|
311
|
+
if (result.oldRowSourceHashes) {
|
|
312
|
+
for (const [k, v] of Object.entries(result.oldRowSourceHashes))
|
|
313
|
+
oldRowHashes.set(k, v);
|
|
314
|
+
}
|
|
315
|
+
if (result.newRowIds)
|
|
316
|
+
newRowIds.push(...result.newRowIds);
|
|
317
|
+
setMigrationProgress({
|
|
318
|
+
id: migration.id,
|
|
319
|
+
rowsDone,
|
|
320
|
+
rowsRemaining: Math.max(0, initialRemaining - rowsDone),
|
|
321
|
+
state: "running",
|
|
322
|
+
});
|
|
323
|
+
lock.touch();
|
|
324
|
+
if (result.processed === 0)
|
|
325
|
+
break;
|
|
326
|
+
await deps.sleep(deps.batchDelayMs);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
catch (err) {
|
|
330
|
+
await haltMigration(migration, `run() threw mid-batch: ${err?.message ?? String(err)}`, {
|
|
331
|
+
deps,
|
|
332
|
+
startedAt,
|
|
333
|
+
rowsDone,
|
|
334
|
+
rowsRemaining: Math.max(0, initialRemaining - rowsDone),
|
|
335
|
+
hashEnvelopeMatch: null,
|
|
336
|
+
state,
|
|
337
|
+
});
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
// ── Completion gate — strictness per risk class ──
|
|
341
|
+
setMigrationProgress({ id: migration.id, rowsDone, rowsRemaining: 0, state: "completing" });
|
|
342
|
+
let finalRemaining;
|
|
343
|
+
try {
|
|
344
|
+
finalRemaining = await migration.countPending();
|
|
345
|
+
}
|
|
346
|
+
catch (err) {
|
|
347
|
+
await haltMigration(migration, `post-batch countPending() threw: ${err?.message ?? String(err)}`, {
|
|
348
|
+
deps,
|
|
349
|
+
startedAt,
|
|
350
|
+
rowsDone,
|
|
351
|
+
rowsRemaining: 0,
|
|
352
|
+
hashEnvelopeMatch: null,
|
|
353
|
+
state,
|
|
354
|
+
});
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
let hashEnvelopeMatch = null;
|
|
358
|
+
let gateOk = finalRemaining === 0;
|
|
359
|
+
if (gateOk && posture.gate === "count+full-envelope") {
|
|
360
|
+
try {
|
|
361
|
+
const postEnvelope = await computeCorpusEnvelope(deps.getTable, deps.now);
|
|
362
|
+
hashEnvelopeMatch = postEnvelope.corpusHash === envelope.corpusHash;
|
|
363
|
+
gateOk = gateOk && hashEnvelopeMatch;
|
|
364
|
+
}
|
|
365
|
+
catch (err) {
|
|
366
|
+
hashEnvelopeMatch = false;
|
|
367
|
+
gateOk = false;
|
|
368
|
+
await haltMigration(migration, `post-hash computation failed: ${err?.message ?? String(err)}`, {
|
|
369
|
+
deps,
|
|
370
|
+
startedAt,
|
|
371
|
+
rowsDone,
|
|
372
|
+
rowsRemaining: finalRemaining,
|
|
373
|
+
hashEnvelopeMatch,
|
|
374
|
+
state,
|
|
375
|
+
});
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
else if (gateOk && posture.gate === "count+old-row-envelope+new-row-presence") {
|
|
380
|
+
let allMatch = true;
|
|
381
|
+
for (const [key, preHash] of oldRowHashes) {
|
|
382
|
+
const sepIdx = key.indexOf(":");
|
|
383
|
+
const table = key.slice(0, sepIdx);
|
|
384
|
+
const id = key.slice(sepIdx + 1);
|
|
385
|
+
let current = null;
|
|
386
|
+
try {
|
|
387
|
+
current = await deps.getTable(table).get(id);
|
|
388
|
+
}
|
|
389
|
+
catch {
|
|
390
|
+
allMatch = false;
|
|
391
|
+
break;
|
|
392
|
+
}
|
|
393
|
+
const currentHash = current ? hashSourceFields(current, sourceFieldsFor(table)) : null;
|
|
394
|
+
if (currentHash !== preHash) {
|
|
395
|
+
allMatch = false;
|
|
396
|
+
break;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
const presenceOk = newRowIds.length === oldRowHashes.size;
|
|
400
|
+
hashEnvelopeMatch = allMatch && presenceOk;
|
|
401
|
+
gateOk = gateOk && hashEnvelopeMatch;
|
|
402
|
+
}
|
|
403
|
+
// "count+marker" (derived-only): gateOk already reflects finalRemaining === 0;
|
|
404
|
+
// no content-hash comparison — recomputable by definition (Kern verdict).
|
|
405
|
+
const endedAt = deps.now().toISOString();
|
|
406
|
+
if (!gateOk) {
|
|
407
|
+
const reasonParts = [`rowsRemaining=${finalRemaining}`];
|
|
408
|
+
if (hashEnvelopeMatch === false)
|
|
409
|
+
reasonParts.push("hash envelope mismatch");
|
|
410
|
+
await haltMigration(migration, `completion gate failed: ${reasonParts.join(", ")}`, {
|
|
411
|
+
deps,
|
|
412
|
+
startedAt,
|
|
413
|
+
rowsDone,
|
|
414
|
+
rowsRemaining: finalRemaining,
|
|
415
|
+
hashEnvelopeMatch,
|
|
416
|
+
state,
|
|
417
|
+
});
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
// ── Success: ledger → state → prune ──
|
|
421
|
+
const evt = {
|
|
422
|
+
migrationId: migration.id,
|
|
423
|
+
initiator: deps.initiator,
|
|
424
|
+
fromVersion: state[migration.id]?.completedAtVersion ?? "unknown",
|
|
425
|
+
toVersion: deps.runningVersion,
|
|
426
|
+
scope: "full",
|
|
427
|
+
startedAt,
|
|
428
|
+
endedAt,
|
|
429
|
+
outcome: "success",
|
|
430
|
+
rowsProcessed: rowsDone,
|
|
431
|
+
rowsRemaining: 0,
|
|
432
|
+
hashEnvelopeMatch,
|
|
433
|
+
};
|
|
434
|
+
try {
|
|
435
|
+
await writeLedgerEvent(evt, deps.ledgerDeps);
|
|
436
|
+
}
|
|
437
|
+
catch {
|
|
438
|
+
// A ledger-write failure after a genuinely successful migration must not
|
|
439
|
+
// flip the outcome to failed — the data-safety work already completed.
|
|
440
|
+
}
|
|
441
|
+
try {
|
|
442
|
+
writeMigrationStateEntry(deps.statePath, migration.id, {
|
|
443
|
+
completedAtVersion: deps.runningVersion,
|
|
444
|
+
completedAt: endedAt,
|
|
445
|
+
lastOutcome: "success",
|
|
446
|
+
rowsProcessed: rowsDone,
|
|
447
|
+
rowsRemaining: 0,
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
catch {
|
|
451
|
+
/* best-effort — worst case this migration's detect() runs (cheaply) again next boot */
|
|
452
|
+
}
|
|
453
|
+
pruneMigrationSnapshots(deps.snapshotRoot);
|
|
454
|
+
setMigrationProgress({ id: migration.id, rowsDone, rowsRemaining: 0, state: "completed" });
|
|
455
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* snapshot.ts — risk-scoped pre-flight snapshot (ladder step 3) + retention
|
|
3
|
+
* pruning (ladder... well, invariant III's retention rule, applied after a
|
|
4
|
+
* successful completion gate).
|
|
5
|
+
*
|
|
6
|
+
* This is a LOGICAL, in-process snapshot — deliberately NOT the same
|
|
7
|
+
* mechanism as `flair snapshot create` (src/cli.ts's createDataSnapshot),
|
|
8
|
+
* which takes a byte-exact tar.gz of the whole data directory but requires
|
|
9
|
+
* STOPPING Harper first for consistency. The zero-touch runner executes
|
|
10
|
+
* INSIDE the live-serving Harper process (boot-keyed, server ready first —
|
|
11
|
+
* see resources/MigrationBoot.ts), so stopping the server to snapshot
|
|
12
|
+
* itself is a non-starter; this writes a small, risk-class-scoped JSON/JSONL
|
|
13
|
+
* manifest instead, using the same file-safety discipline (0700 dirs, 0600
|
|
14
|
+
* files, stat-verified — see dir-safety.ts) as the rest of Flair's snapshot
|
|
15
|
+
* machinery.
|
|
16
|
+
*
|
|
17
|
+
* Scope per risk class (flair#695, space-pressure
|
|
18
|
+
* step 3 / resources/migrations/risk-policy.ts):
|
|
19
|
+
* - metadata-only: manifest only (row counts, versions) — no corpus
|
|
20
|
+
* snapshot needed; derived data is recomputable by
|
|
21
|
+
* definition (invariant I).
|
|
22
|
+
* - schema+metadata: manifest + a schema summary (table/field names,
|
|
23
|
+
* never row data) — no row rewrites happen, so
|
|
24
|
+
* there's nothing to snapshot beyond proving what
|
|
25
|
+
* shape existed before the migration.
|
|
26
|
+
* - pointers+metadata: manifest + a JSONL of POINTER fields only (id,
|
|
27
|
+
* supersedes, validFrom, validTo) for touched rows
|
|
28
|
+
* — never content. Content-transform migrations use
|
|
29
|
+
* native supersession (old rows retained in-store,
|
|
30
|
+
* never mutated), so the corpus itself doesn't need
|
|
31
|
+
* snapshotting; only the pointer state that proves
|
|
32
|
+
* which rows were touched and how they chain.
|
|
33
|
+
*/
|
|
34
|
+
import { statSync, readdirSync, rmSync } from "node:fs";
|
|
35
|
+
import { join } from "node:path";
|
|
36
|
+
import { ensureSecureDir, writeSecureFile } from "./dir-safety.js";
|
|
37
|
+
function sanitizeIdPart(s) {
|
|
38
|
+
return s.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
39
|
+
}
|
|
40
|
+
/** Creates the risk-scoped snapshot under `deps.snapshotRoot/<migrationId>-<iso>/`. */
|
|
41
|
+
export function createMigrationSnapshot(opts, deps) {
|
|
42
|
+
const now = deps.now();
|
|
43
|
+
const iso = now.toISOString().replace(/[:.]/g, "-");
|
|
44
|
+
const dir = join(deps.snapshotRoot, `${sanitizeIdPart(opts.migrationId)}-${iso}`);
|
|
45
|
+
ensureSecureDir(dir);
|
|
46
|
+
const manifest = {
|
|
47
|
+
migrationId: opts.migrationId,
|
|
48
|
+
scope: opts.scope,
|
|
49
|
+
createdAt: now.toISOString(),
|
|
50
|
+
fromVersion: opts.fromVersion,
|
|
51
|
+
toVersion: opts.toVersion,
|
|
52
|
+
rowCounts: opts.rowCounts,
|
|
53
|
+
};
|
|
54
|
+
const manifestPath = join(dir, "manifest.json");
|
|
55
|
+
writeSecureFile(manifestPath, JSON.stringify(manifest, null, 2) + "\n", dir);
|
|
56
|
+
let bytes = statSync(manifestPath).size;
|
|
57
|
+
if (opts.scope === "schema+metadata" && opts.schema) {
|
|
58
|
+
const p = join(dir, "schema.json");
|
|
59
|
+
writeSecureFile(p, JSON.stringify(opts.schema, null, 2) + "\n", dir);
|
|
60
|
+
bytes += statSync(p).size;
|
|
61
|
+
}
|
|
62
|
+
if (opts.scope === "pointers+metadata" && opts.pointers) {
|
|
63
|
+
const p = join(dir, "pointers.jsonl");
|
|
64
|
+
const body = opts.pointers.map((r) => JSON.stringify(r)).join("\n") + (opts.pointers.length ? "\n" : "");
|
|
65
|
+
writeSecureFile(p, body, dir);
|
|
66
|
+
bytes += statSync(p).size;
|
|
67
|
+
}
|
|
68
|
+
return { dir, manifestPath, bytes };
|
|
69
|
+
}
|
|
70
|
+
function listMigrationSnapshotDirs(snapshotRoot) {
|
|
71
|
+
let names;
|
|
72
|
+
try {
|
|
73
|
+
names = readdirSync(snapshotRoot);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
78
|
+
const out = [];
|
|
79
|
+
for (const name of names) {
|
|
80
|
+
const path = join(snapshotRoot, name);
|
|
81
|
+
try {
|
|
82
|
+
const st = statSync(path);
|
|
83
|
+
if (st.isDirectory())
|
|
84
|
+
out.push({ path, mtimeMs: st.mtimeMs });
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
/* vanished between readdir and stat — skip */
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
export const DEFAULT_SNAPSHOT_KEEP_LAST = 3;
|
|
93
|
+
export const DEFAULT_SNAPSHOT_MAX_AGE_MS = 30 * 24 * 3600 * 1000;
|
|
94
|
+
/**
|
|
95
|
+
* Retention (Kern verdict): "keep-last-3 floor AND 30-day age ceiling, more
|
|
96
|
+
* permissive wins." Implemented as a UNION of both rules — a snapshot
|
|
97
|
+
* survives if EITHER "among the 3 most recent" OR "younger than 30 days" is
|
|
98
|
+
* true; only a snapshot that fails BOTH gets pruned. This is the
|
|
99
|
+
* more-permissive (keeps more, never less) interpretation: the last-3 floor
|
|
100
|
+
* alone would prune a 2-day-old 4th snapshot; the union keeps it.
|
|
101
|
+
*/
|
|
102
|
+
export function pruneMigrationSnapshots(snapshotRoot, opts = {}) {
|
|
103
|
+
const keepLast = opts.keepLast ?? DEFAULT_SNAPSHOT_KEEP_LAST;
|
|
104
|
+
const maxAgeMs = opts.maxAgeMs ?? DEFAULT_SNAPSHOT_MAX_AGE_MS;
|
|
105
|
+
const nowMs = (opts.now ?? Date.now)();
|
|
106
|
+
const all = listMigrationSnapshotDirs(snapshotRoot).sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
107
|
+
const keep = new Set();
|
|
108
|
+
for (const e of all.slice(0, keepLast))
|
|
109
|
+
keep.add(e.path);
|
|
110
|
+
for (const e of all) {
|
|
111
|
+
if (nowMs - e.mtimeMs <= maxAgeMs)
|
|
112
|
+
keep.add(e.path);
|
|
113
|
+
}
|
|
114
|
+
const removed = [];
|
|
115
|
+
for (const e of all) {
|
|
116
|
+
if (!keep.has(e.path)) {
|
|
117
|
+
try {
|
|
118
|
+
rmSync(e.path, { recursive: true, force: true });
|
|
119
|
+
removed.push(e.path);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
/* best-effort — a prune failure must never fail an otherwise-successful migration */
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return removed;
|
|
127
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* source-fields.ts — SOURCE_FIELDS enumerations + the integrity envelope
|
|
3
|
+
* hash (flair#695 invariant I + Kern verdict).
|
|
4
|
+
*
|
|
5
|
+
* "SOURCE_FIELDS constant per resource — enumerate source vs derived per
|
|
6
|
+
* table, mechanically checked by the hash gate." The exact enumerations
|
|
7
|
+
* below are copied verbatim from the Kern verdict (2026-07-11):
|
|
8
|
+
*
|
|
9
|
+
* Memory source = content, agentId, durability, visibility, tags,
|
|
10
|
+
* createdAt, supersedes
|
|
11
|
+
* (derived: embedding, embeddingModel, retrievalCount, usageCount,
|
|
12
|
+
* lastRetrieved, provenance, originatorInstanceId)
|
|
13
|
+
* Relationship source = subject, predicate, object, agentId, validFrom,
|
|
14
|
+
* validTo
|
|
15
|
+
*
|
|
16
|
+
* "Pre+post content-hash ENVELOPE ... full-corpus hash-of-source-field-hashes
|
|
17
|
+
* computed before first write and after completion; must match." — hashCorpus
|
|
18
|
+
* below is exactly that: a per-row hash over ONLY the source fields, combined
|
|
19
|
+
* (sorted by id, so iteration order never perturbs the result) into one
|
|
20
|
+
* corpus-level hash.
|
|
21
|
+
*/
|
|
22
|
+
import { createHash } from "node:crypto";
|
|
23
|
+
export const MEMORY_SOURCE_FIELDS = [
|
|
24
|
+
"content",
|
|
25
|
+
"agentId",
|
|
26
|
+
"durability",
|
|
27
|
+
"visibility",
|
|
28
|
+
"tags",
|
|
29
|
+
"createdAt",
|
|
30
|
+
"supersedes",
|
|
31
|
+
];
|
|
32
|
+
/** Not hashed — recomputable/mutable-by-design; listed for documentation + the mechanical-check tests. */
|
|
33
|
+
export const MEMORY_DERIVED_FIELDS = [
|
|
34
|
+
"embedding",
|
|
35
|
+
"embeddingModel",
|
|
36
|
+
"retrievalCount",
|
|
37
|
+
"usageCount",
|
|
38
|
+
"lastRetrieved",
|
|
39
|
+
"provenance",
|
|
40
|
+
"originatorInstanceId",
|
|
41
|
+
];
|
|
42
|
+
export const RELATIONSHIP_SOURCE_FIELDS = [
|
|
43
|
+
"subject",
|
|
44
|
+
"predicate",
|
|
45
|
+
"object",
|
|
46
|
+
"agentId",
|
|
47
|
+
"validFrom",
|
|
48
|
+
"validTo",
|
|
49
|
+
];
|
|
50
|
+
export function sourceFieldsFor(table) {
|
|
51
|
+
return table === "Memory" ? MEMORY_SOURCE_FIELDS : RELATIONSHIP_SOURCE_FIELDS;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Deterministic canonicalization: sorts object keys recursively so the same
|
|
55
|
+
* logical value always serializes identically regardless of property
|
|
56
|
+
* insertion order (Harper doesn't guarantee field order is stable across
|
|
57
|
+
* reads). `undefined` normalizes to `null` — a missing/undefined source
|
|
58
|
+
* field and an explicit `null` must hash identically (both mean "no value"),
|
|
59
|
+
* otherwise a legitimate no-op re-fetch could flip the corpus hash.
|
|
60
|
+
*/
|
|
61
|
+
function canonicalize(value) {
|
|
62
|
+
if (Array.isArray(value))
|
|
63
|
+
return value.map(canonicalize);
|
|
64
|
+
if (value && typeof value === "object") {
|
|
65
|
+
const out = {};
|
|
66
|
+
for (const k of Object.keys(value).sort()) {
|
|
67
|
+
out[k] = canonicalize(value[k]);
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
return value === undefined ? null : value;
|
|
72
|
+
}
|
|
73
|
+
/** Hash of ONLY the listed fields off one row. Order-independent, missing-vs-null-independent. */
|
|
74
|
+
export function hashSourceFields(row, fields) {
|
|
75
|
+
const picked = {};
|
|
76
|
+
for (const f of fields)
|
|
77
|
+
picked[f] = row[f] ?? null;
|
|
78
|
+
return createHash("sha256").update(JSON.stringify(canonicalize(picked))).digest("hex");
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Hash-of-source-field-hashes over a corpus (or any subset of rows a caller
|
|
82
|
+
* passes in — the content-transform gate calls this over just the touched
|
|
83
|
+
* old rows, not the whole table). Rows are sorted by `id` before combining
|
|
84
|
+
* so the result is independent of read/iteration order — two reads of the
|
|
85
|
+
* same logical corpus in different order must hash identically.
|
|
86
|
+
*/
|
|
87
|
+
export function hashCorpus(rows, fields) {
|
|
88
|
+
const perRow = rows
|
|
89
|
+
.map((r) => ({ id: String(r.id ?? ""), h: hashSourceFields(r, fields) }))
|
|
90
|
+
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
91
|
+
const combined = perRow.map((r) => `${r.id}:${r.h}`).join("|");
|
|
92
|
+
return createHash("sha256").update(combined).digest("hex");
|
|
93
|
+
}
|