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 CHANGED
@@ -1 +1 @@
1
- 2.82.0
1
+ 2.84.0
@@ -82,7 +82,11 @@ class ProfileManager:
82
82
  """
83
83
 
84
84
  def __init__(self, path: Path | None = None) -> None:
85
- self._path = path or DEFAULT_PROFILE_PATH
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
- import type { OverviewData } from '~/types'
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 { data: overview, status, error, refresh } = await fetchApi<OverviewData>('/api/overview')
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
- const stats = computed(() => [
9
- { label: 'Agents', value: overview.value?.agents ?? 0, icon: 'i-lucide-users' },
10
- { label: 'Skills', value: overview.value?.skills ?? 0, icon: 'i-lucide-sparkles' },
11
- { label: 'Departments', value: overview.value?.departments ?? 0, icon: 'i-lucide-building-2' },
12
- { label: 'Tests', value: overview.value?.tests ?? 0, icon: 'i-lucide-test-tubes' },
13
- { label: 'Commands', value: overview.value?.commands ?? 0, icon: 'i-lucide-terminal' },
14
- { label: 'Workflows', value: overview.value?.workflows ?? 0, icon: 'i-lucide-git-branch' }
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
- const budgetPercent = computed(() => overview.value?.budget?.percent_used ?? 0)
18
- const budgetUsed = computed(() => overview.value?.budget?.used ?? 0)
19
- const budgetAllocated = computed(() => overview.value?.budget?.allocated ?? 0)
20
- const budgetUnlimited = computed(() => overview.value?.budget?.is_unlimited ?? false)
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 formatCurrency(value: number): string {
23
- return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(value)
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="Overview">
125
+ <UDashboardNavbar title="Command Center">
31
126
  <template #leading>
32
127
  <UDashboardSidebarCollapse />
33
128
  </template>
34
-
35
129
  <template #right>
36
- <UBadge v-if="overview?.version" :label="`v${overview.version}`" variant="subtle" color="primary" />
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 overview"
145
+ loading-label="Loading command center"
46
146
  :on-retry="() => refresh()"
47
147
  >
48
- <!-- Stats Grid -->
49
- <div class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6">
50
- <div
51
- v-for="stat in stats"
52
- :key="stat.label"
53
- class="flex flex-col gap-2 rounded-lg border border-default p-4"
54
- >
55
- <div class="flex items-center gap-2">
56
- <UIcon :name="stat.icon" class="size-4 text-muted" />
57
- <span class="text-sm text-muted">{{ stat.label }}</span>
58
- </div>
59
- <span class="text-2xl font-semibold text-highlighted">{{ stat.value }}</span>
60
- </div>
61
- </div>
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-center">
90
- <p class="text-2xl font-semibold text-primary">{{ overview?.tasks?.active ?? 0 }}</p>
91
- <p class="text-xs text-muted">Active</p>
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
- <div class="text-center">
94
- <p class="text-2xl font-semibold text-muted">{{ overview?.tasks?.queued ?? 0 }}</p>
95
- <p class="text-xs text-muted">Queued</p>
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
- <!-- Knowledge Summary -->
101
- <div class="rounded-lg border border-default p-6">
102
- <h3 class="mb-4 text-lg font-semibold text-highlighted">Knowledge Base</h3>
103
- <div class="grid grid-cols-2 gap-4">
104
- <div class="text-center">
105
- <p class="text-2xl font-semibold text-highlighted">{{ overview?.knowledge?.total_chunks ?? 0 }}</p>
106
- <p class="text-xs text-muted">Chunks</p>
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
- <div class="text-center">
109
- <p class="text-2xl font-semibold text-highlighted">{{ overview?.knowledge?.total_files ?? 0 }}</p>
110
- <p class="text-xs text-muted">Files</p>
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 { Task, TaskSummary } from '~/types'
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 { fetchApi } = useApi()
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
- const { data, status, error, refresh } = await fetchApi<{ tasks: Task[], summary: TaskSummary }>('/api/tasks')
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
- const tasks = computed(() => data.value?.tasks ?? [])
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 filteredTasks = computed(() => {
23
- if (activeTab.value === 'all') return tasks.value
24
- return tasks.value.filter(t => {
25
- if (activeTab.value === 'active') return t.status === 'processing' || t.status === 'active'
26
- return t.status === activeTab.value
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
- type StatusColorType = 'success' | 'error' | 'primary' | 'warning' | 'neutral'
143
+ // ─── Cancel ──────────────────────────────────────────────────────────────
144
+
145
+ const cancelling = ref<Set<string>>(new Set())
31
146
 
32
- function statusColor(taskStatus: string): StatusColorType {
33
- const colors: Record<string, StatusColorType> = {
34
- completed: 'success',
35
- processing: 'primary',
36
- active: 'primary',
37
- queued: 'neutral',
38
- failed: 'error',
39
- cancelled: 'warning'
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
- return colors[taskStatus] ?? 'neutral'
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
- day: 'numeric',
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
- const columns: TableColumn<Task>[] = [
59
- {
60
- accessorKey: 'agent',
61
- header: 'Type'
62
- },
63
- {
64
- accessorKey: 'title',
65
- header: 'Title'
66
- },
67
- {
68
- accessorKey: 'department',
69
- header: 'Department'
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 tasks"
260
+ loading-label="Loading jobs"
101
261
  :on-retry="() => refresh()"
102
262
  >
103
- <!-- Summary Cards -->
104
- <div class="grid grid-cols-2 gap-4 sm:grid-cols-4">
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.active }}</p>
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
- <!-- Status Filter Tabs -->
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 string"
292
+ @update:model-value="activeTab = $event as 'all' | 'active' | 'queued' | 'completed' | 'failed'"
129
293
  />
130
294
  </div>
131
295
 
132
- <!-- Empty State -->
133
- <div v-if="!tasks.length" class="mt-6 flex flex-col items-center justify-center gap-4 py-16">
134
- <UIcon name="i-lucide-list-checks" class="size-16 text-muted" />
135
- <h3 class="text-lg font-semibold text-highlighted">No tasks yet</h3>
136
- <p class="text-sm text-muted text-center max-w-md">
137
- Tasks are created when you run ArkaOS workflows. Start by indexing your project or running a command.
138
- </p>
139
- <div class="mt-2 rounded-lg border border-default bg-elevated/50 px-4 py-3">
140
- <p class="text-xs text-muted mb-1">Try running:</p>
141
- <code class="font-mono text-sm text-primary">npx arkaos index</code>
142
- </div>
143
- </div>
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
- <!-- Filtered Empty -->
146
- <div v-else-if="!filteredTasks.length" class="mt-6 flex flex-col items-center justify-center gap-4 py-12">
147
- <UIcon name="i-lucide-filter-x" class="size-12 text-muted" />
148
- <p class="text-sm text-muted">No {{ activeTab }} tasks found.</p>
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
- <template #agent-cell="{ row }">
167
- <UBadge :label="row.original.agent" variant="subtle" color="primary" size="sm" />
168
- </template>
169
- <template #department-cell="{ row }">
170
- <span class="text-sm text-muted">{{ row.original.department }}</span>
171
- </template>
172
- <template #status-cell="{ row }">
173
- <UBadge
174
- :label="row.original.status"
175
- :color="statusColor(row.original.status)"
176
- variant="subtle"
177
- size="sm"
178
- class="capitalize"
179
- />
180
- </template>
181
- <template #progress-cell="{ row }">
182
- <div class="flex items-center gap-2 min-w-24">
183
- <UProgress :value="row.original.progress" :max="100" size="xs" class="flex-1" />
184
- <span class="text-xs text-muted font-mono w-8 text-right">{{ row.original.progress }}%</span>
185
- </div>
186
- </template>
187
- <template #created_at-cell="{ row }">
188
- <span class="text-xs text-muted">{{ formatDate(row.original.created_at) }}</span>
189
- </template>
190
- </UTable>
191
- </div>
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arkaos",
3
- "version": "2.82.0",
3
+ "version": "2.84.0",
4
4
  "description": "The Operating System for AI Agent Teams",
5
5
  "type": "module",
6
6
  "bin": {
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "arkaos-core"
3
- version = "2.82.0"
3
+ version = "2.84.0"
4
4
  description = "Core engine for ArkaOS — The Operating System for AI Agent Teams"
5
5
  readme = "README.md"
6
6
  license = {text = "MIT"}
@@ -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")