dsh-ferry 0.1.1 → 0.1.3
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/lib/ferry.js +4 -0
- package/lib/routes.js +1 -0
- package/lib/state.js +26 -5
- package/lib/transfer.js +124 -11
- package/package.json +1 -1
package/lib/ferry.js
CHANGED
|
@@ -285,6 +285,7 @@ export function createFerry({ ctx, config, transfer, log = () => {} }) {
|
|
|
285
285
|
{
|
|
286
286
|
sessionId,
|
|
287
287
|
eventCount: description.session.eventCount,
|
|
288
|
+
conversationCount: description.session.conversationCount,
|
|
288
289
|
createdAt: description.session.createdAt,
|
|
289
290
|
formatVersion: description.session.formatVersion,
|
|
290
291
|
sourceRoot: description.session.cwd,
|
|
@@ -362,6 +363,7 @@ export function createFerry({ ctx, config, transfer, log = () => {} }) {
|
|
|
362
363
|
// With a fresh id, an id collision on the peer is not a conflict.
|
|
363
364
|
sessionId: forkId ? undefined : sessionId,
|
|
364
365
|
eventCount: description.session.eventCount,
|
|
366
|
+
conversationCount: description.session.conversationCount,
|
|
365
367
|
createdAt: description.session.createdAt,
|
|
366
368
|
formatVersion: description.session.formatVersion,
|
|
367
369
|
// No target means the receiver applies its own mapping: reuse the
|
|
@@ -463,6 +465,7 @@ export function createFerry({ ctx, config, transfer, log = () => {} }) {
|
|
|
463
465
|
const local = await transfer.preflight({
|
|
464
466
|
sessionId,
|
|
465
467
|
eventCount: description.session.eventCount,
|
|
468
|
+
conversationCount: description.session.conversationCount,
|
|
466
469
|
createdAt: description.session.createdAt,
|
|
467
470
|
formatVersion: description.session.formatVersion,
|
|
468
471
|
sourceRoot: description.session.cwd,
|
|
@@ -513,6 +516,7 @@ export function createFerry({ ctx, config, transfer, log = () => {} }) {
|
|
|
513
516
|
const local = await transfer.preflight({
|
|
514
517
|
sessionId: forkId ? undefined : sessionId,
|
|
515
518
|
eventCount: description.session.eventCount,
|
|
519
|
+
conversationCount: description.session.conversationCount,
|
|
516
520
|
createdAt: description.session.createdAt,
|
|
517
521
|
formatVersion: description.session.formatVersion,
|
|
518
522
|
targetDir,
|
package/lib/routes.js
CHANGED
|
@@ -106,6 +106,7 @@ export function createPeerHandler({ config, ferry, transfer, log = () => {} }) {
|
|
|
106
106
|
const decision = await transfer.preflight({
|
|
107
107
|
sessionId: typeof body.sessionId === 'string' ? body.sessionId : undefined,
|
|
108
108
|
eventCount: typeof body.eventCount === 'number' ? body.eventCount : undefined,
|
|
109
|
+
conversationCount: typeof body.conversationCount === 'number' ? body.conversationCount : undefined,
|
|
109
110
|
createdAt: typeof body.createdAt === 'number' ? body.createdAt : undefined,
|
|
110
111
|
formatVersion: typeof body.formatVersion === 'number' ? body.formatVersion : undefined,
|
|
111
112
|
sourceRoot: typeof body.sourceRoot === 'string' ? body.sourceRoot : undefined,
|
package/lib/state.js
CHANGED
|
@@ -10,6 +10,20 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { createHash } from 'node:crypto';
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Payload fields that hold a sequence number pointing at another event. They
|
|
15
|
+
* move when a log is renumbered, so they are compared as "a reference exists"
|
|
16
|
+
* rather than by value.
|
|
17
|
+
*/
|
|
18
|
+
const SEQ_REFERENCE_KEYS = new Set([
|
|
19
|
+
'headerSeq',
|
|
20
|
+
'sourceEventSeq',
|
|
21
|
+
'sourceEventSeqs',
|
|
22
|
+
'messageSeqs',
|
|
23
|
+
'shadowedSeqs',
|
|
24
|
+
'shadowedRange',
|
|
25
|
+
]);
|
|
26
|
+
|
|
13
27
|
/** Number of trailing events that make up a tail fingerprint. */
|
|
14
28
|
export const TAIL_EVENTS = 8;
|
|
15
29
|
|
|
@@ -63,7 +77,10 @@ function updateHash(hash, value) {
|
|
|
63
77
|
for (const key of Object.keys(record).sort()) {
|
|
64
78
|
hash.update(JSON.stringify(key));
|
|
65
79
|
hash.update(':');
|
|
66
|
-
|
|
80
|
+
// Sequence references are derived positions, not content: an append that
|
|
81
|
+
// renumbers its tail must not read as a different conversation.
|
|
82
|
+
if (SEQ_REFERENCE_KEYS.has(key)) hash.update('"#seq"');
|
|
83
|
+
else updateHash(hash, record[key]);
|
|
67
84
|
hash.update(',');
|
|
68
85
|
}
|
|
69
86
|
hash.update('}');
|
|
@@ -121,11 +138,15 @@ export function conversationEvents(events) {
|
|
|
121
138
|
* @returns {object} event as compared.
|
|
122
139
|
*/
|
|
123
140
|
function comparableEvent(event) {
|
|
124
|
-
if (event
|
|
125
|
-
|
|
126
|
-
|
|
141
|
+
if (event === null || typeof event !== 'object') return event;
|
|
142
|
+
// Position is the array index; the stored number moves when a tail is
|
|
143
|
+
// renumbered onto a peer's log, so it must not decide equality.
|
|
144
|
+
const { seq: _seq, ...rest } = event;
|
|
145
|
+
if (rest.type === 'session/end-seed' && 'time' in rest) {
|
|
146
|
+
const { time: _time, ...withoutTime } = rest;
|
|
147
|
+
return withoutTime;
|
|
127
148
|
}
|
|
128
|
-
return
|
|
149
|
+
return rest;
|
|
129
150
|
}
|
|
130
151
|
|
|
131
152
|
/**
|
package/lib/transfer.js
CHANGED
|
@@ -276,6 +276,7 @@ export function createTransfer({ ctx, config, rules, log = () => {} }) {
|
|
|
276
276
|
}
|
|
277
277
|
const records = await ctx.sessionPersistence.list({ signal });
|
|
278
278
|
const children = records.filter((record) => record.header.parentSession === sessionId).length;
|
|
279
|
+
const conversation = conversationEvents(snapshot.events);
|
|
279
280
|
return {
|
|
280
281
|
session: {
|
|
281
282
|
sessionId,
|
|
@@ -283,7 +284,8 @@ export function createTransfer({ ctx, config, rules, log = () => {} }) {
|
|
|
283
284
|
cwd: header.cwd,
|
|
284
285
|
createdAt: header.createdAt,
|
|
285
286
|
eventCount: snapshot.events.length,
|
|
286
|
-
|
|
287
|
+
conversationCount: conversation.length,
|
|
288
|
+
tail: tailOf(conversation),
|
|
287
289
|
inheritedEventCount: snapshot.inheritedEventCount,
|
|
288
290
|
origin: header.origin,
|
|
289
291
|
agentPreset: header.agentPreset,
|
|
@@ -442,7 +444,8 @@ export function createTransfer({ ctx, config, rules, log = () => {} }) {
|
|
|
442
444
|
header,
|
|
443
445
|
inheritedEventCount: snapshot.inheritedEventCount,
|
|
444
446
|
eventCount: snapshot.events.length,
|
|
445
|
-
|
|
447
|
+
conversationCount: conversationEvents(snapshot.events).length,
|
|
448
|
+
tail: tailOf(conversationEvents(snapshot.events)),
|
|
446
449
|
lastSeq: snapshot.events.at(-1)?.seq ?? -1,
|
|
447
450
|
agentPreset: header.agentPreset,
|
|
448
451
|
origin: header.origin,
|
|
@@ -504,9 +507,15 @@ export function createTransfer({ ctx, config, rules, log = () => {} }) {
|
|
|
504
507
|
);
|
|
505
508
|
}
|
|
506
509
|
const local = await readSnapshot(sessionId, signal);
|
|
507
|
-
const
|
|
510
|
+
const mine = conversationEvents(local.events);
|
|
511
|
+
const remoteCount =
|
|
512
|
+
typeof sourceSession.conversationCount === 'number'
|
|
513
|
+
? sourceSession.conversationCount
|
|
514
|
+
: typeof sourceSession.eventCount === 'number'
|
|
515
|
+
? sourceSession.eventCount
|
|
516
|
+
: undefined;
|
|
508
517
|
const state = classifySession(
|
|
509
|
-
{ count:
|
|
518
|
+
{ count: mine.length, tail: tailOf(mine) },
|
|
510
519
|
{ count: remoteCount, tail: readTail(sourceSession.tail) },
|
|
511
520
|
);
|
|
512
521
|
if (state === 'same') return { mode: update ? 'update' : 'exists', snapshot: local };
|
|
@@ -751,6 +760,19 @@ export function createTransfer({ ctx, config, rules, log = () => {} }) {
|
|
|
751
760
|
workspaceId = workspace.id;
|
|
752
761
|
if (plan.mode === 'update') {
|
|
753
762
|
const shared = appendFrom;
|
|
763
|
+
// The receiver may hold more events than the sender at the same
|
|
764
|
+
// conversation position (it wrote its own runtime markers), so the
|
|
765
|
+
// appended batch is renumbered to continue the receiver's log.
|
|
766
|
+
const local = /** @type {{ events: object[] }} */ (plan.snapshot);
|
|
767
|
+
const sourceEvents = sessionPayload.events;
|
|
768
|
+
const nextSeq = local.events.length;
|
|
769
|
+
const delta = nextSeq - (sourceEvents[shared]?.seq ?? shared);
|
|
770
|
+
const tail = sourceEvents.slice(shared).map((event, index) => {
|
|
771
|
+
const seq = nextSeq + index;
|
|
772
|
+
return seq === event.seq ? event : renumberEvent(event, seq, delta, shared);
|
|
773
|
+
});
|
|
774
|
+
// A mismatch here would write a log the receiver can never open again.
|
|
775
|
+
assertTailReferences([...local.events, ...tail], nextSeq);
|
|
754
776
|
let handle;
|
|
755
777
|
try {
|
|
756
778
|
handle = await ctx.sessionPersistence.open(sessionId, 'write', { signal });
|
|
@@ -767,10 +789,9 @@ export function createTransfer({ ctx, config, rules, log = () => {} }) {
|
|
|
767
789
|
throw error;
|
|
768
790
|
}
|
|
769
791
|
try {
|
|
770
|
-
|
|
771
|
-
for (let offset = shared; offset < events.length; offset += 200) {
|
|
792
|
+
for (let offset = 0; offset < tail.length; offset += 200) {
|
|
772
793
|
signal?.throwIfAborted();
|
|
773
|
-
await handle.append(
|
|
794
|
+
await handle.append(tail.slice(offset, offset + 200), { signal });
|
|
774
795
|
}
|
|
775
796
|
await handle.flush({ signal });
|
|
776
797
|
} finally {
|
|
@@ -792,7 +813,7 @@ export function createTransfer({ ctx, config, rules, log = () => {} }) {
|
|
|
792
813
|
sessionId,
|
|
793
814
|
targetDir: target,
|
|
794
815
|
workspaceId,
|
|
795
|
-
appended:
|
|
816
|
+
appended: tail.length,
|
|
796
817
|
events: sessionPayload.events.length,
|
|
797
818
|
attachments: { rewritten: attachmentReplacements.size },
|
|
798
819
|
workspace: {
|
|
@@ -886,6 +907,7 @@ export function createTransfer({ ctx, config, rules, log = () => {} }) {
|
|
|
886
907
|
targetDir,
|
|
887
908
|
merge = false,
|
|
888
909
|
tail,
|
|
910
|
+
conversationCount,
|
|
889
911
|
}) => {
|
|
890
912
|
const target = await resolveImportTarget({ requested: targetDir, sourceRoot, sessionId });
|
|
891
913
|
const currentVersion = await currentFormatVersion();
|
|
@@ -923,10 +945,14 @@ export function createTransfer({ ctx, config, rules, log = () => {} }) {
|
|
|
923
945
|
}`,
|
|
924
946
|
};
|
|
925
947
|
}
|
|
926
|
-
|
|
948
|
+
const conversation = conversationEvents(snapshot.events);
|
|
949
|
+
localCount = conversation.length;
|
|
927
950
|
state = classifySession(
|
|
928
|
-
{ count: localCount, tail: tailOf(
|
|
929
|
-
{
|
|
951
|
+
{ count: localCount, tail: tailOf(conversation) },
|
|
952
|
+
{
|
|
953
|
+
count: typeof conversationCount === 'number' ? conversationCount : eventCount,
|
|
954
|
+
tail: readTail(tail),
|
|
955
|
+
},
|
|
930
956
|
);
|
|
931
957
|
if (state === 'unknown') {
|
|
932
958
|
// A sender predating tail fingerprints only reports its creation time.
|
|
@@ -990,6 +1016,93 @@ async function* once(buffer) {
|
|
|
990
1016
|
if (buffer !== undefined && buffer.length > 0) yield buffer;
|
|
991
1017
|
}
|
|
992
1018
|
|
|
1019
|
+
/**
|
|
1020
|
+
* Check that the references inside a renumbered tail still resolve.
|
|
1021
|
+
*
|
|
1022
|
+
* The stored format validates a few of them on open (`headerSeq` must point at
|
|
1023
|
+
* a request header, `messageSeqs` at human messages). A field this plugin does
|
|
1024
|
+
* not know about cannot be checked, so the known ones are verified before the
|
|
1025
|
+
* write rather than after it.
|
|
1026
|
+
* @param {readonly object[]} events - the receiver's log with the tail appended.
|
|
1027
|
+
* @param {number} from - index of the first appended event.
|
|
1028
|
+
* @throws {HttpError} when a reference would not resolve on the receiver.
|
|
1029
|
+
*/
|
|
1030
|
+
function assertTailReferences(events, from) {
|
|
1031
|
+
for (let index = from; index < events.length; index += 1) {
|
|
1032
|
+
const data = events[index].data;
|
|
1033
|
+
if (data === null || typeof data !== 'object') continue;
|
|
1034
|
+
const headerSeq = data.headerSeq;
|
|
1035
|
+
if (Number.isSafeInteger(headerSeq) && events[headerSeq]?.type !== 'request/header') {
|
|
1036
|
+
throw new HttpError(
|
|
1037
|
+
409,
|
|
1038
|
+
'session-reference-mismatch',
|
|
1039
|
+
`Cannot renumber this session: event ${events[index].seq} references request header ${headerSeq}, which would not exist`,
|
|
1040
|
+
);
|
|
1041
|
+
}
|
|
1042
|
+
const messageSeqs = data.messageSeqs;
|
|
1043
|
+
if (Array.isArray(messageSeqs)) {
|
|
1044
|
+
for (const seq of messageSeqs) {
|
|
1045
|
+
if (events[seq]?.type !== 'user/message') {
|
|
1046
|
+
throw new HttpError(
|
|
1047
|
+
409,
|
|
1048
|
+
'session-reference-mismatch',
|
|
1049
|
+
`Cannot renumber this session: event ${events[index].seq} references message ${seq}, which would not be a user message`,
|
|
1050
|
+
);
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
/**
|
|
1058
|
+
* Rewrite one event for a log that numbers the same content differently.
|
|
1059
|
+
* @param {object} event - source event.
|
|
1060
|
+
* @param {number} seq - sequence number it takes in the receiver's log.
|
|
1061
|
+
* @param {number} delta - shift applied to the receiver-relative numbering.
|
|
1062
|
+
* @param {number} from - first sender seq that belongs to the shifted tail.
|
|
1063
|
+
* @returns {object} renumbered event.
|
|
1064
|
+
*/
|
|
1065
|
+
function renumberEvent(event, seq, delta, from) {
|
|
1066
|
+
const next = { ...event, seq };
|
|
1067
|
+
if (event.data !== null && typeof event.data === 'object') {
|
|
1068
|
+
next.data = shiftSeqRefs(event.data, delta, from);
|
|
1069
|
+
}
|
|
1070
|
+
return next;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
/**
|
|
1074
|
+
* Shift the sequence references a payload may hold into the receiver's numbering.
|
|
1075
|
+
* @param {unknown} value - payload value.
|
|
1076
|
+
* @param {number} delta - shift for references inside the moved tail.
|
|
1077
|
+
* @param {number} from - first sender seq that belongs to the moved tail.
|
|
1078
|
+
* @returns {unknown} rewritten value.
|
|
1079
|
+
*/
|
|
1080
|
+
function shiftSeqRefs(value, delta, from) {
|
|
1081
|
+
if (Array.isArray(value)) return value.map((item) => shiftSeqRefs(item, delta, from));
|
|
1082
|
+
if (value === null || typeof value !== 'object') return value;
|
|
1083
|
+
const out = {};
|
|
1084
|
+
for (const [key, item] of Object.entries(value)) {
|
|
1085
|
+
if ((key === 'headerSeq' || key === 'sourceEventSeq') && Number.isSafeInteger(item) && item >= from) {
|
|
1086
|
+
out[key] = item + delta;
|
|
1087
|
+
continue;
|
|
1088
|
+
}
|
|
1089
|
+
if ((key === 'shadowedSeqs' || key === 'messageSeqs' || key === 'sourceEventSeqs') && Array.isArray(item)) {
|
|
1090
|
+
out[key] = item.map((entry) => (Number.isSafeInteger(entry) && entry >= from ? entry + delta : entry));
|
|
1091
|
+
continue;
|
|
1092
|
+
}
|
|
1093
|
+
if (key === 'shadowedRange' && item !== null && typeof item === 'object') {
|
|
1094
|
+
out[key] = {
|
|
1095
|
+
...item,
|
|
1096
|
+
...(Number.isSafeInteger(item.start) && item.start >= from ? { start: item.start + delta } : {}),
|
|
1097
|
+
...(Number.isSafeInteger(item.end) && item.end >= from ? { end: item.end + delta } : {}),
|
|
1098
|
+
};
|
|
1099
|
+
continue;
|
|
1100
|
+
}
|
|
1101
|
+
out[key] = shiftSeqRefs(item, delta, from);
|
|
1102
|
+
}
|
|
1103
|
+
return out;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
993
1106
|
/**
|
|
994
1107
|
* Raw index in the incoming log where the receiver's new events begin.
|
|
995
1108
|
* @param {readonly object[]} incoming - incoming session events.
|