birdclaw 0.5.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/README.md +5 -0
- package/bin/birdclaw.mjs +50 -11
- package/package.json +7 -6
- package/public/birdclaw-mark.png +0 -0
- package/scripts/browser-perf.mjs +399 -0
- package/scripts/build-docs-site.mjs +940 -0
- package/scripts/docs-site-assets.mjs +311 -0
- package/scripts/run-vitest.mjs +21 -0
- package/scripts/sanitize-node-options.mjs +23 -0
- package/scripts/start-test-server.mjs +29 -0
- package/src/cli.ts +46 -1
- package/src/components/AppNav.tsx +3 -3
- package/src/components/BrandMark.tsx +67 -0
- package/src/components/ConversationThread.tsx +11 -10
- package/src/components/DmWorkspace.tsx +24 -9
- package/src/components/FeedState.tsx +147 -0
- package/src/components/InboxCard.tsx +14 -2
- package/src/components/SavedTimelineView.tsx +64 -56
- package/src/components/SyncNowButton.tsx +105 -0
- package/src/components/ThemeSlider.tsx +10 -59
- package/src/components/TimelineCard.tsx +157 -61
- package/src/components/TimelineRouteFrame.tsx +156 -0
- package/src/components/TweetMediaGrid.tsx +120 -23
- package/src/components/TweetRichText.tsx +13 -2
- package/src/components/useTimelineRouteData.ts +137 -0
- package/src/lib/api-client.ts +229 -0
- package/src/lib/archive-finder.ts +24 -20
- package/src/lib/archive-import.ts +18 -3
- package/src/lib/conversation-surface.ts +174 -0
- package/src/lib/db.ts +2 -0
- package/src/lib/queries.ts +93 -28
- package/src/lib/ui.ts +11 -3
- package/src/lib/web-sync.ts +443 -0
- package/src/routeTree.gen.ts +21 -0
- package/src/routes/api/sync.tsx +59 -0
- package/src/routes/dms.tsx +100 -27
- package/src/routes/index.tsx +21 -127
- package/src/routes/links.tsx +50 -5
- package/src/routes/mentions.tsx +21 -127
- package/src/styles.css +74 -11
- package/vite.config.ts +8 -0
package/src/lib/queries.ts
CHANGED
|
@@ -321,32 +321,39 @@ function countTimelineEdges(db: Database, kind: "home" | "mention") {
|
|
|
321
321
|
const row = db
|
|
322
322
|
.prepare(
|
|
323
323
|
`
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
324
|
+
select (
|
|
325
|
+
(
|
|
326
|
+
select count(*)
|
|
327
|
+
from tweet_account_edges edge
|
|
328
|
+
where edge.kind = ?
|
|
329
|
+
and exists (
|
|
330
|
+
select 1
|
|
331
|
+
from tweets t
|
|
332
|
+
where t.id = edge.tweet_id
|
|
333
|
+
)
|
|
334
|
+
)
|
|
335
|
+
+
|
|
336
|
+
(
|
|
337
|
+
select count(*)
|
|
338
|
+
from tweets legacy
|
|
339
|
+
where legacy.kind = ?
|
|
340
|
+
and not exists (
|
|
341
|
+
select 1
|
|
342
|
+
from tweet_account_edges edge
|
|
343
|
+
where edge.account_id = legacy.account_id
|
|
344
|
+
and edge.tweet_id = legacy.id
|
|
345
|
+
and edge.kind = legacy.kind
|
|
346
|
+
)
|
|
347
|
+
)
|
|
348
|
+
) as count
|
|
344
349
|
`,
|
|
345
350
|
)
|
|
346
|
-
.get(kind, kind
|
|
351
|
+
.get(kind, kind) as { count: number | bigint } | undefined;
|
|
347
352
|
return Number(row?.count ?? 0);
|
|
348
353
|
}
|
|
349
354
|
|
|
355
|
+
const RECENT_TIMELINE_EDGE_CANDIDATES = 5000;
|
|
356
|
+
|
|
350
357
|
export async function getQueryEnvelope(): Promise<QueryEnvelope> {
|
|
351
358
|
const db = getDb();
|
|
352
359
|
const nativeDb = getNativeDb();
|
|
@@ -432,10 +439,23 @@ export function listTimelineItems({
|
|
|
432
439
|
)
|
|
433
440
|
)
|
|
434
441
|
`;
|
|
442
|
+
const unwindowedTimelineEdgesCte = timelineEdgesCte;
|
|
443
|
+
let usedRecentEdgeWindow = false;
|
|
435
444
|
let join = "";
|
|
436
445
|
let where = "where t.kind = ?";
|
|
437
446
|
let searchSnippetSelect = "";
|
|
438
447
|
|
|
448
|
+
const canUseRecentEdgeWindow =
|
|
449
|
+
!likedOnly &&
|
|
450
|
+
!bookmarkedOnly &&
|
|
451
|
+
!account &&
|
|
452
|
+
!search?.trim() &&
|
|
453
|
+
replyFilter === "all" &&
|
|
454
|
+
!since?.trim() &&
|
|
455
|
+
!until?.trim() &&
|
|
456
|
+
includeReplies &&
|
|
457
|
+
qualityFilter === "all";
|
|
458
|
+
|
|
439
459
|
if (likedOnly || bookmarkedOnly) {
|
|
440
460
|
if (likedOnly && bookmarkedOnly) {
|
|
441
461
|
timelineEdgesCte = `
|
|
@@ -481,10 +501,49 @@ export function listTimelineItems({
|
|
|
481
501
|
and collection.kind = ?
|
|
482
502
|
)
|
|
483
503
|
)
|
|
484
|
-
|
|
504
|
+
`;
|
|
485
505
|
params.push(collectionKind, collectionKind);
|
|
486
506
|
}
|
|
487
507
|
where = "where 1 = 1";
|
|
508
|
+
} else if (canUseRecentEdgeWindow) {
|
|
509
|
+
usedRecentEdgeWindow = true;
|
|
510
|
+
timelineEdgesCte = `
|
|
511
|
+
with timeline_edges as (
|
|
512
|
+
select account_id, tweet_id, kind
|
|
513
|
+
from tweet_account_edges
|
|
514
|
+
where kind = ?
|
|
515
|
+
and tweet_id in (
|
|
516
|
+
select id
|
|
517
|
+
from tweets
|
|
518
|
+
order by created_at desc
|
|
519
|
+
limit ?
|
|
520
|
+
)
|
|
521
|
+
union all
|
|
522
|
+
select legacy.account_id, legacy.id as tweet_id, legacy.kind
|
|
523
|
+
from tweets legacy
|
|
524
|
+
where legacy.kind = ?
|
|
525
|
+
and legacy.id in (
|
|
526
|
+
select id
|
|
527
|
+
from tweets
|
|
528
|
+
order by created_at desc
|
|
529
|
+
limit ?
|
|
530
|
+
)
|
|
531
|
+
and not exists (
|
|
532
|
+
select 1
|
|
533
|
+
from tweet_account_edges edge
|
|
534
|
+
where edge.account_id = legacy.account_id
|
|
535
|
+
and edge.tweet_id = legacy.id
|
|
536
|
+
and edge.kind = legacy.kind
|
|
537
|
+
)
|
|
538
|
+
)
|
|
539
|
+
`;
|
|
540
|
+
const candidateLimit = Math.max(
|
|
541
|
+
RECENT_TIMELINE_EDGE_CANDIDATES,
|
|
542
|
+
limit * 50,
|
|
543
|
+
);
|
|
544
|
+
params.push(kind, candidateLimit, kind, candidateLimit);
|
|
545
|
+
where = "where e.kind = ?";
|
|
546
|
+
params.push(kind);
|
|
488
547
|
} else {
|
|
489
548
|
params.push(kind, kind);
|
|
490
549
|
where = "where e.kind = ?";
|
|
@@ -532,10 +591,8 @@ export function listTimelineItems({
|
|
|
532
591
|
|
|
533
592
|
params.push(limit);
|
|
534
593
|
|
|
535
|
-
const
|
|
536
|
-
|
|
537
|
-
`
|
|
538
|
-
${timelineEdgesCte}
|
|
594
|
+
const buildTimelineSelectSql = (timelineEdgesSql: string) => `
|
|
595
|
+
${timelineEdgesSql}
|
|
539
596
|
select
|
|
540
597
|
t.id,
|
|
541
598
|
e.account_id,
|
|
@@ -626,10 +683,18 @@ export function listTimelineItems({
|
|
|
626
683
|
${where}
|
|
627
684
|
order by t.created_at desc
|
|
628
685
|
limit ?
|
|
629
|
-
|
|
630
|
-
|
|
686
|
+
`;
|
|
687
|
+
|
|
688
|
+
let rows = db
|
|
689
|
+
.prepare(buildTimelineSelectSql(timelineEdgesCte))
|
|
631
690
|
.all(...params) as Array<Record<string, unknown>>;
|
|
632
691
|
|
|
692
|
+
if (usedRecentEdgeWindow && rows.length < limit) {
|
|
693
|
+
rows = db
|
|
694
|
+
.prepare(buildTimelineSelectSql(unwindowedTimelineEdgesCte))
|
|
695
|
+
.all(kind, kind, kind, limit) as Array<Record<string, unknown>>;
|
|
696
|
+
}
|
|
697
|
+
|
|
633
698
|
const urlExpansionCache: UrlExpansionCache = new Map();
|
|
634
699
|
return rows.map((row) => {
|
|
635
700
|
const author = {
|
package/src/lib/ui.ts
CHANGED
|
@@ -15,8 +15,7 @@ export const sidebarShellClass =
|
|
|
15
15
|
export const sidebarBrandClass =
|
|
16
16
|
"flex items-center gap-2.5 px-2 py-2 text-[var(--ink)] min-[1100px]:px-3";
|
|
17
17
|
|
|
18
|
-
export const sidebarBrandMarkClass =
|
|
19
|
-
"grid size-9 place-items-center rounded-full bg-[var(--accent)] text-white";
|
|
18
|
+
export const sidebarBrandMarkClass = "grid size-10 place-items-center";
|
|
20
19
|
|
|
21
20
|
export const sidebarBrandCopyClass =
|
|
22
21
|
"hidden flex-col leading-tight min-[1100px]:flex";
|
|
@@ -121,6 +120,15 @@ export const feedRowBadgeAlertClass =
|
|
|
121
120
|
export const feedRowBadgeNeutralClass =
|
|
122
121
|
"bg-[var(--bg-active)] text-[var(--ink-soft)]";
|
|
123
122
|
|
|
123
|
+
export const feedRowStatePillClass =
|
|
124
|
+
"inline-flex shrink-0 items-center gap-1 rounded-full border border-[var(--line)] bg-[var(--bg-active)] px-2 py-0.5 text-[12px] font-semibold text-[var(--ink-soft)]";
|
|
125
|
+
|
|
126
|
+
export const feedRowStatePillActiveClass =
|
|
127
|
+
"border-[color:color-mix(in_srgb,var(--accent)_35%,var(--line))] bg-[var(--accent-soft)] text-[var(--accent)]";
|
|
128
|
+
|
|
129
|
+
export const feedRowStatePillOpenClass =
|
|
130
|
+
"border-[color:color-mix(in_srgb,var(--ink-soft)_34%,var(--line))] bg-transparent text-[var(--ink-soft)]";
|
|
131
|
+
|
|
124
132
|
/* Forms / inputs. */
|
|
125
133
|
export const searchFieldShellClass =
|
|
126
134
|
"flex items-center gap-2 rounded-full border border-transparent bg-[var(--bg-active)] px-4 py-2 transition-colors focus-within:border-[var(--accent)] focus-within:bg-[var(--bg)] focus-within:shadow-[0_0_0_1px_var(--accent)]";
|
|
@@ -328,7 +336,7 @@ export function tweetMediaGridClass(count: number) {
|
|
|
328
336
|
|
|
329
337
|
export function tweetMediaTileClass(index: number, count: number) {
|
|
330
338
|
return cx(
|
|
331
|
-
"tweet-media-tile relative block overflow-hidden bg-[var(--bg-active)]",
|
|
339
|
+
"tweet-media-tile relative block overflow-hidden border-0 bg-[var(--bg-active)] p-0 text-left",
|
|
332
340
|
count === 1 && "aspect-[16/10]",
|
|
333
341
|
count === 2 && "aspect-square",
|
|
334
342
|
count === 3 && index === 0 && "row-span-2 aspect-[3/4]",
|
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { maybeAutoSyncBackup } from "./backup";
|
|
3
|
+
import { getBirdclawPaths } from "./config";
|
|
4
|
+
import { syncDirectMessagesViaCachedBird } from "./dms-live";
|
|
5
|
+
import { syncMentionThreads } from "./mention-threads-live";
|
|
6
|
+
import { syncMentions } from "./mentions-live";
|
|
7
|
+
import NativeSqliteDatabase from "./sqlite";
|
|
8
|
+
import { syncTimelineCollection } from "./timeline-collections-live";
|
|
9
|
+
import { syncHomeTimeline } from "./timeline-live";
|
|
10
|
+
|
|
11
|
+
export type WebSyncKind =
|
|
12
|
+
| "timeline"
|
|
13
|
+
| "mentions"
|
|
14
|
+
| "likes"
|
|
15
|
+
| "bookmarks"
|
|
16
|
+
| "dms";
|
|
17
|
+
|
|
18
|
+
export interface WebSyncStep {
|
|
19
|
+
kind: WebSyncKind | "mention-threads";
|
|
20
|
+
label: string;
|
|
21
|
+
count: number;
|
|
22
|
+
source?: string;
|
|
23
|
+
partial?: boolean;
|
|
24
|
+
warnings?: string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface WebSyncResponse {
|
|
28
|
+
ok: boolean;
|
|
29
|
+
kind: WebSyncKind;
|
|
30
|
+
accountId?: string;
|
|
31
|
+
startedAt: string;
|
|
32
|
+
finishedAt?: string;
|
|
33
|
+
summary: string;
|
|
34
|
+
steps: WebSyncStep[];
|
|
35
|
+
inProgress?: boolean;
|
|
36
|
+
backup?: Awaited<ReturnType<typeof maybeAutoSyncBackup>>;
|
|
37
|
+
error?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type WebSyncJobStatus = "running" | "succeeded" | "failed";
|
|
41
|
+
|
|
42
|
+
export interface WebSyncJobSnapshot {
|
|
43
|
+
id: string;
|
|
44
|
+
kind: WebSyncKind;
|
|
45
|
+
accountId?: string;
|
|
46
|
+
status: WebSyncJobStatus;
|
|
47
|
+
startedAt: string;
|
|
48
|
+
finishedAt?: string;
|
|
49
|
+
summary: string;
|
|
50
|
+
inProgress: boolean;
|
|
51
|
+
result?: WebSyncResponse;
|
|
52
|
+
error?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface WebSyncPlan {
|
|
56
|
+
label: string;
|
|
57
|
+
accountAware: boolean;
|
|
58
|
+
run: (accountId: string | undefined) => Promise<WebSyncStep[]>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const runningSyncs = new Map<string, WebSyncJobSnapshot>();
|
|
62
|
+
const webSyncJobs = new Map<string, WebSyncJobSnapshot>();
|
|
63
|
+
const webSyncJobKeys = new Map<string, string>();
|
|
64
|
+
const completedJobCleanupTimers = new Map<
|
|
65
|
+
string,
|
|
66
|
+
ReturnType<typeof setTimeout>
|
|
67
|
+
>();
|
|
68
|
+
const COMPLETED_JOB_TTL_MS = 5 * 60 * 1000;
|
|
69
|
+
|
|
70
|
+
function assertRecord(
|
|
71
|
+
value: unknown,
|
|
72
|
+
): asserts value is Record<string, unknown> {
|
|
73
|
+
if (!value || typeof value !== "object") {
|
|
74
|
+
throw new Error("Expected sync result object");
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function readNumber(value: unknown, key: string): number {
|
|
79
|
+
assertRecord(value);
|
|
80
|
+
const raw = value[key];
|
|
81
|
+
return typeof raw === "number" && Number.isFinite(raw) ? raw : 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function readString(value: unknown, key: string) {
|
|
85
|
+
assertRecord(value);
|
|
86
|
+
const raw = value[key];
|
|
87
|
+
return typeof raw === "string" ? raw : undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function readBoolean(value: unknown, key: string) {
|
|
91
|
+
assertRecord(value);
|
|
92
|
+
const raw = value[key];
|
|
93
|
+
return typeof raw === "boolean" ? raw : undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function parseWebSyncKind(value: unknown): WebSyncKind | null {
|
|
97
|
+
return value === "timeline" ||
|
|
98
|
+
value === "mentions" ||
|
|
99
|
+
value === "likes" ||
|
|
100
|
+
value === "bookmarks" ||
|
|
101
|
+
value === "dms"
|
|
102
|
+
? value
|
|
103
|
+
: null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function summarizeSteps(steps: WebSyncStep[]) {
|
|
107
|
+
const total = steps.reduce((sum, step) => sum + step.count, 0);
|
|
108
|
+
const partial = steps.some((step) => step.partial);
|
|
109
|
+
const suffix = partial ? " (partial)" : "";
|
|
110
|
+
return `Synced ${String(total)} items${suffix}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const WEB_SYNC_PLANS: Record<WebSyncKind, WebSyncPlan> = {
|
|
114
|
+
timeline: {
|
|
115
|
+
label: "Home timeline",
|
|
116
|
+
accountAware: false,
|
|
117
|
+
run: async (account) => {
|
|
118
|
+
const result = await syncHomeTimeline({
|
|
119
|
+
account,
|
|
120
|
+
limit: 100,
|
|
121
|
+
following: true,
|
|
122
|
+
refresh: true,
|
|
123
|
+
});
|
|
124
|
+
return [
|
|
125
|
+
{
|
|
126
|
+
kind: "timeline",
|
|
127
|
+
label: "Home timeline",
|
|
128
|
+
count: readNumber(result, "count"),
|
|
129
|
+
source: readString(result, "source"),
|
|
130
|
+
},
|
|
131
|
+
];
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
mentions: {
|
|
135
|
+
label: "Mentions",
|
|
136
|
+
accountAware: true,
|
|
137
|
+
run: async (account) => {
|
|
138
|
+
const mentions = await syncMentions({
|
|
139
|
+
account,
|
|
140
|
+
mode: "xurl",
|
|
141
|
+
limit: 100,
|
|
142
|
+
maxPages: 3,
|
|
143
|
+
refresh: true,
|
|
144
|
+
});
|
|
145
|
+
const steps: WebSyncStep[] = [
|
|
146
|
+
{
|
|
147
|
+
kind: "mentions",
|
|
148
|
+
label: "Mentions",
|
|
149
|
+
count: readNumber(mentions, "count"),
|
|
150
|
+
source: readString(mentions, "source"),
|
|
151
|
+
partial: readBoolean(mentions, "partial"),
|
|
152
|
+
},
|
|
153
|
+
];
|
|
154
|
+
|
|
155
|
+
const threads = await syncMentionThreads({
|
|
156
|
+
account,
|
|
157
|
+
mode: "xurl",
|
|
158
|
+
limit: 30,
|
|
159
|
+
delayMs: 1500,
|
|
160
|
+
timeoutMs: 15000,
|
|
161
|
+
});
|
|
162
|
+
steps.push({
|
|
163
|
+
kind: "mention-threads",
|
|
164
|
+
label: "Mention threads",
|
|
165
|
+
count: readNumber(threads, "mergedTweets"),
|
|
166
|
+
source: readString(threads, "source"),
|
|
167
|
+
partial: readBoolean(threads, "partial"),
|
|
168
|
+
warnings:
|
|
169
|
+
Array.isArray(threads.warnings) && threads.warnings.length > 0
|
|
170
|
+
? threads.warnings.map(String)
|
|
171
|
+
: undefined,
|
|
172
|
+
});
|
|
173
|
+
return steps;
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
likes: {
|
|
177
|
+
label: "Likes",
|
|
178
|
+
accountAware: true,
|
|
179
|
+
run: (account) => syncSavedCollection("likes", account),
|
|
180
|
+
},
|
|
181
|
+
bookmarks: {
|
|
182
|
+
label: "Bookmarks",
|
|
183
|
+
accountAware: true,
|
|
184
|
+
run: (account) => syncSavedCollection("bookmarks", account),
|
|
185
|
+
},
|
|
186
|
+
dms: {
|
|
187
|
+
label: "Direct messages",
|
|
188
|
+
accountAware: false,
|
|
189
|
+
run: async (account) => {
|
|
190
|
+
const result = await syncDirectMessagesViaCachedBird({
|
|
191
|
+
account,
|
|
192
|
+
limit: 50,
|
|
193
|
+
refresh: true,
|
|
194
|
+
});
|
|
195
|
+
return [
|
|
196
|
+
{
|
|
197
|
+
kind: "dms",
|
|
198
|
+
label: "Direct messages",
|
|
199
|
+
count: readNumber(result, "messages"),
|
|
200
|
+
source: readString(result, "source"),
|
|
201
|
+
},
|
|
202
|
+
];
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
async function syncSavedCollection(
|
|
208
|
+
kind: "likes" | "bookmarks",
|
|
209
|
+
account: string | undefined,
|
|
210
|
+
) {
|
|
211
|
+
const isNonDefaultAccount =
|
|
212
|
+
account !== undefined && account !== resolveDefaultSyncAccountId();
|
|
213
|
+
const result = await syncTimelineCollection({
|
|
214
|
+
kind,
|
|
215
|
+
account,
|
|
216
|
+
mode: isNonDefaultAccount ? "xurl" : "auto",
|
|
217
|
+
limit: 100,
|
|
218
|
+
maxPages: 5,
|
|
219
|
+
refresh: true,
|
|
220
|
+
earlyStop: true,
|
|
221
|
+
});
|
|
222
|
+
return [
|
|
223
|
+
{
|
|
224
|
+
kind,
|
|
225
|
+
label: kind === "likes" ? "Likes" : "Bookmarks",
|
|
226
|
+
count: readNumber(result, "count"),
|
|
227
|
+
source: readString(result, "source"),
|
|
228
|
+
},
|
|
229
|
+
];
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function performWebSync(
|
|
233
|
+
kind: WebSyncKind,
|
|
234
|
+
accountId?: string,
|
|
235
|
+
): Promise<WebSyncResponse> {
|
|
236
|
+
const startedAt = new Date().toISOString();
|
|
237
|
+
const steps = await WEB_SYNC_PLANS[kind].run(accountId);
|
|
238
|
+
|
|
239
|
+
const backup = await maybeAutoSyncBackup();
|
|
240
|
+
const finishedAt = new Date().toISOString();
|
|
241
|
+
return {
|
|
242
|
+
ok: true,
|
|
243
|
+
kind,
|
|
244
|
+
...(accountId ? { accountId } : {}),
|
|
245
|
+
startedAt,
|
|
246
|
+
finishedAt,
|
|
247
|
+
summary: summarizeSteps(steps),
|
|
248
|
+
steps,
|
|
249
|
+
backup,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function createWebSyncJobId(kind: WebSyncKind) {
|
|
254
|
+
return `sync_${kind}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function resolveDefaultSyncAccountId() {
|
|
258
|
+
const dbPath = getBirdclawPaths().dbPath;
|
|
259
|
+
if (!existsSync(dbPath)) {
|
|
260
|
+
return "acct_primary";
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
let db: NativeSqliteDatabase | undefined;
|
|
264
|
+
try {
|
|
265
|
+
db = new NativeSqliteDatabase(dbPath, { readonly: true });
|
|
266
|
+
const row = db
|
|
267
|
+
.prepare(
|
|
268
|
+
`
|
|
269
|
+
select id
|
|
270
|
+
from accounts
|
|
271
|
+
order by is_default desc, created_at asc
|
|
272
|
+
limit 1
|
|
273
|
+
`,
|
|
274
|
+
)
|
|
275
|
+
.get() as { id: string } | undefined;
|
|
276
|
+
return row?.id ?? "acct_primary";
|
|
277
|
+
} catch {
|
|
278
|
+
return "acct_primary";
|
|
279
|
+
} finally {
|
|
280
|
+
db?.close();
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function getRunningSyncKey(kind: WebSyncKind, accountId: string | undefined) {
|
|
285
|
+
if (!WEB_SYNC_PLANS[kind].accountAware) {
|
|
286
|
+
return kind;
|
|
287
|
+
}
|
|
288
|
+
return `${kind}:${accountId ?? resolveDefaultSyncAccountId()}`;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function getEffectiveAccountId(
|
|
292
|
+
kind: WebSyncKind,
|
|
293
|
+
accountId: string | undefined,
|
|
294
|
+
) {
|
|
295
|
+
return WEB_SYNC_PLANS[kind].accountAware ? accountId : undefined;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function setJobSnapshot(snapshot: WebSyncJobSnapshot) {
|
|
299
|
+
webSyncJobs.set(snapshot.id, snapshot);
|
|
300
|
+
const syncKey =
|
|
301
|
+
webSyncJobKeys.get(snapshot.id) ??
|
|
302
|
+
getRunningSyncKey(snapshot.kind, snapshot.accountId);
|
|
303
|
+
const cleanupTimer = completedJobCleanupTimers.get(snapshot.id);
|
|
304
|
+
if (cleanupTimer) {
|
|
305
|
+
clearTimeout(cleanupTimer);
|
|
306
|
+
completedJobCleanupTimers.delete(snapshot.id);
|
|
307
|
+
}
|
|
308
|
+
if (snapshot.inProgress) {
|
|
309
|
+
runningSyncs.set(syncKey, snapshot);
|
|
310
|
+
} else if (runningSyncs.get(syncKey)?.id === snapshot.id) {
|
|
311
|
+
runningSyncs.delete(syncKey);
|
|
312
|
+
const timer = setTimeout(() => {
|
|
313
|
+
webSyncJobs.delete(snapshot.id);
|
|
314
|
+
webSyncJobKeys.delete(snapshot.id);
|
|
315
|
+
completedJobCleanupTimers.delete(snapshot.id);
|
|
316
|
+
}, COMPLETED_JOB_TTL_MS);
|
|
317
|
+
timer.unref?.();
|
|
318
|
+
completedJobCleanupTimers.set(snapshot.id, timer);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function toFailedResponse(
|
|
323
|
+
kind: WebSyncKind,
|
|
324
|
+
startedAt: string,
|
|
325
|
+
error: unknown,
|
|
326
|
+
accountId?: string,
|
|
327
|
+
): WebSyncResponse {
|
|
328
|
+
const finishedAt = new Date().toISOString();
|
|
329
|
+
const message = error instanceof Error ? error.message : "Sync failed";
|
|
330
|
+
return {
|
|
331
|
+
ok: false,
|
|
332
|
+
kind,
|
|
333
|
+
...(accountId ? { accountId } : {}),
|
|
334
|
+
startedAt,
|
|
335
|
+
finishedAt,
|
|
336
|
+
summary: message,
|
|
337
|
+
steps: [],
|
|
338
|
+
error: message,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export function startWebSync(
|
|
343
|
+
kind: WebSyncKind,
|
|
344
|
+
accountId?: string,
|
|
345
|
+
): WebSyncJobSnapshot {
|
|
346
|
+
const effectiveAccountId = getEffectiveAccountId(kind, accountId);
|
|
347
|
+
const syncKey = getRunningSyncKey(kind, effectiveAccountId);
|
|
348
|
+
const current = runningSyncs.get(syncKey);
|
|
349
|
+
if (current) {
|
|
350
|
+
return current;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const startedAt = new Date().toISOString();
|
|
354
|
+
const job: WebSyncJobSnapshot = {
|
|
355
|
+
id: createWebSyncJobId(kind),
|
|
356
|
+
kind,
|
|
357
|
+
...(effectiveAccountId ? { accountId: effectiveAccountId } : {}),
|
|
358
|
+
status: "running",
|
|
359
|
+
startedAt,
|
|
360
|
+
summary: `Syncing ${WEB_SYNC_PLANS[kind].label}`,
|
|
361
|
+
inProgress: true,
|
|
362
|
+
};
|
|
363
|
+
webSyncJobKeys.set(job.id, syncKey);
|
|
364
|
+
setJobSnapshot(job);
|
|
365
|
+
|
|
366
|
+
void performWebSync(kind, effectiveAccountId)
|
|
367
|
+
.then((result) => {
|
|
368
|
+
setJobSnapshot({
|
|
369
|
+
...job,
|
|
370
|
+
status: "succeeded",
|
|
371
|
+
finishedAt: result.finishedAt,
|
|
372
|
+
summary: result.summary,
|
|
373
|
+
inProgress: false,
|
|
374
|
+
result,
|
|
375
|
+
});
|
|
376
|
+
})
|
|
377
|
+
.catch((error: unknown) => {
|
|
378
|
+
const result = toFailedResponse(
|
|
379
|
+
kind,
|
|
380
|
+
startedAt,
|
|
381
|
+
error,
|
|
382
|
+
effectiveAccountId,
|
|
383
|
+
);
|
|
384
|
+
setJobSnapshot({
|
|
385
|
+
...job,
|
|
386
|
+
status: "failed",
|
|
387
|
+
finishedAt: result.finishedAt,
|
|
388
|
+
summary: result.summary,
|
|
389
|
+
inProgress: false,
|
|
390
|
+
result,
|
|
391
|
+
error: result.error,
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
return job;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export function getWebSyncJob(id: string): WebSyncJobSnapshot | null {
|
|
399
|
+
return webSyncJobs.get(id) ?? null;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
export async function runWebSync(
|
|
403
|
+
kind: WebSyncKind,
|
|
404
|
+
accountId?: string,
|
|
405
|
+
): Promise<WebSyncResponse> {
|
|
406
|
+
const effectiveAccountId = getEffectiveAccountId(kind, accountId);
|
|
407
|
+
const current = runningSyncs.get(getRunningSyncKey(kind, effectiveAccountId));
|
|
408
|
+
const startedAt = new Date().toISOString();
|
|
409
|
+
if (current) {
|
|
410
|
+
return {
|
|
411
|
+
ok: false,
|
|
412
|
+
kind,
|
|
413
|
+
...(effectiveAccountId ? { accountId: effectiveAccountId } : {}),
|
|
414
|
+
startedAt,
|
|
415
|
+
summary: "Sync already running",
|
|
416
|
+
steps: [],
|
|
417
|
+
inProgress: true,
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const job = startWebSync(kind, effectiveAccountId);
|
|
422
|
+
while (job.inProgress) {
|
|
423
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
424
|
+
const latest = getWebSyncJob(job.id);
|
|
425
|
+
if (!latest?.inProgress) {
|
|
426
|
+
if (!latest?.result) throw new Error("Sync job disappeared");
|
|
427
|
+
return latest.result;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if (!job.result) throw new Error("Sync job did not finish");
|
|
432
|
+
return job.result;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
export function clearWebSyncLocksForTests() {
|
|
436
|
+
runningSyncs.clear();
|
|
437
|
+
webSyncJobs.clear();
|
|
438
|
+
webSyncJobKeys.clear();
|
|
439
|
+
for (const timer of completedJobCleanupTimers.values()) {
|
|
440
|
+
clearTimeout(timer);
|
|
441
|
+
}
|
|
442
|
+
completedJobCleanupTimers.clear();
|
|
443
|
+
}
|