bridgeflow 0.1.0__py3-none-any.whl

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.
bridgeflow/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ __all__ = ["__version__"]
2
+
3
+ __version__ = "0.1.0"
bridgeflow/binding.py ADDED
@@ -0,0 +1,125 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timedelta
4
+ import secrets
5
+ import string
6
+
7
+ from bridgeflow.config import PatrolConfig
8
+
9
+
10
+ BIND_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
11
+
12
+
13
+ def _now() -> datetime:
14
+ return datetime.now()
15
+
16
+
17
+ def _fmt(dt: datetime) -> str:
18
+ return dt.strftime("%Y-%m-%d %H:%M:%S")
19
+
20
+
21
+ def _parse(value: str) -> datetime | None:
22
+ if not value:
23
+ return None
24
+ try:
25
+ return datetime.strptime(value, "%Y-%m-%d %H:%M:%S")
26
+ except ValueError:
27
+ return None
28
+
29
+
30
+ def _new_code(length: int = 6) -> str:
31
+ return "".join(secrets.choice(BIND_ALPHABET) for _ in range(length))
32
+
33
+
34
+ def has_active_bind_code(config: PatrolConfig) -> bool:
35
+ if not config.pending_bind_code or not config.pending_bind_expires_at:
36
+ return False
37
+ expires_at = _parse(config.pending_bind_expires_at)
38
+ return expires_at is not None and expires_at >= _now()
39
+
40
+
41
+ def public_bind_state(config: PatrolConfig) -> dict:
42
+ return {
43
+ "status": config.bind_status,
44
+ "machine_code": config.machine_code,
45
+ "bound_mobile_device_id": config.bound_mobile_device_id,
46
+ "bound_mobile_device_name": config.bound_mobile_device_name,
47
+ "bound_at": config.bound_at,
48
+ "has_pending_code": has_active_bind_code(config),
49
+ "pending_bind_expires_at": config.pending_bind_expires_at if has_active_bind_code(config) else "",
50
+ "pending_mobile_device_id": config.pending_mobile_device_id,
51
+ "pending_mobile_device_name": config.pending_mobile_device_name,
52
+ "bind_code_ttl_seconds": config.bind_code_ttl_seconds,
53
+ }
54
+
55
+
56
+ def private_bind_state(config: PatrolConfig) -> dict:
57
+ data = public_bind_state(config)
58
+ data["pending_bind_code"] = config.pending_bind_code if has_active_bind_code(config) else ""
59
+ data["last_bind_code_issued_at"] = config.last_bind_code_issued_at
60
+ return data
61
+
62
+
63
+ def issue_bind_code(
64
+ config: PatrolConfig,
65
+ *,
66
+ mobile_device_id: str = "",
67
+ mobile_device_name: str = "",
68
+ ) -> dict:
69
+ now = _now()
70
+ code = _new_code()
71
+ expires_at = now + timedelta(seconds=config.bind_code_ttl_seconds)
72
+ config.update_bind(
73
+ status="pending",
74
+ pending_bind_code=code,
75
+ pending_bind_expires_at=_fmt(expires_at),
76
+ pending_mobile_device_id=mobile_device_id.strip(),
77
+ pending_mobile_device_name=mobile_device_name.strip(),
78
+ last_bind_code_issued_at=_fmt(now),
79
+ )
80
+ return private_bind_state(config)
81
+
82
+
83
+ def approve_bind(
84
+ config: PatrolConfig,
85
+ *,
86
+ bind_code: str,
87
+ mobile_device_id: str,
88
+ mobile_device_name: str,
89
+ ) -> dict:
90
+ bind_code = bind_code.strip().upper()
91
+ if not bind_code:
92
+ raise ValueError("绑定码不能为空")
93
+ if not mobile_device_id.strip():
94
+ raise ValueError("mobile_device_id 不能为空")
95
+ if not has_active_bind_code(config):
96
+ raise ValueError("当前没有可用的绑定码")
97
+ if bind_code != config.pending_bind_code.strip().upper():
98
+ raise ValueError("绑定码不正确")
99
+
100
+ now = _now()
101
+ config.update_bind(
102
+ status="bound",
103
+ bound_mobile_device_id=mobile_device_id.strip(),
104
+ bound_mobile_device_name=mobile_device_name.strip(),
105
+ bound_at=_fmt(now),
106
+ pending_bind_code="",
107
+ pending_bind_expires_at="",
108
+ pending_mobile_device_id="",
109
+ pending_mobile_device_name="",
110
+ )
111
+ return public_bind_state(config)
112
+
113
+
114
+ def clear_binding(config: PatrolConfig) -> dict:
115
+ config.update_bind(
116
+ status="unbound",
117
+ bound_mobile_device_id="",
118
+ bound_mobile_device_name="",
119
+ bound_at="",
120
+ pending_bind_code="",
121
+ pending_bind_expires_at="",
122
+ pending_mobile_device_id="",
123
+ pending_mobile_device_name="",
124
+ )
125
+ return public_bind_state(config)
bridgeflow/cli.py ADDED
@@ -0,0 +1,304 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import asyncio
5
+ import hashlib
6
+ import json
7
+ import re
8
+ import socket
9
+ import sys
10
+ import uuid
11
+ from pathlib import Path
12
+
13
+ from bridgeflow.binding import approve_bind, clear_binding, issue_bind_code, private_bind_state, public_bind_state
14
+ from bridgeflow.config import DEFAULT_CONFIG_NAME, DEFAULT_FIXED_ROLES, load_config
15
+ from bridgeflow.desktop.executor import execute_desktop_action
16
+ from bridgeflow.desktop.patrol import patrol_once, print_doctor_result, run_doctor
17
+ from bridgeflow.desktop.runner import start_desktop_bridge
18
+ from bridgeflow.task_writer import write_admin_task, write_role_reply
19
+
20
+
21
+ def _default_project_root() -> Path:
22
+ return Path.cwd()
23
+
24
+
25
+ def _slug(value: str) -> str:
26
+ clean = re.sub(r"[^A-Za-z0-9_-]+", "-", value.strip())
27
+ clean = clean.strip("-").lower()
28
+ return clean or "bridgeflow-pc"
29
+
30
+
31
+ def _build_machine_code(hostname: str) -> str:
32
+ seed = f"{hostname}-{uuid.getnode()}".encode("utf-8", errors="ignore")
33
+ digest = hashlib.sha1(seed).hexdigest()[:12].upper()
34
+ return f"BF-{digest}"
35
+
36
+
37
+ def _print_bind_state(state: dict) -> None:
38
+ print(f"绑定状态:{state.get('status', '')}")
39
+ print(f"机器码:{state.get('machine_code', '')}")
40
+ if state.get("pending_bind_code"):
41
+ print(f"一次性绑定码:{state['pending_bind_code']}")
42
+ if state.get("pending_bind_expires_at"):
43
+ print(f"绑定码过期时间:{state['pending_bind_expires_at']}")
44
+ if state.get("pending_mobile_device_id") or state.get("pending_mobile_device_name"):
45
+ print(f"待确认手机:{state.get('pending_mobile_device_name', '')} {state.get('pending_mobile_device_id', '')}".strip())
46
+ if state.get("bound_mobile_device_id") or state.get("bound_mobile_device_name"):
47
+ print(f"已绑定手机:{state.get('bound_mobile_device_name', '')} {state.get('bound_mobile_device_id', '')}".strip())
48
+ if state.get("bound_at"):
49
+ print(f"绑定时间:{state['bound_at']}")
50
+
51
+
52
+ def _print_action_result(result: dict) -> None:
53
+ print(f"动作:{result.get('action', '')}")
54
+ print(f"结果:{'成功' if result.get('ok') else '失败'}")
55
+ print(f"说明:{result.get('message', '')}")
56
+ print(f"窗口数:{result.get('window_count', 0)}")
57
+ print(f"目标窗口:{result.get('target_title', '')}")
58
+ if result.get("typed_text"):
59
+ print(f"发送文本:{result['typed_text']}")
60
+ print(f"dry-run:{result.get('dry_run', False)}")
61
+
62
+
63
+ def cmd_init(args: argparse.Namespace) -> int:
64
+ project_root = Path(args.project_root or _default_project_root()).resolve()
65
+ project_root.mkdir(parents=True, exist_ok=True)
66
+ target = project_root / DEFAULT_CONFIG_NAME
67
+ example = Path(__file__).resolve().parents[2] / "examples" / DEFAULT_CONFIG_NAME
68
+ if target.exists() and not args.force:
69
+ print(f"配置已存在:{target}")
70
+ return 0
71
+
72
+ hostname = socket.gethostname() or "BridgeFlow-PC"
73
+ raw = json.loads(example.read_text(encoding="utf-8-sig"))
74
+ raw["project"]["root_dir"] = str(project_root)
75
+ raw.setdefault("device", {})
76
+ raw["device"]["device_id"] = f"{_slug(hostname)}-pc"
77
+ raw["device"]["device_name"] = f"{hostname} AI执行机"
78
+ raw["device"].setdefault("owner_role", "PM01")
79
+ raw["device"]["machine_code"] = _build_machine_code(hostname)
80
+
81
+ raw.setdefault("ai_team", {})
82
+ raw["ai_team"]["fixed_roles"] = DEFAULT_FIXED_ROLES[:]
83
+ raw["ai_team"]["cursor_only"] = True
84
+
85
+ raw.setdefault("bind", {})
86
+ raw["bind"].setdefault("status", "unbound")
87
+ raw["bind"].setdefault("bound_mobile_device_id", "")
88
+ raw["bind"].setdefault("bound_mobile_device_name", "")
89
+ raw["bind"].setdefault("bound_at", "")
90
+ raw["bind"].setdefault("bind_code_ttl_seconds", 600)
91
+ raw["bind"].setdefault("pending_bind_code", "")
92
+ raw["bind"].setdefault("pending_bind_expires_at", "")
93
+ raw["bind"].setdefault("pending_mobile_device_id", "")
94
+ raw["bind"].setdefault("pending_mobile_device_name", "")
95
+ raw["bind"].setdefault("last_bind_code_issued_at", "")
96
+
97
+ raw.setdefault("runtime", {})
98
+ raw["runtime"].setdefault("runtime_dir", ".bridgeflow/runtime")
99
+ raw["runtime"].setdefault("status_dir", ".bridgeflow/runtime/status")
100
+ raw["runtime"].setdefault("task_details_dir", ".bridgeflow/runtime/task_details")
101
+ raw["runtime"].setdefault("idle_seconds", 180)
102
+ raw["runtime"].setdefault("stale_task_seconds", 900)
103
+
104
+ target.write_text(json.dumps(raw, ensure_ascii=False, indent=2), encoding="utf-8")
105
+ config = load_config(target)
106
+ config.ensure_runtime_dirs()
107
+ print(f"已生成配置:{target}")
108
+ print(f"设备ID:{config.device_id}")
109
+ print(f"机器码:{config.machine_code}")
110
+ print(f"运行态目录:{config.runtime_dir}")
111
+ return 0
112
+
113
+
114
+ def cmd_write_admin_task(args: argparse.Namespace) -> int:
115
+ config = load_config(args.config)
116
+ written = write_admin_task(
117
+ config,
118
+ args.text,
119
+ recipient=args.recipient,
120
+ priority=args.priority,
121
+ attachments=args.attachments or [],
122
+ thread_key=args.thread_key,
123
+ )
124
+ print(f"已写入:{written.path}")
125
+ print(f"线程:{written.thread_key}")
126
+ return 0
127
+
128
+
129
+ def cmd_write_reply(args: argparse.Namespace) -> int:
130
+ config = load_config(args.config)
131
+ written = write_role_reply(
132
+ config,
133
+ args.text,
134
+ sender=args.sender,
135
+ recipient=args.recipient,
136
+ priority=args.priority,
137
+ attachments=args.attachments or [],
138
+ thread_key=args.thread_key,
139
+ )
140
+ print(f"已写入:{written.path}")
141
+ print(f"线程:{written.thread_key}")
142
+ return 0
143
+
144
+
145
+ def cmd_bind_status(args: argparse.Namespace) -> int:
146
+ config = load_config(args.config)
147
+ _print_bind_state(private_bind_state(config))
148
+ return 0
149
+
150
+
151
+ def cmd_bind_code(args: argparse.Namespace) -> int:
152
+ config = load_config(args.config)
153
+ state = issue_bind_code(
154
+ config,
155
+ mobile_device_id=args.mobile_device_id,
156
+ mobile_device_name=args.mobile_device_name,
157
+ )
158
+ _print_bind_state(state)
159
+ return 0
160
+
161
+
162
+ def cmd_approve_bind(args: argparse.Namespace) -> int:
163
+ config = load_config(args.config)
164
+ state = approve_bind(
165
+ config,
166
+ bind_code=args.code,
167
+ mobile_device_id=args.mobile_device_id,
168
+ mobile_device_name=args.mobile_device_name,
169
+ )
170
+ _print_bind_state(state)
171
+ return 0
172
+
173
+
174
+ def cmd_unbind(args: argparse.Namespace) -> int:
175
+ config = load_config(args.config)
176
+ state = clear_binding(config)
177
+ _print_bind_state(state)
178
+ return 0
179
+
180
+
181
+ def cmd_desktop_action(args: argparse.Namespace) -> int:
182
+ config = load_config(args.config)
183
+ result = execute_desktop_action(config, args.action, dry_run=args.dry_run).to_dict()
184
+ _print_action_result(result)
185
+ return 0 if result.get("ok") else 1
186
+
187
+
188
+ def cmd_relay_connect(args: argparse.Namespace) -> int:
189
+ config = load_config(args.config)
190
+ asyncio.run(start_desktop_bridge(config))
191
+ return 0
192
+
193
+
194
+ def cmd_run(args: argparse.Namespace) -> int:
195
+ return cmd_relay_connect(args)
196
+
197
+
198
+ def cmd_doctor(args: argparse.Namespace) -> int:
199
+ config = load_config(args.config)
200
+ config.validate_roles()
201
+ config.ensure_runtime_dirs()
202
+ result = run_doctor(config)
203
+ print_doctor_result(result)
204
+ return 0 if result.tasks_dir_exists and result.reports_dir_exists else 1
205
+
206
+
207
+ def cmd_patrol_once(args: argparse.Namespace) -> int:
208
+ config = load_config(args.config)
209
+ targets = sorted(patrol_once(config))
210
+ if not targets:
211
+ print("本轮未发现需要巡检的目标。")
212
+ return 0
213
+ print("本轮目标:")
214
+ for item in targets:
215
+ print(f"- {item}")
216
+ return 0
217
+
218
+
219
+ def build_parser() -> argparse.ArgumentParser:
220
+ parser = argparse.ArgumentParser(prog="bridgeflow", description="BridgeFlow 人机协作桥接与团队巡检工具")
221
+ sub = parser.add_subparsers(dest="command", required=True)
222
+
223
+ init_p = sub.add_parser("init", help="生成默认 bridgeflow_config.json")
224
+ init_p.add_argument("--project-root", default="")
225
+ init_p.add_argument("--force", action="store_true")
226
+ init_p.set_defaults(func=cmd_init)
227
+
228
+ bind_status_p = sub.add_parser("bind-status", help="查看当前绑定状态")
229
+ bind_status_p.add_argument("--config", default=DEFAULT_CONFIG_NAME)
230
+ bind_status_p.set_defaults(func=cmd_bind_status)
231
+
232
+ bind_code_p = sub.add_parser("bind-code", help="生成一次性绑定码")
233
+ bind_code_p.add_argument("--config", default=DEFAULT_CONFIG_NAME)
234
+ bind_code_p.add_argument("--mobile-device-id", default="")
235
+ bind_code_p.add_argument("--mobile-device-name", default="")
236
+ bind_code_p.set_defaults(func=cmd_bind_code)
237
+
238
+ approve_bind_p = sub.add_parser("approve-bind", help="确认手机绑定")
239
+ approve_bind_p.add_argument("--config", default=DEFAULT_CONFIG_NAME)
240
+ approve_bind_p.add_argument("--code", required=True)
241
+ approve_bind_p.add_argument("--mobile-device-id", required=True)
242
+ approve_bind_p.add_argument("--mobile-device-name", default="")
243
+ approve_bind_p.set_defaults(func=cmd_approve_bind)
244
+
245
+ unbind_p = sub.add_parser("unbind", help="解除当前绑定")
246
+ unbind_p.add_argument("--config", default=DEFAULT_CONFIG_NAME)
247
+ unbind_p.set_defaults(func=cmd_unbind)
248
+
249
+ action_p = sub.add_parser("desktop-action", help="执行 PC 端桌面动作")
250
+ action_p.add_argument("--config", default=DEFAULT_CONFIG_NAME)
251
+ action_p.add_argument("--action", choices=["focus_cursor", "inspect", "start_work"], required=True)
252
+ action_p.add_argument("--dry-run", action="store_true")
253
+ action_p.set_defaults(func=cmd_desktop_action)
254
+
255
+ write_p = sub.add_parser("write-admin-task", help="直接生成 ADMIN01 -> PM01 任务文件")
256
+ write_p.add_argument("--config", default=DEFAULT_CONFIG_NAME)
257
+ write_p.add_argument("--text", required=True)
258
+ write_p.add_argument("--recipient", default="")
259
+ write_p.add_argument("--priority", default="")
260
+ write_p.add_argument("--thread-key", default="")
261
+ write_p.add_argument("--attachments", nargs="*")
262
+ write_p.set_defaults(func=cmd_write_admin_task)
263
+
264
+ reply_p = sub.add_parser("write-reply", help="直接生成 PM/DEV/QA -> ADMIN01 回执文件")
265
+ reply_p.add_argument("--config", default=DEFAULT_CONFIG_NAME)
266
+ reply_p.add_argument("--sender", required=True, help="发送角色,如 PM01/DEV01/QA01")
267
+ reply_p.add_argument("--recipient", default="ADMIN01")
268
+ reply_p.add_argument("--text", required=True)
269
+ reply_p.add_argument("--priority", default="")
270
+ reply_p.add_argument("--thread-key", default="")
271
+ reply_p.add_argument("--attachments", nargs="*")
272
+ reply_p.set_defaults(func=cmd_write_reply)
273
+
274
+ relay_p = sub.add_parser("relay-connect", help="连接中继并启动桌面桥接")
275
+ relay_p.add_argument("--config", default=DEFAULT_CONFIG_NAME)
276
+ relay_p.set_defaults(func=cmd_relay_connect)
277
+
278
+ doctor_p = sub.add_parser("doctor", help="检查目录、模板和 pyautogui 环境")
279
+ doctor_p.add_argument("--config", default=DEFAULT_CONFIG_NAME)
280
+ doctor_p.set_defaults(func=cmd_doctor)
281
+
282
+ patrol_p = sub.add_parser("patrol-once", help="扫描当前任务/报告并输出本轮巡检目标")
283
+ patrol_p.add_argument("--config", default=DEFAULT_CONFIG_NAME)
284
+ patrol_p.set_defaults(func=cmd_patrol_once)
285
+
286
+ run_p = sub.add_parser("run", help="启动桌面桥接主循环")
287
+ run_p.add_argument("--config", default=DEFAULT_CONFIG_NAME)
288
+ run_p.set_defaults(func=cmd_run)
289
+
290
+ return parser
291
+
292
+
293
+ def main() -> None:
294
+ if hasattr(sys.stdout, "reconfigure"):
295
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
296
+ if hasattr(sys.stderr, "reconfigure"):
297
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
298
+ parser = build_parser()
299
+ args = parser.parse_args()
300
+ raise SystemExit(args.func(args))
301
+
302
+
303
+ if __name__ == "__main__":
304
+ main()
bridgeflow/config.py ADDED
@@ -0,0 +1,262 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+
9
+ DEFAULT_CONFIG_NAME = "bridgeflow_config.json"
10
+ DEFAULT_FIXED_ROLES = ["ADMIN01", "PM01", "DEV01", "QA01"]
11
+
12
+
13
+ @dataclass
14
+ class PatrolConfig:
15
+ raw: dict
16
+ path: Path
17
+
18
+ @property
19
+ def relay_url(self) -> str:
20
+ return str(self.raw.get("relay", {}).get("url", "")).strip()
21
+
22
+ @property
23
+ def room_key(self) -> str:
24
+ return str(self.raw.get("relay", {}).get("room_key", "")).strip()
25
+
26
+ @property
27
+ def shared_secret(self) -> str:
28
+ return str(self.raw.get("relay", {}).get("shared_secret", "")).strip()
29
+
30
+ @property
31
+ def device_id(self) -> str:
32
+ return str(self.raw.get("device", {}).get("device_id", "")).strip()
33
+
34
+ @property
35
+ def device_name(self) -> str:
36
+ return str(self.raw.get("device", {}).get("device_name", "")).strip()
37
+
38
+ @property
39
+ def owner_role(self) -> str:
40
+ return str(self.raw.get("device", {}).get("owner_role", "")).strip()
41
+
42
+ @property
43
+ def machine_code(self) -> str:
44
+ return str(self.raw.get("device", {}).get("machine_code", "")).strip()
45
+
46
+ @property
47
+ def device_label(self) -> str:
48
+ return self.device_name or self.device_id or "未命名设备"
49
+
50
+ @property
51
+ def bind_status(self) -> str:
52
+ return str(self.raw.get("bind", {}).get("status", "unbound")).strip() or "unbound"
53
+
54
+ @property
55
+ def bound_mobile_device_id(self) -> str:
56
+ return str(self.raw.get("bind", {}).get("bound_mobile_device_id", "")).strip()
57
+
58
+ @property
59
+ def bound_mobile_device_name(self) -> str:
60
+ return str(self.raw.get("bind", {}).get("bound_mobile_device_name", "")).strip()
61
+
62
+ @property
63
+ def bound_at(self) -> str:
64
+ return str(self.raw.get("bind", {}).get("bound_at", "")).strip()
65
+
66
+ @property
67
+ def bind_code_ttl_seconds(self) -> int:
68
+ return int(self.raw.get("bind", {}).get("bind_code_ttl_seconds", 600))
69
+
70
+ @property
71
+ def pending_bind_code(self) -> str:
72
+ return str(self.raw.get("bind", {}).get("pending_bind_code", "")).strip()
73
+
74
+ @property
75
+ def pending_bind_expires_at(self) -> str:
76
+ return str(self.raw.get("bind", {}).get("pending_bind_expires_at", "")).strip()
77
+
78
+ @property
79
+ def pending_mobile_device_id(self) -> str:
80
+ return str(self.raw.get("bind", {}).get("pending_mobile_device_id", "")).strip()
81
+
82
+ @property
83
+ def pending_mobile_device_name(self) -> str:
84
+ return str(self.raw.get("bind", {}).get("pending_mobile_device_name", "")).strip()
85
+
86
+ @property
87
+ def last_bind_code_issued_at(self) -> str:
88
+ return str(self.raw.get("bind", {}).get("last_bind_code_issued_at", "")).strip()
89
+
90
+ @property
91
+ def project_root(self) -> Path:
92
+ root = self.raw.get("project", {}).get("root_dir", ".")
93
+ return Path(root).expanduser().resolve()
94
+
95
+ @property
96
+ def tasks_dir(self) -> Path:
97
+ rel = self.raw.get("project", {}).get("tasks_dir", "docs/agents/tasks")
98
+ return (self.project_root / rel).resolve()
99
+
100
+ @property
101
+ def reports_dir(self) -> Path:
102
+ rel = self.raw.get("project", {}).get("reports_dir", "docs/agents/reports")
103
+ return (self.project_root / rel).resolve()
104
+
105
+ @property
106
+ def issues_dir(self) -> Path:
107
+ rel = self.raw.get("project", {}).get("issues_dir", "docs/agents/issues")
108
+ return (self.project_root / rel).resolve()
109
+
110
+ @property
111
+ def templates_dir(self) -> Path:
112
+ rel = self.raw.get("patrol", {}).get("templates_dir", "ops/patrol_templates")
113
+ return (self.project_root / rel).resolve()
114
+
115
+ @property
116
+ def runtime_dir(self) -> Path:
117
+ rel = self.raw.get("runtime", {}).get("runtime_dir", ".bridgeflow/runtime")
118
+ return (self.project_root / rel).resolve()
119
+
120
+ @property
121
+ def status_dir(self) -> Path:
122
+ rel = self.raw.get("runtime", {}).get("status_dir", ".bridgeflow/runtime/status")
123
+ return (self.project_root / rel).resolve()
124
+
125
+ @property
126
+ def task_details_dir(self) -> Path:
127
+ rel = self.raw.get("runtime", {}).get("task_details_dir", ".bridgeflow/runtime/task_details")
128
+ return (self.project_root / rel).resolve()
129
+
130
+ @property
131
+ def heartbeat_file(self) -> Path:
132
+ return self.status_dir / "heartbeat.json"
133
+
134
+ @property
135
+ def device_status_file(self) -> Path:
136
+ return self.status_dir / "device_status.json"
137
+
138
+ @property
139
+ def task_index_file(self) -> Path:
140
+ return self.status_dir / "task_index.json"
141
+
142
+ @property
143
+ def last_activity_file(self) -> Path:
144
+ return self.status_dir / "last_activity.json"
145
+
146
+ @property
147
+ def admin_sender(self) -> str:
148
+ return str(self.raw.get("admin", {}).get("sender", "ADMIN01")).strip()
149
+
150
+ @property
151
+ def admin_target(self) -> str:
152
+ return str(self.raw.get("admin", {}).get("target", "PM01")).strip()
153
+
154
+ @property
155
+ def default_priority(self) -> str:
156
+ return str(self.raw.get("admin", {}).get("default_priority", "P1")).strip()
157
+
158
+ @property
159
+ def patrol_message(self) -> str:
160
+ return str(self.raw.get("patrol", {}).get("message", "巡检")).strip()
161
+
162
+ @property
163
+ def patrol_poll_interval(self) -> int:
164
+ return int(self.raw.get("patrol", {}).get("poll_interval", 10))
165
+
166
+ @property
167
+ def patrol_check_delay(self) -> int:
168
+ return int(self.raw.get("patrol", {}).get("check_delay", 15))
169
+
170
+ @property
171
+ def patrol_max_retry(self) -> int:
172
+ return int(self.raw.get("patrol", {}).get("max_retry", 3))
173
+
174
+ @property
175
+ def patrol_confidence(self) -> float:
176
+ return float(self.raw.get("patrol", {}).get("confidence", 0.7))
177
+
178
+ @property
179
+ def all_worker_chats(self) -> list[str]:
180
+ return list(self.raw.get("patrol", {}).get("all_worker_chats", []))
181
+
182
+ @property
183
+ def role_to_chat(self) -> dict[str, str]:
184
+ return dict(self.raw.get("patrol", {}).get("role_to_chat", {}))
185
+
186
+ @property
187
+ def roles(self) -> dict[str, dict]:
188
+ raw_roles = self.raw.get("roles", {})
189
+ return {str(key).strip(): dict(value) for key, value in raw_roles.items()}
190
+
191
+ @property
192
+ def fixed_roles(self) -> list[str]:
193
+ raw_roles = self.raw.get("ai_team", {}).get("fixed_roles", DEFAULT_FIXED_ROLES)
194
+ items = [str(item).strip() for item in raw_roles if str(item).strip()]
195
+ return items or DEFAULT_FIXED_ROLES[:]
196
+
197
+ @property
198
+ def cursor_only(self) -> bool:
199
+ return self.raw.get("ai_team", {}).get("cursor_only", True) is not False
200
+
201
+ @property
202
+ def idle_seconds(self) -> int:
203
+ return int(self.raw.get("runtime", {}).get("idle_seconds", 180))
204
+
205
+ @property
206
+ def stale_task_seconds(self) -> int:
207
+ return int(self.raw.get("runtime", {}).get("stale_task_seconds", 900))
208
+
209
+ @property
210
+ def sendable_roles(self) -> list[dict[str, str]]:
211
+ items: list[dict[str, str]] = []
212
+ for role_name in self.fixed_roles:
213
+ meta = self.roles.get(role_name, {})
214
+ if role_name == self.admin_sender:
215
+ continue
216
+ display_names = list(meta.get("display_names", []))
217
+ label = display_names[0] if display_names else role_name
218
+ items.append({"role": role_name, "label": label})
219
+ if not any(item["role"] == self.admin_target for item in items):
220
+ items.insert(0, {"role": self.admin_target, "label": self.admin_target})
221
+ return items
222
+
223
+ def validate_device(self) -> None:
224
+ missing = []
225
+ if not self.device_id:
226
+ missing.append("device.device_id")
227
+ if not self.device_name:
228
+ missing.append("device.device_name")
229
+ if not self.owner_role:
230
+ missing.append("device.owner_role")
231
+ if not self.machine_code:
232
+ missing.append("device.machine_code")
233
+ if missing:
234
+ raise ValueError(f"配置缺少设备身份字段: {', '.join(missing)}")
235
+
236
+ def validate_roles(self) -> None:
237
+ missing = [role for role in self.fixed_roles if role not in self.roles]
238
+ if missing:
239
+ raise ValueError(f"配置缺少固定角色定义: {', '.join(missing)}")
240
+
241
+ def ensure_runtime_dirs(self) -> None:
242
+ self.tasks_dir.mkdir(parents=True, exist_ok=True)
243
+ self.reports_dir.mkdir(parents=True, exist_ok=True)
244
+ self.issues_dir.mkdir(parents=True, exist_ok=True)
245
+ self.runtime_dir.mkdir(parents=True, exist_ok=True)
246
+ self.status_dir.mkdir(parents=True, exist_ok=True)
247
+ self.task_details_dir.mkdir(parents=True, exist_ok=True)
248
+
249
+ def save(self, raw: dict[str, Any] | None = None) -> None:
250
+ self.path.write_text(json.dumps(raw or self.raw, ensure_ascii=False, indent=2), encoding="utf-8")
251
+
252
+ def update_bind(self, **kwargs: Any) -> None:
253
+ bind = dict(self.raw.get("bind", {}))
254
+ bind.update(kwargs)
255
+ self.raw["bind"] = bind
256
+ self.save()
257
+
258
+
259
+ def load_config(path: str | Path) -> PatrolConfig:
260
+ cfg_path = Path(path).expanduser().resolve()
261
+ raw = json.loads(cfg_path.read_text(encoding="utf-8-sig"))
262
+ return PatrolConfig(raw=raw, path=cfg_path)