@aion0/forge 0.6.1 → 0.8.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 (145) hide show
  1. package/.forge/mcp.json +8 -0
  2. package/.forge/worktrees/pipeline-0a33c50d/lib/help-docs/01-settings.md +5 -5
  3. package/.forge/worktrees/pipeline-0a33c50d/lib/help-docs/07-projects.md +1 -1
  4. package/.forge/worktrees/pipeline-2ba01c10/lib/help-docs/01-settings.md +5 -5
  5. package/.forge/worktrees/pipeline-2ba01c10/lib/help-docs/07-projects.md +1 -1
  6. package/.forge/worktrees/pipeline-3156a8b3/lib/help-docs/01-settings.md +5 -5
  7. package/.forge/worktrees/pipeline-3156a8b3/lib/help-docs/07-projects.md +1 -1
  8. package/.forge/worktrees/pipeline-316c6574/lib/help-docs/01-settings.md +5 -5
  9. package/.forge/worktrees/pipeline-316c6574/lib/help-docs/07-projects.md +1 -1
  10. package/.forge/worktrees/pipeline-44a94121/lib/help-docs/01-settings.md +5 -5
  11. package/.forge/worktrees/pipeline-44a94121/lib/help-docs/07-projects.md +1 -1
  12. package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/01-settings.md +5 -5
  13. package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/07-projects.md +1 -1
  14. package/.forge/worktrees/pipeline-d1757a50/lib/help-docs/01-settings.md +5 -5
  15. package/.forge/worktrees/pipeline-d1757a50/lib/help-docs/07-projects.md +1 -1
  16. package/.forge/worktrees/pipeline-d59c2fe2/lib/help-docs/01-settings.md +5 -5
  17. package/.forge/worktrees/pipeline-d59c2fe2/lib/help-docs/07-projects.md +1 -1
  18. package/.forge/worktrees/pipeline-d6a6ef23/lib/help-docs/01-settings.md +5 -5
  19. package/.forge/worktrees/pipeline-d6a6ef23/lib/help-docs/07-projects.md +1 -1
  20. package/.forge/worktrees/pipeline-e7f78b7a/lib/help-docs/01-settings.md +5 -5
  21. package/.forge/worktrees/pipeline-e7f78b7a/lib/help-docs/07-projects.md +1 -1
  22. package/.forge/worktrees/pipeline-e97c13c7/lib/help-docs/01-settings.md +5 -5
  23. package/.forge/worktrees/pipeline-e97c13c7/lib/help-docs/07-projects.md +1 -1
  24. package/.forge/worktrees/pipeline-ecd7cb0f/lib/help-docs/01-settings.md +5 -5
  25. package/.forge/worktrees/pipeline-ecd7cb0f/lib/help-docs/07-projects.md +1 -1
  26. package/CLAUDE.md +2 -2
  27. package/RELEASE_NOTES.md +101 -5
  28. package/app/api/auth/check/route.ts +18 -0
  29. package/app/api/browser-bridge/route.ts +70 -0
  30. package/app/api/chat/sessions/[id]/events/route.ts +17 -0
  31. package/app/api/chat/sessions/[id]/fork/route.ts +15 -0
  32. package/app/api/chat/sessions/[id]/messages/route.ts +21 -0
  33. package/app/api/chat/sessions/[id]/route.ts +23 -0
  34. package/app/api/chat/sessions/route.ts +12 -0
  35. package/app/api/chat/temper-ping/route.ts +18 -0
  36. package/app/api/chat-proxy/[...path]/route.ts +83 -0
  37. package/app/api/connector-tool/route.ts +38 -0
  38. package/app/api/connectors/[id]/settings/route.ts +112 -0
  39. package/app/api/connectors/route.ts +108 -0
  40. package/app/api/health/tools/route.ts +14 -0
  41. package/app/api/issue-scanner-gitlab/route.ts +95 -0
  42. package/app/api/jobs/[id]/reset_dedup/route.ts +15 -0
  43. package/app/api/jobs/[id]/route.ts +31 -0
  44. package/app/api/jobs/[id]/run/route.ts +44 -0
  45. package/app/api/jobs/[id]/runs/[runId]/route.ts +15 -0
  46. package/app/api/jobs/[id]/runs/route.ts +15 -0
  47. package/app/api/jobs/preview/route.ts +193 -0
  48. package/app/api/jobs/route.ts +36 -0
  49. package/app/api/notify/test/route.ts +39 -7
  50. package/app/api/pipelines/[id]/route.ts +10 -1
  51. package/app/api/pipelines/route.ts +16 -2
  52. package/app/api/plugins/route.ts +40 -8
  53. package/app/api/project-sessions/route.ts +50 -10
  54. package/app/api/settings/route.ts +13 -0
  55. package/app/chat/page.tsx +531 -0
  56. package/bin/forge-server.mjs +3 -1
  57. package/cli/chat.ts +283 -0
  58. package/cli/jobs.ts +176 -0
  59. package/cli/mw.ts +28 -1
  60. package/cli/worktree.ts +245 -0
  61. package/components/ConnectorsPanel.tsx +275 -0
  62. package/components/Dashboard.tsx +90 -37
  63. package/components/JobsView.tsx +361 -0
  64. package/components/LogViewer.tsx +12 -2
  65. package/components/PipelineView.tsx +275 -56
  66. package/components/PluginsPanel.tsx +3 -1
  67. package/components/SettingsModal.tsx +229 -40
  68. package/components/SkillsPanel.tsx +12 -4
  69. package/components/TerminalLauncher.tsx +3 -1
  70. package/components/WebTerminal.tsx +32 -9
  71. package/components/WorkspaceView.tsx +18 -10
  72. package/docs/Connector-DeclarativeExtract-Handoff.md +471 -0
  73. package/docs/Connector-DeclarativeExtract-Spec.md +364 -0
  74. package/docs/Implementation-Plan-Browser-Agent.md +487 -0
  75. package/docs/Jobs-Design.md +240 -0
  76. package/docs/LOCAL-DEPLOY.md +3 -3
  77. package/docs/RFC-Browser-Connectors.md +509 -0
  78. package/lib/agents/index.ts +44 -6
  79. package/lib/agents/types.ts +1 -1
  80. package/lib/browser-bridge-standalone.ts +317 -0
  81. package/lib/builtin-plugins/github-api.yaml +93 -0
  82. package/lib/builtin-plugins/gitlab.yaml +860 -0
  83. package/lib/builtin-plugins/mantis.probe.js +176 -0
  84. package/lib/builtin-plugins/mantis.yaml +964 -0
  85. package/lib/builtin-plugins/pmdb.yaml +178 -0
  86. package/lib/builtin-plugins/teams.yaml +913 -0
  87. package/lib/chat/__test__/smoke.ts +30 -0
  88. package/lib/chat/agent-loop.ts +523 -0
  89. package/lib/chat/bridge-client.ts +59 -0
  90. package/lib/chat/llm/anthropic.ts +99 -0
  91. package/lib/chat/llm/index.ts +20 -0
  92. package/lib/chat/llm/openai.ts +215 -0
  93. package/lib/chat/llm/types.ts +42 -0
  94. package/lib/chat/local-memory.ts +300 -0
  95. package/lib/chat/memory-store.ts +87 -0
  96. package/lib/chat/memory-tools.ts +157 -0
  97. package/lib/chat/protocols/http.ts +118 -0
  98. package/lib/chat/protocols/shell.ts +101 -0
  99. package/lib/chat/proxy.ts +51 -0
  100. package/lib/chat/session-store.ts +272 -0
  101. package/lib/chat/telegram-bridge.ts +276 -0
  102. package/lib/chat/temper.ts +281 -0
  103. package/lib/chat/tool-dispatcher.ts +190 -0
  104. package/lib/chat/types.ts +50 -0
  105. package/lib/chat-standalone.ts +286 -0
  106. package/lib/crypto.ts +1 -1
  107. package/lib/health.ts +131 -0
  108. package/lib/help-docs/00-overview.md +2 -1
  109. package/lib/help-docs/01-settings.md +46 -25
  110. package/lib/help-docs/07-projects.md +1 -1
  111. package/lib/help-docs/10-troubleshooting.md +10 -2
  112. package/lib/help-docs/16-gitlab-autofix.md +114 -0
  113. package/lib/help-docs/17-connectors.md +322 -0
  114. package/lib/help-docs/18-chrome-mcp.md +134 -0
  115. package/lib/help-docs/19-jobs.md +140 -0
  116. package/lib/help-docs/20-mantis-bug-fix.md +115 -0
  117. package/lib/help-docs/CLAUDE.md +10 -0
  118. package/lib/init.ts +137 -50
  119. package/lib/iso-time.ts +30 -0
  120. package/lib/issue-scanner-gitlab.ts +281 -0
  121. package/lib/jobs/dispatcher.ts +217 -0
  122. package/lib/jobs/scheduler.ts +334 -0
  123. package/lib/jobs/store.ts +319 -0
  124. package/lib/jobs/types.ts +117 -0
  125. package/lib/pipeline-scheduler.ts +1 -6
  126. package/lib/pipeline.ts +790 -10
  127. package/lib/plugins/registry.ts +133 -8
  128. package/lib/plugins/templates.ts +83 -0
  129. package/lib/plugins/types.ts +140 -1
  130. package/lib/session-watcher.ts +36 -10
  131. package/lib/settings.ts +65 -33
  132. package/lib/skills.ts +3 -1
  133. package/lib/task-manager.ts +50 -22
  134. package/lib/telegram-bot.ts +71 -0
  135. package/lib/terminal-standalone.ts +58 -36
  136. package/lib/workspace/orchestrator.ts +1 -0
  137. package/middleware.ts +10 -0
  138. package/package.json +3 -2
  139. package/scripts/bench/README.md +1 -1
  140. package/scripts/bench/tasks/01-text-utils/validator.sh +1 -1
  141. package/scripts/bench/tasks/02-pagination/setup.sh +1 -1
  142. package/scripts/bench/tasks/02-pagination/validator.sh +1 -1
  143. package/scripts/bench/tasks/03-bug-fix/setup.sh +1 -1
  144. package/scripts/bench/tasks/03-bug-fix/validator.sh +1 -1
  145. package/src/core/db/database.ts +21 -12
@@ -0,0 +1,281 @@
1
+ /**
2
+ * GitLab Issue Scanner — sibling of issue-scanner.ts for GitHub.
3
+ *
4
+ * Per-project config (table: issue_autofix_gitlab_config):
5
+ * - enabled: boolean
6
+ * - interval: minutes (0 = manual only)
7
+ * - labels: string[] filter by label (empty = all)
8
+ * - baseBranch: '' default '' (auto-resolve from milestone/label/desc/default)
9
+ * - baseBranchRule: 'milestone' | 'label:<prefix>' | 'desc' | 'default'
10
+ * - mrTitleTemplate: string
11
+ * - mrBodyTemplate: string
12
+ *
13
+ * Triggers the `gitlab-issue-fix-and-review` builtin pipeline.
14
+ */
15
+
16
+ import { execSync } from 'node:child_process';
17
+ import { getDb } from '@/src/core/db/database';
18
+ import { getDbPath } from '@/src/config';
19
+ import { startPipeline } from './pipeline';
20
+
21
+ function db() { return getDb(getDbPath()); }
22
+
23
+ export interface GitLabAutofixConfig {
24
+ projectPath: string;
25
+ projectName: string;
26
+ enabled: boolean;
27
+ interval: number;
28
+ labels: string[];
29
+ baseBranch: string;
30
+ baseBranchRule: string; // 'milestone' | 'label:<prefix>' | 'desc' | 'default'
31
+ mrTitleTemplate: string;
32
+ mrBodyTemplate: string;
33
+ assigneeFilter: string; // 'me' | <username> | '' (no filter)
34
+ }
35
+
36
+ // ── DB schema ────────────────────────────────────────────
37
+
38
+ export function ensureTable() {
39
+ db().exec(`
40
+ CREATE TABLE IF NOT EXISTS issue_autofix_gitlab_config (
41
+ project_path TEXT PRIMARY KEY,
42
+ project_name TEXT NOT NULL,
43
+ enabled INTEGER NOT NULL DEFAULT 0,
44
+ interval_min INTEGER NOT NULL DEFAULT 30,
45
+ labels TEXT NOT NULL DEFAULT '[]',
46
+ base_branch TEXT NOT NULL DEFAULT '',
47
+ base_branch_rule TEXT NOT NULL DEFAULT 'milestone',
48
+ mr_title_template TEXT NOT NULL DEFAULT '',
49
+ mr_body_template TEXT NOT NULL DEFAULT '',
50
+ assignee_filter TEXT NOT NULL DEFAULT 'me',
51
+ last_scan_at TEXT
52
+ );
53
+ CREATE TABLE IF NOT EXISTS issue_autofix_gitlab_processed (
54
+ project_path TEXT NOT NULL,
55
+ issue_iid INTEGER NOT NULL,
56
+ pipeline_id TEXT,
57
+ mr_iid INTEGER,
58
+ status TEXT NOT NULL DEFAULT 'processing',
59
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
60
+ PRIMARY KEY (project_path, issue_iid)
61
+ );
62
+ `);
63
+ }
64
+
65
+ // ── CRUD ────────────────────────────────────────────────
66
+
67
+ export function getConfig(projectPath: string): GitLabAutofixConfig | null {
68
+ ensureTable();
69
+ const row = db().prepare('SELECT * FROM issue_autofix_gitlab_config WHERE project_path = ?').get(projectPath) as any;
70
+ if (!row) return null;
71
+ return {
72
+ projectPath: row.project_path,
73
+ projectName: row.project_name,
74
+ enabled: !!row.enabled,
75
+ interval: row.interval_min,
76
+ labels: JSON.parse(row.labels || '[]'),
77
+ baseBranch: row.base_branch || '',
78
+ baseBranchRule: row.base_branch_rule || 'milestone',
79
+ mrTitleTemplate: row.mr_title_template || '',
80
+ mrBodyTemplate: row.mr_body_template || '',
81
+ assigneeFilter: row.assignee_filter || 'me',
82
+ };
83
+ }
84
+
85
+ export function saveConfig(c: GitLabAutofixConfig): void {
86
+ ensureTable();
87
+ db().prepare(`
88
+ INSERT OR REPLACE INTO issue_autofix_gitlab_config
89
+ (project_path, project_name, enabled, interval_min, labels, base_branch, base_branch_rule, mr_title_template, mr_body_template, assignee_filter)
90
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
91
+ `).run(
92
+ c.projectPath, c.projectName, c.enabled ? 1 : 0, c.interval,
93
+ JSON.stringify(c.labels || []), c.baseBranch || '', c.baseBranchRule || 'milestone',
94
+ c.mrTitleTemplate || '', c.mrBodyTemplate || '', c.assigneeFilter || 'me',
95
+ );
96
+ refreshTimer(c);
97
+ }
98
+
99
+ export function listConfigs(): GitLabAutofixConfig[] {
100
+ ensureTable();
101
+ return (db().prepare('SELECT * FROM issue_autofix_gitlab_config').all() as any[]).map(r => ({
102
+ projectPath: r.project_path, projectName: r.project_name,
103
+ enabled: !!r.enabled, interval: r.interval_min,
104
+ labels: JSON.parse(r.labels || '[]'),
105
+ baseBranch: r.base_branch || '', baseBranchRule: r.base_branch_rule || 'milestone',
106
+ mrTitleTemplate: r.mr_title_template || '', mrBodyTemplate: r.mr_body_template || '',
107
+ assigneeFilter: r.assignee_filter || 'me',
108
+ }));
109
+ }
110
+
111
+ // ── GitLab remote parsing ──────────────────────────────
112
+
113
+ function gitRemote(projectPath: string): string | null {
114
+ try {
115
+ return execSync(`git -C "${projectPath}" remote get-url origin`, { encoding: 'utf8', timeout: 3000 }).trim();
116
+ } catch { return null; }
117
+ }
118
+
119
+ /** Resolve <namespace>/<project> path from origin URL. Returns null if not a GitLab remote we can talk to. */
120
+ function projectPathFromRemote(remote: string): string | null {
121
+ // git@host:group/sub/project.git OR https://host/group/sub/project(.git)
122
+ const cleaned = remote
123
+ .replace(/^https?:\/\/[^/]+\//, '')
124
+ .replace(/^git@[^:]+:/, '')
125
+ .replace(/\.git$/, '');
126
+ if (!cleaned.includes('/')) return null;
127
+ return cleaned;
128
+ }
129
+
130
+ // ── Fetch issues via glab ──────────────────────────────
131
+
132
+ function fetchOpenIssues(projectPath: string, c: GitLabAutofixConfig): { iid: number; title: string; error?: string }[] {
133
+ const remote = gitRemote(projectPath);
134
+ if (!remote) return [{ iid: -1, title: '', error: 'No git remote configured' }];
135
+ const projPath = projectPathFromRemote(remote);
136
+ if (!projPath) return [{ iid: -1, title: '', error: `Unrecognized git remote: ${remote}` }];
137
+
138
+ const labelFilter = c.labels.length > 0 ? ` --label "${c.labels.join(',')}"` : '';
139
+ const assigneeFilter = c.assigneeFilter === 'me' ? ' --assignee="@me"'
140
+ : c.assigneeFilter ? ` --assignee="${c.assigneeFilter}"` : '';
141
+
142
+ try {
143
+ const out = execSync(
144
+ `glab issue list --state opened -R "${projPath}"${labelFilter}${assigneeFilter} --output json`,
145
+ { encoding: 'utf8', timeout: 30000, cwd: projectPath },
146
+ );
147
+ const parsed = JSON.parse(out);
148
+ return (Array.isArray(parsed) ? parsed : []).map((it: any) => ({
149
+ iid: Number(it.iid ?? it.id ?? -1),
150
+ title: it.title || '',
151
+ }));
152
+ } catch (e: any) {
153
+ const msg = e.stderr?.toString() || e.message || 'glab CLI failed';
154
+ if (/not authenticated|auth/i.test(msg)) {
155
+ return [{ iid: -1, title: '', error: 'glab CLI not authenticated. Run: glab auth login' }];
156
+ }
157
+ if (/glab.*not found|command not found/i.test(msg)) {
158
+ return [{ iid: -1, title: '', error: 'glab CLI not installed. brew install glab && glab auth login' }];
159
+ }
160
+ return [{ iid: -1, title: '', error: msg }];
161
+ }
162
+ }
163
+
164
+ // ── Dedup state ────────────────────────────────────────
165
+
166
+ function isProcessed(projectPath: string, iid: number): boolean {
167
+ return !!db().prepare('SELECT 1 FROM issue_autofix_gitlab_processed WHERE project_path = ? AND issue_iid = ?').get(projectPath, iid);
168
+ }
169
+
170
+ function markProcessed(projectPath: string, iid: number, pipelineId: string): void {
171
+ db().prepare(`
172
+ INSERT OR REPLACE INTO issue_autofix_gitlab_processed (project_path, issue_iid, pipeline_id, status)
173
+ VALUES (?, ?, ?, 'processing')
174
+ `).run(projectPath, iid, pipelineId);
175
+ }
176
+
177
+ export function updateProcessedStatus(projectPath: string, iid: number, status: string, mrIid?: number): void {
178
+ if (mrIid != null) {
179
+ db().prepare('UPDATE issue_autofix_gitlab_processed SET status = ?, mr_iid = ? WHERE project_path = ? AND issue_iid = ?')
180
+ .run(status, mrIid, projectPath, iid);
181
+ } else {
182
+ db().prepare('UPDATE issue_autofix_gitlab_processed SET status = ? WHERE project_path = ? AND issue_iid = ?')
183
+ .run(status, projectPath, iid);
184
+ }
185
+ }
186
+
187
+ export function resetProcessedIssue(projectPath: string, iid: number): void {
188
+ db().prepare('DELETE FROM issue_autofix_gitlab_processed WHERE project_path = ? AND issue_iid = ?').run(projectPath, iid);
189
+ }
190
+
191
+ export function getProcessedIssues(projectPath: string): { issueIid: number; pipelineId: string; mrIid: number | null; status: string; createdAt: string }[] {
192
+ ensureTable();
193
+ return (db().prepare('SELECT * FROM issue_autofix_gitlab_processed WHERE project_path = ? ORDER BY created_at DESC').all(projectPath) as any[]).map(r => ({
194
+ issueIid: r.issue_iid, pipelineId: r.pipeline_id,
195
+ mrIid: r.mr_iid, status: r.status, createdAt: r.created_at,
196
+ }));
197
+ }
198
+
199
+ // ── Scan + trigger ─────────────────────────────────────
200
+
201
+ function saveLastScan(projectPath: string) {
202
+ const now = Date.now();
203
+ scannerState.lastScan.set(projectPath, now);
204
+ try { db().prepare('UPDATE issue_autofix_gitlab_config SET last_scan_at = ? WHERE project_path = ?').run(new Date(now).toISOString(), projectPath); } catch {}
205
+ }
206
+
207
+ function loadLastScan(projectPath: string): number | null {
208
+ try {
209
+ const row = db().prepare('SELECT last_scan_at FROM issue_autofix_gitlab_config WHERE project_path = ?').get(projectPath) as any;
210
+ return row?.last_scan_at ? new Date(row.last_scan_at).getTime() : null;
211
+ } catch { return null; }
212
+ }
213
+
214
+ export function scanAndTrigger(c: GitLabAutofixConfig): { triggered: number; issues: number[]; total: number; error?: string } {
215
+ saveLastScan(c.projectPath);
216
+ const issues = fetchOpenIssues(c.projectPath, c);
217
+ if (issues.length === 1 && (issues[0] as any).error) {
218
+ return { triggered: 0, issues: [], total: 0, error: (issues[0] as any).error };
219
+ }
220
+ const triggered: number[] = [];
221
+ for (const it of issues) {
222
+ if (it.iid < 0 || isProcessed(c.projectPath, it.iid)) continue;
223
+ try {
224
+ const pipeline = startPipeline('gitlab-issue-fix-and-review', {
225
+ issue_id: String(it.iid),
226
+ project: c.projectName,
227
+ base_branch: c.baseBranch || 'auto',
228
+ base_branch_rule: c.baseBranchRule || 'milestone',
229
+ mr_title_template: c.mrTitleTemplate || '',
230
+ mr_body_template: c.mrBodyTemplate || '',
231
+ });
232
+ markProcessed(c.projectPath, it.iid, pipeline.id);
233
+ triggered.push(it.iid);
234
+ console.log(`[gl-issue-scanner] Triggered fix for !${it.iid} "${it.title}" in ${c.projectName} (pipeline: ${pipeline.id})`);
235
+ } catch (e) {
236
+ console.error(`[gl-issue-scanner] Failed to trigger for !${it.iid}:`, e);
237
+ }
238
+ }
239
+ return { triggered: triggered.length, issues: triggered, total: issues.length };
240
+ }
241
+
242
+ // ── Periodic scanner ───────────────────────────────────
243
+
244
+ const scannerKey = Symbol.for('forge-issue-scanner-gitlab');
245
+ const gAny = globalThis as any;
246
+ if (!gAny[scannerKey]) gAny[scannerKey] = { timers: new Map<string, NodeJS.Timeout>(), started: false, lastScan: new Map<string, number>() };
247
+ const scannerState = gAny[scannerKey] as { timers: Map<string, NodeJS.Timeout>; started: boolean; lastScan: Map<string, number> };
248
+
249
+ function refreshTimer(c: GitLabAutofixConfig) {
250
+ const existing = scannerState.timers.get(c.projectPath);
251
+ if (existing) { clearInterval(existing); scannerState.timers.delete(c.projectPath); }
252
+ if (!c.enabled || c.interval <= 0) return;
253
+ const intervalMs = c.interval * 60 * 1000;
254
+ const dbLast = loadLastScan(c.projectPath);
255
+ if (dbLast) scannerState.lastScan.set(c.projectPath, dbLast);
256
+ else scannerState.lastScan.set(c.projectPath, Date.now());
257
+ const elapsed = Date.now() - (scannerState.lastScan.get(c.projectPath) || 0);
258
+ const firstDelay = Math.max(0, intervalMs - elapsed);
259
+ setTimeout(() => {
260
+ try { const cfg = getConfig(c.projectPath); if (cfg?.enabled) scanAndTrigger(cfg); } catch {}
261
+ const timer = setInterval(() => {
262
+ const cfg = getConfig(c.projectPath);
263
+ if (!cfg || !cfg.enabled) return;
264
+ try { scanAndTrigger(cfg); } catch {}
265
+ }, intervalMs);
266
+ scannerState.timers.set(c.projectPath, timer);
267
+ }, firstDelay);
268
+ }
269
+
270
+ export function startScanner() {
271
+ if (scannerState.started) return;
272
+ scannerState.started = true;
273
+ ensureTable();
274
+ for (const c of listConfigs()) refreshTimer(c);
275
+ }
276
+
277
+ export function stopScanner() {
278
+ for (const t of scannerState.timers.values()) clearInterval(t);
279
+ scannerState.timers.clear();
280
+ scannerState.started = false;
281
+ }
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Per-item dispatch: take one new item from a Job's connector result and
3
+ * either trigger a Pipeline run or post a message to a Chat session.
4
+ *
5
+ * Template syntax: `{{item.<dotted.path>}}` — see docs/Jobs-Design.md §
6
+ * Template syntax.
7
+ */
8
+
9
+ import type { PipelineDispatchParams, ChatDispatchParams } from './types';
10
+ import { triggerPipeline } from '../pipeline-scheduler';
11
+
12
+ const CHAT_PORT = Number(process.env.CHAT_PORT) || 8408;
13
+
14
+ // ─── Template rendering ───────────────────────────────────
15
+
16
+ function pickPath(obj: unknown, path: string): unknown {
17
+ if (!path) return obj;
18
+ const parts = path.split('.');
19
+ let cur: any = obj;
20
+ for (const p of parts) {
21
+ if (cur == null || typeof cur !== 'object') return undefined;
22
+ cur = cur[p];
23
+ }
24
+ return cur;
25
+ }
26
+
27
+ function renderValue(v: unknown): string {
28
+ if (v == null) return '';
29
+ if (typeof v === 'string') return v;
30
+ if (typeof v === 'number' || typeof v === 'boolean') return String(v);
31
+ try { return JSON.stringify(v); } catch { return ''; }
32
+ }
33
+
34
+ export function renderTemplate(template: string, item: unknown): string {
35
+ return template.replace(/\{\{\s*item(?:\.([^}\s]+))?\s*\}\}/g, (_, path) => {
36
+ const v = path ? pickPath(item, path) : item;
37
+ return renderValue(v);
38
+ });
39
+ }
40
+
41
+ function renderTemplateMap(map: Record<string, string>, item: unknown): Record<string, string> {
42
+ const out: Record<string, string> = {};
43
+ for (const [k, v] of Object.entries(map)) {
44
+ out[k] = renderTemplate(v, item);
45
+ }
46
+ return out;
47
+ }
48
+
49
+ // ─── Pipeline dispatch ────────────────────────────────────
50
+
51
+ /**
52
+ * Trigger a pipeline run for one new item. `dedupKey` is the Job's per-item
53
+ * key — purely for the audit row in job_dispatches. We DO NOT forward it to
54
+ * triggerPipeline's own dedup column: Jobs already enforce de-duplication
55
+ * at job_seen, and pipeline_runs has a UNIQUE constraint on
56
+ * (project_path, workflow_name, dedup_key) that would block any 'Reset
57
+ * dedup → Run now' replay (the row from the old run still occupies the
58
+ * slot). Letting pipeline_runs.dedup_key be NULL means each dispatch
59
+ * gets its own row.
60
+ */
61
+ export function dispatchToPipeline(params: PipelineDispatchParams, item: unknown, _dedupKey: string): { target_id: string; rendered_input: Record<string, string>; empty_keys: string[] } {
62
+ const renderedInput = renderTemplateMap(params.input_template || {}, item);
63
+ // Heads-up if a template key rendered to empty — almost always means the
64
+ // user wrote {{item.foo}} but the item has no 'foo' field. The pipeline
65
+ // node usually exits 1 with "ERROR: <key> is required" which is hard to
66
+ // act on without knowing where the empty came from.
67
+ const emptyKeys: string[] = [];
68
+ for (const [k, v] of Object.entries(renderedInput)) {
69
+ if (!String(v ?? '').trim()) emptyKeys.push(k);
70
+ }
71
+ if (emptyKeys.length > 0) {
72
+ console.warn(
73
+ `[jobs] dispatchToPipeline: ${emptyKeys.length} key(s) rendered to empty — ` +
74
+ `pipeline will likely fail. keys=${emptyKeys.join(', ')}. ` +
75
+ `Check the job's input_template — '{{item.X}}' values resolve to empty if X isn't on the item.`,
76
+ );
77
+ }
78
+ const result = triggerPipeline(
79
+ params.project_path,
80
+ params.project_name,
81
+ params.workflow_name,
82
+ renderedInput,
83
+ // intentionally no dedup_key — see comment above
84
+ );
85
+ return { target_id: result.pipelineId, rendered_input: renderedInput, empty_keys: emptyKeys };
86
+ }
87
+
88
+ // ─── Chat dispatch ────────────────────────────────────────
89
+
90
+ async function chatHttp(path: string, body?: unknown): Promise<any> {
91
+ const res = await fetch(`http://127.0.0.1:${CHAT_PORT}${path}`, {
92
+ method: body ? 'POST' : 'GET',
93
+ headers: { 'content-type': 'application/json' },
94
+ body: body ? JSON.stringify(body) : undefined,
95
+ });
96
+ if (!res.ok && res.status !== 202) {
97
+ throw new Error(`chat ${path}: HTTP ${res.status} ${(await res.text().catch(() => '')).slice(0, 200)}`);
98
+ }
99
+ if (res.status === 204) return null;
100
+ return res.json();
101
+ }
102
+
103
+ /** Resolve the user's persistent main session id (creates if missing). */
104
+ async function getMainSessionId(): Promise<string> {
105
+ const r = await chatHttp('/api/sessions/main');
106
+ const id = String(r?.session?.id || '');
107
+ if (!id) throw new Error('main session lookup returned no id');
108
+ return id;
109
+ }
110
+
111
+ /**
112
+ * Per-job in-memory map of reused chat sessions, so all items in a `reuse_session: true`
113
+ * job append to one session. Lives in this module (one per Next.js worker).
114
+ */
115
+ const reusedSessionByJob = new Map<string, string>();
116
+
117
+ export async function dispatchToChat(params: ChatDispatchParams, item: unknown, jobId: string): Promise<{ target_id: string }> {
118
+ let sessionId: string | undefined;
119
+ if (params.target === 'main') {
120
+ sessionId = await getMainSessionId();
121
+ } else if (params.reuse_session) {
122
+ sessionId = reusedSessionByJob.get(jobId);
123
+ }
124
+
125
+ if (!sessionId) {
126
+ const title = params.session_title_template
127
+ ? renderTemplate(params.session_title_template, item)
128
+ : 'Job dispatch';
129
+ const created = await chatHttp('/api/sessions', {
130
+ title,
131
+ provider: params.agent_profile,
132
+ meta: { kind: 'temp', source: `job:${jobId}` },
133
+ });
134
+ sessionId = String(created?.session?.id || '');
135
+ if (!sessionId) throw new Error('chat session create returned no id');
136
+ if (params.reuse_session) reusedSessionByJob.set(jobId, sessionId);
137
+ }
138
+
139
+ const text = renderTemplate(params.message_template, item);
140
+ if (!text.trim()) throw new Error('rendered chat message is empty');
141
+ await chatHttp(`/api/sessions/${encodeURIComponent(sessionId)}/messages`, { text });
142
+ return { target_id: sessionId };
143
+ }
144
+
145
+ /**
146
+ * Summary-mode dispatch — fold ALL new items in this tick into ONE message
147
+ * sent to a single chat session. Lets the LLM analyze + summarize whatever
148
+ * came back from the connector instead of getting only the first item.
149
+ *
150
+ * message_template placeholders specific to this mode:
151
+ * {{items}} — JSON.stringify of the full items array
152
+ * {{items_text}} — one line per item ("id. title")
153
+ * {{count}} — items.length
154
+ * {{item.X}} also still works against items[0] for header lines like
155
+ * "Today's report for {{item.project}}".
156
+ */
157
+ export async function dispatchToChatSummary(
158
+ params: ChatDispatchParams,
159
+ items: unknown[],
160
+ jobId: string,
161
+ opts: { totalMatching?: number | string | null } = {},
162
+ ): Promise<{ target_id: string; count: number }> {
163
+ let sessionId: string | undefined;
164
+ // Default target for summary mode is the main chat — that's where the
165
+ // user's agent already lives. They don't want a new chat tab per tick.
166
+ const target = params.target ?? 'main';
167
+ if (target === 'main') {
168
+ sessionId = await getMainSessionId();
169
+ } else if (params.reuse_session) {
170
+ sessionId = reusedSessionByJob.get(jobId);
171
+ }
172
+
173
+ if (!sessionId) {
174
+ const title = params.session_title_template
175
+ ? renderTemplate(params.session_title_template, items[0] ?? {})
176
+ : `Job summary (${items.length})`;
177
+ const created = await chatHttp('/api/sessions', {
178
+ title, provider: params.agent_profile,
179
+ meta: { kind: 'temp', source: `job:${jobId}` },
180
+ });
181
+ sessionId = String(created?.session?.id || '');
182
+ if (!sessionId) throw new Error('chat session create returned no id');
183
+ if (params.reuse_session) reusedSessionByJob.set(jobId, sessionId);
184
+ }
185
+
186
+ const itemsJson = JSON.stringify(items, null, 2);
187
+ const itemsText = items.map((it, i) => {
188
+ if (it && typeof it === 'object') {
189
+ const o = it as Record<string, unknown>;
190
+ const id = o.id ?? o.iid ?? o.number ?? (i + 1);
191
+ const title = o.title || o.summary || o.name || '';
192
+ return title ? `${id}. ${title}` : `${id}.`;
193
+ }
194
+ return `${i + 1}. ${String(it)}`;
195
+ }).join('\n');
196
+
197
+ let text = String(params.message_template || '');
198
+ text = text.replace(/\{\{\s*items\s*\}\}/g, itemsJson);
199
+ text = text.replace(/\{\{\s*items_text\s*\}\}/g, itemsText);
200
+ // {{count}} = items shown in this dispatch (after dedup + limit).
201
+ // {{total_matching}} = how many matched the filter server-side. Useful
202
+ // when the connector returned only first N (e.g. mantis limit=20 but
203
+ // 151 bugs match — caller wants "20 of 151 …").
204
+ text = text.replace(/\{\{\s*count\s*\}\}/g, String(items.length));
205
+ const tm = opts.totalMatching;
206
+ text = text.replace(/\{\{\s*total_matching\s*\}\}/g, tm != null ? String(tm) : String(items.length));
207
+ // {{item.X}} → first item, so "header" lines work the same way as per-item mode
208
+ text = renderTemplate(text, items[0] ?? {});
209
+
210
+ if (!text.trim()) throw new Error('rendered chat summary message is empty');
211
+ await chatHttp(`/api/sessions/${encodeURIComponent(sessionId)}/messages`, { text });
212
+ return { target_id: sessionId, count: items.length };
213
+ }
214
+
215
+ export function clearReusedSession(jobId: string): void {
216
+ reusedSessionByJob.delete(jobId);
217
+ }