Unified Agent + reusable Go agent core.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(console): unify state files and group audit by run id

Lyric 2829a893 2c289635

+380 -334
+33 -10
docs/arch.md
··· 22 22 | config snapshot + deps | 23 23 +------------+------------+ 24 24 | 25 - +-----------------------+-----------------------+ 26 - | | 27 - +---------v----------+ +----------v---------+ 28 - | One-shot runtime | | Channel runtime | 29 - | run / serve | | telegram / slack | 30 - +---------+----------+ +----------+---------+ 31 - | | 32 - +-----------------------+-----------------------+ 25 + +-----------------+---------------+-----------------------+ 26 + | | | 27 + +----v-----+ +-------v--------+ +--------v--------+ 28 + | One-shot | | Channel runtime| | Heartbeat | 29 + | runtime | | telegram/slack | | scheduler | 30 + | run/serve| | event workers | | periodic checks | 31 + +----+-----+ +-------+--------+ +--------+--------+ 32 + | | | 33 + +-----------------+-------------------+-------------------+ 33 34 | 34 35 +--------v--------+ 35 36 | agent.Engine | ··· 42 43 +-----v-----+ +-----v------------------+ 43 44 | providers | | builtin/tools/adapters | 44 45 +-----------+ +------------------------+ 45 - 46 - Cross-cutting: guard, skills/prompt blocks, inspect dump, bus idempotency, file_state_dir 46 + Cross-cutting: guard, skills/prompt blocks, inspect dump, bus idempotency, file_state_dir, HEARTBEAT.md 47 47 ``` 48 48 49 49 ## 2. Main Execution Flow ··· 97 97 - Tools system: [`./tools.md`](./tools.md) 98 98 - Security / Guard: [`./security.md`](./security.md) 99 99 - Skills system: [`./skills.md`](./skills.md) 100 + - Heartbeat feature notes: [`./feat/feat_20260204_heartbeat.md`](./feat/feat_20260204_heartbeat.md) 100 101 - Telegram runtime behavior: [`./telegram.md`](./telegram.md) 101 102 - Slack Socket Mode: [`./slack.md`](./slack.md) 102 103 - Bus design and implementation: [`./bus.md`](./bus.md), [`./bus_impl.md`](./bus_impl.md) ··· 133 134 - Runtime-level memory integration is currently wired for Telegram only; Slack memory integration is not yet wired. 134 135 - Storage model lives in `memory/*`, runtime integration is in `internal/channelruntime/telegram/runtime_task.go`. 135 136 137 + ### 5.3 Heartbeat Runtime Path 138 + 139 + ```text 140 + heartbeat ticker (runtime scheduler) 141 + -> heartbeatutil.Tick(state, buildTask, enqueueTask) 142 + -> BuildHeartbeatTask(HEARTBEAT.md) 143 + -> enqueue heartbeat job (meta.trigger=heartbeat) 144 + -> agent.Engine.Run (normal tools/skills enabled) 145 + -> summary output (runtime-defined sink, e.g. logs/chat) 146 + ``` 147 + 148 + Notes: 149 + 150 + - Heartbeat shares the same agent execution core; it differs mainly by scheduler path and metadata envelope. 151 + - Scheduler-side skip reasons include `already_running`, `worker_busy`, `worker_queue_full`, and `empty_task`. 152 + - Consecutive failures are tracked by `heartbeatutil.State`; alert escalation is emitted after threshold. 153 + - Code: 154 + - shared helpers: `internal/heartbeatutil/heartbeat.go`, `internal/heartbeatutil/scheduler.go` 155 + - runtime integrations: `cmd/mistermorph/daemoncmd/serve.go`, `internal/channelruntime/telegram/runtime.go` 156 + 136 157 ## 6. State Directory and Naming Baseline 137 158 138 159 ```text 139 160 file_state_dir (default ~/.morph) 161 + ├── HEARTBEAT.md 140 162 ├── contacts/ 141 163 │ ├── ACTIVE.md 142 164 │ ├── INACTIVE.md ··· 153 175 154 176 Additional notes: 155 177 178 + - `HEARTBEAT.md` is the default heartbeat checklist input (`statepaths.HeartbeatChecklistPath()`). 156 179 - Memory short-term filenames come from sanitized `session_id` values (letters, digits, `-`, `_`). 157 180 - Contacts bus dedupe keys: 158 181 - inbox: `(channel, platform_message_id)`
+96 -36
internal/daemonruntime/server.go
··· 183 183 diagnoseFileReadable("todo_done", paths.todoDone), 184 184 diagnoseFileReadable("persona_identity", paths.identityPath), 185 185 diagnoseFileReadable("persona_soul", paths.soulPath), 186 + diagnoseFileReadable("heartbeat_checklist", paths.heartbeatPath), 186 187 diagnoseFileReadable("audit_jsonl", paths.auditPath), 187 188 } 188 189 w.Header().Set("Content-Type", "application/json") ··· 193 194 }) 194 195 }) 195 196 197 + mux.HandleFunc("/state/files", func(w http.ResponseWriter, r *http.Request) { 198 + if r.Method != http.MethodGet { 199 + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 200 + return 201 + } 202 + if !checkAuth(r, authToken) { 203 + http.Error(w, "unauthorized", http.StatusUnauthorized) 204 + return 205 + } 206 + paths := resolveRuntimeStatePaths() 207 + w.Header().Set("Content-Type", "application/json") 208 + _ = json.NewEncoder(w).Encode(map[string]any{ 209 + "items": describeStateFiles(paths, ""), 210 + }) 211 + }) 212 + mux.HandleFunc("/state/files/", func(w http.ResponseWriter, r *http.Request) { 213 + if !checkAuth(r, authToken) { 214 + http.Error(w, "unauthorized", http.StatusUnauthorized) 215 + return 216 + } 217 + paths := resolveRuntimeStatePaths() 218 + name := strings.TrimSpace(strings.TrimPrefix(r.URL.Path, "/state/files/")) 219 + spec, ok := resolveStateFileSpec(paths, "", name) 220 + if !ok { 221 + http.Error(w, "invalid file name", http.StatusBadRequest) 222 + return 223 + } 224 + handleTextFileDetail(w, r, spec.Name, spec.Path) 225 + }) 226 + 196 227 mux.HandleFunc("/todo/files", func(w http.ResponseWriter, r *http.Request) { 197 228 if r.Method != http.MethodGet { 198 229 http.Error(w, "method not allowed", http.StatusMethodNotAllowed) ··· 205 236 paths := resolveRuntimeStatePaths() 206 237 w.Header().Set("Content-Type", "application/json") 207 238 _ = json.NewEncoder(w).Encode(map[string]any{ 208 - "items": []map[string]any{ 209 - describeFile("TODO.md", paths.todoWIP), 210 - describeFile("TODO.DONE.md", paths.todoDone), 211 - }, 239 + "items": describeStateFiles(paths, "todo"), 212 240 }) 213 241 }) 214 242 mux.HandleFunc("/todo/files/", func(w http.ResponseWriter, r *http.Request) { ··· 218 246 } 219 247 paths := resolveRuntimeStatePaths() 220 248 name := strings.TrimSpace(strings.TrimPrefix(r.URL.Path, "/todo/files/")) 221 - var filePath string 222 - switch strings.ToUpper(name) { 223 - case "TODO.MD": 224 - filePath = paths.todoWIP 225 - case "TODO.DONE.MD": 226 - filePath = paths.todoDone 227 - default: 249 + spec, ok := resolveStateFileSpec(paths, "todo", name) 250 + if !ok { 228 251 http.Error(w, "invalid file name", http.StatusBadRequest) 229 252 return 230 253 } 231 - handleTextFileDetail(w, r, name, filePath) 254 + handleTextFileDetail(w, r, spec.Name, spec.Path) 232 255 }) 233 256 234 257 mux.HandleFunc("/contacts/files", func(w http.ResponseWriter, r *http.Request) { ··· 243 266 paths := resolveRuntimeStatePaths() 244 267 w.Header().Set("Content-Type", "application/json") 245 268 _ = json.NewEncoder(w).Encode(map[string]any{ 246 - "items": []map[string]any{ 247 - describeFile("ACTIVE.md", paths.contactsActive), 248 - describeFile("INACTIVE.md", paths.contactsInactive), 249 - }, 269 + "items": describeStateFiles(paths, "contacts"), 250 270 }) 251 271 }) 252 272 mux.HandleFunc("/contacts/files/", func(w http.ResponseWriter, r *http.Request) { ··· 256 276 } 257 277 paths := resolveRuntimeStatePaths() 258 278 name := strings.TrimSpace(strings.TrimPrefix(r.URL.Path, "/contacts/files/")) 259 - var filePath string 260 - switch strings.ToUpper(name) { 261 - case "ACTIVE.MD": 262 - filePath = paths.contactsActive 263 - case "INACTIVE.MD": 264 - filePath = paths.contactsInactive 265 - default: 279 + spec, ok := resolveStateFileSpec(paths, "contacts", name) 280 + if !ok { 266 281 http.Error(w, "invalid file name", http.StatusBadRequest) 267 282 return 268 283 } 269 - handleTextFileDetail(w, r, name, filePath) 284 + handleTextFileDetail(w, r, spec.Name, spec.Path) 270 285 }) 271 286 272 287 mux.HandleFunc("/persona/files", func(w http.ResponseWriter, r *http.Request) { ··· 281 296 paths := resolveRuntimeStatePaths() 282 297 w.Header().Set("Content-Type", "application/json") 283 298 _ = json.NewEncoder(w).Encode(map[string]any{ 284 - "items": []map[string]any{ 285 - describeFile("IDENTITY.md", paths.identityPath), 286 - describeFile("SOUL.md", paths.soulPath), 287 - }, 299 + "items": describeStateFiles(paths, "persona"), 288 300 }) 289 301 }) 290 302 mux.HandleFunc("/persona/files/", func(w http.ResponseWriter, r *http.Request) { ··· 294 306 } 295 307 paths := resolveRuntimeStatePaths() 296 308 name := strings.TrimSpace(strings.TrimPrefix(r.URL.Path, "/persona/files/")) 297 - var filePath string 298 - switch strings.ToUpper(name) { 299 - case "IDENTITY.MD": 300 - filePath = paths.identityPath 301 - case "SOUL.MD": 302 - filePath = paths.soulPath 303 - default: 309 + spec, ok := resolveStateFileSpec(paths, "persona", name) 310 + if !ok { 304 311 http.Error(w, "invalid file name", http.StatusBadRequest) 305 312 return 306 313 } 307 - handleTextFileDetail(w, r, name, filePath) 314 + handleTextFileDetail(w, r, spec.Name, spec.Path) 308 315 }) 309 316 310 317 mux.HandleFunc("/audit/files", func(w http.ResponseWriter, r *http.Request) { ··· 608 615 contactsInactive string 609 616 identityPath string 610 617 soulPath string 618 + heartbeatPath string 611 619 todoWIP string 612 620 todoDone string 613 621 auditPath string ··· 625 633 contactsInactive: filepath.Join(contactsDir, "INACTIVE.md"), 626 634 identityPath: filepath.Join(stateDir, "IDENTITY.md"), 627 635 soulPath: filepath.Join(stateDir, "SOUL.md"), 636 + heartbeatPath: statepaths.HeartbeatChecklistPath(), 628 637 todoWIP: statepaths.TODOWIPPath(), 629 638 todoDone: statepaths.TODODONEPath(), 630 639 auditPath: resolveGuardAuditPath(stateDir), ··· 647 656 "path": p, 648 657 "exists": err == nil, 649 658 } 659 + } 660 + 661 + type stateFileSpec struct { 662 + Name string 663 + Group string 664 + Path string 665 + } 666 + 667 + func runtimeStateFileSpecs(paths runtimeStatePaths) []stateFileSpec { 668 + return []stateFileSpec{ 669 + {Name: "TODO.md", Group: "todo", Path: paths.todoWIP}, 670 + {Name: "TODO.DONE.md", Group: "todo", Path: paths.todoDone}, 671 + {Name: "ACTIVE.md", Group: "contacts", Path: paths.contactsActive}, 672 + {Name: "INACTIVE.md", Group: "contacts", Path: paths.contactsInactive}, 673 + {Name: "IDENTITY.md", Group: "persona", Path: paths.identityPath}, 674 + {Name: "SOUL.md", Group: "persona", Path: paths.soulPath}, 675 + {Name: "HEARTBEAT.md", Group: "heartbeat", Path: paths.heartbeatPath}, 676 + } 677 + } 678 + 679 + func describeStateFiles(paths runtimeStatePaths, group string) []map[string]any { 680 + group = strings.ToLower(strings.TrimSpace(group)) 681 + specs := runtimeStateFileSpecs(paths) 682 + items := make([]map[string]any, 0, len(specs)) 683 + for _, spec := range specs { 684 + if group != "" && spec.Group != group { 685 + continue 686 + } 687 + item := describeFile(spec.Name, spec.Path) 688 + item["group"] = spec.Group 689 + items = append(items, item) 690 + } 691 + return items 692 + } 693 + 694 + func resolveStateFileSpec(paths runtimeStatePaths, group string, name string) (stateFileSpec, bool) { 695 + group = strings.ToLower(strings.TrimSpace(group)) 696 + name = strings.TrimSpace(name) 697 + if name == "" { 698 + return stateFileSpec{}, false 699 + } 700 + specs := runtimeStateFileSpecs(paths) 701 + for _, spec := range specs { 702 + if group != "" && spec.Group != group { 703 + continue 704 + } 705 + if strings.EqualFold(spec.Name, name) { 706 + return spec, true 707 + } 708 + } 709 + return stateFileSpec{}, false 650 710 } 651 711 652 712 func handleTextFileDetail(w http.ResponseWriter, r *http.Request, name, filePath string) {
+97
internal/daemonruntime/server_state_files_test.go
··· 1 + package daemonruntime 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + "net/http/httptest" 7 + "testing" 8 + 9 + "github.com/spf13/viper" 10 + ) 11 + 12 + func TestRuntimeStateFileSpecsIncludesHeartbeat(t *testing.T) { 13 + paths := runtimeStatePaths{ 14 + todoWIP: "/tmp/TODO.md", 15 + todoDone: "/tmp/TODO.DONE.md", 16 + contactsActive: "/tmp/ACTIVE.md", 17 + contactsInactive: "/tmp/INACTIVE.md", 18 + identityPath: "/tmp/IDENTITY.md", 19 + soulPath: "/tmp/SOUL.md", 20 + heartbeatPath: "/tmp/HEARTBEAT.md", 21 + } 22 + 23 + items := describeStateFiles(paths, "") 24 + if len(items) != 7 { 25 + t.Fatalf("len(items) = %d, want 7", len(items)) 26 + } 27 + 28 + foundHeartbeat := false 29 + for _, item := range items { 30 + if item["name"] == "HEARTBEAT.md" && item["group"] == "heartbeat" { 31 + foundHeartbeat = true 32 + break 33 + } 34 + } 35 + if !foundHeartbeat { 36 + t.Fatalf("HEARTBEAT.md should be present in state files: %#v", items) 37 + } 38 + } 39 + 40 + func TestResolveStateFileSpec(t *testing.T) { 41 + paths := runtimeStatePaths{ 42 + todoWIP: "/tmp/TODO.md", 43 + todoDone: "/tmp/TODO.DONE.md", 44 + contactsActive: "/tmp/ACTIVE.md", 45 + contactsInactive: "/tmp/INACTIVE.md", 46 + identityPath: "/tmp/IDENTITY.md", 47 + soulPath: "/tmp/SOUL.md", 48 + heartbeatPath: "/tmp/HEARTBEAT.md", 49 + } 50 + 51 + if spec, ok := resolveStateFileSpec(paths, "", "heartbeat.md"); !ok || spec.Group != "heartbeat" { 52 + t.Fatalf("resolve heartbeat failed: ok=%v spec=%#v", ok, spec) 53 + } 54 + if _, ok := resolveStateFileSpec(paths, "todo", "ACTIVE.md"); ok { 55 + t.Fatalf("resolve with wrong group should fail") 56 + } 57 + if spec, ok := resolveStateFileSpec(paths, "todo", "todo.md"); !ok || spec.Name != "TODO.md" { 58 + t.Fatalf("resolve todo failed: ok=%v spec=%#v", ok, spec) 59 + } 60 + } 61 + 62 + func TestStateFilesRoute(t *testing.T) { 63 + stateDir := t.TempDir() 64 + oldStateDir := viper.GetString("file_state_dir") 65 + oldContactsDir := viper.GetString("contacts.dir_name") 66 + t.Cleanup(func() { 67 + viper.Set("file_state_dir", oldStateDir) 68 + viper.Set("contacts.dir_name", oldContactsDir) 69 + }) 70 + viper.Set("file_state_dir", stateDir) 71 + viper.Set("contacts.dir_name", "contacts") 72 + 73 + mux := http.NewServeMux() 74 + RegisterRoutes(mux, RoutesOptions{ 75 + Mode: "serve", 76 + AuthToken: "token", 77 + }) 78 + 79 + req := httptest.NewRequest(http.MethodGet, "/state/files", nil) 80 + req.Header.Set("Authorization", "Bearer token") 81 + rec := httptest.NewRecorder() 82 + mux.ServeHTTP(rec, req) 83 + 84 + if rec.Code != http.StatusOK { 85 + t.Fatalf("expected status 200, got %d (%s)", rec.Code, rec.Body.String()) 86 + } 87 + 88 + var payload struct { 89 + Items []map[string]any `json:"items"` 90 + } 91 + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { 92 + t.Fatalf("invalid json: %v", err) 93 + } 94 + if len(payload.Items) != 7 { 95 + t.Fatalf("len(items) = %d, want 7", len(payload.Items)) 96 + } 97 + }
+7 -20
web/console/README.md
··· 28 28 - auto-refresh every 60 seconds 29 29 - Tasks: 30 30 - list + detail (read-only) 31 - - TODO files: 32 - - edit `TODO.md` and `TODO.DONE.md` 33 - - Contacts files: 34 - - edit `ACTIVE.md` and `INACTIVE.md` 35 - - Persona files: 36 - - edit `IDENTITY.md` and `SOUL.md` 31 + - Files: 32 + - unified editor for `TODO.md`, `TODO.DONE.md`, `ACTIVE.md`, `INACTIVE.md`, `IDENTITY.md`, `SOUL.md`, `HEARTBEAT.md` 37 33 - Audit: 38 34 - browse Guard audit files 39 35 - windowed reads for large JSONL logs (`max_bytes` + `before`) 40 36 - newest entries shown first in the UI 37 + - entries grouped by `run_id` for easier review 41 38 - Settings: 42 39 - config snapshot + diagnostics 43 40 - language selector ··· 70 67 - `GET /tasks` 71 68 - `GET /tasks/{id}` 72 69 73 - TODO files: 74 - - `GET /todo/files` 75 - - `GET /todo/files/{name}` (`TODO.md|TODO.DONE.md`) 76 - - `PUT /todo/files/{name}` 77 - 78 - Contacts files: 79 - - `GET /contacts/files` 80 - - `GET /contacts/files/{name}` (`ACTIVE.md|INACTIVE.md`) 81 - - `PUT /contacts/files/{name}` 82 - 83 - Persona files: 84 - - `GET /persona/files` 85 - - `GET /persona/files/{name}` (`IDENTITY.md|SOUL.md`) 86 - - `PUT /persona/files/{name}` 70 + Files: 71 + - `GET /state/files` 72 + - `GET /state/files/{name}` (`TODO.md|TODO.DONE.md|ACTIVE.md|INACTIVE.md|IDENTITY.md|SOUL.md|HEARTBEAT.md`) 73 + - `PUT /state/files/{name}` 87 74 88 75 Audit: 89 76 - `GET /audit/files`
+24
web/console/src/i18n/index.js
··· 67 67 audit_action: "Action", 68 68 audit_tool: "Tool", 69 69 audit_run: "Run", 70 + audit_group_count: "Items", 70 71 audit_step: "Step", 71 72 audit_actor: "Actor", 72 73 audit_approval: "Approval", ··· 85 86 contacts_title: "Contacts", 86 87 todo_title: "TODO", 87 88 persona_title: "Persona", 89 + files_title: "Files", 90 + files_group_todo: "TODO", 91 + files_group_contacts: "Contacts", 92 + files_group_persona: "Persona", 93 + files_group_heartbeat: "Heartbeat", 94 + files_group_other: "Other", 88 95 settings_title: "System", 89 96 placeholder_select_file: "Select file", 90 97 placeholder_audit_file: "Audit file", ··· 118 125 nav_todo: "TODO", 119 126 nav_contacts: "Contacts", 120 127 nav_persona: "Persona", 128 + nav_files: "Files", 121 129 nav_settings: "Settings", 122 130 drawer_nav: "Navigation", 123 131 topbar_ttl: "TTL {value}", ··· 188 196 audit_action: "动作", 189 197 audit_tool: "工具", 190 198 audit_run: "Run", 199 + audit_group_count: "条目", 191 200 audit_step: "步骤", 192 201 audit_actor: "操作者", 193 202 audit_approval: "审批", ··· 206 215 contacts_title: "联系人", 207 216 todo_title: "待办", 208 217 persona_title: "人格", 218 + files_title: "文件管理", 219 + files_group_todo: "待办", 220 + files_group_contacts: "联系人", 221 + files_group_persona: "人格", 222 + files_group_heartbeat: "心跳", 223 + files_group_other: "其他", 209 224 settings_title: "系统", 210 225 placeholder_select_file: "选择文件", 211 226 placeholder_audit_file: "选择审计文件", ··· 239 254 nav_todo: "待办", 240 255 nav_contacts: "联系人", 241 256 nav_persona: "人格", 257 + nav_files: "文件管理", 242 258 nav_settings: "配置", 243 259 drawer_nav: "导航", 244 260 topbar_ttl: "会话 {value}", ··· 309 325 audit_action: "アクション", 310 326 audit_tool: "ツール", 311 327 audit_run: "Run", 328 + audit_group_count: "件数", 312 329 audit_step: "ステップ", 313 330 audit_actor: "実行者", 314 331 audit_approval: "承認", ··· 327 344 contacts_title: "連絡先", 328 345 todo_title: "TODO", 329 346 persona_title: "ペルソナ", 347 + files_title: "ファイル", 348 + files_group_todo: "TODO", 349 + files_group_contacts: "連絡先", 350 + files_group_persona: "ペルソナ", 351 + files_group_heartbeat: "Heartbeat", 352 + files_group_other: "その他", 330 353 settings_title: "システム", 331 354 placeholder_select_file: "ファイルを選択", 332 355 placeholder_audit_file: "監査ファイルを選択", ··· 360 383 nav_todo: "TODO", 361 384 nav_contacts: "連絡先", 362 385 nav_persona: "ペルソナ", 386 + nav_files: "ファイル", 363 387 nav_settings: "設定", 364 388 drawer_nav: "ナビゲーション", 365 389 topbar_ttl: "TTL {value}",
+3 -9
web/console/src/router/index.js
··· 3 3 import { BASE_PATH, apiFetch, authState, authValid, clearAuth, saveAuth } from "../core/context"; 4 4 import { 5 5 AuditView, 6 - ContactsFilesView, 7 6 DashboardView, 8 7 LoginView, 9 8 OverviewView, 10 - PersonaFilesView, 11 9 SettingsView, 10 + StateFilesView, 12 11 TasksView, 13 12 TaskDetailView, 14 - TODOFilesView, 15 13 } from "../views"; 16 14 17 15 const routes = [ ··· 21 19 { path: "/tasks", component: TasksView }, 22 20 { path: "/tasks/:id", component: TaskDetailView }, 23 21 { path: "/audit", component: AuditView }, 24 - { path: "/todo-files", component: TODOFilesView }, 25 - { path: "/contacts-files", component: ContactsFilesView }, 26 - { path: "/persona-files", component: PersonaFilesView }, 22 + { path: "/files", component: StateFilesView }, 27 23 { path: "/settings", component: SettingsView }, 28 24 { path: "/", redirect: "/overview" }, 29 25 ]; ··· 37 33 { id: "/dashboard", titleKey: "nav_runtime", icon: "QIconSpeedoMeter" }, 38 34 { id: "/tasks", titleKey: "nav_tasks", icon: "QIconInbox" }, 39 35 { id: "/audit", titleKey: "nav_audit", icon: "QIconFingerprint" }, 40 - { id: "/todo-files", titleKey: "nav_todo", icon: "QIconBookOpen" }, 41 - { id: "/contacts-files", titleKey: "nav_contacts", icon: "QIconUsers" }, 42 - { id: "/persona-files", titleKey: "nav_persona", icon: "QIconUserCircle" }, 36 + { id: "/files", titleKey: "nav_files", icon: "QIconBookOpen" }, 43 37 { id: "/settings", titleKey: "nav_settings", icon: "QIconSettings" }, 44 38 ]; 45 39
+17
web/console/src/views/AuditView.css
··· 14 14 gap: 6px; 15 15 } 16 16 17 + .audit-group { 18 + display: grid; 19 + gap: 6px; 20 + } 21 + 22 + .audit-group-head { 23 + display: flex; 24 + flex-wrap: wrap; 25 + gap: 8px; 26 + margin: 2px 0; 27 + } 28 + 29 + .audit-group-head code { 30 + font-size: 11px; 31 + color: var(--text-2); 32 + } 33 + 17 34 .audit-row { 18 35 display: grid; 19 36 gap: 6px;
+48 -27
web/console/src/views/AuditView.js
··· 33 33 .map((line, idx) => parseAuditLine(line, idx)) 34 34 .reverse(); 35 35 }); 36 + const auditGroups = computed(() => { 37 + const groups = []; 38 + const byRunID = new Map(); 39 + for (const item of auditItems.value) { 40 + const runID = item.parsed ? item.runID : "-"; 41 + const groupKey = `run:${runID}`; 42 + let group = byRunID.get(groupKey); 43 + if (!group) { 44 + group = { key: groupKey, runID, items: [] }; 45 + byRunID.set(groupKey, group); 46 + groups.push(group); 47 + } 48 + group.items.push(item); 49 + } 50 + return groups; 51 + }); 36 52 37 53 function normalizeAuditText(value, fallback = "-") { 38 54 if (typeof value === "string") { ··· 284 300 err, 285 301 fileItems, 286 302 selectedFileItem, 287 - auditItems, 303 + auditGroups, 288 304 meta, 289 305 canGoNewer, 290 306 refreshLatest, ··· 318 334 <code>{{ t("audit_range") }}: {{ meta.from }} - {{ meta.to }}</code> 319 335 </div> 320 336 <div class="audit-list"> 321 - <div v-for="item in auditItems" :key="item.key" class="audit-row"> 322 - <template v-if="item.parsed"> 323 - <div class="audit-item-head"> 324 - <code class="audit-item-id">{{ item.eventID }}</code> 325 - <code class="audit-item-time">{{ t("audit_time") }}: {{ item.tsText }}</code> 326 - <QBadge :type="item.decisionType" size="sm" variant="filled">{{ item.decisionLabel }}</QBadge> 327 - <QBadge :type="item.riskType" size="sm" variant="filled">{{ item.riskLabel }}</QBadge> 328 - </div> 329 - <div class="audit-item-meta"> 330 - <code>{{ t("audit_action") }}: {{ item.actionType }}</code> 331 - <code>{{ t("audit_tool") }}: {{ item.toolName }}</code> 332 - <code>{{ t("audit_run") }}: {{ item.runID }}</code> 333 - <code>{{ t("audit_step") }}: {{ item.stepText }}</code> 334 - <code v-if="item.approvalStatus !== '-'">{{ t("audit_approval") }}: {{ item.approvalStatus }}</code> 335 - <code v-if="item.actor !== '-'">{{ t("audit_actor") }}: {{ item.actor }}</code> 336 - </div> 337 - <code v-if="item.summary !== '-'" class="audit-summary">{{ t("audit_summary") }}: {{ item.summary }}</code> 338 - <code v-if="item.reasonsText !== '-'" class="audit-summary">{{ t("audit_reasons") }}: {{ item.reasonsText }}</code> 339 - </template> 340 - <template v-else> 341 - <div class="audit-item-head"> 342 - <QBadge type="default" size="sm" variant="filled">{{ t("audit_raw") }}</QBadge> 343 - </div> 344 - <code class="audit-line">{{ item.raw }}</code> 345 - </template> 337 + <div v-for="group in auditGroups" :key="group.key" class="audit-group"> 338 + <div class="audit-group-head"> 339 + <code>{{ t("audit_run") }}: {{ group.runID }}</code> 340 + <code>{{ t("audit_group_count") }}: {{ group.items.length }}</code> 341 + </div> 342 + <div v-for="item in group.items" :key="item.key" class="audit-row"> 343 + <template v-if="item.parsed"> 344 + <div class="audit-item-head"> 345 + <code class="audit-item-id">{{ item.eventID }}</code> 346 + <code class="audit-item-time">{{ t("audit_time") }}: {{ item.tsText }}</code> 347 + <QBadge :type="item.decisionType" size="sm" variant="filled">{{ item.decisionLabel }}</QBadge> 348 + <QBadge :type="item.riskType" size="sm" variant="filled">{{ item.riskLabel }}</QBadge> 349 + </div> 350 + <div class="audit-item-meta"> 351 + <code>{{ t("audit_action") }}: {{ item.actionType }}</code> 352 + <code>{{ t("audit_tool") }}: {{ item.toolName }}</code> 353 + <code>{{ t("audit_step") }}: {{ item.stepText }}</code> 354 + <code v-if="item.approvalStatus !== '-'">{{ t("audit_approval") }}: {{ item.approvalStatus }}</code> 355 + <code v-if="item.actor !== '-'">{{ t("audit_actor") }}: {{ item.actor }}</code> 356 + </div> 357 + <code v-if="item.summary !== '-'" class="audit-summary">{{ t("audit_summary") }}: {{ item.summary }}</code> 358 + <code v-if="item.reasonsText !== '-'" class="audit-summary">{{ t("audit_reasons") }}: {{ item.reasonsText }}</code> 359 + </template> 360 + <template v-else> 361 + <div class="audit-item-head"> 362 + <QBadge type="default" size="sm" variant="filled">{{ t("audit_raw") }}</QBadge> 363 + </div> 364 + <code class="audit-line">{{ item.raw }}</code> 365 + </template> 366 + </div> 346 367 </div> 347 - <p v-if="!loading && auditItems.length === 0" class="muted">{{ meta.exists ? t("audit_empty") : t("audit_no_file") }}</p> 368 + <p v-if="!loading && auditGroups.length === 0" class="muted">{{ meta.exists ? t("audit_empty") : t("audit_no_file") }}</p> 348 369 </div> 349 370 </section> 350 371 `,
-104
web/console/src/views/ContactsFilesView.js
··· 1 - import { onMounted, ref } from "vue"; 2 - 3 - import { runtimeApiFetch, translate } from "../core/context"; 4 - 5 - const ContactsFilesView = { 6 - setup() { 7 - const t = translate; 8 - const loading = ref(false); 9 - const saving = ref(false); 10 - const err = ref(""); 11 - const ok = ref(""); 12 - const fileItems = ref([ 13 - { title: "ACTIVE.md", name: "ACTIVE.md" }, 14 - { title: "INACTIVE.md", name: "INACTIVE.md" }, 15 - ]); 16 - const selectedFile = ref(fileItems.value[0]); 17 - const content = ref(""); 18 - 19 - async function loadFiles() { 20 - const data = await runtimeApiFetch("/contacts/files"); 21 - const items = Array.isArray(data.items) ? data.items : []; 22 - if (items.length === 0) { 23 - return; 24 - } 25 - fileItems.value = items.map((it) => ({ 26 - title: it.name || "", 27 - name: it.name || "", 28 - })); 29 - if (!fileItems.value.find((x) => x.name === selectedFile.value?.name)) { 30 - selectedFile.value = fileItems.value[0]; 31 - } 32 - } 33 - 34 - async function loadContent(name) { 35 - loading.value = true; 36 - err.value = ""; 37 - ok.value = ""; 38 - try { 39 - const data = await runtimeApiFetch(`/contacts/files/${encodeURIComponent(name)}`); 40 - content.value = data.content || ""; 41 - } catch (e) { 42 - err.value = e.message || t("msg_read_failed"); 43 - } finally { 44 - loading.value = false; 45 - } 46 - } 47 - 48 - async function save() { 49 - saving.value = true; 50 - err.value = ""; 51 - ok.value = ""; 52 - try { 53 - await runtimeApiFetch(`/contacts/files/${encodeURIComponent(selectedFile.value.name)}`, { 54 - method: "PUT", 55 - body: { content: content.value }, 56 - }); 57 - ok.value = t("msg_save_success"); 58 - } catch (e) { 59 - err.value = e.message || t("msg_save_failed"); 60 - } finally { 61 - saving.value = false; 62 - } 63 - } 64 - 65 - async function onFileChange(item) { 66 - if (!item || typeof item !== "object" || !item.name) { 67 - return; 68 - } 69 - selectedFile.value = item; 70 - await loadContent(item.name); 71 - } 72 - 73 - async function init() { 74 - await loadFiles(); 75 - await loadContent(selectedFile.value.name); 76 - } 77 - 78 - onMounted(init); 79 - return { t, loading, saving, err, ok, fileItems, selectedFile, content, onFileChange, save }; 80 - }, 81 - template: ` 82 - <section> 83 - <h2 class="title">{{ t("contacts_title") }}</h2> 84 - <div class="toolbar wrap"> 85 - <div class="tool-item"> 86 - <QDropdownMenu 87 - :items="fileItems" 88 - :initialItem="selectedFile" 89 - :placeholder="t('placeholder_select_file')" 90 - @change="onFileChange" 91 - /> 92 - </div> 93 - <QButton class="primary" :loading="saving" @click="save">{{ t("action_save") }}</QButton> 94 - </div> 95 - <QProgress v-if="loading" :infinite="true" /> 96 - <QFence v-if="err" type="danger" icon="QIconCloseCircle" :text="err" /> 97 - <QFence v-if="ok" type="success" icon="QIconCheckCircle" :text="ok" /> 98 - <QTextarea v-model="content" :rows="22" /> 99 - </section> 100 - `, 101 - }; 102 - 103 - 104 - export default ContactsFilesView;
-109
web/console/src/views/PersonaFilesView.js
··· 1 - import { onMounted, ref } from "vue"; 2 - 3 - import { runtimeApiFetch, translate } from "../core/context"; 4 - 5 - const PersonaFilesView = { 6 - setup() { 7 - const t = translate; 8 - const loading = ref(false); 9 - const saving = ref(false); 10 - const err = ref(""); 11 - const ok = ref(""); 12 - const fileItems = ref([ 13 - { title: "IDENTITY.md", name: "IDENTITY.md" }, 14 - { title: "SOUL.md", name: "SOUL.md" }, 15 - ]); 16 - const selectedFile = ref(fileItems.value[0]); 17 - const content = ref(""); 18 - 19 - async function loadFiles() { 20 - const data = await runtimeApiFetch("/persona/files"); 21 - const items = Array.isArray(data.items) ? data.items : []; 22 - if (items.length === 0) { 23 - return; 24 - } 25 - fileItems.value = items.map((it) => ({ 26 - title: it.name || "", 27 - name: it.name || "", 28 - })); 29 - if (!fileItems.value.find((x) => x.name === selectedFile.value?.name)) { 30 - selectedFile.value = fileItems.value[0]; 31 - } 32 - } 33 - 34 - async function loadContent(name) { 35 - loading.value = true; 36 - err.value = ""; 37 - ok.value = ""; 38 - try { 39 - const data = await runtimeApiFetch(`/persona/files/${encodeURIComponent(name)}`); 40 - content.value = data.content || ""; 41 - } catch (e) { 42 - if (e && e.status === 404) { 43 - content.value = ""; 44 - ok.value = t("msg_file_missing_create"); 45 - return; 46 - } 47 - err.value = e.message || t("msg_read_failed"); 48 - } finally { 49 - loading.value = false; 50 - } 51 - } 52 - 53 - async function save() { 54 - saving.value = true; 55 - err.value = ""; 56 - ok.value = ""; 57 - try { 58 - await runtimeApiFetch(`/persona/files/${encodeURIComponent(selectedFile.value.name)}`, { 59 - method: "PUT", 60 - body: { content: content.value }, 61 - }); 62 - ok.value = t("msg_save_success"); 63 - } catch (e) { 64 - err.value = e.message || t("msg_save_failed"); 65 - } finally { 66 - saving.value = false; 67 - } 68 - } 69 - 70 - async function onFileChange(item) { 71 - if (!item || typeof item !== "object" || !item.name) { 72 - return; 73 - } 74 - selectedFile.value = item; 75 - await loadContent(item.name); 76 - } 77 - 78 - async function init() { 79 - await loadFiles(); 80 - await loadContent(selectedFile.value.name); 81 - } 82 - 83 - onMounted(init); 84 - return { t, loading, saving, err, ok, fileItems, selectedFile, content, onFileChange, save }; 85 - }, 86 - template: ` 87 - <section> 88 - <h2 class="title">{{ t("persona_title") }}</h2> 89 - <div class="toolbar wrap"> 90 - <div class="tool-item"> 91 - <QDropdownMenu 92 - :items="fileItems" 93 - :initialItem="selectedFile" 94 - :placeholder="t('placeholder_select_file')" 95 - @change="onFileChange" 96 - /> 97 - </div> 98 - <QButton class="primary" :loading="saving" @click="save">{{ t("action_save") }}</QButton> 99 - </div> 100 - <QProgress v-if="loading" :infinite="true" /> 101 - <QFence v-if="err" type="danger" icon="QIconCloseCircle" :text="err" /> 102 - <QFence v-if="ok" type="success" icon="QIconCheckCircle" :text="ok" /> 103 - <QTextarea v-model="content" :rows="22" /> 104 - </section> 105 - `, 106 - }; 107 - 108 - 109 - export default PersonaFilesView;
+54 -16
web/console/src/views/TODOFilesView.js web/console/src/views/StateFilesView.js
··· 2 2 3 3 import { runtimeApiFetch, translate } from "../core/context"; 4 4 5 - const TODOFilesView = { 5 + const DEFAULT_FILES = [ 6 + { name: "TODO.md", group: "todo" }, 7 + { name: "TODO.DONE.md", group: "todo" }, 8 + { name: "ACTIVE.md", group: "contacts" }, 9 + { name: "INACTIVE.md", group: "contacts" }, 10 + { name: "IDENTITY.md", group: "persona" }, 11 + { name: "SOUL.md", group: "persona" }, 12 + { name: "HEARTBEAT.md", group: "heartbeat" }, 13 + ]; 14 + 15 + function normalizeGroup(value) { 16 + return String(value || "").trim().toLowerCase(); 17 + } 18 + 19 + function groupTitle(t, group) { 20 + switch (normalizeGroup(group)) { 21 + case "todo": 22 + return t("files_group_todo"); 23 + case "contacts": 24 + return t("files_group_contacts"); 25 + case "persona": 26 + return t("files_group_persona"); 27 + case "heartbeat": 28 + return t("files_group_heartbeat"); 29 + default: 30 + return t("files_group_other"); 31 + } 32 + } 33 + 34 + function toFileItem(t, item) { 35 + const name = String(item?.name || "").trim(); 36 + const group = normalizeGroup(item?.group); 37 + return { 38 + title: `${groupTitle(t, group)} / ${name}`, 39 + name, 40 + group, 41 + }; 42 + } 43 + 44 + const StateFilesView = { 6 45 setup() { 7 46 const t = translate; 8 47 const loading = ref(false); 9 48 const saving = ref(false); 10 49 const err = ref(""); 11 50 const ok = ref(""); 12 - const fileItems = ref([ 13 - { title: "TODO.md", name: "TODO.md" }, 14 - { title: "TODO.DONE.md", name: "TODO.DONE.md" }, 15 - ]); 51 + 52 + const fileItems = ref(DEFAULT_FILES.map((item) => toFileItem(t, item))); 16 53 const selectedFile = ref(fileItems.value[0]); 17 54 const content = ref(""); 18 55 19 56 async function loadFiles() { 20 - const data = await runtimeApiFetch("/todo/files"); 57 + const data = await runtimeApiFetch("/state/files"); 21 58 const items = Array.isArray(data.items) ? data.items : []; 22 59 if (items.length === 0) { 23 60 return; 24 61 } 25 - fileItems.value = items.map((it) => ({ 26 - title: it.name || "", 27 - name: it.name || "", 28 - })); 29 - if (!fileItems.value.find((x) => x.name === selectedFile.value?.name)) { 62 + fileItems.value = items 63 + .map((item) => toFileItem(t, item)) 64 + .filter((item) => item.name !== ""); 65 + if (fileItems.value.length === 0) { 66 + return; 67 + } 68 + if (!fileItems.value.find((item) => item.name === selectedFile.value?.name)) { 30 69 selectedFile.value = fileItems.value[0]; 31 70 } 32 71 } ··· 36 75 err.value = ""; 37 76 ok.value = ""; 38 77 try { 39 - const data = await runtimeApiFetch(`/todo/files/${encodeURIComponent(name)}`); 78 + const data = await runtimeApiFetch(`/state/files/${encodeURIComponent(name)}`); 40 79 content.value = data.content || ""; 41 80 } catch (e) { 42 81 if (e && e.status === 404) { ··· 55 94 err.value = ""; 56 95 ok.value = ""; 57 96 try { 58 - await runtimeApiFetch(`/todo/files/${encodeURIComponent(selectedFile.value.name)}`, { 97 + await runtimeApiFetch(`/state/files/${encodeURIComponent(selectedFile.value.name)}`, { 59 98 method: "PUT", 60 99 body: { content: content.value }, 61 100 }); ··· 85 124 }, 86 125 template: ` 87 126 <section> 88 - <h2 class="title">{{ t("todo_title") }}</h2> 127 + <h2 class="title">{{ t("files_title") }}</h2> 89 128 <div class="toolbar wrap"> 90 129 <div class="tool-item"> 91 130 <QDropdownMenu ··· 105 144 `, 106 145 }; 107 146 108 - 109 - export default TODOFilesView; 147 + export default StateFilesView;
+1 -3
web/console/src/views/index.js
··· 6 6 export { default as TasksView } from "./TasksView"; 7 7 export { default as TaskDetailView } from "./TaskDetailView"; 8 8 export { default as AuditView } from "./AuditView"; 9 - export { default as ContactsFilesView } from "./ContactsFilesView"; 10 - export { default as TODOFilesView } from "./TODOFilesView"; 11 - export { default as PersonaFilesView } from "./PersonaFilesView"; 9 + export { default as StateFilesView } from "./StateFilesView"; 12 10 export { default as SettingsView } from "./SettingsView";