dsh-ferry 0.1.0 → 0.1.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/README.md CHANGED
@@ -86,7 +86,7 @@ A 推送会话到 B、在 A 上继续之后再次推送,对话框会显示对
86
86
  | 对方内容一致 | ✅ | 0 条事件,只同步工作区文件 |
87
87
  | 对方也续写过(分叉) | ❌ 只能新建 | 对方副本原样保留 |
88
88
  | 对方副本更长 | ❌ 只能新建 | 或反过来从对方拉取 |
89
- | 对方正在运行该会话 | ❌ 只能新建 | 先停止才能追加 |
89
+ | 对方已打开该会话 | ❌ 只能新建 | 对方的 DSH 进程持有该会话的写句柄,即使没在运行也一样;在那里关闭该会话或重启实例后才能追加 |
90
90
 
91
91
  判定依据是事件数 + 尾部 8 条事件指纹;真正导入前还会逐条校验前缀,不一致直接拒绝。追加只追加事件,不改会话头部:对方的 cwd、标题、模型预设保持原样。
92
92
 
@@ -228,3 +228,5 @@ npm view dsh-ferry version --registry=https://registry.npmjs.org/
228
228
 
229
229
  发布前用 `npm pack --dry-run` 检查包内容(`files` 已限定为入口、`lib/`、`client.js`、`locale/`、`cordis.patch.yml`、`icon.svg`、README 和 `docs/`)。
230
230
  版本号遵循语义化版本:改了协议/行为就走 minor,纯修复走 patch。
231
+
232
+ 首次发布若走了 npm 的 staged publish,包里会先出现一个 `0.0.0-stage` 占位版本(manifest 没有 `dsh` 字段),镜像可能缓存它几分钟。此时安装会报 `dsh-ferry declares no dsh.bundle`,等真实版本同步后再装即可;也可以 `npm deprecate dsh-ferry@0.0.0-stage "staged-publish placeholder"` 标记作废。
package/client.js CHANGED
@@ -71,16 +71,20 @@ window.__ModuleLoader__.load({
71
71
  .ferry-choice.offline:hover{background:transparent}
72
72
  .ferry-status{display:inline-flex;align-items:center;gap:6px;flex:none;font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary)}
73
73
  .ferry-choice-body{flex:1 1 auto;min-width:0}
74
- .ferry-choice-row{display:flex;align-items:center;gap:8px;min-width:0}
75
74
  .ferry-state{font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary);margin-top:2px;white-space:normal}
76
- .ferry-mode{display:flex;flex-direction:column;gap:6px;border-top:.5px solid var(--dsw-alias-border-l1);padding-top:10px}
77
- .ferry-mode-label{display:flex;align-items:flex-start;gap:8px;font-size:12px;line-height:18px;cursor:pointer}
78
- .ferry-mode-label.disabled{opacity:.45;cursor:default}
79
- .ferry-mode-text{display:flex;flex-direction:column;min-width:0}
75
+ .ferry-mode{display:flex;flex-direction:column;gap:2px;padding-top:6px}
76
+ .ferry-mode-title{font-size:12px;line-height:18px;font-weight:500;color:var(--dsw-alias-label-primary);padding:0 10px;margin-bottom:2px}
77
+ .ferry-mode-label{display:flex;align-items:flex-start;gap:10px;padding:5px 10px;border-radius:7px;font-size:12px;line-height:18px;cursor:pointer}
78
+ .ferry-mode-label:hover:not(.disabled){background:var(--dsw-alias-bg-layer-2)}
79
+ .ferry-mode-label input[type=radio]{flex:none;margin:2px 0 0;width:14px;height:14px;cursor:inherit}
80
+ .ferry-mode-label.disabled{cursor:default;color:var(--dsw-alias-label-secondary)}
81
+ .ferry-mode-text{display:flex;flex-direction:column;min-width:0;gap:1px}
82
+ .ferry-mode-hint{font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary)}
83
+ .ferry-stack{display:flex;flex-direction:column;gap:4px}
80
84
  .ferry-choice-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
81
85
  .ferry-modal-error{font-size:11px;line-height:16px;color:var(--dsw-alias-state-error-primary)}
82
86
  .ferry-modal-foot{display:flex;align-items:center;gap:8px;border-top:.5px solid var(--dsw-alias-border-l1);padding-top:12px;margin-top:2px}
83
- .ferry-check{display:flex;align-items:center;gap:6px;font-size:11px;color:var(--dsw-alias-label-secondary);cursor:pointer}
87
+ .ferry-check{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--dsw-alias-label-secondary);cursor:pointer}
84
88
  .ferry-toasts{position:fixed;right:20px;bottom:20px;z-index:90;display:flex;flex-direction:column;gap:8px;max-width:380px;pointer-events:none}
85
89
  .ferry-toast{pointer-events:auto;border:.5px solid var(--dsw-alias-border-l2);border-left-width:3px;border-radius:8px;background:var(--dsw-alias-bg-overlay);padding:10px 12px;font-size:12px;line-height:18px;box-shadow:0 8px 24px rgba(0,0,0,.24)}
86
90
  .ferry-toast.success{border-left-color:var(--dsw-alias-state-success-primary)}
@@ -161,23 +165,31 @@ window.__ModuleLoader__.load({
161
165
  mergeInMenu: 'Merge into an existing directory (backs up)',
162
166
  modeTitle: 'How to send it',
163
167
  modeNew: 'New session',
164
- modeNewHint: 'Create a separate copy there; the peer keeps whatever it already has.',
168
+ modeNewHint: 'Create a separate copy; the peer keeps what it already has.',
165
169
  modeUpdate: 'Append update',
166
- modeUpdateHint: 'Keep one session: add the events the peer is missing and merge the workspace.',
167
- modeUpdateUnavailable: 'Not possible for this peer',
170
+ modeUpdateHint: 'Append only the missing events and merge the workspace.',
171
+ modeUpdateUnavailable: 'Not available for this peer',
172
+ blocked_state_open: 'The peer has it open',
173
+ blocked_state_ahead: "The peer's copy is longer",
174
+ blocked_state_diverged: 'The two copies differ',
175
+ blocked_state_absent: 'The peer has no copy yet',
176
+ blocked_local_open: 'It is open here',
177
+ blocked_local_ahead: 'The local copy is longer',
178
+ blocked_local_diverged: 'The two copies differ',
179
+ blocked_local_absent: 'No local copy here yet',
168
180
  state_absent: 'No copy there yet; it will be created',
169
181
  state_behind: 'The peer has {have} events; {add} will be appended',
170
182
  state_same: 'The peer has {have} events and matches; only workspace files are synced',
171
183
  state_ahead: 'The peer has {have} events, more than here; only a new copy is possible',
172
184
  state_diverged: 'The peer copy has diverged; only a new copy is possible',
173
- state_active: 'The peer has this session open; only a new copy is possible',
185
+ state_active: 'The peer has this session open; close it or restart that instance to append',
174
186
  state_unknown: 'Cannot tell what the peer has (offline, or its plugin is older; append updates need the same version on both sides)',
175
187
  local_absent: 'No local copy yet; it will be created',
176
188
  local_behind: 'This instance has {have} events; {add} will be appended',
177
189
  local_same: 'This instance has {have} events and matches; only workspace files are synced',
178
190
  local_ahead: 'This instance has {have} events, more than the peer; only a new copy is possible',
179
191
  local_diverged: 'The local copy has diverged; only a new copy is possible',
180
- local_active: 'This session is open here; only a new copy is possible',
192
+ local_active: 'This session is open here; close it or restart this instance to append',
181
193
  local_unknown: 'Cannot tell what this instance has',
182
194
  pullDialogTitle: 'Pull from another instance',
183
195
  pullDialogChecking: 'Checking the local copy…',
@@ -186,7 +198,7 @@ window.__ModuleLoader__.load({
186
198
  appended: 'events appended',
187
199
  code_session_ahead: 'The peer has a longer copy of this session; send it as a new session.',
188
200
  code_session_diverged: 'The peer has a different copy of this session; send it as a new session.',
189
- code_session_active: 'The session is open there; close it or stop its turn before updating it.',
201
+ code_session_active: 'The session is open there; close it or restart that instance before updating it.',
190
202
  code_session_unreadable: 'The stored session log could not be read, so the peer copy cannot be compared.',
191
203
  code_sessionForked: 'The two copies had diverged, so the push created a separate session on the peer.',
192
204
  files: 'files',
@@ -291,23 +303,31 @@ window.__ModuleLoader__.load({
291
303
  mergeInMenu: '合并已有目录(自动备份)',
292
304
  modeTitle: '推送方式',
293
305
  modeNew: '新建会话',
294
- modeNewHint: '在对方创建一份独立副本,对方已有的内容不动。',
306
+ modeNewHint: '在对方创建独立副本,不动已有内容。',
295
307
  modeUpdate: '追加更新',
296
- modeUpdateHint: '沿用同一个会话:补上对方缺少的事件,并合并工作区。',
297
- modeUpdateUnavailable: '当前对端不可用',
308
+ modeUpdateHint: '只补上对方缺少的事件,并合并工作区。',
309
+ modeUpdateUnavailable: '当前对端不可追加',
310
+ blocked_state_open: '对方已打开该会话',
311
+ blocked_state_ahead: '对方副本更长',
312
+ blocked_state_diverged: '两边内容已不同',
313
+ blocked_state_absent: '对方没有此会话',
314
+ blocked_local_open: '本地已打开该会话',
315
+ blocked_local_ahead: '本地副本更长',
316
+ blocked_local_diverged: '两边内容已不同',
317
+ blocked_local_absent: '本地还没有此会话',
298
318
  state_absent: '对方没有此会话,将新建',
299
319
  state_behind: '对方已有 {have} 条,本次追加 {add} 条',
300
320
  state_same: '对方已有 {have} 条且内容一致,只同步工作区文件',
301
321
  state_ahead: '对方已有 {have} 条,比本地多,只能新建副本',
302
322
  state_diverged: '对方内容与本地不同,只能新建副本',
303
- state_active: '对方已打开该会话,只能新建副本',
323
+ state_active: '对方已打开该会话;在那边关闭或重启实例后即可追加',
304
324
  state_unknown: '无法判断对方状态(离线,或对方插件较旧;追加更新需要两端同版本)',
305
325
  local_absent: '本地没有此会话,将新建',
306
326
  local_behind: '本地已有 {have} 条,本次追加 {add} 条',
307
327
  local_same: '本地已有 {have} 条且内容一致,只同步工作区文件',
308
328
  local_ahead: '本地已有 {have} 条,比远端多,只能新建副本',
309
329
  local_diverged: '本地内容与远端不同,只能新建副本',
310
- local_active: '本地已打开该会话,只能新建副本',
330
+ local_active: '本地已打开该会话;关闭或重启本实例后即可追加',
311
331
  local_unknown: '无法判断本地状态',
312
332
  pullDialogTitle: '从其他实例拉取',
313
333
  pullDialogChecking: '正在检查本地副本…',
@@ -316,7 +336,7 @@ window.__ModuleLoader__.load({
316
336
  appended: '条事件已追加',
317
337
  code_session_ahead: '对方有比本地更长的副本,请改为新建会话推送。',
318
338
  code_session_diverged: '对方已有内容不同的同名会话,请改为新建会话推送。',
319
- code_session_active: '该会话在对端/本地处于打开状态,请先关闭或停止后再更新。',
339
+ code_session_active: '该会话在对端/本地处于打开状态,请先关闭或重启实例后再更新。',
320
340
  code_session_unreadable: '会话日志无法读取,无法比较对方副本。',
321
341
  code_sessionForked: '两边内容已分叉,已在对方新建独立会话。',
322
342
  files: '文件',
@@ -960,6 +980,20 @@ window.__ModuleLoader__.load({
960
980
  return row.state === 'behind' || row.state === 'same';
961
981
  }
962
982
 
983
+ /**
984
+ * Why an in-place append is unavailable, phrased for the row it belongs to.
985
+ * @param {'state'|'local'} side - whose copy the row describes.
986
+ * @param {object | undefined} row - plan row.
987
+ * @param {(key: string) => string} t - translator.
988
+ * @returns {string} short reason.
989
+ */
990
+ function blockedReason(side, row, t) {
991
+ const reason = row?.active === true ? 'open' : (row?.state ?? 'unknown');
992
+ const key = `blocked_${side}_${reason}`;
993
+ const text = t(key);
994
+ return text === key ? t('modeUpdateUnavailable') : text;
995
+ }
996
+
963
997
  /**
964
998
  * Render one state line for a peer row (`state_*`) or a local plan row
965
999
  * (`local_*`).
@@ -1092,7 +1126,7 @@ window.__ModuleLoader__.load({
1092
1126
  }
1093
1127
  };
1094
1128
 
1095
- const modeOption = (value, label, hint, enabled) =>
1129
+ const modeOption = (value, label, hint, enabled, reason) =>
1096
1130
  h(
1097
1131
  'label',
1098
1132
  { key: value, className: `ferry-mode-label ${enabled ? '' : 'disabled'}` },
@@ -1108,7 +1142,7 @@ window.__ModuleLoader__.load({
1108
1142
  'span',
1109
1143
  { className: 'ferry-mode-text' },
1110
1144
  h('span', null, label),
1111
- h('span', { className: 'ferry-modal-hint' }, hint),
1145
+ h('span', { className: 'ferry-mode-hint' }, enabled ? hint : reason ?? t('modeUpdateUnavailable')),
1112
1146
  ),
1113
1147
  );
1114
1148
 
@@ -1149,30 +1183,25 @@ window.__ModuleLoader__.load({
1149
1183
  { className: 'ferry-choice-body' },
1150
1184
  h(
1151
1185
  'div',
1152
- { className: 'ferry-choice-row' },
1153
- h(
1154
- 'span',
1155
- { className: 'ferry-choice-name', title: candidate.name || candidate.id },
1156
- candidate.name || candidate.id,
1157
- ),
1158
- h('span', { className: 'ferry-spacer' }),
1159
- h(
1160
- 'span',
1161
- { className: 'ferry-status' },
1162
- h('span', { className: `ferry-dot ${candidate.online === true ? 'on' : 'off'}` }),
1163
- h(
1164
- 'span',
1165
- null,
1166
- candidate.online === true && typeof candidate.latencyMs === 'number'
1167
- ? `${candidate.latencyMs}${t('latency')}`
1168
- : t('offline'),
1169
- ),
1170
- ),
1186
+ { className: 'ferry-choice-name', title: candidate.name || candidate.id },
1187
+ candidate.name || candidate.id,
1171
1188
  ),
1172
1189
  candidate.online === true
1173
1190
  ? h('div', { className: 'ferry-state' }, stateLine('state', candidate, total, t))
1174
1191
  : null,
1175
1192
  ),
1193
+ h(
1194
+ 'span',
1195
+ { className: 'ferry-status' },
1196
+ h('span', { className: `ferry-dot ${candidate.online === true ? 'on' : 'off'}` }),
1197
+ h(
1198
+ 'span',
1199
+ null,
1200
+ candidate.online === true && typeof candidate.latencyMs === 'number'
1201
+ ? `${candidate.latencyMs}${t('latency')}`
1202
+ : t('offline'),
1203
+ ),
1204
+ ),
1176
1205
  ),
1177
1206
  ),
1178
1207
  ),
@@ -1229,13 +1258,14 @@ window.__ModuleLoader__.load({
1229
1258
  : h(
1230
1259
  'div',
1231
1260
  { className: 'ferry-mode' },
1232
- h('div', { className: 'ferry-modal-hint' }, t('modeTitle')),
1261
+ h('div', { className: 'ferry-mode-title' }, t('modeTitle')),
1233
1262
  modeOption('new', t('modeNew'), t('modeNewHint'), true),
1234
1263
  modeOption(
1235
1264
  'update',
1236
1265
  t('modeUpdate'),
1237
- canAppend ? t('modeUpdateHint') : `${t('modeUpdateHint')} · ${t('modeUpdateUnavailable')}`,
1266
+ t('modeUpdateHint'),
1238
1267
  canAppend,
1268
+ blockedReason('state', selectedPeer, t),
1239
1269
  ),
1240
1270
  ),
1241
1271
  mode === 'new'
@@ -1407,7 +1437,7 @@ window.__ModuleLoader__.load({
1407
1437
  }
1408
1438
  };
1409
1439
 
1410
- const modeOption = (value, label, hint, enabled) =>
1440
+ const modeOption = (value, label, hint, enabled, reason) =>
1411
1441
  h(
1412
1442
  'label',
1413
1443
  { key: value, className: `ferry-mode-label ${enabled ? '' : 'disabled'}` },
@@ -1423,7 +1453,7 @@ window.__ModuleLoader__.load({
1423
1453
  'span',
1424
1454
  { className: 'ferry-mode-text' },
1425
1455
  h('span', null, label),
1426
- h('span', { className: 'ferry-modal-hint' }, hint),
1456
+ h('span', { className: 'ferry-mode-hint' }, enabled ? hint : reason ?? t('modeUpdateUnavailable')),
1427
1457
  ),
1428
1458
  );
1429
1459
 
@@ -1448,18 +1478,19 @@ window.__ModuleLoader__.load({
1448
1478
  h(
1449
1479
  'div',
1450
1480
  { className: 'ferry-mode' },
1451
- h('div', { className: 'ferry-modal-hint' }, t('modeTitle')),
1481
+ h('div', { className: 'ferry-mode-title' }, t('modeTitle')),
1452
1482
  modeOption('new', t('modeNew'), t('modeNewHint'), true),
1453
1483
  modeOption(
1454
1484
  'update',
1455
1485
  t('modeUpdate'),
1456
- canAppend ? t('modeUpdateHint') : `${t('modeUpdateHint')} · ${t('modeUpdateUnavailable')}`,
1486
+ t('modeUpdateHint'),
1457
1487
  canAppend,
1488
+ blockedReason('local', plan, t),
1458
1489
  ),
1459
1490
  ),
1460
1491
  h(
1461
1492
  'label',
1462
- { className: 'ferry-mode-text', style: { gap: '4px' } },
1493
+ { className: 'ferry-stack' },
1463
1494
  h('span', { className: 'ferry-modal-hint' }, t('targetDir')),
1464
1495
  h('input', {
1465
1496
  className: 'ferry-input wide',
package/lib/state.js CHANGED
@@ -69,6 +69,65 @@ function updateHash(hash, value) {
69
69
  hash.update('}');
70
70
  }
71
71
 
72
+ /**
73
+ * Event types each instance writes for its own runtime, not as conversation.
74
+ *
75
+ * DSH records the locally chosen model, sandbox mode, permission preset, and a
76
+ * seed marker on open. Two instances that ever opened the same session hold
77
+ * their own copies of these events, stamped with their own clocks and values;
78
+ * requiring them to match would report divergence on every healthy pair.
79
+ */
80
+ export const LOCAL_EVENT_TYPES = new Set([
81
+ 'session/end-seed',
82
+ 'model/selection',
83
+ 'sandbox/mode',
84
+ 'permission/preset',
85
+ 'agent-preset/selected',
86
+ 'plan/mode',
87
+ 'working-directory/change',
88
+ 'goal/change',
89
+ 'session/title',
90
+ 'feedback/record',
91
+ 'feedback/message-put',
92
+ 'feedback/message-delete',
93
+ ]);
94
+
95
+ /**
96
+ * Whether one event belongs to the shared conversation rather than local state.
97
+ * @param {object} event - session event.
98
+ * @returns {boolean} true when both copies must agree on it.
99
+ */
100
+ export function isConversationEvent(event) {
101
+ return event === null || typeof event !== 'object' || !LOCAL_EVENT_TYPES.has(event.type);
102
+ }
103
+
104
+ /**
105
+ * Keep only the events both instances must agree on.
106
+ * @param {readonly object[]} events - session events, in seq order.
107
+ * @returns {object[]} conversation events.
108
+ */
109
+ export function conversationEvents(events) {
110
+ return events.filter(isConversationEvent);
111
+ }
112
+
113
+ /**
114
+ * Drop the one field that is written locally rather than carried in the log.
115
+ *
116
+ * DSH appends a `session/end-seed` marker when a session is opened, stamped
117
+ * with the local clock. Two instances that open the same session therefore
118
+ * hold the same event at the same seq with different times; that difference is
119
+ * not a divergence, and treating it as one blocks every legitimate append.
120
+ * @param {object} event - session event.
121
+ * @returns {object} event as compared.
122
+ */
123
+ 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;
127
+ }
128
+ return event;
129
+ }
130
+
72
131
  /**
73
132
  * Digest one contiguous slice of a session log.
74
133
  * @param {readonly object[]} events - session events, in seq order.
@@ -79,7 +138,7 @@ function updateHash(hash, value) {
79
138
  export function hashEvents(events, from = 0, to = events.length) {
80
139
  const hash = createHash('sha256');
81
140
  for (let index = from; index < to; index += 1) {
82
- updateHash(hash, events[index]);
141
+ updateHash(hash, comparableEvent(events[index]));
83
142
  hash.update('\n');
84
143
  }
85
144
  return hash.digest('hex');
package/lib/transfer.js CHANGED
@@ -15,7 +15,7 @@ import path from 'node:path';
15
15
  import { BundleReader, PROTOCOL, encodeBundle, validateManifest } from './bundle.js';
16
16
  import { HttpError } from './http.js';
17
17
  import { canonicalRoots, chooseImportTarget } from './paths.js';
18
- import { classifySession, hashEvents, readTail, tailOf } from './state.js';
18
+ import { classifySession, conversationEvents, hashEvents, isConversationEvent, readTail, tailOf } from './state.js';
19
19
  import { hashFile, hashPlan, planWorkspace, resolveBelow } from './workspace.js';
20
20
 
21
21
  /** Event types whose payload may embed an attachment reference. */
@@ -705,21 +705,32 @@ export function createTransfer({ ctx, config, rules, log = () => {} }) {
705
705
  events: rewriteAttachmentRefs(sessionPayload.events, attachmentReplacements),
706
706
  };
707
707
  }
708
- // An update may only append: prove the receiver's log is an exact prefix
709
- // of what arrived before touching either the log or the workspace.
708
+ // An update may only append: prove the receiver's conversation is a prefix
709
+ // of what arrived before touching either the log or the workspace. Each
710
+ // instance writes its own runtime events (model choice, seed marker), so
711
+ // those are compared away rather than read as divergence.
712
+ let appendFrom = 0;
710
713
  if (plan.mode === 'update') {
711
714
  const local = /** @type {{ events: object[] }} */ (plan.snapshot);
712
- const shared = local.events.length;
715
+ const mine = conversationEvents(local.events);
716
+ const incoming = conversationEvents(sessionPayload.events);
713
717
  if (
714
- sessionPayload.events.length < shared ||
715
- hashEvents(sessionPayload.events, 0, shared) !== hashEvents(local.events, 0, shared)
718
+ incoming.length < mine.length ||
719
+ hashEvents(incoming, 0, mine.length) !== hashEvents(mine)
716
720
  ) {
721
+ const mismatch = firstDivergence(mine, incoming);
717
722
  throw new HttpError(
718
723
  409,
719
724
  'session-diverged',
720
- `Session "${sessionId}" has diverged from this instance's copy; import it under a new id instead`,
725
+ `Session "${sessionId}" has diverged from this instance's copy${
726
+ mismatch === undefined ? '' : ` at event ${mismatch.seq} (${mismatch.type})`
727
+ }; import it under a new id instead`,
721
728
  );
722
729
  }
730
+ // Runtime events may sit at different positions on either side, so the
731
+ // new tail starts at the sender's next conversation event, not at the
732
+ // receiver's raw length.
733
+ appendFrom = appendOffset(sessionPayload.events, mine.length);
723
734
  }
724
735
  const header = {
725
736
  ...sessionPayload.header,
@@ -739,8 +750,22 @@ export function createTransfer({ ctx, config, rules, log = () => {} }) {
739
750
  const workspace = await ctx.workspaceRegistry.create(target);
740
751
  workspaceId = workspace.id;
741
752
  if (plan.mode === 'update') {
742
- const shared = /** @type {{ events: object[] }} */ (plan.snapshot).events.length;
743
- const handle = await ctx.sessionPersistence.open(sessionId, 'write', { signal });
753
+ const shared = appendFrom;
754
+ let handle;
755
+ try {
756
+ handle = await ctx.sessionPersistence.open(sessionId, 'write', { signal });
757
+ } catch (error) {
758
+ // The owner can appear between preflight and the upload; report it as
759
+ // the state it is instead of an internal error.
760
+ if (error instanceof Error && error.name === 'SessionAlreadyOwnedError') {
761
+ throw new HttpError(
762
+ 409,
763
+ 'session-active',
764
+ `Session "${sessionId}" is open on this instance; close it or restart the instance before updating it`,
765
+ );
766
+ }
767
+ throw error;
768
+ }
744
769
  try {
745
770
  const events = sessionPayload.events;
746
771
  for (let offset = shared; offset < events.length; offset += 200) {
@@ -965,6 +990,38 @@ async function* once(buffer) {
965
990
  if (buffer !== undefined && buffer.length > 0) yield buffer;
966
991
  }
967
992
 
993
+ /**
994
+ * Raw index in the incoming log where the receiver's new events begin.
995
+ * @param {readonly object[]} incoming - incoming session events.
996
+ * @param {number} matched - conversation events the receiver already holds.
997
+ * @returns {number} index into `incoming`; past the end when nothing is new.
998
+ */
999
+ function appendOffset(incoming, matched) {
1000
+ let seen = 0;
1001
+ for (let index = 0; index < incoming.length; index += 1) {
1002
+ if (!isConversationEvent(incoming[index])) continue;
1003
+ if (seen === matched) return index;
1004
+ seen += 1;
1005
+ }
1006
+ return incoming.length;
1007
+ }
1008
+
1009
+ /**
1010
+ * Find the first event two conversation logs disagree on, for diagnostics.
1011
+ * @param {readonly object[]} mine - receiver events.
1012
+ * @param {readonly object[]} incoming - incoming events.
1013
+ * @returns {{ seq: number, type: string } | undefined} first mismatch.
1014
+ */
1015
+ function firstDivergence(mine, incoming) {
1016
+ const shared = Math.min(mine.length, incoming.length);
1017
+ for (let index = 0; index < shared; index += 1) {
1018
+ if (hashEvents([mine[index]]) !== hashEvents([incoming[index]])) {
1019
+ return { seq: mine[index].seq, type: String(mine[index].type) };
1020
+ }
1021
+ }
1022
+ return shared === incoming.length ? { seq: mine[shared]?.seq ?? shared, type: 'missing event' } : undefined;
1023
+ }
1024
+
968
1025
  /**
969
1026
  * Reject a merge that would follow a symlinked parent directory out of the
970
1027
  * workspace. The manifest paths are already lexical-safe; this closes the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-ferry",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "type": "module",
5
5
  "description": "Move Sessions and their Workspace between DSH instances over any reachable network.",
6
6
  "license": "MIT",