dsh-ferry 0.1.0 → 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/README.md +3 -1
- package/client.js +77 -46
- package/lib/ferry.js +4 -0
- package/lib/routes.js +1 -0
- package/lib/state.js +75 -2
- package/lib/transfer.js +150 -20
- package/package.json +1 -1
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:
|
|
77
|
-
.ferry-mode-
|
|
78
|
-
.ferry-mode-label
|
|
79
|
-
.ferry-mode-
|
|
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:
|
|
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
|
|
168
|
+
modeNewHint: 'Create a separate copy; the peer keeps what it already has.',
|
|
165
169
|
modeUpdate: 'Append update',
|
|
166
|
-
modeUpdateHint: '
|
|
167
|
-
modeUpdateUnavailable: 'Not
|
|
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;
|
|
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;
|
|
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
|
|
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-
|
|
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-
|
|
1153
|
-
|
|
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-
|
|
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
|
-
|
|
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-
|
|
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-
|
|
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
|
-
|
|
1486
|
+
t('modeUpdateHint'),
|
|
1457
1487
|
canAppend,
|
|
1488
|
+
blockedReason('local', plan, t),
|
|
1458
1489
|
),
|
|
1459
1490
|
),
|
|
1460
1491
|
h(
|
|
1461
1492
|
'label',
|
|
1462
|
-
{ className: 'ferry-
|
|
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/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,12 +70,78 @@ 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('}');
|
|
70
80
|
}
|
|
71
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Event types each instance writes for its own runtime, not as conversation.
|
|
84
|
+
*
|
|
85
|
+
* DSH records the locally chosen model, sandbox mode, permission preset, and a
|
|
86
|
+
* seed marker on open. Two instances that ever opened the same session hold
|
|
87
|
+
* their own copies of these events, stamped with their own clocks and values;
|
|
88
|
+
* requiring them to match would report divergence on every healthy pair.
|
|
89
|
+
*/
|
|
90
|
+
export const LOCAL_EVENT_TYPES = new Set([
|
|
91
|
+
'session/end-seed',
|
|
92
|
+
'model/selection',
|
|
93
|
+
'sandbox/mode',
|
|
94
|
+
'permission/preset',
|
|
95
|
+
'agent-preset/selected',
|
|
96
|
+
'plan/mode',
|
|
97
|
+
'working-directory/change',
|
|
98
|
+
'goal/change',
|
|
99
|
+
'session/title',
|
|
100
|
+
'feedback/record',
|
|
101
|
+
'feedback/message-put',
|
|
102
|
+
'feedback/message-delete',
|
|
103
|
+
]);
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Whether one event belongs to the shared conversation rather than local state.
|
|
107
|
+
* @param {object} event - session event.
|
|
108
|
+
* @returns {boolean} true when both copies must agree on it.
|
|
109
|
+
*/
|
|
110
|
+
export function isConversationEvent(event) {
|
|
111
|
+
return event === null || typeof event !== 'object' || !LOCAL_EVENT_TYPES.has(event.type);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Keep only the events both instances must agree on.
|
|
116
|
+
* @param {readonly object[]} events - session events, in seq order.
|
|
117
|
+
* @returns {object[]} conversation events.
|
|
118
|
+
*/
|
|
119
|
+
export function conversationEvents(events) {
|
|
120
|
+
return events.filter(isConversationEvent);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Drop the one field that is written locally rather than carried in the log.
|
|
125
|
+
*
|
|
126
|
+
* DSH appends a `session/end-seed` marker when a session is opened, stamped
|
|
127
|
+
* with the local clock. Two instances that open the same session therefore
|
|
128
|
+
* hold the same event at the same seq with different times; that difference is
|
|
129
|
+
* not a divergence, and treating it as one blocks every legitimate append.
|
|
130
|
+
* @param {object} event - session event.
|
|
131
|
+
* @returns {object} event as compared.
|
|
132
|
+
*/
|
|
133
|
+
function comparableEvent(event) {
|
|
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;
|
|
141
|
+
}
|
|
142
|
+
return rest;
|
|
143
|
+
}
|
|
144
|
+
|
|
72
145
|
/**
|
|
73
146
|
* Digest one contiguous slice of a session log.
|
|
74
147
|
* @param {readonly object[]} events - session events, in seq order.
|
|
@@ -79,7 +152,7 @@ function updateHash(hash, value) {
|
|
|
79
152
|
export function hashEvents(events, from = 0, to = events.length) {
|
|
80
153
|
const hash = createHash('sha256');
|
|
81
154
|
for (let index = from; index < to; index += 1) {
|
|
82
|
-
updateHash(hash, events[index]);
|
|
155
|
+
updateHash(hash, comparableEvent(events[index]));
|
|
83
156
|
hash.update('\n');
|
|
84
157
|
}
|
|
85
158
|
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. */
|
|
@@ -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 };
|
|
@@ -705,21 +714,32 @@ export function createTransfer({ ctx, config, rules, log = () => {} }) {
|
|
|
705
714
|
events: rewriteAttachmentRefs(sessionPayload.events, attachmentReplacements),
|
|
706
715
|
};
|
|
707
716
|
}
|
|
708
|
-
// An update may only append: prove the receiver's
|
|
709
|
-
// of what arrived before touching either the log or the workspace.
|
|
717
|
+
// An update may only append: prove the receiver's conversation is a prefix
|
|
718
|
+
// of what arrived before touching either the log or the workspace. Each
|
|
719
|
+
// instance writes its own runtime events (model choice, seed marker), so
|
|
720
|
+
// those are compared away rather than read as divergence.
|
|
721
|
+
let appendFrom = 0;
|
|
710
722
|
if (plan.mode === 'update') {
|
|
711
723
|
const local = /** @type {{ events: object[] }} */ (plan.snapshot);
|
|
712
|
-
const
|
|
724
|
+
const mine = conversationEvents(local.events);
|
|
725
|
+
const incoming = conversationEvents(sessionPayload.events);
|
|
713
726
|
if (
|
|
714
|
-
|
|
715
|
-
hashEvents(
|
|
727
|
+
incoming.length < mine.length ||
|
|
728
|
+
hashEvents(incoming, 0, mine.length) !== hashEvents(mine)
|
|
716
729
|
) {
|
|
730
|
+
const mismatch = firstDivergence(mine, incoming);
|
|
717
731
|
throw new HttpError(
|
|
718
732
|
409,
|
|
719
733
|
'session-diverged',
|
|
720
|
-
`Session "${sessionId}" has diverged from this instance's copy
|
|
734
|
+
`Session "${sessionId}" has diverged from this instance's copy${
|
|
735
|
+
mismatch === undefined ? '' : ` at event ${mismatch.seq} (${mismatch.type})`
|
|
736
|
+
}; import it under a new id instead`,
|
|
721
737
|
);
|
|
722
738
|
}
|
|
739
|
+
// Runtime events may sit at different positions on either side, so the
|
|
740
|
+
// new tail starts at the sender's next conversation event, not at the
|
|
741
|
+
// receiver's raw length.
|
|
742
|
+
appendFrom = appendOffset(sessionPayload.events, mine.length);
|
|
723
743
|
}
|
|
724
744
|
const header = {
|
|
725
745
|
...sessionPayload.header,
|
|
@@ -739,13 +759,37 @@ export function createTransfer({ ctx, config, rules, log = () => {} }) {
|
|
|
739
759
|
const workspace = await ctx.workspaceRegistry.create(target);
|
|
740
760
|
workspaceId = workspace.id;
|
|
741
761
|
if (plan.mode === 'update') {
|
|
742
|
-
const shared =
|
|
743
|
-
|
|
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
|
+
let handle;
|
|
744
775
|
try {
|
|
745
|
-
|
|
746
|
-
|
|
776
|
+
handle = await ctx.sessionPersistence.open(sessionId, 'write', { signal });
|
|
777
|
+
} catch (error) {
|
|
778
|
+
// The owner can appear between preflight and the upload; report it as
|
|
779
|
+
// the state it is instead of an internal error.
|
|
780
|
+
if (error instanceof Error && error.name === 'SessionAlreadyOwnedError') {
|
|
781
|
+
throw new HttpError(
|
|
782
|
+
409,
|
|
783
|
+
'session-active',
|
|
784
|
+
`Session "${sessionId}" is open on this instance; close it or restart the instance before updating it`,
|
|
785
|
+
);
|
|
786
|
+
}
|
|
787
|
+
throw error;
|
|
788
|
+
}
|
|
789
|
+
try {
|
|
790
|
+
for (let offset = 0; offset < tail.length; offset += 200) {
|
|
747
791
|
signal?.throwIfAborted();
|
|
748
|
-
await handle.append(
|
|
792
|
+
await handle.append(tail.slice(offset, offset + 200), { signal });
|
|
749
793
|
}
|
|
750
794
|
await handle.flush({ signal });
|
|
751
795
|
} finally {
|
|
@@ -767,7 +811,7 @@ export function createTransfer({ ctx, config, rules, log = () => {} }) {
|
|
|
767
811
|
sessionId,
|
|
768
812
|
targetDir: target,
|
|
769
813
|
workspaceId,
|
|
770
|
-
appended:
|
|
814
|
+
appended: tail.length,
|
|
771
815
|
events: sessionPayload.events.length,
|
|
772
816
|
attachments: { rewritten: attachmentReplacements.size },
|
|
773
817
|
workspace: {
|
|
@@ -861,6 +905,7 @@ export function createTransfer({ ctx, config, rules, log = () => {} }) {
|
|
|
861
905
|
targetDir,
|
|
862
906
|
merge = false,
|
|
863
907
|
tail,
|
|
908
|
+
conversationCount,
|
|
864
909
|
}) => {
|
|
865
910
|
const target = await resolveImportTarget({ requested: targetDir, sourceRoot, sessionId });
|
|
866
911
|
const currentVersion = await currentFormatVersion();
|
|
@@ -898,10 +943,14 @@ export function createTransfer({ ctx, config, rules, log = () => {} }) {
|
|
|
898
943
|
}`,
|
|
899
944
|
};
|
|
900
945
|
}
|
|
901
|
-
|
|
946
|
+
const conversation = conversationEvents(snapshot.events);
|
|
947
|
+
localCount = conversation.length;
|
|
902
948
|
state = classifySession(
|
|
903
|
-
{ count: localCount, tail: tailOf(
|
|
904
|
-
{
|
|
949
|
+
{ count: localCount, tail: tailOf(conversation) },
|
|
950
|
+
{
|
|
951
|
+
count: typeof conversationCount === 'number' ? conversationCount : eventCount,
|
|
952
|
+
tail: readTail(tail),
|
|
953
|
+
},
|
|
905
954
|
);
|
|
906
955
|
if (state === 'unknown') {
|
|
907
956
|
// A sender predating tail fingerprints only reports its creation time.
|
|
@@ -965,6 +1014,87 @@ async function* once(buffer) {
|
|
|
965
1014
|
if (buffer !== undefined && buffer.length > 0) yield buffer;
|
|
966
1015
|
}
|
|
967
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
|
+
|
|
1066
|
+
/**
|
|
1067
|
+
* Raw index in the incoming log where the receiver's new events begin.
|
|
1068
|
+
* @param {readonly object[]} incoming - incoming session events.
|
|
1069
|
+
* @param {number} matched - conversation events the receiver already holds.
|
|
1070
|
+
* @returns {number} index into `incoming`; past the end when nothing is new.
|
|
1071
|
+
*/
|
|
1072
|
+
function appendOffset(incoming, matched) {
|
|
1073
|
+
let seen = 0;
|
|
1074
|
+
for (let index = 0; index < incoming.length; index += 1) {
|
|
1075
|
+
if (!isConversationEvent(incoming[index])) continue;
|
|
1076
|
+
if (seen === matched) return index;
|
|
1077
|
+
seen += 1;
|
|
1078
|
+
}
|
|
1079
|
+
return incoming.length;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
/**
|
|
1083
|
+
* Find the first event two conversation logs disagree on, for diagnostics.
|
|
1084
|
+
* @param {readonly object[]} mine - receiver events.
|
|
1085
|
+
* @param {readonly object[]} incoming - incoming events.
|
|
1086
|
+
* @returns {{ seq: number, type: string } | undefined} first mismatch.
|
|
1087
|
+
*/
|
|
1088
|
+
function firstDivergence(mine, incoming) {
|
|
1089
|
+
const shared = Math.min(mine.length, incoming.length);
|
|
1090
|
+
for (let index = 0; index < shared; index += 1) {
|
|
1091
|
+
if (hashEvents([mine[index]]) !== hashEvents([incoming[index]])) {
|
|
1092
|
+
return { seq: mine[index].seq, type: String(mine[index].type) };
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
return shared === incoming.length ? { seq: mine[shared]?.seq ?? shared, type: 'missing event' } : undefined;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
968
1098
|
/**
|
|
969
1099
|
* Reject a merge that would follow a symlinked parent directory out of the
|
|
970
1100
|
* workspace. The manifest paths are already lexical-safe; this closes the
|