@alsdd/t3-import 0.0.0-stage → 0.1.1-alsd.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/LICENSE +21 -0
- package/README.md +141 -2
- package/assets/fork-demo.gif +0 -0
- package/assets/fork-demo.png +0 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +399 -0
- package/dist/cli.js.map +1 -0
- package/dist/core/checkpoint.d.ts +25 -0
- package/dist/core/checkpoint.js +82 -0
- package/dist/core/checkpoint.js.map +1 -0
- package/dist/core/errors.d.ts +12 -0
- package/dist/core/errors.js +17 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/jsonl.d.ts +7 -0
- package/dist/core/jsonl.js +37 -0
- package/dist/core/jsonl.js.map +1 -0
- package/dist/core/providers.d.ts +6 -0
- package/dist/core/providers.js +17 -0
- package/dist/core/providers.js.map +1 -0
- package/dist/core/types.d.ts +247 -0
- package/dist/core/types.js +4 -0
- package/dist/core/types.js.map +1 -0
- package/dist/core/util.d.ts +14 -0
- package/dist/core/util.js +61 -0
- package/dist/core/util.js.map +1 -0
- package/dist/sources/claude.d.ts +6 -0
- package/dist/sources/claude.js +576 -0
- package/dist/sources/claude.js.map +1 -0
- package/dist/sources/codex.d.ts +11 -0
- package/dist/sources/codex.js +603 -0
- package/dist/sources/codex.js.map +1 -0
- package/dist/sources/codexAppServer.d.ts +24 -0
- package/dist/sources/codexAppServer.js +190 -0
- package/dist/sources/codexAppServer.js.map +1 -0
- package/dist/sources/files.d.ts +1 -0
- package/dist/sources/files.js +24 -0
- package/dist/sources/files.js.map +1 -0
- package/dist/sources/index.d.ts +3 -0
- package/dist/sources/index.js +15 -0
- package/dist/sources/index.js.map +1 -0
- package/dist/sources/opencode.d.ts +6 -0
- package/dist/sources/opencode.js +443 -0
- package/dist/sources/opencode.js.map +1 -0
- package/dist/target/cache.d.ts +14 -0
- package/dist/target/cache.js +155 -0
- package/dist/target/cache.js.map +1 -0
- package/dist/target/config.d.ts +22 -0
- package/dist/target/config.js +90 -0
- package/dist/target/config.js.map +1 -0
- package/dist/target/destination.d.ts +21 -0
- package/dist/target/destination.js +77 -0
- package/dist/target/destination.js.map +1 -0
- package/dist/target/importer.d.ts +86 -0
- package/dist/target/importer.js +597 -0
- package/dist/target/importer.js.map +1 -0
- package/dist/target/launch.d.ts +6 -0
- package/dist/target/launch.js +77 -0
- package/dist/target/launch.js.map +1 -0
- package/dist/target/ledger.d.ts +35 -0
- package/dist/target/ledger.js +174 -0
- package/dist/target/ledger.js.map +1 -0
- package/dist/target/reconciliation.d.ts +60 -0
- package/dist/target/reconciliation.js +259 -0
- package/dist/target/reconciliation.js.map +1 -0
- package/dist/target/replace.d.ts +9 -0
- package/dist/target/replace.js +341 -0
- package/dist/target/replace.js.map +1 -0
- package/dist/target/schema.d.ts +22 -0
- package/dist/target/schema.js +108 -0
- package/dist/target/schema.js.map +1 -0
- package/dist/target/sync.d.ts +34 -0
- package/dist/target/sync.js +879 -0
- package/dist/target/sync.js.map +1 -0
- package/dist/target/syncAll.d.ts +4 -0
- package/dist/target/syncAll.js +98 -0
- package/dist/target/syncAll.js.map +1 -0
- package/dist/target/v2.d.ts +18 -0
- package/dist/target/v2.js +267 -0
- package/dist/target/v2.js.map +1 -0
- package/dist/target/v2Profiles.d.ts +5 -0
- package/dist/target/v2Profiles.js +14 -0
- package/dist/target/v2Profiles.js.map +1 -0
- package/dist/tui.d.ts +49 -0
- package/dist/tui.js +560 -0
- package/dist/tui.js.map +1 -0
- package/package.json +93 -4
- package/vendor/t3-v010/LICENSE +21 -0
- package/vendor/t3-v010/THIRD-PARTY-NOTICES.txt +2274 -0
- package/vendor/t3-v010/reference.json +6 -0
- package/vendor/t3-v010/runtime.mjs +60364 -0
- package/vendor/t3-v010/schema-columns.json +568 -0
- package/vendor/t3-v011/LICENSE +21 -0
- package/vendor/t3-v011/THIRD-PARTY-NOTICES.txt +2274 -0
- package/vendor/t3-v011/reference.json +6 -0
- package/vendor/t3-v011/runtime.mjs +60993 -0
- package/vendor/t3-v011/schema-columns.json +526 -0
|
@@ -0,0 +1,879 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { rmSync } from "node:fs";
|
|
4
|
+
import { sourceDriver } from "../core/providers.js";
|
|
5
|
+
import { isTerminalTurn } from "../core/types.js";
|
|
6
|
+
import { checkpointForThread, legacyTurnSemanticHash, parseCheckpoint, turnSemanticHash } from "../core/checkpoint.js";
|
|
7
|
+
import { canonicalPath, deterministicUuid, isObject, sha256, stringValue } from "../core/util.js";
|
|
8
|
+
import { safetyError, writeError } from "../core/errors.js";
|
|
9
|
+
import { assertT3Closed, isV2Database, targetMigration, validateTargetDatabase } from "./schema.js";
|
|
10
|
+
import { assertV2ThreadSettled, prepareV2Write, v2ProviderThreads, v2SourceConflict, v2Thread } from "./v2.js";
|
|
11
|
+
import { threadWorkingDirectory } from "./destination.js";
|
|
12
|
+
import { listImports, recordImports } from "./ledger.js";
|
|
13
|
+
import { IMPORTER_VERSION, MAX_SAFE_IMPORT_EVENTS, acquireLock, compactThreadForImport, createBackup, event, materializeAssets, planThread, projectionBacklog, } from "./importer.js";
|
|
14
|
+
export function targetId(paths) {
|
|
15
|
+
return sha256(canonicalPath(paths.dbPath));
|
|
16
|
+
}
|
|
17
|
+
export function readEvents(db, threadId) {
|
|
18
|
+
const rows = db.prepare(`
|
|
19
|
+
SELECT sequence, event_id eventId, event_type type, payload_json payload, metadata_json metadata
|
|
20
|
+
FROM orchestration_events WHERE aggregate_kind = 'thread' AND stream_id = ? ORDER BY sequence
|
|
21
|
+
`).all(threadId);
|
|
22
|
+
const parsed = rows.flatMap((row) => {
|
|
23
|
+
try {
|
|
24
|
+
const payload = JSON.parse(row.payload);
|
|
25
|
+
const metadata = JSON.parse(row.metadata);
|
|
26
|
+
return isObject(payload) && isObject(metadata) ? [{ ...row, payload, metadata }] : [];
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
if (!isV2Database(db))
|
|
33
|
+
return parsed;
|
|
34
|
+
// A migrated thread retains its original V1 event stream. T3's lazy V1
|
|
35
|
+
// transcript copies are the same history, not a second set of messages.
|
|
36
|
+
const turns = db.prepare("SELECT payload_json payload FROM orchestration_v2_projection_provider_turns WHERE provider_thread_id IN (SELECT provider_thread_id FROM orchestration_v2_projection_provider_threads WHERE thread_id=?)").all(threadId);
|
|
37
|
+
const nativeTurns = new Map(turns.map((row) => {
|
|
38
|
+
const turn = JSON.parse(row.payload);
|
|
39
|
+
const native = isObject(turn.nativeTurnRef) ? stringValue(turn.nativeTurnRef.nativeId) : undefined;
|
|
40
|
+
return [String(turn.id), native];
|
|
41
|
+
}));
|
|
42
|
+
const items = db.prepare("SELECT payload_json payload FROM orchestration_v2_projection_turn_items WHERE thread_id=? AND type IN ('user_message','assistant_message')").all(threadId);
|
|
43
|
+
const messageTurns = new Map(items.map((row) => {
|
|
44
|
+
const item = JSON.parse(row.payload);
|
|
45
|
+
return [String(item.messageId), nativeTurns.get(String(item.providerTurnId))];
|
|
46
|
+
}));
|
|
47
|
+
return parsed.flatMap((row) => {
|
|
48
|
+
if (row.eventId.startsWith("migration:v1:"))
|
|
49
|
+
return [];
|
|
50
|
+
if (row.type === "message.updated")
|
|
51
|
+
return [{ ...row, type: "thread.message-sent", payload: { ...row.payload, messageId: row.payload.id, turnId: messageTurns.get(String(row.payload.id)) ?? row.metadata.providerTurnId, streaming: false } }];
|
|
52
|
+
if (row.type === "provider-turn.updated") {
|
|
53
|
+
const nativeId = isObject(row.payload.nativeTurnRef) ? stringValue(row.payload.nativeTurnRef.nativeId) : undefined;
|
|
54
|
+
const status = row.payload.status === "completed" ? "ready" : row.payload.status === "failed" ? "error" : ["interrupted", "cancelled"].includes(String(row.payload.status)) ? "interrupted" : undefined;
|
|
55
|
+
return status && nativeId ? [{ ...row, type: "thread.session-set", metadata: { ...row.metadata, providerTurnId: nativeId, v2Native: true }, payload: { session: { status } } }] : [];
|
|
56
|
+
}
|
|
57
|
+
if (row.type === "thread.metadata-updated")
|
|
58
|
+
return [{ ...row, type: "thread.meta-updated", payload: { ...row.payload, threadId } }];
|
|
59
|
+
if (row.type === "turn-item.updated" && row.metadata.t3ImportTerminalStatus)
|
|
60
|
+
return [{ ...row, type: "thread.session-set", payload: { session: { status: row.metadata.t3ImportTerminalStatus === "completed" ? "ready" : row.metadata.t3ImportTerminalStatus === "failed" ? "error" : "interrupted" } } }];
|
|
61
|
+
if (row.type === "thread.created")
|
|
62
|
+
return [{ ...row, payload: { ...row.payload, threadId } }];
|
|
63
|
+
return [row];
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
export function createEvent(events) {
|
|
67
|
+
return events.find((row) => row.type === "thread.created");
|
|
68
|
+
}
|
|
69
|
+
export function currentTitle(db, threadId, events) {
|
|
70
|
+
if (isV2Database(db)) {
|
|
71
|
+
const title = stringValue(v2Thread(db, threadId)?.title);
|
|
72
|
+
if (title)
|
|
73
|
+
return title;
|
|
74
|
+
}
|
|
75
|
+
const table = db.prepare("SELECT 1 found FROM sqlite_master WHERE type='table' AND name='projection_threads'").get();
|
|
76
|
+
if (table) {
|
|
77
|
+
const row = db.prepare("SELECT title FROM projection_threads WHERE thread_id = ? AND deleted_at IS NULL").get(threadId);
|
|
78
|
+
if (row?.title)
|
|
79
|
+
return row.title;
|
|
80
|
+
}
|
|
81
|
+
for (const row of [...events].reverse()) {
|
|
82
|
+
if (row.type === "thread.meta-updated") {
|
|
83
|
+
const title = stringValue(row.payload.title);
|
|
84
|
+
if (title)
|
|
85
|
+
return title;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return stringValue(createEvent(events)?.payload.title) ?? "Imported conversation";
|
|
89
|
+
}
|
|
90
|
+
export function existingProvider(db, thread, record, events) {
|
|
91
|
+
const runtime = db.prepare(`
|
|
92
|
+
SELECT provider_name providerName, provider_instance_id instanceId, adapter_key adapterKey
|
|
93
|
+
FROM provider_session_runtime WHERE thread_id = ?
|
|
94
|
+
`).get(record.threadId);
|
|
95
|
+
const current = isV2Database(db) ? v2Thread(db, record.threadId) : undefined;
|
|
96
|
+
const createdSelection = current?.modelSelection ?? createEvent(events)?.payload.modelSelection;
|
|
97
|
+
const selection = isObject(createdSelection) ? createdSelection : {};
|
|
98
|
+
const options = Array.isArray(selection.options)
|
|
99
|
+
? selection.options.filter((value) => isObject(value) && typeof value.id === "string" && (typeof value.value === "string" || typeof value.value === "boolean"))
|
|
100
|
+
: [];
|
|
101
|
+
const providerName = runtime?.providerName ?? sourceDriver(thread.source);
|
|
102
|
+
return {
|
|
103
|
+
providerName,
|
|
104
|
+
instanceId: stringValue(current?.providerInstanceId) ?? runtime?.instanceId ?? stringValue(selection.instanceId) ?? providerName,
|
|
105
|
+
adapterKey: runtime?.adapterKey ?? providerName,
|
|
106
|
+
model: stringValue(selection.model) ?? thread.model,
|
|
107
|
+
options,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
function canonicalMarker(seed) {
|
|
111
|
+
return deterministicUuid(`t3-import:event:${seed}:thread.created`);
|
|
112
|
+
}
|
|
113
|
+
const NATIVE_CODEX_SEED_PREFIX = "t3-import:native-codex:";
|
|
114
|
+
function nativeSeedPrefix(source) {
|
|
115
|
+
return source === "opencode" ? "t3-import:native-opencode:" : NATIVE_CODEX_SEED_PREFIX;
|
|
116
|
+
}
|
|
117
|
+
function nativeProviderRecord(db, paths, thread) {
|
|
118
|
+
const driver = sourceDriver(thread.source);
|
|
119
|
+
if (isV2Database(db)) {
|
|
120
|
+
const linked = v2ProviderThreads(db).filter((row) => row.driver === driver && isObject(row.nativeThreadRef) && row.nativeThreadRef.driver === driver && row.nativeThreadRef.strength === "strong"
|
|
121
|
+
&& row.nativeThreadRef.nativeId === thread.sourceSessionId && typeof row.appThreadId === "string"
|
|
122
|
+
&& !v2Thread(db, row.appThreadId)?.deletedAt);
|
|
123
|
+
const ids = [...new Set(linked.map((row) => String(row.appThreadId)))];
|
|
124
|
+
if (ids.length > 1)
|
|
125
|
+
throw safetyError("Multiple V2 threads refer to this source provider session. Resolve the ambiguous binding before synchronizing.");
|
|
126
|
+
const threadId = ids[0];
|
|
127
|
+
if (threadId) {
|
|
128
|
+
const events = readEvents(db, threadId);
|
|
129
|
+
const created = createEvent(events);
|
|
130
|
+
const projectId = stringValue(created?.payload.projectId);
|
|
131
|
+
if (created && projectId && canonicalPath(threadWorkingDirectory(db, threadId, "").cwd) === canonicalPath(thread.workspace)) {
|
|
132
|
+
return { targetId: targetId(paths), source: thread.source, sourceSessionId: thread.sourceSessionId,
|
|
133
|
+
sourceKey: thread.sourceKey, sourceFingerprint: "", projectId, threadId,
|
|
134
|
+
importedAt: stringValue(created.payload.createdAt) ?? thread.createdAt,
|
|
135
|
+
importerVersion: "native-adoption", migration: targetMigration(db),
|
|
136
|
+
firstSequence: created.sequence, lastSequence: events.at(-1)?.sequence ?? created.sequence,
|
|
137
|
+
resumable: true, backupPath: "", warnings: [],
|
|
138
|
+
identitySeed: `${nativeSeedPrefix(thread.source)}${thread.sourceSessionId}:${threadId}`,
|
|
139
|
+
currentSourceKey: thread.sourceKey, sourceTitle: thread.title, isCanonical: true };
|
|
140
|
+
}
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (thread.source !== "codex")
|
|
145
|
+
return undefined;
|
|
146
|
+
const candidates = db.prepare(`
|
|
147
|
+
SELECT thread_id threadId, resume_cursor_json cursor, runtime_payload_json runtime, status
|
|
148
|
+
FROM provider_session_runtime
|
|
149
|
+
WHERE provider_name = 'codex' AND adapter_key = 'codex'
|
|
150
|
+
`).all();
|
|
151
|
+
const matches = candidates.filter((candidate) => {
|
|
152
|
+
if (candidate.threadId === thread.sourceSessionId || candidate.status !== "stopped")
|
|
153
|
+
return false;
|
|
154
|
+
try {
|
|
155
|
+
const cursor = candidate.cursor ? JSON.parse(candidate.cursor) : {};
|
|
156
|
+
return stringValue(cursor.threadId) === thread.sourceSessionId;
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
if (matches.length !== 1)
|
|
163
|
+
return undefined;
|
|
164
|
+
const threadId = matches[0].threadId;
|
|
165
|
+
const events = readEvents(db, threadId);
|
|
166
|
+
const created = createEvent(events);
|
|
167
|
+
if (!created || events.some((row) => row.type === "thread.deleted"))
|
|
168
|
+
return undefined;
|
|
169
|
+
const projectId = stringValue(created.payload.projectId);
|
|
170
|
+
if (!projectId)
|
|
171
|
+
return undefined;
|
|
172
|
+
const project = db.prepare(`
|
|
173
|
+
SELECT payload_json payload FROM orchestration_events
|
|
174
|
+
WHERE aggregate_kind = 'project' AND stream_id = ? AND event_type = 'project.created'
|
|
175
|
+
ORDER BY sequence LIMIT 1
|
|
176
|
+
`).get(projectId);
|
|
177
|
+
if (!project)
|
|
178
|
+
return undefined;
|
|
179
|
+
try {
|
|
180
|
+
const root = stringValue(JSON.parse(project.payload).workspaceRoot);
|
|
181
|
+
const runtime = matches[0].runtime ? JSON.parse(matches[0].runtime) : undefined;
|
|
182
|
+
const cwd = isObject(runtime) ? stringValue(runtime.cwd) : undefined;
|
|
183
|
+
// Scratch threads run in a per-thread directory below the T3 project root.
|
|
184
|
+
// Accept only an exact recorded runtime cwd, never an ancestor/path guess.
|
|
185
|
+
if (!root || (canonicalPath(root) !== canonicalPath(thread.workspace)
|
|
186
|
+
&& (!cwd || canonicalPath(cwd) !== canonicalPath(thread.workspace))))
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
const identitySeed = `${nativeSeedPrefix(thread.source)}${thread.sourceSessionId}:${threadId}`;
|
|
193
|
+
return {
|
|
194
|
+
targetId: targetId(paths), source: thread.source, sourceSessionId: thread.sourceSessionId,
|
|
195
|
+
sourceKey: thread.sourceKey, sourceFingerprint: "", projectId, threadId,
|
|
196
|
+
importedAt: stringValue(created.payload.createdAt) ?? thread.createdAt,
|
|
197
|
+
importerVersion: "native-adoption", migration: targetMigration(db),
|
|
198
|
+
firstSequence: created.sequence, lastSequence: events.at(-1)?.sequence ?? created.sequence,
|
|
199
|
+
resumable: true, backupPath: "", warnings: [], identitySeed,
|
|
200
|
+
currentSourceKey: thread.sourceKey,
|
|
201
|
+
sourceTitle: thread.title,
|
|
202
|
+
isCanonical: true,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
export function validRecord(db, record) {
|
|
206
|
+
if (record.identitySeed.startsWith(nativeSeedPrefix(record.source))) {
|
|
207
|
+
if (isV2Database(db) && v2ProviderThreads(db, record.threadId).some((row) => row.driver === sourceDriver(record.source) && isObject(row.nativeThreadRef) && row.nativeThreadRef.driver === sourceDriver(record.source) && row.nativeThreadRef.strength === "strong" && row.nativeThreadRef.nativeId === record.sourceSessionId)) {
|
|
208
|
+
return Boolean(v2Thread(db, record.threadId)) && !v2Thread(db, record.threadId)?.deletedAt;
|
|
209
|
+
}
|
|
210
|
+
const runtime = db.prepare(`
|
|
211
|
+
SELECT resume_cursor_json cursor FROM provider_session_runtime
|
|
212
|
+
WHERE thread_id = ? AND provider_name = 'codex' AND adapter_key = 'codex'
|
|
213
|
+
`).get(record.threadId);
|
|
214
|
+
if (!runtime)
|
|
215
|
+
return false;
|
|
216
|
+
try {
|
|
217
|
+
const cursor = runtime.cursor ? JSON.parse(runtime.cursor) : {};
|
|
218
|
+
const events = readEvents(db, record.threadId);
|
|
219
|
+
return stringValue(cursor.threadId) === record.sourceSessionId
|
|
220
|
+
&& Boolean(createEvent(events))
|
|
221
|
+
&& !events.some((row) => row.type === "thread.deleted");
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return Boolean(db.prepare(`
|
|
228
|
+
SELECT 1 found FROM orchestration_events
|
|
229
|
+
WHERE stream_id = ? AND event_type = 'thread.created' AND event_id = ? LIMIT 1
|
|
230
|
+
`).get(record.threadId, canonicalMarker(record.identitySeed)));
|
|
231
|
+
}
|
|
232
|
+
export function fallbackRecord(db, paths, thread) {
|
|
233
|
+
let threadId;
|
|
234
|
+
let seed = thread.sourceKey;
|
|
235
|
+
if (thread.source === "codex") {
|
|
236
|
+
threadId = thread.sourceSessionId;
|
|
237
|
+
}
|
|
238
|
+
else if (thread.source === "opencode") {
|
|
239
|
+
if (!isV2Database(db))
|
|
240
|
+
return undefined;
|
|
241
|
+
threadId = deterministicUuid(`t3-import:thread:${thread.sourceKey}`);
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
if (isV2Database(db))
|
|
245
|
+
threadId = deterministicUuid(`t3-import:thread:${thread.sourceKey}`);
|
|
246
|
+
const candidates = db.prepare("SELECT thread_id threadId, resume_cursor_json cursor FROM provider_session_runtime WHERE provider_name = 'claudeAgent'").all();
|
|
247
|
+
for (const candidate of candidates) {
|
|
248
|
+
try {
|
|
249
|
+
const cursor = candidate.cursor ? JSON.parse(candidate.cursor) : {};
|
|
250
|
+
if (cursor.resume !== thread.sourceSessionId)
|
|
251
|
+
continue;
|
|
252
|
+
threadId = candidate.threadId;
|
|
253
|
+
const leaf = stringValue(cursor.resumeSessionAt);
|
|
254
|
+
if (leaf)
|
|
255
|
+
seed = `claude:${thread.sourceSessionId}:${leaf}`;
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
258
|
+
catch { /* continue */ }
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (!threadId)
|
|
262
|
+
return undefined;
|
|
263
|
+
const events = readEvents(db, threadId);
|
|
264
|
+
// Early 0.1 builds used the bare Codex session ID as their deterministic
|
|
265
|
+
// event seed. Discovery now exposes `codex:<session-id>`, but the imported
|
|
266
|
+
// stream and provider thread ID remain the bare UUID. Accept both markers
|
|
267
|
+
// and retain the marker's original seed so checkpoint IDs can be rebuilt.
|
|
268
|
+
const seeds = thread.source === "codex"
|
|
269
|
+
? [thread.sourceKey, thread.sourceSessionId]
|
|
270
|
+
: [seed];
|
|
271
|
+
const matched = seeds
|
|
272
|
+
.map((candidate) => ({ candidate, created: events.find((row) => row.eventId === canonicalMarker(candidate)) }))
|
|
273
|
+
.find((candidate) => candidate.created);
|
|
274
|
+
const created = matched?.created;
|
|
275
|
+
if (!created)
|
|
276
|
+
return thread.source !== "claude" ? nativeProviderRecord(db, paths, thread) : undefined;
|
|
277
|
+
seed = matched.candidate;
|
|
278
|
+
const projectId = stringValue(created.payload.projectId);
|
|
279
|
+
if (!projectId)
|
|
280
|
+
return undefined;
|
|
281
|
+
return {
|
|
282
|
+
targetId: targetId(paths), source: thread.source, sourceSessionId: thread.sourceSessionId,
|
|
283
|
+
sourceKey: seed, sourceFingerprint: "", projectId, threadId,
|
|
284
|
+
importedAt: stringValue(created.payload.createdAt) ?? thread.createdAt,
|
|
285
|
+
importerVersion: "legacy", migration: targetMigration(db),
|
|
286
|
+
firstSequence: created.sequence, lastSequence: events.at(-1)?.sequence ?? created.sequence,
|
|
287
|
+
resumable: true, backupPath: "", warnings: [], identitySeed: seed,
|
|
288
|
+
currentSourceKey: seed, sourceTitle: stringValue(created.payload.title) ?? thread.title,
|
|
289
|
+
isCanonical: true,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
function replacementMetadataRecord(db, paths, thread) {
|
|
293
|
+
const rows = db.prepare(`
|
|
294
|
+
SELECT stream_id threadId, sequence, payload_json payload, metadata_json metadata
|
|
295
|
+
FROM orchestration_events
|
|
296
|
+
WHERE aggregate_kind = 'thread' AND event_type = 'thread.created'
|
|
297
|
+
ORDER BY sequence DESC
|
|
298
|
+
`).all();
|
|
299
|
+
for (const row of rows) {
|
|
300
|
+
try {
|
|
301
|
+
const payload = JSON.parse(row.payload);
|
|
302
|
+
const metadata = JSON.parse(row.metadata);
|
|
303
|
+
if (!isObject(payload) || !isObject(metadata) || !isObject(metadata.t3Import))
|
|
304
|
+
continue;
|
|
305
|
+
const marker = metadata.t3Import;
|
|
306
|
+
if (marker.canonical !== true || marker.source !== thread.source || marker.sourceSessionId !== thread.sourceSessionId)
|
|
307
|
+
continue;
|
|
308
|
+
const events = readEvents(db, row.threadId);
|
|
309
|
+
if (events.some((candidate) => candidate.type === "thread.deleted"))
|
|
310
|
+
continue;
|
|
311
|
+
const projectId = stringValue(payload.projectId);
|
|
312
|
+
const identitySeed = stringValue(marker.identitySeed);
|
|
313
|
+
const sourceKey = stringValue(marker.sourceKey) ?? thread.sourceKey;
|
|
314
|
+
if (!projectId || !identitySeed)
|
|
315
|
+
continue;
|
|
316
|
+
return {
|
|
317
|
+
targetId: targetId(paths), source: thread.source, sourceSessionId: thread.sourceSessionId,
|
|
318
|
+
sourceKey, sourceFingerprint: "", projectId, threadId: row.threadId,
|
|
319
|
+
importedAt: stringValue(payload.createdAt) ?? thread.createdAt, importerVersion: "metadata-recovered",
|
|
320
|
+
migration: targetMigration(db), firstSequence: row.sequence,
|
|
321
|
+
lastSequence: events.at(-1)?.sequence ?? row.sequence, resumable: marker.resumable !== false, backupPath: "",
|
|
322
|
+
warnings: [isV2Database(db) ? "Recovered canonical import identity from T3 event metadata." : "Recovered canonical replacement identity from T3 event metadata."], identitySeed,
|
|
323
|
+
currentSourceKey: sourceKey, sourceTitle: stringValue(payload.title) ?? thread.title,
|
|
324
|
+
isCanonical: true,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
catch { /* continue */ }
|
|
328
|
+
}
|
|
329
|
+
return undefined;
|
|
330
|
+
}
|
|
331
|
+
export function findRecord(db, paths, thread) {
|
|
332
|
+
const candidates = listImports(targetId(paths), thread.source).filter((row) => row.sourceSessionId === thread.sourceSessionId);
|
|
333
|
+
const canonicalCandidates = candidates.filter((row) => row.isCanonical === true || (row.isCanonical === undefined && validRecord(db, row) && row.identitySeed === row.sourceKey));
|
|
334
|
+
const valid = canonicalCandidates.filter((row) => validRecord(db, row));
|
|
335
|
+
if (thread.source === "codex") {
|
|
336
|
+
const record = valid.find((row) => (row.sourceKey === thread.sourceKey || row.currentSourceKey === thread.sourceKey)) ?? replacementMetadataRecord(db, paths, thread) ?? fallbackRecord(db, paths, thread);
|
|
337
|
+
return { ...(record ? { record } : {}), stale: canonicalCandidates.length > 0 && valid.length === 0 };
|
|
338
|
+
}
|
|
339
|
+
const resumable = valid.find((row) => row.resumable);
|
|
340
|
+
const record = resumable ?? (valid.length === 1 ? valid[0] : undefined) ?? replacementMetadataRecord(db, paths, thread) ?? fallbackRecord(db, paths, thread);
|
|
341
|
+
return { ...(record ? { record } : {}), stale: canonicalCandidates.length > 0 && valid.length === 0 };
|
|
342
|
+
}
|
|
343
|
+
export function bootstrapCheckpoint(thread, record, events) {
|
|
344
|
+
const saved = [...events].reverse().find((row) => row.metadata.t3ImportCheckpoint);
|
|
345
|
+
const savedCheckpoint = saved ? parseCheckpoint(JSON.stringify(saved.metadata.t3ImportCheckpoint)) : undefined;
|
|
346
|
+
if (savedCheckpoint)
|
|
347
|
+
return { checkpoint: savedCheckpoint, lastSequence: saved.sequence };
|
|
348
|
+
const byId = new Map(events.map((row) => [row.eventId, row]));
|
|
349
|
+
const importedEvent = (...keys) => keys
|
|
350
|
+
.map((key) => byId.get(deterministicUuid(`t3-import:event:${record.identitySeed}:${key}`)))
|
|
351
|
+
.find((row) => row !== undefined);
|
|
352
|
+
const turns = [];
|
|
353
|
+
let lastSequence = record.firstSequence;
|
|
354
|
+
for (const [index, turn] of thread.turns.entries()) {
|
|
355
|
+
// The initial one-off Codex importer used longer event-key suffixes. Its
|
|
356
|
+
// payloads and provider IDs are canonical, so validate them identically
|
|
357
|
+
// while accepting either deterministic layout.
|
|
358
|
+
const user = importedEvent(`turn.${index}.user`, `turn.${index}.user-message`);
|
|
359
|
+
const start = importedEvent(`turn.${index}.start`, `turn.${index}.start-requested`);
|
|
360
|
+
const running = importedEvent(`turn.${index}.running`, `turn.${index}.session-running`);
|
|
361
|
+
const settled = importedEvent(`turn.${index}.settled`, `turn.${index}.session-ready`);
|
|
362
|
+
if (!user || !start || !running || !settled)
|
|
363
|
+
break;
|
|
364
|
+
if (stringValue(user.payload.text) !== turn.user.text || stringValue(running.metadata.providerTurnId) !== turn.id)
|
|
365
|
+
return undefined;
|
|
366
|
+
// New imports settle before their messages for per-projector bootstrap;
|
|
367
|
+
// older imports settled last. The next user message bounds either layout.
|
|
368
|
+
const nextUser = events.find((row) => row.sequence > user.sequence && row.type === "thread.message-sent" && row.payload.role === "user");
|
|
369
|
+
const turnEvents = events.filter((row) => row.sequence >= user.sequence && row.sequence < (nextUser?.sequence ?? Infinity));
|
|
370
|
+
const assistant = turnEvents
|
|
371
|
+
.filter((row) => row.type === "thread.message-sent" && row.payload.role === "assistant")
|
|
372
|
+
.map((row) => stringValue(row.payload.text) ?? "");
|
|
373
|
+
if (assistant.join("\n\u0000\n") !== turn.assistant.map((message) => message.text).join("\n\u0000\n"))
|
|
374
|
+
return undefined;
|
|
375
|
+
const status = terminalStatusFromEvent(settled);
|
|
376
|
+
if (!status || status !== turn.status)
|
|
377
|
+
return undefined;
|
|
378
|
+
turns.push({ id: turn.id, status, ...(turn.terminalReason ? { terminalReason: turn.terminalReason } : {}), ...(turn.terminalError ? { terminalError: turn.terminalError } : {}), hash: turnSemanticHash(turn) });
|
|
379
|
+
lastSequence = Math.max(lastSequence, settled.sequence, ...turnEvents.map((row) => row.sequence));
|
|
380
|
+
}
|
|
381
|
+
return turns.length > 0 ? { checkpoint: { version: 2, turns }, lastSequence } : undefined;
|
|
382
|
+
}
|
|
383
|
+
function verifiedSourcePrefixLength(thread, checkpoint) {
|
|
384
|
+
if (checkpoint.version === 1) {
|
|
385
|
+
let sourceIndex = 0;
|
|
386
|
+
for (const saved of checkpoint.turns) {
|
|
387
|
+
while (thread.turns[sourceIndex] && thread.turns[sourceIndex].status !== "completed")
|
|
388
|
+
sourceIndex += 1;
|
|
389
|
+
const current = thread.turns[sourceIndex];
|
|
390
|
+
if (!current || current.id !== saved.id || legacyTurnSemanticHash(current) !== saved.hash)
|
|
391
|
+
return undefined;
|
|
392
|
+
sourceIndex += 1;
|
|
393
|
+
}
|
|
394
|
+
return sourceIndex;
|
|
395
|
+
}
|
|
396
|
+
if (thread.turns.length < checkpoint.turns.length)
|
|
397
|
+
return undefined;
|
|
398
|
+
const valid = checkpoint.turns.every((saved, index) => {
|
|
399
|
+
const current = thread.turns[index];
|
|
400
|
+
return Boolean(current && current.id === saved.id && current.status === saved.status && current.terminalReason === saved.terminalReason && current.terminalError === saved.terminalError && turnSemanticHash(current) === saved.hash);
|
|
401
|
+
});
|
|
402
|
+
return valid ? checkpoint.turns.length : undefined;
|
|
403
|
+
}
|
|
404
|
+
function terminalStatusFromEvent(row) {
|
|
405
|
+
if (!row || row.type !== "thread.session-set" || !isObject(row.payload.session))
|
|
406
|
+
return undefined;
|
|
407
|
+
switch (stringValue(row.payload.session.status)) {
|
|
408
|
+
case "ready":
|
|
409
|
+
case "idle": return "completed";
|
|
410
|
+
case "interrupted":
|
|
411
|
+
case "stopped": return "interrupted";
|
|
412
|
+
case "error": return "failed";
|
|
413
|
+
default: return undefined;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
function settledProviderTurns(events, afterSequence) {
|
|
417
|
+
return events.flatMap((row) => {
|
|
418
|
+
if (row.sequence <= afterSequence)
|
|
419
|
+
return [];
|
|
420
|
+
const id = stringValue(row.metadata.providerTurnId);
|
|
421
|
+
const status = terminalStatusFromEvent(row);
|
|
422
|
+
return id && status ? [{ id, status }] : [];
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
function codexAdoptedTurns(thread, start, events, afterSequence) {
|
|
426
|
+
const settled = settledProviderTurns(events, afterSequence);
|
|
427
|
+
const byId = new Map(settled.map((entry) => [entry.id, entry.status]));
|
|
428
|
+
let adopted = 0;
|
|
429
|
+
while (thread.turns[start + adopted] && byId.has(thread.turns[start + adopted].id)) {
|
|
430
|
+
const turn = thread.turns[start + adopted];
|
|
431
|
+
if (byId.get(turn.id) !== turn.status)
|
|
432
|
+
return -1;
|
|
433
|
+
adopted += 1;
|
|
434
|
+
}
|
|
435
|
+
if (thread.turns.slice(start + adopted + 1).some((turn) => byId.has(turn.id)))
|
|
436
|
+
return -1;
|
|
437
|
+
return adopted;
|
|
438
|
+
}
|
|
439
|
+
function sourceAttachments(turn) {
|
|
440
|
+
return turn.user.attachments.map((attachment) => ({ name: attachment.name, mimeType: attachment.mimeType, sizeBytes: attachment.sizeBytes }));
|
|
441
|
+
}
|
|
442
|
+
function eventAttachments(row) {
|
|
443
|
+
if (!row)
|
|
444
|
+
return undefined;
|
|
445
|
+
if (!Array.isArray(row.payload.attachments))
|
|
446
|
+
return [];
|
|
447
|
+
const attachments = row.payload.attachments.flatMap((value) => {
|
|
448
|
+
if (!isObject(value))
|
|
449
|
+
return [];
|
|
450
|
+
const name = stringValue(value.name);
|
|
451
|
+
const mimeType = stringValue(value.mimeType);
|
|
452
|
+
const sizeBytes = typeof value.sizeBytes === "number" ? value.sizeBytes : undefined;
|
|
453
|
+
return name && mimeType && sizeBytes !== undefined ? [{ name, mimeType, sizeBytes }] : [];
|
|
454
|
+
});
|
|
455
|
+
return attachments.length === row.payload.attachments.length ? attachments : undefined;
|
|
456
|
+
}
|
|
457
|
+
function nativeCodexPrefixLength(thread, events) {
|
|
458
|
+
const v2Terminal = new Map(events.filter((row) => row.metadata.v2Native === true).map((row) => [stringValue(row.metadata.providerTurnId), terminalStatusFromEvent(row)]));
|
|
459
|
+
const turns = [];
|
|
460
|
+
const messages = new Map();
|
|
461
|
+
let active;
|
|
462
|
+
for (const row of events) {
|
|
463
|
+
if (row.type === "thread.message-sent") {
|
|
464
|
+
const role = stringValue(row.payload.role);
|
|
465
|
+
if (role === "user") {
|
|
466
|
+
if (active)
|
|
467
|
+
turns.push(active);
|
|
468
|
+
active = { user: row, assistant: [] };
|
|
469
|
+
}
|
|
470
|
+
else if (role === "assistant") {
|
|
471
|
+
if (!active)
|
|
472
|
+
return undefined;
|
|
473
|
+
// Match T3's message projector: streaming events append deltas;
|
|
474
|
+
// a non-empty final event replaces the accumulated text, while an
|
|
475
|
+
// empty final event retains it.
|
|
476
|
+
const messageId = stringValue(row.payload.messageId);
|
|
477
|
+
const providerTurnId = stringValue(row.payload.turnId) ?? stringValue(row.metadata.providerTurnId);
|
|
478
|
+
if (providerTurnId) {
|
|
479
|
+
if (active.providerTurnId && active.providerTurnId !== providerTurnId)
|
|
480
|
+
return undefined;
|
|
481
|
+
active.providerTurnId = providerTurnId;
|
|
482
|
+
}
|
|
483
|
+
const existingIndex = messageId
|
|
484
|
+
? active.assistant.findIndex((message) => stringValue(message.payload.messageId) === messageId)
|
|
485
|
+
: -1;
|
|
486
|
+
const previous = messageId ? messages.get(messageId) : undefined;
|
|
487
|
+
const text = stringValue(row.payload.text) ?? "";
|
|
488
|
+
const previousText = stringValue(previous?.payload.text) ?? "";
|
|
489
|
+
const merged = {
|
|
490
|
+
...row,
|
|
491
|
+
payload: { ...row.payload, text: row.payload.streaming === true ? previousText + text : text || previousText },
|
|
492
|
+
};
|
|
493
|
+
// One assistant message can span multiple steered user inputs.
|
|
494
|
+
// All those turn fragments share the same accumulated snapshot.
|
|
495
|
+
if (previous)
|
|
496
|
+
Object.assign(previous, merged);
|
|
497
|
+
const snapshot = previous ?? merged;
|
|
498
|
+
if (messageId)
|
|
499
|
+
messages.set(messageId, snapshot);
|
|
500
|
+
if (existingIndex >= 0)
|
|
501
|
+
active.assistant[existingIndex] = snapshot;
|
|
502
|
+
else
|
|
503
|
+
active.assistant.push(snapshot);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
else if (row.type === "thread.session-set" && active?.assistant.length && !active.status) {
|
|
507
|
+
const status = terminalStatusFromEvent(row);
|
|
508
|
+
if (status)
|
|
509
|
+
active.status = status;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
if (active)
|
|
513
|
+
turns.push(active);
|
|
514
|
+
if (turns.length === 0)
|
|
515
|
+
return undefined;
|
|
516
|
+
// T3 stores each steered input as a user message. Codex stores all inputs
|
|
517
|
+
// handled by one provider turn together. Merge only with an explicit shared
|
|
518
|
+
// provider turn ID, allowing a user-only fragment between matching IDs.
|
|
519
|
+
const grouped = [];
|
|
520
|
+
for (const [index, turn] of turns.entries()) {
|
|
521
|
+
const previous = grouped.at(-1);
|
|
522
|
+
const bridge = !turn.providerTurnId && !turn.status && turn.assistant.length === 0
|
|
523
|
+
&& previous?.providerTurnId === turns[index + 1]?.providerTurnId;
|
|
524
|
+
if (previous?.providerTurnId && !previous.status
|
|
525
|
+
&& (previous.providerTurnId === turn.providerTurnId || bridge)) {
|
|
526
|
+
const previousAttachments = eventAttachments(previous.user);
|
|
527
|
+
const nextAttachments = eventAttachments(turn.user);
|
|
528
|
+
if (!previousAttachments || !nextAttachments)
|
|
529
|
+
return undefined;
|
|
530
|
+
previous.user = {
|
|
531
|
+
...previous.user,
|
|
532
|
+
payload: {
|
|
533
|
+
...previous.user.payload,
|
|
534
|
+
text: `${stringValue(previous.user.payload.text) ?? ""}\n\n${stringValue(turn.user.payload.text) ?? ""}`,
|
|
535
|
+
attachments: [...previousAttachments, ...nextAttachments],
|
|
536
|
+
},
|
|
537
|
+
};
|
|
538
|
+
previous.assistant = [...new Set([...previous.assistant, ...turn.assistant])];
|
|
539
|
+
if (turn.status)
|
|
540
|
+
previous.status = turn.status;
|
|
541
|
+
}
|
|
542
|
+
else
|
|
543
|
+
grouped.push(turn);
|
|
544
|
+
}
|
|
545
|
+
if (grouped.length > thread.turns.length)
|
|
546
|
+
return undefined;
|
|
547
|
+
for (const [index, native] of grouped.entries()) {
|
|
548
|
+
const v2Status = native.providerTurnId ? v2Terminal.get(native.providerTurnId) : undefined;
|
|
549
|
+
if (v2Status)
|
|
550
|
+
native.status = v2Status;
|
|
551
|
+
const source = thread.turns[index];
|
|
552
|
+
const nativeUser = stringValue(native.user.payload.text);
|
|
553
|
+
const sourceUser = source?.user.text ?? "";
|
|
554
|
+
// A resumed Codex rollout may start with a context envelope before the
|
|
555
|
+
// original T3 prompt. Accept that only when the visible prompt is its exact
|
|
556
|
+
// final segment; all later turns must match byte-for-byte.
|
|
557
|
+
const userMatches = sourceUser === nativeUser
|
|
558
|
+
|| (thread.source === "codex" && index === 0 && sourceUser.endsWith(`\n\n${nativeUser}`));
|
|
559
|
+
const actualAttachments = eventAttachments(native.user);
|
|
560
|
+
const expectedAttachments = source ? sourceAttachments(source) : [];
|
|
561
|
+
const actualAssistant = native.assistant.map((row) => stringValue(row.payload.text) ?? "");
|
|
562
|
+
const expectedAssistant = source?.assistant.map((message) => message.text) ?? [];
|
|
563
|
+
if (!source || (native.providerTurnId && native.providerTurnId !== source.id) || !userMatches || actualAttachments === undefined
|
|
564
|
+
|| JSON.stringify(actualAttachments) !== JSON.stringify(expectedAttachments)
|
|
565
|
+
|| JSON.stringify(actualAssistant) !== JSON.stringify(expectedAssistant)
|
|
566
|
+
|| native.status !== source.status)
|
|
567
|
+
return undefined;
|
|
568
|
+
}
|
|
569
|
+
return grouped.length;
|
|
570
|
+
}
|
|
571
|
+
function nativeCodexAdoptedTurns(thread, start, events, afterSequence) {
|
|
572
|
+
const suffix = events.filter((row) => row.sequence > afterSequence);
|
|
573
|
+
if (!suffix.some((row) => row.type === "thread.message-sent"
|
|
574
|
+
&& (row.payload.role === "user" || row.payload.role === "assistant")))
|
|
575
|
+
return 0;
|
|
576
|
+
return nativeCodexPrefixLength({ ...thread, turns: thread.turns.slice(start) }, suffix) ?? -1;
|
|
577
|
+
}
|
|
578
|
+
function claudeAdoptedTurns(thread, start, events, afterSequence) {
|
|
579
|
+
const messages = events.filter((row) => row.sequence > afterSequence && row.type === "thread.message-sent" && (row.payload.role === "user" || row.payload.role === "assistant"));
|
|
580
|
+
let cursor = 0;
|
|
581
|
+
let adopted = 0;
|
|
582
|
+
const settled = settledProviderTurns(events, afterSequence);
|
|
583
|
+
for (const turn of thread.turns.slice(start)) {
|
|
584
|
+
const expected = [{ role: "user", text: turn.user.text }, ...turn.assistant.map((message) => ({ role: "assistant", text: message.text }))];
|
|
585
|
+
const rows = messages.slice(cursor, cursor + expected.length);
|
|
586
|
+
if (rows.length === 0)
|
|
587
|
+
break;
|
|
588
|
+
const actual = rows.map((row) => ({ role: row.payload.role, text: stringValue(row.payload.text) ?? "" }));
|
|
589
|
+
const attachments = eventAttachments(rows[0]);
|
|
590
|
+
if (JSON.stringify(actual) !== JSON.stringify(expected) || attachments === undefined || JSON.stringify(attachments) !== JSON.stringify(sourceAttachments(turn)))
|
|
591
|
+
return -1;
|
|
592
|
+
if (!settled[adopted] || settled[adopted].status !== turn.status)
|
|
593
|
+
return -1;
|
|
594
|
+
cursor += expected.length;
|
|
595
|
+
adopted += 1;
|
|
596
|
+
}
|
|
597
|
+
return cursor === messages.length ? adopted : -1;
|
|
598
|
+
}
|
|
599
|
+
function conflictResult(thread, status, warning, record) {
|
|
600
|
+
return {
|
|
601
|
+
source: thread.source, sourceId: thread.sourceSessionId, sourceKey: thread.sourceKey,
|
|
602
|
+
status, ...(record ? { threadId: record.threadId, projectId: record.projectId } : {}),
|
|
603
|
+
title: thread.title, turnsAdded: 0, turnsAdopted: 0, events: 0, attachments: 0,
|
|
604
|
+
titleAction: "unchanged", resumable: Boolean(record?.resumable), warnings: [warning],
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
function buildPlan(db, paths, conversation, dryRun) {
|
|
608
|
+
const sourceThread = conversation.threads.find((thread) => thread.currentBranch) ?? conversation.threads[0];
|
|
609
|
+
if (!sourceThread)
|
|
610
|
+
throw writeError(`Conversation ${conversation.summary.id} has no importable task.`);
|
|
611
|
+
const thread = compactThreadForImport({ ...sourceThread, turns: sourceThread.turns.filter(isTerminalTurn) });
|
|
612
|
+
if (thread.turns.length === 0 && (thread.ignoredInProgressTurns ?? 0) > 0) {
|
|
613
|
+
const result = conflictResult(thread, "not-imported", "Conversation has no terminal turns; its active turn was ignored.");
|
|
614
|
+
return { conversation, thread, result, titleEvent: false, previouslyImported: false };
|
|
615
|
+
}
|
|
616
|
+
const match = findRecord(db, paths, thread);
|
|
617
|
+
if (!match.record) {
|
|
618
|
+
const result = conflictResult(thread, "not-imported", match.stale ? "The ledger entry is stale because its T3 task is missing." : "Conversation has not been imported into this T3 database.");
|
|
619
|
+
return { conversation, thread, result, titleEvent: false, previouslyImported: match.stale };
|
|
620
|
+
}
|
|
621
|
+
const record = match.record;
|
|
622
|
+
if (isV2Database(db)) {
|
|
623
|
+
assertV2ThreadSettled(db, record.threadId);
|
|
624
|
+
const current = v2Thread(db, record.threadId);
|
|
625
|
+
if (current?.deletedAt)
|
|
626
|
+
return { conversation, thread, record, result: conflictResult(thread, "target-missing", "The V2 thread is deleted.", record), titleEvent: false, previouslyImported: true };
|
|
627
|
+
const conflict = v2SourceConflict(db, record.threadId, thread);
|
|
628
|
+
if (conflict)
|
|
629
|
+
return { conversation, thread, record, result: conflictResult(thread, "history-diverged", conflict, record), titleEvent: false, previouslyImported: true };
|
|
630
|
+
}
|
|
631
|
+
const events = readEvents(db, record.threadId);
|
|
632
|
+
if (!createEvent(events))
|
|
633
|
+
return { conversation, thread, record, result: conflictResult(thread, "target-missing", "The imported T3 task no longer exists.", record), titleEvent: false, previouslyImported: true };
|
|
634
|
+
if (thread.source === "claude" && conversation.summary.branches !== 1) {
|
|
635
|
+
return { conversation, thread, record, result: conflictResult(thread, "branch-sync-unsupported", "Claude synchronization currently requires exactly one non-sidechain leaf.", record), titleEvent: false, previouslyImported: true };
|
|
636
|
+
}
|
|
637
|
+
const nativeAdoption = record.identitySeed.startsWith(nativeSeedPrefix(record.source));
|
|
638
|
+
const bootstrapped = record.checkpoint ? undefined : bootstrapCheckpoint(thread, record, events);
|
|
639
|
+
const checkpoint = record.checkpoint ?? bootstrapped?.checkpoint;
|
|
640
|
+
const sourcePrefixLength = checkpoint
|
|
641
|
+
? verifiedSourcePrefixLength(thread, checkpoint)
|
|
642
|
+
: nativeAdoption ? nativeCodexPrefixLength(thread, events) : undefined;
|
|
643
|
+
if (sourcePrefixLength === undefined || (!checkpoint && !nativeAdoption)) {
|
|
644
|
+
return { conversation, thread, record, result: conflictResult(thread, "history-diverged", "Previously imported history is no longer an unchanged source prefix.", record), titleEvent: false, previouslyImported: true };
|
|
645
|
+
}
|
|
646
|
+
const adopted = nativeAdoption
|
|
647
|
+
? checkpoint ? nativeCodexAdoptedTurns(thread, sourcePrefixLength, events, record.lastSequence) : 0
|
|
648
|
+
: thread.source !== "claude"
|
|
649
|
+
? isV2Database(db) ? nativeCodexAdoptedTurns(thread, sourcePrefixLength, events, bootstrapped?.lastSequence ?? record.lastSequence) : codexAdoptedTurns(thread, sourcePrefixLength, events, bootstrapped?.lastSequence ?? record.lastSequence)
|
|
650
|
+
: claudeAdoptedTurns(thread, sourcePrefixLength, events, bootstrapped?.lastSequence ?? record.lastSequence);
|
|
651
|
+
if (adopted < 0)
|
|
652
|
+
return { conversation, thread, record, result: conflictResult(thread, "history-diverged", "T3 contains an ambiguous or partial provider turn suffix.", record), titleEvent: false, previouslyImported: true };
|
|
653
|
+
const newTurns = thread.turns.slice(sourcePrefixLength + adopted);
|
|
654
|
+
const t3Title = currentTitle(db, record.threadId, events);
|
|
655
|
+
const baselineTitle = record.sourceTitle || stringValue(createEvent(events)?.payload.title) || thread.title;
|
|
656
|
+
let titleAction = "unchanged";
|
|
657
|
+
const warnings = [...thread.warnings];
|
|
658
|
+
if (thread.title !== baselineTitle) {
|
|
659
|
+
if (t3Title === baselineTitle)
|
|
660
|
+
titleAction = "updated";
|
|
661
|
+
else if (t3Title !== thread.title) {
|
|
662
|
+
titleAction = "preserved-local";
|
|
663
|
+
warnings.push(`Preserved locally edited T3 title '${t3Title}' instead of provider title '${thread.title}'.`);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
const provider = existingProvider(db, thread, record, events);
|
|
667
|
+
const syncThread = { ...thread, turns: newTurns, warnings };
|
|
668
|
+
const turnOffset = nativeAdoption ? sourcePrefixLength : (checkpoint?.turns.length ?? sourcePrefixLength) + adopted;
|
|
669
|
+
const planned = planThread(syncThread, record.threadId, record.projectId, paths, provider, record.identitySeed, undefined, record.resumable && Boolean(thread.resumeCursor), conversation.fingerprint, turnOffset, false);
|
|
670
|
+
planned.checkpoint = checkpointForThread(thread);
|
|
671
|
+
if (nativeAdoption)
|
|
672
|
+
markAsHistoricalTranscript(planned);
|
|
673
|
+
if (titleAction === "updated") {
|
|
674
|
+
planned.events.push(event(record.identitySeed, `sync.title.${events.filter((row) => row.type === "thread.meta-updated").length}`, "thread", record.threadId, "thread.meta-updated", thread.updatedAt, "provider", { threadId: record.threadId, title: thread.title, updatedAt: thread.updatedAt }, { adapterKey: provider.adapterKey }));
|
|
675
|
+
}
|
|
676
|
+
const changed = planned.events.length > 0 || adopted > 0 || !record.checkpoint || record.currentSourceKey !== thread.sourceKey;
|
|
677
|
+
const status = dryRun && changed ? "dry-run" : planned.events.length > 0 || adopted > 0 ? "synced" : "up-to-date";
|
|
678
|
+
const result = {
|
|
679
|
+
source: thread.source, sourceId: thread.sourceSessionId, sourceKey: thread.sourceKey,
|
|
680
|
+
status, threadId: record.threadId, projectId: record.projectId, title: thread.title,
|
|
681
|
+
turnsAdded: newTurns.length, turnsAdopted: adopted, events: planned.events.length,
|
|
682
|
+
attachments: planned.assets.length, titleAction, resumable: planned.resumable, warnings: planned.warnings,
|
|
683
|
+
};
|
|
684
|
+
return {
|
|
685
|
+
conversation, thread, record, result, planned, checkpoint: checkpointForThread(thread),
|
|
686
|
+
titleEvent: titleAction === "updated", previouslyImported: true,
|
|
687
|
+
...(adopted > 0 && events.at(-1) ? { adoptionSequence: events.at(-1).sequence } : {}),
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
function hasConflict(status) {
|
|
691
|
+
return ["history-diverged", "branch-sync-unsupported", "target-missing", "not-imported"].includes(status);
|
|
692
|
+
}
|
|
693
|
+
/**
|
|
694
|
+
* Native Codex wrappers already exist in T3, so newly discovered Codex turns
|
|
695
|
+
* are transcript history. Emit them with T3's history-import semantics rather
|
|
696
|
+
* than replaying them as live provider turns; a turn-start request would ask
|
|
697
|
+
* the provider to execute historical work again.
|
|
698
|
+
*/
|
|
699
|
+
function markAsHistoricalTranscript(planned) {
|
|
700
|
+
planned.events = planned.events
|
|
701
|
+
.filter((row) => row.type !== "thread.turn-start-requested" && row.type !== "thread.session-set")
|
|
702
|
+
.map((row) => {
|
|
703
|
+
if (row.type === "thread.message-sent") {
|
|
704
|
+
return {
|
|
705
|
+
...row,
|
|
706
|
+
payload: { ...row.payload, turnId: null },
|
|
707
|
+
metadata: { ...row.metadata, historyImport: true },
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
if (row.type === "thread.activity-appended") {
|
|
711
|
+
const activity = row.payload.activity;
|
|
712
|
+
return {
|
|
713
|
+
...row,
|
|
714
|
+
payload: { ...row.payload, activity: { ...activity, turnId: null } },
|
|
715
|
+
metadata: { ...row.metadata, historyImport: true },
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
return row;
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
function aggregate(results, dryRun) {
|
|
722
|
+
if (dryRun && results.some((result) => result.status === "dry-run"))
|
|
723
|
+
return "dry-run";
|
|
724
|
+
const conflicts = results.some((result) => hasConflict(result.status));
|
|
725
|
+
const synced = results.some((result) => result.status === "synced");
|
|
726
|
+
if (conflicts && synced)
|
|
727
|
+
return "mixed";
|
|
728
|
+
if (conflicts)
|
|
729
|
+
return "conflict";
|
|
730
|
+
return synced ? "synced" : "up-to-date";
|
|
731
|
+
}
|
|
732
|
+
export async function inspectConversationSync(conversation, paths) {
|
|
733
|
+
const db = new Database(paths.dbPath, { readonly: true, fileMustExist: true });
|
|
734
|
+
try {
|
|
735
|
+
validateTargetDatabase(db);
|
|
736
|
+
const plan = buildPlan(db, paths, conversation, false);
|
|
737
|
+
const result = plan.result;
|
|
738
|
+
const activeOnly = plan.thread.turns.length === 0 && (plan.thread.ignoredInProgressTurns ?? 0) > 0;
|
|
739
|
+
const status = activeOnly
|
|
740
|
+
? "active-only"
|
|
741
|
+
: result.status === "not-imported" ? "new"
|
|
742
|
+
: result.status === "synced" || result.status === "dry-run" ? "syncable"
|
|
743
|
+
: result.status === "up-to-date" ? "up-to-date"
|
|
744
|
+
: result.status;
|
|
745
|
+
const newTurns = plan.thread.turns.slice(Math.max(0, plan.thread.turns.length - result.turnsAdded));
|
|
746
|
+
return {
|
|
747
|
+
sourceId: result.sourceId, status, newTurns: result.turnsAdded,
|
|
748
|
+
newCompletedTurns: newTurns.filter((turn) => turn.status === "completed").length,
|
|
749
|
+
newInterruptedTurns: newTurns.filter((turn) => turn.status === "interrupted").length,
|
|
750
|
+
newFailedTurns: newTurns.filter((turn) => turn.status === "failed").length,
|
|
751
|
+
ignoredActiveTurns: plan.thread.ignoredInProgressTurns ?? 0,
|
|
752
|
+
adoptedTurns: result.turnsAdopted, titleChanged: result.titleAction === "updated",
|
|
753
|
+
selectable: status === "new" || status === "syncable", previouslyImported: plan.previouslyImported,
|
|
754
|
+
...(result.threadId ? { threadId: result.threadId } : {}), warnings: result.warnings,
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
finally {
|
|
758
|
+
db.close();
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
export async function syncConversations(selections, paths, options) {
|
|
762
|
+
await assertT3Closed(paths);
|
|
763
|
+
const releaseLock = options.dryRun ? () => { } : acquireLock(paths);
|
|
764
|
+
const db = new Database(paths.dbPath, { readonly: options.dryRun, fileMustExist: true });
|
|
765
|
+
let backup = null;
|
|
766
|
+
let v2Committed = false;
|
|
767
|
+
const warnings = [];
|
|
768
|
+
try {
|
|
769
|
+
const schema = validateTargetDatabase(db);
|
|
770
|
+
const plans = selections.map((selection) => buildPlan(db, paths, selection.conversation, options.dryRun));
|
|
771
|
+
const writable = plans.filter((plan) => plan.planned && !hasConflict(plan.result.status));
|
|
772
|
+
const eventPlans = writable.filter((plan) => plan.planned.events.length > 0);
|
|
773
|
+
const t3Plans = writable.filter((plan) => plan.planned.events.length > 0 || plan.result.turnsAdopted > 0);
|
|
774
|
+
const v2Write = isV2Database(db) && eventPlans.length ? await prepareV2Write(db, eventPlans.map((plan) => plan.planned)) : undefined;
|
|
775
|
+
if (v2Write)
|
|
776
|
+
for (const plan of eventPlans)
|
|
777
|
+
plan.result.events = plan.planned.targetEventCount;
|
|
778
|
+
if (t3Plans.length > 0) {
|
|
779
|
+
const backlog = projectionBacklog(db);
|
|
780
|
+
if (backlog.backlog > 0 && !options.allowProjectionBacklog)
|
|
781
|
+
throw safetyError(`T3 has ${backlog.backlog} unprojected event${backlog.backlog === 1 ? "" : "s"}. Open T3 and let it finish loading before synchronizing.`);
|
|
782
|
+
const count = eventPlans.reduce((sum, plan) => sum + (plan.planned.targetEventCount ?? plan.planned.events.length), 0);
|
|
783
|
+
if (count > MAX_SAFE_IMPORT_EVENTS)
|
|
784
|
+
throw safetyError(`Synchronization would append ${count} events, exceeding the safe one-launch limit of ${MAX_SAFE_IMPORT_EVENTS}. Sync fewer conversations at a time.`);
|
|
785
|
+
}
|
|
786
|
+
if (options.dryRun)
|
|
787
|
+
return { schemaVersion: 1, status: aggregate(plans.map((plan) => plan.result), true), target: paths.dbPath, migration: schema.migration, backup: null, results: plans.map((plan) => plan.result), warnings, hasConflicts: plans.some((plan) => hasConflict(plan.result.status)) };
|
|
788
|
+
if (t3Plans.length > 0)
|
|
789
|
+
backup = createBackup(db, paths);
|
|
790
|
+
const assets = eventPlans.length > 0 ? materializeAssets(eventPlans.map((plan) => plan.planned), paths, randomUUID()) : { created: [], cleanup: () => { } };
|
|
791
|
+
const ledgerRecords = [];
|
|
792
|
+
try {
|
|
793
|
+
const v2Written = v2Write ? await v2Write.write() : undefined;
|
|
794
|
+
v2Committed = Boolean(v2Written);
|
|
795
|
+
const nextVersion = db.prepare("SELECT COALESCE(MAX(stream_version), -1) + 1 nextVersion FROM orchestration_events WHERE aggregate_kind = ? AND stream_id = ?");
|
|
796
|
+
const insert = db.prepare(`INSERT INTO orchestration_events (event_id, aggregate_kind, stream_id, stream_version, event_type, occurred_at, command_id, causation_event_id, correlation_id, actor_kind, payload_json, metadata_json) VALUES (?, ?, ?, ?, ?, ?, NULL, NULL, NULL, ?, ?, ?)`);
|
|
797
|
+
const upsertRuntime = db.prepare(`
|
|
798
|
+
INSERT INTO provider_session_runtime (thread_id, provider_name, provider_instance_id, adapter_key, runtime_mode, status, last_seen_at, resume_cursor_json, runtime_payload_json)
|
|
799
|
+
VALUES (?, ?, ?, ?, 'full-access', 'stopped', ?, ?, ?)
|
|
800
|
+
ON CONFLICT(thread_id) DO UPDATE SET provider_name=excluded.provider_name, provider_instance_id=excluded.provider_instance_id, adapter_key=excluded.adapter_key, runtime_mode=excluded.runtime_mode, status=excluded.status, last_seen_at=excluded.last_seen_at, resume_cursor_json=excluded.resume_cursor_json, runtime_payload_json=excluded.runtime_payload_json
|
|
801
|
+
`);
|
|
802
|
+
const collectRecords = () => {
|
|
803
|
+
for (const plan of writable) {
|
|
804
|
+
const record = plan.record;
|
|
805
|
+
const planned = plan.planned;
|
|
806
|
+
let first = Number.MAX_SAFE_INTEGER;
|
|
807
|
+
let last = Math.max(record.lastSequence, plan.adoptionSequence ?? record.lastSequence);
|
|
808
|
+
let version = v2Written ? 0 : nextVersion.get("thread", record.threadId).nextVersion;
|
|
809
|
+
if (v2Written) {
|
|
810
|
+
const sequence = v2Written.get(planned);
|
|
811
|
+
if (sequence) {
|
|
812
|
+
first = sequence.first;
|
|
813
|
+
last = Math.max(last, sequence.last);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
for (const row of v2Written ? [] : planned.events) {
|
|
817
|
+
const inserted = insert.run(row.eventId, row.aggregateKind, row.streamId, version++, row.type, row.occurredAt, row.actorKind, JSON.stringify(row.payload), JSON.stringify(row.metadata));
|
|
818
|
+
const sequence = Number(inserted.lastInsertRowid);
|
|
819
|
+
first = Math.min(first, sequence);
|
|
820
|
+
last = Math.max(last, sequence);
|
|
821
|
+
}
|
|
822
|
+
if (!isV2Database(db) && (planned.events.length > 0 || plan.result.turnsAdopted > 0) && planned.resumable && plan.thread.resumeCursor) {
|
|
823
|
+
const cursor = plan.thread.source === "claude" ? { threadId: record.threadId, ...plan.thread.resumeCursor } : plan.thread.resumeCursor;
|
|
824
|
+
upsertRuntime.run(record.threadId, planned.provider.providerName, planned.provider.instanceId, planned.provider.adapterKey, plan.thread.updatedAt, JSON.stringify(cursor), JSON.stringify({ cwd: plan.thread.workspace, model: planned.provider.model, activeTurnId: null, lastError: null, modelSelection: { instanceId: planned.provider.instanceId, model: planned.provider.model, options: planned.provider.options }, lastRuntimeEvent: `t3-import.sync.${plan.thread.source}`, lastRuntimeEventAt: plan.thread.updatedAt }));
|
|
825
|
+
}
|
|
826
|
+
if (planned.events.length > 0)
|
|
827
|
+
plan.result.sequence = { first, last };
|
|
828
|
+
ledgerRecords.push({
|
|
829
|
+
...record, sourceFingerprint: plan.conversation.fingerprint,
|
|
830
|
+
currentSourceKey: plan.thread.sourceKey,
|
|
831
|
+
...(plan.thread.leafId ? { sourceLeafId: plan.thread.leafId } : {}),
|
|
832
|
+
sourceTitle: plan.thread.title, checkpoint: plan.checkpoint, syncedAt: new Date().toISOString(),
|
|
833
|
+
importerVersion: IMPORTER_VERSION, migration: schema.migration,
|
|
834
|
+
lastSequence: last, backupPath: backup ?? record.backupPath, warnings: plan.result.warnings,
|
|
835
|
+
isCanonical: true,
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
};
|
|
839
|
+
if (v2Written)
|
|
840
|
+
collectRecords();
|
|
841
|
+
else
|
|
842
|
+
db.transaction(collectRecords)();
|
|
843
|
+
assets.cleanup();
|
|
844
|
+
}
|
|
845
|
+
catch (error) {
|
|
846
|
+
if (!v2Committed)
|
|
847
|
+
for (const path of assets.created)
|
|
848
|
+
rmSync(path, { force: true });
|
|
849
|
+
assets.cleanup();
|
|
850
|
+
throw error;
|
|
851
|
+
}
|
|
852
|
+
if (ledgerRecords.length) {
|
|
853
|
+
try {
|
|
854
|
+
recordImports(ledgerRecords);
|
|
855
|
+
}
|
|
856
|
+
catch (error) {
|
|
857
|
+
if (!v2Committed)
|
|
858
|
+
throw error;
|
|
859
|
+
warnings.push(`V2 synchronization committed, but the external ledger could not be updated: ${error instanceof Error ? error.message : String(error)}`);
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
const integrity = String(db.pragma("integrity_check", { simple: true }));
|
|
863
|
+
if (integrity !== "ok")
|
|
864
|
+
throw writeError(`Post-sync integrity check failed: ${integrity}`);
|
|
865
|
+
return { schemaVersion: 1, status: aggregate(plans.map((plan) => plan.result), false), target: paths.dbPath, migration: schema.migration, backup, results: plans.map((plan) => plan.result), warnings, hasConflicts: plans.some((plan) => hasConflict(plan.result.status)) };
|
|
866
|
+
}
|
|
867
|
+
catch (error) {
|
|
868
|
+
if (v2Committed)
|
|
869
|
+
throw writeError(`V2 synchronization committed, but post-write verification or bookkeeping failed: ${error instanceof Error ? error.message : String(error)}. Attachments were retained. Inspect the target and recover the external ledger before further writes.`, error);
|
|
870
|
+
if (error instanceof Error && error.name === "ImporterError")
|
|
871
|
+
throw error;
|
|
872
|
+
throw writeError(`Synchronization failed: ${error instanceof Error ? error.message : String(error)}`, error);
|
|
873
|
+
}
|
|
874
|
+
finally {
|
|
875
|
+
db.close();
|
|
876
|
+
releaseLock();
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
//# sourceMappingURL=sync.js.map
|