@hienlh/ppm 0.10.4 → 0.11.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.
Files changed (126) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/web/assets/ai-settings-section-D2vqiydT.js +1 -0
  3. package/dist/web/assets/{api-settings-CoKe_BdR.js → api-settings-2eTz4SgY.js} +1 -1
  4. package/dist/web/assets/architecture-PBZL5I3N-BRW4VwMk.js +1 -0
  5. package/dist/web/assets/chat-tab-CbguR_l0.js +10 -0
  6. package/dist/web/assets/code-editor-DbZP0Dnj.js +8 -0
  7. package/dist/web/assets/{conflict-editor-HvxI1A29.js → conflict-editor-BzrH1UpC.js} +3 -3
  8. package/dist/web/assets/{csv-preview-BizIVMyb.js → csv-preview-D37K2LRd.js} +1 -1
  9. package/dist/web/assets/{database-viewer-BgCXPc4e.js → database-viewer-CqMOv2Sg.js} +2 -2
  10. package/dist/web/assets/{diff-viewer-blzXAJHd.js → diff-viewer-B6a2oYYn.js} +1 -1
  11. package/dist/web/assets/{esm-K1XIK4vc.js → esm-B99v94EE.js} +1 -1
  12. package/dist/web/assets/{extension-store-3yZYn07W.js → extension-store-CkyOvGbF.js} +1 -1
  13. package/dist/web/assets/extension-webview-CZr_fvOm.js +3 -0
  14. package/dist/web/assets/gitGraph-HDMCJU4V-Bt68dqWT.js +1 -0
  15. package/dist/web/assets/index-C68PuiOm.js +26 -0
  16. package/dist/web/assets/index-iZHWllzQ.css +2 -0
  17. package/dist/web/assets/info-3K5VOQVL-ySD5z855.js +1 -0
  18. package/dist/web/assets/{keybindings-store-D2N-Tq4N.js → keybindings-store-CpP5_miA.js} +1 -1
  19. package/dist/web/assets/keybindings-store-qfYScgY0.js +1 -0
  20. package/dist/web/assets/{markdown-renderer-Hcj-59AX.js → markdown-renderer-BhNYbXCp.js} +3 -3
  21. package/dist/web/assets/packet-RMMSAZCW-CLxaXgIf.js +1 -0
  22. package/dist/web/assets/pie-UPGHQEXC-C9wPZfkn.js +1 -0
  23. package/dist/web/assets/port-forwarding-tab-Dw9MUu5a.js +1 -0
  24. package/dist/web/assets/{postgres-viewer-BEUI1N1X.js → postgres-viewer-YKyNjTLp.js} +3 -3
  25. package/dist/web/assets/{project-store-Ciq-cK1O.js → project-store-CczGNZyf.js} +1 -1
  26. package/dist/web/assets/radar-KQ55EAFF-DxEpzVN_.js +1 -0
  27. package/dist/web/assets/settings-store-CuYjM0FF.js +2 -0
  28. package/dist/web/assets/settings-tab-2tdZuQIn.js +1 -0
  29. package/dist/web/assets/{sql-query-editor-DZ9xskL8.js → sql-query-editor-CVEi0jLM.js} +1 -1
  30. package/dist/web/assets/{sqlite-viewer-sQs615K6.js → sqlite-viewer-Fx9qDD4-.js} +1 -1
  31. package/dist/web/assets/{tab-store-DZbiYk7y.js → tab-store-Jvy1eZGM.js} +1 -1
  32. package/dist/web/assets/terminal-tab-BxljmYb7.js +1 -0
  33. package/dist/web/assets/treemap-KZPCXAKY-yelcZZqO.js +1 -0
  34. package/dist/web/assets/{use-monaco-theme-OY18iXNi.js → use-monaco-theme-kjiAwvOp.js} +1 -1
  35. package/dist/web/assets/{vendor-mermaid-B2SLgECS.js → vendor-mermaid-CylkVm4U.js} +3 -3
  36. package/dist/web/index.html +13 -13
  37. package/dist/web/sw.js +1 -1
  38. package/docs/codebase-summary.md +29 -5
  39. package/docs/project-changelog.md +31 -1
  40. package/docs/system-architecture.md +106 -1
  41. package/package.json +1 -1
  42. package/packages/ext-git-graph/src/extension.ts +11 -4
  43. package/packages/ext-git-graph/src/webview-html.ts +25 -11
  44. package/src/cli/commands/jira-cmd.ts +92 -0
  45. package/src/cli/commands/jira-watcher-cmd.ts +149 -0
  46. package/src/index.ts +3 -0
  47. package/src/server/index.ts +19 -0
  48. package/src/server/routes/files.ts +15 -0
  49. package/src/server/routes/fs-browse.ts +40 -1
  50. package/src/server/routes/jira-config-routes.ts +74 -0
  51. package/src/server/routes/jira-watcher-routes.ts +316 -0
  52. package/src/server/routes/jira.ts +7 -0
  53. package/src/server/ws/chat.ts +21 -0
  54. package/src/services/db.service.ts +65 -1
  55. package/src/services/extension-host-worker.ts +3 -2
  56. package/src/services/extension.service.ts +4 -2
  57. package/src/services/file.service.ts +42 -0
  58. package/src/services/jira-api-client.ts +216 -0
  59. package/src/services/jira-config.service.ts +83 -0
  60. package/src/services/jira-debug-session.service.ts +240 -0
  61. package/src/services/jira-watcher-db.service.ts +195 -0
  62. package/src/services/jira-watcher.service.ts +159 -0
  63. package/src/services/notification.service.ts +6 -0
  64. package/src/services/supervisor-state.ts +13 -1
  65. package/src/services/supervisor.ts +4 -3
  66. package/src/types/jira.ts +128 -0
  67. package/src/web/app.tsx +15 -12
  68. package/src/web/components/chat/chat-tab.tsx +32 -1
  69. package/src/web/components/chat/message-input.tsx +56 -5
  70. package/src/web/components/explorer/file-tree.tsx +9 -0
  71. package/src/web/components/extensions/extension-webview.tsx +31 -13
  72. package/src/web/components/jira/jira-config-form.tsx +109 -0
  73. package/src/web/components/jira/jira-debug-prompt-dialog.tsx +58 -0
  74. package/src/web/components/jira/jira-filter-builder.tsx +197 -0
  75. package/src/web/components/jira/jira-panel.tsx +201 -0
  76. package/src/web/components/jira/jira-results-panel.tsx +184 -0
  77. package/src/web/components/jira/jira-settings-section.tsx +58 -0
  78. package/src/web/components/jira/jira-status-badge.tsx +18 -0
  79. package/src/web/components/jira/jira-ticket-card.tsx +144 -0
  80. package/src/web/components/jira/jira-ticket-detail.tsx +153 -0
  81. package/src/web/components/jira/jira-watcher-form.tsx +154 -0
  82. package/src/web/components/jira/jira-watcher-list.tsx +98 -0
  83. package/src/web/components/layout/mobile-drawer.tsx +18 -5
  84. package/src/web/components/layout/sidebar.tsx +20 -3
  85. package/src/web/components/settings/settings-tab.tsx +20 -3
  86. package/src/web/components/shared/markdown-code-block.tsx +5 -3
  87. package/src/web/components/ui/file-browser-picker.tsx +88 -1
  88. package/src/web/hooks/use-chat.ts +6 -0
  89. package/src/web/lib/report-bug.ts +3 -2
  90. package/src/web/lib/ws-client.ts +14 -6
  91. package/src/web/stores/jira-store.ts +198 -0
  92. package/src/web/stores/settings-store.ts +24 -5
  93. package/src/web/styles/globals.css +7 -0
  94. package/vite.config.ts +5 -66
  95. package/bun.lock +0 -2062
  96. package/bunfig.toml +0 -2
  97. package/dist/web/assets/ai-settings-section-LMO_cfIW.js +0 -1
  98. package/dist/web/assets/architecture-PBZL5I3N-CUZIB1Vq.js +0 -1
  99. package/dist/web/assets/chat-tab-By7krQ3s.js +0 -10
  100. package/dist/web/assets/code-editor-BoKL57Co.js +0 -8
  101. package/dist/web/assets/extension-webview-Dvk_61ON.js +0 -3
  102. package/dist/web/assets/gitGraph-HDMCJU4V-CtOMUphQ.js +0 -1
  103. package/dist/web/assets/index-DPnjO2FY.css +0 -2
  104. package/dist/web/assets/index-EgCQVN13.js +0 -26
  105. package/dist/web/assets/info-3K5VOQVL-BCrPCWGY.js +0 -1
  106. package/dist/web/assets/keybindings-store-C7No6mtl.js +0 -1
  107. package/dist/web/assets/packet-RMMSAZCW-D_OqB-zi.js +0 -1
  108. package/dist/web/assets/pie-UPGHQEXC-WUHpLNJz.js +0 -1
  109. package/dist/web/assets/port-forwarding-tab-CUgwDn_5.js +0 -1
  110. package/dist/web/assets/radar-KQ55EAFF-HQIIecVM.js +0 -1
  111. package/dist/web/assets/settings-store-B470PCWf.js +0 -2
  112. package/dist/web/assets/settings-tab-BGvgK51L.js +0 -1
  113. package/dist/web/assets/square-nsMa3iMk.js +0 -1
  114. package/dist/web/assets/terminal-tab-CUyHmiHH.js +0 -1
  115. package/dist/web/assets/treemap-KZPCXAKY-0wLgUUTz.js +0 -1
  116. /package/dist/web/assets/{api-client-o_6TmLGC.js → api-client-C3tXCh0r.js} +0 -0
  117. /package/dist/web/assets/{csv-parser--2WJNgS7.js → csv-parser-BAa56Nnn.js} +0 -0
  118. /package/dist/web/assets/{dist-im4ynINo.js → dist-On3hz9_g.js} +0 -0
  119. /package/dist/web/assets/{katex-CKoArbIw.js → katex-Bbu770d9.js} +0 -0
  120. /package/dist/web/assets/{lib-D_kRA9p6.js → lib-BqkcKGFq.js} +0 -0
  121. /package/dist/web/assets/{react-GqWghJ-L.js → react-BkWDCPD7.js} +0 -0
  122. /package/dist/web/assets/{sql-completion-provider-C3cq9j99.js → sql-completion-provider-D3acAhav.js} +0 -0
  123. /package/dist/web/assets/{table-Dq575bPF.js → table-DbSviOmw.js} +0 -0
  124. /package/dist/web/assets/{text-wrap-Cn6BNQfq.js → text-wrap-DzvCTq_i.js} +0 -0
  125. /package/dist/web/assets/{trash-2-CJYoLw7Q.js → trash-2-BgDIBl6f.js} +0 -0
  126. /package/dist/web/assets/{vendor-xterm-ejLe7-tK.js → vendor-xterm-B9BUAFKA.js} +0 -0
@@ -0,0 +1,195 @@
1
+ import { getDb } from "./db.service.ts";
2
+ import type {
3
+ JiraWatcherRow, JiraWatchResultRow,
4
+ JiraWatcher, JiraWatchResult, JiraWatcherMode, JiraResultStatus,
5
+ } from "../types/jira.ts";
6
+
7
+ // ── Row → API mappers ─────────────────────────────────────────────────
8
+
9
+ export function rowToWatcher(row: JiraWatcherRow): JiraWatcher {
10
+ return {
11
+ id: row.id,
12
+ jiraConfigId: row.jira_config_id,
13
+ name: row.name,
14
+ jql: row.jql,
15
+ promptTemplate: row.prompt_template,
16
+ enabled: row.enabled === 1,
17
+ mode: row.mode as JiraWatcherMode,
18
+ intervalMs: row.interval_ms,
19
+ lastPolledAt: row.last_polled_at,
20
+ createdAt: row.created_at,
21
+ };
22
+ }
23
+
24
+ export function rowToResult(row: JiraWatchResultRow): JiraWatchResult {
25
+ return {
26
+ id: row.id,
27
+ watcherId: row.watcher_id,
28
+ issueKey: row.issue_key,
29
+ issueSummary: row.issue_summary,
30
+ issueUpdated: row.issue_updated,
31
+ sessionId: row.session_id,
32
+ status: row.status,
33
+ aiSummary: row.ai_summary,
34
+ source: row.source,
35
+ readAt: row.read_at,
36
+ triggeredBy: row.triggered_by,
37
+ createdAt: row.created_at,
38
+ };
39
+ }
40
+
41
+ // ── Watcher CRUD ──────────────────────────────────────────────────────
42
+
43
+ export function createWatcher(
44
+ configId: number, name: string, jql: string,
45
+ opts?: { promptTemplate?: string; intervalMs?: number; mode?: JiraWatcherMode },
46
+ ): JiraWatcher {
47
+ const result = getDb().query(`
48
+ INSERT INTO jira_watchers (jira_config_id, name, jql, prompt_template, interval_ms, mode)
49
+ VALUES (?, ?, ?, ?, ?, ?) RETURNING *
50
+ `).get(
51
+ configId, name, jql,
52
+ opts?.promptTemplate ?? null,
53
+ opts?.intervalMs ?? 120000,
54
+ opts?.mode ?? "debug",
55
+ ) as JiraWatcherRow;
56
+ return rowToWatcher(result);
57
+ }
58
+
59
+ export function updateWatcher(
60
+ id: number,
61
+ fields: Partial<{ name: string; jql: string; promptTemplate: string | null; intervalMs: number; enabled: boolean; mode: JiraWatcherMode }>,
62
+ ): JiraWatcher | null {
63
+ const sets: string[] = [];
64
+ const params: (string | number | null)[] = [];
65
+ if (fields.name !== undefined) { sets.push("name = ?"); params.push(fields.name); }
66
+ if (fields.jql !== undefined) { sets.push("jql = ?"); params.push(fields.jql); }
67
+ if (fields.promptTemplate !== undefined) { sets.push("prompt_template = ?"); params.push(fields.promptTemplate); }
68
+ if (fields.intervalMs !== undefined) { sets.push("interval_ms = ?"); params.push(fields.intervalMs); }
69
+ if (fields.enabled !== undefined) { sets.push("enabled = ?"); params.push(fields.enabled ? 1 : 0); }
70
+ if (fields.mode !== undefined) { sets.push("mode = ?"); params.push(fields.mode); }
71
+ if (sets.length === 0) return getWatcherById(id);
72
+ params.push(id);
73
+ getDb().query(`UPDATE jira_watchers SET ${sets.join(", ")} WHERE id = ?`).run(...params);
74
+ return getWatcherById(id);
75
+ }
76
+
77
+ export function deleteWatcher(id: number): boolean {
78
+ return getDb().query("DELETE FROM jira_watchers WHERE id = ?").run(id).changes > 0;
79
+ }
80
+
81
+ export function getWatcherById(id: number): JiraWatcher | null {
82
+ const row = getDb().query("SELECT * FROM jira_watchers WHERE id = ?").get(id) as JiraWatcherRow | null;
83
+ return row ? rowToWatcher(row) : null;
84
+ }
85
+
86
+ export function getWatchersByConfigId(configId: number): JiraWatcher[] {
87
+ const rows = getDb().query("SELECT * FROM jira_watchers WHERE jira_config_id = ? ORDER BY id")
88
+ .all(configId) as JiraWatcherRow[];
89
+ return rows.map(rowToWatcher);
90
+ }
91
+
92
+ export function getAllEnabledWatchers(): JiraWatcherRow[] {
93
+ return getDb().query("SELECT * FROM jira_watchers WHERE enabled = 1")
94
+ .all() as JiraWatcherRow[];
95
+ }
96
+
97
+ // ── Result CRUD ───────────────────────────────────────────────────────
98
+
99
+ /** Insert a result. Resurrects soft-deleted duplicates. Returns true if row was inserted or resurrected. */
100
+ export function insertResult(
101
+ watcherId: number | null, issueKey: string,
102
+ issueSummary: string | null, issueUpdated: string | null,
103
+ source: "watcher" | "manual" = "watcher",
104
+ triggeredBy: "auto" | "manual" = "auto",
105
+ ): { inserted: boolean; resultId: number | null } {
106
+ try {
107
+ const row = getDb().query(`
108
+ INSERT INTO jira_watch_results (watcher_id, issue_key, issue_summary, issue_updated, source, triggered_by)
109
+ VALUES (?, ?, ?, ?, ?, ?) RETURNING id
110
+ `).get(watcherId, issueKey, issueSummary, issueUpdated, source, triggeredBy) as { id: number } | null;
111
+ return { inserted: true, resultId: row?.id ?? null };
112
+ } catch (e: any) {
113
+ if (e.message?.includes("UNIQUE constraint")) {
114
+ // Resurrect soft-deleted row if it exists
115
+ const resurrected = getDb().query(`
116
+ UPDATE jira_watch_results
117
+ SET deleted = 0, status = 'pending', session_id = NULL, ai_summary = NULL,
118
+ read_at = NULL, triggered_by = ?, source = ?, created_at = datetime('now')
119
+ WHERE watcher_id IS ? AND issue_key = ? AND issue_updated IS ? AND deleted = 1
120
+ RETURNING id
121
+ `).get(triggeredBy, source, watcherId, issueKey, issueUpdated) as { id: number } | null;
122
+ if (resurrected) return { inserted: true, resultId: resurrected.id };
123
+ return { inserted: false, resultId: null };
124
+ }
125
+ throw e;
126
+ }
127
+ }
128
+
129
+ export function updateResultStatus(
130
+ resultId: number,
131
+ status: JiraResultStatus,
132
+ updates?: { sessionId?: string; aiSummary?: string },
133
+ ): void {
134
+ const sets = ["status = ?"];
135
+ const params: (string | number)[] = [status];
136
+ if (updates?.sessionId !== undefined) { sets.push("session_id = ?"); params.push(updates.sessionId); }
137
+ if (updates?.aiSummary !== undefined) { sets.push("ai_summary = ?"); params.push(updates.aiSummary); }
138
+ params.push(resultId);
139
+ getDb().query(`UPDATE jira_watch_results SET ${sets.join(", ")} WHERE id = ?`).run(...params);
140
+ }
141
+
142
+ export function getResultsByWatcherId(
143
+ watcherId?: number,
144
+ opts?: { status?: string; limit?: number; offset?: number; includeDeleted?: boolean },
145
+ ): JiraWatchResult[] {
146
+ const clauses: string[] = [];
147
+ const params: (string | number)[] = [];
148
+ if (watcherId !== undefined) { clauses.push("watcher_id = ?"); params.push(watcherId); }
149
+ if (opts?.status) { clauses.push("status = ?"); params.push(opts.status); }
150
+ if (!opts?.includeDeleted) clauses.push("deleted = 0");
151
+ const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
152
+ const limit = opts?.limit ?? 50;
153
+ const offset = opts?.offset ?? 0;
154
+ const rows = getDb()
155
+ .query(`SELECT * FROM jira_watch_results ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`)
156
+ .all(...params, limit, offset) as JiraWatchResultRow[];
157
+ return rows.map(rowToResult);
158
+ }
159
+
160
+ export function getResultById(id: number): JiraWatchResult | null {
161
+ const row = getDb().query("SELECT * FROM jira_watch_results WHERE id = ?")
162
+ .get(id) as JiraWatchResultRow | null;
163
+ return row ? rowToResult(row) : null;
164
+ }
165
+
166
+ export function softDeleteResult(id: number): boolean {
167
+ return getDb().query("UPDATE jira_watch_results SET deleted = 1 WHERE id = ?").run(id).changes > 0;
168
+ }
169
+
170
+ export function getRunningResults(): JiraWatchResultRow[] {
171
+ return getDb().query("SELECT * FROM jira_watch_results WHERE status = 'running'")
172
+ .all() as JiraWatchResultRow[];
173
+ }
174
+
175
+ export function getWatcherStats(): Record<JiraResultStatus, number> {
176
+ const rows = getDb().query(
177
+ "SELECT status, COUNT(*) as count FROM jira_watch_results WHERE deleted = 0 GROUP BY status",
178
+ ).all() as Array<{ status: JiraResultStatus; count: number }>;
179
+ const stats: Record<string, number> = { pending: 0, queued: 0, running: 0, done: 0, failed: 0 };
180
+ for (const r of rows) stats[r.status] = r.count;
181
+ return stats as Record<JiraResultStatus, number>;
182
+ }
183
+
184
+ export function markResultRead(resultId: number): boolean {
185
+ return getDb().query(
186
+ "UPDATE jira_watch_results SET read_at = datetime('now') WHERE id = ? AND read_at IS NULL",
187
+ ).run(resultId).changes > 0;
188
+ }
189
+
190
+ export function getUnreadCount(): number {
191
+ const row = getDb().query(
192
+ "SELECT COUNT(*) as count FROM jira_watch_results WHERE status = 'done' AND read_at IS NULL AND deleted = 0",
193
+ ).get() as { count: number };
194
+ return row.count;
195
+ }
@@ -0,0 +1,159 @@
1
+ import { searchIssues, JiraApiError, getRateLimitState } from "./jira-api-client.ts";
2
+ import { getDecryptedCredentials } from "./jira-config.service.ts";
3
+ import { getAllEnabledWatchers, insertResult } from "./jira-watcher-db.service.ts";
4
+ import { jiraDebugService } from "./jira-debug-session.service.ts";
5
+ import { getDb } from "./db.service.ts";
6
+ import { notificationService } from "./notification.service.ts";
7
+ import type { JiraWatcherRow, JiraIssue } from "../types/jira.ts";
8
+
9
+ const INTERVAL_MIN = 30_000; // 30s
10
+ const INTERVAL_MAX = 3_600_000; // 60m
11
+ const RATE_LIMIT_PAUSE_MS = 300_000; // 5min
12
+
13
+ export function clampInterval(ms: number): number {
14
+ return Math.max(INTERVAL_MIN, Math.min(INTERVAL_MAX, ms));
15
+ }
16
+
17
+ class JiraWatcherService {
18
+ private activeTimers = new Map<number, Timer>();
19
+
20
+ async startAll(): Promise<void> {
21
+ const watchers = getAllEnabledWatchers();
22
+ for (const w of watchers) this.startWatcher(w.id, w.interval_ms);
23
+ if (watchers.length) console.log(`[jira] Started ${watchers.length} watcher(s)`);
24
+ }
25
+
26
+ stopAll(): void {
27
+ for (const [id, timer] of this.activeTimers) {
28
+ clearInterval(timer);
29
+ this.activeTimers.delete(id);
30
+ }
31
+ }
32
+
33
+ startWatcher(id: number, intervalMs: number): void {
34
+ if (this.activeTimers.has(id)) this.stopWatcher(id);
35
+ const interval = clampInterval(intervalMs);
36
+ const timer = setInterval(() => this.pollWatcher(id).catch((e) =>
37
+ console.warn(`[jira] Poll error watcher ${id}:`, e.message),
38
+ ), interval);
39
+ this.activeTimers.set(id, timer);
40
+ }
41
+
42
+ stopWatcher(id: number): void {
43
+ const timer = this.activeTimers.get(id);
44
+ if (timer) { clearInterval(timer); this.activeTimers.delete(id); }
45
+ }
46
+
47
+ isRunning(id: number): boolean {
48
+ return this.activeTimers.has(id);
49
+ }
50
+
51
+ async pollWatcher(watcherId: number, source: "auto" | "manual" = "auto"): Promise<number> {
52
+ const watcher = getDb()
53
+ .query("SELECT * FROM jira_watchers WHERE id = ?")
54
+ .get(watcherId) as JiraWatcherRow | null;
55
+ if (!watcher) return 0;
56
+
57
+ const creds = getDecryptedCredentials(watcher.jira_config_id);
58
+ if (!creds) { console.warn(`[jira] No credentials for config ${watcher.jira_config_id}`); return 0; }
59
+
60
+ // Check rate limit pause
61
+ const rlState = getRateLimitState(creds.baseUrl);
62
+ if (rlState.pausedUntil && Date.now() < rlState.pausedUntil) return 0;
63
+
64
+ const isFirstPoll = !watcher.last_polled_at;
65
+
66
+ try {
67
+ // First auto-poll = baseline only: just set last_polled_at, skip inserts
68
+ // Manual pull always fetches everything
69
+ if (isFirstPoll && source === "auto") {
70
+ await searchIssues(creds, watcher.jql); // validate JQL works
71
+ getDb().query("UPDATE jira_watchers SET last_polled_at = datetime('now') WHERE id = ?").run(watcherId);
72
+ console.log(`[jira] Watcher "${watcher.name}": baseline poll done (skipped inserts)`);
73
+ return 0;
74
+ }
75
+
76
+ const response = await searchIssues(creds, watcher.jql);
77
+ let newCount = 0;
78
+ const newResultIds: number[] = [];
79
+
80
+ for (const issue of response.issues) {
81
+ let inserted: boolean, resultId: number | null;
82
+ try {
83
+ ({ inserted, resultId } = insertResult(
84
+ watcher.id, issue.key,
85
+ issue.fields.summary, issue.fields.updated,
86
+ "watcher", source,
87
+ ));
88
+ } catch (e: any) {
89
+ console.error(`[jira] insertResult FK error for watcher ${watcher.id}, issue ${issue.key}:`, e.message);
90
+ throw e;
91
+ }
92
+ if (inserted && resultId) {
93
+ newCount++;
94
+ newResultIds.push(resultId);
95
+
96
+ if (watcher.mode === "notify") {
97
+ notificationService.broadcast("done", {
98
+ title: `Jira: ${issue.key}`,
99
+ body: issue.fields.summary,
100
+ project: "", sessionId: "",
101
+ }).catch(() => {});
102
+ }
103
+ }
104
+ }
105
+
106
+ // Auto-enqueue debug for new issues (≤5 to avoid flooding)
107
+ if (watcher.mode === "debug" && source === "auto" && newResultIds.length > 0 && newResultIds.length <= 5) {
108
+ for (const rid of newResultIds) {
109
+ try { jiraDebugService.enqueue(rid); } catch (e: any) {
110
+ console.warn(`[jira] enqueue debug error resultId=${rid}:`, e.message);
111
+ }
112
+ }
113
+ }
114
+
115
+ // Update last_polled_at
116
+ getDb().query("UPDATE jira_watchers SET last_polled_at = datetime('now') WHERE id = ?").run(watcherId);
117
+ if (newCount) console.log(`[jira] Watcher "${watcher.name}": ${newCount} new issue(s)`);
118
+ return newCount;
119
+ } catch (e) {
120
+ if (e instanceof JiraApiError && e.status === 429) {
121
+ console.warn(`[jira] Rate limited — pausing watchers for config ${watcher.jira_config_id}`);
122
+ this.pauseConfigWatchers(watcher.jira_config_id);
123
+ }
124
+ throw e;
125
+ }
126
+ }
127
+
128
+ // ── Internal helpers ──────────────────────────────────────────────
129
+
130
+ buildPrompt(watcher: JiraWatcherRow, issue: JiraIssue): string {
131
+ if (watcher.prompt_template) {
132
+ return watcher.prompt_template
133
+ .replace(/\{issue_key\}/g, issue.key)
134
+ .replace(/\{summary\}/g, issue.fields.summary)
135
+ .replace(/\{description\}/g, issue.fields.description ?? "(no description)")
136
+ .replace(/\{status\}/g, issue.fields.status.name)
137
+ .replace(/\{priority\}/g, issue.fields.priority?.name ?? "None");
138
+ }
139
+ return `Debug Jira issue ${issue.key}: ${issue.fields.summary}\n\nDescription:\n${issue.fields.description ?? "(no description)"}`;
140
+ }
141
+
142
+ private pauseConfigWatchers(configId: number): void {
143
+ const watchers = getDb()
144
+ .query("SELECT id FROM jira_watchers WHERE jira_config_id = ? AND enabled = 1")
145
+ .all(configId) as Array<{ id: number }>;
146
+ for (const w of watchers) this.stopWatcher(w.id);
147
+ // Re-start after pause
148
+ setTimeout(() => {
149
+ for (const w of watchers) {
150
+ const row = getDb().query("SELECT * FROM jira_watchers WHERE id = ? AND enabled = 1")
151
+ .get(w.id) as JiraWatcherRow | null;
152
+ if (row) this.startWatcher(row.id, row.interval_ms);
153
+ }
154
+ }, RATE_LIMIT_PAUSE_MS);
155
+ }
156
+
157
+ }
158
+
159
+ export const jiraWatcherService = new JiraWatcherService();
@@ -13,6 +13,12 @@ export interface NotificationPayload {
13
13
  }
14
14
 
15
15
  class NotificationService {
16
+ /** Broadcast event to all connected WebSocket clients */
17
+ async broadcastWs(event: unknown): Promise<void> {
18
+ const { broadcastGlobalEvent } = await import("../server/ws/chat.ts");
19
+ broadcastGlobalEvent(event);
20
+ }
21
+
16
22
  /** Broadcast notification to all channels (push, telegram). Fire-and-forget. */
17
23
  async broadcast(_type: NotificationType, payload: NotificationPayload): Promise<void> {
18
24
  const tasks: Promise<void>[] = [];
@@ -55,7 +55,19 @@ export function updateStatus(patch: Record<string, unknown>) {
55
55
  try {
56
56
  const data = { ...readStatus(), ...patch };
57
57
  atomicWriteJson(STATUS_FILE(), data);
58
- } catch {}
58
+ } catch (e) {
59
+ // Log to stderr so failures are visible in ppm.log
60
+ try { process.stderr.write(`[updateStatus] Failed to write status.json: ${e}\n`); } catch {}
61
+ }
62
+ }
63
+
64
+ /** Full write — replaces entire status.json (use at supervisor startup to clear stale data) */
65
+ export function writeStatus(data: Record<string, unknown>) {
66
+ try {
67
+ atomicWriteJson(STATUS_FILE(), data);
68
+ } catch (e) {
69
+ try { process.stderr.write(`[writeStatus] Failed to write status.json: ${e}\n`); } catch {}
70
+ }
59
71
  }
60
72
 
61
73
  // ─── Command file protocol ─────────────────────────────────────────────
@@ -15,7 +15,7 @@ import { isCompiledBinary } from "./autostart-generator.ts";
15
15
  import {
16
16
  type SupervisorState,
17
17
  getState, setState, waitForResume, triggerResume,
18
- readAndDeleteCmd, readStatus, updateStatus,
18
+ readAndDeleteCmd, readStatus, updateStatus, writeStatus,
19
19
  STATUS_FILE, PID_FILE,
20
20
  } from "./supervisor-state.ts";
21
21
  import { startStoppedPage, stopStoppedPage } from "./supervisor-stopped-page.ts";
@@ -767,11 +767,12 @@ export async function runSupervisor(opts: {
767
767
  log("ERROR", `Unhandled rejection: ${reason}`);
768
768
  });
769
769
 
770
- // Write supervisor PID + clear stale availableVersion from previous run
770
+ // Full write to clear any stale data from previous runs (different port, dead PIDs, etc.)
771
771
  writeFileSync(PID_FILE(), String(process.pid));
772
- updateStatus({
772
+ writeStatus({
773
773
  supervisorPid: process.pid, port: opts.port, host: opts.host, availableVersion: null,
774
774
  state: "running", pausedAt: null, pauseReason: null, lastCrashError: null,
775
+ pid: null, tunnelPid: null, shareUrl: null,
775
776
  });
776
777
 
777
778
  // Build __serve__ args
@@ -0,0 +1,128 @@
1
+ // ── DB row types (snake_case, matches SQLite columns) ─────────────────
2
+
3
+ export interface JiraConfigRow {
4
+ id: number;
5
+ project_id: number;
6
+ base_url: string;
7
+ email: string;
8
+ api_token_encrypted: string;
9
+ created_at: string;
10
+ }
11
+
12
+ export interface JiraWatcherRow {
13
+ id: number;
14
+ jira_config_id: number;
15
+ name: string;
16
+ jql: string;
17
+ prompt_template: string | null;
18
+ enabled: number; // 0 | 1
19
+ mode: string; // "debug" | "notify"
20
+ interval_ms: number;
21
+ last_polled_at: string | null;
22
+ created_at: string;
23
+ }
24
+
25
+ export interface JiraWatchResultRow {
26
+ id: number;
27
+ watcher_id: number | null;
28
+ issue_key: string;
29
+ issue_summary: string | null;
30
+ issue_updated: string | null;
31
+ session_id: string | null;
32
+ status: JiraResultStatus;
33
+ ai_summary: string | null;
34
+ source: string; // "watcher" | "manual"
35
+ deleted: number; // 0 | 1
36
+ read_at: string | null;
37
+ triggered_by: string; // "auto" | "manual"
38
+ created_at: string;
39
+ }
40
+
41
+ export type JiraResultStatus = "pending" | "queued" | "running" | "done" | "failed";
42
+ export type JiraWatcherMode = "debug" | "notify";
43
+
44
+ // ── API response types (camelCase for frontend) ───────────────────────
45
+
46
+ export interface JiraConfig {
47
+ id: number;
48
+ projectId: number;
49
+ baseUrl: string;
50
+ email: string;
51
+ hasToken: boolean; // never expose actual token
52
+ createdAt: string;
53
+ }
54
+
55
+ export interface JiraWatcher {
56
+ id: number;
57
+ jiraConfigId: number;
58
+ name: string;
59
+ jql: string;
60
+ promptTemplate: string | null;
61
+ enabled: boolean;
62
+ mode: JiraWatcherMode;
63
+ intervalMs: number;
64
+ lastPolledAt: string | null;
65
+ createdAt: string;
66
+ }
67
+
68
+ export interface JiraWatchResult {
69
+ id: number;
70
+ watcherId: number | null;
71
+ issueKey: string;
72
+ issueSummary: string | null;
73
+ issueUpdated: string | null;
74
+ sessionId: string | null;
75
+ status: JiraResultStatus;
76
+ aiSummary: string | null;
77
+ source: string;
78
+ readAt: string | null;
79
+ triggeredBy: string;
80
+ createdAt: string;
81
+ }
82
+
83
+ // ── Jira Cloud API response shapes (subset we use) ────────────────────
84
+
85
+ export interface JiraIssue {
86
+ key: string;
87
+ id: string;
88
+ fields: {
89
+ summary: string;
90
+ description: string | null;
91
+ status: { name: string; id: string };
92
+ priority: { name: string; id: string } | null;
93
+ assignee: { accountId: string; displayName: string; emailAddress?: string } | null;
94
+ updated: string;
95
+ created: string;
96
+ };
97
+ }
98
+
99
+ export interface JiraSearchResponse {
100
+ issues: JiraIssue[];
101
+ total: number;
102
+ maxResults: number;
103
+ nextPageToken?: string;
104
+ }
105
+
106
+ export interface JiraTransition {
107
+ id: string;
108
+ name: string;
109
+ to: { name: string };
110
+ }
111
+
112
+ // ── Credentials (internal, decrypted for API calls) ───────────────────
113
+
114
+ export interface JiraCredentials {
115
+ baseUrl: string;
116
+ email: string;
117
+ token: string; // plaintext (decrypted)
118
+ }
119
+
120
+ // ── Rate limit tracking ───────────────────────────────────────────────
121
+
122
+ export interface JiraRateLimitState {
123
+ remaining: number | null;
124
+ limit: number | null;
125
+ resetAt: string | null;
126
+ backingOff: boolean;
127
+ pausedUntil: number | null; // epoch ms
128
+ }
package/src/web/app.tsx CHANGED
@@ -178,19 +178,22 @@ export function App() {
178
178
 
179
179
  useProjectStore.getState().setActiveProject(target);
180
180
 
181
+ // Switch panel layout to target project BEFORE opening URL tabs.
182
+ // Without this, autoOpenFromUrl creates tabs in the __global__ layout
183
+ // which get lost when the switchProject effect fires after render.
184
+ useTabStore.getState().switchProject(target.name);
185
+
181
186
  // Auto-open target tab from URL (type-based)
182
- queueMicrotask(() => {
183
- if (urlState.tabType) {
184
- autoOpenFromUrl(urlState.tabType, urlState.tabIdentifier, target!.name);
185
- }
186
- // Legacy: ?openChat= query param
187
- if (urlState.openChat) {
188
- autoOpenFromUrl("chat", urlState.openChat, target!.name);
189
- const url = new URL(window.location.href);
190
- url.searchParams.delete("openChat");
191
- window.history.replaceState(null, "", url.pathname);
192
- }
193
- });
187
+ if (urlState.tabType) {
188
+ autoOpenFromUrl(urlState.tabType, urlState.tabIdentifier, target!.name);
189
+ }
190
+ // Legacy: ?openChat= query param
191
+ if (urlState.openChat) {
192
+ autoOpenFromUrl("chat", urlState.openChat, target!.name);
193
+ const url = new URL(window.location.href);
194
+ url.searchParams.delete("openChat");
195
+ window.history.replaceState(null, "", url.pathname);
196
+ }
194
197
  });
195
198
  }, [authState, fetchProjects]);
196
199
 
@@ -57,6 +57,8 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
57
57
  // Drag-and-drop state
58
58
  const [isDragging, setIsDragging] = useState(false);
59
59
  const [externalFiles, setExternalFiles] = useState<File[] | null>(null);
60
+ const [externalPaths, setExternalPaths] = useState<string[] | null>(null);
61
+ const [disambiguateItems, setDisambiguateItems] = useState<FileNode[] | null>(null);
60
62
  const dragCounterRef = useRef(0);
61
63
 
62
64
  // Use tab's own project, not global activeProject (keep-alive: hidden tabs must not react to switches)
@@ -285,6 +287,16 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
285
287
  setSlashFilter("");
286
288
  }, []);
287
289
 
290
+ // --- Disambiguation picker handler (OS drag resolve with multiple matches) ---
291
+ const handleDisambiguate = useCallback((matches: FileNode[]) => {
292
+ setDisambiguateItems(matches);
293
+ }, []);
294
+
295
+ const handleDisambiguateSelect = useCallback((item: FileNode) => {
296
+ setExternalPaths([item.path]);
297
+ setDisambiguateItems(null);
298
+ }, []);
299
+
288
300
  // --- File picker handlers ---
289
301
  const handleFileStateChange = useCallback((visible: boolean, filter: string) => {
290
302
  setShowFilePicker(visible);
@@ -307,7 +319,7 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
307
319
  const handleDragEnter = useCallback((e: DragEvent) => {
308
320
  e.preventDefault();
309
321
  dragCounterRef.current++;
310
- if (e.dataTransfer.types.includes("Files")) {
322
+ if (e.dataTransfer.types.includes("application/x-ppm-path") || e.dataTransfer.types.includes("Files")) {
311
323
  setIsDragging(true);
312
324
  }
313
325
  }, []);
@@ -329,6 +341,13 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
329
341
  dragCounterRef.current = 0;
330
342
  setIsDragging(false);
331
343
 
344
+ // Check for internal file tree drag (custom MIME) first
345
+ const ppmPath = e.dataTransfer.getData("application/x-ppm-path");
346
+ if (ppmPath) {
347
+ setExternalPaths([ppmPath]);
348
+ return;
349
+ }
350
+
332
351
  const files = Array.from(e.dataTransfer.files);
333
352
  if (files.length > 0) {
334
353
  setExternalFiles(files);
@@ -422,6 +441,15 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
422
441
  onClose={handleFileClose}
423
442
  visible={showFilePicker}
424
443
  />
444
+ {disambiguateItems && (
445
+ <FilePicker
446
+ items={disambiguateItems}
447
+ filter=""
448
+ onSelect={handleDisambiguateSelect}
449
+ onClose={() => setDisambiguateItems(null)}
450
+ visible={true}
451
+ />
452
+ )}
425
453
 
426
454
  {/* Input */}
427
455
  <MessageInput
@@ -438,6 +466,9 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
438
466
  onFileItemsLoaded={setFileItems}
439
467
  fileSelected={fileSelected}
440
468
  externalFiles={externalFiles}
469
+ externalPaths={externalPaths}
470
+ onExternalPathsConsumed={() => setExternalPaths(null)}
471
+ onDisambiguate={handleDisambiguate}
441
472
  permissionMode={permissionMode}
442
473
  onModeChange={setPermissionMode}
443
474
  providerId={providerId}