mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-07 16:19:09 +00:00
Compare commits
8 commits
1daaa66ed8
...
4eff9a32c2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4eff9a32c2 | ||
|
|
9fdd3985c2 | ||
|
|
c3b0628ceb | ||
|
|
c4905ac01e | ||
|
|
589cd236c6 | ||
|
|
dfdd41879c | ||
|
|
2a226e2cd1 | ||
|
|
b65c3a07db |
56 changed files with 2811 additions and 390 deletions
56
.githooks/_reject_guard.sh
Normal file
56
.githooks/_reject_guard.sh
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
# shellcheck shell=bash
|
||||
# Shared cherry-pick reject guard. SOURCED (never executed directly) by both
|
||||
# .githooks/prepare-commit-msg and .githooks/commit-msg. Git only executes files whose
|
||||
# names exactly match a hook name, so this underscore-prefixed helper is ignored by git.
|
||||
#
|
||||
# Recovers the upstream SHA of an in-progress cherry-pick and HARD-BLOCKS (returns 1) when
|
||||
# the triage ledger marks it "Won't merge". No-op for anything that is not a cherry-pick.
|
||||
# Fail-safe: only a confirmed, non-overridden reject returns 1; every other path returns 0.
|
||||
#
|
||||
# Override an intentional re-pick with either:
|
||||
# SH_CHERRYPICK_ALLOW_REJECT=1 git cherry-pick -x <sha>
|
||||
# git config sh.cherrypick.blockRejects false # warn-only for this repo
|
||||
|
||||
sh_cherrypick_reject_guard() {
|
||||
local msgfile="${1:-}"
|
||||
local gd up_sha root triage py reason rc
|
||||
|
||||
gd="$(git rev-parse --absolute-git-dir 2>/dev/null)" || return 0
|
||||
|
||||
up_sha=""
|
||||
if [ -f "$gd/CHERRY_PICK_HEAD" ]; then
|
||||
up_sha="$(tr -d '[:space:]' <"$gd/CHERRY_PICK_HEAD" 2>/dev/null)"
|
||||
fi
|
||||
if [ -z "$up_sha" ] && [ -n "$msgfile" ] && [ -f "$msgfile" ]; then
|
||||
up_sha="$(sed -n 's/.*cherry picked from commit \([0-9a-f]\{7,40\}\).*/\1/p' "$msgfile" | tail -1)"
|
||||
fi
|
||||
[ -z "$up_sha" ] && return 0 # not a cherry-pick -> no-op (normal commits untouched)
|
||||
|
||||
root="$(git rev-parse --show-toplevel 2>/dev/null)" || return 0
|
||||
triage="$root/scripts/triage.py"
|
||||
[ -f "$triage" ] || return 0
|
||||
py="$(command -v python3 || command -v python 2>/dev/null)"
|
||||
[ -n "$py" ] || return 0
|
||||
|
||||
reason="$("$py" "$triage" check-reject "$up_sha" 2>/dev/null)"
|
||||
rc=$?
|
||||
[ "$rc" -eq 3 ] || return 0 # rc 0 = ok; rc 2 = ledger error -> fail-safe; only rc 3 blocks
|
||||
|
||||
echo "" >&2
|
||||
echo "sh-cherrypick: BLOCKED — ${up_sha:0:12} is marked \"Won't merge\" in the triage ledger." >&2
|
||||
echo " reason: ${reason:-<none recorded>}" >&2
|
||||
|
||||
if [ "${SH_CHERRYPICK_ALLOW_REJECT:-}" = "1" ]; then
|
||||
echo " override: SH_CHERRYPICK_ALLOW_REJECT=1 — allowing this pick." >&2
|
||||
return 0
|
||||
fi
|
||||
if [ "$(git config --bool sh.cherrypick.blockRejects 2>/dev/null || echo true)" = "false" ]; then
|
||||
echo " override: sh.cherrypick.blockRejects=false — warn-only, allowing." >&2
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo " To re-evaluate intentionally: SH_CHERRYPICK_ALLOW_REJECT=1 git cherry-pick -x <sha>" >&2
|
||||
echo " or repo-wide warn-only: git config sh.cherrypick.blockRejects false" >&2
|
||||
echo " Recover this pick: git cherry-pick --abort (or --skip)" >&2
|
||||
return 1
|
||||
}
|
||||
17
.githooks/commit-msg
Executable file
17
.githooks/commit-msg
Executable file
|
|
@ -0,0 +1,17 @@
|
|||
#!/usr/bin/env bash
|
||||
# sh-cherrypick commit-msg: SECONDARY reject backstop.
|
||||
#
|
||||
# commit-msg does NOT fire on a clean `git cherry-pick` auto-commit (that path is caught by
|
||||
# prepare-commit-msg + post-commit); it does fire on the `git commit`-backed path such as
|
||||
# `git cherry-pick --continue`. On that path prepare-commit-msg already runs first and blocks,
|
||||
# so this hook is defense-in-depth against git versions/config where prepare-commit-msg is
|
||||
# bypassed. Shares the exact same guard logic.
|
||||
#
|
||||
# Fail-safe: the only non-zero exit is the deliberate reject block; normal commits are no-ops.
|
||||
set -uo pipefail
|
||||
|
||||
# shellcheck source=_reject_guard.sh
|
||||
. "$(dirname "$0")/_reject_guard.sh"
|
||||
|
||||
sh_cherrypick_reject_guard "${1:-}" || exit 1
|
||||
exit 0
|
||||
28
.githooks/post-commit
Executable file
28
.githooks/post-commit
Executable file
|
|
@ -0,0 +1,28 @@
|
|||
#!/usr/bin/env bash
|
||||
# sh-cherrypick post-commit: after a `git cherry-pick -x` lands, record the upstream SHA
|
||||
# (recovered from the `(cherry picked from commit <sha>)` trailer) + the new local SHA into
|
||||
# an UNTRACKED .git-local journal. Never edits tracked files. No-op for normal commits.
|
||||
# Fail-safe: any internal error exits 0 so a commit is never aborted here.
|
||||
set -uo pipefail
|
||||
|
||||
{
|
||||
msg="$(git log -1 --format=%B 2>/dev/null)" || exit 0
|
||||
up_sha="$(printf '%s\n' "$msg" \
|
||||
| sed -n 's/.*cherry picked from commit \([0-9a-f]\{7,40\}\).*/\1/p' | tail -1)"
|
||||
[ -z "$up_sha" ] && exit 0 # not a cherry-pick (or no -x) -> leave normal commits untouched
|
||||
|
||||
local_sha="$(git rev-parse HEAD 2>/dev/null)" || exit 0
|
||||
gd="$(git rev-parse --absolute-git-dir 2>/dev/null)" || exit 0
|
||||
journal="$gd/sh-cherrypick-journal"
|
||||
ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
tab="$(printf '\t')"
|
||||
|
||||
if ! { [ -f "$journal" ] && grep -q "^${up_sha}${tab}" "$journal" 2>/dev/null; }; then
|
||||
printf '%s\t%s\t%s\n' "$up_sha" "$local_sha" "$ts" >> "$journal"
|
||||
fi
|
||||
|
||||
n="$(grep -c . "$journal" 2>/dev/null || echo '?')"
|
||||
echo "sh-cherrypick: journaled ${up_sha:0:12} (${n} pending) — run: make triage-reconcile" >&2
|
||||
} || true
|
||||
|
||||
exit 0
|
||||
15
.githooks/pre-push
Executable file
15
.githooks/pre-push
Executable file
|
|
@ -0,0 +1,15 @@
|
|||
#!/usr/bin/env bash
|
||||
# sh-cherrypick pre-push SHIM.
|
||||
#
|
||||
# core.hooksPath is a SINGLE directory, not a search path. Pointing this repo at .githooks/
|
||||
# SHADOWS the machine-global hook dir (~/.config/git/hooks), which holds the MANDATORY Sea
|
||||
# Haven security pre-push gate. To avoid silently disabling that gate, this shim re-execs the
|
||||
# global pre-push, passing through args + stdin and preserving its exit code.
|
||||
GLOBAL="${SH_GLOBAL_HOOKS:-$HOME/.config/git/hooks}/pre-push"
|
||||
|
||||
if [ -x "$GLOBAL" ]; then
|
||||
exec "$GLOBAL" "$@"
|
||||
fi
|
||||
|
||||
echo "sh-cherrypick: no global pre-push at $GLOBAL — nothing to delegate to." >&2
|
||||
exit 0
|
||||
16
.githooks/prepare-commit-msg
Executable file
16
.githooks/prepare-commit-msg
Executable file
|
|
@ -0,0 +1,16 @@
|
|||
#!/usr/bin/env bash
|
||||
# sh-cherrypick prepare-commit-msg: PRIMARY reject backstop for a raw `git cherry-pick`.
|
||||
#
|
||||
# This (not commit-msg) is the hook that fires on a CLEAN cherry-pick auto-commit. Git runs
|
||||
# prepare-commit-msg + post-commit on a clean pick but SKIPS pre-commit/commit-msg; commit-msg
|
||||
# only fires on the `git commit`-backed path (`--continue`, normal commits). So the hard-block
|
||||
# for known-reject picks lives here to cover clean picks too.
|
||||
#
|
||||
# Fail-safe: the only non-zero exit is the deliberate reject block; normal commits are no-ops.
|
||||
set -uo pipefail
|
||||
|
||||
# shellcheck source=_reject_guard.sh
|
||||
. "$(dirname "$0")/_reject_guard.sh"
|
||||
|
||||
sh_cherrypick_reject_guard "${1:-}" || exit 1
|
||||
exit 0
|
||||
14
CLAUDE.md
14
CLAUDE.md
|
|
@ -116,3 +116,17 @@ Webhooks compute deterministic thread ids so the same Linear issue / Slack threa
|
|||
- New dashboard endpoints: add to `agent/dashboard/routes.py`. The router is auto-mounted on the FastAPI app.
|
||||
- New graphs: register the entrypoint in `langgraph.json` under `graphs`.
|
||||
- Minimal-to-no code comments — only when the *why* isn't obvious from the code.
|
||||
|
||||
## Fork maintenance — syncing `upstream/main`
|
||||
|
||||
This is a long-lived fork of `langchain-ai/open-swe` with Sea Haven customizations woven into upstream-owned files (notably `agent/prompt.py` prompt constants, `agent/webapp.py`, and the tool/middleware wiring). Merging upstream is a triage exercise, not a fast-forward. When you want upstream's clean changes but must **defer a large structural refactor** (and its entangled features), work in this order:
|
||||
|
||||
1. **Triage before resolving.** Merge-base is `git merge-base HEAD upstream/main`. The truthful conflict set is the combined merge, `git merge-tree --write-tree --name-only HEAD upstream/main` — a per-commit probe against each commit's parent *overstates* conflicts (a file a refactor merely added shows up as a phantom `modify/delete`). Decide keep-baseline vs adopt-refactor **before** resolving, and surface the choice to a human for any auth/webhook/IAM surface.
|
||||
2. **Chase the cascade, not just the textual conflicts.** The hard part is the non-conflicting files the refactor also touched. Get the refactor's file set (`git diff-tree --no-commit-id --name-status -r <refactor-sha>`) and cross-reference the files this fork modified (`git diff --name-only <fork-base> HEAD`). Files in both = hand-resolve; files only the refactor touched = mechanical.
|
||||
3. **Deferring a refactor:** default every refactor-touched file to **upstream**, except the deleted-module cluster, which stays at your **baseline (HEAD)** — and move its **paired tests to the same side**. A file goes to HEAD when its upstream version imports a module the refactor deleted, or kept code needs an old API. Bring back files the refactor deleted but you still use with `git checkout HEAD -- <file>`. Iterate `pytest --co -q` to chase import breaks one module at a time.
|
||||
4. **Two silent hazards.** (a) A thin upstream router ends with `from .webhooks.slack import process_slack_mention`; merged alongside your monolith's *local* `def process_slack_mention`, Python rebinds the name at import, so **upstream's handler runs and silently drops your fixes** — delete those re-import lines. (b) A new tool/middleware importing a deleted module crashes the whole graph at import — if you defer the feature, delete the tool file **and** all its wiring (`server.py` tool list, `tools/__init__.py`, prompt guidance, e2e harness, its test).
|
||||
5. **Keep test + impl on the same side** — a test at upstream and its impl at HEAD (or vice-versa) yields async-vs-sync or contract drift. Keep the whole vertical (backend + UI + e2e spec + fixtures) on one side.
|
||||
6. **Run CI in layers** — `ruff`/`tsc` (syntax/types) → `pytest --co` (import-time breaks) → unit tests (contract mismatches) → **E2E (Playwright + the real LangGraph dev server)**, which is the only layer that catches import-time crashes in tool/middleware *wiring* and frontend↔backend contract drift. "Unit green" is not "done" for a structural merge.
|
||||
7. **Tooling-switch fallout** — a package-manager/build-tool switch (upstream `pnpm`, this fork keeps `bun`) auto-merges into build scripts, CI, the `packageManager` field, and lockfiles even when you reject it for the product build. After merging, sweep those and never ship two lockfiles.
|
||||
|
||||
Validate on a throwaway branch with granular commits (one per cascade class) and let each CI layer prove out before promoting.
|
||||
|
|
|
|||
27
Makefile
27
Makefile
|
|
@ -1,4 +1,5 @@
|
|||
.PHONY: all format format-check lint test tests integration_tests help run dev
|
||||
.PHONY: all format format-check lint test tests integration_tests help run dev \
|
||||
install-hooks triage-sync triage-reconcile triage-render triage-check
|
||||
|
||||
# Default target executed when no arguments are given to make.
|
||||
all: help
|
||||
|
|
@ -53,6 +54,25 @@ format:
|
|||
format-check:
|
||||
uv run ruff format $(PYTHON_FILES) --check
|
||||
|
||||
######################
|
||||
# UPSTREAM SYNC / CHERRY-PICK TRIAGE
|
||||
######################
|
||||
|
||||
install-hooks:
|
||||
bash scripts/install-hooks.sh
|
||||
|
||||
triage-sync:
|
||||
python3 scripts/triage.py sync
|
||||
|
||||
triage-reconcile:
|
||||
python3 scripts/triage.py reconcile
|
||||
|
||||
triage-render:
|
||||
python3 scripts/triage.py generate
|
||||
|
||||
triage-check:
|
||||
python3 scripts/triage.py generate --check
|
||||
|
||||
######################
|
||||
# HELP
|
||||
######################
|
||||
|
|
@ -66,3 +86,8 @@ help:
|
|||
@echo 'lint - run linters'
|
||||
@echo 'test - run unit tests'
|
||||
@echo 'integration_tests - run integration tests'
|
||||
@echo 'install-hooks - install cherry-pick triage git hooks (per clone)'
|
||||
@echo 'triage-sync - fetch upstream + add new dev..upstream/main commits as untriaged'
|
||||
@echo 'triage-reconcile - drain cherry-pick journal into the triage ledger'
|
||||
@echo 'triage-render - regenerate docs/upstream-sync/triage.md'
|
||||
@echo 'triage-check - fail if triage.md is stale vs triage.jsonl (CI)'
|
||||
|
|
|
|||
|
|
@ -99,9 +99,18 @@ def _provider_of(model_id: str) -> str | None:
|
|||
|
||||
def _claude_family_of(model_id: str) -> str | None:
|
||||
provider, _, name = model_id.partition(":")
|
||||
if provider != "anthropic" or not name.startswith("claude-"):
|
||||
if provider == "anthropic":
|
||||
claude = name
|
||||
elif provider == "bedrock_converse":
|
||||
# Bedrock ids embed the model as a region-prefixed path, e.g.
|
||||
# "us.anthropic.claude-sonnet-5" — take the trailing "claude-*" segment so
|
||||
# our Bedrock Claude models get the same family-aware fallback.
|
||||
claude = name.rpartition(".")[2]
|
||||
else:
|
||||
return None
|
||||
parts = name.split("-")
|
||||
if not claude.startswith("claude-"):
|
||||
return None
|
||||
parts = claude.split("-")
|
||||
if len(parts) < 2:
|
||||
return None
|
||||
return "-".join(parts[:2])
|
||||
|
|
|
|||
|
|
@ -32,6 +32,22 @@ TEAM_SETTINGS_KEY = "default"
|
|||
ORG_GUIDELINES_MAX_CHARS = 10_000
|
||||
REVIEW_TRACING_PROJECT_MAX_CHARS = 256
|
||||
|
||||
# Sea Haven review baseline seeded as the org-wide guidelines default. Surfaces
|
||||
# in the reviewer prompt for every repo until an admin overrides it with a
|
||||
# non-empty value via the dashboard (PUT /team-settings). Keep it stack-agnostic
|
||||
# and well under ORG_GUIDELINES_MAX_CHARS.
|
||||
DEFAULT_ORG_REVIEW_GUIDELINES = """\
|
||||
Sea Haven review baseline (applies to every repo unless a repo-specific guideline overrides it):
|
||||
|
||||
- Severity: map findings to critical / high / medium / low. Reserve critical and high for correctness bugs, security issues, data loss, or broken contracts — not style.
|
||||
- Secrets & config: flag any hardcoded secret, credential, or real `.env`/config value committed to source, and any sensitive value placed outside the platform's secrets manager.
|
||||
- Security surface (raise as high): changes to authentication/authorization or access checks; IAM/policy/permission or infrastructure-access changes; changes to the exported signature or contract of a public handler/endpoint; and untrusted-input handling (request parsing, deserialization, file uploads, SSRF-prone fetches, and template/SQL/command construction — flag unescaped interpolation of dynamic or user-controlled data).
|
||||
- Tests: flag new behavior that ships without a corresponding test, and "fixes" that only silence a check (added excludes, `noqa` / `# type: ignore`, skipped or `xfail`ed tests).
|
||||
- Naming & conventions: flag resources or code that break the repo's established naming and layout conventions.
|
||||
- Deferred work: a finding the author chooses to defer must be captured in a tracked issue, not dropped silently.
|
||||
|
||||
Only file a finding that anchors to a changed line and names a concrete failure mode. Do not police pre-existing issues outside the diff or raise pure style nits."""
|
||||
|
||||
|
||||
class TeamSettingsUpdate(BaseModel):
|
||||
review_draft_prs: bool = False
|
||||
|
|
@ -152,7 +168,7 @@ def _default_settings() -> dict[str, Any]:
|
|||
"pr_summaries": True,
|
||||
"review_trace_links": True,
|
||||
"review_tracing_project": None,
|
||||
"org_guidelines": None,
|
||||
"org_guidelines": DEFAULT_ORG_REVIEW_GUIDELINES,
|
||||
"default_agent_model": fallback_model,
|
||||
"default_agent_reasoning_effort": fallback_effort,
|
||||
"default_agent_subagent_model": fallback_model,
|
||||
|
|
|
|||
|
|
@ -163,8 +163,12 @@ Before any task that changes code, set up the repo in your sandbox, in order:
|
|||
This authors every commit. It is required for CI (e.g. Vercel preview deploys reject commits whose author email can't be resolved to a GitHub account; this email resolves). Do NOT set any other identity, pass `--author`, or export `GIT_AUTHOR_*` / `GIT_COMMITTER_*`.
|
||||
4. **Choose your branch** — Use a Sea Haven branch name: `<prefix>/<description>`, all kebab-case. Pick the prefix by the kind of work:
|
||||
- `feature/` — new functionality or an enhancement
|
||||
- `bug/` — a defect caught before it reaches production
|
||||
- `fix/` — a defect caught before it reaches production
|
||||
- `hotfix/` — a fix for a production-impacting issue
|
||||
- `chore/` — tooling, dependencies, config, or other maintenance
|
||||
- `docs/` — documentation-only changes
|
||||
- `refactor/` — internal restructuring with no behavior change
|
||||
- `release/` — release preparation
|
||||
|
||||
Keep `<description>` short and kebab-case (e.g. `feature/add-receipt-parser`). When a ticket key is resolvable from the run context, put it first: `feature/<KEY>-add-receipt-parser`; if no key is resolvable, omit it. Never commit directly to `main`. Keep the branch thread-stable: if a branch already exists for this thread, reuse it: fetch and check it out, starting from `origin/<branch>` (not the base branch) so prior commits are preserved for review — do not recreate it.
|
||||
5. **Read `AGENTS.md`** — IMMEDIATELY after cloning, you MUST check if `AGENTS.md` exists at the repository root (`{working_dir}/<repo>/AGENTS.md`). If it exists, you MUST read it IN FULL before doing ANY other work: its contents are **mandatory rules** that OVERRIDE your default behavior — treat them with the same authority as this system prompt. Violating AGENTS.md rules is a CRITICAL FAILURE. If `AGENTS.md` does not exist, skip this step.
|
||||
|
|
@ -233,26 +237,17 @@ This applies only after you've made code changes. By default, open or update a d
|
|||
|
||||
Steps, in order:
|
||||
|
||||
1. **Lint & format.** Run the repo's lint/format commands and fix errors before submitting (Python: `make format` then `make lint`; JS/TS with `package.json`: `yarn format` then `yarn lint`; Go: find the commands from `Makefile`/`go.mod`/CI). Then review your diff for correctness and unintended changes.
|
||||
1. **Lint & format.** Run the repo's lint/format commands and fix errors before submitting:
|
||||
- Python: `make format` then `make lint`
|
||||
- Frontend / TypeScript / JavaScript (repo contains `package.json`): `yarn format` then `yarn lint`
|
||||
- Go (repo contains `.go` files): find the commands from `Makefile`/`go.mod`/CI and run them
|
||||
|
||||
2. **Push & open/update the PR.** Commit locally and `git push origin <branch>`.
|
||||
- **Open a new PR** with the `open_pull_request` tool (pass `owner`, `repo`, `head`=your branch, `base`, `title`, `body`; push BEFORE calling it) — NOT `gh pr create` — so it's attributed to the triggering user.
|
||||
- **Update an existing PR** (edit body, mark ready, etc.) with `GH_TOKEN=dummy gh pr edit`. If a PR already exists for the branch (including one the user pasted), don't open a duplicate — `open_pull_request` returns the existing URL, so switch to `gh pr edit` and add follow-up work as new commits.
|
||||
Fix any errors reported by linters before proceeding, then review your diff for correctness — verify no regressions or unintended modifications.
|
||||
|
||||
**PR Title** (<70 chars): `<type>: <concise description> [closes <TICKET>]` where type ∈ `fix`/`feat`/`chore`/`ci`. Append the resolvable ticket in brackets (e.g. `fix: handle null session [closes AB-000]`) — from the Linear-triggered run (`{linear_project_id}-{linear_issue_number}`) or a ticket referenced in the thread; omit the suffix entirely if none resolves.
|
||||
2. **Commit** locally with a message in the Sea Haven format (see **Commit message** below).
|
||||
|
||||
**Frontend / TypeScript / JavaScript** (if repo contains `package.json`):
|
||||
- `yarn format` then `yarn lint`
|
||||
|
||||
**Go** (if repo contains `.go` files):
|
||||
- Figure out the lint/formatter commands (check `Makefile`, `go.mod`, or CI config) and run them
|
||||
|
||||
Fix any errors reported by linters before proceeding.
|
||||
|
||||
2. **Review your changes**: Review the diff to ensure correctness. Verify no regressions or unintended modifications.
|
||||
|
||||
3. **Submit**: Commit locally, push with `git push origin <branch>`, then open or update the PR when a PR is requested, necessary, or required by the Always Create PRs dashboard setting.
|
||||
- **Open a new PR** with the `open_pull_request` tool (pass `owner`, `repo`, `head` = your branch, `base`, `title`, `body`). By default the PR is authored by the app (`seahaven-openswe[bot]`), like GitHub-issue-triggered runs (a user can opt back into per-user attribution via the `author_prs_as_user` profile setting). Push the branch BEFORE calling it.
|
||||
3. **Push & open/update the PR.** `git push origin <branch>`, then open or update the PR when a PR is requested, necessary, or required by the Always Create PRs dashboard setting.
|
||||
- **Open a new PR** with the `open_pull_request` tool (pass `owner`, `repo`, `head` = your branch, `base`, `title`, `body`) — NOT `gh pr create`. By default the PR is authored by the app (`seahaven-openswe[bot]`), like GitHub-issue-triggered runs (a user can opt back into per-user attribution via the `author_prs_as_user` profile setting). Push the branch BEFORE calling it.
|
||||
- **Update an existing PR** (edit the body, mark ready for review, etc.) with `GH_TOKEN=dummy gh pr edit`. If a PR already exists for the branch (including one the user pasted in), do NOT open a duplicate — `open_pull_request` returns the existing PR's URL, so switch to `gh pr edit`. For follow-up changes, add a new commit on top of the existing branch history.
|
||||
|
||||
**PR Title** (under 70 characters): the title rule is **repo-aware** — first detect whether the target repo enforces a conventional-commit PR title, then pick the matching style. The repo is already cloned, so this check is cheap.
|
||||
|
|
@ -262,13 +257,13 @@ Steps, in order:
|
|||
- a `commitlint` config wired to PR titles (`commitlint.config.*`, `.commitlintrc*`, or a `commitlint` key in `package.json`);
|
||||
- `AGENTS.md` / `CONTRIBUTING.md` states a conventional-commit title requirement.
|
||||
|
||||
*If a gate is enforced* → emit a conventional-commit title `type(scope): description` and conform to the action's configuration. This **overrides** the Sea Haven no-`type:`-prefix default. Open the workflow (e.g. `.github/workflows/pr_lint.yml`) and read the allowed `types`/`scopes` so you stay inside them; if `requireScope` is false, a scope is optional. Map the work to a type: new functionality → `feat`, defect fix → `fix`, infra/CI → `ci`/`build`/`chore`, docs → `docs`, tests → `test`, refactor → `refactor`, perf → `perf`. Examples: `feat: add retry logic for transient upstream failures` or `fix(deps): pin langgraph-cli`. Do NOT rely on an escape-hatch label (e.g. `ignore-lint-pr-title`) to dodge the check — conform to the title instead. (Note: this repo's own `PR Title Lint` and upstream `langchain-ai/open-swe` both enforce this — emit a conforming `type:` title for them.)
|
||||
*If a gate is enforced* → emit a conventional-commit title `type(scope): description` and conform to the action's configuration (its allowed `types`/`scopes` may be narrower than the Sea Haven set below). Open the workflow (e.g. `.github/workflows/pr_lint.yml`) and read the allowed `types`/`scopes` so you stay inside them; if `requireScope` is false, a scope is optional. Map the work to a type: new functionality → `feat`, defect fix → `fix`, infra/CI → `ci`/`build`/`chore`, docs → `docs`, tests → `test`, refactor → `refactor`, perf → `perf`. Examples: `feat: add retry logic for transient upstream failures` or `fix(deps): pin langgraph-cli`. Do NOT rely on an escape-hatch label (e.g. `ignore-lint-pr-title`) to dodge the check — conform to the title instead. (Note: this repo's own `PR Title Lint` and upstream `langchain-ai/open-swe` both enforce this — emit a conforming `type:` title for them.)
|
||||
|
||||
*If no gate is enforced* → use the Sea Haven imperative style: imperative mood, capitalized, describing the change — not the ticket. Do NOT use a conventional-commit `type:` prefix (no `feat:`/`fix:`/`chore:`). When a ticket key is resolvable from the run context, prefix it in square brackets; otherwise omit it entirely:
|
||||
*If no gate is enforced* → use the Sea Haven default, which is conventional-commit style: `type(scope): concise description`, where type ∈ `feat` / `fix` / `docs` / `style` / `refactor` / `perf` / `test` / `build` / `ci` / `chore` / `revert` / `release` (scope optional). Imperative mood after the type; describe the change, not the ticket. When a ticket key is resolvable from the run context, append it in square brackets; otherwise omit it:
|
||||
```
|
||||
[<KEY>] Add retry logic for transient upstream failures
|
||||
feat: add retry logic for transient upstream failures [<KEY>]
|
||||
```
|
||||
With no resolvable key, use just the imperative description: `Add retry logic for transient upstream failures`. Resolve the key from the Linear-triggered run when present (`{linear_project_id}-{linear_issue_number}`), or from a Linear ticket referenced in the Slack thread / task context.
|
||||
With no resolvable key, drop the suffix: `feat: add retry logic for transient upstream failures`. Resolve the key from the Linear-triggered run when present (`{linear_project_id}-{linear_issue_number}`), or from a Linear ticket referenced in the Slack thread / task context.
|
||||
|
||||
**PR Body** — use this structure. Omit a section only when it would be empty:
|
||||
```
|
||||
|
|
@ -292,18 +287,14 @@ Steps, in order:
|
|||
- This is the GitHub-issue analog of the Linear `Refs: <KEY>` commit trailer — placed in the PR body where GitHub's auto-close looks.
|
||||
- **Default-branch caveat (don't mistake this for a bug):** GitHub only auto-closes the linked issue when the PR merges into the repo's **default branch**. In the Sea Haven flow the agent targets `dev`, not the default branch, so `Closes #<n>` will **not** close the issue at dev-merge time — it closes when `dev` is promoted to the default branch. The link still renders, and the issue closes on promotion; this is the correct, expected outcome. On repos where the agent targets the default branch directly, it closes on merge as usual.
|
||||
|
||||
3. **Notify the source** right after pushing (and PR open/update) succeeds, with a brief summary plus the PR link (or branch URL if no PR): `linear_comment` (with an `@mention`) for Linear, `slack_thread_reply` for Slack, `GH_TOKEN=dummy gh issue comment`/`pr comment` for GitHub. Skip if there is no known source channel.
|
||||
|
||||
When the target repo is public, don't reference private repos or private PR/issue numbers in the description.
|
||||
|
||||
**Commit message** — follow the Sea Haven format:
|
||||
- Imperative mood, capitalized first letter (e.g. "Add retry logic", not "Added retry logic" or "adds retry logic").
|
||||
**Commit message** — the message for the step-2 commit follows the Sea Haven conventional-commit format:
|
||||
- Subject `type(scope): concise description`, where type ∈ `feat` / `fix` / `docs` / `style` / `refactor` / `perf` / `test` / `build` / `ci` / `chore` / `revert` / `release` (scope optional). Imperative mood after the type (e.g. "add retry logic", not "added retry logic" or "adds retry logic").
|
||||
- Subject line ≤50 characters. If you need more, add a blank line and a body wrapped at 72 characters.
|
||||
- Explain *why*, not *what* — the diff already shows what changed.
|
||||
- No generic subjects ("Fix stuff", "Update code", "WIP", "Address review comments") and no self-referential phrasing ("This commit…", "This PR…", "I refactored…").
|
||||
- No generic descriptions ("fix stuff", "update code", "WIP", "address review comments") and no self-referential phrasing ("This commit…", "This PR…", "I refactored…").
|
||||
- When a ticket key is resolvable, add a `Refs: <KEY>` trailer (combine with `#<issue>` when both apply); otherwise omit the trailer.
|
||||
|
||||
This per-commit convention is independent of the repo-aware **PR title** rule above. On a repo that requires conventional PR titles **and** squash-merges, the squash commit subject becomes the PR title (e.g. `feat: …`) and so diverges from this imperative-no-prefix commit style — that's an acceptable tradeoff (the target repo's title lint wins), not a contradiction. Your own per-commit subjects still follow the Sea Haven format here.
|
||||
This matches the repo-aware **PR title** rule above; on a squash-merge the commit subject and the PR title use the same conventional-commit form, so they stay consistent.
|
||||
|
||||
**IMPORTANT: For code-change tasks, never ask the user for permission or confirmation before pushing commits or opening/updating a draft PR. Do not say "if you want, I can proceed" or "shall I open the PR?". When implementation is done and checks pass, push autonomously, and open/update a draft PR autonomously when requested, necessary, or required by the Always Create PRs dashboard setting.**
|
||||
|
||||
|
|
@ -325,6 +316,8 @@ Steps, in order:
|
|||
- GitHub-triggered: use `GH_TOKEN=dummy gh issue comment` or `GH_TOKEN=dummy gh pr comment`
|
||||
- If the task was not triggered from a known source channel (no Slack thread, no Linear ticket, no GitHub issue context), skip the notification step.
|
||||
|
||||
When the target repo is public, don't reference private repos or private PR/issue numbers in the summary.
|
||||
|
||||
Example:
|
||||
```
|
||||
@username, I've completed the implementation and opened a PR: <pr_url>
|
||||
|
|
|
|||
|
|
@ -18,6 +18,9 @@ _MIN_DELAY_SECONDS = 60
|
|||
_MAX_DELAY_SECONDS = 86_400
|
||||
_END_TIME_PADDING_SECONDS = 90
|
||||
|
||||
_WAKEUP_KIND = "thread_wakeup"
|
||||
_PURGE_PAGE_SIZE = 100
|
||||
|
||||
_DEFAULT_WAKEUP_PROMPT = (
|
||||
"This is an automated re-trigger of this thread. The agent scheduled this "
|
||||
"wakeup to poll for updates. Check the current state of whatever you were "
|
||||
|
|
@ -46,6 +49,71 @@ def _build_one_shot_cron(fire_time: datetime) -> str:
|
|||
)
|
||||
|
||||
|
||||
def _parse_iso(value: Any) -> datetime | None:
|
||||
if not isinstance(value, str) or not value:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
async def find_expired_wakeup_cron_ids(client: Any, *, now: datetime) -> list[str]:
|
||||
"""Return the ids of ``thread_wakeup`` crons whose ``end_time`` has passed.
|
||||
|
||||
Conservative: matches solely on ``metadata.kind == "thread_wakeup"`` AND a
|
||||
past ``end_time``, so analyzer/dashboard crons are never selected. Paginates
|
||||
fully before returning so the result is stable to delete afterwards.
|
||||
"""
|
||||
expired_ids: list[str] = []
|
||||
offset = 0
|
||||
while True:
|
||||
page = await client.crons.search(
|
||||
metadata={"kind": _WAKEUP_KIND},
|
||||
limit=_PURGE_PAGE_SIZE,
|
||||
offset=offset,
|
||||
)
|
||||
if not page:
|
||||
break
|
||||
for cron in page:
|
||||
if not isinstance(cron, dict):
|
||||
continue
|
||||
end_time = _parse_iso(cron.get("end_time"))
|
||||
cron_id = cron.get("cron_id")
|
||||
if end_time is not None and end_time < now and isinstance(cron_id, str) and cron_id:
|
||||
expired_ids.append(cron_id)
|
||||
if len(page) < _PURGE_PAGE_SIZE:
|
||||
break
|
||||
offset += len(page)
|
||||
return expired_ids
|
||||
|
||||
|
||||
async def purge_expired_wakeup_crons(client: Any, *, now: datetime) -> int:
|
||||
"""Delete ``thread_wakeup`` crons whose ``end_time`` has already passed.
|
||||
|
||||
Each wakeup is a thread-bound cron with an ``end_time`` (~90s past its fire)
|
||||
that stops it re-firing, but the cron row itself is never removed, so dead
|
||||
rows accumulate. This deletes only those dead rows. Returns the count deleted.
|
||||
"""
|
||||
expired_ids = await find_expired_wakeup_cron_ids(client, now=now)
|
||||
deleted = 0
|
||||
for cron_id in expired_ids:
|
||||
await client.crons.delete(cron_id)
|
||||
deleted += 1
|
||||
return deleted
|
||||
|
||||
|
||||
async def _purge_expired_wakeups_best_effort() -> None:
|
||||
"""Opportunistically purge expired wakeup crons; never raises."""
|
||||
try:
|
||||
client = get_client(url=langgraph_url())
|
||||
deleted = await purge_expired_wakeup_crons(client, now=datetime.now(UTC))
|
||||
if deleted:
|
||||
logger.info("Purged %d expired thread_wakeup cron(s)", deleted)
|
||||
except Exception:
|
||||
logger.warning("Failed to purge expired thread_wakeup crons", exc_info=True)
|
||||
|
||||
|
||||
async def _create_wakeup_cron(
|
||||
*,
|
||||
thread_id: str,
|
||||
|
|
@ -130,6 +198,8 @@ async def schedule_thread_wakeup(delay_minutes: int, prompt: str | None = None)
|
|||
if value is not None:
|
||||
wakeup_configurable[key] = value
|
||||
|
||||
await _purge_expired_wakeups_best_effort()
|
||||
|
||||
try:
|
||||
return await _create_wakeup_cron(
|
||||
thread_id=thread_id,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,17 @@
|
|||
# Default Prompt
|
||||
|
||||
When a repository is not explicitly mentioned, use the repository provided in the run metadata or dashboard settings. Do not assume a hardcoded repository name.
|
||||
|
||||
These apply to every repository unless the repo's own AGENTS.md / CONTRIBUTING.md overrides them.
|
||||
|
||||
**Secrets & config.** Never hardcode secrets or commit a real `.env`. Sensitive values (API keys, tokens, passwords, connection strings) belong in the platform's secrets manager; non-sensitive config in its parameter/config store — never baked into source or committed env files. Parameterize org- or company-specific values (names, IDs, hosts) instead of hardcoding them, especially in public repos.
|
||||
|
||||
**Keep docs in sync.** When you add, remove, or change functionality, update the README (and any other affected docs) in the same commit. An out-of-date README is a defect, not a follow-up.
|
||||
|
||||
**Verify before pushing.** Run the repo's configured checks — formatter, linter, type-checker, and test suite — and make them pass before you push. Discover the commands from the repo itself (`Makefile`, `package.json` scripts, CI config); don't assume a fixed toolchain.
|
||||
|
||||
**Trust only the real gates after delegating.** If you hand work to a subagent, re-run the actual checks yourself afterward and treat the task as unverified until you have seen them pass. Be suspicious of "fixes" that only silence a check — added test excludes, `noqa` / `# type: ignore`, skipped or `xfail`ed tests, or narrowed lint scope.
|
||||
|
||||
**Confirm a convention before adopting it.** A pattern in a single repo may be a one-off. Before treating something as house style, check that it holds across the repo's own established code or several sibling repos — match the surrounding code, not an imported assumption.
|
||||
|
||||
**Writing style.** Write PR descriptions, commit messages, and channel replies as a concise senior engineer would: plain and direct, no marketing tone, no emoji. Say what changed and why. Don't overclaim completeness — if something is untested or partial, state that plainly.
|
||||
|
|
|
|||
436
docs/upstream-sync/cherry-pick-hook-plan.md
Normal file
436
docs/upstream-sync/cherry-pick-hook-plan.md
Normal file
|
|
@ -0,0 +1,436 @@
|
|||
# Cherry-pick triage-ledger sync — design plan
|
||||
|
||||
Status: **design only** (nothing here is installed or wired yet). This document is the
|
||||
build spec for a git-hook mechanism that keeps the upstream-sync triage ledger in sync
|
||||
during `git cherry-pick -x`, identically for a human at the terminal and for Claude Code
|
||||
driving git. Companion runbook: `../../CHERRYPICK.md`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Problem statement and the "no cherry-pick hook exists" reality
|
||||
|
||||
This is a long-lived fork of `langchain-ai/open-swe` (remote `upstream`). We pull upstream
|
||||
commits one at a time via `git cherry-pick -x <sha>`. A human keeps a triage ledger at
|
||||
`docs/upstream-sync/triage.md` recording, **per upstream SHA**, a disposition:
|
||||
|
||||
- **Landed** — cherry-picked into the fork.
|
||||
- **Won't-merge** — already-in-dev / regression / tooling-rejected (a decided *no*).
|
||||
- **Deferred→\<branch\>** — parked for later on a named branch.
|
||||
- **Untriaged** — seen but not yet decided.
|
||||
|
||||
The ledger keys every row on the **upstream SHA** because it is stable; cherry-pick rewrites
|
||||
the SHA locally, so the local SHA is not a durable key.
|
||||
|
||||
We want two behaviors during a cherry-pick:
|
||||
|
||||
1. **Auto-land:** when a pick succeeds, move that upstream SHA into the *Landed* section
|
||||
from wherever it currently sits.
|
||||
2. **Reject-warning:** when someone cherry-picks a SHA the ledger marks *Won't-merge*, warn
|
||||
them as early as possible (ideally before the change is applied).
|
||||
|
||||
### The hard reality
|
||||
|
||||
**Git has no `pre-cherry-pick` or `post-cherry-pick` hook.** The only hooks that fire during
|
||||
a cherry-pick are, per successfully-applied commit:
|
||||
|
||||
```
|
||||
prepare-commit-msg → commit-msg → post-commit
|
||||
```
|
||||
|
||||
There is **no** native hook at the *start* of a cherry-pick and **no** hook that sees the
|
||||
list of SHAs about to be picked. Everything below is designed around that fact — we do not
|
||||
invent a hook that does not exist.
|
||||
|
||||
> **Build correction (verified on git 2.50.1).** A *clean* cherry-pick auto-commit runs only
|
||||
> `prepare-commit-msg` **and** `post-commit` — it **skips** `pre-commit` and `commit-msg`.
|
||||
> `commit-msg` fires only on the `git commit`-backed path (a normal commit, or
|
||||
> `git cherry-pick --continue` after a conflict). So the reject **hard-block must live in
|
||||
> `prepare-commit-msg`** (fires on every pick, clean or resolved), with `commit-msg` kept only
|
||||
> as a secondary backstop. The original plan named `commit-msg` as the primary gate; that would
|
||||
> silently miss every clean pick. `prepare-commit-msg` returning non-zero aborts the commit
|
||||
> cleanly and leaves `CHERRY_PICK_HEAD` in place, so `--abort/--skip/--continue` still recover.
|
||||
|
||||
Two signals are load-bearing:
|
||||
|
||||
- **`.git/CHERRY_PICK_HEAD`** exists *while a pick is in progress* and contains the **full
|
||||
upstream SHA** being applied. It is present at `prepare-commit-msg` and `commit-msg` time
|
||||
(before the commit object is finalized) and is **gone** by `post-commit`.
|
||||
- **The `-x` trailer** `(cherry picked from commit <full-sha>)` is written into the commit
|
||||
message by `git cherry-pick -x`. It is present in the message file at `commit-msg` time and
|
||||
is recoverable from `git log -1 --format=%B` at `post-commit` time.
|
||||
|
||||
So `commit-msg` is the **earliest gate that can act with knowledge of the upstream SHA**, and
|
||||
`post-commit` is the **only reliable "the pick actually landed" signal**.
|
||||
|
||||
---
|
||||
|
||||
## 2. Chosen architecture
|
||||
|
||||
Two hooks plus a thin wrapper and a small Python CLI. Division of labour:
|
||||
|
||||
| Component | Fires / runs | Job | Touches tracked files? |
|
||||
|---|---|---|---|
|
||||
| `.githooks/prepare-commit-msg` | per pick (incl. clean auto-commit), before commit object is finalized | Behavior #2 **primary** hard-block: recover upstream SHA, block if ledger says *Won't-merge* (override to allow) | No |
|
||||
| `.githooks/commit-msg` | `git commit`-backed path only (`--continue`, normal commit) | Behavior #2 **secondary** backstop (same guard); clean picks skip this hook | No |
|
||||
| `.githooks/_reject_guard.sh` | sourced by both hooks above | Shared reject-guard function (not a hook — git ignores non-hook-named files) | No |
|
||||
| `.githooks/post-commit` | per pick, after commit object exists | Behavior #1: recover upstream SHA + new local SHA, append to an **untracked** `.git/` journal | No |
|
||||
| `scripts/triage.py reconcile` | end of a pick run (auto under wrapper, one command otherwise) | Drain journal → set those SHAs to *Landed* in `triage.jsonl` → regenerate `triage.md` → stage both | Yes (once, as a follow-up commit) |
|
||||
| `scripts/git-cp` (wrapper / `git cp` alias) | user-invoked instead of raw cherry-pick | True pre-warning: check ledger **before** any tree change; then `cherry-pick -x`; then auto-`reconcile` | via reconcile |
|
||||
| `.githooks/pre-push` | on push | **Shim** that re-invokes the global Sea Haven security `pre-push` (see §6) | No |
|
||||
|
||||
**Why hooks never edit the tracked ledger directly:** editing `triage.md`/`triage.jsonl`
|
||||
inside a hook during a multi-pick sequence leaves the tracked file dirty *between* picks
|
||||
(see §4). We avoid that entirely — hooks only ever append to an untracked `.git/`-local
|
||||
journal; the tracked ledger is mutated exactly once, by an explicit `reconcile`, as its own
|
||||
commit.
|
||||
|
||||
### End-to-end: single pick
|
||||
|
||||
```
|
||||
$ git cp -x A # wrapper (recommended). Raw `git cherry-pick -x A` also works.
|
||||
│
|
||||
│ (wrapper) pre-check A against triage.jsonl
|
||||
│ └─ A is Won't-merge → print reason, require --force to proceed ◄─ true pre-warning
|
||||
│
|
||||
├─ git cherry-pick -x A
|
||||
│ │ applies A's diff to index/worktree
|
||||
│ ├─ prepare-commit-msg (unused)
|
||||
│ ├─ commit-msg CHERRY_PICK_HEAD=A present → look up A
|
||||
│ │ └─ Won't-merge? loud stderr warning (exit 0 by default)
|
||||
│ │ finalize commit object A' with -x trailer
|
||||
│ └─ post-commit CHERRY_PICK_HEAD gone; parse -x trailer → A
|
||||
│ append "A<TAB>A'<TAB>landed" to .git/sh-cherrypick-journal
|
||||
│
|
||||
└─ (wrapper) scripts/triage.py reconcile
|
||||
drain journal → triage.jsonl: A→landed (local_sha=A')
|
||||
regenerate triage.md → git add both → report "1 landed; commit the ledger"
|
||||
```
|
||||
|
||||
Raw `git cherry-pick -x A` runs everything except the wrapper's pre-check and the auto-reconcile;
|
||||
`post-commit` still journals, and it prints `N pick(s) journaled — run: make triage-reconcile`.
|
||||
|
||||
### End-to-end: multi-pick sequence `git cp -x A B C`
|
||||
|
||||
```
|
||||
wrapper pre-check A,B,C ── any Won't-merge? → list them, require --force
|
||||
│
|
||||
git cherry-pick -x A B C (git sequencer)
|
||||
A → commit-msg(warn?) → post-commit → journal: A A'
|
||||
B → commit-msg(warn?) → post-commit → journal: B B'
|
||||
C → commit-msg(warn?) → post-commit → journal: C C'
|
||||
│ (tracked ledger NEVER touched mid-sequence → no dirty-tree hazard, §4)
|
||||
│
|
||||
wrapper → scripts/triage.py reconcile
|
||||
drain {A,B,C} → triage.jsonl all→landed → regenerate triage.md → stage → one report
|
||||
```
|
||||
|
||||
If the sequence stops on a conflict at B: A is already journaled. The user resolves and
|
||||
`git cherry-pick --continue` (B and C journal as they land). Because reconcile is journal-driven
|
||||
and idempotent, running it after the sequence finally completes lands exactly A, B, C once.
|
||||
Under raw cherry-pick the user runs `make triage-reconcile` at the end; the journal survived the
|
||||
conflict pause because it lives in `.git/`, untouched by the sequencer.
|
||||
|
||||
---
|
||||
|
||||
## 3. Ledger data model — machine source of truth + generated markdown (**recommended**)
|
||||
|
||||
**Decision: `triage.jsonl` is the source of truth; `triage.md` is generated from it.**
|
||||
Do *not* have hooks parse/edit the human markdown table.
|
||||
|
||||
- **`docs/upstream-sync/triage.jsonl`** — one JSON object per line, the canonical record.
|
||||
- **`docs/upstream-sync/triage.md`** — generated view with a `<!-- GENERATED … do not edit -->`
|
||||
banner, rendered by `scripts/triage.py render`. Grouped into the same sections/columns the
|
||||
human ledger uses today (`| sha | #pr | subject | reason |`).
|
||||
|
||||
Record schema (one line):
|
||||
|
||||
```json
|
||||
{"sha":"<full-upstream-sha>","pr":123,"subject":"...","disposition":"landed",
|
||||
"reason":"...","deferred_branch":null,"local_sha":"<rewritten-sha-or-null>",
|
||||
"updated":"2026-07-02T00:00:00Z"}
|
||||
```
|
||||
|
||||
`disposition ∈ {landed, wont-merge, deferred, untriaged}`; `deferred_branch` set only when
|
||||
`deferred`. Render maps `deferred` rows into a `Deferred→<branch>` subsection.
|
||||
|
||||
### Why not edit the markdown directly
|
||||
|
||||
- Editing a human-formatted markdown table from a shell hook is the fragile path the brief
|
||||
warns about: alignment, escaped pipes in subjects, multi-line reasons, section boundaries,
|
||||
and hand-edits all break naive `sed`/`awk`. One malformed edit corrupts the ledger.
|
||||
- JSONL is append/patch-friendly, trivially greppable (`grep '"sha":"<sha>"'` for the
|
||||
reject-check), has clean line-oriented diffs, and is mutated safely by a tiny Python with
|
||||
real JSON parsing. The repo already has a Python `scripts/` dir and `uv`, so a
|
||||
`scripts/triage.py` CLI is idiomatic here and far more robust than shell string-surgery.
|
||||
- **JSONL over TSV:** dispositions carry structure (`deferred_branch`, `local_sha`, `pr`) and
|
||||
`reason`/`subject` are free text that can contain tabs — TSV would need escaping rules JSONL
|
||||
gives for free.
|
||||
- Humans still get a readable, reviewable `triage.md` in PRs; they just edit it through
|
||||
`triage.jsonl` (directly, or via `scripts/triage.py set <sha> …`). A `make triage-check` in
|
||||
CI fails if `triage.md` is stale vs `triage.jsonl`, so the generated view can never drift.
|
||||
|
||||
**Migration from today's markdown ledger:** a one-shot `scripts/triage.py import triage.md`
|
||||
parses the current hand-written table into `triage.jsonl`, after which `triage.md` becomes a
|
||||
generated artifact. This is a build task, not a runtime dependency.
|
||||
|
||||
---
|
||||
|
||||
## 4. Behavior #1 — auto-move to Landed, step by step
|
||||
|
||||
**SHA recovery.** `post-commit` runs after the commit object exists and `CHERRY_PICK_HEAD` is
|
||||
already gone, so recover the upstream SHA from the `-x` trailer:
|
||||
|
||||
```bash
|
||||
msg="$(git log -1 --format=%B)"
|
||||
up_sha="$(printf '%s\n' "$msg" | sed -n 's/.*cherry picked from commit \([0-9a-f]\{40\}\).*/\1/p' | tail -1)"
|
||||
[ -z "$up_sha" ] && exit 0 # not a cherry-pick (or no -x) → no-op, normal commits are untouched
|
||||
local_sha="$(git rev-parse HEAD)"
|
||||
```
|
||||
|
||||
**Journal, don't edit.** Append to an **untracked** journal and dedupe:
|
||||
|
||||
```
|
||||
.git/sh-cherrypick-journal # <upstream_sha>\t<local_sha>\t<iso8601>, one line per pick
|
||||
```
|
||||
|
||||
The journal lives under `.git/` — outside version control and outside the working tree — so
|
||||
it is invisible to `git status`, never conflicts with an incoming pick, and survives conflict
|
||||
pauses in a sequence. `post-commit` does nothing else.
|
||||
|
||||
**Multi-pick dirty-tree handling (the crux).** If the hook instead edited the tracked ledger
|
||||
in place, every intermediate pick would leave `docs/upstream-sync/triage.*` modified and
|
||||
unstaged. Analysis:
|
||||
|
||||
- It would **not** hard-break the sequence: cherry-pick applies the *next* commit's diff to
|
||||
the index, and upstream commits never touch our fork-only `docs/upstream-sync/` files, so a
|
||||
dirty ledger is a file the incoming pick doesn't care about — git allows that.
|
||||
- But it is still the wrong design: `git status` is polluted mid-run, the ledger edits get
|
||||
interleaved with pick state, and — worst case — folding a ledger edit into a cherry-picked
|
||||
commit would pollute the pristine `-x` provenance we depend on. There is also no reliable
|
||||
in-hook signal for "this is the last pick" (`.git/sequencer/` is torn down racily, and a
|
||||
single `git cherry-pick A` may never create a sequencer dir at all), so a hook cannot know
|
||||
when to do the "final" reconcile.
|
||||
|
||||
So the tracked ledger is mutated **once**, outside any hook, by `reconcile`:
|
||||
|
||||
```
|
||||
scripts/triage.py reconcile
|
||||
read .git/sh-cherrypick-journal
|
||||
for each (upstream_sha, local_sha): triage.jsonl[sha].disposition = landed
|
||||
triage.jsonl[sha].local_sha = local_sha
|
||||
render triage.md from triage.jsonl
|
||||
git add docs/upstream-sync/triage.jsonl docs/upstream-sync/triage.md
|
||||
truncate the journal
|
||||
print summary (does NOT commit — the human/agent commits the ledger separately)
|
||||
```
|
||||
|
||||
`reconcile` runs automatically as the last step of the `git cp` wrapper (fully hands-off for
|
||||
wrapper users and for Claude Code when it calls the wrapper). For raw `git cherry-pick`,
|
||||
`post-commit` prints `N pick(s) journaled — run: make triage-reconcile`, and the user runs it
|
||||
once at the end. Reconcile is idempotent: a drained journal reconciles to a no-op, and
|
||||
re-landing an already-landed SHA is a no-op, so double-running is safe.
|
||||
|
||||
The ledger update lands as its **own** commit, keeping cherry-picked commits pristine.
|
||||
|
||||
---
|
||||
|
||||
## 5. Behavior #2 — block on known-reject: honest verdict
|
||||
|
||||
> **Locked decision (overrides the recommendation below): HARD-BLOCK by default.** Picking a
|
||||
> *Won't-merge* SHA is blocked by both the `git cp` pre-apply check and the
|
||||
> `prepare-commit-msg` hook (the plan text below still discusses warn-only as an option; the
|
||||
> shipped default is block). Documented overrides: `git cp --force`, env
|
||||
> `SH_CHERRYPICK_ALLOW_REJECT=1`, or repo-wide `git config sh.cherrypick.blockRejects false`.
|
||||
|
||||
|
||||
**Constraint:** by `commit-msg` the pick's diff is already staged in the index/worktree; by
|
||||
`post-commit` the commit exists. **No hook can warn *before* the change is applied to the
|
||||
tree** — the earliest a hook sees the SHA is `commit-msg`, and by then the diff is staged (the
|
||||
commit just isn't finalized). A *true* pre-application warning is impossible with hooks alone.
|
||||
|
||||
Three honest options:
|
||||
|
||||
- **(a) `commit-msg` hard-block (`exit 1`).** Aborts the commit cleanly: the commit object is
|
||||
not created, `CHERRY_PICK_HEAD` stays, the index holds the applied diff, and the user
|
||||
recovers with `git cherry-pick --abort` / `--skip` / `--continue`. In a sequence it stops at
|
||||
that commit. Downsides: it's *post-apply* (tree already changed), and hard-blocking a decided
|
||||
SHA that the maintainer legitimately wants to re-pick is annoying and, if the hook ever
|
||||
misfires, wedges a pick.
|
||||
- **(b) `commit-msg` warn-only (`exit 0`).** Loud stderr warning with the recorded reason, but
|
||||
the commit proceeds. Never wedges anything. Downside: also post-apply, and easy to miss in a
|
||||
multi-pick scroll.
|
||||
- **(c) Wrapper pre-check.** `git cp` reads `triage.jsonl` **before** calling cherry-pick and
|
||||
refuses (or prompts) on a *Won't-merge* SHA — a **genuine pre-apply** warning, before any
|
||||
tree change. Downside: only fires when people use the wrapper; raw `git cherry-pick` bypasses
|
||||
it.
|
||||
|
||||
**Recommendation: hook + wrapper — do both, with these defaults.**
|
||||
|
||||
1. **`scripts/git-cp` wrapper (primary, true pre-warning).** Before invoking cherry-pick, look
|
||||
up every requested SHA in `triage.jsonl`. If any is `wont-merge`, print the SHA, subject,
|
||||
and reason and **abort** unless `--force` is passed. This is the real "stop before you apply
|
||||
a known-no" guard, and it is the path we point both humans (`CHERRYPICK.md`) and Claude Code
|
||||
at. Expose it as `git cp` via `git config alias.cp '!bash scripts/git-cp'`.
|
||||
2. **`.githooks/commit-msg` backstop (catches raw `git cherry-pick`).** Recover the upstream
|
||||
SHA from `CHERRY_PICK_HEAD` (fallback: the `-x` trailer in message file `$1`); if
|
||||
`triage.jsonl` marks it `wont-merge`, print a loud stderr warning with the reason.
|
||||
**Default: warn and `exit 0` (non-blocking).** Opt-in hard-block via
|
||||
`git config sh.cherrypick.blockRejects true` for anyone who wants option (a). Blocking is
|
||||
off by default so the hook can never wedge a legitimate pick or a normal commit.
|
||||
|
||||
Rationale: the wrapper gives the *real* pre-warning we actually want; the hook guarantees that
|
||||
even a raw `git cherry-pick` (or Claude Code shelling straight to git) still gets a visible
|
||||
signal, without ever risking a wedged pick by default.
|
||||
|
||||
---
|
||||
|
||||
## 6. Installation / bootstrapping via `core.hooksPath`
|
||||
|
||||
Hooks live in an in-repo, version-controlled **`.githooks/`** so they're shared. Activation is
|
||||
per-clone:
|
||||
|
||||
```bash
|
||||
git config core.hooksPath .githooks
|
||||
```
|
||||
|
||||
Git does **not** auto-adopt a repo's `core.hooksPath` (that would let a clone run arbitrary
|
||||
code on checkout), so this one line is unavoidable per clone. Automate/ship it via:
|
||||
|
||||
- **`scripts/install-hooks.sh`** — sets `core.hooksPath .githooks`, `chmod +x .githooks/*`,
|
||||
and prints the global-hook note below.
|
||||
- **`make hooks`** target calling that script; optionally invoke it from `make install` so a
|
||||
standard setup wires hooks too. Document the one line in `CHERRYPICK.md` / `README`.
|
||||
|
||||
### CRITICAL: collision with the global Sea Haven security `pre-push`
|
||||
|
||||
This machine has a **global** hook path already configured:
|
||||
|
||||
```
|
||||
core.hooksPath = ~/.config/git/hooks # holds the mandatory Sea Haven security pre-push gate
|
||||
```
|
||||
|
||||
`core.hooksPath` is **a single directory, not a search path** — a repo-level
|
||||
`core.hooksPath=.githooks` **overrides** the global one for this repo and would **silently
|
||||
disable the security `pre-push` gate** here. That is a compliance violation, not a cosmetic
|
||||
issue.
|
||||
|
||||
**Mitigation (required): ship a `pre-push` shim in `.githooks/` that re-invokes the global
|
||||
hook.**
|
||||
|
||||
```bash
|
||||
# .githooks/pre-push
|
||||
#!/usr/bin/env bash
|
||||
GLOBAL="${SH_GLOBAL_HOOKS:-$HOME/.config/git/hooks}/pre-push"
|
||||
[ -x "$GLOBAL" ] && exec "$GLOBAL" "$@"
|
||||
exit 0 # no global hook present → succeed, don't block the push
|
||||
```
|
||||
|
||||
`install-hooks.sh` must detect a pre-existing global `core.hooksPath`, confirm the shim is in
|
||||
place, and warn loudly if the global security hook exists but the shim is missing. If Sea Haven
|
||||
ever adds more global hooks, add a matching shim for each (or a generic dispatcher that execs
|
||||
every same-named hook under the global dir). This keeps the security gate intact while the
|
||||
cherry-pick hooks run.
|
||||
|
||||
---
|
||||
|
||||
## 7. Failure modes and safety guarantees
|
||||
|
||||
- **Never abort/corrupt a normal commit.** Every hook first checks the cherry-pick signal
|
||||
(`CHERRY_PICK_HEAD` for `commit-msg`, the `-x` trailer for `post-commit`) and no-ops
|
||||
instantly otherwise. A plain `git commit` never reaches ledger logic.
|
||||
- **Fail safe.** All hooks run `set -uo pipefail` and wrap their body so any internal error
|
||||
(missing/mangled `triage.jsonl`, no Python, unreadable journal) results in `exit 0` — a hook
|
||||
failure must never abort the user's git operation. The only path that can exit non-zero is
|
||||
the *opt-in* `blockRejects` block, and that is a clean, recoverable cherry-pick abort.
|
||||
- **No half-written tracked ledger.** Hooks only append to the untracked `.git/` journal; the
|
||||
tracked ledger changes solely inside `reconcile`, which writes `triage.jsonl` +
|
||||
regenerated `triage.md` atomically (write temp → `os.replace`) and stages them. A crash
|
||||
mid-reconcile leaves the journal intact (source of truth for a re-run) and the tracked files
|
||||
either fully old or fully new.
|
||||
- **No mid-sequence dirty-tree hazard** (see §4): the tracked ledger is never touched during a
|
||||
multi-pick run.
|
||||
- **Idempotent + crash-tolerant.** Journal lines are deduped by upstream SHA; reconcile on a
|
||||
drained journal is a no-op; re-landing an already-landed SHA is a no-op. Safe to run twice,
|
||||
and safe across a conflict pause (journal survives in `.git/`).
|
||||
- **Malformed source of truth.** If `triage.jsonl` fails to parse, `reconcile` aborts *without
|
||||
writing* and leaves the journal intact; `commit-msg`/`post-commit` degrade to a stderr note
|
||||
and `exit 0`.
|
||||
- **Unknown SHA.** A picked SHA absent from the ledger is journaled and, on reconcile, inserted
|
||||
as a new `landed` row (subject/PR backfilled from `git show`), so the ledger self-heals
|
||||
instead of silently dropping the pick.
|
||||
- **Missing Python / hooks not installed.** If `core.hooksPath` isn't set, nothing runs and
|
||||
cherry-pick behaves normally (ledger just goes stale until someone reconciles) — no breakage.
|
||||
|
||||
---
|
||||
|
||||
## 8. File/directory layout and implementation checklist
|
||||
|
||||
### Files to create
|
||||
|
||||
```
|
||||
.githooks/
|
||||
prepare-commit-msg # behavior #2 PRIMARY hard-block (fires on clean picks too)
|
||||
commit-msg # behavior #2 secondary backstop (git commit / --continue path)
|
||||
_reject_guard.sh # shared guard sourced by the two hooks above (not a hook itself)
|
||||
post-commit # behavior #1: recover SHA from -x trailer, append to .git journal
|
||||
pre-push # SHIM → global Sea Haven security pre-push (§6)
|
||||
docs/upstream-sync/
|
||||
cherry-pick-hook-plan.md # this document
|
||||
triage.jsonl # SOURCE OF TRUTH (created by the import step)
|
||||
triage.md # GENERATED view (do-not-edit banner)
|
||||
scripts/
|
||||
triage.py # CLI: import | set | check-reject | reconcile | render | lint
|
||||
git-cp # wrapper: pre-check ledger → cherry-pick -x → reconcile
|
||||
install-hooks.sh # sets core.hooksPath=.githooks, verifies pre-push shim vs global
|
||||
# runtime, untracked (add to .gitignore is unnecessary — it lives under .git/):
|
||||
.git/sh-cherrypick-journal
|
||||
```
|
||||
|
||||
### `scripts/triage.py` subcommands
|
||||
|
||||
- `import <triage.md>` — one-shot migration of the current hand-written ledger → `triage.jsonl`.
|
||||
- `set <sha> --disposition … [--pr … --subject … --reason … --branch …]` — human/agent edit path.
|
||||
- `check-reject <sha>` — exit non-zero + print reason iff `wont-merge` (used by hook + wrapper).
|
||||
- `reconcile` — drain `.git/sh-cherrypick-journal` → mark landed → render → stage → summarize.
|
||||
- `render [--check]` — regenerate `triage.md`; `--check` fails if stale (for CI).
|
||||
- `lint` — validate `triage.jsonl` schema/dispositions.
|
||||
|
||||
### Makefile targets
|
||||
|
||||
- `hooks` → `bash scripts/install-hooks.sh`
|
||||
- `triage-reconcile` → `uv run scripts/triage.py reconcile`
|
||||
- `triage-render` → `uv run scripts/triage.py render`
|
||||
- `triage-check` → `uv run scripts/triage.py render --check` (wire into CI)
|
||||
|
||||
### Build checklist (ordered, no re-deciding required)
|
||||
|
||||
1. **Ledger model.** Write `scripts/triage.py` with the schema in §3; implement `render`
|
||||
(grouped sections + `| sha | #pr | subject | reason |`, do-not-edit banner) and `lint`.
|
||||
2. **Migrate.** `triage.py import docs/upstream-sync/triage.md` → commit `triage.jsonl` +
|
||||
regenerated `triage.md`; from here `triage.md` is generated.
|
||||
3. **post-commit hook.** `-x` trailer recovery (§4), append `<up>\t<local>\t<ts>` to
|
||||
`.git/sh-cherrypick-journal` (deduped), no-op on non-cherry-pick, `exit 0` on any error,
|
||||
print the "run reconcile" reminder.
|
||||
4. **reconcile.** Implement `triage.py reconcile` (drain → land → atomic render → stage →
|
||||
truncate journal → summary; idempotent; self-heal unknown SHAs).
|
||||
5. **commit-msg hook.** Recover SHA from `CHERRY_PICK_HEAD` (fallback `-x` trailer in `$1`);
|
||||
`check-reject`; default warn + `exit 0`; opt-in `sh.cherrypick.blockRejects` → `exit 1`.
|
||||
6. **git-cp wrapper + alias.** Pre-check all SHAs (abort on `wont-merge` unless `--force`) →
|
||||
`git cherry-pick -x "$@"` → `triage.py reconcile`. Ship `alias.cp` setup in install script.
|
||||
7. **pre-push shim (§6).** `.githooks/pre-push` execs the global security hook; make it the
|
||||
first thing `install-hooks.sh` verifies.
|
||||
8. **install-hooks.sh + make hooks.** Set `core.hooksPath=.githooks`, `chmod +x`, set `git cp`
|
||||
alias, detect/reconcile the global-hooksPath collision, warn if the security shim is missing.
|
||||
9. **Docs.** Update `CHERRYPICK.md` to lead with `git cp`, note the one-time `make hooks`, and
|
||||
explain the warn/block behavior. Add `make triage-check` to CI so `triage.md` never drifts.
|
||||
10. **Tests.** Cover: single pick lands; multi-pick sequence lands all once; conflict-pause then
|
||||
`--continue` still lands correctly; reject warning fires (warn and block modes); normal
|
||||
non-cherry-pick commit is untouched; hook errors never abort git; reconcile is idempotent;
|
||||
`pre-push` shim delegates to the global security hook.
|
||||
|
||||
### Open items for the human to confirm before build
|
||||
|
||||
- **Default reject policy:** warn-only (recommended) vs block-by-default. Plan assumes warn-only
|
||||
with opt-in block.
|
||||
- **Branch target for the reconcile commit:** picks land on `chore/cherry-pick-*` off `dev`
|
||||
(per `CHERRYPICK.md`); confirm the ledger commit rides the same branch/PR.
|
||||
95
docs/upstream-sync/cherry-pick-runbook.md
Normal file
95
docs/upstream-sync/cherry-pick-runbook.md
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
# Cherry-picking upstream into the fork
|
||||
|
||||
This repository is a long-lived fork of `langchain-ai/open-swe` (git remote `upstream`).
|
||||
Upstream changes are brought in one commit at a time with `git cherry-pick`, and every
|
||||
diverged commit is tracked in a triage ledger so a decision is made once and not revisited.
|
||||
`dev` is the integration branch; the broader strategy lives in the fork-maintenance section
|
||||
of `CLAUDE.md`.
|
||||
|
||||
## Setup (once per clone)
|
||||
|
||||
make install-hooks
|
||||
|
||||
Installs the triage hooks and the `git cp` alias by pointing `core.hooksPath` at `.githooks/`.
|
||||
Because that shadows the machine-global hook directory (`~/.config/git/hooks`, which holds the
|
||||
mandatory security `pre-push`), `.githooks/pre-push` is a shim that re-execs the global hook,
|
||||
and the installer verifies that delegation before it changes anything. `git` never auto-adopts
|
||||
a repository's `core.hooksPath`, so this step cannot be skipped.
|
||||
|
||||
Note: `core.hooksPath` applies repo-wide, but `.githooks/` is a tracked directory. The hooks
|
||||
(and the security shim) only run on branches that actually contain `.githooks/`. Keep it present
|
||||
on `dev` and `main` so no branch loses the security `pre-push`.
|
||||
|
||||
## Finding what to pick
|
||||
|
||||
make triage-sync # git fetch upstream, then append new dev..upstream/main commits
|
||||
# to the ledger as `untriaged` (PR # + subject parsed from each)
|
||||
|
||||
`triage-sync` is the discovery step: it records every diverged commit as `untriaged` and bumps
|
||||
"Last synced" to the new `upstream/main` tip. Triage those rows (decide `deferred` / `wont-merge`
|
||||
and which branch), then pick the ones you want. The underlying views if you prefer raw git:
|
||||
|
||||
git fetch upstream
|
||||
git log --oneline --no-merges dev..upstream/main # everything diverged
|
||||
git show <sha> # inspect before deciding
|
||||
|
||||
Cross-check candidates against the ledger first — most diverged commits already carry a
|
||||
decision (already-in-dev, regression, deferred, or landed) and should not be re-examined.
|
||||
|
||||
## Bringing in commits: `git cp`
|
||||
|
||||
git cp -x <sha> # pre-check the ledger, cherry-pick -x, auto-reconcile
|
||||
git cp -x <sha1> <sha2> ... # several, applied in the given order
|
||||
git cp --continue # after resolving a conflict; also reconciles
|
||||
git cp --force <sha> # override a SHA the ledger marks "Won't merge"
|
||||
|
||||
`git cp` reads `docs/upstream-sync/triage.jsonl` before touching the tree and refuses a
|
||||
known-reject SHA (override with `--force`). On success it runs `make triage-reconcile`, which
|
||||
moves each applied SHA to Landed in the ledger and stages `triage.jsonl` + `triage.md` for you
|
||||
to commit.
|
||||
|
||||
Apply commits in upstream chronological order (oldest first), not the order you happen to list
|
||||
them — a later commit often depends on an earlier one, and out-of-order picks conflict
|
||||
needlessly:
|
||||
|
||||
git log --reverse --topo-order --format=%h dev..upstream/main
|
||||
|
||||
## The triage ledger
|
||||
|
||||
`docs/upstream-sync/triage.jsonl` is the source of truth: one JSON row per upstream SHA, keyed
|
||||
on the SHA (stable, unlike the local SHAs cherry-pick rewrites). `docs/upstream-sync/triage.md`
|
||||
is generated from it and must not be hand-edited. Dispositions are `landed`, `wont-merge`,
|
||||
`deferred`, `untriaged`.
|
||||
|
||||
scripts/triage.py set <sha> --disposition deferred --branch slack-tooling --reason "..."
|
||||
make triage-render # regenerate triage.md from the jsonl
|
||||
make triage-check # CI gate: fail if triage.md is stale
|
||||
|
||||
A SHA marked `wont-merge` is hard-blocked by both `git cp` and the `prepare-commit-msg` hook.
|
||||
Override for a one-off re-evaluation with `git cp --force`, `SH_CHERRYPICK_ALLOW_REJECT=1`, or
|
||||
`git config sh.cherrypick.blockRejects false`.
|
||||
|
||||
## Branch layout
|
||||
|
||||
Never cherry-pick onto `dev` directly. Work on a themed branch off `dev` and open a PR into
|
||||
`dev`; the ledger's `branch` column records where each deferred commit is meant to land
|
||||
(for example `slack-tooling`, `gateway-routing`, `plan-approval`, `durable-dispatch`). Keep
|
||||
each PR to one theme so conflict resolution stays within one subsystem.
|
||||
|
||||
## Raw `git cherry-pick`
|
||||
|
||||
The hooks fire on a plain `git cherry-pick -x <sha>` too: `post-commit` journals each applied
|
||||
pick and `prepare-commit-msg` blocks known-rejects. Run `make triage-reconcile` once at the end
|
||||
to land the picks in the ledger, then commit `triage.jsonl` + `triage.md`.
|
||||
|
||||
## Conflicts
|
||||
|
||||
# resolve the files, then:
|
||||
git add <files>
|
||||
git cherry-pick --continue # or: git cp --continue
|
||||
git cherry-pick --abort # bail out of the whole pick
|
||||
git cherry-pick --skip # drop just this commit and continue the batch
|
||||
|
||||
A commit that conflicts because `dev` already carries a newer version of the same code is a
|
||||
regression, not a merge — skip it and record the decision as `wont-merge` in the ledger rather
|
||||
than forcing it in.
|
||||
53
docs/upstream-sync/triage.jsonl
Normal file
53
docs/upstream-sync/triage.jsonl
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
{"_meta": {"last_synced": "73b7d1c0", "last_synced_date": "2026-07-02"}}
|
||||
{"sha": "0b76afdc", "pr": 1653, "subject": "reviews block agenda, sticky headers, diff scroll", "disposition": "landed", "reason": "", "branch": "cherry-pick-upstream", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "7530653b", "pr": 1655, "subject": "ResizeObserver settle for review scroll-to", "disposition": "landed", "reason": "", "branch": "cherry-pick-upstream", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "23bd4a63", "pr": 1660, "subject": "top padding to sticky review block header", "disposition": "landed", "reason": "", "branch": "cherry-pick-upstream", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "9e5a1924", "pr": 1656, "subject": "purge expired thread_wakeup crons", "disposition": "landed", "reason": "", "branch": "cherry-pick-upstream", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "63eb9a08", "pr": 1661, "subject": "sidebar filter popover border tokens", "disposition": "landed", "reason": "", "branch": "cherry-pick-upstream", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "bc7ce591", "pr": 1668, "subject": "preserve dashboard redirect after login", "disposition": "landed", "reason": "", "branch": "cherry-pick-upstream", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "f32e492a", "pr": 1637, "subject": "return to thread after plan approval", "disposition": "landed", "reason": "", "branch": "cherry-pick-upstream", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "7ee3e057", "pr": 1636, "subject": "make plan view mobile friendly", "disposition": "landed", "reason": "", "branch": "cherry-pick-upstream", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "6575c327", "pr": 1654, "subject": "disable React StrictMode", "disposition": "landed", "reason": "kept fork's `PwaUpdateProvider`", "branch": "cherry-pick-upstream", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "c3292d82", "pr": 1611, "subject": "bake sfw binary into sandbox image", "disposition": "wont-merge", "reason": "already in dev", "branch": "", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "48bf712b", "pr": 1609, "subject": "show message timestamps", "disposition": "wont-merge", "reason": "already in dev", "branch": "", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "85c0f63e", "pr": 1620, "subject": "clickable shared PR header", "disposition": "wont-merge", "reason": "already in dev", "branch": "", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "db2ae58e", "pr": 1643, "subject": "pre-bundle shiki/@pierre deps", "disposition": "wont-merge", "reason": "already in dev", "branch": "", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "1d9da064", "pr": 1662, "subject": "bump astral-sh/setup-uv", "disposition": "wont-merge", "reason": "dev ahead (v8.2.0, `checkout@v7`)", "branch": "", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "83cb40a0", "pr": 1616, "subject": "update langsmith sdk to 0.9.3", "disposition": "wont-merge", "reason": "regression — dev has 0.9.6", "branch": "", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "00906401", "pr": 1610, "subject": "editable plan mode", "disposition": "wont-merge", "reason": "regression — dev plan-mode supersedes", "branch": "", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "e5a29eca", "pr": 1613, "subject": "plan links in PR descriptions", "disposition": "wont-merge", "reason": "regression — dev has async plan-ref", "branch": "", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "e1d85526", "pr": 1645, "subject": "switch ui to pnpm", "disposition": "wont-merge", "reason": "tooling — fork keeps bun", "branch": "", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "f5670f24", "pr": 1639, "subject": "require bun for ui agent work", "disposition": "deferred", "reason": "clean new file, aligned", "branch": "PR1", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "209132d3", "pr": 1621, "subject": "durable interrupt dispatch + completion webhook", "disposition": "deferred", "reason": "investigate first — may be applied", "branch": "durable-dispatch", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "02bb4dfd", "pr": 1658, "subject": "don't attach loopback run-complete webhooks", "disposition": "deferred", "reason": "", "branch": "durable-dispatch", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "29015fad", "pr": 1614, "subject": "gate workflow pushes with approval", "disposition": "deferred", "reason": "", "branch": "durable-dispatch", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "546042a4", "pr": 1652, "subject": "add workflow approval UI", "disposition": "deferred", "reason": "", "branch": "plan-approval", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "ae04b72b", "pr": 1635, "subject": "publish plans from sandbox files", "disposition": "deferred", "reason": "", "branch": "plan-approval", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "c03a6be7", "pr": 1634, "subject": "keep plan guidance high-level", "disposition": "deferred", "reason": "", "branch": "plan-approval", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "96cceb74", "pr": 1632, "subject": "notify Slack on plan approval", "disposition": "deferred", "reason": "", "branch": "plan-approval", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "2f56d754", "pr": 1618, "subject": "omit plan link when no plan exists", "disposition": "deferred", "reason": "likely regression", "branch": "plan-approval", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "ee224d3e", "pr": 1650, "subject": "add Slack reaction tool", "disposition": "deferred", "reason": "", "branch": "slack-tooling", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "747ce4bb", "pr": 1638, "subject": "add Slack breakout thread tool", "disposition": "deferred", "reason": "", "branch": "slack-tooling", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "27d90ef1", "pr": 1633, "subject": "include Slack channel context in prompts", "disposition": "deferred", "reason": "", "branch": "slack-tooling", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "4cd5fa5c", "pr": 1629, "subject": "avoid recapping Slack replies", "disposition": "deferred", "reason": "", "branch": "slack-tooling", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "92dbf6f9", "pr": 1630, "subject": "update Slack trace reply on web handoff", "disposition": "deferred", "reason": "", "branch": "slack-tooling", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "bb36448b", "pr": 1627, "subject": "surface Slack thread errors", "disposition": "deferred", "reason": "", "branch": "slack-tooling", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "73b7d1c0", "pr": 1678, "subject": "fix OpenAI Responses reasoning replay", "disposition": "deferred", "reason": "", "branch": "gateway-routing", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "5f7c2f46", "pr": 1674, "subject": "fix Fireworks Gateway base URL", "disposition": "deferred", "reason": "", "branch": "gateway-routing", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "702ef908", "pr": 1673, "subject": "dedicated LangSmith gateway API key", "disposition": "deferred", "reason": "", "branch": "gateway-routing", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "e9dc6e01", "pr": 1671, "subject": "opt-in LangSmith LLM Gateway routing", "disposition": "deferred", "reason": "", "branch": "gateway-routing", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "289f5e3a", "pr": 1651, "subject": "add Sonnet 5 to model picker", "disposition": "landed", "reason": "already in dev; added Bedrock family fallback fix (c16fb915)", "branch": "gateway-routing", "local_sha": null, "updated": "2026-07-03T00:19:50Z"}
|
||||
{"sha": "5da3d0c6", "pr": 1624, "subject": "post reviewer resolution notes verbatim", "disposition": "deferred", "reason": "", "branch": "reviewer-misc", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "69148f54", "pr": 1612, "subject": "add PR trace resolution", "disposition": "deferred", "reason": "", "branch": "reviewer-misc", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "6d125526", "pr": 1625, "subject": "stop wrapping installs in sfw", "disposition": "deferred", "reason": "", "branch": "reviewer-misc", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "320bb39a", "pr": 1657, "subject": "opt-in tracemalloc for aiohttp sessions", "disposition": "deferred", "reason": "", "branch": "reviewer-misc", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "baf0c248", "pr": 1617, "subject": "filter & grouping menu in threads sidebar", "disposition": "deferred", "reason": "~998 LOC", "branch": "own branch", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "f29868ff", "pr": 1615, "subject": "recover thread work as patch", "disposition": "deferred", "reason": "~495 LOC", "branch": "own branch", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "8e0788dc", "pr": 1631, "subject": "show queued dashboard follow-ups", "disposition": "deferred", "reason": "", "branch": "own branch", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "9c601ca1", "pr": 1648, "subject": "add Stagehand-powered browser subagent", "disposition": "deferred", "reason": "", "branch": "own branch", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "8c944381", "pr": 1622, "subject": "restore forced tool call", "disposition": "deferred", "reason": "", "branch": "own branch", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "5dc360d8", "pr": 1619, "subject": "bump langgraph-checkpoint 4.1.0→4.1.1", "disposition": "untriaged", "reason": "", "branch": "", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "4f913198", "pr": 1647, "subject": "widen split review diffs", "disposition": "untriaged", "reason": "", "branch": "", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "20f63e8c", "pr": 1646, "subject": "install missing deps before verification", "disposition": "untriaged", "reason": "", "branch": "", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "89f886e2", "pr": 1642, "subject": "request actions read for sandbox logs", "disposition": "untriaged", "reason": "", "branch": "", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "2f237b53", "pr": 1626, "subject": "fall back to vision model for image threads", "disposition": "untriaged", "reason": "", "branch": "", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
66
docs/upstream-sync/triage.md
Normal file
66
docs/upstream-sync/triage.md
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
<!-- GENERATED — do not hand-edit. Edit docs/upstream-sync/triage.jsonl, then run `make triage-render`. Staleness is enforced in CI by `make triage-check`. -->
|
||||
|
||||
# Upstream triage ledger
|
||||
|
||||
Commits on `upstream/main` (langchain-ai/open-swe) not yet in `dev`, and the decision on each.
|
||||
Rows key on the **upstream SHA** (stable across local cherry-picks). Deferred rows are provisional
|
||||
— re-inspect before picking. See the fork-maintenance runbook in `CLAUDE.md`.
|
||||
|
||||
**Last synced `upstream/main`:** `73b7d1c0` (2026-07-02)
|
||||
|
||||
| sha | pr | subject | decision | why | branch |
|
||||
|---|---|---|---|---|---|
|
||||
| `0b76afdc` | #1653 | reviews block agenda, sticky headers, diff scroll | Landed | | cherry-pick-upstream |
|
||||
| `7530653b` | #1655 | ResizeObserver settle for review scroll-to | Landed | | cherry-pick-upstream |
|
||||
| `23bd4a63` | #1660 | top padding to sticky review block header | Landed | | cherry-pick-upstream |
|
||||
| `9e5a1924` | #1656 | purge expired thread_wakeup crons | Landed | | cherry-pick-upstream |
|
||||
| `63eb9a08` | #1661 | sidebar filter popover border tokens | Landed | | cherry-pick-upstream |
|
||||
| `bc7ce591` | #1668 | preserve dashboard redirect after login | Landed | | cherry-pick-upstream |
|
||||
| `f32e492a` | #1637 | return to thread after plan approval | Landed | | cherry-pick-upstream |
|
||||
| `7ee3e057` | #1636 | make plan view mobile friendly | Landed | | cherry-pick-upstream |
|
||||
| `6575c327` | #1654 | disable React StrictMode | Landed | kept fork's `PwaUpdateProvider` | cherry-pick-upstream |
|
||||
| `289f5e3a` | #1651 | add Sonnet 5 to model picker | Landed | already in dev; added Bedrock family fallback fix (c16fb915) | gateway-routing |
|
||||
| `c3292d82` | #1611 | bake sfw binary into sandbox image | Won't merge | already in dev | |
|
||||
| `48bf712b` | #1609 | show message timestamps | Won't merge | already in dev | |
|
||||
| `85c0f63e` | #1620 | clickable shared PR header | Won't merge | already in dev | |
|
||||
| `db2ae58e` | #1643 | pre-bundle shiki/@pierre deps | Won't merge | already in dev | |
|
||||
| `1d9da064` | #1662 | bump astral-sh/setup-uv | Won't merge | dev ahead (v8.2.0, `checkout@v7`) | |
|
||||
| `83cb40a0` | #1616 | update langsmith sdk to 0.9.3 | Won't merge | regression — dev has 0.9.6 | |
|
||||
| `00906401` | #1610 | editable plan mode | Won't merge | regression — dev plan-mode supersedes | |
|
||||
| `e5a29eca` | #1613 | plan links in PR descriptions | Won't merge | regression — dev has async plan-ref | |
|
||||
| `e1d85526` | #1645 | switch ui to pnpm | Won't merge | tooling — fork keeps bun | |
|
||||
| `f5670f24` | #1639 | require bun for ui agent work | Deferred | clean new file, aligned | PR1 |
|
||||
| `209132d3` | #1621 | durable interrupt dispatch + completion webhook | Deferred | investigate first — may be applied | durable-dispatch |
|
||||
| `02bb4dfd` | #1658 | don't attach loopback run-complete webhooks | Deferred | | durable-dispatch |
|
||||
| `29015fad` | #1614 | gate workflow pushes with approval | Deferred | | durable-dispatch |
|
||||
| `546042a4` | #1652 | add workflow approval UI | Deferred | | plan-approval |
|
||||
| `ae04b72b` | #1635 | publish plans from sandbox files | Deferred | | plan-approval |
|
||||
| `c03a6be7` | #1634 | keep plan guidance high-level | Deferred | | plan-approval |
|
||||
| `96cceb74` | #1632 | notify Slack on plan approval | Deferred | | plan-approval |
|
||||
| `2f56d754` | #1618 | omit plan link when no plan exists | Deferred | likely regression | plan-approval |
|
||||
| `ee224d3e` | #1650 | add Slack reaction tool | Deferred | | slack-tooling |
|
||||
| `747ce4bb` | #1638 | add Slack breakout thread tool | Deferred | | slack-tooling |
|
||||
| `27d90ef1` | #1633 | include Slack channel context in prompts | Deferred | | slack-tooling |
|
||||
| `4cd5fa5c` | #1629 | avoid recapping Slack replies | Deferred | | slack-tooling |
|
||||
| `92dbf6f9` | #1630 | update Slack trace reply on web handoff | Deferred | | slack-tooling |
|
||||
| `bb36448b` | #1627 | surface Slack thread errors | Deferred | | slack-tooling |
|
||||
| `73b7d1c0` | #1678 | fix OpenAI Responses reasoning replay | Deferred | | gateway-routing |
|
||||
| `5f7c2f46` | #1674 | fix Fireworks Gateway base URL | Deferred | | gateway-routing |
|
||||
| `702ef908` | #1673 | dedicated LangSmith gateway API key | Deferred | | gateway-routing |
|
||||
| `e9dc6e01` | #1671 | opt-in LangSmith LLM Gateway routing | Deferred | | gateway-routing |
|
||||
| `5da3d0c6` | #1624 | post reviewer resolution notes verbatim | Deferred | | reviewer-misc |
|
||||
| `69148f54` | #1612 | add PR trace resolution | Deferred | | reviewer-misc |
|
||||
| `6d125526` | #1625 | stop wrapping installs in sfw | Deferred | | reviewer-misc |
|
||||
| `320bb39a` | #1657 | opt-in tracemalloc for aiohttp sessions | Deferred | | reviewer-misc |
|
||||
| `baf0c248` | #1617 | filter & grouping menu in threads sidebar | Deferred | ~998 LOC | own branch |
|
||||
| `f29868ff` | #1615 | recover thread work as patch | Deferred | ~495 LOC | own branch |
|
||||
| `8e0788dc` | #1631 | show queued dashboard follow-ups | Deferred | | own branch |
|
||||
| `9c601ca1` | #1648 | add Stagehand-powered browser subagent | Deferred | | own branch |
|
||||
| `8c944381` | #1622 | restore forced tool call | Deferred | | own branch |
|
||||
| `5dc360d8` | #1619 | bump langgraph-checkpoint 4.1.0→4.1.1 | Untriaged | | |
|
||||
| `4f913198` | #1647 | widen split review diffs | Untriaged | | |
|
||||
| `20f63e8c` | #1646 | install missing deps before verification | Untriaged | | |
|
||||
| `89f886e2` | #1642 | request actions read for sandbox logs | Untriaged | | |
|
||||
| `2f237b53` | #1626 | fall back to vision model for image threads | Untriaged | | |
|
||||
|
||||
_Maintenance: after a `git sync`, add new `dev..upstream/main` SHAs as **Untriaged** (edit `triage.jsonl`) and bump "Last synced". A successful `git cherry-pick -x` auto-moves the row to **Landed** via the `post-commit` journal + `make triage-reconcile`._
|
||||
|
|
@ -11,7 +11,7 @@ dependencies = [
|
|||
"uvicorn>=0.49.0",
|
||||
"httpx>=0.28.1",
|
||||
"PyJWT>=2.13.0",
|
||||
"cryptography>=48.0.1",
|
||||
"cryptography>=49.0.0",
|
||||
"langgraph-sdk>=0.4.2",
|
||||
"langchain>=1.3.9",
|
||||
"langgraph>=1.1.10",
|
||||
|
|
@ -19,7 +19,7 @@ dependencies = [
|
|||
"langchain-anthropic>=1.4.6",
|
||||
"langchain-aws>=0.2.0",
|
||||
"langgraph-cli[inmem]>=0.4.30",
|
||||
"langsmith==0.9.6",
|
||||
"langsmith==0.9.7",
|
||||
"langchain-openai>=1.3.3",
|
||||
"langchain-fireworks>=1.4.3",
|
||||
# langchain-fireworks 1.4.2 pins a pre-release fireworks-ai; opt in explicitly so uv resolves it.
|
||||
|
|
|
|||
124
scripts/git-cp
Executable file
124
scripts/git-cp
Executable file
|
|
@ -0,0 +1,124 @@
|
|||
#!/usr/bin/env bash
|
||||
# `git cp` wrapper — the real pre-apply guard the hooks can't be.
|
||||
#
|
||||
# git cp <sha>... pre-check ledger, cherry-pick -x, auto-reconcile
|
||||
# git cp --force <sha>... pick even known "Won't merge" SHAs (documented override)
|
||||
# git cp --continue|--abort|--skip|--quit forwarded to git cherry-pick
|
||||
#
|
||||
# Behaviour:
|
||||
# 1. Resolve requested SHAs (single, list, or A^..B range) BEFORE touching the tree.
|
||||
# 2. If any is disposition "wont-merge", print SHA + reason and ABORT unless --force.
|
||||
# 3. git cherry-pick -x <args> (ensures -x is present exactly once).
|
||||
# 4. On success, run `scripts/triage.py reconcile` to land the picks in the ledger.
|
||||
set -uo pipefail
|
||||
|
||||
root="$(git rev-parse --show-toplevel 2>/dev/null)" || {
|
||||
echo "git cp: not a git repository" >&2
|
||||
exit 1
|
||||
}
|
||||
triage="$root/scripts/triage.py"
|
||||
py="$(command -v python3 || command -v python 2>/dev/null)"
|
||||
|
||||
force=0
|
||||
control=0
|
||||
passthru=()
|
||||
picks=()
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--force | --allow-reject)
|
||||
force=1
|
||||
;;
|
||||
--continue | --abort | --skip | --quit)
|
||||
control=1
|
||||
passthru+=("$arg")
|
||||
;;
|
||||
-*)
|
||||
passthru+=("$arg")
|
||||
;;
|
||||
*)
|
||||
passthru+=("$arg")
|
||||
picks+=("$arg")
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
reconcile() {
|
||||
if [ -n "$py" ] && [ -f "$triage" ]; then
|
||||
"$py" "$triage" reconcile
|
||||
else
|
||||
echo "git cp: python/triage.py unavailable — skipping auto-reconcile" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_x() {
|
||||
# Prepend -x unless the caller already passed it.
|
||||
for a in "${passthru[@]}"; do
|
||||
[ "$a" = "-x" ] && return 0
|
||||
done
|
||||
passthru=("-x" "${passthru[@]}")
|
||||
}
|
||||
|
||||
# Sequencer control verbs: forward and (for --continue) reconcile whatever landed.
|
||||
if [ "$control" -eq 1 ]; then
|
||||
git cherry-pick "${passthru[@]}"
|
||||
rc=$?
|
||||
if [ "$rc" -eq 0 ]; then
|
||||
reconcile
|
||||
fi
|
||||
exit "$rc"
|
||||
fi
|
||||
|
||||
# Pre-apply reject check.
|
||||
if [ -n "$py" ] && [ -f "$triage" ] && [ "${#picks[@]}" -gt 0 ]; then
|
||||
resolved=()
|
||||
for tok in "${picks[@]}"; do
|
||||
if [[ "$tok" == *..* ]]; then
|
||||
while IFS= read -r s; do
|
||||
[ -n "$s" ] && resolved+=("$s")
|
||||
done < <(git rev-list --reverse "$tok" 2>/dev/null)
|
||||
else
|
||||
s="$(git rev-parse --verify --quiet "${tok}^{commit}" 2>/dev/null)" && resolved+=("$s")
|
||||
fi
|
||||
done
|
||||
|
||||
rejects=()
|
||||
for s in ${resolved[@]+"${resolved[@]}"}; do
|
||||
reason="$("$py" "$triage" check-reject "$s" 2>/dev/null)"
|
||||
if [ "$?" -eq 3 ]; then
|
||||
rejects+=("${s:0:12} $reason")
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "${#rejects[@]}" -gt 0 ]; then
|
||||
echo "git cp: the following SHA(s) are marked \"Won't merge\" in the triage ledger:" >&2
|
||||
for r in "${rejects[@]}"; do
|
||||
echo " ⛔ $r" >&2
|
||||
done
|
||||
if [ "$force" -eq 0 ]; then
|
||||
echo "" >&2
|
||||
echo "Refusing to cherry-pick a known-reject. To override intentionally: git cp --force ..." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo " --force set — proceeding anyway." >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
ensure_x
|
||||
|
||||
# --force must also satisfy the commit-msg backstop, so allow rejects through the hook too.
|
||||
if [ "$force" -eq 1 ]; then
|
||||
export SH_CHERRYPICK_ALLOW_REJECT=1
|
||||
fi
|
||||
|
||||
git cherry-pick "${passthru[@]}"
|
||||
rc=$?
|
||||
|
||||
if [ "$rc" -eq 0 ]; then
|
||||
reconcile
|
||||
else
|
||||
echo "" >&2
|
||||
echo "git cp: cherry-pick stopped (rc=$rc). Resolve, then: git cp --continue (or --abort/--skip)." >&2
|
||||
echo " Landed picks are journaled; reconcile runs on --continue or via make triage-reconcile." >&2
|
||||
fi
|
||||
exit "$rc"
|
||||
56
scripts/install-hooks.sh
Executable file
56
scripts/install-hooks.sh
Executable file
|
|
@ -0,0 +1,56 @@
|
|||
#!/usr/bin/env bash
|
||||
# Install the cherry-pick triage hooks for THIS clone.
|
||||
#
|
||||
# What it does (per-clone; git never auto-adopts a repo's core.hooksPath):
|
||||
# 0. FIRST verify the Sea Haven global security pre-push still fires through our shim.
|
||||
# 1. chmod +x the hooks + scripts.
|
||||
# 2. git config core.hooksPath .githooks
|
||||
# 3. git config alias.cp -> scripts/git-cp
|
||||
#
|
||||
# Safe to run repeatedly.
|
||||
set -euo pipefail
|
||||
|
||||
root="$(git rev-parse --show-toplevel)"
|
||||
cd "$root"
|
||||
|
||||
global_dir="${SH_GLOBAL_HOOKS:-$HOME/.config/git/hooks}"
|
||||
global_pp="$global_dir/pre-push"
|
||||
shim="$root/.githooks/pre-push"
|
||||
|
||||
echo "==> [1/4] Verifying the Sea Haven global security pre-push will still fire..."
|
||||
if [ -x "$global_pp" ]; then
|
||||
if [ ! -f "$shim" ]; then
|
||||
echo "FATAL: global security pre-push exists ($global_pp) but the shim ($shim) is MISSING." >&2
|
||||
echo " Setting core.hooksPath=.githooks would SHADOW and silently disable the security" >&2
|
||||
echo " gate. Refusing to install. Restore .githooks/pre-push first." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q "$global_dir" "$shim" && ! grep -q 'SH_GLOBAL_HOOKS' "$shim"; then
|
||||
echo "FATAL: shim ($shim) does not appear to delegate to the global hook dir. Refusing." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q 'exec "\$GLOBAL"' "$shim"; then
|
||||
echo "FATAL: shim ($shim) does not exec the global pre-push. Refusing." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo " OK: .githooks/pre-push shim re-execs $global_pp — security gate preserved."
|
||||
else
|
||||
echo " WARN: no global security pre-push found at $global_pp."
|
||||
echo " If you expected the Sea Haven security gate, investigate BEFORE pushing."
|
||||
fi
|
||||
|
||||
echo "==> [2/4] Marking hooks + scripts executable..."
|
||||
chmod +x "$root/.githooks/"* 2>/dev/null || true
|
||||
chmod +x "$root/scripts/git-cp" "$root/scripts/install-hooks.sh" 2>/dev/null || true
|
||||
|
||||
echo "==> [3/4] Pointing core.hooksPath at .githooks..."
|
||||
git config core.hooksPath .githooks
|
||||
|
||||
echo "==> [4/4] Installing the 'git cp' alias..."
|
||||
git config alias.cp '!bash "$(git rev-parse --show-toplevel)/scripts/git-cp"'
|
||||
|
||||
echo ""
|
||||
echo "Done. This clone now:"
|
||||
echo " - journals cherry-picks (post-commit) and blocks known-rejects (commit-msg)"
|
||||
echo " - runs 'git cp' as the guarded cherry-pick wrapper"
|
||||
echo " - STILL runs the global security pre-push via the .githooks/pre-push shim"
|
||||
88
scripts/purge_wakeup_crons.py
Normal file
88
scripts/purge_wakeup_crons.py
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
"""One-time backfill: delete expired ``thread_wakeup`` crons from a deployment.
|
||||
|
||||
One-shot wakeup crons set an ``end_time`` that stops them re-firing, but the
|
||||
cron row is never removed, so dead rows accumulate. The ``schedule_thread_wakeup``
|
||||
tool now purges these opportunistically; this script clears the backlog.
|
||||
|
||||
Usage:
|
||||
uv run python scripts/purge_wakeup_crons.py --dry-run
|
||||
uv run python scripts/purge_wakeup_crons.py
|
||||
|
||||
Resolves the deployment URL from ``--url`` or ``LANGGRAPH_URL`` / ``LANGGRAPH_URL_PROD``,
|
||||
and the API key from ``LANGGRAPH_API_KEY`` / ``LANGSMITH_API_KEY`` / ``LANGSMITH_API_KEY_PROD``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from langgraph_sdk import get_client
|
||||
|
||||
from agent.tools.schedule_thread_wakeup import (
|
||||
find_expired_wakeup_cron_ids,
|
||||
purge_expired_wakeup_crons,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _load_dotenv_if_available() -> None:
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
except ImportError:
|
||||
return
|
||||
load_dotenv()
|
||||
|
||||
|
||||
def _resolve_url(arg_url: str | None) -> str:
|
||||
url = arg_url or os.environ.get("LANGGRAPH_URL") or os.environ.get("LANGGRAPH_URL_PROD")
|
||||
if not url:
|
||||
raise RuntimeError("Set --url or LANGGRAPH_URL / LANGGRAPH_URL_PROD")
|
||||
return url
|
||||
|
||||
|
||||
def _resolve_api_key() -> str | None:
|
||||
return (
|
||||
os.environ.get("LANGGRAPH_API_KEY")
|
||||
or os.environ.get("LANGSMITH_API_KEY")
|
||||
or os.environ.get("LANGSMITH_API_KEY_PROD")
|
||||
)
|
||||
|
||||
|
||||
async def _run(url: str, api_key: str | None, dry_run: bool) -> None:
|
||||
client = get_client(url=url, api_key=api_key)
|
||||
now = datetime.now(UTC)
|
||||
if dry_run:
|
||||
expired = await find_expired_wakeup_cron_ids(client, now=now)
|
||||
logger.info("[dry-run] %d expired thread_wakeup cron(s) would be deleted", len(expired))
|
||||
for cron_id in expired:
|
||||
logger.info(" %s", cron_id)
|
||||
return
|
||||
deleted = await purge_expired_wakeup_crons(client, now=now)
|
||||
logger.info("Deleted %d expired thread_wakeup cron(s)", deleted)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Purge expired thread_wakeup crons.")
|
||||
parser.add_argument("--url", default=None, help="Deployment URL (defaults to env).")
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="List the crons that would be deleted without deleting them.",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
_load_dotenv_if_available()
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
args = parse_args()
|
||||
asyncio.run(_run(_resolve_url(args.url), _resolve_api_key(), args.dry_run))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
615
scripts/triage.py
Executable file
615
scripts/triage.py
Executable file
|
|
@ -0,0 +1,615 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Upstream cherry-pick triage ledger tool.
|
||||
|
||||
Source of truth is ``docs/upstream-sync/triage.jsonl`` (one JSON object per line);
|
||||
``docs/upstream-sync/triage.md`` is a GENERATED, human-readable view of it.
|
||||
|
||||
Subcommands:
|
||||
migrate <triage.md> one-shot: parse the hand-written markdown ledger -> triage.jsonl
|
||||
generate [--check] render triage.jsonl -> triage.md (--check: fail if md is stale)
|
||||
reconcile drain .git journal -> mark SHAs landed -> render -> git add
|
||||
sync add new base..ref commits (default dev..upstream/main) as untriaged
|
||||
lookup <sha> print a SHA's disposition (+ reason)
|
||||
check-reject <sha> exit 3 iff the SHA is disposition "wont-merge" (used by hooks)
|
||||
set <sha> ... edit a row (disposition / pr / subject / reason / branch)
|
||||
|
||||
Stdlib only. Hooks call this without uv, so keep it dependency-free.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
DISPOSITIONS = ("landed", "wont-merge", "deferred", "untriaged")
|
||||
|
||||
DISPOSITION_LABELS = {
|
||||
"landed": "Landed",
|
||||
"wont-merge": "Won't merge",
|
||||
"deferred": "Deferred",
|
||||
"untriaged": "Untriaged",
|
||||
}
|
||||
|
||||
# Normalized label text -> disposition. Keys are lowercased with apostrophes stripped.
|
||||
_LABEL_TO_DISPOSITION = {
|
||||
"landed": "landed",
|
||||
"wont merge": "wont-merge",
|
||||
"wontmerge": "wont-merge",
|
||||
"wont-merge": "wont-merge",
|
||||
"deferred": "deferred",
|
||||
"untriaged": "untriaged",
|
||||
}
|
||||
|
||||
GENERATED_BANNER = (
|
||||
"<!-- GENERATED — do not hand-edit. Edit docs/upstream-sync/triage.jsonl, then run "
|
||||
"`make triage-render`. Staleness is enforced in CI by `make triage-check`. -->"
|
||||
)
|
||||
|
||||
PREAMBLE = (
|
||||
"# Upstream triage ledger\n"
|
||||
"\n"
|
||||
"Commits on `upstream/main` (langchain-ai/open-swe) not yet in `dev`, and the decision on each.\n"
|
||||
"Rows key on the **upstream SHA** (stable across local cherry-picks). Deferred rows are provisional\n"
|
||||
"— re-inspect before picking. See the fork-maintenance runbook in `CLAUDE.md`.\n"
|
||||
)
|
||||
|
||||
MAINTENANCE_NOTE = (
|
||||
"_Maintenance: after a `git sync`, add new `dev..upstream/main` SHAs as **Untriaged** "
|
||||
'(edit `triage.jsonl`) and bump "Last synced". A successful `git cherry-pick -x` auto-moves '
|
||||
"the row to **Landed** via the `post-commit` journal + `make triage-reconcile`._\n"
|
||||
)
|
||||
|
||||
JOURNAL_NAME = "sh-cherrypick-journal"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- paths
|
||||
|
||||
|
||||
def repo_root() -> Path:
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return Path(out.stdout.strip())
|
||||
except Exception:
|
||||
return Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def git_dir() -> Path | None:
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["git", "rev-parse", "--absolute-git-dir"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return Path(out.stdout.strip())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def jsonl_path(root: Path) -> Path:
|
||||
return root / "docs" / "upstream-sync" / "triage.jsonl"
|
||||
|
||||
|
||||
def md_path(root: Path) -> Path:
|
||||
return root / "docs" / "upstream-sync" / "triage.md"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------- records
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def load_ledger(path: Path) -> tuple[dict, list[dict]]:
|
||||
"""Return (meta, records). First line may be a ``{"_meta": {...}}`` object."""
|
||||
meta: dict = {}
|
||||
records: list[dict] = []
|
||||
if not path.exists():
|
||||
return meta, records
|
||||
for lineno, raw in enumerate(path.read_text().splitlines(), start=1):
|
||||
line = raw.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
obj = json.loads(line)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"{path}:{lineno}: invalid JSON: {exc}") from exc
|
||||
if "_meta" in obj:
|
||||
meta = obj["_meta"]
|
||||
continue
|
||||
records.append(obj)
|
||||
return meta, records
|
||||
|
||||
|
||||
def write_ledger(path: Path, meta: dict, records: list[dict]) -> None:
|
||||
lines = [json.dumps({"_meta": meta}, ensure_ascii=False)]
|
||||
for rec in records:
|
||||
lines.append(json.dumps(rec, ensure_ascii=False))
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
tmp.write_text("\n".join(lines) + "\n")
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
def sha_match(a: str, b: str) -> bool:
|
||||
"""Prefix match either direction — ledger holds short SHAs, journal holds full SHAs."""
|
||||
a = a.lower()
|
||||
b = b.lower()
|
||||
return a.startswith(b) or b.startswith(a)
|
||||
|
||||
|
||||
def find_record(records: list[dict], sha: str) -> dict | None:
|
||||
for rec in records:
|
||||
if sha_match(rec.get("sha", ""), sha):
|
||||
return rec
|
||||
return None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------- render
|
||||
|
||||
|
||||
def _disposition_rank(disp: str) -> int:
|
||||
try:
|
||||
return DISPOSITIONS.index(disp)
|
||||
except ValueError:
|
||||
return len(DISPOSITIONS)
|
||||
|
||||
|
||||
def render_md(meta: dict, records: list[dict]) -> str:
|
||||
last_synced = meta.get("last_synced", "")
|
||||
last_synced_date = meta.get("last_synced_date", "")
|
||||
synced_line = ""
|
||||
if last_synced:
|
||||
synced_line = f"**Last synced `upstream/main`:** `{last_synced}`"
|
||||
if last_synced_date:
|
||||
synced_line += f" ({last_synced_date})"
|
||||
synced_line += "\n"
|
||||
|
||||
ordered = sorted(
|
||||
enumerate(records),
|
||||
key=lambda item: (_disposition_rank(item[1].get("disposition", "")), item[0]),
|
||||
)
|
||||
|
||||
rows = [
|
||||
"| sha | pr | subject | decision | why | branch |",
|
||||
"|---|---|---|---|---|---|",
|
||||
]
|
||||
for _, rec in ordered:
|
||||
sha = rec.get("sha", "")
|
||||
pr = rec.get("pr")
|
||||
pr_cell = f"#{pr}" if pr not in (None, "") else ""
|
||||
subject = rec.get("subject", "") or ""
|
||||
decision = DISPOSITION_LABELS.get(rec.get("disposition", ""), rec.get("disposition", ""))
|
||||
why = rec.get("reason", "") or ""
|
||||
branch = rec.get("branch", "") or ""
|
||||
cells = [f"`{sha}`", pr_cell, subject, decision, why, branch]
|
||||
rows.append("| " + " | ".join(cells) + " |")
|
||||
|
||||
parts = [GENERATED_BANNER, "", PREAMBLE.rstrip("\n"), ""]
|
||||
if synced_line:
|
||||
parts.append(synced_line.rstrip("\n"))
|
||||
parts.append("")
|
||||
parts.append("\n".join(rows))
|
||||
parts.append("")
|
||||
parts.append(MAINTENANCE_NOTE.rstrip("\n"))
|
||||
return "\n".join(parts) + "\n"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------ migrate
|
||||
|
||||
|
||||
_ROW_RE = re.compile(r"^\|(.+)\|\s*$")
|
||||
|
||||
|
||||
def _norm_label(text: str) -> str:
|
||||
return re.sub(r"[^a-z\- ]", "", text.strip().lower()).strip()
|
||||
|
||||
|
||||
def parse_md_ledger(text: str) -> tuple[dict, list[dict]]:
|
||||
meta: dict = {}
|
||||
records: list[dict] = []
|
||||
|
||||
m = re.search(r"Last synced.*?`([0-9a-f]{6,40})`\s*(?:\(([^)]*)\))?", text)
|
||||
if m:
|
||||
meta["last_synced"] = m.group(1)
|
||||
if m.group(2):
|
||||
meta["last_synced_date"] = m.group(2)
|
||||
|
||||
for raw in text.splitlines():
|
||||
m = _ROW_RE.match(raw)
|
||||
if not m:
|
||||
continue
|
||||
cells = [c.strip() for c in m.group(1).split("|")]
|
||||
if len(cells) < 6:
|
||||
continue
|
||||
first = cells[0].strip("`").strip().lower()
|
||||
# skip header + separator rows
|
||||
if first in ("sha", "") or set(cells[0]) <= {"-", " "}:
|
||||
continue
|
||||
if not re.fullmatch(r"[0-9a-f]{7,40}", first):
|
||||
continue
|
||||
sha = first
|
||||
pr_raw = cells[1].lstrip("#").strip()
|
||||
pr = int(pr_raw) if pr_raw.isdigit() else None
|
||||
subject = cells[2].strip()
|
||||
disposition = _LABEL_TO_DISPOSITION.get(_norm_label(cells[3]))
|
||||
if disposition is None:
|
||||
raise ValueError(f"unrecognized decision {cells[3]!r} for {sha}")
|
||||
reason = cells[4].strip()
|
||||
branch = cells[5].strip()
|
||||
updated = (
|
||||
f"{meta.get('last_synced_date')}T00:00:00Z"
|
||||
if meta.get("last_synced_date")
|
||||
else _now_iso()
|
||||
)
|
||||
records.append(
|
||||
{
|
||||
"sha": sha,
|
||||
"pr": pr,
|
||||
"subject": subject,
|
||||
"disposition": disposition,
|
||||
"reason": reason,
|
||||
"branch": branch,
|
||||
"local_sha": None,
|
||||
"updated": updated,
|
||||
}
|
||||
)
|
||||
return meta, records
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- subcommands
|
||||
|
||||
|
||||
def cmd_migrate(args: argparse.Namespace) -> int:
|
||||
root = repo_root()
|
||||
src = Path(args.source)
|
||||
if not src.exists():
|
||||
print(f"error: {src} not found", file=sys.stderr)
|
||||
return 2
|
||||
meta, records = parse_md_ledger(src.read_text())
|
||||
out = jsonl_path(root)
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
write_ledger(out, meta, records)
|
||||
print(f"migrated {len(records)} rows -> {out}")
|
||||
md_path(root).write_text(render_md(meta, records))
|
||||
print(f"rendered {md_path(root)}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_generate(args: argparse.Namespace) -> int:
|
||||
root = repo_root()
|
||||
jl = jsonl_path(root)
|
||||
try:
|
||||
meta, records = load_ledger(jl)
|
||||
except ValueError as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
rendered = render_md(meta, records)
|
||||
md = md_path(root)
|
||||
if args.check:
|
||||
current = md.read_text() if md.exists() else ""
|
||||
if current != rendered:
|
||||
print(
|
||||
f"error: {md} is stale vs {jl}. Run `make triage-render` and commit.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
print(f"ok: {md.name} is up to date with {jl.name}")
|
||||
return 0
|
||||
md.write_text(rendered)
|
||||
print(f"rendered {md}")
|
||||
return 0
|
||||
|
||||
|
||||
def _read_journal(journal: Path) -> list[tuple[str, str, str]]:
|
||||
entries: list[tuple[str, str, str]] = []
|
||||
seen: set[str] = set()
|
||||
if not journal.exists():
|
||||
return entries
|
||||
for raw in journal.read_text().splitlines():
|
||||
line = raw.strip()
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split("\t")
|
||||
up = parts[0]
|
||||
local = parts[1] if len(parts) > 1 else ""
|
||||
ts = parts[2] if len(parts) > 2 else _now_iso()
|
||||
if up in seen:
|
||||
# keep the latest entry for a given upstream sha
|
||||
entries = [e for e in entries if e[0] != up]
|
||||
seen.add(up)
|
||||
entries.append((up, local, ts))
|
||||
return entries
|
||||
|
||||
|
||||
def cmd_reconcile(args: argparse.Namespace) -> int:
|
||||
root = repo_root()
|
||||
gd = git_dir()
|
||||
if gd is None:
|
||||
print("error: not a git repository", file=sys.stderr)
|
||||
return 2
|
||||
journal = gd / JOURNAL_NAME
|
||||
entries = _read_journal(journal)
|
||||
if not entries:
|
||||
print("reconcile: journal empty — nothing to do")
|
||||
return 0
|
||||
|
||||
jl = jsonl_path(root)
|
||||
try:
|
||||
meta, records = load_ledger(jl)
|
||||
except ValueError as exc:
|
||||
print(f"error: {exc} — aborting without writing; journal preserved", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
landed = 0
|
||||
inserted = 0
|
||||
for up_sha, local_sha, ts in entries:
|
||||
rec = find_record(records, up_sha)
|
||||
if rec is None:
|
||||
subject = ""
|
||||
if local_sha:
|
||||
try:
|
||||
subject = subprocess.run(
|
||||
["git", "log", "-1", "--format=%s", local_sha],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
except Exception:
|
||||
subject = ""
|
||||
records.append(
|
||||
{
|
||||
"sha": up_sha[:8],
|
||||
"pr": None,
|
||||
"subject": subject,
|
||||
"disposition": "landed",
|
||||
"reason": "",
|
||||
"branch": "",
|
||||
"local_sha": local_sha or None,
|
||||
"updated": ts,
|
||||
}
|
||||
)
|
||||
inserted += 1
|
||||
landed += 1
|
||||
continue
|
||||
already = rec.get("disposition") == "landed" and rec.get("local_sha") == (local_sha or None)
|
||||
if already:
|
||||
continue
|
||||
rec["disposition"] = "landed"
|
||||
rec["local_sha"] = local_sha or None
|
||||
rec["updated"] = ts
|
||||
landed += 1
|
||||
|
||||
write_ledger(jl, meta, records)
|
||||
md = md_path(root)
|
||||
md.write_text(render_md(meta, records))
|
||||
|
||||
# stage the tracked ledger; drain the journal
|
||||
try:
|
||||
subprocess.run(["git", "add", str(jl), str(md)], check=True)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"warning: `git add` failed ({exc}); files updated but not staged", file=sys.stderr)
|
||||
journal.write_text("")
|
||||
|
||||
print(
|
||||
f"reconcile: {landed} landed ({inserted} new), staged {jl.name} + {md.name}, "
|
||||
"journal drained. Commit the ledger separately."
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_lookup(args: argparse.Namespace) -> int:
|
||||
root = repo_root()
|
||||
try:
|
||||
_meta, records = load_ledger(jsonl_path(root))
|
||||
except ValueError as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
rec = find_record(records, args.sha)
|
||||
if rec is None:
|
||||
print("unknown")
|
||||
return 0
|
||||
disp = rec.get("disposition", "")
|
||||
reason = rec.get("reason", "")
|
||||
print(f"{disp}\t{reason}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_check_reject(args: argparse.Namespace) -> int:
|
||||
"""Exit 3 iff SHA is disposition 'wont-merge'. Exit 0 otherwise. Exit 2 on ledger error."""
|
||||
root = repo_root()
|
||||
try:
|
||||
_meta, records = load_ledger(jsonl_path(root))
|
||||
except ValueError as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
rec = find_record(records, args.sha)
|
||||
if rec is not None and rec.get("disposition") == "wont-merge":
|
||||
print(rec.get("reason", "") or "(no reason recorded)")
|
||||
return 3
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_set(args: argparse.Namespace) -> int:
|
||||
root = repo_root()
|
||||
jl = jsonl_path(root)
|
||||
try:
|
||||
meta, records = load_ledger(jl)
|
||||
except ValueError as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
rec = find_record(records, args.sha)
|
||||
if rec is None:
|
||||
rec = {
|
||||
"sha": args.sha[:8],
|
||||
"pr": None,
|
||||
"subject": "",
|
||||
"disposition": "untriaged",
|
||||
"reason": "",
|
||||
"branch": "",
|
||||
"local_sha": None,
|
||||
"updated": _now_iso(),
|
||||
}
|
||||
records.append(rec)
|
||||
if args.disposition:
|
||||
if args.disposition not in DISPOSITIONS:
|
||||
print(f"error: disposition must be one of {DISPOSITIONS}", file=sys.stderr)
|
||||
return 2
|
||||
rec["disposition"] = args.disposition
|
||||
if args.pr is not None:
|
||||
rec["pr"] = args.pr
|
||||
if args.subject is not None:
|
||||
rec["subject"] = args.subject
|
||||
if args.reason is not None:
|
||||
rec["reason"] = args.reason
|
||||
if args.branch is not None:
|
||||
rec["branch"] = args.branch
|
||||
rec["updated"] = _now_iso()
|
||||
write_ledger(jl, meta, records)
|
||||
md_path(root).write_text(render_md(meta, records))
|
||||
print(f"updated {rec['sha']} -> {rec['disposition']}")
|
||||
return 0
|
||||
|
||||
|
||||
def _diverged_commits(base: str, ref: str) -> list[tuple[str, int | None, str]]:
|
||||
"""Return ``[(short_sha, pr, subject)]`` for non-merge commits in ``base..ref``.
|
||||
|
||||
Newest first, matching ``git log`` default order. ``pr`` is parsed from a
|
||||
trailing ``(#1234)`` in the subject, else ``None``.
|
||||
"""
|
||||
out = subprocess.run(
|
||||
["git", "log", "--no-merges", "--format=%h%x09%s", f"{base}..{ref}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
).stdout
|
||||
commits: list[tuple[str, int | None, str]] = []
|
||||
for line in out.splitlines():
|
||||
if "\t" not in line:
|
||||
continue
|
||||
sha, subject = line.split("\t", 1)
|
||||
prs = re.findall(r"\(#(\d+)\)", subject)
|
||||
pr = int(prs[-1]) if prs else None
|
||||
commits.append((sha.strip(), pr, subject.strip()))
|
||||
return commits
|
||||
|
||||
|
||||
def cmd_sync(args: argparse.Namespace) -> int:
|
||||
root = repo_root()
|
||||
ref = args.ref
|
||||
if not args.no_fetch:
|
||||
remote = ref.split("/", 1)[0] if "/" in ref else "upstream"
|
||||
try:
|
||||
subprocess.run(["git", "fetch", remote], check=True)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"error: `git fetch {remote}` failed: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
commits = _diverged_commits(args.base, ref)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
print(f"error: `git log {args.base}..{ref}` failed: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
jl = jsonl_path(root)
|
||||
try:
|
||||
meta, records = load_ledger(jl)
|
||||
except ValueError as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
added = 0
|
||||
# Append oldest-first so freshly-discovered rows read chronologically.
|
||||
for sha, pr, subject in reversed(commits):
|
||||
if find_record(records, sha) is not None:
|
||||
continue
|
||||
records.append(
|
||||
{
|
||||
"sha": sha[:8],
|
||||
"pr": pr,
|
||||
"subject": subject,
|
||||
"disposition": "untriaged",
|
||||
"reason": "",
|
||||
"branch": "",
|
||||
"local_sha": None,
|
||||
"updated": _now_iso(),
|
||||
}
|
||||
)
|
||||
added += 1
|
||||
|
||||
try:
|
||||
tip = subprocess.run(
|
||||
["git", "rev-parse", "--short", ref],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
meta["last_synced"] = tip
|
||||
meta["last_synced_date"] = datetime.now(UTC).strftime("%Y-%m-%d")
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
write_ledger(jl, meta, records)
|
||||
md_path(root).write_text(render_md(meta, records))
|
||||
print(
|
||||
f"sync: {added} new untriaged from {args.base}..{ref}; "
|
||||
f"last synced -> {meta.get('last_synced', '?')} ({len(records)} rows total). "
|
||||
"Triage the new rows, then commit triage.jsonl + triage.md."
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
p = sub.add_parser("migrate", help="parse hand-written triage.md -> triage.jsonl (one-time)")
|
||||
p.add_argument("source", help="path to the current triage.md")
|
||||
p.set_defaults(func=cmd_migrate)
|
||||
|
||||
p = sub.add_parser("generate", help="render triage.jsonl -> triage.md")
|
||||
p.add_argument("--check", action="store_true", help="fail if triage.md is stale (for CI)")
|
||||
p.set_defaults(func=cmd_generate)
|
||||
|
||||
p = sub.add_parser("reconcile", help="drain journal, mark landed, render, stage")
|
||||
p.set_defaults(func=cmd_reconcile)
|
||||
|
||||
p = sub.add_parser("sync", help="add new base..ref commits as untriaged")
|
||||
p.add_argument("--base", default="dev", help="base branch already in the fork (default: dev)")
|
||||
p.add_argument("--ref", default="upstream/main", help="upstream ref (default: upstream/main)")
|
||||
p.add_argument("--no-fetch", action="store_true", help="skip `git fetch` of the remote")
|
||||
p.set_defaults(func=cmd_sync)
|
||||
|
||||
p = sub.add_parser("lookup", help="print a SHA's disposition")
|
||||
p.add_argument("sha")
|
||||
p.set_defaults(func=cmd_lookup)
|
||||
|
||||
p = sub.add_parser("check-reject", help="exit 3 iff SHA is 'wont-merge'")
|
||||
p.add_argument("sha")
|
||||
p.set_defaults(func=cmd_check_reject)
|
||||
|
||||
p = sub.add_parser("set", help="edit or add a ledger row")
|
||||
p.add_argument("sha")
|
||||
p.add_argument("--disposition", choices=DISPOSITIONS)
|
||||
p.add_argument("--pr", type=int)
|
||||
p.add_argument("--subject")
|
||||
p.add_argument("--reason")
|
||||
p.add_argument("--branch")
|
||||
p.set_defaults(func=cmd_set)
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
return args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -12,17 +12,17 @@ This drives the **whole happy path** through two mock UIs:
|
|||
Only the **LLM** and the **external SaaS HTTP boundaries** are faked. All agent
|
||||
code runs for real.
|
||||
|
||||
| Piece | Real or fake |
|
||||
|---|---|
|
||||
| Slack webhook → `process_slack_mention` → run dispatch | **real** (`agent.webapp`) |
|
||||
| `get_agent`, deepagents loop, tools, middleware, prompt | **real** |
|
||||
| `open_pull_request`, `slack_thread_reply` tools | **real** |
|
||||
| Sandbox | **real** `local` provider, rooted in a throwaway temp dir |
|
||||
| Git remote ("GitHub") | **real git**, a local bare repo the agent clones/pushes |
|
||||
| The LLM | **fake** — a scripted model (`fake_llm.py`) emitting a fixed tool sequence |
|
||||
| `api.github.com` REST (PR create) | **fake** (`/fake-gh/...`), state rendered at `/mock/github` |
|
||||
| `slack.com/api` (post message, etc.) | **fake** (`/fake-slack/...`), thread rendered at `/mock/slack` |
|
||||
| GitHub App token mint, `api.github.com/user` identity | stubbed (offline) |
|
||||
| Piece | Real or fake |
|
||||
| ---------------------------------------------------------------- | -------------------------------------------------------------------------- |
|
||||
| Slack webhook → `process_slack_mention` → run dispatch | **real** (`agent.webapp`) |
|
||||
| `get_agent`, deepagents loop, tools, middleware, prompt | **real** |
|
||||
| `open_pull_request`, `slack_thread_reply` tools | **real** |
|
||||
| Sandbox | **real** `local` provider, rooted in a throwaway temp dir |
|
||||
| Git remote ("GitHub") | **real git**, a local bare repo the agent clones/pushes |
|
||||
| The LLM | **fake** — a scripted model (`fake_llm.py`) emitting a fixed tool sequence |
|
||||
| `api.github.com` REST (PR create) + dashboard GitHub OAuth login | **fake** (`/fake-gh/...`), state rendered at `/mock/github` |
|
||||
| `slack.com/api` (post message, etc.) | **fake** (`/fake-slack/...`), thread rendered at `/mock/slack` |
|
||||
| GitHub App token mint, `api.github.com/user` identity | stubbed (offline) |
|
||||
|
||||
The fake GitHub/Slack stores are the single source of truth the mock UIs render,
|
||||
so what Playwright asserts on is exactly what the real agent produced.
|
||||
|
|
|
|||
|
|
@ -18,8 +18,10 @@ import json
|
|||
import os
|
||||
import sys
|
||||
import time
|
||||
from html import escape
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
|
@ -185,29 +187,43 @@ async def control_login_get(login: str = "", email: str = "", next_url: str = ""
|
|||
|
||||
|
||||
@app.get("/dashboard/api/auth/login")
|
||||
async def mock_github_login(redirect_to: str = "", login: str = "") -> Response:
|
||||
"""Mock stand-in for GitHub OAuth: the dashboard's "Continue with GitHub"
|
||||
button lands here. With no ``login``, render a picker of the fake GitHub
|
||||
test users; once one is chosen, mint the real session cookie and redirect
|
||||
back into the dashboard (``redirect_to``)."""
|
||||
async def mock_github_login(redirect_to: str = "") -> Response:
|
||||
"""E2E stand-in for the dashboard OAuth start route.
|
||||
|
||||
The real route would redirect to github.com. Keep the dashboard-facing URL
|
||||
intact, then hand off to the fake GitHub simulator so Playwright exercises a
|
||||
browser login flow instead of test code pre-minting a session cookie.
|
||||
"""
|
||||
ui = os.environ.get("DASHBOARD_BASE_URL", "").rstrip("/")
|
||||
dest = redirect_to or (f"{ui}/agents" if ui else "/agents")
|
||||
return RedirectResponse(f"/fake-gh/login/oauth/authorize?redirect_to={quote(dest)}", 302)
|
||||
|
||||
|
||||
@app.get("/fake-gh/login/oauth/authorize")
|
||||
async def fake_github_authorize(redirect_to: str = "", login: str = "") -> Response:
|
||||
"""Fake GitHub OAuth consent/login page for dashboard e2e tests."""
|
||||
ui = os.environ.get("DASHBOARD_BASE_URL", "").rstrip("/")
|
||||
dest = redirect_to or (f"{ui}/agents" if ui else "/agents")
|
||||
if not login:
|
||||
options = "".join(
|
||||
f'<option value="{u["login"]}">{u["name"]} (@{u["login"]})</option>' for u in TEST_USERS
|
||||
f'<option value="{escape(u["login"], quote=True)}">'
|
||||
f"{escape(u['name'])} (@{escape(u['login'])})</option>"
|
||||
for u in TEST_USERS
|
||||
)
|
||||
return HTMLResponse(
|
||||
f"""<!doctype html><meta charset=utf-8><title>Continue with GitHub (mock)</title>
|
||||
f"""<!doctype html><meta charset=utf-8><title>GitHub · Authorize open-swe</title>
|
||||
<body style="font-family:system-ui;max-width:420px;margin:3rem auto;padding:0 1rem">
|
||||
<h1 style="font-size:1.1rem">Continue with GitHub (mock)</h1>
|
||||
<p style="color:#888;font-size:0.9rem">Pick a fake GitHub account to sign in as.</p>
|
||||
<form method=get action=/dashboard/api/auth/login>
|
||||
<input type=hidden name=redirect_to value="{dest}">
|
||||
<select name=login style="font:inherit;padding:0.4rem">{options}</select>
|
||||
<button style="font:inherit;padding:0.45rem 0.9rem;cursor:pointer">Continue</button>
|
||||
</form>
|
||||
<p style="color:#888;font-size:0.85rem">Tip: use a separate browser or profile per
|
||||
user so their sessions don't overwrite each other.</p>
|
||||
<main data-testid="fake-github-login">
|
||||
<h1 style="font-size:1.1rem">Authorize open-swe</h1>
|
||||
<p style="color:#888;font-size:0.9rem">Pick a fake GitHub account to continue.</p>
|
||||
<form method=get action=/fake-gh/login/oauth/authorize>
|
||||
<input type=hidden name=redirect_to value="{escape(dest, quote=True)}">
|
||||
<label>GitHub user
|
||||
<select name=login style="font:inherit;padding:0.4rem">{options}</select>
|
||||
</label>
|
||||
<button style="font:inherit;padding:0.45rem 0.9rem;cursor:pointer">Authorize open-swe</button>
|
||||
</form>
|
||||
</main>
|
||||
</body>"""
|
||||
)
|
||||
match = next((u for u in TEST_USERS if u["login"] == login), None)
|
||||
|
|
|
|||
|
|
@ -38,9 +38,14 @@ test.describe("Plan review (HTTP comments)", () => {
|
|||
// 1. A user asks the bot to PLAN something in Slack.
|
||||
await request.post("/control/reset");
|
||||
const send = await request.post("/mock/slack/send", {
|
||||
data: { text: "<@U0BOT> plan how to add a greet() helper", mention_bot: true },
|
||||
data: {
|
||||
text: "<@U0BOT> plan how to add a greet() helper",
|
||||
mention_bot: true,
|
||||
},
|
||||
});
|
||||
const { thread_id: threadId } = (await send.json()) as { thread_id: string };
|
||||
const { thread_id: threadId } = (await send.json()) as {
|
||||
thread_id: string;
|
||||
};
|
||||
expect(threadId).toBeTruthy();
|
||||
const planPath = `/agents/${threadId}/plan`;
|
||||
|
||||
|
|
@ -57,7 +62,9 @@ test.describe("Plan review (HTTP comments)", () => {
|
|||
};
|
||||
return (state.values?.messages ?? [])
|
||||
.map((m) =>
|
||||
typeof m.content === "string" ? m.content : JSON.stringify(m.content),
|
||||
typeof m.content === "string"
|
||||
? m.content
|
||||
: JSON.stringify(m.content),
|
||||
)
|
||||
.some((c) => c.includes("Plan mode is active"));
|
||||
},
|
||||
|
|
@ -67,13 +74,45 @@ test.describe("Plan review (HTTP comments)", () => {
|
|||
|
||||
// 2. The agent shares the plan-review link, then announces the plan is ready.
|
||||
await expect
|
||||
.poll(async () => (await botMessages(request)).join("\n"), { timeout: 60_000 })
|
||||
.poll(async () => (await botMessages(request)).join("\n"), {
|
||||
timeout: 60_000,
|
||||
})
|
||||
.toMatch(/\/agents\/[^/]+\/plan\b/);
|
||||
await expect
|
||||
.poll(async () => (await botMessages(request)).join("\n"), { timeout: 60_000 })
|
||||
.poll(async () => (await botMessages(request)).join("\n"), {
|
||||
timeout: 60_000,
|
||||
})
|
||||
.toMatch(/ready for review/i);
|
||||
|
||||
// 3. The OWNER opens the conversation, follows the "Review plan" banner, and
|
||||
// 3. A logged-out user follows the plan deep link, signs in through the fake
|
||||
// GitHub OAuth simulator, and lands back on the same plan page.
|
||||
const loggedOutCtx = await browser.newContext();
|
||||
const loggedOut = await loggedOutCtx.newPage();
|
||||
await loggedOut.goto(planPath);
|
||||
await expect(loggedOut).toHaveURL(
|
||||
new RegExp(`/login\\?redirect=.*${threadId}.*plan`),
|
||||
);
|
||||
await expect(loggedOut.getByText("Sign in to open-swe")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await loggedOut.getByRole("link", { name: "Continue with GitHub" }).click();
|
||||
await expect(loggedOut).toHaveURL(/\/fake-gh\/login\/oauth\/authorize/);
|
||||
await expect(loggedOut.getByTestId("fake-github-login")).toBeVisible();
|
||||
await loggedOut.getByLabel("GitHub user").selectOption(OWNER.login);
|
||||
await loggedOut.getByRole("button", { name: "Authorize open-swe" }).click();
|
||||
await expect(loggedOut).toHaveURL(new RegExp(`/agents/${threadId}/plan$`));
|
||||
await expect(loggedOut.getByTestId("plan-review")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(loggedOut.getByTestId("plan-document")).toContainText(
|
||||
"greet",
|
||||
{
|
||||
timeout: 30_000,
|
||||
},
|
||||
);
|
||||
await loggedOutCtx.close();
|
||||
|
||||
// 4. The OWNER opens the conversation, follows the "Review plan" banner, and
|
||||
// sees the rendered plan.
|
||||
const ownerCtx = await browser.newContext({
|
||||
permissions: ["clipboard-read", "clipboard-write"],
|
||||
|
|
@ -85,7 +124,9 @@ test.describe("Plan review (HTTP comments)", () => {
|
|||
await expect(reviewLink).toBeVisible({ timeout: 30_000 });
|
||||
await reviewLink.click();
|
||||
await expect(owner).toHaveURL(new RegExp(`/agents/${threadId}/plan$`));
|
||||
await expect(owner.getByTestId("plan-review")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(owner.getByTestId("plan-review")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(owner.getByText("Back to conversation")).toBeVisible();
|
||||
await expect(owner.getByTestId("plan-document")).toContainText("greet", {
|
||||
timeout: 30_000,
|
||||
|
|
@ -98,7 +139,9 @@ test.describe("Plan review (HTTP comments)", () => {
|
|||
// Copy the whole plan as markdown.
|
||||
await owner.getByTestId("copy-plan").click();
|
||||
await expect(owner.getByTestId("copy-plan")).toContainText("Copied!");
|
||||
const clipboard = await owner.evaluate(() => navigator.clipboard.readText());
|
||||
const clipboard = await owner.evaluate(() =>
|
||||
navigator.clipboard.readText(),
|
||||
);
|
||||
expect(clipboard).toContain("## Plan: Add greet() helper");
|
||||
expect(clipboard).toContain("### Verification");
|
||||
|
||||
|
|
@ -107,18 +150,24 @@ test.describe("Plan review (HTTP comments)", () => {
|
|||
await expect(owner.getByTestId("plan-comment")).toHaveCount(1);
|
||||
await expect(owner.getByTestId("reject-plan")).toBeEnabled();
|
||||
|
||||
// 4. A COLLABORATOR opens the same plan: sees it AND the owner's comment
|
||||
// 5. A COLLABORATOR opens the same plan: sees it AND the owner's comment
|
||||
// (fetched over HTTP), but has NO approve button.
|
||||
const collabCtx = await browser.newContext();
|
||||
await collabCtx.request.post("/control/login", { data: COLLABORATOR });
|
||||
const collab = await collabCtx.newPage();
|
||||
await collab.goto(planPath);
|
||||
await expect(collab.getByTestId("plan-review")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(collab.getByTestId("plan-review")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(collab.getByTestId("plan-document")).toContainText("greet", {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(collab.getByTestId("plan-comment")).toHaveCount(1, { timeout: 30_000 });
|
||||
await expect(collab.getByTestId("plan-comment")).toContainText("looks solid");
|
||||
await expect(collab.getByTestId("plan-comment")).toHaveCount(1, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(collab.getByTestId("plan-comment")).toContainText(
|
||||
"looks solid",
|
||||
);
|
||||
await expect(collab.getByTestId("approve-plan")).toHaveCount(0);
|
||||
await expect(collab.getByTestId("reject-plan")).toBeVisible();
|
||||
|
||||
|
|
@ -126,20 +175,27 @@ test.describe("Plan review (HTTP comments)", () => {
|
|||
await addComment(collab, "Reviewer: please also add a docstring.");
|
||||
await expect(collab.getByTestId("plan-comment")).toHaveCount(2);
|
||||
|
||||
// 5. The owner sees the collaborator's comment (polled), then approves.
|
||||
await expect(owner.getByTestId("plan-comment")).toHaveCount(2, { timeout: 30_000 });
|
||||
// 6. The owner sees the collaborator's comment (polled), then approves and
|
||||
// returns to the main conversation while implementation starts.
|
||||
await expect(owner.getByTestId("plan-comment")).toHaveCount(2, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await owner.getByTestId("approve-plan").click();
|
||||
await expect(owner.getByTestId("plan-decision")).toContainText(/implementing/i);
|
||||
await expect(owner).toHaveURL(new RegExp(`/agents/${threadId}$`));
|
||||
|
||||
// 6. The agent implements, opens a PR, and links it back in the Slack thread,
|
||||
// 7. The agent implements, opens a PR, and links it back in the Slack thread,
|
||||
// echoing the reviewers' feedback — which proves the comments were stored
|
||||
// and harvested server-side on approve.
|
||||
await expect
|
||||
.poll(async () => (await botMessages(request)).join("\n"), { timeout: 90_000 })
|
||||
.poll(async () => (await botMessages(request)).join("\n"), {
|
||||
timeout: 90_000,
|
||||
})
|
||||
.toMatch(/\/pull\//);
|
||||
expect((await botMessages(request)).join("\n")).toMatch(/docstring/);
|
||||
|
||||
const prs = (await (await request.get("/mock/github/data")).json()) as Array<unknown>;
|
||||
const prs = (await (
|
||||
await request.get("/mock/github/data")
|
||||
).json()) as Array<unknown>;
|
||||
expect(prs.length).toBeGreaterThan(0);
|
||||
|
||||
await ownerCtx.close();
|
||||
|
|
|
|||
30
tests/test_dashboard_oauth_redirect.py
Normal file
30
tests/test_dashboard_oauth_redirect.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from agent.dashboard.oauth import sanitize_redirect_to
|
||||
|
||||
|
||||
def test_sanitize_redirect_to_preserves_allowed_dashboard_target(monkeypatch) -> None:
|
||||
monkeypatch.setenv("DASHBOARD_BASE_URL", "https://dashboard.example")
|
||||
monkeypatch.setenv("DASHBOARD_ALLOWED_ORIGINS", "https://preview.example")
|
||||
|
||||
target = "https://dashboard.example/agents/thread-1/plan?from=slack#review"
|
||||
|
||||
assert sanitize_redirect_to(target) == target
|
||||
|
||||
|
||||
def test_sanitize_redirect_to_preserves_allowed_preview_target(monkeypatch) -> None:
|
||||
monkeypatch.setenv("DASHBOARD_BASE_URL", "https://dashboard.example")
|
||||
monkeypatch.setenv("DASHBOARD_ALLOWED_ORIGINS", "https://preview.example")
|
||||
|
||||
target = "https://preview.example/agents/thread-1/plan?from=slack#review"
|
||||
|
||||
assert sanitize_redirect_to(target) == target
|
||||
|
||||
|
||||
def test_sanitize_redirect_to_rejects_external_target(monkeypatch) -> None:
|
||||
monkeypatch.setenv("DASHBOARD_BASE_URL", "https://dashboard.example")
|
||||
monkeypatch.setenv("DASHBOARD_ALLOWED_ORIGINS", "https://preview.example")
|
||||
|
||||
assert sanitize_redirect_to("https://evil.example/agents/thread-1/plan") == (
|
||||
"https://dashboard.example"
|
||||
)
|
||||
|
|
@ -229,8 +229,10 @@ def test_construct_system_prompt_uses_sea_haven_conventions() -> None:
|
|||
prompt = construct_system_prompt(working_dir="/workspace")
|
||||
|
||||
# Branch naming, PR structure, and commit format follow the handbook.
|
||||
assert "feature/" in prompt and "hotfix/" in prompt
|
||||
assert "Do NOT use a conventional-commit `type:` prefix" in prompt
|
||||
assert "feature/" in prompt and "hotfix/" in prompt and "chore/" in prompt
|
||||
# Commits use the Sea Haven conventional-commit format with the allowed type list.
|
||||
assert "conventional-commit format" in prompt
|
||||
assert "revert" in prompt and "release" in prompt
|
||||
assert "## Summary" in prompt and "## Validation" in prompt
|
||||
assert "## Release Note" not in prompt
|
||||
|
||||
|
|
@ -254,8 +256,8 @@ def test_construct_system_prompt_pr_title_rule_is_repo_aware() -> None:
|
|||
assert "amannn/action-semantic-pull-request" in prompt
|
||||
assert "repo-aware" in prompt
|
||||
assert "type(scope): description" in prompt or "type(scope): …" in prompt
|
||||
# ...without dropping the no-prefix default for repos that don't enforce one.
|
||||
assert "Do NOT use a conventional-commit `type:` prefix" in prompt
|
||||
# ...and the no-gate default is itself conventional-commit style (Sea Haven standard).
|
||||
assert "the Sea Haven default, which is conventional-commit style" in prompt
|
||||
|
||||
|
||||
def test_construct_system_prompt_shell_escapes_user_name() -> None:
|
||||
|
|
|
|||
|
|
@ -12,12 +12,21 @@ from agent.dashboard.team_settings import get_team_default_model
|
|||
|
||||
STALE_ANTHROPIC = "bedrock_converse:us.anthropic.claude-opus-4-7"
|
||||
SUPPORTED_ANTHROPIC = "bedrock_converse:us.anthropic.claude-opus-4-8"
|
||||
STALE_SONNET = "bedrock_converse:us.anthropic.claude-sonnet-4-9"
|
||||
SUPPORTED_SONNET = "bedrock_converse:us.anthropic.claude-sonnet-5"
|
||||
|
||||
|
||||
def test_provider_fallback_preserves_provider_and_effort() -> None:
|
||||
assert provider_fallback_pair(STALE_ANTHROPIC, "xhigh") == (SUPPORTED_ANTHROPIC, "xhigh")
|
||||
|
||||
|
||||
def test_provider_fallback_keeps_bedrock_sonnet_in_family() -> None:
|
||||
# A dropped Bedrock Sonnet must prefer the current Bedrock Sonnet, not cross to
|
||||
# the Bedrock Opus that happens to sit first in the provider's list. Requires
|
||||
# _claude_family_of to understand bedrock_converse ids, not just anthropic:.
|
||||
assert provider_fallback_pair(STALE_SONNET, "high") == (SUPPORTED_SONNET, "high")
|
||||
|
||||
|
||||
def test_provider_fallback_uses_default_effort_when_unsupported() -> None:
|
||||
assert provider_fallback_pair(STALE_ANTHROPIC, "bogus") == (SUPPORTED_ANTHROPIC, "high")
|
||||
assert provider_fallback_pair(STALE_ANTHROPIC, None) == (SUPPORTED_ANTHROPIC, "high")
|
||||
|
|
|
|||
|
|
@ -1,13 +1,67 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from datetime import UTC, datetime
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
wakeup_tool = importlib.import_module("agent.tools.schedule_thread_wakeup")
|
||||
|
||||
# Captured before the autouse stub replaces it, for the one test that needs the real wrapper.
|
||||
_real_purge_best_effort = wakeup_tool._purge_expired_wakeups_best_effort
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _stub_purge(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Keep the opportunistic purge from touching the network in every test."""
|
||||
|
||||
async def _noop() -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(wakeup_tool, "_purge_expired_wakeups_best_effort", _noop)
|
||||
|
||||
|
||||
class _FakeCrons:
|
||||
def __init__(self, crons: list[dict[str, Any]]) -> None:
|
||||
self._crons = list(crons)
|
||||
self.deleted: list[str] = []
|
||||
self.search_calls: list[dict[str, Any]] = []
|
||||
|
||||
async def search(
|
||||
self,
|
||||
*,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
**_: Any,
|
||||
) -> list[dict[str, Any]]:
|
||||
self.search_calls.append({"metadata": metadata, "limit": limit, "offset": offset})
|
||||
items = [
|
||||
c
|
||||
for c in self._crons
|
||||
if not metadata
|
||||
or all((c.get("metadata") or {}).get(k) == v for k, v in metadata.items())
|
||||
]
|
||||
return items[offset : offset + limit]
|
||||
|
||||
async def delete(self, cron_id: str) -> None:
|
||||
self.deleted.append(cron_id)
|
||||
self._crons = [c for c in self._crons if c.get("cron_id") != cron_id]
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, crons: list[dict[str, Any]]) -> None:
|
||||
self.crons = _FakeCrons(crons)
|
||||
|
||||
|
||||
def _wakeup_cron(cron_id: str, end_time: datetime | None) -> dict[str, Any]:
|
||||
return {
|
||||
"cron_id": cron_id,
|
||||
"end_time": end_time.isoformat() if end_time else None,
|
||||
"metadata": {"kind": "thread_wakeup"},
|
||||
}
|
||||
|
||||
|
||||
def _config(**overrides: Any) -> dict[str, Any]:
|
||||
base: dict[str, Any] = {
|
||||
|
|
@ -241,3 +295,71 @@ def test_build_one_shot_cron_handles_month_boundary() -> None:
|
|||
assert parts[1] == "23"
|
||||
assert parts[2] == "31"
|
||||
assert parts[3] == "12"
|
||||
|
||||
|
||||
async def test_purge_deletes_only_expired_wakeups() -> None:
|
||||
now = datetime(2026, 6, 30, 22, 0, tzinfo=UTC)
|
||||
client = _FakeClient(
|
||||
[
|
||||
_wakeup_cron("expired-1", now - timedelta(hours=1)),
|
||||
_wakeup_cron("expired-2", now - timedelta(days=1)),
|
||||
_wakeup_cron("future-1", now + timedelta(hours=1)),
|
||||
_wakeup_cron("no-end", None),
|
||||
]
|
||||
)
|
||||
|
||||
deleted = await wakeup_tool.purge_expired_wakeup_crons(client, now=now)
|
||||
|
||||
assert deleted == 2
|
||||
assert client.crons.deleted == ["expired-1", "expired-2"]
|
||||
# Search is scoped to the thread_wakeup kind so other crons are never seen.
|
||||
assert client.crons.search_calls[0]["metadata"] == {"kind": "thread_wakeup"}
|
||||
|
||||
|
||||
async def test_purge_paginates(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(wakeup_tool, "_PURGE_PAGE_SIZE", 2)
|
||||
now = datetime(2026, 6, 30, 22, 0, tzinfo=UTC)
|
||||
client = _FakeClient([_wakeup_cron(f"expired-{i}", now - timedelta(hours=1)) for i in range(3)])
|
||||
|
||||
deleted = await wakeup_tool.purge_expired_wakeup_crons(client, now=now)
|
||||
|
||||
assert deleted == 3
|
||||
assert sorted(client.crons.deleted) == ["expired-0", "expired-1", "expired-2"]
|
||||
# Two pages fetched (offset 0 and 2), then a short final page ends the loop.
|
||||
assert [c["offset"] for c in client.crons.search_calls] == [0, 2]
|
||||
|
||||
|
||||
async def test_best_effort_purge_swallows_errors(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def boom(*_: Any, **__: Any) -> int:
|
||||
raise RuntimeError("search failed")
|
||||
|
||||
monkeypatch.setattr(wakeup_tool, "purge_expired_wakeup_crons", boom)
|
||||
monkeypatch.setattr(wakeup_tool, "get_client", lambda url: object())
|
||||
|
||||
# The real wrapper must never propagate — a purge failure can't block wakeups.
|
||||
await _real_purge_best_effort()
|
||||
|
||||
|
||||
async def test_schedule_purges_before_creating(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
calls: list[str] = []
|
||||
|
||||
async def spy_purge() -> None:
|
||||
calls.append("purge")
|
||||
|
||||
async def fake_create_wakeup_cron(**kwargs: Any) -> dict[str, Any]:
|
||||
calls.append("create")
|
||||
return {
|
||||
"success": True,
|
||||
"cron_id": "cron-1",
|
||||
"scheduled_for": "",
|
||||
"thread_id": kwargs["thread_id"],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(wakeup_tool, "get_config", _config)
|
||||
monkeypatch.setattr(wakeup_tool, "_purge_expired_wakeups_best_effort", spy_purge)
|
||||
monkeypatch.setattr(wakeup_tool, "_create_wakeup_cron", fake_create_wakeup_cron)
|
||||
|
||||
result = await wakeup_tool.schedule_thread_wakeup(5)
|
||||
|
||||
assert result["success"] is True
|
||||
assert calls == ["purge", "create"]
|
||||
|
|
|
|||
|
|
@ -6,9 +6,11 @@ import pytest
|
|||
from pydantic import ValidationError
|
||||
|
||||
from agent.dashboard.team_settings import (
|
||||
DEFAULT_ORG_REVIEW_GUIDELINES,
|
||||
ORG_GUIDELINES_MAX_CHARS,
|
||||
REVIEW_TRACING_PROJECT_MAX_CHARS,
|
||||
TeamSettingsUpdate,
|
||||
_default_settings,
|
||||
get_org_review_guidelines,
|
||||
get_team_default_model,
|
||||
get_team_review_tracing_project,
|
||||
|
|
@ -33,6 +35,14 @@ def test_org_guidelines_rejects_oversized() -> None:
|
|||
TeamSettingsUpdate(org_guidelines="x" * (ORG_GUIDELINES_MAX_CHARS + 1))
|
||||
|
||||
|
||||
def test_default_settings_seed_sea_haven_org_guidelines() -> None:
|
||||
# Unset org guidelines default to the baked Sea Haven review baseline so the
|
||||
# reviewer applies it on every repo until an admin overrides it.
|
||||
assert _default_settings()["org_guidelines"] == DEFAULT_ORG_REVIEW_GUIDELINES
|
||||
assert "Sea Haven review baseline" in DEFAULT_ORG_REVIEW_GUIDELINES
|
||||
assert len(DEFAULT_ORG_REVIEW_GUIDELINES) <= ORG_GUIDELINES_MAX_CHARS
|
||||
|
||||
|
||||
def test_review_tracing_project_blank_normalizes_to_none() -> None:
|
||||
assert TeamSettingsUpdate(review_tracing_project=" ").review_tracing_project is None
|
||||
assert TeamSettingsUpdate(review_tracing_project=None).review_tracing_project is None
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
"@monaco-editor/react": "^4.7.0",
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"@pierre/diffs": "^1.2.1",
|
||||
"@pierre/trees": "1.0.0-beta.4",
|
||||
"@pierre/trees": "1.0.0-beta.5",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@tanstack/react-devtools": "^0.10.0",
|
||||
"@tanstack/react-query": "^5.100.10",
|
||||
|
|
@ -27,7 +27,7 @@
|
|||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.16.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"nitro": "latest",
|
||||
"nitro": "3.0.260603-beta",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-icons": "^5.6.0",
|
||||
|
|
@ -494,7 +494,9 @@
|
|||
|
||||
"@pierre/theme": ["@pierre/theme@1.0.3", "", {}, "sha512-sWHv11TMoqKxKDgTIk5VbhQjdPhs8DCcBxbjh3mRlS3YOM/OcrWoGX6MM8eBGn9cUu3M46Py0JnxsG2nJaFTuA=="],
|
||||
|
||||
"@pierre/trees": ["@pierre/trees@1.0.0-beta.4", "", { "dependencies": { "preact": "11.0.0-beta.0", "preact-render-to-string": "6.6.5" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-OfT1yk9ne8Te5+GB5zUY8yqE6B8BqjBHQJleH4lu8ltwNpoocZl4vXt1AzlEExpxI/pp+AFX5QG+lR3JjtTEag=="],
|
||||
"@pierre/theming": ["@pierre/theming@0.0.2", "", { "peerDependencies": { "@pierre/theme": "^1.1.0", "@shikijs/themes": "^3.0.0 || ^4.0.0", "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0", "shiki": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["@pierre/theme", "@shikijs/themes", "react", "react-dom", "shiki"] }, "sha512-QM1M4stXfnzfaE8I8YbjXSApV8c+2dBsXJj8eYg9WTpBR/cTmCZIcfGnN4p13iRrYu2Br/R/OJfEL7uR8Qjctw=="],
|
||||
|
||||
"@pierre/trees": ["@pierre/trees@1.0.0-beta.5", "", { "dependencies": { "@pierre/theming": "0.0.2", "preact": "11.0.0-beta.0", "preact-render-to-string": "6.6.5" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-IzxkB9qv6GLbeEXObhlAD205LfYHiLeRwJdnaIdX0f5keTZF4X9EfiuEQ3QiyxOxouVVmUX3rX7m6a8zNMo/wA=="],
|
||||
|
||||
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.4", "", { "os": "android", "cpu": "arm64" }, "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw=="],
|
||||
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@
|
|||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.16.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"nitro": "latest",
|
||||
"nitro": "3.0.260603-beta",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-icons": "^5.6.0",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { StartClient } from "@tanstack/react-start/client"
|
||||
import { StrictMode, useEffect, useState } from "react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { hydrateRoot } from "react-dom/client"
|
||||
import { registerSW } from "virtual:pwa-register"
|
||||
|
||||
|
|
@ -35,8 +35,8 @@ function PwaUpdateProvider() {
|
|||
|
||||
hydrateRoot(
|
||||
document,
|
||||
<StrictMode>
|
||||
<>
|
||||
<StartClient />
|
||||
<PwaUpdateProvider />
|
||||
</StrictMode>
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useCallback, useEffect, useState } from "react"
|
||||
import { useNavigate } from "@tanstack/react-router"
|
||||
|
||||
import type { PlanComment, PlanData } from "@/lib/plan"
|
||||
import {
|
||||
|
|
@ -47,6 +48,7 @@ async function copyToClipboard(text: string): Promise<boolean> {
|
|||
}
|
||||
|
||||
export function PlanReview({ plan }: { plan: PlanData }) {
|
||||
const navigate = useNavigate()
|
||||
const resolvedTheme = useResolvedTheme()
|
||||
const [comments, setComments] = useState<Array<PlanComment>>([])
|
||||
const [draft, setDraft] = useState("")
|
||||
|
|
@ -108,20 +110,23 @@ export function PlanReview({ plan }: { plan: PlanData }) {
|
|||
setBusy(kind)
|
||||
setError(null)
|
||||
try {
|
||||
if (kind === "approve") await approvePlan(plan.threadId)
|
||||
else await rejectPlan(plan.threadId)
|
||||
setDecision(
|
||||
kind === "approve"
|
||||
? "Plan approved — the agent is implementing it."
|
||||
: "Changes requested — the agent is revising the plan."
|
||||
)
|
||||
if (kind === "approve") {
|
||||
await approvePlan(plan.threadId)
|
||||
await navigate({
|
||||
to: "/agents/$threadId",
|
||||
params: { threadId: plan.threadId },
|
||||
})
|
||||
return
|
||||
}
|
||||
await rejectPlan(plan.threadId)
|
||||
setDecision("Changes requested — the agent is revising the plan.")
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
},
|
||||
[plan.threadId]
|
||||
[navigate, plan.threadId]
|
||||
)
|
||||
|
||||
const copyPlan = useCallback(async () => {
|
||||
|
|
@ -139,8 +144,8 @@ export function PlanReview({ plan }: { plan: PlanData }) {
|
|||
data-testid="plan-review"
|
||||
className="flex min-h-0 flex-1 flex-col bg-[var(--ui-bg)] text-[var(--ui-text)]"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4 border-b border-[var(--ui-border)] px-6 py-3">
|
||||
<div>
|
||||
<div className="flex flex-col gap-3 border-b border-[var(--ui-border)] px-4 py-3 md:flex-row md:items-center md:justify-between md:gap-4 md:px-6">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-base font-semibold text-[var(--ui-text)]">
|
||||
Implementation plan
|
||||
</h1>
|
||||
|
|
@ -150,11 +155,11 @@ export function PlanReview({ plan }: { plan: PlanData }) {
|
|||
<span data-testid="plan-status">{plan.status}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2 md:shrink-0 md:justify-end">
|
||||
{decision && (
|
||||
<span
|
||||
data-testid="plan-decision"
|
||||
className="text-xs text-[var(--ui-text-dim)]"
|
||||
className="w-full text-xs text-[var(--ui-text-dim)] md:w-auto"
|
||||
>
|
||||
{decision}
|
||||
</span>
|
||||
|
|
@ -194,9 +199,9 @@ export function PlanReview({ plan }: { plan: PlanData }) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 overflow-hidden">
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto md:flex-row md:overflow-hidden">
|
||||
<div
|
||||
className="min-h-0 flex-1 overflow-auto px-6 py-4"
|
||||
className="min-w-0 px-4 py-4 md:min-h-0 md:flex-1 md:overflow-auto md:px-6"
|
||||
data-testid="plan-document"
|
||||
data-color-scheme={resolvedTheme}
|
||||
>
|
||||
|
|
@ -209,14 +214,14 @@ export function PlanReview({ plan }: { plan: PlanData }) {
|
|||
)}
|
||||
</div>
|
||||
|
||||
<aside className="flex w-80 shrink-0 flex-col border-l border-[var(--ui-border)]">
|
||||
<aside className="flex shrink-0 flex-col border-t border-[var(--ui-border)] md:w-80 md:border-t-0 md:border-l">
|
||||
<div className="border-b border-[var(--ui-border)] px-4 py-3">
|
||||
<h2 className="text-sm font-semibold text-[var(--ui-text)]">
|
||||
Comments
|
||||
</h2>
|
||||
</div>
|
||||
<div
|
||||
className="min-h-0 flex-1 space-y-3 overflow-auto px-4 py-3"
|
||||
className="max-h-80 space-y-3 overflow-auto px-4 py-3 md:max-h-none md:min-h-0 md:flex-1"
|
||||
data-testid="plan-comments"
|
||||
>
|
||||
{comments.length === 0 ? (
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ import {
|
|||
MultiFileDiff,
|
||||
Virtualizer,
|
||||
WorkerPoolContextProvider,
|
||||
useVirtualizer,
|
||||
} from "@pierre/diffs/react"
|
||||
import type { Icon } from "@phosphor-icons/react"
|
||||
import type { FileContents } from "@pierre/diffs/react"
|
||||
|
|
@ -73,7 +74,10 @@ import {
|
|||
ReviewChatComposerProvider,
|
||||
useReviewChatComposer,
|
||||
} from "@/components/agents/ReviewChat"
|
||||
import { ReviewSidebarPanel } from "@/components/agents/ReviewSidebar"
|
||||
import {
|
||||
ReviewSidebarPanel,
|
||||
renderInlineCode,
|
||||
} from "@/components/agents/ReviewSidebar"
|
||||
import {
|
||||
DIFF_VIRTUALIZER_CONFIG,
|
||||
DIFF_VIRTUAL_METRICS,
|
||||
|
|
@ -213,46 +217,60 @@ function selectedRangeFromDiff(
|
|||
}
|
||||
}
|
||||
|
||||
// Scroll a file card / group flush to the top of the diff scroller. Under
|
||||
// virtualization, scrollIntoView computes its target against estimated row
|
||||
// heights; scrolling past unmeasured files reconciles their real heights
|
||||
// mid-animation and the Virtualizer re-pins its scroll anchor, which leaves the
|
||||
// target off the top. Once the smooth scroll settles, re-assert alignment (now
|
||||
// against measured heights) until the target sits at the top or the budget runs
|
||||
// out. Respects the element's scroll-margin-top.
|
||||
function scrollCardToTop(el: HTMLElement, scroller: HTMLElement | null): void {
|
||||
el.scrollIntoView({ block: "start", behavior: "smooth" })
|
||||
if (!scroller) return
|
||||
let frames = 0
|
||||
let lastTop = Number.NaN
|
||||
let stableFrames = 0
|
||||
let corrections = 0
|
||||
const align = () => {
|
||||
if (frames++ > 240) return
|
||||
const top = scroller.scrollTop
|
||||
if (top === lastTop) stableFrames++
|
||||
else {
|
||||
stableFrames = 0
|
||||
lastTop = top
|
||||
}
|
||||
// Wait for the smooth scroll + height reconciliation to settle.
|
||||
if (stableFrames < 3) {
|
||||
requestAnimationFrame(align)
|
||||
return
|
||||
}
|
||||
// Scroll a file card / group flush to the top of the diff scroller (fallback
|
||||
// when no virtualizer geometry is available). Jumps instantly to a bounding-rect
|
||||
// target — respecting the element's scroll-margin-top — then holds that target
|
||||
// as content above reflows, so no smooth-scroll animation races the height
|
||||
// reconciliation. Returns a stop fn to cancel the hold.
|
||||
function scrollCardToTop(
|
||||
el: HTMLElement,
|
||||
scroller: HTMLElement | null
|
||||
): () => void {
|
||||
if (!scroller) {
|
||||
el.scrollIntoView({ block: "start" })
|
||||
return () => {}
|
||||
}
|
||||
return jumpAndHold(scroller, () => {
|
||||
const marginTop = parseFloat(getComputedStyle(el).scrollMarginTop) || 0
|
||||
const delta =
|
||||
el.getBoundingClientRect().top -
|
||||
scroller.getBoundingClientRect().top -
|
||||
marginTop
|
||||
if (Math.abs(delta) > 1 && corrections++ < 5) {
|
||||
el.scrollIntoView({ block: "start", behavior: "smooth" })
|
||||
stableFrames = 0
|
||||
lastTop = Number.NaN
|
||||
requestAnimationFrame(align)
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(align)
|
||||
return clampScrollTop(scroller, scroller.scrollTop + delta)
|
||||
})
|
||||
}
|
||||
|
||||
// The virtualizer instance returned by useVirtualizer(); exposes
|
||||
// getOffsetInScrollContainer for accurate scroll targeting.
|
||||
type DiffVirtualizer = NonNullable<ReturnType<typeof useVirtualizer>>
|
||||
|
||||
// Breathing room left above a block/file when it's scrolled to the top.
|
||||
const SCROLL_TOP_GAP = 8
|
||||
|
||||
// Scroll a block / file card flush to the top of the diff scroller using the
|
||||
// virtualizer's own geometry. getOffsetInScrollContainer returns the element's
|
||||
// absolute offset within the scroll content; with uniform fixed-height rows
|
||||
// (see diffUtils) that offset is stable, so an instant jump lands precisely.
|
||||
// jumpAndHold then re-reads the offset whenever the content reflows (rows above
|
||||
// measuring/expanding) and re-asserts it, so the target stays pinned to the top.
|
||||
// Returns a stop fn to cancel the hold.
|
||||
function scrollCardToTopVirtual(
|
||||
el: HTMLElement,
|
||||
scroller: HTMLElement,
|
||||
virtualizer: DiffVirtualizer
|
||||
): () => void {
|
||||
return jumpAndHold(scroller, () =>
|
||||
clampScrollTop(
|
||||
scroller,
|
||||
virtualizer.getOffsetInScrollContainer(el) - SCROLL_TOP_GAP
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Older stored summaries embed `[label](#loc=path:line)` diff links; render the
|
||||
// label as inline code instead so no stale jump-links leak into the block body.
|
||||
function stripLocationLinks(summary: string): string {
|
||||
return summary.replace(/\[([^\]]+)\]\(#loc=[^)]*\)/g, "`$1`")
|
||||
}
|
||||
|
||||
interface PositionedDiffInstance {
|
||||
|
|
@ -283,16 +301,68 @@ function clampScrollTop(scroller: HTMLElement, top: number): number {
|
|||
)
|
||||
}
|
||||
|
||||
function scrollElementToCenter(el: HTMLElement, scroller: HTMLElement): number {
|
||||
// How long to keep re-asserting a scroll target after the initial jump.
|
||||
const SCROLL_HOLD_TIMEOUT_MS = 700
|
||||
|
||||
// Jump the scroller to getTarget() instantly, then re-assert that target each
|
||||
// time the scroll content reflows (off-screen cards mounting, files expanding,
|
||||
// annotation cards measuring) — a ResizeObserver is the real "layout settled"
|
||||
// signal, replacing fixed frame-budget correction loops. Bails the moment the
|
||||
// user scrolls so we never fight them, and disconnects after a short ceiling.
|
||||
function jumpAndHold(
|
||||
scroller: HTMLElement,
|
||||
getTarget: () => number,
|
||||
timeout = SCROLL_HOLD_TIMEOUT_MS
|
||||
): () => void {
|
||||
let raf = 0
|
||||
let stopped = false
|
||||
let timer = 0
|
||||
let ro: ResizeObserver | null = null
|
||||
const stop = () => {
|
||||
if (stopped) return
|
||||
stopped = true
|
||||
ro?.disconnect()
|
||||
if (raf) cancelAnimationFrame(raf)
|
||||
scroller.removeEventListener("wheel", stop)
|
||||
scroller.removeEventListener("touchstart", stop)
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
const reassert = () => {
|
||||
raf = 0
|
||||
if (stopped) return
|
||||
const desired = getTarget()
|
||||
if (Math.abs(desired - scroller.scrollTop) > 1) {
|
||||
scroller.scrollTo({ top: desired, behavior: "auto" })
|
||||
}
|
||||
}
|
||||
const schedule = () => {
|
||||
if (!raf && !stopped) raf = requestAnimationFrame(reassert)
|
||||
}
|
||||
scroller.scrollTo({ top: getTarget(), behavior: "auto" })
|
||||
ro = new ResizeObserver(schedule)
|
||||
ro.observe(scroller.firstElementChild ?? scroller)
|
||||
scroller.addEventListener("wheel", stop, { passive: true })
|
||||
scroller.addEventListener("touchstart", stop, { passive: true })
|
||||
timer = window.setTimeout(stop, timeout)
|
||||
return stop
|
||||
}
|
||||
|
||||
// Absolute scrollTop that centers el within the scroller's viewport.
|
||||
function elementCenterTarget(el: HTMLElement, scroller: HTMLElement): number {
|
||||
const elementRect = el.getBoundingClientRect()
|
||||
const scrollerRect = scroller.getBoundingClientRect()
|
||||
const delta =
|
||||
elementRect.top -
|
||||
scrollerRect.top -
|
||||
(scroller.clientHeight - elementRect.height) / 2
|
||||
const targetTop = clampScrollTop(scroller, scroller.scrollTop + delta)
|
||||
return clampScrollTop(scroller, scroller.scrollTop + delta)
|
||||
}
|
||||
|
||||
function scrollElementToCenter(el: HTMLElement, scroller: HTMLElement): number {
|
||||
const before = scroller.scrollTop
|
||||
const targetTop = elementCenterTarget(el, scroller)
|
||||
scroller.scrollTo({ top: targetTop, behavior: "auto" })
|
||||
return Math.abs(delta)
|
||||
return Math.abs(targetTop - before)
|
||||
}
|
||||
|
||||
function scrollDiffLineToCenter(
|
||||
|
|
@ -561,8 +631,15 @@ function ReviewBodyInner({
|
|||
range: SelectedLineRange
|
||||
} | null>(null)
|
||||
const diffScrollElRef = useRef<HTMLDivElement | null>(null)
|
||||
const virtualizerRef = useRef<DiffVirtualizer | null>(null)
|
||||
const findingScrollRequestRef = useRef(0)
|
||||
// Cancels the in-flight scroll "hold" (see jumpAndHold) when a new navigation
|
||||
// begins or the component unmounts, so holds never fight each other.
|
||||
const scrollHoldStopRef = useRef<(() => void) | null>(null)
|
||||
const groupRefs = useRef<Record<number, HTMLDivElement | null>>({})
|
||||
// The block pinned at the top of the diff (scroll-spy), highlighted in the
|
||||
// agenda sidebar.
|
||||
const [activeGroup, setActiveGroup] = useState<number | null>(null)
|
||||
const [diffStyle, setDiffStyleState] = useState<DiffStyle>(() =>
|
||||
readStoredDiffStyle()
|
||||
)
|
||||
|
|
@ -726,11 +803,6 @@ function ReviewBodyInner({
|
|||
return groupedView.map((group) => ({
|
||||
index: group.index,
|
||||
title: group.title,
|
||||
summary: group.summary,
|
||||
additions: group.additions,
|
||||
deletions: group.deletions,
|
||||
fileCount: group.files.length,
|
||||
files: group.files.map((file) => file.path),
|
||||
}))
|
||||
}, [groupedView])
|
||||
|
||||
|
|
@ -757,19 +829,66 @@ function ReviewBodyInner({
|
|||
const scrollToFile = useCallback((path: string) => {
|
||||
setSelectedFile(path)
|
||||
setExpandedFiles((prev) => ({ ...prev, [path]: true }))
|
||||
scrollHoldStopRef.current?.()
|
||||
requestAnimationFrame(() => {
|
||||
const el = fileRefs.current[path]
|
||||
if (el) scrollCardToTop(el, diffScrollElRef.current)
|
||||
const scroller = diffScrollElRef.current
|
||||
if (!el || !scroller) return
|
||||
scrollHoldStopRef.current = virtualizerRef.current
|
||||
? scrollCardToTopVirtual(el, scroller, virtualizerRef.current)
|
||||
: scrollCardToTop(el, scroller)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const scrollToGroup = useCallback((index: number) => {
|
||||
scrollHoldStopRef.current?.()
|
||||
requestAnimationFrame(() => {
|
||||
const el = groupRefs.current[index]
|
||||
if (el) scrollCardToTop(el, diffScrollElRef.current)
|
||||
const scroller = diffScrollElRef.current
|
||||
if (!el || !scroller) return
|
||||
scrollHoldStopRef.current = virtualizerRef.current
|
||||
? scrollCardToTopVirtual(el, scroller, virtualizerRef.current)
|
||||
: scrollCardToTop(el, scroller)
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => () => scrollHoldStopRef.current?.(), [])
|
||||
|
||||
// Scroll-spy: track which block's header is currently pinned at the top of the
|
||||
// diff scroller and surface it as the active agenda row (Google-Docs outline).
|
||||
useEffect(() => {
|
||||
if (view !== "ai" || !groupedView || groupedView.length === 0) {
|
||||
setActiveGroup(null)
|
||||
return
|
||||
}
|
||||
const scroller = diffScrollElRef.current
|
||||
if (!scroller) return
|
||||
let raf = 0
|
||||
const compute = () => {
|
||||
raf = 0
|
||||
const top = scroller.getBoundingClientRect().top
|
||||
let current = groupedView[0]?.index ?? null
|
||||
for (const group of groupedView) {
|
||||
const el = groupRefs.current[group.index]
|
||||
if (!el) continue
|
||||
if (el.getBoundingClientRect().top - top <= SCROLL_TOP_GAP + 2)
|
||||
current = group.index
|
||||
else break
|
||||
}
|
||||
setActiveGroup(current)
|
||||
}
|
||||
const onScroll = () => {
|
||||
if (raf) return
|
||||
raf = requestAnimationFrame(compute)
|
||||
}
|
||||
compute()
|
||||
scroller.addEventListener("scroll", onScroll, { passive: true })
|
||||
return () => {
|
||||
scroller.removeEventListener("scroll", onScroll)
|
||||
if (raf) cancelAnimationFrame(raf)
|
||||
}
|
||||
}, [view, groupedView])
|
||||
|
||||
const filesByPath = useMemo(
|
||||
() => new Map((diffFiles ?? []).map((file) => [file.path, file])),
|
||||
[diffFiles]
|
||||
|
|
@ -903,6 +1022,7 @@ function ReviewBodyInner({
|
|||
if (!willExpand || !isAnchored(finding)) return
|
||||
setSelectedFile(finding.file)
|
||||
setExpandedFiles((prev) => ({ ...prev, [finding.file]: true }))
|
||||
scrollHoldStopRef.current?.()
|
||||
let frames = 0
|
||||
let lineScrollDone = false
|
||||
const snap = () => {
|
||||
|
|
@ -910,12 +1030,13 @@ function ReviewBodyInner({
|
|||
const scroller = diffScrollElRef.current
|
||||
if (!scroller) return
|
||||
|
||||
// Once the finding's inline card has mounted (its diff rows window in
|
||||
// under virtualization), center it and hold as the card settles.
|
||||
const annotation = annotationRefs.current[finding.id]
|
||||
if (annotation?.isConnected && annotation.getClientRects().length > 0) {
|
||||
const delta = scrollElementToCenter(annotation, scroller)
|
||||
if (delta <= 1 || frames >= FINDING_SCROLL_MAX_FRAMES) return
|
||||
frames += 1
|
||||
requestAnimationFrame(snap)
|
||||
scrollHoldStopRef.current = jumpAndHold(scroller, () =>
|
||||
elementCenterTarget(annotation, scroller)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -961,6 +1082,7 @@ function ReviewBodyInner({
|
|||
}
|
||||
setSelectedFile(path)
|
||||
setExpandedFiles((prev) => ({ ...prev, [path]: true }))
|
||||
scrollHoldStopRef.current?.()
|
||||
const requestId = ++findingScrollRequestRef.current
|
||||
const side: SelectionSide =
|
||||
openComment.side === "LEFT" ? "deletions" : "additions"
|
||||
|
|
@ -975,10 +1097,9 @@ function ReviewBodyInner({
|
|||
const annotation = annotationRefs.current[key]
|
||||
if (annotation?.isConnected && annotation.getClientRects().length > 0) {
|
||||
mounted = true
|
||||
const delta = scrollElementToCenter(annotation, scroller)
|
||||
if (delta <= 1 || frames >= FINDING_SCROLL_MAX_FRAMES) return
|
||||
frames += 1
|
||||
requestAnimationFrame(snap)
|
||||
scrollHoldStopRef.current = jumpAndHold(scroller, () =>
|
||||
elementCenterTarget(annotation, scroller)
|
||||
)
|
||||
return
|
||||
}
|
||||
const diffTarget = diffInstanceRefs.current[path]
|
||||
|
|
@ -1055,6 +1176,7 @@ function ReviewBodyInner({
|
|||
view,
|
||||
onViewChange: setView,
|
||||
onSelectGroup: scrollToGroup,
|
||||
activeGroup,
|
||||
}),
|
||||
[
|
||||
detail.number,
|
||||
|
|
@ -1066,6 +1188,7 @@ function ReviewBodyInner({
|
|||
view,
|
||||
setView,
|
||||
scrollToGroup,
|
||||
activeGroup,
|
||||
]
|
||||
)
|
||||
|
||||
|
|
@ -1122,7 +1245,10 @@ function ReviewBodyInner({
|
|||
)}
|
||||
config={DIFF_VIRTUALIZER_CONFIG}
|
||||
>
|
||||
<div ref={scrollerProbe} aria-hidden className="hidden" />
|
||||
<VirtualizerBridge
|
||||
probeRef={scrollerProbe}
|
||||
instanceRef={virtualizerRef}
|
||||
/>
|
||||
<PrHeader
|
||||
url={detail.url}
|
||||
title={detail.pr.title}
|
||||
|
|
@ -1271,21 +1397,54 @@ function DiffStyleButton({
|
|||
)
|
||||
}
|
||||
|
||||
// Grabs the virtualizer instance from context (only available inside
|
||||
// <Virtualizer>) and lifts it to the parent ref so scroll-to can read accurate
|
||||
// offsets. Doubles as the hidden scroll-element probe.
|
||||
function VirtualizerBridge({
|
||||
probeRef,
|
||||
instanceRef,
|
||||
}: {
|
||||
probeRef: (node: HTMLDivElement | null) => void
|
||||
instanceRef: React.MutableRefObject<DiffVirtualizer | null>
|
||||
}) {
|
||||
const virtualizer = useVirtualizer()
|
||||
useEffect(() => {
|
||||
instanceRef.current = virtualizer ?? null
|
||||
}, [virtualizer, instanceRef])
|
||||
return <div ref={probeRef} aria-hidden className="hidden" />
|
||||
}
|
||||
|
||||
// The block header: number + title + stats, then the block description. Pinned
|
||||
// at the top of the diff scroller while scrolling the block (Google-Docs feel),
|
||||
// stacked above Pierre's in-diff sticky header (z-index 4). A long description
|
||||
// scrolls within the pinned header instead of consuming the viewport.
|
||||
function GroupHeader({ group }: { group: ResolvedGroup }) {
|
||||
const title = useMemo(() => renderInlineCode(group.title), [group.title])
|
||||
const summary = useMemo(
|
||||
() => (group.summary ? stripLocationLinks(group.summary) : ""),
|
||||
[group.summary]
|
||||
)
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex size-5 shrink-0 items-center justify-center rounded bg-[var(--ui-panel-2)] text-[11px] font-medium text-muted-foreground">
|
||||
{group.index}
|
||||
</span>
|
||||
<h3 className="min-w-0 truncate text-sm font-medium">{group.title}</h3>
|
||||
<span className="flex shrink-0 items-center gap-1.5 font-mono text-[11px]">
|
||||
{group.additions > 0 && (
|
||||
<span className="text-emerald-500">+{group.additions}</span>
|
||||
)}
|
||||
{group.deletions > 0 && (
|
||||
<span className="text-red-500">-{group.deletions}</span>
|
||||
)}
|
||||
</span>
|
||||
<div className="sticky top-0 z-[5] border-b border-border bg-background py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex size-5 shrink-0 items-center justify-center rounded bg-[var(--ui-panel-2)] text-[11px] font-medium text-muted-foreground">
|
||||
{group.index}
|
||||
</span>
|
||||
<h3 className="min-w-0 flex-1 truncate text-sm font-medium">{title}</h3>
|
||||
<span className="flex shrink-0 items-center gap-1.5 font-mono text-[11px]">
|
||||
{group.additions > 0 && (
|
||||
<span className="text-emerald-500">+{group.additions}</span>
|
||||
)}
|
||||
{group.deletions > 0 && (
|
||||
<span className="text-red-500">-{group.deletions}</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{summary && (
|
||||
<div className="mt-2 max-h-40 overflow-y-auto text-xs text-muted-foreground">
|
||||
<Markdown content={summary} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,10 @@
|
|||
import { memo, useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { memo, useCallback, useEffect, useMemo } from "react"
|
||||
import {
|
||||
FileTree,
|
||||
useFileTree,
|
||||
useFileTreeSelection,
|
||||
} from "@pierre/trees/react"
|
||||
import {
|
||||
CaretRightIcon,
|
||||
ListBulletsIcon,
|
||||
TreeViewIcon,
|
||||
} from "@phosphor-icons/react"
|
||||
import { ListBulletsIcon, TreeViewIcon } from "@phosphor-icons/react"
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
import type {
|
||||
|
|
@ -17,7 +13,6 @@ import type {
|
|||
GitStatusEntry,
|
||||
} from "@pierre/trees"
|
||||
import type { ReviewDiffFile } from "@/lib/api"
|
||||
import { Markdown } from "@/components/agents/ported"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import {
|
||||
TREE_UNSAFE_CSS,
|
||||
|
|
@ -37,11 +32,6 @@ export type ReviewSidebarView = "ai" | "files"
|
|||
export interface ReviewSidebarGroup {
|
||||
index: number
|
||||
title: string
|
||||
summary: string
|
||||
additions: number
|
||||
deletions: number
|
||||
fileCount: number
|
||||
files: Array<string>
|
||||
}
|
||||
|
||||
export interface ReviewSidebarData {
|
||||
|
|
@ -54,6 +44,9 @@ export interface ReviewSidebarData {
|
|||
view: ReviewSidebarView
|
||||
onViewChange: (view: ReviewSidebarView) => void
|
||||
onSelectGroup: (index: number) => void
|
||||
// The block currently pinned at the top of the diff (scroll-spy), highlighted
|
||||
// in the agenda. null when no block is active or the AI view isn't shown.
|
||||
activeGroup: number | null
|
||||
}
|
||||
|
||||
export function ReviewSidebarPanel({ data }: { data: ReviewSidebarData }) {
|
||||
|
|
@ -73,8 +66,8 @@ export function ReviewSidebarPanel({ data }: { data: ReviewSidebarData }) {
|
|||
{showAi ? (
|
||||
<ReviewGroupList
|
||||
groups={data.groups ?? []}
|
||||
activeGroup={data.activeGroup}
|
||||
onSelectGroup={data.onSelectGroup}
|
||||
onSelectFile={data.onSelect}
|
||||
/>
|
||||
) : !data.files ? (
|
||||
<div className="px-4 pt-1">
|
||||
|
|
@ -150,43 +143,31 @@ function ReviewViewToggleButton({
|
|||
|
||||
function ReviewGroupList({
|
||||
groups,
|
||||
activeGroup,
|
||||
onSelectGroup,
|
||||
onSelectFile,
|
||||
}: {
|
||||
groups: Array<ReviewSidebarGroup>
|
||||
activeGroup: number | null
|
||||
onSelectGroup: (index: number) => void
|
||||
onSelectFile: (path: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="min-h-0 flex-1 divide-y divide-[var(--ui-border-subtle)] overflow-y-auto">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto py-1">
|
||||
{groups.map((group) => (
|
||||
<ReviewGroupRow
|
||||
key={group.index}
|
||||
group={group}
|
||||
active={group.index === activeGroup}
|
||||
onSelectGroup={onSelectGroup}
|
||||
onSelectFile={onSelectFile}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function splitPath(path: string): { dir: string; base: string } {
|
||||
const idx = path.lastIndexOf("/")
|
||||
if (idx === -1) return { dir: "", base: path }
|
||||
return { dir: path.slice(0, idx), base: path.slice(idx + 1) }
|
||||
}
|
||||
|
||||
// Older stored summaries embed `[label](#loc=path:line)` diff links. Render the
|
||||
// label as inline code instead so no stale jump-links leak into the explanation.
|
||||
function stripLocationLinks(summary: string): string {
|
||||
return summary.replace(/\[([^\]]+)\]\(#loc=[^)]*\)/g, "`$1`")
|
||||
}
|
||||
|
||||
// Render a title with `backtick`-delimited spans as inline code chips, matching
|
||||
// the Markdown component's inline-code styling, without pulling in the full
|
||||
// block renderer for a single line.
|
||||
function renderInlineCode(text: string): Array<ReactNode> {
|
||||
export function renderInlineCode(text: string): Array<ReactNode> {
|
||||
return text.split(/(`[^`]+`)/g).map((part, i) => {
|
||||
if (part.length >= 2 && part.startsWith("`") && part.endsWith("`")) {
|
||||
return (
|
||||
|
|
@ -202,26 +183,20 @@ function renderInlineCode(text: string): Array<ReactNode> {
|
|||
})
|
||||
}
|
||||
|
||||
// The whole card is the scroll-to-group target so clicks anywhere (including
|
||||
// the expanded explanation body) focus the diff. Nested controls — the file
|
||||
// links and the "Read explanation" toggle — stop propagation so they keep
|
||||
// their own behavior. memo'd + memoized string processing so re-renders from
|
||||
// sibling state don't re-run Markdown/inline-code work.
|
||||
// A single agenda entry: just the block number + title, like a Google-Docs
|
||||
// outline. Clicking (or Enter/Space) scrolls the diff to that block. The active
|
||||
// block (scroll-spy) gets an accent rule + emphasis. memo'd so scroll-spy
|
||||
// re-renders only repaint the rows whose active state actually changed.
|
||||
const ReviewGroupRow = memo(function ReviewGroupRow({
|
||||
group,
|
||||
active,
|
||||
onSelectGroup,
|
||||
onSelectFile,
|
||||
}: {
|
||||
group: ReviewSidebarGroup
|
||||
active: boolean
|
||||
onSelectGroup: (index: number) => void
|
||||
onSelectFile: (path: string) => void
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const title = useMemo(() => renderInlineCode(group.title), [group.title])
|
||||
const summary = useMemo(
|
||||
() => stripLocationLinks(group.summary),
|
||||
[group.summary]
|
||||
)
|
||||
const selectGroup = useCallback(
|
||||
() => onSelectGroup(group.index),
|
||||
[onSelectGroup, group.index]
|
||||
|
|
@ -240,86 +215,29 @@ const ReviewGroupRow = memo(function ReviewGroupRow({
|
|||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-current={active ? "true" : undefined}
|
||||
onClick={selectGroup}
|
||||
onKeyDown={onKeyDown}
|
||||
className="cursor-pointer px-3 py-3 transition-colors hover:bg-[var(--ui-sidebar-hover)]"
|
||||
className={cn(
|
||||
"flex cursor-pointer items-start gap-2 border-l-2 px-3 py-1.5 text-left transition-colors",
|
||||
active
|
||||
? "border-[var(--ui-accent)] bg-[var(--ui-sidebar-hover)]"
|
||||
: "border-transparent hover:bg-[var(--ui-sidebar-hover)]"
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full items-start gap-2 text-left">
|
||||
<span className="mt-0.5 flex size-5 shrink-0 items-center justify-center rounded bg-[var(--ui-panel-2)] text-[11px] font-medium text-[var(--ui-text-dim)]">
|
||||
{group.index}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-xs leading-5 font-medium text-[var(--ui-text)]">
|
||||
{title}
|
||||
</span>
|
||||
<span className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px] text-[var(--ui-text-dim)]">
|
||||
<span>
|
||||
{group.fileCount} file{group.fileCount === 1 ? "" : "s"}
|
||||
</span>
|
||||
{group.additions > 0 && (
|
||||
<span className="text-emerald-500">+{group.additions}</span>
|
||||
)}
|
||||
{group.deletions > 0 && (
|
||||
<span className="text-red-500">-{group.deletions}</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{group.files.length > 0 && (
|
||||
<div className="mt-2 space-y-0.5 pl-7">
|
||||
{group.files.map((path) => {
|
||||
const { dir, base } = splitPath(path)
|
||||
return (
|
||||
<button
|
||||
key={path}
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onSelectFile(path)
|
||||
}}
|
||||
title={path}
|
||||
className="flex w-full items-baseline gap-1.5 text-left text-[11px] hover:text-[var(--ui-accent)]"
|
||||
>
|
||||
<span className="shrink-0 font-medium text-[var(--ui-text-muted)]">
|
||||
{base}
|
||||
</span>
|
||||
{dir && (
|
||||
<span className="min-w-0 truncate text-[var(--ui-text-dim)]">
|
||||
{dir}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{group.summary && (
|
||||
<div className="mt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
setExpanded((value) => !value)
|
||||
}}
|
||||
className="inline-flex items-center gap-1 text-[11px] font-medium text-[var(--ui-accent)]"
|
||||
>
|
||||
<CaretRightIcon
|
||||
className={cn(
|
||||
"size-3 transition-transform",
|
||||
expanded && "rotate-90"
|
||||
)}
|
||||
/>
|
||||
Read explanation
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="mt-1.5">
|
||||
<Markdown content={summary} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<span className="mt-px shrink-0 text-[11px] font-medium text-[var(--ui-text-dim)] tabular-nums">
|
||||
{group.index}.
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 text-xs leading-5",
|
||||
active
|
||||
? "font-medium text-[var(--ui-text)]"
|
||||
: "text-[var(--ui-text-muted)]"
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -21,15 +21,15 @@ import {
|
|||
import { cn } from "@/lib/utils"
|
||||
|
||||
const POPUP_CLASS =
|
||||
"z-50 min-w-[12rem] origin-(--transform-origin) overflow-hidden rounded-md border border-[var(--ui-border)] bg-popover p-1 text-popover-foreground shadow-md outline-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95"
|
||||
"z-50 min-w-[12rem] origin-(--transform-origin) overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md outline-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95"
|
||||
|
||||
const ITEM_CLASS =
|
||||
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-xs outline-none select-none data-highlighted:bg-[var(--ui-sidebar-hover)] data-disabled:pointer-events-none data-disabled:opacity-50"
|
||||
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-xs outline-none select-none data-highlighted:bg-muted data-disabled:pointer-events-none data-disabled:opacity-50"
|
||||
|
||||
const LABEL_CLASS =
|
||||
"px-2 py-1 text-[10px] font-medium tracking-wide text-[var(--ui-text-dim)] uppercase"
|
||||
"px-2 py-1 text-[10px] font-medium tracking-wide text-muted-foreground uppercase"
|
||||
|
||||
const SEPARATOR_CLASS = "my-1 h-px bg-[var(--ui-border)]"
|
||||
const SEPARATOR_CLASS = "my-1 h-px bg-border"
|
||||
|
||||
function Indicator() {
|
||||
return <CheckIcon className="size-3.5 shrink-0" weight="bold" />
|
||||
|
|
@ -38,7 +38,7 @@ function Indicator() {
|
|||
function CountBadge({ count }: { count: number }) {
|
||||
if (count <= 0) return null
|
||||
return (
|
||||
<span className="ml-auto rounded bg-[var(--ui-panel-2)] px-1.5 py-0.5 text-[10px] text-[var(--ui-text-muted)]">
|
||||
<span className="ml-auto rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
||||
{count}
|
||||
</span>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -70,6 +70,18 @@ export const DIFF_UNSAFE_CSS = `
|
|||
[data-gutter-buffer="annotation"][data-selected-line] {
|
||||
--diffs-line-bg: var(--ui-panel) !important;
|
||||
}
|
||||
|
||||
/* Pin every code row to one exact, uniform height (kept in sync with
|
||||
DIFF_VIRTUAL_METRICS.lineHeight below). In scroll mode code never wraps, so a
|
||||
hard height won't clip content — it just makes the virtualizer's per-line
|
||||
estimate match measured layout, so scroll-to lands precisely instead of
|
||||
over/under-shooting as off-estimate rows reconcile while scrolling. */
|
||||
[data-line] {
|
||||
height: 18px !important;
|
||||
min-height: 18px !important;
|
||||
max-height: 18px !important;
|
||||
line-height: 18px !important;
|
||||
}
|
||||
`
|
||||
|
||||
export const diffOptions = {
|
||||
|
|
@ -104,6 +116,8 @@ export const DIFF_VIRTUALIZER_CONFIG = {
|
|||
|
||||
export const DIFF_VIRTUAL_METRICS = {
|
||||
hunkLineCount: 80,
|
||||
// Must match the hard `[data-line]` height pinned in DIFF_UNSAFE_CSS so the
|
||||
// virtualizer's pre-measurement estimate equals the measured row height.
|
||||
lineHeight: 18,
|
||||
diffHeaderHeight: 0,
|
||||
spacing: 8,
|
||||
|
|
|
|||
|
|
@ -823,7 +823,7 @@ export const api = {
|
|||
|
||||
export function loginUrl(redirectTo?: string): string {
|
||||
const target =
|
||||
redirectTo ?? (typeof window !== "undefined" ? window.location.origin : "")
|
||||
redirectTo ?? (typeof window !== "undefined" ? window.location.href : "")
|
||||
const qs = target ? `?redirect_to=${encodeURIComponent(target)}` : ""
|
||||
return `${API_BASE}/dashboard/api/auth/login${qs}`
|
||||
}
|
||||
|
|
|
|||
120
ui/src/lib/auth-redirect-core.ts
Normal file
120
ui/src/lib/auth-redirect-core.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
export const DEFAULT_AUTH_REDIRECT = "/agents"
|
||||
export const AUTH_REDIRECT_STORAGE_KEY = "open-swe-auth-redirect"
|
||||
|
||||
type LocationParts = {
|
||||
pathname: string
|
||||
search?: string
|
||||
hash?: string
|
||||
}
|
||||
|
||||
function browserOrigin(): string | null {
|
||||
return typeof window === "undefined" ? null : window.location.origin
|
||||
}
|
||||
|
||||
function storage(): Storage | null {
|
||||
if (typeof window === "undefined") return null
|
||||
try {
|
||||
return window.sessionStorage
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function isBlockedRedirectPath(path: string): boolean {
|
||||
return /^(?:\/login|\/dashboard\/api|\/_serverFn)(?:[/?#]|$)/.test(path)
|
||||
}
|
||||
|
||||
export function sanitizeAuthRedirect(
|
||||
candidate: unknown,
|
||||
fallback = DEFAULT_AUTH_REDIRECT
|
||||
): string {
|
||||
if (typeof candidate !== "string") return fallback
|
||||
const trimmed = candidate.trim()
|
||||
if (!trimmed) return fallback
|
||||
|
||||
const origin = browserOrigin()
|
||||
const isProtocolRelative = trimmed.startsWith("//")
|
||||
const hasScheme = /^[a-zA-Z][a-zA-Z\d+.-]*:/.test(trimmed)
|
||||
if (isProtocolRelative) return fallback
|
||||
if (hasScheme && !origin) return fallback
|
||||
|
||||
let parsed: URL
|
||||
try {
|
||||
parsed = new URL(trimmed, origin ?? "https://open-swe.invalid")
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
|
||||
if ((hasScheme || origin) && origin && parsed.origin !== origin) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
const path = `${parsed.pathname}${parsed.search}${parsed.hash}`
|
||||
if (!path.startsWith("/") || isBlockedRedirectPath(path)) return fallback
|
||||
return path
|
||||
}
|
||||
|
||||
export function authRedirectPathFromLocation(location: LocationParts): string {
|
||||
const hash = location.hash
|
||||
? location.hash.startsWith("#")
|
||||
? location.hash
|
||||
: `#${location.hash}`
|
||||
: ""
|
||||
return sanitizeAuthRedirect(
|
||||
`${location.pathname}${location.search ?? ""}${hash}`
|
||||
)
|
||||
}
|
||||
|
||||
export function currentAuthRedirectPath(): string {
|
||||
if (typeof window === "undefined") return DEFAULT_AUTH_REDIRECT
|
||||
return authRedirectPathFromLocation(window.location)
|
||||
}
|
||||
|
||||
export function rememberAuthRedirect(candidate: unknown): string {
|
||||
const path = sanitizeAuthRedirect(candidate)
|
||||
const s = storage()
|
||||
if (s) {
|
||||
try {
|
||||
s.setItem(AUTH_REDIRECT_STORAGE_KEY, path)
|
||||
} catch {}
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
export function getRememberedAuthRedirect(): string | null {
|
||||
const s = storage()
|
||||
if (!s) return null
|
||||
let raw: string | null = null
|
||||
try {
|
||||
raw = s.getItem(AUTH_REDIRECT_STORAGE_KEY)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (!raw) return null
|
||||
const path = sanitizeAuthRedirect(raw, "")
|
||||
if (path) return path
|
||||
clearRememberedAuthRedirect()
|
||||
return null
|
||||
}
|
||||
|
||||
export function clearRememberedAuthRedirect(): void {
|
||||
const s = storage()
|
||||
if (!s) return
|
||||
try {
|
||||
s.removeItem(AUTH_REDIRECT_STORAGE_KEY)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function consumeAuthRedirect(candidate?: unknown): string {
|
||||
const explicit = sanitizeAuthRedirect(candidate, "")
|
||||
const path = explicit || getRememberedAuthRedirect() || DEFAULT_AUTH_REDIRECT
|
||||
clearRememberedAuthRedirect()
|
||||
return path
|
||||
}
|
||||
|
||||
export function authRedirectUrl(candidate?: unknown): string {
|
||||
const path = sanitizeAuthRedirect(candidate)
|
||||
const origin = browserOrigin()
|
||||
if (!origin) return path
|
||||
return new URL(path, origin).toString()
|
||||
}
|
||||
76
ui/src/lib/auth-redirect.test.ts
Normal file
76
ui/src/lib/auth-redirect.test.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
/** @vitest-environment jsdom */
|
||||
|
||||
import { beforeEach, describe, expect, it } from "vitest"
|
||||
|
||||
import { loginUrl } from "./api"
|
||||
import {
|
||||
AUTH_REDIRECT_STORAGE_KEY,
|
||||
DEFAULT_AUTH_REDIRECT,
|
||||
authRedirectPathFromLocation,
|
||||
authRedirectUrl,
|
||||
consumeAuthRedirect,
|
||||
currentAuthRedirectPath,
|
||||
getRememberedAuthRedirect,
|
||||
rememberAuthRedirect,
|
||||
sanitizeAuthRedirect,
|
||||
} from "./auth-redirect-core"
|
||||
|
||||
beforeEach(() => {
|
||||
window.sessionStorage.clear()
|
||||
window.history.pushState({}, "", "/")
|
||||
})
|
||||
|
||||
describe("auth redirect helpers", () => {
|
||||
it("captures protected route targets as relative paths", () => {
|
||||
const path = authRedirectPathFromLocation({
|
||||
pathname: "/agents/thread-1/plan",
|
||||
search: "?from=slack",
|
||||
hash: "#review",
|
||||
})
|
||||
|
||||
expect(path).toBe("/agents/thread-1/plan?from=slack#review")
|
||||
expect(rememberAuthRedirect(path)).toBe(path)
|
||||
expect(window.sessionStorage.getItem(AUTH_REDIRECT_STORAGE_KEY)).toBe(path)
|
||||
})
|
||||
|
||||
it("resolves login targets to absolute same-origin URLs", () => {
|
||||
const path = rememberAuthRedirect("/agents/thread-1/plan?from=slack#review")
|
||||
|
||||
const target = `${window.location.origin}/agents/thread-1/plan?from=slack#review`
|
||||
|
||||
expect(authRedirectUrl(path)).toBe(target)
|
||||
expect(loginUrl(authRedirectUrl(path))).toContain(
|
||||
encodeURIComponent(target)
|
||||
)
|
||||
})
|
||||
|
||||
it("consumes remembered targets and clears session storage", () => {
|
||||
rememberAuthRedirect("/agents/thread-1/plan")
|
||||
|
||||
expect(consumeAuthRedirect()).toBe("/agents/thread-1/plan")
|
||||
expect(getRememberedAuthRedirect()).toBeNull()
|
||||
})
|
||||
|
||||
it("falls back for unsafe targets", () => {
|
||||
expect(
|
||||
sanitizeAuthRedirect("https://evil.example/agents/thread-1/plan")
|
||||
).toBe(DEFAULT_AUTH_REDIRECT)
|
||||
expect(sanitizeAuthRedirect("//evil.example/agents/thread-1/plan")).toBe(
|
||||
DEFAULT_AUTH_REDIRECT
|
||||
)
|
||||
expect(sanitizeAuthRedirect("/login?redirect=/agents/thread-1/plan")).toBe(
|
||||
DEFAULT_AUTH_REDIRECT
|
||||
)
|
||||
})
|
||||
|
||||
it("builds a plan sign-in target for the current plan URL", () => {
|
||||
window.history.pushState({}, "", "/agents/thread-1/plan?from=slack")
|
||||
|
||||
expect(currentAuthRedirectPath()).toBe("/agents/thread-1/plan?from=slack")
|
||||
expect(loginUrl(authRedirectUrl(currentAuthRedirectPath()))).toContain(
|
||||
encodeURIComponent(
|
||||
`${window.location.origin}/agents/thread-1/plan?from=slack`
|
||||
)
|
||||
)
|
||||
})
|
||||
})
|
||||
13
ui/src/lib/auth-redirect.tsx
Normal file
13
ui/src/lib/auth-redirect.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { Navigate } from "@tanstack/react-router"
|
||||
|
||||
import {
|
||||
currentAuthRedirectPath,
|
||||
rememberAuthRedirect,
|
||||
} from "./auth-redirect-core"
|
||||
|
||||
export * from "./auth-redirect-core"
|
||||
|
||||
export function RequireLogin() {
|
||||
const redirect = rememberAuthRedirect(currentAuthRedirectPath())
|
||||
return <Navigate to="/login" search={{ redirect }} />
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ import {
|
|||
} from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { api } from "@/lib/api"
|
||||
import { RequireLogin } from "@/lib/auth-redirect"
|
||||
import { useSession } from "@/lib/session"
|
||||
|
||||
export const Route = createFileRoute("/admin")({ component: AdminPage })
|
||||
|
|
@ -43,7 +44,7 @@ function AdminPage() {
|
|||
</main>
|
||||
)
|
||||
}
|
||||
if (!session.data) return <Navigate to="/login" />
|
||||
if (!session.data) return <RequireLogin />
|
||||
if (!session.data.is_admin) return <Navigate to="/my-settings" />
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { AppShell, SettingsSection } from "@/components/AppShell"
|
|||
import { Button } from "@/components/ui/button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { api } from "@/lib/api"
|
||||
import { RequireLogin } from "@/lib/auth-redirect"
|
||||
import { useSession } from "@/lib/session"
|
||||
|
||||
export const Route = createFileRoute("/admin_/evals")({ component: ReviewerEvalPage })
|
||||
|
|
@ -21,7 +22,7 @@ function ReviewerEvalPage() {
|
|||
</main>
|
||||
)
|
||||
}
|
||||
if (!session.data) return <Navigate to="/login" />
|
||||
if (!session.data) return <RequireLogin />
|
||||
if (!session.data.is_admin) return <Navigate to="/my-settings" />
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,14 +1,10 @@
|
|||
import {
|
||||
Navigate,
|
||||
Outlet,
|
||||
createFileRoute,
|
||||
useRouterState,
|
||||
} from "@tanstack/react-router"
|
||||
import { Outlet, createFileRoute, useRouterState } from "@tanstack/react-router"
|
||||
|
||||
import { AgentsShell } from "@/components/agents/AgentsSidebar"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import agentsCss from "@/styles/agents.css?url"
|
||||
import { AgentThreadStreamProvider } from "@/lib/agents/AgentThreadStreamProvider"
|
||||
import { RequireLogin } from "@/lib/auth-redirect"
|
||||
import { useSession } from "@/lib/session"
|
||||
|
||||
export const Route = createFileRoute("/agents")({
|
||||
|
|
@ -41,7 +37,7 @@ function AgentsLayout() {
|
|||
)
|
||||
}
|
||||
|
||||
if (!session.data) return <Navigate to="/login" />
|
||||
if (!session.data) return <RequireLogin />
|
||||
|
||||
return (
|
||||
<AgentsShell user={session.data} activeThreadId={activeThreadId}>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,10 @@ import { useEffect, useState } from "react"
|
|||
import { ArrowLeft } from "lucide-react"
|
||||
|
||||
import { PlanReview } from "@/components/agents/PlanReview"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { loginUrl } from "@/lib/api"
|
||||
import { authRedirectUrl, currentAuthRedirectPath } from "@/lib/auth-redirect"
|
||||
import { PlanApiError, getPlan } from "@/lib/plan"
|
||||
|
||||
export const Route = createFileRoute("/agents/$threadId_/plan")({
|
||||
|
|
@ -13,7 +16,7 @@ export const Route = createFileRoute("/agents/$threadId_/plan")({
|
|||
|
||||
function Centered({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-1 items-center justify-center p-6">
|
||||
<div className="flex min-w-0 flex-1 items-center justify-center px-4 py-6 max-md:pt-14 md:p-6">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
|
@ -32,6 +35,18 @@ function BackLink({ threadId }: { threadId: string }) {
|
|||
)
|
||||
}
|
||||
|
||||
export function planSignInHref(): string {
|
||||
return loginUrl(authRedirectUrl(currentAuthRedirectPath()))
|
||||
}
|
||||
|
||||
export function PlanSignInButton() {
|
||||
return (
|
||||
<a href={planSignInHref()} className={buttonVariants({ size: "sm" })}>
|
||||
Sign in to view this plan
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
function PlanPage() {
|
||||
const { threadId } = Route.useParams()
|
||||
|
||||
|
|
@ -70,6 +85,7 @@ function PlanPage() {
|
|||
? "Please sign in to view this plan."
|
||||
: "This plan could not be found."}
|
||||
</p>
|
||||
{status === 401 ? <PlanSignInButton /> : null}
|
||||
<BackLink threadId={threadId} />
|
||||
</div>
|
||||
</Centered>
|
||||
|
|
@ -100,7 +116,7 @@ function PlanPage() {
|
|||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<div className="border-b border-[var(--ui-border)] px-6 pt-3">
|
||||
<div className="border-b border-[var(--ui-border)] px-4 pt-14 md:px-6 md:pt-3">
|
||||
<BackLink threadId={threadId} />
|
||||
</div>
|
||||
<PlanReview plan={plan} />
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Link, Navigate, createFileRoute } from "@tanstack/react-router"
|
||||
import { Link, createFileRoute } from "@tanstack/react-router"
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { ArrowLeftIcon, GitPullRequestIcon } from "@phosphor-icons/react"
|
||||
|
|
@ -9,6 +9,7 @@ import { ReviewMainBody } from "@/components/agents/ReviewMainBody"
|
|||
import { useSidebarControls } from "@/components/sidebar-layout"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { api } from "@/lib/api"
|
||||
import { RequireLogin } from "@/lib/auth-redirect"
|
||||
import { useSession } from "@/lib/session"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
|
|
@ -70,7 +71,7 @@ function ReviewDetailPage() {
|
|||
</main>
|
||||
)
|
||||
}
|
||||
if (!session.data) return <Navigate to="/login" />
|
||||
if (!session.data) return <RequireLogin />
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-1 flex-col overflow-hidden bg-background text-foreground">
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { Navigate, createFileRoute } from "@tanstack/react-router";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
import { AgentInstructionsPanel } from "@/components/AgentInstructionsPanel";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { RequireLogin } from "@/lib/auth-redirect";
|
||||
import { useSession } from "@/lib/session";
|
||||
|
||||
export const Route = createFileRoute("/agents_/instructions")({
|
||||
|
|
@ -19,7 +20,7 @@ function AgentInstructionsPage() {
|
|||
</main>
|
||||
);
|
||||
}
|
||||
if (!session.data) return <Navigate to="/login" />;
|
||||
if (!session.data) return <RequireLogin />;
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Navigate, createFileRoute } from "@tanstack/react-router"
|
|||
|
||||
import { AppShell } from "@/components/AppShell"
|
||||
import { RepoSnapshotsPanel } from "@/components/RepoSnapshotsPanel"
|
||||
import { RequireLogin } from "@/lib/auth-redirect"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { useSession } from "@/lib/session"
|
||||
|
||||
|
|
@ -19,7 +20,7 @@ function RepoSnapshotsPage() {
|
|||
</main>
|
||||
)
|
||||
}
|
||||
if (!session.data) return <Navigate to="/login" />
|
||||
if (!session.data) return <RequireLogin />
|
||||
if (!session.data.is_admin) return <Navigate to="/my-settings" />
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Link, Navigate, createFileRoute } from "@tanstack/react-router"
|
||||
import { Link, createFileRoute } from "@tanstack/react-router"
|
||||
import { CaretRightIcon } from "@phosphor-icons/react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
|
||||
|
|
@ -23,6 +23,7 @@ import {
|
|||
useRepos,
|
||||
useSaveProfile,
|
||||
} from "@/lib/profile"
|
||||
import { RequireLogin } from "@/lib/auth-redirect"
|
||||
import { useSession } from "@/lib/session"
|
||||
|
||||
export const Route = createFileRoute("/cloud-agents")({
|
||||
|
|
@ -112,7 +113,7 @@ function CloudAgentsPage() {
|
|||
</main>
|
||||
)
|
||||
}
|
||||
if (!session.data) return <Navigate to="/login" />
|
||||
if (!session.data) return <RequireLogin />
|
||||
|
||||
const fallbackModel = defaultAgentModel
|
||||
const fallbackEffort = defaultAgentEffort
|
||||
|
|
|
|||
|
|
@ -1,16 +1,44 @@
|
|||
import { Navigate, createFileRoute } from "@tanstack/react-router";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useEffect, useMemo } from "react";
|
||||
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { loginUrl } from "@/lib/api";
|
||||
import {
|
||||
DEFAULT_AUTH_REDIRECT,
|
||||
authRedirectUrl,
|
||||
consumeAuthRedirect,
|
||||
getRememberedAuthRedirect,
|
||||
rememberAuthRedirect,
|
||||
} from "@/lib/auth-redirect";
|
||||
import { useSession } from "@/lib/session";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const Route = createFileRoute("/login")({ component: Login });
|
||||
type LoginSearch = { redirect?: string };
|
||||
|
||||
export const Route = createFileRoute("/login")({
|
||||
validateSearch: (search: Record<string, unknown>): LoginSearch => ({
|
||||
redirect: typeof search.redirect === "string" ? search.redirect : undefined,
|
||||
}),
|
||||
component: Login,
|
||||
});
|
||||
|
||||
function Login() {
|
||||
const session = useSession();
|
||||
const search = Route.useSearch();
|
||||
const redirectParam = search.redirect;
|
||||
const intendedPath = useMemo(
|
||||
() =>
|
||||
redirectParam
|
||||
? rememberAuthRedirect(redirectParam)
|
||||
: getRememberedAuthRedirect() ?? DEFAULT_AUTH_REDIRECT,
|
||||
[redirectParam]
|
||||
);
|
||||
const authenticatedRedirect = useMemo(
|
||||
() => (session.data ? consumeAuthRedirect(redirectParam) : null),
|
||||
[redirectParam, session.data]
|
||||
);
|
||||
|
||||
if (session.isLoading) {
|
||||
return (
|
||||
|
|
@ -20,8 +48,8 @@ function Login() {
|
|||
);
|
||||
}
|
||||
|
||||
if (session.data) {
|
||||
return <Navigate to="/my-settings" />;
|
||||
if (authenticatedRedirect) {
|
||||
return <ClientRedirect path={authenticatedRedirect} />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -35,7 +63,10 @@ function Login() {
|
|||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<a href={loginUrl()} className={cn(buttonVariants({ size: "lg" }), "w-full")}>
|
||||
<a
|
||||
href={loginUrl(authRedirectUrl(intendedPath))}
|
||||
className={cn(buttonVariants({ size: "lg" }), "w-full")}
|
||||
>
|
||||
Continue with GitHub
|
||||
</a>
|
||||
</CardContent>
|
||||
|
|
@ -43,3 +74,15 @@ function Login() {
|
|||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function ClientRedirect({ path }: { path: string }) {
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") window.location.replace(path);
|
||||
}, [path]);
|
||||
|
||||
return (
|
||||
<main className="flex min-h-svh items-center justify-center p-6">
|
||||
<Skeleton className="h-40 w-80" />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Navigate, createFileRoute, useNavigate } from "@tanstack/react-router"
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router"
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { useState } from "react"
|
||||
import { IoLogoSlack } from "react-icons/io5"
|
||||
|
|
@ -24,6 +24,7 @@ import {
|
|||
useProfile,
|
||||
useSaveProfile,
|
||||
} from "@/lib/profile"
|
||||
import { RequireLogin } from "@/lib/auth-redirect"
|
||||
import { useSession } from "@/lib/session"
|
||||
import {
|
||||
notificationsEnabled,
|
||||
|
|
@ -366,7 +367,7 @@ function MySettingsPage() {
|
|||
</main>
|
||||
)
|
||||
}
|
||||
if (!session.data) return <Navigate to="/login" />
|
||||
if (!session.data) return <RequireLogin />
|
||||
|
||||
const handleLogout = async () => {
|
||||
await api.logout()
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Link, Navigate, createFileRoute } from "@tanstack/react-router";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { CaretRightIcon } from "@phosphor-icons/react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
|
@ -11,6 +11,7 @@ import { Skeleton } from "@/components/ui/skeleton";
|
|||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
import { RequireLogin } from "@/lib/auth-redirect";
|
||||
import { useSession } from "@/lib/session";
|
||||
|
||||
export const Route = createFileRoute("/review")({ component: ReviewPage });
|
||||
|
|
@ -66,7 +67,7 @@ function ReviewPage() {
|
|||
</main>
|
||||
);
|
||||
}
|
||||
if (!session.data) return <Navigate to="/login" />;
|
||||
if (!session.data) return <RequireLogin />;
|
||||
|
||||
const current: TeamSettings = local;
|
||||
const canEdit = session.data.is_admin;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Navigate, createFileRoute } from "@tanstack/react-router";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
|
|
@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
|
|||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
import { RequireLogin } from "@/lib/auth-redirect";
|
||||
import { useSession } from "@/lib/session";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
|
@ -78,7 +79,7 @@ function RepositoriesOwnerPage() {
|
|||
</main>
|
||||
);
|
||||
}
|
||||
if (!session.data) return <Navigate to="/login" />;
|
||||
if (!session.data) return <RequireLogin />;
|
||||
|
||||
const canEdit = session.data.is_admin;
|
||||
const enabledCount = ownerRepos.filter((r) => enabledSet.has(r.full_name)).length;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { Navigate, createFileRoute } from "@tanstack/react-router";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { ReviewStylesPanel } from "@/components/ReviewStylesPanel";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { RequireLogin } from "@/lib/auth-redirect";
|
||||
import { useSession } from "@/lib/session";
|
||||
|
||||
export const Route = createFileRoute("/review_/styles")({ component: ReviewStylesPage });
|
||||
|
|
@ -17,7 +18,7 @@ function ReviewStylesPage() {
|
|||
</main>
|
||||
);
|
||||
}
|
||||
if (!session.data) return <Navigate to="/login" />;
|
||||
if (!session.data) return <RequireLogin />;
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Navigate, createFileRoute } from "@tanstack/react-router"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
|
||||
import type {
|
||||
|
|
@ -17,6 +17,7 @@ import {
|
|||
} from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { api } from "@/lib/api"
|
||||
import { RequireLogin } from "@/lib/auth-redirect"
|
||||
import { useSession } from "@/lib/session"
|
||||
|
||||
export const Route = createFileRoute("/usage")({
|
||||
|
|
@ -57,7 +58,7 @@ function UsagePage() {
|
|||
</main>
|
||||
)
|
||||
}
|
||||
if (!session.data) return <Navigate to="/login" />
|
||||
if (!session.data) return <RequireLogin />
|
||||
|
||||
return (
|
||||
<AppShell user={session.data} title="Usage" className="max-w-5xl">
|
||||
|
|
|
|||
107
uv.lock
generated
107
uv.lock
generated
|
|
@ -544,61 +544,58 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "48.0.1"
|
||||
version = "49.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/12/45/870e7f4bef50e5f53b9f51d4428aee5290eedf58ba443f16b1ebb7ab8e66/cryptography-48.0.1.tar.gz", hash = "sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a", size = 832989, upload-time = "2026-06-09T22:32:31.8Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/bc/ee4137cbbe105652c0ee4252792b78fc8e7afa4b8e61d9d5dc05a7f45731/cryptography-48.0.1-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:3e4a1a3232eef2e6c732827d5722db29a0cc8b27af2a4d865b094cf954be9ca1", size = 8008324, upload-time = "2026-06-09T22:31:00.702Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/85/6379d42181bfc713094f081360fc5784d6c816b599d45e7f082502d173ce/cryptography-48.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:32143b24adb918f078134e1e230f1eb8cc04886b92c28b5f0041aaf3e5699225", size = 4696243, upload-time = "2026-06-09T22:32:33.446Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/87/c85d147b53323c7eb4d850920c8901377323c2a0ff8d79c262d4fee89aa2/cryptography-48.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0d27a5696721ef7a672b8c810f6aded391058e0b9486e63e6d93baf765da691", size = 4713235, upload-time = "2026-06-09T22:31:40.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/58/67cbf8cf1ee7c54b439ca07bbecf8362c07afc11a3724fea70f745784add/cryptography-48.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb86ce1af36fe65041b6db9a8bb064ee621a7e5fded0f80d475ec243477cd242", size = 4702323, upload-time = "2026-06-09T22:31:42.191Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/c6/24266ac10c47f6cd2a865f4446062b466da1d1f10b27189eac00e61bf0c9/cryptography-48.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b024e784ad6c077ee0147b35ea9cbfc1e34e1fd4c1dcca214c2794d73a12df08", size = 5300085, upload-time = "2026-06-09T22:31:58.703Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/bb/cc4b78784f97efc8c5874c2a9743708d172be6663024b34a0467885ae0c8/cryptography-48.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3752f2dbc8f07a30aad2932c986cea495b03bb554887828225da104f732852b6", size = 4746137, upload-time = "2026-06-09T22:31:31.01Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/52/0c44de3f5267f8fbe8e835138017522a333436166e406f0db9b9e6e3033f/cryptography-48.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:bd81490cd5801d755cf97bb68ac191f14b708470b1c7cf4580f669b9c9264cd8", size = 4333867, upload-time = "2026-06-09T22:32:28.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/2e/772d7adbfa931537bc401640b7cac9976bff689bda187833e5d63b428e49/cryptography-48.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:66fd0771e7b9c6dcd44cf1120690d2338d16d72795cf40cae2786a39eba65429", size = 4701805, upload-time = "2026-06-09T22:31:38.284Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/a3/b06844f303873493c963caf581c04df31c7035e0c1b0f02c4814d319ec80/cryptography-48.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:3fd2ca57062b241c856670b073487d2e86c4637937ca5601e48f97bf8e11fc8f", size = 5258461, upload-time = "2026-06-09T22:31:04.187Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/13/8b765e2e12b07c74941caadb9d1c8fdc006c4dfbf2b8f2d610519758954d/cryptography-48.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:0ee6ea481db1ab889cba043ec1eda17bb9c1ea79db6722f779c3667f9f70322f", size = 4745488, upload-time = "2026-06-09T22:32:30.07Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/aa/48972bce55049b32a94f4907eda4d75fa385aad8a39506cc2fc72196ecf0/cryptography-48.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f2ceef93cb096aa3c4cc4b5c94ca6131f9196d28c64d6111533402a9b2054d41", size = 4830256, upload-time = "2026-06-09T22:31:43.868Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/a2/e5079a032fb85cf6005046ca92bbd78b0c82dad2b5751ab8c311659da06f/cryptography-48.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bd3f92d76217892b15df84ca256c2c113d386fdda7a7d8691aeeced976507c6", size = 4979117, upload-time = "2026-06-09T22:31:05.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/a0/8f50cae9c74e718ed769d63ed5c74bd0ea830c9550a74629cebd1b9c7bc7/cryptography-48.0.1-cp311-abi3-win32.whl", hash = "sha256:b9a32b876490d66c8bcc9963ef220199569748434ab01a9d6aaeabf88e7f5158", size = 3304154, upload-time = "2026-06-09T22:32:16.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/69/0572c77dbace6fef72f33755bd52ea399c71367250d366237f8691826b9e/cryptography-48.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:39489bfca54c7a1f6b297efcd8bc608ab92d16c4ca631b0cad4da46724588b24", size = 3817138, upload-time = "2026-06-09T22:32:00.388Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/06/3e768b4c3bc78201583fa35a0e18f640dd782ff41afba88f8545481a8874/cryptography-48.0.1-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:f817adc181390bd54f2f700107a7419040fb7c1bdf2fc26f36551a06a68c3345", size = 7989830, upload-time = "2026-06-09T22:31:07.8Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/13/6476736484b94041110c8340a3eb63962fea4975baea8cb4a512adb44d4d/cryptography-48.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d5d30989c6917b478b5817902e85fddaea2261efa8648383d965381ccb9e1ac4", size = 4689201, upload-time = "2026-06-09T22:31:09.745Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/62/65a87f34d2a431546e2509b85d55e8c90df86d668f6731da64d538512ac2/cryptography-48.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:df637c05205ea7c1d7fbcbe54bbfea648a52951155f997af13d895d0ecc96991", size = 4702822, upload-time = "2026-06-09T22:32:24.409Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/59/810b5204b0a9b10f4b6bc06bd551a8b609803cd931806bc3b71884b225e5/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:869c3b8a53bfe27147832df48b32adadf558249d50e76cb3769d40e986b13265", size = 4694875, upload-time = "2026-06-09T22:32:08.737Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/dc/d8ca05ffea724eec6d232ea6f18e74c269eb6bdfdcc9bfba689790d1325f/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:e361afba8918070d376df76f408a4f67fec0ee9cff81a99e48fe9a233ef59e17", size = 5290385, upload-time = "2026-06-09T22:31:15.212Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/8c/3be6cb4da181f5bb6c19cf560c2359d60644a6b5fc5b57854e528f47b296/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:d069066deead00ac7f090be101be875a06855908f7ec004c27b8fefb4acfb411", size = 4737082, upload-time = "2026-06-09T22:32:22.66Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/f6/d5f60a5a1434dbfd949e227fd0065d194c7e6b6ac526b17f5c06152b8231/cryptography-48.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:09f73a725d582cef64b91281a322cd798d14a33b2b6f2b7ad9531dc336d84c02", size = 4325328, upload-time = "2026-06-09T22:32:10.777Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/b7/ba75dd947a14b6ad907b01ae8f6b5b348cdd1b48142f0063dee9e20c1d9d/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:15254441469dd6bf027039453288e2072124f8b6603563f5d759e1c9b69273fa", size = 4694530, upload-time = "2026-06-09T22:31:53.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/29/50d6b9e8aff12d8b67afaeb3569335e32dc83a5723e3bbded24fdac9f809/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:8ace4507d1e6533c125f4fac754f8bb8b6a74c08e92179dabd7e16571a3efbf3", size = 5245046, upload-time = "2026-06-09T22:31:25.774Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/04/618f4115cfc0add0838c82507aa18a346089428da8653ad38b3ff36f5cb3/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b4e391975f038e66432328639620a4aff2d307513b004f1ca06d6225bced815c", size = 4736660, upload-time = "2026-06-09T22:32:12.676Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/9c/06e062462a0de28a3b3911322eded4c16deb9f441b1b7575d3dc59488ab5/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42fcd8e26fe555d9b3577a135f5091fefa0aa4e99129c23fb56787a1bd4ada72", size = 4822229, upload-time = "2026-06-09T22:31:17.062Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/be/0561971eaaee4b8a0e7d5113c536921063ab91aaf23278ac374eaf881e11/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c1400da5e32a43253392277eac7490a60e497d810a63dd5608d71bbd7af507c9", size = 4966364, upload-time = "2026-06-09T22:31:32.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/27/728c77876f12b000820b69ae490f3c4083775e79e07827e9e60be07ad209/cryptography-48.0.1-cp314-cp314t-win32.whl", hash = "sha256:0df56b056bc17c1b7d6821dfa65216e62bd232d8ab05eb3db44e71d235651471", size = 3278498, upload-time = "2026-06-09T22:31:29.154Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/e3/79a612c6d7b1e6ee0edd43633d53035bec2cfb78c82b76f7864f39e36f34/cryptography-48.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:9de21387aa95e2a895823d0745b430bed4f33503ba9ab5e0b5311f33e37d66d2", size = 3798790, upload-time = "2026-06-09T22:31:56.697Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/6c/00fa2a95997164c8b2072ce327c23d4ab20809ccc323ea5fab91e53a4bba/cryptography-48.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:4fdc69f8e4316bcf0c8c8ec1f26f285d12e8142d88d96c876a59a03be3f6ae67", size = 7987408, upload-time = "2026-06-09T22:32:20.777Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/d9/45f309a7e4e5f3f8f121d6d3be9e94024a7726ec598d6e08ae04edb2f04d/cryptography-48.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48fe40804d4caa2288f24e70ca8c64c42dd826da0ad7e4f1b41b2128d679e6c8", size = 4690196, upload-time = "2026-06-09T22:31:54.74Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/9f/a1bc8bcc798811b8527eb374bbccf30a3f3e806829d967118222bf1125eb/cryptography-48.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:86be3b1b0b6bf09482fb50a979c508d2950ed95f5621ec77f4e385962006b83a", size = 4696782, upload-time = "2026-06-09T22:31:45.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/c2/81a4fb4e4373c500bb526bc337ac5719dd31dd15b970b84a238168c6aa08/cryptography-48.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4ab0a343c807bbcd90c971cd1ecf072937cd01847a9e002bef88fb47ac6be577", size = 4696618, upload-time = "2026-06-09T22:31:11.564Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/0b/aa68b221dde92d09cb29a024ede17550ee21e77a404e59fc093c82bb51e1/cryptography-48.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9621de99d2da096006b629979efd8ae7eb2d8b822488d0c89ee4000c306c59b1", size = 5289970, upload-time = "2026-06-09T22:31:20.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/13/fba657f958d2af66ea959a4ba01212632089249d34af1ae48054136344d7/cryptography-48.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:88c852a0ae366e262e5a1744b685e6a433dc8788dd2a277e418bf4904203609d", size = 4731873, upload-time = "2026-06-09T22:31:22.253Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/4c/9a964756d24a26b3e34dfcb16f961b89838786e6700b635b0d1e3adff4b6/cryptography-48.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:43c5835e2cb98c8733d86f57d6fc879b613f5c3478607281c3e36daffc6dd8a6", size = 4330804, upload-time = "2026-06-09T22:31:36.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/0f/a10f3a6eb12950a10e3a874070283aa2dd5875b2bfd15fad8a3e17b3f13e/cryptography-48.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:fe0180af5bf9236518a087e35bf2d9a347d5f5f51e63c579d683ddff424e3d46", size = 4696217, upload-time = "2026-06-09T22:31:13.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/6f/5cd12f951165ea73ef85266775d97e4c763b2474ccfd816dd69d3a18d6f8/cryptography-48.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:b7a2d1a937a738a881737cec135a38bb61470589b17515b9f73f571d0ae10401", size = 5245252, upload-time = "2026-06-09T22:32:02.193Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/ab/8aaa12e4516ec4464033ab79b6f3b592bd5a92102467c4ace8a0d970203f/cryptography-48.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b74ca3b8e5ecdd833bf6a002ca41b4793bb27fb8f1c06ffaf2643c9e9140e31b", size = 4731388, upload-time = "2026-06-09T22:32:04.019Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/24/50027ea4dca85ec1f40688f3c24fb32ccacd520583c9592c3cc95628e6fb/cryptography-48.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2c37f2461406063b417837f5f3daab668652acd82423efcd7f0a9f04be972de1", size = 4824186, upload-time = "2026-06-09T22:32:18.707Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/41/04cb5eb17085ade6f50cc611fb657df6a0f5885350de8764ece89c050197/cryptography-48.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:86fe77abb1bd87afb251d4d02ada7ecf53a32cee9b67d976abb2e45a13297475", size = 4964539, upload-time = "2026-06-09T22:31:18.793Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/bf/ed70785c496e89d7e73b7cda2d21f2447fd6d4e821714b8d04ff217fed92/cryptography-48.0.1-cp39-abi3-win32.whl", hash = "sha256:6b2c0c3e6ccf3ade7750f836ef3ee36eea250cc467d45c256895573ac08cc6f1", size = 3282307, upload-time = "2026-06-09T22:30:53.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/ff/371ea7d252656ee1eb6d83eeeef3d1d0c6baf1d6497687d081ea03814670/cryptography-48.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:9a49ca6c81417f6a5edb50375a60cccdd70fa0a91a5211829dbea74eba94d2ac", size = 3793408, upload-time = "2026-06-09T22:32:15.191Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/d3/eb4e394e587341fdad09a09101fa76478ead3a78b0ad63e55c22f0d75c02/cryptography-48.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:08a597acce1ff37f347400087776599e2348a3a8bc53b44120e463cd274efe4a", size = 3951747, upload-time = "2026-06-09T22:31:23.871Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/4a/3f43451b4f858bfceaaaffc649e6e787e8d4fb332a1d443af39ab02cc8f1/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:735824ec41b7f74a7c45fb1591349333e4c696cb6c044e5f46356e560143e4cd", size = 4641226, upload-time = "2026-06-09T22:31:02.532Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/4e/855584c2c23b09e4ce2d3b9c30e983e679cd60b068c513c6bbdb91e11782/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:92a46e1d638daa264ba2971c0b0489c9409787943efae4d60ffda3d091ef832c", size = 4668958, upload-time = "2026-06-09T22:32:06.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/3b/d35750e41d803d1e516fd6d6011f065424924da7af1748cef4cc9cb3ede1/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:7e234ac052af99f2700826a5c29ea99d9c1b1f80341cde62d11c8154dc8e0bd9", size = 4640793, upload-time = "2026-06-09T22:32:26.331Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/aa/cdb7181fe865285e87e96825aaab239400f1de0c3bfba9bd9769b79f1a92/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:33842cf0888951cef5bc7ac724ab844a42044c1727b967b7f8997289a0464f92", size = 4668505, upload-time = "2026-06-09T22:31:27.534Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/8c/ce3823c06c2804f194f9e64f0d67fa3f4094a39f2bb1a990cd03603af8fc/cryptography-48.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6184ca7b174f28d7c703f1290d4b297217c45355f77a98f67e9b7f14549ac54a", size = 3742204, upload-time = "2026-06-09T22:31:34.773Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", size = 3993685, upload-time = "2026-06-12T20:02:14.883Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239, upload-time = "2026-06-12T20:02:28.793Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584, upload-time = "2026-06-12T20:01:27.495Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885, upload-time = "2026-06-12T20:01:55.49Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449, upload-time = "2026-06-12T20:02:05.469Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1750,7 +1747,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "langsmith"
|
||||
version = "0.9.6"
|
||||
version = "0.9.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
|
|
@ -1768,9 +1765,9 @@ dependencies = [
|
|||
{ name = "xxhash" },
|
||||
{ name = "zstandard" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/85/dc/ca2ab3b74f3a5a2089aee1483e86b6aa7ae15fba96f73866a16e31356319/langsmith-0.9.6.tar.gz", hash = "sha256:1df590a40352b2f40a36229d90e2ef6af276a0bc9f1e44a87b829f879eed2130", size = 4714803, upload-time = "2026-07-02T11:14:30.636Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d4/8b/a56b97a3b8a94f850fe2ec42351d4c508cc6bd7ca96644906c311e32ec5b/langsmith-0.9.7.tar.gz", hash = "sha256:40f8a66a466a7abd991a9df1b50de0a9d30c48ee0d3da46ce00516b68e41c9d7", size = 4711142, upload-time = "2026-07-02T20:11:09.619Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/8f/6e8f1f3e12be9848993c297c879f63f225361eb594f8343ff42c2b26c6fd/langsmith-0.9.6-py3-none-any.whl", hash = "sha256:c5e5f2425dcb8fe363b9e1fa87a9cb9cf5631389bf7e168aa4f325f580ca284c", size = 660131, upload-time = "2026-07-02T11:14:28.552Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/95/0abb8647123c9ebbb31162bcdba3183f5f3d3bb2f752ef3d777ab715e88c/langsmith-0.9.7-py3-none-any.whl", hash = "sha256:a5ff9179b77eb0c3a0129294d30924eab13e51e2720f374372dbbc014c892911", size = 671052, upload-time = "2026-07-02T20:11:07.702Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
|
|
@ -2220,7 +2217,7 @@ dev = [
|
|||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "cryptography", specifier = ">=48.0.1" },
|
||||
{ name = "cryptography", specifier = ">=49.0.0" },
|
||||
{ name = "deepagents", specifier = "==0.6.12" },
|
||||
{ name = "exa-py", specifier = ">=2.16.0" },
|
||||
{ name = "fastapi", specifier = ">=0.139.0" },
|
||||
|
|
@ -2239,7 +2236,7 @@ requires-dist = [
|
|||
{ name = "langgraph", specifier = ">=1.1.10" },
|
||||
{ name = "langgraph-cli", extras = ["inmem"], specifier = ">=0.4.30" },
|
||||
{ name = "langgraph-sdk", specifier = ">=0.4.2" },
|
||||
{ name = "langsmith", specifier = "==0.9.6" },
|
||||
{ name = "langsmith", specifier = "==0.9.7" },
|
||||
{ name = "markdownify", specifier = ">=1.2.3" },
|
||||
{ name = "pygments", marker = "extra == 'dev'", specifier = ">=2.20.0" },
|
||||
{ name = "pyjwt", specifier = ">=2.13.0" },
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue