arkaos 2.82.0 → 2.84.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/profile/__pycache__/manager.cpython-313.pyc +0 -0
- package/core/profile/manager.py +5 -1
- package/dashboard/app/pages/index.vue +276 -77
- package/dashboard/app/pages/tasks.vue +319 -120
- package/dashboard/app/types/index.d.ts +29 -0
- 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 +170 -0
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2.
|
|
1
|
+
2.84.0
|
|
Binary file
|
package/core/profile/manager.py
CHANGED
|
@@ -82,7 +82,11 @@ class ProfileManager:
|
|
|
82
82
|
"""
|
|
83
83
|
|
|
84
84
|
def __init__(self, path: Path | None = None) -> None:
|
|
85
|
-
|
|
85
|
+
# Resolve at call time so HOME changes (tests, multi-tenant
|
|
86
|
+
# daemons) are honoured. DEFAULT_PROFILE_PATH stays as a
|
|
87
|
+
# module-level constant for callers that want the canonical
|
|
88
|
+
# path explicitly.
|
|
89
|
+
self._path = path or (Path.home() / ".arkaos" / "profile.json")
|
|
86
90
|
|
|
87
91
|
@property
|
|
88
92
|
def path(self) -> Path:
|
|
@@ -1,39 +1,139 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
|
|
2
|
+
// PR66 v2.83.0 — Command center.
|
|
3
|
+
//
|
|
4
|
+
// Replaces the 6-stat-card Overview that just counted things you
|
|
5
|
+
// already knew (agents=62, skills=256, ...) with telemetry-driven
|
|
6
|
+
// information the operator actually uses: greeting, today's cost,
|
|
7
|
+
// per-project state, recent incidents, quick actions.
|
|
8
|
+
|
|
9
|
+
interface ProjectRow {
|
|
10
|
+
name: string
|
|
11
|
+
path: string
|
|
12
|
+
stack: string[]
|
|
13
|
+
status: string
|
|
14
|
+
ecosystem: string
|
|
15
|
+
last_commit_days: number | null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface IncidentRow {
|
|
19
|
+
ts: string
|
|
20
|
+
tool: string
|
|
21
|
+
reason: string
|
|
22
|
+
cwd: string
|
|
23
|
+
bypass_used: boolean
|
|
24
|
+
kind: 'bypass' | 'blocked'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface QuickAction {
|
|
28
|
+
command: string
|
|
29
|
+
description: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface CommandCenterPayload {
|
|
33
|
+
greeting: {
|
|
34
|
+
name: string
|
|
35
|
+
role: string
|
|
36
|
+
company: string
|
|
37
|
+
language: string
|
|
38
|
+
}
|
|
39
|
+
today_cost: {
|
|
40
|
+
total_usd: number | null
|
|
41
|
+
call_count: number
|
|
42
|
+
tokens_in: number
|
|
43
|
+
tokens_out: number
|
|
44
|
+
cache_hit_rate: number
|
|
45
|
+
}
|
|
46
|
+
projects: ProjectRow[]
|
|
47
|
+
recent_incidents: IncidentRow[]
|
|
48
|
+
quick_actions: QuickAction[]
|
|
49
|
+
}
|
|
3
50
|
|
|
4
51
|
const { fetchApi } = useApi()
|
|
5
52
|
|
|
6
|
-
const {
|
|
53
|
+
const {
|
|
54
|
+
data,
|
|
55
|
+
status,
|
|
56
|
+
error,
|
|
57
|
+
refresh,
|
|
58
|
+
} = await fetchApi<CommandCenterPayload>('/api/overview/command-center')
|
|
59
|
+
|
|
60
|
+
const greetingLabel = computed(() => {
|
|
61
|
+
const name = data.value?.greeting?.name?.trim()
|
|
62
|
+
const language = data.value?.greeting?.language ?? 'en'
|
|
63
|
+
if (!name) return language === 'pt' ? 'Olá' : 'Welcome'
|
|
64
|
+
return language === 'pt' ? `Olá, ${name}` : `Hi, ${name}`
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
const todayCost = computed(() => data.value?.today_cost)
|
|
68
|
+
|
|
69
|
+
function formatCost(value: number | null | undefined): string {
|
|
70
|
+
if (value === null || value === undefined) return 'n/a'
|
|
71
|
+
if (value === 0) return '$0'
|
|
72
|
+
if (value < 0.01) return `$${value.toFixed(4)}`
|
|
73
|
+
return `$${value.toFixed(2)}`
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function formatTokens(n: number): string {
|
|
77
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
|
|
78
|
+
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
|
|
79
|
+
return n.toString()
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function statusColor(status: string): 'success' | 'warning' | 'neutral' | 'error' {
|
|
83
|
+
switch (status) {
|
|
84
|
+
case 'active': return 'success'
|
|
85
|
+
case 'paused': return 'warning'
|
|
86
|
+
case 'archived': return 'neutral'
|
|
87
|
+
case 'error': return 'error'
|
|
88
|
+
default: return 'neutral'
|
|
89
|
+
}
|
|
90
|
+
}
|
|
7
91
|
|
|
8
|
-
|
|
9
|
-
{
|
|
10
|
-
|
|
11
|
-
{
|
|
12
|
-
{
|
|
13
|
-
|
|
14
|
-
{
|
|
15
|
-
|
|
92
|
+
function commitFreshness(days: number | null): { color: string; label: string } {
|
|
93
|
+
if (days === null) return { color: 'text-muted', label: 'no git' }
|
|
94
|
+
if (days === 0) return { color: 'text-green-500', label: 'today' }
|
|
95
|
+
if (days === 1) return { color: 'text-green-500', label: '1 day ago' }
|
|
96
|
+
if (days < 7) return { color: 'text-primary', label: `${days} days ago` }
|
|
97
|
+
if (days < 30) return { color: 'text-yellow-500', label: `${days} days ago` }
|
|
98
|
+
return { color: 'text-red-500', label: `${days} days ago` }
|
|
99
|
+
}
|
|
16
100
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
101
|
+
function formatIncidentTs(iso: string): string {
|
|
102
|
+
if (!iso) return ''
|
|
103
|
+
try {
|
|
104
|
+
return new Intl.DateTimeFormat('en-US', {
|
|
105
|
+
month: 'short',
|
|
106
|
+
day: 'numeric',
|
|
107
|
+
hour: '2-digit',
|
|
108
|
+
minute: '2-digit',
|
|
109
|
+
}).format(new Date(iso))
|
|
110
|
+
} catch {
|
|
111
|
+
return iso
|
|
112
|
+
}
|
|
113
|
+
}
|
|
21
114
|
|
|
22
|
-
function
|
|
23
|
-
|
|
115
|
+
function copyCommand(cmd: string) {
|
|
116
|
+
if (typeof navigator !== 'undefined' && navigator.clipboard) {
|
|
117
|
+
navigator.clipboard.writeText(cmd).catch(() => { /* ignore */ })
|
|
118
|
+
}
|
|
24
119
|
}
|
|
25
120
|
</script>
|
|
26
121
|
|
|
27
122
|
<template>
|
|
28
123
|
<UDashboardPanel id="overview">
|
|
29
124
|
<template #header>
|
|
30
|
-
<UDashboardNavbar title="
|
|
125
|
+
<UDashboardNavbar title="Command Center">
|
|
31
126
|
<template #leading>
|
|
32
127
|
<UDashboardSidebarCollapse />
|
|
33
128
|
</template>
|
|
34
|
-
|
|
35
129
|
<template #right>
|
|
36
|
-
<
|
|
130
|
+
<UButton
|
|
131
|
+
label="Refresh"
|
|
132
|
+
variant="ghost"
|
|
133
|
+
icon="i-lucide-refresh-cw"
|
|
134
|
+
size="sm"
|
|
135
|
+
@click="refresh()"
|
|
136
|
+
/>
|
|
37
137
|
</template>
|
|
38
138
|
</UDashboardNavbar>
|
|
39
139
|
</template>
|
|
@@ -42,72 +142,171 @@ function formatCurrency(value: number): string {
|
|
|
42
142
|
<DashboardState
|
|
43
143
|
:status="status"
|
|
44
144
|
:error="error"
|
|
45
|
-
loading-label="Loading
|
|
145
|
+
loading-label="Loading command center"
|
|
46
146
|
:on-retry="() => refresh()"
|
|
47
147
|
>
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
<
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
<!-- Budget Gauge -->
|
|
64
|
-
<div class="mt-6 rounded-lg border border-default p-6">
|
|
65
|
-
<h3 class="mb-4 text-lg font-semibold text-highlighted">Budget</h3>
|
|
66
|
-
<div v-if="budgetUnlimited" class="text-sm text-muted">
|
|
67
|
-
Unlimited budget configured.
|
|
68
|
-
</div>
|
|
69
|
-
<div v-else class="space-y-3">
|
|
70
|
-
<div class="flex items-center justify-between text-sm">
|
|
71
|
-
<span class="text-muted">Used: {{ formatCurrency(budgetUsed) }}</span>
|
|
72
|
-
<span class="text-muted">Allocated: {{ formatCurrency(budgetAllocated) }}</span>
|
|
73
|
-
</div>
|
|
74
|
-
<UProgress :value="budgetPercent" :max="100" size="md" />
|
|
75
|
-
<p class="text-xs text-muted">{{ budgetPercent.toFixed(1) }}% of budget used</p>
|
|
76
|
-
</div>
|
|
77
|
-
</div>
|
|
78
|
-
|
|
79
|
-
<!-- Bottom Row: Tasks + Knowledge -->
|
|
80
|
-
<div class="mt-6 grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
81
|
-
<!-- Tasks Summary -->
|
|
82
|
-
<div class="rounded-lg border border-default p-6">
|
|
83
|
-
<h3 class="mb-4 text-lg font-semibold text-highlighted">Tasks</h3>
|
|
84
|
-
<div class="grid grid-cols-3 gap-4">
|
|
85
|
-
<div class="text-center">
|
|
86
|
-
<p class="text-2xl font-semibold text-highlighted">{{ overview?.tasks?.total ?? 0 }}</p>
|
|
87
|
-
<p class="text-xs text-muted">Total</p>
|
|
148
|
+
<div class="space-y-6">
|
|
149
|
+
<!-- Hero: greeting + today's cost -->
|
|
150
|
+
<UCard>
|
|
151
|
+
<div class="flex flex-col gap-2 md:flex-row md:items-baseline md:justify-between">
|
|
152
|
+
<div>
|
|
153
|
+
<h1 class="text-2xl font-bold">{{ greetingLabel }}.</h1>
|
|
154
|
+
<p class="text-sm text-muted mt-1">
|
|
155
|
+
<template v-if="data?.greeting?.role && data?.greeting?.company">
|
|
156
|
+
{{ data.greeting.role }} @ {{ data.greeting.company }}
|
|
157
|
+
</template>
|
|
158
|
+
<template v-else>
|
|
159
|
+
Set your profile in Settings to personalise this view.
|
|
160
|
+
</template>
|
|
161
|
+
</p>
|
|
88
162
|
</div>
|
|
89
|
-
<div class="text-
|
|
90
|
-
<
|
|
91
|
-
|
|
163
|
+
<div v-if="todayCost" class="flex items-baseline gap-6 text-right">
|
|
164
|
+
<div>
|
|
165
|
+
<p class="text-xs font-semibold text-muted uppercase tracking-wider">
|
|
166
|
+
Today's cost
|
|
167
|
+
</p>
|
|
168
|
+
<p class="text-2xl font-bold">{{ formatCost(todayCost.total_usd) }}</p>
|
|
169
|
+
</div>
|
|
170
|
+
<div>
|
|
171
|
+
<p class="text-xs font-semibold text-muted uppercase tracking-wider">
|
|
172
|
+
Calls
|
|
173
|
+
</p>
|
|
174
|
+
<p class="text-2xl font-bold">{{ todayCost.call_count }}</p>
|
|
175
|
+
</div>
|
|
176
|
+
<div>
|
|
177
|
+
<p class="text-xs font-semibold text-muted uppercase tracking-wider">
|
|
178
|
+
Cache
|
|
179
|
+
</p>
|
|
180
|
+
<p class="text-2xl font-bold">
|
|
181
|
+
{{ (todayCost.cache_hit_rate * 100).toFixed(0) }}%
|
|
182
|
+
</p>
|
|
183
|
+
</div>
|
|
92
184
|
</div>
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
185
|
+
</div>
|
|
186
|
+
</UCard>
|
|
187
|
+
|
|
188
|
+
<!-- Two columns: projects + incidents -->
|
|
189
|
+
<div class="grid grid-cols-1 lg:grid-cols-[2fr_1fr] gap-6">
|
|
190
|
+
<!-- Projects -->
|
|
191
|
+
<div>
|
|
192
|
+
<div class="flex items-baseline justify-between mb-3">
|
|
193
|
+
<h2 class="text-sm font-semibold uppercase tracking-wider text-muted">
|
|
194
|
+
Projects
|
|
195
|
+
</h2>
|
|
196
|
+
<NuxtLink to="/settings" class="text-xs text-muted hover:text-primary">
|
|
197
|
+
Configure dirs →
|
|
198
|
+
</NuxtLink>
|
|
96
199
|
</div>
|
|
200
|
+
<DashboardState
|
|
201
|
+
:status="status"
|
|
202
|
+
:empty="!data?.projects?.length"
|
|
203
|
+
empty-title="No projects discovered yet"
|
|
204
|
+
empty-description="Add your project directories in Settings → Projects."
|
|
205
|
+
empty-icon="i-lucide-folder-open"
|
|
206
|
+
>
|
|
207
|
+
<div class="space-y-2">
|
|
208
|
+
<div
|
|
209
|
+
v-for="p in data?.projects"
|
|
210
|
+
:key="p.name"
|
|
211
|
+
class="rounded-lg border border-default p-3 hover:border-primary/40 transition-colors"
|
|
212
|
+
>
|
|
213
|
+
<div class="flex items-start justify-between gap-3">
|
|
214
|
+
<div class="min-w-0 flex-1">
|
|
215
|
+
<div class="flex items-center gap-2 mb-1">
|
|
216
|
+
<span class="text-sm font-semibold truncate">{{ p.name }}</span>
|
|
217
|
+
<UBadge
|
|
218
|
+
v-if="p.status"
|
|
219
|
+
:label="p.status"
|
|
220
|
+
:color="statusColor(p.status)"
|
|
221
|
+
variant="subtle"
|
|
222
|
+
size="xs"
|
|
223
|
+
/>
|
|
224
|
+
<UBadge
|
|
225
|
+
v-if="p.ecosystem"
|
|
226
|
+
:label="p.ecosystem"
|
|
227
|
+
color="primary"
|
|
228
|
+
variant="outline"
|
|
229
|
+
size="xs"
|
|
230
|
+
/>
|
|
231
|
+
</div>
|
|
232
|
+
<p class="text-xs text-muted font-mono truncate">{{ p.path }}</p>
|
|
233
|
+
<div class="flex items-center gap-2 mt-2 flex-wrap">
|
|
234
|
+
<UBadge
|
|
235
|
+
v-for="s in p.stack"
|
|
236
|
+
:key="s"
|
|
237
|
+
:label="s"
|
|
238
|
+
variant="soft"
|
|
239
|
+
size="xs"
|
|
240
|
+
/>
|
|
241
|
+
</div>
|
|
242
|
+
</div>
|
|
243
|
+
<span
|
|
244
|
+
class="text-xs font-mono shrink-0"
|
|
245
|
+
:class="commitFreshness(p.last_commit_days).color"
|
|
246
|
+
>
|
|
247
|
+
{{ commitFreshness(p.last_commit_days).label }}
|
|
248
|
+
</span>
|
|
249
|
+
</div>
|
|
250
|
+
</div>
|
|
251
|
+
</div>
|
|
252
|
+
</DashboardState>
|
|
97
253
|
</div>
|
|
98
|
-
</div>
|
|
99
254
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
<
|
|
255
|
+
<!-- Incidents + Quick actions -->
|
|
256
|
+
<div class="space-y-6">
|
|
257
|
+
<div>
|
|
258
|
+
<h2 class="text-sm font-semibold uppercase tracking-wider text-muted mb-3">
|
|
259
|
+
Recent incidents
|
|
260
|
+
</h2>
|
|
261
|
+
<DashboardState
|
|
262
|
+
:status="status"
|
|
263
|
+
:empty="!data?.recent_incidents?.length"
|
|
264
|
+
empty-title="No incidents"
|
|
265
|
+
empty-description="Bypass uses and flow blocks show up here."
|
|
266
|
+
empty-icon="i-lucide-shield-check"
|
|
267
|
+
>
|
|
268
|
+
<div class="space-y-2">
|
|
269
|
+
<div
|
|
270
|
+
v-for="(i, idx) in data?.recent_incidents"
|
|
271
|
+
:key="idx"
|
|
272
|
+
class="rounded-lg border border-default p-3"
|
|
273
|
+
>
|
|
274
|
+
<div class="flex items-center gap-2 mb-1">
|
|
275
|
+
<UBadge
|
|
276
|
+
:label="i.kind"
|
|
277
|
+
:color="i.kind === 'bypass' ? 'warning' : 'error'"
|
|
278
|
+
variant="subtle"
|
|
279
|
+
size="xs"
|
|
280
|
+
/>
|
|
281
|
+
<span class="text-xs text-muted">{{ formatIncidentTs(i.ts) }}</span>
|
|
282
|
+
</div>
|
|
283
|
+
<p class="text-xs font-mono text-muted truncate" :title="i.reason">
|
|
284
|
+
{{ i.tool }} — {{ i.reason }}
|
|
285
|
+
</p>
|
|
286
|
+
</div>
|
|
287
|
+
</div>
|
|
288
|
+
</DashboardState>
|
|
107
289
|
</div>
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
<
|
|
290
|
+
|
|
291
|
+
<div>
|
|
292
|
+
<h2 class="text-sm font-semibold uppercase tracking-wider text-muted mb-3">
|
|
293
|
+
Quick actions
|
|
294
|
+
</h2>
|
|
295
|
+
<div class="space-y-1">
|
|
296
|
+
<button
|
|
297
|
+
v-for="a in data?.quick_actions"
|
|
298
|
+
:key="a.command"
|
|
299
|
+
type="button"
|
|
300
|
+
class="w-full text-left p-3 rounded-lg border border-default hover:border-primary/40 hover:bg-elevated/30 transition-colors"
|
|
301
|
+
@click="copyCommand(a.command)"
|
|
302
|
+
>
|
|
303
|
+
<div class="flex items-center gap-2">
|
|
304
|
+
<code class="text-sm font-mono font-semibold text-primary">{{ a.command }}</code>
|
|
305
|
+
<UIcon name="i-lucide-clipboard" class="size-3 text-muted ml-auto" />
|
|
306
|
+
</div>
|
|
307
|
+
<p class="text-xs text-muted mt-1">{{ a.description }}</p>
|
|
308
|
+
</button>
|
|
309
|
+
</div>
|
|
111
310
|
</div>
|
|
112
311
|
</div>
|
|
113
312
|
</div>
|
|
@@ -1,85 +1,227 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
+
// PR67 v2.84.0 — Tasks page rewritten against /api/jobs.
|
|
3
|
+
//
|
|
4
|
+
// The legacy /api/tasks endpoint returns the deprecated TaskManager
|
|
5
|
+
// data, which has no live updates and no cancel. The SQLite job queue
|
|
6
|
+
// (/api/jobs) is the real workhorse — knowledge ingest fans out into
|
|
7
|
+
// jobs, WebSocket broadcasts every progress event, DELETE /api/jobs/{id}
|
|
8
|
+
// cancels queued work. This page now consumes the real thing.
|
|
9
|
+
//
|
|
10
|
+
// What changed vs the previous tasks.vue:
|
|
11
|
+
// - Polls /api/jobs (jobs are the new tasks)
|
|
12
|
+
// - Subscribes to /ws/tasks for live progress + status flips
|
|
13
|
+
// - Cancel button on queued/processing rows
|
|
14
|
+
// - Empty state suggests a real command (Knowledge → ingest) instead
|
|
15
|
+
// of the dead-link "npx arkaos index" hint
|
|
16
|
+
// - DashboardState (PR64) wraps loading/error/empty consistently
|
|
17
|
+
|
|
2
18
|
import type { TableColumn } from '@nuxt/ui'
|
|
3
|
-
import type {
|
|
19
|
+
import type { Job, JobSummary } from '~/types'
|
|
20
|
+
|
|
21
|
+
const { fetchApi, apiBase } = useApi()
|
|
22
|
+
const toast = useToast()
|
|
23
|
+
|
|
24
|
+
const {
|
|
25
|
+
data,
|
|
26
|
+
status,
|
|
27
|
+
error,
|
|
28
|
+
refresh,
|
|
29
|
+
} = await fetchApi<{ jobs: Job[], summary: JobSummary }>('/api/jobs?limit=200')
|
|
4
30
|
|
|
5
|
-
const
|
|
31
|
+
const jobs = ref<Job[]>(data.value?.jobs ?? [])
|
|
32
|
+
const summary = ref<JobSummary>(data.value?.summary ?? {
|
|
33
|
+
total: 0, queued: 0, processing: 0, completed: 0, failed: 0,
|
|
34
|
+
})
|
|
6
35
|
|
|
7
|
-
|
|
36
|
+
watch(data, (d) => {
|
|
37
|
+
if (!d) return
|
|
38
|
+
jobs.value = d.jobs ?? []
|
|
39
|
+
summary.value = d.summary ?? {
|
|
40
|
+
total: 0, queued: 0, processing: 0, completed: 0, failed: 0,
|
|
41
|
+
}
|
|
42
|
+
})
|
|
8
43
|
|
|
9
|
-
|
|
10
|
-
const summary = computed(() => data.value?.summary ?? { total: 0, active: 0, queued: 0, completed: 0 })
|
|
44
|
+
// ─── Filter tabs ─────────────────────────────────────────────────────────
|
|
11
45
|
|
|
12
|
-
const activeTab = ref('all')
|
|
46
|
+
const activeTab = ref<'all' | 'active' | 'queued' | 'completed' | 'failed'>('all')
|
|
13
47
|
|
|
14
48
|
const tabItems = [
|
|
15
|
-
{ label: 'All', value: 'all' },
|
|
16
|
-
{ label: 'Active', value: 'active' },
|
|
17
|
-
{ label: 'Queued', value: 'queued' },
|
|
18
|
-
{ label: 'Completed', value: 'completed' },
|
|
19
|
-
{ label: 'Failed', value: 'failed' }
|
|
49
|
+
{ label: 'All', value: 'all' as const },
|
|
50
|
+
{ label: 'Active', value: 'active' as const },
|
|
51
|
+
{ label: 'Queued', value: 'queued' as const },
|
|
52
|
+
{ label: 'Completed', value: 'completed' as const },
|
|
53
|
+
{ label: 'Failed', value: 'failed' as const },
|
|
20
54
|
]
|
|
21
55
|
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
56
|
+
const ACTIVE_STATUSES: Job['status'][] = [
|
|
57
|
+
'processing', 'downloading', 'transcribing', 'embedding',
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
const filteredJobs = computed<Job[]>(() => {
|
|
61
|
+
if (activeTab.value === 'all') return jobs.value
|
|
62
|
+
if (activeTab.value === 'active') {
|
|
63
|
+
return jobs.value.filter((j) => ACTIVE_STATUSES.includes(j.status))
|
|
64
|
+
}
|
|
65
|
+
return jobs.value.filter((j) => j.status === activeTab.value)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
const CANCELLABLE: Job['status'][] = ['queued', 'processing']
|
|
69
|
+
function isCancellable(job: Job): boolean {
|
|
70
|
+
return CANCELLABLE.includes(job.status)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ─── WebSocket — live updates ────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
let ws: WebSocket | null = null
|
|
76
|
+
const wsConnected = ref(false)
|
|
77
|
+
|
|
78
|
+
function connectWebSocket() {
|
|
79
|
+
if (ws && ws.readyState === WebSocket.OPEN) return
|
|
80
|
+
const wsUrl = apiBase.replace('http://', 'ws://').replace('https://', 'wss://') + '/ws/tasks'
|
|
81
|
+
ws = new WebSocket(wsUrl)
|
|
82
|
+
ws.onopen = () => { wsConnected.value = true }
|
|
83
|
+
ws.onclose = () => { wsConnected.value = false }
|
|
84
|
+
ws.onerror = () => { wsConnected.value = false }
|
|
85
|
+
ws.onmessage = (event) => {
|
|
86
|
+
try {
|
|
87
|
+
const msg = JSON.parse(event.data) as {
|
|
88
|
+
type: string
|
|
89
|
+
job_id?: string
|
|
90
|
+
progress?: number
|
|
91
|
+
message?: string
|
|
92
|
+
status?: Job['status']
|
|
93
|
+
error?: string
|
|
94
|
+
chunks_created?: number
|
|
95
|
+
}
|
|
96
|
+
if (!msg.job_id) return
|
|
97
|
+
const idx = jobs.value.findIndex((j) => j.id === msg.job_id)
|
|
98
|
+
if (idx === -1) {
|
|
99
|
+
// We don't have the row yet (new job spawned mid-session).
|
|
100
|
+
// Refetch the list so it shows up.
|
|
101
|
+
refresh()
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
const current = jobs.value[idx]
|
|
105
|
+
if (!current) return
|
|
106
|
+
const next: Job = { ...current }
|
|
107
|
+
if (msg.type === 'job_progress') {
|
|
108
|
+
if (typeof msg.progress === 'number') next.progress = msg.progress
|
|
109
|
+
if (typeof msg.message === 'string') next.message = msg.message
|
|
110
|
+
if (msg.status) next.status = msg.status
|
|
111
|
+
} else if (msg.type === 'job_complete') {
|
|
112
|
+
next.status = 'completed'
|
|
113
|
+
next.progress = 100
|
|
114
|
+
if (typeof msg.chunks_created === 'number') next.chunks_created = msg.chunks_created
|
|
115
|
+
} else if (msg.type === 'job_failed') {
|
|
116
|
+
next.status = 'failed'
|
|
117
|
+
if (typeof msg.error === 'string') next.error = msg.error
|
|
118
|
+
} else if (msg.type === 'job_cancelled') {
|
|
119
|
+
next.status = 'cancelled'
|
|
120
|
+
}
|
|
121
|
+
jobs.value.splice(idx, 1, next)
|
|
122
|
+
} catch {
|
|
123
|
+
/* ignore malformed messages */
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function disconnectWebSocket() {
|
|
129
|
+
if (!ws) return
|
|
130
|
+
try { ws.close() } catch { /* already closed */ }
|
|
131
|
+
ws = null
|
|
132
|
+
wsConnected.value = false
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
onMounted(() => {
|
|
136
|
+
connectWebSocket()
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
onBeforeUnmount(() => {
|
|
140
|
+
disconnectWebSocket()
|
|
28
141
|
})
|
|
29
142
|
|
|
30
|
-
|
|
143
|
+
// ─── Cancel ──────────────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
const cancelling = ref<Set<string>>(new Set())
|
|
31
146
|
|
|
32
|
-
function
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
147
|
+
async function cancelJob(job: Job) {
|
|
148
|
+
if (!isCancellable(job)) return
|
|
149
|
+
cancelling.value.add(job.id)
|
|
150
|
+
try {
|
|
151
|
+
const res = await $fetch<{ cancelled?: boolean, error?: string }>(
|
|
152
|
+
`${apiBase}/api/jobs/${job.id}`,
|
|
153
|
+
{ method: 'DELETE' },
|
|
154
|
+
)
|
|
155
|
+
if (res.cancelled) {
|
|
156
|
+
// WS will broadcast 'job_cancelled' — handler will flip the row.
|
|
157
|
+
// Refresh as a safety net in case the WS message races.
|
|
158
|
+
await refresh()
|
|
159
|
+
toast.add({
|
|
160
|
+
title: 'Job cancelled',
|
|
161
|
+
description: job.source || job.id,
|
|
162
|
+
color: 'success',
|
|
163
|
+
})
|
|
164
|
+
} else {
|
|
165
|
+
toast.add({
|
|
166
|
+
title: 'Cancel rejected',
|
|
167
|
+
description: res.error || 'Only queued jobs can be cancelled.',
|
|
168
|
+
color: 'warning',
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
} catch (err) {
|
|
172
|
+
toast.add({
|
|
173
|
+
title: 'Cancel failed',
|
|
174
|
+
description: err instanceof Error ? err.message : 'unknown error',
|
|
175
|
+
color: 'error',
|
|
176
|
+
})
|
|
177
|
+
} finally {
|
|
178
|
+
cancelling.value.delete(job.id)
|
|
40
179
|
}
|
|
41
|
-
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ─── Render helpers ──────────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
type StatusColor = 'success' | 'error' | 'primary' | 'warning' | 'neutral'
|
|
185
|
+
|
|
186
|
+
const STATUS_COLOR: Record<Job['status'], StatusColor> = {
|
|
187
|
+
queued: 'neutral',
|
|
188
|
+
processing: 'primary',
|
|
189
|
+
downloading: 'primary',
|
|
190
|
+
transcribing: 'primary',
|
|
191
|
+
embedding: 'primary',
|
|
192
|
+
completed: 'success',
|
|
193
|
+
failed: 'error',
|
|
194
|
+
cancelled: 'warning',
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function statusColor(s: Job['status']): StatusColor {
|
|
198
|
+
return STATUS_COLOR[s] ?? 'neutral'
|
|
42
199
|
}
|
|
43
200
|
|
|
44
201
|
function formatDate(dateStr: string) {
|
|
45
202
|
if (!dateStr) return '-'
|
|
46
203
|
try {
|
|
47
204
|
return new Intl.DateTimeFormat('en-US', {
|
|
48
|
-
month: 'short',
|
|
49
|
-
|
|
50
|
-
hour: '2-digit',
|
|
51
|
-
minute: '2-digit'
|
|
205
|
+
month: 'short', day: 'numeric',
|
|
206
|
+
hour: '2-digit', minute: '2-digit',
|
|
52
207
|
}).format(new Date(dateStr))
|
|
53
208
|
} catch {
|
|
54
209
|
return dateStr
|
|
55
210
|
}
|
|
56
211
|
}
|
|
57
212
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
},
|
|
67
|
-
{
|
|
68
|
-
|
|
69
|
-
header: '
|
|
70
|
-
},
|
|
71
|
-
{
|
|
72
|
-
accessorKey: 'status',
|
|
73
|
-
header: 'Status'
|
|
74
|
-
},
|
|
75
|
-
{
|
|
76
|
-
accessorKey: 'progress',
|
|
77
|
-
header: 'Progress'
|
|
78
|
-
},
|
|
79
|
-
{
|
|
80
|
-
accessorKey: 'created_at',
|
|
81
|
-
header: 'Created'
|
|
82
|
-
}
|
|
213
|
+
function truncate(value: string, max = 60): string {
|
|
214
|
+
if (!value) return ''
|
|
215
|
+
return value.length > max ? value.slice(0, max - 1) + '…' : value
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const columns: TableColumn<Job>[] = [
|
|
219
|
+
{ accessorKey: 'type', header: 'Type' },
|
|
220
|
+
{ accessorKey: 'source', header: 'Source' },
|
|
221
|
+
{ accessorKey: 'status', header: 'Status' },
|
|
222
|
+
{ accessorKey: 'progress', header: 'Progress' },
|
|
223
|
+
{ accessorKey: 'created_at', header: 'Created' },
|
|
224
|
+
{ accessorKey: 'actions', header: '' },
|
|
83
225
|
]
|
|
84
226
|
</script>
|
|
85
227
|
|
|
@@ -90,6 +232,24 @@ const columns: TableColumn<Task>[] = [
|
|
|
90
232
|
<template #leading>
|
|
91
233
|
<UDashboardSidebarCollapse />
|
|
92
234
|
</template>
|
|
235
|
+
<template #trailing>
|
|
236
|
+
<UBadge
|
|
237
|
+
:label="wsConnected ? 'Live' : 'Offline'"
|
|
238
|
+
:color="wsConnected ? 'success' : 'neutral'"
|
|
239
|
+
variant="subtle"
|
|
240
|
+
size="xs"
|
|
241
|
+
:icon="wsConnected ? 'i-lucide-radio-tower' : 'i-lucide-radio'"
|
|
242
|
+
/>
|
|
243
|
+
</template>
|
|
244
|
+
<template #right>
|
|
245
|
+
<UButton
|
|
246
|
+
label="Refresh"
|
|
247
|
+
variant="ghost"
|
|
248
|
+
icon="i-lucide-refresh-cw"
|
|
249
|
+
size="sm"
|
|
250
|
+
@click="refresh()"
|
|
251
|
+
/>
|
|
252
|
+
</template>
|
|
93
253
|
</UDashboardNavbar>
|
|
94
254
|
</template>
|
|
95
255
|
|
|
@@ -97,17 +257,17 @@ const columns: TableColumn<Task>[] = [
|
|
|
97
257
|
<DashboardState
|
|
98
258
|
:status="status"
|
|
99
259
|
:error="error"
|
|
100
|
-
loading-label="Loading
|
|
260
|
+
loading-label="Loading jobs"
|
|
101
261
|
:on-retry="() => refresh()"
|
|
102
262
|
>
|
|
103
|
-
<!-- Summary
|
|
104
|
-
<div class="grid grid-cols-2 gap-4 sm:grid-cols-
|
|
263
|
+
<!-- Summary cards -->
|
|
264
|
+
<div class="grid grid-cols-2 gap-4 sm:grid-cols-5">
|
|
105
265
|
<div class="rounded-lg border border-default p-4 text-center">
|
|
106
266
|
<p class="text-2xl font-semibold text-highlighted">{{ summary.total }}</p>
|
|
107
267
|
<p class="text-xs text-muted">Total</p>
|
|
108
268
|
</div>
|
|
109
269
|
<div class="rounded-lg border border-default p-4 text-center">
|
|
110
|
-
<p class="text-2xl font-semibold text-primary">{{ summary.
|
|
270
|
+
<p class="text-2xl font-semibold text-primary">{{ summary.processing ?? 0 }}</p>
|
|
111
271
|
<p class="text-xs text-muted">Active</p>
|
|
112
272
|
</div>
|
|
113
273
|
<div class="rounded-lg border border-default p-4 text-center">
|
|
@@ -118,77 +278,116 @@ const columns: TableColumn<Task>[] = [
|
|
|
118
278
|
<p class="text-2xl font-semibold text-green-500">{{ summary.completed }}</p>
|
|
119
279
|
<p class="text-xs text-muted">Completed</p>
|
|
120
280
|
</div>
|
|
281
|
+
<div class="rounded-lg border border-default p-4 text-center">
|
|
282
|
+
<p class="text-2xl font-semibold text-red-500">{{ summary.failed }}</p>
|
|
283
|
+
<p class="text-xs text-muted">Failed</p>
|
|
284
|
+
</div>
|
|
121
285
|
</div>
|
|
122
286
|
|
|
123
|
-
<!--
|
|
287
|
+
<!-- Filter tabs -->
|
|
124
288
|
<div class="mt-6">
|
|
125
289
|
<UTabs
|
|
126
290
|
:items="tabItems"
|
|
127
291
|
:model-value="activeTab"
|
|
128
|
-
@update:model-value="activeTab = $event as
|
|
292
|
+
@update:model-value="activeTab = $event as 'all' | 'active' | 'queued' | 'completed' | 'failed'"
|
|
129
293
|
/>
|
|
130
294
|
</div>
|
|
131
295
|
|
|
132
|
-
<!--
|
|
133
|
-
<
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
<
|
|
140
|
-
<
|
|
141
|
-
<
|
|
142
|
-
|
|
143
|
-
|
|
296
|
+
<!-- Global empty (no jobs at all) -->
|
|
297
|
+
<DashboardState
|
|
298
|
+
:status="status"
|
|
299
|
+
:empty="!jobs.length"
|
|
300
|
+
empty-title="No jobs yet"
|
|
301
|
+
empty-icon="i-lucide-list-checks"
|
|
302
|
+
>
|
|
303
|
+
<template #empty>
|
|
304
|
+
<UIcon name="i-lucide-list-checks" class="size-16 text-muted" />
|
|
305
|
+
<h3 class="text-lg font-semibold text-highlighted">No jobs yet</h3>
|
|
306
|
+
<p class="text-sm text-muted text-center max-w-md">
|
|
307
|
+
Jobs are created when you ingest content. Head to the Knowledge tab and paste a URL or upload a file — each one runs as a job here in real time.
|
|
308
|
+
</p>
|
|
309
|
+
<UButton
|
|
310
|
+
to="/knowledge"
|
|
311
|
+
label="Open Knowledge"
|
|
312
|
+
icon="i-lucide-brain"
|
|
313
|
+
size="md"
|
|
314
|
+
class="mt-2"
|
|
315
|
+
/>
|
|
316
|
+
</template>
|
|
144
317
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
</div>
|
|
150
|
-
|
|
151
|
-
<!-- Task Table -->
|
|
152
|
-
<div v-else class="mt-4">
|
|
153
|
-
<UTable
|
|
154
|
-
:data="filteredTasks"
|
|
155
|
-
:columns="columns"
|
|
156
|
-
:loading="status === 'pending'"
|
|
157
|
-
class="shrink-0"
|
|
158
|
-
:ui="{
|
|
159
|
-
base: 'table-fixed border-separate border-spacing-0',
|
|
160
|
-
thead: '[&>tr]:bg-elevated/50 [&>tr]:after:content-none',
|
|
161
|
-
tbody: '[&>tr]:last:[&>td]:border-b-0',
|
|
162
|
-
th: 'py-2 first:rounded-l-lg last:rounded-r-lg border-y border-default first:border-l last:border-r',
|
|
163
|
-
td: 'border-b border-default'
|
|
164
|
-
}"
|
|
318
|
+
<!-- Filtered empty (jobs exist but not in this tab) -->
|
|
319
|
+
<div
|
|
320
|
+
v-if="!filteredJobs.length"
|
|
321
|
+
class="mt-6 flex flex-col items-center justify-center gap-4 py-12"
|
|
165
322
|
>
|
|
166
|
-
<
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
<
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
<
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
323
|
+
<UIcon name="i-lucide-filter-x" class="size-12 text-muted" />
|
|
324
|
+
<p class="text-sm text-muted">No {{ activeTab }} jobs found.</p>
|
|
325
|
+
</div>
|
|
326
|
+
|
|
327
|
+
<!-- Job table -->
|
|
328
|
+
<div v-else class="mt-4">
|
|
329
|
+
<UTable
|
|
330
|
+
:data="filteredJobs"
|
|
331
|
+
:columns="columns"
|
|
332
|
+
:loading="status === 'pending'"
|
|
333
|
+
class="shrink-0"
|
|
334
|
+
:ui="{
|
|
335
|
+
base: 'table-fixed border-separate border-spacing-0',
|
|
336
|
+
thead: '[&>tr]:bg-elevated/50 [&>tr]:after:content-none',
|
|
337
|
+
tbody: '[&>tr]:last:[&>td]:border-b-0',
|
|
338
|
+
th: 'py-2 first:rounded-l-lg last:rounded-r-lg border-y border-default first:border-l last:border-r',
|
|
339
|
+
td: 'border-b border-default',
|
|
340
|
+
}"
|
|
341
|
+
>
|
|
342
|
+
<template #type-cell="{ row }">
|
|
343
|
+
<UBadge :label="row.original.type || 'unknown'" variant="subtle" color="primary" size="sm" />
|
|
344
|
+
</template>
|
|
345
|
+
<template #source-cell="{ row }">
|
|
346
|
+
<span class="text-sm font-mono" :title="row.original.source">
|
|
347
|
+
{{ truncate(row.original.source, 50) }}
|
|
348
|
+
</span>
|
|
349
|
+
<p v-if="row.original.message" class="text-xs text-muted mt-0.5">
|
|
350
|
+
{{ truncate(row.original.message, 60) }}
|
|
351
|
+
</p>
|
|
352
|
+
<p v-if="row.original.error" class="text-xs text-red-400 mt-0.5">
|
|
353
|
+
{{ truncate(row.original.error, 80) }}
|
|
354
|
+
</p>
|
|
355
|
+
</template>
|
|
356
|
+
<template #status-cell="{ row }">
|
|
357
|
+
<UBadge
|
|
358
|
+
:label="row.original.status"
|
|
359
|
+
:color="statusColor(row.original.status)"
|
|
360
|
+
variant="subtle"
|
|
361
|
+
size="sm"
|
|
362
|
+
class="capitalize"
|
|
363
|
+
/>
|
|
364
|
+
</template>
|
|
365
|
+
<template #progress-cell="{ row }">
|
|
366
|
+
<div class="flex items-center gap-2 min-w-24">
|
|
367
|
+
<UProgress :value="row.original.progress" :max="100" size="xs" class="flex-1" />
|
|
368
|
+
<span class="text-xs text-muted font-mono w-8 text-right">
|
|
369
|
+
{{ row.original.progress }}%
|
|
370
|
+
</span>
|
|
371
|
+
</div>
|
|
372
|
+
</template>
|
|
373
|
+
<template #created_at-cell="{ row }">
|
|
374
|
+
<span class="text-xs text-muted">{{ formatDate(row.original.created_at) }}</span>
|
|
375
|
+
</template>
|
|
376
|
+
<template #actions-cell="{ row }">
|
|
377
|
+
<UButton
|
|
378
|
+
v-if="isCancellable(row.original)"
|
|
379
|
+
icon="i-lucide-x"
|
|
380
|
+
variant="ghost"
|
|
381
|
+
color="error"
|
|
382
|
+
size="xs"
|
|
383
|
+
:loading="cancelling.has(row.original.id)"
|
|
384
|
+
aria-label="Cancel job"
|
|
385
|
+
@click="cancelJob(row.original)"
|
|
386
|
+
/>
|
|
387
|
+
</template>
|
|
388
|
+
</UTable>
|
|
389
|
+
</div>
|
|
390
|
+
</DashboardState>
|
|
192
391
|
</DashboardState>
|
|
193
392
|
</template>
|
|
194
393
|
</UDashboardPanel>
|
|
@@ -95,6 +95,35 @@ export interface TaskSummary {
|
|
|
95
95
|
completed: number
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
// PR67 v2.84.0 — Jobs shape from /api/jobs (SQLite job queue).
|
|
99
|
+
// Mirrors core/jobs/manager.Job dataclass.
|
|
100
|
+
export interface Job {
|
|
101
|
+
id: string
|
|
102
|
+
type: string
|
|
103
|
+
source: string
|
|
104
|
+
title: string
|
|
105
|
+
status:
|
|
106
|
+
| 'queued' | 'processing' | 'downloading' | 'transcribing'
|
|
107
|
+
| 'embedding' | 'completed' | 'failed' | 'cancelled'
|
|
108
|
+
progress: number
|
|
109
|
+
message: string
|
|
110
|
+
chunks_created: number
|
|
111
|
+
media_path: string
|
|
112
|
+
error: string
|
|
113
|
+
created_at: string
|
|
114
|
+
started_at: string
|
|
115
|
+
completed_at: string
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface JobSummary {
|
|
119
|
+
total: number
|
|
120
|
+
queued: number
|
|
121
|
+
processing: number
|
|
122
|
+
completed: number
|
|
123
|
+
failed: number
|
|
124
|
+
cancelled?: number
|
|
125
|
+
}
|
|
126
|
+
|
|
98
127
|
export interface KnowledgeStats {
|
|
99
128
|
total_chunks: number
|
|
100
129
|
total_files: number
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
Binary file
|
package/scripts/dashboard-api.py
CHANGED
|
@@ -729,6 +729,176 @@ def persona_build(body: dict):
|
|
|
729
729
|
}
|
|
730
730
|
|
|
731
731
|
|
|
732
|
+
# --- Command center (PR66 v2.83.0) ---
|
|
733
|
+
|
|
734
|
+
@app.get("/api/overview/command-center")
|
|
735
|
+
def overview_command_center():
|
|
736
|
+
"""Telemetry-driven overview surfacing what the operator actually needs.
|
|
737
|
+
|
|
738
|
+
Returns greeting, today's cost, project list with stack + last-commit
|
|
739
|
+
+ status, recent enforcement incidents, and suggested quick actions.
|
|
740
|
+
"""
|
|
741
|
+
from core.profile import ProfileManager
|
|
742
|
+
from core.profile.manager import parse_projects_dirs
|
|
743
|
+
from core.runtime.llm_cost_telemetry import summarise
|
|
744
|
+
|
|
745
|
+
profile = ProfileManager().read()
|
|
746
|
+
today_cost = summarise(period="today")
|
|
747
|
+
|
|
748
|
+
return {
|
|
749
|
+
"greeting": {
|
|
750
|
+
"name": profile.name,
|
|
751
|
+
"role": profile.role,
|
|
752
|
+
"company": profile.company,
|
|
753
|
+
"language": profile.language,
|
|
754
|
+
},
|
|
755
|
+
"today_cost": {
|
|
756
|
+
"total_usd": today_cost.total_cost_usd,
|
|
757
|
+
"call_count": today_cost.call_count,
|
|
758
|
+
"tokens_in": today_cost.total_tokens_in,
|
|
759
|
+
"tokens_out": today_cost.total_tokens_out,
|
|
760
|
+
"cache_hit_rate": today_cost.cache_hit_rate,
|
|
761
|
+
},
|
|
762
|
+
"projects": _scan_projects(parse_projects_dirs(profile.projectsDir)),
|
|
763
|
+
"recent_incidents": _recent_incidents(limit=8),
|
|
764
|
+
"quick_actions": [
|
|
765
|
+
{"command": "/arka update", "description": "Sync projects + skills"},
|
|
766
|
+
{"command": "/arka costs", "description": "View detailed LLM cost breakdown"},
|
|
767
|
+
{"command": "/arka conclave", "description": "Convene the personal AI advisory board"},
|
|
768
|
+
{"command": "/dev review", "description": "Run a code review on the current branch"},
|
|
769
|
+
],
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def _scan_projects(projects_dirs: list[str]) -> list[dict]:
|
|
774
|
+
"""Read each project descriptor and enrich with last-commit info.
|
|
775
|
+
|
|
776
|
+
Best-effort: never raises. Returns an empty list when descriptors
|
|
777
|
+
or scan directories are missing.
|
|
778
|
+
"""
|
|
779
|
+
from datetime import datetime, timezone
|
|
780
|
+
descriptor_dir = Path.home() / ".arkaos" / "projects"
|
|
781
|
+
if not descriptor_dir.exists():
|
|
782
|
+
return []
|
|
783
|
+
|
|
784
|
+
rows: list[dict] = []
|
|
785
|
+
for entry in sorted(descriptor_dir.iterdir()):
|
|
786
|
+
if entry.is_dir():
|
|
787
|
+
descriptor = entry / "PROJECT.md"
|
|
788
|
+
elif entry.suffix == ".md":
|
|
789
|
+
descriptor = entry
|
|
790
|
+
else:
|
|
791
|
+
continue
|
|
792
|
+
if not descriptor.exists():
|
|
793
|
+
continue
|
|
794
|
+
try:
|
|
795
|
+
data = _parse_descriptor(descriptor)
|
|
796
|
+
except Exception:
|
|
797
|
+
continue
|
|
798
|
+
rows.append(data)
|
|
799
|
+
|
|
800
|
+
# Sort by last_commit_days ascending (most recently active first).
|
|
801
|
+
rows.sort(key=lambda r: (
|
|
802
|
+
r.get("last_commit_days") if r.get("last_commit_days") is not None else 9999,
|
|
803
|
+
r.get("name", ""),
|
|
804
|
+
))
|
|
805
|
+
return rows[:30] # cap to keep payload bounded
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
def _parse_descriptor(path: Path) -> dict:
|
|
809
|
+
"""Extract frontmatter + last-commit-days from a project descriptor."""
|
|
810
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
811
|
+
fm: dict = {}
|
|
812
|
+
if text.startswith("---\n"):
|
|
813
|
+
end = text.find("\n---", 4)
|
|
814
|
+
if end > 0:
|
|
815
|
+
import yaml as _yaml
|
|
816
|
+
try:
|
|
817
|
+
fm = _yaml.safe_load(text[4:end]) or {}
|
|
818
|
+
except Exception:
|
|
819
|
+
fm = {}
|
|
820
|
+
name = str(fm.get("name") or path.stem)
|
|
821
|
+
project_path = str(fm.get("path") or "")
|
|
822
|
+
stack = fm.get("stack") or []
|
|
823
|
+
if not isinstance(stack, list):
|
|
824
|
+
stack = [str(stack)]
|
|
825
|
+
status = str(fm.get("status") or "unknown")
|
|
826
|
+
ecosystem = str(fm.get("ecosystem") or "")
|
|
827
|
+
last_commit_days = _last_commit_days(project_path) if project_path else None
|
|
828
|
+
return {
|
|
829
|
+
"name": name,
|
|
830
|
+
"path": project_path,
|
|
831
|
+
"stack": [str(s) for s in stack][:6],
|
|
832
|
+
"status": status,
|
|
833
|
+
"ecosystem": ecosystem,
|
|
834
|
+
"last_commit_days": last_commit_days,
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
def _last_commit_days(project_path: str) -> Optional[int]:
|
|
839
|
+
"""Return days since the last git commit, or None when unknown."""
|
|
840
|
+
import os
|
|
841
|
+
if not project_path or not os.path.isdir(project_path):
|
|
842
|
+
return None
|
|
843
|
+
git_dir = os.path.join(project_path, ".git")
|
|
844
|
+
if not os.path.exists(git_dir):
|
|
845
|
+
return None
|
|
846
|
+
try:
|
|
847
|
+
from datetime import datetime, timezone
|
|
848
|
+
result = subprocess.run(
|
|
849
|
+
["git", "-C", project_path, "log", "-1", "--format=%ct"],
|
|
850
|
+
capture_output=True, text=True, timeout=3, check=False,
|
|
851
|
+
)
|
|
852
|
+
if result.returncode != 0 or not result.stdout.strip():
|
|
853
|
+
return None
|
|
854
|
+
committed_at = datetime.fromtimestamp(int(result.stdout.strip()), tz=timezone.utc)
|
|
855
|
+
delta = datetime.now(timezone.utc) - committed_at
|
|
856
|
+
return max(0, delta.days)
|
|
857
|
+
except (OSError, ValueError, subprocess.TimeoutExpired):
|
|
858
|
+
return None
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
def _recent_incidents(limit: int = 8) -> list[dict]:
|
|
862
|
+
"""Recent enforcement / bypass events from telemetry.
|
|
863
|
+
|
|
864
|
+
Reads the tail of ~/.arkaos/telemetry/enforcement.jsonl and keeps
|
|
865
|
+
rows where the operator hit a bypass or a flow-marker block. The
|
|
866
|
+
UI uses these to show "what went sideways recently".
|
|
867
|
+
"""
|
|
868
|
+
log = Path.home() / ".arkaos" / "telemetry" / "enforcement.jsonl"
|
|
869
|
+
if not log.exists():
|
|
870
|
+
return []
|
|
871
|
+
try:
|
|
872
|
+
text = log.read_text(encoding="utf-8", errors="replace")
|
|
873
|
+
except OSError:
|
|
874
|
+
return []
|
|
875
|
+
rows: list[dict] = []
|
|
876
|
+
# Walk lines in reverse; stop when we've gathered `limit` matches.
|
|
877
|
+
for line in reversed(text.splitlines()):
|
|
878
|
+
if len(rows) >= limit:
|
|
879
|
+
break
|
|
880
|
+
if not line.strip():
|
|
881
|
+
continue
|
|
882
|
+
try:
|
|
883
|
+
entry = json.loads(line)
|
|
884
|
+
except json.JSONDecodeError:
|
|
885
|
+
continue
|
|
886
|
+
# Interesting events: bypass used OR allow=False (a block).
|
|
887
|
+
bypass = bool(entry.get("bypass_used"))
|
|
888
|
+
allowed = entry.get("allow")
|
|
889
|
+
if not bypass and allowed is not False:
|
|
890
|
+
continue
|
|
891
|
+
rows.append({
|
|
892
|
+
"ts": entry.get("ts", ""),
|
|
893
|
+
"tool": entry.get("tool", ""),
|
|
894
|
+
"reason": entry.get("reason", ""),
|
|
895
|
+
"cwd": entry.get("cwd", ""),
|
|
896
|
+
"bypass_used": bypass,
|
|
897
|
+
"kind": "bypass" if bypass else "blocked",
|
|
898
|
+
})
|
|
899
|
+
return rows
|
|
900
|
+
|
|
901
|
+
|
|
732
902
|
# --- LLM Costs (PR65 v2.82.0) ---
|
|
733
903
|
|
|
734
904
|
@app.get("/api/llm-costs")
|