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,756 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Capture through @agent-realm/harness-session-adapters: memhouse as a sessions v0 consumer (step 3 of
|
|
3
|
+
// the switch, designed 2026-10-23: the package reads and derives each store, memhouse archives what is
|
|
4
|
+
// new and writes only the rows that changed).
|
|
5
|
+
//
|
|
6
|
+
// One store at a time. `list` names the sessions and their sources; per session, every source is
|
|
7
|
+
// read from its position, the new units are archived, `derive` runs over what is new, and only the
|
|
8
|
+
// rows that changed are written. The table shapes are not this module's: a `mapping` turns a
|
|
9
|
+
// session's record view into rows (records-to-rows for schema 3; harness-sessions-sql's profile in step B).
|
|
10
|
+
//
|
|
11
|
+
// Where the state lives. The house is the truth, the local state an accelerator:
|
|
12
|
+
// a source's generation, position and known units its `files` row (file_key = source_key,
|
|
13
|
+
// fgen = generation, derive_state = {position,
|
|
14
|
+
// known, ords})
|
|
15
|
+
// the units archive `records`, kind 'unit', raw = the unit
|
|
16
|
+
// as JSON, in 1 MiB chunks
|
|
17
|
+
// derive state, record view, row hashes, first-seen times, last rev
|
|
18
|
+
// $MEMHOUSE_HOME/state/derive/<sha1>.json
|
|
19
|
+
// Lost or stale local state is never a loss: the session refolds from its archived units, every
|
|
20
|
+
// row is written again at a higher rev, and keys the house holds that the refold no longer
|
|
21
|
+
// produces become removed versions.
|
|
22
|
+
//
|
|
23
|
+
// Write order, as capture.js: archive, then rows, then files, then sessions; the local state last.
|
|
24
|
+
|
|
25
|
+
const crypto = require('crypto');
|
|
26
|
+
const fs = require('fs');
|
|
27
|
+
const path = require('path');
|
|
28
|
+
|
|
29
|
+
const base = require('../../editors/base').capture;
|
|
30
|
+
const recordsToRows = require('../house/records-to-rows');
|
|
31
|
+
const { ADAPTER: BRIDGE } = require('./log-adapter');
|
|
32
|
+
|
|
33
|
+
const CHUNK = 1 << 20;
|
|
34
|
+
const EPOCH_ISO = '1970-01-01T00:00:00.000Z';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The package. MEMHOUSE_SESSION_ADAPTERS names a package root for development (a harness-skeleton
|
|
38
|
+
* checkout's packages/harness-session-adapters, kept outside git); otherwise the installed
|
|
39
|
+
* dependency. A failure to load it says where it looked (PACKAGE_UNLOADABLE), so a pass never takes
|
|
40
|
+
* it for one store's error. A package whose adapters for the harnesses memhouse reads through it
|
|
41
|
+
* (ADAPTER_HARNESSES) still name themselves @agent-realm/session-adapters/… predates the rename:
|
|
42
|
+
* their rows would read as another adapter's (profile-capture.js, adapter switch), and a pass would
|
|
43
|
+
* re-read and re-archive every session. It is refused (PACKAGE_RENAMED).
|
|
44
|
+
*/
|
|
45
|
+
const PACKAGE_NAME = '@agent-realm/harness-session-adapters';
|
|
46
|
+
const OLD_ADAPTER_PREFIX = '@agent-realm/session-adapters';
|
|
47
|
+
const ADAPTER_HARNESSES = ['claude-code', 'codex'];
|
|
48
|
+
const PACKAGE_MISSING = 'harness-session-adapters missing: Claude Code and Codex are not captured on the profile layout (install @agent-realm/harness-session-adapters)';
|
|
49
|
+
const PACKAGE_UNLOADABLE = 'harness-session-adapters could not be loaded, so Claude Code and Codex are not captured';
|
|
50
|
+
const PACKAGE_RENAMED = 'harness-session-adapters is from before its rename, so Claude Code and Codex are not captured';
|
|
51
|
+
function loadPackage(env = process.env) {
|
|
52
|
+
const root = env.MEMHOUSE_SESSION_ADAPTERS;
|
|
53
|
+
const where = root ? `MEMHOUSE_SESSION_ADAPTERS=${root}` : PACKAGE_NAME;
|
|
54
|
+
let pkg;
|
|
55
|
+
try { pkg = root ? require(path.join(path.resolve(root), 'src', 'index.js')) : require(PACKAGE_NAME); }
|
|
56
|
+
catch (e) { throw new Error(`${PACKAGE_UNLOADABLE} (${where}): ${String((e && e.message) || e).split('\n')[0]}`); }
|
|
57
|
+
const isOld = (a) => a === OLD_ADAPTER_PREFIX || a.startsWith(`${OLD_ADAPTER_PREFIX}/`);
|
|
58
|
+
const old = ADAPTER_HARNESSES.filter((h) => {
|
|
59
|
+
try { return isOld(String(pkg.adapterFor(h).describe().adapter || '')); } catch { return false; }
|
|
60
|
+
});
|
|
61
|
+
if (old.length) {
|
|
62
|
+
throw new Error(`${PACKAGE_RENAMED}: ${where} names its ${old.join(' and ')} adapter${old.length > 1 ? 's' : ''} ${OLD_ADAPTER_PREFIX}/…. ${root
|
|
63
|
+
? 'Point MEMHOUSE_SESSION_ADAPTERS at packages/harness-session-adapters of an updated harness-skeleton checkout, or unset it to use the installed package.'
|
|
64
|
+
: `Reinstall memhouse, which depends on ${PACKAGE_NAME}.`}`);
|
|
65
|
+
}
|
|
66
|
+
return pkg;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The loaded package's version, from its package.json (1.0.0 exports none). '' when unknown. */
|
|
70
|
+
function packageVersion(env = process.env) {
|
|
71
|
+
try {
|
|
72
|
+
const root = env.MEMHOUSE_SESSION_ADAPTERS;
|
|
73
|
+
if (root) return String(JSON.parse(fs.readFileSync(path.join(path.resolve(root), 'package.json'), 'utf-8')).version || '');
|
|
74
|
+
for (let dir = path.dirname(require.resolve(PACKAGE_NAME)), i = 0; i < 4; dir = path.dirname(dir), i++) {
|
|
75
|
+
const f = path.join(dir, 'package.json');
|
|
76
|
+
if (!fs.existsSync(f)) continue;
|
|
77
|
+
const p = JSON.parse(fs.readFileSync(f, 'utf-8'));
|
|
78
|
+
if (p.name === PACKAGE_NAME) return String(p.version || '');
|
|
79
|
+
}
|
|
80
|
+
} catch { /* unknown */ }
|
|
81
|
+
return '';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Whether the package loads: { ok, version } or { ok: false, error }. Never throws. */
|
|
85
|
+
function packageStatus(env = process.env) {
|
|
86
|
+
try {
|
|
87
|
+
const pkg = loadPackage(env);
|
|
88
|
+
return { ok: true, version: String((pkg && pkg.version) || packageVersion(env)) };
|
|
89
|
+
} catch (e) { return { ok: false, error: String((e && e.message) || e).split('\n')[0] }; }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Whether an adapter's error is the package failing to load at all, or loading from before the rename. */
|
|
93
|
+
const isPackageMissing = (msg) => String(msg || '').startsWith(PACKAGE_UNLOADABLE) || String(msg || '').startsWith(PACKAGE_RENAMED)
|
|
94
|
+
|| /Cannot find module '@agent-realm\/harness-session-adapters'|MEMHOUSE_SESSION_ADAPTERS/.test(String(msg || ''))
|
|
95
|
+
|| /Cannot find module '[^']*harness-session-adapters[^']*'/.test(String(msg || ''));
|
|
96
|
+
|
|
97
|
+
/** What a pass or doctor says about such an error: loadPackage's own errors say what and where. */
|
|
98
|
+
const packageMessage = (msg) => (String(msg || '').startsWith(PACKAGE_UNLOADABLE) || String(msg || '').startsWith(PACKAGE_RENAMED)
|
|
99
|
+
? String(msg) : PACKAGE_MISSING);
|
|
100
|
+
|
|
101
|
+
const sha1 = (s) => crypto.createHash('sha1').update(s).digest('hex');
|
|
102
|
+
const sessionId = (k) => [k.host, k.harness, k.harness_path, k.session_id, k.incarnation || ''].join('\u0000');
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* "0.1.0" as the UInt32 the adapter_ver columns hold: 10^9 + major·10^6 + minor·10^3 + patch. The
|
|
106
|
+
* 10^9 floor keeps every package row apart from the 0.20 engines' (Claude Code 2, Codex 3, the log
|
|
107
|
+
* derive 1005), so a session switching to the package can tell its old rows from its own.
|
|
108
|
+
*/
|
|
109
|
+
const PACKAGE_VER_FLOOR = 1e9;
|
|
110
|
+
function verNumber(v) {
|
|
111
|
+
const m = /^(\d+)\.(\d+)\.(\d+)/.exec(String(v || ''));
|
|
112
|
+
return m ? PACKAGE_VER_FLOOR + Number(m[1]) * 1e6 + Number(m[2]) * 1e3 + Number(m[3]) : 0;
|
|
113
|
+
}
|
|
114
|
+
const isPackageRow = (r) => Number(r.adapter_ver) >= PACKAGE_VER_FLOOR;
|
|
115
|
+
|
|
116
|
+
/** JSON with object keys sorted: the canonical form for the scalars and plain objects a link key holds. */
|
|
117
|
+
function canon(v) {
|
|
118
|
+
if (Array.isArray(v)) return `[${v.map(canon).join(',')}]`;
|
|
119
|
+
if (v && typeof v === 'object') return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canon(v[k])}`).join(',')}}`;
|
|
120
|
+
return JSON.stringify(v === undefined ? null : v);
|
|
121
|
+
}
|
|
122
|
+
/** A record's own key in its session, as `live` lists it (sessions §2). */
|
|
123
|
+
function recordKey(r) {
|
|
124
|
+
switch (r.type) {
|
|
125
|
+
case 'session': return '';
|
|
126
|
+
case 'message': return String(r.message_id);
|
|
127
|
+
case 'usage': return String(r.usage_id);
|
|
128
|
+
case 'lifecycle': return String(r.lifecycle_id);
|
|
129
|
+
case 'link': return canon([r.kind, r.stated_on === 'origin' ? r.child : r.origin, r.at ?? '']);
|
|
130
|
+
default: throw new Error(`unknown record type ${r.type}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/** The view after one derive: `changed` upserted, then every key absent from both `live` and `held` dropped. */
|
|
134
|
+
function applyDerive(view, { changed, live, held = {} }) {
|
|
135
|
+
const next = new Map(view);
|
|
136
|
+
for (const r of changed) next.set(`${r.type}\u0000${recordKey(r)}`, r);
|
|
137
|
+
const keep = new Set();
|
|
138
|
+
for (const set of [live || {}, held || {}]) for (const [type, keys] of Object.entries(set)) for (const k of keys) keep.add(`${type}\u0000${k}`);
|
|
139
|
+
for (const k of [...next.keys()]) if (!keep.has(k)) next.delete(k);
|
|
140
|
+
return next;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── local state ──────────────────────────────────────────────────────────────────────────────
|
|
144
|
+
/** `ns`: the state's namespace, one per table layout ('derive' for schema 3, 'derive-profile'). */
|
|
145
|
+
function statePath(home, key, ns = 'derive') { return path.join(home, 'state', ns, `${sha1(sessionId(key))}.json`); }
|
|
146
|
+
function loadLocal(home, key, ns) {
|
|
147
|
+
try {
|
|
148
|
+
const s = JSON.parse(fs.readFileSync(statePath(home, key, ns), 'utf-8'));
|
|
149
|
+
return s && s.v === 1 && s.id === sessionId(key) ? s : null;
|
|
150
|
+
} catch { return null; }
|
|
151
|
+
}
|
|
152
|
+
function saveLocal(home, key, s, ns) {
|
|
153
|
+
const f = statePath(home, key, ns);
|
|
154
|
+
fs.mkdirSync(path.dirname(f), { recursive: true });
|
|
155
|
+
const tmp = `${f}.${process.pid}.tmp`;
|
|
156
|
+
fs.writeFileSync(tmp, JSON.stringify({ ...s, v: 1, id: sessionId(key) }), { mode: 0o600 });
|
|
157
|
+
fs.renameSync(tmp, f);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ── archive ──────────────────────────────────────────────────────────────────────────────────
|
|
161
|
+
/** One unit as archive rows: its JSON, chunked; `pos` orders it within its source's generation. */
|
|
162
|
+
function unitRows(key, sourceKey, gen, pos, unit, capturedAt) {
|
|
163
|
+
const raw = JSON.stringify(unit);
|
|
164
|
+
const rows = [];
|
|
165
|
+
for (let i = 0, c = 0; i < raw.length || c === 0; i += CHUNK, c++) {
|
|
166
|
+
rows.push({ harness: key.harness, harness_path: key.harness_path, session_id: key.session_id, incarnation: key.incarnation || '',
|
|
167
|
+
file_key: sourceKey, fgen: gen, pos, chunk: c, sha: unit.sha256, kind: 'unit', enc: '', raw: raw.slice(i, i + CHUNK), host: key.host, captured_at: capturedAt });
|
|
168
|
+
}
|
|
169
|
+
return rows;
|
|
170
|
+
}
|
|
171
|
+
/** The units of one generation, back from their archive rows. */
|
|
172
|
+
function unitsFromArchive(rows) {
|
|
173
|
+
const byPos = new Map();
|
|
174
|
+
for (const r of rows) {
|
|
175
|
+
if (r.kind !== 'unit') continue;
|
|
176
|
+
const k = Number(r.pos);
|
|
177
|
+
if (!byPos.has(k)) byPos.set(k, []);
|
|
178
|
+
byPos.get(k).push(r);
|
|
179
|
+
}
|
|
180
|
+
return [...byPos.keys()].sort((a, b) => a - b).map((k) => JSON.parse(byPos.get(k).sort((a, b) => a.chunk - b.chunk).map((r) => r.raw).join('')));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ── rows ─────────────────────────────────────────────────────────────────────────────────────
|
|
184
|
+
function tsWire(iso) { const ms = Date.parse(iso); return base.chTs(Number.isFinite(ms) ? ms : 0); }
|
|
185
|
+
/** schema 3 rows from the mapping's output, every column filled. */
|
|
186
|
+
function schema3Rows(mapped, key, { rev, adapterVer }) {
|
|
187
|
+
const meta = mapped.meta || {};
|
|
188
|
+
const folder = String(meta.folder || '');
|
|
189
|
+
const project = folder ? folder.replace(/\/+$/, '').split('/').pop() : '';
|
|
190
|
+
const turns = mapped.turns.map((t) => {
|
|
191
|
+
const full = base.cleanText(t.text);
|
|
192
|
+
const cap = t.kind === 'tool_result' || t.kind === 'tool_call' ? base.TOOL_RESULT_CAP : base.TEXT_CAP;
|
|
193
|
+
return {
|
|
194
|
+
harness: key.harness, harness_version: String(meta.harness_version || ''), harness_path: key.harness_path, session_id: key.session_id,
|
|
195
|
+
incarnation: key.incarnation || '', agent_id: String(t.agent_id || ''), message_id: String(t.message_id), part_id: String(t.part_id),
|
|
196
|
+
part_ord: Number(t.part_ord) || 0, parent_id: String(t.parent_id || ''), response_id: String(t.response_id || ''), ts: tsWire(t.ts),
|
|
197
|
+
ord: Number(t.ord) || 0, rev: String(rev), is_deleted: 0, removed: 0, state: String(t.state || ''), role: String(t.role), kind: String(t.kind),
|
|
198
|
+
model: String(t.model || ''), effort: String(t.effort || ''),
|
|
199
|
+
input_tokens: t.input_tokens || 0, output_tokens: t.output_tokens || 0, cache_read_tokens: t.cache_read_tokens || 0,
|
|
200
|
+
cache_write_tokens: t.cache_write_tokens || 0, reasoning_tokens: t.reasoning_tokens || 0,
|
|
201
|
+
text: base.capText(full, cap), text_len: full.length, folder, project, host: key.host, origin: 'ship',
|
|
202
|
+
extra: JSON.stringify(t.extra || {}), adapter_ver: adapterVer,
|
|
203
|
+
};
|
|
204
|
+
});
|
|
205
|
+
const byMsg = new Map(turns.map((t) => [t.message_id, t]));
|
|
206
|
+
const calls = mapped.calls.filter((c) => c.tool_name !== undefined).map((c) => ({
|
|
207
|
+
harness: key.harness, harness_path: key.harness_path, session_id: key.session_id, incarnation: key.incarnation || '',
|
|
208
|
+
agent_id: String(c.agent_id || ''), call_id: String(c.call_id), message_id: String(c.message_id),
|
|
209
|
+
ts: c.ts ? tsWire(c.ts) : (byMsg.get(String(c.message_id)) || {}).ts || base.chTs(0), rev: String(rev), is_deleted: 0, removed: 0,
|
|
210
|
+
tool_name: String(c.tool_name || ''), args: base.capText(base.cleanText(c.args || '{}'), base.ARGS_CAP),
|
|
211
|
+
result_ts: c.result_ts ? tsWire(c.result_ts) : null, is_error: !!c.is_error, result_len: Number(c.result_len) || 0,
|
|
212
|
+
model: String(c.model || ''), folder, project, host: key.host, adapter_ver: adapterVer,
|
|
213
|
+
}));
|
|
214
|
+
return { turns, calls };
|
|
215
|
+
}
|
|
216
|
+
/** What a row says, without its rev: equal digests mean nothing to write. */
|
|
217
|
+
function rowDigest(r) { const { rev: _r, ...rest } = r; return sha1(JSON.stringify(rest)); }
|
|
218
|
+
/** The session's exact totals over its rows (base's contributions), with the metadata buildSession reads. */
|
|
219
|
+
function contribOf(rows, meta) {
|
|
220
|
+
const c = base.newContrib();
|
|
221
|
+
for (const t of rows.turns) base.applyTurn(c, t, 1);
|
|
222
|
+
for (const k of rows.calls) base.applyCall(c, k, 1);
|
|
223
|
+
const firstUser = rows.turns.filter((t) => t.role === 'user' && t.kind === 'text' && !t.agent_id && t.text).sort((a, b) => (a.ts < b.ts ? -1 : a.ts > b.ts ? 1 : a.ord - b.ord))[0];
|
|
224
|
+
c.meta = {
|
|
225
|
+
...(meta && meta.folder ? { folder: meta.folder } : {}), ...(meta && meta.title ? { title: meta.title } : {}),
|
|
226
|
+
...(meta && meta.harness_version ? { version: meta.harness_version } : {}), ...(firstUser ? { first_prompt: firstUser.text.slice(0, 200) } : {}),
|
|
227
|
+
};
|
|
228
|
+
return base.serializeContrib(c);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Which 0.20 engine rows the package's view supersedes. The two key the same content differently, so
|
|
233
|
+
* content decides: a turn by kind and text (a textless one by its message and part id), a call by its
|
|
234
|
+
* call_id. Each view row answers for one row the house holds, and a view row that replaced a row under
|
|
235
|
+
* the same key has answered already: two engine rows with one text, where the source now holds that
|
|
236
|
+
* text once, are one duplicate and one removed line. Returns (kind, row) => is it a duplicate.
|
|
237
|
+
*/
|
|
238
|
+
function twinsOf(rows, cur, digests) {
|
|
239
|
+
const sig = { turns: (r) => (r.text ? `${r.kind}\u0000${r.text}` : `${r.kind}\u0000\u0000${r.message_id}\u0000${r.part_id}`), calls: (r) => String(r.call_id) };
|
|
240
|
+
const bag = { turns: new Map(), calls: new Map() };
|
|
241
|
+
const take = (kind, r) => { const k = sig[kind](r); const n = bag[kind].get(k) || 0; if (!n) return false; bag[kind].set(k, n - 1); return true; };
|
|
242
|
+
for (const r of rows.turns) bag.turns.set(sig.turns(r), (bag.turns.get(sig.turns(r)) || 0) + 1);
|
|
243
|
+
for (const r of rows.calls) bag.calls.set(sig.calls(r), (bag.calls.get(sig.calls(r)) || 0) + 1);
|
|
244
|
+
for (const r of cur.turns) if (base.turnKey(r) in digests.t) take('turns', r);
|
|
245
|
+
for (const r of cur.calls) if (base.callKey(r) in digests.c) take('calls', r);
|
|
246
|
+
return take;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Capture one session. `ctx`: { house, home, host, now(), mapping? }. `ad`: the harness's adapter;
|
|
251
|
+
* `d`: its describe(). `listed`: its `list` entry. Returns what it did.
|
|
252
|
+
*/
|
|
253
|
+
async function captureSession(ctx, ad, d, store, listed) {
|
|
254
|
+
const key = { ...store, session_id: listed.session_id, incarnation: listed.incarnation ?? '' };
|
|
255
|
+
const ref = { harness: key.harness, host: key.host, harness_path: key.harness_path, session_id: key.session_id, incarnation: key.incarnation };
|
|
256
|
+
const out = { session_id: key.session_id, archived: 0, turns: 0, calls: 0, removed: 0, refold: false, skipped: false, more: false };
|
|
257
|
+
let local = loadLocal(ctx.home, key);
|
|
258
|
+
const H = new Map((await ctx.house.filesRows([ref])).map((r) => [r.file_key, r]));
|
|
259
|
+
// The local state counts only while it agrees with the house on every source it read.
|
|
260
|
+
if (local && (local.adapter_ver !== d.adapter_ver || local.state_ver !== d.state_ver
|
|
261
|
+
|| Object.entries(local.sources || {}).some(([sk, s]) => { const h = H.get(sk); return !h || Number(h.fgen) !== s.gen || String(h.upto) !== String(s.upto); }))) local = null;
|
|
262
|
+
const reDerive = !local && [...H.values()].some((h) => h.role !== 'legacy' && h.role !== 'inherited');
|
|
263
|
+
const capturedAt = base.chTs(ctx.now());
|
|
264
|
+
const sources = {};
|
|
265
|
+
const archive = [];
|
|
266
|
+
const fresh = [];
|
|
267
|
+
let full = !local;
|
|
268
|
+
for (const src of listed.sources) {
|
|
269
|
+
const sk = src.source_key;
|
|
270
|
+
const h = H.get(sk);
|
|
271
|
+
// A files row the package wrote seeds the source. A 0.20 engine's row under the same key (its
|
|
272
|
+
// file_key can equal a source_key: a Codex rollout's base name) does not: its state is the old
|
|
273
|
+
// engine's, so the source starts from nothing in the generation past it, and the package's row
|
|
274
|
+
// (a higher fgen, so a higher ver) replaces it.
|
|
275
|
+
const pkgRow = h && isPackageRow(h);
|
|
276
|
+
const prior = local ? local.sources[sk] : (pkgRow ? { gen: Number(h.fgen), upto: String(h.upto), ...JSON.parse(h.derive_state || '{}') } : null);
|
|
277
|
+
let gen = prior ? prior.gen : (h ? Number(h.fgen) + 1 : 0);
|
|
278
|
+
const ords = { ...(prior && prior.ords) };
|
|
279
|
+
let next = Object.keys(ords).length ? Math.max(...Object.values(ords)) + 1 : 0;
|
|
280
|
+
const known = prior && prior.known && Object.keys(prior.known).length ? prior.known : null;
|
|
281
|
+
// One capped read per source per step (the package reads at most 64 MiB and says `more`). A big
|
|
282
|
+
// session is captured over several steps, each derived and written on its own, so memory holds
|
|
283
|
+
// one cap of units and that step's records, never the whole transcript.
|
|
284
|
+
const r = ad.read(store, key, sk, prior ? prior.position ?? null : null, known);
|
|
285
|
+
if (r.more) out.more = true;
|
|
286
|
+
let units = [];
|
|
287
|
+
let gone = r.gone || [];
|
|
288
|
+
let knownNext = { ...(known || {}) };
|
|
289
|
+
switch (r.continuity) {
|
|
290
|
+
case 'reset':
|
|
291
|
+
gen = prior ? gen + 1 : gen;
|
|
292
|
+
units = r.units; gone = [];
|
|
293
|
+
knownNext = {};
|
|
294
|
+
full = true;
|
|
295
|
+
for (const k of Object.keys(ords)) delete ords[k];
|
|
296
|
+
next = 0;
|
|
297
|
+
break;
|
|
298
|
+
case 'snapshot':
|
|
299
|
+
units = r.units; knownNext = {};
|
|
300
|
+
break;
|
|
301
|
+
case 'appended': case 'delta':
|
|
302
|
+
units = r.units;
|
|
303
|
+
break;
|
|
304
|
+
case 'unchanged':
|
|
305
|
+
break;
|
|
306
|
+
default: throw new Error(`${sk}: unknown continuity ${r.continuity}`);
|
|
307
|
+
}
|
|
308
|
+
for (const k of gone) delete knownNext[k];
|
|
309
|
+
for (const u of units) {
|
|
310
|
+
knownNext[u.unit_key] = u.sha256;
|
|
311
|
+
const stream = /^\d+$/.test(String(u.unit_key)) && src.mode === 'stream';
|
|
312
|
+
if (!stream && ords[u.unit_key] === undefined) ords[u.unit_key] = next++;
|
|
313
|
+
const pos = stream ? Number(u.unit_key) : ords[u.unit_key];
|
|
314
|
+
archive.push(...unitRows(key, sk, gen, pos, u, capturedAt));
|
|
315
|
+
}
|
|
316
|
+
const upto = r.position && Number.isFinite(Number(r.position.offset)) ? String(r.position.offset)
|
|
317
|
+
: String((prior ? Number(prior.upto) : 0) + (units.length || gone.length ? 1 : 0));
|
|
318
|
+
sources[sk] = { gen, upto, position: r.position ?? null, known: src.mode === 'stream' ? {} : knownNext, ords: src.mode === 'stream' ? {} : ords, role: src.role || 'main' };
|
|
319
|
+
if (units.length || gone.length) fresh.push({ source_key: sk, generation: gen, complete: false, units, gone });
|
|
320
|
+
}
|
|
321
|
+
out.archived = archive.length;
|
|
322
|
+
if (!full && !fresh.length) {
|
|
323
|
+
// Nothing new. Positions that moved without units (a held-back tail) are still kept.
|
|
324
|
+
if (local && Object.entries(sources).every(([sk, s]) => canon(s.position) === canon((local.sources[sk] || {}).position))) { out.skipped = true; return out; }
|
|
325
|
+
}
|
|
326
|
+
if (archive.length) await ctx.house.insert('records', archive);
|
|
327
|
+
|
|
328
|
+
// The input. A full refold takes every unit of every source's current generation from the archive.
|
|
329
|
+
let input = fresh;
|
|
330
|
+
if (full) {
|
|
331
|
+
input = [];
|
|
332
|
+
for (const [sk, s] of Object.entries(sources)) {
|
|
333
|
+
const units = unitsFromArchive(await ctx.house.archiveUnits(ref, sk, s.gen));
|
|
334
|
+
input.push({ source_key: sk, generation: s.gen, complete: true, units, gone: [] });
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
const res = ad.derive(key, input, full ? null : local.state);
|
|
338
|
+
const oldView = new Map(full ? [] : (local.view || []));
|
|
339
|
+
const view = applyDerive(oldView, res);
|
|
340
|
+
const firstSeen = { ...(local && local.firstSeen) };
|
|
341
|
+
// A refold reads what the house holds for the session once: first-seen times, the rev floor, and
|
|
342
|
+
// the keys to retire.
|
|
343
|
+
const cur = full ? await ctx.house.capturedRows(ref) : null;
|
|
344
|
+
if (cur) for (const r of cur.turns) if (!firstSeen[r.message_id]) firstSeen[r.message_id] = new Date(Date.parse(`${r.ts.replace(' ', 'T')}Z`)).toISOString();
|
|
345
|
+
const nowIso = new Date(ctx.now()).toISOString();
|
|
346
|
+
const tsOf = (id) => (firstSeen[id] || (firstSeen[id] = nowIso));
|
|
347
|
+
const mapped = (ctx.mapping || recordsToRows.rowsOf)([...view.values()], { tsOf });
|
|
348
|
+
// rev is at least last + 1 (harness-sessions-sql §7): the local state's last rev, or on a refold the house's
|
|
349
|
+
// greatest for the session, so a clock that went back (a restored VM) never writes below it.
|
|
350
|
+
let lastRev = BigInt(local ? local.lastRev : 0);
|
|
351
|
+
if (cur) for (const r of [...cur.turns, ...cur.calls]) if (r.rev != null && BigInt(r.rev) > lastRev) lastRev = BigInt(r.rev);
|
|
352
|
+
const rev = BigInt(ctx.now()) > lastRev ? BigInt(ctx.now()) : lastRev + 1n;
|
|
353
|
+
const adapterVer = verNumber(d.adapter_ver);
|
|
354
|
+
const rows = schema3Rows(mapped, key, { rev, adapterVer });
|
|
355
|
+
|
|
356
|
+
// What to write: rows whose digest moved; removed versions for keys the house holds and the view no longer makes.
|
|
357
|
+
const digests = { t: {}, c: {} };
|
|
358
|
+
for (const t of rows.turns) digests.t[base.turnKey(t)] = rowDigest(t);
|
|
359
|
+
for (const c of rows.calls) digests.c[base.callKey(c)] = rowDigest(c);
|
|
360
|
+
const old = local ? local.digests : null;
|
|
361
|
+
const writeT = rows.turns.filter((t) => !old || old.t[base.turnKey(t)] !== digests.t[base.turnKey(t)]);
|
|
362
|
+
const writeC = rows.calls.filter((c) => !old || old.c[base.callKey(c)] !== digests.c[base.callKey(c)]);
|
|
363
|
+
let goneT; let goneC;
|
|
364
|
+
if (old) {
|
|
365
|
+
goneT = Object.keys(old.t).filter((k) => !(k in digests.t));
|
|
366
|
+
goneC = Object.keys(old.c).filter((k) => !(k in digests.c));
|
|
367
|
+
} else {
|
|
368
|
+
// A held record (incomplete: neither written nor removed) is absent from a refold's view, so its
|
|
369
|
+
// rows are not "gone": they keep their last version (sessions §2).
|
|
370
|
+
const heldMsg = new Set((res.held && res.held.message) || []);
|
|
371
|
+
const heldUsage = new Set(((res.held && res.held.usage) || []).map((u) => `usage:${u}`));
|
|
372
|
+
const kept = (r) => { const id = String(r.message_id); return heldMsg.has(id) || heldUsage.has(id.replace(/~+$/, '')); };
|
|
373
|
+
goneT = cur.turns.filter((r) => !kept(r)).map(base.turnKey).filter((k) => !(k in digests.t));
|
|
374
|
+
goneC = cur.calls.filter((r) => !kept(r)).map(base.callKey).filter((k) => !(k in digests.c));
|
|
375
|
+
}
|
|
376
|
+
const tKeys = goneT.map((k) => { const [ts, message_id, part_id] = k.split('\u0000'); return { ts, message_id, part_id }; });
|
|
377
|
+
const cKeys = goneC.map((k) => { const [ts, call_id] = k.split('\u0000'); return { ts, call_id }; });
|
|
378
|
+
// A key the package made and no longer makes left the source: a removed version. A key the 0.20
|
|
379
|
+
// engine made (a session switching to the package) is a superseded duplicate, a tombstone, only
|
|
380
|
+
// when the view holds its content (twinsOf). Engine content the view lacks left the source before
|
|
381
|
+
// the switch, or is a kind the package does not make: kept as a removed version (sessions §4).
|
|
382
|
+
const twin = cur ? twinsOf(rows, cur, digests) : () => false;
|
|
383
|
+
const retire = (kind) => (r) => (!isPackageRow(r) && twin(kind, r) ? base.tombstoneOf(kind, r, rev) : base.removedOf(kind, r, rev));
|
|
384
|
+
const removedT = tKeys.length ? (await ctx.house.rowsByKey('turns', ref, tKeys)).map(retire('turns')) : [];
|
|
385
|
+
const removedC = cKeys.length ? (await ctx.house.rowsByKey('calls', ref, cKeys)).map(retire('calls')) : [];
|
|
386
|
+
if (writeT.length || removedT.length) await ctx.house.insert('turns', [...writeT, ...removedT]);
|
|
387
|
+
if (writeC.length || removedC.length) await ctx.house.insert('calls', [...writeC, ...removedC]);
|
|
388
|
+
out.turns = writeT.length; out.calls = writeC.length; out.refold = full; out.reDerive = reDerive;
|
|
389
|
+
out.removed = [...removedT, ...removedC].filter((r) => Number(r.removed)).length;
|
|
390
|
+
out.superseded = [...removedT, ...removedC].filter((r) => Number(r.is_deleted)).length;
|
|
391
|
+
|
|
392
|
+
// The sources' files rows: the session's totals ride on its first main source.
|
|
393
|
+
const contrib = contribOf(rows, mapped.meta);
|
|
394
|
+
const mainSk = Object.keys(sources).find((sk) => sources[sk].role === 'main') || Object.keys(sources)[0];
|
|
395
|
+
const empty = base.serializeContrib(base.newContrib());
|
|
396
|
+
const files = Object.entries(sources).map(([sk, s]) => {
|
|
397
|
+
const c = sk === mainSk ? contrib : empty;
|
|
398
|
+
const anchor = s.position && /^[0-9a-f]{64}$/.test(String(s.position.anchor_sha || '')) ? s.position.anchor_sha : sha1('').padEnd(64, '0');
|
|
399
|
+
return {
|
|
400
|
+
harness: key.harness, harness_path: key.harness_path, session_id: key.session_id, incarnation: key.incarnation || '',
|
|
401
|
+
file_key: sk, agent_id: '', role: s.role === 'agent' || s.role === 'side' ? s.role : 'main', agent_type: '', agent_desc: '',
|
|
402
|
+
fgen: s.gen, upto: s.upto, reparse_from: 0, derive_state: JSON.stringify({ position: s.position, known: s.known, ords: s.ords }),
|
|
403
|
+
adapter_ver: adapterVer, size: Number(s.position && s.position.offset) || 0, inode: Number(s.position && s.position.inode) || 0,
|
|
404
|
+
anchor_sha: anchor, prefix_sha: anchor, is_deleted: 0, contrib: JSON.stringify(c),
|
|
405
|
+
first_ts: c.first_ts || '1970-01-01 00:00:00.000', last_ts: c.last_ts || '1970-01-01 00:00:00.000', host: key.host,
|
|
406
|
+
};
|
|
407
|
+
});
|
|
408
|
+
// The 0.20 engine's files rows of this session (sources the package names otherwise) retire with
|
|
409
|
+
// their rows, or buildSession would add their totals to the package's.
|
|
410
|
+
const listedKeys = new Set(listed.sources.map((s) => s.source_key));
|
|
411
|
+
const SERVER = new Set(['ver', 'user_id']);
|
|
412
|
+
const oldFiles = [...H.values()].filter((h) => !listedKeys.has(h.file_key) && ['main', 'agent', 'side'].includes(h.role) && !Number(h.is_deleted) && !isPackageRow(h))
|
|
413
|
+
.map((h) => ({ ...Object.fromEntries(Object.entries(h).filter(([k]) => !SERVER.has(k))), is_deleted: 1, fgen: Number(h.fgen) + 1 }));
|
|
414
|
+
out.supersededFiles = oldFiles.length;
|
|
415
|
+
await ctx.house.insert('files', [...files, ...oldFiles]);
|
|
416
|
+
if (ctx.buildSession) {
|
|
417
|
+
const all = await ctx.house.filesRows([ref]);
|
|
418
|
+
await ctx.house.insert('sessions', [ctx.buildSession(key.harness, key.session_id, all, { host: key.host, hostLabel: ctx.hostLabel || '', harnessPath: key.harness_path, incarnation: key.incarnation })]);
|
|
419
|
+
}
|
|
420
|
+
if (res.checkpoint !== false) {
|
|
421
|
+
saveLocal(ctx.home, key, {
|
|
422
|
+
// The list hint is kept only once the last step is in: a pass that dies between steps must
|
|
423
|
+
// not skip the session next time.
|
|
424
|
+
adapter_ver: d.adapter_ver, state_ver: d.state_ver, hint: out.more ? null : canon(listed.updated_hint ?? null), state: res.state,
|
|
425
|
+
view: [...view.entries()], digests, firstSeen, lastRev: String(rev),
|
|
426
|
+
sources: Object.fromEntries(Object.entries(sources).map(([sk, s]) => [sk, { gen: s.gen, upto: s.upto, position: s.position, known: s.known, ords: s.ords }])),
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
return out;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* The sessions of `store` the house holds, as far as `adapter` goes, on the profile layout: every
|
|
434
|
+
* session whose cursor (session_sources) names a source of `adapter` that is neither deleted nor
|
|
435
|
+
* gone, and every session `adapter` archived manifests for that has no cursor row of `adapter` at all
|
|
436
|
+
* (an anti-join): a replay from the archive alone (harness-sessions-sql §4.1), however far an interrupted one
|
|
437
|
+
* got, and a session whose first capture stopped before its first cursor write. An archive-only
|
|
438
|
+
* session whose every source's newest generation is source_gone is `allGone`: replaying it reads
|
|
439
|
+
* nothing of the store. A pass hands each one `list` no longer returns to the session capture (THE
|
|
440
|
+
* VANISH SWEEP below). One round trip, ordered by session. Another layout's house holds no such
|
|
441
|
+
* cursor: none. Returns [{ session_id, incarnation, allGone }].
|
|
442
|
+
*/
|
|
443
|
+
async function heldSessions(house, store, adapter) {
|
|
444
|
+
const P = house && house.P;
|
|
445
|
+
if (!P || !P.session_sources || typeof house.rows !== 'function') return [];
|
|
446
|
+
const { lit } = require('../house/convert-v3');
|
|
447
|
+
const where = `(host, harness, harness_path) = (${[store.host, store.harness, store.harness_path].map(lit).join(', ')})`;
|
|
448
|
+
const mine = `(adapter = ${lit(adapter)} OR adapter = '')`;
|
|
449
|
+
const cursored = `SELECT session_id, incarnation, max(is_deleted = 0 AND ${mine} AND NOT JSONExtractBool(state, 'gone')) AS live, toUInt8(0) AS all_gone
|
|
450
|
+
FROM ${P.session_sources} FINAL WHERE ${where} AND role != 'legacy' GROUP BY session_id, incarnation`;
|
|
451
|
+
const gone = "JSONExtractBool(raw, 'source_gone')";
|
|
452
|
+
const archived = !P.session_units ? '' : `
|
|
453
|
+
UNION ALL
|
|
454
|
+
SELECT session_id, incarnation, toUInt8(1) AS live, min(src_gone) AS all_gone FROM (
|
|
455
|
+
SELECT session_id, incarnation, source_key, toUInt8(countIf(${gone}) > 0 AND maxIf(generation, ${gone}) = max(generation)) AS src_gone
|
|
456
|
+
FROM ${P.session_units}
|
|
457
|
+
WHERE ${where} AND unit_key = '' AND enc = 'manifest' AND adapter = ${lit(adapter)}
|
|
458
|
+
AND (session_id, incarnation) NOT IN (SELECT session_id, incarnation FROM ${P.session_sources} WHERE ${where} AND role != 'legacy' AND ${mine})
|
|
459
|
+
GROUP BY session_id, incarnation, source_key)
|
|
460
|
+
GROUP BY session_id, incarnation`;
|
|
461
|
+
const rows = await house.rows(`SELECT session_id, incarnation, max(live) AS live, max(all_gone) AS all_gone FROM (${cursored}${archived})
|
|
462
|
+
GROUP BY session_id, incarnation ORDER BY session_id, incarnation`);
|
|
463
|
+
return rows.filter((r) => Number(r.live)).map((r) => ({ session_id: String(r.session_id), incarnation: String(r.incarnation || ''), allGone: !!Number(r.all_gone) }));
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Capture every session of one harness store. A session whose `updated_hint` and adapter are what
|
|
468
|
+
* the local state last saw costs nothing past `list`. `ctx.captureSession` and `ctx.stateNs` swap
|
|
469
|
+
* in another layout's pass (profile-capture.js).
|
|
470
|
+
*
|
|
471
|
+
* THE VANISH SWEEP (each line is a decision)
|
|
472
|
+
* candidates a session the house holds (heldSessions) that `list` no longer returns. It is
|
|
473
|
+
* captured with no source listed, so the session capture reads each source it held
|
|
474
|
+
* and records it as vanished (profile-capture.js: a source_gone manifest with the
|
|
475
|
+
* policy it applies, harness-sessions-sql §4.1) only when the adapter answers it is not there
|
|
476
|
+
* (session_unknown, incarnation_gone, source_unknown); replaying an archive, a source
|
|
477
|
+
* already gone there replays as its manifests say. The house is read before this pass
|
|
478
|
+
* writes anything of the store
|
|
479
|
+
* confirmed by the adapter's own read, never by an absence: a list can come back empty or short
|
|
480
|
+
* without throwing (a folder briefly missing, a DB file being replaced, a scan race),
|
|
481
|
+
* and a vanish recorded from it re-archives the whole source in a new generation the
|
|
482
|
+
* moment it is listed again. A candidate that still reads (`stillThere`) or whose read
|
|
483
|
+
* fails (`candidateErrors`) is a refusal the pass reports. A listed session's source
|
|
484
|
+
* the list left out that still reads is read as listed and counted `unconfirmed`, a
|
|
485
|
+
* warning: an adapter may list a source only while it holds something (a sidebar row,
|
|
486
|
+
* a summaries table) and still read it as empty
|
|
487
|
+
* refused when `list` names none of the sessions the house holds: an empty or unrelated list is
|
|
488
|
+
* what a store that cannot be read looks like, and the adapter's read of that same store
|
|
489
|
+
* would agree with it. Nothing of the store is taken as vanished (`vanishRefused`). An
|
|
490
|
+
* archive-only session whose sources are all gone already is still replayed (it reads
|
|
491
|
+
* nothing of the store)
|
|
492
|
+
* confirmed a store whose every session the editor deleted lists nothing, and is refused on every
|
|
493
|
+
* empty pass; the refusal records which sessions it refused (the ids of the held sessions that
|
|
494
|
+
* are not all gone already, in the store's state: those replay under the refusal and
|
|
495
|
+
* need no confirmation). `memhouse doctor --confirm-empty-store <harness>:<store>`
|
|
496
|
+
* (confirmEmptyStore) writes a confirmation of exactly those, in a file of its own beside
|
|
497
|
+
* the store's state, which a pass never rewrites: the pass claims it by renaming it, and
|
|
498
|
+
* puts it back only where no newer one was written meanwhile. The next sweep that finds
|
|
499
|
+
* the list still empty, the store's folder (or file) there and readable, and no session
|
|
500
|
+
* held (all gone already aside) that was not confirmed lifts the refusal: the held
|
|
501
|
+
* sessions go through the session capture's typed-read confirmation as any candidate
|
|
502
|
+
* does, and vanish only on the adapter's answer. The confirmation is used up by that
|
|
503
|
+
* sweep, unless candidates wait past the cap, failed to read or were not reached (a
|
|
504
|
+
* stop): then it is put back as it is, and applies to what is left of its sessions. A
|
|
505
|
+
* list that names a held session again, or a session held that was not confirmed,
|
|
506
|
+
* discards it
|
|
507
|
+
* bridge never swept. The log adapter's stores (memhouse-bridge, harness-sessions-sql §4.1's exemption)
|
|
508
|
+
* hold logs the normaliser never deletes, so a log missing from its list is an error,
|
|
509
|
+
* and its read answers a missing log as an empty source, so it cannot confirm one
|
|
510
|
+
* gate the house is asked only when something can have left: no local store state yet (or
|
|
511
|
+
* another house's or adapter's), a session the last pass listed is not listed now, or
|
|
512
|
+
* the last sweep left something pending: a refusal, candidates past the cap, a candidate
|
|
513
|
+
* that still read, failed or was not reached (a stop), a sweep read that failed, or an
|
|
514
|
+
* empty-store confirmation waiting. A listed session's own error or refold does not
|
|
515
|
+
* reopen it, nor does a candidate's refold (its outcome is one of the above). A quiet pass costs
|
|
516
|
+
* nothing past `list`. The local store state ($MEMHOUSE_HOME/state/<ns>/stores/) is saved
|
|
517
|
+
* after every pass whose list answered. It is this home's: a house replaced under the
|
|
518
|
+
* same name while the home is kept is swept only once a listed session goes missing,
|
|
519
|
+
* or after `state/<ns>/stores/` alone is removed (each session's state stays)
|
|
520
|
+
* cap at most ctx.vanishCap (VANISH_CAP) candidates a store pass, in session order from
|
|
521
|
+
* where the last capped pass stopped (store state `after`), so candidates that keep
|
|
522
|
+
* failing do not starve the rest; the others wait for the next pass (`vanishWaiting`).
|
|
523
|
+
* The first pass after this lands meets every session the store deleted since profile
|
|
524
|
+
* capture began, and each candidate costs a read
|
|
525
|
+
*/
|
|
526
|
+
const VANISH_CAP = 100;
|
|
527
|
+
const storeIdOf = (store) => [store.host, store.harness, store.harness_path].join('\u0000');
|
|
528
|
+
const storeStatePath = (home, store, ns) => path.join(home, 'state', ns, 'stores', `${sha1(storeIdOf(store))}.json`);
|
|
529
|
+
function loadStoreState(home, store, ns) {
|
|
530
|
+
try {
|
|
531
|
+
const s = JSON.parse(fs.readFileSync(storeStatePath(home, store, ns), 'utf-8'));
|
|
532
|
+
return s && s.v === 1 && s.id === storeIdOf(store) && Array.isArray(s.listed) ? s : null;
|
|
533
|
+
} catch { return null; }
|
|
534
|
+
}
|
|
535
|
+
/** Whether a store's folder (or file) is there and can be read: a confirmed empty store must be. */
|
|
536
|
+
function storeReadable(store) {
|
|
537
|
+
try {
|
|
538
|
+
const st = fs.statSync(store.harness_path);
|
|
539
|
+
if (st.isDirectory()) fs.readdirSync(store.harness_path); else fs.accessSync(store.harness_path, fs.constants.R_OK);
|
|
540
|
+
return true;
|
|
541
|
+
} catch { return false; }
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* The sessions a refusal refused, as a confirmation names them: the ids of the held sessions that are
|
|
545
|
+
* not all gone already (those replay under the refusal and need none), sorted. A confirmation applies
|
|
546
|
+
* while every such session held is one it names, however many of them have gone since.
|
|
547
|
+
*/
|
|
548
|
+
const refusedMark = (held) => { const ids = held.filter((h) => !h.allGone).map(idOfHeld).sort(); return { n: ids.length, ids }; };
|
|
549
|
+
const idOfHeld = (h) => `${h.session_id}\u0000${h.incarnation ?? ''}`;
|
|
550
|
+
const confirms = (confirm, held) => {
|
|
551
|
+
if (!Array.isArray(confirm.ids)) return false;
|
|
552
|
+
const named = new Set(confirm.ids);
|
|
553
|
+
return held.every((h) => h.allGone || named.has(idOfHeld(h)));
|
|
554
|
+
};
|
|
555
|
+
const confirmPath = (home, store, ns) => storeStatePath(home, store, ns).replace(/\.json$/, '.confirm.json');
|
|
556
|
+
const claimedPath = (home, store, ns) => storeStatePath(home, store, ns).replace(/\.json$/, '.confirm.claimed');
|
|
557
|
+
/** The empty-store confirmation waiting for `store`, claimed by a pass or not: what doctor wrote, or null. */
|
|
558
|
+
function readConfirmation(home, store, ns) {
|
|
559
|
+
for (const f of [confirmPath(home, store, ns), claimedPath(home, store, ns)]) {
|
|
560
|
+
try { const c = JSON.parse(fs.readFileSync(f, 'utf-8')); if (c && c.v === 1 && c.id === storeIdOf(store)) return c; } catch { /* none */ }
|
|
561
|
+
}
|
|
562
|
+
return null;
|
|
563
|
+
}
|
|
564
|
+
/**
|
|
565
|
+
* `memhouse doctor --confirm-empty-store <harness>:<store>`: a confirmation that the sessions the last
|
|
566
|
+
* pass refused of that store were deleted (THE VANISH SWEEP: confirmed empty), written for every store
|
|
567
|
+
* of this home that matches. Returns { confirmed: [{ harness, harness_path, n }], notRefused: [...] }:
|
|
568
|
+
* a store whose last pass did not refuse it as empty has nothing to confirm.
|
|
569
|
+
*/
|
|
570
|
+
function confirmEmptyStore(home, ns, harness, harnessPath, at = Date.now()) {
|
|
571
|
+
const dir = path.join(home, 'state', ns, 'stores');
|
|
572
|
+
const out = { confirmed: [], notRefused: [] };
|
|
573
|
+
let names = [];
|
|
574
|
+
try { names = fs.readdirSync(dir).filter((x) => /^[0-9a-f]{40}\.json$/.test(x)); } catch { return out; }
|
|
575
|
+
for (const x of names) {
|
|
576
|
+
let s;
|
|
577
|
+
try { s = JSON.parse(fs.readFileSync(path.join(dir, x), 'utf-8')); } catch { continue; }
|
|
578
|
+
const [host, h, hp] = String((s && s.id) || '').split('\u0000');
|
|
579
|
+
if (!s || s.v !== 1 || h !== harness || hp !== harnessPath) continue;
|
|
580
|
+
const store = { host, harness: h, harness_path: hp };
|
|
581
|
+
if (!s.refused) { out.notRefused.push({ harness: h, harness_path: hp }); continue; }
|
|
582
|
+
const f = confirmPath(home, store, ns);
|
|
583
|
+
const tmp = `${f}.${process.pid}.tmp`;
|
|
584
|
+
fs.writeFileSync(tmp, JSON.stringify({ v: 1, id: storeIdOf(store), at, n: s.refused.n, ids: s.refused.ids || [] }), { mode: 0o600 });
|
|
585
|
+
fs.renameSync(tmp, f);
|
|
586
|
+
out.confirmed.push({ harness: h, harness_path: hp, n: s.refused.n });
|
|
587
|
+
}
|
|
588
|
+
return out;
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* Write `c` to `to` unless a file is there (a newer confirmation doctor wrote): a hard link of a whole
|
|
592
|
+
* written file where the filesystem has them, else an exclusive create, which refuses in the same
|
|
593
|
+
* case. EEXIST is the newer one; any other link error (EPERM, ENOTSUP, EXDEV, ...: exFAT, some SMB or
|
|
594
|
+
* FUSE mounts) is only a filesystem without links. Returns 'written', 'newer' or 'failed'.
|
|
595
|
+
*/
|
|
596
|
+
function putBack(beside, to, c) {
|
|
597
|
+
const body = JSON.stringify(c);
|
|
598
|
+
const tmp = `${beside}.${process.pid}.tmp`;
|
|
599
|
+
try {
|
|
600
|
+
fs.writeFileSync(tmp, body, { mode: 0o600 });
|
|
601
|
+
try { fs.linkSync(tmp, to); return 'written'; } catch (e) {
|
|
602
|
+
if (e && e.code === 'EEXIST') return 'newer';
|
|
603
|
+
try { fs.writeFileSync(to, body, { mode: 0o600, flag: 'wx' }); return 'written'; } catch (e2) { return e2 && e2.code === 'EEXIST' ? 'newer' : 'failed'; }
|
|
604
|
+
}
|
|
605
|
+
} catch { return 'failed'; } finally { try { fs.rmSync(tmp, { force: true }); } catch { /* gone */ } }
|
|
606
|
+
}
|
|
607
|
+
function saveStoreState(home, store, ns, s) {
|
|
608
|
+
const f = storeStatePath(home, store, ns);
|
|
609
|
+
fs.mkdirSync(path.dirname(f), { recursive: true });
|
|
610
|
+
const tmp = `${f}.${process.pid}.tmp`;
|
|
611
|
+
fs.writeFileSync(tmp, JSON.stringify({ ...s, v: 1, id: storeIdOf(store) }), { mode: 0o600 });
|
|
612
|
+
fs.renameSync(tmp, f);
|
|
613
|
+
}
|
|
614
|
+
async function captureStore(ctx, ad, store, { skip = null } = {}) {
|
|
615
|
+
const session = ctx.captureSession || captureSession;
|
|
616
|
+
const d = ad.describe();
|
|
617
|
+
const st = { harness: store.harness, harness_path: store.harness_path, sessions: 0, captured: 0, skipped: 0, kept: 0, vanished: 0, unlisted: 0, unconfirmed: 0, stillThere: 0, candidateErrors: 0, vanishRefused: null, vanishWaiting: 0,
|
|
618
|
+
steps: 0, refolds: 0, turns: 0, calls: 0, rows: 0, removed: 0, archived: 0, errors: [], refs: [] };
|
|
619
|
+
// A store whose list throws (an unreadable folder) reports it and captures nothing; the next stores go on.
|
|
620
|
+
// An adapter may name entries it had to leave out (list `errors`, as the log adapter does for a log
|
|
621
|
+
// it cannot stat): reported, and the rest captured.
|
|
622
|
+
let list;
|
|
623
|
+
try {
|
|
624
|
+
const got = ad.list(store);
|
|
625
|
+
list = [...got];
|
|
626
|
+
for (const x of (got && Array.isArray(got.errors) ? got.errors : [])) st.errors.push({ code: x.code, error: `list: ${x.error}` });
|
|
627
|
+
} catch (e) { st.errors.push({ code: e && e.code, error: `list: ${String(e && e.message).split('\n')[0]}` }); return st; }
|
|
628
|
+
// The vanish sweep (above). A house that cannot say what it holds reports it; the listed sessions
|
|
629
|
+
// are still captured.
|
|
630
|
+
const idOf = (x) => `${x.session_id}\u0000${x.incarnation ?? ''}`;
|
|
631
|
+
const adapter = String(d.adapter || d.harness || store.harness);
|
|
632
|
+
const P = ctx.house && ctx.house.P;
|
|
633
|
+
const sweeps = adapter !== BRIDGE && !!P && !!P.session_sources && typeof ctx.house.rows === 'function';
|
|
634
|
+
const ns = ctx.stateNs || 'derive';
|
|
635
|
+
const listedNow = list.map(idOf);
|
|
636
|
+
let prev = null; let confirm = null; let after = null; let refused = null;
|
|
637
|
+
if (sweeps) {
|
|
638
|
+
prev = ctx.home ? loadStoreState(ctx.home, store, ns) : null;
|
|
639
|
+
// A confirmation doctor wrote (header: confirmed empty), claimed by this pass.
|
|
640
|
+
if (ctx.home) {
|
|
641
|
+
try { fs.renameSync(confirmPath(ctx.home, store, ns), claimedPath(ctx.home, store, ns)); } catch { /* none written since */ }
|
|
642
|
+
confirm = readConfirmation(ctx.home, store, ns);
|
|
643
|
+
}
|
|
644
|
+
const now = new Set(listedNow);
|
|
645
|
+
st.swept = !prev || prev.house !== P.session_sources || prev.adapter !== adapter || !!prev.pending || prev.listed.some((id) => !now.has(id)) || !!confirm;
|
|
646
|
+
if (st.swept) {
|
|
647
|
+
try {
|
|
648
|
+
const held = await heldSessions(ctx.house, store, adapter);
|
|
649
|
+
let unlisted = held.filter((h) => !now.has(idOf(h)));
|
|
650
|
+
const namesNone = unlisted.length > 0 && unlisted.length === held.length;
|
|
651
|
+
// A confirmed empty store (header): a list that names a held session again, or other sessions
|
|
652
|
+
// held than were confirmed, discards it.
|
|
653
|
+
if (confirm && (!namesNone || (!list.length && !confirms(confirm, held)))) {
|
|
654
|
+
st.confirmDiscarded = !namesNone ? (held.length ? 'its list names a session again' : 'the house holds nothing of it any more') : `it holds a session the confirmation of ${confirm.n} does not name`;
|
|
655
|
+
confirm = null;
|
|
656
|
+
}
|
|
657
|
+
if (namesNone && !(confirm && !list.length && storeReadable(store))) {
|
|
658
|
+
st.vanishRefused = `its list names none of the ${held.length} session${held.length === 1 ? '' : 's'} the house holds of it${list.length ? '' : ' (the list is empty)'}`;
|
|
659
|
+
st.emptyStore = !list.length;
|
|
660
|
+
if (st.emptyStore) refused = refusedMark(held);
|
|
661
|
+
// Refused, an archive-only session whose sources are all gone already is still replayed.
|
|
662
|
+
unlisted = unlisted.filter((h) => h.allGone);
|
|
663
|
+
} else if (namesNone) st.confirmedEmpty = true;
|
|
664
|
+
const cap = Number.isFinite(ctx.vanishCap) ? ctx.vanishCap : VANISH_CAP;
|
|
665
|
+
// In session order, from where the last capped pass stopped (header: cap).
|
|
666
|
+
unlisted.sort((x, y) => (idOf(x) < idOf(y) ? -1 : idOf(x) > idOf(y) ? 1 : 0));
|
|
667
|
+
const from = prev && prev.after != null && unlisted.length > cap ? unlisted.findIndex((h) => idOf(h) > prev.after) : -1;
|
|
668
|
+
if (from > 0) unlisted = [...unlisted.slice(from), ...unlisted.slice(0, from)];
|
|
669
|
+
const hand = unlisted.slice(0, cap);
|
|
670
|
+
st.unlisted = hand.length;
|
|
671
|
+
st.vanishWaiting = unlisted.length - hand.length;
|
|
672
|
+
after = st.vanishWaiting ? idOf(hand[hand.length - 1]) : null;
|
|
673
|
+
list.push(...hand.map((h) => ({ session_id: h.session_id, incarnation: h.incarnation, updated_hint: null, sources: [], unlisted: true })));
|
|
674
|
+
} catch (e) { st.sweepFailed = true; st.errors.push({ code: e && e.code, error: `vanished: ${String(e && e.message).split('\n')[0]}` }); }
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
const keyOf = (listed) => ({ ...store, session_id: listed.session_id, incarnation: listed.incarnation ?? '' });
|
|
678
|
+
const unchanged = (key, listed) => {
|
|
679
|
+
const local = loadLocal(ctx.home, key, ctx.stateNs);
|
|
680
|
+
return !!local && local.adapter_ver === d.adapter_ver && local.state_ver === d.state_ver && local.hint === canon(listed.updated_hint ?? null) && listed.updated_hint != null
|
|
681
|
+
// A store whose rows have a mapping version of their own (profile-capture's ROWS_VER): a state it moved past is read again.
|
|
682
|
+
&& (ctx.rowsVer == null || (local.rows_ver || 1) === ctx.rowsVer)
|
|
683
|
+
// ...and one whose engine edges have a version of their own (profile-capture's EDGES_VER).
|
|
684
|
+
&& (ctx.edgesVer == null || (local.edges_ver || 0) === ctx.edgesVer);
|
|
685
|
+
};
|
|
686
|
+
// The sessions to read are taken in groups: a group's first reads are made together (ctx.prefetch).
|
|
687
|
+
const GROUP = 50;
|
|
688
|
+
// A pass whose lock was taken over (capture.js passLock) stops before its next session.
|
|
689
|
+
const stopped = () => !!(ctx.stopped && ctx.stopped());
|
|
690
|
+
for (let g = 0; g < list.length && !st.stopped; g += GROUP) {
|
|
691
|
+
if (ctx.prefetch && !stopped()) {
|
|
692
|
+
const group = list.slice(g, g + GROUP).filter((listed) => { const key = keyOf(listed); return !(skip && skip(key, listed)) && !unchanged(key, listed); });
|
|
693
|
+
try { if (group.length) await ctx.prefetch(ctx, d, store, group); } catch { ctx.pre = null; /* each session reads its own */ }
|
|
694
|
+
}
|
|
695
|
+
for (const listed of list.slice(g, g + GROUP)) {
|
|
696
|
+
if (st.stopped || stopped()) { st.stopped = true; break; }
|
|
697
|
+
st.sessions++;
|
|
698
|
+
const key = keyOf(listed);
|
|
699
|
+
// A session another path keeps (capture.js: too big at first sight).
|
|
700
|
+
if (skip && skip(key, listed)) { st.kept++; continue; }
|
|
701
|
+
if (unchanged(key, listed)) { st.skipped++; continue; }
|
|
702
|
+
try {
|
|
703
|
+
let r; let first = true; let any = false; let gone = 0; let unconfirmed = 0;
|
|
704
|
+
do {
|
|
705
|
+
r = await session(ctx, ad, d, store, listed);
|
|
706
|
+
st.steps++;
|
|
707
|
+
gone += r.gone || 0; unconfirmed += r.unconfirmed || 0;
|
|
708
|
+
if (r.skipped) break;
|
|
709
|
+
any = true;
|
|
710
|
+
if (first) st.refolds += r.refold ? 1 : 0;
|
|
711
|
+
first = false;
|
|
712
|
+
st.turns += r.turns || 0; st.calls += r.calls || 0; st.rows += r.rows || 0; st.removed += r.removed; st.archived += r.archived;
|
|
713
|
+
// Between steps too: a session's next step is the new holder's (its local state is saved with
|
|
714
|
+
// no hint, so the next pass goes on from the step this one finished).
|
|
715
|
+
if (r.more && stopped()) { st.stopped = true; break; }
|
|
716
|
+
} while (r.more);
|
|
717
|
+
// A candidate is vanished when the adapter answered for every source it held; one that still
|
|
718
|
+
// reads was left out by a short list (a refusal the pass reports). A listed session's source the
|
|
719
|
+
// list left out that still reads is a warning (header: confirmed).
|
|
720
|
+
if (listed.unlisted) { if (unconfirmed) st.stillThere++; else if (gone) st.vanished++; } else st.unconfirmed += unconfirmed;
|
|
721
|
+
if (st.stopped) { st.more = 1; break; }
|
|
722
|
+
if (!any) { st.skipped++; continue; }
|
|
723
|
+
st.captured++;
|
|
724
|
+
st.refs.push({ harness: key.harness, host: key.host, harness_path: key.harness_path, session_id: key.session_id, incarnation: key.incarnation });
|
|
725
|
+
} catch (e) {
|
|
726
|
+
st.errors.push({ session_id: listed.session_id, code: e && e.code, error: String(e && e.message).split('\n')[0] });
|
|
727
|
+
if (listed.unlisted) st.candidateErrors++;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
ctx.pre = null;
|
|
731
|
+
}
|
|
732
|
+
// What a stopped pass left: the session it stopped in, and the ones it did not reach (some of
|
|
733
|
+
// which may have had nothing new).
|
|
734
|
+
if (st.stopped) st.more = (st.more || 0) + list.length - st.sessions;
|
|
735
|
+
// The sweep's gate for the next pass (THE VANISH SWEEP: gate).
|
|
736
|
+
if (sweeps && ctx.home) {
|
|
737
|
+
// Pending: only what the sweep itself left (header: gate), never a listed session's error or refold.
|
|
738
|
+
const reached = st.sessions - (list.length - st.unlisted);
|
|
739
|
+
const pending = !!(st.vanishRefused || st.vanishWaiting || st.stillThere || st.candidateErrors || st.sweepFailed
|
|
740
|
+
|| (st.stopped && st.unlisted && reached < st.unlisted));
|
|
741
|
+
try { saveStoreState(ctx.home, store, ns, { house: P.session_sources, adapter, listed: [...new Set(listedNow)].sort(), pending, ...(after != null ? { after } : {}), ...(refused ? { refused } : {}) }); }
|
|
742
|
+
catch { try { fs.rmSync(storeStatePath(ctx.home, store, ns), { force: true }); } catch { /* the next pass asks the house */ } }
|
|
743
|
+
// The claimed confirmation (header: confirmed empty): used up, or put back as it is, for what is left
|
|
744
|
+
// of its sessions (candidates waiting, failed or not reached) or while it did not apply (the
|
|
745
|
+
// store's folder away, a list that names nothing held). Put back only where doctor wrote none
|
|
746
|
+
// meanwhile: a newer one wins, and applies as any does.
|
|
747
|
+
const claimed = claimedPath(ctx.home, store, ns);
|
|
748
|
+
const back = confirm && (!st.confirmedEmpty || st.vanishWaiting || st.candidateErrors || st.stopped) ? confirm : null;
|
|
749
|
+
if (back) putBack(claimed, confirmPath(ctx.home, store, ns), back);
|
|
750
|
+
try { fs.rmSync(claimed, { force: true }); } catch { /* none */ }
|
|
751
|
+
}
|
|
752
|
+
return st;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
module.exports = { PACKAGE_RENAMED, PACKAGE_UNLOADABLE, OLD_ADAPTER_PREFIX, packageMessage, packageVersion, PACKAGE_VER_FLOOR, isPackageRow, loadPackage, loadLocal, saveLocal, rowDigest, captureStore, captureSession, heldSessions, confirmEmptyStore, readConfirmation, loadStoreState, storeReadable, applyDerive, recordKey, canon, verNumber, unitsFromArchive, unitRows, schema3Rows, statePath, packageStatus, isPackageMissing, PACKAGE_MISSING,
|
|
756
|
+
};
|