Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 85 additions & 4 deletions src/features/TeamCollaboration/engine/collabSessionImport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ import { buildCloudOrgSelectorValue } from "@src/features/Org2Cloud/org2CloudOrg
import { createLogger } from "@src/hooks/logger";
import { sessionsAtom } from "@src/store/session/sessionAtom/atoms";
import { recordGuestImportedSession } from "@src/store/session/sessionAtom/guestImportRegistry";
import { upsertSession } from "@src/store/session/sessionAtom/mutations";
import {
applyImportedSessionTimestamps,
upsertSession,
} from "@src/store/session/sessionAtom/mutations";
import { persistSessions } from "@src/store/session/sessionAtom/persistence";
import type {
Session,
Expand Down Expand Up @@ -459,6 +462,42 @@ async function streamIncrementalRemoteSessionToCache(
}
}

/**
* Activity time of the OWNER's session, for the imported copy's timestamps.
*
* The replay copy describes someone else's work; stamping it with the moment
* the viewer clicked made every card jump its Started / Last updated to "Now"
* on first open, reordered List/Diary around the click, and pulled an old
* session back out of the auto-archived column. Cloud metadata carries no
* creation timestamp, so `lastActivityAt` is the only source-side time we
* have — the same proxy the pre-click cloud card itself renders. Undefined
* for pre-`lastActivityAt` rows, which keep whatever local stamp they have.
*/
function readSourceActivityAt(
remoteSession: ImportRemoteSessionOptions["remoteSession"]
): string | undefined {
const lastActivityAt = remoteSession.lastActivityAt;
if (!lastActivityAt) return undefined;
return Number.isFinite(Date.parse(lastActivityAt))
? lastActivityAt
: undefined;
}

/**
* A session cannot have been created after its own last activity. Keeping the
* earlier of the two also heals rows imported before the fix above, whose
* `created_at` is the old import-click stamp.
*/
function resolveImportedCreatedAt(
existingCreatedAt: string | undefined,
activityAt: string
): string {
if (!existingCreatedAt) return activityAt;
const existingMs = Date.parse(existingCreatedAt);
if (!Number.isFinite(existingMs)) return activityAt;
return existingMs <= Date.parse(activityAt) ? existingCreatedAt : activityAt;
}

function resolveImportedSourceDisplay(
remoteSession: ImportRemoteSessionOptions["remoteSession"],
existing: Session | undefined
Expand Down Expand Up @@ -526,8 +565,21 @@ function refreshImportedSessionPresentation(
existing.orgId === importedFrom.orgId
? buildCloudOrgSelectorValue(importedFrom.orgId)
: existing.orgId;
// Same healing rationale for the source-activity timestamps: a copy imported
// before they were tracked carries the old import-click stamp, and a
// cursor-current reopen never reaches the write path that would correct it.
const activityAt = readSourceActivityAt(remoteSession);
const createdAt = activityAt
? resolveImportedCreatedAt(existing.created_at, activityAt)
: existing.created_at;
const timestampsUnchanged =
!activityAt ||
(existing.created_at === createdAt &&
existing.updated_at === activityAt &&
existing.completed_at === activityAt);
const unchanged =
existing.orgId === normalizedOrgId &&
timestampsUnchanged &&
existing.name === remoteSession.title &&
existing.repoPath === repoPath &&
existing.agentDisplayName === sourcePresentation.agentLabel &&
Expand All @@ -547,13 +599,28 @@ function refreshImportedSessionPresentation(
const refreshed: Session = {
...existing,
...(normalizedOrgId !== undefined ? { orgId: normalizedOrgId } : {}),
...(activityAt
? {
created_at: createdAt,
updated_at: activityAt,
completed_at: activityAt,
}
: {}),
name: remoteSession.title,
repoPath,
agentDisplayName: sourcePresentation.agentLabel,
agentIconId: sourcePresentation.agentIconId,
importedFrom: refreshedImportedFrom,
};
upsertSession(refreshed);
if (activityAt) {
// upsertSession pins timestamps; this row's clock is the source's.
applyImportedSessionTimestamps(existing.session_id, {
created_at: createdAt ?? activityAt,
updated_at: activityAt,
completed_at: activityAt,
});
}
recordGuestImportedSession(refreshed);
persistSessions(getInstrumentedStore().get(sessionsAtom) as Session[]);
}
Expand Down Expand Up @@ -818,6 +885,13 @@ async function importRemoteSessionInner(
try {
throwIfAborted(options.signal);
const now = new Date().toISOString();
// Source-side activity time, NOT `now`: see readSourceActivityAt.
// `importedAt` below stays `now` — that one really is about this device.
const activityAt = readSourceActivityAt(remoteSession) ?? now;
const createdAt = resolveImportedCreatedAt(
existing?.created_at,
activityAt
);
const importedFrom: SessionImportedFrom = {
orgId,
sourceSessionId: remoteSession.sourceSessionId,
Expand Down Expand Up @@ -847,9 +921,9 @@ async function importRemoteSessionInner(
const importedRow: Session = {
session_id: localSessionId,
status: "completed",
created_at: existing?.created_at ?? now,
updated_at: now,
completed_at: now,
created_at: createdAt,
updated_at: activityAt,
completed_at: activityAt,
name: remoteSession.title,
repoPath: remoteSession.repoPath,
category: "external_history",
Expand Down Expand Up @@ -909,6 +983,13 @@ async function importRemoteSessionInner(
// No await after the final abort check: the session row, guest registry
// and persisted list commit synchronously as one local critical section.
upsertSession(importedRow);
// Re-import of an existing copy: upsertSession pins timestamps against
// careless reconcile writes, but this row's clock belongs to the source.
applyImportedSessionTimestamps(localSessionId, {
created_at: createdAt,
updated_at: activityAt,
completed_at: activityAt,
});
recordGuestImportedSession(importedRow);
persistSessions(store.get(sessionsAtom) as Session[]);
} catch (error) {
Expand Down
142 changes: 142 additions & 0 deletions src/features/TeamCollaboration/engine/collabSyncEngineHelpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -809,6 +809,148 @@ describe("importRemoteSession", () => {
});
});

it("stamps the imported copy with the source's activity time, not the click", async () => {
// Regression: a fresh import stamped created_at/updated_at/completed_at
// with `now`, so opening a cloud card in Kanban flipped its Started /
// Last updated to the moment of the click and dragged the row to the top
// of List/Diary.
const client = {
getSessionEventSegments: vi.fn(async () => sealSnapshot(makeSnapshot())),
} satisfies Pick<CollabSyncBackendClient, "getSessionEventSegments">;

const result = await importRemoteSession({
client,
orgId: "org-1",
remoteSession: makeRemote({ lastActivityAt: "2026-06-01T09:30:00.000Z" }),
});

const record = (store.get(sessionsAtom) as Session[]).find(
(session) => session.session_id === result!.localSessionId
)!;
expect(record.created_at).toBe("2026-06-01T09:30:00.000Z");
expect(record.updated_at).toBe("2026-06-01T09:30:00.000Z");
expect(record.completed_at).toBe("2026-06-01T09:30:00.000Z");
// The import moment still belongs on the provenance cursor.
expect(record.importedFrom?.importedAt).not.toBe(
"2026-06-01T09:30:00.000Z"
);
});

it("falls back to the import moment when the row carries no activity time", async () => {
const client = {
getSessionEventSegments: vi.fn(async () => sealSnapshot(makeSnapshot())),
} satisfies Pick<CollabSyncBackendClient, "getSessionEventSegments">;

const result = await importRemoteSession({
client,
orgId: "org-1",
remoteSession: makeRemote({ lastActivityAt: undefined }),
});

const record = (store.get(sessionsAtom) as Session[]).find(
(session) => session.session_id === result!.localSessionId
)!;
expect(record.updated_at).toBe(record.importedFrom?.importedAt);
expect(record.created_at).toBe(record.updated_at);
});

it("heals an import-click timestamp on a refresh-only reopen", async () => {
// Copies imported before the fix above carry the click stamp, and a
// cursor-current reopen never reaches the write path — heal them here or
// they show the wrong Started / Last updated forever.
const client = {
getSessionEventSegments: vi.fn(async () => sealSnapshot(makeSnapshot())),
} satisfies Pick<CollabSyncBackendClient, "getSessionEventSegments">;
const expectedId = await deriveImportedSessionId("org-1", "remote-1");
store.set(sessionsAtom, [
{
session_id: expectedId,
status: "completed",
created_at: "2026-07-20T12:00:00.000Z",
updated_at: "2026-07-20T12:00:00.000Z",
completed_at: "2026-07-20T12:00:00.000Z",
name: "Remote session",
orgId: "cloud:org-1",
importedFrom: {
orgId: "org-1",
sourceSessionId: "remote-1",
ownerMemberId: "m2",
ownerDisplayName: "Bob",
epoch: 1,
seq: 1,
count: 1,
frozenCount: 1,
tailHash: undefined,
importedAt: "2026-07-20T12:00:00.000Z",
},
},
]);
eventStoreMock.getPersistedEvents.mockResolvedValue([
{ id: "e1" } as unknown as SessionEvent,
]);
eventStoreMock.countPersistedEvents.mockResolvedValue(1);

const result = await importRemoteSession({
client,
orgId: "org-1",
remoteSession: makeRemote({ lastActivityAt: "2026-06-01T09:30:00.000Z" }),
});

const record = (store.get(sessionsAtom) as Session[]).find(
(session) => session.session_id === expectedId
)!;
expect(result?.updated).toBe(false);
expect(client.getSessionEventSegments).not.toHaveBeenCalled();
expect(record.created_at).toBe("2026-06-01T09:30:00.000Z");
expect(record.updated_at).toBe("2026-06-01T09:30:00.000Z");
expect(record.completed_at).toBe("2026-06-01T09:30:00.000Z");
});

it("keeps a created_at that predates the source's last activity", async () => {
const client = {
getSessionEventSegments: vi.fn(async () => sealSnapshot(makeSnapshot())),
} satisfies Pick<CollabSyncBackendClient, "getSessionEventSegments">;
const expectedId = await deriveImportedSessionId("org-1", "remote-1");
store.set(sessionsAtom, [
{
session_id: expectedId,
status: "completed",
created_at: "2026-05-01T08:00:00.000Z",
updated_at: "2026-06-01T09:30:00.000Z",
completed_at: "2026-06-01T09:30:00.000Z",
name: "Remote session",
orgId: "cloud:org-1",
importedFrom: {
orgId: "org-1",
sourceSessionId: "remote-1",
ownerMemberId: "m2",
ownerDisplayName: "Bob",
epoch: 1,
seq: 1,
count: 1,
frozenCount: 1,
tailHash: undefined,
importedAt: "2026-06-01T10:00:00.000Z",
},
},
]);
eventStoreMock.getPersistedEvents.mockResolvedValue([
{ id: "e1" } as unknown as SessionEvent,
]);
eventStoreMock.countPersistedEvents.mockResolvedValue(1);

await importRemoteSession({
client,
orgId: "org-1",
remoteSession: makeRemote({ lastActivityAt: "2026-06-01T09:30:00.000Z" }),
});

const record = (store.get(sessionsAtom) as Session[]).find(
(session) => session.session_id === expectedId
)!;
expect(record.created_at).toBe("2026-05-01T08:00:00.000Z");
});

it("stamps Session.orgId on a MEMBER import so the sidebar org filter matches", async () => {
const client = {
getSessionEventSegments: vi.fn(async () => sealSnapshot(makeSnapshot())),
Expand Down
Loading
Loading