- 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
+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+});
+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";
+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 `;
+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
+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;
+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);
+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+});
+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
+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` 里结合真实输入模型再细化冲突策略