@danypops/tickets 0.12.0 → 0.13.0
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/package.json +3 -3
- package/src/agent-tools/tickets-vehicle.ts +67 -2
- package/src/cli/index.ts +53 -0
- package/src/process/bootstrap.ts +21 -2
- package/src/process/watch-sync.ts +204 -0
- package/src/rpc/ops.ts +29 -0
- package/src/rpc/server.ts +47 -0
- package/src/sqlite/watches.ts +355 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danypops/tickets",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Unified CLI, daemon, and TypeScript library for issue tracking across GitHub, GitLab, and Jira.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -24,8 +24,8 @@
|
|
|
24
24
|
"typecheck": "tsc --noEmit"
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@danypops/vehicle-core": "^0.
|
|
28
|
-
"@danypops/vehicle-server": "^0.
|
|
27
|
+
"@danypops/vehicle-core": "^0.15.0",
|
|
28
|
+
"@danypops/vehicle-server": "^0.21.0",
|
|
29
29
|
"@danypops/vehicle-client": "^0.7.1",
|
|
30
30
|
"@danypops/enigma-client": "^0.6.1",
|
|
31
31
|
"@gitbeaker/rest": "^43.8.0",
|
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
import { VehicleRegistry } from "@danypops/vehicle-server";
|
|
26
26
|
import type { BackendCapabilities, TicketService } from "../issue/service.js";
|
|
27
27
|
import type { TicketOperation } from "../rpc/ops.js";
|
|
28
|
-
import { TICKET_OP_HANDLERS, type TicketsAppDeps } from "../rpc/server.js";
|
|
28
|
+
import { type HandlerCallContext, TICKET_OP_HANDLERS, type TicketsAppDeps } from "../rpc/server.js";
|
|
29
29
|
import { withTicketsErrorParity } from "./error-mapping.js";
|
|
30
30
|
|
|
31
31
|
const OWNER = "tickets";
|
|
@@ -265,6 +265,61 @@ const OPERATIONS: readonly OperationSpec[] = [
|
|
|
265
265
|
properties: { name: stringProp, limit: numberProp },
|
|
266
266
|
required: ["name"],
|
|
267
267
|
},
|
|
268
|
+
{
|
|
269
|
+
action: "issue.subscribe",
|
|
270
|
+
description:
|
|
271
|
+
"Has the daemon keep watching one issue in the background -- comments, status, and other field changes are reported through watch_events, no manual re-checking needed. Idempotent. subscriberId scopes this watch to one caller (defaults to this calling Pi session's own real session id, then a shared anonymous subscriber for a raw RPC client with no session at all); scheduleMs bounds how often that subscriber's watch is refreshed, in milliseconds (omit to refresh on every background sync tick). projectRoot attributes this subscription to a project -- defaults to this calling session's own cwd; rarely needs to be passed explicitly.",
|
|
272
|
+
effect: "local-write",
|
|
273
|
+
properties: { ref: stringProp, subscriberId: stringProp, scheduleMs: numberProp, projectRoot: stringProp },
|
|
274
|
+
required: ["ref"],
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
action: "issue.unsubscribe",
|
|
278
|
+
description:
|
|
279
|
+
"Stops the daemon watching one issue in the background. Idempotent -- no error if it wasn't subscribed. subscriberId removes only that one caller's watch, leaving any other subscriber's own watch on the same issue intact.",
|
|
280
|
+
effect: "local-write",
|
|
281
|
+
properties: { ref: stringProp, subscriberId: stringProp },
|
|
282
|
+
required: ["ref"],
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
action: "issue.subscribed",
|
|
286
|
+
description:
|
|
287
|
+
"Every issue this subscriber is currently watching -- never a live backend call, cheap to call frequently. subscriberId defaults to this calling Pi session's own real session id.",
|
|
288
|
+
effect: "read",
|
|
289
|
+
properties: { subscriberId: stringProp },
|
|
290
|
+
required: [],
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
action: "query.subscribe",
|
|
294
|
+
description:
|
|
295
|
+
"Has the daemon keep re-running one saved query in the background -- new matching items or items that drop out are reported through watch_events, no manual re-checking needed. Idempotent. subscriberId/scheduleMs/projectRoot behave exactly like issue.subscribe's own.",
|
|
296
|
+
effect: "local-write",
|
|
297
|
+
properties: { name: stringProp, subscriberId: stringProp, scheduleMs: numberProp, projectRoot: stringProp },
|
|
298
|
+
required: ["name"],
|
|
299
|
+
},
|
|
300
|
+
{
|
|
301
|
+
action: "query.unsubscribe",
|
|
302
|
+
description: "Stops the daemon re-running one saved query in the background. Idempotent -- no error if it wasn't subscribed.",
|
|
303
|
+
effect: "local-write",
|
|
304
|
+
properties: { name: stringProp, subscriberId: stringProp },
|
|
305
|
+
required: ["name"],
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
action: "query.subscribed",
|
|
309
|
+
description:
|
|
310
|
+
"Every saved query this subscriber is currently watching -- never a live backend call, cheap to call frequently. subscriberId defaults to this calling Pi session's own real session id.",
|
|
311
|
+
effect: "read",
|
|
312
|
+
properties: { subscriberId: stringProp },
|
|
313
|
+
required: [],
|
|
314
|
+
},
|
|
315
|
+
{
|
|
316
|
+
action: "watch.events",
|
|
317
|
+
description:
|
|
318
|
+
"New change events (comments, status, label/field changes, saved-query membership changes) for everything this subscriber currently watches, since sinceId -- cheaper than re-fetching every watched issue/query yourself. Pass the previous call's lastId as sinceId to page forward; omit it once to start from 'now' without replaying history.",
|
|
319
|
+
effect: "read",
|
|
320
|
+
properties: { subscriberId: stringProp, sinceId: numberProp, limit: numberProp },
|
|
321
|
+
required: [],
|
|
322
|
+
},
|
|
268
323
|
{
|
|
269
324
|
action: "stage.add",
|
|
270
325
|
description:
|
|
@@ -419,7 +474,17 @@ export function createTicketsVehicleRegistry(deps: Omit<TicketsAppDeps, "vehicle
|
|
|
419
474
|
bindVehicleOperation(
|
|
420
475
|
operation,
|
|
421
476
|
() => async (context) =>
|
|
422
|
-
withTicketsErrorParity<unknown>(() =>
|
|
477
|
+
withTicketsErrorParity<unknown>(() => {
|
|
478
|
+
// Threaded through unconditionally -- harmless for the vast majority of handlers that
|
|
479
|
+
// never read it; issue.subscribe/query.subscribe (and their siblings) use it to default
|
|
480
|
+
// subscriberId/projectRoot from this real call's own session identity, mirroring
|
|
481
|
+
// @danypops/pipes' own ci.subscribe. See HandlerCallContext's own doc comment.
|
|
482
|
+
const callContext: HandlerCallContext = {
|
|
483
|
+
callerSessionId: context.callerSessionId,
|
|
484
|
+
callerProjectRoot: context.callerProjectRoot,
|
|
485
|
+
};
|
|
486
|
+
return handler(deps, mapInput(context.input as Record<string, unknown>) as never, callContext);
|
|
487
|
+
}),
|
|
423
488
|
),
|
|
424
489
|
);
|
|
425
490
|
}
|
package/src/cli/index.ts
CHANGED
|
@@ -158,6 +158,37 @@ program
|
|
|
158
158
|
await withClient((client) => client.call("issue.merge", { ref, method: opts.method }));
|
|
159
159
|
});
|
|
160
160
|
|
|
161
|
+
program
|
|
162
|
+
.command("subscribe <ref>")
|
|
163
|
+
.description("watch one issue in the background -- comments, status, and other field changes are reported via `watch-events`")
|
|
164
|
+
.option("--schedule-ms <ms>", "minimum check cadence for this subscription, in milliseconds", (v) => Number.parseInt(v, 10))
|
|
165
|
+
.action(async (ref: string, opts) => {
|
|
166
|
+
await withClient((client) => client.call("issue.subscribe", { ref, scheduleMs: opts.scheduleMs }));
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
program
|
|
170
|
+
.command("unsubscribe <ref>")
|
|
171
|
+
.description("stop watching one issue")
|
|
172
|
+
.action(async (ref: string) => {
|
|
173
|
+
await withClient((client) => client.call("issue.unsubscribe", { ref }));
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
program
|
|
177
|
+
.command("subscribed")
|
|
178
|
+
.description("list every issue you're currently watching")
|
|
179
|
+
.action(async () => {
|
|
180
|
+
await withClient((client) => client.call("issue.subscribed", {}));
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
program
|
|
184
|
+
.command("watch-events")
|
|
185
|
+
.description("new change events for everything you're currently watching (issues and saved queries), since --since-id")
|
|
186
|
+
.option("--since-id <id>", "only events after this event id", (v) => Number.parseInt(v, 10))
|
|
187
|
+
.option("--limit <n>", "max events", (v) => Number.parseInt(v, 10))
|
|
188
|
+
.action(async (opts) => {
|
|
189
|
+
await withClient((client) => client.call("watch.events", { sinceId: opts.sinceId, limit: opts.limit }));
|
|
190
|
+
});
|
|
191
|
+
|
|
161
192
|
const comment = program.command("comment").description("comment operations");
|
|
162
193
|
|
|
163
194
|
comment
|
|
@@ -321,6 +352,28 @@ queryCmd
|
|
|
321
352
|
await withClient((client) => client.call("query.run", { name, limit: opts.limit }));
|
|
322
353
|
});
|
|
323
354
|
|
|
355
|
+
queryCmd
|
|
356
|
+
.command("subscribe <name>")
|
|
357
|
+
.description("watch one saved query in the background -- new/dropped items are reported via `watch-events`")
|
|
358
|
+
.option("--schedule-ms <ms>", "minimum check cadence for this subscription, in milliseconds", (v) => Number.parseInt(v, 10))
|
|
359
|
+
.action(async (name: string, opts) => {
|
|
360
|
+
await withClient((client) => client.call("query.subscribe", { name, scheduleMs: opts.scheduleMs }));
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
queryCmd
|
|
364
|
+
.command("unsubscribe <name>")
|
|
365
|
+
.description("stop watching one saved query")
|
|
366
|
+
.action(async (name: string) => {
|
|
367
|
+
await withClient((client) => client.call("query.unsubscribe", { name }));
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
queryCmd
|
|
371
|
+
.command("subscribed")
|
|
372
|
+
.description("list every saved query you're currently watching")
|
|
373
|
+
.action(async () => {
|
|
374
|
+
await withClient((client) => client.call("query.subscribed", {}));
|
|
375
|
+
});
|
|
376
|
+
|
|
324
377
|
discoverCmd
|
|
325
378
|
.command("board_quickfilter")
|
|
326
379
|
.description(
|
package/src/process/bootstrap.ts
CHANGED
|
@@ -19,8 +19,10 @@ import { buildApp, type TicketsAppDeps } from "../rpc/server.js";
|
|
|
19
19
|
import { FOCUS_MIGRATIONS, FocusStore } from "../sqlite/focus.js";
|
|
20
20
|
import { LEDGER_MIGRATIONS, Ledger } from "../sqlite/ledger.js";
|
|
21
21
|
import { SAVED_QUERY_MIGRATIONS, SavedQueryStore } from "../sqlite/saved-queries.js";
|
|
22
|
+
import { WATCH_MIGRATIONS, WatchStore } from "../sqlite/watches.js";
|
|
22
23
|
import { StageStore } from "../stage/store.js";
|
|
23
24
|
import { createSyncTask } from "./poller.js";
|
|
25
|
+
import { createIssueWatchSyncTask, createQueryWatchSyncTask } from "./watch-sync.js";
|
|
24
26
|
|
|
25
27
|
export interface BootstrapOptions {
|
|
26
28
|
pathEnv?: PathEnvironment;
|
|
@@ -39,6 +41,10 @@ export interface BootstrapOptions {
|
|
|
39
41
|
checkpointIntervalMs?: number;
|
|
40
42
|
/** How often the live backend set re-resolves from config/env/Enigma. Ignored when repos is injected. */
|
|
41
43
|
backendRefreshIntervalMs?: number;
|
|
44
|
+
/** How often every subscribed issue is re-fetched and diffed. Defaults to DEFAULT_ISSUE_WATCH_INTERVAL_MS. */
|
|
45
|
+
issueWatchIntervalMs?: number;
|
|
46
|
+
/** How often every subscribed saved query is re-run and diffed. Defaults to DEFAULT_QUERY_WATCH_INTERVAL_MS. */
|
|
47
|
+
queryWatchIntervalMs?: number;
|
|
42
48
|
/**
|
|
43
49
|
* Overrides the daemon.shutdown op's effect. Defaults to sending this
|
|
44
50
|
* process SIGTERM, which vehicle-server's runDaemonProcess already handles
|
|
@@ -54,6 +60,7 @@ export interface BootstrappedDaemon {
|
|
|
54
60
|
focusStore: FocusStore;
|
|
55
61
|
queries: SavedQueryStore;
|
|
56
62
|
stageStore: StageStore;
|
|
63
|
+
watches: WatchStore;
|
|
57
64
|
service: TicketService;
|
|
58
65
|
options: StartDaemonOptions;
|
|
59
66
|
}
|
|
@@ -61,15 +68,23 @@ export interface BootstrappedDaemon {
|
|
|
61
68
|
const DEFAULT_SYNC_INTERVAL_MS = 5 * 60_000;
|
|
62
69
|
const DEFAULT_CHECKPOINT_INTERVAL_MS = 10 * 60_000;
|
|
63
70
|
const DEFAULT_BACKEND_REFRESH_INTERVAL_MS = 30_000;
|
|
71
|
+
/** Deliberately coarser than pipes' own 30s RUN_POOL_SYNC_INTERVAL_MS -- a CI run's status changes
|
|
72
|
+
* on the order of seconds/minutes; an issue's comments/status change on the order of minutes/hours,
|
|
73
|
+
* so polling that fast would only waste API quota against GitHub/GitLab/Jira's own rate limits. */
|
|
74
|
+
const DEFAULT_ISSUE_WATCH_INTERVAL_MS = 60_000;
|
|
75
|
+
const DEFAULT_QUERY_WATCH_INTERVAL_MS = 60_000;
|
|
64
76
|
|
|
65
77
|
export async function bootstrap(opts: BootstrapOptions = {}): Promise<BootstrappedDaemon> {
|
|
66
78
|
const paths = resolveDaemonPaths(TICKETS_DAEMON_NAMES, opts.pathEnv);
|
|
67
79
|
const token = ensureAuthToken(paths.token, "Tickets");
|
|
68
|
-
const db = openSqliteWithPragmas(paths.database, {
|
|
80
|
+
const db = openSqliteWithPragmas(paths.database, {
|
|
81
|
+
migrations: [...LEDGER_MIGRATIONS, ...FOCUS_MIGRATIONS, ...SAVED_QUERY_MIGRATIONS, ...WATCH_MIGRATIONS],
|
|
82
|
+
});
|
|
69
83
|
const ledger = new Ledger(db);
|
|
70
84
|
const focusStore = new FocusStore(db);
|
|
71
85
|
const queries = new SavedQueryStore(db);
|
|
72
86
|
const stageStore = new StageStore();
|
|
87
|
+
const watches = new WatchStore(db);
|
|
73
88
|
const logger = opts.logger ?? createLogger("tickets-daemon", { levelEnvVar: "TICKETS_LOG_LEVEL" });
|
|
74
89
|
const config = opts.config ?? loadConfig();
|
|
75
90
|
const buildRepos = opts.buildRepositories ?? buildRepositories;
|
|
@@ -88,6 +103,7 @@ export async function bootstrap(opts: BootstrapOptions = {}): Promise<Bootstrapp
|
|
|
88
103
|
focusStore,
|
|
89
104
|
queries,
|
|
90
105
|
stageStore,
|
|
106
|
+
watches,
|
|
91
107
|
token,
|
|
92
108
|
version,
|
|
93
109
|
logger,
|
|
@@ -100,6 +116,8 @@ export async function bootstrap(opts: BootstrapOptions = {}): Promise<Bootstrapp
|
|
|
100
116
|
logger,
|
|
101
117
|
maintenanceTasks: [
|
|
102
118
|
createSyncTask(service, ledger, opts.syncIntervalMs ?? DEFAULT_SYNC_INTERVAL_MS, logger),
|
|
119
|
+
createIssueWatchSyncTask(service, watches, opts.issueWatchIntervalMs ?? DEFAULT_ISSUE_WATCH_INTERVAL_MS, logger),
|
|
120
|
+
createQueryWatchSyncTask(service, queries, watches, opts.queryWatchIntervalMs ?? DEFAULT_QUERY_WATCH_INTERVAL_MS, logger),
|
|
103
121
|
{
|
|
104
122
|
name: "checkpoint",
|
|
105
123
|
intervalMs: opts.checkpointIntervalMs ?? DEFAULT_CHECKPOINT_INTERVAL_MS,
|
|
@@ -127,6 +145,7 @@ export async function bootstrap(opts: BootstrapOptions = {}): Promise<Bootstrapp
|
|
|
127
145
|
focusStore,
|
|
128
146
|
queries,
|
|
129
147
|
stageStore,
|
|
148
|
+
watches,
|
|
130
149
|
token,
|
|
131
150
|
version,
|
|
132
151
|
logger,
|
|
@@ -138,5 +157,5 @@ export async function bootstrap(opts: BootstrapOptions = {}): Promise<Bootstrapp
|
|
|
138
157
|
},
|
|
139
158
|
};
|
|
140
159
|
|
|
141
|
-
return { db, ledger, focusStore, queries, stageStore, service, options };
|
|
160
|
+
return { db, ledger, focusStore, queries, stageStore, watches, service, options };
|
|
142
161
|
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Watch sync — the tickets daemon's own analog of @danypops/pipes' run/monitor.ts's syncRunPool,
|
|
3
|
+
* for individual issues and saved queries instead of CI jobs. Two independent maintenance tasks
|
|
4
|
+
* (createIssueWatchSyncTask / createQueryWatchSyncTask, wired in process/bootstrap.ts), same
|
|
5
|
+
* shape as pipes': read the current subscription list fresh from SQLite every tick, group by key
|
|
6
|
+
* (so N subscribers on the same ref/query share one live fetch), skip anything not yet due per its
|
|
7
|
+
* own scheduleMs, fetch once per due group, diff against the last-cached snapshot, and only
|
|
8
|
+
* persist a WatchEvent (via WatchStore.recordEvent) on a real, human-describable change.
|
|
9
|
+
*
|
|
10
|
+
* Deliberately never unsubscribes anything on its own (see watches.ts's own doc comment) -- a
|
|
11
|
+
* failed fetch for one key is logged and skipped, retried next tick, exactly like pipes' own
|
|
12
|
+
* per-group isolation.
|
|
13
|
+
*/
|
|
14
|
+
import type { MaintenanceTask } from "@danypops/vehicle-server/daemon";
|
|
15
|
+
import type { Logger } from "@danypops/vehicle-server/logging";
|
|
16
|
+
import type { TicketService } from "../issue/service.js";
|
|
17
|
+
import type { SavedQueryStore } from "../sqlite/saved-queries.js";
|
|
18
|
+
import type { IssueWatchSubscription, QueryWatchSubscription, WatchStore } from "../sqlite/watches.js";
|
|
19
|
+
|
|
20
|
+
const NOOP_LOGGER: Logger = { debug() {}, info() {}, warn() {}, error() {} };
|
|
21
|
+
|
|
22
|
+
const DEFAULT_QUERY_WATCH_LIMIT = 50;
|
|
23
|
+
|
|
24
|
+
/** True if this subscription's own cadence has elapsed since it was last checked -- always true for a subscription with no scheduleMs, matching pipes' own isDue. */
|
|
25
|
+
function isDue(subscription: { scheduleMs?: number; lastCheckedAt?: Date }, nowMs: number): boolean {
|
|
26
|
+
if (subscription.scheduleMs === undefined) return true;
|
|
27
|
+
if (subscription.lastCheckedAt === undefined) return true;
|
|
28
|
+
return nowMs - subscription.lastCheckedAt.getTime() >= subscription.scheduleMs;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function groupBy<T, K>(items: T[], key: (item: T) => K): Map<K, T[]> {
|
|
32
|
+
const groups = new Map<K, T[]>();
|
|
33
|
+
for (const item of items) {
|
|
34
|
+
const k = key(item);
|
|
35
|
+
const existing = groups.get(k);
|
|
36
|
+
if (existing) existing.push(item);
|
|
37
|
+
else groups.set(k, [item]);
|
|
38
|
+
}
|
|
39
|
+
return groups;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Best-effort comment count: undefined (never diffed on) for a backend that doesn't support
|
|
44
|
+
* comments at all (NotSupportedError) or a transient failure fetching them -- a missing comment
|
|
45
|
+
* count must never itself look like "0 comments" and falsely report "N new comments" once support
|
|
46
|
+
* (or connectivity) returns.
|
|
47
|
+
*/
|
|
48
|
+
async function tryCommentCount(service: TicketService, ref: string): Promise<number | undefined> {
|
|
49
|
+
try {
|
|
50
|
+
return (await service.comments(ref)).length;
|
|
51
|
+
} catch {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Human-readable diffs between two issue snapshots, most specific first. Empty means "no real, describable change" even if fetched_at moved. */
|
|
57
|
+
function diffIssueSnapshot(
|
|
58
|
+
previous: { status: string; updatedAt?: string; commentCount?: number } | undefined,
|
|
59
|
+
current: { status: string; updatedAt?: string; commentCount?: number },
|
|
60
|
+
): string[] {
|
|
61
|
+
if (!previous) return [];
|
|
62
|
+
const changes: string[] = [];
|
|
63
|
+
if (current.status !== previous.status) changes.push(`status: ${previous.status} -> ${current.status}`);
|
|
64
|
+
if (current.commentCount !== undefined && previous.commentCount !== undefined && current.commentCount > previous.commentCount) {
|
|
65
|
+
const added = current.commentCount - previous.commentCount;
|
|
66
|
+
changes.push(`${added} new comment${added === 1 ? "" : "s"}`);
|
|
67
|
+
}
|
|
68
|
+
// A generic fallback for a backend-reported update this diff can't further characterize (a field
|
|
69
|
+
// edit, a label/assignee change, ...) -- only surfaced when nothing more specific already explains
|
|
70
|
+
// it, so a status change never also reports a redundant "updated".
|
|
71
|
+
if (changes.length === 0 && current.updatedAt !== undefined && current.updatedAt !== previous.updatedAt) {
|
|
72
|
+
changes.push("updated");
|
|
73
|
+
}
|
|
74
|
+
return changes;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface IssueWatchChange {
|
|
78
|
+
ref: string;
|
|
79
|
+
changes: string[];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** One tick: fetches every due watched issue once (deduped across subscribers), diffs, and records a WatchEvent per real change. */
|
|
83
|
+
export async function syncIssueWatches(
|
|
84
|
+
service: TicketService,
|
|
85
|
+
watches: WatchStore,
|
|
86
|
+
logger: Logger = NOOP_LOGGER,
|
|
87
|
+
onChange?: (change: IssueWatchChange) => void,
|
|
88
|
+
now: () => number = Date.now,
|
|
89
|
+
): Promise<void> {
|
|
90
|
+
const groups = groupBy(watches.issueSubscriptions(), (s: IssueWatchSubscription) => s.ref);
|
|
91
|
+
const nowMs = now();
|
|
92
|
+
const due = [...groups.entries()].filter(([, subs]) => subs.some((s) => isDue(s, nowMs)));
|
|
93
|
+
|
|
94
|
+
await Promise.all(
|
|
95
|
+
due.map(async ([ref, subs]) => {
|
|
96
|
+
try {
|
|
97
|
+
const issue = await service.get(ref);
|
|
98
|
+
const commentCount = await tryCommentCount(service, ref);
|
|
99
|
+
const previous = watches.getIssueSnapshot(ref);
|
|
100
|
+
const fetchedAt = new Date(nowMs);
|
|
101
|
+
watches.upsertIssueSnapshot({ ref, status: issue.status, updatedAt: issue.updatedAt, commentCount: commentCount ?? 0, fetchedAt });
|
|
102
|
+
|
|
103
|
+
const changes = diffIssueSnapshot(
|
|
104
|
+
previous ? { status: previous.status, updatedAt: previous.updatedAt, commentCount: previous.commentCount } : undefined,
|
|
105
|
+
{ status: issue.status, updatedAt: issue.updatedAt, commentCount },
|
|
106
|
+
);
|
|
107
|
+
if (changes.length > 0) {
|
|
108
|
+
const message = `${ref} (${issue.title}): ${changes.join(", ")}`;
|
|
109
|
+
watches.recordEvent("issue", ref, message, fetchedAt);
|
|
110
|
+
onChange?.({ ref, changes });
|
|
111
|
+
}
|
|
112
|
+
for (const subscription of subs) watches.markIssueChecked(ref, subscription.subscriberId, fetchedAt);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
logger.warn("issue watch sync failed for one ref", { ref, error: error instanceof Error ? error.message : String(error) });
|
|
115
|
+
}
|
|
116
|
+
}),
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface QueryWatchChange {
|
|
121
|
+
name: string;
|
|
122
|
+
added: string[];
|
|
123
|
+
removed: string[];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** One tick: re-runs every due watched saved query once, diffs the *set* of matching refs against last time, and records a WatchEvent when items appeared or dropped out. */
|
|
127
|
+
export async function syncQueryWatches(
|
|
128
|
+
service: TicketService,
|
|
129
|
+
queries: SavedQueryStore,
|
|
130
|
+
watches: WatchStore,
|
|
131
|
+
logger: Logger = NOOP_LOGGER,
|
|
132
|
+
onChange?: (change: QueryWatchChange) => void,
|
|
133
|
+
now: () => number = Date.now,
|
|
134
|
+
): Promise<void> {
|
|
135
|
+
const groups = groupBy(watches.queryWatchSubscriptions(), (s: QueryWatchSubscription) => s.name);
|
|
136
|
+
const nowMs = now();
|
|
137
|
+
const due = [...groups.entries()].filter(([, subs]) => subs.some((s) => isDue(s, nowMs)));
|
|
138
|
+
|
|
139
|
+
await Promise.all(
|
|
140
|
+
due.map(async ([name, subs]) => {
|
|
141
|
+
try {
|
|
142
|
+
const saved = queries.get(name);
|
|
143
|
+
if (!saved) {
|
|
144
|
+
logger.warn("watched saved query no longer exists", { name });
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const issues = await service.runQuery(saved.backend, saved.query, DEFAULT_QUERY_WATCH_LIMIT);
|
|
148
|
+
const refs = issues.map((issue) => issue.ref);
|
|
149
|
+
const previous = watches.getQuerySnapshot(name);
|
|
150
|
+
const fetchedAt = new Date(nowMs);
|
|
151
|
+
watches.upsertQuerySnapshot({ name, refs, fetchedAt });
|
|
152
|
+
|
|
153
|
+
if (previous) {
|
|
154
|
+
const previousSet = new Set(previous.refs);
|
|
155
|
+
const currentSet = new Set(refs);
|
|
156
|
+
const added = refs.filter((ref) => !previousSet.has(ref));
|
|
157
|
+
const removed = previous.refs.filter((ref) => !currentSet.has(ref));
|
|
158
|
+
if (added.length > 0 || removed.length > 0) {
|
|
159
|
+
const parts: string[] = [];
|
|
160
|
+
if (added.length > 0) parts.push(`${added.length} new: ${added.join(", ")}`);
|
|
161
|
+
if (removed.length > 0) parts.push(`${removed.length} dropped out: ${removed.join(", ")}`);
|
|
162
|
+
const message = `"${name}": ${parts.join("; ")}`;
|
|
163
|
+
watches.recordEvent("query", name, message, fetchedAt);
|
|
164
|
+
onChange?.({ name, added, removed });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
for (const subscription of subs) watches.markQueryChecked(name, subscription.subscriberId, fetchedAt);
|
|
168
|
+
} catch (error) {
|
|
169
|
+
logger.warn("query watch sync failed for one saved query", { name, error: error instanceof Error ? error.message : String(error) });
|
|
170
|
+
}
|
|
171
|
+
}),
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** MaintenanceTask wrapper for syncIssueWatches -- reads watches.issueSubscriptions() fresh every tick, so an empty watch list means this tick does no live fetches at all (see this module's own doc comment). */
|
|
176
|
+
export function createIssueWatchSyncTask(
|
|
177
|
+
service: TicketService,
|
|
178
|
+
watches: WatchStore,
|
|
179
|
+
intervalMs: number,
|
|
180
|
+
logger?: Logger,
|
|
181
|
+
onChange?: (change: IssueWatchChange) => void,
|
|
182
|
+
): MaintenanceTask {
|
|
183
|
+
return {
|
|
184
|
+
name: "issue-watch-sync",
|
|
185
|
+
intervalMs,
|
|
186
|
+
run: () => syncIssueWatches(service, watches, logger, onChange),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** MaintenanceTask wrapper for syncQueryWatches -- same empty-watch-list-is-a-no-op shape as createIssueWatchSyncTask. */
|
|
191
|
+
export function createQueryWatchSyncTask(
|
|
192
|
+
service: TicketService,
|
|
193
|
+
queries: SavedQueryStore,
|
|
194
|
+
watches: WatchStore,
|
|
195
|
+
intervalMs: number,
|
|
196
|
+
logger?: Logger,
|
|
197
|
+
onChange?: (change: QueryWatchChange) => void,
|
|
198
|
+
): MaintenanceTask {
|
|
199
|
+
return {
|
|
200
|
+
name: "query-watch-sync",
|
|
201
|
+
intervalMs,
|
|
202
|
+
run: () => syncQueryWatches(service, queries, watches, logger, onChange),
|
|
203
|
+
};
|
|
204
|
+
}
|
package/src/rpc/ops.ts
CHANGED
|
@@ -9,6 +9,7 @@ import type { BackendCapabilities } from "../issue/service.js";
|
|
|
9
9
|
import type { Template } from "../issue/template.js";
|
|
10
10
|
import type { TicketFocusState } from "../sqlite/focus.js";
|
|
11
11
|
import type { SavedQuery } from "../sqlite/saved-queries.js";
|
|
12
|
+
import type { IssueWatchSubscription, QueryWatchSubscription, WatchEvent } from "../sqlite/watches.js";
|
|
12
13
|
import type { StagedItem, StagePatchFields, StagePayload } from "../stage/store.js";
|
|
13
14
|
|
|
14
15
|
export type TicketOperation =
|
|
@@ -40,6 +41,13 @@ export type TicketOperation =
|
|
|
40
41
|
| "query.list"
|
|
41
42
|
| "query.remove"
|
|
42
43
|
| "query.run"
|
|
44
|
+
| "issue.subscribe"
|
|
45
|
+
| "issue.unsubscribe"
|
|
46
|
+
| "issue.subscribed"
|
|
47
|
+
| "query.subscribe"
|
|
48
|
+
| "query.unsubscribe"
|
|
49
|
+
| "query.subscribed"
|
|
50
|
+
| "watch.events"
|
|
43
51
|
| "stage.add"
|
|
44
52
|
| "stage.list"
|
|
45
53
|
| "stage.show"
|
|
@@ -77,6 +85,13 @@ export interface TicketOpInputs extends Record<TicketOperation, unknown> {
|
|
|
77
85
|
"query.list": Record<string, never>;
|
|
78
86
|
"query.remove": { name: string };
|
|
79
87
|
"query.run": { name: string; limit?: number };
|
|
88
|
+
"issue.subscribe": { ref: string; subscriberId?: string; scheduleMs?: number; projectRoot?: string };
|
|
89
|
+
"issue.unsubscribe": { ref: string; subscriberId?: string };
|
|
90
|
+
"issue.subscribed": { subscriberId?: string };
|
|
91
|
+
"query.subscribe": { name: string; subscriberId?: string; scheduleMs?: number; projectRoot?: string };
|
|
92
|
+
"query.unsubscribe": { name: string; subscriberId?: string };
|
|
93
|
+
"query.subscribed": { subscriberId?: string };
|
|
94
|
+
"watch.events": { subscriberId?: string; sinceId?: number; limit?: number };
|
|
80
95
|
"stage.add": { payload: StagePayload };
|
|
81
96
|
"stage.list": Record<string, never>;
|
|
82
97
|
"stage.show": { id: string };
|
|
@@ -118,6 +133,13 @@ export interface TicketOpOutputs extends Record<TicketOperation, unknown> {
|
|
|
118
133
|
"query.list": { queries: SavedQuery[] };
|
|
119
134
|
"query.remove": { removed: boolean };
|
|
120
135
|
"query.run": { issues: Issue[] };
|
|
136
|
+
"issue.subscribe": { subscribed: true };
|
|
137
|
+
"issue.unsubscribe": { unsubscribed: true };
|
|
138
|
+
"issue.subscribed": { watches: IssueWatchSubscription[] };
|
|
139
|
+
"query.subscribe": { subscribed: true };
|
|
140
|
+
"query.unsubscribe": { unsubscribed: true };
|
|
141
|
+
"query.subscribed": { watches: QueryWatchSubscription[] };
|
|
142
|
+
"watch.events": { events: WatchEvent[]; lastId: number };
|
|
121
143
|
"stage.add": { item: StagedItem };
|
|
122
144
|
"stage.list": { items: StagedItem[] };
|
|
123
145
|
"stage.show": { item: StagedItem };
|
|
@@ -156,6 +178,13 @@ export const TICKET_OPERATIONS: TicketOperation[] = [
|
|
|
156
178
|
"query.list",
|
|
157
179
|
"query.remove",
|
|
158
180
|
"query.run",
|
|
181
|
+
"issue.subscribe",
|
|
182
|
+
"issue.unsubscribe",
|
|
183
|
+
"issue.subscribed",
|
|
184
|
+
"query.subscribe",
|
|
185
|
+
"query.unsubscribe",
|
|
186
|
+
"query.subscribed",
|
|
187
|
+
"watch.events",
|
|
159
188
|
"stage.add",
|
|
160
189
|
"stage.list",
|
|
161
190
|
"stage.show",
|
package/src/rpc/server.ts
CHANGED
|
@@ -16,6 +16,7 @@ import type { TicketService } from "../issue/service.js";
|
|
|
16
16
|
import { FocusError, type FocusStore } from "../sqlite/focus.js";
|
|
17
17
|
import type { Ledger } from "../sqlite/ledger.js";
|
|
18
18
|
import { SavedQueryNotFoundError, type SavedQueryStore } from "../sqlite/saved-queries.js";
|
|
19
|
+
import type { WatchStore } from "../sqlite/watches.js";
|
|
19
20
|
import type { StagePayload, StageStore } from "../stage/store.js";
|
|
20
21
|
import { statusForKnownTicketError } from "./error-status.js";
|
|
21
22
|
import { type StagePushResult, TICKET_OPERATIONS, type TicketOperation, type TicketOpInputs, type TicketOpOutputs } from "./ops.js";
|
|
@@ -45,6 +46,20 @@ export interface TicketsAppDeps {
|
|
|
45
46
|
*/
|
|
46
47
|
vehicleRegistry: VehicleRegistry;
|
|
47
48
|
stageStore: StageStore;
|
|
49
|
+
watches: WatchStore;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The subset of a Vehicle call's own identity a handful of handlers read for a sensible default --
|
|
54
|
+
* see issue.subscribe/query.subscribe below, which default subscriberId/projectRoot from these
|
|
55
|
+
* when the caller doesn't pass them explicitly, mirroring @danypops/pipes' own ci.subscribe.
|
|
56
|
+
* Always undefined-safe: absent entirely for the raw HTTP /api/v1/ops dispatch (which has no Pi
|
|
57
|
+
* session behind it), present when the call came through agent-tools/tickets-vehicle.ts's
|
|
58
|
+
* Vehicle registration (see its own VehicleOperationContext.callerSessionId/callerProjectRoot).
|
|
59
|
+
*/
|
|
60
|
+
export interface HandlerCallContext {
|
|
61
|
+
callerSessionId?: string;
|
|
62
|
+
callerProjectRoot?: string;
|
|
48
63
|
}
|
|
49
64
|
|
|
50
65
|
// Narrower than TicketsAppDeps on purpose: no real handler reads
|
|
@@ -53,6 +68,7 @@ export interface TicketsAppDeps {
|
|
|
53
68
|
export type Handler<Op extends TicketOperation> = (
|
|
54
69
|
deps: Omit<TicketsAppDeps, "vehicleRegistry">,
|
|
55
70
|
input: TicketOpInputs[Op],
|
|
71
|
+
callContext?: HandlerCallContext,
|
|
56
72
|
) => Promise<TicketOpOutputs[Op]>;
|
|
57
73
|
|
|
58
74
|
/**
|
|
@@ -109,6 +125,37 @@ export const TICKET_OP_HANDLERS: { [Op in TicketOperation]: Handler<Op> } = {
|
|
|
109
125
|
if (!saved) throw new SavedQueryNotFoundError(input.name);
|
|
110
126
|
return { issues: await deps.service.runQuery(saved.backend, saved.query, input.limit) };
|
|
111
127
|
},
|
|
128
|
+
"issue.subscribe": async (deps, input, callContext) => {
|
|
129
|
+
const subscriberId = input.subscriberId ?? callContext?.callerSessionId ?? "";
|
|
130
|
+
const projectRoot = input.projectRoot ?? callContext?.callerProjectRoot;
|
|
131
|
+
deps.watches.subscribeIssue(input.ref, { subscriberId, scheduleMs: input.scheduleMs, projectRoot });
|
|
132
|
+
return { subscribed: true };
|
|
133
|
+
},
|
|
134
|
+
"issue.unsubscribe": async (deps, input, callContext) => {
|
|
135
|
+
deps.watches.unsubscribeIssue(input.ref, input.subscriberId ?? callContext?.callerSessionId ?? "");
|
|
136
|
+
return { unsubscribed: true };
|
|
137
|
+
},
|
|
138
|
+
"issue.subscribed": async (deps, input, callContext) => ({
|
|
139
|
+
watches: deps.watches.issueSubscriptionsFor(input.subscriberId ?? callContext?.callerSessionId ?? ""),
|
|
140
|
+
}),
|
|
141
|
+
"query.subscribe": async (deps, input, callContext) => {
|
|
142
|
+
const subscriberId = input.subscriberId ?? callContext?.callerSessionId ?? "";
|
|
143
|
+
const projectRoot = input.projectRoot ?? callContext?.callerProjectRoot;
|
|
144
|
+
deps.watches.subscribeQuery(input.name, { subscriberId, scheduleMs: input.scheduleMs, projectRoot });
|
|
145
|
+
return { subscribed: true };
|
|
146
|
+
},
|
|
147
|
+
"query.unsubscribe": async (deps, input, callContext) => {
|
|
148
|
+
deps.watches.unsubscribeQuery(input.name, input.subscriberId ?? callContext?.callerSessionId ?? "");
|
|
149
|
+
return { unsubscribed: true };
|
|
150
|
+
},
|
|
151
|
+
"query.subscribed": async (deps, input, callContext) => ({
|
|
152
|
+
watches: deps.watches.queryWatchSubscriptionsFor(input.subscriberId ?? callContext?.callerSessionId ?? ""),
|
|
153
|
+
}),
|
|
154
|
+
"watch.events": async (deps, input, callContext) => {
|
|
155
|
+
const subscriberId = input.subscriberId ?? callContext?.callerSessionId ?? "";
|
|
156
|
+
const events = deps.watches.eventsSince(subscriberId, input.sinceId ?? 0, input.limit);
|
|
157
|
+
return { events, lastId: events.at(-1)?.id ?? input.sinceId ?? 0 };
|
|
158
|
+
},
|
|
112
159
|
"stage.add": async (deps, input) => ({ item: deps.stageStore.add(input.payload) }),
|
|
113
160
|
"stage.list": async (deps) => ({ items: deps.stageStore.list() }),
|
|
114
161
|
"stage.show": async (deps, input) => ({ item: deps.stageStore.show(input.id) }),
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Watches — the daemon's local subscription + last-known-snapshot store for individual issues
|
|
3
|
+
* and saved queries, mirroring @danypops/pipes' own job_watches/run_snapshots split
|
|
4
|
+
* (packages/pipes/src/sqlite/run-pool.ts) one domain over: `issue_watches`/`query_watches` are
|
|
5
|
+
* the authoritative subscription lists the background sync tasks (process/watch-sync.ts) iterate;
|
|
6
|
+
* `issue_watch_snapshots`/`query_watch_snapshots` hold each watched key's last-observed state,
|
|
7
|
+
* independent of subscriptions, so a sync tick can tell "did this actually change" apart from
|
|
8
|
+
* "this is the first time we've ever looked." `watch_events` is the append-only, cursor-readable
|
|
9
|
+
* change log a client polls (watch.events) instead of re-deriving a diff itself.
|
|
10
|
+
*
|
|
11
|
+
* Deliberately does NOT auto-unsubscribe on any kind of "terminal" state the way run_snapshots
|
|
12
|
+
* does for a finished CI run: an issue can be reopened and a saved query's result set has no
|
|
13
|
+
* notion of "done" at all, so nothing here is a permanent completion signal worth stopping a
|
|
14
|
+
* background poll over. A subscription only ever ends via an explicit issue.unsubscribe/
|
|
15
|
+
* query.unsubscribe call.
|
|
16
|
+
*/
|
|
17
|
+
import type { Database } from "bun:sqlite";
|
|
18
|
+
import type { Migration } from "@danypops/vehicle-server/storage";
|
|
19
|
+
|
|
20
|
+
export const WATCH_MIGRATIONS: Migration[] = [
|
|
21
|
+
{
|
|
22
|
+
version: 4,
|
|
23
|
+
up: (db) => {
|
|
24
|
+
db.exec(`
|
|
25
|
+
CREATE TABLE issue_watches (
|
|
26
|
+
ref TEXT NOT NULL,
|
|
27
|
+
subscriber_id TEXT NOT NULL DEFAULT '',
|
|
28
|
+
schedule_ms INTEGER,
|
|
29
|
+
last_checked_at INTEGER,
|
|
30
|
+
project_root TEXT,
|
|
31
|
+
PRIMARY KEY (ref, subscriber_id)
|
|
32
|
+
);
|
|
33
|
+
CREATE TABLE issue_watch_snapshots (
|
|
34
|
+
ref TEXT PRIMARY KEY,
|
|
35
|
+
status TEXT NOT NULL,
|
|
36
|
+
updated_at TEXT,
|
|
37
|
+
comment_count INTEGER NOT NULL DEFAULT 0,
|
|
38
|
+
fetched_at INTEGER NOT NULL
|
|
39
|
+
);
|
|
40
|
+
CREATE TABLE query_watches (
|
|
41
|
+
name TEXT NOT NULL,
|
|
42
|
+
subscriber_id TEXT NOT NULL DEFAULT '',
|
|
43
|
+
schedule_ms INTEGER,
|
|
44
|
+
last_checked_at INTEGER,
|
|
45
|
+
project_root TEXT,
|
|
46
|
+
PRIMARY KEY (name, subscriber_id)
|
|
47
|
+
);
|
|
48
|
+
CREATE TABLE query_watch_snapshots (
|
|
49
|
+
name TEXT PRIMARY KEY,
|
|
50
|
+
refs_json TEXT NOT NULL,
|
|
51
|
+
fetched_at INTEGER NOT NULL
|
|
52
|
+
);
|
|
53
|
+
CREATE TABLE watch_events (
|
|
54
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
55
|
+
kind TEXT NOT NULL,
|
|
56
|
+
key TEXT NOT NULL,
|
|
57
|
+
message TEXT NOT NULL,
|
|
58
|
+
created_at INTEGER NOT NULL
|
|
59
|
+
);
|
|
60
|
+
CREATE INDEX watch_events_kind_key_idx ON watch_events(kind, key);
|
|
61
|
+
`);
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
export interface IssueWatchSubscription {
|
|
67
|
+
ref: string;
|
|
68
|
+
subscriberId: string;
|
|
69
|
+
scheduleMs?: number;
|
|
70
|
+
lastCheckedAt?: Date;
|
|
71
|
+
projectRoot?: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface QueryWatchSubscription {
|
|
75
|
+
name: string;
|
|
76
|
+
subscriberId: string;
|
|
77
|
+
scheduleMs?: number;
|
|
78
|
+
lastCheckedAt?: Date;
|
|
79
|
+
projectRoot?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface IssueWatchSnapshot {
|
|
83
|
+
ref: string;
|
|
84
|
+
status: string;
|
|
85
|
+
updatedAt?: string;
|
|
86
|
+
commentCount: number;
|
|
87
|
+
fetchedAt: Date;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface QueryWatchSnapshot {
|
|
91
|
+
name: string;
|
|
92
|
+
refs: string[];
|
|
93
|
+
fetchedAt: Date;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export type WatchEventKind = "issue" | "query";
|
|
97
|
+
|
|
98
|
+
export interface WatchEvent {
|
|
99
|
+
id: number;
|
|
100
|
+
kind: WatchEventKind;
|
|
101
|
+
/** The watched issue's ref, or the watched query's name. */
|
|
102
|
+
key: string;
|
|
103
|
+
message: string;
|
|
104
|
+
createdAt: Date;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
interface IssueWatchRow {
|
|
108
|
+
ref: string;
|
|
109
|
+
subscriber_id: string;
|
|
110
|
+
schedule_ms: number | null;
|
|
111
|
+
last_checked_at: number | null;
|
|
112
|
+
project_root: string | null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
interface QueryWatchRow {
|
|
116
|
+
name: string;
|
|
117
|
+
subscriber_id: string;
|
|
118
|
+
schedule_ms: number | null;
|
|
119
|
+
last_checked_at: number | null;
|
|
120
|
+
project_root: string | null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function toIssueSubscription(row: IssueWatchRow): IssueWatchSubscription {
|
|
124
|
+
return {
|
|
125
|
+
ref: row.ref,
|
|
126
|
+
subscriberId: row.subscriber_id,
|
|
127
|
+
scheduleMs: row.schedule_ms ?? undefined,
|
|
128
|
+
lastCheckedAt: row.last_checked_at !== null ? new Date(row.last_checked_at) : undefined,
|
|
129
|
+
projectRoot: row.project_root ?? undefined,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function toQuerySubscription(row: QueryWatchRow): QueryWatchSubscription {
|
|
134
|
+
return {
|
|
135
|
+
name: row.name,
|
|
136
|
+
subscriberId: row.subscriber_id,
|
|
137
|
+
scheduleMs: row.schedule_ms ?? undefined,
|
|
138
|
+
lastCheckedAt: row.last_checked_at !== null ? new Date(row.last_checked_at) : undefined,
|
|
139
|
+
projectRoot: row.project_root ?? undefined,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export class WatchStore {
|
|
144
|
+
constructor(private readonly db: Database) {}
|
|
145
|
+
|
|
146
|
+
// ---- issue watches ----
|
|
147
|
+
|
|
148
|
+
subscribeIssue(ref: string, options?: { subscriberId?: string; scheduleMs?: number; projectRoot?: string }): void {
|
|
149
|
+
const subscriberId = options?.subscriberId ?? "";
|
|
150
|
+
this.db
|
|
151
|
+
.query(
|
|
152
|
+
`INSERT INTO issue_watches (ref, subscriber_id, schedule_ms, project_root)
|
|
153
|
+
VALUES ($ref, $subscriberId, $scheduleMs, $projectRoot)
|
|
154
|
+
ON CONFLICT(ref, subscriber_id) DO UPDATE SET schedule_ms = excluded.schedule_ms, project_root = excluded.project_root`,
|
|
155
|
+
)
|
|
156
|
+
.run({
|
|
157
|
+
$ref: ref,
|
|
158
|
+
$subscriberId: subscriberId,
|
|
159
|
+
$scheduleMs: options?.scheduleMs ?? null,
|
|
160
|
+
$projectRoot: options?.projectRoot ?? null,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
unsubscribeIssue(ref: string, subscriberId = ""): void {
|
|
165
|
+
this.db
|
|
166
|
+
.query("DELETE FROM issue_watches WHERE ref = $ref AND subscriber_id = $subscriberId")
|
|
167
|
+
.run({ $ref: ref, $subscriberId: subscriberId });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
isIssueSubscribed(ref: string, subscriberId = ""): boolean {
|
|
171
|
+
return (
|
|
172
|
+
this.db.query("SELECT 1 FROM issue_watches WHERE ref = $ref AND subscriber_id = $subscriberId").get({
|
|
173
|
+
$ref: ref,
|
|
174
|
+
$subscriberId: subscriberId,
|
|
175
|
+
}) !== null
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Every individual issue subscription -- what the sync task iterates. */
|
|
180
|
+
issueSubscriptions(): IssueWatchSubscription[] {
|
|
181
|
+
const rows = this.db.query("SELECT * FROM issue_watches").all() as IssueWatchRow[];
|
|
182
|
+
return rows.map(toIssueSubscription);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Subscriptions scoped to one subscriber -- what issue.subscribed returns. */
|
|
186
|
+
issueSubscriptionsFor(subscriberId: string): IssueWatchSubscription[] {
|
|
187
|
+
const rows = this.db.query("SELECT * FROM issue_watches WHERE subscriber_id = $subscriberId").all({
|
|
188
|
+
$subscriberId: subscriberId,
|
|
189
|
+
}) as IssueWatchRow[];
|
|
190
|
+
return rows.map(toIssueSubscription);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
markIssueChecked(ref: string, subscriberId: string, at: Date): void {
|
|
194
|
+
this.db
|
|
195
|
+
.query("UPDATE issue_watches SET last_checked_at = $at WHERE ref = $ref AND subscriber_id = $subscriberId")
|
|
196
|
+
.run({ $at: at.getTime(), $ref: ref, $subscriberId: subscriberId });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
getIssueSnapshot(ref: string): IssueWatchSnapshot | undefined {
|
|
200
|
+
const row = this.db.query("SELECT * FROM issue_watch_snapshots WHERE ref = $ref").get({ $ref: ref }) as {
|
|
201
|
+
ref: string;
|
|
202
|
+
status: string;
|
|
203
|
+
updated_at: string | null;
|
|
204
|
+
comment_count: number;
|
|
205
|
+
fetched_at: number;
|
|
206
|
+
} | null;
|
|
207
|
+
if (!row) return undefined;
|
|
208
|
+
return {
|
|
209
|
+
ref: row.ref,
|
|
210
|
+
status: row.status,
|
|
211
|
+
updatedAt: row.updated_at ?? undefined,
|
|
212
|
+
commentCount: row.comment_count,
|
|
213
|
+
fetchedAt: new Date(row.fetched_at),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
upsertIssueSnapshot(snapshot: IssueWatchSnapshot): void {
|
|
218
|
+
this.db
|
|
219
|
+
.query(
|
|
220
|
+
`INSERT INTO issue_watch_snapshots (ref, status, updated_at, comment_count, fetched_at)
|
|
221
|
+
VALUES ($ref, $status, $updatedAt, $commentCount, $fetchedAt)
|
|
222
|
+
ON CONFLICT(ref) DO UPDATE SET status = excluded.status, updated_at = excluded.updated_at, comment_count = excluded.comment_count, fetched_at = excluded.fetched_at`,
|
|
223
|
+
)
|
|
224
|
+
.run({
|
|
225
|
+
$ref: snapshot.ref,
|
|
226
|
+
$status: snapshot.status,
|
|
227
|
+
$updatedAt: snapshot.updatedAt ?? null,
|
|
228
|
+
$commentCount: snapshot.commentCount,
|
|
229
|
+
$fetchedAt: snapshot.fetchedAt.getTime(),
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ---- query watches ----
|
|
234
|
+
|
|
235
|
+
subscribeQuery(name: string, options?: { subscriberId?: string; scheduleMs?: number; projectRoot?: string }): void {
|
|
236
|
+
const subscriberId = options?.subscriberId ?? "";
|
|
237
|
+
this.db
|
|
238
|
+
.query(
|
|
239
|
+
`INSERT INTO query_watches (name, subscriber_id, schedule_ms, project_root)
|
|
240
|
+
VALUES ($name, $subscriberId, $scheduleMs, $projectRoot)
|
|
241
|
+
ON CONFLICT(name, subscriber_id) DO UPDATE SET schedule_ms = excluded.schedule_ms, project_root = excluded.project_root`,
|
|
242
|
+
)
|
|
243
|
+
.run({
|
|
244
|
+
$name: name,
|
|
245
|
+
$subscriberId: subscriberId,
|
|
246
|
+
$scheduleMs: options?.scheduleMs ?? null,
|
|
247
|
+
$projectRoot: options?.projectRoot ?? null,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
unsubscribeQuery(name: string, subscriberId = ""): void {
|
|
252
|
+
this.db
|
|
253
|
+
.query("DELETE FROM query_watches WHERE name = $name AND subscriber_id = $subscriberId")
|
|
254
|
+
.run({ $name: name, $subscriberId: subscriberId });
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
isQuerySubscribed(name: string, subscriberId = ""): boolean {
|
|
258
|
+
return (
|
|
259
|
+
this.db.query("SELECT 1 FROM query_watches WHERE name = $name AND subscriber_id = $subscriberId").get({
|
|
260
|
+
$name: name,
|
|
261
|
+
$subscriberId: subscriberId,
|
|
262
|
+
}) !== null
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
queryWatchSubscriptions(): QueryWatchSubscription[] {
|
|
267
|
+
const rows = this.db.query("SELECT * FROM query_watches").all() as QueryWatchRow[];
|
|
268
|
+
return rows.map(toQuerySubscription);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
queryWatchSubscriptionsFor(subscriberId: string): QueryWatchSubscription[] {
|
|
272
|
+
const rows = this.db.query("SELECT * FROM query_watches WHERE subscriber_id = $subscriberId").all({
|
|
273
|
+
$subscriberId: subscriberId,
|
|
274
|
+
}) as QueryWatchRow[];
|
|
275
|
+
return rows.map(toQuerySubscription);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
markQueryChecked(name: string, subscriberId: string, at: Date): void {
|
|
279
|
+
this.db
|
|
280
|
+
.query("UPDATE query_watches SET last_checked_at = $at WHERE name = $name AND subscriber_id = $subscriberId")
|
|
281
|
+
.run({ $at: at.getTime(), $name: name, $subscriberId: subscriberId });
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
getQuerySnapshot(name: string): QueryWatchSnapshot | undefined {
|
|
285
|
+
const row = this.db.query("SELECT * FROM query_watch_snapshots WHERE name = $name").get({ $name: name }) as {
|
|
286
|
+
name: string;
|
|
287
|
+
refs_json: string;
|
|
288
|
+
fetched_at: number;
|
|
289
|
+
} | null;
|
|
290
|
+
if (!row) return undefined;
|
|
291
|
+
return { name: row.name, refs: JSON.parse(row.refs_json) as string[], fetchedAt: new Date(row.fetched_at) };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
upsertQuerySnapshot(snapshot: QueryWatchSnapshot): void {
|
|
295
|
+
this.db
|
|
296
|
+
.query(
|
|
297
|
+
`INSERT INTO query_watch_snapshots (name, refs_json, fetched_at)
|
|
298
|
+
VALUES ($name, $refsJson, $fetchedAt)
|
|
299
|
+
ON CONFLICT(name) DO UPDATE SET refs_json = excluded.refs_json, fetched_at = excluded.fetched_at`,
|
|
300
|
+
)
|
|
301
|
+
.run({ $name: snapshot.name, $refsJson: JSON.stringify(snapshot.refs), $fetchedAt: snapshot.fetchedAt.getTime() });
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ---- change events ----
|
|
305
|
+
|
|
306
|
+
/** Appends one change event -- called only by the sync tasks, once per real diff. */
|
|
307
|
+
recordEvent(kind: WatchEventKind, key: string, message: string, at: Date = new Date()): void {
|
|
308
|
+
this.db
|
|
309
|
+
.query("INSERT INTO watch_events (kind, key, message, created_at) VALUES ($kind, $key, $message, $createdAt)")
|
|
310
|
+
.run({ $kind: kind, $key: key, $message: message, $createdAt: at.getTime() });
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Events since `sinceId` (exclusive), newest-last, bounded by `limit`, scoped to keys the given
|
|
315
|
+
* subscriber is *currently* subscribed to (an EXISTS join against issue_watches/query_watches --
|
|
316
|
+
* same scoping shape run-pool.ts's watchedRunsWithProjectLabels already uses for subscriberId).
|
|
317
|
+
* An event for a key this subscriber never subscribed to, or already unsubscribed from, never
|
|
318
|
+
* appears here -- avoids a global firehose leaking one session's watches into another's.
|
|
319
|
+
*/
|
|
320
|
+
eventsSince(subscriberId: string, sinceId: number, limit = 100): WatchEvent[] {
|
|
321
|
+
const bounded = Math.max(1, Math.min(500, Math.floor(limit)));
|
|
322
|
+
const rows = this.db
|
|
323
|
+
.query(
|
|
324
|
+
`SELECT * FROM watch_events
|
|
325
|
+
WHERE id > $sinceId
|
|
326
|
+
AND (
|
|
327
|
+
(kind = 'issue' AND EXISTS (SELECT 1 FROM issue_watches WHERE issue_watches.ref = watch_events.key AND issue_watches.subscriber_id = $subscriberId))
|
|
328
|
+
OR
|
|
329
|
+
(kind = 'query' AND EXISTS (SELECT 1 FROM query_watches WHERE query_watches.name = watch_events.key AND query_watches.subscriber_id = $subscriberId))
|
|
330
|
+
)
|
|
331
|
+
ORDER BY id ASC
|
|
332
|
+
LIMIT $limit`,
|
|
333
|
+
)
|
|
334
|
+
.all({ $sinceId: sinceId, $subscriberId: subscriberId, $limit: bounded }) as Array<{
|
|
335
|
+
id: number;
|
|
336
|
+
kind: string;
|
|
337
|
+
key: string;
|
|
338
|
+
message: string;
|
|
339
|
+
created_at: number;
|
|
340
|
+
}>;
|
|
341
|
+
return rows.map((row) => ({
|
|
342
|
+
id: row.id,
|
|
343
|
+
kind: row.kind as WatchEventKind,
|
|
344
|
+
key: row.key,
|
|
345
|
+
message: row.message,
|
|
346
|
+
createdAt: new Date(row.created_at),
|
|
347
|
+
}));
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** The highest event id recorded so far, or 0 if none -- lets a fresh subscriber start its cursor at "now" instead of replaying every historical event. */
|
|
351
|
+
latestEventId(): number {
|
|
352
|
+
const row = this.db.query("SELECT MAX(id) as max_id FROM watch_events").get() as { max_id: number | null } | null;
|
|
353
|
+
return row?.max_id ?? 0;
|
|
354
|
+
}
|
|
355
|
+
}
|