@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,334 @@
1
+ /**
2
+ * Jobs scheduler — ticks every 60s, runs each due Job, dispatches new items.
3
+ *
4
+ * Started by lib/init.ts on first request. Runs inside the Next.js worker
5
+ * alongside pipeline-scheduler. Each tick is async — the loop never waits
6
+ * for a job's connector call to finish (just kicks it off and advances
7
+ * next_run_at). The job's own run row is updated when the work completes.
8
+ */
9
+
10
+ import {
11
+ ensureSchema, getDueJobs, hasInflightRun, startRun, finishRun,
12
+ markSeen, recordDispatch, getJob, updateJob,
13
+ } from './store';
14
+ import type { Job, JobRunStatus, PipelineDispatchParams, ChatDispatchParams } from './types';
15
+ import { dispatchTool } from '@/lib/chat/tool-dispatcher';
16
+ import { dispatchToPipeline, dispatchToChat, dispatchToChatSummary } from './dispatcher';
17
+
18
+ const TICK_INTERVAL_MS = 60_000;
19
+
20
+ const schedulerKey = Symbol.for('forge-jobs-scheduler');
21
+ const g = globalThis as any;
22
+
23
+ export function startScheduler(): void {
24
+ if (g[schedulerKey]) return;
25
+ g[schedulerKey] = true;
26
+ ensureSchema();
27
+ console.log('[jobs-scheduler] started');
28
+ void tick();
29
+ setInterval(() => { void tick(); }, TICK_INTERVAL_MS).unref?.();
30
+ }
31
+
32
+ async function tick(): Promise<void> {
33
+ let due: Job[] = [];
34
+ try { due = getDueJobs(); } catch (e) { console.error('[jobs-scheduler] getDueJobs failed', e); return; }
35
+
36
+ for (const job of due) {
37
+ if (hasInflightRun(job.id)) {
38
+ console.log(`[jobs] skip ${job.id} — previous run still inflight`);
39
+ // Still advance the schedule so we re-evaluate at the next interval.
40
+ advanceSchedule(job);
41
+ continue;
42
+ }
43
+ advanceSchedule(job);
44
+ // Kick off the run; don't await — long connector calls / pipeline triggers
45
+ // shouldn't block the scheduler loop.
46
+ const { runId } = prepareRun(job, 'schedule');
47
+ void executeRun(job, runId).catch((e) => {
48
+ console.error(`[jobs] runJob ${job.id} crashed`, e);
49
+ });
50
+ }
51
+ }
52
+
53
+ function advanceSchedule(job: Job): void {
54
+ const next = new Date(Date.now() + Math.max(1, job.schedule_interval_minutes) * 60_000);
55
+ // setNextRunAt also bumps last_run_at — call updateJob via raw SQL for clarity.
56
+ const { setNextRunAt } = require('./store') as typeof import('./store');
57
+ setNextRunAt(job.id, next.toISOString().replace('T', ' ').slice(0, 19));
58
+ }
59
+
60
+ /**
61
+ * Execute one Job tick fully. Public for use by the scheduler loop and
62
+ * the manual-fire API. The manual route prefers prepareRun + executeRun
63
+ * (split below) so it can return the run id before the work finishes.
64
+ */
65
+ export async function runJob(jobOrId: Job | string, trigger: 'schedule' | 'manual'): Promise<string> {
66
+ const { job, runId } = prepareRun(jobOrId, trigger);
67
+ await executeRun(job, runId);
68
+ return runId;
69
+ }
70
+
71
+ /**
72
+ * Insert a run row and return its id immediately. Use with executeRun(job, runId)
73
+ * when you want fire-and-forget semantics (e.g. manual API trigger).
74
+ */
75
+ export function prepareRun(jobOrId: Job | string, trigger: 'schedule' | 'manual'): { job: Job; runId: string } {
76
+ const job = typeof jobOrId === 'string' ? getJob(jobOrId) : jobOrId;
77
+ if (!job) throw new Error(`job not found: ${typeof jobOrId === 'string' ? jobOrId : '(null)'}`);
78
+ const run = startRun(job.id, trigger);
79
+ return { job, runId: run.id };
80
+ }
81
+
82
+ /**
83
+ * Do the actual tick work (connector call + dedup + dispatch). Updates the run
84
+ * row, including a verbose plain-text execution log saved to `job_runs.log`
85
+ * for inspection in the UI ("show log" expansion under each run row).
86
+ *
87
+ * Every meaningful step appends one timestamped line to a per-tick buffer;
88
+ * we also mirror the high-level lines to console for live tailing via
89
+ * `tail -f forge.log | grep [jobs]`.
90
+ */
91
+ export async function executeRun(job: Job, runId: string): Promise<void> {
92
+ const t0 = Date.now();
93
+ let itemsSeen = 0, itemsNew = 0, itemsDispatched = 0;
94
+ let runError: string | null = null;
95
+ const callName = `${job.source_connector}.${job.source_tool}`;
96
+ const lines: string[] = [];
97
+ const logLine = (level: 'info' | 'warn' | 'error', msg: string, mirror = true) => {
98
+ const ts = new Date().toISOString().slice(11, 23);
99
+ const ms = String(Date.now() - t0).padStart(5, ' ') + 'ms';
100
+ lines.push(`${ts} ${ms} ${level.toUpperCase().padEnd(5)} ${msg}`);
101
+ if (mirror) {
102
+ const tag = `[jobs] ${job.id} ${callName}`;
103
+ if (level === 'error') console.error(tag, msg);
104
+ else if (level === 'warn') console.warn(tag, msg);
105
+ else console.log(tag, msg);
106
+ }
107
+ };
108
+ const truncate = (s: string, n: number) => (s.length > n ? s.slice(0, n) + ` …(${s.length - n} bytes truncated)` : s);
109
+
110
+ // Always persist whatever log we accumulated, even on uncaught throws.
111
+ const persist = (extra: { status: JobRunStatus; error?: string | null; notes?: string | null }) => {
112
+ finishRun(runId, {
113
+ status: extra.status,
114
+ items_seen: itemsSeen,
115
+ items_new: itemsNew,
116
+ items_dispatched: itemsDispatched,
117
+ error: extra.error ?? null,
118
+ notes: extra.notes ?? null,
119
+ log: lines.join('\n'),
120
+ });
121
+ };
122
+
123
+ try {
124
+ const { __mark_existing_as_seen, ...sourceInput } = job.source_input as any;
125
+ logLine('info', `tick start trigger=${(__mark_existing_as_seen ? 'backfill' : 'normal')} dispatch=${job.dispatch_type}`);
126
+ logLine('info', `source input: ${JSON.stringify(sourceInput)}`);
127
+ logLine('info', `calling connector ${callName}…`);
128
+
129
+ const toolResult = await dispatchTool({ id: `job-${runId}`, name: callName, input: sourceInput });
130
+
131
+ const respBytes = toolResult.content?.length ?? 0;
132
+ logLine(toolResult.is_error ? 'error' : 'info',
133
+ `connector returned ${toolResult.is_error ? 'is_error=true ' : ''}${respBytes} bytes`);
134
+ logLine('info', `response preview:\n${truncate(toolResult.content || '(empty)', 600)}`, false);
135
+
136
+ if (toolResult.is_error) {
137
+ throw new Error(`connector ${callName} failed: ${toolResult.content.slice(0, 500)}`);
138
+ }
139
+
140
+ // ── Parse + extract items ────────────────────────────────────
141
+ const parsed = safeParseJson(toolResult.content);
142
+ if (parsed === undefined) {
143
+ const note = `Connector returned non-JSON content (${respBytes} bytes). Preview: ${toolResult.content.slice(0, 200)}`;
144
+ logLine('warn', note);
145
+ persist({ status: 'ok', notes: note });
146
+ return;
147
+ }
148
+ logLine('info', `parsed JSON; type=${Array.isArray(parsed) ? 'array' : typeof parsed}`);
149
+
150
+ let items = pickPath(parsed, job.items_path) as unknown;
151
+ // Detail-style connectors (mantis.get_bug, gitlab.get_issue, …) return a
152
+ // single object, not an array. Treat that as a 1-item list so the same
153
+ // Job machinery (dedup, dispatch, render) works for both list and detail
154
+ // tools. This is what lets you test a pipeline end-to-end with one
155
+ // known item without writing a separate code path.
156
+ if (items && typeof items === 'object' && !Array.isArray(items)) {
157
+ logLine('info', `items_path resolved to a single object — wrapping as 1-item list`);
158
+ items = [items];
159
+ }
160
+ if (!Array.isArray(items)) {
161
+ const topKeys = parsed && typeof parsed === 'object' && !Array.isArray(parsed)
162
+ ? Object.keys(parsed as Record<string, unknown>).join(', ')
163
+ : Array.isArray(parsed) ? '(top-level is an array — leave items_path empty)' : typeof parsed;
164
+ const note = `items_path "${job.items_path || '(empty)'}" did not resolve to an array or object. Top-level keys: ${topKeys}`;
165
+ logLine('warn', note);
166
+ persist({ status: 'ok', notes: note });
167
+ return;
168
+ }
169
+ const itemsArr = items as unknown[];
170
+ itemsSeen = itemsArr.length;
171
+ logLine('info', `items_path="${job.items_path || '(root)'}" → ${itemsSeen} item(s)`);
172
+
173
+ // ── Backfill guard ───────────────────────────────────────────
174
+ if (__mark_existing_as_seen) {
175
+ let backfilledKeys = 0, missingKey = 0;
176
+ for (const item of itemsArr) {
177
+ const key = pickDedupKey(item, job.dedup_field);
178
+ if (key) { markSeen(job.id, key); backfilledKeys++; }
179
+ else missingKey++;
180
+ }
181
+ updateJob(job.id, { source_input: sourceInput });
182
+ logLine('info', `backfill complete: marked ${backfilledKeys} items as seen` + (missingKey ? `, ${missingKey} skipped (missing dedup_field "${job.dedup_field}")` : ''));
183
+ const note = `Backfill (first tick): marked ${backfilledKeys} existing items as seen — no dispatches by design. Future ticks dispatch only NEW items. To re-process everything, click "Reset dedup".`;
184
+ persist({ status: 'ok', notes: note });
185
+ return;
186
+ }
187
+
188
+ // ── Chat summary mode — bundle all NEW items into ONE message ──
189
+ // Useful when search_bugs returns N items and you want the LLM to
190
+ // analyze + summarize, instead of getting N separate chat messages
191
+ // (or just the first item if reuse_session was off).
192
+ if (job.dispatch_type === 'chat' &&
193
+ (job.dispatch_params as ChatDispatchParams).mode === 'summary') {
194
+ const chatParams = job.dispatch_params as ChatDispatchParams;
195
+ let dedupHits = 0, missingKey = 0;
196
+ const newItems: unknown[] = [];
197
+ for (const [idx, item] of itemsArr.entries()) {
198
+ const key = pickDedupKey(item, job.dedup_field);
199
+ if (!key) { missingKey++; logLine('warn', `[${idx}] item missing dedup_field "${job.dedup_field}" — skipping`); continue; }
200
+ if (!markSeen(job.id, key)) { dedupHits++; continue; }
201
+ itemsNew++;
202
+ newItems.push(item);
203
+ }
204
+ if (newItems.length === 0) {
205
+ const note = itemsSeen === 0
206
+ ? 'Connector returned 0 items.'
207
+ : `All ${itemsSeen} items already seen — nothing new to summarize.`;
208
+ logLine('info', `summary mode: ${note}`);
209
+ persist({ status: 'ok', notes: note });
210
+ return;
211
+ }
212
+ logLine('info', `summary mode: bundling ${newItems.length} new item(s) into one chat message`);
213
+ // Pull the connector's server-side total (e.g. mantis search_bugs
214
+ // total_matching = "151" parsed from "Viewing Bugs (1 - 50 / 151)").
215
+ // The {{count}} placeholder in the user's template now means "items
216
+ // they're actually seeing"; {{total_matching}} is "how many matched
217
+ // the filter on the server". Falls back to parsed.total when the
218
+ // connector doesn't emit total_matching.
219
+ const totalMatching = (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
220
+ ? (parsed as any).total_matching ?? (parsed as any).total ?? null
221
+ : null;
222
+ try {
223
+ const out = await dispatchToChatSummary(chatParams, newItems, job.id, { totalMatching });
224
+ recordDispatch({
225
+ job_run_id: runId,
226
+ item_key: `summary-${runId}`,
227
+ item_preview: `[${newItems.length} items folded into one message]`,
228
+ dispatch_type: 'chat', dispatch_target_id: out.target_id, status: 'dispatched',
229
+ });
230
+ itemsDispatched = 1; // one logical dispatch, regardless of item count
231
+ logLine('info', `summary → chat:${out.target_id} (${newItems.length} items)`);
232
+ } catch (e) {
233
+ const msg = e instanceof Error ? e.message : String(e);
234
+ recordDispatch({
235
+ job_run_id: runId, item_key: `summary-${runId}`, item_preview: '',
236
+ dispatch_type: 'chat', dispatch_target_id: null, status: 'error', error: msg,
237
+ });
238
+ logLine('error', `summary dispatch failed: ${msg}`);
239
+ }
240
+ logLine('info', `tick done in ${Date.now() - t0}ms — ${itemsSeen} seen, ${itemsNew} new, ${itemsDispatched} dispatched (summary), ${dedupHits} dedup hits` + (missingKey ? `, ${missingKey} missing-key` : ''));
241
+ persist({ status: 'ok', notes: null });
242
+ return;
243
+ }
244
+
245
+ // ── Per-item dispatch ────────────────────────────────────────
246
+ let dedupHits = 0, missingKey = 0;
247
+ for (const [idx, item] of itemsArr.entries()) {
248
+ const key = pickDedupKey(item, job.dedup_field);
249
+ if (!key) {
250
+ missingKey++;
251
+ logLine('warn', `[${idx}] item missing dedup_field "${job.dedup_field}" — skipping`);
252
+ continue;
253
+ }
254
+ const isNew = markSeen(job.id, key);
255
+ if (!isNew) {
256
+ dedupHits++;
257
+ // Don't mirror to console — too chatty in the typical "0 new" case
258
+ logLine('info', `[${idx}] ${key} — already seen, skip`, false);
259
+ continue;
260
+ }
261
+ itemsNew++;
262
+ const preview = renderItemPreview(item);
263
+ logLine('info', `[${idx}] ${key} — new — dispatching ${job.dispatch_type}…`);
264
+ const dispatchStart = Date.now();
265
+ try {
266
+ const out = job.dispatch_type === 'pipeline'
267
+ ? dispatchToPipeline(job.dispatch_params as PipelineDispatchParams, item, key)
268
+ : await dispatchToChat(job.dispatch_params as ChatDispatchParams, item, job.id);
269
+ recordDispatch({
270
+ job_run_id: runId, item_key: key, item_preview: preview,
271
+ dispatch_type: job.dispatch_type, dispatch_target_id: out.target_id, status: 'dispatched',
272
+ });
273
+ itemsDispatched++;
274
+ logLine('info', `[${idx}] ${key} → ${job.dispatch_type}:${out.target_id} (${Date.now() - dispatchStart}ms)`);
275
+ const emptyKeys = (out as any).empty_keys as string[] | undefined;
276
+ if (emptyKeys && emptyKeys.length > 0) {
277
+ logLine('warn', `[${idx}] ${key} — these input_template keys rendered EMPTY (likely a {{item.X}} where X isn't on the item): ${emptyKeys.join(', ')}. Pipeline node will probably fail with 'is required' — fix by typing a literal value in the job's input_template.`);
278
+ }
279
+ } catch (e) {
280
+ const msg = e instanceof Error ? e.message : String(e);
281
+ recordDispatch({
282
+ job_run_id: runId, item_key: key, item_preview: preview,
283
+ dispatch_type: job.dispatch_type, dispatch_target_id: null, status: 'error', error: msg,
284
+ });
285
+ logLine('error', `[${idx}] ${key} dispatch failed: ${msg}`);
286
+ }
287
+ }
288
+
289
+ let note: string | null = null;
290
+ if (itemsDispatched === 0) {
291
+ if (itemsSeen === 0) note = 'Connector returned 0 items.';
292
+ else if (dedupHits === itemsSeen) note = `All ${itemsSeen} items were already seen (dedup hits). No new items to dispatch. Click "Reset dedup" to re-process everything.`;
293
+ else if (missingKey === itemsSeen) note = `All ${itemsSeen} items lacked the dedup_field "${job.dedup_field}". Check the field name vs the connector's response shape.`;
294
+ }
295
+
296
+ logLine('info', `tick done in ${Date.now() - t0}ms — ${itemsSeen} seen, ${itemsNew} new, ${itemsDispatched} dispatched, ${dedupHits} dedup hits` + (missingKey ? `, ${missingKey} missing-key` : ''));
297
+ persist({ status: 'ok', notes: note });
298
+ } catch (e) {
299
+ runError = e instanceof Error ? e.message : String(e);
300
+ logLine('error', `tick failed: ${runError}`);
301
+ persist({ status: 'error', error: runError });
302
+ }
303
+ }
304
+
305
+ // ─── Helpers ──────────────────────────────────────────────
306
+
307
+ function safeParseJson(content: string): unknown | undefined {
308
+ try { return JSON.parse(content); }
309
+ catch { return undefined; }
310
+ }
311
+
312
+ function pickPath(obj: unknown, path: string): unknown {
313
+ if (!path) return obj;
314
+ const parts = path.split('.');
315
+ let cur: any = obj;
316
+ for (const p of parts) {
317
+ if (cur == null || typeof cur !== 'object') return undefined;
318
+ cur = cur[p];
319
+ }
320
+ return cur;
321
+ }
322
+
323
+ function pickDedupKey(item: unknown, field: string): string | null {
324
+ const v = pickPath(item, field);
325
+ if (v == null) return null;
326
+ return typeof v === 'string' ? v : String(v);
327
+ }
328
+
329
+ function renderItemPreview(item: unknown): string {
330
+ try {
331
+ const s = JSON.stringify(item);
332
+ return s.length > 200 ? s.slice(0, 200) + '…' : s;
333
+ } catch { return ''; }
334
+ }
@@ -0,0 +1,319 @@
1
+ /**
2
+ * Sqlite-backed CRUD for Jobs. Schema lives here so the table is auto-
3
+ * created on first use (mirrors lib/issue-scanner.ts style).
4
+ */
5
+
6
+ import { getDb } from '@/src/core/db/database';
7
+ import { getDbPath } from '@/src/config';
8
+ import { randomUUID } from 'node:crypto';
9
+ import { toIsoUTC } from '@/lib/iso-time';
10
+ import type {
11
+ Job, JobRun, JobDispatch, CreateJobInput,
12
+ JobRunStatus, JobRunTrigger, JobDispatchStatus,
13
+ DispatchParams,
14
+ } from './types';
15
+
16
+ function db() { return getDb(getDbPath()); }
17
+
18
+ let ensured = false;
19
+ export function ensureSchema(): void {
20
+ if (ensured) return;
21
+ db().exec(`
22
+ CREATE TABLE IF NOT EXISTS jobs (
23
+ id TEXT PRIMARY KEY,
24
+ name TEXT NOT NULL,
25
+ enabled INTEGER NOT NULL DEFAULT 1,
26
+ schedule_interval_minutes INTEGER NOT NULL DEFAULT 30,
27
+ source_connector TEXT NOT NULL,
28
+ source_tool TEXT NOT NULL,
29
+ source_input TEXT NOT NULL DEFAULT '{}',
30
+ items_path TEXT,
31
+ dedup_field TEXT NOT NULL,
32
+ dispatch_type TEXT NOT NULL,
33
+ dispatch_params TEXT NOT NULL DEFAULT '{}',
34
+ last_run_at TEXT,
35
+ next_run_at TEXT,
36
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
37
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
38
+ );
39
+ CREATE TABLE IF NOT EXISTS job_runs (
40
+ id TEXT PRIMARY KEY,
41
+ job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
42
+ started_at TEXT NOT NULL DEFAULT (datetime('now')),
43
+ finished_at TEXT,
44
+ status TEXT NOT NULL DEFAULT 'running',
45
+ items_seen INTEGER NOT NULL DEFAULT 0,
46
+ items_new INTEGER NOT NULL DEFAULT 0,
47
+ items_dispatched INTEGER NOT NULL DEFAULT 0,
48
+ error TEXT,
49
+ trigger TEXT NOT NULL DEFAULT 'schedule',
50
+ notes TEXT,
51
+ /** Plain-text execution log for this tick — one timestamped line per step. */
52
+ log TEXT
53
+ );
54
+ CREATE TABLE IF NOT EXISTS job_seen (
55
+ job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
56
+ dedup_key TEXT NOT NULL,
57
+ first_seen_at TEXT NOT NULL DEFAULT (datetime('now')),
58
+ PRIMARY KEY (job_id, dedup_key)
59
+ );
60
+ CREATE TABLE IF NOT EXISTS job_dispatches (
61
+ id TEXT PRIMARY KEY,
62
+ job_run_id TEXT NOT NULL REFERENCES job_runs(id) ON DELETE CASCADE,
63
+ item_key TEXT NOT NULL,
64
+ item_preview TEXT,
65
+ dispatch_type TEXT NOT NULL,
66
+ dispatch_target_id TEXT,
67
+ status TEXT NOT NULL DEFAULT 'dispatched',
68
+ error TEXT,
69
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
70
+ );
71
+ CREATE INDEX IF NOT EXISTS idx_job_runs_job ON job_runs(job_id, started_at DESC);
72
+ CREATE INDEX IF NOT EXISTS idx_job_dispatches_run ON job_dispatches(job_run_id, created_at DESC);
73
+ `);
74
+ // Migrations for already-existing job_runs tables.
75
+ try { db().exec(`ALTER TABLE job_runs ADD COLUMN notes TEXT`); } catch {}
76
+ try { db().exec(`ALTER TABLE job_runs ADD COLUMN log TEXT`); } catch {}
77
+ ensured = true;
78
+ }
79
+
80
+ // ─── Row → Job/JobRun/JobDispatch mapping ─────────────────
81
+
82
+ function rowToJob(r: any): Job {
83
+ return {
84
+ id: r.id,
85
+ name: r.name,
86
+ enabled: !!r.enabled,
87
+ schedule_interval_minutes: r.schedule_interval_minutes,
88
+ source_connector: r.source_connector,
89
+ source_tool: r.source_tool,
90
+ source_input: safeParse(r.source_input, {}) as Record<string, unknown>,
91
+ items_path: r.items_path || '',
92
+ dedup_field: r.dedup_field,
93
+ dispatch_type: r.dispatch_type,
94
+ dispatch_params: safeParse(r.dispatch_params, {}) as DispatchParams,
95
+ last_run_at: toIsoUTC(r.last_run_at),
96
+ next_run_at: toIsoUTC(r.next_run_at),
97
+ created_at: toIsoUTC(r.created_at) || r.created_at,
98
+ updated_at: toIsoUTC(r.updated_at) || r.updated_at,
99
+ };
100
+ }
101
+
102
+ function rowToRun(r: any): JobRun {
103
+ return {
104
+ id: r.id,
105
+ job_id: r.job_id,
106
+ started_at: toIsoUTC(r.started_at) || r.started_at,
107
+ finished_at: toIsoUTC(r.finished_at),
108
+ status: r.status as JobRunStatus,
109
+ items_seen: r.items_seen,
110
+ items_new: r.items_new,
111
+ items_dispatched: r.items_dispatched,
112
+ error: r.error,
113
+ trigger: r.trigger as JobRunTrigger,
114
+ notes: r.notes || null,
115
+ log: r.log || null,
116
+ };
117
+ }
118
+
119
+ function rowToDispatch(r: any): JobDispatch {
120
+ return {
121
+ id: r.id,
122
+ job_run_id: r.job_run_id,
123
+ item_key: r.item_key,
124
+ item_preview: r.item_preview,
125
+ dispatch_type: r.dispatch_type,
126
+ dispatch_target_id: r.dispatch_target_id,
127
+ status: r.status as JobDispatchStatus,
128
+ error: r.error,
129
+ created_at: toIsoUTC(r.created_at) || r.created_at,
130
+ };
131
+ }
132
+
133
+ function safeParse(raw: string | null, fallback: unknown): unknown {
134
+ if (!raw) return fallback;
135
+ try { return JSON.parse(raw); } catch { return fallback; }
136
+ }
137
+
138
+ // ─── Job CRUD ─────────────────────────────────────────────
139
+
140
+ export function listJobs(): Job[] {
141
+ ensureSchema();
142
+ const rows = db().prepare('SELECT * FROM jobs ORDER BY created_at DESC').all() as any[];
143
+ return rows.map(rowToJob);
144
+ }
145
+
146
+ export function getJob(id: string): Job | null {
147
+ ensureSchema();
148
+ const r = db().prepare('SELECT * FROM jobs WHERE id = ?').get(id) as any;
149
+ return r ? rowToJob(r) : null;
150
+ }
151
+
152
+ export function createJob(input: CreateJobInput): Job {
153
+ ensureSchema();
154
+ const id = randomUUID().slice(0, 12);
155
+ db().prepare(`
156
+ INSERT INTO jobs (id, name, enabled, schedule_interval_minutes,
157
+ source_connector, source_tool, source_input,
158
+ items_path, dedup_field,
159
+ dispatch_type, dispatch_params)
160
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
161
+ `).run(
162
+ id,
163
+ input.name,
164
+ input.enabled === false ? 0 : 1,
165
+ input.schedule_interval_minutes ?? 30,
166
+ input.source_connector,
167
+ input.source_tool,
168
+ JSON.stringify(input.source_input ?? {}),
169
+ input.items_path ?? '',
170
+ input.dedup_field,
171
+ input.dispatch_type,
172
+ JSON.stringify(input.dispatch_params),
173
+ );
174
+
175
+ // Backfill guard: if mark_existing_as_seen is true (default), we don't pre-seed
176
+ // here — the first tick will do it (calling the connector once, marking
177
+ // everything, dispatching nothing). This keeps create() pure DB work.
178
+ // The flag is stashed in source_input under __mark_existing_as_seen so the
179
+ // first tick can read & clear it.
180
+ if (input.mark_existing_as_seen !== false) {
181
+ const inputWithFlag = { ...(input.source_input ?? {}), __mark_existing_as_seen: true };
182
+ db().prepare('UPDATE jobs SET source_input = ? WHERE id = ?').run(JSON.stringify(inputWithFlag), id);
183
+ }
184
+
185
+ return getJob(id)!;
186
+ }
187
+
188
+ export function updateJob(id: string, patch: Partial<{
189
+ name: string; enabled: boolean; schedule_interval_minutes: number;
190
+ source_connector: string; source_tool: string; source_input: Record<string, unknown>;
191
+ items_path: string; dedup_field: string;
192
+ dispatch_type: 'pipeline' | 'chat'; dispatch_params: DispatchParams;
193
+ }>): boolean {
194
+ ensureSchema();
195
+ const sets: string[] = []; const vals: any[] = [];
196
+ if (patch.name !== undefined) { sets.push('name = ?'); vals.push(patch.name); }
197
+ if (patch.enabled !== undefined) { sets.push('enabled = ?'); vals.push(patch.enabled ? 1 : 0); }
198
+ if (patch.schedule_interval_minutes !== undefined) { sets.push('schedule_interval_minutes = ?'); vals.push(patch.schedule_interval_minutes); }
199
+ if (patch.source_connector !== undefined) { sets.push('source_connector = ?'); vals.push(patch.source_connector); }
200
+ if (patch.source_tool !== undefined) { sets.push('source_tool = ?'); vals.push(patch.source_tool); }
201
+ if (patch.source_input !== undefined) { sets.push('source_input = ?'); vals.push(JSON.stringify(patch.source_input)); }
202
+ if (patch.items_path !== undefined) { sets.push('items_path = ?'); vals.push(patch.items_path); }
203
+ if (patch.dedup_field !== undefined) { sets.push('dedup_field = ?'); vals.push(patch.dedup_field); }
204
+ if (patch.dispatch_type !== undefined) { sets.push('dispatch_type = ?'); vals.push(patch.dispatch_type); }
205
+ if (patch.dispatch_params !== undefined) { sets.push('dispatch_params = ?'); vals.push(JSON.stringify(patch.dispatch_params)); }
206
+ if (sets.length === 0) return false;
207
+ sets.push("updated_at = datetime('now')");
208
+ vals.push(id);
209
+ const r = db().prepare(`UPDATE jobs SET ${sets.join(', ')} WHERE id = ?`).run(...vals);
210
+ return r.changes > 0;
211
+ }
212
+
213
+ export function deleteJob(id: string): boolean {
214
+ ensureSchema();
215
+ const r = db().prepare('DELETE FROM jobs WHERE id = ?').run(id);
216
+ return r.changes > 0;
217
+ }
218
+
219
+ export function setNextRunAt(id: string, nextRunAt: string | null): void {
220
+ ensureSchema();
221
+ db().prepare("UPDATE jobs SET next_run_at = ?, last_run_at = datetime('now') WHERE id = ?").run(nextRunAt, id);
222
+ }
223
+
224
+ /** Jobs due to run: enabled AND (next_run_at IS NULL OR next_run_at <= now). */
225
+ export function getDueJobs(): Job[] {
226
+ ensureSchema();
227
+ const rows = db().prepare(`
228
+ SELECT * FROM jobs
229
+ WHERE enabled = 1
230
+ AND (next_run_at IS NULL OR next_run_at <= datetime('now'))
231
+ ORDER BY (next_run_at IS NULL) DESC, next_run_at ASC
232
+ `).all() as any[];
233
+ return rows.map(rowToJob);
234
+ }
235
+
236
+ // ─── Run CRUD ─────────────────────────────────────────────
237
+
238
+ export function startRun(jobId: string, trigger: JobRunTrigger): JobRun {
239
+ ensureSchema();
240
+ const id = randomUUID().slice(0, 12);
241
+ db().prepare(`INSERT INTO job_runs (id, job_id, trigger) VALUES (?, ?, ?)`).run(id, jobId, trigger);
242
+ return db().prepare('SELECT * FROM job_runs WHERE id = ?').get(id) as any as JobRun;
243
+ }
244
+
245
+ export function finishRun(runId: string, patch: { status: JobRunStatus; items_seen?: number; items_new?: number; items_dispatched?: number; error?: string | null; notes?: string | null; log?: string | null }): void {
246
+ ensureSchema();
247
+ db().prepare(`
248
+ UPDATE job_runs
249
+ SET finished_at = datetime('now'),
250
+ status = ?,
251
+ items_seen = COALESCE(?, items_seen),
252
+ items_new = COALESCE(?, items_new),
253
+ items_dispatched = COALESCE(?, items_dispatched),
254
+ error = ?,
255
+ notes = COALESCE(?, notes),
256
+ log = COALESCE(?, log)
257
+ WHERE id = ?
258
+ `).run(patch.status, patch.items_seen ?? null, patch.items_new ?? null, patch.items_dispatched ?? null, patch.error ?? null, patch.notes ?? null, patch.log ?? null, runId);
259
+ }
260
+
261
+ export function listRuns(jobId: string, limit = 20): JobRun[] {
262
+ ensureSchema();
263
+ const rows = db().prepare('SELECT * FROM job_runs WHERE job_id = ? ORDER BY started_at DESC LIMIT ?').all(jobId, limit) as any[];
264
+ return rows.map(rowToRun);
265
+ }
266
+
267
+ export function getRun(runId: string): JobRun | null {
268
+ ensureSchema();
269
+ const r = db().prepare('SELECT * FROM job_runs WHERE id = ?').get(runId) as any;
270
+ return r ? rowToRun(r) : null;
271
+ }
272
+
273
+ /** True if a previous tick is still running for this job. */
274
+ export function hasInflightRun(jobId: string): boolean {
275
+ ensureSchema();
276
+ const r = db().prepare(`SELECT 1 FROM job_runs WHERE job_id = ? AND status = 'running' AND finished_at IS NULL LIMIT 1`).get(jobId);
277
+ return !!r;
278
+ }
279
+
280
+ // ─── Dedup ────────────────────────────────────────────────
281
+
282
+ /** Returns true if the (job, key) pair was newly inserted. */
283
+ export function markSeen(jobId: string, key: string): boolean {
284
+ ensureSchema();
285
+ const r = db().prepare(`INSERT OR IGNORE INTO job_seen (job_id, dedup_key) VALUES (?, ?)`).run(jobId, key);
286
+ return r.changes > 0;
287
+ }
288
+
289
+ export function resetDedup(jobId: string): number {
290
+ ensureSchema();
291
+ const r = db().prepare('DELETE FROM job_seen WHERE job_id = ?').run(jobId);
292
+ return r.changes;
293
+ }
294
+
295
+ // ─── Dispatches ───────────────────────────────────────────
296
+
297
+ export function recordDispatch(input: {
298
+ job_run_id: string;
299
+ item_key: string;
300
+ item_preview?: string;
301
+ dispatch_type: 'pipeline' | 'chat';
302
+ dispatch_target_id?: string | null;
303
+ status: JobDispatchStatus;
304
+ error?: string | null;
305
+ }): JobDispatch {
306
+ ensureSchema();
307
+ const id = randomUUID().slice(0, 12);
308
+ db().prepare(`
309
+ INSERT INTO job_dispatches (id, job_run_id, item_key, item_preview, dispatch_type, dispatch_target_id, status, error)
310
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
311
+ `).run(id, input.job_run_id, input.item_key, input.item_preview ?? null, input.dispatch_type, input.dispatch_target_id ?? null, input.status, input.error ?? null);
312
+ return db().prepare('SELECT * FROM job_dispatches WHERE id = ?').get(id) as any as JobDispatch;
313
+ }
314
+
315
+ export function listDispatches(runId: string): JobDispatch[] {
316
+ ensureSchema();
317
+ const rows = db().prepare('SELECT * FROM job_dispatches WHERE job_run_id = ? ORDER BY created_at ASC').all(runId) as any[];
318
+ return rows.map(rowToDispatch);
319
+ }