arkaos 3.27.0 → 3.29.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
- 3.27.0
1
+ 3.29.0
@@ -368,6 +368,7 @@ def _current_category() -> str:
368
368
  PR60 v2.77.0 — orchestration layers can set
369
369
  ``ARKA_CALL_CATEGORY=skill:<slug>`` /
370
370
  ``subagent:<dept>`` or ``subagent:<dept>:<agent_id>`` /
371
+ ``workflow:<workflow_id>`` /
371
372
  ``plugin:<id>`` / ``mcp:<server>`` before
372
373
  invoking the provider so `/arka costs --by-category` (PR47) can
373
374
  attribute spend. Returns "" when unset, which lands in the base
@@ -50,6 +50,45 @@ watch(profile, (p) => {
50
50
 
51
51
  const savingProfile = ref(false)
52
52
 
53
+ // PR89c v3.29.0 — vault connection test.
54
+ interface VaultStatus {
55
+ configured: boolean
56
+ vault_path: string
57
+ exists: boolean
58
+ personas: { dir: string, count: number }
59
+ agents: { dir: string, count: number }
60
+ }
61
+ const vaultStatus = ref<VaultStatus | null>(null)
62
+ const testingVault = ref(false)
63
+
64
+ async function testVault() {
65
+ testingVault.value = true
66
+ try {
67
+ // Save first so the backend reads the current value
68
+ if (profileDraft.value.vaultPath !== profile.value?.vaultPath) {
69
+ await $fetch(`${apiBase}/api/profile`, {
70
+ method: 'POST',
71
+ body: { vaultPath: profileDraft.value.vaultPath },
72
+ })
73
+ }
74
+ vaultStatus.value = await $fetch<VaultStatus>(`${apiBase}/api/settings/vault`)
75
+ toast.add({
76
+ title: vaultStatus.value.exists ? 'Vault reachable' : 'Vault not found',
77
+ description: vaultStatus.value.vault_path || 'Set a path first',
78
+ color: vaultStatus.value.exists ? 'success' : 'warning',
79
+ icon: vaultStatus.value.exists ? 'i-lucide-check-circle' : 'i-lucide-alert-circle',
80
+ })
81
+ } catch (err) {
82
+ toast.add({
83
+ title: 'Test failed',
84
+ description: err instanceof Error ? err.message : 'unknown error',
85
+ color: 'error',
86
+ })
87
+ } finally {
88
+ testingVault.value = false
89
+ }
90
+ }
91
+
53
92
  async function saveProfile() {
54
93
  savingProfile.value = true
55
94
  try {
@@ -326,8 +365,18 @@ const activeSection = ref<SectionId>('profile')
326
365
 
327
366
  <UFormField
328
367
  label="Vault path"
329
- help="Where your Obsidian vault lives. Used by the KB-first hook."
368
+ help="Where your Obsidian vault lives. Used by the KB-first hook + Persona/Agent exporters."
330
369
  >
370
+ <template #hint>
371
+ <UButton
372
+ label="Test connection"
373
+ icon="i-lucide-plug-zap"
374
+ size="xs"
375
+ variant="soft"
376
+ :loading="testingVault"
377
+ @click="testVault"
378
+ />
379
+ </template>
331
380
  <UInput
332
381
  v-model="profileDraft.vaultPath"
333
382
  placeholder="/Users/you/Documents/Vault"
@@ -335,6 +384,50 @@ const activeSection = ref<SectionId>('profile')
335
384
  />
336
385
  </UFormField>
337
386
 
387
+ <!-- PR89c v3.29.0 — vault connection test result -->
388
+ <div
389
+ v-if="vaultStatus"
390
+ class="rounded-lg border p-3 text-xs space-y-1"
391
+ :class="
392
+ vaultStatus.exists
393
+ ? 'border-emerald-500/40 bg-emerald-500/5'
394
+ : 'border-yellow-500/40 bg-yellow-500/5'
395
+ "
396
+ >
397
+ <div class="flex items-center gap-2 font-semibold">
398
+ <UIcon
399
+ :name="vaultStatus.exists ? 'i-lucide-check-circle' : 'i-lucide-alert-circle'"
400
+ :class="vaultStatus.exists ? 'text-emerald-500 size-4' : 'text-yellow-500 size-4'"
401
+ />
402
+ <span v-if="!vaultStatus.configured">Vault not configured</span>
403
+ <span v-else-if="!vaultStatus.exists">Path does not exist</span>
404
+ <span v-else>Vault reachable</span>
405
+ </div>
406
+ <p v-if="vaultStatus.exists" class="text-muted font-mono">
407
+ {{ vaultStatus.vault_path }}
408
+ </p>
409
+ <ul v-if="vaultStatus.exists" class="space-y-0.5 pt-1">
410
+ <li class="flex items-center gap-2">
411
+ <UIcon name="i-lucide-folder" class="size-3 text-muted" />
412
+ <span class="font-mono">Personas/</span>
413
+ <UBadge
414
+ :label="`${vaultStatus.personas.count} files`"
415
+ variant="subtle"
416
+ size="xs"
417
+ />
418
+ </li>
419
+ <li class="flex items-center gap-2">
420
+ <UIcon name="i-lucide-folder" class="size-3 text-muted" />
421
+ <span class="font-mono">Agents/</span>
422
+ <UBadge
423
+ :label="`${vaultStatus.agents.count} files`"
424
+ variant="subtle"
425
+ size="xs"
426
+ />
427
+ </li>
428
+ </ul>
429
+ </div>
430
+
338
431
  <div class="flex justify-end pt-2">
339
432
  <UButton
340
433
  label="Save profile"
@@ -19,9 +19,38 @@ interface Workflow {
19
19
  content: string
20
20
  }
21
21
 
22
- const { fetchApi } = useApi()
22
+ const { fetchApi, apiBase } = useApi()
23
23
  const { data, status, error, refresh } = await fetchApi<{ workflows: Workflow[] }>('/api/workflows')
24
24
 
25
+ // PR89b v3.28.0 — recent runs for the selected workflow.
26
+ interface WorkflowRun {
27
+ session_id: string
28
+ started_at: string
29
+ ended_at: string
30
+ duration_s: number | null
31
+ calls: number
32
+ cost_usd: number | null
33
+ tokens_in: number
34
+ tokens_out: number
35
+ }
36
+ const runs = ref<WorkflowRun[]>([])
37
+ const runsLoading = ref(false)
38
+ const sidePanelTab = ref<'yaml' | 'runs'>('yaml')
39
+
40
+ async function loadRuns(id: string) {
41
+ runsLoading.value = true
42
+ try {
43
+ const res = await $fetch<{ runs: WorkflowRun[] }>(
44
+ `${apiBase}/api/workflows/${id}/runs?limit=10`,
45
+ )
46
+ runs.value = res.runs ?? []
47
+ } catch {
48
+ runs.value = []
49
+ } finally {
50
+ runsLoading.value = false
51
+ }
52
+ }
53
+
25
54
  const workflows = computed(() => data.value?.workflows ?? [])
26
55
  const search = ref('')
27
56
  const deptFilter = ref<'all' | string>('all')
@@ -123,7 +152,7 @@ const columns: TableColumn<Workflow>[] = [
123
152
  th: 'py-2 first:rounded-l-lg last:rounded-r-lg border-y border-default first:border-l last:border-r',
124
153
  td: 'border-b border-default',
125
154
  }"
126
- @select="(row: { original: Workflow }) => selected = row.original"
155
+ @select="(row: { original: Workflow }) => { selected = row.original; sidePanelTab = 'yaml'; runs = []; loadRuns(row.original.id) }"
127
156
  >
128
157
  <template #name-cell="{ row }">
129
158
  <div class="min-w-0">
@@ -160,7 +189,7 @@ const columns: TableColumn<Workflow>[] = [
160
189
  <div class="px-4 py-3 border-b border-default bg-elevated/30">
161
190
  <div class="flex items-start justify-between gap-3">
162
191
  <div class="min-w-0">
163
- <p class="text-xs text-muted uppercase tracking-wide">YAML preview</p>
192
+ <p class="text-xs text-muted uppercase tracking-wide">Workflow</p>
164
193
  <p class="font-semibold truncate">{{ selected.name }}</p>
165
194
  <p class="text-xs text-muted font-mono truncate">{{ selected.file }}</p>
166
195
  </div>
@@ -175,8 +204,67 @@ const columns: TableColumn<Workflow>[] = [
175
204
  <p v-if="selected.description" class="text-xs text-muted mt-2">
176
205
  {{ selected.description }}
177
206
  </p>
207
+ <div class="flex items-center gap-1 mt-3 text-xs">
208
+ <button
209
+ type="button"
210
+ class="px-2 py-1 rounded-md transition-colors"
211
+ :class="sidePanelTab === 'yaml' ? 'bg-elevated/60 text-default font-semibold' : 'text-muted hover:text-default'"
212
+ @click="sidePanelTab = 'yaml'"
213
+ >
214
+ YAML
215
+ </button>
216
+ <button
217
+ type="button"
218
+ class="px-2 py-1 rounded-md transition-colors"
219
+ :class="sidePanelTab === 'runs' ? 'bg-elevated/60 text-default font-semibold' : 'text-muted hover:text-default'"
220
+ @click="sidePanelTab = 'runs'"
221
+ >
222
+ Runs
223
+ </button>
224
+ </div>
225
+ </div>
226
+ <div v-if="sidePanelTab === 'yaml'" class="overflow-x-auto">
227
+ <pre class="p-4 text-xs font-mono whitespace-pre">{{ selected.content }}</pre>
228
+ </div>
229
+ <div v-else class="p-4">
230
+ <div v-if="runsLoading" class="py-6 text-center text-sm text-muted">
231
+ <UIcon name="i-lucide-loader-2" class="size-4 animate-spin inline" /> Loading…
232
+ </div>
233
+ <div v-else-if="!runs.length" class="py-6 text-center text-sm text-muted">
234
+ <UIcon name="i-lucide-history" class="size-6 mx-auto mb-2" />
235
+ No recorded runs yet. Set
236
+ <code class="font-mono text-xs">ARKA_CALL_CATEGORY=workflow:{{ selected.id }}</code>
237
+ in the orchestrator to populate this.
238
+ </div>
239
+ <ul v-else class="space-y-2">
240
+ <li
241
+ v-for="r in runs"
242
+ :key="r.session_id"
243
+ class="rounded-lg border border-default p-3"
244
+ >
245
+ <div class="flex items-center justify-between gap-3 text-xs">
246
+ <span class="font-mono text-muted truncate">{{ r.session_id }}</span>
247
+ <span class="text-muted shrink-0">{{ r.started_at }}</span>
248
+ </div>
249
+ <div class="flex items-center gap-3 text-xs mt-2">
250
+ <span>
251
+ <span class="text-muted">Calls</span>
252
+ <span class="font-mono font-semibold ml-1">{{ r.calls }}</span>
253
+ </span>
254
+ <span>
255
+ <span class="text-muted">Cost</span>
256
+ <span class="font-mono font-semibold ml-1">
257
+ {{ r.cost_usd === null ? '—' : `$${r.cost_usd.toFixed(3)}` }}
258
+ </span>
259
+ </span>
260
+ <span v-if="r.duration_s !== null">
261
+ <span class="text-muted">Duration</span>
262
+ <span class="font-mono font-semibold ml-1">{{ r.duration_s }}s</span>
263
+ </span>
264
+ </div>
265
+ </li>
266
+ </ul>
178
267
  </div>
179
- <pre class="p-4 text-xs font-mono overflow-x-auto whitespace-pre">{{ selected.content }}</pre>
180
268
  </div>
181
269
  <div
182
270
  v-else
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arkaos",
3
- "version": "3.27.0",
3
+ "version": "3.29.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 = "3.27.0"
3
+ version = "3.29.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"}
@@ -1617,6 +1617,75 @@ def department_detail(dept_id: str):
1617
1617
 
1618
1618
  # --- Workflows (PR88b v3.24.0) ---
1619
1619
 
1620
+ @app.get("/api/workflows/{workflow_id}/runs")
1621
+ def workflow_runs(workflow_id: str, limit: int = 20):
1622
+ """PR89b v3.28.0 — list recent runs of a workflow.
1623
+
1624
+ Parses the PR47 cost telemetry JSONL for rows tagged
1625
+ ``workflow:<workflow_id>`` and groups them by ``session_id``.
1626
+ Returns ``{runs: [{session_id, started_at, ended_at, duration_s,
1627
+ calls, cost_usd, tokens_in, tokens_out}]}`` sorted desc by start.
1628
+
1629
+ Workflows must set ``ARKA_CALL_CATEGORY=workflow:<id>`` before
1630
+ each call for this to populate. Returns an empty list when no
1631
+ matching entries exist (the common case until orchestrators opt in).
1632
+ """
1633
+ target_category = f"workflow:{workflow_id}"
1634
+ try:
1635
+ from core.runtime.llm_cost_telemetry import _load_slice
1636
+ except Exception:
1637
+ return {"runs": []}
1638
+ entries, _ = _load_slice(None, None)
1639
+ sessions: dict[str, dict] = {}
1640
+ for entry in entries:
1641
+ if entry.get("category") != target_category:
1642
+ continue
1643
+ sid = str(entry.get("session_id") or "")
1644
+ if not sid:
1645
+ continue
1646
+ ts = entry.get("ts") or ""
1647
+ bucket = sessions.setdefault(sid, {
1648
+ "session_id": sid,
1649
+ "started_at": ts,
1650
+ "ended_at": ts,
1651
+ "calls": 0,
1652
+ "cost_usd": 0.0,
1653
+ "any_cost_known": False,
1654
+ "tokens_in": 0,
1655
+ "tokens_out": 0,
1656
+ })
1657
+ bucket["calls"] += 1
1658
+ if ts and ts < bucket["started_at"]:
1659
+ bucket["started_at"] = ts
1660
+ if ts and ts > bucket["ended_at"]:
1661
+ bucket["ended_at"] = ts
1662
+ bucket["tokens_in"] += int(entry.get("tokens_in") or 0)
1663
+ bucket["tokens_out"] += int(entry.get("tokens_out") or 0)
1664
+ cost = entry.get("estimated_cost_usd")
1665
+ if isinstance(cost, (int, float)):
1666
+ bucket["cost_usd"] += float(cost)
1667
+ bucket["any_cost_known"] = True
1668
+
1669
+ runs = list(sessions.values())
1670
+ for r in runs:
1671
+ r["cost_usd"] = round(r["cost_usd"], 6) if r.pop("any_cost_known") else None
1672
+ r["duration_s"] = _iso_duration_s(r["started_at"], r["ended_at"])
1673
+ runs.sort(key=lambda r: str(r.get("started_at") or ""), reverse=True)
1674
+ return {"runs": runs[: max(0, int(limit))]}
1675
+
1676
+
1677
+ def _iso_duration_s(start_iso: str, end_iso: str) -> Optional[int]:
1678
+ if not start_iso or not end_iso:
1679
+ return None
1680
+ try:
1681
+ from datetime import datetime
1682
+ start = datetime.fromisoformat(start_iso.replace("Z", "+00:00"))
1683
+ end = datetime.fromisoformat(end_iso.replace("Z", "+00:00"))
1684
+ return max(0, int((end - start).total_seconds()))
1685
+ except (ValueError, TypeError):
1686
+ return None
1687
+
1688
+
1620
1689
  @app.get("/api/workflows")
1621
1690
  def workflows_list():
1622
1691
  """Scan departments/*/workflows/*.yaml and return metadata + content."""
@@ -2431,6 +2500,44 @@ def settings_plugins():
2431
2500
 
2432
2501
  # --- Profile (PR63 v2.81.0) ---
2433
2502
 
2503
+ @app.get("/api/settings/vault")
2504
+ def settings_vault():
2505
+ """PR89c v3.29.0 — vault path connection test.
2506
+
2507
+ Reads ``profile.vaultPath`` and reports back whether the path
2508
+ exists, whether ``Personas/`` and ``Agents/`` subdirs are present,
2509
+ and how many ``*.md`` files each contains.
2510
+
2511
+ Returns ``{configured, vault_path, exists, personas: {dir, count},
2512
+ agents: {dir, count}}``.
2513
+ """
2514
+ from core.profile import ProfileManager
2515
+ profile = ProfileManager().read()
2516
+ configured = bool(profile.vaultPath)
2517
+ vault = Path(profile.vaultPath).expanduser() if configured else None
2518
+ exists = bool(vault and vault.exists() and vault.is_dir())
2519
+
2520
+ def _subdir_info(name: str) -> dict:
2521
+ if not exists:
2522
+ return {"dir": "", "count": 0}
2523
+ sub = vault / name
2524
+ if not sub.exists():
2525
+ return {"dir": str(sub), "count": 0}
2526
+ try:
2527
+ count = sum(1 for _ in sub.glob("*.md"))
2528
+ except OSError:
2529
+ count = 0
2530
+ return {"dir": str(sub), "count": count}
2531
+
2532
+ return {
2533
+ "configured": configured,
2534
+ "vault_path": str(vault) if vault else "",
2535
+ "exists": exists,
2536
+ "personas": _subdir_info("Personas"),
2537
+ "agents": _subdir_info("Agents"),
2538
+ }
2539
+
2540
+
2434
2541
  @app.get("/api/profile")
2435
2542
  def profile_get():
2436
2543
  """Return the operator profile from ~/.arkaos/profile.json.