Skip to content

Commit db8bab4

Browse files
feat(workflows): standardized StepResult execution envelope + structured rendering
Backend: every workflow step now emits a uniform envelope (status/summary/items/duration_ns/timestamp/data/error) via StepResult + NewSuccess/ErrorStepResult + reflection-based SummarizeResult. ExecuteWorkflow times each step and preserves transactional abort-on-error. Frontend: WorkflowCenter renders status badge, summary, copy button, key/value table or shell output block with duration/records/timestamp footers (lib/workflowResults.ts helpers). No more raw JSON dumps. Tests: common/workflow_test.go (11), app/Workflow_test.go (3), workflowResults.test.ts (15). Also fixes inverted assertion in TestAIOps_GetAISetupRecommendation_Logic (PullRequired == !QwythosExists).
1 parent 4c5ea40 commit db8bab4

8 files changed

Lines changed: 873 additions & 50 deletions

File tree

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { describe, expect, it } from 'vitest'
2+
import {
3+
formatDurationNs,
4+
formatTimestamp,
5+
formatValue,
6+
getResultSummary,
7+
isShellPayload,
8+
isStepResult,
9+
resultToRows,
10+
} from './workflowResults'
11+
12+
describe('isStepResult', () => {
13+
it('recognizes a standardized envelope', () => {
14+
expect(isStepResult({ status: 'success', summary: '2 records', data: [] })).toBe(true)
15+
})
16+
it('rejects raw payloads and non-objects', () => {
17+
expect(isStepResult({ Command: 'x', Output: 'y', ExitCode: 0 })).toBe(false)
18+
expect(isStepResult('hello')).toBe(false)
19+
expect(isStepResult(null)).toBe(false)
20+
expect(isStepResult(undefined)).toBe(false)
21+
})
22+
})
23+
24+
describe('isShellPayload', () => {
25+
it('recognizes the PascalCase shell payload nested in envelope.data', () => {
26+
expect(isShellPayload({ Command: 'Get-Process', Output: 'a\nb', ExitCode: 0, Duration: 5 })).toBe(true)
27+
})
28+
it('rejects envelopes and non-shell shapes', () => {
29+
expect(isShellPayload({ status: 'success', data: [] })).toBe(false)
30+
expect(isShellPayload('nope')).toBe(false)
31+
})
32+
})
33+
34+
describe('formatDurationNs', () => {
35+
it('formats ms, seconds, and minutes', () => {
36+
expect(formatDurationNs(500_000)).toBe('<1ms')
37+
expect(formatDurationNs(12_000_000)).toBe('12ms')
38+
expect(formatDurationNs(1_500_000_000)).toBe('1.5s')
39+
expect(formatDurationNs(125_000_000_000)).toBe('2m 5s')
40+
})
41+
it('returns empty for missing/zero values', () => {
42+
expect(formatDurationNs(undefined)).toBe('')
43+
expect(formatDurationNs(0)).toBe('')
44+
})
45+
})
46+
47+
describe('formatTimestamp', () => {
48+
it('formats an RFC3339 timestamp', () => {
49+
expect(formatTimestamp('2026-08-07T12:34:56Z')).toMatch(/\d{2}:\d{2}:\d{2}/)
50+
})
51+
it('returns empty for garbage', () => {
52+
expect(formatTimestamp('not-a-date')).toBe('')
53+
expect(formatTimestamp(undefined)).toBe('')
54+
})
55+
})
56+
57+
describe('resultToRows', () => {
58+
it('flattens an array of objects with index prefixes', () => {
59+
const rows = resultToRows([{ name: 'svc1', status: 'running' }, { name: 'svc2' }])
60+
expect(rows).toEqual([
61+
{ key: '#1 · name', value: 'svc1' },
62+
{ key: '#1 · status', value: 'running' },
63+
{ key: '#2 · name', value: 'svc2' },
64+
])
65+
})
66+
it('flattens a plain object', () => {
67+
expect(resultToRows({ cpu: 12, mem: 80 })).toEqual([
68+
{ key: 'cpu', value: 12 },
69+
{ key: 'mem', value: 80 },
70+
])
71+
})
72+
it('wraps a scalar in a single row', () => {
73+
expect(resultToRows('done')).toEqual([{ key: 'value', value: 'done' }])
74+
})
75+
it('returns [] for empty arrays, null, and undefined', () => {
76+
expect(resultToRows([])).toEqual([])
77+
expect(resultToRows(null)).toEqual([])
78+
expect(resultToRows(undefined)).toEqual([])
79+
})
80+
})
81+
82+
describe('formatValue', () => {
83+
it('passes strings through and stringifies nested objects', () => {
84+
expect(formatValue('plain')).toBe('plain')
85+
expect(formatValue(42)).toBe('42')
86+
expect(formatValue({ a: 1 })).toContain('"a": 1')
87+
})
88+
it('returns empty for null/undefined', () => {
89+
expect(formatValue(null)).toBe('')
90+
expect(formatValue(undefined)).toBe('')
91+
})
92+
})
93+
94+
describe('getResultSummary', () => {
95+
it('uses summary for success and error message for failures', () => {
96+
expect(getResultSummary({ status: 'success', summary: '3 services' })).toBe('3 services')
97+
expect(getResultSummary({ status: 'error', error: 'boom' })).toBe('boom')
98+
expect(getResultSummary({ status: 'error' })).toBe('Step failed')
99+
expect(getResultSummary({ status: 'success' })).toBe('Completed')
100+
})
101+
})
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
/**
2+
* Standardized Execution Result helpers for the Workflow Center.
3+
*
4+
* The backend wraps every workflow step payload in a StepResult envelope
5+
* (see internal/common/workflow.go) with stable snake_case keys. These helpers
6+
* decode that envelope for rendering: status badge, summary line, meta footer
7+
* (duration / item count / timestamp), and a table-friendly row view.
8+
*/
9+
10+
// StepResult mirrors internal/common.StepResult json tags.
11+
export interface StepResult {
12+
status: 'success' | 'error'
13+
summary?: string
14+
items?: number
15+
duration_ns?: number
16+
timestamp?: string
17+
data?: any
18+
error?: string
19+
}
20+
21+
export function isStepResult(r: any): r is StepResult {
22+
return (
23+
r !== null &&
24+
typeof r === 'object' &&
25+
typeof r.status === 'string' &&
26+
('data' in r || 'summary' in r || 'error' in r || 'duration_ns' in r)
27+
)
28+
}
29+
30+
// Shell payload nested inside envelope.data. devops.ShellResult has no json
31+
// tags, so it serializes with PascalCase keys — detect it the same way the
32+
// old duck-typing did, but scoped to the envelope's data field.
33+
export interface ShellPayload {
34+
Command: string
35+
Output: string
36+
ExitCode: number
37+
Duration: number
38+
}
39+
40+
export function isShellPayload(d: any): d is ShellPayload {
41+
return (
42+
d !== null &&
43+
typeof d === 'object' &&
44+
typeof d.Command === 'string' &&
45+
typeof d.Output === 'string' &&
46+
typeof d.ExitCode === 'number'
47+
)
48+
}
49+
50+
export function formatDurationNs(ns?: number): string {
51+
if (!ns || ns <= 0) return ''
52+
if (ns < 1_000_000) return '<1ms'
53+
const ms = Math.floor(ns / 1_000_000)
54+
if (ms < 1000) return `${ms}ms`
55+
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`
56+
const min = Math.floor(ms / 60_000)
57+
const sec = Math.floor((ms % 60_000) / 1000)
58+
return `${min}m ${sec}s`
59+
}
60+
61+
export function formatTimestamp(ts?: string): string {
62+
if (!ts) return ''
63+
const d = new Date(ts)
64+
if (Number.isNaN(d.getTime())) return ''
65+
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
66+
}
67+
68+
export interface ResultRow {
69+
key: string
70+
value: any
71+
}
72+
73+
/**
74+
* Flattens an arbitrary payload into key/value rows for a table or grid.
75+
* - Array of objects -> one row per entry per field, prefixed with the index.
76+
* - Plain object -> one row per key.
77+
* - Scalar / string -> single row.
78+
*/
79+
export function resultToRows(data: any): ResultRow[] {
80+
if (data === null || data === undefined) return []
81+
82+
if (Array.isArray(data)) {
83+
if (data.length === 0) return []
84+
const rows: ResultRow[] = []
85+
data.forEach((item, i) => {
86+
if (item !== null && typeof item === 'object' && !Array.isArray(item)) {
87+
const entries = Object.entries(item)
88+
if (entries.length === 0) {
89+
rows.push({ key: `#${i + 1}`, value: item })
90+
return
91+
}
92+
for (const [k, v] of entries) {
93+
rows.push({ key: `#${i + 1} · ${k}`, value: v })
94+
}
95+
} else {
96+
rows.push({ key: `#${i + 1}`, value: item })
97+
}
98+
})
99+
return rows
100+
}
101+
102+
if (typeof data === 'object') {
103+
return Object.entries(data).map(([k, v]) => ({ key: k, value: v }))
104+
}
105+
106+
return [{ key: 'value', value: data }]
107+
}
108+
109+
/** Renders a scalar value; nested objects/arrays are JSON-stringified. */
110+
export function formatValue(v: any): string {
111+
if (v === null || v === undefined) return ''
112+
if (typeof v === 'string') return v
113+
if (typeof v === 'number' || typeof v === 'boolean') return String(v)
114+
return JSON.stringify(v, null, 2)
115+
}
116+
117+
/** Extracts the one-line interpretation for a step envelope. */
118+
export function getResultSummary(r: StepResult): string {
119+
if (r.status === 'error') return r.error || 'Step failed'
120+
return r.summary || 'Completed'
121+
}

0 commit comments

Comments
 (0)