arkaos 3.26.0 → 3.28.0
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/VERSION +1 -1
- package/core/runtime/__pycache__/llm_provider.cpython-313.pyc +0 -0
- package/core/runtime/llm_provider.py +1 -0
- package/dashboard/app/layouts/default.vue +7 -0
- package/dashboard/app/pages/departments/[dept].vue +158 -0
- package/dashboard/app/pages/departments/index.vue +127 -0
- package/dashboard/app/pages/workflows.vue +92 -4
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/__pycache__/dashboard-api.cpython-313.pyc +0 -0
- package/scripts/dashboard-api.py +190 -0
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
3.
|
|
1
|
+
3.28.0
|
|
Binary file
|
|
@@ -368,6 +368,7 @@ def _current_category() -> str:
|
|
|
368
368
|
PR60 v2.77.0 — orchestration layers can set
|
|
369
369
|
``ARKA_CALL_CATEGORY=skill:<slug>`` /
|
|
370
370
|
``subagent:<dept>`` or ``subagent:<dept>:<agent_id>`` /
|
|
371
|
+
``workflow:<workflow_id>`` /
|
|
371
372
|
``plugin:<id>`` / ``mcp:<server>`` before
|
|
372
373
|
invoking the provider so `/arka costs --by-category` (PR47) can
|
|
373
374
|
attribute spend. Returns "" when unset, which lands in the base
|
|
@@ -20,6 +20,13 @@ const links = [[{
|
|
|
20
20
|
onSelect: () => {
|
|
21
21
|
open.value = false
|
|
22
22
|
}
|
|
23
|
+
}, {
|
|
24
|
+
label: 'Departments',
|
|
25
|
+
icon: 'i-lucide-folder-tree',
|
|
26
|
+
to: '/departments',
|
|
27
|
+
onSelect: () => {
|
|
28
|
+
open.value = false
|
|
29
|
+
}
|
|
23
30
|
}, {
|
|
24
31
|
label: 'Personas',
|
|
25
32
|
icon: 'i-lucide-user-plus',
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// PR89a v3.27.0 — Department detail page.
|
|
3
|
+
//
|
|
4
|
+
// Shows agents in the department + its workflows + 30d cost.
|
|
5
|
+
|
|
6
|
+
const route = useRoute()
|
|
7
|
+
const deptId = computed(() => String(route.params.dept ?? ''))
|
|
8
|
+
const { fetchApi } = useApi()
|
|
9
|
+
|
|
10
|
+
interface AgentLite {
|
|
11
|
+
id: string
|
|
12
|
+
name?: string
|
|
13
|
+
role?: string
|
|
14
|
+
tier?: number
|
|
15
|
+
mbti?: string
|
|
16
|
+
disc?: { primary?: string, secondary?: string }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface WorkflowLite {
|
|
20
|
+
id: string
|
|
21
|
+
name: string
|
|
22
|
+
tier: string
|
|
23
|
+
command: string
|
|
24
|
+
phases_count: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface DeptDetail {
|
|
28
|
+
department: string
|
|
29
|
+
agents: AgentLite[]
|
|
30
|
+
workflows: WorkflowLite[]
|
|
31
|
+
calls_30d: number
|
|
32
|
+
cost_usd_30d: number | null
|
|
33
|
+
error?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const { data, status, error, refresh } = await fetchApi<DeptDetail>(
|
|
37
|
+
`/api/departments/${deptId.value}`,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
const errorMsg = computed(() => data.value?.error || error.value?.message || null)
|
|
41
|
+
const detail = computed<DeptDetail | null>(() => {
|
|
42
|
+
if (!data.value || data.value.error) return null
|
|
43
|
+
return data.value
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
function formatCost(cost: number | null): string {
|
|
47
|
+
if (cost === null || cost === undefined) return '—'
|
|
48
|
+
if (cost < 0.01) return '<$0.01'
|
|
49
|
+
if (cost < 1) return `$${cost.toFixed(3)}`
|
|
50
|
+
return `$${cost.toFixed(2)}`
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const tierColor = (tier: number | undefined) => {
|
|
54
|
+
const colors: Record<number, 'error' | 'warning' | 'primary' | 'neutral'> = {
|
|
55
|
+
0: 'error', 1: 'warning', 2: 'primary', 3: 'neutral',
|
|
56
|
+
}
|
|
57
|
+
return colors[tier ?? 99] ?? 'neutral'
|
|
58
|
+
}
|
|
59
|
+
</script>
|
|
60
|
+
|
|
61
|
+
<template>
|
|
62
|
+
<UDashboardPanel :id="`dept-${deptId}`">
|
|
63
|
+
<template #header>
|
|
64
|
+
<UDashboardNavbar :title="`Department · ${deptId}`">
|
|
65
|
+
<template #leading>
|
|
66
|
+
<UButton icon="i-lucide-arrow-left" variant="ghost" size="sm" to="/departments" aria-label="Back" />
|
|
67
|
+
</template>
|
|
68
|
+
</UDashboardNavbar>
|
|
69
|
+
</template>
|
|
70
|
+
|
|
71
|
+
<template #body>
|
|
72
|
+
<DashboardState
|
|
73
|
+
:status="status"
|
|
74
|
+
:error="errorMsg ? new Error(errorMsg) : null"
|
|
75
|
+
:empty="!detail"
|
|
76
|
+
empty-title="Department not found"
|
|
77
|
+
empty-icon="i-lucide-folder-x"
|
|
78
|
+
loading-label="Loading department"
|
|
79
|
+
:on-retry="() => refresh()"
|
|
80
|
+
>
|
|
81
|
+
<div v-if="detail" class="space-y-5 max-w-5xl">
|
|
82
|
+
<!-- Stats row -->
|
|
83
|
+
<section class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
|
84
|
+
<div class="rounded-xl border border-default p-4 bg-elevated/20">
|
|
85
|
+
<p class="text-sm font-semibold text-muted uppercase tracking-wide mb-1">Agents</p>
|
|
86
|
+
<p class="text-2xl font-bold">{{ detail.agents.length }}</p>
|
|
87
|
+
</div>
|
|
88
|
+
<div class="rounded-xl border border-default p-4 bg-elevated/20">
|
|
89
|
+
<p class="text-sm font-semibold text-muted uppercase tracking-wide mb-1">Workflows</p>
|
|
90
|
+
<p class="text-2xl font-bold">{{ detail.workflows.length }}</p>
|
|
91
|
+
</div>
|
|
92
|
+
<div class="rounded-xl border border-default p-4 bg-elevated/20">
|
|
93
|
+
<p class="text-sm font-semibold text-muted uppercase tracking-wide mb-1">Calls (30d)</p>
|
|
94
|
+
<p class="text-2xl font-bold">{{ detail.calls_30d }}</p>
|
|
95
|
+
</div>
|
|
96
|
+
<div class="rounded-xl border border-default p-4 bg-elevated/20">
|
|
97
|
+
<p class="text-sm font-semibold text-muted uppercase tracking-wide mb-1">Cost (30d)</p>
|
|
98
|
+
<p class="text-2xl font-bold">{{ formatCost(detail.cost_usd_30d) }}</p>
|
|
99
|
+
</div>
|
|
100
|
+
</section>
|
|
101
|
+
|
|
102
|
+
<!-- Agents -->
|
|
103
|
+
<section>
|
|
104
|
+
<h2 class="text-sm font-semibold uppercase tracking-wide text-muted mb-3">Agents</h2>
|
|
105
|
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
|
106
|
+
<NuxtLink
|
|
107
|
+
v-for="a in detail.agents"
|
|
108
|
+
:key="a.id"
|
|
109
|
+
:to="`/agents/${a.id}`"
|
|
110
|
+
class="block rounded-lg border border-default p-3 hover:border-primary/40 transition-colors"
|
|
111
|
+
>
|
|
112
|
+
<div class="flex items-center justify-between gap-3">
|
|
113
|
+
<div class="min-w-0">
|
|
114
|
+
<p class="font-semibold truncate">{{ a.name }}</p>
|
|
115
|
+
<p class="text-xs text-muted truncate">{{ a.role || '—' }}</p>
|
|
116
|
+
</div>
|
|
117
|
+
<div class="flex items-center gap-2 shrink-0">
|
|
118
|
+
<UBadge
|
|
119
|
+
:label="`T${a.tier}`"
|
|
120
|
+
:color="tierColor(a.tier)"
|
|
121
|
+
variant="subtle"
|
|
122
|
+
size="xs"
|
|
123
|
+
/>
|
|
124
|
+
<UBadge v-if="a.mbti" :label="a.mbti" variant="soft" size="xs" />
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
</NuxtLink>
|
|
128
|
+
</div>
|
|
129
|
+
</section>
|
|
130
|
+
|
|
131
|
+
<!-- Workflows -->
|
|
132
|
+
<section v-if="detail.workflows.length > 0">
|
|
133
|
+
<h2 class="text-sm font-semibold uppercase tracking-wide text-muted mb-3">Workflows</h2>
|
|
134
|
+
<div class="space-y-2">
|
|
135
|
+
<NuxtLink
|
|
136
|
+
v-for="w in detail.workflows"
|
|
137
|
+
:key="w.id"
|
|
138
|
+
to="/workflows"
|
|
139
|
+
class="block rounded-lg border border-default p-3 hover:border-primary/40 transition-colors"
|
|
140
|
+
>
|
|
141
|
+
<div class="flex items-center justify-between gap-3">
|
|
142
|
+
<div class="min-w-0">
|
|
143
|
+
<p class="font-semibold truncate">{{ w.name }}</p>
|
|
144
|
+
<p class="text-xs text-muted font-mono truncate">{{ w.command || w.id }}</p>
|
|
145
|
+
</div>
|
|
146
|
+
<div class="flex items-center gap-2 shrink-0 text-xs">
|
|
147
|
+
<UBadge v-if="w.tier" :label="w.tier" variant="subtle" size="xs" />
|
|
148
|
+
<span class="text-muted">{{ w.phases_count }} phase{{ w.phases_count === 1 ? '' : 's' }}</span>
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
</NuxtLink>
|
|
152
|
+
</div>
|
|
153
|
+
</section>
|
|
154
|
+
</div>
|
|
155
|
+
</DashboardState>
|
|
156
|
+
</template>
|
|
157
|
+
</UDashboardPanel>
|
|
158
|
+
</template>
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// PR89a v3.27.0 — Departments index.
|
|
3
|
+
//
|
|
4
|
+
// Lists every department with agent count, tier distribution, and 30d
|
|
5
|
+
// cost. Click a row to drill into /departments/{dept}.
|
|
6
|
+
|
|
7
|
+
import type { TableColumn } from '@nuxt/ui'
|
|
8
|
+
|
|
9
|
+
interface DeptRow {
|
|
10
|
+
department: string
|
|
11
|
+
agent_count: number
|
|
12
|
+
tier_counts: Record<'0' | '1' | '2' | '3', number>
|
|
13
|
+
calls_30d: number
|
|
14
|
+
cost_usd_30d: number | null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const { fetchApi } = useApi()
|
|
18
|
+
const { data, status, error, refresh } = await fetchApi<{
|
|
19
|
+
departments: DeptRow[]
|
|
20
|
+
total: number
|
|
21
|
+
}>('/api/departments')
|
|
22
|
+
|
|
23
|
+
const rows = computed<DeptRow[]>(() => data.value?.departments ?? [])
|
|
24
|
+
const search = ref('')
|
|
25
|
+
const filtered = computed(() => {
|
|
26
|
+
const q = search.value.toLowerCase().trim()
|
|
27
|
+
if (!q) return rows.value
|
|
28
|
+
return rows.value.filter((r) => r.department.toLowerCase().includes(q))
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
const columns: TableColumn<DeptRow>[] = [
|
|
32
|
+
{ accessorKey: 'department', header: 'Department' },
|
|
33
|
+
{ accessorKey: 'agent_count', header: 'Agents' },
|
|
34
|
+
{ id: 'tiers', header: 'Tiers (0/1/2/3)' },
|
|
35
|
+
{ accessorKey: 'calls_30d', header: 'Calls (30d)' },
|
|
36
|
+
{ accessorKey: 'cost_usd_30d', header: 'Cost (30d)' },
|
|
37
|
+
{ id: 'actions', header: '' },
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
function formatCost(cost: number | null): string {
|
|
41
|
+
if (cost === null || cost === undefined) return '—'
|
|
42
|
+
if (cost < 0.01) return '<$0.01'
|
|
43
|
+
if (cost < 1) return `$${cost.toFixed(3)}`
|
|
44
|
+
return `$${cost.toFixed(2)}`
|
|
45
|
+
}
|
|
46
|
+
</script>
|
|
47
|
+
|
|
48
|
+
<template>
|
|
49
|
+
<UDashboardPanel id="departments">
|
|
50
|
+
<template #header>
|
|
51
|
+
<UDashboardNavbar title="Departments">
|
|
52
|
+
<template #leading>
|
|
53
|
+
<UDashboardSidebarCollapse />
|
|
54
|
+
</template>
|
|
55
|
+
<template #trailing>
|
|
56
|
+
<UBadge v-if="data?.total" :label="String(data.total)" variant="subtle" />
|
|
57
|
+
</template>
|
|
58
|
+
</UDashboardNavbar>
|
|
59
|
+
</template>
|
|
60
|
+
|
|
61
|
+
<template #body>
|
|
62
|
+
<DashboardState
|
|
63
|
+
:status="status"
|
|
64
|
+
:error="error"
|
|
65
|
+
:empty="!rows.length"
|
|
66
|
+
empty-title="No departments yet"
|
|
67
|
+
empty-icon="i-lucide-folder-tree"
|
|
68
|
+
loading-label="Loading departments"
|
|
69
|
+
:on-retry="() => refresh()"
|
|
70
|
+
>
|
|
71
|
+
<div class="flex items-center gap-3 mb-4">
|
|
72
|
+
<UInput
|
|
73
|
+
v-model="search"
|
|
74
|
+
class="max-w-sm"
|
|
75
|
+
icon="i-lucide-search"
|
|
76
|
+
placeholder="Filter departments…"
|
|
77
|
+
/>
|
|
78
|
+
<span class="ml-auto text-xs text-muted">
|
|
79
|
+
{{ filtered.length }} dept{{ filtered.length === 1 ? '' : 's' }}
|
|
80
|
+
</span>
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
<UTable
|
|
84
|
+
:data="filtered"
|
|
85
|
+
:columns="columns"
|
|
86
|
+
:loading="status === 'pending'"
|
|
87
|
+
:ui="{
|
|
88
|
+
tbody: '[&>tr]:cursor-pointer [&>tr]:hover:bg-elevated/50 [&>tr]:transition-colors',
|
|
89
|
+
th: 'py-2 first:rounded-l-lg last:rounded-r-lg border-y border-default first:border-l last:border-r',
|
|
90
|
+
td: 'border-b border-default',
|
|
91
|
+
}"
|
|
92
|
+
@select="(row: { original: DeptRow }) => navigateTo(`/departments/${row.original.department}`)"
|
|
93
|
+
>
|
|
94
|
+
<template #department-cell="{ row }">
|
|
95
|
+
<span class="font-semibold capitalize">{{ row.original.department }}</span>
|
|
96
|
+
</template>
|
|
97
|
+
<template #agent_count-cell="{ row }">
|
|
98
|
+
<span class="font-mono font-semibold">{{ row.original.agent_count }}</span>
|
|
99
|
+
</template>
|
|
100
|
+
<template #tiers-cell="{ row }">
|
|
101
|
+
<div class="flex items-center gap-1 text-xs font-mono">
|
|
102
|
+
<UBadge v-if="row.original.tier_counts['0'] > 0" :label="`0:${row.original.tier_counts['0']}`" color="error" variant="subtle" size="xs" />
|
|
103
|
+
<UBadge v-if="row.original.tier_counts['1'] > 0" :label="`1:${row.original.tier_counts['1']}`" color="warning" variant="subtle" size="xs" />
|
|
104
|
+
<UBadge v-if="row.original.tier_counts['2'] > 0" :label="`2:${row.original.tier_counts['2']}`" color="primary" variant="subtle" size="xs" />
|
|
105
|
+
<UBadge v-if="row.original.tier_counts['3'] > 0" :label="`3:${row.original.tier_counts['3']}`" color="neutral" variant="subtle" size="xs" />
|
|
106
|
+
</div>
|
|
107
|
+
</template>
|
|
108
|
+
<template #calls_30d-cell="{ row }">
|
|
109
|
+
<span class="font-mono text-sm">{{ row.original.calls_30d }}</span>
|
|
110
|
+
</template>
|
|
111
|
+
<template #cost_usd_30d-cell="{ row }">
|
|
112
|
+
<span class="font-mono text-sm font-semibold">{{ formatCost(row.original.cost_usd_30d) }}</span>
|
|
113
|
+
</template>
|
|
114
|
+
<template #actions-cell="{ row }">
|
|
115
|
+
<UButton
|
|
116
|
+
icon="i-lucide-arrow-right"
|
|
117
|
+
variant="ghost"
|
|
118
|
+
size="xs"
|
|
119
|
+
aria-label="Open department"
|
|
120
|
+
@click.stop="navigateTo(`/departments/${row.original.department}`)"
|
|
121
|
+
/>
|
|
122
|
+
</template>
|
|
123
|
+
</UTable>
|
|
124
|
+
</DashboardState>
|
|
125
|
+
</template>
|
|
126
|
+
</UDashboardPanel>
|
|
127
|
+
</template>
|
|
@@ -19,9 +19,38 @@ interface Workflow {
|
|
|
19
19
|
content: string
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
const { fetchApi } = useApi()
|
|
22
|
+
const { fetchApi, apiBase } = useApi()
|
|
23
23
|
const { data, status, error, refresh } = await fetchApi<{ workflows: Workflow[] }>('/api/workflows')
|
|
24
24
|
|
|
25
|
+
// PR89b v3.28.0 — recent runs for the selected workflow.
|
|
26
|
+
interface WorkflowRun {
|
|
27
|
+
session_id: string
|
|
28
|
+
started_at: string
|
|
29
|
+
ended_at: string
|
|
30
|
+
duration_s: number | null
|
|
31
|
+
calls: number
|
|
32
|
+
cost_usd: number | null
|
|
33
|
+
tokens_in: number
|
|
34
|
+
tokens_out: number
|
|
35
|
+
}
|
|
36
|
+
const runs = ref<WorkflowRun[]>([])
|
|
37
|
+
const runsLoading = ref(false)
|
|
38
|
+
const sidePanelTab = ref<'yaml' | 'runs'>('yaml')
|
|
39
|
+
|
|
40
|
+
async function loadRuns(id: string) {
|
|
41
|
+
runsLoading.value = true
|
|
42
|
+
try {
|
|
43
|
+
const res = await $fetch<{ runs: WorkflowRun[] }>(
|
|
44
|
+
`${apiBase}/api/workflows/${id}/runs?limit=10`,
|
|
45
|
+
)
|
|
46
|
+
runs.value = res.runs ?? []
|
|
47
|
+
} catch {
|
|
48
|
+
runs.value = []
|
|
49
|
+
} finally {
|
|
50
|
+
runsLoading.value = false
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
25
54
|
const workflows = computed(() => data.value?.workflows ?? [])
|
|
26
55
|
const search = ref('')
|
|
27
56
|
const deptFilter = ref<'all' | string>('all')
|
|
@@ -123,7 +152,7 @@ const columns: TableColumn<Workflow>[] = [
|
|
|
123
152
|
th: 'py-2 first:rounded-l-lg last:rounded-r-lg border-y border-default first:border-l last:border-r',
|
|
124
153
|
td: 'border-b border-default',
|
|
125
154
|
}"
|
|
126
|
-
@select="(row: { original: Workflow }) => selected = row.original"
|
|
155
|
+
@select="(row: { original: Workflow }) => { selected = row.original; sidePanelTab = 'yaml'; runs = []; loadRuns(row.original.id) }"
|
|
127
156
|
>
|
|
128
157
|
<template #name-cell="{ row }">
|
|
129
158
|
<div class="min-w-0">
|
|
@@ -160,7 +189,7 @@ const columns: TableColumn<Workflow>[] = [
|
|
|
160
189
|
<div class="px-4 py-3 border-b border-default bg-elevated/30">
|
|
161
190
|
<div class="flex items-start justify-between gap-3">
|
|
162
191
|
<div class="min-w-0">
|
|
163
|
-
<p class="text-xs text-muted uppercase tracking-wide">
|
|
192
|
+
<p class="text-xs text-muted uppercase tracking-wide">Workflow</p>
|
|
164
193
|
<p class="font-semibold truncate">{{ selected.name }}</p>
|
|
165
194
|
<p class="text-xs text-muted font-mono truncate">{{ selected.file }}</p>
|
|
166
195
|
</div>
|
|
@@ -175,8 +204,67 @@ const columns: TableColumn<Workflow>[] = [
|
|
|
175
204
|
<p v-if="selected.description" class="text-xs text-muted mt-2">
|
|
176
205
|
{{ selected.description }}
|
|
177
206
|
</p>
|
|
207
|
+
<div class="flex items-center gap-1 mt-3 text-xs">
|
|
208
|
+
<button
|
|
209
|
+
type="button"
|
|
210
|
+
class="px-2 py-1 rounded-md transition-colors"
|
|
211
|
+
:class="sidePanelTab === 'yaml' ? 'bg-elevated/60 text-default font-semibold' : 'text-muted hover:text-default'"
|
|
212
|
+
@click="sidePanelTab = 'yaml'"
|
|
213
|
+
>
|
|
214
|
+
YAML
|
|
215
|
+
</button>
|
|
216
|
+
<button
|
|
217
|
+
type="button"
|
|
218
|
+
class="px-2 py-1 rounded-md transition-colors"
|
|
219
|
+
:class="sidePanelTab === 'runs' ? 'bg-elevated/60 text-default font-semibold' : 'text-muted hover:text-default'"
|
|
220
|
+
@click="sidePanelTab = 'runs'"
|
|
221
|
+
>
|
|
222
|
+
Runs
|
|
223
|
+
</button>
|
|
224
|
+
</div>
|
|
225
|
+
</div>
|
|
226
|
+
<div v-if="sidePanelTab === 'yaml'" class="overflow-x-auto">
|
|
227
|
+
<pre class="p-4 text-xs font-mono whitespace-pre">{{ selected.content }}</pre>
|
|
228
|
+
</div>
|
|
229
|
+
<div v-else class="p-4">
|
|
230
|
+
<div v-if="runsLoading" class="py-6 text-center text-sm text-muted">
|
|
231
|
+
<UIcon name="i-lucide-loader-2" class="size-4 animate-spin inline" /> Loading…
|
|
232
|
+
</div>
|
|
233
|
+
<div v-else-if="!runs.length" class="py-6 text-center text-sm text-muted">
|
|
234
|
+
<UIcon name="i-lucide-history" class="size-6 mx-auto mb-2" />
|
|
235
|
+
No recorded runs yet. Set
|
|
236
|
+
<code class="font-mono text-xs">ARKA_CALL_CATEGORY=workflow:{{ selected.id }}</code>
|
|
237
|
+
in the orchestrator to populate this.
|
|
238
|
+
</div>
|
|
239
|
+
<ul v-else class="space-y-2">
|
|
240
|
+
<li
|
|
241
|
+
v-for="r in runs"
|
|
242
|
+
:key="r.session_id"
|
|
243
|
+
class="rounded-lg border border-default p-3"
|
|
244
|
+
>
|
|
245
|
+
<div class="flex items-center justify-between gap-3 text-xs">
|
|
246
|
+
<span class="font-mono text-muted truncate">{{ r.session_id }}</span>
|
|
247
|
+
<span class="text-muted shrink-0">{{ r.started_at }}</span>
|
|
248
|
+
</div>
|
|
249
|
+
<div class="flex items-center gap-3 text-xs mt-2">
|
|
250
|
+
<span>
|
|
251
|
+
<span class="text-muted">Calls</span>
|
|
252
|
+
<span class="font-mono font-semibold ml-1">{{ r.calls }}</span>
|
|
253
|
+
</span>
|
|
254
|
+
<span>
|
|
255
|
+
<span class="text-muted">Cost</span>
|
|
256
|
+
<span class="font-mono font-semibold ml-1">
|
|
257
|
+
{{ r.cost_usd === null ? '—' : `$${r.cost_usd.toFixed(3)}` }}
|
|
258
|
+
</span>
|
|
259
|
+
</span>
|
|
260
|
+
<span v-if="r.duration_s !== null">
|
|
261
|
+
<span class="text-muted">Duration</span>
|
|
262
|
+
<span class="font-mono font-semibold ml-1">{{ r.duration_s }}s</span>
|
|
263
|
+
</span>
|
|
264
|
+
</div>
|
|
265
|
+
</li>
|
|
266
|
+
</ul>
|
|
178
267
|
</div>
|
|
179
|
-
<pre class="p-4 text-xs font-mono overflow-x-auto whitespace-pre">{{ selected.content }}</pre>
|
|
180
268
|
</div>
|
|
181
269
|
<div
|
|
182
270
|
v-else
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
Binary file
|
package/scripts/dashboard-api.py
CHANGED
|
@@ -1494,8 +1494,198 @@ def agent_export_to_vault(agent_id: str):
|
|
|
1494
1494
|
return {"exported": True, "path": str(res.path), "vault_path": str(res.vault_path)}
|
|
1495
1495
|
|
|
1496
1496
|
|
|
1497
|
+
# --- Departments (PR89a v3.27.0) ---
|
|
1498
|
+
|
|
1499
|
+
@app.get("/api/departments")
|
|
1500
|
+
def departments_list():
|
|
1501
|
+
"""List every department with agent count + 30d cost summary."""
|
|
1502
|
+
agents = _load_agents()
|
|
1503
|
+
by_dept: dict[str, dict] = {}
|
|
1504
|
+
for a in agents:
|
|
1505
|
+
dept = a.get("department") or ""
|
|
1506
|
+
if not dept:
|
|
1507
|
+
continue
|
|
1508
|
+
row = by_dept.setdefault(dept, {
|
|
1509
|
+
"department": dept,
|
|
1510
|
+
"agent_count": 0,
|
|
1511
|
+
"tier_counts": {"0": 0, "1": 0, "2": 0, "3": 0},
|
|
1512
|
+
})
|
|
1513
|
+
row["agent_count"] += 1
|
|
1514
|
+
tier_key = str(a.get("tier") or "")
|
|
1515
|
+
if tier_key in row["tier_counts"]:
|
|
1516
|
+
row["tier_counts"][tier_key] += 1
|
|
1517
|
+
cost_map: dict[str, dict] = {}
|
|
1518
|
+
try:
|
|
1519
|
+
from core.runtime.llm_cost_telemetry import summarise
|
|
1520
|
+
s = summarise(period="month")
|
|
1521
|
+
for category, row in (s.by_category or {}).items():
|
|
1522
|
+
if not isinstance(category, str) or not category.startswith("subagent:"):
|
|
1523
|
+
continue
|
|
1524
|
+
parts = category.split(":", 2)
|
|
1525
|
+
dept = parts[1] if len(parts) >= 2 else "unknown"
|
|
1526
|
+
bucket = cost_map.setdefault(dept, {
|
|
1527
|
+
"calls_30d": 0,
|
|
1528
|
+
"cost_usd_30d": 0.0,
|
|
1529
|
+
"any_cost_known": False,
|
|
1530
|
+
})
|
|
1531
|
+
bucket["calls_30d"] += int(row.get("call_count", 0))
|
|
1532
|
+
cost = row.get("total_cost_usd")
|
|
1533
|
+
if isinstance(cost, (int, float)):
|
|
1534
|
+
bucket["cost_usd_30d"] += float(cost)
|
|
1535
|
+
bucket["any_cost_known"] = True
|
|
1536
|
+
except Exception: # noqa: BLE001
|
|
1537
|
+
pass
|
|
1538
|
+
out: list[dict] = []
|
|
1539
|
+
for dept in sorted(by_dept.keys()):
|
|
1540
|
+
row = by_dept[dept]
|
|
1541
|
+
cost = cost_map.get(dept, {})
|
|
1542
|
+
row["calls_30d"] = cost.get("calls_30d", 0)
|
|
1543
|
+
row["cost_usd_30d"] = (
|
|
1544
|
+
round(cost.get("cost_usd_30d", 0.0), 6)
|
|
1545
|
+
if cost.get("any_cost_known") else None
|
|
1546
|
+
)
|
|
1547
|
+
out.append(row)
|
|
1548
|
+
return {"departments": out, "total": len(out)}
|
|
1549
|
+
|
|
1550
|
+
|
|
1551
|
+
@app.get("/api/departments/{dept_id}")
|
|
1552
|
+
def department_detail(dept_id: str):
|
|
1553
|
+
"""Full department detail: agents, workflows, 30d cost."""
|
|
1554
|
+
dept_id = dept_id.strip().lower()
|
|
1555
|
+
agents = [a for a in _load_agents() if a.get("department") == dept_id]
|
|
1556
|
+
if not agents:
|
|
1557
|
+
return {"error": "Department not found or has no agents"}
|
|
1558
|
+
workflows: list[dict] = []
|
|
1559
|
+
try:
|
|
1560
|
+
import yaml as _yaml
|
|
1561
|
+
wf_dir = ARKAOS_ROOT / "departments" / dept_id / "workflows"
|
|
1562
|
+
if wf_dir.exists():
|
|
1563
|
+
for path in sorted(wf_dir.glob("*.yaml")):
|
|
1564
|
+
try:
|
|
1565
|
+
raw = _yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
1566
|
+
except Exception: # noqa: BLE001
|
|
1567
|
+
raw = {}
|
|
1568
|
+
if not isinstance(raw, dict):
|
|
1569
|
+
continue
|
|
1570
|
+
workflows.append({
|
|
1571
|
+
"id": str(raw.get("id") or path.stem),
|
|
1572
|
+
"name": str(raw.get("name") or path.stem),
|
|
1573
|
+
"tier": str(raw.get("tier") or ""),
|
|
1574
|
+
"command": str(raw.get("command") or ""),
|
|
1575
|
+
"phases_count": len(raw.get("phases") or []),
|
|
1576
|
+
})
|
|
1577
|
+
except ImportError:
|
|
1578
|
+
pass
|
|
1579
|
+
calls_30d = 0
|
|
1580
|
+
cost_usd_30d: Optional[float] = None
|
|
1581
|
+
try:
|
|
1582
|
+
from core.runtime.llm_cost_telemetry import summarise
|
|
1583
|
+
s = summarise(period="month")
|
|
1584
|
+
total_cost = 0.0
|
|
1585
|
+
any_known = False
|
|
1586
|
+
for category, row in (s.by_category or {}).items():
|
|
1587
|
+
if not isinstance(category, str) or not category.startswith("subagent:"):
|
|
1588
|
+
continue
|
|
1589
|
+
parts = category.split(":", 2)
|
|
1590
|
+
if len(parts) >= 2 and parts[1] == dept_id:
|
|
1591
|
+
calls_30d += int(row.get("call_count", 0))
|
|
1592
|
+
cost = row.get("total_cost_usd")
|
|
1593
|
+
if isinstance(cost, (int, float)):
|
|
1594
|
+
total_cost += float(cost)
|
|
1595
|
+
any_known = True
|
|
1596
|
+
cost_usd_30d = round(total_cost, 6) if any_known else None
|
|
1597
|
+
except Exception: # noqa: BLE001
|
|
1598
|
+
pass
|
|
1599
|
+
return {
|
|
1600
|
+
"department": dept_id,
|
|
1601
|
+
"agents": [
|
|
1602
|
+
{
|
|
1603
|
+
"id": a.get("id"),
|
|
1604
|
+
"name": a.get("name"),
|
|
1605
|
+
"role": a.get("role"),
|
|
1606
|
+
"tier": a.get("tier"),
|
|
1607
|
+
"mbti": a.get("mbti"),
|
|
1608
|
+
"disc": a.get("disc"),
|
|
1609
|
+
}
|
|
1610
|
+
for a in agents
|
|
1611
|
+
],
|
|
1612
|
+
"workflows": workflows,
|
|
1613
|
+
"calls_30d": calls_30d,
|
|
1614
|
+
"cost_usd_30d": cost_usd_30d,
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
|
|
1497
1618
|
# --- Workflows (PR88b v3.24.0) ---
|
|
1498
1619
|
|
|
1620
|
+
@app.get("/api/workflows/{workflow_id}/runs")
|
|
1621
|
+
def workflow_runs(workflow_id: str, limit: int = 20):
|
|
1622
|
+
"""PR89b v3.28.0 — list recent runs of a workflow.
|
|
1623
|
+
|
|
1624
|
+
Parses the PR47 cost telemetry JSONL for rows tagged
|
|
1625
|
+
``workflow:<workflow_id>`` and groups them by ``session_id``.
|
|
1626
|
+
Returns ``{runs: [{session_id, started_at, ended_at, duration_s,
|
|
1627
|
+
calls, cost_usd, tokens_in, tokens_out}]}`` sorted desc by start.
|
|
1628
|
+
|
|
1629
|
+
Workflows must set ``ARKA_CALL_CATEGORY=workflow:<id>`` before
|
|
1630
|
+
each call for this to populate. Returns an empty list when no
|
|
1631
|
+
matching entries exist (the common case until orchestrators opt in).
|
|
1632
|
+
"""
|
|
1633
|
+
target_category = f"workflow:{workflow_id}"
|
|
1634
|
+
try:
|
|
1635
|
+
from core.runtime.llm_cost_telemetry import _load_slice
|
|
1636
|
+
except Exception:
|
|
1637
|
+
return {"runs": []}
|
|
1638
|
+
entries, _ = _load_slice(None, None)
|
|
1639
|
+
sessions: dict[str, dict] = {}
|
|
1640
|
+
for entry in entries:
|
|
1641
|
+
if entry.get("category") != target_category:
|
|
1642
|
+
continue
|
|
1643
|
+
sid = str(entry.get("session_id") or "")
|
|
1644
|
+
if not sid:
|
|
1645
|
+
continue
|
|
1646
|
+
ts = entry.get("ts") or ""
|
|
1647
|
+
bucket = sessions.setdefault(sid, {
|
|
1648
|
+
"session_id": sid,
|
|
1649
|
+
"started_at": ts,
|
|
1650
|
+
"ended_at": ts,
|
|
1651
|
+
"calls": 0,
|
|
1652
|
+
"cost_usd": 0.0,
|
|
1653
|
+
"any_cost_known": False,
|
|
1654
|
+
"tokens_in": 0,
|
|
1655
|
+
"tokens_out": 0,
|
|
1656
|
+
})
|
|
1657
|
+
bucket["calls"] += 1
|
|
1658
|
+
if ts and ts < bucket["started_at"]:
|
|
1659
|
+
bucket["started_at"] = ts
|
|
1660
|
+
if ts and ts > bucket["ended_at"]:
|
|
1661
|
+
bucket["ended_at"] = ts
|
|
1662
|
+
bucket["tokens_in"] += int(entry.get("tokens_in") or 0)
|
|
1663
|
+
bucket["tokens_out"] += int(entry.get("tokens_out") or 0)
|
|
1664
|
+
cost = entry.get("estimated_cost_usd")
|
|
1665
|
+
if isinstance(cost, (int, float)):
|
|
1666
|
+
bucket["cost_usd"] += float(cost)
|
|
1667
|
+
bucket["any_cost_known"] = True
|
|
1668
|
+
|
|
1669
|
+
runs = list(sessions.values())
|
|
1670
|
+
for r in runs:
|
|
1671
|
+
r["cost_usd"] = round(r["cost_usd"], 6) if r.pop("any_cost_known") else None
|
|
1672
|
+
r["duration_s"] = _iso_duration_s(r["started_at"], r["ended_at"])
|
|
1673
|
+
runs.sort(key=lambda r: str(r.get("started_at") or ""), reverse=True)
|
|
1674
|
+
return {"runs": runs[: max(0, int(limit))]}
|
|
1675
|
+
|
|
1676
|
+
|
|
1677
|
+
def _iso_duration_s(start_iso: str, end_iso: str) -> Optional[int]:
|
|
1678
|
+
if not start_iso or not end_iso:
|
|
1679
|
+
return None
|
|
1680
|
+
try:
|
|
1681
|
+
from datetime import datetime
|
|
1682
|
+
start = datetime.fromisoformat(start_iso.replace("Z", "+00:00"))
|
|
1683
|
+
end = datetime.fromisoformat(end_iso.replace("Z", "+00:00"))
|
|
1684
|
+
return max(0, int((end - start).total_seconds()))
|
|
1685
|
+
except (ValueError, TypeError):
|
|
1686
|
+
return None
|
|
1687
|
+
|
|
1688
|
+
|
|
1499
1689
|
@app.get("/api/workflows")
|
|
1500
1690
|
def workflows_list():
|
|
1501
1691
|
"""Scan departments/*/workflows/*.yaml and return metadata + content."""
|