@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
@@ -14,6 +14,7 @@ const CodeViewer = lazy(() => import('./CodeViewer'));
14
14
  const ProjectManager = lazy(() => import('./ProjectManager'));
15
15
  const BrowserPanel = lazy(() => import('./BrowserPanel'));
16
16
  const PipelineView = lazy(() => import('./PipelineView'));
17
+ const JobsView = lazy(() => import('./JobsView'));
17
18
  const HelpDialog = lazy(() => import('./HelpDialog'));
18
19
  const LogViewer = lazy(() => import('./LogViewer'));
19
20
  const SkillsPanel = lazy(() => import('./SkillsPanel'));
@@ -97,7 +98,7 @@ function FloatingBrowser({ onClose }: { onClose: () => void }) {
97
98
  }
98
99
 
99
100
  export default function Dashboard({ user }: { user: any }) {
100
- const [viewMode, setViewMode] = useState<'tasks' | 'sessions' | 'terminal' | 'docs' | 'projects' | 'pipelines' | 'workspace' | 'skills' | 'logs' | 'usage'>('terminal');
101
+ const [viewMode, setViewMode] = useState<'tasks' | 'sessions' | 'terminal' | 'docs' | 'projects' | 'pipelines' | 'jobs' | 'workspace' | 'skills' | 'logs' | 'usage'>('terminal');
101
102
 
102
103
  // Honour `?view=<mode>` from the URL so external links (eg the VSCode
103
104
  // extension) can deep-link straight into a section. Only views that have a
@@ -105,12 +106,18 @@ export default function Dashboard({ user }: { user: any }) {
105
106
  // inside ProjectDetail, so they alias to `projects` (where you pick the
106
107
  // project that contains the workspace/session).
107
108
  useEffect(() => {
108
- const raw = new URLSearchParams(window.location.search).get('view');
109
- if (!raw) return;
110
- const aliases: Record<string, string> = { workspace: 'projects', sessions: 'projects' };
111
- const v = aliases[raw] || raw;
112
- const valid = ['tasks', 'terminal', 'docs', 'projects', 'pipelines', 'skills', 'logs', 'usage'];
113
- if (valid.includes(v)) setViewMode(v as any);
109
+ const params = new URLSearchParams(window.location.search);
110
+ const raw = params.get('view');
111
+ if (raw) {
112
+ const aliases: Record<string, string> = { workspace: 'projects', sessions: 'projects' };
113
+ const v = aliases[raw] || raw;
114
+ const valid = ['tasks', 'terminal', 'docs', 'projects', 'pipelines', 'jobs', 'skills', 'logs', 'usage'];
115
+ if (valid.includes(v)) setViewMode(v as any);
116
+ }
117
+ // Optional deep-link to a specific pipeline run — used by the extension
118
+ // Jobs tab when surfacing a dispatch's target pipeline.
119
+ const pid = params.get('pipelineId');
120
+ if (pid) setPendingPipelineId(pid);
114
121
  }, []);
115
122
  // workspaceProject state kept for forge:open-terminal event compatibility
116
123
  const [workspaceProject, setWorkspaceProject] = useState<{ name: string; path: string } | null>(null);
@@ -180,13 +187,16 @@ export default function Dashboard({ user }: { user: any }) {
180
187
  return () => window.removeEventListener('forge:open-terminal', handler);
181
188
  }, []);
182
189
 
183
- // Listen for navigation events (e.g. from ProjectDetail → Pipelines)
190
+ // Listen for navigation events (e.g. from ProjectDetail → Pipelines, or
191
+ // JobsView → Logs with a search pre-filter).
184
192
  const [pendingPipelineId, setPendingPipelineId] = useState<string | null>(null);
193
+ const [pendingLogSearch, setPendingLogSearch] = useState<string>('');
185
194
  useEffect(() => {
186
195
  const handler = (e: Event) => {
187
- const { view, pipelineId } = (e as CustomEvent).detail;
196
+ const { view, pipelineId, logSearch } = (e as CustomEvent).detail;
188
197
  if (view) setViewMode(view);
189
198
  if (pipelineId) setPendingPipelineId(pipelineId);
199
+ if (logSearch != null) setPendingLogSearch(String(logSearch));
190
200
  };
191
201
  window.addEventListener('forge:navigate', handler);
192
202
  return () => window.removeEventListener('forge:navigate', handler);
@@ -355,20 +365,21 @@ export default function Dashboard({ user }: { user: any }) {
355
365
  Docs
356
366
  </button>
357
367
  <span className="w-[2px] h-4 bg-[var(--text-secondary)]/30 mx-1.5" />
358
- {/* Automation */}
359
- {(['tasks', 'pipelines'] as const).map(mode => (
360
- <button
361
- key={mode}
362
- onClick={() => setViewMode(mode)}
363
- className={`text-[11px] px-2.5 py-0.5 rounded transition-colors ${
364
- viewMode === mode
365
- ? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm'
366
- : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
367
- }`}
368
- >
369
- {{ tasks: 'Tasks', pipelines: 'Pipelines' }[mode]}
370
- </button>
371
- ))}
368
+ {/* Automation — single button that lights up for tasks/pipelines/jobs;
369
+ clicking it lands on whichever sub-view you used last (default Tasks).
370
+ The sub-tab strip below the header switches between the three. */}
371
+ <button
372
+ onClick={() => {
373
+ if (!['tasks', 'pipelines', 'jobs'].includes(viewMode)) setViewMode('jobs');
374
+ }}
375
+ className={`text-[11px] px-2.5 py-0.5 rounded transition-colors ${
376
+ ['tasks', 'pipelines', 'jobs'].includes(viewMode)
377
+ ? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm'
378
+ : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
379
+ }`}
380
+ >
381
+ Automation
382
+ </button>
372
383
  <span className="w-[2px] h-4 bg-[var(--text-secondary)]/30 mx-1.5" />
373
384
  {/* Marketplace */}
374
385
  <button
@@ -383,21 +394,9 @@ export default function Dashboard({ user }: { user: any }) {
383
394
  </button>
384
395
  </div>
385
396
 
386
- {viewMode === 'tasks' && (
387
- <span className="text-[10px] text-[var(--text-secondary)]">
388
- {running.length} running · {queued.length} queued · {tasks.filter(t => t.status === 'done').length} done
389
- </span>
390
- )}
391
397
  </div>
392
398
  <div className="flex items-center gap-2.5">
393
- {viewMode === 'tasks' && (
394
- <button
395
- onClick={() => setShowNewTask(true)}
396
- className="text-[10px] px-2.5 py-1 bg-[var(--accent)] text-white rounded hover:opacity-90"
397
- >
398
- + New Task
399
- </button>
400
- )}
399
+ {/* (Automation context actions moved to the secondary toolbar row below) */}
401
400
  {/* Help */}
402
401
  <button
403
402
  onClick={() => setShowHelp(v => !v)}
@@ -605,6 +604,12 @@ export default function Dashboard({ user }: { user: any }) {
605
604
  >
606
605
  Logs
607
606
  </button>
607
+ <a
608
+ href="/chat"
609
+ className="block w-full text-left text-[11px] px-3 py-1.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]"
610
+ >
611
+ Chat (web)
612
+ </a>
608
613
  <a
609
614
  href="/mobile"
610
615
  className="block w-full text-left text-[11px] px-3 py-1.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]"
@@ -625,6 +630,43 @@ export default function Dashboard({ user }: { user: any }) {
625
630
  </div>
626
631
  </header>
627
632
 
633
+ {/* Automation secondary toolbar — sub-tabs + context actions.
634
+ Lives below the main header so it never squishes the top nav. */}
635
+ {['tasks', 'pipelines', 'jobs'].includes(viewMode) && (
636
+ <div className="h-8 border-b border-[var(--border)] flex items-center justify-between px-4 shrink-0 bg-[var(--bg-tertiary)]/40">
637
+ <div className="flex items-center gap-1">
638
+ {(['jobs', 'pipelines', 'tasks'] as const).map((m) => (
639
+ <button
640
+ key={m}
641
+ onClick={() => setViewMode(m)}
642
+ className={`text-[11px] px-2.5 py-0.5 rounded transition-colors ${
643
+ viewMode === m
644
+ ? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm'
645
+ : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
646
+ }`}
647
+ >
648
+ {{ tasks: 'Tasks', pipelines: 'Pipelines', jobs: 'Jobs' }[m]}
649
+ </button>
650
+ ))}
651
+ {viewMode === 'tasks' && (
652
+ <span className="ml-3 text-[10px] text-[var(--text-secondary)]">
653
+ {running.length} running · {queued.length} queued · {tasks.filter(t => t.status === 'done').length} done
654
+ </span>
655
+ )}
656
+ </div>
657
+ <div className="flex items-center gap-2">
658
+ {viewMode === 'tasks' && (
659
+ <button
660
+ onClick={() => setShowNewTask(true)}
661
+ className="text-[10px] px-2.5 py-1 bg-[var(--accent)] text-white rounded hover:opacity-90"
662
+ >
663
+ + New Task
664
+ </button>
665
+ )}
666
+ </div>
667
+ </div>
668
+ )}
669
+
628
670
  {/* Main content */}
629
671
  <div className="flex-1 flex min-h-0">
630
672
  {viewMode === 'tasks' ? (
@@ -737,6 +779,17 @@ export default function Dashboard({ user }: { user: any }) {
737
779
  </Suspense>
738
780
  )}
739
781
 
782
+ {/* Jobs — scheduled connector polls (Forge web read-mostly; create via extension) */}
783
+ {viewMode === 'jobs' && (
784
+ <Suspense fallback={<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">Loading...</div>}>
785
+ <JobsView
786
+ onViewPipeline={(pipelineId) => {
787
+ setPendingPipelineId(pipelineId);
788
+ setViewMode('pipelines');
789
+ }}
790
+ />
791
+ </Suspense>
792
+ )}
740
793
 
741
794
  {/* Skills */}
742
795
  {viewMode === 'skills' && (
@@ -756,7 +809,7 @@ export default function Dashboard({ user }: { user: any }) {
756
809
  {/* Logs */}
757
810
  {viewMode === 'logs' && (
758
811
  <Suspense fallback={<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">Loading...</div>}>
759
- <LogViewer />
812
+ <LogViewer initialSearch={pendingLogSearch} key={pendingLogSearch || 'all'} />
760
813
  </Suspense>
761
814
  )}
762
815
 
@@ -0,0 +1,361 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Jobs panel for the Forge web UI. Mirrors the extension's JobsTab —
5
+ * list jobs, expand a job to see recent runs, expand a run to see
6
+ * per-item dispatches. Click a dispatch's "open →" to deep-link into
7
+ * the resulting Pipeline run (?view=pipelines&pipelineId=...).
8
+ *
9
+ * Read + run + toggle + delete + reset_dedup. Creation lives in the
10
+ * extension (form is connector-aware); web is read-mostly.
11
+ */
12
+
13
+ import { useEffect, useState, useCallback } from 'react';
14
+
15
+ interface Job {
16
+ id: string;
17
+ name: string;
18
+ enabled: boolean;
19
+ schedule_interval_minutes: number;
20
+ source_connector: string;
21
+ source_tool: string;
22
+ dispatch_type: 'pipeline' | 'chat';
23
+ last_run_at: string | null;
24
+ next_run_at: string | null;
25
+ }
26
+
27
+ interface JobRun {
28
+ id: string;
29
+ job_id: string;
30
+ started_at: string;
31
+ finished_at: string | null;
32
+ status: 'running' | 'ok' | 'error';
33
+ items_seen: number;
34
+ items_new: number;
35
+ items_dispatched: number;
36
+ error: string | null;
37
+ trigger: 'schedule' | 'manual';
38
+ notes?: string | null;
39
+ log?: string | null;
40
+ }
41
+
42
+ interface JobDispatch {
43
+ id: string;
44
+ item_key: string;
45
+ item_preview: string | null;
46
+ dispatch_type: 'pipeline' | 'chat';
47
+ dispatch_target_id: string | null;
48
+ status: 'dispatched' | 'error';
49
+ error: string | null;
50
+ created_at: string;
51
+ }
52
+
53
+ interface Props {
54
+ /** Switch the dashboard to the pipelines view for a specific run. */
55
+ onViewPipeline?: (pipelineId: string) => void;
56
+ }
57
+
58
+ /**
59
+ * Dispatch the Dashboard nav event to jump to the Logs view with a
60
+ * pre-filled search string. Used by the "view logs" buttons here.
61
+ */
62
+ function navigateLogs(search: string) {
63
+ window.dispatchEvent(new CustomEvent('forge:navigate', { detail: { view: 'logs', logSearch: search } }));
64
+ }
65
+
66
+ export default function JobsView({ onViewPipeline }: Props) {
67
+ const [jobs, setJobs] = useState<Job[] | null>(null);
68
+ const [err, setErr] = useState('');
69
+ const [expandedJobId, setExpandedJobId] = useState<string>('');
70
+ const [runsByJob, setRunsByJob] = useState<Record<string, JobRun[]>>({});
71
+
72
+ const refresh = useCallback(async () => {
73
+ try {
74
+ const r = await fetch('/api/jobs');
75
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
76
+ const j = await r.json();
77
+ setJobs(j.jobs || []);
78
+ setErr('');
79
+ } catch (e) {
80
+ setErr(e instanceof Error ? e.message : String(e));
81
+ setJobs([]);
82
+ }
83
+ }, []);
84
+
85
+ useEffect(() => { void refresh(); }, [refresh]);
86
+
87
+ async function loadRuns(jobId: string) {
88
+ try {
89
+ const r = await fetch(`/api/jobs/${encodeURIComponent(jobId)}/runs?limit=20`);
90
+ const j = await r.json();
91
+ setRunsByJob((prev) => ({ ...prev, [jobId]: j.runs || [] }));
92
+ } catch {}
93
+ }
94
+
95
+ function expand(id: string) {
96
+ if (expandedJobId === id) { setExpandedJobId(''); return; }
97
+ setExpandedJobId(id);
98
+ if (!runsByJob[id]) void loadRuns(id);
99
+ }
100
+
101
+ async function fireNow(id: string, opts?: { resetDedup?: boolean }) {
102
+ try {
103
+ const qs = opts?.resetDedup ? '?reset_dedup=1' : '';
104
+ await fetch(`/api/jobs/${encodeURIComponent(id)}/run${qs}`, { method: 'POST' });
105
+ await loadRuns(id);
106
+ } catch (e) { alert(`Fire failed: ${e instanceof Error ? e.message : String(e)}`); }
107
+ }
108
+
109
+ async function toggle(j: Job) {
110
+ try {
111
+ await fetch(`/api/jobs/${encodeURIComponent(j.id)}`, {
112
+ method: 'PATCH',
113
+ headers: { 'Content-Type': 'application/json' },
114
+ body: JSON.stringify({ enabled: !j.enabled }),
115
+ });
116
+ await refresh();
117
+ } catch (e) { alert(`Toggle failed: ${e instanceof Error ? e.message : String(e)}`); }
118
+ }
119
+
120
+ async function remove(id: string) {
121
+ if (!confirm('Delete this job? (cascades runs + dedup)')) return;
122
+ try {
123
+ await fetch(`/api/jobs/${encodeURIComponent(id)}`, { method: 'DELETE' });
124
+ await refresh();
125
+ } catch (e) { alert(`Delete failed: ${e instanceof Error ? e.message : String(e)}`); }
126
+ }
127
+
128
+ async function resetDedup(id: string) {
129
+ if (!confirm('Wipe dedup state? Next tick will re-process every item.')) return;
130
+ try {
131
+ const r = await fetch(`/api/jobs/${encodeURIComponent(id)}/reset_dedup`, { method: 'POST' });
132
+ const j = await r.json();
133
+ alert(`Removed ${j.removed} dedup keys`);
134
+ } catch (e) { alert(`Reset failed: ${e instanceof Error ? e.message : String(e)}`); }
135
+ }
136
+
137
+ return (
138
+ <div className="flex-1 flex flex-col min-h-0 overflow-auto p-4">
139
+ <div className="flex items-center justify-between mb-3">
140
+ <div>
141
+ <h2 className="text-sm font-semibold text-[var(--text-primary)]">Jobs</h2>
142
+ <p className="text-[11px] text-[var(--text-secondary)] mt-0.5">
143
+ Scheduled connector polls — each new item fans out to a Pipeline run or a Chat session.
144
+ Create + edit via the Forge extension (Jobs tab).
145
+ </p>
146
+ </div>
147
+ <div className="flex items-center gap-2">
148
+ <button
149
+ onClick={() => navigateLogs('[jobs]')}
150
+ className="text-[11px] px-2 py-1 border border-[var(--border)] rounded text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
151
+ title="Tail forge.log filtered to [jobs] lines"
152
+ >
153
+ View all job logs →
154
+ </button>
155
+ <button
156
+ onClick={() => void refresh()}
157
+ className="text-[11px] px-2 py-1 border border-[var(--border)] rounded text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
158
+ >
159
+ Refresh
160
+ </button>
161
+ </div>
162
+ </div>
163
+
164
+ {err && <div className="text-[11px] text-[var(--red)] bg-[var(--red)]/10 rounded p-2 mb-3">{err}</div>}
165
+
166
+ {jobs == null ? (
167
+ <div className="text-[11px] text-[var(--text-secondary)] text-center py-8">Loading…</div>
168
+ ) : jobs.length === 0 ? (
169
+ <div className="text-[11px] text-[var(--text-secondary)] text-center py-8">
170
+ (no jobs · open the Forge extension → Jobs tab → + New job)
171
+ </div>
172
+ ) : (
173
+ <div className="space-y-2">
174
+ {jobs.map((j) => (
175
+ <JobRow
176
+ key={j.id}
177
+ job={j}
178
+ expanded={expandedJobId === j.id}
179
+ runs={runsByJob[j.id]}
180
+ onClick={() => expand(j.id)}
181
+ onFire={() => fireNow(j.id)}
182
+ onForceFire={() => fireNow(j.id, { resetDedup: true })}
183
+ onToggle={() => toggle(j)}
184
+ onDelete={() => remove(j.id)}
185
+ onReset={() => resetDedup(j.id)}
186
+ onViewPipeline={onViewPipeline}
187
+ />
188
+ ))}
189
+ </div>
190
+ )}
191
+ </div>
192
+ );
193
+ }
194
+
195
+ function JobRow({ job, expanded, runs, onClick, onFire, onForceFire, onToggle, onDelete, onReset, onViewPipeline }: {
196
+ job: Job; expanded: boolean; runs?: JobRun[];
197
+ onClick: () => void; onFire: () => void; onForceFire: () => void; onToggle: () => void; onDelete: () => void; onReset: () => void;
198
+ onViewPipeline?: (id: string) => void;
199
+ }) {
200
+ return (
201
+ <div className="rounded border border-[var(--border)] bg-[var(--bg-secondary)]">
202
+ <div onClick={onClick} className="px-3 py-2 cursor-pointer flex items-baseline gap-2">
203
+ <span className="text-[10px] text-[var(--text-secondary)] w-3">{expanded ? '▾' : '▸'}</span>
204
+ <div className="flex-1 min-w-0">
205
+ <div className="flex items-baseline gap-2">
206
+ <span className="text-[12px] font-semibold text-[var(--text-primary)] truncate">{job.name}</span>
207
+ <span className={`text-[9px] px-1.5 py-0.5 rounded ${job.enabled ? 'bg-[var(--green)]/15 text-[var(--green)]' : 'bg-[var(--text-secondary)]/15 text-[var(--text-secondary)]'}`}>
208
+ {job.enabled ? 'enabled' : 'disabled'}
209
+ </span>
210
+ </div>
211
+ <div className="text-[10px] text-[var(--text-secondary)] font-mono mt-0.5 truncate">
212
+ {job.source_connector}.{job.source_tool} → {job.dispatch_type} · every {job.schedule_interval_minutes}m
213
+ </div>
214
+ <div className="text-[10px] text-[var(--text-secondary)] mt-0.5">
215
+ {job.last_run_at ? `last ${job.last_run_at}` : 'never run'} · {job.next_run_at ? `next ${job.next_run_at}` : 'next: pending'}
216
+ </div>
217
+ </div>
218
+ </div>
219
+ {expanded && (
220
+ <div className="border-t border-[var(--border)] px-3 py-2 bg-[var(--bg-tertiary)]">
221
+ <div className="flex items-center gap-2 mb-2">
222
+ <button onClick={(e) => { e.stopPropagation(); onFire(); }} className="text-[10px] px-2 py-0.5 border border-[var(--border)] rounded hover:bg-[var(--bg-secondary)]">Run now</button>
223
+ <button onClick={(e) => { e.stopPropagation(); onForceFire(); }} className="text-[10px] px-2 py-0.5 border border-[var(--border)] rounded hover:bg-[var(--bg-secondary)]" title="Reset dedup + run — re-dispatch every item this tick">Force run</button>
224
+ <button onClick={(e) => { e.stopPropagation(); onToggle(); }} className="text-[10px] px-2 py-0.5 border border-[var(--border)] rounded hover:bg-[var(--bg-secondary)]">{job.enabled ? 'Disable' : 'Enable'}</button>
225
+ <button onClick={(e) => { e.stopPropagation(); onReset(); }} className="text-[10px] px-2 py-0.5 border border-[var(--border)] rounded hover:bg-[var(--bg-secondary)]" title="Wipe dedup state">Reset dedup</button>
226
+ <button onClick={(e) => { e.stopPropagation(); navigateLogs(`[jobs] ${job.id}`); }} className="text-[10px] px-2 py-0.5 border border-[var(--border)] rounded hover:bg-[var(--bg-secondary)]" title="Tail forge.log filtered to this job's id">View logs</button>
227
+ <span className="flex-1" />
228
+ <button onClick={(e) => { e.stopPropagation(); onDelete(); }} className="text-[10px] px-2 py-0.5 border border-[var(--red)]/50 text-[var(--red)] rounded hover:bg-[var(--red)]/10">Delete</button>
229
+ </div>
230
+ <div className="text-[10px] font-semibold text-[var(--text-secondary)] uppercase mb-1">Recent runs</div>
231
+ {runs == null ? (
232
+ <div className="text-[10px] text-[var(--text-secondary)]">Loading…</div>
233
+ ) : runs.length === 0 ? (
234
+ <div className="text-[10px] text-[var(--text-secondary)]">(no runs yet)</div>
235
+ ) : (
236
+ <div className="space-y-0.5">
237
+ {runs.map((r) => (
238
+ <RunRow key={r.id} jobId={job.id} run={r} onViewPipeline={onViewPipeline} />
239
+ ))}
240
+ </div>
241
+ )}
242
+ </div>
243
+ )}
244
+ </div>
245
+ );
246
+ }
247
+
248
+ function RunRow({ jobId, run, onViewPipeline }: { jobId: string; run: JobRun; onViewPipeline?: (id: string) => void }) {
249
+ const [expanded, setExpanded] = useState(false);
250
+ const [dispatches, setDispatches] = useState<JobDispatch[] | null>(null);
251
+ const [loading, setLoading] = useState(false);
252
+ const [showLog, setShowLog] = useState(false);
253
+
254
+ async function load() {
255
+ if (dispatches != null || loading) return;
256
+ setLoading(true);
257
+ try {
258
+ const r = await fetch(`/api/jobs/${encodeURIComponent(jobId)}/runs/${encodeURIComponent(run.id)}`);
259
+ const j = await r.json();
260
+ setDispatches(j.dispatches || []);
261
+ } catch {} finally { setLoading(false); }
262
+ }
263
+
264
+ function onToggle() {
265
+ const next = !expanded;
266
+ setExpanded(next);
267
+ if (next) void load();
268
+ }
269
+
270
+ async function copyLog() {
271
+ if (!run.log) return;
272
+ try { await navigator.clipboard.writeText(run.log); } catch {}
273
+ }
274
+
275
+ const dur = run.finished_at && run.started_at
276
+ ? `${Math.round((new Date(run.finished_at).getTime() - new Date(run.started_at).getTime()) / 100) / 10}s`
277
+ : '…';
278
+ const color = run.status === 'ok' ? 'var(--green)' : run.status === 'error' ? 'var(--red)' : 'var(--text-secondary)';
279
+
280
+ return (
281
+ <div className="text-[10.5px]">
282
+ <div onClick={onToggle} className="cursor-pointer flex items-baseline gap-1.5 py-0.5">
283
+ <span className="text-[9px] text-[var(--text-secondary)] w-3">{expanded ? '▾' : '▸'}</span>
284
+ <span className="font-mono text-[var(--text-secondary)]">{run.started_at}</span>
285
+ <span style={{ color }}>{run.status}</span>
286
+ <span className="text-[var(--text-secondary)]">seen={run.items_seen} new={run.items_new} dispatched={run.items_dispatched}</span>
287
+ <span className="text-[var(--text-secondary)]">{dur}</span>
288
+ {run.trigger === 'manual' && <span className="text-[9px] px-1 rounded bg-[var(--text-secondary)]/15 text-[var(--text-secondary)]">manual</span>}
289
+ </div>
290
+ {run.error && <div className="font-mono text-[10px] text-[var(--red)] pl-5 py-0.5">{run.error.slice(0, 300)}</div>}
291
+ {run.notes && <div className="text-[10px] text-[var(--text-secondary)] pl-5 py-0.5 italic">{run.notes}</div>}
292
+ {expanded && (
293
+ <div className="pl-5 pr-1 pb-1 pt-0.5 space-y-0.5 border-l border-[var(--border)] ml-1.5">
294
+ <div className="flex items-center gap-2 py-0.5">
295
+ <span className="text-[10px] text-[var(--text-secondary)] uppercase">Dispatches</span>
296
+ <span className="flex-1" />
297
+ {run.log && (
298
+ <>
299
+ <button
300
+ onClick={() => setShowLog((v) => !v)}
301
+ className="text-[10px] px-1.5 py-0 border border-[var(--border)] rounded hover:bg-[var(--bg-secondary)]"
302
+ >
303
+ {showLog ? 'hide log' : `show log (${run.log.split('\n').length} lines)`}
304
+ </button>
305
+ {showLog && (
306
+ <button onClick={copyLog} className="text-[10px] px-1.5 py-0 border border-[var(--border)] rounded hover:bg-[var(--bg-secondary)]">copy</button>
307
+ )}
308
+ </>
309
+ )}
310
+ </div>
311
+ {showLog && run.log && (
312
+ <pre className="text-[10px] font-mono whitespace-pre-wrap leading-snug p-2 bg-black/30 text-[var(--text-secondary)] border border-[var(--border)] rounded max-h-80 overflow-auto">
313
+ {run.log}
314
+ </pre>
315
+ )}
316
+ {loading ? (
317
+ <div className="text-[10px] text-[var(--text-secondary)]">Loading dispatches…</div>
318
+ ) : dispatches == null || dispatches.length === 0 ? (
319
+ <div className="text-[10px] text-[var(--text-secondary)]">(no dispatches)</div>
320
+ ) : (
321
+ dispatches.map((d) => <DispatchRow key={d.id} d={d} onViewPipeline={onViewPipeline} />)
322
+ )}
323
+ </div>
324
+ )}
325
+ </div>
326
+ );
327
+ }
328
+
329
+ function DispatchRow({ d, onViewPipeline }: { d: JobDispatch; onViewPipeline?: (id: string) => void }) {
330
+ function open() {
331
+ if (!d.dispatch_target_id) return;
332
+ if (d.dispatch_type === 'pipeline' && onViewPipeline) {
333
+ onViewPipeline(d.dispatch_target_id);
334
+ } else if (d.dispatch_type === 'chat') {
335
+ // No web chat surface yet — copy session id for use in the extension.
336
+ navigator.clipboard?.writeText(d.dispatch_target_id).catch(() => {});
337
+ }
338
+ }
339
+
340
+ const statusColor = d.status === 'dispatched' ? 'var(--green)' : 'var(--red)';
341
+
342
+ return (
343
+ <div className="text-[10px] py-0.5">
344
+ <div className="flex items-center gap-1.5">
345
+ <span className="font-mono text-[var(--text-secondary)] w-16 shrink-0 truncate">{d.item_key}</span>
346
+ <span className="flex-1 min-w-0 truncate text-[var(--text-primary)]">{d.item_preview || '(no preview)'}</span>
347
+ <span style={{ color: statusColor }} className="text-[9px]">{d.status}</span>
348
+ {d.dispatch_target_id && (
349
+ <button
350
+ onClick={open}
351
+ className="text-[9px] px-1 border border-[var(--border)] rounded hover:bg-[var(--bg-secondary)]"
352
+ title={d.dispatch_type === 'pipeline' ? 'Open pipeline run' : 'Copy chat session id'}
353
+ >
354
+ {d.dispatch_type === 'pipeline' ? 'open →' : 'copy id'}
355
+ </button>
356
+ )}
357
+ </div>
358
+ {d.error && <div className="font-mono text-[9.5px] text-[var(--red)] pl-1">{d.error.slice(0, 200)}</div>}
359
+ </div>
360
+ );
361
+ }
@@ -2,12 +2,22 @@
2
2
 
3
3
  import { useState, useEffect, useRef, useCallback } from 'react';
4
4
 
5
- export default function LogViewer() {
5
+ interface Props {
6
+ /** Pre-fill the search input — used by deep-links (e.g. JobsView "view logs"). */
7
+ initialSearch?: string;
8
+ }
9
+
10
+ export default function LogViewer({ initialSearch }: Props = {}) {
6
11
  const [lines, setLines] = useState<string[]>([]);
7
12
  const [total, setTotal] = useState(0);
8
13
  const [fileSize, setFileSize] = useState(0);
9
14
  const [filePath, setFilePath] = useState('');
10
- const [search, setSearch] = useState('');
15
+ const [search, setSearch] = useState(initialSearch || '');
16
+
17
+ // If the parent re-mounts us with a different initialSearch, honour it.
18
+ useEffect(() => {
19
+ if (initialSearch != null) setSearch(initialSearch);
20
+ }, [initialSearch]);
11
21
  const [maxLines, setMaxLines] = useState(200);
12
22
  const [autoRefresh, setAutoRefresh] = useState(true);
13
23
  const [processes, setProcesses] = useState<{ pid: string; cpu: string; mem: string; cmd: string }[]>([]);