memhouse 0.8.0 → 0.11.0
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 +84 -47
- package/bin/memhouse.js +993 -89
- package/editors/antigravity.js +7 -15
- package/editors/cursor.js +18 -24
- package/editors/goose.js +9 -21
- package/editors/index.js +19 -70
- package/editors/opencode.js +6 -15
- package/editors/sqlite.js +74 -0
- package/editors/windsurf.js +3 -3
- package/editors/zed.js +10 -15
- package/memhouse/COMPETITION.md +9 -5
- package/memhouse/DESIGN.md +37 -12
- package/memhouse/RESUME-AND-UPDATE.md +7 -10
- package/memhouse/delivery/AGENT-INSTALL-PROMPT.md +10 -2
- package/memhouse/delivery/AGENT-INSTALL.md +56 -32
- package/memhouse/delivery/PROMPT.md +37 -13
- package/memhouse/delivery/kernel-install.md +4 -4
- package/memhouse/delivery/plugin/.claude-plugin/plugin.json +14 -5
- package/memhouse/delivery/plugin/skills/ask/SKILL.md +112 -0
- package/memhouse/delivery/plugin/skills/hello/SKILL.md +76 -0
- package/memhouse/delivery/plugin/skills/invite/SKILL.md +65 -0
- package/memhouse/delivery/plugin/skills/search/SKILL.md +1 -1
- package/memhouse/delivery/plugin/skills/sessions/SKILL.md +3 -3
- package/memhouse/delivery/plugin/skills/share/SKILL.md +130 -0
- package/memhouse/delivery/plugin/skills/sql/SKILL.md +34 -11
- package/memhouse/delivery/plugin/skills/status/SKILL.md +114 -0
- package/memhouse/delivery/plugin/skills/users/SKILL.md +97 -0
- package/memhouse/house/HOUSE.md +40 -5
- package/memhouse/house/house.js +191 -10
- package/memhouse/house/migrate.js +277 -0
- package/memhouse/house/migrations/0100-epoch-key.js +68 -0
- package/memhouse/house/schema.sql.tpl +96 -25
- package/memhouse/self-update.js +4 -4
- package/memhouse/service.js +7 -2
- package/memhouse/shipper/ship.js +471 -131
- package/package.json +3 -6
package/memhouse/shipper/ship.js
CHANGED
|
@@ -14,11 +14,18 @@
|
|
|
14
14
|
// - DateTime64 values travel as 'YYYY-MM-DD HH:MM:SS.mmm' UTC strings (plain
|
|
15
15
|
// format; ISO 'T'/'Z' forms parse unreliably under JSONEachRow). Nullable → null.
|
|
16
16
|
// - Int64-bound values are integer-coerced (some adapters emit fractional ms).
|
|
17
|
+
// - INSERT-ONLY. The shipper never deletes and never mutates. A re-parse that shrinks
|
|
18
|
+
// or diverges from what is stored is written under a NEW epoch, so the superseded
|
|
19
|
+
// parse survives intact — Claude Code compacts transcripts and deletes them after
|
|
20
|
+
// cleanupPeriodDays (30 by default), which makes the house the only remaining copy.
|
|
21
|
+
// memhouse is an accumulator, not a mirror: absence of a session is never a signal,
|
|
22
|
+
// and no "sync" or "prune" feature may ever be built here.
|
|
17
23
|
// - Re-shipping is always safe: ReplacingMergeTree(ingested_at) collapses to
|
|
18
|
-
// latest-wins at FINAL. Keyed (session_id, user_id, origin, seq) on messages
|
|
19
|
-
// (
|
|
20
|
-
//
|
|
21
|
-
// NO origin — one metadata row per session is
|
|
24
|
+
// latest-wins at FINAL. Keyed (session_id, user_id, origin, epoch, seq) on messages
|
|
25
|
+
// and (…, epoch, idx) on tool_calls, so an imported row and a shipped one at the same
|
|
26
|
+
// seq are two rows, and so are two parses of the same session. sessions is
|
|
27
|
+
// (session_id, user_id) with NO origin and NO epoch — one metadata row per session is
|
|
28
|
+
// what every read path assumes.
|
|
22
29
|
//
|
|
23
30
|
// CLI: node ship.js one incremental pass
|
|
24
31
|
// node ship.js --loop [sec] repeat every sec seconds (default 300)
|
|
@@ -52,7 +59,14 @@ const selfUpdate = require('../self-update');
|
|
|
52
59
|
// Captured at require time, which is as close to "what this process booted with" as it
|
|
53
60
|
// gets. Taking it later would record whatever an upgrade had already replaced.
|
|
54
61
|
const selfSnap = selfUpdate.snapshot(__filename);
|
|
55
|
-
const {
|
|
62
|
+
const {
|
|
63
|
+
resolveRooms, READ_SETTINGS, ROOM_TYPES, META_TYPES, SCHEMA_VERSION, MEMBER_PIN,
|
|
64
|
+
SUPPORTED_SCHEMAS, keyProblem,
|
|
65
|
+
} = require('../house/house');
|
|
66
|
+
// Rooms plus the house's own record of itself. Every table the template declares, which
|
|
67
|
+
// is what the column healer and the drift warning have to cover — a column added to
|
|
68
|
+
// house_events would otherwise roll out to nobody.
|
|
69
|
+
const ALL_TABLES = [...ROOM_TYPES, ...META_TYPES];
|
|
56
70
|
|
|
57
71
|
const BATCH_ROWS = 2000; // insert batch ceiling (binding)
|
|
58
72
|
const TEXT_MAX = 50000; // messages.text truncation
|
|
@@ -263,11 +277,19 @@ function makeClient() {
|
|
|
263
277
|
*
|
|
264
278
|
* MATERIALIZED and DEFAULT clauses are kept: `user_id String MATERIALIZED currentUser()`
|
|
265
279
|
* has to be added exactly that way or the identity stamp does not happen.
|
|
280
|
+
*
|
|
281
|
+
* The room name in this pattern is the LITERAL table name. It used to be
|
|
282
|
+
* `<type>_{{MEMBER}}`, from the per-member layout, and nobody updated it when the rooms
|
|
283
|
+
* became plain shared tables — so it matched nothing, and every consumer silently got an
|
|
284
|
+
* empty column list. That took out three surfaces at once, all of them reporting success:
|
|
285
|
+
* the column healer in ensureSchema had nothing to add, warnMissingColumns had nothing to
|
|
286
|
+
* warn about, and doctor's column check printed "✓ columns: every room matches the schema
|
|
287
|
+
* template" having compared zero columns. A regression test now pins it (misc/unit-test.js).
|
|
266
288
|
*/
|
|
267
289
|
function templateColumns(tpl) {
|
|
268
290
|
const out = {};
|
|
269
|
-
for (const t of
|
|
270
|
-
const re = new RegExp(`CREATE TABLE IF NOT EXISTS ${t}
|
|
291
|
+
for (const t of ALL_TABLES) {
|
|
292
|
+
const re = new RegExp(`CREATE TABLE IF NOT EXISTS ${t}\\s*\\(([\\s\\S]*?)\\n\\)`, 'm');
|
|
271
293
|
const m = tpl.match(re);
|
|
272
294
|
if (!m) continue;
|
|
273
295
|
const cols = [];
|
|
@@ -308,15 +330,15 @@ async function warnMissingColumns(client, rooms) {
|
|
|
308
330
|
catch { return; }
|
|
309
331
|
const want = templateColumns(tpl);
|
|
310
332
|
const missing = [];
|
|
311
|
-
for (const t of
|
|
333
|
+
for (const t of ALL_TABLES) {
|
|
312
334
|
try {
|
|
313
335
|
const rs = await client.query({
|
|
314
336
|
query: 'SELECT name FROM system.columns WHERE database = currentDatabase() AND table = {n:String}',
|
|
315
|
-
query_params: { n: rooms[t] }, format: 'JSONEachRow',
|
|
337
|
+
query_params: { n: rooms[`${t}_raw`] }, format: 'JSONEachRow',
|
|
316
338
|
});
|
|
317
339
|
const have = new Set((await rs.json()).map((r) => r.name));
|
|
318
340
|
if (!have.size) continue;
|
|
319
|
-
for (const c of (want[t] || [])) if (!have.has(c.name)) missing.push(`${rooms[t]}.${c.name}`);
|
|
341
|
+
for (const c of (want[t] || [])) if (!have.has(c.name)) missing.push(`${rooms[`${t}_raw`]}.${c.name}`);
|
|
320
342
|
} catch { /* unreadable rooms are assertRoomsExist's problem */ }
|
|
321
343
|
}
|
|
322
344
|
if (missing.length) {
|
|
@@ -329,11 +351,11 @@ async function warnMissingColumns(client, rooms) {
|
|
|
329
351
|
async function assertRoomsExist(client, rooms) {
|
|
330
352
|
const rs = await client.query({
|
|
331
353
|
query: `SELECT name FROM system.tables WHERE database = currentDatabase() AND name IN ({n:Array(String)})`,
|
|
332
|
-
query_params: { n: ROOM_TYPES.map((t) => rooms[t]) },
|
|
354
|
+
query_params: { n: ROOM_TYPES.map((t) => rooms[`${t}_raw`]) },
|
|
333
355
|
format: 'JSONEachRow',
|
|
334
356
|
});
|
|
335
357
|
const have = new Set((await rs.json()).map((r) => r.name));
|
|
336
|
-
const missing = ROOM_TYPES.map((t) => rooms[t]).filter((n) => !have.has(n));
|
|
358
|
+
const missing = ROOM_TYPES.map((t) => rooms[`${t}_raw`]).filter((n) => !have.has(n));
|
|
337
359
|
if (!missing.length) return;
|
|
338
360
|
throw new Error(
|
|
339
361
|
`'${rooms.member}' has no ${missing.join(', ')} in this house.\n`
|
|
@@ -343,46 +365,45 @@ async function assertRoomsExist(client, rooms) {
|
|
|
343
365
|
+ ' A house from before 0.4 reaches this too — its rooms have different names.');
|
|
344
366
|
}
|
|
345
367
|
|
|
346
|
-
|
|
368
|
+
/**
|
|
369
|
+
* Refuse to write into rooms whose sorting key is not the one the shipper's safety
|
|
370
|
+
* depends on. What each column buys is written out on ROOM_KEYS in ../house/house.js;
|
|
371
|
+
* this is the enforcement, run at the top of EVERY pass rather than only in
|
|
372
|
+
* `--ensure-schema`, because a plain `memhouse ship` never calls ensureSchema and a guard
|
|
373
|
+
* living only there is a guard that never runs on the path that does the damage.
|
|
374
|
+
*/
|
|
375
|
+
async function assertRoomKeys(client, rooms) {
|
|
347
376
|
const wrong = [];
|
|
348
377
|
for (const t of ROOM_TYPES) {
|
|
349
378
|
const rs = await client.query({
|
|
350
379
|
query: `SELECT sorting_key FROM system.tables WHERE database = currentDatabase() AND name = {n:String}`,
|
|
351
|
-
query_params: { n: rooms[t] }, format: 'JSONEachRow',
|
|
380
|
+
query_params: { n: rooms[`${t}_raw`] }, format: 'JSONEachRow',
|
|
352
381
|
});
|
|
353
382
|
const row = (await rs.json())[0];
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
// `DELETE query is not supported for table …`, which is exactly what this refusal is
|
|
359
|
-
// meant to prevent. (A room that does not exist at all is assertRoomsExist's job and is
|
|
360
|
-
// reported there.)
|
|
361
|
-
if (row && !key) { wrong.push(`${rooms[t]} — not a MergeTree, so it cannot be shipped to`); continue; }
|
|
362
|
-
if (!key) continue;
|
|
363
|
-
const keyed = /\borigin\b/.test(key);
|
|
364
|
-
// sessions is one row per session and must NOT key on origin; the transcript rooms
|
|
365
|
-
// hold many rows per session and must.
|
|
366
|
-
const want = t !== 'sessions';
|
|
367
|
-
if (keyed !== want) {
|
|
368
|
-
wrong.push(`${rooms[t]} (${key}) — origin ${want ? 'missing from' : 'must not be in'} the key`);
|
|
369
|
-
}
|
|
383
|
+
// A room that does not exist at all is assertRoomsExist's job and is reported there.
|
|
384
|
+
if (!row) continue;
|
|
385
|
+
const problem = keyProblem(t, row.sorting_key);
|
|
386
|
+
if (problem) wrong.push(`${rooms[`${t}_raw`]} ${problem}`);
|
|
370
387
|
}
|
|
371
388
|
if (wrong.length) {
|
|
372
389
|
throw new Error(
|
|
373
390
|
'these rooms have the wrong sorting key, so a ship pass would corrupt them:\n'
|
|
374
391
|
+ wrong.map((s) => ` ${s}`).join('\n')
|
|
375
|
-
+ '\n ORDER BY cannot be altered in place
|
|
376
|
-
+ '\n
|
|
377
|
-
+
|
|
378
|
-
+ '\n
|
|
379
|
-
+ '\n (add `, \'ship\' AS origin` to the SELECT only if <room>_old has no origin column)'
|
|
392
|
+
+ '\n ORDER BY cannot be altered in place — the rooms have to be rebuilt:'
|
|
393
|
+
+ '\n memhouse migrate-rooms'
|
|
394
|
+
+ '\n It copies each room into one with the current key, swaps them atomically, and'
|
|
395
|
+
+ '\n keeps the old one as <room>_pre_epoch for you to drop. Nothing is deleted.'
|
|
380
396
|
+ '\n A house with no imported rows can also just be re-shipped from scratch.');
|
|
381
397
|
}
|
|
382
398
|
}
|
|
383
399
|
|
|
384
400
|
async function ensureSchema(client) {
|
|
385
401
|
const rooms = await resolveRooms(client);
|
|
402
|
+
// BEFORE the CREATEs and ALTERs, not beside the end-of-function asserts. A house whose
|
|
403
|
+
// record says a newer release moved it forward may have columns this template does not
|
|
404
|
+
// know; running this template's DDL first could add back what that release removed —
|
|
405
|
+
// the exact write the guard exists to prevent.
|
|
406
|
+
await assertWriterSupported(client, rooms);
|
|
386
407
|
const tpl = fs.readFileSync(path.join(__dirname, '..', 'house', 'schema.sql.tpl'), 'utf-8');
|
|
387
408
|
const sql = tpl; // plain shared tables — nothing to render
|
|
388
409
|
const stripped = sql.split('\n').map((l) => l.replace(/--.*$/, '')).join('\n');
|
|
@@ -418,10 +439,11 @@ async function ensureSchema(client) {
|
|
|
418
439
|
console.error(`[memhouse] ${denied} schema statement(s) needed rights you do not hold — continuing with what you can do.`);
|
|
419
440
|
console.error('[memhouse] creating or replacing a ROOM is the house owner\'s job; adding a missing COLUMN is not.');
|
|
420
441
|
}
|
|
421
|
-
// A house created before origin existed has no such column, and
|
|
422
|
-
//
|
|
423
|
-
//
|
|
424
|
-
//
|
|
442
|
+
// A house created before origin existed has no such column, and every read and write
|
|
443
|
+
// scopes by it. Add it in place; ReplacingMergeTree backfills the DEFAULT, so every
|
|
444
|
+
// pre-existing row reads as 'ship', which is what it was. (Having the COLUMN is not
|
|
445
|
+
// having it in the sorting KEY — that is assertRoomKeys' refusal and migrate-rooms'
|
|
446
|
+
// job, because ORDER BY cannot be altered.)
|
|
425
447
|
//
|
|
426
448
|
// EVERY column the template declares, not just `origin`.
|
|
427
449
|
//
|
|
@@ -435,60 +457,198 @@ async function ensureSchema(client) {
|
|
|
435
457
|
// It also made the grant a lie: rooms.js and INSTALL.md both say ALTER ADD COLUMN is
|
|
436
458
|
// granted "for ensureSchema's rollout", and no rollout of anything but `origin` existed.
|
|
437
459
|
const wantCols = templateColumns(tpl);
|
|
438
|
-
for (const t of
|
|
460
|
+
for (const t of ALL_TABLES) {
|
|
439
461
|
let have = new Set();
|
|
440
462
|
try {
|
|
441
463
|
const rs = await client.query({
|
|
442
464
|
query: 'SELECT name FROM system.columns WHERE database = currentDatabase() AND table = {n:String}',
|
|
443
|
-
query_params: { n: rooms[t] }, format: 'JSONEachRow',
|
|
465
|
+
query_params: { n: rooms[`${t}_raw`] }, format: 'JSONEachRow',
|
|
444
466
|
});
|
|
445
467
|
have = new Set((await rs.json()).map((r) => r.name));
|
|
446
468
|
} catch { /* unreadable: the checks above already reported why */ }
|
|
469
|
+
// No columns means the table is not there — the CREATEs above were denied, or this is
|
|
470
|
+
// a house where the meta tables were never created. ALTERing it would throw
|
|
471
|
+
// UNKNOWN_TABLE, which is not a permissions error and so prints, once per column, on
|
|
472
|
+
// every `--ensure-schema`. Nothing to heal on a table that does not exist.
|
|
473
|
+
if (!have.size) continue;
|
|
447
474
|
for (const col of (wantCols[t] || [])) {
|
|
448
|
-
if (have.
|
|
475
|
+
if (have.has(col.name)) continue;
|
|
449
476
|
try {
|
|
450
477
|
await client.command({
|
|
451
|
-
query: `ALTER TABLE ${rooms[t]} ADD COLUMN IF NOT EXISTS ${col.name} ${col.type}`,
|
|
478
|
+
query: `ALTER TABLE ${rooms[`${t}_raw`]} ADD COLUMN IF NOT EXISTS ${col.name} ${col.type}`,
|
|
452
479
|
clickhouse_settings: { allow_experimental_full_text_index: 1 },
|
|
453
480
|
});
|
|
454
|
-
|
|
481
|
+
console.log(`[memhouse] added missing column ${rooms[`${t}_raw`]}.${col.name}`);
|
|
455
482
|
} catch (e) {
|
|
456
483
|
const m = e && e.message ? e.message : String(e);
|
|
457
484
|
if (!/Not enough privileges|ACCESS_DENIED/i.test(m)) {
|
|
458
|
-
console.error(`[memhouse] could not add ${rooms[t]}.${col.name}: ${m}`);
|
|
485
|
+
console.error(`[memhouse] could not add ${rooms[`${t}_raw`]}.${col.name}: ${m}`);
|
|
459
486
|
}
|
|
460
487
|
}
|
|
461
488
|
}
|
|
462
|
-
try {
|
|
463
|
-
await client.command({
|
|
464
|
-
query: `ALTER TABLE ${rooms[t]} ADD COLUMN IF NOT EXISTS origin LowCardinality(String) DEFAULT 'ship'`,
|
|
465
|
-
// 25.11 refuses ANY alter on a table carrying the messages text indexes unless
|
|
466
|
-
// this is set — including an ADD COLUMN that has nothing to do with them
|
|
467
|
-
// (Code: 344, SUPPORT_IS_DISABLED). Without it the backfill fails silently and
|
|
468
|
-
// the guard ends up missing on precisely the houses that need upgrading. A no-op
|
|
469
|
-
// on 26.x, and query-scoped, so it needs no server config.
|
|
470
|
-
clickhouse_settings: { allow_experimental_full_text_index: 1 },
|
|
471
|
-
});
|
|
472
|
-
} catch (e) {
|
|
473
|
-
// No rights to alter is genuinely not fatal — a member on someone else's house
|
|
474
|
-
// cannot roll a column out and does not need to. But this used to swallow
|
|
475
|
-
// EVERYTHING, including the 25.11 Code 344 this call carries a setting to avoid, and
|
|
476
|
-
// `schema ensured` printed regardless. Name anything that is not a permissions
|
|
477
|
-
// problem, and keep going.
|
|
478
|
-
const m = e && e.message ? e.message : String(e);
|
|
479
|
-
if (!/Not enough privileges|ACCESS_DENIED/i.test(m)) {
|
|
480
|
-
console.error(`[memhouse] could not add the 'origin' column to ${rooms[t]}: ${m}`);
|
|
481
|
-
console.error('[memhouse] the room still works; a house that predates 0.4.4 needs rebuilding — memhouse doctor');
|
|
482
|
-
}
|
|
483
|
-
}
|
|
484
489
|
}
|
|
490
|
+
// There used to be a second, hardcoded `ADD COLUMN … origin` here, from when the healer
|
|
491
|
+
// above could only add that one column. It is redundant now that the generic loop reads
|
|
492
|
+
// the template correctly — and it would have been actively wrong once this loop covered
|
|
493
|
+
// house_meta and house_events, which have no origin and want none.
|
|
485
494
|
|
|
486
495
|
await assertRoomsExist(client, rooms);
|
|
487
|
-
await
|
|
496
|
+
await assertRoomKeys(client, rooms);
|
|
488
497
|
await warnMissingColumns(client, rooms);
|
|
498
|
+
// Re-assert the async-insert pin. A 0.11 member holds ALTER USER on themselves (so
|
|
499
|
+
// `memhouse passwd` needs no admin) — and that same grant lets them drop the pin
|
|
500
|
+
// (`ALTER USER <self> SETTINGS NONE`), which on some ClickHouse versions makes their
|
|
501
|
+
// OWN async inserts land with an empty user_id. It cannot forge another identity
|
|
502
|
+
// (currentUser() is server-side) and doctor flags any blank user_id, but re-adding the
|
|
503
|
+
// pin here means an accidental or transient drop self-heals on the next --ensure-schema
|
|
504
|
+
// (install, update, migrate). Best-effort: a member who cannot ADD SETTING just stays
|
|
505
|
+
// as they were.
|
|
506
|
+
try { await client.command({ query: `ALTER USER \`${rooms.user}\` ADD SETTING ${MEMBER_PIN}`, clickhouse_settings: { async_insert: 0 } }); }
|
|
507
|
+
catch { /* no self-alter grant (pre-0.11), or not permitted — leave it */ }
|
|
508
|
+
// The rooms are at this schema generation — the assertion above is what makes that a
|
|
509
|
+
// fact rather than a claim. Recording it here means a house built by `install` is
|
|
510
|
+
// already stamped, instead of looking un-migrated until its first ship pass.
|
|
511
|
+
//
|
|
512
|
+
// MEMHOUSE_NO_RECORD is the invite path: `memhouse invite` runs this only to PROVE the
|
|
513
|
+
// new member can build the rooms, on a throwaway home. Recording would stamp a phantom
|
|
514
|
+
// <member>@<ephemeral-host> writer into a house the inviter never ships to — the
|
|
515
|
+
// invitee mints their real identity on their own first pass.
|
|
516
|
+
if (process.env.MEMHOUSE_NO_RECORD !== '1') await recordHouseState(client, rooms, hostId());
|
|
489
517
|
return stmts.length;
|
|
490
518
|
}
|
|
491
519
|
|
|
520
|
+
/**
|
|
521
|
+
* Keep the house's record of itself current: which schema generation it is at, and which
|
|
522
|
+
* memhouse version is writing into it.
|
|
523
|
+
*
|
|
524
|
+
* Written only when something CHANGED. A row per pass would turn house_events into a
|
|
525
|
+
* heartbeat log — under `--loop` at the default interval that is 288 rows a day per
|
|
526
|
+
* machine, and the one question the table exists to answer ("when did this house move,
|
|
527
|
+
* and who moved it") would be buried in noise.
|
|
528
|
+
*
|
|
529
|
+
* Entirely best-effort. A member on someone else's house may hold no rights on these
|
|
530
|
+
* tables, or the house may predate them; none of that is a reason to stop shipping, so
|
|
531
|
+
* every failure here is swallowed. The rooms are the product, this is the paperwork.
|
|
532
|
+
*/
|
|
533
|
+
async function recordHouseState(client, rooms, host) {
|
|
534
|
+
const version = require('../../package.json').version;
|
|
535
|
+
const writer = `${rooms.user}@${host}`;
|
|
536
|
+
try {
|
|
537
|
+
const rs = await client.query({
|
|
538
|
+
query: `SELECT key, value FROM ${rooms.house_meta} FINAL WHERE key IN ('schema_version', {ck:String}, {cs:String})`,
|
|
539
|
+
// Keyed per member AND host. Keyed by member alone, two machines of one member
|
|
540
|
+
// overwrote each other's entry on every pass, so the fleet view could only ever
|
|
541
|
+
// show the machine that shipped last — the exact machine that needs no attention.
|
|
542
|
+
query_params: { ck: `client_version:${writer}`, cs: `client_schema:${writer}` }, format: 'JSONEachRow',
|
|
543
|
+
});
|
|
544
|
+
const have = new Map((await rs.json()).map((r) => [r.key, String(r.value)]));
|
|
545
|
+
const events = [];
|
|
546
|
+
const metas = [];
|
|
547
|
+
if (have.get('schema_version') !== String(SCHEMA_VERSION)) {
|
|
548
|
+
// The rooms are at this generation — assertRoomKeys ran before this and refuses to
|
|
549
|
+
// let a pass reach here otherwise, so recording it is a statement of fact, not a
|
|
550
|
+
// claim about what someone intends to do.
|
|
551
|
+
metas.push({ key: 'schema_version', value: String(SCHEMA_VERSION), host });
|
|
552
|
+
events.push({
|
|
553
|
+
kind: 'schema', status: 'observed', host,
|
|
554
|
+
from_version: have.get('schema_version') || '', to_version: String(SCHEMA_VERSION),
|
|
555
|
+
detail: 'rooms carry the current sorting keys',
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
const clientKey = `client_version:${writer}`;
|
|
559
|
+
// The schema this writer SUPPORTS, beside the marketing version — the fleet view
|
|
560
|
+
// judges on this, because a version string cannot be compared against a schema
|
|
561
|
+
// requirement (and a pre-release checkout may not have bumped package.json at all).
|
|
562
|
+
// Tracked on its own change, not the version's: tying it to a version bump left every
|
|
563
|
+
// already-recorded writer without one forever.
|
|
564
|
+
const mySchema = String(Math.max(...SUPPORTED_SCHEMAS));
|
|
565
|
+
if (have.get(`client_schema:${writer}`) !== mySchema) {
|
|
566
|
+
metas.push({ key: `client_schema:${writer}`, value: mySchema, host });
|
|
567
|
+
}
|
|
568
|
+
if (have.get(clientKey) !== version) {
|
|
569
|
+
metas.push({ key: clientKey, value: version, host });
|
|
570
|
+
events.push({
|
|
571
|
+
kind: 'version', status: 'observed', host,
|
|
572
|
+
from_version: have.get(clientKey) || '', to_version: version,
|
|
573
|
+
detail: `${rooms.user} shipping from ${host}`,
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
// The heartbeat, EVERY pass — the fleet view's health column. Latest-wins on the key,
|
|
577
|
+
// so it is one live row per writer however often it fires; version/schema above stay
|
|
578
|
+
// change-only so house_events remains a record of moves, not a pulse trace.
|
|
579
|
+
metas.push({ key: `last_ship:${writer}`, value: new Date().toISOString(), host });
|
|
580
|
+
await client.insert({
|
|
581
|
+
table: rooms.house_meta_raw, values: metas, format: 'JSONEachRow',
|
|
582
|
+
clickhouse_settings: { async_insert: 0 },
|
|
583
|
+
});
|
|
584
|
+
if (events.length) {
|
|
585
|
+
await client.insert({
|
|
586
|
+
table: rooms.house_events_raw, values: events, format: 'JSONEachRow',
|
|
587
|
+
clickhouse_settings: { async_insert: 0 },
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
} catch { /* the house's paperwork is never worth failing a pass over */ }
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* The forward half of the compatibility story: refuse a house whose RECORDED schema this
|
|
595
|
+
* release does not support, before any room is touched.
|
|
596
|
+
*
|
|
597
|
+
* Distinct from assertRoomKeys, which inspects key SHAPES — a future generation could be
|
|
598
|
+
* a data transform the keys do not show. This reads what the house says about itself:
|
|
599
|
+
* house_meta['schema_version'] (what generation the rooms are at) and
|
|
600
|
+
* house_meta['min_writer_schema'] (the floor `memhouse migrate` sets under writers).
|
|
601
|
+
* Too new -> the fix is on THIS machine: memhouse update. Below the floor -> same.
|
|
602
|
+
* (A house OLDER than this release is not an error here — the room checks catch it and
|
|
603
|
+
* name `memhouse migrate`; this guard must not fire on a pre-0.10 house that has no
|
|
604
|
+
* record at all.)
|
|
605
|
+
*
|
|
606
|
+
* Releases before 0.10.0 never read this — for them the floor is enforced by the
|
|
607
|
+
* pilot's GRANTs, not by code.
|
|
608
|
+
*/
|
|
609
|
+
async function assertWriterSupported(client, rooms) {
|
|
610
|
+
let have;
|
|
611
|
+
try {
|
|
612
|
+
const rs = await client.query({
|
|
613
|
+
query: `SELECT key, value FROM ${rooms.house_meta} FINAL WHERE key IN ('schema_version', 'min_writer_schema')`,
|
|
614
|
+
format: 'JSONEachRow',
|
|
615
|
+
});
|
|
616
|
+
have = new Map((await rs.json()).map((r) => [r.key, toInt(r.value)]));
|
|
617
|
+
} catch (e) {
|
|
618
|
+
// ONLY a missing table means a pre-0.10 house (the room checks own that case). Any
|
|
619
|
+
// other failure — an ACCESS_DENIED on house_meta, a timeout — used to fall through
|
|
620
|
+
// here too, and a writer that merely could not READ the record was treated as if the
|
|
621
|
+
// record did not exist: on a newer-schema house whose key shapes happen to match,
|
|
622
|
+
// that bypassed the whole compatibility guard. Not being able to check is a reason
|
|
623
|
+
// to refuse, never a reason to proceed.
|
|
624
|
+
const m = e && e.message ? e.message : String(e);
|
|
625
|
+
if (/UNKNOWN_TABLE|Unknown table expression|doesn't exist|does not exist/i.test(m)) return;
|
|
626
|
+
throw new Error(`could not read this house's compatibility record (${m.split('\n')[0]})\n`
|
|
627
|
+
+ ' Refusing to write until it is readable — a writer that cannot check the schema\n'
|
|
628
|
+
+ ' generation must not assume it matches.');
|
|
629
|
+
}
|
|
630
|
+
const houseSchema = have.get('schema_version') || 0;
|
|
631
|
+
const floor = have.get('min_writer_schema') || 0;
|
|
632
|
+
const mine = Math.max(...SUPPORTED_SCHEMAS);
|
|
633
|
+
// MEMBERSHIP, not a ceiling. `houseSchema <= max` would let a [4]-only writer into a
|
|
634
|
+
// schema-3 house — and if generation 4 was a data transform, the sorting keys match and
|
|
635
|
+
// nothing else refuses. A recorded generation this release does not list is unwritable
|
|
636
|
+
// in either direction; only the remedy differs.
|
|
637
|
+
if (houseSchema > 0 && !SUPPORTED_SCHEMAS.includes(houseSchema)) {
|
|
638
|
+
throw new Error(houseSchema > mine
|
|
639
|
+
? `this house is at schema ${houseSchema}; this memhouse (${require('../../package.json').version}) supports ${SUPPORTED_SCHEMAS.join(', ')}.\n`
|
|
640
|
+
+ ' A newer release moved the house forward. Writing with this one could corrupt it, so it will not.\n'
|
|
641
|
+
+ ' Update THIS machine: memhouse update'
|
|
642
|
+
: `this house is at schema ${houseSchema}; this memhouse supports ${SUPPORTED_SCHEMAS.join(', ')}.\n`
|
|
643
|
+
+ ' Bring the house forward: memhouse migrate');
|
|
644
|
+
}
|
|
645
|
+
if (floor > mine) {
|
|
646
|
+
throw new Error(
|
|
647
|
+
`this house requires writers at schema ${floor}+; this memhouse supports ${SUPPORTED_SCHEMAS.join(', ')}.\n`
|
|
648
|
+
+ ' Update THIS machine: memhouse update');
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
492
652
|
// Incremental state: what the house already holds, keyed by session_id. We compare
|
|
493
653
|
// against extra.bubbleCount (the adapter's own cheap count, stored at ship time) —
|
|
494
654
|
// NOT message_count — because parsed-message count and bubbleCount are different
|
|
@@ -496,11 +656,11 @@ async function ensureSchema(client) {
|
|
|
496
656
|
// must be decidable WITHOUT calling getMessages on every chat.
|
|
497
657
|
async function loadExisting(client, rooms) {
|
|
498
658
|
const rs = await client.query({
|
|
499
|
-
query: `SELECT session_id, last_updated_at, message_count, extra FROM ${rooms.
|
|
659
|
+
query: `SELECT session_id, last_updated_at, message_count, extra FROM ${rooms.sessions_raw} FINAL WHERE user_id = currentUser()`,
|
|
500
660
|
format: 'JSONEachRow',
|
|
501
661
|
});
|
|
502
|
-
// Actual message rows per session: an interrupted re-ship (crash
|
|
503
|
-
//
|
|
662
|
+
// Actual message rows per session: an interrupted re-ship (a crash mid-flush on a
|
|
663
|
+
// large transcript, or between the sessions insert and the messages one) leaves a
|
|
504
664
|
// fresh-looking session row with a missing or PARTIAL transcript — the skip
|
|
505
665
|
// must compare the real row count against the recorded message_count, not
|
|
506
666
|
// merely check that some row exists.
|
|
@@ -511,12 +671,40 @@ async function loadExisting(client, rooms) {
|
|
|
511
671
|
// on every pass — no data lost, but the incremental skip silently stops existing.
|
|
512
672
|
// Measured: adding one origin='import' row to a settled session made it re-ship
|
|
513
673
|
// every pass; removing it froze the session again.
|
|
674
|
+
//
|
|
675
|
+
// GROUPED BY EPOCH, and only the newest epoch's rows count. A session whose parse was
|
|
676
|
+
// superseded still holds every older parse — that is the point of the epoch — so a
|
|
677
|
+
// plain count would exceed message_count forever, `intact` would never be true, and the
|
|
678
|
+
// incremental skip would quietly stop existing for exactly the sessions that have been
|
|
679
|
+
// through a compaction. One row per (session, epoch); epochs are rare, so this stays
|
|
680
|
+
// the same size as the per-session grouping it replaces.
|
|
514
681
|
const mr = await client.query({
|
|
515
|
-
query: `SELECT session_id,
|
|
682
|
+
query: `SELECT session_id, epoch, count() AS n, max(seq) AS max_seq FROM ${rooms.messages_raw} FINAL
|
|
683
|
+
WHERE user_id = currentUser() AND origin = 'ship' GROUP BY session_id, epoch`,
|
|
516
684
|
format: 'JSONEachRow',
|
|
517
685
|
});
|
|
518
|
-
|
|
519
|
-
|
|
686
|
+
// Kept PER EPOCH, not collapsed to each room's own newest — a room can legitimately
|
|
687
|
+
// have no rows at the session's current epoch, and reading its highest epoch instead
|
|
688
|
+
// would answer with the superseded parse. See the tool_calls note below, which is where
|
|
689
|
+
// that actually bites.
|
|
690
|
+
//
|
|
691
|
+
// U+0000 as the key separator, and written as an ESCAPE, deliberately. A session_id is
|
|
692
|
+
// '<source>:<adapter-local id>' and an adapter-local id can legally contain any
|
|
693
|
+
// printable character, so only a byte that cannot appear in one is collision-proof.
|
|
694
|
+
// The escape matters twice over: a literal NUL byte typed here once made grep treat
|
|
695
|
+
// this file as binary — and one of the three key sites was typed with a plain space
|
|
696
|
+
// instead, so the tool lookups missed on every call, `tools.n` read 0, every session
|
|
697
|
+
// with tool calls failed `intact`, and a real 437-session history re-shipped 324
|
|
698
|
+
// sessions on EVERY pass, forever, with every surface green. All three sites must
|
|
699
|
+
// build this key identically; the unit test pins them to each other.
|
|
700
|
+
const at = (m, id, epoch) => m.get(`${id}\u0000${epoch}`) || null;
|
|
701
|
+
const msgState = new Map(); // (session, epoch) → { n, maxSeq }
|
|
702
|
+
const msgEpoch = new Map(); // session → newest epoch present
|
|
703
|
+
for (const r of await mr.json()) {
|
|
704
|
+
const epoch = toInt(r.epoch);
|
|
705
|
+
msgState.set(`${r.session_id}\u0000${epoch}`, { n: toInt(r.n), maxSeq: toInt(r.max_seq) });
|
|
706
|
+
msgEpoch.set(r.session_id, Math.max(epoch, msgEpoch.get(r.session_id) || 0));
|
|
707
|
+
}
|
|
520
708
|
// The tool_calls room needs the same check, and used to have none. shipSession writes
|
|
521
709
|
// sessions, then messages, then tool_calls — so a pass that fails during the LAST of
|
|
522
710
|
// those three leaves a session whose message count matches perfectly. The next pass
|
|
@@ -525,11 +713,17 @@ async function loadExisting(client, rooms) {
|
|
|
525
713
|
// Reproduced by dropping the room mid-pass; any transient — a ClickHouse restart, a
|
|
526
714
|
// quota rejection, a network blip — reaches the same state.
|
|
527
715
|
const tr = await client.query({
|
|
528
|
-
query: `SELECT session_id,
|
|
716
|
+
query: `SELECT session_id, epoch, count() AS n, max(idx) AS max_idx FROM ${rooms.tool_calls_raw} FINAL
|
|
717
|
+
WHERE user_id = currentUser() AND origin = 'ship' GROUP BY session_id, epoch`,
|
|
529
718
|
format: 'JSONEachRow',
|
|
530
719
|
});
|
|
531
|
-
const
|
|
532
|
-
|
|
720
|
+
const toolState = new Map(); // (session, epoch) → { n, maxIdx }
|
|
721
|
+
const toolEpoch = new Map(); // session → newest epoch present
|
|
722
|
+
for (const r of await tr.json()) {
|
|
723
|
+
const epoch = toInt(r.epoch);
|
|
724
|
+
toolState.set(`${r.session_id}\u0000${epoch}`, { n: toInt(r.n), maxIdx: toInt(r.max_idx) });
|
|
725
|
+
toolEpoch.set(r.session_id, Math.max(epoch, toolEpoch.get(r.session_id) || 0));
|
|
726
|
+
}
|
|
533
727
|
const map = new Map();
|
|
534
728
|
for (const r of await rs.json()) {
|
|
535
729
|
// DateTime64 comes back as 'YYYY-MM-DD HH:MM:SS.mmm' — re-parse as UTC.
|
|
@@ -540,9 +734,30 @@ async function loadExisting(client, rooms) {
|
|
|
540
734
|
// have tool calls re-ship exactly once and then record it. Self-correcting, and
|
|
541
735
|
// cheaper than a branch that has to be remembered forever.
|
|
542
736
|
const toolCount = toInt(r.extra && r.extra.toolCallCount);
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
737
|
+
// The session's current epoch is whatever its ROWS say, never what the sessions row
|
|
738
|
+
// says: sessions is latest-wins with no origin in its key, so an import writing that
|
|
739
|
+
// row after a bump would hand the next pass a 0 and make it overwrite the current
|
|
740
|
+
// parse. The column there is for people, not for this.
|
|
741
|
+
const epoch = Math.max(msgEpoch.get(r.session_id) || 0, toolEpoch.get(r.session_id) || 0);
|
|
742
|
+
// Everything else is read AT that epoch, and "no rows there" means zero — not "look
|
|
743
|
+
// at the newest epoch this room happens to have".
|
|
744
|
+
//
|
|
745
|
+
// Getting this wrong does not lose data, it makes the shipper churn forever. A parse
|
|
746
|
+
// that keeps its messages but drops every tool call writes nothing into tool_calls at
|
|
747
|
+
// the new epoch. Reading that room's own newest epoch then returned the SUPERSEDED
|
|
748
|
+
// count (say 2) and its maxIdx — so `intact` compared 2 against the recorded 0 and was
|
|
749
|
+
// false on every pass, and decideEpoch compared `0 - 1 < oldMaxIdx` and bumped again
|
|
750
|
+
// on every pass. The session re-shipped and gained an epoch forever, growing the house
|
|
751
|
+
// without end, while every surface reported success.
|
|
752
|
+
const msgs = at(msgState, r.session_id, epoch) || { n: 0, maxSeq: -1 };
|
|
753
|
+
const tools = at(toolState, r.session_id, epoch) || { n: 0, maxIdx: -1 };
|
|
754
|
+
const intact = msgs.n === count && tools.n === toolCount;
|
|
755
|
+
map.set(r.session_id, {
|
|
756
|
+
ms, count, bc, intact, epoch,
|
|
757
|
+
maxSeq: msgs.maxSeq,
|
|
758
|
+
maxIdx: tools.maxIdx,
|
|
759
|
+
hasRows: msgEpoch.has(r.session_id) || toolEpoch.has(r.session_id),
|
|
760
|
+
});
|
|
546
761
|
}
|
|
547
762
|
return map;
|
|
548
763
|
}
|
|
@@ -663,20 +878,124 @@ function rowsForChat(chat, host) {
|
|
|
663
878
|
return { session, msgRows, toolRows };
|
|
664
879
|
}
|
|
665
880
|
|
|
881
|
+
/**
|
|
882
|
+
* Which parse epoch this re-ship belongs to, and why.
|
|
883
|
+
*
|
|
884
|
+
* THE RULE: the shipper never writes over stored content that differs from what it is
|
|
885
|
+
* writing. If the new parse can be laid on top of the old one without changing or
|
|
886
|
+
* orphaning anything, it reuses the epoch and ReplacingMergeTree dedupes it exactly as
|
|
887
|
+
* before — the common case, free. Otherwise it moves to a new epoch, where the new rows
|
|
888
|
+
* cannot collide with the old ones, and the old parse stays whole.
|
|
889
|
+
*
|
|
890
|
+
* Two things make a parse un-overwritable, and both are ordinary:
|
|
891
|
+
*
|
|
892
|
+
* SHRINK. A re-parse with fewer messages leaves the old higher-seq rows with nothing
|
|
893
|
+
* written over them. That stale tail is what the DELETE this replaces was written to
|
|
894
|
+
* remove — and the delete could not tell a fixed adapter bug (junk, worth removing)
|
|
895
|
+
* from a compacted or user-deleted transcript (irreplaceable), because from outside
|
|
896
|
+
* they look identical.
|
|
897
|
+
*
|
|
898
|
+
* DIVERGENCE. Claude Code COMPACTS a session in place: same session, rewritten
|
|
899
|
+
* shorter, with different content at low seq numbers. Bumping on shrink alone would
|
|
900
|
+
* preserve the tail and still overwrite the rewritten head — the same loss, reached
|
|
901
|
+
* through the merge instead of the delete. line_hash is already stored per row, so
|
|
902
|
+
* comparing is a lookup, not a re-read of the source.
|
|
903
|
+
*
|
|
904
|
+
* Tool calls carry no hash, so they are compared on (tool_name, args) directly. They are
|
|
905
|
+
* derived from `m._toolCalls`, which can change while the assistant text does not.
|
|
906
|
+
*
|
|
907
|
+
* Pure, and exported: this is the decision the whole redesign rests on, and it is unit
|
|
908
|
+
* tested without a server.
|
|
909
|
+
*
|
|
910
|
+
* @param {object|null} stored { epoch, maxSeq, maxIdx, hashes: Map<seq,string>,
|
|
911
|
+
* tools: Map<idx,{tool_name,args}> } — null when the
|
|
912
|
+
* session is not in the house yet.
|
|
913
|
+
* @param {object} incoming { msgRows, toolRows } as built by rowsForChat.
|
|
914
|
+
* @returns {{ epoch: number, reason: string|null }} reason is null when nothing moved.
|
|
915
|
+
*/
|
|
916
|
+
function decideEpoch(stored, incoming) {
|
|
917
|
+
if (!stored) return { epoch: 0, reason: null };
|
|
918
|
+
const epoch = toInt(stored.epoch);
|
|
919
|
+
const bump = (reason) => ({ epoch: epoch + 1, reason });
|
|
920
|
+
|
|
921
|
+
const maxSeq = Number.isFinite(stored.maxSeq) ? stored.maxSeq : -1;
|
|
922
|
+
const maxIdx = Number.isFinite(stored.maxIdx) ? stored.maxIdx : -1;
|
|
923
|
+
if (incoming.msgRows.length - 1 < maxSeq) {
|
|
924
|
+
return bump(`${maxSeq + 1} messages stored, ${incoming.msgRows.length} parsed`);
|
|
925
|
+
}
|
|
926
|
+
if (incoming.toolRows.length - 1 < maxIdx) {
|
|
927
|
+
return bump(`${maxIdx + 1} tool calls stored, ${incoming.toolRows.length} parsed`);
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// A seq the house does not hold is a gap, not a disagreement — there is nothing there
|
|
931
|
+
// to destroy, so it is not a reason to fork the session.
|
|
932
|
+
const hashes = stored.hashes || new Map();
|
|
933
|
+
for (const row of incoming.msgRows) {
|
|
934
|
+
if (row.seq > maxSeq) break;
|
|
935
|
+
const was = hashes.get(row.seq);
|
|
936
|
+
if (was !== undefined && was !== String(row.line_hash)) {
|
|
937
|
+
return bump(`message ${row.seq} was rewritten`);
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
const tools = stored.tools || new Map();
|
|
941
|
+
for (const row of incoming.toolRows) {
|
|
942
|
+
if (row.idx > maxIdx) break;
|
|
943
|
+
const was = tools.get(row.idx);
|
|
944
|
+
if (was !== undefined && (was.tool_name !== row.tool_name || was.args !== row.args)) {
|
|
945
|
+
return bump(`tool call ${row.idx} was rewritten`);
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
return { epoch, reason: null };
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
/**
|
|
952
|
+
* What the house currently holds for ONE session, at its current epoch — the input
|
|
953
|
+
* decideEpoch needs to tell an append from a rewrite.
|
|
954
|
+
*
|
|
955
|
+
* Read only for sessions actually being re-shipped, which the incremental skip has
|
|
956
|
+
* already narrowed to a handful; it replaces the two DELETE mutations that used to run
|
|
957
|
+
* per re-shipped session, so a normal pass does strictly less work than before.
|
|
958
|
+
*
|
|
959
|
+
* `toString(line_hash)`: the client is configured with
|
|
960
|
+
* output_format_json_quote_64bit_integers = 0, so a UInt64 arrives as a JSON number and
|
|
961
|
+
* everything above 2^53 comes back rounded. Comparing rounded hashes would report
|
|
962
|
+
* divergence on identical rows and fork a session on every single pass.
|
|
963
|
+
*/
|
|
964
|
+
async function loadStoredParse(client, rooms, id, uid, epoch) {
|
|
965
|
+
const params = { id, uid, e: epoch };
|
|
966
|
+
const mr = await client.query({
|
|
967
|
+
query: `SELECT seq, toString(line_hash) AS line_hash FROM ${rooms.messages_raw} FINAL
|
|
968
|
+
WHERE session_id = {id:String} AND user_id = {uid:String} AND origin = 'ship' AND epoch = {e:UInt32}`,
|
|
969
|
+
query_params: params, format: 'JSONEachRow',
|
|
970
|
+
});
|
|
971
|
+
const hashes = new Map();
|
|
972
|
+
for (const r of await mr.json()) hashes.set(toInt(r.seq), String(r.line_hash));
|
|
973
|
+
const tr = await client.query({
|
|
974
|
+
query: `SELECT idx, tool_name, args FROM ${rooms.tool_calls_raw} FINAL
|
|
975
|
+
WHERE session_id = {id:String} AND user_id = {uid:String} AND origin = 'ship' AND epoch = {e:UInt32}`,
|
|
976
|
+
query_params: params, format: 'JSONEachRow',
|
|
977
|
+
});
|
|
978
|
+
const tools = new Map();
|
|
979
|
+
for (const r of await tr.json()) tools.set(toInt(r.idx), { tool_name: r.tool_name, args: r.args });
|
|
980
|
+
return { hashes, tools };
|
|
981
|
+
}
|
|
982
|
+
|
|
666
983
|
// One shipping pass. Incremental unless opts.full: a chat is skipped when the house
|
|
667
984
|
// already has it at least as fresh (last_updated_at) and at least as large
|
|
668
985
|
// (extra.bubbleCount) — both readable without parsing the chat.
|
|
669
986
|
async function runShip(client, opts = {}) {
|
|
670
987
|
const { full = false } = opts;
|
|
671
988
|
const host = hostId();
|
|
672
|
-
// Always load what the house holds — even with --full. The skip decision uses it
|
|
673
|
-
//
|
|
674
|
-
//
|
|
675
|
-
//
|
|
989
|
+
// Always load what the house holds — even with --full. The skip decision uses it only
|
|
990
|
+
// in incremental mode, but re-shipping a KNOWN session needs its current epoch and row
|
|
991
|
+
// shape: writing at the wrong epoch either forks a session that did not change, or
|
|
992
|
+
// overwrites a stored parse that did.
|
|
676
993
|
const rooms = await resolveRooms(client);
|
|
994
|
+
await assertWriterSupported(client, rooms);
|
|
677
995
|
await assertRoomsExist(client, rooms);
|
|
678
|
-
await
|
|
996
|
+
await assertRoomKeys(client, rooms);
|
|
679
997
|
await warnMissingColumns(client, rooms);
|
|
998
|
+
await recordHouseState(client, rooms, host);
|
|
680
999
|
const existing = await loadExisting(client, rooms);
|
|
681
1000
|
const chats = getAllChats();
|
|
682
1001
|
|
|
@@ -693,7 +1012,7 @@ async function runShip(client, opts = {}) {
|
|
|
693
1012
|
const flush = async (table) => {
|
|
694
1013
|
if (!batches[table].length) return;
|
|
695
1014
|
await client.insert({
|
|
696
|
-
table: rooms[table],
|
|
1015
|
+
table: rooms[`${table}_raw`],
|
|
697
1016
|
values: batches[table],
|
|
698
1017
|
format: 'JSONEachRow',
|
|
699
1018
|
clickhouse_settings: { async_insert: 0 }, // binding: user_id stamping breaks otherwise
|
|
@@ -706,7 +1025,7 @@ async function runShip(client, opts = {}) {
|
|
|
706
1025
|
};
|
|
707
1026
|
|
|
708
1027
|
const seen = new Set(); // adapters must not double-ship a session id within a pass
|
|
709
|
-
let sessions = 0, skipped = 0, msgRows = 0, toolRows = 0, unreadable = 0;
|
|
1028
|
+
let sessions = 0, skipped = 0, msgRows = 0, toolRows = 0, unreadable = 0, bumped = 0, withheld = 0;
|
|
710
1029
|
for (const chat of chats) {
|
|
711
1030
|
if (chat.encrypted) continue;
|
|
712
1031
|
// Same canonical '<source>:<adapter-local id>' as rowsForChat: dedup and
|
|
@@ -734,11 +1053,13 @@ async function runShip(client, opts = {}) {
|
|
|
734
1053
|
}
|
|
735
1054
|
|
|
736
1055
|
// rowsForChat returns null only when getMessages() *throws*. An adapter that
|
|
737
|
-
// swallows its own failure returns [] instead, which would look like a session
|
|
738
|
-
//
|
|
739
|
-
//
|
|
740
|
-
//
|
|
741
|
-
//
|
|
1056
|
+
// swallows its own failure returns [] instead, which would look like a session that
|
|
1057
|
+
// genuinely has no messages — and for a known session that is a maximal shrink, so
|
|
1058
|
+
// the pass would fork it to a new, EMPTY epoch and every read would show nothing
|
|
1059
|
+
// where a transcript used to be. Nothing is destroyed any more, but a house that
|
|
1060
|
+
// reports empty sessions after a locked SQLite file is still wrong. So treat a
|
|
1061
|
+
// failure recorded while reading THIS chat as unreadable: write nothing, retry next
|
|
1062
|
+
// pass.
|
|
742
1063
|
const errsBefore = adapterErrorSink.recorded().length;
|
|
743
1064
|
const rows = rowsForChat(chat, host);
|
|
744
1065
|
const failedHere = adapterErrorSink.recorded().slice(errsBefore).some((e) => e.source === chat.source);
|
|
@@ -753,57 +1074,69 @@ async function runShip(client, opts = {}) {
|
|
|
753
1074
|
// the skip predicate entirely, so every pass retries it and re-reports it until
|
|
754
1075
|
// the underlying store is readable.
|
|
755
1076
|
if (!rows || failedHere) { unreadable++; continue; }
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
1077
|
+
|
|
1078
|
+
// A KNOWN session that now parses to nothing is withheld, not written.
|
|
1079
|
+
//
|
|
1080
|
+
// Two reasons, and the second is the one that bites. An adapter that swallows its own
|
|
1081
|
+
// failure returns [] rather than throwing, which is indistinguishable from a chat
|
|
1082
|
+
// whose content really did vanish — and the house is the party that still has the
|
|
1083
|
+
// content either way. Worse, writing it would not even be honest about itself: with
|
|
1084
|
+
// zero rows to insert, the new epoch exists nowhere in the room, so the read filter's
|
|
1085
|
+
// max(epoch) would keep answering with the SUPERSEDED epoch and serve the old parse as
|
|
1086
|
+
// if it were current. Withholding leaves the session out of the skip predicate, so
|
|
1087
|
+
// every pass retries it and says so.
|
|
1088
|
+
if (prev && prev.hasRows && !rows.msgRows.length) {
|
|
1089
|
+
withheld++;
|
|
1090
|
+
console.log(`[memhouse] WARNING: ${id} parsed to 0 messages but the house holds ${prev.count} — withholding`);
|
|
1091
|
+
continue;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
// Which parse this belongs to. A known session whose stored rows the new parse
|
|
1095
|
+
// cannot be laid over — shorter, or rewritten at an overlapping position — moves to
|
|
1096
|
+
// a new epoch instead of overwriting anything. See decideEpoch.
|
|
1097
|
+
//
|
|
1098
|
+
// This is where a DELETE used to be. It removed the session's shipped rows before
|
|
1099
|
+
// re-inserting them, which is the only way to clear a stale seq tail in a
|
|
1100
|
+
// ReplacingMergeTree — and it destroyed content that existed nowhere else, because
|
|
1101
|
+
// Claude Code compacts transcripts and deletes them after cleanupPeriodDays (30 by
|
|
1102
|
+
// default), so a shorter re-parse is at least as likely to mean "the source lost it"
|
|
1103
|
+
// as "the adapter was fixed". The house is supposed to outlive the source.
|
|
1104
|
+
let epoch = 0;
|
|
1105
|
+
if (prev && prev.hasRows) {
|
|
1106
|
+
const stored = await loadStoredParse(client, rooms, id, rooms.user, prev.epoch);
|
|
1107
|
+
const d = decideEpoch({ ...prev, ...stored }, rows);
|
|
1108
|
+
epoch = d.epoch;
|
|
1109
|
+
if (d.reason) {
|
|
1110
|
+
bumped++;
|
|
1111
|
+
console.log(`[memhouse] ${id} → epoch ${epoch} (${d.reason}); the previous parse is kept`);
|
|
781
1112
|
}
|
|
1113
|
+
} else if (prev) {
|
|
1114
|
+
epoch = toInt(prev.epoch);
|
|
782
1115
|
}
|
|
1116
|
+
rows.session.epoch = epoch;
|
|
783
1117
|
await push('sessions', scrub(rows.session));
|
|
784
1118
|
sessions++;
|
|
785
|
-
for (const r of rows.msgRows) { await push('messages', scrub(r)); msgRows++; }
|
|
786
|
-
for (const r of rows.toolRows) { await push('tool_calls', scrub(r)); toolRows++; }
|
|
1119
|
+
for (const r of rows.msgRows) { r.epoch = epoch; await push('messages', scrub(r)); msgRows++; }
|
|
1120
|
+
for (const r of rows.toolRows) { r.epoch = epoch; await push('tool_calls', scrub(r)); toolRows++; }
|
|
787
1121
|
}
|
|
788
1122
|
for (const table of Object.keys(batches)) await flush(table);
|
|
789
1123
|
// Anything that only failed while reading messages — the sink is reset by the
|
|
790
1124
|
// next getAllChats(), so unreported here means never reported at all.
|
|
791
1125
|
reportAdapterErrors(warned);
|
|
792
|
-
return { sessions, skipped, msgRows, toolRows, unreadable };
|
|
1126
|
+
return { sessions, skipped, msgRows, toolRows, unreadable, bumped, withheld };
|
|
793
1127
|
}
|
|
794
1128
|
|
|
795
|
-
// Warn once per adapter per pass. `warned` carries across the two call sites so
|
|
796
|
-
//
|
|
1129
|
+
// Warn once per adapter per pass. `warned` carries across the two call sites so one
|
|
1130
|
+
// unreadable store is not reported twice in the same run.
|
|
1131
|
+
//
|
|
1132
|
+
// The loud case used to be a missing better-sqlite3 binding, which took five adapters
|
|
1133
|
+
// out together and came with a reinstall command attached. SQLite ships with Node now;
|
|
1134
|
+
// what is left is per-store, and each error already says what happened.
|
|
797
1135
|
function reportAdapterErrors(warned) {
|
|
798
1136
|
const errors = getAdapterErrors().filter((e) => !warned.has(e.source));
|
|
799
1137
|
if (!errors.length) return;
|
|
800
1138
|
for (const e of errors) warned.add(e.source);
|
|
801
|
-
const
|
|
802
|
-
if (noBinding.length) {
|
|
803
|
-
console.log(`[memhouse] WARNING: ${noBinding.length} adapter(s) skipped, sessions NOT shipped — better-sqlite3 has no native binding: ${noBinding.join(', ')}`);
|
|
804
|
-
console.log(`[memhouse] fix: ${require('../house/house').installCommand()}`);
|
|
805
|
-
}
|
|
806
|
-
for (const e of errors.filter((x) => !x.missingBinding)) {
|
|
1139
|
+
for (const e of errors) {
|
|
807
1140
|
console.log(`[memhouse] WARNING: ${e.source} skipped — ${e.message}`);
|
|
808
1141
|
}
|
|
809
1142
|
}
|
|
@@ -871,7 +1204,10 @@ async function main() {
|
|
|
871
1204
|
let failed = false;
|
|
872
1205
|
try {
|
|
873
1206
|
const r = await runShip(client, { full });
|
|
874
|
-
console.log(`[memhouse] shipped ${r.sessions} sessions (${r.skipped} skipped
|
|
1207
|
+
console.log(`[memhouse] shipped ${r.sessions} sessions (${r.skipped} skipped`
|
|
1208
|
+
+ `${r.unreadable ? `, ${r.unreadable} unreadable-will-retry` : ''}`
|
|
1209
|
+
+ `${r.withheld ? `, ${r.withheld} withheld-empty` : ''}`
|
|
1210
|
+
+ `${r.bumped ? `, ${r.bumped} kept an earlier parse` : ''}) → ` +
|
|
875
1211
|
`${r.msgRows} msg rows, ${r.toolRows} tool rows in ${((Date.now() - t0) / 1000).toFixed(1)}s`);
|
|
876
1212
|
} catch (e) {
|
|
877
1213
|
failed = true;
|
|
@@ -922,7 +1258,7 @@ async function main() {
|
|
|
922
1258
|
}
|
|
923
1259
|
}
|
|
924
1260
|
|
|
925
|
-
module.exports = { runShip, ensureSchema, templateColumns };
|
|
1261
|
+
module.exports = { runShip, ensureSchema, templateColumns, decideEpoch };
|
|
926
1262
|
|
|
927
1263
|
if (require.main === module) {
|
|
928
1264
|
main().catch((e) => {
|
|
@@ -937,7 +1273,11 @@ if (require.main === module) {
|
|
|
937
1273
|
// advice to run `ship --ensure-schema`.
|
|
938
1274
|
const m = e && e.message ? e.message : String(e);
|
|
939
1275
|
if (/Not enough privileges|ACCESS_DENIED/i.test(m) && process.env.MEMHOUSE_QUIET_DENIED === '1') process.exit(1);
|
|
940
|
-
|
|
1276
|
+
// The sorting-key refusal joins the permissions one: both are the shipper DECLINING
|
|
1277
|
+
// to act, with the remediation already in the message. "fatal:" ahead of it made the
|
|
1278
|
+
// most-seen line of the upgrade path — every `--ensure-schema` on a pre-epoch house —
|
|
1279
|
+
// read like a crash.
|
|
1280
|
+
console.error(`[memhouse] ${/Not enough privileges|ACCESS_DENIED|wrong sorting key/i.test(m) ? 'refused' : 'fatal'}: ${m}`);
|
|
941
1281
|
process.exit(1);
|
|
942
1282
|
});
|
|
943
1283
|
}
|