arkaos 3.11.0 → 3.12.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/__pycache__/trash.cpython-313.pyc +0 -0
- package/core/trash.py +245 -0
- package/dashboard/app/layouts/default.vue +7 -0
- package/dashboard/app/pages/agents/index.vue +51 -18
- package/dashboard/app/pages/personas/index.vue +35 -8
- package/dashboard/app/pages/trash.vue +163 -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 +67 -3
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
3.
|
|
1
|
+
3.12.0
|
|
Binary file
|
package/core/trash.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Trash + undo for destructive dashboard actions (PR85b v3.12.0).
|
|
2
|
+
|
|
3
|
+
Captures agent/persona deletions and agent moves into
|
|
4
|
+
``~/.arkaos/trash/`` so the operator can undo recent mistakes.
|
|
5
|
+
|
|
6
|
+
Each trash entry is two files:
|
|
7
|
+
- ``<timestamp>-<id>.payload`` — the file content (for deletes) or
|
|
8
|
+
empty (for moves, which don't lose data)
|
|
9
|
+
- ``<timestamp>-<id>.meta.json`` — metadata: kind, original_path,
|
|
10
|
+
optional new_path, agent/persona id, timestamp
|
|
11
|
+
|
|
12
|
+
Trash is bounded by ``MAX_ENTRIES`` (newest 50 kept). Older entries
|
|
13
|
+
are pruned on every `record_*` call.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import time
|
|
20
|
+
import uuid
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Literal
|
|
24
|
+
|
|
25
|
+
TrashKind = Literal["agent-delete", "persona-delete", "agent-move"]
|
|
26
|
+
|
|
27
|
+
MAX_ENTRIES = 50
|
|
28
|
+
_TRASH_DIR_NAME = ".arkaos/trash"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _trash_dir() -> Path:
|
|
32
|
+
target = Path.home() / _TRASH_DIR_NAME
|
|
33
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
return target
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class TrashEntry:
|
|
39
|
+
id: str
|
|
40
|
+
kind: TrashKind
|
|
41
|
+
item_id: str
|
|
42
|
+
timestamp: float
|
|
43
|
+
original_path: str
|
|
44
|
+
new_path: str | None
|
|
45
|
+
has_payload: bool
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def record_deletion(
|
|
49
|
+
kind: TrashKind,
|
|
50
|
+
item_id: str,
|
|
51
|
+
original_path: str,
|
|
52
|
+
content: str,
|
|
53
|
+
) -> str:
|
|
54
|
+
"""Record a destructive delete. Returns the new trash entry id."""
|
|
55
|
+
return _record(
|
|
56
|
+
kind=kind,
|
|
57
|
+
item_id=item_id,
|
|
58
|
+
original_path=original_path,
|
|
59
|
+
new_path=None,
|
|
60
|
+
payload=content,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def record_move(item_id: str, from_path: str, to_path: str) -> str:
|
|
65
|
+
"""Record an agent department move. Payload is empty."""
|
|
66
|
+
return _record(
|
|
67
|
+
kind="agent-move",
|
|
68
|
+
item_id=item_id,
|
|
69
|
+
original_path=from_path,
|
|
70
|
+
new_path=to_path,
|
|
71
|
+
payload="",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def list_trash(limit: int = 10) -> list[dict]:
|
|
76
|
+
"""Return the newest N trash entries (sorted desc by timestamp)."""
|
|
77
|
+
entries = _scan()
|
|
78
|
+
return [_entry_to_dict(e) for e in entries[: max(0, int(limit))]]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def restore(trash_id: str) -> dict:
|
|
82
|
+
"""Undo the action identified by trash_id. Returns a status dict."""
|
|
83
|
+
entry = _find(trash_id)
|
|
84
|
+
if entry is None:
|
|
85
|
+
return {"error": "trash entry not found"}
|
|
86
|
+
if entry.kind in ("agent-delete", "persona-delete"):
|
|
87
|
+
return _restore_delete(entry)
|
|
88
|
+
if entry.kind == "agent-move":
|
|
89
|
+
return _restore_move(entry)
|
|
90
|
+
return {"error": f"unknown trash kind: {entry.kind!r}"}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def purge(trash_id: str) -> dict:
|
|
94
|
+
"""Drop a trash entry without restoring it."""
|
|
95
|
+
entry = _find(trash_id)
|
|
96
|
+
if entry is None:
|
|
97
|
+
return {"error": "trash entry not found"}
|
|
98
|
+
_delete_entry(entry)
|
|
99
|
+
return {"purged": True, "id": trash_id}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# --- Internal helpers ---
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _record(
|
|
106
|
+
*,
|
|
107
|
+
kind: TrashKind,
|
|
108
|
+
item_id: str,
|
|
109
|
+
original_path: str,
|
|
110
|
+
new_path: str | None,
|
|
111
|
+
payload: str,
|
|
112
|
+
) -> str:
|
|
113
|
+
ts = time.time()
|
|
114
|
+
trash_id = f"{int(ts)}-{uuid.uuid4().hex[:6]}"
|
|
115
|
+
base = _trash_dir() / trash_id
|
|
116
|
+
if payload:
|
|
117
|
+
base.with_suffix(".payload").write_text(payload, encoding="utf-8")
|
|
118
|
+
meta = {
|
|
119
|
+
"id": trash_id,
|
|
120
|
+
"kind": kind,
|
|
121
|
+
"item_id": item_id,
|
|
122
|
+
"timestamp": ts,
|
|
123
|
+
"original_path": original_path,
|
|
124
|
+
"new_path": new_path,
|
|
125
|
+
"has_payload": bool(payload),
|
|
126
|
+
}
|
|
127
|
+
base.with_suffix(".meta.json").write_text(
|
|
128
|
+
json.dumps(meta, indent=2), encoding="utf-8",
|
|
129
|
+
)
|
|
130
|
+
_prune()
|
|
131
|
+
return trash_id
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _scan() -> list[TrashEntry]:
|
|
135
|
+
entries: list[TrashEntry] = []
|
|
136
|
+
for meta_file in _trash_dir().glob("*.meta.json"):
|
|
137
|
+
try:
|
|
138
|
+
meta = json.loads(meta_file.read_text(encoding="utf-8"))
|
|
139
|
+
except (OSError, json.JSONDecodeError):
|
|
140
|
+
continue
|
|
141
|
+
try:
|
|
142
|
+
entries.append(TrashEntry(
|
|
143
|
+
id=str(meta.get("id") or meta_file.stem.replace(".meta", "")),
|
|
144
|
+
kind=meta.get("kind") or "agent-delete",
|
|
145
|
+
item_id=str(meta.get("item_id") or ""),
|
|
146
|
+
timestamp=float(meta.get("timestamp") or 0.0),
|
|
147
|
+
original_path=str(meta.get("original_path") or ""),
|
|
148
|
+
new_path=meta.get("new_path"),
|
|
149
|
+
has_payload=bool(meta.get("has_payload")),
|
|
150
|
+
))
|
|
151
|
+
except (TypeError, ValueError):
|
|
152
|
+
continue
|
|
153
|
+
entries.sort(key=lambda e: e.timestamp, reverse=True)
|
|
154
|
+
return entries
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _find(trash_id: str) -> TrashEntry | None:
|
|
158
|
+
for entry in _scan():
|
|
159
|
+
if entry.id == trash_id:
|
|
160
|
+
return entry
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _restore_delete(entry: TrashEntry) -> dict:
|
|
165
|
+
payload_path = _trash_dir() / f"{entry.id}.payload"
|
|
166
|
+
if not payload_path.exists():
|
|
167
|
+
return {"error": "payload missing — cannot restore"}
|
|
168
|
+
dest = Path(entry.original_path)
|
|
169
|
+
if dest.exists():
|
|
170
|
+
return {"error": f"target already exists: {dest}"}
|
|
171
|
+
try:
|
|
172
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
173
|
+
dest.write_text(payload_path.read_text(encoding="utf-8"), encoding="utf-8")
|
|
174
|
+
except OSError as exc:
|
|
175
|
+
return {"error": f"restore failed: {exc}"}
|
|
176
|
+
_delete_entry(entry)
|
|
177
|
+
return {"restored": True, "kind": entry.kind, "path": str(dest)}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _restore_move(entry: TrashEntry) -> dict:
|
|
181
|
+
if not entry.new_path:
|
|
182
|
+
return {"error": "move record has no new_path"}
|
|
183
|
+
current = Path(entry.new_path)
|
|
184
|
+
if not current.exists():
|
|
185
|
+
return {"error": f"file no longer at {current}"}
|
|
186
|
+
target = Path(entry.original_path)
|
|
187
|
+
if target.exists():
|
|
188
|
+
return {"error": f"original path occupied: {target}"}
|
|
189
|
+
try:
|
|
190
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
191
|
+
# Rewrite the `department:` field back if YAML
|
|
192
|
+
if target.suffix in (".yaml", ".yml"):
|
|
193
|
+
try:
|
|
194
|
+
import yaml as _yaml
|
|
195
|
+
raw = _yaml.safe_load(current.read_text(encoding="utf-8")) or {}
|
|
196
|
+
if isinstance(raw, dict):
|
|
197
|
+
old_dept = target.parent.parent.name
|
|
198
|
+
raw["department"] = old_dept
|
|
199
|
+
current.write_text(
|
|
200
|
+
_yaml.safe_dump(
|
|
201
|
+
raw,
|
|
202
|
+
sort_keys=False,
|
|
203
|
+
allow_unicode=True,
|
|
204
|
+
default_flow_style=False,
|
|
205
|
+
),
|
|
206
|
+
encoding="utf-8",
|
|
207
|
+
)
|
|
208
|
+
except Exception:
|
|
209
|
+
pass
|
|
210
|
+
current.rename(target)
|
|
211
|
+
except OSError as exc:
|
|
212
|
+
return {"error": f"restore failed: {exc}"}
|
|
213
|
+
_delete_entry(entry)
|
|
214
|
+
return {"restored": True, "kind": entry.kind, "path": str(target)}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _delete_entry(entry: TrashEntry) -> None:
|
|
218
|
+
base = _trash_dir() / entry.id
|
|
219
|
+
for suffix in (".payload", ".meta.json"):
|
|
220
|
+
p = base.with_suffix(suffix)
|
|
221
|
+
if p.exists():
|
|
222
|
+
try:
|
|
223
|
+
p.unlink()
|
|
224
|
+
except OSError:
|
|
225
|
+
pass
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _prune() -> None:
|
|
229
|
+
entries = _scan()
|
|
230
|
+
if len(entries) <= MAX_ENTRIES:
|
|
231
|
+
return
|
|
232
|
+
for entry in entries[MAX_ENTRIES:]:
|
|
233
|
+
_delete_entry(entry)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _entry_to_dict(entry: TrashEntry) -> dict:
|
|
237
|
+
return {
|
|
238
|
+
"id": entry.id,
|
|
239
|
+
"kind": entry.kind,
|
|
240
|
+
"item_id": entry.item_id,
|
|
241
|
+
"timestamp": entry.timestamp,
|
|
242
|
+
"original_path": entry.original_path,
|
|
243
|
+
"new_path": entry.new_path,
|
|
244
|
+
"has_payload": entry.has_payload,
|
|
245
|
+
}
|
|
@@ -186,26 +186,32 @@ async function bulkMove(targetDept: string) {
|
|
|
186
186
|
bulkMoving.value = true
|
|
187
187
|
const results = await Promise.allSettled(
|
|
188
188
|
ids.map((id) =>
|
|
189
|
-
$fetch<{ moved?: boolean, error?: string }>(
|
|
190
|
-
|
|
191
|
-
body: { department: targetDept },
|
|
192
|
-
|
|
189
|
+
$fetch<{ moved?: boolean, trash_id?: string, error?: string }>(
|
|
190
|
+
`${apiBase}/api/agents/${id}/move`,
|
|
191
|
+
{ method: 'POST', body: { department: targetDept } },
|
|
192
|
+
),
|
|
193
193
|
),
|
|
194
194
|
)
|
|
195
195
|
const successes = results.filter(
|
|
196
196
|
(r) => r.status === 'fulfilled' && r.value.moved,
|
|
197
|
-
)
|
|
198
|
-
const
|
|
197
|
+
)
|
|
198
|
+
const trashIds = successes
|
|
199
|
+
.map((r) => (r.status === 'fulfilled' ? r.value.trash_id : null))
|
|
200
|
+
.filter((v): v is string => Boolean(v))
|
|
201
|
+
const failures = ids.length - successes.length
|
|
199
202
|
toast.add({
|
|
200
|
-
title: successes > 0
|
|
201
|
-
? `Moved ${successes} agent${successes === 1 ? '' : 's'}`
|
|
203
|
+
title: successes.length > 0
|
|
204
|
+
? `Moved ${successes.length} agent${successes.length === 1 ? '' : 's'}`
|
|
202
205
|
: 'Nothing moved',
|
|
203
206
|
description: failures > 0
|
|
204
207
|
? `${failures} skipped (Tier 0, collision, or missing)`
|
|
205
208
|
: undefined,
|
|
206
|
-
color: successes > 0 && failures === 0
|
|
209
|
+
color: successes.length > 0 && failures === 0
|
|
207
210
|
? 'success'
|
|
208
|
-
: failures > 0 && successes > 0 ? 'warning' : 'error',
|
|
211
|
+
: failures > 0 && successes.length > 0 ? 'warning' : 'error',
|
|
212
|
+
actions: trashIds.length > 0
|
|
213
|
+
? [{ label: 'Undo', icon: 'i-lucide-rotate-ccw', onClick: () => undoTrashIds(trashIds) }]
|
|
214
|
+
: undefined,
|
|
209
215
|
})
|
|
210
216
|
clearSelection()
|
|
211
217
|
bulkMoving.value = false
|
|
@@ -252,20 +258,27 @@ async function bulkDelete() {
|
|
|
252
258
|
bulkDeleting.value = true
|
|
253
259
|
const results = await Promise.allSettled(
|
|
254
260
|
ids.map((id) =>
|
|
255
|
-
$fetch<{ deleted?: boolean, error?: string }>(
|
|
256
|
-
|
|
257
|
-
|
|
261
|
+
$fetch<{ deleted?: boolean, trash_id?: string, error?: string }>(
|
|
262
|
+
`${apiBase}/api/agents/${id}`,
|
|
263
|
+
{ method: 'DELETE' },
|
|
264
|
+
),
|
|
258
265
|
),
|
|
259
266
|
)
|
|
260
267
|
const successes = results.filter(
|
|
261
268
|
(r) => r.status === 'fulfilled' && r.value.deleted,
|
|
262
|
-
)
|
|
263
|
-
const
|
|
264
|
-
|
|
269
|
+
)
|
|
270
|
+
const trashIds = successes
|
|
271
|
+
.map((r) => (r.status === 'fulfilled' ? r.value.trash_id : null))
|
|
272
|
+
.filter((v): v is string => Boolean(v))
|
|
273
|
+
const failures = ids.length - successes.length
|
|
274
|
+
if (successes.length > 0) {
|
|
265
275
|
toast.add({
|
|
266
|
-
title: `Deleted ${successes} agent${successes === 1 ? '' : 's'}`,
|
|
267
|
-
description: failures > 0 ? `${failures} skipped (Tier 0 or missing)` :
|
|
276
|
+
title: `Deleted ${successes.length} agent${successes.length === 1 ? '' : 's'}`,
|
|
277
|
+
description: failures > 0 ? `${failures} skipped (Tier 0 or missing)` : 'Undo from /trash within 50 ops.',
|
|
268
278
|
color: failures > 0 ? 'warning' : 'success',
|
|
279
|
+
actions: trashIds.length > 0
|
|
280
|
+
? [{ label: 'Undo', icon: 'i-lucide-rotate-ccw', onClick: () => undoTrashIds(trashIds) }]
|
|
281
|
+
: undefined,
|
|
269
282
|
})
|
|
270
283
|
} else {
|
|
271
284
|
toast.add({
|
|
@@ -278,6 +291,26 @@ async function bulkDelete() {
|
|
|
278
291
|
bulkDeleting.value = false
|
|
279
292
|
await refreshAll()
|
|
280
293
|
}
|
|
294
|
+
|
|
295
|
+
async function undoTrashIds(ids: string[]) {
|
|
296
|
+
const results = await Promise.allSettled(
|
|
297
|
+
ids.map((tid) =>
|
|
298
|
+
$fetch<{ restored?: boolean, error?: string }>(
|
|
299
|
+
`${apiBase}/api/trash/${tid}/restore`,
|
|
300
|
+
{ method: 'POST' },
|
|
301
|
+
),
|
|
302
|
+
),
|
|
303
|
+
)
|
|
304
|
+
const restored = results.filter(
|
|
305
|
+
(r) => r.status === 'fulfilled' && r.value.restored,
|
|
306
|
+
).length
|
|
307
|
+
toast.add({
|
|
308
|
+
title: restored > 0 ? `Restored ${restored}` : 'Undo failed',
|
|
309
|
+
description: restored < ids.length ? `${ids.length - restored} could not be restored.` : undefined,
|
|
310
|
+
color: restored === ids.length ? 'success' : restored > 0 ? 'warning' : 'error',
|
|
311
|
+
})
|
|
312
|
+
await refreshAll()
|
|
313
|
+
}
|
|
281
314
|
</script>
|
|
282
315
|
|
|
283
316
|
<template>
|
|
@@ -179,20 +179,27 @@ async function bulkDelete() {
|
|
|
179
179
|
bulkDeleting.value = true
|
|
180
180
|
const results = await Promise.allSettled(
|
|
181
181
|
ids.map((id) =>
|
|
182
|
-
$fetch<{ deleted?: boolean, error?: string }>(
|
|
183
|
-
|
|
184
|
-
|
|
182
|
+
$fetch<{ deleted?: boolean, trash_id?: string, error?: string }>(
|
|
183
|
+
`${apiBase}/api/personas/${id}`,
|
|
184
|
+
{ method: 'DELETE' },
|
|
185
|
+
),
|
|
185
186
|
),
|
|
186
187
|
)
|
|
187
188
|
const successes = results.filter(
|
|
188
189
|
(r) => r.status === 'fulfilled' && r.value.deleted,
|
|
189
|
-
)
|
|
190
|
-
const
|
|
191
|
-
|
|
190
|
+
)
|
|
191
|
+
const trashIds = successes
|
|
192
|
+
.map((r) => (r.status === 'fulfilled' ? r.value.trash_id : null))
|
|
193
|
+
.filter((v): v is string => Boolean(v))
|
|
194
|
+
const failures = ids.length - successes.length
|
|
195
|
+
if (successes.length > 0) {
|
|
192
196
|
toast.add({
|
|
193
|
-
title: `Deleted ${successes} persona${successes === 1 ? '' : 's'}`,
|
|
194
|
-
description: failures > 0 ? `${failures} failed` :
|
|
197
|
+
title: `Deleted ${successes.length} persona${successes.length === 1 ? '' : 's'}`,
|
|
198
|
+
description: failures > 0 ? `${failures} failed` : 'Undo from /trash within 50 ops.',
|
|
195
199
|
color: failures > 0 ? 'warning' : 'success',
|
|
200
|
+
actions: trashIds.length > 0
|
|
201
|
+
? [{ label: 'Undo', icon: 'i-lucide-rotate-ccw', onClick: () => undoTrashIds(trashIds) }]
|
|
202
|
+
: undefined,
|
|
196
203
|
})
|
|
197
204
|
} else {
|
|
198
205
|
toast.add({
|
|
@@ -204,6 +211,26 @@ async function bulkDelete() {
|
|
|
204
211
|
bulkDeleting.value = false
|
|
205
212
|
await refreshAll()
|
|
206
213
|
}
|
|
214
|
+
|
|
215
|
+
async function undoTrashIds(ids: string[]) {
|
|
216
|
+
const results = await Promise.allSettled(
|
|
217
|
+
ids.map((tid) =>
|
|
218
|
+
$fetch<{ restored?: boolean, error?: string }>(
|
|
219
|
+
`${apiBase}/api/trash/${tid}/restore`,
|
|
220
|
+
{ method: 'POST' },
|
|
221
|
+
),
|
|
222
|
+
),
|
|
223
|
+
)
|
|
224
|
+
const restored = results.filter(
|
|
225
|
+
(r) => r.status === 'fulfilled' && r.value.restored,
|
|
226
|
+
).length
|
|
227
|
+
toast.add({
|
|
228
|
+
title: restored > 0 ? `Restored ${restored}` : 'Undo failed',
|
|
229
|
+
description: restored < ids.length ? `${ids.length - restored} could not be restored.` : undefined,
|
|
230
|
+
color: restored === ids.length ? 'success' : restored > 0 ? 'warning' : 'error',
|
|
231
|
+
})
|
|
232
|
+
await refreshAll()
|
|
233
|
+
}
|
|
207
234
|
</script>
|
|
208
235
|
|
|
209
236
|
<template>
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// PR85b v3.12.0 — Trash listing + Restore / Discard.
|
|
3
|
+
|
|
4
|
+
interface TrashEntry {
|
|
5
|
+
id: string
|
|
6
|
+
kind: 'agent-delete' | 'persona-delete' | 'agent-move'
|
|
7
|
+
item_id: string
|
|
8
|
+
timestamp: number
|
|
9
|
+
original_path: string
|
|
10
|
+
new_path: string | null
|
|
11
|
+
has_payload: boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const { fetchApi, apiBase } = useApi()
|
|
15
|
+
const toast = useToast()
|
|
16
|
+
const confirmDialog = useConfirmDialog()
|
|
17
|
+
|
|
18
|
+
const { data, status, error, refresh } = await fetchApi<{ entries: TrashEntry[] }>(
|
|
19
|
+
'/api/trash?limit=50',
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
async function restore(entry: TrashEntry) {
|
|
23
|
+
try {
|
|
24
|
+
const res = await $fetch<{ restored?: boolean, error?: string }>(
|
|
25
|
+
`${apiBase}/api/trash/${entry.id}/restore`,
|
|
26
|
+
{ method: 'POST' },
|
|
27
|
+
)
|
|
28
|
+
if (res.error) throw new Error(res.error)
|
|
29
|
+
toast.add({
|
|
30
|
+
title: 'Restored',
|
|
31
|
+
description: kindLabel(entry.kind) + ' restored.',
|
|
32
|
+
color: 'success',
|
|
33
|
+
icon: 'i-lucide-rotate-ccw',
|
|
34
|
+
})
|
|
35
|
+
await refresh()
|
|
36
|
+
} catch (err) {
|
|
37
|
+
toast.add({
|
|
38
|
+
title: 'Restore failed',
|
|
39
|
+
description: err instanceof Error ? err.message : 'unknown error',
|
|
40
|
+
color: 'error',
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function discard(entry: TrashEntry) {
|
|
46
|
+
const ok = await confirmDialog({
|
|
47
|
+
title: 'Discard trash entry?',
|
|
48
|
+
description: `${kindLabel(entry.kind)} for ${entry.item_id} will be permanently dropped.`,
|
|
49
|
+
confirmLabel: 'Discard',
|
|
50
|
+
cancelLabel: 'Cancel',
|
|
51
|
+
variant: 'danger',
|
|
52
|
+
})
|
|
53
|
+
if (!ok) return
|
|
54
|
+
try {
|
|
55
|
+
await $fetch(`${apiBase}/api/trash/${entry.id}`, { method: 'DELETE' })
|
|
56
|
+
toast.add({ title: 'Discarded', color: 'success' })
|
|
57
|
+
await refresh()
|
|
58
|
+
} catch (err) {
|
|
59
|
+
toast.add({
|
|
60
|
+
title: 'Discard failed',
|
|
61
|
+
description: err instanceof Error ? err.message : 'unknown error',
|
|
62
|
+
color: 'error',
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function kindLabel(kind: string): string {
|
|
68
|
+
return {
|
|
69
|
+
'agent-delete': 'Agent delete',
|
|
70
|
+
'persona-delete': 'Persona delete',
|
|
71
|
+
'agent-move': 'Agent move',
|
|
72
|
+
}[kind] ?? kind
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function kindColor(kind: string): 'error' | 'warning' | 'primary' | 'neutral' {
|
|
76
|
+
return ({
|
|
77
|
+
'agent-delete': 'error',
|
|
78
|
+
'persona-delete': 'error',
|
|
79
|
+
'agent-move': 'warning',
|
|
80
|
+
} as const)[kind] ?? 'neutral'
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function formatRelative(ts: number): string {
|
|
84
|
+
const diff = Date.now() - ts * 1000
|
|
85
|
+
const minutes = Math.floor(diff / 60_000)
|
|
86
|
+
if (minutes < 1) return 'just now'
|
|
87
|
+
if (minutes < 60) return `${minutes}m ago`
|
|
88
|
+
const hours = Math.floor(minutes / 60)
|
|
89
|
+
if (hours < 24) return `${hours}h ago`
|
|
90
|
+
const days = Math.floor(hours / 24)
|
|
91
|
+
return `${days}d ago`
|
|
92
|
+
}
|
|
93
|
+
</script>
|
|
94
|
+
|
|
95
|
+
<template>
|
|
96
|
+
<UDashboardPanel id="trash">
|
|
97
|
+
<template #header>
|
|
98
|
+
<UDashboardNavbar title="Trash">
|
|
99
|
+
<template #leading>
|
|
100
|
+
<UDashboardSidebarCollapse />
|
|
101
|
+
</template>
|
|
102
|
+
<template #trailing>
|
|
103
|
+
<UBadge
|
|
104
|
+
v-if="data?.entries?.length"
|
|
105
|
+
:label="`${data.entries.length} item${data.entries.length === 1 ? '' : 's'}`"
|
|
106
|
+
variant="subtle"
|
|
107
|
+
/>
|
|
108
|
+
</template>
|
|
109
|
+
</UDashboardNavbar>
|
|
110
|
+
</template>
|
|
111
|
+
|
|
112
|
+
<template #body>
|
|
113
|
+
<DashboardState
|
|
114
|
+
:status="status"
|
|
115
|
+
:error="error"
|
|
116
|
+
:empty="!data?.entries?.length"
|
|
117
|
+
empty-title="Trash is empty"
|
|
118
|
+
empty-description="Deleted agents, personas, and department moves show up here for 50 entries."
|
|
119
|
+
empty-icon="i-lucide-trash"
|
|
120
|
+
loading-label="Loading trash"
|
|
121
|
+
:on-retry="() => refresh()"
|
|
122
|
+
>
|
|
123
|
+
<div class="space-y-2 max-w-4xl">
|
|
124
|
+
<div
|
|
125
|
+
v-for="entry in data?.entries ?? []"
|
|
126
|
+
:key="entry.id"
|
|
127
|
+
class="rounded-lg border border-default p-3 flex items-center gap-3 hover:border-primary/40 transition-colors"
|
|
128
|
+
>
|
|
129
|
+
<UBadge
|
|
130
|
+
:label="kindLabel(entry.kind)"
|
|
131
|
+
:color="kindColor(entry.kind)"
|
|
132
|
+
variant="subtle"
|
|
133
|
+
size="sm"
|
|
134
|
+
/>
|
|
135
|
+
<div class="flex-1 min-w-0">
|
|
136
|
+
<p class="text-sm font-mono font-semibold truncate">{{ entry.item_id }}</p>
|
|
137
|
+
<p class="text-xs text-muted truncate" :title="entry.original_path">
|
|
138
|
+
{{ entry.original_path }}<span v-if="entry.new_path"> → {{ entry.new_path }}</span>
|
|
139
|
+
</p>
|
|
140
|
+
</div>
|
|
141
|
+
<span class="text-xs text-muted shrink-0">{{ formatRelative(entry.timestamp) }}</span>
|
|
142
|
+
<UButton
|
|
143
|
+
label="Restore"
|
|
144
|
+
icon="i-lucide-rotate-ccw"
|
|
145
|
+
size="xs"
|
|
146
|
+
variant="soft"
|
|
147
|
+
color="primary"
|
|
148
|
+
@click="restore(entry)"
|
|
149
|
+
/>
|
|
150
|
+
<UButton
|
|
151
|
+
icon="i-lucide-x"
|
|
152
|
+
size="xs"
|
|
153
|
+
variant="ghost"
|
|
154
|
+
color="neutral"
|
|
155
|
+
aria-label="Discard"
|
|
156
|
+
@click="discard(entry)"
|
|
157
|
+
/>
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
</DashboardState>
|
|
161
|
+
</template>
|
|
162
|
+
</UDashboardPanel>
|
|
163
|
+
</template>
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
Binary file
|
package/scripts/dashboard-api.py
CHANGED
|
@@ -1223,9 +1223,20 @@ def agent_move(agent_id: str, body: dict):
|
|
|
1223
1223
|
)
|
|
1224
1224
|
tmp.replace(yaml_file)
|
|
1225
1225
|
yaml_file.rename(dest_file)
|
|
1226
|
+
from core import trash as _trash
|
|
1227
|
+
trash_id = _trash.record_move(
|
|
1228
|
+
item_id=agent_id,
|
|
1229
|
+
from_path=str(yaml_file),
|
|
1230
|
+
to_path=str(dest_file),
|
|
1231
|
+
)
|
|
1226
1232
|
except (OSError, ImportError) as exc:
|
|
1227
1233
|
return {"error": f"move failed: {exc}"}
|
|
1228
|
-
return {
|
|
1234
|
+
return {
|
|
1235
|
+
"moved": True,
|
|
1236
|
+
"id": agent_id,
|
|
1237
|
+
"yaml_path": str(dest_file),
|
|
1238
|
+
"trash_id": trash_id,
|
|
1239
|
+
}
|
|
1229
1240
|
|
|
1230
1241
|
|
|
1231
1242
|
@app.delete("/api/agents/{agent_id}")
|
|
@@ -1247,10 +1258,23 @@ def agent_delete(agent_id: str):
|
|
|
1247
1258
|
if tier == 0:
|
|
1248
1259
|
return {"error": "Cannot delete Tier 0 (C-Suite) agents from the dashboard"}
|
|
1249
1260
|
try:
|
|
1261
|
+
from core import trash as _trash
|
|
1262
|
+
content = yaml_file.read_text(encoding="utf-8")
|
|
1263
|
+
trash_id = _trash.record_deletion(
|
|
1264
|
+
kind="agent-delete",
|
|
1265
|
+
item_id=agent_id,
|
|
1266
|
+
original_path=str(yaml_file),
|
|
1267
|
+
content=content,
|
|
1268
|
+
)
|
|
1250
1269
|
yaml_file.unlink()
|
|
1251
1270
|
except OSError as exc:
|
|
1252
1271
|
return {"error": f"delete failed: {exc}"}
|
|
1253
|
-
return {
|
|
1272
|
+
return {
|
|
1273
|
+
"deleted": True,
|
|
1274
|
+
"id": agent_id,
|
|
1275
|
+
"yaml_path": str(yaml_file),
|
|
1276
|
+
"trash_id": trash_id,
|
|
1277
|
+
}
|
|
1254
1278
|
|
|
1255
1279
|
|
|
1256
1280
|
def _resolve_agent_yaml(agent_id: str) -> Optional[Path]:
|
|
@@ -1282,11 +1306,51 @@ def persona_delete(persona_id: str):
|
|
|
1282
1306
|
mgr = _get_persona_manager()
|
|
1283
1307
|
if not mgr:
|
|
1284
1308
|
return {"error": "Persona manager unavailable"}
|
|
1309
|
+
# Capture content + path BEFORE delete for trash record (best-effort)
|
|
1310
|
+
trash_id = None
|
|
1311
|
+
try:
|
|
1312
|
+
from core import trash as _trash
|
|
1313
|
+
target = mgr.get(persona_id) if hasattr(mgr, "get") else None
|
|
1314
|
+
if target is not None:
|
|
1315
|
+
import json as _json
|
|
1316
|
+
payload = _json.dumps(
|
|
1317
|
+
target if isinstance(target, dict) else target.model_dump(),
|
|
1318
|
+
indent=2,
|
|
1319
|
+
)
|
|
1320
|
+
store_path = getattr(mgr, "_store_path", None) or "personas.json"
|
|
1321
|
+
trash_id = _trash.record_deletion(
|
|
1322
|
+
kind="persona-delete",
|
|
1323
|
+
item_id=persona_id,
|
|
1324
|
+
original_path=str(store_path) + f"#{persona_id}",
|
|
1325
|
+
content=payload,
|
|
1326
|
+
)
|
|
1327
|
+
except Exception:
|
|
1328
|
+
pass
|
|
1285
1329
|
if mgr.delete(persona_id):
|
|
1286
|
-
return {"deleted": True}
|
|
1330
|
+
return {"deleted": True, "trash_id": trash_id}
|
|
1287
1331
|
return {"error": "Persona not found"}
|
|
1288
1332
|
|
|
1289
1333
|
|
|
1334
|
+
# --- Trash / Undo (PR85b v3.12.0) ---
|
|
1335
|
+
|
|
1336
|
+
@app.get("/api/trash")
|
|
1337
|
+
def trash_list(limit: int = 10):
|
|
1338
|
+
from core import trash as _trash
|
|
1339
|
+
return {"entries": _trash.list_trash(limit=limit)}
|
|
1340
|
+
|
|
1341
|
+
|
|
1342
|
+
@app.post("/api/trash/{trash_id}/restore")
|
|
1343
|
+
def trash_restore(trash_id: str):
|
|
1344
|
+
from core import trash as _trash
|
|
1345
|
+
return _trash.restore(trash_id)
|
|
1346
|
+
|
|
1347
|
+
|
|
1348
|
+
@app.delete("/api/trash/{trash_id}")
|
|
1349
|
+
def trash_purge(trash_id: str):
|
|
1350
|
+
from core import trash as _trash
|
|
1351
|
+
return _trash.purge(trash_id)
|
|
1352
|
+
|
|
1353
|
+
|
|
1290
1354
|
@app.post("/api/personas/build")
|
|
1291
1355
|
def persona_build(body: dict):
|
|
1292
1356
|
"""PR57 v2.74.0 — AI-powered persona draft from already-indexed content.
|