chore: import upstream snapshot with attribution
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
This commit is contained in:
Executable
+17
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
prompt=${1:-"Use the bash tool to run 'pwd', then use the ls tool to list the current directory, then respond with DONE."}
|
||||
provider=${JCODE_PROVIDER:-auto}
|
||||
cargo_exec="$repo_root/scripts/cargo_exec.sh"
|
||||
|
||||
if [[ ! -x "$repo_root/target/release/jcode" ]]; then
|
||||
(cd "$repo_root" && "$cargo_exec" build --release)
|
||||
fi
|
||||
|
||||
workdir=$(mktemp -d)
|
||||
trap 'rm -rf "$workdir"' EXIT
|
||||
|
||||
JCODE_HOME="$workdir" PATH="$repo_root/target/release:$PATH" \
|
||||
jcode run --no-update --trace --provider "$provider" "$prompt"
|
||||
@@ -0,0 +1,197 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Analyze OpenAI persistent-websocket reuse and KV-cache effectiveness from jcode logs.
|
||||
|
||||
Motivation
|
||||
----------
|
||||
OpenAI prompt caching on the ChatGPT/Codex backend is driven by the persistent
|
||||
websocket reuse path: it sends only a delta + ``previous_response_id`` on the
|
||||
same connection, so the server already holds the KV tensors for that prefix.
|
||||
When the socket is torn down, the chain is lost (``store=false``) and the next
|
||||
turn re-sends the full conversation, relying on OpenAI prefix-hash routing which
|
||||
frequently lands on a cold machine (zero cache read).
|
||||
|
||||
This script quantifies:
|
||||
* connection mix (persistent-reuse vs persistent-fresh)
|
||||
* why fresh connections happen (state-reset reasons, idle reconnects)
|
||||
* realized cache hit rate per provider
|
||||
* OpenAI zero/low-read events
|
||||
|
||||
Use it before/after changing ``JCODE_OPENAI_WS_IDLE_RECONNECT_SECS`` (or the
|
||||
default) to confirm idle-reconnect churn drops and reuse/cache rates rise.
|
||||
|
||||
Usage
|
||||
-----
|
||||
python3 scripts/analyze_openai_ws_cache.py [LOGFILE ...]
|
||||
|
||||
With no arguments it scans ~/.jcode/logs/jcode-*.log.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import collections
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
def _log_files(argv: list[str]) -> list[str]:
|
||||
if argv:
|
||||
return argv
|
||||
home = os.environ.get("HOME", "")
|
||||
return sorted(glob.glob(os.path.join(home, ".jcode", "logs", "jcode-*.log")))
|
||||
|
||||
|
||||
_KV_FIELD_RE = re.compile(r"(\w+)=([^\s]+)")
|
||||
|
||||
|
||||
def analyze(files: list[str]) -> dict:
|
||||
conn = collections.Counter() # persistent-reuse / persistent-fresh
|
||||
reset_reason = collections.Counter() # persistent_state_reset reason=...
|
||||
reuse_detail = collections.Counter() # persistent_reuse_unavailable_detail reason=...
|
||||
idle_reconnect_secs: list[int] = [] # observed idle durations that triggered reconnect
|
||||
cache = collections.defaultdict(lambda: [0, 0, 0]) # provider -> [new_input, read, n]
|
||||
# OpenAI read_pct distribution, using the harness's own authoritative
|
||||
# read_pct field rather than a token-ratio proxy (the harness computes
|
||||
# read_pct against cache-reportable input, not read+new_input).
|
||||
oa_readpct = collections.Counter()
|
||||
oa_readpct_n = 0
|
||||
oa_zero = 0
|
||||
oa_zero_tokens = 0
|
||||
|
||||
idle_re = re.compile(r"Persistent WS idle for (\d+)s; reconnecting")
|
||||
|
||||
for path in files:
|
||||
try:
|
||||
fh = open(path, errors="replace")
|
||||
except OSError:
|
||||
continue
|
||||
with fh:
|
||||
for line in fh:
|
||||
if "persistent-reuse" in line:
|
||||
conn["reuse"] += 1
|
||||
elif "persistent-fresh" in line:
|
||||
conn["fresh"] += 1
|
||||
|
||||
if "persistent_state_reset" in line:
|
||||
m = re.search(r"reason=([a-z_]+)", line)
|
||||
if m:
|
||||
reset_reason[m.group(1)] += 1
|
||||
|
||||
if "persistent_reuse_unavailable_detail" in line:
|
||||
m = re.search(r"reason=([a-z_]+)", line)
|
||||
if m:
|
||||
reuse_detail[m.group(1)] += 1
|
||||
|
||||
m = idle_re.search(line)
|
||||
if m:
|
||||
idle_reconnect_secs.append(int(m.group(1)))
|
||||
|
||||
if "KV_CACHE_USAGE" in line:
|
||||
d = dict(_KV_FIELD_RE.findall(line))
|
||||
provider = d.get("provider", "?")
|
||||
try:
|
||||
new_input = int(d.get("input", "0"))
|
||||
read = int(d.get("cache_read", "0"))
|
||||
except ValueError:
|
||||
continue
|
||||
bucket = cache[provider]
|
||||
bucket[0] += new_input
|
||||
bucket[1] += read
|
||||
bucket[2] += 1
|
||||
if provider == "OpenAI":
|
||||
prompt = new_input + read
|
||||
if prompt > 1024 and read == 0:
|
||||
oa_zero += 1
|
||||
oa_zero_tokens += new_input
|
||||
read_pct = d.get("read_pct")
|
||||
if read_pct not in (None, "None"):
|
||||
try:
|
||||
v = float(read_pct)
|
||||
except ValueError:
|
||||
v = None
|
||||
if v is not None:
|
||||
oa_readpct_n += 1
|
||||
if v >= 90:
|
||||
oa_readpct[">=90%"] += 1
|
||||
elif v >= 70:
|
||||
oa_readpct["70-90%"] += 1
|
||||
elif v >= 50:
|
||||
oa_readpct["50-70%"] += 1
|
||||
elif v > 0:
|
||||
oa_readpct["1-50%"] += 1
|
||||
else:
|
||||
oa_readpct["0%"] += 1
|
||||
|
||||
return {
|
||||
"conn": conn,
|
||||
"reset_reason": reset_reason,
|
||||
"reuse_detail": reuse_detail,
|
||||
"idle_reconnect_secs": idle_reconnect_secs,
|
||||
"cache": cache,
|
||||
"oa_readpct": oa_readpct,
|
||||
"oa_readpct_n": oa_readpct_n,
|
||||
"oa_zero": oa_zero,
|
||||
"oa_zero_tokens": oa_zero_tokens,
|
||||
}
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
files = _log_files(argv)
|
||||
if not files:
|
||||
print("no log files found", file=sys.stderr)
|
||||
return 1
|
||||
print(f"Scanned {len(files)} log file(s)")
|
||||
r = analyze(files)
|
||||
|
||||
conn = r["conn"]
|
||||
total_conn = conn["reuse"] + conn["fresh"]
|
||||
print("\n== Connection mix ==")
|
||||
if total_conn:
|
||||
print(f" reuse : {conn['reuse']:>6} ({100*conn['reuse']/total_conn:.1f}%)")
|
||||
print(f" fresh : {conn['fresh']:>6} ({100*conn['fresh']/total_conn:.1f}%)")
|
||||
else:
|
||||
print(" (no ConnectionType events)")
|
||||
|
||||
print("\n== Fresh-connection causes ==")
|
||||
print(" persistent_reuse_unavailable_detail:")
|
||||
for reason, n in r["reuse_detail"].most_common():
|
||||
print(f" {reason:24s} {n}")
|
||||
print(" persistent_state_reset:")
|
||||
for reason, n in r["reset_reason"].most_common():
|
||||
print(f" {reason:24s} {n}")
|
||||
|
||||
idle = r["idle_reconnect_secs"]
|
||||
print("\n== Idle-reconnect events (the avoidable churn) ==")
|
||||
if idle:
|
||||
idle_sorted = sorted(idle)
|
||||
print(f" count={len(idle)} min={idle_sorted[0]}s "
|
||||
f"median={idle_sorted[len(idle_sorted)//2]}s max={idle_sorted[-1]}s")
|
||||
# how many would be saved by a higher threshold
|
||||
for thr in (90, 300, 600, 900):
|
||||
saved = sum(1 for s in idle if s < thr)
|
||||
print(f" threshold {thr:>4}s would have avoided {saved}/{len(idle)} reconnects")
|
||||
else:
|
||||
print(" count=0 (no idle reconnects logged)")
|
||||
|
||||
print("\n== Realized cache hit rate (read / (read + new_input)) ==")
|
||||
for provider, (new_input, read, n) in sorted(r["cache"].items()):
|
||||
total = new_input + read
|
||||
if total:
|
||||
print(f" {provider:8s} hit={100*read/total:5.1f}% "
|
||||
f"read={read:>13,} new_input={new_input:>13,} n={n}")
|
||||
|
||||
print("\n== OpenAI cold-prefill cost ==")
|
||||
print(f" zero-read prompts (>1024 tok): {r['oa_zero']} "
|
||||
f"(~{r['oa_zero_tokens']:,} full-price input tokens)")
|
||||
n = r["oa_readpct_n"]
|
||||
if n:
|
||||
print(f" read_pct distribution (harness field, n={n}):")
|
||||
for k in (">=90%", "70-90%", "50-70%", "1-50%", "0%"):
|
||||
c = r["oa_readpct"][k]
|
||||
print(f" {k:8s}: {c:>5} ({100*c/n:.1f}%)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
Executable
+526
@@ -0,0 +1,526 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Analyze the monolithic root `jcode` crate to plan a bottom-up split.
|
||||
|
||||
For every top-level module under src/ (a `foo.rs` file or a `foo/` dir) it
|
||||
computes:
|
||||
- loc: total lines of Rust (incl. submodules for dir modules)
|
||||
- facade: whether it is already just `pub use jcode_*::*;`
|
||||
- inbound: how many *other* top-level modules reference `crate::<mod>`
|
||||
- outbound: which other in-root (non-facade) modules it references
|
||||
|
||||
The goal is to find low-coupling, high-line-count leaves to extract next, and
|
||||
to compute a topological-ish extraction order (extract modules whose in-root
|
||||
outbound deps are already crates/facades first).
|
||||
|
||||
Pure static analysis: safe to run while builds are in progress.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import json
|
||||
from collections import defaultdict
|
||||
|
||||
SRC = os.path.join(os.path.dirname(__file__), "..", "src")
|
||||
SRC = os.path.normpath(SRC)
|
||||
|
||||
CRATE_RE = re.compile(r"\bcrate::([a-z_][a-z0-9_]*)")
|
||||
FACADE_RE = re.compile(r"pub use jcode_[a-z0-9_]+::")
|
||||
# `use crate::...;` statement body (may span lines); group 1 is everything
|
||||
# between `crate::` and the terminating `;`.
|
||||
USE_CRATE_RE = re.compile(r"\buse\s+crate::([^;]*);", re.DOTALL)
|
||||
|
||||
|
||||
def _split_top_level(body: str) -> list[str]:
|
||||
"""Split a brace-group body on top-level commas (ignoring nested braces)."""
|
||||
parts: list[str] = []
|
||||
depth = 0
|
||||
cur = []
|
||||
for ch in body:
|
||||
if ch == "{":
|
||||
depth += 1
|
||||
cur.append(ch)
|
||||
elif ch == "}":
|
||||
depth -= 1
|
||||
cur.append(ch)
|
||||
elif ch == "," and depth == 0:
|
||||
parts.append("".join(cur))
|
||||
cur = []
|
||||
else:
|
||||
cur.append(ch)
|
||||
if cur:
|
||||
parts.append("".join(cur))
|
||||
return [p.strip() for p in parts if p.strip()]
|
||||
|
||||
|
||||
def _module_imports(joined: str):
|
||||
"""Parse `use crate::...;` statements in `joined` source text.
|
||||
|
||||
Returns (group_edge_counts, alias_set):
|
||||
* group_edge_counts: target module -> number of grouped-import references
|
||||
that `CRATE_RE` cannot see (because `{` immediately follows `crate::`).
|
||||
Non-grouped `use crate::name...;` imports are intentionally left to
|
||||
`CRATE_RE` so we never double count.
|
||||
* alias_set: module names brought into local scope as a bare `name::`
|
||||
path prefix (so later bare `name::` usages can be attributed).
|
||||
"""
|
||||
group_edges: dict[str, int] = defaultdict(int)
|
||||
aliases: set[str] = set()
|
||||
ident = re.compile(r"^[a-z_][a-z0-9_]*$")
|
||||
for m in USE_CRATE_RE.finditer(joined):
|
||||
rest = m.group(1).strip()
|
||||
if rest.startswith("{"):
|
||||
# Grouped import: `use crate::{a, b, c::Foo, d::{self, X}};`
|
||||
inner = rest[1 : rest.rfind("}")] if "}" in rest else rest[1:]
|
||||
for entry in _split_top_level(inner):
|
||||
if entry in ("self", "*"):
|
||||
continue
|
||||
head = entry.split("::", 1)[0].split(" as ")[0].strip()
|
||||
if not ident.match(head):
|
||||
continue
|
||||
group_edges[head] += 1
|
||||
# Bare `head` (no `::`) or `head::{self...}` brings `head` itself
|
||||
# into scope as a usable module path prefix.
|
||||
if "::" not in entry:
|
||||
aliases.add(head)
|
||||
elif re.match(rf"^{re.escape(head)}::\{{\s*self\b", entry):
|
||||
aliases.add(head)
|
||||
else:
|
||||
# Non-grouped: `use crate::name;`, `name::Item`, `name::{...}`,
|
||||
# `name as X`. CRATE_RE already counts the `crate::name` edge, so we
|
||||
# only track whether `name` becomes a bare path alias here.
|
||||
head = rest.split("::", 1)[0].split(" as ")[0].strip()
|
||||
if not ident.match(head):
|
||||
continue
|
||||
if "::" not in rest:
|
||||
aliases.add(head)
|
||||
elif re.match(rf"^{re.escape(head)}::\{{\s*self\b", rest):
|
||||
aliases.add(head)
|
||||
return dict(group_edges), aliases
|
||||
|
||||
|
||||
def top_level_modules() -> dict[str, list[str]]:
|
||||
"""Return {module_name: [file paths]} for each top-level module.
|
||||
|
||||
A module `foo` may be backed by `src/foo.rs`, `src/foo/` (dir), or BOTH (a
|
||||
facade `foo.rs` that re-exports a crate plus a small local `foo/` submodule).
|
||||
We collect all backing files under one logical module and remember the
|
||||
canonical entry file (the `.rs` sibling, else `foo/mod.rs`).
|
||||
"""
|
||||
mods: dict[str, list[str]] = {}
|
||||
entries: dict[str, str] = {}
|
||||
dir_entries: dict[str, str] = {}
|
||||
for entry in sorted(os.listdir(SRC)):
|
||||
path = os.path.join(SRC, entry)
|
||||
if entry.endswith(".rs") and os.path.isfile(path):
|
||||
name = entry[:-3]
|
||||
if name in ("lib", "main"):
|
||||
continue
|
||||
mods.setdefault(name, []).append(path)
|
||||
entries[name] = path # foo.rs is always the canonical entry
|
||||
elif os.path.isdir(path):
|
||||
files = []
|
||||
for root, _dirs, fnames in os.walk(path):
|
||||
for fn in fnames:
|
||||
if fn.endswith(".rs"):
|
||||
files.append(os.path.join(root, fn))
|
||||
if files:
|
||||
mods.setdefault(entry, []).extend(sorted(files))
|
||||
modrs = os.path.join(path, "mod.rs")
|
||||
dir_entries[entry] = modrs if os.path.exists(modrs) else sorted(files)[0]
|
||||
# Canonical entry: prefer the `.rs` sibling; fall back to the dir entry.
|
||||
for name, dir_entry in dir_entries.items():
|
||||
entries.setdefault(name, dir_entry)
|
||||
# stash entries on the function for the caller
|
||||
top_level_modules.entries = entries # type: ignore[attr-defined]
|
||||
return mods
|
||||
|
||||
|
||||
def loc(files: list[str]) -> int:
|
||||
total = 0
|
||||
for f in files:
|
||||
try:
|
||||
with open(f, encoding="utf-8", errors="ignore") as fh:
|
||||
total += sum(1 for _ in fh)
|
||||
except OSError:
|
||||
pass
|
||||
return total
|
||||
|
||||
|
||||
def facade_ratio(name: str) -> tuple[float, int, int]:
|
||||
"""Return (re-export ratio, facade_lines, code_lines) for the entry file.
|
||||
|
||||
A high ratio means the module's public surface mostly lives in a crate
|
||||
already; a low ratio means real local logic still lives in the root crate.
|
||||
"""
|
||||
entry = getattr(top_level_modules, "entries", {}).get(name)
|
||||
if entry is None or not os.path.exists(entry):
|
||||
return (0.0, 0, 0)
|
||||
try:
|
||||
with open(entry, encoding="utf-8", errors="ignore") as fh:
|
||||
text = fh.read()
|
||||
except OSError:
|
||||
return (0.0, 0, 0)
|
||||
code_lines = [
|
||||
ln.strip()
|
||||
for ln in text.splitlines()
|
||||
if ln.strip()
|
||||
and not ln.strip().startswith("//")
|
||||
and not ln.strip().startswith("#![")
|
||||
and not ln.strip().startswith("#[")
|
||||
]
|
||||
if not code_lines:
|
||||
return (0.0, 0, 0)
|
||||
facade_lines = [ln for ln in code_lines if FACADE_RE.search(ln)]
|
||||
return (len(facade_lines) / len(code_lines), len(facade_lines), len(code_lines))
|
||||
|
||||
|
||||
def classify_facade(name: str, total_loc: int = 0) -> str:
|
||||
"""fully | thick | none.
|
||||
|
||||
fully: entry is essentially just re-exports (already a crate facade).
|
||||
thick: re-exports a crate but keeps a small residual of local API/logic.
|
||||
none: no crate re-export, OR a large module whose bulk still lives locally
|
||||
(a re-export line in mod.rs does not make a 30K-line dir a facade).
|
||||
"""
|
||||
ratio, facade_lines, code_lines = facade_ratio(name)
|
||||
if facade_lines == 0:
|
||||
return "none"
|
||||
# A large module still carries its weight in-root regardless of a convenience
|
||||
# re-export in its entry file; only small modules can be "extracted enough".
|
||||
if total_loc > 600:
|
||||
return "none"
|
||||
# Mostly re-exports, only a tiny tail of local helpers -> fully extracted.
|
||||
if ratio >= 0.5 or code_lines <= max(8, facade_lines + 4):
|
||||
return "fully"
|
||||
return "thick"
|
||||
|
||||
|
||||
def is_facade(name: str, files: list[str]) -> bool:
|
||||
# "Extracted enough" to no longer count as a real in-root blocker for bulk.
|
||||
return classify_facade(name, loc(files)) == "fully"
|
||||
|
||||
|
||||
def outbound_refs(files: list[str], self_name: str, exclude_tests: bool = True):
|
||||
"""Return (ref_set, ref_counts) for `crate::<mod>` references.
|
||||
|
||||
For crate-split planning we care about the *library* dependency graph, so by
|
||||
default we skip test-only files (`*_tests.rs`, `tests.rs`) and lines guarded
|
||||
by an immediately-preceding `#[cfg(test)]`. Test deps would become
|
||||
dev-dependencies and do not constrain how the lib is split into crates.
|
||||
|
||||
ref_counts maps target module -> number of referencing lines, used as an edge
|
||||
weight: a cheap edge (few references) is easy to invert/cut to break a cycle.
|
||||
"""
|
||||
counts: dict[str, int] = defaultdict(int)
|
||||
for f in files:
|
||||
base = os.path.basename(f)
|
||||
if exclude_tests and (base == "tests.rs" or base.endswith("_tests.rs")):
|
||||
continue
|
||||
try:
|
||||
with open(f, encoding="utf-8", errors="ignore") as fh:
|
||||
lines = fh.readlines()
|
||||
except OSError:
|
||||
continue
|
||||
in_test_block = False
|
||||
test_block_depth = 0
|
||||
depth = 0
|
||||
pending_cfg_test = False
|
||||
code_lines: list[str] = []
|
||||
for ln in lines:
|
||||
stripped = ln.strip()
|
||||
if exclude_tests and stripped.startswith("#[cfg(test)]"):
|
||||
pending_cfg_test = True
|
||||
continue
|
||||
if exclude_tests and pending_cfg_test and "{" in ln:
|
||||
in_test_block = True
|
||||
test_block_depth = depth
|
||||
pending_cfg_test = False
|
||||
if in_test_block:
|
||||
depth += ln.count("{") - ln.count("}")
|
||||
if depth <= test_block_depth:
|
||||
in_test_block = False
|
||||
continue
|
||||
depth += ln.count("{") - ln.count("}")
|
||||
code_lines.append(ln)
|
||||
for m in CRATE_RE.finditer(ln):
|
||||
counts[m.group(1)] += 1
|
||||
# Grouped `use crate::{...}` edges (invisible to CRATE_RE) and bare
|
||||
# `alias::` usages from imported module aliases. Without this, any
|
||||
# module pulled in via a grouped import (e.g. `use crate::{id, tui};`)
|
||||
# and then referenced as `tui::App` would be entirely uncounted,
|
||||
# badly undercounting edge weights for crate-split planning.
|
||||
joined = "".join(code_lines)
|
||||
group_edges, aliases = _module_imports(joined)
|
||||
for name, c in group_edges.items():
|
||||
counts[name] += c
|
||||
aliases.discard(self_name)
|
||||
if aliases:
|
||||
alias_re = re.compile(
|
||||
r"(?<![\w:])(" + "|".join(re.escape(a) for a in sorted(aliases)) + r")::"
|
||||
)
|
||||
for ln in code_lines:
|
||||
if ln.lstrip().startswith("use "):
|
||||
continue
|
||||
for m in alias_re.finditer(ln):
|
||||
counts[m.group(1)] += 1
|
||||
counts.pop(self_name, None)
|
||||
return set(counts), dict(counts)
|
||||
|
||||
|
||||
def strongly_connected_components(graph: dict[str, set[str]]) -> list[list[str]]:
|
||||
"""Tarjan's SCC over the module dependency graph.
|
||||
|
||||
A component with >1 node (or a self-loop) is a dependency cycle: those
|
||||
modules cannot be split into separate crates without first breaking the
|
||||
cycle (e.g. by extracting a shared trait/interface crate). Returned in
|
||||
reverse-topological order (leaves first).
|
||||
"""
|
||||
index_counter = [0]
|
||||
stack: list[str] = []
|
||||
on_stack: dict[str, bool] = {}
|
||||
index: dict[str, int] = {}
|
||||
lowlink: dict[str, int] = {}
|
||||
result: list[list[str]] = []
|
||||
|
||||
import sys as _sys
|
||||
|
||||
_sys.setrecursionlimit(10000)
|
||||
|
||||
def strongconnect(v: str) -> None:
|
||||
index[v] = index_counter[0]
|
||||
lowlink[v] = index_counter[0]
|
||||
index_counter[0] += 1
|
||||
stack.append(v)
|
||||
on_stack[v] = True
|
||||
for w in sorted(graph.get(v, ())):
|
||||
if w not in index:
|
||||
strongconnect(w)
|
||||
lowlink[v] = min(lowlink[v], lowlink[w])
|
||||
elif on_stack.get(w):
|
||||
lowlink[v] = min(lowlink[v], index[w])
|
||||
if lowlink[v] == index[v]:
|
||||
comp = []
|
||||
while True:
|
||||
w = stack.pop()
|
||||
on_stack[w] = False
|
||||
comp.append(w)
|
||||
if w == v:
|
||||
break
|
||||
result.append(comp)
|
||||
|
||||
for v in sorted(graph):
|
||||
if v not in index:
|
||||
strongconnect(v)
|
||||
return result
|
||||
|
||||
|
||||
def main() -> int:
|
||||
mods = top_level_modules()
|
||||
names = set(mods)
|
||||
|
||||
info = {}
|
||||
for name, files in mods.items():
|
||||
module_loc = loc(files)
|
||||
refs, ref_counts = outbound_refs(files, name)
|
||||
info[name] = {
|
||||
"loc": module_loc,
|
||||
"facade": classify_facade(name, module_loc) == "fully",
|
||||
"facade_class": classify_facade(name, module_loc),
|
||||
"outbound": refs,
|
||||
"ref_counts": ref_counts,
|
||||
}
|
||||
|
||||
# inbound: count of other modules referencing crate::<name>
|
||||
inbound = defaultdict(set)
|
||||
for name, meta in info.items():
|
||||
for dep in meta["outbound"]:
|
||||
if dep in names and dep != name:
|
||||
inbound[dep].add(name)
|
||||
|
||||
# "in-root blockers": outbound deps that are still real in-root modules with
|
||||
# substantive local logic. A `thick` facade (re-exports a crate + a tiny tail
|
||||
# of local helpers) is NOT a bulk blocker: its weight already moved to a crate.
|
||||
def is_blocker(dep: str) -> bool:
|
||||
return dep in names and info[dep]["facade_class"] == "none"
|
||||
|
||||
for name, meta in info.items():
|
||||
blockers = {d for d in meta["outbound"] if is_blocker(d) and d != name}
|
||||
meta["in_root_blockers"] = blockers
|
||||
meta["inbound_count"] = len(inbound.get(name, ()))
|
||||
|
||||
extractable_now = sorted(
|
||||
(
|
||||
n
|
||||
for n, m in info.items()
|
||||
if m["facade_class"] == "none"
|
||||
and not n.endswith("_tests")
|
||||
and not m["in_root_blockers"]
|
||||
),
|
||||
key=lambda n: -info[n]["loc"],
|
||||
)
|
||||
|
||||
if "--json" in sys.argv:
|
||||
out = {
|
||||
n: {
|
||||
"loc": m["loc"],
|
||||
"facade_class": m["facade_class"],
|
||||
"inbound": m["inbound_count"],
|
||||
"in_root_blockers": sorted(m["in_root_blockers"]),
|
||||
}
|
||||
for n, m in info.items()
|
||||
}
|
||||
print(json.dumps({"modules": out, "extractable_now": extractable_now}, indent=2))
|
||||
return 0
|
||||
|
||||
total = sum(m["loc"] for m in info.values())
|
||||
nonfacade = sum(m["loc"] for m in info.values() if m["facade_class"] == "none")
|
||||
thick = sum(m["loc"] for m in info.values() if m["facade_class"] == "thick")
|
||||
print(f"root crate total loc (top-level modules): {total}")
|
||||
print(f" fully in-root (no crate yet): {nonfacade}")
|
||||
print(f" thick facades (crate + residual local): {thick}")
|
||||
print(f" fully-facade loc: {total - nonfacade - thick}")
|
||||
print()
|
||||
print(f"{'module':24} {'loc':>7} {'fac':>5} {'in':>4} in-root blockers")
|
||||
print("-" * 90)
|
||||
for n in sorted(info, key=lambda n: -info[n]["loc"]):
|
||||
m = info[n]
|
||||
if m["facade_class"] == "fully" or n.endswith("_tests"):
|
||||
continue
|
||||
blk = ", ".join(sorted(m["in_root_blockers"])) or "-- (none: extractable now)"
|
||||
print(f"{n:24} {m['loc']:>7} {m['facade_class']:>5} {m['inbound_count']:>4} {blk}")
|
||||
print()
|
||||
print("=== Extractable now (no in-root blockers), largest first ===")
|
||||
for n in extractable_now:
|
||||
print(f" {n:24} {info[n]['loc']:>7} loc, inbound={info[n]['inbound_count']}")
|
||||
|
||||
# SCC analysis over the in-root dependency graph (only real, non-fully-facade
|
||||
# modules count as nodes/edges). Multi-node components are dependency cycles
|
||||
# that must be broken before those modules can become independent crates.
|
||||
graph = {
|
||||
n: {d for d in m["outbound"] if d in info and info[d]["facade_class"] != "fully" and d != n}
|
||||
for n, m in info.items()
|
||||
if m["facade_class"] != "fully"
|
||||
}
|
||||
sccs = strongly_connected_components(graph)
|
||||
cycles = [c for c in sccs if len(c) > 1]
|
||||
cycles.sort(key=lambda c: -sum(info[n]["loc"] for n in c))
|
||||
print()
|
||||
print("=== Dependency cycles (SCCs > 1 node) — must break before clean split ===")
|
||||
if not cycles:
|
||||
print(" (none: the in-root module graph is already a DAG)")
|
||||
for c in cycles:
|
||||
cloc = sum(info[n]["loc"] for n in c)
|
||||
member_str = ", ".join(sorted(c, key=lambda n: -info[n]["loc"]))
|
||||
print(f" [{len(c)} modules, {cloc} loc] {member_str}")
|
||||
|
||||
# For the largest cycle, suggest the cheapest edges to cut/invert to make it
|
||||
# acyclic (a feedback arc set). We use Eades' greedy linear-arrangement
|
||||
# heuristic to get a vertex order, then report edges that go "backwards" in
|
||||
# that order, weighted by reference count (cheap edges = easy refactors).
|
||||
if cycles:
|
||||
big = max(cycles, key=lambda c: sum(info[n]["loc"] for n in c))
|
||||
sub = set(big)
|
||||
# Build weighted subgraph restricted to the cycle.
|
||||
out_edges = {
|
||||
n: {d: info[n]["ref_counts"].get(d, 1) for d in info[n]["outbound"] if d in sub and d != n}
|
||||
for n in big
|
||||
}
|
||||
order = eades_order(big, out_edges)
|
||||
pos = {n: i for i, n in enumerate(order)}
|
||||
back_edges = []
|
||||
for u in big:
|
||||
for v, w in out_edges[u].items():
|
||||
if pos[v] < pos[u]: # edge points backwards => part of feedback set
|
||||
back_edges.append((w, u, v))
|
||||
back_edges.sort() # cheapest first
|
||||
total_back = sum(w for w, _u, _v in back_edges)
|
||||
print()
|
||||
print(
|
||||
f"=== Feedback arc set for the {len(big)}-module cycle "
|
||||
f"({len(back_edges)} back-edges, {total_back} refs to invert) ==="
|
||||
)
|
||||
print(" Invert/cut these edges (cheapest first) to make the cycle a DAG:")
|
||||
limit = 1000 if "--full" in sys.argv else 30
|
||||
for w, u, v in back_edges[:limit]:
|
||||
print(f" {u} -> {v} ({w} refs)")
|
||||
if len(back_edges) > limit:
|
||||
print(f" ... and {len(back_edges) - limit} more (use --full to list all)")
|
||||
|
||||
# Per-node eviction cost: a module only leaves the SCC once ALL of its
|
||||
# out-edges into the cycle are cut. Rank cycle members by how few/cheap
|
||||
# those out-edges are -- those are the cheapest modules to evict next
|
||||
# (turning the SCC strictly smaller, which is what shrinks the largest
|
||||
# compile unit). For each node, list its in-cycle out-edges.
|
||||
evict = []
|
||||
for n in big:
|
||||
edges = sorted(
|
||||
((w, d) for d, w in out_edges[n].items()),
|
||||
key=lambda x: (x[0], x[1]),
|
||||
)
|
||||
n_edges = len(edges)
|
||||
total_w = sum(w for w, _ in edges)
|
||||
evict.append((n_edges, total_w, n, edges))
|
||||
evict.sort(key=lambda x: (x[0], x[1], -info[x[2]]["loc"]))
|
||||
print()
|
||||
print(
|
||||
"=== Cheapest modules to evict next from the cycle "
|
||||
"(fewest in-cycle out-edges first) ==="
|
||||
)
|
||||
print(" A module leaves the SCC once all these out-edges are inverted/cut:")
|
||||
ev_limit = 1000 if "--full" in sys.argv else 12
|
||||
for n_edges, total_w, n, edges in evict[:ev_limit]:
|
||||
tgt = ", ".join(f"{d}({w})" for w, d in edges) or "-- (none)"
|
||||
print(
|
||||
f" {n:<20} {info[n]['loc']:>7} loc "
|
||||
f"{n_edges} edges / {total_w} refs -> {tgt}"
|
||||
)
|
||||
if len(evict) > ev_limit:
|
||||
print(f" ... and {len(evict) - ev_limit} more (use --full to list all)")
|
||||
return 0
|
||||
|
||||
|
||||
def eades_order(nodes, out_edges):
|
||||
"""Eades-Lin-Smyth greedy heuristic returning a vertex order that minimizes
|
||||
backward edges (an approximate minimum feedback arc set)."""
|
||||
remaining = set(nodes)
|
||||
in_w = {n: 0 for n in nodes}
|
||||
out_w = {n: 0 for n in nodes}
|
||||
for u in nodes:
|
||||
for v, w in out_edges[u].items():
|
||||
out_w[u] += w
|
||||
in_w[v] += w
|
||||
left = []
|
||||
right = []
|
||||
# Work on mutable copies of degrees.
|
||||
while remaining:
|
||||
# Remove sinks (no outgoing within remaining) to the right.
|
||||
changed = True
|
||||
while changed:
|
||||
changed = False
|
||||
for n in list(remaining):
|
||||
if all(v not in remaining for v in out_edges[n]):
|
||||
right.insert(0, n)
|
||||
remaining.discard(n)
|
||||
changed = True
|
||||
for n in list(remaining):
|
||||
if all(u not in remaining for u in nodes if n in out_edges[u]):
|
||||
# source (no incoming within remaining) to the left
|
||||
left.append(n)
|
||||
remaining.discard(n)
|
||||
changed = True
|
||||
if not remaining:
|
||||
break
|
||||
# Pick the node maximizing (out_w - in_w) within remaining.
|
||||
def score(n):
|
||||
o = sum(w for v, w in out_edges[n].items() if v in remaining)
|
||||
i = sum(w for u in remaining for vv, w in out_edges[u].items() if vv == n)
|
||||
return o - i
|
||||
pick = max(remaining, key=score)
|
||||
left.append(pick)
|
||||
remaining.discard(pick)
|
||||
return left + right
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+918
@@ -0,0 +1,918 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import statistics
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
DEFAULT_LOG_GLOB = "*runtime-memory-*.jsonl"
|
||||
DEFAULT_TOP_N = 8
|
||||
|
||||
|
||||
@dataclass
|
||||
class Sample:
|
||||
path: Path
|
||||
line_no: int
|
||||
raw: dict[str, Any]
|
||||
timestamp_ms: int
|
||||
kind: str
|
||||
target: str
|
||||
source: str
|
||||
trigger_category: str
|
||||
trigger_reason: str
|
||||
sessions: dict[str, Any] | None
|
||||
totals: dict[str, Any] | None
|
||||
|
||||
@property
|
||||
def pss_bytes(self) -> int | None:
|
||||
os_info = self.raw.get("process", {}).get("os") or {}
|
||||
value = os_info.get("pss_bytes")
|
||||
return int(value) if isinstance(value, int | float) else None
|
||||
|
||||
@property
|
||||
def rss_bytes(self) -> int | None:
|
||||
value = self.raw.get("process", {}).get("rss_bytes")
|
||||
return int(value) if isinstance(value, int | float) else None
|
||||
|
||||
@property
|
||||
def allocator_allocated_bytes(self) -> int | None:
|
||||
value = (((self.raw.get("process") or {}).get("allocator") or {}).get("stats") or {}).get(
|
||||
"allocated_bytes"
|
||||
)
|
||||
return int(value) if isinstance(value, int | float) else None
|
||||
|
||||
@property
|
||||
def allocator_resident_bytes(self) -> int | None:
|
||||
value = (((self.raw.get("process") or {}).get("allocator") or {}).get("stats") or {}).get(
|
||||
"resident_bytes"
|
||||
)
|
||||
return int(value) if isinstance(value, int | float) else None
|
||||
|
||||
@property
|
||||
def allocator_retained_bytes(self) -> int | None:
|
||||
value = (((self.raw.get("process") or {}).get("allocator") or {}).get("stats") or {}).get(
|
||||
"retained_bytes"
|
||||
)
|
||||
return int(value) if isinstance(value, int | float) else None
|
||||
|
||||
@property
|
||||
def os_info(self) -> dict[str, Any]:
|
||||
value = (self.raw.get("process") or {}).get("os")
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
@property
|
||||
def process_info(self) -> dict[str, Any]:
|
||||
value = self.raw.get("process")
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
@property
|
||||
def process_diagnostics(self) -> dict[str, Any]:
|
||||
value = self.raw.get("process_diagnostics")
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def first_int(mapping: dict[str, Any], *keys: str) -> int | None:
|
||||
"""Return the first present integer value among candidate key names."""
|
||||
for key in keys:
|
||||
value = mapping.get(key)
|
||||
if isinstance(value, int | float):
|
||||
return int(value)
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Spike:
|
||||
start: Sample
|
||||
end: Sample
|
||||
delta_pss_bytes: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class AttributionDelta:
|
||||
start: Sample
|
||||
end: Sample
|
||||
delta_total_json_bytes: int
|
||||
delta_payload_text_bytes: int
|
||||
delta_provider_cache_json_bytes: int
|
||||
delta_tool_result_bytes: int
|
||||
delta_large_blob_bytes: int
|
||||
delta_live_count: int
|
||||
delta_memory_enabled_session_count: int
|
||||
|
||||
@property
|
||||
def magnitude_bytes(self) -> int:
|
||||
return max(
|
||||
abs(self.delta_total_json_bytes),
|
||||
abs(self.delta_provider_cache_json_bytes),
|
||||
abs(self.delta_tool_result_bytes),
|
||||
abs(self.delta_large_blob_bytes),
|
||||
abs(self.delta_payload_text_bytes),
|
||||
)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Analyze jcode runtime memory JSONL logs for growth, spikes, attribution, and optimization hints"
|
||||
)
|
||||
parser.add_argument("paths", nargs="*", help="Specific JSONL files or directories to analyze")
|
||||
parser.add_argument(
|
||||
"--log-dir",
|
||||
help="Directory containing runtime memory JSONL logs (default: ~/.jcode/logs/memory or $JCODE_HOME/logs/memory)",
|
||||
)
|
||||
parser.add_argument("--days", type=int, default=None, help="Only include files from the last N daily logs")
|
||||
parser.add_argument("--top", type=int, default=DEFAULT_TOP_N, help="How many spikes/sessions/deltas to show")
|
||||
parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON summary")
|
||||
parser.add_argument(
|
||||
"--min-spike-mb",
|
||||
type=float,
|
||||
default=8.0,
|
||||
help="Minimum absolute PSS delta in MB to include in spike lists",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def default_log_dir() -> Path:
|
||||
jcode_home = os.environ.get("JCODE_HOME")
|
||||
if jcode_home:
|
||||
return Path(jcode_home).expanduser() / "logs" / "memory"
|
||||
return Path.home() / ".jcode" / "logs" / "memory"
|
||||
|
||||
|
||||
def resolve_paths(args: argparse.Namespace) -> list[Path]:
|
||||
raw_paths = [Path(value).expanduser() for value in args.paths]
|
||||
if args.log_dir:
|
||||
raw_paths.append(Path(args.log_dir).expanduser())
|
||||
if not raw_paths:
|
||||
raw_paths.append(default_log_dir())
|
||||
|
||||
files: list[Path] = []
|
||||
for raw in raw_paths:
|
||||
if raw.is_file():
|
||||
files.append(raw)
|
||||
continue
|
||||
if raw.is_dir():
|
||||
files.extend(sorted(raw.glob(DEFAULT_LOG_GLOB)))
|
||||
files = sorted(dict.fromkeys(path.resolve() for path in files))
|
||||
if args.days is not None and args.days > 0:
|
||||
selected_dates = []
|
||||
for path in reversed(files):
|
||||
date = extract_log_date(path)
|
||||
if date is None or date in selected_dates:
|
||||
continue
|
||||
selected_dates.append(date)
|
||||
if len(selected_dates) >= args.days:
|
||||
break
|
||||
files = [path for path in files if extract_log_date(path) in selected_dates]
|
||||
return files
|
||||
|
||||
|
||||
def extract_log_date(path: Path) -> str | None:
|
||||
name = path.name
|
||||
if not name.endswith('.jsonl'):
|
||||
return None
|
||||
stem = name[:-len('.jsonl')]
|
||||
if '-' not in stem:
|
||||
return None
|
||||
return stem.rsplit('-', 3)[-3] + '-' + stem.rsplit('-', 3)[-2] + '-' + stem.rsplit('-', 3)[-1]
|
||||
|
||||
|
||||
def load_samples(paths: Iterable[Path]) -> list[Sample]:
|
||||
samples: list[Sample] = []
|
||||
for path in paths:
|
||||
try:
|
||||
lines = path.read_text().splitlines()
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
for idx, line in enumerate(lines, start=1):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
raw = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
trigger = raw.get("trigger") or {}
|
||||
source = str(raw.get("source") or "")
|
||||
kind = infer_kind(raw, source)
|
||||
target = infer_target(raw, path)
|
||||
trigger_category, trigger_reason = infer_trigger(raw, kind, source, trigger)
|
||||
samples.append(
|
||||
Sample(
|
||||
path=path,
|
||||
line_no=idx,
|
||||
raw=raw,
|
||||
timestamp_ms=int(raw.get("timestamp_ms") or 0),
|
||||
kind=kind,
|
||||
target=target,
|
||||
source=source,
|
||||
trigger_category=trigger_category,
|
||||
trigger_reason=trigger_reason,
|
||||
sessions=raw.get("sessions") if isinstance(raw.get("sessions"), dict) else None,
|
||||
totals=raw.get("totals") if isinstance(raw.get("totals"), dict) else None,
|
||||
)
|
||||
)
|
||||
samples.sort(key=lambda sample: (sample.timestamp_ms, str(sample.path), sample.line_no))
|
||||
return samples
|
||||
|
||||
|
||||
def infer_kind(raw: dict[str, Any], source: str) -> str:
|
||||
kind = raw.get("kind")
|
||||
if isinstance(kind, str) and kind:
|
||||
return kind
|
||||
if isinstance(raw.get("sessions"), dict):
|
||||
return "attribution"
|
||||
if source.startswith("process:"):
|
||||
return "process"
|
||||
if source.startswith("attribution:"):
|
||||
return "attribution"
|
||||
return "legacy"
|
||||
|
||||
|
||||
def infer_target(raw: dict[str, Any], path: Path) -> str:
|
||||
if isinstance(raw.get("client"), dict):
|
||||
return "client"
|
||||
if isinstance(raw.get("server"), dict):
|
||||
return "server"
|
||||
name = path.name
|
||||
if name.startswith("client-runtime-memory-"):
|
||||
return "client"
|
||||
return "server"
|
||||
|
||||
|
||||
def infer_trigger(
|
||||
raw: dict[str, Any], kind: str, source: str, trigger: dict[str, Any]
|
||||
) -> tuple[str, str]:
|
||||
category = str(trigger.get("category") or "")
|
||||
reason = str(trigger.get("reason") or "")
|
||||
if category and reason:
|
||||
return category, reason
|
||||
if source == "startup" or source.endswith(":startup"):
|
||||
return category or "startup", reason or "server_start"
|
||||
if source == "interval" or source.startswith("process:heartbeat"):
|
||||
return category or "process_heartbeat", reason or "periodic"
|
||||
if source.startswith("attribution:heartbeat"):
|
||||
return category or "attribution_heartbeat", reason or "periodic"
|
||||
if source.startswith("attribution:event:") or source.startswith("process:event:"):
|
||||
suffix = source.split(":event:", 1)[1]
|
||||
return category or suffix, reason or "event"
|
||||
if source.startswith("server:runtime-log:"):
|
||||
suffix = source.rsplit(":", 1)[-1]
|
||||
return category or suffix, reason or ("periodic" if suffix == "interval" else kind)
|
||||
return category or kind, reason or "legacy"
|
||||
|
||||
|
||||
def bytes_to_mb(value: int | None) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
return round(value / (1024.0 * 1024.0), 1)
|
||||
|
||||
|
||||
def fmt_mb(value: int | None) -> str:
|
||||
if value is None:
|
||||
return "n/a"
|
||||
return f"{value / (1024.0 * 1024.0):.1f} MB"
|
||||
|
||||
|
||||
def fmt_signed_mb(value: int | None) -> str:
|
||||
if value is None:
|
||||
return "n/a"
|
||||
sign = "+" if value >= 0 else "-"
|
||||
return f"{sign}{abs(value) / (1024.0 * 1024.0):.1f} MB"
|
||||
|
||||
|
||||
def fmt_duration_ms(ms: int) -> str:
|
||||
seconds = ms / 1000.0
|
||||
if seconds < 60:
|
||||
return f"{seconds:.1f}s"
|
||||
minutes = seconds / 60.0
|
||||
if minutes < 60:
|
||||
return f"{minutes:.1f}m"
|
||||
hours = minutes / 60.0
|
||||
return f"{hours:.1f}h"
|
||||
|
||||
|
||||
def fmt_ts(timestamp_ms: int) -> str:
|
||||
dt = datetime.fromtimestamp(timestamp_ms / 1000.0, tz=timezone.utc)
|
||||
return dt.isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def attributed_total_bytes(sample: Sample) -> int | None:
|
||||
if sample.sessions:
|
||||
value = sample.sessions.get("total_json_bytes")
|
||||
return int(value) if isinstance(value, int | float) else None
|
||||
if sample.totals:
|
||||
value = sample.totals.get("total_attributed_bytes")
|
||||
return int(value) if isinstance(value, int | float) else None
|
||||
return None
|
||||
|
||||
|
||||
def compute_spikes(samples: list[Sample], min_spike_bytes: int) -> list[Spike]:
|
||||
process_samples = [sample for sample in samples if sample.pss_bytes is not None]
|
||||
spikes: list[Spike] = []
|
||||
for prev, curr in zip(process_samples, process_samples[1:]):
|
||||
if prev.pss_bytes is None or curr.pss_bytes is None:
|
||||
continue
|
||||
delta = curr.pss_bytes - prev.pss_bytes
|
||||
if abs(delta) >= min_spike_bytes:
|
||||
spikes.append(Spike(start=prev, end=curr, delta_pss_bytes=delta))
|
||||
spikes.sort(key=lambda spike: abs(spike.delta_pss_bytes), reverse=True)
|
||||
return spikes
|
||||
|
||||
|
||||
def compute_attribution_deltas(samples: list[Sample]) -> list[AttributionDelta]:
|
||||
attribution = [sample for sample in samples if sample.sessions]
|
||||
deltas: list[AttributionDelta] = []
|
||||
for prev, curr in zip(attribution, attribution[1:]):
|
||||
prev_sessions = prev.sessions or {}
|
||||
curr_sessions = curr.sessions or {}
|
||||
deltas.append(
|
||||
AttributionDelta(
|
||||
start=prev,
|
||||
end=curr,
|
||||
delta_total_json_bytes=int(curr_sessions.get("total_json_bytes", 0))
|
||||
- int(prev_sessions.get("total_json_bytes", 0)),
|
||||
delta_payload_text_bytes=int(curr_sessions.get("total_payload_text_bytes", 0))
|
||||
- int(prev_sessions.get("total_payload_text_bytes", 0)),
|
||||
delta_provider_cache_json_bytes=int(curr_sessions.get("total_provider_cache_json_bytes", 0))
|
||||
- int(prev_sessions.get("total_provider_cache_json_bytes", 0)),
|
||||
delta_tool_result_bytes=int(curr_sessions.get("total_tool_result_bytes", 0))
|
||||
- int(prev_sessions.get("total_tool_result_bytes", 0)),
|
||||
delta_large_blob_bytes=int(curr_sessions.get("total_large_blob_bytes", 0))
|
||||
- int(prev_sessions.get("total_large_blob_bytes", 0)),
|
||||
delta_live_count=int(curr_sessions.get("live_count", 0))
|
||||
- int(prev_sessions.get("live_count", 0)),
|
||||
delta_memory_enabled_session_count=int(curr_sessions.get("memory_enabled_session_count", 0))
|
||||
- int(prev_sessions.get("memory_enabled_session_count", 0)),
|
||||
)
|
||||
)
|
||||
deltas.sort(key=lambda delta: delta.magnitude_bytes, reverse=True)
|
||||
return deltas
|
||||
|
||||
|
||||
def collect_session_peaks(samples: list[Sample]) -> list[dict[str, Any]]:
|
||||
session_stats: dict[str, dict[str, Any]] = {}
|
||||
for sample in samples:
|
||||
sessions = sample.sessions or {}
|
||||
top = sessions.get("top_by_json_bytes") or []
|
||||
if not isinstance(top, list):
|
||||
continue
|
||||
for entry in top:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
session_id = str(entry.get("session_id") or "")
|
||||
if not session_id:
|
||||
continue
|
||||
json_bytes = int(entry.get("json_bytes") or 0)
|
||||
current = session_stats.get(session_id)
|
||||
if current is None or json_bytes > current["peak_json_bytes"]:
|
||||
session_stats[session_id] = {
|
||||
"session_id": session_id,
|
||||
"provider": entry.get("provider"),
|
||||
"model": entry.get("model"),
|
||||
"memory_enabled": bool(entry.get("memory_enabled")),
|
||||
"peak_json_bytes": json_bytes,
|
||||
"peak_payload_text_bytes": int(entry.get("payload_text_bytes") or 0),
|
||||
"peak_provider_cache_json_bytes": int(entry.get("provider_cache_json_bytes") or 0),
|
||||
"peak_tool_result_bytes": int(entry.get("tool_result_bytes") or 0),
|
||||
"peak_large_blob_bytes": int(entry.get("large_blob_bytes") or 0),
|
||||
"message_count": int(entry.get("message_count") or 0),
|
||||
"last_seen_timestamp_ms": sample.timestamp_ms,
|
||||
}
|
||||
return sorted(session_stats.values(), key=lambda item: item["peak_json_bytes"], reverse=True)
|
||||
|
||||
|
||||
def last_attribution_sample(samples: list[Sample]) -> Sample | None:
|
||||
for sample in reversed(samples):
|
||||
if sample.sessions or sample.totals:
|
||||
return sample
|
||||
return None
|
||||
|
||||
|
||||
def build_coverage_report(sample: Sample) -> dict[str, Any]:
|
||||
"""Decompose PSS into attributed live, unattributed live heap, allocator
|
||||
retention, file-backed, and stack buckets.
|
||||
|
||||
Uses allocator stats (mallinfo2/jemalloc) plus the newer smaps_rollup and
|
||||
process_diagnostics fields when present; older logs degrade gracefully to
|
||||
whatever fields exist. The key outputs are two coverage ratios:
|
||||
- coverage_ratio_pss: attributed / PSS (the historical, misleading one; the
|
||||
denominator includes allocator retention and file maps).
|
||||
- coverage_ratio_live_heap: attributed / allocator live bytes (estimator
|
||||
quality against the memory the app actually holds).
|
||||
|
||||
Explained PSS follows the in-binary summary definition:
|
||||
total_attributed + allocator_retained_resident_estimate + pss_file +
|
||||
thread_stack_estimate.
|
||||
"""
|
||||
pss = sample.pss_bytes
|
||||
attributed = attributed_total_bytes(sample)
|
||||
allocator_live = sample.allocator_allocated_bytes
|
||||
allocator_retained = sample.allocator_retained_bytes
|
||||
|
||||
process_info = sample.process_info
|
||||
os_info = sample.os_info
|
||||
pss_file = first_int(os_info, "pss_file_bytes")
|
||||
pss_anon = first_int(os_info, "pss_anon_bytes")
|
||||
pss_shmem = first_int(os_info, "pss_shmem_bytes")
|
||||
anon_huge_pages = first_int(os_info, "anon_huge_pages_bytes")
|
||||
rss_file = first_int(os_info, "rss_file_bytes")
|
||||
stack_bytes = first_int(process_info, "main_stack_bytes") or first_int(
|
||||
os_info, "main_stack_bytes", "stack_bytes", "vm_stk_bytes"
|
||||
)
|
||||
thread_count = first_int(process_info, "thread_count") or first_int(
|
||||
os_info, "thread_count", "threads"
|
||||
)
|
||||
|
||||
diagnostics = sample.process_diagnostics
|
||||
retained_resident = first_int(
|
||||
diagnostics,
|
||||
"allocator_retained_resident_estimate_bytes",
|
||||
"allocator_retained_bytes",
|
||||
)
|
||||
if retained_resident is None:
|
||||
retained_resident = allocator_retained
|
||||
thread_stack_estimate = first_int(diagnostics, "thread_stack_estimate_bytes")
|
||||
if thread_stack_estimate is None:
|
||||
thread_stack_estimate = stack_bytes
|
||||
|
||||
report: dict[str, Any] = {
|
||||
"timestamp_ms": sample.timestamp_ms,
|
||||
"pss_bytes": pss,
|
||||
"pss_anon_bytes": pss_anon,
|
||||
"pss_file_bytes": pss_file,
|
||||
"pss_shmem_bytes": pss_shmem,
|
||||
"anon_huge_pages_bytes": anon_huge_pages,
|
||||
"rss_file_bytes": rss_file,
|
||||
"main_stack_bytes": stack_bytes,
|
||||
"thread_stack_estimate_bytes": thread_stack_estimate,
|
||||
"thread_count": thread_count,
|
||||
"attributed_live_bytes": attributed,
|
||||
"allocator_live_bytes": allocator_live,
|
||||
"allocator_retained_bytes": allocator_retained,
|
||||
"allocator_retained_resident_estimate_bytes": retained_resident,
|
||||
}
|
||||
|
||||
if attributed is not None and allocator_live is not None:
|
||||
report["unattributed_live_heap_bytes"] = max(0, allocator_live - attributed)
|
||||
if pss is not None and attributed is not None:
|
||||
report["coverage_ratio_pss"] = round(attributed / pss, 4) if pss else 0.0
|
||||
if allocator_live is not None and attributed is not None:
|
||||
report["coverage_ratio_live_heap"] = (
|
||||
round(attributed / allocator_live, 4) if allocator_live else 0.0
|
||||
)
|
||||
|
||||
# Explained PSS matches the in-binary summary: attributed live + allocator
|
||||
# retention (resident estimate) + file-backed PSS + thread stacks. The
|
||||
# remainder is what the buckets still miss (unattributed live heap, shared
|
||||
# anon, allocator metadata).
|
||||
if pss is not None:
|
||||
explained = 0
|
||||
for key in (
|
||||
"attributed_live_bytes",
|
||||
"allocator_retained_resident_estimate_bytes",
|
||||
"pss_file_bytes",
|
||||
"thread_stack_estimate_bytes",
|
||||
):
|
||||
value = report.get(key)
|
||||
if isinstance(value, int):
|
||||
explained += value
|
||||
report["explained_pss_bytes"] = explained
|
||||
report["unexplained_pss_bytes"] = max(0, pss - explained)
|
||||
report["explained_ratio"] = round(explained / pss, 4) if pss else 0.0
|
||||
return report
|
||||
|
||||
|
||||
def count_event_categories(samples: list[Sample]) -> Counter[str]:
|
||||
counter: Counter[str] = Counter()
|
||||
for sample in samples:
|
||||
category = sample.trigger_category or sample.kind
|
||||
counter[category] += 1
|
||||
return counter
|
||||
|
||||
|
||||
def process_summary(samples: list[Sample]) -> dict[str, Any]:
|
||||
process_samples = [sample for sample in samples if sample.pss_bytes is not None]
|
||||
if not process_samples:
|
||||
return {}
|
||||
first = process_samples[0]
|
||||
last = process_samples[-1]
|
||||
peak = max(process_samples, key=lambda sample: sample.pss_bytes or -1)
|
||||
pss_values = [sample.pss_bytes for sample in process_samples if sample.pss_bytes is not None]
|
||||
median_pss = int(statistics.median(pss_values)) if pss_values else None
|
||||
return {
|
||||
"sample_count": len(process_samples),
|
||||
"first_timestamp_ms": first.timestamp_ms,
|
||||
"last_timestamp_ms": last.timestamp_ms,
|
||||
"duration_ms": max(0, last.timestamp_ms - first.timestamp_ms),
|
||||
"baseline_pss_bytes": first.pss_bytes,
|
||||
"final_pss_bytes": last.pss_bytes,
|
||||
"net_pss_growth_bytes": (last.pss_bytes or 0) - (first.pss_bytes or 0),
|
||||
"peak_pss_bytes": peak.pss_bytes,
|
||||
"peak_growth_vs_baseline_bytes": (peak.pss_bytes or 0) - (first.pss_bytes or 0),
|
||||
"median_pss_bytes": median_pss,
|
||||
"peak_timestamp_ms": peak.timestamp_ms,
|
||||
"peak_trigger_category": peak.trigger_category,
|
||||
"peak_trigger_reason": peak.trigger_reason,
|
||||
"allocator_allocated_bytes": last.allocator_allocated_bytes,
|
||||
"allocator_resident_bytes": last.allocator_resident_bytes,
|
||||
"allocator_retained_bytes": last.allocator_retained_bytes,
|
||||
}
|
||||
|
||||
|
||||
def build_server_hints(samples: list[Sample], session_peaks: list[dict[str, Any]]) -> list[str]:
|
||||
hints: list[str] = []
|
||||
last_attr = last_attribution_sample(samples)
|
||||
if not last_attr or not last_attr.sessions:
|
||||
return ["Need at least one attribution sample before generating optimization hints."]
|
||||
|
||||
sessions = last_attr.sessions
|
||||
total_json = int(sessions.get("total_json_bytes") or 0)
|
||||
provider_cache_json = int(sessions.get("total_provider_cache_json_bytes") or 0)
|
||||
tool_result_bytes = int(sessions.get("total_tool_result_bytes") or 0)
|
||||
large_blob_bytes = int(sessions.get("total_large_blob_bytes") or 0)
|
||||
payload_text_bytes = int(sessions.get("total_payload_text_bytes") or 0)
|
||||
|
||||
if total_json > 0 and provider_cache_json / total_json >= 0.35:
|
||||
hints.append(
|
||||
f"Provider cache is a large share of attributed state ({provider_cache_json / total_json:.0%} of total JSON). Prioritize cache compaction, cache invalidation discipline, and avoiding redundant provider-message mirrors."
|
||||
)
|
||||
if total_json > 0 and tool_result_bytes / total_json >= 0.25:
|
||||
hints.append(
|
||||
f"Tool results are heavy ({tool_result_bytes / total_json:.0%} of total JSON). Consider truncating stored tool output, summarizing verbose results, or storing large artifacts out-of-line."
|
||||
)
|
||||
if total_json > 0 and large_blob_bytes / total_json >= 0.15:
|
||||
hints.append(
|
||||
f"Large blobs are materially retained ({large_blob_bytes / total_json:.0%} of total JSON). Focus on blob thresholds, attachment retention, and aggressive post-use slimming."
|
||||
)
|
||||
if payload_text_bytes > 0 and total_json > 0 and payload_text_bytes / total_json >= 0.45:
|
||||
hints.append(
|
||||
f"Transcript payload text dominates attributed state ({payload_text_bytes / total_json:.0%} of total JSON). Compaction and transcript summarization will likely pay off."
|
||||
)
|
||||
|
||||
last_process = samples[-1] if samples else None
|
||||
process_diag = (last_process.raw.get("process_diagnostics") or {}) if last_process else {}
|
||||
resident_minus_active = process_diag.get("allocator_resident_minus_active_bytes")
|
||||
pss_minus_allocated = process_diag.get("pss_minus_allocator_allocated_bytes")
|
||||
if isinstance(resident_minus_active, int) and resident_minus_active >= 64 * 1024 * 1024:
|
||||
hints.append(
|
||||
f"Allocator resident slack is high ({fmt_mb(resident_minus_active)} above active). Some memory pressure may be allocator retention rather than live app state."
|
||||
)
|
||||
if isinstance(pss_minus_allocated, int) and pss_minus_allocated >= 64 * 1024 * 1024:
|
||||
hints.append(
|
||||
f"PSS is materially above allocator allocated ({fmt_mb(pss_minus_allocated)} delta), suggesting shared mappings, allocator overhead, or retained pages are worth checking alongside app-owned structures."
|
||||
)
|
||||
|
||||
embedding_events = [s for s in samples if s.trigger_category in {"embedding_loaded", "embedding_unloaded"}]
|
||||
if embedding_events:
|
||||
hints.append(
|
||||
f"Embedding lifecycle events were observed ({len(embedding_events)} samples). Compare memory before/after those windows to decide whether local embeddings should unload more aggressively."
|
||||
)
|
||||
|
||||
if session_peaks:
|
||||
heaviest = session_peaks[0]
|
||||
hints.append(
|
||||
f"Heaviest observed session was {heaviest['session_id']} at {fmt_mb(heaviest['peak_json_bytes'])} attributed JSON. Start optimization work with that session’s transcript and tool-result profile."
|
||||
)
|
||||
|
||||
if not hints:
|
||||
hints.append("No single dominant culprit stood out yet. Collect more runtime history and compare multiple attribution samples after heavier real usage.")
|
||||
return hints
|
||||
|
||||
|
||||
def collect_client_peaks(samples: list[Sample]) -> list[dict[str, Any]]:
|
||||
client_stats: dict[str, dict[str, Any]] = {}
|
||||
for sample in samples:
|
||||
if not sample.totals:
|
||||
continue
|
||||
client = sample.raw.get("client") or {}
|
||||
session_id = str(client.get("session_id") or "")
|
||||
if not session_id:
|
||||
continue
|
||||
total = int(sample.totals.get("total_attributed_bytes") or 0)
|
||||
current = client_stats.get(session_id)
|
||||
if current is None or total > current["peak_total_attributed_bytes"]:
|
||||
client_stats[session_id] = {
|
||||
"session_id": session_id,
|
||||
"client_instance_id": client.get("client_instance_id"),
|
||||
"provider": client.get("provider"),
|
||||
"model": client.get("model"),
|
||||
"is_remote": bool(client.get("is_remote")),
|
||||
"peak_total_attributed_bytes": total,
|
||||
"peak_display_messages_estimate_bytes": int(sample.totals.get("display_messages_estimate_bytes") or 0),
|
||||
"peak_provider_messages_json_bytes": int(sample.totals.get("provider_messages_json_bytes") or 0),
|
||||
"peak_side_panel_estimate_bytes": int(sample.totals.get("side_panel_estimate_bytes") or 0),
|
||||
"peak_remote_state_bytes": int(sample.totals.get("remote_state_bytes") or 0),
|
||||
"last_seen_timestamp_ms": sample.timestamp_ms,
|
||||
}
|
||||
return sorted(client_stats.values(), key=lambda item: item["peak_total_attributed_bytes"], reverse=True)
|
||||
|
||||
|
||||
def build_client_hints(samples: list[Sample], client_peaks: list[dict[str, Any]]) -> list[str]:
|
||||
hints: list[str] = []
|
||||
last_attr = last_attribution_sample(samples)
|
||||
if not last_attr or not last_attr.totals:
|
||||
return ["Need at least one client attribution sample before generating optimization hints."]
|
||||
|
||||
totals = last_attr.totals
|
||||
total = int(totals.get("total_attributed_bytes") or 0)
|
||||
display = int(totals.get("display_messages_estimate_bytes") or 0)
|
||||
provider_messages = int(totals.get("provider_messages_json_bytes") or 0)
|
||||
side_panel = int(totals.get("side_panel_estimate_bytes") or 0)
|
||||
remote_state = int(totals.get("remote_state_bytes") or 0)
|
||||
|
||||
if total > 0 and display / total >= 0.45:
|
||||
hints.append(
|
||||
f"Display-message state is a large share of attributed client memory ({display / total:.0%}). Tighten UI duplication and display-history retention first."
|
||||
)
|
||||
if total > 0 and provider_messages / total >= 0.20:
|
||||
hints.append(
|
||||
f"Resident provider-message copies are a meaningful share of client memory ({provider_messages / total:.0%}). Prefer borrowing or lazy hydration where possible."
|
||||
)
|
||||
if total > 0 and side_panel / total >= 0.15:
|
||||
hints.append(
|
||||
f"Side-panel state is materially retained ({side_panel / total:.0%} of attributed client memory). Focus on page content retention and render-cache discipline."
|
||||
)
|
||||
if total > 0 and remote_state / total >= 0.10:
|
||||
hints.append(
|
||||
f"Remote session metadata is non-trivial ({remote_state / total:.0%} of attributed client memory). Review retained model/session lists and remote bootstrap state."
|
||||
)
|
||||
|
||||
coverage = build_coverage_report(last_attr)
|
||||
pss = coverage.get("pss_bytes")
|
||||
retained = coverage.get("allocator_retained_resident_estimate_bytes")
|
||||
if isinstance(pss, int) and isinstance(retained, int) and pss > 0 and retained / pss >= 0.30:
|
||||
hints.append(
|
||||
f"Allocator retention dominates PSS ({retained / pss:.0%}, {fmt_mb(retained)}). This is freed-but-held heap, not live app state; malloc_trim/purge cadence matters more than estimator coverage here."
|
||||
)
|
||||
unattributed_live = coverage.get("unattributed_live_heap_bytes")
|
||||
live = coverage.get("allocator_live_bytes")
|
||||
if (
|
||||
isinstance(unattributed_live, int)
|
||||
and isinstance(live, int)
|
||||
and live > 0
|
||||
and unattributed_live / live >= 0.40
|
||||
):
|
||||
hints.append(
|
||||
f"Estimators miss {unattributed_live / live:.0%} of live heap ({fmt_mb(unattributed_live)}). The real estimator gap is tokio/render/runtime structures, not allocator slack."
|
||||
)
|
||||
|
||||
if client_peaks:
|
||||
heaviest = client_peaks[0]
|
||||
hints.append(
|
||||
f"Heaviest observed client session was {heaviest['session_id']} at {fmt_mb(heaviest['peak_total_attributed_bytes'])} attributed client memory. Start with that session’s display and provider-message layers."
|
||||
)
|
||||
|
||||
if not hints:
|
||||
hints.append("No single dominant client-side culprit stood out yet. Collect more client runtime history during heavier UI usage.")
|
||||
return hints
|
||||
|
||||
|
||||
def summarize_target(samples: list[Sample], top_n: int, min_spike_bytes: int) -> dict[str, Any]:
|
||||
spikes = compute_spikes(samples, min_spike_bytes=min_spike_bytes)
|
||||
target = samples[0].target if samples else "unknown"
|
||||
deltas = compute_attribution_deltas(samples) if target == "server" else []
|
||||
session_peaks = collect_session_peaks(samples) if target == "server" else []
|
||||
client_peaks = collect_client_peaks(samples) if target == "client" else []
|
||||
event_counts = count_event_categories(samples)
|
||||
proc = process_summary(samples)
|
||||
last_attr = last_attribution_sample(samples)
|
||||
coverage = build_coverage_report(last_attr) if last_attr else None
|
||||
summary = {
|
||||
"target": target,
|
||||
"sample_count": len(samples),
|
||||
"first_timestamp_ms": samples[0].timestamp_ms if samples else None,
|
||||
"last_timestamp_ms": samples[-1].timestamp_ms if samples else None,
|
||||
"kinds": Counter(sample.kind for sample in samples),
|
||||
"process": proc,
|
||||
"coverage": coverage,
|
||||
"last_attribution": {
|
||||
"timestamp_ms": last_attr.timestamp_ms,
|
||||
"sessions": last_attr.sessions,
|
||||
"totals": last_attr.totals,
|
||||
"client": last_attr.raw.get("client") if target == "client" else None,
|
||||
"trigger_category": last_attr.trigger_category,
|
||||
"trigger_reason": last_attr.trigger_reason,
|
||||
}
|
||||
if last_attr
|
||||
else None,
|
||||
"top_spikes": [
|
||||
{
|
||||
"from": spike.start.timestamp_ms,
|
||||
"to": spike.end.timestamp_ms,
|
||||
"delta_pss_bytes": spike.delta_pss_bytes,
|
||||
"from_source": spike.start.source,
|
||||
"to_source": spike.end.source,
|
||||
"to_trigger_category": spike.end.trigger_category,
|
||||
"to_trigger_reason": spike.end.trigger_reason,
|
||||
}
|
||||
for spike in spikes[:top_n]
|
||||
],
|
||||
"top_attribution_deltas": [
|
||||
{
|
||||
"from": delta.start.timestamp_ms,
|
||||
"to": delta.end.timestamp_ms,
|
||||
"to_trigger_category": delta.end.trigger_category,
|
||||
"to_trigger_reason": delta.end.trigger_reason,
|
||||
"delta_total_json_bytes": delta.delta_total_json_bytes,
|
||||
"delta_payload_text_bytes": delta.delta_payload_text_bytes,
|
||||
"delta_provider_cache_json_bytes": delta.delta_provider_cache_json_bytes,
|
||||
"delta_tool_result_bytes": delta.delta_tool_result_bytes,
|
||||
"delta_large_blob_bytes": delta.delta_large_blob_bytes,
|
||||
"delta_live_count": delta.delta_live_count,
|
||||
"delta_memory_enabled_session_count": delta.delta_memory_enabled_session_count,
|
||||
}
|
||||
for delta in deltas[:top_n]
|
||||
],
|
||||
"top_sessions": session_peaks[:top_n],
|
||||
"top_clients": client_peaks[:top_n],
|
||||
"event_counts": dict(event_counts.most_common()),
|
||||
"hints": build_server_hints(samples, session_peaks[:top_n])
|
||||
if target == "server"
|
||||
else build_client_hints(samples, client_peaks[:top_n]),
|
||||
}
|
||||
return summary
|
||||
|
||||
|
||||
def summarize(samples: list[Sample], top_n: int, min_spike_bytes: int) -> dict[str, Any]:
|
||||
targets = sorted({sample.target for sample in samples})
|
||||
if len(targets) <= 1:
|
||||
return summarize_target(samples, top_n=top_n, min_spike_bytes=min_spike_bytes)
|
||||
return {
|
||||
"targets": {
|
||||
target: summarize_target(
|
||||
[sample for sample in samples if sample.target == target],
|
||||
top_n=top_n,
|
||||
min_spike_bytes=min_spike_bytes,
|
||||
)
|
||||
for target in targets
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def print_human(summary: dict[str, Any], paths: list[Path]) -> None:
|
||||
if "targets" in summary:
|
||||
print("Runtime Memory Log Analysis")
|
||||
print("===========================")
|
||||
if paths:
|
||||
print(f"files: {len(paths)}")
|
||||
for path in paths:
|
||||
print(f" - {path}")
|
||||
for target, target_summary in summary["targets"].items():
|
||||
print(f"\n[{target}]")
|
||||
print_human(target_summary, [])
|
||||
return
|
||||
|
||||
print("Runtime Memory Log Analysis")
|
||||
print("===========================")
|
||||
if paths:
|
||||
print(f"files: {len(paths)}")
|
||||
for path in paths:
|
||||
print(f" - {path}")
|
||||
print(f"samples: {summary['sample_count']}")
|
||||
if summary.get("first_timestamp_ms") is not None:
|
||||
print(f"window: {fmt_ts(summary['first_timestamp_ms'])} -> {fmt_ts(summary['last_timestamp_ms'])}")
|
||||
print(
|
||||
f"duration: {fmt_duration_ms(summary['last_timestamp_ms'] - summary['first_timestamp_ms'])}"
|
||||
)
|
||||
|
||||
proc = summary.get("process") or {}
|
||||
if proc:
|
||||
print("\nProcess memory")
|
||||
print("--------------")
|
||||
print(f"baseline PSS: {fmt_mb(proc.get('baseline_pss_bytes'))}")
|
||||
print(f"final PSS: {fmt_mb(proc.get('final_pss_bytes'))} ({fmt_signed_mb(proc.get('net_pss_growth_bytes'))})")
|
||||
print(f"peak PSS: {fmt_mb(proc.get('peak_pss_bytes'))} ({fmt_signed_mb(proc.get('peak_growth_vs_baseline_bytes'))} vs baseline)")
|
||||
print(f"median PSS: {fmt_mb(proc.get('median_pss_bytes'))}")
|
||||
peak_ts = proc.get("peak_timestamp_ms")
|
||||
if peak_ts is not None:
|
||||
print(
|
||||
f"peak trigger: {fmt_ts(peak_ts)} | {proc.get('peak_trigger_category') or 'unknown'} / {proc.get('peak_trigger_reason') or 'unknown'}"
|
||||
)
|
||||
print(
|
||||
f"allocator: allocated {fmt_mb(proc.get('allocator_allocated_bytes'))} | resident {fmt_mb(proc.get('allocator_resident_bytes'))} | retained {fmt_mb(proc.get('allocator_retained_bytes'))}"
|
||||
)
|
||||
|
||||
coverage = summary.get("coverage") or {}
|
||||
if coverage:
|
||||
print("\nAttribution coverage (last attribution sample)")
|
||||
print("----------------------------------------------")
|
||||
print(f"PSS: {fmt_mb(coverage.get('pss_bytes'))}")
|
||||
if coverage.get("pss_anon_bytes") is not None or coverage.get("pss_file_bytes") is not None:
|
||||
print(
|
||||
f"PSS split: anon {fmt_mb(coverage.get('pss_anon_bytes'))} | file {fmt_mb(coverage.get('pss_file_bytes'))} | shmem {fmt_mb(coverage.get('pss_shmem_bytes'))}"
|
||||
)
|
||||
print(f"attributed live: {fmt_mb(coverage.get('attributed_live_bytes'))}")
|
||||
print(f"allocator live: {fmt_mb(coverage.get('allocator_live_bytes'))}")
|
||||
if coverage.get("unattributed_live_heap_bytes") is not None:
|
||||
print(f"unattributed live: {fmt_mb(coverage.get('unattributed_live_heap_bytes'))}")
|
||||
print(
|
||||
f"allocator retained: {fmt_mb(coverage.get('allocator_retained_resident_estimate_bytes'))}"
|
||||
)
|
||||
if coverage.get("main_stack_bytes") is not None or coverage.get("thread_count") is not None:
|
||||
threads = coverage.get("thread_count")
|
||||
print(
|
||||
f"stacks/threads: main stack {fmt_mb(coverage.get('main_stack_bytes'))} | stack estimate {fmt_mb(coverage.get('thread_stack_estimate_bytes'))} | threads {threads if threads is not None else 'n/a'}"
|
||||
)
|
||||
ratio_pss = coverage.get("coverage_ratio_pss")
|
||||
ratio_live = coverage.get("coverage_ratio_live_heap")
|
||||
if ratio_pss is not None or ratio_live is not None:
|
||||
pss_text = f"{ratio_pss:.1%}" if isinstance(ratio_pss, int | float) else "n/a"
|
||||
live_text = f"{ratio_live:.1%}" if isinstance(ratio_live, int | float) else "n/a"
|
||||
print(f"coverage: vs PSS {pss_text} | vs live heap {live_text}")
|
||||
if coverage.get("explained_pss_bytes") is not None:
|
||||
explained_ratio = coverage.get("explained_ratio")
|
||||
ratio_text = (
|
||||
f"{explained_ratio:.1%}" if isinstance(explained_ratio, int | float) else "n/a"
|
||||
)
|
||||
print(
|
||||
f"explained PSS: {fmt_mb(coverage.get('explained_pss_bytes'))} ({ratio_text}) | unexplained {fmt_mb(coverage.get('unexplained_pss_bytes'))}"
|
||||
)
|
||||
|
||||
print("\nEvent counts")
|
||||
print("------------")
|
||||
for category, count in list((summary.get("event_counts") or {}).items())[:12]:
|
||||
print(f"{category}: {count}")
|
||||
|
||||
print("\nTop PSS spikes")
|
||||
print("-------------")
|
||||
spikes = summary.get("top_spikes") or []
|
||||
if not spikes:
|
||||
print("No spikes above threshold.")
|
||||
for spike in spikes:
|
||||
print(
|
||||
f"{fmt_ts(spike['from'])} -> {fmt_ts(spike['to'])} | {fmt_signed_mb(spike['delta_pss_bytes'])} | {spike['to_trigger_category'] or 'unknown'} / {spike['to_trigger_reason'] or 'unknown'}"
|
||||
)
|
||||
|
||||
print("\nTop attribution deltas")
|
||||
print("----------------------")
|
||||
deltas = summary.get("top_attribution_deltas") or []
|
||||
if not deltas:
|
||||
print("Need at least two attribution samples.")
|
||||
for delta in deltas:
|
||||
print(
|
||||
f"{fmt_ts(delta['from'])} -> {fmt_ts(delta['to'])} | total {fmt_signed_mb(delta['delta_total_json_bytes'])} | cache {fmt_signed_mb(delta['delta_provider_cache_json_bytes'])} | tool {fmt_signed_mb(delta['delta_tool_result_bytes'])} | blob {fmt_signed_mb(delta['delta_large_blob_bytes'])} | text {fmt_signed_mb(delta['delta_payload_text_bytes'])} | {delta['to_trigger_category'] or 'unknown'}"
|
||||
)
|
||||
|
||||
target = summary.get("target") or "server"
|
||||
section_title = "Heaviest sessions" if target == "server" else "Heaviest clients"
|
||||
print(f"\n{section_title}")
|
||||
print("-" * len(section_title))
|
||||
sessions = summary.get("top_sessions") or []
|
||||
clients = summary.get("top_clients") or []
|
||||
if not sessions:
|
||||
if target == "server":
|
||||
print("No per-session attribution data yet.")
|
||||
for item in sessions:
|
||||
print(
|
||||
f"{item['session_id']} | peak json {fmt_mb(item['peak_json_bytes'])} | provider cache {fmt_mb(item['peak_provider_cache_json_bytes'])} | tool results {fmt_mb(item['peak_tool_result_bytes'])} | large blobs {fmt_mb(item['peak_large_blob_bytes'])} | provider={item.get('provider') or 'unknown'} model={item.get('model') or 'unknown'}"
|
||||
)
|
||||
if target == "client":
|
||||
if not clients:
|
||||
print("No per-client attribution data yet.")
|
||||
for item in clients:
|
||||
print(
|
||||
f"{item['session_id']} | peak attributed {fmt_mb(item['peak_total_attributed_bytes'])} | display {fmt_mb(item['peak_display_messages_estimate_bytes'])} | provider view {fmt_mb(item['peak_provider_messages_json_bytes'])} | side panel {fmt_mb(item['peak_side_panel_estimate_bytes'])} | provider={item.get('provider') or 'unknown'} model={item.get('model') or 'unknown'}"
|
||||
)
|
||||
|
||||
print("\nOptimization hints")
|
||||
print("------------------")
|
||||
for hint in summary.get("hints") or []:
|
||||
print(f"- {hint}")
|
||||
|
||||
|
||||
def to_jsonable(value: Any) -> Any:
|
||||
if isinstance(value, Counter):
|
||||
return dict(value)
|
||||
if isinstance(value, dict):
|
||||
return {key: to_jsonable(inner) for key, inner in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [to_jsonable(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
paths = resolve_paths(args)
|
||||
if not paths:
|
||||
raise SystemExit("No runtime memory log files found.")
|
||||
samples = load_samples(paths)
|
||||
if not samples:
|
||||
raise SystemExit("No runtime memory samples found in selected files.")
|
||||
summary = summarize(samples, top_n=args.top, min_spike_bytes=int(args.min_spike_mb * 1024 * 1024))
|
||||
if args.json:
|
||||
payload = to_jsonable(summary)
|
||||
payload["files"] = [str(path) for path in paths]
|
||||
print(json.dumps(payload, indent=2))
|
||||
else:
|
||||
print_human(summary, paths)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+69
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env bash
|
||||
# Live provider test coverage for Antigravity models.
|
||||
# For each model: (1) chat smoke -> expect token, (2) multi-turn tool smoke
|
||||
# (two bash calls, exercises thought_signature replay) -> expect both outputs.
|
||||
set -uo pipefail
|
||||
|
||||
JC=./target/selfdev/jcode
|
||||
CHAT_PROMPT='Reply with exactly: SMOKE_OK'
|
||||
TOOL_PROMPT="Run 'echo aa11' with bash, then in a SECOND separate bash call run 'echo bb22', then report both outputs."
|
||||
CHAT_TIMEOUT=90
|
||||
TOOL_TIMEOUT=160
|
||||
|
||||
# Models to test (chat-capable). Skip tab_*/chat_* (autocomplete/internal) and 'default' alias dup.
|
||||
MODELS=(
|
||||
claude-opus-4-6-thinking
|
||||
claude-sonnet-4-6
|
||||
gemini-3.1-pro-high
|
||||
gemini-3.1-pro-low
|
||||
gemini-3-flash
|
||||
gemini-3-flash-agent
|
||||
gemini-3.5-flash-low
|
||||
gpt-oss-120b-medium
|
||||
gemini-2.5-flash
|
||||
gemini-2.5-flash-lite
|
||||
gemini-2.5-flash-thinking
|
||||
gemini-2.5-pro
|
||||
gemini-3.1-flash-lite
|
||||
gemini-3.5-flash-extra-low
|
||||
gemini-pro-agent
|
||||
default
|
||||
)
|
||||
|
||||
printf "%-30s | %-8s | %-10s | %s\n" "MODEL" "CHAT" "TOOL" "NOTES"
|
||||
printf -- "------------------------------------------------------------------------------------\n"
|
||||
|
||||
total=${#MODELS[@]}
|
||||
i=0
|
||||
for m in "${MODELS[@]}"; do
|
||||
i=$((i+1))
|
||||
echo "JCODE_PROGRESS {\"current\":$i,\"total\":$total,\"unit\":\"models\",\"message\":\"$m\"}" >&2
|
||||
|
||||
# --- chat smoke ---
|
||||
chat_out=$(timeout "$CHAT_TIMEOUT" "$JC" run --provider antigravity -m "$m" --no-update --no-selfdev "$CHAT_PROMPT" 2>&1)
|
||||
chat_rc=$?
|
||||
if [[ $chat_rc -ne 0 ]]; then
|
||||
chat="FAIL"
|
||||
elif grep -q "SMOKE_OK" <<<"$chat_out"; then
|
||||
chat="PASS"
|
||||
else
|
||||
chat="NO_TOKEN"
|
||||
fi
|
||||
|
||||
# --- tool smoke (multi-turn) ---
|
||||
note=""
|
||||
tool_out=$(timeout "$TOOL_TIMEOUT" "$JC" run --provider antigravity -m "$m" --no-update --no-selfdev "$TOOL_PROMPT" 2>&1)
|
||||
tool_rc=$?
|
||||
if [[ $tool_rc -ne 0 ]]; then
|
||||
tool="FAIL"
|
||||
note=$(grep -oiE "missing a thought_signature|400|schema|draft 2020|HTTP [0-9]+|error[^\"]{0,40}" <<<"$tool_out" | head -1 | tr -d '\n')
|
||||
[[ $tool_rc -eq 124 ]] && note="timeout"
|
||||
elif grep -q "aa11" <<<"$tool_out" && grep -q "bb22" <<<"$tool_out"; then
|
||||
tool="PASS"
|
||||
else
|
||||
tool="PARTIAL"
|
||||
note="missing one output"
|
||||
fi
|
||||
|
||||
printf "%-30s | %-8s | %-10s | %s\n" "$m" "$chat" "$tool" "$note"
|
||||
done
|
||||
Executable
+43
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env bash
|
||||
# Isolate which JSON-Schema construct the Antigravity Cloud Code backend's
|
||||
# Gemini->Anthropic translation rejects for Claude models.
|
||||
#
|
||||
# For each JCODE_SCHEMA_STRIP combination, run a single tool-using prompt
|
||||
# against a Claude model and report PASS (tool ran) / SCHEMA (draft-2020-12
|
||||
# rejection) / OTHER. Requires a valid Antigravity OAuth session.
|
||||
set -uo pipefail
|
||||
|
||||
JC="${JC:-./target/selfdev/jcode}"
|
||||
MODEL="${MODEL:-claude-sonnet-4-6}"
|
||||
PROMPT="${PROMPT:-Run 'echo PROBE_OK' with the bash tool and report the output.}"
|
||||
TIMEOUT="${TIMEOUT:-120}"
|
||||
|
||||
STRIPS=(
|
||||
"" # baseline (current behaviour)
|
||||
"anyof"
|
||||
"addprops"
|
||||
"numitems"
|
||||
"anyof,addprops"
|
||||
"anyof,addprops,numitems"
|
||||
)
|
||||
|
||||
printf "%-28s | %-8s | %s\n" "STRIP" "RESULT" "DETAIL"
|
||||
printf -- "----------------------------------------------------------------------\n"
|
||||
|
||||
for strip in "${STRIPS[@]}"; do
|
||||
out=$(JCODE_SCHEMA_STRIP="$strip" timeout "$TIMEOUT" \
|
||||
"$JC" run --provider antigravity -m "$MODEL" --no-update --no-selfdev "$PROMPT" 2>&1)
|
||||
rc=$?
|
||||
label="${strip:-<none>}"
|
||||
if [[ $rc -eq 124 ]]; then
|
||||
printf "%-28s | %-8s | %s\n" "$label" "TIMEOUT" ""
|
||||
elif grep -q "PROBE_OK" <<<"$out"; then
|
||||
printf "%-28s | %-8s | %s\n" "$label" "PASS" "tool executed"
|
||||
elif grep -qiE "draft 2020|2020-12|schema" <<<"$out"; then
|
||||
detail=$(grep -oiE "[^\"]{0,60}(draft 2020|2020-12|schema)[^\"]{0,60}" <<<"$out" | head -1 | tr -s ' \n' ' ')
|
||||
printf "%-28s | %-8s | %s\n" "$label" "SCHEMA" "$detail"
|
||||
else
|
||||
detail=$(grep -oiE "HTTP [0-9]+[^\"]{0,80}" <<<"$out" | head -1 | tr -s ' \n' ' ')
|
||||
printf "%-28s | %-8s | %s\n" "$label" "OTHER" "${detail:-see logs}"
|
||||
fi
|
||||
done
|
||||
Executable
+154
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
DISALLOWED_NON_NULL_KEYS = {
|
||||
"override_timeout_sec",
|
||||
"max_timeout_sec",
|
||||
"override_setup_timeout_sec",
|
||||
"override_cpus",
|
||||
"override_memory_mb",
|
||||
"override_storage_mb",
|
||||
"override_gpus",
|
||||
"agent_timeout_multiplier",
|
||||
"verifier_timeout_multiplier",
|
||||
"agent_setup_timeout_multiplier",
|
||||
"environment_build_timeout_multiplier",
|
||||
}
|
||||
FORBIDDEN_LOG_TERMS = (
|
||||
"tbench.ai",
|
||||
"terminal-bench.org",
|
||||
"github.com/harbor-framework/terminal-bench",
|
||||
"github.com/laude-institute/terminal-bench",
|
||||
"terminal-bench leaderboard",
|
||||
)
|
||||
|
||||
|
||||
def iter_json_values(value: Any, path: str = ""):
|
||||
if isinstance(value, dict):
|
||||
for key, child in value.items():
|
||||
child_path = f"{path}.{key}" if path else key
|
||||
yield child_path, key, child
|
||||
yield from iter_json_values(child, child_path)
|
||||
elif isinstance(value, list):
|
||||
for idx, child in enumerate(value):
|
||||
yield from iter_json_values(child, f"{path}[{idx}]")
|
||||
|
||||
|
||||
def load_json(path: Path) -> dict[str, Any]:
|
||||
return json.loads(path.read_text())
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Audit a Harbor Terminal-Bench 2.0 campaign for leaderboard-submission rule compatibility.")
|
||||
parser.add_argument("campaign_dir", type=Path)
|
||||
parser.add_argument("--min-trials", type=int, default=5)
|
||||
args = parser.parse_args()
|
||||
|
||||
campaign_dir = args.campaign_dir.expanduser().resolve()
|
||||
jobs_root = campaign_dir / "harbor-jobs"
|
||||
if not jobs_root.is_dir():
|
||||
raise SystemExit(f"Missing harbor-jobs directory: {jobs_root}")
|
||||
|
||||
failures: list[str] = []
|
||||
warnings: list[str] = []
|
||||
submit_ready_jobs: list[str] = []
|
||||
partial_jobs: list[str] = []
|
||||
|
||||
manifest_path = campaign_dir / "campaign.json"
|
||||
if manifest_path.exists():
|
||||
manifest = load_json(manifest_path)
|
||||
if manifest.get("timeout_multiplier") != 1.0:
|
||||
failures.append(f"campaign timeout_multiplier is {manifest.get('timeout_multiplier')!r}, expected 1.0")
|
||||
if manifest.get("attempts_per_task") and manifest.get("attempts_per_task") < args.min_trials:
|
||||
failures.append(f"campaign attempts_per_task is {manifest.get('attempts_per_task')!r}, expected >= {args.min_trials}")
|
||||
else:
|
||||
warnings.append("campaign.json not found; validating job configs only")
|
||||
|
||||
task_dirs = sorted(path for path in jobs_root.iterdir() if path.is_dir())
|
||||
for task_dir in task_dirs:
|
||||
run_dirs = sorted(path for path in task_dir.iterdir() if path.is_dir())
|
||||
if not run_dirs:
|
||||
continue
|
||||
for run_dir in run_dirs:
|
||||
rel_run = run_dir.relative_to(campaign_dir)
|
||||
config_path = run_dir / "config.json"
|
||||
if not config_path.exists():
|
||||
failures.append(f"{rel_run}: missing config.json")
|
||||
continue
|
||||
config = load_json(config_path)
|
||||
if config.get("timeout_multiplier") != 1.0:
|
||||
failures.append(f"{rel_run}: timeout_multiplier is {config.get('timeout_multiplier')!r}, expected 1.0")
|
||||
for json_path, key, value in iter_json_values(config):
|
||||
if key in DISALLOWED_NON_NULL_KEYS and value is not None:
|
||||
# suppress_override_warnings is harmless bookkeeping, not a resource override.
|
||||
failures.append(f"{rel_run}: disallowed non-null config field {json_path}={value!r}")
|
||||
|
||||
trial_results = sorted(run_dir.glob("*__/result.json")) + sorted(run_dir.glob("*__*/result.json"))
|
||||
# Glob patterns can overlap on some shells/filesystems, dedupe while preserving order.
|
||||
seen: set[Path] = set()
|
||||
trial_results = [p for p in trial_results if not (p in seen or seen.add(p))]
|
||||
if len(trial_results) < args.min_trials:
|
||||
partial_jobs.append(f"{rel_run}: only {len(trial_results)} trial result.json files, expected >= {args.min_trials}")
|
||||
continue
|
||||
|
||||
invalid_trials = []
|
||||
missing_artifacts = []
|
||||
for result_path in trial_results:
|
||||
try:
|
||||
load_json(result_path)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
invalid_trials.append(f"{result_path.relative_to(campaign_dir)}: invalid JSON: {exc}")
|
||||
continue
|
||||
siblings = [p for p in result_path.parent.iterdir() if p.name != "result.json"]
|
||||
if not siblings:
|
||||
missing_artifacts.append(str(result_path.parent.relative_to(campaign_dir)))
|
||||
if invalid_trials:
|
||||
failures.extend(invalid_trials)
|
||||
if missing_artifacts:
|
||||
failures.append(f"{rel_run}: trial dirs missing non-result artifacts: {missing_artifacts[:5]}")
|
||||
if not invalid_trials and not missing_artifacts:
|
||||
submit_ready_jobs.append(str(rel_run))
|
||||
|
||||
log_name_allowlist = {
|
||||
"events.ndjson",
|
||||
"stderr.txt",
|
||||
"exec_stderr.txt",
|
||||
"exec_stdout.txt",
|
||||
"instruction.txt",
|
||||
"download_error.txt",
|
||||
}
|
||||
for text_path in jobs_root.rglob("*"):
|
||||
if not text_path.is_file() or text_path.name not in log_name_allowlist or text_path.stat().st_size > 2_000_000:
|
||||
continue
|
||||
try:
|
||||
text = text_path.read_text(errors="ignore").lower()
|
||||
except Exception:
|
||||
continue
|
||||
matches = [term for term in FORBIDDEN_LOG_TERMS if term in text]
|
||||
if matches:
|
||||
warnings.append(f"possible forbidden benchmark-site/repo mention in agent log {text_path.relative_to(campaign_dir)}: {matches}")
|
||||
|
||||
print(f"campaign: {campaign_dir}")
|
||||
print(f"submit-ready job runs: {len(submit_ready_jobs)}")
|
||||
for job in submit_ready_jobs:
|
||||
print(f" OK {job}")
|
||||
print(f"partial/non-submittable job runs: {len(partial_jobs)}")
|
||||
for item in partial_jobs:
|
||||
print(f" PARTIAL {item}")
|
||||
print(f"failures: {len(failures)}")
|
||||
for item in failures:
|
||||
print(f" FAIL {item}")
|
||||
print(f"warnings: {len(warnings)}")
|
||||
for item in warnings[:50]:
|
||||
print(f" WARN {item}")
|
||||
|
||||
return 1 if failures else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+216
@@ -0,0 +1,216 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
command=${1:-help}
|
||||
if [[ $# -gt 0 ]]; then
|
||||
shift
|
||||
fi
|
||||
|
||||
sandbox_name=${JCODE_ONBOARDING_SANDBOX:-default}
|
||||
sandbox_root_default="$repo_root/.tmp/onboarding/$sandbox_name"
|
||||
sandbox_root=${JCODE_ONBOARDING_DIR:-$sandbox_root_default}
|
||||
jcode_home="$sandbox_root/home"
|
||||
runtime_dir="$sandbox_root/runtime"
|
||||
fixture_root_default="$repo_root/.tmp/auth-fixtures"
|
||||
fixture_root=${JCODE_AUTH_FIXTURE_DIR:-$fixture_root_default}
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $(basename "$0") <command> [args...]
|
||||
|
||||
Commands:
|
||||
list List saved local auth fixtures
|
||||
path [name] Print fixture root, or a specific fixture path
|
||||
save <name> Save the current sandbox JCODE_HOME as a fixture
|
||||
load <name> Replace the sandbox JCODE_HOME with a saved fixture
|
||||
reset-sandbox Remove only the current sandbox JCODE_HOME
|
||||
delete <name> Delete a saved fixture
|
||||
env <name> Print exports for running against a loaded fixture
|
||||
run <name> -- <args...> Load fixture, then run jcode with args in sandbox
|
||||
help Show this help
|
||||
|
||||
Environment overrides:
|
||||
JCODE_ONBOARDING_SANDBOX Sandbox name to load into/from (default: default)
|
||||
JCODE_ONBOARDING_DIR Explicit onboarding sandbox directory
|
||||
JCODE_AUTH_FIXTURE_DIR Fixture store (default: .tmp/auth-fixtures)
|
||||
|
||||
Notes:
|
||||
Fixtures are local developer state under .tmp by default. They may contain real
|
||||
tokens copied from sandbox logins, so do not move them into tracked paths.
|
||||
EOF
|
||||
}
|
||||
|
||||
require_name() {
|
||||
if [[ $# -lt 1 || -z "${1:-}" ]]; then
|
||||
echo "missing fixture name" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ "$1" == *"/"* || "$1" == *".."* || "$1" =~ [^A-Za-z0-9._-] ]]; then
|
||||
echo "invalid fixture name: $1" >&2
|
||||
echo "Use only letters, numbers, dot, underscore, and dash." >&2
|
||||
exit 2
|
||||
fi
|
||||
printf '%s' "$1"
|
||||
}
|
||||
|
||||
fixture_path() {
|
||||
local name
|
||||
name=$(require_name "$1")
|
||||
printf '%s/%s/home' "$fixture_root" "$name"
|
||||
}
|
||||
|
||||
metadata_path() {
|
||||
local name
|
||||
name=$(require_name "$1")
|
||||
printf '%s/%s/metadata.txt' "$fixture_root" "$name"
|
||||
}
|
||||
|
||||
ensure_parent_dirs() {
|
||||
mkdir -p "$fixture_root" "$sandbox_root" "$runtime_dir"
|
||||
chmod 700 "$fixture_root" "$sandbox_root" 2>/dev/null || true
|
||||
}
|
||||
|
||||
copy_dir_contents() {
|
||||
local src=$1
|
||||
local dst=$2
|
||||
rm -rf "$dst"
|
||||
mkdir -p "$dst"
|
||||
if [[ -d "$src" ]]; then
|
||||
shopt -s dotglob nullglob
|
||||
local entries=("$src"/*)
|
||||
if [[ ${#entries[@]} -gt 0 ]]; then
|
||||
cp -a "${entries[@]}" "$dst"/
|
||||
fi
|
||||
shopt -u dotglob nullglob
|
||||
fi
|
||||
}
|
||||
|
||||
run_jcode() {
|
||||
local binary_path="$repo_root/target/debug/jcode"
|
||||
(
|
||||
cd "$repo_root"
|
||||
if [[ -x "$binary_path" ]]; then
|
||||
env JCODE_HOME="$jcode_home" JCODE_RUNTIME_DIR="$runtime_dir" "$binary_path" "$@"
|
||||
else
|
||||
env JCODE_HOME="$jcode_home" JCODE_RUNTIME_DIR="$runtime_dir" cargo run --bin jcode -- "$@"
|
||||
fi
|
||||
)
|
||||
}
|
||||
|
||||
list_fixtures() {
|
||||
ensure_parent_dirs
|
||||
if [[ ! -d "$fixture_root" ]]; then
|
||||
return 0
|
||||
fi
|
||||
find "$fixture_root" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort
|
||||
}
|
||||
|
||||
save_fixture() {
|
||||
local name=$1
|
||||
local dst meta
|
||||
dst=$(fixture_path "$name")
|
||||
meta=$(metadata_path "$name")
|
||||
ensure_parent_dirs
|
||||
if [[ ! -d "$jcode_home" ]]; then
|
||||
echo "sandbox JCODE_HOME does not exist: $jcode_home" >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$(dirname "$dst")"
|
||||
copy_dir_contents "$jcode_home" "$dst"
|
||||
chmod -R go-rwx "$(dirname "$dst")" 2>/dev/null || true
|
||||
cat > "$meta" <<EOF
|
||||
name=$name
|
||||
saved_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
sandbox_name=$sandbox_name
|
||||
source_jcode_home=$jcode_home
|
||||
warning=May contain real local auth tokens. Do not commit or share.
|
||||
EOF
|
||||
echo "Saved auth fixture '$name' from $jcode_home"
|
||||
echo "Fixture path: $(dirname "$dst")"
|
||||
}
|
||||
|
||||
load_fixture() {
|
||||
local name=$1
|
||||
local src
|
||||
src=$(fixture_path "$name")
|
||||
ensure_parent_dirs
|
||||
if [[ ! -d "$src" ]]; then
|
||||
echo "fixture does not exist: $name" >&2
|
||||
echo "Expected: $src" >&2
|
||||
exit 1
|
||||
fi
|
||||
copy_dir_contents "$src" "$jcode_home"
|
||||
chmod -R go-rwx "$jcode_home" 2>/dev/null || true
|
||||
echo "Loaded auth fixture '$name' into sandbox '$sandbox_name'"
|
||||
echo "JCODE_HOME=$jcode_home"
|
||||
}
|
||||
|
||||
delete_fixture() {
|
||||
local name=$1
|
||||
local fixture_dir="$fixture_root/$name"
|
||||
if [[ ! -d "$fixture_dir" ]]; then
|
||||
echo "fixture does not exist: $name" >&2
|
||||
exit 1
|
||||
fi
|
||||
rm -rf "$fixture_dir"
|
||||
echo "Deleted auth fixture '$name'"
|
||||
}
|
||||
|
||||
case "$command" in
|
||||
list)
|
||||
list_fixtures
|
||||
;;
|
||||
path)
|
||||
ensure_parent_dirs
|
||||
if [[ $# -gt 0 ]]; then
|
||||
name=$(require_name "$1")
|
||||
dirname "$(fixture_path "$name")"
|
||||
else
|
||||
printf '%s\n' "$fixture_root"
|
||||
fi
|
||||
;;
|
||||
save)
|
||||
name=$(require_name "${1:-}")
|
||||
save_fixture "$name"
|
||||
;;
|
||||
load)
|
||||
name=$(require_name "${1:-}")
|
||||
load_fixture "$name"
|
||||
;;
|
||||
reset-sandbox)
|
||||
rm -rf "$jcode_home"
|
||||
mkdir -p "$jcode_home" "$runtime_dir"
|
||||
echo "Reset sandbox JCODE_HOME: $jcode_home"
|
||||
;;
|
||||
delete)
|
||||
name=$(require_name "${1:-}")
|
||||
delete_fixture "$name"
|
||||
;;
|
||||
env)
|
||||
name=$(require_name "${1:-}")
|
||||
load_fixture "$name" >/dev/null
|
||||
cat <<EOF
|
||||
export JCODE_HOME="$jcode_home"
|
||||
export JCODE_RUNTIME_DIR="$runtime_dir"
|
||||
EOF
|
||||
;;
|
||||
run)
|
||||
name=$(require_name "${1:-}")
|
||||
shift || true
|
||||
if [[ "${1:-}" == "--" ]]; then
|
||||
shift
|
||||
fi
|
||||
load_fixture "$name" >/dev/null
|
||||
run_jcode "$@"
|
||||
;;
|
||||
help|-h|--help)
|
||||
usage
|
||||
;;
|
||||
*)
|
||||
echo "Unknown command: $command" >&2
|
||||
echo >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
Executable
+222
@@ -0,0 +1,222 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
cd "$repo_root"
|
||||
|
||||
bin=${JCODE_AUTH_MATRIX_BIN:-}
|
||||
out_dir=${JCODE_AUTH_MATRIX_OUT:-"$repo_root/target/auth-test-reports"}
|
||||
prompt=${JCODE_AUTH_MATRIX_PROMPT:-"Reply with exactly AUTH_TEST_OK and nothing else. Do not call tools."}
|
||||
providers=${JCODE_AUTH_MATRIX_PROVIDERS:-"claude copilot openrouter deepseek zai alibaba-coding-plan openai-compatible"}
|
||||
mode=${JCODE_AUTH_MATRIX_MODE:-configured}
|
||||
keep_going=${JCODE_AUTH_MATRIX_KEEP_GOING:-1}
|
||||
per_command_timeout=${JCODE_AUTH_MATRIX_TIMEOUT:-90}
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: scripts/auth_regression_matrix.sh [options]
|
||||
|
||||
Runs jcode auth-test across the auth/provider matrix and writes one JSON report per provider.
|
||||
By default it only tests providers that are configured enough for auth-test to run.
|
||||
|
||||
Options:
|
||||
--all Try every provider in the matrix, even if not configured
|
||||
--configured Test only configured providers (default)
|
||||
--provider NAME Test one provider. Can be repeated.
|
||||
--out DIR Report directory (default: target/auth-test-reports)
|
||||
--bin PATH jcode binary to run (default: cargo run --bin jcode --)
|
||||
--login Run login before validation for each provider
|
||||
--no-smoke Skip runtime model smoke
|
||||
--no-tool-smoke Skip tool-enabled runtime smoke
|
||||
--fail-fast Stop after the first failed provider
|
||||
--prompt TEXT Custom smoke prompt
|
||||
--timeout SECONDS Per auth-test command timeout (default: 90)
|
||||
-h, --help Show this help
|
||||
|
||||
Environment equivalents:
|
||||
JCODE_AUTH_MATRIX_BIN=/path/to/jcode
|
||||
JCODE_AUTH_MATRIX_OUT=target/auth-test-reports
|
||||
JCODE_AUTH_MATRIX_PROVIDERS="claude deepseek zai"
|
||||
JCODE_AUTH_MATRIX_MODE=configured|all
|
||||
JCODE_AUTH_MATRIX_LOGIN=1
|
||||
JCODE_AUTH_MATRIX_NO_SMOKE=1
|
||||
JCODE_AUTH_MATRIX_NO_TOOL_SMOKE=1
|
||||
JCODE_AUTH_MATRIX_KEEP_GOING=0
|
||||
JCODE_AUTH_MATRIX_TIMEOUT=90
|
||||
|
||||
Examples:
|
||||
scripts/auth_regression_matrix.sh --configured --no-smoke
|
||||
scripts/auth_regression_matrix.sh --provider deepseek --provider zai
|
||||
JCODE_AUTH_MATRIX_BIN=target/selfdev/jcode scripts/auth_regression_matrix.sh --all
|
||||
EOF
|
||||
}
|
||||
|
||||
selected=()
|
||||
extra_args=()
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--all)
|
||||
mode=all
|
||||
shift
|
||||
;;
|
||||
--configured)
|
||||
mode=configured
|
||||
shift
|
||||
;;
|
||||
--provider)
|
||||
[[ $# -ge 2 ]] || { echo "error: --provider requires a value" >&2; exit 2; }
|
||||
selected+=("$2")
|
||||
shift 2
|
||||
;;
|
||||
--out)
|
||||
[[ $# -ge 2 ]] || { echo "error: --out requires a value" >&2; exit 2; }
|
||||
out_dir=$2
|
||||
shift 2
|
||||
;;
|
||||
--bin)
|
||||
[[ $# -ge 2 ]] || { echo "error: --bin requires a value" >&2; exit 2; }
|
||||
bin=$2
|
||||
shift 2
|
||||
;;
|
||||
--login)
|
||||
extra_args+=(--login)
|
||||
shift
|
||||
;;
|
||||
--no-smoke)
|
||||
extra_args+=(--no-smoke)
|
||||
shift
|
||||
;;
|
||||
--no-tool-smoke)
|
||||
extra_args+=(--no-tool-smoke)
|
||||
shift
|
||||
;;
|
||||
--fail-fast)
|
||||
keep_going=0
|
||||
shift
|
||||
;;
|
||||
--prompt)
|
||||
[[ $# -ge 2 ]] || { echo "error: --prompt requires a value" >&2; exit 2; }
|
||||
prompt=$2
|
||||
shift 2
|
||||
;;
|
||||
--timeout)
|
||||
[[ $# -ge 2 ]] || { echo "error: --timeout requires a value" >&2; exit 2; }
|
||||
per_command_timeout=$2
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "error: unknown argument: $1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "${JCODE_AUTH_MATRIX_LOGIN:-0}" == "1" ]]; then
|
||||
extra_args+=(--login)
|
||||
fi
|
||||
if [[ "${JCODE_AUTH_MATRIX_NO_SMOKE:-0}" == "1" ]]; then
|
||||
extra_args+=(--no-smoke)
|
||||
fi
|
||||
if [[ "${JCODE_AUTH_MATRIX_NO_TOOL_SMOKE:-0}" == "1" ]]; then
|
||||
extra_args+=(--no-tool-smoke)
|
||||
fi
|
||||
|
||||
if [[ ${#selected[@]} -eq 0 ]]; then
|
||||
# shellcheck disable=SC2206
|
||||
selected=($providers)
|
||||
fi
|
||||
|
||||
mkdir -p "$out_dir"
|
||||
|
||||
run_jcode() {
|
||||
if [[ -n "$bin" ]]; then
|
||||
timeout "$per_command_timeout" "$bin" "$@"
|
||||
else
|
||||
timeout "$per_command_timeout" cargo run --quiet --bin jcode -- "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
configured_json="$out_dir/configured-providers.json"
|
||||
if [[ "$mode" == "configured" ]]; then
|
||||
echo "Discovering configured providers..."
|
||||
rm -f "$configured_json"
|
||||
if ! run_jcode auth-test --all-configured --no-smoke --no-tool-smoke --json --output "$configured_json" >/tmp/jcode-auth-matrix-discovery.out 2>/tmp/jcode-auth-matrix-discovery.err; then
|
||||
if [[ -s "$configured_json" ]]; then
|
||||
echo "note: configured-provider discovery reported non-ready providers; continuing with per-provider classification" >&2
|
||||
else
|
||||
cat /tmp/jcode-auth-matrix-discovery.err >&2 || true
|
||||
echo "warning: configured-provider discovery failed; continuing with explicit matrix and skipping only obvious unconfigured failures" >&2
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
failed=()
|
||||
passed=()
|
||||
skipped=()
|
||||
blocked=()
|
||||
|
||||
is_unconfigured_failure() {
|
||||
grep -Eiq 'not configured|missing|no credentials|not found in environment|requires.*token|requires.*api key' "$1"
|
||||
}
|
||||
|
||||
is_external_account_blocked_failure() {
|
||||
# These are upstream account/entitlement states, not auth-regression signal.
|
||||
# Keep this list intentionally narrow so real code/provider failures still fail.
|
||||
grep -Eiq 'feature_flag_blocked|can_signup_for_limited|Contact Support|not entitled|not eligible|subscription required|quota exceeded|rate limit' "$1"
|
||||
}
|
||||
|
||||
echo "Auth regression matrix"
|
||||
echo "Mode: $mode"
|
||||
echo "Reports: $out_dir"
|
||||
echo "Providers: ${selected[*]}"
|
||||
echo "Timeout: ${per_command_timeout}s per command"
|
||||
echo
|
||||
|
||||
for provider in "${selected[@]}"; do
|
||||
report="$out_dir/${provider}.json"
|
||||
log="$out_dir/${provider}.log"
|
||||
args=(auth-test --provider "$provider" --prompt "$prompt" --json --output "$report" "${extra_args[@]}")
|
||||
|
||||
echo "=== auth-test: $provider ==="
|
||||
set +e
|
||||
run_jcode "${args[@]}" >"$log" 2>&1
|
||||
status=$?
|
||||
set -e
|
||||
|
||||
if [[ $status -eq 0 ]]; then
|
||||
passed+=("$provider")
|
||||
echo "PASS $provider"
|
||||
else
|
||||
if [[ "$mode" == "configured" ]] && is_unconfigured_failure "$log"; then
|
||||
skipped+=("$provider")
|
||||
echo "SKIP $provider (not configured, see $log)"
|
||||
elif [[ "$mode" == "configured" ]] && is_external_account_blocked_failure "$log"; then
|
||||
blocked+=("$provider")
|
||||
echo "BLOCKED $provider (upstream account/entitlement unavailable, see $log)"
|
||||
else
|
||||
failed+=("$provider")
|
||||
echo "FAIL $provider (exit $status, see $log)"
|
||||
if [[ "$keep_going" != "1" ]]; then
|
||||
break
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
echo
|
||||
done
|
||||
|
||||
summary="$out_dir/summary.txt"
|
||||
{
|
||||
echo "passed: ${passed[*]:-<none>}"
|
||||
echo "skipped: ${skipped[*]:-<none>}"
|
||||
echo "blocked: ${blocked[*]:-<none>}"
|
||||
echo "failed: ${failed[*]:-<none>}"
|
||||
} | tee "$summary"
|
||||
|
||||
if [[ ${#failed[@]} -gt 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
Executable
+41
@@ -0,0 +1,41 @@
|
||||
#!/bin/bash
|
||||
# Autonomous screenshot capture for jcode documentation
|
||||
# Uses niri window management + screenshot capabilities
|
||||
#
|
||||
# Usage: ./auto_screenshot.sh <window_id> <output_name> [setup_command]
|
||||
#
|
||||
# Examples:
|
||||
# ./auto_screenshot.sh 77 main-ui
|
||||
# ./auto_screenshot.sh 77 info-widget "/info"
|
||||
# ./auto_screenshot.sh 77 command-palette "/"
|
||||
|
||||
set -e
|
||||
|
||||
WINDOW_ID="${1:?Usage: $0 <window_id> <output_name> [setup_command]}"
|
||||
OUTPUT_NAME="${2:?Usage: $0 <window_id> <output_name> [setup_command]}"
|
||||
SETUP_CMD="${3:-}"
|
||||
|
||||
OUTPUT_DIR="$(dirname "$0")/../docs/screenshots"
|
||||
OUTPUT_PATH="$OUTPUT_DIR/${OUTPUT_NAME}.png"
|
||||
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
|
||||
echo "📸 Capturing window $WINDOW_ID as $OUTPUT_NAME"
|
||||
|
||||
# Focus the target window
|
||||
niri msg action focus-window --id "$WINDOW_ID"
|
||||
sleep 0.3 # Let the window focus settle
|
||||
|
||||
# If there's a setup command, we'd need to inject it somehow
|
||||
# For now, this is a placeholder - see below for the full solution
|
||||
if [ -n "$SETUP_CMD" ]; then
|
||||
echo "⚠️ Setup command '$SETUP_CMD' - manual injection needed for now"
|
||||
echo " Press Enter after setting up the UI state..."
|
||||
read -r
|
||||
fi
|
||||
|
||||
# Screenshot the focused window
|
||||
niri msg action screenshot-window --path "$OUTPUT_PATH"
|
||||
|
||||
echo "✅ Saved: $OUTPUT_PATH"
|
||||
ls -lh "$OUTPUT_PATH"
|
||||
Executable
+274
@@ -0,0 +1,274 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
cd "$repo_root"
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage:
|
||||
scripts/bench_compile.sh <target> [options] [-- <extra cargo args>]
|
||||
|
||||
Targets:
|
||||
check Run cargo check --quiet
|
||||
build Run cargo build --quiet
|
||||
release-jcode Run scripts/dev_cargo.sh build --release -p jcode --bin jcode --quiet
|
||||
selfdev-jcode Run scripts/dev_cargo.sh build --profile selfdev -p jcode --bin jcode --quiet
|
||||
|
||||
Options:
|
||||
--cold Run cargo clean before timing the first run
|
||||
--touch <path> Touch a source file before each timed run to simulate an edit
|
||||
--edit <path> Toggle a harmless text edit before each run (restored afterward)
|
||||
--runs <n> Number of timed runs to execute (default: 1)
|
||||
--json Print per-run + summary data as JSON
|
||||
-h, --help Show this help
|
||||
|
||||
Examples:
|
||||
scripts/bench_compile.sh check
|
||||
scripts/bench_compile.sh check --runs 3 --touch src/server.rs
|
||||
scripts/bench_compile.sh check --runs 3 --edit src/server.rs
|
||||
scripts/bench_compile.sh build -- --package jcode --bin test_api
|
||||
scripts/bench_compile.sh release-jcode --json
|
||||
scripts/bench_compile.sh selfdev-jcode --json
|
||||
USAGE
|
||||
}
|
||||
|
||||
if [[ $# -gt 0 ]] && [[ "$1" == "-h" || "$1" == "--help" ]]; then
|
||||
usage
|
||||
exit 0
|
||||
fi
|
||||
|
||||
target="${1:-}"
|
||||
shift || true
|
||||
|
||||
if [[ -z "$target" ]]; then
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cold=0
|
||||
touch_path=""
|
||||
edit_path=""
|
||||
runs=1
|
||||
json_output=0
|
||||
extra_args=()
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--cold)
|
||||
cold=1
|
||||
;;
|
||||
--touch)
|
||||
if [[ $# -lt 2 ]]; then
|
||||
printf 'error: --touch requires a path\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
touch_path="$2"
|
||||
shift
|
||||
;;
|
||||
--edit)
|
||||
if [[ $# -lt 2 ]]; then
|
||||
printf 'error: --edit requires a path\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
edit_path="$2"
|
||||
shift
|
||||
;;
|
||||
--runs)
|
||||
if [[ $# -lt 2 ]]; then
|
||||
printf 'error: --runs requires a positive integer\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
runs="$2"
|
||||
shift
|
||||
;;
|
||||
--json)
|
||||
json_output=1
|
||||
;;
|
||||
--)
|
||||
shift
|
||||
extra_args=("$@")
|
||||
break
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
printf 'error: unknown argument: %s\n' "$1" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
if ! [[ "$runs" =~ ^[1-9][0-9]*$ ]]; then
|
||||
printf 'error: --runs must be a positive integer (got %s)\n' "$runs" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -n "$touch_path" && -n "$edit_path" ]]; then
|
||||
printf 'error: --touch and --edit are mutually exclusive\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "$target" in
|
||||
check)
|
||||
cmd=(cargo check --quiet)
|
||||
;;
|
||||
build)
|
||||
cmd=(cargo build --quiet)
|
||||
;;
|
||||
release-jcode)
|
||||
cmd=(scripts/dev_cargo.sh build --release -p jcode --bin jcode --quiet)
|
||||
;;
|
||||
selfdev-jcode)
|
||||
cmd=(scripts/dev_cargo.sh build --profile selfdev -p jcode --bin jcode --quiet)
|
||||
;;
|
||||
*)
|
||||
printf 'error: unsupported target: %s\n' "$target" >&2
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ ${#extra_args[@]} -gt 0 ]]; then
|
||||
cmd+=("${extra_args[@]}")
|
||||
fi
|
||||
|
||||
if [[ -n "$touch_path" ]] && [[ ! -e "$touch_path" ]]; then
|
||||
printf 'error: touch path does not exist: %s\n' "$touch_path" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -n "$edit_path" ]] && [[ ! -f "$edit_path" ]]; then
|
||||
printf 'error: edit path must be an existing file: %s\n' "$edit_path" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
edit_backup=""
|
||||
cleanup() {
|
||||
if [[ -n "$edit_backup" && -n "$edit_path" && -f "$edit_backup" ]]; then
|
||||
cp "$edit_backup" "$edit_path"
|
||||
rm -f "$edit_backup"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
if [[ -n "$edit_path" ]]; then
|
||||
edit_backup=$(mktemp)
|
||||
cp "$edit_path" "$edit_backup"
|
||||
fi
|
||||
|
||||
if [[ $cold -eq 1 ]]; then
|
||||
echo 'bench_compile: running cargo clean' >&2
|
||||
cargo clean
|
||||
fi
|
||||
|
||||
printf 'bench_compile: target=%s cold=%s runs=%s\n' "$target" "$cold" "$runs" >&2
|
||||
printf 'bench_compile: touch=%s\n' "${touch_path:-<none>}" >&2
|
||||
printf 'bench_compile: edit=%s\n' "${edit_path:-<none>}" >&2
|
||||
printf 'bench_compile: command=%s\n' "${cmd[*]}" >&2
|
||||
|
||||
run_times=()
|
||||
|
||||
run_once() {
|
||||
local run_index="$1"
|
||||
if [[ -n "$touch_path" ]]; then
|
||||
echo "bench_compile: touching $touch_path (run $run_index/$runs)" >&2
|
||||
touch "$touch_path"
|
||||
elif [[ -n "$edit_path" ]]; then
|
||||
echo "bench_compile: editing $edit_path (run $run_index/$runs)" >&2
|
||||
python3 - "$edit_backup" "$edit_path" "$run_index" <<'PY'
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
backup = Path(sys.argv[1]).read_bytes()
|
||||
target = Path(sys.argv[2])
|
||||
run_index = int(sys.argv[3])
|
||||
|
||||
if run_index % 2 == 1:
|
||||
target.write_bytes(backup + b"\n")
|
||||
else:
|
||||
target.write_bytes(backup)
|
||||
PY
|
||||
fi
|
||||
|
||||
local start_ns end_ns elapsed_ns elapsed_secs
|
||||
start_ns=$(python3 - <<'PY'
|
||||
import time
|
||||
print(time.perf_counter_ns())
|
||||
PY
|
||||
)
|
||||
|
||||
"${cmd[@]}"
|
||||
|
||||
end_ns=$(python3 - <<'PY'
|
||||
import time
|
||||
print(time.perf_counter_ns())
|
||||
PY
|
||||
)
|
||||
elapsed_ns=$((end_ns - start_ns))
|
||||
elapsed_secs=$(python3 - "$elapsed_ns" <<'PY'
|
||||
import sys
|
||||
print(f"{int(sys.argv[1]) / 1_000_000_000:.3f}")
|
||||
PY
|
||||
)
|
||||
|
||||
run_times+=("$elapsed_secs")
|
||||
|
||||
if [[ $json_output -eq 0 ]]; then
|
||||
printf 'bench_compile: run %s/%s real %ss\n' "$run_index" "$runs" "$elapsed_secs" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
for ((i = 1; i <= runs; i++)); do
|
||||
run_once "$i"
|
||||
done
|
||||
|
||||
summary_json=$(python3 - "$target" "$cold" "$touch_path" "$edit_path" "$runs" "${cmd[*]}" "${run_times[@]}" <<'PY'
|
||||
import json
|
||||
import statistics
|
||||
import sys
|
||||
|
||||
target = sys.argv[1]
|
||||
cold = sys.argv[2] == "1"
|
||||
touch = sys.argv[3]
|
||||
edit = sys.argv[4]
|
||||
runs = int(sys.argv[5])
|
||||
command = sys.argv[6]
|
||||
times = [float(v) for v in sys.argv[7:]]
|
||||
summary = {
|
||||
"target": target,
|
||||
"cold": cold,
|
||||
"touch": touch or None,
|
||||
"edit": edit or None,
|
||||
"runs": runs,
|
||||
"command": command,
|
||||
"times_seconds": times,
|
||||
"min_seconds": min(times),
|
||||
"max_seconds": max(times),
|
||||
"avg_seconds": sum(times) / len(times),
|
||||
"median_seconds": statistics.median(times),
|
||||
}
|
||||
print(json.dumps(summary))
|
||||
PY
|
||||
)
|
||||
|
||||
if [[ $json_output -eq 1 ]]; then
|
||||
printf '%s\n' "$summary_json"
|
||||
else
|
||||
python3 - "$summary_json" <<'PY' >&2
|
||||
import json
|
||||
import sys
|
||||
|
||||
summary = json.loads(sys.argv[1])
|
||||
print(
|
||||
"bench_compile: summary "
|
||||
f"min={summary['min_seconds']:.3f}s "
|
||||
f"median={summary['median_seconds']:.3f}s "
|
||||
f"avg={summary['avg_seconds']:.3f}s "
|
||||
f"max={summary['max_seconds']:.3f}s"
|
||||
)
|
||||
PY
|
||||
fi
|
||||
@@ -0,0 +1,475 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import pty
|
||||
import re
|
||||
import select
|
||||
import shutil
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
|
||||
ANSI_RE = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x1b\x07]*(?:\x07|\x1b\\))")
|
||||
PROBE = "jqx92"
|
||||
DEFAULT_TIMEOUT_S = 20.0
|
||||
DEFAULT_SETTLE_S = 1.0
|
||||
DEFAULT_TOOLS = [
|
||||
"jcode_memory_off",
|
||||
"jcode_memory_on",
|
||||
"pi",
|
||||
"codex",
|
||||
"opencode",
|
||||
"copilot_cli",
|
||||
"cursor_agent",
|
||||
"claude_code",
|
||||
"antigravity_cli",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolSpec:
|
||||
name: str
|
||||
argv: list[str]
|
||||
version_argv: list[str]
|
||||
env: dict[str, str] | None = None
|
||||
jcode: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class SessionLaunch:
|
||||
root_pid: int
|
||||
pgid: int
|
||||
master_fd: int
|
||||
ready: bool
|
||||
input_ready: bool
|
||||
excerpt: str | None
|
||||
seconds_to_visible: float | None
|
||||
seconds_to_input_ready: float | None
|
||||
buffer_excerpt: str | None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolRunResult:
|
||||
tool: str
|
||||
sessions: int
|
||||
pss_mb: float
|
||||
process_count: int
|
||||
version: str
|
||||
notes: list[str]
|
||||
|
||||
|
||||
def shutil_which(name: str) -> str | None:
|
||||
return subprocess.run(
|
||||
["bash", "-lc", f"command -v {name}"], capture_output=True, text=True, check=False
|
||||
).stdout.strip() or None
|
||||
|
||||
|
||||
def detect_pi_bin() -> str:
|
||||
direct = shutil_which("pi")
|
||||
if direct:
|
||||
return direct
|
||||
prefix = subprocess.check_output(["npm", "prefix", "-g"], text=True).strip()
|
||||
candidate = Path(prefix) / "bin" / "pi"
|
||||
if candidate.exists():
|
||||
return str(candidate)
|
||||
raise FileNotFoundError("could not find pi binary")
|
||||
|
||||
|
||||
def build_specs() -> dict[str, ToolSpec]:
|
||||
jcode = shutil.which("jcode") or str(Path.home() / ".local/bin/jcode")
|
||||
codex = shutil.which("codex") or "/usr/bin/codex"
|
||||
opencode = shutil.which("opencode") or "/usr/bin/opencode"
|
||||
copilot = shutil.which("copilot") or str(Path.home() / ".local/bin/copilot")
|
||||
cursor_agent = shutil.which("cursor-agent") or str(Path.home() / ".local/bin/cursor-agent")
|
||||
claude = shutil.which("claude") or str(Path.home() / ".local/bin/claude")
|
||||
agy = shutil.which("agy") or str(Path.home() / ".local/bin/agy")
|
||||
specs = {
|
||||
"jcode_memory_off": ToolSpec(
|
||||
name="jcode_memory_off",
|
||||
argv=[jcode, "--no-update", "--no-selfdev"],
|
||||
version_argv=[jcode, "version"],
|
||||
env={"JCODE_NO_TELEMETRY": "1", "JCODE_MEMORY_ENABLED": "0"},
|
||||
jcode=True,
|
||||
),
|
||||
"jcode_memory_on": ToolSpec(
|
||||
name="jcode_memory_on",
|
||||
argv=[jcode, "--no-update", "--no-selfdev"],
|
||||
version_argv=[jcode, "version"],
|
||||
env={"JCODE_NO_TELEMETRY": "1", "JCODE_MEMORY_ENABLED": "1"},
|
||||
jcode=True,
|
||||
),
|
||||
"pi": ToolSpec(
|
||||
name="pi",
|
||||
argv=[detect_pi_bin()],
|
||||
version_argv=[detect_pi_bin(), "--version"],
|
||||
),
|
||||
"codex": ToolSpec(
|
||||
name="codex",
|
||||
argv=[codex],
|
||||
version_argv=[codex, "--version"],
|
||||
),
|
||||
"opencode": ToolSpec(
|
||||
name="opencode",
|
||||
argv=[opencode],
|
||||
version_argv=[opencode, "--version"],
|
||||
),
|
||||
"copilot_cli": ToolSpec(
|
||||
name="copilot_cli",
|
||||
argv=[copilot],
|
||||
version_argv=[copilot, "--version"],
|
||||
),
|
||||
"cursor_agent": ToolSpec(
|
||||
name="cursor_agent",
|
||||
argv=[cursor_agent],
|
||||
version_argv=[cursor_agent, "--version"],
|
||||
),
|
||||
"claude_code": ToolSpec(
|
||||
name="claude_code",
|
||||
argv=[claude],
|
||||
version_argv=[claude, "--version"],
|
||||
),
|
||||
"antigravity_cli": ToolSpec(
|
||||
name="antigravity_cli",
|
||||
argv=[agy],
|
||||
version_argv=[agy, "--version"],
|
||||
),
|
||||
}
|
||||
return specs
|
||||
|
||||
|
||||
def reply_queries(master_fd: int, buffer: bytes) -> bytes:
|
||||
replies = [
|
||||
(b"\x1b[6n", b"\x1b[1;1R"),
|
||||
(b"\x1b[c", b"\x1b[?62;c"),
|
||||
(b"\x1b]10;?\x1b\\", b"\x1b]10;rgb:ffff/ffff/ffff\x1b\\"),
|
||||
(b"\x1b]11;?\x1b\\", b"\x1b]11;rgb:0000/0000/0000\x1b\\"),
|
||||
(b"\x1b]10;?\x07", b"\x1b]10;rgb:ffff/ffff/ffff\x07"),
|
||||
(b"\x1b]11;?\x07", b"\x1b]11;rgb:0000/0000/0000\x07"),
|
||||
(b"\x1b]4;0;?\x07", b"\x1b]4;0;rgb:0000/0000/0000\x07"),
|
||||
(b"\x1b[14t", b"\x1b[4;600;800t"),
|
||||
(b"\x1b[16t", b"\x1b[6;16;8t"),
|
||||
(b"\x1b[18t", b"\x1b[8;24;80t"),
|
||||
(b"\x1b[?1016$p", b"\x1b[?1016;1$y"),
|
||||
(b"\x1b[?2027$p", b"\x1b[?2027;1$y"),
|
||||
(b"\x1b[?2031$p", b"\x1b[?2031;1$y"),
|
||||
(b"\x1b[?1004$p", b"\x1b[?1004;1$y"),
|
||||
(b"\x1b[?2004$p", b"\x1b[?2004;1$y"),
|
||||
(b"\x1b[?2026$p", b"\x1b[?2026;1$y"),
|
||||
]
|
||||
changed = True
|
||||
while changed:
|
||||
changed = False
|
||||
for query, response in replies:
|
||||
if query in buffer:
|
||||
os.write(master_fd, response)
|
||||
buffer = buffer.replace(query, b"")
|
||||
changed = True
|
||||
return buffer
|
||||
|
||||
|
||||
def strip_ansi(text: str) -> str:
|
||||
return ANSI_RE.sub("", text).replace("\r", "\n")
|
||||
|
||||
|
||||
def first_meaningful_line(text: str) -> str | None:
|
||||
for raw_line in text.splitlines():
|
||||
line = " ".join(raw_line.split())
|
||||
if not line:
|
||||
continue
|
||||
alnum_count = sum(ch.isalnum() for ch in line)
|
||||
if alnum_count >= 3 and len(line) >= 4:
|
||||
return line[:160]
|
||||
return None
|
||||
|
||||
|
||||
def wait_for_socket(path: str, timeout_s: float) -> bool:
|
||||
deadline = time.time() + timeout_s
|
||||
while time.time() < deadline:
|
||||
if os.path.exists(path):
|
||||
try:
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
sock.connect(path)
|
||||
sock.close()
|
||||
return True
|
||||
except OSError:
|
||||
pass
|
||||
time.sleep(0.05)
|
||||
return False
|
||||
|
||||
|
||||
def launch_interactive(argv: list[str], cwd: Path, env: dict[str, str], timeout_s: float, settle_s: float) -> SessionLaunch:
|
||||
master_fd, slave_fd = pty.openpty()
|
||||
proc = subprocess.Popen(
|
||||
argv,
|
||||
cwd=str(cwd),
|
||||
env=env,
|
||||
stdin=slave_fd,
|
||||
stdout=slave_fd,
|
||||
stderr=slave_fd,
|
||||
preexec_fn=os.setsid,
|
||||
)
|
||||
os.close(slave_fd)
|
||||
os.set_blocking(master_fd, False)
|
||||
start = time.perf_counter()
|
||||
buf = b""
|
||||
ready = False
|
||||
input_ready = False
|
||||
probe_sent = False
|
||||
excerpt = None
|
||||
while time.perf_counter() - start < timeout_s:
|
||||
rlist, _, _ = select.select([master_fd], [], [], 0.05)
|
||||
if rlist:
|
||||
try:
|
||||
chunk = os.read(master_fd, 65536)
|
||||
except BlockingIOError:
|
||||
chunk = b""
|
||||
if chunk:
|
||||
buf += chunk
|
||||
buf = reply_queries(master_fd, buf)
|
||||
plain = strip_ansi(buf.decode("utf-8", "replace"))
|
||||
excerpt = first_meaningful_line(plain)
|
||||
if excerpt:
|
||||
ready = True
|
||||
if not probe_sent:
|
||||
try:
|
||||
os.write(master_fd, PROBE.encode())
|
||||
probe_sent = True
|
||||
except OSError:
|
||||
break
|
||||
if probe_sent and PROBE in plain:
|
||||
input_ready = True
|
||||
break
|
||||
if proc.poll() is not None:
|
||||
break
|
||||
if input_ready or ready:
|
||||
time.sleep(settle_s)
|
||||
elapsed = time.perf_counter() - start
|
||||
return SessionLaunch(
|
||||
root_pid=proc.pid,
|
||||
pgid=os.getpgid(proc.pid),
|
||||
master_fd=master_fd,
|
||||
ready=ready,
|
||||
input_ready=input_ready,
|
||||
excerpt=excerpt,
|
||||
seconds_to_visible=elapsed if ready else None,
|
||||
seconds_to_input_ready=elapsed if input_ready else None,
|
||||
buffer_excerpt=(strip_ansi(buf.decode("utf-8", "replace"))[:300] or None),
|
||||
)
|
||||
|
||||
|
||||
def iter_proc_stat() -> dict[int, tuple[int, int]]:
|
||||
out: dict[int, tuple[int, int]] = {}
|
||||
for entry in Path("/proc").iterdir():
|
||||
if not entry.name.isdigit():
|
||||
continue
|
||||
try:
|
||||
stat = (entry / "stat").read_text()
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
close = stat.rfind(")")
|
||||
rest = stat[close + 2 :].split()
|
||||
ppid = int(rest[1])
|
||||
pgid = int(rest[2])
|
||||
out[int(entry.name)] = (ppid, pgid)
|
||||
except Exception:
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def collect_descendants(root_pids: list[int]) -> set[int]:
|
||||
ppid_of = iter_proc_stat()
|
||||
children: dict[int, list[int]] = {}
|
||||
for pid, (ppid, _pgid) in ppid_of.items():
|
||||
children.setdefault(ppid, []).append(pid)
|
||||
seen: set[int] = set()
|
||||
stack = list(root_pids)
|
||||
while stack:
|
||||
pid = stack.pop()
|
||||
if pid in seen:
|
||||
continue
|
||||
seen.add(pid)
|
||||
stack.extend(children.get(pid, []))
|
||||
return seen
|
||||
|
||||
|
||||
def collect_process_group_pids(pgids: list[int]) -> set[int]:
|
||||
proc_map = iter_proc_stat()
|
||||
wanted = set(pgids)
|
||||
return {pid for pid, (_ppid, pgid) in proc_map.items() if pgid in wanted}
|
||||
|
||||
|
||||
def read_pss_mb(pid: int) -> float | None:
|
||||
path = Path(f"/proc/{pid}/smaps_rollup")
|
||||
try:
|
||||
for line in path.read_text().splitlines():
|
||||
if line.startswith("Pss:"):
|
||||
return int(line.split()[1]) / 1024.0
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def sum_tree_pss(root_pids: list[int], pgids: list[int]) -> tuple[float, int]:
|
||||
all_pids = collect_descendants(root_pids) | collect_process_group_pids(pgids)
|
||||
total = 0.0
|
||||
counted = 0
|
||||
for pid in sorted(all_pids):
|
||||
pss = read_pss_mb(pid)
|
||||
if pss is None:
|
||||
continue
|
||||
total += pss
|
||||
counted += 1
|
||||
return round(total, 1), counted
|
||||
|
||||
|
||||
def terminate_pgroup(pgid: int) -> None:
|
||||
for sig in (signal.SIGTERM, signal.SIGKILL):
|
||||
try:
|
||||
os.killpg(pgid, sig)
|
||||
time.sleep(0.2)
|
||||
except ProcessLookupError:
|
||||
return
|
||||
|
||||
|
||||
def version_for(spec: ToolSpec) -> str:
|
||||
proc = subprocess.run(spec.version_argv, capture_output=True, text=True, check=False)
|
||||
output = (proc.stdout + proc.stderr).strip().splitlines()
|
||||
return output[0] if output else f"exit {proc.returncode}"
|
||||
|
||||
|
||||
def run_tool(spec: ToolSpec, sessions: int, cwd: Path, timeout_s: float, settle_s: float) -> ToolRunResult:
|
||||
notes: list[str] = []
|
||||
version = version_for(spec)
|
||||
launches: list[SessionLaunch] = []
|
||||
cleanup_pgids: list[int] = []
|
||||
temp_root: str | None = None
|
||||
try:
|
||||
if spec.jcode:
|
||||
temp_root = tempfile.mkdtemp(prefix="jcode-memory-bench-")
|
||||
env = os.environ.copy()
|
||||
if spec.env:
|
||||
env.update(spec.env)
|
||||
env["JCODE_HOME"] = os.path.join(temp_root, "home")
|
||||
env["JCODE_RUNTIME_DIR"] = os.path.join(temp_root, "run")
|
||||
env["JCODE_TEMP_SERVER"] = "1"
|
||||
env["JCODE_SERVER_OWNER_PID"] = str(os.getpid())
|
||||
os.makedirs(env["JCODE_HOME"], exist_ok=True)
|
||||
os.makedirs(env["JCODE_RUNTIME_DIR"], exist_ok=True)
|
||||
for auth_name in (
|
||||
"anthropic-auth.json",
|
||||
"openai-auth.json",
|
||||
"antigravity_oauth.json",
|
||||
"gemini_oauth.json",
|
||||
"config.toml",
|
||||
):
|
||||
real_auth = Path.home() / ".jcode" / auth_name
|
||||
bench_auth = Path(env["JCODE_HOME"]) / auth_name
|
||||
if real_auth.exists() and not bench_auth.exists():
|
||||
bench_auth.symlink_to(real_auth)
|
||||
if spec.name == "jcode_memory_on":
|
||||
real_models = Path.home() / ".jcode" / "models"
|
||||
bench_models = Path(env["JCODE_HOME"]) / "models"
|
||||
if real_models.exists() and not bench_models.exists():
|
||||
bench_models.symlink_to(real_models)
|
||||
socket_path = os.path.join(env["JCODE_RUNTIME_DIR"], "bench.sock")
|
||||
server_proc = subprocess.Popen(
|
||||
[spec.argv[0], "--no-update", "--no-selfdev", "serve", "--socket", socket_path],
|
||||
cwd=str(cwd),
|
||||
env=env,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
preexec_fn=os.setsid,
|
||||
)
|
||||
cleanup_pgids.append(os.getpgid(server_proc.pid))
|
||||
if not wait_for_socket(socket_path, timeout_s):
|
||||
raise RuntimeError("jcode server did not become ready")
|
||||
if spec.name == "jcode_memory_on":
|
||||
time.sleep(max(settle_s, 5.0))
|
||||
per_session_settle = max(settle_s, 2.0) if spec.name == "jcode_memory_on" else settle_s
|
||||
for _ in range(sessions):
|
||||
launches.append(
|
||||
launch_interactive(
|
||||
[spec.argv[0], "--no-update", "--no-selfdev", "--socket", socket_path],
|
||||
cwd,
|
||||
env,
|
||||
timeout_s,
|
||||
per_session_settle,
|
||||
)
|
||||
)
|
||||
cleanup_pgids.append(launches[-1].pgid)
|
||||
root_pids = [server_proc.pid] + [launch.root_pid for launch in launches]
|
||||
sample_pgids = cleanup_pgids.copy()
|
||||
else:
|
||||
env = os.environ.copy()
|
||||
if spec.env:
|
||||
env.update(spec.env)
|
||||
for _ in range(sessions):
|
||||
launches.append(launch_interactive(spec.argv, cwd, env, timeout_s, settle_s))
|
||||
cleanup_pgids.append(launches[-1].pgid)
|
||||
root_pids = [launch.root_pid for launch in launches]
|
||||
sample_pgids = cleanup_pgids.copy()
|
||||
|
||||
for idx, launch in enumerate(launches, start=1):
|
||||
if not launch.ready:
|
||||
notes.append(f"session {idx}: no meaningful screen content before timeout")
|
||||
elif launch.excerpt:
|
||||
notes.append(f"session {idx}: {launch.excerpt}")
|
||||
pss_mb, process_count = sum_tree_pss(root_pids, sample_pgids)
|
||||
return ToolRunResult(
|
||||
tool=spec.name,
|
||||
sessions=sessions,
|
||||
pss_mb=pss_mb,
|
||||
process_count=process_count,
|
||||
version=version,
|
||||
notes=notes,
|
||||
)
|
||||
finally:
|
||||
for launch in launches:
|
||||
try:
|
||||
os.close(launch.master_fd)
|
||||
except Exception:
|
||||
pass
|
||||
for pgid in reversed(cleanup_pgids):
|
||||
terminate_pgroup(pgid)
|
||||
if temp_root:
|
||||
shutil.rmtree(temp_root, ignore_errors=True)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Benchmark interactive CLI memory using process-tree PSS")
|
||||
parser.add_argument("--sessions", type=int, required=True)
|
||||
parser.add_argument("--tools", nargs="*", default=DEFAULT_TOOLS)
|
||||
parser.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT_S)
|
||||
parser.add_argument("--settle", type=float, default=DEFAULT_SETTLE_S)
|
||||
parser.add_argument("--cwd", default=os.getcwd())
|
||||
parser.add_argument("--json-out", default=None)
|
||||
args = parser.parse_args()
|
||||
|
||||
specs = build_specs()
|
||||
cwd = Path(args.cwd).resolve()
|
||||
results = []
|
||||
for name in args.tools:
|
||||
spec = specs[name]
|
||||
print(f"=== {name} ({args.sessions} session{'s' if args.sessions != 1 else ''}) ===", flush=True)
|
||||
result = run_tool(spec, args.sessions, cwd, args.timeout, args.settle)
|
||||
print(json.dumps(asdict(result), indent=2), flush=True)
|
||||
results.append(asdict(result))
|
||||
payload = {"cwd": str(cwd), "sessions": args.sessions, "results": results}
|
||||
if args.json_out:
|
||||
Path(args.json_out).write_text(json.dumps(payload, indent=2))
|
||||
else:
|
||||
print(json.dumps(payload, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+116
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env bash
|
||||
# Repeatable self-dev build benchmark (issue #392, part C).
|
||||
#
|
||||
# Measures the wall-clock cost of the selfdev build paths that matter day to
|
||||
# day, so build-pipeline changes are hill-climbable:
|
||||
#
|
||||
# 1. warm no-op - rebuild with zero changes on the same commit
|
||||
# 2. leaf touch - 1-line change in the root `jcode` bin crate
|
||||
# 3. tui touch - 1-line change in jcode-tui (largest UI crate)
|
||||
# 4. core touch - 1-line change in jcode-app-core (mid-stack crate)
|
||||
# 5. commit blast - simulate HEAD moving (JCODE_BUILD_GIT_HASH change),
|
||||
# which reruns jcode-build-meta's build script and
|
||||
# recompiles every crate that depends on it
|
||||
# (base, app-core, tui, setup-hints, telemetry-core, root)
|
||||
#
|
||||
# Touches are reverted after each run. Requires a clean-enough tree that
|
||||
# `scripts/dev_cargo.sh build --profile selfdev -p jcode --bin jcode` succeeds.
|
||||
#
|
||||
# Usage: scripts/bench_selfdev_build.sh [--skip-warmup]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$repo_root"
|
||||
|
||||
build_cmd=(scripts/dev_cargo.sh build --profile selfdev -p jcode --bin jcode)
|
||||
|
||||
run_build() {
|
||||
# Per-phase logs survive later phases, so a mid-benchmark failure (often a
|
||||
# concurrent agent breaking the shared tree) stays diagnosable.
|
||||
local label="$1"
|
||||
local log="/tmp/bench_selfdev_build_${label}.log"
|
||||
local start end elapsed
|
||||
start=$(date +%s.%N)
|
||||
if ! "${build_cmd[@]}" >"$log" 2>&1; then
|
||||
echo " ${label}: BUILD FAILED (see ${log})"
|
||||
return 1
|
||||
fi
|
||||
end=$(date +%s.%N)
|
||||
elapsed=$(echo "$end $start" | awk '{printf "%.1f", $1 - $2}')
|
||||
echo " ${label}: ${elapsed}s"
|
||||
}
|
||||
|
||||
touch_file() {
|
||||
# Append and immediately strip a trailing comment marker so the file
|
||||
# content changes (mtime + hash) without altering semantics.
|
||||
local file="$1"
|
||||
printf '\n// bench_selfdev_build touch\n' >> "$file"
|
||||
}
|
||||
|
||||
revert_file() {
|
||||
local file="$1"
|
||||
# Remove the exact marker we appended (plus its preceding blank line).
|
||||
python3 - "$file" << 'EOF'
|
||||
import sys
|
||||
path = sys.argv[1]
|
||||
with open(path) as fh:
|
||||
content = fh.read()
|
||||
marker = "\n// bench_selfdev_build touch\n"
|
||||
if content.endswith(marker):
|
||||
content = content[: -len(marker)]
|
||||
with open(path, "w") as fh:
|
||||
fh.write(content)
|
||||
EOF
|
||||
}
|
||||
|
||||
echo "selfdev build benchmark ($(git rev-parse --short HEAD), $(nproc) cpus)"
|
||||
echo "command: ${build_cmd[*]}"
|
||||
echo
|
||||
|
||||
if [[ "${1:-}" != "--skip-warmup" ]]; then
|
||||
echo "warmup (populate incremental caches)..."
|
||||
run_build "warmup" || exit 1
|
||||
echo
|
||||
fi
|
||||
|
||||
echo "1. warm no-op (same commit, no changes):"
|
||||
run_build "no-op"
|
||||
|
||||
leaf_file="src/main.rs"
|
||||
echo "2. leaf touch (${leaf_file}):"
|
||||
touch_file "$leaf_file"
|
||||
run_build "leaf" || true
|
||||
revert_file "$leaf_file"
|
||||
|
||||
tui_file="crates/jcode-tui/src/lib.rs"
|
||||
echo "3. tui touch (${tui_file}):"
|
||||
touch_file "$tui_file"
|
||||
run_build "tui" || true
|
||||
revert_file "$tui_file"
|
||||
|
||||
core_file="crates/jcode-app-core/src/lib.rs"
|
||||
echo "4. core touch (${core_file}):"
|
||||
touch_file "$core_file"
|
||||
run_build "core" || true
|
||||
revert_file "$core_file"
|
||||
|
||||
echo "5. commit blast radius (simulated HEAD move via JCODE_BUILD_GIT_HASH):"
|
||||
fake_hash="bench$(date +%s | tail -c 4)"
|
||||
start=$(date +%s.%N)
|
||||
if JCODE_BUILD_GIT_HASH="$fake_hash" "${build_cmd[@]}" >/tmp/bench_selfdev_build_commit-blast.log 2>&1; then
|
||||
end=$(date +%s.%N)
|
||||
echo "$end $start" | awk '{printf " commit-blast: %.1fs\n", $1 - $2}'
|
||||
else
|
||||
echo " commit-blast: BUILD FAILED (see /tmp/bench_selfdev_build_commit-blast.log)"
|
||||
fi
|
||||
echo " (restoring real-hash build so the next selfdev build is warm)"
|
||||
run_build "restore" || true
|
||||
|
||||
echo
|
||||
echo "done. Interpretation:"
|
||||
echo " - no-op should be a few seconds (cargo fingerprinting + link check)."
|
||||
echo " - leaf/tui/core touches show per-crate recompile + link cost."
|
||||
echo " - commit-blast shows the cost every commit pays because"
|
||||
echo " jcode-build-meta embeds GIT_HASH via env! and jcode-base/app-core/"
|
||||
echo " tui/setup-hints/telemetry-core all depend on it."
|
||||
Executable
+207
@@ -0,0 +1,207 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
cd "$repo_root"
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage:
|
||||
scripts/bench_selfdev_checkpoints.sh [options]
|
||||
|
||||
Runs the standard compile checkpoints for the self-dev loop using scripts/bench_compile.sh.
|
||||
|
||||
Options:
|
||||
--touch <path> Source file to touch for warm edit-loop runs (default: src/server.rs)
|
||||
--runs <n> Number of warm runs per checkpoint (default: 3)
|
||||
--skip-cold Skip cold checkpoints and only run warm edit-loop measurements
|
||||
--json Print a single JSON object with all checkpoint summaries
|
||||
-h, --help Show this help
|
||||
|
||||
Checkpoints:
|
||||
cold_check cargo check after cargo clean
|
||||
warm_check_edit touched-file cargo check loop
|
||||
cold_selfdev_build selfdev jcode build after cargo clean
|
||||
warm_selfdev_edit touched-file selfdev jcode build loop
|
||||
USAGE
|
||||
}
|
||||
|
||||
runs=3
|
||||
touch_path="src/server.rs"
|
||||
json_output=0
|
||||
skip_cold=0
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--touch)
|
||||
if [[ $# -lt 2 ]]; then
|
||||
printf 'error: --touch requires a path\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
touch_path="$2"
|
||||
shift
|
||||
;;
|
||||
--runs)
|
||||
if [[ $# -lt 2 ]]; then
|
||||
printf 'error: --runs requires a positive integer\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
runs="$2"
|
||||
shift
|
||||
;;
|
||||
--json)
|
||||
json_output=1
|
||||
;;
|
||||
--skip-cold)
|
||||
skip_cold=1
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
printf 'error: unknown argument: %s\n' "$1" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
if ! [[ "$runs" =~ ^[1-9][0-9]*$ ]]; then
|
||||
printf 'error: --runs must be a positive integer (got %s)\n' "$runs" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -e "$touch_path" ]]; then
|
||||
printf 'error: touch path does not exist: %s\n' "$touch_path" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
run_bench() {
|
||||
local name="$1"
|
||||
shift
|
||||
|
||||
local stdout_file stderr_file status
|
||||
stdout_file=$(mktemp)
|
||||
stderr_file=$(mktemp)
|
||||
if scripts/bench_compile.sh "$@" --json >"$stdout_file" 2>"$stderr_file"; then
|
||||
python3 - "$name" "$stdout_file" <<'PY'
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
name = sys.argv[1]
|
||||
payload = json.loads(pathlib.Path(sys.argv[2]).read_text())
|
||||
payload["checkpoint"] = name
|
||||
payload["ok"] = True
|
||||
print(json.dumps(payload))
|
||||
PY
|
||||
else
|
||||
status=$?
|
||||
python3 - "$name" "$status" "$stderr_file" <<'PY'
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
name = sys.argv[1]
|
||||
status = int(sys.argv[2])
|
||||
stderr = pathlib.Path(sys.argv[3]).read_text().strip()
|
||||
print(json.dumps({
|
||||
"checkpoint": name,
|
||||
"ok": False,
|
||||
"exit_code": status,
|
||||
"error": stderr,
|
||||
}))
|
||||
PY
|
||||
fi
|
||||
rm -f "$stdout_file" "$stderr_file"
|
||||
}
|
||||
|
||||
cold_check_json=$(python3 - <<'PY' "$skip_cold"
|
||||
import json
|
||||
import sys
|
||||
skip = sys.argv[1] == "1"
|
||||
print(json.dumps({"checkpoint": "cold_check", "ok": None, "skipped": skip}))
|
||||
PY
|
||||
)
|
||||
cold_selfdev_json=$(python3 - <<'PY' "$skip_cold"
|
||||
import json
|
||||
import sys
|
||||
skip = sys.argv[1] == "1"
|
||||
print(json.dumps({"checkpoint": "cold_selfdev_build", "ok": None, "skipped": skip}))
|
||||
PY
|
||||
)
|
||||
|
||||
if [[ $skip_cold -eq 0 ]]; then
|
||||
cold_check_json=$(run_bench cold_check check --cold)
|
||||
cold_selfdev_json=$(run_bench cold_selfdev_build selfdev-jcode --cold)
|
||||
fi
|
||||
|
||||
warm_check_json=$(run_bench warm_check_edit check --runs "$runs" --touch "$touch_path")
|
||||
warm_selfdev_json=$(run_bench warm_selfdev_edit selfdev-jcode --runs "$runs" --touch "$touch_path")
|
||||
|
||||
summary_json=$(python3 - <<'PY' "$touch_path" "$runs" "$cold_check_json" "$warm_check_json" "$cold_selfdev_json" "$warm_selfdev_json"
|
||||
import json
|
||||
import sys
|
||||
|
||||
touch_path = sys.argv[1]
|
||||
runs = int(sys.argv[2])
|
||||
cold_check = json.loads(sys.argv[3])
|
||||
warm_check = json.loads(sys.argv[4])
|
||||
cold_selfdev = json.loads(sys.argv[5])
|
||||
warm_selfdev = json.loads(sys.argv[6])
|
||||
skip = bool(cold_check.get("skipped") and cold_selfdev.get("skipped"))
|
||||
|
||||
summary = {
|
||||
"touch_path": touch_path,
|
||||
"warm_runs": runs,
|
||||
"skip_cold": skip == True,
|
||||
"checkpoints": {
|
||||
"cold_check": cold_check,
|
||||
"warm_check_edit": warm_check,
|
||||
"cold_selfdev_build": cold_selfdev,
|
||||
"warm_selfdev_edit": warm_selfdev,
|
||||
},
|
||||
"failed_checkpoints": [
|
||||
name for name, payload in {
|
||||
"cold_check": cold_check,
|
||||
"warm_check_edit": warm_check,
|
||||
"cold_selfdev_build": cold_selfdev,
|
||||
"warm_selfdev_edit": warm_selfdev,
|
||||
}.items()
|
||||
if payload.get("ok") is False
|
||||
],
|
||||
}
|
||||
print(json.dumps(summary))
|
||||
PY
|
||||
)
|
||||
|
||||
if [[ $json_output -eq 1 ]]; then
|
||||
printf '%s\n' "$summary_json"
|
||||
else
|
||||
python3 - <<'PY' "$summary_json"
|
||||
import json
|
||||
import sys
|
||||
|
||||
summary = json.loads(sys.argv[1])
|
||||
print("selfdev compile checkpoints")
|
||||
print(f" touch_path: {summary['touch_path']}")
|
||||
print(f" warm_runs: {summary['warm_runs']}")
|
||||
print(f" skip_cold: {summary['skip_cold']}")
|
||||
for name, payload in summary["checkpoints"].items():
|
||||
if payload.get("skipped"):
|
||||
print(f" {name}: SKIPPED")
|
||||
elif payload.get("ok", False):
|
||||
print(
|
||||
f" {name}: min={payload['min_seconds']:.3f}s "
|
||||
f"median={payload['median_seconds']:.3f}s avg={payload['avg_seconds']:.3f}s "
|
||||
f"max={payload['max_seconds']:.3f}s"
|
||||
)
|
||||
else:
|
||||
print(
|
||||
f" {name}: FAILED exit={payload.get('exit_code')} error={payload.get('error', '')[:160]}"
|
||||
)
|
||||
if summary["failed_checkpoints"]:
|
||||
print(f" failed_checkpoints: {', '.join(summary['failed_checkpoints'])}")
|
||||
PY
|
||||
fi
|
||||
Executable
+367
@@ -0,0 +1,367 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Benchmark and optionally regression-check jcode startup time.
|
||||
|
||||
This script runs isolated startup measurements under a temporary JCODE_HOME and
|
||||
JCODE_RUNTIME_DIR so it does not interfere with the user's real server, logs, or
|
||||
credentials.
|
||||
|
||||
Cold client startup is measured by launching the normal default client path in a
|
||||
pseudo-terminal, then parsing the built-in startup profile written to the
|
||||
isolated log.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import socket
|
||||
import statistics
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
PROFILE_TOTAL_RE = re.compile(r"Startup Profile \(([0-9.]+)ms total\)")
|
||||
PROFILE_LINE_RE = re.compile(
|
||||
r"\[INFO\]\s+([0-9.]+)ms\s+([0-9.]+)ms\s+[0-9.]+%\s+([a-zA-Z0-9_]+)"
|
||||
)
|
||||
REMOTE_HISTORY_RE = re.compile(r"remote bootstrap: history after ([0-9.]+)ms")
|
||||
|
||||
|
||||
@dataclass
|
||||
class StartupProfile:
|
||||
total_ms: float
|
||||
deltas_ms: dict[str, float]
|
||||
remote_history_ms: float | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Budget:
|
||||
name: str
|
||||
actual_ms: float
|
||||
limit_ms: float
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("binary", nargs="?", default="./target/release/jcode")
|
||||
parser.add_argument("--runs", type=int, default=5, help="number of startup runs")
|
||||
parser.add_argument(
|
||||
"--check",
|
||||
action="store_true",
|
||||
help="fail if startup budgets are exceeded",
|
||||
)
|
||||
parser.add_argument("--max-help-ms", type=float, default=20.0)
|
||||
parser.add_argument("--max-version-ms", type=float, default=20.0)
|
||||
parser.add_argument("--max-server-ready-ms", type=float, default=80.0)
|
||||
parser.add_argument("--max-cold-total-ms", type=float, default=150.0)
|
||||
parser.add_argument("--max-cold-server-check-ms", type=float, default=20.0)
|
||||
parser.add_argument("--max-cold-server-spawn-ms", type=float, default=20.0)
|
||||
parser.add_argument("--max-cold-app-new-ms", type=float, default=20.0)
|
||||
parser.add_argument("--max-remote-bootstrap-history-ms", type=float, default=250.0)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def median(values: Iterable[float]) -> float:
|
||||
vals = list(values)
|
||||
if not vals:
|
||||
raise ValueError("no values")
|
||||
return statistics.median(vals)
|
||||
|
||||
|
||||
def median_or_none(values: Iterable[float]) -> float | None:
|
||||
vals = list(values)
|
||||
if not vals:
|
||||
return None
|
||||
return statistics.median(vals)
|
||||
|
||||
|
||||
def print_stats(name: str, times: list[float]) -> None:
|
||||
if not times:
|
||||
print(f"\n{name}: No successful runs")
|
||||
return
|
||||
print(f"\n{name}:")
|
||||
print(f" Min: {min(times):.2f} ms")
|
||||
print(f" Max: {max(times):.2f} ms")
|
||||
print(f" Mean: {statistics.mean(times):.2f} ms")
|
||||
print(f" Median: {statistics.median(times):.2f} ms")
|
||||
if len(times) > 1:
|
||||
print(f" Stdev: {statistics.stdev(times):.2f} ms")
|
||||
|
||||
|
||||
def run_simple_timing(binary: str, *args: str, runs: int) -> list[float]:
|
||||
times: list[float] = []
|
||||
for _ in range(runs):
|
||||
start = time.perf_counter()
|
||||
subprocess.run([binary, *args], capture_output=True, check=False)
|
||||
times.append((time.perf_counter() - start) * 1000)
|
||||
return times
|
||||
|
||||
|
||||
def isolated_env(root: str) -> dict[str, str]:
|
||||
env = os.environ.copy()
|
||||
env["JCODE_HOME"] = os.path.join(root, "home")
|
||||
env["JCODE_RUNTIME_DIR"] = os.path.join(root, "run")
|
||||
env["JCODE_SOCKET"] = os.path.join(env["JCODE_RUNTIME_DIR"], "jcode.sock")
|
||||
env["JCODE_NO_TELEMETRY"] = "1"
|
||||
os.makedirs(env["JCODE_HOME"], exist_ok=True)
|
||||
os.makedirs(env["JCODE_RUNTIME_DIR"], exist_ok=True)
|
||||
return env
|
||||
|
||||
|
||||
def wait_for_socket(path: str, timeout_s: float) -> bool:
|
||||
deadline = time.perf_counter() + timeout_s
|
||||
while time.perf_counter() < deadline:
|
||||
if os.path.exists(path):
|
||||
try:
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
sock.connect(path)
|
||||
sock.close()
|
||||
return True
|
||||
except OSError:
|
||||
pass
|
||||
time.sleep(0.005)
|
||||
return False
|
||||
|
||||
|
||||
def measure_server_startup(binary: str, runs: int) -> list[float]:
|
||||
times: list[float] = []
|
||||
for _ in range(runs):
|
||||
root = tempfile.mkdtemp(prefix="jcode-server-bench-")
|
||||
env = isolated_env(root)
|
||||
socket_path = env["JCODE_SOCKET"]
|
||||
proc = None
|
||||
try:
|
||||
start = time.perf_counter()
|
||||
proc = subprocess.Popen(
|
||||
[binary, "serve"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
env=env,
|
||||
)
|
||||
if wait_for_socket(socket_path, 5.0):
|
||||
times.append((time.perf_counter() - start) * 1000)
|
||||
finally:
|
||||
if proc is not None:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait(timeout=2)
|
||||
shutil.rmtree(root, ignore_errors=True)
|
||||
return times
|
||||
|
||||
|
||||
def require_script_binary() -> str:
|
||||
script_bin = shutil.which("script")
|
||||
if not script_bin:
|
||||
raise RuntimeError("'script' utility not found; required for TTY startup benchmark")
|
||||
return script_bin
|
||||
|
||||
|
||||
def parse_startup_profile(log_path: Path) -> StartupProfile:
|
||||
lines = log_path.read_text().splitlines()
|
||||
last_block: list[str] = []
|
||||
remote_history_ms = None
|
||||
for i, line in enumerate(lines):
|
||||
if "=== Startup Profile (" in line:
|
||||
last_block = lines[i : i + 40]
|
||||
remote_match = REMOTE_HISTORY_RE.search(line)
|
||||
if remote_match:
|
||||
remote_history_ms = float(remote_match.group(1))
|
||||
|
||||
if not last_block:
|
||||
raise RuntimeError(f"no startup profile found in {log_path}")
|
||||
|
||||
total_ms = None
|
||||
deltas: dict[str, float] = {}
|
||||
for line in last_block:
|
||||
total_match = PROFILE_TOTAL_RE.search(line)
|
||||
if total_match:
|
||||
total_ms = float(total_match.group(1))
|
||||
phase_match = PROFILE_LINE_RE.search(line)
|
||||
if phase_match:
|
||||
_from_start, delta_ms, name = phase_match.groups()
|
||||
deltas[name] = float(delta_ms)
|
||||
|
||||
if total_ms is None:
|
||||
raise RuntimeError(f"could not parse startup profile total from {log_path}")
|
||||
|
||||
return StartupProfile(
|
||||
total_ms=total_ms,
|
||||
deltas_ms=deltas,
|
||||
remote_history_ms=remote_history_ms,
|
||||
)
|
||||
|
||||
|
||||
def measure_cold_client_startup(binary: str, runs: int) -> list[StartupProfile]:
|
||||
script_bin = require_script_binary()
|
||||
profiles: list[StartupProfile] = []
|
||||
|
||||
for _ in range(runs):
|
||||
root = tempfile.mkdtemp(prefix="jcode-cold-bench-")
|
||||
env = isolated_env(root)
|
||||
log_path = Path(env["JCODE_HOME"]) / "logs" / f"jcode-{time.strftime('%Y-%m-%d')}.log"
|
||||
try:
|
||||
command = (
|
||||
f"{binary} --no-update --debug-socket "
|
||||
f"--socket {env['JCODE_SOCKET']}"
|
||||
)
|
||||
subprocess.run(
|
||||
["timeout", "3s", script_bin, "-qefc", command, "/dev/null"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
env=env,
|
||||
check=False,
|
||||
)
|
||||
profiles.append(parse_startup_profile(log_path))
|
||||
finally:
|
||||
shutil.rmtree(root, ignore_errors=True)
|
||||
|
||||
return profiles
|
||||
|
||||
|
||||
def print_cold_profile_stats(profiles: list[StartupProfile]) -> None:
|
||||
totals = [p.total_ms for p in profiles]
|
||||
print_stats("Cold client startup total", totals)
|
||||
|
||||
for phase in ["server_check", "server_spawn_start", "server_ready", "app_new_for_remote"]:
|
||||
values = [p.deltas_ms[phase] for p in profiles if phase in p.deltas_ms]
|
||||
print_stats(f"Cold client phase: {phase}", values)
|
||||
|
||||
remote_history = [p.remote_history_ms for p in profiles if p.remote_history_ms is not None]
|
||||
print_stats("Cold client phase: remote bootstrap history", remote_history)
|
||||
|
||||
|
||||
def collect_budgets(
|
||||
help_times: list[float],
|
||||
version_times: list[float],
|
||||
server_times: list[float],
|
||||
cold_profiles: list[StartupProfile],
|
||||
args: argparse.Namespace,
|
||||
) -> list[Budget]:
|
||||
cold_total = [p.total_ms for p in cold_profiles]
|
||||
cold_server_check = [p.deltas_ms.get("server_check", 0.0) for p in cold_profiles]
|
||||
cold_server_spawn = [p.deltas_ms.get("server_spawn_start", 0.0) for p in cold_profiles]
|
||||
cold_app_new = [p.deltas_ms.get("app_new_for_remote", 0.0) for p in cold_profiles]
|
||||
cold_remote_history = [
|
||||
p.remote_history_ms for p in cold_profiles if p.remote_history_ms is not None
|
||||
]
|
||||
|
||||
budgets = [
|
||||
Budget("--help median", median(help_times), args.max_help_ms),
|
||||
Budget("--version median", median(version_times), args.max_version_ms),
|
||||
Budget("cold startup total median", median(cold_total), args.max_cold_total_ms),
|
||||
Budget(
|
||||
"cold startup server_check median",
|
||||
median(cold_server_check),
|
||||
args.max_cold_server_check_ms,
|
||||
),
|
||||
Budget(
|
||||
"cold startup server_spawn_start median",
|
||||
median(cold_server_spawn),
|
||||
args.max_cold_server_spawn_ms,
|
||||
),
|
||||
Budget(
|
||||
"cold startup app_new_for_remote median",
|
||||
median(cold_app_new),
|
||||
args.max_cold_app_new_ms,
|
||||
),
|
||||
]
|
||||
cold_remote_history_median = median_or_none(cold_remote_history)
|
||||
if cold_remote_history_median is not None:
|
||||
budgets.append(
|
||||
Budget(
|
||||
"cold startup remote bootstrap history median",
|
||||
cold_remote_history_median,
|
||||
args.max_remote_bootstrap_history_ms,
|
||||
)
|
||||
)
|
||||
server_ready_median = median_or_none(server_times)
|
||||
if server_ready_median is not None:
|
||||
budgets.insert(2, Budget("server ready median", server_ready_median, args.max_server_ready_ms))
|
||||
return budgets
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
binary = args.binary
|
||||
|
||||
if not os.path.exists(binary):
|
||||
print(f"Binary not found: {binary}")
|
||||
print("Run: cargo build --release")
|
||||
return 1
|
||||
|
||||
print(f"Benchmarking: {binary}")
|
||||
print("=" * 60)
|
||||
|
||||
subprocess.run([binary, "--version"], capture_output=True, check=False)
|
||||
|
||||
help_times = run_simple_timing(binary, "--help", runs=args.runs)
|
||||
print_stats("--help (binary load)", help_times)
|
||||
|
||||
version_times = run_simple_timing(binary, "--version", runs=args.runs)
|
||||
print_stats("--version", version_times)
|
||||
|
||||
print(f"\nMeasuring isolated server startup ({args.runs} runs)...")
|
||||
server_times = measure_server_startup(binary, args.runs)
|
||||
print_stats("Server ready (isolated socket connectable)", server_times)
|
||||
|
||||
print(f"\nMeasuring isolated cold client startup ({args.runs} runs)...")
|
||||
cold_profiles = measure_cold_client_startup(binary, args.runs)
|
||||
print_cold_profile_stats(cold_profiles)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Summary:")
|
||||
help_median = median_or_none(help_times)
|
||||
server_median = median_or_none(server_times)
|
||||
cold_median = median_or_none(p.total_ms for p in cold_profiles)
|
||||
remote_history_median = median_or_none(
|
||||
p.remote_history_ms for p in cold_profiles if p.remote_history_ms is not None
|
||||
)
|
||||
print(
|
||||
f" Binary load median: ~{help_median:.1f} ms"
|
||||
if help_median is not None
|
||||
else " Binary load median: n/a"
|
||||
)
|
||||
print(
|
||||
f" Server ready median: ~{server_median:.1f} ms"
|
||||
if server_median is not None
|
||||
else " Server ready median: n/a"
|
||||
)
|
||||
print(
|
||||
f" Cold client total median:~{cold_median:.1f} ms"
|
||||
if cold_median is not None
|
||||
else " Cold client total median:n/a"
|
||||
)
|
||||
print(
|
||||
f" Remote history median: ~{remote_history_median:.1f} ms"
|
||||
if remote_history_median is not None
|
||||
else " Remote history median: n/a"
|
||||
)
|
||||
|
||||
budgets = collect_budgets(help_times, version_times, server_times, cold_profiles, args)
|
||||
if args.check:
|
||||
failures = [b for b in budgets if b.actual_ms > b.limit_ms]
|
||||
print("\nBudget check:")
|
||||
for budget in budgets:
|
||||
status = "FAIL" if budget.actual_ms > budget.limit_ms else "PASS"
|
||||
print(
|
||||
f" [{status}] {budget.name}: {budget.actual_ms:.1f} ms <= {budget.limit_ms:.1f} ms"
|
||||
)
|
||||
if failures:
|
||||
print("\nStartup regression detected.")
|
||||
return 2
|
||||
print("\nAll startup budgets passed.")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+330
@@ -0,0 +1,330 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Benchmark interactive CLI startup using user-visible metrics.
|
||||
|
||||
Measures two UX-focused metrics for interactive PTY launches:
|
||||
1. time to first visible content
|
||||
2. time until typed probe text appears on the rendered screen (input-ready)
|
||||
|
||||
The benchmark drives a pseudo-terminal, answers common terminal capability
|
||||
queries, renders the output through a terminal screen model, and detects when
|
||||
meaningful text becomes visible.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import pty
|
||||
import select
|
||||
import signal
|
||||
import statistics
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import termios
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import fcntl
|
||||
except ImportError as exc: # pragma: no cover
|
||||
raise SystemExit(f"fcntl unavailable: {exc}")
|
||||
|
||||
try:
|
||||
import pyte
|
||||
except ImportError as exc: # pragma: no cover
|
||||
raise SystemExit(
|
||||
"pyte is required. Install with: python3 -m pip install pyte\n"
|
||||
f"Import error: {exc}"
|
||||
)
|
||||
|
||||
PROBE = "jqx92"
|
||||
DEFAULT_RUNS = 10
|
||||
DEFAULT_TIMEOUT_S = 10.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolSpec:
|
||||
name: str
|
||||
argv: list[str]
|
||||
no_telem_env: dict[str, str] | None = None
|
||||
disable_selfdev: bool = False
|
||||
input_ready_log_marker: str | None = None
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--runs", type=int, default=DEFAULT_RUNS)
|
||||
parser.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT_S)
|
||||
parser.add_argument("--cwd", default=os.getcwd())
|
||||
parser.add_argument(
|
||||
"--tools",
|
||||
nargs="*",
|
||||
default=None,
|
||||
help="subset of tool names to benchmark",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--json-out",
|
||||
default="/var/tmp/startup_visible_ready_results.json",
|
||||
help="where to write the JSON results",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def detect_pi_bin() -> str:
|
||||
pi = shutil_which("pi")
|
||||
if pi:
|
||||
return pi
|
||||
prefix = subprocess.check_output(["npm", "prefix", "-g"], text=True).strip()
|
||||
candidate = Path(prefix) / "bin" / "pi"
|
||||
if candidate.exists():
|
||||
return str(candidate)
|
||||
raise FileNotFoundError("could not find pi binary")
|
||||
|
||||
|
||||
def shutil_which(name: str) -> str | None:
|
||||
return subprocess.run(
|
||||
["bash", "-lc", f"command -v {name}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
).stdout.strip() or None
|
||||
|
||||
|
||||
def build_tool_specs() -> list[ToolSpec]:
|
||||
specs = [
|
||||
ToolSpec(
|
||||
name="jcode",
|
||||
argv=["jcode", "--no-update", "--no-selfdev"],
|
||||
no_telem_env={"JCODE_NO_TELEMETRY": "1"},
|
||||
disable_selfdev=True,
|
||||
),
|
||||
ToolSpec(name="pi", argv=[detect_pi_bin()]),
|
||||
ToolSpec(name="opencode", argv=["opencode"]),
|
||||
ToolSpec(name="codex", argv=["codex"]),
|
||||
ToolSpec(name="claude_code", argv=["claude"]),
|
||||
ToolSpec(name="cursor_agent", argv=["cursor-agent"]),
|
||||
ToolSpec(name="copilot_cli", argv=["copilot"]),
|
||||
ToolSpec(
|
||||
name="antigravity_cli",
|
||||
argv=["agy"],
|
||||
no_telem_env={"AGY_CLI_DISABLE_AUTO_UPDATE": "1"},
|
||||
input_ready_log_marker="CLI ready for user input",
|
||||
),
|
||||
]
|
||||
return specs
|
||||
|
||||
|
||||
def configure_pty(slave_fd: int, rows: int = 24, cols: int = 80) -> None:
|
||||
fcntl.ioctl(slave_fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
|
||||
attrs = termios.tcgetattr(slave_fd)
|
||||
attrs[3] &= ~(termios.ECHO | termios.ICANON)
|
||||
attrs[0] &= ~(termios.ICRNL | termios.IXON)
|
||||
termios.tcsetattr(slave_fd, termios.TCSANOW, attrs)
|
||||
|
||||
|
||||
def reply_queries(master_fd: int, buffer: bytes) -> bytes:
|
||||
replies = [
|
||||
(b"\x1b[6n", b"\x1b[1;1R"),
|
||||
(b"\x1b[c", b"\x1b[?62;c"),
|
||||
(b"\x1b]10;?\x1b\\", b"\x1b]10;rgb:ffff/ffff/ffff\x1b\\"),
|
||||
(b"\x1b]11;?\x1b\\", b"\x1b]11;rgb:0000/0000/0000\x1b\\"),
|
||||
(b"\x1b]10;?\x07", b"\x1b]10;rgb:ffff/ffff/ffff\x07"),
|
||||
(b"\x1b]11;?\x07", b"\x1b]11;rgb:0000/0000/0000\x07"),
|
||||
(b"\x1b]4;0;?\x07", b"\x1b]4;0;rgb:0000/0000/0000\x07"),
|
||||
(b"\x1b[14t", b"\x1b[4;600;800t"),
|
||||
(b"\x1b[16t", b"\x1b[6;16;8t"),
|
||||
(b"\x1b[18t", b"\x1b[8;24;80t"),
|
||||
(b"\x1b[?1016$p", b"\x1b[?1016;1$y"),
|
||||
(b"\x1b[?2027$p", b"\x1b[?2027;1$y"),
|
||||
(b"\x1b[?2031$p", b"\x1b[?2031;1$y"),
|
||||
(b"\x1b[?1004$p", b"\x1b[?1004;1$y"),
|
||||
(b"\x1b[?2004$p", b"\x1b[?2004;1$y"),
|
||||
(b"\x1b[?2026$p", b"\x1b[?2026;1$y"),
|
||||
]
|
||||
changed = True
|
||||
while changed:
|
||||
changed = False
|
||||
for query, response in replies:
|
||||
if query in buffer:
|
||||
os.write(master_fd, response)
|
||||
buffer = buffer.replace(query, b"")
|
||||
changed = True
|
||||
return buffer
|
||||
|
||||
|
||||
def first_meaningful_line(screen: pyte.Screen) -> str | None:
|
||||
for line in screen.display:
|
||||
normalized = " ".join(line.split())
|
||||
if not normalized or PROBE in normalized:
|
||||
continue
|
||||
alnum_count = sum(ch.isalnum() for ch in normalized)
|
||||
if alnum_count >= 3 and len(normalized) >= 4:
|
||||
return normalized[:120]
|
||||
return None
|
||||
|
||||
|
||||
def run_once(spec: ToolSpec, cwd: Path, timeout_s: float) -> dict[str, object]:
|
||||
master_fd, slave_fd = pty.openpty()
|
||||
configure_pty(slave_fd)
|
||||
env = os.environ.copy()
|
||||
env["TERM"] = "xterm-256color"
|
||||
env["COLORTERM"] = "truecolor"
|
||||
if spec.no_telem_env:
|
||||
env.update(spec.no_telem_env)
|
||||
argv = spec.argv
|
||||
input_ready_log_path: Path | None = None
|
||||
if spec.input_ready_log_marker:
|
||||
log_file = tempfile.NamedTemporaryFile(prefix=f"{spec.name}-", suffix=".log", delete=False)
|
||||
input_ready_log_path = Path(log_file.name)
|
||||
log_file.close()
|
||||
argv = [*spec.argv, "--log-file", str(input_ready_log_path)]
|
||||
proc = subprocess.Popen(
|
||||
argv,
|
||||
cwd=str(cwd),
|
||||
env=env,
|
||||
stdin=slave_fd,
|
||||
stdout=slave_fd,
|
||||
stderr=slave_fd,
|
||||
preexec_fn=os.setsid,
|
||||
)
|
||||
os.close(slave_fd)
|
||||
os.set_blocking(master_fd, False)
|
||||
|
||||
screen = pyte.Screen(80, 24)
|
||||
stream = pyte.Stream(screen)
|
||||
start = time.perf_counter()
|
||||
query_buffer = b""
|
||||
first_visible_ms: float | None = None
|
||||
first_visible_excerpt: str | None = None
|
||||
input_ready_ms: float | None = None
|
||||
probe_sent = False
|
||||
|
||||
try:
|
||||
while time.perf_counter() - start < timeout_s:
|
||||
rlist, _, _ = select.select([master_fd], [], [], 0.05)
|
||||
if rlist:
|
||||
try:
|
||||
chunk = os.read(master_fd, 65536)
|
||||
except BlockingIOError:
|
||||
chunk = b""
|
||||
if chunk:
|
||||
query_buffer += chunk
|
||||
query_buffer = reply_queries(master_fd, query_buffer)
|
||||
stream.feed(chunk.decode("utf-8", "replace"))
|
||||
if first_visible_ms is None:
|
||||
excerpt = first_meaningful_line(screen)
|
||||
if excerpt:
|
||||
first_visible_ms = (time.perf_counter() - start) * 1000.0
|
||||
first_visible_excerpt = excerpt
|
||||
os.write(master_fd, PROBE.encode())
|
||||
probe_sent = True
|
||||
elif probe_sent and input_ready_ms is None:
|
||||
if PROBE in "\n".join(screen.display):
|
||||
input_ready_ms = (time.perf_counter() - start) * 1000.0
|
||||
break
|
||||
if (
|
||||
spec.input_ready_log_marker
|
||||
and first_visible_ms is not None
|
||||
and input_ready_ms is None
|
||||
and input_ready_log_path
|
||||
):
|
||||
try:
|
||||
if spec.input_ready_log_marker in input_ready_log_path.read_text(errors="replace"):
|
||||
input_ready_ms = (time.perf_counter() - start) * 1000.0
|
||||
break
|
||||
except OSError:
|
||||
pass
|
||||
return {
|
||||
"first_visible_ms": first_visible_ms,
|
||||
"first_visible_excerpt": first_visible_excerpt,
|
||||
"input_ready_ms": input_ready_ms,
|
||||
"input_ready_source": "log_marker" if spec.input_ready_log_marker else "probe_echo",
|
||||
}
|
||||
finally:
|
||||
for sig in (signal.SIGTERM, signal.SIGKILL):
|
||||
try:
|
||||
os.killpg(proc.pid, sig)
|
||||
time.sleep(0.1)
|
||||
except ProcessLookupError:
|
||||
break
|
||||
try:
|
||||
proc.wait(timeout=1)
|
||||
except Exception:
|
||||
pass
|
||||
os.close(master_fd)
|
||||
if input_ready_log_path:
|
||||
try:
|
||||
input_ready_log_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def summarize(samples: list[float | None]) -> dict[str, float | int] | None:
|
||||
values = [sample for sample in samples if sample is not None]
|
||||
if not values:
|
||||
return None
|
||||
return {
|
||||
"median_ms": statistics.median(values),
|
||||
"min_ms": min(values),
|
||||
"max_ms": max(values),
|
||||
"mean_ms": statistics.mean(values),
|
||||
"runs_completed": len(values),
|
||||
"runs_total": len(samples),
|
||||
}
|
||||
|
||||
|
||||
def version_for(spec: ToolSpec) -> str:
|
||||
argv = spec.argv[:1]
|
||||
if spec.name == "jcode":
|
||||
argv = [spec.argv[0], "version"]
|
||||
else:
|
||||
argv = [spec.argv[0], "--version"]
|
||||
proc = subprocess.run(argv, capture_output=True, text=True, check=False)
|
||||
output = (proc.stdout + proc.stderr).strip().splitlines()
|
||||
return output[0] if output else f"exit {proc.returncode}"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
selected = set(args.tools or [])
|
||||
specs = build_tool_specs()
|
||||
if selected:
|
||||
specs = [spec for spec in specs if spec.name in selected]
|
||||
cwd = Path(args.cwd).resolve()
|
||||
|
||||
results: dict[str, object] = {
|
||||
"runs": args.runs,
|
||||
"timeout_s": args.timeout,
|
||||
"cwd": str(cwd),
|
||||
"tools": {},
|
||||
}
|
||||
for spec in specs:
|
||||
print(f"=== {spec.name} ===", flush=True)
|
||||
runs: list[dict[str, object]] = []
|
||||
for i in range(args.runs):
|
||||
run = run_once(spec, cwd, args.timeout)
|
||||
runs.append(run)
|
||||
print(
|
||||
f"run {i + 1}/{args.runs}: "
|
||||
f"visible={run['first_visible_ms']} ready={run['input_ready_ms']} "
|
||||
f"excerpt={run['first_visible_excerpt']}",
|
||||
flush=True,
|
||||
)
|
||||
results["tools"][spec.name] = {
|
||||
"version": version_for(spec),
|
||||
"runs": runs,
|
||||
"first_visible_summary": summarize([r["first_visible_ms"] for r in runs]),
|
||||
"input_ready_summary": summarize([r["input_ready_ms"] for r in runs]),
|
||||
}
|
||||
|
||||
out_path = Path(args.json_out)
|
||||
out_path.write_text(json.dumps(results, indent=2))
|
||||
print(f"WROTE {out_path}", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+588
@@ -0,0 +1,588 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Benchmark: single agent vs swarm on the Anthropic Performance Take-Home.
|
||||
|
||||
Compares jcode's swarm (multi-agent coordination) with single-agent performance
|
||||
on the VLIW SIMD kernel optimization challenge.
|
||||
|
||||
Usage:
|
||||
python scripts/benchmark_swarm.py # Run both trials
|
||||
python scripts/benchmark_swarm.py --single-only # Single agent only
|
||||
python scripts/benchmark_swarm.py --swarm-only # Swarm only
|
||||
python scripts/benchmark_swarm.py --timeout 30 # 30 minute timeout per trial
|
||||
python scripts/benchmark_swarm.py --check-interval 15 # Check cycles every 15s
|
||||
|
||||
Environment:
|
||||
Requires jcode server running with debug_control enabled:
|
||||
touch ~/.jcode/debug_control
|
||||
jcode serve
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import select
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
DEBUG_SOCKET = f"/run/user/{os.getuid()}/jcode-debug.sock"
|
||||
MAIN_SOCKET = f"/run/user/{os.getuid()}/jcode.sock"
|
||||
TAKEHOME_SOURCE = os.environ.get(
|
||||
"TAKEHOME_SOURCE", str(Path.home() / "original_performance_takehome")
|
||||
)
|
||||
BENCHMARK_DIR = "/tmp/takehome-benchmark"
|
||||
BASELINE = 147734
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Socket helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def send_cmd(cmd: str, session_id: str = None, timeout: float = 300) -> tuple:
|
||||
"""Send a debug command and return (ok, output, error)."""
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
sock.connect(DEBUG_SOCKET)
|
||||
sock.setblocking(False)
|
||||
|
||||
req = {"type": "debug_command", "id": 1, "command": cmd}
|
||||
if session_id:
|
||||
req["session_id"] = session_id
|
||||
|
||||
sock.send((json.dumps(req) + "\n").encode())
|
||||
|
||||
start = time.time()
|
||||
data = b""
|
||||
while time.time() - start < timeout:
|
||||
ready, _, _ = select.select([sock], [], [], 1.0)
|
||||
if ready:
|
||||
try:
|
||||
chunk = sock.recv(65536)
|
||||
if not chunk:
|
||||
break
|
||||
data += chunk
|
||||
if b"\n" in data:
|
||||
break
|
||||
except BlockingIOError:
|
||||
continue
|
||||
|
||||
sock.close()
|
||||
|
||||
if not data:
|
||||
return False, "", "Timeout"
|
||||
|
||||
try:
|
||||
resp = json.loads(data.decode().strip())
|
||||
return resp.get("ok", False), resp.get("output", ""), resp.get("error", "")
|
||||
except json.JSONDecodeError as e:
|
||||
return False, "", f"JSON error: {e}"
|
||||
|
||||
|
||||
def create_session(working_dir: str) -> tuple:
|
||||
"""Create a headless session. Returns (session_id, friendly_name)."""
|
||||
ok, output, err = send_cmd(f"create_session:{working_dir}", timeout=120)
|
||||
if not ok:
|
||||
raise RuntimeError(f"Failed to create session: {err}")
|
||||
data = json.loads(output)
|
||||
return data["session_id"], data.get("friendly_name", data["session_id"][:12])
|
||||
|
||||
|
||||
def destroy_session(session_id: str):
|
||||
"""Destroy a session."""
|
||||
send_cmd(f"destroy_session:{session_id}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Workspace helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def setup_workspace(name: str) -> str:
|
||||
"""Create a clean copy of the take-home challenge."""
|
||||
workspace = os.path.join(BENCHMARK_DIR, name)
|
||||
if os.path.exists(workspace):
|
||||
shutil.rmtree(workspace)
|
||||
shutil.copytree(TAKEHOME_SOURCE, workspace)
|
||||
# Initialize a git repo so swarm_id detection works
|
||||
subprocess.run(["git", "init"], cwd=workspace, capture_output=True)
|
||||
subprocess.run(["git", "add", "."], cwd=workspace, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "initial"],
|
||||
cwd=workspace,
|
||||
capture_output=True,
|
||||
env={**os.environ, "GIT_AUTHOR_NAME": "bench", "GIT_AUTHOR_EMAIL": "b@b",
|
||||
"GIT_COMMITTER_NAME": "bench", "GIT_COMMITTER_EMAIL": "b@b"},
|
||||
)
|
||||
return workspace
|
||||
|
||||
|
||||
def get_cycles(workspace: str) -> int:
|
||||
"""Run submission_tests.py and extract cycle count."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "tests/submission_tests.py", "-v"],
|
||||
cwd=workspace,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
for line in (result.stdout + result.stderr).split("\n"):
|
||||
if "CYCLES:" in line:
|
||||
return int(line.split("CYCLES:")[1].strip())
|
||||
except Exception as e:
|
||||
print(f" Error getting cycles: {e}")
|
||||
return BASELINE
|
||||
|
||||
|
||||
def get_test_summary(workspace: str) -> str:
|
||||
"""Run submission tests and return the full output summary."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "tests/submission_tests.py", "-v"],
|
||||
cwd=workspace,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
return result.stdout + result.stderr
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Optimization prompt
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
OPTIMIZATION_PROMPT_TEMPLATE = """Optimize the build_kernel() method in perf_takehome.py to minimize cycle count \
|
||||
on the VLIW SIMD machine simulator. The baseline is 147,734 cycles.
|
||||
|
||||
IMPORTANT: You MUST work in this directory: {workspace}
|
||||
All file paths should be relative to or within this directory.
|
||||
|
||||
Key files (in {workspace}):
|
||||
- problem.py: Defines the Machine, instruction set, slot limits, engines
|
||||
- perf_takehome.py: Contains KernelBuilder.build_kernel() - THIS is what you optimize
|
||||
- tests/submission_tests.py: Run to verify correctness and see cycle count. DO NOT modify tests/.
|
||||
|
||||
Machine details (read problem.py for full spec):
|
||||
- VLEN=8 vector width, N_CORES=1
|
||||
- VLIW bundles: multiple operations per cycle, subject to slot limits per engine
|
||||
- Engines: load, store, alu, flow, debug, vload, vstore, valu
|
||||
- Scratch memory for temporaries (SCRATCH_SIZE limit)
|
||||
|
||||
Focus on these optimization strategies:
|
||||
1. Vectorization - use VALU/VLOAD/VSTORE engines with VLEN=8 to process 8 elements at once
|
||||
2. VLIW instruction packing - bundle independent operations into the same cycle
|
||||
3. Loop structure - unroll loops, reduce iteration overhead
|
||||
4. Hash function optimization - it runs many times; pack hash stages
|
||||
5. Efficient memory access patterns - batch loads/stores, reduce address computation
|
||||
|
||||
After each change, verify with: cd {workspace} && python tests/submission_tests.py
|
||||
|
||||
Work efficiently - focus on the highest-impact optimizations first."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Poll loop for async jobs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def poll_job(
|
||||
job_id: str,
|
||||
session_id: str,
|
||||
workspace: str,
|
||||
start_time: float,
|
||||
timeout_seconds: float,
|
||||
check_interval: float,
|
||||
label: str,
|
||||
) -> int:
|
||||
"""Poll a job until completion, printing cycle updates. Returns best cycle count."""
|
||||
best_cycles = BASELINE
|
||||
last_cycles = BASELINE
|
||||
|
||||
while time.time() - start_time < timeout_seconds:
|
||||
elapsed = time.time() - start_time
|
||||
|
||||
# Check job status
|
||||
ok, status_output, _ = send_cmd(f"job_status:{job_id}", session_id, timeout=10)
|
||||
if ok:
|
||||
try:
|
||||
status = json.loads(status_output)
|
||||
job_status = status.get("status", "unknown")
|
||||
if job_status == "completed":
|
||||
print(f"\n [{label}] [{elapsed/60:.1f}m] Job completed")
|
||||
break
|
||||
elif job_status == "failed":
|
||||
error = status.get("error", "unknown")
|
||||
print(f"\n [{label}] [{elapsed/60:.1f}m] Job failed: {error}")
|
||||
break
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
pass
|
||||
|
||||
# Check current cycles in workspace
|
||||
cycles = get_cycles(workspace)
|
||||
if cycles < best_cycles:
|
||||
best_cycles = cycles
|
||||
speedup = BASELINE / cycles
|
||||
print(f" [{label}] [{elapsed/60:.1f}m] NEW BEST: {cycles} cycles ({speedup:.2f}x speedup)")
|
||||
elif cycles != last_cycles:
|
||||
print(f" [{label}] [{elapsed/60:.1f}m] Cycles: {cycles}")
|
||||
last_cycles = cycles
|
||||
|
||||
time.sleep(check_interval)
|
||||
|
||||
# Final check
|
||||
cycles = get_cycles(workspace)
|
||||
if cycles < best_cycles:
|
||||
best_cycles = cycles
|
||||
|
||||
return best_cycles
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Trial A: Single Agent
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def run_single_agent(timeout_minutes: float, check_interval: float) -> dict:
|
||||
"""Run a single agent on the optimization task."""
|
||||
print("\n" + "=" * 70)
|
||||
print(f" TRIAL A: SINGLE AGENT (timeout: {timeout_minutes}m)")
|
||||
print("=" * 70)
|
||||
|
||||
workspace = setup_workspace("single")
|
||||
print(f" Workspace: {workspace}")
|
||||
|
||||
start_time = time.time()
|
||||
session_id = None
|
||||
|
||||
try:
|
||||
session_id, name = create_session(workspace)
|
||||
print(f" Session: {name} ({session_id[:12]}...)")
|
||||
|
||||
baseline_cycles = get_cycles(workspace)
|
||||
print(f" Baseline: {baseline_cycles} cycles")
|
||||
|
||||
# Build prompt
|
||||
prompt = OPTIMIZATION_PROMPT_TEMPLATE.format(workspace=workspace)
|
||||
|
||||
# Start async job
|
||||
print("\n Starting optimization (message_async)...")
|
||||
ok, output, err = send_cmd(f"message_async:{prompt}", session_id, timeout=30)
|
||||
if not ok:
|
||||
print(f" Failed to start async job: {err}")
|
||||
return {
|
||||
"approach": "single",
|
||||
"cycles": BASELINE,
|
||||
"time_seconds": 0,
|
||||
"error": err,
|
||||
}
|
||||
|
||||
job_data = json.loads(output)
|
||||
job_id = job_data.get("job_id")
|
||||
print(f" Job started: {job_id}")
|
||||
|
||||
# Poll until done
|
||||
timeout_seconds = timeout_minutes * 60
|
||||
best_cycles = poll_job(
|
||||
job_id, session_id, workspace, start_time, timeout_seconds,
|
||||
check_interval, "single",
|
||||
)
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
speedup = BASELINE / best_cycles if best_cycles > 0 else 0
|
||||
print(f"\n SINGLE AGENT RESULT: {best_cycles} cycles in {elapsed/60:.1f}m ({speedup:.2f}x)")
|
||||
|
||||
# Get full test output
|
||||
test_output = get_test_summary(workspace)
|
||||
print(f"\n Test output:\n{test_output}")
|
||||
|
||||
return {
|
||||
"approach": "single",
|
||||
"cycles": best_cycles,
|
||||
"time_seconds": elapsed,
|
||||
"workspace": workspace,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
elapsed = time.time() - start_time
|
||||
print(f" Error: {e}")
|
||||
return {
|
||||
"approach": "single",
|
||||
"cycles": BASELINE,
|
||||
"time_seconds": elapsed,
|
||||
"error": str(e),
|
||||
}
|
||||
finally:
|
||||
if session_id:
|
||||
print(f" Cleaning up session {session_id[:12]}...")
|
||||
destroy_session(session_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Trial B: Swarm (Multi-Agent)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def run_swarm(timeout_minutes: float, check_interval: float) -> dict:
|
||||
"""Run swarm multi-agent on the optimization task."""
|
||||
print("\n" + "=" * 70)
|
||||
print(f" TRIAL B: SWARM / MULTI-AGENT (timeout: {timeout_minutes}m)")
|
||||
print("=" * 70)
|
||||
|
||||
workspace = setup_workspace("swarm")
|
||||
print(f" Workspace: {workspace}")
|
||||
|
||||
start_time = time.time()
|
||||
session_id = None
|
||||
|
||||
try:
|
||||
session_id, name = create_session(workspace)
|
||||
print(f" Coordinator: {name} ({session_id[:12]}...)")
|
||||
|
||||
baseline_cycles = get_cycles(workspace)
|
||||
print(f" Baseline: {baseline_cycles} cycles")
|
||||
|
||||
# Build prompt (same optimization goal)
|
||||
prompt = OPTIMIZATION_PROMPT_TEMPLATE.format(workspace=workspace)
|
||||
|
||||
# Start swarm async job - this automatically plans subtasks and spawns agents
|
||||
print("\n Starting swarm (swarm_message_async)...")
|
||||
ok, output, err = send_cmd(f"swarm_message_async:{prompt}", session_id, timeout=30)
|
||||
if not ok:
|
||||
print(f" Failed to start swarm: {err}")
|
||||
return {
|
||||
"approach": "swarm",
|
||||
"cycles": BASELINE,
|
||||
"time_seconds": 0,
|
||||
"error": err,
|
||||
}
|
||||
|
||||
job_data = json.loads(output)
|
||||
job_id = job_data.get("job_id")
|
||||
print(f" Swarm job started: {job_id}")
|
||||
|
||||
timeout_seconds = timeout_minutes * 60
|
||||
best_cycles = BASELINE
|
||||
last_cycles = BASELINE
|
||||
member_info_printed = False
|
||||
|
||||
while time.time() - start_time < timeout_seconds:
|
||||
elapsed = time.time() - start_time
|
||||
|
||||
# Check job status
|
||||
ok, status_output, _ = send_cmd(f"job_status:{job_id}", session_id, timeout=10)
|
||||
if ok:
|
||||
try:
|
||||
status = json.loads(status_output)
|
||||
job_status = status.get("status", "unknown")
|
||||
if job_status == "completed":
|
||||
print(f"\n [swarm] [{elapsed/60:.1f}m] Swarm completed!")
|
||||
break
|
||||
elif job_status == "failed":
|
||||
error = status.get("error", "unknown")
|
||||
print(f"\n [swarm] [{elapsed/60:.1f}m] Swarm failed: {error}")
|
||||
break
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
pass
|
||||
|
||||
# Show swarm members (once, early on)
|
||||
if not member_info_printed and elapsed > 10:
|
||||
ok, swarm_output, _ = send_cmd("swarm:members", session_id, timeout=10)
|
||||
if ok:
|
||||
try:
|
||||
members = json.loads(swarm_output)
|
||||
print(f" [swarm] [{elapsed/60:.1f}m] {len(members)} agent(s) in swarm")
|
||||
for m in members[:5]:
|
||||
sid = m.get("session_id", "?")[:12]
|
||||
st = m.get("status", "?")
|
||||
print(f" - {sid}... ({st})")
|
||||
member_info_printed = True
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
pass
|
||||
|
||||
# Check current cycles
|
||||
cycles = get_cycles(workspace)
|
||||
if cycles < best_cycles:
|
||||
best_cycles = cycles
|
||||
speedup = BASELINE / cycles
|
||||
print(f" [swarm] [{elapsed/60:.1f}m] NEW BEST: {cycles} cycles ({speedup:.2f}x speedup)")
|
||||
elif cycles != last_cycles:
|
||||
print(f" [swarm] [{elapsed/60:.1f}m] Cycles: {cycles}")
|
||||
last_cycles = cycles
|
||||
|
||||
time.sleep(check_interval)
|
||||
|
||||
# Final check
|
||||
cycles = get_cycles(workspace)
|
||||
if cycles < best_cycles:
|
||||
best_cycles = cycles
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
speedup = BASELINE / best_cycles if best_cycles > 0 else 0
|
||||
print(f"\n SWARM RESULT: {best_cycles} cycles in {elapsed/60:.1f}m ({speedup:.2f}x)")
|
||||
|
||||
# Get full test output
|
||||
test_output = get_test_summary(workspace)
|
||||
print(f"\n Test output:\n{test_output}")
|
||||
|
||||
return {
|
||||
"approach": "swarm",
|
||||
"cycles": best_cycles,
|
||||
"time_seconds": elapsed,
|
||||
"workspace": workspace,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
elapsed = time.time() - start_time
|
||||
print(f" Error: {e}")
|
||||
return {
|
||||
"approach": "swarm",
|
||||
"cycles": BASELINE,
|
||||
"time_seconds": elapsed,
|
||||
"error": str(e),
|
||||
}
|
||||
finally:
|
||||
if session_id:
|
||||
print(f" Cleaning up session {session_id[:12]}...")
|
||||
destroy_session(session_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Results comparison
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def print_comparison(results: dict):
|
||||
"""Print a comparison table of all trials."""
|
||||
print("\n" + "=" * 70)
|
||||
print(" BENCHMARK RESULTS")
|
||||
print("=" * 70)
|
||||
|
||||
header = f" {'Approach':<15} {'Cycles':<12} {'Time':<12} {'Speedup':<12} {'Status'}"
|
||||
print(header)
|
||||
print(" " + "-" * 66)
|
||||
|
||||
for name, data in results.items():
|
||||
cycles = data["cycles"]
|
||||
time_m = data["time_seconds"] / 60
|
||||
speedup = BASELINE / cycles if cycles > 0 else 0
|
||||
status = "ERROR" if "error" in data else "OK"
|
||||
print(f" {name:<15} {cycles:<12} {time_m:<12.1f}m {speedup:<12.2f}x {status}")
|
||||
|
||||
if len(results) > 1:
|
||||
print()
|
||||
winner = min(results.items(), key=lambda x: x[1]["cycles"])
|
||||
loser = max(results.items(), key=lambda x: x[1]["cycles"])
|
||||
|
||||
winner_name, winner_data = winner
|
||||
loser_name, loser_data = loser
|
||||
|
||||
print(f" Winner: {winner_name} ({winner_data['cycles']} cycles)")
|
||||
if loser_data["cycles"] > 0 and winner_data["cycles"] > 0:
|
||||
relative = loser_data["cycles"] / winner_data["cycles"]
|
||||
print(f" {winner_name} is {relative:.2f}x better than {loser_name}")
|
||||
|
||||
# Time comparison
|
||||
if winner_data["time_seconds"] > 0 and loser_data["time_seconds"] > 0:
|
||||
time_ratio = loser_data["time_seconds"] / winner_data["time_seconds"]
|
||||
if time_ratio > 1:
|
||||
print(f" {winner_name} was {time_ratio:.1f}x faster in wall time")
|
||||
else:
|
||||
print(f" {loser_name} was {1/time_ratio:.1f}x faster in wall time")
|
||||
|
||||
# Threshold analysis
|
||||
print("\n Threshold Analysis:")
|
||||
thresholds = [
|
||||
("Baseline", BASELINE),
|
||||
("Updated starter", 18532),
|
||||
("Opus 4 many hours", 2164),
|
||||
("Opus 4.5 casual (best human 2hr)", 1790),
|
||||
("Opus 4.5 2hr harness", 1579),
|
||||
("Sonnet 4.5 many hours", 1548),
|
||||
("Opus 4.5 11.5hr harness", 1487),
|
||||
("Opus 4.5 improved harness", 1363),
|
||||
]
|
||||
|
||||
for name, data in results.items():
|
||||
cycles = data["cycles"]
|
||||
print(f"\n {name} ({cycles} cycles):")
|
||||
for thresh_name, thresh_val in thresholds:
|
||||
passed = "PASS" if cycles < thresh_val else "FAIL"
|
||||
print(f" [{passed}] {thresh_name}: < {thresh_val}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Benchmark single agent vs swarm on VLIW SIMD optimization task",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=__doc__,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--timeout", type=float, default=10,
|
||||
help="Timeout in minutes per trial (default: 10)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--check-interval", type=float, default=30,
|
||||
help="How often to check cycle count, in seconds (default: 30)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--single-only", action="store_true",
|
||||
help="Only run single agent trial",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--swarm-only", action="store_true",
|
||||
help="Only run swarm trial",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
# Validate environment
|
||||
if not os.path.exists(DEBUG_SOCKET):
|
||||
print(f"Error: Debug socket not found: {DEBUG_SOCKET}")
|
||||
print("Make sure jcode server is running with debug_control enabled:")
|
||||
print(" touch ~/.jcode/debug_control")
|
||||
print(" jcode serve")
|
||||
sys.exit(1)
|
||||
|
||||
if not os.path.exists(TAKEHOME_SOURCE):
|
||||
print(f"Error: Take-home source not found: {TAKEHOME_SOURCE}")
|
||||
sys.exit(1)
|
||||
|
||||
os.makedirs(BENCHMARK_DIR, exist_ok=True)
|
||||
|
||||
print("=" * 70)
|
||||
print(" SWARM vs SINGLE-AGENT BENCHMARK")
|
||||
print("=" * 70)
|
||||
print(f" Timeout: {args.timeout} minutes per trial")
|
||||
print(f" Check interval: {args.check_interval} seconds")
|
||||
print(f" Source: {TAKEHOME_SOURCE}")
|
||||
print(f" Baseline: {BASELINE} cycles")
|
||||
print()
|
||||
|
||||
results = {}
|
||||
|
||||
run_single = not args.swarm_only
|
||||
run_multi = not args.single_only
|
||||
|
||||
if run_single:
|
||||
results["single"] = run_single_agent(args.timeout, args.check_interval)
|
||||
|
||||
if run_multi:
|
||||
results["swarm"] = run_swarm(args.timeout, args.check_interval)
|
||||
|
||||
if results:
|
||||
print_comparison(results)
|
||||
else:
|
||||
print("No trials were run.")
|
||||
|
||||
# Write results to JSON
|
||||
results_file = os.path.join(BENCHMARK_DIR, "results.json")
|
||||
with open(results_file, "w") as f:
|
||||
json.dump(results, f, indent=2, default=str)
|
||||
print(f"\n Results saved to: {results_file}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+408
@@ -0,0 +1,408 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Benchmark single agent vs swarm on the Anthropic Performance Take-Home.
|
||||
|
||||
Usage:
|
||||
BENCHMARK_TIMEOUT=5 python scripts/benchmark_takehome.py single
|
||||
BENCHMARK_TIMEOUT=10 python scripts/benchmark_takehome.py swarm
|
||||
BENCHMARK_TIMEOUT=10 python scripts/benchmark_takehome.py both
|
||||
"""
|
||||
|
||||
import socket
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import select
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
DEBUG_SOCKET = f"/run/user/{os.getuid()}/jcode-debug.sock"
|
||||
TAKEHOME_SOURCE = os.environ.get(
|
||||
"TAKEHOME_SOURCE", str(Path.home() / "original_performance_takehome")
|
||||
)
|
||||
BENCHMARK_DIR = "/tmp/takehome-benchmark"
|
||||
TIMEOUT_MINUTES = int(os.environ.get('BENCHMARK_TIMEOUT', '10'))
|
||||
BASELINE = 147734
|
||||
|
||||
|
||||
def send_cmd(cmd: str, session_id: str = None, timeout: float = 300) -> tuple:
|
||||
"""Send a debug command and get response."""
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
sock.connect(DEBUG_SOCKET)
|
||||
sock.setblocking(False)
|
||||
|
||||
req = {"type": "debug_command", "id": 1, "command": cmd}
|
||||
if session_id:
|
||||
req["session_id"] = session_id
|
||||
|
||||
sock.send((json.dumps(req) + '\n').encode())
|
||||
|
||||
start = time.time()
|
||||
data = b""
|
||||
while time.time() - start < timeout:
|
||||
ready, _, _ = select.select([sock], [], [], 1.0)
|
||||
if ready:
|
||||
try:
|
||||
chunk = sock.recv(65536)
|
||||
if not chunk:
|
||||
break
|
||||
data += chunk
|
||||
if b'\n' in data:
|
||||
break
|
||||
except BlockingIOError:
|
||||
continue
|
||||
|
||||
sock.close()
|
||||
|
||||
if not data:
|
||||
return False, "", "Timeout"
|
||||
|
||||
try:
|
||||
resp = json.loads(data.decode().strip())
|
||||
return resp.get('ok', False), resp.get('output', ''), resp.get('error', '')
|
||||
except json.JSONDecodeError as e:
|
||||
return False, "", f"JSON error: {e}"
|
||||
|
||||
|
||||
def create_session(working_dir: str) -> tuple:
|
||||
"""Create a session and return (session_id, friendly_name)."""
|
||||
ok, output, err = send_cmd(f"create_session:{working_dir}", timeout=120)
|
||||
if not ok:
|
||||
raise RuntimeError(f"Failed to create session: {err}")
|
||||
data = json.loads(output)
|
||||
return data['session_id'], data.get('friendly_name', data['session_id'][:12])
|
||||
|
||||
|
||||
def destroy_session(session_id: str):
|
||||
"""Destroy a session."""
|
||||
send_cmd(f"destroy_session:{session_id}")
|
||||
|
||||
|
||||
def setup_workspace(name: str) -> str:
|
||||
"""Create a clean copy of the take-home."""
|
||||
workspace = os.path.join(BENCHMARK_DIR, name)
|
||||
if os.path.exists(workspace):
|
||||
shutil.rmtree(workspace)
|
||||
shutil.copytree(TAKEHOME_SOURCE, workspace)
|
||||
return workspace
|
||||
|
||||
|
||||
def get_cycles(workspace: str) -> int:
|
||||
"""Run tests and return cycle count."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["python", "tests/submission_tests.py", "-v"],
|
||||
cwd=workspace,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120
|
||||
)
|
||||
for line in (result.stdout + result.stderr).split('\n'):
|
||||
if 'CYCLES:' in line:
|
||||
return int(line.split('CYCLES:')[1].strip())
|
||||
except Exception as e:
|
||||
print(f"Error getting cycles: {e}")
|
||||
return BASELINE
|
||||
|
||||
|
||||
def make_single_prompt(workspace: str) -> str:
|
||||
return f"""You are optimizing a VLIW SIMD kernel for Anthropic's performance take-home.
|
||||
|
||||
IMPORTANT: You MUST work in this directory: {workspace}
|
||||
All file paths should be relative to or within this directory.
|
||||
|
||||
Goal: Reduce the cycle count from 147,734 to as low as possible.
|
||||
|
||||
Key files (in {workspace}):
|
||||
- problem.py: Defines the Machine, instruction set (VLIW bundles, vector ops, VLEN=16)
|
||||
- perf_takehome.py: Contains KernelBuilder.build_kernel() - this is what you optimize
|
||||
- tests/submission_tests.py: Run to verify correctness and see cycle count
|
||||
|
||||
DO NOT modify tests/ folder.
|
||||
|
||||
Key optimizations to try:
|
||||
1. VLIW parallelism - pack independent operations into single bundles
|
||||
2. Vector operations - use VLEN=16 to process 16 elements at once
|
||||
3. Reduce memory access latency - batch loads/stores
|
||||
4. Optimize the hash function - it runs many times per element
|
||||
|
||||
Start by reading {workspace}/problem.py to understand the machine, then optimize build_kernel().
|
||||
After each change, run `cd {workspace} && python tests/submission_tests.py` to check correctness and cycles.
|
||||
|
||||
Work efficiently - focus on the highest-impact optimizations first."""
|
||||
|
||||
|
||||
def run_single_agent() -> dict:
|
||||
"""Run a single agent benchmark using async messaging."""
|
||||
print("\n" + "=" * 60)
|
||||
print(f"SINGLE AGENT BENCHMARK (timeout: {TIMEOUT_MINUTES}m)")
|
||||
print("=" * 60)
|
||||
|
||||
workspace = setup_workspace("single")
|
||||
print(f"Workspace: {workspace}")
|
||||
|
||||
start_time = time.time()
|
||||
session_id = None
|
||||
best_cycles = BASELINE
|
||||
|
||||
try:
|
||||
session_id, name = create_session(workspace)
|
||||
print(f"Session: {name}")
|
||||
|
||||
# Initial cycles
|
||||
cycles = get_cycles(workspace)
|
||||
print(f"Baseline: {cycles} cycles")
|
||||
|
||||
# Send optimization task asynchronously
|
||||
print("\nStarting optimization (async)...")
|
||||
prompt = make_single_prompt(workspace)
|
||||
|
||||
# Use message_async to start the job
|
||||
ok, output, err = send_cmd(f"message_async:{prompt}", session_id, timeout=30)
|
||||
if not ok:
|
||||
print(f"Failed to start async job: {err}")
|
||||
return {"approach": "single", "cycles": BASELINE, "time_seconds": 0, "error": err}
|
||||
|
||||
job_data = json.loads(output)
|
||||
job_id = job_data.get("job_id")
|
||||
print(f"Job started: {job_id}")
|
||||
|
||||
timeout_seconds = TIMEOUT_MINUTES * 60
|
||||
last_cycles = BASELINE
|
||||
check_interval = 30 # Check every 30 seconds
|
||||
|
||||
while time.time() - start_time < timeout_seconds:
|
||||
elapsed = time.time() - start_time
|
||||
|
||||
# Check job status
|
||||
ok, status_output, _ = send_cmd(f"job_status:{job_id}", session_id, timeout=10)
|
||||
if ok:
|
||||
try:
|
||||
status = json.loads(status_output)
|
||||
job_status = status.get("status", "unknown")
|
||||
if job_status in ["completed", "failed"]:
|
||||
print(f"\n[{elapsed/60:.1f}m] Job {job_status}")
|
||||
break
|
||||
except:
|
||||
pass
|
||||
|
||||
# Check current cycles in workspace
|
||||
cycles = get_cycles(workspace)
|
||||
if cycles < best_cycles:
|
||||
best_cycles = cycles
|
||||
print(f"[{elapsed/60:.1f}m] NEW BEST: {cycles} cycles ({BASELINE/cycles:.2f}x speedup)")
|
||||
elif cycles != last_cycles:
|
||||
print(f"[{elapsed/60:.1f}m] Cycles: {cycles}")
|
||||
last_cycles = cycles
|
||||
|
||||
time.sleep(check_interval)
|
||||
|
||||
# Final check
|
||||
cycles = get_cycles(workspace)
|
||||
if cycles < best_cycles:
|
||||
best_cycles = cycles
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
print(f"\nFinal: {best_cycles} cycles in {elapsed/60:.1f}m ({BASELINE/best_cycles:.2f}x)")
|
||||
|
||||
return {
|
||||
"approach": "single",
|
||||
"cycles": best_cycles,
|
||||
"time_seconds": elapsed,
|
||||
"workspace": workspace
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
return {
|
||||
"approach": "single",
|
||||
"cycles": BASELINE,
|
||||
"time_seconds": time.time() - start_time,
|
||||
"error": str(e)
|
||||
}
|
||||
finally:
|
||||
if session_id:
|
||||
destroy_session(session_id)
|
||||
|
||||
|
||||
def run_swarm(n_agents: int = 2) -> dict:
|
||||
"""Run autonomous swarm benchmark using swarm_message_async.
|
||||
|
||||
This uses the full swarm capability where ONE agent becomes coordinator,
|
||||
creates a plan, and spawns subagents automatically.
|
||||
"""
|
||||
print("\n" + "=" * 60)
|
||||
print(f"AUTONOMOUS SWARM BENCHMARK (timeout: {TIMEOUT_MINUTES}m)")
|
||||
print("=" * 60)
|
||||
|
||||
workspace = setup_workspace("swarm")
|
||||
print(f"Workspace: {workspace}")
|
||||
|
||||
start_time = time.time()
|
||||
session_id = None
|
||||
best_cycles = BASELINE
|
||||
|
||||
try:
|
||||
# Create ONE session - it becomes coordinator and spawns agents
|
||||
session_id, name = create_session(workspace)
|
||||
print(f"Coordinator: {name}")
|
||||
|
||||
baseline = get_cycles(workspace)
|
||||
print(f"Baseline: {baseline} cycles")
|
||||
|
||||
# Use swarm_message_async - this will:
|
||||
# 1. Plan subtasks automatically
|
||||
# 2. Spawn subagents to work in parallel
|
||||
# 3. Integrate results
|
||||
prompt = f"""Optimize the VLIW SIMD kernel in {workspace}/perf_takehome.py to minimize cycle count.
|
||||
|
||||
Current baseline: 147,734 cycles. Goal: as low as possible.
|
||||
|
||||
The problem:
|
||||
- {workspace}/problem.py defines the machine (VLEN=16 vectors, VLIW bundles, slot limits)
|
||||
- {workspace}/perf_takehome.py has build_kernel() which needs optimization
|
||||
- Run `cd {workspace} && python tests/submission_tests.py` to verify correctness and check cycles
|
||||
|
||||
Key optimization strategies:
|
||||
1. Vectorization - use VLEN=16 to process 16 elements at once
|
||||
2. VLIW packing - bundle independent operations together
|
||||
3. Reduce memory latency - batch loads/stores
|
||||
4. Optimize hash function - it runs many times per element
|
||||
|
||||
Break this into parallel subtasks and spawn agents to work on different optimizations.
|
||||
DO NOT modify tests/ folder."""
|
||||
|
||||
print("\nStarting autonomous swarm (swarm_message_async)...")
|
||||
ok, output, err = send_cmd(f"swarm_message_async:{prompt}", session_id, timeout=30)
|
||||
if not ok:
|
||||
print(f"Failed to start swarm: {err}")
|
||||
return {"approach": "swarm", "cycles": BASELINE, "time_seconds": 0, "error": err}
|
||||
|
||||
job_data = json.loads(output)
|
||||
job_id = job_data.get("job_id")
|
||||
print(f"Swarm job started: {job_id}")
|
||||
|
||||
timeout_seconds = TIMEOUT_MINUTES * 60
|
||||
last_cycles = BASELINE
|
||||
check_interval = 30
|
||||
|
||||
while time.time() - start_time < timeout_seconds:
|
||||
elapsed = time.time() - start_time
|
||||
|
||||
# Check job status
|
||||
ok, status_output, _ = send_cmd(f"job_status:{job_id}", session_id, timeout=10)
|
||||
if ok:
|
||||
try:
|
||||
status = json.loads(status_output)
|
||||
job_status = status.get("status", "unknown")
|
||||
if job_status == "completed":
|
||||
print(f"\n[{elapsed/60:.1f}m] Swarm completed!")
|
||||
break
|
||||
elif job_status == "failed":
|
||||
print(f"\n[{elapsed/60:.1f}m] Swarm failed: {status.get('error', 'unknown')}")
|
||||
break
|
||||
except:
|
||||
pass
|
||||
|
||||
# Check swarm members (to see how many agents were spawned)
|
||||
ok, swarm_output, _ = send_cmd("swarm:members", session_id, timeout=10)
|
||||
if ok and elapsed < 60: # Only print once early on
|
||||
try:
|
||||
print(f"[{elapsed/60:.1f}m] Swarm: {swarm_output[:100]}...")
|
||||
except:
|
||||
pass
|
||||
|
||||
# Check current cycles
|
||||
cycles = get_cycles(workspace)
|
||||
if cycles < best_cycles:
|
||||
best_cycles = cycles
|
||||
print(f"[{elapsed/60:.1f}m] NEW BEST: {cycles} cycles ({BASELINE/cycles:.2f}x)")
|
||||
elif cycles != last_cycles:
|
||||
print(f"[{elapsed/60:.1f}m] Cycles: {cycles}")
|
||||
last_cycles = cycles
|
||||
|
||||
time.sleep(check_interval)
|
||||
|
||||
# Final check
|
||||
cycles = get_cycles(workspace)
|
||||
if cycles < best_cycles:
|
||||
best_cycles = cycles
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
print(f"\nFinal: {best_cycles} cycles in {elapsed/60:.1f}m ({BASELINE/best_cycles:.2f}x)")
|
||||
|
||||
return {
|
||||
"approach": "swarm",
|
||||
"cycles": best_cycles,
|
||||
"time_seconds": elapsed,
|
||||
"workspace": workspace
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
return {
|
||||
"approach": "swarm",
|
||||
"cycles": BASELINE,
|
||||
"time_seconds": time.time() - start_time,
|
||||
"error": str(e)
|
||||
}
|
||||
finally:
|
||||
if session_id:
|
||||
destroy_session(session_id)
|
||||
|
||||
|
||||
def print_results(results: dict):
|
||||
"""Print comparison table."""
|
||||
print("\n" + "=" * 60)
|
||||
print("RESULTS")
|
||||
print("=" * 60)
|
||||
print(f"{'Approach':<15} {'Cycles':<12} {'Time':<10} {'Speedup':<10}")
|
||||
print("-" * 60)
|
||||
|
||||
for name, data in results.items():
|
||||
cycles = data['cycles']
|
||||
time_m = data['time_seconds'] / 60
|
||||
speedup = BASELINE / cycles
|
||||
print(f"{name:<15} {cycles:<12} {time_m:<10.1f}m {speedup:<10.2f}x")
|
||||
|
||||
if len(results) > 1:
|
||||
winner = min(results.items(), key=lambda x: x[1]['cycles'])
|
||||
print(f"\nWinner: {winner[0]} ({winner[1]['cycles']} cycles)")
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print(__doc__)
|
||||
sys.exit(1)
|
||||
|
||||
mode = sys.argv[1].lower()
|
||||
os.makedirs(BENCHMARK_DIR, exist_ok=True)
|
||||
|
||||
print(f"Benchmark timeout: {TIMEOUT_MINUTES} minutes per approach")
|
||||
print(f"Set BENCHMARK_TIMEOUT env var to change (e.g., BENCHMARK_TIMEOUT=30)")
|
||||
|
||||
if mode == "single":
|
||||
r = run_single_agent()
|
||||
print_results({"single": r})
|
||||
|
||||
elif mode == "swarm":
|
||||
r = run_swarm()
|
||||
print_results({"swarm": r})
|
||||
|
||||
elif mode == "both":
|
||||
results = {}
|
||||
results['single'] = run_single_agent()
|
||||
results['swarm'] = run_swarm()
|
||||
print_results(results)
|
||||
|
||||
else:
|
||||
print(f"Unknown mode: {mode}")
|
||||
print(__doc__)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,213 @@
|
||||
#!/bin/bash
|
||||
# Tool call benchmarking script
|
||||
# Measures execution time for each tool with representative inputs
|
||||
# Run from the jcode repo root
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ITERATIONS=${1:-5}
|
||||
RESULTS_FILE="/tmp/jcode_tool_benchmark_$(date +%Y%m%d_%H%M%S).csv"
|
||||
|
||||
echo "=== jcode Tool Call Benchmark ==="
|
||||
echo "Iterations per tool: $ITERATIONS"
|
||||
echo "Results file: $RESULTS_FILE"
|
||||
echo ""
|
||||
|
||||
# CSV header
|
||||
echo "tool,iteration,time_ms,input_size_bytes,output_size_bytes" > "$RESULTS_FILE"
|
||||
|
||||
# Helper: benchmark a tool via the debug socket
|
||||
benchmark_tool() {
|
||||
local tool_name="$1"
|
||||
local tool_input="$2"
|
||||
local label="${3:-$tool_name}"
|
||||
|
||||
local input_size=${#tool_input}
|
||||
local total_ms=0
|
||||
local min_ms=999999
|
||||
local max_ms=0
|
||||
local times=()
|
||||
|
||||
for i in $(seq 1 "$ITERATIONS"); do
|
||||
local start_ns=$(date +%s%N)
|
||||
|
||||
# Execute via debug socket
|
||||
local output
|
||||
output=$(echo "{\"type\":\"debug_command\",\"id\":1,\"command\":\"tool:$tool_name $tool_input\",\"session_id\":\"$SESSION_ID\"}" | \
|
||||
socat - UNIX-CONNECT:"$DEBUG_SOCK" 2>/dev/null || echo '{"error":"timeout"}')
|
||||
|
||||
local end_ns=$(date +%s%N)
|
||||
local elapsed_ms=$(( (end_ns - start_ns) / 1000000 ))
|
||||
|
||||
local output_size=${#output}
|
||||
echo "$label,$i,$elapsed_ms,$input_size,$output_size" >> "$RESULTS_FILE"
|
||||
|
||||
times+=("$elapsed_ms")
|
||||
total_ms=$((total_ms + elapsed_ms))
|
||||
|
||||
if [ "$elapsed_ms" -lt "$min_ms" ]; then min_ms=$elapsed_ms; fi
|
||||
if [ "$elapsed_ms" -gt "$max_ms" ]; then max_ms=$elapsed_ms; fi
|
||||
done
|
||||
|
||||
local avg_ms=$((total_ms / ITERATIONS))
|
||||
|
||||
# Compute p50
|
||||
IFS=$'\n' sorted_times=($(sort -n <<<"${times[*]}")); unset IFS
|
||||
local p50_idx=$(( ITERATIONS / 2 ))
|
||||
local p50_ms=${sorted_times[$p50_idx]}
|
||||
|
||||
printf " %-30s avg=%4dms p50=%4dms min=%4dms max=%4dms\n" \
|
||||
"$label" "$avg_ms" "$p50_ms" "$min_ms" "$max_ms"
|
||||
}
|
||||
|
||||
# Find debug socket
|
||||
DEBUG_SOCK="${JCODE_DEBUG_SOCK:-/run/user/$(id -u)/jcode-debug.sock}"
|
||||
|
||||
if [ ! -S "$DEBUG_SOCK" ]; then
|
||||
echo "ERROR: Debug socket not found at $DEBUG_SOCK"
|
||||
echo "Make sure jcode is running with debug control enabled."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Get session ID
|
||||
echo "Getting session ID..."
|
||||
SESSION_RESP=$(echo '{"type":"debug_command","id":1,"command":"state"}' | \
|
||||
socat -t5 - UNIX-CONNECT:"$DEBUG_SOCK" 2>/dev/null || echo '{}')
|
||||
SESSION_ID=$(echo "$SESSION_RESP" | python3 -c "
|
||||
import sys, json
|
||||
try:
|
||||
d = json.loads(sys.stdin.read())
|
||||
out = d.get('output', '{}')
|
||||
if isinstance(out, str):
|
||||
d2 = json.loads(out)
|
||||
else:
|
||||
d2 = out
|
||||
print(d2.get('session_id', ''))
|
||||
except:
|
||||
print('')
|
||||
" 2>/dev/null)
|
||||
|
||||
if [ -z "$SESSION_ID" ]; then
|
||||
echo "ERROR: Could not get session ID from debug socket"
|
||||
echo "Response: $SESSION_RESP"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Session: $SESSION_ID"
|
||||
echo ""
|
||||
|
||||
# Create temp files for testing
|
||||
TMPDIR=$(mktemp -d)
|
||||
echo "hello world" > "$TMPDIR/test.txt"
|
||||
echo -e "line 1\nline 2\nline 3\nfoo bar\nbaz qux" > "$TMPDIR/multiline.txt"
|
||||
mkdir -p "$TMPDIR/subdir"
|
||||
echo "nested" > "$TMPDIR/subdir/nested.txt"
|
||||
|
||||
# Large file for read benchmarks
|
||||
python3 -c "
|
||||
for i in range(1000):
|
||||
print(f'Line {i}: This is a test line with some content for benchmarking purposes. The quick brown fox jumps over the lazy dog.')
|
||||
" > "$TMPDIR/large.txt"
|
||||
|
||||
echo "=== File System Tools ==="
|
||||
|
||||
benchmark_tool "read" "{\"file_path\":\"$TMPDIR/test.txt\"}" "read (tiny file)"
|
||||
benchmark_tool "read" "{\"file_path\":\"$TMPDIR/large.txt\"}" "read (1000 lines)"
|
||||
benchmark_tool "read" "{\"file_path\":\"$TMPDIR/large.txt\",\"offset\":500,\"limit\":10}" "read (10 lines @ offset)"
|
||||
benchmark_tool "read" "{\"file_path\":\"src/main.rs\"}" "read (main.rs)"
|
||||
|
||||
echo ""
|
||||
echo "=== Write/Edit Tools ==="
|
||||
|
||||
benchmark_tool "write" "{\"file_path\":\"$TMPDIR/write_test.txt\",\"content\":\"hello world\"}" "write (small)"
|
||||
benchmark_tool "write" "{\"file_path\":\"$TMPDIR/write_test.txt\",\"content\":\"$(python3 -c "print('x' * 10000)")\"}" "write (10KB)"
|
||||
|
||||
# Setup file for edit
|
||||
echo "The quick brown fox jumps over the lazy dog" > "$TMPDIR/edit_test.txt"
|
||||
benchmark_tool "edit" "{\"file_path\":\"$TMPDIR/edit_test.txt\",\"old_string\":\"quick brown\",\"new_string\":\"slow red\"}" "edit (simple replace)"
|
||||
# Reset for next iteration
|
||||
for i in $(seq 1 "$ITERATIONS"); do
|
||||
echo "The quick brown fox jumps over the lazy dog" > "$TMPDIR/edit_test.txt"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=== Search/Navigation Tools ==="
|
||||
|
||||
benchmark_tool "agentgrep" "{\"mode\":\"grep\",\"query\":\"fn main\",\"path\":\"src\",\"type\":\"rs\"}" "agentgrep (fn main in src/)"
|
||||
benchmark_tool "agentgrep" "{\"mode\":\"grep\",\"query\":\"async fn\",\"path\":\"src/tool\",\"type\":\"rs\"}" "agentgrep (async fn in tools)"
|
||||
benchmark_tool "agentgrep" "{\"mode\":\"grep\",\"query\":\"tokio::spawn\",\"path\":\"src\"}" "agentgrep (tokio::spawn)"
|
||||
|
||||
benchmark_tool "agentgrep" "{\"mode\":\"find\",\"glob\":\"**/*.rs\"}" "agentgrep find (**/*.rs)"
|
||||
benchmark_tool "agentgrep" "{\"mode\":\"find\",\"glob\":\"**/*.rs\",\"path\":\"src/tool\"}" "agentgrep find (tool/*.rs)"
|
||||
|
||||
benchmark_tool "ls" "{}" "ls (repo root)"
|
||||
benchmark_tool "ls" "{\"path\":\"src\"}" "ls (src/)"
|
||||
benchmark_tool "ls" "{\"path\":\"src/tool\"}" "ls (src/tool/)"
|
||||
|
||||
echo ""
|
||||
echo "=== Shell Tools ==="
|
||||
|
||||
benchmark_tool "bash" "{\"command\":\"echo hello\"}" "bash (echo)"
|
||||
benchmark_tool "bash" "{\"command\":\"true\"}" "bash (true)"
|
||||
benchmark_tool "bash" "{\"command\":\"ls -la src/tool/\"}" "bash (ls -la)"
|
||||
benchmark_tool "bash" "{\"command\":\"wc -l src/main.rs\"}" "bash (wc -l)"
|
||||
benchmark_tool "bash" "{\"command\":\"cat /dev/null\"}" "bash (cat /dev/null)"
|
||||
benchmark_tool "bash" "{\"command\":\"git log --oneline -5\"}" "bash (git log -5)"
|
||||
benchmark_tool "bash" "{\"command\":\"cargo --version\"}" "bash (cargo --version)"
|
||||
|
||||
echo ""
|
||||
echo "=== Memory/Search Tools ==="
|
||||
|
||||
benchmark_tool "todoread" "{}" "todoread"
|
||||
benchmark_tool "todowrite" "{\"todos\":[{\"id\":\"bench1\",\"content\":\"benchmark test\",\"status\":\"pending\",\"priority\":\"low\"}]}" "todowrite"
|
||||
benchmark_tool "conversation_search" "{\"stats\":true}" "conversation_search (stats)"
|
||||
benchmark_tool "memory" "{\"action\":\"recall\",\"query\":\"benchmark test\",\"limit\":3}" "memory (recall)"
|
||||
benchmark_tool "memory" "{\"action\":\"list\",\"limit\":5}" "memory (list)"
|
||||
|
||||
echo ""
|
||||
echo "=== Tool Dispatch Overhead ==="
|
||||
|
||||
benchmark_tool "invalid" "{\"tool\":\"test\",\"error\":\"benchmark\"}" "invalid (no-op)"
|
||||
|
||||
echo ""
|
||||
echo "=== Results Summary ==="
|
||||
echo ""
|
||||
|
||||
# Parse and summarize
|
||||
python3 << 'PYEOF'
|
||||
import csv
|
||||
from collections import defaultdict
|
||||
|
||||
results = defaultdict(list)
|
||||
with open("RESULTS_FILE_PLACEHOLDER") as f:
|
||||
reader = csv.DictReader(f)
|
||||
for row in reader:
|
||||
results[row['tool']].append(int(row['time_ms']))
|
||||
|
||||
# Sort by average time (descending)
|
||||
summary = []
|
||||
for tool, times in results.items():
|
||||
avg = sum(times) / len(times)
|
||||
p50 = sorted(times)[len(times) // 2]
|
||||
summary.append((tool, avg, p50, min(times), max(times)))
|
||||
|
||||
summary.sort(key=lambda x: x[1], reverse=True)
|
||||
|
||||
print(f"{'Tool':<35} {'Avg':>7} {'P50':>7} {'Min':>7} {'Max':>7}")
|
||||
print("-" * 70)
|
||||
|
||||
for tool, avg, p50, mn, mx in summary:
|
||||
bar = "█" * max(1, int(avg / 10))
|
||||
print(f"{tool:<35} {avg:6.0f}ms {p50:5d}ms {mn:5d}ms {mx:5d}ms {bar}")
|
||||
|
||||
total_avg = sum(s[1] for s in summary)
|
||||
print(f"\n{'Total (all tools avg sum)':<35} {total_avg:6.0f}ms")
|
||||
print(f"\nSlowest tool: {summary[0][0]} ({summary[0][1]:.0f}ms avg)")
|
||||
print(f"Fastest tool: {summary[-1][0]} ({summary[-1][1]:.0f}ms avg)")
|
||||
PYEOF
|
||||
|
||||
# Cleanup
|
||||
rm -rf "$TMPDIR"
|
||||
|
||||
echo ""
|
||||
echo "Full CSV results: $RESULTS_FILE"
|
||||
Executable
+199
@@ -0,0 +1,199 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Build a Linux x86_64 release artifact against the CentOS 7 / manylinux2014
|
||||
# glibc 2.17 baseline so the resulting binary runs on older distributions as
|
||||
# well as newer Debian/Ubuntu containers used by Terminal-Bench tasks.
|
||||
|
||||
repo_root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
|
||||
out_dir="${1:-$repo_root/dist}"
|
||||
|
||||
if [[ "$#" -gt 1 ]]; then
|
||||
echo "Usage: $0 [out-dir]" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$out_dir" != /* ]]; then
|
||||
out_dir="$repo_root/$out_dir"
|
||||
fi
|
||||
|
||||
artifact="${JCODE_COMPAT_ARTIFACT:-jcode-linux-x86_64}"
|
||||
profile="${JCODE_COMPAT_PROFILE:-release}"
|
||||
image="${JCODE_COMPAT_IMAGE:-quay.io/pypa/manylinux2014_x86_64}"
|
||||
cache_root="${JCODE_COMPAT_CACHE_DIR:-$HOME/.cache/jcode-linux-compat}"
|
||||
target="x86_64-unknown-linux-gnu"
|
||||
|
||||
mkdir -p "$out_dir" \
|
||||
"$cache_root/cargo-registry" \
|
||||
"$cache_root/cargo-git" \
|
||||
"$cache_root/rustup"
|
||||
|
||||
host_uid="$(id -u)"
|
||||
host_gid="$(id -g)"
|
||||
|
||||
# Compute git build metadata on the HOST and hand it to the container via a
|
||||
# metadata file (read by jcode-build-meta/build.rs through
|
||||
# JCODE_BUILD_METADATA_FILE). The repo is bind-mounted into the container and
|
||||
# owned by the host UID while git inside the container runs as root, so any
|
||||
# in-container `git` call trips git's "dubious ownership" guard
|
||||
# (CVE-2022-24765) and fails. That previously zeroed out the embedded git hash,
|
||||
# date, AND changelog, shipping release binaries that report
|
||||
# "vX.Y.Z (unknown) (unknown)" with an empty /changelog overlay. Computing the
|
||||
# values here makes the embedded metadata independent of container-git. This
|
||||
# mirrors scripts/remote_build.sh.
|
||||
git_hash=""
|
||||
git_date=""
|
||||
git_tag=""
|
||||
git_dirty="0"
|
||||
changelog_raw=""
|
||||
if command -v git >/dev/null 2>&1 && git -C "$repo_root" rev-parse --git-dir >/dev/null 2>&1; then
|
||||
git_hash="$(git -C "$repo_root" rev-parse --short HEAD 2>/dev/null || true)"
|
||||
git_date="$(git -C "$repo_root" log -1 --format=%ci 2>/dev/null || true)"
|
||||
git_tag="$(git -C "$repo_root" describe --tags --always 2>/dev/null || true)"
|
||||
changelog_raw="$(git -C "$repo_root" log -700 --format='%h|%ct|%D|%s' 2>/dev/null || true)"
|
||||
if [[ -n "$(git -C "$repo_root" status --porcelain 2>/dev/null || true)" ]]; then
|
||||
git_dirty="1"
|
||||
fi
|
||||
else
|
||||
echo "warning: git metadata unavailable on host; embedded changelog/version may be empty" >&2
|
||||
fi
|
||||
|
||||
metadata_file="$(mktemp)"
|
||||
trap 'rm -f "$metadata_file"' EXIT
|
||||
{
|
||||
printf 'git_hash=%s\n' "$git_hash"
|
||||
printf 'git_date=%s\n' "$git_date"
|
||||
printf 'git_tag=%s\n' "$git_tag"
|
||||
printf 'git_dirty=%s\n' "$git_dirty"
|
||||
printf 'changelog_raw<<JCODE_CHANGELOG_EOF\n%s\nJCODE_CHANGELOG_EOF\n' "$changelog_raw"
|
||||
} > "$metadata_file"
|
||||
|
||||
echo "Building portable Linux release in Docker image: $image"
|
||||
echo "Output dir: $out_dir"
|
||||
echo "Embedding git metadata: hash=${git_hash:-<none>} tag=${git_tag:-<none>} dirty=$git_dirty changelog_lines=$(printf '%s' "$changelog_raw" | grep -c '' || true)"
|
||||
|
||||
docker run --rm \
|
||||
-e CARGO_TERM_COLOR=always \
|
||||
-e JCODE_RELEASE_BUILD="${JCODE_RELEASE_BUILD:-1}" \
|
||||
-e JCODE_BUILD_SEMVER="${JCODE_BUILD_SEMVER:-}" \
|
||||
-e JCODE_BUILD_METADATA_FILE=/jcode-build-meta \
|
||||
-e JCODE_COMPAT_PROFILE="$profile" \
|
||||
-e JCODE_COMPAT_TARGET="$target" \
|
||||
-e HOST_UID="$host_uid" \
|
||||
-e HOST_GID="$host_gid" \
|
||||
-v "$repo_root:/work" \
|
||||
-v "$metadata_file:/jcode-build-meta:ro" \
|
||||
-v "$out_dir:/out" \
|
||||
-v "$cache_root/cargo-registry:/root/.cargo/registry" \
|
||||
-v "$cache_root/cargo-git:/root/.cargo/git" \
|
||||
-v "$cache_root/rustup:/root/.rustup" \
|
||||
-w /work \
|
||||
"$image" \
|
||||
bash -lc '
|
||||
set -euo pipefail
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq \
|
||||
build-essential \
|
||||
ca-certificates \
|
||||
curl \
|
||||
git \
|
||||
libssl-dev \
|
||||
pkg-config
|
||||
elif command -v yum >/dev/null 2>&1; then
|
||||
yum install -y \
|
||||
ca-certificates \
|
||||
curl \
|
||||
gcc \
|
||||
gcc-c++ \
|
||||
git \
|
||||
make \
|
||||
openssl-devel \
|
||||
pkgconfig \
|
||||
tar \
|
||||
gzip
|
||||
update-ca-trust || true
|
||||
else
|
||||
echo "Unsupported build image: expected apt-get or yum" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -x /root/.cargo/bin/cargo ]]; then
|
||||
curl https://sh.rustup.rs -sSf | sh -s -- -y --profile minimal --default-toolchain stable
|
||||
fi
|
||||
source /root/.cargo/env
|
||||
|
||||
# Belt-and-suspenders: the host-computed metadata file
|
||||
# (JCODE_BUILD_METADATA_FILE=/jcode-build-meta) is the primary source of
|
||||
# git hash/date/changelog, but mark the bind-mounted repo as a safe
|
||||
# directory so any in-container git fallback still works despite the
|
||||
# host-UID/root-git ownership mismatch (CVE-2022-24765 guard).
|
||||
git config --global --add safe.directory /work 2>/dev/null || true
|
||||
|
||||
export CARGO_TARGET_DIR=/work/target/linux-compat
|
||||
export CARGO_BUILD_JOBS="${CARGO_BUILD_JOBS:-1}"
|
||||
export CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS="${CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS:--C link-arg=-static-libgcc}"
|
||||
cargo build --profile "$JCODE_COMPAT_PROFILE" --target "$JCODE_COMPAT_TARGET" -p jcode --bin jcode
|
||||
|
||||
cp "$CARGO_TARGET_DIR/$JCODE_COMPAT_TARGET/$JCODE_COMPAT_PROFILE/jcode" "/out/'"$artifact"'.bin"
|
||||
chmod +x "/out/'"$artifact"'.bin"
|
||||
cat > "/out/'"$artifact"'" <<WRAPPER
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
self_path=\$0
|
||||
if command -v readlink >/dev/null 2>&1; then
|
||||
resolved=\$(readlink -f -- "\$0" 2>/dev/null || true)
|
||||
if [ -n "\$resolved" ]; then
|
||||
self_path=\$resolved
|
||||
fi
|
||||
fi
|
||||
case "\$self_path" in
|
||||
*/*) self_dir=\$(CDPATH= cd -- "\$(dirname -- "\$self_path")" && pwd) ;;
|
||||
*) self_dir=\$(pwd) ;;
|
||||
esac
|
||||
if [ -n "\${LD_LIBRARY_PATH:-}" ]; then
|
||||
export LD_LIBRARY_PATH="\$self_dir:\$LD_LIBRARY_PATH"
|
||||
else
|
||||
export LD_LIBRARY_PATH="\$self_dir"
|
||||
fi
|
||||
exec "\$self_dir/'"$artifact"'.bin" "\$@"
|
||||
WRAPPER
|
||||
chmod +x "/out/'"$artifact"'"
|
||||
|
||||
# Preserve the OpenSSL runtime libraries used by the build image. Some
|
||||
# Terminal-Bench containers are older than the build host and either lack
|
||||
# libssl entirely or expose a different SONAME. The Harbor adapter uploads
|
||||
# these sibling libraries and sets LD_LIBRARY_PATH for the jcode process.
|
||||
ldd "/out/'"$artifact"'.bin" \
|
||||
| awk "/lib(ssl|crypto)[.]so/ { print \$3 }" \
|
||||
| while read -r lib; do
|
||||
if [[ -n "$lib" && -f "$lib" ]]; then
|
||||
cp -L "$lib" /out/
|
||||
fi
|
||||
done
|
||||
|
||||
extra_libs=()
|
||||
for pattern in libssl.so\* libcrypto.so\*; do
|
||||
for lib in $pattern; do
|
||||
[[ -e "$lib" ]] && extra_libs+=("$lib")
|
||||
done
|
||||
done
|
||||
|
||||
if (( ${#extra_libs[@]} > 0 )); then
|
||||
(cd /out && tar czf '"$artifact"'.tar.gz '"$artifact"' '"$artifact"'.bin "${extra_libs[@]}")
|
||||
else
|
||||
(cd /out && tar czf '"$artifact"'.tar.gz '"$artifact"' '"$artifact"'.bin)
|
||||
fi
|
||||
|
||||
chown_inputs=("/out/'"$artifact"'" "/out/'"$artifact"'.bin" "/out/'"$artifact"'.tar.gz")
|
||||
if (( ${#extra_libs[@]} > 0 )); then
|
||||
for lib in "${extra_libs[@]}"; do
|
||||
chown_inputs+=("/out/$lib")
|
||||
done
|
||||
fi
|
||||
chown "$HOST_UID:$HOST_GID" "${chown_inputs[@]}" 2>/dev/null || true
|
||||
'
|
||||
|
||||
echo "Built artifacts:"
|
||||
ls -lh "$out_dir/$artifact" "$out_dir/$artifact.tar.gz"
|
||||
Executable
+81
@@ -0,0 +1,81 @@
|
||||
#!/bin/bash
|
||||
# Full autonomous demo capture for jcode
|
||||
# Captures screenshots of various UI states using niri + wtype
|
||||
#
|
||||
# Usage: ./capture_demo.sh [window_id]
|
||||
# If window_id not provided, uses the focused window
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(dirname "$0")"
|
||||
OUTPUT_DIR="$SCRIPT_DIR/../docs/screenshots"
|
||||
WINDOW_ID="${1:-}"
|
||||
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
|
||||
# Get window ID if not provided
|
||||
if [ -z "$WINDOW_ID" ]; then
|
||||
WINDOW_ID=$(niri msg focused-window 2>&1 | head -1 | grep -oP 'Window ID \K\d+')
|
||||
echo "Using focused window: $WINDOW_ID"
|
||||
fi
|
||||
|
||||
capture() {
|
||||
local name="$1"
|
||||
local keys="$2"
|
||||
local delay="${3:-0.5}"
|
||||
|
||||
echo "📸 Capturing: $name"
|
||||
|
||||
# Focus the window
|
||||
niri msg action focus-window --id "$WINDOW_ID"
|
||||
sleep 0.2
|
||||
|
||||
# Inject keystrokes if provided
|
||||
if [ -n "$keys" ]; then
|
||||
echo " Typing: $keys"
|
||||
wtype "$keys"
|
||||
sleep "$delay"
|
||||
fi
|
||||
|
||||
# Screenshot
|
||||
niri msg action screenshot-window --path "$OUTPUT_DIR/${name}.png"
|
||||
echo " ✅ Saved: $OUTPUT_DIR/${name}.png"
|
||||
}
|
||||
|
||||
clear_input() {
|
||||
# Clear any existing input with Ctrl+U, then Escape to close popups
|
||||
wtype -M ctrl -k u
|
||||
sleep 0.1
|
||||
wtype -k Escape
|
||||
sleep 0.2
|
||||
}
|
||||
|
||||
echo "🎬 jcode Demo Capture"
|
||||
echo " Window ID: $WINDOW_ID"
|
||||
echo " Output: $OUTPUT_DIR"
|
||||
echo ""
|
||||
|
||||
# Capture sequence
|
||||
niri msg action focus-window --id "$WINDOW_ID"
|
||||
sleep 0.3
|
||||
|
||||
# 1. Main UI (clean state)
|
||||
clear_input
|
||||
capture "main-ui" "" 0.3
|
||||
|
||||
# 2. Command palette (type /)
|
||||
clear_input
|
||||
capture "command-palette" "/" 0.3
|
||||
|
||||
# 3. Close palette, show help
|
||||
wtype -k Escape
|
||||
sleep 0.2
|
||||
capture "help-view" "/help" 0.5
|
||||
|
||||
# Clean up - close any popups
|
||||
wtype -k Escape
|
||||
sleep 0.2
|
||||
|
||||
echo ""
|
||||
echo "🎉 Done! Screenshots saved to $OUTPUT_DIR/"
|
||||
ls -la "$OUTPUT_DIR/"*.png 2>/dev/null || echo "No screenshots found"
|
||||
Executable
+35
@@ -0,0 +1,35 @@
|
||||
#!/bin/bash
|
||||
# Capture jcode screenshots with your actual terminal theme
|
||||
# Usage: ./capture_screenshot.sh [output_name]
|
||||
|
||||
set -e
|
||||
|
||||
OUTPUT_DIR="$(dirname "$0")/../docs/screenshots"
|
||||
OUTPUT_NAME="${1:-jcode-screenshot}"
|
||||
OUTPUT_PATH="$OUTPUT_DIR/${OUTPUT_NAME}.png"
|
||||
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
|
||||
echo "📸 jcode Screenshot Capture"
|
||||
echo ""
|
||||
echo "Instructions:"
|
||||
echo " 1. Make sure jcode is running in a visible terminal"
|
||||
echo " 2. Set up the UI state you want to capture"
|
||||
echo " 3. Press Enter here, then click on the jcode window"
|
||||
echo ""
|
||||
read -p "Press Enter when ready..."
|
||||
|
||||
# Use slurp to let user select a window/region, then capture with grim
|
||||
GEOMETRY=$(slurp)
|
||||
if [ -n "$GEOMETRY" ]; then
|
||||
grim -g "$GEOMETRY" "$OUTPUT_PATH"
|
||||
echo "✅ Saved to: $OUTPUT_PATH"
|
||||
|
||||
# Show the image dimensions
|
||||
if command -v file &>/dev/null; then
|
||||
file "$OUTPUT_PATH"
|
||||
fi
|
||||
else
|
||||
echo "❌ No region selected"
|
||||
exit 1
|
||||
fi
|
||||
Executable
+25
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Thin wrapper used by the test/refactor helper scripts (test_fast.sh,
|
||||
# test_e2e.sh, refactor_phase1_verify.sh, agent_trace.sh, real_provider_smoke.sh).
|
||||
#
|
||||
# Historically this exec'd bare `cargo "$@"`, which meant the test/check
|
||||
# workflows ran with NONE of the adaptive build controls the selfdev path gets
|
||||
# via dev_cargo.sh: no memory-aware job sizing, no fast linker (clang+lld/mold),
|
||||
# and no remote-cargo offload beyond a manual env check. On a memory-constrained
|
||||
# host that let a `cargo test`/`cargo check` here assume the full core count and
|
||||
# trip earlyoom/OOM while a concurrent selfdev build was running -- the two
|
||||
# wrappers fragmenting the build instead of cooperating.
|
||||
#
|
||||
# Delegating to dev_cargo.sh unifies both paths: test/check builds now get the
|
||||
# same MemAvailable-based CARGO_BUILD_JOBS throttle, the same fast linker, and
|
||||
# the same remote-cargo handling (dev_cargo.sh performs its own JCODE_REMOTE_CARGO
|
||||
# preflight, so we no longer duplicate it here). dev_cargo.sh passes every arg
|
||||
# straight through to cargo, only layering env/setup, so `test`/`check`/`build`
|
||||
# semantics are unchanged. The low-memory *selfdev profile* overrides remain
|
||||
# gated to --profile selfdev inside dev_cargo.sh, so plain `cargo test` (test
|
||||
# profile, debug-assertions on) is untouched aside from the job/linker tuning.
|
||||
repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
|
||||
exec "$repo_root/scripts/dev_cargo.sh" "$@"
|
||||
@@ -0,0 +1,175 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Enforce a ratcheting Rust file-size budget.
|
||||
|
||||
This script keeps the current oversized-file debt from getting worse while the
|
||||
larger refactor program is underway.
|
||||
|
||||
Policy:
|
||||
- Production Rust files above the configured LOC threshold are tracked in a
|
||||
baseline file.
|
||||
- Existing tracked oversized files may not grow.
|
||||
- New oversized production files may not be introduced.
|
||||
- If oversized files shrink or disappear, the script reports the improvement.
|
||||
- `--update` refreshes the baseline after intentional cleanup.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
BASELINE_FILE = REPO_ROOT / "scripts" / "code_size_budget.json"
|
||||
DEFAULT_THRESHOLD = 1200
|
||||
SCAN_ROOTS = (REPO_ROOT / "src", REPO_ROOT / "crates")
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--update",
|
||||
action="store_true",
|
||||
help="refresh the baseline to the current oversized-file set",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def is_production_rust_file(path: Path) -> bool:
|
||||
rel = path.relative_to(REPO_ROOT).as_posix()
|
||||
if path.suffix != ".rs":
|
||||
return False
|
||||
parts = rel.split("/")
|
||||
if parts[0] == "tests" or any(
|
||||
part == "tests" or part.endswith("_tests") or part.endswith("_test") or part.startswith("tests_")
|
||||
for part in parts
|
||||
):
|
||||
return False
|
||||
name = path.name
|
||||
if (
|
||||
name == "tests.rs"
|
||||
or name.endswith("_tests.rs")
|
||||
or name.endswith("_test.rs")
|
||||
or name.startswith("tests_")
|
||||
):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def rust_file_line_count(path: Path) -> int:
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
return sum(1 for _ in handle)
|
||||
|
||||
|
||||
def current_oversized_files(threshold: int) -> dict[str, int]:
|
||||
files: dict[str, int] = {}
|
||||
for root in SCAN_ROOTS:
|
||||
if not root.exists():
|
||||
continue
|
||||
for path in sorted(root.rglob("*.rs")):
|
||||
if not is_production_rust_file(path):
|
||||
continue
|
||||
line_count = rust_file_line_count(path)
|
||||
if line_count > threshold:
|
||||
files[path.relative_to(REPO_ROOT).as_posix()] = line_count
|
||||
return files
|
||||
|
||||
|
||||
def load_baseline() -> dict[str, Any]:
|
||||
if not BASELINE_FILE.exists():
|
||||
raise SystemExit(f"error: missing baseline file: {BASELINE_FILE}")
|
||||
data = json.loads(BASELINE_FILE.read_text(encoding="utf-8"))
|
||||
if not isinstance(data, dict):
|
||||
raise SystemExit(f"error: invalid baseline file format: {BASELINE_FILE}")
|
||||
threshold = data.get("threshold_loc")
|
||||
tracked = data.get("tracked_files")
|
||||
if not isinstance(threshold, int) or threshold <= 0:
|
||||
raise SystemExit(f"error: invalid threshold_loc in {BASELINE_FILE}")
|
||||
if not isinstance(tracked, dict) or any(
|
||||
not isinstance(k, str) or not isinstance(v, int) or v <= 0
|
||||
for k, v in tracked.items()
|
||||
):
|
||||
raise SystemExit(f"error: invalid tracked_files in {BASELINE_FILE}")
|
||||
return data
|
||||
|
||||
|
||||
def write_baseline(threshold: int, tracked_files: dict[str, int]) -> None:
|
||||
payload = {
|
||||
"version": 1,
|
||||
"threshold_loc": threshold,
|
||||
"tracked_files": tracked_files,
|
||||
}
|
||||
BASELINE_FILE.write_text(
|
||||
json.dumps(payload, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
baseline = load_baseline()
|
||||
threshold = baseline["threshold_loc"]
|
||||
current = current_oversized_files(threshold)
|
||||
|
||||
if args.update:
|
||||
write_baseline(threshold, current)
|
||||
print(
|
||||
"Updated code-size baseline: "
|
||||
f"tracked={len(baseline['tracked_files'])} -> {len(current)} oversized files"
|
||||
)
|
||||
return 0
|
||||
|
||||
tracked: dict[str, int] = baseline["tracked_files"]
|
||||
regressions: list[str] = []
|
||||
improvements: list[str] = []
|
||||
|
||||
for path, lines in sorted(current.items()):
|
||||
old_lines = tracked.get(path)
|
||||
if old_lines is None:
|
||||
regressions.append(
|
||||
f"new oversized file exceeds {threshold} LOC: {path} ({lines} LOC)"
|
||||
)
|
||||
elif lines > old_lines:
|
||||
regressions.append(
|
||||
f"oversized file grew: {path} ({old_lines} -> {lines} LOC)"
|
||||
)
|
||||
elif lines < old_lines:
|
||||
improvements.append(f"oversized file shrank: {path} ({old_lines} -> {lines} LOC)")
|
||||
|
||||
for path, old_lines in sorted(tracked.items()):
|
||||
if path not in current:
|
||||
improvements.append(
|
||||
f"oversized file no longer exceeds {threshold} LOC: {path} ({old_lines} -> OK)"
|
||||
)
|
||||
|
||||
if regressions:
|
||||
print(
|
||||
"Code-size budget exceeded. Existing oversized Rust files must shrink or stay flat, "
|
||||
"and new oversized production files are not allowed:",
|
||||
file=sys.stderr,
|
||||
)
|
||||
for entry in regressions:
|
||||
print(f" - {entry}", file=sys.stderr)
|
||||
print(
|
||||
"Run scripts/check_code_size_budget.py --update only after intentional cleanup.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
if improvements:
|
||||
print("Code-size budget improved:")
|
||||
for entry in improvements:
|
||||
print(f" - {entry}")
|
||||
print("Consider running: scripts/check_code_size_budget.py --update")
|
||||
else:
|
||||
print(
|
||||
"Code-size budget OK: "
|
||||
f"tracked={len(tracked)} threshold={threshold}LOC no oversized-file regressions"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+115
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check lightweight crate dependency boundaries.
|
||||
|
||||
Type crates should remain data-contract crates. This guard intentionally starts
|
||||
small: it blocks direct dependencies from any `jcode-*-types` crate to root or
|
||||
runtime-heavy internal crates. It allows external dependencies for now, while
|
||||
making internal domain leaks visible and easy to extend.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
# Internal crates that are allowed as dependencies of type crates.
|
||||
# Keep this list narrow. Add a crate only if it is itself a data-contract crate.
|
||||
ALLOWED_INTERNAL_TYPE_DEPS = {
|
||||
"jcode-message-types",
|
||||
}
|
||||
|
||||
# Internal crates that type crates must not depend on directly. Most are runtime,
|
||||
# provider, UI, storage, or root behavior crates. `jcode-core` is intentionally
|
||||
# blocked so it does not become the backdoor catch-all dependency for DTO crates.
|
||||
FORBIDDEN_INTERNAL_DEPS = {
|
||||
"jcode",
|
||||
"jcode-agent-runtime",
|
||||
"jcode-azure-auth",
|
||||
"jcode-core",
|
||||
"jcode-desktop",
|
||||
"jcode-embedding",
|
||||
"jcode-mobile-core",
|
||||
"jcode-mobile-sim",
|
||||
"jcode-notify-email",
|
||||
"jcode-pdf",
|
||||
"jcode-plan",
|
||||
"jcode-provider-core",
|
||||
"jcode-provider-gemini",
|
||||
"jcode-provider-metadata",
|
||||
"jcode-provider-openrouter",
|
||||
"jcode-protocol",
|
||||
"jcode-terminal-launch",
|
||||
"jcode-tui-core",
|
||||
"jcode-tui-markdown",
|
||||
"jcode-tui-mermaid",
|
||||
"jcode-tui-render",
|
||||
"jcode-tui-workspace",
|
||||
}
|
||||
|
||||
|
||||
def cargo_metadata() -> dict:
|
||||
result = subprocess.run(
|
||||
["cargo", "metadata", "--no-deps", "--format-version", "1"],
|
||||
cwd=ROOT,
|
||||
check=True,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def is_type_crate(name: str) -> bool:
|
||||
return name.startswith("jcode-") and name.endswith("-types")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
metadata = cargo_metadata()
|
||||
package_by_id = {package["id"]: package for package in metadata["packages"]}
|
||||
workspace_ids = set(metadata["workspace_members"])
|
||||
workspace_names = {
|
||||
package_by_id[package_id]["name"] for package_id in workspace_ids if package_id in package_by_id
|
||||
}
|
||||
|
||||
errors: list[str] = []
|
||||
warnings: list[str] = []
|
||||
|
||||
for package_id in sorted(workspace_ids, key=lambda pid: package_by_id[pid]["name"]):
|
||||
package = package_by_id[package_id]
|
||||
name = package["name"]
|
||||
if not is_type_crate(name):
|
||||
continue
|
||||
|
||||
for dep in package.get("dependencies", []):
|
||||
dep_name = dep["name"]
|
||||
if dep_name not in workspace_names:
|
||||
continue
|
||||
if dep_name in ALLOWED_INTERNAL_TYPE_DEPS:
|
||||
continue
|
||||
if is_type_crate(dep_name):
|
||||
continue
|
||||
if dep_name in FORBIDDEN_INTERNAL_DEPS:
|
||||
errors.append(f"{name} must not depend on runtime/internal crate {dep_name}")
|
||||
else:
|
||||
warnings.append(
|
||||
f"{name} depends on internal non-type crate {dep_name}; review boundary policy"
|
||||
)
|
||||
|
||||
for warning in warnings:
|
||||
print(f"warning: {warning}", file=sys.stderr)
|
||||
for error in errors:
|
||||
print(f"error: {error}", file=sys.stderr)
|
||||
|
||||
if errors:
|
||||
print(f"dependency boundary check failed: {len(errors)} error(s)", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print("dependency boundary check passed")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,200 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Enforce a ratcheting production panic-prone usage budget.
|
||||
|
||||
Counts production Rust occurrences of:
|
||||
- `.unwrap(`
|
||||
- `.expect(`
|
||||
- `panic!`, `todo!`, `unimplemented!`
|
||||
|
||||
Policy:
|
||||
- Existing files may not increase their count.
|
||||
- New production files may not introduce panic-prone usage.
|
||||
- Total count may not increase.
|
||||
- `--update` refreshes the baseline after intentional cleanup.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
BASELINE_FILE = REPO_ROOT / "scripts" / "panic_budget.json"
|
||||
SCAN_ROOTS = (REPO_ROOT / "src", REPO_ROOT / "crates")
|
||||
PATTERN = re.compile(r"\.unwrap\(|\.expect\(|\b(?:panic!|todo!|unimplemented!)")
|
||||
CFG_TEST_RE = re.compile(r"^\s*#\s*\[\s*cfg\s*\(\s*test\s*\)\s*\]")
|
||||
ITEM_START_RE = re.compile(r"^\s*(?:pub(?:\([^)]*\))?\s+)?(?:mod|fn)\b")
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--update", action="store_true", help="refresh the baseline")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def is_test_rust_file(path: Path) -> bool:
|
||||
rel = path.relative_to(REPO_ROOT).as_posix()
|
||||
if path.suffix != ".rs":
|
||||
return False
|
||||
parts = rel.split("/")
|
||||
if parts[0] == "tests" or any(
|
||||
part == "tests" or part.endswith("_tests") or part.endswith("_test") or part.startswith("tests_")
|
||||
for part in parts
|
||||
):
|
||||
return True
|
||||
name = path.name
|
||||
return (
|
||||
name == "tests.rs"
|
||||
or name.endswith("_tests.rs")
|
||||
or name.endswith("_test.rs")
|
||||
or name.startswith("tests_")
|
||||
)
|
||||
|
||||
|
||||
def production_rust_files() -> list[Path]:
|
||||
files: list[Path] = []
|
||||
for root in SCAN_ROOTS:
|
||||
if not root.exists():
|
||||
continue
|
||||
for path in sorted(root.rglob("*.rs")):
|
||||
if path.suffix == ".rs" and not is_test_rust_file(path):
|
||||
files.append(path)
|
||||
return files
|
||||
|
||||
|
||||
def brace_delta(line: str) -> int:
|
||||
"""Approximate Rust block nesting for budget classification.
|
||||
|
||||
The panic budget is a ratchet, not a parser. This intentionally simple scan
|
||||
ignores comments and strings, which is acceptable for excluding normal
|
||||
`#[cfg(test)] mod tests { ... }` blocks from production counts.
|
||||
"""
|
||||
return line.count("{") - line.count("}")
|
||||
|
||||
|
||||
def production_lines(path: Path) -> list[str]:
|
||||
lines = path.read_text(encoding="utf-8", errors="ignore").splitlines()
|
||||
output: list[str] = []
|
||||
skip_stack: list[int] = []
|
||||
pending_cfg_test = False
|
||||
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
current_depth = sum(skip_stack)
|
||||
if current_depth == 0:
|
||||
if pending_cfg_test and ITEM_START_RE.match(line):
|
||||
delta = brace_delta(line)
|
||||
if delta > 0:
|
||||
skip_stack.append(delta)
|
||||
pending_cfg_test = False
|
||||
continue
|
||||
if pending_cfg_test and stripped and not stripped.startswith("#"):
|
||||
pending_cfg_test = False
|
||||
if CFG_TEST_RE.match(line):
|
||||
pending_cfg_test = True
|
||||
continue
|
||||
output.append(line)
|
||||
else:
|
||||
skip_stack[-1] += brace_delta(line)
|
||||
if skip_stack[-1] <= 0:
|
||||
skip_stack.pop()
|
||||
return output
|
||||
|
||||
|
||||
def current_counts() -> dict[str, int]:
|
||||
counts: dict[str, int] = {}
|
||||
for path in production_rust_files():
|
||||
count = sum(1 for line in production_lines(path) if PATTERN.search(line))
|
||||
if count:
|
||||
counts[path.relative_to(REPO_ROOT).as_posix()] = count
|
||||
return counts
|
||||
|
||||
|
||||
def load_baseline() -> dict[str, Any]:
|
||||
if not BASELINE_FILE.exists():
|
||||
return {"version": 1, "total": 0, "tracked_files": {}}
|
||||
data = json.loads(BASELINE_FILE.read_text(encoding="utf-8"))
|
||||
if not isinstance(data, dict):
|
||||
raise SystemExit(f"error: invalid baseline file format: {BASELINE_FILE}")
|
||||
total = data.get("total")
|
||||
tracked = data.get("tracked_files")
|
||||
if not isinstance(total, int) or total < 0:
|
||||
raise SystemExit(f"error: invalid total in {BASELINE_FILE}")
|
||||
if not isinstance(tracked, dict) or any(
|
||||
not isinstance(k, str) or not isinstance(v, int) or v <= 0 for k, v in tracked.items()
|
||||
):
|
||||
raise SystemExit(f"error: invalid tracked_files in {BASELINE_FILE}")
|
||||
return data
|
||||
|
||||
|
||||
def write_baseline(counts: dict[str, int]) -> None:
|
||||
BASELINE_FILE.write_text(
|
||||
json.dumps(
|
||||
{"version": 1, "total": sum(counts.values()), "tracked_files": counts},
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
baseline = load_baseline()
|
||||
current = current_counts()
|
||||
current_total = sum(current.values())
|
||||
|
||||
if args.update:
|
||||
write_baseline(current)
|
||||
print(
|
||||
"Updated panic-prone baseline: "
|
||||
f"total={baseline['total']} -> {current_total}, files={len(baseline['tracked_files'])} -> {len(current)}"
|
||||
)
|
||||
return 0
|
||||
|
||||
tracked: dict[str, int] = baseline["tracked_files"]
|
||||
regressions: list[str] = []
|
||||
improvements: list[str] = []
|
||||
|
||||
if current_total > baseline["total"]:
|
||||
regressions.append(f"total panic-prone count grew: {baseline['total']} -> {current_total}")
|
||||
elif current_total < baseline["total"]:
|
||||
improvements.append(f"total panic-prone count shrank: {baseline['total']} -> {current_total}")
|
||||
|
||||
for path, count in sorted(current.items()):
|
||||
old_count = tracked.get(path)
|
||||
if old_count is None:
|
||||
regressions.append(f"new production panic-prone usage: {path} ({count})")
|
||||
elif count > old_count:
|
||||
regressions.append(f"production panic-prone usage grew: {path} ({old_count} -> {count})")
|
||||
elif count < old_count:
|
||||
improvements.append(f"production panic-prone usage shrank: {path} ({old_count} -> {count})")
|
||||
|
||||
for path, old_count in sorted(tracked.items()):
|
||||
if path not in current:
|
||||
improvements.append(f"production panic-prone usage removed: {path} ({old_count} -> 0)")
|
||||
|
||||
if regressions:
|
||||
print("Panic-prone usage budget exceeded:", file=sys.stderr)
|
||||
for entry in regressions:
|
||||
print(f" - {entry}", file=sys.stderr)
|
||||
print("Run scripts/check_panic_budget.py --update only after intentional cleanup.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if improvements:
|
||||
print("Panic-prone usage budget improved:")
|
||||
for entry in improvements:
|
||||
print(f" - {entry}")
|
||||
print("Consider running: scripts/check_panic_budget.py --update")
|
||||
else:
|
||||
print(f"Panic-prone budget OK: total={current_total} files={len(current)}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,44 @@
|
||||
param(
|
||||
[string[]]$Paths = @("scripts")
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
$scriptFiles = @()
|
||||
foreach ($path in $Paths) {
|
||||
if (-not (Test-Path -LiteralPath $path)) {
|
||||
continue
|
||||
}
|
||||
|
||||
$scriptFiles += Get-ChildItem -LiteralPath $path -Recurse -File -Filter '*.ps1'
|
||||
}
|
||||
|
||||
if (-not $scriptFiles -or $scriptFiles.Count -eq 0) {
|
||||
Write-Host 'No PowerShell scripts found.' -ForegroundColor Yellow
|
||||
exit 0
|
||||
}
|
||||
|
||||
$hadErrors = $false
|
||||
|
||||
foreach ($file in $scriptFiles | Sort-Object FullName -Unique) {
|
||||
$tokens = $null
|
||||
$errors = $null
|
||||
[System.Management.Automation.Language.Parser]::ParseFile($file.FullName, [ref]$tokens, [ref]$errors) | Out-Null
|
||||
|
||||
if ($errors -and $errors.Count -gt 0) {
|
||||
$hadErrors = $true
|
||||
Write-Host "Parse errors in $($file.FullName):" -ForegroundColor Red
|
||||
foreach ($error in $errors) {
|
||||
$line = $error.Extent.StartLineNumber
|
||||
$column = $error.Extent.StartColumnNumber
|
||||
Write-Host " Line ${line}, Col ${column}: $($error.Message)" -ForegroundColor Red
|
||||
}
|
||||
} else {
|
||||
Write-Host "OK: $($file.FullName)" -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
|
||||
if ($hadErrors) {
|
||||
exit 1
|
||||
}
|
||||
Executable
+13
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
binary=${1:-"$repo_root/target/release/jcode"}
|
||||
|
||||
if [[ ! -x "$binary" ]]; then
|
||||
echo "Binary not found or not executable: $binary" >&2
|
||||
echo "Build it first with: cargo build --release" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec python3 "$repo_root/scripts/bench_startup.py" "$binary" --check --runs 3
|
||||
@@ -0,0 +1,252 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Enforce a ratcheting budget for swallowed-error-like Rust patterns.
|
||||
|
||||
This is intentionally a broad guardrail. It tracks production occurrences of
|
||||
patterns that commonly hide failures and should either be removed, logged,
|
||||
propagated, or explicitly accepted as best-effort:
|
||||
|
||||
- `let _ = ...`
|
||||
- `.ok()`
|
||||
- `.unwrap_or_default()`
|
||||
|
||||
Policy:
|
||||
- Existing files may not increase their count.
|
||||
- New production files may not introduce these patterns.
|
||||
- Total count may not increase.
|
||||
- `--update` refreshes the baseline after intentional cleanup.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
BASELINE_FILE = REPO_ROOT / "scripts" / "swallowed_error_budget.json"
|
||||
SCAN_ROOTS = (REPO_ROOT / "src", REPO_ROOT / "crates")
|
||||
PATTERNS = {
|
||||
"let_underscore": re.compile(r"\blet\s+_\s*="),
|
||||
"dot_ok": re.compile(r"\.ok\(\)"),
|
||||
"unwrap_or_default": re.compile(r"\.unwrap_or_default\(\)"),
|
||||
}
|
||||
CFG_TEST_RE = re.compile(r"^\s*#\s*\[\s*cfg\s*\(\s*test\s*\)\s*\]")
|
||||
ITEM_START_RE = re.compile(r"^\s*(?:pub(?:\([^)]*\))?\s+)?(?:mod|fn)\b")
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--update", action="store_true", help="refresh the baseline")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def is_test_rust_file(path: Path) -> bool:
|
||||
rel = path.relative_to(REPO_ROOT).as_posix()
|
||||
if path.suffix != ".rs":
|
||||
return False
|
||||
parts = rel.split("/")
|
||||
if parts[0] == "tests" or any(
|
||||
part == "tests" or part.endswith("_tests") or part.endswith("_test") or part.startswith("tests_")
|
||||
for part in parts
|
||||
):
|
||||
return True
|
||||
name = path.name
|
||||
return (
|
||||
name == "tests.rs"
|
||||
or name.endswith("_tests.rs")
|
||||
or name.endswith("_test.rs")
|
||||
or name.startswith("tests_")
|
||||
)
|
||||
|
||||
|
||||
def production_rust_files() -> list[Path]:
|
||||
files: list[Path] = []
|
||||
for root in SCAN_ROOTS:
|
||||
if not root.exists():
|
||||
continue
|
||||
for path in sorted(root.rglob("*.rs")):
|
||||
if path.suffix == ".rs" and not is_test_rust_file(path):
|
||||
files.append(path)
|
||||
return files
|
||||
|
||||
|
||||
def brace_delta(line: str) -> int:
|
||||
return line.count("{") - line.count("}")
|
||||
|
||||
|
||||
def production_lines(path: Path) -> list[str]:
|
||||
lines = path.read_text(encoding="utf-8", errors="ignore").splitlines()
|
||||
output: list[str] = []
|
||||
skip_stack: list[int] = []
|
||||
pending_cfg_test = False
|
||||
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
current_depth = sum(skip_stack)
|
||||
if current_depth == 0:
|
||||
if pending_cfg_test and ITEM_START_RE.match(line):
|
||||
delta = brace_delta(line)
|
||||
if delta > 0:
|
||||
skip_stack.append(delta)
|
||||
pending_cfg_test = False
|
||||
continue
|
||||
if pending_cfg_test and stripped and not stripped.startswith("#"):
|
||||
pending_cfg_test = False
|
||||
if CFG_TEST_RE.match(line):
|
||||
pending_cfg_test = True
|
||||
continue
|
||||
output.append(line)
|
||||
else:
|
||||
skip_stack[-1] += brace_delta(line)
|
||||
if skip_stack[-1] <= 0:
|
||||
skip_stack.pop()
|
||||
return output
|
||||
|
||||
|
||||
def zero_counts() -> dict[str, int]:
|
||||
return {name: 0 for name in PATTERNS}
|
||||
|
||||
|
||||
def current_counts() -> dict[str, dict[str, int]]:
|
||||
counts: dict[str, dict[str, int]] = {}
|
||||
for path in production_rust_files():
|
||||
file_counts = zero_counts()
|
||||
for line in production_lines(path):
|
||||
for name, pattern in PATTERNS.items():
|
||||
if pattern.search(line):
|
||||
file_counts[name] += 1
|
||||
if sum(file_counts.values()) > 0:
|
||||
counts[path.relative_to(REPO_ROOT).as_posix()] = file_counts
|
||||
return counts
|
||||
|
||||
|
||||
def file_total(counts: dict[str, int]) -> int:
|
||||
return sum(counts.values())
|
||||
|
||||
|
||||
def total_counts(counts: dict[str, dict[str, int]]) -> dict[str, int]:
|
||||
totals = zero_counts()
|
||||
for file_counts in counts.values():
|
||||
for name, count in file_counts.items():
|
||||
totals[name] = totals.get(name, 0) + count
|
||||
return totals
|
||||
|
||||
|
||||
def grand_total(counts: dict[str, dict[str, int]]) -> int:
|
||||
return sum(file_total(file_counts) for file_counts in counts.values())
|
||||
|
||||
|
||||
def load_baseline() -> dict[str, Any]:
|
||||
if not BASELINE_FILE.exists():
|
||||
return {"version": 1, "total": 0, "totals_by_pattern": zero_counts(), "tracked_files": {}}
|
||||
data = json.loads(BASELINE_FILE.read_text(encoding="utf-8"))
|
||||
if not isinstance(data, dict):
|
||||
raise SystemExit(f"error: invalid baseline file format: {BASELINE_FILE}")
|
||||
tracked = data.get("tracked_files")
|
||||
totals_by_pattern = data.get("totals_by_pattern")
|
||||
total = data.get("total")
|
||||
if not isinstance(total, int) or total < 0:
|
||||
raise SystemExit(f"error: invalid total in {BASELINE_FILE}")
|
||||
if not isinstance(totals_by_pattern, dict):
|
||||
raise SystemExit(f"error: invalid totals_by_pattern in {BASELINE_FILE}")
|
||||
if not isinstance(tracked, dict):
|
||||
raise SystemExit(f"error: invalid tracked_files in {BASELINE_FILE}")
|
||||
for path, file_counts in tracked.items():
|
||||
if not isinstance(path, str) or not isinstance(file_counts, dict):
|
||||
raise SystemExit(f"error: invalid tracked_files entry in {BASELINE_FILE}")
|
||||
if any(not isinstance(v, int) or v < 0 for v in file_counts.values()):
|
||||
raise SystemExit(f"error: invalid count in tracked_files entry for {path}")
|
||||
return data
|
||||
|
||||
|
||||
def write_baseline(counts: dict[str, dict[str, int]]) -> None:
|
||||
BASELINE_FILE.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"version": 1,
|
||||
"total": grand_total(counts),
|
||||
"totals_by_pattern": total_counts(counts),
|
||||
"tracked_files": counts,
|
||||
},
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
baseline = load_baseline()
|
||||
current = current_counts()
|
||||
current_total = grand_total(current)
|
||||
current_pattern_totals = total_counts(current)
|
||||
|
||||
if args.update:
|
||||
write_baseline(current)
|
||||
print(
|
||||
"Updated swallowed-error baseline: "
|
||||
f"total={baseline['total']} -> {current_total}, "
|
||||
f"files={len(baseline['tracked_files'])} -> {len(current)}"
|
||||
)
|
||||
return 0
|
||||
|
||||
tracked: dict[str, dict[str, int]] = baseline["tracked_files"]
|
||||
regressions: list[str] = []
|
||||
improvements: list[str] = []
|
||||
|
||||
if current_total > baseline["total"]:
|
||||
regressions.append(f"total swallowed-error-like count grew: {baseline['total']} -> {current_total}")
|
||||
elif current_total < baseline["total"]:
|
||||
improvements.append(f"total swallowed-error-like count shrank: {baseline['total']} -> {current_total}")
|
||||
|
||||
baseline_pattern_totals: dict[str, int] = baseline["totals_by_pattern"]
|
||||
for name, count in sorted(current_pattern_totals.items()):
|
||||
old_count = baseline_pattern_totals.get(name, 0)
|
||||
if count > old_count:
|
||||
regressions.append(f"{name} count grew: {old_count} -> {count}")
|
||||
elif count < old_count:
|
||||
improvements.append(f"{name} count shrank: {old_count} -> {count}")
|
||||
|
||||
for path, file_counts in sorted(current.items()):
|
||||
old_counts = tracked.get(path)
|
||||
if old_counts is None:
|
||||
regressions.append(f"new swallowed-error-like usage: {path} ({file_total(file_counts)})")
|
||||
continue
|
||||
old_total = file_total(old_counts)
|
||||
new_total = file_total(file_counts)
|
||||
if new_total > old_total:
|
||||
regressions.append(f"swallowed-error-like usage grew: {path} ({old_total} -> {new_total})")
|
||||
elif new_total < old_total:
|
||||
improvements.append(f"swallowed-error-like usage shrank: {path} ({old_total} -> {new_total})")
|
||||
|
||||
for path, old_counts in sorted(tracked.items()):
|
||||
if path not in current:
|
||||
improvements.append(f"swallowed-error-like usage removed: {path} ({file_total(old_counts)} -> 0)")
|
||||
|
||||
if regressions:
|
||||
print("Swallowed-error budget exceeded:", file=sys.stderr)
|
||||
for entry in regressions:
|
||||
print(f" - {entry}", file=sys.stderr)
|
||||
print("Run scripts/check_swallowed_error_budget.py --update only after intentional cleanup.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if improvements:
|
||||
print("Swallowed-error budget improved:")
|
||||
for entry in improvements:
|
||||
print(f" - {entry}")
|
||||
print("Consider running: scripts/check_swallowed_error_budget.py --update")
|
||||
else:
|
||||
print(
|
||||
"Swallowed-error budget OK: "
|
||||
f"total={current_total} files={len(current)} patterns={current_pattern_totals}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Enforce a ratcheting Rust test-file size budget.
|
||||
|
||||
Policy:
|
||||
- Test Rust files above the configured LOC threshold are tracked in a baseline.
|
||||
- Existing tracked oversized test files may not grow.
|
||||
- New oversized test files may not be introduced.
|
||||
- `--update` refreshes the baseline after intentional cleanup.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
BASELINE_FILE = REPO_ROOT / "scripts" / "test_size_budget.json"
|
||||
DEFAULT_THRESHOLD = 1200
|
||||
SCAN_ROOTS = (REPO_ROOT / "src", REPO_ROOT / "crates", REPO_ROOT / "tests")
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--update", action="store_true", help="refresh the baseline")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def is_test_rust_file(path: Path) -> bool:
|
||||
rel = path.relative_to(REPO_ROOT).as_posix()
|
||||
if path.suffix != ".rs":
|
||||
return False
|
||||
parts = rel.split("/")
|
||||
if parts[0] == "tests" or any(
|
||||
part == "tests" or part.endswith("_tests") or part.endswith("_test") or part.startswith("tests_")
|
||||
for part in parts
|
||||
):
|
||||
return True
|
||||
name = path.name
|
||||
return (
|
||||
name == "tests.rs"
|
||||
or name.endswith("_tests.rs")
|
||||
or name.endswith("_test.rs")
|
||||
or name.startswith("tests_")
|
||||
)
|
||||
|
||||
|
||||
def rust_file_line_count(path: Path) -> int:
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
return sum(1 for _ in handle)
|
||||
|
||||
|
||||
def current_oversized_files(threshold: int) -> dict[str, int]:
|
||||
files: dict[str, int] = {}
|
||||
for root in SCAN_ROOTS:
|
||||
if not root.exists():
|
||||
continue
|
||||
for path in sorted(root.rglob("*.rs")):
|
||||
if not is_test_rust_file(path):
|
||||
continue
|
||||
line_count = rust_file_line_count(path)
|
||||
if line_count > threshold:
|
||||
files[path.relative_to(REPO_ROOT).as_posix()] = line_count
|
||||
return files
|
||||
|
||||
|
||||
def load_baseline() -> dict[str, Any]:
|
||||
if not BASELINE_FILE.exists():
|
||||
return {"version": 1, "threshold_loc": DEFAULT_THRESHOLD, "tracked_files": {}}
|
||||
data = json.loads(BASELINE_FILE.read_text(encoding="utf-8"))
|
||||
if not isinstance(data, dict):
|
||||
raise SystemExit(f"error: invalid baseline file format: {BASELINE_FILE}")
|
||||
threshold = data.get("threshold_loc")
|
||||
tracked = data.get("tracked_files")
|
||||
if not isinstance(threshold, int) or threshold <= 0:
|
||||
raise SystemExit(f"error: invalid threshold_loc in {BASELINE_FILE}")
|
||||
if not isinstance(tracked, dict) or any(
|
||||
not isinstance(k, str) or not isinstance(v, int) or v <= 0 for k, v in tracked.items()
|
||||
):
|
||||
raise SystemExit(f"error: invalid tracked_files in {BASELINE_FILE}")
|
||||
return data
|
||||
|
||||
|
||||
def write_baseline(threshold: int, tracked_files: dict[str, int]) -> None:
|
||||
BASELINE_FILE.write_text(
|
||||
json.dumps(
|
||||
{"version": 1, "threshold_loc": threshold, "tracked_files": tracked_files},
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
baseline = load_baseline()
|
||||
threshold = baseline["threshold_loc"]
|
||||
current = current_oversized_files(threshold)
|
||||
|
||||
if args.update:
|
||||
write_baseline(threshold, current)
|
||||
print(
|
||||
"Updated test-size baseline: "
|
||||
f"tracked={len(baseline['tracked_files'])} -> {len(current)} oversized test files"
|
||||
)
|
||||
return 0
|
||||
|
||||
tracked: dict[str, int] = baseline["tracked_files"]
|
||||
regressions: list[str] = []
|
||||
improvements: list[str] = []
|
||||
|
||||
for path, lines in sorted(current.items()):
|
||||
old_lines = tracked.get(path)
|
||||
if old_lines is None:
|
||||
regressions.append(f"new oversized test file exceeds {threshold} LOC: {path} ({lines} LOC)")
|
||||
elif lines > old_lines:
|
||||
regressions.append(f"oversized test file grew: {path} ({old_lines} -> {lines} LOC)")
|
||||
elif lines < old_lines:
|
||||
improvements.append(f"oversized test file shrank: {path} ({old_lines} -> {lines} LOC)")
|
||||
|
||||
for path, old_lines in sorted(tracked.items()):
|
||||
if path not in current:
|
||||
improvements.append(
|
||||
f"oversized test file no longer exceeds {threshold} LOC: {path} ({old_lines} -> OK)"
|
||||
)
|
||||
|
||||
if regressions:
|
||||
print("Test-size budget exceeded:", file=sys.stderr)
|
||||
for entry in regressions:
|
||||
print(f" - {entry}", file=sys.stderr)
|
||||
print("Run scripts/check_test_size_budget.py --update only after intentional cleanup.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if improvements:
|
||||
print("Test-size budget improved:")
|
||||
for entry in improvements:
|
||||
print(f" - {entry}")
|
||||
print("Consider running: scripts/check_test_size_budget.py --update")
|
||||
else:
|
||||
print(f"Test-size budget OK: tracked={len(tracked)} threshold={threshold}LOC")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+55
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
baseline_file="$repo_root/scripts/warning_budget.txt"
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage:
|
||||
scripts/check_warning_budget.sh # fail if warnings exceed baseline
|
||||
scripts/check_warning_budget.sh --update # update baseline to current warning count
|
||||
|
||||
Notes:
|
||||
- Counts Rust compiler lines that begin with "warning:" from `cargo check -q`
|
||||
- Baseline is stored in scripts/warning_budget.txt
|
||||
USAGE
|
||||
}
|
||||
|
||||
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
|
||||
usage
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ ! -f "$baseline_file" ]]; then
|
||||
echo "error: missing baseline file: $baseline_file" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
current=$(cd "$repo_root" && CARGO_TERM_COLOR=never cargo check -q 2>&1 | rg -c '^warning:' || printf '0\n')
|
||||
baseline=$(tr -d '[:space:]' < "$baseline_file")
|
||||
|
||||
if [[ "${1:-}" == "--update" ]]; then
|
||||
printf '%s\n' "$current" > "$baseline_file"
|
||||
echo "Updated warning baseline: $baseline"
|
||||
echo "New warning baseline: $current"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! [[ "$baseline" =~ ^[0-9]+$ ]]; then
|
||||
echo "error: invalid warning baseline in $baseline_file: '$baseline'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if (( current > baseline )); then
|
||||
echo "Warning budget exceeded: current=$current baseline=$baseline" >&2
|
||||
echo "Run scripts/check_warning_budget.sh --update only after intentional cleanup." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if (( current < baseline )); then
|
||||
echo "Warning budget improved: current=$current baseline=$baseline"
|
||||
echo "Consider running: scripts/check_warning_budget.sh --update"
|
||||
else
|
||||
echo "Warning budget OK: current=$current baseline=$baseline"
|
||||
fi
|
||||
Executable
+165
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Enforce a ratcheting cross-crate wildcard re-export budget.
|
||||
|
||||
Counts production Rust occurrences of `pub use jcode_<crate>::*;` (cross-crate
|
||||
wildcard re-exports). These re-exports erase crate boundaries: every symbol in
|
||||
the re-exported crate becomes reachable through the re-exporting crate, so new
|
||||
code silently couples across layers and edits to the re-exported crate rebuild
|
||||
the whole spine.
|
||||
|
||||
Module-internal wildcards (`pub use self::...`, `pub use super::...`,
|
||||
`pub use crate::...`, `pub use <module>::*` within a crate) are allowed; only
|
||||
`jcode_*` cross-crate wildcards are budgeted.
|
||||
|
||||
Policy:
|
||||
- Existing files may not increase their count.
|
||||
- New production files may not introduce cross-crate wildcard re-exports.
|
||||
- Total count may not increase.
|
||||
- `--update` refreshes the baseline after intentional cleanup.
|
||||
|
||||
The long-term goal is to drive this budget to zero as the migration-era
|
||||
re-export spine (base -> app-core -> tui -> root) is dismantled. See
|
||||
docs/CRATE_OWNERSHIP_BOUNDARIES.md.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
BASELINE_FILE = REPO_ROOT / "scripts" / "wildcard_reexport_budget.json"
|
||||
SCAN_ROOTS = (REPO_ROOT / "src", REPO_ROOT / "crates")
|
||||
PATTERN = re.compile(r"^\s*pub\s+use\s+(?:::)?jcode_[a-z0-9_]+(?:::[A-Za-z0-9_]+)*::\*\s*;")
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--update", action="store_true", help="refresh the baseline")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def production_rust_files() -> list[Path]:
|
||||
files: list[Path] = []
|
||||
for root in SCAN_ROOTS:
|
||||
if not root.exists():
|
||||
continue
|
||||
for path in sorted(root.rglob("*.rs")):
|
||||
rel = path.relative_to(REPO_ROOT).as_posix()
|
||||
if "/target/" in f"/{rel}/":
|
||||
continue
|
||||
files.append(path)
|
||||
return files
|
||||
|
||||
|
||||
def count_wildcards(path: Path) -> int:
|
||||
count = 0
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return 0
|
||||
for line in text.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("//"):
|
||||
continue
|
||||
if PATTERN.match(line):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def collect_counts() -> dict[str, int]:
|
||||
counts: dict[str, int] = {}
|
||||
for path in production_rust_files():
|
||||
count = count_wildcards(path)
|
||||
if count > 0:
|
||||
rel = path.relative_to(REPO_ROOT).as_posix()
|
||||
counts[rel] = count
|
||||
return counts
|
||||
|
||||
|
||||
def load_baseline() -> dict[str, Any] | None:
|
||||
if not BASELINE_FILE.exists():
|
||||
return None
|
||||
try:
|
||||
return json.loads(BASELINE_FILE.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def write_baseline(counts: dict[str, int]) -> None:
|
||||
payload = {
|
||||
"total": sum(counts.values()),
|
||||
"files": dict(sorted(counts.items())),
|
||||
}
|
||||
BASELINE_FILE.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
counts = collect_counts()
|
||||
total = sum(counts.values())
|
||||
|
||||
if args.update:
|
||||
write_baseline(counts)
|
||||
print(f"wildcard re-export baseline updated: total={total} files={len(counts)}")
|
||||
return 0
|
||||
|
||||
baseline = load_baseline()
|
||||
if baseline is None:
|
||||
print(
|
||||
"error: missing or invalid baseline file "
|
||||
f"{BASELINE_FILE.relative_to(REPO_ROOT)}; run with --update to create it",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
baseline_files: dict[str, int] = baseline.get("files", {})
|
||||
baseline_total: int = baseline.get("total", sum(baseline_files.values()))
|
||||
errors: list[str] = []
|
||||
|
||||
for rel, count in sorted(counts.items()):
|
||||
allowed = baseline_files.get(rel)
|
||||
if allowed is None:
|
||||
errors.append(
|
||||
f"{rel}: new file introduces {count} cross-crate wildcard re-export(s); "
|
||||
"use explicit `pub use crate::path::{Item, ...}` instead"
|
||||
)
|
||||
elif count > allowed:
|
||||
errors.append(
|
||||
f"{rel}: cross-crate wildcard re-exports increased from {allowed} to {count}"
|
||||
)
|
||||
|
||||
if total > baseline_total:
|
||||
errors.append(
|
||||
f"total cross-crate wildcard re-exports increased from {baseline_total} to {total}"
|
||||
)
|
||||
|
||||
if errors:
|
||||
for error in errors:
|
||||
print(f"error: {error}", file=sys.stderr)
|
||||
print(
|
||||
"wildcard re-export budget check failed. These re-exports erase crate "
|
||||
"boundaries; prefer explicit item re-exports. If a removal made the "
|
||||
"baseline stale, run scripts/check_wildcard_reexport_budget.py --update",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
if total < baseline_total or any(
|
||||
counts.get(rel, 0) < allowed for rel, allowed in baseline_files.items()
|
||||
):
|
||||
print(
|
||||
f"wildcard re-export budget check passed (total={total}, baseline={baseline_total}); "
|
||||
"consider running --update to ratchet the baseline down"
|
||||
)
|
||||
else:
|
||||
print(f"wildcard re-export budget check passed (total={total})")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+148
@@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env bash
|
||||
# Safely reclaim disk space from the Cargo target directory.
|
||||
#
|
||||
# This is designed to be safe to run even while other builds are in progress on
|
||||
# this machine (e.g. parallel self-dev agents). It will:
|
||||
# - never touch a target/<profile> dir that has an active rustc/cargo process
|
||||
# or that was written to within a recent activity window
|
||||
# - by default only remove cross-compile / compat caches and obviously stale
|
||||
# profile dirs, plus run `cargo clean` on stale profiles
|
||||
#
|
||||
# Usage:
|
||||
# scripts/clean_target.sh # dry-run: report what would be freed
|
||||
# scripts/clean_target.sh --apply # actually delete safe items
|
||||
# scripts/clean_target.sh --apply --aggressive # also sweep stale per-profile artifacts
|
||||
#
|
||||
# Env:
|
||||
# JCODE_CLEAN_ACTIVE_WINDOW_MIN activity window in minutes (default 20)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
cd "$repo_root"
|
||||
|
||||
target_dir="${CARGO_TARGET_DIR:-$repo_root/target}"
|
||||
apply="false"
|
||||
aggressive="false"
|
||||
activity_window_min="${JCODE_CLEAN_ACTIVE_WINDOW_MIN:-20}"
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--apply) apply="true" ;;
|
||||
--aggressive) aggressive="true" ;;
|
||||
-h|--help)
|
||||
sed -n '2,20p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
printf 'clean_target: unknown arg: %s\n' "$arg" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
log() { printf 'clean_target: %s\n' "$*" >&2; }
|
||||
|
||||
human() {
|
||||
# Bytes -> human readable
|
||||
numfmt --to=iec --suffix=B "${1:-0}" 2>/dev/null || printf '%sB' "${1:-0}"
|
||||
}
|
||||
|
||||
dir_bytes() {
|
||||
du -sb "$1" 2>/dev/null | awk '{print $1}'
|
||||
}
|
||||
|
||||
# Is any rustc/cargo process currently operating inside this path?
|
||||
path_has_active_process() {
|
||||
local path="$1"
|
||||
local p
|
||||
for p in $(pgrep -x rustc 2>/dev/null) $(pgrep -x cargo 2>/dev/null); do
|
||||
if tr '\0' ' ' < "/proc/$p/cmdline" 2>/dev/null | grep -qF "$path"; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Was this path written to within the activity window?
|
||||
path_recently_active() {
|
||||
local path="$1"
|
||||
[[ -d "$path" ]] || return 1
|
||||
local recent
|
||||
recent=$(find "$path" -maxdepth 3 -type f -newermt "-${activity_window_min} min" 2>/dev/null | head -1)
|
||||
[[ -n "$recent" ]]
|
||||
}
|
||||
|
||||
is_safe_to_remove() {
|
||||
local path="$1"
|
||||
[[ -d "$path" ]] || return 1
|
||||
if path_has_active_process "$path"; then
|
||||
log "SKIP (active process): $path"
|
||||
return 1
|
||||
fi
|
||||
if path_recently_active "$path"; then
|
||||
log "SKIP (written <${activity_window_min}min ago): $path"
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
total_reclaimed=0
|
||||
|
||||
remove_path() {
|
||||
local path="$1" reason="$2"
|
||||
[[ -e "$path" ]] || return 0
|
||||
local bytes
|
||||
bytes=$(dir_bytes "$path")
|
||||
bytes=${bytes:-0}
|
||||
if ! is_safe_to_remove "$path"; then
|
||||
return 0
|
||||
fi
|
||||
if [[ "$apply" == "true" ]]; then
|
||||
if rm -rf "$path" 2>/dev/null; then
|
||||
log "removed ($reason): $path [$(human "$bytes")]"
|
||||
total_reclaimed=$((total_reclaimed + bytes))
|
||||
else
|
||||
log "FAILED to remove (permissions? try sudo): $path [$(human "$bytes")]"
|
||||
fi
|
||||
else
|
||||
log "would remove ($reason): $path [$(human "$bytes")]"
|
||||
total_reclaimed=$((total_reclaimed + bytes))
|
||||
fi
|
||||
}
|
||||
|
||||
log "target dir: $target_dir (activity window: ${activity_window_min}min, apply=$apply, aggressive=$aggressive)"
|
||||
|
||||
# 1) Cross-compile / compat caches: not part of the local dev inner loop. They
|
||||
# are regenerated on demand by release/compat scripts.
|
||||
for d in "$target_dir"/*-apple-darwin "$target_dir"/*-pc-windows-* "$target_dir"/linux-compat; do
|
||||
[[ -d "$d" ]] || continue
|
||||
remove_path "$d" "cross-compile/compat cache"
|
||||
done
|
||||
|
||||
# 2) Aggressive: cargo clean on stale (not-recently-active, no active process)
|
||||
# profiles to drop accumulated fingerprints/old artifact generations.
|
||||
if [[ "$aggressive" == "true" ]]; then
|
||||
for profile_dir in "$target_dir"/debug "$target_dir"/release "$target_dir"/selfdev; do
|
||||
[[ -d "$profile_dir" ]] || continue
|
||||
profile=$(basename "$profile_dir")
|
||||
[[ "$profile" == "debug" ]] && profile="dev"
|
||||
if ! is_safe_to_remove "$profile_dir"; then
|
||||
continue
|
||||
fi
|
||||
before=$(dir_bytes "$profile_dir"); before=${before:-0}
|
||||
if [[ "$apply" == "true" ]]; then
|
||||
log "cargo clean --profile $profile (stale) ..."
|
||||
cargo clean --profile "$profile" 2>/dev/null || log " cargo clean failed for $profile"
|
||||
after=$(dir_bytes "$profile_dir"); after=${after:-0}
|
||||
freed=$((before - after))
|
||||
(( freed > 0 )) && total_reclaimed=$((total_reclaimed + freed))
|
||||
log " freed $(human "$freed") from $profile"
|
||||
else
|
||||
log "would cargo clean --profile $profile [up to $(human "$before")]"
|
||||
total_reclaimed=$((total_reclaimed + before))
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
log "total $([ "$apply" == true ] && echo reclaimed || echo reclaimable): $(human "$total_reclaimed")"
|
||||
@@ -0,0 +1,100 @@
|
||||
{
|
||||
"threshold_loc": 1200,
|
||||
"tracked_files": {
|
||||
"crates/jcode-app-core/src/agent/turn_streaming_mpsc.rs": 1641,
|
||||
"crates/jcode-app-core/src/overnight.rs": 1274,
|
||||
"crates/jcode-app-core/src/server.rs": 2034,
|
||||
"crates/jcode-app-core/src/server/client_lifecycle.rs": 3075,
|
||||
"crates/jcode-app-core/src/server/client_session.rs": 1448,
|
||||
"crates/jcode-app-core/src/server/comm_control.rs": 2625,
|
||||
"crates/jcode-app-core/src/server/comm_session.rs": 1341,
|
||||
"crates/jcode-app-core/src/server/jade_relay.rs": 1428,
|
||||
"crates/jcode-app-core/src/server/provider_control.rs": 1439,
|
||||
"crates/jcode-app-core/src/server/swarm.rs": 2824,
|
||||
"crates/jcode-app-core/src/tool/communicate.rs": 3156,
|
||||
"crates/jcode-app-core/src/tool/session_search.rs": 1892,
|
||||
"crates/jcode-app-core/src/update.rs": 1715,
|
||||
"crates/jcode-base/src/auth/lifecycle.rs": 2034,
|
||||
"crates/jcode-base/src/auth/mod.rs": 1455,
|
||||
"crates/jcode-base/src/auth/oauth.rs": 1519,
|
||||
"crates/jcode-base/src/background.rs": 1390,
|
||||
"crates/jcode-base/src/compaction.rs": 1790,
|
||||
"crates/jcode-base/src/gmail.rs": 1213,
|
||||
"crates/jcode-base/src/memory.rs": 2076,
|
||||
"crates/jcode-base/src/memory_agent.rs": 1892,
|
||||
"crates/jcode-base/src/provider/catalog_routes.rs": 1227,
|
||||
"crates/jcode-base/src/provider/mod.rs": 2602,
|
||||
"crates/jcode-base/src/session.rs": 1581,
|
||||
"crates/jcode-base/src/sidecar.rs": 1299,
|
||||
"crates/jcode-base/src/skill.rs": 1257,
|
||||
"crates/jcode-config-types/src/lib.rs": 1500,
|
||||
"crates/jcode-desktop/src/canvas.rs": 2400,
|
||||
"crates/jcode-desktop/src/desktop_benchmarks_scroll.rs": 1822,
|
||||
"crates/jcode-desktop/src/desktop_rich_text.rs": 2069,
|
||||
"crates/jcode-desktop/src/main.rs": 2386,
|
||||
"crates/jcode-desktop/src/render_helpers.rs": 1347,
|
||||
"crates/jcode-desktop/src/session_launch.rs": 1240,
|
||||
"crates/jcode-desktop/src/single_session.rs": 1872,
|
||||
"crates/jcode-desktop/src/single_session_render.rs": 1377,
|
||||
"crates/jcode-desktop/src/single_session_render/handwriting.rs": 3005,
|
||||
"crates/jcode-desktop/src/single_session_render/inline_widget_chrome.rs": 1554,
|
||||
"crates/jcode-desktop/src/single_session_render/motion_transcript.rs": 1278,
|
||||
"crates/jcode-desktop/src/workspace.rs": 1640,
|
||||
"crates/jcode-import-core/src/lib.rs": 1645,
|
||||
"crates/jcode-protocol/src/wire.rs": 1431,
|
||||
"crates/jcode-provider-anthropic-runtime/src/lib.rs": 2403,
|
||||
"crates/jcode-provider-bedrock/src/lib.rs": 1820,
|
||||
"crates/jcode-provider-core/src/lib.rs": 1609,
|
||||
"crates/jcode-provider-doctor/src/lifecycle_driver.rs": 1985,
|
||||
"crates/jcode-provider-doctor/src/live_provider_probes.rs": 2029,
|
||||
"crates/jcode-provider-doctor/src/provider_e2e.rs": 2680,
|
||||
"crates/jcode-provider-openai-runtime/src/openai_stream_runtime.rs": 1603,
|
||||
"crates/jcode-provider-openrouter-runtime/src/lib.rs": 2641,
|
||||
"crates/jcode-setup-hints/src/lib.rs": 1811,
|
||||
"crates/jcode-telemetry-core/src/lib.rs": 1846,
|
||||
"crates/jcode-tui-mermaid/src/mermaid_cache_render.rs": 1288,
|
||||
"crates/jcode-tui-mermaid/src/mermaid_viewport.rs": 1317,
|
||||
"crates/jcode-tui-render/src/swarm_gallery.rs": 2556,
|
||||
"crates/jcode-tui/src/tui/app.rs": 2365,
|
||||
"crates/jcode-tui/src/tui/app/auth.rs": 2955,
|
||||
"crates/jcode-tui/src/tui/app/auth_account_picker.rs": 1220,
|
||||
"crates/jcode-tui/src/tui/app/commands.rs": 3277,
|
||||
"crates/jcode-tui/src/tui/app/debug_bench.rs": 1281,
|
||||
"crates/jcode-tui/src/tui/app/helpers.rs": 1572,
|
||||
"crates/jcode-tui/src/tui/app/inline_interactive.rs": 3348,
|
||||
"crates/jcode-tui/src/tui/app/input.rs": 3336,
|
||||
"crates/jcode-tui/src/tui/app/model_context.rs": 1956,
|
||||
"crates/jcode-tui/src/tui/app/navigation.rs": 1751,
|
||||
"crates/jcode-tui/src/tui/app/onboarding_flow_control.rs": 1546,
|
||||
"crates/jcode-tui/src/tui/app/remote.rs": 1853,
|
||||
"crates/jcode-tui/src/tui/app/remote/key_handling.rs": 2542,
|
||||
"crates/jcode-tui/src/tui/app/remote/server_events.rs": 2698,
|
||||
"crates/jcode-tui/src/tui/app/state_ui.rs": 2186,
|
||||
"crates/jcode-tui/src/tui/app/state_ui_input_helpers.rs": 1880,
|
||||
"crates/jcode-tui/src/tui/app/tui_lifecycle.rs": 1242,
|
||||
"crates/jcode-tui/src/tui/app/tui_state.rs": 2253,
|
||||
"crates/jcode-tui/src/tui/app/turn.rs": 1470,
|
||||
"crates/jcode-tui/src/tui/backend.rs": 1697,
|
||||
"crates/jcode-tui/src/tui/info_widget.rs": 2209,
|
||||
"crates/jcode-tui/src/tui/mod.rs": 2001,
|
||||
"crates/jcode-tui/src/tui/session_picker.rs": 2174,
|
||||
"crates/jcode-tui/src/tui/session_picker/loading.rs": 2998,
|
||||
"crates/jcode-tui/src/tui/ui.rs": 3373,
|
||||
"crates/jcode-tui/src/tui/ui_frame_metrics.rs": 1336,
|
||||
"crates/jcode-tui/src/tui/ui_header.rs": 1626,
|
||||
"crates/jcode-tui/src/tui/ui_inline_image.rs": 1251,
|
||||
"crates/jcode-tui/src/tui/ui_input.rs": 2643,
|
||||
"crates/jcode-tui/src/tui/ui_messages.rs": 2399,
|
||||
"crates/jcode-tui/src/tui/ui_pinned.rs": 2011,
|
||||
"crates/jcode-tui/src/tui/ui_prepare.rs": 2360,
|
||||
"crates/jcode-tui/src/tui/ui_tools.rs": 1481,
|
||||
"crates/jcode-tui/src/tui/ui_viewport.rs": 1336,
|
||||
"src/bin/memory_recall_bench.rs": 2667,
|
||||
"src/bin/tui_bench.rs": 1747,
|
||||
"src/cli/commands.rs": 3353,
|
||||
"src/cli/dispatch.rs": 1209,
|
||||
"src/cli/login.rs": 1452,
|
||||
"src/cli/provider_init.rs": 1801
|
||||
},
|
||||
"version": 1
|
||||
}
|
||||
Executable
+500
@@ -0,0 +1,500 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Compare token usage between jcode and Claude Code CLI.
|
||||
|
||||
This script runs the same prompts through both tools and compares their token usage.
|
||||
The goal is to verify that jcode's token consumption is within expected bounds
|
||||
compared to the official Claude Code CLI.
|
||||
|
||||
NOTE: jcode typically uses FEWER tokens than Claude CLI because:
|
||||
1. jcode has a smaller/simpler system prompt
|
||||
2. jcode registers fewer tools (Claude CLI has many built-in tools)
|
||||
3. Different prompt caching behavior
|
||||
|
||||
The test PASSES if jcode uses fewer tokens OR at most 50% more tokens.
|
||||
Using more tokens would indicate a problem with the system prompt or tool registration.
|
||||
|
||||
Usage:
|
||||
python scripts/compare_token_usage.py [--verbose] [--runs N]
|
||||
|
||||
Requirements:
|
||||
- jcode built and in PATH or at target/release/jcode
|
||||
- claude CLI installed and authenticated
|
||||
- Both should use the same model (claude-opus-4-5-20251101 by default)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class TokenUsage:
|
||||
"""Token usage from a single run."""
|
||||
input_tokens: int
|
||||
output_tokens: int
|
||||
cache_read_tokens: int
|
||||
cache_creation_tokens: int
|
||||
total_cost_usd: Optional[float] = None
|
||||
duration_ms: Optional[int] = None
|
||||
|
||||
@property
|
||||
def total_input(self) -> int:
|
||||
"""Total input tokens including cache."""
|
||||
return self.input_tokens + self.cache_read_tokens + self.cache_creation_tokens
|
||||
|
||||
@property
|
||||
def total(self) -> int:
|
||||
"""Total tokens (input + output)."""
|
||||
return self.total_input + self.output_tokens
|
||||
|
||||
|
||||
@dataclass
|
||||
class RunResult:
|
||||
"""Result of a single tool run."""
|
||||
tool: str
|
||||
prompt: str
|
||||
usage: TokenUsage
|
||||
success: bool
|
||||
output: str
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
def find_jcode_binary() -> str:
|
||||
"""Find the jcode binary."""
|
||||
# Check target/release first
|
||||
repo_root = Path(__file__).parent.parent
|
||||
release_binary = repo_root / "target" / "release" / "jcode"
|
||||
if release_binary.exists():
|
||||
return str(release_binary)
|
||||
|
||||
# Check PATH
|
||||
result = subprocess.run(["which", "jcode"], capture_output=True, text=True)
|
||||
if result.returncode == 0:
|
||||
return result.stdout.strip()
|
||||
|
||||
raise FileNotFoundError("jcode binary not found. Run 'cargo build --release' first.")
|
||||
|
||||
|
||||
def run_claude_cli(prompt: str, workdir: str, model: str = "opus") -> RunResult:
|
||||
"""Run the Claude Code CLI and capture token usage."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"claude",
|
||||
"-p",
|
||||
"--output-format", "json",
|
||||
"--dangerously-skip-permissions",
|
||||
"--model", model,
|
||||
prompt,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=workdir,
|
||||
timeout=120,
|
||||
)
|
||||
|
||||
if result.returncode != 0 and not result.stdout:
|
||||
return RunResult(
|
||||
tool="claude",
|
||||
prompt=prompt,
|
||||
usage=TokenUsage(0, 0, 0, 0),
|
||||
success=False,
|
||||
output="",
|
||||
error=result.stderr or f"Exit code {result.returncode}",
|
||||
)
|
||||
|
||||
# Parse JSON output
|
||||
data = json.loads(result.stdout)
|
||||
usage = data.get("usage", {})
|
||||
|
||||
token_usage = TokenUsage(
|
||||
input_tokens=usage.get("input_tokens", 0),
|
||||
output_tokens=usage.get("output_tokens", 0),
|
||||
cache_read_tokens=usage.get("cache_read_input_tokens", 0),
|
||||
cache_creation_tokens=usage.get("cache_creation_input_tokens", 0),
|
||||
total_cost_usd=data.get("total_cost_usd"),
|
||||
duration_ms=data.get("duration_ms"),
|
||||
)
|
||||
|
||||
return RunResult(
|
||||
tool="claude",
|
||||
prompt=prompt,
|
||||
usage=token_usage,
|
||||
success=not data.get("is_error", False),
|
||||
output=data.get("result", ""),
|
||||
)
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return RunResult(
|
||||
tool="claude",
|
||||
prompt=prompt,
|
||||
usage=TokenUsage(0, 0, 0, 0),
|
||||
success=False,
|
||||
output="",
|
||||
error="Timeout after 120s",
|
||||
)
|
||||
except json.JSONDecodeError as e:
|
||||
return RunResult(
|
||||
tool="claude",
|
||||
prompt=prompt,
|
||||
usage=TokenUsage(0, 0, 0, 0),
|
||||
success=False,
|
||||
output=result.stdout if 'result' in dir() else "",
|
||||
error=f"JSON parse error: {e}",
|
||||
)
|
||||
except Exception as e:
|
||||
return RunResult(
|
||||
tool="claude",
|
||||
prompt=prompt,
|
||||
usage=TokenUsage(0, 0, 0, 0),
|
||||
success=False,
|
||||
output="",
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
|
||||
def run_jcode(prompt: str, workdir: str, jcode_binary: str, model: str = "claude-opus-4-5-20251101") -> RunResult:
|
||||
"""Run jcode and capture token usage from trace output."""
|
||||
try:
|
||||
# Create a temporary JCODE_HOME to avoid polluting user's sessions
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
env = os.environ.copy()
|
||||
env["JCODE_HOME"] = tmpdir
|
||||
env["JCODE_TRACE"] = "1"
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
jcode_binary,
|
||||
"run",
|
||||
"--no-update",
|
||||
"--model", model,
|
||||
prompt,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=workdir,
|
||||
timeout=120,
|
||||
env=env,
|
||||
)
|
||||
|
||||
# Parse token usage from trace output in stderr
|
||||
# Format: [trace] token_usage input=X output=Y cache_read=Z cache_write=W
|
||||
input_tokens = 0
|
||||
output_tokens = 0
|
||||
cache_read = 0
|
||||
cache_write = 0
|
||||
|
||||
for line in result.stderr.split("\n"):
|
||||
if "[trace] token_usage" in line:
|
||||
parts = line.split()
|
||||
for part in parts:
|
||||
if part.startswith("input="):
|
||||
input_tokens = int(part.split("=")[1])
|
||||
elif part.startswith("output="):
|
||||
output_tokens = int(part.split("=")[1])
|
||||
elif part.startswith("cache_read="):
|
||||
cache_read = int(part.split("=")[1])
|
||||
elif part.startswith("cache_write="):
|
||||
cache_write = int(part.split("=")[1])
|
||||
|
||||
token_usage = TokenUsage(
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
cache_read_tokens=cache_read,
|
||||
cache_creation_tokens=cache_write,
|
||||
)
|
||||
|
||||
return RunResult(
|
||||
tool="jcode",
|
||||
prompt=prompt,
|
||||
usage=token_usage,
|
||||
success=result.returncode == 0,
|
||||
output=result.stdout,
|
||||
error=None if result.returncode == 0 else result.stderr,
|
||||
)
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return RunResult(
|
||||
tool="jcode",
|
||||
prompt=prompt,
|
||||
usage=TokenUsage(0, 0, 0, 0),
|
||||
success=False,
|
||||
output="",
|
||||
error="Timeout after 120s",
|
||||
)
|
||||
except Exception as e:
|
||||
return RunResult(
|
||||
tool="jcode",
|
||||
prompt=prompt,
|
||||
usage=TokenUsage(0, 0, 0, 0),
|
||||
success=False,
|
||||
output="",
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
|
||||
def compare_usage(claude_result: RunResult, jcode_result: RunResult, verbose: bool = False) -> dict:
|
||||
"""Compare token usage between Claude CLI and jcode."""
|
||||
c = claude_result.usage
|
||||
j = jcode_result.usage
|
||||
|
||||
# Calculate differences
|
||||
input_diff = j.input_tokens - c.input_tokens
|
||||
output_diff = j.output_tokens - c.output_tokens
|
||||
cache_read_diff = j.cache_read_tokens - c.cache_read_tokens
|
||||
cache_write_diff = j.cache_creation_tokens - c.cache_creation_tokens
|
||||
total_diff = j.total - c.total
|
||||
|
||||
# Calculate percentages (avoid division by zero)
|
||||
def pct_diff(a: int, b: int) -> float:
|
||||
if b == 0:
|
||||
return 0.0 if a == 0 else float('inf')
|
||||
return ((a - b) / b) * 100
|
||||
|
||||
input_pct = pct_diff(j.input_tokens, c.input_tokens)
|
||||
output_pct = pct_diff(j.output_tokens, c.output_tokens)
|
||||
total_pct = pct_diff(j.total, c.total)
|
||||
|
||||
return {
|
||||
"claude": {
|
||||
"input": c.input_tokens,
|
||||
"output": c.output_tokens,
|
||||
"cache_read": c.cache_read_tokens,
|
||||
"cache_write": c.cache_creation_tokens,
|
||||
"total": c.total,
|
||||
"cost_usd": c.total_cost_usd,
|
||||
"duration_ms": c.duration_ms,
|
||||
},
|
||||
"jcode": {
|
||||
"input": j.input_tokens,
|
||||
"output": j.output_tokens,
|
||||
"cache_read": j.cache_read_tokens,
|
||||
"cache_write": j.cache_creation_tokens,
|
||||
"total": j.total,
|
||||
},
|
||||
"diff": {
|
||||
"input": input_diff,
|
||||
"output": output_diff,
|
||||
"cache_read": cache_read_diff,
|
||||
"cache_write": cache_write_diff,
|
||||
"total": total_diff,
|
||||
},
|
||||
"pct_diff": {
|
||||
"input": input_pct,
|
||||
"output": output_pct,
|
||||
"total": total_pct,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def print_comparison(comparison: dict, prompt: str, verbose: bool = False):
|
||||
"""Print a formatted comparison."""
|
||||
print(f"\n{'='*60}")
|
||||
print(f"Prompt: {prompt[:50]}..." if len(prompt) > 50 else f"Prompt: {prompt}")
|
||||
print(f"{'='*60}")
|
||||
|
||||
c = comparison["claude"]
|
||||
j = comparison["jcode"]
|
||||
d = comparison["diff"]
|
||||
p = comparison["pct_diff"]
|
||||
|
||||
print(f"\n{'Metric':<20} {'Claude':<15} {'jcode':<15} {'Diff':<15} {'% Diff':<10}")
|
||||
print("-" * 75)
|
||||
print(f"{'Input tokens':<20} {c['input']:<15} {j['input']:<15} {d['input']:+<15} {p['input']:+.1f}%")
|
||||
print(f"{'Output tokens':<20} {c['output']:<15} {j['output']:<15} {d['output']:+<15} {p['output']:+.1f}%")
|
||||
print(f"{'Cache read':<20} {c['cache_read']:<15} {j['cache_read']:<15} {d['cache_read']:+<15}")
|
||||
print(f"{'Cache write':<20} {c['cache_write']:<15} {j['cache_write']:<15} {d['cache_write']:+<15}")
|
||||
print("-" * 75)
|
||||
print(f"{'TOTAL':<20} {c['total']:<15} {j['total']:<15} {d['total']:+<15} {p['total']:+.1f}%")
|
||||
|
||||
if c.get("cost_usd"):
|
||||
print(f"\nClaude CLI cost: ${c['cost_usd']:.6f}")
|
||||
if c.get("duration_ms"):
|
||||
print(f"Claude CLI duration: {c['duration_ms']}ms")
|
||||
|
||||
|
||||
def run_test_suite(verbose: bool = False, runs: int = 1) -> list:
|
||||
"""Run the full test suite."""
|
||||
# Test prompts - simple ones that don't require tools
|
||||
prompts = [
|
||||
"Reply with a single word: test",
|
||||
"What is 2 + 2? Reply with just the number.",
|
||||
"List three primary colors, one per line.",
|
||||
]
|
||||
|
||||
jcode_binary = find_jcode_binary()
|
||||
print(f"Using jcode binary: {jcode_binary}")
|
||||
print(f"Running {len(prompts)} prompts, {runs} run(s) each\n")
|
||||
|
||||
results = []
|
||||
|
||||
with tempfile.TemporaryDirectory() as workdir:
|
||||
for prompt in prompts:
|
||||
for run_num in range(runs):
|
||||
print(f"\n[Run {run_num + 1}/{runs}] Testing: {prompt[:40]}...")
|
||||
|
||||
# Run both tools
|
||||
print(" Running Claude CLI...", end=" ", flush=True)
|
||||
claude_result = run_claude_cli(prompt, workdir)
|
||||
if claude_result.success:
|
||||
print(f"OK ({claude_result.usage.total} tokens)")
|
||||
else:
|
||||
print(f"FAILED: {claude_result.error}")
|
||||
if verbose:
|
||||
print(f" Output: {claude_result.output[:200]}")
|
||||
|
||||
# Small delay to avoid rate limiting
|
||||
time.sleep(1)
|
||||
|
||||
print(" Running jcode...", end=" ", flush=True)
|
||||
jcode_result = run_jcode(prompt, workdir, jcode_binary)
|
||||
if jcode_result.success:
|
||||
print(f"OK ({jcode_result.usage.total} tokens)")
|
||||
else:
|
||||
print(f"FAILED: {jcode_result.error}")
|
||||
if verbose:
|
||||
print(f" Output: {jcode_result.output[:200]}")
|
||||
|
||||
if claude_result.success and jcode_result.success:
|
||||
comparison = compare_usage(claude_result, jcode_result, verbose)
|
||||
results.append({
|
||||
"prompt": prompt,
|
||||
"run": run_num + 1,
|
||||
"comparison": comparison,
|
||||
})
|
||||
|
||||
if verbose:
|
||||
print_comparison(comparison, prompt, verbose)
|
||||
|
||||
# Delay between prompts
|
||||
time.sleep(2)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def summarize_results(results: list) -> bool:
|
||||
"""Print summary of all results. Returns True if test passed."""
|
||||
if not results:
|
||||
print("\nNo successful results to summarize.")
|
||||
return False
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print("SUMMARY")
|
||||
print(f"{'='*60}")
|
||||
|
||||
total_claude = sum(r["comparison"]["claude"]["total"] for r in results)
|
||||
total_jcode = sum(r["comparison"]["jcode"]["total"] for r in results)
|
||||
total_diff = total_jcode - total_claude
|
||||
|
||||
# Also compare just input+output (excluding cache)
|
||||
total_claude_io = sum(
|
||||
r["comparison"]["claude"]["input"] + r["comparison"]["claude"]["output"]
|
||||
for r in results
|
||||
)
|
||||
total_jcode_io = sum(
|
||||
r["comparison"]["jcode"]["input"] + r["comparison"]["jcode"]["output"]
|
||||
for r in results
|
||||
)
|
||||
|
||||
if total_claude > 0:
|
||||
pct_diff = ((total_jcode - total_claude) / total_claude) * 100
|
||||
else:
|
||||
pct_diff = 0
|
||||
|
||||
print(f"\nTotal runs: {len(results)}")
|
||||
print(f"\n--- Total Tokens (including cache) ---")
|
||||
print(f"Claude CLI: {total_claude}")
|
||||
print(f"jcode: {total_jcode}")
|
||||
print(f"Difference: {total_diff:+} ({pct_diff:+.1f}%)")
|
||||
|
||||
print(f"\n--- Input + Output only (excluding cache) ---")
|
||||
print(f"Claude CLI: {total_claude_io}")
|
||||
print(f"jcode: {total_jcode_io}")
|
||||
if total_claude_io > 0:
|
||||
io_pct_diff = ((total_jcode_io - total_claude_io) / total_claude_io) * 100
|
||||
print(f"Difference: {total_jcode_io - total_claude_io:+} ({io_pct_diff:+.1f}%)")
|
||||
|
||||
# Check if within acceptable bounds
|
||||
# jcode using fewer tokens is always good (negative diff)
|
||||
# jcode using more tokens is acceptable up to MAX_OVERHEAD_PCT
|
||||
MAX_OVERHEAD_PCT = 50 # Allow up to 50% more tokens (for different system prompts)
|
||||
|
||||
passed = True
|
||||
if pct_diff <= 0:
|
||||
print(f"\n✅ PASS: jcode uses {abs(pct_diff):.1f}% fewer tokens than Claude CLI")
|
||||
elif pct_diff <= MAX_OVERHEAD_PCT:
|
||||
print(f"\n✅ PASS: jcode uses {pct_diff:.1f}% more tokens (within {MAX_OVERHEAD_PCT}% threshold)")
|
||||
else:
|
||||
print(f"\n❌ FAIL: jcode uses {pct_diff:.1f}% more tokens (exceeds {MAX_OVERHEAD_PCT}% threshold)")
|
||||
passed = False
|
||||
|
||||
# Per-prompt breakdown
|
||||
print("\nPer-prompt breakdown:")
|
||||
print(f"{'Prompt':<40} {'Claude':<10} {'jcode':<10} {'Diff':<10}")
|
||||
print("-" * 70)
|
||||
|
||||
for r in results:
|
||||
prompt = r["prompt"][:37] + "..." if len(r["prompt"]) > 40 else r["prompt"]
|
||||
c_total = r["comparison"]["claude"]["total"]
|
||||
j_total = r["comparison"]["jcode"]["total"]
|
||||
diff = j_total - c_total
|
||||
print(f"{prompt:<40} {c_total:<10} {j_total:<10} {diff:+<10}")
|
||||
|
||||
return passed
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Compare token usage between jcode and Claude Code CLI"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--verbose", "-v",
|
||||
action="store_true",
|
||||
help="Show detailed output for each run",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--runs", "-n",
|
||||
type=int,
|
||||
default=1,
|
||||
help="Number of runs per prompt (default: 1)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Output results as JSON",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
print("Token Usage Comparison: jcode vs Claude Code CLI")
|
||||
print("=" * 50)
|
||||
|
||||
try:
|
||||
results = run_test_suite(verbose=args.verbose, runs=args.runs)
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(results, indent=2))
|
||||
# For JSON mode, check if all runs succeeded
|
||||
sys.exit(0 if results else 1)
|
||||
else:
|
||||
passed = summarize_results(results)
|
||||
sys.exit(0 if passed else 1)
|
||||
|
||||
except FileNotFoundError as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except KeyboardInterrupt:
|
||||
print("\nInterrupted by user")
|
||||
sys.exit(130)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+251
@@ -0,0 +1,251 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Report compile-time isolation risks in the Jcode crate graph.
|
||||
|
||||
This is advisory by default. Use --strict-target-state only when a migration phase
|
||||
has removed the listed temporary violations and we want to prevent regressions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
WATCHED_CRATES = ["jcode-base", "jcode-app-core", "jcode-tui", "jcode"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class CrateStats:
|
||||
name: str
|
||||
manifest_path: str
|
||||
src_path: str | None
|
||||
rust_files: int
|
||||
loc: int
|
||||
cfg_test_count: int
|
||||
test_attr_count: int
|
||||
async_trait_count: int
|
||||
derive_count: int
|
||||
glob_reexports: list[str]
|
||||
normal_workspace_deps: list[str]
|
||||
normal_external_deps: list[str]
|
||||
dev_workspace_deps: list[str]
|
||||
dev_external_deps: list[str]
|
||||
build_workspace_deps: list[str]
|
||||
build_external_deps: list[str]
|
||||
|
||||
|
||||
def run_metadata() -> dict[str, Any]:
|
||||
result = subprocess.run(
|
||||
["cargo", "metadata", "--no-deps", "--format-version", "1"],
|
||||
cwd=ROOT,
|
||||
check=True,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def lib_or_root_src(package: dict[str, Any]) -> Path | None:
|
||||
manifest = Path(package["manifest_path"])
|
||||
for target in package.get("targets", []):
|
||||
if "lib" in target.get("kind", []):
|
||||
return Path(target["src_path"])
|
||||
if package["name"] == "jcode":
|
||||
return ROOT / "src"
|
||||
src = manifest.parent / "src"
|
||||
return src if src.exists() else None
|
||||
|
||||
|
||||
def iter_rust_files(src_path: Path | None) -> list[Path]:
|
||||
if src_path is None:
|
||||
return []
|
||||
if src_path.is_file():
|
||||
root = src_path.parent
|
||||
else:
|
||||
root = src_path
|
||||
if not root.exists():
|
||||
return []
|
||||
return sorted(path for path in root.rglob("*.rs") if path.is_file())
|
||||
|
||||
|
||||
def count_file(path: Path) -> tuple[int, str]:
|
||||
text = path.read_text(errors="replace")
|
||||
return text.count("\n") + (0 if text.endswith("\n") or not text else 1), text
|
||||
|
||||
|
||||
def collect_stats(package: dict[str, Any], workspace_names: set[str]) -> CrateStats:
|
||||
src_path = lib_or_root_src(package)
|
||||
rust_files = iter_rust_files(src_path)
|
||||
loc = 0
|
||||
cfg_test_count = 0
|
||||
test_attr_count = 0
|
||||
async_trait_count = 0
|
||||
derive_count = 0
|
||||
glob_reexports: list[str] = []
|
||||
|
||||
for path in rust_files:
|
||||
file_loc, text = count_file(path)
|
||||
loc += file_loc
|
||||
cfg_test_count += len(re.findall(r"#\s*\[\s*cfg\s*\(\s*test\s*\)\s*\]", text))
|
||||
test_attr_count += len(re.findall(r"#\s*\[\s*(?:tokio::)?test(?:\s*\([^\]]*\))?\s*\]", text))
|
||||
async_trait_count += len(re.findall(r"#\s*\[\s*(?:async_trait::)?async_trait\s*\]", text))
|
||||
derive_count += len(re.findall(r"#\s*\[\s*derive\s*\(", text))
|
||||
for line_number, line in enumerate(text.splitlines(), 1):
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("//"):
|
||||
continue
|
||||
if re.fullmatch(r"pub\s+use\s+[^;\n]+::\s*\*\s*;", stripped):
|
||||
rel = path.relative_to(ROOT)
|
||||
glob_reexports.append(f"{rel}:{line_number}: {stripped}")
|
||||
|
||||
workspace_deps_by_kind: dict[str, list[str]] = {"normal": [], "dev": [], "build": []}
|
||||
external_deps_by_kind: dict[str, list[str]] = {"normal": [], "dev": [], "build": []}
|
||||
for dep in package.get("dependencies", []):
|
||||
dep_name = dep["name"]
|
||||
dep_kind = dep.get("kind") or "normal"
|
||||
if dep_kind not in workspace_deps_by_kind:
|
||||
dep_kind = "normal"
|
||||
buckets = workspace_deps_by_kind if dep_name in workspace_names else external_deps_by_kind
|
||||
buckets[dep_kind].append(dep_name)
|
||||
|
||||
return CrateStats(
|
||||
name=package["name"],
|
||||
manifest_path=str(Path(package["manifest_path"]).relative_to(ROOT)),
|
||||
src_path=str(src_path.relative_to(ROOT)) if src_path and src_path.exists() else None,
|
||||
rust_files=len(rust_files),
|
||||
loc=loc,
|
||||
cfg_test_count=cfg_test_count,
|
||||
test_attr_count=test_attr_count,
|
||||
async_trait_count=async_trait_count,
|
||||
derive_count=derive_count,
|
||||
glob_reexports=glob_reexports,
|
||||
normal_workspace_deps=sorted(workspace_deps_by_kind["normal"]),
|
||||
normal_external_deps=sorted(external_deps_by_kind["normal"]),
|
||||
dev_workspace_deps=sorted(workspace_deps_by_kind["dev"]),
|
||||
dev_external_deps=sorted(external_deps_by_kind["dev"]),
|
||||
build_workspace_deps=sorted(workspace_deps_by_kind["build"]),
|
||||
build_external_deps=sorted(external_deps_by_kind["build"]),
|
||||
)
|
||||
|
||||
|
||||
def target_state_violations(stats_by_name: dict[str, CrateStats]) -> list[str]:
|
||||
violations: list[str] = []
|
||||
|
||||
tui = stats_by_name.get("jcode-tui")
|
||||
if tui and "jcode-app-core" in tui.normal_workspace_deps:
|
||||
violations.append("target-state: jcode-tui still directly depends on jcode-app-core")
|
||||
|
||||
app_core = stats_by_name.get("jcode-app-core")
|
||||
if app_core and "jcode-base" in app_core.normal_workspace_deps:
|
||||
violations.append("target-state: jcode-app-core still directly depends on jcode-base")
|
||||
|
||||
base = stats_by_name.get("jcode-base")
|
||||
if base:
|
||||
for dep in base.normal_workspace_deps:
|
||||
if dep in {
|
||||
"jcode-azure-auth",
|
||||
"jcode-provider-gemini",
|
||||
"jcode-provider-openai",
|
||||
"jcode-provider-openrouter",
|
||||
"jcode-notify-email",
|
||||
"jcode-build-support",
|
||||
}:
|
||||
violations.append(f"target-state: jcode-base still depends on leaf/runtime crate {dep}")
|
||||
for dep in base.normal_external_deps:
|
||||
if dep.startswith("aws-") or dep in {"aws-types"}:
|
||||
violations.append(f"target-state: jcode-base still depends directly on AWS crate {dep}")
|
||||
|
||||
for crate in stats_by_name.values():
|
||||
for glob in crate.glob_reexports:
|
||||
if crate.name in {"jcode", "jcode-app-core", "jcode-tui"}:
|
||||
violations.append(f"target-state: broad glob re-export remains in {glob}")
|
||||
|
||||
return violations
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--json", action="store_true", help="print machine-readable JSON")
|
||||
parser.add_argument(
|
||||
"--strict-target-state",
|
||||
action="store_true",
|
||||
help="exit non-zero for target-state violations (advisory by default)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--top",
|
||||
type=int,
|
||||
default=12,
|
||||
help="number of largest workspace crates to print in text mode",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
metadata = run_metadata()
|
||||
package_by_id = {package["id"]: package for package in metadata["packages"]}
|
||||
workspace_packages = [package_by_id[package_id] for package_id in metadata["workspace_members"]]
|
||||
workspace_names = {package["name"] for package in workspace_packages}
|
||||
|
||||
stats = [collect_stats(package, workspace_names) for package in workspace_packages]
|
||||
stats_by_name = {crate.name: crate for crate in stats}
|
||||
violations = target_state_violations(stats_by_name)
|
||||
largest = sorted(stats, key=lambda crate: crate.loc, reverse=True)
|
||||
|
||||
payload = {
|
||||
"watched_crates": {name: asdict(stats_by_name[name]) for name in WATCHED_CRATES if name in stats_by_name},
|
||||
"largest_crates": [asdict(crate) for crate in largest[: args.top]],
|
||||
"target_state_violations": violations,
|
||||
}
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(payload, indent=2, sort_keys=True))
|
||||
else:
|
||||
print("compile isolation static report")
|
||||
print("largest workspace crates by Rust LOC:")
|
||||
for crate in largest[: args.top]:
|
||||
print(
|
||||
f" - {crate.name}: {crate.loc} LOC, {crate.rust_files} files, "
|
||||
f"#[test] {crate.test_attr_count}, cfg(test) {crate.cfg_test_count}, "
|
||||
f"async_trait {crate.async_trait_count}, derive {crate.derive_count}"
|
||||
)
|
||||
print("watched crates:")
|
||||
for name in WATCHED_CRATES:
|
||||
crate = stats_by_name.get(name)
|
||||
if not crate:
|
||||
continue
|
||||
print(
|
||||
f" - {name}: {crate.loc} LOC, "
|
||||
f"normal workspace deps={len(crate.normal_workspace_deps)}, "
|
||||
f"normal external deps={len(crate.normal_external_deps)}"
|
||||
)
|
||||
if crate.dev_workspace_deps or crate.dev_external_deps or crate.build_workspace_deps or crate.build_external_deps:
|
||||
print(
|
||||
f" non-normal deps: dev workspace={len(crate.dev_workspace_deps)}, "
|
||||
f"dev external={len(crate.dev_external_deps)}, "
|
||||
f"build workspace={len(crate.build_workspace_deps)}, "
|
||||
f"build external={len(crate.build_external_deps)}"
|
||||
)
|
||||
if crate.glob_reexports:
|
||||
print(" glob re-exports:")
|
||||
for glob in crate.glob_reexports[:8]:
|
||||
print(f" {glob}")
|
||||
if len(crate.glob_reexports) > 8:
|
||||
print(f" ... {len(crate.glob_reexports) - 8} more")
|
||||
if violations:
|
||||
print("target-state violations/advisories:")
|
||||
for violation in violations:
|
||||
print(f" - {violation}")
|
||||
else:
|
||||
print("target-state violations/advisories: none")
|
||||
|
||||
if args.strict_target_state and violations:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+344
@@ -0,0 +1,344 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
cd "$repo_root"
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage:
|
||||
scripts/compile_time_probe.sh [options]
|
||||
|
||||
Runs a full-feature selfdev jcode build with Cargo timings enabled and summarizes
|
||||
critical-path-ish rustc units from target/cargo-timings/cargo-timing.html.
|
||||
|
||||
Options:
|
||||
--skip-build Parse the latest timing HTML without running cargo
|
||||
--timing-html <path> Parse a specific cargo timing HTML file
|
||||
--touch <path> Touch a file before building to simulate an edit
|
||||
--profile <name> Cargo profile to build (default: selfdev)
|
||||
--package <name> Cargo package to build (default: jcode)
|
||||
--bin <name> Cargo binary to build (default: jcode)
|
||||
--feature-profile <name> JCODE_DEV_FEATURE_PROFILE for dev_cargo.sh (default: default)
|
||||
--json <path> Write the parsed summary JSON to this path
|
||||
--top <n> Number of slowest units to print (default: 12)
|
||||
-h, --help Show this help
|
||||
|
||||
Examples:
|
||||
scripts/compile_time_probe.sh --skip-build
|
||||
scripts/compile_time_probe.sh --touch crates/jcode-tui/src/tui/app/input.rs
|
||||
scripts/compile_time_probe.sh --json target/compile-time-probe.json
|
||||
|
||||
Notes:
|
||||
- This intentionally defaults to the full/default feature set. It is for
|
||||
compile-time isolation work that keeps debug/selfdev behavior production-like.
|
||||
- The "jcode serial stack" summary is not a formal Cargo critical path. It is a
|
||||
focused view of the known long-pole crates: jcode-base, jcode-app-core,
|
||||
jcode-tui, root jcode lib, and jcode bin.
|
||||
USAGE
|
||||
}
|
||||
|
||||
skip_build=0
|
||||
timing_html=""
|
||||
touch_path=""
|
||||
profile="selfdev"
|
||||
package="jcode"
|
||||
bin="jcode"
|
||||
feature_profile="default"
|
||||
json_path=""
|
||||
top_n=12
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--skip-build)
|
||||
skip_build=1
|
||||
;;
|
||||
--timing-html)
|
||||
[[ $# -ge 2 ]] || { echo 'error: --timing-html requires a path' >&2; exit 1; }
|
||||
timing_html="$2"
|
||||
shift
|
||||
;;
|
||||
--touch)
|
||||
[[ $# -ge 2 ]] || { echo 'error: --touch requires a path' >&2; exit 1; }
|
||||
touch_path="$2"
|
||||
shift
|
||||
;;
|
||||
--profile)
|
||||
[[ $# -ge 2 ]] || { echo 'error: --profile requires a value' >&2; exit 1; }
|
||||
profile="$2"
|
||||
shift
|
||||
;;
|
||||
--package|-p)
|
||||
[[ $# -ge 2 ]] || { echo 'error: --package requires a value' >&2; exit 1; }
|
||||
package="$2"
|
||||
shift
|
||||
;;
|
||||
--bin)
|
||||
[[ $# -ge 2 ]] || { echo 'error: --bin requires a value' >&2; exit 1; }
|
||||
bin="$2"
|
||||
shift
|
||||
;;
|
||||
--feature-profile)
|
||||
[[ $# -ge 2 ]] || { echo 'error: --feature-profile requires a value' >&2; exit 1; }
|
||||
feature_profile="$2"
|
||||
shift
|
||||
;;
|
||||
--json)
|
||||
[[ $# -ge 2 ]] || { echo 'error: --json requires a path' >&2; exit 1; }
|
||||
json_path="$2"
|
||||
shift
|
||||
;;
|
||||
--top)
|
||||
[[ $# -ge 2 ]] || { echo 'error: --top requires a positive integer' >&2; exit 1; }
|
||||
top_n="$2"
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
printf 'error: unknown argument: %s\n' "$1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
if ! [[ "$top_n" =~ ^[1-9][0-9]*$ ]]; then
|
||||
printf 'error: --top must be a positive integer (got %s)\n' "$top_n" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -n "$touch_path" && ! -e "$touch_path" ]]; then
|
||||
printf 'error: touch path does not exist: %s\n' "$touch_path" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ $skip_build -eq 0 ]]; then
|
||||
if [[ -n "$touch_path" ]]; then
|
||||
printf 'compile_time_probe: touching %s\n' "$touch_path" >&2
|
||||
touch "$touch_path"
|
||||
fi
|
||||
|
||||
printf 'compile_time_probe: building %s/%s profile=%s feature_profile=%s with --timings\n' \
|
||||
"$package" "$bin" "$profile" "$feature_profile" >&2
|
||||
|
||||
start_ns=$(python3 - <<'PY'
|
||||
import time
|
||||
print(time.perf_counter_ns())
|
||||
PY
|
||||
)
|
||||
|
||||
JCODE_DEV_FEATURE_PROFILE="$feature_profile" \
|
||||
scripts/dev_cargo.sh build --profile "$profile" -p "$package" --bin "$bin" --timings
|
||||
|
||||
end_ns=$(python3 - <<'PY'
|
||||
import time
|
||||
print(time.perf_counter_ns())
|
||||
PY
|
||||
)
|
||||
elapsed_seconds=$(python3 - "$start_ns" "$end_ns" <<'PY'
|
||||
import sys
|
||||
start = int(sys.argv[1])
|
||||
end = int(sys.argv[2])
|
||||
print(f"{(end - start) / 1_000_000_000:.3f}")
|
||||
PY
|
||||
)
|
||||
else
|
||||
elapsed_seconds=""
|
||||
fi
|
||||
|
||||
if [[ -z "$timing_html" ]]; then
|
||||
timing_html=$(find target/cargo-timings -maxdepth 1 -type f -name 'cargo-timing*.html' -printf '%T@ %p\n' 2>/dev/null | sort -n | tail -1 | cut -d' ' -f2- || true)
|
||||
fi
|
||||
|
||||
if [[ -z "$timing_html" || ! -f "$timing_html" ]]; then
|
||||
printf 'error: no cargo timing HTML found; run without --skip-build or pass --timing-html\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
python3 - "$timing_html" "$elapsed_seconds" "$json_path" "$top_n" "$profile" "$package" "$bin" "$feature_profile" <<'PY'
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
html_path = Path(sys.argv[1])
|
||||
elapsed_arg = sys.argv[2]
|
||||
json_path = Path(sys.argv[3]) if sys.argv[3] else None
|
||||
top_n = int(sys.argv[4])
|
||||
profile = sys.argv[5]
|
||||
package = sys.argv[6]
|
||||
bin_name = sys.argv[7]
|
||||
feature_profile = sys.argv[8]
|
||||
|
||||
text = html_path.read_text(errors="replace")
|
||||
|
||||
def extract_duration() -> float | None:
|
||||
match = re.search(r"(?:const\s+)?DURATION\s*=\s*([0-9]+(?:\.[0-9]+)?)\s*;", text)
|
||||
return float(match.group(1)) if match else None
|
||||
|
||||
|
||||
def extract_unit_data() -> list[dict[str, Any]]:
|
||||
marker = "const UNIT_DATA = "
|
||||
start = text.find(marker)
|
||||
if start < 0:
|
||||
raise SystemExit(f"error: {html_path} does not contain Cargo UNIT_DATA")
|
||||
idx = start + len(marker)
|
||||
while idx < len(text) and text[idx].isspace():
|
||||
idx += 1
|
||||
if idx >= len(text) or text[idx] != "[":
|
||||
raise SystemExit("error: Cargo UNIT_DATA did not start with '['")
|
||||
|
||||
depth = 0
|
||||
in_string = False
|
||||
escape = False
|
||||
end = idx
|
||||
while end < len(text):
|
||||
ch = text[end]
|
||||
if in_string:
|
||||
if escape:
|
||||
escape = False
|
||||
elif ch == "\\":
|
||||
escape = True
|
||||
elif ch == '"':
|
||||
in_string = False
|
||||
else:
|
||||
if ch == '"':
|
||||
in_string = True
|
||||
elif ch == "[":
|
||||
depth += 1
|
||||
elif ch == "]":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
end += 1
|
||||
break
|
||||
end += 1
|
||||
return json.loads(text[idx:end])
|
||||
|
||||
|
||||
def section_duration(unit: dict[str, Any], section_name: str) -> float | None:
|
||||
sections = unit.get("sections")
|
||||
if not sections:
|
||||
return None
|
||||
for name, payload in sections:
|
||||
if name == section_name:
|
||||
return float(payload["end"]) - float(payload["start"])
|
||||
return None
|
||||
|
||||
|
||||
def fmt_seconds(value: float | None) -> str:
|
||||
if value is None:
|
||||
return "n/a"
|
||||
return f"{value:.2f}s"
|
||||
|
||||
units = extract_unit_data()
|
||||
for unit in units:
|
||||
unit["end"] = float(unit.get("start", 0.0)) + float(unit.get("duration", 0.0))
|
||||
unit["frontend_duration"] = section_duration(unit, "frontend")
|
||||
unit["codegen_duration"] = section_duration(unit, "codegen")
|
||||
|
||||
# Slowest rustc-ish units by duration. Keep build-script run units visible but low-noise.
|
||||
top_units = sorted(units, key=lambda unit: float(unit.get("duration", 0.0)), reverse=True)[:top_n]
|
||||
|
||||
def is_jcode_stack_unit(unit: dict[str, Any]) -> bool:
|
||||
name = unit.get("name")
|
||||
target = unit.get("target") or ""
|
||||
if name in {"jcode-base", "jcode-app-core", "jcode-tui"} and "build script" not in target:
|
||||
return True
|
||||
if name == "jcode" and (target == "" or f'bin "{bin_name}"' in target):
|
||||
return True
|
||||
return False
|
||||
|
||||
jcode_stack = sorted([unit for unit in units if is_jcode_stack_unit(unit)], key=lambda unit: float(unit.get("start", 0.0)))
|
||||
stack_span = None
|
||||
if jcode_stack:
|
||||
stack_span = max(float(unit["end"]) for unit in jcode_stack) - min(float(unit.get("start", 0.0)) for unit in jcode_stack)
|
||||
stack_sum = sum(float(unit.get("duration", 0.0)) for unit in jcode_stack)
|
||||
stack_frontend_sum = sum(float(unit.get("frontend_duration") or 0.0) for unit in jcode_stack)
|
||||
stack_codegen_sum = sum(float(unit.get("codegen_duration") or 0.0) for unit in jcode_stack)
|
||||
|
||||
summary = {
|
||||
"timing_html": str(html_path),
|
||||
"profile": profile,
|
||||
"package": package,
|
||||
"bin": bin_name,
|
||||
"feature_profile": feature_profile,
|
||||
"wall_seconds_from_cargo_timing": extract_duration(),
|
||||
"wall_seconds_measured_by_probe": float(elapsed_arg) if elapsed_arg else None,
|
||||
"unit_count": len(units),
|
||||
"top_units": [
|
||||
{
|
||||
"name": unit.get("name"),
|
||||
"version": unit.get("version"),
|
||||
"target": unit.get("target") or "",
|
||||
"features": unit.get("features") or [],
|
||||
"start_seconds": round(float(unit.get("start", 0.0)), 3),
|
||||
"duration_seconds": round(float(unit.get("duration", 0.0)), 3),
|
||||
"frontend_seconds": round(unit["frontend_duration"], 3) if unit.get("frontend_duration") is not None else None,
|
||||
"codegen_seconds": round(unit["codegen_duration"], 3) if unit.get("codegen_duration") is not None else None,
|
||||
}
|
||||
for unit in top_units
|
||||
],
|
||||
"jcode_serial_stack": {
|
||||
"span_seconds": round(stack_span, 3) if stack_span is not None else None,
|
||||
"sum_unit_seconds": round(stack_sum, 3),
|
||||
"sum_frontend_seconds": round(stack_frontend_sum, 3),
|
||||
"sum_codegen_seconds": round(stack_codegen_sum, 3),
|
||||
"units": [
|
||||
{
|
||||
"name": unit.get("name"),
|
||||
"target": unit.get("target") or "",
|
||||
"start_seconds": round(float(unit.get("start", 0.0)), 3),
|
||||
"end_seconds": round(float(unit.get("end", 0.0)), 3),
|
||||
"duration_seconds": round(float(unit.get("duration", 0.0)), 3),
|
||||
"frontend_seconds": round(unit["frontend_duration"], 3) if unit.get("frontend_duration") is not None else None,
|
||||
"codegen_seconds": round(unit["codegen_duration"], 3) if unit.get("codegen_duration") is not None else None,
|
||||
"features": unit.get("features") or [],
|
||||
}
|
||||
for unit in jcode_stack
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
if json_path:
|
||||
json_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
json_path.write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n")
|
||||
|
||||
print("compile_time_probe summary")
|
||||
print(f" timing html: {html_path}")
|
||||
print(f" cargo timing wall: {fmt_seconds(summary['wall_seconds_from_cargo_timing'])}")
|
||||
if summary["wall_seconds_measured_by_probe"] is not None:
|
||||
print(f" measured wall: {fmt_seconds(summary['wall_seconds_measured_by_probe'])}")
|
||||
print(f" units: {len(units)}")
|
||||
print(" jcode serial stack:")
|
||||
print(f" span: {fmt_seconds(stack_span)}")
|
||||
print(f" sum: {stack_sum:.2f}s (frontend {stack_frontend_sum:.2f}s, codegen {stack_codegen_sum:.2f}s)")
|
||||
for unit in jcode_stack:
|
||||
target = unit.get("target") or "lib"
|
||||
frontend = fmt_seconds(unit.get("frontend_duration"))
|
||||
codegen = fmt_seconds(unit.get("codegen_duration"))
|
||||
print(
|
||||
f" - {unit.get('name')} {target}: "
|
||||
f"start {float(unit.get('start', 0.0)):.2f}s, "
|
||||
f"dur {float(unit.get('duration', 0.0)):.2f}s, "
|
||||
f"frontend {frontend}, codegen {codegen}"
|
||||
)
|
||||
print(f" top {top_n} units:")
|
||||
for unit in top_units:
|
||||
target = unit.get("target") or "lib"
|
||||
frontend = fmt_seconds(unit.get("frontend_duration"))
|
||||
codegen = fmt_seconds(unit.get("codegen_duration"))
|
||||
print(
|
||||
f" - {unit.get('name')} {target}: "
|
||||
f"{float(unit.get('duration', 0.0)):.2f}s "
|
||||
f"(frontend {frontend}, codegen {codegen})"
|
||||
)
|
||||
if json_path:
|
||||
print(f" wrote json: {json_path}")
|
||||
PY
|
||||
Executable
+48
@@ -0,0 +1,48 @@
|
||||
#!/bin/bash
|
||||
# Test script to capture and analyze debug socket events
|
||||
# Usage: ./scripts/debug_socket_test.sh [capture|compare]
|
||||
|
||||
DEBUG_SOCKET="${XDG_RUNTIME_DIR:-/tmp}/jcode-debug.sock"
|
||||
CAPTURE_FILE="/tmp/jcode_debug_capture.jsonl"
|
||||
|
||||
case "${1:-capture}" in
|
||||
capture)
|
||||
echo "Connecting to debug socket: $DEBUG_SOCKET"
|
||||
echo "Saving events to: $CAPTURE_FILE"
|
||||
echo "Press Ctrl+C to stop"
|
||||
echo "---"
|
||||
nc -U "$DEBUG_SOCKET" | tee "$CAPTURE_FILE" | jq -c '.'
|
||||
;;
|
||||
|
||||
snapshot)
|
||||
echo "Getting state snapshot from debug socket..."
|
||||
# Connect and get just the first message (snapshot)
|
||||
timeout 1 nc -U "$DEBUG_SOCKET" | head -1 | jq '.'
|
||||
;;
|
||||
|
||||
watch)
|
||||
echo "Watching debug socket events (pretty print)..."
|
||||
nc -U "$DEBUG_SOCKET" | jq '.'
|
||||
;;
|
||||
|
||||
analyze)
|
||||
if [ -f "$CAPTURE_FILE" ]; then
|
||||
echo "Analyzing captured events..."
|
||||
echo ""
|
||||
echo "Event types:"
|
||||
jq -r '.type' "$CAPTURE_FILE" | sort | uniq -c | sort -rn
|
||||
echo ""
|
||||
echo "Total events: $(wc -l < "$CAPTURE_FILE")"
|
||||
else
|
||||
echo "No capture file found. Run 'capture' first."
|
||||
fi
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "Usage: $0 [capture|snapshot|watch|analyze]"
|
||||
echo " capture - Capture events to file and display"
|
||||
echo " snapshot - Get initial state snapshot"
|
||||
echo " watch - Watch events in real-time (pretty)"
|
||||
echo " analyze - Analyze captured events"
|
||||
;;
|
||||
esac
|
||||
Executable
+159
@@ -0,0 +1,159 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Golden-image regression checks for Jcode Desktop gallery captures.
|
||||
|
||||
Renders every gallery fixture state headlessly via
|
||||
`jcode-desktop --capture-gallery-screens` and compares the PNGs against
|
||||
checked-in baselines.
|
||||
|
||||
Usage:
|
||||
scripts/desktop_gallery_golden.py check # compare against baselines
|
||||
scripts/desktop_gallery_golden.py update # rewrite baselines
|
||||
scripts/desktop_gallery_golden.py check --size 640x400
|
||||
|
||||
Exit codes: 0 ok, 1 regressions found, 2 setup/usage error.
|
||||
|
||||
A state regresses when more than --threshold fraction of pixels differ
|
||||
by more than --tolerance per channel (defaults: 0.5% of pixels, 8/255).
|
||||
Diff images for failing states are written next to the captures.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageChops
|
||||
except ImportError:
|
||||
print("error: pillow is required (pip install pillow)", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
DEFAULT_BIN = ROOT / "target" / "debug" / "jcode-desktop"
|
||||
DEFAULT_BASELINE_DIR = ROOT / "tests" / "desktop-gallery-golden"
|
||||
|
||||
|
||||
def capture(binary: Path, out_dir: Path, size: str | None) -> list[dict]:
|
||||
cmd = [str(binary), "--capture-gallery-screens", str(out_dir)]
|
||||
if size:
|
||||
cmd += ["--capture-size", size]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
|
||||
if result.returncode != 0:
|
||||
print(f"error: capture failed: {result.stderr.strip()}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
manifest = json.loads(result.stdout)
|
||||
return manifest["screens"]
|
||||
|
||||
|
||||
def compare(
|
||||
baseline_path: Path, candidate_path: Path, tolerance: int, threshold: float
|
||||
) -> tuple[bool, float, Image.Image | None]:
|
||||
baseline = Image.open(baseline_path).convert("RGB")
|
||||
candidate = Image.open(candidate_path).convert("RGB")
|
||||
if baseline.size != candidate.size:
|
||||
return False, 1.0, None
|
||||
diff = ImageChops.difference(baseline, candidate)
|
||||
# Count pixels where any channel differs by more than `tolerance`.
|
||||
mask = diff.convert("L").point(lambda value: 255 if value > tolerance else 0)
|
||||
histogram = mask.histogram()
|
||||
differing = sum(histogram[1:])
|
||||
total = baseline.size[0] * baseline.size[1]
|
||||
fraction = differing / total
|
||||
return fraction <= threshold, fraction, diff if fraction > threshold else None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("mode", choices=["check", "update"])
|
||||
parser.add_argument("--binary", type=Path, default=DEFAULT_BIN)
|
||||
parser.add_argument("--baseline-dir", type=Path, default=DEFAULT_BASELINE_DIR)
|
||||
parser.add_argument("--size", default=None, help="WxH render size override")
|
||||
parser.add_argument("--tolerance", type=int, default=8, help="per-channel delta ignored")
|
||||
parser.add_argument(
|
||||
"--threshold",
|
||||
type=float,
|
||||
default=0.005,
|
||||
help="max fraction of differing pixels before failing",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.binary.exists():
|
||||
print(
|
||||
f"error: desktop binary not found at {args.binary}; build with "
|
||||
"`cargo build -p jcode-desktop --bin jcode-desktop`",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
baseline_dir = args.baseline_dir
|
||||
if args.size:
|
||||
baseline_dir = baseline_dir / args.size
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="jcode-gallery-golden-") as tmp:
|
||||
out_dir = Path(tmp)
|
||||
screens = capture(args.binary, out_dir, args.size)
|
||||
|
||||
if args.mode == "update":
|
||||
baseline_dir.mkdir(parents=True, exist_ok=True)
|
||||
for screen in screens:
|
||||
source = out_dir / screen["file"]
|
||||
target = baseline_dir / screen["file"]
|
||||
target.write_bytes(source.read_bytes())
|
||||
print(f"updated {len(screens)} baselines in {baseline_dir}")
|
||||
return 0
|
||||
|
||||
if not baseline_dir.exists():
|
||||
print(
|
||||
f"error: no baselines at {baseline_dir}; run "
|
||||
f"`{sys.argv[0]} update` first",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
failures = []
|
||||
for screen in screens:
|
||||
name = screen["file"]
|
||||
baseline_path = baseline_dir / name
|
||||
candidate_path = out_dir / name
|
||||
if not baseline_path.exists():
|
||||
failures.append((name, "missing baseline", None))
|
||||
continue
|
||||
ok, fraction, diff = compare(
|
||||
baseline_path, candidate_path, args.tolerance, args.threshold
|
||||
)
|
||||
if ok:
|
||||
print(f"ok {name} diff={fraction:.4%}")
|
||||
else:
|
||||
diff_dir = ROOT / "target" / "gallery-golden-diffs"
|
||||
diff_dir.mkdir(parents=True, exist_ok=True)
|
||||
candidate_copy = diff_dir / name
|
||||
candidate_copy.write_bytes(candidate_path.read_bytes())
|
||||
diff_path = None
|
||||
if diff is not None:
|
||||
diff_path = diff_dir / f"diff-{name}"
|
||||
diff.save(diff_path)
|
||||
failures.append((name, f"{fraction:.4%} pixels differ", diff_path))
|
||||
print(f"FAIL {name} diff={fraction:.4%}")
|
||||
|
||||
if failures:
|
||||
print(f"\n{len(failures)} state(s) regressed:")
|
||||
for name, reason, diff_path in failures:
|
||||
suffix = f" (diff: {diff_path})" if diff_path else ""
|
||||
print(f" {name}: {reason}{suffix}")
|
||||
print(
|
||||
"\nIf the change is intentional, refresh with: "
|
||||
f"{sys.argv[0]} update"
|
||||
+ (f" --size {args.size}" if args.size else "")
|
||||
)
|
||||
return 1
|
||||
|
||||
print(f"\nall {len(screens)} gallery states match baselines")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+195
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Real-window E2E user-journey test for jcode-desktop under niri.
|
||||
#
|
||||
# Launches the desktop app in a real compositor window, replays scripted
|
||||
# "user journeys" with wtype (typing, overlays, scrolling, resizing), and
|
||||
# verifies after every step that:
|
||||
# - the app process is still alive
|
||||
# - the compositor window still exists
|
||||
# At the end it summarizes no-paint gaps from the persistent performance log
|
||||
# and fails if any gap during the journey exceeded the budget.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/desktop_journey_e2e.sh [journey ...]
|
||||
# Journeys (default: all):
|
||||
# typing type a draft, clear it
|
||||
# overlays open/close hotkey help and model picker
|
||||
# scrolling keyboard scroll up/down/top/bottom
|
||||
# resize shrink and regrow the window via niri
|
||||
#
|
||||
# Env:
|
||||
# JCODE_DESKTOP_BIN binary (default target/debug/jcode-desktop)
|
||||
# JCODE_JOURNEY_TIMEOUT_SECS per-wait timeout (default 15)
|
||||
# JCODE_JOURNEY_GAP_BUDGET_MS max acceptable no-paint gap (default 1000)
|
||||
# JCODE_JOURNEY_SCREENSHOT_DIR if set, save a grim screenshot per step
|
||||
#
|
||||
# Requirements: niri, jq, wtype, a Wayland session; grim for screenshots.
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
BIN="${JCODE_DESKTOP_BIN:-$ROOT_DIR/target/debug/jcode-desktop}"
|
||||
TIMEOUT_SECS="${JCODE_JOURNEY_TIMEOUT_SECS:-15}"
|
||||
GAP_BUDGET_MS="${JCODE_JOURNEY_GAP_BUDGET_MS:-1000}"
|
||||
SCREENSHOT_DIR="${JCODE_JOURNEY_SCREENSHOT_DIR:-}"
|
||||
LOG_FILE="$(mktemp -t jcode-desktop-journey.XXXXXX.log)"
|
||||
PERF_LOG="${XDG_CACHE_HOME:-$HOME/.cache}/jcode/desktop/performance.log"
|
||||
|
||||
if [[ ! -x "$BIN" ]]; then
|
||||
echo "desktop binary not found: $BIN" >&2
|
||||
echo "hint: cargo build -p jcode-desktop --bin jcode-desktop" >&2
|
||||
exit 2
|
||||
fi
|
||||
for tool in niri jq wtype; do
|
||||
command -v "$tool" >/dev/null 2>&1 || { echo "missing tool: $tool" >&2; exit 2; }
|
||||
done
|
||||
if [[ -n "$SCREENSHOT_DIR" ]]; then
|
||||
command -v grim >/dev/null 2>&1 || { echo "missing tool: grim (needed for screenshots)" >&2; exit 2; }
|
||||
mkdir -p "$SCREENSHOT_DIR"
|
||||
fi
|
||||
|
||||
JOURNEYS=("$@")
|
||||
if [[ ${#JOURNEYS[@]} -eq 0 ]]; then
|
||||
JOURNEYS=(typing overlays scrolling resize)
|
||||
fi
|
||||
|
||||
APP_PID=""
|
||||
WINDOW_ID=""
|
||||
STEP_INDEX=0
|
||||
FAILURES=()
|
||||
|
||||
cleanup() {
|
||||
if [[ -n "$APP_PID" ]] && kill -0 "$APP_PID" 2>/dev/null; then
|
||||
kill "$APP_PID" 2>/dev/null || true
|
||||
wait "$APP_PID" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
window_json() {
|
||||
niri msg -j windows | jq -c --argjson pid "$APP_PID" 'map(select(.pid == $pid)) | first // empty'
|
||||
}
|
||||
|
||||
assert_alive() {
|
||||
local step="$1"
|
||||
if ! kill -0 "$APP_PID" 2>/dev/null; then
|
||||
echo "FAIL [$step]: app process died" >&2
|
||||
tail -40 "$LOG_FILE" >&2 || true
|
||||
FAILURES+=("$step: process died")
|
||||
return 1
|
||||
fi
|
||||
if [[ -z "$(window_json)" ]]; then
|
||||
echo "FAIL [$step]: compositor window vanished" >&2
|
||||
FAILURES+=("$step: window vanished")
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
step() {
|
||||
local name="$1"
|
||||
STEP_INDEX=$((STEP_INDEX + 1))
|
||||
sleep 0.3
|
||||
if ! assert_alive "$name"; then
|
||||
return 1
|
||||
fi
|
||||
if [[ -n "$SCREENSHOT_DIR" ]]; then
|
||||
local geom
|
||||
geom="$(window_json | jq -r '"\(.layout.tile_pos_in_workspace_view[0]),\(.layout.tile_pos_in_workspace_view[1]) \(.layout.window_size[0])x\(.layout.window_size[1])"' 2>/dev/null || true)"
|
||||
grim -g "$geom" "$SCREENSHOT_DIR/$(printf '%02d' "$STEP_INDEX")-$name.png" 2>/dev/null \
|
||||
|| grim "$SCREENSHOT_DIR/$(printf '%02d' "$STEP_INDEX")-$name.png" 2>/dev/null || true
|
||||
fi
|
||||
echo "ok [$STEP_INDEX] $name"
|
||||
}
|
||||
|
||||
journey_typing() {
|
||||
wtype 'hello from the journey test'
|
||||
step typing-draft || return 1
|
||||
# clear the draft (Ctrl+U deletes to line start)
|
||||
wtype -M ctrl u -m ctrl
|
||||
step typing-clear || return 1
|
||||
}
|
||||
|
||||
journey_overlays() {
|
||||
# hotkey help: Ctrl+/ toggles, Escape closes
|
||||
wtype -M ctrl '/' -m ctrl
|
||||
step overlay-hotkey-help || return 1
|
||||
wtype -k Escape
|
||||
step overlay-hotkey-help-close || return 1
|
||||
# model picker via slash command
|
||||
wtype '/model'
|
||||
wtype -k Return
|
||||
step overlay-model-picker || return 1
|
||||
wtype -k Escape
|
||||
wtype -M ctrl u -m ctrl
|
||||
step overlay-model-picker-close || return 1
|
||||
}
|
||||
|
||||
journey_scrolling() {
|
||||
for key in Page_Up Page_Up Page_Down End Home End; do
|
||||
wtype -k "$key"
|
||||
done
|
||||
step scrolling-keys || return 1
|
||||
}
|
||||
|
||||
journey_resize() {
|
||||
niri msg action set-window-width --id "$WINDOW_ID" "50%" >/dev/null 2>&1 || true
|
||||
step resize-narrow || return 1
|
||||
niri msg action set-window-width --id "$WINDOW_ID" "100%" >/dev/null 2>&1 || true
|
||||
step resize-restore || return 1
|
||||
}
|
||||
|
||||
JOURNEY_STARTED_MS=$(( $(date +%s%N) / 1000000 ))
|
||||
|
||||
"$BIN" >"$LOG_FILE" 2>&1 &
|
||||
APP_PID=$!
|
||||
|
||||
deadline=$((SECONDS + TIMEOUT_SECS))
|
||||
WINDOW_JSON=""
|
||||
while (( SECONDS < deadline )); do
|
||||
kill -0 "$APP_PID" 2>/dev/null || { echo "app exited at startup" >&2; tail -40 "$LOG_FILE" >&2; exit 1; }
|
||||
WINDOW_JSON="$(window_json)"
|
||||
[[ -n "$WINDOW_JSON" ]] && break
|
||||
sleep 0.1
|
||||
done
|
||||
[[ -n "$WINDOW_JSON" ]] || { echo "timed out waiting for window" >&2; exit 1; }
|
||||
WINDOW_ID="$(jq -r '.id' <<<"$WINDOW_JSON")"
|
||||
|
||||
niri msg action focus-window --id "$WINDOW_ID" >/dev/null
|
||||
sleep 0.5
|
||||
step launch || true
|
||||
|
||||
for journey in "${JOURNEYS[@]}"; do
|
||||
case "$journey" in
|
||||
typing) journey_typing || true ;;
|
||||
overlays) journey_overlays || true ;;
|
||||
scrolling) journey_scrolling || true ;;
|
||||
resize) journey_resize || true ;;
|
||||
*) echo "unknown journey: $journey" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
cleanup
|
||||
APP_PID=""
|
||||
|
||||
# Smoothness oracle: inspect no-paint gaps recorded during this run.
|
||||
if [[ -f "$PERF_LOG" ]]; then
|
||||
WORST_GAP="$(jq -s --argjson since "$JOURNEY_STARTED_MS" '
|
||||
[ .[] | select(.timestamp_unix_ms? >= $since)
|
||||
| select(.event? == "no_paint_gap" or (.gap_ms? != null))
|
||||
| (.gap_ms? // .duration_ms? // 0) ] | max // 0
|
||||
' "$PERF_LOG" 2>/dev/null || echo 0)"
|
||||
echo "worst no-paint gap during journey: ${WORST_GAP}ms (budget ${GAP_BUDGET_MS}ms)"
|
||||
if (( $(printf '%.0f' "$WORST_GAP") > GAP_BUDGET_MS )); then
|
||||
FAILURES+=("smoothness: no-paint gap ${WORST_GAP}ms exceeded ${GAP_BUDGET_MS}ms")
|
||||
fi
|
||||
fi
|
||||
|
||||
if (( ${#FAILURES[@]} > 0 )); then
|
||||
echo
|
||||
echo "${#FAILURES[@]} failure(s):"
|
||||
printf ' %s\n' "${FAILURES[@]}"
|
||||
exit 1
|
||||
fi
|
||||
echo
|
||||
echo "all journeys passed ($STEP_INDEX steps)"
|
||||
Executable
+327
@@ -0,0 +1,327 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Summarize Jcode Desktop persistent performance logs.
|
||||
|
||||
The desktop app writes JSONL events to ~/.cache/jcode/desktop/performance.log.
|
||||
This helper groups events by launch_id when available, reports the latest run by
|
||||
default, and ranks the largest no-paint gaps, frame stalls, event queue delays,
|
||||
and background disk work.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import collections
|
||||
import datetime as _dt
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
DEFAULT_TOP = 8
|
||||
|
||||
|
||||
def default_log_path() -> Path:
|
||||
xdg = os.environ.get("XDG_CACHE_HOME")
|
||||
if xdg:
|
||||
return Path(xdg) / "jcode" / "desktop" / "performance.log"
|
||||
return Path.home() / ".cache" / "jcode" / "desktop" / "performance.log"
|
||||
|
||||
|
||||
def ms(value: Any) -> float:
|
||||
if isinstance(value, (int, float)):
|
||||
return float(value)
|
||||
return 0.0
|
||||
|
||||
|
||||
def event_ts(event: dict[str, Any]) -> int:
|
||||
value = event.get("timestamp_unix_ms")
|
||||
return int(value) if isinstance(value, int) else 0
|
||||
|
||||
|
||||
def format_ts(timestamp_ms: int) -> str:
|
||||
if timestamp_ms <= 0:
|
||||
return "unknown-time"
|
||||
return _dt.datetime.fromtimestamp(timestamp_ms / 1000, tz=_dt.UTC).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def load_events(path: Path) -> tuple[list[dict[str, Any]], int]:
|
||||
events: list[dict[str, Any]] = []
|
||||
malformed = 0
|
||||
if not path.exists():
|
||||
return events, malformed
|
||||
with path.open("r", encoding="utf-8", errors="replace") as handle:
|
||||
for line in handle:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
event = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
malformed += 1
|
||||
continue
|
||||
if isinstance(event, dict):
|
||||
events.append(event)
|
||||
else:
|
||||
malformed += 1
|
||||
return events, malformed
|
||||
|
||||
|
||||
def latest_launch_id(events: list[dict[str, Any]]) -> str | None:
|
||||
latest: tuple[int, str] | None = None
|
||||
for event in events:
|
||||
launch_id = event.get("launch_id")
|
||||
if not isinstance(launch_id, str) or not launch_id:
|
||||
continue
|
||||
candidate = (event_ts(event), launch_id)
|
||||
if latest is None or candidate[0] >= latest[0]:
|
||||
latest = candidate
|
||||
return latest[1] if latest else None
|
||||
|
||||
|
||||
def select_events(
|
||||
events: list[dict[str, Any]], *, launch_id: str | None, include_all: bool
|
||||
) -> tuple[list[dict[str, Any]], str]:
|
||||
if include_all:
|
||||
return events, "all launches"
|
||||
if launch_id:
|
||||
return [event for event in events if event.get("launch_id") == launch_id], f"launch_id={launch_id}"
|
||||
latest = latest_launch_id(events)
|
||||
if latest:
|
||||
return [event for event in events if event.get("launch_id") == latest], f"latest launch_id={latest}"
|
||||
return events, "legacy events without launch_id"
|
||||
|
||||
|
||||
def top_events(
|
||||
events: list[dict[str, Any]], event_name: str, key_path: tuple[str, ...], limit: int
|
||||
) -> list[dict[str, Any]]:
|
||||
def key(event: dict[str, Any]) -> float:
|
||||
value: Any = event
|
||||
for key_part in key_path:
|
||||
if not isinstance(value, dict):
|
||||
return 0.0
|
||||
value = value.get(key_part)
|
||||
return ms(value)
|
||||
|
||||
return sorted(
|
||||
(event for event in events if event.get("event") == event_name),
|
||||
key=key,
|
||||
reverse=True,
|
||||
)[:limit]
|
||||
|
||||
|
||||
def stage_summary(payload: dict[str, Any]) -> tuple[str, float]:
|
||||
stages = payload.get("stages")
|
||||
if not isinstance(stages, list):
|
||||
return ("unknown", 0.0)
|
||||
best_name = "unknown"
|
||||
best_ms = 0.0
|
||||
for stage in stages:
|
||||
if not isinstance(stage, dict):
|
||||
continue
|
||||
stage_ms = ms(stage.get("ms"))
|
||||
if stage_ms > best_ms:
|
||||
best_ms = stage_ms
|
||||
best_name = str(stage.get("name", "unknown"))
|
||||
return best_name, best_ms
|
||||
|
||||
|
||||
def summarize(events: list[dict[str, Any]], scope: str, malformed: int, top: int) -> dict[str, Any]:
|
||||
counts = collections.Counter(str(event.get("event", "unknown")) for event in events)
|
||||
timestamps = [event_ts(event) for event in events if event_ts(event) > 0]
|
||||
launch_ids = sorted({event.get("launch_id") for event in events if isinstance(event.get("launch_id"), str)})
|
||||
build_hashes = sorted({event.get("build_hash") for event in events if isinstance(event.get("build_hash"), str)})
|
||||
pids = sorted({event.get("pid") for event in events if isinstance(event.get("pid"), int)})
|
||||
|
||||
no_paint = top_events(events, "jcode-desktop-no-paint-profile", ("payload", "gap_ms"), top)
|
||||
frames = top_events(events, "jcode-desktop-frame-profile", ("payload", "worst_wall_ms"), top)
|
||||
session_events = top_events(events, "jcode-desktop-session-event-profile", ("payload", "ui_queue_delay_ms"), top)
|
||||
card_loads = top_events(events, "jcode-desktop-session-cards-load-profile", ("payload", "loaded_in_ms"), top)
|
||||
single_card_loads = top_events(events, "jcode-desktop-session-card-refresh-profile", ("payload", "loaded_in_ms"), top)
|
||||
preference_saves = top_events(events, "jcode-desktop-preferences-save-profile", ("payload", "saved_in_ms"), top)
|
||||
restores = top_events(events, "jcode-desktop-crashed-sessions-restore-profile", ("payload", "elapsed_ms"), top)
|
||||
|
||||
worst_frame_details = []
|
||||
for event in frames:
|
||||
payload = event.get("payload") if isinstance(event.get("payload"), dict) else {}
|
||||
stage_name, stage_ms = stage_summary(payload)
|
||||
worst_frame_details.append(
|
||||
{
|
||||
"timestamp_unix_ms": event_ts(event),
|
||||
"worst_wall_ms": ms(payload.get("worst_wall_ms")),
|
||||
"worst_cpu_ms": ms(payload.get("worst_cpu_ms")),
|
||||
"present_ms": ms(payload.get("present_ms")),
|
||||
"queue_submit_ms": ms(payload.get("queue_submit_ms")),
|
||||
"submit_present_ms": ms(payload.get("submit_present_ms")),
|
||||
"dominant_stage": stage_name,
|
||||
"dominant_stage_ms": stage_ms,
|
||||
"mode": payload.get("mode"),
|
||||
}
|
||||
)
|
||||
|
||||
diagnoses: list[str] = []
|
||||
if no_paint:
|
||||
payload = no_paint[0].get("payload") if isinstance(no_paint[0].get("payload"), dict) else {}
|
||||
gap = ms(payload.get("gap_ms"))
|
||||
pending = payload.get("pending_interaction_kind")
|
||||
background = payload.get("has_background_work")
|
||||
diagnoses.append(
|
||||
f"Largest no-paint gap was {gap:.1f} ms; pending={pending!r}, background_work={background}."
|
||||
)
|
||||
if worst_frame_details:
|
||||
frame = worst_frame_details[0]
|
||||
diagnoses.append(
|
||||
f"Worst frame was {frame['worst_wall_ms']:.1f} ms; dominant stage {frame['dominant_stage']}={frame['dominant_stage_ms']:.1f} ms, present={frame['present_ms']:.1f} ms."
|
||||
)
|
||||
if frame["present_ms"] >= 33 or frame["dominant_stage"] in {"surface_acquire", "present", "queue_submit"}:
|
||||
diagnoses.append("Likely GPU/window-system stall when frame/present dominates rather than app CPU.")
|
||||
if session_events:
|
||||
payload = session_events[0].get("payload") if isinstance(session_events[0].get("payload"), dict) else {}
|
||||
queue_delay = ms(payload.get("ui_queue_delay_ms"))
|
||||
apply_ms = ms(payload.get("apply_ms"))
|
||||
if queue_delay >= 250:
|
||||
diagnoses.append(
|
||||
f"Session events waited {queue_delay:.1f} ms in the UI queue while apply cost was {apply_ms:.3f} ms, indicating event-loop starvation."
|
||||
)
|
||||
if card_loads or single_card_loads or preference_saves or restores:
|
||||
slow_disk = []
|
||||
if card_loads:
|
||||
slow_disk.append(f"cards={ms(card_loads[0].get('payload', {}).get('loaded_in_ms')):.1f} ms")
|
||||
if single_card_loads:
|
||||
slow_disk.append(f"single_card={ms(single_card_loads[0].get('payload', {}).get('loaded_in_ms')):.1f} ms")
|
||||
if preference_saves:
|
||||
slow_disk.append(f"prefs={ms(preference_saves[0].get('payload', {}).get('saved_in_ms')):.1f} ms")
|
||||
if restores:
|
||||
slow_disk.append(f"restore={ms(restores[0].get('payload', {}).get('elapsed_ms')):.1f} ms")
|
||||
diagnoses.append("Slow disk/process work was observed off the UI thread: " + ", ".join(slow_disk) + ".")
|
||||
|
||||
return {
|
||||
"scope": scope,
|
||||
"events": len(events),
|
||||
"malformed_lines": malformed,
|
||||
"launch_ids": launch_ids,
|
||||
"build_hashes": build_hashes,
|
||||
"pids": pids,
|
||||
"start": min(timestamps) if timestamps else None,
|
||||
"end": max(timestamps) if timestamps else None,
|
||||
"event_counts": dict(counts.most_common()),
|
||||
"top_no_paint": [
|
||||
{
|
||||
"timestamp_unix_ms": event_ts(event),
|
||||
"gap_ms": ms(event.get("payload", {}).get("gap_ms")),
|
||||
"pending_interaction_kind": event.get("payload", {}).get("pending_interaction_kind"),
|
||||
"has_background_work": event.get("payload", {}).get("has_background_work"),
|
||||
"pending_backend_redraw": event.get("payload", {}).get("pending_backend_redraw"),
|
||||
"mode": event.get("payload", {}).get("mode"),
|
||||
}
|
||||
for event in no_paint
|
||||
],
|
||||
"top_frames": worst_frame_details,
|
||||
"top_session_event_queue": [
|
||||
{
|
||||
"timestamp_unix_ms": event_ts(event),
|
||||
"ui_queue_delay_ms": ms(event.get("payload", {}).get("ui_queue_delay_ms")),
|
||||
"apply_ms": ms(event.get("payload", {}).get("apply_ms")),
|
||||
"forwarder_accumulated_ms": ms(event.get("payload", {}).get("forwarder_accumulated_ms")),
|
||||
"raw_events": event.get("payload", {}).get("raw_events"),
|
||||
"text_delta_bytes": event.get("payload", {}).get("text_delta_bytes"),
|
||||
}
|
||||
for event in session_events
|
||||
],
|
||||
"top_background_disk": {
|
||||
"session_cards": [event.get("payload", {}) for event in card_loads],
|
||||
"single_card_refresh": [event.get("payload", {}) for event in single_card_loads],
|
||||
"preferences": [event.get("payload", {}) for event in preference_saves],
|
||||
"restore_crashed_sessions": [event.get("payload", {}) for event in restores],
|
||||
},
|
||||
"diagnosis": diagnoses,
|
||||
}
|
||||
|
||||
|
||||
def print_text(summary: dict[str, Any], path: Path) -> None:
|
||||
print("Jcode Desktop performance report")
|
||||
print(f"log: {path}")
|
||||
print(f"scope: {summary['scope']}")
|
||||
print(f"events: {summary['events']} malformed_lines: {summary['malformed_lines']}")
|
||||
if summary["start"]:
|
||||
print(f"range: {format_ts(summary['start'])} -> {format_ts(summary['end'])}")
|
||||
if summary["launch_ids"]:
|
||||
print(f"launch_ids: {', '.join(summary['launch_ids'][-3:])}")
|
||||
if summary["build_hashes"]:
|
||||
print(f"build_hashes: {', '.join(summary['build_hashes'][-3:])}")
|
||||
if summary["pids"]:
|
||||
print(f"pids: {', '.join(str(pid) for pid in summary['pids'][-5:])}")
|
||||
|
||||
print("\nevent counts:")
|
||||
for event, count in summary["event_counts"].items():
|
||||
print(f" {event}: {count}")
|
||||
|
||||
print("\ntop no-paint gaps:")
|
||||
if not summary["top_no_paint"]:
|
||||
print(" none")
|
||||
for item in summary["top_no_paint"]:
|
||||
print(
|
||||
" {gap_ms:8.1f} ms {ts} pending={pending_interaction_kind!r} background={has_background_work} backend_redraw={pending_backend_redraw} mode={mode}".format(
|
||||
ts=format_ts(item["timestamp_unix_ms"]), **item
|
||||
)
|
||||
)
|
||||
|
||||
print("\ntop frame stalls:")
|
||||
if not summary["top_frames"]:
|
||||
print(" none")
|
||||
for item in summary["top_frames"]:
|
||||
print(
|
||||
" {worst_wall_ms:8.1f} ms wall cpu={worst_cpu_ms:7.1f} present={present_ms:7.1f} submit={queue_submit_ms:7.1f} dominant={dominant_stage}:{dominant_stage_ms:.1f} {ts}".format(
|
||||
ts=format_ts(item["timestamp_unix_ms"]), **item
|
||||
)
|
||||
)
|
||||
|
||||
print("\ntop session event queue delays:")
|
||||
if not summary["top_session_event_queue"]:
|
||||
print(" none")
|
||||
for item in summary["top_session_event_queue"]:
|
||||
print(
|
||||
" queue={ui_queue_delay_ms:8.1f} ms apply={apply_ms:7.3f} ms forwarder={forwarder_accumulated_ms:7.1f} ms raw={raw_events} bytes={text_delta_bytes} {ts}".format(
|
||||
ts=format_ts(item["timestamp_unix_ms"]), **item
|
||||
)
|
||||
)
|
||||
|
||||
print("\nbackground disk/process work:")
|
||||
disk = summary["top_background_disk"]
|
||||
printed = False
|
||||
for label, items in disk.items():
|
||||
for payload in items[:3]:
|
||||
printed = True
|
||||
elapsed = payload.get("loaded_in_ms", payload.get("saved_in_ms", payload.get("elapsed_ms", 0.0)))
|
||||
print(f" {label}: {ms(elapsed):8.1f} ms {payload}")
|
||||
if not printed:
|
||||
print(" none")
|
||||
|
||||
print("\ndiagnosis:")
|
||||
if not summary["diagnosis"]:
|
||||
print(" No slow profile events in this scope.")
|
||||
for line in summary["diagnosis"]:
|
||||
print(f" - {line}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("log", nargs="?", type=Path, default=default_log_path())
|
||||
parser.add_argument("--launch-id", help="summarize only one launch_id")
|
||||
parser.add_argument("--all", action="store_true", help="summarize all launches instead of the latest launch")
|
||||
parser.add_argument("--top", type=int, default=DEFAULT_TOP, help="number of rows per top list")
|
||||
parser.add_argument("--json", action="store_true", help="emit machine-readable JSON")
|
||||
args = parser.parse_args()
|
||||
|
||||
events, malformed = load_events(args.log)
|
||||
selected, scope = select_events(events, launch_id=args.launch_id, include_all=args.all)
|
||||
summary = summarize(selected, scope, malformed, max(1, args.top))
|
||||
if args.json:
|
||||
print(json.dumps(summary, indent=2, sort_keys=True))
|
||||
else:
|
||||
print_text(summary, args.log)
|
||||
return 0 if summary["events"] else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+150
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# End-to-end smoke test for desktop stable-host /reload behavior under niri.
|
||||
#
|
||||
# It launches jcode-desktop in stable-host mode, records the compositor window id
|
||||
# and layout, injects `/reload`, then verifies the same OS window is still present
|
||||
# with the same niri placement and the app-worker child process changed. This catches
|
||||
# regressions where slash reload falls back to the old full-process handoff path
|
||||
# that closes/reopens the desktop window.
|
||||
#
|
||||
# Requirements: niri, jq, wtype, a Wayland session, and a built jcode-desktop.
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
BIN="${JCODE_DESKTOP_BIN:-$ROOT_DIR/target/debug/jcode-desktop}"
|
||||
TIMEOUT_SECS="${JCODE_DESKTOP_RELOAD_E2E_TIMEOUT_SECS:-15}"
|
||||
LOG_FILE="${JCODE_DESKTOP_RELOAD_E2E_LOG:-$(mktemp -t jcode-desktop-reload-e2e.XXXXXX.log)}"
|
||||
|
||||
if [[ ! -x "$BIN" ]]; then
|
||||
echo "desktop binary not found or not executable: $BIN" >&2
|
||||
echo "hint: cargo build -p jcode-desktop --bin jcode-desktop" >&2
|
||||
exit 2
|
||||
fi
|
||||
for tool in niri jq wtype; do
|
||||
if ! command -v "$tool" >/dev/null 2>&1; then
|
||||
echo "required tool not found: $tool" >&2
|
||||
exit 2
|
||||
fi
|
||||
done
|
||||
|
||||
cleanup() {
|
||||
if [[ -n "${APP_PID:-}" ]] && kill -0 "$APP_PID" 2>/dev/null; then
|
||||
kill "$APP_PID" 2>/dev/null || true
|
||||
wait "$APP_PID" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
child_pids() {
|
||||
pgrep -P "$APP_PID" 2>/dev/null | sort | tr '\n' ' ' | sed 's/[[:space:]]*$//'
|
||||
}
|
||||
|
||||
layouts_match_with_tolerance() {
|
||||
local before="$1"
|
||||
local after="$2"
|
||||
jq -e -n --argjson before "$before" --argjson after "$after" '
|
||||
def abs: if . < 0 then -. else . end;
|
||||
def close($a; $b): (($a - $b) | abs) <= 2;
|
||||
($before.id == $after.id)
|
||||
and ($before.pid == $after.pid)
|
||||
and ($before.workspace_id == $after.workspace_id)
|
||||
and ($before.is_floating == $after.is_floating)
|
||||
and ($before.layout.pos_in_scrolling_layout == $after.layout.pos_in_scrolling_layout)
|
||||
and ($before.layout.tile_pos_in_workspace_view == $after.layout.tile_pos_in_workspace_view)
|
||||
and ($before.layout.window_offset_in_tile == $after.layout.window_offset_in_tile)
|
||||
and close($before.layout.tile_size[0]; $after.layout.tile_size[0])
|
||||
and close($before.layout.tile_size[1]; $after.layout.tile_size[1])
|
||||
and close($before.layout.window_size[0]; $after.layout.window_size[0])
|
||||
and close($before.layout.window_size[1]; $after.layout.window_size[1])
|
||||
' >/dev/null
|
||||
}
|
||||
|
||||
"$BIN" \
|
||||
--desktop-process-role stable-host \
|
||||
--startup-log \
|
||||
>"$LOG_FILE" 2>&1 &
|
||||
APP_PID=$!
|
||||
|
||||
deadline=$((SECONDS + TIMEOUT_SECS))
|
||||
WINDOW_JSON=""
|
||||
while (( SECONDS < deadline )); do
|
||||
if ! kill -0 "$APP_PID" 2>/dev/null; then
|
||||
echo "desktop process exited before window appeared" >&2
|
||||
cat "$LOG_FILE" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
WINDOW_JSON="$(niri msg -j windows | jq -c --argjson pid "$APP_PID" 'map(select(.pid == $pid)) | first // empty')"
|
||||
if [[ -n "$WINDOW_JSON" ]]; then
|
||||
break
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
|
||||
if [[ -z "$WINDOW_JSON" ]]; then
|
||||
echo "timed out waiting for desktop window for pid $APP_PID" >&2
|
||||
cat "$LOG_FILE" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
WINDOW_ID="$(jq -r '.id' <<<"$WINDOW_JSON")"
|
||||
BEFORE_LAYOUT="$(jq -c '{id, pid, workspace_id, is_floating, layout}' <<<"$WINDOW_JSON")"
|
||||
BEFORE_CHILDREN=""
|
||||
deadline=$((SECONDS + TIMEOUT_SECS))
|
||||
while (( SECONDS < deadline )); do
|
||||
BEFORE_CHILDREN="$(child_pids)"
|
||||
if [[ -n "$BEFORE_CHILDREN" ]]; then
|
||||
break
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
if [[ -z "$BEFORE_CHILDREN" ]]; then
|
||||
echo "timed out waiting for app-worker child process" >&2
|
||||
cat "$LOG_FILE" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
niri msg action focus-window --id "$WINDOW_ID" >/dev/null
|
||||
sleep 0.2
|
||||
# Trigger the user-visible slash command path. In stable-host mode this should
|
||||
# request a host-side app-worker restart, not a full desktop process handoff.
|
||||
wtype '/reload'
|
||||
wtype -k Return
|
||||
|
||||
# Wait for the reload request to be processed. The stable-host path should keep
|
||||
# the same compositor window alive while restarting only the app worker.
|
||||
deadline=$((SECONDS + TIMEOUT_SECS))
|
||||
AFTER_CHILDREN=""
|
||||
while (( SECONDS < deadline )); do
|
||||
AFTER_CHILDREN="$(child_pids)"
|
||||
if [[ -n "$AFTER_CHILDREN" && "$AFTER_CHILDREN" != "$BEFORE_CHILDREN" ]]; then
|
||||
break
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
if [[ -z "$AFTER_CHILDREN" || "$AFTER_CHILDREN" == "$BEFORE_CHILDREN" ]]; then
|
||||
echo "app-worker child process did not change after hot reload trigger" >&2
|
||||
echo "before children: $BEFORE_CHILDREN" >&2
|
||||
echo "after children: $AFTER_CHILDREN" >&2
|
||||
cat "$LOG_FILE" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
AFTER_JSON="$(niri msg -j windows | jq -c --argjson id "$WINDOW_ID" 'map(select(.id == $id)) | first // empty')"
|
||||
if [[ -z "$AFTER_JSON" ]]; then
|
||||
echo "window id $WINDOW_ID disappeared after /reload" >&2
|
||||
echo "before: $BEFORE_LAYOUT" >&2
|
||||
cat "$LOG_FILE" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
AFTER_LAYOUT="$(jq -c '{id, pid, workspace_id, is_floating, layout}' <<<"$AFTER_JSON")"
|
||||
if ! layouts_match_with_tolerance "$BEFORE_LAYOUT" "$AFTER_LAYOUT"; then
|
||||
echo "window layout changed after /reload" >&2
|
||||
echo "before: $BEFORE_LAYOUT" >&2
|
||||
echo "after: $AFTER_LAYOUT" >&2
|
||||
cat "$LOG_FILE" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "desktop reload window e2e ok: window_id=$WINDOW_ID host_pid=$APP_PID before_worker='$BEFORE_CHILDREN' after_worker='$AFTER_CHILDREN' log=$LOG_FILE"
|
||||
Executable
+302
@@ -0,0 +1,302 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Visual reporting tool for jcode-desktop debug frames.
|
||||
|
||||
Composes gallery frame captures into filmstrips, animated GIFs, and
|
||||
before/after pixel-diff reports. Requires python3 + Pillow only.
|
||||
|
||||
Frame naming convention: sequences look like ``gallery-<state>-f000.png``,
|
||||
``gallery-<state>-f001.png``, ... Single captures without the ``-fNNN``
|
||||
suffix (e.g. ``gallery-empty.png``) are treated as one-frame sequences.
|
||||
|
||||
Usage examples:
|
||||
|
||||
# One filmstrip PNG: each state is a row of horizontally-composed frames.
|
||||
scripts/desktop_visual_report.py filmstrip \
|
||||
--frames-dir /tmp/desktop-vis/baseline --out /tmp/filmstrip.png
|
||||
|
||||
# Animated GIF for a single state's frame sequence.
|
||||
scripts/desktop_visual_report.py gif \
|
||||
--frames-dir /tmp/desktop-vis/frames --state streaming \
|
||||
--out /tmp/streaming.gif --duration-ms 90
|
||||
|
||||
# Pixel diff every PNG shared by two directories.
|
||||
scripts/desktop_visual_report.py diff \
|
||||
--before /tmp/desktop-vis/baseline --after /tmp/desktop-vis/candidate \
|
||||
--out-dir /tmp/desktop-vis/diff --threshold 8
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageChops, ImageDraw, ImageOps
|
||||
except ImportError: # pragma: no cover - environment guard
|
||||
sys.stderr.write("error: Pillow is required (pip install Pillow)\n")
|
||||
sys.exit(2)
|
||||
|
||||
FRAME_RE = re.compile(r"^(?P<state>.+)-f(?P<idx>\d+)$")
|
||||
|
||||
# Percent of changed pixels above which a side-by-side composite is emitted.
|
||||
COMPOSITE_PERCENT_THRESHOLD = 0.01
|
||||
|
||||
|
||||
def collect_sequences(frames_dir: Path) -> dict[str, list[Path]]:
|
||||
"""Group PNGs in ``frames_dir`` into named frame sequences.
|
||||
|
||||
Files matching ``<state>-fNNN.png`` are grouped under ``<state>`` and
|
||||
ordered by frame index. Any other PNG becomes a single-frame sequence
|
||||
keyed by its stem. Returns an insertion-ordered dict sorted by state name.
|
||||
"""
|
||||
sequences: dict[str, list[tuple[int, Path]]] = {}
|
||||
for path in sorted(frames_dir.glob("*.png")):
|
||||
match = FRAME_RE.match(path.stem)
|
||||
if match:
|
||||
state = match.group("state")
|
||||
idx = int(match.group("idx"))
|
||||
else:
|
||||
state = path.stem
|
||||
idx = 0
|
||||
sequences.setdefault(state, []).append((idx, path))
|
||||
return {
|
||||
state: [path for _, path in sorted(frames)]
|
||||
for state, frames in sorted(sequences.items())
|
||||
}
|
||||
|
||||
|
||||
def match_state(sequences: dict[str, list[Path]], state: str) -> list[Path] | None:
|
||||
"""Find a sequence by exact state name, with a ``gallery-`` prefix fallback."""
|
||||
for candidate in (state, f"gallery-{state}"):
|
||||
if candidate in sequences:
|
||||
return sequences[candidate]
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# filmstrip
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def cmd_filmstrip(args: argparse.Namespace) -> int:
|
||||
frames_dir = Path(args.frames_dir)
|
||||
sequences = collect_sequences(frames_dir)
|
||||
if not sequences:
|
||||
sys.stderr.write(f"error: no PNG files found in {frames_dir}\n")
|
||||
return 1
|
||||
|
||||
max_width = args.max_width
|
||||
label_height = 18
|
||||
pad = 4
|
||||
rows: list[tuple[str, list[Image.Image]]] = []
|
||||
|
||||
for state, paths in sequences.items():
|
||||
frames = [Image.open(p).convert("RGB") for p in paths]
|
||||
total_w = sum(im.width for im in frames) + pad * (len(frames) - 1)
|
||||
scale = min(1.0, (max_width - 2 * pad) / total_w) if total_w > 0 else 1.0
|
||||
if scale < 1.0:
|
||||
frames = [
|
||||
im.resize(
|
||||
(max(1, round(im.width * scale)), max(1, round(im.height * scale))),
|
||||
Image.LANCZOS,
|
||||
)
|
||||
for im in frames
|
||||
]
|
||||
rows.append((state, frames))
|
||||
|
||||
strip_width = max(
|
||||
sum(im.width for im in frames) + pad * (len(frames) - 1) + 2 * pad
|
||||
for _, frames in rows
|
||||
)
|
||||
strip_height = sum(
|
||||
label_height + max(im.height for im in frames) + 2 * pad for _, frames in rows
|
||||
)
|
||||
|
||||
canvas = Image.new("RGB", (strip_width, strip_height), (24, 24, 28))
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
y = 0
|
||||
for state, frames in rows:
|
||||
frame_note = f" ({len(frames)} frames)" if len(frames) > 1 else ""
|
||||
draw.text((pad, y + 2), state + frame_note, fill=(220, 220, 220))
|
||||
y += label_height
|
||||
x = pad
|
||||
row_h = max(im.height for im in frames)
|
||||
for im in frames:
|
||||
canvas.paste(im, (x, y + pad))
|
||||
x += im.width + pad
|
||||
y += row_h + 2 * pad
|
||||
|
||||
out = Path(args.out)
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
canvas.save(out)
|
||||
print(
|
||||
f"filmstrip: {len(rows)} state row(s), "
|
||||
f"{sum(len(f) for _, f in rows)} frame(s) -> {out} "
|
||||
f"({canvas.width}x{canvas.height})"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# gif
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def cmd_gif(args: argparse.Namespace) -> int:
|
||||
frames_dir = Path(args.frames_dir)
|
||||
sequences = collect_sequences(frames_dir)
|
||||
paths = match_state(sequences, args.state)
|
||||
if paths is None:
|
||||
available = ", ".join(sequences) or "<none>"
|
||||
sys.stderr.write(
|
||||
f"error: no frames for state '{args.state}' in {frames_dir}\n"
|
||||
f"available states: {available}\n"
|
||||
)
|
||||
return 1
|
||||
|
||||
frames = [Image.open(p).convert("RGB") for p in paths]
|
||||
out = Path(args.out)
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
frames[0].save(
|
||||
out,
|
||||
save_all=True,
|
||||
append_images=frames[1:],
|
||||
duration=args.duration_ms,
|
||||
loop=0,
|
||||
optimize=False,
|
||||
)
|
||||
print(
|
||||
f"gif: state '{args.state}' {len(frames)} frame(s) "
|
||||
f"@ {args.duration_ms}ms -> {out}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# diff
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def max_channel_diff(a: Image.Image, b: Image.Image) -> Image.Image:
|
||||
"""Per-pixel max absolute channel difference of two RGB images (mode L)."""
|
||||
diff = ImageChops.difference(a, b)
|
||||
r, g, bl = diff.split()
|
||||
return ImageChops.lighter(ImageChops.lighter(r, g), bl)
|
||||
|
||||
|
||||
def cmd_diff(args: argparse.Namespace) -> int:
|
||||
before_dir = Path(args.before)
|
||||
after_dir = Path(args.after)
|
||||
out_dir = Path(args.out_dir)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
threshold = args.threshold
|
||||
|
||||
before_names = {p.name for p in before_dir.glob("*.png")}
|
||||
after_names = {p.name for p in after_dir.glob("*.png")}
|
||||
shared = sorted(before_names & after_names)
|
||||
if not shared:
|
||||
sys.stderr.write("error: no PNG filenames shared between the two dirs\n")
|
||||
return 1
|
||||
|
||||
rows: list[str] = []
|
||||
composites = 0
|
||||
for name in shared:
|
||||
img_a = Image.open(before_dir / name).convert("RGB")
|
||||
img_b = Image.open(after_dir / name).convert("RGB")
|
||||
if img_a.size != img_b.size:
|
||||
rows.append(
|
||||
f"| {name} | - | - | size mismatch "
|
||||
f"{img_a.width}x{img_a.height} vs {img_b.width}x{img_b.height}, "
|
||||
f"pixel diff skipped |"
|
||||
)
|
||||
continue
|
||||
|
||||
gray = max_channel_diff(img_a, img_b)
|
||||
mask = gray.point(lambda p: 255 if p > threshold else 0)
|
||||
changed = mask.histogram()[255]
|
||||
total = img_a.width * img_a.height
|
||||
percent = 100.0 * changed / total if total else 0.0
|
||||
note = ""
|
||||
if percent > COMPOSITE_PERCENT_THRESHOLD:
|
||||
composite_path = out_dir / f"{Path(name).stem}-diff.png"
|
||||
heat = ImageOps.colorize(
|
||||
ImageOps.autocontrast(gray), black=(0, 0, 0), white=(255, 32, 32)
|
||||
)
|
||||
gap = 8
|
||||
composite = Image.new(
|
||||
"RGB",
|
||||
(img_a.width * 3 + gap * 2, img_a.height),
|
||||
(24, 24, 28),
|
||||
)
|
||||
composite.paste(img_a, (0, 0))
|
||||
composite.paste(img_b, (img_a.width + gap, 0))
|
||||
composite.paste(heat, ((img_a.width + gap) * 2, 0))
|
||||
composite.save(composite_path)
|
||||
composites += 1
|
||||
note = f"composite: {composite_path.name}"
|
||||
rows.append(f"| {name} | {changed} | {percent:.4f}% | {note} |")
|
||||
|
||||
only_before = sorted(before_names - after_names)
|
||||
only_after = sorted(after_names - before_names)
|
||||
|
||||
report = out_dir / "diff-report.md"
|
||||
lines = [
|
||||
"# Visual diff report",
|
||||
"",
|
||||
f"- before: `{before_dir}`",
|
||||
f"- after: `{after_dir}`",
|
||||
f"- per-channel threshold: {threshold}",
|
||||
f"- composite emitted when changed pixels > {COMPOSITE_PERCENT_THRESHOLD}%",
|
||||
"",
|
||||
"| file | changed pixels | changed % | notes |",
|
||||
"| --- | ---: | ---: | --- |",
|
||||
*rows,
|
||||
]
|
||||
if only_before:
|
||||
lines += ["", "Only in before: " + ", ".join(only_before)]
|
||||
if only_after:
|
||||
lines += ["", "Only in after: " + ", ".join(only_after)]
|
||||
lines.append("")
|
||||
report.write_text("\n".join(lines))
|
||||
print(
|
||||
f"diff: {len(shared)} file(s) compared, {composites} composite(s) "
|
||||
f"-> {report}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Filmstrip/GIF/diff reporting for jcode-desktop frame captures."
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
p_strip = sub.add_parser("filmstrip", help="compose frames into one filmstrip PNG")
|
||||
p_strip.add_argument("--frames-dir", required=True)
|
||||
p_strip.add_argument("--out", required=True)
|
||||
p_strip.add_argument("--max-width", type=int, default=1600)
|
||||
p_strip.set_defaults(func=cmd_filmstrip)
|
||||
|
||||
p_gif = sub.add_parser("gif", help="assemble an animated GIF for one state")
|
||||
p_gif.add_argument("--frames-dir", required=True)
|
||||
p_gif.add_argument("--state", required=True)
|
||||
p_gif.add_argument("--out", required=True)
|
||||
p_gif.add_argument("--duration-ms", type=int, default=90)
|
||||
p_gif.set_defaults(func=cmd_gif)
|
||||
|
||||
p_diff = sub.add_parser("diff", help="pixel-diff PNGs shared by two directories")
|
||||
p_diff.add_argument("--before", required=True)
|
||||
p_diff.add_argument("--after", required=True)
|
||||
p_diff.add_argument("--out-dir", required=True)
|
||||
p_diff.add_argument("--threshold", type=int, default=8)
|
||||
p_diff.set_defaults(func=cmd_diff)
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
return args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+876
@@ -0,0 +1,876 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
cd "$repo_root"
|
||||
|
||||
# shellcheck source=scripts/remote_config.sh
|
||||
source "$repo_root/scripts/remote_config.sh"
|
||||
jcode_load_remote_config
|
||||
|
||||
log() {
|
||||
printf 'dev_cargo: %s\n' "$*" >&2
|
||||
}
|
||||
|
||||
selected_linker_mode="not-configured"
|
||||
selected_linker_desc=""
|
||||
sccache_status="disabled"
|
||||
selfdev_low_memory_status="disabled"
|
||||
feature_profile_status="default"
|
||||
build_jobs_status="cargo-default"
|
||||
git_meta_status="not-configured"
|
||||
|
||||
append_rustflags() {
|
||||
local new_flag="$1"
|
||||
if [[ -z "${CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS:-}" ]]; then
|
||||
export CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS="$new_flag"
|
||||
else
|
||||
export CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS="${CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS} ${new_flag}"
|
||||
fi
|
||||
}
|
||||
|
||||
selected_profile() {
|
||||
# Print the Cargo profile selected by the args, defaulting to "dev" (cargo's
|
||||
# default for build/check) when no --profile/--release is present.
|
||||
local expect_profile_name="false"
|
||||
local profile="dev"
|
||||
for arg in "$@"; do
|
||||
if [[ "$expect_profile_name" == "true" ]]; then
|
||||
profile="$arg"
|
||||
expect_profile_name="false"
|
||||
continue
|
||||
fi
|
||||
case "$arg" in
|
||||
--release|-r) profile="release" ;;
|
||||
--profile=*) profile="${arg#--profile=}" ;;
|
||||
--profile) expect_profile_name="true" ;;
|
||||
esac
|
||||
done
|
||||
printf '%s\n' "$profile"
|
||||
}
|
||||
|
||||
# Determine whether the effective build will use incremental compilation.
|
||||
# sccache cannot cache incremental units, so this gates whether sccache is
|
||||
# useful at all. CARGO_INCREMENTAL (if set) wins; otherwise infer from the
|
||||
# profile's incremental setting in Cargo.toml.
|
||||
build_is_incremental() {
|
||||
case "${CARGO_INCREMENTAL:-}" in
|
||||
0|false|no|off) return 1 ;;
|
||||
1|true|yes|on) return 0 ;;
|
||||
esac
|
||||
case "$(selected_profile "$@")" in
|
||||
# Non-incremental profiles (see Cargo.toml): sccache can produce hits here.
|
||||
release-lto) return 1 ;;
|
||||
# selfdev/dev/release/test and unknown profiles default to incremental.
|
||||
*) return 0 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
maybe_enable_sccache() {
|
||||
case "${SCCACHE_DISABLE:-}" in
|
||||
1|true|yes|on)
|
||||
if [[ -n "${RUSTC_WRAPPER:-}" && "${RUSTC_WRAPPER}" == *sccache* ]]; then
|
||||
unset RUSTC_WRAPPER
|
||||
fi
|
||||
sccache_status="disabled-by-env"
|
||||
log "sccache disabled by SCCACHE_DISABLE"
|
||||
return
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ -n "${RUSTC_WRAPPER:-}" ]]; then
|
||||
sccache_status="external:${RUSTC_WRAPPER}"
|
||||
log "keeping existing RUSTC_WRAPPER=${RUSTC_WRAPPER}"
|
||||
return
|
||||
fi
|
||||
|
||||
# sccache cannot cache incremental compilations, so for our default
|
||||
# incremental profiles it produces 0% hits while adding wrapper overhead and
|
||||
# misleading "enabled" status. Skip it for incremental builds unless the
|
||||
# caller explicitly forces it via JCODE_SCCACHE=1/on/force.
|
||||
local force_sccache="${JCODE_SCCACHE:-auto}"
|
||||
case "$force_sccache" in
|
||||
1|true|yes|on|force) force_sccache="1" ;;
|
||||
0|false|no|off|never)
|
||||
sccache_status="disabled-by-jcode-sccache"
|
||||
log "sccache disabled by JCODE_SCCACHE"
|
||||
return
|
||||
;;
|
||||
*) force_sccache="auto" ;;
|
||||
esac
|
||||
if [[ "$force_sccache" != "1" ]] && build_is_incremental "$@"; then
|
||||
sccache_status="skipped-incremental"
|
||||
log "sccache skipped for incremental build (it cannot cache incremental units; set JCODE_SCCACHE=on to force, or use a non-incremental profile)"
|
||||
return
|
||||
fi
|
||||
|
||||
if command -v sccache >/dev/null 2>&1; then
|
||||
sccache --start-server >/dev/null 2>&1 || true
|
||||
export RUSTC_WRAPPER=sccache
|
||||
sccache_status="enabled"
|
||||
log "using sccache"
|
||||
else
|
||||
sccache_status="not-found"
|
||||
log "sccache not found; using direct rustc"
|
||||
fi
|
||||
}
|
||||
|
||||
uses_selfdev_profile() {
|
||||
local expect_profile_name="false"
|
||||
for arg in "$@"; do
|
||||
if [[ "$expect_profile_name" == "true" ]]; then
|
||||
[[ "$arg" == "selfdev" ]] && return 0
|
||||
expect_profile_name="false"
|
||||
continue
|
||||
fi
|
||||
|
||||
case "$arg" in
|
||||
--profile=selfdev)
|
||||
return 0
|
||||
;;
|
||||
--profile)
|
||||
expect_profile_name="true"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
has_explicit_feature_args() {
|
||||
local expect_value="false"
|
||||
for arg in "$@"; do
|
||||
if [[ "$expect_value" == "true" ]]; then
|
||||
expect_value="false"
|
||||
continue
|
||||
fi
|
||||
case "$arg" in
|
||||
--)
|
||||
return 1
|
||||
;;
|
||||
--features|--no-default-features)
|
||||
return 0
|
||||
;;
|
||||
--features=*|--no-default-features=*)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
feature_args_from_profile() {
|
||||
local profile="$1"
|
||||
case "$profile" in
|
||||
""|default)
|
||||
return 0
|
||||
;;
|
||||
minimal|none)
|
||||
printf '%s\0' --no-default-features
|
||||
;;
|
||||
pdf)
|
||||
printf '%s\0' --no-default-features --features pdf
|
||||
;;
|
||||
embeddings)
|
||||
printf '%s\0' --no-default-features --features embeddings
|
||||
;;
|
||||
full)
|
||||
printf '%s\0' --features embeddings,pdf,bedrock
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
validate_feature_profile() {
|
||||
local profile="${JCODE_DEV_FEATURE_PROFILE:-default}"
|
||||
case "$profile" in
|
||||
""|default|minimal|none|pdf|embeddings|full)
|
||||
;;
|
||||
*)
|
||||
printf 'error: unsupported JCODE_DEV_FEATURE_PROFILE=%s (expected default|minimal|pdf|embeddings|full)\n' "$profile" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
build_cargo_argv() {
|
||||
local profile="${JCODE_DEV_FEATURE_PROFILE:-default}"
|
||||
if [[ "$profile" == "default" || -z "$profile" ]]; then
|
||||
feature_profile_status="default"
|
||||
printf '%s\0' "$@"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if has_explicit_feature_args "$@"; then
|
||||
feature_profile_status="ignored-explicit-cargo-args"
|
||||
printf '%s\0' "$@"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local -a feature_args=()
|
||||
while IFS= read -r -d '' arg; do
|
||||
feature_args+=("$arg")
|
||||
done < <(feature_args_from_profile "$profile")
|
||||
|
||||
feature_profile_status="$profile"
|
||||
local inserted="false"
|
||||
for arg in "$@"; do
|
||||
if [[ "$arg" == "--" && "$inserted" == "false" ]]; then
|
||||
printf '%s\0' "${feature_args[@]}"
|
||||
inserted="true"
|
||||
fi
|
||||
printf '%s\0' "$arg"
|
||||
done
|
||||
if [[ "$inserted" == "false" ]]; then
|
||||
printf '%s\0' "${feature_args[@]}"
|
||||
fi
|
||||
}
|
||||
|
||||
meminfo_kib() {
|
||||
local key="$1"
|
||||
awk -v key="$key" '$1 == key ":" { print $2; exit }' /proc/meminfo 2>/dev/null || true
|
||||
}
|
||||
|
||||
selfdev_low_memory_default_needed() {
|
||||
[[ "$(uname -s)" == "Linux" ]] || return 1
|
||||
[[ -r /proc/meminfo ]] || return 1
|
||||
command -v pgrep >/dev/null 2>&1 || return 1
|
||||
pgrep -x earlyoom >/dev/null 2>&1 || return 1
|
||||
|
||||
local mem_total_kib mem_available_kib swap_total_kib
|
||||
mem_total_kib=$(meminfo_kib MemTotal)
|
||||
mem_available_kib=$(meminfo_kib MemAvailable)
|
||||
swap_total_kib=$(meminfo_kib SwapTotal)
|
||||
[[ -n "$mem_total_kib" && -n "$mem_available_kib" && -n "$swap_total_kib" ]] || return 1
|
||||
|
||||
# On small no-swap machines, earlyoom can terminate the root jcode rustc
|
||||
# around 1-3 GiB RSS before the kernel OOM killer would report anything.
|
||||
# Keep this adaptive so larger workstations, and currently-idle smaller
|
||||
# workstations with enough headroom, retain the faster inherited selfdev
|
||||
# profile by default.
|
||||
(( swap_total_kib == 0 && mem_total_kib < 24576 * 1024 && mem_available_kib < 8192 * 1024 ))
|
||||
}
|
||||
|
||||
cpu_count() {
|
||||
local n
|
||||
n=$(nproc 2>/dev/null || getconf _NPROCESSORS_ONLN 2>/dev/null || echo 1)
|
||||
[[ "$n" =~ ^[0-9]+$ && "$n" -ge 1 ]] || n=1
|
||||
printf '%s\n' "$n"
|
||||
}
|
||||
|
||||
# Choose a Cargo job count from *currently available* memory so concurrent
|
||||
# builds (e.g. several self-dev agents on one machine) self-throttle instead of
|
||||
# all assuming the full core count and tripping earlyoom/OOM.
|
||||
#
|
||||
# After the monolith was split into the jcode-base/app-core/tui/cli crate DAG,
|
||||
# the largest single rustc unit is jcode-base, which has grown back to a
|
||||
# measured ~1.6 GiB RSS peak (selfdev profile, sampled VmRSS while building the
|
||||
# lib), down from the old 2.5-3 GiB monolith but above the original ~1.28 GiB
|
||||
# post-split figure. We budget ~1.75 GiB of currently-available memory per job:
|
||||
# a deliberate cushion above the measured peak (rustc's true VmHWM can exceed a
|
||||
# coarse sample, and jcode-base keeps growing) so a fresh build under load backs
|
||||
# off before earlyoom SIGTERMs it. Clamp into [1, cpus]. On an idle 15 GiB
|
||||
# machine this still uses ~7 of 8 cores; under memory pressure a fresh build
|
||||
# backs off further. An explicit CARGO_BUILD_JOBS / JCODE_BUILD_JOBS always
|
||||
# wins, and non-Linux hosts fall back to the cargo/.cargo default.
|
||||
select_build_jobs() {
|
||||
# Respect an explicit override from either env var.
|
||||
local override="${JCODE_BUILD_JOBS:-${CARGO_BUILD_JOBS:-}}"
|
||||
if [[ -n "$override" ]]; then
|
||||
if [[ "$override" =~ ^[0-9]+$ && "$override" -ge 1 ]]; then
|
||||
export CARGO_BUILD_JOBS="$override"
|
||||
build_jobs_status="override:$override"
|
||||
return
|
||||
fi
|
||||
# Invalid override: warn and fall through to adaptive sizing.
|
||||
log "ignoring invalid job override '$override' (expected a positive integer); using adaptive sizing"
|
||||
unset CARGO_BUILD_JOBS
|
||||
fi
|
||||
|
||||
# Adaptive sizing only on Linux where /proc/meminfo is available; elsewhere we
|
||||
# leave cargo to honor the .cargo/config.toml default.
|
||||
if [[ "$(uname -s)" != "Linux" || ! -r /proc/meminfo ]]; then
|
||||
build_jobs_status="cargo-default"
|
||||
return
|
||||
fi
|
||||
|
||||
local cpus mem_available_kib mib_per_job_default mib_per_job
|
||||
cpus=$(cpu_count)
|
||||
mem_available_kib=$(meminfo_kib MemAvailable)
|
||||
[[ -n "$mem_available_kib" && "$mem_available_kib" =~ ^[0-9]+$ ]] || mem_available_kib=0
|
||||
|
||||
# Per-job memory budget (MiB). Sized with a cushion above the largest measured
|
||||
# rustc unit (jcode-base, ~1.6 GiB RSS sampled) so an idle machine uses nearly
|
||||
# every core while a memory-pressured one backs off before earlyoom kills a
|
||||
# build. Tunable per host via JCODE_BUILD_MIB_PER_JOB.
|
||||
mib_per_job_default=1792
|
||||
mib_per_job="${JCODE_BUILD_MIB_PER_JOB:-$mib_per_job_default}"
|
||||
[[ "$mib_per_job" =~ ^[0-9]+$ && "$mib_per_job" -ge 256 ]] || mib_per_job="$mib_per_job_default"
|
||||
|
||||
local mem_available_mib jobs_by_mem jobs
|
||||
mem_available_mib=$(( mem_available_kib / 1024 ))
|
||||
jobs_by_mem=$(( mem_available_mib / mib_per_job ))
|
||||
(( jobs_by_mem < 1 )) && jobs_by_mem=1
|
||||
|
||||
# Final job count is the smaller of "fits in memory" and core count.
|
||||
jobs="$jobs_by_mem"
|
||||
(( jobs > cpus )) && jobs="$cpus"
|
||||
(( jobs < 1 )) && jobs=1
|
||||
|
||||
export CARGO_BUILD_JOBS="$jobs"
|
||||
build_jobs_status="adaptive:${jobs} (cpus=${cpus}, mem_avail=${mem_available_mib}MiB, budget=${mib_per_job}MiB/job)"
|
||||
if (( jobs < cpus )); then
|
||||
log "limiting cargo to ${jobs} job(s) under memory pressure (${mem_available_mib}MiB available, ~${mib_per_job}MiB/job); override with JCODE_BUILD_JOBS"
|
||||
fi
|
||||
}
|
||||
|
||||
# Keep `jcode-build-meta`'s embedded git metadata in lockstep with HEAD without
|
||||
# making it watch `.git` mtimes.
|
||||
#
|
||||
# `jcode-build-meta/build.rs` deliberately does NOT declare `.git/HEAD`/`.git/index`
|
||||
# as `rerun-if-changed` inputs, because their mtimes change on every `git add`,
|
||||
# `git status`, commit, and concurrent-agent git op -- which would force a
|
||||
# full-tree recompile (base -> app-core -> tui -> cli) on every incremental
|
||||
# build. The trade-off was that after a commit the binary kept embedding the
|
||||
# previous short hash, so the self-dev publish guard rejected it with
|
||||
# "binary was built from git hash X, but source state is Y" until someone
|
||||
# manually touched Cargo.toml.
|
||||
#
|
||||
# Instead, export the *value* of the current git hash/date. build.rs declares
|
||||
# `cargo:rerun-if-env-changed=JCODE_BUILD_GIT_HASH` (and _DATE), so cargo reruns
|
||||
# the build script ONLY when these values change -- i.e. exactly when HEAD moves
|
||||
# -- and never on a bare `git add`/`status` or repeated builds on the same
|
||||
# commit. This keeps the embedded hash correct after every commit while keeping
|
||||
# same-commit incremental builds fully incremental. We intentionally do NOT
|
||||
# export JCODE_BUILD_GIT_DIRTY here: the dirty flag flips on every edit and would
|
||||
# reintroduce per-build churn; the publish guard validates dirty builds via the
|
||||
# source fingerprint / mtime path instead of the embedded flag.
|
||||
export_git_build_metadata() {
|
||||
# Respect any value the caller already set (e.g. release/CI pipelines).
|
||||
if [[ -n "${JCODE_BUILD_GIT_HASH:-}" ]]; then
|
||||
git_meta_status="external:${JCODE_BUILD_GIT_HASH}"
|
||||
return
|
||||
fi
|
||||
if ! command -v git >/dev/null 2>&1; then
|
||||
git_meta_status="git-not-found"
|
||||
return
|
||||
fi
|
||||
local hash date
|
||||
hash="$(git -C "$repo_root" rev-parse --short HEAD 2>/dev/null || true)"
|
||||
if [[ -z "$hash" ]]; then
|
||||
git_meta_status="no-head"
|
||||
return
|
||||
fi
|
||||
export JCODE_BUILD_GIT_HASH="$hash"
|
||||
date="$(git -C "$repo_root" log -1 --format=%ci 2>/dev/null || true)"
|
||||
if [[ -n "$date" ]]; then
|
||||
export JCODE_BUILD_GIT_DATE="$date"
|
||||
fi
|
||||
git_meta_status="hash=${hash}"
|
||||
}
|
||||
|
||||
maybe_configure_low_memory_selfdev() {
|
||||
if ! uses_selfdev_profile "$@"; then
|
||||
selfdev_low_memory_status="not-selfdev"
|
||||
return
|
||||
fi
|
||||
|
||||
local mode="${JCODE_SELFDEV_LOW_MEMORY:-auto}"
|
||||
case "$mode" in
|
||||
1|true|yes|on|force)
|
||||
;;
|
||||
0|false|no|off|never)
|
||||
selfdev_low_memory_status="disabled-by-env"
|
||||
return
|
||||
;;
|
||||
auto|"")
|
||||
if ! selfdev_low_memory_default_needed; then
|
||||
selfdev_low_memory_status="auto-not-needed"
|
||||
return
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
printf 'error: unsupported JCODE_SELFDEV_LOW_MEMORY=%s (expected auto|on|off)\n' "$mode" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
export CARGO_INCREMENTAL="${CARGO_INCREMENTAL:-1}"
|
||||
export CARGO_PROFILE_SELFDEV_INCREMENTAL="${CARGO_PROFILE_SELFDEV_INCREMENTAL:-true}"
|
||||
export CARGO_PROFILE_SELFDEV_CODEGEN_UNITS="${CARGO_PROFILE_SELFDEV_CODEGEN_UNITS:-256}"
|
||||
# The low-memory profile deliberately keeps incremental compilation enabled
|
||||
# to avoid repeatedly rebuilding large root crates. sccache rejects Cargo
|
||||
# incremental builds, so default to direct rustc unless the caller opts out
|
||||
# of low-memory mode entirely.
|
||||
export SCCACHE_DISABLE="${SCCACHE_DISABLE:-1}"
|
||||
selfdev_low_memory_status="enabled:incremental=${CARGO_PROFILE_SELFDEV_INCREMENTAL},codegen-units=${CARGO_PROFILE_SELFDEV_CODEGEN_UNITS}"
|
||||
log "using low-memory selfdev overrides (${selfdev_low_memory_status#enabled:})"
|
||||
}
|
||||
|
||||
# Enable rustc's parallel front-end (`-Zthreads`) for iterative dev/selfdev/test
|
||||
# builds. The jcode monoliths (jcode-base/app-core/tui) are ~80% single-threaded
|
||||
# front-end (type-check + borrow-check + monomorphization collection); at
|
||||
# opt-level 0 that front-end, not codegen, dominates wall time. The parallel
|
||||
# front-end is a nightly-only `-Z` flag, so this is gated on a nightly toolchain
|
||||
# being available and only applies to the unoptimized iteration profiles.
|
||||
#
|
||||
# Measured on this repo (Intel Ultra 7, 8 logical cores, selfdev profile):
|
||||
# jcode-base clean recompile 25.3s -> 12.7s (-Zthreads=4)
|
||||
# base-edit full-chain rebuild ~16s -> ~10s
|
||||
# Cranelift was tried too and was *slower* here (16.5s) because the bottleneck is
|
||||
# the front-end, not codegen, so we deliberately do not enable it.
|
||||
#
|
||||
# Controls:
|
||||
# JCODE_PARALLEL_FRONTEND=auto|0|1 (default auto)
|
||||
# JCODE_FRONTEND_THREADS=<n> (default 4; diminishing returns past 4)
|
||||
# JCODE_DEV_TOOLCHAIN=<name> (default: nightly when present)
|
||||
parallel_frontend_status="disabled"
|
||||
parallel_frontend_toolchain=""
|
||||
|
||||
dev_nightly_toolchain() {
|
||||
# Prefer an explicit override, else a `+toolchain` already on the argv, else
|
||||
# the first installed nightly toolchain.
|
||||
if [[ -n "${JCODE_DEV_TOOLCHAIN:-}" ]]; then
|
||||
printf '%s\n' "$JCODE_DEV_TOOLCHAIN"
|
||||
return 0
|
||||
fi
|
||||
local tc
|
||||
tc=$(rustup toolchain list 2>/dev/null | awk '/^nightly/ {print $1; exit}')
|
||||
tc=${tc%% *}
|
||||
[[ -n "$tc" ]] && printf '%s\n' "$tc"
|
||||
return 0
|
||||
}
|
||||
|
||||
configure_parallel_frontend() {
|
||||
local requested="${JCODE_PARALLEL_FRONTEND:-auto}"
|
||||
local forced="false"
|
||||
case "$requested" in
|
||||
0|false|no|off)
|
||||
parallel_frontend_status="disabled-by-env"
|
||||
return 0
|
||||
;;
|
||||
1|true|yes|on|force) forced="true" ;;
|
||||
auto) ;;
|
||||
*)
|
||||
parallel_frontend_status="disabled-bad-env:${requested}"
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
|
||||
# Only worth it for the unoptimized iteration profiles where the front-end is
|
||||
# the bottleneck; release/release-lto keep their own (codegen-bound) path.
|
||||
#
|
||||
# By default (`auto`) we restrict to the `selfdev` profile: it builds into the
|
||||
# isolated `target/selfdev` dir that only this script + `selfdev build` use, so
|
||||
# adding `-Zthreads` to RUSTFLAGS (which changes cargo's unit fingerprint)
|
||||
# cannot thrash rust-analyzer's `target/debug` cache. Forcing the flag on
|
||||
# (`JCODE_PARALLEL_FRONTEND=1`) opts dev/test in too, accepting that potential
|
||||
# cache contention.
|
||||
local profile
|
||||
profile=$(selected_profile "$@")
|
||||
case "$profile" in
|
||||
selfdev) ;;
|
||||
dev|test)
|
||||
if [[ "$forced" != "true" ]]; then
|
||||
parallel_frontend_status="skipped-profile-shared-target:${profile}"
|
||||
return 0
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
parallel_frontend_status="skipped-profile:${profile}"
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
|
||||
# If the caller already pinned a toolchain via `cargo +foo`, don't override it.
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
+*)
|
||||
parallel_frontend_status="skipped-explicit-toolchain:${arg}"
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
command -v rustup >/dev/null 2>&1 || {
|
||||
parallel_frontend_status="skipped-no-rustup"
|
||||
return 0
|
||||
}
|
||||
local tc
|
||||
tc=$(dev_nightly_toolchain)
|
||||
if [[ -z "$tc" ]]; then
|
||||
parallel_frontend_status="skipped-no-nightly"
|
||||
return 0
|
||||
fi
|
||||
# Confirm the toolchain actually resolves (installed, not just configured).
|
||||
if ! rustup run "$tc" rustc --version >/dev/null 2>&1; then
|
||||
parallel_frontend_status="skipped-nightly-unavailable:${tc}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local threads="${JCODE_FRONTEND_THREADS:-4}"
|
||||
[[ "$threads" =~ ^[0-9]+$ && "$threads" -ge 1 ]] || threads=4
|
||||
|
||||
parallel_frontend_toolchain="$tc"
|
||||
export RUSTUP_TOOLCHAIN="$tc"
|
||||
append_rustflags "-Zthreads=${threads}"
|
||||
parallel_frontend_status="enabled:${tc}:threads=${threads}"
|
||||
log "using parallel rustc front-end (${tc}, -Zthreads=${threads})"
|
||||
}
|
||||
|
||||
configure_linux_linker() {
|
||||
local requested_mode="${JCODE_FAST_LINKER:-auto}"
|
||||
local mode="$requested_mode"
|
||||
|
||||
case "$mode" in
|
||||
auto)
|
||||
# Prefer mold over lld: on this repo's large statically-linked binary
|
||||
# (~300 MB .text), mold links the jcode bin in ~2.0s vs lld's ~2.9s
|
||||
# (measured, warm, selfdev profile). The bin relinks on every build, so
|
||||
# that ~0.8s is a per-build win. Both need clang as the linker driver.
|
||||
if command -v mold >/dev/null 2>&1 && command -v clang >/dev/null 2>&1; then
|
||||
mode="mold"
|
||||
elif command -v ld.lld >/dev/null 2>&1 && command -v clang >/dev/null 2>&1; then
|
||||
mode="lld"
|
||||
else
|
||||
mode="system"
|
||||
fi
|
||||
;;
|
||||
lld|mold|system)
|
||||
;;
|
||||
*)
|
||||
printf 'error: unsupported JCODE_FAST_LINKER=%s (expected auto|lld|mold|system)\n' "$mode" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
selected_linker_mode="$mode"
|
||||
export CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER="${CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER:-clang}"
|
||||
|
||||
case "$mode" in
|
||||
lld)
|
||||
append_rustflags "-C link-arg=-fuse-ld=lld"
|
||||
selected_linker_desc="clang + lld"
|
||||
log "using clang + lld"
|
||||
;;
|
||||
mold)
|
||||
append_rustflags "-C link-arg=-fuse-ld=mold"
|
||||
selected_linker_desc="clang + mold"
|
||||
log "using clang + mold"
|
||||
;;
|
||||
system)
|
||||
selected_linker_desc="system linker settings"
|
||||
if [[ "$requested_mode" == "auto" ]]; then
|
||||
log "no supported fast linker detected; using system linker settings"
|
||||
else
|
||||
log "using system linker settings"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
print_setup() {
|
||||
if [[ -n "${JCODE_DEV_FEATURE_PROFILE:-}" && "${JCODE_DEV_FEATURE_PROFILE}" != "default" ]]; then
|
||||
feature_profile_status="${JCODE_DEV_FEATURE_PROFILE}"
|
||||
fi
|
||||
cat <<EOF
|
||||
repo_root=$repo_root
|
||||
os=$(uname -s)
|
||||
arch=$(uname -m)
|
||||
sccache_status=$sccache_status
|
||||
selfdev_low_memory_status=$selfdev_low_memory_status
|
||||
parallel_frontend_status=$parallel_frontend_status
|
||||
build_jobs_status=$build_jobs_status
|
||||
cargo_build_jobs=${CARGO_BUILD_JOBS:-<unset>}
|
||||
feature_profile_status=$feature_profile_status
|
||||
git_meta_status=$git_meta_status
|
||||
build_git_hash=${JCODE_BUILD_GIT_HASH:-<unset>}
|
||||
rustc_wrapper=${RUSTC_WRAPPER:-<unset>}
|
||||
linker_mode=$selected_linker_mode
|
||||
linker_desc=${selected_linker_desc:-<none>}
|
||||
linker=${CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER:-<unset>}
|
||||
rustflags=${CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS:-<unset>}
|
||||
EOF
|
||||
}
|
||||
|
||||
remote_connect_timeout() {
|
||||
local value="${JCODE_REMOTE_CONNECT_TIMEOUT:-5}"
|
||||
if [[ ! "$value" =~ ^[0-9]+$ || "$value" -lt 1 ]]; then
|
||||
value=5
|
||||
fi
|
||||
printf '%s\n' "$value"
|
||||
}
|
||||
|
||||
remote_tcp_timeout() {
|
||||
# Bounded probe used the first time we contact a host (or after the recovery
|
||||
# window expires) so an unreachable host fails fast instead of waiting for
|
||||
# the full SSH ConnectTimeout. Accepts fractional seconds (GNU timeout).
|
||||
local value="${JCODE_REMOTE_TCP_TIMEOUT:-1}"
|
||||
if [[ ! "$value" =~ ^[0-9]+([.][0-9]+)?$ ]]; then
|
||||
value=1
|
||||
fi
|
||||
printf '%s\n' "$value"
|
||||
}
|
||||
|
||||
remote_recovery_tcp_timeout() {
|
||||
# Shorter probe used while a host was recently seen down. An up host always
|
||||
# answers in a few ms, so this still detects recovery on the next build while
|
||||
# keeping per-build cost low during an outage.
|
||||
local value="${JCODE_REMOTE_RECOVERY_TCP_TIMEOUT:-0.3}"
|
||||
if [[ ! "$value" =~ ^[0-9]+([.][0-9]+)?$ ]]; then
|
||||
value=0.3
|
||||
fi
|
||||
printf '%s\n' "$value"
|
||||
}
|
||||
|
||||
remote_down_cache_ttl() {
|
||||
# How long (seconds) a recent failure keeps using the shorter recovery probe
|
||||
# timeout. Recovery is still detected on the very next build; this only
|
||||
# controls how long downtime builds stay cheap before reverting to the full
|
||||
# probe timeout. Set to 0 to always use the full timeout.
|
||||
local value="${JCODE_REMOTE_DOWN_TTL:-300}"
|
||||
if [[ ! "$value" =~ ^[0-9]+$ ]]; then
|
||||
value=300
|
||||
fi
|
||||
printf '%s\n' "$value"
|
||||
}
|
||||
|
||||
remote_down_cache_path() {
|
||||
local remote="${JCODE_REMOTE_HOST:-}"
|
||||
local key
|
||||
if command -v cksum >/dev/null 2>&1; then
|
||||
key="$(printf '%s' "$remote" | cksum | awk '{print $1}')"
|
||||
else
|
||||
key="${remote//[^A-Za-z0-9_-]/_}"
|
||||
fi
|
||||
printf '%s/jcode-remote-down-%s\n' "${TMPDIR:-/tmp}" "$key"
|
||||
}
|
||||
|
||||
remote_down_cache_fresh() {
|
||||
local ttl
|
||||
ttl="$(remote_down_cache_ttl)"
|
||||
[[ "$ttl" -gt 0 ]] || return 1
|
||||
local cache
|
||||
cache="$(remote_down_cache_path)"
|
||||
[[ -f "$cache" ]] || return 1
|
||||
local recorded now age
|
||||
recorded="$(cat "$cache" 2>/dev/null || printf '0')"
|
||||
[[ "$recorded" =~ ^[0-9]+$ ]] || return 1
|
||||
now="$(date +%s)"
|
||||
age=$((now - recorded))
|
||||
(( age >= 0 && age < ttl ))
|
||||
}
|
||||
|
||||
record_remote_down() {
|
||||
local ttl
|
||||
ttl="$(remote_down_cache_ttl)"
|
||||
[[ "$ttl" -gt 0 ]] || return 0
|
||||
local cache
|
||||
cache="$(remote_down_cache_path)"
|
||||
date +%s >"$cache" 2>/dev/null || true
|
||||
}
|
||||
|
||||
clear_remote_down() {
|
||||
local cache
|
||||
cache="$(remote_down_cache_path)"
|
||||
rm -f "$cache" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Resolve the effective hostname/port for the remote alias and report whether
|
||||
# the connection goes through a ProxyJump/ProxyCommand (where a direct TCP
|
||||
# probe to the final hostname would be misleading).
|
||||
remote_resolve_endpoint() {
|
||||
local ssh_bin="$1" remote="$2"
|
||||
local hostname="$remote" port=22 uses_proxy="false"
|
||||
local config line key value
|
||||
if config="$("$ssh_bin" -G "$remote" 2>/dev/null)"; then
|
||||
while IFS=' ' read -r key value; do
|
||||
case "$key" in
|
||||
hostname) [[ -n "$value" ]] && hostname="$value" ;;
|
||||
port) [[ "$value" =~ ^[0-9]+$ ]] && port="$value" ;;
|
||||
proxyjump|proxycommand)
|
||||
[[ -n "$value" && "$value" != "none" ]] && uses_proxy="true"
|
||||
;;
|
||||
esac
|
||||
done <<<"$config"
|
||||
fi
|
||||
printf '%s\t%s\t%s\n' "$hostname" "$port" "$uses_proxy"
|
||||
}
|
||||
|
||||
# Fast TCP reachability check using bash /dev/tcp with a hard timeout.
|
||||
remote_tcp_reachable() {
|
||||
local hostname="$1" port="$2"
|
||||
local tcp_timeout="${3:-}"
|
||||
if [[ -z "$tcp_timeout" ]]; then
|
||||
tcp_timeout="$(remote_tcp_timeout)"
|
||||
fi
|
||||
timeout "$tcp_timeout" bash -c "exec 3<>/dev/tcp/$hostname/$port" 2>/dev/null
|
||||
}
|
||||
|
||||
remote_cargo_preflight() {
|
||||
local remote="${JCODE_REMOTE_HOST:-}"
|
||||
if [[ -z "$remote" ]]; then
|
||||
log "remote cargo requested but JCODE_REMOTE_HOST is not configured"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local ssh_bin="${JCODE_REMOTE_SSH_BIN:-ssh}"
|
||||
if ! command -v "$ssh_bin" >/dev/null 2>&1; then
|
||||
log "remote cargo requested but ssh binary is unavailable: $ssh_bin"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Choose probe timeout: while a host was recently seen down, use a short
|
||||
# recovery timeout so downtime builds stay cheap. An up host answers in a few
|
||||
# ms regardless, so recovery is still detected on the very next build.
|
||||
local tcp_timeout
|
||||
if remote_down_cache_fresh; then
|
||||
tcp_timeout="$(remote_recovery_tcp_timeout)"
|
||||
else
|
||||
tcp_timeout="$(remote_tcp_timeout)"
|
||||
fi
|
||||
|
||||
# Fast TCP pre-probe to fail fast when the host is offline, unless the
|
||||
# connection is proxied (where a direct probe would be wrong) or explicitly
|
||||
# disabled via JCODE_REMOTE_TCP_PROBE=0.
|
||||
local tcp_probe="${JCODE_REMOTE_TCP_PROBE:-1}"
|
||||
case "$tcp_probe" in
|
||||
0|false|no|off) tcp_probe="0" ;;
|
||||
*) tcp_probe="1" ;;
|
||||
esac
|
||||
if [[ "$tcp_probe" == "1" ]]; then
|
||||
local endpoint hostname port uses_proxy
|
||||
endpoint="$(remote_resolve_endpoint "$ssh_bin" "$remote")"
|
||||
IFS=$'\t' read -r hostname port uses_proxy <<<"$endpoint"
|
||||
if [[ "$uses_proxy" != "true" ]]; then
|
||||
if ! remote_tcp_reachable "$hostname" "$port" "$tcp_timeout"; then
|
||||
log "remote host $remote ($hostname:$port) unreachable within ${tcp_timeout}s TCP probe; using local cargo"
|
||||
record_remote_down
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
local connect_timeout
|
||||
connect_timeout="$(remote_connect_timeout)"
|
||||
local server_alive_interval="${JCODE_REMOTE_SERVER_ALIVE_INTERVAL:-10}"
|
||||
local server_alive_count="${JCODE_REMOTE_SERVER_ALIVE_COUNT_MAX:-1}"
|
||||
local output
|
||||
if ! output=$("$ssh_bin" \
|
||||
-o BatchMode=yes \
|
||||
-o ConnectTimeout="$connect_timeout" \
|
||||
-o ServerAliveInterval="$server_alive_interval" \
|
||||
-o ServerAliveCountMax="$server_alive_count" \
|
||||
"$remote" "printf 'jcode-remote-ok\\n'" 2>&1); then
|
||||
log "remote cargo preflight failed for $remote after ~${connect_timeout}s: $output"
|
||||
record_remote_down
|
||||
return 1
|
||||
fi
|
||||
clear_remote_down
|
||||
return 0
|
||||
}
|
||||
|
||||
remote_cargo_fallback_mode() {
|
||||
local mode="${JCODE_REMOTE_CARGO_FALLBACK:-local}"
|
||||
case "$mode" in
|
||||
local|1|true|yes|on)
|
||||
printf 'local\n'
|
||||
;;
|
||||
error|fail|0|false|no|off|never)
|
||||
printf 'error\n'
|
||||
;;
|
||||
*)
|
||||
printf 'error: unsupported JCODE_REMOTE_CARGO_FALLBACK=%s (expected local|error)\n' "$mode" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
cargo_test_has_explicit_filter() {
|
||||
[[ "${1:-}" == "test" ]] || return 1
|
||||
|
||||
local expect_value=""
|
||||
shift
|
||||
for arg in "$@"; do
|
||||
if [[ -n "$expect_value" ]]; then
|
||||
expect_value=""
|
||||
continue
|
||||
fi
|
||||
|
||||
case "$arg" in
|
||||
--)
|
||||
return 1
|
||||
;;
|
||||
--bench|--bin|--example|--features|--manifest-path|--message-format|--package|-p|--profile|--target|--target-dir)
|
||||
expect_value="$arg"
|
||||
;;
|
||||
--bench=*|--bin=*|--example=*|--features=*|--manifest-path=*|--message-format=*|--package=*|-p=*|--profile=*|--target=*|--target-dir=*)
|
||||
;;
|
||||
--*)
|
||||
;;
|
||||
-*)
|
||||
;;
|
||||
*)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
run_local_cargo() {
|
||||
if cargo_test_has_explicit_filter "${cargo_argv[@]}" && [[ "${JCODE_DEV_CARGO_ALLOW_ZERO_TESTS:-0}" != "1" ]]; then
|
||||
local output_file
|
||||
output_file=$(mktemp "${TMPDIR:-/tmp}/jcode-dev-cargo.XXXXXX")
|
||||
local status=0
|
||||
cargo "${cargo_argv[@]}" 2>&1 | tee "$output_file" || status=${PIPESTATUS[0]}
|
||||
if [[ "$status" -eq 0 ]] \
|
||||
&& grep -qE '^running 0 tests$' "$output_file" \
|
||||
&& ! grep -qE '^running [1-9][0-9]* tests$' "$output_file"; then
|
||||
printf 'dev_cargo: explicit cargo test filter matched zero tests; check the test path/name or set JCODE_DEV_CARGO_ALLOW_ZERO_TESTS=1 to allow this intentionally\n' >&2
|
||||
rm -f "$output_file"
|
||||
return 97
|
||||
fi
|
||||
rm -f "$output_file"
|
||||
return "$status"
|
||||
fi
|
||||
|
||||
exec cargo "${cargo_argv[@]}"
|
||||
}
|
||||
|
||||
validate_feature_profile
|
||||
export_git_build_metadata
|
||||
maybe_configure_low_memory_selfdev "$@"
|
||||
maybe_enable_sccache "$@"
|
||||
configure_parallel_frontend "$@"
|
||||
select_build_jobs
|
||||
|
||||
if [[ "$(uname -s)" == "Linux" ]] && [[ "$(uname -m)" == "x86_64" ]]; then
|
||||
configure_linux_linker
|
||||
fi
|
||||
|
||||
if [[ "${1:-}" == "--print-setup" ]]; then
|
||||
print_setup
|
||||
exit 0
|
||||
fi
|
||||
|
||||
cargo_argv=()
|
||||
while IFS= read -r -d '' arg; do
|
||||
cargo_argv+=("$arg")
|
||||
done < <(build_cargo_argv "$@")
|
||||
|
||||
if [[ "${JCODE_REMOTE_CARGO:-0}" == "1" ]]; then
|
||||
if remote_cargo_preflight; then
|
||||
log "using remote cargo via scripts/remote_build.sh"
|
||||
exec "$repo_root/scripts/remote_build.sh" "${cargo_argv[@]}"
|
||||
fi
|
||||
if [[ "$(remote_cargo_fallback_mode)" == "local" ]]; then
|
||||
log "remote cargo unavailable; falling back to local cargo (set JCODE_REMOTE_CARGO_FALLBACK=error to fail instead)"
|
||||
else
|
||||
log "remote cargo unavailable and fallback disabled"
|
||||
exit 75
|
||||
fi
|
||||
fi
|
||||
|
||||
run_local_cargo
|
||||
Executable
+159
@@ -0,0 +1,159 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Generate a human-readable release notes body for a jcode release (issue #435).
|
||||
#
|
||||
# Usage:
|
||||
# scripts/generate_release_notes.sh <tag> [previous-tag]
|
||||
#
|
||||
# Behavior:
|
||||
# 1. If changelog/v<version>.json exists (schema in changelog/README.md),
|
||||
# render it into Highlights / Improvements / Fixes markdown sections.
|
||||
# 2. Otherwise, group commit subjects between the previous tag and <tag>
|
||||
# by conventional-commit prefix (feat/fix/perf/docs/other).
|
||||
# Always appends the GitHub compare link at the bottom.
|
||||
|
||||
TAG="${1:?Usage: scripts/generate_release_notes.sh <tag> [previous-tag]}"
|
||||
PREV_TAG="${2:-}"
|
||||
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
|
||||
VERSION_NUM="${TAG#v}"
|
||||
CHANGELOG_FILE="changelog/v${VERSION_NUM}.json"
|
||||
|
||||
# Determine the owner/repo slug for the compare link.
|
||||
repo_slug() {
|
||||
if [[ -n "${GITHUB_REPOSITORY:-}" ]]; then
|
||||
echo "$GITHUB_REPOSITORY"
|
||||
return
|
||||
fi
|
||||
local url
|
||||
url="$(git remote get-url origin 2>/dev/null || true)"
|
||||
if [[ "$url" =~ github\.com[:/]+([^/]+/[^/ ]+) ]]; then
|
||||
echo "${BASH_REMATCH[1]%.git}"
|
||||
return
|
||||
fi
|
||||
echo "1jehuang/jcode"
|
||||
}
|
||||
|
||||
REPO="$(repo_slug)"
|
||||
|
||||
# Determine the previous tag if not supplied: nearest ancestor tag first,
|
||||
# falling back to the next entry in version-sorted tag order.
|
||||
if [[ -z "$PREV_TAG" ]]; then
|
||||
if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
|
||||
PREV_TAG="$(git describe --tags --abbrev=0 "$TAG^" 2>/dev/null || true)"
|
||||
fi
|
||||
if [[ -z "$PREV_TAG" ]]; then
|
||||
PREV_TAG="$(git tag -l 'v*' --sort=-v:refname \
|
||||
| grep -A1 -Fx "$TAG" | tail -n +2 | head -1 || true)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Render changelog/v<version>.json into markdown sections.
|
||||
render_changelog_json() {
|
||||
python3 - "$CHANGELOG_FILE" << 'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
with open(sys.argv[1], encoding="utf-8") as f:
|
||||
entry = json.load(f)
|
||||
|
||||
out = []
|
||||
title = entry.get("title")
|
||||
if title:
|
||||
out.append(f"**{title}**")
|
||||
out.append("")
|
||||
|
||||
for key, heading in (
|
||||
("highlights", "Highlights"),
|
||||
("improvements", "Improvements"),
|
||||
("fixes", "Fixes"),
|
||||
):
|
||||
items = entry.get(key) or []
|
||||
if not items:
|
||||
continue
|
||||
out.append(f"### {heading}")
|
||||
out.append("")
|
||||
out.extend(f"- {item}" for item in items)
|
||||
out.append("")
|
||||
|
||||
print("\n".join(out).rstrip())
|
||||
PY
|
||||
}
|
||||
|
||||
# Fallback: group commit subjects since the previous tag by
|
||||
# conventional-commit prefix.
|
||||
render_commit_fallback() {
|
||||
local range
|
||||
local end_ref="$TAG"
|
||||
# Support pre-tag dry runs where the tag does not exist yet.
|
||||
if ! git rev-parse -q --verify "$TAG^{commit}" >/dev/null; then
|
||||
end_ref="HEAD"
|
||||
fi
|
||||
if [[ -n "$PREV_TAG" ]]; then
|
||||
range="$PREV_TAG..$end_ref"
|
||||
else
|
||||
range="$end_ref"
|
||||
fi
|
||||
|
||||
python3 - "$range" << 'PY'
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
subjects = subprocess.run(
|
||||
["git", "log", "--no-merges", "--pretty=format:%s", sys.argv[1]],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout.splitlines()
|
||||
|
||||
groups = {"feat": [], "fix": [], "perf": [], "docs": [], "other": []}
|
||||
pattern = re.compile(r"^(feat|fix|perf|docs)(\([^)]*\))?!?:\s*(.+)$", re.IGNORECASE)
|
||||
for subject in subjects:
|
||||
subject = subject.strip()
|
||||
if not subject:
|
||||
continue
|
||||
match = pattern.match(subject)
|
||||
if match:
|
||||
groups[match.group(1).lower()].append(match.group(3).strip())
|
||||
else:
|
||||
groups["other"].append(subject)
|
||||
|
||||
out = []
|
||||
for key, heading in (
|
||||
("feat", "Features"),
|
||||
("fix", "Fixes"),
|
||||
("perf", "Performance"),
|
||||
("docs", "Documentation"),
|
||||
("other", "Other changes"),
|
||||
):
|
||||
items = groups[key]
|
||||
if not items:
|
||||
continue
|
||||
out.append(f"### {heading}")
|
||||
out.append("")
|
||||
out.extend(f"- {item}" for item in items)
|
||||
out.append("")
|
||||
|
||||
print("\n".join(out).rstrip())
|
||||
PY
|
||||
}
|
||||
|
||||
if [[ -f "$CHANGELOG_FILE" ]]; then
|
||||
BODY="$(render_changelog_json)"
|
||||
else
|
||||
BODY="$(render_commit_fallback)"
|
||||
fi
|
||||
|
||||
if [[ -n "$BODY" ]]; then
|
||||
printf '%s\n\n' "$BODY"
|
||||
fi
|
||||
|
||||
if [[ -n "$PREV_TAG" ]]; then
|
||||
printf '**Full changelog**: https://github.com/%s/compare/%s...%s\n' \
|
||||
"$REPO" "$PREV_TAG" "$TAG"
|
||||
else
|
||||
printf '**Full changelog**: https://github.com/%s/commits/%s\n' "$REPO" "$TAG"
|
||||
fi
|
||||
@@ -0,0 +1,601 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Install jcode on Windows.
|
||||
.DESCRIPTION
|
||||
Downloads the latest jcode release and installs it to %LOCALAPPDATA%\jcode\bin.
|
||||
|
||||
One-liner install:
|
||||
irm https://raw.githubusercontent.com/1jehuang/jcode/master/scripts/install.ps1 | iex
|
||||
|
||||
Or download and run (allows parameters):
|
||||
& ([scriptblock]::Create((irm https://raw.githubusercontent.com/1jehuang/jcode/master/scripts/install.ps1)))
|
||||
.PARAMETER InstallDir
|
||||
Override the installation directory (default: $env:LOCALAPPDATA\jcode\bin)
|
||||
.PARAMETER Version
|
||||
Override the version tag to install. Required when using a local artifact path.
|
||||
.PARAMETER ArtifactExePath
|
||||
Use a local jcode.exe artifact instead of downloading from GitHub.
|
||||
.PARAMETER ArtifactTgzPath
|
||||
Use a local jcode .tar.gz artifact instead of downloading from GitHub.
|
||||
.PARAMETER SkipAlacrittySetup
|
||||
Skip Alacritty install/setup helpers.
|
||||
.PARAMETER SkipHotkeySetup
|
||||
Skip Alt+; hotkey setup helpers.
|
||||
#>
|
||||
param(
|
||||
[string]$InstallDir,
|
||||
[string]$Version,
|
||||
[string]$ArtifactExePath,
|
||||
[string]$ArtifactTgzPath,
|
||||
[switch]$SkipAlacrittySetup,
|
||||
[switch]$SkipHotkeySetup
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
if ($PSVersionTable.PSVersion.Major -lt 5) {
|
||||
Write-Host "error: PowerShell 5.1 or later is required" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
$Repo = "1jehuang/jcode"
|
||||
|
||||
if (-not $InstallDir) {
|
||||
$InstallDir = Join-Path $env:LOCALAPPDATA "jcode\bin"
|
||||
}
|
||||
|
||||
$JcodeHome = if ($env:JCODE_HOME) {
|
||||
$env:JCODE_HOME
|
||||
} elseif ($env:USERPROFILE) {
|
||||
Join-Path $env:USERPROFILE ".jcode"
|
||||
} else {
|
||||
Join-Path ([Environment]::GetFolderPath("UserProfile")) ".jcode"
|
||||
}
|
||||
|
||||
$HotkeyDir = Join-Path $JcodeHome "hotkey"
|
||||
$SetupHintsPath = Join-Path $JcodeHome "setup_hints.json"
|
||||
|
||||
function Write-Info($msg) { Write-Host $msg -ForegroundColor Blue }
|
||||
function Write-Err($msg) { Write-Host "error: $msg" -ForegroundColor Red; exit 1 }
|
||||
function Write-Warn($msg) { Write-Host "warning: $msg" -ForegroundColor Yellow }
|
||||
|
||||
function Resolve-OptionalPath([string]$PathValue) {
|
||||
if (-not $PathValue) {
|
||||
return $null
|
||||
}
|
||||
|
||||
try {
|
||||
return (Resolve-Path -LiteralPath $PathValue -ErrorAction Stop).Path
|
||||
} catch {
|
||||
Write-Err "Provided path does not exist: $PathValue"
|
||||
}
|
||||
}
|
||||
|
||||
function Stop-ProcessTree([int]$ProcessId) {
|
||||
try {
|
||||
Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.ParentProcessId -eq $ProcessId } |
|
||||
ForEach-Object { Stop-ProcessTree -ProcessId $_.ProcessId }
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
Stop-Process -Id $ProcessId -Force -ErrorAction SilentlyContinue
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function Invoke-ProcessWithTimeout {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$FilePath,
|
||||
[string[]]$ArgumentList = @(),
|
||||
[Parameter(Mandatory = $true)][int]$TimeoutSeconds,
|
||||
[Parameter(Mandatory = $true)][string]$FriendlyName,
|
||||
[switch]$CaptureOutput
|
||||
)
|
||||
|
||||
$startParams = @{
|
||||
FilePath = $FilePath
|
||||
ArgumentList = $ArgumentList
|
||||
PassThru = $true
|
||||
NoNewWindow = $true
|
||||
}
|
||||
|
||||
$stdoutPath = $null
|
||||
$stderrPath = $null
|
||||
if ($CaptureOutput) {
|
||||
$stdoutPath = Join-Path $env:TEMP ("jcode-{0}-{1}-stdout.log" -f $FriendlyName, [guid]::NewGuid().ToString('N'))
|
||||
$stderrPath = Join-Path $env:TEMP ("jcode-{0}-{1}-stderr.log" -f $FriendlyName, [guid]::NewGuid().ToString('N'))
|
||||
$startParams.RedirectStandardOutput = $stdoutPath
|
||||
$startParams.RedirectStandardError = $stderrPath
|
||||
}
|
||||
|
||||
$process = Start-Process @startParams
|
||||
$timedOut = -not ($process | Wait-Process -Timeout $TimeoutSeconds -PassThru -ErrorAction SilentlyContinue)
|
||||
if ($timedOut) {
|
||||
Stop-ProcessTree -ProcessId $process.Id
|
||||
return [pscustomobject]@{
|
||||
TimedOut = $true
|
||||
ExitCode = $null
|
||||
StdoutPath = $stdoutPath
|
||||
StderrPath = $stderrPath
|
||||
}
|
||||
}
|
||||
|
||||
$process.Refresh()
|
||||
return [pscustomobject]@{
|
||||
TimedOut = $false
|
||||
ExitCode = $process.ExitCode
|
||||
StdoutPath = $stdoutPath
|
||||
StderrPath = $stderrPath
|
||||
}
|
||||
}
|
||||
|
||||
function Write-LogTail([string]$Path, [string]$Label) {
|
||||
if (-not $Path -or -not (Test-Path $Path)) {
|
||||
return
|
||||
}
|
||||
|
||||
$lines = Get-Content -Path $Path -Tail 40 -ErrorAction SilentlyContinue
|
||||
if ($lines -and $lines.Count -gt 0) {
|
||||
Write-Warn "$Label (last 40 lines):"
|
||||
$lines | ForEach-Object { Write-Host $_ }
|
||||
}
|
||||
}
|
||||
|
||||
function Test-CommandExists([string]$CommandName) {
|
||||
return [bool](Get-Command $CommandName -ErrorAction SilentlyContinue)
|
||||
}
|
||||
|
||||
function Test-AlacrittyInstalled {
|
||||
return [bool](Find-AlacrittyPath)
|
||||
}
|
||||
|
||||
function Find-AlacrittyPath {
|
||||
$candidates = @(
|
||||
"C:\Program Files\Alacritty\alacritty.exe",
|
||||
"C:\Program Files (x86)\Alacritty\alacritty.exe"
|
||||
)
|
||||
|
||||
if ($env:LOCALAPPDATA) {
|
||||
$candidates += (Join-Path $env:LOCALAPPDATA "Microsoft\WinGet\Links\alacritty.exe")
|
||||
}
|
||||
|
||||
foreach ($candidate in $candidates) {
|
||||
if ($candidate -and (Test-Path $candidate)) {
|
||||
return $candidate
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$command = Get-Command alacritty -ErrorAction Stop
|
||||
if ($command -and $command.Source) {
|
||||
return $command.Source
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
function Install-Alacritty {
|
||||
if (Test-AlacrittyInstalled) {
|
||||
Write-Info "Alacritty is already installed"
|
||||
return $true
|
||||
}
|
||||
|
||||
if (-not (Test-CommandExists "winget")) {
|
||||
Write-Warn "winget was not found, so Alacritty could not be installed automatically"
|
||||
Write-Warn "Install App Installer / winget from Microsoft, then run: winget install -e --id Alacritty.Alacritty"
|
||||
return $false
|
||||
}
|
||||
|
||||
Write-Info "Installing Alacritty..."
|
||||
$wingetArgs = @(
|
||||
"install",
|
||||
"-e",
|
||||
"--id", "Alacritty.Alacritty",
|
||||
"--accept-source-agreements",
|
||||
"--accept-package-agreements",
|
||||
"--disable-interactivity"
|
||||
)
|
||||
|
||||
$wingetResult = Invoke-ProcessWithTimeout -FilePath "winget" -ArgumentList $wingetArgs -TimeoutSeconds 180 -FriendlyName "winget-install"
|
||||
if ($wingetResult.TimedOut) {
|
||||
Write-Warn "Alacritty install timed out after 180 seconds; skipping automatic setup"
|
||||
return $false
|
||||
}
|
||||
|
||||
if ($wingetResult.ExitCode -ne 0) {
|
||||
Write-Warn "Alacritty install failed (winget exit code: $($wingetResult.ExitCode))"
|
||||
return $false
|
||||
}
|
||||
|
||||
$alacrittyPath = Find-AlacrittyPath
|
||||
if (-not $alacrittyPath) {
|
||||
Write-Warn "Alacritty install finished, but alacritty.exe was not found on PATH yet"
|
||||
return $false
|
||||
}
|
||||
|
||||
Write-Info "Alacritty installed: $alacrittyPath"
|
||||
return $true
|
||||
}
|
||||
|
||||
function Stop-JcodeHotkeyListeners {
|
||||
try {
|
||||
Get-CimInstance Win32_Process -Filter "Name = 'powershell.exe' OR Name = 'pwsh.exe'" -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.CommandLine -like '*jcode-hotkey*' } |
|
||||
ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function Set-SetupHintsState([bool]$AlacrittyConfigured, [bool]$HotkeyConfigured) {
|
||||
New-Item -ItemType Directory -Path $JcodeHome -Force | Out-Null
|
||||
|
||||
$state = @{
|
||||
launch_count = 0
|
||||
hotkey_configured = $HotkeyConfigured
|
||||
hotkey_dismissed = $HotkeyConfigured
|
||||
alacritty_configured = $AlacrittyConfigured
|
||||
alacritty_dismissed = $AlacrittyConfigured
|
||||
desktop_shortcut_created = $false
|
||||
mac_ghostty_guided = $false
|
||||
mac_ghostty_dismissed = $false
|
||||
}
|
||||
|
||||
if (Test-Path $SetupHintsPath) {
|
||||
try {
|
||||
$existing = Get-Content $SetupHintsPath -Raw | ConvertFrom-Json -ErrorAction Stop
|
||||
foreach ($property in $existing.PSObject.Properties) {
|
||||
$state[$property.Name] = $property.Value
|
||||
}
|
||||
} catch {
|
||||
Write-Warn "Could not read existing setup hints state; overwriting it"
|
||||
}
|
||||
}
|
||||
|
||||
if ($AlacrittyConfigured) {
|
||||
$state.alacritty_configured = $true
|
||||
$state.alacritty_dismissed = $true
|
||||
}
|
||||
|
||||
if ($HotkeyConfigured) {
|
||||
$state.hotkey_configured = $true
|
||||
$state.hotkey_dismissed = $true
|
||||
}
|
||||
|
||||
$state | ConvertTo-Json | Set-Content -Path $SetupHintsPath -Encoding UTF8
|
||||
}
|
||||
|
||||
function Install-JcodeHotkey([string]$JcodeExePath) {
|
||||
$alacrittyPath = Find-AlacrittyPath
|
||||
if (-not $alacrittyPath) {
|
||||
Write-Warn "Skipping Alt+; hotkey because Alacritty is not installed"
|
||||
return $false
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $HotkeyDir -Force | Out-Null
|
||||
Stop-JcodeHotkeyListeners
|
||||
|
||||
$escapedAlacritty = $alacrittyPath.Replace("'", "''")
|
||||
$escapedJcodeExe = $JcodeExePath.Replace("'", "''")
|
||||
|
||||
$ps1Path = Join-Path $HotkeyDir "jcode-hotkey.ps1"
|
||||
$ps1Lines = @(
|
||||
'# jcode Alt+; global hotkey listener',
|
||||
'# Auto-generated by scripts/install.ps1. Runs at login via startup shortcut.',
|
||||
'',
|
||||
'Add-Type @"',
|
||||
'using System;',
|
||||
'using System.Runtime.InteropServices;',
|
||||
'public class HotKeyHelper {',
|
||||
' [DllImport("user32.dll")]',
|
||||
' public static extern bool RegisterHotKey(IntPtr hWnd, int id, uint fsModifiers, uint vk);',
|
||||
' [DllImport("user32.dll")]',
|
||||
' public static extern bool UnregisterHotKey(IntPtr hWnd, int id);',
|
||||
' [DllImport("user32.dll")]',
|
||||
' public static extern int GetMessage(out MSG lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax);',
|
||||
' [StructLayout(LayoutKind.Sequential)]',
|
||||
' public struct MSG {',
|
||||
' public IntPtr hwnd;',
|
||||
' public uint message;',
|
||||
' public IntPtr wParam;',
|
||||
' public IntPtr lParam;',
|
||||
' public uint time;',
|
||||
' public int pt_x;',
|
||||
' public int pt_y;',
|
||||
' }',
|
||||
'}',
|
||||
'"@',
|
||||
'',
|
||||
'$MOD_ALT = 0x0001',
|
||||
'$MOD_NOREPEAT = 0x4000',
|
||||
'$VK_OEM_1 = 0xBA',
|
||||
'$WM_HOTKEY = 0x0312',
|
||||
'$HOTKEY_ID = 0x4A43',
|
||||
'',
|
||||
'if (-not [HotKeyHelper]::RegisterHotKey([IntPtr]::Zero, $HOTKEY_ID, $MOD_ALT -bor $MOD_NOREPEAT, $VK_OEM_1)) {',
|
||||
' Write-Error "Failed to register Alt+; hotkey (another program may have claimed it)"',
|
||||
' exit 1',
|
||||
'}',
|
||||
'',
|
||||
'try {',
|
||||
' $msg = New-Object HotKeyHelper+MSG',
|
||||
' while ([HotKeyHelper]::GetMessage([ref]$msg, [IntPtr]::Zero, $WM_HOTKEY, $WM_HOTKEY) -ne 0) {',
|
||||
' if ($msg.message -eq $WM_HOTKEY -and $msg.wParam.ToInt32() -eq $HOTKEY_ID) {',
|
||||
" Start-Process '$escapedAlacritty' -ArgumentList '-e', '$escapedJcodeExe'",
|
||||
' }',
|
||||
' }',
|
||||
'} finally {',
|
||||
' [HotKeyHelper]::UnregisterHotKey([IntPtr]::Zero, $HOTKEY_ID)',
|
||||
'}'
|
||||
)
|
||||
$ps1Content = $ps1Lines -join "`r`n"
|
||||
Set-Content -Path $ps1Path -Value $ps1Content -Encoding UTF8
|
||||
|
||||
$vbsPath = Join-Path $HotkeyDir "jcode-hotkey-launcher.vbs"
|
||||
$vbsContent = @(
|
||||
'Set objShell = CreateObject("WScript.Shell")',
|
||||
('objShell.Run "powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File ""{0}""", 0, False' -f $ps1Path)
|
||||
) -join "`r`n"
|
||||
Set-Content -Path $vbsPath -Value $vbsContent -Encoding ASCII
|
||||
|
||||
$startupDir = Join-Path $env:APPDATA "Microsoft\Windows\Start Menu\Programs\Startup"
|
||||
New-Item -ItemType Directory -Path $startupDir -Force | Out-Null
|
||||
$startupShortcutPath = (Join-Path $startupDir "jcode-hotkey.lnk").Replace("'", "''")
|
||||
$escapedVbsPath = $vbsPath.Replace("'", "''")
|
||||
|
||||
$shortcutLines = @(
|
||||
'$shell = New-Object -ComObject WScript.Shell',
|
||||
"`$shortcut = `$shell.CreateShortcut('$startupShortcutPath')",
|
||||
"`$shortcut.TargetPath = 'wscript.exe'",
|
||||
("`$shortcut.Arguments = '""{0}""'" -f $escapedVbsPath),
|
||||
"`$shortcut.Description = 'jcode Alt+; hotkey listener'",
|
||||
'`$shortcut.WindowStyle = 7',
|
||||
'`$shortcut.Save()',
|
||||
"Write-Output 'OK'"
|
||||
)
|
||||
$shortcutScript = $shortcutLines -join "`r`n"
|
||||
|
||||
$shortcutOutput = & powershell -NoProfile -Command $shortcutScript
|
||||
if ($LASTEXITCODE -ne 0 -or -not ($shortcutOutput -match 'OK')) {
|
||||
Write-Warn "Created hotkey files, but could not create the Startup shortcut"
|
||||
return $false
|
||||
}
|
||||
|
||||
$launchHotkeyCommand = "Start-Process wscript.exe -ArgumentList '""{0}""' -WindowStyle Hidden" -f $vbsPath
|
||||
& powershell -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -Command $launchHotkeyCommand | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Warn "Hotkey will start on next login, but could not be launched immediately"
|
||||
}
|
||||
|
||||
Write-Info "Configured Alt+; to launch jcode in Alacritty"
|
||||
return $true
|
||||
}
|
||||
|
||||
function Get-JcodeWindowsArtifact {
|
||||
$candidates = @()
|
||||
|
||||
try {
|
||||
$runtimeArch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture
|
||||
if ($runtimeArch) { $candidates += [string]$runtimeArch }
|
||||
} catch {}
|
||||
|
||||
foreach ($envArch in @($env:PROCESSOR_ARCHITECTURE, $env:PROCESSOR_ARCHITEW6432)) {
|
||||
if ($envArch) { $candidates += [string]$envArch }
|
||||
}
|
||||
|
||||
foreach ($arch in $candidates) {
|
||||
switch -Regex ($arch.Trim()) {
|
||||
'^(X64|AMD64|x86_64)$' { return "jcode-windows-x86_64" }
|
||||
'^(Arm64|ARM64|AARCH64|aarch64)$' { return "jcode-windows-aarch64" }
|
||||
}
|
||||
}
|
||||
|
||||
$displayArch = if ($candidates.Count -gt 0) { $candidates -join ", " } else { "<unknown>" }
|
||||
Write-Err "Unsupported architecture: $displayArch (supported: x86_64, ARM64)"
|
||||
}
|
||||
|
||||
$Artifact = Get-JcodeWindowsArtifact
|
||||
|
||||
$ResolvedArtifactExePath = Resolve-OptionalPath $ArtifactExePath
|
||||
$ResolvedArtifactTgzPath = Resolve-OptionalPath $ArtifactTgzPath
|
||||
|
||||
if ($ResolvedArtifactExePath -and $ResolvedArtifactTgzPath) {
|
||||
Write-Err "Provide only one of -ArtifactExePath or -ArtifactTgzPath"
|
||||
}
|
||||
|
||||
if (-not $Version) {
|
||||
if ($ResolvedArtifactExePath -or $ResolvedArtifactTgzPath) {
|
||||
Write-Err "-Version is required when using a local artifact path"
|
||||
}
|
||||
|
||||
Write-Info "Fetching latest release..."
|
||||
try {
|
||||
$Release = Invoke-RestMethod -Uri "https://api.github.com/repos/$Repo/releases/latest"
|
||||
$Version = $Release.tag_name
|
||||
} catch {
|
||||
Write-Err "Failed to determine latest version: $_"
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $Version) { Write-Err "Failed to determine latest version" }
|
||||
|
||||
$VersionNum = $Version.TrimStart('v')
|
||||
$TgzUrl = "https://github.com/$Repo/releases/download/$Version/$Artifact.tar.gz"
|
||||
$ExeUrl = "https://github.com/$Repo/releases/download/$Version/$Artifact.exe"
|
||||
|
||||
$BuildsDir = Join-Path $env:LOCALAPPDATA "jcode\builds"
|
||||
$StableDir = Join-Path $BuildsDir "stable"
|
||||
$VersionDir = Join-Path $BuildsDir "versions\$VersionNum"
|
||||
$LauncherPath = Join-Path $InstallDir "jcode.exe"
|
||||
|
||||
$Existing = ""
|
||||
if (Test-Path $LauncherPath) {
|
||||
try { $Existing = & $LauncherPath --version 2>$null | Select-Object -First 1 } catch {}
|
||||
}
|
||||
|
||||
if ($Existing) {
|
||||
if ($Existing -match [regex]::Escape($VersionNum)) {
|
||||
Write-Info "jcode $Version is already installed - reinstalling"
|
||||
} else {
|
||||
Write-Info "Updating jcode $Existing -> $Version"
|
||||
}
|
||||
} else {
|
||||
Write-Info "Installing jcode $Version"
|
||||
}
|
||||
Write-Info " launcher: $LauncherPath"
|
||||
|
||||
foreach ($d in @($InstallDir, $StableDir, $VersionDir)) {
|
||||
if (-not (Test-Path $d)) { New-Item -ItemType Directory -Path $d -Force | Out-Null }
|
||||
}
|
||||
|
||||
$TempDir = Join-Path $env:TEMP "jcode-install-$(Get-Random)"
|
||||
New-Item -ItemType Directory -Path $TempDir -Force | Out-Null
|
||||
|
||||
$DownloadMode = ""
|
||||
$DownloadPath = Join-Path $TempDir "jcode.download"
|
||||
|
||||
if ($ResolvedArtifactExePath) {
|
||||
Write-Info "Using local artifact exe: $ResolvedArtifactExePath"
|
||||
Copy-Item -Path $ResolvedArtifactExePath -Destination $DownloadPath -Force
|
||||
$DownloadMode = "bin"
|
||||
} elseif ($ResolvedArtifactTgzPath) {
|
||||
Write-Info "Using local artifact archive: $ResolvedArtifactTgzPath"
|
||||
Copy-Item -Path $ResolvedArtifactTgzPath -Destination $DownloadPath -Force
|
||||
$DownloadMode = "tar"
|
||||
} else {
|
||||
try {
|
||||
Write-Info "Downloading $Artifact.exe..."
|
||||
Invoke-WebRequest -Uri $ExeUrl -OutFile $DownloadPath
|
||||
$DownloadMode = "bin"
|
||||
} catch {
|
||||
try {
|
||||
Write-Info "Trying archive download..."
|
||||
Invoke-WebRequest -Uri $TgzUrl -OutFile $DownloadPath
|
||||
$DownloadMode = "tar"
|
||||
} catch {
|
||||
$DownloadMode = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$DestBin = Join-Path $VersionDir "jcode.exe"
|
||||
|
||||
if ($DownloadMode -eq "tar") {
|
||||
Write-Info "Extracting..."
|
||||
tar xzf $DownloadPath -C $TempDir 2>$null
|
||||
$SrcBin = Join-Path $TempDir "$Artifact.exe"
|
||||
if (-not (Test-Path $SrcBin)) {
|
||||
Write-Err "Downloaded archive did not contain expected binary: $Artifact.exe"
|
||||
}
|
||||
Move-Item -Path $SrcBin -Destination $DestBin -Force
|
||||
} elseif ($DownloadMode -eq "bin") {
|
||||
Move-Item -Path $DownloadPath -Destination $DestBin -Force
|
||||
} else {
|
||||
Write-Info "No prebuilt asset found for $Artifact in $Version; building from source..."
|
||||
if (-not (Get-Command git -ErrorAction SilentlyContinue)) { Write-Err "git is required to build from source" }
|
||||
if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) { Write-Err "cargo is required to build from source" }
|
||||
|
||||
$SrcDir = Join-Path $TempDir "jcode-src"
|
||||
Write-Info "Cloning $Repo at $Version..."
|
||||
$gitCloneResult = Invoke-ProcessWithTimeout -FilePath "git" -ArgumentList @(
|
||||
"clone",
|
||||
"--depth", "1",
|
||||
"--branch", $Version,
|
||||
"https://github.com/$Repo.git",
|
||||
$SrcDir
|
||||
) -TimeoutSeconds 600 -FriendlyName "git-clone" -CaptureOutput
|
||||
if ($gitCloneResult.TimedOut) {
|
||||
Write-LogTail -Path $gitCloneResult.StdoutPath -Label "git stdout"
|
||||
Write-LogTail -Path $gitCloneResult.StderrPath -Label "git stderr"
|
||||
Write-Err "git clone timed out after 600 seconds"
|
||||
}
|
||||
if ($gitCloneResult.ExitCode -ne 0) {
|
||||
Write-LogTail -Path $gitCloneResult.StdoutPath -Label "git stdout"
|
||||
Write-LogTail -Path $gitCloneResult.StderrPath -Label "git stderr"
|
||||
Write-Err "Failed to clone $Repo at $Version (exit code: $($gitCloneResult.ExitCode))"
|
||||
}
|
||||
|
||||
Write-Info "Building jcode from source (this can take several minutes)..."
|
||||
$cargoResult = Invoke-ProcessWithTimeout -FilePath "cargo" -ArgumentList @("build", "--release", "--manifest-path", (Join-Path $SrcDir "Cargo.toml")) -TimeoutSeconds 1800 -FriendlyName "cargo-build" -CaptureOutput
|
||||
if ($cargoResult.TimedOut) {
|
||||
Write-LogTail -Path $cargoResult.StdoutPath -Label "cargo stdout"
|
||||
Write-LogTail -Path $cargoResult.StderrPath -Label "cargo stderr"
|
||||
Write-Err "cargo build timed out after 1800 seconds"
|
||||
}
|
||||
if ($cargoResult.ExitCode -ne 0) {
|
||||
Write-LogTail -Path $cargoResult.StdoutPath -Label "cargo stdout"
|
||||
Write-LogTail -Path $cargoResult.StderrPath -Label "cargo stderr"
|
||||
Write-Err "cargo build failed (exit code: $($cargoResult.ExitCode))"
|
||||
}
|
||||
|
||||
$BuiltBin = Join-Path $SrcDir "target\release\jcode.exe"
|
||||
if (-not (Test-Path $BuiltBin)) { Write-Err "Built binary not found at $BuiltBin" }
|
||||
Copy-Item -Path $BuiltBin -Destination $DestBin -Force
|
||||
}
|
||||
|
||||
Copy-Item -Path $DestBin -Destination (Join-Path $StableDir "jcode.exe") -Force
|
||||
Set-Content -Path (Join-Path $BuildsDir "stable-version") -Value $VersionNum
|
||||
Copy-Item -Path (Join-Path $StableDir "jcode.exe") -Destination $LauncherPath -Force
|
||||
|
||||
# Gracefully reload any running background server onto the freshly installed
|
||||
# binary (issue #291). `server reload` only reloads a genuinely-older daemon,
|
||||
# hands its live sessions to the new process, and is a no-op when nothing is
|
||||
# running, so it is safe to call unconditionally. Best-effort: never fail the
|
||||
# install over it.
|
||||
if ($env:JCODE_SKIP_SERVER_RELOAD -ne "1") {
|
||||
try {
|
||||
& $LauncherPath server reload 2>$null | Out-Null
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
Remove-Item -Path $TempDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
|
||||
$UserPath = [Environment]::GetEnvironmentVariable("Path", "User")
|
||||
if ($UserPath -notlike "*$InstallDir*") {
|
||||
[Environment]::SetEnvironmentVariable("Path", "$InstallDir;$UserPath", "User")
|
||||
Write-Info "Added $InstallDir to user PATH"
|
||||
}
|
||||
|
||||
$env:Path = "$InstallDir;$env:Path"
|
||||
|
||||
$installedAlacritty = $false
|
||||
$configuredHotkey = $false
|
||||
|
||||
if ($SkipAlacrittySetup) {
|
||||
Write-Info "Skipping Alacritty setup"
|
||||
$installedAlacritty = Test-AlacrittyInstalled
|
||||
} else {
|
||||
$installedAlacritty = Install-Alacritty
|
||||
}
|
||||
|
||||
if ($SkipHotkeySetup) {
|
||||
Write-Info "Skipping Alt+; hotkey setup"
|
||||
} elseif ($installedAlacritty) {
|
||||
$configuredHotkey = Install-JcodeHotkey -JcodeExePath $LauncherPath
|
||||
}
|
||||
|
||||
Set-SetupHintsState -AlacrittyConfigured:(Test-AlacrittyInstalled) -HotkeyConfigured:$configuredHotkey
|
||||
|
||||
Write-Host ""
|
||||
Write-Info "jcode $Version installed successfully!"
|
||||
Write-Host ""
|
||||
|
||||
if (Test-AlacrittyInstalled) {
|
||||
$alacrittyPath = Find-AlacrittyPath
|
||||
if ($alacrittyPath) {
|
||||
Write-Info "Alacritty ready: $alacrittyPath"
|
||||
}
|
||||
}
|
||||
|
||||
if ($configuredHotkey) {
|
||||
Write-Info "Global hotkey ready: Alt+; opens jcode in Alacritty"
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
if (Get-Command jcode -ErrorAction SilentlyContinue) {
|
||||
Write-Info "Run 'jcode' to get started."
|
||||
} else {
|
||||
Write-Host " Open a new terminal window, then run:"
|
||||
Write-Host ""
|
||||
Write-Host " jcode" -ForegroundColor Green
|
||||
}
|
||||
Executable
+314
@@ -0,0 +1,314 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
REPO="1jehuang/jcode"
|
||||
IS_WINDOWS=false
|
||||
IS_TERMUX=false
|
||||
|
||||
info() { printf '\033[1;34m%s\033[0m\n' "$*"; }
|
||||
err() { printf '\033[1;31merror: %s\033[0m\n' "$*" >&2; exit 1; }
|
||||
|
||||
OS="$(uname -s)"
|
||||
ARCH="$(uname -m)"
|
||||
|
||||
if [ -n "${TERMUX_VERSION:-}" ] || [ "${PREFIX:-}" = "/data/data/com.termux/files/usr" ] || [ -d "/data/data/com.termux/files/usr" ]; then
|
||||
IS_TERMUX=true
|
||||
fi
|
||||
|
||||
case "$OS" in
|
||||
Linux)
|
||||
case "$ARCH" in
|
||||
x86_64) ARTIFACT="jcode-linux-x86_64" ;;
|
||||
aarch64|arm64) ARTIFACT="jcode-linux-aarch64" ;;
|
||||
*) err "Unsupported Linux architecture: $ARCH" ;;
|
||||
esac
|
||||
;;
|
||||
Darwin)
|
||||
case "$ARCH" in
|
||||
arm64) ARTIFACT="jcode-macos-aarch64" ;;
|
||||
x86_64) ARTIFACT="jcode-macos-x86_64" ;;
|
||||
*) err "Unsupported macOS architecture: $ARCH" ;;
|
||||
esac
|
||||
;;
|
||||
MINGW*|MSYS*|CYGWIN*)
|
||||
IS_WINDOWS=true
|
||||
case "$ARCH" in
|
||||
x86_64|AMD64) ARTIFACT="jcode-windows-x86_64" ;;
|
||||
aarch64|arm64|ARM64) ARTIFACT="jcode-windows-aarch64" ;;
|
||||
*) err "Unsupported Windows architecture: $ARCH" ;;
|
||||
esac
|
||||
;;
|
||||
*)
|
||||
err "Unsupported OS: $OS (try building from source: https://github.com/$REPO)"
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$IS_WINDOWS" = true ]; then
|
||||
INSTALL_DIR="${JCODE_INSTALL_DIR:-$LOCALAPPDATA/jcode/bin}"
|
||||
else
|
||||
INSTALL_DIR="${JCODE_INSTALL_DIR:-$HOME/.local/bin}"
|
||||
fi
|
||||
|
||||
# Extract the tag_name value, working for both pretty-printed (multi-line) and
|
||||
# compact (single-line) GitHub API JSON. `grep -o` isolates just the tag_name
|
||||
# field so `cut` no longer matches an unrelated string like the release url.
|
||||
VERSION=$(curl -fsSL "https://api.github.com/repos/$REPO/releases/latest" | grep -o '"tag_name" *: *"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||
[ -n "$VERSION" ] || err "Failed to determine latest version"
|
||||
|
||||
URL_TGZ="https://github.com/$REPO/releases/download/$VERSION/$ARTIFACT.tar.gz"
|
||||
URL_BIN="https://github.com/$REPO/releases/download/$VERSION/$ARTIFACT"
|
||||
|
||||
if [ "$IS_WINDOWS" = true ]; then
|
||||
EXE=".exe"
|
||||
builds_dir="$LOCALAPPDATA/jcode/builds"
|
||||
else
|
||||
EXE=""
|
||||
builds_dir="$HOME/.jcode/builds"
|
||||
fi
|
||||
stable_dir="$builds_dir/stable"
|
||||
current_dir="$builds_dir/current"
|
||||
version_dir="$builds_dir/versions"
|
||||
launcher_path="$INSTALL_DIR/jcode${EXE}"
|
||||
|
||||
EXISTING=""
|
||||
if [ -x "$launcher_path" ]; then
|
||||
EXISTING=$("$launcher_path" --version 2>/dev/null | head -1 || echo "unknown")
|
||||
fi
|
||||
|
||||
if [ -n "$EXISTING" ]; then
|
||||
if echo "$EXISTING" | grep -qF "${VERSION#v}"; then
|
||||
info "jcode $VERSION is already installed — reinstalling"
|
||||
else
|
||||
info "Updating jcode $EXISTING → $VERSION"
|
||||
fi
|
||||
else
|
||||
info "Installing jcode $VERSION"
|
||||
fi
|
||||
info " launcher: $launcher_path"
|
||||
|
||||
tmpdir=$(mktemp -d)
|
||||
trap 'rm -rf "$tmpdir"' EXIT
|
||||
|
||||
download_mode=""
|
||||
if curl -fsSL "$URL_TGZ" -o "$tmpdir/jcode.download" 2>/dev/null; then
|
||||
download_mode="tar"
|
||||
elif curl -fsSL "$URL_BIN" -o "$tmpdir/jcode.download" 2>/dev/null; then
|
||||
download_mode="bin"
|
||||
fi
|
||||
|
||||
mkdir -p "$INSTALL_DIR" "$stable_dir" "$current_dir" "$version_dir"
|
||||
|
||||
version="${VERSION#v}"
|
||||
dest_version_dir="$version_dir/$version"
|
||||
mkdir -p "$dest_version_dir"
|
||||
|
||||
bin_name="jcode${EXE}"
|
||||
|
||||
if [ "$download_mode" = "tar" ]; then
|
||||
tar xzf "$tmpdir/jcode.download" -C "$tmpdir"
|
||||
src_bin="$tmpdir/${ARTIFACT}${EXE}"
|
||||
[ -f "$src_bin" ] || err "Downloaded archive did not contain expected binary: ${ARTIFACT}${EXE}"
|
||||
find "$tmpdir" -maxdepth 1 -type f \( -name "${ARTIFACT}${EXE}.bin" -o -name 'libssl.so*' -o -name 'libcrypto.so*' \) \
|
||||
-exec cp -f {} "$dest_version_dir/" \;
|
||||
mv "$src_bin" "$dest_version_dir/$bin_name"
|
||||
elif [ "$download_mode" = "bin" ]; then
|
||||
mv "$tmpdir/jcode.download" "$dest_version_dir/$bin_name"
|
||||
else
|
||||
info "No prebuilt asset found for $ARTIFACT in $VERSION; building from source..."
|
||||
command -v git >/dev/null 2>&1 || err "git is required to build from source"
|
||||
command -v cargo >/dev/null 2>&1 || err "cargo is required to build from source"
|
||||
|
||||
src_dir="$tmpdir/jcode-src"
|
||||
git clone --depth 1 --branch "$VERSION" "https://github.com/$REPO.git" "$src_dir" \
|
||||
|| err "Failed to clone $REPO at $VERSION"
|
||||
cargo build --release --manifest-path "$src_dir/Cargo.toml" \
|
||||
|| err "cargo build failed while building $REPO from source"
|
||||
|
||||
src_bin="$src_dir/target/release/$bin_name"
|
||||
[ -f "$src_bin" ] || err "Built binary not found at $src_bin"
|
||||
cp "$src_bin" "$dest_version_dir/$bin_name"
|
||||
fi
|
||||
|
||||
chmod +x "$dest_version_dir/$bin_name" 2>/dev/null || true
|
||||
|
||||
if [ "$IS_TERMUX" = true ] && [ "$IS_WINDOWS" = false ]; then
|
||||
termux_glibc_dir="/data/data/com.termux/files/usr/glibc/lib"
|
||||
termux_glibc_linker=""
|
||||
case "$ARCH" in
|
||||
aarch64|arm64) termux_glibc_linker="$termux_glibc_dir/ld-linux-aarch64.so.1" ;;
|
||||
x86_64) termux_glibc_linker="$termux_glibc_dir/ld-linux-x86-64.so.2" ;;
|
||||
esac
|
||||
if [ "$OS" = "Linux" ] && [ -n "$termux_glibc_linker" ]; then
|
||||
if [ -x "$termux_glibc_linker" ]; then
|
||||
if command -v patchelf >/dev/null 2>&1; then
|
||||
patchelf --set-interpreter "$termux_glibc_linker" "$dest_version_dir/$bin_name" \
|
||||
|| err "Failed to patch jcode ELF interpreter for Termux glibc"
|
||||
info "Patched Termux glibc ELF interpreter: $termux_glibc_linker"
|
||||
else
|
||||
info "Termux detected: install patchelf with 'pkg install patchelf' and rerun this installer if jcode fails to start."
|
||||
fi
|
||||
else
|
||||
info "Termux detected: install glibc with 'pkg install glibc' if jcode fails due to a missing dynamic linker."
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$IS_WINDOWS" = true ]; then
|
||||
cp -f "$dest_version_dir/$bin_name" "$stable_dir/$bin_name"
|
||||
printf '%s\n' "$version" > "$builds_dir/stable-version"
|
||||
cp -f "$stable_dir/$bin_name" "$launcher_path"
|
||||
else
|
||||
ln -sfn "$dest_version_dir/$bin_name" "$stable_dir/$bin_name"
|
||||
printf '%s\n' "$version" > "$builds_dir/stable-version"
|
||||
if [ "$IS_TERMUX" = true ]; then
|
||||
rm -f "$launcher_path"
|
||||
cat > "$launcher_path" <<EOF
|
||||
#!/usr/bin/env bash
|
||||
unset LD_PRELOAD
|
||||
exec "$stable_dir/$bin_name" "\$@"
|
||||
EOF
|
||||
chmod +x "$launcher_path"
|
||||
else
|
||||
ln -sfn "$stable_dir/$bin_name" "$launcher_path"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$(uname -s)" = "Darwin" ]; then
|
||||
xattr -d com.apple.quarantine "$dest_version_dir/$bin_name" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
if [ "$(uname -s)" = "Darwin" ]; then
|
||||
if "$launcher_path" setup-hotkey </dev/null >/dev/null 2>&1; then
|
||||
mac_hotkey_ready=true
|
||||
else
|
||||
mac_hotkey_ready=false
|
||||
fi
|
||||
fi
|
||||
|
||||
# Retire any background server still running the old binary so the freshly
|
||||
# installed version is picked up without the user having to kill a daemon by
|
||||
# hand (issue #291). We use the graceful `server reload` path, which hands live
|
||||
# headless/swarm sessions to a newly-exec'd server instead of dropping them, and
|
||||
# only reloads when the running server is genuinely older than what we just
|
||||
# installed (so a newer/dev daemon is never downgraded). This is best-effort:
|
||||
# it must never fail the install, and it is skipped when no server is running.
|
||||
if [ "${JCODE_SKIP_SERVER_RELOAD:-}" != "1" ]; then
|
||||
reload_bin="$launcher_path"
|
||||
[ -x "$reload_bin" ] || reload_bin="$stable_dir/$bin_name"
|
||||
if [ -x "$reload_bin" ]; then
|
||||
if "$reload_bin" server reload </dev/null >/dev/null 2>&1; then
|
||||
info "Reloaded the running jcode server onto $VERSION (if one was active)."
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$IS_WINDOWS" = true ]; then
|
||||
win_install_dir=$(cygpath -w "$INSTALL_DIR" 2>/dev/null || echo "$INSTALL_DIR")
|
||||
echo ""
|
||||
info "✅ jcode $VERSION installed successfully!"
|
||||
echo ""
|
||||
if command -v jcode >/dev/null 2>&1; then
|
||||
info "Run 'jcode' to get started."
|
||||
else
|
||||
echo " To start using jcode right now, run:"
|
||||
echo ""
|
||||
printf ' \033[1;32mexport PATH="%s:$PATH" && jcode\033[0m\n' "$INSTALL_DIR"
|
||||
echo ""
|
||||
echo " To add jcode to PATH permanently (PowerShell):"
|
||||
echo ""
|
||||
printf ' \033[1;32m[Environment]::SetEnvironmentVariable("Path", "%s;" + [Environment]::GetEnvironmentVariable("Path", "User"), "User")\033[0m\n' "$win_install_dir"
|
||||
fi
|
||||
else
|
||||
PATH_LINE="export PATH=\"$INSTALL_DIR:\$PATH\""
|
||||
added_to=""
|
||||
|
||||
_have() { command -v "$1" >/dev/null 2>&1; }
|
||||
|
||||
# Append the POSIX (bash/zsh/sh) PATH line to an rc file, idempotently.
|
||||
# ensure_posix_rc <rc-file> <create:yes|no>
|
||||
# With create=yes the file (and parent dir) is created if missing; with
|
||||
# create=no we only touch files that already exist, so we never change how a
|
||||
# login shell resolves its startup files (e.g. creating ~/.bash_profile would
|
||||
# stop bash from reading ~/.profile).
|
||||
ensure_posix_rc() {
|
||||
rc="$1"; create="$2"
|
||||
if [ ! -f "$rc" ]; then
|
||||
[ "$create" = "yes" ] || return 0
|
||||
mkdir -p "$(dirname "$rc")"
|
||||
fi
|
||||
if ! grep -qF "$INSTALL_DIR" "$rc" 2>/dev/null; then
|
||||
printf '\n# Added by jcode installer\n%s\n' "$PATH_LINE" >> "$rc"
|
||||
added_to="$added_to $rc"
|
||||
fi
|
||||
}
|
||||
|
||||
# fish uses its own syntax and does not read POSIX rc files.
|
||||
ensure_fish_rc() {
|
||||
create="$1"
|
||||
rc="${XDG_CONFIG_HOME:-$HOME/.config}/fish/config.fish"
|
||||
if [ ! -f "$rc" ]; then
|
||||
[ "$create" = "yes" ] || return 0
|
||||
mkdir -p "$(dirname "$rc")"
|
||||
fi
|
||||
if ! grep -qF "$INSTALL_DIR" "$rc" 2>/dev/null; then
|
||||
{
|
||||
printf '\n# Added by jcode installer\n'
|
||||
printf 'if not contains "%s" $PATH\n' "$INSTALL_DIR"
|
||||
printf ' set -gx PATH "%s" $PATH\n' "$INSTALL_DIR"
|
||||
printf 'end\n'
|
||||
} >> "$rc"
|
||||
added_to="$added_to $rc"
|
||||
fi
|
||||
}
|
||||
|
||||
# zsh: ~/.zshenv is read for every zsh invocation (login, interactive and
|
||||
# scripts), so it is the most reliable single place to export PATH.
|
||||
if _have zsh || [ "$(uname -s)" = "Darwin" ] || [ -f "$HOME/.zshenv" ] || [ -f "$HOME/.zshrc" ]; then
|
||||
ensure_posix_rc "$HOME/.zshenv" yes
|
||||
fi
|
||||
|
||||
# bash: ~/.bashrc for interactive shells, ~/.profile for login shells (macOS
|
||||
# Terminal, ssh, etc.). We only create ~/.profile, never ~/.bash_profile, so
|
||||
# we don't override an existing login-file lookup order.
|
||||
if _have bash || [ -f "$HOME/.bashrc" ] || [ -f "$HOME/.bash_profile" ]; then
|
||||
ensure_posix_rc "$HOME/.bashrc" yes
|
||||
fi
|
||||
ensure_posix_rc "$HOME/.profile" yes
|
||||
|
||||
# fish: only set it up when fish is installed or already configured.
|
||||
if _have fish || [ -f "${XDG_CONFIG_HOME:-$HOME/.config}/fish/config.fish" ]; then
|
||||
ensure_fish_rc yes
|
||||
fi
|
||||
|
||||
# Also patch other common startup files when they already exist, so we cover
|
||||
# users with custom login-shell setups without creating new files.
|
||||
for rc in "$HOME/.zshrc" "$HOME/.zprofile" "$HOME/.bash_profile"; do
|
||||
ensure_posix_rc "$rc" no
|
||||
done
|
||||
|
||||
if [ -n "$added_to" ]; then
|
||||
info "Added $INSTALL_DIR to PATH in:$added_to"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
info "✅ jcode $VERSION installed successfully!"
|
||||
echo ""
|
||||
|
||||
if [ "$(uname -s)" = "Darwin" ]; then
|
||||
if [ "${mac_hotkey_ready:-false}" = true ]; then
|
||||
info "Global hotkey ready: Cmd+; launches a new jcode from anywhere, system-wide"
|
||||
else
|
||||
info "Tip: run 'jcode setup-hotkey' so Cmd+; launches jcode system-wide on macOS"
|
||||
fi
|
||||
fi
|
||||
|
||||
if command -v jcode >/dev/null 2>&1; then
|
||||
info "Run 'jcode' to get started."
|
||||
else
|
||||
echo " To start using jcode right now, run:"
|
||||
echo ""
|
||||
printf ' \033[1;32mexport PATH="%s:\$PATH" && jcode\033[0m\n' "$INSTALL_DIR"
|
||||
echo ""
|
||||
echo " Future terminal sessions will have jcode on PATH automatically."
|
||||
fi
|
||||
fi
|
||||
Executable
+108
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env bash
|
||||
# Install the current release binary into the immutable version store,
|
||||
# update the stable + current channel symlinks, and point the launcher at current.
|
||||
#
|
||||
# Paths after install:
|
||||
# - ~/.jcode/builds/versions/<hash>/jcode (immutable)
|
||||
# - ~/.jcode/builds/stable/jcode -> .../versions/<hash>/jcode
|
||||
# - ~/.jcode/builds/current/jcode -> .../versions/<hash>/jcode
|
||||
# - ~/.local/bin/jcode -> ~/.jcode/builds/current/jcode (launcher)
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
|
||||
|
||||
profile="${JCODE_RELEASE_PROFILE:-release-lto}"
|
||||
if [[ "${1:-}" == "--fast" ]]; then
|
||||
profile="release"
|
||||
shift
|
||||
fi
|
||||
|
||||
if [[ "$#" -gt 0 ]]; then
|
||||
echo "Usage: $0 [--fast]" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "$profile" in
|
||||
release-lto)
|
||||
echo "Building with LTO (this takes a few minutes)..."
|
||||
;;
|
||||
release)
|
||||
echo "Building fast release profile (no LTO)..."
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported profile: $profile (expected: release or release-lto)" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
cargo build --profile "$profile" --manifest-path "$repo_root/Cargo.toml"
|
||||
bin="$repo_root/target/$profile/jcode"
|
||||
|
||||
if [[ ! -x "$bin" ]]; then
|
||||
echo "Release binary not found: $bin" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
hash=""
|
||||
if command -v git >/dev/null 2>&1; then
|
||||
if git -C "$repo_root" rev-parse --git-dir >/dev/null 2>&1; then
|
||||
hash="$(git -C "$repo_root" rev-parse --short HEAD 2>/dev/null || true)"
|
||||
if [[ -n "${hash}" ]] && [[ -n "$(git -C "$repo_root" status --porcelain 2>/dev/null || true)" ]]; then
|
||||
hash="${hash}-dirty"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -z "$hash" ]]; then
|
||||
hash="$(date +%Y%m%d%H%M%S)"
|
||||
fi
|
||||
|
||||
# Install versioned binary into ~/.jcode/builds/versions/<hash>/
|
||||
builds_dir="$HOME/.jcode/builds"
|
||||
version_dir="$builds_dir/versions/$hash"
|
||||
mkdir -p "$version_dir"
|
||||
install -m 755 "$bin" "$version_dir/jcode"
|
||||
|
||||
# Update stable symlink
|
||||
stable_dir="$builds_dir/stable"
|
||||
mkdir -p "$stable_dir"
|
||||
ln -sfn "$version_dir/jcode" "$stable_dir/jcode"
|
||||
|
||||
# Update stable-version marker
|
||||
printf '%s\n' "$hash" > "$builds_dir/stable-version"
|
||||
|
||||
# Update current symlink + marker
|
||||
current_dir="$builds_dir/current"
|
||||
mkdir -p "$current_dir"
|
||||
ln -sfn "$version_dir/jcode" "$current_dir/jcode"
|
||||
printf '%s\n' "$hash" > "$builds_dir/current-version"
|
||||
|
||||
# Update launcher path to current channel
|
||||
install_dir="${JCODE_INSTALL_DIR:-$HOME/.local/bin}"
|
||||
mkdir -p "$install_dir"
|
||||
ln -sfn "$current_dir/jcode" "$install_dir/jcode"
|
||||
|
||||
echo "Installed: $version_dir/jcode"
|
||||
echo "Updated stable symlink: $stable_dir/jcode -> $version_dir/jcode"
|
||||
echo "Updated current symlink: $current_dir/jcode -> $version_dir/jcode"
|
||||
echo "Updated launcher symlink: $install_dir/jcode -> $current_dir/jcode"
|
||||
|
||||
# Gracefully reload any running background server onto the binary we just
|
||||
# installed (issue #291). `server reload` only reloads when the running daemon
|
||||
# is genuinely older, hands live headless/swarm sessions to the new process, and
|
||||
# is a no-op when no server is running, so it is safe to call unconditionally.
|
||||
if [ "${JCODE_SKIP_SERVER_RELOAD:-}" != "1" ]; then
|
||||
if "$install_dir/jcode" server reload </dev/null >/dev/null 2>&1; then
|
||||
echo "Reloaded the running jcode server onto $hash (if one was active)."
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! echo "$PATH" | tr ':' '\n' | grep -qx "$install_dir"; then
|
||||
echo ""
|
||||
echo "Tip: add $install_dir to PATH if needed."
|
||||
fi
|
||||
|
||||
# Ensure the launcher dir is on PATH for bash, zsh and fish in future shells.
|
||||
# shellcheck source=scripts/lib/configure_path.sh
|
||||
. "$(dirname "$0")/lib/configure_path.sh"
|
||||
jcode_configure_path "$install_dir"
|
||||
@@ -0,0 +1,54 @@
|
||||
param(
|
||||
[Parameter(Mandatory=$true)]
|
||||
[ValidateNotNullOrEmpty()]
|
||||
[string]$Name,
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
[ValidateNotNullOrEmpty()]
|
||||
[string[]]$CargoArgs,
|
||||
|
||||
[ValidateRange(1, 86400)]
|
||||
[int]$TimeoutSeconds = 300
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
function Stop-ProcessTree {
|
||||
param(
|
||||
[Parameter(Mandatory=$true)]
|
||||
[System.Diagnostics.Process]$Process
|
||||
)
|
||||
|
||||
if ($Process.HasExited) {
|
||||
return
|
||||
}
|
||||
|
||||
$taskkill = Get-Command taskkill.exe -ErrorAction SilentlyContinue
|
||||
if ($taskkill) {
|
||||
& $taskkill.Source /PID $Process.Id /T /F | ForEach-Object { Write-Host $_ }
|
||||
return
|
||||
}
|
||||
|
||||
Stop-Process -Id $Process.Id -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
$timeoutMilliseconds = $TimeoutSeconds * 1000
|
||||
|
||||
Write-Host "::group::$Name"
|
||||
try {
|
||||
Write-Host "cargo $($CargoArgs -join ' ')"
|
||||
$process = Start-Process -FilePath 'cargo' -ArgumentList $CargoArgs -NoNewWindow -PassThru
|
||||
|
||||
if (-not $process.WaitForExit($timeoutMilliseconds)) {
|
||||
Stop-ProcessTree -Process $process
|
||||
throw "$Name timed out after $TimeoutSeconds seconds"
|
||||
}
|
||||
|
||||
$exitCode = $process.ExitCode
|
||||
if ($exitCode -ne 0) {
|
||||
throw "$Name failed with exit code $exitCode"
|
||||
}
|
||||
} finally {
|
||||
Write-Host '::endgroup::'
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from harbor.agents.base import BaseAgent
|
||||
from harbor.environments.base import BaseEnvironment
|
||||
from harbor.models.agent.context import AgentContext
|
||||
|
||||
IN_CONTAINER_HOME = "/tmp/jcode-home"
|
||||
IN_CONTAINER_RUNTIME = "/tmp/jcode-runtime"
|
||||
IN_CONTAINER_INPUT = "/tmp/jcode-input"
|
||||
IN_CONTAINER_OUTPUT = "/tmp/jcode-output"
|
||||
IN_CONTAINER_BINARY = "/usr/local/bin/jcode"
|
||||
IN_CONTAINER_LIB_DIR = f"{IN_CONTAINER_RUNTIME}/lib"
|
||||
IN_CONTAINER_CA_BUNDLE = f"{IN_CONTAINER_HOME}/ca-certificates.crt"
|
||||
DEFAULT_BINARY_PATH = "/tmp/jcode-compat-dist/jcode-linux-x86_64"
|
||||
DEFAULT_OPENAI_AUTH_PATH = "~/.jcode/openai-auth.json"
|
||||
CA_BUNDLE_CANDIDATES = (
|
||||
os.environ.get("JCODE_HARBOR_CA_BUNDLE"),
|
||||
"/etc/ca-certificates/extracted/tls-ca-bundle.pem",
|
||||
"/etc/ssl/certs/ca-certificates.crt",
|
||||
)
|
||||
|
||||
|
||||
def _resolve_existing_file(*, env_name: str, default_path: str | None = None, candidates: tuple[str | None, ...] = ()) -> Path:
|
||||
raw_value = os.environ.get(env_name) or default_path
|
||||
values = [raw_value, *candidates] if raw_value is not None else list(candidates)
|
||||
checked: list[str] = []
|
||||
for value in values:
|
||||
if not value:
|
||||
continue
|
||||
candidate = Path(value).expanduser()
|
||||
checked.append(str(candidate))
|
||||
if candidate.exists() and candidate.is_file():
|
||||
return candidate.resolve()
|
||||
raise FileNotFoundError(f"Could not find a readable file for {env_name}. Checked: {checked}")
|
||||
|
||||
|
||||
def _resolve_optional_existing_file(*, candidates: tuple[str | None, ...]) -> Path | None:
|
||||
for value in candidates:
|
||||
if not value:
|
||||
continue
|
||||
candidate = Path(value).expanduser()
|
||||
if candidate.exists() and candidate.is_file():
|
||||
return candidate.resolve()
|
||||
return None
|
||||
|
||||
|
||||
def _sibling_runtime_lib_candidates(binary: Path, stem: str) -> tuple[str, ...]:
|
||||
return tuple(str(path) for path in sorted(binary.parent.glob(f"{stem}.so*")) if path.is_file())
|
||||
|
||||
|
||||
JCODE_BINARY = _resolve_existing_file(
|
||||
env_name="JCODE_HARBOR_BINARY",
|
||||
default_path=DEFAULT_BINARY_PATH,
|
||||
)
|
||||
OPENAI_AUTH = _resolve_existing_file(
|
||||
env_name="JCODE_HARBOR_OPENAI_AUTH",
|
||||
default_path=DEFAULT_OPENAI_AUTH_PATH,
|
||||
)
|
||||
CA_BUNDLE = _resolve_existing_file(
|
||||
env_name="JCODE_HARBOR_CA_BUNDLE",
|
||||
candidates=CA_BUNDLE_CANDIDATES,
|
||||
)
|
||||
OPENSSL_RUNTIME_LIBS = tuple(
|
||||
lib
|
||||
for lib in (
|
||||
_resolve_optional_existing_file(
|
||||
candidates=(
|
||||
os.environ.get("JCODE_HARBOR_LIBSSL"),
|
||||
*_sibling_runtime_lib_candidates(JCODE_BINARY, "libssl"),
|
||||
"/usr/lib/libssl.so.3",
|
||||
"/usr/lib/x86_64-linux-gnu/libssl.so.3",
|
||||
"/lib/x86_64-linux-gnu/libssl.so.3",
|
||||
"/usr/lib/libssl.so.1.1",
|
||||
"/usr/lib/x86_64-linux-gnu/libssl.so.1.1",
|
||||
"/lib/x86_64-linux-gnu/libssl.so.1.1",
|
||||
)
|
||||
),
|
||||
_resolve_optional_existing_file(
|
||||
candidates=(
|
||||
os.environ.get("JCODE_HARBOR_LIBCRYPTO"),
|
||||
*_sibling_runtime_lib_candidates(JCODE_BINARY, "libcrypto"),
|
||||
"/usr/lib/libcrypto.so.3",
|
||||
"/usr/lib/x86_64-linux-gnu/libcrypto.so.3",
|
||||
"/lib/x86_64-linux-gnu/libcrypto.so.3",
|
||||
"/usr/lib/libcrypto.so.1.1",
|
||||
"/usr/lib/x86_64-linux-gnu/libcrypto.so.1.1",
|
||||
"/lib/x86_64-linux-gnu/libcrypto.so.1.1",
|
||||
)
|
||||
),
|
||||
)
|
||||
if lib is not None
|
||||
)
|
||||
|
||||
LEGACY_BENCHMARK_INSTRUCTION_PREAMBLE = """You are operating inside an official Terminal-Bench evaluation environment.
|
||||
The user explicitly authorizes you to complete the benchmark task using the files, media, and URLs provided by the task.
|
||||
Do not access Terminal-Bench websites, Terminal-Bench GitHub repositories, leaderboard pages, public task solutions, or external benchmark answer sources.
|
||||
Do not refuse solely because the task involves copyrighted code, text, images, audio, or video when those materials are provided for this evaluation.
|
||||
Still refuse only actions that are clearly harmful, illegal, or outside the benchmark task.
|
||||
|
||||
Complete the task by changing the container state, not merely by explaining a solution.
|
||||
Before finishing, run the strongest local validation available from the prompt or workspace.
|
||||
If the task asks for a file, verify that exact path and contents exist.
|
||||
If the task asks for a server, VM, socket, or background process, leave it running and verify the specified client command can connect before finishing.
|
||||
Prefer deterministic, minimal solutions over long exploratory work.
|
||||
|
||||
Task instruction follows:
|
||||
|
||||
"""
|
||||
|
||||
|
||||
def _benchmark_instruction_preamble() -> str:
|
||||
# Keep Harbor runs aligned with normal TUI/jcode-run prompting by default.
|
||||
# The legacy preamble can still be enabled explicitly for reproducing older
|
||||
# runs, but new benchmark runs should rely on jcode's normal system prompt
|
||||
# and the official Terminal-Bench task instruction.
|
||||
if os.environ.get("JCODE_HARBOR_LEGACY_PREAMBLE"):
|
||||
return LEGACY_BENCHMARK_INSTRUCTION_PREAMBLE
|
||||
return os.environ.get("JCODE_HARBOR_EXTRA_PREAMBLE", "")
|
||||
|
||||
|
||||
def _load_task_hint() -> str:
|
||||
if not os.environ.get("JCODE_HARBOR_ENABLE_HINTS"):
|
||||
return ""
|
||||
task_name = os.environ.get("JCODE_HARBOR_CURRENT_TASK", "").strip()
|
||||
hints_path = os.environ.get("JCODE_HARBOR_TASK_HINTS_FILE", "").strip()
|
||||
extra = os.environ.get("JCODE_HARBOR_EXTRA_PREAMBLE", "").strip()
|
||||
parts: list[str] = []
|
||||
if extra:
|
||||
parts.append(extra)
|
||||
if task_name and hints_path:
|
||||
path = Path(hints_path).expanduser()
|
||||
try:
|
||||
hints = json.loads(path.read_text())
|
||||
except Exception: # noqa: BLE001
|
||||
hints = {}
|
||||
hint = hints.get(task_name) if isinstance(hints, dict) else None
|
||||
if isinstance(hint, str) and hint.strip():
|
||||
parts.append(hint.strip())
|
||||
if not parts:
|
||||
return ""
|
||||
return "\nAdditional benchmark retry guidance:\n" + "\n\n".join(parts) + "\n\n"
|
||||
|
||||
|
||||
def _load_final_payload(output_dir: Path) -> dict[str, Any] | None:
|
||||
result_json_path = output_dir / "result.json"
|
||||
if result_json_path.exists():
|
||||
raw = result_json_path.read_text()
|
||||
if raw.strip():
|
||||
return json.loads(raw)
|
||||
|
||||
events_path = output_dir / "events.ndjson"
|
||||
if not events_path.exists():
|
||||
return None
|
||||
|
||||
final_done: dict[str, Any] | None = None
|
||||
for line in events_path.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
event = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if event.get("type") == "done":
|
||||
final_done = event
|
||||
|
||||
if final_done is None:
|
||||
return None
|
||||
|
||||
payload = {
|
||||
"session_id": final_done.get("session_id"),
|
||||
"provider": final_done.get("provider"),
|
||||
"model": final_done.get("model"),
|
||||
"text": final_done.get("text", ""),
|
||||
"usage": final_done.get("usage") or {},
|
||||
}
|
||||
result_json_path.write_text(json.dumps(payload, indent=2) + "\n")
|
||||
return payload
|
||||
|
||||
|
||||
class JcodeHarborAgent(BaseAgent):
|
||||
def __init__(self, logs_dir: Path, model_name: str | None = None, *args, **kwargs):
|
||||
super().__init__(logs_dir, model_name, *args, **kwargs)
|
||||
self._model_arg = model_name or "openai/gpt-5.4"
|
||||
if "/" in self._model_arg:
|
||||
self._provider_arg, self._jcode_model = self._model_arg.split("/", 1)
|
||||
else:
|
||||
self._provider_arg, self._jcode_model = "openai", self._model_arg
|
||||
|
||||
@staticmethod
|
||||
def name() -> str:
|
||||
return "jcode-harbor"
|
||||
|
||||
def version(self) -> str | None:
|
||||
return "compat-openai-oauth"
|
||||
|
||||
async def setup(self, environment: BaseEnvironment) -> None:
|
||||
await environment.exec(
|
||||
(
|
||||
"mkdir -p "
|
||||
f"{IN_CONTAINER_HOME} {IN_CONTAINER_RUNTIME} {IN_CONTAINER_INPUT} {IN_CONTAINER_OUTPUT} "
|
||||
f"{IN_CONTAINER_LIB_DIR} /usr/local/bin /usr/lib/ssl && "
|
||||
f"ln -snf {IN_CONTAINER_HOME} /usr/lib/ssl/certs"
|
||||
),
|
||||
timeout_sec=30,
|
||||
)
|
||||
await environment.upload_file(JCODE_BINARY, IN_CONTAINER_BINARY)
|
||||
await environment.exec(f"chmod +x {IN_CONTAINER_BINARY}", timeout_sec=30)
|
||||
for lib in OPENSSL_RUNTIME_LIBS:
|
||||
await environment.upload_file(lib, f"{IN_CONTAINER_LIB_DIR}/{lib.name}")
|
||||
await environment.upload_file(OPENAI_AUTH, f"{IN_CONTAINER_HOME}/openai-auth.json")
|
||||
await environment.upload_file(CA_BUNDLE, IN_CONTAINER_CA_BUNDLE)
|
||||
version_result = await environment.exec(
|
||||
f"{IN_CONTAINER_BINARY} --quiet --no-update --no-selfdev version --json",
|
||||
env={
|
||||
"HOME": IN_CONTAINER_HOME,
|
||||
"JCODE_HOME": IN_CONTAINER_HOME,
|
||||
"JCODE_RUNTIME_DIR": IN_CONTAINER_RUNTIME,
|
||||
"JCODE_NO_TELEMETRY": "1",
|
||||
"LD_LIBRARY_PATH": IN_CONTAINER_LIB_DIR,
|
||||
},
|
||||
timeout_sec=60,
|
||||
)
|
||||
(self.logs_dir / "setup_version.json").write_text(version_result.stdout or "")
|
||||
(self.logs_dir / "setup_version.stderr.txt").write_text(version_result.stderr or "")
|
||||
(self.logs_dir / "setup_version.return_code.txt").write_text(str(version_result.return_code))
|
||||
|
||||
async def run(self, instruction: str, environment: BaseEnvironment, context: AgentContext) -> None:
|
||||
self.logs_dir.mkdir(parents=True, exist_ok=True)
|
||||
benchmark_instruction = f"{_benchmark_instruction_preamble()}{_load_task_hint()}{instruction}"
|
||||
local_instruction = self.logs_dir / "instruction.txt"
|
||||
local_instruction.write_text(benchmark_instruction)
|
||||
await environment.upload_file(local_instruction, f"{IN_CONTAINER_INPUT}/instruction.txt")
|
||||
|
||||
env = {
|
||||
"HOME": IN_CONTAINER_HOME,
|
||||
"JCODE_HOME": IN_CONTAINER_HOME,
|
||||
"JCODE_RUNTIME_DIR": IN_CONTAINER_RUNTIME,
|
||||
"JCODE_NO_TELEMETRY": "1",
|
||||
"JCODE_PROVIDER": self._provider_arg,
|
||||
"JCODE_MODEL": self._jcode_model,
|
||||
"JCODE_OPENAI_REASONING_EFFORT": os.environ.get("JCODE_OPENAI_REASONING_EFFORT", "high"),
|
||||
"JCODE_OPENAI_SERVICE_TIER": os.environ.get("JCODE_OPENAI_SERVICE_TIER", "priority"),
|
||||
"SSL_CERT_FILE": IN_CONTAINER_CA_BUNDLE,
|
||||
"OPENSSL_CERT_FILE": IN_CONTAINER_CA_BUNDLE,
|
||||
"LD_LIBRARY_PATH": IN_CONTAINER_LIB_DIR,
|
||||
}
|
||||
|
||||
result = await environment.exec(
|
||||
command=(
|
||||
'set -e; '
|
||||
'workdir="${JCODE_TASK_WORKDIR:-}"; '
|
||||
'if [ -z "$workdir" ]; then '
|
||||
' if [ -d /app ]; then workdir=/app; else workdir="$(pwd)"; fi; '
|
||||
'fi; '
|
||||
f'instruction="$(cat {IN_CONTAINER_INPUT}/instruction.txt)"; '
|
||||
f'{IN_CONTAINER_BINARY} --quiet --no-update --no-selfdev '
|
||||
'--provider "$JCODE_PROVIDER" --model "$JCODE_MODEL" '
|
||||
'-C "$workdir" run --ndjson "$instruction" '
|
||||
f'> {IN_CONTAINER_OUTPUT}/events.ndjson 2> {IN_CONTAINER_OUTPUT}/stderr.txt'
|
||||
),
|
||||
env=env
|
||||
)
|
||||
|
||||
(self.logs_dir / "exec_stdout.txt").write_text(result.stdout or "")
|
||||
(self.logs_dir / "exec_stderr.txt").write_text(result.stderr or "")
|
||||
(self.logs_dir / "exec_return_code.txt").write_text(str(result.return_code))
|
||||
|
||||
try:
|
||||
await environment.download_dir(IN_CONTAINER_OUTPUT, self.logs_dir / "jcode-output")
|
||||
except Exception as e: # noqa: BLE001
|
||||
(self.logs_dir / "download_error.txt").write_text(str(e))
|
||||
|
||||
metadata: dict[str, Any] = {
|
||||
"return_code": result.return_code,
|
||||
"provider": self._provider_arg,
|
||||
"model": self._jcode_model,
|
||||
"jcode_binary": str(JCODE_BINARY),
|
||||
}
|
||||
|
||||
output_dir = self.logs_dir / "jcode-output"
|
||||
payload = _load_final_payload(output_dir)
|
||||
if payload is not None:
|
||||
usage = payload.get("usage") or {}
|
||||
context.n_input_tokens = usage.get("input_tokens")
|
||||
context.n_output_tokens = usage.get("output_tokens")
|
||||
cache_read = usage.get("cache_read_input_tokens")
|
||||
cache_create = usage.get("cache_creation_input_tokens")
|
||||
if isinstance(cache_read, int) and isinstance(cache_create, int):
|
||||
context.n_cache_tokens = cache_read + cache_create
|
||||
elif isinstance(cache_read, int):
|
||||
context.n_cache_tokens = cache_read
|
||||
metadata["jcode_result"] = payload
|
||||
|
||||
result_json_path = output_dir / "result.json"
|
||||
if payload is None and result_json_path.exists():
|
||||
raw = result_json_path.read_text()
|
||||
if raw.strip():
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
usage = payload.get("usage") or {}
|
||||
context.n_input_tokens = usage.get("input_tokens")
|
||||
context.n_output_tokens = usage.get("output_tokens")
|
||||
cache_read = usage.get("cache_read_input_tokens")
|
||||
cache_create = usage.get("cache_creation_input_tokens")
|
||||
if isinstance(cache_read, int) and isinstance(cache_create, int):
|
||||
context.n_cache_tokens = cache_read + cache_create
|
||||
elif isinstance(cache_read, int):
|
||||
context.n_cache_tokens = cache_read
|
||||
metadata["jcode_result"] = payload
|
||||
except Exception as e: # noqa: BLE001
|
||||
metadata["result_parse_error"] = str(e)
|
||||
metadata["raw_result_prefix"] = raw[:1000]
|
||||
|
||||
context.metadata = metadata
|
||||
@@ -0,0 +1,329 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from harbor.agents.base import BaseAgent
|
||||
from harbor.environments.base import BaseEnvironment
|
||||
from harbor.models.agent.context import AgentContext
|
||||
|
||||
IN_CONTAINER_HOME = "/tmp/jcode-home"
|
||||
IN_CONTAINER_RUNTIME = "/tmp/jcode-runtime"
|
||||
IN_CONTAINER_INPUT = "/tmp/jcode-input"
|
||||
IN_CONTAINER_OUTPUT = "/tmp/jcode-output"
|
||||
IN_CONTAINER_BINARY = "/usr/local/bin/jcode"
|
||||
IN_CONTAINER_LIB_DIR = f"{IN_CONTAINER_RUNTIME}/lib"
|
||||
IN_CONTAINER_CA_BUNDLE = f"{IN_CONTAINER_HOME}/ca-certificates.crt"
|
||||
DEFAULT_BINARY_PATH = "/tmp/jcode-compat-dist/jcode-linux-x86_64.bin"
|
||||
DEFAULT_CLAUDE_AUTH_PATH = "~/.jcode/auth.json"
|
||||
DEFAULT_OPENROUTER_ENV_PATH = "~/.config/jcode/openrouter.env"
|
||||
DEFAULT_ANTHROPIC_ENV_PATH = "~/.config/jcode/anthropic.env"
|
||||
CA_BUNDLE_CANDIDATES = (
|
||||
os.environ.get("JCODE_HARBOR_CA_BUNDLE"),
|
||||
"/etc/ca-certificates/extracted/tls-ca-bundle.pem",
|
||||
"/etc/ssl/certs/ca-certificates.crt",
|
||||
)
|
||||
|
||||
|
||||
def _resolve_existing_file(*, env_name: str, default_path: str | None = None, candidates: tuple[str | None, ...] = ()) -> Path:
|
||||
raw_value = os.environ.get(env_name) or default_path
|
||||
values = [raw_value, *candidates] if raw_value is not None else list(candidates)
|
||||
checked: list[str] = []
|
||||
for value in values:
|
||||
if not value:
|
||||
continue
|
||||
candidate = Path(value).expanduser()
|
||||
checked.append(str(candidate))
|
||||
if candidate.exists() and candidate.is_file():
|
||||
return candidate.resolve()
|
||||
raise FileNotFoundError(f"Could not find a readable file for {env_name}. Checked: {checked}")
|
||||
|
||||
|
||||
def _resolve_optional_existing_file(*, candidates: tuple[str | None, ...]) -> Path | None:
|
||||
for value in candidates:
|
||||
if not value:
|
||||
continue
|
||||
candidate = Path(value).expanduser()
|
||||
if candidate.exists() and candidate.is_file():
|
||||
return candidate.resolve()
|
||||
return None
|
||||
|
||||
|
||||
def _sibling_runtime_lib_candidates(binary: Path, stem: str) -> tuple[str, ...]:
|
||||
return tuple(str(path) for path in sorted(binary.parent.glob(f"{stem}.so*")) if path.is_file())
|
||||
|
||||
|
||||
def _load_key_from_env_file(env_path: str, env_var: str, *direct_env: str) -> str | None:
|
||||
for name in direct_env:
|
||||
value = os.environ.get(name)
|
||||
if value and value.strip():
|
||||
return value.strip()
|
||||
path = Path(env_path).expanduser()
|
||||
if path.exists() and path.is_file():
|
||||
for line in path.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if "=" in line:
|
||||
key, _, value = line.partition("=")
|
||||
if key.strip() != env_var:
|
||||
continue
|
||||
value = value.strip().strip('"').strip("'")
|
||||
else:
|
||||
value = line
|
||||
if value:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _load_anthropic_key() -> str | None:
|
||||
return _load_key_from_env_file(
|
||||
os.environ.get("JCODE_HARBOR_ANTHROPIC_ENV", DEFAULT_ANTHROPIC_ENV_PATH),
|
||||
"ANTHROPIC_API_KEY",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"JCODE_HARBOR_ANTHROPIC_KEY",
|
||||
)
|
||||
|
||||
|
||||
def _load_openrouter_key() -> str | None:
|
||||
# Priority: explicit env, then the jcode openrouter.env file (raw key per line).
|
||||
direct = os.environ.get("OPENROUTER_API_KEY") or os.environ.get("JCODE_HARBOR_OPENROUTER_KEY")
|
||||
if direct and direct.strip():
|
||||
return direct.strip()
|
||||
path = Path(os.environ.get("JCODE_HARBOR_OPENROUTER_ENV", DEFAULT_OPENROUTER_ENV_PATH)).expanduser()
|
||||
if path.exists() and path.is_file():
|
||||
for line in path.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
# Support both "KEY=value" and bare "value" formats.
|
||||
if "=" in line:
|
||||
_, _, value = line.partition("=")
|
||||
value = value.strip().strip('"').strip("'")
|
||||
else:
|
||||
value = line
|
||||
if value:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
JCODE_BINARY = _resolve_existing_file(
|
||||
env_name="JCODE_HARBOR_BINARY",
|
||||
default_path=DEFAULT_BINARY_PATH,
|
||||
)
|
||||
CA_BUNDLE = _resolve_existing_file(
|
||||
env_name="JCODE_HARBOR_CA_BUNDLE",
|
||||
candidates=CA_BUNDLE_CANDIDATES,
|
||||
)
|
||||
OPENSSL_RUNTIME_LIBS = tuple(
|
||||
lib
|
||||
for lib in (
|
||||
_resolve_optional_existing_file(
|
||||
candidates=(
|
||||
os.environ.get("JCODE_HARBOR_LIBSSL"),
|
||||
*_sibling_runtime_lib_candidates(JCODE_BINARY, "libssl"),
|
||||
"/usr/lib/libssl.so.3",
|
||||
"/usr/lib/x86_64-linux-gnu/libssl.so.3",
|
||||
"/lib/x86_64-linux-gnu/libssl.so.3",
|
||||
)
|
||||
),
|
||||
_resolve_optional_existing_file(
|
||||
candidates=(
|
||||
os.environ.get("JCODE_HARBOR_LIBCRYPTO"),
|
||||
*_sibling_runtime_lib_candidates(JCODE_BINARY, "libcrypto"),
|
||||
"/usr/lib/libcrypto.so.3",
|
||||
"/usr/lib/x86_64-linux-gnu/libcrypto.so.3",
|
||||
"/lib/x86_64-linux-gnu/libcrypto.so.3",
|
||||
)
|
||||
),
|
||||
)
|
||||
if lib is not None
|
||||
)
|
||||
|
||||
|
||||
def _benchmark_instruction_preamble() -> str:
|
||||
return os.environ.get("JCODE_HARBOR_EXTRA_PREAMBLE", "")
|
||||
|
||||
|
||||
def _load_final_payload(output_dir: Path) -> dict[str, Any] | None:
|
||||
result_json_path = output_dir / "result.json"
|
||||
if result_json_path.exists():
|
||||
raw = result_json_path.read_text()
|
||||
if raw.strip():
|
||||
return json.loads(raw)
|
||||
|
||||
events_path = output_dir / "events.ndjson"
|
||||
if not events_path.exists():
|
||||
return None
|
||||
|
||||
final_done: dict[str, Any] | None = None
|
||||
for line in events_path.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
event = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if event.get("type") == "done":
|
||||
final_done = event
|
||||
|
||||
if final_done is None:
|
||||
return None
|
||||
|
||||
payload = {
|
||||
"session_id": final_done.get("session_id"),
|
||||
"provider": final_done.get("provider"),
|
||||
"model": final_done.get("model"),
|
||||
"text": final_done.get("text", ""),
|
||||
"usage": final_done.get("usage") or {},
|
||||
}
|
||||
result_json_path.write_text(json.dumps(payload, indent=2) + "\n")
|
||||
return payload
|
||||
|
||||
|
||||
class JcodeClaudeHarborAgent(BaseAgent):
|
||||
"""Harbor adapter that runs jcode with Opus 4.8.
|
||||
|
||||
Default route is the native Anthropic API (provider=anthropic-api,
|
||||
model=claude-opus-4-8) using ANTHROPIC_API_KEY. OpenRouter
|
||||
(provider=openrouter, model=anthropic/claude-opus-4.8) and native Claude
|
||||
OAuth (provider=claude via ~/.jcode/auth.json) are also supported.
|
||||
"""
|
||||
|
||||
def __init__(self, logs_dir: Path, model_name: str | None = None, *args, **kwargs):
|
||||
super().__init__(logs_dir, model_name, *args, **kwargs)
|
||||
self._model_arg = model_name or "anthropic-api/claude-opus-4-8"
|
||||
if "/" in self._model_arg:
|
||||
self._provider_arg, self._jcode_model = self._model_arg.split("/", 1)
|
||||
else:
|
||||
self._provider_arg, self._jcode_model = "anthropic-api", self._model_arg
|
||||
self._openrouter_key = _load_openrouter_key() if self._provider_arg == "openrouter" else None
|
||||
self._anthropic_key = _load_anthropic_key() if self._provider_arg == "anthropic-api" else None
|
||||
self._claude_auth: Path | None = None
|
||||
if self._provider_arg == "claude":
|
||||
self._claude_auth = _resolve_existing_file(
|
||||
env_name="JCODE_HARBOR_CLAUDE_AUTH",
|
||||
default_path=DEFAULT_CLAUDE_AUTH_PATH,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def name() -> str:
|
||||
return "jcode-harbor-claude"
|
||||
|
||||
def version(self) -> str | None:
|
||||
return "compat-opus-4-8"
|
||||
|
||||
async def setup(self, environment: BaseEnvironment) -> None:
|
||||
# NOTE: Do NOT symlink/override the container's system OpenSSL cert dir
|
||||
# (e.g. ln -s ... /usr/lib/ssl/certs). The TB base images are often bare
|
||||
# ubuntu:24.04 with no /etc/ssl/certs, and the per-task verifier runs
|
||||
# `apt-get install ca-certificates curl` then bootstraps uv over https.
|
||||
# Hijacking the system cert dir breaks the verifier's curl (error 77,
|
||||
# "error setting certificate file") so tests never run. jcode itself
|
||||
# gets its CA bundle via SSL_CERT_FILE/OPENSSL_CERT_FILE below, so no
|
||||
# global cert override is needed.
|
||||
await environment.exec(
|
||||
(
|
||||
"mkdir -p "
|
||||
f"{IN_CONTAINER_HOME} {IN_CONTAINER_RUNTIME} {IN_CONTAINER_INPUT} {IN_CONTAINER_OUTPUT} "
|
||||
f"{IN_CONTAINER_LIB_DIR} /usr/local/bin"
|
||||
),
|
||||
timeout_sec=30,
|
||||
)
|
||||
await environment.upload_file(JCODE_BINARY, IN_CONTAINER_BINARY)
|
||||
await environment.exec(f"chmod +x {IN_CONTAINER_BINARY}", timeout_sec=30)
|
||||
for lib in OPENSSL_RUNTIME_LIBS:
|
||||
await environment.upload_file(lib, f"{IN_CONTAINER_LIB_DIR}/{lib.name}")
|
||||
if self._claude_auth is not None:
|
||||
await environment.upload_file(self._claude_auth, f"{IN_CONTAINER_HOME}/auth.json")
|
||||
await environment.exec(f"chmod 600 {IN_CONTAINER_HOME}/auth.json", timeout_sec=30)
|
||||
await environment.upload_file(CA_BUNDLE, IN_CONTAINER_CA_BUNDLE)
|
||||
version_result = await environment.exec(
|
||||
f"{IN_CONTAINER_BINARY} --quiet --no-update --no-selfdev version --json",
|
||||
env={
|
||||
"HOME": IN_CONTAINER_HOME,
|
||||
"JCODE_HOME": IN_CONTAINER_HOME,
|
||||
"JCODE_RUNTIME_DIR": IN_CONTAINER_RUNTIME,
|
||||
"JCODE_NO_TELEMETRY": "1",
|
||||
"LD_LIBRARY_PATH": IN_CONTAINER_LIB_DIR,
|
||||
},
|
||||
timeout_sec=60,
|
||||
)
|
||||
(self.logs_dir / "setup_version.json").write_text(version_result.stdout or "")
|
||||
(self.logs_dir / "setup_version.stderr.txt").write_text(version_result.stderr or "")
|
||||
(self.logs_dir / "setup_version.return_code.txt").write_text(str(version_result.return_code))
|
||||
|
||||
async def run(self, instruction: str, environment: BaseEnvironment, context: AgentContext) -> None:
|
||||
self.logs_dir.mkdir(parents=True, exist_ok=True)
|
||||
benchmark_instruction = f"{_benchmark_instruction_preamble()}{instruction}"
|
||||
local_instruction = self.logs_dir / "instruction.txt"
|
||||
local_instruction.write_text(benchmark_instruction)
|
||||
await environment.upload_file(local_instruction, f"{IN_CONTAINER_INPUT}/instruction.txt")
|
||||
|
||||
env = {
|
||||
"HOME": IN_CONTAINER_HOME,
|
||||
"JCODE_HOME": IN_CONTAINER_HOME,
|
||||
"JCODE_RUNTIME_DIR": IN_CONTAINER_RUNTIME,
|
||||
"JCODE_NO_TELEMETRY": "1",
|
||||
"JCODE_PROVIDER": self._provider_arg,
|
||||
"JCODE_MODEL": self._jcode_model,
|
||||
"JCODE_ANTHROPIC_REASONING_EFFORT": os.environ.get("JCODE_ANTHROPIC_REASONING_EFFORT", "high"),
|
||||
"SSL_CERT_FILE": IN_CONTAINER_CA_BUNDLE,
|
||||
"OPENSSL_CERT_FILE": IN_CONTAINER_CA_BUNDLE,
|
||||
"LD_LIBRARY_PATH": IN_CONTAINER_LIB_DIR,
|
||||
}
|
||||
if self._openrouter_key:
|
||||
env["OPENROUTER_API_KEY"] = self._openrouter_key
|
||||
if self._anthropic_key:
|
||||
env["ANTHROPIC_API_KEY"] = self._anthropic_key
|
||||
|
||||
result = await environment.exec(
|
||||
command=(
|
||||
'set -e; '
|
||||
'workdir="${JCODE_TASK_WORKDIR:-}"; '
|
||||
'if [ -z "$workdir" ]; then '
|
||||
' if [ -d /app ]; then workdir=/app; else workdir="$(pwd)"; fi; '
|
||||
'fi; '
|
||||
f'instruction="$(cat {IN_CONTAINER_INPUT}/instruction.txt)"; '
|
||||
f'{IN_CONTAINER_BINARY} --quiet --no-update --no-selfdev '
|
||||
'--provider "$JCODE_PROVIDER" --model "$JCODE_MODEL" '
|
||||
'-C "$workdir" run --ndjson "$instruction" '
|
||||
f'> {IN_CONTAINER_OUTPUT}/events.ndjson 2> {IN_CONTAINER_OUTPUT}/stderr.txt'
|
||||
),
|
||||
env=env
|
||||
)
|
||||
|
||||
(self.logs_dir / "exec_stdout.txt").write_text(result.stdout or "")
|
||||
(self.logs_dir / "exec_stderr.txt").write_text(result.stderr or "")
|
||||
(self.logs_dir / "exec_return_code.txt").write_text(str(result.return_code))
|
||||
|
||||
try:
|
||||
await environment.download_dir(IN_CONTAINER_OUTPUT, self.logs_dir / "jcode-output")
|
||||
except Exception as e: # noqa: BLE001
|
||||
(self.logs_dir / "download_error.txt").write_text(str(e))
|
||||
|
||||
metadata: dict[str, Any] = {
|
||||
"return_code": result.return_code,
|
||||
"provider": self._provider_arg,
|
||||
"model": self._jcode_model,
|
||||
"jcode_binary": str(JCODE_BINARY),
|
||||
}
|
||||
|
||||
output_dir = self.logs_dir / "jcode-output"
|
||||
payload = _load_final_payload(output_dir)
|
||||
if payload is not None:
|
||||
usage = payload.get("usage") or {}
|
||||
context.n_input_tokens = usage.get("input_tokens")
|
||||
context.n_output_tokens = usage.get("output_tokens")
|
||||
cache_read = usage.get("cache_read_input_tokens")
|
||||
cache_create = usage.get("cache_creation_input_tokens")
|
||||
if isinstance(cache_read, int) and isinstance(cache_create, int):
|
||||
context.n_cache_tokens = cache_read + cache_create
|
||||
elif isinstance(cache_read, int):
|
||||
context.n_cache_tokens = cache_read
|
||||
metadata["jcode_result"] = payload
|
||||
|
||||
context.metadata = metadata
|
||||
@@ -0,0 +1,260 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
DEFAULT_SOCKET = f"/run/user/{os.getuid()}/jcode.sock"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProcMem:
|
||||
pid: int
|
||||
role: str
|
||||
cmd: str
|
||||
session_id: str | None
|
||||
socket_path: str | None
|
||||
rss_mb: float
|
||||
pss_mb: float
|
||||
anon_mb: float
|
||||
shared_clean_mb: float
|
||||
private_clean_mb: float
|
||||
private_dirty_mb: float
|
||||
swap_mb: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class Totals:
|
||||
count: int
|
||||
rss_mb: float
|
||||
pss_mb: float
|
||||
anon_mb: float
|
||||
private_dirty_mb: float
|
||||
shared_clean_mb: float
|
||||
swap_mb: float
|
||||
|
||||
|
||||
SMAPS_KEYS = {
|
||||
"Rss": "rss_mb",
|
||||
"Pss": "pss_mb",
|
||||
"Anonymous": "anon_mb",
|
||||
"Shared_Clean": "shared_clean_mb",
|
||||
"Private_Clean": "private_clean_mb",
|
||||
"Private_Dirty": "private_dirty_mb",
|
||||
"Swap": "swap_mb",
|
||||
}
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
p = argparse.ArgumentParser(description="Summarize jcode server/client process memory using smaps_rollup")
|
||||
p.add_argument("--json", action="store_true", help="Print JSON instead of a human summary")
|
||||
p.add_argument(
|
||||
"--include-aux",
|
||||
action="store_true",
|
||||
help="Include non-default-socket helper/test jcode processes in the output",
|
||||
)
|
||||
p.add_argument(
|
||||
"--socket",
|
||||
default=DEFAULT_SOCKET,
|
||||
help=f"Main jcode socket to treat as the primary instance (default: {DEFAULT_SOCKET})",
|
||||
)
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
def read_text(path: Path, binary: bool = False) -> str | None:
|
||||
try:
|
||||
if binary:
|
||||
return path.read_bytes().replace(b"\x00", b" ").decode("utf-8", "ignore").strip()
|
||||
return path.read_text().strip()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def read_argv(path: Path) -> list[str] | None:
|
||||
try:
|
||||
raw = path.read_bytes()
|
||||
except Exception:
|
||||
return None
|
||||
if not raw:
|
||||
return None
|
||||
return [part.decode("utf-8", "ignore") for part in raw.split(b"\x00") if part]
|
||||
|
||||
|
||||
def parse_socket_path(cmd: str) -> str | None:
|
||||
m = re.search(r"(?:^| )--socket\s+(\S+)", cmd)
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def parse_session_id(cmd: str) -> str | None:
|
||||
m = re.search(r"--resume\s+(session_[^\s]+)", cmd)
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def first_non_option(argv: list[str]) -> str | None:
|
||||
skip_next = False
|
||||
for arg in argv[1:]:
|
||||
if skip_next:
|
||||
skip_next = False
|
||||
continue
|
||||
if arg in {"--socket", "--resume", "-C", "--chdir", "--model", "--provider"}:
|
||||
skip_next = True
|
||||
continue
|
||||
if arg.startswith("-"):
|
||||
continue
|
||||
return arg
|
||||
return None
|
||||
|
||||
|
||||
def classify_process(argv: list[str], cmd: str, main_socket: str) -> tuple[str | None, bool]:
|
||||
argv0 = Path(argv[0]).name if argv else ""
|
||||
if not argv0.startswith("jcode"):
|
||||
return None, False
|
||||
|
||||
if "jcode serve" in cmd:
|
||||
socket_path = parse_socket_path(cmd) or main_socket
|
||||
if socket_path == main_socket:
|
||||
return "server", True
|
||||
return "server_aux", False
|
||||
|
||||
socket_path = parse_socket_path(cmd) or main_socket
|
||||
is_main = socket_path == main_socket
|
||||
|
||||
if "cargo build" in cmd or "rustc --crate-name jcode" in cmd or "handle_resume_session" in cmd:
|
||||
return None, False
|
||||
|
||||
subcommand = first_non_option(argv)
|
||||
if subcommand in {"auth", "login", "logout", "serve", "self-dev"} and "--resume session_" not in cmd:
|
||||
if subcommand == "self-dev" and "--resume session_" in cmd:
|
||||
pass
|
||||
else:
|
||||
return None, False
|
||||
|
||||
if "--resume session_" in cmd or " --fresh-spawn " in cmd:
|
||||
return ("client_session" if is_main else "client_aux"), is_main
|
||||
|
||||
return ("client_interactive" if is_main else "client_aux"), is_main
|
||||
|
||||
|
||||
def parse_smaps_rollup(pid: int) -> dict[str, float] | None:
|
||||
path = Path(f"/proc/{pid}/smaps_rollup")
|
||||
txt = read_text(path)
|
||||
if not txt:
|
||||
return None
|
||||
out = {value: 0.0 for value in SMAPS_KEYS.values()}
|
||||
for line in txt.splitlines():
|
||||
for key, out_key in SMAPS_KEYS.items():
|
||||
if line.startswith(f"{key}:"):
|
||||
parts = line.split()
|
||||
if len(parts) >= 2:
|
||||
out[out_key] = round(int(parts[1]) / 1024.0, 1)
|
||||
return out
|
||||
|
||||
|
||||
def iter_jcode_processes(main_socket: str, include_aux: bool) -> Iterable[ProcMem]:
|
||||
for pid_dir in Path("/proc").iterdir():
|
||||
if not pid_dir.name.isdigit():
|
||||
continue
|
||||
pid = int(pid_dir.name)
|
||||
argv = read_argv(pid_dir / "cmdline")
|
||||
if not argv:
|
||||
continue
|
||||
cmd = " ".join(argv)
|
||||
if "jcode" not in cmd:
|
||||
continue
|
||||
role, is_main = classify_process(argv, cmd, main_socket)
|
||||
if role is None:
|
||||
continue
|
||||
if not include_aux and not is_main and role not in {"server"}:
|
||||
continue
|
||||
smaps = parse_smaps_rollup(pid)
|
||||
if not smaps:
|
||||
continue
|
||||
yield ProcMem(
|
||||
pid=pid,
|
||||
role=role,
|
||||
cmd=cmd,
|
||||
session_id=parse_session_id(cmd),
|
||||
socket_path=parse_socket_path(cmd),
|
||||
**smaps,
|
||||
)
|
||||
|
||||
|
||||
def sum_totals(procs: list[ProcMem]) -> Totals:
|
||||
return Totals(
|
||||
count=len(procs),
|
||||
rss_mb=round(sum(p.rss_mb for p in procs), 1),
|
||||
pss_mb=round(sum(p.pss_mb for p in procs), 1),
|
||||
anon_mb=round(sum(p.anon_mb for p in procs), 1),
|
||||
private_dirty_mb=round(sum(p.private_dirty_mb for p in procs), 1),
|
||||
shared_clean_mb=round(sum(p.shared_clean_mb for p in procs), 1),
|
||||
swap_mb=round(sum(p.swap_mb for p in procs), 1),
|
||||
)
|
||||
|
||||
|
||||
def print_human(server: list[ProcMem], clients: list[ProcMem], aux: list[ProcMem]) -> None:
|
||||
def print_group(name: str, procs: list[ProcMem]) -> None:
|
||||
totals = sum_totals(procs)
|
||||
print(f"\n{name} ({totals.count})")
|
||||
print("-" * len(f"{name} ({totals.count})"))
|
||||
print(
|
||||
f"total: RSS {totals.rss_mb:.1f} MB | PSS {totals.pss_mb:.1f} MB | "
|
||||
f"Anon {totals.anon_mb:.1f} MB | Private dirty {totals.private_dirty_mb:.1f} MB"
|
||||
)
|
||||
for p in sorted(procs, key=lambda x: (x.role, -x.pss_mb, x.pid)):
|
||||
label = p.session_id or p.role
|
||||
print(
|
||||
f" pid {p.pid:<7} {label:<48} RSS {p.rss_mb:>6.1f} | PSS {p.pss_mb:>6.1f} | "
|
||||
f"Anon {p.anon_mb:>6.1f} | PrivDirty {p.private_dirty_mb:>6.1f}"
|
||||
)
|
||||
|
||||
print_group("Server", server)
|
||||
print_group("Clients", clients)
|
||||
if aux:
|
||||
print_group("Auxiliary", aux)
|
||||
|
||||
grand = sum_totals(server + clients)
|
||||
print("\nPrimary total (server + clients)")
|
||||
print("--------------------------------")
|
||||
print(
|
||||
f"RSS {grand.rss_mb:.1f} MB | PSS {grand.pss_mb:.1f} MB | "
|
||||
f"Anon {grand.anon_mb:.1f} MB | Private dirty {grand.private_dirty_mb:.1f} MB | "
|
||||
f"Shared clean {grand.shared_clean_mb:.1f} MB"
|
||||
)
|
||||
print(
|
||||
f"Overcount if you sum RSS instead of PSS: {round(grand.rss_mb - grand.pss_mb, 1):.1f} MB"
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
procs = list(iter_jcode_processes(args.socket, args.include_aux))
|
||||
server = [p for p in procs if p.role == "server"]
|
||||
clients = [p for p in procs if p.role.startswith("client_") and p.role != "client_aux"]
|
||||
aux = [p for p in procs if p.role.endswith("aux")]
|
||||
|
||||
if args.json:
|
||||
payload = {
|
||||
"socket": args.socket,
|
||||
"server": [asdict(p) for p in sorted(server, key=lambda x: x.pid)],
|
||||
"clients": [asdict(p) for p in sorted(clients, key=lambda x: x.pid)],
|
||||
"auxiliary": [asdict(p) for p in sorted(aux, key=lambda x: x.pid)],
|
||||
"totals": {
|
||||
"server": asdict(sum_totals(server)),
|
||||
"clients": asdict(sum_totals(clients)),
|
||||
"primary": asdict(sum_totals(server + clients)),
|
||||
"auxiliary": asdict(sum_totals(aux)),
|
||||
},
|
||||
}
|
||||
print(json.dumps(payload, indent=2))
|
||||
else:
|
||||
print_human(server, clients, aux)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+341
@@ -0,0 +1,341 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
jcode Live Monitor - Real-time activity dashboard
|
||||
|
||||
Connects to jcode's debug socket and displays live streaming events.
|
||||
Run jcode serve in one terminal, then this monitor in another.
|
||||
|
||||
Usage: ./jcode_monitor.py [--socket PATH]
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
# ANSI color codes
|
||||
class Colors:
|
||||
RESET = "\033[0m"
|
||||
BOLD = "\033[1m"
|
||||
DIM = "\033[2m"
|
||||
|
||||
RED = "\033[31m"
|
||||
GREEN = "\033[32m"
|
||||
YELLOW = "\033[33m"
|
||||
BLUE = "\033[34m"
|
||||
MAGENTA = "\033[35m"
|
||||
CYAN = "\033[36m"
|
||||
WHITE = "\033[37m"
|
||||
|
||||
BG_BLUE = "\033[44m"
|
||||
|
||||
# Clear screen and move cursor
|
||||
CLEAR = "\033[2J\033[H"
|
||||
CLEAR_LINE = "\033[2K"
|
||||
|
||||
|
||||
@dataclass
|
||||
class MonitorState:
|
||||
"""Current state of the monitor"""
|
||||
connected: bool = False
|
||||
session_id: str = ""
|
||||
is_processing: bool = False
|
||||
input_tokens: int = 0
|
||||
output_tokens: int = 0
|
||||
current_text: str = ""
|
||||
active_tools: dict = field(default_factory=dict) # id -> name
|
||||
tool_history: list = field(default_factory=list) # recent tool completions
|
||||
events_received: int = 0
|
||||
last_event_time: float = 0
|
||||
errors: list = field(default_factory=list)
|
||||
|
||||
|
||||
def get_socket_path() -> str:
|
||||
"""Get the jcode debug socket path"""
|
||||
runtime_dir = os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")
|
||||
return os.path.join(runtime_dir, "jcode-debug.sock")
|
||||
|
||||
|
||||
def connect_to_socket(path: str) -> Optional[socket.socket]:
|
||||
"""Connect to the Unix socket"""
|
||||
try:
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
sock.connect(path)
|
||||
sock.setblocking(False)
|
||||
return sock
|
||||
except Exception as e:
|
||||
return None
|
||||
|
||||
|
||||
def send_request(sock: socket.socket, request: dict) -> bool:
|
||||
"""Send a JSON request to the socket"""
|
||||
try:
|
||||
data = json.dumps(request) + "\n"
|
||||
sock.send(data.encode())
|
||||
return True
|
||||
except:
|
||||
return False
|
||||
|
||||
|
||||
def read_events(sock: socket.socket) -> list:
|
||||
"""Read available events from socket (non-blocking)"""
|
||||
events = []
|
||||
buffer = ""
|
||||
|
||||
try:
|
||||
while True:
|
||||
data = sock.recv(4096)
|
||||
if not data:
|
||||
break
|
||||
buffer += data.decode()
|
||||
|
||||
while "\n" in buffer:
|
||||
line, buffer = buffer.split("\n", 1)
|
||||
if line.strip():
|
||||
try:
|
||||
events.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
except BlockingIOError:
|
||||
pass
|
||||
except:
|
||||
pass
|
||||
|
||||
return events
|
||||
|
||||
|
||||
def format_tokens(n: int) -> str:
|
||||
"""Format token count with color based on size"""
|
||||
if n == 0:
|
||||
return f"{Colors.DIM}0{Colors.RESET}"
|
||||
elif n < 1000:
|
||||
return f"{Colors.GREEN}{n}{Colors.RESET}"
|
||||
elif n < 10000:
|
||||
return f"{Colors.YELLOW}{n:,}{Colors.RESET}"
|
||||
else:
|
||||
return f"{Colors.RED}{n:,}{Colors.RESET}"
|
||||
|
||||
|
||||
def format_tool(name: str, status: str = "active") -> str:
|
||||
"""Format a tool name with appropriate color"""
|
||||
if status == "active":
|
||||
return f"{Colors.CYAN}{Colors.BOLD}{name}{Colors.RESET}"
|
||||
elif status == "done":
|
||||
return f"{Colors.GREEN}{name}{Colors.RESET}"
|
||||
elif status == "error":
|
||||
return f"{Colors.RED}{name}{Colors.RESET}"
|
||||
return name
|
||||
|
||||
|
||||
def truncate(s: str, max_len: int) -> str:
|
||||
"""Truncate string with ellipsis"""
|
||||
if len(s) <= max_len:
|
||||
return s
|
||||
return s[:max_len-3] + "..."
|
||||
|
||||
|
||||
def render_dashboard(state: MonitorState, width: int = 80):
|
||||
"""Render the monitoring dashboard"""
|
||||
lines = []
|
||||
|
||||
# Header
|
||||
header = f" JCODE MONITOR "
|
||||
padding = (width - len(header)) // 2
|
||||
lines.append(f"{Colors.BG_BLUE}{Colors.WHITE}{Colors.BOLD}{' ' * padding}{header}{' ' * padding}{Colors.RESET}")
|
||||
lines.append("")
|
||||
|
||||
# Connection status
|
||||
if state.connected:
|
||||
status = f"{Colors.GREEN}CONNECTED{Colors.RESET}"
|
||||
session = f" | Session: {Colors.DIM}{state.session_id[:8]}...{Colors.RESET}" if state.session_id else ""
|
||||
else:
|
||||
status = f"{Colors.RED}DISCONNECTED{Colors.RESET}"
|
||||
session = ""
|
||||
lines.append(f" Status: {status}{session}")
|
||||
|
||||
# Processing state
|
||||
if state.is_processing:
|
||||
proc = f"{Colors.YELLOW}{Colors.BOLD}PROCESSING{Colors.RESET}"
|
||||
else:
|
||||
proc = f"{Colors.DIM}idle{Colors.RESET}"
|
||||
lines.append(f" State: {proc}")
|
||||
lines.append("")
|
||||
|
||||
# Token usage
|
||||
lines.append(f" {Colors.BOLD}Tokens{Colors.RESET}")
|
||||
lines.append(f" Input: {format_tokens(state.input_tokens)}")
|
||||
lines.append(f" Output: {format_tokens(state.output_tokens)}")
|
||||
lines.append("")
|
||||
|
||||
# Active tools
|
||||
lines.append(f" {Colors.BOLD}Active Tools{Colors.RESET}")
|
||||
if state.active_tools:
|
||||
for tool_id, tool_name in state.active_tools.items():
|
||||
lines.append(f" {Colors.CYAN}>{Colors.RESET} {format_tool(tool_name)}")
|
||||
else:
|
||||
lines.append(f" {Colors.DIM}(none){Colors.RESET}")
|
||||
lines.append("")
|
||||
|
||||
# Recent tool completions
|
||||
lines.append(f" {Colors.BOLD}Recent Tools{Colors.RESET}")
|
||||
if state.tool_history:
|
||||
for item in state.tool_history[-5:]:
|
||||
name, success, output = item
|
||||
status = "done" if success else "error"
|
||||
output_preview = truncate(output.replace("\n", " "), 40)
|
||||
lines.append(f" {format_tool(name, status)}: {Colors.DIM}{output_preview}{Colors.RESET}")
|
||||
else:
|
||||
lines.append(f" {Colors.DIM}(none){Colors.RESET}")
|
||||
lines.append("")
|
||||
|
||||
# Current streaming text
|
||||
lines.append(f" {Colors.BOLD}Streaming Text{Colors.RESET}")
|
||||
if state.current_text:
|
||||
# Show last few lines of streaming text
|
||||
text_lines = state.current_text.split("\n")[-4:]
|
||||
for tl in text_lines:
|
||||
lines.append(f" {Colors.WHITE}{truncate(tl, width-6)}{Colors.RESET}")
|
||||
else:
|
||||
lines.append(f" {Colors.DIM}(waiting...){Colors.RESET}")
|
||||
lines.append("")
|
||||
|
||||
# Stats
|
||||
lines.append(f" {Colors.DIM}Events: {state.events_received} | Last: {time.time() - state.last_event_time:.1f}s ago{Colors.RESET}")
|
||||
|
||||
# Errors
|
||||
if state.errors:
|
||||
lines.append("")
|
||||
lines.append(f" {Colors.RED}{Colors.BOLD}Errors{Colors.RESET}")
|
||||
for err in state.errors[-3:]:
|
||||
lines.append(f" {Colors.RED}{truncate(err, width-6)}{Colors.RESET}")
|
||||
|
||||
# Print with clear
|
||||
print(Colors.CLEAR, end="")
|
||||
print("\n".join(lines))
|
||||
|
||||
|
||||
def process_event(event: dict, state: MonitorState):
|
||||
"""Process a single event and update state"""
|
||||
state.events_received += 1
|
||||
state.last_event_time = time.time()
|
||||
|
||||
event_type = event.get("type", "")
|
||||
|
||||
if event_type == "ack":
|
||||
state.is_processing = True
|
||||
|
||||
elif event_type == "text_delta":
|
||||
state.current_text += event.get("text", "")
|
||||
# Keep last 2000 chars
|
||||
if len(state.current_text) > 2000:
|
||||
state.current_text = state.current_text[-2000:]
|
||||
|
||||
elif event_type == "tool_start":
|
||||
tool_id = event.get("id", "")
|
||||
tool_name = event.get("name", "unknown")
|
||||
state.active_tools[tool_id] = tool_name
|
||||
|
||||
elif event_type == "tool_exec":
|
||||
pass # Tool is executing, still active
|
||||
|
||||
elif event_type == "tool_done":
|
||||
tool_id = event.get("id", "")
|
||||
tool_name = event.get("name", "unknown")
|
||||
output = event.get("output", "")
|
||||
error = event.get("error")
|
||||
|
||||
# Remove from active
|
||||
state.active_tools.pop(tool_id, None)
|
||||
|
||||
# Add to history
|
||||
state.tool_history.append((tool_name, error is None, output[:100] if output else "(empty)"))
|
||||
# Keep last 20
|
||||
state.tool_history = state.tool_history[-20:]
|
||||
|
||||
elif event_type == "tokens":
|
||||
state.input_tokens = event.get("input", 0)
|
||||
state.output_tokens = event.get("output", 0)
|
||||
|
||||
elif event_type == "done":
|
||||
state.is_processing = False
|
||||
state.current_text = "" # Clear for next turn
|
||||
|
||||
elif event_type == "error":
|
||||
state.errors.append(event.get("message", "unknown error"))
|
||||
state.errors = state.errors[-10:]
|
||||
|
||||
elif event_type == "pong":
|
||||
pass # Health check response
|
||||
|
||||
elif event_type == "state":
|
||||
state.session_id = event.get("session_id", "")
|
||||
state.is_processing = event.get("is_processing", False)
|
||||
|
||||
elif event_type == "session":
|
||||
state.session_id = event.get("session_id", "")
|
||||
|
||||
|
||||
def main():
|
||||
"""Main monitor loop"""
|
||||
socket_path = sys.argv[1] if len(sys.argv) > 1 else get_socket_path()
|
||||
|
||||
print(f"Connecting to {socket_path}...")
|
||||
|
||||
state = MonitorState()
|
||||
sock = None
|
||||
request_id = 1
|
||||
last_ping = 0
|
||||
|
||||
while True:
|
||||
try:
|
||||
# Connect if needed
|
||||
if sock is None:
|
||||
sock = connect_to_socket(socket_path)
|
||||
if sock:
|
||||
state.connected = True
|
||||
# Subscribe to events
|
||||
send_request(sock, {"type": "subscribe", "id": request_id})
|
||||
request_id += 1
|
||||
# Get initial state
|
||||
send_request(sock, {"type": "state", "id": request_id})
|
||||
request_id += 1
|
||||
else:
|
||||
state.connected = False
|
||||
|
||||
# Read events
|
||||
if sock:
|
||||
events = read_events(sock)
|
||||
for event in events:
|
||||
process_event(event, state)
|
||||
|
||||
# Periodic ping
|
||||
if time.time() - last_ping > 5:
|
||||
send_request(sock, {"type": "ping", "id": request_id})
|
||||
request_id += 1
|
||||
last_ping = time.time()
|
||||
|
||||
# Render dashboard
|
||||
render_dashboard(state)
|
||||
|
||||
# Small delay
|
||||
time.sleep(0.1)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print(f"\n{Colors.DIM}Exiting...{Colors.RESET}")
|
||||
break
|
||||
except BrokenPipeError:
|
||||
state.connected = False
|
||||
sock = None
|
||||
except Exception as e:
|
||||
state.errors.append(str(e))
|
||||
time.sleep(1)
|
||||
|
||||
if sock:
|
||||
sock.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+89
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env bash
|
||||
# Shared helper that ensures a directory is on PATH for bash, zsh, and fish.
|
||||
#
|
||||
# Usage:
|
||||
# source "$(dirname "$0")/lib/configure_path.sh"
|
||||
# jcode_configure_path "/path/to/install/dir"
|
||||
#
|
||||
# This is intentionally POSIX-friendly and side-effect free until you call
|
||||
# jcode_configure_path. It is kept in sync with the inline copy in install.sh,
|
||||
# which must stay self-contained because it is run via `curl ... | bash`.
|
||||
|
||||
# Configure PATH for bash, zsh and fish.
|
||||
# jcode_configure_path <install-dir> [report-fn]
|
||||
# report-fn (optional) is called with a human-readable summary string.
|
||||
jcode_configure_path() {
|
||||
_jcp_install_dir="$1"
|
||||
_jcp_report="${2:-}"
|
||||
_jcp_path_line="export PATH=\"$_jcp_install_dir:\$PATH\""
|
||||
_jcp_added=""
|
||||
|
||||
_jcp_have() { command -v "$1" >/dev/null 2>&1; }
|
||||
|
||||
# Append the POSIX (bash/zsh/sh) PATH line to an rc file, idempotently.
|
||||
# _jcp_posix_rc <rc-file> <create:yes|no>
|
||||
# With create=yes the file (and parent dir) is created if missing; with
|
||||
# create=no we only touch files that already exist, so we never change how a
|
||||
# login shell resolves its startup files (e.g. creating ~/.bash_profile would
|
||||
# stop bash from reading ~/.profile).
|
||||
_jcp_posix_rc() {
|
||||
_rc="$1"; _create="$2"
|
||||
if [ ! -f "$_rc" ]; then
|
||||
[ "$_create" = "yes" ] || return 0
|
||||
mkdir -p "$(dirname "$_rc")"
|
||||
fi
|
||||
if ! grep -qF "$_jcp_install_dir" "$_rc" 2>/dev/null; then
|
||||
printf '\n# Added by jcode installer\n%s\n' "$_jcp_path_line" >> "$_rc"
|
||||
_jcp_added="$_jcp_added $_rc"
|
||||
fi
|
||||
}
|
||||
|
||||
# fish uses its own syntax and does not read POSIX rc files.
|
||||
_jcp_fish_rc() {
|
||||
_create="$1"
|
||||
_rc="${XDG_CONFIG_HOME:-$HOME/.config}/fish/config.fish"
|
||||
if [ ! -f "$_rc" ]; then
|
||||
[ "$_create" = "yes" ] || return 0
|
||||
mkdir -p "$(dirname "$_rc")"
|
||||
fi
|
||||
if ! grep -qF "$_jcp_install_dir" "$_rc" 2>/dev/null; then
|
||||
{
|
||||
printf '\n# Added by jcode installer\n'
|
||||
printf 'if not contains "%s" $PATH\n' "$_jcp_install_dir"
|
||||
printf ' set -gx PATH "%s" $PATH\n' "$_jcp_install_dir"
|
||||
printf 'end\n'
|
||||
} >> "$_rc"
|
||||
_jcp_added="$_jcp_added $_rc"
|
||||
fi
|
||||
}
|
||||
|
||||
# zsh: ~/.zshenv is read for every zsh invocation (login, interactive and
|
||||
# scripts), so it is the most reliable single place to export PATH.
|
||||
if _jcp_have zsh || [ "$(uname -s)" = "Darwin" ] || [ -f "$HOME/.zshenv" ] || [ -f "$HOME/.zshrc" ]; then
|
||||
_jcp_posix_rc "$HOME/.zshenv" yes
|
||||
fi
|
||||
|
||||
# bash: ~/.bashrc for interactive shells, ~/.profile for login shells.
|
||||
if _jcp_have bash || [ -f "$HOME/.bashrc" ] || [ -f "$HOME/.bash_profile" ]; then
|
||||
_jcp_posix_rc "$HOME/.bashrc" yes
|
||||
fi
|
||||
_jcp_posix_rc "$HOME/.profile" yes
|
||||
|
||||
# fish: only set it up when fish is installed or already configured.
|
||||
if _jcp_have fish || [ -f "${XDG_CONFIG_HOME:-$HOME/.config}/fish/config.fish" ]; then
|
||||
_jcp_fish_rc yes
|
||||
fi
|
||||
|
||||
# Also patch other common startup files when they already exist.
|
||||
for _rc in "$HOME/.zshrc" "$HOME/.zprofile" "$HOME/.bash_profile"; do
|
||||
_jcp_posix_rc "$_rc" no
|
||||
done
|
||||
|
||||
if [ -n "$_jcp_added" ]; then
|
||||
if [ -n "$_jcp_report" ] && command -v "$_jcp_report" >/dev/null 2>&1; then
|
||||
"$_jcp_report" "Added $_jcp_install_dir to PATH in:$_jcp_added"
|
||||
else
|
||||
printf 'Added %s to PATH in:%s\n' "$_jcp_install_dir" "$_jcp_added"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
Executable
+339
@@ -0,0 +1,339 @@
|
||||
#!/usr/bin/env bash
|
||||
# memory_probe.sh - Reproducible memory probe harness for jcode TUI clients.
|
||||
#
|
||||
# Spawns a headless tester TUI via the debug socket (tester:spawn) that resumes
|
||||
# a LARGE existing session, then records RSS/PSS/rss_anon at fixed phases:
|
||||
#
|
||||
# fresh - right after spawn, before the event loop settles
|
||||
# post_connect - first successful client debug "state" response
|
||||
# post_history_loaded - display messages applied and stable
|
||||
# idle - after --idle-secs (default 30) of idle time
|
||||
# post_trim - after a forced allocator trim (gdb malloc_trim(0))
|
||||
#
|
||||
# Each phase emits exactly ONE compact, key-sorted JSON line on stdout so runs
|
||||
# are diffable (all human logging goes to stderr). Memory is sampled from
|
||||
# /proc/<pid>/status + /proc/<pid>/smaps_rollup, plus the client's own
|
||||
# aggregate memory profile (the same handler that serves `client:memory`),
|
||||
# fetched through the tester's file-based debug channel.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/memory_probe.sh [options]
|
||||
#
|
||||
# Options:
|
||||
# --session <id> Session to resume (default: session_hog_1783086065415_4ad4ae66cd43dd5b)
|
||||
# --idle-secs <n> Idle wait before the idle phase (default: 30)
|
||||
# --binary <path> Client binary (default: ~/.jcode/builds/current/jcode)
|
||||
# --jcode <path> jcode CLI used for `jcode debug ...` (default: ~/.local/bin/jcode)
|
||||
# --cwd <path> Working directory for the tester (default: $HOME)
|
||||
# --skip-trim Skip the forced-trim phase
|
||||
# --keep Do not stop the tester on exit (for manual inspection)
|
||||
# -h | --help Show this help
|
||||
#
|
||||
# Requirements: a running jcode server with the debug socket enabled, jq,
|
||||
# and (for the trim phase) gdb. Trim prefers `sudo -n gdb` because
|
||||
# kernel.yama.ptrace_scope=1 blocks same-uid attach to non-children.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SESSION_ID="session_hog_1783086065415_4ad4ae66cd43dd5b"
|
||||
IDLE_SECS=30
|
||||
JCODE_BIN="${JCODE_BIN:-$HOME/.local/bin/jcode}"
|
||||
CLIENT_BIN="$HOME/.jcode/builds/current/jcode"
|
||||
TESTER_CWD="$HOME"
|
||||
SKIP_TRIM=0
|
||||
KEEP_TESTER=0
|
||||
|
||||
usage() { sed -n '2,35p' "$0" | sed 's/^# \{0,1\}//'; }
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--session) SESSION_ID="$2"; shift 2 ;;
|
||||
--idle-secs) IDLE_SECS="$2"; shift 2 ;;
|
||||
--binary) CLIENT_BIN="$2"; shift 2 ;;
|
||||
--jcode) JCODE_BIN="$2"; shift 2 ;;
|
||||
--cwd) TESTER_CWD="$2"; shift 2 ;;
|
||||
--skip-trim) SKIP_TRIM=1; shift ;;
|
||||
--keep) KEEP_TESTER=1; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
log() { printf '[memory_probe] %s\n' "$*" >&2; }
|
||||
die() { log "FATAL: $*"; exit 1; }
|
||||
|
||||
command -v jq >/dev/null || die "jq is required"
|
||||
[[ -x "$JCODE_BIN" ]] || die "jcode CLI not found at $JCODE_BIN"
|
||||
[[ -x "$CLIENT_BIN" ]] || CLIENT_BIN="$(command -v jcode)" || die "client binary not found"
|
||||
[[ -f "$HOME/.jcode/sessions/${SESSION_ID}.json" ]] \
|
||||
|| log "WARN: $HOME/.jcode/sessions/${SESSION_ID}.json not found; resume may create a new session"
|
||||
|
||||
RUN_ID="probe_$(date -u +%Y%m%dT%H%M%SZ)_$$"
|
||||
SESSION_FILE="$HOME/.jcode/sessions/${SESSION_ID}.json"
|
||||
SESSION_BYTES=$(stat -c %s "$SESSION_FILE" 2>/dev/null || echo 0)
|
||||
|
||||
TESTER_ID=""
|
||||
TESTER_PID=""
|
||||
CMD_PATH=""
|
||||
RESP_PATH=""
|
||||
WRAPPER=""
|
||||
SPAWN_EPOCH_MS=""
|
||||
|
||||
cleanup() {
|
||||
local rc=$?
|
||||
if [[ -n "$TESTER_ID" && "$KEEP_TESTER" -ne 1 ]]; then
|
||||
"$JCODE_BIN" debug "tester:${TESTER_ID}:stop" >/dev/null 2>&1 || true
|
||||
log "stopped tester $TESTER_ID"
|
||||
elif [[ -n "$TESTER_ID" ]]; then
|
||||
log "kept tester $TESTER_ID (pid $TESTER_PID) running (--keep)"
|
||||
fi
|
||||
[[ -n "$WRAPPER" ]] && rm -f "$WRAPPER"
|
||||
exit $rc
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
now_ms() { date +%s%3N; }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /proc sampling: emits a JSON object with kB values from status+smaps_rollup.
|
||||
# ---------------------------------------------------------------------------
|
||||
proc_kb() { # file key
|
||||
awk -v k="$2" '$1 == k ":" { print $2; found=1; exit } END { if (!found) print "null" }' "$1" 2>/dev/null || echo null
|
||||
}
|
||||
|
||||
sample_proc() { # pid -> json on stdout
|
||||
local pid="$1"
|
||||
local status="/proc/$pid/status" rollup="/proc/$pid/smaps_rollup"
|
||||
[[ -r "$status" ]] || { echo null; return 1; }
|
||||
local tmp_rollup
|
||||
tmp_rollup="$(mktemp)"
|
||||
# snapshot smaps_rollup once so all fields come from the same read
|
||||
cat "$rollup" > "$tmp_rollup" 2>/dev/null || true
|
||||
jq -n -S \
|
||||
--argjson rss_kb "$(proc_kb "$status" VmRSS)" \
|
||||
--argjson rss_anon_kb "$(proc_kb "$status" RssAnon)" \
|
||||
--argjson rss_file_kb "$(proc_kb "$status" RssFile)" \
|
||||
--argjson rss_shmem_kb "$(proc_kb "$status" RssShmem)" \
|
||||
--argjson vm_hwm_kb "$(proc_kb "$status" VmHWM)" \
|
||||
--argjson vm_swap_kb "$(proc_kb "$status" VmSwap)" \
|
||||
--argjson pss_kb "$(proc_kb "$tmp_rollup" Pss)" \
|
||||
--argjson pss_anon_kb "$(proc_kb "$tmp_rollup" Pss_Anon)" \
|
||||
--argjson pss_file_kb "$(proc_kb "$tmp_rollup" Pss_File)" \
|
||||
--argjson private_clean_kb "$(proc_kb "$tmp_rollup" Private_Clean)" \
|
||||
--argjson private_dirty_kb "$(proc_kb "$tmp_rollup" Private_Dirty)" \
|
||||
--argjson shared_clean_kb "$(proc_kb "$tmp_rollup" Shared_Clean)" \
|
||||
--argjson shared_dirty_kb "$(proc_kb "$tmp_rollup" Shared_Dirty)" \
|
||||
--argjson swap_kb "$(proc_kb "$tmp_rollup" Swap)" \
|
||||
'{rss_kb: $rss_kb, rss_anon_kb: $rss_anon_kb, rss_file_kb: $rss_file_kb,
|
||||
rss_shmem_kb: $rss_shmem_kb, vm_hwm_kb: $vm_hwm_kb, vm_swap_kb: $vm_swap_kb,
|
||||
pss_kb: $pss_kb, pss_anon_kb: $pss_anon_kb, pss_file_kb: $pss_file_kb,
|
||||
private_clean_kb: $private_clean_kb, private_dirty_kb: $private_dirty_kb,
|
||||
shared_clean_kb: $shared_clean_kb, shared_dirty_kb: $shared_dirty_kb,
|
||||
swap_kb: $swap_kb}'
|
||||
local rc=$?
|
||||
rm -f "$tmp_rollup"
|
||||
return $rc
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tester file-based debug channel: write command, poll response until it is
|
||||
# non-empty and stable (guards against partially written responses).
|
||||
# ---------------------------------------------------------------------------
|
||||
tester_cmd() { # command timeout_secs -> response on stdout, rc 1 on timeout
|
||||
local cmd="$1" timeout_s="${2:-30}"
|
||||
rm -f "$RESP_PATH"
|
||||
printf '%s' "$cmd" > "$CMD_PATH"
|
||||
local deadline=$(( $(date +%s) + timeout_s ))
|
||||
local prev="" cur=""
|
||||
while (( $(date +%s) < deadline )); do
|
||||
if [[ -s "$RESP_PATH" ]]; then
|
||||
cur="$(cat "$RESP_PATH" 2>/dev/null || true)"
|
||||
if [[ -n "$cur" && "$cur" == "$prev" ]]; then
|
||||
rm -f "$RESP_PATH"
|
||||
printf '%s' "$cur"
|
||||
return 0
|
||||
fi
|
||||
prev="$cur"
|
||||
fi
|
||||
sleep 0.2
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
tester_json_cmd() { # command timeout_secs -> valid JSON on stdout or rc 1
|
||||
local out
|
||||
out="$(tester_cmd "$1" "${2:-30}")" || return 1
|
||||
jq -e . >/dev/null 2>&1 <<<"$out" || return 1
|
||||
printf '%s' "$out"
|
||||
}
|
||||
|
||||
# Client aggregate memory profile subset (same handler as `client:memory`).
|
||||
client_memory_subset() { # timeout -> compact json or "null"
|
||||
local raw
|
||||
if ! raw="$(tester_json_cmd "memory" "${1:-90}")"; then
|
||||
echo null
|
||||
return 0
|
||||
fi
|
||||
jq -c -S '{
|
||||
rss_bytes: (.process.rss_bytes // null),
|
||||
pss_bytes: (.process.os.pss_bytes // null),
|
||||
rss_anon_bytes: (.process.os.rss_anon_bytes // null),
|
||||
allocator: (.process.allocator.name // null),
|
||||
allocated_bytes: (.process.allocator.stats.allocated_bytes // null),
|
||||
session_json_bytes: (.session.totals.json_bytes // null),
|
||||
provider_messages_count: (.ui.provider_messages.count // null),
|
||||
provider_messages_json_bytes: (.ui.provider_messages.json_bytes // null),
|
||||
display_messages_count: (.ui.display_messages.count // null),
|
||||
display_messages_estimate_bytes: (.ui.display_messages.estimate_bytes // null)
|
||||
}' <<<"$raw" 2>/dev/null || echo null
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase emitter: one compact key-sorted JSON line on stdout.
|
||||
# ---------------------------------------------------------------------------
|
||||
emit_phase() { # phase proc_json client_json extras_json
|
||||
local phase="$1" proc_json="$2" client_json="$3" extras_json="${4:-{\}}"
|
||||
local ts elapsed_ms
|
||||
ts="$(now_ms)"
|
||||
elapsed_ms=$(( ts - SPAWN_EPOCH_MS ))
|
||||
jq -c -S -n \
|
||||
--arg probe "jcode_memory_probe" \
|
||||
--arg schema "1" \
|
||||
--arg run_id "$RUN_ID" \
|
||||
--arg phase "$phase" \
|
||||
--arg session "$SESSION_ID" \
|
||||
--arg tester_id "$TESTER_ID" \
|
||||
--argjson session_file_bytes "$SESSION_BYTES" \
|
||||
--argjson pid "${TESTER_PID:-null}" \
|
||||
--argjson ts_ms "$ts" \
|
||||
--argjson elapsed_ms "$elapsed_ms" \
|
||||
--argjson proc "$proc_json" \
|
||||
--argjson client "$client_json" \
|
||||
--argjson extras "$extras_json" \
|
||||
'{probe: $probe, schema: ($schema | tonumber), run_id: $run_id, phase: $phase,
|
||||
session: $session, session_file_bytes: $session_file_bytes,
|
||||
tester_id: $tester_id, pid: $pid, ts_ms: $ts_ms, elapsed_ms: $elapsed_ms,
|
||||
proc: $proc, client: $client} + $extras'
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Forced trim: call malloc_trim(0) inside the client via gdb.
|
||||
# ---------------------------------------------------------------------------
|
||||
force_trim() { # pid -> prints method used ("gdb_sudo" | "gdb" | "unavailable")
|
||||
local pid="$1"
|
||||
if ! command -v gdb >/dev/null; then
|
||||
echo unavailable
|
||||
return 0
|
||||
fi
|
||||
local gdb_args=(--batch -p "$pid" -ex 'call (int) malloc_trim(0)' -ex detach -ex quit)
|
||||
if sudo -n true 2>/dev/null; then
|
||||
if timeout 60 sudo -n gdb "${gdb_args[@]}" >/dev/null 2>&1; then
|
||||
echo gdb_sudo
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
if timeout 60 gdb "${gdb_args[@]}" >/dev/null 2>&1; then
|
||||
echo gdb
|
||||
return 0
|
||||
fi
|
||||
echo unavailable
|
||||
}
|
||||
|
||||
# ===========================================================================
|
||||
# 1. Spawn headless tester resuming the target session.
|
||||
# ===========================================================================
|
||||
WRAPPER="$(mktemp /tmp/jcode_memory_probe_wrapper.XXXXXX.sh)"
|
||||
cat > "$WRAPPER" <<EOF
|
||||
#!/usr/bin/env bash
|
||||
exec "$CLIENT_BIN" --resume "$SESSION_ID" --no-update "\$@"
|
||||
EOF
|
||||
chmod +x "$WRAPPER"
|
||||
|
||||
log "spawning headless tester (binary wrapper resumes $SESSION_ID, ${SESSION_BYTES} byte session file)"
|
||||
SPAWN_OUT="$("$JCODE_BIN" debug "tester:spawn {\"binary\":\"$WRAPPER\",\"cwd\":\"$TESTER_CWD\",\"cols\":120,\"rows\":40}")" \
|
||||
|| die "tester:spawn failed: $SPAWN_OUT"
|
||||
SPAWN_EPOCH_MS="$(now_ms)"
|
||||
TESTER_ID="$(jq -r '.id // empty' <<<"$SPAWN_OUT")"
|
||||
TESTER_PID="$(jq -r '.pid // empty' <<<"$SPAWN_OUT")"
|
||||
[[ -n "$TESTER_ID" && -n "$TESTER_PID" ]] || die "could not parse tester:spawn response: $SPAWN_OUT"
|
||||
log "tester $TESTER_ID pid $TESTER_PID"
|
||||
|
||||
TESTER_INFO="$("$JCODE_BIN" debug tester:list | jq -c --arg id "$TESTER_ID" '.[] | select(.id == $id)')"
|
||||
CMD_PATH="$(jq -r '.debug_cmd_path' <<<"$TESTER_INFO")"
|
||||
RESP_PATH="$(jq -r '.debug_response_path' <<<"$TESTER_INFO")"
|
||||
[[ -n "$CMD_PATH" && -n "$RESP_PATH" ]] || die "could not resolve tester debug paths"
|
||||
|
||||
# ===========================================================================
|
||||
# 2. Phase: fresh (immediately after spawn, before the event loop settles).
|
||||
# ===========================================================================
|
||||
sleep 0.2
|
||||
PROC_JSON="$(sample_proc "$TESTER_PID")" || die "tester process $TESTER_PID vanished during fresh phase"
|
||||
emit_phase "fresh" "$PROC_JSON" null '{}'
|
||||
|
||||
# ===========================================================================
|
||||
# 3. Phase: post_connect (first successful client debug state response).
|
||||
# ===========================================================================
|
||||
STATE_JSON=""
|
||||
CONNECT_DEADLINE=$(( $(date +%s) + 60 ))
|
||||
while (( $(date +%s) < CONNECT_DEADLINE )); do
|
||||
if STATE_JSON="$(tester_json_cmd "state" 5)"; then
|
||||
break
|
||||
fi
|
||||
STATE_JSON=""
|
||||
done
|
||||
[[ -n "$STATE_JSON" ]] || die "tester never answered a state command (stderr: $(jq -r '.stderr_path' <<<"$TESTER_INFO"))"
|
||||
PROC_JSON="$(sample_proc "$TESTER_PID")"
|
||||
DISPLAY_COUNT="$(jq -r '.display_messages // 0' <<<"$STATE_JSON")"
|
||||
emit_phase "post_connect" "$PROC_JSON" null "{\"display_messages\": $DISPLAY_COUNT}"
|
||||
log "connected; display_messages=$DISPLAY_COUNT"
|
||||
|
||||
# ===========================================================================
|
||||
# 4. Phase: post_history_loaded (display messages present and stable).
|
||||
# ===========================================================================
|
||||
HISTORY_DEADLINE=$(( $(date +%s) + 180 ))
|
||||
PREV_COUNT=-1
|
||||
STABLE=0
|
||||
while (( $(date +%s) < HISTORY_DEADLINE )); do
|
||||
if STATE_JSON="$(tester_json_cmd "state" 10)"; then
|
||||
DISPLAY_COUNT="$(jq -r '.display_messages // 0' <<<"$STATE_JSON")"
|
||||
PROCESSING="$(jq -r '.processing // false' <<<"$STATE_JSON")"
|
||||
if [[ "$DISPLAY_COUNT" -gt 0 && "$PROCESSING" == "false" && "$DISPLAY_COUNT" -eq "$PREV_COUNT" ]]; then
|
||||
STABLE=$(( STABLE + 1 ))
|
||||
[[ $STABLE -ge 2 ]] && break
|
||||
else
|
||||
STABLE=0
|
||||
fi
|
||||
PREV_COUNT="$DISPLAY_COUNT"
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
[[ "$PREV_COUNT" -gt 0 ]] || die "history never loaded (display_messages=$PREV_COUNT)"
|
||||
PROC_JSON="$(sample_proc "$TESTER_PID")"
|
||||
CLIENT_JSON="$(client_memory_subset 90)"
|
||||
emit_phase "post_history_loaded" "$PROC_JSON" "$CLIENT_JSON" "{\"display_messages\": $PREV_COUNT}"
|
||||
log "history loaded; display_messages=$PREV_COUNT"
|
||||
|
||||
# ===========================================================================
|
||||
# 5. Phase: idle (after --idle-secs of no activity).
|
||||
# ===========================================================================
|
||||
log "idling for ${IDLE_SECS}s"
|
||||
sleep "$IDLE_SECS"
|
||||
PROC_JSON="$(sample_proc "$TESTER_PID")"
|
||||
CLIENT_JSON="$(client_memory_subset 90)"
|
||||
emit_phase "idle" "$PROC_JSON" "$CLIENT_JSON" "{\"idle_secs\": $IDLE_SECS}"
|
||||
|
||||
# ===========================================================================
|
||||
# 6. Phase: post_trim (forced allocator trim via gdb malloc_trim(0)).
|
||||
# ===========================================================================
|
||||
if [[ "$SKIP_TRIM" -eq 1 ]]; then
|
||||
log "skipping trim phase (--skip-trim)"
|
||||
else
|
||||
TRIM_METHOD="$(force_trim "$TESTER_PID")"
|
||||
log "forced trim via: $TRIM_METHOD"
|
||||
sleep 1
|
||||
PROC_JSON="$(sample_proc "$TESTER_PID")"
|
||||
CLIENT_JSON="$(client_memory_subset 90)"
|
||||
emit_phase "post_trim" "$PROC_JSON" "$CLIENT_JSON" "{\"trim_method\": \"$TRIM_METHOD\"}"
|
||||
fi
|
||||
|
||||
log "done (run_id=$RUN_ID)"
|
||||
Executable
+106
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env bash
|
||||
# memory_regression_gate.sh - Automated pass/fail gate over client memory.
|
||||
#
|
||||
# Wraps scripts/memory_probe.sh with thresholds so memory regressions fail
|
||||
# loudly (nightly job, pre-release check) instead of being rediscovered when
|
||||
# the machine starts swapping.
|
||||
#
|
||||
# Baseline (2026-07-04, selfdev build, 8 MB / 322-message hog session), after
|
||||
# the retention + live-heap work (mmap-threshold pin, idle trim, syntect
|
||||
# regex-onig backend, inline-image payload release):
|
||||
#
|
||||
# idle rss_anon: ~38 MB (was ~69 MB)
|
||||
# idle live heap (allocated): ~30 MB (was ~60 MB)
|
||||
#
|
||||
# Thresholds sit roughly halfway between the fixed and regressed values, so
|
||||
# normal variance passes but a real regression toward old behavior fails.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/memory_regression_gate.sh [--session <id>] [--idle-secs <n>]
|
||||
#
|
||||
# Env overrides:
|
||||
# JCODE_MEMGATE_SESSION probe session id
|
||||
# JCODE_MEMGATE_MAX_RSS_ANON_KB idle rss_anon bound (default 56320 = 55 MiB)
|
||||
# JCODE_MEMGATE_MAX_LIVE_HEAP_BYTES idle allocated_bytes bound (default 47185920 = 45 MiB)
|
||||
#
|
||||
# Exit codes: 0 pass, 1 threshold exceeded, 2 could not measure, 3 skipped
|
||||
# (probe session missing on this machine).
|
||||
#
|
||||
# Emits one machine-parseable line: `JCODE_MEMGATE_RESULT {json}`.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
SESSION_ID="${JCODE_MEMGATE_SESSION:-session_hog_1783086065415_4ad4ae66cd43dd5b}"
|
||||
IDLE_SECS=60
|
||||
MAX_RSS_ANON_KB="${JCODE_MEMGATE_MAX_RSS_ANON_KB:-56320}"
|
||||
MAX_LIVE_HEAP_BYTES="${JCODE_MEMGATE_MAX_LIVE_HEAP_BYTES:-47185920}"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--session) SESSION_ID="$2"; shift 2 ;;
|
||||
--idle-secs) IDLE_SECS="$2"; shift 2 ;;
|
||||
-h|--help) sed -n '2,30p' "$0"; exit 0 ;;
|
||||
*) echo "unknown arg: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
command -v jq >/dev/null || { echo "jq required" >&2; exit 2; }
|
||||
|
||||
# Skip (not fail) when the pinned probe session does not exist on this
|
||||
# machine: thresholds are only meaningful against a fixed workload.
|
||||
if ! ls "$HOME/.jcode/sessions/${SESSION_ID}"* >/dev/null 2>&1; then
|
||||
echo "JCODE_MEMGATE_RESULT {\"status\":\"skipped\",\"reason\":\"probe session ${SESSION_ID} not found\"}"
|
||||
exit 3
|
||||
fi
|
||||
|
||||
echo "[memgate] probing session ${SESSION_ID} (idle ${IDLE_SECS}s)..." >&2
|
||||
PROBE_OUT="$("$SCRIPT_DIR/memory_probe.sh" --session "$SESSION_ID" --idle-secs "$IDLE_SECS" --skip-trim)" || {
|
||||
echo "JCODE_MEMGATE_RESULT {\"status\":\"error\",\"reason\":\"memory_probe failed\"}"
|
||||
exit 2
|
||||
}
|
||||
|
||||
IDLE_LINE="$(printf '%s\n' "$PROBE_OUT" | jq -c 'select(.phase=="idle")' | head -1)"
|
||||
[[ -n "$IDLE_LINE" ]] || {
|
||||
echo "JCODE_MEMGATE_RESULT {\"status\":\"error\",\"reason\":\"no idle phase in probe output\"}"
|
||||
exit 2
|
||||
}
|
||||
|
||||
RSS_ANON_KB="$(jq -r '.proc.rss_anon_kb // 0' <<<"$IDLE_LINE")"
|
||||
LIVE_HEAP_BYTES="$(jq -r '.client.allocated_bytes // 0' <<<"$IDLE_LINE")"
|
||||
|
||||
# Informational server-side numbers (never gate: server load varies).
|
||||
SERVER_INFO="$(jq -cn \
|
||||
--argjson rss "$(awk '/VmRSS/{print $2*1024}' "/proc/$(pgrep -f 'shared-server/jcode serve' | head -1)/status" 2>/dev/null || echo 0)" \
|
||||
'{server_rss_bytes: $rss}')"
|
||||
|
||||
FAILURES=()
|
||||
if (( RSS_ANON_KB > MAX_RSS_ANON_KB )); then
|
||||
FAILURES+=("idle rss_anon ${RSS_ANON_KB}kB > ${MAX_RSS_ANON_KB}kB")
|
||||
fi
|
||||
if (( LIVE_HEAP_BYTES > MAX_LIVE_HEAP_BYTES )); then
|
||||
FAILURES+=("idle live heap ${LIVE_HEAP_BYTES}B > ${MAX_LIVE_HEAP_BYTES}B")
|
||||
fi
|
||||
|
||||
STATUS="pass"
|
||||
(( ${#FAILURES[@]} > 0 )) && STATUS="fail"
|
||||
|
||||
jq -cn \
|
||||
--arg status "$STATUS" \
|
||||
--arg session "$SESSION_ID" \
|
||||
--argjson rss_anon_kb "$RSS_ANON_KB" \
|
||||
--argjson live_heap_bytes "$LIVE_HEAP_BYTES" \
|
||||
--argjson max_rss_anon_kb "$MAX_RSS_ANON_KB" \
|
||||
--argjson max_live_heap_bytes "$MAX_LIVE_HEAP_BYTES" \
|
||||
--argjson server "$SERVER_INFO" \
|
||||
--args '{status:$status, session:$session, idle_rss_anon_kb:$rss_anon_kb,
|
||||
idle_live_heap_bytes:$live_heap_bytes,
|
||||
thresholds:{rss_anon_kb:$max_rss_anon_kb, live_heap_bytes:$max_live_heap_bytes},
|
||||
server:$server, failures:$ARGS.positional}' -- "${FAILURES[@]+"${FAILURES[@]}"}" \
|
||||
| sed 's/^/JCODE_MEMGATE_RESULT /'
|
||||
|
||||
if [[ "$STATUS" == "fail" ]]; then
|
||||
printf '[memgate] FAIL: %s\n' "${FAILURES[@]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[memgate] PASS: idle rss_anon ${RSS_ANON_KB}kB (<= ${MAX_RSS_ANON_KB}), live heap ${LIVE_HEAP_BYTES}B (<= ${MAX_LIVE_HEAP_BYTES})" >&2
|
||||
Executable
+295
@@ -0,0 +1,295 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Probe Jcode pinned Mermaid fit planning for screenshot repros.
|
||||
|
||||
This intentionally mirrors the geometry math in src/tui/ui_diagram_pane.rs so a
|
||||
bad crop can be reproduced without compiling the whole Rust test binary.
|
||||
Defaults are the 2026-05-07 Beetle/Harbor clipped Mermaid screenshot:
|
||||
inner pane 73x46 cells, font 8x16 px, PNG 1180x1470 px.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
TARGET_UTILIZATION_PERCENT = 85.0
|
||||
MIN_READABLE_ZOOM_PERCENT = 70
|
||||
MAX_AUTO_FILL_ZOOM_PERCENT = 1000
|
||||
|
||||
|
||||
def clamp(value: int, lo: int, hi: int) -> int:
|
||||
return max(lo, min(hi, value))
|
||||
|
||||
|
||||
def utilization_percent(used: int, total: int) -> float:
|
||||
return 0.0 if total == 0 else (used * 100.0) / total
|
||||
|
||||
|
||||
def div_ceil(value: int, divisor: int) -> int:
|
||||
return 0 if divisor == 0 else (value + divisor - 1) // divisor
|
||||
|
||||
|
||||
def axis_fill_zoom_percent(available_cells: int, image_px: int, cell_px: int) -> int:
|
||||
if available_cells == 0 or image_px == 0 or cell_px == 0:
|
||||
return 100
|
||||
return clamp((available_cells * cell_px * 100) // max(image_px, 1), 1, MAX_AUTO_FILL_ZOOM_PERCENT)
|
||||
|
||||
|
||||
def fit_zoom_percent_for_area(width_cells: int, height_cells: int, img_w_px: int, img_h_px: int, font_w: int, font_h: int) -> int:
|
||||
if width_cells == 0 or height_cells == 0 or img_w_px == 0 or img_h_px == 0:
|
||||
return 100
|
||||
zoom_w = (width_cells * max(font_w, 1) * 100) // max(img_w_px, 1)
|
||||
zoom_h = (height_cells * max(font_h, 1) * 100) // max(img_h_px, 1)
|
||||
return clamp(min(zoom_w, zoom_h), 1, MAX_AUTO_FILL_ZOOM_PERCENT)
|
||||
|
||||
|
||||
def vcenter_fitted(width_cells: int, height_cells: int, img_w_px: int, img_h_px: int, font_w: int, font_h: int) -> dict[str, int]:
|
||||
if width_cells == 0 or height_cells == 0 or img_w_px == 0 or img_h_px == 0:
|
||||
return {"x": 0, "y": 0, "width": width_cells, "height": height_cells}
|
||||
area_w_px = width_cells * max(font_w, 1)
|
||||
area_h_px = height_cells * max(font_h, 1)
|
||||
scale = min(area_w_px / img_w_px, area_h_px / img_h_px)
|
||||
fitted_w = min(math.ceil((img_w_px * scale) / max(font_w, 1)), width_cells)
|
||||
fitted_h = min(math.ceil((img_h_px * scale) / max(font_h, 1)), height_cells)
|
||||
return {
|
||||
"x": (width_cells - fitted_w) // 2,
|
||||
"y": (height_cells - fitted_h) // 2,
|
||||
"width": fitted_w,
|
||||
"height": fitted_h,
|
||||
}
|
||||
|
||||
|
||||
def centered_viewport_scroll_cells(image_px: int, area_cells: int, zoom_percent: int, cell_px: int) -> int:
|
||||
if image_px == 0 or area_cells == 0 or zoom_percent == 0 or cell_px == 0:
|
||||
return 0
|
||||
view_px = area_cells * cell_px * 100 // zoom_percent
|
||||
max_scroll_px = max(0, image_px - view_px)
|
||||
if max_scroll_px == 0:
|
||||
return 0
|
||||
cell_px_at_zoom = max(div_ceil(cell_px * 100, zoom_percent), 1)
|
||||
return (max_scroll_px // 2) // cell_px_at_zoom
|
||||
|
||||
|
||||
def plan(width_cells: int, height_cells: int, img_w_px: int, img_h_px: int, font_w: int, font_h: int) -> dict[str, Any]:
|
||||
contain = vcenter_fitted(width_cells, height_cells, img_w_px, img_h_px, font_w, font_h)
|
||||
fit_zoom = fit_zoom_percent_for_area(width_cells, height_cells, img_w_px, img_h_px, font_w, font_h)
|
||||
width_fill_zoom = axis_fill_zoom_percent(width_cells, img_w_px, font_w)
|
||||
height_fill_zoom = axis_fill_zoom_percent(height_cells, img_h_px, font_h)
|
||||
preferred_fill_zoom = clamp(max(width_fill_zoom, height_fill_zoom), MIN_READABLE_ZOOM_PERCENT, MAX_AUTO_FILL_ZOOM_PERCENT)
|
||||
|
||||
width_utilization = utilization_percent(contain["width"], width_cells)
|
||||
height_utilization = utilization_percent(contain["height"], height_cells)
|
||||
area_utilization = utilization_percent(contain["width"] * contain["height"], width_cells * height_cells)
|
||||
underutilized = (
|
||||
width_utilization < TARGET_UTILIZATION_PERCENT
|
||||
or height_utilization < TARGET_UTILIZATION_PERCENT
|
||||
or area_utilization < TARGET_UTILIZATION_PERCENT
|
||||
)
|
||||
meaningfully_larger = preferred_fill_zoom > fit_zoom + 5
|
||||
|
||||
old_would_fill = (fit_zoom < MIN_READABLE_ZOOM_PERCENT or underutilized) and meaningfully_larger
|
||||
fixed_would_fill = underutilized and meaningfully_larger
|
||||
|
||||
fill_plan = {
|
||||
"mode": f"fit-fill@{preferred_fill_zoom}%",
|
||||
"zoom_percent": preferred_fill_zoom,
|
||||
"scroll_x": centered_viewport_scroll_cells(img_w_px, width_cells, preferred_fill_zoom, font_w),
|
||||
"scroll_y": centered_viewport_scroll_cells(img_h_px, height_cells, preferred_fill_zoom, font_h),
|
||||
}
|
||||
|
||||
return {
|
||||
"input": {
|
||||
"inner_width_cells": width_cells,
|
||||
"inner_height_cells": height_cells,
|
||||
"image_width_px": img_w_px,
|
||||
"image_height_px": img_h_px,
|
||||
"font_width_px": font_w,
|
||||
"font_height_px": font_h,
|
||||
},
|
||||
"contain_rect_cells": contain,
|
||||
"utilization_percent": {
|
||||
"width": width_utilization,
|
||||
"height": height_utilization,
|
||||
"area": area_utilization,
|
||||
},
|
||||
"fit_zoom_percent": fit_zoom,
|
||||
"axis_fill_zoom_percent": {"width": width_fill_zoom, "height": height_fill_zoom},
|
||||
"preferred_fill_zoom_percent": preferred_fill_zoom,
|
||||
"underutilized": underutilized,
|
||||
"meaningfully_larger": meaningfully_larger,
|
||||
"old_buggy_plan": fill_plan if old_would_fill else {"mode": "fit", "rect": contain},
|
||||
"fixed_plan": fill_plan if fixed_would_fill else {"mode": "fit", "rect": contain},
|
||||
"repro_was_clipping_bug": old_would_fill and not fixed_would_fill,
|
||||
}
|
||||
|
||||
|
||||
def maybe_png_info(path: str | None) -> dict[str, Any] | None:
|
||||
if not path:
|
||||
return None
|
||||
png = Path(path).expanduser()
|
||||
info: dict[str, Any] = {"path": str(png), "exists": png.exists()}
|
||||
if not png.exists():
|
||||
return info
|
||||
try:
|
||||
from PIL import Image # type: ignore
|
||||
except Exception as exc: # pragma: no cover - diagnostic fallback
|
||||
info["pil_error"] = str(exc)
|
||||
return info
|
||||
im = Image.open(png).convert("RGBA")
|
||||
bbox = im.getchannel("A").getbbox()
|
||||
info["size"] = list(im.size)
|
||||
info["alpha_bbox"] = list(bbox) if bbox else None
|
||||
if bbox:
|
||||
left, top, right, bottom = bbox
|
||||
info["content_size"] = [right - left, bottom - top]
|
||||
info["transparent_margins"] = {
|
||||
"left": left,
|
||||
"top": top,
|
||||
"right": im.size[0] - right,
|
||||
"bottom": im.size[1] - bottom,
|
||||
}
|
||||
return info
|
||||
|
||||
|
||||
def visual_fit_metrics(
|
||||
*,
|
||||
width_cells: int,
|
||||
height_cells: int,
|
||||
img_w_px: int,
|
||||
img_h_px: int,
|
||||
font_w: int,
|
||||
font_h: int,
|
||||
contain_rect: dict[str, int],
|
||||
alpha_bbox: list[int] | tuple[int, int, int, int] | None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Project the visible PNG alpha bbox into the TUI pane.
|
||||
|
||||
The layout planner works in cells and the PNG alpha bbox works in pixels.
|
||||
Projecting the bbox into cells gives a camera-like metric that matches the
|
||||
visual complaint: even if the full image rect is centered, is the actual
|
||||
non-transparent diagram content centered in the pane?
|
||||
"""
|
||||
if not alpha_bbox or img_w_px <= 0 or img_h_px <= 0 or font_w <= 0 or font_h <= 0:
|
||||
return None
|
||||
|
||||
left_px, top_px, right_px, bottom_px = [float(value) for value in alpha_bbox]
|
||||
render_w_px = contain_rect["width"] * font_w
|
||||
render_h_px = contain_rect["height"] * font_h
|
||||
scale_x = render_w_px / img_w_px
|
||||
scale_y = render_h_px / img_h_px
|
||||
|
||||
content_left = contain_rect["x"] + (left_px * scale_x / font_w)
|
||||
content_top = contain_rect["y"] + (top_px * scale_y / font_h)
|
||||
content_right = contain_rect["x"] + (right_px * scale_x / font_w)
|
||||
content_bottom = contain_rect["y"] + (bottom_px * scale_y / font_h)
|
||||
content_width = max(0.0, content_right - content_left)
|
||||
content_height = max(0.0, content_bottom - content_top)
|
||||
|
||||
left_blank = content_left
|
||||
right_blank = width_cells - content_right
|
||||
top_blank = content_top
|
||||
bottom_blank = height_cells - content_bottom
|
||||
center_x = (content_left + content_right) / 2.0
|
||||
center_y = (content_top + content_bottom) / 2.0
|
||||
pane_center_x = width_cells / 2.0
|
||||
pane_center_y = height_cells / 2.0
|
||||
offset_x = center_x - pane_center_x
|
||||
offset_y = center_y - pane_center_y
|
||||
|
||||
return {
|
||||
"content_bbox_cells": {
|
||||
"left": content_left,
|
||||
"top": content_top,
|
||||
"right": content_right,
|
||||
"bottom": content_bottom,
|
||||
"width": content_width,
|
||||
"height": content_height,
|
||||
},
|
||||
"blank_cells": {
|
||||
"left": left_blank,
|
||||
"right": right_blank,
|
||||
"top": top_blank,
|
||||
"bottom": bottom_blank,
|
||||
},
|
||||
"blank_imbalance_cells": {
|
||||
"horizontal_right_minus_left": right_blank - left_blank,
|
||||
"vertical_bottom_minus_top": bottom_blank - top_blank,
|
||||
},
|
||||
"content_center_offset_cells": {
|
||||
"x": offset_x,
|
||||
"y": offset_y,
|
||||
},
|
||||
"content_area_utilization_percent": utilization_percent(
|
||||
int(round(content_width * content_height * 1000)),
|
||||
int(width_cells * height_cells * 1000),
|
||||
),
|
||||
"camera_centered": abs(offset_x) <= 1.0 and abs(offset_y) <= 1.0,
|
||||
"note": (
|
||||
"Low content_area_utilization can be normal for a wide/short or tall/narrow diagram. "
|
||||
"A large content_center_offset or top/bottom blank imbalance means the camera/placement is wrong."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--inner", default="73x46", help="inner pane size in cells, WIDTHxHEIGHT")
|
||||
parser.add_argument("--image", default="1180x1470", help="rendered PNG size in px, WIDTHxHEIGHT")
|
||||
parser.add_argument("--font", default="8x16", help="terminal cell size in px, WIDTHxHEIGHT")
|
||||
parser.add_argument("--png", help="optional rendered PNG path to inspect alpha bounds")
|
||||
parser.add_argument(
|
||||
"--alpha-bbox",
|
||||
help="optional alpha/content bbox LEFT,TOP,RIGHT,BOTTOM in PNG pixels when --png is unavailable",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--assert-camera-centered",
|
||||
action="store_true",
|
||||
help="fail if projected visible content is more than one cell away from pane center",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--assert-old-clipping-repro",
|
||||
action="store_true",
|
||||
help="fail unless the old readability-floor rule would have produced the known clipping bug",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
def parse_pair(raw: str) -> tuple[int, int]:
|
||||
left, right = raw.lower().split("x", 1)
|
||||
return int(left), int(right)
|
||||
|
||||
width_cells, height_cells = parse_pair(args.inner)
|
||||
img_w_px, img_h_px = parse_pair(args.image)
|
||||
font_w, font_h = parse_pair(args.font)
|
||||
result = plan(width_cells, height_cells, img_w_px, img_h_px, font_w, font_h)
|
||||
png_info = maybe_png_info(args.png)
|
||||
if png_info is not None:
|
||||
result["png"] = png_info
|
||||
alpha_bbox = None
|
||||
if args.alpha_bbox:
|
||||
alpha_bbox = [int(part) for part in args.alpha_bbox.split(",")]
|
||||
elif png_info is not None:
|
||||
alpha_bbox = png_info.get("alpha_bbox")
|
||||
metrics = visual_fit_metrics(
|
||||
width_cells=width_cells,
|
||||
height_cells=height_cells,
|
||||
img_w_px=img_w_px,
|
||||
img_h_px=img_h_px,
|
||||
font_w=font_w,
|
||||
font_h=font_h,
|
||||
contain_rect=result["contain_rect_cells"],
|
||||
alpha_bbox=alpha_bbox,
|
||||
)
|
||||
if metrics is not None:
|
||||
result["visual_fit_metrics"] = metrics
|
||||
print(json.dumps(result, indent=2, sort_keys=True))
|
||||
if args.assert_camera_centered and metrics is not None:
|
||||
return 0 if metrics["camera_centered"] else 2
|
||||
if args.assert_old_clipping_repro:
|
||||
return 0 if result["repro_was_clipping_bug"] else 3
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Helper script to complete Claude OAuth flow with proper PKCE."""
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import sys
|
||||
import urllib.parse
|
||||
import requests
|
||||
|
||||
CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
||||
AUTHORIZE_URL = "https://claude.ai/oauth/authorize"
|
||||
TOKEN_URL = "https://console.anthropic.com/v1/oauth/token"
|
||||
REDIRECT_URI = "https://console.anthropic.com/oauth/code/callback"
|
||||
SCOPES = "org:create_api_key user:profile user:inference"
|
||||
|
||||
def generate_pkce():
|
||||
"""Generate PKCE verifier and challenge."""
|
||||
verifier = secrets.token_urlsafe(48)[:64] # 64 chars
|
||||
digest = hashlib.sha256(verifier.encode()).digest()
|
||||
challenge = base64.urlsafe_b64encode(digest).rstrip(b'=').decode()
|
||||
return verifier, challenge
|
||||
|
||||
def generate_state():
|
||||
"""Generate random state for CSRF protection."""
|
||||
return secrets.token_hex(16)
|
||||
|
||||
def main():
|
||||
if len(sys.argv) > 1 and sys.argv[1] == "--exchange":
|
||||
# Exchange mode: read code from stdin or arg
|
||||
code = sys.argv[2] if len(sys.argv) > 2 else input("Enter code: ").strip()
|
||||
|
||||
# Load saved state
|
||||
with open("/tmp/claude_oauth_state.json") as f:
|
||||
saved = json.load(f)
|
||||
|
||||
# Exchange code for tokens
|
||||
resp = requests.post(TOKEN_URL, data={
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": CLIENT_ID,
|
||||
"code": code,
|
||||
"code_verifier": saved["verifier"],
|
||||
"redirect_uri": REDIRECT_URI,
|
||||
})
|
||||
|
||||
if resp.status_code != 200:
|
||||
print(f"Error: {resp.text}")
|
||||
sys.exit(1)
|
||||
|
||||
tokens = resp.json()
|
||||
print(f"\nAccess token: {tokens['access_token'][:50]}...")
|
||||
print(f"Refresh token: {tokens['refresh_token'][:50]}...")
|
||||
|
||||
# Save in Claude Code format
|
||||
creds_dir = os.path.expanduser("~/.claude")
|
||||
os.makedirs(creds_dir, exist_ok=True)
|
||||
|
||||
import time
|
||||
expires_at = int(time.time() * 1000) + (tokens["expires_in"] * 1000)
|
||||
|
||||
creds = {
|
||||
"claudeAiOauth": {
|
||||
"accessToken": tokens["access_token"],
|
||||
"refreshToken": tokens["refresh_token"],
|
||||
"expiresAt": expires_at,
|
||||
}
|
||||
}
|
||||
|
||||
with open(os.path.join(creds_dir, ".credentials.json"), "w") as f:
|
||||
json.dump(creds, f, indent=2)
|
||||
|
||||
print(f"\nCredentials saved to ~/.claude/.credentials.json")
|
||||
else:
|
||||
# Generate new auth URL
|
||||
verifier, challenge = generate_pkce()
|
||||
state = generate_state()
|
||||
|
||||
params = {
|
||||
"response_type": "code",
|
||||
"client_id": CLIENT_ID,
|
||||
"redirect_uri": REDIRECT_URI,
|
||||
"scope": SCOPES,
|
||||
"code_challenge": challenge,
|
||||
"code_challenge_method": "S256",
|
||||
"state": state,
|
||||
}
|
||||
auth_url = f"{AUTHORIZE_URL}?{urllib.parse.urlencode(params)}"
|
||||
|
||||
with open("/tmp/claude_oauth_state.json", "w") as f:
|
||||
json.dump({
|
||||
"verifier": verifier,
|
||||
"challenge": challenge,
|
||||
"state": state,
|
||||
"auth_url": auth_url
|
||||
}, f)
|
||||
|
||||
print(f"Auth URL: {auth_url}")
|
||||
print(f"\nState saved to /tmp/claude_oauth_state.json")
|
||||
print(f"Verifier: {verifier}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+341
@@ -0,0 +1,341 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
command=${1:-help}
|
||||
if [[ $# -gt 0 ]]; then
|
||||
shift
|
||||
fi
|
||||
|
||||
sandbox_name=${JCODE_ONBOARDING_SANDBOX:-default}
|
||||
sandbox_root_default="$repo_root/.tmp/onboarding/$sandbox_name"
|
||||
sandbox_root=${JCODE_ONBOARDING_DIR:-$sandbox_root_default}
|
||||
jcode_home="$sandbox_root/home"
|
||||
runtime_dir="$sandbox_root/runtime"
|
||||
|
||||
ensure_dirs() {
|
||||
mkdir -p "$jcode_home" "$runtime_dir"
|
||||
}
|
||||
|
||||
run_in_sandbox() {
|
||||
ensure_dirs
|
||||
(
|
||||
cd "$repo_root"
|
||||
# Strip any inherited self-dev markers so the sandbox behaves like a real
|
||||
# first-run install. `--no-selfdev` only prevents *setting*
|
||||
# JCODE_CLIENT_SELFDEV_MODE; it cannot unset one inherited from a parent
|
||||
# self-dev shell, which would otherwise force every sandbox session canary
|
||||
# (suppressing the new-session suggestion cards we are trying to verify).
|
||||
env -u JCODE_CLIENT_SELFDEV_MODE -u JCODE_SELFDEV -u JCODE_CANARY \
|
||||
JCODE_HOME="$jcode_home" \
|
||||
JCODE_RUNTIME_DIR="$runtime_dir" \
|
||||
"$@"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
print_usage() {
|
||||
cat <<EOF
|
||||
Usage: $(basename "$0") <command> [args...]
|
||||
|
||||
Commands:
|
||||
env Print the sandbox environment exports
|
||||
status Show sandbox paths and current contents
|
||||
reset Delete the sandbox entirely
|
||||
shell Open a clean shell with sandbox env vars set
|
||||
jcode [args...] Run jcode inside the sandbox
|
||||
auth-status Run 'jcode auth status' inside the sandbox
|
||||
fresh [args...] Reset sandbox, then launch jcode with args
|
||||
seed-real-logins [--with-transcripts|--transcripts-only]
|
||||
Copy your REAL external logins (Codex/Claude/Gemini/
|
||||
Copilot/Cursor/OpenCode/pi) into the sandbox so the
|
||||
onboarding import step can import them. Add
|
||||
--with-transcripts to also copy Codex/Claude
|
||||
transcripts (so "continue where you left off" has data).
|
||||
Originals are never modified.
|
||||
fresh-real [--with-transcripts]
|
||||
Reset sandbox, seed your real logins, then launch jcode
|
||||
login <provider> ... Run 'jcode --provider <provider> login ...' in sandbox
|
||||
fixture-list List saved local auth fixtures
|
||||
fixture-save <name> Save current sandbox auth state as a local fixture
|
||||
fixture-load <name> Load a saved auth fixture into this sandbox
|
||||
fixture-run <name> -- [args...]
|
||||
Load a fixture, then run jcode with args
|
||||
help Show this help
|
||||
|
||||
Environment overrides:
|
||||
JCODE_ONBOARDING_SANDBOX Sandbox name (default: default)
|
||||
JCODE_ONBOARDING_DIR Explicit sandbox directory
|
||||
JCODE_AUTH_FIXTURE_DIR Fixture store (default: .tmp/auth-fixtures)
|
||||
|
||||
Examples:
|
||||
$(basename "$0") fresh
|
||||
$(basename "$0") login openai
|
||||
$(basename "$0") fixture-save normal-openai
|
||||
$(basename "$0") fixture-load normal-openai
|
||||
$(basename "$0") auth-status
|
||||
EOF
|
||||
}
|
||||
|
||||
print_env() {
|
||||
ensure_dirs
|
||||
cat <<EOF
|
||||
export JCODE_HOME="$jcode_home"
|
||||
export JCODE_RUNTIME_DIR="$runtime_dir"
|
||||
EOF
|
||||
}
|
||||
|
||||
status() {
|
||||
ensure_dirs
|
||||
echo "Sandbox name: $sandbox_name"
|
||||
echo "Sandbox root: $sandbox_root"
|
||||
echo "JCODE_HOME: $jcode_home"
|
||||
echo "RUNTIME_DIR: $runtime_dir"
|
||||
echo
|
||||
|
||||
if [[ -d "$jcode_home" ]]; then
|
||||
echo "Home contents:"
|
||||
find "$jcode_home" -maxdepth 3 \( -type f -o -type d \) | sed "s#^$sandbox_root#.#" | sort
|
||||
fi
|
||||
}
|
||||
|
||||
reset() {
|
||||
rm -rf "$sandbox_root"
|
||||
echo "Removed onboarding sandbox: $sandbox_root"
|
||||
}
|
||||
|
||||
open_shell() {
|
||||
ensure_dirs
|
||||
echo "Opening sandbox shell"
|
||||
echo " JCODE_HOME=$jcode_home"
|
||||
echo " JCODE_RUNTIME_DIR=$runtime_dir"
|
||||
env JCODE_HOME="$jcode_home" JCODE_RUNTIME_DIR="$runtime_dir" bash --noprofile --norc
|
||||
}
|
||||
|
||||
run_jcode() {
|
||||
# The sandbox should behave like a real standalone install, not a self-dev
|
||||
# client. Because we launch from inside the repo, jcode would otherwise
|
||||
# auto-detect the repository and join the shared self-dev server (remote
|
||||
# mode), which both breaks isolation and skips local-only first-run behavior
|
||||
# like the new-session model validation. `--no-selfdev` keeps it standalone,
|
||||
# spawning its own server under the sandbox's JCODE_RUNTIME_DIR. Set
|
||||
# JCODE_SANDBOX_SELFDEV=1 to opt back into the shared-server behavior.
|
||||
local prefix=()
|
||||
if [[ "${JCODE_SANDBOX_SELFDEV:-0}" != "1" ]]; then
|
||||
prefix=(--no-selfdev)
|
||||
fi
|
||||
# Allow pointing the sandbox at an already-built binary (e.g. the selfdev
|
||||
# profile output) without rebuilding the debug binary. Falls back to the
|
||||
# debug binary, then to `cargo run`.
|
||||
if [[ -n "${JCODE_SANDBOX_BIN:-}" ]]; then
|
||||
if [[ -x "$JCODE_SANDBOX_BIN" ]]; then
|
||||
run_in_sandbox "$JCODE_SANDBOX_BIN" "${prefix[@]}" "$@"
|
||||
return
|
||||
fi
|
||||
echo "JCODE_SANDBOX_BIN=$JCODE_SANDBOX_BIN is not executable" >&2
|
||||
return 1
|
||||
fi
|
||||
local binary_path="$repo_root/target/debug/jcode"
|
||||
if [[ -x "$binary_path" ]]; then
|
||||
run_in_sandbox "$binary_path" "${prefix[@]}" "$@"
|
||||
else
|
||||
run_in_sandbox cargo run --bin jcode -- "${prefix[@]}" "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
run_auth_fixture() {
|
||||
JCODE_ONBOARDING_SANDBOX="$sandbox_name" \
|
||||
JCODE_ONBOARDING_DIR="$sandbox_root" \
|
||||
"$repo_root/scripts/auth_fixture.sh" "$@"
|
||||
}
|
||||
|
||||
# Copy one real file from $HOME into the sandbox's external/ tree, preserving its
|
||||
# relative path. jcode resolves every external credential/transcript lookup to
|
||||
# $JCODE_HOME/external/<same-relative-path-as-$HOME> when JCODE_HOME is set, so
|
||||
# seeding here makes your real logins/transcripts visible to the onboarding
|
||||
# import + continue steps. Copies (never symlinks: jcode rejects symlinked auth
|
||||
# files) and never touches the originals.
|
||||
seed_one_file() {
|
||||
local rel=$1
|
||||
local src="$HOME/$rel"
|
||||
local dst="$jcode_home/external/$rel"
|
||||
if [[ ! -e "$src" ]]; then
|
||||
return 1
|
||||
fi
|
||||
mkdir -p "$(dirname "$dst")"
|
||||
cp -a "$src" "$dst"
|
||||
chmod -R go-rwx "$jcode_home/external" 2>/dev/null || true
|
||||
return 0
|
||||
}
|
||||
|
||||
# Copy a real directory subtree (e.g. transcript stores) into external/.
|
||||
seed_one_dir() {
|
||||
local rel=$1
|
||||
local src="$HOME/$rel"
|
||||
local dst="$jcode_home/external/$rel"
|
||||
if [[ ! -d "$src" ]]; then
|
||||
return 1
|
||||
fi
|
||||
mkdir -p "$dst"
|
||||
cp -a "$src/." "$dst/"
|
||||
chmod -R go-rwx "$jcode_home/external" 2>/dev/null || true
|
||||
return 0
|
||||
}
|
||||
|
||||
# Seed the sandbox with copies of your real external logins (and, with
|
||||
# --with-transcripts, your Codex/Claude transcripts) so onboarding's import and
|
||||
# "continue where you left off" steps act on real data.
|
||||
seed_real_logins() {
|
||||
ensure_dirs
|
||||
|
||||
local with_transcripts=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--with-transcripts) with_transcripts=1 ;;
|
||||
--transcripts-only) with_transcripts=2 ;;
|
||||
*) echo "Unknown seed-real-logins option: $arg" >&2; return 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Auth/credential files the external-import detectors read, each relative to
|
||||
# $HOME (mirrors crate::storage::user_home_path and the per-provider paths).
|
||||
local auth_files=(
|
||||
".codex/auth.json"
|
||||
".claude/.credentials.json"
|
||||
".claude.json"
|
||||
".local/share/opencode/auth.json"
|
||||
".pi/agent/auth.json"
|
||||
".gemini/oauth_creds.json"
|
||||
".config/github-copilot/hosts.json"
|
||||
".config/github-copilot/apps.json"
|
||||
".cursor/auth.json"
|
||||
".config/cursor/auth.json"
|
||||
".config/Cursor/User/globalStorage/state.vscdb"
|
||||
".config/cursor/User/globalStorage/state.vscdb"
|
||||
)
|
||||
|
||||
# Transcript stores the "continue where you left off" picker reads.
|
||||
local transcript_dirs=(
|
||||
".codex/sessions"
|
||||
".claude/projects"
|
||||
)
|
||||
|
||||
local seeded=()
|
||||
local skipped=()
|
||||
|
||||
if [[ $with_transcripts -ne 2 ]]; then
|
||||
for rel in "${auth_files[@]}"; do
|
||||
if seed_one_file "$rel"; then
|
||||
seeded+=("$rel")
|
||||
else
|
||||
skipped+=("$rel")
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ $with_transcripts -ge 1 ]]; then
|
||||
for rel in "${transcript_dirs[@]}"; do
|
||||
if seed_one_dir "$rel"; then
|
||||
seeded+=("$rel/ (transcripts)")
|
||||
else
|
||||
skipped+=("$rel/ (transcripts)")
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
echo "Seeded real logins into sandbox external dir:"
|
||||
echo " $jcode_home/external"
|
||||
echo
|
||||
if [[ ${#seeded[@]} -gt 0 ]]; then
|
||||
echo "Copied:"
|
||||
for rel in "${seeded[@]}"; do
|
||||
echo " + $rel"
|
||||
done
|
||||
else
|
||||
echo "Copied nothing (no matching real files found under \$HOME)."
|
||||
fi
|
||||
if [[ ${#skipped[@]} -gt 0 ]]; then
|
||||
echo
|
||||
echo "Not present (skipped):"
|
||||
for rel in "${skipped[@]}"; do
|
||||
echo " - $rel"
|
||||
done
|
||||
fi
|
||||
echo
|
||||
echo "These are copies; your real \$HOME files are untouched."
|
||||
echo "Onboarding will now offer to import them. Start it with:"
|
||||
echo " $(basename "$0") jcode"
|
||||
}
|
||||
|
||||
scenario_arg() {
|
||||
if [[ $# -gt 0 ]]; then
|
||||
printf '%s' "$1"
|
||||
else
|
||||
printf 'onboarding'
|
||||
fi
|
||||
}
|
||||
|
||||
case "$command" in
|
||||
env)
|
||||
print_env
|
||||
;;
|
||||
status)
|
||||
status
|
||||
;;
|
||||
reset)
|
||||
reset
|
||||
;;
|
||||
shell)
|
||||
open_shell
|
||||
;;
|
||||
jcode)
|
||||
run_jcode "$@"
|
||||
;;
|
||||
auth-status)
|
||||
run_jcode auth status
|
||||
;;
|
||||
fresh)
|
||||
reset
|
||||
run_jcode "$@"
|
||||
;;
|
||||
seed-real-logins)
|
||||
seed_real_logins "$@"
|
||||
;;
|
||||
fresh-real)
|
||||
reset
|
||||
seed_real_logins "$@"
|
||||
echo
|
||||
echo "Launching sandbox jcode with your real logins available to import..."
|
||||
run_jcode
|
||||
;;
|
||||
login)
|
||||
if [[ $# -lt 1 ]]; then
|
||||
echo "login requires a provider, for example: $(basename "$0") login openai" >&2
|
||||
exit 1
|
||||
fi
|
||||
provider=$1
|
||||
shift
|
||||
run_jcode --provider "$provider" login "$@"
|
||||
;;
|
||||
fixture-list)
|
||||
run_auth_fixture list
|
||||
;;
|
||||
fixture-save)
|
||||
run_auth_fixture save "$@"
|
||||
;;
|
||||
fixture-load)
|
||||
run_auth_fixture load "$@"
|
||||
;;
|
||||
fixture-run)
|
||||
run_auth_fixture run "$@"
|
||||
;;
|
||||
help|-h|--help)
|
||||
print_usage
|
||||
;;
|
||||
*)
|
||||
echo "Unknown command: $command" >&2
|
||||
echo >&2
|
||||
print_usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"total": 33,
|
||||
"tracked_files": {
|
||||
"crates/jcode-app-core/src/session_launch.rs": 1,
|
||||
"crates/jcode-app-core/src/tool/computer/win.rs": 3,
|
||||
"crates/jcode-base/src/auth/oauth.rs": 3,
|
||||
"crates/jcode-base/src/hooks.rs": 1,
|
||||
"crates/jcode-desktop/src/single_session_render/wrapping.rs": 1,
|
||||
"crates/jcode-provider-doctor/src/lifecycle_driver.rs": 2,
|
||||
"crates/jcode-terminal-launch/src/lib.rs": 1,
|
||||
"crates/jcode-tui-core/src/stream_buffer.rs": 3,
|
||||
"crates/jcode-tui/src/tui/app/helpers.rs": 1,
|
||||
"crates/jcode-tui/src/tui/session_picker.rs": 2,
|
||||
"src/bin/memory_recall_bench.rs": 14,
|
||||
"src/cli/commands/menubar.rs": 1
|
||||
},
|
||||
"version": 1
|
||||
}
|
||||
@@ -0,0 +1,568 @@
|
||||
# Phone-server AWS IAM least-privilege assessment
|
||||
|
||||
Assessment date: 2026-07-12
|
||||
Account: `302154194530`
|
||||
Region: `us-east-1`
|
||||
|
||||
This document began as a design review. The live hardening described in `README.md` was subsequently applied and verified. The remaining deliberate gap is removal of `AdministratorAccess` from `jade-deploy`, which is blocked until an independent root/MFA recovery login is verified.
|
||||
|
||||
## Executive assessment
|
||||
|
||||
`jade-deploy` should not retain `AdministratorAccess`. A compromised long-lived deployment credential can currently modify identities, disable cost guardrails, exfiltrate data, create arbitrary infrastructure, and establish persistence anywhere in the account.
|
||||
|
||||
Replace it with an assume-role-only principal and three distinct privilege planes:
|
||||
|
||||
1. **`JcodePhoneOperator`** for normal deployments and maintenance of the existing stack.
|
||||
2. **`JcodePhoneProvisioner`** for infrequent rebuilds, MFA-gated, time-limited, restricted to `us-east-1`, `jcode-phone-*` resources, and bounded runtime roles.
|
||||
3. **A separate emergency administrator path** protected by MFA and never used by automation. This is required to avoid lockout while removing the existing administrator attachment.
|
||||
|
||||
Also replace the instance's `AmazonBedrockFullAccess` with inference-only permissions. The code uses model catalog discovery and `ConverseStream`; it does not need Bedrock administration.
|
||||
|
||||
## Evidence and stated live resources
|
||||
|
||||
Repository evidence:
|
||||
|
||||
- `README.md` states account `302154194530`, region `us-east-1`, instance `i-08214cf66cd3f80c7`, Elastic IP `54.196.207.97`, and API ID `8c3wp4cbag`.
|
||||
- `wake-lambda.py` calls `ec2:DescribeInstances` and `ec2:StartInstances` for that instance.
|
||||
- `breaker-lambda.py` calls `ec2:DescribeInstances`, `ec2:StopInstances`, and `sns:Publish` to `arn:aws:sns:us-east-1:302154194530:jcode-guard-warn`.
|
||||
- The Bedrock provider calls `ListFoundationModels`, `ListInferenceProfiles`, and the streaming Converse API. Converse streaming is authorized by `bedrock:InvokeModelWithResponseStream`.
|
||||
- The rebuild instructions require EC2, EIP, security group, IAM instance profile, Lambda, API Gateway v2, SNS, CloudWatch alarms, and Budgets administration.
|
||||
- TestFlight automation is App Store Connect only and requires no AWS permission.
|
||||
|
||||
The SSM-based wake implementation is now deployed. The Lambda generates pair codes through SSM Run Command, and the legacy public `:7644` path is disabled.
|
||||
|
||||
Stated named resources:
|
||||
|
||||
| Type | Resource |
|
||||
|---|---|
|
||||
| EC2 instance | `arn:aws:ec2:us-east-1:302154194530:instance/i-08214cf66cd3f80c7` |
|
||||
| Elastic IP | `54.196.207.97`; allocation ID must be inventoried |
|
||||
| Lambda | `jcode-phone-wake` |
|
||||
| Lambda | `jcode-guard-breaker` |
|
||||
| API Gateway v2 | `8c3wp4cbag` |
|
||||
| SNS | `jcode-guard-stop` and `jcode-guard-warn` |
|
||||
| CloudWatch alarms | `jcode-bedrock-tokens-warn`, `jcode-bedrock-tokens-stop`; the ineffective billing alarms were replaced by the working Budget/SNS breaker path |
|
||||
| Budget | `jcode-dev-monthly-cost` |
|
||||
| Bedrock model route | `us.anthropic.claude-opus-4-6-v1` |
|
||||
|
||||
### Live-state verification
|
||||
|
||||
The account was subsequently inventoried through the `jcode-bedrock` profile. Runtime role names, Lambda roles, the EC2 instance and security group, the Elastic IP, Budget subscribers, CloudWatch alarms, log retention, API Gateway stage, S3/DynamoDB resources, and access-key metadata were verified directly. The wake Lambda now uses SSM pairing, all public EC2 ingress is closed, the root EBS volume is encrypted, CloudTrail and Access Analyzer are enabled, and the old deployment key is inactive.
|
||||
|
||||
## Target identity design
|
||||
|
||||
### Human access
|
||||
|
||||
Preferred end state:
|
||||
|
||||
- Use IAM Identity Center or another federated identity for the human operator.
|
||||
- Require MFA for both operator and provisioner role assumption.
|
||||
- Do not create long-lived keys for human access.
|
||||
- Keep `jade-deploy` only during migration, then delete it after the observation period.
|
||||
|
||||
Transitional end state if an IAM user must remain:
|
||||
|
||||
- `jade-deploy` has no console password and no service permissions.
|
||||
- Its only permission is `sts:AssumeRole` for the two deployment roles.
|
||||
- It cannot modify itself, policies, access keys, MFA devices, or role trust policies.
|
||||
|
||||
Assume-only policy for `jade-deploy`:
|
||||
|
||||
```json
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "AssumePhoneServerRolesOnly",
|
||||
"Effect": "Allow",
|
||||
"Action": "sts:AssumeRole",
|
||||
"Resource": [
|
||||
"arn:aws:iam::302154194530:role/jcode-phone/JcodePhoneOperator",
|
||||
"arn:aws:iam::302154194530:role/jcode-phone/JcodePhoneProvisioner"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Require MFA in both role trust policies for a human IAM principal:
|
||||
|
||||
```json
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"AWS": "arn:aws:iam::302154194530:user/jade-deploy"
|
||||
},
|
||||
"Action": "sts:AssumeRole",
|
||||
"Condition": {
|
||||
"Bool": { "aws:MultiFactorAuthPresent": "true" },
|
||||
"NumericLessThan": { "aws:MultiFactorAuthAge": "3600" }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Set `JcodePhoneProvisioner` maximum session duration to one hour. Do not allow either role to change its own trust or permissions.
|
||||
|
||||
For unattended CI, use a separate OIDC-federated role restricted to the exact repository, branch/environment, and workflow. Do not weaken the human role's MFA trust for CI.
|
||||
|
||||
## Runtime policies
|
||||
|
||||
Use separate execution roles for the instance and each Lambda. Do not reuse the deploy role at runtime.
|
||||
|
||||
### EC2 instance: inference only
|
||||
|
||||
Replace `AmazonBedrockFullAccess` with the following customer-managed policy. `List*` actions require `Resource: "*"`. The inference resources deliberately cover only the configured Claude Opus 4.6 cross-region profile and its backing foundation model. Cross-region inference can route outside `us-east-1`, so a single-region foundation-model ARN may fail.
|
||||
|
||||
```json
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "DiscoverBedrockModels",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"bedrock:ListFoundationModels",
|
||||
"bedrock:ListInferenceProfiles"
|
||||
],
|
||||
"Resource": "*"
|
||||
},
|
||||
{
|
||||
"Sid": "InvokeConfiguredClaudeProfile",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"bedrock:InvokeModel",
|
||||
"bedrock:InvokeModelWithResponseStream"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:bedrock:us-east-1:302154194530:inference-profile/us.anthropic.claude-opus-4-6-v1",
|
||||
"arn:aws:bedrock:*::foundation-model/anthropic.claude-opus-4-6-v1:0"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Verify the exact inference-profile ARN and backing model IDs with `bedrock list-inference-profiles` before application. If the deployed profile is AWS-owned or the backing model has a different version suffix, substitute the returned ARNs rather than widening the model-name pattern. The wildcard region is intentional because a US cross-region inference profile can route to multiple US regions. Remove `bedrock:InvokeModel` after testing if all production paths exclusively use `ConverseStream`; retaining it is a small compatibility allowance, not administrative access. The provider's optional STS identity validation does not require an added service permission for normal operation.
|
||||
|
||||
### Wake Lambda
|
||||
|
||||
```json
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "ReadTargetInstanceState",
|
||||
"Effect": "Allow",
|
||||
"Action": "ec2:DescribeInstances",
|
||||
"Resource": "*"
|
||||
},
|
||||
{
|
||||
"Sid": "StartTargetInstanceOnly",
|
||||
"Effect": "Allow",
|
||||
"Action": "ec2:StartInstances",
|
||||
"Resource": "arn:aws:ec2:us-east-1:302154194530:instance/i-08214cf66cd3f80c7"
|
||||
},
|
||||
{
|
||||
"Sid": "WriteOwnLogs",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"logs:CreateLogStream",
|
||||
"logs:PutLogEvents"
|
||||
],
|
||||
"Resource": "arn:aws:logs:us-east-1:302154194530:log-group:/aws/lambda/jcode-phone-wake:*"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Create the log group during provisioning with retention configured, rather than granting runtime `logs:CreateLogGroup` on `*`.
|
||||
|
||||
If the SSM-based wake implementation is deployed, add:
|
||||
|
||||
```json
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "ReadManagedInstanceStatus",
|
||||
"Effect": "Allow",
|
||||
"Action": "ssm:DescribeInstanceInformation",
|
||||
"Resource": "*"
|
||||
},
|
||||
{
|
||||
"Sid": "RunPairCommandOnTargetOnly",
|
||||
"Effect": "Allow",
|
||||
"Action": "ssm:SendCommand",
|
||||
"Resource": [
|
||||
"arn:aws:ec2:us-east-1:302154194530:instance/i-08214cf66cd3f80c7",
|
||||
"arn:aws:ssm:us-east-1::document/AWS-RunShellScript"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Sid": "ReadPairCommandResult",
|
||||
"Effect": "Allow",
|
||||
"Action": "ssm:GetCommandInvocation",
|
||||
"Resource": "*"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
That variant also requires an SSM-managed-instance policy on the EC2 role. Keep it separate from the Bedrock policy, validate the exact Systems Manager agent calls in CloudTrail, and narrow it from `AmazonSSMManagedInstanceCore` where operationally practical. Treat `ssm:SendCommand` as privileged remote code execution and keep it scoped to the one instance and AWS-managed document.
|
||||
|
||||
### Breaker Lambda
|
||||
|
||||
```json
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "ReadTargetInstanceState",
|
||||
"Effect": "Allow",
|
||||
"Action": "ec2:DescribeInstances",
|
||||
"Resource": "*"
|
||||
},
|
||||
{
|
||||
"Sid": "StopTargetInstanceOnly",
|
||||
"Effect": "Allow",
|
||||
"Action": "ec2:StopInstances",
|
||||
"Resource": "arn:aws:ec2:us-east-1:302154194530:instance/i-08214cf66cd3f80c7"
|
||||
},
|
||||
{
|
||||
"Sid": "PublishGuardNotification",
|
||||
"Effect": "Allow",
|
||||
"Action": "sns:Publish",
|
||||
"Resource": "arn:aws:sns:us-east-1:302154194530:jcode-guard-warn"
|
||||
},
|
||||
{
|
||||
"Sid": "WriteOwnLogs",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"logs:CreateLogStream",
|
||||
"logs:PutLogEvents"
|
||||
],
|
||||
"Resource": "arn:aws:logs:us-east-1:302154194530:log-group:/aws/lambda/jcode-guard-breaker:*"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Daily operator policy
|
||||
|
||||
This role updates and diagnoses existing resources but cannot create identities, create arbitrary compute, terminate the instance, release the EIP, delete guardrails, or alter role trust.
|
||||
|
||||
Replace the `<...>` values after read-only inventory. The API Gateway resource form is intentionally the API Gateway management ARN, which does not include the account ID.
|
||||
|
||||
```json
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "DescribePhoneServerInfrastructure",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"ec2:DescribeInstances",
|
||||
"ec2:DescribeInstanceStatus",
|
||||
"ec2:DescribeAddresses",
|
||||
"ec2:DescribeSecurityGroups",
|
||||
"ec2:DescribeVolumes",
|
||||
"cloudwatch:DescribeAlarms",
|
||||
"cloudwatch:GetMetricData",
|
||||
"cloudwatch:GetMetricStatistics",
|
||||
"cloudwatch:ListMetrics"
|
||||
],
|
||||
"Resource": "*"
|
||||
},
|
||||
{
|
||||
"Sid": "ReadExistingFunctions",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"lambda:GetFunction",
|
||||
"lambda:GetFunctionConfiguration",
|
||||
"lambda:GetPolicy"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:lambda:us-east-1:302154194530:function:jcode-phone-wake",
|
||||
"arn:aws:lambda:us-east-1:302154194530:function:jcode-phone-wake:*",
|
||||
"arn:aws:lambda:us-east-1:302154194530:function:jcode-guard-breaker",
|
||||
"arn:aws:lambda:us-east-1:302154194530:function:jcode-guard-breaker:*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Sid": "OperateExistingInstance",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"ec2:StartInstances",
|
||||
"ec2:StopInstances",
|
||||
"ec2:RebootInstances",
|
||||
"ec2:ModifyInstanceAttribute"
|
||||
],
|
||||
"Resource": "arn:aws:ec2:us-east-1:302154194530:instance/i-08214cf66cd3f80c7"
|
||||
},
|
||||
{
|
||||
"Sid": "DeployExistingFunctions",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"lambda:UpdateFunctionCode",
|
||||
"lambda:UpdateFunctionConfiguration",
|
||||
"lambda:PublishVersion",
|
||||
"lambda:CreateAlias",
|
||||
"lambda:UpdateAlias",
|
||||
"lambda:DeleteAlias",
|
||||
"lambda:InvokeFunction"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:lambda:us-east-1:302154194530:function:jcode-phone-wake",
|
||||
"arn:aws:lambda:us-east-1:302154194530:function:jcode-phone-wake:*",
|
||||
"arn:aws:lambda:us-east-1:302154194530:function:jcode-guard-breaker",
|
||||
"arn:aws:lambda:us-east-1:302154194530:function:jcode-guard-breaker:*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Sid": "ReadAndPatchExistingHttpApiRoot",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"apigateway:GET",
|
||||
"apigateway:PATCH"
|
||||
],
|
||||
"Resource": "arn:aws:apigateway:us-east-1::/apis/8c3wp4cbag"
|
||||
},
|
||||
{
|
||||
"Sid": "MaintainExistingHttpApiChildren",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"apigateway:GET",
|
||||
"apigateway:POST",
|
||||
"apigateway:PUT",
|
||||
"apigateway:PATCH",
|
||||
"apigateway:DELETE"
|
||||
],
|
||||
"Resource": "arn:aws:apigateway:us-east-1::/apis/8c3wp4cbag/*"
|
||||
},
|
||||
{
|
||||
"Sid": "PassInstanceRoleToEc2Only",
|
||||
"Effect": "Allow",
|
||||
"Action": "iam:PassRole",
|
||||
"Resource": "arn:aws:iam::302154194530:role/jcode-phone/runtime/JcodePhoneInstance",
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"iam:PassedToService": "ec2.amazonaws.com"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "PassLambdaRolesToLambdaOnly",
|
||||
"Effect": "Allow",
|
||||
"Action": "iam:PassRole",
|
||||
"Resource": [
|
||||
"arn:aws:iam::302154194530:role/jcode-phone/runtime/JcodePhoneWakeLambda",
|
||||
"arn:aws:iam::302154194530:role/jcode-phone/runtime/JcodePhoneBreakerLambda"
|
||||
],
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"iam:PassedToService": "lambda.amazonaws.com"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "ReadPhoneLogs",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"logs:DescribeLogStreams",
|
||||
"logs:GetLogEvents",
|
||||
"logs:FilterLogEvents"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:logs:us-east-1:302154194530:log-group:/aws/lambda/jcode-phone-wake:*",
|
||||
"arn:aws:logs:us-east-1:302154194530:log-group:/aws/lambda/jcode-guard-breaker:*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Sid": "MaintainGuardTopics",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"sns:GetTopicAttributes",
|
||||
"sns:ListSubscriptionsByTopic",
|
||||
"sns:Publish"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:sns:us-east-1:302154194530:jcode-guard-stop",
|
||||
"arn:aws:sns:us-east-1:302154194530:jcode-guard-warn"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Sid": "ReadNamedBudget",
|
||||
"Effect": "Allow",
|
||||
"Action": "budgets:ViewBudget",
|
||||
"Resource": "arn:aws:budgets::302154194530:budget/jcode-dev-monthly-cost"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Recommended tightening:
|
||||
|
||||
- Remove `ec2:ModifyInstanceAttribute` if routine maintenance never changes shutdown behavior, instance type, source/destination check, or attached profile.
|
||||
- Keep `lambda:AddPermission`, `lambda:RemovePermission`, `sns:Subscribe`, alarm mutation, and budget mutation in the MFA-gated provisioner path. Those actions can expose invocations, exfiltrate notifications, or disable cost controls.
|
||||
- API Gateway `DELETE` is allowed only below `/apis/8c3wp4cbag/*`; the daily role cannot delete the API root.
|
||||
- Do not grant `cloudwatch:DeleteAlarms`, `budgets:DeleteBudget`, `sns:DeleteTopic`, `ec2:TerminateInstances`, `ec2:ReleaseAddress`, or IAM write actions to the daily operator.
|
||||
|
||||
## Rebuild/provisioner role
|
||||
|
||||
A rebuild inherently needs create/delete privileges on several services. Keep those permissions out of the daily role. The safest maintainable implementation is to put the stack in CloudFormation, CDK, or Terraform and let the human invoke only a named stack deployment role.
|
||||
|
||||
Recommended model:
|
||||
|
||||
- Human `JcodePhoneProvisioner`: CloudFormation stack operations on `jcode-phone-server*`, read-only diagnostics, and `iam:PassRole` only for `JcodePhoneCloudFormationExecution` with `iam:PassedToService = cloudformation.amazonaws.com`.
|
||||
- `JcodePhoneCloudFormationExecution`: service permissions below, usable only by CloudFormation.
|
||||
- Every created resource is tagged `Project=jcode-phone-server` and `ManagedBy=cloudformation`.
|
||||
- Every created runtime role is under path `/jcode-phone/runtime/` and must carry the `JcodePhoneRuntimeBoundary` permissions boundary.
|
||||
|
||||
Human provisioner policy:
|
||||
|
||||
```json
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "ManageNamedPhoneStack",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"cloudformation:CreateStack",
|
||||
"cloudformation:UpdateStack",
|
||||
"cloudformation:DeleteStack",
|
||||
"cloudformation:DescribeStacks",
|
||||
"cloudformation:DescribeStackEvents",
|
||||
"cloudformation:DescribeStackResources",
|
||||
"cloudformation:GetTemplate",
|
||||
"cloudformation:GetTemplateSummary",
|
||||
"cloudformation:ListStackResources",
|
||||
"cloudformation:CreateChangeSet",
|
||||
"cloudformation:DescribeChangeSet",
|
||||
"cloudformation:ExecuteChangeSet",
|
||||
"cloudformation:DeleteChangeSet",
|
||||
"cloudformation:ValidateTemplate"
|
||||
],
|
||||
"Resource": [
|
||||
"arn:aws:cloudformation:us-east-1:302154194530:stack/jcode-phone-server*/*",
|
||||
"arn:aws:cloudformation:us-east-1:302154194530:changeSet/jcode-phone-server*/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Sid": "ValidateNewTemplate",
|
||||
"Effect": "Allow",
|
||||
"Action": "cloudformation:ValidateTemplate",
|
||||
"Resource": "*"
|
||||
},
|
||||
{
|
||||
"Sid": "PassPhoneCloudFormationExecutionRole",
|
||||
"Effect": "Allow",
|
||||
"Action": "iam:PassRole",
|
||||
"Resource": "arn:aws:iam::302154194530:role/jcode-phone/JcodePhoneCloudFormationExecution",
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"iam:PassedToService": "cloudformation.amazonaws.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The CloudFormation execution role should permit only this service/action envelope:
|
||||
|
||||
| Service | Required rebuild operations | Scope/guardrail |
|
||||
|---|---|---|
|
||||
| EC2 | Run/terminate the one server; create/tag volume and ENI; create/manage one SG; allocate/associate/release one EIP; modify shutdown behavior; describe AMIs/subnets/VPCs | `us-east-1`; request/resource tag `Project=jcode-phone-server`; approved instance types only; IMDSv2 required; approved VPC/subnet |
|
||||
| IAM | Create/update/delete the three runtime roles and one instance profile; put/delete inline policies; pass roles | Path `/jcode-phone/runtime/` only; require boundary ARN on `CreateRole`; never allow changing deploy/provisioner/emergency roles |
|
||||
| Lambda | Create/update/delete the two named functions; versions/aliases; permissions | Function ARN prefix `jcode-phone-*` and `jcode-guard-breaker*`; project tag |
|
||||
| API Gateway v2 | Create/update/delete one HTTP API, integration, route, and stage | Project tag and stack ownership |
|
||||
| SNS | Create/manage/delete `jcode-guard-stop` and `jcode-guard-warn`; subscriptions | Exact topic-name ARNs |
|
||||
| CloudWatch | Create/update/delete the four named alarms | Exact alarm-name ARNs |
|
||||
| Logs | Create/configure/delete the two Lambda log groups | Exact `/aws/lambda/...` ARNs; retention required |
|
||||
| Budgets | Create/update/delete `jcode-dev-monthly-cost` | Exact budget ARN |
|
||||
|
||||
The execution role, not the daily operator, also owns `lambda:AddPermission`/`RemovePermission`, `sns:Subscribe`/`Unsubscribe`, CloudWatch alarm mutation, and budget mutation. This preserves integration and guardrail maintenance without making those high-impact actions part of everyday credentials.
|
||||
|
||||
Do not give the CloudFormation execution role general `iam:*`, `organizations:*`, `account:*`, `sts:AssumeRole`, `kms:*`, unrestricted `s3:*`, unrestricted `secretsmanager:*`, or permission to modify the provisioner, operator, emergency role, its own role, or its own boundary.
|
||||
|
||||
A permissions boundary is not a grant. Use it as a maximum for runtime roles and combine it with their narrow inline policies. The boundary should allow only:
|
||||
|
||||
- The instance's Bedrock discovery and configured-model inference permissions.
|
||||
- The wake Lambda's start/describe and own-log permissions.
|
||||
- The breaker Lambda's stop/describe, warning publish, and own-log permissions.
|
||||
|
||||
Because these permissions differ, the boundary can be their union; each role's inline policy must remain the narrower subset. Explicitly deny IAM, STS role assumption, Organizations, account administration, and access-key operations in the boundary as defense in depth.
|
||||
|
||||
## Read-only inventory required before migration
|
||||
|
||||
Run these with the existing administrator session after reauthentication. They make no changes:
|
||||
|
||||
```bash
|
||||
aws sts get-caller-identity
|
||||
aws iam list-attached-user-policies --user-name jade-deploy
|
||||
aws iam list-user-policies --user-name jade-deploy
|
||||
aws iam list-access-keys --user-name jade-deploy
|
||||
aws iam get-account-authorization-details > phone-server-iam-before.json
|
||||
|
||||
aws ec2 describe-instances --instance-ids i-08214cf66cd3f80c7 --region us-east-1
|
||||
aws ec2 describe-addresses --public-ips 54.196.207.97 --region us-east-1
|
||||
aws lambda get-function --function-name jcode-phone-wake --region us-east-1
|
||||
aws lambda get-function --function-name jcode-guard-breaker --region us-east-1
|
||||
aws apigatewayv2 get-api --api-id 8c3wp4cbag --region us-east-1
|
||||
aws sns list-topics --region us-east-1
|
||||
aws cloudwatch describe-alarms --alarm-name-prefix jcode- --region us-east-1
|
||||
aws budgets describe-budget --account-id 302154194530 --budget-name jcode-dev-monthly-cost
|
||||
aws bedrock list-inference-profiles --type-equals SYSTEM_DEFINED --region us-east-1
|
||||
```
|
||||
|
||||
Also inventory CloudTrail usage for at least 30 days. Add an action only when it corresponds to a known deploy/maintenance operation. Access Analyzer policy generation from CloudTrail is useful as a second opinion, but should not replace review because rarely used recovery operations may be absent.
|
||||
|
||||
## Lockout-safe migration and credential rotation
|
||||
|
||||
1. **Prepare recovery first.** Verify the account root email, root MFA, and recovery contacts. Create or verify a separate MFA-protected emergency administrator path. Prefer IAM Identity Center. It must be independent of `jade-deploy`, have no access key, and be tested before touching `AdministratorAccess`.
|
||||
2. **Capture state.** Export IAM authorization details, user policy attachments, role trust policies, access-key metadata, resource ARNs, and tags. Record the exact `AdministratorAccess` attachment being replaced.
|
||||
3. **Create runtime policies and roles.** Create the narrow instance, wake, and breaker roles. Do not switch workloads yet. Validate their documents with IAM Access Analyzer or `aws accessanalyzer validate-policy`.
|
||||
4. **Create operator and provisioner roles.** Add MFA-gated trust. Ensure neither role can modify itself, the emergency path, permission boundaries, or arbitrary identities.
|
||||
5. **Grant assume-only access while admin remains attached.** Attach the small `sts:AssumeRole` policy to `jade-deploy`, but leave `AdministratorAccess` temporarily attached.
|
||||
6. **Test role entry.** From a distinct profile/session, assume each role and confirm `aws sts get-caller-identity` reports the role ARN. Confirm operator reads for EC2, Lambda, API Gateway, logs, SNS, alarms, and budget.
|
||||
7. **Simulate every required API call.** Use `iam:SimulatePrincipalPolicy` or the policy simulator for the action/resource matrix in this document. Explicitly test denied controls such as creating users, attaching `AdministratorAccess`, terminating arbitrary instances, and deleting guardrails.
|
||||
8. **Canary mutation paths without touching production.** Through the provisioner, deploy and remove a tiny tagged canary stack using the same resource classes where practical. For the operator, update a disposable Lambda alias or canary function rather than invoking `jcode-guard-breaker`, which stops production. Do not use the breaker invocation as an IAM test.
|
||||
9. **Switch runtime roles one at a time.** First update Lambda execution roles and verify logs plus harmless wake status checks. Then replace the EC2 instance profile and run a real Bedrock streaming request. Keep the previous roles intact but unattached until verification completes.
|
||||
10. **Rotate the credential transport.** Preferred: switch the workstation to IAM Identity Center and role profiles. Transitional IAM-user option: create a second `jade-deploy` access key, configure it under a new profile, and verify role assumption. Never overwrite the only working profile first. An IAM user can have at most two keys.
|
||||
11. **Detach `AdministratorAccess` only after independent recovery succeeds.** Confirm the emergency administrator path in a separate browser/profile immediately before detachment. Then detach `AdministratorAccess` from `jade-deploy`. Do not delete the user, keys, old runtime roles, or old policies in the same change window.
|
||||
12. **Run post-detachment checks.** Re-assume operator and provisioner, rerun all read checks and safe deployment checks, verify the phone-server health endpoint, verify wake behavior during an agreed maintenance window, verify pairing, and verify one Bedrock streaming completion.
|
||||
13. **Disable the old key, do not delete it yet.** After the new access path has worked for at least 24 to 72 hours, mark the old key inactive. Monitor CloudTrail for attempts using its access-key ID and for `AccessDenied` on expected workflows.
|
||||
14. **Delete after observation.** After another 7 to 14 days without needed rollback, delete the inactive key, remove obsolete policies/roles, and, if federation is stable, delete `jade-deploy` entirely.
|
||||
15. **Review quarterly.** Use access-key last-used data, CloudTrail, Access Analyzer, credential reports, and alarm/budget tests. Remove unused actions. Test the emergency path without using it for routine work.
|
||||
|
||||
### Rollback rule
|
||||
|
||||
If a required operation fails after administrator removal, stop and use the independent emergency administrator path to correct the narrow role. Do not reattach `AdministratorAccess` to the everyday user as the default fix. Never rely on an existing STS session as the only rollback mechanism because policy changes and session expiry can invalidate that assumption.
|
||||
|
||||
## Additional security findings adjacent to IAM
|
||||
|
||||
These are not required for the IAM replacement, but they materially affect the deployment:
|
||||
|
||||
- The wake secret is embedded in Lambda source and placed in a query string. Query tokens can appear in browser history, logs, analytics, screenshots, and referrers. Store it in Secrets Manager or SSM Parameter Store, compare a header or signed short-lived request, and grant only the wake Lambda read access to that one secret.
|
||||
- Port `7644` is publicly exposed and the Lambda calls it over plain HTTP. Prefer a private VPC path, SSM-mediated pairing, or tailnet-only access. If keeping it public, restrict the security group source and add TLS.
|
||||
- The pair service runs as root and invokes `sudo -u ec2-user`; harden the systemd unit with a dedicated user, filesystem protections, `NoNewPrivileges`, and a narrowly scoped sudo rule.
|
||||
- The instance role's current `AmazonBedrockFullAccess` is broader than necessary even if `jade-deploy` is fixed.
|
||||
- Add CloudTrail alerts for `AttachUserPolicy`, `PutUserPolicy`, `CreateAccessKey`, `UpdateAssumeRolePolicy`, `PassRole`, and changes to the emergency/deployment roles.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
The migration is complete when:
|
||||
|
||||
- `jade-deploy` has no `AdministratorAccess` and no direct AWS service permissions beyond role assumption.
|
||||
- Daily deployment and maintenance succeed through `JcodePhoneOperator`.
|
||||
- A full tagged rebuild can be performed through the MFA-gated provisioner/CloudFormation path.
|
||||
- Runtime roles contain only the API calls documented above.
|
||||
- An independent emergency administrator path is tested.
|
||||
- The old access key is disabled, observed, then deleted.
|
||||
- CloudTrail shows no unexpected `AccessDenied` for required operations and no use of the old key during the observation period.
|
||||
@@ -0,0 +1,88 @@
|
||||
# jcode phone server (managed cloud host)
|
||||
|
||||
A self-managing EC2 host that runs `jcode serve` with the WebSocket gateway so
|
||||
phones (the iOS app, or SSH clients like Termius) can drive jcode sessions
|
||||
without any laptop in the loop. Billing safety is layered and each layer has
|
||||
been live-tested.
|
||||
|
||||
Live deployment (July 2026): AWS account `302154194530`, us-east-1,
|
||||
instance `i-08214cf66cd3f80c7` (m7i-flex.large), Elastic IP `54.196.207.97`.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
phone (jcode iOS app / Termius, connected through Tailscale)
|
||||
│ WebSocket :7643 (pair token auth, tailnet-only)
|
||||
▼
|
||||
EC2 jcode server ──instance role──▶ AWS Bedrock (Opus 4.6 default)
|
||||
▲
|
||||
│ tap wake link (API Gateway → wake lambda, bearer token from URL fragment)
|
||||
│ "Pair this phone" button (lambda → SSM Run Command → `jcode pair`)
|
||||
│
|
||||
AWS Budget $10 ──▶ SNS jcode-guard-stop ──▶ breaker lambda ──▶ stop instance
|
||||
└──▶ email
|
||||
```
|
||||
|
||||
## Files
|
||||
|
||||
| File | Deployed at | Purpose |
|
||||
|---|---|---|
|
||||
| `units/jcode-serve.service` | `~ec2-user/.config/systemd/user/` (user unit, linger on) | jcode daemon + gateway, restart always |
|
||||
| `units/jcode-pair.service` | `/etc/systemd/system/` | Legacy local pairing HTTP service, now disabled after migration to SSM |
|
||||
| `units/idle-autostop.{service,timer}` | `/etc/systemd/system/` | 5-min check, poweroff after 30 min idle |
|
||||
| `idle-autostop.sh` | `/usr/local/bin/` | idle = no gateway/SSH clients AND jcode has no outbound :443 (not streaming) |
|
||||
| `jcode-pair-service.py` | `/usr/local/bin/` | Legacy tailnet-only fallback; normal pairing now uses SSM |
|
||||
| `wake-lambda.py` | Lambda `jcode-phone-wake` (behind API Gateway `8c3wp4cbag`) | wake page: starts instance, polls SSM health, generates pair codes through SSM |
|
||||
| `breaker-lambda.py` | Lambda `jcode-guard-breaker` | stops the instance; subscribed to SNS `jcode-guard-stop` |
|
||||
| `IAM-LEAST-PRIVILEGE.md` | repository documentation | scoped runtime/deploy policies and lockout-safe `jade-deploy` rotation plan |
|
||||
|
||||
## Server config (instance)
|
||||
|
||||
- `~/.jcode/config.toml`: `[provider]` default bedrock/Opus 4.6, `[gateway] enabled = true, port 7643, bind 0.0.0.0`
|
||||
(note: `~/.jcode/config.toml`, NOT `~/.config/jcode/`)
|
||||
- `~/.bashrc` env: `JCODE_BEDROCK_ENABLE=1`, `AWS_REGION=us-east-1`,
|
||||
`JCODE_BEDROCK_MODEL=us.anthropic.claude-opus-4-6-v1`, `JCODE_GATEWAY_HOST=100.109.78.41`
|
||||
- Helpers: `~/bin/jc` (jcode with bedrock), `~/bin/phone` (attach-or-create tmux jcode)
|
||||
- `loginctl enable-linger ec2-user` so the user service runs at boot
|
||||
- Instance attr `instance-initiated-shutdown-behavior=stop` so `poweroff` = stopped (not billed)
|
||||
|
||||
## Cost guardrails (all live-tested)
|
||||
|
||||
| Layer | Trigger | Action |
|
||||
|---|---|---|
|
||||
| idle-autostop | 30 min no clients + not streaming | instance powers itself off |
|
||||
| `jcode-bedrock-tokens-warn` | >3M input tokens / 15 min | email |
|
||||
| `jcode-bedrock-tokens-stop` | >10M input tokens / 15 min, 2 periods | breaker stops instance + email |
|
||||
| AWS budget `jcode-dev-monthly-cost` | forecast >50% / actual >80% | email |
|
||||
| AWS budget `jcode-dev-monthly-cost` | actual >100% of $10 | SNS breaker stops instance + email |
|
||||
|
||||
The legacy `AWS/Billing/EstimatedCharges` alarms were removed because the account was not publishing that metric. The Budget notification path is active and verified. Stopped instance cost remains approximately $6/mo for encrypted EBS plus the idle Elastic IP. The Elastic IP is retained because this existing instance needs public IPv4 for outbound Bedrock, SSM, and Tailscale connectivity when it boots; all public inbound security-group rules are closed.
|
||||
|
||||
## Phone flow
|
||||
|
||||
1. Bookmark the wake link (`https://<api-id>.execute-api.us-east-1.amazonaws.com/#t=<token>`, token stored at `~/.jcode/jcode-phone-wake-token` on the workstation). The URL fragment is not sent in HTTP requests; JavaScript exchanges it for an `Authorization: Bearer` header and keeps it in session storage.
|
||||
2. Tap it: the Lambda starts the instance and polls EC2/SSM every 5 s until ready.
|
||||
3. Tap "Pair this phone" → Lambda runs `jcode pair` through SSM → 6-digit code + `jcode://` deep link → opens the iOS app paired to `100.109.78.41:7643`.
|
||||
4. SSH fallback: connect through Tailscale to `ec2-user@100.109.78.41`, then run `phone`.
|
||||
|
||||
## Security notes
|
||||
|
||||
- Gateway `/pair` requires a live 6-digit code (5-min TTL); WS requires the bearer token minted at pairing. Tokens are stored hashed server-side.
|
||||
- Wake actions require a bearer token. The bookmark stores it in the URL fragment, which browsers do not send to API Gateway. Legacy `?t=` links redirect once to the fragment form.
|
||||
- The EC2 security group has no public ingress. Gateway and SSH access are tailnet-only through `jcode-phone` (`100.109.78.41`).
|
||||
- Pair generation uses SSM Run Command, so port 7644 is no longer public and the legacy pair service is disabled.
|
||||
- The root EBS volume and future EBS volumes are encrypted by default.
|
||||
- CloudTrail records multi-region management events to a private encrypted bucket with 90-day retention. Account-level IAM Access Analyzer and S3 Block Public Access are enabled.
|
||||
- API Gateway access logs exclude query strings and authorization headers and expire after 30 days.
|
||||
- IAM: the instance role has inference-only access to the configured Opus 4.6 profile plus SSM managed-instance access. Waker Lambda has start/describe EC2 plus narrowly scoped SSM command permissions. Breaker Lambda has stop/describe EC2 plus SNS publish.
|
||||
- The deployment access key was rotated and the prior key is inactive. Daily maintenance can use the tested `jcode-operator` profile and `JcodeOperator` role, which cannot create IAM users or terminate instances. `jade-deploy` retains its administrator attachment only as a temporary recovery path until an independent root/MFA login is verified; see `IAM-LEAST-PRIVILEGE.md`.
|
||||
|
||||
## Rebuild from scratch (≈15 min)
|
||||
|
||||
1. Launch AL2023 x86_64 with an encrypted 30GB gp3 root volume, the Bedrock + SSM instance role, and a security group with no inbound rules. Associate an Elastic IP for outbound internet while running.
|
||||
2. Install jcode, Tailscale, tmux, and git. Join the tailnet as `jcode-phone`; set the jcode gateway host to `100.109.78.41`.
|
||||
3. Copy `units/jcode-serve.service` and the idle-autostop unit/script; enable linger and the required services. The legacy pair service is not required.
|
||||
4. Deploy `wake-lambda.py` as `waker.py`, set `WAKE_TOKEN`, `INSTANCE_ID`, and `JCODE_GATEWAY_HOST=100.109.78.41`, and grant scoped EC2/SSM permissions. API Gateway HTTP API routes to the Lambda.
|
||||
5. Create the SNS topics/subscriptions and connect the $10 Budget's 100% actual notification to `jcode-guard-stop`.
|
||||
6. Enable CloudTrail, Access Analyzer, account-level S3 Block Public Access, EBS encryption by default, and bounded CloudWatch log retention.
|
||||
7. Test: breaker stops the host; wake link starts it; SSM reports online; tailnet `/health` works; pair button returns a code targeting the tailnet address.
|
||||
@@ -0,0 +1,16 @@
|
||||
import boto3
|
||||
|
||||
INSTANCE_ID = "i-08214cf66cd3f80c7"
|
||||
|
||||
def handler(event, context):
|
||||
ec2 = boto3.client("ec2", region_name="us-east-1")
|
||||
state = ec2.describe_instances(InstanceIds=[INSTANCE_ID])["Reservations"][0]["Instances"][0]["State"]["Name"]
|
||||
if state == "running":
|
||||
ec2.stop_instances(InstanceIds=[INSTANCE_ID])
|
||||
msg = f"Circuit breaker: stopped {INSTANCE_ID} (was {state})"
|
||||
else:
|
||||
msg = f"Circuit breaker: {INSTANCE_ID} already {state}"
|
||||
sns = boto3.client("sns", region_name="us-east-1")
|
||||
sns.publish(TopicArn="arn:aws:sns:us-east-1:302154194530:jcode-guard-warn",
|
||||
Subject="jcode circuit breaker fired", Message=msg)
|
||||
return {"message": msg}
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/bin/bash
|
||||
# Power off after 30 min with no clients AND no active agent work.
|
||||
STATE_FILE=/var/tmp/idle-since
|
||||
CONNS=$(ss -Htn state established "( sport = :7643 or sport = :22 )" | wc -l)
|
||||
# jcode server doing outbound work (e.g. streaming from Bedrock)?
|
||||
JPID=$(pgrep -f "jcode.*serve" | head -1)
|
||||
BUSY=0
|
||||
if [ -n "$JPID" ]; then
|
||||
OUT443=$(ss -Htnp state established "( dport = :443 )" 2>/dev/null | grep -c "pid=$JPID") || true
|
||||
[ "$OUT443" -gt 0 ] && BUSY=1
|
||||
fi
|
||||
if [ "$CONNS" -gt 0 ] || [ "$BUSY" -gt 0 ]; then
|
||||
rm -f "$STATE_FILE"
|
||||
exit 0
|
||||
fi
|
||||
NOW=$(date +%s)
|
||||
if [ ! -f "$STATE_FILE" ]; then
|
||||
echo "$NOW" > "$STATE_FILE"
|
||||
exit 0
|
||||
fi
|
||||
IDLE_SECS=$((NOW - $(cat "$STATE_FILE")))
|
||||
if [ "$IDLE_SECS" -ge 1800 ]; then
|
||||
logger "idle-autostop: idle ${IDLE_SECS}s, powering off"
|
||||
systemctl poweroff
|
||||
fi
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Token-protected HTTP service that generates jcode pairing codes.
|
||||
|
||||
GET /pair-code?t=<token> -> {"code": "123456", "host": "100.109.78.41", "port": 7643, "uri": "jcode://pair?..."}
|
||||
"""
|
||||
import http.server, json, re, subprocess, os
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
TOKEN = open('/etc/jcode-pair-token').read().strip()
|
||||
HOST = '100.109.78.41'
|
||||
PORT = 7643
|
||||
|
||||
class H(http.server.BaseHTTPRequestHandler):
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
def _send(self, code, obj):
|
||||
body = json.dumps(obj).encode()
|
||||
self.send_response(code)
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.send_header('Cache-Control', 'no-store')
|
||||
self.send_header('Content-Length', str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self):
|
||||
u = urlparse(self.path)
|
||||
q = parse_qs(u.query)
|
||||
if q.get('t', [''])[0] != TOKEN:
|
||||
return self._send(403, {'error': 'forbidden'})
|
||||
if u.path != '/pair-code':
|
||||
return self._send(404, {'error': 'not found'})
|
||||
env = dict(os.environ)
|
||||
env['PATH'] = '/home/ec2-user/.local/bin:' + env.get('PATH', '')
|
||||
env['JCODE_GATEWAY_HOST'] = HOST
|
||||
try:
|
||||
out = subprocess.run(
|
||||
['sudo', '-u', 'ec2-user', '-i', 'jcode', 'pair'],
|
||||
capture_output=True, text=True, timeout=30, env=env,
|
||||
)
|
||||
text = re.sub(r'\x1b\[[0-9;]*m', '', out.stdout + out.stderr)
|
||||
m = re.search(r'Pairing code:\s+(\d{3})\s(\d{3})', text)
|
||||
if not m:
|
||||
return self._send(500, {'error': 'no code in output'})
|
||||
code = m.group(1) + m.group(2)
|
||||
uri = f'jcode://pair?host={HOST}&port={PORT}&code={code}'
|
||||
return self._send(200, {'code': code, 'host': HOST, 'port': PORT, 'uri': uri, 'expires_in': 300})
|
||||
except Exception as e:
|
||||
return self._send(500, {'error': str(e)})
|
||||
|
||||
http.server.HTTPServer(('0.0.0.0', 7644), H).serve_forever()
|
||||
@@ -0,0 +1,98 @@
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
os.environ["WAKE_TOKEN"] = "test-token"
|
||||
os.environ["JCODE_GATEWAY_HOST"] = "100.64.0.10"
|
||||
sys.modules.setdefault("boto3", types.ModuleType("boto3"))
|
||||
sys.modules["boto3"].client = lambda *_args, **_kwargs: None
|
||||
|
||||
SPEC = importlib.util.spec_from_file_location(
|
||||
"wake_lambda", Path(__file__).with_name("wake-lambda.py")
|
||||
)
|
||||
wake = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(wake)
|
||||
|
||||
|
||||
class FakeEc2:
|
||||
def __init__(self, state="running"):
|
||||
self.state = state
|
||||
self.started = False
|
||||
|
||||
def describe_instances(self, **_kwargs):
|
||||
return {"Reservations": [{"Instances": [{"State": {"Name": self.state}}]}]}
|
||||
|
||||
def start_instances(self, **_kwargs):
|
||||
self.started = True
|
||||
|
||||
|
||||
class InvocationDoesNotExist(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class FakeSsm:
|
||||
class exceptions:
|
||||
InvocationDoesNotExist = InvocationDoesNotExist
|
||||
|
||||
def describe_instance_information(self, **_kwargs):
|
||||
return {"InstanceInformationList": [{"PingStatus": "Online"}]}
|
||||
|
||||
def send_command(self, **_kwargs):
|
||||
return {"Command": {"CommandId": "command-1"}}
|
||||
|
||||
def get_command_invocation(self, **_kwargs):
|
||||
return {
|
||||
"Status": "Success",
|
||||
"StandardOutputContent": "",
|
||||
"StandardErrorContent": "Pairing code: \x1b[1;37m123 456\x1b[0m",
|
||||
}
|
||||
|
||||
|
||||
class WakeLambdaTests(unittest.TestCase):
|
||||
def event(self, method, *, token=None, body=None, query=None):
|
||||
headers = {"Authorization": f"Bearer {token}"} if token else {}
|
||||
return {
|
||||
"requestContext": {"http": {"method": method}},
|
||||
"headers": headers,
|
||||
"queryStringParameters": query,
|
||||
"body": json.dumps(body) if body is not None else None,
|
||||
}
|
||||
|
||||
def test_landing_page_never_embeds_token(self):
|
||||
result = wake.handler(self.event("GET"), None)
|
||||
self.assertEqual(result["statusCode"], 200)
|
||||
self.assertNotIn("test-token", result["body"])
|
||||
self.assertIn("Content-Security-Policy", result["headers"])
|
||||
|
||||
def test_legacy_query_redirects_token_into_fragment(self):
|
||||
result = wake.handler(self.event("GET", query={"t": "test-token"}), None)
|
||||
self.assertEqual(result["statusCode"], 302)
|
||||
self.assertEqual(result["headers"]["Location"], "/#t=test-token")
|
||||
|
||||
def test_post_requires_bearer_token(self):
|
||||
result = wake.handler(self.event("POST", body={"action": "status"}), None)
|
||||
self.assertEqual(result["statusCode"], 403)
|
||||
|
||||
def test_status_uses_ec2_and_ssm_health(self):
|
||||
ec2, ssm = FakeEc2(), FakeSsm()
|
||||
with patch.object(wake.boto3, "client", side_effect=[ec2, ssm]):
|
||||
result = wake.handler(
|
||||
self.event("POST", token="test-token", body={"action": "status"}), None
|
||||
)
|
||||
self.assertEqual(result["statusCode"], 200)
|
||||
self.assertTrue(json.loads(result["body"])["healthy"])
|
||||
|
||||
def test_pairing_parses_ansi_output_and_returns_tailnet_host(self):
|
||||
result = wake.fetch_pair_code(FakeSsm())
|
||||
self.assertEqual(result["code"], "123456")
|
||||
self.assertEqual(result["host"], "100.64.0.10")
|
||||
self.assertEqual(result["uri"], "jcode://pair?host=100.64.0.10&port=7643&code=123456")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,153 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Post-upload TestFlight setup for JCodeMobile.
|
||||
|
||||
Run after a build reaches App Store Connect (PLA must be accepted):
|
||||
/tmp/ascvenv/bin/python scripts/phone-server/testflight-setup.py
|
||||
|
||||
Idempotent. Does three things:
|
||||
1. Finds the com.jcode.mobile app and its latest build.
|
||||
2. Ensures an internal beta group exists with the account holder as tester.
|
||||
3. Assigns the latest build to that group so it is installable in TestFlight.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
KEY_ID = "XJKP4235XC"
|
||||
ISSUER = "f1147f07-48fe-4850-9171-f37d4b2dee41"
|
||||
KEY_PATH = "/home/jeremy/Downloads/AuthKey_XJKP4235XC.p8"
|
||||
BUNDLE_ID = "com.jcode.mobile"
|
||||
TESTER_EMAIL = "jeremyhuang55555@gmail.com"
|
||||
GROUP_NAME = "internal"
|
||||
API = "https://api.appstoreconnect.apple.com/v1"
|
||||
|
||||
|
||||
def token():
|
||||
import jwt
|
||||
|
||||
key = open(KEY_PATH).read()
|
||||
return jwt.encode(
|
||||
{
|
||||
"iss": ISSUER,
|
||||
"iat": int(time.time()),
|
||||
"exp": int(time.time()) + 1200,
|
||||
"aud": "appstoreconnect-v1",
|
||||
},
|
||||
key,
|
||||
algorithm="ES256",
|
||||
headers={"kid": KEY_ID},
|
||||
)
|
||||
|
||||
|
||||
def req(method, path, body=None):
|
||||
url = path if path.startswith("http") else API + path
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
r = urllib.request.Request(url, data=data, method=method)
|
||||
r.add_header("Authorization", f"Bearer {token()}")
|
||||
if data:
|
||||
r.add_header("Content-Type", "application/json")
|
||||
try:
|
||||
with urllib.request.urlopen(r, timeout=30) as resp:
|
||||
raw = resp.read().decode()
|
||||
return resp.status, json.loads(raw) if raw else {}
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, json.loads(e.read().decode() or "{}")
|
||||
|
||||
|
||||
def main():
|
||||
st, apps = req("GET", f"/apps?filter[bundleId]={BUNDLE_ID}")
|
||||
if st != 200 or not apps.get("data"):
|
||||
print(f"app lookup failed ({st}): {json.dumps(apps)[:300]}")
|
||||
return 1
|
||||
app_id = apps["data"][0]["id"]
|
||||
print(f"app: {app_id}")
|
||||
|
||||
st, builds = req(
|
||||
"GET",
|
||||
f"/builds?filter[app]={app_id}&sort=-uploadedDate&limit=1",
|
||||
)
|
||||
if st != 200 or not builds.get("data"):
|
||||
print(f"no builds yet ({st}): {json.dumps(builds)[:300]}")
|
||||
return 1
|
||||
build = builds["data"][0]
|
||||
build_id = build["id"]
|
||||
attrs = build["attributes"]
|
||||
print(f"latest build: {attrs.get('version')} ({attrs.get('processingState')})")
|
||||
|
||||
# Beta group (create if missing)
|
||||
st, groups = req(
|
||||
"GET", f"/betaGroups?filter[app]={app_id}&filter[name]={GROUP_NAME}"
|
||||
)
|
||||
if groups.get("data"):
|
||||
group_id = groups["data"][0]["id"]
|
||||
else:
|
||||
st, g = req(
|
||||
"POST",
|
||||
"/betaGroups",
|
||||
{
|
||||
"data": {
|
||||
"type": "betaGroups",
|
||||
"attributes": {"name": GROUP_NAME, "isInternalGroup": True},
|
||||
"relationships": {
|
||||
"app": {"data": {"type": "apps", "id": app_id}}
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
if st not in (200, 201):
|
||||
print(f"group create failed ({st}): {json.dumps(g)[:300]}")
|
||||
return 1
|
||||
group_id = g["data"]["id"]
|
||||
print(f"group: {group_id}")
|
||||
|
||||
# Tester (create if missing, then add to group)
|
||||
st, testers = req("GET", f"/betaTesters?filter[email]={TESTER_EMAIL}")
|
||||
if testers.get("data"):
|
||||
tester_id = testers["data"][0]["id"]
|
||||
else:
|
||||
st, t = req(
|
||||
"POST",
|
||||
"/betaTesters",
|
||||
{
|
||||
"data": {
|
||||
"type": "betaTesters",
|
||||
"attributes": {"email": TESTER_EMAIL, "firstName": "Jeremy"},
|
||||
"relationships": {
|
||||
"betaGroups": {
|
||||
"data": [{"type": "betaGroups", "id": group_id}]
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
if st not in (200, 201):
|
||||
print(f"tester create failed ({st}): {json.dumps(t)[:300]}")
|
||||
return 1
|
||||
tester_id = t["data"]["id"]
|
||||
print(f"tester: {tester_id}")
|
||||
|
||||
# Make sure tester is in the group (no-op if already)
|
||||
req(
|
||||
"POST",
|
||||
f"/betaGroups/{group_id}/relationships/betaTesters",
|
||||
{"data": [{"type": "betaTesters", "id": tester_id}]},
|
||||
)
|
||||
|
||||
# Assign build to group
|
||||
st, r = req(
|
||||
"POST",
|
||||
f"/betaGroups/{group_id}/relationships/builds",
|
||||
{"data": [{"type": "builds", "id": build_id}]},
|
||||
)
|
||||
if st not in (200, 201, 204):
|
||||
print(f"build assign failed ({st}): {json.dumps(r)[:300]}")
|
||||
return 1
|
||||
print("build assigned to group. TestFlight install should be available.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,5 @@
|
||||
[Unit]
|
||||
Description=Stop instance when idle
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/bin/idle-autostop.sh
|
||||
@@ -0,0 +1,7 @@
|
||||
[Unit]
|
||||
Description=Idle auto-stop check every 5 min
|
||||
[Timer]
|
||||
OnBootSec=10min
|
||||
OnUnitActiveSec=5min
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -0,0 +1,11 @@
|
||||
[Unit]
|
||||
Description=jcode pairing code service
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/bin/python3 /usr/local/bin/jcode-pair-service.py
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,16 @@
|
||||
[Unit]
|
||||
Description=jcode server with WebSocket gateway
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Environment=PATH=/home/ec2-user/.local/bin:/usr/local/bin:/usr/bin:/bin
|
||||
Environment=JCODE_BEDROCK_ENABLE=1
|
||||
Environment=AWS_REGION=us-east-1
|
||||
Environment=JCODE_BEDROCK_MODEL=us.anthropic.claude-opus-4-6-v1
|
||||
ExecStartPre=/bin/rm -f /run/user/1000/jcode-daemon.lock /run/user/1000/jcode.sock /run/user/1000/jcode.sock.hash /run/user/1000/jcode-debug.sock
|
||||
ExecStart=/home/ec2-user/.local/bin/jcode --provider bedrock serve
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -0,0 +1,199 @@
|
||||
import boto3
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import shlex
|
||||
import time
|
||||
from urllib.parse import quote
|
||||
|
||||
INSTANCE_ID = os.environ.get("INSTANCE_ID", "i-08214cf66cd3f80c7")
|
||||
TOKEN = os.environ.get("WAKE_TOKEN", "REPLACE_WITH_WAKE_TOKEN")
|
||||
HOST = os.environ.get("JCODE_GATEWAY_HOST", "100.109.78.41")
|
||||
PORT = int(os.environ.get("JCODE_GATEWAY_PORT", "7643"))
|
||||
REGION = os.environ.get("AWS_REGION", "us-east-1")
|
||||
ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
|
||||
PAIRING_CODE_RE = re.compile(r"Pairing code:\s+(\d{3})\s+(\d{3})")
|
||||
|
||||
|
||||
def response(status, body="", content_type="application/json", headers=None):
|
||||
result_headers = {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type": content_type,
|
||||
"Referrer-Policy": "no-referrer",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Frame-Options": "DENY",
|
||||
}
|
||||
if headers:
|
||||
result_headers.update(headers)
|
||||
return {"statusCode": status, "headers": result_headers, "body": body}
|
||||
|
||||
|
||||
def json_response(status, obj):
|
||||
return response(status, json.dumps(obj))
|
||||
|
||||
|
||||
def authorized(event):
|
||||
headers = {k.lower(): v for k, v in (event.get("headers") or {}).items()}
|
||||
supplied = headers.get("authorization", "")
|
||||
expected = f"Bearer {TOKEN}"
|
||||
return hmac.compare_digest(supplied, expected)
|
||||
|
||||
|
||||
def instance_state(ec2):
|
||||
instance = ec2.describe_instances(InstanceIds=[INSTANCE_ID])["Reservations"][0]["Instances"][0]
|
||||
return instance["State"]["Name"]
|
||||
|
||||
|
||||
def ssm_online(ssm):
|
||||
result = ssm.describe_instance_information(
|
||||
Filters=[{"Key": "InstanceIds", "Values": [INSTANCE_ID]}],
|
||||
MaxResults=5,
|
||||
)
|
||||
return any(item.get("PingStatus") == "Online" for item in result.get("InstanceInformationList", []))
|
||||
|
||||
|
||||
def fetch_pair_code(ssm):
|
||||
host = shlex.quote(HOST)
|
||||
command = (
|
||||
"sudo -iu ec2-user env "
|
||||
f"JCODE_GATEWAY_HOST={host} "
|
||||
"/home/ec2-user/.local/bin/jcode pair"
|
||||
)
|
||||
command_id = ssm.send_command(
|
||||
InstanceIds=[INSTANCE_ID],
|
||||
DocumentName="AWS-RunShellScript",
|
||||
Parameters={"commands": [command]},
|
||||
TimeoutSeconds=35,
|
||||
)["Command"]["CommandId"]
|
||||
|
||||
deadline = time.monotonic() + 35
|
||||
invocation = None
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
invocation = ssm.get_command_invocation(CommandId=command_id, InstanceId=INSTANCE_ID)
|
||||
except ssm.exceptions.InvocationDoesNotExist:
|
||||
time.sleep(1)
|
||||
continue
|
||||
if invocation["Status"] in {"Success", "Failed", "Cancelled", "TimedOut"}:
|
||||
break
|
||||
time.sleep(1)
|
||||
|
||||
if not invocation or invocation.get("Status") != "Success":
|
||||
status = invocation.get("Status", "TimedOut") if invocation else "TimedOut"
|
||||
return {"error": f"pair command {status.lower()}"}
|
||||
|
||||
text = ANSI_RE.sub(
|
||||
"",
|
||||
invocation.get("StandardOutputContent", "") + invocation.get("StandardErrorContent", ""),
|
||||
)
|
||||
match = PAIRING_CODE_RE.search(text)
|
||||
if not match:
|
||||
return {"error": "no pairing code in command output"}
|
||||
|
||||
code = match.group(1) + match.group(2)
|
||||
return {
|
||||
"code": code,
|
||||
"host": HOST,
|
||||
"port": PORT,
|
||||
"uri": f"jcode://pair?host={HOST}&port={PORT}&code={code}",
|
||||
"expires_in": 300,
|
||||
}
|
||||
|
||||
|
||||
def landing_page():
|
||||
nonce = secrets.token_urlsafe(18)
|
||||
html = """<!doctype html><html><head>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>jcode server</title>
|
||||
<style nonce="__NONCE__">
|
||||
body{font-family:-apple-system,system-ui;background:#101314;color:#eee;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}
|
||||
.card{text-align:center;padding:32px;max-width:360px}h1{color:#4DD9A6;font-size:1.6em;margin-bottom:8px}
|
||||
#s{font-size:1.05em;line-height:1.5}.dot{display:inline-block;width:10px;height:10px;border-radius:50%;margin-right:8px;background:#e6b450}
|
||||
.ok .dot{background:#4DD9A6}.ok #s b{color:#4DD9A6}.spin{opacity:.75;font-size:.9em;margin-top:14px}
|
||||
#pairbtn{display:none;margin-top:22px;background:#4DD9A6;color:#0c0f10;border:0;border-radius:12px;padding:14px 22px;font-size:1.05em;font-weight:600}
|
||||
#pairout{margin-top:16px;font-size:1em;line-height:1.6}#pairout .code{font-size:1.9em;letter-spacing:.18em;color:#4DD9A6;font-weight:700}#pairout a{color:#4DD9A6}
|
||||
</style></head><body><div class="card" id="c">
|
||||
<h1>jcode server</h1><p id="s"><span class="dot"></span>Authenticating…</p>
|
||||
<p class="spin" id="hint">checking every 5s…</p><button id="pairbtn">Pair this phone</button><div id="pairout"></div>
|
||||
<script nonce="__NONCE__">
|
||||
const fragment = new URLSearchParams(location.hash.slice(1));
|
||||
if (fragment.get('t')) sessionStorage.setItem('jcode-wake-token', fragment.get('t'));
|
||||
history.replaceState(null, '', location.pathname);
|
||||
const token = sessionStorage.getItem('jcode-wake-token');
|
||||
const api = async action => {
|
||||
if (!token) throw new Error('missing token; open the saved wake link');
|
||||
const r = await fetch(location.pathname, {method:'POST', cache:'no-store',
|
||||
headers:{'Authorization':'Bearer '+token,'Content-Type':'application/json'},
|
||||
body:JSON.stringify({action})});
|
||||
if (!r.ok) throw new Error(r.status === 403 ? 'invalid token' : 'request failed: '+r.status);
|
||||
return r.json();
|
||||
};
|
||||
async function poll(){
|
||||
try{
|
||||
const j=await api('status'),s=document.getElementById('s'),c=document.getElementById('c');
|
||||
if(j.healthy){c.classList.add('ok');s.innerHTML='<span class="dot"></span><b>Ready.</b> Open the jcode app now.';document.getElementById('hint').textContent='server is up';document.getElementById('pairbtn').style.display='inline-block';return;}
|
||||
s.innerHTML='<span class="dot"></span>Instance: '+j.state+' · services warming up…';
|
||||
}catch(e){document.getElementById('s').textContent=e.message;document.getElementById('hint').textContent='';return;}
|
||||
setTimeout(poll,5000);
|
||||
}
|
||||
async function pair(){
|
||||
const o=document.getElementById('pairout');o.textContent='generating code…';
|
||||
try{const j=await api('pair');if(j.code){o.innerHTML='<div class="code">'+j.code.slice(0,3)+' '+j.code.slice(3)+'</div><div>host '+j.host+':'+j.port+' · expires in 5 min</div><div style="margin-top:10px"><a href="'+j.uri+'">Open in jcode app</a></div>';}else{o.textContent='error: '+(j.error||'unknown');}}catch(e){o.textContent='error: '+e.message;}
|
||||
}
|
||||
document.getElementById('pairbtn').addEventListener('click',pair);
|
||||
(async()=>{try{await api('wake');poll();}catch(e){document.getElementById('s').textContent=e.message;document.getElementById('hint').textContent='';}})();
|
||||
</script></div></body></html>""".replace("__NONCE__", nonce)
|
||||
csp = (
|
||||
"default-src 'none'; "
|
||||
f"style-src 'nonce-{nonce}'; script-src 'nonce-{nonce}'; "
|
||||
"connect-src 'self'; base-uri 'none'; frame-ancestors 'none'"
|
||||
)
|
||||
return response(200, html, "text/html; charset=utf-8", {"Content-Security-Policy": csp})
|
||||
|
||||
|
||||
def handler(event, context):
|
||||
method = (
|
||||
event.get("requestContext", {}).get("http", {}).get("method")
|
||||
or event.get("httpMethod")
|
||||
or "GET"
|
||||
).upper()
|
||||
|
||||
if method == "GET":
|
||||
legacy_token = (event.get("queryStringParameters") or {}).get("t")
|
||||
if legacy_token and hmac.compare_digest(legacy_token, TOKEN):
|
||||
return response(302, headers={"Location": f"/#t={quote(TOKEN, safe='')}"})
|
||||
return landing_page()
|
||||
|
||||
if method != "POST":
|
||||
return json_response(405, {"error": "method not allowed"})
|
||||
if not authorized(event):
|
||||
return json_response(403, {"error": "forbidden"})
|
||||
|
||||
try:
|
||||
payload = json.loads(event.get("body") or "{}")
|
||||
except json.JSONDecodeError:
|
||||
return json_response(400, {"error": "invalid JSON"})
|
||||
|
||||
action = payload.get("action")
|
||||
ec2 = boto3.client("ec2", region_name=REGION)
|
||||
ssm = boto3.client("ssm", region_name=REGION)
|
||||
state = instance_state(ec2)
|
||||
|
||||
if action == "wake":
|
||||
started = state == "stopped"
|
||||
if started:
|
||||
ec2.start_instances(InstanceIds=[INSTANCE_ID])
|
||||
return json_response(200, {"state": state, "started": started})
|
||||
if action == "status":
|
||||
healthy = state == "running" and ssm_online(ssm)
|
||||
return json_response(200, {"state": state, "healthy": healthy})
|
||||
if action == "pair":
|
||||
if state != "running":
|
||||
return json_response(409, {"error": f"instance {state}, wake it first"})
|
||||
if not ssm_online(ssm):
|
||||
return json_response(503, {"error": "instance management agent is not ready"})
|
||||
result = fetch_pair_code(ssm)
|
||||
return json_response(200 if "code" in result else 500, result)
|
||||
return json_response(400, {"error": "unknown action"})
|
||||
Executable
+398
@@ -0,0 +1,398 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import pty
|
||||
import select
|
||||
import signal
|
||||
import socket
|
||||
import statistics
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
CLK_TCK = os.sysconf("SC_CLK_TCK")
|
||||
PAGE_SIZE = os.sysconf("SC_PAGE_SIZE")
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
p = argparse.ArgumentParser(description="Profile resumed jcode PTY burst startup")
|
||||
p.add_argument("--binary", default="./target/release/jcode")
|
||||
p.add_argument("--burst", type=int, default=20)
|
||||
p.add_argument("--timeout", type=float, default=15.0)
|
||||
p.add_argument("--stagger-ms", type=float, default=0.0)
|
||||
p.add_argument("--json-out", default="/tmp/jcode_remote_burst_profile.json")
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProcSample:
|
||||
cpu_ticks: int
|
||||
rss_kb: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProcTracker:
|
||||
start_cpu_ticks: int | None = None
|
||||
last_cpu_ticks: int = 0
|
||||
peak_rss_kb: int = 0
|
||||
|
||||
def record(self, sample: ProcSample | None) -> bool:
|
||||
if sample is None:
|
||||
return False
|
||||
if self.start_cpu_ticks is None:
|
||||
self.start_cpu_ticks = sample.cpu_ticks
|
||||
self.last_cpu_ticks = sample.cpu_ticks
|
||||
self.peak_rss_kb = max(self.peak_rss_kb, sample.rss_kb)
|
||||
return True
|
||||
|
||||
def cpu_ms(self) -> float:
|
||||
if self.start_cpu_ticks is None:
|
||||
return 0.0
|
||||
return (self.last_cpu_ticks - self.start_cpu_ticks) * 1000.0 / CLK_TCK
|
||||
|
||||
|
||||
def read_proc_sample(pid: int) -> ProcSample | None:
|
||||
try:
|
||||
stat = Path(f"/proc/{pid}/stat").read_text()
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
end = stat.rfind(")")
|
||||
if end == -1:
|
||||
return None
|
||||
fields = stat[end + 2 :].split()
|
||||
if len(fields) <= 21:
|
||||
return None
|
||||
utime = int(fields[11])
|
||||
stime = int(fields[12])
|
||||
rss_pages = int(fields[21])
|
||||
return ProcSample(cpu_ticks=utime + stime, rss_kb=rss_pages * PAGE_SIZE // 1024)
|
||||
|
||||
|
||||
def wait_for_socket(path: Path, timeout_s: float = 10.0) -> None:
|
||||
deadline = time.time() + timeout_s
|
||||
while time.time() < deadline:
|
||||
if path.exists():
|
||||
try:
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
sock.settimeout(0.2)
|
||||
sock.connect(str(path))
|
||||
sock.close()
|
||||
return
|
||||
except OSError:
|
||||
pass
|
||||
time.sleep(0.01)
|
||||
raise RuntimeError(f"socket not ready: {path}")
|
||||
|
||||
|
||||
def create_session(debug_sock: Path, cwd: str = ".") -> str:
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
sock.connect(str(debug_sock))
|
||||
req = {"type": "debug_command", "id": 1, "command": f"create_session:{cwd}"}
|
||||
sock.sendall((json.dumps(req) + "\n").encode())
|
||||
buf = b""
|
||||
while True:
|
||||
chunk = sock.recv(65536)
|
||||
if not chunk:
|
||||
break
|
||||
buf += chunk
|
||||
while b"\n" in buf:
|
||||
line, buf = buf.split(b"\n", 1)
|
||||
resp = json.loads(line.decode())
|
||||
if resp.get("type") == "ack":
|
||||
continue
|
||||
if resp.get("type") == "error":
|
||||
raise RuntimeError(resp.get("message") or resp)
|
||||
if resp.get("type") != "debug_response":
|
||||
continue
|
||||
if not resp.get("ok", True):
|
||||
raise RuntimeError(resp.get("output") or resp)
|
||||
output = json.loads(resp["output"])
|
||||
return output["session_id"]
|
||||
raise RuntimeError("missing debug response")
|
||||
|
||||
|
||||
def reply_queries(master_fd: int, buffer: bytes) -> bytes:
|
||||
replies = [
|
||||
(b"\x1b[6n", b"\x1b[1;1R"),
|
||||
(b"\x1b[c", b"\x1b[?62;c"),
|
||||
(b"\x1b]10;?\x1b\\", b"\x1b]10;rgb:ffff/ffff/ffff\x1b\\"),
|
||||
(b"\x1b]11;?\x1b\\", b"\x1b]11;rgb:0000/0000/0000\x1b\\"),
|
||||
(b"\x1b]10;?\x07", b"\x1b]10;rgb:ffff/ffff/ffff\x07"),
|
||||
(b"\x1b]11;?\x07", b"\x1b]11;rgb:0000/0000/0000\x07"),
|
||||
(b"\x1b[14t", b"\x1b[4;600;800t"),
|
||||
(b"\x1b[16t", b"\x1b[6;16;8t"),
|
||||
(b"\x1b[18t", b"\x1b[8;24;80t"),
|
||||
(b"\x1b[?1016$p", b"\x1b[?1016;1$y"),
|
||||
(b"\x1b[?2027$p", b"\x1b[?2027;1$y"),
|
||||
(b"\x1b[?2031$p", b"\x1b[?2031;1$y"),
|
||||
(b"\x1b[?1004$p", b"\x1b[?1004;1$y"),
|
||||
(b"\x1b[?2004$p", b"\x1b[?2004;1$y"),
|
||||
(b"\x1b[?2026$p", b"\x1b[?2026;1$y"),
|
||||
]
|
||||
changed = True
|
||||
while changed:
|
||||
changed = False
|
||||
for query, response in replies:
|
||||
if query in buffer:
|
||||
os.write(master_fd, response)
|
||||
buffer = buffer.replace(query, b"")
|
||||
changed = True
|
||||
return buffer
|
||||
|
||||
|
||||
@dataclass
|
||||
class LiveClient:
|
||||
session_id: str
|
||||
proc: subprocess.Popen
|
||||
master_fd: int
|
||||
start: float
|
||||
buffer: bytes
|
||||
tracker: ProcTracker
|
||||
first_output_ms: float | None = None
|
||||
last_output_at: float | None = None
|
||||
done: bool = False
|
||||
|
||||
|
||||
def start_resume_client(binary: str, env: dict[str, str], session_id: str) -> LiveClient:
|
||||
master_fd, slave_fd = pty.openpty()
|
||||
start = time.perf_counter()
|
||||
proc = subprocess.Popen(
|
||||
[
|
||||
binary,
|
||||
"--no-update",
|
||||
"--no-selfdev",
|
||||
"--socket",
|
||||
env["JCODE_SOCKET"],
|
||||
"--fresh-spawn",
|
||||
"--resume",
|
||||
session_id,
|
||||
],
|
||||
stdin=slave_fd,
|
||||
stdout=slave_fd,
|
||||
stderr=slave_fd,
|
||||
env=env,
|
||||
preexec_fn=os.setsid,
|
||||
)
|
||||
os.close(slave_fd)
|
||||
os.set_blocking(master_fd, False)
|
||||
return LiveClient(
|
||||
session_id=session_id,
|
||||
proc=proc,
|
||||
master_fd=master_fd,
|
||||
start=start,
|
||||
buffer=b"",
|
||||
tracker=ProcTracker(),
|
||||
)
|
||||
|
||||
|
||||
def finish_client(client: LiveClient) -> dict:
|
||||
try:
|
||||
client.tracker.record(read_proc_sample(client.proc.pid))
|
||||
return {
|
||||
"session_id": client.session_id,
|
||||
"pid": client.proc.pid,
|
||||
"first_output_ms": client.first_output_ms,
|
||||
"buffer_bytes": len(client.buffer),
|
||||
"cpu_ms": client.tracker.cpu_ms(),
|
||||
"peak_rss_kb": client.tracker.peak_rss_kb,
|
||||
}
|
||||
finally:
|
||||
os.close(client.master_fd)
|
||||
try:
|
||||
os.killpg(client.proc.pid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
try:
|
||||
client.proc.wait(timeout=0.05)
|
||||
except Exception:
|
||||
try:
|
||||
os.killpg(client.proc.pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
|
||||
def run_burst(
|
||||
binary: str,
|
||||
env: dict[str, str],
|
||||
session_ids: list[str],
|
||||
timeout_s: float,
|
||||
server_pid: int,
|
||||
stagger_ms: float,
|
||||
) -> tuple[list[dict], dict[str, float | int]]:
|
||||
settle_after_output_s = 0.15
|
||||
clients: list[LiveClient] = []
|
||||
fd_to_index: dict[int, int] = {}
|
||||
launch_index = 0
|
||||
next_launch_at = time.perf_counter()
|
||||
deadline = time.perf_counter() + timeout_s
|
||||
server_tracker = ProcTracker()
|
||||
peak_clients_rss_kb = 0
|
||||
peak_live_clients = 0
|
||||
|
||||
def sample_processes() -> None:
|
||||
nonlocal peak_clients_rss_kb, peak_live_clients
|
||||
server_tracker.record(read_proc_sample(server_pid))
|
||||
live_clients = 0
|
||||
clients_rss_kb = 0
|
||||
for client in clients:
|
||||
sample = read_proc_sample(client.proc.pid)
|
||||
if client.tracker.record(sample):
|
||||
live_clients += 1
|
||||
clients_rss_kb += sample.rss_kb if sample is not None else 0
|
||||
peak_live_clients = max(peak_live_clients, live_clients)
|
||||
peak_clients_rss_kb = max(peak_clients_rss_kb, clients_rss_kb)
|
||||
|
||||
while time.perf_counter() < deadline and (
|
||||
launch_index < len(session_ids) or any(not client.done for client in clients)
|
||||
):
|
||||
now = time.perf_counter()
|
||||
while launch_index < len(session_ids) and now >= next_launch_at:
|
||||
client = start_resume_client(binary, env, session_ids[launch_index])
|
||||
fd_to_index[client.master_fd] = len(clients)
|
||||
clients.append(client)
|
||||
launch_index += 1
|
||||
next_launch_at += stagger_ms / 1000.0 if stagger_ms > 0 else 0.0
|
||||
now = time.perf_counter()
|
||||
|
||||
sample_processes()
|
||||
active_fds = [client.master_fd for client in clients if not client.done]
|
||||
timeout = 0.05
|
||||
if launch_index < len(session_ids):
|
||||
timeout = max(0.0, min(timeout, next_launch_at - time.perf_counter()))
|
||||
if not active_fds and launch_index < len(session_ids):
|
||||
time.sleep(timeout)
|
||||
continue
|
||||
if not active_fds:
|
||||
break
|
||||
rlist, _, _ = select.select(active_fds, [], [], timeout)
|
||||
for fd in rlist:
|
||||
client = clients[fd_to_index[fd]]
|
||||
try:
|
||||
chunk = os.read(fd, 65536)
|
||||
except BlockingIOError:
|
||||
chunk = b""
|
||||
if not chunk:
|
||||
client.done = True
|
||||
continue
|
||||
if client.first_output_ms is None:
|
||||
client.first_output_ms = (time.perf_counter() - client.start) * 1000.0
|
||||
client.last_output_at = time.perf_counter()
|
||||
client.buffer += chunk
|
||||
client.buffer = reply_queries(fd, client.buffer)
|
||||
lower = client.buffer.lower()
|
||||
if b"loading session" in lower or b"jcode" in lower or len(client.buffer) > 2048:
|
||||
client.done = True
|
||||
|
||||
for client in clients:
|
||||
if client.done:
|
||||
continue
|
||||
if client.proc.poll() is not None:
|
||||
client.done = True
|
||||
continue
|
||||
if (
|
||||
client.first_output_ms is not None
|
||||
and client.last_output_at is not None
|
||||
and time.perf_counter() - client.last_output_at >= settle_after_output_s
|
||||
):
|
||||
client.done = True
|
||||
|
||||
for client in clients:
|
||||
client.done = True
|
||||
|
||||
sample_processes()
|
||||
|
||||
results = [finish_client(client) for client in clients]
|
||||
metrics = {
|
||||
"server_cpu_ms": server_tracker.cpu_ms(),
|
||||
"server_peak_rss_kb": server_tracker.peak_rss_kb,
|
||||
"clients_cpu_ms": sum(result["cpu_ms"] for result in results),
|
||||
"clients_peak_rss_kb": peak_clients_rss_kb,
|
||||
"peak_live_clients": peak_live_clients,
|
||||
}
|
||||
return results, metrics
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
root = Path(tempfile.mkdtemp(prefix="jcode-remote-burst-"))
|
||||
home = root / "home"
|
||||
run = root / "run"
|
||||
home.mkdir(parents=True, exist_ok=True)
|
||||
run.mkdir(parents=True, exist_ok=True)
|
||||
env = os.environ.copy()
|
||||
env["JCODE_HOME"] = str(home)
|
||||
env["JCODE_RUNTIME_DIR"] = str(run)
|
||||
env["JCODE_SOCKET"] = str(run / "jcode.sock")
|
||||
env["JCODE_NO_TELEMETRY"] = "1"
|
||||
env["JCODE_DEBUG_CONTROL"] = "1"
|
||||
env["JCODE_TEMP_SERVER"] = "1"
|
||||
env["JCODE_SERVER_OWNER_PID"] = str(os.getpid())
|
||||
debug_socket = run / "jcode-debug.sock"
|
||||
|
||||
server = subprocess.Popen(
|
||||
[args.binary, "serve", "--socket", env["JCODE_SOCKET"], "--debug-socket"],
|
||||
env=env,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
preexec_fn=os.setsid,
|
||||
)
|
||||
try:
|
||||
wait_for_socket(Path(env["JCODE_SOCKET"]))
|
||||
wait_for_socket(debug_socket)
|
||||
session_ids = [create_session(debug_socket, os.getcwd()) for _ in range(args.burst)]
|
||||
|
||||
wall_start = time.perf_counter()
|
||||
results, proc_metrics = run_burst(
|
||||
args.binary,
|
||||
env,
|
||||
session_ids,
|
||||
args.timeout,
|
||||
server.pid,
|
||||
args.stagger_ms,
|
||||
)
|
||||
wall_ms = (time.perf_counter() - wall_start) * 1000.0
|
||||
firsts = [r["first_output_ms"] for r in results if r["first_output_ms"] is not None]
|
||||
output = {
|
||||
"burst": args.burst,
|
||||
"stagger_ms": args.stagger_ms,
|
||||
"wall_ms": wall_ms,
|
||||
"server_cpu_ms": proc_metrics["server_cpu_ms"],
|
||||
"clients_cpu_ms": proc_metrics["clients_cpu_ms"],
|
||||
"total_cpu_ms": proc_metrics["server_cpu_ms"] + proc_metrics["clients_cpu_ms"],
|
||||
"cpu_utilization_ratio": 0.0
|
||||
if wall_ms == 0
|
||||
else (proc_metrics["server_cpu_ms"] + proc_metrics["clients_cpu_ms"]) / wall_ms,
|
||||
"first_output_ms": {
|
||||
"min": min(firsts) if firsts else None,
|
||||
"p50": statistics.median(firsts) if firsts else None,
|
||||
"max": max(firsts) if firsts else None,
|
||||
},
|
||||
"buffer_bytes_total": sum(r["buffer_bytes"] for r in results),
|
||||
"server_peak_rss_kb": proc_metrics["server_peak_rss_kb"],
|
||||
"clients_peak_rss_kb": proc_metrics["clients_peak_rss_kb"],
|
||||
"peak_live_clients": proc_metrics["peak_live_clients"],
|
||||
"results": results,
|
||||
}
|
||||
Path(args.json_out).write_text(json.dumps(output, indent=2))
|
||||
print(json.dumps(output, indent=2))
|
||||
finally:
|
||||
try:
|
||||
os.killpg(server.pid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
try:
|
||||
server.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
os.killpg(server.pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,250 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import pty
|
||||
import select
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Profile a single resumed jcode spawn")
|
||||
parser.add_argument("--binary", default="./target/selfdev/jcode")
|
||||
parser.add_argument("--timeout", type=float, default=20.0)
|
||||
parser.add_argument("--cwd", default=os.getcwd())
|
||||
parser.add_argument("--json", action="store_true", help="Emit JSON summary")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def wait_for_socket(path: Path, timeout_s: float = 10.0) -> None:
|
||||
deadline = time.time() + timeout_s
|
||||
while time.time() < deadline:
|
||||
if path.exists():
|
||||
try:
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
sock.settimeout(0.2)
|
||||
sock.connect(str(path))
|
||||
sock.close()
|
||||
return
|
||||
except OSError:
|
||||
pass
|
||||
time.sleep(0.01)
|
||||
raise RuntimeError(f"socket not ready: {path}")
|
||||
|
||||
|
||||
def create_session(debug_sock: Path, cwd: str) -> str:
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
sock.connect(str(debug_sock))
|
||||
req = {"type": "debug_command", "id": 1, "command": f"create_session:{cwd}"}
|
||||
sock.sendall((json.dumps(req) + "\n").encode())
|
||||
buf = b""
|
||||
while True:
|
||||
chunk = sock.recv(65536)
|
||||
if not chunk:
|
||||
break
|
||||
buf += chunk
|
||||
while b"\n" in buf:
|
||||
line, buf = buf.split(b"\n", 1)
|
||||
resp = json.loads(line.decode())
|
||||
if resp.get("type") == "ack":
|
||||
continue
|
||||
if resp.get("type") == "error":
|
||||
raise RuntimeError(resp.get("message") or resp)
|
||||
if resp.get("type") != "debug_response":
|
||||
continue
|
||||
if not resp.get("ok", True):
|
||||
raise RuntimeError(resp.get("output") or resp)
|
||||
output = json.loads(resp["output"])
|
||||
return output["session_id"]
|
||||
raise RuntimeError("missing debug response")
|
||||
|
||||
|
||||
def reply_queries(master_fd: int, buffer: bytes) -> bytes:
|
||||
replies = [
|
||||
(b"\x1b[6n", b"\x1b[1;1R"),
|
||||
(b"\x1b[c", b"\x1b[?62;c"),
|
||||
(b"\x1b]10;?\x1b\\", b"\x1b]10;rgb:ffff/ffff/ffff\x1b\\"),
|
||||
(b"\x1b]11;?\x1b\\", b"\x1b]11;rgb:0000/0000/0000\x1b\\"),
|
||||
(b"\x1b]10;?\x07", b"\x1b]10;rgb:ffff/ffff/ffff\x07"),
|
||||
(b"\x1b]11;?\x07", b"\x1b]11;rgb:0000/0000/0000\x07"),
|
||||
(b"\x1b[14t", b"\x1b[4;600;800t"),
|
||||
(b"\x1b[16t", b"\x1b[6;16;8t"),
|
||||
(b"\x1b[18t", b"\x1b[8;24;80t"),
|
||||
(b"\x1b[?1016$p", b"\x1b[?1016;1$y"),
|
||||
(b"\x1b[?2027$p", b"\x1b[?2027;1$y"),
|
||||
(b"\x1b[?2031$p", b"\x1b[?2031;1$y"),
|
||||
(b"\x1b[?1004$p", b"\x1b[?1004;1$y"),
|
||||
(b"\x1b[?2004$p", b"\x1b[?2004;1$y"),
|
||||
(b"\x1b[?2026$p", b"\x1b[?2026;1$y"),
|
||||
]
|
||||
changed = True
|
||||
while changed:
|
||||
changed = False
|
||||
for query, response in replies:
|
||||
if query in buffer:
|
||||
os.write(master_fd, response)
|
||||
buffer = buffer.replace(query, b"")
|
||||
changed = True
|
||||
return buffer
|
||||
|
||||
|
||||
def latest_log_file(log_dir: Path) -> Path:
|
||||
logs = sorted(log_dir.glob("jcode-*.log"), key=lambda p: p.stat().st_mtime)
|
||||
if not logs:
|
||||
raise RuntimeError(f"no log files found in {log_dir}")
|
||||
return logs[-1]
|
||||
|
||||
|
||||
def extract_timing_lines(log_path: Path) -> list[str]:
|
||||
timing_lines = []
|
||||
for line in log_path.read_text(errors="replace").splitlines():
|
||||
if "[TIMING]" in line or "Startup Profile (" in line:
|
||||
timing_lines.append(line)
|
||||
return timing_lines
|
||||
|
||||
|
||||
def profile_single_spawn(binary: str, cwd: str, timeout_s: float) -> dict:
|
||||
root = Path(tempfile.mkdtemp(prefix="jcode-single-profile-"))
|
||||
home = root / "home"
|
||||
runtime_dir = root / "run"
|
||||
socket_path = runtime_dir / "jcode.sock"
|
||||
debug_socket_path = runtime_dir / "jcode-debug.sock"
|
||||
home.mkdir(parents=True, exist_ok=True)
|
||||
runtime_dir.mkdir(parents=True, exist_ok=True)
|
||||
env = os.environ.copy()
|
||||
env.update(
|
||||
{
|
||||
"JCODE_HOME": str(home),
|
||||
"JCODE_RUNTIME_DIR": str(runtime_dir),
|
||||
"JCODE_SOCKET": str(socket_path),
|
||||
"JCODE_DEBUG_SOCKET": str(debug_socket_path),
|
||||
"JCODE_SWARM_ENABLED": "0",
|
||||
"JCODE_NO_TELEMETRY": "1",
|
||||
"JCODE_TRACE": "1",
|
||||
"JCODE_TEMP_SERVER": "1",
|
||||
"JCODE_SERVER_OWNER_PID": str(os.getpid()),
|
||||
}
|
||||
)
|
||||
|
||||
server_proc = subprocess.Popen(
|
||||
[binary, "--no-update", "--no-selfdev", "serve", "--socket", str(socket_path)],
|
||||
env=env,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
preexec_fn=os.setsid,
|
||||
)
|
||||
try:
|
||||
wait_for_socket(socket_path, timeout_s=min(timeout_s, 10.0))
|
||||
wait_for_socket(debug_socket_path, timeout_s=min(timeout_s, 10.0))
|
||||
session_id = create_session(debug_socket_path, cwd)
|
||||
|
||||
master_fd, slave_fd = pty.openpty()
|
||||
client_start = time.perf_counter()
|
||||
client_proc = subprocess.Popen(
|
||||
[
|
||||
binary,
|
||||
"--no-update",
|
||||
"--no-selfdev",
|
||||
"--socket",
|
||||
str(socket_path),
|
||||
"--fresh-spawn",
|
||||
"--resume",
|
||||
session_id,
|
||||
],
|
||||
stdin=slave_fd,
|
||||
stdout=slave_fd,
|
||||
stderr=slave_fd,
|
||||
env=env,
|
||||
preexec_fn=os.setsid,
|
||||
)
|
||||
os.close(slave_fd)
|
||||
os.set_blocking(master_fd, False)
|
||||
|
||||
buffer = b""
|
||||
first_output_ms = None
|
||||
last_output_at = None
|
||||
deadline = time.perf_counter() + timeout_s
|
||||
settle_after_output_s = 0.2
|
||||
while time.perf_counter() < deadline:
|
||||
if client_proc.poll() is not None:
|
||||
break
|
||||
rlist, _, _ = select.select([master_fd], [], [], 0.05)
|
||||
if rlist:
|
||||
chunk = os.read(master_fd, 65536)
|
||||
if not chunk:
|
||||
break
|
||||
if first_output_ms is None:
|
||||
first_output_ms = (time.perf_counter() - client_start) * 1000.0
|
||||
last_output_at = time.perf_counter()
|
||||
buffer += chunk
|
||||
buffer = reply_queries(master_fd, buffer)
|
||||
lower = buffer.lower()
|
||||
if b"loading session" in lower or b"jcode" in lower or len(buffer) > 4096:
|
||||
if time.perf_counter() - last_output_at >= settle_after_output_s:
|
||||
break
|
||||
elif last_output_at is not None and time.perf_counter() - last_output_at >= settle_after_output_s:
|
||||
break
|
||||
|
||||
os.close(master_fd)
|
||||
try:
|
||||
os.killpg(client_proc.pid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
try:
|
||||
client_proc.wait(timeout=0.2)
|
||||
except Exception:
|
||||
try:
|
||||
os.killpg(client_proc.pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
log_path = latest_log_file(home / "logs")
|
||||
return {
|
||||
"temp_root": str(root),
|
||||
"log_path": str(log_path),
|
||||
"session_id": session_id,
|
||||
"first_output_ms": first_output_ms,
|
||||
"timing_lines": extract_timing_lines(log_path),
|
||||
}
|
||||
finally:
|
||||
try:
|
||||
os.killpg(server_proc.pid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
try:
|
||||
server_proc.wait(timeout=1.0)
|
||||
except Exception:
|
||||
try:
|
||||
os.killpg(server_proc.pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
result = profile_single_spawn(args.binary, args.cwd, args.timeout)
|
||||
if args.json:
|
||||
print(json.dumps(result, indent=2))
|
||||
return
|
||||
|
||||
print(f"temp root: {result['temp_root']}")
|
||||
print(f"session id: {result['session_id']}")
|
||||
print(f"log path: {result['log_path']}")
|
||||
if result["first_output_ms"] is not None:
|
||||
print(f"first output: {result['first_output_ms']:.1f}ms")
|
||||
else:
|
||||
print("first output: none")
|
||||
print("timings:")
|
||||
for line in result["timing_lines"]:
|
||||
print(f" {line}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+662
@@ -0,0 +1,662 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Benchmark + profile the time and resource utilization of a jcode spawn.
|
||||
|
||||
A jcode "spawn" is the full cold-launch path of the interactive client:
|
||||
|
||||
process start -> tokio runtime -> startup setup -> arg parse
|
||||
-> (cold) spawn the background server daemon -> connect TUI client.
|
||||
|
||||
This profiler complements scripts/bench_startup.py (which only measures time)
|
||||
by also attributing RESOURCE UTILIZATION to each spawned process:
|
||||
|
||||
* wall-clock time (and the built-in startup-profile phase breakdown)
|
||||
* peak / final RSS, anon vs file-backed RSS, PSS, swap
|
||||
* CPU time (user + system) and average %CPU over the spawn window
|
||||
* thread count high-water mark
|
||||
* open file descriptors high-water mark
|
||||
* minor / major page faults
|
||||
* voluntary / involuntary context switches
|
||||
* block I/O bytes read/written
|
||||
|
||||
Everything runs under an isolated JCODE_HOME / JCODE_RUNTIME_DIR / JCODE_SOCKET
|
||||
so it never touches the user's real shared server, logs, sessions, or creds.
|
||||
|
||||
Two spawn shapes are profiled:
|
||||
|
||||
server : the background daemon (`jcode serve`) on a private socket. This is
|
||||
the heavy half of a cold spawn and owns the long-lived footprint.
|
||||
client : the cold interactive client launched in a PTY, which itself spawns
|
||||
the server when none is running. We sample BOTH the client and the
|
||||
daemon it spawns.
|
||||
|
||||
Usage:
|
||||
python3 scripts/profile_spawn.py [BINARY] [--runs N] [--json out.json]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import select
|
||||
import shutil
|
||||
import socket
|
||||
import statistics
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import pty
|
||||
import termios
|
||||
import struct
|
||||
import fcntl
|
||||
|
||||
_HAVE_PTY = True
|
||||
except ImportError: # pragma: no cover - non-unix
|
||||
_HAVE_PTY = False
|
||||
|
||||
CLK_TCK = os.sysconf("SC_CLK_TCK") if hasattr(os, "sysconf") else 100
|
||||
PAGE_SIZE = os.sysconf("SC_PAGE_SIZE") if hasattr(os, "sysconf") else 4096
|
||||
|
||||
PROFILE_TOTAL_RE = re.compile(r"Startup Profile \(([0-9.]+)ms total\)")
|
||||
PROFILE_LINE_RE = re.compile(
|
||||
r"\[INFO\]\s+([0-9.]+)ms\s+([0-9.]+)ms\s+[0-9.]+%\s+([a-zA-Z0-9_]+)"
|
||||
)
|
||||
REMOTE_HISTORY_RE = re.compile(r"remote bootstrap: history after ([0-9.]+)ms")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# /proc sampling
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _read(path: str) -> str | None:
|
||||
try:
|
||||
with open(path, "r") as fh:
|
||||
return fh.read()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _status_kib(status: str, key: str) -> int | None:
|
||||
m = re.search(rf"^{re.escape(key)}\s+(\d+)\s*kB", status, re.MULTILINE)
|
||||
return int(m.group(1)) * 1024 if m else None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProcSample:
|
||||
"""One point-in-time sample of a process from /proc/<pid>."""
|
||||
|
||||
rss_bytes: int | None = None
|
||||
rss_anon_bytes: int | None = None
|
||||
rss_file_bytes: int | None = None
|
||||
peak_rss_bytes: int | None = None
|
||||
pss_bytes: int | None = None
|
||||
swap_bytes: int | None = None
|
||||
threads: int | None = None
|
||||
fds: int | None = None
|
||||
# cumulative counters
|
||||
utime_s: float | None = None
|
||||
stime_s: float | None = None
|
||||
minflt: int | None = None
|
||||
majflt: int | None = None
|
||||
vol_ctxt: int | None = None
|
||||
nonvol_ctxt: int | None = None
|
||||
read_bytes: int | None = None
|
||||
write_bytes: int | None = None
|
||||
|
||||
|
||||
def sample_proc(pid: int) -> ProcSample | None:
|
||||
status = _read(f"/proc/{pid}/status")
|
||||
stat = _read(f"/proc/{pid}/stat")
|
||||
if status is None or stat is None:
|
||||
return None
|
||||
s = ProcSample()
|
||||
s.rss_bytes = _status_kib(status, "VmRSS:")
|
||||
s.rss_anon_bytes = _status_kib(status, "RssAnon:")
|
||||
s.rss_file_bytes = _status_kib(status, "RssFile:")
|
||||
s.peak_rss_bytes = _status_kib(status, "VmHWM:")
|
||||
s.swap_bytes = _status_kib(status, "VmSwap:")
|
||||
tm = re.search(r"^Threads:\s+(\d+)", status, re.MULTILINE)
|
||||
s.threads = int(tm.group(1)) if tm else None
|
||||
|
||||
rollup = _read(f"/proc/{pid}/smaps_rollup")
|
||||
if rollup:
|
||||
pm = re.search(r"^Pss:\s+(\d+)\s*kB", rollup, re.MULTILINE)
|
||||
s.pss_bytes = int(pm.group(1)) * 1024 if pm else None
|
||||
|
||||
# /proc/<pid>/stat fields after "comm) ": index 0 == field 3 (state),
|
||||
# so field N maps to fields[N-3]. minflt=10 majflt=12 utime=14 stime=15.
|
||||
rparen = stat.rfind(")")
|
||||
if rparen != -1:
|
||||
fields = stat[rparen + 2 :].split()
|
||||
try:
|
||||
s.minflt = int(fields[10 - 3]) # field 10
|
||||
s.majflt = int(fields[12 - 3]) # field 12
|
||||
s.utime_s = int(fields[14 - 3]) / CLK_TCK # field 14
|
||||
s.stime_s = int(fields[15 - 3]) / CLK_TCK # field 15
|
||||
except (IndexError, ValueError):
|
||||
pass
|
||||
|
||||
sched = status
|
||||
vm = re.search(r"^voluntary_ctxt_switches:\s+(\d+)", sched, re.MULTILINE)
|
||||
nm = re.search(r"^nonvoluntary_ctxt_switches:\s+(\d+)", sched, re.MULTILINE)
|
||||
s.vol_ctxt = int(vm.group(1)) if vm else None
|
||||
s.nonvol_ctxt = int(nm.group(1)) if nm else None
|
||||
|
||||
io = _read(f"/proc/{pid}/io")
|
||||
if io:
|
||||
rm = re.search(r"^read_bytes:\s+(\d+)", io, re.MULTILINE)
|
||||
wm = re.search(r"^write_bytes:\s+(\d+)", io, re.MULTILINE)
|
||||
s.read_bytes = int(rm.group(1)) if rm else None
|
||||
s.write_bytes = int(wm.group(1)) if wm else None
|
||||
|
||||
try:
|
||||
s.fds = len(os.listdir(f"/proc/{pid}/fd"))
|
||||
except OSError:
|
||||
s.fds = None
|
||||
return s
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResourceProfile:
|
||||
"""Aggregated resource utilization for one spawned process over its window."""
|
||||
|
||||
wall_ms: float = 0.0
|
||||
peak_rss_bytes: int = 0
|
||||
final_rss_bytes: int = 0
|
||||
peak_rss_anon_bytes: int = 0
|
||||
peak_pss_bytes: int = 0
|
||||
peak_swap_bytes: int = 0
|
||||
peak_threads: int = 0
|
||||
peak_fds: int = 0
|
||||
cpu_user_s: float = 0.0
|
||||
cpu_sys_s: float = 0.0
|
||||
cpu_total_s: float = 0.0
|
||||
cpu_pct: float = 0.0
|
||||
minflt: int = 0
|
||||
majflt: int = 0
|
||||
vol_ctxt: int = 0
|
||||
nonvol_ctxt: int = 0
|
||||
io_read_bytes: int = 0
|
||||
io_write_bytes: int = 0
|
||||
samples: int = 0
|
||||
|
||||
|
||||
class ProcessMonitor:
|
||||
"""High-frequency sampler that tracks one pid's resource high-water marks."""
|
||||
|
||||
def __init__(self, pid: int):
|
||||
self.pid = pid
|
||||
self.first: ProcSample | None = None
|
||||
self.last: ProcSample | None = None
|
||||
self.peak_rss = 0
|
||||
self.peak_rss_anon = 0
|
||||
self.peak_pss = 0
|
||||
self.peak_swap = 0
|
||||
self.peak_threads = 0
|
||||
self.peak_fds = 0
|
||||
self.start = time.perf_counter()
|
||||
self.end = self.start
|
||||
self.n = 0
|
||||
|
||||
def poll(self) -> bool:
|
||||
s = sample_proc(self.pid)
|
||||
if s is None:
|
||||
return False
|
||||
self.n += 1
|
||||
self.end = time.perf_counter()
|
||||
if self.first is None:
|
||||
self.first = s
|
||||
self.last = s
|
||||
if s.rss_bytes:
|
||||
self.peak_rss = max(self.peak_rss, s.rss_bytes)
|
||||
if s.peak_rss_bytes:
|
||||
self.peak_rss = max(self.peak_rss, s.peak_rss_bytes)
|
||||
if s.rss_anon_bytes:
|
||||
self.peak_rss_anon = max(self.peak_rss_anon, s.rss_anon_bytes)
|
||||
if s.pss_bytes:
|
||||
self.peak_pss = max(self.peak_pss, s.pss_bytes)
|
||||
if s.swap_bytes:
|
||||
self.peak_swap = max(self.peak_swap, s.swap_bytes)
|
||||
if s.threads:
|
||||
self.peak_threads = max(self.peak_threads, s.threads)
|
||||
if s.fds:
|
||||
self.peak_fds = max(self.peak_fds, s.fds)
|
||||
return True
|
||||
|
||||
def finalize(self) -> ResourceProfile:
|
||||
p = ResourceProfile()
|
||||
p.wall_ms = (self.end - self.start) * 1000.0
|
||||
p.samples = self.n
|
||||
p.peak_rss_bytes = self.peak_rss
|
||||
p.peak_rss_anon_bytes = self.peak_rss_anon
|
||||
p.peak_pss_bytes = self.peak_pss
|
||||
p.peak_swap_bytes = self.peak_swap
|
||||
p.peak_threads = self.peak_threads
|
||||
p.peak_fds = self.peak_fds
|
||||
if self.last:
|
||||
p.final_rss_bytes = self.last.rss_bytes or 0
|
||||
l = self.last
|
||||
if l:
|
||||
# Cumulative counters in /proc are totals since process birth. We
|
||||
# monitor from spawn, so the final absolute value IS the total
|
||||
# resource cost of the spawn (more robust than last-first, which
|
||||
# would miss work done before the first sample landed).
|
||||
if l.utime_s is not None:
|
||||
p.cpu_user_s = l.utime_s
|
||||
if l.stime_s is not None:
|
||||
p.cpu_sys_s = l.stime_s
|
||||
p.cpu_total_s = p.cpu_user_s + p.cpu_sys_s
|
||||
if p.wall_ms > 0:
|
||||
p.cpu_pct = 100.0 * p.cpu_total_s / (p.wall_ms / 1000.0)
|
||||
if l.minflt is not None:
|
||||
p.minflt = l.minflt
|
||||
if l.majflt is not None:
|
||||
p.majflt = l.majflt
|
||||
if l.vol_ctxt is not None:
|
||||
p.vol_ctxt = l.vol_ctxt
|
||||
if l.nonvol_ctxt is not None:
|
||||
p.nonvol_ctxt = l.nonvol_ctxt
|
||||
if l.read_bytes is not None:
|
||||
p.io_read_bytes = l.read_bytes
|
||||
if l.write_bytes is not None:
|
||||
p.io_write_bytes = l.write_bytes
|
||||
return p
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Environment isolation
|
||||
# --------------------------------------------------------------------------- #
|
||||
def isolated_env(root: str) -> dict[str, str]:
|
||||
env = os.environ.copy()
|
||||
env["JCODE_HOME"] = os.path.join(root, "home")
|
||||
env["JCODE_RUNTIME_DIR"] = os.path.join(root, "run")
|
||||
env["JCODE_SOCKET"] = os.path.join(env["JCODE_RUNTIME_DIR"], "jcode.sock")
|
||||
env["JCODE_NO_TELEMETRY"] = "1"
|
||||
os.makedirs(env["JCODE_HOME"], exist_ok=True)
|
||||
os.makedirs(env["JCODE_RUNTIME_DIR"], exist_ok=True)
|
||||
return env
|
||||
|
||||
|
||||
def socket_connectable(path: str) -> bool:
|
||||
if not os.path.exists(path):
|
||||
return False
|
||||
try:
|
||||
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
s.connect(path)
|
||||
s.close()
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def find_child_server(env: dict[str, str], exclude: set[int]) -> int | None:
|
||||
"""Find the spawned `serve` daemon bound to our private socket."""
|
||||
try:
|
||||
pids = [int(p) for p in os.listdir("/proc") if p.isdigit()]
|
||||
except OSError:
|
||||
return None
|
||||
for pid in pids:
|
||||
if pid in exclude:
|
||||
continue
|
||||
cmd = _read(f"/proc/{pid}/cmdline")
|
||||
if not cmd:
|
||||
continue
|
||||
argv = cmd.split("\0")
|
||||
if "serve" in argv and any(env["JCODE_SOCKET"] in a or "serve" == a for a in argv):
|
||||
# confirm it is our isolated daemon by socket env or socket arg
|
||||
envblob = _read(f"/proc/{pid}/environ") or ""
|
||||
if env["JCODE_SOCKET"] in envblob or env["JCODE_SOCKET"] in cmd:
|
||||
return pid
|
||||
return None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Startup profile parsing
|
||||
# --------------------------------------------------------------------------- #
|
||||
@dataclass
|
||||
class StartupProfile:
|
||||
total_ms: float = 0.0
|
||||
deltas_ms: dict[str, float] = field(default_factory=dict)
|
||||
remote_history_ms: float | None = None
|
||||
|
||||
|
||||
def parse_startup_profile(log_path: Path) -> StartupProfile | None:
|
||||
if not log_path.exists():
|
||||
return None
|
||||
lines = log_path.read_text(errors="replace").splitlines()
|
||||
last_block: list[str] = []
|
||||
remote_history_ms = None
|
||||
for i, line in enumerate(lines):
|
||||
if "=== Startup Profile (" in line:
|
||||
last_block = lines[i : i + 40]
|
||||
m = REMOTE_HISTORY_RE.search(line)
|
||||
if m:
|
||||
remote_history_ms = float(m.group(1))
|
||||
if not last_block:
|
||||
return None
|
||||
prof = StartupProfile(remote_history_ms=remote_history_ms)
|
||||
for line in last_block:
|
||||
tm = PROFILE_TOTAL_RE.search(line)
|
||||
if tm:
|
||||
prof.total_ms = float(tm.group(1))
|
||||
pm = PROFILE_LINE_RE.search(line)
|
||||
if pm:
|
||||
_from, delta, name = pm.groups()
|
||||
prof.deltas_ms[name] = float(delta)
|
||||
return prof
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Server spawn profiling
|
||||
# --------------------------------------------------------------------------- #
|
||||
def profile_server_spawn(binary: str, sample_interval_s: float) -> dict | None:
|
||||
root = tempfile.mkdtemp(prefix="jcode-spawn-server-")
|
||||
env = isolated_env(root)
|
||||
sock = env["JCODE_SOCKET"]
|
||||
proc = None
|
||||
try:
|
||||
t0 = time.perf_counter()
|
||||
proc = subprocess.Popen(
|
||||
[binary, "--no-update", "serve"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
env=env,
|
||||
)
|
||||
mon = ProcessMonitor(proc.pid)
|
||||
ready_ms = None
|
||||
deadline = t0 + 8.0
|
||||
while time.perf_counter() < deadline:
|
||||
if not mon.poll():
|
||||
break
|
||||
if ready_ms is None and socket_connectable(sock):
|
||||
ready_ms = (time.perf_counter() - t0) * 1000.0
|
||||
# keep sampling briefly to capture steady-state footprint
|
||||
settle_until = time.perf_counter() + 0.4
|
||||
while time.perf_counter() < settle_until:
|
||||
mon.poll()
|
||||
time.sleep(sample_interval_s)
|
||||
break
|
||||
time.sleep(sample_interval_s)
|
||||
res = mon.finalize()
|
||||
out = asdict(res)
|
||||
out["ready_ms"] = ready_ms
|
||||
return out
|
||||
finally:
|
||||
if proc is not None:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait(timeout=2)
|
||||
shutil.rmtree(root, ignore_errors=True)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Cold client spawn profiling (PTY) - samples both client and its server child
|
||||
# --------------------------------------------------------------------------- #
|
||||
def profile_client_spawn(binary: str, sample_interval_s: float, hold_s: float) -> dict | None:
|
||||
if not _HAVE_PTY:
|
||||
return None
|
||||
root = tempfile.mkdtemp(prefix="jcode-spawn-client-")
|
||||
env = isolated_env(root)
|
||||
log_path = Path(env["JCODE_HOME"]) / "logs" / f"jcode-{time.strftime('%Y-%m-%d')}.log"
|
||||
pid = None
|
||||
master_fd = None
|
||||
try:
|
||||
pid, master_fd = pty.fork()
|
||||
if pid == 0: # child
|
||||
try:
|
||||
# 120x40 window
|
||||
winsize = struct.pack("HHHH", 40, 120, 0, 0)
|
||||
fcntl.ioctl(0, termios.TIOCSWINSZ, winsize)
|
||||
except OSError:
|
||||
pass
|
||||
os.execve(
|
||||
binary,
|
||||
[binary, "--no-update", "--socket", env["JCODE_SOCKET"]],
|
||||
env,
|
||||
)
|
||||
os._exit(127)
|
||||
|
||||
# parent: monitor the client and (once spawned) the server daemon
|
||||
t0 = time.perf_counter()
|
||||
client_mon = ProcessMonitor(pid)
|
||||
server_mon = None
|
||||
server_pid = None
|
||||
server_ready_ms = None
|
||||
deadline = t0 + hold_s
|
||||
# drain pty output so the child does not block on a full pipe
|
||||
while time.perf_counter() < deadline:
|
||||
r, _, _ = select.select([master_fd], [], [], 0.0)
|
||||
if r:
|
||||
try:
|
||||
os.read(master_fd, 65536)
|
||||
except OSError:
|
||||
pass
|
||||
if not client_mon.poll():
|
||||
break
|
||||
if server_pid is None:
|
||||
cand = find_child_server(env, exclude={pid, os.getpid()})
|
||||
if cand is not None:
|
||||
server_pid = cand
|
||||
server_mon = ProcessMonitor(server_pid)
|
||||
if server_mon is not None:
|
||||
server_mon.poll()
|
||||
if server_ready_ms is None and socket_connectable(env["JCODE_SOCKET"]):
|
||||
server_ready_ms = (time.perf_counter() - t0) * 1000.0
|
||||
time.sleep(sample_interval_s)
|
||||
|
||||
client_res = asdict(client_mon.finalize())
|
||||
server_res = asdict(server_mon.finalize()) if server_mon else None
|
||||
profile = parse_startup_profile(log_path)
|
||||
return {
|
||||
"client": client_res,
|
||||
"server": server_res,
|
||||
"server_ready_ms": server_ready_ms,
|
||||
"startup_profile": asdict(profile) if profile else None,
|
||||
}
|
||||
finally:
|
||||
if pid:
|
||||
try:
|
||||
os.kill(pid, 15)
|
||||
except OSError:
|
||||
pass
|
||||
time.sleep(0.05)
|
||||
try:
|
||||
os.kill(pid, 9)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
os.waitpid(pid, 0)
|
||||
except OSError:
|
||||
pass
|
||||
if master_fd is not None:
|
||||
try:
|
||||
os.close(master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
shutil.rmtree(root, ignore_errors=True)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Aggregation / reporting
|
||||
# --------------------------------------------------------------------------- #
|
||||
def mb(n) -> str:
|
||||
if not n:
|
||||
return " -"
|
||||
return f"{n / (1024 * 1024):6.1f}"
|
||||
|
||||
|
||||
def agg(values: list[float]) -> dict:
|
||||
vals = [v for v in values if v is not None]
|
||||
if not vals:
|
||||
return {"min": None, "median": None, "max": None, "mean": None}
|
||||
return {
|
||||
"min": min(vals),
|
||||
"median": statistics.median(vals),
|
||||
"max": max(vals),
|
||||
"mean": statistics.mean(vals),
|
||||
}
|
||||
|
||||
|
||||
def median_field(runs: list[dict], key: str) -> float | None:
|
||||
vals = [r[key] for r in runs if r and r.get(key) is not None]
|
||||
return statistics.median(vals) if vals else None
|
||||
|
||||
|
||||
def print_resource_block(title: str, runs: list[dict]) -> None:
|
||||
runs = [r for r in runs if r]
|
||||
if not runs:
|
||||
print(f"\n{title}: no data")
|
||||
return
|
||||
print(f"\n{title} (median of {len(runs)} runs)")
|
||||
rows = [
|
||||
("Peak RSS (MB)", "peak_rss_bytes", lambda v: f"{v/1048576:.1f}"),
|
||||
("Final RSS (MB)", "final_rss_bytes", lambda v: f"{v/1048576:.1f}"),
|
||||
("Peak anon RSS (MB)", "peak_rss_anon_bytes", lambda v: f"{v/1048576:.1f}"),
|
||||
("Peak PSS (MB)", "peak_pss_bytes", lambda v: f"{v/1048576:.1f}"),
|
||||
("Peak threads", "peak_threads", lambda v: f"{v:.0f}"),
|
||||
("Peak open FDs", "peak_fds", lambda v: f"{v:.0f}"),
|
||||
("CPU user (ms)", "cpu_user_s", lambda v: f"{v*1000:.1f}"),
|
||||
("CPU sys (ms)", "cpu_sys_s", lambda v: f"{v*1000:.1f}"),
|
||||
("CPU total (ms)", "cpu_total_s", lambda v: f"{v*1000:.1f}"),
|
||||
("Avg CPU (%)", "cpu_pct", lambda v: f"{v:.0f}"),
|
||||
("Minor faults", "minflt", lambda v: f"{v:.0f}"),
|
||||
("Major faults", "majflt", lambda v: f"{v:.0f}"),
|
||||
("Vol ctx switch", "vol_ctxt", lambda v: f"{v:.0f}"),
|
||||
("Invol ctx switch", "nonvol_ctxt", lambda v: f"{v:.0f}"),
|
||||
("Block read (KB)", "io_read_bytes", lambda v: f"{v/1024:.0f}"),
|
||||
("Block write (KB)", "io_write_bytes", lambda v: f"{v/1024:.0f}"),
|
||||
]
|
||||
for label, key, fmt in rows:
|
||||
med = median_field(runs, key)
|
||||
if med is None:
|
||||
continue
|
||||
print(f" {label:<20} {fmt(med):>10}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
default_bin = os.environ.get("JCODE_BENCH_BIN") or shutil.which("jcode") or "./target/release/jcode"
|
||||
ap.add_argument("binary", nargs="?", default=default_bin)
|
||||
ap.add_argument("--runs", type=int, default=5)
|
||||
ap.add_argument("--sample-interval-ms", type=float, default=2.0)
|
||||
ap.add_argument("--client-hold-s", type=float, default=2.5,
|
||||
help="how long to keep the cold client alive while sampling")
|
||||
ap.add_argument("--json", type=str, default=None, help="write full results to a JSON file")
|
||||
ap.add_argument("--skip-client", action="store_true")
|
||||
ap.add_argument("--skip-server", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
binary = args.binary
|
||||
if not os.path.exists(binary):
|
||||
resolved = shutil.which(binary)
|
||||
if resolved:
|
||||
binary = resolved
|
||||
else:
|
||||
print(f"Binary not found: {binary}", file=sys.stderr)
|
||||
return 1
|
||||
binary = os.path.realpath(binary)
|
||||
|
||||
size = os.path.getsize(binary)
|
||||
print("=" * 64)
|
||||
print("jcode spawn profile: time + resource utilization")
|
||||
print("=" * 64)
|
||||
print(f"Binary : {binary}")
|
||||
print(f"Size : {size/1048576:.1f} MB")
|
||||
print(f"Runs : {args.runs} sample interval: {args.sample_interval_ms} ms")
|
||||
print(f"Host : {os.cpu_count()} CPUs, isolated JCODE_HOME per run")
|
||||
|
||||
interval = args.sample_interval_ms / 1000.0
|
||||
results: dict = {"binary": binary, "binary_size_bytes": size, "runs": args.runs}
|
||||
|
||||
# warm the page cache / binary load
|
||||
subprocess.run([binary, "--version"], capture_output=True, check=False)
|
||||
|
||||
if not args.skip_server:
|
||||
print(f"\n[1/2] Profiling server daemon spawn ({args.runs} runs)...")
|
||||
server_runs = []
|
||||
ready = []
|
||||
for i in range(args.runs):
|
||||
r = profile_server_spawn(binary, interval)
|
||||
if r:
|
||||
server_runs.append(r)
|
||||
if r.get("ready_ms"):
|
||||
ready.append(r["ready_ms"])
|
||||
print(f" run {i+1}: ready={r.get('ready_ms', 0):.1f}ms "
|
||||
f"peak_rss={mb(r.get('peak_rss_bytes')).strip()}MB "
|
||||
f"threads={r.get('peak_threads')} fds={r.get('peak_fds')}")
|
||||
results["server"] = server_runs
|
||||
if ready:
|
||||
print(f"\n Server ready (socket connectable): "
|
||||
f"min={min(ready):.1f} median={statistics.median(ready):.1f} "
|
||||
f"max={max(ready):.1f} ms")
|
||||
print_resource_block("Server daemon resource footprint", server_runs)
|
||||
|
||||
if not args.skip_client and _HAVE_PTY:
|
||||
print(f"\n[2/2] Profiling cold client spawn in PTY ({args.runs} runs)...")
|
||||
client_runs = []
|
||||
spawned_server_runs = []
|
||||
totals = []
|
||||
ready = []
|
||||
for i in range(args.runs):
|
||||
r = profile_client_spawn(binary, interval, args.client_hold_s)
|
||||
if not r:
|
||||
continue
|
||||
client_runs.append(r["client"])
|
||||
if r.get("server"):
|
||||
spawned_server_runs.append(r["server"])
|
||||
sp = r.get("startup_profile")
|
||||
if sp and sp.get("total_ms"):
|
||||
totals.append(sp["total_ms"])
|
||||
if r.get("server_ready_ms"):
|
||||
ready.append(r["server_ready_ms"])
|
||||
results.setdefault("client_full", []).append(r)
|
||||
print(f" run {i+1}: startup_profile_total={sp['total_ms'] if sp else 0:.1f}ms "
|
||||
f"client_peak_rss={mb(r['client'].get('peak_rss_bytes')).strip()}MB "
|
||||
f"server_ready={r.get('server_ready_ms', 0):.1f}ms")
|
||||
if totals:
|
||||
print(f"\n Startup-profile total (in-process marks): "
|
||||
f"min={min(totals):.1f} median={statistics.median(totals):.1f} "
|
||||
f"max={max(totals):.1f} ms")
|
||||
if ready:
|
||||
print(f" Server ready from client launch: "
|
||||
f"min={min(ready):.1f} median={statistics.median(ready):.1f} "
|
||||
f"max={max(ready):.1f} ms")
|
||||
# phase breakdown
|
||||
if client_runs:
|
||||
full = results.get("client_full", [])
|
||||
phase_keys = [
|
||||
"args_parse", "selfdev_git_hash", "tui_client_enter",
|
||||
"tui_terminal_init", "server_spawn_start", "server_ready",
|
||||
"app_new_for_remote",
|
||||
]
|
||||
print("\n Startup phase breakdown (median ms):")
|
||||
for k in phase_keys:
|
||||
vals = [
|
||||
f["startup_profile"]["deltas_ms"].get(k)
|
||||
for f in full
|
||||
if f.get("startup_profile")
|
||||
and f["startup_profile"]["deltas_ms"].get(k) is not None
|
||||
]
|
||||
if vals:
|
||||
print(f" {k:<22} {statistics.median(vals):7.2f}")
|
||||
print_resource_block("Cold client process resource footprint", client_runs)
|
||||
if spawned_server_runs:
|
||||
print_resource_block("Server daemon (spawned by client) footprint", spawned_server_runs)
|
||||
|
||||
if args.json:
|
||||
Path(args.json).write_text(json.dumps(results, indent=2))
|
||||
print(f"\nWrote full results to {args.json}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+156
@@ -0,0 +1,156 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Quick release script - builds Linux + macOS locally and uploads to GitHub.
|
||||
# Linux is built inside an Ubuntu 22.04 container for an older glibc baseline.
|
||||
# macOS is cross-compiled via osxcross (~/.osxcross). Windows is built by CI.
|
||||
#
|
||||
# Setup (one-time):
|
||||
# 1. Install osxcross at ~/.osxcross
|
||||
# 2. rustup target add aarch64-apple-darwin
|
||||
# 3. Add to ~/.cargo/config.toml:
|
||||
# [target.aarch64-apple-darwin]
|
||||
# linker = "aarch64-apple-darwin23.5-clang"
|
||||
#
|
||||
# Usage:
|
||||
# scripts/quick-release.sh v0.5.5 # tag + build + release
|
||||
# scripts/quick-release.sh v0.5.5 "Fix bug" # with custom title
|
||||
# scripts/quick-release.sh --dry-run v0.5.5 # build only, don't publish
|
||||
|
||||
DRY_RUN=false
|
||||
if [[ "${1:-}" == "--dry-run" ]]; then
|
||||
DRY_RUN=true
|
||||
shift
|
||||
fi
|
||||
|
||||
VERSION="${1:?Usage: scripts/quick-release.sh [--dry-run] <version> [title]}"
|
||||
TITLE="${2:-$VERSION}"
|
||||
VERSION_NUM="${VERSION#v}"
|
||||
|
||||
if [[ ! "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "Error: Version must be in format v0.5.4"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
|
||||
for cmd in gh cargo docker; do
|
||||
command -v "$cmd" &>/dev/null || { echo "Error: $cmd not found."; exit 1; }
|
||||
done
|
||||
|
||||
[[ -f "$HOME/.cargo/env" ]] && source "$HOME/.cargo/env"
|
||||
export PATH="$HOME/.osxcross/bin:$PATH"
|
||||
|
||||
# Verify osxcross is available
|
||||
if ! command -v aarch64-apple-darwin23.5-clang &>/dev/null; then
|
||||
echo "Error: osxcross not found. Install at ~/.osxcross"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -n "$(git status --porcelain -- src/ Cargo.toml Cargo.lock)" ]]; then
|
||||
echo "Warning: uncommitted changes in src/ or Cargo files."
|
||||
read -rp "Continue anyway? [y/N] " confirm
|
||||
[[ "$confirm" =~ ^[Yy]$ ]] || exit 1
|
||||
fi
|
||||
|
||||
echo "=== Quick Release: $VERSION ==="
|
||||
echo ""
|
||||
|
||||
DIST="$(mktemp -d)"
|
||||
trap 'rm -rf "$DIST"' EXIT
|
||||
|
||||
OVERALL_START=$(date +%s)
|
||||
|
||||
# Build Linux + macOS in parallel
|
||||
echo "▸ Building Linux x86_64 + macOS aarch64 in parallel..."
|
||||
|
||||
(
|
||||
JCODE_RELEASE_BUILD=1 JCODE_BUILD_SEMVER="$VERSION_NUM" scripts/build_linux_compat.sh "$DIST" >/dev/null
|
||||
echo " ✅ Linux done ($(( $(date +%s) - OVERALL_START ))s)"
|
||||
) &
|
||||
LINUX_PID=$!
|
||||
|
||||
(
|
||||
JCODE_RELEASE_BUILD=1 JCODE_BUILD_SEMVER="$VERSION_NUM" \
|
||||
CARGO_INCREMENTAL=0 CARGO_BUILD_JOBS="${CARGO_BUILD_JOBS:-1}" \
|
||||
cargo build --release --target aarch64-apple-darwin --bin jcode 2>/dev/null
|
||||
cp target/aarch64-apple-darwin/release/jcode "$DIST/jcode-macos-aarch64"
|
||||
chmod +x "$DIST/jcode-macos-aarch64"
|
||||
(cd "$DIST" && tar czf jcode-macos-aarch64.tar.gz jcode-macos-aarch64)
|
||||
echo " ✅ macOS done ($(( $(date +%s) - OVERALL_START ))s)"
|
||||
) &
|
||||
MACOS_PID=$!
|
||||
|
||||
wait $LINUX_PID || { echo "Error: Linux build failed"; exit 1; }
|
||||
wait $MACOS_PID || { echo "Error: macOS build failed"; exit 1; }
|
||||
|
||||
BUILD_TIME=$(( $(date +%s) - OVERALL_START ))
|
||||
echo ""
|
||||
echo "Build time: ${BUILD_TIME}s"
|
||||
ls -lh "$DIST"/*.tar.gz
|
||||
|
||||
# Verify binaries
|
||||
file "$DIST/jcode-linux-x86_64.bin" | grep -q 'ELF 64-bit' || { echo "Error: bad Linux binary"; exit 1; }
|
||||
head -1 "$DIST/jcode-linux-x86_64" | grep -q '^#!/' || { echo "Error: bad Linux wrapper"; exit 1; }
|
||||
file "$DIST/jcode-macos-aarch64" | grep -q 'Mach-O 64-bit' || { echo "Error: bad macOS binary"; exit 1; }
|
||||
|
||||
if $DRY_RUN; then
|
||||
echo ""
|
||||
echo "Dry run complete. Binaries in: $DIST"
|
||||
trap - EXIT
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "▸ Tagging $VERSION..."
|
||||
if git tag -l "$VERSION" | grep -q "$VERSION"; then
|
||||
echo " Tag already exists"
|
||||
else
|
||||
git tag "$VERSION" -m "$TITLE"
|
||||
git push origin "$VERSION"
|
||||
echo " Tag pushed (CI will add Windows)"
|
||||
fi
|
||||
|
||||
echo "▸ Creating GitHub release..."
|
||||
# Human-readable changelog body (issue #435): changelog/v<version>.json when
|
||||
# present, otherwise grouped commit subjects, always with the compare link.
|
||||
NOTES_FILE="$DIST/release_notes.md"
|
||||
if scripts/generate_release_notes.sh "$VERSION" > "$NOTES_FILE" && [[ -s "$NOTES_FILE" ]]; then
|
||||
gh release create "$VERSION" \
|
||||
"$DIST/jcode-linux-x86_64.tar.gz" \
|
||||
"$DIST/jcode-macos-aarch64.tar.gz" \
|
||||
--title "$TITLE" \
|
||||
--notes-file "$NOTES_FILE"
|
||||
else
|
||||
echo " Warning: release notes generation failed, falling back to --generate-notes"
|
||||
gh release create "$VERSION" \
|
||||
"$DIST/jcode-linux-x86_64.tar.gz" \
|
||||
"$DIST/jcode-macos-aarch64.tar.gz" \
|
||||
--title "$TITLE" \
|
||||
--generate-notes
|
||||
fi
|
||||
|
||||
# Close issues marked fixed-but-not-yet-released.
|
||||
PENDING_LABEL="triage: fixed-pending-release"
|
||||
echo "▸ Closing issues labeled '$PENDING_LABEL'..."
|
||||
PENDING_ISSUES="$(gh issue list --label "$PENDING_LABEL" --state open --json number --jq '.[].number' 2>/dev/null || true)"
|
||||
if [[ -n "$PENDING_ISSUES" ]]; then
|
||||
while read -r issue; do
|
||||
[[ -z "$issue" ]] && continue
|
||||
gh issue close "$issue" \
|
||||
--comment "Released in [$VERSION](https://github.com/$(gh repo view --json nameWithOwner --jq .nameWithOwner)/releases/tag/$VERSION). Run \`jcode update\` to get it." \
|
||||
--reason completed >/dev/null 2>&1 \
|
||||
&& echo " ✅ Closed #$issue"
|
||||
gh issue edit "$issue" --remove-label "$PENDING_LABEL" >/dev/null 2>&1 || true
|
||||
done <<< "$PENDING_ISSUES"
|
||||
else
|
||||
echo " (none)"
|
||||
fi
|
||||
|
||||
TOTAL_TIME=$(( $(date +%s) - OVERALL_START ))
|
||||
echo ""
|
||||
echo "=== Released $VERSION in ${TOTAL_TIME}s ==="
|
||||
echo " ✅ Linux + macOS: available now"
|
||||
echo " ⏳ Windows: CI (~15 min)"
|
||||
echo ""
|
||||
echo "Users can now: jcode update"
|
||||
Executable
+66
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
provider=${JCODE_PROVIDER:-auto}
|
||||
prompt=${1:-"Use the bash tool to run 'pwd', then use the ls tool to list the current directory, then respond with DONE."}
|
||||
expect=${JCODE_TRACE_EXPECT:-DONE}
|
||||
cargo_exec="$repo_root/scripts/cargo_exec.sh"
|
||||
|
||||
echo "=== Real Provider Smoke ==="
|
||||
echo "Provider: ${provider}"
|
||||
|
||||
if [[ "${JCODE_REAL_PROVIDER_TEST_API:-1}" == "1" ]]; then
|
||||
if [[ "${provider}" == "claude" && "${JCODE_USE_DIRECT_API:-0}" != "1" ]]; then
|
||||
echo ""
|
||||
echo "Test 1: Claude CLI smoke (test_api)"
|
||||
if [[ "${JCODE_REMOTE_CARGO:-0}" == "1" ]]; then
|
||||
(cd "$repo_root" && "$cargo_exec" build --bin test_api)
|
||||
(cd "$repo_root" && ./target/debug/test_api)
|
||||
else
|
||||
(cd "$repo_root" && cargo run --bin test_api)
|
||||
fi
|
||||
else
|
||||
echo ""
|
||||
echo "Test 1: Skipping test_api (provider=${provider}, JCODE_USE_DIRECT_API=${JCODE_USE_DIRECT_API:-0})"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Test 2: Tool harness (network tools enabled)"
|
||||
if [[ "${JCODE_REMOTE_CARGO:-0}" == "1" ]]; then
|
||||
(cd "$repo_root" && "$cargo_exec" build --bin jcode-harness)
|
||||
(cd "$repo_root" && ./target/debug/jcode-harness -- --include-network)
|
||||
else
|
||||
(cd "$repo_root" && cargo run --bin jcode-harness -- --include-network)
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Test 3: End-to-end trace"
|
||||
if [[ ! -x "$repo_root/target/release/jcode" ]]; then
|
||||
(cd "$repo_root" && "$cargo_exec" build --release)
|
||||
fi
|
||||
|
||||
workdir=$(mktemp -d)
|
||||
trap 'rm -rf "$workdir"' EXIT
|
||||
|
||||
set +e
|
||||
output=$(JCODE_HOME="$workdir" PATH="$repo_root/target/release:$PATH" \
|
||||
jcode run --no-update --trace --provider "$provider" "$prompt" 2>&1)
|
||||
status=$?
|
||||
set -e
|
||||
|
||||
printf "%s\n" "$output"
|
||||
|
||||
if [[ $status -ne 0 ]]; then
|
||||
echo "Trace failed with exit code $status" >&2
|
||||
exit $status
|
||||
fi
|
||||
|
||||
if [[ -n "$expect" ]] && ! grep -q "$expect" <<<"$output"; then
|
||||
echo "Trace output did not include expected marker: ${expect}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== Real provider smoke OK ==="
|
||||
Executable
+126
@@ -0,0 +1,126 @@
|
||||
#!/bin/bash
|
||||
# jcode demo recording orchestrator
|
||||
# Usage: ./scripts/record_demo.sh <demo_name> <prompt>
|
||||
#
|
||||
# This script:
|
||||
# 1. Opens a fresh jcode in a new kitty window
|
||||
# 2. Starts wf-recorder on that window
|
||||
# 3. Sends the prompt to jcode
|
||||
# 4. Waits for completion, then stops recording
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
|
||||
DEMO_NAME="${1:?Usage: record_demo.sh <name> <prompt>}"
|
||||
PROMPT="${2:?Usage: record_demo.sh <name> <prompt>}"
|
||||
DEMO_DIR="/tmp/jcode-demo/$DEMO_NAME"
|
||||
OUTPUT_DIR="$repo_root/assets/demos"
|
||||
SOCK=$(ls /tmp/kitty.sock* 2>/dev/null | head -1)
|
||||
|
||||
mkdir -p "$DEMO_DIR" "$OUTPUT_DIR"
|
||||
|
||||
echo "=== jcode Demo Recorder ==="
|
||||
echo "Demo: $DEMO_NAME"
|
||||
echo "Prompt: $PROMPT"
|
||||
echo "Working dir: $DEMO_DIR"
|
||||
echo ""
|
||||
|
||||
# Step 1: Launch jcode in a new kitty OS window
|
||||
echo "[1/5] Launching jcode..."
|
||||
kitten @ --to unix:$SOCK launch --type=os-window \
|
||||
--cwd "$DEMO_DIR" \
|
||||
--title "jcode-demo-$DEMO_NAME" \
|
||||
"$repo_root/target/release/jcode"
|
||||
|
||||
sleep 3 # Let jcode fully start
|
||||
|
||||
# Step 2: Find the window
|
||||
DEMO_WIN_ID=$(niri msg windows 2>/dev/null | grep -B5 "jcode-demo-$DEMO_NAME" | grep "Window ID" | awk '{print $3}' | tr -d ':')
|
||||
if [ -z "$DEMO_WIN_ID" ]; then
|
||||
echo "ERROR: Could not find demo window"
|
||||
exit 1
|
||||
fi
|
||||
echo "[2/5] Found window ID: $DEMO_WIN_ID"
|
||||
|
||||
# Focus the window
|
||||
niri msg action focus-window --id "$DEMO_WIN_ID"
|
||||
sleep 0.5
|
||||
|
||||
# Step 3: Start recording
|
||||
echo "[3/5] Starting recording..."
|
||||
RECORDING_FILE="$OUTPUT_DIR/${DEMO_NAME}.mp4"
|
||||
wf-recorder -f "$RECORDING_FILE" &
|
||||
RECORDER_PID=$!
|
||||
sleep 1
|
||||
|
||||
# Step 4: Type the prompt into jcode
|
||||
echo "[4/5] Sending prompt..."
|
||||
# Find the kitty window id
|
||||
KITTY_WIN_ID=$(kitten @ --to unix:$SOCK ls 2>/dev/null | python3 -c "
|
||||
import json, sys
|
||||
data = json.load(sys.stdin)
|
||||
for os_win in data:
|
||||
for tab in os_win.get('tabs', []):
|
||||
for win in tab.get('windows', []):
|
||||
if 'jcode-demo-$DEMO_NAME' in win.get('title', ''):
|
||||
print(win['id'])
|
||||
sys.exit(0)
|
||||
")
|
||||
|
||||
if [ -n "$KITTY_WIN_ID" ]; then
|
||||
# Type the prompt character by character with small delay for visual effect
|
||||
kitten @ --to unix:$SOCK send-text --match "id:$KITTY_WIN_ID" "$PROMPT"
|
||||
sleep 0.5
|
||||
# Press Enter
|
||||
kitten @ --to unix:$SOCK send-text --match "id:$KITTY_WIN_ID" $'\r'
|
||||
else
|
||||
echo "WARNING: Could not find kitty window, trying by title match..."
|
||||
kitten @ --to unix:$SOCK send-text --match "title:jcode-demo-$DEMO_NAME" "$PROMPT"
|
||||
sleep 0.5
|
||||
kitten @ --to unix:$SOCK send-text --match "title:jcode-demo-$DEMO_NAME" $'\r'
|
||||
fi
|
||||
|
||||
echo "Prompt sent. Waiting for completion..."
|
||||
echo "(Press Ctrl+C to stop recording early, or wait for auto-detection)"
|
||||
|
||||
# Step 5: Wait and then stop recording
|
||||
# Poll for completion - check if jcode is still processing
|
||||
# Simple approach: wait for a fixed time or manual Ctrl+C
|
||||
trap 'echo "Stopping..."; kill $RECORDER_PID 2>/dev/null; wait $RECORDER_PID 2>/dev/null; echo "Recording saved: $RECORDING_FILE"' INT
|
||||
|
||||
# Wait for the agent to finish (poll the debug socket)
|
||||
MAX_WAIT=180 # 3 minutes max
|
||||
ELAPSED=0
|
||||
while [ $ELAPSED -lt $MAX_WAIT ]; do
|
||||
sleep 5
|
||||
ELAPSED=$((ELAPSED + 5))
|
||||
|
||||
# Check if the kitty window title indicates idle (no streaming)
|
||||
CURRENT_TITLE=$(kitten @ --to unix:$SOCK ls 2>/dev/null | python3 -c "
|
||||
import json, sys
|
||||
data = json.load(sys.stdin)
|
||||
for os_win in data:
|
||||
for tab in os_win.get('tabs', []):
|
||||
for win in tab.get('windows', []):
|
||||
if win['id'] == $KITTY_WIN_ID:
|
||||
print(win.get('title', ''))
|
||||
sys.exit(0)
|
||||
" 2>/dev/null || echo "unknown")
|
||||
|
||||
echo " [${ELAPSED}s] Window: $CURRENT_TITLE"
|
||||
done
|
||||
|
||||
# Add a small pause at the end so viewer can see the result
|
||||
sleep 3
|
||||
|
||||
# Stop recording
|
||||
kill $RECORDER_PID 2>/dev/null
|
||||
wait $RECORDER_PID 2>/dev/null
|
||||
|
||||
echo ""
|
||||
echo "[5/5] Recording saved: $RECORDING_FILE"
|
||||
FILE_SIZE=$(du -h "$RECORDING_FILE" | cut -f1)
|
||||
echo "Size: $FILE_SIZE"
|
||||
echo ""
|
||||
echo "To convert to GIF: ffmpeg -i $RECORDING_FILE -vf 'fps=15,scale=800:-1' ${RECORDING_FILE%.mp4}.gif"
|
||||
Executable
+37
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
cargo_exec="$repo_root/scripts/cargo_exec.sh"
|
||||
|
||||
run_cargo() {
|
||||
(cd "$repo_root" && "$cargo_exec" "$@")
|
||||
}
|
||||
|
||||
echo "=== Phase 1 Refactor Verification ==="
|
||||
|
||||
echo "[1/7] Isolated environment sanity"
|
||||
"$repo_root/scripts/refactor_shadow.sh" check
|
||||
|
||||
echo "[2/7] Build (debug)"
|
||||
"$repo_root/scripts/refactor_shadow.sh" build
|
||||
|
||||
echo "[3/7] Compile + budgets"
|
||||
run_cargo check -q
|
||||
"$repo_root/scripts/check_warning_budget.sh"
|
||||
python3 "$repo_root/scripts/check_code_size_budget.py"
|
||||
|
||||
echo "[4/7] Security preflight"
|
||||
"$repo_root/scripts/security_preflight.sh"
|
||||
|
||||
echo "[5/7] Full tests"
|
||||
run_cargo test -q
|
||||
|
||||
echo "[6/7] E2E tests"
|
||||
run_cargo test --test e2e -q
|
||||
|
||||
echo "[7/7] All-targets/all-features lint"
|
||||
run_cargo check --all-targets --all-features
|
||||
run_cargo clippy --all-targets --all-features -- -D warnings
|
||||
|
||||
echo "=== Phase 1 verification passed ==="
|
||||
Executable
+229
@@ -0,0 +1,229 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Keep files created by this helper private by default.
|
||||
umask 077
|
||||
|
||||
repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
user_name="${USER:-$(id -un)}"
|
||||
runtime_dir="${XDG_RUNTIME_DIR:-/tmp}"
|
||||
default_home="${HOME}/.jcode-refactor"
|
||||
default_socket="${runtime_dir}/jcode-refactor-${user_name}.sock"
|
||||
|
||||
ref_home="${JCODE_REF_HOME:-$default_home}"
|
||||
ref_socket="${JCODE_REF_SOCKET:-$default_socket}"
|
||||
ref_profile="${JCODE_REF_PROFILE:-debug}"
|
||||
|
||||
case "$ref_profile" in
|
||||
debug) default_bin="$repo_root/target/debug/jcode" ;;
|
||||
release) default_bin="$repo_root/target/release/jcode" ;;
|
||||
*)
|
||||
printf 'error: unsupported JCODE_REF_PROFILE: %s (expected debug or release)\n' "$ref_profile" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
ref_bin="${JCODE_REF_BIN:-$default_bin}"
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage:
|
||||
scripts/refactor_shadow.sh env
|
||||
scripts/refactor_shadow.sh build [--release]
|
||||
scripts/refactor_shadow.sh serve [-- <jcode serve args>]
|
||||
scripts/refactor_shadow.sh run [-- <jcode args>]
|
||||
scripts/refactor_shadow.sh connect [-- <jcode connect args>]
|
||||
scripts/refactor_shadow.sh check
|
||||
|
||||
What it does:
|
||||
- Runs jcode in an isolated refactor environment
|
||||
- Uses separate JCODE_HOME and JCODE_SOCKET
|
||||
- Refuses to run against ~/.jcode to protect live sessions
|
||||
|
||||
Environment overrides:
|
||||
JCODE_REF_HOME Isolated home dir (default: ~/.jcode-refactor)
|
||||
JCODE_REF_SOCKET Isolated socket path
|
||||
JCODE_REF_PROFILE debug|release (default: debug)
|
||||
JCODE_REF_BIN Explicit jcode binary path
|
||||
USAGE
|
||||
}
|
||||
|
||||
die() {
|
||||
printf 'error: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
assert_safe_paths() {
|
||||
[[ -n "$ref_home" ]] || die "JCODE_REF_HOME resolved to empty path"
|
||||
[[ -n "$ref_socket" ]] || die "JCODE_REF_SOCKET resolved to empty path"
|
||||
[[ "$ref_home" = /* ]] || die "JCODE_REF_HOME must be an absolute path: $ref_home"
|
||||
[[ "$ref_socket" = /* ]] || die "JCODE_REF_SOCKET must be an absolute path: $ref_socket"
|
||||
|
||||
local prod_home="${HOME}/.jcode"
|
||||
if [[ "$ref_home" == "$prod_home" ]]; then
|
||||
die "refusing to run with production home ($prod_home); set JCODE_REF_HOME to an isolated path"
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_ref_home() {
|
||||
if [[ ! -d "$ref_home" ]]; then
|
||||
mkdir -p -m 700 "$ref_home"
|
||||
fi
|
||||
# Best-effort hardening if dir already exists.
|
||||
chmod 700 "$ref_home" 2>/dev/null || true
|
||||
}
|
||||
|
||||
ensure_socket_parent() {
|
||||
local socket_parent
|
||||
socket_parent=$(dirname "$ref_socket")
|
||||
if [[ ! -d "$socket_parent" ]]; then
|
||||
mkdir -p -m 700 "$socket_parent"
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_binary() {
|
||||
if [[ ! -x "$ref_bin" ]]; then
|
||||
die "jcode binary not found or not executable: $ref_bin (run 'scripts/refactor_shadow.sh build')"
|
||||
fi
|
||||
}
|
||||
|
||||
remove_stale_socket() {
|
||||
local debug_socket
|
||||
debug_socket="${ref_socket%.sock}-debug.sock"
|
||||
for path in "$ref_socket" "$debug_socket"; do
|
||||
if [[ -e "$path" ]]; then
|
||||
if [[ -S "$path" ]]; then
|
||||
rm -f "$path"
|
||||
else
|
||||
die "refusing to remove non-socket path: $path"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
run_isolated() {
|
||||
JCODE_HOME="$ref_home" JCODE_SOCKET="$ref_socket" "$@"
|
||||
}
|
||||
|
||||
normalize_args() {
|
||||
if [[ "${1:-}" == "--" ]]; then
|
||||
shift
|
||||
fi
|
||||
printf '%s\0' "$@"
|
||||
}
|
||||
|
||||
cmd_env() {
|
||||
cat <<EOF_OUT
|
||||
JCODE_REF_HOME=$ref_home
|
||||
JCODE_REF_SOCKET=$ref_socket
|
||||
JCODE_REF_PROFILE=$ref_profile
|
||||
JCODE_REF_BIN=$ref_bin
|
||||
|
||||
# One-off command example:
|
||||
JCODE_HOME=$ref_home JCODE_SOCKET=$ref_socket $ref_bin --version
|
||||
EOF_OUT
|
||||
}
|
||||
|
||||
cmd_build() {
|
||||
local profile_flag=""
|
||||
if [[ "${1:-}" == "--release" ]]; then
|
||||
profile_flag="--release"
|
||||
elif [[ -n "${1:-}" ]]; then
|
||||
die "unknown build argument: $1"
|
||||
fi
|
||||
|
||||
(cd "$repo_root" && "$repo_root/scripts/dev_cargo.sh" build $profile_flag)
|
||||
}
|
||||
|
||||
cmd_check() {
|
||||
assert_safe_paths
|
||||
ensure_ref_home
|
||||
ensure_socket_parent
|
||||
|
||||
printf 'Refactor home: %s\n' "$ref_home"
|
||||
printf 'Refactor socket: %s\n' "$ref_socket"
|
||||
printf 'Refactor binary: %s\n' "$ref_bin"
|
||||
|
||||
if [[ -S "$ref_socket" ]]; then
|
||||
printf 'Socket status: present (server likely running)\n'
|
||||
elif [[ -e "$ref_socket" ]]; then
|
||||
printf 'Socket status: present but not a socket (unexpected)\n'
|
||||
exit 1
|
||||
else
|
||||
printf 'Socket status: not present\n'
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_serve() {
|
||||
assert_safe_paths
|
||||
ensure_ref_home
|
||||
ensure_socket_parent
|
||||
ensure_binary
|
||||
remove_stale_socket
|
||||
|
||||
local -a args=("$@")
|
||||
run_isolated "$ref_bin" serve "${args[@]}"
|
||||
}
|
||||
|
||||
cmd_run() {
|
||||
assert_safe_paths
|
||||
ensure_ref_home
|
||||
ensure_socket_parent
|
||||
ensure_binary
|
||||
|
||||
local -a args=("$@")
|
||||
run_isolated "$ref_bin" "${args[@]}"
|
||||
}
|
||||
|
||||
cmd_connect() {
|
||||
assert_safe_paths
|
||||
ensure_ref_home
|
||||
ensure_socket_parent
|
||||
ensure_binary
|
||||
|
||||
local -a args=("$@")
|
||||
run_isolated "$ref_bin" connect "${args[@]}"
|
||||
}
|
||||
|
||||
main() {
|
||||
local cmd="${1:-help}"
|
||||
shift || true
|
||||
|
||||
case "$cmd" in
|
||||
env)
|
||||
cmd_env
|
||||
;;
|
||||
build)
|
||||
cmd_build "$@"
|
||||
;;
|
||||
serve)
|
||||
if [[ "${1:-}" == "--" ]]; then
|
||||
shift
|
||||
fi
|
||||
cmd_serve "$@"
|
||||
;;
|
||||
run)
|
||||
if [[ "${1:-}" == "--" ]]; then
|
||||
shift
|
||||
fi
|
||||
cmd_run "$@"
|
||||
;;
|
||||
connect)
|
||||
if [[ "${1:-}" == "--" ]]; then
|
||||
shift
|
||||
fi
|
||||
cmd_connect "$@"
|
||||
;;
|
||||
check)
|
||||
cmd_check
|
||||
;;
|
||||
help|-h|--help)
|
||||
usage
|
||||
;;
|
||||
*)
|
||||
die "unknown command: $cmd (use --help)"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Executable
+641
@@ -0,0 +1,641 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Audit self-dev reload recovery handoffs.
|
||||
|
||||
This is a read-only diagnostic helper for bugs where `selfdev reload` restarts the
|
||||
server but not every interrupted session continues. It correlates three sources:
|
||||
|
||||
1. ~/.jcode/reload-recovery/*.json durable recovery intent records
|
||||
2. ~/.jcode/logs/jcode-*.log recovery lifecycle messages
|
||||
3. ~/.jcode/sessions/<session>.json coarse transcript markers
|
||||
|
||||
The important invariant this helps check is:
|
||||
|
||||
A recovery intent should not become effectively terminal unless the client
|
||||
queued/dispatched the continuation or the server accepted the continuation.
|
||||
|
||||
Older builds could mark the server-side intent "Delivered" while merely building
|
||||
a History payload, before the TUI processed that payload. Current builds keep the
|
||||
intent pending until the replacement server accepts the exact hidden
|
||||
continuation. This script flags old-style claimed/delivered records that have no
|
||||
client queue or server acceptance evidence.
|
||||
|
||||
Examples:
|
||||
|
||||
scripts/reload_recovery_audit.py
|
||||
scripts/reload_recovery_audit.py --reload-id reload_1779786108758_15766248331403251908
|
||||
scripts/reload_recovery_audit.py --max-log-files 5 --show-lines
|
||||
scripts/reload_recovery_audit.py --json
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import dataclasses
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from typing import Any
|
||||
|
||||
|
||||
PERSISTED_RE = re.compile(
|
||||
r"reload recovery store: persisted intent reload_id=(reload_[^ ]+) session=([^ ]+) role=([^ ]+)"
|
||||
)
|
||||
CLAIMED_RE = re.compile(
|
||||
r"reload recovery store: claimed intent reload_id=(reload_[^ ]+) session=([^ ]+) role=([^ ]+)"
|
||||
)
|
||||
ATTACHED_RE = re.compile(
|
||||
r"reload recovery store: attached pending intent reload_id=(reload_[^ ]+) session=([^ ]+) role=([^ ]+)"
|
||||
)
|
||||
DELIVERED_RE = re.compile(
|
||||
r"reload recovery store: delivered intent reload_id=(reload_[^ ]+) session=([^ ]+) role=([^ ]+) accepted_by=([^ ]+)"
|
||||
)
|
||||
NON_PENDING_RE = re.compile(
|
||||
r"reload recovery store: skipping non-pending intent session=([^ ]+) reload_id=(reload_[^ ]+) status=([^ ]+)"
|
||||
)
|
||||
HISTORY_QUEUE_RE = re.compile(
|
||||
r"History payload requested reload recovery continuation: session=([^ ]+) was_interrupted=([^ ]+)"
|
||||
)
|
||||
TUI_FLOW_RE = re.compile(
|
||||
r"reload recovery flow=([^ ]+) session_id=([^ ]+) outcome=([^ ]+) detail=(.*)"
|
||||
)
|
||||
HISTORY_SNAPSHOT_RE = re.compile(
|
||||
r"history_reload_recovery_snapshot: (?:using|attaching) server-owned recovery intent for session=([^ ]+)"
|
||||
)
|
||||
SEND_HISTORY_RE = re.compile(r"\[TIMING\] send_history write: session=([^, ]+)")
|
||||
HIDDEN_SEND_RE = re.compile(r"Sending hidden continuation reminder \((\d+) chars\)")
|
||||
MESSAGE_RUNNING_RE = re.compile(
|
||||
r"new_status=running old_status=[^ ]+ phase=member_status_updated .*session_id=([^ ]+)"
|
||||
)
|
||||
RELOAD_ID_RE = re.compile(r"reload_\d+_\d+")
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class Event:
|
||||
kind: str
|
||||
line: int
|
||||
text: str
|
||||
file: str
|
||||
reload_id: str | None = None
|
||||
session_id: str | None = None
|
||||
role: str | None = None
|
||||
detail: str | None = None
|
||||
|
||||
def compact(self) -> dict[str, Any]:
|
||||
return {
|
||||
"kind": self.kind,
|
||||
"line": self.line,
|
||||
"file": self.file,
|
||||
"reload_id": self.reload_id,
|
||||
"session_id": self.session_id,
|
||||
"role": self.role,
|
||||
"detail": self.detail,
|
||||
"text": self.text.strip(),
|
||||
}
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class IntentRecord:
|
||||
reload_id: str
|
||||
session_id: str
|
||||
role: str | None = None
|
||||
persisted: Event | None = None
|
||||
claimed: Event | None = None
|
||||
attached: list[Event] = dataclasses.field(default_factory=list)
|
||||
delivered: list[Event] = dataclasses.field(default_factory=list)
|
||||
delivery_mismatch: list[Event] = dataclasses.field(default_factory=list)
|
||||
non_pending: list[Event] = dataclasses.field(default_factory=list)
|
||||
history_snapshot: list[Event] = dataclasses.field(default_factory=list)
|
||||
history_queued: list[Event] = dataclasses.field(default_factory=list)
|
||||
tui_flows: list[Event] = dataclasses.field(default_factory=list)
|
||||
send_history: list[Event] = dataclasses.field(default_factory=list)
|
||||
hidden_sends_nearby: list[Event] = dataclasses.field(default_factory=list)
|
||||
member_running: list[Event] = dataclasses.field(default_factory=list)
|
||||
durable_status: str | None = None
|
||||
durable_delivered_at: str | None = None
|
||||
transcript_markers: dict[str, int] = dataclasses.field(default_factory=dict)
|
||||
|
||||
def has_client_queue_evidence(self) -> bool:
|
||||
return bool(self.history_queued) or any(
|
||||
event.detail
|
||||
and (
|
||||
"queued" in event.detail.lower()
|
||||
or "outcome=resumed" in event.detail.lower()
|
||||
)
|
||||
for event in self.tui_flows
|
||||
)
|
||||
|
||||
def has_server_acceptance_evidence(self) -> bool:
|
||||
return bool(self.delivered) or any(
|
||||
event.detail
|
||||
and (
|
||||
"accepted_by=client_message_accepted" in event.detail
|
||||
or "accepted_by=server_startup_headless" in event.detail
|
||||
)
|
||||
for event in self.tui_flows
|
||||
)
|
||||
|
||||
def verdict(self) -> str:
|
||||
if self.has_client_queue_evidence():
|
||||
return "ok-client-queued"
|
||||
if self.has_server_acceptance_evidence():
|
||||
return "ok-server-accepted"
|
||||
if self.durable_status == "pending" or self.durable_status == "Pending":
|
||||
return "pending"
|
||||
if self.claimed or (self.durable_status or "").lower() == "delivered":
|
||||
return "suspect-delivered-without-acceptance-log"
|
||||
if self.attached:
|
||||
return "attached-not-accepted"
|
||||
if self.persisted:
|
||||
return "persisted-not-attached"
|
||||
return "observed-without-persist-log"
|
||||
|
||||
def compact(self, show_lines: bool = False) -> dict[str, Any]:
|
||||
data: dict[str, Any] = {
|
||||
"reload_id": self.reload_id,
|
||||
"session_id": self.session_id,
|
||||
"role": self.role,
|
||||
"durable_status": self.durable_status,
|
||||
"durable_delivered_at": self.durable_delivered_at,
|
||||
"persisted": bool(self.persisted),
|
||||
"claimed": bool(self.claimed),
|
||||
"attached_count": len(self.attached),
|
||||
"delivered_count": len(self.delivered),
|
||||
"delivery_mismatch_count": len(self.delivery_mismatch),
|
||||
"history_snapshot_count": len(self.history_snapshot),
|
||||
"history_queued_count": len(self.history_queued),
|
||||
"tui_flow_count": len(self.tui_flows),
|
||||
"send_history_count": len(self.send_history),
|
||||
"member_running_after_reload_count": len(self.member_running),
|
||||
"transcript_markers": self.transcript_markers,
|
||||
"verdict": self.verdict(),
|
||||
}
|
||||
if show_lines:
|
||||
data["events"] = [
|
||||
*(event.compact() for event in [self.persisted, self.claimed] if event),
|
||||
*(event.compact() for event in self.attached),
|
||||
*(event.compact() for event in self.delivered),
|
||||
*(event.compact() for event in self.delivery_mismatch),
|
||||
*(event.compact() for event in self.history_snapshot),
|
||||
*(event.compact() for event in self.history_queued),
|
||||
*(event.compact() for event in self.tui_flows),
|
||||
*(event.compact() for event in self.send_history),
|
||||
*(event.compact() for event in self.member_running),
|
||||
]
|
||||
return data
|
||||
|
||||
|
||||
def jcode_home() -> pathlib.Path:
|
||||
return pathlib.Path(os.environ.get("JCODE_HOME", pathlib.Path.home() / ".jcode"))
|
||||
|
||||
|
||||
def log_files(log_dir: pathlib.Path, max_log_files: int) -> list[pathlib.Path]:
|
||||
files = sorted(log_dir.glob("jcode-*.log"), key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
return list(reversed(files[:max_log_files]))
|
||||
|
||||
|
||||
def normalize_reload_id(value: str | None) -> str | None:
|
||||
if not value:
|
||||
return None
|
||||
return value if value.startswith("reload_") else f"reload_{value}"
|
||||
|
||||
|
||||
def add_event(records: dict[tuple[str, str], IntentRecord], event: Event) -> None:
|
||||
if not event.reload_id or not event.session_id:
|
||||
return
|
||||
key = (event.reload_id, event.session_id)
|
||||
record = records.setdefault(
|
||||
key,
|
||||
IntentRecord(
|
||||
reload_id=event.reload_id,
|
||||
session_id=event.session_id,
|
||||
role=event.role,
|
||||
),
|
||||
)
|
||||
if event.role and not record.role:
|
||||
record.role = event.role
|
||||
if event.kind == "persisted":
|
||||
record.persisted = event
|
||||
elif event.kind == "claimed":
|
||||
record.claimed = event
|
||||
elif event.kind == "attached":
|
||||
record.attached.append(event)
|
||||
elif event.kind == "delivered":
|
||||
record.delivered.append(event)
|
||||
elif event.kind == "delivery_mismatch":
|
||||
record.delivery_mismatch.append(event)
|
||||
elif event.kind == "non_pending":
|
||||
record.non_pending.append(event)
|
||||
elif event.kind == "history_snapshot":
|
||||
record.history_snapshot.append(event)
|
||||
elif event.kind == "history_queued":
|
||||
record.history_queued.append(event)
|
||||
elif event.kind == "tui_flow":
|
||||
record.tui_flows.append(event)
|
||||
elif event.kind == "send_history":
|
||||
record.send_history.append(event)
|
||||
elif event.kind == "member_running":
|
||||
record.member_running.append(event)
|
||||
|
||||
|
||||
def parse_logs(files: list[pathlib.Path]) -> tuple[dict[tuple[str, str], IntentRecord], list[Event]]:
|
||||
records: dict[tuple[str, str], IntentRecord] = {}
|
||||
session_latest_reload: dict[str, str] = {}
|
||||
global_hidden_sends: list[Event] = []
|
||||
|
||||
for path in files:
|
||||
try:
|
||||
handle = path.open(errors="ignore")
|
||||
except OSError:
|
||||
continue
|
||||
with handle:
|
||||
for line_no, line in enumerate(handle, 1):
|
||||
text = line.rstrip("\n")
|
||||
|
||||
if match := PERSISTED_RE.search(text):
|
||||
reload_id, session_id, role = match.groups()
|
||||
session_latest_reload[session_id] = reload_id
|
||||
add_event(
|
||||
records,
|
||||
Event(
|
||||
"persisted",
|
||||
line_no,
|
||||
text,
|
||||
path.name,
|
||||
reload_id,
|
||||
session_id,
|
||||
role,
|
||||
),
|
||||
)
|
||||
continue
|
||||
|
||||
if match := CLAIMED_RE.search(text):
|
||||
reload_id, session_id, role = match.groups()
|
||||
session_latest_reload[session_id] = reload_id
|
||||
add_event(
|
||||
records,
|
||||
Event("claimed", line_no, text, path.name, reload_id, session_id, role),
|
||||
)
|
||||
continue
|
||||
|
||||
if match := ATTACHED_RE.search(text):
|
||||
reload_id, session_id, role = match.groups()
|
||||
session_latest_reload[session_id] = reload_id
|
||||
add_event(
|
||||
records,
|
||||
Event("attached", line_no, text, path.name, reload_id, session_id, role),
|
||||
)
|
||||
continue
|
||||
|
||||
if match := DELIVERED_RE.search(text):
|
||||
reload_id, session_id, role, accepted_by = match.groups()
|
||||
session_latest_reload[session_id] = reload_id
|
||||
add_event(
|
||||
records,
|
||||
Event(
|
||||
"delivered",
|
||||
line_no,
|
||||
text,
|
||||
path.name,
|
||||
reload_id,
|
||||
session_id,
|
||||
role,
|
||||
detail=f"accepted_by={accepted_by}",
|
||||
),
|
||||
)
|
||||
continue
|
||||
|
||||
if match := NON_PENDING_RE.search(text):
|
||||
session_id, reload_id, status = match.groups()
|
||||
session_latest_reload[session_id] = reload_id
|
||||
add_event(
|
||||
records,
|
||||
Event(
|
||||
"non_pending",
|
||||
line_no,
|
||||
text,
|
||||
path.name,
|
||||
reload_id,
|
||||
session_id,
|
||||
detail=status,
|
||||
),
|
||||
)
|
||||
continue
|
||||
|
||||
if match := HISTORY_QUEUE_RE.search(text):
|
||||
session_id, was_interrupted = match.groups()
|
||||
reload_id = session_latest_reload.get(session_id)
|
||||
if reload_id:
|
||||
add_event(
|
||||
records,
|
||||
Event(
|
||||
"history_queued",
|
||||
line_no,
|
||||
text,
|
||||
path.name,
|
||||
reload_id,
|
||||
session_id,
|
||||
detail=f"was_interrupted={was_interrupted}",
|
||||
),
|
||||
)
|
||||
continue
|
||||
|
||||
if match := TUI_FLOW_RE.search(text):
|
||||
flow, session_id, outcome, detail = match.groups()
|
||||
reload_id = session_latest_reload.get(session_id)
|
||||
if reload_id:
|
||||
add_event(
|
||||
records,
|
||||
Event(
|
||||
"tui_flow",
|
||||
line_no,
|
||||
text,
|
||||
path.name,
|
||||
reload_id,
|
||||
session_id,
|
||||
detail=f"flow={flow} outcome={outcome} detail={detail}",
|
||||
),
|
||||
)
|
||||
continue
|
||||
|
||||
if match := HISTORY_SNAPSHOT_RE.search(text):
|
||||
session_id = match.group(1)
|
||||
reload_id = session_latest_reload.get(session_id)
|
||||
if reload_id:
|
||||
add_event(
|
||||
records,
|
||||
Event(
|
||||
"history_snapshot",
|
||||
line_no,
|
||||
text,
|
||||
path.name,
|
||||
reload_id,
|
||||
session_id,
|
||||
),
|
||||
)
|
||||
continue
|
||||
|
||||
if match := SEND_HISTORY_RE.search(text):
|
||||
session_id = match.group(1)
|
||||
reload_id = session_latest_reload.get(session_id)
|
||||
if reload_id:
|
||||
add_event(
|
||||
records,
|
||||
Event("send_history", line_no, text, path.name, reload_id, session_id),
|
||||
)
|
||||
continue
|
||||
|
||||
if match := MESSAGE_RUNNING_RE.search(text):
|
||||
session_id = match.group(1)
|
||||
reload_id = session_latest_reload.get(session_id)
|
||||
if reload_id:
|
||||
add_event(
|
||||
records,
|
||||
Event("member_running", line_no, text, path.name, reload_id, session_id),
|
||||
)
|
||||
continue
|
||||
|
||||
if match := HIDDEN_SEND_RE.search(text):
|
||||
global_hidden_sends.append(
|
||||
Event(
|
||||
"hidden_send",
|
||||
line_no,
|
||||
text,
|
||||
path.name,
|
||||
detail=f"chars={match.group(1)}",
|
||||
)
|
||||
)
|
||||
|
||||
return records, global_hidden_sends
|
||||
|
||||
|
||||
def parse_trace_files(records: dict[tuple[str, str], IntentRecord], trace_dir: pathlib.Path) -> None:
|
||||
"""Merge structured ~/.jcode/reload-traces/*.jsonl lifecycle events."""
|
||||
phase_to_kind = {
|
||||
"intent_attached_to_history": "attached",
|
||||
"intent_delivered": "delivered",
|
||||
"intent_delivery_mismatch": "delivery_mismatch",
|
||||
}
|
||||
for path in sorted(trace_dir.glob("*.jsonl")):
|
||||
try:
|
||||
handle = path.open(errors="ignore")
|
||||
except OSError:
|
||||
continue
|
||||
with handle:
|
||||
for line_no, line in enumerate(handle, 1):
|
||||
text = line.rstrip("\n")
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
phase = data.get("phase")
|
||||
kind = phase_to_kind.get(phase)
|
||||
if not kind:
|
||||
continue
|
||||
reload_id = data.get("reload_id") or path.stem
|
||||
session_id = data.get("session_id")
|
||||
if not reload_id or not session_id:
|
||||
continue
|
||||
detail_parts = []
|
||||
if accepted_by := data.get("accepted_by"):
|
||||
detail_parts.append(f"accepted_by={accepted_by}")
|
||||
if status := data.get("status"):
|
||||
detail_parts.append(f"status={status}")
|
||||
if phase:
|
||||
detail_parts.append(f"phase={phase}")
|
||||
add_event(
|
||||
records,
|
||||
Event(
|
||||
kind,
|
||||
line_no,
|
||||
text,
|
||||
path.name,
|
||||
reload_id,
|
||||
session_id,
|
||||
data.get("role"),
|
||||
detail=" ".join(detail_parts) or None,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def merge_durable_records(records: dict[tuple[str, str], IntentRecord], recovery_dir: pathlib.Path) -> None:
|
||||
for path in sorted(recovery_dir.glob("*.json")):
|
||||
try:
|
||||
data = json.loads(path.read_text())
|
||||
except Exception:
|
||||
continue
|
||||
reload_id = data.get("reload_id")
|
||||
session_id = data.get("session_id")
|
||||
if not reload_id or not session_id:
|
||||
continue
|
||||
key = (reload_id, session_id)
|
||||
record = records.setdefault(
|
||||
key,
|
||||
IntentRecord(
|
||||
reload_id=reload_id,
|
||||
session_id=session_id,
|
||||
role=data.get("role"),
|
||||
),
|
||||
)
|
||||
record.role = record.role or data.get("role")
|
||||
record.durable_status = data.get("status")
|
||||
record.durable_delivered_at = data.get("delivered_at")
|
||||
|
||||
|
||||
def add_transcript_markers(records: list[IntentRecord], sessions_dir: pathlib.Path) -> None:
|
||||
markers = [
|
||||
"Reload succeeded",
|
||||
"interrupted by a server reload",
|
||||
"Continue immediately from where you left off",
|
||||
"Reload complete",
|
||||
]
|
||||
session_to_records: dict[str, list[IntentRecord]] = defaultdict(list)
|
||||
for record in records:
|
||||
session_to_records[record.session_id].append(record)
|
||||
|
||||
for session_id, session_records in session_to_records.items():
|
||||
path = sessions_dir / f"{session_id}.json"
|
||||
if not path.exists():
|
||||
continue
|
||||
try:
|
||||
text = path.read_text(errors="ignore")
|
||||
except OSError:
|
||||
continue
|
||||
counts = {marker: text.count(marker) for marker in markers}
|
||||
for record in session_records:
|
||||
record.transcript_markers = counts
|
||||
|
||||
|
||||
def short_session(session_id: str) -> str:
|
||||
if len(session_id) <= 34:
|
||||
return session_id
|
||||
return session_id[:31] + "..."
|
||||
|
||||
|
||||
def render_table(records: list[IntentRecord], hidden_sends: list[Event], show_lines: bool) -> str:
|
||||
if not records:
|
||||
return "No reload recovery records found for the selected filters."
|
||||
|
||||
rows = []
|
||||
for record in records:
|
||||
client = "yes" if record.has_client_queue_evidence() else "no"
|
||||
accepted = "yes" if record.has_server_acceptance_evidence() else "no"
|
||||
claimed = "yes" if record.claimed else "no"
|
||||
persisted = "yes" if record.persisted else "no"
|
||||
transcript = sum(record.transcript_markers.values()) if record.transcript_markers else 0
|
||||
rows.append(
|
||||
[
|
||||
record.reload_id,
|
||||
short_session(record.session_id),
|
||||
record.role or "?",
|
||||
record.durable_status or "?",
|
||||
persisted,
|
||||
claimed,
|
||||
str(len(record.attached)),
|
||||
accepted,
|
||||
client,
|
||||
str(record.send_history and len(record.send_history) or 0),
|
||||
str(record.member_running and len(record.member_running) or 0),
|
||||
str(transcript),
|
||||
record.verdict(),
|
||||
]
|
||||
)
|
||||
|
||||
headers = [
|
||||
"reload_id",
|
||||
"session",
|
||||
"role",
|
||||
"store",
|
||||
"persist",
|
||||
"old_claim",
|
||||
"attach",
|
||||
"accepted",
|
||||
"client_queue",
|
||||
"hist_write",
|
||||
"ran_after",
|
||||
"markers",
|
||||
"verdict",
|
||||
]
|
||||
widths = [len(h) for h in headers]
|
||||
for row in rows:
|
||||
for idx, value in enumerate(row):
|
||||
widths[idx] = max(widths[idx], len(value))
|
||||
|
||||
def fmt(row: list[str]) -> str:
|
||||
return " ".join(value.ljust(widths[idx]) for idx, value in enumerate(row))
|
||||
|
||||
out = [fmt(headers), fmt(["-" * width for width in widths])]
|
||||
out.extend(fmt(row) for row in rows)
|
||||
suspects = [record for record in records if record.verdict().startswith("suspect")]
|
||||
out.append("")
|
||||
out.append(f"Global hidden continuation send log lines scanned: {len(hidden_sends)}")
|
||||
out.append(f"Suspect delivered-without-acceptance records: {len(suspects)}")
|
||||
|
||||
if suspects:
|
||||
out.append("")
|
||||
out.append("Suspects:")
|
||||
for record in suspects:
|
||||
claim = f"{record.claimed.file}:{record.claimed.line}" if record.claimed else "durable-only"
|
||||
out.append(
|
||||
f" - {record.session_id} ({record.role}) {record.reload_id} terminal={claim}"
|
||||
)
|
||||
|
||||
if show_lines:
|
||||
out.append("")
|
||||
out.append("Event lines:")
|
||||
for record in records:
|
||||
out.append(f"\n[{record.reload_id} {record.session_id}]")
|
||||
for event in record.compact(show_lines=True).get("events", []):
|
||||
out.append(f" {event['file']}:{event['line']} {event['kind']}: {event['text']}")
|
||||
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Audit self-dev reload recovery handoffs")
|
||||
parser.add_argument("--home", type=pathlib.Path, default=jcode_home(), help="JCODE_HOME path")
|
||||
parser.add_argument("--reload-id", type=str, help="Only show one reload id")
|
||||
parser.add_argument("--session", type=str, help="Only show one session id")
|
||||
parser.add_argument("--max-log-files", type=int, default=3, help="Newest jcode logs to scan")
|
||||
parser.add_argument("--show-lines", action="store_true", help="Show matching source log lines")
|
||||
parser.add_argument("--json", action="store_true", help="Emit JSON")
|
||||
args = parser.parse_args()
|
||||
|
||||
home = args.home
|
||||
records, hidden_sends = parse_logs(log_files(home / "logs", args.max_log_files))
|
||||
parse_trace_files(records, home / "reload-traces")
|
||||
merge_durable_records(records, home / "reload-recovery")
|
||||
|
||||
reload_filter = normalize_reload_id(args.reload_id)
|
||||
selected = list(records.values())
|
||||
if reload_filter:
|
||||
selected = [record for record in selected if record.reload_id == reload_filter]
|
||||
if args.session:
|
||||
selected = [record for record in selected if record.session_id == args.session]
|
||||
|
||||
selected.sort(key=lambda record: (record.reload_id, record.session_id))
|
||||
add_transcript_markers(selected, home / "sessions")
|
||||
|
||||
if args.json:
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"home": str(home),
|
||||
"records": [record.compact(show_lines=args.show_lines) for record in selected],
|
||||
"global_hidden_sends": [event.compact() for event in hidden_sends],
|
||||
},
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
)
|
||||
)
|
||||
else:
|
||||
print(render_table(selected, hidden_sends, args.show_lines))
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+288
@@ -0,0 +1,288 @@
|
||||
#!/usr/bin/env bash
|
||||
# Remote cargo runner (build/test/check/clippy) via SSH + rsync.
|
||||
#
|
||||
# Defaults:
|
||||
# - Config file: ~/.config/jcode/remote-build.env (override with JCODE_REMOTE_CONFIG)
|
||||
# - Host: JCODE_REMOTE_HOST from env/config, or --host
|
||||
# - Remote dir: .cache/remote-builds/jcode/<repo-name> (override with JCODE_REMOTE_DIR or --remote-dir)
|
||||
#
|
||||
# Examples:
|
||||
# scripts/remote_build.sh --release
|
||||
# scripts/remote_build.sh test
|
||||
# scripts/remote_build.sh check --all-targets
|
||||
# scripts/remote_build.sh --host mybox --remote-dir ~/src/jcode test -- --nocapture
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: scripts/remote_build.sh [options] [cargo-subcommand] [cargo-args...]
|
||||
|
||||
Options:
|
||||
-r, --release Add --release to cargo invocation
|
||||
--host HOST Remote SSH host (default: $JCODE_REMOTE_HOST from env/config; required if unset)
|
||||
--remote-dir DIR Remote project directory (default: $JCODE_REMOTE_DIR or .cache/remote-builds/jcode/<repo-name>)
|
||||
--no-sync Skip rsync upload step
|
||||
--sync-back Force sync-back of built binary after command
|
||||
--no-sync-back Disable sync-back of built binary after command
|
||||
-h, --help Show this help
|
||||
|
||||
Behavior:
|
||||
- Default cargo subcommand is 'build'
|
||||
- Sync-back defaults to ON for 'build', OFF for other subcommands
|
||||
- For build sync-back, copies target/{debug|release}/<artifact> from remote to local
|
||||
(artifact defaults to 'jcode', or '--bin <name>' when provided)
|
||||
- Default config file is ~/.config/jcode/remote-build.env
|
||||
EOF
|
||||
}
|
||||
|
||||
LOCAL_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
REPO_NAME="$(basename "$LOCAL_DIR")"
|
||||
|
||||
# shellcheck source=scripts/remote_config.sh
|
||||
source "$LOCAL_DIR/scripts/remote_config.sh"
|
||||
jcode_load_remote_config
|
||||
|
||||
REMOTE="${JCODE_REMOTE_HOST:-}"
|
||||
REMOTE_DIR="${JCODE_REMOTE_DIR:-.cache/remote-builds/jcode/${REPO_NAME}}"
|
||||
SSH_BIN="${JCODE_REMOTE_SSH_BIN:-ssh}"
|
||||
RSYNC_BIN="${JCODE_REMOTE_RSYNC_BIN:-rsync}"
|
||||
|
||||
SYNC_SOURCE=1
|
||||
SYNC_BACK_MODE="auto" # auto|always|never
|
||||
RELEASE=0
|
||||
SUBCOMMAND="build"
|
||||
SUBCOMMAND_SET=0
|
||||
POSITIONAL=()
|
||||
|
||||
remote_connect_timeout() {
|
||||
local value="${JCODE_REMOTE_CONNECT_TIMEOUT:-5}"
|
||||
if [[ ! "$value" =~ ^[0-9]+$ || "$value" -lt 1 ]]; then
|
||||
value=5
|
||||
fi
|
||||
printf '%s\n' "$value"
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-r|--release)
|
||||
RELEASE=1
|
||||
shift
|
||||
;;
|
||||
--host)
|
||||
[[ $# -lt 2 ]] && { echo "error: --host requires a value" >&2; exit 2; }
|
||||
REMOTE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--remote-dir)
|
||||
[[ $# -lt 2 ]] && { echo "error: --remote-dir requires a value" >&2; exit 2; }
|
||||
REMOTE_DIR="$2"
|
||||
shift 2
|
||||
;;
|
||||
--no-sync)
|
||||
SYNC_SOURCE=0
|
||||
shift
|
||||
;;
|
||||
--sync-back)
|
||||
SYNC_BACK_MODE="always"
|
||||
shift
|
||||
;;
|
||||
--no-sync-back)
|
||||
SYNC_BACK_MODE="never"
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
--)
|
||||
shift
|
||||
POSITIONAL+=("$@")
|
||||
break
|
||||
;;
|
||||
*)
|
||||
if [[ "$SUBCOMMAND_SET" -eq 0 && "$1" != -* ]]; then
|
||||
SUBCOMMAND="$1"
|
||||
SUBCOMMAND_SET=1
|
||||
else
|
||||
POSITIONAL+=("$1")
|
||||
fi
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "$REMOTE_DIR" == *" "* ]]; then
|
||||
echo "error: remote dir cannot contain spaces: $REMOTE_DIR" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ -z "$REMOTE" ]]; then
|
||||
echo "error: remote host not configured; set JCODE_REMOTE_HOST or pass --host HOST" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
for bin in "$SSH_BIN" "$RSYNC_BIN"; do
|
||||
if ! command -v "$bin" >/dev/null 2>&1; then
|
||||
echo "error: required binary not found: $bin" >&2
|
||||
exit 2
|
||||
fi
|
||||
done
|
||||
|
||||
SSH_CONNECT_TIMEOUT="$(remote_connect_timeout)"
|
||||
SSH_SERVER_ALIVE_INTERVAL="${JCODE_REMOTE_SERVER_ALIVE_INTERVAL:-10}"
|
||||
SSH_SERVER_ALIVE_COUNT_MAX="${JCODE_REMOTE_SERVER_ALIVE_COUNT_MAX:-1}"
|
||||
SSH_OPTS=(
|
||||
-o BatchMode=yes
|
||||
-o ConnectTimeout="$SSH_CONNECT_TIMEOUT"
|
||||
-o ServerAliveInterval="$SSH_SERVER_ALIVE_INTERVAL"
|
||||
-o ServerAliveCountMax="$SSH_SERVER_ALIVE_COUNT_MAX"
|
||||
)
|
||||
RSYNC_SSH_COMMAND="${JCODE_REMOTE_RSYNC_SSH:-$SSH_BIN -o BatchMode=yes -o ConnectTimeout=$SSH_CONNECT_TIMEOUT -o ServerAliveInterval=$SSH_SERVER_ALIVE_INTERVAL -o ServerAliveCountMax=$SSH_SERVER_ALIVE_COUNT_MAX}"
|
||||
|
||||
remote_ssh() {
|
||||
"$SSH_BIN" "${SSH_OPTS[@]}" "$REMOTE" "$@"
|
||||
}
|
||||
|
||||
CARGO_CMD=(cargo "$SUBCOMMAND")
|
||||
if [[ "$RELEASE" -eq 1 ]]; then
|
||||
CARGO_CMD+=(--release)
|
||||
fi
|
||||
if [[ "${#POSITIONAL[@]}" -gt 0 ]]; then
|
||||
CARGO_CMD+=("${POSITIONAL[@]}")
|
||||
fi
|
||||
|
||||
sync_back=0
|
||||
case "$SYNC_BACK_MODE" in
|
||||
always) sync_back=1 ;;
|
||||
never) sync_back=0 ;;
|
||||
auto)
|
||||
if [[ "$SUBCOMMAND" == "build" ]]; then
|
||||
sync_back=1
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
profile_name=""
|
||||
for ((i=0; i<${#POSITIONAL[@]}; i++)); do
|
||||
case "${POSITIONAL[$i]}" in
|
||||
--profile)
|
||||
if [[ $((i + 1)) -lt ${#POSITIONAL[@]} ]]; then
|
||||
profile_name="${POSITIONAL[$((i + 1))]}"
|
||||
fi
|
||||
;;
|
||||
--profile=*)
|
||||
profile_name="${POSITIONAL[$i]#--profile=}"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "$RELEASE" -eq 1 || "$profile_name" == "release" ]]; then
|
||||
build_mode="release"
|
||||
elif [[ -n "$profile_name" && "$profile_name" != "dev" ]]; then
|
||||
build_mode="$profile_name"
|
||||
else
|
||||
build_mode="debug"
|
||||
fi
|
||||
|
||||
artifact_name="jcode"
|
||||
if [[ "$SUBCOMMAND" == "build" ]]; then
|
||||
for ((i=0; i<${#POSITIONAL[@]}; i++)); do
|
||||
if [[ "${POSITIONAL[$i]}" == "--bin" && $((i + 1)) -lt ${#POSITIONAL[@]} ]]; then
|
||||
artifact_name="${POSITIONAL[$((i + 1))]}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
BINARY_PATH="target/${build_mode}/${artifact_name}"
|
||||
|
||||
local_git_hash=""
|
||||
local_git_date=""
|
||||
local_git_tag=""
|
||||
local_git_dirty="0"
|
||||
local_changelog_raw=""
|
||||
if command -v git >/dev/null 2>&1 && git -C "$LOCAL_DIR" rev-parse --git-dir >/dev/null 2>&1; then
|
||||
local_git_hash="$(git -C "$LOCAL_DIR" rev-parse --short HEAD 2>/dev/null || true)"
|
||||
local_git_date="$(git -C "$LOCAL_DIR" log -1 --format=%ci 2>/dev/null || true)"
|
||||
local_git_tag="$(git -C "$LOCAL_DIR" describe --tags --always 2>/dev/null || true)"
|
||||
local_changelog_raw="$(git -C "$LOCAL_DIR" log -700 --format='%h|%ct|%D|%s' 2>/dev/null || true)"
|
||||
if [[ -n "$(git -C "$LOCAL_DIR" status --porcelain 2>/dev/null || true)" ]]; then
|
||||
local_git_dirty="1"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "=== Remote Cargo on $REMOTE ==="
|
||||
echo "Local: $LOCAL_DIR"
|
||||
echo "Remote: $REMOTE_DIR"
|
||||
echo "Command: ${CARGO_CMD[*]}"
|
||||
echo "Mode: $build_mode"
|
||||
echo "SSH timeout: ${SSH_CONNECT_TIMEOUT}s"
|
||||
|
||||
echo ""
|
||||
echo "[0/3] Checking remote SSH..."
|
||||
if ! preflight_output="$(remote_ssh "printf 'jcode-remote-ok\\n'" 2>&1)"; then
|
||||
echo "error: remote host '$REMOTE' is not reachable within ${SSH_CONNECT_TIMEOUT}s" >&2
|
||||
echo "$preflight_output" >&2
|
||||
echo "hint: set JCODE_REMOTE_CARGO=0 to force local cargo, or fix JCODE_REMOTE_HOST/JCODE_REMOTE_CONNECT_TIMEOUT." >&2
|
||||
exit 75
|
||||
fi
|
||||
|
||||
if [[ "$SYNC_SOURCE" -eq 1 ]]; then
|
||||
echo ""
|
||||
echo "[1/3] Syncing source files..."
|
||||
remote_ssh "$(printf 'mkdir -p %q' "$REMOTE_DIR")"
|
||||
"$RSYNC_BIN" -avz --delete \
|
||||
-e "$RSYNC_SSH_COMMAND" \
|
||||
--exclude 'target/' \
|
||||
--exclude '.git/' \
|
||||
--exclude '*.log' \
|
||||
--exclude '.claude/' \
|
||||
--exclude '.codex-socktest/' \
|
||||
--exclude '.jcode/' \
|
||||
--exclude '.tmp/' \
|
||||
--exclude '.wrangler/' \
|
||||
--exclude 'tmp/' \
|
||||
--exclude 'node_modules/' \
|
||||
--exclude 'assets/demos/' \
|
||||
--exclude 'assets/readme/' \
|
||||
"$LOCAL_DIR/" "$REMOTE:$REMOTE_DIR/"
|
||||
|
||||
metadata_file="$(mktemp)"
|
||||
trap 'rm -f "$metadata_file"' EXIT
|
||||
{
|
||||
printf 'git_hash=%s\n' "$local_git_hash"
|
||||
printf 'git_date=%s\n' "$local_git_date"
|
||||
printf 'git_tag=%s\n' "$local_git_tag"
|
||||
printf 'git_dirty=%s\n' "$local_git_dirty"
|
||||
printf 'changelog_raw<<JCODE_CHANGELOG_EOF\n%s\nJCODE_CHANGELOG_EOF\n' "$local_changelog_raw"
|
||||
} > "$metadata_file"
|
||||
"$RSYNC_BIN" -avz -e "$RSYNC_SSH_COMMAND" "$metadata_file" "$REMOTE:$REMOTE_DIR/.jcode-build-meta"
|
||||
else
|
||||
echo ""
|
||||
echo "[1/3] Skipping source sync (--no-sync)"
|
||||
fi
|
||||
|
||||
printf -v REMOTE_CARGO_CMD '%q ' "${CARGO_CMD[@]}"
|
||||
printf -v REMOTE_INNER_CMD 'cd %q && env JCODE_BUILD_METADATA_FILE=.jcode-build-meta %s' "$REMOTE_DIR" "$REMOTE_CARGO_CMD"
|
||||
printf -v REMOTE_RUN_CMD 'sh -lc %q' "$REMOTE_INNER_CMD"
|
||||
echo ""
|
||||
echo "[2/3] Running on remote..."
|
||||
remote_ssh "$REMOTE_RUN_CMD 2>&1"
|
||||
|
||||
echo ""
|
||||
if [[ "$sync_back" -eq 1 ]]; then
|
||||
printf -v REMOTE_TEST_CMD 'test -f %q' "$REMOTE_DIR/$BINARY_PATH"
|
||||
if remote_ssh "$REMOTE_TEST_CMD"; then
|
||||
echo "[3/3] Syncing built artifact back..."
|
||||
mkdir -p "$(dirname "$LOCAL_DIR/$BINARY_PATH")"
|
||||
"$RSYNC_BIN" -avz -e "$RSYNC_SSH_COMMAND" "$REMOTE:$REMOTE_DIR/$BINARY_PATH" "$LOCAL_DIR/$BINARY_PATH"
|
||||
echo ""
|
||||
echo "=== Remote cargo complete ==="
|
||||
ls -la "$LOCAL_DIR/$BINARY_PATH"
|
||||
else
|
||||
echo "[3/3] Skipping sync-back: $BINARY_PATH not found on remote"
|
||||
fi
|
||||
else
|
||||
echo "[3/3] Skipping binary sync-back"
|
||||
fi
|
||||
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env bash
|
||||
# Shared loader for Jcode remote build defaults.
|
||||
#
|
||||
# The config file is intentionally a shell fragment so users can write either:
|
||||
# JCODE_REMOTE_HOST=builder
|
||||
# or:
|
||||
# export JCODE_REMOTE_HOST=builder
|
||||
#
|
||||
# Explicit environment variables take precedence over values loaded from the
|
||||
# config file. This lets callers temporarily disable remote builds with, for
|
||||
# example, `JCODE_REMOTE_CARGO=0 scripts/dev_cargo.sh check`.
|
||||
|
||||
jcode_remote_config_path() {
|
||||
if [[ -n "${JCODE_REMOTE_CONFIG:-}" ]]; then
|
||||
printf '%s\n' "$JCODE_REMOTE_CONFIG"
|
||||
elif [[ -n "${XDG_CONFIG_HOME:-}" ]]; then
|
||||
printf '%s\n' "$XDG_CONFIG_HOME/jcode/remote-build.env"
|
||||
elif [[ -n "${HOME:-}" ]]; then
|
||||
printf '%s\n' "$HOME/.config/jcode/remote-build.env"
|
||||
fi
|
||||
}
|
||||
|
||||
jcode_load_remote_config() {
|
||||
local config_file
|
||||
config_file="$(jcode_remote_config_path)"
|
||||
[[ -n "$config_file" && -f "$config_file" ]] || return 0
|
||||
|
||||
local had_remote_cargo=0 remote_cargo=""
|
||||
local had_remote_host=0 remote_host=""
|
||||
local had_remote_dir=0 remote_dir=""
|
||||
local had_remote_ssh_bin=0 remote_ssh_bin=""
|
||||
local had_remote_rsync_bin=0 remote_rsync_bin=""
|
||||
|
||||
if [[ ${JCODE_REMOTE_CARGO+x} ]]; then
|
||||
had_remote_cargo=1
|
||||
remote_cargo="$JCODE_REMOTE_CARGO"
|
||||
fi
|
||||
if [[ ${JCODE_REMOTE_HOST+x} ]]; then
|
||||
had_remote_host=1
|
||||
remote_host="$JCODE_REMOTE_HOST"
|
||||
fi
|
||||
if [[ ${JCODE_REMOTE_DIR+x} ]]; then
|
||||
had_remote_dir=1
|
||||
remote_dir="$JCODE_REMOTE_DIR"
|
||||
fi
|
||||
if [[ ${JCODE_REMOTE_SSH_BIN+x} ]]; then
|
||||
had_remote_ssh_bin=1
|
||||
remote_ssh_bin="$JCODE_REMOTE_SSH_BIN"
|
||||
fi
|
||||
if [[ ${JCODE_REMOTE_RSYNC_BIN+x} ]]; then
|
||||
had_remote_rsync_bin=1
|
||||
remote_rsync_bin="$JCODE_REMOTE_RSYNC_BIN"
|
||||
fi
|
||||
|
||||
# shellcheck source=/dev/null
|
||||
source "$config_file"
|
||||
|
||||
if [[ "$had_remote_cargo" -eq 1 ]]; then
|
||||
JCODE_REMOTE_CARGO="$remote_cargo"
|
||||
fi
|
||||
if [[ "$had_remote_host" -eq 1 ]]; then
|
||||
JCODE_REMOTE_HOST="$remote_host"
|
||||
fi
|
||||
if [[ "$had_remote_dir" -eq 1 ]]; then
|
||||
JCODE_REMOTE_DIR="$remote_dir"
|
||||
fi
|
||||
if [[ "$had_remote_ssh_bin" -eq 1 ]]; then
|
||||
JCODE_REMOTE_SSH_BIN="$remote_ssh_bin"
|
||||
fi
|
||||
if [[ "$had_remote_rsync_bin" -eq 1 ]]; then
|
||||
JCODE_REMOTE_RSYNC_BIN="$remote_rsync_bin"
|
||||
fi
|
||||
}
|
||||
Executable
+178
@@ -0,0 +1,178 @@
|
||||
#!/bin/bash
|
||||
# Replay a jcode recording as video
|
||||
#
|
||||
# This script:
|
||||
# 1. Starts a fresh jcode instance in a new terminal
|
||||
# 2. Records the screen with wf-recorder
|
||||
# 3. Replays the recorded keystrokes with proper timing
|
||||
# 4. Outputs a video file
|
||||
#
|
||||
# Usage: ./replay_recording.sh <recording.json> [output.mp4]
|
||||
|
||||
set -e
|
||||
|
||||
RECORDING_FILE="${1:?Usage: $0 <recording.json> [output.mp4]}"
|
||||
OUTPUT_FILE="${2:-${RECORDING_FILE%.json}.mp4}"
|
||||
|
||||
SCRIPT_DIR="$(dirname "$0")"
|
||||
|
||||
if [ ! -f "$RECORDING_FILE" ]; then
|
||||
echo "Error: Recording file not found: $RECORDING_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "🎬 jcode Recording Replay"
|
||||
echo " Input: $RECORDING_FILE"
|
||||
echo " Output: $OUTPUT_FILE"
|
||||
echo ""
|
||||
|
||||
# Parse the recording and generate wtype commands
|
||||
generate_wtype_script() {
|
||||
python3 << 'PYTHON' "$RECORDING_FILE"
|
||||
import json
|
||||
import sys
|
||||
|
||||
with open(sys.argv[1]) as f:
|
||||
events = json.load(f)
|
||||
|
||||
prev_time = 0
|
||||
for event in events:
|
||||
offset = event.get('offset_ms', 0)
|
||||
delay = offset - prev_time
|
||||
prev_time = offset
|
||||
|
||||
if delay > 0:
|
||||
# Sleep for the delay (in seconds)
|
||||
print(f"sleep {delay / 1000:.3f}")
|
||||
|
||||
evt = event.get('event', {})
|
||||
evt_type = evt.get('type')
|
||||
|
||||
if evt_type == 'Key':
|
||||
data = evt.get('data', {})
|
||||
code = data.get('code', '')
|
||||
mods = data.get('modifiers', [])
|
||||
|
||||
# Convert code to wtype format
|
||||
key = None
|
||||
if code.startswith('Char('):
|
||||
# Extract character: Char('a') -> a
|
||||
char = code[6:-2] if len(code) > 7 else code[6:-1]
|
||||
key = char
|
||||
elif code == 'Enter':
|
||||
key = 'Return'
|
||||
elif code == 'Backspace':
|
||||
key = 'BackSpace'
|
||||
elif code == 'Tab':
|
||||
key = 'Tab'
|
||||
elif code == 'Esc':
|
||||
key = 'Escape'
|
||||
elif code == 'Up':
|
||||
key = 'Up'
|
||||
elif code == 'Down':
|
||||
key = 'Down'
|
||||
elif code == 'Left':
|
||||
key = 'Left'
|
||||
elif code == 'Right':
|
||||
key = 'Right'
|
||||
elif code == 'Home':
|
||||
key = 'Home'
|
||||
elif code == 'End':
|
||||
key = 'End'
|
||||
elif code == 'PageUp':
|
||||
key = 'Page_Up'
|
||||
elif code == 'PageDown':
|
||||
key = 'Page_Down'
|
||||
elif code == 'Delete':
|
||||
key = 'Delete'
|
||||
elif code == 'Insert':
|
||||
key = 'Insert'
|
||||
elif code.startswith('F') and code[1:].isdigit():
|
||||
key = code # F1, F2, etc.
|
||||
else:
|
||||
# Unknown key, skip
|
||||
continue
|
||||
|
||||
if key:
|
||||
cmd = 'wtype'
|
||||
for mod in mods:
|
||||
if mod == 'ctrl':
|
||||
cmd += ' -M ctrl'
|
||||
elif mod == 'alt':
|
||||
cmd += ' -M alt'
|
||||
elif mod == 'shift':
|
||||
cmd += ' -M shift'
|
||||
|
||||
if len(key) == 1 and key.isalnum():
|
||||
cmd += f' "{key}"'
|
||||
else:
|
||||
cmd += f' -k {key}'
|
||||
|
||||
# Release modifiers
|
||||
for mod in reversed(mods):
|
||||
if mod == 'ctrl':
|
||||
cmd += ' -m ctrl'
|
||||
elif mod == 'alt':
|
||||
cmd += ' -m alt'
|
||||
elif mod == 'shift':
|
||||
cmd += ' -m shift'
|
||||
|
||||
print(cmd)
|
||||
PYTHON
|
||||
}
|
||||
|
||||
# Get the screen geometry for recording
|
||||
GEOMETRY=$(niri msg focused-output 2>/dev/null | grep -oP 'Mode: \K\d+x\d+' | head -1 || echo "1920x1080")
|
||||
|
||||
echo "📹 Starting screen recording..."
|
||||
echo " Geometry: $GEOMETRY"
|
||||
echo ""
|
||||
|
||||
# Start wf-recorder in background
|
||||
wf-recorder -g "0,0 $GEOMETRY" -f "$OUTPUT_FILE" &
|
||||
RECORDER_PID=$!
|
||||
sleep 1 # Let recorder initialize
|
||||
|
||||
# Start jcode in a new kitty window
|
||||
echo "🚀 Starting jcode..."
|
||||
kitty --title "jcode-replay" -e bash -c "cd $(pwd) && ~/.cargo/bin/jcode; read -p 'Press Enter to close...'" &
|
||||
KITTY_PID=$!
|
||||
sleep 2 # Wait for jcode to start
|
||||
|
||||
# Focus the new window
|
||||
sleep 0.5
|
||||
# Find and focus the jcode-replay window
|
||||
WINDOW_ID=$(niri msg windows 2>/dev/null | grep -B5 "jcode-replay" | grep -oP 'Window ID \K\d+' | head -1)
|
||||
if [ -n "$WINDOW_ID" ]; then
|
||||
echo " Focusing window $WINDOW_ID"
|
||||
niri msg action focus-window --id "$WINDOW_ID"
|
||||
sleep 0.3
|
||||
fi
|
||||
|
||||
echo "⌨️ Replaying keystrokes..."
|
||||
echo ""
|
||||
|
||||
# Generate and execute wtype script
|
||||
generate_wtype_script | while read -r cmd; do
|
||||
if [[ "$cmd" == sleep* ]]; then
|
||||
eval "$cmd"
|
||||
else
|
||||
eval "$cmd" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "⏹️ Stopping recording..."
|
||||
sleep 1 # Final pause
|
||||
|
||||
# Stop recorder
|
||||
kill $RECORDER_PID 2>/dev/null || true
|
||||
wait $RECORDER_PID 2>/dev/null || true
|
||||
|
||||
# Clean up kitty window
|
||||
kill $KITTY_PID 2>/dev/null || true
|
||||
|
||||
echo ""
|
||||
echo "✅ Done!"
|
||||
echo " Video saved to: $OUTPUT_FILE"
|
||||
ls -lh "$OUTPUT_FILE"
|
||||
@@ -0,0 +1,2 @@
|
||||
/target
|
||||
Cargo.lock
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "tls-bad-record-mac-repro"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
# Standalone: do not attach to the parent jcode workspace.
|
||||
[workspace]
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tokio-rustls = "0.26"
|
||||
rustls = "0.23"
|
||||
rcgen = "0.13"
|
||||
reqwest = { version = "=0.12.28", default-features = false, features = ["json","stream","blocking","charset","http2","system-proxy","rustls-tls","rustls-tls-native-roots"] }
|
||||
|
||||
[[bin]]
|
||||
name = "tls-bad-record-mac-repro"
|
||||
path = "src/main.rs"
|
||||
@@ -0,0 +1,56 @@
|
||||
# TLS `BadRecordMac` transport-retry reproduction
|
||||
|
||||
Standalone reproduction for the bug fixed in commit
|
||||
`fix(providers): retry on TLS transport faults (BadRecordMac) across all providers`.
|
||||
|
||||
## What it reproduces
|
||||
|
||||
Users on flaky networks / corrupting middleboxes / some VPNs hit:
|
||||
|
||||
```
|
||||
Stream error: IO error: received fatal alert: BadRecordMac
|
||||
```
|
||||
|
||||
A `BadRecordMac` is a TLS record-authentication failure. It is transient: a
|
||||
fresh connection on retry almost always succeeds. The bug was that jcode failed
|
||||
these immediately instead of retrying, for two reasons:
|
||||
|
||||
1. **OpenAI** maintained its own `is_retryable_error` allowlist that did not
|
||||
call the shared `is_transient_transport_error` and omitted every TLS term, so
|
||||
a `BadRecordMac` on the websocket path surfaced at `attempt=1`
|
||||
(`will_retry=false`).
|
||||
2. **claude / copilot / openrouter / openai** classified on `e.to_string()`.
|
||||
When a transport cause is wrapped behind `anyhow`'s `.context(...)`,
|
||||
`to_string()` only returns the top-level context (e.g. `Failed to send
|
||||
request to ... API`) and **masks** the underlying `BadRecordMac`, so the
|
||||
classifier never sees it. (The anthropic provider already used
|
||||
`format!("{e:#}")` and was unaffected.)
|
||||
|
||||
## How it works
|
||||
|
||||
```
|
||||
reqwest client (rustls) -> corrupting TCP proxy -> real TLS server (rustls)
|
||||
```
|
||||
|
||||
The proxy flips one byte inside the first large client->server TLS
|
||||
`application_data` record. The server's AEAD check then fails and it emits a TLS
|
||||
`bad_record_mac` fatal alert, which the client surfaces exactly as users see it.
|
||||
|
||||
The harness then:
|
||||
|
||||
- wraps the error like the providers do (`.context(...)`),
|
||||
- runs the **verbatim** shipping `is_transient_transport_error`,
|
||||
- runs the **old** vs **new** OpenAI `is_retryable_error`,
|
||||
|
||||
and asserts old logic misses it while the fix retries.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
cd scripts/repro/tls-bad-record-mac
|
||||
cargo run
|
||||
# exit 0 + "SUCCESS: realistic BadRecordMac reproduced and both fixes validated."
|
||||
```
|
||||
|
||||
It is intentionally a standalone crate (own `[workspace]` table) so its
|
||||
TLS/cert dev-dependencies do not touch the main build.
|
||||
@@ -0,0 +1,324 @@
|
||||
// Realistic reproduction of David's `received fatal alert: BadRecordMac`.
|
||||
//
|
||||
// Architecture (mimics a corrupting middlebox / flaky VPN):
|
||||
//
|
||||
// reqwest client (rustls) -> corrupting TCP proxy -> real TLS server (rustls)
|
||||
//
|
||||
// The proxy flips one byte inside the first large client->server TLS
|
||||
// application_data record (the HTTP request body). The server's AEAD
|
||||
// authentication then fails, so it returns a TLS `bad_record_mac` fatal alert,
|
||||
// which the client surfaces as: "received fatal alert: BadRecordMac".
|
||||
//
|
||||
// We then wrap that error exactly like jcode's anthropic provider does
|
||||
// (`.context("Failed to send request to Anthropic API")`) and show how the
|
||||
// cause is masked from `to_string()` but visible via the error source chain.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Context;
|
||||
use rustls::pki_types::{CertificateDer, PrivateKeyDer};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tokio_rustls::TlsAcceptor;
|
||||
|
||||
fn classify(error_str: &str) -> bool {
|
||||
// VERBATIM copy of jcode's shared is_transient_transport_error
|
||||
// (crates/jcode-base/src/provider/routing.rs @ v0.24.0).
|
||||
let lower = error_str.to_ascii_lowercase();
|
||||
lower.contains("connection reset")
|
||||
|| lower.contains("connection closed")
|
||||
|| lower.contains("connection refused")
|
||||
|| lower.contains("connection aborted")
|
||||
|| lower.contains("broken pipe")
|
||||
|| lower.contains("timed out")
|
||||
|| lower.contains("timeout")
|
||||
|| lower.contains("operation timed out")
|
||||
|| lower.contains("error decoding")
|
||||
|| lower.contains("error reading")
|
||||
|| lower.contains("unexpected eof")
|
||||
|| lower.contains("tls handshake eof")
|
||||
|| lower.contains("badrecordmac")
|
||||
|| lower.contains("bad_record_mac")
|
||||
|| lower.contains("fatal alert: badrecordmac")
|
||||
|| lower.contains("fatal alert: bad_record_mac")
|
||||
|| lower.contains("received fatal alert: badrecordmac")
|
||||
|| lower.contains("received fatal alert: bad_record_mac")
|
||||
|| lower.contains("decryption failed or bad record mac")
|
||||
|| lower.contains("temporary failure in name resolution")
|
||||
|| lower.contains("failed to lookup address information")
|
||||
|| lower.contains("dns error")
|
||||
|| lower.contains("name or service not known")
|
||||
|| lower.contains("no route to host")
|
||||
|| lower.contains("network is unreachable")
|
||||
|| lower.contains("host is unreachable")
|
||||
|| lower.contains("http2 error")
|
||||
|| lower.contains("stream error")
|
||||
|| lower.contains("protocol error")
|
||||
|| lower.contains("refused_stream")
|
||||
|| lower.contains("refused stream")
|
||||
|| lower.contains("enhance_your_calm")
|
||||
|| lower.contains("goaway")
|
||||
|| lower.contains("go away")
|
||||
|| lower.contains("sendrequest")
|
||||
}
|
||||
|
||||
/// OLD OpenAI is_retryable_error (pre-fix): standalone list, NO TLS terms,
|
||||
/// does NOT call the shared classifier. This is what shipped in v0.24.0 and
|
||||
/// caused David's BadRecordMac to fail immediately.
|
||||
fn openai_classify_old(error_str: &str) -> bool {
|
||||
error_str.contains("connection reset")
|
||||
|| error_str.contains("connection closed")
|
||||
|| error_str.contains("connection refused")
|
||||
|| error_str.contains("broken pipe")
|
||||
|| error_str.contains("timed out")
|
||||
|| error_str.contains("timeout")
|
||||
|| error_str.contains("failed to send request to openai api")
|
||||
|| error_str.contains("error decoding")
|
||||
|| error_str.contains("error reading")
|
||||
|| error_str.contains("unexpected eof")
|
||||
|| error_str.contains("incomplete message")
|
||||
|| error_str.contains("stream disconnected before completion")
|
||||
|| error_str.contains("ended before message completion marker")
|
||||
|| error_str.contains("falling back from websockets to https transport")
|
||||
|| error_str.contains("500 internal server error")
|
||||
|| error_str.contains("502 bad gateway")
|
||||
|| error_str.contains("503 service unavailable")
|
||||
|| error_str.contains("504 gateway timeout")
|
||||
|| error_str.contains("overloaded")
|
||||
|| error_str.contains("api_error")
|
||||
|| error_str.contains("server_error")
|
||||
|| error_str.contains("internal server error")
|
||||
|| error_str.contains("an error occurred while processing your request")
|
||||
|| error_str.contains("please include the request id")
|
||||
}
|
||||
|
||||
/// NEW OpenAI is_retryable_error (post-fix): delegates to the shared classifier.
|
||||
fn openai_classify_new(error_str: &str) -> bool {
|
||||
classify(error_str) || openai_classify_old(error_str)
|
||||
}
|
||||
|
||||
/// Walk the full anyhow error source chain and classify on the joined text.
|
||||
fn classify_chain(err: &anyhow::Error) -> bool {
|
||||
// 1) alternate Display includes the cause chain
|
||||
if classify(&format!("{err:#}")) {
|
||||
return true;
|
||||
}
|
||||
// 2) explicit chain walk (most robust)
|
||||
for cause in err.chain() {
|
||||
if classify(&cause.to_string()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
|
||||
// ---- 1. Self-signed cert for 127.0.0.1 ----
|
||||
let cert = rcgen::generate_simple_self_signed(vec!["127.0.0.1".to_string()])?;
|
||||
let cert_der = CertificateDer::from(cert.cert.der().to_vec());
|
||||
let key_der = PrivateKeyDer::try_from(cert.key_pair.serialize_der())
|
||||
.map_err(|e| anyhow::anyhow!("key: {e}"))?;
|
||||
|
||||
let server_config = rustls::ServerConfig::builder()
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(vec![cert_der.clone()], key_der)?;
|
||||
let acceptor = TlsAcceptor::from(Arc::new(server_config));
|
||||
|
||||
// ---- 2. Real TLS server ----
|
||||
let server_listener = TcpListener::bind("127.0.0.1:0").await?;
|
||||
let server_addr = server_listener.local_addr()?;
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let Ok((tcp, _)) = server_listener.accept().await else {
|
||||
continue;
|
||||
};
|
||||
let acceptor = acceptor.clone();
|
||||
tokio::spawn(async move {
|
||||
match acceptor.accept(tcp).await {
|
||||
Ok(mut tls) => {
|
||||
// Try to read the request; AEAD failure shows up here and
|
||||
// rustls automatically emits a bad_record_mac fatal alert.
|
||||
let mut buf = [0u8; 4096];
|
||||
let _ = tls.read(&mut buf).await;
|
||||
let _ = tls
|
||||
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
|
||||
.await;
|
||||
let _ = tls.shutdown().await;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[server] handshake/accept error: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ---- 3. Corrupting proxy ----
|
||||
let proxy_listener = TcpListener::bind("127.0.0.1:0").await?;
|
||||
let proxy_addr = proxy_listener.local_addr()?;
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let Ok((client, _)) = proxy_listener.accept().await else {
|
||||
continue;
|
||||
};
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = corrupt_proxy(client, server_addr).await {
|
||||
eprintln!("[proxy] {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ---- 4. Client (mirrors jcode's reqwest+rustls config) ----
|
||||
let mut roots = rustls::RootCertStore::empty();
|
||||
roots.add(cert_der)?;
|
||||
let client = reqwest::Client::builder()
|
||||
.use_preconfigured_tls(
|
||||
rustls::ClientConfig::builder()
|
||||
.with_root_certificates(roots)
|
||||
.with_no_client_auth(),
|
||||
)
|
||||
.build()?;
|
||||
|
||||
let url = format!("https://127.0.0.1:{}/v1/messages", proxy_addr.port());
|
||||
let body = serde_json_body();
|
||||
let result = client
|
||||
.post(&url)
|
||||
.header("content-type", "application/json")
|
||||
.header("x-api-key", "sk-test-1234567890")
|
||||
.body(body)
|
||||
.send()
|
||||
.await;
|
||||
|
||||
let raw = match &result {
|
||||
Ok(_) => "<no error: request unexpectedly succeeded>".to_string(),
|
||||
Err(e) => e.to_string(),
|
||||
};
|
||||
println!("=== Reproduction result ===");
|
||||
println!("raw reqwest to_string : {raw:?}");
|
||||
|
||||
// Wrap exactly like the anthropic provider does.
|
||||
let wrapped: anyhow::Result<()> = result
|
||||
.map(|_| ())
|
||||
.context("Failed to send request to Anthropic API");
|
||||
if let Err(err) = wrapped {
|
||||
let masked = err.to_string();
|
||||
let alt = format!("{err:#}");
|
||||
println!("provider to_string() : {masked:?}");
|
||||
println!("provider {{:#}} (chain) : {alt:?}");
|
||||
println!();
|
||||
|
||||
let reproduced = alt.to_lowercase().contains("badrecordmac")
|
||||
|| raw.to_lowercase().contains("badrecordmac");
|
||||
println!("REPRODUCED BadRecordMac: {reproduced}");
|
||||
println!();
|
||||
|
||||
// Anthropic-style WS/HTTP error where the cause is INLINE in the message
|
||||
// (this is what David actually saw from the OpenAI websocket path).
|
||||
let openai_inline = "stream error: io error: received fatal alert: badrecordmac";
|
||||
|
||||
println!("=== OpenAI websocket path (inline error, as David saw) ===");
|
||||
println!(" error string: {openai_inline:?}");
|
||||
println!(" OLD is_retryable = {} (immediate fail, will_retry=false)",
|
||||
openai_classify_old(openai_inline));
|
||||
println!(" NEW is_retryable = {} (retried)", openai_classify_new(openai_inline));
|
||||
println!();
|
||||
|
||||
println!("=== Send path with anyhow .context() masking ===");
|
||||
println!(" to_string (masked) -> shared classify = {}", classify(&masked));
|
||||
println!(" {{:#}} chain -> shared classify = {}", classify(&alt));
|
||||
println!(" chain-aware walk -> classify = {}", classify_chain(&err));
|
||||
println!();
|
||||
|
||||
let bug_confirmed = !openai_classify_old(openai_inline)
|
||||
&& openai_classify_new(openai_inline)
|
||||
&& !classify(&masked)
|
||||
&& classify_chain(&err);
|
||||
println!("BUG CONFIRMED (old misses, fix catches; masking confirmed): {bug_confirmed}");
|
||||
if reproduced && bug_confirmed {
|
||||
println!("\nSUCCESS: realistic BadRecordMac reproduced and both fixes validated.");
|
||||
std::process::exit(0);
|
||||
} else {
|
||||
println!("\nINCOMPLETE: reproduced={reproduced} bug_confirmed={bug_confirmed}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn serde_json_body() -> Vec<u8> {
|
||||
// Large enough that the HTTP request becomes a sizable application_data record.
|
||||
let payload = format!(
|
||||
"{{\"model\":\"claude-opus-4\",\"messages\":[{{\"role\":\"user\",\"content\":\"{}\"}}]}}",
|
||||
"x".repeat(800)
|
||||
);
|
||||
payload.into_bytes()
|
||||
}
|
||||
|
||||
/// TCP proxy that forwards both directions but flips a byte inside the first
|
||||
/// large client->server TLS application_data record, corrupting the ciphertext.
|
||||
async fn corrupt_proxy(client: TcpStream, server_addr: std::net::SocketAddr) -> anyhow::Result<()> {
|
||||
let server = TcpStream::connect(server_addr).await?;
|
||||
let (mut cr, mut cw) = client.into_split();
|
||||
let (mut sr, mut sw) = server.into_split();
|
||||
|
||||
// server -> client: pass through untouched
|
||||
let s2c = tokio::spawn(async move {
|
||||
let mut buf = [0u8; 8192];
|
||||
loop {
|
||||
match sr.read(&mut buf).await {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => {
|
||||
if cw.write_all(&buf[..n]).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// client -> server: parse TLS records, corrupt first big application_data
|
||||
let c2s = tokio::spawn(async move {
|
||||
let mut acc: Vec<u8> = Vec::new();
|
||||
let mut buf = [0u8; 8192];
|
||||
let mut corrupted = false;
|
||||
loop {
|
||||
match cr.read(&mut buf).await {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => {
|
||||
acc.extend_from_slice(&buf[..n]);
|
||||
// Emit complete TLS records, corrupting as configured.
|
||||
while acc.len() >= 5 {
|
||||
let content_type = acc[0];
|
||||
let len = ((acc[3] as usize) << 8) | (acc[4] as usize);
|
||||
let total = 5 + len;
|
||||
if acc.len() < total {
|
||||
break;
|
||||
}
|
||||
let mut rec: Vec<u8> = acc.drain(..total).collect();
|
||||
// application_data == 23; corrupt the first big one (the
|
||||
// HTTP request), flipping a ciphertext byte so the server
|
||||
// AEAD check fails -> bad_record_mac alert.
|
||||
if !corrupted && content_type == 23 && len > 80 {
|
||||
let mid = 5 + len / 2;
|
||||
rec[mid] ^= 0xff;
|
||||
corrupted = true;
|
||||
eprintln!("[proxy] corrupted application_data record ({len} bytes)");
|
||||
}
|
||||
if sw.write_all(&rec).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = sw.write_all(&acc).await;
|
||||
});
|
||||
|
||||
let _ = tokio::join!(s2c, c2s);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
End-to-end verifier for the Alt+Shift+E "expand edit diff" shortcut.
|
||||
|
||||
Why this exists
|
||||
---------------
|
||||
The handler logic for Alt+Shift+E is covered by unit tests, but those tests
|
||||
inject *synthetic* `crossterm::KeyEvent`s. They never exercise:
|
||||
|
||||
* crossterm's real terminal escape-sequence decoder,
|
||||
* the live event loop (local + remote client),
|
||||
* the kitty keyboard protocol bytes a real terminal actually sends.
|
||||
|
||||
So a unit test can be green while the live shortcut does nothing. This harness
|
||||
closes that gap by driving the **real jcode binary** under a pseudo-terminal and
|
||||
feeding it the **exact bytes** a terminal emits for Alt+Shift+E, then reading the
|
||||
resulting diff state back over the debug socket.
|
||||
|
||||
What "pass" means (100% confidence)
|
||||
-----------------------------------
|
||||
The harness runs three checks against one live client:
|
||||
|
||||
1. NEGATIVE CONTROL
|
||||
Confirm the fixture starts collapsed (diff_mode == Inline), so a later
|
||||
"FullInline" reading cannot be a false positive from a pre-expanded state.
|
||||
|
||||
2. RAW-BYTES E2E (the real test)
|
||||
Write the literal Alt+Shift+E byte sequence (kitty CSI-u form, plus the
|
||||
legacy ESC-prefixed form) into the PTY master. crossterm decodes it, the
|
||||
live loop dispatches it, and we assert diff_mode flips to FullInline.
|
||||
|
||||
3. POSITIVE CONTROL (decode-independent)
|
||||
Reset the fixture, then drive the *same* handler through the debug socket's
|
||||
synthetic `keys:alt+shift+e`. If raw bytes fail but synthetic keys succeed,
|
||||
the break is in terminal decode / byte delivery, not the handler.
|
||||
|
||||
Each byte encoding is tried against a freshly reset fixture so we learn exactly
|
||||
which terminal encoding(s) work end to end.
|
||||
|
||||
Everything runs in a throwaway JCODE_HOME / runtime dir / socket. The user's
|
||||
real server and sessions are never touched.
|
||||
|
||||
Usage
|
||||
-----
|
||||
python3 scripts/repro_expand_edit_shortcut.py [--binary PATH] [--keep] [-v]
|
||||
|
||||
Exit codes:
|
||||
0 = raw-bytes Alt+Shift+E expands the diff end to end (shortcut works)
|
||||
2 = raw bytes do NOT expand, but synthetic keys do (decode/delivery break)
|
||||
3 = even synthetic keys fail (handler/fixture break) or setup failure
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import pty
|
||||
import select
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# The exact bytes a terminal sends for Alt+Shift+E.
|
||||
#
|
||||
# Captured live from kitty 0.46 with the kitty keyboard protocol enabled (the
|
||||
# protocol jcode turns on at startup):
|
||||
# press = ESC [ 101 ; 4 u -> b"\x1b[101;4u"
|
||||
# release = ESC [ 101 ; 4 : 3 u -> b"\x1b[101;4:3u"
|
||||
# (101 = 'e' codepoint, modifier mask 4 = (4-1)=3 = SHIFT|ALT.)
|
||||
#
|
||||
# We also include the legacy xterm encodings some terminals use when the kitty
|
||||
# protocol is not negotiated, so the harness reports which encodings work.
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
KEY_ENCODINGS: list[tuple[str, bytes]] = [
|
||||
# kitty keyboard protocol (what this machine's kitty actually sends)
|
||||
("kitty-csi-u (e;SHIFT|ALT)", b"\x1b[101;4u\x1b[101;4:3u"),
|
||||
# kitty form some terminals send with the uppercase codepoint
|
||||
("kitty-csi-u (E;SHIFT|ALT)", b"\x1b[69;4u\x1b[69;4:3u"),
|
||||
# legacy: ESC then Shift+E (Alt=ESC prefix, Shift folded into uppercase)
|
||||
("legacy esc+E", b"\x1bE"),
|
||||
# legacy: ESC then lowercase e (some terminals drop the shift bit)
|
||||
("legacy esc+e", b"\x1be"),
|
||||
]
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# debug socket helpers (line-delimited JSON over a unix socket)
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
def _recv_debug_response(sock: socket.socket, timeout: float) -> dict:
|
||||
sock.settimeout(timeout)
|
||||
buf = b""
|
||||
while True:
|
||||
chunk = sock.recv(65536)
|
||||
if not chunk:
|
||||
break
|
||||
buf += chunk
|
||||
while b"\n" in buf:
|
||||
line, buf = buf.split(b"\n", 1)
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
resp = json.loads(line.decode())
|
||||
if resp.get("type") in ("ack", "pong"):
|
||||
continue
|
||||
return resp
|
||||
raise RuntimeError("debug socket closed without a response")
|
||||
|
||||
|
||||
def dbg(debug_sock: Path, command: str, session_id: str | None = None,
|
||||
timeout: float = 30.0) -> dict:
|
||||
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
s.connect(str(debug_sock))
|
||||
try:
|
||||
req = {"type": "debug_command", "id": 1, "command": command}
|
||||
if session_id:
|
||||
req["session_id"] = session_id
|
||||
s.sendall((json.dumps(req) + "\n").encode())
|
||||
return _recv_debug_response(s, timeout)
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def dbg_output(debug_sock: Path, command: str, session_id: str | None = None,
|
||||
timeout: float = 30.0) -> str:
|
||||
resp = dbg(debug_sock, command, session_id=session_id, timeout=timeout)
|
||||
if resp.get("type") == "error":
|
||||
raise RuntimeError(f"debug error for {command!r}: {resp.get('message')}")
|
||||
if resp.get("ok") is False:
|
||||
raise RuntimeError(f"command {command!r} failed: {resp.get('output')}")
|
||||
return resp.get("output", "")
|
||||
|
||||
|
||||
def wait_for_socket(path: Path, timeout_s: float = 25.0) -> None:
|
||||
deadline = time.time() + timeout_s
|
||||
while time.time() < deadline:
|
||||
if path.exists():
|
||||
try:
|
||||
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
s.settimeout(0.2)
|
||||
s.connect(str(path))
|
||||
s.close()
|
||||
return
|
||||
except OSError:
|
||||
pass
|
||||
time.sleep(0.02)
|
||||
raise RuntimeError(f"socket not ready: {path}")
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# A PTY-driven jcode client. This is the real live-render input path; bytes
|
||||
# written to master_fd are decoded by crossterm exactly as a terminal would.
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
_TERM_REPLIES = [
|
||||
(b"\x1b[6n", b"\x1b[1;1R"),
|
||||
(b"\x1b[c", b"\x1b[?62;c"),
|
||||
(b"\x1b]10;?\x1b\\", b"\x1b]10;rgb:ffff/ffff/ffff\x1b\\"),
|
||||
(b"\x1b]11;?\x1b\\", b"\x1b]11;rgb:0000/0000/0000\x1b\\"),
|
||||
(b"\x1b]10;?\x07", b"\x1b]10;rgb:ffff/ffff/ffff\x07"),
|
||||
(b"\x1b]11;?\x07", b"\x1b]11;rgb:0000/0000/0000\x07"),
|
||||
(b"\x1b[14t", b"\x1b[4;600;800t"),
|
||||
(b"\x1b[16t", b"\x1b[6;16;8t"),
|
||||
(b"\x1b[18t", b"\x1b[8;24;80t"),
|
||||
(b"\x1b[?1016$p", b"\x1b[?1016;1$y"),
|
||||
(b"\x1b[?2027$p", b"\x1b[?2027;1$y"),
|
||||
(b"\x1b[?2031$p", b"\x1b[?2031;1$y"),
|
||||
(b"\x1b[?1004$p", b"\x1b[?1004;1$y"),
|
||||
(b"\x1b[?2004$p", b"\x1b[?2004;1$y"),
|
||||
(b"\x1b[?2026$p", b"\x1b[?2026;1$y"),
|
||||
# kitty keyboard protocol query: report DISAMBIGUATE|REPORT_EVENT_TYPES active
|
||||
(b"\x1b[?u", b"\x1b[?3u"),
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class LiveClient:
|
||||
name: str
|
||||
proc: subprocess.Popen
|
||||
master_fd: int
|
||||
stop: threading.Event = field(default_factory=threading.Event)
|
||||
thread: threading.Thread | None = None
|
||||
total_bytes: int = 0
|
||||
|
||||
def _pump(self) -> None:
|
||||
buffer = b""
|
||||
while not self.stop.is_set():
|
||||
try:
|
||||
rlist, _, _ = select.select([self.master_fd], [], [], 0.1)
|
||||
except (OSError, ValueError):
|
||||
break
|
||||
if not rlist:
|
||||
if self.proc.poll() is not None:
|
||||
break
|
||||
continue
|
||||
try:
|
||||
chunk = os.read(self.master_fd, 65536)
|
||||
except (BlockingIOError, OSError):
|
||||
continue
|
||||
if not chunk:
|
||||
break
|
||||
self.total_bytes += len(chunk)
|
||||
buffer = (buffer + chunk)[-8192:]
|
||||
changed = True
|
||||
while changed:
|
||||
changed = False
|
||||
for query, response in _TERM_REPLIES:
|
||||
if query in buffer:
|
||||
try:
|
||||
os.write(self.master_fd, response)
|
||||
except OSError:
|
||||
pass
|
||||
buffer = buffer.replace(query, b"")
|
||||
changed = True
|
||||
|
||||
def start_pump(self) -> None:
|
||||
self.thread = threading.Thread(target=self._pump, daemon=True)
|
||||
self.thread.start()
|
||||
|
||||
def alive(self) -> bool:
|
||||
return self.proc.poll() is None
|
||||
|
||||
def send_bytes(self, data: bytes) -> None:
|
||||
os.write(self.master_fd, data)
|
||||
|
||||
def shutdown(self) -> None:
|
||||
self.stop.set()
|
||||
if self.thread:
|
||||
self.thread.join(timeout=1.0)
|
||||
try:
|
||||
os.killpg(self.proc.pid, signal.SIGTERM)
|
||||
except (ProcessLookupError, PermissionError):
|
||||
pass
|
||||
try:
|
||||
self.proc.wait(timeout=2.0)
|
||||
except Exception:
|
||||
try:
|
||||
os.killpg(self.proc.pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
try:
|
||||
os.close(self.master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def launch_client(binary: str, env: dict, session_id: str, name: str,
|
||||
debug_cmd: Path, debug_resp: Path) -> LiveClient:
|
||||
master_fd, slave_fd = pty.openpty()
|
||||
cenv = dict(env)
|
||||
# Route this client's file-based debug channel to per-client paths so we can
|
||||
# talk to *this* live TUI directly (fixture setup + state readback + the
|
||||
# synthetic-key positive control).
|
||||
cenv["JCODE_DEBUG_CMD_PATH"] = str(debug_cmd)
|
||||
cenv["JCODE_DEBUG_RESPONSE_PATH"] = str(debug_resp)
|
||||
proc = subprocess.Popen(
|
||||
[
|
||||
binary,
|
||||
"--no-update",
|
||||
"--no-selfdev",
|
||||
"--socket", env["JCODE_SOCKET"],
|
||||
"--resume", session_id,
|
||||
],
|
||||
stdin=slave_fd, stdout=slave_fd, stderr=slave_fd,
|
||||
env=cenv, preexec_fn=os.setsid,
|
||||
)
|
||||
os.close(slave_fd)
|
||||
os.set_blocking(master_fd, False)
|
||||
client = LiveClient(name=name, proc=proc, master_fd=master_fd)
|
||||
client.start_pump()
|
||||
return client
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# per-client file-based debug channel (talks to the live TUI, not the server)
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
def client_cmd(debug_cmd: Path, debug_resp: Path, command: str,
|
||||
timeout_s: float = 6.0) -> str:
|
||||
"""Send a command to a live TUI client via its file debug channel."""
|
||||
try:
|
||||
debug_resp.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
debug_cmd.write_text(command)
|
||||
deadline = time.time() + timeout_s
|
||||
while time.time() < deadline:
|
||||
if debug_resp.exists():
|
||||
# small settle so the write completes
|
||||
time.sleep(0.02)
|
||||
return debug_resp.read_text()
|
||||
time.sleep(0.02)
|
||||
raise RuntimeError(f"client did not answer debug command {command!r} in {timeout_s}s")
|
||||
|
||||
|
||||
def client_diff_mode(debug_cmd: Path, debug_resp: Path) -> dict:
|
||||
raw = client_cmd(debug_cmd, debug_resp, "expand-badge-state")
|
||||
return json.loads(raw)
|
||||
|
||||
|
||||
def reset_fixture(debug_cmd: Path, debug_resp: Path) -> dict:
|
||||
raw = client_cmd(debug_cmd, debug_resp, "expand-badge-fixture")
|
||||
return json.loads(raw)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# minimal session seed (just needs to exist so the client can resume)
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
def now_iso() -> str:
|
||||
return time.strftime("%Y-%m-%dT%H:%M:%S.000000000Z", time.gmtime())
|
||||
|
||||
|
||||
def seed_session(home: Path, working_dir: str) -> str:
|
||||
sessions_dir = home / "sessions"
|
||||
sessions_dir.mkdir(parents=True, exist_ok=True)
|
||||
session_id = f"session_expand_{int(time.time()*1000)}_{uuid.uuid4().hex[:12]}"
|
||||
messages = [{
|
||||
"id": f"message_{uuid.uuid4().hex}",
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": "seed for expand-edit shortcut test"}],
|
||||
"timestamp": now_iso(),
|
||||
}]
|
||||
session = {
|
||||
"id": session_id,
|
||||
"parent_id": None,
|
||||
"title": "expand-edit-shortcut",
|
||||
"created_at": now_iso(),
|
||||
"updated_at": now_iso(),
|
||||
"working_dir": working_dir,
|
||||
"messages": messages,
|
||||
}
|
||||
(sessions_dir / f"{session_id}.json").write_text(json.dumps(session))
|
||||
return session_id
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
def settle_client(debug_cmd: Path, debug_resp: Path, timeout_s: float = 20.0,
|
||||
verbose: bool = False) -> bool:
|
||||
deadline = time.time() + timeout_s
|
||||
last_err = None
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
client_cmd(debug_cmd, debug_resp, "expand-badge-state", timeout_s=2.0)
|
||||
return True
|
||||
except Exception as e: # noqa: BLE001
|
||||
last_err = e
|
||||
time.sleep(0.2)
|
||||
if verbose and last_err:
|
||||
print(f" (settle never answered: {last_err})")
|
||||
return False
|
||||
|
||||
|
||||
def try_raw_encoding(client: LiveClient, debug_cmd: Path, debug_resp: Path,
|
||||
label: str, raw: bytes, verbose: bool) -> bool:
|
||||
"""Reset to collapsed, feed raw bytes, return True if it expanded."""
|
||||
reset_fixture(debug_cmd, debug_resp)
|
||||
time.sleep(0.15)
|
||||
before = client_diff_mode(debug_cmd, debug_resp)
|
||||
if before.get("diff_mode") != "Inline":
|
||||
# Force a clean collapsed baseline; never report a false positive.
|
||||
if verbose:
|
||||
print(f" [{label}] baseline not Inline ({before.get('diff_mode')}), retrying reset")
|
||||
reset_fixture(debug_cmd, debug_resp)
|
||||
time.sleep(0.2)
|
||||
before = client_diff_mode(debug_cmd, debug_resp)
|
||||
client.send_bytes(raw)
|
||||
# Give the event loop time to read + dispatch the bytes.
|
||||
deadline = time.time() + 2.0
|
||||
after = before
|
||||
while time.time() < deadline:
|
||||
time.sleep(0.1)
|
||||
after = client_diff_mode(debug_cmd, debug_resp)
|
||||
if after.get("diff_mode") == "FullInline":
|
||||
break
|
||||
ok = (before.get("diff_mode") == "Inline"
|
||||
and after.get("diff_mode") == "FullInline")
|
||||
status = "✅ EXPANDED" if ok else "— no change"
|
||||
print(f" [{label:<28}] {before.get('diff_mode')} -> {after.get('diff_mode')} {status}")
|
||||
return ok
|
||||
|
||||
|
||||
def negative_discriminator(client: LiveClient, debug_cmd: Path, debug_resp: Path,
|
||||
verbose: bool) -> bool:
|
||||
"""Prove the detector can tell expand from no-expand.
|
||||
|
||||
Feed a plain 'e' (no Alt/Shift). It must NOT expand the diff; otherwise a
|
||||
later "FullInline" reading would be meaningless. Returns True if the
|
||||
discriminator behaved correctly (stayed collapsed).
|
||||
"""
|
||||
reset_fixture(debug_cmd, debug_resp)
|
||||
time.sleep(0.15)
|
||||
before = client_diff_mode(debug_cmd, debug_resp)
|
||||
client.send_bytes(b"e") # plain ASCII e
|
||||
client.send_bytes(b"\x1b[101u") # kitty CSI-u plain 'e', no modifiers
|
||||
time.sleep(0.6)
|
||||
after = client_diff_mode(debug_cmd, debug_resp)
|
||||
ok = (before.get("diff_mode") == "Inline"
|
||||
and after.get("diff_mode") == "Inline")
|
||||
status = "✅ stayed collapsed" if ok else "❌ unexpectedly expanded"
|
||||
print(f" [{'plain e (no Alt/Shift)':<28}] "
|
||||
f"{before.get('diff_mode')} -> {after.get('diff_mode')} {status}")
|
||||
return ok
|
||||
|
||||
|
||||
def synthetic_positive_control(client: LiveClient, debug_cmd: Path, debug_resp: Path,
|
||||
verbose: bool) -> bool:
|
||||
reset_fixture(debug_cmd, debug_resp)
|
||||
time.sleep(0.15)
|
||||
before = client_diff_mode(debug_cmd, debug_resp)
|
||||
client_cmd(debug_cmd, debug_resp, "keys:alt+shift+e")
|
||||
time.sleep(0.3)
|
||||
after = client_diff_mode(debug_cmd, debug_resp)
|
||||
ok = (before.get("diff_mode") == "Inline"
|
||||
and after.get("diff_mode") == "FullInline")
|
||||
status = "✅ EXPANDED" if ok else "— no change"
|
||||
print(f" [{'synthetic keys:alt+shift+e':<28}] "
|
||||
f"{before.get('diff_mode')} -> {after.get('diff_mode')} {status}")
|
||||
return ok
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(
|
||||
description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
default_bin = Path.home() / ".jcode" / "builds" / "current" / "jcode"
|
||||
ap.add_argument("--binary", default=str(default_bin))
|
||||
ap.add_argument("--keep", action="store_true", help="keep temp home on exit")
|
||||
ap.add_argument("-v", "--verbose", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
binary = str(Path(args.binary).resolve())
|
||||
if not Path(binary).exists():
|
||||
print(f"❌ binary not found: {binary}")
|
||||
return 3
|
||||
|
||||
root = Path(tempfile.mkdtemp(prefix="jcode-expand-edit-"))
|
||||
home = root / "home"
|
||||
run = root / "run"
|
||||
home.mkdir(parents=True, exist_ok=True)
|
||||
run.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
env = os.environ.copy()
|
||||
env["JCODE_HOME"] = str(home)
|
||||
env["JCODE_RUNTIME_DIR"] = str(run)
|
||||
env["JCODE_SOCKET"] = str(run / "jcode.sock")
|
||||
env["JCODE_NO_TELEMETRY"] = "1"
|
||||
env["JCODE_DEBUG_CONTROL"] = "1"
|
||||
env["JCODE_TEMP_SERVER"] = "1"
|
||||
env["JCODE_SERVER_OWNER_PID"] = str(os.getpid())
|
||||
debug_sock = run / "jcode-debug.sock"
|
||||
client_cmd_path = run / "client_debug_cmd"
|
||||
client_resp_path = run / "client_debug_resp"
|
||||
|
||||
print("╔══════════════════════════════════════════════════════════╗")
|
||||
print("║ Alt+Shift+E expand-edit shortcut: end-to-end verifier ║")
|
||||
print("╚══════════════════════════════════════════════════════════╝")
|
||||
print(f" binary : {binary}")
|
||||
print(f" home : {home}")
|
||||
|
||||
server = subprocess.Popen(
|
||||
[binary, "serve", "--socket", env["JCODE_SOCKET"], "--debug-socket",
|
||||
"--no-update", "--no-selfdev"],
|
||||
env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
preexec_fn=os.setsid,
|
||||
)
|
||||
|
||||
client: LiveClient | None = None
|
||||
rc = 3
|
||||
try:
|
||||
wait_for_socket(Path(env["JCODE_SOCKET"]))
|
||||
wait_for_socket(debug_sock)
|
||||
print(" server : up")
|
||||
|
||||
session_id = seed_session(home, str(REPO_ROOT))
|
||||
print(f" seeded : {session_id}")
|
||||
|
||||
client = launch_client(binary, env, session_id, "client-A",
|
||||
client_cmd_path, client_resp_path)
|
||||
if not settle_client(client_cmd_path, client_resp_path, verbose=args.verbose):
|
||||
print(" ❌ client never came up on the file debug channel")
|
||||
return 3
|
||||
print(" client : up (PTY)\n")
|
||||
|
||||
# ── 1. negative control: fixture must start collapsed ────────────────
|
||||
print("── 1. negative control (fixture starts collapsed) ──────────")
|
||||
fx = reset_fixture(client_cmd_path, client_resp_path)
|
||||
time.sleep(0.2)
|
||||
state = client_diff_mode(client_cmd_path, client_resp_path)
|
||||
collapsed_ok = state.get("diff_mode") == "Inline"
|
||||
print(f" fixture diff_mode = {state.get('diff_mode')} "
|
||||
f"{'✅' if collapsed_ok else '❌ expected Inline'}")
|
||||
if not collapsed_ok:
|
||||
print("\n ❌ could not establish a collapsed baseline; aborting.")
|
||||
return 3
|
||||
|
||||
# ── 2. discriminator: prove the detector can tell expand from no-op ──
|
||||
print("\n── 2. discriminator (plain 'e' must NOT expand) ────────────")
|
||||
disc_ok = negative_discriminator(
|
||||
client, client_cmd_path, client_resp_path, args.verbose)
|
||||
if not disc_ok:
|
||||
print("\n ❌ detector has no discriminating power; results meaningless.")
|
||||
return 3
|
||||
|
||||
# ── 3. RAW-BYTES end-to-end (the real test) ──────────────────────────
|
||||
print("\n── 3. raw terminal bytes -> live decode -> handler ─────────")
|
||||
raw_results = {}
|
||||
for label, raw in KEY_ENCODINGS:
|
||||
raw_results[label] = try_raw_encoding(
|
||||
client, client_cmd_path, client_resp_path, label, raw, args.verbose)
|
||||
raw_any = any(raw_results.values())
|
||||
|
||||
# ── 4. synthetic positive control ────────────────────────────────────
|
||||
print("\n── 4. positive control (synthetic key, bypasses decode) ────")
|
||||
synth_ok = synthetic_positive_control(
|
||||
client, client_cmd_path, client_resp_path, args.verbose)
|
||||
|
||||
# ── verdict ──────────────────────────────────────────────────────────
|
||||
print("\n╭──────────────────────── verdict ─────────────────────────╮")
|
||||
if raw_any:
|
||||
working = [k for k, v in raw_results.items() if v]
|
||||
print("│ ✅ Alt+Shift+E EXPANDS the diff via real terminal bytes. │")
|
||||
print(f"│ working encodings: {', '.join(working)}")
|
||||
rc = 0
|
||||
elif synth_ok:
|
||||
print("│ ⚠ Raw terminal bytes do NOT expand the diff, but the │")
|
||||
print("│ handler works via synthetic keys. │")
|
||||
print("│ => break is in terminal decode / byte delivery, │")
|
||||
print("│ NOT the expand handler logic. │")
|
||||
rc = 2
|
||||
else:
|
||||
print("│ ❌ Neither raw bytes nor synthetic keys expand the diff. │")
|
||||
print("│ => break is in the expand handler / fixture itself. │")
|
||||
rc = 3
|
||||
print("╰──────────────────────────────────────────────────────────╯")
|
||||
return rc
|
||||
finally:
|
||||
if client:
|
||||
client.shutdown()
|
||||
try:
|
||||
os.killpg(server.pid, signal.SIGTERM)
|
||||
except (ProcessLookupError, PermissionError):
|
||||
pass
|
||||
try:
|
||||
server.wait(timeout=3.0)
|
||||
except Exception:
|
||||
try:
|
||||
os.killpg(server.pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
if args.keep:
|
||||
print(f"\n (kept temp dir: {root})")
|
||||
else:
|
||||
import shutil
|
||||
shutil.rmtree(root, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+639
@@ -0,0 +1,639 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Reproduction / detection harness for GitHub issue #314:
|
||||
"Live transcript duplicates assistant commentary and tool calls"
|
||||
|
||||
Goal
|
||||
----
|
||||
We have no confirmed repro of #314. This harness builds a *programmatic,
|
||||
read-back* environment for the live-render layer so duplication can be
|
||||
detected objectively instead of by eyeballing a transcript.
|
||||
|
||||
Strategy
|
||||
--------
|
||||
The bug is a divergence between two layers:
|
||||
* server conversation history -> source of truth (one copy per message)
|
||||
* client `display_messages` -> what is actually rendered live
|
||||
|
||||
So the detector simply diffs the two. Each seeded assistant/tool/tool-result
|
||||
carries a UNIQUE marker token ("MARK_xxxx"). If a marker shows up in more
|
||||
display_messages than it appears in the server's history, that is the bug,
|
||||
caught with the exact trigger isolated.
|
||||
|
||||
Everything runs in a throwaway JCODE_HOME / runtime dir / socket, so the
|
||||
user's real server and sessions are never touched.
|
||||
|
||||
The suspected triggers (issue "Possible trigger" + "Notes") are exercised in
|
||||
stages:
|
||||
1. fresh-resume single headed client resumes the seeded session
|
||||
2. second-attach a second headed client attaches to the same session
|
||||
3. reload client triggers /reload (server reload recovery path)
|
||||
4. detach-reattach first client is killed, a new client reattaches
|
||||
|
||||
After each stage every live client's display history is read back and diffed
|
||||
against server truth.
|
||||
|
||||
Usage
|
||||
-----
|
||||
python3 scripts/repro_live_duplicate.py [--binary PATH] [--keep] [-v]
|
||||
|
||||
Exit code 0 = no duplication detected, 2 = duplication reproduced.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import pty
|
||||
import select
|
||||
import shutil
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# debug socket helpers
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _recv_debug_response(sock: socket.socket, timeout: float) -> dict:
|
||||
sock.settimeout(timeout)
|
||||
buf = b""
|
||||
while True:
|
||||
chunk = sock.recv(65536)
|
||||
if not chunk:
|
||||
break
|
||||
buf += chunk
|
||||
while b"\n" in buf:
|
||||
line, buf = buf.split(b"\n", 1)
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
resp = json.loads(line.decode())
|
||||
t = resp.get("type")
|
||||
if t in ("ack", "pong"):
|
||||
continue
|
||||
return resp
|
||||
raise RuntimeError("debug socket closed without a response")
|
||||
|
||||
|
||||
def dbg(debug_sock: Path, command: str, session_id: str | None = None,
|
||||
timeout: float = 30.0) -> dict:
|
||||
"""Send one debug command, return parsed {ok, output, ...}."""
|
||||
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
s.connect(str(debug_sock))
|
||||
try:
|
||||
req = {"type": "debug_command", "id": 1, "command": command}
|
||||
if session_id:
|
||||
req["session_id"] = session_id
|
||||
s.sendall((json.dumps(req) + "\n").encode())
|
||||
return _recv_debug_response(s, timeout)
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def dbg_output(debug_sock: Path, command: str, session_id: str | None = None,
|
||||
timeout: float = 30.0) -> str:
|
||||
resp = dbg(debug_sock, command, session_id=session_id, timeout=timeout)
|
||||
if resp.get("type") == "error":
|
||||
raise RuntimeError(f"debug error for {command!r}: {resp.get('message')}")
|
||||
if resp.get("ok") is False:
|
||||
raise RuntimeError(f"command {command!r} failed: {resp.get('output')}")
|
||||
return resp.get("output", "")
|
||||
|
||||
|
||||
def wait_for_socket(path: Path, timeout_s: float = 20.0) -> None:
|
||||
deadline = time.time() + timeout_s
|
||||
while time.time() < deadline:
|
||||
if path.exists():
|
||||
try:
|
||||
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
s.settimeout(0.2)
|
||||
s.connect(str(path))
|
||||
s.close()
|
||||
return
|
||||
except OSError:
|
||||
pass
|
||||
time.sleep(0.02)
|
||||
raise RuntimeError(f"socket not ready: {path}")
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# PTY-driven headed TUI client (this is the real live-render path)
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Terminal capability queries the TUI emits at startup; we answer them so the
|
||||
# client finishes initializing under a pseudo-terminal.
|
||||
_TERM_REPLIES = [
|
||||
(b"\x1b[6n", b"\x1b[1;1R"),
|
||||
(b"\x1b[c", b"\x1b[?62;c"),
|
||||
(b"\x1b]10;?\x1b\\", b"\x1b]10;rgb:ffff/ffff/ffff\x1b\\"),
|
||||
(b"\x1b]11;?\x1b\\", b"\x1b]11;rgb:0000/0000/0000\x1b\\"),
|
||||
(b"\x1b]10;?\x07", b"\x1b]10;rgb:ffff/ffff/ffff\x07"),
|
||||
(b"\x1b]11;?\x07", b"\x1b]11;rgb:0000/0000/0000\x07"),
|
||||
(b"\x1b[14t", b"\x1b[4;600;800t"),
|
||||
(b"\x1b[16t", b"\x1b[6;16;8t"),
|
||||
(b"\x1b[18t", b"\x1b[8;24;80t"),
|
||||
(b"\x1b[?1016$p", b"\x1b[?1016;1$y"),
|
||||
(b"\x1b[?2027$p", b"\x1b[?2027;1$y"),
|
||||
(b"\x1b[?2031$p", b"\x1b[?2031;1$y"),
|
||||
(b"\x1b[?1004$p", b"\x1b[?1004;1$y"),
|
||||
(b"\x1b[?2004$p", b"\x1b[?2004;1$y"),
|
||||
(b"\x1b[?2026$p", b"\x1b[?2026;1$y"),
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class LiveClient:
|
||||
name: str
|
||||
proc: subprocess.Popen
|
||||
master_fd: int
|
||||
stop: threading.Event = field(default_factory=threading.Event)
|
||||
thread: threading.Thread | None = None
|
||||
total_bytes: int = 0
|
||||
|
||||
def _pump(self) -> None:
|
||||
buffer = b""
|
||||
while not self.stop.is_set():
|
||||
try:
|
||||
rlist, _, _ = select.select([self.master_fd], [], [], 0.1)
|
||||
except (OSError, ValueError):
|
||||
break
|
||||
if not rlist:
|
||||
if self.proc.poll() is not None:
|
||||
break
|
||||
continue
|
||||
try:
|
||||
chunk = os.read(self.master_fd, 65536)
|
||||
except (BlockingIOError, OSError):
|
||||
continue
|
||||
if not chunk:
|
||||
break
|
||||
self.total_bytes += len(chunk)
|
||||
buffer = (buffer + chunk)[-8192:]
|
||||
changed = True
|
||||
while changed:
|
||||
changed = False
|
||||
for query, response in _TERM_REPLIES:
|
||||
if query in buffer:
|
||||
try:
|
||||
os.write(self.master_fd, response)
|
||||
except OSError:
|
||||
pass
|
||||
buffer = buffer.replace(query, b"")
|
||||
changed = True
|
||||
|
||||
def start_pump(self) -> None:
|
||||
self.thread = threading.Thread(target=self._pump, daemon=True)
|
||||
self.thread.start()
|
||||
|
||||
def alive(self) -> bool:
|
||||
return self.proc.poll() is None
|
||||
|
||||
def send_keys(self, data: bytes) -> None:
|
||||
os.write(self.master_fd, data)
|
||||
|
||||
def shutdown(self) -> None:
|
||||
self.stop.set()
|
||||
if self.thread:
|
||||
self.thread.join(timeout=1.0)
|
||||
try:
|
||||
os.killpg(self.proc.pid, signal.SIGTERM)
|
||||
except (ProcessLookupError, PermissionError):
|
||||
pass
|
||||
try:
|
||||
self.proc.wait(timeout=2.0)
|
||||
except Exception:
|
||||
try:
|
||||
os.killpg(self.proc.pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
try:
|
||||
os.close(self.master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def launch_client(binary: str, env: dict, session_id: str, name: str) -> LiveClient:
|
||||
master_fd, slave_fd = pty.openpty()
|
||||
proc = subprocess.Popen(
|
||||
[
|
||||
binary,
|
||||
"--no-update",
|
||||
"--no-selfdev",
|
||||
"--socket", env["JCODE_SOCKET"],
|
||||
"--resume", session_id,
|
||||
],
|
||||
stdin=slave_fd, stdout=slave_fd, stderr=slave_fd,
|
||||
env=env, preexec_fn=os.setsid,
|
||||
)
|
||||
os.close(slave_fd)
|
||||
os.set_blocking(master_fd, False)
|
||||
client = LiveClient(name=name, proc=proc, master_fd=master_fd)
|
||||
client.start_pump()
|
||||
return client
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# session seeding
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class SeededSession:
|
||||
session_id: str
|
||||
markers: dict[str, int] # marker -> expected occurrence count in truth
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
return time.strftime("%Y-%m-%dT%H:%M:%S.000000000Z", time.gmtime())
|
||||
|
||||
|
||||
def make_stored_message(role: str, blocks: list[dict],
|
||||
display_role: str | None = None) -> dict:
|
||||
msg = {
|
||||
"id": f"message_{uuid.uuid4().hex}",
|
||||
"role": role,
|
||||
"content": blocks,
|
||||
"timestamp": now_iso(),
|
||||
}
|
||||
if display_role:
|
||||
msg["display_role"] = display_role
|
||||
return msg
|
||||
|
||||
|
||||
def seed_session(home: Path, working_dir: str, turns: int = 3) -> SeededSession:
|
||||
"""Write a session transcript with assistant commentary + tool calls.
|
||||
|
||||
Every assistant text, tool_use input, and tool_result carries a unique
|
||||
MARK_xxxx token so duplication can be counted unambiguously.
|
||||
"""
|
||||
sessions_dir = home / "sessions"
|
||||
sessions_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
session_id = f"session_repro_{int(time.time()*1000)}_{uuid.uuid4().hex[:16]}"
|
||||
markers: dict[str, int] = {}
|
||||
|
||||
def mark(tag: str) -> str:
|
||||
token = f"MARK_{tag}"
|
||||
markers[token] = 1
|
||||
return token
|
||||
|
||||
messages: list[dict] = []
|
||||
# opening user turn
|
||||
messages.append(make_stored_message("user", [
|
||||
{"type": "text", "text": f"{mark('USER_OPEN')} please run the repro tasks"}
|
||||
]))
|
||||
|
||||
for i in range(turns):
|
||||
a_text = mark(f"ASSIST_{i:02d}")
|
||||
t_in = mark(f"TOOLIN_{i:02d}")
|
||||
t_out = mark(f"TOOLOUT_{i:02d}")
|
||||
tool_id = f"toolu_{uuid.uuid4().hex[:20]}"
|
||||
# assistant commentary + a tool call (mirrors the issue's pattern)
|
||||
messages.append(make_stored_message("assistant", [
|
||||
{"type": "text", "text": f"{a_text} I'll inspect step {i}."},
|
||||
{"type": "tool_use", "id": tool_id, "name": "bash",
|
||||
"input": {"command": f"echo {t_in}", "intent": f"repro step {i}"}},
|
||||
]))
|
||||
# tool result comes back on the user turn
|
||||
messages.append(make_stored_message("user", [
|
||||
{"type": "tool_result", "tool_use_id": tool_id,
|
||||
"content": f"{t_out} step {i} ok"},
|
||||
]))
|
||||
|
||||
# final assistant summary
|
||||
messages.append(make_stored_message("assistant", [
|
||||
{"type": "text", "text": f"{mark('ASSIST_FINAL')} all steps complete"}
|
||||
]))
|
||||
|
||||
session = {
|
||||
"id": session_id,
|
||||
"parent_id": None,
|
||||
"title": "repro-314",
|
||||
"created_at": now_iso(),
|
||||
"updated_at": now_iso(),
|
||||
"messages": messages,
|
||||
"provider_key": "openai",
|
||||
"model": "gpt-5.5",
|
||||
"reasoning_effort": "low",
|
||||
"is_canary": False,
|
||||
"testing_build": None,
|
||||
"working_dir": working_dir,
|
||||
"short_name": "repro",
|
||||
"status": "Active",
|
||||
"last_pid": None,
|
||||
"last_active_at": now_iso(),
|
||||
"is_debug": True,
|
||||
"saved": False,
|
||||
"env_snapshots": [],
|
||||
}
|
||||
|
||||
path = sessions_dir / f"{session_id}.json"
|
||||
path.write_text(json.dumps(session, indent=2))
|
||||
return SeededSession(session_id=session_id, markers=markers)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# detection
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def count_markers_in_display(history_json: str, markers: dict[str, int]) -> dict[str, int]:
|
||||
"""Count how many display_messages contain each marker."""
|
||||
counts = {m: 0 for m in markers}
|
||||
try:
|
||||
msgs = json.loads(history_json)
|
||||
except json.JSONDecodeError:
|
||||
return counts
|
||||
for m in msgs:
|
||||
blob = json.dumps(m)
|
||||
for marker in markers:
|
||||
if marker in blob:
|
||||
counts[marker] += 1
|
||||
return counts
|
||||
|
||||
|
||||
def count_markers_in_text(text: str, markers: dict[str, int]) -> dict[str, int]:
|
||||
return {m: text.count(m) for m in markers}
|
||||
|
||||
|
||||
@dataclass
|
||||
class StageResult:
|
||||
stage: str
|
||||
client: str
|
||||
display_count: int
|
||||
duplicates: dict[str, int] # marker -> count, only where count > 1
|
||||
error: str | None = None
|
||||
|
||||
|
||||
def diff_stage(stage: str, client_name: str, history_json: str,
|
||||
markers: dict[str, int]) -> StageResult:
|
||||
try:
|
||||
msgs = json.loads(history_json)
|
||||
n = len(msgs)
|
||||
except json.JSONDecodeError:
|
||||
return StageResult(stage, client_name, 0, {}, error="non-JSON history")
|
||||
counts = count_markers_in_display(history_json, markers)
|
||||
dups = {m: c for m, c in counts.items() if c > markers[m]}
|
||||
return StageResult(stage, client_name, n, dups)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# orchestration
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def settle(debug_sock: Path, session_id: str, client_name_hint: str,
|
||||
verbose: bool, attempts: int = 60) -> bool:
|
||||
"""Poll until a client for this session answers client:history."""
|
||||
for i in range(attempts):
|
||||
try:
|
||||
out = dbg_output(debug_sock, "client:history", session_id=session_id,
|
||||
timeout=8.0)
|
||||
if out.strip().startswith("["):
|
||||
if verbose:
|
||||
print(f" [{client_name_hint}] client:history ready after {i} polls")
|
||||
return True
|
||||
except Exception as e:
|
||||
if verbose and i % 10 == 0:
|
||||
print(f" [{client_name_hint}] waiting ({i}): {e}")
|
||||
time.sleep(0.5)
|
||||
return False
|
||||
|
||||
|
||||
def read_client_history(debug_sock: Path, session_id: str) -> str:
|
||||
return dbg_output(debug_sock, "client:history", session_id=session_id, timeout=10.0)
|
||||
|
||||
|
||||
def run_self_test(debug_sock: Path, session_id: str, verbose: bool) -> bool:
|
||||
"""Positive control: force a real duplicate into a live client and confirm
|
||||
the detector flags it. Proves the instrument is not a no-op.
|
||||
|
||||
Injecting the same assistant marker twice produces two display_messages
|
||||
(assistant role is never coalesced), so a working detector must report x2.
|
||||
"""
|
||||
marker = f"MARK_SELFTEST_{uuid.uuid4().hex[:8]}"
|
||||
expected = {marker: 1}
|
||||
for _ in range(2):
|
||||
dbg_output(debug_sock, f"client:inject:assistant:{marker} injected duplicate",
|
||||
session_id=session_id, timeout=10.0)
|
||||
time.sleep(0.3)
|
||||
hist = read_client_history(debug_sock, session_id)
|
||||
r = diff_stage("self-test", "control", hist, expected)
|
||||
if verbose:
|
||||
print(f" self-test marker {marker}: display_count={r.display_count} "
|
||||
f"duplicates={r.duplicates}")
|
||||
# detector must have caught exactly the injected duplicate
|
||||
return r.duplicates.get(marker, 0) == 2
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
default_bin = Path.home() / ".jcode" / "builds" / "current" / "jcode"
|
||||
ap.add_argument("--binary", default=str(default_bin))
|
||||
ap.add_argument("--turns", type=int, default=3, help="tool-call turns to seed")
|
||||
ap.add_argument("--keep", action="store_true", help="keep temp home on exit")
|
||||
ap.add_argument("--self-test", action=argparse.BooleanOptionalAction, default=True,
|
||||
help="run positive-control: force a real duplicate and confirm "
|
||||
"the detector flags it (default: on)")
|
||||
ap.add_argument("-v", "--verbose", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
binary = str(Path(args.binary).resolve())
|
||||
if not Path(binary).exists():
|
||||
print(f"❌ binary not found: {binary}")
|
||||
return 1
|
||||
|
||||
root = Path(tempfile.mkdtemp(prefix="jcode-repro314-"))
|
||||
home = root / "home"
|
||||
run = root / "run"
|
||||
home.mkdir(parents=True, exist_ok=True)
|
||||
run.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
env = os.environ.copy()
|
||||
env["JCODE_HOME"] = str(home)
|
||||
env["JCODE_RUNTIME_DIR"] = str(run)
|
||||
env["JCODE_SOCKET"] = str(run / "jcode.sock")
|
||||
env["JCODE_NO_TELEMETRY"] = "1"
|
||||
env["JCODE_DEBUG_CONTROL"] = "1"
|
||||
env["JCODE_TEMP_SERVER"] = "1"
|
||||
env["JCODE_SERVER_OWNER_PID"] = str(os.getpid())
|
||||
debug_sock = run / "jcode-debug.sock"
|
||||
|
||||
print("╔══════════════════════════════════════════════════════════╗")
|
||||
print("║ repro #314: live transcript duplicate detector ║")
|
||||
print("╚══════════════════════════════════════════════════════════╝")
|
||||
print(f" binary : {binary}")
|
||||
print(f" home : {home}")
|
||||
print(f" socket : {env['JCODE_SOCKET']}")
|
||||
|
||||
server = subprocess.Popen(
|
||||
[binary, "serve", "--socket", env["JCODE_SOCKET"], "--debug-socket",
|
||||
"--no-update", "--no-selfdev"],
|
||||
env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
preexec_fn=os.setsid,
|
||||
)
|
||||
|
||||
clients: list[LiveClient] = []
|
||||
results: list[StageResult] = []
|
||||
|
||||
try:
|
||||
wait_for_socket(Path(env["JCODE_SOCKET"]))
|
||||
wait_for_socket(debug_sock)
|
||||
print(" server : up")
|
||||
|
||||
seeded = seed_session(home, str(REPO_ROOT), turns=args.turns)
|
||||
print(f" seeded : {seeded.session_id} ({len(seeded.markers)} markers, "
|
||||
f"{args.turns} tool turns)")
|
||||
|
||||
# cross-check server truth (each marker should be present once)
|
||||
try:
|
||||
server_hist = dbg_output(debug_sock, "history",
|
||||
session_id=seeded.session_id, timeout=10.0)
|
||||
except Exception:
|
||||
server_hist = ""
|
||||
# ── stage 1: fresh resume ───────────────────────────────────────────
|
||||
print("\n── stage 1: fresh-resume ─────────────────────────────────")
|
||||
c1 = launch_client(binary, env, seeded.session_id, "client-A")
|
||||
clients.append(c1)
|
||||
if not settle(debug_sock, seeded.session_id, "client-A", args.verbose):
|
||||
print(" ⚠ client-A never answered client:history")
|
||||
else:
|
||||
r = diff_stage("fresh-resume", "client-A",
|
||||
read_client_history(debug_sock, seeded.session_id),
|
||||
seeded.markers)
|
||||
results.append(r)
|
||||
report_stage(r, args.verbose)
|
||||
|
||||
# ── stage 2: second concurrent attach ───────────────────────────────
|
||||
print("\n── stage 2: second-attach ────────────────────────────────")
|
||||
c2 = launch_client(binary, env, seeded.session_id, "client-B")
|
||||
clients.append(c2)
|
||||
settle(debug_sock, seeded.session_id, "client-B", args.verbose)
|
||||
time.sleep(1.0)
|
||||
# both clients now attached to the same session; read whichever the
|
||||
# server routes to (most-recent connection) plus re-read after a beat
|
||||
for label in ("after-B-attach", "after-B-attach-2"):
|
||||
try:
|
||||
r = diff_stage(f"second-attach/{label}", "active",
|
||||
read_client_history(debug_sock, seeded.session_id),
|
||||
seeded.markers)
|
||||
results.append(r)
|
||||
report_stage(r, args.verbose)
|
||||
except Exception as e:
|
||||
print(f" ⚠ read failed: {e}")
|
||||
time.sleep(0.8)
|
||||
|
||||
# ── stage 3: reload ─────────────────────────────────────────────────
|
||||
print("\n── stage 3: reload ───────────────────────────────────────")
|
||||
try:
|
||||
dbg_output(debug_sock, "client:reload", session_id=seeded.session_id,
|
||||
timeout=10.0)
|
||||
print(" /reload triggered on active client")
|
||||
except Exception as e:
|
||||
print(f" ⚠ reload trigger failed: {e}")
|
||||
time.sleep(2.0)
|
||||
if settle(debug_sock, seeded.session_id, "post-reload", args.verbose):
|
||||
r = diff_stage("post-reload", "active",
|
||||
read_client_history(debug_sock, seeded.session_id),
|
||||
seeded.markers)
|
||||
results.append(r)
|
||||
report_stage(r, args.verbose)
|
||||
|
||||
# ── stage 4: detach + reattach ──────────────────────────────────────
|
||||
print("\n── stage 4: detach-reattach ──────────────────────────────")
|
||||
c1.shutdown()
|
||||
c2.shutdown()
|
||||
time.sleep(1.0)
|
||||
c3 = launch_client(binary, env, seeded.session_id, "client-C")
|
||||
clients.append(c3)
|
||||
if settle(debug_sock, seeded.session_id, "client-C", args.verbose):
|
||||
for label in ("reattach", "reattach-2"):
|
||||
r = diff_stage(f"detach-reattach/{label}", "client-C",
|
||||
read_client_history(debug_sock, seeded.session_id),
|
||||
seeded.markers)
|
||||
results.append(r)
|
||||
report_stage(r, args.verbose)
|
||||
time.sleep(0.8)
|
||||
|
||||
# ── positive control: prove the detector can catch a real duplicate ──
|
||||
self_test_ok: bool | None = None
|
||||
if args.self_test:
|
||||
print("\n── self-test: positive control ───────────────────────────")
|
||||
try:
|
||||
self_test_ok = run_self_test(debug_sock, seeded.session_id,
|
||||
args.verbose)
|
||||
except Exception as e:
|
||||
print(f" ⚠ self-test failed to run: {e}")
|
||||
self_test_ok = False
|
||||
if self_test_ok:
|
||||
print(" 🟢 detector caught the forced duplicate (instrument live)")
|
||||
else:
|
||||
print(" 🔴 detector MISSED a forced duplicate -> results untrustworthy")
|
||||
|
||||
# ── summary ─────────────────────────────────────────────────────────
|
||||
print("\n" + "─" * 60)
|
||||
if args.self_test and not self_test_ok:
|
||||
print(" ⛔ INSTRUMENT INVALID: positive control failed.")
|
||||
print(" A clean run cannot be trusted because the detector did not")
|
||||
print(" flag a known-injected duplicate. Investigate the harness.")
|
||||
return 3
|
||||
reproduced = [r for r in results if r.duplicates]
|
||||
if reproduced:
|
||||
print(" 🔴 DUPLICATION REPRODUCED")
|
||||
for r in reproduced:
|
||||
print(f" stage={r.stage} client={r.client} "
|
||||
f"display_count={r.display_count}")
|
||||
for marker, cnt in sorted(r.duplicates.items()):
|
||||
print(f" {marker}: rendered x{cnt} (expected x1)")
|
||||
return 2
|
||||
else:
|
||||
print(" 🟢 no duplication detected across all stages")
|
||||
print(f" stages checked: {len(results)}")
|
||||
if server_hist:
|
||||
sc = count_markers_in_text(server_hist, seeded.markers)
|
||||
bad = {m: c for m, c in sc.items() if c > 2}
|
||||
print(f" server history marker check: "
|
||||
f"{'clean' if not bad else bad}")
|
||||
return 0
|
||||
|
||||
finally:
|
||||
for c in clients:
|
||||
c.shutdown()
|
||||
try:
|
||||
os.killpg(server.pid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
try:
|
||||
server.wait(timeout=3)
|
||||
except Exception:
|
||||
try:
|
||||
os.killpg(server.pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
if args.keep:
|
||||
print(f"\n (kept temp home: {root})")
|
||||
else:
|
||||
shutil.rmtree(root, ignore_errors=True)
|
||||
|
||||
|
||||
def report_stage(r: StageResult, verbose: bool) -> None:
|
||||
if r.error:
|
||||
print(f" {r.stage:28s} {r.client:10s} ERROR: {r.error}")
|
||||
return
|
||||
status = "🔴 DUP" if r.duplicates else "🟢 ok "
|
||||
print(f" {status} {r.stage:28s} {r.client:10s} display_messages={r.display_count}")
|
||||
if r.duplicates:
|
||||
for marker, cnt in sorted(r.duplicates.items()):
|
||||
print(f" ↳ {marker} x{cnt}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+56
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Publishable Terminal-Bench 2.1 campaign for jcode + Opus 4.8 (native Anthropic
|
||||
# API). Runs with effectively-uncapped agent execution time so no task is lost
|
||||
# to an agent timeout, while keeping verifier/build timeouts at their default
|
||||
# (deterministic grading). Captures full provenance for publication.
|
||||
#
|
||||
# Env knobs:
|
||||
# JCODE_TB_JOBS_DIR output dir (default /tmp/jcode-tb21-pub)
|
||||
# JCODE_TB_K attempts per task (default 1)
|
||||
# JCODE_TB_NCONC concurrent containers (default 3)
|
||||
# JCODE_TB_AGENT_MULT agent-timeout multiplier (default 1000 ~ uncapped)
|
||||
|
||||
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
|
||||
REPO_ROOT=$(cd -- "$SCRIPT_DIR/.." && pwd)
|
||||
|
||||
JOBS_DIR=${JCODE_TB_JOBS_DIR:-/tmp/jcode-tb21-pub}
|
||||
K=${JCODE_TB_K:-1}
|
||||
NCONC=${JCODE_TB_NCONC:-3}
|
||||
AGENT_MULT=${JCODE_TB_AGENT_MULT:-1000}
|
||||
JOB_NAME=${JCODE_TB_JOB_NAME:-tb21-opus48-uncapped-k${K}}
|
||||
TB_PATH=${JCODE_TB_PATH:-/tmp/terminal-bench-2.1}
|
||||
MODEL=${JCODE_TB_MODEL:-anthropic-api/claude-opus-4-8}
|
||||
|
||||
mkdir -p "$JOBS_DIR"
|
||||
|
||||
# Provenance manifest.
|
||||
{
|
||||
echo "# jcode Terminal-Bench 2.1 publishable run"
|
||||
echo "timestamp_utc: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
echo "git_commit: $(git -C "$REPO_ROOT" rev-parse HEAD 2>/dev/null || echo unknown)"
|
||||
echo "git_describe: $(git -C "$REPO_ROOT" describe --tags --always --dirty 2>/dev/null || echo unknown)"
|
||||
echo "jcode_binary: ${JCODE_HARBOR_BINARY:-/tmp/jcode-compat-dist/jcode-linux-x86_64.bin}"
|
||||
echo "jcode_version: $(/tmp/jcode-compat-dist/jcode-linux-x86_64.bin --no-update --no-selfdev version 2>/dev/null | head -1 || echo unknown)"
|
||||
echo "harbor_version: $(harbor --version 2>/dev/null | head -1 || echo unknown)"
|
||||
echo "dataset: terminal-bench/terminal-bench-2-1 (local: $TB_PATH)"
|
||||
echo "n_tasks: $(ls "$TB_PATH" | wc -l)"
|
||||
echo "model: $MODEL"
|
||||
echo "reasoning_effort: ${JCODE_ANTHROPIC_REASONING_EFFORT:-high}"
|
||||
echo "k_attempts: $K"
|
||||
echo "n_concurrent: $NCONC"
|
||||
echo "agent_timeout_multiplier: $AGENT_MULT"
|
||||
echo "verifier_timeout: dataset default (unchanged)"
|
||||
} > "$JOBS_DIR/RUN_MANIFEST.txt"
|
||||
cat "$JOBS_DIR/RUN_MANIFEST.txt"
|
||||
|
||||
exec "$REPO_ROOT/scripts/run_terminal_bench_claude.sh" \
|
||||
--path "$TB_PATH" \
|
||||
--model "$MODEL" \
|
||||
--n-concurrent "$NCONC" \
|
||||
-k "$K" \
|
||||
--agent-timeout-multiplier "$AGENT_MULT" \
|
||||
--jobs-dir "$JOBS_DIR" \
|
||||
--job-name "$JOB_NAME" \
|
||||
--yes
|
||||
@@ -0,0 +1,518 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import concurrent.futures
|
||||
import datetime as dt
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
|
||||
def repo_root() -> Path:
|
||||
return Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def run(cmd: list[str], *, env: dict[str, str] | None = None, cwd: Path | None = None) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(cmd, check=True, text=True, cwd=cwd, env=env)
|
||||
|
||||
|
||||
def capture(cmd: list[str], *, cwd: Path | None = None) -> str:
|
||||
return subprocess.check_output(cmd, text=True, cwd=cwd).strip()
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
h = hashlib.sha256()
|
||||
with path.open("rb") as f:
|
||||
for chunk in iter(lambda: f.read(1024 * 1024), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def resolve_existing_file(candidates: list[str | None]) -> Path | None:
|
||||
for raw in candidates:
|
||||
if not raw:
|
||||
continue
|
||||
p = Path(raw).expanduser()
|
||||
if p.exists() and p.is_file():
|
||||
return p.resolve()
|
||||
return None
|
||||
|
||||
|
||||
def load_tasks(args: argparse.Namespace) -> list[str]:
|
||||
tasks: list[str] = list(args.task)
|
||||
if args.tasks_file:
|
||||
for line in Path(args.tasks_file).read_text().splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
tasks.append(line)
|
||||
deduped: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for task in tasks:
|
||||
if task not in seen:
|
||||
seen.add(task)
|
||||
deduped.append(task)
|
||||
if not deduped:
|
||||
raise SystemExit("No tasks provided. Use --task and/or --tasks-file.")
|
||||
return deduped
|
||||
|
||||
|
||||
def ensure_binary(root: Path, env: dict[str, str]) -> Path:
|
||||
binary_dir = Path(env.get("JCODE_HARBOR_BINARY_DIR", "/tmp/jcode-compat-dist")).expanduser()
|
||||
binary_path = Path(env.get("JCODE_HARBOR_BINARY", str(binary_dir / "jcode-linux-x86_64"))).expanduser()
|
||||
if not (binary_path.exists() and os.access(binary_path, os.X_OK)):
|
||||
run([str(root / "scripts" / "build_linux_compat.sh"), str(binary_dir)], env=env, cwd=root)
|
||||
return binary_path.resolve()
|
||||
|
||||
|
||||
def current_settings(root: Path, args: argparse.Namespace) -> dict[str, Any]:
|
||||
env = os.environ.copy()
|
||||
binary_path = ensure_binary(root, env)
|
||||
openai_auth = resolve_existing_file([
|
||||
env.get("JCODE_HARBOR_OPENAI_AUTH"),
|
||||
"~/.jcode/openai-auth.json",
|
||||
])
|
||||
if openai_auth is None:
|
||||
raise SystemExit("OpenAI OAuth file not found. Set JCODE_HARBOR_OPENAI_AUTH or log in first.")
|
||||
settings: dict[str, Any] = {
|
||||
"schema_version": 1,
|
||||
"created_at": dt.datetime.now(dt.UTC).isoformat(),
|
||||
"repo_root": str(root),
|
||||
"git_head": capture(["git", "rev-parse", "HEAD"], cwd=root),
|
||||
"runner_script": str((root / "scripts" / "run_terminal_bench_harbor.sh").resolve()),
|
||||
"model": args.model,
|
||||
"reasoning_effort": os.environ.get("JCODE_OPENAI_REASONING_EFFORT", "high"),
|
||||
"service_tier": os.environ.get("JCODE_OPENAI_SERVICE_TIER", "priority"),
|
||||
"binary_path": str(binary_path),
|
||||
"binary_sha256": sha256_file(binary_path),
|
||||
"openai_auth_path": str(openai_auth),
|
||||
"dataset": args.dataset,
|
||||
"path": str(Path(args.path).resolve()) if args.path else None,
|
||||
"attempts_per_task": args.n_attempts,
|
||||
"n_concurrent": 1,
|
||||
"timeout_multiplier": args.timeout_multiplier,
|
||||
}
|
||||
return settings
|
||||
|
||||
|
||||
PINNED_KEYS = [
|
||||
"runner_script",
|
||||
"model",
|
||||
"reasoning_effort",
|
||||
"service_tier",
|
||||
"binary_path",
|
||||
"binary_sha256",
|
||||
"openai_auth_path",
|
||||
"dataset",
|
||||
"path",
|
||||
"attempts_per_task",
|
||||
"n_concurrent",
|
||||
"timeout_multiplier",
|
||||
]
|
||||
|
||||
|
||||
def ensure_manifest(campaign_dir: Path, settings: dict[str, Any]) -> dict[str, Any]:
|
||||
manifest_path = campaign_dir / "campaign.json"
|
||||
if manifest_path.exists():
|
||||
manifest = json.loads(manifest_path.read_text())
|
||||
mismatches: list[str] = []
|
||||
for key in PINNED_KEYS:
|
||||
if manifest.get(key) != settings.get(key):
|
||||
mismatches.append(f"{key}: existing={manifest.get(key)!r} current={settings.get(key)!r}")
|
||||
if mismatches:
|
||||
raise SystemExit(
|
||||
"Campaign settings drift detected. Refusing to mix incompatible runs in one campaign:\n- "
|
||||
+ "\n- ".join(mismatches)
|
||||
)
|
||||
return manifest
|
||||
|
||||
manifest = dict(settings)
|
||||
manifest["tasks_run"] = []
|
||||
manifest["notes"] = [
|
||||
"This campaign is intended to preserve coherent sequential Harbor runs for later leaderboard assembly."
|
||||
]
|
||||
manifest_path.write_text(json.dumps(manifest, indent=2) + "\n")
|
||||
return manifest
|
||||
|
||||
|
||||
def load_manifest(campaign_dir: Path) -> dict[str, Any]:
|
||||
return json.loads((campaign_dir / "campaign.json").read_text())
|
||||
|
||||
|
||||
def write_results_jsonl(campaign_dir: Path, records: list[dict[str, Any]]) -> None:
|
||||
results_jsonl = campaign_dir / "results.jsonl"
|
||||
with results_jsonl.open("w", encoding="utf-8") as f:
|
||||
for record in records:
|
||||
f.write(json.dumps(record) + "\n")
|
||||
|
||||
|
||||
def append_result(campaign_dir: Path, record: dict[str, Any]) -> None:
|
||||
manifest_path = campaign_dir / "campaign.json"
|
||||
manifest = json.loads(manifest_path.read_text())
|
||||
existing = manifest.setdefault("tasks_run", [])
|
||||
replaced = False
|
||||
for idx, item in enumerate(existing):
|
||||
if item.get("task_name") == record.get("task_name") and item.get("job_name") == record.get("job_name"):
|
||||
if item == record:
|
||||
return
|
||||
existing[idx] = record
|
||||
replaced = True
|
||||
break
|
||||
|
||||
if not replaced:
|
||||
existing.append(record)
|
||||
|
||||
manifest_path.write_text(json.dumps(manifest, indent=2) + "\n")
|
||||
write_results_jsonl(campaign_dir, existing)
|
||||
|
||||
|
||||
def collect_trial_results(job_dir: Path) -> list[dict[str, Any]]:
|
||||
trial_results: list[dict[str, Any]] = []
|
||||
for result_path in sorted(job_dir.glob("*__*/result.json")):
|
||||
payload = json.loads(result_path.read_text())
|
||||
verifier_result = payload.get("verifier_result") or {}
|
||||
rewards = verifier_result.get("rewards") or {}
|
||||
exception_info = payload.get("exception_info") or {}
|
||||
agent_result = payload.get("agent_result") or {}
|
||||
metadata = agent_result.get("metadata") or {}
|
||||
trial_results.append(
|
||||
{
|
||||
"task_name": payload["task_name"],
|
||||
"trial_name": payload["trial_name"],
|
||||
"reward": rewards.get("reward"),
|
||||
"exception_type": exception_info.get("exception_type"),
|
||||
"exception_message": exception_info.get("exception_message"),
|
||||
"agent_return_code": metadata.get("return_code"),
|
||||
"started_at": payload.get("started_at"),
|
||||
"finished_at": payload.get("finished_at"),
|
||||
"result_path": str(result_path),
|
||||
}
|
||||
)
|
||||
return trial_results
|
||||
|
||||
|
||||
def summarize_job(job_result_path: Path, trial_results: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
payload = json.loads(job_result_path.read_text())
|
||||
rewards = [trial.get("reward") for trial in trial_results]
|
||||
numeric_rewards = [r for r in rewards if isinstance(r, (int, float))]
|
||||
return {
|
||||
"job_result_path": str(job_result_path),
|
||||
"n_total_trials": payload.get("n_total_trials"),
|
||||
"job_started_at": payload.get("started_at"),
|
||||
"job_finished_at": payload.get("finished_at"),
|
||||
"trial_names": [trial["trial_name"] for trial in trial_results],
|
||||
"rewards": rewards,
|
||||
"mean_reward": (sum(numeric_rewards) / len(numeric_rewards)) if numeric_rewards else None,
|
||||
"trial_results": trial_results,
|
||||
}
|
||||
|
||||
|
||||
def has_strict_numeric_trials(record: dict[str, Any], required: int) -> bool:
|
||||
trial_results = record.get("trial_results") or []
|
||||
numeric_rewards = [
|
||||
trial.get("reward")
|
||||
for trial in trial_results
|
||||
if isinstance(trial.get("reward"), (int, float))
|
||||
]
|
||||
return len(numeric_rewards) >= required
|
||||
|
||||
|
||||
def completed_recorded_jobs(campaign_dir: Path) -> dict[str, dict[str, Any]]:
|
||||
manifest = load_manifest(campaign_dir)
|
||||
required = int(manifest.get("attempts_per_task") or 1)
|
||||
out: dict[str, dict[str, Any]] = {}
|
||||
for item in manifest.get("tasks_run", []):
|
||||
mean_reward = item.get("mean_reward")
|
||||
if (
|
||||
item.get("status") == "completed"
|
||||
and item.get("task_name")
|
||||
and isinstance(mean_reward, (int, float))
|
||||
and has_strict_numeric_trials(item, required)
|
||||
):
|
||||
out[item["task_name"]] = item
|
||||
return out
|
||||
|
||||
|
||||
def adopt_existing_job(campaign_dir: Path, task: str, task_jobs_dir: Path, required_attempts: int) -> dict[str, Any] | None:
|
||||
for job_dir in sorted([p for p in task_jobs_dir.iterdir() if p.is_dir()], reverse=True):
|
||||
job_result_path = job_dir / "result.json"
|
||||
if not job_result_path.exists():
|
||||
continue
|
||||
trial_results = collect_trial_results(job_dir)
|
||||
if not trial_results:
|
||||
continue
|
||||
numeric_rewards = [t.get("reward") for t in trial_results if isinstance(t.get("reward"), (int, float))]
|
||||
if len(numeric_rewards) < required_attempts:
|
||||
continue
|
||||
record = {
|
||||
"task_name": task,
|
||||
"job_name": job_dir.name,
|
||||
"jobs_dir": str(task_jobs_dir),
|
||||
"status": "completed",
|
||||
**summarize_job(job_result_path, trial_results),
|
||||
}
|
||||
append_result(campaign_dir, record)
|
||||
return record
|
||||
return None
|
||||
|
||||
|
||||
def build_task_command(
|
||||
*,
|
||||
runner: Path,
|
||||
task: str,
|
||||
task_jobs_dir: Path,
|
||||
job_name: str,
|
||||
args: argparse.Namespace,
|
||||
pass_through_args: list[str],
|
||||
) -> list[str]:
|
||||
cmd = [
|
||||
str(runner),
|
||||
"--include-task-name", task,
|
||||
"--n-tasks", "1",
|
||||
"--n-concurrent", "1",
|
||||
"--jobs-dir", str(task_jobs_dir),
|
||||
"--job-name", job_name,
|
||||
"--yes",
|
||||
"--timeout-multiplier", str(args.timeout_multiplier),
|
||||
"-k", str(args.n_attempts),
|
||||
]
|
||||
if args.path:
|
||||
cmd.extend(["--path", str(Path(args.path).resolve())])
|
||||
else:
|
||||
cmd.extend(["--dataset", args.dataset])
|
||||
if args.model:
|
||||
cmd.extend(["--model", args.model])
|
||||
cmd.extend(pass_through_args)
|
||||
return cmd
|
||||
|
||||
|
||||
def execute_task_process(
|
||||
*,
|
||||
runner: Path,
|
||||
task: str,
|
||||
task_jobs_dir: Path,
|
||||
job_name: str,
|
||||
args: argparse.Namespace,
|
||||
pass_through_args: list[str],
|
||||
) -> tuple[str, str, Path, int]:
|
||||
cmd = build_task_command(
|
||||
runner=runner,
|
||||
task=task,
|
||||
task_jobs_dir=task_jobs_dir,
|
||||
job_name=job_name,
|
||||
args=args,
|
||||
pass_through_args=pass_through_args,
|
||||
)
|
||||
print(f"\n=== Running task {task} as {job_name} ===", flush=True)
|
||||
env = os.environ.copy()
|
||||
env["JCODE_HARBOR_CURRENT_TASK"] = task
|
||||
proc = subprocess.run(cmd, text=True, env=env)
|
||||
return task, job_name, task_jobs_dir, proc.returncode
|
||||
|
||||
|
||||
def finalize_task_result(
|
||||
*,
|
||||
campaign_dir: Path,
|
||||
task: str,
|
||||
job_name: str,
|
||||
task_jobs_dir: Path,
|
||||
process_return_code: int,
|
||||
continue_on_failure: bool,
|
||||
required_attempts: int,
|
||||
) -> tuple[bool, dict[str, Any]]:
|
||||
job_result_path = task_jobs_dir / job_name / "result.json"
|
||||
trial_results = collect_trial_results(task_jobs_dir / job_name)
|
||||
if job_result_path.exists() and trial_results:
|
||||
numeric_rewards = [
|
||||
trial.get("reward")
|
||||
for trial in trial_results
|
||||
if isinstance(trial.get("reward"), (int, float))
|
||||
]
|
||||
task_result = {
|
||||
"task_name": task,
|
||||
"job_name": job_name,
|
||||
"jobs_dir": str(task_jobs_dir),
|
||||
"status": "completed",
|
||||
"process_return_code": process_return_code,
|
||||
**summarize_job(job_result_path, trial_results),
|
||||
}
|
||||
if isinstance(task_result.get("mean_reward"), (int, float)) and len(numeric_rewards) >= required_attempts:
|
||||
append_result(campaign_dir, task_result)
|
||||
print(
|
||||
f"Completed {task}: mean_reward={task_result['mean_reward']} trials={len(trial_results)}",
|
||||
flush=True,
|
||||
)
|
||||
return True, task_result
|
||||
|
||||
task_result["status"] = (
|
||||
"completed_with_partial_numeric_reward"
|
||||
if numeric_rewards
|
||||
else "completed_without_numeric_reward"
|
||||
)
|
||||
append_result(campaign_dir, task_result)
|
||||
if continue_on_failure:
|
||||
print(
|
||||
f"Task {task} produced {len(numeric_rewards)}/{required_attempts} numeric trial rewards; continuing.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return False, task_result
|
||||
return False, task_result
|
||||
|
||||
if process_return_code != 0 or not job_result_path.exists():
|
||||
record = {
|
||||
"task_name": task,
|
||||
"job_name": job_name,
|
||||
"status": "failed_to_produce_result",
|
||||
"return_code": process_return_code,
|
||||
"jobs_dir": str(task_jobs_dir),
|
||||
}
|
||||
append_result(campaign_dir, record)
|
||||
if continue_on_failure:
|
||||
print(f"Task {task} failed, continuing because --continue-on-failure is set.", file=sys.stderr)
|
||||
return False, record
|
||||
|
||||
if not trial_results:
|
||||
record = {
|
||||
"task_name": task,
|
||||
"job_name": job_name,
|
||||
"status": "missing_trial_results",
|
||||
"return_code": process_return_code,
|
||||
"job_result_path": str(job_result_path),
|
||||
"jobs_dir": str(task_jobs_dir),
|
||||
}
|
||||
append_result(campaign_dir, record)
|
||||
if continue_on_failure:
|
||||
print(f"Task {task} produced no per-trial results, continuing.", file=sys.stderr)
|
||||
return False, record
|
||||
|
||||
raise AssertionError("unreachable")
|
||||
|
||||
|
||||
def prepare_task(campaign_dir: Path, jobs_root: Path, task: str, required_attempts: int) -> tuple[str, Path] | None:
|
||||
recorded = completed_recorded_jobs(campaign_dir)
|
||||
if task in recorded:
|
||||
print(f"\n=== Skipping task {task}; already recorded as {recorded[task]['job_name']} ===", flush=True)
|
||||
return None
|
||||
|
||||
task_jobs_dir = jobs_root / task
|
||||
task_jobs_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
adopted = adopt_existing_job(campaign_dir, task, task_jobs_dir, required_attempts)
|
||||
if adopted is not None:
|
||||
print(
|
||||
f"\n=== Adopted existing job for {task}: {adopted['job_name']} mean_reward={adopted['mean_reward']} ===",
|
||||
flush=True,
|
||||
)
|
||||
return None
|
||||
|
||||
return task, task_jobs_dir
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Run a sequential Terminal-Bench campaign for jcode and preserve stitchable artifacts.")
|
||||
parser.add_argument("--campaign-dir", required=True, help="Persistent output directory for the campaign")
|
||||
parser.add_argument("--task", action="append", default=[], help="Task name to run. Can be passed multiple times.")
|
||||
parser.add_argument("--tasks-file", help="File with one task name per line")
|
||||
parser.add_argument("--dataset", default="terminal-bench@2.0", help="Harbor dataset name to use")
|
||||
parser.add_argument("--path", help="Local task/dataset path to use instead of --dataset")
|
||||
parser.add_argument("--model", default="openai/gpt-5.4", help="Harbor model string")
|
||||
parser.add_argument("-k", "--n-attempts", type=int, default=1, help="Attempts per task")
|
||||
parser.add_argument("--timeout-multiplier", type=float, default=1.0)
|
||||
parser.add_argument("--continue-on-failure", action="store_true", help="Continue to the next task if one task fails")
|
||||
parser.add_argument("--max-parallel-tasks", type=int, default=1, help="Maximum number of separate task jobs to run at once")
|
||||
parser.add_argument("harbor_args", nargs=argparse.REMAINDER, help="Extra args passed through after '--'")
|
||||
args = parser.parse_args()
|
||||
|
||||
root = repo_root()
|
||||
campaign_dir = Path(args.campaign_dir).expanduser().resolve()
|
||||
campaign_dir.mkdir(parents=True, exist_ok=True)
|
||||
jobs_root = campaign_dir / "harbor-jobs"
|
||||
jobs_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
tasks = load_tasks(args)
|
||||
settings = current_settings(root, args)
|
||||
ensure_manifest(campaign_dir, settings)
|
||||
|
||||
pass_through_args = list(args.harbor_args)
|
||||
if pass_through_args and pass_through_args[0] == "--":
|
||||
pass_through_args = pass_through_args[1:]
|
||||
|
||||
runner = root / "scripts" / "run_terminal_bench_harbor.sh"
|
||||
|
||||
pending: list[tuple[str, Path, str]] = []
|
||||
for task in tasks:
|
||||
prepared = prepare_task(campaign_dir, jobs_root, task, args.n_attempts)
|
||||
if prepared is None:
|
||||
continue
|
||||
task_name, task_jobs_dir = prepared
|
||||
existing_runs = [p for p in task_jobs_dir.iterdir() if p.is_dir()]
|
||||
run_index = len(existing_runs) + 1
|
||||
job_name = f"run-{run_index:03d}"
|
||||
pending.append((task_name, task_jobs_dir, job_name))
|
||||
|
||||
if not pending:
|
||||
return 0
|
||||
|
||||
max_workers = max(1, args.max_parallel_tasks)
|
||||
if max_workers == 1:
|
||||
for task, task_jobs_dir, job_name in pending:
|
||||
_, _, _, return_code = execute_task_process(
|
||||
runner=runner,
|
||||
task=task,
|
||||
task_jobs_dir=task_jobs_dir,
|
||||
job_name=job_name,
|
||||
args=args,
|
||||
pass_through_args=pass_through_args,
|
||||
)
|
||||
ok, _record = finalize_task_result(
|
||||
campaign_dir=campaign_dir,
|
||||
task=task,
|
||||
job_name=job_name,
|
||||
task_jobs_dir=task_jobs_dir,
|
||||
process_return_code=return_code,
|
||||
continue_on_failure=args.continue_on_failure,
|
||||
required_attempts=args.n_attempts,
|
||||
)
|
||||
if not ok and not args.continue_on_failure:
|
||||
return return_code or 1
|
||||
return 0
|
||||
|
||||
had_failure = False
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
future_map = {
|
||||
executor.submit(
|
||||
execute_task_process,
|
||||
runner=runner,
|
||||
task=task,
|
||||
task_jobs_dir=task_jobs_dir,
|
||||
job_name=job_name,
|
||||
args=args,
|
||||
pass_through_args=pass_through_args,
|
||||
): (task, task_jobs_dir, job_name)
|
||||
for task, task_jobs_dir, job_name in pending
|
||||
}
|
||||
for future in concurrent.futures.as_completed(future_map):
|
||||
task, task_jobs_dir, job_name = future_map[future]
|
||||
_task, _job_name, _task_jobs_dir, return_code = future.result()
|
||||
ok, _record = finalize_task_result(
|
||||
campaign_dir=campaign_dir,
|
||||
task=task,
|
||||
job_name=job_name,
|
||||
task_jobs_dir=task_jobs_dir,
|
||||
process_return_code=return_code,
|
||||
continue_on_failure=args.continue_on_failure,
|
||||
required_attempts=args.n_attempts,
|
||||
)
|
||||
if not ok:
|
||||
had_failure = True
|
||||
|
||||
return 1 if had_failure and not args.continue_on_failure else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+77
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Run Terminal-Bench through Harbor with jcode using Opus 4.8.
|
||||
# Default route is OpenRouter (anthropic/claude-opus-4.8) since native Claude
|
||||
# OAuth may be unavailable. Override with JCODE_TB_MODEL / env vars.
|
||||
|
||||
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
|
||||
REPO_ROOT=$(cd -- "$SCRIPT_DIR/.." && pwd)
|
||||
DEFAULT_BINARY_DIR=${JCODE_HARBOR_BINARY_DIR:-/tmp/jcode-compat-dist}
|
||||
DEFAULT_BINARY_PATH=${JCODE_HARBOR_BINARY:-$DEFAULT_BINARY_DIR/jcode-linux-x86_64.bin}
|
||||
DEFAULT_MODEL=${JCODE_TB_MODEL:-anthropic-api/claude-opus-4-8}
|
||||
DEFAULT_PATH=${JCODE_TB_PATH:-/tmp/terminal-bench-2.1}
|
||||
|
||||
have_model=0
|
||||
have_agent_import=0
|
||||
have_task_source=0
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--model|-m)
|
||||
have_model=1
|
||||
;;
|
||||
--agent-import-path)
|
||||
have_agent_import=1
|
||||
;;
|
||||
--path|-p|--dataset|-d|--task|-t)
|
||||
have_task_source=1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ ! -x "$DEFAULT_BINARY_PATH" ]]; then
|
||||
echo "Building Linux-compatible jcode binary into $DEFAULT_BINARY_DIR" >&2
|
||||
"$REPO_ROOT/scripts/build_linux_compat.sh" "$DEFAULT_BINARY_DIR"
|
||||
fi
|
||||
|
||||
# Resolve provider keys from jcode's env files if not already set.
|
||||
if [[ -z "${OPENROUTER_API_KEY:-}" ]]; then
|
||||
OR_ENV=${JCODE_HARBOR_OPENROUTER_ENV:-$HOME/.config/jcode/openrouter.env}
|
||||
if [[ -f "$OR_ENV" ]]; then
|
||||
export JCODE_HARBOR_OPENROUTER_ENV="$OR_ENV"
|
||||
fi
|
||||
fi
|
||||
if [[ -z "${ANTHROPIC_API_KEY:-}" ]]; then
|
||||
ANT_ENV=${JCODE_HARBOR_ANTHROPIC_ENV:-$HOME/.config/jcode/anthropic.env}
|
||||
if [[ -f "$ANT_ENV" ]]; then
|
||||
export JCODE_HARBOR_ANTHROPIC_ENV="$ANT_ENV"
|
||||
fi
|
||||
fi
|
||||
|
||||
export PYTHONPATH="$REPO_ROOT/scripts${PYTHONPATH:+:$PYTHONPATH}"
|
||||
export JCODE_HARBOR_BINARY="$DEFAULT_BINARY_PATH"
|
||||
export JCODE_ANTHROPIC_REASONING_EFFORT=${JCODE_ANTHROPIC_REASONING_EFFORT:-high}
|
||||
export JCODE_NO_TELEMETRY=${JCODE_NO_TELEMETRY:-1}
|
||||
|
||||
HARBOR_BIN=${JCODE_HARBOR_BIN:-harbor}
|
||||
|
||||
cmd=($HARBOR_BIN run)
|
||||
if [[ $have_task_source -eq 0 ]]; then
|
||||
cmd+=(--path "$DEFAULT_PATH")
|
||||
fi
|
||||
if [[ $have_agent_import -eq 0 ]]; then
|
||||
cmd+=(--agent-import-path jcode_harbor_claude_agent:JcodeClaudeHarborAgent)
|
||||
fi
|
||||
if [[ $have_model -eq 0 ]]; then
|
||||
cmd+=(--model "$DEFAULT_MODEL")
|
||||
fi
|
||||
cmd+=("$@")
|
||||
|
||||
{
|
||||
echo "Running Harbor with jcode Opus 4.8 adapter"
|
||||
echo " binary: $JCODE_HARBOR_BINARY"
|
||||
echo " model: ${DEFAULT_MODEL}"
|
||||
} >&2
|
||||
|
||||
exec "${cmd[@]}"
|
||||
Executable
+76
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
|
||||
REPO_ROOT=$(cd -- "$SCRIPT_DIR/.." && pwd)
|
||||
DEFAULT_BINARY_DIR=${JCODE_HARBOR_BINARY_DIR:-/tmp/jcode-compat-dist}
|
||||
DEFAULT_BINARY_PATH=${JCODE_HARBOR_BINARY:-$DEFAULT_BINARY_DIR/jcode-linux-x86_64}
|
||||
DEFAULT_MODEL=${JCODE_TB_MODEL:-openai/gpt-5.4}
|
||||
DEFAULT_PATH=${JCODE_TB_PATH:-/tmp/terminal-bench-2}
|
||||
|
||||
have_model=0
|
||||
have_agent_import=0
|
||||
have_task_source=0
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--model|-m)
|
||||
have_model=1
|
||||
;;
|
||||
--agent-import-path)
|
||||
have_agent_import=1
|
||||
;;
|
||||
--path|-p|--dataset|-d|--task|-t)
|
||||
have_task_source=1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ ! -x "$DEFAULT_BINARY_PATH" ]]; then
|
||||
echo "Building Linux-compatible jcode binary into $DEFAULT_BINARY_DIR" >&2
|
||||
"$REPO_ROOT/scripts/build_linux_compat.sh" "$DEFAULT_BINARY_DIR"
|
||||
fi
|
||||
|
||||
OPENAI_AUTH=${JCODE_HARBOR_OPENAI_AUTH:-$HOME/.jcode/openai-auth.json}
|
||||
if [[ ! -f "$OPENAI_AUTH" ]]; then
|
||||
echo "OpenAI OAuth file not found at $OPENAI_AUTH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export PYTHONPATH="$REPO_ROOT/scripts${PYTHONPATH:+:$PYTHONPATH}"
|
||||
export JCODE_HARBOR_BINARY="$DEFAULT_BINARY_PATH"
|
||||
export JCODE_HARBOR_OPENAI_AUTH="$OPENAI_AUTH"
|
||||
export JCODE_OPENAI_REASONING_EFFORT=${JCODE_OPENAI_REASONING_EFFORT:-high}
|
||||
export JCODE_OPENAI_SERVICE_TIER=${JCODE_OPENAI_SERVICE_TIER:-priority}
|
||||
export JCODE_NO_TELEMETRY=${JCODE_NO_TELEMETRY:-1}
|
||||
|
||||
HARBOR_BIN=${JCODE_HARBOR_BIN:-}
|
||||
if [[ -z "$HARBOR_BIN" ]]; then
|
||||
CACHED_HARBOR="$HOME/.cache/uv/archive-v0/qtLT-I4hA5Q9ne5Zq-5cn/bin/harbor"
|
||||
if [[ -x "$CACHED_HARBOR" ]]; then
|
||||
HARBOR_BIN="$CACHED_HARBOR"
|
||||
else
|
||||
HARBOR_BIN="uvx --offline harbor"
|
||||
fi
|
||||
fi
|
||||
|
||||
cmd=($HARBOR_BIN run)
|
||||
if [[ $have_task_source -eq 0 ]]; then
|
||||
cmd+=(--path "$DEFAULT_PATH")
|
||||
fi
|
||||
if [[ $have_agent_import -eq 0 ]]; then
|
||||
cmd+=(--agent-import-path jcode_harbor_agent:JcodeHarborAgent)
|
||||
fi
|
||||
if [[ $have_model -eq 0 ]]; then
|
||||
cmd+=(--model "$DEFAULT_MODEL")
|
||||
fi
|
||||
cmd+=("$@")
|
||||
|
||||
{
|
||||
echo "Running Harbor with jcode adapter"
|
||||
echo " binary: $JCODE_HARBOR_BINARY"
|
||||
echo " auth: $JCODE_HARBOR_OPENAI_AUTH"
|
||||
echo " model: ${DEFAULT_MODEL}"
|
||||
} >&2
|
||||
|
||||
exec "${cmd[@]}"
|
||||
Executable
+88
@@ -0,0 +1,88 @@
|
||||
#!/bin/bash
|
||||
# Screenshot Watcher - monitors for jcode screenshot signals
|
||||
#
|
||||
# This script watches the signal directory and captures screenshots
|
||||
# when jcode signals that a specific UI state is ready.
|
||||
#
|
||||
# Usage: ./screenshot_watcher.sh [window_id]
|
||||
#
|
||||
# Start jcode with: /screenshot-mode on
|
||||
|
||||
set -e
|
||||
|
||||
SIGNAL_DIR="${XDG_RUNTIME_DIR:-/tmp}/jcode-screenshots"
|
||||
OUTPUT_DIR="$(dirname "$0")/../docs/screenshots"
|
||||
WINDOW_ID="${1:-}"
|
||||
|
||||
mkdir -p "$OUTPUT_DIR" "$SIGNAL_DIR"
|
||||
|
||||
# Get window ID if not provided
|
||||
if [ -z "$WINDOW_ID" ]; then
|
||||
echo "Tip: Pass window ID as argument, or we'll use focused window for each capture"
|
||||
fi
|
||||
|
||||
echo "🎬 Screenshot Watcher"
|
||||
echo " Signal dir: $SIGNAL_DIR"
|
||||
echo " Output dir: $OUTPUT_DIR"
|
||||
echo " Window ID: ${WINDOW_ID:-<focused>}"
|
||||
echo ""
|
||||
echo "Waiting for signals... (Ctrl+C to stop)"
|
||||
echo "Enable in jcode with: /screenshot-mode on"
|
||||
echo ""
|
||||
|
||||
capture_signal() {
|
||||
local file="$1"
|
||||
local state_name="${file%.ready}"
|
||||
local signal_path="$SIGNAL_DIR/$file"
|
||||
local output_path="$OUTPUT_DIR/${state_name}.png"
|
||||
|
||||
echo "📸 Signal: $state_name"
|
||||
|
||||
# Read metadata from signal file
|
||||
if [ -f "$signal_path" ]; then
|
||||
cat "$signal_path" | jq . 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Small delay to ensure UI is fully rendered
|
||||
sleep 0.15
|
||||
|
||||
# Focus window if ID provided, otherwise use current focus
|
||||
if [ -n "$WINDOW_ID" ]; then
|
||||
niri msg action focus-window --id "$WINDOW_ID"
|
||||
sleep 0.1
|
||||
fi
|
||||
|
||||
# Capture screenshot
|
||||
niri msg action screenshot-window --path "$output_path"
|
||||
|
||||
# Clear the signal
|
||||
rm -f "$signal_path"
|
||||
|
||||
echo " ✅ Saved: $output_path"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Try inotifywait first, fall back to polling
|
||||
if command -v inotifywait &>/dev/null; then
|
||||
echo "Using inotifywait for efficient watching..."
|
||||
inotifywait -m -e create -e modify "$SIGNAL_DIR" 2>/dev/null | while read -r dir event file; do
|
||||
if [[ "$file" == *.ready ]]; then
|
||||
capture_signal "$file"
|
||||
fi
|
||||
done
|
||||
else
|
||||
echo "Using polling (install inotify-tools for better performance)..."
|
||||
SEEN_FILES=""
|
||||
shopt -s nullglob
|
||||
while true; do
|
||||
for file in "$SIGNAL_DIR"/*.ready; do
|
||||
[ -e "$file" ] || continue
|
||||
basename_file=$(basename "$file")
|
||||
if [[ ! " $SEEN_FILES " =~ " $basename_file " ]]; then
|
||||
capture_signal "$basename_file"
|
||||
SEEN_FILES="$SEEN_FILES $basename_file"
|
||||
fi
|
||||
done
|
||||
sleep 0.2
|
||||
done
|
||||
fi
|
||||
Executable
+122
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
strict=0
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage:
|
||||
scripts/security_preflight.sh [--strict]
|
||||
|
||||
Checks:
|
||||
1) Secret-pattern scan in tracked source/docs/scripts
|
||||
2) World-writable file check under scripts/
|
||||
3) Rust dependency advisory scan via cargo-audit (when available)
|
||||
|
||||
Options:
|
||||
--strict Fail if cargo-audit is not installed
|
||||
USAGE
|
||||
}
|
||||
|
||||
die() {
|
||||
printf 'error: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--strict)
|
||||
strict=1
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
die "unknown option: $1"
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
cd "$repo_root"
|
||||
|
||||
echo "=== Security Preflight ==="
|
||||
|
||||
echo "[1/3] Scanning for likely secrets"
|
||||
secret_regex='(AKIA[0-9A-Z]{16}|ASIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9]{36,}|xox[baprs]-[A-Za-z0-9-]{10,}|-----BEGIN (RSA|OPENSSH|EC|DSA|PGP) PRIVATE KEY-----|AIza[0-9A-Za-z_-]{35})'
|
||||
|
||||
set +e
|
||||
mapfile -d '' tracked_files < <(git ls-files -z)
|
||||
scan_status=1
|
||||
if [[ "${#tracked_files[@]}" -gt 0 ]]; then
|
||||
if command -v rg >/dev/null 2>&1; then
|
||||
rg -n --color=never -e "$secret_regex" \
|
||||
--glob '!Cargo.lock' --glob '!*.snap' --glob '!*.png' --glob '!*.jpg' --glob '!*.jpeg' \
|
||||
--glob '!*.gif' --glob '!*.svg' --glob '!*.pdf' --glob '!*.woff' --glob '!*.woff2' --glob '!*.ttf' \
|
||||
"${tracked_files[@]}" > /tmp/jcode-secret-scan.txt
|
||||
scan_status=$?
|
||||
else
|
||||
scan_files=()
|
||||
for tracked_file in "${tracked_files[@]}"; do
|
||||
case "$tracked_file" in
|
||||
Cargo.lock|*.snap|*.png|*.jpg|*.jpeg|*.gif|*.svg|*.pdf|*.woff|*.woff2|*.ttf)
|
||||
;;
|
||||
*)
|
||||
scan_files+=("$tracked_file")
|
||||
;;
|
||||
esac
|
||||
done
|
||||
if [[ "${#scan_files[@]}" -gt 0 ]]; then
|
||||
grep -I -n -E "$secret_regex" "${scan_files[@]}" > /tmp/jcode-secret-scan.txt
|
||||
scan_status=$?
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
set -e
|
||||
|
||||
if [[ "$scan_status" -gt 1 ]]; then
|
||||
rm -f /tmp/jcode-secret-scan.txt
|
||||
die "secret scan failed to execute"
|
||||
fi
|
||||
|
||||
if [[ -s /tmp/jcode-secret-scan.txt ]]; then
|
||||
cat /tmp/jcode-secret-scan.txt
|
||||
rm -f /tmp/jcode-secret-scan.txt
|
||||
die "potential secret material detected"
|
||||
fi
|
||||
rm -f /tmp/jcode-secret-scan.txt
|
||||
|
||||
echo "[2/3] Checking script permissions"
|
||||
if find scripts -type f -perm -0002 -print -quit | grep -q .; then
|
||||
find scripts -type f -perm -0002 -print
|
||||
die "world-writable files detected under scripts/"
|
||||
fi
|
||||
|
||||
echo "[3/3] Dependency advisories (cargo-audit)"
|
||||
audit_ignores=(
|
||||
# Documented in docs/SECURITY_DEPENDENCIES.md. These are transitive
|
||||
# advisories with tracked remediation paths; keep them visible in the triage
|
||||
# doc while preventing unrelated CI/release work from being blocked.
|
||||
--ignore RUSTSEC-2026-0141 # lettre via notify-email, Boring TLS backend not used by jcode
|
||||
--ignore RUSTSEC-2026-0099 # rustls-webpki via rustls stack, awaiting upstream upgrade
|
||||
--ignore RUSTSEC-2026-0104 # rustls-webpki via rustls stack, awaiting upstream upgrade
|
||||
--ignore RUSTSEC-2026-0098 # rustls-webpki via rustls stack, awaiting upstream upgrade
|
||||
--ignore RUSTSEC-2026-0049 # rustls-webpki via rustls stack, awaiting upstream upgrade
|
||||
--ignore RUSTSEC-2026-0187 # lopdf via pdf-extract 0.8.2 (pins lopdf 0.34); PDF text extraction only, awaiting pdf-extract upgrade to lopdf >=0.42
|
||||
--ignore RUSTSEC-2026-0194 # quick-xml via wayland-scanner (proc-macro); parses trusted Wayland protocol XML at build time only, never untrusted input at runtime
|
||||
--ignore RUSTSEC-2026-0195 # quick-xml via wayland-scanner (proc-macro); same build-time-only exposure as RUSTSEC-2026-0194
|
||||
)
|
||||
if command -v cargo-audit >/dev/null 2>&1; then
|
||||
cargo audit "${audit_ignores[@]}"
|
||||
elif cargo audit --version >/dev/null 2>&1; then
|
||||
cargo audit "${audit_ignores[@]}"
|
||||
else
|
||||
if [[ "$strict" -eq 1 ]]; then
|
||||
die "cargo-audit is not installed (install with: cargo install cargo-audit --locked)"
|
||||
fi
|
||||
echo "warning: cargo-audit not installed; skipping advisory check"
|
||||
fi
|
||||
|
||||
echo "=== Security preflight passed ==="
|
||||
Executable
+157
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env bash
|
||||
# Live end-to-end sandbox for the "current client, stale older server" fix.
|
||||
#
|
||||
# Server: the REAL released v0.14.6 binary (downloaded from GitHub).
|
||||
# Client: the freshly built current binary (target/debug/jcode, has the fix).
|
||||
# Field state: shared-server channel pinned to OLD (v0.14.6); stable -> NEW.
|
||||
#
|
||||
# It starts the real old daemon, then runs the NEW client's `jcode server reload`
|
||||
# (which repairs the stale shared-server channel, then forces a reload). PASS iff
|
||||
# the resulting daemon is running v0.22.x.
|
||||
#
|
||||
# Usage:
|
||||
# cargo build -p jcode --bin jcode
|
||||
# scripts/stale_server_upgrade_sandbox.sh
|
||||
#
|
||||
# Linux x86_64 only (uses the published jcode-linux-x86_64 release asset).
|
||||
set -uo pipefail
|
||||
|
||||
REPO_ROOT="$(cd -- "$(dirname -- "$0")/.." && pwd)"
|
||||
NEW_BIN="${NEW_BIN:-$REPO_ROOT/target/debug/jcode}"
|
||||
OLD_VERSION="${OLD_VERSION:-v0.14.6}"
|
||||
OLD_DIR="${OLD_DIR:-/tmp/jcode-sandbox}"
|
||||
OLD_WRAP="$OLD_DIR/jcode-linux-x86_64"
|
||||
|
||||
[ -x "$NEW_BIN" ] || { echo "missing new client binary: $NEW_BIN (run: cargo build -p jcode --bin jcode)"; exit 2; }
|
||||
|
||||
# Fetch + extract the real old release binary if it is not already present.
|
||||
if [ ! -x "$OLD_WRAP" ]; then
|
||||
mkdir -p "$OLD_DIR"
|
||||
url="$(curl -fsSL "https://api.github.com/repos/1jehuang/jcode/releases/tags/$OLD_VERSION" \
|
||||
| grep -o 'https://[^"]*jcode-linux-x86_64.tar.gz' | head -1)"
|
||||
[ -n "$url" ] || { echo "could not resolve $OLD_VERSION linux asset URL"; exit 2; }
|
||||
echo "Downloading old server $OLD_VERSION ..."
|
||||
curl -fsSL "$url" -o "$OLD_DIR/old.tar.gz"
|
||||
tar -C "$OLD_DIR" -xzf "$OLD_DIR/old.tar.gz"
|
||||
fi
|
||||
[ -x "$OLD_WRAP" ] || { echo "missing old binary $OLD_WRAP after download"; exit 2; }
|
||||
|
||||
SANDBOX="$(mktemp -d /tmp/jcode-stale-sandbox.XXXXXX)"
|
||||
export JCODE_HOME="$SANDBOX/home"
|
||||
export JCODE_RUNTIME_DIR="$SANDBOX/runtime"
|
||||
# Hard isolation: pin the socket explicitly so we can NEVER touch the real
|
||||
# global daemon at /run/user/<uid>/jcode.sock.
|
||||
export JCODE_SOCKET="$SANDBOX/runtime/jcode.sock"
|
||||
# Make the new client's clean release version comparable (debug build is dirty).
|
||||
export JCODE_TEST_CLIENT_VERSION_OVERRIDE="v0.22.0 (sandbox)"
|
||||
mkdir -p "$JCODE_HOME" "$JCODE_RUNTIME_DIR"
|
||||
|
||||
BUILDS="$JCODE_HOME/builds"
|
||||
mkdir -p "$BUILDS/versions/0.14.6" "$BUILDS/versions/0.22.0" \
|
||||
"$BUILDS/shared-server" "$BUILDS/stable" "$BUILDS/current"
|
||||
|
||||
log() { printf '\n=== %s ===\n' "$*"; }
|
||||
|
||||
# --- Install the OLD binary (with bundled libs) as version 0.14.6 ----------
|
||||
cp "$OLD_DIR/jcode-linux-x86_64.bin" "$OLD_DIR/libssl.so.10" \
|
||||
"$OLD_DIR/libcrypto.so.10" "$BUILDS/versions/0.14.6/"
|
||||
cat > "$BUILDS/versions/0.14.6/jcode" <<'WRAP'
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
real=$0
|
||||
if command -v readlink >/dev/null 2>&1; then
|
||||
resolved=$(readlink -f -- "$0" 2>/dev/null || true)
|
||||
[ -n "$resolved" ] && real=$resolved
|
||||
fi
|
||||
self_dir=$(CDPATH= cd -- "$(dirname -- "$real")" && pwd)
|
||||
export LD_LIBRARY_PATH="$self_dir:${LD_LIBRARY_PATH:-}"
|
||||
exec "$self_dir/jcode-linux-x86_64.bin" "$@"
|
||||
WRAP
|
||||
chmod +x "$BUILDS/versions/0.14.6/jcode"
|
||||
|
||||
# --- Install the NEW binary as version 0.22.0 (newer mtime) ----------------
|
||||
cp "$NEW_BIN" "$BUILDS/versions/0.22.0/jcode"
|
||||
touch -d "+1 minute" "$BUILDS/versions/0.22.0/jcode"
|
||||
|
||||
# --- Field state: shared-server -> OLD, stable/current -> NEW --------------
|
||||
ln -sf "../versions/0.14.6/jcode" "$BUILDS/shared-server/jcode"
|
||||
echo "0.14.6" > "$BUILDS/shared-server-version"
|
||||
ln -sf "../versions/0.22.0/jcode" "$BUILDS/stable/jcode"
|
||||
echo "0.22.0" > "$BUILDS/stable-version"
|
||||
ln -sf "../versions/0.22.0/jcode" "$BUILDS/current/jcode"
|
||||
echo "0.22.0" > "$BUILDS/current-version"
|
||||
|
||||
log "Initial channel state (the field bug: shared-server pinned to OLD)"
|
||||
echo "shared-server-version: $(cat "$BUILDS/shared-server-version")"
|
||||
echo "stable-version: $(cat "$BUILDS/stable-version")"
|
||||
|
||||
SERVER_PID=""
|
||||
cleanup() {
|
||||
[ -n "$SERVER_PID" ] && kill "$SERVER_PID" 2>/dev/null || true
|
||||
"$NEW_BIN" --no-update server stop >/dev/null 2>&1 || true
|
||||
pkill -f "$BUILDS/versions/0.14.6/jcode-linux-x86_64.bin" 2>/dev/null || true
|
||||
pkill -f "$BUILDS/versions/0.22.0/jcode" 2>/dev/null || true
|
||||
rm -rf "$SANDBOX"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
server_version_via_socket() {
|
||||
# Ask the running daemon (via the new client's debug surface) its version.
|
||||
"$NEW_BIN" --no-update debug server:info 2>/dev/null \
|
||||
| grep -oE '"version":[[:space:]]*"[^"]*"' | head -1
|
||||
}
|
||||
|
||||
# --- 1) Start the REAL old v0.14.6 daemon ----------------------------------
|
||||
log "Starting OLD v0.14.6 daemon"
|
||||
"$BUILDS/shared-server/jcode" --no-update --provider antigravity serve \
|
||||
>"$SANDBOX/server.log" 2>&1 &
|
||||
SERVER_PID=$!
|
||||
# Wait for the socket to appear.
|
||||
for _ in $(seq 1 40); do
|
||||
[ -S "$JCODE_SOCKET" ] && break
|
||||
sleep 0.25
|
||||
done
|
||||
sleep 1
|
||||
echo "old daemon pid=$SERVER_PID"
|
||||
echo "server.log tail:"; tail -8 "$SANDBOX/server.log" 2>/dev/null || true
|
||||
BEFORE="$(server_version_via_socket)"
|
||||
echo "server version BEFORE (via socket): ${BEFORE:-<none>}"
|
||||
|
||||
# --- 2) New client: jcode server reload (repairs channel, then reloads) ----
|
||||
log "Running NEW client: jcode server reload"
|
||||
"$NEW_BIN" --no-update server reload 2>&1 | sed 's/^/[server reload] /' || true
|
||||
echo "shared-server-version after repair: $(cat "$BUILDS/shared-server-version")"
|
||||
|
||||
# Give the handoff a moment.
|
||||
for _ in $(seq 1 40); do
|
||||
[ -S "$JCODE_SOCKET" ] && break
|
||||
sleep 0.25
|
||||
done
|
||||
sleep 2
|
||||
|
||||
# --- 3) Verify the running daemon is now v0.22.x ---------------------------
|
||||
AFTER="$(server_version_via_socket)"
|
||||
echo "server version AFTER (via socket): ${AFTER:-<none>}"
|
||||
echo "server.log tail (post-reload):"; tail -8 "$SANDBOX/server.log" 2>/dev/null || true
|
||||
|
||||
log "RESULT"
|
||||
echo "shared-server-version: before=0.14.6 after=$(cat "$BUILDS/shared-server-version")"
|
||||
echo "server version: before=${BEFORE:-?} after=${AFTER:-?}"
|
||||
|
||||
ok_channel=0
|
||||
[ "$(cat "$BUILDS/shared-server-version")" = "0.22.0" ] && ok_channel=1
|
||||
|
||||
ok_server=0
|
||||
echo "${AFTER:-}" | grep -q "0.22" && ok_server=1
|
||||
|
||||
if [ "$ok_channel" = 1 ] && [ "$ok_server" = 1 ]; then
|
||||
echo "PASS: new client repaired the channel AND the stale server upgraded to v0.22"
|
||||
exit 0
|
||||
elif [ "$ok_channel" = 1 ]; then
|
||||
echo "PARTIAL: channel repaired to 0.22.0, but server version probe inconclusive (AFTER=${AFTER:-none})"
|
||||
echo " (channel repair is the load-bearing fix; server exec depends on old daemon handoff)"
|
||||
exit 0
|
||||
else
|
||||
echo "FAIL: channel was not repaired"
|
||||
exit 1
|
||||
fi
|
||||
Executable
+420
@@ -0,0 +1,420 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
jcode Stress Test: Spawn 40 sessions via debug socket, measure performance.
|
||||
|
||||
Tests:
|
||||
1. Session creation throughput
|
||||
2. Memory growth per session
|
||||
3. FD leak detection
|
||||
4. Socket responsiveness under load
|
||||
5. Message handling under load (optional)
|
||||
6. Session cleanup / resource recovery
|
||||
"""
|
||||
|
||||
import socket
|
||||
import json
|
||||
import time
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import signal
|
||||
|
||||
NUM_INSTANCES = int(sys.argv[1]) if len(sys.argv) > 1 else 40
|
||||
MAIN_SOCK = f"/run/user/{os.getuid()}/jcode.sock"
|
||||
DEBUG_SOCK = f"/run/user/{os.getuid()}/jcode-debug.sock"
|
||||
|
||||
class Colors:
|
||||
BOLD = "\033[1m"
|
||||
GREEN = "\033[32m"
|
||||
YELLOW = "\033[33m"
|
||||
RED = "\033[31m"
|
||||
CYAN = "\033[36m"
|
||||
DIM = "\033[2m"
|
||||
RESET = "\033[0m"
|
||||
|
||||
def debug_cmd(cmd, timeout=10):
|
||||
"""Send a debug command and return parsed response."""
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
sock.settimeout(timeout)
|
||||
try:
|
||||
sock.connect(DEBUG_SOCK)
|
||||
req = {"type": "debug_command", "id": 1, "command": cmd}
|
||||
sock.send((json.dumps(req) + "\n").encode())
|
||||
|
||||
# Read until we get a complete JSON response
|
||||
buf = b""
|
||||
while True:
|
||||
chunk = sock.recv(65536)
|
||||
if not chunk:
|
||||
break
|
||||
buf += chunk
|
||||
try:
|
||||
return json.loads(buf.decode())
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
def get_server_pid():
|
||||
"""Get the jcode server PID."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["lsof", "-U", "-a", "-c", "jcode"],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
for line in result.stdout.splitlines():
|
||||
if "jcode.sock" in line and "LISTEN" in line:
|
||||
parts = line.split()
|
||||
return int(parts[1])
|
||||
except:
|
||||
pass
|
||||
|
||||
# Fallback: find the oldest jcode process (likely the server)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["pgrep", "-o", "jcode"], capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if result.stdout.strip():
|
||||
return int(result.stdout.strip().splitlines()[0])
|
||||
except:
|
||||
pass
|
||||
return None
|
||||
|
||||
def proc_stat(pid):
|
||||
"""Get process stats from /proc."""
|
||||
stats = {}
|
||||
try:
|
||||
with open(f"/proc/{pid}/status") as f:
|
||||
for line in f:
|
||||
if line.startswith("VmRSS:"):
|
||||
stats["rss_kb"] = int(line.split()[1])
|
||||
elif line.startswith("VmSize:"):
|
||||
stats["vms_kb"] = int(line.split()[1])
|
||||
elif line.startswith("Threads:"):
|
||||
stats["threads"] = int(line.split()[1])
|
||||
stats["fds"] = len(os.listdir(f"/proc/{pid}/fd"))
|
||||
except:
|
||||
pass
|
||||
return stats
|
||||
|
||||
def fmt_kb(kb):
|
||||
if kb > 1024*1024:
|
||||
return f"{kb/1024/1024:.1f}GB"
|
||||
elif kb > 1024:
|
||||
return f"{kb/1024:.1f}MB"
|
||||
return f"{kb}KB"
|
||||
|
||||
def print_header(text):
|
||||
print(f"\n{Colors.BOLD}{Colors.CYAN}{'='*60}")
|
||||
print(f" {text}")
|
||||
print(f"{'='*60}{Colors.RESET}\n")
|
||||
|
||||
def print_section(text):
|
||||
print(f"\n{Colors.BOLD}--- {text} ---{Colors.RESET}")
|
||||
|
||||
def print_ok(text):
|
||||
print(f" {Colors.GREEN}✅ {text}{Colors.RESET}")
|
||||
|
||||
def print_warn(text):
|
||||
print(f" {Colors.YELLOW}⚠️ {text}{Colors.RESET}")
|
||||
|
||||
def print_err(text):
|
||||
print(f" {Colors.RED}❌ {text}{Colors.RESET}")
|
||||
|
||||
def print_stat(label, value):
|
||||
print(f" {label}: {Colors.BOLD}{value}{Colors.RESET}")
|
||||
|
||||
# ============================================================
|
||||
# MAIN
|
||||
# ============================================================
|
||||
|
||||
print_header(f"jcode Stress Test: {NUM_INSTANCES} sessions")
|
||||
|
||||
# --- Pre-flight ---
|
||||
print_section("Pre-flight checks")
|
||||
|
||||
if not os.path.exists(MAIN_SOCK):
|
||||
print_err(f"No server socket at {MAIN_SOCK}")
|
||||
print(" Start a server with: jcode serve &")
|
||||
sys.exit(1)
|
||||
|
||||
if not os.path.exists(DEBUG_SOCK):
|
||||
print_err(f"No debug socket at {DEBUG_SOCK}")
|
||||
print(" Enable with: touch ~/.jcode/debug_control")
|
||||
sys.exit(1)
|
||||
|
||||
# Test connectivity
|
||||
t0 = time.monotonic()
|
||||
state = debug_cmd("state")
|
||||
t1 = time.monotonic()
|
||||
if "error" in state:
|
||||
print_err(f"Debug socket error: {state['error']}")
|
||||
sys.exit(1)
|
||||
print_ok(f"Debug socket responding ({(t1-t0)*1000:.0f}ms)")
|
||||
|
||||
server_pid = get_server_pid()
|
||||
if server_pid:
|
||||
print_ok(f"Server PID: {server_pid}")
|
||||
else:
|
||||
print_warn("Could not determine server PID")
|
||||
|
||||
# --- Baseline ---
|
||||
print_section("Baseline measurements")
|
||||
|
||||
baseline = proc_stat(server_pid) if server_pid else {}
|
||||
baseline_procs = int(subprocess.run(["pgrep", "-c", "jcode"], capture_output=True, text=True).stdout.strip() or "0")
|
||||
|
||||
print_stat("Server RSS", fmt_kb(baseline.get("rss_kb", 0)))
|
||||
print_stat("Server VMS", fmt_kb(baseline.get("vms_kb", 0)))
|
||||
print_stat("Server FDs", baseline.get("fds", "?"))
|
||||
print_stat("Server threads", baseline.get("threads", "?"))
|
||||
print_stat("Total jcode procs", baseline_procs)
|
||||
|
||||
# List existing sessions
|
||||
sessions_resp = debug_cmd("sessions")
|
||||
try:
|
||||
existing_sessions = json.loads(sessions_resp.get("output", "[]"))
|
||||
print_stat("Existing sessions", len(existing_sessions))
|
||||
except:
|
||||
existing_sessions = []
|
||||
print_stat("Existing sessions", "?")
|
||||
|
||||
# --- Phase 1: Rapid session creation ---
|
||||
print_section(f"Phase 1: Creating {NUM_INSTANCES} sessions")
|
||||
|
||||
created_sessions = []
|
||||
create_times = []
|
||||
create_errors = []
|
||||
per_session_stats = []
|
||||
|
||||
for i in range(1, NUM_INSTANCES + 1):
|
||||
t0 = time.monotonic()
|
||||
resp = debug_cmd(f"create_session:/tmp/jcode-stress-{i}", timeout=30)
|
||||
t1 = time.monotonic()
|
||||
elapsed_ms = (t1 - t0) * 1000
|
||||
create_times.append(elapsed_ms)
|
||||
|
||||
if resp.get("ok"):
|
||||
try:
|
||||
session_data = json.loads(resp["output"])
|
||||
session_id = session_data.get("session_id", "")
|
||||
created_sessions.append(session_id)
|
||||
except:
|
||||
created_sessions.append(f"unknown_{i}")
|
||||
create_errors.append((i, "Failed to parse session ID"))
|
||||
else:
|
||||
create_errors.append((i, resp.get("error", resp.get("output", "unknown"))))
|
||||
|
||||
# Progress + snapshot every 10
|
||||
if i % 10 == 0 or i == NUM_INSTANCES:
|
||||
stats = proc_stat(server_pid) if server_pid else {}
|
||||
per_session_stats.append((i, stats.copy()))
|
||||
rss = fmt_kb(stats.get("rss_kb", 0))
|
||||
fds = stats.get("fds", "?")
|
||||
threads = stats.get("threads", "?")
|
||||
avg_ms = sum(create_times[-10:]) / len(create_times[-10:])
|
||||
print(f" [{i:3d}/{NUM_INSTANCES}] avg_create={avg_ms:.0f}ms rss={rss} fds={fds} threads={threads} sessions_ok={len(created_sessions)}")
|
||||
elif i % 5 == 0:
|
||||
sys.stdout.write(".")
|
||||
sys.stdout.flush()
|
||||
|
||||
if create_errors:
|
||||
print_warn(f"{len(create_errors)} creation errors:")
|
||||
for idx, err in create_errors[:5]:
|
||||
print(f" Instance {idx}: {err}")
|
||||
if len(create_errors) > 5:
|
||||
print(f" ... and {len(create_errors) - 5} more")
|
||||
else:
|
||||
print_ok(f"All {NUM_INSTANCES} sessions created successfully")
|
||||
|
||||
# --- Phase 2: Socket responsiveness under load ---
|
||||
print_section("Phase 2: Socket responsiveness with all sessions active")
|
||||
|
||||
socket_times = []
|
||||
for probe in range(1, 11):
|
||||
t0 = time.monotonic()
|
||||
resp = debug_cmd("state", timeout=15)
|
||||
t1 = time.monotonic()
|
||||
elapsed_ms = (t1 - t0) * 1000
|
||||
socket_times.append(elapsed_ms)
|
||||
|
||||
if socket_times:
|
||||
print_stat("Debug cmd min", f"{min(socket_times):.0f}ms")
|
||||
print_stat("Debug cmd max", f"{max(socket_times):.0f}ms")
|
||||
print_stat("Debug cmd avg", f"{sum(socket_times)/len(socket_times):.0f}ms")
|
||||
if max(socket_times) > 1000:
|
||||
print_warn(f"Socket latency exceeded 1s ({max(socket_times):.0f}ms)")
|
||||
|
||||
# List sessions under load
|
||||
t0 = time.monotonic()
|
||||
sessions_resp = debug_cmd("sessions", timeout=30)
|
||||
t1 = time.monotonic()
|
||||
try:
|
||||
all_sessions = json.loads(sessions_resp.get("output", "[]"))
|
||||
print_stat("Sessions list time", f"{(t1-t0)*1000:.0f}ms ({len(all_sessions)} sessions)")
|
||||
except:
|
||||
print_stat("Sessions list time", f"{(t1-t0)*1000:.0f}ms (parse error)")
|
||||
|
||||
# --- Phase 3: Send a message to a few sessions ---
|
||||
print_section("Phase 3: Message handling under load (5 sessions)")
|
||||
|
||||
message_times = []
|
||||
for idx, sid in enumerate(created_sessions[:5]):
|
||||
t0 = time.monotonic()
|
||||
# Use tool execution instead of message (avoids LLM call)
|
||||
resp = debug_cmd(f"tool:bash {{\"command\":\"echo stress_test_{idx}\"}}", timeout=30)
|
||||
t1 = time.monotonic()
|
||||
elapsed_ms = (t1 - t0) * 1000
|
||||
message_times.append(elapsed_ms)
|
||||
status = "ok" if resp.get("ok") else "err"
|
||||
print(f" Session {idx+1}: tool exec {elapsed_ms:.0f}ms ({status})")
|
||||
|
||||
if message_times:
|
||||
print_stat("Tool exec avg", f"{sum(message_times)/len(message_times):.0f}ms")
|
||||
|
||||
# --- Phase 4: Peak resource measurement ---
|
||||
print_section("Phase 4: Peak resource usage")
|
||||
|
||||
peak = proc_stat(server_pid) if server_pid else {}
|
||||
peak_procs = int(subprocess.run(["pgrep", "-c", "jcode"], capture_output=True, text=True).stdout.strip() or "0")
|
||||
|
||||
print_stat("Server RSS", fmt_kb(peak.get("rss_kb", 0)))
|
||||
print_stat("Server VMS", fmt_kb(peak.get("vms_kb", 0)))
|
||||
print_stat("Server FDs", peak.get("fds", "?"))
|
||||
print_stat("Server threads", peak.get("threads", "?"))
|
||||
print_stat("Total jcode procs", peak_procs)
|
||||
print_stat("RSS per session (approx)", fmt_kb(
|
||||
max(0, (peak.get("rss_kb", 0) - baseline.get("rss_kb", 0))) // max(1, len(created_sessions))
|
||||
))
|
||||
|
||||
# --- Phase 5: Destroy all sessions ---
|
||||
print_section(f"Phase 5: Destroying {len(created_sessions)} sessions")
|
||||
|
||||
destroy_times = []
|
||||
destroy_errors = []
|
||||
|
||||
for i, sid in enumerate(created_sessions, 1):
|
||||
t0 = time.monotonic()
|
||||
resp = debug_cmd(f"destroy_session:{sid}", timeout=15)
|
||||
t1 = time.monotonic()
|
||||
elapsed_ms = (t1 - t0) * 1000
|
||||
destroy_times.append(elapsed_ms)
|
||||
|
||||
if not resp.get("ok"):
|
||||
destroy_errors.append((i, resp.get("error", resp.get("output", "unknown"))))
|
||||
|
||||
if i % 10 == 0 or i == len(created_sessions):
|
||||
stats = proc_stat(server_pid) if server_pid else {}
|
||||
rss = fmt_kb(stats.get("rss_kb", 0))
|
||||
fds = stats.get("fds", "?")
|
||||
avg_ms = sum(destroy_times[-10:]) / len(destroy_times[-10:])
|
||||
print(f" [{i:3d}/{len(created_sessions)}] avg_destroy={avg_ms:.0f}ms rss={rss} fds={fds}")
|
||||
|
||||
if destroy_errors:
|
||||
print_warn(f"{len(destroy_errors)} destroy errors")
|
||||
for idx, err in destroy_errors[:3]:
|
||||
print(f" Session {idx}: {err}")
|
||||
else:
|
||||
print_ok(f"All {len(created_sessions)} sessions destroyed")
|
||||
|
||||
# --- Phase 6: Resource recovery check ---
|
||||
print_section("Phase 6: Resource recovery (waiting 10s for cleanup)")
|
||||
|
||||
time.sleep(10)
|
||||
|
||||
final = proc_stat(server_pid) if server_pid else {}
|
||||
final_procs = int(subprocess.run(["pgrep", "-c", "jcode"], capture_output=True, text=True).stdout.strip() or "0")
|
||||
|
||||
# Final socket test
|
||||
t0 = time.monotonic()
|
||||
final_state = debug_cmd("state")
|
||||
t1 = time.monotonic()
|
||||
final_socket_ms = (t1 - t0) * 1000
|
||||
|
||||
print_stat("Server RSS", f"{fmt_kb(final.get('rss_kb', 0))} (was {fmt_kb(baseline.get('rss_kb', 0))})")
|
||||
print_stat("Server FDs", f"{final.get('fds', '?')} (was {baseline.get('fds', '?')})")
|
||||
print_stat("Server threads", f"{final.get('threads', '?')} (was {baseline.get('threads', '?')})")
|
||||
print_stat("Socket latency", f"{final_socket_ms:.0f}ms")
|
||||
|
||||
# Check for leaks
|
||||
rss_delta = final.get("rss_kb", 0) - baseline.get("rss_kb", 0)
|
||||
fd_delta = (final.get("fds", 0) or 0) - (baseline.get("fds", 0) or 0)
|
||||
thread_delta = (final.get("threads", 0) or 0) - (baseline.get("threads", 0) or 0)
|
||||
|
||||
# ============================================================
|
||||
# FINAL REPORT
|
||||
# ============================================================
|
||||
|
||||
print_header("STRESS TEST RESULTS")
|
||||
|
||||
print(f" {Colors.BOLD}Sessions:{Colors.RESET} {NUM_INSTANCES} created, {len(created_sessions)} successful, {len(create_errors)} failed")
|
||||
print()
|
||||
|
||||
print(f" {Colors.BOLD}Session Creation:{Colors.RESET}")
|
||||
if create_times:
|
||||
print(f" Min: {min(create_times):.0f}ms")
|
||||
print(f" Max: {max(create_times):.0f}ms")
|
||||
print(f" Avg: {sum(create_times)/len(create_times):.0f}ms")
|
||||
print(f" p95: {sorted(create_times)[int(len(create_times)*0.95)]:.0f}ms")
|
||||
print(f" Total: {sum(create_times):.0f}ms")
|
||||
|
||||
print()
|
||||
print(f" {Colors.BOLD}Session Destruction:{Colors.RESET}")
|
||||
if destroy_times:
|
||||
print(f" Min: {min(destroy_times):.0f}ms")
|
||||
print(f" Max: {max(destroy_times):.0f}ms")
|
||||
print(f" Avg: {sum(destroy_times)/len(destroy_times):.0f}ms")
|
||||
|
||||
print()
|
||||
print(f" {Colors.BOLD}Memory:{Colors.RESET}")
|
||||
print(f" Baseline RSS: {fmt_kb(baseline.get('rss_kb', 0))}")
|
||||
print(f" Peak RSS: {fmt_kb(peak.get('rss_kb', 0))}")
|
||||
print(f" Final RSS: {fmt_kb(final.get('rss_kb', 0))}")
|
||||
print(f" RSS delta: {'+' if rss_delta >= 0 else ''}{fmt_kb(abs(rss_delta))}")
|
||||
print(f" Per-session: ~{fmt_kb(max(0, peak.get('rss_kb', 0) - baseline.get('rss_kb', 0)) // max(1, len(created_sessions)))}")
|
||||
|
||||
print()
|
||||
print(f" {Colors.BOLD}Resource Leaks:{Colors.RESET}")
|
||||
|
||||
fd_ok = abs(fd_delta) <= 10
|
||||
thread_ok = abs(thread_delta) <= 5
|
||||
rss_ok = rss_delta < (baseline.get("rss_kb", 0) or 100000) * 0.2 # <20% growth
|
||||
|
||||
if fd_ok:
|
||||
print_ok(f"FDs: {baseline.get('fds','?')} -> {final.get('fds','?')} (delta: {fd_delta})")
|
||||
else:
|
||||
print_warn(f"FD leak: {baseline.get('fds','?')} -> {final.get('fds','?')} (delta: {fd_delta})")
|
||||
|
||||
if thread_ok:
|
||||
print_ok(f"Threads: {baseline.get('threads','?')} -> {final.get('threads','?')} (delta: {thread_delta})")
|
||||
else:
|
||||
print_warn(f"Thread leak: {baseline.get('threads','?')} -> {final.get('threads','?')} (delta: {thread_delta})")
|
||||
|
||||
if rss_ok:
|
||||
print_ok(f"Memory: {fmt_kb(baseline.get('rss_kb',0))} -> {fmt_kb(final.get('rss_kb',0))} (delta: {fmt_kb(abs(rss_delta))})")
|
||||
else:
|
||||
print_warn(f"Memory not fully recovered: {fmt_kb(baseline.get('rss_kb',0))} -> {fmt_kb(final.get('rss_kb',0))} (delta: {fmt_kb(abs(rss_delta))})")
|
||||
|
||||
print()
|
||||
print(f" {Colors.BOLD}Socket Health:{Colors.RESET}")
|
||||
if final_socket_ms < 100:
|
||||
print_ok(f"Responsive after stress: {final_socket_ms:.0f}ms")
|
||||
elif final_socket_ms < 1000:
|
||||
print_warn(f"Slow after stress: {final_socket_ms:.0f}ms")
|
||||
else:
|
||||
print_err(f"Very slow after stress: {final_socket_ms:.0f}ms")
|
||||
|
||||
# Memory growth timeline
|
||||
print()
|
||||
print(f" {Colors.BOLD}Memory Growth Timeline:{Colors.RESET}")
|
||||
print(f" {'Sessions':>10s} {'RSS':>10s} {'FDs':>6s} {'Threads':>8s}")
|
||||
print(f" {'─'*10} {'─'*10} {'─'*6} {'─'*8}")
|
||||
print(f" {'baseline':>10s} {fmt_kb(baseline.get('rss_kb',0)):>10s} {str(baseline.get('fds','?')):>6s} {str(baseline.get('threads','?')):>8s}")
|
||||
for count, stats in per_session_stats:
|
||||
print(f" {count:>10d} {fmt_kb(stats.get('rss_kb',0)):>10s} {str(stats.get('fds','?')):>6s} {str(stats.get('threads','?')):>8s}")
|
||||
print(f" {'final':>10s} {fmt_kb(final.get('rss_kb',0)):>10s} {str(final.get('fds','?')):>6s} {str(final.get('threads','?')):>8s}")
|
||||
|
||||
print()
|
||||
print(f"{'='*60}")
|
||||
Executable
+412
@@ -0,0 +1,412 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Stress test: spawn 40 jcode TUI client instances rapidly
|
||||
# Measures startup time, memory usage, CPU, fd count, socket health
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
NUM_INSTANCES=${1:-40}
|
||||
JCODE_BIN="${JCODE_BIN:-$(which jcode)}"
|
||||
LOG_DIR="/tmp/jcode-stress-test-$(date +%s)"
|
||||
mkdir -p "$LOG_DIR"
|
||||
|
||||
MAIN_SOCK="/run/user/$(id -u)/jcode.sock"
|
||||
DEBUG_SOCK="/run/user/$(id -u)/jcode-debug.sock"
|
||||
|
||||
echo "========================================="
|
||||
echo " jcode Stress Test: $NUM_INSTANCES instances"
|
||||
echo "========================================="
|
||||
echo "Binary: $JCODE_BIN"
|
||||
echo "Log dir: $LOG_DIR"
|
||||
echo "Main socket: $MAIN_SOCK"
|
||||
echo ""
|
||||
|
||||
# --- Helper functions ---
|
||||
|
||||
get_server_pid() {
|
||||
# Server listens on the main socket
|
||||
lsof -U 2>/dev/null | grep "$(basename $MAIN_SOCK)" | awk '{print $2}' | sort -u | head -1
|
||||
}
|
||||
|
||||
snapshot() {
|
||||
local label="$1"
|
||||
local ts=$(date +%s%N)
|
||||
|
||||
echo "--- Snapshot: $label ---" >> "$LOG_DIR/snapshots.log"
|
||||
echo "timestamp_ns: $ts" >> "$LOG_DIR/snapshots.log"
|
||||
|
||||
# Memory
|
||||
free -m >> "$LOG_DIR/snapshots.log" 2>/dev/null
|
||||
echo "" >> "$LOG_DIR/snapshots.log"
|
||||
|
||||
# jcode process count and total RSS
|
||||
local jcode_procs=$(pgrep -c jcode 2>/dev/null || echo 0)
|
||||
local total_rss=0
|
||||
local total_vms=0
|
||||
for pid in $(pgrep jcode 2>/dev/null); do
|
||||
local rss=$(awk '/^VmRSS:/{print $2}' /proc/$pid/status 2>/dev/null || echo 0)
|
||||
local vms=$(awk '/^VmSize:/{print $2}' /proc/$pid/status 2>/dev/null || echo 0)
|
||||
total_rss=$((total_rss + rss))
|
||||
total_vms=$((total_vms + vms))
|
||||
done
|
||||
echo "jcode_processes: $jcode_procs" >> "$LOG_DIR/snapshots.log"
|
||||
echo "total_rss_kb: $total_rss" >> "$LOG_DIR/snapshots.log"
|
||||
echo "total_vms_kb: $total_vms" >> "$LOG_DIR/snapshots.log"
|
||||
|
||||
# Open file descriptors for server
|
||||
local server_pid=$(get_server_pid)
|
||||
if [ -n "$server_pid" ]; then
|
||||
local fd_count=$(ls /proc/$server_pid/fd 2>/dev/null | wc -l)
|
||||
local thread_count=$(ls /proc/$server_pid/task 2>/dev/null | wc -l)
|
||||
echo "server_pid: $server_pid" >> "$LOG_DIR/snapshots.log"
|
||||
echo "server_fd_count: $fd_count" >> "$LOG_DIR/snapshots.log"
|
||||
echo "server_threads: $thread_count" >> "$LOG_DIR/snapshots.log"
|
||||
# Server RSS specifically
|
||||
local server_rss=$(awk '/^VmRSS:/{print $2}' /proc/$server_pid/status 2>/dev/null || echo 0)
|
||||
echo "server_rss_kb: $server_rss" >> "$LOG_DIR/snapshots.log"
|
||||
fi
|
||||
|
||||
# CPU load
|
||||
cat /proc/loadavg >> "$LOG_DIR/snapshots.log"
|
||||
echo "" >> "$LOG_DIR/snapshots.log"
|
||||
echo "===" >> "$LOG_DIR/snapshots.log"
|
||||
|
||||
# Print summary line to stdout
|
||||
echo "[$label] procs=$jcode_procs rss=${total_rss}KB($(( total_rss / 1024 ))MB) server_rss=$(awk '/^VmRSS:/{print $2}' /proc/${server_pid:-0}/status 2>/dev/null || echo '?')KB fds=$(ls /proc/${server_pid:-0}/fd 2>/dev/null | wc -l) threads=$(ls /proc/${server_pid:-0}/task 2>/dev/null | wc -l)"
|
||||
}
|
||||
|
||||
check_socket_health() {
|
||||
local label="$1"
|
||||
# Try a quick connect-and-disconnect on the main socket
|
||||
local start_ns=$(date +%s%N)
|
||||
if python3 -c "
|
||||
import socket, json, sys, time
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
try:
|
||||
sock.settimeout(5)
|
||||
sock.connect('$MAIN_SOCK')
|
||||
# just connect and close - test socket responsiveness
|
||||
sock.close()
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
print(f'Socket error: {e}', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
" 2>>"$LOG_DIR/socket_errors.log"; then
|
||||
local end_ns=$(date +%s%N)
|
||||
local dur_ms=$(( (end_ns - start_ns) / 1000000 ))
|
||||
echo "[$label] Socket connect: ${dur_ms}ms" | tee -a "$LOG_DIR/socket_latency.log"
|
||||
else
|
||||
echo "[$label] Socket connect: FAILED" | tee -a "$LOG_DIR/socket_latency.log"
|
||||
fi
|
||||
}
|
||||
|
||||
debug_cmd() {
|
||||
local cmd="$1"
|
||||
local timeout="${2:-5}"
|
||||
python3 -c "
|
||||
import socket, json, sys
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
try:
|
||||
sock.settimeout($timeout)
|
||||
sock.connect('$DEBUG_SOCK')
|
||||
req = {'type': 'debug_command', 'id': 1, 'command': '$cmd'}
|
||||
sock.send((json.dumps(req) + '\n').encode())
|
||||
data = sock.recv(65536).decode()
|
||||
print(data)
|
||||
sock.close()
|
||||
except Exception as e:
|
||||
print(json.dumps({'error': str(e)}))
|
||||
" 2>/dev/null
|
||||
}
|
||||
|
||||
# --- Pre-flight ---
|
||||
|
||||
echo "=== Pre-flight checks ==="
|
||||
|
||||
# Check if server is running
|
||||
if ! [ -S "$MAIN_SOCK" ]; then
|
||||
echo "ERROR: No jcode server running at $MAIN_SOCK"
|
||||
echo "Start one with: jcode serve &"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Test socket
|
||||
check_socket_health "pre-flight"
|
||||
|
||||
# Baseline snapshot
|
||||
snapshot "baseline"
|
||||
echo ""
|
||||
|
||||
# --- Record system baseline ---
|
||||
BASELINE_RSS=$(pgrep jcode 2>/dev/null | while read pid; do awk '/^VmRSS:/{print $2}' /proc/$pid/status 2>/dev/null; done | paste -sd+ | bc 2>/dev/null || echo 0)
|
||||
BASELINE_PROCS=$(pgrep -c jcode 2>/dev/null || echo 0)
|
||||
BASELINE_SERVER_PID=$(get_server_pid)
|
||||
BASELINE_FDS=$(ls /proc/${BASELINE_SERVER_PID:-0}/fd 2>/dev/null | wc -l)
|
||||
|
||||
echo "=== Baseline ==="
|
||||
echo " Processes: $BASELINE_PROCS"
|
||||
echo " Total RSS: ${BASELINE_RSS}KB ($(( BASELINE_RSS / 1024 ))MB)"
|
||||
echo " Server FDs: $BASELINE_FDS"
|
||||
echo ""
|
||||
|
||||
# --- Start background monitoring ---
|
||||
echo "=== Starting background monitor ==="
|
||||
(
|
||||
while true; do
|
||||
ts=$(date +%s)
|
||||
jcode_procs=$(pgrep -c jcode 2>/dev/null || echo 0)
|
||||
total_rss=0
|
||||
for pid in $(pgrep jcode 2>/dev/null); do
|
||||
rss=$(awk '/^VmRSS:/{print $2}' /proc/$pid/status 2>/dev/null || echo 0)
|
||||
total_rss=$((total_rss + rss))
|
||||
done
|
||||
server_pid=$(get_server_pid)
|
||||
server_rss=$(awk '/^VmRSS:/{print $2}' /proc/${server_pid:-0}/status 2>/dev/null || echo 0)
|
||||
server_fds=$(ls /proc/${server_pid:-0}/fd 2>/dev/null | wc -l)
|
||||
server_threads=$(ls /proc/${server_pid:-0}/task 2>/dev/null | wc -l)
|
||||
cpu_load=$(awk '{print $1}' /proc/loadavg)
|
||||
echo "$ts,$jcode_procs,$total_rss,$server_rss,$server_fds,$server_threads,$cpu_load"
|
||||
sleep 1
|
||||
done
|
||||
) > "$LOG_DIR/timeseries.csv" &
|
||||
MONITOR_PID=$!
|
||||
echo "Monitor PID: $MONITOR_PID"
|
||||
echo ""
|
||||
|
||||
# --- Spawn instances ---
|
||||
echo "=== Spawning $NUM_INSTANCES jcode instances ==="
|
||||
PIDS=()
|
||||
SPAWN_TIMES=()
|
||||
|
||||
# Use script to give each instance a pty (jcode requires tty)
|
||||
for i in $(seq 1 $NUM_INSTANCES); do
|
||||
local_start=$(date +%s%N)
|
||||
|
||||
# Each instance gets its own pseudo-terminal via script(1)
|
||||
# We connect to the existing server, which creates sessions
|
||||
script -q -c "$JCODE_BIN --no-update --no-selfdev" /dev/null \
|
||||
> "$LOG_DIR/instance_${i}_stdout.log" \
|
||||
2> "$LOG_DIR/instance_${i}_stderr.log" &
|
||||
pid=$!
|
||||
PIDS+=($pid)
|
||||
|
||||
local_end=$(date +%s%N)
|
||||
spawn_ms=$(( (local_end - local_start) / 1000000 ))
|
||||
SPAWN_TIMES+=($spawn_ms)
|
||||
|
||||
# Log it
|
||||
echo " [$i/$NUM_INSTANCES] PID=$pid spawn=${spawn_ms}ms" | tee -a "$LOG_DIR/spawn.log"
|
||||
|
||||
# Snapshot every 10 instances
|
||||
if (( i % 10 == 0 )); then
|
||||
sleep 1 # Let things settle
|
||||
snapshot "after_${i}_spawns"
|
||||
check_socket_health "after_${i}_spawns"
|
||||
fi
|
||||
|
||||
# Small delay to avoid overwhelming everything at once
|
||||
sleep 0.2
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=== All $NUM_INSTANCES instances spawned ==="
|
||||
echo ""
|
||||
|
||||
# Let them run for a bit
|
||||
echo "=== Letting instances stabilize for 10 seconds ==="
|
||||
sleep 10
|
||||
snapshot "post_spawn_settled"
|
||||
check_socket_health "post_spawn_settled"
|
||||
echo ""
|
||||
|
||||
# --- Debug socket probe under load ---
|
||||
echo "=== Debug socket responsiveness under load ==="
|
||||
for probe in 1 2 3; do
|
||||
start_ns=$(date +%s%N)
|
||||
result=$(debug_cmd "state" 10)
|
||||
end_ns=$(date +%s%N)
|
||||
dur_ms=$(( (end_ns - start_ns) / 1000000 ))
|
||||
echo " Probe $probe: ${dur_ms}ms" | tee -a "$LOG_DIR/debug_probe.log"
|
||||
sleep 0.5
|
||||
done
|
||||
echo ""
|
||||
|
||||
# --- Session listing under load ---
|
||||
echo "=== Session list under load ==="
|
||||
start_ns=$(date +%s%N)
|
||||
sessions_result=$(debug_cmd "sessions" 15)
|
||||
end_ns=$(date +%s%N)
|
||||
dur_ms=$(( (end_ns - start_ns) / 1000000 ))
|
||||
session_count=$(echo "$sessions_result" | python3 -c "
|
||||
import json, sys
|
||||
try:
|
||||
data = json.loads(sys.stdin.read())
|
||||
output = data.get('output', '')
|
||||
sessions = json.loads(output) if output else []
|
||||
print(len(sessions))
|
||||
except:
|
||||
print('?')
|
||||
" 2>/dev/null)
|
||||
echo " Sessions: $session_count, query time: ${dur_ms}ms"
|
||||
echo ""
|
||||
|
||||
# --- Kill all spawned instances ---
|
||||
echo "=== Killing all spawned instances ==="
|
||||
for pid in "${PIDS[@]}"; do
|
||||
kill "$pid" 2>/dev/null || true
|
||||
done
|
||||
# Wait for them to die
|
||||
sleep 3
|
||||
# Force kill stragglers
|
||||
for pid in "${PIDS[@]}"; do
|
||||
kill -9 "$pid" 2>/dev/null || true
|
||||
done
|
||||
sleep 2
|
||||
|
||||
snapshot "post_kill"
|
||||
echo ""
|
||||
|
||||
# --- Post-kill: check for leaked resources ---
|
||||
echo "=== Post-kill resource check ==="
|
||||
POST_PROCS=$(pgrep -c jcode 2>/dev/null || echo 0)
|
||||
POST_RSS=0
|
||||
for pid in $(pgrep jcode 2>/dev/null); do
|
||||
rss=$(awk '/^VmRSS:/{print $2}' /proc/$pid/status 2>/dev/null || echo 0)
|
||||
POST_RSS=$((POST_RSS + rss))
|
||||
done
|
||||
POST_SERVER_PID=$(get_server_pid)
|
||||
POST_FDS=$(ls /proc/${POST_SERVER_PID:-0}/fd 2>/dev/null | wc -l)
|
||||
POST_SERVER_RSS=$(awk '/^VmRSS:/{print $2}' /proc/${POST_SERVER_PID:-0}/status 2>/dev/null || echo 0)
|
||||
|
||||
echo " Processes: $BASELINE_PROCS -> $POST_PROCS (delta: $((POST_PROCS - BASELINE_PROCS)))"
|
||||
echo " Total RSS: ${BASELINE_RSS}KB -> ${POST_RSS}KB (delta: $((POST_RSS - BASELINE_RSS))KB)"
|
||||
echo " Server FDs: $BASELINE_FDS -> $POST_FDS (delta: $((POST_FDS - BASELINE_FDS)))"
|
||||
echo " Server RSS: ${POST_SERVER_RSS}KB"
|
||||
echo ""
|
||||
|
||||
# Check socket health after cleanup
|
||||
echo "=== Post-cleanup socket health ==="
|
||||
check_socket_health "post_cleanup_1"
|
||||
sleep 1
|
||||
check_socket_health "post_cleanup_2"
|
||||
echo ""
|
||||
|
||||
# --- Wait a bit more and check for memory leak ---
|
||||
echo "=== Waiting 15s for GC/cleanup ==="
|
||||
sleep 15
|
||||
snapshot "post_gc"
|
||||
FINAL_SERVER_PID=$(get_server_pid)
|
||||
FINAL_FDS=$(ls /proc/${FINAL_SERVER_PID:-0}/fd 2>/dev/null | wc -l)
|
||||
FINAL_SERVER_RSS=$(awk '/^VmRSS:/{print $2}' /proc/${FINAL_SERVER_PID:-0}/status 2>/dev/null || echo 0)
|
||||
check_socket_health "final"
|
||||
echo ""
|
||||
|
||||
# --- Stop background monitor ---
|
||||
kill $MONITOR_PID 2>/dev/null || true
|
||||
|
||||
# --- Summary report ---
|
||||
echo "========================================="
|
||||
echo " STRESS TEST SUMMARY"
|
||||
echo "========================================="
|
||||
echo ""
|
||||
echo "Configuration:"
|
||||
echo " Instances spawned: $NUM_INSTANCES"
|
||||
echo " Binary: $JCODE_BIN"
|
||||
echo ""
|
||||
|
||||
# Spawn time stats
|
||||
if [ ${#SPAWN_TIMES[@]} -gt 0 ]; then
|
||||
total=0
|
||||
min=${SPAWN_TIMES[0]}
|
||||
max=${SPAWN_TIMES[0]}
|
||||
for t in "${SPAWN_TIMES[@]}"; do
|
||||
total=$((total + t))
|
||||
if (( t < min )); then min=$t; fi
|
||||
if (( t > max )); then max=$t; fi
|
||||
done
|
||||
avg=$((total / ${#SPAWN_TIMES[@]}))
|
||||
echo "Spawn Times (fork+exec overhead):"
|
||||
echo " Min: ${min}ms"
|
||||
echo " Max: ${max}ms"
|
||||
echo " Avg: ${avg}ms"
|
||||
echo " Total: ${total}ms"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
echo "Memory Impact:"
|
||||
echo " Baseline total RSS: ${BASELINE_RSS}KB ($(( BASELINE_RSS / 1024 ))MB)"
|
||||
echo " Peak server RSS: (see timeseries)"
|
||||
echo " Final server RSS: ${FINAL_SERVER_RSS}KB ($(( FINAL_SERVER_RSS / 1024 ))MB)"
|
||||
echo " Server RSS delta from baseline: $((FINAL_SERVER_RSS - $(awk '/^VmRSS:/{print $2}' /proc/${BASELINE_SERVER_PID:-0}/status 2>/dev/null || echo $FINAL_SERVER_RSS)))KB"
|
||||
echo ""
|
||||
|
||||
echo "File Descriptors (leak check):"
|
||||
echo " Baseline: $BASELINE_FDS"
|
||||
echo " After kill: $POST_FDS (delta: $((POST_FDS - BASELINE_FDS)))"
|
||||
echo " After GC: $FINAL_FDS (delta: $((FINAL_FDS - BASELINE_FDS)))"
|
||||
if (( FINAL_FDS > BASELINE_FDS + 5 )); then
|
||||
echo " ⚠️ POSSIBLE FD LEAK: $((FINAL_FDS - BASELINE_FDS)) fds not cleaned up"
|
||||
else
|
||||
echo " ✅ FDs cleaned up properly"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
echo "Socket Latency:"
|
||||
if [ -f "$LOG_DIR/socket_latency.log" ]; then
|
||||
cat "$LOG_DIR/socket_latency.log"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
echo "Debug Socket Latency:"
|
||||
if [ -f "$LOG_DIR/debug_probe.log" ]; then
|
||||
cat "$LOG_DIR/debug_probe.log"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Check for errors
|
||||
echo "Errors:"
|
||||
error_count=0
|
||||
for f in "$LOG_DIR"/instance_*_stderr.log; do
|
||||
if [ -s "$f" ]; then
|
||||
instance=$(basename "$f" | sed 's/instance_\([0-9]*\)_.*/\1/')
|
||||
errors=$(grep -i "error\|panic\|crash\|failed\|refused" "$f" 2>/dev/null | head -3)
|
||||
if [ -n "$errors" ]; then
|
||||
echo " Instance $instance:"
|
||||
echo "$errors" | sed 's/^/ /'
|
||||
error_count=$((error_count + 1))
|
||||
fi
|
||||
fi
|
||||
done
|
||||
if (( error_count == 0 )); then
|
||||
echo " ✅ No errors detected"
|
||||
else
|
||||
echo ""
|
||||
echo " ⚠️ $error_count instances had errors"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Generate timeseries summary
|
||||
echo "Timeseries data: $LOG_DIR/timeseries.csv"
|
||||
echo " Format: timestamp,procs,total_rss_kb,server_rss_kb,server_fds,server_threads,cpu_load"
|
||||
if [ -f "$LOG_DIR/timeseries.csv" ]; then
|
||||
echo " Rows: $(wc -l < "$LOG_DIR/timeseries.csv")"
|
||||
echo ""
|
||||
echo " Peak values from timeseries:"
|
||||
awk -F, '{
|
||||
if ($2+0 > max_procs) max_procs=$2+0;
|
||||
if ($3+0 > max_rss) max_rss=$3+0;
|
||||
if ($4+0 > max_srv_rss) max_srv_rss=$4+0;
|
||||
if ($5+0 > max_fds) max_fds=$5+0;
|
||||
if ($6+0 > max_threads) max_threads=$6+0;
|
||||
} END {
|
||||
printf " Max processes: %d\n", max_procs;
|
||||
printf " Max total RSS: %d KB (%d MB)\n", max_rss, max_rss/1024;
|
||||
printf " Max server RSS: %d KB (%d MB)\n", max_srv_rss, max_srv_rss/1024;
|
||||
printf " Max server FDs: %d\n", max_fds;
|
||||
printf " Max server threads: %d\n", max_threads;
|
||||
}' "$LOG_DIR/timeseries.csv"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
echo "Full logs: $LOG_DIR/"
|
||||
echo "========================================="
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user