memhouse 0.18.12 → 0.20.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/AGENTS.md +22 -151
- package/CHANGELOG.md +263 -29
- package/LICENSE +202 -22
- package/LICENSES/agentlytics-MIT.txt +21 -0
- package/LICENSES/memhouse-MIT-until-0.18.11.txt +22 -0
- package/LICENSES/models.dev-MIT.txt +22 -0
- package/NOTICE +28 -0
- package/README.md +42 -20
- package/SECURITY.md +21 -8
- package/bin/memhouse.js +2223 -311
- package/docs/README.md +11 -9
- package/docs/guides/admin-password.md +37 -10
- package/docs/guides/configuration.md +13 -6
- package/docs/guides/daemons.md +64 -3
- package/docs/guides/import-claude-ai.md +204 -0
- package/docs/guides/install.md +11 -6
- package/docs/guides/invite.md +21 -7
- package/docs/guides/operate.md +114 -7
- package/docs/guides/search-and-resume.md +72 -3
- package/docs/guides/share.md +91 -52
- package/docs/guides/storage.md +44 -4
- package/docs/guides/troubleshooting.md +121 -14
- package/docs/guides/uninstall.md +4 -3
- package/docs/guides/update.md +16 -0
- package/docs/guides/upgrade-to-0.20.md +188 -0
- package/docs/releases/README.md +2 -1
- package/docs/releases/v0.18.10.md +5 -5
- package/docs/releases/v0.18.11.md +3 -3
- package/docs/releases/v0.20.0.md +627 -0
- package/docs/releases/v0.20.1.md +114 -0
- package/docs/tutorials/01-first-house.md +10 -7
- package/docs/tutorials/02-join-a-house.md +24 -15
- package/docs/tutorials/03-share-your-memory.md +40 -31
- package/editors/antigravity.js +1 -80
- package/editors/base.js +463 -9
- package/editors/bridge.js +119 -0
- package/editors/claude.js +273 -105
- package/editors/codex.js +278 -52
- package/editors/copilot.js +1 -70
- package/editors/cursor.js +1 -75
- package/editors/derive-fingerprints.json +18 -0
- package/editors/harness-paths.js +69 -0
- package/editors/index.js +1 -20
- package/editors/normalise/agy.js +910 -0
- package/editors/normalise/copilot-cli.js +321 -0
- package/editors/normalise/cursor-agent.js +852 -0
- package/editors/normalise/cursor.js +474 -0
- package/editors/normalise/devin.js +575 -0
- package/editors/normalise/gemini-cli.js +602 -0
- package/editors/normalise/goose.js +673 -0
- package/editors/normalise/opencode.js +633 -0
- package/editors/normalise/vscode.js +549 -0
- package/editors/normalise/zed.js +473 -0
- package/editors/opencode-v4.js +119 -0
- package/editors/scope.js +14 -3
- package/editors/sessions-log.js +349 -0
- package/editors/sqlite.js +28 -1
- package/editors/vscode.js +1 -72
- package/editors/windsurf.js +1 -143
- package/examples/01-local-one-machine/README.md +24 -19
- package/examples/01-local-one-machine/run.sh +1 -1
- package/examples/02-existing-clickhouse/README.md +30 -22
- package/examples/02-existing-clickhouse/run.sh +5 -4
- package/examples/03-team-house/README.md +44 -37
- package/examples/03-team-house/run.sh +35 -18
- package/examples/04-self-hosted-team/README.md +29 -14
- package/examples/04-self-hosted-team/compose.yml +5 -2
- package/examples/04-self-hosted-team/smoke.sh +26 -12
- package/examples/README.md +8 -4
- package/examples/fixtures/README.md +4 -1
- package/examples/fixtures/make-sessions.js +8 -5
- package/memhouse/admin-secret.js +89 -24
- package/memhouse/argv-secrets.js +64 -0
- package/memhouse/channel.js +49 -9
- package/memhouse/codex-plugin.js +194 -0
- package/memhouse/delivery/AGENT-INSTALL-PROMPT.md +2 -1
- package/memhouse/delivery/AGENT-INSTALL.md +22 -11
- package/memhouse/delivery/INVITATION.md.tpl +44 -45
- package/memhouse/delivery/PROMPT.md +53 -5
- package/memhouse/delivery/UPGRADE.md.tpl +1 -1
- package/memhouse/delivery/codex/AGENTS-BLOCK.md +11 -0
- package/memhouse/delivery/codex/SKILL.md +50 -0
- package/memhouse/delivery/kernel-install.md +17 -9
- package/memhouse/delivery/plugin/.claude-plugin/plugin.json +2 -2
- package/memhouse/delivery/plugin/reference/HOUSE.md +168 -41
- package/memhouse/delivery/plugin/skills/access/SKILL.md +13 -5
- package/memhouse/delivery/plugin/skills/admin/SKILL.md +14 -6
- package/memhouse/delivery/plugin/skills/house/SKILL.md +25 -8
- package/memhouse/delivery/plugin/skills/recall/SKILL.md +66 -8
- package/memhouse/delivery/plugin/skills/sql/SKILL.md +66 -7
- package/memhouse/deploy.js +1 -1
- package/memhouse/flags.js +35 -18
- package/memhouse/help.js +37 -0
- package/memhouse/host.js +82 -2
- package/memhouse/house/HOUSE.md +2 -2
- package/memhouse/house/convert-profile.js +638 -0
- package/memhouse/house/convert-v3.js +742 -0
- package/memhouse/house/doctor-v3.js +324 -0
- package/memhouse/house/host-alias.js +85 -0
- package/memhouse/house/house.js +3 -3
- package/memhouse/house/migrate.js +2 -2
- package/memhouse/house/profile-dedupe.js +183 -0
- package/memhouse/house/profile-edges.js +554 -0
- package/memhouse/house/profile-house.js +430 -0
- package/memhouse/house/profile-rollup.js +190 -0
- package/memhouse/house/q-cli.js +863 -0
- package/memhouse/house/queries-v3.js +1013 -0
- package/memhouse/house/reconcile-v3.js +192 -0
- package/memhouse/house/records-to-profile.js +174 -0
- package/memhouse/house/records-to-rows.js +169 -0
- package/memhouse/house/relocate.js +2 -1
- package/memhouse/house/schema-choice.js +121 -0
- package/memhouse/house/schema-profile.js +290 -0
- package/memhouse/house/schema-profile.sql.tpl +398 -0
- package/memhouse/house/schema-v3.js +267 -0
- package/memhouse/house/schema-v3.sql.tpl +213 -0
- package/memhouse/import/claude-ai.js +420 -0
- package/memhouse/import/import.js +372 -0
- package/memhouse/import/pyjson.js +206 -0
- package/memhouse/import/records.js +64 -0
- package/memhouse/import/writer-profile.js +192 -0
- package/memhouse/import/writer-v3.js +240 -0
- package/memhouse/import/zip.js +112 -0
- package/memhouse/invitation.js +102 -0
- package/memhouse/invitefile.js +5 -2
- package/memhouse/plugin-copies.js +176 -0
- package/memhouse/provision.js +7 -2
- package/memhouse/release-hint.js +24 -0
- package/memhouse/resume.js +4 -4
- package/memhouse/server/api-v3.js +632 -0
- package/memhouse/server/dashboard-auth.js +181 -0
- package/memhouse/server/dashboard-port.js +91 -0
- package/memhouse/server/queries.js +30 -0
- package/memhouse/server/server.js +118 -20
- package/memhouse/service.js +1 -1
- package/memhouse/share.js +687 -1
- package/memhouse/shipper/adapter-capture.js +756 -0
- package/memhouse/shipper/capture.js +2571 -0
- package/memhouse/shipper/dedupe-profile.js +237 -0
- package/memhouse/shipper/dedupe-v3.js +1027 -0
- package/memhouse/shipper/forks.js +233 -0
- package/memhouse/shipper/log-adapter.js +219 -0
- package/memhouse/shipper/maintenance.js +49 -0
- package/memhouse/shipper/normalise-run.js +148 -0
- package/memhouse/shipper/passlog.js +68 -5
- package/memhouse/shipper/profile-capture.js +903 -0
- package/memhouse/shipper/ship.js +91 -8
- package/memhouse/shipper/snapshots.js +193 -0
- package/memhouse/shipper/source-roots.js +111 -0
- package/memhouse/version-notice.js +66 -0
- package/package.json +14 -7
- package/public/THIRD_PARTY_NOTICES.txt +2 -2
- package/public/assets/index-CcNnhfM3.js +78 -0
- package/public/index.html +1 -1
- package/ui/package-lock.json +56 -37
- package/ui/package.json +1 -0
- package/ui/src/App.jsx +29 -14
- package/ui/src/components/ChatSidebar.jsx +69 -12
- package/ui/src/lib/api.js +76 -0
- package/ui/src/lib/dashboard-token.js +41 -0
- package/ui/src/main.jsx +22 -5
- package/ui/src/pages/Dashboard.jsx +22 -36
- package/ui/third-party-notices.js +119 -0
- package/ui/vite.config.js +5 -1
- package/docs/releases/v0.18.12.md +0 -59
- package/memhouse/COMPETITION.md +0 -221
- package/memhouse/DESIGN.md +0 -222
- package/memhouse/RESUME-AND-UPDATE.md +0 -182
- package/public/assets/index-CnWzrjF3.js +0 -78
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
// `memhouse doctor` on a schema-3 house (harness-sessions-sql's profile): the house-side checks,
|
|
2
|
+
// transport-free.
|
|
3
|
+
//
|
|
4
|
+
// tables the six conversation tables in <db> and the two capture tables in <db>_archive
|
|
5
|
+
// exist, with exactly the sorting keys schema-profile.js PROFILE_KEYS names
|
|
6
|
+
// columns every column of the template is on the server with its type and, where the
|
|
7
|
+
// template says MATERIALIZED, still materialized (a lost MATERIALIZED search_word
|
|
8
|
+
// is a search that silently finds nothing)
|
|
9
|
+
// identity no row with an empty writer (an async insert skips the stamp)
|
|
10
|
+
// ts stable no message part or call whose ts moved between its versions: sessions-model
|
|
11
|
+
// §1 rule 7 says a record's ts never changes
|
|
12
|
+
// dedupe captured sessions of this host whose converted rows still wait for the dedupe,
|
|
13
|
+
// and deduped ones that keep converted rows the capture lacks (profile-dedupe.js)
|
|
14
|
+
// pricing every model in the house has a price
|
|
15
|
+
// token capture no harness ships assistant messages with zero tokens
|
|
16
|
+
// forks the engine edges (profile-edges.js): forks billed once, the copied tokens counted
|
|
17
|
+
// only in the origin, and the edges not verified (informational)
|
|
18
|
+
// host identity which machines wrote sessions, and whether this one is among them
|
|
19
|
+
//
|
|
20
|
+
// NO WHOLE-TABLE SCANS BY DEFAULT. Everything on `sessions` reads it FINAL (one row per session,
|
|
21
|
+
// small by design). On `session_parts` and `calls` the identity and ts checks read only the last
|
|
22
|
+
// `days` days (`ts >= …`, the minmax index), and `full: true` (`doctor --full`) widens them to the
|
|
23
|
+
// whole tables, which on a big house is the one expensive thing doctor can be asked to do.
|
|
24
|
+
//
|
|
25
|
+
// Every entry point takes a transport `{ exec, q }` (q-cli.js httpTransport) and returns
|
|
26
|
+
// checks: [{ ok, label, hint }], the shape bin/memhouse.js renders.
|
|
27
|
+
|
|
28
|
+
'use strict';
|
|
29
|
+
|
|
30
|
+
const V3 = require('./schema-profile');
|
|
31
|
+
const Q = require('./queries-v3');
|
|
32
|
+
const H = require('./house');
|
|
33
|
+
|
|
34
|
+
const SETTINGS = {};
|
|
35
|
+
|
|
36
|
+
function lit(s) { return `'${String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`; }
|
|
37
|
+
|
|
38
|
+
/** Split a column declaration's type from its DEFAULT / MATERIALIZED / CODEC / … tail (top level only). */
|
|
39
|
+
function splitDecl(decl) {
|
|
40
|
+
const s = String(decl).trim();
|
|
41
|
+
let depth = 0; let inStr = false;
|
|
42
|
+
for (let i = 0; i < s.length; i++) {
|
|
43
|
+
const c = s[i];
|
|
44
|
+
if (inStr) { if (c === '\\') i++; else if (c === "'") inStr = false; continue; }
|
|
45
|
+
if (c === "'") { inStr = true; continue; }
|
|
46
|
+
if (c === '(') depth++;
|
|
47
|
+
else if (c === ')') depth--;
|
|
48
|
+
else if (depth === 0 && /\s/.test(c)) {
|
|
49
|
+
const rest = s.slice(i).trimStart();
|
|
50
|
+
const m = /^(DEFAULT|MATERIALIZED|ALIAS|EPHEMERAL|CODEC|COMMENT|TTL)\b/i.exec(rest);
|
|
51
|
+
if (m) return { type: s.slice(0, i).trim(), kind: ['DEFAULT', 'MATERIALIZED', 'ALIAS', 'EPHEMERAL'].includes(m[1].toUpperCase()) ? m[1].toUpperCase() : (/\b(DEFAULT|MATERIALIZED|ALIAS|EPHEMERAL)\b/i.exec(rest.slice(m[0].length)) || [null, ''])[1].toUpperCase() };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return { type: s, kind: '' };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Split `a T, b U(x, y)` at top-level commas. */
|
|
58
|
+
function topSplit(s) {
|
|
59
|
+
const out = []; let depth = 0; let buf = '';
|
|
60
|
+
for (const c of s) {
|
|
61
|
+
if (c === '(') depth++;
|
|
62
|
+
if (c === ')') depth--;
|
|
63
|
+
if (c === ',' && depth === 0) { out.push(buf.trim()); buf = ''; continue; }
|
|
64
|
+
buf += c;
|
|
65
|
+
}
|
|
66
|
+
if (buf.trim()) out.push(buf.trim());
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The columns the server should report for one table, from the template:
|
|
72
|
+
* [{ name, type, materialized }]. A Nested column becomes its `name.field Array(T)` columns.
|
|
73
|
+
*/
|
|
74
|
+
function expectedColumns(tpl, type) {
|
|
75
|
+
const cols = V3.templateColumns(tpl)[type] || [];
|
|
76
|
+
const out = [];
|
|
77
|
+
for (const c of cols) {
|
|
78
|
+
const nm = /^Nested\s*\(([\s\S]*)\)\s*$/i.exec(c.type.trim());
|
|
79
|
+
if (nm) {
|
|
80
|
+
for (const f of topSplit(nm[1])) {
|
|
81
|
+
const sp = f.indexOf(' ');
|
|
82
|
+
out.push({ name: `${c.name}.${f.slice(0, sp)}`, type: `Array(${f.slice(sp + 1).trim()})`, materialized: false });
|
|
83
|
+
}
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const d = splitDecl(c.type);
|
|
87
|
+
out.push({ name: c.name, type: d.type, materialized: d.kind === 'MATERIALIZED' });
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const norm = (t) => String(t).replace(/\s+/g, ' ').replace(/\(\s+/g, '(').replace(/\s+\)/g, ')').trim();
|
|
93
|
+
|
|
94
|
+
/** Compare the server's system.columns rows for one table against the template. */
|
|
95
|
+
function columnProblems(fq, expected, serverCols) {
|
|
96
|
+
const by = new Map(serverCols.map((r) => [r.name, r]));
|
|
97
|
+
const missing = []; const wrong = [];
|
|
98
|
+
for (const c of expected) {
|
|
99
|
+
const got = by.get(c.name);
|
|
100
|
+
if (!got) { missing.push(`${fq}.${c.name}`); continue; }
|
|
101
|
+
if (norm(got.type) !== norm(c.type)) wrong.push(`${fq}.${c.name} is ${got.type}, template says ${c.type}`);
|
|
102
|
+
else if (c.materialized && got.default_kind !== 'MATERIALIZED') wrong.push(`${fq}.${c.name} lost its MATERIALIZED clause`);
|
|
103
|
+
}
|
|
104
|
+
return { missing, wrong };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The house-side checks.
|
|
109
|
+
* @param {{exec, q}} t
|
|
110
|
+
* @param {{ db, member, full?, days?, hostId?, tpl?, pricing?, user?, url? }} o user/url: who connects, to name a refused credential
|
|
111
|
+
*/
|
|
112
|
+
async function houseChecks(t, { db, member, full = false, days = 14, hostId = null, hostAliases = [], tpl = null, calculateCost = null, user = '', url = '', pricingHint = null } = {}) {
|
|
113
|
+
const checks = [];
|
|
114
|
+
const add = (ok, label, hint, o = {}) => checks.push({ ok, label, hint, ...o });
|
|
115
|
+
// A credential the server refused is named as that, by ClickHouse's code (schema-choice.js), not
|
|
116
|
+
// as tables that could not be listed.
|
|
117
|
+
const SC = require('./schema-choice');
|
|
118
|
+
const listFailed = (what, e) => (SC.isAuthFailure(e) ? `${SC.authFailureText(user, url)} — fix the connection above first` : `could not list ${what}: ${e.message}`);
|
|
119
|
+
const N = V3.profileNames(db, member);
|
|
120
|
+
const rooms = Q.v3Rooms(member, db);
|
|
121
|
+
const rows = (sql, settings) => t.q.rows(sql, { ...SETTINGS, ...(settings || {}) });
|
|
122
|
+
rooms.hostAliases = await require('./host-alias').load((sql) => rows(sql), rooms);
|
|
123
|
+
const template = tpl || V3.readTemplate();
|
|
124
|
+
|
|
125
|
+
// 1. tables and keys
|
|
126
|
+
let present = new Map();
|
|
127
|
+
try {
|
|
128
|
+
present = new Map((await rows(`SELECT database, name, sorting_key FROM system.tables
|
|
129
|
+
WHERE (database = ${lit(N.db)} AND name IN (${V3.CONVERSATION_TYPES.map((x) => lit(`${member}_${x}`)).join(', ')}))
|
|
130
|
+
OR (database = ${lit(N.archive)} AND name IN (${V3.CAPTURE_TYPES.map((x) => lit(`${member}_${x}`)).join(', ')}))`))
|
|
131
|
+
.map((r) => [`${r.database}.${r.name}`, r.sorting_key]));
|
|
132
|
+
} catch (e) { add(false, 'schema-3 tables', listFailed('them', e)); return checks; }
|
|
133
|
+
// The statements that create the missing tables, from the template --ensure-schema applies, and
|
|
134
|
+
// for capture tables their grant: what an admin runs. Never an install as the member, which
|
|
135
|
+
// would configure the admin's machine as that member.
|
|
136
|
+
// In the server's own text-index dialect, as --ensure-schema writes it (house.js textIndexDialectFor).
|
|
137
|
+
const createSql = async (types) => {
|
|
138
|
+
let legacy = false;
|
|
139
|
+
try { legacy = H.textIndexDialectFor(String(((await rows('SELECT version() AS v'))[0] || {}).v || '')) === 'legacy'; } catch { /* the modern grammar */ }
|
|
140
|
+
const stmts = V3.renderStatements(template, { db, member }).filter((s) => types.includes(s.type)).map((s) => `${legacy ? H.legacyTextIndexDialect(s.sql) : s.sql};`);
|
|
141
|
+
const grants = V3.captureGrants(db, member).filter((g) => types.some((x) => V3.CAPTURE_TYPES.includes(x) && g.includes(` ${N[x]} `))).map((g) => `${g};`);
|
|
142
|
+
const dbs = types.some((x) => V3.CAPTURE_TYPES.includes(x)) ? [`CREATE DATABASE IF NOT EXISTS ${N.archive};`] : [];
|
|
143
|
+
return [...dbs, ...stmts, ...grants].join('\n');
|
|
144
|
+
};
|
|
145
|
+
const adminFix = async (types) => `as an admin, run (on servers before 26.x with SETTINGS allow_experimental_full_text_index = 1):\n${await createSql(types)}`;
|
|
146
|
+
|
|
147
|
+
// 1b. a member name that collides with another (bob, bob_smith): the grant <db>.<name>_* of one
|
|
148
|
+
// reaches the other's tables. Seen from here as tables we can list beyond our own names.
|
|
149
|
+
try {
|
|
150
|
+
const names = (await rows(`SELECT name FROM system.tables WHERE database = ${lit(N.db)}`)).map((r) => r.name);
|
|
151
|
+
const collide = require('../share').memberCollisions(member, names);
|
|
152
|
+
add(!collide.length, collide.length ? `member names collide: ${collide.join(', ')}` : 'member name: no other member\'s tables within reach',
|
|
153
|
+
collide.length ? `'${member}' and ${collide.join(', ')}: one name is the other's plus an underscore, so a grant <db>.<name>_* reaches the other's tables, to read and to write — docs/guides/troubleshooting.md, "Two member names that collide"` : undefined);
|
|
154
|
+
} catch (e) { add(false, 'member name', listFailed("the house's tables", e)); }
|
|
155
|
+
// 1c. sessions `memhouse import` wrote on the development layout (<m>_turns): a recapture cannot
|
|
156
|
+
// bring them back, so they are named before anyone moves the member (schema-choice.js).
|
|
157
|
+
try {
|
|
158
|
+
const sc = require('./schema-choice');
|
|
159
|
+
const det = await sc.detectHouseSchema((sql) => rows(sql), N.db, member);
|
|
160
|
+
if (det.devLayout) {
|
|
161
|
+
const notice = sc.importsNotice(det, member);
|
|
162
|
+
add(!notice, notice ? `${det.devImports === null ? 'some' : det.devImports} imported session(s) need a re-import` : `${member}_turns: no imported sessions to carry over`,
|
|
163
|
+
notice || `${member}_turns is the development builds' layout: its admin recaptures or converts '${member}' (docs/guides/upgrade-to-0.20.md)`);
|
|
164
|
+
}
|
|
165
|
+
} catch (e) { add(false, 'imported sessions', `could not check: ${e.message}`); }
|
|
166
|
+
for (const [layer, types, where] of [['conversation', V3.CONVERSATION_TYPES, N.db], ['capture', V3.CAPTURE_TYPES, N.archive]]) {
|
|
167
|
+
const missing = types.filter((x) => !present.has(N[x]));
|
|
168
|
+
const keyBad = types.filter((x) => present.has(N[x])).map((x) => [x, V3.keyProblem(x, present.get(N[x]))]).filter(([, p]) => p);
|
|
169
|
+
add(!missing.length && !keyBad.length,
|
|
170
|
+
missing.length ? `${layer} tables: ${types.length - missing.length}/${types.length} in '${where}' — missing ${missing.map((x) => N[x]).join(', ')}`
|
|
171
|
+
: keyBad.length ? `${layer} tables: wrong sorting key on ${keyBad.map(([x, p]) => `${N[x]} ${p}`).join('; ')}`
|
|
172
|
+
: `${layer} tables: ${types.length}/${types.length} in '${where}' (${types.map((x) => N[x]).join(', ')}), keys correct`,
|
|
173
|
+
missing.length ? (layer === 'capture' ? `the shipper cannot write without them, and a member cannot create them; ${await adminFix(missing)}`
|
|
174
|
+
: `memhouse ship --ensure-schema creates them (the member holds ${N.db}.${member}_*); if it is refused, ${await adminFix(missing)}`)
|
|
175
|
+
: 'a wrong key breaks replacement (§2): the table has to be rebuilt from the template by its owner');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// 2. columns against the template
|
|
179
|
+
try {
|
|
180
|
+
const missing = []; const wrong = []; let seen = 0;
|
|
181
|
+
for (const x of V3.PROFILE_TYPES) {
|
|
182
|
+
if (!present.has(N[x])) continue;
|
|
183
|
+
seen++;
|
|
184
|
+
const [d, tb] = N[x].split('.');
|
|
185
|
+
const cols = await rows(`SELECT name, type, default_kind FROM system.columns WHERE database = ${lit(d)} AND table = ${lit(tb)}`);
|
|
186
|
+
const p = columnProblems(N[x], expectedColumns(template, x), cols);
|
|
187
|
+
missing.push(...p.missing); wrong.push(...p.wrong);
|
|
188
|
+
}
|
|
189
|
+
const bad = missing.length + wrong.length;
|
|
190
|
+
add(bad === 0 && seen === V3.PROFILE_TYPES.length,
|
|
191
|
+
seen !== V3.PROFILE_TYPES.length ? `columns: only ${seen}/${V3.PROFILE_TYPES.length} tables exist, so the template comparison is incomplete`
|
|
192
|
+
: bad === 0 ? 'columns: every schema-3 table matches the template (name, type and kind)'
|
|
193
|
+
: `columns: ${missing.length} missing, ${wrong.length} wrong — ${[...missing, ...wrong].slice(0, 4).join('; ')}${bad > 4 ? ' …' : ''}`,
|
|
194
|
+
'a missing column, or a type retyped in harness-sessions-sql v1: memhouse ship --ensure-schema (the archive tables: an admin, docs/guides/troubleshooting.md); any other changed type, or a lost MATERIALIZED clause, needs the table rebuilt by its owner');
|
|
195
|
+
} catch (e) { add(false, 'columns', `could not compare against the template: ${e.message}`); }
|
|
196
|
+
|
|
197
|
+
const haveConv = V3.CONVERSATION_TYPES.every((x) => present.has(N[x]));
|
|
198
|
+
if (!haveConv) return checks;
|
|
199
|
+
const window = full ? '' : ` AND ts >= now64(3) - toIntervalDay(${Number(days)})`;
|
|
200
|
+
const scope = full ? 'all rows' : `last ${days} days`;
|
|
201
|
+
|
|
202
|
+
// 3. identity stamping
|
|
203
|
+
try {
|
|
204
|
+
const s = (await rows(`SELECT count() AS n, countIf(writer = '') AS blank FROM ${N.sessions} FINAL WHERE is_deleted = 0`))[0] || {};
|
|
205
|
+
const tr = (await rows(`SELECT count() AS n, countIf(writer = '') AS blank FROM ${N.session_parts} WHERE 1${window}`))[0] || {};
|
|
206
|
+
const cr = (await rows(`SELECT count() AS n, countIf(writer = '') AS blank FROM ${N.calls} WHERE 1${window}`))[0] || {};
|
|
207
|
+
const blank = Number(s.blank || 0) + Number(tr.blank || 0) + Number(cr.blank || 0);
|
|
208
|
+
const total = Number(s.n || 0) + Number(tr.n || 0) + Number(cr.n || 0);
|
|
209
|
+
add(blank === 0, total === 0 ? 'identity stamping (no rows yet)'
|
|
210
|
+
: blank === 0 ? `identity stamping: ${s.n} sessions, ${tr.n} part and ${cr.n} call rows (${scope}), all attributed`
|
|
211
|
+
: `identity stamping: ${blank} of ${total} rows have an empty writer (${scope})`,
|
|
212
|
+
'a writer used async_insert=1 — the MATERIALIZED currentUser() stamp does not run during an async flush');
|
|
213
|
+
} catch (e) { add(false, 'identity stamping', e.message); }
|
|
214
|
+
|
|
215
|
+
// 4. ts stable (sessions-model §1 rule 7): a part's or call's versions all carry one ts. The
|
|
216
|
+
// profile keys rows without ts, so a moved ts replaces; it is still an adapter breaking the rule.
|
|
217
|
+
try {
|
|
218
|
+
const dupSql = (table, id) => `SELECT count() AS n, groupArray(3)(concat(harness, ':', session_id, ' ', i)) AS sample FROM (
|
|
219
|
+
SELECT harness, session_id, ${id} AS i FROM ${table} WHERE is_deleted = 0${window}
|
|
220
|
+
GROUP BY harness, host, harness_path, session_id, incarnation, i HAVING uniqExact(ts) > 1)`;
|
|
221
|
+
const dt = (await rows(dupSql(N.session_parts, "concat(message_id, '#', part_id)")))[0] || {};
|
|
222
|
+
const dc = (await rows(dupSql(N.calls, 'call_id')))[0] || {};
|
|
223
|
+
const n = Number(dt.n || 0) + Number(dc.n || 0);
|
|
224
|
+
add(n === 0,
|
|
225
|
+
n === 0 ? `ts stable: no message part or call whose ts moved (${scope})`
|
|
226
|
+
: `ts stable: ${dt.n} message part(s), ${dc.n} call(s) whose ts moved between versions (${scope}) — e.g. ${[...(dt.sample || []), ...(dc.sample || [])].slice(0, 3).join('; ')}`,
|
|
227
|
+
'sessions-model §1 rule 7: a record\'s ts never changes; the adapter that wrote them has an unstable ts');
|
|
228
|
+
} catch (e) { add(false, 'ts stable', e.message); }
|
|
229
|
+
|
|
230
|
+
// 5. the dedupe (profile-dedupe.js): this host's captured sessions whose converted rows still wait
|
|
231
|
+
// for it, and deduped sessions that keep converted rows the capture lacks (their transcript
|
|
232
|
+
// lost them; kept, and said so).
|
|
233
|
+
if (hostId) {
|
|
234
|
+
try {
|
|
235
|
+
const pd = require('./profile-dedupe');
|
|
236
|
+
const pending = await rows(pd.storesWithLegacySQL(N, hostId, { marked: false, aliases: hostAliases }));
|
|
237
|
+
const kept = await rows(`SELECT count() AS n, sum(JSONExtractUInt(state, 'dedupe', 'kept', 'turns') + JSONExtractUInt(state, 'dedupe', 'kept', 'calls')) AS rows
|
|
238
|
+
FROM ${N.session_sources} FINAL WHERE is_deleted = 0 AND host = ${lit(hostId)} AND role = 'legacy' AND NOT JSONExtractBool(state, 'pending')
|
|
239
|
+
AND JSONExtractUInt(state, 'dedupe', 'kept', 'turns') + JSONExtractUInt(state, 'dedupe', 'kept', 'calls') > 0`);
|
|
240
|
+
const k = kept[0] || {};
|
|
241
|
+
add(pending.length === 0,
|
|
242
|
+
(pending.length === 0 ? 'dedupe: no captured session here waits for its converted rows to be matched'
|
|
243
|
+
: `dedupe: ${pending.length} captured session(s) here still hold converted rows not yet matched — e.g. ${pending.slice(0, 3).map((x) => `${x.harness}:${x.session_id}`).join(', ')}`)
|
|
244
|
+
+ (Number(k.n || 0) ? `; ${k.n} deduped session(s) keep ${k.rows} converted row(s) their transcript no longer has` : ''),
|
|
245
|
+
'the capture dedupes a session once it has captured it to the end; one pass (memhouse ship) usually clears it');
|
|
246
|
+
} catch (e) { add(false, 'dedupe', e.message); }
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// 6. pricing and token capture — from sessions FINAL (usage entries per session)
|
|
250
|
+
try {
|
|
251
|
+
const cost = calculateCost || require('../../pricing').calculateCost;
|
|
252
|
+
const models = await t.exec(Q.aggregate(rooms, { source: 'usage', dims: ['model'], metrics: ['messages'], where: { excludeModels: true } }));
|
|
253
|
+
const unpriced = models.filter((m) => cost(m.model, 1e6, 0, 0, 0) === null);
|
|
254
|
+
const total = models.reduce((a, m) => a + Number(m.messages), 0);
|
|
255
|
+
const missed = unpriced.reduce((a, m) => a + Number(m.messages), 0);
|
|
256
|
+
const pct = total ? (missed / total) * 100 : 0;
|
|
257
|
+
const share = pct >= 0.5 ? `${Math.round(pct)}% of messages` : `${missed} message${missed === 1 ? '' : 's'}`;
|
|
258
|
+
// An unpriced model is a gap in the cost figures, not a fault in the house: a warning, never a
|
|
259
|
+
// failure. The caller says whether an update would bring prices (pricingHint); without it, the
|
|
260
|
+
// hint names both cases rather than sending a current install to `memhouse update`.
|
|
261
|
+
if (!unpriced.length) add(true, `pricing: every model in this house has a price (${models.length} models)`);
|
|
262
|
+
else {
|
|
263
|
+
add(true, `pricing: ${unpriced.length} model${unpriced.length > 1 ? 's' : ''} unpriced — ${share} cost nothing in the dashboard (${unpriced.map((m) => m.model).join(', ')})`,
|
|
264
|
+
`${pricingHint || 'prices ship with each release: a newer one may add them (memhouse update), and on the latest one their cost stays unknown until a release does'}; memhouse q spend lists them`,
|
|
265
|
+
{ warn: true });
|
|
266
|
+
}
|
|
267
|
+
const zero = await rows(`SELECT harness, sum(assistant_messages) AS n FROM ${N.sessions} FINAL
|
|
268
|
+
WHERE is_deleted = 0 AND origin = 'ship' GROUP BY harness
|
|
269
|
+
HAVING n > 0 AND sum(input_tokens) + sum(output_tokens) = 0 ORDER BY harness`);
|
|
270
|
+
add(zero.length === 0,
|
|
271
|
+
zero.length === 0 ? 'token capture: every harness shipping here reports usage'
|
|
272
|
+
: `token capture: ${zero.map((r) => `${r.harness} (${r.n} messages)`).join(', ')} ship ZERO tokens — their cost shows as $0, not as unknown`,
|
|
273
|
+
'that adapter does not extract usage; the messages are stored, the numbers are not');
|
|
274
|
+
} catch (e) { add(false, 'pricing', e.message); }
|
|
275
|
+
|
|
276
|
+
// 6b. removed content (plan v4 M4): what every read hides, kept as removed versions. Informational.
|
|
277
|
+
try {
|
|
278
|
+
const rc = await rows(`SELECT toString(harness) AS h, uniqExact(session_id, harness_path, incarnation) AS s, uniqExact(session_id, harness_path, incarnation, message_id) AS m
|
|
279
|
+
FROM ${N.session_parts} FINAL WHERE removed = 1 AND is_deleted = 0${window} GROUP BY h ORDER BY m DESC`);
|
|
280
|
+
const m = rc.reduce((a, r) => a + Number(r.m), 0); const s = rc.reduce((a, r) => a + Number(r.s), 0);
|
|
281
|
+
add(true, m === 0 ? `removed content: none (${scope})`
|
|
282
|
+
: `removed content: ${m} message(s) in ${s} session(s) (${scope}) — ${rc.map((r) => `${r.h} ${r.m}`).join(', ')}; kept as removed versions, searchable with \`memhouse q removed\`, never counted`);
|
|
283
|
+
} catch (e) { add(false, 'removed content', e.message); }
|
|
284
|
+
|
|
285
|
+
// 6c. forks (plan v4 M4): the engine edges, and the copied usage they keep out of the forks'
|
|
286
|
+
// totals (billed once, in the origin). Informational.
|
|
287
|
+
try {
|
|
288
|
+
const edges = `FROM ${N.session_links} FINAL WHERE owner = 'engine' AND removed = 0 AND kind IN ('fork', 'rewind_copy')`;
|
|
289
|
+
// A lineage fork (verified null: Claude Code's and Codex's copies) is neither verified nor failed.
|
|
290
|
+
const fk = (await rows(`SELECT count() AS n, countIf(ifNull(verified, false)) AS ok, countIf(verified = false) AS failed, countIf(verified IS NULL) AS lineage,
|
|
291
|
+
countIf(ifNull(verified, false) AND length(r_copied_usage) > 0) AS billed, sum(length(r_copied_usage)) AS out ${edges}`))[0] || {};
|
|
292
|
+
// In the house split the rollup bills (profile-rollup.js: cached input counted once, reasoning in output).
|
|
293
|
+
// Every listed row is summed: a per_record response's earlier records too (only converted Claude Code
|
|
294
|
+
// rows are per_record, and the edges compare captured rows first, so they are not listed in practice).
|
|
295
|
+
const tk = Number(fk.out || 0) ? (await rows(`SELECT sum(toUInt64(greatest(if(ifNull(input_includes_cache, false), toInt64(ifNull(input, 0)) - toInt64(ifNull(cache_read, 0)) - toInt64(ifNull(cache_write, 0)), toInt64(ifNull(input, 0))), 0))
|
|
296
|
+
+ ifNull(tool_prompt, 0) + ifNull(output, 0) + if(ifNull(output_includes_reasoning, true), 0, ifNull(reasoning, 0)) + ifNull(cache_read, 0) + ifNull(cache_write, 0)) AS t
|
|
297
|
+
FROM ${N.session_usage} FINAL WHERE removed = 0 AND (host, toString(harness), toString(harness_path), session_id, incarnation, usage_id) IN (
|
|
298
|
+
SELECT host, toString(harness), toString(harness_path), session_id, incarnation, arrayJoin(r_copied_usage) ${edges} AND ifNull(verified, false))`))[0] || {} : {};
|
|
299
|
+
const n = Number(fk.n || 0); const unverified = Number(fk.failed || 0); const lineage = Number(fk.lineage || 0);
|
|
300
|
+
add(true, n === 0 ? 'forks: no fork link in this house'
|
|
301
|
+
: `forks: ${n} fork link(s), ${fk.billed} billed once (${Number(tk.t || 0)} copied tokens counted only in the origin)`
|
|
302
|
+
+ (unverified ? `; ${unverified} not verified, counted in both sessions` : '')
|
|
303
|
+
+ (lineage ? `; ${lineage} of Claude Code or Codex, lineage only` : ''));
|
|
304
|
+
} catch (e) { add(false, 'forks', e.message); }
|
|
305
|
+
|
|
306
|
+
// 7. host identity
|
|
307
|
+
try {
|
|
308
|
+
// Folded as status and `q machines` fold them: a 0.18 id this member's machine recorded as
|
|
309
|
+
// its own reads as that machine (host-alias.js).
|
|
310
|
+
const hosts = require('./q-cli').machinesFolded(await t.exec(Q.machines(rooms)), rooms);
|
|
311
|
+
// host is sessions-model/host/v0 (a 32-hex machine fingerprint); the label is for people.
|
|
312
|
+
const name = (h) => (h.label ? `${h.label} (${h.host.slice(0, 8)}…)` : h.host);
|
|
313
|
+
const mine = hostId ? hosts.find((h) => h.host === hostId) : null;
|
|
314
|
+
const before = hosts.filter((h) => hostAliases.includes(h.host));
|
|
315
|
+
const others = hosts.filter((h) => h.host !== hostId && !hostAliases.includes(h.host));
|
|
316
|
+
add(true, hosts.length === 0 ? `host identity ${hostId || '?'} (nothing shipped from here yet)`
|
|
317
|
+
: `host identity ${hostId || '?'} — ${mine ? `${mine.sessions} sessions from this machine${mine.aliases ? ` (its pre-0.20 id ${mine.aliases.join(', ')} folded in)` : ''}` : 'no sessions from this machine yet'}`
|
|
318
|
+
+ (before.length ? `, ${before.reduce((a, h) => a + Number(h.sessions), 0)} under its pre-0.20 id ${before.map((h) => h.host).join(', ')}` : '')
|
|
319
|
+
+ (others.length ? `, ${others.length} other host${others.length > 1 ? 's' : ''}: ${others.map((h) => `${name(h)} (${h.sessions})`).join(', ')}` : ''));
|
|
320
|
+
} catch { /* the reads above already reported anything that would break this */ }
|
|
321
|
+
return checks;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
module.exports = { houseChecks, expectedColumns, columnProblems, splitDecl, topSplit };
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// One machine, two ids: the 0.18 one and the sessions-model one.
|
|
2
|
+
//
|
|
3
|
+
// 0.18 named a machine `<short>-<8 hex>` (host.js identity()); 0.20 names it by
|
|
4
|
+
// sessions-model/host/v0, a 32-hex digest of the OS machine id. Rows converted from the 0.18 rooms
|
|
5
|
+
// keep the 0.18 id, captured rows carry the new one, so after a move one machine read as two
|
|
6
|
+
// writers. Neither id can be computed from the other — the 8 hex are a different, truncated digest
|
|
7
|
+
// — so the conversion, which sees only the house, cannot map them. The machine itself knows both.
|
|
8
|
+
// It records the pair in its member's own meta room (`host_alias:<0.18 id>` = `<new id>`) when it
|
|
9
|
+
// switches and on each capture pass, and readers fold the 0.18 id's rows into the machine's at
|
|
10
|
+
// query time. Rows are never rewritten. An alias is read from a member's own meta and applied to
|
|
11
|
+
// that member's own rows only, so no member can fold another's machines.
|
|
12
|
+
|
|
13
|
+
const KEY = 'host_alias:';
|
|
14
|
+
const OLD_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; // the 0.18 `<short>-<fp8>` (and anything of its shape)
|
|
15
|
+
const NEW_RE = /^(r-)?[0-9a-f]{32}$/; // sessions-model/host/v0, or its random fallback
|
|
16
|
+
|
|
17
|
+
const lit = (s) => `'${String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
|
18
|
+
const valid = (old, neu) => typeof old === 'string' && typeof neu === 'string' && old !== neu && OLD_RE.test(old) && NEW_RE.test(neu);
|
|
19
|
+
|
|
20
|
+
/** The recorded aliases of one member, latest per 0.18 id. `meta` is that member's meta room. */
|
|
21
|
+
function readSQL(meta) {
|
|
22
|
+
return `SELECT substring(key, ${KEY.length + 1}) AS old, argMax(value, updated_at) AS new
|
|
23
|
+
FROM ${meta} WHERE startsWith(key, ${lit(KEY)}) GROUP BY key`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** { old: new } from rows of readSQL, dropping anything that is not of the two ids' shapes. */
|
|
27
|
+
function parse(rows) {
|
|
28
|
+
const map = {};
|
|
29
|
+
for (const r of rows || []) if (valid(r.old, r.new)) map[r.old] = r.new;
|
|
30
|
+
return map;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A member's aliases, or {} when its meta cannot be read (a housemate's share does not grant it). */
|
|
34
|
+
async function load(rows, rooms) {
|
|
35
|
+
if (!rooms || !rooms.meta) return {};
|
|
36
|
+
try { return parse(await rows(readSQL(rooms.meta))); } catch { return {}; }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Record `old` -> `neu` in the member's meta when rows exist under `old` and the pair is not
|
|
41
|
+
* recorded yet. `ph` answers rows(sql) and run(sql) (profile-house.js, or a transport).
|
|
42
|
+
* @returns {Promise<boolean>} whether it wrote
|
|
43
|
+
*/
|
|
44
|
+
async function record(ph, { meta, sessions }, old, neu) {
|
|
45
|
+
if (!valid(old, neu)) return false;
|
|
46
|
+
const have = parse(await ph.rows(readSQL(meta)));
|
|
47
|
+
if (have[old] === neu) return false;
|
|
48
|
+
const under = await ph.rows(`SELECT count() AS n FROM ${sessions} WHERE host = ${lit(old)} LIMIT 1`);
|
|
49
|
+
if (!under.length || !Number(under[0].n)) return false;
|
|
50
|
+
await ph.run(`INSERT INTO ${meta} (key, value) VALUES (${lit(KEY + old)}, ${lit(neu)})`);
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The id a row's host reads as. */
|
|
55
|
+
const fold = (host, aliases) => (aliases && Object.prototype.hasOwnProperty.call(aliases, host) ? aliases[host] : host);
|
|
56
|
+
|
|
57
|
+
/** Every id that reads as `host`: itself and the 0.18 ids aliased to it. */
|
|
58
|
+
function idsOf(host, aliases) {
|
|
59
|
+
return [host, ...Object.keys(aliases || {}).filter((o) => aliases[o] === host && o !== host)];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Merge per-host rows whose hosts fold to one machine. `how` names the columns: sum, min, max,
|
|
64
|
+
* union (arrays, sorted), any (first non-empty). Row order is kept by first appearance.
|
|
65
|
+
*/
|
|
66
|
+
function foldRows(rows, aliases, how = {}) {
|
|
67
|
+
if (!aliases || !Object.keys(aliases).length) return rows;
|
|
68
|
+
const key = how.key || 'host';
|
|
69
|
+
const out = new Map();
|
|
70
|
+
for (const r of rows) {
|
|
71
|
+
const k = fold(r[key], aliases);
|
|
72
|
+
const cur = out.get(k);
|
|
73
|
+
if (!cur) { out.set(k, { ...r, [key]: k, ...(k !== r[key] ? { aliases: [r[key]] } : {}) }); continue; }
|
|
74
|
+
if (k !== r[key]) cur.aliases = [...(cur.aliases || []), r[key]];
|
|
75
|
+
for (const c of how.sum || []) cur[c] = Number(cur[c] || 0) + Number(r[c] || 0);
|
|
76
|
+
for (const c of how.max || []) cur[c] = Math.max(Number(cur[c] || 0), Number(r[c] || 0));
|
|
77
|
+
for (const c of how.min || []) cur[c] = [cur[c], r[c]].filter((v) => v !== null && v !== undefined && v !== '').map(Number).reduce((a, b) => Math.min(a, b), Infinity);
|
|
78
|
+
for (const c of how.union || []) cur[c] = [...new Set([...(cur[c] || []), ...(r[c] || [])])].sort();
|
|
79
|
+
for (const c of how.any || []) if (!cur[c] && r[c]) cur[c] = r[c];
|
|
80
|
+
}
|
|
81
|
+
for (const r of out.values()) for (const c of how.min || []) if (r[c] === Infinity) r[c] = null;
|
|
82
|
+
return [...out.values()];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = { KEY, readSQL, parse, load, record, fold, idsOf, foldRows, valid };
|
package/memhouse/house/house.js
CHANGED
|
@@ -80,7 +80,7 @@ const MIGRATIONS = {
|
|
|
80
80
|
* mixed-fleet story: every release from 0.10.0 on REFUSES a house whose recorded schema
|
|
81
81
|
* it does not support, in both directions — a house too new says "update memhouse", a
|
|
82
82
|
* house too old says "memhouse migrate". The backward half cannot be code: releases
|
|
83
|
-
* already on the registry read none of this, so for them the enforcement is the
|
|
83
|
+
* already on the registry read none of this, so for them the enforcement is the user's
|
|
84
84
|
* GRANTs (revoke the mutation privileges after migrating, and a pre-0.10 shipper's
|
|
85
85
|
* delete fails loudly instead of destroying retained parses). `memhouse migrate` prints
|
|
86
86
|
* that advice.
|
|
@@ -184,8 +184,8 @@ function installCommand() {
|
|
|
184
184
|
// user names. Refuse what would need quoting rather than quote it: a name a person cannot
|
|
185
185
|
// type bare into their own SQL is a name they will get wrong. `default` is deliberately
|
|
186
186
|
// ALLOWED as a house name — a stock container's default database is a real place to keep
|
|
187
|
-
// a house, and the
|
|
188
|
-
// are not a database at all are refused.
|
|
187
|
+
// a house, and the person running memhouse may pick user 'alice' with house 'default'.
|
|
188
|
+
// Only the names that are not a database at all are refused.
|
|
189
189
|
const RESERVED_DBS = new Set(['system', 'information_schema']);
|
|
190
190
|
function assertUsableName(name, what = 'name') {
|
|
191
191
|
if (typeof name !== 'string' || !/^[A-Za-z][A-Za-z0-9_]*$/.test(name)) {
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
// measured losses and are not per-migration choices:
|
|
24
24
|
//
|
|
25
25
|
// NOTHING IS DELETED. A replaced object is renamed to `<name>_pre_<suffix>` and left
|
|
26
|
-
// for the
|
|
26
|
+
// for the user to drop. A leftover `__migrating` table from an interrupted run is
|
|
27
27
|
// never cleared automatically — it may hold the only copy of mid-flight rows.
|
|
28
28
|
//
|
|
29
29
|
// PROVENANCE IS COPIED, NOT RESTAMPED. `user_id` is MATERIALIZED currentUser(), so a
|
|
@@ -193,7 +193,7 @@ async function rebuildRoom(q, ctx, { type, name, keepSuffix }, ui) {
|
|
|
193
193
|
* ADD COLUMN anything the template declares that the live table lacks — for objects a
|
|
194
194
|
* migration does NOT rebuild. `sessions` keeps its key across schema 2 but still gains
|
|
195
195
|
* `epoch`; without this, the very next ship opens with a "fields are being DISCARDED"
|
|
196
|
-
* warning and sends the
|
|
196
|
+
* warning and sends the user to a second command.
|
|
197
197
|
*/
|
|
198
198
|
async function healColumns(q, ctx, { type, name }, ui) {
|
|
199
199
|
const { templateColumns } = require('../shipper/ship');
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// The dedupe writer on harness-sessions-sql's profile (step B4): one session's converted rows (adapter
|
|
2
|
+
// 'legacy') matched against its adapter-derived rows by dedupe-profile.js, the matched ones
|
|
3
|
+
// tombstoned, the session marked deduped. The profile's form of capture.js dedupeSession.
|
|
4
|
+
//
|
|
5
|
+
// THE WRITER (each line is a decision)
|
|
6
|
+
// stores the captured session's store, and the same session id in the unknown store ('': a
|
|
7
|
+
// 0.18 row without a transcript path), under this host and its aliases (the old host
|
|
8
|
+
// id where the conversion kept it); the captured session's incarnation
|
|
9
|
+
// reads the legacy session_parts, calls and session_usage in EVERY version (no FINAL: a
|
|
10
|
+
// tombstoned row keeps the run it matched, and the tokens a Codex re-report proof reads),
|
|
11
|
+
// the store's live adapter-derived parts
|
|
12
|
+
// nothing a session with no legacy rows is { none }; one with no captured parts is { skipped }
|
|
13
|
+
// and gets no marker: matched against nothing, every legacy row would be kept for good
|
|
14
|
+
// tombstones server-side, by key: the live row (FINAL) written again at rev + 1, is_deleted 1. In
|
|
15
|
+
// order: a pending marker, parts, calls, then their usage, the sessions refresh, then
|
|
16
|
+
// the marker. A pass stopped anywhere between them leaves the pending marker (or live
|
|
17
|
+
// legacy rows), so the sweep finds the session and the next pass finishes it; its plan
|
|
18
|
+
// decides the same way (a dead message's leftover usage goes with it; a message still
|
|
19
|
+
// live keeps the usage its proof reads). Two passes racing on one session converge
|
|
20
|
+
// marker a session_sources row in the captured store: source_key 'legacy:rows', role
|
|
21
|
+
// 'legacy', mode 'snapshot', generation 0, progress 1 + the legacy ship rows dead
|
|
22
|
+
// (at least the previous marker's + 1, so a repair outranks it under FINAL), state
|
|
23
|
+
// { v: 1, dedupe: <report> }. The pending marker is the same row with `pending: true`,
|
|
24
|
+
// at the previous marker's progress + 1 (1 with none), written once before the first
|
|
25
|
+
// tombstone; the marker then outranks it
|
|
26
|
+
// sessions every store's sessions row of the session written again through the shared rollup
|
|
27
|
+
// (profile-rollup.js refreshSessionsSQL), before the marker; a store with no live row
|
|
28
|
+
// left is retired. A store that keeps legacy rows the capture lacks stays a session of
|
|
29
|
+
// its own: the profile counts a session per key, and harness-sessions-sql does not merge two
|
|
30
|
+
// hosts' copies
|
|
31
|
+
// repair { redo: true } writes nothing when the plan tombstones nothing new and the marker
|
|
32
|
+
// already reports the same kept counts
|
|
33
|
+
|
|
34
|
+
'use strict';
|
|
35
|
+
|
|
36
|
+
const dp = require('../shipper/dedupe-profile');
|
|
37
|
+
const dedupe = require('../shipper/dedupe-v3');
|
|
38
|
+
const { lit } = require('./convert-v3');
|
|
39
|
+
const rollup = require('./profile-rollup');
|
|
40
|
+
|
|
41
|
+
const STORE = '(host, harness, harness_path, session_id, incarnation)';
|
|
42
|
+
const MARKER_KEY = dedupe.LEGACY_FILE_KEY;
|
|
43
|
+
const MARKER_ROLE = dedupe.LEGACY_ROLE;
|
|
44
|
+
|
|
45
|
+
const tuple = (vals) => `(${vals.map(lit).join(', ')})`;
|
|
46
|
+
const storeTuple = (r) => tuple([r.host, r.harness, r.harness_path, r.session_id, r.incarnation || '']);
|
|
47
|
+
/** The stores a session's legacy rows may sit in (header). */
|
|
48
|
+
function legacyStores(ref, aliases = []) {
|
|
49
|
+
const out = [];
|
|
50
|
+
for (const host of [ref.host, ...aliases]) for (const hp of [...new Set([ref.harness_path, ''])]) out.push({ ...ref, host, harness_path: hp });
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
const inStores = (stores) => `${STORE} IN (${stores.map(storeTuple).join(', ')})`;
|
|
54
|
+
|
|
55
|
+
const PART_READ = `host, toString(harness) AS harness, toString(harness_path) AS harness_path, session_id, incarnation, message_id, part_id, part_index,
|
|
56
|
+
order_key, agent_id, toString(role) AS role, toString(ts) AS ts, toString(served_model) AS served_model, toString(requested_model) AS requested_model,
|
|
57
|
+
response_id, toString(kind) AS kind, text, toString(tool_name) AS tool_name, args, output, call_id, error_message, extra, toString(rev) AS rev, is_deleted, removed`;
|
|
58
|
+
const CALL_READ = `host, toString(harness) AS harness, toString(harness_path) AS harness_path, session_id, incarnation, call_id, message_id, agent_id,
|
|
59
|
+
toString(ts) AS ts, toString(tool_name) AS tool_name, args, toString(rev) AS rev, is_deleted, removed`;
|
|
60
|
+
|
|
61
|
+
// A converted row the conversion wrote removed (content only an older 0.18 copy held) is history: the
|
|
62
|
+
// dedupe never matches, keeps or retires it.
|
|
63
|
+
function legacyPartsSQL(P, stores) { return `SELECT ${PART_READ} FROM ${P.session_parts} WHERE ${inStores(stores)} AND adapter = 'legacy' AND removed = 0`; }
|
|
64
|
+
function legacyCallsSQL(P, stores) { return `SELECT ${CALL_READ} FROM ${P.calls} WHERE ${inStores(stores)} AND adapter = 'legacy' AND removed = 0`; }
|
|
65
|
+
function legacyUsageSQL(P, stores) {
|
|
66
|
+
return `SELECT host, toString(harness_path) AS harness_path, usage_id, toString(anchor_kind) AS anchor_kind, anchor_id, input, output,
|
|
67
|
+
cache_read, cache_write, input_includes_cache, toString(rev) AS rev, is_deleted
|
|
68
|
+
FROM ${P.session_usage} WHERE ${inStores(stores)} AND adapter = 'legacy' AND removed = 0`;
|
|
69
|
+
}
|
|
70
|
+
function capturedPartsSQL(P, ref) {
|
|
71
|
+
return `SELECT ${PART_READ} FROM ${P.session_parts} FINAL WHERE ${inStores([ref])} AND adapter != 'legacy' AND removed = 0`;
|
|
72
|
+
}
|
|
73
|
+
function capturedUsageSQL(P, ref) {
|
|
74
|
+
return `SELECT usage_id, toString(anchor_kind) AS anchor_kind, anchor_id, toString(ts) AS ts, toString(model) AS model, toString(input) AS input, toString(output) AS output,
|
|
75
|
+
toString(cache_read) AS cache_read, toString(cache_write) AS cache_write, input_includes_cache
|
|
76
|
+
FROM ${P.session_usage} FINAL WHERE ${inStores([ref])} AND adapter != 'legacy' AND removed = 0`;
|
|
77
|
+
}
|
|
78
|
+
function markerSQL(P, ref) {
|
|
79
|
+
return `SELECT toString(progress) AS progress, state FROM ${P.session_sources} FINAL
|
|
80
|
+
WHERE ${inStores([ref])} AND source_key = ${lit(MARKER_KEY)} AND role = ${lit(MARKER_ROLE)} AND is_deleted = 0`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The live legacy rows of `table` at these keys, written again at rev + 1 as tombstones. `id`: the key's own columns. */
|
|
84
|
+
function tombstoneSQL(P, table, stores, id, keys) {
|
|
85
|
+
// The filter sits in a subquery: ClickHouse resolves a SELECT alias in WHERE (profile-house.js retire).
|
|
86
|
+
return `INSERT INTO ${P[table]} SELECT * REPLACE (rev + 1 AS rev, toUInt8(1) AS is_deleted, now64(3) AS ingested_at)
|
|
87
|
+
FROM (SELECT * FROM ${P[table]} FINAL
|
|
88
|
+
WHERE ${inStores(stores)} AND adapter = 'legacy' AND (host, harness_path, ${id.join(', ')}) IN (${keys.map((k) => tuple([k.host, k.harness_path, ...id.map((c) => k[c])])).join(', ')}))`;
|
|
89
|
+
}
|
|
90
|
+
const TOMBSTONED = [['session_parts', ['message_id', 'part_id']], ['calls', ['call_id']], ['session_usage', ['usage_id']]];
|
|
91
|
+
|
|
92
|
+
/** The marker row (header); `pending`: the one written before the first tombstone. */
|
|
93
|
+
function markerRow(ref, plan, prev, { pending = false } = {}) {
|
|
94
|
+
let progress = pending ? 1 : plan.dead + 1;
|
|
95
|
+
if (prev && Number(prev.progress) >= progress) progress = Number(prev.progress) + 1;
|
|
96
|
+
return {
|
|
97
|
+
host: ref.host, harness: ref.harness, harness_path: ref.harness_path, session_id: ref.session_id, incarnation: ref.incarnation || '',
|
|
98
|
+
source_key: MARKER_KEY, mode: 'snapshot', position: '', generation: 0, progress,
|
|
99
|
+
state: JSON.stringify({ v: 1, ...(pending ? { pending: true } : {}), dedupe: plan.report }),
|
|
100
|
+
state_ver: 1, adapter: 'legacy', adapter_ver: '0', role: MARKER_ROLE, is_deleted: 0,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
const keptOf = (m) => { try { const k = JSON.parse(m.state).dedupe.kept; return `${k.turns}/${k.calls}`; } catch { return null; } };
|
|
104
|
+
const isPending = (m) => { try { return JSON.parse(m.state).pending === true; } catch { return false; } };
|
|
105
|
+
const insertMarker = (db, P, m) => db.run(`INSERT INTO ${P.session_sources} FORMAT JSONEachRow\n${JSON.stringify(m)}`);
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Dedupe one captured session on the profile.
|
|
109
|
+
* @param {{rows: (sql: string) => Promise<object[]>, run: (sql: string) => Promise<unknown>}} db
|
|
110
|
+
* @param {object} P profileNames(db, member)
|
|
111
|
+
* @param {object} ref the captured session's store: host, harness, harness_path, session_id, incarnation
|
|
112
|
+
* @param {object} [o]
|
|
113
|
+
* @param {string[]} [o.aliases] this host's other ids
|
|
114
|
+
* @param {boolean} [o.redo] a repair re-plan
|
|
115
|
+
* @param {(ref: object) => Promise<Array>} [o.repeatsOf] Codex re-report proofs, read only when the plan needs them
|
|
116
|
+
* @returns {Promise<{none?: true, skipped?: true, unchanged?: true, plan?: object, marker?: object}>}
|
|
117
|
+
*/
|
|
118
|
+
async function dedupeProfileSession(db, P, ref, o = {}) {
|
|
119
|
+
const stores = legacyStores(ref, o.aliases || []);
|
|
120
|
+
const [legacyParts, legacyCalls] = [await db.rows(legacyPartsSQL(P, stores)), await db.rows(legacyCallsSQL(P, stores))];
|
|
121
|
+
if (!legacyParts.length && !legacyCalls.length) return { none: true };
|
|
122
|
+
const capturedParts = await db.rows(capturedPartsSQL(P, ref));
|
|
123
|
+
if (!capturedParts.length) return { skipped: true };
|
|
124
|
+
const legacyUsage = await db.rows(legacyUsageSQL(P, stores));
|
|
125
|
+
const capturedUsage = await db.rows(capturedUsageSQL(P, ref));
|
|
126
|
+
const input = { harness: ref.harness, session_id: ref.session_id, legacyParts, legacyCalls, legacyUsage, capturedParts, capturedUsage };
|
|
127
|
+
let plan = dp.planProfileDedupe(input);
|
|
128
|
+
if (plan.report.rereport_unproven > 0 && o.repeatsOf) {
|
|
129
|
+
const repeats = await o.repeatsOf(ref);
|
|
130
|
+
if (repeats.length) plan = dp.planProfileDedupe({ ...input, repeats });
|
|
131
|
+
}
|
|
132
|
+
const prev = (await db.rows(markerSQL(P, ref)))[0];
|
|
133
|
+
const T = plan.tombstones;
|
|
134
|
+
if (o.redo && !T.session_parts.length && !T.calls.length && !T.session_usage.length
|
|
135
|
+
&& (!prev || (!isPending(prev) && keptOf(prev) === `${plan.report.kept.turns}/${plan.report.kept.calls}`))) return { plan, unchanged: true };
|
|
136
|
+
let last = prev;
|
|
137
|
+
if (!prev || !isPending(prev)) { last = markerRow(ref, plan, prev, { pending: true }); await insertMarker(db, P, last); }
|
|
138
|
+
for (const [table, id] of TOMBSTONED) {
|
|
139
|
+
const keys = T[table];
|
|
140
|
+
for (let i = 0; i < keys.length; i += 500) await db.run(tombstoneSQL(P, table, stores, id, keys.slice(i, i + 500)));
|
|
141
|
+
}
|
|
142
|
+
const keys = new Map([[storeTuple(ref), ref]]);
|
|
143
|
+
for (const r of [...legacyParts, ...legacyCalls]) keys.set(storeTuple(r), r);
|
|
144
|
+
await db.run(rollup.refreshSessionsSQL(P, `${STORE} IN (${[...keys.keys()].join(', ')})`));
|
|
145
|
+
const marker = markerRow(ref, plan, last);
|
|
146
|
+
await insertMarker(db, P, marker);
|
|
147
|
+
return { plan, marker };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The captured stores of this host whose session has live legacy rows in a store dedupe reads.
|
|
152
|
+
* `marked`: with a marker (repair candidates: live legacy ship rows, or a marker reporting kept rows),
|
|
153
|
+
* else without one (the sweep). A capture's store is one with a session_sources row of a source.
|
|
154
|
+
* `stores`: only these ({harness, harness_path}), so a LIMIT is not filled by stores a pass skips.
|
|
155
|
+
*/
|
|
156
|
+
function storesWithLegacySQL(P, host, { marked, aliases = [], stores = null }) {
|
|
157
|
+
const hosts = [host, ...aliases].map(lit).join(', ');
|
|
158
|
+
const prefix = lit(marked ? 'legacy:ship:' : 'legacy:');
|
|
159
|
+
const legacy = (t, id) => `SELECT DISTINCT toString(harness) AS h, toString(harness_path) AS p, session_id AS s, incarnation AS i FROM ${t} FINAL
|
|
160
|
+
WHERE host IN (${hosts}) AND adapter = 'legacy' AND removed = 0 AND startsWith(${id}, ${prefix})`;
|
|
161
|
+
// The sweep also takes live legacy usage alone: a pass of an older build stopped after the parts and calls.
|
|
162
|
+
const usage = `SELECT DISTINCT toString(harness) AS h, toString(harness_path) AS p, session_id AS s, incarnation AS i FROM ${P.session_usage} FINAL
|
|
163
|
+
WHERE host IN (${hosts}) AND adapter = 'legacy' AND removed = 0`;
|
|
164
|
+
const L = `${legacy(P.session_parts, 'message_id')} UNION DISTINCT ${legacy(P.calls, 'call_id')}${marked ? '' : ` UNION DISTINCT ${usage}`}`;
|
|
165
|
+
const src = (role) => `SELECT DISTINCT toString(harness) AS h, toString(harness_path) AS p, session_id AS s, incarnation AS i,
|
|
166
|
+
JSONExtractUInt(state, 'dedupe', 'kept', 'turns') + JSONExtractUInt(state, 'dedupe', 'kept', 'calls') AS k
|
|
167
|
+
FROM ${P.session_sources} FINAL WHERE is_deleted = 0 AND host = ${lit(host)} AND ${role}`;
|
|
168
|
+
const isMarker = `source_key = ${lit(MARKER_KEY)} AND role = ${lit(MARKER_ROLE)}`;
|
|
169
|
+
// A pending marker is a dedupe that stopped part way: the sweep's, until the marker replaces it.
|
|
170
|
+
const markers = (pending) => `SELECT toString(harness), toString(harness_path), session_id, incarnation FROM ${P.session_sources} FINAL
|
|
171
|
+
WHERE is_deleted = 0 AND host = ${lit(host)} AND ${isMarker} AND ${pending ? '' : 'NOT '}JSONExtractBool(state, 'pending')`;
|
|
172
|
+
return `SELECT h AS harness, p AS harness_path, s AS session_id, i AS incarnation FROM (
|
|
173
|
+
${src(marked ? isMarker : `role NOT IN ('side', ${lit(MARKER_ROLE)}, 'inherited')`)})
|
|
174
|
+
WHERE ((h, p, s, i) IN (${L}) OR (h, '', s, i) IN (${L})${marked ? ' OR k > 0' : ` OR (h, p, s, i) IN (${markers(true)})`})
|
|
175
|
+
${marked ? '' : `AND (h, p, s, i) NOT IN (${markers(false)})`}
|
|
176
|
+
${stores ? `AND (h, p) IN (${stores.length ? stores.map((x) => tuple([x.harness, x.harness_path])).join(', ') : "('', '')"})` : ''}
|
|
177
|
+
ORDER BY h, p, s`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
module.exports = {
|
|
181
|
+
dedupeProfileSession, storesWithLegacySQL, legacyStores, legacyPartsSQL, legacyCallsSQL, legacyUsageSQL, capturedPartsSQL, capturedUsageSQL, markerSQL,
|
|
182
|
+
tombstoneSQL, markerRow, MARKER_KEY, MARKER_ROLE,
|
|
183
|
+
};
|