riskrails 4.1.0 → 4.3.0
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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +25 -15
- package/agents/ai-reviewer.md.tmpl +14 -1
- package/ai/agents/implementer.md +6 -0
- package/ai/agents/reviewer.md +8 -1
- package/bin/ansi.js +12 -0
- package/bin/menu.js +71 -12
- package/bin/riskrails.js +240 -14
- package/bin/uninstall.js +115 -0
- package/bin/wizard.js +72 -0
- package/hooks/AGENTS.md +0 -6
- package/hooks/CLAUDE.md +0 -6
- package/hooks/context-guard.py +11 -1
- package/hooks/dialog-gate.py +3 -2
- package/hooks/guard/decision.py +4 -3
- package/hooks/guard/dialog.py +12 -2
- package/hooks/guard/ere.py +29 -10
- package/hooks/guard/git.py +2 -1
- package/hooks/guard/paths.py +48 -1406
- package/hooks/guard/paths_base.py +66 -0
- package/hooks/guard/paths_bash.py +176 -0
- package/hooks/guard/paths_classify.py +205 -0
- package/hooks/guard/paths_fs.py +137 -0
- package/hooks/guard/paths_human.py +105 -0
- package/hooks/guard/paths_interp.py +162 -0
- package/hooks/guard/paths_movers.py +393 -0
- package/hooks/guard/paths_shell.py +288 -0
- package/hooks/runtime-gate.py +87 -0
- package/hooks/update_notice.py +23 -8
- package/package.json +1 -1
- package/skills/AGENTS.md +0 -6
- package/skills/CLAUDE.md +0 -6
- package/skills/ai-init/templates/.ai/policies/coding.md +9 -0
- package/skills/ai-init/templates/gitignore.snippet +2 -0
- package/skills/ai-task/memory_cleanup.py +213 -0
- package/skills/ai-task/memory_compact.py +25 -0
- package/skills/ai-task/memory_config.py +120 -0
- package/skills/ai-task/memory_export.py +246 -0
- package/skills/ai-task/memory_format.py +7 -3
- package/skills/ai-task/memory_hook.py +172 -0
- package/skills/ai-task/memory_io.py +122 -0
- package/skills/ai-task/memory_migrate.py +187 -0
- package/skills/ai-task/memory_packages.py +83 -0
- package/skills/ai-task/memory_risks.py +84 -0
- package/skills/ai-task/memory_status.py +76 -0
- package/skills/ai-task/memory_sync.py +20 -1481
- package/skills/ai-task/memory_verify.py +316 -0
- package/skills/ai-task/rr_state/cli.py +1 -0
- package/skills/ai-task/rr_state/close.py +2 -2
- package/skills/ai-task/rr_state/dialog.py +32 -7
- package/skills/ai-task/rr_state/dialog_cmd.py +38 -5
- package/skills/ai-task/rr_state/gate.py +10 -4
- package/skills/ai-task/rr_state/labels.py +33 -4
- package/skills/ai-task/rr_state/memory_cmd.py +84 -64
- package/skills/ai-task/rr_state/questions_cmd.py +5 -4
- package/skills/ai-task/rr_state/report.py +73 -10
- package/skills/ai-task/rr_state/report_cmd.py +2 -0
- package/skills/ai-task/rr_state/review.py +18 -14
- package/skills/ai-task/rr_state/start.py +12 -9
- package/skills/ai-task/rr_state/steps.py +16 -7
- package/skills/ai-task/rr_state/verify.py +17 -12
- package/skills/project-update/history/blobs/dac90781793b8a1c8e5f3e0307665b6394d922140c3831c5508d019f79e1ed0a +41 -0
- package/skills/project-update/history/blobs/fa0cb370b024e1a30e1355912032657b08f609e47773a603c193b1e609b0f997 +8 -0
- package/skills/project-update/history/index.json +8 -4
- package/skills/project-update/rr_update/__init__.py +1 -0
- package/skills/project-update/rr_update/apply.py +188 -0
- package/skills/project-update/rr_update/blocks.py +38 -0
- package/skills/project-update/rr_update/build.py +96 -0
- package/skills/project-update/rr_update/cleanup.py +194 -0
- package/skills/project-update/rr_update/cli.py +102 -0
- package/skills/project-update/rr_update/files.py +106 -0
- package/skills/project-update/rr_update/gate.py +60 -0
- package/skills/project-update/rr_update/hints.py +128 -0
- package/skills/project-update/rr_update/history.py +117 -0
- package/skills/project-update/rr_update/kept.py +120 -0
- package/skills/project-update/rr_update/paths.py +108 -0
- package/skills/project-update/rr_update/plan.py +123 -0
- package/skills/project-update/rr_update/report.py +51 -0
- package/skills/project-update/rr_update/rules.py +178 -0
- package/skills/project-update/rr_update/shared.py +74 -0
- package/skills/project-update/rr_update/siblings.py +37 -0
- package/skills/project-update/rr_update/weights.py +171 -0
- package/skills/project-update/update.py +8 -1682
- package/skills/rr-build/SKILL.md +5 -2
- package/skills/rr-setup/SKILL.md +4 -2
- package/skills/rr-setup/setup.py +13 -12
- package/skills/rr-ship/SKILL.md +2 -2
- package/skills/rr-spec/SKILL.md +6 -0
- package/skills/rr-status/SKILL.md +2 -0
- package/skills/rr-status/status.py +5 -3
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Cleanup, restore, redaction (§D6, §D13) and pack (§D4, R7): the writers that remove, bring back, blank
|
|
2
|
+
or archive what a package holds."""
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import shutil
|
|
7
|
+
|
|
8
|
+
import memory_config as mcfg
|
|
9
|
+
import memory_format as mf
|
|
10
|
+
import memory_io as mio
|
|
11
|
+
import memory_packages as mpkg
|
|
12
|
+
import memory_verify as mver
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# ------------------------------------------------------------------ cleanup, restore, redact (§D6, §D13)
|
|
16
|
+
def cleanup_ready(root, task_id, cfg, packages=None):
|
|
17
|
+
"""The R22 conditions for one task, or the first that fails."""
|
|
18
|
+
packages = packages if packages is not None else mpkg.load_packages(root)
|
|
19
|
+
seals = [(n, e) for n, (_, entries, _) in packages.items() for e in entries
|
|
20
|
+
if e.get("kind") == "task" and e.get("task") == task_id]
|
|
21
|
+
if not cfg.cleanup:
|
|
22
|
+
return "cleanup is off in %s" % mio.POLICY_REL
|
|
23
|
+
if not seals:
|
|
24
|
+
return "no seal for %s in a package in the working tree" % task_id
|
|
25
|
+
findings = mver.check_packages({n: packages[n] for n, _ in seals}, cfg.package_max_bytes)
|
|
26
|
+
if findings:
|
|
27
|
+
return "its package does not verify (%s)" % findings[0].what
|
|
28
|
+
for _, seal in seals:
|
|
29
|
+
if (seal.get("counts") or {}).get("skipped"):
|
|
30
|
+
return "%s has %s torn journal line(s)" % (mf.key_of(seal), seal["counts"]["skipped"])
|
|
31
|
+
journal = mio.read_bytes(os.path.join(root, ".ai", "reports", task_id, "events.jsonl"))
|
|
32
|
+
if journal is not None:
|
|
33
|
+
found = mver.check_journals(lambda rel: journal if rel.endswith("events.jsonl") else None,
|
|
34
|
+
[".ai/reports/%s/events.jsonl" % task_id], packages)
|
|
35
|
+
found = [f for f in found if f.check == "V5"]
|
|
36
|
+
if found:
|
|
37
|
+
return "coverage is incomplete (%s)" % found[0].what
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def cleanup_task(root, task_id, cfg):
|
|
42
|
+
"""B6: .ai/reports/<id> → .ai/state/logs/<id>/report; copy, verify,
|
|
43
|
+
remove when the two are on different filesystems."""
|
|
44
|
+
why = cleanup_ready(root, task_id, cfg)
|
|
45
|
+
if why:
|
|
46
|
+
raise mio.MemoryError_(why, 1)
|
|
47
|
+
src = os.path.join(root, ".ai", "reports", task_id)
|
|
48
|
+
dest = os.path.join(root, ".ai", "state", "logs", task_id, "report")
|
|
49
|
+
if not os.path.isdir(src):
|
|
50
|
+
return "%s already cleaned up" % task_id
|
|
51
|
+
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
|
52
|
+
if os.path.exists(dest):
|
|
53
|
+
raise mio.MemoryError_("%s exists: 'state.py memory restore %s' first" % (os.path.relpath(dest, root), task_id), 3)
|
|
54
|
+
try:
|
|
55
|
+
if os.environ.get("AI_MEMORY_FORCE_EXDEV"):
|
|
56
|
+
raise OSError(18, "Invalid cross-device link (forced)")
|
|
57
|
+
os.replace(src, dest)
|
|
58
|
+
except OSError as exc:
|
|
59
|
+
if exc.errno != 18:
|
|
60
|
+
raise
|
|
61
|
+
shutil.copytree(src, dest)
|
|
62
|
+
if tree_digest(src) != tree_digest(dest):
|
|
63
|
+
shutil.rmtree(dest, ignore_errors=True)
|
|
64
|
+
raise mio.MemoryError_("the copy of %s does not match its source — nothing removed" % task_id, 3) \
|
|
65
|
+
from exc
|
|
66
|
+
shutil.rmtree(src)
|
|
67
|
+
return "%s → %s" % (os.path.relpath(src, root), os.path.relpath(dest, root))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def tree_digest(path):
|
|
71
|
+
import hashlib # pylint: disable=import-outside-toplevel
|
|
72
|
+
h = hashlib.sha256()
|
|
73
|
+
for dirpath, dirs, names in os.walk(path):
|
|
74
|
+
dirs.sort()
|
|
75
|
+
for name in sorted(names):
|
|
76
|
+
full = os.path.join(dirpath, name)
|
|
77
|
+
h.update(os.path.relpath(full, path).encode() + b"\0" + (mio.read_bytes(full) or b"") + b"\0")
|
|
78
|
+
return h.hexdigest()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def cleanup(root, ids):
|
|
82
|
+
cfg = mcfg.load_config(root)
|
|
83
|
+
if not cfg.packages:
|
|
84
|
+
raise mio.MemoryError_("files mode: cleanup needs packages mode", 2)
|
|
85
|
+
if not cfg.cleanup:
|
|
86
|
+
raise mio.MemoryError_("cleanup is off: set \"cleanup\": true in %s once 'memory verify --all' and "
|
|
87
|
+
"'--fresh-clone' are green (§D6)" % mio.POLICY_REL, 2)
|
|
88
|
+
findings = [f for f in mver.verify(root) if not f.check.endswith("-warn")]
|
|
89
|
+
if findings:
|
|
90
|
+
raise mio.MemoryError_("'memory verify --all' is not green: %s" % mver.format_finding(findings[0]), 1)
|
|
91
|
+
packages = mpkg.load_packages(root)
|
|
92
|
+
if ids == "all":
|
|
93
|
+
ids = sorted(mpkg.sealed_task_ids(packages) & set(os.listdir(os.path.join(root, ".ai", "reports"))))
|
|
94
|
+
out = []
|
|
95
|
+
for task_id in ids:
|
|
96
|
+
try:
|
|
97
|
+
out.append(cleanup_task(root, task_id, cfg))
|
|
98
|
+
except mio.MemoryError_ as exc:
|
|
99
|
+
out.append("%s kept: %s" % (task_id, exc))
|
|
100
|
+
return out
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def restore(root, ids):
|
|
104
|
+
import memory_store # pylint: disable=import-outside-toplevel
|
|
105
|
+
logs = os.path.join(root, ".ai", "state", "logs")
|
|
106
|
+
if ids == "all":
|
|
107
|
+
ids = sorted(n for n in os.listdir(logs) if os.path.isdir(os.path.join(logs, n, "report"))) \
|
|
108
|
+
if os.path.isdir(logs) else []
|
|
109
|
+
history = memory_store.FileHistory(root, mcfg.load_config(root))
|
|
110
|
+
ids = sorted(set(ids) | {s.get("task") for s in history.seals().values()
|
|
111
|
+
if not os.path.isdir(os.path.join(root, ".ai", "reports", s.get("task") or "-"))
|
|
112
|
+
and mf.NEW_ID_RE.match(s.get("task") or "")})
|
|
113
|
+
out = []
|
|
114
|
+
history = None
|
|
115
|
+
for task_id in ids:
|
|
116
|
+
dest = os.path.join(root, ".ai", "reports", task_id)
|
|
117
|
+
raw = os.path.join(logs, task_id, "report")
|
|
118
|
+
if os.path.isdir(dest):
|
|
119
|
+
out.append("%s is in .ai/reports/ already" % task_id)
|
|
120
|
+
continue
|
|
121
|
+
if os.path.isdir(raw):
|
|
122
|
+
os.replace(raw, dest)
|
|
123
|
+
out.append("%s restored from %s" % (task_id, os.path.relpath(raw, root)))
|
|
124
|
+
continue
|
|
125
|
+
history = history or memory_store.FileHistory(root, mcfg.load_config(root))
|
|
126
|
+
entries = history.entries(task_id)
|
|
127
|
+
if not entries:
|
|
128
|
+
out.append("%s: nothing recorded" % task_id)
|
|
129
|
+
continue
|
|
130
|
+
os.makedirs(dest)
|
|
131
|
+
md = ["<!-- restored from record %s — a reconstruction, not the original task.md -->" % mio.now()]
|
|
132
|
+
md += mf.render_task_md(entries)
|
|
133
|
+
mio.atomic_write(os.path.join(dest, "task.md"), "\n".join(md) + "\n")
|
|
134
|
+
lines = []
|
|
135
|
+
for e in entries:
|
|
136
|
+
data = {k: v for k, v in e.items() if k not in mf.ENVELOPE}
|
|
137
|
+
data["restored"] = True
|
|
138
|
+
data["rid"] = e["rid"]
|
|
139
|
+
lines.append(json.dumps({"ts": e["ts"], "task": task_id, "event": "restored_%s" % e["kind"],
|
|
140
|
+
"actor": e["actor"], "runtime": e["runtime"], "stage": "",
|
|
141
|
+
"detail": "restored from record", "data": data}, ensure_ascii=False))
|
|
142
|
+
mio.atomic_write(os.path.join(dest, "events.jsonl"), "\n".join(lines) + "\n")
|
|
143
|
+
out.append("%s restored from record (task.md marked, events.jsonl with data.restored)" % task_id)
|
|
144
|
+
return out
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def redact(root, rid, field, why, by=None):
|
|
148
|
+
"""The one exception to append-only (R8): the field becomes "[redacted]",
|
|
149
|
+
the line gets a new rid, a redaction entry names the old one."""
|
|
150
|
+
cfg = mcfg.load_config(root)
|
|
151
|
+
with mio.locked(root):
|
|
152
|
+
packages = mpkg.load_packages(root)
|
|
153
|
+
for name, (header, entries, _) in packages.items():
|
|
154
|
+
hit = [e for e in entries if e["rid"] == rid]
|
|
155
|
+
if not hit:
|
|
156
|
+
continue
|
|
157
|
+
entry = mf.strip_private(hit[0])
|
|
158
|
+
if field in mf.ENVELOPE or field not in entry:
|
|
159
|
+
raise mio.MemoryError_("%s has no redactable field %r" % (rid, field), 2)
|
|
160
|
+
new = dict(entry, **{field: "[redacted]"})
|
|
161
|
+
new = mf.with_rid(new)
|
|
162
|
+
note = mf.with_rid({"v": mf.FORMAT, "task": entry.get("task"), "inst": entry.get("inst") or 1,
|
|
163
|
+
"seq": entry.get("seq") or 0, "kind": "redaction", "ts": mio.now(), "actor": "human",
|
|
164
|
+
"by": by, "runtime": "unknown", "src": ["state.py memory redact"],
|
|
165
|
+
"target": rid, "field": field, "why": why, "replacement": new["rid"]})
|
|
166
|
+
rest = [mf.strip_private(e) for e in entries if e["rid"] != rid]
|
|
167
|
+
mio.atomic_write(os.path.join(mio.memory_dir(root), name), mf.render_package(header, rest + [new, note]))
|
|
168
|
+
del cfg
|
|
169
|
+
return new["rid"], name
|
|
170
|
+
raise mio.MemoryError_("no entry %s in .ai/memory/" % rid, 1)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# ------------------------------------------------------------------ pack (§D4, R7)
|
|
174
|
+
def pack(root, period):
|
|
175
|
+
cfg = mcfg.load_config(root)
|
|
176
|
+
if not re.match(r"^\d{4}(?:-\d{2}|-Q[1-4])?$", period or ""):
|
|
177
|
+
raise mio.MemoryError_("--period takes YYYY-MM, YYYY-Qn or YYYY", 2)
|
|
178
|
+
grain = "quarter" if "-Q" in period else ("month" if "-" in period else "year")
|
|
179
|
+
current = mf.period_of(mio.now(), grain)
|
|
180
|
+
if period >= current:
|
|
181
|
+
raise mio.MemoryError_("period %s has not ended (now %s)" % (period, current), 2)
|
|
182
|
+
with mio.locked(root):
|
|
183
|
+
packages = mpkg.load_packages(root)
|
|
184
|
+
mine = [n for n in packages if (mf.parse_package_name(n) or {}).get("period") == period
|
|
185
|
+
and (mf.parse_package_name(n) or {}).get("author") == cfg.author]
|
|
186
|
+
if not mine:
|
|
187
|
+
raise mio.MemoryError_("no package of %s for %s" % (cfg.author, period), 1)
|
|
188
|
+
target = mf.package_name(period, cfg.author)
|
|
189
|
+
before = set()
|
|
190
|
+
entries = {}
|
|
191
|
+
for name in mine:
|
|
192
|
+
for e in packages[name][1]:
|
|
193
|
+
before.add(e["rid"])
|
|
194
|
+
entries[e["rid"]] = mf.strip_private(e)
|
|
195
|
+
seals = {mf.key_of(e): e for e in entries.values() if e.get("kind") == "task"}
|
|
196
|
+
|
|
197
|
+
def order(e):
|
|
198
|
+
seal = seals.get(mf.key_of(e)) or {}
|
|
199
|
+
return (seal.get("closed_at") or e.get("ts") or "", mf.key_of(e), e.get("seq") or 0, e["rid"])
|
|
200
|
+
ordered = sorted(entries.values(), key=order)
|
|
201
|
+
header = mf.package_header(period, cfg.author, None, mio.now())
|
|
202
|
+
data = b"\n".join([mf.canonical(header)] + [mf.canonical(e) for e in ordered]) + b"\n"
|
|
203
|
+
mio.atomic_write(os.path.join(mio.memory_dir(root), target), data)
|
|
204
|
+
_, check, errors = mf.parse_package(data, target)
|
|
205
|
+
if errors or {e["rid"] for e in check} != before:
|
|
206
|
+
raise mio.MemoryError_("the pack does not hold the batches' rid set — batches kept", 3)
|
|
207
|
+
removed = [n for n in mine if n != target]
|
|
208
|
+
for name in removed:
|
|
209
|
+
os.remove(os.path.join(mio.memory_dir(root), name))
|
|
210
|
+
books = mpkg.read_batches(root)
|
|
211
|
+
books["open"] = {b: n for b, n in books["open"].items() if n not in removed}
|
|
212
|
+
mpkg.write_batches(root, books)
|
|
213
|
+
return target, removed, len(before)
|
|
@@ -872,9 +872,34 @@ def _seal(ctx, entries, inst, last, state, started, closed, seal_src, summary, d
|
|
|
872
872
|
if truncated:
|
|
873
873
|
seal["truncated"] = truncated
|
|
874
874
|
seal["unrecorded"] = sorted(set(unrecorded))
|
|
875
|
+
_fit_changes(seal)
|
|
875
876
|
return mf.with_rid(seal)
|
|
876
877
|
|
|
877
878
|
|
|
879
|
+
def _fit_changes(seal):
|
|
880
|
+
"""GitHub #74: a seal line over mf.LINE_MAX_BYTES (a task that changed ~1200 files) is unreadable to
|
|
881
|
+
parse_package. Keep the longest prefix of `changes` whose line fits, say so in `truncated` and
|
|
882
|
+
`changes_truncated`. A seal that fits is untouched; the result depends only on the seal's own fields."""
|
|
883
|
+
rows = seal.get("changes")
|
|
884
|
+
if not isinstance(rows, list) or not rows or len(mf.canonical(mf.with_rid(seal))) <= mf.LINE_MAX_BYTES:
|
|
885
|
+
return
|
|
886
|
+
total = {k: sum(r[k] for r in rows if isinstance(r, dict) and isinstance(r.get(k), int)
|
|
887
|
+
and not isinstance(r.get(k), bool)) for k in ("added", "deleted")}
|
|
888
|
+
seal["changes_truncated"] = {"rows": len(rows), "added": total["added"], "deleted": total["deleted"]}
|
|
889
|
+
seal["truncated"] = sorted(set(seal.get("truncated") or []) | {"changes"})
|
|
890
|
+
seal["changes"] = []
|
|
891
|
+
room = mf.LINE_MAX_BYTES - len(mf.canonical(mf.with_rid(seal)))
|
|
892
|
+
keep = 0
|
|
893
|
+
for row in rows:
|
|
894
|
+
room -= len(mf.canonical(row)) + (1 if keep else 0)
|
|
895
|
+
if room < 0:
|
|
896
|
+
break
|
|
897
|
+
keep += 1
|
|
898
|
+
seal["changes"] = rows[:keep]
|
|
899
|
+
while seal["changes"] and len(mf.canonical(mf.with_rid(seal))) > mf.LINE_MAX_BYTES:
|
|
900
|
+
seal["changes"] = seal["changes"][:-1]
|
|
901
|
+
|
|
902
|
+
|
|
878
903
|
# ------------------------------------------------------------------ known-risks.md (§D9, R24)
|
|
879
904
|
RISK_HEAD_RE = re.compile(r"^## (.+)$")
|
|
880
905
|
RISK_FIELD_RE = re.compile(r"^- \*\*([^*]+)\*\*:\s?(.*)$")
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""The configuration of packages mode: the policy file's schema and defaults, MemoryConfig, loading and
|
|
2
|
+
checking .ai/policies/memory.json, and the mode a project runs in."""
|
|
3
|
+
import contextlib
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
import memory_format as mf
|
|
8
|
+
import memory_io as mio
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
SCHEMA = 1
|
|
12
|
+
DEFAULTS = {"schema": 1, "mode": "files", "period": "quarter", "package_max_bytes": 1048576, "cleanup": False,
|
|
13
|
+
"excerpt_max_bytes": 1024, "keep_rules": 1, "recall": {"budget_tokens": 1500, "max_items": 40}}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# ------------------------------------------------------------------ configuration (§D11, §I6)
|
|
17
|
+
def author_slug(name):
|
|
18
|
+
slug = re.sub(r"[^a-z0-9-]+", "-", (name or "").casefold()).strip("-")[:32].strip("-")
|
|
19
|
+
return slug if slug and mf.AUTHOR_RE.match(slug) else "unknown"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class MemoryConfig:
|
|
23
|
+
"""The project policy (.ai/policies/memory.json) and the developer's own
|
|
24
|
+
choices (.ai/local/memory/config.json)."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, root, policy, local, present):
|
|
27
|
+
self.root, self.policy, self.local, self.present = root, policy, local, present
|
|
28
|
+
|
|
29
|
+
def __getattr__(self, name):
|
|
30
|
+
if name in ("mode", "period", "package_max_bytes", "cleanup", "excerpt_max_bytes", "keep_rules"):
|
|
31
|
+
return self.policy.get(name, DEFAULTS[name])
|
|
32
|
+
raise AttributeError(name)
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def recall_budget(self):
|
|
36
|
+
return int((self.policy.get("recall") or {}).get("budget_tokens", 1500))
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def recall_max_items(self):
|
|
40
|
+
return int((self.policy.get("recall") or {}).get("max_items", 40))
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def author(self):
|
|
44
|
+
return self.local.get("author") or author_slug(os.environ.get("USER") or os.environ.get("USERNAME"))
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def index(self):
|
|
48
|
+
return self.local.get("index") or "none"
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def packages(self):
|
|
52
|
+
return self.mode == "packages"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def load_config(root):
|
|
56
|
+
path = os.path.join(root, mio.POLICY_REL)
|
|
57
|
+
present = os.path.exists(path)
|
|
58
|
+
policy = dict(DEFAULTS)
|
|
59
|
+
if present:
|
|
60
|
+
try:
|
|
61
|
+
loaded = mio.read_json(path)
|
|
62
|
+
except (OSError, ValueError) as exc:
|
|
63
|
+
raise mio.MemoryError_("%s is not valid JSON (%s): fix it, or 'state.py memory disable'"
|
|
64
|
+
% (mio.POLICY_REL, exc)) from exc
|
|
65
|
+
if not isinstance(loaded, dict):
|
|
66
|
+
raise mio.MemoryError_("%s must be a JSON object" % mio.POLICY_REL)
|
|
67
|
+
schema = loaded.get("schema", 1)
|
|
68
|
+
if isinstance(schema, int) and not isinstance(schema, bool) and schema > SCHEMA:
|
|
69
|
+
raise mio.MemoryError_("%s has schema %s; this state.py knows %d — update the plugin"
|
|
70
|
+
% (mio.POLICY_REL, schema, SCHEMA))
|
|
71
|
+
policy.update(loaded)
|
|
72
|
+
check_policy(policy)
|
|
73
|
+
local = {}
|
|
74
|
+
with contextlib.suppress(OSError, ValueError):
|
|
75
|
+
found = mio.read_json(os.path.join(root, mio.LOCAL_REL, "config.json"))
|
|
76
|
+
if isinstance(found, dict):
|
|
77
|
+
local = found
|
|
78
|
+
where = os.path.join(mio.LOCAL_REL, "config.json")
|
|
79
|
+
if local.get("author") is not None and not (isinstance(local["author"], str)
|
|
80
|
+
and mf.AUTHOR_RE.match(local["author"])):
|
|
81
|
+
raise mio.MemoryError_("%s: author %r is not a slug of [a-z0-9-]" % (where, local["author"]))
|
|
82
|
+
if local.get("index", "none") not in ("none", "sqlite"):
|
|
83
|
+
raise mio.MemoryError_("%s: index takes none or sqlite" % where)
|
|
84
|
+
return MemoryConfig(root, policy, local, present)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def check_policy(policy):
|
|
88
|
+
"""Every key of memory.json with its type (§I6): a wrong one is a refusal
|
|
89
|
+
naming the key, never a traceback inside close."""
|
|
90
|
+
def is_int(value, low):
|
|
91
|
+
return isinstance(value, int) and not isinstance(value, bool) and value >= low
|
|
92
|
+
problems = []
|
|
93
|
+
if not is_int(policy.get("schema"), 1):
|
|
94
|
+
problems.append("schema must be an integer (1)")
|
|
95
|
+
if policy.get("mode") not in ("files", "packages"):
|
|
96
|
+
problems.append('mode must be "files" or "packages"')
|
|
97
|
+
if policy.get("period") not in mf.PERIODS:
|
|
98
|
+
problems.append("period must be month, quarter or year")
|
|
99
|
+
if not is_int(policy.get("package_max_bytes"), 1024):
|
|
100
|
+
problems.append("package_max_bytes must be an integer of at least 1024")
|
|
101
|
+
if not isinstance(policy.get("cleanup"), bool):
|
|
102
|
+
problems.append("cleanup must be true or false")
|
|
103
|
+
if not is_int(policy.get("excerpt_max_bytes"), 0):
|
|
104
|
+
problems.append("excerpt_max_bytes must be an integer >= 0")
|
|
105
|
+
if policy.get("keep_rules") != 1:
|
|
106
|
+
problems.append("keep_rules must be 1")
|
|
107
|
+
recall = policy.get("recall")
|
|
108
|
+
if not (isinstance(recall, dict) and is_int(recall.get("budget_tokens", 1500), 1)
|
|
109
|
+
and is_int(recall.get("max_items", 40), 1)):
|
|
110
|
+
problems.append('recall must be {"budget_tokens": N, "max_items": N}')
|
|
111
|
+
if problems:
|
|
112
|
+
raise mio.MemoryError_("%s: %s" % (mio.POLICY_REL, "; ".join(problems)))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def mode_of(root):
|
|
116
|
+
"""'files' or 'packages' without raising: close must archive whatever happens."""
|
|
117
|
+
try:
|
|
118
|
+
return load_config(root).mode
|
|
119
|
+
except mio.MemoryError_:
|
|
120
|
+
return "files"
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""Export at close (§D5): the outbox, the compaction of a task into a package, the export itself, the
|
|
2
|
+
lines it reports, the pending ids and the close hook."""
|
|
3
|
+
import collections
|
|
4
|
+
import contextlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
import memory_cleanup as mclean
|
|
9
|
+
import memory_compact as mc
|
|
10
|
+
import memory_config as mcfg
|
|
11
|
+
import memory_format as mf
|
|
12
|
+
import memory_io as mio
|
|
13
|
+
import memory_packages as mpkg
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# ------------------------------------------------------------------ export (§D5)
|
|
17
|
+
def outbox_path(root, key):
|
|
18
|
+
task, inst = key.rsplit("#", 1)
|
|
19
|
+
return mio.local_path(root, "outbox", "%s~%s.jsonl" % (task, inst))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def exportable(compactions):
|
|
23
|
+
return [c for c in compactions if c.seal and c.seal["result"] in ("closed", "abandoned", "restarted")]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
ExportResult = collections.namedtuple("ExportResult",
|
|
27
|
+
"package added sealed coverage failures conflicts already added_by_key")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def compact_for_export(root, task_id, cfg):
|
|
31
|
+
found = mc.compact_instances(mc.FsReader(root), task_id, "close", cfg.excerpt_max_bytes)
|
|
32
|
+
problems = [p for c in found for p in c.problems if "torn journal" not in p]
|
|
33
|
+
if problems:
|
|
34
|
+
raise mio.MemoryError_(problems[0], 3)
|
|
35
|
+
ready = exportable(found)
|
|
36
|
+
if not ready:
|
|
37
|
+
raise mio.MemoryError_("%s has no closed instance to export" % task_id, 3)
|
|
38
|
+
return ready
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def write_outbox(root, compaction):
|
|
42
|
+
key = mf.key_of(compaction.seal)
|
|
43
|
+
lines = [mf.canonical(e) for e in compaction.entries + [compaction.seal]]
|
|
44
|
+
mio.atomic_write(outbox_path(root, key), b"\n".join(lines) + b"\n")
|
|
45
|
+
return key
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def read_outbox(root):
|
|
49
|
+
base = mio.local_path(root, "outbox")
|
|
50
|
+
out = {}
|
|
51
|
+
for name in sorted(os.listdir(base)) if os.path.isdir(base) else []:
|
|
52
|
+
if not name.endswith(".jsonl"):
|
|
53
|
+
continue
|
|
54
|
+
entries = []
|
|
55
|
+
for raw in (mio.read_bytes(os.path.join(base, name)) or b"").split(b"\n"):
|
|
56
|
+
if raw.strip():
|
|
57
|
+
with contextlib.suppress(ValueError, UnicodeDecodeError):
|
|
58
|
+
entries.append(json.loads(raw.decode("utf-8")))
|
|
59
|
+
seals = [e for e in entries if e.get("kind") == "task"]
|
|
60
|
+
if seals:
|
|
61
|
+
out[mf.key_of(seals[-1])] = entries
|
|
62
|
+
return out
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def export(root, ids, cfg=None, stamp=None):
|
|
66
|
+
"""B2–B4 for every id (or the pending ones); returns ExportResult. A
|
|
67
|
+
failure of one task keeps its outbox and is reported, never raised."""
|
|
68
|
+
cfg = cfg or mcfg.load_config(root)
|
|
69
|
+
if not cfg.packages:
|
|
70
|
+
raise mio.MemoryError_("files mode: nothing is exported ('state.py memory enable' turns packages on)")
|
|
71
|
+
failures, coverage, sealed, already, added_by_key = [], {}, [], [], {}
|
|
72
|
+
if ids == "pending":
|
|
73
|
+
ids = pending_ids(root, cfg)
|
|
74
|
+
ids = list(ids)
|
|
75
|
+
# A key with a seal in the tree is never sealed again from other sources: a
|
|
76
|
+
# second compaction of changed local sources (a restarted id, a cleaned-up
|
|
77
|
+
# directory) would add a degraded seal to an append-only record (R8, R9).
|
|
78
|
+
# The same seal is a repair: the rids it lacks come back, nothing else (V4, V5).
|
|
79
|
+
sealed_here = mpkg.seals_by_key(mpkg.load_packages(root))
|
|
80
|
+
for task_id in ids:
|
|
81
|
+
try:
|
|
82
|
+
for compaction in compact_for_export(root, task_id, cfg):
|
|
83
|
+
key = mf.key_of(compaction.seal)
|
|
84
|
+
coverage[key] = compaction.coverage
|
|
85
|
+
stored = {s["rid"] for _, s in sealed_here.get(key, [])}
|
|
86
|
+
if stored and compaction.seal["rid"] not in stored:
|
|
87
|
+
already.append(key)
|
|
88
|
+
continue
|
|
89
|
+
write_outbox(root, compaction)
|
|
90
|
+
except mio.MemoryError_ as exc:
|
|
91
|
+
failures.append((task_id, str(exc)))
|
|
92
|
+
except OSError as exc:
|
|
93
|
+
failures.append((task_id, "the outbox could not be written (%s)" % exc))
|
|
94
|
+
mio.crash("B2")
|
|
95
|
+
added, conflicts, name = 0, [], None
|
|
96
|
+
try:
|
|
97
|
+
with mio.locked(root):
|
|
98
|
+
outbox = read_outbox(root) # under the lock: another close may have taken it
|
|
99
|
+
packages = mpkg.load_packages(root)
|
|
100
|
+
have = mpkg.all_rids(packages)
|
|
101
|
+
seals = mpkg.seals_by_key(packages)
|
|
102
|
+
recorded = {e.get("key"): set(e.get("rids") or []) for _, entries, _ in packages.values()
|
|
103
|
+
for e in entries if e.get("kind") == "conflict"}
|
|
104
|
+
new, keep = [], {}
|
|
105
|
+
for key, entries in outbox.items():
|
|
106
|
+
seal_rid = [e for e in entries if e.get("kind") == "task"][-1]["rid"]
|
|
107
|
+
stored = {s["rid"] for _, s in seals.get(key, [])}
|
|
108
|
+
missing = [e for e in entries if e["rid"] not in have]
|
|
109
|
+
if (stored and seal_rid not in stored) or not missing:
|
|
110
|
+
# Sealed meanwhile from other sources, or nothing lacking (a re-run, B4).
|
|
111
|
+
os.remove(outbox_path(root, key))
|
|
112
|
+
if key not in already:
|
|
113
|
+
already.append(key)
|
|
114
|
+
continue
|
|
115
|
+
new += missing
|
|
116
|
+
added_by_key[key] = len(missing)
|
|
117
|
+
keep[key] = entries
|
|
118
|
+
for key in coverage:
|
|
119
|
+
if key in seals and key not in keep and key not in already:
|
|
120
|
+
already.append(key) # another close took this outbox and sealed it (review L3)
|
|
121
|
+
for key, found in sorted(seals.items()):
|
|
122
|
+
# Two seals that arrived by a merge (one id closed in two clones):
|
|
123
|
+
# recorded side by side, never merged (R9).
|
|
124
|
+
rids = sorted({s["rid"] for _, s in found} - {s.get("supersedes") for _, s in found})
|
|
125
|
+
if len(rids) < 2 or key.rsplit("#", 1)[0] not in ids or set(rids) <= recorded.get(key, set()):
|
|
126
|
+
continue
|
|
127
|
+
seal = found[-1][1]
|
|
128
|
+
conflict = mf.with_rid({
|
|
129
|
+
"v": mf.FORMAT, "task": seal["task"], "inst": seal["inst"], "seq": seal["seq"] + 1,
|
|
130
|
+
"kind": "conflict", "ts": max(s["ts"] for _, s in found), "actor": "agent", "by": None,
|
|
131
|
+
"runtime": seal["runtime"], "src": ["export"], "key": key, "rids": rids,
|
|
132
|
+
"reason": "two different seals for one key — kept side by side, never merged"})
|
|
133
|
+
if conflict["rid"] not in have:
|
|
134
|
+
new.append(conflict)
|
|
135
|
+
conflicts.append(key)
|
|
136
|
+
if not new:
|
|
137
|
+
return ExportResult(None, 0, sealed, coverage, failures, conflicts, already, added_by_key)
|
|
138
|
+
name, branch, books = mpkg.open_batch(root, cfg, stamp)
|
|
139
|
+
path = os.path.join(mio.memory_dir(root), name)
|
|
140
|
+
header, current, _ = packages.get(name, (None, [], []))
|
|
141
|
+
parts = mf.parse_package_name(name)
|
|
142
|
+
header = header or mf.package_header(parts["period"], cfg.author, parts["batch"], stamp or mio.now())
|
|
143
|
+
current = [mf.strip_private(e) for e in current]
|
|
144
|
+
outbox = keep
|
|
145
|
+
if new:
|
|
146
|
+
data = mf.render_package(header, current + new)
|
|
147
|
+
mio.crash("B3")
|
|
148
|
+
mio.atomic_write(path, data)
|
|
149
|
+
added = len(new)
|
|
150
|
+
books["open"][branch] = name
|
|
151
|
+
mpkg.write_batches(root, books)
|
|
152
|
+
mio.crash("B4")
|
|
153
|
+
check = mpkg.load_packages(root)
|
|
154
|
+
present = mpkg.all_rids(check)
|
|
155
|
+
for key, entries in outbox.items():
|
|
156
|
+
seal = [e for e in entries if e.get("kind") == "task"][-1]
|
|
157
|
+
if all(e["rid"] in present for e in entries):
|
|
158
|
+
os.remove(outbox_path(root, key))
|
|
159
|
+
sealed.append(key)
|
|
160
|
+
else:
|
|
161
|
+
failures.append((seal["task"], "read-back found %d of %d entries" % (
|
|
162
|
+
sum(e["rid"] in present for e in entries), len(entries))))
|
|
163
|
+
except OSError as exc:
|
|
164
|
+
failures.append((",".join(sorted(coverage)) or "the outbox",
|
|
165
|
+
"%s (%s)" % (exc.strerror or exc, getattr(exc, "filename", ""))))
|
|
166
|
+
if added and cfg.index == "sqlite":
|
|
167
|
+
mio.crash("B5")
|
|
168
|
+
index_import(root, cfg)
|
|
169
|
+
return ExportResult(name, added, sealed, coverage, failures, conflicts, already, added_by_key)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def record_lines(result):
|
|
173
|
+
"""What close and `memory export` print: each key newly sealed with what it
|
|
174
|
+
added, each key that was already sealed, each conflict recorded (review L5)."""
|
|
175
|
+
out = []
|
|
176
|
+
for key in result.sealed:
|
|
177
|
+
cov = result.coverage.get(key) or {}
|
|
178
|
+
out.append("record: %s (+%d entries, %s sealed, coverage %s/%s lines)" % (
|
|
179
|
+
os.path.join(mio.MEMORY_REL, result.package or "-"), result.added_by_key.get(key, 0),
|
|
180
|
+
key.rsplit("#", 1)[0], cov.get("accounted", "?"), cov.get("lines", "?")))
|
|
181
|
+
for key in result.already:
|
|
182
|
+
out.append("record: %s already sealed in %s — nothing added" % (key, mio.MEMORY_REL))
|
|
183
|
+
for key in result.conflicts:
|
|
184
|
+
out.append("conflict: %s has two different seals — recorded side by side, never merged "
|
|
185
|
+
"('state.py memory show %s')" % (key, key.rsplit("#", 1)[0]))
|
|
186
|
+
return out
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def index_import(root, cfg):
|
|
190
|
+
"""B5: the index learns the new package; any failure marks it stale."""
|
|
191
|
+
try:
|
|
192
|
+
import memory_store # pylint: disable=import-outside-toplevel
|
|
193
|
+
memory_store.SqliteHistory(root, cfg).refresh()
|
|
194
|
+
except Exception: # pylint: disable=broad-except # a stale index is rebuilt by the next read
|
|
195
|
+
with contextlib.suppress(Exception):
|
|
196
|
+
import memory_store # pylint: disable=import-outside-toplevel
|
|
197
|
+
memory_store.mark_stale(root)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def pending_ids(root, cfg=None):
|
|
201
|
+
"""Closed new-shape tasks (and anything in the outbox) without a seal:
|
|
202
|
+
B1 left them closed but not exported. Legacy ids wait for the migration."""
|
|
203
|
+
sealed = mpkg.sealed_task_ids(mpkg.load_packages(root))
|
|
204
|
+
found = set(k.rsplit("#", 1)[0] for k in read_outbox(root))
|
|
205
|
+
logs = os.path.join(root, ".ai", "state", "logs")
|
|
206
|
+
for name in os.listdir(logs) if os.path.isdir(logs) else []:
|
|
207
|
+
if not mf.NEW_ID_RE.match(name) or name in sealed:
|
|
208
|
+
continue
|
|
209
|
+
with contextlib.suppress(OSError, ValueError):
|
|
210
|
+
state = mio.read_json(os.path.join(logs, name, "state.json"))
|
|
211
|
+
if state.get("result") in ("closed", "abandoned"):
|
|
212
|
+
found.add(name)
|
|
213
|
+
del cfg
|
|
214
|
+
return sorted(found - sealed)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def after_close(root, task_id, out=print):
|
|
218
|
+
"""The one call close makes after archive_v7. Packages mode only; every
|
|
219
|
+
failure is one printed line and close still exits 0 (B4)."""
|
|
220
|
+
try:
|
|
221
|
+
cfg = mcfg.load_config(root)
|
|
222
|
+
except mio.MemoryError_ as exc:
|
|
223
|
+
out("memory: %s — the task is closed in files mode" % exc)
|
|
224
|
+
return None
|
|
225
|
+
if not cfg.packages:
|
|
226
|
+
return None
|
|
227
|
+
try:
|
|
228
|
+
pending = [t for t in pending_ids(root, cfg) if t != task_id][:mio.PENDING_MAX]
|
|
229
|
+
result = export(root, [task_id] + pending, cfg)
|
|
230
|
+
except (mio.MemoryError_, ValueError, OSError) as exc:
|
|
231
|
+
out("memory: export of %s failed (%s) — the task is closed; 'state.py memory export %s' retries"
|
|
232
|
+
% (task_id, exc, task_id))
|
|
233
|
+
return None
|
|
234
|
+
for failed, why in result.failures:
|
|
235
|
+
out("memory: export of %s failed (%s) — the task is closed; 'state.py memory export %s' retries"
|
|
236
|
+
% (failed, why, failed))
|
|
237
|
+
for line in record_lines(result):
|
|
238
|
+
out(line)
|
|
239
|
+
if cfg.cleanup and any(k.rsplit("#", 1)[0] == task_id for k in result.sealed + result.already):
|
|
240
|
+
mio.crash("B6")
|
|
241
|
+
try:
|
|
242
|
+
done = mclean.cleanup_task(root, task_id, cfg)
|
|
243
|
+
out("cleanup: %s" % done)
|
|
244
|
+
except mio.MemoryError_ as exc:
|
|
245
|
+
out("cleanup: %s kept in .ai/reports/ (%s)" % (task_id, exc))
|
|
246
|
+
return result
|
|
@@ -275,7 +275,9 @@ def sort_entries(entries):
|
|
|
275
275
|
|
|
276
276
|
def parse_package(data, name=None):
|
|
277
277
|
"""(header, entries, errors): errors are (line, what) pairs; a line that
|
|
278
|
-
fails is left out of entries, so a reader still sees the rest.
|
|
278
|
+
fails is left out of entries, so a reader still sees the rest. A line over
|
|
279
|
+
LINE_MAX_BYTES is an error, and a valid entry on it is still kept, with its
|
|
280
|
+
byte length in `_over` (GitHub #74)."""
|
|
279
281
|
errors, entries, header = [], [], None
|
|
280
282
|
if isinstance(data, str):
|
|
281
283
|
data = data.encode("utf-8")
|
|
@@ -293,7 +295,6 @@ def parse_package(data, name=None):
|
|
|
293
295
|
raw = raw[:-1]
|
|
294
296
|
if len(raw) > LINE_MAX_BYTES:
|
|
295
297
|
errors.append((number, "line is %d bytes, over %d" % (len(raw), LINE_MAX_BYTES)))
|
|
296
|
-
continue
|
|
297
298
|
try:
|
|
298
299
|
obj = json.loads(raw.decode("utf-8"))
|
|
299
300
|
except (UnicodeDecodeError, ValueError) as exc:
|
|
@@ -318,6 +319,8 @@ def parse_package(data, name=None):
|
|
|
318
319
|
errors.append((number, "; ".join(problems)))
|
|
319
320
|
continue
|
|
320
321
|
obj["_line"] = number
|
|
322
|
+
if len(raw) > LINE_MAX_BYTES:
|
|
323
|
+
obj["_over"] = len(raw)
|
|
321
324
|
entries.append(obj)
|
|
322
325
|
if header is None and not errors:
|
|
323
326
|
errors.append((1, "empty package"))
|
|
@@ -688,7 +691,8 @@ def render_task_md(entries):
|
|
|
688
691
|
facts = seal.get("facts") or {}
|
|
689
692
|
if facts:
|
|
690
693
|
out += ["facts: (a) %s · (b) %s" % tuple(_fact(facts.get(k)) for k in ("a", "b")), ""]
|
|
691
|
-
files = seal.get("changes")
|
|
694
|
+
files = seal.get("changes")
|
|
695
|
+
files = files if isinstance(files, list) else []
|
|
692
696
|
out += ["## Files", ""]
|
|
693
697
|
if files:
|
|
694
698
|
out += ["- %s (%s, %s)" % (c.get("path"), "binary" if c.get("binary") else "+%s −%s" % (
|