opencode-session-port 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +84 -2
- package/package.json +56 -4
- package/src/core/contracts/crypto.ts +66 -0
- package/src/core/contracts/index.ts +13 -0
- package/src/core/contracts/json.ts +142 -0
- package/src/core/contracts/session.ts +12 -0
- package/src/core/crypto/container.ts +686 -0
- package/src/core/crypto/envelope.ts +232 -0
- package/src/core/crypto/errors.ts +52 -0
- package/src/core/crypto/gzip-stream.ts +388 -0
- package/src/core/crypto/index.ts +13 -0
- package/src/core/crypto/kdf.ts +101 -0
- package/src/core/crypto/policy.ts +202 -0
- package/src/core/crypto/zero.ts +33 -0
- package/src/core/restore/identity.ts +202 -0
- package/src/core/restore/index.ts +24 -0
- package/src/core/restore/planner.ts +1122 -0
- package/src/core/sessions/collector.ts +323 -0
- package/src/core/sessions/errors.ts +37 -0
- package/src/core/sessions/guards.ts +11 -0
- package/src/core/sessions/index.ts +29 -0
- package/src/core/sessions/policy.ts +80 -0
- package/src/core/sessions/port.ts +88 -0
- package/src/core/sessions/serialize.ts +15 -0
- package/src/core/sessions/unsettled.ts +35 -0
- package/src/integrations/omo/index.ts +28 -0
- package/src/tui/adapters/host-client.ts +254 -0
- package/src/tui/adapters/index.ts +72 -0
- package/src/tui/adapters/location-resolver.ts +281 -0
- package/src/tui/adapters/project-destinations.ts +271 -0
- package/src/tui/adapters/session-id-inventory.ts +378 -0
- package/src/tui/adapters/session-import.ts +189 -0
- package/src/tui/adapters/session-read.ts +115 -0
- package/src/tui/artifacts/files.ts +285 -0
- package/src/tui/orchestration/checkpoint.ts +160 -0
- package/src/tui/orchestration/index.ts +55 -0
- package/src/tui/orchestration/restore-auth.ts +94 -0
- package/src/tui/orchestration/restore-execution.ts +381 -0
- package/src/tui/orchestration/restore-preflight.ts +223 -0
- package/src/tui/orchestration/shared.ts +75 -0
- package/src/tui/ui/password-dialog.tsx +251 -0
- package/src/tui/ui/password-flow.ts +288 -0
- package/src/tui/ui/restore-project-selector.ts +62 -0
- package/src/tui/ui/restore-report-dialog.tsx +251 -0
- package/src/tui/ui/restore-report-flow.ts +94 -0
- package/src/tui/ui/sessionport-commands.tsx +65 -0
- package/src/tui/wiring/composition.ts +354 -0
- package/src/tui/wiring/experimental-profile.ts +86 -0
- package/src/tui/wiring/index.ts +51 -0
- package/src/tui/wiring/install.ts +108 -0
- package/src/tui/wiring/reports.ts +340 -0
- package/src/tui.ts +34 -0
- package/tsconfig.json +18 -0
- package/tui.ts +12 -0
|
@@ -0,0 +1,1122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure restore preflight and planning (Phase 3; O-04/O-05/O-22).
|
|
3
|
+
*
|
|
4
|
+
* `planRestore` is a side-effect-free planning step: it snapshots and validates
|
|
5
|
+
* the checkpoint payload and the local destination snapshot, classifies every
|
|
6
|
+
* unique artifact session (`skipped` / `imported` / `renamed`), deterministically
|
|
7
|
+
* rederives every message ID of every session that will be imported, rewrites
|
|
8
|
+
* only the schema-defined references, detects ID collisions and dangling
|
|
9
|
+
* external references, and returns a parent-before-child import order. It never
|
|
10
|
+
* calls a host API, touches the filesystem, runs an LLM, or performs an import.
|
|
11
|
+
*
|
|
12
|
+
* Policy boundaries (see `docs/v1/decision-log.md` O-04/O-05/O-22):
|
|
13
|
+
* - Canonical comparison is the complete `{info,messages}` record serialized as
|
|
14
|
+
* JSON with object keys sorted recursively; values, array order, IDs, and
|
|
15
|
+
* absent-vs-null distinctions are preserved (O-04).
|
|
16
|
+
* - Every message ID of every session planned for import is rederived through
|
|
17
|
+
* the injected `RemappedIdDeriver` (owner clarification 2026-10-10, O-05),
|
|
18
|
+
* even when it does not currently collide with destination history. Only an
|
|
19
|
+
* exact source-record duplicate that is skipped keeps its message IDs.
|
|
20
|
+
* - A divergent local session under the same session ID derives a new session
|
|
21
|
+
* ID (`renamed`); new nonconflicting sessions keep their session ID. A
|
|
22
|
+
* planned ID that collides with different destination data fails preflight
|
|
23
|
+
* and is never regenerated.
|
|
24
|
+
* - Final target IDs are unique across all unique artifact entries, including
|
|
25
|
+
* exact-local and exact-planned-retry skips: two distinct source sessions can
|
|
26
|
+
* never coalesce onto one target session ID or one final target message ID,
|
|
27
|
+
* even when a deriver collision makes their transformed records identical.
|
|
28
|
+
* Destination-occupancy checks apply only to entries actually being imported;
|
|
29
|
+
* exact-local/retry records already exist in the destination by definition.
|
|
30
|
+
* - Exact-content retry detection only recognizes a destination record that is
|
|
31
|
+
* exactly the deterministic planned record at the planned session ID. Retry
|
|
32
|
+
* classification is iterated to a fixed point in deterministic session-ID
|
|
33
|
+
* order so the result does not depend on payload order. Broader
|
|
34
|
+
* partial-import retry/reporting semantics remain OPEN (O-06); this planner
|
|
35
|
+
* deliberately makes no claim about them.
|
|
36
|
+
* - Cross-project collision policy (owner-approved 2026-10-14 amendment): the
|
|
37
|
+
* optional `globallyVisibleSessionIDs` inventory carries session-ID
|
|
38
|
+
* occupancy outside the selected destination only — never records, messages,
|
|
39
|
+
* or retry evidence. An exact source-record duplicate in the destination is
|
|
40
|
+
* still skipped unchanged. Otherwise a source ID occupied in the destination
|
|
41
|
+
* or in the global inventory is renamed with the same deterministic session
|
|
42
|
+
* derivation, and all-message rederivation plus parent/fork/revert rewriting
|
|
43
|
+
* are unchanged. A derived target ID that is globally occupied but absent
|
|
44
|
+
* from the selected destination fails closed: it is never re-derived and
|
|
45
|
+
* never treated as a retry. The global inventory can never satisfy a
|
|
46
|
+
* reference, classify a duplicate, or classify a retry — those matches
|
|
47
|
+
* require the exact planned record in the selected destination. Destination
|
|
48
|
+
* IDs naturally overlap the global inventory, and that overlap is valid.
|
|
49
|
+
* Inventory shape/blank/duplicate violations fail closed as `invalid-input`.
|
|
50
|
+
* - The payload root must have exactly the top-level `sessions` key; any
|
|
51
|
+
* additional root key fails closed so unsupported payload data is never
|
|
52
|
+
* silently omitted. A record must have exactly the top-level `info` and
|
|
53
|
+
* `messages` keys; any additional top-level key fails closed. When present,
|
|
54
|
+
* `info.fork` must carry
|
|
55
|
+
* a string `sessionID`, an object `boundary`, a `boundary.type` that is
|
|
56
|
+
* exactly `before`/`through`, and a string `boundary.messageID`; `info.revert`
|
|
57
|
+
* must carry a string `messageID`. Present reference IDs must use their
|
|
58
|
+
* `ses`/`msg_` schema prefixes.
|
|
59
|
+
* - Only `info.id`, `info.parentID`, `info.fork.sessionID`, every message `id`,
|
|
60
|
+
* `info.fork.boundary.messageID`, and `info.revert.messageID` are rewritten.
|
|
61
|
+
* Snapshot IDs, `revert.partID`, tool/shell/skill/part IDs, and every other
|
|
62
|
+
* opaque field are preserved unchanged.
|
|
63
|
+
* - Full-preflight reference resolution (O-05) covers every artifact session.
|
|
64
|
+
* Exact-local skipped records are never rewritten: their unchanged session
|
|
65
|
+
* references must already exist in the destination or resolve to an artifact
|
|
66
|
+
* session that keeps its source ID, and their unchanged message references
|
|
67
|
+
* must already exist at their original IDs in the destination. Exact-retry
|
|
68
|
+
* records are the deterministic planned records built when the retry match
|
|
69
|
+
* was detected, so their references were resolved at that point.
|
|
70
|
+
*
|
|
71
|
+
* All inputs are snapshotted into detached JSON-only copies before validation,
|
|
72
|
+
* so caller-owned objects are never mutated and non-JSON values (undefined,
|
|
73
|
+
* functions, class instances, accessors, cycles, non-finite numbers, `-0`,
|
|
74
|
+
* sparse arrays) are rejected instead of being silently dropped or coerced.
|
|
75
|
+
* The caller's artifact identity is likewise copied on entry, and every
|
|
76
|
+
* `derive` call receives its own fresh copy, so a deriver can neither mutate
|
|
77
|
+
* caller-owned bytes nor influence later derivations through a shared buffer.
|
|
78
|
+
*/
|
|
79
|
+
|
|
80
|
+
import {
|
|
81
|
+
ARTIFACT_IDENTITY_BYTES,
|
|
82
|
+
snapshotJsonValue,
|
|
83
|
+
type ArtifactIdentity,
|
|
84
|
+
type CheckpointPayload,
|
|
85
|
+
type JsonObject,
|
|
86
|
+
type JsonValue,
|
|
87
|
+
type SessionRecord,
|
|
88
|
+
} from "../contracts/index.ts";
|
|
89
|
+
import type { RemappedIdDeriver } from "./identity.ts";
|
|
90
|
+
|
|
91
|
+
/* ------------------------------------------------------------------------- */
|
|
92
|
+
/* Public error taxonomy and plan shape */
|
|
93
|
+
/* ------------------------------------------------------------------------- */
|
|
94
|
+
|
|
95
|
+
/** Stable machine-readable failure codes for restore preflight/planning. */
|
|
96
|
+
export type RestorePlanErrorCode =
|
|
97
|
+
| "invalid-input"
|
|
98
|
+
| "malformed-payload"
|
|
99
|
+
| "malformed-destination"
|
|
100
|
+
| "divergent-artifact-session"
|
|
101
|
+
| "duplicate-artifact-message-id"
|
|
102
|
+
| "ambiguous-destination"
|
|
103
|
+
| "dangling-session-reference"
|
|
104
|
+
| "dangling-message-reference"
|
|
105
|
+
| "session-id-collision"
|
|
106
|
+
| "message-id-collision"
|
|
107
|
+
| "dependency-cycle"
|
|
108
|
+
| "derivation-failure";
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Fail-closed planning error. Messages carry IDs/labels only; they never echo
|
|
112
|
+
* an entire session record or message and are never truncated.
|
|
113
|
+
*/
|
|
114
|
+
export class RestorePlanError extends Error {
|
|
115
|
+
readonly code: RestorePlanErrorCode;
|
|
116
|
+
|
|
117
|
+
constructor(code: RestorePlanErrorCode, message: string, options?: ErrorOptions) {
|
|
118
|
+
super(message, options);
|
|
119
|
+
this.name = "RestorePlanError";
|
|
120
|
+
this.code = code;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Outcome for one unique artifact session. */
|
|
125
|
+
export type RestoreClassification = "skipped" | "imported" | "renamed";
|
|
126
|
+
|
|
127
|
+
/** Stable reasons for a `skipped` entry. */
|
|
128
|
+
export type RestoreSkipReason = "exact-local-duplicate" | "exact-planned-retry";
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* One unique artifact session, in source order. `record` is the final planned
|
|
132
|
+
* record: the unchanged source record for an exact local duplicate, the exact
|
|
133
|
+
* deterministic planned record for an exact retry, and the fully remapped
|
|
134
|
+
* record for imported/renamed sessions.
|
|
135
|
+
*/
|
|
136
|
+
export interface RestorePlanEntry {
|
|
137
|
+
readonly sourceSessionID: string;
|
|
138
|
+
readonly targetSessionID: string;
|
|
139
|
+
readonly classification: RestoreClassification;
|
|
140
|
+
readonly record: SessionRecord;
|
|
141
|
+
readonly skipReason?: RestoreSkipReason;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface RestorePlan {
|
|
145
|
+
/** One entry per unique artifact session (after identical-record collapse), in source order. */
|
|
146
|
+
readonly entries: readonly RestorePlanEntry[];
|
|
147
|
+
/** Only `imported`/`renamed` entries, in deterministic parent-before-child order. */
|
|
148
|
+
readonly importOrder: readonly RestorePlanEntry[];
|
|
149
|
+
/** Number of fully identical repeated artifact session records that were collapsed. */
|
|
150
|
+
readonly collapsedDuplicateCount: number;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export interface PlanRestoreInput {
|
|
154
|
+
readonly payload: CheckpointPayload;
|
|
155
|
+
readonly artifactIdentity: ArtifactIdentity;
|
|
156
|
+
/** Full local session snapshot used for classification and collision checks. */
|
|
157
|
+
readonly destination: readonly SessionRecord[];
|
|
158
|
+
/**
|
|
159
|
+
* Optional host-visible session-ID inventory across projects, including the
|
|
160
|
+
* selected destination (occupancy only; never records or messages). Absent
|
|
161
|
+
* means an empty inventory, preserving isolated core callers. Entries must
|
|
162
|
+
* be unique non-blank strings; violations fail closed as `invalid-input`.
|
|
163
|
+
*/
|
|
164
|
+
readonly globallyVisibleSessionIDs?: readonly string[];
|
|
165
|
+
readonly idDeriver: RemappedIdDeriver;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/* ------------------------------------------------------------------------- */
|
|
169
|
+
/* Internal helpers */
|
|
170
|
+
/* ------------------------------------------------------------------------- */
|
|
171
|
+
|
|
172
|
+
interface UniqueSession {
|
|
173
|
+
/** Position among unique sessions (source order); used for stable tie-breaking. */
|
|
174
|
+
readonly sourceIndex: number;
|
|
175
|
+
/** Position in `payload.sessions`; used only in error labels. */
|
|
176
|
+
readonly payloadIndex: number;
|
|
177
|
+
readonly sourceSessionID: string;
|
|
178
|
+
readonly record: SessionRecord;
|
|
179
|
+
readonly canonical: string;
|
|
180
|
+
readonly messageIDs: readonly string[];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
interface SessionPlan {
|
|
184
|
+
readonly session: UniqueSession;
|
|
185
|
+
classification: RestoreClassification;
|
|
186
|
+
targetSessionID: string;
|
|
187
|
+
skipReason: RestoreSkipReason | undefined;
|
|
188
|
+
record: SessionRecord;
|
|
189
|
+
messageTargets: string[];
|
|
190
|
+
planned: boolean;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
interface DestinationSession {
|
|
194
|
+
readonly record: SessionRecord;
|
|
195
|
+
readonly canonical: string;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function isObjectValue(value: unknown): value is { readonly [key: string]: unknown } {
|
|
199
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function isJsonRecordValue(value: unknown): value is { readonly [key: string]: JsonValue } {
|
|
203
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function describeError(value: unknown): string {
|
|
207
|
+
return value instanceof Error ? value.message : String(value);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Schema reference prefixes: session references use `ses`, message references `msg_`. */
|
|
211
|
+
const SESSION_REFERENCE_PREFIX = "ses";
|
|
212
|
+
const MESSAGE_REFERENCE_PREFIX = "msg_";
|
|
213
|
+
|
|
214
|
+
/** Exact selected target-ID shapes (O-22): lowercase hex digests only. */
|
|
215
|
+
const TARGET_ID_PATTERNS: Readonly<Record<"session" | "message", RegExp>> = Object.freeze({
|
|
216
|
+
session: /^ses_[0-9a-f]{64}$/,
|
|
217
|
+
message: /^msg_[0-9a-f]{64}$/,
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Canonical JSON string: object keys sorted recursively at every level; array
|
|
222
|
+
* order, primitive values, IDs, and absent-vs-null are preserved exactly.
|
|
223
|
+
* Iterative (explicit stack) so deep payloads cannot overflow the call stack.
|
|
224
|
+
*/
|
|
225
|
+
function canonicalJson(root: JsonValue): string {
|
|
226
|
+
type WorkItem = { readonly kind: "value"; readonly value: JsonValue } | { readonly kind: "text"; readonly text: string };
|
|
227
|
+
const parts: string[] = [];
|
|
228
|
+
const stack: WorkItem[] = [{ kind: "value", value: root }];
|
|
229
|
+
|
|
230
|
+
while (stack.length > 0) {
|
|
231
|
+
const item = stack.pop()!;
|
|
232
|
+
if (item.kind === "text") {
|
|
233
|
+
parts.push(item.text);
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const value = item.value;
|
|
238
|
+
if (value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
|
239
|
+
const encoded = JSON.stringify(value);
|
|
240
|
+
if (encoded === undefined) {
|
|
241
|
+
throw new RestorePlanError("malformed-payload", "internal canonicalization failure: a JSON primitive could not be serialized");
|
|
242
|
+
}
|
|
243
|
+
parts.push(encoded);
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (Array.isArray(value)) {
|
|
248
|
+
stack.push({ kind: "text", text: "]" });
|
|
249
|
+
for (let index = value.length - 1; index >= 0; index--) {
|
|
250
|
+
stack.push({ kind: "value", value: value[index]! });
|
|
251
|
+
if (index > 0) stack.push({ kind: "text", text: "," });
|
|
252
|
+
}
|
|
253
|
+
stack.push({ kind: "text", text: "[" });
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const objectValue = value as { readonly [key: string]: JsonValue };
|
|
258
|
+
const keys = Object.keys(objectValue).sort();
|
|
259
|
+
stack.push({ kind: "text", text: "}" });
|
|
260
|
+
for (let index = keys.length - 1; index >= 0; index--) {
|
|
261
|
+
const key = keys[index]!;
|
|
262
|
+
stack.push({ kind: "value", value: objectValue[key]! });
|
|
263
|
+
stack.push({ kind: "text", text: `${JSON.stringify(key)}:` });
|
|
264
|
+
if (index > 0) stack.push({ kind: "text", text: "," });
|
|
265
|
+
}
|
|
266
|
+
stack.push({ kind: "text", text: "{" });
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return parts.join("");
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Detached JSON-only snapshot; non-JSON values reject the plan instead of being dropped. */
|
|
273
|
+
function snapshotValue(value: unknown, code: RestorePlanErrorCode, label: string): JsonValue {
|
|
274
|
+
try {
|
|
275
|
+
return snapshotJsonValue(value, label);
|
|
276
|
+
} catch (cause) {
|
|
277
|
+
throw new RestorePlanError(code, `${label} is not a JSON-stable value: ${describeError(cause)}`, { cause });
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function validateSessionRecords(
|
|
282
|
+
values: readonly JsonValue[],
|
|
283
|
+
code: RestorePlanErrorCode,
|
|
284
|
+
label: string,
|
|
285
|
+
validateReferences: boolean,
|
|
286
|
+
): SessionRecord[] {
|
|
287
|
+
const records: SessionRecord[] = [];
|
|
288
|
+
for (let index = 0; index < values.length; index++) {
|
|
289
|
+
records.push(validateSessionRecord(values[index], code, `${label}[${index}]`, validateReferences));
|
|
290
|
+
}
|
|
291
|
+
return records;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function validateSessionRecord(
|
|
295
|
+
value: JsonValue | undefined,
|
|
296
|
+
code: RestorePlanErrorCode,
|
|
297
|
+
label: string,
|
|
298
|
+
validateReferences: boolean,
|
|
299
|
+
): SessionRecord {
|
|
300
|
+
if (!isJsonRecordValue(value)) {
|
|
301
|
+
throw new RestorePlanError(code, `${label} must be an object with info and messages`);
|
|
302
|
+
}
|
|
303
|
+
for (const key of Object.keys(value)) {
|
|
304
|
+
if (key !== "info" && key !== "messages") {
|
|
305
|
+
throw new RestorePlanError(
|
|
306
|
+
code,
|
|
307
|
+
`${label} has unsupported top-level key ${JSON.stringify(key)}; expected exactly "info" and "messages"`,
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
const info = value["info"];
|
|
312
|
+
if (!isJsonRecordValue(info)) {
|
|
313
|
+
throw new RestorePlanError(code, `${label}.info must be an object`);
|
|
314
|
+
}
|
|
315
|
+
const id = info["id"];
|
|
316
|
+
if (typeof id !== "string" || !id.startsWith("ses")) {
|
|
317
|
+
throw new RestorePlanError(code, `${label}.info.id must be a string starting with "ses"`);
|
|
318
|
+
}
|
|
319
|
+
const rawMessages = value["messages"];
|
|
320
|
+
if (!Array.isArray(rawMessages)) {
|
|
321
|
+
throw new RestorePlanError(code, `${label}.messages must be an array`);
|
|
322
|
+
}
|
|
323
|
+
const messages: JsonObject[] = [];
|
|
324
|
+
for (let index = 0; index < rawMessages.length; index++) {
|
|
325
|
+
const rawMessage = rawMessages[index];
|
|
326
|
+
if (!isJsonRecordValue(rawMessage)) {
|
|
327
|
+
throw new RestorePlanError(code, `${label}.messages[${index}] must be an object`);
|
|
328
|
+
}
|
|
329
|
+
const messageID = rawMessage["id"];
|
|
330
|
+
if (typeof messageID !== "string" || !messageID.startsWith("msg_")) {
|
|
331
|
+
throw new RestorePlanError(code, `${label}.messages[${index}].id must be a string starting with "msg_"`);
|
|
332
|
+
}
|
|
333
|
+
messages.push(rawMessage);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const record: SessionRecord = { info, messages };
|
|
337
|
+
if (validateReferences) validateSchemaReferences(record, code, label);
|
|
338
|
+
return record;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Validates the schema-defined reference fields and their required nested
|
|
343
|
+
* shape. `info.parentID` is optional when absent. When `info.fork` is present
|
|
344
|
+
* it must carry a string `sessionID`, an object `boundary`, a `boundary.type`
|
|
345
|
+
* that is exactly `before` or `through`, and a string `boundary.messageID`;
|
|
346
|
+
* when `info.revert` is present it must carry a string `messageID`. Present
|
|
347
|
+
* reference IDs must use their `ses`/`msg_` schema prefixes, and a present
|
|
348
|
+
* value that does not match fails closed instead of being treated as absent.
|
|
349
|
+
*/
|
|
350
|
+
function validateSchemaReferences(record: SessionRecord, code: RestorePlanErrorCode, label: string): void {
|
|
351
|
+
const info = record.info;
|
|
352
|
+
validateOptionalReferenceString(info, "parentID", code, `${label}.info`, SESSION_REFERENCE_PREFIX);
|
|
353
|
+
|
|
354
|
+
const fork = info["fork"];
|
|
355
|
+
if (fork !== undefined) {
|
|
356
|
+
if (!isJsonRecordValue(fork)) {
|
|
357
|
+
throw new RestorePlanError(code, `${label}.info.fork must be an object when present`);
|
|
358
|
+
}
|
|
359
|
+
validateRequiredReferenceString(fork, "sessionID", code, `${label}.info.fork`, SESSION_REFERENCE_PREFIX);
|
|
360
|
+
const boundary = fork["boundary"];
|
|
361
|
+
if (!isJsonRecordValue(boundary)) {
|
|
362
|
+
throw new RestorePlanError(code, `${label}.info.fork.boundary must be an object`);
|
|
363
|
+
}
|
|
364
|
+
const boundaryType = boundary["type"];
|
|
365
|
+
if (boundaryType !== "before" && boundaryType !== "through") {
|
|
366
|
+
throw new RestorePlanError(code, `${label}.info.fork.boundary.type must be exactly "before" or "through"`);
|
|
367
|
+
}
|
|
368
|
+
validateRequiredReferenceString(boundary, "messageID", code, `${label}.info.fork.boundary`, MESSAGE_REFERENCE_PREFIX);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const revert = info["revert"];
|
|
372
|
+
if (revert !== undefined) {
|
|
373
|
+
if (!isJsonRecordValue(revert)) {
|
|
374
|
+
throw new RestorePlanError(code, `${label}.info.revert must be an object when present`);
|
|
375
|
+
}
|
|
376
|
+
validateRequiredReferenceString(revert, "messageID", code, `${label}.info.revert`, MESSAGE_REFERENCE_PREFIX);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function validateOptionalReferenceString(
|
|
381
|
+
container: JsonObject,
|
|
382
|
+
key: string,
|
|
383
|
+
code: RestorePlanErrorCode,
|
|
384
|
+
label: string,
|
|
385
|
+
prefix: string,
|
|
386
|
+
): void {
|
|
387
|
+
const value: JsonValue | undefined = container[key];
|
|
388
|
+
if (value === undefined) return;
|
|
389
|
+
if (typeof value !== "string" || !value.startsWith(prefix)) {
|
|
390
|
+
throw new RestorePlanError(code, `${label}.${key} must be a string starting with ${JSON.stringify(prefix)} when present`);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function validateRequiredReferenceString(
|
|
395
|
+
container: JsonObject,
|
|
396
|
+
key: string,
|
|
397
|
+
code: RestorePlanErrorCode,
|
|
398
|
+
label: string,
|
|
399
|
+
prefix: string,
|
|
400
|
+
): void {
|
|
401
|
+
const value: JsonValue | undefined = container[key];
|
|
402
|
+
if (typeof value !== "string" || !value.startsWith(prefix)) {
|
|
403
|
+
throw new RestorePlanError(code, `${label}.${key} must be a string starting with ${JSON.stringify(prefix)}`);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function cloneJsonObject(source: JsonObject): { [key: string]: JsonValue } {
|
|
408
|
+
const copy = Object.create(null) as { [key: string]: JsonValue };
|
|
409
|
+
for (const key of Object.keys(source)) {
|
|
410
|
+
copy[key] = source[key]!;
|
|
411
|
+
}
|
|
412
|
+
return copy;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function resolveSessionReference(
|
|
416
|
+
original: string,
|
|
417
|
+
sourceSessionID: string,
|
|
418
|
+
targetSessionID: string,
|
|
419
|
+
sessionTargets: ReadonlyMap<string, string>,
|
|
420
|
+
destinationSessionIDs: ReadonlySet<string>,
|
|
421
|
+
label: string,
|
|
422
|
+
): string {
|
|
423
|
+
if (original === sourceSessionID) return targetSessionID;
|
|
424
|
+
const internal = sessionTargets.get(original);
|
|
425
|
+
if (internal !== undefined) return internal;
|
|
426
|
+
if (destinationSessionIDs.has(original)) return original;
|
|
427
|
+
throw new RestorePlanError(
|
|
428
|
+
"dangling-session-reference",
|
|
429
|
+
`${label} references session ${JSON.stringify(original)}, which is neither part of this artifact nor present in the destination`,
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function resolveMessageReference(
|
|
434
|
+
original: string,
|
|
435
|
+
messageTargets: ReadonlyMap<string, string>,
|
|
436
|
+
destinationMessageIDs: ReadonlySet<string>,
|
|
437
|
+
label: string,
|
|
438
|
+
): string {
|
|
439
|
+
const internal = messageTargets.get(original);
|
|
440
|
+
if (internal !== undefined) return internal;
|
|
441
|
+
if (destinationMessageIDs.has(original)) return original;
|
|
442
|
+
throw new RestorePlanError(
|
|
443
|
+
"dangling-message-reference",
|
|
444
|
+
`${label} references message ${JSON.stringify(original)}, which is neither part of this artifact nor present in the destination`,
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Builds the planned record: `info.id` set to the target session ID, every
|
|
450
|
+
* message `id` set to its planned target, and only the schema-defined
|
|
451
|
+
* references rewritten. All other fields are preserved as detached copies.
|
|
452
|
+
*/
|
|
453
|
+
function buildPlannedRecord(
|
|
454
|
+
session: UniqueSession,
|
|
455
|
+
targetSessionID: string,
|
|
456
|
+
plannedMessageTargets: readonly string[],
|
|
457
|
+
sessionTargets: ReadonlyMap<string, string>,
|
|
458
|
+
messageTargets: ReadonlyMap<string, string>,
|
|
459
|
+
destinationSessionIDs: ReadonlySet<string>,
|
|
460
|
+
destinationMessageIDs: ReadonlySet<string>,
|
|
461
|
+
): SessionRecord {
|
|
462
|
+
const label = `payload.sessions[${session.payloadIndex}]`;
|
|
463
|
+
const info = session.record.info;
|
|
464
|
+
const nextInfo = cloneJsonObject(info);
|
|
465
|
+
nextInfo["id"] = targetSessionID;
|
|
466
|
+
|
|
467
|
+
const parentID = info["parentID"] as string | undefined;
|
|
468
|
+
if (parentID !== undefined) {
|
|
469
|
+
nextInfo["parentID"] = resolveSessionReference(
|
|
470
|
+
parentID,
|
|
471
|
+
session.sourceSessionID,
|
|
472
|
+
targetSessionID,
|
|
473
|
+
sessionTargets,
|
|
474
|
+
destinationSessionIDs,
|
|
475
|
+
`${label}.info.parentID`,
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const fork = info["fork"] as JsonObject | undefined;
|
|
480
|
+
if (fork !== undefined) {
|
|
481
|
+
const nextFork = cloneJsonObject(fork);
|
|
482
|
+
const forkSessionID = fork["sessionID"] as string | undefined;
|
|
483
|
+
if (forkSessionID !== undefined) {
|
|
484
|
+
nextFork["sessionID"] = resolveSessionReference(
|
|
485
|
+
forkSessionID,
|
|
486
|
+
session.sourceSessionID,
|
|
487
|
+
targetSessionID,
|
|
488
|
+
sessionTargets,
|
|
489
|
+
destinationSessionIDs,
|
|
490
|
+
`${label}.info.fork.sessionID`,
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
const boundary = fork["boundary"] as JsonObject | undefined;
|
|
494
|
+
if (boundary !== undefined) {
|
|
495
|
+
const nextBoundary = cloneJsonObject(boundary);
|
|
496
|
+
const boundaryMessageID = boundary["messageID"] as string | undefined;
|
|
497
|
+
if (boundaryMessageID !== undefined) {
|
|
498
|
+
nextBoundary["messageID"] = resolveMessageReference(
|
|
499
|
+
boundaryMessageID,
|
|
500
|
+
messageTargets,
|
|
501
|
+
destinationMessageIDs,
|
|
502
|
+
`${label}.info.fork.boundary.messageID`,
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
nextFork["boundary"] = nextBoundary;
|
|
506
|
+
}
|
|
507
|
+
nextInfo["fork"] = nextFork;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const revert = info["revert"] as JsonObject | undefined;
|
|
511
|
+
if (revert !== undefined) {
|
|
512
|
+
const nextRevert = cloneJsonObject(revert);
|
|
513
|
+
const revertMessageID = revert["messageID"] as string | undefined;
|
|
514
|
+
if (revertMessageID !== undefined) {
|
|
515
|
+
nextRevert["messageID"] = resolveMessageReference(
|
|
516
|
+
revertMessageID,
|
|
517
|
+
messageTargets,
|
|
518
|
+
destinationMessageIDs,
|
|
519
|
+
`${label}.info.revert.messageID`,
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
nextInfo["revert"] = nextRevert;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const messages: JsonObject[] = [];
|
|
526
|
+
for (let index = 0; index < session.record.messages.length; index++) {
|
|
527
|
+
const nextMessage = cloneJsonObject(session.record.messages[index]!);
|
|
528
|
+
nextMessage["id"] = plannedMessageTargets[index]!;
|
|
529
|
+
messages.push(nextMessage);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
return { info: nextInfo, messages };
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Full-preflight reference resolution for an unchanged skipped record (an
|
|
537
|
+
* exact local duplicate). The record is never rewritten, so every unchanged
|
|
538
|
+
* reference must still resolve after the restore:
|
|
539
|
+
* - a session reference is acceptable when the referenced session already
|
|
540
|
+
* exists in the destination, or when it references an artifact session that
|
|
541
|
+
* this plan keeps at its source ID (imported/skipped targets);
|
|
542
|
+
* - a message reference is acceptable only when the referenced message ID
|
|
543
|
+
* already exists in the destination, because every imported message ID is
|
|
544
|
+
* rederived and the skipped record cannot be rewritten to a remapped ID.
|
|
545
|
+
*/
|
|
546
|
+
function validateSkippedRecordReferences(
|
|
547
|
+
plan: SessionPlan,
|
|
548
|
+
planBySessionID: ReadonlyMap<string, SessionPlan>,
|
|
549
|
+
destinationSessionIDs: ReadonlySet<string>,
|
|
550
|
+
destinationMessageIDs: ReadonlySet<string>,
|
|
551
|
+
): void {
|
|
552
|
+
const label = `payload.sessions[${plan.session.payloadIndex}]`;
|
|
553
|
+
|
|
554
|
+
const requireUnchangedSessionReference = (original: string, context: string): void => {
|
|
555
|
+
if (destinationSessionIDs.has(original)) return;
|
|
556
|
+
const referenced = planBySessionID.get(original);
|
|
557
|
+
if (referenced !== undefined && referenced.targetSessionID === original) return;
|
|
558
|
+
throw new RestorePlanError(
|
|
559
|
+
"dangling-session-reference",
|
|
560
|
+
`${context} references session ${JSON.stringify(original)}, which is neither present in the destination nor kept at the same id by this plan; an unchanged skipped session cannot be rewritten`,
|
|
561
|
+
);
|
|
562
|
+
};
|
|
563
|
+
const requireUnchangedMessageReference = (original: string, context: string): void => {
|
|
564
|
+
if (destinationMessageIDs.has(original)) return;
|
|
565
|
+
throw new RestorePlanError(
|
|
566
|
+
"dangling-message-reference",
|
|
567
|
+
`${context} references message ${JSON.stringify(original)}, which is not present in the destination; an unchanged skipped session cannot be rewritten to a remapped message id`,
|
|
568
|
+
);
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
const info = plan.session.record.info;
|
|
572
|
+
const parentID = info["parentID"] as string | undefined;
|
|
573
|
+
if (parentID !== undefined) requireUnchangedSessionReference(parentID, `${label}.info.parentID`);
|
|
574
|
+
|
|
575
|
+
const fork = info["fork"] as JsonObject | undefined;
|
|
576
|
+
if (fork !== undefined) {
|
|
577
|
+
requireUnchangedSessionReference(fork["sessionID"] as string, `${label}.info.fork.sessionID`);
|
|
578
|
+
const boundary = fork["boundary"] as JsonObject;
|
|
579
|
+
requireUnchangedMessageReference(boundary["messageID"] as string, `${label}.info.fork.boundary.messageID`);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const revert = info["revert"] as JsonObject | undefined;
|
|
583
|
+
if (revert !== undefined) {
|
|
584
|
+
requireUnchangedMessageReference(revert["messageID"] as string, `${label}.info.revert.messageID`);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/* ------------------------------------------------------------------------- */
|
|
589
|
+
/* planRestore */
|
|
590
|
+
/* ------------------------------------------------------------------------- */
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Plans a restore from one authenticated checkpoint payload against the full
|
|
594
|
+
* local destination snapshot. Pure and deterministic for identical inputs; it
|
|
595
|
+
* performs no host call, file IO, or import. See the module comment for the
|
|
596
|
+
* exact policy boundaries.
|
|
597
|
+
*/
|
|
598
|
+
export function planRestore(input: PlanRestoreInput): RestorePlan {
|
|
599
|
+
if (!isObjectValue(input)) {
|
|
600
|
+
throw new RestorePlanError(
|
|
601
|
+
"invalid-input",
|
|
602
|
+
"planRestore requires an input object with payload, artifactIdentity, destination, and idDeriver",
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const rawDeriver: unknown = input.idDeriver;
|
|
607
|
+
if (!isObjectValue(rawDeriver) || typeof rawDeriver["derive"] !== "function") {
|
|
608
|
+
throw new RestorePlanError(
|
|
609
|
+
"invalid-input",
|
|
610
|
+
"planRestore requires an idDeriver with a derive(namespace, artifactIdentity, originalID) function",
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
const idDeriver = rawDeriver as unknown as RemappedIdDeriver;
|
|
614
|
+
|
|
615
|
+
const rawIdentity: unknown = input.artifactIdentity;
|
|
616
|
+
if (!(rawIdentity instanceof Uint8Array) || rawIdentity.length !== ARTIFACT_IDENTITY_BYTES) {
|
|
617
|
+
throw new RestorePlanError(
|
|
618
|
+
"invalid-input",
|
|
619
|
+
`planRestore requires an artifactIdentity Uint8Array of exactly ${ARTIFACT_IDENTITY_BYTES} bytes`,
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
const artifactIdentity = Uint8Array.from(rawIdentity);
|
|
623
|
+
|
|
624
|
+
const rawDestination: unknown = input.destination;
|
|
625
|
+
if (!Array.isArray(rawDestination)) {
|
|
626
|
+
throw new RestorePlanError("invalid-input", "planRestore requires destination to be an array of local session records");
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/* -- Global occupancy inventory (2026-10-14 owner amendment): session IDs
|
|
630
|
+
* visible outside the selected destination project, occupancy only. It is
|
|
631
|
+
* snapshotted into a set immediately, must be a duplicate-free list of
|
|
632
|
+
* non-blank string IDs, and is never used to satisfy references, classify
|
|
633
|
+
* duplicates, or classify retries. -- */
|
|
634
|
+
const rawGlobalInventory: unknown = input.globallyVisibleSessionIDs;
|
|
635
|
+
const globalSessionIDs = new Set<string>();
|
|
636
|
+
if (rawGlobalInventory !== undefined) {
|
|
637
|
+
if (!Array.isArray(rawGlobalInventory)) {
|
|
638
|
+
throw new RestorePlanError(
|
|
639
|
+
"invalid-input",
|
|
640
|
+
"planRestore requires globallyVisibleSessionIDs to be an array of session ids when provided",
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
for (let index = 0; index < rawGlobalInventory.length; index++) {
|
|
644
|
+
const sessionID: unknown = rawGlobalInventory[index];
|
|
645
|
+
if (typeof sessionID !== "string" || sessionID.trim().length === 0) {
|
|
646
|
+
throw new RestorePlanError(
|
|
647
|
+
"invalid-input",
|
|
648
|
+
`globallyVisibleSessionIDs[${index}] must be a non-blank string session id`,
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
if (globalSessionIDs.has(sessionID)) {
|
|
652
|
+
throw new RestorePlanError(
|
|
653
|
+
"invalid-input",
|
|
654
|
+
`globallyVisibleSessionIDs repeats session id ${JSON.stringify(sessionID)}; refusing to plan against an ambiguous global inventory`,
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
globalSessionIDs.add(sessionID);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/* -- 1. Snapshot/validate the artifact payload (fail closed, no mutation). -- */
|
|
662
|
+
const payloadSnapshot = snapshotValue(input.payload, "malformed-payload", "payload");
|
|
663
|
+
if (!isJsonRecordValue(payloadSnapshot)) {
|
|
664
|
+
throw new RestorePlanError("malformed-payload", "payload must be a JSON object with a sessions array");
|
|
665
|
+
}
|
|
666
|
+
const sessionsValue = payloadSnapshot["sessions"];
|
|
667
|
+
if (!Array.isArray(sessionsValue)) {
|
|
668
|
+
throw new RestorePlanError("malformed-payload", "payload.sessions must be an array of session records");
|
|
669
|
+
}
|
|
670
|
+
for (const key of Object.keys(payloadSnapshot)) {
|
|
671
|
+
if (key !== "sessions") {
|
|
672
|
+
throw new RestorePlanError(
|
|
673
|
+
"malformed-payload",
|
|
674
|
+
`payload has unsupported top-level key ${JSON.stringify(key)}; expected exactly "sessions"`,
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
const sourceRecords = validateSessionRecords(sessionsValue, "malformed-payload", "payload.sessions", true);
|
|
679
|
+
|
|
680
|
+
/* -- 2. Collapse identical repeated artifact records; reject divergence and message-ID reuse. -- */
|
|
681
|
+
const uniqueSessions: UniqueSession[] = [];
|
|
682
|
+
const artifactBySessionID = new Map<string, { canonical: string; uniqueIndex: number }>();
|
|
683
|
+
let collapsedDuplicateCount = 0;
|
|
684
|
+
|
|
685
|
+
for (let index = 0; index < sourceRecords.length; index++) {
|
|
686
|
+
const record = sourceRecords[index]!;
|
|
687
|
+
const sourceSessionID = record.info["id"] as string;
|
|
688
|
+
const canonical = canonicalJson(record as unknown as JsonValue);
|
|
689
|
+
const existing = artifactBySessionID.get(sourceSessionID);
|
|
690
|
+
if (existing !== undefined) {
|
|
691
|
+
if (existing.canonical === canonical) {
|
|
692
|
+
collapsedDuplicateCount += 1;
|
|
693
|
+
continue;
|
|
694
|
+
}
|
|
695
|
+
throw new RestorePlanError(
|
|
696
|
+
"divergent-artifact-session",
|
|
697
|
+
`payload.sessions[${index}] repeats session id ${JSON.stringify(sourceSessionID)} with different content`,
|
|
698
|
+
);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const messageIDs: string[] = [];
|
|
702
|
+
for (let messageIndex = 0; messageIndex < record.messages.length; messageIndex++) {
|
|
703
|
+
messageIDs.push(record.messages[messageIndex]!["id"] as string);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
artifactBySessionID.set(sourceSessionID, { canonical, uniqueIndex: uniqueSessions.length });
|
|
707
|
+
uniqueSessions.push({
|
|
708
|
+
sourceIndex: uniqueSessions.length,
|
|
709
|
+
payloadIndex: index,
|
|
710
|
+
sourceSessionID,
|
|
711
|
+
record,
|
|
712
|
+
canonical,
|
|
713
|
+
messageIDs,
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
const artifactMessageOwner = new Map<string, number>();
|
|
718
|
+
for (const session of uniqueSessions) {
|
|
719
|
+
for (const messageID of session.messageIDs) {
|
|
720
|
+
if (artifactMessageOwner.has(messageID)) {
|
|
721
|
+
throw new RestorePlanError(
|
|
722
|
+
"duplicate-artifact-message-id",
|
|
723
|
+
`message id ${JSON.stringify(messageID)} appears more than once among the unique artifact session records`,
|
|
724
|
+
);
|
|
725
|
+
}
|
|
726
|
+
artifactMessageOwner.set(messageID, session.sourceIndex);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/* -- 3. Snapshot/validate the destination; reject ambiguous duplicates. -- */
|
|
731
|
+
const destinationSnapshot = snapshotValue(rawDestination, "malformed-destination", "destination");
|
|
732
|
+
if (!Array.isArray(destinationSnapshot)) {
|
|
733
|
+
throw new RestorePlanError("malformed-destination", "destination must be an array of session records");
|
|
734
|
+
}
|
|
735
|
+
const destinationRecords = validateSessionRecords(destinationSnapshot, "malformed-destination", "destination", false);
|
|
736
|
+
|
|
737
|
+
const destinationBySessionID = new Map<string, DestinationSession>();
|
|
738
|
+
const destinationSessionIDs = new Set<string>();
|
|
739
|
+
const destinationMessageIDs = new Set<string>();
|
|
740
|
+
for (let index = 0; index < destinationRecords.length; index++) {
|
|
741
|
+
const record = destinationRecords[index]!;
|
|
742
|
+
const sessionID = record.info["id"] as string;
|
|
743
|
+
if (destinationBySessionID.has(sessionID)) {
|
|
744
|
+
throw new RestorePlanError(
|
|
745
|
+
"ambiguous-destination",
|
|
746
|
+
`destination repeats session id ${JSON.stringify(sessionID)}; refusing to classify against an ambiguous local snapshot`,
|
|
747
|
+
);
|
|
748
|
+
}
|
|
749
|
+
destinationBySessionID.set(sessionID, { record, canonical: canonicalJson(record as unknown as JsonValue) });
|
|
750
|
+
destinationSessionIDs.add(sessionID);
|
|
751
|
+
for (let messageIndex = 0; messageIndex < record.messages.length; messageIndex++) {
|
|
752
|
+
const messageID = record.messages[messageIndex]!["id"] as string;
|
|
753
|
+
if (destinationMessageIDs.has(messageID)) {
|
|
754
|
+
throw new RestorePlanError(
|
|
755
|
+
"ambiguous-destination",
|
|
756
|
+
`destination repeats message id ${JSON.stringify(messageID)}; refusing to classify against an ambiguous local snapshot`,
|
|
757
|
+
);
|
|
758
|
+
}
|
|
759
|
+
destinationMessageIDs.add(messageID);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/* -- Derivation helper: deterministic, fail closed, never regenerates.
|
|
764
|
+
* Each call gets a fresh copy of the identity so a mutating deriver cannot
|
|
765
|
+
* influence later derivations or reach caller-owned bytes. -- */
|
|
766
|
+
const deriveTargetID = (namespace: "session" | "message", originalID: string, context: string): string => {
|
|
767
|
+
let derived: unknown;
|
|
768
|
+
try {
|
|
769
|
+
derived = idDeriver.derive(namespace, Uint8Array.from(artifactIdentity), originalID);
|
|
770
|
+
} catch (cause) {
|
|
771
|
+
throw new RestorePlanError(
|
|
772
|
+
"derivation-failure",
|
|
773
|
+
`remapped ${namespace} ID derivation failed for ${context}: ${describeError(cause)}`,
|
|
774
|
+
{ cause },
|
|
775
|
+
);
|
|
776
|
+
}
|
|
777
|
+
const pattern = TARGET_ID_PATTERNS[namespace];
|
|
778
|
+
if (typeof derived !== "string" || !pattern.test(derived)) {
|
|
779
|
+
throw new RestorePlanError(
|
|
780
|
+
"derivation-failure",
|
|
781
|
+
`remapped ${namespace} ID derivation for ${context} must return a string matching ${pattern.source}`,
|
|
782
|
+
);
|
|
783
|
+
}
|
|
784
|
+
return derived;
|
|
785
|
+
};
|
|
786
|
+
|
|
787
|
+
/* -- 4. Classify each unique session; derive every message ID for imports. -- */
|
|
788
|
+
const plans: SessionPlan[] = [];
|
|
789
|
+
for (const session of uniqueSessions) {
|
|
790
|
+
const destinationMatch = destinationBySessionID.get(session.sourceSessionID);
|
|
791
|
+
if (destinationMatch !== undefined && destinationMatch.canonical === session.canonical) {
|
|
792
|
+
plans.push({
|
|
793
|
+
session,
|
|
794
|
+
classification: "skipped",
|
|
795
|
+
targetSessionID: session.sourceSessionID,
|
|
796
|
+
skipReason: "exact-local-duplicate",
|
|
797
|
+
record: session.record,
|
|
798
|
+
messageTargets: [...session.messageIDs],
|
|
799
|
+
planned: false,
|
|
800
|
+
});
|
|
801
|
+
continue;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/* Occupied in the selected destination or visible outside it: derive a
|
|
805
|
+
* new session ID with the existing deterministic derivation. */
|
|
806
|
+
const conflict = destinationMatch !== undefined || globalSessionIDs.has(session.sourceSessionID);
|
|
807
|
+
plans.push({
|
|
808
|
+
session,
|
|
809
|
+
classification: conflict ? "renamed" : "imported",
|
|
810
|
+
targetSessionID: conflict
|
|
811
|
+
? deriveTargetID("session", session.sourceSessionID, `session ${JSON.stringify(session.sourceSessionID)}`)
|
|
812
|
+
: session.sourceSessionID,
|
|
813
|
+
skipReason: undefined,
|
|
814
|
+
record: session.record,
|
|
815
|
+
messageTargets: [],
|
|
816
|
+
planned: true,
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
for (const plan of plans) {
|
|
821
|
+
if (!plan.planned) continue;
|
|
822
|
+
const targets: string[] = [];
|
|
823
|
+
for (const messageID of plan.session.messageIDs) {
|
|
824
|
+
targets.push(
|
|
825
|
+
deriveTargetID(
|
|
826
|
+
"message",
|
|
827
|
+
messageID,
|
|
828
|
+
`message ${JSON.stringify(messageID)} in session ${JSON.stringify(plan.session.sourceSessionID)}`,
|
|
829
|
+
),
|
|
830
|
+
);
|
|
831
|
+
}
|
|
832
|
+
plan.messageTargets = targets;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
const sessionTargets = new Map<string, string>();
|
|
836
|
+
const messageTargets = new Map<string, string>();
|
|
837
|
+
for (const plan of plans) {
|
|
838
|
+
sessionTargets.set(plan.session.sourceSessionID, plan.targetSessionID);
|
|
839
|
+
for (let index = 0; index < plan.session.messageIDs.length; index++) {
|
|
840
|
+
messageTargets.set(plan.session.messageIDs[index]!, plan.messageTargets[index]!);
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/* -- 5. Exact deterministic retry detection, iterated to a fixed point
|
|
845
|
+
* (bounded exact-content only; O-06 stays OPEN). Plans are re-evaluated in
|
|
846
|
+
* deterministic source-session-ID order until no classification changes, so
|
|
847
|
+
* a retry whose planned record depends on another plan that only became an
|
|
848
|
+
* exact retry in an earlier pass is still recognized and the result does not
|
|
849
|
+
* depend on payload order. Classification only ever moves `renamed` ->
|
|
850
|
+
* `skipped`, so the loop terminates after at most one full pass per plan.
|
|
851
|
+
* Every retry match requires the exact planned record in the selected
|
|
852
|
+
* destination (at the source ID, or at the derived target for a global-only
|
|
853
|
+
* conflict); the global inventory is never retry evidence. -- */
|
|
854
|
+
const retryEvaluationOrder = [...plans].sort((left, right) => {
|
|
855
|
+
const leftID = left.session.sourceSessionID;
|
|
856
|
+
const rightID = right.session.sourceSessionID;
|
|
857
|
+
return leftID < rightID ? -1 : leftID > rightID ? 1 : 0;
|
|
858
|
+
});
|
|
859
|
+
let classificationChanged = true;
|
|
860
|
+
while (classificationChanged) {
|
|
861
|
+
classificationChanged = false;
|
|
862
|
+
for (const plan of retryEvaluationOrder) {
|
|
863
|
+
if (!plan.planned || plan.classification !== "renamed") continue;
|
|
864
|
+
const destinationMatch = destinationBySessionID.get(plan.session.sourceSessionID);
|
|
865
|
+
|
|
866
|
+
/* A destination record at the source ID can only be an exact retry
|
|
867
|
+
* at the source ID; a global-only conflict has no such record, so
|
|
868
|
+
* only the derived target is checked for it. */
|
|
869
|
+
if (destinationMatch !== undefined) {
|
|
870
|
+
const retryAtSource = buildPlannedRecord(
|
|
871
|
+
plan.session,
|
|
872
|
+
plan.session.sourceSessionID,
|
|
873
|
+
plan.messageTargets,
|
|
874
|
+
sessionTargets,
|
|
875
|
+
messageTargets,
|
|
876
|
+
destinationSessionIDs,
|
|
877
|
+
destinationMessageIDs,
|
|
878
|
+
);
|
|
879
|
+
if (destinationMatch.canonical === canonicalJson(retryAtSource as unknown as JsonValue)) {
|
|
880
|
+
plan.classification = "skipped";
|
|
881
|
+
plan.targetSessionID = plan.session.sourceSessionID;
|
|
882
|
+
plan.skipReason = "exact-planned-retry";
|
|
883
|
+
plan.record = retryAtSource;
|
|
884
|
+
plan.planned = false;
|
|
885
|
+
sessionTargets.set(plan.session.sourceSessionID, plan.session.sourceSessionID);
|
|
886
|
+
classificationChanged = true;
|
|
887
|
+
continue;
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
const derivedMatch = destinationBySessionID.get(plan.targetSessionID);
|
|
892
|
+
if (derivedMatch === undefined) continue;
|
|
893
|
+
const retryAtDerived = buildPlannedRecord(
|
|
894
|
+
plan.session,
|
|
895
|
+
plan.targetSessionID,
|
|
896
|
+
plan.messageTargets,
|
|
897
|
+
sessionTargets,
|
|
898
|
+
messageTargets,
|
|
899
|
+
destinationSessionIDs,
|
|
900
|
+
destinationMessageIDs,
|
|
901
|
+
);
|
|
902
|
+
if (derivedMatch.canonical === canonicalJson(retryAtDerived as unknown as JsonValue)) {
|
|
903
|
+
plan.classification = "skipped";
|
|
904
|
+
plan.skipReason = "exact-planned-retry";
|
|
905
|
+
plan.record = retryAtDerived;
|
|
906
|
+
plan.planned = false;
|
|
907
|
+
classificationChanged = true;
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
/* -- 5b. A still-renamed plan whose derived target already exists with
|
|
913
|
+
* different content is a collision. Deferred until the retry fixed point
|
|
914
|
+
* settles so a state-dependent retry match is never missed. -- */
|
|
915
|
+
for (const plan of plans) {
|
|
916
|
+
if (!plan.planned || plan.classification !== "renamed") continue;
|
|
917
|
+
if (destinationSessionIDs.has(plan.targetSessionID)) {
|
|
918
|
+
throw new RestorePlanError(
|
|
919
|
+
"session-id-collision",
|
|
920
|
+
`planned remapped session id ${JSON.stringify(plan.targetSessionID)} for source session ${JSON.stringify(plan.session.sourceSessionID)} already exists in the destination with different content`,
|
|
921
|
+
);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
/* -- 5c. A planned target session ID that is globally occupied but absent
|
|
926
|
+
* from the selected destination fails closed (2026-10-14 owner amendment):
|
|
927
|
+
* the derived ID is never re-derived and never retried. Entries whose
|
|
928
|
+
* target already exists in the selected destination are exempt because the
|
|
929
|
+
* destination's own IDs are naturally part of the global inventory; exact
|
|
930
|
+
* planned retries therefore remain recognized only at the selected
|
|
931
|
+
* destination. -- */
|
|
932
|
+
for (const plan of plans) {
|
|
933
|
+
if (!plan.planned) continue;
|
|
934
|
+
if (!destinationSessionIDs.has(plan.targetSessionID) && globalSessionIDs.has(plan.targetSessionID)) {
|
|
935
|
+
throw new RestorePlanError(
|
|
936
|
+
"session-id-collision",
|
|
937
|
+
`planned target session id ${JSON.stringify(plan.targetSessionID)} for source session ${JSON.stringify(plan.session.sourceSessionID)} is already occupied by a session outside the selected destination; refusing to re-derive or retry`,
|
|
938
|
+
);
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
/* -- 6. Build final records for imported/renamed sessions; validate references. -- */
|
|
943
|
+
for (const plan of plans) {
|
|
944
|
+
if (!plan.planned) continue;
|
|
945
|
+
plan.record = buildPlannedRecord(
|
|
946
|
+
plan.session,
|
|
947
|
+
plan.targetSessionID,
|
|
948
|
+
plan.messageTargets,
|
|
949
|
+
sessionTargets,
|
|
950
|
+
messageTargets,
|
|
951
|
+
destinationSessionIDs,
|
|
952
|
+
destinationMessageIDs,
|
|
953
|
+
);
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/* -- 6b. Full-preflight resolution for unchanged skipped (exact local
|
|
957
|
+
* duplicate) records: nothing is rewritten for them, so every schema-defined
|
|
958
|
+
* reference must already resolve after the restore. -- */
|
|
959
|
+
const planBySessionID = new Map<string, SessionPlan>();
|
|
960
|
+
for (const plan of plans) planBySessionID.set(plan.session.sourceSessionID, plan);
|
|
961
|
+
for (const plan of plans) {
|
|
962
|
+
if (plan.classification !== "skipped" || plan.skipReason !== "exact-local-duplicate") continue;
|
|
963
|
+
validateSkippedRecordReferences(plan, planBySessionID, destinationSessionIDs, destinationMessageIDs);
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/* -- 7. Collision detection: final target IDs must be unique across every
|
|
967
|
+
* unique artifact entry (exact-local and exact-retry skips included), so two
|
|
968
|
+
* distinct source sessions can never coalesce onto one target even when a
|
|
969
|
+
* deriver collision makes their transformed records identical. Destination
|
|
970
|
+
* occupancy is only checked for entries that are actually imported: exact
|
|
971
|
+
* local/retry records necessarily already exist in the destination. -- */
|
|
972
|
+
const artifactSessionTargets = new Map<string, string>();
|
|
973
|
+
for (const plan of plans) {
|
|
974
|
+
const prior = artifactSessionTargets.get(plan.targetSessionID);
|
|
975
|
+
if (prior !== undefined) {
|
|
976
|
+
throw new RestorePlanError(
|
|
977
|
+
"session-id-collision",
|
|
978
|
+
`artifact sessions ${JSON.stringify(prior)} and ${JSON.stringify(plan.session.sourceSessionID)} map to the same target session id ${JSON.stringify(plan.targetSessionID)}`,
|
|
979
|
+
);
|
|
980
|
+
}
|
|
981
|
+
artifactSessionTargets.set(plan.targetSessionID, plan.session.sourceSessionID);
|
|
982
|
+
}
|
|
983
|
+
for (const plan of plans) {
|
|
984
|
+
if (!plan.planned) continue;
|
|
985
|
+
if (destinationSessionIDs.has(plan.targetSessionID)) {
|
|
986
|
+
throw new RestorePlanError(
|
|
987
|
+
"session-id-collision",
|
|
988
|
+
`planned target session id ${JSON.stringify(plan.targetSessionID)} for source session ${JSON.stringify(plan.session.sourceSessionID)} already exists in the destination`,
|
|
989
|
+
);
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
const artifactMessageTargets = new Map<string, string>();
|
|
994
|
+
for (const plan of plans) {
|
|
995
|
+
for (const target of plan.messageTargets) {
|
|
996
|
+
const prior = artifactMessageTargets.get(target);
|
|
997
|
+
if (prior !== undefined) {
|
|
998
|
+
throw new RestorePlanError(
|
|
999
|
+
"message-id-collision",
|
|
1000
|
+
`artifact sessions ${JSON.stringify(prior)} and ${JSON.stringify(plan.session.sourceSessionID)} map to the same target message id ${JSON.stringify(target)}`,
|
|
1001
|
+
);
|
|
1002
|
+
}
|
|
1003
|
+
artifactMessageTargets.set(target, plan.session.sourceSessionID);
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
for (const plan of plans) {
|
|
1007
|
+
if (!plan.planned) continue;
|
|
1008
|
+
for (const target of plan.messageTargets) {
|
|
1009
|
+
if (destinationMessageIDs.has(target)) {
|
|
1010
|
+
throw new RestorePlanError(
|
|
1011
|
+
"message-id-collision",
|
|
1012
|
+
`planned target message id ${JSON.stringify(target)} for session ${JSON.stringify(plan.session.sourceSessionID)} already exists in the destination`,
|
|
1013
|
+
);
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/* -- 8. Deterministic parent-before-child import order. -- */
|
|
1019
|
+
const plannedPlans = plans.filter((plan) => plan.planned);
|
|
1020
|
+
const planByMessageID = new Map<string, SessionPlan>();
|
|
1021
|
+
for (const plan of plans) {
|
|
1022
|
+
for (const messageID of plan.session.messageIDs) planByMessageID.set(messageID, plan);
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
const dependencies = new Map<SessionPlan, Set<SessionPlan>>();
|
|
1026
|
+
for (const plan of plannedPlans) dependencies.set(plan, new Set());
|
|
1027
|
+
|
|
1028
|
+
const requireSessionDependency = (plan: SessionPlan, referenced: SessionPlan, kind: string): void => {
|
|
1029
|
+
if (referenced === plan) {
|
|
1030
|
+
throw new RestorePlanError(
|
|
1031
|
+
"dependency-cycle",
|
|
1032
|
+
`${kind} of session ${JSON.stringify(plan.session.sourceSessionID)} references the same session; refusing an ambiguous self-dependency`,
|
|
1033
|
+
);
|
|
1034
|
+
}
|
|
1035
|
+
if (referenced.planned) dependencies.get(plan)!.add(referenced);
|
|
1036
|
+
};
|
|
1037
|
+
const requireMessageDependency = (plan: SessionPlan, referenced: SessionPlan): void => {
|
|
1038
|
+
// A message reference into the same session is part of the same import
|
|
1039
|
+
// unit (for example a session reverting to its own message), so it adds
|
|
1040
|
+
// no cross-session ordering constraint.
|
|
1041
|
+
if (referenced === plan) return;
|
|
1042
|
+
if (referenced.planned) dependencies.get(plan)!.add(referenced);
|
|
1043
|
+
};
|
|
1044
|
+
|
|
1045
|
+
for (const plan of plannedPlans) {
|
|
1046
|
+
const info = plan.session.record.info;
|
|
1047
|
+
const parentID = info["parentID"] as string | undefined;
|
|
1048
|
+
if (parentID !== undefined) {
|
|
1049
|
+
const referenced = planBySessionID.get(parentID);
|
|
1050
|
+
if (referenced !== undefined) requireSessionDependency(plan, referenced, "info.parentID");
|
|
1051
|
+
}
|
|
1052
|
+
const fork = info["fork"] as JsonObject | undefined;
|
|
1053
|
+
if (fork !== undefined) {
|
|
1054
|
+
const forkSessionID = fork["sessionID"] as string | undefined;
|
|
1055
|
+
if (forkSessionID !== undefined) {
|
|
1056
|
+
const referenced = planBySessionID.get(forkSessionID);
|
|
1057
|
+
if (referenced !== undefined) requireSessionDependency(plan, referenced, "info.fork.sessionID");
|
|
1058
|
+
}
|
|
1059
|
+
const boundary = fork["boundary"] as JsonObject | undefined;
|
|
1060
|
+
if (boundary !== undefined) {
|
|
1061
|
+
const boundaryMessageID = boundary["messageID"] as string | undefined;
|
|
1062
|
+
if (boundaryMessageID !== undefined) {
|
|
1063
|
+
const referenced = planByMessageID.get(boundaryMessageID);
|
|
1064
|
+
if (referenced !== undefined) requireMessageDependency(plan, referenced);
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
const revert = info["revert"] as JsonObject | undefined;
|
|
1069
|
+
if (revert !== undefined) {
|
|
1070
|
+
const revertMessageID = revert["messageID"] as string | undefined;
|
|
1071
|
+
if (revertMessageID !== undefined) {
|
|
1072
|
+
const referenced = planByMessageID.get(revertMessageID);
|
|
1073
|
+
if (referenced !== undefined) requireMessageDependency(plan, referenced);
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
const orderedPlans: SessionPlan[] = [];
|
|
1079
|
+
const remaining = new Set<SessionPlan>(plannedPlans);
|
|
1080
|
+
while (remaining.size > 0) {
|
|
1081
|
+
let picked: SessionPlan | undefined;
|
|
1082
|
+
for (const candidate of plannedPlans) {
|
|
1083
|
+
if (!remaining.has(candidate)) continue;
|
|
1084
|
+
let ready = true;
|
|
1085
|
+
for (const dependency of dependencies.get(candidate)!) {
|
|
1086
|
+
if (remaining.has(dependency)) {
|
|
1087
|
+
ready = false;
|
|
1088
|
+
break;
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
if (ready) {
|
|
1092
|
+
picked = candidate;
|
|
1093
|
+
break;
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
if (picked === undefined) {
|
|
1097
|
+
throw new RestorePlanError(
|
|
1098
|
+
"dependency-cycle",
|
|
1099
|
+
`import order cannot be resolved: ${remaining.size} session(s) form a reference cycle or ambiguous dependency`,
|
|
1100
|
+
);
|
|
1101
|
+
}
|
|
1102
|
+
orderedPlans.push(picked);
|
|
1103
|
+
remaining.delete(picked);
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
const entries: RestorePlanEntry[] = plans.map((plan) => {
|
|
1107
|
+
const entry: RestorePlanEntry = {
|
|
1108
|
+
sourceSessionID: plan.session.sourceSessionID,
|
|
1109
|
+
targetSessionID: plan.targetSessionID,
|
|
1110
|
+
classification: plan.classification,
|
|
1111
|
+
record: plan.record,
|
|
1112
|
+
...(plan.skipReason !== undefined ? { skipReason: plan.skipReason } : {}),
|
|
1113
|
+
};
|
|
1114
|
+
return entry;
|
|
1115
|
+
});
|
|
1116
|
+
|
|
1117
|
+
const entryByPlan = new Map<SessionPlan, RestorePlanEntry>();
|
|
1118
|
+
for (let index = 0; index < plans.length; index++) entryByPlan.set(plans[index]!, entries[index]!);
|
|
1119
|
+
const importOrder = orderedPlans.map((plan) => entryByPlan.get(plan)!);
|
|
1120
|
+
|
|
1121
|
+
return { entries, importOrder, collapsedDuplicateCount };
|
|
1122
|
+
}
|