@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,216 @@
1
+ import type {
2
+ JiraCredentials,
3
+ JiraIssue,
4
+ JiraSearchResponse,
5
+ JiraTransition,
6
+ JiraRateLimitState,
7
+ } from "../types/jira.ts";
8
+
9
+ // ── Error class ───────────────────────────────────────────────────────
10
+
11
+ export class JiraApiError extends Error {
12
+ constructor(
13
+ message: string,
14
+ public status: number,
15
+ public retryAfter?: number,
16
+ public rateLimitRemaining?: number,
17
+ ) {
18
+ super(message);
19
+ this.name = "JiraApiError";
20
+ }
21
+ }
22
+
23
+ // ── Rate limit state per config (keyed by baseUrl) ────────────────────
24
+
25
+ const rateLimitStates = new Map<string, JiraRateLimitState>();
26
+
27
+ export function getRateLimitState(baseUrl: string): JiraRateLimitState {
28
+ return rateLimitStates.get(baseUrl) ?? {
29
+ remaining: null, limit: null, resetAt: null,
30
+ backingOff: false, pausedUntil: null,
31
+ };
32
+ }
33
+
34
+ function extractRateLimits(headers: Headers, baseUrl: string): void {
35
+ const remaining = headers.get("x-ratelimit-remaining");
36
+ const limit = headers.get("x-ratelimit-limit");
37
+ const state = getRateLimitState(baseUrl);
38
+ if (remaining !== null) state.remaining = parseInt(remaining, 10);
39
+ if (limit !== null) state.limit = parseInt(limit, 10);
40
+ // Check if backing off needed
41
+ if (state.remaining !== null && state.limit !== null && state.limit > 0) {
42
+ state.backingOff = state.remaining / state.limit < 0.2;
43
+ }
44
+ rateLimitStates.set(baseUrl, state);
45
+ }
46
+
47
+ // ── Auth helper ───────────────────────────────────────────────────────
48
+
49
+ function buildAuthHeader(creds: JiraCredentials): string {
50
+ return "Basic " + Buffer.from(`${creds.email}:${creds.token}`).toString("base64");
51
+ }
52
+
53
+ // ── Core fetch wrapper ────────────────────────────────────────────────
54
+
55
+ async function jiraFetch<T>(
56
+ creds: JiraCredentials,
57
+ method: string,
58
+ path: string,
59
+ body?: unknown,
60
+ ): Promise<T> {
61
+ const url = `${creds.baseUrl.replace(/\/$/, "")}${path}`;
62
+ const controller = new AbortController();
63
+ const timeout = setTimeout(() => controller.abort(), 15_000);
64
+
65
+ try {
66
+ const res = await fetch(url, {
67
+ method,
68
+ headers: {
69
+ Authorization: buildAuthHeader(creds),
70
+ "Content-Type": "application/json",
71
+ Accept: "application/json",
72
+ },
73
+ body: body ? JSON.stringify(body) : undefined,
74
+ signal: controller.signal,
75
+ });
76
+
77
+ extractRateLimits(res.headers, creds.baseUrl);
78
+
79
+ if (res.status === 429) {
80
+ const retryAfter = parseInt(res.headers.get("retry-after") ?? "300", 10);
81
+ const state = getRateLimitState(creds.baseUrl);
82
+ state.pausedUntil = Date.now() + retryAfter * 1000;
83
+ rateLimitStates.set(creds.baseUrl, state);
84
+ throw new JiraApiError("Rate limited by Jira", 429, retryAfter);
85
+ }
86
+
87
+ if (res.status === 204) return undefined as T; // void responses (PUT update)
88
+
89
+ if (!res.ok) {
90
+ const text = await res.text().catch(() => "");
91
+ throw new JiraApiError(
92
+ `Jira API ${res.status}: ${text.slice(0, 200)}`,
93
+ res.status,
94
+ undefined,
95
+ getRateLimitState(creds.baseUrl).remaining ?? undefined,
96
+ );
97
+ }
98
+
99
+ return (await res.json()) as T;
100
+ } finally {
101
+ clearTimeout(timeout);
102
+ }
103
+ }
104
+
105
+ // ── Public API ────────────────────────────────────────────────────────
106
+
107
+ const DEFAULT_FIELDS = "summary,status,priority,assignee,description,updated,created";
108
+
109
+ export async function searchIssues(
110
+ creds: JiraCredentials,
111
+ jql: string,
112
+ fields = DEFAULT_FIELDS,
113
+ maxResults = 50,
114
+ nextPageToken?: string,
115
+ ): Promise<JiraSearchResponse> {
116
+ const body: Record<string, unknown> = {
117
+ jql, fields: fields.split(","), maxResults,
118
+ };
119
+ if (nextPageToken) body.nextPageToken = nextPageToken;
120
+ return jiraFetch<JiraSearchResponse>(creds, "POST", "/rest/api/3/search/jql", body);
121
+ }
122
+
123
+ export async function getIssue(
124
+ creds: JiraCredentials,
125
+ issueKey: string,
126
+ fields = DEFAULT_FIELDS,
127
+ ): Promise<JiraIssue> {
128
+ return jiraFetch<JiraIssue>(creds, "GET", `/rest/api/3/issue/${issueKey}?fields=${fields}`);
129
+ }
130
+
131
+ export async function updateIssue(
132
+ creds: JiraCredentials,
133
+ issueKey: string,
134
+ fields: Record<string, unknown>,
135
+ ): Promise<void> {
136
+ await jiraFetch<void>(creds, "PUT", `/rest/api/3/issue/${issueKey}`, { fields });
137
+ }
138
+
139
+ export async function getTransitions(
140
+ creds: JiraCredentials,
141
+ issueKey: string,
142
+ ): Promise<JiraTransition[]> {
143
+ const res = await jiraFetch<{ transitions: JiraTransition[] }>(
144
+ creds, "GET", `/rest/api/3/issue/${issueKey}/transitions`,
145
+ );
146
+ return res.transitions;
147
+ }
148
+
149
+ export async function transitionIssue(
150
+ creds: JiraCredentials,
151
+ issueKey: string,
152
+ transitionId: string,
153
+ ): Promise<void> {
154
+ await jiraFetch<void>(creds, "POST", `/rest/api/3/issue/${issueKey}/transitions`, {
155
+ transition: { id: transitionId },
156
+ });
157
+ }
158
+
159
+ export async function testConnection(creds: JiraCredentials): Promise<boolean> {
160
+ // Use bounded JQL — unbounded queries return 400 on /search/jql
161
+ await searchIssues(creds, "created >= -30d ORDER BY created DESC", "summary", 1);
162
+ return true;
163
+ }
164
+
165
+ /** Escape JQL special characters in user input */
166
+ function escapeJql(value: string): string {
167
+ // Remove control characters, escape JQL reserved chars
168
+ return value
169
+ .replace(/[\x00-\x1f]/g, "")
170
+ .replace(/[\\'"{}()\[\]+\-&|!~*?^]/g, "\\$&");
171
+ }
172
+
173
+ export async function searchText(
174
+ creds: JiraCredentials,
175
+ query: string,
176
+ maxResults = 20,
177
+ ): Promise<JiraSearchResponse> {
178
+ const jql = `text ~ "${escapeJql(query)}" ORDER BY updated DESC`;
179
+ return searchIssues(creds, jql, DEFAULT_FIELDS, maxResults);
180
+ }
181
+
182
+ /** Fetch Jira project list for filter builder */
183
+ export async function getProjects(
184
+ creds: JiraCredentials,
185
+ ): Promise<Array<{ key: string; name: string }>> {
186
+ return jiraFetch<Array<{ key: string; name: string }>>(
187
+ creds, "GET", "/rest/api/3/project/search?maxResults=100",
188
+ ).then((res: any) => (res.values ?? res).map((p: any) => ({ key: p.key, name: p.name })));
189
+ }
190
+
191
+ /** Fetch metadata for filter builder (issue types, priorities, statuses) */
192
+ export async function getFieldOptions(
193
+ creds: JiraCredentials,
194
+ fieldName: "issuetype" | "priority" | "status",
195
+ ): Promise<Array<{ id: string; name: string }>> {
196
+ if (fieldName === "issuetype") {
197
+ return jiraFetch<Array<{ id: string; name: string }>>(creds, "GET", "/rest/api/3/issuetype");
198
+ }
199
+ if (fieldName === "priority") {
200
+ return jiraFetch<Array<{ id: string; name: string }>>(creds, "GET", "/rest/api/3/priority");
201
+ }
202
+ // statuses
203
+ return jiraFetch<Array<any>>(creds, "GET", "/rest/api/3/status")
204
+ .then((list) => list.map((s: any) => ({ id: s.id, name: s.name })));
205
+ }
206
+
207
+ /** Fetch assignable users for filter builder */
208
+ export async function getAssignableUsers(
209
+ creds: JiraCredentials,
210
+ ): Promise<Array<{ accountId: string; displayName: string }>> {
211
+ return jiraFetch<Array<any>>(creds, "GET", "/rest/api/3/users/search?maxResults=100")
212
+ .then((list) => list
213
+ .filter((u: any) => u.accountType === "atlassian")
214
+ .map((u: any) => ({ accountId: u.accountId, displayName: u.displayName })),
215
+ );
216
+ }
@@ -0,0 +1,83 @@
1
+ import { getDb } from "./db.service.ts";
2
+ import { encrypt, decrypt } from "../lib/account-crypto.ts";
3
+ import type { JiraConfigRow, JiraConfig, JiraCredentials } from "../types/jira.ts";
4
+
5
+ // ── Row → API mapper ──────────────────────────────────────────────────
6
+
7
+ function rowToConfig(row: JiraConfigRow): JiraConfig {
8
+ return {
9
+ id: row.id,
10
+ projectId: row.project_id,
11
+ baseUrl: row.base_url,
12
+ email: row.email,
13
+ hasToken: !!row.api_token_encrypted,
14
+ createdAt: row.created_at,
15
+ };
16
+ }
17
+
18
+ // ── Public API ────────────────────────────────────────────────────────
19
+
20
+ export function getConfigByProjectId(projectId: number): JiraConfig | null {
21
+ const row = getDb()
22
+ .query("SELECT * FROM jira_config WHERE project_id = ?")
23
+ .get(projectId) as JiraConfigRow | null;
24
+ return row ? rowToConfig(row) : null;
25
+ }
26
+
27
+ export function getConfigById(id: number): JiraConfigRow | null {
28
+ return getDb()
29
+ .query("SELECT * FROM jira_config WHERE id = ?")
30
+ .get(id) as JiraConfigRow | null;
31
+ }
32
+
33
+ export function getAllConfigs(): JiraConfig[] {
34
+ const rows = getDb()
35
+ .query("SELECT * FROM jira_config ORDER BY id")
36
+ .all() as JiraConfigRow[];
37
+ return rows.map(rowToConfig);
38
+ }
39
+
40
+ export function upsertConfig(
41
+ projectId: number,
42
+ baseUrl: string,
43
+ email: string,
44
+ plainToken?: string,
45
+ ): JiraConfig {
46
+ if (plainToken) {
47
+ // Full upsert with new token
48
+ const encrypted = encrypt(plainToken);
49
+ getDb().query(`
50
+ INSERT INTO jira_config (project_id, base_url, email, api_token_encrypted)
51
+ VALUES (?, ?, ?, ?)
52
+ ON CONFLICT(project_id) DO UPDATE SET
53
+ base_url = excluded.base_url,
54
+ email = excluded.email,
55
+ api_token_encrypted = excluded.api_token_encrypted
56
+ `).run(projectId, baseUrl, email, encrypted);
57
+ } else {
58
+ // Update URL/email only, preserve existing token
59
+ getDb().query(`
60
+ UPDATE jira_config SET base_url = ?, email = ? WHERE project_id = ?
61
+ `).run(baseUrl, email, projectId);
62
+ }
63
+ return getConfigByProjectId(projectId)!;
64
+ }
65
+
66
+ export function deleteConfig(projectId: number): boolean {
67
+ const result = getDb()
68
+ .query("DELETE FROM jira_config WHERE project_id = ?")
69
+ .run(projectId);
70
+ return result.changes > 0;
71
+ }
72
+
73
+ export function getDecryptedCredentials(configId: number): JiraCredentials | null {
74
+ const row = getConfigById(configId);
75
+ if (!row) return null;
76
+ try {
77
+ const token = decrypt(row.api_token_encrypted);
78
+ return { baseUrl: row.base_url, email: row.email, token };
79
+ } catch (e) {
80
+ console.warn(`[jira] Failed to decrypt token for config ${configId}:`, (e as Error).message);
81
+ return null;
82
+ }
83
+ }
@@ -0,0 +1,240 @@
1
+ import { chatService } from "./chat.service.ts";
2
+ import { getDb } from "./db.service.ts";
3
+ import { updateResultStatus, getResultById } from "./jira-watcher-db.service.ts";
4
+ import { notificationService } from "./notification.service.ts";
5
+ import { forwardEventToSession } from "../server/ws/chat.ts";
6
+ import type { JiraWatcherRow } from "../types/jira.ts";
7
+ import type { PermissionMode } from "../types/config.ts";
8
+
9
+ const MAX_CONCURRENT = 2;
10
+ const MAX_PER_PROJECT = 1;
11
+
12
+ interface QueueItem {
13
+ resultId: number;
14
+ prompt?: string;
15
+ resume?: boolean; // reuse existing session instead of creating new
16
+ }
17
+
18
+ class JiraDebugSessionService {
19
+ private queue: QueueItem[] = [];
20
+ private active = new Map<number, AbortController>(); // resultId → abort
21
+ private activeByProject = new Map<string, number>(); // projectPath → count
22
+ private enqueuedIds = new Set<number>(); // guard against double-enqueue
23
+
24
+ /** Reset zombie results from previous server run (running/queued with no active process) */
25
+ init(): void {
26
+ const zombies = getDb().query(
27
+ `UPDATE jira_watch_results SET status = 'failed', ai_summary = 'Server restarted during debug'
28
+ WHERE status IN ('running', 'queued') AND deleted = 0 RETURNING id, issue_key`,
29
+ ).all() as { id: number; issue_key: string }[];
30
+ if (zombies.length > 0) {
31
+ console.log(`[jira-debug] Reset ${zombies.length} zombie results: ${zombies.map((z) => z.issue_key).join(", ")}`);
32
+ }
33
+ }
34
+
35
+ /** Enqueue a result for debug. Accepts "pending" or "failed" (retry). */
36
+ enqueue(resultId: number, promptOverride?: string): void {
37
+ if (this.enqueuedIds.has(resultId)) return; // prevent double-enqueue
38
+ const result = getResultById(resultId);
39
+ if (!result) throw new Error("Result not found");
40
+ if (result.status !== "pending" && result.status !== "failed") {
41
+ throw new Error(`Result status is "${result.status}", expected "pending" or "failed"`);
42
+ }
43
+
44
+ this.enqueuedIds.add(resultId);
45
+ updateResultStatus(resultId, "queued");
46
+ this.broadcastStatusChange(resultId, result.issueKey, "queued");
47
+ this.queue.push({ resultId, prompt: promptOverride });
48
+ this.processQueue();
49
+ }
50
+
51
+ /** Resume a failed session that already has a sessionId */
52
+ resumeDebug(resultId: number, prompt?: string): void {
53
+ if (this.enqueuedIds.has(resultId)) return;
54
+ const result = getResultById(resultId);
55
+ if (!result) throw new Error("Result not found");
56
+ if (result.status !== "failed") throw new Error("Only failed results can be resumed");
57
+ if (!result.sessionId) throw new Error("No session to resume — use debug instead");
58
+
59
+ this.enqueuedIds.add(resultId);
60
+ updateResultStatus(resultId, "queued");
61
+ this.broadcastStatusChange(resultId, result.issueKey, "queued");
62
+ this.queue.push({
63
+ resultId,
64
+ prompt: prompt ?? "Continue debugging. The previous session was interrupted before completing.",
65
+ resume: true,
66
+ });
67
+ this.processQueue();
68
+ }
69
+
70
+ /** Cancel a running or queued debug session */
71
+ cancelDebug(resultId: number): boolean {
72
+ // Remove from queue if still queued
73
+ const qIdx = this.queue.findIndex((q) => q.resultId === resultId);
74
+ if (qIdx >= 0) {
75
+ this.queue.splice(qIdx, 1);
76
+ this.enqueuedIds.delete(resultId);
77
+ const result = getResultById(resultId);
78
+ updateResultStatus(resultId, "failed", { aiSummary: "Cancelled by user" });
79
+ if (result) this.broadcastStatusChange(resultId, result.issueKey, "failed");
80
+ return true;
81
+ }
82
+ // Abort if actively running
83
+ const abort = this.active.get(resultId);
84
+ if (!abort) return false;
85
+ abort.abort();
86
+ const result = getResultById(resultId);
87
+ updateResultStatus(resultId, "failed", { aiSummary: "Cancelled by user" });
88
+ if (result) this.broadcastStatusChange(resultId, result.issueKey, "failed");
89
+ return true;
90
+ }
91
+
92
+ // [H2 fix] Iterate through queue to avoid head-of-line blocking
93
+ private processQueue(): void {
94
+ let i = 0;
95
+ while (i < this.queue.length && this.active.size < MAX_CONCURRENT) {
96
+ const item = this.queue[i]!;
97
+ const project = this.resolveProjectInfo(item.resultId);
98
+ if (!project) { this.queue.splice(i, 1); continue; }
99
+
100
+ const projectCount = this.activeByProject.get(project.path) ?? 0;
101
+ if (projectCount >= MAX_PER_PROJECT) { i++; continue; }
102
+
103
+ this.queue.splice(i, 1);
104
+ this.runDebugSession(item.resultId, item.prompt, project, item.resume).catch((e) => {
105
+ console.error(`[jira-debug] session error resultId=${item.resultId}:`, e.message);
106
+ });
107
+ }
108
+ }
109
+
110
+ // [H3 fix] Single method for project lookup — used by both processQueue and runDebugSession
111
+ private resolveProjectInfo(resultId: number): { path: string; name: string } | null {
112
+ return getDb().query(`
113
+ SELECT p.path, p.name FROM jira_watch_results r
114
+ JOIN jira_watchers w ON w.id = r.watcher_id
115
+ JOIN jira_config c ON c.id = w.jira_config_id
116
+ JOIN projects p ON p.id = c.project_id
117
+ WHERE r.id = ?
118
+ `).get(resultId) as { path: string; name: string } | null;
119
+ }
120
+
121
+ private resolveWatcherForResult(resultId: number): JiraWatcherRow | null {
122
+ return getDb().query(`
123
+ SELECT w.* FROM jira_watch_results r
124
+ JOIN jira_watchers w ON w.id = r.watcher_id
125
+ WHERE r.id = ?
126
+ `).get(resultId) as JiraWatcherRow | null;
127
+ }
128
+
129
+ private async runDebugSession(
130
+ resultId: number, promptOverride: string | undefined,
131
+ project: { path: string; name: string },
132
+ resume?: boolean,
133
+ ): Promise<void> {
134
+ const result = getResultById(resultId);
135
+ if (!result) return;
136
+
137
+ // Build prompt
138
+ let prompt: string;
139
+ if (promptOverride) {
140
+ prompt = promptOverride;
141
+ } else {
142
+ const watcher = this.resolveWatcherForResult(resultId);
143
+ if (watcher?.prompt_template) {
144
+ prompt = watcher.prompt_template
145
+ .replace(/\{issue_key\}/g, result.issueKey)
146
+ .replace(/\{summary\}/g, result.issueSummary ?? "")
147
+ .replace(/\{description\}/g, "(fetched from Jira)")
148
+ .replace(/\{status\}/g, "")
149
+ .replace(/\{priority\}/g, "");
150
+ } else {
151
+ prompt = `Debug Jira issue ${result.issueKey}: ${result.issueSummary ?? "No summary"}`;
152
+ }
153
+ }
154
+
155
+ // Track concurrency
156
+ const abort = new AbortController();
157
+ this.active.set(resultId, abort);
158
+ this.activeByProject.set(project.path, (this.activeByProject.get(project.path) ?? 0) + 1);
159
+
160
+ updateResultStatus(resultId, "running");
161
+ this.broadcastStatusChange(resultId, result.issueKey, "running");
162
+
163
+ try {
164
+ // Resume: reuse existing session (SDK can resume from disk even after restart)
165
+ let session: { id: string; providerId: string };
166
+ if (resume && result.sessionId) {
167
+ // SDK provider's sendMessage auto-resumes sessions from disk via resumeSession()
168
+ // We just need the sessionId and the correct providerId
169
+ const existing = chatService.getSession(result.sessionId);
170
+ session = existing
171
+ ? { id: existing.id, providerId: existing.providerId }
172
+ : { id: result.sessionId, providerId: "claude" }; // default provider
173
+ } else {
174
+ session = await chatService.createSession(undefined, {
175
+ projectPath: project.path,
176
+ projectName: project.name,
177
+ title: `[Jira Debug] ${result.issueKey}: ${(result.issueSummary ?? "").slice(0, 50)}`,
178
+ });
179
+ }
180
+
181
+ // Persist sessionId immediately so UI can show "Open" button while running
182
+ updateResultStatus(resultId, "running", { sessionId: session.id });
183
+ this.broadcastStatusChange(resultId, result.issueKey, "running", session.id);
184
+
185
+ // bypassPermissions: automated debug sessions run without user approval (same as PPMBot)
186
+ const opts = { permissionMode: "bypassPermissions" as PermissionMode };
187
+ const events = chatService.sendMessage(session.providerId, session.id, prompt, opts);
188
+
189
+ let lastAssistantText = "";
190
+ for await (const event of events) {
191
+ if (abort.signal.aborted) break;
192
+ if (event.type === "text") lastAssistantText = event.content;
193
+ // Forward events to any connected WS client viewing this session
194
+ forwardEventToSession(session.id, event);
195
+ }
196
+
197
+ if (abort.signal.aborted) return;
198
+
199
+ const aiSummary = lastAssistantText.slice(0, 500) || "Debug session completed (no text output)";
200
+ updateResultStatus(resultId, "done", { sessionId: session.id, aiSummary });
201
+
202
+ // Broadcast WS event + notification
203
+ this.broadcastStatusChange(resultId, result.issueKey, "done", session.id);
204
+
205
+ notificationService.broadcast("done", {
206
+ title: `Jira: ${result.issueKey}`,
207
+ body: aiSummary.slice(0, 200),
208
+ project: project.name,
209
+ sessionId: session.id,
210
+ }).catch(() => {});
211
+ } catch (e: any) {
212
+ if (!abort.signal.aborted) {
213
+ updateResultStatus(resultId, "failed", { aiSummary: e.message?.slice(0, 300) ?? "Unknown error" });
214
+ this.broadcastStatusChange(resultId, result.issueKey, "failed");
215
+ }
216
+ } finally {
217
+ this.cleanup(resultId, project.path);
218
+ this.processQueue();
219
+ }
220
+ }
221
+
222
+ private broadcastStatusChange(resultId: number, issueKey: string, status: string, sessionId?: string): void {
223
+ notificationService.broadcastWs({
224
+ type: "jira:status_change",
225
+ resultId, issueKey, status, sessionId,
226
+ }).catch(() => {});
227
+ }
228
+
229
+ // [C1 fix] Idempotent cleanup — safe to call multiple times
230
+ private cleanup(resultId: number, projectPath: string): void {
231
+ if (!this.active.has(resultId)) return; // already cleaned up
232
+ this.active.delete(resultId);
233
+ this.enqueuedIds.delete(resultId);
234
+ const count = (this.activeByProject.get(projectPath) ?? 1) - 1;
235
+ if (count <= 0) this.activeByProject.delete(projectPath);
236
+ else this.activeByProject.set(projectPath, count);
237
+ }
238
+ }
239
+
240
+ export const jiraDebugService = new JiraDebugSessionService();