@danypops/tickets 0.12.0 → 0.14.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.12.0",
3
+ "version": "0.14.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,9 +24,9 @@
24
24
  "typecheck": "tsc --noEmit"
25
25
  },
26
26
  "dependencies": {
27
- "@danypops/vehicle-core": "^0.13.0",
28
- "@danypops/vehicle-server": "^0.18.4",
29
- "@danypops/vehicle-client": "^0.7.1",
27
+ "@danypops/vehicle-core": "^0.15.0",
28
+ "@danypops/vehicle-server": "^0.21.1",
29
+ "@danypops/vehicle-client": "^0.8.1",
30
30
  "@danypops/enigma-client": "^0.6.1",
31
31
  "@gitbeaker/rest": "^43.8.0",
32
32
  "commander": "^12.1.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>(() => 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
+ }),
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(
@@ -16,11 +16,14 @@ import type { IssueRepository } from "../issue/repository.js";
16
16
  import { TicketService } from "../issue/service.js";
17
17
  import { TICKETS_DAEMON_NAMES } from "../rpc/ops.js";
18
18
  import { buildApp, type TicketsAppDeps } from "../rpc/server.js";
19
- import { FOCUS_MIGRATIONS, FocusStore } from "../sqlite/focus.js";
19
+ import { FOCUS_MIGRATIONS, FOCUS_STALE_AFTER_MS, 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 { SESSION_IDENTITY_MIGRATIONS, SqliteSessionIdentityStore } from "../sqlite/session-identity.js";
23
+ import { WATCH_MIGRATIONS, WatchStore } from "../sqlite/watches.js";
22
24
  import { StageStore } from "../stage/store.js";
23
25
  import { createSyncTask } from "./poller.js";
26
+ import { createIssueWatchSyncTask, createQueryWatchSyncTask } from "./watch-sync.js";
24
27
 
25
28
  export interface BootstrapOptions {
26
29
  pathEnv?: PathEnvironment;
@@ -39,6 +42,12 @@ export interface BootstrapOptions {
39
42
  checkpointIntervalMs?: number;
40
43
  /** How often the live backend set re-resolves from config/env/Enigma. Ignored when repos is injected. */
41
44
  backendRefreshIntervalMs?: number;
45
+ /** How often every subscribed issue is re-fetched and diffed. Defaults to DEFAULT_ISSUE_WATCH_INTERVAL_MS. */
46
+ issueWatchIntervalMs?: number;
47
+ /** How often every subscribed saved query is re-run and diffed. Defaults to DEFAULT_QUERY_WATCH_INTERVAL_MS. */
48
+ queryWatchIntervalMs?: number;
49
+ /** How often stale (untouched for FOCUS_STALE_AFTER_MS) Focus scopes are reaped. Defaults to DEFAULT_FOCUS_REAP_INTERVAL_MS. */
50
+ focusReapIntervalMs?: number;
42
51
  /**
43
52
  * Overrides the daemon.shutdown op's effect. Defaults to sending this
44
53
  * process SIGTERM, which vehicle-server's runDaemonProcess already handles
@@ -54,6 +63,8 @@ export interface BootstrappedDaemon {
54
63
  focusStore: FocusStore;
55
64
  queries: SavedQueryStore;
56
65
  stageStore: StageStore;
66
+ watches: WatchStore;
67
+ sessionIdentity: SqliteSessionIdentityStore;
57
68
  service: TicketService;
58
69
  options: StartDaemonOptions;
59
70
  }
@@ -61,15 +72,26 @@ export interface BootstrappedDaemon {
61
72
  const DEFAULT_SYNC_INTERVAL_MS = 5 * 60_000;
62
73
  const DEFAULT_CHECKPOINT_INTERVAL_MS = 10 * 60_000;
63
74
  const DEFAULT_BACKEND_REFRESH_INTERVAL_MS = 30_000;
75
+ /** Deliberately coarser than pipes' own 30s RUN_POOL_SYNC_INTERVAL_MS -- a CI run's status changes
76
+ * on the order of seconds/minutes; an issue's comments/status change on the order of minutes/hours,
77
+ * so polling that fast would only waste API quota against GitHub/GitLab/Jira's own rate limits. */
78
+ const DEFAULT_ISSUE_WATCH_INTERVAL_MS = 60_000;
79
+ const DEFAULT_QUERY_WATCH_INTERVAL_MS = 60_000;
80
+ /** Focus scopes are session-lifetime pointers, not hot state -- reaping once an hour is plenty prompt against FOCUS_STALE_AFTER_MS's own 30-day window. */
81
+ const DEFAULT_FOCUS_REAP_INTERVAL_MS = 60 * 60_000;
64
82
 
65
83
  export async function bootstrap(opts: BootstrapOptions = {}): Promise<BootstrappedDaemon> {
66
84
  const paths = resolveDaemonPaths(TICKETS_DAEMON_NAMES, opts.pathEnv);
67
85
  const token = ensureAuthToken(paths.token, "Tickets");
68
- const db = openSqliteWithPragmas(paths.database, { migrations: [...LEDGER_MIGRATIONS, ...FOCUS_MIGRATIONS, ...SAVED_QUERY_MIGRATIONS] });
86
+ const db = openSqliteWithPragmas(paths.database, {
87
+ migrations: [...LEDGER_MIGRATIONS, ...FOCUS_MIGRATIONS, ...SAVED_QUERY_MIGRATIONS, ...WATCH_MIGRATIONS, ...SESSION_IDENTITY_MIGRATIONS],
88
+ });
69
89
  const ledger = new Ledger(db);
70
90
  const focusStore = new FocusStore(db);
71
91
  const queries = new SavedQueryStore(db);
72
92
  const stageStore = new StageStore();
93
+ const watches = new WatchStore(db);
94
+ const sessionIdentity = new SqliteSessionIdentityStore(db);
73
95
  const logger = opts.logger ?? createLogger("tickets-daemon", { levelEnvVar: "TICKETS_LOG_LEVEL" });
74
96
  const config = opts.config ?? loadConfig();
75
97
  const buildRepos = opts.buildRepositories ?? buildRepositories;
@@ -88,6 +110,8 @@ export async function bootstrap(opts: BootstrapOptions = {}): Promise<Bootstrapp
88
110
  focusStore,
89
111
  queries,
90
112
  stageStore,
113
+ watches,
114
+ sessionIdentity,
91
115
  token,
92
116
  version,
93
117
  logger,
@@ -100,11 +124,21 @@ export async function bootstrap(opts: BootstrapOptions = {}): Promise<Bootstrapp
100
124
  logger,
101
125
  maintenanceTasks: [
102
126
  createSyncTask(service, ledger, opts.syncIntervalMs ?? DEFAULT_SYNC_INTERVAL_MS, logger),
127
+ createIssueWatchSyncTask(service, watches, opts.issueWatchIntervalMs ?? DEFAULT_ISSUE_WATCH_INTERVAL_MS, logger),
128
+ createQueryWatchSyncTask(service, queries, watches, opts.queryWatchIntervalMs ?? DEFAULT_QUERY_WATCH_INTERVAL_MS, logger),
103
129
  {
104
130
  name: "checkpoint",
105
131
  intervalMs: opts.checkpointIntervalMs ?? DEFAULT_CHECKPOINT_INTERVAL_MS,
106
132
  run: () => checkpoint(db),
107
133
  },
134
+ {
135
+ name: "focus-reap-stale",
136
+ intervalMs: opts.focusReapIntervalMs ?? DEFAULT_FOCUS_REAP_INTERVAL_MS,
137
+ run: () => {
138
+ const removed = focusStore.reapStale(new Date(Date.now() - FOCUS_STALE_AFTER_MS).toISOString());
139
+ if (removed > 0) logger.debug("reaped stale focus scopes", { removed });
140
+ },
141
+ },
108
142
  // Only when repos came from real config/env/Enigma resolution -- an
109
143
  // injected test fixture (opts.repos) has no config to re-resolve from.
110
144
  ...(opts.repos === undefined
@@ -127,6 +161,8 @@ export async function bootstrap(opts: BootstrapOptions = {}): Promise<Bootstrapp
127
161
  focusStore,
128
162
  queries,
129
163
  stageStore,
164
+ watches,
165
+ sessionIdentity,
130
166
  token,
131
167
  version,
132
168
  logger,
@@ -138,5 +174,5 @@ export async function bootstrap(opts: BootstrapOptions = {}): Promise<Bootstrapp
138
174
  },
139
175
  };
140
176
 
141
- return { db, ledger, focusStore, queries, stageStore, service, options };
177
+ return { db, ledger, focusStore, queries, stageStore, watches, sessionIdentity, service, options };
142
178
  }
@@ -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
+ }
@@ -2,6 +2,7 @@ import { AuthRequiredError, IssueNotFoundError } from "../issue/errors.js";
2
2
  import { NotSupportedError, UnknownBackendError } from "../issue/service.js";
3
3
  import { FocusError } from "../sqlite/focus.js";
4
4
  import { SavedQueryNotFoundError } from "../sqlite/saved-queries.js";
5
+ import { SessionAuthError } from "../sqlite/session-identity.js";
5
6
  import { StagedItemNotFoundError } from "../stage/store.js";
6
7
 
7
8
  /** Returns the legacy HTTP status only for reviewed business errors; unknown failures stay unclassified. */
@@ -10,5 +11,6 @@ export function statusForKnownTicketError(error: unknown): number | undefined {
10
11
  return 404;
11
12
  if (error instanceof UnknownBackendError || error instanceof NotSupportedError || error instanceof FocusError) return 400;
12
13
  if (error instanceof AuthRequiredError) return 422;
14
+ if (error instanceof SessionAuthError) return 401;
13
15
  return undefined;
14
16
  }
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 =
@@ -31,6 +32,8 @@ export type TicketOperation =
31
32
  | "focus.pause"
32
33
  | "focus.unpause"
33
34
  | "focus.clear"
35
+ | "session.register"
36
+ | "session.release"
34
37
  | "discover.fields"
35
38
  | "discover.statuses"
36
39
  | "discover.template"
@@ -40,6 +43,13 @@ export type TicketOperation =
40
43
  | "query.list"
41
44
  | "query.remove"
42
45
  | "query.run"
46
+ | "issue.subscribe"
47
+ | "issue.unsubscribe"
48
+ | "issue.subscribed"
49
+ | "query.subscribe"
50
+ | "query.unsubscribe"
51
+ | "query.subscribed"
52
+ | "watch.events"
43
53
  | "stage.add"
44
54
  | "stage.list"
45
55
  | "stage.show"
@@ -63,11 +73,31 @@ export interface TicketOpInputs extends Record<TicketOperation, unknown> {
63
73
  "issue.merge": { ref: string; method?: "merge" | "squash" | "rebase" };
64
74
  "ledger.search": { query: string; limit?: number; backend?: string };
65
75
  "ledger.stats": Record<string, never>;
66
- "focus.set": { ref: string };
67
- "focus.get": Record<string, never>;
68
- "focus.pause": { reason?: string };
69
- "focus.unpause": Record<string, never>;
70
- "focus.clear": Record<string, never>;
76
+ /**
77
+ * sessionId: optional explicit scope override (see sqlite/focus.ts's own normalizeFocusScope) --
78
+ * defaults to callContext?.callerSessionId, then "global", the same input-wins-over-callContext
79
+ * precedence issue.subscribe/query.subscribe already established. sessionSecret authorizes an
80
+ * EXPLICIT sessionId claim against session.register's own identity store (see
81
+ * sqlite/session-identity.ts) -- never required, and never even read, for the implicit
82
+ * callContext.callerSessionId default, since a Vehicle-projected tool call's own
83
+ * callerSessionId is host-derived, not model-settable.
84
+ */
85
+ "focus.set": { ref: string; sessionId?: string; sessionSecret?: string };
86
+ "focus.get": { sessionId?: string };
87
+ "focus.pause": { reason?: string; sessionId?: string; sessionSecret?: string };
88
+ "focus.unpause": { sessionId?: string; sessionSecret?: string };
89
+ "focus.clear": { sessionId?: string; sessionSecret?: string };
90
+ /**
91
+ * No CLI command, and excluded from Vehicle tool projection (see agent-tools/tickets-vehicle.ts's
92
+ * own OWNER/OPERATIONS list) -- a pure client<->daemon handshake pi-tickets' own extension code
93
+ * calls directly (see pi-tickets' tui.ts), never a human- or model-meaningful action the way
94
+ * every other operation here is. daemon.shutdown is the one other operation with no Pi tool for
95
+ * a similar reason, but it at least keeps a human-facing CLI command (`daemon stop`); these two
96
+ * have no comparable human verb at all.
97
+ */
98
+ "session.register": { sessionId: string };
99
+ "session.release": { sessionId: string; sessionSecret?: string };
100
+
71
101
  "discover.fields": { backend: string };
72
102
  "discover.statuses": { backend: string };
73
103
  "discover.template": { backend: string; project: string; issueType: string; sampleSize?: number };
@@ -77,6 +107,13 @@ export interface TicketOpInputs extends Record<TicketOperation, unknown> {
77
107
  "query.list": Record<string, never>;
78
108
  "query.remove": { name: string };
79
109
  "query.run": { name: string; limit?: number };
110
+ "issue.subscribe": { ref: string; subscriberId?: string; scheduleMs?: number; projectRoot?: string };
111
+ "issue.unsubscribe": { ref: string; subscriberId?: string };
112
+ "issue.subscribed": { subscriberId?: string };
113
+ "query.subscribe": { name: string; subscriberId?: string; scheduleMs?: number; projectRoot?: string };
114
+ "query.unsubscribe": { name: string; subscriberId?: string };
115
+ "query.subscribed": { subscriberId?: string };
116
+ "watch.events": { subscriberId?: string; sinceId?: number; limit?: number };
80
117
  "stage.add": { payload: StagePayload };
81
118
  "stage.list": Record<string, never>;
82
119
  "stage.show": { id: string };
@@ -109,6 +146,9 @@ export interface TicketOpOutputs extends Record<TicketOperation, unknown> {
109
146
  "focus.pause": { focus: TicketFocusState };
110
147
  "focus.unpause": { focus: TicketFocusState };
111
148
  "focus.clear": { cleared: boolean };
149
+ /** secret: shown once, plaintext, on register -- never persisted or logged by the client, mirroring vehicle-server/session-identity's own contract. */
150
+ "session.register": { sessionId: string; secret: string };
151
+ "session.release": { released: true };
112
152
  "discover.fields": { mappings: Record<string, string> };
113
153
  "discover.statuses": { mappings: Record<string, string> };
114
154
  "discover.template": { template: Template | null };
@@ -118,6 +158,13 @@ export interface TicketOpOutputs extends Record<TicketOperation, unknown> {
118
158
  "query.list": { queries: SavedQuery[] };
119
159
  "query.remove": { removed: boolean };
120
160
  "query.run": { issues: Issue[] };
161
+ "issue.subscribe": { subscribed: true };
162
+ "issue.unsubscribe": { unsubscribed: true };
163
+ "issue.subscribed": { watches: IssueWatchSubscription[] };
164
+ "query.subscribe": { subscribed: true };
165
+ "query.unsubscribe": { unsubscribed: true };
166
+ "query.subscribed": { watches: QueryWatchSubscription[] };
167
+ "watch.events": { events: WatchEvent[]; lastId: number };
121
168
  "stage.add": { item: StagedItem };
122
169
  "stage.list": { items: StagedItem[] };
123
170
  "stage.show": { item: StagedItem };
@@ -147,6 +194,8 @@ export const TICKET_OPERATIONS: TicketOperation[] = [
147
194
  "focus.pause",
148
195
  "focus.unpause",
149
196
  "focus.clear",
197
+ "session.register",
198
+ "session.release",
150
199
  "discover.fields",
151
200
  "discover.statuses",
152
201
  "discover.template",
@@ -156,6 +205,13 @@ export const TICKET_OPERATIONS: TicketOperation[] = [
156
205
  "query.list",
157
206
  "query.remove",
158
207
  "query.run",
208
+ "issue.subscribe",
209
+ "issue.unsubscribe",
210
+ "issue.subscribed",
211
+ "query.subscribe",
212
+ "query.unsubscribe",
213
+ "query.subscribed",
214
+ "watch.events",
159
215
  "stage.add",
160
216
  "stage.list",
161
217
  "stage.show",