@swarmclawai/swarmclaw 1.9.7 → 1.9.9
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/README.md +74 -0
- package/package.json +2 -2
- package/src/app/api/quality/release-readiness/route.ts +38 -0
- package/src/app/api/schedules/[id]/history/route.ts +15 -0
- package/src/app/api/schedules/[id]/route.test.ts +52 -3
- package/src/app/api/schedules/schedule-history-route.test.ts +60 -0
- package/src/cli/index.js +2 -0
- package/src/cli/index.ts +8 -0
- package/src/cli/spec.js +1 -0
- package/src/components/quality/quality-workspace.tsx +164 -4
- package/src/components/schedules/schedule-console.tsx +173 -15
- package/src/lib/quality/release-readiness.test.ts +129 -0
- package/src/lib/quality/release-readiness.ts +187 -0
- package/src/lib/schedules/schedules.ts +10 -1
- package/src/lib/server/runtime/scheduler.ts +52 -20
- package/src/lib/server/schedules/schedule-history.test.ts +121 -0
- package/src/lib/server/schedules/schedule-history.ts +234 -0
- package/src/lib/server/schedules/schedule-lifecycle.ts +34 -2
- package/src/lib/server/schedules/schedule-route-service.ts +11 -1
- package/src/lib/server/schedules/schedule-service.ts +39 -7
- package/src/lib/server/session-tools/crud.ts +2 -0
- package/src/lib/server/storage-normalization.ts +15 -0
- package/src/types/schedule.ts +22 -0
|
@@ -14,10 +14,10 @@ import { api } from '@/lib/app/api-client'
|
|
|
14
14
|
import { archiveSchedule, purgeSchedule, restoreSchedule, runSchedule, updateSchedule } from '@/lib/schedules/schedules'
|
|
15
15
|
import { cronToHuman } from '@/lib/schedules/cron-human'
|
|
16
16
|
import { timeAgo, timeUntil } from '@/lib/time-format'
|
|
17
|
-
import type { BoardTask, ProtocolRun, Schedule, ScheduleStatus } from '@/types'
|
|
17
|
+
import type { BoardTask, ProtocolRun, Schedule, ScheduleHistoryEntry, ScheduleStatus } from '@/types'
|
|
18
18
|
import { toast } from 'sonner'
|
|
19
19
|
|
|
20
|
-
type ScheduleScope = 'live' | 'archived' | 'runs'
|
|
20
|
+
type ScheduleScope = 'live' | 'archived' | 'runs' | 'history'
|
|
21
21
|
type ScheduleFilterStatus = 'all' | ScheduleStatus
|
|
22
22
|
type ScheduleRunStatusFilter = 'all' | Extract<BoardTask['status'], 'queued' | 'running' | 'completed' | 'failed' | 'cancelled'>
|
|
23
23
|
type ScheduleCadenceFilter = 'all' | Schedule['scheduleType']
|
|
@@ -35,6 +35,12 @@ type ScheduleConsoleRunRow = {
|
|
|
35
35
|
scheduleId: string | null
|
|
36
36
|
scheduleName: string
|
|
37
37
|
}
|
|
38
|
+
type ScheduleHistoryRow = {
|
|
39
|
+
id: string
|
|
40
|
+
schedule: Schedule
|
|
41
|
+
entry: ScheduleHistoryEntry
|
|
42
|
+
searchText: string
|
|
43
|
+
}
|
|
38
44
|
|
|
39
45
|
const STATUS_STYLES: Record<string, string> = {
|
|
40
46
|
active: 'bg-emerald-500/12 text-emerald-400 border-emerald-500/20',
|
|
@@ -139,6 +145,38 @@ function protocolRunPreview(run: ProtocolRun): string {
|
|
|
139
145
|
return (goal || run.title || 'Structured session run').slice(0, 180)
|
|
140
146
|
}
|
|
141
147
|
|
|
148
|
+
function historyActionLabel(action: ScheduleHistoryEntry['action']): string {
|
|
149
|
+
switch (action) {
|
|
150
|
+
case 'created':
|
|
151
|
+
return 'Created'
|
|
152
|
+
case 'updated':
|
|
153
|
+
return 'Updated'
|
|
154
|
+
case 'archived':
|
|
155
|
+
return 'Archived'
|
|
156
|
+
case 'restored':
|
|
157
|
+
return 'Restored'
|
|
158
|
+
case 'run_started':
|
|
159
|
+
return 'Run started'
|
|
160
|
+
case 'skipped':
|
|
161
|
+
return 'Skipped'
|
|
162
|
+
case 'failed':
|
|
163
|
+
return 'Failed'
|
|
164
|
+
default:
|
|
165
|
+
return action
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function historyActionBadge(action: ScheduleHistoryEntry['action']): string {
|
|
170
|
+
if (action === 'created' || action === 'restored' || action === 'run_started') return badgeClass('completed')
|
|
171
|
+
if (action === 'failed') return badgeClass('failed')
|
|
172
|
+
if (action === 'skipped' || action === 'archived') return badgeClass('paused')
|
|
173
|
+
return badgeClass('running')
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function formatHistoryValue(value: string | null | undefined): string {
|
|
177
|
+
return value == null || value === '' ? 'empty' : value
|
|
178
|
+
}
|
|
179
|
+
|
|
142
180
|
function ActionButton(
|
|
143
181
|
props: ButtonHTMLAttributes<HTMLButtonElement> & { tone?: 'default' | 'danger' },
|
|
144
182
|
) {
|
|
@@ -337,6 +375,42 @@ export function ScheduleConsole() {
|
|
|
337
375
|
.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
338
376
|
}, [agentFilter, agents, cadenceFilter, projectScopedProtocolRuns, projectScopedRuns, runStatusFilter, schedules, search])
|
|
339
377
|
|
|
378
|
+
const filteredHistory = useMemo<ScheduleHistoryRow[]>(() => {
|
|
379
|
+
const q = search.trim().toLowerCase()
|
|
380
|
+
return projectScopedSchedules
|
|
381
|
+
.filter((schedule) => {
|
|
382
|
+
if (statusFilter !== 'all' && schedule.status !== statusFilter) return false
|
|
383
|
+
if (cadenceFilter !== 'all' && schedule.scheduleType !== cadenceFilter) return false
|
|
384
|
+
if (agentFilter !== 'all' && schedule.agentId !== agentFilter) return false
|
|
385
|
+
return true
|
|
386
|
+
})
|
|
387
|
+
.flatMap((schedule) => {
|
|
388
|
+
const agentName = agents[schedule.agentId]?.name || ''
|
|
389
|
+
const history = Array.isArray(schedule.history) ? schedule.history : []
|
|
390
|
+
return history.map((entry) => {
|
|
391
|
+
const changeText = Array.isArray(entry.changes)
|
|
392
|
+
? entry.changes.map((change) => [change.label, change.before, change.after].filter(Boolean).join(' ')).join(' ')
|
|
393
|
+
: ''
|
|
394
|
+
return {
|
|
395
|
+
id: `${schedule.id}:${entry.id}`,
|
|
396
|
+
schedule,
|
|
397
|
+
entry,
|
|
398
|
+
searchText: [
|
|
399
|
+
schedule.name,
|
|
400
|
+
schedule.taskPrompt,
|
|
401
|
+
agentName,
|
|
402
|
+
entry.summary,
|
|
403
|
+
entry.actor,
|
|
404
|
+
historyActionLabel(entry.action),
|
|
405
|
+
changeText,
|
|
406
|
+
].filter(Boolean).join(' ').toLowerCase(),
|
|
407
|
+
}
|
|
408
|
+
})
|
|
409
|
+
})
|
|
410
|
+
.filter((row) => !q || row.searchText.includes(q))
|
|
411
|
+
.sort((a, b) => b.entry.at - a.entry.at || b.entry.revision - a.entry.revision)
|
|
412
|
+
}, [agentFilter, agents, cadenceFilter, projectScopedSchedules, search, statusFilter])
|
|
413
|
+
|
|
340
414
|
const handleArchive = async (scheduleId: string) => {
|
|
341
415
|
setBusyId(scheduleId)
|
|
342
416
|
try {
|
|
@@ -432,7 +506,11 @@ export function ScheduleConsole() {
|
|
|
432
506
|
setSortBy('nextRunAt')
|
|
433
507
|
}
|
|
434
508
|
|
|
435
|
-
const scopeCount = scope === 'runs'
|
|
509
|
+
const scopeCount = scope === 'runs'
|
|
510
|
+
? filteredRuns.length
|
|
511
|
+
: scope === 'history'
|
|
512
|
+
? filteredHistory.length
|
|
513
|
+
: filteredSchedules.length
|
|
436
514
|
|
|
437
515
|
if (!loaded) {
|
|
438
516
|
return <PageLoader label="Loading schedules..." />
|
|
@@ -464,6 +542,7 @@ export function ScheduleConsole() {
|
|
|
464
542
|
<FilterPill label="Live" active={scope === 'live'} onClick={() => setScope('live')} />
|
|
465
543
|
<FilterPill label="Archived" active={scope === 'archived'} onClick={() => setScope('archived')} />
|
|
466
544
|
<FilterPill label="Runs" active={scope === 'runs'} onClick={() => setScope('runs')} />
|
|
545
|
+
<FilterPill label="History" active={scope === 'history'} onClick={() => setScope('history')} />
|
|
467
546
|
</div>
|
|
468
547
|
</div>
|
|
469
548
|
<div className="w-full lg:max-w-[360px]">
|
|
@@ -472,7 +551,11 @@ export function ScheduleConsole() {
|
|
|
472
551
|
value={search}
|
|
473
552
|
onChange={(e) => setSearch(e.target.value)}
|
|
474
553
|
onClear={() => setSearch('')}
|
|
475
|
-
placeholder={scope === 'runs'
|
|
554
|
+
placeholder={scope === 'runs'
|
|
555
|
+
? 'Search runs, schedules, or agents...'
|
|
556
|
+
: scope === 'history'
|
|
557
|
+
? 'Search history, schedules, or changes...'
|
|
558
|
+
: 'Search schedules, agents, or recipients...'}
|
|
476
559
|
/>
|
|
477
560
|
</div>
|
|
478
561
|
</div>
|
|
@@ -544,7 +627,7 @@ export function ScheduleConsole() {
|
|
|
544
627
|
</select>
|
|
545
628
|
</label>
|
|
546
629
|
|
|
547
|
-
{scope !== 'runs' ? (
|
|
630
|
+
{scope !== 'runs' && scope !== 'history' ? (
|
|
548
631
|
<label className="text-[12px] text-text-3/70">
|
|
549
632
|
<span className="block mb-1.5 font-600 uppercase tracking-[0.08em] text-[10px]">Delivery</span>
|
|
550
633
|
<select
|
|
@@ -562,16 +645,26 @@ export function ScheduleConsole() {
|
|
|
562
645
|
|
|
563
646
|
<label className="text-[12px] text-text-3/70">
|
|
564
647
|
<span className="block mb-1.5 font-600 uppercase tracking-[0.08em] text-[10px]">Sort</span>
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
648
|
+
{scope === 'history' ? (
|
|
649
|
+
<select
|
|
650
|
+
value="history"
|
|
651
|
+
disabled
|
|
652
|
+
className="w-full px-3 py-2.5 rounded-[12px] border border-white/[0.06] bg-surface text-text-3"
|
|
653
|
+
>
|
|
654
|
+
<option value="history">Newest changes</option>
|
|
655
|
+
</select>
|
|
656
|
+
) : (
|
|
657
|
+
<select
|
|
658
|
+
value={sortBy}
|
|
659
|
+
onChange={(e) => setSortBy(e.target.value as ScheduleSortBy)}
|
|
660
|
+
className="w-full px-3 py-2.5 rounded-[12px] border border-white/[0.06] bg-surface text-text-2"
|
|
661
|
+
>
|
|
662
|
+
<option value="nextRunAt">Next run</option>
|
|
663
|
+
<option value="lastRunAt">Last run</option>
|
|
664
|
+
<option value="updatedAt">Recently updated</option>
|
|
665
|
+
<option value="name">Name</option>
|
|
666
|
+
</select>
|
|
667
|
+
)}
|
|
575
668
|
</label>
|
|
576
669
|
|
|
577
670
|
<div className="flex items-end">
|
|
@@ -628,6 +721,71 @@ export function ScheduleConsole() {
|
|
|
628
721
|
)
|
|
629
722
|
})}
|
|
630
723
|
</div>
|
|
724
|
+
) : scope === 'history' ? (
|
|
725
|
+
<div className="divide-y divide-white/[0.05]">
|
|
726
|
+
{filteredHistory.length === 0 ? (
|
|
727
|
+
<div className="px-5 py-10 text-center text-text-3/60">No schedule history matches the current filters.</div>
|
|
728
|
+
) : filteredHistory.map((row) => {
|
|
729
|
+
const { schedule, entry } = row
|
|
730
|
+
const agent = agents[schedule.agentId]
|
|
731
|
+
const changes = Array.isArray(entry.changes) ? entry.changes.slice(0, 4) : []
|
|
732
|
+
const remainingChanges = Math.max(0, (entry.changes?.length || 0) - changes.length)
|
|
733
|
+
return (
|
|
734
|
+
<div key={row.id} className="px-5 py-4 hover:bg-white/[0.02] transition-colors">
|
|
735
|
+
<div className="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
|
|
736
|
+
<div className="min-w-0">
|
|
737
|
+
<div className="flex flex-wrap items-center gap-2 mb-1.5">
|
|
738
|
+
<span className={`px-2 py-0.5 rounded-[8px] border text-[10px] font-700 uppercase tracking-[0.08em] ${historyActionBadge(entry.action)}`}>
|
|
739
|
+
{historyActionLabel(entry.action)}
|
|
740
|
+
</span>
|
|
741
|
+
<span className="text-[11px] text-text-3/60 uppercase tracking-[0.08em]">{schedule.scheduleType}</span>
|
|
742
|
+
<span className="text-[11px] text-text-3/40 uppercase tracking-[0.08em]">rev {entry.revision}</span>
|
|
743
|
+
</div>
|
|
744
|
+
<div className="text-[15px] font-600 text-text-2">{schedule.name}</div>
|
|
745
|
+
<div className="text-[13px] text-text-3 mt-1 line-clamp-2">{entry.summary}</div>
|
|
746
|
+
{changes.length > 0 && (
|
|
747
|
+
<div className="mt-3 grid grid-cols-1 md:grid-cols-2 gap-2">
|
|
748
|
+
{changes.map((change) => (
|
|
749
|
+
<div key={`${entry.id}:${change.field}`} className="rounded-[10px] border border-white/[0.06] bg-white/[0.025] px-3 py-2">
|
|
750
|
+
<div className="text-[10px] uppercase tracking-[0.08em] text-text-3/50 font-700">{change.label}</div>
|
|
751
|
+
<div className="mt-1 text-[12px] text-text-2 break-words">
|
|
752
|
+
<span className="text-text-3">{formatHistoryValue(change.before)}</span>
|
|
753
|
+
<span className="mx-1.5 text-text-3/40">-></span>
|
|
754
|
+
<span>{formatHistoryValue(change.after)}</span>
|
|
755
|
+
</div>
|
|
756
|
+
</div>
|
|
757
|
+
))}
|
|
758
|
+
{remainingChanges > 0 && (
|
|
759
|
+
<div className="rounded-[10px] border border-white/[0.06] bg-white/[0.025] px-3 py-2 text-[12px] text-text-3">
|
|
760
|
+
{remainingChanges} more change{remainingChanges === 1 ? '' : 's'}
|
|
761
|
+
</div>
|
|
762
|
+
)}
|
|
763
|
+
</div>
|
|
764
|
+
)}
|
|
765
|
+
<div className="flex flex-wrap items-center gap-2 mt-3">
|
|
766
|
+
{agent && (
|
|
767
|
+
<div className="inline-flex items-center gap-2 rounded-[10px] bg-white/[0.03] px-2.5 py-1.5 text-[12px] text-text-2">
|
|
768
|
+
<AgentAvatar
|
|
769
|
+
seed={agent.avatarSeed}
|
|
770
|
+
avatarUrl={agent.avatarUrl}
|
|
771
|
+
name={agent.name}
|
|
772
|
+
size={16}
|
|
773
|
+
/>
|
|
774
|
+
<span>{agent.name}</span>
|
|
775
|
+
</div>
|
|
776
|
+
)}
|
|
777
|
+
<span className="text-[12px] text-text-3/60">{timeAgo(entry.at, now)}</span>
|
|
778
|
+
<span className="text-[12px] text-text-3/50">Actor: {entry.actor}</span>
|
|
779
|
+
</div>
|
|
780
|
+
</div>
|
|
781
|
+
<div className="flex flex-wrap items-center gap-2 shrink-0">
|
|
782
|
+
<ActionButton onClick={() => openSchedule(schedule.id)}>Open Schedule</ActionButton>
|
|
783
|
+
</div>
|
|
784
|
+
</div>
|
|
785
|
+
</div>
|
|
786
|
+
)
|
|
787
|
+
})}
|
|
788
|
+
</div>
|
|
631
789
|
) : (
|
|
632
790
|
<div className="divide-y divide-white/[0.05]">
|
|
633
791
|
{filteredSchedules.length === 0 ? (
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { describe, it } from 'node:test'
|
|
3
|
+
|
|
4
|
+
import { buildReleaseReadinessReport } from './release-readiness'
|
|
5
|
+
import type { EvalGateResult } from '@/lib/server/eval/types'
|
|
6
|
+
import type { OperationPulse } from '@/types'
|
|
7
|
+
|
|
8
|
+
const now = 100_000
|
|
9
|
+
|
|
10
|
+
function pulse(overrides: Partial<OperationPulse> = {}): OperationPulse {
|
|
11
|
+
return {
|
|
12
|
+
generatedAt: now,
|
|
13
|
+
range: '24h',
|
|
14
|
+
windowStart: now - 86_400_000,
|
|
15
|
+
kpis: {
|
|
16
|
+
activeMissions: 0,
|
|
17
|
+
runningRuns: 0,
|
|
18
|
+
failedRuns: 0,
|
|
19
|
+
pendingApprovals: 0,
|
|
20
|
+
connectorAttention: 0,
|
|
21
|
+
gatewayAttention: 0,
|
|
22
|
+
budgetWarnings: 0,
|
|
23
|
+
},
|
|
24
|
+
actions: [],
|
|
25
|
+
...overrides,
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function evalGate(overrides: Partial<EvalGateResult> = {}): EvalGateResult {
|
|
30
|
+
return {
|
|
31
|
+
agentId: 'agent_1',
|
|
32
|
+
scope: {
|
|
33
|
+
type: 'suite',
|
|
34
|
+
id: 'core',
|
|
35
|
+
label: 'core',
|
|
36
|
+
scenarioIds: ['coding-prime'],
|
|
37
|
+
},
|
|
38
|
+
status: 'pass',
|
|
39
|
+
generatedAt: now,
|
|
40
|
+
baseline: null,
|
|
41
|
+
latestRuns: [],
|
|
42
|
+
currentScore: 10,
|
|
43
|
+
currentMaxScore: 10,
|
|
44
|
+
currentPercent: 100,
|
|
45
|
+
regressionPoints: 0,
|
|
46
|
+
minPercent: 80,
|
|
47
|
+
maxRegressionPoints: 5,
|
|
48
|
+
checks: [{ code: 'score_threshold_met', status: 'pass', message: 'Current score meets the 80% gate.' }],
|
|
49
|
+
...overrides,
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe('release readiness report', () => {
|
|
54
|
+
it('passes when eval gate and operations pulse are clean', () => {
|
|
55
|
+
const report = buildReleaseReadinessReport({
|
|
56
|
+
pulse: pulse(),
|
|
57
|
+
evalGate: evalGate(),
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
assert.equal(report.status, 'ready')
|
|
61
|
+
assert.equal(report.score, 100)
|
|
62
|
+
assert.equal(report.blockerCount, 0)
|
|
63
|
+
assert.equal(report.warningCount, 0)
|
|
64
|
+
assert.ok(report.checks.some((check) => check.code === 'eval_gate_passed'))
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('warns when no eval gate is selected', () => {
|
|
68
|
+
const report = buildReleaseReadinessReport({
|
|
69
|
+
pulse: pulse(),
|
|
70
|
+
evalGate: null,
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
assert.equal(report.status, 'warning')
|
|
74
|
+
assert.equal(report.blockerCount, 0)
|
|
75
|
+
assert.equal(report.warningCount, 1)
|
|
76
|
+
assert.ok(report.score < 100)
|
|
77
|
+
assert.ok(report.checks.some((check) => check.code === 'eval_gate_missing'))
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('blocks when eval regression gate fails', () => {
|
|
81
|
+
const report = buildReleaseReadinessReport({
|
|
82
|
+
pulse: pulse(),
|
|
83
|
+
evalGate: evalGate({
|
|
84
|
+
status: 'fail',
|
|
85
|
+
currentPercent: 60,
|
|
86
|
+
checks: [{ code: 'score_below_threshold', status: 'fail', message: 'Current score is below the 80% gate.' }],
|
|
87
|
+
}),
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
assert.equal(report.status, 'blocked')
|
|
91
|
+
assert.equal(report.blockerCount, 1)
|
|
92
|
+
assert.ok(report.score <= 70)
|
|
93
|
+
assert.ok(report.checks.some((check) => check.code === 'eval_gate_failed'))
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('blocks on failed runs and pending approvals, then surfaces pulse actions', () => {
|
|
97
|
+
const report = buildReleaseReadinessReport({
|
|
98
|
+
pulse: pulse({
|
|
99
|
+
kpis: {
|
|
100
|
+
activeMissions: 1,
|
|
101
|
+
runningRuns: 1,
|
|
102
|
+
failedRuns: 2,
|
|
103
|
+
pendingApprovals: 3,
|
|
104
|
+
connectorAttention: 1,
|
|
105
|
+
gatewayAttention: 1,
|
|
106
|
+
budgetWarnings: 1,
|
|
107
|
+
},
|
|
108
|
+
actions: [{
|
|
109
|
+
id: 'run:failed',
|
|
110
|
+
kind: 'run',
|
|
111
|
+
severity: 'high',
|
|
112
|
+
title: 'Review failed run',
|
|
113
|
+
summary: 'Run failed',
|
|
114
|
+
href: '/quality?tab=runs',
|
|
115
|
+
evidence: ['run'],
|
|
116
|
+
createdAt: now,
|
|
117
|
+
}],
|
|
118
|
+
}),
|
|
119
|
+
evalGate: evalGate(),
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
assert.equal(report.status, 'blocked')
|
|
123
|
+
assert.equal(report.blockerCount, 2)
|
|
124
|
+
assert.ok(report.warningCount >= 4)
|
|
125
|
+
assert.equal(report.nextActions[0]?.id, 'run:failed')
|
|
126
|
+
assert.ok(report.checks.some((check) => check.code === 'failed_runs_present'))
|
|
127
|
+
assert.ok(report.checks.some((check) => check.code === 'pending_approvals_present'))
|
|
128
|
+
})
|
|
129
|
+
})
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import type { EvalGateResult } from '@/lib/server/eval/types'
|
|
2
|
+
import type { OperationPulse, OperationPulseAction, OperationPulseRange } from '@/types'
|
|
3
|
+
|
|
4
|
+
export type ReleaseReadinessStatus = 'ready' | 'warning' | 'blocked'
|
|
5
|
+
|
|
6
|
+
export interface ReleaseReadinessCheck {
|
|
7
|
+
code: string
|
|
8
|
+
status: ReleaseReadinessStatus
|
|
9
|
+
title: string
|
|
10
|
+
summary: string
|
|
11
|
+
href?: string
|
|
12
|
+
evidence?: string[]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ReleaseReadinessReport {
|
|
16
|
+
generatedAt: number
|
|
17
|
+
range: OperationPulseRange
|
|
18
|
+
status: ReleaseReadinessStatus
|
|
19
|
+
score: number
|
|
20
|
+
blockerCount: number
|
|
21
|
+
warningCount: number
|
|
22
|
+
pulse: OperationPulse
|
|
23
|
+
evalGate: EvalGateResult | null
|
|
24
|
+
checks: ReleaseReadinessCheck[]
|
|
25
|
+
nextActions: OperationPulseAction[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const BLOCKER_PENALTY = 30
|
|
29
|
+
const WARNING_PENALTY = 10
|
|
30
|
+
|
|
31
|
+
function readinessStatus(checks: ReleaseReadinessCheck[]): ReleaseReadinessStatus {
|
|
32
|
+
if (checks.some((check) => check.status === 'blocked')) return 'blocked'
|
|
33
|
+
if (checks.some((check) => check.status === 'warning')) return 'warning'
|
|
34
|
+
return 'ready'
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function readinessScore(checks: ReleaseReadinessCheck[]): number {
|
|
38
|
+
const penalty = checks.reduce((sum, check) => {
|
|
39
|
+
if (check.status === 'blocked') return sum + BLOCKER_PENALTY
|
|
40
|
+
if (check.status === 'warning') return sum + WARNING_PENALTY
|
|
41
|
+
return sum
|
|
42
|
+
}, 0)
|
|
43
|
+
return Math.max(0, 100 - penalty)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function plural(count: number, singular: string, pluralLabel = `${singular}s`): string {
|
|
47
|
+
return `${count} ${count === 1 ? singular : pluralLabel}`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function addCheck(checks: ReleaseReadinessCheck[], check: ReleaseReadinessCheck): void {
|
|
51
|
+
checks.push(check)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function buildReleaseReadinessReport(input: {
|
|
55
|
+
pulse: OperationPulse
|
|
56
|
+
evalGate?: EvalGateResult | null
|
|
57
|
+
}): ReleaseReadinessReport {
|
|
58
|
+
const checks: ReleaseReadinessCheck[] = []
|
|
59
|
+
const evalGate = input.evalGate ?? null
|
|
60
|
+
|
|
61
|
+
if (!evalGate) {
|
|
62
|
+
addCheck(checks, {
|
|
63
|
+
code: 'eval_gate_missing',
|
|
64
|
+
status: 'warning',
|
|
65
|
+
title: 'Select an eval gate',
|
|
66
|
+
summary: 'No eval regression gate is included in this readiness report.',
|
|
67
|
+
href: '/quality?tab=evals',
|
|
68
|
+
})
|
|
69
|
+
} else if (evalGate.status === 'fail') {
|
|
70
|
+
addCheck(checks, {
|
|
71
|
+
code: 'eval_gate_failed',
|
|
72
|
+
status: 'blocked',
|
|
73
|
+
title: 'Eval gate failed',
|
|
74
|
+
summary: `${evalGate.scope.label} is not passing the configured eval release gate.`,
|
|
75
|
+
href: '/quality?tab=evals',
|
|
76
|
+
evidence: evalGate.checks
|
|
77
|
+
.filter((check) => check.status === 'fail')
|
|
78
|
+
.map((check) => check.message),
|
|
79
|
+
})
|
|
80
|
+
} else if (evalGate.status === 'warn') {
|
|
81
|
+
addCheck(checks, {
|
|
82
|
+
code: 'eval_gate_warning',
|
|
83
|
+
status: 'warning',
|
|
84
|
+
title: 'Eval gate needs a baseline',
|
|
85
|
+
summary: `${evalGate.scope.label} passes the score threshold but still has release-gate warnings.`,
|
|
86
|
+
href: '/quality?tab=evals',
|
|
87
|
+
evidence: evalGate.checks
|
|
88
|
+
.filter((check) => check.status === 'warn')
|
|
89
|
+
.map((check) => check.message),
|
|
90
|
+
})
|
|
91
|
+
} else {
|
|
92
|
+
addCheck(checks, {
|
|
93
|
+
code: 'eval_gate_passed',
|
|
94
|
+
status: 'ready',
|
|
95
|
+
title: 'Eval gate passed',
|
|
96
|
+
summary: `${evalGate.scope.label} meets the configured score and regression checks.`,
|
|
97
|
+
href: '/quality?tab=evals',
|
|
98
|
+
evidence: [`${evalGate.currentPercent ?? 'n/a'}% current score`],
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (input.pulse.kpis.failedRuns > 0) {
|
|
103
|
+
addCheck(checks, {
|
|
104
|
+
code: 'failed_runs_present',
|
|
105
|
+
status: 'blocked',
|
|
106
|
+
title: 'Failed runs need review',
|
|
107
|
+
summary: `${plural(input.pulse.kpis.failedRuns, 'failed run')} found in the ${input.pulse.range} operations window.`,
|
|
108
|
+
href: '/quality?tab=runs',
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (input.pulse.kpis.pendingApprovals > 0) {
|
|
113
|
+
addCheck(checks, {
|
|
114
|
+
code: 'pending_approvals_present',
|
|
115
|
+
status: 'blocked',
|
|
116
|
+
title: 'Pending approvals need decisions',
|
|
117
|
+
summary: `${plural(input.pulse.kpis.pendingApprovals, 'approval')} still waiting on an operator.`,
|
|
118
|
+
href: '/quality?tab=approvals',
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (input.pulse.kpis.runningRuns > 0) {
|
|
123
|
+
addCheck(checks, {
|
|
124
|
+
code: 'active_runs_present',
|
|
125
|
+
status: 'warning',
|
|
126
|
+
title: 'Runs are still active',
|
|
127
|
+
summary: `${plural(input.pulse.kpis.runningRuns, 'run')} queued or running while this report was generated.`,
|
|
128
|
+
href: '/runs',
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (input.pulse.kpis.connectorAttention > 0) {
|
|
133
|
+
addCheck(checks, {
|
|
134
|
+
code: 'connector_attention_present',
|
|
135
|
+
status: 'warning',
|
|
136
|
+
title: 'Connector readiness needs attention',
|
|
137
|
+
summary: `${plural(input.pulse.kpis.connectorAttention, 'connector')} reporting degraded readiness.`,
|
|
138
|
+
href: '/connectors',
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (input.pulse.kpis.gatewayAttention > 0) {
|
|
143
|
+
addCheck(checks, {
|
|
144
|
+
code: 'gateway_attention_present',
|
|
145
|
+
status: 'warning',
|
|
146
|
+
title: 'Gateway readiness needs attention',
|
|
147
|
+
summary: `${plural(input.pulse.kpis.gatewayAttention, 'gateway')} reporting topology or environment warnings.`,
|
|
148
|
+
href: '/providers',
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (input.pulse.kpis.budgetWarnings > 0) {
|
|
153
|
+
addCheck(checks, {
|
|
154
|
+
code: 'budget_warnings_present',
|
|
155
|
+
status: 'warning',
|
|
156
|
+
title: 'Mission budget pressure',
|
|
157
|
+
summary: `${plural(input.pulse.kpis.budgetWarnings, 'mission')} near a configured budget limit.`,
|
|
158
|
+
href: '/missions',
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (input.pulse.kpis.activeMissions > 0) {
|
|
163
|
+
addCheck(checks, {
|
|
164
|
+
code: 'active_missions_present',
|
|
165
|
+
status: 'warning',
|
|
166
|
+
title: 'Missions are still active',
|
|
167
|
+
summary: `${plural(input.pulse.kpis.activeMissions, 'mission')} running or paused in the operations window.`,
|
|
168
|
+
href: '/missions',
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const blockerCount = checks.filter((check) => check.status === 'blocked').length
|
|
173
|
+
const warningCount = checks.filter((check) => check.status === 'warning').length
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
generatedAt: input.pulse.generatedAt,
|
|
177
|
+
range: input.pulse.range,
|
|
178
|
+
status: readinessStatus(checks),
|
|
179
|
+
score: readinessScore(checks),
|
|
180
|
+
blockerCount,
|
|
181
|
+
warningCount,
|
|
182
|
+
pulse: input.pulse,
|
|
183
|
+
evalGate,
|
|
184
|
+
checks,
|
|
185
|
+
nextActions: input.pulse.actions.slice(0, 8),
|
|
186
|
+
}
|
|
187
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { api } from '@/lib/app/api-client'
|
|
2
|
-
import type { Schedule } from '@/types'
|
|
2
|
+
import type { Schedule, ScheduleHistoryEntry } from '@/types'
|
|
3
3
|
|
|
4
4
|
export interface ScheduleArchiveResponse {
|
|
5
5
|
ok: boolean
|
|
@@ -19,6 +19,12 @@ export interface SchedulePurgeResponse {
|
|
|
19
19
|
purgedIds: string[]
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
export interface ScheduleHistoryResponse {
|
|
23
|
+
scheduleId: string
|
|
24
|
+
revision: number
|
|
25
|
+
history: ScheduleHistoryEntry[]
|
|
26
|
+
}
|
|
27
|
+
|
|
22
28
|
export const fetchSchedules = (includeArchived = false) =>
|
|
23
29
|
api<Record<string, Schedule>>('GET', `/schedules${includeArchived ? '?includeArchived=true' : ''}`)
|
|
24
30
|
|
|
@@ -42,3 +48,6 @@ export const purgeSchedule = (id: string) =>
|
|
|
42
48
|
|
|
43
49
|
export const runSchedule = (id: string) =>
|
|
44
50
|
api<{ ok: boolean; queued?: boolean; reason?: string; taskId?: string; runNumber?: number }>('POST', `/schedules/${id}/run`)
|
|
51
|
+
|
|
52
|
+
export const fetchScheduleHistory = (id: string) =>
|
|
53
|
+
api<ScheduleHistoryResponse>('GET', `/schedules/${id}/history`)
|