baa-conductor

git clone 

commit
3a5a93d
parent
684fcce
author
codex@macbookpro
date
2026-03-31 15:57:27 +0800 CST
fix: avoid silent renewal link scan truncation
6 files changed,  +496, -62
M apps/conductor-daemon/src/index.test.js
+145, -0
  1@@ -7049,6 +7049,151 @@ test("observeRenewalConversation gives targetId absolute priority over weaker pa
  2   }
  3 });
  4 
  5+test("observeRenewalConversation paginates exact-match scans and reports diagnostics at the scan limit", async () => {
  6+  const rootDir = mkdtempSync(join(tmpdir(), "baa-conductor-renewal-scan-limit-"));
  7+  const stateDir = join(rootDir, "state");
  8+  const store = new ArtifactStore({
  9+    artifactDir: join(stateDir, ARTIFACTS_DIRNAME),
 10+    databasePath: join(stateDir, ARTIFACT_DB_FILENAME)
 11+  });
 12+  const observedAt = Date.UTC(2026, 2, 30, 12, 8, 0);
 13+  const diagnostics = [];
 14+
 15+  try {
 16+    for (let index = 0; index < 50; index += 1) {
 17+      const localConversationId = `lc-scan-limit-decoy-${index}`;
 18+      const linkId = `link-scan-limit-decoy-${index}`;
 19+      const decoyObservedAt = observedAt + 50_000 - index;
 20+
 21+      await store.upsertLocalConversation({
 22+        localConversationId,
 23+        platform: "chatgpt"
 24+      });
 25+      await store.upsertConversationLink({
 26+        clientId: "firefox-chatgpt",
 27+        createdAt: decoyObservedAt,
 28+        isActive: true,
 29+        linkId,
 30+        localConversationId,
 31+        observedAt: decoyObservedAt,
 32+        pageTitle: `Decoy Thread ${index}`,
 33+        pageUrl: `https://chatgpt.com/c/conv-scan-limit-decoy-${index}`,
 34+        platform: "chatgpt",
 35+        remoteConversationId: `conv-scan-limit-decoy-${index}`,
 36+        routePath: `/c/conv-scan-limit-decoy-${index}`,
 37+        routePattern: "/c/:conversationId",
 38+        targetId: "tab:1",
 39+        targetKind: "browser.proxy_delivery",
 40+        targetPayload: {
 41+          clientId: "firefox-chatgpt",
 42+          tabId: 1
 43+        },
 44+        updatedAt: decoyObservedAt
 45+      });
 46+    }
 47+
 48+    await store.upsertLocalConversation({
 49+      localConversationId: "lc-scan-limit-target",
 50+      platform: "chatgpt"
 51+    });
 52+    await store.upsertConversationLink({
 53+      clientId: "firefox-chatgpt",
 54+      createdAt: observedAt - 60_000,
 55+      isActive: true,
 56+      linkId: "link-scan-limit-target",
 57+      localConversationId: "lc-scan-limit-target",
 58+      observedAt: observedAt - 60_000,
 59+      pageTitle: "Shared Thread",
 60+      pageUrl: "https://chatgpt.com/c/conv-scan-limit-target",
 61+      platform: "chatgpt",
 62+      remoteConversationId: "conv-scan-limit-target-existing",
 63+      routePath: "/c/conv-scan-limit-target",
 64+      routePattern: "/c/:conversationId",
 65+      targetId: "tab:1",
 66+      targetKind: "browser.proxy_delivery",
 67+      targetPayload: {
 68+        clientId: "firefox-chatgpt",
 69+        pageUrl: "https://chatgpt.com/c/conv-scan-limit-target",
 70+        tabId: 1
 71+      },
 72+      updatedAt: observedAt - 60_000
 73+    });
 74+
 75+    const observation = await observeRenewalConversation({
 76+      assistantMessageId: "msg-scan-limit",
 77+      clientId: "firefox-chatgpt",
 78+      onDiagnostic: (diagnostic) => diagnostics.push(diagnostic),
 79+      observedAt,
 80+      pageTitle: "Shared Thread",
 81+      pageUrl: "https://chatgpt.com/c/conv-scan-limit-target",
 82+      platform: "chatgpt",
 83+      route: {
 84+        assistantMessageId: "msg-scan-limit",
 85+        conversationId: null,
 86+        observedAt,
 87+        organizationId: null,
 88+        pageTitle: "Shared Thread",
 89+        pageUrl: "https://chatgpt.com/c/conv-scan-limit-target",
 90+        platform: "chatgpt",
 91+        shellPage: false,
 92+        tabId: 1
 93+      },
 94+      store
 95+    });
 96+
 97+    assert.equal(observation.created, false);
 98+    assert.equal(observation.resolvedBy, "active_link");
 99+    assert.equal(observation.conversation.localConversationId, "lc-scan-limit-target");
100+    assert.equal(observation.link.linkId, "link-scan-limit-target");
101+
102+    const links = await store.listConversationLinks({
103+      clientId: "firefox-chatgpt",
104+      limit: 100,
105+      platform: "chatgpt",
106+      targetId: "tab:1"
107+    });
108+    assert.equal(links.length, 51);
109+    assert.equal(
110+      links.filter((link) => link.isActive).length,
111+      1
112+    );
113+    assert.equal(
114+      links.find((link) => link.linkId === "link-scan-limit-target")?.isActive,
115+      true
116+    );
117+    assert.equal(
118+      links.filter((link) => link.linkId !== "link-scan-limit-target" && link.isActive).length,
119+      0
120+    );
121+    assert.deepEqual(diagnostics, [
122+      {
123+        clientId: "firefox-chatgpt",
124+        code: "conversation_link_scan_limit_reached",
125+        limit: 50,
126+        offset: 0,
127+        operation: "resolve",
128+        platform: "chatgpt",
129+        signal: "target_id"
130+      },
131+      {
132+        clientId: "firefox-chatgpt",
133+        code: "conversation_link_scan_limit_reached",
134+        limit: 50,
135+        offset: 0,
136+        operation: "deactivate",
137+        platform: "chatgpt",
138+        signal: "target_id"
139+      }
140+    ]);
141+  } finally {
142+    store.close();
143+    rmSync(rootDir, {
144+      force: true,
145+      recursive: true
146+    });
147+  }
148+});
149+
150 test("ConductorRuntime persists renewal conversation links from browser.final_message and exposes renewal controls", async () => {
151   const stateDir = mkdtempSync(join(tmpdir(), "baa-conductor-renewal-control-"));
152   const runtime = new ConductorRuntime(
M apps/conductor-daemon/src/renewal/conversations.ts
+197, -22
  1@@ -17,6 +17,7 @@ const DEFAULT_LINK_SCAN_LIMIT = 50;
  2 export interface ObserveRenewalConversationInput {
  3   assistantMessageId: string;
  4   clientId?: string | null;
  5+  onDiagnostic?: RenewalConversationDiagnosticSink;
  6   observedAt: number;
  7   pageTitle?: string | null;
  8   pageUrl?: string | null;
  9@@ -67,6 +68,28 @@ interface ConversationLinkScore {
 10   weakSignalScore: number;
 11 }
 12 
 13+type RenewalConversationDiagnosticOperation = "deactivate" | "resolve";
 14+type RenewalConversationDiagnosticSignal = "page_title" | "page_url" | "route_path" | "target_id";
 15+type ConversationLinkQueryField = "pageTitle" | "pageUrl" | "routePath" | "targetId";
 16+
 17+interface ConversationLinkCandidateQuery {
 18+  field: ConversationLinkQueryField;
 19+  signal: RenewalConversationDiagnosticSignal;
 20+  value: string | null;
 21+}
 22+
 23+export interface RenewalConversationDiagnostic {
 24+  code: "conversation_link_scan_limit_reached";
 25+  clientId: string | null;
 26+  limit: number;
 27+  offset: number;
 28+  operation: RenewalConversationDiagnosticOperation;
 29+  platform: string;
 30+  signal: RenewalConversationDiagnosticSignal;
 31+}
 32+
 33+export type RenewalConversationDiagnosticSink = (diagnostic: RenewalConversationDiagnostic) => void;
 34+
 35 export async function getRenewalConversationDetail(
 36   store: ArtifactStore,
 37   localConversationId: string,
 38@@ -126,8 +149,10 @@ export async function observeRenewalConversation(
 39     remoteConversationId: input.remoteConversationId,
 40     route: input.route ?? null
 41   });
 42+  const onDiagnostic = input.onDiagnostic ?? null;
 43   const existingResolution = await resolveExistingConversationLink(input.store, {
 44     clientId,
 45+    onDiagnostic,
 46     observedRoute,
 47     platform
 48   });
 49@@ -143,6 +168,7 @@ export async function observeRenewalConversation(
 50     clientId,
 51     keepLinkId: existingResolution.link?.linkId ?? null,
 52     localConversationId: conversation.localConversationId,
 53+    onDiagnostic,
 54     observedAt,
 55     observedRoute,
 56     platform
 57@@ -233,22 +259,35 @@ async function deactivateConflictingConversationLinks(
 58     clientId: string | null;
 59     keepLinkId: string | null;
 60     localConversationId: string;
 61+    onDiagnostic: RenewalConversationDiagnosticSink | null;
 62     observedAt: number;
 63     observedRoute: NormalizedObservedRoute;
 64     platform: string;
 65   }
 66 ): Promise<void> {
 67-  const filters: ListConversationLinksOptions = {
 68-    isActive: true,
 69-    limit: DEFAULT_LINK_SCAN_LIMIT,
 70-    platform: input.platform
 71-  };
 72-
 73-  if (input.clientId != null) {
 74-    filters.clientId = input.clientId;
 75-  }
 76-
 77-  const activeLinks = await store.listConversationLinks(filters);
 78+  const activeLinks = await collectConversationLinkCandidates(store, {
 79+    clientId: input.clientId,
 80+    onDiagnostic: input.onDiagnostic,
 81+    operation: "deactivate",
 82+    platform: input.platform,
 83+    queries: [
 84+      {
 85+        field: "targetId",
 86+        signal: "target_id",
 87+        value: input.observedRoute.targetId
 88+      },
 89+      {
 90+        field: "pageUrl",
 91+        signal: "page_url",
 92+        value: input.observedRoute.pageUrl
 93+      },
 94+      {
 95+        field: "routePath",
 96+        signal: "route_path",
 97+        value: input.observedRoute.routePath
 98+      }
 99+    ]
100+  });
101 
102   for (const candidate of activeLinks) {
103     if (candidate.linkId === input.keepLinkId || candidate.isActive !== true) {
104@@ -488,6 +527,7 @@ async function resolveExistingConversationLink(
105   store: ArtifactStore,
106   input: {
107     clientId: string | null;
108+    onDiagnostic: RenewalConversationDiagnosticSink | null;
109     observedRoute: NormalizedObservedRoute;
110     platform: string;
111   }
112@@ -518,17 +558,34 @@ async function resolveExistingConversationLink(
113     };
114   }
115 
116-  const filters: ListConversationLinksOptions = {
117-    isActive: true,
118-    limit: DEFAULT_LINK_SCAN_LIMIT,
119-    platform: input.platform
120-  };
121-
122-  if (input.clientId != null) {
123-    filters.clientId = input.clientId;
124-  }
125-
126-  const candidates = await store.listConversationLinks(filters);
127+  const candidates = await collectConversationLinkCandidates(store, {
128+    clientId: input.clientId,
129+    onDiagnostic: input.onDiagnostic,
130+    operation: "resolve",
131+    platform: input.platform,
132+    queries: [
133+      {
134+        field: "targetId",
135+        signal: "target_id",
136+        value: input.observedRoute.targetId
137+      },
138+      {
139+        field: "pageUrl",
140+        signal: "page_url",
141+        value: input.observedRoute.pageUrl
142+      },
143+      {
144+        field: "routePath",
145+        signal: "route_path",
146+        value: input.observedRoute.routePath
147+      },
148+      {
149+        field: "pageTitle",
150+        signal: "page_title",
151+        value: input.observedRoute.pageTitle
152+      }
153+    ]
154+  });
155   let bestLink: ConversationLinkRecord | null = null;
156   let bestScore: ConversationLinkScore | null = null;
157 
158@@ -555,6 +612,124 @@ async function resolveExistingConversationLink(
159   };
160 }
161 
162+async function collectConversationLinkCandidates(
163+  store: ArtifactStore,
164+  input: {
165+    clientId: string | null;
166+    onDiagnostic: RenewalConversationDiagnosticSink | null;
167+    operation: RenewalConversationDiagnosticOperation;
168+    platform: string;
169+    queries: ConversationLinkCandidateQuery[];
170+  }
171+): Promise<ConversationLinkRecord[]> {
172+  const candidates = new Map<string, ConversationLinkRecord>();
173+
174+  for (const query of input.queries) {
175+    if (query.value == null) {
176+      continue;
177+    }
178+
179+    let offset = 0;
180+    let warned = false;
181+
182+    while (true) {
183+      const page = await store.listConversationLinks(
184+        buildConversationLinkCandidateFilters(input.clientId, input.platform, query, offset)
185+      );
186+
187+      for (const candidate of page) {
188+        candidates.set(candidate.linkId, candidate);
189+      }
190+
191+      if (page.length < DEFAULT_LINK_SCAN_LIMIT) {
192+        break;
193+      }
194+
195+      if (!warned) {
196+        reportConversationLinkScanLimitReached({
197+          clientId: input.clientId,
198+          limit: DEFAULT_LINK_SCAN_LIMIT,
199+          offset,
200+          onDiagnostic: input.onDiagnostic,
201+          operation: input.operation,
202+          platform: input.platform,
203+          signal: query.signal
204+        });
205+        warned = true;
206+      }
207+
208+      offset += page.length;
209+    }
210+  }
211+
212+  return [...candidates.values()];
213+}
214+
215+function buildConversationLinkCandidateFilters(
216+  clientId: string | null,
217+  platform: string,
218+  query: ConversationLinkCandidateQuery,
219+  offset: number
220+): ListConversationLinksOptions {
221+  const filters: ListConversationLinksOptions = {
222+    isActive: true,
223+    limit: DEFAULT_LINK_SCAN_LIMIT,
224+    offset,
225+    platform
226+  };
227+
228+  if (clientId != null) {
229+    filters.clientId = clientId;
230+  }
231+
232+  switch (query.field) {
233+    case "pageTitle":
234+      filters.pageTitle = query.value ?? undefined;
235+      return filters;
236+    case "pageUrl":
237+      filters.pageUrl = query.value ?? undefined;
238+      return filters;
239+    case "routePath":
240+      filters.routePath = query.value ?? undefined;
241+      return filters;
242+    case "targetId":
243+      filters.targetId = query.value ?? undefined;
244+      return filters;
245+  }
246+}
247+
248+function reportConversationLinkScanLimitReached(input: {
249+  clientId: string | null;
250+  limit: number;
251+  offset: number;
252+  onDiagnostic: RenewalConversationDiagnosticSink | null;
253+  operation: RenewalConversationDiagnosticOperation;
254+  platform: string;
255+  signal: RenewalConversationDiagnosticSignal;
256+}): void {
257+  const diagnostic: RenewalConversationDiagnostic = {
258+    clientId: input.clientId,
259+    code: "conversation_link_scan_limit_reached",
260+    limit: input.limit,
261+    offset: input.offset,
262+    operation: input.operation,
263+    platform: input.platform,
264+    signal: input.signal
265+  };
266+
267+  if (input.onDiagnostic != null) {
268+    input.onDiagnostic(diagnostic);
269+    return;
270+  }
271+
272+  console.warn(
273+    `[baa-renewal] conversation link scan limit reached `
274+      + `(operation=${input.operation} signal=${input.signal} `
275+      + `platform=${input.platform} clientId=${input.clientId ?? "any"} `
276+      + `limit=${input.limit} offset=${input.offset}); continuing with pagination.`
277+  );
278+}
279+
280 function compareConversationLinkScores(
281   candidateScore: ConversationLinkScore,
282   bestScore: ConversationLinkScore,
A bugs/archive/FIX-BUG-031.md
+36, -0
 1@@ -0,0 +1,36 @@
 2+# FIX-BUG-031: conversation link 扫描上限不再静默截断
 3+
 4+## 执行状态
 5+
 6+- 已完成(2026-03-31,代码 + 自动化验证已落地)
 7+
 8+## 关联 Bug
 9+
10+BUG-031-link-scan-limit-silent-truncation.md
11+
12+## 实际修改文件
13+
14+- `apps/conductor-daemon/src/renewal/conversations.ts`
15+- `apps/conductor-daemon/src/index.test.js`
16+- `packages/artifact-db/src/store.ts`
17+- `packages/artifact-db/src/types.ts`
18+- `packages/artifact-db/src/index.test.js`
19+
20+## 实际修改
21+
22+- 不再按 `platform + clientId` 粗扫前 50 条 active link,再在内存里筛候选。
23+- `resolveExistingConversationLink` 现在按 `targetId`、`pageUrl`、`routePath`、`pageTitle` 做精确候选查询,并在单页打满时继续分页,避免旧实现只看前 50 条导致漏选。
24+- `deactivateConflictingConversationLinks` 现在按 `targetId`、`pageUrl`、`routePath` 精确收集冲突候选,并在更新前先把候选页读全,避免 scan limit 命中时漏停用。
25+- 当任一候选查询页命中 `DEFAULT_LINK_SCAN_LIMIT = 50` 时,会发出 `conversation_link_scan_limit_reached` 诊断;默认走 `console.warn`,调用方也可以注入 sink 收集结构化诊断。
26+- 顺手修正了 `ArtifactStore.upsertConversationLink` 返回值与落库记录在 `createdAt` 上不一致的问题,保证重用已有 link 时返回值和数据库一致。
27+
28+## 验收标准
29+
30+1. active link 总量较多时,conversation link 的 resolve / deactivate 不再因为前 50 条截断而静默失败。
31+2. 候选查询命中单页上限时,日志里会留下明确的 `conversation_link_scan_limit_reached` warn/diagnostic。
32+3. 命中 scan limit 的回归测试可以证明:
33+   - resolve 能跨页找到真正的最佳 link
34+   - deactivate 能跨页停用其余冲突 link
35+4. 自动化验证通过:
36+   - `pnpm -C /Users/george/code/baa-conductor-bug-031/packages/artifact-db test`
37+   - `pnpm -C /Users/george/code/baa-conductor-bug-031/apps/conductor-daemon test`
M packages/artifact-db/src/index.test.js
+75, -0
 1@@ -537,6 +537,81 @@ test("ArtifactStore findConversationLinkByRemoteConversation only returns active
 2   }
 3 });
 4 
 5+test("ArtifactStore listConversationLinks supports exact renewal identity filters", async () => {
 6+  const rootDir = mkdtempSync(join(tmpdir(), "artifact-db-link-filter-test-"));
 7+  const stateDir = join(rootDir, "state");
 8+  const databasePath = join(stateDir, ARTIFACT_DB_FILENAME);
 9+  const artifactDir = join(stateDir, ARTIFACTS_DIRNAME);
10+  const store = new ArtifactStore({
11+    artifactDir,
12+    databasePath
13+  });
14+  const observedAt = Date.UTC(2026, 2, 30, 8, 5, 0);
15+
16+  try {
17+    await store.upsertLocalConversation({
18+      localConversationId: "lc_filter_1",
19+      platform: "chatgpt"
20+    });
21+    await store.upsertLocalConversation({
22+      localConversationId: "lc_filter_2",
23+      platform: "chatgpt"
24+    });
25+
26+    const matchingLink = await store.upsertConversationLink({
27+      clientId: "firefox-chatgpt",
28+      linkId: "link_filter_match",
29+      localConversationId: "lc_filter_1",
30+      observedAt,
31+      pageTitle: "Target Thread",
32+      pageUrl: "https://chatgpt.com/c/conv-filter-match",
33+      platform: "chatgpt",
34+      remoteConversationId: "conv-filter-match",
35+      routePath: "/c/conv-filter-match",
36+      routePattern: "/c/:conversationId",
37+      targetId: "tab:7",
38+      updatedAt: observedAt
39+    });
40+    await store.upsertConversationLink({
41+      clientId: "firefox-chatgpt",
42+      linkId: "link_filter_other",
43+      localConversationId: "lc_filter_2",
44+      observedAt: observedAt + 1_000,
45+      pageTitle: "Other Thread",
46+      pageUrl: "https://chatgpt.com/c/conv-filter-other",
47+      platform: "chatgpt",
48+      remoteConversationId: "conv-filter-other",
49+      routePath: "/c/conv-filter-other",
50+      routePattern: "/c/:conversationId",
51+      targetId: "tab:8",
52+      updatedAt: observedAt + 1_000
53+    });
54+
55+    assert.deepEqual(
56+      await store.listConversationLinks({ pageTitle: "Target Thread", platform: "chatgpt" }),
57+      [matchingLink]
58+    );
59+    assert.deepEqual(
60+      await store.listConversationLinks({ pageUrl: "https://chatgpt.com/c/conv-filter-match", platform: "chatgpt" }),
61+      [matchingLink]
62+    );
63+    assert.deepEqual(
64+      await store.listConversationLinks({ platform: "chatgpt", routePath: "/c/conv-filter-match" }),
65+      [matchingLink]
66+    );
67+    assert.deepEqual(
68+      await store.listConversationLinks({ platform: "chatgpt", targetId: "tab:7" }),
69+      [matchingLink]
70+    );
71+  } finally {
72+    store.close();
73+    rmSync(rootDir, {
74+      force: true,
75+      recursive: true
76+    });
77+  }
78+});
79+
80 test("ArtifactStore reuses the same null-remote conversation link for repeated route upserts", async () => {
81   const rootDir = mkdtempSync(join(tmpdir(), "artifact-db-null-remote-upsert-test-"));
82   const stateDir = join(rootDir, "state");
M packages/artifact-db/src/store.ts
+39, -40
 1@@ -780,47 +780,46 @@ export class ArtifactStore {
 2   async listConversationLinks(
 3     options: ListConversationLinksOptions = {}
 4   ): Promise<ConversationLinkRecord[]> {
 5-    const conditions: string[] = [];
 6-    const params: Array<number | string | null> = [];
 7-
 8-    if (options.localConversationId != null) {
 9-      conditions.push("local_conversation_id = ?");
10-      params.push(options.localConversationId);
11-    }
12-
13-    if (options.platform != null) {
14-      conditions.push("platform = ?");
15-      params.push(options.platform);
16-    }
17-
18-    if (options.remoteConversationId != null) {
19-      conditions.push("remote_conversation_id = ?");
20-      params.push(options.remoteConversationId);
21-    }
22-
23-    if (options.clientId != null) {
24-      conditions.push("client_id = ?");
25-      params.push(options.clientId);
26-    }
27-
28-    if (options.isActive != null) {
29-      conditions.push("is_active = ?");
30-      params.push(options.isActive ? 1 : 0);
31-    }
32+    const query = [
33+      "SELECT * FROM conversation_links",
34+      buildWhereClause(
35+        [
36+          buildCondition("local_conversation_id", options.localConversationId),
37+          buildCondition("platform", options.platform),
38+          buildCondition("remote_conversation_id", options.remoteConversationId),
39+          buildCondition("client_id", options.clientId),
40+          buildCondition("page_title", options.pageTitle),
41+          buildCondition("page_url", options.pageUrl),
42+          buildCondition("route_path", options.routePath),
43+          buildCondition("target_id", options.targetId),
44+          options.isActive == null ? null : "is_active = ?"
45+        ],
46+        "AND"
47+      ),
48+      "ORDER BY observed_at DESC, updated_at DESC",
49+      "LIMIT ?",
50+      "OFFSET ?"
51+    ]
52+      .filter(Boolean)
53+      .join(" ");
54 
55     const rows = this.getRows<ConversationLinkRow>(
56-      [
57-        "SELECT * FROM conversation_links",
58-        conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "",
59-        "ORDER BY observed_at DESC, updated_at DESC",
60-        "LIMIT ?",
61-        "OFFSET ?"
62-      ]
63-        .filter(Boolean)
64-        .join(" "),
65-      ...params,
66-      normalizeLimit(options.limit),
67-      normalizeOffset(options.offset)
68+      query,
69+      ...buildQueryParams(
70+        [
71+          options.localConversationId ?? undefined,
72+          options.platform ?? undefined,
73+          options.remoteConversationId ?? undefined,
74+          options.clientId ?? undefined,
75+          options.pageTitle ?? undefined,
76+          options.pageUrl ?? undefined,
77+          options.routePath ?? undefined,
78+          options.targetId ?? undefined,
79+          options.isActive == null ? undefined : (options.isActive ? 1 : 0)
80+        ],
81+        normalizeLimit(options.limit),
82+        normalizeOffset(options.offset)
83+      )
84     );
85 
86     return rows.map(mapConversationLinkRow);
87@@ -1504,7 +1503,7 @@ function buildConversationLinkRecord(
88   input: UpsertConversationLinkInput,
89   existing: ConversationLinkRecord | null
90 ): ConversationLinkRecord {
91-  const createdAt = input.createdAt ?? existing?.createdAt ?? Date.now();
92+  const createdAt = existing?.createdAt ?? input.createdAt ?? Date.now();
93 
94   return {
95     clientId: mergeOptionalString(input.clientId, existing?.clientId ?? null),
M packages/artifact-db/src/types.ts
+4, -0
 1@@ -294,8 +294,12 @@ export interface ListConversationLinksOptions {
 2   limit?: number;
 3   localConversationId?: string;
 4   offset?: number;
 5+  pageTitle?: string;
 6+  pageUrl?: string;
 7   platform?: string;
 8   remoteConversationId?: string;
 9+  routePath?: string;
10+  targetId?: string;
11 }
12 
13 export interface ListRenewalJobsOptions {