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 +3 -1
- package/client.js +77 -46
- package/lib/state.js +60 -1
- package/lib/transfer.js +66 -9
- 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/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
|
|
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
|
|
715
|
+
const mine = conversationEvents(local.events);
|
|
716
|
+
const incoming = conversationEvents(sessionPayload.events);
|
|
713
717
|
if (
|
|
714
|
-
|
|
715
|
-
hashEvents(
|
|
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
|
|
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 =
|
|
743
|
-
|
|
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
|