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 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
- updateHash(hash, record[key]);
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 !== null && typeof event === 'object' && event.type === 'session/end-seed' && 'time' in event) {
125
- const { time: _time, ...rest } = event;
126
- return rest;
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 event;
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
- tail: tailOf(snapshot.events),
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
- tail: tailOf(snapshot.events),
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 remoteCount = typeof sourceSession.eventCount === 'number' ? sourceSession.eventCount : undefined;
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: local.events.length, tail: tailOf(local.events) },
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
- const events = sessionPayload.events;
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(events.slice(offset, offset + 200), { signal });
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: sessionPayload.events.length - shared,
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
- localCount = snapshot.events.length;
948
+ const conversation = conversationEvents(snapshot.events);
949
+ localCount = conversation.length;
927
950
  state = classifySession(
928
- { count: localCount, tail: tailOf(snapshot.events) },
929
- { count: eventCount, tail: readTail(tail) },
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-ferry",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "type": "module",
5
5
  "description": "Move Sessions and their Workspace between DSH instances over any reachable network.",
6
6
  "license": "MIT",