@yeaft/webchat-agent 1.0.641 → 1.0.643
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/connection/message-router.js +4 -1
- package/index.js +1 -1
- package/local-runtime/server/handlers/agent-output.js +7 -5
- package/local-runtime/server/handlers/client-conversation.js +47 -0
- package/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +191 -133
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/index.html +2 -2
- package/local-runtime/web/style.bundle.css +1 -1
- package/local-runtime/web/style.bundle.css.gz +0 -0
- package/package.json +1 -1
- package/yeaft/conversation/persist.js +272 -24
- package/yeaft/conversation/session-history.js +336 -0
- package/yeaft/engine.js +28 -18
- package/yeaft/routing/router.js +4 -0
- package/yeaft/sessions/index.js +1 -0
- package/yeaft/sessions/session-crud.js +68 -3
- package/yeaft/tools/agent.js +4 -1
- package/yeaft/web-bridge.js +255 -54
|
Binary file
|
package/package.json
CHANGED
|
@@ -31,8 +31,9 @@ import {
|
|
|
31
31
|
iterateCanonicalVisibleEntriesNewestFirst,
|
|
32
32
|
normalizeLiteralSearch,
|
|
33
33
|
} from './visible-entry.js';
|
|
34
|
-
import { copyPersistedSessionAttachments } from '../attachments.js';
|
|
34
|
+
import { copyPersistedSessionAttachments, readPersistedAttachmentFile, persistYeaftAttachments, MAX_TOTAL_BYTES } from '../attachments.js';
|
|
35
35
|
import { markConversationDirty } from './history-index-state.js';
|
|
36
|
+
import { historyError, isHumanRequest, messageOrigin, historicalReplayRow, planSessionMerge, materializeSessionMerge } from './session-history.js';
|
|
36
37
|
|
|
37
38
|
/**
|
|
38
39
|
* Default cold-start "recent window" size, expressed in TURNS (not raw
|
|
@@ -92,6 +93,49 @@ const MESSAGE_SEQUENCE_RESERVATION_SIZE = 256;
|
|
|
92
93
|
// by this process, so long-lived stores cannot diverge or reuse one another's ids.
|
|
93
94
|
const messageSequenceReservations = new Map();
|
|
94
95
|
|
|
96
|
+
/** Validate every shared local blob before choosing a source; metadata is not content identity. */
|
|
97
|
+
function copyMergeAttachments(plan, yeaftDir, targetSessionId) {
|
|
98
|
+
const copiedPaths = new Map();
|
|
99
|
+
for (const turn of plan.turns) {
|
|
100
|
+
const alternatives = [...plan.aliases].filter(([, source]) => source === turn).map(([other]) => other);
|
|
101
|
+
turn.rows = turn.rows.map((row, index) => {
|
|
102
|
+
if (!Array.isArray(row.attachments)) return row;
|
|
103
|
+
return { ...row, attachments: row.attachments.map((att, attachmentIndex) => {
|
|
104
|
+
// Server-owned IDs are metadata, not permission to read another owner.
|
|
105
|
+
const candidates = [turn, ...alternatives];
|
|
106
|
+
if (!candidates.some(candidate => candidate.rows[index].attachments[attachmentIndex]?.path)) return att;
|
|
107
|
+
let chosen = null;
|
|
108
|
+
for (const candidate of candidates) {
|
|
109
|
+
const source = candidate.rows[index].attachments[attachmentIndex];
|
|
110
|
+
let payload;
|
|
111
|
+
try {
|
|
112
|
+
payload = readPersistedAttachmentFile(source.path, {
|
|
113
|
+
yeaftDir, sessionId: candidate.input.sessionId,
|
|
114
|
+
}, MAX_TOTAL_BYTES);
|
|
115
|
+
} catch { continue; } // Another verified alias may still own the blob.
|
|
116
|
+
if (chosen && !chosen.buffer.equals(payload.buffer)) {
|
|
117
|
+
throw historyError('merge_attachment_conflict', `Shared attachment has different bytes: ${att.name || 'file'}`);
|
|
118
|
+
}
|
|
119
|
+
chosen ||= payload;
|
|
120
|
+
}
|
|
121
|
+
if (!chosen) {
|
|
122
|
+
throw historyError('merge_attachment_unavailable', `Required attachment is missing or inaccessible: ${att.name || 'file'}`);
|
|
123
|
+
}
|
|
124
|
+
if (!copiedPaths.has(chosen.path)) {
|
|
125
|
+
const bundle = persistYeaftAttachments([{ ...att, data: chosen.buffer.toString('base64') }], {
|
|
126
|
+
yeaftDir, sessionId: targetSessionId,
|
|
127
|
+
});
|
|
128
|
+
if (bundle.failed.length || bundle.promptAttachments.length !== 1) {
|
|
129
|
+
throw historyError('merge_attachment_copy_failed', `Session attachment copy failed: ${bundle.failed[0]?.error || 'missing output'}`);
|
|
130
|
+
}
|
|
131
|
+
copiedPaths.set(chosen.path, bundle.promptAttachments[0].path);
|
|
132
|
+
}
|
|
133
|
+
return { ...att, path: copiedPaths.get(chosen.path) };
|
|
134
|
+
}) };
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
95
139
|
function sleepSync(ms) {
|
|
96
140
|
if (ms <= 0) return;
|
|
97
141
|
const buffer = new Int32Array(new SharedArrayBuffer(4));
|
|
@@ -1544,7 +1588,9 @@ export class ConversationStore {
|
|
|
1544
1588
|
};
|
|
1545
1589
|
|
|
1546
1590
|
try {
|
|
1547
|
-
this.#segmentStoreFor(fullMsg, { create: true })
|
|
1591
|
+
const segment = this.#segmentStoreFor(fullMsg, { create: true });
|
|
1592
|
+
if (fullMsg.sessionId) fullMsg.origin = messageOrigin(fullMsg, fullMsg.sessionId, segment.metadata().streamId);
|
|
1593
|
+
segment.append(fullMsg);
|
|
1548
1594
|
} catch (err) {
|
|
1549
1595
|
if (!strict && isPermissionError(err)) {
|
|
1550
1596
|
if (!_permissionWarned) {
|
|
@@ -1611,7 +1657,12 @@ export class ConversationStore {
|
|
|
1611
1657
|
|
|
1612
1658
|
for (const batch of batches.values()) {
|
|
1613
1659
|
try {
|
|
1614
|
-
this.#segmentStoreFor(batch[0], { create: true })
|
|
1660
|
+
const segment = this.#segmentStoreFor(batch[0], { create: true });
|
|
1661
|
+
if (batch[0].sessionId) {
|
|
1662
|
+
const { streamId } = segment.metadata();
|
|
1663
|
+
for (const row of batch) row.origin = messageOrigin(row, row.sessionId, streamId);
|
|
1664
|
+
}
|
|
1665
|
+
segment.appendBatch(batch);
|
|
1615
1666
|
} catch (err) {
|
|
1616
1667
|
if (isPermissionError(err)) {
|
|
1617
1668
|
if (!_permissionWarned) {
|
|
@@ -1975,6 +2026,61 @@ export class ConversationStore {
|
|
|
1975
2026
|
return this.loadRecentBySession(sessionId, Infinity);
|
|
1976
2027
|
}
|
|
1977
2028
|
|
|
2029
|
+
/**
|
|
2030
|
+
* 同步捕获完整 durable raw transcript 的不透明、可 JSON 序列化前缀边界。
|
|
2031
|
+
* 包含 cold、legacy、internal 和 folded source rows;空历史也返回显式边界。
|
|
2032
|
+
* 调用者须在输入 enqueue 的第一个 await 前捕获,并在整个活动生命周期
|
|
2033
|
+
* 保留最早边界。捕获不代表请求完成;Store 不判断 runtime activity。
|
|
2034
|
+
* copySession 验证前缀未被替换、删改或插入;仅追加新行不会使边界失效。
|
|
2035
|
+
*
|
|
2036
|
+
* @param {string} sessionId Agent-owned Session identity
|
|
2037
|
+
* @returns {object} Opaque serializable boundary for options.throughBoundary
|
|
2038
|
+
* @throws {Error} invalid_fork_boundary for a missing Session identity
|
|
2039
|
+
*/
|
|
2040
|
+
captureSessionCopyBoundary(sessionId) {
|
|
2041
|
+
if (typeof sessionId !== 'string' || !sessionId) {
|
|
2042
|
+
throw historyError('invalid_fork_boundary', 'Session identity is required');
|
|
2043
|
+
}
|
|
2044
|
+
const { rows, streamId } = this.#loadDurableSession(sessionId);
|
|
2045
|
+
return {
|
|
2046
|
+
version: 1,
|
|
2047
|
+
sessionId,
|
|
2048
|
+
streamId,
|
|
2049
|
+
rowCount: rows.length,
|
|
2050
|
+
lastMessageId: rows.at(-1)?.id ?? null,
|
|
2051
|
+
digest: this.#sessionCopyPrefixDigest(rows),
|
|
2052
|
+
};
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
#sessionCopyPrefixDigest(rows) {
|
|
2056
|
+
const hash = createHash('sha256');
|
|
2057
|
+
for (const row of rows) hash.update(JSON.stringify(row)).update('\n');
|
|
2058
|
+
return hash.digest('hex');
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
/**
|
|
2062
|
+
* Compatibility fence reader. New runtime callers should use the opaque
|
|
2063
|
+
* captureSessionCopyBoundary / throughBoundary API instead.
|
|
2064
|
+
* Capture the newest durable raw row synchronously, before routing/input awaits.
|
|
2065
|
+
* Includes cold, internal and folded source rows, not just visible history.
|
|
2066
|
+
* A caller may retain this fence across awaits and pass its messageId (or null
|
|
2067
|
+
* for an empty Session) to copySession's inclusive throughMessageId option.
|
|
2068
|
+
* Active-input exclusion is the caller's responsibility: capture before enqueue
|
|
2069
|
+
* and clamp to the fence preceding the earliest still-active input envelope.
|
|
2070
|
+
*
|
|
2071
|
+
* @param {string} sessionId Agent-owned Session identity
|
|
2072
|
+
* @returns {{messageId: string, seq: number}|null} Newest raw row, or null if empty
|
|
2073
|
+
*/
|
|
2074
|
+
getSessionCopyFence(sessionId) {
|
|
2075
|
+
if (!sessionId) return null;
|
|
2076
|
+
const primary = this.#segmentStoreForConversationDir(this.#sessionConversationDir(sessionId));
|
|
2077
|
+
if (!primary.hasData() && this.#sessionFileEntries('all', sessionId).length === 0) return null;
|
|
2078
|
+
for (const row of this.#iterateSessionRows(sessionId, { desc: true, projection: 'transcript' })) {
|
|
2079
|
+
if (row?.sessionId === sessionId) return { messageId: row.id, seq: parseSeqFromId(row.id) };
|
|
2080
|
+
}
|
|
2081
|
+
return null;
|
|
2082
|
+
}
|
|
2083
|
+
|
|
1978
2084
|
/**
|
|
1979
2085
|
* Copy the complete durable transcript to a new Session identity.
|
|
1980
2086
|
*
|
|
@@ -1982,36 +2088,89 @@ export class ConversationStore {
|
|
|
1982
2088
|
* reflections, and the rows hidden by reflection tombstones. New persisted
|
|
1983
2089
|
* message ids are allocated in original order. References that point to a
|
|
1984
2090
|
* copied persisted message are remapped; external/client/tool identities are
|
|
1985
|
-
* intentionally preserved.
|
|
2091
|
+
* intentionally preserved. Provider-native continuation/thinking payloads
|
|
2092
|
+
* stay in the source transcript; the target replays canonical text/tools,
|
|
2093
|
+
* with origin linking back to the untouched provider provenance.
|
|
1986
2094
|
*
|
|
1987
2095
|
* @param {string} sourceSessionId
|
|
1988
2096
|
* @param {string} targetSessionId
|
|
1989
|
-
* @param {{throughTurnId?: string}} [options]
|
|
2097
|
+
* @param {{throughBoundary?: object, throughTurnId?: string, throughMessageId?: string|null, beforeMessageId?: string}} [options]
|
|
2098
|
+
* throughBoundary 是 captureSessionCopyBoundary 返回的原始前缀边界;可跨
|
|
2099
|
+
* await/重启传递但不能手工构造。显式空边界复制空历史。组合 throughTurnId
|
|
2100
|
+
* 时,其完整历史 endpoint 必须在边界之内,否则 incomplete_fork_boundary;
|
|
2101
|
+
* 不会把活动 turn 截短伪装成完整 turn。前缀删改拒绝为 invalid_fork_boundary。
|
|
2102
|
+
* 兼容旧 inclusive throughMessageId(null 为空)和 exclusive beforeMessageId;
|
|
2103
|
+
* 所有已提供边界均验证,取较早 cutoff。runtime 负责在最早活动输入前捕获。
|
|
1990
2104
|
* @returns {{ copiedCount: number, idMap: Map<string, string> }}
|
|
1991
2105
|
*/
|
|
1992
2106
|
copySession(sourceSessionId, targetSessionId, options = {}) {
|
|
1993
2107
|
if (!sourceSessionId || !targetSessionId || sourceSessionId === targetSessionId) {
|
|
1994
2108
|
return { copiedCount: 0, idMap: new Map() };
|
|
1995
2109
|
}
|
|
1996
|
-
const
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2110
|
+
const { rows: durableRows, streamId } = this.#loadDurableSession(sourceSessionId);
|
|
2111
|
+
let rows = durableRows;
|
|
2112
|
+
let cutoff = rows.length;
|
|
2113
|
+
let capturedCutoff = null;
|
|
2114
|
+
if (Object.prototype.hasOwnProperty.call(options, 'throughBoundary')) {
|
|
2115
|
+
const boundary = options.throughBoundary;
|
|
2116
|
+
if (!boundary || typeof boundary !== 'object' || boundary.version !== 1
|
|
2117
|
+
|| boundary.sessionId !== sourceSessionId
|
|
2118
|
+
|| (boundary.rowCount !== 0 && boundary.streamId !== streamId)
|
|
2119
|
+
|| !Number.isSafeInteger(boundary.rowCount) || boundary.rowCount < 0
|
|
2120
|
+
|| boundary.rowCount > rows.length || typeof boundary.digest !== 'string'
|
|
2121
|
+
|| boundary.lastMessageId !== (rows[boundary.rowCount - 1]?.id ?? null)
|
|
2122
|
+
|| boundary.digest !== this.#sessionCopyPrefixDigest(rows.slice(0, boundary.rowCount))) {
|
|
2123
|
+
throw historyError('invalid_fork_boundary', 'Captured Session prefix is invalid or has changed');
|
|
2124
|
+
}
|
|
2125
|
+
capturedCutoff = boundary.rowCount;
|
|
2126
|
+
cutoff = capturedCutoff;
|
|
2127
|
+
}
|
|
2128
|
+
if (Object.prototype.hasOwnProperty.call(options, 'throughMessageId')) {
|
|
2129
|
+
if (options.throughMessageId === null) cutoff = 0;
|
|
2130
|
+
else {
|
|
2131
|
+
const fence = typeof options.throughMessageId === 'string' ? options.throughMessageId.trim() : '';
|
|
2132
|
+
if (!fence) throw historyError('invalid_fork_boundary', 'throughMessageId must be a nonempty string or null');
|
|
2133
|
+
const index = rows.findIndex(row => row.id === fence);
|
|
2134
|
+
if (index < 0) throw historyError('fork_boundary_not_found', `Durable message not found: ${fence}`);
|
|
2135
|
+
cutoff = Math.min(cutoff, index + 1);
|
|
2136
|
+
}
|
|
2137
|
+
}
|
|
2138
|
+
if (Object.prototype.hasOwnProperty.call(options, 'beforeMessageId')) {
|
|
2139
|
+
const fence = typeof options.beforeMessageId === 'string' ? options.beforeMessageId.trim() : '';
|
|
2140
|
+
if (!fence) throw historyError('invalid_fork_boundary', 'beforeMessageId must be a nonempty string');
|
|
2141
|
+
const index = rows.findIndex(row => row.id === fence);
|
|
2142
|
+
if (index < 0) throw historyError('fork_boundary_not_found', `Human message not found: ${fence}`);
|
|
2143
|
+
if (!isHumanRequest(rows[index])) throw historyError('invalid_fork_boundary', 'beforeMessageId must identify a durable human request');
|
|
2144
|
+
const user = rows[index];
|
|
2145
|
+
// A multi-VP legacy request can have several persisted human variants.
|
|
2146
|
+
// Fence before the first variant, never before just the running VP.
|
|
2147
|
+
const originStream = row => row.origin?.streamId || streamId;
|
|
2148
|
+
const variant = row => isHumanRequest(row) && user.clientMessageId
|
|
2149
|
+
&& row.clientMessageId === user.clientMessageId && originStream(row) === originStream(user);
|
|
2150
|
+
let humanCutoff = user.clientMessageId ? rows.findIndex(variant) : index;
|
|
2151
|
+
// Follow only this request's causal closure, not every turn in the
|
|
2152
|
+
// suffix: a late answer to an older request must not pull the fence back
|
|
2153
|
+
// into (and truncate) that older request. Client/VP identities are scoped
|
|
2154
|
+
// by origin stream: a merged Session may contain independent collisions.
|
|
2155
|
+
const vpTurnKey = row => row.turnId
|
|
2156
|
+
? JSON.stringify([originStream(row), row.speakerVpId || row.vpId || '', row.turnId]) : null;
|
|
2157
|
+
const clientKey = (row, id) => JSON.stringify([originStream(row), id]);
|
|
2158
|
+
const excludedRows = new Set(rows.filter(row => row === user || variant(row)));
|
|
2159
|
+
let previousSize;
|
|
2160
|
+
do {
|
|
2161
|
+
previousSize = excludedRows.size;
|
|
2162
|
+
const excluded = new Set([...excludedRows].map(row => row.id));
|
|
2163
|
+
const clients = new Set([...excludedRows].filter(row => row.clientMessageId)
|
|
2164
|
+
.map(row => clientKey(row, row.clientMessageId)));
|
|
2165
|
+
const turns = new Set([...excludedRows].map(vpTurnKey).filter(Boolean));
|
|
2166
|
+
for (const row of rows) {
|
|
2167
|
+
if (excluded.has(row.causalRootId) || clients.has(clientKey(row, row.causalRootId))
|
|
2168
|
+
|| turns.has(vpTurnKey(row))) excludedRows.add(row);
|
|
2003
2169
|
}
|
|
2004
|
-
})
|
|
2005
|
-
.
|
|
2006
|
-
|
|
2007
|
-
// markdown layout and the segment store. Preserve legacy-only rows, but let
|
|
2008
|
-
// the canonical segment copy win when both contain the same persisted id.
|
|
2009
|
-
const rowsById = new Map();
|
|
2010
|
-
for (const row of [...legacyRows, ...segmentedRows]) {
|
|
2011
|
-
if (row?.sessionId !== sourceSessionId || typeof row.id !== 'string' || !row.id) continue;
|
|
2012
|
-
rowsById.set(row.id, row);
|
|
2170
|
+
} while (excludedRows.size > previousSize);
|
|
2171
|
+
humanCutoff = Math.min(humanCutoff, rows.findIndex(row => excludedRows.has(row)));
|
|
2172
|
+
cutoff = Math.min(cutoff, humanCutoff);
|
|
2013
2173
|
}
|
|
2014
|
-
let rows = [...rowsById.values()].sort(compareMessagesBySeq);
|
|
2015
2174
|
if (Object.prototype.hasOwnProperty.call(options, 'throughTurnId')) {
|
|
2016
2175
|
const throughTurnId = typeof options.throughTurnId === 'string'
|
|
2017
2176
|
? options.throughTurnId.trim()
|
|
@@ -2035,6 +2194,9 @@ export class ConversationStore {
|
|
|
2035
2194
|
throw error;
|
|
2036
2195
|
}
|
|
2037
2196
|
const endpoint = rows.reduce((last, row, index) => row?.turnId === throughTurnId ? index : last, -1);
|
|
2197
|
+
if (capturedCutoff !== null && endpoint >= capturedCutoff) {
|
|
2198
|
+
throw historyError('incomplete_fork_boundary', `Assistant turn exceeds captured Session prefix: ${throughTurnId}`);
|
|
2199
|
+
}
|
|
2038
2200
|
const prefix = rows.slice(0, endpoint + 1);
|
|
2039
2201
|
const sanitized = pairSanitize(prefix);
|
|
2040
2202
|
const pairShape = row => ({
|
|
@@ -2047,8 +2209,9 @@ export class ConversationStore {
|
|
|
2047
2209
|
error.code = 'incomplete_fork_boundary';
|
|
2048
2210
|
throw error;
|
|
2049
2211
|
}
|
|
2050
|
-
|
|
2212
|
+
cutoff = Math.min(cutoff, prefix.length);
|
|
2051
2213
|
}
|
|
2214
|
+
rows = rows.slice(0, cutoff);
|
|
2052
2215
|
if (rows.length === 0) return { copiedCount: 0, idMap: new Map() };
|
|
2053
2216
|
|
|
2054
2217
|
const firstSeq = this.#reserveSeqRange(rows.length);
|
|
@@ -2060,10 +2223,13 @@ export class ConversationStore {
|
|
|
2060
2223
|
const assetRows = copyPersistedSessionAttachments(rows, { yeaftDir: this.#dir, sourceSessionId, targetSessionId });
|
|
2061
2224
|
const copies = assetRows.map(row => {
|
|
2062
2225
|
const copy = {
|
|
2063
|
-
...row,
|
|
2226
|
+
...historicalReplayRow(row),
|
|
2064
2227
|
id: idMap.get(row.id),
|
|
2065
2228
|
sessionId: targetSessionId,
|
|
2229
|
+
origin: messageOrigin(row, sourceSessionId, streamId),
|
|
2066
2230
|
};
|
|
2231
|
+
delete copy.seq;
|
|
2232
|
+
if (row.quote?.messageId) copy.quote = { ...row.quote, messageId: remapId(row.quote.messageId) };
|
|
2067
2233
|
if (idMap.has(row.causalRootId)) copy.causalRootId = remapId(row.causalRootId);
|
|
2068
2234
|
if (Array.isArray(row.foldedMessageIds)) {
|
|
2069
2235
|
copy.foldedMessageIds = row.foldedMessageIds.map(remapId);
|
|
@@ -2091,6 +2257,88 @@ export class ConversationStore {
|
|
|
2091
2257
|
return { copiedCount: written.length, idMap };
|
|
2092
2258
|
}
|
|
2093
2259
|
|
|
2260
|
+
#loadDurableSession(sourceSessionId) {
|
|
2261
|
+
const primary = this.#segmentStoreForConversationDir(this.#sessionConversationDir(sourceSessionId));
|
|
2262
|
+
const segmentedRows = primary.readAllRaw({ includeCold: true });
|
|
2263
|
+
if (new Set(segmentedRows.map(row => row.id)).size !== segmentedRows.length) {
|
|
2264
|
+
throw historyError('ambiguous_merge_history', 'Duplicate persisted identity within one Session');
|
|
2265
|
+
}
|
|
2266
|
+
const legacyRows = this.#sessionFileEntries('all', sourceSessionId)
|
|
2267
|
+
.map(entry => {
|
|
2268
|
+
try { return this.readMessageFile(entry.path); } catch (err) {
|
|
2269
|
+
if (isPermissionError(err)) return null;
|
|
2270
|
+
throw err;
|
|
2271
|
+
}
|
|
2272
|
+
})
|
|
2273
|
+
.filter(Boolean);
|
|
2274
|
+
// Migration can temporarily leave the same durable row in both the legacy
|
|
2275
|
+
// markdown layout and the segment store. Preserve legacy-only rows, but let
|
|
2276
|
+
// the canonical segment copy win when both contain the same persisted id.
|
|
2277
|
+
const rowsById = new Map();
|
|
2278
|
+
for (const row of [...legacyRows, ...segmentedRows]) {
|
|
2279
|
+
if (row?.sessionId !== sourceSessionId || typeof row.id !== 'string' || !row.id) continue;
|
|
2280
|
+
rowsById.set(row.id, row);
|
|
2281
|
+
}
|
|
2282
|
+
const rows = [...rowsById.values()].sort(compareMessagesBySeq);
|
|
2283
|
+
return { sessionId: sourceSessionId, streamId: rows.length || primary.hasData() ? primary.metadata().streamId : null, rows };
|
|
2284
|
+
}
|
|
2285
|
+
|
|
2286
|
+
/**
|
|
2287
|
+
* Merge two idle, Agent-owned Session transcripts into an EMPTY target.
|
|
2288
|
+
* Sources are never rewritten. Shared whole human requests are deduplicated
|
|
2289
|
+
* by origin (or guarded legacy client/VP-turn identities); divergent copies
|
|
2290
|
+
* of a shared request reject with `ambiguous_merge_history`. Common requests
|
|
2291
|
+
* precede timestamp-sorted branch requests, with source/order tie breaks.
|
|
2292
|
+
* Message/tool references are namespaced per source; local attachments are
|
|
2293
|
+
* independently copied and Server-owned attachment metadata stays unchanged.
|
|
2294
|
+
* CRUD owns target metadata/manifest rollback; this method rolls back its
|
|
2295
|
+
* newly-created conversation and attachment directories on write failure.
|
|
2296
|
+
*
|
|
2297
|
+
* @param {string} sourceSessionId Source of config/project/workdir semantics
|
|
2298
|
+
* @param {string} otherSessionId Other idle Session in the same data root
|
|
2299
|
+
* @param {string} targetSessionId New Session, distinct from both sources
|
|
2300
|
+
* @param {object} [options] Reserved; caller guarantees both sources are idle
|
|
2301
|
+
* @returns {{copiedCount:number, sharedTurnCount:number, idMaps:Map<string,Map<string,string>>}}
|
|
2302
|
+
*/
|
|
2303
|
+
mergeSessions(sourceSessionId, otherSessionId, targetSessionId, options = {}) {
|
|
2304
|
+
if (![sourceSessionId, otherSessionId, targetSessionId].every(id =>
|
|
2305
|
+
typeof id === 'string' && /^[A-Za-z0-9_-]+$/.test(id))
|
|
2306
|
+
|| new Set([sourceSessionId, otherSessionId, targetSessionId]).size !== 3) {
|
|
2307
|
+
throw historyError('invalid_merge_source', 'Merge requires three distinct Session identities');
|
|
2308
|
+
}
|
|
2309
|
+
const targetDir = this.#sessionConversationDir(targetSessionId);
|
|
2310
|
+
const attachmentDir = join(this.#dir, 'sessions', targetSessionId, 'attachments');
|
|
2311
|
+
if (existsSync(targetDir) || existsSync(attachmentDir)) {
|
|
2312
|
+
throw historyError('duplicate', 'Merge target must have no conversation or attachments');
|
|
2313
|
+
}
|
|
2314
|
+
const inputs = [sourceSessionId, otherSessionId].map(id => this.#loadDurableSession(id));
|
|
2315
|
+
const plan = planSessionMerge(inputs);
|
|
2316
|
+
const count = plan.turns.reduce((sum, turn) => sum + turn.rows.length, 0);
|
|
2317
|
+
const firstSeq = count ? this.#reserveSeqRange(count) : 0;
|
|
2318
|
+
try {
|
|
2319
|
+
// Ownership is taken from every actual source, including deduplicated
|
|
2320
|
+
// aliases. Missing primary blobs may use an available verified alias;
|
|
2321
|
+
// byte conflicts or entirely missing local blobs reject atomically.
|
|
2322
|
+
copyMergeAttachments(plan, this.#dir, targetSessionId);
|
|
2323
|
+
const { rows, idMaps } = materializeSessionMerge(plan, targetSessionId, firstSeq);
|
|
2324
|
+
if (rows.length) {
|
|
2325
|
+
const target = this.#segmentStoreForConversationDir(targetDir);
|
|
2326
|
+
const written = target.appendBatch(rows);
|
|
2327
|
+
const persisted = target.readAllRaw({ includeCold: true });
|
|
2328
|
+
if (written.length !== rows.length || persisted.length !== rows.length
|
|
2329
|
+
|| persisted.some((row, index) => row.id !== rows[index].id)) {
|
|
2330
|
+
throw new Error('Session transcript merge incomplete');
|
|
2331
|
+
}
|
|
2332
|
+
this.#markDirty({ sessionId: targetSessionId }, 'merge-sessions');
|
|
2333
|
+
}
|
|
2334
|
+
return { copiedCount: count, sharedTurnCount: plan.sharedTurnCount, idMaps };
|
|
2335
|
+
} catch (error) {
|
|
2336
|
+
rmSync(targetDir, { recursive: true, force: true });
|
|
2337
|
+
rmSync(attachmentDir, { recursive: true, force: true });
|
|
2338
|
+
throw error;
|
|
2339
|
+
}
|
|
2340
|
+
}
|
|
2341
|
+
|
|
2094
2342
|
/**
|
|
2095
2343
|
* VP-scoped view of Session history, used to build a pair-safe provider
|
|
2096
2344
|
* snapshot for one VP. It operates on what the VP can actually see, not
|