From 47ac50a098e746fc99e558d3858dcfde6707cc5f Mon Sep 17 00:00:00 2001 From: Ben G Date: Mon, 23 Mar 2026 19:41:31 +0100 Subject: [PATCH 1/3] fix: as any type fix: isRateLimitError fix: agentfieldclient.ts as any fix: agent.ts --- sdk/typescript/src/agent/Agent.ts | 90 ++++++----- sdk/typescript/src/ai/RateLimiter.ts | 117 +++++++++++--- sdk/typescript/src/ai/ToolCalling.ts | 10 +- sdk/typescript/src/client/AgentFieldClient.ts | 153 +++++++++++++----- sdk/typescript/src/types/agent.ts | 27 +++- 5 files changed, 282 insertions(+), 115 deletions(-) diff --git a/sdk/typescript/src/agent/Agent.ts b/sdk/typescript/src/agent/Agent.ts index 95a8cfde9..e1b822cd1 100644 --- a/sdk/typescript/src/agent/Agent.ts +++ b/sdk/typescript/src/agent/Agent.ts @@ -9,7 +9,8 @@ import type { DeploymentType, HealthStatus, ServerlessEvent, - ServerlessResponse + ServerlessResponse, + RawExecutionContext } from '../types/agent.js'; import { ReasonerRegistry } from './ReasonerRegistry.js'; import { SkillRegistry } from './SkillRegistry.js'; @@ -41,11 +42,32 @@ import type { MCPToolRegistration } from '../types/mcp.js'; import { MCPClientRegistry } from '../mcp/MCPClientRegistry.js'; import { MCPToolRegistrar } from '../mcp/MCPToolRegistrar.js'; import { LocalVerifier } from '../verification/LocalVerifier.js'; +import type { Request, Response } from 'express'; +import type { ParamsDictionary } from 'express-serve-static-core'; +interface WildcardParams extends ParamsDictionary { + 0: string; +} class TargetNotFoundError extends Error {} const harnessRunners = new WeakMap(); + + +function normalizeExecutionContext(ctx: RawExecutionContext): Partial { + return { + executionId: ctx.executionId ?? ctx.execution_id, + runId: ctx.runId ?? ctx.run_id, + workflowId: ctx.workflowId ?? ctx.workflow_id, + parentExecutionId: ctx.parentExecutionId ?? ctx.parent_execution_id, + sessionId: ctx.sessionId ?? ctx.session_id, + actorId: ctx.actorId ?? ctx.actor_id, + callerDid: ctx.callerDid ?? ctx.caller_did, + targetDid: ctx.targetDid ?? ctx.target_did, + agentNodeDid: ctx.agentNodeDid ?? ctx.agent_node_did + }; +} + export class Agent { readonly config: AgentConfig; readonly app: express.Express; @@ -605,10 +627,10 @@ export class Agent { }); } - this.app.post('/api/v1/reasoners/*', (req, res) => this.executeReasoner(req, res, (req.params as any)[0])); + this.app.post('/api/v1/reasoners/*', (req: Request, res: Response) => this.executeReasoner(req, res, req.params[0])); this.app.post('/reasoners/:name', (req, res) => this.executeReasoner(req, res, req.params.name)); - this.app.post('/api/v1/skills/*', (req, res) => this.executeSkill(req, res, (req.params as any)[0])); + this.app.post('/api/v1/skills/*', (req: Request, res: Response) => this.executeSkill(req, res, req.params[0])); this.app.post('/skills/:name', (req, res) => this.executeSkill(req, res, req.params.name)); // Serverless-friendly execute endpoint that accepts { target, input } or { reasoner, input } @@ -737,7 +759,7 @@ export class Agent { private handleHttpRequest(req: http.IncomingMessage | express.Request, res: http.ServerResponse | express.Response) { const handler = this.app as unknown as (req: http.IncomingMessage, res: http.ServerResponse) => void; - return handler(req as any, res as any); + return handler(req as http.IncomingMessage, res as http.ServerResponse); } private async handleServerlessEvent(event: ServerlessEvent): Promise { @@ -752,7 +774,7 @@ export class Agent { }; } - const body = this.normalizeEventBody(event); + const body = event?.body !== undefined ? this.parseBody(event.body): event; const invocation = this.extractInvocationDetails({ path, query: event?.queryStringParameters, @@ -800,44 +822,23 @@ export class Agent { } private normalizeEventBody(event: ServerlessEvent) { - const parsed = this.parseBody((event as any)?.body); - if (parsed && typeof parsed === 'object' && event?.input !== undefined && (parsed as any).input === undefined) { - return { ...(parsed as Record), input: event.input }; - } - if ((parsed === undefined || parsed === null) && event?.input !== undefined) { - return { input: event.input }; + interface ParsedBody { + input?: unknown; + data?: unknown; + [key: string]: unknown; } + + const parsed = this.parseBody(event?.body) as ParsedBody | undefined; + + if (parsed?.input !== undefined) return parsed.input; + if (parsed?.data !== undefined) return parsed.data; + return parsed; } private mergeExecutionContext(event: ServerlessEvent): Partial { - const ctx = (event?.executionContext ?? (event as any)?.execution_context) as Partial< - ExecutionMetadata & { - execution_id?: string; - run_id?: string; - workflow_id?: string; - parent_execution_id?: string; - session_id?: string; - actor_id?: string; - caller_did?: string; - target_did?: string; - agent_node_did?: string; - } - >; - - if (!ctx) return {}; - - return { - executionId: (ctx as any).executionId ?? ctx.execution_id ?? ctx.executionId, - runId: ctx.runId ?? (ctx as any).run_id, - workflowId: ctx.workflowId ?? (ctx as any).workflow_id, - parentExecutionId: ctx.parentExecutionId ?? (ctx as any).parent_execution_id, - sessionId: ctx.sessionId ?? (ctx as any).session_id, - actorId: ctx.actorId ?? (ctx as any).actor_id, - callerDid: (ctx as any).callerDid ?? (ctx as any).caller_did, - targetDid: (ctx as any).targetDid ?? (ctx as any).target_did, - agentNodeDid: (ctx as any).agentNodeDid ?? (ctx as any).agent_node_did - }; + const rawCtx = event?.executionContext ?? event?.execution_context; + return rawCtx ? normalizeExecutionContext(rawCtx) : {}; } private extractInvocationDetails(params: { @@ -922,12 +923,15 @@ export class Agent { if (parsed && typeof parsed === 'object') { const { target, reasoner, skill, type, targetType, ...rest } = parsed as Record; - if ((parsed as any).input !== undefined) { - return (parsed as any).input; - } - if ((parsed as any).data !== undefined) { - return (parsed as any).data; + interface ParsedBody { + input?: any; + data?: any; + [key: string]: any; } + + const parsedBody = parsed as ParsedBody; + if (parsedBody.input !== undefined) return parsedBody.input; + if (parsedBody.data !== undefined) return parsedBody.data; if (Object.keys(rest).length === 0) { return {}; } diff --git a/sdk/typescript/src/ai/RateLimiter.ts b/sdk/typescript/src/ai/RateLimiter.ts index c9a6767c4..3766e2be8 100644 --- a/sdk/typescript/src/ai/RateLimiter.ts +++ b/sdk/typescript/src/ai/RateLimiter.ts @@ -3,11 +3,33 @@ import os from 'node:os'; export class RateLimitError extends Error { retryAfter?: number; - - constructor(message: string, retryAfter?: number) { + status?: number; + statusCode?: number; + response?: { + status?: number; + statusCode?: number; + status_code?: number; + headers?: Record; + }; + + constructor( + message: string, + retryAfter?: number, + status?: number, + statusCode?: number, + response?: { + status?: number; + statusCode?: number; + status_code?: number; + headers?: Record; + } + ) { super(message); this.name = 'RateLimitError'; this.retryAfter = retryAfter; + this.status = status; + this.statusCode = statusCode; + this.response = response; } } @@ -57,26 +79,38 @@ export class StatelessRateLimiter { protected _isRateLimitError(error: unknown): boolean { if (!error) return false; - const err = error as any; - const className = err?.constructor?.name; - if (className && className.includes('RateLimitError')) { + if (error instanceof RateLimitError) { return true; } - const response = err?.response; + if ( + typeof error === 'object' && + error !== null && + typeof (error as { constructor?: { name?: string } }).constructor?.name === 'string' && + (error as { constructor: { name: string } }).constructor.name.includes('RateLimitError') + ) { + return true; + } + + if (isRateLimitError(error)) { const statusCandidates = [ - err?.status, - err?.statusCode, - response?.status, - response?.statusCode, - response?.status_code + error.status, + error.statusCode, + error.response?.status, + error.response?.statusCode, + error.response?.status_code, ]; - if (statusCandidates.some((code: any) => code === 429 || code === 503)) { + if (statusCandidates.some((code) => code === 429 || code === 503)) { return true; } - const message = String(err?.message ?? err ?? '').toLowerCase(); + if (error.retryAfter !== undefined) { + return true; + } + } + const err = toError(error); + const message = err.message.toLowerCase(); const rateLimitKeywords = [ 'rate limit', 'rate-limit', @@ -98,26 +132,40 @@ export class StatelessRateLimiter { protected _extractRetryAfter(error: unknown): number | undefined { if (!error) return undefined; - const err = error as any; + if (error instanceof RateLimitError) { + if (error.retryAfter) { + return error.retryAfter; + } + } - const headers = err?.response?.headers ?? err?.response?.Headers ?? err?.response?.header; + if (isRateLimitError(error)) { + const headers = error.response?.headers; if (headers && typeof headers === 'object') { const retryAfterKey = Object.keys(headers).find((k) => k.toLowerCase() === 'retry-after'); if (retryAfterKey) { const value = Array.isArray(headers[retryAfterKey]) ? headers[retryAfterKey][0] : headers[retryAfterKey]; - const parsed = parseFloat(value); - if (!Number.isNaN(parsed)) { - return parsed; + if (value !== undefined) { + const parsed = parseFloat(String(value)); + if (!Number.isNaN(parsed)) { + return parsed; + } } } } - - const retryAfter = err?.retryAfter ?? err?.retry_after; - const parsed = parseFloat(retryAfter); - if (!Number.isNaN(parsed)) { - return parsed; + if (error.retryAfter !== undefined) { + const parsed = parseFloat(String(error.retryAfter)); + if (!Number.isNaN(parsed)) { + return parsed; + } + } + } + const err = error as { retryAfter?: string | number }; + if (err.retryAfter !== undefined) { + const parsed = parseFloat(String(err.retryAfter)); + if (!Number.isNaN(parsed)) { + return parsed; + } } - return undefined; } @@ -219,3 +267,24 @@ export class StatelessRateLimiter { ); } } + + +function toError(error: unknown): Error { + if (error instanceof Error) return error; + return new Error(String(error)); +} + + +function isRateLimitError(error: unknown): error is RateLimitError { + return ( + typeof error === 'object' && + error !== null && + ('name' in error || 'message' in error) && + ( + 'status' in error || + 'statusCode' in error || + 'response' in error || + 'retryAfter' in error + ) + ); +} diff --git a/sdk/typescript/src/ai/ToolCalling.ts b/sdk/typescript/src/ai/ToolCalling.ts index 1ae065838..f0968cd8d 100644 --- a/sdk/typescript/src/ai/ToolCalling.ts +++ b/sdk/typescript/src/ai/ToolCalling.ts @@ -97,6 +97,12 @@ export interface AIToolRequestOptions extends AIRequestOptions { maxToolCalls?: number; } +type ToolConfig = { + description: string + inputSchema: any; + execute?: (args: Record) => Promise; +} + // --------------------------------------------------------------------------- // Capability -> Tool Definition Conversion // --------------------------------------------------------------------------- @@ -368,7 +374,7 @@ function wrapToolsWithObservability( const observableTools: ToolSet = {}; for (const [name, t] of Object.entries(toolMap)) { - const originalTool = t as any; + const originalTool = t as ToolConfig; observableTools[name] = tool({ description: originalTool.description ?? '', inputSchema: originalTool.inputSchema, @@ -443,7 +449,7 @@ export async function executeToolCallLoop( // Create non-executable tool stubs so the LLM selects but doesn't execute const selectionTools: ToolSet = {}; for (const [name, t] of Object.entries(toolMap)) { - const orig = t as any; + const orig = t as ToolConfig; selectionTools[name] = tool({ description: orig.description ?? '', inputSchema: orig.inputSchema, diff --git a/sdk/typescript/src/client/AgentFieldClient.ts b/sdk/typescript/src/client/AgentFieldClient.ts index 14a22cdf0..e4e5139ac 100644 --- a/sdk/typescript/src/client/AgentFieldClient.ts +++ b/sdk/typescript/src/client/AgentFieldClient.ts @@ -20,6 +20,67 @@ export interface ExecutionStatusUpdate { statusReason?: string; } +// Raw discovery payload from API (snake_case) +interface RawDiscoveryPayload { + discovered_at?: string; + total_agents?: number; + total_reasoners?: number; + total_skills?: number; + pagination?: { + limit?: number; + offset?: number; + has_more?: boolean; + }; + capabilities?: RawCapability[]; +} + +interface RawCapability { + agent_id?: string; + base_url?: string; + version?: string; + health_status?: string; + deployment_type?: string; + last_heartbeat?: string; + reasoners?: RawReasoner[]; + skills?: RawSkill[]; +} + +interface RawReasoner { + id?: string; + description?: string; + tags?: string[]; + input_schema?: any; + output_schema?: any; + examples?: any; + invocation_target?: string; +} + +interface RawSkill { + id?: string; + description?: string; + tags?: string[]; + input_schema?: any; + invocation_target?: string; +} + +// Compact format +interface RawCompactDiscoveryPayload { + discovered_at?: string; + reasoners?: RawCompactCapability[]; + skills?: RawCompactCapability[]; +} + +interface RawCompactCapability { + id?: string; + agent_id?: string; + target?: string; + tags?: string[]; +} + +interface ExecutionError extends Error { + status: number; + responseData: unknown; +} export class AgentFieldClient { private readonly http: AxiosInstance; private readonly config: AgentConfig; @@ -113,9 +174,13 @@ this.http = axios.create({ if (respData) { const status = err.response.status; const msg = respData.message || respData.error || JSON.stringify(respData); - const enriched = new Error(`execute ${target} failed (${status}): ${msg}`); - (enriched as any).status = status; - (enriched as any).responseData = respData; + const enriched: ExecutionError = Object.assign( + new Error(`execute ${target} failed (${status}): ${msg}`), + { + status, + responseData: respData + } + ); throw enriched; } throw err; @@ -244,72 +309,72 @@ this.http = axios.create({ return { format: 'xml', raw, xml: raw }; } - const parsed = typeof res.data === 'string' ? JSON.parse(res.data) : res.data; + const parsed: RawDiscoveryPayload | RawCompactDiscoveryPayload = typeof res.data === 'string' ? JSON.parse(res.data) : res.data; if (format === 'compact') { return { format: 'compact', raw, - compact: this.mapCompactDiscovery(parsed as any) + compact: this.mapCompactDiscovery(parsed as RawCompactDiscoveryPayload) }; } return { format: 'json', raw, - json: this.mapDiscoveryResponse(parsed as any) + json: this.mapDiscoveryResponse(parsed as RawDiscoveryPayload) }; } - private mapDiscoveryResponse(payload: any): DiscoveryResponse { + private mapDiscoveryResponse(payload: RawDiscoveryPayload): DiscoveryResponse { return { - discoveredAt: String(payload?.discovered_at ?? ''), - totalAgents: Number(payload?.total_agents ?? 0), - totalReasoners: Number(payload?.total_reasoners ?? 0), - totalSkills: Number(payload?.total_skills ?? 0), + discoveredAt: String(payload.discovered_at ?? ''), + totalAgents: Number(payload.total_agents ?? 0), + totalReasoners: Number(payload.total_reasoners ?? 0), + totalSkills: Number(payload.total_skills ?? 0), pagination: { - limit: Number(payload?.pagination?.limit ?? 0), - offset: Number(payload?.pagination?.offset ?? 0), - hasMore: Boolean(payload?.pagination?.has_more) + limit: Number(payload.pagination?.limit ?? 0), + offset: Number(payload.pagination?.offset ?? 0), + hasMore: Boolean(payload.pagination?.has_more) }, - capabilities: (payload?.capabilities ?? []).map((cap: any) => ({ - agentId: cap?.agent_id ?? '', - baseUrl: cap?.base_url ?? '', - version: cap?.version ?? '', - healthStatus: cap?.health_status ?? '', - deploymentType: cap?.deployment_type, - lastHeartbeat: cap?.last_heartbeat, - reasoners: (cap?.reasoners ?? []).map((r: any) => ({ - id: r?.id ?? '', - description: r?.description, - tags: r?.tags ?? [], - inputSchema: r?.input_schema, - outputSchema: r?.output_schema, - examples: r?.examples, - invocationTarget: r?.invocation_target ?? '' + capabilities: (payload.capabilities ?? []).map((cap) => ({ + agentId: cap.agent_id ?? '', + baseUrl: cap.base_url ?? '', + version: cap.version ?? '', + healthStatus: cap.health_status ?? '', + deploymentType: cap.deployment_type, + lastHeartbeat: cap.last_heartbeat, + reasoners: (cap.reasoners ?? []).map((r) => ({ + id: r.id ?? '', + description: r.description, + tags: r.tags ?? [], + inputSchema: r.input_schema, + outputSchema: r.output_schema, + examples: r.examples, + invocationTarget: r.invocation_target ?? '' })), - skills: (cap?.skills ?? []).map((s: any) => ({ - id: s?.id ?? '', - description: s?.description, - tags: s?.tags ?? [], - inputSchema: s?.input_schema, - invocationTarget: s?.invocation_target ?? '' + skills: (cap.skills ?? []).map((s) => ({ + id: s.id ?? '', + description: s.description, + tags: s.tags ?? [], + inputSchema: s.input_schema, + invocationTarget: s.invocation_target ?? '' })) })) }; } - private mapCompactDiscovery(payload: any): CompactDiscoveryResponse { - const toCap = (cap: any) => ({ - id: cap?.id ?? '', - agentId: cap?.agent_id ?? '', - target: cap?.target ?? '', - tags: cap?.tags ?? [] + private mapCompactDiscovery(payload: RawCompactDiscoveryPayload): CompactDiscoveryResponse { + const toCap = (cap: RawCompactCapability) => ({ + id: cap.id ?? '', + agentId: cap.agent_id ?? '', + target: cap.target ?? '', + tags: cap.tags ?? [] }); return { - discoveredAt: String(payload?.discovered_at ?? ''), - reasoners: (payload?.reasoners ?? []).map(toCap), - skills: (payload?.skills ?? []).map(toCap) + discoveredAt: String(payload.discovered_at ?? ''), + reasoners: (payload.reasoners ?? []).map(toCap), + skills: (payload.skills ?? []).map(toCap) }; } diff --git a/sdk/typescript/src/types/agent.ts b/sdk/typescript/src/types/agent.ts index f1b05614a..e327b14f8 100644 --- a/sdk/typescript/src/types/agent.ts +++ b/sdk/typescript/src/types/agent.ts @@ -197,8 +197,8 @@ export interface ServerlessEvent { type?: 'reasoner' | 'skill'; body?: any; input?: any; - executionContext?: Partial; - execution_context?: Partial; + executionContext?: RawExecutionContext; + execution_context?: RawExecutionContext; } export interface ServerlessResponse { @@ -215,3 +215,26 @@ export type AgentHandler = ( ) => Promise | ServerlessResponse | void; export type Awaitable = T | Promise; + +export interface RawExecutionContext { + executionId?: string; + runId?: string; + workflowId?: string; + parentExecutionId?: string; + sessionId?: string; + actorId?: string; + callerDid?: string; + targetDid?: string; + agentNodeDid?: string; + + // snake_case variants + execution_id?: string; + run_id?: string; + workflow_id?: string; + parent_execution_id?: string; + session_id?: string; + actor_id?: string; + caller_did?: string; + target_did?: string; + agent_node_did?: string; +} \ No newline at end of file From 6cf3ecd52091a71e6348cca2c2b212d1bb65d3e6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 12 Apr 2026 12:53:16 +0000 Subject: [PATCH 2/3] chore(release): v0.1.67-rc.2 [skip ci] --- CHANGELOG.md | 4981 +++++++++++++++++ VERSION | 2 +- .../internal/templates/go/go.mod.tmpl | 2 +- sdk/python/agentfield/__init__.py | 2 +- sdk/python/pyproject.toml | 2 +- sdk/typescript/package.json | 2 +- 6 files changed, 4986 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d466843af..d24846050 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,4987 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) +## [0.1.67-rc.2] - 2026-04-12 + + +### Added + +- Feat(skill+cli): agentfield-multi-reasoner-builder skill, af doctor, af init --docker, af skill install (#367) + +Adds the agentfield-multi-reasoner-builder skill, af doctor, af init --docker, and the full af skill install architecture (embed + 7 target integrations + state tracking). install.sh now installs the skill into every detected coding agent by default. See PR #367 for the full breakdown, end-to-end test results, and design rationale. (c34e3e6) + +- Feat(runs): pause/resume/cancel + unified status primitives + notification center (#345) + +* fix(ui): cancelled + paused node colors in run trace and DAG graph + +- Route RunTrace StatusDot/TraceRow colors through getStatusTheme +- Add strikethrough to cancelled reasoner labels in waterfall +- Add cancelled/paused cases to FloatingEdge and EnhancedEdge +- Fix DAG node component to honor own-status (not parent) for color +- No hardcoded colors; everything routed through existing theme system + +* feat(web): notification center with bell popover and persistent log + +Replaces the toast-only notification system with a dual-mode center: + +- Persistent in-session log backing the new NotificationBell (sidebar + header, next to ModeToggle). Shows an unread count Badge and opens a + shadcn Popover with the full notification history, mark-read, + mark-all-read, and clear-all controls. +- Transient bottom-right toasts continue to fire for live feedback and + auto-dismiss on their existing schedule; dismissing a toast no longer + removes it from the log. +- mounted globally in App.tsx so any page can + surface notifications without local wiring. +- Cleaned up NotificationToastItem styling to use theme-consistent + tokens (left accent border per type, shadcn Card/Button) instead of + hardcoded tailwind color classes. +- Existing useSuccess/Error/Info/WarningNotification hook signatures + preserved — no downstream caller changes required. + +* feat(web): runs table pause/resume/cancel via per-row kebab + bulk bar + +Adds full lifecycle controls to the runs index page: + +- Per-row kebab (MoreHorizontal) DropdownMenu with Pause / Resume / + Cancel items, shown based on each run's status. Muted at rest, + brightens on row hover via a group/run-row selector so it stays + discoverable without adding visual noise. Cancel opens an AlertDialog + with honest copy explaining that in-flight nodes finish their current + step and their output is discarded. +- New RunLifecycleMenu component in components/runs/ centralises the + menu, dialog, and the shared CANCEL_RUN_COPY constants so the bulk + bar can mirror the exact same language. +- Bulk bar (shown when >=1 row is selected) upgraded from a single + "Cancel running" button to Pause / Resume / Cancel alongside the + existing Compare selected action. Buttons enable only when at least + one selected row is eligible. A single shared AlertDialog with + count-aware title confirms bulk cancels. +- Bulk mutations fire via Promise.allSettled and emit one summary + notification — success, partial failure ("4 of 5 cancelled — 1 could + not be stopped"), or full failure. +- Per-row spinner via pendingIds Set so each row reflects its own + mutation state independently of the mutation hook's global isPending. +- Replay of existing success/error notifications via the global + notification provider — no new toast plumbing. + +* feat(web): run detail page pause/resume/cancel cluster + cancellation strip + +- Replace the lone Cancel button in the run detail header with a full + Pause / Resume / Cancel lifecycle cluster matching the h-8 text-xs + sizing and outline/destructive variants used elsewhere in the header. + All three share a single lifecycleBusy flag so mutations are + serialized and the active control renders a spinner (Activity icon). +- Cancel opens a shadcn AlertDialog that reuses the CANCEL_RUN_COPY + constants from the runs table, so the dialog body language is + identical across single-run and bulk confirmation flows. +- Success and error surfaces through the global notification provider + via useSuccessNotification / useErrorNotification — no local toast. +- Add a muted "Cancellation registered" info strip that renders only + when the run is in the cancelled state AND at least one child node + is still reporting running. Copy makes the asymmetry explicit: + "No new nodes will start; their output will be discarded." The strip + disappears naturally once every node reaches a terminal state via + react-query refetch / SSE. + +* feat(web): unified status primitives, semantic notifications, consistent liveness + +Cross-cutting UX pass addressing multiple issues from rapid review: + +Backend +- Expose RootExecutionStatus in WorkflowRunSummary so the UI can reflect + what the user actually controls (the root execution) instead of the + children-aggregated status, which lies in the presence of in-flight + stragglers after a pause or cancel. +- Add paused_count to the run summary SQL aggregation and root_status + column so both ListWorkflowRuns and getRunAggregation populate it. +- Normalise root status via types.NormalizeExecutionStatus on the way + out so downstream consumers see canonical values. + +Unified status primitives (web) +- Extend StatusTheme in utils/status.ts with `icon: LucideIcon` and + `motion: "none" | "live"`. Single source of truth for glyph and motion + per canonical status. +- Rebuild components/ui/status-pill.tsx into three shared primitives — + StatusDot, StatusIcon, StatusPill — each deriving colour/glyph/motion + from getStatusTheme(). Running statuses get a pinging halo on dots + and a slow (2.5s) spin on icons. +- Replace inline StatusDot implementations in RunsPage and RunTrace + with the shared primitive. Badge "running" variant auto-spins its + icon via the same theme. + +Runs table liveness +- RunsPage kebab + StatusDot + DurationCell + bulk bar eligibility all + key on `root_execution_status ?? status`. Paused/cancelled rows stop + ticking immediately even when aggregate stays running. +- Adaptive tick intervals: 1s under 1m, 5s under 5m, 30s under 1h, + frozen past 1h. Duration format drops seconds after 5 min. Motion + is proportional to information; no more 19m runs counting seconds. + +Run detail page +- Lifecycle cluster (Pause/Resume/Cancel) uses root execution status + from the DAG timeline instead of the aggregated workflow status. +- Status badge at the top reflects the root status. +- "Cancellation registered" info strip also recognises paused-with- + running-children and adjusts copy. +- RunTrace receives rootStatus; child rows whose own status is still + running but whose root is terminal render desaturated with motion + suppressed — honest depiction of abandoned stragglers. + +Dashboard +- partitionDashboardRuns active/terminal split now uses + root_execution_status so a timed-out run with stale children no + longer appears in "Active runs". +- All RunStatusBadge call sites pass the effective status. + +Notification center — compact tree, semantic icons +- Add NotificationEventKind (pause/resume/cancel/error/complete/start/ + info) driving a dedicated icon + accent map. Pause uses PauseCircle + amber, Resume PlayCircle emerald, Cancel Ban muted, Error + AlertTriangle destructive. No more universal green checkmark. +- Sonner toasts now pass a custom icon element so the glyph matches + the bell popover; richColors removed for a quiet neutral card with + only a thin type-tinted left border. +- Bell popover redesigned as a collapsed-by-default run tree: each run + group shows one header line + one latest-event summary (~44px); + expand via chevron to see the full timeline with a connector line + on the left. Event rows are single-line with hover tooltip for the + full message, hover-reveal dismiss ×, and compact timestamps + ("now", "2m", "3h"). +- useRunNotification accepts an eventKind parameter; RunsPage and + RunDetailPage handlers pass explicit kinds. +- Replace Radix ScrollArea inside the popover with a plain overflow + div — Radix was eating wheel events. +- Fix "View run" navigation: Link uses `to={`/runs/${runId}`}` + directly (no href string manipulation) so basename=/ui prepends + properly. Sonner toast action builds the URL from VITE_BASE_PATH. + +Top bar + layout +- Move NotificationBell from the sidebar header to the main content + top bar, next to the ⌘K hint. Sidebar header is back to just logo + + ModeToggle. +- Constrain SidebarProvider to h-svh overflow-hidden so the inner + content div is the scroll container — top header stays pinned at + the viewport top without needing a sticky hack. +- NotificationProvider reflects unreadCount in the browser tab title + as "(N) …" so notifications surface in the Chrome tab when the + window is unfocused. + +Dependencies +- Add sonner ^2.0.7 for standard shadcn toasts. + +* fix(runs-cancel-pause): address multi-pass review findings (H1-3, M1-10) + +Backend +- H1 deriveStatusFromCounts: add explicit paused branch before succeeded + fallback so all-paused runs no longer collapse to succeeded. Terminal + check already excludes paused, so completed_at/duration_ms stay nil. + +Frontend — single source of truth via getStatusTheme() +- H3 WorkflowNode: delete duplicate STATUS_TONE_TOKEN_MAP and switch-based + getStatusIcon; route icon, color, motion through getStatusTheme(). +- H2 RunsPage: StatusMenuDot delegates to instead of + hardcoding bg-green/red/blue. +- M3+M4 RunDetailPage: statusVariant helper removed; header now uses + for unified status visual. +- M1+M2 badge: swap Phosphor → Lucide icons; derive spin from + StatusTheme.motion === "live" via variantToCanonical map instead of + hardcoding `variant === "running"`. + +Frontend — root-effective status consistency +- M5 RunsPage filteredRuns: filter on root_execution_status ?? r.status + so client-side filter agrees with the dot. +- M6 NewDashboardPage: duration cell uses isTerminalStatus(effective) + instead of aggregate run.terminal so cancelled/paused roots freeze the + timer even while children drain. +- M7 RunDetailPage lifecycle cluster: render for any non-terminal root + (Cancel now available for pending/queued/waiting), Pause/Resume still + gated on running/paused. + +Frontend — accessibility + contrast +- M8 NotificationBell: remove nested role="button" + key handler on + NotificationRow (was wrapping a real