@gperezmz/bb-plugin-thread-glance 0.1.2
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/LICENSE +21 -0
- package/README.md +20 -0
- package/THIRD_PARTY_NOTICES.md +230 -0
- package/app.tsx +12 -0
- package/components/ui/alert-dialog.tsx +142 -0
- package/components/ui/button.tsx +59 -0
- package/components/ui/card.tsx +83 -0
- package/components/ui/coarse-pointer-sizing.ts +66 -0
- package/components/ui/context-menu.tsx +355 -0
- package/components/ui/dialog.tsx +416 -0
- package/components/ui/dropdown-menu.tsx +640 -0
- package/components/ui/hooks/use-compact-viewport.tsx +37 -0
- package/components/ui/hooks/use-media-query.ts +70 -0
- package/components/ui/hooks/use-pointer-coarse.ts +7 -0
- package/components/ui/hover-card.tsx +36 -0
- package/components/ui/icon-extended.tsx +333 -0
- package/components/ui/icon-registry.ts +189 -0
- package/components/ui/icon.tsx +444 -0
- package/components/ui/input.tsx +30 -0
- package/components/ui/menu-item-hover.tsx +93 -0
- package/components/ui/motion.ts +4 -0
- package/components/ui/overlay-trigger.ts +84 -0
- package/components/ui/popover.tsx +223 -0
- package/components/ui/responsive-overlay.tsx +823 -0
- package/components/ui/tooltip.tsx +63 -0
- package/dist/app.css +2 -0
- package/dist/app.js +66 -0
- package/dist/app.meta.json +11 -0
- package/dist/server.js +71 -0
- package/dist/server.js.map +6 -0
- package/dist/server.meta.json +11 -0
- package/features/thread-list/components/Dialogs.tsx +299 -0
- package/features/thread-list/components/FoldRows.tsx +147 -0
- package/features/thread-list/components/GroupSection.tsx +370 -0
- package/features/thread-list/components/ProviderBadge.tsx +57 -0
- package/features/thread-list/components/PullRequestBadge.tsx +50 -0
- package/features/thread-list/components/Rails.tsx +28 -0
- package/features/thread-list/components/RenameEditor.tsx +118 -0
- package/features/thread-list/components/RowMenu.tsx +168 -0
- package/features/thread-list/components/SplitMiniMap.tsx +55 -0
- package/features/thread-list/components/ThreadDetails.tsx +138 -0
- package/features/thread-list/components/ThreadList.tsx +855 -0
- package/features/thread-list/components/ThreadRowView.tsx +631 -0
- package/features/thread-list/components/Toolbar.tsx +296 -0
- package/features/thread-list/components/controller.ts +64 -0
- package/features/thread-list/components/glyphs.tsx +129 -0
- package/features/thread-list/components/row-card.ts +127 -0
- package/features/thread-list/data/storage.ts +18 -0
- package/features/thread-list/data/useAutoExpand.ts +53 -0
- package/features/thread-list/data/useClientPreferences.ts +20 -0
- package/features/thread-list/data/useNotes.ts +41 -0
- package/features/thread-list/data/useNow.ts +19 -0
- package/features/thread-list/data/usePreferences.ts +126 -0
- package/features/thread-list/data/useScheduled.ts +43 -0
- package/features/thread-list/data/useStamps.ts +68 -0
- package/features/thread-list/icons.ts +44 -0
- package/features/thread-list/index.ts +2 -0
- package/features/thread-list/model/attention.ts +89 -0
- package/features/thread-list/model/counters.ts +67 -0
- package/features/thread-list/model/details.ts +79 -0
- package/features/thread-list/model/drag.ts +104 -0
- package/features/thread-list/model/expansion.ts +71 -0
- package/features/thread-list/model/families.ts +297 -0
- package/features/thread-list/model/groups.ts +263 -0
- package/features/thread-list/model/labels.ts +50 -0
- package/features/thread-list/model/layout.ts +61 -0
- package/features/thread-list/model/menu.ts +78 -0
- package/features/thread-list/model/move.ts +46 -0
- package/features/thread-list/model/notes.ts +52 -0
- package/features/thread-list/model/provider-mark.ts +59 -0
- package/features/thread-list/model/share.ts +101 -0
- package/features/thread-list/model/sort.ts +78 -0
- package/features/thread-list/model/state.ts +330 -0
- package/features/thread-list/model/time.ts +107 -0
- package/features/thread-list/model/toggles.ts +99 -0
- package/features/thread-list/model/view.ts +669 -0
- package/features/thread-list/model/windowing.ts +27 -0
- package/features/thread-list/testing/fixtures.ts +162 -0
- package/hooks/useBrowserDimmingModal.ts +5 -0
- package/lib/portal-scope.ts +15 -0
- package/lib/utils.ts +17 -0
- package/package.json +96 -0
- package/server/cli.ts +107 -0
- package/server/failures.ts +136 -0
- package/server/import.ts +144 -0
- package/server/notes.ts +236 -0
- package/server/preference-store.ts +96 -0
- package/server/scheduled.ts +129 -0
- package/server/serial.ts +12 -0
- package/server/stamps.ts +142 -0
- package/server/startup.ts +121 -0
- package/server.ts +126 -0
- package/shared/contract.ts +73 -0
- package/shared/preferences.ts +241 -0
- package/shared/signals.ts +71 -0
- package/skills/thread-glance/SKILL.md +69 -0
- package/tsconfig.json +36 -0
- package/types/global-jsx.d.ts +10 -0
package/server/notes.ts
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// One-line notes on why a thread is blocked, failed or done: one kv row per
|
|
2
|
+
// thread, `note:<threadId>`, read into memory once and written through.
|
|
3
|
+
import type { BbPluginApi, PluginThreadEventPayloads } from "@get-bb/plugin-sdk";
|
|
4
|
+
import {
|
|
5
|
+
CHANNELS,
|
|
6
|
+
NOTE_MAX_LENGTH,
|
|
7
|
+
threadNotesSchema,
|
|
8
|
+
type Note,
|
|
9
|
+
type NotesSignal,
|
|
10
|
+
type ThreadNotes,
|
|
11
|
+
} from "../shared/contract";
|
|
12
|
+
import { createSerialQueue } from "./serial";
|
|
13
|
+
|
|
14
|
+
export const NOTE_KEY_PREFIX = "note:";
|
|
15
|
+
/** bb's builtin plugin that lets any provider ask a multiple-choice question. */
|
|
16
|
+
export const ASK_USER_QUESTION_PLUGIN_ID = "ask-user-question";
|
|
17
|
+
const FALLBACK_PENDING_TEXT = "Needs your input";
|
|
18
|
+
|
|
19
|
+
type PendingInteraction = PluginThreadEventPayloads["interaction.pending"]["interaction"];
|
|
20
|
+
export type NoteSlot = keyof ThreadNotes;
|
|
21
|
+
export type NoteDraft = Pick<Note, "kind" | "text">;
|
|
22
|
+
|
|
23
|
+
export function noteKvKey(threadId: string): string {
|
|
24
|
+
return `${NOTE_KEY_PREFIX}${threadId}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Collapses whitespace, strips leading markdown headings, bullets, numbers
|
|
29
|
+
* and quote marks, and truncates to NOTE_MAX_LENGTH with an ellipsis.
|
|
30
|
+
*/
|
|
31
|
+
export function noteText(raw: string): string {
|
|
32
|
+
let text = raw.replace(/\s+/g, " ").trim();
|
|
33
|
+
for (;;) {
|
|
34
|
+
const stripped = text.replace(/^(?:#{1,6}|[-*+>]|\d+[.)])\s+/, "");
|
|
35
|
+
if (stripped === text) break;
|
|
36
|
+
text = stripped;
|
|
37
|
+
}
|
|
38
|
+
if (text.length <= NOTE_MAX_LENGTH) return text;
|
|
39
|
+
return `${text.slice(0, NOTE_MAX_LENGTH - 1).trimEnd()}…`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The first line with text, without its markdown heading marks. */
|
|
43
|
+
function firstNonEmptyLine(text: string): string {
|
|
44
|
+
for (const line of text.split("\n")) {
|
|
45
|
+
const stripped = line.replace(/^\s*#+/, "").trim();
|
|
46
|
+
if (stripped !== "") return stripped;
|
|
47
|
+
}
|
|
48
|
+
return "";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function withMore(first: string, total: number): string {
|
|
52
|
+
return total > 1 ? `${first} (+${total - 1} more)` : first;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The first question's prompt from a `{ questions: [{ prompt }] }` object. */
|
|
56
|
+
function questionsText(data: unknown): string | null {
|
|
57
|
+
if (data === null || typeof data !== "object" || Array.isArray(data)) return null;
|
|
58
|
+
const questions = (data as { questions?: unknown }).questions;
|
|
59
|
+
if (!Array.isArray(questions) || questions.length === 0) return null;
|
|
60
|
+
const prompt = (questions[0] as { prompt?: unknown } | null)?.prompt;
|
|
61
|
+
return typeof prompt === "string" && prompt.trim() !== ""
|
|
62
|
+
? withMore(prompt, questions.length)
|
|
63
|
+
: null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function approvalText(subjectText: string, reason: string | null): string {
|
|
67
|
+
return subjectText.trim() === "" && reason ? reason : subjectText;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function describeRaw(interaction: PendingInteraction): NoteDraft {
|
|
71
|
+
const payload = interaction.payload as { kind: string };
|
|
72
|
+
switch (interaction.payload.kind) {
|
|
73
|
+
case "user_question": {
|
|
74
|
+
const { questions } = interaction.payload;
|
|
75
|
+
const first = questions[0]?.prompt ?? "";
|
|
76
|
+
return { kind: "question", text: withMore(first, questions.length) };
|
|
77
|
+
}
|
|
78
|
+
case "approval": {
|
|
79
|
+
const { subject, reason } = interaction.payload;
|
|
80
|
+
switch (subject.kind) {
|
|
81
|
+
case "command":
|
|
82
|
+
return { kind: "approval", text: approvalText(subject.command, reason) };
|
|
83
|
+
case "file_change":
|
|
84
|
+
return {
|
|
85
|
+
kind: "approval",
|
|
86
|
+
text: `File changes${subject.writeScope ? ` in ${subject.writeScope}` : ""}`,
|
|
87
|
+
};
|
|
88
|
+
case "permission_grant":
|
|
89
|
+
return {
|
|
90
|
+
kind: "approval",
|
|
91
|
+
text: `Permissions${subject.toolName ? ` for ${subject.toolName}` : ""}`,
|
|
92
|
+
};
|
|
93
|
+
case "tool_use":
|
|
94
|
+
return { kind: "approval", text: subject.presentation.title ?? subject.tool };
|
|
95
|
+
case "plan":
|
|
96
|
+
return { kind: "plan", text: firstNonEmptyLine(subject.plan) };
|
|
97
|
+
default:
|
|
98
|
+
return { kind: "approval", text: reason ?? "" };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
case "plugin": {
|
|
102
|
+
if (interaction.origin?.kind !== "plugin") break;
|
|
103
|
+
const { title, presentation, data } = interaction.payload as {
|
|
104
|
+
title: string;
|
|
105
|
+
presentation?: { detail?: string };
|
|
106
|
+
data: unknown;
|
|
107
|
+
};
|
|
108
|
+
if (interaction.origin.pluginId === ASK_USER_QUESTION_PLUGIN_ID) {
|
|
109
|
+
return { kind: "question", text: questionsText(data) ?? title };
|
|
110
|
+
}
|
|
111
|
+
const detail = presentation?.detail ? noteText(presentation.detail) : "";
|
|
112
|
+
const combined = `${title}: ${detail}`;
|
|
113
|
+
return {
|
|
114
|
+
kind: "input",
|
|
115
|
+
text: detail !== "" && combined.length <= NOTE_MAX_LENGTH ? combined : title,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if ("title" in payload && typeof payload.title === "string") {
|
|
120
|
+
return { kind: "input", text: payload.title };
|
|
121
|
+
}
|
|
122
|
+
return { kind: "input", text: FALLBACK_PENDING_TEXT };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The `pending` note for an interaction; never empty. */
|
|
126
|
+
export function describeInteraction(interaction: PendingInteraction): NoteDraft {
|
|
127
|
+
const draft = describeRaw(interaction);
|
|
128
|
+
const text = noteText(draft.text);
|
|
129
|
+
return { kind: draft.kind, text: text === "" ? FALLBACK_PENDING_TEXT : text };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The `failed` note for a failure's text (see `failureText`); never empty. */
|
|
133
|
+
export function describeFailure(error: string | null): NoteDraft {
|
|
134
|
+
const text = error === null ? "" : noteText(error);
|
|
135
|
+
return { kind: "failed", text: text === "" ? "Failed" : text };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The `done` note for the last assistant text, or null when there is none. */
|
|
139
|
+
export function describeDone(lastAssistantText: string | null): NoteDraft | null {
|
|
140
|
+
const text = lastAssistantText === null ? "" : noteText(lastAssistantText);
|
|
141
|
+
return text === "" ? null : { kind: "done", text };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Per slot: a note to store, or null to delete it. Absent slots are kept. */
|
|
145
|
+
export type NoteChanges = Partial<Record<NoteSlot, Note | null>>;
|
|
146
|
+
|
|
147
|
+
export interface NoteStore {
|
|
148
|
+
list(): Promise<Record<string, ThreadNotes>>;
|
|
149
|
+
get(threadId: string): Promise<ThreadNotes | undefined>;
|
|
150
|
+
/** Applies the changes and publishes the thread's notes if they changed. */
|
|
151
|
+
update(threadId: string, changes: NoteChanges): Promise<void>;
|
|
152
|
+
/** Deletes every note of each thread, publishing `notes: null` for each. */
|
|
153
|
+
forget(threadIds: readonly string[]): Promise<void>;
|
|
154
|
+
/**
|
|
155
|
+
* Forgets threads missing from `liveIds` whose newest note is older than
|
|
156
|
+
* `before`, and returns their ids.
|
|
157
|
+
*/
|
|
158
|
+
prune(liveIds: ReadonlySet<string>, before: number): Promise<string[]>;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function createNoteStore(bb: Pick<BbPluginApi, "storage" | "realtime" | "log">): NoteStore {
|
|
162
|
+
const { kv } = bb.storage;
|
|
163
|
+
const serial = createSerialQueue();
|
|
164
|
+
let cache: Map<string, ThreadNotes> | null = null;
|
|
165
|
+
|
|
166
|
+
async function load(): Promise<Map<string, ThreadNotes>> {
|
|
167
|
+
if (cache !== null) return cache;
|
|
168
|
+
const loaded = new Map<string, ThreadNotes>();
|
|
169
|
+
for (const key of await kv.list(NOTE_KEY_PREFIX)) {
|
|
170
|
+
const threadId = key.slice(NOTE_KEY_PREFIX.length);
|
|
171
|
+
const parsed = threadNotesSchema.safeParse(await kv.get<unknown>(key));
|
|
172
|
+
if (!parsed.success || Object.keys(parsed.data).length === 0) {
|
|
173
|
+
bb.log.warn(`stored notes for ${threadId} are invalid; dropping them`);
|
|
174
|
+
await kv.delete(key);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
loaded.set(threadId, parsed.data);
|
|
178
|
+
}
|
|
179
|
+
cache = loaded;
|
|
180
|
+
return loaded;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function save(
|
|
184
|
+
rows: Map<string, ThreadNotes>,
|
|
185
|
+
threadId: string,
|
|
186
|
+
notes: ThreadNotes,
|
|
187
|
+
): Promise<void> {
|
|
188
|
+
const empty = Object.keys(notes).length === 0;
|
|
189
|
+
if (empty) {
|
|
190
|
+
rows.delete(threadId);
|
|
191
|
+
await kv.delete(noteKvKey(threadId));
|
|
192
|
+
} else {
|
|
193
|
+
rows.set(threadId, notes);
|
|
194
|
+
await kv.set(noteKvKey(threadId), notes);
|
|
195
|
+
}
|
|
196
|
+
const signal: NotesSignal = { threadId, notes: empty ? null : notes };
|
|
197
|
+
bb.realtime.publish(CHANNELS.notes, signal);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
list: () => serial(async () => Object.fromEntries(await load())),
|
|
202
|
+
get: (threadId) => serial(async () => (await load()).get(threadId)),
|
|
203
|
+
update: (threadId, changes) =>
|
|
204
|
+
serial(async () => {
|
|
205
|
+
const rows = await load();
|
|
206
|
+
const current = rows.get(threadId) ?? {};
|
|
207
|
+
const next: ThreadNotes = { ...current };
|
|
208
|
+
for (const [slot, note] of Object.entries(changes) as [NoteSlot, Note | null][]) {
|
|
209
|
+
if (note === null) delete next[slot];
|
|
210
|
+
else next[slot] = note;
|
|
211
|
+
}
|
|
212
|
+
if (JSON.stringify(next) === JSON.stringify(current)) return;
|
|
213
|
+
await save(rows, threadId, next);
|
|
214
|
+
}),
|
|
215
|
+
forget: (threadIds) =>
|
|
216
|
+
serial(async () => {
|
|
217
|
+
const rows = await load();
|
|
218
|
+
for (const threadId of threadIds) {
|
|
219
|
+
if (rows.has(threadId)) await save(rows, threadId, {});
|
|
220
|
+
}
|
|
221
|
+
}),
|
|
222
|
+
prune: (liveIds, before) =>
|
|
223
|
+
serial(async () => {
|
|
224
|
+
const rows = await load();
|
|
225
|
+
const pruned: string[] = [];
|
|
226
|
+
for (const [threadId, notes] of [...rows]) {
|
|
227
|
+
if (liveIds.has(threadId)) continue;
|
|
228
|
+
const newest = Math.max(...Object.values(notes).map((note) => note.at));
|
|
229
|
+
if (newest >= before) continue;
|
|
230
|
+
await save(rows, threadId, {});
|
|
231
|
+
pruned.push(threadId);
|
|
232
|
+
}
|
|
233
|
+
return pruned;
|
|
234
|
+
}),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Server-side preferences in the plugin kv, the same pattern as bb's
|
|
2
|
+
// own thread-list server: one row per key, validated on every read and write.
|
|
3
|
+
import type { BbPluginApi } from "@get-bb/plugin-sdk";
|
|
4
|
+
import { CHANNELS, type PreferenceSignal } from "../shared/contract";
|
|
5
|
+
import {
|
|
6
|
+
PREFERENCE_KEYS,
|
|
7
|
+
defaultPreferences,
|
|
8
|
+
parsePreference,
|
|
9
|
+
preferenceDefault,
|
|
10
|
+
type PreferenceKey,
|
|
11
|
+
type Preferences,
|
|
12
|
+
} from "../shared/preferences";
|
|
13
|
+
|
|
14
|
+
const PREFERENCE_KEY_PREFIX = "preference:";
|
|
15
|
+
|
|
16
|
+
export function preferenceKvKey(key: PreferenceKey): string {
|
|
17
|
+
return `${PREFERENCE_KEY_PREFIX}${key}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A value that fails the key's schema. The message names the key. */
|
|
21
|
+
export class PreferenceValidationError extends Error {
|
|
22
|
+
readonly key: PreferenceKey;
|
|
23
|
+
readonly detail: string;
|
|
24
|
+
|
|
25
|
+
constructor(key: PreferenceKey, detail: string) {
|
|
26
|
+
super(`Invalid value for ${key}: ${detail}`);
|
|
27
|
+
this.name = "PreferenceValidationError";
|
|
28
|
+
this.key = key;
|
|
29
|
+
this.detail = detail;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface PreferenceStore {
|
|
34
|
+
/** The stored value, or the default when none is stored or it is invalid. */
|
|
35
|
+
read<K extends PreferenceKey>(key: K): Promise<Preferences[K]>;
|
|
36
|
+
readAll(): Promise<Preferences>;
|
|
37
|
+
/** Whether the kv holds a row for the key, valid or not. */
|
|
38
|
+
isStored(key: PreferenceKey): Promise<boolean>;
|
|
39
|
+
/**
|
|
40
|
+
* Validates, stores and publishes the value, and returns it as parsed.
|
|
41
|
+
*
|
|
42
|
+
* @throws PreferenceValidationError when the value fails the key's schema.
|
|
43
|
+
*/
|
|
44
|
+
write<K extends PreferenceKey>(key: K, value: unknown): Promise<Preferences[K]>;
|
|
45
|
+
/** Deletes the stored value, publishes the default and returns it. */
|
|
46
|
+
reset<K extends PreferenceKey>(key: K): Promise<Preferences[K]>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function createPreferenceStore(
|
|
50
|
+
bb: Pick<BbPluginApi, "storage" | "realtime" | "log">,
|
|
51
|
+
): PreferenceStore {
|
|
52
|
+
const { kv } = bb.storage;
|
|
53
|
+
|
|
54
|
+
function publish(key: PreferenceKey, value: unknown): void {
|
|
55
|
+
const signal: PreferenceSignal = { key, value };
|
|
56
|
+
bb.realtime.publish(CHANNELS.preferences, signal);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function read<K extends PreferenceKey>(key: K): Promise<Preferences[K]> {
|
|
60
|
+
const stored = await kv.get<unknown>(preferenceKvKey(key));
|
|
61
|
+
if (stored === undefined) return preferenceDefault(key);
|
|
62
|
+
const parsed = parsePreference(key, stored);
|
|
63
|
+
if (parsed.success) return parsed.value;
|
|
64
|
+
bb.log.warn(`stored preference ${key} is invalid (${parsed.message}); using the default`);
|
|
65
|
+
return preferenceDefault(key);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
read,
|
|
70
|
+
async readAll() {
|
|
71
|
+
const result = defaultPreferences();
|
|
72
|
+
await Promise.all(
|
|
73
|
+
PREFERENCE_KEYS.map(async (key) => {
|
|
74
|
+
(result as Record<PreferenceKey, unknown>)[key] = await read(key);
|
|
75
|
+
}),
|
|
76
|
+
);
|
|
77
|
+
return result;
|
|
78
|
+
},
|
|
79
|
+
async isStored(key) {
|
|
80
|
+
return (await kv.get<unknown>(preferenceKvKey(key))) !== undefined;
|
|
81
|
+
},
|
|
82
|
+
async write(key, value) {
|
|
83
|
+
const parsed = parsePreference(key, value);
|
|
84
|
+
if (!parsed.success) throw new PreferenceValidationError(key, parsed.message);
|
|
85
|
+
await kv.set(preferenceKvKey(key), parsed.value);
|
|
86
|
+
publish(key, parsed.value);
|
|
87
|
+
return parsed.value;
|
|
88
|
+
},
|
|
89
|
+
async reset(key) {
|
|
90
|
+
await kv.delete(preferenceKvKey(key));
|
|
91
|
+
const value = preferenceDefault(key);
|
|
92
|
+
publish(key, value);
|
|
93
|
+
return value;
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Scheduled sends: queued rows whose `sendAt` is in the future, kept in
|
|
2
|
+
// memory and seeded again on every server start.
|
|
3
|
+
import type { ScheduledSignal } from "../shared/contract";
|
|
4
|
+
|
|
5
|
+
/** The fields of a queued row the tracker reads. */
|
|
6
|
+
export interface QueuedRow {
|
|
7
|
+
id: string;
|
|
8
|
+
threadId: string;
|
|
9
|
+
sendAt: number | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type Change =
|
|
13
|
+
| { type: "upsert"; row: QueuedRow }
|
|
14
|
+
| { type: "remove"; rowId: string }
|
|
15
|
+
| { type: "remove-thread"; threadId: string };
|
|
16
|
+
|
|
17
|
+
export interface ScheduledTracker {
|
|
18
|
+
/** `message.queued`: keeps the row while its `sendAt` is in the future. */
|
|
19
|
+
queued(row: QueuedRow): void;
|
|
20
|
+
/** `message.dispatched` and `message.cancelled`. */
|
|
21
|
+
removed(rowId: string): void;
|
|
22
|
+
/** `thread.archived` and `thread.deleted`. */
|
|
23
|
+
threadGone(threadId: string): void;
|
|
24
|
+
/**
|
|
25
|
+
* Replaces the rows with `load`'s result. Changes that arrive while `load`
|
|
26
|
+
* runs are applied on top, so they are not lost. A failed load sets the
|
|
27
|
+
* status to `error` and keeps the rows it had. Returns whether it loaded.
|
|
28
|
+
*/
|
|
29
|
+
seed(load: () => Promise<readonly QueuedRow[]>): Promise<boolean>;
|
|
30
|
+
/** The current map; waits for a seed that is running. */
|
|
31
|
+
snapshot(): Promise<ScheduledSignal>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Creates a tracker that calls `publish` whenever the per-thread map or the
|
|
36
|
+
* status changes. The status is `error` until the first seed succeeds.
|
|
37
|
+
*/
|
|
38
|
+
export function createScheduledTracker(options: {
|
|
39
|
+
publish: (signal: ScheduledSignal) => void;
|
|
40
|
+
now?: () => number;
|
|
41
|
+
}): ScheduledTracker {
|
|
42
|
+
const now = options.now ?? Date.now;
|
|
43
|
+
let rows = new Map<string, { threadId: string; sendAt: number }>();
|
|
44
|
+
let status: ScheduledSignal["status"] = "error";
|
|
45
|
+
let duringSeed: Change[] | null = null;
|
|
46
|
+
let seeding: Promise<boolean> | null = null;
|
|
47
|
+
let lastPublished = JSON.stringify({ status, scheduled: {} } satisfies ScheduledSignal);
|
|
48
|
+
|
|
49
|
+
function apply(change: Change): void {
|
|
50
|
+
switch (change.type) {
|
|
51
|
+
case "upsert": {
|
|
52
|
+
const { id, threadId, sendAt } = change.row;
|
|
53
|
+
if (sendAt !== null && sendAt > now()) rows.set(id, { threadId, sendAt });
|
|
54
|
+
else rows.delete(id);
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
case "remove":
|
|
58
|
+
rows.delete(change.rowId);
|
|
59
|
+
break;
|
|
60
|
+
case "remove-thread":
|
|
61
|
+
for (const [id, row] of rows) if (row.threadId === change.threadId) rows.delete(id);
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function current(): ScheduledSignal {
|
|
67
|
+
const at = now();
|
|
68
|
+
const scheduled: Record<string, number> = {};
|
|
69
|
+
for (const [id, row] of rows) {
|
|
70
|
+
if (row.sendAt <= at) {
|
|
71
|
+
rows.delete(id);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const earliest = scheduled[row.threadId];
|
|
75
|
+
if (earliest === undefined || row.sendAt < earliest) scheduled[row.threadId] = row.sendAt;
|
|
76
|
+
}
|
|
77
|
+
return { status, scheduled };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function publishIfChanged(): void {
|
|
81
|
+
const signal = current();
|
|
82
|
+
const serialized = JSON.stringify(signal);
|
|
83
|
+
if (serialized === lastPublished) return;
|
|
84
|
+
lastPublished = serialized;
|
|
85
|
+
options.publish(signal);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function change(next: Change): void {
|
|
89
|
+
duringSeed?.push(next);
|
|
90
|
+
apply(next);
|
|
91
|
+
publishIfChanged();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function runSeed(load: () => Promise<readonly QueuedRow[]>): Promise<boolean> {
|
|
95
|
+
duringSeed = [];
|
|
96
|
+
try {
|
|
97
|
+
const loaded = await load();
|
|
98
|
+
const pending = duringSeed;
|
|
99
|
+
rows = new Map();
|
|
100
|
+
for (const row of loaded) apply({ type: "upsert", row });
|
|
101
|
+
for (const later of pending) apply(later);
|
|
102
|
+
status = "ready";
|
|
103
|
+
return true;
|
|
104
|
+
} catch {
|
|
105
|
+
status = "error";
|
|
106
|
+
return false;
|
|
107
|
+
} finally {
|
|
108
|
+
duringSeed = null;
|
|
109
|
+
publishIfChanged();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
queued: (row) => change({ type: "upsert", row }),
|
|
115
|
+
removed: (rowId) => change({ type: "remove", rowId }),
|
|
116
|
+
threadGone: (threadId) => change({ type: "remove-thread", threadId }),
|
|
117
|
+
seed(load) {
|
|
118
|
+
const run = runSeed(load).finally(() => {
|
|
119
|
+
if (seeding === run) seeding = null;
|
|
120
|
+
});
|
|
121
|
+
seeding = run;
|
|
122
|
+
return run;
|
|
123
|
+
},
|
|
124
|
+
async snapshot() {
|
|
125
|
+
if (seeding !== null) await seeding;
|
|
126
|
+
return current();
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
package/server/serial.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns a runner that executes tasks one at a time, in call order. A failed
|
|
3
|
+
* task rejects its own promise and does not stop the tasks after it.
|
|
4
|
+
*/
|
|
5
|
+
export function createSerialQueue(): <T>(task: () => Promise<T>) => Promise<T> {
|
|
6
|
+
let tail: Promise<unknown> = Promise.resolve();
|
|
7
|
+
return <T>(task: () => Promise<T>): Promise<T> => {
|
|
8
|
+
const result = tail.then(task, task);
|
|
9
|
+
tail = result.catch(() => undefined);
|
|
10
|
+
return result;
|
|
11
|
+
};
|
|
12
|
+
}
|
package/server/stamps.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Per-thread timestamps: one kv row per thread, `stamp:<threadId>`,
|
|
2
|
+
// holding whichever of the four kinds it has. Rows are read into memory once
|
|
3
|
+
// and written through, so listing does not read every row per call.
|
|
4
|
+
import type { BbPluginApi } from "@get-bb/plugin-sdk";
|
|
5
|
+
import { CHANNELS, type StampKind, type StampSignal, type Stamps } from "../shared/contract";
|
|
6
|
+
import { createSerialQueue } from "./serial";
|
|
7
|
+
|
|
8
|
+
export const STAMP_KEY_PREFIX = "stamp:";
|
|
9
|
+
export const STAMP_KINDS: readonly StampKind[] = ["startedAt", "finishedAt", "pendingAt", "seenAt"];
|
|
10
|
+
|
|
11
|
+
export type ThreadStamps = Partial<Record<StampKind, number>>;
|
|
12
|
+
|
|
13
|
+
export function stampKvKey(threadId: string): string {
|
|
14
|
+
return `${STAMP_KEY_PREFIX}${threadId}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Keeps the known kinds whose value is a finite number; null if none. */
|
|
18
|
+
export function parseThreadStamps(raw: unknown): ThreadStamps | null {
|
|
19
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
20
|
+
const record = raw as Record<string, unknown>;
|
|
21
|
+
const result: ThreadStamps = {};
|
|
22
|
+
for (const kind of STAMP_KINDS) {
|
|
23
|
+
const value = record[kind];
|
|
24
|
+
if (typeof value === "number" && Number.isFinite(value)) result[kind] = value;
|
|
25
|
+
}
|
|
26
|
+
return Object.keys(result).length > 0 ? result : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface StampStore {
|
|
30
|
+
list(): Promise<Stamps>;
|
|
31
|
+
/** Sets `kind` to `at` for each thread and publishes one signal. */
|
|
32
|
+
stamp(kind: StampKind, threadIds: readonly string[], at: number): Promise<void>;
|
|
33
|
+
/** Deletes `kind` for each thread and publishes one signal with `value: null`. */
|
|
34
|
+
clear(kind: StampKind, threadIds: readonly string[]): Promise<void>;
|
|
35
|
+
/** Deletes every stamp of each thread. Publishes nothing. */
|
|
36
|
+
forget(threadIds: readonly string[]): Promise<void>;
|
|
37
|
+
/**
|
|
38
|
+
* Forgets threads missing from `liveIds` whose newest stamp is older than
|
|
39
|
+
* `before`, and returns their ids. Publishes nothing.
|
|
40
|
+
*
|
|
41
|
+
* `before` is when the caller started listing threads, so a thread created
|
|
42
|
+
* after the list was read, and stamped since, is kept.
|
|
43
|
+
*/
|
|
44
|
+
prune(liveIds: ReadonlySet<string>, before: number): Promise<string[]>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function createStampStore(
|
|
48
|
+
bb: Pick<BbPluginApi, "storage" | "realtime" | "log">,
|
|
49
|
+
): StampStore {
|
|
50
|
+
const { kv } = bb.storage;
|
|
51
|
+
const serial = createSerialQueue();
|
|
52
|
+
let cache: Map<string, ThreadStamps> | null = null;
|
|
53
|
+
|
|
54
|
+
async function load(): Promise<Map<string, ThreadStamps>> {
|
|
55
|
+
if (cache !== null) return cache;
|
|
56
|
+
const loaded = new Map<string, ThreadStamps>();
|
|
57
|
+
for (const key of await kv.list(STAMP_KEY_PREFIX)) {
|
|
58
|
+
const threadId = key.slice(STAMP_KEY_PREFIX.length);
|
|
59
|
+
const stamps = parseThreadStamps(await kv.get<unknown>(key));
|
|
60
|
+
if (stamps === null) {
|
|
61
|
+
bb.log.warn(`stored stamps for ${threadId} are invalid; dropping them`);
|
|
62
|
+
await kv.delete(key);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
loaded.set(threadId, stamps);
|
|
66
|
+
}
|
|
67
|
+
cache = loaded;
|
|
68
|
+
return loaded;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function save(
|
|
72
|
+
rows: Map<string, ThreadStamps>,
|
|
73
|
+
threadId: string,
|
|
74
|
+
stamps: ThreadStamps,
|
|
75
|
+
): Promise<void> {
|
|
76
|
+
if (Object.keys(stamps).length === 0) {
|
|
77
|
+
rows.delete(threadId);
|
|
78
|
+
await kv.delete(stampKvKey(threadId));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
rows.set(threadId, stamps);
|
|
82
|
+
await kv.set(stampKvKey(threadId), stamps);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function publish(signal: StampSignal): void {
|
|
86
|
+
bb.realtime.publish(CHANNELS.stamps, signal);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
list: () =>
|
|
91
|
+
serial(async () => {
|
|
92
|
+
const result: Stamps = { startedAt: {}, finishedAt: {}, pendingAt: {}, seenAt: {} };
|
|
93
|
+
for (const [threadId, stamps] of await load()) {
|
|
94
|
+
for (const kind of STAMP_KINDS) {
|
|
95
|
+
const value = stamps[kind];
|
|
96
|
+
if (value !== undefined) result[kind][threadId] = value;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return result;
|
|
100
|
+
}),
|
|
101
|
+
stamp: (kind, threadIds, at) =>
|
|
102
|
+
serial(async () => {
|
|
103
|
+
if (threadIds.length === 0) return;
|
|
104
|
+
const rows = await load();
|
|
105
|
+
for (const threadId of new Set(threadIds)) {
|
|
106
|
+
await save(rows, threadId, { ...rows.get(threadId), [kind]: at });
|
|
107
|
+
}
|
|
108
|
+
publish({ kind, threadIds: [...threadIds], value: at });
|
|
109
|
+
}),
|
|
110
|
+
clear: (kind, threadIds) =>
|
|
111
|
+
serial(async () => {
|
|
112
|
+
if (threadIds.length === 0) return;
|
|
113
|
+
const rows = await load();
|
|
114
|
+
for (const threadId of new Set(threadIds)) {
|
|
115
|
+
const current = rows.get(threadId);
|
|
116
|
+
if (current?.[kind] === undefined) continue;
|
|
117
|
+
const { [kind]: _removed, ...rest } = current;
|
|
118
|
+
await save(rows, threadId, rest);
|
|
119
|
+
}
|
|
120
|
+
publish({ kind, threadIds: [...threadIds], value: null });
|
|
121
|
+
}),
|
|
122
|
+
forget: (threadIds) =>
|
|
123
|
+
serial(async () => {
|
|
124
|
+
const rows = await load();
|
|
125
|
+
for (const threadId of threadIds) {
|
|
126
|
+
if (rows.has(threadId)) await save(rows, threadId, {});
|
|
127
|
+
}
|
|
128
|
+
}),
|
|
129
|
+
prune: (liveIds, before) =>
|
|
130
|
+
serial(async () => {
|
|
131
|
+
const rows = await load();
|
|
132
|
+
const pruned: string[] = [];
|
|
133
|
+
for (const [threadId, stamps] of [...rows]) {
|
|
134
|
+
if (liveIds.has(threadId)) continue;
|
|
135
|
+
if (Math.max(...Object.values(stamps)) >= before) continue;
|
|
136
|
+
await save(rows, threadId, {});
|
|
137
|
+
pruned.push(threadId);
|
|
138
|
+
}
|
|
139
|
+
return pruned;
|
|
140
|
+
}),
|
|
141
|
+
};
|
|
142
|
+
}
|