exegete 0.14.1a0.dev1__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.
- exegete/__init__.py +16 -0
- exegete/coder_comparison.py +208 -0
- exegete/cursors.py +218 -0
- exegete/database.py +12621 -0
- exegete/env_settings.py +174 -0
- exegete/memo_privacy.py +215 -0
- exegete/names.py +68 -0
- exegete/new_project.py +1027 -0
- exegete/preview_tokens.py +613 -0
- exegete/project_settings.py +1082 -0
- exegete/pseudonymise.py +2796 -0
- exegete/refi_export.py +645 -0
- exegete/server.py +18899 -0
- exegete/sessions.py +1153 -0
- exegete/state_folder.py +360 -0
- exegete/transition.py +1049 -0
- exegete-0.14.1a0.dev1.dist-info/METADATA +515 -0
- exegete-0.14.1a0.dev1.dist-info/RECORD +24 -0
- exegete-0.14.1a0.dev1.dist-info/WHEEL +5 -0
- exegete-0.14.1a0.dev1.dist-info/entry_points.txt +2 -0
- exegete-0.14.1a0.dev1.dist-info/licenses/COPYING.LESSER +165 -0
- exegete-0.14.1a0.dev1.dist-info/licenses/NOTICE +773 -0
- exegete-0.14.1a0.dev1.dist-info/licenses/legal/GPL-3.0.txt +674 -0
- exegete-0.14.1a0.dev1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1082 @@
|
|
|
1
|
+
# SPDX-License-Identifier: LGPL-3.0-or-later
|
|
2
|
+
"""Per-project settings this server keeps beside data.qda (v0.12, D7).
|
|
3
|
+
|
|
4
|
+
One setting lives here so far: the AI coder name every row this server
|
|
5
|
+
writes into a project is attributed to. It is stored in a small JSON
|
|
6
|
+
sidecar, `exegete.json` (until 0.14.0, `qualcoder_mcp.json`), in the
|
|
7
|
+
project folder next to `data.qda`, because the setting belongs to the
|
|
8
|
+
PROJECT and must travel with it:
|
|
9
|
+
backups, workspace copies, a synced folder and a second machine all carry
|
|
10
|
+
it, and two hosts talking to one project agree on it without a shared
|
|
11
|
+
machine-level store.
|
|
12
|
+
|
|
13
|
+
Why the project folder is safe for a file of our own (verified at the
|
|
14
|
+
pinned clone, master 9bddf17): QualCoder never enumerates the project
|
|
15
|
+
root looking for strangers, its own cleanup deletes only `*_BKUP_*`
|
|
16
|
+
folders and its lock file, its backup copies the whole tree with an
|
|
17
|
+
ignore set that does not match this name (`app.py:1619-1631`), and a
|
|
18
|
+
project merge merges INTO the open project (`merge_projects.py:200`), so
|
|
19
|
+
the destination keeps its sidecar.
|
|
20
|
+
|
|
21
|
+
The file's new name (v0.14.1, the owner's ruling 41, decision 7):
|
|
22
|
+
|
|
23
|
+
- A read takes `exegete.json` when it exists, in any state (an
|
|
24
|
+
unreadable one is reported as unreadable, never passed over), and
|
|
25
|
+
otherwise the earlier `qualcoder_mcp.json`.
|
|
26
|
+
- The first write of the name in a project that has only the earlier
|
|
27
|
+
file copies its name, history and other keys into `exegete.json` (the
|
|
28
|
+
one atomic write below), and then rewrites the earlier file with
|
|
29
|
+
format_version 2 and `moved_to: "exegete.json"`. Versions 0.12 to 0.14
|
|
30
|
+
read only the earlier file and refuse to write one whose version is
|
|
31
|
+
above 1 ("written by a newer version"), so a second host still on
|
|
32
|
+
them stops rather than writing rows under a name the researcher has
|
|
33
|
+
since changed. The earlier file keeps the name it held at the move,
|
|
34
|
+
so their reads still recognise the rows it named.
|
|
35
|
+
- A restore of a backup made before the move brings back the earlier
|
|
36
|
+
file alone, unmarked: the read falls back to it and the next write
|
|
37
|
+
moves it again. Messages name the file in use.
|
|
38
|
+
- A MARKED earlier file alone (exegete.json removed or lost, as the
|
|
39
|
+
messages below invite for a damaged one) reads as "no name set",
|
|
40
|
+
keeping its history: the name it holds is the one from before the
|
|
41
|
+
move, and every change since lived in exegete.json only, so the next
|
|
42
|
+
write asks, as the messages promise, and never goes back to it.
|
|
43
|
+
- A project Exegete names first gets an earlier file too, written
|
|
44
|
+
already marked and holding no name, so that 0.12 to 0.14 refuse and
|
|
45
|
+
say to upgrade rather than ask for a name of their own and write under
|
|
46
|
+
it beside Exegete. Until v1.0, like the other support for the old
|
|
47
|
+
name.
|
|
48
|
+
- The mark is reported only when made. One that fails (a locked,
|
|
49
|
+
read-only or synced file) is said, and every owner-bearing write
|
|
50
|
+
tries again (`settle_earlier_file`); a name an older copy stored in
|
|
51
|
+
an unmarked earlier file beside exegete.json joins its history first.
|
|
52
|
+
- An earlier file that cannot be read is never rewritten (its bytes may
|
|
53
|
+
be the only history), and neither is one of a version above 2.
|
|
54
|
+
|
|
55
|
+
Restart resilience: nothing here is cached. Every read goes to disk, so a
|
|
56
|
+
host that recycles the server process between turns sees the same answer,
|
|
57
|
+
and so does a second host editing the same project.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
import json
|
|
61
|
+
import logging
|
|
62
|
+
import os
|
|
63
|
+
import stat
|
|
64
|
+
import tempfile
|
|
65
|
+
import unicodedata
|
|
66
|
+
from datetime import datetime
|
|
67
|
+
from pathlib import Path
|
|
68
|
+
from typing import Any, Dict, List, NamedTuple, Optional, Tuple
|
|
69
|
+
|
|
70
|
+
from . import env_settings, names
|
|
71
|
+
from .database import (KNOWN_AI_ASSISTANT_OWNER, validate_coder_name,
|
|
72
|
+
validate_coder_note)
|
|
73
|
+
|
|
74
|
+
logger = logging.getLogger(__name__)
|
|
75
|
+
|
|
76
|
+
# The sidecar. Only these exact names are ever treated as one. The
|
|
77
|
+
# format marker and version are shared by both files: every version since
|
|
78
|
+
# 0.12 writes version 1.
|
|
79
|
+
SIDECAR_NAME = "exegete.json"
|
|
80
|
+
OLD_SIDECAR_NAME = "qualcoder_mcp.json"
|
|
81
|
+
SIDECAR_FORMAT = "qualcoder-mcp-project"
|
|
82
|
+
SIDECAR_FORMAT_VERSION = 1
|
|
83
|
+
# The earlier file after the move: a version 0.12 to 0.14 refuse to write,
|
|
84
|
+
# and the key naming the file that now holds the name.
|
|
85
|
+
OLD_SIDECAR_MOVED_VERSION = 2
|
|
86
|
+
MOVED_TO_KEY = "moved_to"
|
|
87
|
+
# The payload is a few hundred bytes with a full history; anything past
|
|
88
|
+
# this is not ours (the MRU reader's rationale, server.py:144-170).
|
|
89
|
+
SIDECAR_READ_MAX_BYTES = 64 * 1024
|
|
90
|
+
# History is capped on write, oldest first; the current entry is never
|
|
91
|
+
# dropped. Twenty entries are echoed into the conversation (ruling 11).
|
|
92
|
+
HISTORY_CAP = 200
|
|
93
|
+
HISTORY_ECHO = 20
|
|
94
|
+
# The write cap, in the SAME UNIT as the read cap. The two used to
|
|
95
|
+
# disagree: writes were capped at 200 ENTRIES and reads at 64 KiB, and
|
|
96
|
+
# two things inflate the file between them, the indent=2 formatting and
|
|
97
|
+
# the unknown top-level keys a write deliberately preserves. Ninety-two
|
|
98
|
+
# ordinary name changes were enough to produce a file this server had
|
|
99
|
+
# just written and would then refuse to read, after which every
|
|
100
|
+
# owner-bearing write was refused. A cap in entries cannot bound a file
|
|
101
|
+
# measured in bytes, so the byte budget is what holds and HISTORY_CAP is
|
|
102
|
+
# kept only as the cheap upper bound. The margin leaves room for the
|
|
103
|
+
# next write's own growth, so the file does not sit on the boundary
|
|
104
|
+
# (fix round 4).
|
|
105
|
+
SIDECAR_WRITE_MAX_BYTES = SIDECAR_READ_MAX_BYTES - 4096
|
|
106
|
+
|
|
107
|
+
# The host declaration (v0.11's machine-wide setting, re-purposed by D7)
|
|
108
|
+
# and this server's built-in default.
|
|
109
|
+
# Read under both spellings (v0.14.1), through env_settings.
|
|
110
|
+
AI_CODER_NAME_ENV = names.SETTINGS["ai_coder_name"][0]
|
|
111
|
+
DEFAULT_AI_CODER_NAME = "AI Coding Assistant"
|
|
112
|
+
# QualCoder 4.0's built-in assistant's owner string, re-exported from
|
|
113
|
+
# database.py (one definition, H3). Rows under it that this project has
|
|
114
|
+
# not adopted are another coder's work; `known_ai_assistant` is a
|
|
115
|
+
# heuristic label for them, never a fact about who typed.
|
|
116
|
+
__all_known_ai = KNOWN_AI_ASSISTANT_OWNER
|
|
117
|
+
# The pre-0.11 import label. It is an owner on `source` and `case_text`
|
|
118
|
+
# rows, never a coding owner, so it is not an AI coder name and never
|
|
119
|
+
# enters the set below (Appendix A, R2).
|
|
120
|
+
LEGACY_IMPORT_OWNER = "MCP Import"
|
|
121
|
+
|
|
122
|
+
# The states read_sidecar can report.
|
|
123
|
+
SIDECAR_UNSET = "unset"
|
|
124
|
+
SIDECAR_SET = "project"
|
|
125
|
+
SIDECAR_UNREADABLE = "unreadable"
|
|
126
|
+
SIDECAR_NEWER_FORMAT = "newer_format"
|
|
127
|
+
|
|
128
|
+
_UNREADABLE = (
|
|
129
|
+
"The AI coder name file for this project ({file} in the "
|
|
130
|
+
"project folder) could not be read. Ask the user to repair or remove "
|
|
131
|
+
"it; the next write will then ask for the name again. Nothing was "
|
|
132
|
+
"written.")
|
|
133
|
+
|
|
134
|
+
_NEWER_FORMAT = (
|
|
135
|
+
"The AI coder name file for this project ({file} in the "
|
|
136
|
+
"project folder) was written by a newer version of this server "
|
|
137
|
+
f"({names.SERVER_NAME}, formerly qualcoder-mcp) and this one cannot "
|
|
138
|
+
f"write it safely. Upgrade {names.SERVER_NAME}, or ask the "
|
|
139
|
+
"user to move the file aside; the next write will then ask for the "
|
|
140
|
+
"name again. Nothing was written.")
|
|
141
|
+
|
|
142
|
+
_OVERSIZED = (
|
|
143
|
+
"The AI coder name file for this project ({file} in the "
|
|
144
|
+
"project folder) is too large to write: even with one history entry "
|
|
145
|
+
"it would be bigger than this server can read back. Ask the user to "
|
|
146
|
+
"remove the extra top-level keys in it, or to move the file aside; "
|
|
147
|
+
"the next write will then ask for the name again. Nothing was "
|
|
148
|
+
"written.")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _naming(template: str, path: Any = None) -> str:
|
|
153
|
+
"""A message naming the file in use (`path`), or the new file."""
|
|
154
|
+
return template.format(file=Path(path).name if path else SIDECAR_NAME)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def unreadable_message(path: Any = None) -> str:
|
|
158
|
+
return _naming(_UNREADABLE, path)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def newer_format_message(path: Any = None) -> str:
|
|
162
|
+
return _naming(_NEWER_FORMAT, path)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def oversized_message(path: Any = None) -> str:
|
|
166
|
+
return _naming(_OVERSIZED, path)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# The messages naming the new file, for callers with no path at hand.
|
|
170
|
+
UNREADABLE_MESSAGE = unreadable_message()
|
|
171
|
+
NEWER_FORMAT_MESSAGE = newer_format_message()
|
|
172
|
+
OVERSIZED_MESSAGE = oversized_message()
|
|
173
|
+
|
|
174
|
+
READ_ONLY_FOLDER_MESSAGE = (
|
|
175
|
+
"The project folder is not writable, so the AI coder name cannot be "
|
|
176
|
+
"stored with the project. Make the folder writable, or copy the "
|
|
177
|
+
"project to the workspace (copy_project_to_workspace) and work on the "
|
|
178
|
+
"copy.")
|
|
179
|
+
|
|
180
|
+
UNSET_HINT = (
|
|
181
|
+
"The first write will ask which name to store AI rows under; you can "
|
|
182
|
+
"set it now with set_project_ai_coder_name.")
|
|
183
|
+
|
|
184
|
+
EARLIER_MARKED_HINT = (
|
|
185
|
+
f"{SIDECAR_NAME} is missing from the project folder, and "
|
|
186
|
+
f"{OLD_SIDECAR_NAME} beside it is marked as moved: it holds only the "
|
|
187
|
+
f"names used before the move, so none of them is used now. "
|
|
188
|
+
+ UNSET_HINT)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class SidecarWriteError(Exception):
|
|
192
|
+
"""The sidecar could not be written; nothing on disk was changed."""
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# What a write did about the earlier file (EarlierFile.status).
|
|
196
|
+
EARLIER_NOTHING = "nothing" # none to mark: absent, already marked,
|
|
197
|
+
# or not one this server rewrites
|
|
198
|
+
EARLIER_MARKED = "marked" # an unmarked version 1 file, marked now
|
|
199
|
+
EARLIER_NOT_MARKED = "not_marked" # one this write could not rewrite
|
|
200
|
+
EARLIER_CREATED = "created" # none there: written already marked
|
|
201
|
+
EARLIER_NOT_CREATED = "not_created" # none there, and none could be written
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class EarlierFile(NamedTuple):
|
|
205
|
+
"""What happened to `qualcoder_mcp.json` in one write."""
|
|
206
|
+
status: str
|
|
207
|
+
path: Path
|
|
208
|
+
held_name: Optional[str] = None # the name it holds, for messages
|
|
209
|
+
error: Optional[str] = None # why it was not marked (a type name)
|
|
210
|
+
# names an older copy stored in it after the move, added to
|
|
211
|
+
# exegete.json's history by the same write
|
|
212
|
+
names_added: Tuple[str, ...] = ()
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class NameStored(NamedTuple):
|
|
216
|
+
"""`store_ai_coder_name`'s answer: the entry and the earlier file."""
|
|
217
|
+
entry: Dict[str, Any]
|
|
218
|
+
earlier: EarlierFile
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _now_iso() -> str:
|
|
222
|
+
"""Timezone-aware local time to the second.
|
|
223
|
+
|
|
224
|
+
The same aware clock QualCoder writes its own dates with
|
|
225
|
+
(`__main__.py:1865` at 9bddf17). The MRU file's naive local time is
|
|
226
|
+
fine for a single-machine hint; this file travels between machines,
|
|
227
|
+
so the offset is part of the value.
|
|
228
|
+
"""
|
|
229
|
+
return datetime.now().astimezone().isoformat(timespec="seconds")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _parse_iso(value: Any) -> Optional[str]:
|
|
233
|
+
"""Return `value` when it parses as ISO 8601, else None.
|
|
234
|
+
|
|
235
|
+
Timestamps are echoed into the conversation, so a hand-edited file
|
|
236
|
+
must not be able to put arbitrary text there. A value that does not
|
|
237
|
+
parse is dropped (null), which never invalidates the file: the name
|
|
238
|
+
is what matters and the timestamp is informational.
|
|
239
|
+
"""
|
|
240
|
+
if not isinstance(value, str) or not value.strip():
|
|
241
|
+
return None
|
|
242
|
+
text = value.strip()
|
|
243
|
+
try:
|
|
244
|
+
datetime.fromisoformat(text.replace("Z", "+00:00"))
|
|
245
|
+
except ValueError:
|
|
246
|
+
return None
|
|
247
|
+
return text
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _entry(name: str, set_at: Optional[str], note: str,
|
|
251
|
+
host_declaration: Optional[str]) -> Dict[str, Any]:
|
|
252
|
+
"""One history entry / the current setting, in field order."""
|
|
253
|
+
return {"name": name, "set_at": set_at, "note": note,
|
|
254
|
+
"host_declaration": host_declaration}
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _validated_entry(raw: Any) -> Optional[Dict[str, Any]]:
|
|
258
|
+
"""Validate one sidecar entry, or None when it is not usable.
|
|
259
|
+
|
|
260
|
+
Every field is validated on READ as well as on write, because the
|
|
261
|
+
file can be hand-edited or arrive from another machine: a name goes
|
|
262
|
+
through `validate_coder_name` so a tampered sidecar can never smuggle
|
|
263
|
+
a control character, a bidi override or a '#####' marker into an
|
|
264
|
+
`owner` column, and the note goes through `validate_coder_note` for
|
|
265
|
+
the same reason (it is echoed into the conversation). Unknown keys
|
|
266
|
+
inside an entry are dropped.
|
|
267
|
+
"""
|
|
268
|
+
if not isinstance(raw, dict):
|
|
269
|
+
return None
|
|
270
|
+
try:
|
|
271
|
+
name = validate_coder_name(raw.get("name"), "ai_coder_name.name")
|
|
272
|
+
except ValueError:
|
|
273
|
+
return None
|
|
274
|
+
note_raw = raw.get("note")
|
|
275
|
+
if note_raw is None:
|
|
276
|
+
note_raw = ""
|
|
277
|
+
try:
|
|
278
|
+
note = validate_coder_note(note_raw, "ai_coder_name.note")
|
|
279
|
+
except ValueError:
|
|
280
|
+
return None
|
|
281
|
+
host = raw.get("host_declaration")
|
|
282
|
+
if host is not None:
|
|
283
|
+
try:
|
|
284
|
+
host = validate_coder_name(host, "ai_coder_name.host_declaration")
|
|
285
|
+
except ValueError:
|
|
286
|
+
return None
|
|
287
|
+
return _entry(name, _parse_iso(raw.get("set_at")), note, host)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
class SidecarState:
|
|
291
|
+
"""What the sidecar says, read fresh from disk.
|
|
292
|
+
|
|
293
|
+
`status` is one of "unset" (no file), "project" (a valid current
|
|
294
|
+
name), "unreadable" (present but not ours to read: not UTF-8, not
|
|
295
|
+
JSON, wrong format, oversized, a symlink, or a field that fails
|
|
296
|
+
validation) and "newer_format" (a format_version we do not write).
|
|
297
|
+
A "newer_format" file whose current entry validates still reports its
|
|
298
|
+
name, because reading it is safe; writing it is not. `earlier_marked`
|
|
299
|
+
is True for a marked earlier file read with no exegete.json beside
|
|
300
|
+
it: "unset", with the names it holds in `history`.
|
|
301
|
+
"""
|
|
302
|
+
|
|
303
|
+
__slots__ = ("status", "entry", "history", "path", "earlier_marked")
|
|
304
|
+
|
|
305
|
+
def __init__(self, status: str, entry: Optional[Dict[str, Any]] = None,
|
|
306
|
+
history: Optional[List[Dict[str, Any]]] = None,
|
|
307
|
+
path: Optional[Path] = None, earlier_marked: bool = False):
|
|
308
|
+
self.status = status
|
|
309
|
+
self.entry = entry
|
|
310
|
+
self.history = list(history or [])
|
|
311
|
+
self.path = path
|
|
312
|
+
self.earlier_marked = earlier_marked
|
|
313
|
+
|
|
314
|
+
@property
|
|
315
|
+
def name(self) -> Optional[str]:
|
|
316
|
+
return self.entry["name"] if self.entry else None
|
|
317
|
+
|
|
318
|
+
@property
|
|
319
|
+
def host_declaration(self) -> Optional[str]:
|
|
320
|
+
return self.entry.get("host_declaration") if self.entry else None
|
|
321
|
+
|
|
322
|
+
@property
|
|
323
|
+
def is_set(self) -> bool:
|
|
324
|
+
return self.status == SIDECAR_SET and self.entry is not None
|
|
325
|
+
|
|
326
|
+
def __repr__(self) -> str: # pragma: no cover - debugging aid
|
|
327
|
+
return f"SidecarState({self.status!r}, name={self.name!r})"
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def sidecar_path(project_folder: Any) -> Path:
|
|
331
|
+
"""The sidecar in use in a project folder.
|
|
332
|
+
|
|
333
|
+
`exegete.json` when it exists in any form (an unreadable one
|
|
334
|
+
included: it is reported, never passed over), else the earlier
|
|
335
|
+
`qualcoder_mcp.json` when that exists, else `exegete.json`, where
|
|
336
|
+
the next write puts the name.
|
|
337
|
+
"""
|
|
338
|
+
new = Path(project_folder) / SIDECAR_NAME
|
|
339
|
+
if os.path.lexists(new):
|
|
340
|
+
return new
|
|
341
|
+
old = Path(project_folder) / OLD_SIDECAR_NAME
|
|
342
|
+
if os.path.lexists(old):
|
|
343
|
+
return old
|
|
344
|
+
return new
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _read_raw(path: Path) -> Optional[Dict[str, Any]]:
|
|
348
|
+
"""The sidecar's JSON object, or None when it is not readable.
|
|
349
|
+
|
|
350
|
+
Refuses a symlink before opening (`lstat` first, the MRU reader's
|
|
351
|
+
discipline: a symlink at this name is either a mistake or a trap, and
|
|
352
|
+
following it would let a write land outside the project folder), caps
|
|
353
|
+
the read in BYTES so a multibyte payload cannot slip under a
|
|
354
|
+
character count, and decodes as `utf-8-sig`: BOM tolerance is
|
|
355
|
+
deliberate here and here only, because this file is meant to be
|
|
356
|
+
hand-editable and Windows editors add one (ruling 12).
|
|
357
|
+
"""
|
|
358
|
+
try:
|
|
359
|
+
st = os.lstat(path)
|
|
360
|
+
except (OSError, ValueError):
|
|
361
|
+
return None
|
|
362
|
+
if stat.S_ISLNK(st.st_mode) or not stat.S_ISREG(st.st_mode):
|
|
363
|
+
return None
|
|
364
|
+
try:
|
|
365
|
+
with open(path, "rb") as f:
|
|
366
|
+
raw = f.read(SIDECAR_READ_MAX_BYTES + 1)
|
|
367
|
+
except OSError:
|
|
368
|
+
return None
|
|
369
|
+
if len(raw) > SIDECAR_READ_MAX_BYTES:
|
|
370
|
+
return None
|
|
371
|
+
try:
|
|
372
|
+
data = json.loads(raw.decode("utf-8-sig"))
|
|
373
|
+
except Exception:
|
|
374
|
+
# Deliberately the whole class, not a list of the failures we
|
|
375
|
+
# thought of. `read_sidecar` promises it never raises, and the
|
|
376
|
+
# list did not hold: CPython's JSON scanner raises RecursionError
|
|
377
|
+
# on deep nesting, which is a RuntimeError and not a ValueError,
|
|
378
|
+
# and 64 KiB of '[' is about 30,000 levels, under the size cap.
|
|
379
|
+
# The damage was not here but in restore_backup, which reads the
|
|
380
|
+
# sidecar again AFTER the folder swap: a COMPLETED restore was
|
|
381
|
+
# reported as a bare internal error with no success flag and no
|
|
382
|
+
# safety-backup path, which is the researcher's only way back.
|
|
383
|
+
# Over-catching costs one thing, classifying an odd file as
|
|
384
|
+
# unreadable, which is the documented fallback for every other
|
|
385
|
+
# malformed shape and names the file in the message (fix round 4).
|
|
386
|
+
return None
|
|
387
|
+
return data if isinstance(data, dict) else None
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def read_sidecar(project_folder: Any) -> SidecarState:
|
|
391
|
+
"""Read the project's sidecar. Never raises, never caches, never writes.
|
|
392
|
+
|
|
393
|
+
Called on every use: a host that recycled the process, and a second
|
|
394
|
+
host editing the same project, both see what is on disk now.
|
|
395
|
+
"""
|
|
396
|
+
path = sidecar_path(project_folder)
|
|
397
|
+
if not os.path.lexists(path):
|
|
398
|
+
return SidecarState(SIDECAR_UNSET, path=path)
|
|
399
|
+
data = _read_raw(path)
|
|
400
|
+
if data is None or data.get("format") != SIDECAR_FORMAT:
|
|
401
|
+
return SidecarState(SIDECAR_UNREADABLE, path=path)
|
|
402
|
+
version = data.get("format_version")
|
|
403
|
+
# The earlier file may be at version 2, the move's mark; a newer one
|
|
404
|
+
# is refused as any newer file is.
|
|
405
|
+
newest = (OLD_SIDECAR_MOVED_VERSION if path.name == OLD_SIDECAR_NAME
|
|
406
|
+
else SIDECAR_FORMAT_VERSION)
|
|
407
|
+
if not isinstance(version, int) or isinstance(version, bool) or version < 1:
|
|
408
|
+
return SidecarState(SIDECAR_UNREADABLE, path=path)
|
|
409
|
+
entry = _validated_entry(data.get("ai_coder_name"))
|
|
410
|
+
if data.get("ai_coder_name") is not None and entry is None:
|
|
411
|
+
# A current setting we cannot validate makes the FILE unreadable:
|
|
412
|
+
# we never repair it silently, because it may hold the history
|
|
413
|
+
# the researcher cares about.
|
|
414
|
+
return SidecarState(SIDECAR_UNREADABLE, path=path)
|
|
415
|
+
# A malformed history never costs the current name: it is
|
|
416
|
+
# informational, and the next set rebuilds it (B1.3).
|
|
417
|
+
history = _validated_history(data.get("ai_coder_name_history"))
|
|
418
|
+
if version > newest:
|
|
419
|
+
return SidecarState(SIDECAR_NEWER_FORMAT, entry, history, path)
|
|
420
|
+
if path.name == OLD_SIDECAR_NAME and _is_marked(data):
|
|
421
|
+
# A marked earlier file alone: exegete.json, which held every
|
|
422
|
+
# name since the move, was removed or lost. The name here is the
|
|
423
|
+
# one from before the move, so it is never used again; the
|
|
424
|
+
# project reads as unset, and the next write asks, as the
|
|
425
|
+
# messages for a damaged exegete.json promise. Its names stay in
|
|
426
|
+
# the history, so rows under them still count as this project's
|
|
427
|
+
# AI work, and the next set carries them into exegete.json.
|
|
428
|
+
return SidecarState(SIDECAR_UNSET, None, _held_entries(entry, history),
|
|
429
|
+
path, earlier_marked=True)
|
|
430
|
+
if entry is None:
|
|
431
|
+
return SidecarState(SIDECAR_UNSET, None, history, path)
|
|
432
|
+
return SidecarState(SIDECAR_SET, entry, history, path)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _validated_history(raw: Any) -> List[Dict[str, Any]]:
|
|
436
|
+
"""A history, every entry validated; empty when any entry fails."""
|
|
437
|
+
history: List[Dict[str, Any]] = []
|
|
438
|
+
if isinstance(raw, list):
|
|
439
|
+
for item in raw:
|
|
440
|
+
validated = _validated_entry(item)
|
|
441
|
+
if validated is None:
|
|
442
|
+
return []
|
|
443
|
+
history.append(validated)
|
|
444
|
+
return history
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _is_marked(data: Dict[str, Any]) -> bool:
|
|
448
|
+
"""Whether an earlier file carries the move's mark (version 2 or
|
|
449
|
+
`moved_to`)."""
|
|
450
|
+
return (data.get("format_version") == OLD_SIDECAR_MOVED_VERSION
|
|
451
|
+
or MOVED_TO_KEY in data)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _held_entries(entry: Optional[Dict[str, Any]],
|
|
455
|
+
history: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
456
|
+
"""A file's history with its current entry last, when the history
|
|
457
|
+
lacks it (a hand-edited file, or a malformed history read as empty)."""
|
|
458
|
+
held = list(history)
|
|
459
|
+
if entry is not None and entry not in held:
|
|
460
|
+
held.append(entry)
|
|
461
|
+
return held
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _encoded_payload(payload: Dict[str, Any]) -> bytes:
|
|
465
|
+
"""The exact bytes a write would put on disk.
|
|
466
|
+
|
|
467
|
+
The trim below measures THIS, not an entry count, because it is what
|
|
468
|
+
the reader measures: `_read_raw` caps at SIDECAR_READ_MAX_BYTES of
|
|
469
|
+
file, and `indent=2` plus preserved unknown keys are what grow the
|
|
470
|
+
file between the two caps.
|
|
471
|
+
"""
|
|
472
|
+
return json.dumps(payload, ensure_ascii=False,
|
|
473
|
+
indent=2).encode("utf-8")
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _inherit_mode_bits(tmp_path: Path, project_folder: Path) -> None:
|
|
477
|
+
"""Give the sidecar `data.qda`'s permission bits when we can read them.
|
|
478
|
+
|
|
479
|
+
mkstemp creates at 0600, which would make a sidecar that a shared
|
|
480
|
+
project's other users cannot read while the database beside it is
|
|
481
|
+
group-readable. Masked to 0o666 so no execute bit is ever set, and a
|
|
482
|
+
no-op on Windows, where chmod only moves the read-only flag
|
|
483
|
+
(ruling 8).
|
|
484
|
+
"""
|
|
485
|
+
if os.name == "nt":
|
|
486
|
+
return
|
|
487
|
+
try:
|
|
488
|
+
mode = stat.S_IMODE(os.stat(project_folder / "data.qda").st_mode)
|
|
489
|
+
except OSError:
|
|
490
|
+
return
|
|
491
|
+
try:
|
|
492
|
+
os.chmod(tmp_path, mode & 0o666)
|
|
493
|
+
except OSError:
|
|
494
|
+
pass
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def write_ai_coder_name(project_folder: Any, name: str, note: str = "",
|
|
498
|
+
host_declaration: Optional[str] = None,
|
|
499
|
+
now: Optional[str] = None) -> Dict[str, Any]:
|
|
500
|
+
"""Store `name` as the project's AI coder name; return the new entry.
|
|
501
|
+
|
|
502
|
+
`store_ai_coder_name` without the earlier file's report.
|
|
503
|
+
|
|
504
|
+
Raises:
|
|
505
|
+
SidecarWriteError: As `store_ai_coder_name`.
|
|
506
|
+
"""
|
|
507
|
+
return store_ai_coder_name(project_folder, name, note=note,
|
|
508
|
+
host_declaration=host_declaration,
|
|
509
|
+
now=now).entry
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def store_ai_coder_name(project_folder: Any, name: str, note: str = "",
|
|
513
|
+
host_declaration: Optional[str] = None,
|
|
514
|
+
now: Optional[str] = None) -> NameStored:
|
|
515
|
+
"""Store `name` as the project's AI coder name.
|
|
516
|
+
|
|
517
|
+
Returns the new entry and what happened to the earlier file, which
|
|
518
|
+
the setter reports: whether it was marked, and when it could not be,
|
|
519
|
+
why and under which name an older copy would go on writing.
|
|
520
|
+
|
|
521
|
+
The MRU write discipline (`_open_mru_tmp`, server.py:80-120), with
|
|
522
|
+
one addition: a single `fsync` before the replace, because this file
|
|
523
|
+
is the only record of a choice the researcher made and re-creating it
|
|
524
|
+
means asking them again. `tempfile.mkstemp` opens O_CREAT|O_EXCL at
|
|
525
|
+
an unpredictable name inside the project folder, so two servers can
|
|
526
|
+
never share a temp name and a symlink pre-planted at a would-be name
|
|
527
|
+
is refused rather than written through; `os.replace` is atomic, so a
|
|
528
|
+
reader sees the old file or the new one, never a partial one; a
|
|
529
|
+
failure unlinks the temp and leaves the existing file byte-identical.
|
|
530
|
+
|
|
531
|
+
Temp litter from a crash (`exegete.json.<random>.tmp`) is never
|
|
532
|
+
reopened and never enumerated by this server; it is harmless and a
|
|
533
|
+
user may delete it.
|
|
534
|
+
|
|
535
|
+
The name is always written to `exegete.json`. When the project still
|
|
536
|
+
has the earlier `qualcoder_mcp.json` (the first write after 0.14, or
|
|
537
|
+
a restore of an older backup), its name, history and other keys are
|
|
538
|
+
carried into the new file, and the earlier file is then marked as
|
|
539
|
+
moved (`_mark_earlier_file`), so that 0.12 to 0.14 refuse to write
|
|
540
|
+
it. A failure to mark it is logged and reported in the answer, and
|
|
541
|
+
every later owner-bearing write tries again (`settle_earlier_file`);
|
|
542
|
+
the name itself is already stored.
|
|
543
|
+
|
|
544
|
+
Raises:
|
|
545
|
+
SidecarWriteError: With a message for the caller to return. The
|
|
546
|
+
files on disk are unchanged.
|
|
547
|
+
"""
|
|
548
|
+
folder = Path(project_folder)
|
|
549
|
+
path = sidecar_path(folder)
|
|
550
|
+
target = folder / SIDECAR_NAME
|
|
551
|
+
if os.path.lexists(path):
|
|
552
|
+
try:
|
|
553
|
+
st = os.lstat(path)
|
|
554
|
+
except OSError as e:
|
|
555
|
+
raise SidecarWriteError(unreadable_message(path)) from e
|
|
556
|
+
if stat.S_ISLNK(st.st_mode) or not stat.S_ISREG(st.st_mode):
|
|
557
|
+
raise SidecarWriteError(unreadable_message(path))
|
|
558
|
+
existing = _read_raw(path) if os.path.lexists(path) else {}
|
|
559
|
+
if existing is None:
|
|
560
|
+
raise SidecarWriteError(unreadable_message(path))
|
|
561
|
+
|
|
562
|
+
state = read_sidecar(folder)
|
|
563
|
+
if state.status == SIDECAR_NEWER_FORMAT:
|
|
564
|
+
raise SidecarWriteError(newer_format_message(state.path))
|
|
565
|
+
if state.status == SIDECAR_UNREADABLE:
|
|
566
|
+
raise SidecarWriteError(unreadable_message(state.path))
|
|
567
|
+
|
|
568
|
+
# Validated HERE as well as at the tool layer, because this writes
|
|
569
|
+
# the string every later AI row is attributed to: one gate on the
|
|
570
|
+
# way in, one on the way out (_validated_entry), so no path can put
|
|
571
|
+
# a name in an owner column that the validator would refuse
|
|
572
|
+
# (fix round 4).
|
|
573
|
+
name = validate_coder_name(name, "name")
|
|
574
|
+
note = validate_coder_note(note, "note")
|
|
575
|
+
entry = _entry(name, now or _now_iso(), note, host_declaration)
|
|
576
|
+
added: List[Dict[str, Any]] = []
|
|
577
|
+
if path.name == OLD_SIDECAR_NAME:
|
|
578
|
+
# The move: the name the earlier file holds stays in the history
|
|
579
|
+
# even when its own history lacks it, so its rows still count.
|
|
580
|
+
history = _held_entries(state.entry, state.history)
|
|
581
|
+
else:
|
|
582
|
+
# A file an older copy wrote beside exegete.json: the names it
|
|
583
|
+
# holds join the history in this write, before it is marked.
|
|
584
|
+
history = list(state.history)
|
|
585
|
+
added = _missing(_entries_beside(folder, state), history)
|
|
586
|
+
history += added
|
|
587
|
+
history.append(entry)
|
|
588
|
+
if len(history) > HISTORY_CAP:
|
|
589
|
+
# Oldest first, and never the entry we are writing.
|
|
590
|
+
history = history[len(history) - HISTORY_CAP:]
|
|
591
|
+
|
|
592
|
+
# Unknown top-level keys are preserved: a later feature, or a
|
|
593
|
+
# researcher's own annotation, survives a name change (the
|
|
594
|
+
# "keeping other keys intact" pattern of view_av.py:1369-1376).
|
|
595
|
+
payload: Dict[str, Any] = dict(existing) if isinstance(existing, dict) else {}
|
|
596
|
+
# Carried over from a marked earlier file, the pointer would point at
|
|
597
|
+
# the file itself.
|
|
598
|
+
payload.pop(MOVED_TO_KEY, None)
|
|
599
|
+
payload["format"] = SIDECAR_FORMAT
|
|
600
|
+
payload["format_version"] = SIDECAR_FORMAT_VERSION
|
|
601
|
+
payload["written_by"] = f"{names.DISTRIBUTION} {_package_version()}"
|
|
602
|
+
payload["updated"] = entry["set_at"]
|
|
603
|
+
payload["ai_coder_name"] = entry
|
|
604
|
+
payload["ai_coder_name_history"] = history
|
|
605
|
+
|
|
606
|
+
encoded = _trimmed_encoding(payload, path)
|
|
607
|
+
try:
|
|
608
|
+
_replace_atomically(folder, target, encoded)
|
|
609
|
+
except OSError as e:
|
|
610
|
+
raise SidecarWriteError(
|
|
611
|
+
f"The AI coder name could not be stored with the project "
|
|
612
|
+
f"({type(e).__name__}). Nothing was changed.") from e
|
|
613
|
+
earlier = _mark_earlier_file(folder)
|
|
614
|
+
return NameStored(entry, earlier._replace(names_added=_names(added)))
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _trimmed_encoding(payload: Dict[str, Any], path: Path) -> bytes:
|
|
618
|
+
"""The bytes to write, the history trimmed to the byte budget.
|
|
619
|
+
|
|
620
|
+
Trim in the unit the READER measures, on the bytes that will
|
|
621
|
+
actually be written. Oldest first, and never the last entry, the one
|
|
622
|
+
being written: a name change must not be refused because the
|
|
623
|
+
project has a long past.
|
|
624
|
+
|
|
625
|
+
Raises:
|
|
626
|
+
SidecarWriteError: When a one-entry history still does not fit.
|
|
627
|
+
"""
|
|
628
|
+
encoded = _encoded_payload(payload)
|
|
629
|
+
while (len(encoded) > SIDECAR_WRITE_MAX_BYTES
|
|
630
|
+
and len(payload["ai_coder_name_history"]) > 1):
|
|
631
|
+
payload["ai_coder_name_history"] = \
|
|
632
|
+
payload["ai_coder_name_history"][1:]
|
|
633
|
+
encoded = _encoded_payload(payload)
|
|
634
|
+
if len(encoded) > SIDECAR_WRITE_MAX_BYTES:
|
|
635
|
+
# A one-entry history still does not fit, so the bulk is in the
|
|
636
|
+
# unknown top-level keys this write preserves. Refuse rather than
|
|
637
|
+
# write a file the reader will reject, and say where the size is.
|
|
638
|
+
raise SidecarWriteError(oversized_message(path))
|
|
639
|
+
return encoded
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def _entries_beside(folder: Path, state: SidecarState) -> List[Dict[str, Any]]:
|
|
643
|
+
"""The entries an unmarked earlier file beside exegete.json holds.
|
|
644
|
+
|
|
645
|
+
Empty unless exegete.json is in use and readable at this version and
|
|
646
|
+
the earlier file is one to mark (`_unmarked_earlier_file`): then its
|
|
647
|
+
validated history, and its current name when that history lacks it.
|
|
648
|
+
"""
|
|
649
|
+
if not _earlier_file_to_settle(folder, state):
|
|
650
|
+
return []
|
|
651
|
+
data = _unmarked_earlier_file(folder)
|
|
652
|
+
if data is None:
|
|
653
|
+
return []
|
|
654
|
+
return _held_entries(_validated_entry(data.get("ai_coder_name")),
|
|
655
|
+
_validated_history(data.get(
|
|
656
|
+
"ai_coder_name_history")))
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def _missing(entries: List[Dict[str, Any]],
|
|
660
|
+
history: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
661
|
+
"""The entries not already in `history`, in their order (an entry
|
|
662
|
+
carried at the move is equal to its copy there, field for field)."""
|
|
663
|
+
return [e for i, e in enumerate(entries)
|
|
664
|
+
if e not in history and e not in entries[:i]]
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def _names(entries: List[Dict[str, Any]]) -> Tuple[str, ...]:
|
|
668
|
+
"""The distinct names in `entries`, in order."""
|
|
669
|
+
return tuple(dict.fromkeys(e["name"] for e in entries))
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def _add_to_history(folder: Path) -> List[Dict[str, Any]]:
|
|
673
|
+
"""Add the names an unmarked earlier file holds to exegete.json's
|
|
674
|
+
history, keeping its current name; return the entries added.
|
|
675
|
+
|
|
676
|
+
Read fresh, and written as a name change is (the same byte budget and
|
|
677
|
+
atomic replace), so a failure leaves exegete.json as it was.
|
|
678
|
+
|
|
679
|
+
Raises:
|
|
680
|
+
SidecarWriteError: exegete.json could not be read or written.
|
|
681
|
+
"""
|
|
682
|
+
target = folder / SIDECAR_NAME
|
|
683
|
+
state = read_sidecar(folder)
|
|
684
|
+
added = _missing(_entries_beside(folder, state), state.history)
|
|
685
|
+
if not added:
|
|
686
|
+
return []
|
|
687
|
+
existing = _read_raw(target)
|
|
688
|
+
if existing is None:
|
|
689
|
+
raise SidecarWriteError(unreadable_message(target))
|
|
690
|
+
payload = dict(existing)
|
|
691
|
+
payload["written_by"] = f"{names.DISTRIBUTION} {_package_version()}"
|
|
692
|
+
payload["ai_coder_name_history"] = list(state.history) + added
|
|
693
|
+
encoded = _trimmed_encoding(payload, target)
|
|
694
|
+
try:
|
|
695
|
+
_replace_atomically(folder, target, encoded)
|
|
696
|
+
except OSError as e:
|
|
697
|
+
raise SidecarWriteError(
|
|
698
|
+
f"{SIDECAR_NAME} could not be written "
|
|
699
|
+
f"({type(e).__name__}).") from e
|
|
700
|
+
return added
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def _replace_atomically(folder: Path, target: Path, encoded: bytes) -> None:
|
|
704
|
+
"""Write `encoded` at `target` in one `os.replace`, or not at all.
|
|
705
|
+
|
|
706
|
+
Raises OSError after removing its temp file; `target` is then
|
|
707
|
+
byte-identical to what it was.
|
|
708
|
+
"""
|
|
709
|
+
tmp: Optional[Path] = None
|
|
710
|
+
try:
|
|
711
|
+
fd, tmp_name = tempfile.mkstemp(dir=str(folder),
|
|
712
|
+
prefix=f"{target.name}.",
|
|
713
|
+
suffix=".tmp")
|
|
714
|
+
tmp = Path(tmp_name)
|
|
715
|
+
# The descriptor mkstemp returned belongs to nobody until
|
|
716
|
+
# `os.fdopen` takes it. If that call raises, POSIX still lets
|
|
717
|
+
# the cleanup unlink the open temp file, but Windows refuses,
|
|
718
|
+
# so the sidecar folder keeps the litter (fix round 5).
|
|
719
|
+
try:
|
|
720
|
+
handle = os.fdopen(fd, "wb")
|
|
721
|
+
except BaseException:
|
|
722
|
+
os.close(fd)
|
|
723
|
+
raise
|
|
724
|
+
with handle as f:
|
|
725
|
+
f.write(encoded)
|
|
726
|
+
f.flush()
|
|
727
|
+
os.fsync(f.fileno())
|
|
728
|
+
_inherit_mode_bits(tmp, folder)
|
|
729
|
+
os.replace(str(tmp), str(target))
|
|
730
|
+
except OSError:
|
|
731
|
+
if tmp is not None:
|
|
732
|
+
try:
|
|
733
|
+
tmp.unlink()
|
|
734
|
+
except OSError:
|
|
735
|
+
pass
|
|
736
|
+
raise
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def _unmarked_earlier_file(folder: Path) -> Optional[Dict[str, Any]]:
|
|
740
|
+
"""The earlier file's contents when it is one to mark, else None.
|
|
741
|
+
|
|
742
|
+
That is a regular file this server can read, in the shared format, at
|
|
743
|
+
version 1, which is what 0.12 to 0.14 write. A file already marked,
|
|
744
|
+
an unreadable one (its bytes may be the only history), a newer one
|
|
745
|
+
and anything that is not a regular file are never rewritten.
|
|
746
|
+
"""
|
|
747
|
+
old = folder / OLD_SIDECAR_NAME
|
|
748
|
+
try:
|
|
749
|
+
st = os.lstat(old)
|
|
750
|
+
except (OSError, ValueError):
|
|
751
|
+
return None
|
|
752
|
+
if stat.S_ISLNK(st.st_mode) or not stat.S_ISREG(st.st_mode):
|
|
753
|
+
return None
|
|
754
|
+
data = _read_raw(old)
|
|
755
|
+
if (data is None or data.get("format") != SIDECAR_FORMAT
|
|
756
|
+
or data.get("format_version") != SIDECAR_FORMAT_VERSION
|
|
757
|
+
or isinstance(data.get("format_version"), bool)):
|
|
758
|
+
return None
|
|
759
|
+
return data
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def _held_name(data: Dict[str, Any]) -> Optional[str]:
|
|
763
|
+
"""The name a sidecar's current entry holds, when it validates."""
|
|
764
|
+
entry = _validated_entry(data.get("ai_coder_name"))
|
|
765
|
+
return entry["name"] if entry else None
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def _mark_earlier_file(folder: Path) -> EarlierFile:
|
|
769
|
+
"""Mark the earlier `qualcoder_mcp.json` as moved; say what happened.
|
|
770
|
+
|
|
771
|
+
With nothing at that name (a project Exegete names first, or a file
|
|
772
|
+
removed since), one is written already marked, holding no name
|
|
773
|
+
(`_write_marker`), so that 0.12 to 0.14 refuse and say to upgrade
|
|
774
|
+
rather than ask for a name of their own. Until v1.0, like the other
|
|
775
|
+
support for the old name.
|
|
776
|
+
|
|
777
|
+
Only a file `_unmarked_earlier_file` returns is rewritten: its keys
|
|
778
|
+
are kept (the name it held at the move included, so 0.12 to 0.14
|
|
779
|
+
still recognise the rows it named), and it gains format_version 2
|
|
780
|
+
and `moved_to`, which those versions refuse to write. When the
|
|
781
|
+
rewrite fails (the file locked in the Finder, read-only on Windows,
|
|
782
|
+
held by a sync program), the answer says so, with the name an older
|
|
783
|
+
copy would go on writing under, and nothing else changes.
|
|
784
|
+
"""
|
|
785
|
+
old = folder / OLD_SIDECAR_NAME
|
|
786
|
+
if not os.path.lexists(old):
|
|
787
|
+
return _write_marker(folder)
|
|
788
|
+
data = _unmarked_earlier_file(folder)
|
|
789
|
+
if data is None:
|
|
790
|
+
return EarlierFile(EARLIER_NOTHING, old)
|
|
791
|
+
held = _held_name(data)
|
|
792
|
+
data["format_version"] = OLD_SIDECAR_MOVED_VERSION
|
|
793
|
+
data[MOVED_TO_KEY] = SIDECAR_NAME
|
|
794
|
+
data["written_by"] = f"{names.DISTRIBUTION} {_package_version()}"
|
|
795
|
+
try:
|
|
796
|
+
_replace_atomically(folder, old, _encoded_payload(data))
|
|
797
|
+
except OSError as e:
|
|
798
|
+
logger.warning("%s could not be marked as moved (%s); every "
|
|
799
|
+
"write that stores a name tries again",
|
|
800
|
+
OLD_SIDECAR_NAME, type(e).__name__)
|
|
801
|
+
return EarlierFile(EARLIER_NOT_MARKED, old, held, type(e).__name__)
|
|
802
|
+
return EarlierFile(EARLIER_MARKED, old, held)
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
def _write_marker(folder: Path) -> EarlierFile:
|
|
806
|
+
"""Write `qualcoder_mcp.json` already marked, holding no name.
|
|
807
|
+
|
|
808
|
+
Version 2 and `moved_to`, and nothing else of the format's: 0.12 to
|
|
809
|
+
0.14 check the version first, so they report the file as written by
|
|
810
|
+
a newer version and refuse to write it or any row, and Exegete never
|
|
811
|
+
takes a name from it (a marked file alone reads as unset). Never
|
|
812
|
+
written over a file that appeared meanwhile (`_create_exclusively`).
|
|
813
|
+
"""
|
|
814
|
+
old = folder / OLD_SIDECAR_NAME
|
|
815
|
+
marker = {"format": SIDECAR_FORMAT,
|
|
816
|
+
"format_version": OLD_SIDECAR_MOVED_VERSION,
|
|
817
|
+
MOVED_TO_KEY: SIDECAR_NAME,
|
|
818
|
+
"written_by": f"{names.DISTRIBUTION} {_package_version()}"}
|
|
819
|
+
try:
|
|
820
|
+
created = _create_exclusively(folder, old, _encoded_payload(marker))
|
|
821
|
+
except OSError as e:
|
|
822
|
+
logger.warning("%s could not be written beside %s (%s); every "
|
|
823
|
+
"write that stores a name tries again",
|
|
824
|
+
OLD_SIDECAR_NAME, SIDECAR_NAME, type(e).__name__)
|
|
825
|
+
return EarlierFile(EARLIER_NOT_CREATED, old, None, type(e).__name__)
|
|
826
|
+
return EarlierFile(EARLIER_CREATED if created else EARLIER_NOTHING, old)
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
def _create_exclusively(folder: Path, target: Path, encoded: bytes) -> bool:
|
|
830
|
+
"""Write `encoded` at `target` only if nothing is there; True if so.
|
|
831
|
+
|
|
832
|
+
The temp file of `_replace_atomically`, then a hard link to the
|
|
833
|
+
target's name, which fails rather than replace a file an older copy
|
|
834
|
+
wrote in the same instant. Where the file system has no hard links
|
|
835
|
+
(some shared and removable drives), a replace after a last look, and
|
|
836
|
+
only while the name is still free.
|
|
837
|
+
|
|
838
|
+
Raises:
|
|
839
|
+
OSError: The file could not be written; nothing is left behind.
|
|
840
|
+
"""
|
|
841
|
+
tmp: Optional[Path] = None
|
|
842
|
+
try:
|
|
843
|
+
fd, tmp_name = tempfile.mkstemp(dir=str(folder),
|
|
844
|
+
prefix=f"{target.name}.",
|
|
845
|
+
suffix=".tmp")
|
|
846
|
+
tmp = Path(tmp_name)
|
|
847
|
+
try:
|
|
848
|
+
handle = os.fdopen(fd, "wb")
|
|
849
|
+
except BaseException:
|
|
850
|
+
os.close(fd)
|
|
851
|
+
raise
|
|
852
|
+
with handle as f:
|
|
853
|
+
f.write(encoded)
|
|
854
|
+
f.flush()
|
|
855
|
+
os.fsync(f.fileno())
|
|
856
|
+
_inherit_mode_bits(tmp, folder)
|
|
857
|
+
try:
|
|
858
|
+
os.link(str(tmp), str(target))
|
|
859
|
+
except FileExistsError:
|
|
860
|
+
return False
|
|
861
|
+
except OSError:
|
|
862
|
+
if os.path.lexists(target):
|
|
863
|
+
return False
|
|
864
|
+
os.replace(str(tmp), str(target))
|
|
865
|
+
tmp = None
|
|
866
|
+
return True
|
|
867
|
+
finally:
|
|
868
|
+
if tmp is not None:
|
|
869
|
+
try:
|
|
870
|
+
tmp.unlink()
|
|
871
|
+
except OSError:
|
|
872
|
+
pass
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
def _earlier_file_to_settle(folder: Path, state: SidecarState) -> bool:
|
|
876
|
+
"""Whether exegete.json is in use and readable at this version, the
|
|
877
|
+
one case in which the earlier file beside it is kept marked."""
|
|
878
|
+
return (state.path is not None and state.path.name == SIDECAR_NAME
|
|
879
|
+
and state.status in (SIDECAR_SET, SIDECAR_UNSET))
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def settle_earlier_file(project_folder: Any,
|
|
883
|
+
state: Optional[SidecarState] = None) -> EarlierFile:
|
|
884
|
+
"""Keep the earlier file beside exegete.json marked. Never raises.
|
|
885
|
+
|
|
886
|
+
The retry for a mark that failed, and the mark for a file an older
|
|
887
|
+
copy of the server wrote beside exegete.json after the move, whose
|
|
888
|
+
names first join exegete.json's history (in one write of it, before
|
|
889
|
+
the mark), so its rows count as this project's AI work. With nothing
|
|
890
|
+
at the old name, the marker is written (`_write_marker`). Called
|
|
891
|
+
before every owner-bearing write (`server._resolve_write_owner`),
|
|
892
|
+
after the checks that may refuse it, so a refused write still writes
|
|
893
|
+
nothing. Acts only while exegete.json is in use and readable at this
|
|
894
|
+
version (`state`, read fresh when not given).
|
|
895
|
+
"""
|
|
896
|
+
folder = Path(project_folder)
|
|
897
|
+
old = folder / OLD_SIDECAR_NAME
|
|
898
|
+
try:
|
|
899
|
+
if state is None:
|
|
900
|
+
state = read_sidecar(folder)
|
|
901
|
+
if not _earlier_file_to_settle(folder, state):
|
|
902
|
+
return EarlierFile(EARLIER_NOTHING, old)
|
|
903
|
+
added: List[Dict[str, Any]] = []
|
|
904
|
+
if _missing(_entries_beside(folder, state), state.history):
|
|
905
|
+
try:
|
|
906
|
+
added = _add_to_history(folder)
|
|
907
|
+
except SidecarWriteError as e:
|
|
908
|
+
# Not marked either: marked, the file would never be
|
|
909
|
+
# read for its names again. The next write tries both.
|
|
910
|
+
logger.warning("The names in %s could not be added to "
|
|
911
|
+
"%s; it is left unmarked for now",
|
|
912
|
+
OLD_SIDECAR_NAME, SIDECAR_NAME)
|
|
913
|
+
return EarlierFile(EARLIER_NOT_MARKED, old, None,
|
|
914
|
+
type(e.__cause__ or e).__name__)
|
|
915
|
+
return _mark_earlier_file(folder)._replace(
|
|
916
|
+
names_added=_names(added))
|
|
917
|
+
except Exception as e: # noqa: BLE001
|
|
918
|
+
# A write must never fail on this: the name it uses is right, and
|
|
919
|
+
# the next write tries again.
|
|
920
|
+
logger.warning("%s could not be checked (%s)", OLD_SIDECAR_NAME,
|
|
921
|
+
type(e).__name__)
|
|
922
|
+
return EarlierFile(EARLIER_NOT_MARKED, old, None, type(e).__name__)
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
def unmarked_earlier_file(project_folder: Any,
|
|
926
|
+
state: Optional[SidecarState] = None
|
|
927
|
+
) -> Optional[EarlierFile]:
|
|
928
|
+
"""The earlier file when exegete.json is in use and it is unmarked.
|
|
929
|
+
|
|
930
|
+
A read: it never writes. The project reads report it, so that a mark
|
|
931
|
+
that failed stays visible until a write makes it or the researcher
|
|
932
|
+
unlocks or removes the file.
|
|
933
|
+
"""
|
|
934
|
+
folder = Path(project_folder)
|
|
935
|
+
if state is None:
|
|
936
|
+
state = read_sidecar(folder)
|
|
937
|
+
if not _earlier_file_to_settle(folder, state):
|
|
938
|
+
return None
|
|
939
|
+
data = _unmarked_earlier_file(folder)
|
|
940
|
+
if data is None:
|
|
941
|
+
return None
|
|
942
|
+
return EarlierFile(EARLIER_NOT_MARKED, folder / OLD_SIDECAR_NAME,
|
|
943
|
+
_held_name(data))
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
def _package_version() -> str:
|
|
947
|
+
from . import __version__
|
|
948
|
+
return __version__
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
def mismatch(env: Optional[str], current: Optional[Dict[str, Any]]) -> bool:
|
|
952
|
+
"""True when this host's declaration conflicts with the project (c2).
|
|
953
|
+
|
|
954
|
+
Rule c2 (ruling 1): a write is refused if and only if this host
|
|
955
|
+
declares a name AND the declaration is neither the project's current
|
|
956
|
+
name nor the declaration recorded when that name was set. So an
|
|
957
|
+
undeclared host never nags, a host whose declaration was acknowledged
|
|
958
|
+
once never nags again, and a host that declares a different name from
|
|
959
|
+
the one the project uses asks the user which to use.
|
|
960
|
+
"""
|
|
961
|
+
if env is None:
|
|
962
|
+
return False
|
|
963
|
+
if current is None:
|
|
964
|
+
return False
|
|
965
|
+
if env == current.get("name"):
|
|
966
|
+
return False
|
|
967
|
+
if env == current.get("host_declaration"):
|
|
968
|
+
return False
|
|
969
|
+
return True
|
|
970
|
+
|
|
971
|
+
|
|
972
|
+
def host_declaration(environ: Optional[Dict[str, str]] = None) -> Optional[str]:
|
|
973
|
+
"""This host's declared AI coder name, or None.
|
|
974
|
+
|
|
975
|
+
Invalid values are treated as absent here: `main()` validates the
|
|
976
|
+
variable at start-up and refuses to start on a bad one, so a bad
|
|
977
|
+
value reaching this function means the server was started another way
|
|
978
|
+
(a test, an embedded use); a declaration we would refuse to write is
|
|
979
|
+
not one to compare against either.
|
|
980
|
+
"""
|
|
981
|
+
reading = env_settings.read("ai_coder_name", environ)
|
|
982
|
+
raw = reading.value
|
|
983
|
+
if raw is None:
|
|
984
|
+
return None
|
|
985
|
+
try:
|
|
986
|
+
return validate_coder_name(raw, reading.name)
|
|
987
|
+
except ValueError:
|
|
988
|
+
return None
|
|
989
|
+
|
|
990
|
+
|
|
991
|
+
def ai_coder_names_for_project(
|
|
992
|
+
project_folder: Any,
|
|
993
|
+
environ: Optional[Dict[str, str]] = None) -> Tuple[str, ...]:
|
|
994
|
+
"""The names this server treats as its own AI work in this project.
|
|
995
|
+
|
|
996
|
+
One definition, used by every feature that needs to tell this
|
|
997
|
+
server's rows from another coder's (H3): the collateral warnings of a
|
|
998
|
+
cascade preview, the coder roles of a comparison, and the flagship's
|
|
999
|
+
pseudonymisation next. Ordered, no duplicates:
|
|
1000
|
+
|
|
1001
|
+
1. the project's current AI coder name, when set;
|
|
1002
|
+
2. every name in the project's history, oldest first, then the
|
|
1003
|
+
names in a qualcoder_mcp.json an older copy wrote beside
|
|
1004
|
+
exegete.json and no write has marked yet (v0.14.1);
|
|
1005
|
+
3. `DEFAULT_AI_CODER_NAME`, always, because every pre-0.12 row this
|
|
1006
|
+
server wrote carries it and a name change never re-attributes
|
|
1007
|
+
rows;
|
|
1008
|
+
4. this host's declaration, when set and valid, because v0.11 wrote
|
|
1009
|
+
under it machine-wide.
|
|
1010
|
+
|
|
1011
|
+
"AI Agent" enters the set only through those rules, that is, when the
|
|
1012
|
+
researcher chose it for this project or this host declares it; it is
|
|
1013
|
+
never included on its own. "MCP Import" is an import label, not a
|
|
1014
|
+
coding owner, and is never included.
|
|
1015
|
+
|
|
1016
|
+
Never refuses and never asks: reads do not ask, so an unset or
|
|
1017
|
+
unreadable sidecar yields the built-in default (plus the declaration
|
|
1018
|
+
when there is one).
|
|
1019
|
+
"""
|
|
1020
|
+
names: List[str] = []
|
|
1021
|
+
|
|
1022
|
+
def add(value: Optional[str]) -> None:
|
|
1023
|
+
if value and value not in names:
|
|
1024
|
+
names.append(value)
|
|
1025
|
+
|
|
1026
|
+
state = read_sidecar(project_folder)
|
|
1027
|
+
add(state.name)
|
|
1028
|
+
for item in state.history:
|
|
1029
|
+
add(item.get("name"))
|
|
1030
|
+
# A file an older copy wrote beside exegete.json, not yet marked:
|
|
1031
|
+
# its names are this project's AI work before any write adds them
|
|
1032
|
+
for item in _entries_beside(Path(project_folder), state):
|
|
1033
|
+
add(item.get("name"))
|
|
1034
|
+
add(DEFAULT_AI_CODER_NAME)
|
|
1035
|
+
add(host_declaration(environ))
|
|
1036
|
+
return tuple(names)
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
def known_ai_set(project_folder: Any,
|
|
1040
|
+
environ: Optional[Dict[str, str]] = None) -> Tuple[str, ...]:
|
|
1041
|
+
"""The names the ask may propose and probe for, ordered.
|
|
1042
|
+
|
|
1043
|
+
Narrower than `ai_coder_names_for_project`: the migration probe of
|
|
1044
|
+
B1.12 asks whether a project already holds AI rows, and the answer
|
|
1045
|
+
may only ever name the built-in default, QualCoder 4.0's assistant
|
|
1046
|
+
string and this host's declaration. It never selects distinct owners,
|
|
1047
|
+
so it can never enumerate a human or a hidden coder.
|
|
1048
|
+
"""
|
|
1049
|
+
names: List[str] = [DEFAULT_AI_CODER_NAME, KNOWN_AI_ASSISTANT_OWNER]
|
|
1050
|
+
declared = host_declaration(environ)
|
|
1051
|
+
if declared and declared not in names:
|
|
1052
|
+
names.append(declared)
|
|
1053
|
+
return tuple(names)
|
|
1054
|
+
|
|
1055
|
+
|
|
1056
|
+
def folder_is_writable(project_folder: Any) -> bool:
|
|
1057
|
+
"""Whether a sidecar can be created in this folder (ruling 2).
|
|
1058
|
+
|
|
1059
|
+
`os.access` answers with the real uid, which is what mkstemp will
|
|
1060
|
+
use. On Windows it reports the read-only attribute only, which is the
|
|
1061
|
+
same answer NTFS permissions would give for the common cases; a
|
|
1062
|
+
genuine ACL refusal surfaces as the write error instead.
|
|
1063
|
+
"""
|
|
1064
|
+
return os.access(str(project_folder), os.W_OK | os.X_OK)
|
|
1065
|
+
|
|
1066
|
+
|
|
1067
|
+
def echoed_history(state: SidecarState) -> List[Dict[str, Any]]:
|
|
1068
|
+
"""The last HISTORY_ECHO entries, newest last (ruling 11)."""
|
|
1069
|
+
return state.history[-HISTORY_ECHO:]
|
|
1070
|
+
|
|
1071
|
+
|
|
1072
|
+
def normalise_for_case_compare(value: str) -> str:
|
|
1073
|
+
"""Casefold plus NFC, for the setter's case-only WARNING alone.
|
|
1074
|
+
|
|
1075
|
+
Coder names are compared EXACTLY everywhere a decision depends on
|
|
1076
|
+
them (X2): `coder_names.name` is TEXT UNIQUE under SQLite's BINARY
|
|
1077
|
+
collation (app.py:1470-1475) and every upstream owner match is exact
|
|
1078
|
+
(ai_mcp_server.py:1603-1604, :3235-3240). This helper exists only so
|
|
1079
|
+
the setter can TELL the user that two names differ by letter case;
|
|
1080
|
+
it never decides anything.
|
|
1081
|
+
"""
|
|
1082
|
+
return unicodedata.normalize("NFC", value).casefold()
|