@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 +4 -4
- package/src/agent-tools/tickets-vehicle.ts +67 -2
- package/src/cli/index.ts +53 -0
- package/src/process/bootstrap.ts +39 -3
- package/src/process/watch-sync.ts +204 -0
- package/src/rpc/error-status.ts +2 -0
- package/src/rpc/ops.ts +61 -5
- package/src/rpc/server.ts +97 -6
- package/src/sqlite/focus.ts +91 -30
- package/src/sqlite/session-identity.ts +94 -0
- package/src/sqlite/watches.ts +355 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danypops/tickets",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.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.
|
|
28
|
-
"@danypops/vehicle-server": "^0.
|
|
29
|
-
"@danypops/vehicle-client": "^0.
|
|
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>(() =>
|
|
477
|
+
withTicketsErrorParity<unknown>(() => {
|
|
478
|
+
// Threaded through unconditionally -- harmless for the vast majority of handlers that
|
|
479
|
+
// never read it; issue.subscribe/query.subscribe (and their siblings) use it to default
|
|
480
|
+
// subscriberId/projectRoot from this real call's own session identity, mirroring
|
|
481
|
+
// @danypops/pipes' own ci.subscribe. See HandlerCallContext's own doc comment.
|
|
482
|
+
const callContext: HandlerCallContext = {
|
|
483
|
+
callerSessionId: context.callerSessionId,
|
|
484
|
+
callerProjectRoot: context.callerProjectRoot,
|
|
485
|
+
};
|
|
486
|
+
return handler(deps, mapInput(context.input as Record<string, unknown>) as never, callContext);
|
|
487
|
+
}),
|
|
423
488
|
),
|
|
424
489
|
);
|
|
425
490
|
}
|
package/src/cli/index.ts
CHANGED
|
@@ -158,6 +158,37 @@ program
|
|
|
158
158
|
await withClient((client) => client.call("issue.merge", { ref, method: opts.method }));
|
|
159
159
|
});
|
|
160
160
|
|
|
161
|
+
program
|
|
162
|
+
.command("subscribe <ref>")
|
|
163
|
+
.description("watch one issue in the background -- comments, status, and other field changes are reported via `watch-events`")
|
|
164
|
+
.option("--schedule-ms <ms>", "minimum check cadence for this subscription, in milliseconds", (v) => Number.parseInt(v, 10))
|
|
165
|
+
.action(async (ref: string, opts) => {
|
|
166
|
+
await withClient((client) => client.call("issue.subscribe", { ref, scheduleMs: opts.scheduleMs }));
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
program
|
|
170
|
+
.command("unsubscribe <ref>")
|
|
171
|
+
.description("stop watching one issue")
|
|
172
|
+
.action(async (ref: string) => {
|
|
173
|
+
await withClient((client) => client.call("issue.unsubscribe", { ref }));
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
program
|
|
177
|
+
.command("subscribed")
|
|
178
|
+
.description("list every issue you're currently watching")
|
|
179
|
+
.action(async () => {
|
|
180
|
+
await withClient((client) => client.call("issue.subscribed", {}));
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
program
|
|
184
|
+
.command("watch-events")
|
|
185
|
+
.description("new change events for everything you're currently watching (issues and saved queries), since --since-id")
|
|
186
|
+
.option("--since-id <id>", "only events after this event id", (v) => Number.parseInt(v, 10))
|
|
187
|
+
.option("--limit <n>", "max events", (v) => Number.parseInt(v, 10))
|
|
188
|
+
.action(async (opts) => {
|
|
189
|
+
await withClient((client) => client.call("watch.events", { sinceId: opts.sinceId, limit: opts.limit }));
|
|
190
|
+
});
|
|
191
|
+
|
|
161
192
|
const comment = program.command("comment").description("comment operations");
|
|
162
193
|
|
|
163
194
|
comment
|
|
@@ -321,6 +352,28 @@ queryCmd
|
|
|
321
352
|
await withClient((client) => client.call("query.run", { name, limit: opts.limit }));
|
|
322
353
|
});
|
|
323
354
|
|
|
355
|
+
queryCmd
|
|
356
|
+
.command("subscribe <name>")
|
|
357
|
+
.description("watch one saved query in the background -- new/dropped items are reported via `watch-events`")
|
|
358
|
+
.option("--schedule-ms <ms>", "minimum check cadence for this subscription, in milliseconds", (v) => Number.parseInt(v, 10))
|
|
359
|
+
.action(async (name: string, opts) => {
|
|
360
|
+
await withClient((client) => client.call("query.subscribe", { name, scheduleMs: opts.scheduleMs }));
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
queryCmd
|
|
364
|
+
.command("unsubscribe <name>")
|
|
365
|
+
.description("stop watching one saved query")
|
|
366
|
+
.action(async (name: string) => {
|
|
367
|
+
await withClient((client) => client.call("query.unsubscribe", { name }));
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
queryCmd
|
|
371
|
+
.command("subscribed")
|
|
372
|
+
.description("list every saved query you're currently watching")
|
|
373
|
+
.action(async () => {
|
|
374
|
+
await withClient((client) => client.call("query.subscribed", {}));
|
|
375
|
+
});
|
|
376
|
+
|
|
324
377
|
discoverCmd
|
|
325
378
|
.command("board_quickfilter")
|
|
326
379
|
.description(
|
package/src/process/bootstrap.ts
CHANGED
|
@@ -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, {
|
|
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
|
+
}
|
package/src/rpc/error-status.ts
CHANGED
|
@@ -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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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",
|