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,638 @@
|
|
|
1
|
+
// memhouse 0.18 → harness-sessions-sql's profile (step B3, the conversion; harness-sessions-sql §4.8). Server-side INSERT … SELECT, like convert-v3.js, whose readers of the
|
|
2
|
+
// legacy rooms it reuses (current parse, one row per key, the session and harness split, the
|
|
3
|
+
// data-folder path). Every converted row has adapter 'legacy', adapter_ver 0, rev 0.
|
|
4
|
+
//
|
|
5
|
+
// THE MAPPING (each line is a decision)
|
|
6
|
+
// copies a converted session is (harness, session id, host group): one host group for a
|
|
7
|
+
// globally unique id (GLOBAL_ID_HARNESSES with a UUID or ses_ id) or for one 0.18
|
|
8
|
+
// stream that reads as one machine renamed (oneMachineSQL), else one per host, hosts a reported
|
|
9
|
+
// pair names as one machine together. Its 0.18 copies (a prefixed and a bare id,
|
|
10
|
+
// two users) become one session: the current copy (the member's, then the latest
|
|
11
|
+
// message, the most rows, the newest shipping) converts live; another copy's rows
|
|
12
|
+
// the current one holds again (role, text, usage and model, each matched once) are
|
|
13
|
+
// duplicates and not converted; the rest left the source and convert as removed
|
|
14
|
+
// versions under ids qualified by their copy (~<hash>), so no key collides and
|
|
15
|
+
// nothing is counted twice. A call takes its message's decision; one with no
|
|
16
|
+
// message row converts with its copy (live in the current one)
|
|
17
|
+
// store one per converted session: its newest row's host, its newest non-empty path
|
|
18
|
+
// host host/v0 where the machine reported its pair (meta `host_pair:<host/v0>` =
|
|
19
|
+
// old id) and no other machine reported the same old id; the old id otherwise
|
|
20
|
+
// session_parts one row per 0.18 message: part_id '0' (a usage-only '[assistant activity]'
|
|
21
|
+
// row: part_id '', kind 'none'); kind from the 0.18 markers in the spec's
|
|
22
|
+
// values (reasoning, tool_call, tool_result, text); role 'tool' for a result
|
|
23
|
+
// text text and reasoning rows: the stored text, as is
|
|
24
|
+
// tool_call tool_name from the first marker; the stored text kept in extra.legacy_text
|
|
25
|
+
// tool_result output: the stored text; output_len its length
|
|
26
|
+
// order_key the 0.18 seq, zero-padded so the strings sort
|
|
27
|
+
// served_model the 0.18 model; status 'complete', state 'current'
|
|
28
|
+
// response_id a run id for Claude Code per_record usage (below), else ''
|
|
29
|
+
// content_sha the TSV-line sha (harness-sessions-sql §4.8) over the spec columns in DDL order, without
|
|
30
|
+
// content_sha, message_content_sha, cmp_sha, rev, is_deleted, removed,
|
|
31
|
+
// ingested_at, writer and memhouse's own columns
|
|
32
|
+
// message_content_sha = content_sha; cmp_sha = the TSV-line sha over role, kind, text, tool_name,
|
|
33
|
+
// args, is_error, output (DDL order)
|
|
34
|
+
// first_seen the 0.18 row's ingested_at (memhouse's column)
|
|
35
|
+
// extra memhouse's column: legacy_text (tool_call), agent (a sub-agent's 0.18 id,
|
|
36
|
+
// type and description, for the agents rollup)
|
|
37
|
+
// session_usage one row per 0.18 message with a non-zero counter, as 0.18 stored it:
|
|
38
|
+
// input/output/cache_read/cache_write; reasoning, tool_prompt and cost null;
|
|
39
|
+
// flags claude-code false/true, codex false/true, gemini-cli true/false, other
|
|
40
|
+
// harnesses null; granularity per_record (claude-code), per_turn (codex),
|
|
41
|
+
// per_message (others)
|
|
42
|
+
// anchor Claude Code: anchor 'response' with a minted run id
|
|
43
|
+
// 'legacy-run:<origin>:<epoch>:<first seq>': consecutive rows (by seq) of one
|
|
44
|
+
// session, parse and agent with an identical (input, cache_read, cache_write,
|
|
45
|
+
// model) tuple (sessions-model v1's per_record). Other harnesses: anchor 'message'
|
|
46
|
+
// usage_id sessions-model's form, <anchor> '#' n: 'response:<run id>#<n>', n counting
|
|
47
|
+
// from 1 in seq order within the run, so the run's last record has the
|
|
48
|
+
// greatest n (the record a reader counts output from, #43); 'message:<id>#1'
|
|
49
|
+
// calls one row per 0.18 tool call (memhouse's convenience table): agent and model
|
|
50
|
+
// from its message; is_error, result_ts and output_len null (0.18 kept none);
|
|
51
|
+
// content_sha the session_parts row of its message (the part that holds it),
|
|
52
|
+
// or for a call whose message has no row, a sha of its own key and content
|
|
53
|
+
// sessions one row per session key of the converted parts (run last: after the parts,
|
|
54
|
+
// usage and calls of its bucket, which its rollup reads), and one per 0.18 session
|
|
55
|
+
// with no message row at all (only its sessions room row: a title, a path), grouped
|
|
56
|
+
// by host as a session's messages are (no stream to read a renamed machine from):
|
|
57
|
+
// fingerprint [], no totals, started and ended from its 0.18 times:
|
|
58
|
+
// fingerprint the ordered cmp_shas of the session's legacy rows (by order_key, message_id)
|
|
59
|
+
// from 0.18 title (name), cwd (folder), created_at, updated_at, mode; memhouse's folder,
|
|
60
|
+
// project, git_branch, origin: the session's own row (its host group's; for a
|
|
61
|
+
// global id or one machine renamed, a row of a host whose messages it holds),
|
|
62
|
+
// the member's newest where several users shipped one. With no row of its own:
|
|
63
|
+
// no title or times, folder and project from its first message
|
|
64
|
+
// fixed title_set_by 'unknown', state 'active', reuse 'undetectable' (0.18 kept no
|
|
65
|
+
// birth marker; incarnation ''), ts_source 'legacy' (the time 0.18 stored: a
|
|
66
|
+
// record's own for Claude Code, an interpolation for Codex), aliases [],
|
|
67
|
+
// setup null, pinned null
|
|
68
|
+
// content_sha the TSV-line sha over the spec columns in DDL order, as session_parts'
|
|
69
|
+
// totals memhouse's derived columns (counts, usage, rollups, started/ended): the
|
|
70
|
+
// shared rollup (profile-rollup.js) over the converted parts, usage and calls
|
|
71
|
+
|
|
72
|
+
const conv = require('./convert-v3');
|
|
73
|
+
const { profileNames } = require('./schema-profile');
|
|
74
|
+
const rollup = require('./profile-rollup');
|
|
75
|
+
const { v3Names } = require('./schema-v3');
|
|
76
|
+
|
|
77
|
+
const { lit, harnessSQL, sessionSQL, kindSQL, currentParseSQL, bucketSQL } = conv;
|
|
78
|
+
|
|
79
|
+
/** Settings that pin the TSV bytes behind content_sha (harness-sessions-sql §4.8). */
|
|
80
|
+
const SHA_SETTINGS = { format_tsv_null_representation: '\\N', bool_true_representation: 'true', bool_false_representation: 'false', session_timezone: 'UTC' };
|
|
81
|
+
|
|
82
|
+
const FLAGS = { 'claude-code': ['false', 'true'], codex: ['false', 'true'], 'gemini-cli': ['true', 'false'] };
|
|
83
|
+
const GRANULARITY = { 'claude-code': 'per_record', codex: 'per_turn' };
|
|
84
|
+
const flagSQL = (h, i) => `multiIf(${Object.entries(FLAGS).map(([k, v]) => `${h} = ${lit(k)}, CAST(${v[i]} AS Nullable(Bool))`).join(', ')}, CAST(NULL AS Nullable(Bool)))`;
|
|
85
|
+
const granularitySQL = (h) => `multiIf(${Object.entries(GRANULARITY).map(([k, v]) => `${h} = ${lit(k)}, ${lit(v)}`).join(', ')}, 'per_message')`;
|
|
86
|
+
const shaOf = (cols) => `lower(hex(SHA256(trimRight(formatRow('TabSeparated', ${cols.join(', ')}), '\\n'))))`;
|
|
87
|
+
|
|
88
|
+
/** The session_parts spec columns in DDL order; the line behind content_sha leaves the marked ones out. */
|
|
89
|
+
const PART_COLUMNS = ['host', 'harness', 'harness_path', 'session_id', 'incarnation', 'message_id', 'part_id', 'part_index', 'parent_id', 'order_key',
|
|
90
|
+
'agent_id', 'role', 'source', 'to_user', 'to_model', 'status', 'state', 'on_active_branch', 'ts', 'requested_model', 'served_model', 'effort',
|
|
91
|
+
'response_id', 'message_aliases', 'cmp_sha', 'message_content_sha', 'kind', 'text', 'call_id', 'tool_name', 'args', 'server', 'is_error',
|
|
92
|
+
'result_status', 'output', 'output_ref', 'output_ref_sha256', 'output_ref_bytes', 'output_len', 'truncated', 'exit_code', 'media_type',
|
|
93
|
+
'inline_b64', 'ref', 'bytes_len', 'opaque', 'signature_len', 'replaces_form', 'replaces_value', 'error_kind', 'error_message', 'http_status',
|
|
94
|
+
'level', 'native_type', 'part_source', 'part_state', 'content_sha', 'adapter', 'adapter_ver', 'model_version'];
|
|
95
|
+
const NOT_IN_LINE = new Set(['content_sha', 'message_content_sha', 'cmp_sha', 'rev', 'is_deleted', 'removed', 'ingested_at', 'writer']);
|
|
96
|
+
const PART_LINE = PART_COLUMNS.filter((c) => !NOT_IN_LINE.has(c));
|
|
97
|
+
const CMP_LINE = ['role', 'kind', 'text', 'tool_name', 'args', 'is_error', 'output'];
|
|
98
|
+
const USAGE_COLUMNS = ['host', 'harness', 'harness_path', 'session_id', 'incarnation', 'usage_id', 'anchor_kind', 'anchor_id', 'input', 'output', 'cache_read',
|
|
99
|
+
'cache_write', 'reasoning', 'tool_prompt', 'cost', 'input_includes_cache', 'output_includes_reasoning', 'granularity', 'model', 'content_sha', 'adapter',
|
|
100
|
+
'adapter_ver', 'model_version'];
|
|
101
|
+
const USAGE_LINE = USAGE_COLUMNS.filter((c) => !NOT_IN_LINE.has(c));
|
|
102
|
+
const SESSION_COLUMNS = ['host', 'host_label', 'harness', 'harness_path', 'session_id', 'incarnation', 'engine', 'client', 'harness_version', 'title',
|
|
103
|
+
'title_set_by', 'cwd', 'last_cwd', 'created_at', 'updated_at', 'state', 'pinned', 'mode', 'kind', 'reuse', 'ts_source', 'aliases', 'fingerprint',
|
|
104
|
+
'setup', 'content_sha', 'adapter', 'adapter_ver', 'model_version'];
|
|
105
|
+
const SESSION_LINE = SESSION_COLUMNS.filter((c) => !NOT_IN_LINE.has(c));
|
|
106
|
+
const CALL_COLUMNS = ['host', 'harness', 'harness_path', 'session_id', 'incarnation', 'call_id', 'message_id', 'agent_id', 'ts', 'first_seen',
|
|
107
|
+
'tool_name', 'args', 'result_ts', 'is_error', 'output_len', 'model', 'folder', 'project', 'content_sha', 'adapter', 'adapter_ver'];
|
|
108
|
+
|
|
109
|
+
/** The old-id → host/v0 pairs machines reported, unambiguous ones only (a clone's conflict maps nothing).
|
|
110
|
+
* Joined LEFT without join_use_nulls, a row with no pair reads v0 = '', so the callers test for ''. */
|
|
111
|
+
function hostPairsSQL(P) {
|
|
112
|
+
return `SELECT value AS old_host, any(substring(key, ${'host_pair:'.length + 1})) AS v0
|
|
113
|
+
FROM ${P.meta} FINAL WHERE startsWith(key, 'host_pair:') GROUP BY value HAVING uniqExact(substring(key, ${'host_pair:'.length + 1})) = 1`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* A 0.18 row's host group, the one rule every reader of the 0.18 rooms shares (the copies, the
|
|
118
|
+
* message-less sessions, the 0.18 metadata): '' for a globally unique id or one machine renamed,
|
|
119
|
+
* else the row's host (its host/v0 where a pair says so). `pairV0` is the joined hostPairsSQL v0.
|
|
120
|
+
*/
|
|
121
|
+
function groupSQL(N, P, n, b, h, s, host, pairV0) {
|
|
122
|
+
return `if(${globalIdSQL(h, s)} OR (${h}, ${s}) IN (${oneMachineSQL(N, P, n, b)}), '', ${hostGroupSQL(h, s, host, pairV0)})`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The same group without the one-machine reading, which comes from message streams alone: what a
|
|
127
|
+
* 0.18 sessions row with no message rows of its own is grouped by — '' for a globally unique id,
|
|
128
|
+
* else its host (host/v0 where paired). A sessions row on a machine that shipped no message of the
|
|
129
|
+
* session is that machine's session, not merged into another's.
|
|
130
|
+
*/
|
|
131
|
+
function hostGroupSQL(h, s, host, pairV0) {
|
|
132
|
+
return `if(${globalIdSQL(h, s)}, '', if(ifNull(${pairV0}, '') != '', ${pairV0}, ${host}))`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Harnesses whose 0.18 session ids are globally unique (a UUID, OpenCode's ses_ ids), when the id
|
|
136
|
+
// itself has that form: the same id under two host ids is one session, read by one machine under the
|
|
137
|
+
// ids memhouse gave it over time. Every other harness (goose, zed, kiro, devin, antigravity,
|
|
138
|
+
// copilot-jetbrains, codebuff, commandcode, gsd and any not named here) may use short, sequential or
|
|
139
|
+
// per-machine ids, so its rows under two hosts are one session only when they read as one machine
|
|
140
|
+
// renamed (oneMachineSQL) or the hosts are known aliases (a reported host pair); otherwise one per host.
|
|
141
|
+
const GLOBAL_ID_HARNESSES = ['claude-code', 'claude', 'codex', 'gemini-cli', 'cursor', 'cursor-agent', 'vscode', 'copilot-cli', 'opencode'];
|
|
142
|
+
const globalIdSQL = (h, s) => `(${h} IN (${GLOBAL_ID_HARNESSES.map(lit).join(', ')})
|
|
143
|
+
AND (match(${s}, '[0-9a-fA-F]{8}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{12}') OR match(${s}, '^ses_[0-9A-Za-z]{16,}$')))`;
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The sessions of a bucket whose rows under several hosts read as one machine renamed: one 0.18
|
|
147
|
+
* stream (one 0.18 id, one user, one origin, one epoch) in which the hosts hold separate runs of seqs,
|
|
148
|
+
* each run shipped after the one before it, as a machine that kept shipping under a new id does, and
|
|
149
|
+
* whose hosts no reported pairs name as two machines. Two machines that shared a short id both started
|
|
150
|
+
* at the first seq, and 0.18 keyed a row without its host, so the later one wrote over the earlier
|
|
151
|
+
* one's first rows: their runs interleave, or the run with the lower seqs was shipped later. Rows in
|
|
152
|
+
* two streams (an import beside a shipped copy, two users) carry no such order between them, and two
|
|
153
|
+
* 0.18 ids are copies: kept apart per host for such a harness.
|
|
154
|
+
*/
|
|
155
|
+
function oneMachineSQL(N, P, n, b) {
|
|
156
|
+
const L = N.legacy;
|
|
157
|
+
const ranges = `SELECT ${harnessSQL('x')} AS kh, ${sessionSQL('x')} AS ks, x.session_id AS kl, x.user_id AS ku, toString(x.origin) AS ko, x.epoch AS ke,
|
|
158
|
+
toString(x.host) AS kx, min(x.seq) AS ka, max(x.seq) AS kz, min(x.ingested_at) AS ki, max(x.ingested_at) AS kj
|
|
159
|
+
FROM ${L.messages} AS x FINAL
|
|
160
|
+
WHERE ${currentParseSQL('x', L.messages)} AND ${bucketSQL(harnessSQL('x'), sessionSQL('x'), n, b)}
|
|
161
|
+
GROUP BY kh, ks, kl, ku, ko, ke, kx`;
|
|
162
|
+
const paired = `SELECT r.*, ifNull(kp.v0, '') AS kv FROM (${ranges}) AS r LEFT JOIN (${hostPairsSQL(P)}) AS kp ON kp.old_host = r.kx`;
|
|
163
|
+
const streams = `SELECT kh, ks, arraySort((r) -> r.1, groupArray((ka, kz, ki, kj))) AS rs,
|
|
164
|
+
arrayExists((r, i) -> i > 1 AND (r.1 <= rs[i - 1].2 OR r.3 < rs[i - 1].4), rs, arrayEnumerate(rs)) AS crossed,
|
|
165
|
+
uniqExactIf(kv, kv != '') AS machines
|
|
166
|
+
FROM (${paired}) GROUP BY kh, ks, kl, ku, ko, ke`;
|
|
167
|
+
return `SELECT kh, ks FROM (${streams}) GROUP BY kh, ks HAVING count() = 1 AND max(crossed) = 0 AND max(machines) <= 1`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Every 0.18 row of a bucket with its converted session and the copy decision (header: copies):
|
|
172
|
+
* g the session's host group: '' for a globally unique id or one machine renamed
|
|
173
|
+
* (oneMachineSQL), else the host (its host/v0 where a pair says so); a converted
|
|
174
|
+
* session is (h, s, g)
|
|
175
|
+
* m_copy the 0.18 stream the row came from: its 0.18 id and user
|
|
176
|
+
* cur_copy the converted session's current copy: the member's own first, then the latest
|
|
177
|
+
* message, the most rows, the newest shipping
|
|
178
|
+
* m_keep a row of the current copy, or a row of another copy the current one does not hold
|
|
179
|
+
* (the same role and text, each row of the current copy matched once)
|
|
180
|
+
* m_removed a kept row of another copy: content that left the source, a removed version
|
|
181
|
+
* m_mid its message id, qualified (~<copy>) for another copy's rows, so no key collides
|
|
182
|
+
* z_old_host, z_path the session's store: its newest row's host, its newest non-empty path
|
|
183
|
+
*/
|
|
184
|
+
function copiesSQL(N, P, n, b) {
|
|
185
|
+
const L = N.legacy;
|
|
186
|
+
const t = `if(startsWith(m.text, '[subagent] '), substring(m.text, 12), m.text)`;
|
|
187
|
+
const rows = `SELECT ${harnessSQL('m')} AS h, ${sessionSQL('m')} AS s, m.session_id AS l_session, toString(m.host) AS old_host,
|
|
188
|
+
${groupSQL(N, P, n, b, 'h', 's', 'old_host', 'hp0.v0')} AS g,
|
|
189
|
+
concat(m.session_id, char(0), m.user_id) AS m_copy,
|
|
190
|
+
toString(m.origin) AS m_origin, m.epoch AS m_epoch, m.seq AS m_seq, m.ts AS m_ts, toString(m.role) AS m_role, toString(m.model) AS m_model,
|
|
191
|
+
m.input_tokens AS m_in, m.output_tokens AS m_out, m.cache_read_tokens AS m_cr, m.cache_write_tokens AS m_cw,
|
|
192
|
+
m.text AS m_text, ${t} AS m_text_bare, m.folder AS m_folder, m.project AS m_project, m.user_id AS m_user, m.ingested_at AS m_ingested,
|
|
193
|
+
JSONExtractString(toJSONString(m.extra), 'agent', 'id') AS m_agent, JSONExtractRaw(toJSONString(m.extra), 'agent') AS m_agent_json,
|
|
194
|
+
ifNull(r.l_path, '') AS m_path,
|
|
195
|
+
cityHash64(toString(m.role), m.text, m.input_tokens, m.output_tokens, m.cache_read_tokens, m.cache_write_tokens, toString(m.model)) AS m_th
|
|
196
|
+
FROM ${L.messages} AS m FINAL
|
|
197
|
+
LEFT JOIN (${legacyPathsSQL(N)}) AS r ON r.l_session = m.session_id
|
|
198
|
+
LEFT JOIN (${hostPairsSQL(P)}) AS hp0 ON hp0.old_host = toString(m.host)
|
|
199
|
+
-- Every user's copy enters: the member's is chosen current below (cur_copy), and another user's
|
|
200
|
+
-- rows of the same session become removed versions or duplicates, never silently dropped.
|
|
201
|
+
WHERE ${currentParseSQL('m', L.messages)} AND ${bucketSQL(harnessSQL('m'), sessionSQL('m'), n, b)}`;
|
|
202
|
+
const stats = `SELECT *,
|
|
203
|
+
max(ifNull(m_ts, toDateTime64(0, 3, 'UTC'))) OVER (PARTITION BY h, s, g, m_copy) AS c_ts, count() OVER (PARTITION BY h, s, g, m_copy) AS c_n,
|
|
204
|
+
max(m_ingested) OVER (PARTITION BY h, s, g, m_copy) AS c_ing,
|
|
205
|
+
argMax(old_host, (m_ingested, m_seq)) OVER (PARTITION BY h, s, g) AS z_old_host,
|
|
206
|
+
argMax(m_path, (m_path != '', m_ingested, m_seq)) OVER (PARTITION BY h, s, g) AS z_path
|
|
207
|
+
FROM (${rows})`;
|
|
208
|
+
const current = `SELECT *, argMax(m_copy, (m_user = ${lit(N.member)}, c_ts, c_n, c_ing, m_copy)) OVER (PARTITION BY h, s, g) AS cur_copy FROM (${stats})`;
|
|
209
|
+
const dup = `SELECT *, m_copy = cur_copy AS m_cur,
|
|
210
|
+
row_number() OVER (PARTITION BY h, s, g, m_copy, m_role, m_th ORDER BY m_origin, m_epoch, m_seq) AS m_k,
|
|
211
|
+
countIf(m_copy = cur_copy) OVER (PARTITION BY h, s, g, m_role, m_th) AS m_c
|
|
212
|
+
FROM (${current})`;
|
|
213
|
+
return `SELECT *, m_cur OR m_k > m_c AS m_keep, toUInt8(NOT m_cur) AS m_removed,
|
|
214
|
+
if(m_cur, '', concat('~', lower(hex(cityHash64(m_copy))))) AS m_tag,
|
|
215
|
+
concat('legacy:', m_origin, ':', toString(m_epoch), ':', toString(m_seq), m_tag) AS m_mid
|
|
216
|
+
FROM (${dup})`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The converted rows of one bucket, every column cast to its DDL type: the inner query of the
|
|
221
|
+
* session_parts and session_usage inserts. Only the rows the copy decision keeps.
|
|
222
|
+
*/
|
|
223
|
+
function convertedMessagesSQL(N, P, n, b) {
|
|
224
|
+
return `SELECT *,
|
|
225
|
+
${kindSQL('m_role', 'm_text', 'h')} AS k0,
|
|
226
|
+
multiIf(k0 = 'thinking', 'reasoning', k0 = 'usage', 'none', k0) AS kind,
|
|
227
|
+
if(k0 = 'tool_result', 'tool', m_role) AS role,
|
|
228
|
+
lagInFrame(tuple(m_in, m_cr, m_cw, m_model)) OVER w AS prev_tuple,
|
|
229
|
+
row_number() OVER w AS rn
|
|
230
|
+
-- m_turn: the user rows so far in the copy's stream. A user turn ends a run: two responses with the
|
|
231
|
+
-- same input and cache numbers on either side of a prompt are two responses, not one re-reported.
|
|
232
|
+
-- Counted over the copy's whole stream, before the rows the current copy also holds are left out.
|
|
233
|
+
FROM (SELECT * FROM (SELECT *, countIf(m_role = 'user') OVER (PARTITION BY h, s, g, m_copy, m_origin, m_epoch, m_agent ORDER BY m_seq
|
|
234
|
+
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS m_turn
|
|
235
|
+
FROM (${copiesSQL(N, P, n, b)}))
|
|
236
|
+
WHERE m_keep)
|
|
237
|
+
WINDOW w AS (PARTITION BY h, s, g, m_copy, m_origin, m_epoch, m_agent, m_turn, (m_role = 'assistant' AND (m_in + m_out + m_cr + m_cw) > 0) ORDER BY m_seq ROWS BETWEEN 1 PRECEDING AND CURRENT ROW)`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** The run id of a Claude Code per_record usage row: the seq that started its run (qualified like its copy's message ids). */
|
|
241
|
+
function runsSQL(N, P, n, b) {
|
|
242
|
+
return `SELECT *, row_number() OVER (PARTITION BY h, s, g, m_copy, m_origin, m_epoch, m_agent, has_usage, run_id ORDER BY m_seq) AS run_n
|
|
243
|
+
FROM (SELECT *, if(h = 'claude-code' AND has_usage,
|
|
244
|
+
concat('legacy-run:', m_origin, ':', toString(m_epoch), ':', toString(max(start_seq) OVER (PARTITION BY h, s, g, m_copy, m_origin, m_epoch, m_agent, m_turn, has_usage ORDER BY m_seq ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)), m_tag),
|
|
245
|
+
'') AS run_id
|
|
246
|
+
FROM (SELECT *, (m_role = 'assistant' AND (m_in + m_out + m_cr + m_cw) > 0) AS has_usage,
|
|
247
|
+
if(rn = 1 OR prev_tuple != tuple(m_in, m_cr, m_cw, m_model), m_seq, 0) AS start_seq
|
|
248
|
+
FROM (${convertedMessagesSQL(N, P, n, b)})))`;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** session_parts rows of one bucket. */
|
|
252
|
+
function partsSelectSQL(N, P, n, b) {
|
|
253
|
+
const typed = `SELECT
|
|
254
|
+
CAST(if(ifNull(hp.v0, '') != '', hp.v0, c.z_old_host) AS String) AS host, CAST(c.h AS String) AS harness, CAST(c.z_path AS String) AS harness_path,
|
|
255
|
+
CAST(c.s AS String) AS session_id, '' AS incarnation,
|
|
256
|
+
c.m_mid AS message_id,
|
|
257
|
+
if(c.kind = 'none', '', '0') AS part_id, toUInt32(0) AS part_index, '' AS parent_id, leftPad(toString(c.m_seq), 12, '0') AS order_key,
|
|
258
|
+
c.m_agent AS agent_id, c.role AS role, '' AS source, CAST(NULL AS Nullable(Bool)) AS to_user, CAST(NULL AS Nullable(Bool)) AS to_model,
|
|
259
|
+
'complete' AS status, 'current' AS state, CAST(NULL AS Nullable(Bool)) AS on_active_branch, CAST(c.m_ts AS Nullable(DateTime64(3, 'UTC'))) AS ts,
|
|
260
|
+
'' AS requested_model, c.m_model AS served_model, '' AS effort, c.run_id AS response_id,
|
|
261
|
+
CAST([] AS Array(Tuple(id String, kind String))) AS message_aliases,
|
|
262
|
+
c.kind AS kind,
|
|
263
|
+
if(c.kind IN ('text', 'reasoning'), c.m_text, '') AS text, '' AS call_id,
|
|
264
|
+
if(c.kind = 'tool_call', extract(c.m_text_bare, '^\\\\[tool-call: ([^(\\\\]]+)'), '') AS tool_name, '' AS args, '' AS server,
|
|
265
|
+
CAST(NULL AS Nullable(Bool)) AS is_error, '' AS result_status,
|
|
266
|
+
if(c.kind = 'tool_result', c.m_text, '') AS output, '' AS output_ref, '' AS output_ref_sha256, CAST(NULL AS Nullable(UInt64)) AS output_ref_bytes,
|
|
267
|
+
CAST(if(c.kind = 'tool_result', toUInt64(lengthUTF8(c.m_text)), NULL) AS Nullable(UInt64)) AS output_len,
|
|
268
|
+
CAST(NULL AS Nullable(Bool)) AS truncated, CAST(NULL AS Nullable(Int32)) AS exit_code, '' AS media_type, '' AS inline_b64, '' AS ref,
|
|
269
|
+
CAST(NULL AS Nullable(UInt64)) AS bytes_len, CAST(NULL AS Nullable(Bool)) AS opaque, CAST(NULL AS Nullable(UInt32)) AS signature_len,
|
|
270
|
+
'' AS replaces_form, '' AS replaces_value, '' AS error_kind, '' AS error_message, CAST(NULL AS Nullable(UInt16)) AS http_status, '' AS level,
|
|
271
|
+
'' AS native_type, '' AS part_source, '' AS part_state, 'legacy' AS adapter, '0' AS adapter_ver, 'sessions-model/1' AS model_version,
|
|
272
|
+
c.m_ingested AS first_seen, c.m_folder AS folder, c.m_project AS project, toUInt32(lengthUTF8(c.m_text)) AS text_len, c.m_origin AS origin,
|
|
273
|
+
concat('{', arrayStringConcat(arrayFilter(x -> x != '', [
|
|
274
|
+
if(c.kind = 'tool_call', concat('"legacy_text":', toJSONString(c.m_text)), ''),
|
|
275
|
+
if(c.m_agent != '', concat('"agent":', c.m_agent_json), '')]), ','), '}') AS extra,
|
|
276
|
+
c.m_removed AS removed
|
|
277
|
+
FROM (${runsSQL(N, P, n, b)}) AS c
|
|
278
|
+
LEFT JOIN (${hostPairsSQL(P)}) AS hp ON hp.old_host = c.z_old_host`;
|
|
279
|
+
return `SELECT *, ${shaOf(PART_LINE.map((x) => `x.${x}`))} AS content_sha, content_sha AS message_content_sha, ${shaOf(CMP_LINE.map((x) => `x.${x}`))} AS cmp_sha,
|
|
280
|
+
toUInt64(0) AS rev, toUInt8(0) AS is_deleted
|
|
281
|
+
FROM (${typed}) AS x`;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** session_usage rows of one bucket: the messages with a non-zero counter. */
|
|
285
|
+
function usageSelectSQL(N, P, n, b) {
|
|
286
|
+
const typed = `SELECT
|
|
287
|
+
CAST(if(ifNull(hp.v0, '') != '', hp.v0, c.z_old_host) AS String) AS host, CAST(c.h AS String) AS harness, CAST(c.z_path AS String) AS harness_path,
|
|
288
|
+
CAST(c.s AS String) AS session_id, '' AS incarnation,
|
|
289
|
+
if(c.run_id != '', concat('response:', c.run_id, '#', toString(c.run_n)), concat('message:', c.m_mid, '#1')) AS usage_id,
|
|
290
|
+
if(c.run_id != '', 'response', 'message') AS anchor_kind,
|
|
291
|
+
if(c.run_id != '', c.run_id, c.m_mid) AS anchor_id,
|
|
292
|
+
CAST(c.m_in AS Nullable(UInt64)) AS input, CAST(c.m_out AS Nullable(UInt64)) AS output, CAST(c.m_cr AS Nullable(UInt64)) AS cache_read,
|
|
293
|
+
CAST(c.m_cw AS Nullable(UInt64)) AS cache_write, CAST(NULL AS Nullable(UInt64)) AS reasoning, CAST(NULL AS Nullable(UInt64)) AS tool_prompt,
|
|
294
|
+
CAST(NULL AS Nullable(Decimal(18, 9))) AS cost, ${flagSQL('c.h', 0)} AS input_includes_cache, ${flagSQL('c.h', 1)} AS output_includes_reasoning,
|
|
295
|
+
${granularitySQL('c.h')} AS granularity, c.m_model AS model, 'legacy' AS adapter, '0' AS adapter_ver, 'sessions-model/1' AS model_version,
|
|
296
|
+
CAST(c.m_ts AS Nullable(DateTime64(3, 'UTC'))) AS ts, c.m_origin AS origin, c.m_removed AS removed
|
|
297
|
+
FROM (${runsSQL(N, P, n, b)}) AS c
|
|
298
|
+
LEFT JOIN (${hostPairsSQL(P)}) AS hp ON hp.old_host = c.z_old_host
|
|
299
|
+
WHERE c.has_usage OR (c.m_in + c.m_out + c.m_cr + c.m_cw) > 0`;
|
|
300
|
+
return `SELECT *, ${shaOf(USAGE_LINE.map((x) => `x.${x}`))} AS content_sha, toUInt64(0) AS rev, toUInt8(0) AS is_deleted FROM (${typed}) AS x`;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** The converted parts' session keys in one bucket, with their fingerprints. */
|
|
304
|
+
function sessionKeysSQL(P, n, b) {
|
|
305
|
+
return `SELECT host, harness, harness_path, session_id, incarnation,
|
|
306
|
+
arrayMap((x) -> x.3, arraySort(groupArray((order_key, message_id, cmp_sha)))) AS fp
|
|
307
|
+
FROM ${P.session_parts} FINAL
|
|
308
|
+
WHERE adapter = 'legacy' AND removed = 0 AND ${bucketSQL('harness', 'session_id', n, b)}
|
|
309
|
+
GROUP BY host, harness, harness_path, session_id, incarnation`;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* The (harness, session, host group) of a bucket that hold message rows: a session has messages in its
|
|
314
|
+
* own host group, not merely under its id. Another machine's messages of a short id leave this one's
|
|
315
|
+
* message-less session to convert on its own, and its 0.18 row out of the sessions that have messages.
|
|
316
|
+
*/
|
|
317
|
+
function withMessagesSQL(N, P, n, b) {
|
|
318
|
+
const L = N.legacy;
|
|
319
|
+
return `SELECT DISTINCT mh, ms, ${hostGroupSQL('mh', 'ms', 'mx', 'hpm.v0')} AS mg
|
|
320
|
+
FROM (SELECT ${harnessSQL('m')} AS mh, ${sessionSQL('m')} AS ms, toString(m.host) AS mx FROM ${L.messages} AS m
|
|
321
|
+
WHERE ${bucketSQL(harnessSQL('m'), sessionSQL('m'), n, b)}) AS mm
|
|
322
|
+
LEFT JOIN (${hostPairsSQL(P)}) AS hpm ON hpm.old_host = mm.mx`;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* The 0.18 sessions of one bucket with no message row at all, only their sessions room row (a title, a
|
|
327
|
+
* path): one key per (harness, session id, host group), the groups copiesSQL makes (one for a globally
|
|
328
|
+
* unique id, else one per host, a reported pair's hosts together). The key's host is its newest row's
|
|
329
|
+
* (host/v0 where paired), its path the newest non-empty one; e_ing the newest row's shipping time.
|
|
330
|
+
*/
|
|
331
|
+
function emptySessionKeysSQL(N, P, n, b) {
|
|
332
|
+
const withMessages = withMessagesSQL(N, P, n, b);
|
|
333
|
+
const groups = `SELECT z.v_harness AS e_harness, z.v_session AS e_session,
|
|
334
|
+
${hostGroupSQL('z.v_harness', 'z.v_session', 'z.l_host', 'hp0.v0')} AS e_g,
|
|
335
|
+
argMax(z.l_host, z.l_ing) AS e_host, argMax(z.l_path, (z.l_path != '', z.l_ing)) AS e_path, max(z.l_ing) AS e_ing
|
|
336
|
+
FROM (${conv.legacySessionsSQL(N, n, b)}) AS z
|
|
337
|
+
LEFT JOIN (${hostPairsSQL(P)}) AS hp0 ON hp0.old_host = z.l_host
|
|
338
|
+
WHERE (z.v_harness, z.v_session, ${hostGroupSQL('z.v_harness', 'z.v_session', 'z.l_host', 'hp0.v0')}) NOT IN (${withMessages})
|
|
339
|
+
GROUP BY e_harness, e_session, e_g`;
|
|
340
|
+
return `SELECT CAST(if(ifNull(hp.v0, '') != '', hp.v0, e.e_host) AS String) AS host, CAST(e.e_harness AS String) AS harness,
|
|
341
|
+
CAST(e.e_path AS String) AS harness_path, CAST(e.e_session AS String) AS session_id, '' AS incarnation, e.e_ing AS e_ing
|
|
342
|
+
FROM (${groups}) AS e
|
|
343
|
+
LEFT JOIN (${hostPairsSQL(P)}) AS hp ON hp.old_host = e.e_host`;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* sessions rows of one bucket: one per session key of the converted parts, and one per 0.18 session with
|
|
348
|
+
* no message row (emptySessionKeysSQL), its start and end its 0.18 times (run after the parts, usage and
|
|
349
|
+
* calls inserts).
|
|
350
|
+
*/
|
|
351
|
+
function sessionsSelectSQL(N, P, n, b) {
|
|
352
|
+
// A session's 0.18 metadata: its row in its own host group, the member's newest where several users
|
|
353
|
+
// shipped one. Two machines' sessions of one short id keep their own titles, folders and times.
|
|
354
|
+
// Two groupings of the rows: per machine (a short id's session on one host), and over the hosts
|
|
355
|
+
// whose messages it holds ('': a global id, every host; a session whose messages read as one
|
|
356
|
+
// machine renamed, the hosts of that stream). A row of a host with no messages is that host's own
|
|
357
|
+
// message-less session's, never another's. A session with no row of its own (0.18 kept one row per
|
|
358
|
+
// id and user, so a short id on two machines kept the last one's) borrows none: no title or times,
|
|
359
|
+
// and its folder and project from its messages.
|
|
360
|
+
const pick = `argMax(tuple(ls.l_name, ls.l_mode, ls.l_folder, ls.l_project, ls.l_branch, ls.l_created, ls.l_updated, ls.l_origin), (ls.l_user = ${lit(N.member)}, ls.l_ing)) AS z`;
|
|
361
|
+
const meta = `SELECT ls.v_harness AS z_harness, ls.v_session AS z_session, ${hostGroupSQL('ls.v_harness', 'ls.v_session', 'ls.l_host', 'hpz.v0')} AS z_g, ${pick}
|
|
362
|
+
FROM (${conv.legacySessionsSQL(N, n, b)}) AS ls
|
|
363
|
+
LEFT JOIN (${hostPairsSQL(P)}) AS hpz ON hpz.old_host = ls.l_host
|
|
364
|
+
WHERE NOT ${globalIdSQL('ls.v_harness', 'ls.v_session')}
|
|
365
|
+
GROUP BY z_harness, z_session, z_g
|
|
366
|
+
UNION ALL
|
|
367
|
+
SELECT ls.v_harness AS z_harness, ls.v_session AS z_session, '' AS z_g, ${pick}
|
|
368
|
+
FROM (${conv.legacySessionsSQL(N, n, b)}) AS ls
|
|
369
|
+
LEFT JOIN (${hostPairsSQL(P)}) AS hpw ON hpw.old_host = ls.l_host
|
|
370
|
+
WHERE ${globalIdSQL('ls.v_harness', 'ls.v_session')}
|
|
371
|
+
OR (ls.v_harness, ls.v_session, ${hostGroupSQL('ls.v_harness', 'ls.v_session', 'ls.l_host', 'hpw.v0')}) IN (${withMessagesSQL(N, P, n, b)})
|
|
372
|
+
GROUP BY z_harness, z_session`;
|
|
373
|
+
const keys = `SELECT toString(host) AS host, toString(harness) AS harness, toString(harness_path) AS harness_path, session_id, incarnation, fp,
|
|
374
|
+
toUInt8(0) AS e_empty, CAST(NULL AS Nullable(DateTime64(3, 'UTC'))) AS e_ing FROM (${sessionKeysSQL(P, n, b)})
|
|
375
|
+
UNION ALL
|
|
376
|
+
SELECT host, harness, harness_path, session_id, incarnation, CAST([] AS Array(FixedString(64))) AS fp,
|
|
377
|
+
toUInt8(1) AS e_empty, CAST(e_ing AS Nullable(DateTime64(3, 'UTC'))) AS e_ing FROM (${emptySessionKeysSQL(N, P, n, b)})`;
|
|
378
|
+
// A session with no message row has no rollup: its start and end are its 0.18 times (schema 3's rule).
|
|
379
|
+
const roll = { started: 'if(k.e_empty, coalesce(tupleElement(m.z, 6), tupleElement(m.z, 7), k.e_ing), r.r_started)',
|
|
380
|
+
ended: 'if(k.e_empty, coalesce(tupleElement(m.z, 7), tupleElement(m.z, 6), k.e_ing), r.r_ended)' };
|
|
381
|
+
const typed = `SELECT
|
|
382
|
+
CAST(k.host AS String) AS host, '' AS host_label, CAST(k.harness AS String) AS harness, CAST(k.harness_path AS String) AS harness_path,
|
|
383
|
+
k.session_id AS session_id, k.incarnation AS incarnation, '' AS engine, '' AS client, '' AS harness_version,
|
|
384
|
+
tupleElement(m.z, 1) AS title, 'unknown' AS title_set_by, if(ifNull(m.z_harness, '') = '', r.r_folder, tupleElement(m.z, 3)) AS cwd, '' AS last_cwd,
|
|
385
|
+
CAST(tupleElement(m.z, 6) AS Nullable(DateTime64(3, 'UTC'))) AS created_at, CAST(tupleElement(m.z, 7) AS Nullable(DateTime64(3, 'UTC'))) AS updated_at,
|
|
386
|
+
'active' AS state, CAST(NULL AS Nullable(Bool)) AS pinned, tupleElement(m.z, 2) AS mode, '' AS kind, 'undetectable' AS reuse, 'legacy' AS ts_source,
|
|
387
|
+
CAST([] AS Array(Tuple(id String, kind String))) AS aliases, CAST(k.fp AS Array(FixedString(64))) AS fingerprint, CAST(NULL AS Nullable(String)) AS setup,
|
|
388
|
+
'legacy' AS adapter, '0' AS adapter_ver, 'sessions-model/1' AS model_version,
|
|
389
|
+
if(ifNull(m.z_harness, '') = '', r.r_folder, tupleElement(m.z, 3)) AS folder, if(ifNull(m.z_harness, '') = '', r.r_project, tupleElement(m.z, 4)) AS project,
|
|
390
|
+
tupleElement(m.z, 5) AS git_branch,
|
|
391
|
+
if(tupleElement(m.z, 8) != '', tupleElement(m.z, 8), 'ship') AS origin,
|
|
392
|
+
${rollup.ROLLUP_COLUMNS.map(([c, e]) => `${roll[c] || e} AS ${c}`).join(', ')}
|
|
393
|
+
FROM (${keys}) AS k
|
|
394
|
+
-- A key's metadata group: '' where the id is global, or its messages read as one machine renamed;
|
|
395
|
+
-- else the key's host (host/v0 where paired), as the copies and the message-less keys make it.
|
|
396
|
+
LEFT JOIN (${meta}) AS m ON m.z_harness = k.harness AND m.z_session = k.session_id
|
|
397
|
+
AND m.z_g = if(${globalIdSQL('k.harness', 'k.session_id')} OR (NOT k.e_empty AND (k.harness, k.session_id) IN (${oneMachineSQL(N, P, n, b)})), '', k.host)
|
|
398
|
+
LEFT JOIN (${rollup.sessionRollupSQL(P, bucketSQL('harness', 'session_id', n, b))}) AS r
|
|
399
|
+
ON r.host = k.host AND r.harness = k.harness AND r.harness_path = k.harness_path AND r.session_id = k.session_id AND r.incarnation = k.incarnation`;
|
|
400
|
+
return `SELECT *, ${shaOf(SESSION_LINE.map((x) => `x.${x}`))} AS content_sha, toUInt64(0) AS rev, toUInt8(0) AS is_deleted, toUInt8(0) AS removed
|
|
401
|
+
FROM (${typed}) AS x`;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* The 0.18 tool calls of a bucket that convert: each takes its message's copy decision (dropped with
|
|
406
|
+
* a duplicate message, a removed version with a removed one, its ids qualified like the message's).
|
|
407
|
+
* A call whose message has no row converts on its own: live in the current copy, removed (and
|
|
408
|
+
* qualified) in another; its session and host group are found as its message's would be.
|
|
409
|
+
*/
|
|
410
|
+
function callsDecisionSQL(N, P, n, b) {
|
|
411
|
+
const copies = copiesSQL(N, P, n, b);
|
|
412
|
+
return `SELECT c.*, if(c.i_hit = 1, c.i_h, gs.h) AS d_h0, if(c.i_hit = 1, c.i_s, gs.s) AS d_s0, if(c.i_hit = 1, c.i_g, gs.g) AS d_g,
|
|
413
|
+
if(c.i_hit = 1, c.i_tag, if(c.c_copy = gs.cur_copy, '', concat('~', lower(hex(cityHash64(c.c_copy)))))) AS d_tag,
|
|
414
|
+
toUInt8(if(c.i_hit = 1, c.i_removed, c.c_copy != gs.cur_copy)) AS d_removed,
|
|
415
|
+
if(c.i_hit = 1, c.i_host, gs.z_old_host) AS d_host, if(c.i_hit = 1, c.i_path, gs.z_path) AS d_path,
|
|
416
|
+
if(c.i_hit = 1, c.i_agent, '') AS d_agent, if(c.i_hit = 1, c.i_model, '') AS d_model,
|
|
417
|
+
concat(c.v_call_id, if(c.i_hit = 1, c.i_tag, if(c.c_copy = gs.cur_copy, '', concat('~', lower(hex(cityHash64(c.c_copy))))))) AS cid
|
|
418
|
+
FROM (
|
|
419
|
+
SELECT c.*, ifNull(d.i_hit, 0) AS i_hit, ifNull(d.i_keep, 0) AS i_keep, d.h AS i_h, d.s AS i_s, d.g AS i_g, d.m_tag AS i_tag, d.m_removed AS i_removed,
|
|
420
|
+
d.z_old_host AS i_host, d.z_path AS i_path, d.m_agent AS i_agent, d.m_model AS i_model,
|
|
421
|
+
concat(c.l_session, char(0), c.v_user) AS c_copy,
|
|
422
|
+
${groupSQL(N, P, n, b, 'c.v_harness', 'c.v_session', 'c.v_host', 'hp0.v0')} AS c_g
|
|
423
|
+
FROM (${conv.legacyCallsSQL(N, n, b, { onePerKey: false })}) AS c
|
|
424
|
+
LEFT JOIN (SELECT l_session, m_user, m_origin, m_epoch, m_seq, h, s, g, m_tag, m_removed, z_old_host, z_path, m_agent, m_model,
|
|
425
|
+
toUInt8(1) AS i_hit, toUInt8(m_keep) AS i_keep FROM (${copies})) AS d
|
|
426
|
+
ON d.l_session = c.l_session AND d.m_user = c.v_user AND d.m_origin = c.v_origin AND d.m_epoch = c.l_epoch AND d.m_seq = c.l_seq
|
|
427
|
+
LEFT JOIN (${hostPairsSQL(P)}) AS hp0 ON hp0.old_host = c.v_host
|
|
428
|
+
) AS c
|
|
429
|
+
LEFT JOIN (SELECT DISTINCT h, s, g, cur_copy, z_old_host, z_path FROM (${copies})) AS gs
|
|
430
|
+
ON gs.h = c.v_harness AND gs.s = c.v_session AND gs.g = c.c_g
|
|
431
|
+
WHERE (c.i_hit = 1 AND c.i_keep = 1) OR (c.i_hit = 0 AND gs.h != '')`;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** calls rows of one bucket: one per 0.18 tool call that converts, with its message's part sha (run after partsInsertSQL). */
|
|
435
|
+
function callsSelectSQL(N, P, n, b) {
|
|
436
|
+
const typed = `SELECT
|
|
437
|
+
CAST(if(ifNull(hp.v0, '') != '', hp.v0, c.d_host) AS String) AS host, CAST(c.d_h0 AS String) AS harness, CAST(c.d_path AS String) AS harness_path,
|
|
438
|
+
CAST(c.d_s0 AS String) AS session_id, '' AS incarnation, c.cid AS call_id, concat(c.v_message_id, c.d_tag) AS message_id, c.d_agent AS agent_id,
|
|
439
|
+
CAST(c.v_ts AS Nullable(DateTime64(3, 'UTC'))) AS ts, c.v_ingested AS first_seen, c.v_tool AS tool_name, c.v_args AS args,
|
|
440
|
+
CAST(NULL AS Nullable(DateTime64(3, 'UTC'))) AS result_ts, CAST(NULL AS Nullable(Bool)) AS is_error, CAST(NULL AS Nullable(UInt64)) AS output_len,
|
|
441
|
+
c.d_model AS model, c.v_folder AS folder, c.v_project AS project, 'legacy' AS adapter, '0' AS adapter_ver, c.d_removed AS removed
|
|
442
|
+
FROM (${callsDecisionSQL(N, P, n, b)}) AS c
|
|
443
|
+
LEFT JOIN (${hostPairsSQL(P)}) AS hp ON hp.old_host = c.d_host`;
|
|
444
|
+
// A call whose message converted takes its part's sha; an orphan call (no message row: converted
|
|
445
|
+
// with its session) gets its own, over its key and content, never the join's empty default.
|
|
446
|
+
return `SELECT x.*, if(sp.message_id != '', sp.content_sha,
|
|
447
|
+
${shaOf(['x.host', 'x.harness', 'x.harness_path', 'x.session_id', 'x.incarnation', 'x.call_id', 'x.message_id', 'x.tool_name', 'x.args'])}) AS content_sha,
|
|
448
|
+
toUInt64(0) AS rev, toUInt8(0) AS is_deleted
|
|
449
|
+
FROM (${typed}) AS x
|
|
450
|
+
LEFT JOIN (
|
|
451
|
+
SELECT host, harness, harness_path, session_id, message_id, any(content_sha) AS content_sha FROM ${P.session_parts} FINAL
|
|
452
|
+
WHERE adapter = 'legacy' AND ${bucketSQL('harness', 'session_id', n, b)} GROUP BY host, harness, harness_path, session_id, message_id
|
|
453
|
+
) AS sp ON sp.host = x.host AND sp.harness = x.harness AND sp.harness_path = x.harness_path AND sp.session_id = x.session_id AND sp.message_id = x.message_id`;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** The legacy session paths (convert-v3's rule), when convert-v3 does not export its own. */
|
|
457
|
+
function legacyPathsSQL(N) {
|
|
458
|
+
return `SELECT z.session_id AS l_session, argMax(${conv.pathSQL(harnessSQL('z'), 'z.path')}, z.ingested_at) AS l_path FROM ${N.legacy.sessions} AS z FINAL GROUP BY z.session_id`;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const INSERT_PART_COLUMNS = [...PART_COLUMNS, 'rev', 'is_deleted', 'removed', 'first_seen', 'folder', 'project', 'text_len', 'origin', 'extra'];
|
|
462
|
+
const INSERT_USAGE_COLUMNS = [...USAGE_COLUMNS, 'rev', 'is_deleted', 'removed', 'ts', 'origin'];
|
|
463
|
+
function partsInsertSQL(N, P, n, b) { return `INSERT INTO ${P.session_parts} (${INSERT_PART_COLUMNS.join(', ')}) SELECT ${INSERT_PART_COLUMNS.join(', ')} FROM (${partsSelectSQL(N, P, n, b)})`; }
|
|
464
|
+
function usageInsertSQL(N, P, n, b) { return `INSERT INTO ${P.session_usage} (${INSERT_USAGE_COLUMNS.join(', ')}) SELECT ${INSERT_USAGE_COLUMNS.join(', ')} FROM (${usageSelectSQL(N, P, n, b)})`; }
|
|
465
|
+
const INSERT_SESSION_COLUMNS = [...SESSION_COLUMNS, 'rev', 'is_deleted', 'removed', 'folder', 'project', 'git_branch', 'origin', ...rollup.ROLLUP_COLUMNS.map(([c]) => c)];
|
|
466
|
+
const INSERT_CALL_COLUMNS = [...CALL_COLUMNS, 'rev', 'is_deleted', 'removed'];
|
|
467
|
+
/** Last for a bucket: after its parts, usage and calls (the rollup reads all three). callsInsertSQL after partsInsertSQL. */
|
|
468
|
+
function sessionsInsertSQL(N, P, n, b) { return `INSERT INTO ${P.sessions} (${INSERT_SESSION_COLUMNS.join(', ')}) SELECT ${INSERT_SESSION_COLUMNS.join(', ')} FROM (${sessionsSelectSQL(N, P, n, b)})`; }
|
|
469
|
+
function callsInsertSQL(N, P, n, b) { return `INSERT INTO ${P.calls} (${INSERT_CALL_COLUMNS.join(', ')}) SELECT ${INSERT_CALL_COLUMNS.join(', ')} FROM (${callsSelectSQL(N, P, n, b)})`; }
|
|
470
|
+
|
|
471
|
+
/** A stored row's content_sha, recomputed from its columns: what a verifier runs. */
|
|
472
|
+
function verifyShaSQL(P, table) {
|
|
473
|
+
const line = { session_parts: PART_LINE, session_usage: USAGE_LINE, sessions: SESSION_LINE }[table];
|
|
474
|
+
return `SELECT count() AS rows, countIf(content_sha != ${shaOf(line)}) AS mismatched
|
|
475
|
+
${table === 'session_parts' ? `, countIf(message_content_sha != content_sha) AS msg_mismatched, countIf(cmp_sha != ${shaOf(CMP_LINE)}) AS cmp_mismatched` : ''}
|
|
476
|
+
FROM ${P[table]} FINAL WHERE adapter = 'legacy'`;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** The names both sides of the conversion use. */
|
|
480
|
+
function names(db, member) { return { N: v3Names(db, member), P: profileNames(db, member) }; }
|
|
481
|
+
|
|
482
|
+
// ── running the conversion (memhouse migrate --v3-convert) ────────────────────────────────
|
|
483
|
+
//
|
|
484
|
+
// THE DRIVER (each line is a decision)
|
|
485
|
+
// buckets the 0.18 rows split by cityHash64(harness, session_id) into n buckets (convert-v3's
|
|
486
|
+
// rule: at most BATCH_ROWS rows or BATCH_BYTES of text each), so one INSERT … SELECT
|
|
487
|
+
// stays bounded and a stopped run resumes per bucket
|
|
488
|
+
// complete a bucket is done when its converted parts, calls and sessions hold every key its
|
|
489
|
+
// 0.18 rows make: a message (session, origin, epoch, seq), a tool call (session, call
|
|
490
|
+
// id), a session key of its converted parts. Anything less reruns the bucket's four
|
|
491
|
+
// inserts in order (parts, usage, calls, sessions): each writes the same keys at rev 0
|
|
492
|
+
// with the same content, so a rerun collapses into what is there
|
|
493
|
+
// dedupe once the capture has deduped converted rows (a legacy marker in session_sources), a
|
|
494
|
+
// tombstone cleanup may have dropped them, and a rerun would write them back: refused
|
|
495
|
+
// unless forced
|
|
496
|
+
// ledger an events row per run: pending, then applied or failed
|
|
497
|
+
// reconcile every stored converted row's content_sha (and a part's message_content_sha and
|
|
498
|
+
// cmp_sha) recomputed from its columns, and the buckets' completeness
|
|
499
|
+
|
|
500
|
+
const CONVERT_SETTINGS = { ...conv.CONVERT_SETTINGS, ...SHA_SETTINGS };
|
|
501
|
+
const INSERT_SETTINGS = { ...conv.INSERT_SETTINGS, ...SHA_SETTINGS };
|
|
502
|
+
const bucketOf = (h, s, n) => (n > 1 ? `cityHash64(toString(${h}), toString(${s})) % ${n}` : '0');
|
|
503
|
+
|
|
504
|
+
async function rowsOrEmpty(q, sql) {
|
|
505
|
+
try { return await q.rows(sql, CONVERT_SETTINGS); }
|
|
506
|
+
catch (e) {
|
|
507
|
+
if (/UNKNOWN_TABLE|Unknown table|doesn't exist|does not exist/i.test(String(e && e.message))) return [];
|
|
508
|
+
throw e;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
const byBucket = (rows, field) => new Map(rows.map((r) => [Number(r.b), Number(r[field]) || 0]));
|
|
512
|
+
const sumMap = (m) => [...m.values()].reduce((a, v) => a + v, 0);
|
|
513
|
+
const addMaps = (x, y) => { const m = new Map(x); for (const [k, v] of y) m.set(k, (m.get(k) || 0) + v); return m; };
|
|
514
|
+
|
|
515
|
+
/** Per bucket: what the 0.18 rows make, and what the profile holds of it. */
|
|
516
|
+
async function planProfile(q, N, P, n) {
|
|
517
|
+
const L = N.legacy;
|
|
518
|
+
const [lm, pm, lc, lo, lk, pc, ws, le, hs] = await Promise.all([
|
|
519
|
+
q.rows(`SELECT ${bucketOf('h', 's', n)} AS b, count() AS rows, uniqExactIf((h, s, g, m_mid), m_keep) AS keys
|
|
520
|
+
FROM (${copiesSQL(N, P, 1, 0)}) GROUP BY b`, CONVERT_SETTINGS),
|
|
521
|
+
rowsOrEmpty(q, `SELECT ${bucketOf('harness', 'session_id', n)} AS b, uniqExact(host, toString(harness), toString(harness_path), session_id, message_id) AS keys
|
|
522
|
+
FROM ${P.session_parts} WHERE adapter = 'legacy' GROUP BY b`),
|
|
523
|
+
// every 0.18 call counts in rows, so one that does not convert (its message a duplicate, or no message
|
|
524
|
+
// of its session left) shows as a collision rather than vanishing
|
|
525
|
+
q.rows(`SELECT ${bucketOf('v_harness', 'v_session', n)} AS b, count() AS rows
|
|
526
|
+
FROM (${conv.legacyCallsSQL(N, 1, 0, { onePerKey: false })}) GROUP BY b`, CONVERT_SETTINGS),
|
|
527
|
+
// and so does a shipped call of a session with no shipped message, which no parse holds
|
|
528
|
+
q.rows(`SELECT ${bucketOf(harnessSQL('t'), sessionSQL('t'), n)} AS b, count() AS rows FROM ${L.tool_calls} AS t FINAL
|
|
529
|
+
WHERE t.origin = 'ship' AND (t.session_id, t.user_id) NOT IN (SELECT DISTINCT session_id, user_id FROM ${L.messages} WHERE origin = 'ship') GROUP BY b`, CONVERT_SETTINGS),
|
|
530
|
+
q.rows(`SELECT ${bucketOf('v_harness', 'v_session', n)} AS b, uniqExact(d_h0, d_s0, d_g, cid) AS keys
|
|
531
|
+
FROM (${callsDecisionSQL(N, P, 1, 0)}) GROUP BY b`, CONVERT_SETTINGS),
|
|
532
|
+
rowsOrEmpty(q, `SELECT ${bucketOf('harness', 'session_id', n)} AS b, uniqExact(host, toString(harness), toString(harness_path), session_id, call_id) AS keys
|
|
533
|
+
FROM ${P.calls} WHERE adapter = 'legacy' GROUP BY b`),
|
|
534
|
+
rowsOrEmpty(q, `SELECT ${bucketOf('harness', 'session_id', n)} AS b, uniqExact(host, toString(harness), toString(harness_path), session_id, incarnation) AS keys
|
|
535
|
+
FROM ${P.session_parts} WHERE adapter = 'legacy' GROUP BY b`),
|
|
536
|
+
// a 0.18 session with no message row converts too, as a session without messages
|
|
537
|
+
q.rows(`SELECT ${bucketOf('harness', 'session_id', n)} AS b, uniqExact(host, harness, harness_path, session_id, incarnation) AS keys
|
|
538
|
+
FROM (${emptySessionKeysSQL(N, P, 1, 0)}) GROUP BY b`, CONVERT_SETTINGS),
|
|
539
|
+
rowsOrEmpty(q, `SELECT ${bucketOf('harness', 'session_id', n)} AS b, uniqExact(host, toString(harness), toString(harness_path), session_id, incarnation) AS keys
|
|
540
|
+
FROM ${P.sessions} WHERE adapter = 'legacy' GROUP BY b`),
|
|
541
|
+
]);
|
|
542
|
+
return {
|
|
543
|
+
parts: { rows: byBucket(lm, 'rows'), want: byBucket(lm, 'keys'), have: byBucket(pm, 'keys') },
|
|
544
|
+
calls: { rows: addMaps(byBucket(lc, 'rows'), byBucket(lo, 'rows')), want: byBucket(lk, 'keys'), have: byBucket(pc, 'keys') },
|
|
545
|
+
sessions: { want: addMaps(byBucket(ws, 'keys'), byBucket(le, 'keys')), have: byBucket(hs, 'keys') },
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
const complete = (p, b) => (p.want.get(b) || 0) === (p.have.get(b) || 0);
|
|
549
|
+
function summarize(p, inserted, skipped) {
|
|
550
|
+
const out = { legacy_keys: sumMap(p.want), converted_keys: sumMap(p.have), buckets_inserted: inserted, buckets_skipped: skipped };
|
|
551
|
+
// 0.18 rows of a session's other copies the current copy holds again (true duplicates, not converted): reported, never hidden.
|
|
552
|
+
if (p.rows) { out.legacy_rows = sumMap(p.rows); out.collisions = out.legacy_rows - out.legacy_keys; }
|
|
553
|
+
return out;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Convert one member's 0.18 rooms (renamed into the archive by --v3-prepare) into the profile.
|
|
558
|
+
* @param q { sql(sql, settings), rows(sql, settings) }
|
|
559
|
+
* @param ctx { db, member, ledger?, host?, forceAfterDedupe? }
|
|
560
|
+
*/
|
|
561
|
+
async function convertProfile(q, ctx, { batchRows = conv.BATCH_ROWS, batchBytes = conv.BATCH_BYTES, dryRun = false } = {}, ui = null) {
|
|
562
|
+
const { N, P } = names(ctx.db, ctx.member);
|
|
563
|
+
const say = (m) => ui && ui.ok && ui.ok(m);
|
|
564
|
+
const marked = await rowsOrEmpty(q, `SELECT count() AS n FROM ${P.session_sources} FINAL WHERE role = 'legacy' AND is_deleted = 0`);
|
|
565
|
+
const dedupedSessions = Number((marked[0] || {}).n) || 0;
|
|
566
|
+
const started = Date.now();
|
|
567
|
+
const size = await conv.legacySize(q, N);
|
|
568
|
+
const n = conv.bucketCount(size.rows, batchRows, size.bytes, batchBytes);
|
|
569
|
+
const before = await planProfile(q, N, P, n);
|
|
570
|
+
const done = (pl, b) => complete(pl.parts, b) && complete(pl.calls, b) && complete(pl.sessions, b);
|
|
571
|
+
// A conversion with every bucket complete writes nothing but its mark, so a dedupe is no reason to
|
|
572
|
+
// refuse it: a member converted before the mark existed gets it from a plain re-run.
|
|
573
|
+
if (dedupedSessions && !ctx.forceAfterDedupe && ![...Array(n).keys()].every((b) => done(before, b))) {
|
|
574
|
+
return { member: ctx.member, ok: false, deduped_sessions: dedupedSessions, refused: `${dedupedSessions} session(s) of ${ctx.member} are already deduped against their capture; `
|
|
575
|
+
+ 're-converting could bring back rows the dedupe removed (and a cleanup may have dropped). Refusing.' };
|
|
576
|
+
}
|
|
577
|
+
const report = { member: ctx.member, source: N.legacy, target: { session_parts: P.session_parts, session_usage: P.session_usage, calls: P.calls, sessions: P.sessions },
|
|
578
|
+
buckets: n, legacy_text_bytes: size.bytes, dry_run: Boolean(dryRun) };
|
|
579
|
+
if (dryRun) {
|
|
580
|
+
for (const t of ['parts', 'calls', 'sessions']) report[t] = summarize(before[t], 0, 0);
|
|
581
|
+
report.pending_buckets = [...Array(n).keys()].filter((b) => !done(before, b));
|
|
582
|
+
report.ok = report.pending_buckets.length === 0;
|
|
583
|
+
return report;
|
|
584
|
+
}
|
|
585
|
+
const event = async (e) => { if (ctx.ledger) { try { await ctx.ledger.event(e); } catch { /* best effort */ } } };
|
|
586
|
+
await event({ kind: 'migration', id: '0200-profile-convert', status: 'pending', host: ctx.host || '', detail: `convert ${ctx.member}: ${n} bucket(s)` });
|
|
587
|
+
let inserted = 0; let skipped = 0;
|
|
588
|
+
try {
|
|
589
|
+
for (let b = 0; b < n; b++) {
|
|
590
|
+
if (done(before, b)) { skipped++; continue; }
|
|
591
|
+
for (const [t, build] of [['parts', partsInsertSQL], ['usage', usageInsertSQL], ['calls', callsInsertSQL], ['sessions', sessionsInsertSQL]]) {
|
|
592
|
+
await q.sql(build(N, P, n, b), INSERT_SETTINGS);
|
|
593
|
+
say(`${t}: bucket ${b + 1}/${n}`);
|
|
594
|
+
}
|
|
595
|
+
inserted++;
|
|
596
|
+
}
|
|
597
|
+
} catch (e) {
|
|
598
|
+
await event({ kind: 'migration', id: '0200-profile-convert', status: 'failed', host: ctx.host || '', detail: `convert ${ctx.member}: ${String(e && e.message).split('\n')[0]}` });
|
|
599
|
+
throw e;
|
|
600
|
+
}
|
|
601
|
+
const after = await planProfile(q, N, P, n);
|
|
602
|
+
for (const t of ['parts', 'calls', 'sessions']) { report[t] = summarize(after[t], inserted, skipped); report[t].converted_before = sumMap(before[t].have); }
|
|
603
|
+
report.ok = [...Array(n).keys()].every((b) => done(after, b));
|
|
604
|
+
// The mark that opens the house to schema-3 installs and --v3-switch (schema-choice.js): only a
|
|
605
|
+
// conversion that ran to the end writes it.
|
|
606
|
+
if (report.ok) await q.sql(`INSERT INTO ${P.meta} (key, value) VALUES ('profile_converted', ${lit(new Date(started).toISOString())})`, { async_insert: 0 });
|
|
607
|
+
report.seconds = Math.round((Date.now() - started) / 100) / 10;
|
|
608
|
+
await event({ kind: 'migration', id: '0200-profile-convert', status: report.ok ? 'applied' : 'failed', host: ctx.host || '',
|
|
609
|
+
rows_before: report.parts.legacy_rows, rows_after: report.parts.converted_keys,
|
|
610
|
+
detail: `convert ${ctx.member}: parts ${report.parts.converted_keys}/${report.parts.legacy_keys}, calls ${report.calls.converted_keys}/${report.calls.legacy_keys}, `
|
|
611
|
+
+ `sessions ${report.sessions.converted_keys}/${report.sessions.legacy_keys}${report.parts.collisions ? `, ${report.parts.collisions} collision(s)` : ''}` });
|
|
612
|
+
return report;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** The conversion's check: every converted row's shas recomputed from its columns, and every bucket complete. */
|
|
616
|
+
async function reconcileProfile(q, ctx, { batchRows = conv.BATCH_ROWS, batchBytes = conv.BATCH_BYTES } = {}) {
|
|
617
|
+
const { N, P } = names(ctx.db, ctx.member);
|
|
618
|
+
const out = { member: ctx.member, tables: {} };
|
|
619
|
+
for (const t of ['session_parts', 'session_usage', 'sessions']) {
|
|
620
|
+
const r = (await rowsOrEmpty(q, verifyShaSQL(P, t)))[0] || {};
|
|
621
|
+
out.tables[t] = Object.fromEntries(Object.entries(r).map(([k, v]) => [k, Number(v) || 0]));
|
|
622
|
+
}
|
|
623
|
+
const size = await conv.legacySize(q, N);
|
|
624
|
+
const n = conv.bucketCount(size.rows, batchRows, size.bytes, batchBytes);
|
|
625
|
+
const pl = await planProfile(q, N, P, n);
|
|
626
|
+
out.buckets = n;
|
|
627
|
+
out.incomplete_buckets = [...Array(n).keys()].filter((b) => !(complete(pl.parts, b) && complete(pl.calls, b) && complete(pl.sessions, b)));
|
|
628
|
+
for (const t of ['parts', 'calls', 'sessions']) out[t] = summarize(pl[t], 0, 0);
|
|
629
|
+
out.ok = !out.incomplete_buckets.length && Object.values(out.tables).every((x) => !x.mismatched && !x.msg_mismatched && !x.cmp_mismatched);
|
|
630
|
+
return out;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
module.exports = {
|
|
634
|
+
SHA_SETTINGS, PART_COLUMNS, PART_LINE, CMP_LINE, USAGE_COLUMNS, USAGE_LINE, SESSION_COLUMNS, SESSION_LINE, CALL_COLUMNS, FLAGS, GRANULARITY,
|
|
635
|
+
hostPairsSQL, oneMachineSQL, copiesSQL, callsDecisionSQL, GLOBAL_ID_HARNESSES, convertedMessagesSQL, runsSQL, partsSelectSQL, usageSelectSQL, sessionKeysSQL, emptySessionKeysSQL, sessionsSelectSQL, callsSelectSQL,
|
|
636
|
+
partsInsertSQL, usageInsertSQL, sessionsInsertSQL, callsInsertSQL, verifyShaSQL, names,
|
|
637
|
+
planProfile, convertProfile, reconcileProfile, CONVERT_SETTINGS, INSERT_SETTINGS,
|
|
638
|
+
};
|