@danypops/tickets 0.11.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/tickets",
3
- "version": "0.11.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.13.0",
28
- "@danypops/vehicle-server": "^0.18.4",
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";
@@ -34,6 +34,7 @@ const LIMITS = { defaultTimeoutMs: 10_000, maxTimeoutMs: 30_000, maxRequestBytes
34
34
 
35
35
  const stringProp: LooseObjectProperty = { type: "string" };
36
36
  const numberProp: LooseObjectProperty = { type: "number" };
37
+ const booleanProp: LooseObjectProperty = { type: "boolean" };
37
38
 
38
39
  interface OperationSpec {
39
40
  readonly action: TicketOperation;
@@ -71,7 +72,8 @@ const OPERATIONS: readonly OperationSpec[] = [
71
72
  },
72
73
  {
73
74
  action: "issue.list",
74
- description: "Lists issues from one backend, optionally filtered.",
75
+ description:
76
+ "Lists issues from one backend, optionally filtered. reportedByMe/assignedToMe/reviewRequestedOfMe/qaContactIsMe filter to the caller's own tickets using each backend's own identity (no username needed); set more than one to OR them together. reviewRequestedOfMe is GitHub/GitLab-only (PR/MR reviewer requests); qaContactIsMe is Jira-only (its discovered 'QA Contact' field) -- an unsupported flag on a given backend throws rather than being silently ignored.",
75
77
  effect: "read",
76
78
  properties: {
77
79
  backend: stringProp,
@@ -80,11 +82,25 @@ const OPERATIONS: readonly OperationSpec[] = [
80
82
  assignee: stringProp,
81
83
  labels: stringArrayProp,
82
84
  limit: numberProp,
85
+ reportedByMe: booleanProp,
86
+ assignedToMe: booleanProp,
87
+ reviewRequestedOfMe: booleanProp,
88
+ qaContactIsMe: booleanProp,
83
89
  },
84
90
  required: ["backend"],
85
- mapInput: ({ backend, project, status, assignee, labels, limit }) => ({
91
+ mapInput: ({ backend, project, status, assignee, labels, limit, reportedByMe, assignedToMe, reviewRequestedOfMe, qaContactIsMe }) => ({
86
92
  backend,
87
- filter: definedEntriesOnly({ project, status, assignee, labels, limit }),
93
+ filter: definedEntriesOnly({
94
+ project,
95
+ status,
96
+ assignee,
97
+ labels,
98
+ limit,
99
+ reportedByMe,
100
+ assignedToMe,
101
+ reviewRequestedOfMe,
102
+ qaContactIsMe,
103
+ }),
88
104
  }),
89
105
  },
90
106
  {
@@ -249,6 +265,61 @@ const OPERATIONS: readonly OperationSpec[] = [
249
265
  properties: { name: stringProp, limit: numberProp },
250
266
  required: ["name"],
251
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
+ },
252
323
  {
253
324
  action: "stage.add",
254
325
  description:
@@ -403,7 +474,17 @@ export function createTicketsVehicleRegistry(deps: Omit<TicketsAppDeps, "vehicle
403
474
  bindVehicleOperation(
404
475
  operation,
405
476
  () => async (context) =>
406
- withTicketsErrorParity<unknown>(() => handler(deps, mapInput(context.input as Record<string, unknown>) as never)),
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
+ }),
407
488
  ),
408
489
  );
409
490
  }
package/src/cli/index.ts CHANGED
@@ -47,6 +47,10 @@ program
47
47
  .option("--assignee <user>", "filter by assignee")
48
48
  .option("--label <label...>", "filter by label(s)")
49
49
  .option("--limit <n>", "max results", (v) => Number.parseInt(v, 10))
50
+ .option("--reported-by-me", "only issues reported/authored by you (Jira reporter / GitHub PR author / GitLab issue author)")
51
+ .option("--assigned-to-me", "only issues assigned to you")
52
+ .option("--review-requested-of-me", "only PRs/MRs where you're requested as a reviewer (GitHub/GitLab only)")
53
+ .option("--qa-contact-is-me", "only issues where you're the QA Contact (Jira only)")
50
54
  .action(async (opts) => {
51
55
  const filter: ListFilter = {
52
56
  project: opts.project,
@@ -54,6 +58,10 @@ program
54
58
  assignee: opts.assignee,
55
59
  labels: opts.label,
56
60
  limit: opts.limit,
61
+ reportedByMe: opts.reportedByMe,
62
+ assignedToMe: opts.assignedToMe,
63
+ reviewRequestedOfMe: opts.reviewRequestedOfMe,
64
+ qaContactIsMe: opts.qaContactIsMe,
57
65
  };
58
66
  await withClient((client) => client.call("issue.list", { backend: opts.backend, filter }));
59
67
  });
@@ -150,6 +158,37 @@ program
150
158
  await withClient((client) => client.call("issue.merge", { ref, method: opts.method }));
151
159
  });
152
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
+
153
192
  const comment = program.command("comment").description("comment operations");
154
193
 
155
194
  comment
@@ -313,6 +352,28 @@ queryCmd
313
352
  await withClient((client) => client.call("query.run", { name, limit: opts.limit }));
314
353
  });
315
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
+
316
377
  discoverCmd
317
378
  .command("board_quickfilter")
318
379
  .description(
@@ -165,19 +165,34 @@ export class GitHubRepository {
165
165
  }
166
166
 
167
167
  async list(filter: ListFilter): Promise<Issue[]> {
168
+ if (filter.qaContactIsMe) throw new Error('github: "qaContactIsMe" is a Jira-only concept, not supported on the github backend');
168
169
  const limit = filter.limit && filter.limit > 0 ? filter.limit : 50;
169
- const raw = await this.call((signal) =>
170
- this.client.rest.issues.listForRepo({
171
- owner: this.owner,
172
- repo: this.repoName(),
173
- per_page: limit,
174
- state: filter.status ? mapStatusToGitHub(filter.status) : "all",
175
- assignee: filter.assignee,
176
- labels: filter.labels?.length ? filter.labels.join(",") : undefined,
177
- request: { signal },
178
- }),
179
- );
180
- return (raw as GhIssue[]).map((i) => toDomain(i));
170
+ const meQualifiers = buildMeQualifiers(filter);
171
+ if (meQualifiers.length === 0) {
172
+ const raw = await this.call((signal) =>
173
+ this.client.rest.issues.listForRepo({
174
+ owner: this.owner,
175
+ repo: this.repoName(),
176
+ per_page: limit,
177
+ state: filter.status ? mapStatusToGitHub(filter.status) : "all",
178
+ assignee: filter.assignee,
179
+ labels: filter.labels?.length ? filter.labels.join(",") : undefined,
180
+ request: { signal },
181
+ }),
182
+ );
183
+ return (raw as GhIssue[]).map((i) => toDomain(i));
184
+ }
185
+ // Any "me" flag routes through the Search API instead of listForRepo: it's the only GitHub
186
+ // surface with a review-requested filter at all (confirmed against GitHub's own REST docs --
187
+ // listForRepo has no such parameter), and it happens to support author/assignee "me" qualifiers
188
+ // too, so one call covers every combination of the three flags. Response items are Issue-shaped
189
+ // (same pull_request: {merged_at} stub as listForRepo's own entries -- confirmed against
190
+ // @octokit/openapi-types' issue-search-result-item schema), so toDomain() applies unchanged.
191
+ const q = buildMeSearchQuery(this.owner, this.repoName(), filter, meQualifiers);
192
+ const result = (await this.call((signal) =>
193
+ this.client.rest.search.issuesAndPullRequests({ q, per_page: limit, request: { signal } }),
194
+ )) as { items: GhIssue[] };
195
+ return result.items.map((i) => toDomain(i));
181
196
  }
182
197
 
183
198
  async get(key: string): Promise<Issue> {
@@ -362,6 +377,24 @@ function mapStatusToGitHub(status: Status): "open" | "closed" {
362
377
  return status === "done" || status === "canceled" ? "closed" : "open";
363
378
  }
364
379
 
380
+ /** ListFilter.{reportedByMe,assignedToMe,reviewRequestedOfMe} -> GitHub Search API qualifiers -- see ListFilter's own doc comment. */
381
+ function buildMeQualifiers(filter: ListFilter): string[] {
382
+ const qualifiers: string[] = [];
383
+ if (filter.reportedByMe) qualifiers.push("author:@me");
384
+ if (filter.assignedToMe) qualifiers.push("assignee:@me");
385
+ if (filter.reviewRequestedOfMe) qualifiers.push("user-review-requested:@me");
386
+ return qualifiers;
387
+ }
388
+
389
+ /** Composes a full Search API `q` string: repo scope + status/labels (AND, matching list()'s own semantics) + the "me" qualifiers (OR'd together). */
390
+ function buildMeSearchQuery(owner: string, repo: string, filter: ListFilter, meQualifiers: readonly string[]): string {
391
+ const parts = [`repo:${owner}/${repo}`];
392
+ if (filter.status) parts.push(`is:${mapStatusToGitHub(filter.status)}`);
393
+ for (const label of filter.labels ?? []) parts.push(`label:"${label.replace(/"/g, '\\"')}"`);
394
+ parts.push(meQualifiers.length === 1 ? meQualifiers[0]! : `(${meQualifiers.join(" OR ")})`);
395
+ return parts.join(" ");
396
+ }
397
+
365
398
  function mapStatusFromGitHub(state: string): Status {
366
399
  return state.toLowerCase() === "closed" ? "done" : "todo";
367
400
  }
@@ -166,17 +166,54 @@ export class GitLabRepository {
166
166
  }
167
167
 
168
168
  async list(filter: ListFilter): Promise<Issue[]> {
169
+ if (filter.qaContactIsMe) throw new Error('gitlab: "qaContactIsMe" is a Jira-only concept, not supported on the gitlab backend');
170
+ if (filter.reviewRequestedOfMe) {
171
+ throw new Error(
172
+ 'gitlab: "reviewRequestedOfMe" is a merge-request-only concept (scope=reviews_for_me) that list()\'s Issues-only scope can\'t express -- see get()\'s "!<iid>" convention for merge requests',
173
+ );
174
+ }
169
175
  const limit = filter.limit && filter.limit > 0 ? filter.limit : 50;
170
- const raw = await this.call<GlIssue[]>(() =>
171
- this.client.Issues.all({
172
- projectId: this.projectId,
173
- perPage: limit,
174
- state: filter.status ? mapStatusToGitLab(filter.status) : undefined,
175
- assigneeUsername: filter.assignee ? [filter.assignee] : undefined,
176
- labels: filter.labels?.length ? filter.labels.join(",") : undefined,
177
- }),
176
+ const scopes = meScopes(filter);
177
+ if (scopes.length <= 1) {
178
+ const raw = await this.call<GlIssue[]>(() =>
179
+ this.client.Issues.all({
180
+ projectId: this.projectId,
181
+ perPage: limit,
182
+ state: filter.status ? mapStatusToGitLab(filter.status) : undefined,
183
+ assigneeUsername: filter.assignee ? [filter.assignee] : undefined,
184
+ labels: filter.labels?.length ? filter.labels.join(",") : undefined,
185
+ ...(scopes[0] ? { scope: scopes[0] } : {}),
186
+ }),
187
+ );
188
+ return raw.map(toDomain);
189
+ }
190
+ // Both reportedByMe and assignedToMe: GitLab's own `scope` param takes exactly one value per
191
+ // call (no server-side OR across scopes), so the OR-of-roles group ListFilter's own doc comment
192
+ // describes is executed here as two calls, deduped client-side by ref -- the one place in this
193
+ // adapter an OR of "me" flags genuinely costs more than one request (Jira/GitHub both express
194
+ // it in a single native query).
195
+ const results = await Promise.all(
196
+ scopes.map((scope) =>
197
+ this.call<GlIssue[]>(() =>
198
+ this.client.Issues.all({
199
+ projectId: this.projectId,
200
+ perPage: limit,
201
+ state: filter.status ? mapStatusToGitLab(filter.status) : undefined,
202
+ labels: filter.labels?.length ? filter.labels.join(",") : undefined,
203
+ scope,
204
+ }),
205
+ ),
206
+ ),
178
207
  );
179
- return raw.map(toDomain);
208
+ const seen = new Set<string>();
209
+ const merged: Issue[] = [];
210
+ for (const raw of results.flat()) {
211
+ const issue = toDomain(raw);
212
+ if (seen.has(issue.ref)) continue;
213
+ seen.add(issue.ref);
214
+ merged.push(issue);
215
+ }
216
+ return merged.slice(0, limit);
180
217
  }
181
218
 
182
219
  async get(key: string): Promise<Issue> {
@@ -372,6 +409,14 @@ function mapStatusToGitLab(status: Status): "opened" | "closed" {
372
409
  return status === "done" || status === "canceled" ? "closed" : "opened";
373
410
  }
374
411
 
412
+ /** ListFilter.{reportedByMe,assignedToMe} -> GitLab Issues API `scope` values -- see ListFilter's own doc comment. */
413
+ function meScopes(filter: ListFilter): ("created_by_me" | "assigned_to_me")[] {
414
+ const scopes: ("created_by_me" | "assigned_to_me")[] = [];
415
+ if (filter.reportedByMe) scopes.push("created_by_me");
416
+ if (filter.assignedToMe) scopes.push("assigned_to_me");
417
+ return scopes;
418
+ }
419
+
375
420
  function mapStatusEventToGitLab(status: Status): "close" | "reopen" {
376
421
  return status === "done" || status === "canceled" ? "close" : "reopen";
377
422
  }
@@ -178,6 +178,30 @@ export interface ListFilter {
178
178
  assignee?: string;
179
179
  query?: string;
180
180
  limit?: number;
181
+ /**
182
+ * "Mine" filtering -- deliberately a small set of named, orthogonal boolean flags rather than
183
+ * either a hardcoded single `mine` concept or a generic cross-backend query language (see the
184
+ * research Doc "Tickets 'mine' filtering: grounded API research" for why both alternatives were
185
+ * rejected). Every backend maps each flag to its own native "current user" mechanism (Jira's JQL
186
+ * currentUser(), GitHub's Search API @me qualifiers, GitLab's scope=*_me) -- never a resolved
187
+ * literal username. Setting more than one of these ORs them together ("assignee OR reporter"),
188
+ * AND'd with every other ListFilter field exactly like today's plain fields. A backend that has
189
+ * no native equivalent for a given flag throws rather than silently ignoring it (e.g.
190
+ * reviewRequestedOfMe on Jira, qaContactIsMe on GitHub/GitLab) -- a silently-dropped role would
191
+ * make an incomplete result look complete.
192
+ */
193
+ /** Jira: `reporter = currentUser()`. GitHub: `author:@me` (Search API). GitLab: `scope=created_by_me` (Issues API). */
194
+ reportedByMe?: boolean;
195
+ /** Jira: `assignee = currentUser()`. GitHub: `assignee:@me` (Search API). GitLab: `scope=assigned_to_me` (Issues API). */
196
+ assignedToMe?: boolean;
197
+ /**
198
+ * GitHub: `user-review-requested:@me` (Search API, PRs only). GitLab: `scope=reviews_for_me` --
199
+ * a merge-request-only concept list()'s Issues-only scope can't express, so GitLab throws. Jira
200
+ * has no reviewer concept on plain issues, so Jira throws too.
201
+ */
202
+ reviewRequestedOfMe?: boolean;
203
+ /** Jira only, via the discovered "QA Contact" custom field (auto-discovers if never run). GitHub/GitLab throw -- no equivalent concept. */
204
+ qaContactIsMe?: boolean;
181
205
  }
182
206
 
183
207
  /** "backend:key" ref parsing, split on the first colon only (keys may contain colons). */
package/src/jira/jira.ts CHANGED
@@ -204,6 +204,11 @@ export class JiraRepository {
204
204
  * the background), not just the single legacy `project` config field.
205
205
  */
206
206
  async list(filter: ListFilter): Promise<Issue[]> {
207
+ if (filter.reviewRequestedOfMe) {
208
+ throw new Error(
209
+ 'jira: "reviewRequestedOfMe" has no meaning on Jira issues (no reviewer concept) -- omit this filter for the jira backend',
210
+ );
211
+ }
207
212
  const projects = filter.project ? [filter.project] : this.defaultProjects();
208
213
  const clauses: string[] = [];
209
214
  const scope = projectClause(projects);
@@ -211,9 +216,30 @@ export class JiraRepository {
211
216
  if (filter.status) clauses.push(`status = ${jqlQuote(mapStatusToJira(filter.status))}`);
212
217
  if (filter.assignee) clauses.push(`assignee = ${jqlQuote(filter.assignee)}`);
213
218
  for (const label of filter.labels ?? []) clauses.push(`labels = ${jqlQuote(label)}`);
219
+ const meClauses = await this.buildMeClauses(filter);
220
+ if (meClauses.length > 0) clauses.push(meClauses.length === 1 ? meClauses[0]! : `(${meClauses.join(" OR ")})`);
214
221
  return this.searchJql(buildJql(clauses, "AND"), filter.limit ?? 50);
215
222
  }
216
223
 
224
+ /**
225
+ * Builds the OR'd group of "me" role clauses for list()'s ListFilter.{reportedByMe,assignedToMe,
226
+ * qaContactIsMe} flags -- see ListFilter's own doc comment for why this is a small set of named
227
+ * flags rather than a hardcoded single "mine" concept or a generic query language. "QA Contact"
228
+ * resolves through the same discoverFields()-backed manifest applyCustomFields() already uses
229
+ * (auto-discovering on first use), so an instance without that field throws a clear "unknown
230
+ * custom field" error rather than silently omitting the clause.
231
+ */
232
+ private async buildMeClauses(filter: ListFilter): Promise<string[]> {
233
+ const clauses: string[] = [];
234
+ if (filter.assignedToMe) clauses.push("assignee = currentUser()");
235
+ if (filter.reportedByMe) clauses.push("reporter = currentUser()");
236
+ if (filter.qaContactIsMe) {
237
+ await this.resolveCustomField("QA Contact");
238
+ clauses.push('"QA Contact" = currentUser()');
239
+ }
240
+ return clauses;
241
+ }
242
+
217
243
  async get(key: string): Promise<Issue> {
218
244
  const [raw, remoteLinks] = await Promise.all([
219
245
  this.call<JiraIssue>(() => this.client.issues.getIssue({ issueIdOrKey: key }), key),
@@ -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, { migrations: [...LEDGER_MIGRATIONS, ...FOCUS_MIGRATIONS, ...SAVED_QUERY_MIGRATIONS] });
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
+ }