@rubytech/create-maxy-code 0.1.487 → 0.1.489
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/package.json +1 -1
- package/payload/platform/docs/superpowers/plans/2026-07-22-task-1899-reconcile-report-only.md +748 -0
- package/payload/platform/docs/superpowers/specs/2026-07-22-task-1899-reconcile-report-only-design.md +121 -0
- package/payload/platform/lib/active-rules/package-lock.json +12 -0
- package/payload/platform/lib/graph-mcp/dist/__tests__/schema-cypher-parser.test.js +51 -0
- package/payload/platform/lib/graph-mcp/dist/__tests__/schema-cypher-parser.test.js.map +1 -1
- package/payload/platform/lib/graph-mcp/dist/schema-cypher-parser.d.ts +2 -2
- package/payload/platform/lib/graph-mcp/dist/schema-cypher-parser.d.ts.map +1 -1
- package/payload/platform/lib/graph-mcp/dist/schema-cypher-parser.js +20 -4
- package/payload/platform/lib/graph-mcp/dist/schema-cypher-parser.js.map +1 -1
- package/payload/platform/lib/graph-mcp/src/__tests__/schema-cypher-parser.test.ts +55 -0
- package/payload/platform/lib/graph-mcp/src/schema-cypher-parser.ts +19 -4
- package/payload/platform/lib/graph-style/dist/index.d.ts +24 -0
- package/payload/platform/lib/graph-style/dist/index.d.ts.map +1 -1
- package/payload/platform/lib/graph-style/dist/index.js +25 -1
- package/payload/platform/lib/graph-style/dist/index.js.map +1 -1
- package/payload/platform/lib/graph-style/src/__tests__/additional-base-labels.test.ts +22 -0
- package/payload/platform/lib/graph-style/src/index.ts +27 -0
- package/payload/platform/lib/storage-broker/dist/__tests__/audit.test.js +15 -0
- package/payload/platform/lib/storage-broker/dist/__tests__/audit.test.js.map +1 -1
- package/payload/platform/lib/storage-broker/dist/audit.d.ts +5 -0
- package/payload/platform/lib/storage-broker/dist/audit.d.ts.map +1 -1
- package/payload/platform/lib/storage-broker/dist/audit.js +3 -2
- package/payload/platform/lib/storage-broker/dist/audit.js.map +1 -1
- package/payload/platform/lib/storage-broker/src/__tests__/audit.test.ts +21 -0
- package/payload/platform/lib/storage-broker/src/audit.ts +8 -2
- package/payload/platform/neo4j/schema.cypher +105 -0
- package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +8 -2
- package/payload/platform/plugins/cloudflare/PLUGIN.md +22 -2
- package/payload/platform/plugins/cloudflare/bin/portal-index-push.mjs +30 -8
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-directory-listing.test.ts +75 -13
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-index-push.test.ts +77 -9
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-indexed-download.test.ts +38 -2
- package/payload/platform/plugins/cloudflare/mcp/__tests__/template-config.test.ts +72 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/SKILL.md +43 -7
- package/payload/platform/plugins/cloudflare/skills/data-portal/schema.sql +22 -7
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/download.ts +14 -2
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/files.ts +23 -5
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/index.html +3 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/portal.js +46 -5
- package/payload/platform/plugins/docs/references/calendar-booking.md +5 -1
- package/payload/platform/plugins/docs/references/internals.md +2 -0
- package/payload/platform/plugins/memory/PLUGIN.md +1 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/schema-cypher-drift.test.js +61 -31
- package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/schema-cypher-drift.test.js.map +1 -1
- package/payload/platform/plugins/memory/references/schema-construction.md +13 -0
- package/payload/platform/plugins/scheduling/PLUGIN.md +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/index.js +2 -2
- package/payload/platform/plugins/scheduling/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/booking-reconcile.test.js +70 -5
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/booking-reconcile.test.js.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/getUserTimezone.test.js +31 -6
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/getUserTimezone.test.js.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/lib/booking-reconcile.d.ts +31 -4
- package/payload/platform/plugins/scheduling/mcp/dist/lib/booking-reconcile.d.ts.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/lib/booking-reconcile.js +18 -7
- package/payload/platform/plugins/scheduling/mcp/dist/lib/booking-reconcile.js.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/lib/neo4j.d.ts +15 -6
- package/payload/platform/plugins/scheduling/mcp/dist/lib/neo4j.d.ts.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/lib/neo4j.js +24 -11
- package/payload/platform/plugins/scheduling/mcp/dist/lib/neo4j.js.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/reconcile-bookings.js +84 -5
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/reconcile-bookings.js.map +1 -1
- package/payload/platform/plugins/storage-broker/PLUGIN.md +1 -0
- package/payload/platform/scripts/__tests__/account-schema-owned-dirs.test.sh +22 -2
- package/payload/platform/scripts/__tests__/quarantine-restore.test.sh +143 -0
- package/payload/platform/scripts/quarantine-restore.mjs +175 -0
- package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.d.ts +24 -7
- package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.js +55 -72
- package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/index.js +11 -8
- package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
- package/payload/platform/services/whatsapp-channel/dist/notification.d.ts +32 -0
- package/payload/platform/services/whatsapp-channel/dist/notification.d.ts.map +1 -1
- package/payload/platform/services/whatsapp-channel/dist/notification.js +32 -2
- package/payload/platform/services/whatsapp-channel/dist/notification.js.map +1 -1
- package/payload/platform/services/whatsapp-channel/dist/targets.d.ts +6 -1
- package/payload/platform/services/whatsapp-channel/dist/targets.d.ts.map +1 -1
- package/payload/platform/services/whatsapp-channel/dist/targets.js +6 -0
- package/payload/platform/services/whatsapp-channel/dist/targets.js.map +1 -1
- package/payload/platform/templates/account-schema/SCHEMA.md +7 -4
- package/payload/server/{chunk-REWXLAPC.js → chunk-UFXRUKXO.js} +4 -2
- package/payload/server/public/activity.html +6 -6
- package/payload/server/public/assets/{AdminLoginScreens-DF9S5Rca.js → AdminLoginScreens-Dic_fPt6.js} +1 -1
- package/payload/server/public/assets/{AdminShell-B0PNDCAb.js → AdminShell-JN7rZ8aG.js} +1 -1
- package/payload/server/public/assets/{Checkbox-s5X6dso4.js → Checkbox-CB74kINA.js} +1 -1
- package/payload/server/public/assets/{activity-BRBsaalV.js → activity-DbfpHg3i.js} +1 -1
- package/payload/server/public/assets/{admin-BoVCP4Mw.js → admin-DvQPTxqe.js} +1 -1
- package/payload/server/public/assets/{bot-BVO9Uqr_.js → bot-DnuyRsLS.js} +1 -1
- package/payload/server/public/assets/{browser-BVW4FFii.js → browser-q4EFqKvR.js} +1 -1
- package/payload/server/public/assets/{calendar-CWsD3uLk.js → calendar-C-q9kWmA.js} +1 -1
- package/payload/server/public/assets/chat-DcefIV9q.js +1 -0
- package/payload/server/public/assets/chevron-down-Blma_CKl.js +1 -0
- package/payload/server/public/assets/chevron-left-xeYDxFiC.js +1 -0
- package/payload/server/public/assets/chevron-right-BonbtjqJ.js +1 -0
- package/payload/server/public/assets/data-Bu-5SUmR.js +1 -0
- package/payload/server/public/assets/{graph-mJ9ky5dc.js → graph-Cyh6FWaV.js} +1 -1
- package/payload/server/public/assets/graph-labels-CUQtXFKW.js +1 -0
- package/payload/server/public/assets/{maximize-2-yucCepu_.js → maximize-2-k3Fu5hdP.js} +1 -1
- package/payload/server/public/assets/{operator-911z_Fy_.js → operator-8NVpNLeD.js} +1 -1
- package/payload/server/public/assets/{page-Cv0qUwMG.js → page-Cno8NNMk.js} +1 -1
- package/payload/server/public/assets/{page-CgSgj8Cy.js → page-DhwPG5Zf.js} +1 -1
- package/payload/server/public/assets/{public-BzwveFwz.js → public-BJ5Oo2Fw.js} +1 -1
- package/payload/server/public/assets/{rotate-ccw-BH51cf2Q.js → rotate-ccw-D-ov5jM4.js} +1 -1
- package/payload/server/public/assets/tasks-DHMxTZVD.js +1 -0
- package/payload/server/public/assets/{time-entry-format-DPdGkp1C.js → time-entry-format-EmV5lz6o.js} +1 -1
- package/payload/server/public/assets/{triangle-alert-DO2rjjfV.js → triangle-alert-CivyPfAL.js} +1 -1
- package/payload/server/public/assets/{useCopyFeedback-HMSM7-Ir.js → useCopyFeedback-hbnPS-r9.js} +1 -1
- package/payload/server/public/assets/{useSubAccountSwitcher-2ojOOYNo.css → useSubAccountSwitcher-kwhG-aJj.css} +1 -1
- package/payload/server/public/assets/{useVoiceRecorder-BcukQIza.js → useVoiceRecorder-QN9_GqB_.js} +1 -1
- package/payload/server/public/browser.html +4 -4
- package/payload/server/public/calendar.html +6 -6
- package/payload/server/public/chat.html +13 -13
- package/payload/server/public/data.html +10 -10
- package/payload/server/public/graph.html +11 -11
- package/payload/server/public/index.html +16 -16
- package/payload/server/public/operator.html +15 -15
- package/payload/server/public/public.html +13 -13
- package/payload/server/public/tasks.html +6 -6
- package/payload/server/server.js +1036 -784
- package/payload/server/{src-JJ6PHO5I.js → src-YJF3UJNR.js} +1 -1
- package/payload/server/public/assets/chat-DKGREuIO.js +0 -1
- package/payload/server/public/assets/chevron-down-m581tl1J.js +0 -1
- package/payload/server/public/assets/chevron-left-BAY0xfmR.js +0 -1
- package/payload/server/public/assets/chevron-right-Dy_REdYZ.js +0 -1
- package/payload/server/public/assets/data-CtOxa94P.js +0 -1
- package/payload/server/public/assets/graph-labels-C8-sIKuv.js +0 -1
- package/payload/server/public/assets/tasks-21egY7A6.js +0 -1
- /package/payload/server/public/assets/{useSubAccountSwitcher-DLvqvfoc.js → useSubAccountSwitcher-DotTrvX3.js} +0 -0
|
@@ -73,6 +73,20 @@ ALLOWED_MX="$(awk '/^```allowed-top-level$/{f=1;next} /^```$/{f=0} f' "$A_MX/SCH
|
|
|
73
73
|
assert_nogrep "quoting" "$ALLOWED_MX" "mx-no-quoting"
|
|
74
74
|
assert_nogrep "Plugin-owned top-level workspaces" "$(cat "$A_MX/SCHEMA.md")" "mx-no-section"
|
|
75
75
|
|
|
76
|
+
# --- platform plugins declare pages (storage-broker) + archive (memory) ---
|
|
77
|
+
# Task 1891: these two plugin-owned root dirs must land in the allowed block so
|
|
78
|
+
# the account-dir reconcile stops quarantining them. Symlink the real plugin
|
|
79
|
+
# dirs so the case exercises the shipped PLUGIN.md declaration.
|
|
80
|
+
T_PA="$(make_tree '[]')"
|
|
81
|
+
ln -s "$REAL_PROJECT_DIR/plugins/storage-broker" "$T_PA/platform/plugins/storage-broker"
|
|
82
|
+
ln -s "$REAL_PROJECT_DIR/plugins/memory" "$T_PA/platform/plugins/memory"
|
|
83
|
+
A_PA="$(make_account "$T_PA")"
|
|
84
|
+
export PROJECT_DIR="$T_PA/platform"
|
|
85
|
+
merge_owned_dirs_into_schema "$A_PA" >/dev/null
|
|
86
|
+
ALLOWED_PA="$(awk '/^```allowed-top-level$/{f=1;next} /^```$/{f=0} f' "$A_PA/SCHEMA.md")"
|
|
87
|
+
assert_grep "pages" "$ALLOWED_PA" "pa-allowlist-has-pages"
|
|
88
|
+
assert_grep "archive" "$ALLOWED_PA" "pa-allowlist-has-archive"
|
|
89
|
+
|
|
76
90
|
# --- reconcile: present=false on bare schema, present=true after merge ---
|
|
77
91
|
A_RC="$(make_account "$T_SD")"
|
|
78
92
|
export PROJECT_DIR="$T_SD/platform"
|
|
@@ -117,11 +131,11 @@ OUT_C="$(merge_owned_dirs_into_schema "$A_C")"
|
|
|
117
131
|
# Match a non-empty derived list, not the bare key (which prints even for zero).
|
|
118
132
|
assert_grep "domainBuckets=jobs" "$OUT_C" "con-domain-log"
|
|
119
133
|
# Summary reports the full root set and the job-owned entities with their parent.
|
|
120
|
-
assert_grep "roots=jobs,customers,customer-sites,assets,parts,ppm-contracts" "$OUT_C" "con-roots-log"
|
|
134
|
+
assert_grep "roots=jobs,customers,customer-sites,assets,parts,suppliers,ppm-contracts" "$OUT_C" "con-roots-log"
|
|
121
135
|
assert_grep "nested=Visit<-Job,Quote<-Job,PurchaseOrder<-Job" "$OUT_C" "con-nested-log"
|
|
122
136
|
ALLOWED_C="$(awk '/^```allowed-top-level$/{f=1;next} /^```$/{f=0} f' "$A_C/SCHEMA.md")"
|
|
123
137
|
# Root entities (blank Owner) get a top-level bucket.
|
|
124
|
-
for d in jobs customers customer-sites assets parts ppm-contracts; do
|
|
138
|
+
for d in jobs customers customer-sites assets parts ppm-contracts suppliers; do
|
|
125
139
|
assert_grep "$d" "$ALLOWED_C" "con-allow-$d"
|
|
126
140
|
done
|
|
127
141
|
# Job-owned entities (Owner=Job) do NOT get a root bucket.
|
|
@@ -137,6 +151,10 @@ assert_grep '`Quote` records live under `jobs/` (owned by `Job`), not a root buc
|
|
|
137
151
|
B_C="$(shasum "$A_C/SCHEMA.md" | awk '{print $1}')"
|
|
138
152
|
merge_owned_dirs_into_schema "$A_C" >/dev/null
|
|
139
153
|
assert_eq "$(shasum "$A_C/SCHEMA.md" | awk '{print $1}')" "$B_C" "con-idempotent"
|
|
154
|
+
# Supplier is a root bucket (Task 1892): received invoices file under
|
|
155
|
+
# suppliers/<supplier>/Invoices/, not a stray top-level inbound-invoices/.
|
|
156
|
+
RCS="$(PROJECT_DIR="$PROJECT_DIR" python3 "$PLAT_SCRIPTS/lib/account-schema-owned-dirs.py" reconcile "$A_C")"
|
|
157
|
+
assert_grep "ontologyEntity=Supplier bucket=suppliers present=true" "$RCS" "con-supplier-root-present"
|
|
140
158
|
|
|
141
159
|
# --- estate-agent vertical: root buckets only, listing-owned entities nested ---
|
|
142
160
|
T_E="$(make_vertical_tree '["real-agent"]' 'schema-estate-agent')"; A_E="$(make_account "$T_E")"
|
|
@@ -151,6 +169,8 @@ for d in viewings offers; do
|
|
|
151
169
|
assert_nogrep "$d" "$ALLOWED_E" "ea-noroot-$d"
|
|
152
170
|
done
|
|
153
171
|
assert_nogrep "jobs" "$ALLOWED_E" "ea-no-sitedesk-jobs"
|
|
172
|
+
# The suppliers/ bucket is construction-only; it must not leak into another vertical.
|
|
173
|
+
assert_nogrep "suppliers" "$ALLOWED_E" "ea-no-suppliers"
|
|
154
174
|
|
|
155
175
|
# --- no vertical declared: generic base only ---
|
|
156
176
|
T_N="$(make_tree '[]')"; A_N="$(make_account "$T_N")"
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Tests the one-shot quarantine restore (Task 1899): a free target is moved back,
|
|
3
|
+
# an occupied target is skipped with both copies surviving, an already-restored
|
|
4
|
+
# entry reports absent, a malformed line reports unparsable, and --dry-run
|
|
5
|
+
# changes nothing on disk.
|
|
6
|
+
set -uo pipefail
|
|
7
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
8
|
+
RESTORE="$SCRIPT_DIR/../quarantine-restore.mjs"
|
|
9
|
+
PASS=0; FAIL=0; FAILED=()
|
|
10
|
+
assert_grep() { if printf '%s' "$2" | grep -qF "$1"; then PASS=$((PASS+1)); else FAIL=$((FAIL+1)); FAILED+=("$3: expected to find [$1]"); fi; }
|
|
11
|
+
assert_nogrep() { if printf '%s' "$2" | grep -qF "$1"; then FAIL=$((FAIL+1)); FAILED+=("$3: expected NOT to find [$1]"); else PASS=$((PASS+1)); fi; }
|
|
12
|
+
assert_file() { if [ -e "$1" ]; then PASS=$((PASS+1)); else FAIL=$((FAIL+1)); FAILED+=("$2: expected file [$1]"); fi; }
|
|
13
|
+
assert_nofile() { if [ -e "$1" ]; then FAIL=$((FAIL+1)); FAILED+=("$2: expected NO file [$1]"); else PASS=$((PASS+1)); fi; }
|
|
14
|
+
assert_eq() { if [ "$1" = "$2" ]; then PASS=$((PASS+1)); else FAIL=$((FAIL+1)); FAILED+=("$3: expected [$2] got [$1]"); fi; }
|
|
15
|
+
|
|
16
|
+
# One accounts root with a single account whose manifest covers all four cases.
|
|
17
|
+
make_tree() {
|
|
18
|
+
local T; T="$(mktemp -d)"
|
|
19
|
+
local A="$T/acct-1"
|
|
20
|
+
mkdir -p "$A/.quarantine"
|
|
21
|
+
printf '{}' > "$A/account.json"
|
|
22
|
+
|
|
23
|
+
# (a) free target: quarantined copy exists, nothing at the root.
|
|
24
|
+
printf 'portal' > "$A/.quarantine/data-portal.json"
|
|
25
|
+
# (b) occupied target: quarantined copy AND a live copy the platform rewrote.
|
|
26
|
+
printf 'old' > "$A/.quarantine/wa-channel-bindings.json"
|
|
27
|
+
printf 'live' > "$A/wa-channel-bindings.json"
|
|
28
|
+
# (c) already restored by hand: manifest names a source that is gone.
|
|
29
|
+
printf 'restored' > "$A/session-titles.json"
|
|
30
|
+
|
|
31
|
+
cat > "$A/.quarantine/manifest.jsonl" <<'EOF'
|
|
32
|
+
{"name":"data-portal.json","originalPath":"data-portal.json","reason":"top-level","quarantinePath":".quarantine/data-portal.json","movedAt":"2026-07-21T19:16:05.216Z"}
|
|
33
|
+
{"name":"session-titles.json","originalPath":"session-titles.json","reason":"top-level","quarantinePath":".quarantine/session-titles.json","movedAt":"2026-07-21T19:20:00.000Z"}
|
|
34
|
+
not json at all
|
|
35
|
+
{"name":"wa-channel-bindings.json","originalPath":"wa-channel-bindings.json","reason":"top-level","quarantinePath":".quarantine/wa-channel-bindings.json","movedAt":"2026-07-21T20:45:00.000Z"}
|
|
36
|
+
EOF
|
|
37
|
+
printf '%s' "$T"
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
# --- dry run changes nothing -------------------------------------------------
|
|
41
|
+
T="$(make_tree)"; A="$T/acct-1"
|
|
42
|
+
OUT="$(node "$RESTORE" --accounts-root "$T" --dry-run 2>&1)"
|
|
43
|
+
assert_grep 'result=moved' "$OUT" "dry-run reports the move it would make"
|
|
44
|
+
assert_grep 'dry-run=1' "$OUT" "dry-run marks its lines"
|
|
45
|
+
assert_nofile "$A/data-portal.json" "dry-run leaves the root untouched"
|
|
46
|
+
assert_file "$A/.quarantine/data-portal.json" "dry-run leaves the quarantined copy"
|
|
47
|
+
assert_nofile "$A/.quarantine/restored.jsonl" "dry-run writes no restored.jsonl"
|
|
48
|
+
rm -rf "$T"
|
|
49
|
+
|
|
50
|
+
# --- real run ----------------------------------------------------------------
|
|
51
|
+
T="$(make_tree)"; A="$T/acct-1"
|
|
52
|
+
OUT="$(node "$RESTORE" --accounts-root "$T" 2>&1)"
|
|
53
|
+
|
|
54
|
+
assert_grep 'op=restore account=acct-1 name=data-portal.json result=moved' "$OUT" "free target moved"
|
|
55
|
+
assert_file "$A/data-portal.json" "free target is back at the root"
|
|
56
|
+
assert_nofile "$A/.quarantine/data-portal.json" "free target no longer in quarantine"
|
|
57
|
+
|
|
58
|
+
assert_grep 'name=wa-channel-bindings.json result=skipped-live-copy' "$OUT" "occupied target skipped"
|
|
59
|
+
assert_grep 'live' "$(cat "$A/wa-channel-bindings.json")" "live copy still wins"
|
|
60
|
+
assert_file "$A/.quarantine/wa-channel-bindings.json" "quarantined copy survives the skip"
|
|
61
|
+
|
|
62
|
+
assert_grep 'name=session-titles.json result=absent' "$OUT" "already-restored source reports absent"
|
|
63
|
+
assert_grep 'restored' "$(cat "$A/session-titles.json")" "hand-restored copy untouched"
|
|
64
|
+
|
|
65
|
+
assert_grep 'line=3 result=unparsable' "$OUT" "malformed manifest line is reported"
|
|
66
|
+
|
|
67
|
+
assert_file "$A/.quarantine/manifest.jsonl" "manifest is left intact"
|
|
68
|
+
RESTORED="$(cat "$A/.quarantine/restored.jsonl")"
|
|
69
|
+
assert_grep 'data-portal.json' "$RESTORED" "restored.jsonl records the move"
|
|
70
|
+
assert_nogrep 'wa-channel-bindings.json' "$RESTORED" "restored.jsonl records only moves"
|
|
71
|
+
assert_nogrep 'session-titles.json' "$RESTORED" "restored.jsonl records only moves"
|
|
72
|
+
rm -rf "$T"
|
|
73
|
+
|
|
74
|
+
# --- a name quarantined twice restores the NEWEST copy ------------------------
|
|
75
|
+
# The sweep took wa-channel-bindings.json up to four times on one live account,
|
|
76
|
+
# the platform rewriting it between each. The deleted quarantineDest() took the
|
|
77
|
+
# unsuffixed name first and appended .1 to the next collision, so .1 IS the newer
|
|
78
|
+
# copy. Walking the append-only manifest oldest-first would put the stalest copy
|
|
79
|
+
# back, and the newer record would then find the target occupied.
|
|
80
|
+
T="$(mktemp -d)"; A="$T/acct-2"
|
|
81
|
+
mkdir -p "$A/.quarantine"
|
|
82
|
+
printf '{}' > "$A/account.json"
|
|
83
|
+
printf 'OLDER' > "$A/.quarantine/data-portal.json"
|
|
84
|
+
printf 'NEWER' > "$A/.quarantine/data-portal.json.1"
|
|
85
|
+
cat > "$A/.quarantine/manifest.jsonl" <<'EOF'
|
|
86
|
+
{"name":"data-portal.json","originalPath":"data-portal.json","quarantinePath":".quarantine/data-portal.json","movedAt":"2026-07-21T19:16:05.216Z"}
|
|
87
|
+
{"name":"data-portal.json","originalPath":"data-portal.json","quarantinePath":".quarantine/data-portal.json.1","movedAt":"2026-07-21T20:45:00.000Z"}
|
|
88
|
+
EOF
|
|
89
|
+
OUT="$(node "$RESTORE" --accounts-root "$T" 2>&1)"
|
|
90
|
+
assert_grep 'NEWER' "$(cat "$A/data-portal.json")" "newest quarantined copy is the one restored"
|
|
91
|
+
# The target was created by THIS run, not by a live platform rewrite. Calling it
|
|
92
|
+
# skipped-live-copy would tell the operator the platform holds a current copy.
|
|
93
|
+
assert_grep 'result=skipped-superseded' "$OUT" "the older copy is superseded, not live"
|
|
94
|
+
assert_nogrep 'result=skipped-live-copy' "$OUT" "no live copy existed on this tree"
|
|
95
|
+
assert_file "$A/.quarantine/data-portal.json" "the older copy is left in quarantine, never deleted"
|
|
96
|
+
rm -rf "$T"
|
|
97
|
+
|
|
98
|
+
# --- a record pointing outside its account is refused -------------------------
|
|
99
|
+
# .quarantine is in the write guard's allowed-top-level set, so an account-scoped
|
|
100
|
+
# agent can append to its own manifest. This script runs as the platform user
|
|
101
|
+
# across every account, so an escaping path would move one account's live data
|
|
102
|
+
# into another.
|
|
103
|
+
T="$(mktemp -d)"
|
|
104
|
+
mkdir -p "$T/acct-a/.quarantine" "$T/acct-b"
|
|
105
|
+
printf '{}' > "$T/acct-a/account.json"
|
|
106
|
+
printf '{}' > "$T/acct-b/account.json"
|
|
107
|
+
printf 'LIVE' > "$T/acct-b/wa-channel-bindings.json"
|
|
108
|
+
printf 'x' > "$T/acct-a/.quarantine/evil.json"
|
|
109
|
+
cat > "$T/acct-a/.quarantine/manifest.jsonl" <<'EOF'
|
|
110
|
+
{"name":"evil.json","originalPath":"../acct-b/planted.json","quarantinePath":".quarantine/evil.json"}
|
|
111
|
+
{"name":"steal","originalPath":"stolen.json","quarantinePath":"../acct-b/wa-channel-bindings.json"}
|
|
112
|
+
EOF
|
|
113
|
+
OUT="$(node "$RESTORE" --accounts-root "$T" 2>&1)"; RC=$?
|
|
114
|
+
assert_grep 'result=refused-outside-account' "$OUT" "escaping record is refused"
|
|
115
|
+
assert_grep 'refused=2' "$OUT" "both escaping records are counted"
|
|
116
|
+
assert_nofile "$T/acct-b/planted.json" "nothing is planted in the other account"
|
|
117
|
+
assert_nofile "$T/acct-a/stolen.json" "nothing is stolen from the other account"
|
|
118
|
+
assert_file "$T/acct-b/wa-channel-bindings.json" "the other account's live file is untouched"
|
|
119
|
+
assert_eq "$RC" "1" "a refused record makes the run exit non-zero"
|
|
120
|
+
rm -rf "$T"
|
|
121
|
+
|
|
122
|
+
# --- a failed rename reaches the summary and the exit code --------------------
|
|
123
|
+
# A restore that cannot complete leaves data in quarantine. The operator runs
|
|
124
|
+
# this once and reads the tail line, so a clean summary with a silent stderr
|
|
125
|
+
# line would be the same silent-skip failure this task exists to end.
|
|
126
|
+
T="$(mktemp -d)"; A="$T/acct-3"
|
|
127
|
+
mkdir -p "$A/.quarantine"
|
|
128
|
+
printf '{}' > "$A/account.json"
|
|
129
|
+
printf 'x' > "$A/.quarantine/orphan.json"
|
|
130
|
+
# The target's parent bucket no longer exists, so the target itself is absent
|
|
131
|
+
# (not a live copy) and the rename fails with ENOENT.
|
|
132
|
+
cat > "$A/.quarantine/manifest.jsonl" <<'EOF'
|
|
133
|
+
{"name":"orphan.json","originalPath":"gone-bucket/orphan.json","quarantinePath":".quarantine/orphan.json","movedAt":"2026-07-21T19:16:05.216Z"}
|
|
134
|
+
EOF
|
|
135
|
+
OUT="$(node "$RESTORE" --accounts-root "$T" 2>&1)"; RC=$?
|
|
136
|
+
assert_grep 'result=failed' "$OUT" "the failed rename is reported"
|
|
137
|
+
assert_grep 'failed=1' "$OUT" "the failure reaches the summary line"
|
|
138
|
+
assert_eq "$RC" "1" "a failed restore makes the run exit non-zero"
|
|
139
|
+
rm -rf "$T"
|
|
140
|
+
|
|
141
|
+
# --- summary -----------------------------------------------------------------
|
|
142
|
+
echo "PASS=$PASS FAIL=$FAIL"
|
|
143
|
+
if [ "$FAIL" -gt 0 ]; then printf '%s\n' "${FAILED[@]}"; exit 1; fi
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Task 1899 — one-shot restore of everything the account-dir schema reconcile
|
|
3
|
+
// moved into <account>/.quarantine/ before it was made report-only. The sweep's
|
|
4
|
+
// allowed-list never matched what the platform and its agents write at an
|
|
5
|
+
// account root, so it took live features and live data dark with no error: the
|
|
6
|
+
// portal config, live signed agreements, the channel bindings.
|
|
7
|
+
//
|
|
8
|
+
// Per account it walks .quarantine/manifest.jsonl NEWEST-FIRST (the manifest is
|
|
9
|
+
// append-only, so that is reverse file order). Newest-first is what makes the
|
|
10
|
+
// occupied case correct for a name quarantined more than once: the most recent
|
|
11
|
+
// copy is considered first, and every older copy of that name then finds the
|
|
12
|
+
// target occupied. The live copy always wins, because the platform rewrote it
|
|
13
|
+
// after the move — four wa-channel-bindings.json records on one account prove
|
|
14
|
+
// that happened.
|
|
15
|
+
//
|
|
16
|
+
// Never deletes. The manifest is left intact so the history of what moved and
|
|
17
|
+
// what came back both survive.
|
|
18
|
+
//
|
|
19
|
+
// Usage: node quarantine-restore.mjs --accounts-root <dir> [--dry-run]
|
|
20
|
+
|
|
21
|
+
import { existsSync, readdirSync, readFileSync, renameSync, appendFileSync } from 'node:fs'
|
|
22
|
+
import { join, resolve, sep } from 'node:path'
|
|
23
|
+
|
|
24
|
+
const TAG = '[quarantine-restore]'
|
|
25
|
+
const QUARANTINE_DIR = '.quarantine'
|
|
26
|
+
|
|
27
|
+
/** True when `path` is `root` itself or lives underneath it. Both arguments must
|
|
28
|
+
* already be absolute. */
|
|
29
|
+
function isInside(root, path) {
|
|
30
|
+
return path === root || path.startsWith(root + sep)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function parseArgs(argv) {
|
|
34
|
+
let accountsRoot = null
|
|
35
|
+
let dryRun = false
|
|
36
|
+
for (let i = 0; i < argv.length; i++) {
|
|
37
|
+
if (argv[i] === '--accounts-root') {
|
|
38
|
+
accountsRoot = argv[i + 1] ?? null
|
|
39
|
+
i++
|
|
40
|
+
} else if (argv[i] === '--dry-run') {
|
|
41
|
+
dryRun = true
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return { accountsRoot, dryRun }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const { accountsRoot, dryRun } = parseArgs(process.argv.slice(2))
|
|
48
|
+
if (!accountsRoot) {
|
|
49
|
+
console.error(`${TAG} usage: quarantine-restore.mjs --accounts-root <dir> [--dry-run]`)
|
|
50
|
+
process.exit(1)
|
|
51
|
+
}
|
|
52
|
+
if (!existsSync(accountsRoot)) {
|
|
53
|
+
console.error(`${TAG} accounts root not found: ${accountsRoot}`)
|
|
54
|
+
process.exit(1)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const suffix = dryRun ? ' dry-run=1' : ''
|
|
58
|
+
const totals = {
|
|
59
|
+
moved: 0,
|
|
60
|
+
'skipped-live-copy': 0,
|
|
61
|
+
'skipped-superseded': 0,
|
|
62
|
+
absent: 0,
|
|
63
|
+
unparsable: 0,
|
|
64
|
+
refused: 0,
|
|
65
|
+
failed: 0,
|
|
66
|
+
}
|
|
67
|
+
let accounts = 0
|
|
68
|
+
|
|
69
|
+
for (const entry of readdirSync(accountsRoot)) {
|
|
70
|
+
const accountDir = join(accountsRoot, entry)
|
|
71
|
+
const accountRoot = resolve(accountDir)
|
|
72
|
+
const quarantineRoot = resolve(accountDir, QUARANTINE_DIR)
|
|
73
|
+
const manifestPath = join(accountDir, QUARANTINE_DIR, 'manifest.jsonl')
|
|
74
|
+
if (!existsSync(manifestPath)) continue
|
|
75
|
+
accounts++
|
|
76
|
+
|
|
77
|
+
// Names this run put back at the account root. A later record naming one of
|
|
78
|
+
// them found the target occupied by THIS run, not by a live copy the platform
|
|
79
|
+
// rewrote — two different states that must not share one outcome label.
|
|
80
|
+
const restoredThisRun = new Set()
|
|
81
|
+
|
|
82
|
+
let lines
|
|
83
|
+
try {
|
|
84
|
+
lines = readFileSync(manifestPath, 'utf-8').split('\n')
|
|
85
|
+
} catch (err) {
|
|
86
|
+
totals.failed++
|
|
87
|
+
console.error(
|
|
88
|
+
`${TAG} op=restore account=${entry} result=manifest-unreadable error=${err.message}`,
|
|
89
|
+
)
|
|
90
|
+
continue
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Newest-first: the manifest is append-only, so walk it backwards. The 1-based
|
|
94
|
+
// file line number is carried so an unparsable line can be pointed at.
|
|
95
|
+
const numbered = lines.map((text, i) => ({ text, lineNo: i + 1 })).filter((l) => l.text.trim())
|
|
96
|
+
for (const { text, lineNo } of numbered.reverse()) {
|
|
97
|
+
let rec
|
|
98
|
+
try {
|
|
99
|
+
rec = JSON.parse(text)
|
|
100
|
+
} catch {
|
|
101
|
+
rec = null
|
|
102
|
+
}
|
|
103
|
+
if (!rec || typeof rec.originalPath !== 'string' || typeof rec.quarantinePath !== 'string') {
|
|
104
|
+
// Reported, never silently skipped: a line dropped without a word is the
|
|
105
|
+
// exact failure class this task exists to end.
|
|
106
|
+
totals.unparsable++
|
|
107
|
+
console.log(`${TAG} op=restore account=${entry} line=${lineNo} result=unparsable${suffix}`)
|
|
108
|
+
continue
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const source = resolve(accountDir, rec.quarantinePath)
|
|
112
|
+
const target = resolve(accountDir, rec.originalPath)
|
|
113
|
+
const name = rec.originalPath
|
|
114
|
+
|
|
115
|
+
// The manifest is a file inside the account, and .quarantine is in the write
|
|
116
|
+
// guard's allowed-top-level set, so an account-scoped agent can append to it.
|
|
117
|
+
// This script runs as the platform user across every account, so a record
|
|
118
|
+
// whose paths escape the account would move one account's live data into
|
|
119
|
+
// another. Every record the reconcile ever wrote was a bare basename plus a
|
|
120
|
+
// path under .quarantine/, so this refuses nothing genuine.
|
|
121
|
+
if (!isInside(quarantineRoot, source) || !isInside(accountRoot, target)) {
|
|
122
|
+
totals.refused++
|
|
123
|
+
console.error(
|
|
124
|
+
`${TAG} op=restore account=${entry} line=${lineNo} result=refused-outside-account${suffix}`,
|
|
125
|
+
)
|
|
126
|
+
continue
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (!existsSync(source)) {
|
|
130
|
+
totals.absent++
|
|
131
|
+
console.log(`${TAG} op=restore account=${entry} name=${name} result=absent${suffix}`)
|
|
132
|
+
continue
|
|
133
|
+
}
|
|
134
|
+
if (existsSync(target) || restoredThisRun.has(target)) {
|
|
135
|
+
// A target this run created is superseded, not live. Calling both
|
|
136
|
+
// skipped-live-copy would tell the operator the platform holds a current
|
|
137
|
+
// copy when what is really left is an older copy still in quarantine.
|
|
138
|
+
const outcome = restoredThisRun.has(target) ? 'skipped-superseded' : 'skipped-live-copy'
|
|
139
|
+
totals[outcome]++
|
|
140
|
+
console.log(`${TAG} op=restore account=${entry} name=${name} result=${outcome}${suffix}`)
|
|
141
|
+
continue
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (dryRun) {
|
|
145
|
+
totals.moved++
|
|
146
|
+
restoredThisRun.add(target)
|
|
147
|
+
console.log(`${TAG} op=restore account=${entry} name=${name} result=moved${suffix}`)
|
|
148
|
+
continue
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
renameSync(source, target)
|
|
152
|
+
appendFileSync(
|
|
153
|
+
join(accountDir, QUARANTINE_DIR, 'restored.jsonl'),
|
|
154
|
+
JSON.stringify({ ...rec, restoredAt: new Date().toISOString() }) + '\n',
|
|
155
|
+
)
|
|
156
|
+
totals.moved++
|
|
157
|
+
restoredThisRun.add(target)
|
|
158
|
+
console.log(`${TAG} op=restore account=${entry} name=${name} result=moved`)
|
|
159
|
+
} catch (err) {
|
|
160
|
+
totals.failed++
|
|
161
|
+
console.error(
|
|
162
|
+
`${TAG} op=restore account=${entry} name=${name} result=failed error=${err.message}`,
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
console.log(
|
|
169
|
+
`${TAG} accounts=${accounts} moved=${totals.moved} skipped-live-copy=${totals['skipped-live-copy']} skipped-superseded=${totals['skipped-superseded']} absent=${totals.absent} unparsable=${totals.unparsable} refused=${totals.refused} failed=${totals.failed}${suffix}`,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
// A rename that failed, a manifest that would not open, and a record refused for
|
|
173
|
+
// pointing outside its account all leave data in quarantine. The operator runs
|
|
174
|
+
// this once and reads the tail line, so the exit code has to say so too.
|
|
175
|
+
if (totals.failed > 0 || totals.refused > 0) process.exitCode = 1
|
package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.d.ts
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
export interface ReconcileCounts {
|
|
2
2
|
strayTopLevel: number;
|
|
3
3
|
overDeep: number;
|
|
4
|
-
quarantined: number;
|
|
5
4
|
badName: number;
|
|
6
5
|
}
|
|
6
|
+
export interface ReconcileResult extends ReconcileCounts {
|
|
7
|
+
/** The account-root entries counted as strays, by name — the same set
|
|
8
|
+
* strayTopLevel counts, so PROTECTED_TOP_LEVEL entries and dot-prefixed
|
|
9
|
+
* entries are absent from it, and an account that failed the SCHEMA.md
|
|
10
|
+
* fail-open check returns it empty. A count alone cannot distinguish six
|
|
11
|
+
* pieces of debris from a live plugin config; this list is what makes the
|
|
12
|
+
* report-only posture actionable. */
|
|
13
|
+
strayNames: string[];
|
|
14
|
+
}
|
|
7
15
|
/** The single per-run summary line body. A non-zero count is the standing signal
|
|
8
16
|
* that an agent authored folders the hard guards could not prevent. Pure
|
|
9
17
|
* formatter so the emitter and its test share one wording. */
|
|
@@ -14,18 +22,27 @@ export declare function formatReconcileLine(c: ReconcileCounts): string;
|
|
|
14
22
|
export declare function parseAllowedTopLevel(schemaText: string): Set<string>;
|
|
15
23
|
/** Reconcile one account dir. Reads its SCHEMA.md allowed set; an absent/empty
|
|
16
24
|
* set skips the account (fail open, like the hook — an un-seeded legacy account
|
|
17
|
-
* must not have its whole root
|
|
18
|
-
*
|
|
19
|
-
export declare function reconcileAccount(accountDir: string
|
|
25
|
+
* must not have its whole root reported as stray). Counts and names stray
|
|
26
|
+
* top-level entries; counts over-deep files and bad names. Moves nothing. */
|
|
27
|
+
export declare function reconcileAccount(accountDir: string): ReconcileResult;
|
|
20
28
|
export interface ReconcileDeps {
|
|
21
29
|
accountsRoot: string;
|
|
22
30
|
logger: (line: string) => void;
|
|
23
|
-
now?: () => number;
|
|
24
31
|
}
|
|
25
|
-
/** One reconcile pass over every account under the accounts root
|
|
32
|
+
/** One reconcile pass over every account under the accounts root: one op=strays
|
|
33
|
+
* line naming the unaccounted-for entries of each account that has any, then the
|
|
26
34
|
* single summary line. An account is any child dir with an account.json (the
|
|
27
35
|
* account marker the census uses). Per-account errors are swallowed so one bad
|
|
28
|
-
* account never aborts the run.
|
|
36
|
+
* account never aborts the run.
|
|
37
|
+
*
|
|
38
|
+
* The op=strays line is the standing signal. When a feature that reads an
|
|
39
|
+
* account-root file goes dark, seeing that file named here says the schema does
|
|
40
|
+
* not account for it and it is still on disk, so declare it. The absence of its
|
|
41
|
+
* name reads as "gone" only for a file that could have been named at all: an
|
|
42
|
+
* entry in PROTECTED_TOP_LEVEL, a dot-prefixed entry, and every entry of an
|
|
43
|
+
* account whose SCHEMA.md is missing or has an empty allowed block are never
|
|
44
|
+
* named whether they are present or not. Check the account root itself before
|
|
45
|
+
* concluding a file is gone. */
|
|
29
46
|
export declare function runReconcile(deps: ReconcileDeps): void;
|
|
30
47
|
export interface AccountDirSchemaReconcile {
|
|
31
48
|
start(): void;
|
package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"account-dir-schema-reconcile.d.ts","sourceRoot":"","sources":["../src/account-dir-schema-reconcile.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"account-dir-schema-reconcile.d.ts","sourceRoot":"","sources":["../src/account-dir-schema-reconcile.ts"],"names":[],"mappings":"AAqFA,MAAM,WAAW,eAAe;IAC9B,aAAa,EAAE,MAAM,CAAA;IACrB,QAAQ,EAAE,MAAM,CAAA;IAChB,OAAO,EAAE,MAAM,CAAA;CAChB;AAED,MAAM,WAAW,eAAgB,SAAQ,eAAe;IACtD;;;;;0CAKsC;IACtC,UAAU,EAAE,MAAM,EAAE,CAAA;CACrB;AAED;;+DAE+D;AAC/D,wBAAgB,mBAAmB,CAAC,CAAC,EAAE,eAAe,GAAG,MAAM,CAE9D;AAED;;8EAE8E;AAC9E,wBAAgB,oBAAoB,CAAC,UAAU,EAAE,MAAM,GAAG,GAAG,CAAC,MAAM,CAAC,CAcpE;AA+ED;;;8EAG8E;AAC9E,wBAAgB,gBAAgB,CAAC,UAAU,EAAE,MAAM,GAAG,eAAe,CAoCpE;AAED,MAAM,WAAW,aAAa;IAC5B,YAAY,EAAE,MAAM,CAAA;IACpB,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAA;CAC/B;AAED;;;;;;;;;;;;;iCAaiC;AACjC,wBAAgB,YAAY,CAAC,IAAI,EAAE,aAAa,GAAG,IAAI,CAyBtD;AAED,MAAM,WAAW,yBAAyB;IACxC,KAAK,IAAI,IAAI,CAAA;IACb,IAAI,IAAI,IAAI,CAAA;CACb;AAED;oDACoD;AACpD,wBAAgB,+BAA+B,CAC7C,IAAI,EAAE,aAAa,GAAG;IAAE,UAAU,EAAE,MAAM,CAAA;CAAE,GAC3C,yBAAyB,CAiB3B"}
|
package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.js
CHANGED
|
@@ -11,43 +11,45 @@
|
|
|
11
11
|
// the flat operator-data buckets, mirroring fs-schema-guard.sh's own depth rule
|
|
12
12
|
// exactly (projects/contacts/documents cap at 3 path segments; every other
|
|
13
13
|
// tool-owned bucket is unbounded and not walked), and (c) bad-named folders and
|
|
14
|
-
// files under those same buckets, mirroring the guard's name convention.
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
14
|
+
// files under those same buckets, mirroring the guard's name convention. All
|
|
15
|
+
// three are REPORTED, never moved: the pass used to quarantine stray top-level
|
|
16
|
+
// entries, and because its allowed-list lagged what the platform and its agents
|
|
17
|
+
// actually write at an account root, a mismatch on an entry something still read
|
|
18
|
+
// took that feature or that data dark with no error anywhere (Task 1899 — the
|
|
19
|
+
// portal config, live signed agreements and the channel bindings all went for
|
|
20
|
+
// exactly that reason). Nothing is moved, nothing is
|
|
21
|
+
// deleted, nothing is silently rewritten; re-filing a stray to its schema-correct
|
|
22
|
+
// home is the data-manager specialist's job on dispatch, not this pass.
|
|
21
23
|
//
|
|
22
24
|
// Shape mirrors the standing-audit family (rootless-client-audit.ts): a pure
|
|
23
25
|
// formatter (unit-tested) + an impure per-account collector + a start/stop
|
|
24
26
|
// factory whose setInterval is unref'd. Purely filesystem — no Neo4j, no graph
|
|
25
27
|
// writes, no deletes. Best-effort by construction: a per-account error is caught
|
|
26
28
|
// so one bad account never aborts the run.
|
|
27
|
-
import { existsSync,
|
|
29
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
28
30
|
import { join, relative, sep } from 'node:path';
|
|
29
31
|
const TAG = '[fs-reconcile]';
|
|
30
32
|
const QUARANTINE_DIR = '.quarantine';
|
|
31
|
-
/** Platform control-plane entries the reconcile NEVER
|
|
32
|
-
* what an account's SCHEMA.md allowed set says. provision-account-dir.sh makes
|
|
33
|
+
/** Platform control-plane entries the reconcile NEVER counts as strays, regardless
|
|
34
|
+
* of what an account's SCHEMA.md allowed set says. provision-account-dir.sh makes
|
|
33
35
|
* the account dir a git root (`.git`) and writes its hook-enforcement settings
|
|
34
|
-
* into `.claude/`, and the account's identity lives in account.json
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
* block omits one of these must not have it moved. This is a fail-SAFE belt on
|
|
39
|
-
* the control plane, the mirror of the guard's fail-OPEN on a missing schema —
|
|
40
|
-
* operator strays are still quarantined; the account's own machinery is not.
|
|
36
|
+
* into `.claude/`, and the account's identity lives in account.json. SCHEMA.md is
|
|
37
|
+
* refreshed on every provision, but an account carrying a stale/older-template/
|
|
38
|
+
* hand-edited schema whose allowed block omits one of these must not be reported
|
|
39
|
+
* on every run for machinery it is required to have.
|
|
41
40
|
*
|
|
42
41
|
* The second group is platform-written control-plane FILES the platform reads
|
|
43
42
|
* and writes at the account root every run. Unlike the tool-owned buckets, no
|
|
44
|
-
* plugin declares them into the SCHEMA.md allowed-top-level block
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
* -
|
|
49
|
-
*
|
|
50
|
-
*
|
|
43
|
+
* plugin declares them into the SCHEMA.md allowed-top-level block: calendar-
|
|
44
|
+
* availability.json is the booking page's availability config (quarantining it
|
|
45
|
+
* froze a live booking page, Task 1877 fix); wa/webchat/telegram-channel-
|
|
46
|
+
* bindings.json route each channel's inbound; canonical-webchat-session.json
|
|
47
|
+
* pins the webchat session; session-titles.json holds operator renames. Each is
|
|
48
|
+
* `join(accountDir, <name>)` in platform code, none is operator data.
|
|
49
|
+
*
|
|
50
|
+
* Since Task 1899 this belt no longer protects anything from being moved,
|
|
51
|
+
* because nothing moves. It keeps the account's own machinery out of the stray
|
|
52
|
+
* count so the standing op=strays line names only what an operator must act on. */
|
|
51
53
|
const PROTECTED_TOP_LEVEL = new Set([
|
|
52
54
|
'.git',
|
|
53
55
|
'.claude',
|
|
@@ -79,7 +81,7 @@ const FILE_NAME_RE = /^[a-z0-9-]+(\.[a-z0-9-]+)*$/;
|
|
|
79
81
|
* that an agent authored folders the hard guards could not prevent. Pure
|
|
80
82
|
* formatter so the emitter and its test share one wording. */
|
|
81
83
|
export function formatReconcileLine(c) {
|
|
82
|
-
return `${TAG} stray-top-level=${c.strayTopLevel} over-deep=${c.overDeep}
|
|
84
|
+
return `${TAG} stray-top-level=${c.strayTopLevel} over-deep=${c.overDeep} bad-name=${c.badName}`;
|
|
83
85
|
}
|
|
84
86
|
/** Parse the fenced ```allowed-top-level block from an account's SCHEMA.md —
|
|
85
87
|
* the same structured set fs-schema-guard.sh parses with awk. Returns the
|
|
@@ -189,22 +191,12 @@ function countBadNameEntries(accountDir, bucket) {
|
|
|
189
191
|
walk(bucketRoot);
|
|
190
192
|
return count;
|
|
191
193
|
}
|
|
192
|
-
/** Pick a non-colliding destination name inside .quarantine for a stray. */
|
|
193
|
-
function quarantineDest(quarantineRoot, name) {
|
|
194
|
-
let candidate = join(quarantineRoot, name);
|
|
195
|
-
let n = 1;
|
|
196
|
-
while (existsSync(candidate)) {
|
|
197
|
-
candidate = join(quarantineRoot, `${name}.${n}`);
|
|
198
|
-
n++;
|
|
199
|
-
}
|
|
200
|
-
return candidate;
|
|
201
|
-
}
|
|
202
194
|
/** Reconcile one account dir. Reads its SCHEMA.md allowed set; an absent/empty
|
|
203
195
|
* set skips the account (fail open, like the hook — an un-seeded legacy account
|
|
204
|
-
* must not have its whole root
|
|
205
|
-
*
|
|
206
|
-
export function reconcileAccount(accountDir
|
|
207
|
-
const empty = { strayTopLevel: 0, overDeep: 0,
|
|
196
|
+
* must not have its whole root reported as stray). Counts and names stray
|
|
197
|
+
* top-level entries; counts over-deep files and bad names. Moves nothing. */
|
|
198
|
+
export function reconcileAccount(accountDir) {
|
|
199
|
+
const empty = { strayTopLevel: 0, overDeep: 0, badName: 0, strayNames: [] };
|
|
208
200
|
const schemaPath = join(accountDir, 'SCHEMA.md');
|
|
209
201
|
if (!existsSync(schemaPath))
|
|
210
202
|
return empty;
|
|
@@ -224,47 +216,36 @@ export function reconcileAccount(accountDir, now) {
|
|
|
224
216
|
catch {
|
|
225
217
|
return empty;
|
|
226
218
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
try {
|
|
234
|
-
mkdirSync(quarantineRoot, { recursive: true });
|
|
235
|
-
const dest = quarantineDest(quarantineRoot, name);
|
|
236
|
-
renameSync(join(accountDir, name), dest);
|
|
237
|
-
const record = {
|
|
238
|
-
name,
|
|
239
|
-
originalPath: name,
|
|
240
|
-
reason: 'top-level',
|
|
241
|
-
quarantinePath: relative(accountDir, dest),
|
|
242
|
-
movedAt: new Date(now()).toISOString(),
|
|
243
|
-
};
|
|
244
|
-
appendFileSync(manifestPath, JSON.stringify(record) + '\n');
|
|
245
|
-
quarantined++;
|
|
246
|
-
}
|
|
247
|
-
catch {
|
|
248
|
-
// Move failed (permissions, race). Counted as a detected stray below,
|
|
249
|
-
// not as quarantined — the summary line stays honest.
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
}
|
|
219
|
+
// A dot-prefixed top-level entry is tool/system state, never operator data —
|
|
220
|
+
// the same judgement countBadNameEntries applies when it skips dotfiles. The
|
|
221
|
+
// stray check must agree, or it reports .remember/.superpowers/.wrangler on
|
|
222
|
+
// every run (Task 1891). .git/.claude/.quarantine stay covered by
|
|
223
|
+
// PROTECTED_TOP_LEVEL regardless.
|
|
224
|
+
const strays = rootEntries.filter((name) => !name.startsWith('.') && !PROTECTED_TOP_LEVEL.has(name) && !allowed.has(name));
|
|
253
225
|
let overDeep = 0;
|
|
254
226
|
let badName = 0;
|
|
255
227
|
for (const bucket of DEPTH_CHECKED_BUCKETS) {
|
|
256
228
|
overDeep += countOverDeepFiles(accountDir, bucket);
|
|
257
229
|
badName += countBadNameEntries(accountDir, bucket);
|
|
258
230
|
}
|
|
259
|
-
return { strayTopLevel: strays.length, overDeep,
|
|
231
|
+
return { strayTopLevel: strays.length, overDeep, badName, strayNames: strays };
|
|
260
232
|
}
|
|
261
|
-
/** One reconcile pass over every account under the accounts root
|
|
233
|
+
/** One reconcile pass over every account under the accounts root: one op=strays
|
|
234
|
+
* line naming the unaccounted-for entries of each account that has any, then the
|
|
262
235
|
* single summary line. An account is any child dir with an account.json (the
|
|
263
236
|
* account marker the census uses). Per-account errors are swallowed so one bad
|
|
264
|
-
* account never aborts the run.
|
|
237
|
+
* account never aborts the run.
|
|
238
|
+
*
|
|
239
|
+
* The op=strays line is the standing signal. When a feature that reads an
|
|
240
|
+
* account-root file goes dark, seeing that file named here says the schema does
|
|
241
|
+
* not account for it and it is still on disk, so declare it. The absence of its
|
|
242
|
+
* name reads as "gone" only for a file that could have been named at all: an
|
|
243
|
+
* entry in PROTECTED_TOP_LEVEL, a dot-prefixed entry, and every entry of an
|
|
244
|
+
* account whose SCHEMA.md is missing or has an empty allowed block are never
|
|
245
|
+
* named whether they are present or not. Check the account root itself before
|
|
246
|
+
* concluding a file is gone. */
|
|
265
247
|
export function runReconcile(deps) {
|
|
266
|
-
const
|
|
267
|
-
const total = { strayTopLevel: 0, overDeep: 0, quarantined: 0, badName: 0 };
|
|
248
|
+
const total = { strayTopLevel: 0, overDeep: 0, badName: 0 };
|
|
268
249
|
let entries;
|
|
269
250
|
try {
|
|
270
251
|
entries = readdirSync(deps.accountsRoot);
|
|
@@ -278,10 +259,12 @@ export function runReconcile(deps) {
|
|
|
278
259
|
if (!existsSync(join(accountDir, 'account.json')))
|
|
279
260
|
continue;
|
|
280
261
|
try {
|
|
281
|
-
const c = reconcileAccount(accountDir
|
|
262
|
+
const c = reconcileAccount(accountDir);
|
|
263
|
+
if (c.strayNames.length > 0) {
|
|
264
|
+
deps.logger(`${TAG} op=strays account=${entry} names=${c.strayNames.join(',')}`);
|
|
265
|
+
}
|
|
282
266
|
total.strayTopLevel += c.strayTopLevel;
|
|
283
267
|
total.overDeep += c.overDeep;
|
|
284
|
-
total.quarantined += c.quarantined;
|
|
285
268
|
total.badName += c.badName;
|
|
286
269
|
}
|
|
287
270
|
catch {
|