baa-conductor

git clone 

commit
756d5d5
parent
3bcf667
author
codex@macbookpro
date
2026-03-30 15:07:25 +0800 CST
feat: add renewal storage foundation
9 files changed,  +1468, -11
M packages/artifact-db/src/index.test.js
+137, -0
  1@@ -101,3 +101,140 @@ test("ArtifactStore writes message, execution, session, and index artifacts sync
  2     });
  3   }
  4 });
  5+
  6+test("ArtifactStore persists renewal storage records and enqueues sync payloads", async () => {
  7+  const rootDir = mkdtempSync(join(tmpdir(), "artifact-db-renewal-test-"));
  8+  const stateDir = join(rootDir, "state");
  9+  const databasePath = join(stateDir, ARTIFACT_DB_FILENAME);
 10+  const artifactDir = join(stateDir, ARTIFACTS_DIRNAME);
 11+  const store = new ArtifactStore({
 12+    artifactDir,
 13+    databasePath
 14+  });
 15+  const syncRecords = [];
 16+  store.setSyncQueue({
 17+    enqueueSyncRecord(input) {
 18+      syncRecords.push(input);
 19+    }
 20+  });
 21+
 22+  try {
 23+    const message = await store.insertMessage({
 24+      conversationId: "conv_renew_123",
 25+      id: "msg_renew_123",
 26+      observedAt: Date.UTC(2026, 2, 28, 9, 0, 0),
 27+      platform: "claude",
 28+      rawText: "续命候选消息",
 29+      role: "assistant"
 30+    });
 31+
 32+    const localConversation = await store.upsertLocalConversation({
 33+      automationStatus: "auto",
 34+      cooldownUntil: Date.UTC(2026, 2, 28, 9, 5, 0),
 35+      lastMessageAt: message.observedAt,
 36+      lastMessageId: message.id,
 37+      localConversationId: "lc_123",
 38+      platform: "claude",
 39+      summary: "Initial summary",
 40+      title: "Renewal thread"
 41+    });
 42+    const pausedConversation = await store.upsertLocalConversation({
 43+      automationStatus: "paused",
 44+      localConversationId: localConversation.localConversationId,
 45+      pausedAt: Date.UTC(2026, 2, 28, 9, 6, 0),
 46+      platform: "claude"
 47+    });
 48+
 49+    const conversationLink = await store.upsertConversationLink({
 50+      clientId: "firefox-claude",
 51+      linkId: "link_123",
 52+      localConversationId: localConversation.localConversationId,
 53+      observedAt: Date.UTC(2026, 2, 28, 9, 1, 0),
 54+      pageTitle: "Claude",
 55+      pageUrl: "https://claude.ai/chat/conv_renew_123",
 56+      platform: "claude",
 57+      remoteConversationId: "conv_renew_123",
 58+      routeParams: { conversationId: "conv_renew_123" },
 59+      routePath: "/chat/conv_renew_123",
 60+      routePattern: "/chat/:conversationId",
 61+      targetId: "firefox-claude",
 62+      targetKind: "browser.proxy_delivery",
 63+      targetPayload: { clientId: "firefox-claude" }
 64+    });
 65+    const updatedConversationLink = await store.upsertConversationLink({
 66+      linkId: "link_ignore_new_id",
 67+      localConversationId: localConversation.localConversationId,
 68+      observedAt: Date.UTC(2026, 2, 28, 9, 2, 0),
 69+      pageTitle: "Claude Updated",
 70+      platform: "claude",
 71+      remoteConversationId: "conv_renew_123"
 72+    });
 73+
 74+    const dueJob = await store.insertRenewalJob({
 75+      jobId: "job_123",
 76+      localConversationId: localConversation.localConversationId,
 77+      logPath: "logs/renewal/2026-03-28.jsonl",
 78+      maxAttempts: 5,
 79+      messageId: message.id,
 80+      nextAttemptAt: Date.UTC(2026, 2, 28, 9, 3, 0),
 81+      payload: "[renew] keepalive",
 82+      payloadKind: "text",
 83+      targetSnapshot: {
 84+        clientId: "firefox-claude",
 85+        pageUrl: "https://claude.ai/chat/conv_renew_123"
 86+      }
 87+    });
 88+    const runningJob = await store.updateRenewalJob({
 89+      attemptCount: 1,
 90+      jobId: dueJob.jobId,
 91+      lastAttemptAt: Date.UTC(2026, 2, 28, 9, 4, 0),
 92+      nextAttemptAt: null,
 93+      startedAt: Date.UTC(2026, 2, 28, 9, 4, 0),
 94+      status: "running"
 95+    });
 96+
 97+    assert.deepEqual(await store.getLocalConversation(localConversation.localConversationId), pausedConversation);
 98+    assert.deepEqual(await store.getConversationLink(conversationLink.linkId), updatedConversationLink);
 99+    assert.deepEqual(
100+      await store.findConversationLinkByRemoteConversation("claude", "conv_renew_123"),
101+      updatedConversationLink
102+    );
103+    assert.deepEqual(await store.getRenewalJob(dueJob.jobId), runningJob);
104+
105+    assert.equal(localConversation.automationStatus, "auto");
106+    assert.equal(pausedConversation.automationStatus, "paused");
107+    assert.equal(pausedConversation.summary, "Initial summary");
108+    assert.equal(updatedConversationLink.linkId, "link_123");
109+    assert.equal(updatedConversationLink.clientId, "firefox-claude");
110+    assert.equal(updatedConversationLink.pageTitle, "Claude Updated");
111+    assert.equal(runningJob.status, "running");
112+    assert.equal(runningJob.attemptCount, 1);
113+    assert.equal(runningJob.nextAttemptAt, null);
114+    assert.match(runningJob.targetSnapshot, /firefox-claude/u);
115+
116+    assert.deepEqual(await store.listLocalConversations({ automationStatus: "paused" }), [pausedConversation]);
117+    assert.deepEqual(
118+      await store.listConversationLinks({ localConversationId: localConversation.localConversationId }),
119+      [updatedConversationLink]
120+    );
121+    assert.deepEqual(await store.listRenewalJobs({ status: "running" }), [runningJob]);
122+    assert.deepEqual(
123+      syncRecords.map((record) => [record.tableName, record.operation, record.recordId]),
124+      [
125+        ["messages", "insert", "msg_renew_123"],
126+        ["local_conversations", "insert", "lc_123"],
127+        ["local_conversations", "insert", "lc_123"],
128+        ["conversation_links", "insert", "link_123"],
129+        ["conversation_links", "insert", "link_123"],
130+        ["renewal_jobs", "insert", "job_123"],
131+        ["renewal_jobs", "update", "job_123"]
132+      ]
133+    );
134+  } finally {
135+    store.close();
136+    rmSync(rootDir, {
137+      force: true,
138+      recursive: true
139+    });
140+  }
141+});
M packages/artifact-db/src/index.ts
+13, -0
 1@@ -10,6 +10,8 @@ export {
 2   ARTIFACT_DB_FILENAME,
 3   ARTIFACT_PUBLIC_PATH_SEGMENT,
 4   ARTIFACT_SCOPES,
 5+  type ConversationAutomationStatus,
 6+  type ConversationLinkRecord,
 7   DEFAULT_SESSION_INDEX_LIMIT,
 8   DEFAULT_SUMMARY_LENGTH,
 9   type ArtifactFileKind,
10@@ -20,13 +22,24 @@ export {
11   type ExecutionRecord,
12   type InsertExecutionInput,
13   type InsertMessageInput,
14+  type InsertRenewalJobInput,
15+  type ListConversationLinksOptions,
16   type ListExecutionsOptions,
17+  type ListLocalConversationsOptions,
18   type ListMessagesOptions,
19+  type ListRenewalJobsOptions,
20   type ListSessionsOptions,
21+  type LocalConversationRecord,
22   type MessageRecord,
23+  type RenewalJobPayloadKind,
24+  type RenewalJobRecord,
25+  type RenewalJobStatus,
26   type SessionIndexEntry,
27   type SessionRecord,
28   type SessionTimelineEntry,
29   type SyncEnqueuer,
30+  type UpdateRenewalJobInput,
31+  type UpsertConversationLinkInput,
32+  type UpsertLocalConversationInput,
33   type UpsertSessionInput
34 } from "./types.js";
M packages/artifact-db/src/schema.ts
+78, -0
 1@@ -59,4 +59,82 @@ CREATE INDEX IF NOT EXISTS idx_sessions_platform
 2   ON sessions(platform, last_activity_at DESC);
 3 CREATE INDEX IF NOT EXISTS idx_sessions_conversation
 4   ON sessions(conversation_id);
 5+
 6+CREATE TABLE IF NOT EXISTS local_conversations (
 7+  local_conversation_id TEXT PRIMARY KEY,
 8+  platform              TEXT NOT NULL,
 9+  automation_status     TEXT NOT NULL DEFAULT 'manual',
10+  title                 TEXT,
11+  summary               TEXT,
12+  last_message_id       TEXT,
13+  last_message_at       INTEGER,
14+  cooldown_until        INTEGER,
15+  paused_at             INTEGER,
16+  created_at            INTEGER NOT NULL,
17+  updated_at            INTEGER NOT NULL
18+);
19+
20+CREATE INDEX IF NOT EXISTS idx_local_conversations_status
21+  ON local_conversations(automation_status, updated_at DESC);
22+CREATE INDEX IF NOT EXISTS idx_local_conversations_platform
23+  ON local_conversations(platform, updated_at DESC);
24+CREATE INDEX IF NOT EXISTS idx_local_conversations_last_message
25+  ON local_conversations(last_message_at DESC);
26+
27+CREATE TABLE IF NOT EXISTS conversation_links (
28+  link_id                TEXT PRIMARY KEY,
29+  local_conversation_id  TEXT NOT NULL,
30+  platform               TEXT NOT NULL,
31+  remote_conversation_id TEXT,
32+  client_id              TEXT,
33+  page_url               TEXT,
34+  page_title             TEXT,
35+  route_path             TEXT,
36+  route_pattern          TEXT,
37+  route_params           TEXT,
38+  target_kind            TEXT,
39+  target_id              TEXT,
40+  target_payload         TEXT,
41+  is_active              INTEGER NOT NULL DEFAULT 1,
42+  observed_at            INTEGER NOT NULL,
43+  created_at             INTEGER NOT NULL,
44+  updated_at             INTEGER NOT NULL,
45+  FOREIGN KEY (local_conversation_id) REFERENCES local_conversations(local_conversation_id)
46+);
47+
48+CREATE UNIQUE INDEX IF NOT EXISTS idx_conversation_links_remote
49+  ON conversation_links(platform, remote_conversation_id);
50+CREATE INDEX IF NOT EXISTS idx_conversation_links_local
51+  ON conversation_links(local_conversation_id, is_active, updated_at DESC);
52+CREATE INDEX IF NOT EXISTS idx_conversation_links_client
53+  ON conversation_links(client_id, updated_at DESC);
54+
55+CREATE TABLE IF NOT EXISTS renewal_jobs (
56+  job_id                 TEXT PRIMARY KEY,
57+  local_conversation_id  TEXT NOT NULL,
58+  message_id             TEXT NOT NULL,
59+  status                 TEXT NOT NULL DEFAULT 'pending',
60+  payload                TEXT NOT NULL,
61+  payload_kind           TEXT NOT NULL DEFAULT 'text',
62+  target_snapshot        TEXT,
63+  attempt_count          INTEGER NOT NULL DEFAULT 0,
64+  max_attempts           INTEGER NOT NULL DEFAULT 3,
65+  next_attempt_at        INTEGER,
66+  last_attempt_at        INTEGER,
67+  last_error             TEXT,
68+  log_path               TEXT,
69+  started_at             INTEGER,
70+  finished_at            INTEGER,
71+  created_at             INTEGER NOT NULL,
72+  updated_at             INTEGER NOT NULL,
73+  FOREIGN KEY (local_conversation_id) REFERENCES local_conversations(local_conversation_id),
74+  FOREIGN KEY (message_id) REFERENCES messages(id)
75+);
76+
77+CREATE UNIQUE INDEX IF NOT EXISTS idx_renewal_jobs_message
78+  ON renewal_jobs(message_id);
79+CREATE INDEX IF NOT EXISTS idx_renewal_jobs_status_due
80+  ON renewal_jobs(status, next_attempt_at ASC, created_at ASC);
81+CREATE INDEX IF NOT EXISTS idx_renewal_jobs_local_conversation
82+  ON renewal_jobs(local_conversation_id, status, created_at DESC);
83 `;
M packages/artifact-db/src/store.ts
+813, -4
  1@@ -21,18 +21,31 @@ import {
  2   DEFAULT_SUMMARY_LENGTH,
  3   type ArtifactStoreConfig,
  4   type ArtifactTextFile,
  5+  type ConversationAutomationStatus,
  6+  type ConversationLinkRecord,
  7   type ExecutionRecord,
  8   type ExecutionParamsKind,
  9   type InsertExecutionInput,
 10   type InsertMessageInput,
 11+  type InsertRenewalJobInput,
 12+  type ListConversationLinksOptions,
 13   type ListExecutionsOptions,
 14+  type ListLocalConversationsOptions,
 15   type ListMessagesOptions,
 16+  type ListRenewalJobsOptions,
 17   type ListSessionsOptions,
 18+  type LocalConversationRecord,
 19   type MessageRecord,
 20+  type RenewalJobPayloadKind,
 21+  type RenewalJobRecord,
 22+  type RenewalJobStatus,
 23   type SessionIndexEntry,
 24   type SessionRecord,
 25   type SessionTimelineEntry,
 26   type SyncEnqueuer,
 27+  type UpdateRenewalJobInput,
 28+  type UpsertConversationLinkInput,
 29+  type UpsertLocalConversationInput,
 30   type UpsertSessionInput
 31 } from "./types.js";
 32 
 33@@ -97,6 +110,133 @@ ON CONFLICT(id) DO UPDATE SET
 34   created_at = excluded.created_at;
 35 `;
 36 
 37+const UPSERT_LOCAL_CONVERSATION_SQL = `
 38+INSERT INTO local_conversations (
 39+  local_conversation_id,
 40+  platform,
 41+  automation_status,
 42+  title,
 43+  summary,
 44+  last_message_id,
 45+  last_message_at,
 46+  cooldown_until,
 47+  paused_at,
 48+  created_at,
 49+  updated_at
 50+) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
 51+ON CONFLICT(local_conversation_id) DO UPDATE SET
 52+  platform = excluded.platform,
 53+  automation_status = excluded.automation_status,
 54+  title = excluded.title,
 55+  summary = excluded.summary,
 56+  last_message_id = excluded.last_message_id,
 57+  last_message_at = excluded.last_message_at,
 58+  cooldown_until = excluded.cooldown_until,
 59+  paused_at = excluded.paused_at,
 60+  created_at = excluded.created_at,
 61+  updated_at = excluded.updated_at;
 62+`;
 63+
 64+const UPSERT_CONVERSATION_LINK_SQL = `
 65+INSERT INTO conversation_links (
 66+  link_id,
 67+  local_conversation_id,
 68+  platform,
 69+  remote_conversation_id,
 70+  client_id,
 71+  page_url,
 72+  page_title,
 73+  route_path,
 74+  route_pattern,
 75+  route_params,
 76+  target_kind,
 77+  target_id,
 78+  target_payload,
 79+  is_active,
 80+  observed_at,
 81+  created_at,
 82+  updated_at
 83+) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
 84+ON CONFLICT(link_id) DO UPDATE SET
 85+  local_conversation_id = excluded.local_conversation_id,
 86+  platform = excluded.platform,
 87+  remote_conversation_id = excluded.remote_conversation_id,
 88+  client_id = excluded.client_id,
 89+  page_url = excluded.page_url,
 90+  page_title = excluded.page_title,
 91+  route_path = excluded.route_path,
 92+  route_pattern = excluded.route_pattern,
 93+  route_params = excluded.route_params,
 94+  target_kind = excluded.target_kind,
 95+  target_id = excluded.target_id,
 96+  target_payload = excluded.target_payload,
 97+  is_active = excluded.is_active,
 98+  observed_at = excluded.observed_at,
 99+  created_at = excluded.created_at,
100+  updated_at = excluded.updated_at;
101+`;
102+
103+const INSERT_RENEWAL_JOB_SQL = `
104+INSERT INTO renewal_jobs (
105+  job_id,
106+  local_conversation_id,
107+  message_id,
108+  status,
109+  payload,
110+  payload_kind,
111+  target_snapshot,
112+  attempt_count,
113+  max_attempts,
114+  next_attempt_at,
115+  last_attempt_at,
116+  last_error,
117+  log_path,
118+  started_at,
119+  finished_at,
120+  created_at,
121+  updated_at
122+) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
123+`;
124+
125+const UPSERT_RENEWAL_JOB_SQL = `
126+INSERT INTO renewal_jobs (
127+  job_id,
128+  local_conversation_id,
129+  message_id,
130+  status,
131+  payload,
132+  payload_kind,
133+  target_snapshot,
134+  attempt_count,
135+  max_attempts,
136+  next_attempt_at,
137+  last_attempt_at,
138+  last_error,
139+  log_path,
140+  started_at,
141+  finished_at,
142+  created_at,
143+  updated_at
144+) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
145+ON CONFLICT(job_id) DO UPDATE SET
146+  local_conversation_id = excluded.local_conversation_id,
147+  message_id = excluded.message_id,
148+  status = excluded.status,
149+  payload = excluded.payload,
150+  payload_kind = excluded.payload_kind,
151+  target_snapshot = excluded.target_snapshot,
152+  attempt_count = excluded.attempt_count,
153+  max_attempts = excluded.max_attempts,
154+  next_attempt_at = excluded.next_attempt_at,
155+  last_attempt_at = excluded.last_attempt_at,
156+  last_error = excluded.last_error,
157+  log_path = excluded.log_path,
158+  started_at = excluded.started_at,
159+  finished_at = excluded.finished_at,
160+  created_at = excluded.created_at,
161+  updated_at = excluded.updated_at;
162+`;
163+
164 interface FileMutation {
165   existed: boolean;
166   path: string;
167@@ -148,6 +288,60 @@ interface SessionRow {
168   summary: string | null;
169 }
170 
171+interface LocalConversationRow {
172+  automation_status: ConversationAutomationStatus;
173+  cooldown_until: number | null;
174+  created_at: number;
175+  last_message_at: number | null;
176+  last_message_id: string | null;
177+  local_conversation_id: string;
178+  paused_at: number | null;
179+  platform: string;
180+  summary: string | null;
181+  title: string | null;
182+  updated_at: number;
183+}
184+
185+interface ConversationLinkRow {
186+  client_id: string | null;
187+  created_at: number;
188+  is_active: number;
189+  link_id: string;
190+  local_conversation_id: string;
191+  observed_at: number;
192+  page_title: string | null;
193+  page_url: string | null;
194+  platform: string;
195+  remote_conversation_id: string | null;
196+  route_params: string | null;
197+  route_path: string | null;
198+  route_pattern: string | null;
199+  target_id: string | null;
200+  target_kind: string | null;
201+  target_payload: string | null;
202+  updated_at: number;
203+}
204+
205+interface RenewalJobRow {
206+  attempt_count: number;
207+  created_at: number;
208+  finished_at: number | null;
209+  job_id: string;
210+  last_attempt_at: number | null;
211+  last_error: string | null;
212+  local_conversation_id: string;
213+  log_path: string | null;
214+  max_attempts: number;
215+  message_id: string;
216+  next_attempt_at: number | null;
217+  payload: string;
218+  payload_kind: RenewalJobPayloadKind;
219+  started_at: number | null;
220+  status: RenewalJobStatus;
221+  target_snapshot: string | null;
222+  updated_at: number;
223+}
224+
225 interface TimelineMessageRow {
226   id: string;
227   observed_at: number;
228@@ -256,6 +450,48 @@ export class ArtifactStore {
229     return row == null ? null : mapMessageRow(row);
230   }
231 
232+  async getLocalConversation(localConversationId: string): Promise<LocalConversationRecord | null> {
233+    const row = this.getRow<LocalConversationRow>(
234+      "SELECT * FROM local_conversations WHERE local_conversation_id = ? LIMIT 1;",
235+      localConversationId
236+    );
237+    return row == null ? null : mapLocalConversationRow(row);
238+  }
239+
240+  async getConversationLink(linkId: string): Promise<ConversationLinkRecord | null> {
241+    const row = this.getRow<ConversationLinkRow>(
242+      "SELECT * FROM conversation_links WHERE link_id = ? LIMIT 1;",
243+      linkId
244+    );
245+    return row == null ? null : mapConversationLinkRow(row);
246+  }
247+
248+  async findConversationLinkByRemoteConversation(
249+    platform: string,
250+    remoteConversationId: string
251+  ): Promise<ConversationLinkRecord | null> {
252+    const row = this.getRow<ConversationLinkRow>(
253+      `
254+      SELECT *
255+      FROM conversation_links
256+      WHERE platform = ?
257+        AND remote_conversation_id = ?
258+      LIMIT 1;
259+      `,
260+      normalizeRequiredString(platform, "platform"),
261+      normalizeRequiredString(remoteConversationId, "remoteConversationId")
262+    );
263+    return row == null ? null : mapConversationLinkRow(row);
264+  }
265+
266+  async getRenewalJob(jobId: string): Promise<RenewalJobRecord | null> {
267+    const row = this.getRow<RenewalJobRow>(
268+      "SELECT * FROM renewal_jobs WHERE job_id = ? LIMIT 1;",
269+      jobId
270+    );
271+    return row == null ? null : mapRenewalJobRow(row);
272+  }
273+
274   async insertExecution(input: InsertExecutionInput): Promise<ExecutionRecord> {
275     const record = buildExecutionRecord(input, this.summaryLength);
276     const message = await this.getMessage(record.messageId);
277@@ -320,6 +556,56 @@ export class ArtifactStore {
278     return record;
279   }
280 
281+  async upsertLocalConversation(
282+    input: UpsertLocalConversationInput
283+  ): Promise<LocalConversationRecord> {
284+    const existing = await this.getLocalConversation(input.localConversationId);
285+    const record = buildLocalConversationRecord(input, existing);
286+
287+    this.executeWrite(() => {
288+      this.run(UPSERT_LOCAL_CONVERSATION_SQL, localConversationParams(record));
289+    });
290+
291+    this.enqueueSync(
292+      "local_conversations",
293+      record.localConversationId,
294+      localConversationSyncPayload(record)
295+    );
296+
297+    return record;
298+  }
299+
300+  async upsertConversationLink(
301+    input: UpsertConversationLinkInput
302+  ): Promise<ConversationLinkRecord> {
303+    const existing = this.resolveExistingConversationLink(input);
304+    const record = buildConversationLinkRecord(input, existing);
305+
306+    this.executeWrite(() => {
307+      this.run(UPSERT_CONVERSATION_LINK_SQL, conversationLinkParams(record));
308+    });
309+
310+    this.enqueueSync(
311+      "conversation_links",
312+      record.linkId,
313+      conversationLinkSyncPayload(record)
314+    );
315+
316+    return record;
317+  }
318+
319+  async insertRenewalJob(input: InsertRenewalJobInput): Promise<RenewalJobRecord> {
320+    const record = buildRenewalJobRecord(input);
321+
322+    this.executeWrite(() => {
323+      this.run(INSERT_RENEWAL_JOB_SQL, renewalJobParams(record));
324+    });
325+
326+    this.enqueueSync("renewal_jobs", record.jobId, renewalJobSyncPayload(record));
327+
328+    return record;
329+  }
330+
331   async listExecutions(options: ListExecutionsOptions = {}): Promise<ExecutionRecord[]> {
332     const query = [
333       "SELECT * FROM executions",
334@@ -390,6 +676,152 @@ export class ArtifactStore {
335     return rows.map(mapSessionRow);
336   }
337 
338+  async listLocalConversations(
339+    options: ListLocalConversationsOptions = {}
340+  ): Promise<LocalConversationRecord[]> {
341+    const conditions: string[] = [];
342+    const params: Array<number | string | null> = [];
343+
344+    if (options.platform != null) {
345+      conditions.push("platform = ?");
346+      params.push(options.platform);
347+    }
348+
349+    if (options.automationStatus != null) {
350+      conditions.push("automation_status = ?");
351+      params.push(options.automationStatus);
352+    }
353+
354+    const rows = this.getRows<LocalConversationRow>(
355+      [
356+        "SELECT * FROM local_conversations",
357+        conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "",
358+        "ORDER BY updated_at DESC, created_at DESC",
359+        "LIMIT ?",
360+        "OFFSET ?"
361+      ]
362+        .filter(Boolean)
363+        .join(" "),
364+      ...params,
365+      normalizeLimit(options.limit),
366+      normalizeOffset(options.offset)
367+    );
368+
369+    return rows.map(mapLocalConversationRow);
370+  }
371+
372+  async listConversationLinks(
373+    options: ListConversationLinksOptions = {}
374+  ): Promise<ConversationLinkRecord[]> {
375+    const conditions: string[] = [];
376+    const params: Array<number | string | null> = [];
377+
378+    if (options.localConversationId != null) {
379+      conditions.push("local_conversation_id = ?");
380+      params.push(options.localConversationId);
381+    }
382+
383+    if (options.platform != null) {
384+      conditions.push("platform = ?");
385+      params.push(options.platform);
386+    }
387+
388+    if (options.remoteConversationId != null) {
389+      conditions.push("remote_conversation_id = ?");
390+      params.push(options.remoteConversationId);
391+    }
392+
393+    if (options.clientId != null) {
394+      conditions.push("client_id = ?");
395+      params.push(options.clientId);
396+    }
397+
398+    if (options.isActive != null) {
399+      conditions.push("is_active = ?");
400+      params.push(options.isActive ? 1 : 0);
401+    }
402+
403+    const rows = this.getRows<ConversationLinkRow>(
404+      [
405+        "SELECT * FROM conversation_links",
406+        conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "",
407+        "ORDER BY observed_at DESC, updated_at DESC",
408+        "LIMIT ?",
409+        "OFFSET ?"
410+      ]
411+        .filter(Boolean)
412+        .join(" "),
413+      ...params,
414+      normalizeLimit(options.limit),
415+      normalizeOffset(options.offset)
416+    );
417+
418+    return rows.map(mapConversationLinkRow);
419+  }
420+
421+  async listRenewalJobs(options: ListRenewalJobsOptions = {}): Promise<RenewalJobRecord[]> {
422+    const conditions: string[] = [];
423+    const params: Array<number | string | null> = [];
424+
425+    if (options.localConversationId != null) {
426+      conditions.push("local_conversation_id = ?");
427+      params.push(options.localConversationId);
428+    }
429+
430+    if (options.messageId != null) {
431+      conditions.push("message_id = ?");
432+      params.push(options.messageId);
433+    }
434+
435+    if (options.status != null) {
436+      conditions.push("status = ?");
437+      params.push(options.status);
438+    }
439+
440+    if (options.nextAttemptAtLte != null) {
441+      conditions.push("next_attempt_at IS NOT NULL");
442+      conditions.push("next_attempt_at <= ?");
443+      params.push(options.nextAttemptAtLte);
444+    }
445+
446+    const rows = this.getRows<RenewalJobRow>(
447+      [
448+        "SELECT * FROM renewal_jobs",
449+        conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "",
450+        options.nextAttemptAtLte == null
451+          ? "ORDER BY created_at DESC, updated_at DESC"
452+          : "ORDER BY next_attempt_at ASC, created_at ASC",
453+        "LIMIT ?",
454+        "OFFSET ?"
455+      ]
456+        .filter(Boolean)
457+        .join(" "),
458+      ...params,
459+      normalizeLimit(options.limit),
460+      normalizeOffset(options.offset)
461+    );
462+
463+    return rows.map(mapRenewalJobRow);
464+  }
465+
466+  async updateRenewalJob(input: UpdateRenewalJobInput): Promise<RenewalJobRecord> {
467+    const existing = await this.getRenewalJob(input.jobId);
468+
469+    if (existing == null) {
470+      throw new Error(`Renewal job "${input.jobId}" does not exist.`);
471+    }
472+
473+    const record = buildUpdatedRenewalJobRecord(input, existing);
474+
475+    this.executeWrite(() => {
476+      this.run(UPSERT_RENEWAL_JOB_SQL, renewalJobParams(record));
477+    });
478+
479+    this.enqueueSync("renewal_jobs", record.jobId, renewalJobSyncPayload(record), "update");
480+
481+    return record;
482+  }
483+
484   async upsertSession(input: UpsertSessionInput): Promise<SessionRecord> {
485     const record = buildSessionRecord(input, this.summaryLength);
486 
487@@ -430,6 +862,38 @@ export class ArtifactStore {
488     return statement.all(...params) as T[];
489   }
490 
491+  private resolveExistingConversationLink(
492+    input: UpsertConversationLinkInput
493+  ): ConversationLinkRecord | null {
494+    const byId = this.getRow<ConversationLinkRow>(
495+      "SELECT * FROM conversation_links WHERE link_id = ? LIMIT 1;",
496+      normalizeRequiredString(input.linkId, "linkId")
497+    );
498+
499+    const remoteConversationId = normalizeOptionalString(input.remoteConversationId);
500+    if (remoteConversationId == null) {
501+      return byId == null ? null : mapConversationLinkRow(byId);
502+    }
503+
504+    const byRemote = this.getRow<ConversationLinkRow>(
505+      `
506+      SELECT *
507+      FROM conversation_links
508+      WHERE platform = ?
509+        AND remote_conversation_id = ?
510+      LIMIT 1;
511+      `,
512+      normalizeRequiredString(input.platform, "platform"),
513+      remoteConversationId
514+    );
515+
516+    return byRemote == null
517+      ? byId == null
518+        ? null
519+        : mapConversationLinkRow(byId)
520+      : mapConversationLinkRow(byRemote);
521+  }
522+
523   private readLatestMessageForSession(session: SessionRecord): LatestMessageRow | null {
524     const { clause, params } = buildConversationFilters(session.platform, session.conversationId);
525     return this.getRow<LatestMessageRow>(
526@@ -607,7 +1071,12 @@ export class ArtifactStore {
527     });
528   }
529 
530-  private enqueueSync(tableName: string, recordId: string, payload: Record<string, unknown>): void {
531+  private enqueueSync(
532+    tableName: string,
533+    recordId: string,
534+    payload: Record<string, unknown>,
535+    operation: "insert" | "update" = "insert"
536+  ): void {
537     if (this.syncQueue == null) {
538       return;
539     }
540@@ -616,7 +1085,7 @@ export class ArtifactStore {
541       this.syncQueue.enqueueSyncRecord({
542         tableName,
543         recordId,
544-        operation: "insert",
545+        operation,
546         payload
547       });
548     } catch {
549@@ -762,11 +1231,11 @@ function buildMessageRecord(input: InsertMessageInput, summaryLength: number): M
550 }
551 
552 function buildQueryParams(
553-  values: Array<string | undefined>,
554+  values: Array<number | string | undefined>,
555   limit: number,
556   offset: number
557 ): Array<number | string | null> {
558-  return [...values.filter((value): value is string => value != null), limit, offset];
559+  return [...values.filter((value): value is number | string => value != null), limit, offset];
560 }
561 
562 function buildSessionRecord(input: UpsertSessionInput, summaryLength: number): SessionRecord {
563@@ -786,6 +1255,112 @@ function buildSessionRecord(input: UpsertSessionInput, summaryLength: number): S
564   };
565 }
566 
567+function buildLocalConversationRecord(
568+  input: UpsertLocalConversationInput,
569+  existing: LocalConversationRecord | null
570+): LocalConversationRecord {
571+  const createdAt = input.createdAt ?? existing?.createdAt ?? Date.now();
572+
573+  return {
574+    automationStatus: input.automationStatus ?? existing?.automationStatus ?? "manual",
575+    cooldownUntil: mergeOptionalInteger(input.cooldownUntil, existing?.cooldownUntil ?? null, "cooldownUntil"),
576+    createdAt,
577+    lastMessageAt: mergeOptionalInteger(input.lastMessageAt, existing?.lastMessageAt ?? null, "lastMessageAt"),
578+    lastMessageId: mergeOptionalString(input.lastMessageId, existing?.lastMessageId ?? null),
579+    localConversationId: normalizeRequiredString(input.localConversationId, "localConversationId"),
580+    pausedAt: mergeOptionalInteger(input.pausedAt, existing?.pausedAt ?? null, "pausedAt"),
581+    platform: normalizeRequiredString(input.platform, "platform"),
582+    summary: mergeOptionalString(input.summary, existing?.summary ?? null),
583+    title: mergeOptionalString(input.title, existing?.title ?? null),
584+    updatedAt: input.updatedAt ?? Date.now()
585+  };
586+}
587+
588+function buildConversationLinkRecord(
589+  input: UpsertConversationLinkInput,
590+  existing: ConversationLinkRecord | null
591+): ConversationLinkRecord {
592+  const createdAt = input.createdAt ?? existing?.createdAt ?? Date.now();
593+
594+  return {
595+    clientId: mergeOptionalString(input.clientId, existing?.clientId ?? null),
596+    createdAt,
597+    isActive: input.isActive ?? existing?.isActive ?? true,
598+    linkId: existing?.linkId ?? normalizeRequiredString(input.linkId, "linkId"),
599+    localConversationId: normalizeRequiredString(input.localConversationId, "localConversationId"),
600+    observedAt: input.observedAt,
601+    pageTitle: mergeOptionalString(input.pageTitle, existing?.pageTitle ?? null),
602+    pageUrl: mergeOptionalString(input.pageUrl, existing?.pageUrl ?? null),
603+    platform: normalizeRequiredString(input.platform, "platform"),
604+    remoteConversationId: mergeOptionalString(
605+      input.remoteConversationId,
606+      existing?.remoteConversationId ?? null
607+    ),
608+    routeParams: mergeOptionalSerialized(input.routeParams, existing?.routeParams ?? null),
609+    routePath: mergeOptionalString(input.routePath, existing?.routePath ?? null),
610+    routePattern: mergeOptionalString(input.routePattern, existing?.routePattern ?? null),
611+    targetId: mergeOptionalString(input.targetId, existing?.targetId ?? null),
612+    targetKind: mergeOptionalString(input.targetKind, existing?.targetKind ?? null),
613+    targetPayload: mergeOptionalSerialized(input.targetPayload, existing?.targetPayload ?? null),
614+    updatedAt: input.updatedAt ?? Date.now()
615+  };
616+}
617+
618+function buildRenewalJobRecord(input: InsertRenewalJobInput): RenewalJobRecord {
619+  const createdAt = input.createdAt ?? Date.now();
620+
621+  return {
622+    attemptCount: normalizeNonNegativeInteger(input.attemptCount, 0, "attemptCount"),
623+    createdAt,
624+    finishedAt: normalizeOptionalInteger(input.finishedAt, "finishedAt"),
625+    jobId: normalizeRequiredString(input.jobId, "jobId"),
626+    lastAttemptAt: normalizeOptionalInteger(input.lastAttemptAt, "lastAttemptAt"),
627+    lastError: normalizeOptionalString(input.lastError),
628+    localConversationId: normalizeRequiredString(input.localConversationId, "localConversationId"),
629+    logPath: normalizeOptionalString(input.logPath),
630+    maxAttempts: normalizePositiveInteger(input.maxAttempts, 3),
631+    messageId: normalizeRequiredString(input.messageId, "messageId"),
632+    nextAttemptAt: input.nextAttemptAt === undefined
633+      ? createdAt
634+      : normalizeOptionalInteger(input.nextAttemptAt, "nextAttemptAt"),
635+    payload: normalizeRequiredString(input.payload, "payload"),
636+    payloadKind: input.payloadKind ?? "text",
637+    startedAt: normalizeOptionalInteger(input.startedAt, "startedAt"),
638+    status: input.status ?? "pending",
639+    targetSnapshot: stringifyUnknown(input.targetSnapshot),
640+    updatedAt: input.updatedAt ?? createdAt
641+  };
642+}
643+
644+function buildUpdatedRenewalJobRecord(
645+  input: UpdateRenewalJobInput,
646+  existing: RenewalJobRecord
647+): RenewalJobRecord {
648+  return {
649+    attemptCount: input.attemptCount == null
650+      ? existing.attemptCount
651+      : normalizeNonNegativeInteger(input.attemptCount, existing.attemptCount, "attemptCount"),
652+    createdAt: existing.createdAt,
653+    finishedAt: mergeOptionalInteger(input.finishedAt, existing.finishedAt, "finishedAt"),
654+    jobId: existing.jobId,
655+    lastAttemptAt: mergeOptionalInteger(input.lastAttemptAt, existing.lastAttemptAt, "lastAttemptAt"),
656+    lastError: mergeOptionalString(input.lastError, existing.lastError),
657+    localConversationId: existing.localConversationId,
658+    logPath: mergeOptionalString(input.logPath, existing.logPath),
659+    maxAttempts: input.maxAttempts == null
660+      ? existing.maxAttempts
661+      : normalizePositiveInteger(input.maxAttempts, existing.maxAttempts),
662+    messageId: existing.messageId,
663+    nextAttemptAt: mergeOptionalInteger(input.nextAttemptAt, existing.nextAttemptAt, "nextAttemptAt"),
664+    payload: input.payload == null ? existing.payload : normalizeRequiredString(input.payload, "payload"),
665+    payloadKind: input.payloadKind ?? existing.payloadKind,
666+    startedAt: mergeOptionalInteger(input.startedAt, existing.startedAt, "startedAt"),
667+    status: input.status ?? existing.status,
668+    targetSnapshot: mergeOptionalSerialized(input.targetSnapshot, existing.targetSnapshot),
669+    updatedAt: input.updatedAt ?? Date.now()
670+  };
671+}
672+
673 function buildWhereClause(conditions: Array<string | null>, separator: "AND"): string {
674   const filtered = conditions.filter((condition): condition is string => condition != null);
675   return filtered.length === 0 ? "" : `WHERE ${filtered.join(` ${separator} `)}`;
676@@ -861,6 +1436,126 @@ function mapSessionRow(row: SessionRow): SessionRecord {
677   };
678 }
679 
680+function localConversationParams(record: LocalConversationRecord): Array<number | string | null> {
681+  return [
682+    record.localConversationId,
683+    record.platform,
684+    record.automationStatus,
685+    record.title,
686+    record.summary,
687+    record.lastMessageId,
688+    record.lastMessageAt,
689+    record.cooldownUntil,
690+    record.pausedAt,
691+    record.createdAt,
692+    record.updatedAt
693+  ];
694+}
695+
696+function conversationLinkParams(record: ConversationLinkRecord): Array<number | string | null> {
697+  return [
698+    record.linkId,
699+    record.localConversationId,
700+    record.platform,
701+    record.remoteConversationId,
702+    record.clientId,
703+    record.pageUrl,
704+    record.pageTitle,
705+    record.routePath,
706+    record.routePattern,
707+    record.routeParams,
708+    record.targetKind,
709+    record.targetId,
710+    record.targetPayload,
711+    record.isActive ? 1 : 0,
712+    record.observedAt,
713+    record.createdAt,
714+    record.updatedAt
715+  ];
716+}
717+
718+function renewalJobParams(record: RenewalJobRecord): Array<number | string | null> {
719+  return [
720+    record.jobId,
721+    record.localConversationId,
722+    record.messageId,
723+    record.status,
724+    record.payload,
725+    record.payloadKind,
726+    record.targetSnapshot,
727+    record.attemptCount,
728+    record.maxAttempts,
729+    record.nextAttemptAt,
730+    record.lastAttemptAt,
731+    record.lastError,
732+    record.logPath,
733+    record.startedAt,
734+    record.finishedAt,
735+    record.createdAt,
736+    record.updatedAt
737+  ];
738+}
739+
740+function mapLocalConversationRow(row: LocalConversationRow): LocalConversationRecord {
741+  return {
742+    automationStatus: row.automation_status,
743+    cooldownUntil: row.cooldown_until,
744+    createdAt: row.created_at,
745+    lastMessageAt: row.last_message_at,
746+    lastMessageId: row.last_message_id,
747+    localConversationId: row.local_conversation_id,
748+    pausedAt: row.paused_at,
749+    platform: row.platform,
750+    summary: row.summary,
751+    title: row.title,
752+    updatedAt: row.updated_at
753+  };
754+}
755+
756+function mapConversationLinkRow(row: ConversationLinkRow): ConversationLinkRecord {
757+  return {
758+    clientId: row.client_id,
759+    createdAt: row.created_at,
760+    isActive: row.is_active === 1,
761+    linkId: row.link_id,
762+    localConversationId: row.local_conversation_id,
763+    observedAt: row.observed_at,
764+    pageTitle: row.page_title,
765+    pageUrl: row.page_url,
766+    platform: row.platform,
767+    remoteConversationId: row.remote_conversation_id,
768+    routeParams: row.route_params,
769+    routePath: row.route_path,
770+    routePattern: row.route_pattern,
771+    targetId: row.target_id,
772+    targetKind: row.target_kind,
773+    targetPayload: row.target_payload,
774+    updatedAt: row.updated_at
775+  };
776+}
777+
778+function mapRenewalJobRow(row: RenewalJobRow): RenewalJobRecord {
779+  return {
780+    attemptCount: row.attempt_count,
781+    createdAt: row.created_at,
782+    finishedAt: row.finished_at,
783+    jobId: row.job_id,
784+    lastAttemptAt: row.last_attempt_at,
785+    lastError: row.last_error,
786+    localConversationId: row.local_conversation_id,
787+    logPath: row.log_path,
788+    maxAttempts: row.max_attempts,
789+    messageId: row.message_id,
790+    nextAttemptAt: row.next_attempt_at,
791+    payload: row.payload,
792+    payloadKind: row.payload_kind,
793+    startedAt: row.started_at,
794+    status: row.status,
795+    targetSnapshot: row.target_snapshot,
796+    updatedAt: row.updated_at
797+  };
798+}
799+
800 function messageParams(record: MessageRecord): Array<number | string | null> {
801   return [
802     record.id,
803@@ -908,6 +1603,18 @@ function normalizeOptionalString(value: string | null | undefined): string | nul
804   return normalized === "" ? null : normalized;
805 }
806 
807+function normalizeOptionalInteger(value: number | null | undefined, name: string): number | null {
808+  if (value == null) {
809+    return null;
810+  }
811+
812+  if (!Number.isInteger(value)) {
813+    throw new Error(`${name} must be an integer when provided.`);
814+  }
815+
816+  return value;
817+}
818+
819 function normalizePositiveInteger(value: number | undefined, fallback: number): number {
820   if (value == null) {
821     return fallback;
822@@ -930,6 +1637,22 @@ function normalizeRequiredPath(value: string, name: string): string {
823   return normalized;
824 }
825 
826+function normalizeNonNegativeInteger(
827+  value: number | undefined,
828+  fallback: number,
829+  name: string
830+): number {
831+  if (value == null) {
832+    return fallback;
833+  }
834+
835+  if (!Number.isInteger(value) || value < 0) {
836+    throw new Error(`${name} must be a non-negative integer.`);
837+  }
838+
839+  return value;
840+}
841+
842 function normalizeRequiredString(value: string, name: string): string {
843   const normalized = value.trim();
844 
845@@ -955,6 +1678,28 @@ function sessionParams(record: SessionRecord): Array<number | string | null> {
846   ];
847 }
848 
849+function mergeOptionalInteger(
850+  value: number | null | undefined,
851+  existing: number | null,
852+  name: string
853+): number | null {
854+  return value === undefined ? existing : normalizeOptionalInteger(value, name);
855+}
856+
857+function mergeOptionalString(
858+  value: string | null | undefined,
859+  existing: string | null
860+): string | null {
861+  return value === undefined ? existing : normalizeOptionalString(value);
862+}
863+
864+function mergeOptionalSerialized(
865+  value: unknown,
866+  existing: string | null
867+): string | null {
868+  return value === undefined ? existing : stringifyUnknown(value);
869+}
870+
871 function stringifyUnknown(value: unknown): string | null {
872   if (value == null) {
873     return null;
874@@ -1051,6 +1796,70 @@ function sessionSyncPayload(record: SessionRecord): Record<string, unknown> {
875   };
876 }
877 
878+function localConversationSyncPayload(
879+  record: LocalConversationRecord
880+): Record<string, unknown> {
881+  return {
882+    local_conversation_id: record.localConversationId,
883+    platform: record.platform,
884+    automation_status: record.automationStatus,
885+    title: record.title,
886+    summary: record.summary,
887+    last_message_id: record.lastMessageId,
888+    last_message_at: record.lastMessageAt,
889+    cooldown_until: record.cooldownUntil,
890+    paused_at: record.pausedAt,
891+    created_at: record.createdAt,
892+    updated_at: record.updatedAt
893+  };
894+}
895+
896+function conversationLinkSyncPayload(
897+  record: ConversationLinkRecord
898+): Record<string, unknown> {
899+  return {
900+    link_id: record.linkId,
901+    local_conversation_id: record.localConversationId,
902+    platform: record.platform,
903+    remote_conversation_id: record.remoteConversationId,
904+    client_id: record.clientId,
905+    page_url: record.pageUrl,
906+    page_title: record.pageTitle,
907+    route_path: record.routePath,
908+    route_pattern: record.routePattern,
909+    route_params: record.routeParams,
910+    target_kind: record.targetKind,
911+    target_id: record.targetId,
912+    target_payload: record.targetPayload,
913+    is_active: record.isActive ? 1 : 0,
914+    observed_at: record.observedAt,
915+    created_at: record.createdAt,
916+    updated_at: record.updatedAt
917+  };
918+}
919+
920+function renewalJobSyncPayload(record: RenewalJobRecord): Record<string, unknown> {
921+  return {
922+    job_id: record.jobId,
923+    local_conversation_id: record.localConversationId,
924+    message_id: record.messageId,
925+    status: record.status,
926+    payload: record.payload,
927+    payload_kind: record.payloadKind,
928+    target_snapshot: record.targetSnapshot,
929+    attempt_count: record.attemptCount,
930+    max_attempts: record.maxAttempts,
931+    next_attempt_at: record.nextAttemptAt,
932+    last_attempt_at: record.lastAttemptAt,
933+    last_error: record.lastError,
934+    log_path: record.logPath,
935+    started_at: record.startedAt,
936+    finished_at: record.finishedAt,
937+    created_at: record.createdAt,
938+    updated_at: record.updatedAt
939+  };
940+}
941+
942 function buildStableHash(value: string): string {
943   let hash = 2_166_136_261;
944 
M packages/artifact-db/src/types.ts
+154, -0
  1@@ -6,8 +6,11 @@ export const DEFAULT_SESSION_INDEX_LIMIT = 20;
  2 export const ARTIFACT_SCOPES = ["msg", "exec", "session"] as const;
  3 
  4 export type ArtifactScope = (typeof ARTIFACT_SCOPES)[number];
  5+export type ConversationAutomationStatus = "manual" | "auto" | "paused";
  6 export type ExecutionParamsKind = "none" | "body" | "inline_json" | "inline_string";
  7 export type ArtifactFileKind = "json" | "txt";
  8+export type RenewalJobPayloadKind = "text" | "json";
  9+export type RenewalJobStatus = "pending" | "running" | "done" | "failed";
 10 
 11 export interface ArtifactStoreConfig {
 12   artifactDir: string;
 13@@ -76,6 +79,60 @@ export interface SessionRecord {
 14   createdAt: number;
 15 }
 16 
 17+export interface LocalConversationRecord {
 18+  localConversationId: string;
 19+  platform: string;
 20+  automationStatus: ConversationAutomationStatus;
 21+  title: string | null;
 22+  summary: string | null;
 23+  lastMessageId: string | null;
 24+  lastMessageAt: number | null;
 25+  cooldownUntil: number | null;
 26+  pausedAt: number | null;
 27+  createdAt: number;
 28+  updatedAt: number;
 29+}
 30+
 31+export interface ConversationLinkRecord {
 32+  linkId: string;
 33+  localConversationId: string;
 34+  platform: string;
 35+  remoteConversationId: string | null;
 36+  clientId: string | null;
 37+  pageUrl: string | null;
 38+  pageTitle: string | null;
 39+  routePath: string | null;
 40+  routePattern: string | null;
 41+  routeParams: string | null;
 42+  targetKind: string | null;
 43+  targetId: string | null;
 44+  targetPayload: string | null;
 45+  isActive: boolean;
 46+  observedAt: number;
 47+  createdAt: number;
 48+  updatedAt: number;
 49+}
 50+
 51+export interface RenewalJobRecord {
 52+  jobId: string;
 53+  localConversationId: string;
 54+  messageId: string;
 55+  status: RenewalJobStatus;
 56+  payload: string;
 57+  payloadKind: RenewalJobPayloadKind;
 58+  targetSnapshot: string | null;
 59+  attemptCount: number;
 60+  maxAttempts: number;
 61+  nextAttemptAt: number | null;
 62+  lastAttemptAt: number | null;
 63+  lastError: string | null;
 64+  logPath: string | null;
 65+  startedAt: number | null;
 66+  finishedAt: number | null;
 67+  createdAt: number;
 68+  updatedAt: number;
 69+}
 70+
 71 export interface InsertMessageInput {
 72   id: string;
 73   platform: string;
 74@@ -118,6 +175,77 @@ export interface UpsertSessionInput {
 75   createdAt?: number;
 76 }
 77 
 78+export interface UpsertLocalConversationInput {
 79+  localConversationId: string;
 80+  platform: string;
 81+  automationStatus?: ConversationAutomationStatus;
 82+  title?: string | null;
 83+  summary?: string | null;
 84+  lastMessageId?: string | null;
 85+  lastMessageAt?: number | null;
 86+  cooldownUntil?: number | null;
 87+  pausedAt?: number | null;
 88+  createdAt?: number;
 89+  updatedAt?: number;
 90+}
 91+
 92+export interface UpsertConversationLinkInput {
 93+  linkId: string;
 94+  localConversationId: string;
 95+  platform: string;
 96+  remoteConversationId?: string | null;
 97+  clientId?: string | null;
 98+  pageUrl?: string | null;
 99+  pageTitle?: string | null;
100+  routePath?: string | null;
101+  routePattern?: string | null;
102+  routeParams?: unknown;
103+  targetKind?: string | null;
104+  targetId?: string | null;
105+  targetPayload?: unknown;
106+  isActive?: boolean;
107+  observedAt: number;
108+  createdAt?: number;
109+  updatedAt?: number;
110+}
111+
112+export interface InsertRenewalJobInput {
113+  jobId: string;
114+  localConversationId: string;
115+  messageId: string;
116+  payload: string;
117+  status?: RenewalJobStatus;
118+  payloadKind?: RenewalJobPayloadKind;
119+  targetSnapshot?: unknown;
120+  attemptCount?: number;
121+  maxAttempts?: number;
122+  nextAttemptAt?: number | null;
123+  lastAttemptAt?: number | null;
124+  lastError?: string | null;
125+  logPath?: string | null;
126+  startedAt?: number | null;
127+  finishedAt?: number | null;
128+  createdAt?: number;
129+  updatedAt?: number;
130+}
131+
132+export interface UpdateRenewalJobInput {
133+  jobId: string;
134+  payload?: string;
135+  status?: RenewalJobStatus;
136+  payloadKind?: RenewalJobPayloadKind;
137+  targetSnapshot?: unknown;
138+  attemptCount?: number;
139+  maxAttempts?: number;
140+  nextAttemptAt?: number | null;
141+  lastAttemptAt?: number | null;
142+  lastError?: string | null;
143+  logPath?: string | null;
144+  startedAt?: number | null;
145+  finishedAt?: number | null;
146+  updatedAt?: number;
147+}
148+
149 export interface ListMessagesOptions {
150   conversationId?: string | null;
151   limit?: number;
152@@ -140,6 +268,32 @@ export interface ListSessionsOptions {
153   platform?: string;
154 }
155 
156+export interface ListLocalConversationsOptions {
157+  automationStatus?: ConversationAutomationStatus;
158+  limit?: number;
159+  offset?: number;
160+  platform?: string;
161+}
162+
163+export interface ListConversationLinksOptions {
164+  clientId?: string;
165+  isActive?: boolean;
166+  limit?: number;
167+  localConversationId?: string;
168+  offset?: number;
169+  platform?: string;
170+  remoteConversationId?: string;
171+}
172+
173+export interface ListRenewalJobsOptions {
174+  limit?: number;
175+  localConversationId?: string;
176+  messageId?: string;
177+  nextAttemptAtLte?: number;
178+  offset?: number;
179+  status?: RenewalJobStatus;
180+}
181+
182 export interface SessionTimelineMessageEntry {
183   kind: "message";
184   id: string;
M packages/d1-client/src/d1-setup.sql
+81, -0
 1@@ -77,3 +77,84 @@ CREATE INDEX IF NOT EXISTS idx_sessions_platform
 2   ON sessions(platform, last_activity_at DESC);
 3 CREATE INDEX IF NOT EXISTS idx_sessions_conversation
 4   ON sessions(conversation_id);
 5+
 6+-- local_conversations table (mirrors local artifact.db)
 7+CREATE TABLE IF NOT EXISTS local_conversations (
 8+  local_conversation_id TEXT PRIMARY KEY,
 9+  platform              TEXT NOT NULL,
10+  automation_status     TEXT NOT NULL DEFAULT 'manual',
11+  title                 TEXT,
12+  summary               TEXT,
13+  last_message_id       TEXT,
14+  last_message_at       INTEGER,
15+  cooldown_until        INTEGER,
16+  paused_at             INTEGER,
17+  created_at            INTEGER NOT NULL,
18+  updated_at            INTEGER NOT NULL
19+);
20+
21+CREATE INDEX IF NOT EXISTS idx_local_conversations_status
22+  ON local_conversations(automation_status, updated_at DESC);
23+CREATE INDEX IF NOT EXISTS idx_local_conversations_platform
24+  ON local_conversations(platform, updated_at DESC);
25+CREATE INDEX IF NOT EXISTS idx_local_conversations_last_message
26+  ON local_conversations(last_message_at DESC);
27+
28+-- conversation_links table (mirrors local artifact.db)
29+CREATE TABLE IF NOT EXISTS conversation_links (
30+  link_id                TEXT PRIMARY KEY,
31+  local_conversation_id  TEXT NOT NULL,
32+  platform               TEXT NOT NULL,
33+  remote_conversation_id TEXT,
34+  client_id              TEXT,
35+  page_url               TEXT,
36+  page_title             TEXT,
37+  route_path             TEXT,
38+  route_pattern          TEXT,
39+  route_params           TEXT,
40+  target_kind            TEXT,
41+  target_id              TEXT,
42+  target_payload         TEXT,
43+  is_active              INTEGER NOT NULL DEFAULT 1,
44+  observed_at            INTEGER NOT NULL,
45+  created_at             INTEGER NOT NULL,
46+  updated_at             INTEGER NOT NULL,
47+  FOREIGN KEY (local_conversation_id) REFERENCES local_conversations(local_conversation_id)
48+);
49+
50+CREATE UNIQUE INDEX IF NOT EXISTS idx_conversation_links_remote
51+  ON conversation_links(platform, remote_conversation_id);
52+CREATE INDEX IF NOT EXISTS idx_conversation_links_local
53+  ON conversation_links(local_conversation_id, is_active, updated_at DESC);
54+CREATE INDEX IF NOT EXISTS idx_conversation_links_client
55+  ON conversation_links(client_id, updated_at DESC);
56+
57+-- renewal_jobs table (mirrors local artifact.db)
58+CREATE TABLE IF NOT EXISTS renewal_jobs (
59+  job_id                 TEXT PRIMARY KEY,
60+  local_conversation_id  TEXT NOT NULL,
61+  message_id             TEXT NOT NULL,
62+  status                 TEXT NOT NULL DEFAULT 'pending',
63+  payload                TEXT NOT NULL,
64+  payload_kind           TEXT NOT NULL DEFAULT 'text',
65+  target_snapshot        TEXT,
66+  attempt_count          INTEGER NOT NULL DEFAULT 0,
67+  max_attempts           INTEGER NOT NULL DEFAULT 3,
68+  next_attempt_at        INTEGER,
69+  last_attempt_at        INTEGER,
70+  last_error             TEXT,
71+  log_path               TEXT,
72+  started_at             INTEGER,
73+  finished_at            INTEGER,
74+  created_at             INTEGER NOT NULL,
75+  updated_at             INTEGER NOT NULL,
76+  FOREIGN KEY (local_conversation_id) REFERENCES local_conversations(local_conversation_id),
77+  FOREIGN KEY (message_id) REFERENCES messages(id)
78+);
79+
80+CREATE UNIQUE INDEX IF NOT EXISTS idx_renewal_jobs_message
81+  ON renewal_jobs(message_id);
82+CREATE INDEX IF NOT EXISTS idx_renewal_jobs_status_due
83+  ON renewal_jobs(status, next_attempt_at ASC, created_at ASC);
84+CREATE INDEX IF NOT EXISTS idx_renewal_jobs_local_conversation
85+  ON renewal_jobs(local_conversation_id, status, created_at DESC);
M packages/d1-client/src/index.test.js
+119, -1
  1@@ -2,7 +2,7 @@ import { describe, it } from "node:test";
  2 import assert from "node:assert/strict";
  3 import { tmpdir } from "node:os";
  4 import { join } from "node:path";
  5-import { mkdtempSync, rmSync } from "node:fs";
  6+import { mkdtempSync, readFileSync, rmSync } from "node:fs";
  7 import { DatabaseSync } from "node:sqlite";
  8 
  9 import { D1Client, createD1Client, SyncQueue, D1SyncWorker, createD1SyncWorker } from "../dist/index.js";
 10@@ -273,6 +273,113 @@ describe("D1SyncWorker", () => {
 11     }
 12   });
 13 
 14+  it("syncs renewal storage tables with whitelisted columns", async () => {
 15+    const tmpDir = mkdtempSync(join(tmpdir(), "d1-test-"));
 16+
 17+    try {
 18+      const db = new DatabaseSync(join(tmpDir, "test.db"));
 19+      const queue = new SyncQueue(db);
 20+      const prepared = [];
 21+      const worker = new D1SyncWorker({
 22+        d1: {
 23+          prepare(statement) {
 24+            const entry = {
 25+              statement,
 26+              params: []
 27+            };
 28+            prepared.push(entry);
 29+
 30+            return {
 31+              async run(...params) {
 32+                entry.params = params;
 33+              }
 34+            };
 35+          }
 36+        },
 37+        queue,
 38+        log: () => {}
 39+      });
 40+
 41+      queue.enqueueSyncRecord({
 42+        tableName: "local_conversations",
 43+        recordId: "lc_001",
 44+        operation: "insert",
 45+        payload: {
 46+          local_conversation_id: "lc_001",
 47+          platform: "claude",
 48+          automation_status: "auto",
 49+          title: "Renewal thread",
 50+          created_at: 123,
 51+          updated_at: 124
 52+        }
 53+      });
 54+      queue.enqueueSyncRecord({
 55+        tableName: "renewal_jobs",
 56+        recordId: "job_001",
 57+        operation: "update",
 58+        payload: {
 59+          job_id: "job_001",
 60+          local_conversation_id: "lc_001",
 61+          message_id: "msg_001",
 62+          status: "running",
 63+          payload: "[renew]",
 64+          payload_kind: "text",
 65+          target_snapshot: "{\"clientId\":\"firefox-claude\"}",
 66+          attempt_count: 1,
 67+          max_attempts: 3,
 68+          next_attempt_at: null,
 69+          last_attempt_at: 456,
 70+          last_error: null,
 71+          log_path: "logs/renewal/2026-03-28.jsonl",
 72+          started_at: 456,
 73+          finished_at: null,
 74+          created_at: 123,
 75+          updated_at: 456
 76+        }
 77+      });
 78+
 79+      const records = queue.dequeuePendingSyncRecords(10);
 80+      await worker.syncRecord(records[0]);
 81+      await worker.syncRecord(records[1]);
 82+
 83+      assert.equal(prepared.length, 2);
 84+      assert.match(prepared[0].statement, /^INSERT INTO local_conversations \(/);
 85+      assert.deepEqual(prepared[0].params, [
 86+        "lc_001",
 87+        "claude",
 88+        "auto",
 89+        "Renewal thread",
 90+        123,
 91+        124
 92+      ]);
 93+      assert.match(prepared[1].statement, /^INSERT INTO renewal_jobs \(/);
 94+      assert.deepEqual(prepared[1].params, [
 95+        "job_001",
 96+        "lc_001",
 97+        "msg_001",
 98+        "running",
 99+        "[renew]",
100+        "text",
101+        "{\"clientId\":\"firefox-claude\"}",
102+        1,
103+        3,
104+        null,
105+        456,
106+        null,
107+        "logs/renewal/2026-03-28.jsonl",
108+        456,
109+        null,
110+        123,
111+        456
112+      ]);
113+      assert.equal(queue.dequeuePendingSyncRecords(10).length, 0);
114+
115+      db.close();
116+    } finally {
117+      rmSync(tmpDir, { recursive: true, force: true });
118+    }
119+  });
120+
121   it("rejects records for non-whitelisted tables", async () => {
122     const tmpDir = mkdtempSync(join(tmpdir(), "d1-test-"));
123 
124@@ -390,3 +497,14 @@ describe("createD1SyncWorker", () => {
125     }
126   });
127 });
128+
129+describe("d1-setup.sql", () => {
130+  it("contains renewal storage tables", () => {
131+    const setupSql = readFileSync(new URL("./d1-setup.sql", import.meta.url), "utf8");
132+
133+    assert.match(setupSql, /CREATE TABLE IF NOT EXISTS local_conversations/u);
134+    assert.match(setupSql, /CREATE TABLE IF NOT EXISTS conversation_links/u);
135+    assert.match(setupSql, /CREATE TABLE IF NOT EXISTS renewal_jobs/u);
136+    assert.match(setupSql, /idx_renewal_jobs_status_due/u);
137+  });
138+});
M packages/d1-client/src/sync-worker.ts
+51, -0
 1@@ -53,6 +53,57 @@ const SYNC_COLUMN_WHITELIST = {
 2     "summary",
 3     "static_path",
 4     "created_at"
 5+  ]),
 6+  local_conversations: new Set([
 7+    "local_conversation_id",
 8+    "platform",
 9+    "automation_status",
10+    "title",
11+    "summary",
12+    "last_message_id",
13+    "last_message_at",
14+    "cooldown_until",
15+    "paused_at",
16+    "created_at",
17+    "updated_at"
18+  ]),
19+  conversation_links: new Set([
20+    "link_id",
21+    "local_conversation_id",
22+    "platform",
23+    "remote_conversation_id",
24+    "client_id",
25+    "page_url",
26+    "page_title",
27+    "route_path",
28+    "route_pattern",
29+    "route_params",
30+    "target_kind",
31+    "target_id",
32+    "target_payload",
33+    "is_active",
34+    "observed_at",
35+    "created_at",
36+    "updated_at"
37+  ]),
38+  renewal_jobs: new Set([
39+    "job_id",
40+    "local_conversation_id",
41+    "message_id",
42+    "status",
43+    "payload",
44+    "payload_kind",
45+    "target_snapshot",
46+    "attempt_count",
47+    "max_attempts",
48+    "next_attempt_at",
49+    "last_attempt_at",
50+    "last_error",
51+    "log_path",
52+    "started_at",
53+    "finished_at",
54+    "created_at",
55+    "updated_at"
56   ])
57 } as const;
58 
M tasks/T-S055.md
+22, -6
 1@@ -2,7 +2,7 @@
 2 
 3 ## 状态
 4 
 5-- 当前状态:`待开始`
 6+- 当前状态:`已完成`
 7 - 规模预估:`M`
 8 - 依赖任务:无
 9 - 建议执行者:`Codex`(存储层、schema、同步队列改动集中)
10@@ -154,22 +154,38 @@
11 
12 ### 开始执行
13 
14-- 执行者:
15-- 开始时间:
16+- 执行者:`Codex`
17+- 开始时间:`2026-03-30 14:42:37 +0800`
18 - 状态变更:`待开始` → `进行中`
19 
20 ### 完成摘要
21 
22-- 完成时间:
23+- 完成时间:`2026-03-30 15:06:32 CST`
24 - 状态变更:`进行中` → `已完成`
25 - 修改了哪些文件:
26+  - `packages/artifact-db/src/schema.ts`
27+  - `packages/artifact-db/src/store.ts`
28+  - `packages/artifact-db/src/types.ts`
29+  - `packages/artifact-db/src/index.ts`
30+  - `packages/artifact-db/src/index.test.js`
31+  - `packages/d1-client/src/d1-setup.sql`
32+  - `packages/d1-client/src/sync-worker.ts`
33+  - `packages/d1-client/src/index.test.js`
34+  - `tasks/T-S055.md`
35 - 核心实现思路:
36+  - 在 `artifact.db` / D1 中新增 `local_conversations`、`conversation_links`、`renewal_jobs` 三张表,并补状态、到期扫描、对话映射所需索引
37+  - 为 `ArtifactStore` 增加本地对话、对话关联、续命任务的 record/types/store 原语,并保留 `automation_status`、`next_attempt_at`、目标快照、日志路径等后续字段
38+  - 新增三张表的 D1 同步白名单,并用测试覆盖 store 原语和白名单 SQL 生成
39 - 跑了哪些测试:
40+  - `cd /Users/george/code/baa-conductor-renewal-storage-foundation && pnpm -C packages/artifact-db test`
41+  - `cd /Users/george/code/baa-conductor-renewal-storage-foundation && pnpm -C packages/d1-client test`
42+  - `cd /Users/george/code/baa-conductor-renewal-storage-foundation && pnpm build`
43+  - `cd /Users/george/code/baa-conductor-renewal-storage-foundation && pnpm test`
44 
45 ### 执行过程中遇到的问题
46 
47-- 
48+- worktree 初始未安装依赖,`pnpm exec tsc` 直接失败;补跑一次 `pnpm install` 后恢复正常验证流程
49 
50 ### 剩余风险
51 
52-- 
53+- `conversation_links` 当前按 `(platform, remote_conversation_id)` 维持唯一映射,后续如果同一平台对话需要并存多条活跃路由,需要在 `T-S056` 里结合真实输入模型再细化冲突策略