dsh-ferry 0.1.1 → 0.1.2
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 +19 -5
- package/lib/transfer.js +84 -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,13 @@
|
|
|
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(['headerSeq', 'sourceEventSeq', 'shadowedSeqs', 'shadowedRange']);
|
|
19
|
+
|
|
13
20
|
/** Number of trailing events that make up a tail fingerprint. */
|
|
14
21
|
export const TAIL_EVENTS = 8;
|
|
15
22
|
|
|
@@ -63,7 +70,10 @@ function updateHash(hash, value) {
|
|
|
63
70
|
for (const key of Object.keys(record).sort()) {
|
|
64
71
|
hash.update(JSON.stringify(key));
|
|
65
72
|
hash.update(':');
|
|
66
|
-
|
|
73
|
+
// Sequence references are derived positions, not content: an append that
|
|
74
|
+
// renumbers its tail must not read as a different conversation.
|
|
75
|
+
if (SEQ_REFERENCE_KEYS.has(key)) hash.update('"#seq"');
|
|
76
|
+
else updateHash(hash, record[key]);
|
|
67
77
|
hash.update(',');
|
|
68
78
|
}
|
|
69
79
|
hash.update('}');
|
|
@@ -121,11 +131,15 @@ export function conversationEvents(events) {
|
|
|
121
131
|
* @returns {object} event as compared.
|
|
122
132
|
*/
|
|
123
133
|
function comparableEvent(event) {
|
|
124
|
-
if (event
|
|
125
|
-
|
|
126
|
-
|
|
134
|
+
if (event === null || typeof event !== 'object') return event;
|
|
135
|
+
// Position is the array index; the stored number moves when a tail is
|
|
136
|
+
// renumbered onto a peer's log, so it must not decide equality.
|
|
137
|
+
const { seq: _seq, ...rest } = event;
|
|
138
|
+
if (rest.type === 'session/end-seed' && 'time' in rest) {
|
|
139
|
+
const { time: _time, ...withoutTime } = rest;
|
|
140
|
+
return withoutTime;
|
|
127
141
|
}
|
|
128
|
-
return
|
|
142
|
+
return rest;
|
|
129
143
|
}
|
|
130
144
|
|
|
131
145
|
/**
|
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,17 @@ 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
|
+
});
|
|
754
774
|
let handle;
|
|
755
775
|
try {
|
|
756
776
|
handle = await ctx.sessionPersistence.open(sessionId, 'write', { signal });
|
|
@@ -767,10 +787,9 @@ export function createTransfer({ ctx, config, rules, log = () => {} }) {
|
|
|
767
787
|
throw error;
|
|
768
788
|
}
|
|
769
789
|
try {
|
|
770
|
-
|
|
771
|
-
for (let offset = shared; offset < events.length; offset += 200) {
|
|
790
|
+
for (let offset = 0; offset < tail.length; offset += 200) {
|
|
772
791
|
signal?.throwIfAborted();
|
|
773
|
-
await handle.append(
|
|
792
|
+
await handle.append(tail.slice(offset, offset + 200), { signal });
|
|
774
793
|
}
|
|
775
794
|
await handle.flush({ signal });
|
|
776
795
|
} finally {
|
|
@@ -792,7 +811,7 @@ export function createTransfer({ ctx, config, rules, log = () => {} }) {
|
|
|
792
811
|
sessionId,
|
|
793
812
|
targetDir: target,
|
|
794
813
|
workspaceId,
|
|
795
|
-
appended:
|
|
814
|
+
appended: tail.length,
|
|
796
815
|
events: sessionPayload.events.length,
|
|
797
816
|
attachments: { rewritten: attachmentReplacements.size },
|
|
798
817
|
workspace: {
|
|
@@ -886,6 +905,7 @@ export function createTransfer({ ctx, config, rules, log = () => {} }) {
|
|
|
886
905
|
targetDir,
|
|
887
906
|
merge = false,
|
|
888
907
|
tail,
|
|
908
|
+
conversationCount,
|
|
889
909
|
}) => {
|
|
890
910
|
const target = await resolveImportTarget({ requested: targetDir, sourceRoot, sessionId });
|
|
891
911
|
const currentVersion = await currentFormatVersion();
|
|
@@ -923,10 +943,14 @@ export function createTransfer({ ctx, config, rules, log = () => {} }) {
|
|
|
923
943
|
}`,
|
|
924
944
|
};
|
|
925
945
|
}
|
|
926
|
-
|
|
946
|
+
const conversation = conversationEvents(snapshot.events);
|
|
947
|
+
localCount = conversation.length;
|
|
927
948
|
state = classifySession(
|
|
928
|
-
{ count: localCount, tail: tailOf(
|
|
929
|
-
{
|
|
949
|
+
{ count: localCount, tail: tailOf(conversation) },
|
|
950
|
+
{
|
|
951
|
+
count: typeof conversationCount === 'number' ? conversationCount : eventCount,
|
|
952
|
+
tail: readTail(tail),
|
|
953
|
+
},
|
|
930
954
|
);
|
|
931
955
|
if (state === 'unknown') {
|
|
932
956
|
// A sender predating tail fingerprints only reports its creation time.
|
|
@@ -990,6 +1014,55 @@ async function* once(buffer) {
|
|
|
990
1014
|
if (buffer !== undefined && buffer.length > 0) yield buffer;
|
|
991
1015
|
}
|
|
992
1016
|
|
|
1017
|
+
/**
|
|
1018
|
+
* Rewrite one event for a log that numbers the same content differently.
|
|
1019
|
+
* @param {object} event - source event.
|
|
1020
|
+
* @param {number} seq - sequence number it takes in the receiver's log.
|
|
1021
|
+
* @param {number} delta - shift applied to the receiver-relative numbering.
|
|
1022
|
+
* @param {number} from - first sender seq that belongs to the shifted tail.
|
|
1023
|
+
* @returns {object} renumbered event.
|
|
1024
|
+
*/
|
|
1025
|
+
function renumberEvent(event, seq, delta, from) {
|
|
1026
|
+
const next = { ...event, seq };
|
|
1027
|
+
if (event.data !== null && typeof event.data === 'object') {
|
|
1028
|
+
next.data = shiftSeqRefs(event.data, delta, from);
|
|
1029
|
+
}
|
|
1030
|
+
return next;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
/**
|
|
1034
|
+
* Shift the sequence references a payload may hold into the receiver's numbering.
|
|
1035
|
+
* @param {unknown} value - payload value.
|
|
1036
|
+
* @param {number} delta - shift for references inside the moved tail.
|
|
1037
|
+
* @param {number} from - first sender seq that belongs to the moved tail.
|
|
1038
|
+
* @returns {unknown} rewritten value.
|
|
1039
|
+
*/
|
|
1040
|
+
function shiftSeqRefs(value, delta, from) {
|
|
1041
|
+
if (Array.isArray(value)) return value.map((item) => shiftSeqRefs(item, delta, from));
|
|
1042
|
+
if (value === null || typeof value !== 'object') return value;
|
|
1043
|
+
const out = {};
|
|
1044
|
+
for (const [key, item] of Object.entries(value)) {
|
|
1045
|
+
if ((key === 'headerSeq' || key === 'sourceEventSeq') && Number.isSafeInteger(item) && item >= from) {
|
|
1046
|
+
out[key] = item + delta;
|
|
1047
|
+
continue;
|
|
1048
|
+
}
|
|
1049
|
+
if (key === 'shadowedSeqs' && Array.isArray(item)) {
|
|
1050
|
+
out[key] = item.map((entry) => (Number.isSafeInteger(entry) && entry >= from ? entry + delta : entry));
|
|
1051
|
+
continue;
|
|
1052
|
+
}
|
|
1053
|
+
if (key === 'shadowedRange' && item !== null && typeof item === 'object') {
|
|
1054
|
+
out[key] = {
|
|
1055
|
+
...item,
|
|
1056
|
+
...(Number.isSafeInteger(item.start) && item.start >= from ? { start: item.start + delta } : {}),
|
|
1057
|
+
...(Number.isSafeInteger(item.end) && item.end >= from ? { end: item.end + delta } : {}),
|
|
1058
|
+
};
|
|
1059
|
+
continue;
|
|
1060
|
+
}
|
|
1061
|
+
out[key] = shiftSeqRefs(item, delta, from);
|
|
1062
|
+
}
|
|
1063
|
+
return out;
|
|
1064
|
+
}
|
|
1065
|
+
|
|
993
1066
|
/**
|
|
994
1067
|
* Raw index in the incoming log where the receiver's new events begin.
|
|
995
1068
|
* @param {readonly object[]} incoming - incoming session events.
|