arkaos 2.83.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.83.0
1
+ 2.84.0
@@ -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.83.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.83.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"}