Skip to content

Commit 8b1f681

Browse files
refactor: apply multi-reviewer findings to switch-model
- extract _copy_tree -> shared cowork2code/_fs.py (Protocol-typed); rehome + cowork_session use it - audit.py: type _text_of, sequential assistant message ids, __all__ - cowork_session.py: Callable typing, transcript assert, outputs_dir on dataclass, --to-model format guard, 0o600 writes for the new config + audit - cli.py: dedupe selector resolution into _resolve_session; Namespace annotations; dry-run no longer prints a Cleanup/quit line for files never written - tests: strengthen audit assertions; add symlink-skip-via-execute, exit-5, invalid-model, --title-as, --id, multi-turn, empty-records, source-read-only-on-disk - 73 passing, 93% coverage, ruff clean
1 parent 2856215 commit 8b1f681

8 files changed

Lines changed: 407 additions & 146 deletions

File tree

cowork2code/_fs.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""Shared filesystem helper: copy a file tree without clobbering or following symlinks."""
2+
3+
import shutil
4+
from pathlib import Path
5+
from typing import Protocol
6+
7+
8+
class CopyTarget(Protocol):
9+
"""Anything that records what was copied and what was skipped."""
10+
11+
copied: list[str]
12+
warnings: list[str]
13+
14+
15+
def copy_tree(
16+
src_root: Path, dst_root: Path, rel_prefix: Path, target: CopyTarget, dry_run: bool
17+
) -> None:
18+
"""Copy files from ``src_root`` into ``dst_root`` (no clobber, never follow symlinks).
19+
20+
Appends copied relative paths to ``target.copied`` and skip reasons to
21+
``target.warnings``. Symlinks are skipped (avoids exfiltrating files outside the
22+
source tree into the destination).
23+
"""
24+
for src in sorted(src_root.rglob("*")):
25+
rel = rel_prefix / src.relative_to(src_root)
26+
if src.is_symlink():
27+
target.warnings.append(f"skip symlink {rel}")
28+
continue
29+
if src.is_dir():
30+
continue
31+
dst = dst_root / rel
32+
if dst.exists() and src.resolve() == dst.resolve():
33+
continue
34+
if dst.exists():
35+
target.warnings.append(f"skip existing {rel}")
36+
continue
37+
target.copied.append(str(rel))
38+
if not dry_run:
39+
dst.parent.mkdir(parents=True, exist_ok=True)
40+
shutil.copy2(src, dst)

cowork2code/audit.py

Lines changed: 63 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,17 @@
55
valid signatures.
66
"""
77

8+
__all__ = ["build_audit_log"]
9+
810
_PLACEHOLDER_HMAC = "0" * 64
911

1012

11-
def _text_of(content) -> str:
13+
def _text_of(content: str | list[dict] | None) -> str:
1214
if isinstance(content, str):
1315
return content
1416
if isinstance(content, list):
1517
return "".join(
16-
b.get("text", "")
17-
for b in content
18-
if isinstance(b, dict) and b.get("type") == "text"
18+
b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text"
1919
)
2020
return ""
2121

@@ -26,13 +26,21 @@ def build_audit_log(
2626
"""Return Cowork audit records: system/init, per-turn user/assistant, result."""
2727
out: list[dict] = [
2828
{
29-
"type": "system", "subtype": "init", "cwd": cwd, "session_id": cli_id,
30-
"tools": [], "mcp_servers": [], "model": model, "permissionMode": "default",
31-
"_audit_timestamp": timestamp, "_audit_hmac": _PLACEHOLDER_HMAC,
29+
"type": "system",
30+
"subtype": "init",
31+
"cwd": cwd,
32+
"session_id": cli_id,
33+
"tools": [],
34+
"mcp_servers": [],
35+
"model": model,
36+
"permissionMode": "default",
37+
"_audit_timestamp": timestamp,
38+
"_audit_hmac": _PLACEHOLDER_HMAC,
3239
}
3340
]
3441
last_text = ""
35-
turns = 0
42+
user_count = 0
43+
asst_count = 0
3644
for rec in records:
3745
if rec.get("type") not in ("user", "assistant"):
3846
continue
@@ -41,32 +49,53 @@ def build_audit_log(
4149
if msg.get("role") == "user":
4250
if not text: # skip tool-result-only / empty user turns
4351
continue
44-
out.append({
45-
"type": "user", "uuid": rec.get("uuid") or f"audit-u{turns}",
46-
"session_id": cli_id, "parent_tool_use_id": None,
47-
"client_platform": "desktop_app",
48-
"message": {"role": "user", "content": text},
49-
"_audit_timestamp": timestamp, "_audit_hmac": _PLACEHOLDER_HMAC,
50-
})
51-
turns += 1
52+
out.append(
53+
{
54+
"type": "user",
55+
"uuid": rec.get("uuid") or f"audit-u{user_count}",
56+
"session_id": cli_id,
57+
"parent_tool_use_id": None,
58+
"client_platform": "desktop_app",
59+
"message": {"role": "user", "content": text},
60+
"_audit_timestamp": timestamp,
61+
"_audit_hmac": _PLACEHOLDER_HMAC,
62+
}
63+
)
64+
user_count += 1
5265
elif msg.get("role") == "assistant":
5366
last_text = text or last_text
54-
out.append({
55-
"type": "assistant",
56-
"message": {
57-
"model": model, "id": f"msg_{turns}", "type": "message",
58-
"role": "assistant", "content": [{"type": "text", "text": text}],
59-
"stop_reason": "end_turn", "stop_sequence": None,
60-
"usage": {"input_tokens": 0, "output_tokens": 0},
61-
},
62-
"parent_tool_use_id": None, "session_id": cli_id,
63-
"uuid": rec.get("uuid") or f"audit-a{turns}",
64-
"_audit_timestamp": timestamp, "_audit_hmac": _PLACEHOLDER_HMAC,
65-
})
66-
turns += 1
67-
out.append({
68-
"type": "result", "subtype": "success", "is_error": False,
69-
"result": last_text, "session_id": cli_id, "num_turns": turns, "duration_ms": 0,
70-
"_audit_timestamp": timestamp, "_audit_hmac": _PLACEHOLDER_HMAC,
71-
})
67+
out.append(
68+
{
69+
"type": "assistant",
70+
"message": {
71+
"model": model,
72+
"id": f"msg_{asst_count}",
73+
"type": "message",
74+
"role": "assistant",
75+
"content": [{"type": "text", "text": text}],
76+
"stop_reason": "end_turn",
77+
"stop_sequence": None,
78+
"usage": {"input_tokens": 0, "output_tokens": 0},
79+
},
80+
"parent_tool_use_id": None,
81+
"session_id": cli_id,
82+
"uuid": rec.get("uuid") or f"audit-a{asst_count}",
83+
"_audit_timestamp": timestamp,
84+
"_audit_hmac": _PLACEHOLDER_HMAC,
85+
}
86+
)
87+
asst_count += 1
88+
out.append(
89+
{
90+
"type": "result",
91+
"subtype": "success",
92+
"is_error": False,
93+
"result": last_text,
94+
"session_id": cli_id,
95+
"num_turns": user_count + asst_count,
96+
"duration_ms": 0,
97+
"_audit_timestamp": timestamp,
98+
"_audit_hmac": _PLACEHOLDER_HMAC,
99+
}
100+
)
72101
return out

cowork2code/cli.py

Lines changed: 32 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,25 @@ def _build_parser() -> argparse.ArgumentParser:
6363
return parser
6464

6565

66-
def _cmd_list(args) -> int:
66+
def _resolve_session(
67+
root: Path, args: argparse.Namespace
68+
) -> tuple[discover.SessionRef | None, int]:
69+
"""Resolve the selected session, or print an error and return an exit code."""
70+
try:
71+
ref = discover.resolve(root, title=args.title, sid=args.id, from_dir=args.from_dir)
72+
return ref, 0
73+
except discover.NoMatch as exc:
74+
print(f"error: {exc}", file=sys.stderr)
75+
return None, 2
76+
except discover.AmbiguousMatch as exc:
77+
print("error: selector matched multiple sessions:", file=sys.stderr)
78+
for r in exc.candidates:
79+
print(f" {r.title} (id={r.local_session_id}, model={r.model})", file=sys.stderr)
80+
print("Refine --title or use --id.", file=sys.stderr)
81+
return None, 3
82+
83+
84+
def _cmd_list(args: argparse.Namespace) -> int:
6785
root = Path(args.sessions_root).expanduser()
6886
refs = discover.list_sessions(root, include_archived=args.all)
6987
if args.json:
@@ -80,19 +98,11 @@ def _cmd_list(args) -> int:
8098
return 0
8199

82100

83-
def _cmd_rehome(args) -> int:
101+
def _cmd_rehome(args: argparse.Namespace) -> int:
84102
root = Path(args.sessions_root).expanduser()
85-
try:
86-
ref = discover.resolve(root, title=args.title, sid=args.id, from_dir=args.from_dir)
87-
except discover.NoMatch as exc:
88-
print(f"error: {exc}", file=sys.stderr)
89-
return 2
90-
except discover.AmbiguousMatch as exc:
91-
print("error: selector matched multiple sessions:", file=sys.stderr)
92-
for r in exc.candidates:
93-
print(f" {r.title} (id={r.local_session_id}, model={r.model})", file=sys.stderr)
94-
print("Refine --title or use --id.", file=sys.stderr)
95-
return 3
103+
ref, rc = _resolve_session(root, args)
104+
if ref is None:
105+
return rc
96106

97107
try:
98108
plan = rehome.build_plan(
@@ -129,19 +139,11 @@ def _cmd_rehome(args) -> int:
129139
return 0
130140

131141

132-
def _cmd_switch_model(args) -> int:
142+
def _cmd_switch_model(args: argparse.Namespace) -> int:
133143
root = Path(args.sessions_root).expanduser()
134-
try:
135-
ref = discover.resolve(root, title=args.title, sid=args.id, from_dir=args.from_dir)
136-
except discover.NoMatch as exc:
137-
print(f"error: {exc}", file=sys.stderr)
138-
return 2
139-
except discover.AmbiguousMatch as exc:
140-
print("error: selector matched multiple sessions:", file=sys.stderr)
141-
for r in exc.candidates:
142-
print(f" {r.title} (id={r.local_session_id}, model={r.model})", file=sys.stderr)
143-
print("Refine --title or use --id.", file=sys.stderr)
144-
return 3
144+
ref, rc = _resolve_session(root, args)
145+
if ref is None:
146+
return rc
145147

146148
try:
147149
session = cowork_session.build(ref, to_model=args.to_model, title=args.title_as)
@@ -157,13 +159,14 @@ def _cmd_switch_model(args) -> int:
157159
print(f"{tag}Copied {len(session.copied)} file(s)")
158160
for warning in session.warnings:
159161
print(f"{tag}warn: {warning}")
160-
print()
161-
print("Quit Cowork fully (Cmd+Q) and relaunch to see it on the new model.")
162-
print(f"Cleanup: {session.cleanup_cmd}")
162+
if not args.dry_run:
163+
print()
164+
print("Quit Cowork fully (Cmd+Q) and relaunch to see it on the new model.")
165+
print(f"Cleanup: {session.cleanup_cmd}")
163166
return 0
164167

165168

166-
def main(argv=None) -> int:
169+
def main(argv: list[str] | None = None) -> int:
167170
args = _build_parser().parse_args(list(sys.argv[1:] if argv is None else argv))
168171
if args.cmd == "list":
169172
return _cmd_list(args)

0 commit comments

Comments
 (0)