@artenx/dshb 0.1.18 → 0.1.19

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.
Files changed (101) hide show
  1. package/lib/client.js +2157 -567
  2. package/lib/client.js.map +1 -1
  3. package/lib/core/index.js +72 -4
  4. package/lib/core/index.js.map +1 -1
  5. package/lib/core/node-registry.d.ts +2 -108
  6. package/lib/core/node-registry.js +43 -2
  7. package/lib/core/node-registry.js.map +1 -1
  8. package/lib/core/routes.d.ts +4 -3
  9. package/lib/core/routes.js +67 -23
  10. package/lib/core/routes.js.map +1 -1
  11. package/lib/core/sync/coordinator.d.ts +48 -0
  12. package/lib/core/sync/coordinator.js +247 -0
  13. package/lib/core/sync/coordinator.js.map +1 -0
  14. package/lib/core/sync/host-key.d.ts +2 -0
  15. package/lib/core/sync/host-key.js +71 -0
  16. package/lib/core/sync/host-key.js.map +1 -0
  17. package/lib/core/sync/internal-routes.d.ts +9 -0
  18. package/lib/core/sync/internal-routes.js +236 -0
  19. package/lib/core/sync/internal-routes.js.map +1 -0
  20. package/lib/core/sync/model.d.ts +2 -0
  21. package/lib/core/sync/model.js +106 -0
  22. package/lib/core/sync/model.js.map +1 -0
  23. package/lib/core/sync/node-sync.d.ts +2 -0
  24. package/lib/core/sync/node-sync.js +660 -0
  25. package/lib/core/sync/node-sync.js.map +1 -0
  26. package/lib/core/sync/offline-archive.d.ts +55 -0
  27. package/lib/core/sync/offline-archive.js +673 -0
  28. package/lib/core/sync/offline-archive.js.map +1 -0
  29. package/lib/core/sync/offline-journal.d.ts +22 -0
  30. package/lib/core/sync/offline-journal.js +74 -0
  31. package/lib/core/sync/offline-journal.js.map +1 -0
  32. package/lib/core/sync/offline-routes.d.ts +7 -0
  33. package/lib/core/sync/offline-routes.js +192 -0
  34. package/lib/core/sync/offline-routes.js.map +1 -0
  35. package/lib/core/sync/peer-manager.d.ts +56 -0
  36. package/lib/core/sync/peer-manager.js +258 -0
  37. package/lib/core/sync/peer-manager.js.map +1 -0
  38. package/lib/core/sync/protocol.d.ts +2 -0
  39. package/lib/core/sync/protocol.js +124 -0
  40. package/lib/core/sync/protocol.js.map +1 -0
  41. package/lib/core/sync/remote-helper.d.ts +5 -0
  42. package/lib/core/sync/remote-helper.js +160 -0
  43. package/lib/core/sync/remote-helper.js.map +1 -0
  44. package/lib/core/sync/revision.d.ts +2 -0
  45. package/lib/core/sync/revision.js +77 -0
  46. package/lib/core/sync/revision.js.map +1 -0
  47. package/lib/core/sync/routes.d.ts +9 -0
  48. package/lib/core/sync/routes.js +339 -0
  49. package/lib/core/sync/routes.js.map +1 -0
  50. package/lib/core/sync/scheduler.d.ts +14 -0
  51. package/lib/core/sync/scheduler.js +81 -0
  52. package/lib/core/sync/scheduler.js.map +1 -0
  53. package/lib/core/sync/session-journal.d.ts +2 -0
  54. package/lib/core/sync/session-journal.js +191 -0
  55. package/lib/core/sync/session-journal.js.map +1 -0
  56. package/lib/core/sync/session-routes.d.ts +7 -0
  57. package/lib/core/sync/session-routes.js +67 -0
  58. package/lib/core/sync/session-routes.js.map +1 -0
  59. package/lib/core/sync/session-sync.d.ts +2 -0
  60. package/lib/core/sync/session-sync.js +2 -0
  61. package/lib/core/sync/ssh-transport.d.ts +2 -0
  62. package/lib/core/sync/ssh-transport.js +408 -0
  63. package/lib/core/sync/ssh-transport.js.map +1 -0
  64. package/lib/core/sync/stores.d.ts +3 -0
  65. package/lib/core/sync/stores.js +385 -0
  66. package/lib/core/sync/stores.js.map +1 -0
  67. package/lib/core/test.d.ts +1 -1
  68. package/lib/core/workspace-bindings.d.ts +2 -28
  69. package/lib/core/workspace-bindings.js +14 -14
  70. package/lib/core/workspace-bindings.js.map +1 -1
  71. package/lib/core/workspace-instruction-context.d.ts +3 -2260
  72. package/lib/core/workspace-instruction-context.js +104 -2
  73. package/lib/core/workspace-instruction-context.js.map +1 -0
  74. package/lib/core/workspace-instruction-routes.d.ts +1 -1
  75. package/lib/core/workspace-instructions.d.ts +2 -68
  76. package/lib/core/workspace-instructions.js +13 -0
  77. package/lib/core/workspace-instructions.js.map +1 -1
  78. package/lib/core/workspaces.d.ts +4 -3
  79. package/lib/core/workspaces.js +17 -1
  80. package/lib/core/workspaces.js.map +1 -1
  81. package/lib/host-key-C5afhAUd.d.ts +30 -0
  82. package/lib/index-VF8bcBeX.d.ts +2293 -0
  83. package/lib/{workspace-instruction-context-DytAmaPY.js → lib-gdtU_WKT.js} +159 -102
  84. package/lib/lib-gdtU_WKT.js.map +1 -0
  85. package/lib/model-BP7Gw1yk.d.ts +104 -0
  86. package/lib/node-registry-BTnnM5-k.d.ts +112 -0
  87. package/lib/node-sync-3Z9QLz2n.d.ts +89 -0
  88. package/lib/protocol-CV7LGE44.d.ts +35 -0
  89. package/lib/revision-C9sOlDZ6.d.ts +25 -0
  90. package/lib/router/router-fs.d.ts +12 -3
  91. package/lib/router/router-fs.js.map +1 -1
  92. package/lib/session-journal-pgPqcWn9.d.ts +40 -0
  93. package/lib/session-sync-EdfwUON5.js +2177 -0
  94. package/lib/session-sync-EdfwUON5.js.map +1 -0
  95. package/lib/session-sync-g6qKZzjV.d.ts +1252 -0
  96. package/lib/ssh-transport-BcIiLxsX.d.ts +55 -0
  97. package/lib/stores-CKyEt0yk.d.ts +31 -0
  98. package/lib/workspace-bindings-CsB0TIJB.d.ts +28 -0
  99. package/lib/workspace-instructions-DirYYiku.d.ts +69 -0
  100. package/package.json +20 -1
  101. package/lib/workspace-instruction-context-DytAmaPY.js.map +0 -1
@@ -0,0 +1,2177 @@
1
+ import { compareSyncRevision } from "./core/sync/revision.js";
2
+ import { isNonNegativeSafeInteger, isRecord as isRecord$1, syncRecordKey, syncWorkspaceKey } from "./core/sync/model.js";
3
+ import "./core/workspace-instructions.js";
4
+ import { i as deepFreeze, r as brandString } from "./lib-gdtU_WKT.js";
5
+ import { SyncSessionJournalStore } from "./core/sync/session-journal.js";
6
+ import { MAX_SYNC_TRANSFER_BYTES } from "./core/sync/protocol.js";
7
+ import { tmpdir } from "node:os";
8
+ import { constants, mkdirSync } from "node:fs";
9
+ import { basename, dirname, join, normalize, posix, resolve } from "node:path";
10
+ import { createHash } from "node:crypto";
11
+ import "@deepseek-ai/cordis";
12
+ import { lstat, mkdir, open, realpath, unlink } from "node:fs/promises";
13
+ //#region ../../node_modules/@deepseek-ai/dsh-session/lib/index.js
14
+ /**
15
+ * Brand a string as a {@link SessionId}.
16
+ * @param id - the raw session id string.
17
+ * @returns the same string with the session-id brand.
18
+ */
19
+ function SessionId(id) {
20
+ return brandString(id);
21
+ }
22
+ /**
23
+ * GENERATED by `scripts/gen-persistence-catalog.ts` — do not edit by hand; run
24
+ * `pnpm run gen-persistence-catalog` to regenerate (verified fresh by
25
+ * `pnpm run verify-persistence-catalog`, part of `doc-sync`).
26
+ * @module @deepseek-ai/dsh-session/known-event-types
27
+ */
28
+ /**
29
+ * Every `SessionEventMap` member declared in this repository — the event
30
+ * vocabulary this build understands. The persistence read path refuses to
31
+ * interpret a log containing a type outside this set unless the event
32
+ * carries the envelope's `ignorable` marker (see `SessionEvent.ignorable`
33
+ * in `./types.ts`): such a log was likely written by a newer harness, and
34
+ * silently skipping a required event would reconstruct a wrong session.
35
+ * Downstream (out-of-repo) plugin events are outside this list by
36
+ * construction. The persisted `SessionEvent.ignorable` marker is the
37
+ * compatibility mechanism; event-name registration was rejected because
38
+ * it does not classify omission safety and would make reads
39
+ * composition-dependent. The rationale is in
40
+ * `.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.md`.
41
+ */
42
+ const KNOWN_SESSION_EVENT_TYPES = /* @__PURE__ */ new Set([
43
+ "agent-preset/selected",
44
+ "agent/inbox/spliced",
45
+ "approval/asked",
46
+ "approval/decided",
47
+ "approval/policy",
48
+ "assistant/attempt",
49
+ "assistant/message",
50
+ "command/done",
51
+ "command/run",
52
+ "compaction/end",
53
+ "compaction/prune",
54
+ "compaction/start",
55
+ "compaction/summary",
56
+ "deliverables/presented",
57
+ "developer/message",
58
+ "feedback/message-delete",
59
+ "feedback/message-put",
60
+ "feedback/record",
61
+ "goal/change",
62
+ "hook/invoked",
63
+ "hook/result",
64
+ "image/offload",
65
+ "job/registered",
66
+ "job/settled",
67
+ "llm/retry",
68
+ "llm/retry-started",
69
+ "model/selection",
70
+ "permission/preset",
71
+ "plan/mode",
72
+ "request/context",
73
+ "request/header",
74
+ "sandbox/mode",
75
+ "schedule/change",
76
+ "session-log-deepseek/delivery-accepted",
77
+ "session/end-seed",
78
+ "session/title",
79
+ "session/title-llm-request",
80
+ "step/end",
81
+ "step/start",
82
+ "subagent/catalog",
83
+ "subagent/descriptor",
84
+ "subagent/model-selection-policy",
85
+ "system/message",
86
+ "team/member",
87
+ "team/message/delivered",
88
+ "team/message/queued",
89
+ "team/task",
90
+ "todo/write",
91
+ "tool-workflow/agent-end",
92
+ "tool-workflow/agent-start",
93
+ "tool-workflow/run-end",
94
+ "tool-workflow/run-start",
95
+ "tool/call",
96
+ "tool/ptc-dispatch",
97
+ "tool/ptc-dispatch-start",
98
+ "tool/result",
99
+ "turn/end",
100
+ "turn/start",
101
+ "user/message",
102
+ "web/deepseek-search-llm-request",
103
+ "working-directory/change",
104
+ "workspace/changes"
105
+ ]);
106
+ /**
107
+ * Surface layer on top of the session event log: an ordered view of events
108
+ * that produce LLM messages. The append-only log remains the source of truth.
109
+ *
110
+ * Browser-safe: web clients consume this subpath export, so it must stay free
111
+ * of `node:` imports (they break the vite bundle).
112
+ *
113
+ * @module @deepseek-ai/dsh-session/surface
114
+ */
115
+ /** Runtime counterpart of the message-producing event union. */
116
+ const SURFACE_EVENT_TYPES = /* @__PURE__ */ new Set([
117
+ "system/message",
118
+ "developer/message",
119
+ "user/message",
120
+ "assistant/message",
121
+ "tool/result"
122
+ ]);
123
+ /**
124
+ * Whether an event type can join the model-visible surface.
125
+ * @param type - event type to test.
126
+ * @returns true for one of the message-producing event types.
127
+ */
128
+ function isSurfaceEligibleType(type) {
129
+ return SURFACE_EVENT_TYPES.has(type);
130
+ }
131
+ /** Whether a payload field is a JSON object rather than an array or scalar. */
132
+ function isRecord(value) {
133
+ return typeof value === "object" && value !== null && !Array.isArray(value);
134
+ }
135
+ /**
136
+ * Reject noncanonical request-header fields, developer roles/content, and contradictory tool failure metadata.
137
+ * This does not validate complete event payloads or embedded provider streams.
138
+ * @param event - event whose locally related payload fields are inspected.
139
+ * @param subject - event location to include in validation errors.
140
+ * @throws when request-header fields, developer roles/content, or tool failure metadata are invalid.
141
+ */
142
+ function validateSessionEventData(event, subject) {
143
+ const data = event.data;
144
+ if (SURFACE_EVENT_TYPES.has(event.type) && isRecord(data)) {
145
+ const message = event.type === "user/message" ? data : data["message"];
146
+ if (isRecord(message)) {
147
+ if (event.type === "developer/message" !== (message["role"] === "developer")) throw new Error(`${subject} developer/message and developer role must occur together`);
148
+ if (message["role"] !== "developer" && Array.isArray(message["content"]) && message["content"].some((block) => isRecord(block) && (block["type"] === "tool-addition" || block["type"] === "tool-removal"))) throw new Error(`${subject} tool-change blocks require developer role`);
149
+ if (event.type === "developer/message" && Array.isArray(message["content"])) {
150
+ let hasAdditions = false;
151
+ for (const block of message["content"]) {
152
+ if (!isRecord(block) || block["type"] !== "tool-addition" && block["type"] !== "tool-removal") continue;
153
+ if (typeof block["toolName"] !== "string" || block["toolName"].length === 0) throw new Error(`${subject} ${block["type"]} requires a nonempty toolName`);
154
+ if (block["type"] === "tool-addition") {
155
+ hasAdditions = true;
156
+ if (Object.hasOwn(block, "tool")) throw new Error(`${subject} tool-addition must omit inline tool definitions`);
157
+ }
158
+ }
159
+ if (hasAdditions ? !isEventSeq(data["headerSeq"]) : Object.hasOwn(data, "headerSeq")) throw new Error(`${subject} requires headerSeq exactly when tool additions are present`);
160
+ }
161
+ }
162
+ }
163
+ if (event.type === "request/header") {
164
+ if (!isRecord(data)) throw new Error(`${subject} data must be an object`);
165
+ const header = data["header"];
166
+ if (!isRecord(header)) throw new Error(`${subject} header must be an object`);
167
+ if (Object.hasOwn(header, "system")) throw new Error(`${subject} must omit header.system; use system/message`);
168
+ if (Array.isArray(header["tools"]) && header["tools"].length === 0) throw new Error(`${subject} must omit empty tools`);
169
+ const defaults = header["adapterDefaults"];
170
+ if (isRecord(defaults) && Object.keys(defaults).length === 0) throw new Error(`${subject} must omit empty adapterDefaults`);
171
+ } else if (event.type === "tool/result") {
172
+ if (!isRecord(data)) throw new Error(`${subject} data must be an object`);
173
+ if (data["error"] === void 0) return;
174
+ const message = data["message"];
175
+ if (!isRecord(message) || message["isError"] !== true) throw new Error(`${subject} error requires message.isError === true`);
176
+ }
177
+ }
178
+ /** Whether a runtime value is a non-negative safe event sequence. */
179
+ function isEventSeq(value) {
180
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && !Object.is(value, -0);
181
+ }
182
+ /** Whether a runtime value is the exact positional-replacement shape. */
183
+ function isReplaceOp(value) {
184
+ const op = value;
185
+ return Object.keys(op).length === 3 && Object.hasOwn(op, "op") && Object.hasOwn(op, "startSeq") && Object.hasOwn(op, "endSeq") && op["op"] === "replace" && isEventSeq(op["startSeq"]) && isEventSeq(op["endSeq"]);
186
+ }
187
+ /** Validate event-local surface eligibility and return its operation. */
188
+ function surfaceOpOf(event) {
189
+ const raw = event;
190
+ if (!isSurfaceEligibleType(event.type)) {
191
+ if (!KNOWN_SESSION_EVENT_TYPES.has(event.type) && event.ignorable === true) return;
192
+ if (raw.surfaceOp !== void 0) throw new Error(`session event "${event.type}" is not surface-eligible and cannot carry surfaceOp`);
193
+ if (raw.sourceEventSeqs !== void 0) throw new Error(`session event "${event.type}" is not surface-eligible and cannot carry sourceEventSeqs`);
194
+ return;
195
+ }
196
+ const op = raw.surfaceOp;
197
+ if (op === void 0) throw new Error(`session event "${event.type}" is surface-eligible and requires a surfaceOp marker`);
198
+ if (op === "append") return op;
199
+ if (op === null || typeof op !== "object" || Array.isArray(op)) throw new Error(`session event "${event.type}" carries an invalid surfaceOp`);
200
+ if (!isReplaceOp(op)) throw new Error(`session event "${event.type}" carries an invalid replace surfaceOp`);
201
+ return op;
202
+ }
203
+ /** Validate cited source-event seqs against prior log entries and the replacement range. */
204
+ function assertSourceEventReferences(event, shadowedSeqs) {
205
+ const raw = event.sourceEventSeqs;
206
+ if (event.type === "assistant/message" && raw !== void 0) throw new Error("assistant/message embeds its source stream and cannot carry sourceEventSeqs");
207
+ const sources = /* @__PURE__ */ new Set();
208
+ if (raw !== void 0) {
209
+ if (!Array.isArray(raw)) throw new Error(`sourceEventSeqs on event at seq ${event.seq} must be an array when present`);
210
+ if (raw.length === 0) throw new Error("sourceEventSeqs must not be empty");
211
+ let nonEarlierSource;
212
+ for (const source of raw) {
213
+ if (!isEventSeq(source)) throw new Error(`session event "${event.type}" sourceEventSeqs must densely contain non-negative safe integers`);
214
+ sources.add(source);
215
+ if (nonEarlierSource === void 0 && source >= event.seq) nonEarlierSource = source;
216
+ }
217
+ if (sources.size !== raw.length) throw new Error("sourceEventSeqs must not contain duplicates");
218
+ if (nonEarlierSource !== void 0) throw new Error(`sourceEventSeqs must reference earlier events: ${nonEarlierSource} >= current seq ${event.seq}`);
219
+ }
220
+ const missing = shadowedSeqs.filter((seq) => !sources.has(seq));
221
+ if (missing.length > 0) throw new Error(`surface replace: sourceEventSeqs must include every shadowed surface node; missing ${missing.join(", ")}`);
222
+ }
223
+ /**
224
+ * Validate one event's surface metadata without checking membership in a log or surface.
225
+ * @param event - event whose marker and source sequence values are inspected.
226
+ * Unknown ignorable records retain opaque metadata and never change the surface.
227
+ * @returns the validated operation, or undefined for a log-only or unknown ignorable event.
228
+ * @throws when metadata violates event-local eligibility, marker, or source-sequence rules.
229
+ */
230
+ function validateSurfaceMetadata(event) {
231
+ const op = surfaceOpOf(event);
232
+ if (op !== void 0 && op !== "append" && (op.startSeq >= event.seq || op.endSeq >= event.seq)) throw new Error(`surface replace at seq ${event.seq}: startSeq and endSeq must reference earlier events`);
233
+ if (op !== void 0) assertSourceEventReferences(event, []);
234
+ return op;
235
+ }
236
+ /**
237
+ * Validate an exclusively owned event and deeply freeze its identified message
238
+ * without copying the event. The caller transfers an object graph that no
239
+ * producer retains and that shares no mutable children with another event.
240
+ * Use {@link snapshotSessionEvent} when exclusive ownership is not guaranteed.
241
+ * @param event - exclusively owned event imported across a trusted boundary.
242
+ * @returns the same event object with a validated, deeply frozen message.
243
+ * @throws when event-local surface metadata, request-header fields, or message invariants are invalid; history relations are not checked.
244
+ */
245
+ function adoptSessionEvent(event) {
246
+ validateSessionEventData(event, `session event at seq ${event.seq}`);
247
+ validateSurfaceMetadata(event);
248
+ assertMessageEventShape(event, `session event at seq ${event.seq}`);
249
+ switch (event.type) {
250
+ case "user/message":
251
+ deepFreeze(event.data);
252
+ break;
253
+ case "developer/message":
254
+ case "system/message":
255
+ case "assistant/message":
256
+ case "tool/result": deepFreeze(event.data.message);
257
+ }
258
+ return event;
259
+ }
260
+ /**
261
+ * Detach one event while preserving deep immutability for its identified message.
262
+ * @param event - event imported across a query or persistence boundary.
263
+ * @returns a detached event snapshot with a validated, deeply frozen message.
264
+ */
265
+ function snapshotSessionEvent(event) {
266
+ return adoptSessionEvent(structuredClone(event));
267
+ }
268
+ /** The surface event types whose payload carries an identified message. */
269
+ function isMessageEventType(type) {
270
+ return type === "developer/message" || type === "system/message" || type === "user/message" || type === "assistant/message" || type === "tool/result";
271
+ }
272
+ const MESSAGE_ROLE_BY_TYPE = {
273
+ "system/message": "system",
274
+ "developer/message": "developer",
275
+ "user/message": "user",
276
+ "assistant/message": "assistant",
277
+ "tool/result": "tool"
278
+ };
279
+ /** Validate only the event-specific invariants needed to safely replay a message. */
280
+ function assertMessageEventShape(event, subject) {
281
+ const type = event["type"];
282
+ if (!isMessageEventType(type)) return;
283
+ const data = event["data"];
284
+ const record = typeof data === "object" && data !== null ? data : void 0;
285
+ const message = type === "user/message" ? record : record?.["message"];
286
+ if (typeof message !== "object" || message === null || typeof message["id"] !== "string" || message["id"] === "") throw new Error(`${subject} lacks an identified message`);
287
+ const messageRecord = message;
288
+ const expectedRole = MESSAGE_ROLE_BY_TYPE[type];
289
+ if (messageRecord["role"] !== expectedRole) throw new Error(`${subject} message must have role "${expectedRole}"`);
290
+ const source = messageRecord["source"];
291
+ if (typeof source !== "object" || source === null || typeof source["kind"] !== "string" || source["kind"] === "") throw new Error(`${subject} message has invalid source`);
292
+ if (!Array.isArray(messageRecord["content"])) throw new Error(`${subject} message has invalid content`);
293
+ const sourceRecord = source;
294
+ if (type === "system/message") {
295
+ if (sourceRecord["kind"] !== "system-prompt") throw new Error(`${subject} message must have system-prompt source`);
296
+ return;
297
+ }
298
+ if (type === "assistant/message") {
299
+ if (sourceRecord["kind"] !== "model" || !hasProviderModel(sourceRecord)) throw new Error(`${subject} message must have model source`);
300
+ return;
301
+ }
302
+ if (type !== "tool/result") return;
303
+ if (sourceRecord["kind"] !== "tool" || typeof sourceRecord["callId"] !== "string" || sourceRecord["callId"] === "") throw new Error(`${subject} message must have tool source`);
304
+ if (messageRecord["toolCallId"] !== sourceRecord["callId"]) throw new Error(`${subject} message has mismatched tool call ids`);
305
+ }
306
+ /** Whether an unknown value carries the current provider/model pair. */
307
+ function hasProviderModel(value) {
308
+ if (typeof value !== "object" || value === null) return false;
309
+ const pair = value;
310
+ return typeof pair["provider"] === "string" && pair["provider"].length > 0 && typeof pair["model"] === "string" && pair["model"].length > 0;
311
+ }
312
+ //#endregion
313
+ //#region ../../node_modules/@deepseek-ai/dsh-session-persistence/lib/index.js
314
+ /** Durable session contents failed validation after a successful backend read. */
315
+ var SessionPersistenceCorruptionError = class extends Error {
316
+ /**
317
+ * @param message - stable corruption context.
318
+ * @param options - original validation failure.
319
+ */
320
+ constructor(message, options) {
321
+ super(message, options);
322
+ this.name = "SessionPersistenceCorruptionError";
323
+ }
324
+ };
325
+ /**
326
+ * The stored log is intact but this runtime cannot faithfully interpret it:
327
+ * the header carries an unsupported format version, or an event's type is
328
+ * unknown to this build. Distinct from {@link SessionPersistenceCorruptionError}
329
+ * — nothing is damaged; the raw log remains readable at {@link location} when
330
+ * the backend keeps one artifact per session.
331
+ */
332
+ var SessionFormatUnsupportedError = class extends Error {
333
+ location;
334
+ /**
335
+ * @param message - stable reason the log cannot be interpreted, already
336
+ * including the raw-log path when one exists.
337
+ * @param location - the backend's artifact location, when one exists.
338
+ */
339
+ constructor(message, location) {
340
+ super(message);
341
+ this.location = location;
342
+ this.name = "SessionFormatUnsupportedError";
343
+ }
344
+ };
345
+ /**
346
+ * Backend-shared storage validation: the version gate, the fail-closed event
347
+ * vocabulary, append-batch materialization, and contiguity — one place so
348
+ * every backend refuses the same inputs identically.
349
+ * @module @deepseek-ai/dsh-session-persistence/storage-contract
350
+ */
351
+ /** Build a format refusal that points at the raw artifact when the backend has one. */
352
+ function unsupported(reason, location) {
353
+ return new SessionFormatUnsupportedError(location === void 0 ? reason : `${reason} (raw log: ${location.path})`, location);
354
+ }
355
+ /**
356
+ * Validate one exclusively owned stored event array in place: adopt each
357
+ * record (validating and freezing it) and refuse any event type this build
358
+ * does not know, unless its writer marked it `ignorable: true` — silently
359
+ * skipping an unknown required event could reconstruct a wrong session (the
360
+ * envelope contract on `SessionEvent.ignorable`). Unknown required types and
361
+ * retired pre-release shapes refuse here; this validator performs no migration.
362
+ * @param meta - the stored header the events belong to.
363
+ * @param events - exclusively owned decoded events; validated in place.
364
+ * @param location - the backend's artifact location for refusals, when one exists.
365
+ * @returns the same array, validated and frozen.
366
+ * @throws {SessionFormatUnsupportedError} for unknown event types.
367
+ * @throws {SessionPersistenceCorruptionError} for records that fail validation.
368
+ */
369
+ function validateStoredEvents(meta, events, location) {
370
+ for (const event of events) {
371
+ if (!KNOWN_SESSION_EVENT_TYPES.has(event.type) && event.ignorable !== true) throw unsupported(`session "${meta.id}" contains event type "${event.type}" (seq ${event.seq}) unknown to this harness and not marked ignorable; refusing to interpret the log — it was likely written by a newer harness`, location);
372
+ if (event.type === "request/header") {
373
+ const data = event.data;
374
+ if (typeof data === "object" && data !== null && data["reason"] === "fallback") throw unsupported(`session "${meta.id}" contains a request/header event (seq ${event.seq}) with the unsupported legacy reason "fallback"; refusing to interpret the log — it was written by a retired pre-release harness`, location);
375
+ }
376
+ }
377
+ try {
378
+ for (const [index, event] of events.entries()) events[index] = adoptSessionEvent(event);
379
+ } catch (error) {
380
+ if (error instanceof SessionFormatUnsupportedError) throw error;
381
+ throw new SessionPersistenceCorruptionError(`stored session "${meta.id}" failed validation: ${String(error)}`, { cause: error });
382
+ }
383
+ return events;
384
+ }
385
+ /**
386
+ * Refuse a batch that does not contiguously continue the stored log.
387
+ * @param id - the session the batch belongs to.
388
+ * @param events - the batch, in seq order.
389
+ * @param cursor - the stored next-seq.
390
+ */
391
+ function assertContiguous(id, events, cursor) {
392
+ for (const [index, event] of events.entries()) if (event.seq !== cursor + index) throw new Error(`append seq mismatch for "${id}": expected ${cursor + index} at index ${index}, got ${event.seq}`);
393
+ }
394
+ //#endregion
395
+ //#region src/core/sync/session-sync.ts
396
+ const SESSION_READ_PAGE_SIZE = 256;
397
+ const MAX_SYNC_SESSIONS = 1e4;
398
+ const SPILL_META_VERSION = 1;
399
+ const SPILL_ARTIFACT_META_KEY = "@deepseek-ai/dsh-spill";
400
+ const LEGACY_LOCAL_SPILL_HINT = "Use read with offset/limit, or grep this path to search within it.";
401
+ const LEGACY_LOCAL_SPILL_MARKER = "Full formatted result stored at: ";
402
+ const LEGACY_LOCAL_SPILL_SUFFIX = `. ${LEGACY_LOCAL_SPILL_HINT})`;
403
+ var SyncSessionError = class extends Error {
404
+ code;
405
+ constructor(code, message, cause) {
406
+ super(message, cause === void 0 ? void 0 : { cause });
407
+ this.code = code;
408
+ this.name = "SyncSessionError";
409
+ }
410
+ };
411
+ function spillSessionDirectoryName(sessionId) {
412
+ return `session-${createHash("sha256").update(sessionId).digest("hex").slice(0, 12)}`;
413
+ }
414
+ function spillTransferReferenceError(cause) {
415
+ return new SyncSessionError("spill-integrity", "spill artifact could not be safely transferred", cause);
416
+ }
417
+ async function* readLocalSpillFile(configuredRoot, reference) {
418
+ const filePath = resolve(reference.ref.locator);
419
+ const sessionDirectory = dirname(filePath);
420
+ const root = dirname(sessionDirectory);
421
+ const expectedSessionDirectory = spillSessionDirectoryName(reference.owner.sessionId);
422
+ const leaf = basename(filePath);
423
+ if (filePath !== reference.ref.locator || basename(sessionDirectory) !== expectedSessionDirectory || !/^(?:[a-f0-9]{12}-.+|transfer-[a-f0-9]{64})$/.test(leaf)) throw spillTransferReferenceError();
424
+ const normalizedRoot = resolve(root);
425
+ if (normalizedRoot !== (configuredRoot === void 0 ? void 0 : resolve(configuredRoot))) {
426
+ const tempRoot = resolve(tmpdir());
427
+ if (dirname(normalizedRoot) !== tempRoot || !basename(normalizedRoot).startsWith("dsh-spill-")) throw spillTransferReferenceError();
428
+ }
429
+ let handle;
430
+ try {
431
+ const rootPath = await realpath(normalizedRoot);
432
+ const expectedDirectory = join(rootPath, expectedSessionDirectory);
433
+ if (await realpath(sessionDirectory) !== expectedDirectory) throw spillTransferReferenceError();
434
+ const directoryStat = await lstat(sessionDirectory);
435
+ const entryStat = await lstat(filePath);
436
+ const expectedBytes = reference.ref.bytes ?? entryStat.size;
437
+ if (directoryStat.isSymbolicLink() || !directoryStat.isDirectory() || entryStat.isSymbolicLink() || !entryStat.isFile() || entryStat.size !== expectedBytes || !Number.isSafeInteger(expectedBytes) || expectedBytes < 0 || process.platform !== "win32" && ((directoryStat.mode & 63) !== 0 || (entryStat.mode & 63) !== 0)) throw spillTransferReferenceError();
438
+ const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
439
+ handle = await open(filePath, constants.O_RDONLY | noFollow);
440
+ const openedStat = await handle.stat();
441
+ if (!openedStat.isFile() || openedStat.size !== expectedBytes || entryStat.ino !== 0 && openedStat.ino !== 0 && entryStat.ino !== openedStat.ino || entryStat.dev !== 0 && openedStat.dev !== 0 && entryStat.dev !== openedStat.dev) throw spillTransferReferenceError();
442
+ let bytes = 0;
443
+ for (;;) {
444
+ const buffer = Buffer.allocUnsafe(65536);
445
+ const { bytesRead } = await handle.read(buffer, 0, buffer.byteLength, null);
446
+ if (bytesRead === 0) break;
447
+ bytes += bytesRead;
448
+ if (bytes > expectedBytes) throw spillTransferReferenceError();
449
+ yield buffer.subarray(0, bytesRead);
450
+ }
451
+ const finalStat = await handle.stat();
452
+ if (bytes !== expectedBytes || finalStat.size !== expectedBytes || finalStat.mtimeMs !== openedStat.mtimeMs) throw spillTransferReferenceError();
453
+ } catch (error) {
454
+ if (error instanceof SyncSessionError) throw error;
455
+ throw spillTransferReferenceError(error);
456
+ } finally {
457
+ if (handle) await handle.close();
458
+ }
459
+ }
460
+ function isUnownedSpillRead(error) {
461
+ return error instanceof Error && error.name === "SpillReadError" && "code" in error && error.code === "not-owned";
462
+ }
463
+ async function* readSpillStream(store, reference) {
464
+ if (store.readTextStream) try {
465
+ yield* store.readTextStream({
466
+ owner: { sessionId: SessionId(reference.owner.sessionId) },
467
+ ref: reference.ref
468
+ });
469
+ return;
470
+ } catch (error) {
471
+ if (!isUnownedSpillRead(error)) throw error;
472
+ }
473
+ yield* readLocalSpillFile(store.root, reference);
474
+ }
475
+ async function writeLocalSpillTransfer(root, input) {
476
+ if (!input.transferId || input.transferId.length > 4096 || input.bytes !== input.data.byteLength || sha256(input.data) !== input.digest) throw new SyncSessionError("spill-integrity", "spill transfer metadata is invalid");
477
+ try {
478
+ new TextDecoder("utf-8", { fatal: true }).decode(input.data);
479
+ } catch (error) {
480
+ throw new SyncSessionError("spill-integrity", "spill content is not valid UTF-8", error);
481
+ }
482
+ let rootPath;
483
+ let directory;
484
+ let filePath;
485
+ const sessionId = String(input.owner.sessionId);
486
+ try {
487
+ await mkdir(resolve(root), {
488
+ recursive: true,
489
+ mode: 448
490
+ });
491
+ rootPath = await realpath(resolve(root));
492
+ directory = join(rootPath, spillSessionDirectoryName(sessionId));
493
+ await mkdir(directory, {
494
+ recursive: true,
495
+ mode: 448
496
+ });
497
+ if (await realpath(directory) !== directory) throw spillTransferReferenceError();
498
+ const directoryStat = await lstat(directory);
499
+ if (directoryStat.isSymbolicLink() || !directoryStat.isDirectory() || process.platform !== "win32" && (directoryStat.mode & 63) !== 0) throw spillTransferReferenceError();
500
+ filePath = join(directory, `transfer-${createHash("sha256").update(input.transferId).digest("hex")}`);
501
+ } catch (error) {
502
+ if (error instanceof SyncSessionError) throw error;
503
+ throw spillTransferReferenceError(error);
504
+ }
505
+ const ref = {
506
+ locator: filePath,
507
+ bytes: input.bytes,
508
+ retrievalHint: LEGACY_LOCAL_SPILL_HINT
509
+ };
510
+ let handle;
511
+ try {
512
+ handle = await open(filePath, "wx", 384);
513
+ } catch (error) {
514
+ if (error instanceof Error && "code" in error && error.code === "EEXIST") {
515
+ const existing = await collectBytes(readLocalSpillFile(rootPath, {
516
+ owner: { sessionId },
517
+ source: {
518
+ toolName: "sync",
519
+ callId: input.transferId,
520
+ label: "spill"
521
+ },
522
+ suggestedName: "spill.txt",
523
+ ref
524
+ }));
525
+ if (existing.byteLength === input.bytes && sha256(existing) === input.digest) return ref;
526
+ throw new SyncSessionError("spill-transfer-conflict", "spill transfer key already contains different content", error);
527
+ }
528
+ throw spillTransferReferenceError(error);
529
+ }
530
+ try {
531
+ let offset = 0;
532
+ while (offset < input.data.byteLength) {
533
+ const { bytesWritten } = await handle.write(input.data, offset, input.data.byteLength - offset, null);
534
+ if (bytesWritten === 0) throw spillTransferReferenceError();
535
+ offset += bytesWritten;
536
+ }
537
+ await handle.sync();
538
+ const stat = await handle.stat();
539
+ if (!stat.isFile() || stat.size !== input.bytes || process.platform !== "win32" && (stat.mode & 63) !== 0) throw spillTransferReferenceError();
540
+ } catch (error) {
541
+ await handle.close();
542
+ handle = void 0;
543
+ try {
544
+ await unlink(filePath);
545
+ } catch {}
546
+ if (error instanceof SyncSessionError) throw error;
547
+ throw spillTransferReferenceError(error);
548
+ } finally {
549
+ if (handle) await handle.close();
550
+ }
551
+ return ref;
552
+ }
553
+ async function saveSpillTransfer(store, input) {
554
+ if (store.saveTextStream) return store.saveTextStream({
555
+ owner: input.owner,
556
+ source: input.source,
557
+ suggestedName: input.suggestedName,
558
+ transferId: input.transferId,
559
+ bytes: input.bytes,
560
+ digest: input.digest,
561
+ content: oneChunk(input.data)
562
+ });
563
+ if (typeof store.root === "string") return writeLocalSpillTransfer(store.root, input);
564
+ throw new SyncSessionError("spill-transfer-unavailable", "目标实例的 spill provider 不支持安全写入");
565
+ }
566
+ function canonicalJson(value) {
567
+ if (value === null || typeof value !== "object") return JSON.stringify(value);
568
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
569
+ const object = value;
570
+ return `{${Object.keys(object).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(object[key])}`).join(",")}}`;
571
+ }
572
+ function sha256(value) {
573
+ return `sha256:${createHash("sha256").update(value).digest("hex")}`;
574
+ }
575
+ function objectKey(kind, id) {
576
+ return JSON.stringify([kind, id]);
577
+ }
578
+ function attachmentKey(kind, ref) {
579
+ return objectKey(kind, `${ref.attachmentId}\0${ref.name ?? ""}`);
580
+ }
581
+ function spillKey(reference) {
582
+ return objectKey("spill", `${reference.owner.sessionId}\0${reference.ref.locator}`);
583
+ }
584
+ function sessionOriginKey(metadata) {
585
+ return syncRecordKey("session", metadata);
586
+ }
587
+ function isImageRef(value) {
588
+ return isRecord$1(value) && typeof value.attachmentId === "string" && typeof value.mediaType === "string" && isNonNegativeSafeInteger(value.bytes) && isNonNegativeSafeInteger(value.width) && isNonNegativeSafeInteger(value.height);
589
+ }
590
+ function isFileRef(value) {
591
+ return isRecord$1(value) && typeof value.attachmentId === "string" && typeof value.name === "string" && isNonNegativeSafeInteger(value.bytes) && value.mediaType === void 0;
592
+ }
593
+ function spillReferences(value) {
594
+ const output = [];
595
+ const visit = (current) => {
596
+ if (Array.isArray(current)) {
597
+ for (const entry of current) visit(entry);
598
+ return;
599
+ }
600
+ if (!isRecord$1(current)) return;
601
+ const spillMeta = current[SPILL_ARTIFACT_META_KEY];
602
+ if (spillMeta !== void 0) {
603
+ if (!isRecord$1(spillMeta) || spillMeta.version !== SPILL_META_VERSION || !Array.isArray(spillMeta.references)) throw new SyncSessionError("spill-metadata-invalid", "spill references are invalid");
604
+ for (const reference of spillMeta.references) {
605
+ if (!isRecord$1(reference) || !isRecord$1(reference.owner) || typeof reference.owner.sessionId !== "string" || !isRecord$1(reference.source) || typeof reference.source.toolName !== "string" || typeof reference.source.callId !== "string" || typeof reference.source.label !== "string" || typeof reference.suggestedName !== "string" || !isRecord$1(reference.ref) || typeof reference.ref.locator !== "string" || reference.ref.bytes !== void 0 && !isNonNegativeSafeInteger(reference.ref.bytes) || typeof reference.ref.retrievalHint !== "string") throw new SyncSessionError("spill-metadata-invalid", "spill reference fields are invalid");
606
+ output.push(reference);
607
+ }
608
+ }
609
+ for (const child of Object.values(current)) visit(child);
610
+ };
611
+ visit(value);
612
+ return output;
613
+ }
614
+ function legacySpillReferences(events, sessionId) {
615
+ const toolNames = /* @__PURE__ */ new Map();
616
+ for (const event of events) {
617
+ if (event.type !== "tool/call") continue;
618
+ const data = isRecord$1(event.data) ? event.data : {};
619
+ if (typeof data.callId === "string" && typeof data.name === "string") toolNames.set(data.callId, data.name);
620
+ }
621
+ const found = /* @__PURE__ */ new Map();
622
+ const scan = (value, source) => {
623
+ if (Array.isArray(value)) {
624
+ for (const child of value) scan(child, source);
625
+ return;
626
+ }
627
+ if (!isRecord$1(value)) return;
628
+ if (value.type === "text" && typeof value.text === "string") {
629
+ let start = value.text.indexOf(LEGACY_LOCAL_SPILL_MARKER);
630
+ while (start >= 0) {
631
+ const locatorStart = start + 33;
632
+ const end = value.text.indexOf(LEGACY_LOCAL_SPILL_SUFFIX, locatorStart);
633
+ if (end < 0) throw new SyncSessionError("spill-format-unsupported", "旧 spill 通知格式无法安全识别");
634
+ const locator = value.text.slice(locatorStart, end);
635
+ if (!locator || locator.length > 4096 || /[\u0000-\u001f\u007f]/.test(locator)) throw new SyncSessionError("spill-format-unsupported", "旧 spill locator 无效");
636
+ const reference = {
637
+ owner: { sessionId },
638
+ source,
639
+ suggestedName: `${source.toolName}.txt`,
640
+ ref: {
641
+ locator,
642
+ retrievalHint: LEGACY_LOCAL_SPILL_HINT
643
+ }
644
+ };
645
+ found.set(spillKey(reference), reference);
646
+ start = value.text.indexOf(LEGACY_LOCAL_SPILL_MARKER, end + LEGACY_LOCAL_SPILL_SUFFIX.length);
647
+ }
648
+ }
649
+ for (const child of Object.values(value)) scan(child, source);
650
+ };
651
+ for (const event of events) if (event.type === "tool/result") {
652
+ const data = isRecord$1(event.data) ? event.data : {};
653
+ const message = isRecord$1(data.message) ? data.message : {};
654
+ const callId = message.source?.callId;
655
+ if (typeof callId === "string") scan(message.content, {
656
+ toolName: toolNames.get(callId) ?? "unknown",
657
+ callId,
658
+ label: "result"
659
+ });
660
+ } else if (event.type === "tool/code-dispatch") {
661
+ const data = isRecord$1(event.data) ? event.data : {};
662
+ if (typeof data.name === "string" && typeof data.subCallId === "string") scan(data.content, {
663
+ toolName: data.name,
664
+ callId: data.subCallId,
665
+ label: "dispatch"
666
+ });
667
+ }
668
+ return [...found.values()];
669
+ }
670
+ function attachmentReferences(events) {
671
+ const images = /* @__PURE__ */ new Map();
672
+ const files = /* @__PURE__ */ new Map();
673
+ const visit = (value) => {
674
+ if (Array.isArray(value)) {
675
+ for (const child of value) visit(child);
676
+ return;
677
+ }
678
+ if (!isRecord$1(value)) return;
679
+ if (value.type === "image" && isImageRef(value.attachment)) images.set(attachmentKey("image", value.attachment), value.attachment);
680
+ if (value.type === "file" && isFileRef(value.attachment)) files.set(attachmentKey("file", value.attachment), value.attachment);
681
+ for (const child of Object.values(value)) visit(child);
682
+ };
683
+ for (const event of events) visit(event);
684
+ return {
685
+ images: [...images.values()],
686
+ files: [...files.values()]
687
+ };
688
+ }
689
+ function sessionEventDigest(header, inheritedEventCount, events, workspace, instructionSnapshot, objects, parentOrigin) {
690
+ const imageDigests = /* @__PURE__ */ new Map();
691
+ const fileDigests = /* @__PURE__ */ new Map();
692
+ const spillDigests = /* @__PURE__ */ new Map();
693
+ for (const object of objects) if (object.kind === "image") imageDigests.set(attachmentKey("image", object.ref), object);
694
+ else if (object.kind === "file") fileDigests.set(attachmentKey("file", object.ref), object);
695
+ else spillDigests.set(spillKey(object.reference), object);
696
+ const normalize = (value) => {
697
+ if (Array.isArray(value)) return value.map(normalize);
698
+ if (!isRecord$1(value)) return value;
699
+ if (value.type === "image" && isImageRef(value.attachment)) {
700
+ const object = imageDigests.get(attachmentKey("image", value.attachment));
701
+ return object ? {
702
+ type: "image",
703
+ digest: object.digest,
704
+ ...value.attachment.name ? { name: value.attachment.name } : {}
705
+ } : {
706
+ type: "image",
707
+ missing: value.attachment.attachmentId
708
+ };
709
+ }
710
+ if (value.type === "file" && isFileRef(value.attachment)) {
711
+ const object = fileDigests.get(attachmentKey("file", value.attachment));
712
+ return object ? {
713
+ type: "file",
714
+ digest: object.digest,
715
+ name: value.attachment.name
716
+ } : {
717
+ type: "file",
718
+ missing: value.attachment.attachmentId
719
+ };
720
+ }
721
+ const output = {};
722
+ for (const [key, child] of Object.entries(value)) if (key === SPILL_ARTIFACT_META_KEY && isRecord$1(child) && Array.isArray(child.references)) output[key] = {
723
+ version: child.version,
724
+ references: child.references.map((reference) => {
725
+ if (!isRecord$1(reference) || !isRecord$1(reference.ref)) return reference;
726
+ const spill = spillDigests.get(spillKey(reference));
727
+ return spill ? {
728
+ source: reference.source,
729
+ suggestedName: reference.suggestedName,
730
+ digest: spill.digest,
731
+ bytes: spill.bytes
732
+ } : {
733
+ source: reference.source,
734
+ suggestedName: reference.suggestedName,
735
+ missing: reference.ref.locator
736
+ };
737
+ })
738
+ };
739
+ else if (key === "text" && typeof child === "string") {
740
+ let text = child;
741
+ for (const object of objects) {
742
+ if (object.kind !== "spill") continue;
743
+ const ref = object.reference.ref;
744
+ text = text.replaceAll(`Full formatted result stored at: ${ref.locator}. ${ref.retrievalHint}`, `Full formatted result stored at: ${object.digest}.`);
745
+ }
746
+ output[key] = text;
747
+ } else output[key] = normalize(child);
748
+ return output;
749
+ };
750
+ const { id: _id, cwd: _cwd, parentSession: _parent, ...stableHeader } = header;
751
+ return sha256(canonicalJson({
752
+ header: stableHeader,
753
+ inheritedEventCount,
754
+ events: events.map(normalize),
755
+ workspace: {
756
+ originInstanceId: workspace.originInstanceId,
757
+ originWorkspaceId: workspace.originWorkspaceId,
758
+ nodeOrigin: workspace.nodeOrigin,
759
+ remotePath: workspace.remotePath
760
+ },
761
+ parentOrigin,
762
+ instructionSnapshot: instructionSnapshot ? {
763
+ text: instructionSnapshot.text,
764
+ capturedAt: instructionSnapshot.capturedAt,
765
+ workspaceOrigin: instructionSnapshot.workspaceOrigin,
766
+ workspaceRemotePath: instructionSnapshot.workspaceRemotePath,
767
+ workspaceRevision: instructionSnapshot.workspaceRevision,
768
+ digest: instructionSnapshot.digest,
769
+ inheritedFromOrigin: instructionSnapshot.inheritedFromOrigin
770
+ } : void 0
771
+ }));
772
+ }
773
+ function sessionStateDigest(workspace, workspacePosition, archived, pinned) {
774
+ return sha256(canonicalJson({
775
+ workspace: {
776
+ originInstanceId: workspace.originInstanceId,
777
+ originWorkspaceId: workspace.originWorkspaceId,
778
+ nodeOrigin: workspace.nodeOrigin,
779
+ remotePath: workspace.remotePath,
780
+ title: workspace.title,
781
+ revision: workspace.revision,
782
+ contentDigest: workspace.contentDigest
783
+ },
784
+ workspacePosition,
785
+ archived,
786
+ pinned
787
+ }));
788
+ }
789
+ function planSessionTransfers(direction, localEntries, remoteEntries) {
790
+ const toLocal = [];
791
+ const toRemote = [];
792
+ const keyOf = (entry) => entry.branchId ?? sessionOriginKey(entry.metadata);
793
+ const localByKey = new Map(localEntries.map((entry) => [keyOf(entry), entry]));
794
+ const remoteByKey = new Map(remoteEntries.map((entry) => [keyOf(entry), entry]));
795
+ if (direction === "pull") {
796
+ let skipped = 0;
797
+ for (const [key, remote] of remoteByKey) {
798
+ const local = localByKey.get(key);
799
+ if (!local) {
800
+ toLocal.push(remote);
801
+ continue;
802
+ }
803
+ const comparison = compareSyncRevision(local.metadata.revision, remote.metadata.revision);
804
+ if (comparison < 0) toLocal.push(remote);
805
+ else if (comparison === 0) {
806
+ if (local.metadata.contentDigest !== remote.metadata.contentDigest || local.metadata.stateDigest !== remote.metadata.stateDigest || Boolean(local.metadata.deleted) !== Boolean(remote.metadata.deleted)) throw new SyncSessionError("revision-conflict", "Equal session revisions contain different content");
807
+ skipped += 1;
808
+ } else skipped += 1;
809
+ }
810
+ return {
811
+ toLocal,
812
+ toRemote,
813
+ skipped
814
+ };
815
+ }
816
+ const keys = /* @__PURE__ */ new Set([...localByKey.keys(), ...remoteByKey.keys()]);
817
+ let skipped = 0;
818
+ for (const key of keys) {
819
+ const local = localByKey.get(key);
820
+ const remote = remoteByKey.get(key);
821
+ if (!local) {
822
+ toLocal.push(remote);
823
+ continue;
824
+ }
825
+ if (!remote) {
826
+ toRemote.push(local);
827
+ continue;
828
+ }
829
+ const comparison = compareSyncRevision(local.metadata.revision, remote.metadata.revision);
830
+ if (comparison > 0) toRemote.push(local);
831
+ else if (comparison < 0) toLocal.push(remote);
832
+ else if (local.metadata.contentDigest === remote.metadata.contentDigest && local.metadata.stateDigest === remote.metadata.stateDigest && Boolean(local.metadata.deleted) === Boolean(remote.metadata.deleted)) skipped += 1;
833
+ else throw new SyncSessionError("revision-conflict", "Equal session revisions contain different content");
834
+ }
835
+ return {
836
+ toLocal,
837
+ toRemote,
838
+ skipped
839
+ };
840
+ }
841
+ async function collectBytes(stream, maxBytes = MAX_SYNC_TRANSFER_BYTES) {
842
+ const chunks = [];
843
+ let size = 0;
844
+ for await (const chunk of stream) {
845
+ if (!(chunk instanceof Uint8Array)) throw new SyncSessionError("object-invalid", "session object stream contains a non-byte chunk");
846
+ size += chunk.byteLength;
847
+ if (size > maxBytes) throw new SyncSessionError("bundle-too-large", "session object exceeds the transfer limit");
848
+ chunks.push(Buffer.from(chunk));
849
+ }
850
+ return Buffer.concat(chunks, size);
851
+ }
852
+ async function* oneChunk(data) {
853
+ yield data;
854
+ }
855
+ function findSessionRecordByLocalId(state, localSessionId) {
856
+ return Object.values(state.records).filter((metadata) => metadata.kind === "session" && metadata.localEntityId === localSessionId).sort((a, b) => compareSyncRevision(b.revision, a.revision))[0];
857
+ }
858
+ function findSessionOriginByLocalId(state, localSessionId) {
859
+ const metadata = findSessionRecordByLocalId(state, localSessionId);
860
+ return metadata ? {
861
+ originInstanceId: metadata.originInstanceId,
862
+ originEntityId: metadata.originEntityId
863
+ } : void 0;
864
+ }
865
+ function workspaceDigest(workspace) {
866
+ return sha256(canonicalJson(workspace));
867
+ }
868
+ function workspaceCandidateKey(workspace) {
869
+ return normalize(workspace.path);
870
+ }
871
+ function validateObjectPayload(object) {
872
+ if (!isRecord$1(object) || typeof object.id !== "string" || !object.id || typeof object.digest !== "string" || !/^sha256:[a-f0-9]{64}$/.test(object.digest) || !isNonNegativeSafeInteger(object.bytes) || typeof object.data !== "string") throw new SyncSessionError("object-invalid", "session object metadata is invalid");
873
+ if (!(object.kind === "spill" && object.unavailable === true && object.bytes === 0 && object.data === "")) {
874
+ const bytes = Buffer.from(object.data, "base64");
875
+ if (bytes.toString("base64") !== object.data || bytes.byteLength !== object.bytes || sha256(bytes) !== object.digest) throw new SyncSessionError("object-invalid", "session object encoding or digest is invalid");
876
+ }
877
+ if (object.kind === "image" && isImageRef(object.ref) && object.id === attachmentKey("image", object.ref)) return object;
878
+ if (object.kind === "file" && isFileRef(object.ref) && object.id === attachmentKey("file", object.ref)) return object;
879
+ if (object.kind === "spill" && isRecord$1(object.reference) && isRecord$1(object.reference.owner) && typeof object.reference.owner.sessionId === "string" && isRecord$1(object.reference.ref) && typeof object.reference.ref.locator === "string" && object.id === spillKey(object.reference)) return object;
880
+ throw new SyncSessionError("object-invalid", "session object kind or reference is invalid");
881
+ }
882
+ function validateEntry(entry) {
883
+ if (!isRecord$1(entry) || !isRecord$1(entry.metadata) || entry.metadata.kind !== "session") throw new SyncSessionError("manifest-invalid", "session entry metadata is invalid");
884
+ const metadata = entry.metadata;
885
+ if (typeof metadata.originInstanceId !== "string" || !metadata.originInstanceId || metadata.originInstanceId.length > 512 || /[\u0000-\u001f\u007f]/.test(metadata.originInstanceId) || typeof metadata.originEntityId !== "string" || !metadata.originEntityId || metadata.originEntityId.length > 512 || /[\u0000-\u001f\u007f]/.test(metadata.originEntityId) || typeof metadata.localEntityId !== "string" || !metadata.localEntityId || metadata.localEntityId.length > 512 || /[\u0000-\u001f\u007f]/.test(metadata.localEntityId) || typeof metadata.contentDigest !== "string" || !/^sha256:[a-f0-9]{64}$/.test(metadata.contentDigest) || !metadata.revision || !isNonNegativeSafeInteger(metadata.revision.physicalTimeMs) || !isNonNegativeSafeInteger(metadata.revision.logicalCounter) || typeof metadata.revision.writerInstanceId !== "string" || !metadata.revision.writerInstanceId || /[\u0000-\u001f\u007f]/.test(metadata.revision.writerInstanceId)) throw new SyncSessionError("manifest-invalid", "session entry metadata fields are invalid");
886
+ if (metadata.stateDigest !== void 0 && !/^sha256:[a-f0-9]{64}$/.test(metadata.stateDigest)) throw new SyncSessionError("manifest-invalid", "session state digest is invalid");
887
+ if (entry.branchId !== void 0 && (typeof entry.branchId !== "string" || !/^b-[a-f0-9]{32}$/.test(entry.branchId))) throw new SyncSessionError("manifest-invalid", "session branch ID is invalid");
888
+ if (metadata.workspaceOrigin !== void 0) validateOrigin(metadata.workspaceOrigin);
889
+ if (metadata.deleted) {
890
+ if (metadata.contentDigest !== sessionTombstoneDigest(metadata)) throw new SyncSessionError("manifest-invalid", "session tombstone digest is invalid");
891
+ return {
892
+ metadata,
893
+ ...entry.branchId ? { branchId: entry.branchId } : {}
894
+ };
895
+ }
896
+ const workspace = isRecord$1(entry.workspace) ? entry.workspace : void 0;
897
+ const workspaceRevision = workspace && isRecord$1(workspace.revision) ? workspace.revision : void 0;
898
+ if (typeof entry.sourceLocalSessionId !== "string" || !isRecord$1(entry.header) || typeof entry.inheritedEventCount !== "number" || !Number.isSafeInteger(entry.inheritedEventCount) || entry.inheritedEventCount < 0 || !Array.isArray(entry.events) || !workspace || !Array.isArray(entry.objects) || typeof workspace.originInstanceId !== "string" || typeof workspace.originWorkspaceId !== "string" || !isRecord$1(workspace.nodeOrigin) || typeof workspace.nodeOrigin.originInstanceId !== "string" || typeof workspace.nodeOrigin.originEntityId !== "string" || typeof workspace.remotePath !== "string" || !posix.isAbsolute(workspace.remotePath) || posix.normalize(workspace.remotePath) !== workspace.remotePath || typeof workspace.title !== "string" || typeof workspace.contentDigest !== "string" || !/^sha256:[a-f0-9]{64}$/.test(workspace.contentDigest) || !workspaceRevision || !isNonNegativeSafeInteger(workspaceRevision.physicalTimeMs) || !isNonNegativeSafeInteger(workspaceRevision.logicalCounter) || typeof workspaceRevision.writerInstanceId !== "string" || typeof entry.workspacePosition !== "number" || !Number.isSafeInteger(entry.workspacePosition) || entry.workspacePosition < 0 || typeof entry.archived !== "boolean" || typeof entry.pinned !== "boolean") throw new SyncSessionError("manifest-invalid", "active session entry fields are invalid");
899
+ if (entry.events.length > MAX_SYNC_SESSIONS * 100) throw new SyncSessionError("manifest-invalid", "session event count exceeds limit");
900
+ const header = entry.header;
901
+ if (header.version !== 4 || header.id !== entry.sourceLocalSessionId) throw new SyncSessionError("session-format", "session header format or id is invalid");
902
+ const events = validateStoredEvents(header, structuredClone(entry.events));
903
+ assertContiguous(header.id, events, 0);
904
+ const objects = entry.objects.map(validateObjectPayload);
905
+ if (new Set(objects.map((object) => object.id)).size !== objects.length) throw new SyncSessionError("manifest-invalid", "session entry contains duplicate objects");
906
+ const workspaceValue = workspace;
907
+ if (workspaceDigest({
908
+ originInstanceId: workspaceValue.originInstanceId,
909
+ originWorkspaceId: workspaceValue.originWorkspaceId,
910
+ nodeOrigin: workspaceValue.nodeOrigin,
911
+ remotePath: workspaceValue.remotePath,
912
+ title: workspaceValue.title
913
+ }) !== workspaceValue.contentDigest) throw new SyncSessionError("manifest-invalid", "workspace content digest is invalid");
914
+ const instructionSnapshot = entry.instructionSnapshot === void 0 ? void 0 : validateInstructionSnapshot(entry.instructionSnapshot);
915
+ const referencedObjects = /* @__PURE__ */ new Set();
916
+ const attachments = attachmentReferences(events);
917
+ for (const ref of attachments.images) referencedObjects.add(attachmentKey("image", ref));
918
+ for (const ref of attachments.files) referencedObjects.add(attachmentKey("file", ref));
919
+ for (const reference of [...spillReferences(events), ...legacySpillReferences(events, String(header.id))]) referencedObjects.add(spillKey(reference));
920
+ const objectIds = new Set(objects.map((object) => object.id));
921
+ if ([...referencedObjects].some((id) => !objectIds.has(id)) || [...objectIds].some((id) => !referencedObjects.has(id))) throw new SyncSessionError("manifest-invalid", "session object references and payloads do not match");
922
+ const parentOrigin = entry.parentOrigin === void 0 ? void 0 : validateOrigin(entry.parentOrigin);
923
+ if (sessionEventDigest(header, entry.inheritedEventCount, events, workspaceValue, instructionSnapshot, objects, parentOrigin) !== metadata.contentDigest) throw new SyncSessionError("manifest-invalid", "session content digest is invalid");
924
+ if (metadata.stateDigest !== sessionStateDigest(workspaceValue, entry.workspacePosition, entry.archived, entry.pinned)) throw new SyncSessionError("manifest-invalid", "session state digest is invalid");
925
+ return {
926
+ metadata,
927
+ ...entry.branchId ? { branchId: entry.branchId } : {},
928
+ sourceLocalSessionId: entry.sourceLocalSessionId,
929
+ header,
930
+ inheritedEventCount: entry.inheritedEventCount,
931
+ ...parentOrigin ? { parentOrigin } : {},
932
+ workspace: workspaceValue,
933
+ workspacePosition: entry.workspacePosition,
934
+ archived: entry.archived,
935
+ pinned: entry.pinned,
936
+ ...instructionSnapshot ? { instructionSnapshot } : {},
937
+ events,
938
+ objects
939
+ };
940
+ }
941
+ function bundleDigest(bundle) {
942
+ const manifest = bundle.entries.map((entry) => ({
943
+ key: entry.branchId ?? sessionOriginKey(entry.metadata),
944
+ revision: entry.metadata.revision,
945
+ contentDigest: entry.metadata.contentDigest,
946
+ stateDigest: entry.metadata.stateDigest,
947
+ deleted: Boolean(entry.metadata.deleted),
948
+ objects: (entry.objects ?? []).map((object) => ({
949
+ id: object.id,
950
+ digest: object.digest,
951
+ bytes: object.bytes
952
+ })).sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0)
953
+ })).sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0);
954
+ const skipped = bundle.skipped.map((entry) => ({
955
+ sessionId: entry.sessionId,
956
+ reason: entry.reason
957
+ })).sort((a, b) => a.sessionId < b.sessionId ? -1 : a.sessionId > b.sessionId ? 1 : 0);
958
+ return sha256(canonicalJson({
959
+ instanceId: bundle.instanceId,
960
+ entries: manifest,
961
+ skipped
962
+ }));
963
+ }
964
+ function validateSyncSessionBundle(value) {
965
+ if (!isRecord$1(value) || value.protocolVersion !== 1 || typeof value.instanceId !== "string" || !/^i-[a-f0-9]{32}$/.test(value.instanceId) || !Array.isArray(value.entries) || typeof value.digest !== "string") throw new SyncSessionError("manifest-invalid", "session bundle header is invalid");
966
+ const skipped = value.skipped === void 0 ? [] : value.skipped;
967
+ if (!Array.isArray(skipped) || skipped.some((item) => !isRecord$1(item) || typeof item.sessionId !== "string" || typeof item.reason !== "string")) throw new SyncSessionError("manifest-invalid", "session bundle skip report is invalid");
968
+ if (value.entries.length > MAX_SYNC_SESSIONS) throw new SyncSessionError("manifest-invalid", "session bundle contains too many records");
969
+ const entries = value.entries.map(validateEntry);
970
+ const keys = entries.map((entry) => entry.branchId ?? sessionOriginKey(entry.metadata));
971
+ if (new Set(keys).size !== keys.length) throw new SyncSessionError("manifest-invalid", "session bundle contains duplicate records");
972
+ const bundle = {
973
+ protocolVersion: 1,
974
+ instanceId: value.instanceId,
975
+ entries,
976
+ skipped,
977
+ digest: value.digest
978
+ };
979
+ if (bundleDigest(bundle) !== bundle.digest) throw new SyncSessionError("manifest-digest", "session bundle digest does not match");
980
+ if (Buffer.byteLength(JSON.stringify(bundle), "utf8") > 107374182400) throw new SyncSessionError("bundle-too-large", "session bundle exceeds the transfer limit");
981
+ return bundle;
982
+ }
983
+ function makeSyncSessionBundle(instanceId, entries, skipped = []) {
984
+ const bundle = {
985
+ protocolVersion: 1,
986
+ instanceId,
987
+ entries,
988
+ skipped,
989
+ digest: ""
990
+ };
991
+ bundle.digest = bundleDigest(bundle);
992
+ if (Buffer.byteLength(JSON.stringify(bundle), "utf8") > 107374182400) throw new SyncSessionError("bundle-too-large", "session bundle exceeds the transfer limit");
993
+ return bundle;
994
+ }
995
+ function emptyApplyResult() {
996
+ return {
997
+ created: 0,
998
+ updated: 0,
999
+ archived: 0,
1000
+ deleted: 0,
1001
+ skipped: 0,
1002
+ unmapped: 0
1003
+ };
1004
+ }
1005
+ function isNodeRemoteType(type) {
1006
+ return type === "remote-ssh" || type === "remote-docker";
1007
+ }
1008
+ function branchIdFor(metadata) {
1009
+ return `b-${createHash("sha256").update(canonicalJson({
1010
+ originInstanceId: metadata.originInstanceId,
1011
+ originEntityId: metadata.originEntityId,
1012
+ contentDigest: metadata.contentDigest
1013
+ })).digest("hex").slice(0, 32)}`;
1014
+ }
1015
+ function sessionTombstoneDigest(metadata) {
1016
+ return sha256(canonicalJson({
1017
+ kind: "session",
1018
+ originInstanceId: metadata.originInstanceId,
1019
+ originEntityId: metadata.originEntityId,
1020
+ deleted: true
1021
+ }));
1022
+ }
1023
+ function validateOrigin(value) {
1024
+ if (!isRecord$1(value) || typeof value.originInstanceId !== "string" || !value.originInstanceId || typeof value.originEntityId !== "string" || !value.originEntityId || value.originInstanceId.length > 512 || value.originEntityId.length > 512 || /[\u0000-\u001f\u007f]/.test(value.originInstanceId) || /[\u0000-\u001f\u007f]/.test(value.originEntityId)) throw new SyncSessionError("manifest-invalid", "session origin reference is invalid");
1025
+ return {
1026
+ originInstanceId: value.originInstanceId,
1027
+ originEntityId: value.originEntityId
1028
+ };
1029
+ }
1030
+ function validateInstructionSnapshot(value) {
1031
+ if (!isRecord$1(value) || typeof value.text !== "string" || Buffer.byteLength(value.text, "utf8") > 32768 || typeof value.capturedAt !== "string" || value.capturedAt.length > 64) throw new SyncSessionError("manifest-invalid", "session instruction snapshot is invalid");
1032
+ if (value.workspaceRevision !== void 0 && !isNonNegativeSafeInteger(value.workspaceRevision)) throw new SyncSessionError("manifest-invalid", "instruction workspace revision is invalid");
1033
+ if (value.digest !== void 0 && (typeof value.digest !== "string" || !/^[a-f0-9]{64}$/.test(value.digest))) throw new SyncSessionError("manifest-invalid", "instruction digest is invalid");
1034
+ if (value.workspaceRemotePath !== void 0 && (typeof value.workspaceRemotePath !== "string" || !posix.isAbsolute(value.workspaceRemotePath) || posix.normalize(value.workspaceRemotePath) !== value.workspaceRemotePath)) throw new SyncSessionError("manifest-invalid", "instruction workspace path is invalid");
1035
+ return {
1036
+ text: value.text,
1037
+ capturedAt: value.capturedAt,
1038
+ ...value.workspaceOrigin === void 0 ? {} : { workspaceOrigin: validateOrigin(value.workspaceOrigin) },
1039
+ ...value.workspaceRemotePath === void 0 ? {} : { workspaceRemotePath: value.workspaceRemotePath },
1040
+ ...value.workspaceRevision === void 0 ? {} : { workspaceRevision: value.workspaceRevision },
1041
+ ...value.digest === void 0 ? {} : { digest: value.digest },
1042
+ ...value.inheritedFromOrigin === void 0 ? {} : { inheritedFromOrigin: validateOrigin(value.inheritedFromOrigin) }
1043
+ };
1044
+ }
1045
+ function importedSessionId(metadata) {
1046
+ const identity = canonicalJson({
1047
+ originInstanceId: metadata.originInstanceId,
1048
+ originEntityId: metadata.originEntityId,
1049
+ contentDigest: metadata.contentDigest
1050
+ });
1051
+ return SessionId(`session-sync-${createHash("sha256").update(identity).digest("hex").slice(0, 32)}`);
1052
+ }
1053
+ function archivedSessionId(branchId) {
1054
+ return SessionId(`session-archive-${createHash("sha256").update(branchId).digest("hex").slice(0, 32)}`);
1055
+ }
1056
+ function tombstoneDigest(metadata) {
1057
+ return sha256(canonicalJson({
1058
+ kind: "session",
1059
+ originInstanceId: metadata.originInstanceId,
1060
+ originEntityId: metadata.originEntityId,
1061
+ deleted: true
1062
+ }));
1063
+ }
1064
+ function arrayBuffer(data) {
1065
+ const value = Buffer.from(data, "base64");
1066
+ if (value.toString("base64") !== data) throw new SyncSessionError("object-invalid", "session object is not canonical base64");
1067
+ return value;
1068
+ }
1069
+ function instructionsForSession(store, sessionId, state, preferredWorkspaceOrigin) {
1070
+ const snapshot = store.get(sessionId);
1071
+ if (!snapshot) return void 0;
1072
+ const workspaceMappings = snapshot.workspaceId ? Object.values(state.workspaces).filter((mapping) => mapping.localWorkspaceId === snapshot.workspaceId) : [];
1073
+ const workspaceMapping = preferredWorkspaceOrigin ? workspaceMappings.find((mapping) => mapping.originInstanceId === preferredWorkspaceOrigin.originInstanceId && mapping.originWorkspaceId === preferredWorkspaceOrigin.originEntityId) ?? (workspaceMappings.length === 1 ? workspaceMappings[0] : void 0) : workspaceMappings.length === 1 ? workspaceMappings[0] : void 0;
1074
+ const inheritedFromOrigin = snapshot.inheritedFromSessionId ? findSessionOriginByLocalId(state, snapshot.inheritedFromSessionId) : void 0;
1075
+ return {
1076
+ text: snapshot.text,
1077
+ capturedAt: snapshot.capturedAt,
1078
+ ...workspaceMapping ? {
1079
+ workspaceOrigin: {
1080
+ originInstanceId: workspaceMapping.originInstanceId,
1081
+ originEntityId: workspaceMapping.originWorkspaceId
1082
+ },
1083
+ workspaceRemotePath: workspaceMapping.remotePath
1084
+ } : {},
1085
+ ...snapshot.workspaceRevision === void 0 ? {} : { workspaceRevision: snapshot.workspaceRevision },
1086
+ ...snapshot.digest === void 0 ? {} : { digest: snapshot.digest },
1087
+ ...inheritedFromOrigin ? { inheritedFromOrigin } : {}
1088
+ };
1089
+ }
1090
+ var SyncSessionService = class {
1091
+ state;
1092
+ nodes;
1093
+ registry;
1094
+ bindings;
1095
+ getWorkspaces;
1096
+ getPersistence;
1097
+ getAttachments;
1098
+ getSpills;
1099
+ instructions;
1100
+ journal;
1101
+ now;
1102
+ operationTail = Promise.resolve();
1103
+ constructor(state, nodes, registry, bindings, getWorkspaces, getPersistence, getAttachments, getSpills, instructions, journal = new SyncSessionJournalStore(), now = Date.now) {
1104
+ this.state = state;
1105
+ this.nodes = nodes;
1106
+ this.registry = registry;
1107
+ this.bindings = bindings;
1108
+ this.getWorkspaces = getWorkspaces;
1109
+ this.getPersistence = getPersistence;
1110
+ this.getAttachments = getAttachments;
1111
+ this.getSpills = getSpills;
1112
+ this.instructions = instructions;
1113
+ this.journal = journal;
1114
+ this.now = now;
1115
+ }
1116
+ exportBundle() {
1117
+ return this.serializeMutation(() => this.exportBundleNow());
1118
+ }
1119
+ applyBundle(value, sourceName, peerId = "p-000000000000000000000000", options = {}) {
1120
+ return this.serializeMutation(() => this.applyBundleNow(value, sourceName, peerId, options));
1121
+ }
1122
+ preflightBundle(value, options = {}) {
1123
+ const bundle = validateSyncSessionBundle(value);
1124
+ if (bundle.instanceId === this.state.instanceId() && !options.allowSameInstance) throw new SyncSessionError("same-instance", "cannot import sessions from this instance");
1125
+ const state = this.state.read();
1126
+ for (const entry of bundle.entries) {
1127
+ if (entry.metadata.deleted) {
1128
+ const current = state.records[sessionOriginKey(entry.metadata)];
1129
+ if (!current) continue;
1130
+ if (compareSyncRevision(entry.metadata.revision, current.revision) === 0 && (entry.metadata.contentDigest !== current.contentDigest || !current.deleted)) throw new SyncSessionError("revision-conflict", "Equal session revisions contain different tombstone state");
1131
+ continue;
1132
+ }
1133
+ if (entry.branchId) {
1134
+ const current = state.archives[entry.branchId];
1135
+ if (!current) continue;
1136
+ const comparison = compareSyncRevision(entry.metadata.revision, current.revision);
1137
+ if (comparison === 0 && (entry.metadata.contentDigest !== current.contentDigest || entry.metadata.stateDigest !== current.stateDigest)) throw new SyncSessionError("revision-conflict", "Equal archive revisions contain different content");
1138
+ if (comparison > 0 && entry.metadata.contentDigest !== current.contentDigest) throw new SyncSessionError("archive-conflict", "A session archive ID was reused for different content");
1139
+ continue;
1140
+ }
1141
+ const current = state.records[sessionOriginKey(entry.metadata)];
1142
+ if (!current || current.deleted) continue;
1143
+ if (compareSyncRevision(entry.metadata.revision, current.revision) === 0 && (entry.metadata.contentDigest !== current.contentDigest || entry.metadata.stateDigest !== current.stateDigest)) throw new SyncSessionError("revision-conflict", "Equal session revisions contain different content");
1144
+ }
1145
+ }
1146
+ pendingPeerIds() {
1147
+ return this.journal.pendingPeerIds();
1148
+ }
1149
+ async recoverPending() {
1150
+ const failures = [];
1151
+ for (const batch of this.journal.pendingBatches()) try {
1152
+ const bundle = this.journal.readBundle(batch.batchId);
1153
+ if (bundle === void 0) throw new SyncSessionError("journal-missing-bundle", "未完成的会话 journal 缺少 bundle");
1154
+ if (validateSyncSessionBundle(bundle).digest !== batch.bundleDigest) throw new SyncSessionError("journal-bundle-mismatch", "会话 journal bundle 摘要不匹配");
1155
+ await this.serializeMutation(() => this.applyBundleNow(bundle, batch.sourceName, batch.peerId, {
1156
+ importedVia: batch.importedVia,
1157
+ allowSameInstance: batch.allowSameInstance
1158
+ }, batch));
1159
+ } catch (error) {
1160
+ failures.push(error);
1161
+ }
1162
+ if (failures.length > 0) throw new AggregateError(failures, "One or more sync session batches remain pending");
1163
+ }
1164
+ cleanupOrphanedStaging() {
1165
+ this.journal.cleanupOrphanedStaging();
1166
+ }
1167
+ listArchives() {
1168
+ return Object.values(this.state.read().archives).sort((a, b) => a.archivedAt.localeCompare(b.archivedAt));
1169
+ }
1170
+ restoreArchive(branchId) {
1171
+ return this.serializeMutation(() => this.restoreArchiveNow(branchId));
1172
+ }
1173
+ async exportBundleNow() {
1174
+ const persistence = this.requirePersistence();
1175
+ const workspaceRegistry = this.getWorkspaces();
1176
+ if (!workspaceRegistry) throw new SyncSessionError("workspace-unavailable", "工作区注册表不可用");
1177
+ await persistence.flush();
1178
+ const instanceId = this.state.instanceId();
1179
+ const bindings = this.bindings.list();
1180
+ const workspaces = workspaceRegistry.list();
1181
+ const archivedIds = new Set(workspaceRegistry.archivedSessionIds.map(String));
1182
+ const pinnedIds = new Set((workspaceRegistry.pinnedSessionIds ?? []).map(String));
1183
+ const entries = [];
1184
+ const skipped = [];
1185
+ const eligibleLocalIds = /* @__PURE__ */ new Set();
1186
+ const workspaceById = /* @__PURE__ */ new Map();
1187
+ const archivedBranchSessionIds = new Set(Object.values(this.state.read().archives).map((archive) => archive.localSessionId));
1188
+ for (const workspace of workspaces) {
1189
+ workspaceById.set(String(workspace.id), workspace);
1190
+ const binding = bindings.find((candidate) => normalize(candidate.mirrorPath) === workspaceCandidateKey(workspace));
1191
+ if (!binding) continue;
1192
+ const node = this.registry.get(binding.nodeId);
1193
+ if (!node || !isNodeRemoteType(node.type)) continue;
1194
+ const nodeOrigin = this.nodes.originForLocalNode(node.id);
1195
+ if (!nodeOrigin) continue;
1196
+ for (let position = 0; position < workspace.sessionIds.length; position += 1) {
1197
+ const sessionId = String(workspace.sessionIds[position]);
1198
+ if (archivedBranchSessionIds.has(sessionId)) continue;
1199
+ const currentSession = findSessionRecordByLocalId(this.state.read(), sessionId);
1200
+ if (currentSession?.deleted) continue;
1201
+ const descriptor = this.workspaceDescriptor(workspace, binding, nodeOrigin, currentSession?.workspaceOrigin);
1202
+ const snapshot = await persistence.stat(SessionId(sessionId));
1203
+ if (!snapshot) continue;
1204
+ if (snapshot.formatStatus === "migration-required") throw new SyncSessionError("session-format", "会话格式需要由当前 DSH 版本迁移");
1205
+ const entry = await this.exportSessionEntry(persistence, workspace, descriptor, position, SessionId(sessionId), snapshot, archivedIds.has(sessionId), pinnedIds.has(sessionId));
1206
+ if (entry) {
1207
+ entries.push(entry);
1208
+ eligibleLocalIds.add(sessionId);
1209
+ } else skipped.push({
1210
+ sessionId,
1211
+ reason: "workspace-cwd-mismatch"
1212
+ });
1213
+ }
1214
+ }
1215
+ const initial = this.state.read();
1216
+ for (const metadata of Object.values(initial.records)) {
1217
+ if (metadata.kind !== "session" || archivedBranchSessionIds.has(metadata.localEntityId)) continue;
1218
+ if (metadata.deleted) {
1219
+ entries.push({ metadata });
1220
+ continue;
1221
+ }
1222
+ if (eligibleLocalIds.has(metadata.localEntityId)) continue;
1223
+ const tombstone = {
1224
+ ...metadata,
1225
+ revision: this.state.nextRevision(this.now()),
1226
+ contentDigest: tombstoneDigest(metadata),
1227
+ deleted: true
1228
+ };
1229
+ this.state.update((state) => {
1230
+ state.records[sessionOriginKey(metadata)] = tombstone;
1231
+ });
1232
+ entries.push({ metadata: tombstone });
1233
+ }
1234
+ for (const archive of this.listArchives()) {
1235
+ const workspace = archive.workspaceId ? workspaceById.get(archive.workspaceId) : void 0;
1236
+ if (!workspace) continue;
1237
+ const binding = bindings.find((candidate) => normalize(candidate.mirrorPath) === workspaceCandidateKey(workspace));
1238
+ const node = binding ? this.registry.get(binding.nodeId) : void 0;
1239
+ if (!binding || !node || !isNodeRemoteType(node.type)) continue;
1240
+ const nodeOrigin = this.nodes.originForLocalNode(node.id);
1241
+ if (!nodeOrigin) continue;
1242
+ const archivedMetadata = Object.values(this.state.read().archives).find((item) => item.localSessionId === archive.localSessionId);
1243
+ const descriptor = this.workspaceDescriptor(workspace, binding, nodeOrigin, archivedMetadata?.workspaceOrigin);
1244
+ const snapshot = await persistence.stat(SessionId(archive.localSessionId));
1245
+ if (!snapshot) continue;
1246
+ const entry = await this.exportSessionEntry(persistence, workspace, descriptor, archive.workspacePosition, SessionId(archive.localSessionId), snapshot, true, false, archive);
1247
+ if (entry) entries.push(entry);
1248
+ else skipped.push({
1249
+ sessionId: archive.localSessionId,
1250
+ reason: "archive-workspace-cwd-mismatch"
1251
+ });
1252
+ }
1253
+ return makeSyncSessionBundle(instanceId, entries, skipped);
1254
+ }
1255
+ async applyBundleNow(value, sourceName, peerId, options, recoveryBatch) {
1256
+ const bundle = validateSyncSessionBundle(value);
1257
+ const pendingBatch = recoveryBatch ?? this.journal.findBatch(peerId, bundle.digest);
1258
+ if (pendingBatch && (pendingBatch.bundleDigest !== bundle.digest || pendingBatch.peerId !== peerId)) throw new SyncSessionError("journal-bundle-mismatch", "会话 journal batch 与 bundle 不匹配");
1259
+ const importedVia = pendingBatch?.importedVia ?? options.importedVia ?? "peer";
1260
+ const allowSameInstance = pendingBatch?.allowSameInstance ?? options.allowSameInstance ?? false;
1261
+ const localInstanceId = this.state.instanceId();
1262
+ if (bundle.instanceId === localInstanceId && !allowSameInstance) throw new SyncSessionError("same-instance", "cannot import sessions from this instance");
1263
+ if (!pendingBatch) await this.exportBundleNow();
1264
+ this.preflightBundle(bundle, {
1265
+ importedVia,
1266
+ allowSameInstance
1267
+ });
1268
+ const safeSourceName = (pendingBatch?.sourceName ?? sourceName).replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, 128) || "sync peer";
1269
+ const recordKeys = bundle.entries.map((entry) => entry.branchId ?? sessionOriginKey(entry.metadata));
1270
+ const batchId = pendingBatch?.batchId ?? this.journal.beginWithBundle(peerId, safeSourceName, bundle.digest, recordKeys, bundle, new Date(this.now()).toISOString(), importedVia, allowSameInstance);
1271
+ const result = emptyApplyResult();
1272
+ result.skipped += bundle.skipped.length;
1273
+ result.unmapped += bundle.skipped.length;
1274
+ const workspaceOrders = /* @__PURE__ */ new Map();
1275
+ const plannedSessionIds = /* @__PURE__ */ new Map();
1276
+ for (const entry of bundle.entries) if (!entry.metadata.deleted && !entry.branchId) {
1277
+ const current = this.state.read().records[sessionOriginKey(entry.metadata)];
1278
+ const localId = current && !current.deleted && current.contentDigest === entry.metadata.contentDigest ? SessionId(current.localEntityId) : importedSessionId(entry.metadata);
1279
+ plannedSessionIds.set(sessionOriginKey(entry.metadata), localId);
1280
+ }
1281
+ const workspaceResolutions = /* @__PURE__ */ new Map();
1282
+ const resolveWorkspace = (entry) => {
1283
+ const descriptor = entry.workspace;
1284
+ if (!descriptor) return Promise.resolve(void 0);
1285
+ const workspaceKey = syncWorkspaceKey(descriptor.originInstanceId, descriptor.originWorkspaceId, descriptor.remotePath);
1286
+ let resolution = workspaceResolutions.get(workspaceKey);
1287
+ if (!resolution) {
1288
+ resolution = this.ensureWorkspace(descriptor).catch((error) => {
1289
+ if (error instanceof SyncSessionError && error.code === "workspace-unmapped") return void 0;
1290
+ throw error;
1291
+ });
1292
+ workspaceResolutions.set(workspaceKey, resolution);
1293
+ }
1294
+ return resolution;
1295
+ };
1296
+ try {
1297
+ const queueWorkspaceOrder = (workspaceId, id, position) => {
1298
+ const order = workspaceOrders.get(workspaceId) ?? [];
1299
+ order.push({
1300
+ id,
1301
+ position
1302
+ });
1303
+ workspaceOrders.set(workspaceId, order);
1304
+ };
1305
+ const entries = [...bundle.entries].sort((a, b) => {
1306
+ if (a.branchId || b.branchId) return (a.branchId ?? "").localeCompare(b.branchId ?? "");
1307
+ if (a.workspace?.originWorkspaceId !== b.workspace?.originWorkspaceId) return (a.workspace?.originWorkspaceId ?? "").localeCompare(b.workspace?.originWorkspaceId ?? "");
1308
+ return (b.workspacePosition ?? 0) - (a.workspacePosition ?? 0);
1309
+ });
1310
+ for (const entry of entries) {
1311
+ const key = entry.branchId ?? sessionOriginKey(entry.metadata);
1312
+ if (entry.metadata.deleted) {
1313
+ if (await this.applyTombstone(entry.metadata)) result.deleted += 1;
1314
+ else result.skipped += 1;
1315
+ continue;
1316
+ }
1317
+ const entryWorkspace = await resolveWorkspace(entry);
1318
+ if (!entryWorkspace) {
1319
+ result.skipped += 1;
1320
+ result.unmapped += 1;
1321
+ continue;
1322
+ }
1323
+ const currentState = this.state.read();
1324
+ const currentArchive = entry.branchId ? currentState.archives[entry.branchId] : void 0;
1325
+ if (entry.branchId && currentArchive) {
1326
+ const comparison = compareSyncRevision(entry.metadata.revision, currentArchive.revision);
1327
+ if (comparison < 0) {
1328
+ result.skipped += 1;
1329
+ continue;
1330
+ }
1331
+ if (comparison === 0) {
1332
+ if (entry.metadata.contentDigest !== currentArchive.contentDigest || entry.metadata.stateDigest !== currentArchive.stateDigest) throw new SyncSessionError("revision-conflict", "Equal archive revisions contain different content");
1333
+ const sessionId = SessionId(currentArchive.localSessionId);
1334
+ const snapshot = await this.requirePersistence().stat(sessionId);
1335
+ if (snapshot && normalize(snapshot.header.cwd ?? "") === normalize(entryWorkspace.path)) {
1336
+ await entryWorkspace.attachSession(sessionId);
1337
+ queueWorkspaceOrder(entryWorkspace.id, sessionId, entry.workspacePosition ?? currentArchive.workspacePosition);
1338
+ }
1339
+ result.skipped += 1;
1340
+ continue;
1341
+ }
1342
+ if (entry.metadata.contentDigest !== currentArchive.contentDigest) throw new SyncSessionError("archive-conflict", "A session archive ID was reused for different content");
1343
+ await this.applyArchiveStateOnly(entry, currentArchive, entryWorkspace);
1344
+ result.updated += 1;
1345
+ queueWorkspaceOrder(entryWorkspace.id, SessionId(currentArchive.localSessionId), entry.workspacePosition ?? 0);
1346
+ continue;
1347
+ }
1348
+ const current = entry.branchId ? void 0 : currentState.records[sessionOriginKey(entry.metadata)];
1349
+ if (current) {
1350
+ const comparison = compareSyncRevision(entry.metadata.revision, current.revision);
1351
+ if (comparison < 0) {
1352
+ result.skipped += 1;
1353
+ continue;
1354
+ }
1355
+ if (comparison === 0) {
1356
+ if (current.deleted || entry.metadata.contentDigest !== current.contentDigest || entry.metadata.stateDigest !== current.stateDigest) throw new SyncSessionError("revision-conflict", "Equal session revisions contain different content");
1357
+ const sessionId = SessionId(current.localEntityId);
1358
+ const snapshot = await this.requirePersistence().stat(sessionId);
1359
+ if (snapshot && normalize(snapshot.header.cwd ?? "") === normalize(entryWorkspace.path)) {
1360
+ await entryWorkspace.attachSession(sessionId);
1361
+ queueWorkspaceOrder(entryWorkspace.id, sessionId, entry.workspacePosition ?? 0);
1362
+ }
1363
+ result.skipped += 1;
1364
+ continue;
1365
+ }
1366
+ if (!current.deleted && entry.metadata.contentDigest === current.contentDigest && await this.requirePersistence().stat(SessionId(current.localEntityId))) {
1367
+ await this.applyMembershipOnly(entry, current, safeSourceName, entryWorkspace, importedVia);
1368
+ result.updated += 1;
1369
+ queueWorkspaceOrder(entryWorkspace.id, SessionId(current.localEntityId), entry.workspacePosition ?? 0);
1370
+ continue;
1371
+ }
1372
+ }
1373
+ let preparedValue = this.journal.readPrepared(batchId, key);
1374
+ let prepared;
1375
+ if (preparedValue === void 0) {
1376
+ try {
1377
+ prepared = await this.prepareEntry(entry, safeSourceName, plannedSessionIds, entryWorkspace, importedVia);
1378
+ } catch (error) {
1379
+ if (error instanceof SyncSessionError && error.code === "workspace-unmapped") {
1380
+ result.skipped += 1;
1381
+ result.unmapped += 1;
1382
+ continue;
1383
+ }
1384
+ throw error;
1385
+ }
1386
+ this.journal.writePrepared(batchId, key, prepared);
1387
+ preparedValue = prepared;
1388
+ }
1389
+ prepared = this.validatePrepared(preparedValue);
1390
+ if (current && !current.deleted && current.localEntityId !== prepared.localSessionId) {
1391
+ if (await this.archiveCurrent(current)) result.archived += 1;
1392
+ }
1393
+ const existed = await this.requirePersistence().stat(prepared.localSessionId) !== void 0;
1394
+ await this.applyPrepared(prepared);
1395
+ if (prepared.branchId) result.archived += 1;
1396
+ else if (existed) result.updated += 1;
1397
+ else result.created += 1;
1398
+ queueWorkspaceOrder(prepared.workspaceId, prepared.localSessionId, prepared.workspacePosition);
1399
+ }
1400
+ await this.orderWorkspaceSessions(workspaceOrders);
1401
+ this.journal.complete(batchId);
1402
+ return result;
1403
+ } catch (error) {
1404
+ if (error instanceof SyncSessionError) throw error;
1405
+ throw new SyncSessionError("session-apply-failed", "会话同步应用失败", error);
1406
+ }
1407
+ }
1408
+ async prepareEntry(entry, sourceName, plannedSessionIds, workspace, importedVia) {
1409
+ if (!entry.header || !entry.events || !entry.workspace || !entry.objects) throw new SyncSessionError("manifest-invalid", "active session entry is incomplete");
1410
+ const sessionId = entry.branchId ? archivedSessionId(entry.branchId) : importedSessionId(entry.metadata);
1411
+ const sourceHeader = entry.header;
1412
+ const headerWithoutLocalFields = { ...sourceHeader };
1413
+ delete headerWithoutLocalFields.id;
1414
+ delete headerWithoutLocalFields.cwd;
1415
+ delete headerWithoutLocalFields.parentSession;
1416
+ const parentSession = (entry.parentOrigin ? plannedSessionIds.get(syncRecordKey("session", entry.parentOrigin)) ?? this.localSessionIdForOrigin(entry.parentOrigin) : void 0) ?? sourceHeader.parentSession;
1417
+ const header = {
1418
+ ...headerWithoutLocalFields,
1419
+ id: sessionId,
1420
+ cwd: workspace.path,
1421
+ ...parentSession ? { parentSession } : {}
1422
+ };
1423
+ const inheritedEventCount = entry.inheritedEventCount ?? 0;
1424
+ const persistence = this.requirePersistence();
1425
+ if (await persistence.stat(sessionId)) {
1426
+ const handle = await persistence.open(sessionId, "read");
1427
+ try {
1428
+ if (canonicalJson(handle.header) !== canonicalJson(header) || handle.inheritedEventCount !== inheritedEventCount) throw new SyncSessionError("session-id-conflict", "目标 session ID 已被其他 header 占用");
1429
+ } finally {
1430
+ await handle.close();
1431
+ }
1432
+ }
1433
+ const objectMap = await this.importObjects(entry.objects, sessionId);
1434
+ const validatedEvents = validateStoredEvents(header, this.rewriteEvents(entry.events, entry.objects, objectMap, sessionId, String(entry.header.id)));
1435
+ assertContiguous(sessionId, validatedEvents, 0);
1436
+ const instructionWorkspaceId = entry.instructionSnapshot?.workspaceOrigin ? this.localWorkspaceIdForOrigin(entry.instructionSnapshot.workspaceOrigin, entry.instructionSnapshot.workspaceRemotePath) : void 0;
1437
+ const instructionSnapshot = entry.instructionSnapshot ? {
1438
+ sessionId,
1439
+ ...instructionWorkspaceId ? { workspaceId: instructionWorkspaceId } : {},
1440
+ ...entry.instructionSnapshot.workspaceRevision === void 0 ? {} : { workspaceRevision: entry.instructionSnapshot.workspaceRevision },
1441
+ ...entry.instructionSnapshot.digest === void 0 ? {} : { digest: entry.instructionSnapshot.digest },
1442
+ text: entry.instructionSnapshot.text,
1443
+ capturedAt: entry.instructionSnapshot.capturedAt,
1444
+ ...parentSession ? { inheritedFromSessionId: parentSession } : {}
1445
+ } : void 0;
1446
+ return {
1447
+ metadata: entry.metadata,
1448
+ ...entry.branchId ? { branchId: entry.branchId } : {},
1449
+ localSessionId: sessionId,
1450
+ header,
1451
+ inheritedEventCount,
1452
+ events: [...validatedEvents],
1453
+ workspaceId: workspace.id,
1454
+ workspacePath: workspace.path,
1455
+ workspace: entry.workspace,
1456
+ workspacePosition: entry.workspacePosition ?? 0,
1457
+ archived: Boolean(entry.archived || entry.branchId),
1458
+ pinned: Boolean(entry.pinned && !entry.archived && !entry.branchId),
1459
+ sourceName,
1460
+ importedVia,
1461
+ ...instructionSnapshot ? { instructionSnapshot } : {}
1462
+ };
1463
+ }
1464
+ async ensureWorkspace(descriptor) {
1465
+ const workspaceRegistry = this.getWorkspaces();
1466
+ if (!workspaceRegistry) throw new SyncSessionError("workspace-unavailable", "工作区注册表不可用");
1467
+ const key = syncWorkspaceKey(descriptor.originInstanceId, descriptor.originWorkspaceId, descriptor.remotePath);
1468
+ const committedWorkspace = await this.nodes.withAvailableRemoteNode(descriptor.nodeOrigin, async (activeNodeId) => {
1469
+ const node = this.registry.get(activeNodeId);
1470
+ if (!node || !isNodeRemoteType(node.type)) return void 0;
1471
+ const latest = this.state.read().workspaces[key];
1472
+ const latestComparison = latest ? compareSyncRevision(descriptor.revision, latest.revision) : 1;
1473
+ if (latest && latestComparison === 0 && latest.contentDigest !== descriptor.contentDigest) throw new SyncSessionError("workspace-conflict", "Equal workspace revisions contain different content");
1474
+ const latestTitle = latest && latestComparison < 0 ? latest.title : descriptor.title;
1475
+ let binding = this.bindings.list().find((item) => item.nodeId === activeNodeId && posix.normalize(item.remotePath) === descriptor.remotePath);
1476
+ let workspace = latest ? workspaceRegistry.get(latest.localWorkspaceId) : void 0;
1477
+ if (!workspace && binding) workspace = workspaceRegistry.list().find((item) => normalize(item.path) === normalize(binding.mirrorPath));
1478
+ if (!workspace) {
1479
+ const mirrorPath = latest?.localPath ?? binding?.mirrorPath ?? this.bindings.mirrorRoot(activeNodeId, `sync-${createHash("sha256").update(key).digest("hex").slice(0, 20)}`);
1480
+ mkdirSync(mirrorPath, {
1481
+ recursive: true,
1482
+ mode: 448
1483
+ });
1484
+ workspace = await workspaceRegistry.create(mirrorPath, latestTitle);
1485
+ }
1486
+ if (workspace.title !== latestTitle) await workspace.setTitle(latestTitle);
1487
+ let activeBinding = binding ?? this.bindings.list().find((item) => normalize(item.mirrorPath) === normalize(workspace.path));
1488
+ if (!activeBinding || activeBinding.nodeId !== activeNodeId || posix.normalize(activeBinding.remotePath) !== descriptor.remotePath) {
1489
+ activeBinding = {
1490
+ mirrorPath: workspace.path,
1491
+ nodeId: activeNodeId,
1492
+ remotePath: descriptor.remotePath,
1493
+ workspaceId: workspace.id
1494
+ };
1495
+ this.bindings.add(activeBinding);
1496
+ }
1497
+ const localMappingChanged = !latest || latest.localWorkspaceId !== workspace.id || latest.localPath !== workspace.path || latest.localNodeId !== activeNodeId;
1498
+ if (!latest || latestComparison > 0 || localMappingChanged) {
1499
+ const mapping = {
1500
+ ...descriptor,
1501
+ title: latestTitle,
1502
+ ...latest && latestComparison < 0 ? {
1503
+ revision: latest.revision,
1504
+ contentDigest: latest.contentDigest
1505
+ } : {},
1506
+ localWorkspaceId: workspace.id,
1507
+ localPath: workspace.path,
1508
+ localNodeId: activeNodeId
1509
+ };
1510
+ this.state.update((state) => {
1511
+ state.workspaces[key] = mapping;
1512
+ });
1513
+ }
1514
+ return workspace;
1515
+ });
1516
+ if (!committedWorkspace) throw new SyncSessionError("workspace-unmapped", "目标实例没有对应的可用远程节点");
1517
+ return committedWorkspace;
1518
+ }
1519
+ async importObjects(objects, sessionId) {
1520
+ const attachments = objects.some((object) => object.kind === "image" || object.kind === "file") ? this.getAttachments() : void 0;
1521
+ const spills = objects.some((object) => object.kind === "spill" && !object.unavailable) ? this.getSpills() : void 0;
1522
+ if (objects.some((object) => object.kind === "image" || object.kind === "file") && !attachments) throw new SyncSessionError("attachments-unavailable", "目标实例的附件存储服务不可用");
1523
+ if (objects.some((object) => object.kind === "spill" && !object.unavailable) && !spills) throw new SyncSessionError("spill-transfer-unavailable", "目标实例没有 owner-scoped spill transfer API");
1524
+ const output = /* @__PURE__ */ new Map();
1525
+ for (const object of objects) {
1526
+ if (object.kind === "spill" && object.unavailable) continue;
1527
+ const data = arrayBuffer(object.data);
1528
+ if (data.byteLength !== object.bytes || sha256(data) !== object.digest) throw new SyncSessionError("object-integrity", "session object failed digest validation");
1529
+ if (object.kind === "image") {
1530
+ const ref = await attachments.saveImage({
1531
+ data,
1532
+ mediaType: object.ref.mediaType,
1533
+ ...object.ref.name === void 0 ? {} : { name: object.ref.name }
1534
+ });
1535
+ output.set(object.id, ref);
1536
+ } else if (object.kind === "file") {
1537
+ const ref = await attachments.saveFileStream({
1538
+ data: oneChunk(data),
1539
+ name: object.ref.name
1540
+ });
1541
+ output.set(object.id, ref);
1542
+ } else {
1543
+ const ref = await saveSpillTransfer(spills, {
1544
+ owner: { sessionId },
1545
+ source: object.reference.source,
1546
+ suggestedName: object.reference.suggestedName,
1547
+ transferId: object.id,
1548
+ bytes: object.bytes,
1549
+ digest: object.digest,
1550
+ data
1551
+ });
1552
+ output.set(object.id, ref);
1553
+ }
1554
+ }
1555
+ return output;
1556
+ }
1557
+ rewriteEvents(events, objects, importedRefs, sessionId, sourceSessionId) {
1558
+ const spillReferencesByLocator = /* @__PURE__ */ new Map();
1559
+ for (const object of objects) {
1560
+ if (object.kind !== "spill") continue;
1561
+ if (object.unavailable) {
1562
+ spillReferencesByLocator.set(object.reference.ref.locator, { source: object.reference });
1563
+ continue;
1564
+ }
1565
+ const target = importedRefs.get(object.id);
1566
+ if (!target || !("locator" in target)) throw new SyncSessionError("spill-reference-missing", "目标 spill reference 未创建");
1567
+ spillReferencesByLocator.set(object.reference.ref.locator, {
1568
+ source: object.reference,
1569
+ target
1570
+ });
1571
+ }
1572
+ const rewrite = (value) => {
1573
+ if (Array.isArray(value)) return value.map(rewrite);
1574
+ if (!isRecord$1(value)) return value;
1575
+ if (value.type === "image" && isImageRef(value.attachment)) {
1576
+ const target = importedRefs.get(attachmentKey("image", value.attachment));
1577
+ if (!target || !("mediaType" in target)) throw new SyncSessionError("attachment-reference-missing", "目标图片附件未创建");
1578
+ return {
1579
+ ...value,
1580
+ attachment: target
1581
+ };
1582
+ }
1583
+ if (value.type === "file" && isFileRef(value.attachment)) {
1584
+ const target = importedRefs.get(attachmentKey("file", value.attachment));
1585
+ if (!target || !("name" in target)) throw new SyncSessionError("attachment-reference-missing", "目标文件附件未创建");
1586
+ return {
1587
+ ...value,
1588
+ attachment: target
1589
+ };
1590
+ }
1591
+ if (value.type === "session-log-deepseek/delivery-accepted" && isRecord$1(value.data) && value.data.sessionId === sourceSessionId) return {
1592
+ ...value,
1593
+ data: {
1594
+ ...value.data,
1595
+ sessionId
1596
+ }
1597
+ };
1598
+ const output = {};
1599
+ for (const [key, child] of Object.entries(value)) if (key === SPILL_ARTIFACT_META_KEY && isRecord$1(child) && Array.isArray(child.references)) {
1600
+ const filteredReferences = child.references.filter((entry) => {
1601
+ if (!isRecord$1(entry) || !isRecord$1(entry.owner) || !isRecord$1(entry.ref)) return false;
1602
+ const reference = entry;
1603
+ return spillReferencesByLocator.get(reference.ref.locator)?.target !== void 0;
1604
+ });
1605
+ if (filteredReferences.length === 0) continue;
1606
+ output[key] = {
1607
+ version: child.version,
1608
+ references: filteredReferences.map((entry) => {
1609
+ const reference = entry;
1610
+ const mapped = spillReferencesByLocator.get(reference.ref.locator);
1611
+ if (!mapped?.target) throw new SyncSessionError("spill-reference-missing", "目标 spill reference 未创建");
1612
+ return {
1613
+ ...reference,
1614
+ owner: { sessionId },
1615
+ ref: mapped.target
1616
+ };
1617
+ })
1618
+ };
1619
+ } else if (key === "text" && typeof child === "string") {
1620
+ let text = child;
1621
+ for (const { source, target } of spillReferencesByLocator.values()) {
1622
+ if (!target) continue;
1623
+ text = text.replaceAll(`Full formatted result stored at: ${source.ref.locator}. ${source.ref.retrievalHint}`, `Full formatted result stored at: ${target.locator}. ${target.retrievalHint}`);
1624
+ }
1625
+ output[key] = text;
1626
+ } else output[key] = rewrite(child);
1627
+ return output;
1628
+ };
1629
+ return events.map((event) => snapshotSessionEvent(rewrite(structuredClone(event))));
1630
+ }
1631
+ validatePrepared(value) {
1632
+ if (!isRecord$1(value) || !isRecord$1(value.metadata) || value.metadata.kind !== "session" || typeof value.localSessionId !== "string" || !isRecord$1(value.header) || value.header.id !== value.localSessionId || typeof value.workspaceId !== "string" || typeof value.workspacePath !== "string" || !Array.isArray(value.events) || typeof value.workspacePosition !== "number" || typeof value.archived !== "boolean" || typeof value.pinned !== "boolean" || typeof value.sourceName !== "string" || value.importedVia !== "peer" && value.importedVia !== "offline") throw new SyncSessionError("journal-invalid", "prepared session record is invalid");
1633
+ const header = value.header;
1634
+ if (header.version !== 4) throw new SyncSessionError("session-format", "prepared session format is unsupported");
1635
+ const events = validateStoredEvents(header, structuredClone(value.events));
1636
+ assertContiguous(header.id, events, 0);
1637
+ if (!Number.isSafeInteger(value.workspacePosition) || value.workspacePosition < 0 || typeof value.inheritedEventCount !== "number" || !Number.isSafeInteger(value.inheritedEventCount) || value.inheritedEventCount < 0 || !isRecord$1(value.workspace)) throw new SyncSessionError("journal-invalid", "prepared session metadata is invalid");
1638
+ return {
1639
+ metadata: value.metadata,
1640
+ ...typeof value.branchId === "string" ? { branchId: value.branchId } : {},
1641
+ localSessionId: SessionId(value.localSessionId),
1642
+ header,
1643
+ inheritedEventCount: value.inheritedEventCount,
1644
+ events: [...events],
1645
+ workspaceId: value.workspaceId,
1646
+ workspacePath: value.workspacePath,
1647
+ workspace: value.workspace,
1648
+ workspacePosition: value.workspacePosition,
1649
+ archived: value.archived,
1650
+ pinned: value.pinned,
1651
+ sourceName: value.sourceName,
1652
+ importedVia: value.importedVia,
1653
+ ...isRecord$1(value.instructionSnapshot) ? { instructionSnapshot: value.instructionSnapshot } : {}
1654
+ };
1655
+ }
1656
+ async applyPrepared(prepared) {
1657
+ const sessionId = prepared.localSessionId;
1658
+ const existed = await this.writeSession(prepared);
1659
+ if (prepared.instructionSnapshot) this.instructions.restore(prepared.instructionSnapshot);
1660
+ const registry = this.getWorkspaces();
1661
+ const workspace = registry?.get(prepared.workspaceId);
1662
+ if (!registry || !workspace || normalize(workspace.path) !== normalize(prepared.workspacePath)) throw new SyncSessionError("workspace-unmapped", "导入 session 的目标工作区不可用");
1663
+ await workspace.attachSession(sessionId);
1664
+ if (prepared.archived) await registry.archiveSession(sessionId, { stopActivity: true });
1665
+ else await registry.unarchiveSession(sessionId);
1666
+ if (prepared.pinned && !prepared.archived) await registry.pinSession?.(sessionId);
1667
+ else await registry.unpinSession?.(sessionId);
1668
+ this.state.observeRevision(prepared.metadata.revision, this.now());
1669
+ const binding = this.bindings.list().find((item) => normalize(item.mirrorPath) === normalize(prepared.workspacePath));
1670
+ if (!binding) throw new SyncSessionError("workspace-unmapped", "导入 session 的节点绑定不可用");
1671
+ const activeMetadata = {
1672
+ ...prepared.metadata,
1673
+ localEntityId: String(sessionId),
1674
+ importedVia: prepared.importedVia,
1675
+ sourceName: prepared.sourceName,
1676
+ stateDigest: prepared.metadata.stateDigest ?? sessionStateDigest(prepared.workspace, prepared.workspacePosition, prepared.archived, prepared.pinned),
1677
+ workspaceOrigin: {
1678
+ originInstanceId: prepared.workspace.originInstanceId,
1679
+ originEntityId: prepared.workspace.originWorkspaceId
1680
+ },
1681
+ deleted: false,
1682
+ pendingRemoval: false
1683
+ };
1684
+ const key = sessionOriginKey(prepared.metadata);
1685
+ this.state.update((state) => {
1686
+ const mappingKey = syncWorkspaceKey(prepared.workspace.originInstanceId, prepared.workspace.originWorkspaceId, prepared.workspace.remotePath);
1687
+ state.workspaces[mappingKey] = {
1688
+ ...prepared.workspace,
1689
+ localWorkspaceId: prepared.workspaceId,
1690
+ localPath: prepared.workspacePath,
1691
+ localNodeId: binding.nodeId
1692
+ };
1693
+ if (prepared.branchId) state.archives[prepared.branchId] = {
1694
+ branchId: prepared.branchId,
1695
+ originInstanceId: prepared.metadata.originInstanceId,
1696
+ originSessionId: prepared.metadata.originEntityId,
1697
+ localSessionId: String(sessionId),
1698
+ revision: prepared.metadata.revision,
1699
+ contentDigest: prepared.metadata.contentDigest,
1700
+ stateDigest: prepared.metadata.stateDigest ?? sessionStateDigest(prepared.workspace, prepared.workspacePosition, true, false),
1701
+ workspacePosition: prepared.workspacePosition,
1702
+ workspaceId: prepared.workspaceId,
1703
+ workspaceOrigin: {
1704
+ originInstanceId: prepared.workspace.originInstanceId,
1705
+ originEntityId: prepared.workspace.originWorkspaceId
1706
+ },
1707
+ archivedAt: new Date(this.now()).toISOString()
1708
+ };
1709
+ else state.records[key] = activeMetadata;
1710
+ });
1711
+ return existed;
1712
+ }
1713
+ async applyMembershipOnly(entry, current, sourceName, workspace, importedVia) {
1714
+ const descriptor = entry.workspace;
1715
+ const stateDigest = entry.metadata.stateDigest;
1716
+ if (!descriptor || !stateDigest) throw new SyncSessionError("manifest-invalid", "session state entry is incomplete");
1717
+ const registry = this.getWorkspaces();
1718
+ if (!registry) throw new SyncSessionError("workspace-unavailable", "工作区注册表不可用");
1719
+ const sessionId = SessionId(current.localEntityId);
1720
+ const snapshot = await this.requirePersistence().stat(sessionId);
1721
+ if (!snapshot || normalize(snapshot.header.cwd ?? "") !== normalize(workspace.path)) throw new SyncSessionError("session-workspace-mismatch", "既有 session 的本地 cwd 与目标 workspace 不一致");
1722
+ await workspace.attachSession(sessionId);
1723
+ if (entry.archived) await registry.archiveSession(sessionId, { stopActivity: true });
1724
+ else await registry.unarchiveSession(sessionId);
1725
+ if (entry.pinned && !entry.archived) await registry.pinSession?.(sessionId);
1726
+ else await registry.unpinSession?.(sessionId);
1727
+ if (entry.instructionSnapshot) {
1728
+ const inheritedFromSessionId = entry.parentOrigin ? this.localSessionIdForOrigin(entry.parentOrigin) : void 0;
1729
+ const instructionWorkspaceId = entry.instructionSnapshot.workspaceOrigin ? this.localWorkspaceIdForOrigin(entry.instructionSnapshot.workspaceOrigin, entry.instructionSnapshot.workspaceRemotePath) : void 0;
1730
+ const snapshot = {
1731
+ sessionId,
1732
+ ...instructionWorkspaceId ? { workspaceId: instructionWorkspaceId } : {},
1733
+ ...entry.instructionSnapshot.workspaceRevision === void 0 ? {} : { workspaceRevision: entry.instructionSnapshot.workspaceRevision },
1734
+ ...entry.instructionSnapshot.digest === void 0 ? {} : { digest: entry.instructionSnapshot.digest },
1735
+ text: entry.instructionSnapshot.text,
1736
+ capturedAt: entry.instructionSnapshot.capturedAt,
1737
+ ...inheritedFromSessionId ? { inheritedFromSessionId } : {}
1738
+ };
1739
+ this.instructions.restore(snapshot);
1740
+ }
1741
+ this.state.observeRevision(entry.metadata.revision, this.now());
1742
+ const metadata = {
1743
+ ...entry.metadata,
1744
+ localEntityId: current.localEntityId,
1745
+ importedVia,
1746
+ sourceName,
1747
+ stateDigest,
1748
+ deleted: false,
1749
+ pendingRemoval: false,
1750
+ workspaceOrigin: {
1751
+ originInstanceId: descriptor.originInstanceId,
1752
+ originEntityId: descriptor.originWorkspaceId
1753
+ }
1754
+ };
1755
+ this.state.update((state) => {
1756
+ state.records[sessionOriginKey(metadata)] = metadata;
1757
+ });
1758
+ }
1759
+ async applyArchiveStateOnly(entry, current, workspace) {
1760
+ const branchId = entry.branchId;
1761
+ const descriptor = entry.workspace;
1762
+ const stateDigest = entry.metadata.stateDigest;
1763
+ if (!branchId || !descriptor || !stateDigest) throw new SyncSessionError("manifest-invalid", "archive state entry is incomplete");
1764
+ const registry = this.getWorkspaces();
1765
+ if (!registry) throw new SyncSessionError("workspace-unavailable", "工作区注册表不可用");
1766
+ const sessionId = SessionId(current.localSessionId);
1767
+ const snapshot = await this.requirePersistence().stat(sessionId);
1768
+ if (!snapshot || normalize(snapshot.header.cwd ?? "") !== normalize(workspace.path)) throw new SyncSessionError("session-workspace-mismatch", "归档 session 的本地 cwd 与目标 workspace 不一致");
1769
+ await workspace.attachSession(sessionId);
1770
+ await registry.archiveSession(sessionId, { stopActivity: true });
1771
+ await registry.unpinSession?.(sessionId);
1772
+ this.state.observeRevision(entry.metadata.revision, this.now());
1773
+ this.state.update((state) => {
1774
+ state.archives[branchId] = {
1775
+ ...current,
1776
+ revision: entry.metadata.revision,
1777
+ stateDigest,
1778
+ workspacePosition: entry.workspacePosition ?? current.workspacePosition,
1779
+ workspaceId: workspace.id,
1780
+ workspaceOrigin: {
1781
+ originInstanceId: descriptor.originInstanceId,
1782
+ originEntityId: descriptor.originWorkspaceId
1783
+ }
1784
+ };
1785
+ });
1786
+ }
1787
+ async archiveCurrent(metadata) {
1788
+ if (metadata.deleted || metadata.kind !== "session") return void 0;
1789
+ const branchId = branchIdFor(metadata);
1790
+ if (this.state.read().archives[branchId]) return branchId;
1791
+ const sessionId = SessionId(metadata.localEntityId);
1792
+ const persistence = this.requirePersistence();
1793
+ if (!await persistence.stat(sessionId)) return void 0;
1794
+ await persistence.flush();
1795
+ const registry = this.getWorkspaces();
1796
+ if (!registry) throw new SyncSessionError("workspace-unavailable", "工作区注册表不可用");
1797
+ const workspace = registry?.list().find((candidate) => candidate.sessionIds.some((id) => String(id) === String(sessionId)));
1798
+ const binding = workspace ? this.bindings.list().find((item) => normalize(item.mirrorPath) === normalize(workspace.path)) : void 0;
1799
+ const nodeOrigin = binding ? this.nodes.originForLocalNode(binding.nodeId) : void 0;
1800
+ const descriptor = workspace && binding && nodeOrigin ? this.workspaceDescriptor(workspace, binding, nodeOrigin, metadata.workspaceOrigin) : void 0;
1801
+ const workspacePosition = workspace?.sessionIds.findIndex((id) => String(id) === String(sessionId)) ?? 0;
1802
+ await registry.archiveSession(sessionId, { stopActivity: true });
1803
+ await persistence.flush();
1804
+ this.state.update((draft) => {
1805
+ draft.archives[branchId] = {
1806
+ branchId,
1807
+ originInstanceId: metadata.originInstanceId,
1808
+ originSessionId: metadata.originEntityId,
1809
+ localSessionId: String(sessionId),
1810
+ revision: metadata.revision,
1811
+ contentDigest: metadata.contentDigest,
1812
+ stateDigest: descriptor ? sessionStateDigest(descriptor, Math.max(0, workspacePosition), true, false) : metadata.stateDigest ?? metadata.contentDigest,
1813
+ workspacePosition: Math.max(0, workspacePosition),
1814
+ ...workspace ? { workspaceId: workspace.id } : {},
1815
+ ...descriptor ? { workspaceOrigin: {
1816
+ originInstanceId: descriptor.originInstanceId,
1817
+ originEntityId: descriptor.originWorkspaceId
1818
+ } } : metadata.workspaceOrigin ? { workspaceOrigin: metadata.workspaceOrigin } : {},
1819
+ archivedAt: new Date(this.now()).toISOString()
1820
+ };
1821
+ });
1822
+ return branchId;
1823
+ }
1824
+ async applyTombstone(metadata) {
1825
+ const key = sessionOriginKey(metadata);
1826
+ const current = this.state.read().records[key];
1827
+ if (current) {
1828
+ const comparison = compareSyncRevision(metadata.revision, current.revision);
1829
+ if (comparison < 0) return false;
1830
+ if (comparison === 0) {
1831
+ if (metadata.contentDigest !== current.contentDigest || !current.deleted) throw new SyncSessionError("revision-conflict", "Equal session revisions contain different tombstone state");
1832
+ return false;
1833
+ }
1834
+ if (!current.deleted) await this.archiveCurrent(current);
1835
+ }
1836
+ const localEntityId = current?.localEntityId ?? String(importedSessionId(metadata));
1837
+ const tombstone = {
1838
+ ...metadata,
1839
+ kind: "session",
1840
+ localEntityId,
1841
+ deleted: true
1842
+ };
1843
+ this.state.observeRevision(metadata.revision, this.now());
1844
+ this.state.update((state) => {
1845
+ state.records[key] = tombstone;
1846
+ });
1847
+ return true;
1848
+ }
1849
+ async restoreArchiveNow(branchId) {
1850
+ if (!/^b-[a-f0-9]{32}$/.test(branchId)) throw new SyncSessionError("archive-not-found", "会话归档不存在");
1851
+ const archive = this.state.read().archives[branchId];
1852
+ if (!archive) throw new SyncSessionError("archive-not-found", "会话归档不存在");
1853
+ const registry = this.getWorkspaces();
1854
+ const sessionId = SessionId(archive.localSessionId);
1855
+ const session = await this.requirePersistence().stat(sessionId);
1856
+ if (!registry || !session) throw new SyncSessionError("archive-not-found", "会话归档日志不存在");
1857
+ const key = syncRecordKey("session", {
1858
+ originInstanceId: archive.originInstanceId,
1859
+ originEntityId: archive.originSessionId
1860
+ });
1861
+ const current = this.state.read().records[key];
1862
+ if (current && !current.deleted && current.localEntityId !== archive.localSessionId) await this.archiveCurrent(current);
1863
+ await registry.unarchiveSession(sessionId);
1864
+ const revision = this.state.nextRevision(this.now());
1865
+ const restored = {
1866
+ kind: "session",
1867
+ originInstanceId: archive.originInstanceId,
1868
+ originEntityId: archive.originSessionId,
1869
+ localEntityId: archive.localSessionId,
1870
+ revision,
1871
+ contentDigest: archive.contentDigest,
1872
+ stateDigest: archive.stateDigest,
1873
+ importedVia: "peer",
1874
+ ...archive.workspaceOrigin ? { workspaceOrigin: archive.workspaceOrigin } : {}
1875
+ };
1876
+ this.state.update((state) => {
1877
+ delete state.archives[branchId];
1878
+ state.records[key] = restored;
1879
+ });
1880
+ return sessionId;
1881
+ }
1882
+ async orderWorkspaceSessions(orders) {
1883
+ const registry = this.getWorkspaces();
1884
+ if (!registry) return;
1885
+ for (const [workspaceId, incoming] of orders) {
1886
+ const workspace = registry.get(workspaceId);
1887
+ if (!workspace) continue;
1888
+ const current = [...workspace.sessionIds];
1889
+ const incomingIds = new Set(incoming.map((item) => String(item.id)));
1890
+ const target = [...[...incoming].sort((a, b) => a.position - b.position || String(a.id).localeCompare(String(b.id))).map((item) => item.id).filter((id, index, all) => current.some((existing) => String(existing) === String(id)) && all.findIndex((candidate) => String(candidate) === String(id)) === index), ...current.filter((id) => !incomingIds.has(String(id)))];
1891
+ if (target.length === current.length && target.every((id, index) => String(id) === String(current[index]))) continue;
1892
+ let before;
1893
+ for (const id of target.reverse()) {
1894
+ await workspace.insertSessionBefore(id, before);
1895
+ before = id;
1896
+ }
1897
+ }
1898
+ }
1899
+ localSessionIdForOrigin(origin) {
1900
+ const state = this.state.read();
1901
+ const active = state.records[syncRecordKey("session", origin)];
1902
+ if (active && !active.deleted) return SessionId(active.localEntityId);
1903
+ const archive = Object.values(state.archives).filter((entry) => entry.originInstanceId === origin.originInstanceId && entry.originSessionId === origin.originEntityId).sort((a, b) => compareSyncRevision(b.revision, a.revision))[0];
1904
+ return archive ? SessionId(archive.localSessionId) : void 0;
1905
+ }
1906
+ localWorkspaceIdForOrigin(origin, remotePath) {
1907
+ const mappings = Object.values(this.state.read().workspaces).filter((mapping) => mapping.originInstanceId === origin.originInstanceId && mapping.originWorkspaceId === origin.originEntityId && (remotePath === void 0 || mapping.remotePath === remotePath));
1908
+ return mappings.length === 1 ? mappings[0]?.localWorkspaceId : void 0;
1909
+ }
1910
+ async writeSession(prepared) {
1911
+ const persistence = this.requirePersistence();
1912
+ const existed = await persistence.stat(prepared.localSessionId);
1913
+ const handle = existed ? await persistence.open(prepared.localSessionId, "write") : await persistence.create(prepared.header, prepared.header.isSeeded ? { inheritedEventCount: prepared.inheritedEventCount } : void 0);
1914
+ try {
1915
+ if (canonicalJson(handle.header) !== canonicalJson(prepared.header) || handle.inheritedEventCount !== prepared.inheritedEventCount) throw new SyncSessionError("session-id-conflict", "目标 session ID 已被其他 header 占用");
1916
+ const existing = [];
1917
+ for (let offset = 0;;) {
1918
+ const page = await handle.read(offset, SESSION_READ_PAGE_SIZE);
1919
+ if (page.events.length === 0) break;
1920
+ existing.push(...page.events);
1921
+ offset += page.events.length;
1922
+ if (page.events.length < SESSION_READ_PAGE_SIZE) break;
1923
+ }
1924
+ if (existing.length > prepared.events.length) throw new SyncSessionError("session-prefix-conflict", `目标 session ${prepared.localSessionId} 前缀比导入记录更长(本地 ${existing.length},导入 ${prepared.events.length})`);
1925
+ for (let index = 0; index < existing.length; index += 1) if (canonicalJson(existing[index]) !== canonicalJson(prepared.events[index])) throw new SyncSessionError("session-prefix-conflict", `目标 session ${prepared.localSessionId} 的已提交事件与导入前缀不同(首个差异位置 seq=${existing[index]?.seq},本地 ${existing.length} 事件,导入 ${prepared.events.length} 事件)`);
1926
+ for (let offset = existing.length; offset < prepared.events.length; offset += SESSION_READ_PAGE_SIZE) await handle.append(prepared.events.slice(offset, Math.min(offset + SESSION_READ_PAGE_SIZE, prepared.events.length)));
1927
+ await handle.flush();
1928
+ } finally {
1929
+ await handle.close();
1930
+ }
1931
+ return existed !== void 0;
1932
+ }
1933
+ workspaceDescriptor(workspace, binding, nodeOrigin, preferredOrigin) {
1934
+ const state = this.state.read();
1935
+ const matchingMappings = Object.values(state.workspaces).filter((mapping) => mapping.localWorkspaceId === workspace.id && mapping.localNodeId === binding.nodeId && posix.normalize(mapping.remotePath) === posix.normalize(binding.remotePath));
1936
+ const prior = (preferredOrigin ? Object.values(state.workspaces).find((mapping) => mapping.originInstanceId === preferredOrigin.originInstanceId && mapping.originWorkspaceId === preferredOrigin.originEntityId && mapping.localWorkspaceId === workspace.id && mapping.localNodeId === binding.nodeId) : void 0) ?? (matchingMappings.length === 1 ? matchingMappings[0] : void 0);
1937
+ const originInstanceId = preferredOrigin?.originInstanceId ?? prior?.originInstanceId ?? this.state.instanceId();
1938
+ const originWorkspaceId = preferredOrigin?.originEntityId ?? prior?.originWorkspaceId ?? workspace.id;
1939
+ const base = {
1940
+ originInstanceId,
1941
+ originWorkspaceId,
1942
+ nodeOrigin,
1943
+ remotePath: posix.normalize(binding.remotePath),
1944
+ title: workspace.title
1945
+ };
1946
+ const digest = workspaceDigest(base);
1947
+ const key = syncWorkspaceKey(originInstanceId, originWorkspaceId, base.remotePath);
1948
+ const current = state.workspaces[key];
1949
+ const revision = current && current.contentDigest === digest ? current.revision : this.state.nextRevision(this.now());
1950
+ const mapping = {
1951
+ ...base,
1952
+ localWorkspaceId: workspace.id,
1953
+ localPath: workspace.path,
1954
+ localNodeId: binding.nodeId,
1955
+ revision,
1956
+ contentDigest: digest
1957
+ };
1958
+ this.state.update((draft) => {
1959
+ draft.workspaces[key] = mapping;
1960
+ });
1961
+ return {
1962
+ ...base,
1963
+ revision,
1964
+ contentDigest: digest
1965
+ };
1966
+ }
1967
+ async exportSessionEntry(persistence, workspace, descriptor, workspacePosition, sessionId, firstSnapshot, archived, pinned, archive) {
1968
+ const { header, inheritedEventCount, events } = await this.readStableSession(persistence, sessionId, firstSnapshot);
1969
+ if (!header.cwd || posix.normalize(header.cwd) !== posix.normalize(workspace.path)) return void 0;
1970
+ const localSessionId = String(sessionId);
1971
+ const state = this.state.read();
1972
+ const parentOrigin = header.parentSession ? findSessionOriginByLocalId(state, String(header.parentSession)) : void 0;
1973
+ const syncInstructions = instructionsForSession(this.instructions, String(sessionId), state, {
1974
+ originInstanceId: descriptor.originInstanceId,
1975
+ originEntityId: descriptor.originWorkspaceId
1976
+ });
1977
+ const objects = await this.exportObjects(events, localSessionId);
1978
+ const digest = sessionEventDigest(header, inheritedEventCount, events, descriptor, syncInstructions, objects, parentOrigin);
1979
+ const current = archive ? void 0 : findSessionRecordByLocalId(state, localSessionId) ?? state.records[syncRecordKey("session", {
1980
+ originInstanceId: this.state.instanceId(),
1981
+ originEntityId: localSessionId
1982
+ })];
1983
+ const originInstanceId = archive?.originInstanceId ?? current?.originInstanceId ?? this.state.instanceId();
1984
+ const originEntityId = archive?.originSessionId ?? current?.originEntityId ?? localSessionId;
1985
+ const workspaceOrigin = {
1986
+ originInstanceId: descriptor.originInstanceId,
1987
+ originEntityId: descriptor.originWorkspaceId
1988
+ };
1989
+ const workspaceOriginChanged = Boolean(current?.workspaceOrigin && (current.workspaceOrigin.originInstanceId !== workspaceOrigin.originInstanceId || current.workspaceOrigin.originEntityId !== workspaceOrigin.originEntityId));
1990
+ const stateDigest = sessionStateDigest(descriptor, workspacePosition, archived, pinned);
1991
+ const sessionStateChanged = Boolean(current && current.stateDigest !== stateDigest);
1992
+ let revision = archive?.revision ?? current?.revision;
1993
+ let contentDigest = digest;
1994
+ let outputBranchId = archive?.branchId;
1995
+ if (archive) {
1996
+ if (archive.contentDigest !== digest || archive.stateDigest !== stateDigest || archive.workspacePosition !== workspacePosition) {
1997
+ const nextRevision = this.state.nextRevision(this.now());
1998
+ revision = nextRevision;
1999
+ const branchId = archive.contentDigest !== digest ? branchIdFor({
2000
+ originInstanceId,
2001
+ originEntityId,
2002
+ revision: nextRevision,
2003
+ contentDigest: digest
2004
+ }) : archive.branchId;
2005
+ const updatedArchive = {
2006
+ ...archive,
2007
+ branchId,
2008
+ revision: nextRevision,
2009
+ contentDigest: digest,
2010
+ stateDigest,
2011
+ workspacePosition,
2012
+ workspaceOrigin
2013
+ };
2014
+ this.state.update((draft) => {
2015
+ delete draft.archives[archive.branchId];
2016
+ draft.archives[branchId] = updatedArchive;
2017
+ });
2018
+ outputBranchId = branchId;
2019
+ } else contentDigest = archive.contentDigest;
2020
+ } else if (!current || current.contentDigest !== digest || current.deleted || workspaceOriginChanged || !current.workspaceOrigin || sessionStateChanged) revision = this.state.nextRevision(this.now());
2021
+ const metadata = {
2022
+ kind: "session",
2023
+ originInstanceId,
2024
+ originEntityId,
2025
+ localEntityId: localSessionId,
2026
+ revision: revision ?? this.state.nextRevision(this.now()),
2027
+ contentDigest,
2028
+ stateDigest,
2029
+ workspaceOrigin,
2030
+ ...archive ? {} : current?.importedVia ? { importedVia: current.importedVia } : {},
2031
+ ...archive ? {} : current?.sourceName ? { sourceName: current.sourceName } : {}
2032
+ };
2033
+ if (!archive && (!current || current.contentDigest !== digest || current.deleted || workspaceOriginChanged || !current.workspaceOrigin || sessionStateChanged)) this.state.update((draft) => {
2034
+ draft.records[sessionOriginKey(metadata)] = metadata;
2035
+ });
2036
+ return {
2037
+ metadata,
2038
+ ...outputBranchId ? { branchId: outputBranchId } : {},
2039
+ sourceLocalSessionId: localSessionId,
2040
+ header,
2041
+ inheritedEventCount,
2042
+ ...parentOrigin ? { parentOrigin } : {},
2043
+ workspace: descriptor,
2044
+ workspacePosition,
2045
+ archived,
2046
+ pinned,
2047
+ ...syncInstructions ? { instructionSnapshot: syncInstructions } : {},
2048
+ events: [...events],
2049
+ objects
2050
+ };
2051
+ }
2052
+ async readStableSession(persistence, sessionId, firstSnapshot) {
2053
+ let snapshot = firstSnapshot;
2054
+ for (let attempt = 0; attempt < 2; attempt += 1) {
2055
+ if (snapshot.formatStatus === "migration-required") throw new SyncSessionError("session-format", "会话格式需要由当前 DSH 版本迁移");
2056
+ const handle = await persistence.open(sessionId, "read");
2057
+ try {
2058
+ const events = [];
2059
+ let offset = 0;
2060
+ for (;;) {
2061
+ const page = await handle.read(offset, SESSION_READ_PAGE_SIZE);
2062
+ if (page.events.length === 0) break;
2063
+ events.push(...structuredClone(page.events));
2064
+ offset += page.events.length;
2065
+ if (events.length > MAX_SYNC_SESSIONS * 100) throw new SyncSessionError("session-too-large", "会话事件数超过限制");
2066
+ if (page.events.length < SESSION_READ_PAGE_SIZE) break;
2067
+ }
2068
+ const validated = validateStoredEvents(handle.header, events);
2069
+ assertContiguous(sessionId, validated, 0);
2070
+ const after = await persistence.stat(sessionId);
2071
+ if (!after) throw new SyncSessionError("session-missing", "会话在导出期间消失");
2072
+ if (JSON.stringify(snapshot.revision) === JSON.stringify(after.revision)) return {
2073
+ header: handle.header,
2074
+ inheritedEventCount: Number(handle.inheritedEventCount),
2075
+ events: [...validated]
2076
+ };
2077
+ snapshot = after;
2078
+ } finally {
2079
+ await handle.close();
2080
+ }
2081
+ }
2082
+ throw new SyncSessionError("session-changing", "会话在导出期间持续变化,请稍后重试");
2083
+ }
2084
+ async exportObjects(events, sessionId) {
2085
+ const attachments = attachmentReferences(events);
2086
+ const spills = spillReferences(events);
2087
+ const structuredSpills = new Set(spills.map(spillKey));
2088
+ for (const reference of legacySpillReferences(events, sessionId)) if (!structuredSpills.has(spillKey(reference))) spills.push(reference);
2089
+ const payloads = /* @__PURE__ */ new Map();
2090
+ const attachmentStore = attachments.images.length + attachments.files.length > 0 ? this.getAttachments() : void 0;
2091
+ if (attachments.images.length + attachments.files.length > 0 && !attachmentStore) throw new SyncSessionError("attachments-unavailable", "附件存储服务不可用");
2092
+ for (const ref of attachments.images) {
2093
+ const stored = await attachmentStore.readImage(ref);
2094
+ const digest = sha256(stored.data);
2095
+ payloads.set(attachmentKey("image", ref), {
2096
+ id: attachmentKey("image", ref),
2097
+ kind: "image",
2098
+ ref: stored.ref,
2099
+ bytes: stored.data.byteLength,
2100
+ digest,
2101
+ data: Buffer.from(stored.data).toString("base64")
2102
+ });
2103
+ }
2104
+ for (const ref of attachments.files) {
2105
+ const data = await collectBytes(attachmentStore.readFileStream(ref));
2106
+ payloads.set(attachmentKey("file", ref), {
2107
+ id: attachmentKey("file", ref),
2108
+ kind: "file",
2109
+ ref,
2110
+ bytes: data.byteLength,
2111
+ digest: sha256(data),
2112
+ data: data.toString("base64")
2113
+ });
2114
+ }
2115
+ if (spills.length > 0) {
2116
+ const store = this.getSpills();
2117
+ const untransferred = [];
2118
+ for (const reference of spills) {
2119
+ if (!store) {
2120
+ untransferred.push(reference);
2121
+ continue;
2122
+ }
2123
+ try {
2124
+ const data = await collectBytes(readSpillStream(store, reference));
2125
+ if (reference.ref.bytes !== void 0 && data.byteLength !== reference.ref.bytes) {
2126
+ untransferred.push(reference);
2127
+ continue;
2128
+ }
2129
+ try {
2130
+ new TextDecoder("utf-8", { fatal: true }).decode(data);
2131
+ } catch {
2132
+ untransferred.push(reference);
2133
+ continue;
2134
+ }
2135
+ const key = spillKey(reference);
2136
+ payloads.set(key, {
2137
+ id: key,
2138
+ kind: "spill",
2139
+ reference,
2140
+ bytes: data.byteLength,
2141
+ digest: sha256(data),
2142
+ data: data.toString("base64")
2143
+ });
2144
+ } catch {
2145
+ untransferred.push(reference);
2146
+ }
2147
+ }
2148
+ for (const reference of untransferred) {
2149
+ const key = spillKey(reference);
2150
+ payloads.set(key, {
2151
+ id: key,
2152
+ kind: "spill",
2153
+ reference,
2154
+ bytes: 0,
2155
+ digest: sha256(Buffer.alloc(0)),
2156
+ data: "",
2157
+ unavailable: true
2158
+ });
2159
+ }
2160
+ }
2161
+ return [...payloads.values()];
2162
+ }
2163
+ requirePersistence() {
2164
+ const persistence = this.getPersistence();
2165
+ if (!persistence) throw new SyncSessionError("session-persistence-unavailable", "会话持久化服务不可用");
2166
+ return persistence;
2167
+ }
2168
+ serializeMutation(operation) {
2169
+ const result = this.operationTail.then(operation);
2170
+ this.operationTail = result.then(() => void 0, () => void 0);
2171
+ return result;
2172
+ }
2173
+ };
2174
+ //#endregion
2175
+ export { validateSyncSessionBundle as a, planSessionTransfers as i, SyncSessionService as n, makeSyncSessionBundle as r, SyncSessionError as t };
2176
+
2177
+ //# sourceMappingURL=session-sync-EdfwUON5.js.map