tamfis-code 0.2.3__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.
- tamfis_code/__init__.py +58 -0
- tamfis_code/__main__.py +4 -0
- tamfis_code/agents.py +371 -0
- tamfis_code/api_client.py +461 -0
- tamfis_code/cli.py +2351 -0
- tamfis_code/completion.py +137 -0
- tamfis_code/config.py +199 -0
- tamfis_code/doctor.py +173 -0
- tamfis_code/enforcer.py +318 -0
- tamfis_code/indexer.py +567 -0
- tamfis_code/instructions.py +121 -0
- tamfis_code/interactive.py +985 -0
- tamfis_code/local_chat.py +117 -0
- tamfis_code/local_tools.py +93 -0
- tamfis_code/mcp.py +538 -0
- tamfis_code/metrics.py +105 -0
- tamfis_code/providers.py +423 -0
- tamfis_code/references.py +336 -0
- tamfis_code/render.py +575 -0
- tamfis_code/runner.py +635 -0
- tamfis_code/runner_local.py +364 -0
- tamfis_code/safety.py +164 -0
- tamfis_code/screenshot.py +382 -0
- tamfis_code/sessions.py +253 -0
- tamfis_code/state.py +449 -0
- tamfis_code/tasks.py +42 -0
- tamfis_code/workspace.py +329 -0
- tamfis_code-0.2.3.dist-info/METADATA +78 -0
- tamfis_code-0.2.3.dist-info/RECORD +32 -0
- tamfis_code-0.2.3.dist-info/WHEEL +5 -0
- tamfis_code-0.2.3.dist-info/entry_points.txt +4 -0
- tamfis_code-0.2.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Shell completion for TAMFIS-CODE"""
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import argparse
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import List, Dict, Optional
|
|
9
|
+
|
|
10
|
+
class ShellCompleter:
|
|
11
|
+
"""Dynamic shell completion generator"""
|
|
12
|
+
|
|
13
|
+
SUPPORTED_SHELLS = ['bash', 'zsh', 'fish', 'powershell']
|
|
14
|
+
|
|
15
|
+
COMMANDS = {
|
|
16
|
+
'chat': 'Start an interactive chat session',
|
|
17
|
+
'run': 'Execute a command with context',
|
|
18
|
+
'edit': 'Edit files with AI assistance',
|
|
19
|
+
'review': 'Review code changes',
|
|
20
|
+
'plan': 'Create an execution plan',
|
|
21
|
+
'ingest': 'Ingest documents for context',
|
|
22
|
+
'session': 'Manage sessions',
|
|
23
|
+
'config': 'View/Edit configuration',
|
|
24
|
+
'doctor': 'Run diagnostics',
|
|
25
|
+
'version': 'Show version',
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
FILE_COMMANDS = ['edit', 'review', 'ingest']
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def generate_bash(cls) -> str:
|
|
32
|
+
return '''_tamfis_code_completion() {
|
|
33
|
+
local cur prev opts files
|
|
34
|
+
COMPREPLY=()
|
|
35
|
+
cur="${COMP_WORDS[COMP_CWORD]}"
|
|
36
|
+
prev="${COMP_WORDS[COMP_CWORD-1]}"
|
|
37
|
+
|
|
38
|
+
opts="chat run edit review plan ingest session config doctor version"
|
|
39
|
+
|
|
40
|
+
case "${prev}" in
|
|
41
|
+
edit|review|ingest)
|
|
42
|
+
COMPREPLY=( $(compgen -f -- "${cur}") )
|
|
43
|
+
return 0
|
|
44
|
+
;;
|
|
45
|
+
*)
|
|
46
|
+
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
|
47
|
+
return 0
|
|
48
|
+
;;
|
|
49
|
+
esac
|
|
50
|
+
}
|
|
51
|
+
complete -F _tamfis_code_completion tamfis-code
|
|
52
|
+
'''
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def generate_zsh(cls) -> str:
|
|
56
|
+
return '''#compdef tamfis-code
|
|
57
|
+
|
|
58
|
+
_tamfis_code() {
|
|
59
|
+
local -a commands
|
|
60
|
+
commands=(
|
|
61
|
+
'chat:Start interactive chat session'
|
|
62
|
+
'run:Execute command with context'
|
|
63
|
+
'edit:Edit files with AI assistance'
|
|
64
|
+
'review:Review code changes'
|
|
65
|
+
'plan:Create execution plan'
|
|
66
|
+
'ingest:Ingest documents for context'
|
|
67
|
+
'session:Manage sessions'
|
|
68
|
+
'config:View/Edit configuration'
|
|
69
|
+
'doctor:Run diagnostics'
|
|
70
|
+
'version:Show version'
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
_arguments -C \\
|
|
74
|
+
'1: :->command' \\
|
|
75
|
+
'*:: :->args'
|
|
76
|
+
|
|
77
|
+
case $state in
|
|
78
|
+
command)
|
|
79
|
+
_describe 'commands' commands
|
|
80
|
+
;;
|
|
81
|
+
args)
|
|
82
|
+
case $line[1] in
|
|
83
|
+
edit|review|ingest)
|
|
84
|
+
_files
|
|
85
|
+
;;
|
|
86
|
+
esac
|
|
87
|
+
;;
|
|
88
|
+
esac
|
|
89
|
+
}
|
|
90
|
+
compdef _tamfis_code tamfis-code
|
|
91
|
+
'''
|
|
92
|
+
|
|
93
|
+
@classmethod
|
|
94
|
+
def generate_fish(cls) -> str:
|
|
95
|
+
return '''function __fish_tamfis_code_needs_file
|
|
96
|
+
set cmd (commandline -opc)
|
|
97
|
+
contains -- $cmd[2] edit review ingest
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
complete -c tamfis-code -f
|
|
101
|
+
complete -c tamfis-code -n "__fish_use_subcommand" -a chat -d "Start interactive chat session"
|
|
102
|
+
complete -c tamfis-code -n "__fish_use_subcommand" -a run -d "Execute command with context"
|
|
103
|
+
complete -c tamfis-code -n "__fish_use_subcommand" -a edit -d "Edit files with AI assistance"
|
|
104
|
+
complete -c tamfis-code -n "__fish_use_subcommand" -a review -d "Review code changes"
|
|
105
|
+
complete -c tamfis-code -n "__fish_use_subcommand" -a plan -d "Create execution plan"
|
|
106
|
+
complete -c tamfis-code -n "__fish_use_subcommand" -a ingest -d "Ingest documents"
|
|
107
|
+
complete -c tamfis-code -n "__fish_use_subcommand" -a session -d "Manage sessions"
|
|
108
|
+
complete -c tamfis-code -n "__fish_use_subcommand" -a config -d "View/Edit configuration"
|
|
109
|
+
complete -c tamfis-code -n "__fish_use_subcommand" -a doctor -d "Run diagnostics"
|
|
110
|
+
complete -c tamfis-code -n "__fish_use_subcommand" -a version -d "Show version"
|
|
111
|
+
|
|
112
|
+
complete -c tamfis-code -n "__fish_tamfis_code_needs_file" -a "(__fish_complete_path)"
|
|
113
|
+
'''
|
|
114
|
+
|
|
115
|
+
@classmethod
|
|
116
|
+
def generate_powershell(cls) -> str:
|
|
117
|
+
return '''function _tamfis_code_completion {
|
|
118
|
+
param(
|
|
119
|
+
$commandName,
|
|
120
|
+
$parameterName,
|
|
121
|
+
$wordToComplete,
|
|
122
|
+
$commandAst,
|
|
123
|
+
$fakeBoundParameters
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
$commands = @(
|
|
127
|
+
"chat", "run", "edit", "review", "plan",
|
|
128
|
+
"ingest", "session", "config", "doctor", "version"
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
if ($parameterName -eq "command") {
|
|
132
|
+
$commands | Where-Object { $_ -like "$wordToComplete*" }
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
Register-ArgumentCompleter -Native -CommandName "tamfis-code" -ScriptBlock _tamfis_code_completion
|
|
137
|
+
'''
|
tamfis_code/config.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Configuration and credential storage for TamfisGPT Code.
|
|
2
|
+
|
|
3
|
+
Precedence (highest wins): CLI flag > environment variable > project-local
|
|
4
|
+
.tamfis/config.toml > user ~/.config/tamfis-code/config.toml > built-in
|
|
5
|
+
default. Only fields the CLI actually gives distinct behaviour to are
|
|
6
|
+
supported here -- see docs/REMOTE_AGENT_MASTER_SPEC.md Phase 21's full
|
|
7
|
+
configuration wishlist for what is intentionally deferred.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import stat
|
|
15
|
+
import tomllib
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Optional
|
|
19
|
+
|
|
20
|
+
APPROVAL_MODES = (
|
|
21
|
+
"ask", "safe", "auto", "read-only", "plan-only",
|
|
22
|
+
"suggest", "workspace", "full-auto", "never",
|
|
23
|
+
"accept-edits",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# Short, user-facing names for /mode -- map to the policy values above.
|
|
27
|
+
# "ask" is this CLI's actual default/manual mode, "accept-edits" behaves
|
|
28
|
+
# like "safe" (auto-approve unless the server classifies the action
|
|
29
|
+
# dangerous, which covers ordinary file edits without covering a
|
|
30
|
+
# destructive shell command), "plan" is "plan-only", "auto" is itself.
|
|
31
|
+
MODE_ALIASES = {
|
|
32
|
+
"manual": "ask",
|
|
33
|
+
"plan": "plan-only",
|
|
34
|
+
"accept-edits": "accept-edits",
|
|
35
|
+
"auto": "auto",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
CONFIG_DIR = Path(os.environ.get("TAMFIS_CODE_CONFIG_HOME", "") or (Path.home() / ".config" / "tamfis-code"))
|
|
39
|
+
CREDENTIALS_PATH = CONFIG_DIR / "credentials.json"
|
|
40
|
+
USER_CONFIG_PATH = CONFIG_DIR / "config.toml"
|
|
41
|
+
PROJECT_CONFIG_RELATIVE = Path(".tamfis") / "config.toml"
|
|
42
|
+
|
|
43
|
+
DEFAULT_API_BASE = "http://127.0.0.1:9500"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _load_toml(path: Path) -> dict[str, Any]:
|
|
47
|
+
if not path.is_file():
|
|
48
|
+
return {}
|
|
49
|
+
try:
|
|
50
|
+
with path.open("rb") as fh:
|
|
51
|
+
return tomllib.load(fh)
|
|
52
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
53
|
+
return {}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class Config:
|
|
58
|
+
api_base: str = DEFAULT_API_BASE
|
|
59
|
+
approval_policy: str = "ask"
|
|
60
|
+
colour: bool = True
|
|
61
|
+
output_mode: str = "text"
|
|
62
|
+
timeout_seconds: float = 120.0
|
|
63
|
+
debug: bool = False
|
|
64
|
+
# Real (LLM-backed) subagent delegation is opt-in: concurrent sessions
|
|
65
|
+
# against the Remote backend have open questions (rate limiting, approval
|
|
66
|
+
# prompts interleaving across sessions, concurrent state.json writers)
|
|
67
|
+
# that need validating against a live backend before being on by default.
|
|
68
|
+
enable_subagent_delegation: bool = False
|
|
69
|
+
# "standalone" (default): call a provider directly, no TamfisGPT backend.
|
|
70
|
+
# "remote": use the TamfisGPT Remote Workspace backend for every command
|
|
71
|
+
# without needing --remote on each invocation -- set this once (via
|
|
72
|
+
# `tamfis-code login` writing it, or manually in config.toml) for a paid
|
|
73
|
+
# TamfisGPT tenant who wants that be the default, the same way Claude
|
|
74
|
+
# Code/Codex/kimi-code default to their respective hosted accounts.
|
|
75
|
+
default_backend: str = "standalone"
|
|
76
|
+
sources: dict[str, str] = field(default_factory=dict) # field -> where it came from, for `doctor`/`config`
|
|
77
|
+
|
|
78
|
+
def as_dict(self) -> dict[str, Any]:
|
|
79
|
+
return {
|
|
80
|
+
"api_base": self.api_base,
|
|
81
|
+
"approval_policy": self.approval_policy,
|
|
82
|
+
"colour": self.colour,
|
|
83
|
+
"output_mode": self.output_mode,
|
|
84
|
+
"timeout_seconds": self.timeout_seconds,
|
|
85
|
+
"debug": self.debug,
|
|
86
|
+
"enable_subagent_delegation": self.enable_subagent_delegation,
|
|
87
|
+
"default_backend": self.default_backend,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def load_config(project_root: Optional[Path] = None) -> Config:
|
|
92
|
+
cfg = Config()
|
|
93
|
+
cfg.sources = {k: "default" for k in cfg.as_dict()}
|
|
94
|
+
|
|
95
|
+
layers: list[tuple[str, dict[str, Any]]] = [
|
|
96
|
+
("user config", _load_toml(USER_CONFIG_PATH)),
|
|
97
|
+
]
|
|
98
|
+
if project_root is not None:
|
|
99
|
+
layers.append(("project config", _load_toml(project_root / PROJECT_CONFIG_RELATIVE)))
|
|
100
|
+
|
|
101
|
+
for source_name, data in layers:
|
|
102
|
+
if "api_base" in data:
|
|
103
|
+
cfg.api_base = str(data["api_base"])
|
|
104
|
+
cfg.sources["api_base"] = source_name
|
|
105
|
+
if "approval_policy" in data and data["approval_policy"] in APPROVAL_MODES:
|
|
106
|
+
cfg.approval_policy = str(data["approval_policy"])
|
|
107
|
+
cfg.sources["approval_policy"] = source_name
|
|
108
|
+
if "colour" in data:
|
|
109
|
+
cfg.colour = bool(data["colour"])
|
|
110
|
+
cfg.sources["colour"] = source_name
|
|
111
|
+
if "output_mode" in data:
|
|
112
|
+
cfg.output_mode = str(data["output_mode"])
|
|
113
|
+
cfg.sources["output_mode"] = source_name
|
|
114
|
+
if "timeout_seconds" in data:
|
|
115
|
+
cfg.timeout_seconds = float(data["timeout_seconds"])
|
|
116
|
+
cfg.sources["timeout_seconds"] = source_name
|
|
117
|
+
if "enable_subagent_delegation" in data:
|
|
118
|
+
cfg.enable_subagent_delegation = bool(data["enable_subagent_delegation"])
|
|
119
|
+
cfg.sources["enable_subagent_delegation"] = source_name
|
|
120
|
+
if data.get("default_backend") in ("standalone", "remote"):
|
|
121
|
+
cfg.default_backend = str(data["default_backend"])
|
|
122
|
+
cfg.sources["default_backend"] = source_name
|
|
123
|
+
|
|
124
|
+
env_api_base = os.environ.get("TAMFIS_CODE_API_BASE")
|
|
125
|
+
if env_api_base:
|
|
126
|
+
cfg.api_base = env_api_base
|
|
127
|
+
cfg.sources["api_base"] = "env TAMFIS_CODE_API_BASE"
|
|
128
|
+
|
|
129
|
+
env_approval = os.environ.get("TAMFIS_CODE_APPROVAL_POLICY")
|
|
130
|
+
if env_approval in APPROVAL_MODES:
|
|
131
|
+
cfg.approval_policy = env_approval
|
|
132
|
+
cfg.sources["approval_policy"] = "env TAMFIS_CODE_APPROVAL_POLICY"
|
|
133
|
+
|
|
134
|
+
env_delegation = os.environ.get("TAMFIS_CODE_ENABLE_SUBAGENT_DELEGATION")
|
|
135
|
+
if env_delegation is not None:
|
|
136
|
+
cfg.enable_subagent_delegation = env_delegation.lower() in {"1", "true", "yes"}
|
|
137
|
+
cfg.sources["enable_subagent_delegation"] = "env TAMFIS_CODE_ENABLE_SUBAGENT_DELEGATION"
|
|
138
|
+
|
|
139
|
+
env_backend = os.environ.get("TAMFIS_CODE_DEFAULT_BACKEND")
|
|
140
|
+
if env_backend in ("standalone", "remote"):
|
|
141
|
+
cfg.default_backend = env_backend
|
|
142
|
+
cfg.sources["default_backend"] = "env TAMFIS_CODE_DEFAULT_BACKEND"
|
|
143
|
+
|
|
144
|
+
return cfg
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass
|
|
148
|
+
class Credentials:
|
|
149
|
+
access_token: str
|
|
150
|
+
refresh_token: Optional[str] = None
|
|
151
|
+
user_id: Optional[str] = None
|
|
152
|
+
email: Optional[str] = None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def load_credentials() -> Optional[Credentials]:
|
|
156
|
+
env_token = os.environ.get("TAMFIS_CODE_TOKEN")
|
|
157
|
+
if env_token:
|
|
158
|
+
return Credentials(access_token=env_token)
|
|
159
|
+
|
|
160
|
+
if not CREDENTIALS_PATH.is_file():
|
|
161
|
+
return None
|
|
162
|
+
try:
|
|
163
|
+
data = json.loads(CREDENTIALS_PATH.read_text())
|
|
164
|
+
except (OSError, json.JSONDecodeError):
|
|
165
|
+
return None
|
|
166
|
+
access_token = data.get("access_token")
|
|
167
|
+
if not access_token:
|
|
168
|
+
return None
|
|
169
|
+
return Credentials(
|
|
170
|
+
access_token=access_token,
|
|
171
|
+
refresh_token=data.get("refresh_token"),
|
|
172
|
+
user_id=data.get("user_id"),
|
|
173
|
+
email=data.get("email"),
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def save_credentials(creds: Credentials) -> None:
|
|
178
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
179
|
+
# Only chmod when needed -- see the identical fix in state.py's
|
|
180
|
+
# _save_raw for why doing this unconditionally is a landmine (raises
|
|
181
|
+
# PermissionError the moment CONFIG_DIR is ever owned by a different user
|
|
182
|
+
# than the caller).
|
|
183
|
+
if stat.S_IMODE(os.stat(CONFIG_DIR).st_mode) != stat.S_IRWXU:
|
|
184
|
+
os.chmod(CONFIG_DIR, stat.S_IRWXU) # 0700 -- owner-only, matches spec's "restrictive permissions"
|
|
185
|
+
payload = {
|
|
186
|
+
"access_token": creds.access_token,
|
|
187
|
+
"refresh_token": creds.refresh_token,
|
|
188
|
+
"user_id": creds.user_id,
|
|
189
|
+
"email": creds.email,
|
|
190
|
+
}
|
|
191
|
+
CREDENTIALS_PATH.write_text(json.dumps(payload))
|
|
192
|
+
os.chmod(CREDENTIALS_PATH, stat.S_IRUSR | stat.S_IWUSR) # 0600
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def clear_credentials() -> bool:
|
|
196
|
+
if CREDENTIALS_PATH.is_file():
|
|
197
|
+
CREDENTIALS_PATH.unlink()
|
|
198
|
+
return True
|
|
199
|
+
return False
|
tamfis_code/doctor.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""`tamfis-code doctor` -- connectivity/auth/workspace checks.
|
|
2
|
+
|
|
3
|
+
Reports PASS/WARNING/FAIL per Phase 21. Deliberately checks the things that
|
|
4
|
+
were the actual break points found during this project's Remote-workspace
|
|
5
|
+
audit (see docs/REMOTE_AGENT_MASTER_SPEC.md and the linked memory notes) --
|
|
6
|
+
API reachability, auth, and workspace-scope enforcement really working, not
|
|
7
|
+
just "is the process up."
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Optional
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
from rich.console import Console
|
|
18
|
+
|
|
19
|
+
from .api_client import AuthRequiredError, RemoteAPIClient, RemoteAPIError
|
|
20
|
+
from .config import Config, load_credentials
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class CheckResult:
|
|
25
|
+
name: str
|
|
26
|
+
status: str # "PASS" | "WARNING" | "FAIL"
|
|
27
|
+
detail: str = ""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_STATUS_STYLE = {"PASS": "green", "WARNING": "yellow", "FAIL": "red"}
|
|
31
|
+
|
|
32
|
+
_REUSABLE_SESSION_STATUSES = {"idle", "active"}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def check_event_sequence_integrity(events: list[dict[str, Any]]) -> CheckResult:
|
|
36
|
+
"""Pure check over a window of a session's RemoteEvent replay (as
|
|
37
|
+
returned by GET .../thread): sequence numbers must be unique and, within
|
|
38
|
+
the returned window, contiguous. A duplicate is a real replay-safety
|
|
39
|
+
bug (the same event could be rendered/acted on twice by a client that
|
|
40
|
+
resumes from `last_event_id`); a gap is either dropped events or just
|
|
41
|
+
this window being truncated by the endpoint's own `limit` -- reported
|
|
42
|
+
as a WARNING, not a FAIL, since it can't be told apart from here.
|
|
43
|
+
"""
|
|
44
|
+
if not events:
|
|
45
|
+
return CheckResult("Event replay integrity", "WARNING", "no events yet for this session")
|
|
46
|
+
sequences = [e.get("sequence") for e in events if isinstance(e.get("sequence"), int)]
|
|
47
|
+
if len(sequences) != len(events):
|
|
48
|
+
return CheckResult("Event replay integrity", "FAIL", "one or more events are missing a sequence number")
|
|
49
|
+
sequences.sort()
|
|
50
|
+
duplicates = {s for s in sequences if sequences.count(s) > 1}
|
|
51
|
+
if duplicates:
|
|
52
|
+
return CheckResult("Event replay integrity", "FAIL", f"duplicate sequence number(s): {sorted(duplicates)[:5]}")
|
|
53
|
+
gaps = [b for a, b in zip(sequences, sequences[1:]) if b != a + 1]
|
|
54
|
+
if gaps:
|
|
55
|
+
return CheckResult(
|
|
56
|
+
"Event replay integrity", "WARNING",
|
|
57
|
+
f"{len(gaps)} gap(s) in {len(sequences)} events checked (sequence {sequences[0]}-{sequences[-1]}) "
|
|
58
|
+
"-- may just be this check's window, or a dropped event",
|
|
59
|
+
)
|
|
60
|
+
return CheckResult("Event replay integrity", "PASS", f"{len(sequences)} events, sequence {sequences[0]}-{sequences[-1]}, no gaps or duplicates")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def _diagnose_session(client: RemoteAPIClient, session_id: int, workspace_root: Optional[Path]) -> list[CheckResult]:
|
|
64
|
+
"""Session/workspace-snapshot/event-replay health for the *active*
|
|
65
|
+
session -- distinct from the generic connectivity checks above, this is
|
|
66
|
+
the "am I actually in the state I think I'm in" self-diagnosis."""
|
|
67
|
+
results: list[CheckResult] = []
|
|
68
|
+
try:
|
|
69
|
+
session = await client.get_session(session_id)
|
|
70
|
+
except (AuthRequiredError, RemoteAPIError) as e:
|
|
71
|
+
results.append(CheckResult("Active session", "FAIL", f"session {session_id}: {e}"))
|
|
72
|
+
return results
|
|
73
|
+
|
|
74
|
+
status = str(session.get("status") or "")
|
|
75
|
+
if status in _REUSABLE_SESSION_STATUSES:
|
|
76
|
+
results.append(CheckResult("Active session", "PASS", f"session {session_id} status={status}"))
|
|
77
|
+
else:
|
|
78
|
+
results.append(CheckResult("Active session", "WARNING", f"session {session_id} status={status} (not idle/active)"))
|
|
79
|
+
|
|
80
|
+
server_wd = session.get("working_directory")
|
|
81
|
+
if workspace_root is not None and server_wd:
|
|
82
|
+
if str(workspace_root.resolve()) == server_wd:
|
|
83
|
+
results.append(CheckResult("Session cwd matches local cwd", "PASS", server_wd))
|
|
84
|
+
else:
|
|
85
|
+
results.append(CheckResult(
|
|
86
|
+
"Session cwd matches local cwd", "WARNING",
|
|
87
|
+
f"server has {server_wd!r}, local cwd is {str(workspace_root.resolve())!r}",
|
|
88
|
+
))
|
|
89
|
+
|
|
90
|
+
snapshot = session.get("workspace_snapshot")
|
|
91
|
+
if snapshot is None:
|
|
92
|
+
results.append(CheckResult("Workspace snapshot", "WARNING", "not scanned yet -- will populate on the first AI task"))
|
|
93
|
+
else:
|
|
94
|
+
detail = (
|
|
95
|
+
f"v{snapshot.get('file_index_version')}, "
|
|
96
|
+
f"repo={snapshot.get('repository_type')}, "
|
|
97
|
+
f"branch={snapshot.get('git_branch') or '-'}, "
|
|
98
|
+
f"last scan={snapshot.get('last_scan_at') or 'unknown'} "
|
|
99
|
+
f"(reason: {snapshot.get('scan_reason') or 'unknown'})"
|
|
100
|
+
)
|
|
101
|
+
results.append(CheckResult("Workspace snapshot", "PASS", detail))
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
thread = await client.get_thread(session_id, after_sequence=0)
|
|
105
|
+
results.append(check_event_sequence_integrity(thread.get("events") or []))
|
|
106
|
+
except (AuthRequiredError, RemoteAPIError) as e:
|
|
107
|
+
results.append(CheckResult("Event replay integrity", "FAIL", str(e)))
|
|
108
|
+
|
|
109
|
+
return results
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
async def run_doctor(
|
|
113
|
+
config: Config,
|
|
114
|
+
console: Console,
|
|
115
|
+
workspace_root: Optional[Path] = None,
|
|
116
|
+
*,
|
|
117
|
+
session_id: Optional[int] = None,
|
|
118
|
+
) -> bool:
|
|
119
|
+
results: list[CheckResult] = []
|
|
120
|
+
|
|
121
|
+
results.append(CheckResult("Config", "PASS", f"api_base={config.api_base}"))
|
|
122
|
+
|
|
123
|
+
creds = load_credentials()
|
|
124
|
+
if creds is None:
|
|
125
|
+
results.append(CheckResult("Authentication", "FAIL", "No credentials -- run `tamfis-code login`"))
|
|
126
|
+
else:
|
|
127
|
+
results.append(CheckResult("Authentication", "PASS", f"credentials present for {creds.email or creds.user_id or 'unknown user'}"))
|
|
128
|
+
|
|
129
|
+
client = RemoteAPIClient(config, creds)
|
|
130
|
+
try:
|
|
131
|
+
try:
|
|
132
|
+
servers = await client.list_servers()
|
|
133
|
+
results.append(CheckResult("Remote API (Tier III, port 9500)", "PASS", f"{len(servers)} registered server(s)"))
|
|
134
|
+
except AuthRequiredError as e:
|
|
135
|
+
results.append(CheckResult("Remote API (Tier III, port 9500)", "FAIL", str(e)))
|
|
136
|
+
servers = None
|
|
137
|
+
except (RemoteAPIError, httpx.HTTPError) as e:
|
|
138
|
+
results.append(CheckResult("Remote API (Tier III, port 9500)", "FAIL", str(e)))
|
|
139
|
+
servers = None
|
|
140
|
+
|
|
141
|
+
if servers is not None:
|
|
142
|
+
local_server = next((s for s in servers if s.get("transport_type") == "local"), None)
|
|
143
|
+
if local_server is not None:
|
|
144
|
+
results.append(CheckResult("Local transport server", "PASS", f"server_id={local_server['id']}"))
|
|
145
|
+
else:
|
|
146
|
+
results.append(CheckResult("Local transport server", "WARNING", "none registered yet -- `tamfis-code init` will create one"))
|
|
147
|
+
|
|
148
|
+
if workspace_root is not None:
|
|
149
|
+
wr = str(workspace_root.resolve())
|
|
150
|
+
if workspace_root.is_dir():
|
|
151
|
+
results.append(CheckResult("Workspace directory", "PASS", wr))
|
|
152
|
+
else:
|
|
153
|
+
results.append(CheckResult("Workspace directory", "FAIL", f"{wr} is not a directory"))
|
|
154
|
+
|
|
155
|
+
# Tier IV reachability is inferred, not probed directly -- there is
|
|
156
|
+
# no public health endpoint on port 9555 to hit from here without
|
|
157
|
+
# a session already existing; a real ai-task submission is what
|
|
158
|
+
# actually proves the whole chain, which `doctor` deliberately does
|
|
159
|
+
# not do (it would create session/task rows as a side effect of a
|
|
160
|
+
# health check).
|
|
161
|
+
results.append(CheckResult("Tier IV (agent runtime)", "WARNING", "not directly probed -- verified indirectly via a real `tamfis-code ask`"))
|
|
162
|
+
|
|
163
|
+
if session_id is not None:
|
|
164
|
+
results.extend(await _diagnose_session(client, session_id, workspace_root))
|
|
165
|
+
|
|
166
|
+
finally:
|
|
167
|
+
await client.aclose()
|
|
168
|
+
|
|
169
|
+
for result in results:
|
|
170
|
+
style = _STATUS_STYLE[result.status]
|
|
171
|
+
console.print(f"[{style}]{result.status:8}[/{style}] {result.name} [dim]{result.detail}[/dim]")
|
|
172
|
+
|
|
173
|
+
return all(r.status != "FAIL" for r in results)
|