Skip to content
This repository was archived by the owner on Jun 8, 2026. It is now read-only.

Commit 013df19

Browse files
authored
Merge pull request #277 from closedloop-ai/fix-backfill-source-timestamps
FEA-1564: Backdate backfill timestamps
2 parents 34a3830 + 7ca33f2 commit 013df19

2 files changed

Lines changed: 293 additions & 21 deletions

File tree

apps/desktop/src/main/collectors/import-session.ts

Lines changed: 68 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,13 @@ import type { Harness, NormalizedSession, NormalizedToolUse } from "./types.js";
1111
*
1212
* Idempotency (FEA-1503 AC): re-import adds nothing new.
1313
* - session row: COALESCE-fill on conflict, never clobbers a live row.
14+
* `updated_at` stays an ingest-time mutation cursor for cloud sync; source
15+
* dates live on `started_at`, `ended_at`, events, and token usage analytics.
1416
* - events: per-(session, event_type) high-water-mark on `created_at` — only
15-
* events with a transcript timestamp strictly greater than the stored max are
16-
* inserted (the exact vendor mechanism). Hook-written events carry
17-
* `created_at ≈ now`, so file events with past transcript timestamps fall under
18-
* the high-water-mark and are never double-counted against the live hook path.
17+
* events with a source timestamp strictly greater than the stored max are
18+
* inserted. Backfill never stamps events with importer runtime `now`; when an
19+
* individual source event has no timestamp, it falls back to the session's
20+
* source timestamp so date windows remain tied to when work occurred.
1921
* - tokens: `tokenUsage.replace` nets zero when re-applying equal cumulatives.
2022
*
2123
* Each session is applied in one `BEGIN IMMEDIATE` transaction (mirrors
@@ -81,10 +83,12 @@ export function createImporter(db: DatabaseSync, deps: ImporterDeps): Importer {
8183
cwd = COALESCE(cwd, ?),
8284
harness = CASE WHEN COALESCE(harness, '') = '' THEN ? ELSE harness END,
8385
billing_mode = CASE WHEN COALESCE(billing_mode, '') IN ('', 'unknown') THEN ? ELSE billing_mode END,
84-
metadata = ?,
85-
updated_at = ?
86+
metadata = ?
8687
WHERE id = ?
8788
`);
89+
const touchSessionStmt = db.prepare(
90+
"UPDATE sessions SET updated_at = CASE WHEN updated_at IS NULL OR updated_at < ? THEN ? ELSE updated_at END WHERE id = ?",
91+
);
8892
const reactivateSessionStmt = db.prepare(
8993
"UPDATE sessions SET status = 'active', ended_at = NULL, updated_at = ? WHERE id = ?",
9094
);
@@ -114,11 +118,19 @@ export function createImporter(db: DatabaseSync, deps: ImporterDeps): Importer {
114118
return `${sessionId}-main`;
115119
}
116120

117-
function isRecentlyActive(session: NormalizedSession, nowMs: number): boolean {
121+
function isRecentlyActive(
122+
session: NormalizedSession,
123+
nowMs: number,
124+
sourceUpdatedAt: string,
125+
): boolean {
126+
const sourceUpdatedAtMs = Date.parse(sourceUpdatedAt);
118127
return (
119128
session.fileModifiedAt != null &&
120129
Number.isFinite(session.fileModifiedAt) &&
121-
nowMs - session.fileModifiedAt < RECENT_ACTIVITY_MS
130+
nowMs - session.fileModifiedAt < RECENT_ACTIVITY_MS &&
131+
Number.isFinite(sourceUpdatedAtMs) &&
132+
sourceUpdatedAtMs <= nowMs &&
133+
nowMs - sourceUpdatedAtMs < RECENT_ACTIVITY_MS
122134
);
123135
}
124136

@@ -173,6 +185,29 @@ export function createImporter(db: DatabaseSync, deps: ImporterDeps): Importer {
173185
);
174186
}
175187

188+
function sessionSourceUpdatedAt(session: NormalizedSession, startedAt: string): string {
189+
let latest = startedAt;
190+
let latestMs = Date.parse(startedAt);
191+
const consider = (value: string | null | undefined): void => {
192+
if (!value) return;
193+
const ms = Date.parse(value);
194+
if (!Number.isFinite(ms)) return;
195+
if (!Number.isFinite(latestMs) || ms > latestMs) {
196+
latest = value;
197+
latestMs = ms;
198+
}
199+
};
200+
201+
consider(session.endedAt);
202+
for (const ts of session.messageTimestamps ?? []) consider(ts);
203+
for (const toolUse of session.toolUses ?? []) consider(toolUse.timestamp);
204+
for (const duration of session.turnDurations ?? []) consider(duration.timestamp);
205+
for (const error of session.apiErrors ?? []) consider(error.timestamp);
206+
for (const error of session.toolResultErrors ?? []) consider(error.timestamp);
207+
208+
return latest;
209+
}
210+
176211
function importSession(session: NormalizedSession, harness: Harness): ImportResult {
177212
if (typeof session.sessionId !== "string" || session.sessionId.length === 0) {
178213
return { skipped: true, reactivated: false };
@@ -181,11 +216,14 @@ export function createImporter(db: DatabaseSync, deps: ImporterDeps): Importer {
181216
return { skipped: true, reactivated: false };
182217
}
183218

219+
const startedAt = session.startedAt;
184220
const now = nowFn();
221+
const sourceUpdatedAt = sessionSourceUpdatedAt(session, startedAt);
185222
const nowMs = Date.parse(now);
186223
const recentlyActive = isRecentlyActive(
187224
session,
188225
Number.isNaN(nowMs) ? Date.now() : nowMs,
226+
sourceUpdatedAt,
189227
);
190228
const mainId = mainAgentId(session.sessionId);
191229

@@ -205,9 +243,9 @@ export function createImporter(db: DatabaseSync, deps: ImporterDeps): Importer {
205243
status,
206244
session.cwd ?? null,
207245
session.model ?? null,
208-
session.startedAt,
209-
session.endedAt ?? session.startedAt,
210-
status === "completed" ? session.endedAt ?? null : null,
246+
startedAt,
247+
now,
248+
status === "completed" ? sourceUpdatedAt : null,
211249
harness,
212250
billingMode,
213251
buildMetadata(session, harness),
@@ -223,9 +261,9 @@ export function createImporter(db: DatabaseSync, deps: ImporterDeps): Importer {
223261
status === "completed" ? "completed" : "waiting",
224262
null,
225263
null,
226-
session.startedAt,
264+
startedAt,
227265
now,
228-
status === "completed" ? session.endedAt ?? now : null,
266+
status === "completed" ? sourceUpdatedAt : null,
229267
null,
230268
null,
231269
);
@@ -238,7 +276,6 @@ export function createImporter(db: DatabaseSync, deps: ImporterDeps): Importer {
238276
harness,
239277
billingMode,
240278
buildMetadata(session, harness),
241-
now,
242279
session.sessionId,
243280
);
244281
const isLive = existing.status === "active" && existing.ended_at == null;
@@ -261,6 +298,7 @@ export function createImporter(db: DatabaseSync, deps: ImporterDeps): Importer {
261298
}
262299

263300
let inserted = 0;
301+
let namelessEventCounter = 0;
264302
const addEvent = (
265303
eventType: string,
266304
agentId: string,
@@ -269,9 +307,14 @@ export function createImporter(db: DatabaseSync, deps: ImporterDeps): Importer {
269307
summary: string | null,
270308
data: string | null,
271309
): void => {
272-
if (!ts) return;
310+
const eventTimestamp = ts ?? (() => {
311+
// Synthetic increment to distinguish no-timestamp events in the same
312+
// batch so they don't all collide on the high-water-mark dedup.
313+
const base = Date.parse(sourceUpdatedAt);
314+
return new Date(base + namelessEventCounter++).toISOString();
315+
})();
273316
const prev = highWater.get(eventType);
274-
if (prev != null && ts <= prev) return;
317+
if (prev != null && eventTimestamp <= prev) return;
275318
insertEventStmt.run(
276319
randomUUID(),
277320
session.sessionId,
@@ -280,7 +323,7 @@ export function createImporter(db: DatabaseSync, deps: ImporterDeps): Importer {
280323
toolName,
281324
summary,
282325
data,
283-
ts,
326+
eventTimestamp,
284327
);
285328
inserted++;
286329
};
@@ -312,9 +355,9 @@ export function createImporter(db: DatabaseSync, deps: ImporterDeps): Importer {
312355
subagentName(tu),
313356
strOf(input.subagent_type) ?? null,
314357
prompt ? prompt.slice(0, 500) : null,
315-
tu.timestamp ?? session.startedAt,
316-
now,
317-
tu.timestamp ?? session.endedAt ?? now,
358+
tu.timestamp ?? startedAt,
359+
tu.timestamp ?? now,
360+
tu.timestamp ?? sourceUpdatedAt,
318361
mainId,
319362
);
320363
addEvent("PreToolUse", subId, tu.timestamp, tu.name, "Spawned subagent", eventData(enrichedData));
@@ -335,7 +378,11 @@ export function createImporter(db: DatabaseSync, deps: ImporterDeps): Importer {
335378

336379
// ── Tokens (store reconciles raw/effective; idempotent on equal counts) ───
337380
for (const [model, counts] of Object.entries(session.tokensByModel ?? {})) {
338-
deps.tokenUsage.replace(session.sessionId, model, counts, now);
381+
deps.tokenUsage.replace(session.sessionId, model, counts, sourceUpdatedAt);
382+
}
383+
384+
if (existing != null && inserted > 0 && !reactivated) {
385+
touchSessionStmt.run(now, now, session.sessionId);
339386
}
340387

341388
db.exec("COMMIT");

0 commit comments

Comments
 (0)