@aion0/forge 0.10.31 → 0.10.33
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.
- package/RELEASE_NOTES.md +16 -4
- package/app/api/activity/summary/route.ts +135 -0
- package/components/ActivityPanel.tsx +266 -0
- package/components/Dashboard.tsx +48 -29
- package/components/PipelineView.tsx +74 -3
- package/components/SkillsPanel.tsx +60 -25
- package/lib/chat/tool-dispatcher.ts +83 -32
- package/lib/help-docs/00-overview.md +14 -0
- package/lib/help-docs/06-skills.md +11 -1
- package/lib/help-docs/12-usage.md +1 -1
- package/lib/help-docs/24-watch.md +36 -11
- package/lib/watch/start-watch-tool.ts +4 -2
- package/package.json +1 -1
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,11 +1,23 @@
|
|
|
1
|
-
# Forge v0.10.
|
|
1
|
+
# Forge v0.10.33
|
|
2
2
|
|
|
3
3
|
Released: 2026-06-02
|
|
4
4
|
|
|
5
|
-
## Changes since v0.10.
|
|
5
|
+
## Changes since v0.10.32
|
|
6
|
+
|
|
7
|
+
### Documentation
|
|
8
|
+
- docs: update help-docs for activity pill, marketplace, usage move, watch builtins
|
|
6
9
|
|
|
7
10
|
### Other
|
|
8
|
-
-
|
|
11
|
+
- refactor(marketplace): Pipelines first + default landing
|
|
12
|
+
- refactor(dashboard): move Activity pill next to Automation tab
|
|
13
|
+
- refactor(dashboard): promote Chat (web) + open standalone routes in new tab
|
|
14
|
+
- refactor(dashboard): promote Settings, add icons to user menu rows
|
|
15
|
+
- refactor(dashboard): move Usage into user menu next to Monitor/Login Status
|
|
16
|
+
- refactor(marketplace): split category dropdown by group
|
|
17
|
+
- perf(pipeline-view): invalidate cache after mutations
|
|
18
|
+
- fix(activity): view link uses forge:navigate event
|
|
19
|
+
- perf(pipeline-view): module-level SWR cache for meta + per-workflow runs
|
|
20
|
+
- feat(activity): top-right Activity pill — running pipelines + upcoming schedules
|
|
9
21
|
|
|
10
22
|
|
|
11
|
-
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.
|
|
23
|
+
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.32...v0.10.33
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /api/activity/summary
|
|
3
|
+
*
|
|
4
|
+
* Small snapshot for the Activity pill / dashboard. Three sections:
|
|
5
|
+
* - running: pipelines currently in flight (status queued|running)
|
|
6
|
+
* - upcoming: enabled schedules sorted by next_run_at ASC
|
|
7
|
+
* - recent: last 8 finished pipelines (succeeded|failed|cancelled)
|
|
8
|
+
*
|
|
9
|
+
* Aim <5KB response, <30ms. No expensive joins, no per-pipeline detail.
|
|
10
|
+
* Front-end refetches every 5s when ActivityPanel is open.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { NextResponse } from 'next/server';
|
|
14
|
+
import { listPipelinesSummary } from '@/lib/pipeline';
|
|
15
|
+
import { listSchedules } from '@/lib/schedules/store';
|
|
16
|
+
|
|
17
|
+
interface RunningRow {
|
|
18
|
+
id: string;
|
|
19
|
+
workflowName: string;
|
|
20
|
+
status: string;
|
|
21
|
+
currentNode: string | null;
|
|
22
|
+
progress: { done: number; total: number };
|
|
23
|
+
createdAt: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface UpcomingRow {
|
|
27
|
+
id: string;
|
|
28
|
+
name: string;
|
|
29
|
+
body_kind: string;
|
|
30
|
+
body_ref: string;
|
|
31
|
+
next_run_at: string | null;
|
|
32
|
+
schedule_kind: string;
|
|
33
|
+
schedule_summary: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface RecentRow {
|
|
37
|
+
id: string;
|
|
38
|
+
workflowName: string;
|
|
39
|
+
status: string;
|
|
40
|
+
completedAt: string | null;
|
|
41
|
+
durationMs: number | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface Summary {
|
|
45
|
+
running: RunningRow[];
|
|
46
|
+
upcoming: UpcomingRow[];
|
|
47
|
+
recent: RecentRow[];
|
|
48
|
+
generated_at: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function scheduleSummary(s: {
|
|
52
|
+
schedule_kind: string;
|
|
53
|
+
schedule_interval_minutes: number;
|
|
54
|
+
schedule_at: string | null;
|
|
55
|
+
schedule_cron: string | null;
|
|
56
|
+
}): string {
|
|
57
|
+
switch (s.schedule_kind) {
|
|
58
|
+
case 'period':
|
|
59
|
+
return s.schedule_interval_minutes >= 60
|
|
60
|
+
? `every ${Math.round(s.schedule_interval_minutes / 60)}h`
|
|
61
|
+
: `every ${s.schedule_interval_minutes}m`;
|
|
62
|
+
case 'cron':
|
|
63
|
+
return `cron: ${s.schedule_cron || '?'}`;
|
|
64
|
+
case 'once':
|
|
65
|
+
return s.schedule_at ? `at ${s.schedule_at}` : 'one-shot';
|
|
66
|
+
case 'manual':
|
|
67
|
+
return 'manual only';
|
|
68
|
+
default:
|
|
69
|
+
return s.schedule_kind;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function GET() {
|
|
74
|
+
// Single listPipelinesSummary call — uses the parse cache, sorted desc.
|
|
75
|
+
// 200 runs cap is plenty; we'll filter into running + recent and discard
|
|
76
|
+
// the rest.
|
|
77
|
+
const all = listPipelinesSummary({ limit: 200 });
|
|
78
|
+
|
|
79
|
+
const running: RunningRow[] = [];
|
|
80
|
+
const recent: RecentRow[] = [];
|
|
81
|
+
for (const p of all) {
|
|
82
|
+
const isActive = p.status === 'running';
|
|
83
|
+
if (isActive && running.length < 30) {
|
|
84
|
+
// Compute progress + current node from the in-memory nodes map.
|
|
85
|
+
let done = 0;
|
|
86
|
+
let current: string | null = null;
|
|
87
|
+
for (const name of p.nodeOrder) {
|
|
88
|
+
const st = p.nodes[name]?.status;
|
|
89
|
+
if (st === 'done' || st === 'skipped') done++;
|
|
90
|
+
else if (!current && st === 'running') current = name;
|
|
91
|
+
}
|
|
92
|
+
running.push({
|
|
93
|
+
id: p.id,
|
|
94
|
+
workflowName: p.workflowName,
|
|
95
|
+
status: p.status,
|
|
96
|
+
currentNode: current,
|
|
97
|
+
progress: { done, total: p.nodeOrder.length },
|
|
98
|
+
createdAt: p.createdAt,
|
|
99
|
+
});
|
|
100
|
+
} else if (!isActive && recent.length < 8) {
|
|
101
|
+
const startedMs = Date.parse(p.createdAt);
|
|
102
|
+
const endedMs = p.completedAt ? Date.parse(p.completedAt) : NaN;
|
|
103
|
+
recent.push({
|
|
104
|
+
id: p.id,
|
|
105
|
+
workflowName: p.workflowName,
|
|
106
|
+
status: p.status,
|
|
107
|
+
completedAt: p.completedAt || null,
|
|
108
|
+
durationMs: isFinite(startedMs) && isFinite(endedMs) ? endedMs - startedMs : null,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Upcoming = enabled schedules sorted by next_run_at ASC, top 10.
|
|
114
|
+
const schedules = listSchedules()
|
|
115
|
+
.filter((s) => s.enabled && s.next_run_at)
|
|
116
|
+
.sort((a, b) => String(a.next_run_at).localeCompare(String(b.next_run_at)))
|
|
117
|
+
.slice(0, 10);
|
|
118
|
+
const upcoming: UpcomingRow[] = schedules.map((s) => ({
|
|
119
|
+
id: s.id,
|
|
120
|
+
name: s.name,
|
|
121
|
+
body_kind: s.body_kind,
|
|
122
|
+
body_ref: s.body_ref,
|
|
123
|
+
next_run_at: s.next_run_at,
|
|
124
|
+
schedule_kind: s.schedule_kind,
|
|
125
|
+
schedule_summary: scheduleSummary(s),
|
|
126
|
+
}));
|
|
127
|
+
|
|
128
|
+
const summary: Summary = {
|
|
129
|
+
running,
|
|
130
|
+
upcoming,
|
|
131
|
+
recent,
|
|
132
|
+
generated_at: new Date().toISOString(),
|
|
133
|
+
};
|
|
134
|
+
return NextResponse.json(summary);
|
|
135
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
interface RunningRow {
|
|
6
|
+
id: string;
|
|
7
|
+
workflowName: string;
|
|
8
|
+
status: string;
|
|
9
|
+
currentNode: string | null;
|
|
10
|
+
progress: { done: number; total: number };
|
|
11
|
+
createdAt: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface UpcomingRow {
|
|
15
|
+
id: string;
|
|
16
|
+
name: string;
|
|
17
|
+
body_kind: string;
|
|
18
|
+
body_ref: string;
|
|
19
|
+
next_run_at: string | null;
|
|
20
|
+
schedule_kind: string;
|
|
21
|
+
schedule_summary: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface RecentRow {
|
|
25
|
+
id: string;
|
|
26
|
+
workflowName: string;
|
|
27
|
+
status: string;
|
|
28
|
+
completedAt: string | null;
|
|
29
|
+
durationMs: number | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface Summary {
|
|
33
|
+
running: RunningRow[];
|
|
34
|
+
upcoming: UpcomingRow[];
|
|
35
|
+
recent: RecentRow[];
|
|
36
|
+
generated_at: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const POLL_OPEN_MS = 5000; // active polling while open
|
|
40
|
+
const POLL_BADGE_MS = 30000; // background polling while closed (just for counts)
|
|
41
|
+
|
|
42
|
+
function timeAgo(iso: string | null): string {
|
|
43
|
+
if (!iso) return '';
|
|
44
|
+
const s = Math.round((Date.now() - Date.parse(iso)) / 1000);
|
|
45
|
+
if (s < 60) return `${s}s ago`;
|
|
46
|
+
if (s < 3600) return `${Math.round(s / 60)}m ago`;
|
|
47
|
+
if (s < 86400) return `${Math.round(s / 3600)}h ago`;
|
|
48
|
+
return `${Math.round(s / 86400)}d ago`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function timeUntil(iso: string | null): string {
|
|
52
|
+
if (!iso) return '';
|
|
53
|
+
const s = Math.round((Date.parse(iso) - Date.now()) / 1000);
|
|
54
|
+
if (s < 0) return 'overdue';
|
|
55
|
+
if (s < 60) return `in ${s}s`;
|
|
56
|
+
if (s < 3600) return `in ${Math.round(s / 60)}m`;
|
|
57
|
+
if (s < 86400) return `in ${Math.round(s / 3600)}h`;
|
|
58
|
+
return `in ${Math.round(s / 86400)}d`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function fmtDuration(ms: number | null): string {
|
|
62
|
+
if (ms == null) return '';
|
|
63
|
+
if (ms < 1000) return `${ms}ms`;
|
|
64
|
+
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
|
|
65
|
+
return `${Math.round(ms / 60_000)}m`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function statusGlyph(s: string): string {
|
|
69
|
+
if (s === 'done') return '✓';
|
|
70
|
+
if (s === 'failed') return '✗';
|
|
71
|
+
if (s === 'cancelled') return '⊘';
|
|
72
|
+
if (s === 'running') return '▶';
|
|
73
|
+
return s;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function statusColor(s: string): string {
|
|
77
|
+
if (s === 'done') return 'text-green-500';
|
|
78
|
+
if (s === 'failed') return 'text-red-400';
|
|
79
|
+
if (s === 'cancelled') return 'text-gray-400';
|
|
80
|
+
if (s === 'running') return 'text-blue-400';
|
|
81
|
+
return 'text-[var(--text-secondary)]';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Activity pill — top-right glanceable status for running pipelines +
|
|
86
|
+
* upcoming schedules. Click to expand a 3-section dropdown. Polls every
|
|
87
|
+
* 5s while open, 30s in background for badge counts.
|
|
88
|
+
*/
|
|
89
|
+
export default function ActivityPanel() {
|
|
90
|
+
const [summary, setSummary] = useState<Summary | null>(null);
|
|
91
|
+
const [open, setOpen] = useState(false);
|
|
92
|
+
const panelRef = useRef<HTMLDivElement | null>(null);
|
|
93
|
+
|
|
94
|
+
const refetch = useCallback(async () => {
|
|
95
|
+
try {
|
|
96
|
+
const r = await fetch('/api/activity/summary');
|
|
97
|
+
if (!r.ok) return;
|
|
98
|
+
const j: Summary = await r.json();
|
|
99
|
+
setSummary(j);
|
|
100
|
+
} catch { /* network blip — silent */ }
|
|
101
|
+
}, []);
|
|
102
|
+
|
|
103
|
+
// Initial fetch + adaptive polling cadence
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
refetch();
|
|
106
|
+
const ms = open ? POLL_OPEN_MS : POLL_BADGE_MS;
|
|
107
|
+
const id = setInterval(refetch, ms);
|
|
108
|
+
return () => clearInterval(id);
|
|
109
|
+
}, [open, refetch]);
|
|
110
|
+
|
|
111
|
+
// Click-outside dismisses
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
if (!open) return;
|
|
114
|
+
const onClick = (e: MouseEvent) => {
|
|
115
|
+
if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
|
|
116
|
+
setOpen(false);
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
window.addEventListener('mousedown', onClick);
|
|
120
|
+
return () => window.removeEventListener('mousedown', onClick);
|
|
121
|
+
}, [open]);
|
|
122
|
+
|
|
123
|
+
const runningCount = summary?.running.length ?? 0;
|
|
124
|
+
const upcomingCount = summary?.upcoming.length ?? 0;
|
|
125
|
+
const recentFailed = (summary?.recent ?? []).filter((r) => r.status === 'failed').length;
|
|
126
|
+
|
|
127
|
+
// Pill chips
|
|
128
|
+
const chips: string[] = [];
|
|
129
|
+
if (runningCount) chips.push(`▶${runningCount}`);
|
|
130
|
+
if (upcomingCount) chips.push(`⏰${upcomingCount}`);
|
|
131
|
+
if (!chips.length) chips.push('✓');
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<div className="relative" ref={panelRef}>
|
|
135
|
+
<button
|
|
136
|
+
onClick={() => setOpen((o) => !o)}
|
|
137
|
+
className={`text-[10px] px-2 py-0.5 rounded border border-[var(--border)] flex items-center gap-1.5
|
|
138
|
+
${runningCount > 0 ? 'text-blue-400 border-blue-500/50' : 'text-[var(--text-secondary)]'}
|
|
139
|
+
hover:text-[var(--text-primary)]`}
|
|
140
|
+
title="Activity — running pipelines + upcoming schedules"
|
|
141
|
+
>
|
|
142
|
+
<span className="font-medium">{chips.join(' ')}</span>
|
|
143
|
+
{recentFailed > 0 && (
|
|
144
|
+
<span className="text-red-400 text-[9px]" title={`${recentFailed} recently failed`}>!{recentFailed}</span>
|
|
145
|
+
)}
|
|
146
|
+
</button>
|
|
147
|
+
|
|
148
|
+
{open && (
|
|
149
|
+
<div
|
|
150
|
+
className="absolute right-0 mt-1 w-[440px] max-h-[70vh] overflow-y-auto bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg shadow-xl z-50"
|
|
151
|
+
onClick={(e) => e.stopPropagation()}
|
|
152
|
+
>
|
|
153
|
+
{summary === null ? (
|
|
154
|
+
<div className="p-6 text-center text-xs text-[var(--text-secondary)]">Loading…</div>
|
|
155
|
+
) : (
|
|
156
|
+
<div className="p-3 space-y-4">
|
|
157
|
+
{/* Running */}
|
|
158
|
+
<Section title="Running" count={runningCount}>
|
|
159
|
+
{runningCount === 0 ? (
|
|
160
|
+
<Empty>Nothing running.</Empty>
|
|
161
|
+
) : (
|
|
162
|
+
summary.running.map((r) => (
|
|
163
|
+
<div key={r.id} className="flex items-start gap-2 text-xs py-1">
|
|
164
|
+
<span className={`${statusColor(r.status)} mt-0.5`}>{statusGlyph(r.status)}</span>
|
|
165
|
+
<div className="flex-1 min-w-0">
|
|
166
|
+
<div className="flex items-center gap-2">
|
|
167
|
+
<span className="text-[var(--text-primary)] font-medium truncate">{r.workflowName}</span>
|
|
168
|
+
<span className="text-[10px] text-[var(--text-secondary)]">
|
|
169
|
+
{r.progress.done}/{r.progress.total}
|
|
170
|
+
</span>
|
|
171
|
+
<span className="text-[10px] text-gray-500">{timeAgo(r.createdAt)}</span>
|
|
172
|
+
</div>
|
|
173
|
+
{r.currentNode && (
|
|
174
|
+
<div className="text-[10px] text-[var(--text-secondary)] mt-0.5">
|
|
175
|
+
current: {r.currentNode}
|
|
176
|
+
</div>
|
|
177
|
+
)}
|
|
178
|
+
</div>
|
|
179
|
+
<button
|
|
180
|
+
onClick={() => {
|
|
181
|
+
setOpen(false);
|
|
182
|
+
window.dispatchEvent(new CustomEvent('forge:navigate', { detail: { view: 'pipelines', pipelineId: r.id } }));
|
|
183
|
+
}}
|
|
184
|
+
className="text-[10px] px-1.5 py-0.5 rounded border border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
|
185
|
+
>
|
|
186
|
+
view
|
|
187
|
+
</button>
|
|
188
|
+
</div>
|
|
189
|
+
))
|
|
190
|
+
)}
|
|
191
|
+
</Section>
|
|
192
|
+
|
|
193
|
+
{/* Upcoming */}
|
|
194
|
+
<Section title="Next up" count={upcomingCount}>
|
|
195
|
+
{upcomingCount === 0 ? (
|
|
196
|
+
<Empty>No enabled schedules.</Empty>
|
|
197
|
+
) : (
|
|
198
|
+
summary.upcoming.map((u) => (
|
|
199
|
+
<div key={u.id} className="flex items-start gap-2 text-xs py-1">
|
|
200
|
+
<span className="text-[var(--text-secondary)] mt-0.5">⏰</span>
|
|
201
|
+
<div className="flex-1 min-w-0">
|
|
202
|
+
<div className="flex items-center gap-2">
|
|
203
|
+
<span className="text-[var(--text-primary)] font-medium truncate">{u.name}</span>
|
|
204
|
+
<span className="text-[10px] text-blue-400">{timeUntil(u.next_run_at)}</span>
|
|
205
|
+
</div>
|
|
206
|
+
<div className="text-[10px] text-[var(--text-secondary)] mt-0.5 truncate">
|
|
207
|
+
{u.body_kind}: {u.body_ref} · {u.schedule_summary}
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
210
|
+
</div>
|
|
211
|
+
))
|
|
212
|
+
)}
|
|
213
|
+
</Section>
|
|
214
|
+
|
|
215
|
+
{/* Recent */}
|
|
216
|
+
<Section title="Recent" count={summary.recent.length}>
|
|
217
|
+
{summary.recent.length === 0 ? (
|
|
218
|
+
<Empty>No recent runs.</Empty>
|
|
219
|
+
) : (
|
|
220
|
+
summary.recent.map((r) => (
|
|
221
|
+
<div key={r.id} className="flex items-start gap-2 text-xs py-1">
|
|
222
|
+
<span className={`${statusColor(r.status)} mt-0.5`}>{statusGlyph(r.status)}</span>
|
|
223
|
+
<div className="flex-1 min-w-0">
|
|
224
|
+
<div className="flex items-center gap-2">
|
|
225
|
+
<span className="text-[var(--text-primary)] truncate">{r.workflowName}</span>
|
|
226
|
+
<span className="text-[10px] text-[var(--text-secondary)]">
|
|
227
|
+
{fmtDuration(r.durationMs)} · {timeAgo(r.completedAt)}
|
|
228
|
+
</span>
|
|
229
|
+
</div>
|
|
230
|
+
</div>
|
|
231
|
+
<button
|
|
232
|
+
onClick={() => {
|
|
233
|
+
setOpen(false);
|
|
234
|
+
window.dispatchEvent(new CustomEvent('forge:navigate', { detail: { view: 'pipelines', pipelineId: r.id } }));
|
|
235
|
+
}}
|
|
236
|
+
className="text-[10px] px-1.5 py-0.5 rounded border border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
|
237
|
+
>
|
|
238
|
+
view
|
|
239
|
+
</button>
|
|
240
|
+
</div>
|
|
241
|
+
))
|
|
242
|
+
)}
|
|
243
|
+
</Section>
|
|
244
|
+
</div>
|
|
245
|
+
)}
|
|
246
|
+
</div>
|
|
247
|
+
)}
|
|
248
|
+
</div>
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function Section({ title, count, children }: { title: string; count: number; children: React.ReactNode }) {
|
|
253
|
+
return (
|
|
254
|
+
<div>
|
|
255
|
+
<h3 className="text-[10px] font-semibold text-[var(--text-secondary)] uppercase mb-1 flex items-center gap-2">
|
|
256
|
+
{title}
|
|
257
|
+
<span className="text-gray-500 font-normal">({count})</span>
|
|
258
|
+
</h3>
|
|
259
|
+
<div className="space-y-0.5">{children}</div>
|
|
260
|
+
</div>
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function Empty({ children }: { children: React.ReactNode }) {
|
|
265
|
+
return <div className="text-[10px] text-gray-500 italic py-0.5">{children}</div>;
|
|
266
|
+
}
|
package/components/Dashboard.tsx
CHANGED
|
@@ -25,6 +25,7 @@ const NewTaskModal = lazy(() => import('./NewTaskModal'));
|
|
|
25
25
|
const SettingsModal = lazy(() => import('./SettingsModal'));
|
|
26
26
|
const MonitorPanel = lazy(() => import('./MonitorPanel'));
|
|
27
27
|
const LoginStatusPanel = lazy(() => import('./LoginStatusPanel'));
|
|
28
|
+
const ActivityPanel = lazy(() => import('./ActivityPanel'));
|
|
28
29
|
const WorkspaceView = lazy(() => import('./WorkspaceView'));
|
|
29
30
|
// WorkspaceTree moved into ProjectDetail — no longer needed at Dashboard level
|
|
30
31
|
|
|
@@ -398,6 +399,11 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
398
399
|
>
|
|
399
400
|
Automation
|
|
400
401
|
</button>
|
|
402
|
+
{/* Activity sub-pill — sits next to Automation since its content
|
|
403
|
+
(running pipelines + upcoming schedules + recent runs) is the
|
|
404
|
+
live read-side of Automation. Click anywhere → dropdown with
|
|
405
|
+
3 sections + a "view" jump to the run. */}
|
|
406
|
+
<Suspense fallback={null}><ActivityPanel /></Suspense>
|
|
401
407
|
<span className="w-[2px] h-4 bg-[var(--text-secondary)]/30 mx-1.5" />
|
|
402
408
|
{/* Marketplace */}
|
|
403
409
|
<button
|
|
@@ -465,14 +471,6 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
465
471
|
</>
|
|
466
472
|
)}
|
|
467
473
|
</div>
|
|
468
|
-
<button
|
|
469
|
-
onClick={() => setViewMode('usage')}
|
|
470
|
-
className={`text-[10px] px-2 py-0.5 border rounded transition-colors ${
|
|
471
|
-
viewMode === 'usage'
|
|
472
|
-
? 'border-[var(--accent)] text-[var(--accent)]'
|
|
473
|
-
: 'border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--text-secondary)]'
|
|
474
|
-
}`}
|
|
475
|
-
>Usage</button>
|
|
476
474
|
<Suspense fallback={null}><TunnelToggle /></Suspense>
|
|
477
475
|
{onlineCount.total > 0 && (
|
|
478
476
|
<span className="text-[10px] text-[var(--text-secondary)] flex items-center gap-1" title={`${onlineCount.total} online${onlineCount.remote > 0 ? `, ${onlineCount.remote} remote` : ''}`}>
|
|
@@ -618,52 +616,73 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
618
616
|
{showUserMenu && (
|
|
619
617
|
<>
|
|
620
618
|
<div className="fixed inset-0 z-40" onClick={() => setShowUserMenu(false)} />
|
|
621
|
-
<div className="absolute right-0 top-8 w-[
|
|
619
|
+
<div className="absolute right-0 top-8 w-[170px] bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg shadow-xl z-50 py-1">
|
|
620
|
+
{/* Settings promoted to the top — most-reached item in this
|
|
621
|
+
menu. Each row leads with a glyph so it's scannable. */}
|
|
622
|
+
<button
|
|
623
|
+
onClick={() => { setShowSettings(true); setShowUserMenu(false); }}
|
|
624
|
+
className="w-full text-left text-[11px] px-3 py-1.5 text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] flex items-center gap-2"
|
|
625
|
+
>
|
|
626
|
+
<span className="w-3 text-center">⚙</span><span>Settings</span>
|
|
627
|
+
</button>
|
|
628
|
+
{/* Chat (web) is a separate route — open in a new tab so
|
|
629
|
+
the dashboard isn't replaced. Promoted up here so it's
|
|
630
|
+
one of the prominent items, not buried below Logs. */}
|
|
631
|
+
<a
|
|
632
|
+
href="/chat"
|
|
633
|
+
target="_blank"
|
|
634
|
+
rel="noopener noreferrer"
|
|
635
|
+
className="block w-full text-left text-[11px] px-3 py-1.5 text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] flex items-center gap-2"
|
|
636
|
+
>
|
|
637
|
+
<span className="w-3 text-center">💬</span>
|
|
638
|
+
<span className="flex-1">Chat (web)</span>
|
|
639
|
+
<span className="text-[9px] text-[var(--text-secondary)]">↗</span>
|
|
640
|
+
</a>
|
|
641
|
+
<div className="border-t border-[var(--border)] my-1" />
|
|
622
642
|
<button
|
|
623
643
|
onClick={() => { setShowMonitor(true); setShowUserMenu(false); }}
|
|
624
|
-
className="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)]"
|
|
644
|
+
className="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)] flex items-center gap-2"
|
|
625
645
|
>
|
|
626
|
-
Monitor
|
|
646
|
+
<span className="w-3 text-center">📊</span><span>Monitor</span>
|
|
627
647
|
</button>
|
|
628
648
|
<button
|
|
629
649
|
onClick={() => { setShowLoginStatus(true); setShowUserMenu(false); }}
|
|
630
|
-
className="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)] flex items-center
|
|
650
|
+
className="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)] flex items-center gap-2"
|
|
631
651
|
>
|
|
632
|
-
<span
|
|
652
|
+
<span className="w-3 text-center">🔐</span>
|
|
653
|
+
<span className="flex-1">Login Status</span>
|
|
633
654
|
{loginBadge && loginBadge.broken > 0 && (
|
|
634
|
-
<span className="text-[9px] text-red-400"
|
|
655
|
+
<span className="text-[9px] text-red-400">{loginBadge.broken}/{loginBadge.total}</span>
|
|
635
656
|
)}
|
|
636
657
|
</button>
|
|
637
658
|
<button
|
|
638
|
-
onClick={() => {
|
|
639
|
-
className="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)]"
|
|
659
|
+
onClick={() => { setViewMode('usage'); setShowUserMenu(false); }}
|
|
660
|
+
className="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)] flex items-center gap-2"
|
|
640
661
|
>
|
|
641
|
-
|
|
662
|
+
<span className="w-3 text-center">💰</span><span>Usage</span>
|
|
642
663
|
</button>
|
|
643
664
|
<button
|
|
644
665
|
onClick={() => { setViewMode('logs'); setShowUserMenu(false); }}
|
|
645
|
-
className="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)]"
|
|
666
|
+
className="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)] flex items-center gap-2"
|
|
646
667
|
>
|
|
647
|
-
Logs
|
|
668
|
+
<span className="w-3 text-center">📜</span><span>Logs</span>
|
|
648
669
|
</button>
|
|
649
|
-
<a
|
|
650
|
-
href="/chat"
|
|
651
|
-
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)]"
|
|
652
|
-
>
|
|
653
|
-
Chat (web)
|
|
654
|
-
</a>
|
|
655
670
|
<a
|
|
656
671
|
href="/mobile"
|
|
657
|
-
|
|
672
|
+
target="_blank"
|
|
673
|
+
rel="noopener noreferrer"
|
|
674
|
+
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)] flex items-center gap-2"
|
|
658
675
|
>
|
|
659
|
-
|
|
676
|
+
<span className="w-3 text-center">📱</span>
|
|
677
|
+
<span className="flex-1">Mobile View</span>
|
|
678
|
+
<span className="text-[9px] text-[var(--text-secondary)]">↗</span>
|
|
660
679
|
</a>
|
|
661
680
|
<div className="border-t border-[var(--border)] my-1" />
|
|
662
681
|
<button
|
|
663
682
|
onClick={() => signOut({ callbackUrl: '/login' })}
|
|
664
|
-
className="w-full text-left text-[11px] px-3 py-1.5 text-[var(--text-secondary)] hover:text-[var(--red)] hover:bg-[var(--bg-tertiary)]"
|
|
683
|
+
className="w-full text-left text-[11px] px-3 py-1.5 text-[var(--text-secondary)] hover:text-[var(--red)] hover:bg-[var(--bg-tertiary)] flex items-center gap-2"
|
|
665
684
|
>
|
|
666
|
-
Logout
|
|
685
|
+
<span className="w-3 text-center">⏻</span><span>Logout</span>
|
|
667
686
|
</button>
|
|
668
687
|
</div>
|
|
669
688
|
</>
|
|
@@ -7,6 +7,42 @@ import type { TaskLogEntry } from '@/src/types';
|
|
|
7
7
|
const PipelineEditor = lazy(() => import('./PipelineEditor'));
|
|
8
8
|
const ConversationEditor = lazy(() => import('./ConversationEditor'));
|
|
9
9
|
|
|
10
|
+
// ─── Module-level SWR cache ──────────────────────────────
|
|
11
|
+
// Survives tab switches (but not page refresh). On mount we hydrate
|
|
12
|
+
// state from cache instantly if present (<30s old) and revalidate in
|
|
13
|
+
// the background. Eliminates the "blank then slow load" feeling when
|
|
14
|
+
// users switch away and come back to this tab. Cache is per-component-
|
|
15
|
+
// module — entries are flushed when the page reloads.
|
|
16
|
+
const _metaCache: { ts: number; workflows: any[]; projects: any[]; agents: any[] } | null = null as any;
|
|
17
|
+
const _runsCache = new Map<string, { ts: number; runs: any[] }>();
|
|
18
|
+
const META_TTL_MS = 30_000;
|
|
19
|
+
const RUNS_TTL_MS = 15_000;
|
|
20
|
+
let _metaCacheRef: typeof _metaCache = _metaCache;
|
|
21
|
+
|
|
22
|
+
function readMetaCache() {
|
|
23
|
+
if (_metaCacheRef && Date.now() - _metaCacheRef.ts < META_TTL_MS) return _metaCacheRef;
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
function writeMetaCache(workflows: any[], projects: any[], agents: any[]) {
|
|
27
|
+
_metaCacheRef = { ts: Date.now(), workflows, projects, agents };
|
|
28
|
+
}
|
|
29
|
+
function readRunsCache(workflow: string) {
|
|
30
|
+
const c = _runsCache.get(workflow);
|
|
31
|
+
if (c && Date.now() - c.ts < RUNS_TTL_MS) return c;
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
function writeRunsCache(workflow: string, runs: any[]) {
|
|
35
|
+
_runsCache.set(workflow, { ts: Date.now(), runs });
|
|
36
|
+
}
|
|
37
|
+
// Invalidate after user mutations (delete/import/reinstall) so the next
|
|
38
|
+
// fetchMeta/fetchWorkflowRuns doesn't hydrate stale UI from cache while
|
|
39
|
+
// the live refetch is in flight.
|
|
40
|
+
function clearMetaCache() { _metaCacheRef = null; }
|
|
41
|
+
function clearRunsCache(workflow?: string) {
|
|
42
|
+
if (workflow) _runsCache.delete(workflow);
|
|
43
|
+
else _runsCache.clear();
|
|
44
|
+
}
|
|
45
|
+
|
|
10
46
|
// ─── Live Task Log Hook ──────────────────────────────────
|
|
11
47
|
// Subscribes to SSE stream for a running task, returns live log entries
|
|
12
48
|
function useTaskStream(taskId: string | undefined, isRunning: boolean) {
|
|
@@ -698,6 +734,7 @@ export default function PipelineView({ onViewTask, focusPipelineId, onFocusHandl
|
|
|
698
734
|
});
|
|
699
735
|
const data = await res.json();
|
|
700
736
|
if (!data.ok) { setMarketErr(data.error || 'install failed'); return; }
|
|
737
|
+
clearMetaCache();
|
|
701
738
|
await Promise.all([fetchMarketplace(), fetchData()]);
|
|
702
739
|
} catch (e) {
|
|
703
740
|
setMarketErr(e instanceof Error ? e.message : String(e));
|
|
@@ -743,25 +780,52 @@ export default function PipelineView({ onViewTask, focusPipelineId, onFocusHandl
|
|
|
743
780
|
// Lightweight metadata only — no pipeline runs. Runs are lazy-loaded
|
|
744
781
|
// per-workflow on click. Reduces initial page to a single workflow
|
|
745
782
|
// table fetch + projects + agents, even with hundreds of runs on disk.
|
|
783
|
+
//
|
|
784
|
+
// SWR pattern: if module-level cache is fresh, hydrate UI instantly
|
|
785
|
+
// and revalidate in background. First visit per page-load still pays
|
|
786
|
+
// the full fetch cost; tab switches feel instant.
|
|
746
787
|
const fetchMeta = useCallback(async () => {
|
|
788
|
+
const cached = readMetaCache();
|
|
789
|
+
if (cached) {
|
|
790
|
+
setWorkflows(cached.workflows);
|
|
791
|
+
setProjects(cached.projects.map((p: any) => ({ name: p.name, path: p.path })));
|
|
792
|
+
setAgents(cached.agents);
|
|
793
|
+
// Fall through to background refetch — comment out to make TTL strict.
|
|
794
|
+
}
|
|
747
795
|
const [wRes, projRes, agentRes] = await Promise.all([
|
|
748
796
|
fetch('/api/pipelines?type=workflows'),
|
|
749
797
|
fetch('/api/projects'),
|
|
750
798
|
fetch('/api/agents'),
|
|
751
799
|
]);
|
|
752
800
|
const [wData, projData, agentData] = await Promise.all([wRes.json(), projRes.json(), agentRes.json()]);
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
801
|
+
const ws = Array.isArray(wData) ? wData : [];
|
|
802
|
+
const ps = Array.isArray(projData) ? projData : [];
|
|
803
|
+
const ags = Array.isArray(agentData?.agents) ? agentData.agents : [];
|
|
804
|
+
if (ws.length) setWorkflows(ws);
|
|
805
|
+
if (ps.length) setProjects(ps.map((p: any) => ({ name: p.name, path: p.path })));
|
|
806
|
+
if (ags.length) setAgents(ags);
|
|
807
|
+
writeMetaCache(ws, ps, ags);
|
|
756
808
|
}, []);
|
|
757
809
|
|
|
758
810
|
// Fetch the run history for ONE workflow. Used on first expand + on
|
|
759
811
|
// the 5s polling tick (only for the currently active workflow).
|
|
760
812
|
const fetchWorkflowRuns = useCallback(async (workflowName: string, opts: { append?: boolean } = {}) => {
|
|
813
|
+
// SWR for the first expand of a workflow tab (not for append=true
|
|
814
|
+
// "load more" calls, those want fresh server pagination).
|
|
815
|
+
if (!opts.append) {
|
|
816
|
+
const cached = readRunsCache(workflowName);
|
|
817
|
+
if (cached) {
|
|
818
|
+
setPipelines((prev) => {
|
|
819
|
+
const others = prev.filter((p) => p.workflowName !== workflowName);
|
|
820
|
+
return [...others, ...cached.runs];
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
}
|
|
761
824
|
try {
|
|
762
825
|
const res = await fetch(`/api/pipelines?workflow=${encodeURIComponent(workflowName)}&limit=100`);
|
|
763
826
|
const data: Pipeline[] = await res.json();
|
|
764
827
|
if (!Array.isArray(data)) return;
|
|
828
|
+
if (!opts.append) writeRunsCache(workflowName, data);
|
|
765
829
|
setPipelines(prev => {
|
|
766
830
|
if (!opts.append) {
|
|
767
831
|
// Replace this workflow's runs (covers status changes during polling),
|
|
@@ -890,6 +954,8 @@ export default function PipelineView({ onViewTask, focusPipelineId, onFocusHandl
|
|
|
890
954
|
body: JSON.stringify({ action: 'delete' }),
|
|
891
955
|
});
|
|
892
956
|
if (selectedPipeline?.id === id) setSelectedPipeline(null);
|
|
957
|
+
const wf = pipelines.find((p) => p.id === id)?.workflowName;
|
|
958
|
+
clearRunsCache(wf);
|
|
893
959
|
fetchData();
|
|
894
960
|
};
|
|
895
961
|
|
|
@@ -1015,6 +1081,8 @@ initial_prompt: "{{input.task}}"
|
|
|
1015
1081
|
});
|
|
1016
1082
|
const data = await res.json();
|
|
1017
1083
|
if (!data.ok) { setMarketErr(data.error || 'reinstall failed'); return; }
|
|
1084
|
+
clearMetaCache();
|
|
1085
|
+
clearRunsCache(r.name);
|
|
1018
1086
|
await Promise.all([fetchMarketplace(), fetchData()]);
|
|
1019
1087
|
alert(`"${r.name}" reinstalled from registry (v${r.version}).`);
|
|
1020
1088
|
} catch (e) {
|
|
@@ -1052,6 +1120,7 @@ initial_prompt: "{{input.task}}"
|
|
|
1052
1120
|
});
|
|
1053
1121
|
const data = await res.json();
|
|
1054
1122
|
if (!data.ok) { setMarketErr(data.error || 'import failed'); return; }
|
|
1123
|
+
clearMetaCache();
|
|
1055
1124
|
await Promise.all([fetchMarketplace(), fetchData()]);
|
|
1056
1125
|
alert(`Imported as "${data.installed_as}". Open it from the Workflows list.`);
|
|
1057
1126
|
} catch (e) {
|
|
@@ -1287,6 +1356,8 @@ initial_prompt: "{{input.task}}"
|
|
|
1287
1356
|
const data = await res.json();
|
|
1288
1357
|
if (!res.ok || data.error) { alert(`Delete failed: ${data.error || res.status}`); return; }
|
|
1289
1358
|
if (activeWorkflow === w.name) setActiveWorkflow(null);
|
|
1359
|
+
clearMetaCache();
|
|
1360
|
+
clearRunsCache(w.name);
|
|
1290
1361
|
fetchData();
|
|
1291
1362
|
} catch { alert('Delete failed'); }
|
|
1292
1363
|
}}
|
|
@@ -142,7 +142,7 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
|
|
|
142
142
|
const [syncing, setSyncing] = useState(false);
|
|
143
143
|
const [loading, setLoading] = useState(true);
|
|
144
144
|
const [installTarget, setInstallTarget] = useState<{ skill: string; show: boolean }>({ skill: '', show: false });
|
|
145
|
-
const [typeFilter, setTypeFilter] = useState<'all' | 'skill' | 'command' | 'local' | 'rules' | 'plugins' | 'connectors' | 'crafts' | 'recipes' | 'pipelines'>('
|
|
145
|
+
const [typeFilter, setTypeFilter] = useState<'all' | 'skill' | 'command' | 'local' | 'rules' | 'plugins' | 'connectors' | 'crafts' | 'recipes' | 'pipelines'>('pipelines');
|
|
146
146
|
const [localItems, setLocalItems] = useState<{ name: string; type: string; scope: string; fileCount: number; projectPath?: string }[]>([]);
|
|
147
147
|
// Rules (CLAUDE.md templates)
|
|
148
148
|
const [rulesTemplates, setRulesTemplates] = useState<{ id: string; name: string; description: string; tags: string[]; builtin: boolean; isDefault: boolean; content: string }[]>([]);
|
|
@@ -414,30 +414,65 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
|
|
|
414
414
|
<div className="flex items-center justify-between px-4 py-2 border-b border-[var(--border)] shrink-0">
|
|
415
415
|
<div className="flex items-center gap-2">
|
|
416
416
|
<span className="text-xs font-semibold text-[var(--text-primary)]">Marketplace</span>
|
|
417
|
-
{/*
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
417
|
+
{/* Three group-scoped dropdowns instead of one big <select>.
|
|
418
|
+
Order is by usage frequency: Extensions (Connectors lives
|
|
419
|
+
here) → Templates → Catalog. Each dropdown highlights when
|
|
420
|
+
its current value is active; the inactive ones show the
|
|
421
|
+
group label as a placeholder. Picking from any sets the
|
|
422
|
+
single global typeFilter. */}
|
|
423
|
+
{(() => {
|
|
424
|
+
const groups: Array<{ label: string; opts: Array<{ value: typeof typeFilter; label: string }> }> = [
|
|
425
|
+
{ label: 'Templates', opts: [
|
|
426
|
+
{ value: 'pipelines', label: 'Pipelines' },
|
|
427
|
+
]},
|
|
428
|
+
{ label: 'Extensions', opts: [
|
|
429
|
+
{ value: 'connectors', label: 'Connectors' },
|
|
430
|
+
{ value: 'plugins', label: 'Plugins' },
|
|
431
|
+
{ value: 'crafts', label: 'Crafts' },
|
|
432
|
+
]},
|
|
433
|
+
{ label: 'Catalog', opts: [
|
|
434
|
+
{ value: 'all', label: `All (${skills.length})` },
|
|
435
|
+
{ value: 'skill', label: `Skills (${skillCount})` },
|
|
436
|
+
{ value: 'command', label: `Commands (${commandCount})` },
|
|
437
|
+
{ value: 'local', label: `Local (${localCount})` },
|
|
438
|
+
{ value: 'rules', label: 'Rules' },
|
|
439
|
+
]},
|
|
440
|
+
];
|
|
441
|
+
return groups.map((g) => {
|
|
442
|
+
const isActive = g.opts.some((o) => o.value === typeFilter);
|
|
443
|
+
if (g.opts.length === 1) {
|
|
444
|
+
const only = g.opts[0];
|
|
445
|
+
return (
|
|
446
|
+
<button
|
|
447
|
+
key={g.label}
|
|
448
|
+
onClick={() => setTypeFilter(only.value)}
|
|
449
|
+
className={`text-[10px] px-2 py-1 rounded border ${
|
|
450
|
+
isActive
|
|
451
|
+
? 'border-[var(--accent)] text-[var(--accent)] bg-[var(--accent)]/10'
|
|
452
|
+
: 'border-[var(--border)] text-[var(--text-primary)] bg-[var(--bg-tertiary)] hover:border-[var(--text-secondary)]'
|
|
453
|
+
}`}
|
|
454
|
+
>{only.label}</button>
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
return (
|
|
458
|
+
<select
|
|
459
|
+
key={g.label}
|
|
460
|
+
value={isActive ? (typeFilter as string) : ''}
|
|
461
|
+
onChange={(e) => { if (e.target.value) setTypeFilter(e.target.value as typeof typeFilter); }}
|
|
462
|
+
className={`text-[10px] px-2 py-1 rounded bg-[var(--bg-tertiary)] border focus:outline-none ${
|
|
463
|
+
isActive
|
|
464
|
+
? 'border-[var(--accent)] text-[var(--accent)]'
|
|
465
|
+
: 'border-[var(--border)] text-[var(--text-primary)] focus:border-[var(--accent)]'
|
|
466
|
+
}`}
|
|
467
|
+
>
|
|
468
|
+
<option value="" disabled hidden>{g.label}</option>
|
|
469
|
+
{g.opts.map((o) => (
|
|
470
|
+
<option key={o.value} value={o.value as string}>{o.label}</option>
|
|
471
|
+
))}
|
|
472
|
+
</select>
|
|
473
|
+
);
|
|
474
|
+
});
|
|
475
|
+
})()}
|
|
441
476
|
</div>
|
|
442
477
|
<span className="text-[8px] px-1.5 py-0.5 rounded bg-blue-500/15 text-blue-400">Claude Code</span>
|
|
443
478
|
<input
|
|
@@ -163,50 +163,61 @@ const BUILTINS: Record<string, BuiltinHandler> = {
|
|
|
163
163
|
}
|
|
164
164
|
|
|
165
165
|
const pipeline = startPipeline(params.workflow, stringInput, { skills: skills.length ? skills : undefined });
|
|
166
|
-
|
|
166
|
+
const fresh = getPipeline(pipeline.id) || pipeline;
|
|
167
|
+
const errors: string[] = [];
|
|
167
168
|
if (pipeline.status === 'failed') {
|
|
168
|
-
const fresh = getPipeline(pipeline.id) || pipeline;
|
|
169
|
-
const errs: string[] = [];
|
|
170
169
|
for (const [nid, n] of Object.entries(fresh.nodes || {})) {
|
|
171
|
-
if ((n as any).error)
|
|
170
|
+
if ((n as any).error) errors.push(`${nid}: ${(n as any).error}`);
|
|
172
171
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
// failure mode (empty source). Warn the LLM explicitly so it doesn't
|
|
177
|
-
// claim success.
|
|
178
|
-
const fresh = getPipeline(pipeline.id) || pipeline;
|
|
172
|
+
}
|
|
173
|
+
let warning: string | undefined;
|
|
174
|
+
if (pipeline.status === 'done') {
|
|
179
175
|
const forEach = (fresh as any).forEach;
|
|
180
176
|
if (forEach && typeof forEach === 'object') {
|
|
181
177
|
const iters = Array.isArray(forEach.iterations) ? forEach.iterations.length : 0;
|
|
182
178
|
const total = typeof forEach.total === 'number' ? forEach.total : iters;
|
|
183
179
|
if (total === 0) {
|
|
184
|
-
|
|
180
|
+
warning = 'Pipeline finished with 0 iterations — likely empty source list. This is NOT a success; the work the user asked for did NOT happen. Re-check input fields (especially the one feeding for_each.source) and retry.';
|
|
185
181
|
}
|
|
186
182
|
}
|
|
187
|
-
} else {
|
|
188
|
-
line += '. Watch progress in the Pipelines view.';
|
|
189
183
|
}
|
|
190
|
-
return
|
|
184
|
+
return JSON.stringify({
|
|
185
|
+
ok: pipeline.status !== 'failed',
|
|
186
|
+
pipeline_id: pipeline.id,
|
|
187
|
+
workflow: params.workflow,
|
|
188
|
+
status: pipeline.status,
|
|
189
|
+
terminal: pipeline.status !== 'running',
|
|
190
|
+
...(errors.length ? { errors: errors.join(' | ').slice(0, 500) } : {}),
|
|
191
|
+
...(warning ? { warning } : {}),
|
|
192
|
+
hint: pipeline.status === 'running'
|
|
193
|
+
? 'To wait for completion: start_watch poll="get_pipeline_status" poll_args={pipeline_id:"' + pipeline.id + '"} done_match={path:"status",equals:"done"} fail_path="status==failed". Do NOT poll get_pipeline_status in this conversation.'
|
|
194
|
+
: undefined,
|
|
195
|
+
});
|
|
191
196
|
},
|
|
192
197
|
|
|
193
198
|
// Query a pipeline run's status + per-node results by id (pairs with
|
|
194
|
-
// trigger_pipeline's returned id).
|
|
199
|
+
// trigger_pipeline's returned id). Returns JSON so start_watch's
|
|
200
|
+
// done_match={path:"status",equals:"done"} can resolve — a string-shaped
|
|
201
|
+
// response leaves watches polling forever.
|
|
195
202
|
get_pipeline_status: async (input) => {
|
|
196
203
|
const params = (input as { pipeline_id?: string } | undefined) || {};
|
|
197
|
-
if (!params.pipeline_id) return
|
|
204
|
+
if (!params.pipeline_id) return JSON.stringify({ ok: false, error: 'pipeline_id is required (returned by trigger_pipeline)' });
|
|
198
205
|
const { getPipeline } = await import('../pipeline');
|
|
199
206
|
const pipeline = getPipeline(params.pipeline_id);
|
|
200
|
-
if (!pipeline) return `Pipeline "${params.pipeline_id}" not found
|
|
201
|
-
const nodes = Object.entries(pipeline.nodes || {}).map(([id, n]) => {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
207
|
+
if (!pipeline) return JSON.stringify({ ok: false, error: `Pipeline "${params.pipeline_id}" not found` });
|
|
208
|
+
const nodes = Object.entries(pipeline.nodes || {}).map(([id, n]) => ({
|
|
209
|
+
id,
|
|
210
|
+
status: n.status,
|
|
211
|
+
...(n.error ? { error: n.error } : {}),
|
|
212
|
+
outputs: Object.fromEntries(Object.entries(n.outputs || {}).map(([k, v]) => [k, String(v).slice(0, 500)])),
|
|
213
|
+
}));
|
|
214
|
+
return JSON.stringify({
|
|
215
|
+
id: pipeline.id,
|
|
216
|
+
status: pipeline.status,
|
|
217
|
+
terminal: pipeline.status !== 'running',
|
|
218
|
+
workflowName: pipeline.workflowName,
|
|
219
|
+
nodes,
|
|
220
|
+
});
|
|
210
221
|
},
|
|
211
222
|
|
|
212
223
|
// Surface Forge's local context (projects + agents + skills) so the chat
|
|
@@ -260,11 +271,11 @@ const BUILTINS: Record<string, BuiltinHandler> = {
|
|
|
260
271
|
// caller can ask "what's the status of task <id>?" later — we don't block.
|
|
261
272
|
dispatch_task: async (input) => {
|
|
262
273
|
const params = (input as { project?: string; prompt?: string; agent?: string } | undefined) || {};
|
|
263
|
-
if (!params.prompt) return
|
|
274
|
+
if (!params.prompt) return JSON.stringify({ ok: false, error: 'prompt is required' });
|
|
264
275
|
const { getProjectInfo, SCRATCH_PROJECT_NAME } = await import('../projects');
|
|
265
276
|
const projectName = params.project?.trim() || SCRATCH_PROJECT_NAME;
|
|
266
277
|
const project = getProjectInfo(projectName);
|
|
267
|
-
if (!project) return
|
|
278
|
+
if (!project) return JSON.stringify({ ok: false, error: `project "${projectName}" not found` });
|
|
268
279
|
const { createTask } = await import('../task-manager');
|
|
269
280
|
const task = createTask({
|
|
270
281
|
projectName: project.name,
|
|
@@ -273,7 +284,33 @@ const BUILTINS: Record<string, BuiltinHandler> = {
|
|
|
273
284
|
conversationId: '',
|
|
274
285
|
agent: params.agent || undefined,
|
|
275
286
|
});
|
|
276
|
-
return
|
|
287
|
+
return JSON.stringify({
|
|
288
|
+
ok: true,
|
|
289
|
+
task_id: task.id,
|
|
290
|
+
project: project.name,
|
|
291
|
+
status: task.status,
|
|
292
|
+
hint: 'To wait for completion: start_watch poll="get_task_status" poll_args={task_id:"' + task.id + '"} done_path="terminal". Do NOT poll get_task_status in this conversation.',
|
|
293
|
+
});
|
|
294
|
+
},
|
|
295
|
+
|
|
296
|
+
// Companion to dispatch_task — read a task's status + result. Returns JSON
|
|
297
|
+
// so start_watch can poll via done_path="terminal" or done_match
|
|
298
|
+
// {path:"status", equals:"done"}.
|
|
299
|
+
get_task_status: async (input) => {
|
|
300
|
+
const params = (input as { task_id?: string } | undefined) || {};
|
|
301
|
+
if (!params.task_id) return JSON.stringify({ ok: false, error: 'task_id is required (returned by dispatch_task)' });
|
|
302
|
+
const { getTask } = await import('../task-manager');
|
|
303
|
+
const task = getTask(params.task_id);
|
|
304
|
+
if (!task) return JSON.stringify({ ok: false, error: `Task "${params.task_id}" not found` });
|
|
305
|
+
return JSON.stringify({
|
|
306
|
+
id: task.id,
|
|
307
|
+
status: task.status,
|
|
308
|
+
terminal: task.status === 'done' || task.status === 'failed' || task.status === 'cancelled',
|
|
309
|
+
project: task.projectName,
|
|
310
|
+
...(task.resultSummary ? { result_summary: String(task.resultSummary).slice(0, 1000) } : {}),
|
|
311
|
+
...(task.error ? { error: String(task.error).slice(0, 500) } : {}),
|
|
312
|
+
...(task.completedAt ? { completed_at: task.completedAt } : {}),
|
|
313
|
+
});
|
|
277
314
|
},
|
|
278
315
|
|
|
279
316
|
// List Forge's own help/documentation files so the chat agent can answer
|
|
@@ -315,7 +352,7 @@ export const BUILTIN_TOOL_DEFS: BuiltinToolDef[] = [
|
|
|
315
352
|
},
|
|
316
353
|
{
|
|
317
354
|
name: 'trigger_pipeline',
|
|
318
|
-
description: 'Trigger a Forge pipeline workflow (YAML under flows/). Two-step usage: (1) call with NO args first — returns every workflow + its input schema (which fields are required vs have defaults). (2) call again with workflow=<name> and input={...} passing ONLY required fields and any optional fields the user explicitly specified. NEVER pass invented placeholder values for optional fields with defaults — omit them and the default is used. If the
|
|
355
|
+
description: 'Trigger a Forge pipeline workflow (YAML under flows/). Two-step usage: (1) call with NO args first — returns every workflow + its input schema (which fields are required vs have defaults). (2) call again with workflow=<name> and input={...} passing ONLY required fields and any optional fields the user explicitly specified. NEVER pass invented placeholder values for optional fields with defaults — omit them and the default is used. On success returns JSON: {ok, pipeline_id, workflow, status, terminal, errors?, warning?, hint?}. If the run is still running, follow the hint — call start_watch on get_pipeline_status and STOP polling in this conversation.',
|
|
319
356
|
input_schema: {
|
|
320
357
|
type: 'object',
|
|
321
358
|
properties: {
|
|
@@ -337,7 +374,7 @@ export const BUILTIN_TOOL_DEFS: BuiltinToolDef[] = [
|
|
|
337
374
|
},
|
|
338
375
|
{
|
|
339
376
|
name: 'get_pipeline_status',
|
|
340
|
-
description: "Check a Forge pipeline run's live status + per-node results by id. Pass pipeline_id (returned by trigger_pipeline) to get
|
|
377
|
+
description: "Check a Forge pipeline run's live status + per-node results by id. Pass pipeline_id (returned by trigger_pipeline) to get a JSON object: {id, status: 'running'|'done'|'failed'|'cancelled', terminal: bool, workflowName, nodes: [{id, status, error?, outputs}]}. Use whenever the user asks how a running or finished pipeline is doing. For start_watch, pair this with done_match={path:\"status\",equals:\"done\"} (or done_path=\"terminal\" to fire on ANY terminal state) and fail_path=... per your needs.",
|
|
341
378
|
input_schema: {
|
|
342
379
|
type: 'object',
|
|
343
380
|
properties: {
|
|
@@ -356,7 +393,7 @@ export const BUILTIN_TOOL_DEFS: BuiltinToolDef[] = [
|
|
|
356
393
|
},
|
|
357
394
|
{
|
|
358
395
|
name: 'dispatch_task',
|
|
359
|
-
description: 'Dispatch a one-shot background Claude task in a Forge project. Use for longer-running asks the user wants to fire-and-forget ("analyze X codebase and write findings to a file", "run the test suite and summarize failures"). Returns
|
|
396
|
+
description: 'Dispatch a one-shot background Claude task in a Forge project. Use for longer-running asks the user wants to fire-and-forget ("analyze X codebase and write findings to a file", "run the test suite and summarize failures"). Returns JSON: {ok, task_id, project, status, hint}. The task runs in the background; if the user wants to be notified on completion, follow the hint — call start_watch on get_task_status and STOP polling in this conversation.',
|
|
360
397
|
input_schema: {
|
|
361
398
|
type: 'object',
|
|
362
399
|
properties: {
|
|
@@ -376,6 +413,20 @@ export const BUILTIN_TOOL_DEFS: BuiltinToolDef[] = [
|
|
|
376
413
|
required: ['prompt'],
|
|
377
414
|
},
|
|
378
415
|
},
|
|
416
|
+
{
|
|
417
|
+
name: 'get_task_status',
|
|
418
|
+
description: "Check a dispatched Forge task's status + result by id. Pass task_id (returned by dispatch_task). Returns JSON: {id, status: 'queued'|'running'|'done'|'failed'|'cancelled', terminal: bool, project, result_summary?, error?, completed_at?}. For start_watch, use done_path=\"terminal\" (fires on done/failed/cancelled) or done_match={path:\"status\",equals:\"done\"}.",
|
|
419
|
+
input_schema: {
|
|
420
|
+
type: 'object',
|
|
421
|
+
properties: {
|
|
422
|
+
task_id: {
|
|
423
|
+
type: 'string',
|
|
424
|
+
description: 'Task id (returned by dispatch_task).',
|
|
425
|
+
},
|
|
426
|
+
},
|
|
427
|
+
required: ['task_id'],
|
|
428
|
+
},
|
|
429
|
+
},
|
|
379
430
|
{
|
|
380
431
|
name: 'list_help_docs',
|
|
381
432
|
description: "List Forge's own documentation files. Call this FIRST whenever the user asks how Forge itself works — its features, settings/config, setup, or troubleshooting (e.g. pipelines, schedules, connectors, telegram, tunnel, workspace/smiths, skills, crafts, usage/cost, agents/models). Returns doc filenames; then read the relevant one(s) with read_help_doc and answer from their content. No arguments.",
|
|
@@ -57,3 +57,17 @@ forge server start --port 4000 # custom port
|
|
|
57
57
|
forge server start --dir ~/.forge-test # custom data dir
|
|
58
58
|
forge --reset-password # reset admin password
|
|
59
59
|
```
|
|
60
|
+
|
|
61
|
+
## Dashboard top bar
|
|
62
|
+
|
|
63
|
+
The top toolbar is split between **at-a-glance signals** (left to right) and a **user menu** (right edge):
|
|
64
|
+
|
|
65
|
+
- **? Help** — opens the in-app Help AI.
|
|
66
|
+
- **Browser ▾** — open an embedded browser (float / right / left dock or external tab).
|
|
67
|
+
- **Tunnel** — start/stop the Cloudflare tunnel + online-count badge.
|
|
68
|
+
- **Alerts** — notifications (task done, pipeline failed, tunnel events).
|
|
69
|
+
|
|
70
|
+
Next to the **Automation** tab in the left-side nav sits a small **Activity** sub-pill — running pipelines + upcoming schedules + recent runs (`▶<running>` `⏰<upcoming>`). Click for a 3-section dropdown with a "view" link that jumps to the run. It lives there because its content is the live read-side of Automation.
|
|
71
|
+
- **User menu (▾)** — `⚙ Settings` + `💬 Chat (web) ↗` at the top (Chat opens in a new tab so the dashboard isn't replaced); then a divider, then the periodic-check screens `📊 Monitor` (background watches, processes, queues), `🔐 Login Status` (connector creds), `💰 Usage` (token/cost analytics), `📜 Logs`, `📱 Mobile View ↗`; then `⏻ Logout`.
|
|
72
|
+
|
|
73
|
+
Periodic-check screens (Monitor / Login Status / Usage) live inside the user menu so the top bar only shows things worth glancing at.
|
|
@@ -16,11 +16,21 @@ Both register as `/slash-command` in Claude Code.
|
|
|
16
16
|
|
|
17
17
|
## Install
|
|
18
18
|
|
|
19
|
-
1. Go to **
|
|
19
|
+
1. Go to **Marketplace** tab in Forge
|
|
20
20
|
2. Click **Sync** to fetch latest registry
|
|
21
21
|
3. Click **Install** on any skill → choose Global or specific project
|
|
22
22
|
4. Use in Claude Code with `/<skill-name>`
|
|
23
23
|
|
|
24
|
+
## Navigating the Marketplace
|
|
25
|
+
|
|
26
|
+
The toolbar has three group-scoped controls, ordered by usage frequency. Opening the Marketplace tab lands on **Pipelines** by default — the highest-traffic category.
|
|
27
|
+
|
|
28
|
+
- **Pipelines** (button) — Templates synced from `forge-workflow`. Default landing view.
|
|
29
|
+
- **Extensions ▾** — Connectors / Plugins / Crafts
|
|
30
|
+
- **Catalog ▾** — All / Skills / Commands / Local / Rules
|
|
31
|
+
|
|
32
|
+
The control whose value is currently active highlights in the accent color; the other two show their group label as a placeholder. Click any option to switch — no need to traverse a single long dropdown.
|
|
33
|
+
|
|
24
34
|
## Update
|
|
25
35
|
|
|
26
36
|
Skills with newer versions show a yellow "update" indicator. Click to update (checks for local modifications first).
|
|
@@ -4,7 +4,7 @@ Forge tracks Claude API token usage and estimated costs across all your projects
|
|
|
4
4
|
|
|
5
5
|
## Access
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Open the **user menu** (top-right `▾`) and click **Usage**. It sits next to Monitor and Login Status — the three periodic-check screens are grouped there so the top toolbar only carries at-a-glance signals.
|
|
8
8
|
|
|
9
9
|
## Data Source
|
|
10
10
|
|
|
@@ -47,25 +47,50 @@ Both use the same background machinery; you experience them identically.
|
|
|
47
47
|
## `start_watch` (for the assistant)
|
|
48
48
|
|
|
49
49
|
When you've just started a long job and have a tool that reports its
|
|
50
|
-
status, register a watch instead of polling in the conversation
|
|
50
|
+
status, register a watch instead of polling in the conversation. Two
|
|
51
|
+
poll forms are accepted:
|
|
52
|
+
|
|
53
|
+
**1. Connector tool** — `"<connector>.<tool>"`, e.g. `jenkins.get_build`:
|
|
51
54
|
|
|
52
55
|
```
|
|
53
56
|
start_watch({
|
|
54
|
-
poll: "jenkins.get_build",
|
|
57
|
+
poll: "jenkins.get_build",
|
|
55
58
|
poll_args: { job_path: "job/foo", build_number: 18 },
|
|
56
|
-
done_match: { path: "result", equals: "SUCCESS" },
|
|
59
|
+
done_match: { path: "result", equals: "SUCCESS" },
|
|
57
60
|
interval_sec: 60, timeout_sec: 1800,
|
|
58
|
-
message: "Build 18 finished: {poll.result}"
|
|
61
|
+
message: "Build 18 finished: {poll.result}"
|
|
62
|
+
})
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
**2. Bare builtin name** — Forge's own status readers:
|
|
66
|
+
|
|
67
|
+
| Builtin | Pair with | done condition |
|
|
68
|
+
|---|---|---|
|
|
69
|
+
| `get_pipeline_status` | `trigger_pipeline` → pipeline_id | `done_match={path:"status",equals:"done"}` (or `done_path:"terminal"`) |
|
|
70
|
+
| `get_task_status` | `dispatch_task` → task_id | `done_path:"terminal"` |
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
start_watch({
|
|
74
|
+
poll: "get_pipeline_status", // bare builtin, no prefix
|
|
75
|
+
poll_args: { pipeline_id: "a8e049a3" },
|
|
76
|
+
done_path: "terminal",
|
|
77
|
+
message: "Pipeline {poll.workflowName} finished: {poll.status}"
|
|
59
78
|
})
|
|
60
79
|
```
|
|
61
80
|
|
|
62
|
-
Then **stop** — do not keep calling
|
|
63
|
-
completion message arrives in chat; the user can cancel it from the
|
|
64
|
-
list. Pick `done_match`/`done_path` from a field you saw in the
|
|
65
|
-
tool's output (you usually called it once already). Queue/startup
|
|
66
|
-
(e.g. a build number 404 while still queued) are tolerated for a
|
|
67
|
-
Guards (max polls, timeout, lifetime, active cap) keep it from
|
|
68
|
-
away; worst case it times out and reports that.
|
|
81
|
+
Then **stop** — do not keep calling the status tool in the conversation.
|
|
82
|
+
A completion message arrives in chat; the user can cancel it from the
|
|
83
|
+
watch list. Pick `done_match`/`done_path` from a field you saw in the
|
|
84
|
+
status tool's output (you usually called it once already). Queue/startup
|
|
85
|
+
errors (e.g. a build number 404 while still queued) are tolerated for a
|
|
86
|
+
while. Guards (max polls, timeout, lifetime, active cap) keep it from
|
|
87
|
+
running away; worst case it times out and reports that.
|
|
88
|
+
|
|
89
|
+
### `_raw` fallback for non-JSON tools
|
|
90
|
+
|
|
91
|
+
The poll result is parsed as JSON. If the tool returns plain text (some
|
|
92
|
+
shell-protocol tools), Forge wraps it as `{_raw: "...full output..."}`
|
|
93
|
+
so you can still grep — e.g. `done_match={path:"_raw",contains:"DONE"}`.
|
|
69
94
|
|
|
70
95
|
## Limits
|
|
71
96
|
|
|
@@ -36,11 +36,13 @@ export function buildStartWatchTool(sessionId: string | null): StartWatchTool {
|
|
|
36
36
|
name: 'start_watch',
|
|
37
37
|
description:
|
|
38
38
|
'Register a BACKGROUND WATCH that polls a tool until done, then posts the result back here — for long-running jobs you just kicked off (a Forge pipeline, a Jenkins build, a test run, a device upgrade). Use this INSTEAD of polling in conversation: call the trigger tool, then call start_watch and STOP — Forge polls in the background and a completion message arrives in this chat. ' +
|
|
39
|
-
'Pick `poll` = the read tool that reports status. Two forms accepted: (a) connector tool "<connector>.<tool>" e.g. "jenkins.get_build", "gitlab.get_pipeline"; (b) BARE builtin name
|
|
39
|
+
'Pick `poll` = the read tool that reports status. Two forms accepted: (a) connector tool "<connector>.<tool>" e.g. "jenkins.get_build", "gitlab.get_pipeline"; (b) BARE builtin name — "get_pipeline_status" (pair with poll_args={pipeline_id} and done_match={path:"status",equals:"done"}) or "get_task_status" (pair with poll_args={task_id} and done_path="terminal"). Set `poll_args` to call it with (e.g. the build number you predicted via get_next_build_number). ' +
|
|
40
|
+
'Give a done condition: `done_match` {path, equals} on the poll result (e.g. path "result" equals "SUCCESS"), or `done_path` (a result path that becomes truthy). You usually already saw the poll tool\'s output once, so you know the right field. Optional `fail_path` (truthy = failed). ' +
|
|
41
|
+
'If the poll tool returns NON-JSON text (e.g. some shell-protocol tools), the result is wrapped as {_raw: "...full output..."}, so use done_match={path:"_raw",contains:"DONE"} to grep markers in the output. Tune `interval_sec`/`timeout_sec` to the job (pipeline ≈ 30s / 1800s, build ≈ 60s / 1800s).',
|
|
40
42
|
input_schema: {
|
|
41
43
|
type: 'object',
|
|
42
44
|
properties: {
|
|
43
|
-
poll: { type: 'string', description: 'Tool to poll. Either "<connector>.<tool>" e.g. "jenkins.get_build", OR a bare builtin name
|
|
45
|
+
poll: { type: 'string', description: 'Tool to poll. Either "<connector>.<tool>" e.g. "jenkins.get_build", OR a bare builtin name — "get_pipeline_status" (Forge pipelines) or "get_task_status" (dispatched tasks). Must be a read/status tool.' },
|
|
44
46
|
poll_args: { type: 'object', description: 'Args to call the poll tool with each tick, e.g. {"job_path":"job/foo","build_number":18}. Concrete values, not templates.' },
|
|
45
47
|
done_match: {
|
|
46
48
|
type: 'object',
|
package/package.json
CHANGED