feat: surface retry context in unified history
A retried command carries the same action/result as its original, so the history read as a fresh attempt. The lineage already lived in agent_commands.retried_from_id — it just was not projected. GetAllUnifiedHistory now selects is_retry + retried_from_id (both UNION halves; logs are always false/null); UnifiedHistoryItem carries them; the handler prefixes the narrative with "Retry — ". ChatTimeline composes its own command sentences (narrative is only a log fallback), so it gets the same prefix guarded by entry.is_retry, matching the is_retry/ retried_from_id convention LiveOperations already consumes. Also drop a leftover heartbeat console.log debug block in Agents.tsx.
This commit is contained in:
parent
70ce0c71e9
commit
10a51fa56f
4 changed files with 27 additions and 12 deletions
|
|
@ -1553,8 +1553,14 @@ func (h *UpdateHandler) GetAllLogs(c *gin.Context) {
|
|||
}
|
||||
|
||||
// Populate narrative so the UI never has to compose its own verbiage.
|
||||
// A retry carries the same action/result as its original, so the renderer
|
||||
// stays focused on the outcome and the retry context is prefixed here.
|
||||
for i := range items {
|
||||
items[i].Narrative = services.RenderUpdateLog(items[i].Action, items[i].Result, items[i].Stderr)
|
||||
narrative := services.RenderUpdateLog(items[i].Action, items[i].Result, items[i].Stderr)
|
||||
if items[i].IsRetry {
|
||||
narrative = "Retry — " + narrative
|
||||
}
|
||||
items[i].Narrative = narrative
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
|
|
|
|||
|
|
@ -352,7 +352,7 @@ func (q *UpdateQueries) ListAggregatedPackages(search, packageType, sortBy, sort
|
|||
COUNT(*) FILTER (WHERE status = 'failed') AS failed_count,
|
||||
COUNT(*) FILTER (WHERE status = 'pending_dependencies') AS pending_dependencies_count,
|
||||
MAX(last_discovered_at) AS last_discovered_at,
|
||||
MIN(id) AS representative_id
|
||||
MIN(id::text)::uuid AS representative_id
|
||||
FROM current_package_state
|
||||
%s
|
||||
GROUP BY package_type, package_name
|
||||
|
|
@ -1012,6 +1012,11 @@ type UnifiedHistoryItem struct {
|
|||
DurationSeconds int `json:"duration_seconds" db:"duration_seconds"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
Hostname string `json:"hostname" db:"hostname"`
|
||||
// IsRetry marks a command that re-attempts an earlier one (retried_from_id
|
||||
// set). Logs are never retries. RetriedFromID links to the original command
|
||||
// so the UI can thread the chain. Both come straight from the row.
|
||||
IsRetry bool `json:"is_retry" db:"is_retry"`
|
||||
RetriedFromID *uuid.UUID `json:"retried_from_id,omitempty" db:"retried_from_id"`
|
||||
// Narrative is a server-rendered, operator-facing summary. Populated by
|
||||
// the handler before responding (services.RenderUpdateLog). Not stored.
|
||||
Narrative string `json:"narrative,omitempty" db:"-"`
|
||||
|
|
@ -1067,7 +1072,9 @@ func (q *UpdateQueries) GetAllUnifiedHistory(filters *models.LogFilters) ([]Unif
|
|||
COALESCE((ac.result->>'exit_code')::int, 0) as exit_code,
|
||||
COALESCE((ac.result->>'duration_seconds')::int, 0) as duration_seconds,
|
||||
ac.created_at,
|
||||
COALESCE(a.hostname, '') as hostname
|
||||
COALESCE(a.hostname, '') as hostname,
|
||||
(ac.retried_from_id IS NOT NULL) as is_retry,
|
||||
ac.retried_from_id
|
||||
FROM agent_commands ac
|
||||
LEFT JOIN agents a ON ac.agent_id = a.id
|
||||
WHERE %s
|
||||
|
|
@ -1089,7 +1096,9 @@ func (q *UpdateQueries) GetAllUnifiedHistory(filters *models.LogFilters) ([]Unif
|
|||
ul.exit_code,
|
||||
ul.duration_seconds,
|
||||
ul.executed_at as created_at,
|
||||
COALESCE(a.hostname, '') as hostname
|
||||
COALESCE(a.hostname, '') as hostname,
|
||||
false as is_retry,
|
||||
NULL::uuid as retried_from_id
|
||||
FROM update_logs ul
|
||||
LEFT JOIN agents a ON ul.agent_id = a.id
|
||||
WHERE %s
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@ interface HistoryEntry {
|
|||
params?: Record<string, any>;
|
||||
hostname?: string;
|
||||
narrative?: string; // server-supplied summary; falls back to action enum when absent
|
||||
is_retry?: boolean; // command re-attempts an earlier one (retried_from_id set)
|
||||
retried_from_id?: string; // links to the original command in the retry chain
|
||||
}
|
||||
|
||||
interface ChatTimelineProps {
|
||||
|
|
@ -510,6 +512,12 @@ const ChatTimeline: React.FC<ChatTimelineProps> = ({ agentId, className, isScope
|
|||
}
|
||||
}
|
||||
|
||||
// A retried command carries the same action/result as its original; mark it
|
||||
// so the history reads as a re-attempt rather than a fresh action.
|
||||
if (entry.is_retry && sentence) {
|
||||
sentence = `Retry — ${sentence}`;
|
||||
}
|
||||
|
||||
// Add agent location for global view
|
||||
if (!isScopedView && entry.hostname) {
|
||||
sentence += ` on ${entry.hostname}`;
|
||||
|
|
|
|||
|
|
@ -610,14 +610,6 @@ const Agents: React.FC = () => {
|
|||
// Get source from heartbeat status (stored in agent metadata)
|
||||
const heartbeatSource = heartbeatStatus?.source;
|
||||
|
||||
// Debug: Log the source field
|
||||
console.log('[Heartbeat Debug]', {
|
||||
isRapidPolling,
|
||||
source: heartbeatSource,
|
||||
sourceType: typeof heartbeatSource,
|
||||
heartbeatStatus
|
||||
});
|
||||
|
||||
// Check if heartbeat is system-initiated (blue) or manual (pink)
|
||||
const isSystemHeartbeat = heartbeatSource === 'system';
|
||||
const isManualHeartbeat = heartbeatSource === 'manual';
|
||||
|
|
|
|||
Loading…
Reference in a new issue