mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-17 02:15:31 +02:00
fix: adversarial round — the P0 finalize fail-safe and 12 hardened findings
Three adversarial passes (Claude fresh-context, Codex chaos, Codex structured with P1 gate) on the full wave diff. Multi-source findings, all fixed: - P0: finalize_queue is now explicit-delete-only — a record is unlinked ONLY when classification proves it staged or dropped; a classifier crash, a missing class file, or a malformed pulled .brain-privacy-map.json (which previously nuked the whole snapshotted queue, remotely triggerable) now retains everything, warns, and re-drains next run. load_privacy_map treats corrupt maps as retain-all, never as empty. - next-version cannot silently drop a live claim: unreadable advertised refs get a targeted --depth=1 fetch + retry; still-unreadable claims surface as UNKNOWN warnings instead of duplicate-version silence. - session-update lock: ownership-checked EXIT trap (a TTL-reclaimed holder can no longer delete the new holder's lock) + a 5-min background heartbeat so a legitimately-slow pull/setup is never reclaimed while alive. - ensure-event collapses ALL same-(event,source) duplicates to one canonical entry; unique per-process tmp path; setup call sites surface (not swallow) the hardened refusals. - memory-ingest: --limit counts only policy-permitted pages (denied records no longer starve permitted ones); --probe applies the same policy filter as --bulk (skipped_policy_* fields on the report). - version-bump repair accepts a genuine literal 0.0.0.0 VERSION file. - slug heal restricted to the stray-.git shape — package.json-anchored wrapper roots keep their legit sticky identity (#2212 preserved). - brain-sync: idle fast path sees leftover .migrating records; unparseable spool records quarantine instead of warning forever; migration comment stops overclaiming the transition-window race. - CDP throttling justifications document override persistence (callers own restoration), pinned in the allowlist test. Deferred with record: deny retroactivity for already-ingested pages (P2 TODO, same semantics as the code-import gate); legacy-migration tail race (transition-window, requires pre-spool writers). 288 pass / 0 fail across the 10 touched suites. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
b7d44c45b4
commit
fd0dbdeea2
+99
-37
@@ -140,8 +140,10 @@ spool_has_records() {
|
|||||||
# os.replace, one file per line). Reads the file TWICE before unlinking: a
|
# os.replace, one file per line). Reads the file TWICE before unlinking: a
|
||||||
# pre-rename writer can still append through its already-open fd after our
|
# pre-rename writer can still append through its already-open fd after our
|
||||||
# rename, and those appends land in the renamed file — the second pass
|
# rename, and those appends land in the renamed file — the second pass
|
||||||
# catches them (the tail race the shared-file design could never close).
|
# NARROWS the tail-race window (transition-only: it applies to pre-spool
|
||||||
# Unparseable lines migrate as-is; finalize_queue keeps + warns on them.
|
# writers, and a writer that appends after the second read but before the
|
||||||
|
# unlink can still lose that line; spool-native writers are immune).
|
||||||
|
# Unparseable lines migrate as-is; finalize_queue quarantines + warns on them.
|
||||||
convert_legacy_file() {
|
convert_legacy_file() {
|
||||||
local legacy="$1"
|
local legacy="$1"
|
||||||
python3 - "$legacy" "$QUEUE_DIR" <<'PYEOF' 2>/dev/null || true
|
python3 - "$legacy" "$QUEUE_DIR" <<'PYEOF' 2>/dev/null || true
|
||||||
@@ -244,16 +246,29 @@ def load_lines(path):
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
def load_privacy_map(path):
|
def load_privacy_map(path):
|
||||||
|
# Returns (entries, corrupt). Non-dict entries are filtered out
|
||||||
|
# defensively — the map may be PULLED from the artifacts remote, so a
|
||||||
|
# malformed entry like ["bad"] is remotely triggerable and used to raise
|
||||||
|
# mid-classification (after the snapshot manifest was written), which the
|
||||||
|
# old finalize turned into a full queue wipe. Any malformed shape also
|
||||||
|
# marks the map CORRUPT: privacy classification cannot be trusted, so the
|
||||||
|
# caller holds every queued record instead of guessing (a corrupt privacy
|
||||||
|
# map silently treated as empty would over-share behavioral data).
|
||||||
try:
|
try:
|
||||||
with open(path) as f:
|
with open(path) as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
# Expected: [{"pattern": "glob", "class": "artifact" | "behavioral"}]
|
except FileNotFoundError:
|
||||||
return data if isinstance(data, list) else []
|
return [], False
|
||||||
except (FileNotFoundError, json.JSONDecodeError):
|
except json.JSONDecodeError:
|
||||||
return []
|
return [], True
|
||||||
|
if not isinstance(data, list):
|
||||||
|
return [], True
|
||||||
|
# Expected: [{"pattern": "glob", "class": "artifact" | "behavioral"}]
|
||||||
|
entries = [e for e in data if isinstance(e, dict)]
|
||||||
|
return entries, len(entries) != len(data)
|
||||||
|
|
||||||
allowlist_globs = load_lines(allowlist_path)
|
allowlist_globs = load_lines(allowlist_path)
|
||||||
privacy_map = load_privacy_map(privacy_path)
|
privacy_map, privacy_corrupt = load_privacy_map(privacy_path)
|
||||||
# Normalize skip entries to the POSIX form queued paths use, so a backslash
|
# Normalize skip entries to the POSIX form queued paths use, so a backslash
|
||||||
# entry in .brain-skip.txt still matches on Windows. The drain is the safety
|
# entry in .brain-skip.txt still matches on Windows. The drain is the safety
|
||||||
# boundary that actually stages files, so it must normalize identically to
|
# boundary that actually stages files, so it must normalize identically to
|
||||||
@@ -318,6 +333,14 @@ def mode_allows(cls, mode):
|
|||||||
|
|
||||||
final = []
|
final = []
|
||||||
classified = {"retained": [], "dropped": {"skipped": [], "invalid": [], "unmatched": [], "missing": []}}
|
classified = {"retained": [], "dropped": {"skipped": [], "invalid": [], "unmatched": [], "missing": []}}
|
||||||
|
if privacy_corrupt:
|
||||||
|
# Fail-safe: with an untrustworthy privacy map, stage NOTHING and drop
|
||||||
|
# NOTHING — retain every queued record until the map is fixed. The next
|
||||||
|
# drain re-classifies from scratch.
|
||||||
|
print("BRAIN_SYNC: warning: privacy map at " + privacy_path +
|
||||||
|
" is malformed — holding all queued records until it is fixed", file=sys.stderr)
|
||||||
|
classified["retained"] = sorted(queue_paths)
|
||||||
|
queue_paths = set()
|
||||||
for p in sorted(queue_paths):
|
for p in sorted(queue_paths):
|
||||||
if p in skip_lines:
|
if p in skip_lines:
|
||||||
classified["dropped"]["skipped"].append(p)
|
classified["dropped"]["skipped"].append(p)
|
||||||
@@ -353,25 +376,34 @@ PYEOF
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Finalize the drain: delete exactly the spool record files this drain
|
# Finalize the drain: delete exactly the spool record files this drain
|
||||||
# consumed (per the snapshot manifest), keeping retained (privacy/mode-held)
|
# consumed (per the snapshot manifest) AND positively classified. Deletion is
|
||||||
# and unparseable records queued. The predecessor (a shared-file queue
|
# EXPLICIT-DELETE-ONLY: a record is unlinked only when its path appears in
|
||||||
# rewrite) had a lockless-append race between its live re-read and the
|
# (staged paths ∪ classified dropped). The old polarity ("delete unless
|
||||||
# os.replace; with one file per record that race class is structurally gone —
|
# retained") turned a missing/unparseable classification into retained=∅ and
|
||||||
# a concurrent enqueue is a separate file the snapshot never listed, so
|
# wiped every snapshotted record — remotely triggerable via a malformed
|
||||||
# finalize cannot touch it. Crash semantics are at-least-once: a drain that
|
# pulled privacy map that raised AFTER the manifest write. Now a
|
||||||
# dies before finalize leaves its spool files in place and the next run
|
# missing/unparseable class_file or paths_file deletes NOTHING (warn +
|
||||||
# re-drains them; downstream content-hash dedup absorbs the duplicates.
|
# return), and a path the classification never mentions stays queued.
|
||||||
# Dropped-path detail goes to a 0600 sidecar so the status line can stay
|
# The predecessor (a shared-file queue rewrite) had a lockless-append race
|
||||||
# content-free (counts only).
|
# between its live re-read and the os.replace; with one file per record that
|
||||||
|
# race class is structurally gone — a concurrent enqueue is a separate file
|
||||||
|
# the snapshot never listed, so finalize cannot touch it. Crash semantics are
|
||||||
|
# at-least-once: a drain that dies before finalize leaves its spool files in
|
||||||
|
# place and the next run re-drains them; downstream content-hash dedup
|
||||||
|
# absorbs the duplicates. Unparseable records move to $QUEUE_DIR/quarantine/
|
||||||
|
# (never deleted) so they stop re-warning at every boundary. Dropped-path
|
||||||
|
# detail goes to a 0600 sidecar so the status line can stay content-free
|
||||||
|
# (counts only).
|
||||||
finalize_queue() {
|
finalize_queue() {
|
||||||
local snapshot_file="$1" # spool filenames this drain consumed, one per line
|
local snapshot_file="$1" # spool filenames this drain consumed, one per line
|
||||||
local class_file="$2" # classification JSON from compute_paths_to_stage
|
local class_file="$2" # classification JSON from compute_paths_to_stage
|
||||||
|
local paths_file="$3" # staged paths (compute_paths_to_stage stdout), one per line
|
||||||
# Fail-open by design (a failed finalize self-corrects next run: re-stage →
|
# Fail-open by design (a failed finalize self-corrects next run: re-stage →
|
||||||
# nothing-to-commit), but say so — a silent failure here would let the
|
# nothing-to-commit), but say so — a silent failure here would let the
|
||||||
# subsequent "ok/idle" status claim a drain that did not happen.
|
# subsequent "ok/idle" status claim a drain that did not happen.
|
||||||
python3 - "$QUEUE_DIR" "$snapshot_file" "$class_file" "$GSTACK_HOME/.brain-sync-drops.json" <<'PYEOF' || echo "BRAIN_SYNC: warning: queue finalize failed — entries retained; next run re-drains" >&2
|
python3 - "$QUEUE_DIR" "$snapshot_file" "$class_file" "$paths_file" "$GSTACK_HOME/.brain-sync-drops.json" <<'PYEOF' || echo "BRAIN_SYNC: warning: queue finalize failed — entries retained; next run re-drains" >&2
|
||||||
import json, os, sys, time
|
import json, os, sys, time
|
||||||
spool_dir, snapshot_file, class_file, drops_file = sys.argv[1:5]
|
spool_dir, snapshot_file, class_file, paths_file, drops_file = sys.argv[1:6]
|
||||||
|
|
||||||
def lines(path):
|
def lines(path):
|
||||||
try:
|
try:
|
||||||
@@ -380,15 +412,26 @@ def lines(path):
|
|||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
# Explicit-delete-only inputs. Either input unreadable → delete NOTHING.
|
||||||
try:
|
try:
|
||||||
with open(class_file) as f:
|
with open(class_file) as f:
|
||||||
classified = json.load(f)
|
classified = json.load(f)
|
||||||
|
if not isinstance(classified, dict):
|
||||||
|
raise ValueError("classification is not an object")
|
||||||
except Exception:
|
except Exception:
|
||||||
classified = {"retained": [], "dropped": {}}
|
print("BRAIN_SYNC: warning: classification unreadable — no queue records deleted; next run re-drains", file=sys.stderr)
|
||||||
retained = set(classified.get("retained", []))
|
sys.exit(0)
|
||||||
|
try:
|
||||||
|
with open(paths_file) as f:
|
||||||
|
staged = {l.strip() for l in f if l.strip()}
|
||||||
|
except Exception:
|
||||||
|
print("BRAIN_SYNC: warning: staged-paths file unreadable — no queue records deleted; next run re-drains", file=sys.stderr)
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
dropped = set()
|
dropped = set()
|
||||||
for group in (classified.get("dropped", {}) or {}).values():
|
for group in (classified.get("dropped", {}) or {}).values():
|
||||||
dropped.update(group)
|
dropped.update(group)
|
||||||
|
deletable = staged | dropped
|
||||||
|
|
||||||
unparseable = 0
|
unparseable = 0
|
||||||
for name in lines(snapshot_file):
|
for name in lines(snapshot_file):
|
||||||
@@ -404,16 +447,24 @@ for name in lines(snapshot_file):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
if not isinstance(p, str):
|
if not isinstance(p, str):
|
||||||
unparseable += 1 # keep — never destroy what we can't read
|
# Never destroy what we can't read — but don't leave it re-warning at
|
||||||
|
# every boundary either: move it aside for inspection.
|
||||||
|
unparseable += 1
|
||||||
|
try:
|
||||||
|
qdir = os.path.join(spool_dir, "quarantine")
|
||||||
|
os.makedirs(qdir, exist_ok=True)
|
||||||
|
os.replace(full, os.path.join(qdir, name))
|
||||||
|
except OSError:
|
||||||
|
pass # quarantine move failed — leave in place; next run retries
|
||||||
continue
|
continue
|
||||||
if p in retained:
|
if p not in deletable:
|
||||||
continue # stays queued: syncs under a higher mode
|
continue # retained / unclassified: stays queued (explicit-delete-only)
|
||||||
try:
|
try:
|
||||||
os.unlink(full) # staged or dropped: fully processed
|
os.unlink(full) # staged or dropped: fully processed
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
pass
|
pass
|
||||||
if unparseable:
|
if unparseable:
|
||||||
print(f"BRAIN_SYNC: {unparseable} unparseable spool record(s) held (inspect {spool_dir})", file=sys.stderr)
|
print(f"BRAIN_SYNC: {unparseable} unparseable spool record(s) moved to quarantine (inspect {os.path.join(spool_dir, 'quarantine')})", file=sys.stderr)
|
||||||
|
|
||||||
if dropped:
|
if dropped:
|
||||||
fd = os.open(drops_file, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
fd = os.open(drops_file, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||||
@@ -573,11 +624,13 @@ subcmd_once() {
|
|||||||
# nothing to classify, retain, or drop, and a record created after this
|
# nothing to classify, retain, or drop, and a record created after this
|
||||||
# check simply waits for the next boundary. The legacy file is checked too:
|
# check simply waits for the next boundary. The legacy file is checked too:
|
||||||
# an OLD writer may have recreated it after the migration above (it gets
|
# an OLD writer may have recreated it after the migration above (it gets
|
||||||
# migrated next run, but the depth is honest now). (The detector above
|
# migrated next run, but the depth is honest now) — and so is a leftover
|
||||||
# already ran: its whole point is re-pushing stranded commits when the
|
# .migrating file: if its conversion failed above (e.g. python3 missing),
|
||||||
# queue is empty.) The lock-release trap installed at acquisition covers
|
# records are still pending, so "idle" would be dishonest. (The detector
|
||||||
# this exit.
|
# above already ran: its whole point is re-pushing stranded commits when
|
||||||
if ! spool_has_records && [ ! -s "$QUEUE" ]; then
|
# the queue is empty.) The lock-release trap installed at acquisition
|
||||||
|
# covers this exit.
|
||||||
|
if ! spool_has_records && [ ! -s "$QUEUE" ] && [ ! -s "$QUEUE.migrating" ]; then
|
||||||
write_status "idle" "queue empty"
|
write_status "idle" "queue empty"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
@@ -589,11 +642,20 @@ subcmd_once() {
|
|||||||
# Single trap covers all: lock cleanup AND tempfile cleanup.
|
# Single trap covers all: lock cleanup AND tempfile cleanup.
|
||||||
trap 'rm -f "$paths_file" "$class_file" "$snapshot_file" 2>/dev/null; rm -rf "$lock_dir" 2>/dev/null || true' EXIT INT TERM
|
trap 'rm -f "$paths_file" "$class_file" "$snapshot_file" 2>/dev/null; rm -rf "$lock_dir" 2>/dev/null || true' EXIT INT TERM
|
||||||
|
|
||||||
compute_paths_to_stage "$mode" "$class_file" "$snapshot_file" > "$paths_file"
|
# Fail-safe (G1): a classifier that dies mid-run (ENOSPC/OOM/SIGKILL, or a
|
||||||
|
# shape the defensive filters don't cover) may have already written the
|
||||||
|
# snapshot manifest but no classification. Finalizing on that state is what
|
||||||
|
# used to wipe the queue — so on a nonzero exit, warn loudly, do NOT call
|
||||||
|
# finalize_queue, and leave everything queued for the next drain.
|
||||||
|
if ! compute_paths_to_stage "$mode" "$class_file" "$snapshot_file" > "$paths_file"; then
|
||||||
|
echo "BRAIN_SYNC: warning: queue classification failed — no records consumed; next run re-drains" >&2
|
||||||
|
write_status "error" "classification failed; queue preserved (next run retries)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
if [ ! -s "$paths_file" ]; then
|
if [ ! -s "$paths_file" ]; then
|
||||||
# Nothing stageable. Finalize the snapshot (retained entries survive;
|
# Nothing stageable. Finalize the snapshot (retained entries survive;
|
||||||
# classified drops removed; records created after the snapshot untouched).
|
# classified drops removed; records created after the snapshot untouched).
|
||||||
finalize_queue "$snapshot_file" "$class_file"
|
finalize_queue "$snapshot_file" "$class_file" "$paths_file"
|
||||||
local summary
|
local summary
|
||||||
summary=$(queue_summary "$class_file")
|
summary=$(queue_summary "$class_file")
|
||||||
write_status "idle" "no stageable changes${summary:+ ($summary)}"
|
write_status "idle" "no stageable changes${summary:+ ($summary)}"
|
||||||
@@ -645,7 +707,7 @@ subcmd_once() {
|
|||||||
commit -q -m "$msg" 2>/dev/null || {
|
commit -q -m "$msg" 2>/dev/null || {
|
||||||
# Nothing to commit (e.g. all files already committed). The drained
|
# Nothing to commit (e.g. all files already committed). The drained
|
||||||
# records leave the spool; retained + post-snapshot records survive.
|
# records leave the spool; retained + post-snapshot records survive.
|
||||||
finalize_queue "$snapshot_file" "$class_file"
|
finalize_queue "$snapshot_file" "$class_file" "$paths_file"
|
||||||
write_status "idle" "queue drained but no new changes to commit"
|
write_status "idle" "queue drained but no new changes to commit"
|
||||||
exit 0
|
exit 0
|
||||||
}
|
}
|
||||||
@@ -662,7 +724,7 @@ subcmd_once() {
|
|||||||
# Drained records leave the spool — they live in the local commit, which
|
# Drained records leave the spool — they live in the local commit, which
|
||||||
# the run-start detector re-pushes next time (#2549). Retained +
|
# the run-start detector re-pushes next time (#2549). Retained +
|
||||||
# post-snapshot records survive the finalize.
|
# post-snapshot records survive the finalize.
|
||||||
finalize_queue "$snapshot_file" "$class_file"
|
finalize_queue "$snapshot_file" "$class_file" "$paths_file"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -676,7 +738,7 @@ subcmd_once() {
|
|||||||
if git -C "$GSTACK_HOME" merge --no-edit "origin/$branch" >/dev/null 2>&1; then
|
if git -C "$GSTACK_HOME" merge --no-edit "origin/$branch" >/dev/null 2>&1; then
|
||||||
if GSTACK_HOME="$GSTACK_HOME" _receipted_git closed brain-sync "$push_host" curated-memory-git-push "artifacts_sync_mode!=off" \
|
if GSTACK_HOME="$GSTACK_HOME" _receipted_git closed brain-sync "$push_host" curated-memory-git-push "artifacts_sync_mode!=off" \
|
||||||
bash -c 'git -C "$1" push origin HEAD 2>/dev/null' _ "$GSTACK_HOME"; then
|
bash -c 'git -C "$1" push origin HEAD 2>/dev/null' _ "$GSTACK_HOME"; then
|
||||||
finalize_queue "$snapshot_file" "$class_file"
|
finalize_queue "$snapshot_file" "$class_file" "$paths_file"
|
||||||
date -u +%Y-%m-%dT%H:%M:%SZ > "$LAST_PUSH_FILE"
|
date -u +%Y-%m-%dT%H:%M:%SZ > "$LAST_PUSH_FILE"
|
||||||
write_status "ok" "pushed $n file(s) after rebase"
|
write_status "ok" "pushed $n file(s) after rebase"
|
||||||
exit 0
|
exit 0
|
||||||
@@ -685,12 +747,12 @@ subcmd_once() {
|
|||||||
fi
|
fi
|
||||||
# Commit exists locally; the run-start detector re-pushes it next time.
|
# Commit exists locally; the run-start detector re-pushes it next time.
|
||||||
write_status "push_failed" "push failed: $(printf '%s' "$push_err" | head -1); commit retained locally, will retry next run"
|
write_status "push_failed" "push failed: $(printf '%s' "$push_err" | head -1); commit retained locally, will retry next run"
|
||||||
finalize_queue "$snapshot_file" "$class_file"
|
finalize_queue "$snapshot_file" "$class_file" "$paths_file"
|
||||||
exit 0
|
exit 0
|
||||||
}
|
}
|
||||||
|
|
||||||
# Success: drained records leave the spool (retained + post-snapshot survive).
|
# Success: drained records leave the spool (retained + post-snapshot survive).
|
||||||
finalize_queue "$snapshot_file" "$class_file"
|
finalize_queue "$snapshot_file" "$class_file" "$paths_file"
|
||||||
date -u +%Y-%m-%dT%H:%M:%SZ > "$LAST_PUSH_FILE"
|
date -u +%Y-%m-%dT%H:%M:%SZ > "$LAST_PUSH_FILE"
|
||||||
write_status "ok" "pushed $n file(s)"
|
write_status "ok" "pushed $n file(s)"
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
+86
-18
@@ -143,6 +143,13 @@ interface ProbeReport {
|
|||||||
updated_count: number;
|
updated_count: number;
|
||||||
unchanged_count: number;
|
unchanged_count: number;
|
||||||
skipped_unattributed: number;
|
skipped_unattributed: number;
|
||||||
|
/**
|
||||||
|
* #2392 parity: transcripts whose remote's trust tier is `deny` /
|
||||||
|
* `read-only`. Probe applies the SAME per-remote policy filter --bulk
|
||||||
|
* applies, so its ingestible counts match what --bulk would write.
|
||||||
|
*/
|
||||||
|
skipped_policy_deny: number;
|
||||||
|
skipped_policy_readonly: number;
|
||||||
estimate_minutes: number;
|
estimate_minutes: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1079,9 +1086,10 @@ export function readNewFailures(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The ONE attribution gate (#2394): a transcript is attributable iff its cwd
|
* The ONE attribution gate (#2394): a transcript is attributable iff its cwd
|
||||||
* resolves to a git remote. Both probeMode (via transcriptIsAttributable) and
|
* resolves to a git remote. Both probeMode (via transcriptCwdFromPrefix +
|
||||||
* preparePages route through THIS function, so the two stages' post-attribution
|
* resolveGitRemote — the same memoized resolver) and preparePages route
|
||||||
* counts are structurally identical — the parity the probe report promises.
|
* through THIS logic, so the two stages' post-attribution counts are
|
||||||
|
* structurally identical — the parity the probe report promises.
|
||||||
*/
|
*/
|
||||||
function sessionIsAttributable(cwd: string | undefined | null): boolean {
|
function sessionIsAttributable(cwd: string | undefined | null): boolean {
|
||||||
if (!cwd) return false;
|
if (!cwd) return false;
|
||||||
@@ -1096,12 +1104,12 @@ function sessionIsAttributable(cwd: string | undefined | null): boolean {
|
|||||||
const TRANSCRIPT_PROBE_MAX_BYTES = 256 * 1024;
|
const TRANSCRIPT_PROBE_MAX_BYTES = 256 * 1024;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lightweight attribution check: does a transcript have a resolvable git
|
* Lightweight cwd extraction for the probe: reads a BOUNDED prefix (first
|
||||||
* remote for its cwd? Reads a BOUNDED prefix (first 256KB, never the whole
|
* 256KB, never the whole file — plan C7: the probe must stay a cheap parse on
|
||||||
* file — plan C7: the probe must stay a cheap parse on multi-MB transcripts),
|
* multi-MB transcripts) and extracts the cwd with EXACTLY
|
||||||
* extracts the cwd with EXACTLY parseTranscriptJsonl's rules, and calls
|
* parseTranscriptJsonl's rules. The caller resolves attribution/policy via
|
||||||
* resolveGitRemote. Avoids the full parse (body rendering, message counting)
|
* resolveGitRemote (memoized). Avoids the full parse (body rendering, message
|
||||||
* because probe only needs the yes/no answer.
|
* counting) because probe only needs the cwd.
|
||||||
*
|
*
|
||||||
* Extraction MIRRORS parseTranscriptJsonl (the single source of truth for
|
* Extraction MIRRORS parseTranscriptJsonl (the single source of truth for
|
||||||
* cwd semantics — keep the two in lockstep):
|
* cwd semantics — keep the two in lockstep):
|
||||||
@@ -1117,7 +1125,7 @@ const TRANSCRIPT_PROBE_MAX_BYTES = 256 * 1024;
|
|||||||
* Non-transcript types (artifacts) always pass — the attribution filter in
|
* Non-transcript types (artifacts) always pass — the attribution filter in
|
||||||
* preparePages only applies to transcripts (#2394).
|
* preparePages only applies to transcripts (#2394).
|
||||||
*/
|
*/
|
||||||
function transcriptIsAttributable(path: string): boolean {
|
function transcriptCwdFromPrefix(path: string): string {
|
||||||
let raw: string;
|
let raw: string;
|
||||||
try {
|
try {
|
||||||
const fd = openSync(path, "r");
|
const fd = openSync(path, "r");
|
||||||
@@ -1129,10 +1137,10 @@ function transcriptIsAttributable(path: string): boolean {
|
|||||||
closeSync(fd);
|
closeSync(fd);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return "";
|
||||||
}
|
}
|
||||||
const lines = raw.split("\n").filter((l) => l.trim().length > 0);
|
const lines = raw.split("\n").filter((l) => l.trim().length > 0);
|
||||||
if (lines.length === 0) return false;
|
if (lines.length === 0) return "";
|
||||||
|
|
||||||
let cwd = "";
|
let cwd = "";
|
||||||
let sawFirstParseable = false;
|
let sawFirstParseable = false;
|
||||||
@@ -1159,8 +1167,7 @@ function transcriptIsAttributable(path: string): boolean {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!cwd) return false;
|
return cwd;
|
||||||
return sessionIsAttributable(cwd);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function probeMode(args: CliArgs): Promise<ProbeReport> {
|
async function probeMode(args: CliArgs): Promise<ProbeReport> {
|
||||||
@@ -1184,17 +1191,55 @@ async function probeMode(args: CliArgs): Promise<ProbeReport> {
|
|||||||
let updatedCount = 0;
|
let updatedCount = 0;
|
||||||
let unchangedCount = 0;
|
let unchangedCount = 0;
|
||||||
let skippedUnattributed = 0;
|
let skippedUnattributed = 0;
|
||||||
|
let skippedPolicyDeny = 0;
|
||||||
|
let skippedPolicyReadonly = 0;
|
||||||
|
|
||||||
|
// Two-phase walk (#2392 parity): collect candidates first (remembering each
|
||||||
|
// transcript's resolved remote), THEN apply the same per-remote policy
|
||||||
|
// filter --bulk applies via one repoPolicyTierBatch spawn. Counting during
|
||||||
|
// the walk would report policy-denied transcripts as ingestible — probe's
|
||||||
|
// numbers must match what --bulk would actually write.
|
||||||
|
const candidates: Array<{ path: string; type: MemoryType; remote: string }> = [];
|
||||||
for (const { path, type } of walkAllSources(ctx)) {
|
for (const { path, type } of walkAllSources(ctx)) {
|
||||||
// Apply the same attribution filter preparePages uses (#2394):
|
// Apply the same attribution filter preparePages uses (#2394):
|
||||||
// skip transcripts with no resolvable git remote unless --include-unattributed.
|
// skip transcripts with no resolvable git remote unless --include-unattributed.
|
||||||
if (type === "transcript" && !args.includeUnattributed) {
|
let remote = "";
|
||||||
if (!transcriptIsAttributable(path)) {
|
if (type === "transcript") {
|
||||||
|
const cwd = transcriptCwdFromPrefix(path);
|
||||||
|
remote = cwd ? resolveGitRemote(cwd) : "";
|
||||||
|
if (!args.includeUnattributed && remote === "") {
|
||||||
skippedUnattributed++;
|
skippedUnattributed++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
candidates.push({ path, type, remote });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch policy check — same hasRepoPolicyStore fast path as preparePages:
|
||||||
|
// no store on disk → zero policy work. Only transcripts with a resolved
|
||||||
|
// remote are policy-filtered; artifacts never are (#2392). A missing or
|
||||||
|
// errored verdict counts as "none" here — probe is read-only and must not
|
||||||
|
// hard-fail the way the write path does.
|
||||||
|
if (hasRepoPolicyStore()) {
|
||||||
|
const remotes = [...new Set(candidates.filter((c) => c.type === "transcript" && c.remote).map((c) => c.remote))];
|
||||||
|
if (remotes.length > 0) {
|
||||||
|
const verdicts = repoPolicyTierBatch(remotes);
|
||||||
|
for (let i = candidates.length - 1; i >= 0; i--) {
|
||||||
|
const c = candidates[i];
|
||||||
|
if (c.type !== "transcript" || !c.remote) continue;
|
||||||
|
const tier = verdicts.get(c.remote)?.tier ?? "none";
|
||||||
|
if (tier === "deny") {
|
||||||
|
skippedPolicyDeny++;
|
||||||
|
candidates.splice(i, 1);
|
||||||
|
} else if (tier === "read-only") {
|
||||||
|
skippedPolicyReadonly++;
|
||||||
|
candidates.splice(i, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { path, type } of candidates) {
|
||||||
totalFiles++;
|
totalFiles++;
|
||||||
let size = 0;
|
let size = 0;
|
||||||
try {
|
try {
|
||||||
@@ -1224,6 +1269,8 @@ async function probeMode(args: CliArgs): Promise<ProbeReport> {
|
|||||||
updated_count: updatedCount,
|
updated_count: updatedCount,
|
||||||
unchanged_count: unchangedCount,
|
unchanged_count: unchangedCount,
|
||||||
skipped_unattributed: skippedUnattributed,
|
skipped_unattributed: skippedUnattributed,
|
||||||
|
skipped_policy_deny: skippedPolicyDeny,
|
||||||
|
skipped_policy_readonly: skippedPolicyReadonly,
|
||||||
estimate_minutes: estimateMinutes,
|
estimate_minutes: estimateMinutes,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1277,8 +1324,16 @@ function preparePages(
|
|||||||
let parseFailed = 0;
|
let parseFailed = 0;
|
||||||
let partialPages = 0;
|
let partialPages = 0;
|
||||||
|
|
||||||
|
// --limit semantics: "stop after N pages WRITTEN" = N policy-eligible pages.
|
||||||
|
// When a per-remote policy store exists, eligibility is only known after the
|
||||||
|
// batch policy check below, so the walk must not stop early — a denied-first
|
||||||
|
// corpus would otherwise consume the limit and starve permitted pages. With
|
||||||
|
// no store on disk, every prepared page is eligible and the in-loop break
|
||||||
|
// keeps --limit cheap.
|
||||||
|
const policyStoreExists = hasRepoPolicyStore();
|
||||||
|
|
||||||
for (const { path, type } of walkAllSources(ctx)) {
|
for (const { path, type } of walkAllSources(ctx)) {
|
||||||
if (args.limit !== null && prepared.length >= args.limit) break;
|
if (args.limit !== null && !policyStoreExists && prepared.length >= args.limit) break;
|
||||||
|
|
||||||
if (args.mode === "incremental" && !fileChangedSinceState(path, state)) {
|
if (args.mode === "incremental" && !fileChangedSinceState(path, state)) {
|
||||||
skippedDedup++;
|
skippedDedup++;
|
||||||
@@ -1354,7 +1409,7 @@ function preparePages(
|
|||||||
let skippedPolicyReadonly = 0;
|
let skippedPolicyReadonly = 0;
|
||||||
let skippedPolicyDeny = 0;
|
let skippedPolicyDeny = 0;
|
||||||
let policyError: string | undefined;
|
let policyError: string | undefined;
|
||||||
if (hasRepoPolicyStore()) {
|
if (policyStoreExists) {
|
||||||
const remotes = [
|
const remotes = [
|
||||||
...new Set(
|
...new Set(
|
||||||
prepared
|
prepared
|
||||||
@@ -1399,6 +1454,13 @@ function preparePages(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --limit applies AFTER policy filtering, over permitted pages only. In the
|
||||||
|
// no-store fast path the walk already stopped at the limit, so this slice
|
||||||
|
// is a no-op there.
|
||||||
|
if (args.limit !== null && finalPrepared.length > args.limit) {
|
||||||
|
finalPrepared = finalPrepared.slice(0, args.limit);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
prepared: finalPrepared,
|
prepared: finalPrepared,
|
||||||
skippedSecret,
|
skippedSecret,
|
||||||
@@ -2282,6 +2344,12 @@ function printProbeReport(r: ProbeReport, json: boolean): void {
|
|||||||
if (r.skipped_unattributed > 0) {
|
if (r.skipped_unattributed > 0) {
|
||||||
console.log(`Skipped (unattributed): ${r.skipped_unattributed} (no git remote; use --include-unattributed to include)`);
|
console.log(`Skipped (unattributed): ${r.skipped_unattributed} (no git remote; use --include-unattributed to include)`);
|
||||||
}
|
}
|
||||||
|
if (r.skipped_policy_deny > 0) {
|
||||||
|
console.log(`Skipped (policy deny): ${r.skipped_policy_deny} (remote tier is deny; change with: gstack-gbrain-repo-policy set <remote> read-write)`);
|
||||||
|
}
|
||||||
|
if (r.skipped_policy_readonly > 0) {
|
||||||
|
console.log(`Skipped (policy read-only): ${r.skipped_policy_readonly} (remote tier is read-only; transcript ingest writes pages)`);
|
||||||
|
}
|
||||||
console.log("By type:");
|
console.log("By type:");
|
||||||
for (const [t, v] of Object.entries(r.by_type)) {
|
for (const [t, v] of Object.entries(r.by_type)) {
|
||||||
if (v.count > 0) {
|
if (v.count > 0) {
|
||||||
|
|||||||
@@ -552,6 +552,45 @@ function fetchGitClaimed(
|
|||||||
// read from the local remote-tracking ref.
|
// read from the local remote-tracking ref.
|
||||||
show = runCommand("git", ["show", `refs/remotes/origin/${branch}:${versionPath}`]);
|
show = runCommand("git", ["show", `refs/remotes/origin/${branch}:${versionPath}`]);
|
||||||
}
|
}
|
||||||
|
if (!show.ok && sha) {
|
||||||
|
// ls-remote advertises SHAs without objects: a branch pushed after our
|
||||||
|
// last fetch has NO local object, so both reads above fail. The old
|
||||||
|
// `continue` here silently dropped a LIVE claim — the exact duplicate-
|
||||||
|
// allocation this fallback exists to prevent. Distinguish "object
|
||||||
|
// missing" from "branch has no VERSION file" before deciding.
|
||||||
|
const haveObject = runCommand("git", ["cat-file", "-e", sha]);
|
||||||
|
if (haveObject.ok) {
|
||||||
|
// Object is local and the path read still failed → the branch simply
|
||||||
|
// carries no version file. Genuinely not a claim; skip quietly.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Fetch just this ref shallowly (no prompts, no tags, bounded) and
|
||||||
|
// retry reading VERSION from the now-local object (or FETCH_HEAD).
|
||||||
|
const fetch = spawnSync(
|
||||||
|
"git",
|
||||||
|
["fetch", "origin", `refs/heads/${branch}`, "--depth=1", "--no-tags"],
|
||||||
|
{ encoding: "utf8", timeout: 10000, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } },
|
||||||
|
);
|
||||||
|
if (fetch.status === 0 && !fetch.error) {
|
||||||
|
show = runCommand("git", ["show", `${sha}:${versionPath}`]);
|
||||||
|
if (!show.ok) show = runCommand("git", ["show", `FETCH_HEAD:${versionPath}`]);
|
||||||
|
if (!show.ok && runCommand("git", ["cat-file", "-e", sha]).ok) {
|
||||||
|
// Fetched and the object exists but the path doesn't → no VERSION
|
||||||
|
// file on this branch. Not a claim.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!show.ok) {
|
||||||
|
// STILL unreadable — never skip silently. Surface it as an UNKNOWN
|
||||||
|
// claim so the caller knows the allocation may be unsafe.
|
||||||
|
warnings.push(
|
||||||
|
`origin/${branch}: VERSION unreadable even after a targeted fetch — ` +
|
||||||
|
`counted as an UNKNOWN claim; allocation may collide with this branch. ` +
|
||||||
|
`Run \`git fetch origin ${branch}\` and re-run to verify.`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!show.ok) continue;
|
if (!show.ok) continue;
|
||||||
const raw = extractVersion(show.stdout, versionPath);
|
const raw = extractVersion(show.stdout, versionPath);
|
||||||
if (!raw || !parseVersion(raw)) continue;
|
if (!raw || !parseVersion(raw)) continue;
|
||||||
|
|||||||
@@ -106,11 +106,26 @@ fi
|
|||||||
|
|
||||||
# Write the HOLDER's PID for stale lock detection (see #2613 note above;
|
# Write the HOLDER's PID for stale lock detection (see #2613 note above;
|
||||||
# macOS ships bash 3.2 with no BASHPID — the sh child's $PPID IS this
|
# macOS ships bash 3.2 with no BASHPID — the sh child's $PPID IS this
|
||||||
# subshell, so the fallback is exact there).
|
# subshell, so the fallback is exact there). MYPID is captured once at
|
||||||
echo "${BASHPID:-$(sh -c 'echo $PPID')}" > "$LOCK_DIR/pid" 2>/dev/null
|
# write time so the trap below can prove ownership before removing.
|
||||||
|
MYPID="${BASHPID:-$(sh -c 'echo $PPID')}"
|
||||||
|
echo "$MYPID" > "$LOCK_DIR/pid" 2>/dev/null
|
||||||
|
|
||||||
# Clean up lock on exit
|
# In-flight heartbeat: the step-boundary touches below only fire AFTER the
|
||||||
trap 'rm -rf "$LOCK_DIR" 2>/dev/null' EXIT
|
# pull / setup return, so a legitimately-slow step (cold clone, huge setup)
|
||||||
|
# older than the TTL got reclaimed while ALIVE. This background loop
|
||||||
|
# freshens the pidfile mtime every 5 minutes for as long as we still own
|
||||||
|
# the lock (ownership re-checked each beat: if another updater reclaimed
|
||||||
|
# and wrote its own pid, the loop exits instead of touching THEIR file).
|
||||||
|
( while :; do sleep 300; [ "$(cat "$LOCK_DIR/pid" 2>/dev/null)" = "$MYPID" ] || exit 0; touch "$LOCK_DIR/pid" 2>/dev/null; done ) &
|
||||||
|
HB_PID=$!
|
||||||
|
|
||||||
|
# Clean up lock on exit — ownership-checked: after a TTL reclaim by another
|
||||||
|
# updater, $LOCK_DIR belongs to the NEW holder, and an unconditional rm -rf
|
||||||
|
# here would delete the live holder's lock (cascading reclaims). Remove the
|
||||||
|
# lock ONLY while $LOCK_DIR/pid still contains MYPID; always stop the
|
||||||
|
# heartbeat.
|
||||||
|
trap 'kill "$HB_PID" 2>/dev/null; [ "$(cat "$LOCK_DIR/pid" 2>/dev/null)" = "$MYPID" ] && rm -rf "$LOCK_DIR" 2>/dev/null' EXIT
|
||||||
|
|
||||||
# ── Pull latest ──
|
# ── Pull latest ──
|
||||||
OLD_HEAD=$(git -C "$GSTACK_DIR" rev-parse HEAD 2>/dev/null)
|
OLD_HEAD=$(git -C "$GSTACK_DIR" rev-parse HEAD 2>/dev/null)
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ case "$ACTION" in
|
|||||||
hooks: [{ type: "command", command: hookCmd }]
|
hooks: [{ type: "command", command: hookCmd }]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const tmp = settingsPath + ".tmp";
|
const tmp = settingsPath + ".tmp." + process.pid; // per-process: parallel writers must not share a tmp
|
||||||
fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + "\n");
|
fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + "\n");
|
||||||
fs.renameSync(tmp, settingsPath);
|
fs.renameSync(tmp, settingsPath);
|
||||||
'
|
'
|
||||||
@@ -130,7 +130,7 @@ case "$ACTION" in
|
|||||||
if (settings.hooks.SessionStart.length === 0) delete settings.hooks.SessionStart;
|
if (settings.hooks.SessionStart.length === 0) delete settings.hooks.SessionStart;
|
||||||
if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
|
if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
|
||||||
}
|
}
|
||||||
const tmp = settingsPath + ".tmp";
|
const tmp = settingsPath + ".tmp." + process.pid; // per-process: parallel writers must not share a tmp
|
||||||
fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + "\n");
|
fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + "\n");
|
||||||
fs.renameSync(tmp, settingsPath);
|
fs.renameSync(tmp, settingsPath);
|
||||||
' 2>/dev/null
|
' 2>/dev/null
|
||||||
@@ -218,7 +218,19 @@ case "$ACTION" in
|
|||||||
return sameMatcher && sameCommand;
|
return sameMatcher && sameCommand;
|
||||||
};
|
};
|
||||||
|
|
||||||
let existing = settings.hooks[event].find(matchesEntry);
|
// Collect ALL matches, not just the first: pre-existing installs can
|
||||||
|
// carry two entries with the same (event, _gstack_source) from the old
|
||||||
|
// matcher-keyed dedup. `.find()` updated only the first and left the
|
||||||
|
// stale twin running forever. Keep ONE canonical entry (the first),
|
||||||
|
// remove the rest in the same atomic write.
|
||||||
|
const matched = settings.hooks[event].filter(matchesEntry);
|
||||||
|
let existing = matched.length > 0 ? matched[0] : undefined;
|
||||||
|
let collapsed = 0;
|
||||||
|
if (matched.length > 1) {
|
||||||
|
const extras = new Set(matched.slice(1));
|
||||||
|
settings.hooks[event] = settings.hooks[event].filter((e) => !extras.has(e));
|
||||||
|
collapsed = matched.length - 1;
|
||||||
|
}
|
||||||
const hookEntry = { type: "command", command: cmd };
|
const hookEntry = { type: "command", command: cmd };
|
||||||
if (timeoutRaw) {
|
if (timeoutRaw) {
|
||||||
const n = Number(timeoutRaw);
|
const n = Number(timeoutRaw);
|
||||||
@@ -270,7 +282,10 @@ case "$ACTION" in
|
|||||||
// Atomic tmp+rename: the settings file is either the old JSON (with
|
// Atomic tmp+rename: the settings file is either the old JSON (with
|
||||||
// the old single registration) or the new JSON (with the replaced
|
// the old single registration) or the new JSON (with the replaced
|
||||||
// one) — a failed update can never leave zero or two registrations.
|
// one) — a failed update can never leave zero or two registrations.
|
||||||
const tmp = settingsPath + ".tmp";
|
// Per-process tmp suffix: a fixed settings.json.tmp let two parallel
|
||||||
|
// writers consume one another. (No apostrophes here: this JS lives
|
||||||
|
// inside a bash single-quoted string.)
|
||||||
|
const tmp = settingsPath + ".tmp." + process.pid;
|
||||||
fs.writeFileSync(tmp, after + "\n");
|
fs.writeFileSync(tmp, after + "\n");
|
||||||
fs.renameSync(tmp, settingsPath);
|
fs.renameSync(tmp, settingsPath);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -280,6 +295,9 @@ case "$ACTION" in
|
|||||||
console.error("error: could not update " + settingsPath + ": " + (e && e.message ? e.message : e));
|
console.error("error: could not update " + settingsPath + ": " + (e && e.message ? e.message : e));
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
if (collapsed > 0) {
|
||||||
|
console.error("collapsed " + collapsed + " duplicate (event, source) hook entr" + (collapsed === 1 ? "y" : "ies") + " for " + event + " (source: " + source + ")");
|
||||||
|
}
|
||||||
if (ensure && existing) {
|
if (ensure && existing) {
|
||||||
console.log("OK: " + event + " hook re-pointed (source: " + source + ")");
|
console.log("OK: " + event + " hook re-pointed (source: " + source + ")");
|
||||||
} else {
|
} else {
|
||||||
@@ -318,7 +336,7 @@ case "$ACTION" in
|
|||||||
if (settings.hooks[event].length === 0) delete settings.hooks[event];
|
if (settings.hooks[event].length === 0) delete settings.hooks[event];
|
||||||
}
|
}
|
||||||
if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
|
if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
|
||||||
const tmp = settingsPath + ".tmp";
|
const tmp = settingsPath + ".tmp." + process.pid; // per-process: parallel writers must not share a tmp
|
||||||
fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + "\n");
|
fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + "\n");
|
||||||
fs.renameSync(tmp, settingsPath);
|
fs.renameSync(tmp, settingsPath);
|
||||||
console.log("OK: removed " + removed + " hook entry/entries tagged source=" + source);
|
console.log("OK: removed " + removed + " hook entry/entries tagged source=" + source);
|
||||||
|
|||||||
+13
-8
@@ -188,12 +188,16 @@ _resolve_remote() {
|
|||||||
# - Old-bug shape (#1125): the pre-walk-up resolver cached basename(pwd)
|
# - Old-bug shape (#1125): the pre-walk-up resolver cached basename(pwd)
|
||||||
# for a SUBDIRECTORY of the real project — cached == pwd basename while
|
# for a SUBDIRECTORY of the real project — cached == pwd basename while
|
||||||
# the walk-up says pwd is NOT the project root.
|
# the walk-up says pwd is NOT the project root.
|
||||||
# - Degraded-ancestor shape (2026-08-17): the pre-remote-first resolver
|
# - Degraded-ancestor shape (2026-08-17), STRAY-REPO shape ONLY: the
|
||||||
# cached basename(PROJECT_ROOT) for a marker-only ancestor (stray
|
# pre-remote-first resolver cached basename(PROJECT_ROOT) for an
|
||||||
# ~/.git) that is NOT the remote-bearing repo — cached == the marker
|
# ancestor anchored by a .git entry whose origin does NOT resolve (the
|
||||||
# root's basename while a remote-bearing repo BELOW it exists. Legit
|
# stray empty ~/.git live bug) while a remote-bearing repo BELOW it
|
||||||
# #2212 stickiness is safe: there the repo that adopted the remote IS
|
# exists. A marker root anchored by package.json / pyproject etc. with
|
||||||
# the marker root (REMOTE_ROOT == PROJECT_ROOT), so the heal never fires.
|
# NO .git is legit #2212 sticky identity (a monorepo wrapper that used
|
||||||
|
# gstack before its inner dir grew a remote) and must NOT be healed.
|
||||||
|
# Legit remote-adopting stickiness is safe too: there the repo that
|
||||||
|
# adopted the remote IS the marker root (REMOTE_ROOT == PROJECT_ROOT),
|
||||||
|
# so the heal never fires.
|
||||||
if [[ -z "$SLUG" && -f "$CACHE_FILE" ]]; then
|
if [[ -z "$SLUG" && -f "$CACHE_FILE" ]]; then
|
||||||
_CACHED=$(cat "$CACHE_FILE" 2>/dev/null | tr -cd 'a-zA-Z0-9._-')
|
_CACHED=$(cat "$CACHE_FILE" 2>/dev/null | tr -cd 'a-zA-Z0-9._-')
|
||||||
if [[ -n "$_CACHED" ]]; then
|
if [[ -n "$_CACHED" ]]; then
|
||||||
@@ -204,9 +208,10 @@ if [[ -z "$SLUG" && -f "$CACHE_FILE" ]]; then
|
|||||||
fi
|
fi
|
||||||
if [[ "$_CACHED" == "$_PWD_BASE" && -n "$PROJECT_ROOT" && "$PROJECT_ROOT" != "$PROJECT_DIR" ]]; then
|
if [[ "$_CACHED" == "$_PWD_BASE" && -n "$PROJECT_ROOT" && "$PROJECT_ROOT" != "$PROJECT_DIR" ]]; then
|
||||||
: # old-bug shape — recompute below and self-heal the cache
|
: # old-bug shape — recompute below and self-heal the cache
|
||||||
elif [[ -n "$PROJECT_ROOT" && "$_CACHED" == "$_ROOT_BASE" ]] \
|
elif [[ -n "$PROJECT_ROOT" && "$_CACHED" == "$_ROOT_BASE" && -e "$PROJECT_ROOT/.git" ]] \
|
||||||
|
&& ! git -C "$PROJECT_ROOT" remote get-url origin >/dev/null 2>&1 \
|
||||||
&& { _resolve_remote; [[ -n "$REMOTE_URL" && "$REMOTE_ROOT" != "$PROJECT_ROOT" ]]; }; then
|
&& { _resolve_remote; [[ -n "$REMOTE_URL" && "$REMOTE_ROOT" != "$PROJECT_ROOT" ]]; }; then
|
||||||
: # degraded-ancestor shape — recompute below and self-heal the cache
|
: # degraded-ancestor (stray-repo) shape — recompute below and self-heal the cache
|
||||||
else
|
else
|
||||||
SLUG="$_CACHED"
|
SLUG="$_CACHED"
|
||||||
fi
|
fi
|
||||||
|
|||||||
+18
-6
@@ -463,13 +463,25 @@ function cmdRepair(args: string[], cwd: string): void {
|
|||||||
const current = readVersionFile(versionPath, versionRel);
|
const current = readVersionFile(versionPath, versionRel);
|
||||||
// Guard against readVersionFile folding "file exists but is empty / unparsable"
|
// Guard against readVersionFile folding "file exists but is empty / unparsable"
|
||||||
// into DEFAULT ("0.0.0.0") — same data-corruption pathway as file-missing (#2600).
|
// into DEFAULT ("0.0.0.0") — same data-corruption pathway as file-missing (#2600).
|
||||||
// A fabricated version must never propagate into package.json.
|
// A fabricated version must never propagate into package.json. But DEFAULT is
|
||||||
|
// ambiguous: a VERSION file that GENUINELY reads "0.0.0.0" (a brand-new repo)
|
||||||
|
// is a legitimate version, not the sentinel. Disambiguate on the raw bytes:
|
||||||
|
// if the trimmed file content itself matches the version shape, proceed with
|
||||||
|
// the repair; reject only when the raw content is empty or unparseable.
|
||||||
if (current === DEFAULT) {
|
if (current === DEFAULT) {
|
||||||
fail(
|
let rawTrimmed = "";
|
||||||
`VERSION file at ${versionRel} is empty or contains no parsable version. ` +
|
try {
|
||||||
"Cannot repair package.json with a fabricated version.",
|
rawTrimmed = readFileSync(versionPath, "utf-8").trim();
|
||||||
2,
|
} catch {
|
||||||
);
|
rawTrimmed = "";
|
||||||
|
}
|
||||||
|
if (!VERSION_RE.test(rawTrimmed)) {
|
||||||
|
fail(
|
||||||
|
`VERSION file at ${versionRel} is empty or contains no parsable version. ` +
|
||||||
|
"Cannot repair package.json with a fabricated version.",
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!VERSION_RE.test(current)) {
|
if (!VERSION_RE.test(current)) {
|
||||||
fail(
|
fail(
|
||||||
|
|||||||
@@ -167,14 +167,14 @@ export const CDP_ALLOWLIST: ReadonlyArray<CdpAllowEntry> = Object.freeze([
|
|||||||
method: 'setCPUThrottlingRate',
|
method: 'setCPUThrottlingRate',
|
||||||
scope: 'tab',
|
scope: 'tab',
|
||||||
output: 'trusted',
|
output: 'trusted',
|
||||||
justification: 'CPU slowdown multiplier on the active tab, for measuring performance on a realistic low-end client instead of the developer workstation. Same domain and mutating character as setDeviceMetricsOverride; affects only timing, reads nothing, exfiltrates nothing.',
|
justification: 'CPU slowdown multiplier on the active tab, for measuring performance on a realistic low-end client instead of the developer workstation. Same domain and mutating character as setDeviceMetricsOverride; affects only timing, reads nothing, exfiltrates nothing. NOTE: like setEmulatedMedia the override persists on the tab until cleared (rate: 1) — callers own restoration.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
domain: 'Network',
|
domain: 'Network',
|
||||||
method: 'emulateNetworkConditions',
|
method: 'emulateNetworkConditions',
|
||||||
scope: 'tab',
|
scope: 'tab',
|
||||||
output: 'trusted',
|
output: 'trusted',
|
||||||
justification: 'Bandwidth/latency emulation on the active tab, for measuring page behaviour on a slow connection. Constrains traffic rather than reading it — no request bodies, headers or cookies are exposed.',
|
justification: 'Bandwidth/latency emulation on the active tab, for measuring page behaviour on a slow connection. Constrains traffic rather than reading it — no request bodies, headers or cookies are exposed. NOTE: like setEmulatedMedia the override persists on the tab until cleared (offline: false plus default throughput/latency) — callers own restoration.',
|
||||||
},
|
},
|
||||||
// ─── Page capture (output, not navigation) ─────────────────
|
// ─── Page capture (output, not navigation) ─────────────────
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -92,6 +92,10 @@ describe('CDP allowlist (T2: deny-default)', () => {
|
|||||||
expect(e).not.toBeNull();
|
expect(e).not.toBeNull();
|
||||||
expect(e!.scope).toBe('tab');
|
expect(e!.scope).toBe('tab');
|
||||||
expect(e!.output).toBe('trusted');
|
expect(e!.output).toBe('trusted');
|
||||||
|
// Like setEmulatedMedia, both overrides persist on the tab until
|
||||||
|
// cleared (rate: 1 / offline: false + defaults) — the justification
|
||||||
|
// must say so, since callers own restoration.
|
||||||
|
expect(e!.justification).toContain('persists on the tab until cleared');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+19
-6
@@ -129,12 +129,16 @@ export function outermostRemoteRepo(startDir: string): { root: string; url: stri
|
|||||||
* - old-bug shape (#1125): cached value equals basename(cwd) while the
|
* - old-bug shape (#1125): cached value equals basename(cwd) while the
|
||||||
* walk-up says cwd is NOT the project root; that cache came from the
|
* walk-up says cwd is NOT the project root; that cache came from the
|
||||||
* pre-walk-up resolver, so recompute and heal.
|
* pre-walk-up resolver, so recompute and heal.
|
||||||
* - degraded-ancestor shape (2026-08-17): cached equals the marker root's
|
* - degraded-ancestor shape (2026-08-17), STRAY-REPO shape ONLY: cached
|
||||||
* basename while a remote-bearing repo BELOW the marker root exists —
|
* equals the marker root's basename, the marker root is anchored by a
|
||||||
* the pre-remote-first resolver degraded to a stray ancestor's basename
|
* .git entry whose origin does NOT resolve (the stray empty ~/.git
|
||||||
* (stray empty ~/.git → SLUG=<username>). Legit #2212 stickiness is
|
* live bug), and a remote-bearing repo BELOW it exists — the
|
||||||
* safe: there the repo that adopted the remote IS the marker root
|
* pre-remote-first resolver degraded to that stray ancestor's basename
|
||||||
* (remote root == project root), so the heal never fires.
|
* (SLUG=<username>). A marker root anchored by package.json /
|
||||||
|
* pyproject etc. with NO .git is legit #2212 sticky identity and must
|
||||||
|
* NOT be healed. Legit remote-adopting stickiness is safe too: there
|
||||||
|
* the repo that adopted the remote IS the marker root (remote root ==
|
||||||
|
* project root), so the heal never fires.
|
||||||
* 3. Canonical remote-derived slug from the OUTERMOST remote-bearing repo
|
* 3. Canonical remote-derived slug from the OUTERMOST remote-bearing repo
|
||||||
* (see outermostRemoteRepo — never PROJECT_ROOT, which may be a
|
* (see outermostRemoteRepo — never PROJECT_ROOT, which may be a
|
||||||
* marker-only ancestor with no remote): [:/]<owner>/<repo>[.git] →
|
* marker-only ancestor with no remote): [:/]<owner>/<repo>[.git] →
|
||||||
@@ -176,7 +180,16 @@ export function slugFromEnvironment(gstackHome?: string, cwd: string = process.c
|
|||||||
!oldBugShape &&
|
!oldBugShape &&
|
||||||
projectRoot !== "" &&
|
projectRoot !== "" &&
|
||||||
cached === rootBase &&
|
cached === rootBase &&
|
||||||
|
// STRAY-REPO shape only: the marker root must be anchored by a .git
|
||||||
|
// entry whose origin does NOT resolve. A root anchored by
|
||||||
|
// package.json etc. (no .git) is legit #2212 sticky identity.
|
||||||
|
existsSync(join(projectRoot, ".git")) &&
|
||||||
(() => {
|
(() => {
|
||||||
|
const rootOrigin = spawnSync("git", ["-C", projectRoot, "remote", "get-url", "origin"], {
|
||||||
|
encoding: "utf-8",
|
||||||
|
});
|
||||||
|
const rootUrl = rootOrigin.status === 0 ? (rootOrigin.stdout || "").trim() : "";
|
||||||
|
if (rootUrl) return false; // marker root's own origin resolves — not the stray shape
|
||||||
const r = resolveRemote();
|
const r = resolveRemote();
|
||||||
return r.url !== "" && r.root !== projectRoot;
|
return r.url !== "" && r.root !== projectRoot;
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -2095,7 +2095,12 @@ if [ "$NO_TEAM_MODE" -ne 1 ] \
|
|||||||
# Consent already recorded — no prompt. But a registration from an earlier
|
# Consent already recorded — no prompt. But a registration from an earlier
|
||||||
# setup may carry a stale absolute path (a since-deleted dev worktree);
|
# setup may carry a stale absolute path (a since-deleted dev worktree);
|
||||||
# ensure-event re-points it in place and no-ops when everything matches.
|
# ensure-event re-points it in place and no-ops when everything matches.
|
||||||
_install_plan_tune_hooks >/dev/null 2>&1 || true
|
# Non-fatal to setup, but never silent: the hardened settings-hook refuses
|
||||||
|
# to rewrite a corrupt settings.json (exit 1), and swallowing that refusal
|
||||||
|
# left users with stale hooks and no signal.
|
||||||
|
if ! _PT_ENSURE_ERR=$(_install_plan_tune_hooks 2>&1 >/dev/null); then
|
||||||
|
log " warning: settings hook update failed: $(printf '%s\n' "$_PT_ENSURE_ERR" | head -1) — run $SETTINGS_HOOK manually"
|
||||||
|
fi
|
||||||
log ""
|
log ""
|
||||||
log "Plan-tune hooks already installed. Run \`$SETTINGS_HOOK list-sources\` to inspect."
|
log "Plan-tune hooks already installed. Run \`$SETTINGS_HOOK list-sources\` to inspect."
|
||||||
elif [ "$PT_DECISION" = "yes" ]; then
|
elif [ "$PT_DECISION" = "yes" ]; then
|
||||||
@@ -2203,7 +2208,7 @@ if [ "$NO_TEAM_MODE" -ne 1 ] && [ -x "$SETTINGS_HOOK" ] && [ -x "$TIMELINE_STOP_
|
|||||||
--event Stop \
|
--event Stop \
|
||||||
--command "$TIMELINE_STOP_HOOK" \
|
--command "$TIMELINE_STOP_HOOK" \
|
||||||
--source gstack-timeline-stop \
|
--source gstack-timeline-stop \
|
||||||
--timeout 5 2>/dev/null); then
|
--timeout 5 2>&1); then
|
||||||
case "$_TL_ENSURE_OUT" in
|
case "$_TL_ENSURE_OUT" in
|
||||||
*unchanged*)
|
*unchanged*)
|
||||||
: # already registered with the canonical command — quiet no-op
|
: # already registered with the canonical command — quiet no-op
|
||||||
@@ -2215,6 +2220,11 @@ if [ "$NO_TEAM_MODE" -ne 1 ] && [ -x "$SETTINGS_HOOK" ] && [ -x "$TIMELINE_STOP_
|
|||||||
log " registered Stop hook: session timeline entries now close even when a skill is interrupted (backup: settings.json.bak.<ts>; remove: $SETTINGS_HOOK remove-source --source gstack-timeline-stop)"
|
log " registered Stop hook: session timeline entries now close even when a skill is interrupted (backup: settings.json.bak.<ts>; remove: $SETTINGS_HOOK remove-source --source gstack-timeline-stop)"
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
else
|
||||||
|
# Non-fatal to setup, but never silent: the hardened settings-hook refuses
|
||||||
|
# to rewrite a corrupt settings.json (exit 1), and swallowing that refusal
|
||||||
|
# left the Stop hook unregistered with no signal.
|
||||||
|
log " warning: settings hook update failed: $(printf '%s\n' "$_TL_ENSURE_OUT" | head -1) — run $SETTINGS_HOOK manually"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -326,6 +326,35 @@ describe("walk-up parity with bin/gstack-slug (outermost project root)", () => {
|
|||||||
expect(fs.readFileSync(cacheFile, "utf-8")).toBe("garrytan-gstack");
|
expect(fs.readFileSync(cacheFile, "utf-8")).toBe("garrytan-gstack");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("package.json wrapper root (no .git): sticky basename slug is PRESERVED — heal is stray-repo-shape only", () => {
|
||||||
|
// Legit #2212 shape: a monorepo wrapper anchored by package.json used
|
||||||
|
// gstack before an inner dir grew a remote-bearing repo. The degraded-
|
||||||
|
// ancestor heal must NOT fire — it is restricted to marker roots anchored
|
||||||
|
// by a .git entry whose origin does NOT resolve (the live-bug shape).
|
||||||
|
const wrapper = path.join(tmp, "wrapperproj");
|
||||||
|
const inner = path.join(wrapper, "apps", "web");
|
||||||
|
fs.mkdirSync(inner, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(wrapper, "package.json"), '{"name":"wrapper"}\n');
|
||||||
|
spawnSync("git", ["init", "-q", inner]);
|
||||||
|
spawnSync("git", ["-C", inner, "remote", "add", "origin", "https://github.com/acme/web.git"]);
|
||||||
|
|
||||||
|
const cacheDir = path.join(nativeHome(), "slug-cache");
|
||||||
|
fs.mkdirSync(cacheDir, { recursive: true });
|
||||||
|
const cacheFile = path.join(cacheDir, toMsysPath(inner).replace(/\//g, "_"));
|
||||||
|
fs.writeFileSync(cacheFile, "wrapperproj"); // legit sticky identity
|
||||||
|
|
||||||
|
expect(slugFromEnvironment(nativeHome(), inner)).toBe("wrapperproj"); // NOT healed to acme-web
|
||||||
|
expect(fs.readFileSync(cacheFile, "utf-8")).toBe("wrapperproj");
|
||||||
|
|
||||||
|
// The bash implementation agrees on the same fixture (own home, seeded cache).
|
||||||
|
if (HAS_BASH) {
|
||||||
|
const bashCacheDir = path.join(tmp, "bash-home", ".gstack", "slug-cache");
|
||||||
|
fs.mkdirSync(bashCacheDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(bashCacheDir, toMsysPath(inner).replace(/\//g, "_")), "wrapperproj");
|
||||||
|
expect(bashSlug(inner)).toBe("wrapperproj");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("sticky identity preserved (#2212): a remote adopted AT the marker root is NOT healed", () => {
|
test("sticky identity preserved (#2212): a remote adopted AT the marker root is NOT healed", () => {
|
||||||
// Legit sticky shape: the repo that adopted the remote IS the marker root
|
// Legit sticky shape: the repo that adopted the remote IS the marker root
|
||||||
// (remote root == project root), so the degraded-ancestor heal must not
|
// (remote root == project root), so the degraded-ancestor heal must not
|
||||||
|
|||||||
+83
-8
@@ -553,15 +553,20 @@ describe('#2549 queue integrity', () => {
|
|||||||
expect(detail.dropped.missing).toContain('projects/p/learnings.jsonl');
|
expect(detail.dropped.missing).toContain('projects/p/learnings.jsonl');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('an unparseable legacy queue line migrates as-is and is preserved, never destroyed', () => {
|
test('an unparseable legacy queue line migrates as-is and is quarantined, never destroyed', () => {
|
||||||
// The line lands in the legacy single-file queue (pre-spool writer);
|
// The line lands in the legacy single-file queue (pre-spool writer);
|
||||||
// migration converts it verbatim to a spool record, and the drain keeps
|
// migration converts it verbatim to a spool record, and the drain moves
|
||||||
// what it cannot parse.
|
// what it cannot parse into quarantine (never deletes it, and never
|
||||||
|
// leaves it re-warning at every boundary).
|
||||||
initWithMode('full');
|
initWithMode('full');
|
||||||
fs.appendFileSync(path.join(tmpHome, '.brain-queue.jsonl'), 'not json at all\n');
|
fs.appendFileSync(path.join(tmpHome, '.brain-queue.jsonl'), 'not json at all\n');
|
||||||
const r = run(['gstack-brain-sync', '--once']);
|
const r = run(['gstack-brain-sync', '--once']);
|
||||||
expect(r.status).toBe(0);
|
expect(r.status).toBe(0);
|
||||||
expect(spoolText()).toContain('not json at all');
|
const qDir = path.join(spoolDir(), 'quarantine');
|
||||||
|
expect(fs.existsSync(qDir)).toBe(true);
|
||||||
|
const qFiles = fs.readdirSync(qDir);
|
||||||
|
expect(qFiles.length).toBe(1);
|
||||||
|
expect(fs.readFileSync(path.join(qDir, qFiles[0]), 'utf-8')).toContain('not json at all');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('finalize: a synced record leaves the spool while a held sibling survives the same drain', () => {
|
test('finalize: a synced record leaves the spool while a held sibling survives the same drain', () => {
|
||||||
@@ -841,7 +846,7 @@ describe('C12 spool queue', () => {
|
|||||||
expect(spoolText()).not.toContain('learnings.jsonl');
|
expect(spoolText()).not.toContain('learnings.jsonl');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('an unparseable spool record is kept and warned about; the drain continues', () => {
|
test('an unparseable spool record is quarantined with a warning; the drain continues', () => {
|
||||||
initWithMode('full');
|
initWithMode('full');
|
||||||
fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true });
|
fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true });
|
||||||
fs.writeFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'), '{"skill":"x","ts":"2026-01-01T00:00:00Z"}\n');
|
fs.writeFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'), '{"skill":"x","ts":"2026-01-01T00:00:00Z"}\n');
|
||||||
@@ -850,10 +855,13 @@ describe('C12 spool queue', () => {
|
|||||||
const r = run(['gstack-brain-sync', '--once']);
|
const r = run(['gstack-brain-sync', '--once']);
|
||||||
expect(r.status).toBe(0);
|
expect(r.status).toBe(0);
|
||||||
expect(r.stderr).toContain('unparseable');
|
expect(r.stderr).toContain('unparseable');
|
||||||
// The good sibling synced; the unreadable record was never destroyed.
|
// The good sibling synced; the unreadable record was never destroyed —
|
||||||
|
// it moved to quarantine so it stops re-warning at every boundary.
|
||||||
expect(remoteLog()).toMatch(/sync: 1 file/);
|
expect(remoteLog()).toMatch(/sync: 1 file/);
|
||||||
expect(spoolFiles()).toEqual([badFile]);
|
expect(spoolFiles()).toEqual([]);
|
||||||
expect(spoolText()).toContain('this is not json');
|
const qPath = path.join(spoolDir(), 'quarantine', badFile);
|
||||||
|
expect(fs.existsSync(qPath)).toBe(true);
|
||||||
|
expect(fs.readFileSync(qPath, 'utf-8')).toContain('this is not json');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('--status queue_depth counts spool records plus unmigrated legacy lines', () => {
|
test('--status queue_depth counts spool records plus unmigrated legacy lines', () => {
|
||||||
@@ -867,6 +875,73 @@ describe('C12 spool queue', () => {
|
|||||||
expect(supplemental.queue_depth).toBe(3);
|
expect(supplemental.queue_depth).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('G1: a malformed pulled privacy map (["bad"]) holds the queue — warns, deletes NOTHING, next run re-drains', () => {
|
||||||
|
// Remotely triggerable kill vector: the privacy map arrives via the
|
||||||
|
// artifacts-repo pull. A non-dict entry used to raise mid-classification
|
||||||
|
// AFTER the snapshot manifest was written, and the old finalize polarity
|
||||||
|
// ("delete unless retained") then unlinked EVERY snapshotted record.
|
||||||
|
initWithMode('full');
|
||||||
|
fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'), '{"skill":"x","ts":"2026-01-01T00:00:00Z"}\n');
|
||||||
|
run(['gstack-brain-enqueue', 'projects/p/learnings.jsonl']);
|
||||||
|
const seeded = spoolFiles();
|
||||||
|
expect(seeded.length).toBe(1);
|
||||||
|
fs.writeFileSync(path.join(tmpHome, '.brain-privacy-map.json'), '["bad"]');
|
||||||
|
|
||||||
|
const r = run(['gstack-brain-sync', '--once']);
|
||||||
|
expect(r.status).toBe(0);
|
||||||
|
expect(r.stderr).toContain('privacy map');
|
||||||
|
// Zero records deleted; nothing pushed.
|
||||||
|
expect(spoolFiles()).toEqual(seeded);
|
||||||
|
expect(remoteLog()).not.toMatch(/sync:/);
|
||||||
|
|
||||||
|
// Fix the map: the surviving queue re-drains and syncs.
|
||||||
|
fs.writeFileSync(path.join(tmpHome, '.brain-privacy-map.json'), '[]');
|
||||||
|
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
|
||||||
|
expect(spoolFiles().length).toBe(0);
|
||||||
|
expect(remoteLog()).toMatch(/sync: 1 file/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('G1: a classifier that dies AFTER the snapshot write consumes nothing (call-site exit check + explicit-delete finalize)', () => {
|
||||||
|
// A dict entry with a non-string pattern passes the shape filter but
|
||||||
|
// raises inside fnmatch DURING classification — the post-manifest crash
|
||||||
|
// window (same shape as ENOSPC/OOM mid-run). The call site must see the
|
||||||
|
// nonzero exit, warn, skip finalize, and leave everything queued.
|
||||||
|
initWithMode('full');
|
||||||
|
fs.mkdirSync(path.join(tmpHome, 'projects', 'p'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(tmpHome, 'projects/p/learnings.jsonl'), '{"skill":"x","ts":"2026-01-01T00:00:00Z"}\n');
|
||||||
|
run(['gstack-brain-enqueue', 'projects/p/learnings.jsonl']);
|
||||||
|
const seeded = spoolFiles();
|
||||||
|
expect(seeded.length).toBe(1);
|
||||||
|
fs.writeFileSync(path.join(tmpHome, '.brain-privacy-map.json'), '[{"pattern": 123}]');
|
||||||
|
|
||||||
|
const r = run(['gstack-brain-sync', '--once']);
|
||||||
|
expect(r.status).toBe(0);
|
||||||
|
expect(r.stderr).toContain('classification failed');
|
||||||
|
expect(spoolFiles()).toEqual(seeded); // zero records deleted
|
||||||
|
expect(remoteLog()).not.toMatch(/sync:/);
|
||||||
|
const status = JSON.parse(fs.readFileSync(path.join(tmpHome, '.brain-sync-status.json'), 'utf-8'));
|
||||||
|
expect(status.status).toBe('error');
|
||||||
|
expect(status.message).toContain('queue preserved');
|
||||||
|
|
||||||
|
// Fix the map: the surviving queue re-drains and syncs.
|
||||||
|
fs.writeFileSync(path.join(tmpHome, '.brain-privacy-map.json'), '[]');
|
||||||
|
expect(run(['gstack-brain-sync', '--once']).status).toBe(0);
|
||||||
|
expect(spoolFiles().length).toBe(0);
|
||||||
|
expect(remoteLog()).toMatch(/sync: 1 file/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('G1: finalize is explicit-delete-only and the fast path is .migrating-aware (static pins)', () => {
|
||||||
|
const src = fs.readFileSync(path.join(BIN, 'gstack-brain-sync'), 'utf-8');
|
||||||
|
// The compute call site checks the python exit status before finalizing.
|
||||||
|
expect(src).toMatch(/if ! compute_paths_to_stage /);
|
||||||
|
// finalize_queue takes the staged-paths file and deletes only staged ∪ dropped.
|
||||||
|
expect(src).toContain('deletable = staged | dropped');
|
||||||
|
expect(src).toContain('if p not in deletable:');
|
||||||
|
// The empty fast path also treats a leftover .migrating file as non-idle.
|
||||||
|
expect(src).toMatch(/spool_has_records && \[ ! -s "\$QUEUE" \] && \[ ! -s "\$QUEUE\.migrating" \]/);
|
||||||
|
});
|
||||||
|
|
||||||
test('--drop-queue keeps the --yes gate and counts spool + legacy entries', () => {
|
test('--drop-queue keeps the --yes gate and counts spool + legacy entries', () => {
|
||||||
initWithMode('full');
|
initWithMode('full');
|
||||||
seedSpool('{"file":"projects/p/a.jsonl","ts":"t"}');
|
seedSpool('{"file":"projects/p/a.jsonl","ts":"t"}');
|
||||||
|
|||||||
@@ -300,9 +300,16 @@ describe("internal: parseTranscriptJsonl + buildTranscriptPage shape", () => {
|
|||||||
|
|
||||||
describe("gstack-memory-ingest --limit", () => {
|
describe("gstack-memory-ingest --limit", () => {
|
||||||
it("respects --limit by stopping after N writes (mocked via --probe shortcut)", () => {
|
it("respects --limit by stopping after N writes (mocked via --probe shortcut)", () => {
|
||||||
const r = runScript(["--probe", "--limit", "1"]);
|
// Hermetic home: against the operator's real HOME this walked the whole
|
||||||
|
// transcript corpus (and, post policy-parity, batch-checked its real
|
||||||
|
// policy store), making a pure arg-parsing assertion slow and flaky.
|
||||||
|
const home = makeTestHome();
|
||||||
|
const gstackHome = join(home, ".gstack");
|
||||||
|
mkdirSync(gstackHome, { recursive: true });
|
||||||
|
const r = runScript(["--probe", "--limit", "1"], { HOME: home, GSTACK_HOME: gstackHome });
|
||||||
// --limit doesn't apply to probe but argument should parse without error
|
// --limit doesn't apply to probe but argument should parse without error
|
||||||
expect(r.exitCode).toBe(0);
|
expect(r.exitCode).toBe(0);
|
||||||
|
rmSync(home, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects --limit 0 with exit 1", () => {
|
it("rejects --limit 0 with exit 1", () => {
|
||||||
@@ -1201,6 +1208,82 @@ describe("#2392: transcript ingest honors per-remote trust policy", () => {
|
|||||||
rmSync(home, { recursive: true, force: true });
|
rmSync(home, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("(f) probe policy parity: a denied remote's transcript lands in skipped_policy_deny, not new_count", () => {
|
||||||
|
// --probe used to count policy-denied transcripts as ingestible (it only
|
||||||
|
// applied attribution), so its numbers overstated what --bulk would write.
|
||||||
|
const home = makeTestHome();
|
||||||
|
const gstackHome = join(home, ".gstack");
|
||||||
|
mkdirSync(gstackHome, { recursive: true });
|
||||||
|
|
||||||
|
const denyCwd = makeRepoWithRemote(home, "denied", "https://github.com/denyme/denied.git");
|
||||||
|
writeSessionForRepo(home, "work-denied", "denysess1", denyCwd);
|
||||||
|
setPolicy(gstackHome, "https://github.com/denyme/denied.git", "deny");
|
||||||
|
|
||||||
|
const r = runScript(["--probe"], { HOME: home, GSTACK_HOME: gstackHome });
|
||||||
|
expect(r.exitCode).toBe(0);
|
||||||
|
expect(r.stdout).toContain("Total files in window: 0");
|
||||||
|
expect(r.stdout).toMatch(/New \(never ingested\):\s+0/);
|
||||||
|
expect(r.stdout).toMatch(/Skipped \(policy deny\):\s+1/);
|
||||||
|
expect(r.stdout).not.toMatch(/Skipped \(policy read-only\)/);
|
||||||
|
rmSync(home, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("(g) probe policy parity: a read-only remote's transcript lands in skipped_policy_readonly", () => {
|
||||||
|
const home = makeTestHome();
|
||||||
|
const gstackHome = join(home, ".gstack");
|
||||||
|
mkdirSync(gstackHome, { recursive: true });
|
||||||
|
|
||||||
|
const roCwd = makeRepoWithRemote(home, "readonly", "https://github.com/roorg/rorepo.git");
|
||||||
|
writeSessionForRepo(home, "work-readonly", "rosess1", roCwd);
|
||||||
|
setPolicy(gstackHome, "https://github.com/roorg/rorepo.git", "read-only");
|
||||||
|
|
||||||
|
const r = runScript(["--probe"], { HOME: home, GSTACK_HOME: gstackHome });
|
||||||
|
expect(r.exitCode).toBe(0);
|
||||||
|
expect(r.stdout).toContain("Total files in window: 0");
|
||||||
|
expect(r.stdout).toMatch(/Skipped \(policy read-only\):\s+1/);
|
||||||
|
rmSync(home, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("(h) --limit counts policy-PERMITTED pages only: a denied-first corpus still writes the allowed page", () => {
|
||||||
|
// Walk order is deterministic here: Claude Code projects are walked
|
||||||
|
// BEFORE Codex sessions (walkAllSources), so the DENIED transcript is
|
||||||
|
// prepared first. Pre-fix, --limit 1 was applied to the unfiltered
|
||||||
|
// prepared array — the denied record consumed the limit and the permitted
|
||||||
|
// one starved (written: 0).
|
||||||
|
const home = makeTestHome();
|
||||||
|
const gstackHome = join(home, ".gstack");
|
||||||
|
mkdirSync(gstackHome, { recursive: true });
|
||||||
|
const { binDir } = installFakeGbrain(home);
|
||||||
|
|
||||||
|
const denyCwd = makeRepoWithRemote(home, "denied", "https://github.com/denyme/denied.git");
|
||||||
|
writeSessionForRepo(home, "work-denied", "denysess1", denyCwd); // Claude Code: walked first
|
||||||
|
const okCwd = makeRepoWithRemote(home, "allowed", "https://github.com/okorg/okrepo.git");
|
||||||
|
const today = new Date();
|
||||||
|
const ymd = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
|
||||||
|
writeCodexSession(
|
||||||
|
home, ymd,
|
||||||
|
`{"type":"session_meta","payload":{"id":"oksess-codex","cwd":"${okCwd.replace(/\\/g, "\\\\")}"},"timestamp":"${today.toISOString()}"}\n`,
|
||||||
|
);
|
||||||
|
setPolicy(gstackHome, "https://github.com/denyme/denied.git", "deny");
|
||||||
|
|
||||||
|
const r = runScript(["--bulk", "--quiet", "--limit", "1"], {
|
||||||
|
HOME: home,
|
||||||
|
GSTACK_HOME: gstackHome,
|
||||||
|
PATH: `${binDir}:${process.env.PATH || ""}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(r.exitCode).toBe(0);
|
||||||
|
expect(r.stdout).toMatch(/written:\s+1/);
|
||||||
|
expect(r.stdout).toMatch(/skipped \(policy deny\):\s+1/);
|
||||||
|
// The page that landed is the PERMITTED one (the Codex session), not
|
||||||
|
// whichever record happened to be walked first.
|
||||||
|
const sessions = stateSessions(gstackHome);
|
||||||
|
expect(sessions.length).toBe(1);
|
||||||
|
expect(sessions[0]).toContain("rollout-");
|
||||||
|
|
||||||
|
rmSync(home, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
it("artifacts are never policy-filtered, even when their project's remote is denied", () => {
|
it("artifacts are never policy-filtered, even when their project's remote is denied", () => {
|
||||||
const home = makeTestHome();
|
const home = makeTestHome();
|
||||||
const gstackHome = join(home, ".gstack");
|
const gstackHome = join(home, ".gstack");
|
||||||
|
|||||||
@@ -662,6 +662,102 @@ describe("fetchGitClaimed — non-mutating live remote query (ls-remote first)",
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("fetchGitClaimed — unfetched live claims (G2: ls-remote advertises SHAs without objects)", () => {
|
||||||
|
// `git ls-remote` lists a branch's tip sha without transferring objects, so
|
||||||
|
// a branch pushed AFTER the last local fetch has no local object and both
|
||||||
|
// VERSION reads fail. The old `continue` silently dropped that LIVE claim —
|
||||||
|
// the exact duplicate-allocation this fallback exists to prevent.
|
||||||
|
function git(cwd: string, ...args: string[]) {
|
||||||
|
return Bun.spawnSync(["git", "-c", "user.email=t@t", "-c", "user.name=t", ...args], { cwd });
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneFixture(): { root: string; origin: string; clone: string } {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "nextver-unfetched-"));
|
||||||
|
const origin = join(root, "origin");
|
||||||
|
mkdirSync(origin);
|
||||||
|
git(origin, "init", "-q", "-b", "main");
|
||||||
|
writeFileSync(join(origin, "VERSION"), "0.1.66.0\n");
|
||||||
|
git(origin, "add", "-A");
|
||||||
|
git(origin, "commit", "-qm", "v0.1.66.0 chore: base");
|
||||||
|
const clone = join(root, "clone");
|
||||||
|
git(root, "clone", "-q", origin, clone);
|
||||||
|
return { root, origin, clone };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a claim branch pushed after the last local fetch is read via a targeted fetch", () => {
|
||||||
|
const { root, origin, clone } = cloneFixture();
|
||||||
|
const cwd = process.cwd();
|
||||||
|
try {
|
||||||
|
// The claim lands on origin AFTER the clone — its objects are absent
|
||||||
|
// locally, so `git show <sha>:VERSION` and the remote-tracking read
|
||||||
|
// both fail until the targeted fetch runs.
|
||||||
|
git(origin, "checkout", "-q", "-b", "late-claim");
|
||||||
|
writeFileSync(join(origin, "VERSION"), "0.1.70.0\n");
|
||||||
|
git(origin, "add", "-A");
|
||||||
|
git(origin, "commit", "-qm", "v0.1.70.0 feat: late claim");
|
||||||
|
git(origin, "checkout", "-q", "main");
|
||||||
|
|
||||||
|
process.chdir(clone);
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const claims = fetchGitClaimed("main", "VERSION", warnings);
|
||||||
|
expect(claims.map((c) => c.version)).toContain("0.1.70.0");
|
||||||
|
expect(warnings.join(" ")).not.toContain("UNKNOWN claim");
|
||||||
|
} finally {
|
||||||
|
process.chdir(cwd);
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a claim STILL unreadable after the fetch surfaces as an UNKNOWN-claim warning, never silence", () => {
|
||||||
|
const { root, origin, clone } = cloneFixture();
|
||||||
|
const cwd = process.cwd();
|
||||||
|
try {
|
||||||
|
// A ref origin advertises but cannot serve: dangling sha written
|
||||||
|
// straight into refs/. ls-remote lists it; every local read fails, the
|
||||||
|
// targeted fetch fails ("not our ref"), and the object never appears.
|
||||||
|
writeFileSync(
|
||||||
|
join(origin, ".git", "refs", "heads", "ghost"),
|
||||||
|
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
process.chdir(clone);
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const claims = fetchGitClaimed("main", "VERSION", warnings);
|
||||||
|
expect(claims.map((c) => c.branch)).not.toContain("origin/ghost");
|
||||||
|
const joined = warnings.join(" ");
|
||||||
|
expect(joined).toContain("origin/ghost");
|
||||||
|
expect(joined).toContain("UNKNOWN claim");
|
||||||
|
} finally {
|
||||||
|
process.chdir(cwd);
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a live branch that simply carries no VERSION file is not a claim and not an UNKNOWN warning", () => {
|
||||||
|
const { root, origin, clone } = cloneFixture();
|
||||||
|
const cwd = process.cwd();
|
||||||
|
try {
|
||||||
|
// Branch exists BEFORE the clone (objects local), VERSION deleted on it:
|
||||||
|
// the read fails because the PATH is absent, not the object. Old
|
||||||
|
// semantics (skip quietly) must hold — no phantom UNKNOWN noise.
|
||||||
|
git(origin, "checkout", "-q", "-b", "docs-only");
|
||||||
|
git(origin, "rm", "-q", "VERSION");
|
||||||
|
git(origin, "commit", "-qm", "docs: no version file");
|
||||||
|
git(origin, "checkout", "-q", "main");
|
||||||
|
git(clone, "fetch", "-q", "origin");
|
||||||
|
|
||||||
|
process.chdir(clone);
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const claims = fetchGitClaimed("main", "VERSION", warnings);
|
||||||
|
expect(claims.map((c) => c.branch)).not.toContain("origin/docs-only");
|
||||||
|
expect(warnings.join(" ")).not.toContain("docs-only");
|
||||||
|
} finally {
|
||||||
|
process.chdir(cwd);
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("width pinned on failed base read (3-digit repos)", () => {
|
describe("width pinned on failed base read (3-digit repos)", () => {
|
||||||
// readBaseVersion used to return a literal "0.0.0.0" when origin/<base> was
|
// readBaseVersion used to return a literal "0.0.0.0" when origin/<base> was
|
||||||
// unreadable — a 4-digit string, which flipped versionWidth() to 4 and
|
// unreadable — a 4-digit string, which flipped versionWidth() to 4 and
|
||||||
|
|||||||
@@ -219,6 +219,77 @@ describe('add-event', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// ensure-event: duplicate (event, source) collapse
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('ensure-event collapses duplicate (event, source) entries', () => {
|
||||||
|
test('two same-source entries from the old matcher-keyed dedup collapse to ONE updated entry', () => {
|
||||||
|
// Pre-existing installs can carry two entries with the same
|
||||||
|
// (event, _gstack_source) — the old dedup keyed on the matcher too, so a
|
||||||
|
// matcher change pushed a second registration. `.find()` updated only the
|
||||||
|
// first and left the stale twin running forever.
|
||||||
|
const { spawnSync } = require('child_process');
|
||||||
|
fs.writeFileSync(settingsFile, JSON.stringify({
|
||||||
|
hooks: {
|
||||||
|
PostToolUse: [
|
||||||
|
{ _gstack_source: 'plan-tune-cathedral', matcher: 'OldMatcherA', hooks: [{ type: 'command', command: '/old-a', timeout: 5 }] },
|
||||||
|
{ matcher: 'Bash', hooks: [{ type: 'command', command: '/user-own-hook' }] },
|
||||||
|
{ _gstack_source: 'plan-tune-cathedral', matcher: 'OldMatcherB', hooks: [{ type: 'command', command: '/old-b', timeout: 5 }] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}, null, 2));
|
||||||
|
|
||||||
|
const r = spawnSync('bash', [
|
||||||
|
SETTINGS_HOOK, 'ensure-event',
|
||||||
|
'--event', 'PostToolUse',
|
||||||
|
'--matcher', 'NewMatcher',
|
||||||
|
'--command', '/canonical',
|
||||||
|
'--source', 'plan-tune-cathedral',
|
||||||
|
'--timeout', '5',
|
||||||
|
], { env: { ...process.env, GSTACK_SETTINGS_FILE: settingsFile }, encoding: 'utf-8', timeout: 15_000 });
|
||||||
|
|
||||||
|
expect(r.status).toBe(0);
|
||||||
|
// The collapse is reported on stderr, never silent.
|
||||||
|
expect(r.stderr).toContain('collapsed 1 duplicate');
|
||||||
|
const s = settings();
|
||||||
|
const mine = s.hooks.PostToolUse.filter((e: any) => e._gstack_source === 'plan-tune-cathedral');
|
||||||
|
expect(mine).toHaveLength(1); // ONE canonical entry — the stale twin is gone
|
||||||
|
expect(mine[0].matcher).toBe('NewMatcher');
|
||||||
|
expect(mine[0].hooks[0].command).toBe('/canonical');
|
||||||
|
// Unrelated user hook untouched.
|
||||||
|
const bash = s.hooks.PostToolUse.find((e: any) => e.matcher === 'Bash');
|
||||||
|
expect(bash.hooks[0].command).toBe('/user-own-hook');
|
||||||
|
expect(s.hooks.PostToolUse).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no duplicates → no collapse message, single entry updated as before', () => {
|
||||||
|
const { spawnSync } = require('child_process');
|
||||||
|
fs.writeFileSync(settingsFile, JSON.stringify({
|
||||||
|
hooks: {
|
||||||
|
PostToolUse: [
|
||||||
|
{ _gstack_source: 'plan-tune-cathedral', matcher: 'OldMatcher', hooks: [{ type: 'command', command: '/old', timeout: 5 }] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}, null, 2));
|
||||||
|
|
||||||
|
const r = spawnSync('bash', [
|
||||||
|
SETTINGS_HOOK, 'ensure-event',
|
||||||
|
'--event', 'PostToolUse',
|
||||||
|
'--matcher', 'NewMatcher',
|
||||||
|
'--command', '/new',
|
||||||
|
'--source', 'plan-tune-cathedral',
|
||||||
|
'--timeout', '5',
|
||||||
|
], { env: { ...process.env, GSTACK_SETTINGS_FILE: settingsFile }, encoding: 'utf-8', timeout: 15_000 });
|
||||||
|
|
||||||
|
expect(r.status).toBe(0);
|
||||||
|
expect(r.stderr).not.toContain('collapsed');
|
||||||
|
const s = settings();
|
||||||
|
expect(s.hooks.PostToolUse).toHaveLength(1);
|
||||||
|
expect(s.hooks.PostToolUse[0].hooks[0].command).toBe('/new');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ----------------------------------------------------------------------
|
// ----------------------------------------------------------------------
|
||||||
// remove-source
|
// remove-source
|
||||||
// ----------------------------------------------------------------------
|
// ----------------------------------------------------------------------
|
||||||
|
|||||||
@@ -233,6 +233,28 @@ describe('gstack-slug ↔ remote-slug parity', () => {
|
|||||||
expect(fs.readFileSync(cacheFile, 'utf8').trim()).toBe('dotty');
|
expect(fs.readFileSync(cacheFile, 'utf8').trim()).toBe('dotty');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('package.json wrapper root (no .git): sticky basename slug is PRESERVED — heal is stray-repo-shape only', () => {
|
||||||
|
// Legit #2212 shape: a monorepo wrapper anchored by package.json used
|
||||||
|
// gstack before an inner dir grew a remote-bearing repo. The degraded-
|
||||||
|
// ancestor heal must NOT fire here — it is restricted to marker roots
|
||||||
|
// anchored by a .git entry whose origin does NOT resolve (the live-bug
|
||||||
|
// stray-repo shape).
|
||||||
|
const wrapper = path.join(fixtures, 'wrapperproj');
|
||||||
|
fs.mkdirSync(wrapper, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(wrapper, 'package.json'), '{"name":"wrapper"}\n');
|
||||||
|
const inner = makeRepo(path.join(wrapper, 'apps', 'web'), 'https://github.com/acme/web.git');
|
||||||
|
|
||||||
|
const cacheDir = path.join(tmpHome, '.gstack', 'slug-cache');
|
||||||
|
fs.mkdirSync(cacheDir, { recursive: true });
|
||||||
|
const cacheFile = path.join(cacheDir, encodedCacheKey(inner));
|
||||||
|
fs.writeFileSync(cacheFile, 'wrapperproj');
|
||||||
|
|
||||||
|
const r = runSlug(inner, tmpHome);
|
||||||
|
expect(r.status).toBe(0);
|
||||||
|
expect(slugOf(r)).toBe('wrapperproj'); // NOT healed to acme-web
|
||||||
|
expect(fs.readFileSync(cacheFile, 'utf8').trim()).toBe('wrapperproj');
|
||||||
|
});
|
||||||
|
|
||||||
test('sticky identity preserved (#2212): repo that adopted a remote after first use is NOT healed', () => {
|
test('sticky identity preserved (#2212): repo that adopted a remote after first use is NOT healed', () => {
|
||||||
// Legit sticky shape: the repo itself is the marker root (REMOTE_ROOT ==
|
// Legit sticky shape: the repo itself is the marker root (REMOTE_ROOT ==
|
||||||
// PROJECT_ROOT) and its cached identity is its pre-origin basename slug.
|
// PROJECT_ROOT) and its cached identity is its pre-origin basename slug.
|
||||||
|
|||||||
@@ -651,6 +651,34 @@ describe('#2600: repair must not write fabricated 0.0.0.0 when VERSION is missin
|
|||||||
expect(JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf-8')).version).toBe('0.5.0');
|
expect(JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf-8')).version).toBe('0.5.0');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('repair proceeds when VERSION genuinely reads 0.0.0.0 (a real file, not the sentinel)', () => {
|
||||||
|
// current === DEFAULT is ambiguous: it is BOTH the missing/unparseable
|
||||||
|
// sentinel AND a legitimate literal "0.0.0.0" in a brand-new repo. The
|
||||||
|
// guard now disambiguates on the raw bytes — a real 0.0.0.0 repairs
|
||||||
|
// package.json to the npm-valid 0.0.0.
|
||||||
|
const dir = makeDir();
|
||||||
|
fs.writeFileSync(path.join(dir, 'VERSION'), '0.0.0.0\n');
|
||||||
|
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'x', version: '0.5.0' }, null, 2) + '\n');
|
||||||
|
|
||||||
|
const out = execFileSync('bun', [BIN, 'repair'], { cwd: dir }).toString();
|
||||||
|
const result = JSON.parse(out);
|
||||||
|
expect(result.repaired).toBe('0.0.0.0');
|
||||||
|
expect(result.packageJsonVersion).toBe('0.0.0');
|
||||||
|
expect(JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf-8')).version).toBe('0.0.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('repair still rejects whitespace-only VERSION content (sentinel path, not a real version)', () => {
|
||||||
|
const dir = makeDir();
|
||||||
|
fs.writeFileSync(path.join(dir, 'VERSION'), ' \n\n');
|
||||||
|
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'x', version: '0.5.0' }, null, 2) + '\n');
|
||||||
|
|
||||||
|
let code = 0;
|
||||||
|
try { execFileSync('bun', [BIN, 'repair'], { cwd: dir, stdio: 'pipe' }); }
|
||||||
|
catch (e: any) { code = e.status; }
|
||||||
|
expect(code).toBe(2);
|
||||||
|
expect(JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf-8')).version).toBe('0.5.0');
|
||||||
|
});
|
||||||
|
|
||||||
test('repair reproduces the exact issue scenario: VERSION in root, package.json in app/ (#2600)', () => {
|
test('repair reproduces the exact issue scenario: VERSION in root, package.json in app/ (#2600)', () => {
|
||||||
// The exact layout from the issue: VERSION at repo root, package.json in app/
|
// The exact layout from the issue: VERSION at repo root, package.json in app/
|
||||||
// Running repair from app/ cwd with no VERSION there used to write 0.0.0.0 into app/package.json.
|
// Running repair from app/ cwd with no VERSION there used to write 0.0.0.0 into app/package.json.
|
||||||
|
|||||||
@@ -245,10 +245,36 @@ describe('gstack-session-update lock identity + TTL (#2613)', () => {
|
|||||||
const src = fs.readFileSync(SCRIPT, 'utf8');
|
const src = fs.readFileSync(SCRIPT, 'utf8');
|
||||||
const mvAside = src.match(/mv "\$LOCK_DIR" "\$LOCK_DIR\.reap\.\$\$" 2>\/dev\/null \|\| \{ log_entry "SKIP lock_contested"; exit 0; \}/g) || [];
|
const mvAside = src.match(/mv "\$LOCK_DIR" "\$LOCK_DIR\.reap\.\$\$" 2>\/dev\/null \|\| \{ log_entry "SKIP lock_contested"; exit 0; \}/g) || [];
|
||||||
expect(mvAside.length).toBe(2); // TTL branch + dead-PID branch
|
expect(mvAside.length).toBe(2); // TTL branch + dead-PID branch
|
||||||
// The only rm -rf of the live lock dir is the holder's EXIT trap.
|
// The only rm -rf of the live lock dir is the holder's EXIT trap — and
|
||||||
|
// even that one is ownership-checked (see the static pin below).
|
||||||
const bareRms = src.match(/rm -rf "\$LOCK_DIR"(?!\.)/g) || [];
|
const bareRms = src.match(/rm -rf "\$LOCK_DIR"(?!\.)/g) || [];
|
||||||
expect(bareRms.length).toBe(1);
|
expect(bareRms.length).toBe(1);
|
||||||
expect(src).toContain(`trap 'rm -rf "$LOCK_DIR" 2>/dev/null' EXIT`);
|
expect(src).toContain(
|
||||||
|
`trap 'kill "$HB_PID" 2>/dev/null; [ "$(cat "$LOCK_DIR/pid" 2>/dev/null)" = "$MYPID" ] && rm -rf "$LOCK_DIR" 2>/dev/null' EXIT`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('EXIT trap is ownership-checked and a heartbeat runs during pull/setup (static pins)', () => {
|
||||||
|
const src = fs.readFileSync(SCRIPT, 'utf8');
|
||||||
|
// (a) After a TTL reclaim by another updater, $LOCK_DIR belongs to the
|
||||||
|
// NEW holder — the old holder's trap must remove the lock ONLY while the
|
||||||
|
// pidfile still contains ITS pid (MYPID captured at write time).
|
||||||
|
const trapLine = src.split('\n').find((l) => l.includes("trap '") && l.includes('rm -rf "$LOCK_DIR"'));
|
||||||
|
expect(trapLine).toBeDefined();
|
||||||
|
expect(trapLine!).toContain('[ "$(cat "$LOCK_DIR/pid" 2>/dev/null)" = "$MYPID" ] && rm -rf "$LOCK_DIR"');
|
||||||
|
// MYPID is written to the pidfile (the identity the trap compares against).
|
||||||
|
expect(src).toContain('MYPID="${BASHPID:-$(sh -c \'echo $PPID\')}"');
|
||||||
|
expect(src).toContain('echo "$MYPID" > "$LOCK_DIR/pid"');
|
||||||
|
// (b) In-flight heartbeat: the step-boundary touches only fire AFTER the
|
||||||
|
// pull / setup return, so a legitimately-slow step past the 30-min TTL
|
||||||
|
// got reclaimed while ALIVE. The loop re-checks ownership each beat and
|
||||||
|
// exits instead of touching a reclaimed holder's pidfile.
|
||||||
|
expect(src).toMatch(
|
||||||
|
/while :; do sleep 300; \[ "\$\(cat "\$LOCK_DIR\/pid" 2>\/dev\/null\)" = "\$MYPID" \] \|\| exit 0; touch "\$LOCK_DIR\/pid" 2>\/dev\/null; done/,
|
||||||
|
);
|
||||||
|
expect(src).toContain('HB_PID=$!');
|
||||||
|
// The trap stops the heartbeat so it can never outlive the holder.
|
||||||
|
expect(trapLine!).toContain('kill "$HB_PID"');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('an expired-TTL lock is reclaimed even when its pid is alive (PID reuse)', async () => {
|
test('an expired-TTL lock is reclaimed even when its pid is alive (PID reuse)', async () => {
|
||||||
|
|||||||
@@ -237,6 +237,20 @@ describe('timeline-stop-hook wiring', () => {
|
|||||||
expect(teardown).toContain('remove-source --source gstack-timeline-stop');
|
expect(teardown).toContain('remove-source --source gstack-timeline-stop');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('setup surfaces a settings-hook refusal instead of swallowing it', () => {
|
||||||
|
// The hardened settings-hook refuses to rewrite a corrupt settings.json
|
||||||
|
// (exit 1). Both setup call sites (ALREADY_INSTALLED plan-tune re-point,
|
||||||
|
// timeline ensure-event) must stay non-fatal but PRINT the failure.
|
||||||
|
const setup = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8');
|
||||||
|
const warnings = setup.match(/settings hook update failed/g) || [];
|
||||||
|
expect(warnings.length).toBeGreaterThanOrEqual(2);
|
||||||
|
// The old swallow patterns are gone (the --no-team remove-source teardown
|
||||||
|
// legitimately keeps its 2>/dev/null; only the ensure-event registration
|
||||||
|
// must surface stderr).
|
||||||
|
expect(setup).not.toContain('_install_plan_tune_hooks >/dev/null 2>&1 || true');
|
||||||
|
expect(setup).not.toMatch(/ensure-event[\s\S]{0,220}--source gstack-timeline-stop[\s\S]{0,40}2>\/dev\/null/);
|
||||||
|
});
|
||||||
|
|
||||||
test('setup routes the Stop hook through ensure-event, not presence-only dedup', () => {
|
test('setup routes the Stop hook through ensure-event, not presence-only dedup', () => {
|
||||||
const setup = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8');
|
const setup = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8');
|
||||||
// ensure-event registers when missing AND re-points a stale path in place.
|
// ensure-event registers when missing AND re-points a stale path in place.
|
||||||
|
|||||||
Reference in New Issue
Block a user