Compare commits
15 Commits
docs/758-l
...
feat/869-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5a2bcc516 | ||
|
|
ac44a1aea7 | ||
|
|
c64a04e7dd | ||
|
|
763cecc381 | ||
| b79336a8c1 | |||
| 4e5af23214 | |||
|
|
880c28b191 | ||
|
|
7bc2dfb6c8 | ||
| b0d78d8632 | |||
| 344d86a635 | |||
| acd7d380f6 | |||
| 3b70c66c07 | |||
| 11d2818453 | |||
| aa999daf1b | |||
| 77c9a82614 |
@@ -64,7 +64,7 @@ Active workstream is **W1 — Federation v1**. Workers should:
|
||||
| FCM-M3-002 | in-progress | Add isolated systemd/tmux lifecycle, drift, socket, unmanaged-session, crash, and rollback acceptance coverage | #758 | sonnet | mosaicstack/stack | `test/758-reconciler-lifecycle-gates` | FCM-M3-001 | 25K | Canonical v2 named-socket + legacy-v1 default-server boundaries; fake adapters/temp fixtures only |
|
||||
| FCM-M4-001 | done | Implement field-complete v1-to-v2 inventory/preview/migrator with alias, lifecycle, env-quarantine, and remote/connector disposition evidence | #758 | codex | mosaicstack/stack | `feat/758-v1-v2-migrator` | FCM-M1-003, FCM-M3-001 | 35K | PR #788; final head `d63bb0206a1d312ab8352ec1d3ca3631146b0baa`; tree `4da210da9a71b035130d4160a4a2e691bdfde2da`; squash `9745bc3f29c26b021a478b7ad03cfb494f6c9de3`; descendant-main pipeline 1855 terminal success |
|
||||
| FCM-M4-002 | not-started | Add reversible canary migration, rollback, stale-projection/orphan classification, and current-host 9-managed/3-unmanaged fixture coverage | #758 | sonnet | mosaicstack/stack | `test/758-migration-rollback-gates` | FCM-M4-001, FCM-M3-002 | 25K | HOLD: never starts a previously stopped agent or kills an unproven unmanaged session; not authorized by FCM-M5-001 |
|
||||
| FCM-M5-001 | done | Deliver the accepted fleet documentation IA, how-to/operations/migration references, and link/example validation | #758 | haiku | mosaicstack/stack | `docs/758-fleet-config-operator-docs` | FCM-M1-003, FCM-M2-002, FCM-M3-001, FCM-M4-001 | 24K | #789 squash `627cf2bb`; exact-head RoR (head `a39bafb8`) and PR/main terminal-green CI 1907; accepted fleet documentation IA, how-to/operations/migration references, and link/example validation delivered |
|
||||
| FCM-M5-001 | done | Deliver the accepted fleet documentation IA, how-to/operations/migration references, and link/example validation | #758 | haiku | mosaicstack/stack | `docs/758-fleet-config-operator-docs` | FCM-M1-003, FCM-M2-002, FCM-M3-001, FCM-M4-001 | 24K | #789 content squash 627cf2bb; de-flake repair PR#851/#849 squash 77c9a826; completion proof wp1937 @aa999daf push/ci step 49632 recovery_runtime_unittest.py 3/3 OK (closes wp1932 step 49576 Errno111) |
|
||||
| FCM-M5-002 | not-started | Package/update asset-drift checks, rolling local canary, independent validation certificate, and release evidence | #758 | sonnet | mosaicstack/stack | `feat/758-fleet-config-release-gate` | FCM-M3-002, FCM-M4-002, FCM-M5-001 | 30K | HOLD: final #758 gate; quality, independent code/security review, validator certificate, merge-gate approval, and green CI remain out of M5-001 |
|
||||
|
||||
## Thin-core prompt diet (#528) — feat/contract-thin-core
|
||||
|
||||
58
docs/scratchpads/812-pr-review-comment.md
Normal file
58
docs/scratchpads/812-pr-review-comment.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# Issue #812 — durable Gitea PR review comments
|
||||
|
||||
- **Lane:** ms-812
|
||||
- **Branch:** `fix/812-pr-review-comment`
|
||||
- **Issue:** mosaicstack/stack#812
|
||||
- **Budget:** 15K working estimate; single focused shell-wrapper/test/docs change.
|
||||
|
||||
## Objective
|
||||
|
||||
Make the Gitea `comment` action in `packages/mosaic/framework/tools/git/pr-review.sh` use the supported Gitea comments REST API and report success only after provider read-back verifies the created comment against the intended repository, PR, and exact body.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Add and commit a failing shell regression harness before production changes.
|
||||
2. Verify RED against the nonexistent `tea pr comment` fallback false-positive.
|
||||
3. Implement the minimal supported write plus ID-based provider read-back.
|
||||
4. Document that wrapper write output is not durable provenance until read-back succeeds.
|
||||
5. Run focused regression tests, touched-package tests, and repository quality gates.
|
||||
6. Remediate review findings, queue-guard, and push for coordinator-owned independent review. Do not open or merge a PR.
|
||||
|
||||
## Progress checkpoints
|
||||
|
||||
- [x] RED regression committed and reported to mosaic-100 (rebased commit `770e3f57`)
|
||||
- [x] Initial minimal fix implemented (rebased commit `ea7f8c57`)
|
||||
- [x] Rebased cleanly onto main `627cf2bb387f7c84a532d88819903a7679ce0d72`
|
||||
- [x] Codex blocker remediated by replacing unsupported `tea api` with authenticated REST write/read-back
|
||||
- [x] Focused, package, and repository gates green
|
||||
- [ ] Coordinator-owned independent review pending after push
|
||||
- [x] No PR opened; no self-review or self-merge
|
||||
|
||||
## Tests run
|
||||
|
||||
- RED after rebase: the regression harness failed against `origin/main` with status 1 after reproducing the old `tea pr comment` zero-exit fallback and false success echo.
|
||||
- GREEN at resumed head: the same harness passed with REST POST 201 plus GET 200 read-back.
|
||||
- All `packages/mosaic/framework/tools/git/test-*.sh` harnesses passed.
|
||||
- `shellcheck -x` passed for the changed scripts; `bash -n` passed.
|
||||
- Manifest resolver returned `framework` for `tools/git/test-pr-review-gitea-comment.sh`.
|
||||
- `pnpm test` passed (43/43 Turbo tasks; Mosaic 75 files/1434 tests; Gateway 56 files/628 tests plus documented skips).
|
||||
- `pnpm typecheck` passed (42/42 tasks), `pnpm lint` passed (23/23), and `pnpm format:check` passed.
|
||||
- Firewall checks found no user-home paths or operator identities in changed shipped files; no token value is logged or echoed.
|
||||
|
||||
## Risks / blockers
|
||||
|
||||
- No active implementation blocker. #789 reached terminal merged state and the coordination hold was lifted.
|
||||
- Review round 1 found one portability blocker: the API base reconstructed `https://$host` and discarded configured schemes/path prefixes.
|
||||
- Review round 2 found a second subpath portability blocker: clone-derived `get_repo_slug` retained the deployment prefix, duplicating it under `/api/v1/repos/`.
|
||||
- Round 3 resolves owner/repo relative to the configured Gitea base path for HTTP(S) clones while preserving root-mounted and SSH clone forms. Host matching now compares non-default ports consistently.
|
||||
- REST transport failures, non-201 writes, malformed/missing created IDs, non-200 read-backs, and read-back mismatches all fail closed.
|
||||
- Existing approve/request-changes behavior remains covered.
|
||||
- Independent exact-head re-review remains coordinator-owned.
|
||||
|
||||
## Final verification evidence
|
||||
|
||||
- URL-portability regression was RED before remediation at the new `http://git.mosaicstack.dev` case and GREEN afterward.
|
||||
- Round-3 genuine subpath regression was RED against round-2 head `1b190201` and GREEN after the fix: `https://git.example/gitea/owner/repo.git` maps to API repository `owner/repo` under configured base `/gitea`.
|
||||
- Regression coverage verifies POST and read-back GET for root-mounted HTTP(S), path-prefixed HTTP(S), non-default HTTP port, scp-style SSH, and `ssh://` clone forms.
|
||||
- Focused shell checks, all git-wrapper harnesses, and full repository test/typecheck/lint/format gates passed after remediation.
|
||||
- Branch will be force-pushed with lease for coordinator re-verification; no PR opened.
|
||||
9
packages/mosaic/framework/tools/git/README.md
Normal file
9
packages/mosaic/framework/tools/git/README.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# Git provider wrappers
|
||||
|
||||
These scripts provide host-aware GitHub and Gitea issue, pull-request, milestone, and CI operations.
|
||||
|
||||
## Durable review provenance
|
||||
|
||||
A successful provider write command—or a wrapper message based only on that command's exit code—is **not** durable review provenance. Review comments count as durable provenance only after the wrapper reads the created provider record back and verifies that it belongs to the intended repository and pull request and contains the exact submitted body (or verifies the provider-returned record ID).
|
||||
|
||||
`pr-review.sh` therefore fails closed when a Gitea comment cannot be written, its created comment ID cannot be identified, or provider read-back does not match. It reports comment success only after that read-back verification passes.
|
||||
@@ -81,7 +81,32 @@ get_repo_slug() {
|
||||
gitea_url_matches_host() {
|
||||
local url="${1:-}" host="${2:-}"
|
||||
[[ -n "$url" && -n "$host" ]] || return 1
|
||||
[[ "${url%/}" == "https://$host" || "${url%/}" == "http://$host" || "${url%/}" == *"//$host" ]]
|
||||
python3 - "$url" "$host" <<'PY'
|
||||
import sys
|
||||
from urllib.parse import urlparse
|
||||
|
||||
url, remote_host = sys.argv[1:]
|
||||
configured = urlparse(url)
|
||||
remote = urlparse(f"//{remote_host}")
|
||||
if configured.scheme not in {"http", "https"} or configured.hostname != remote.hostname:
|
||||
raise SystemExit(1)
|
||||
|
||||
# Normalize by scheme: an implicit (portless) HTTP(S) URL and its explicit
|
||||
# default-port form (":80" for http, ":443" for https) name the same
|
||||
# provider endpoint. Apply that equivalence symmetrically -- whichever side
|
||||
# omits the port is treated as carrying the scheme's default port -- so
|
||||
# "configured implicit vs. remote explicit" and "configured explicit vs.
|
||||
# remote implicit" both match. (The remote side here is always an HTTP(S)
|
||||
# authority; an SSH remote's transport port is stripped by get_remote_host
|
||||
# before reaching this comparison, since it identifies an unrelated
|
||||
# service on the same host, not the HTTP(S) provider port.)
|
||||
default_port = 80 if configured.scheme == "http" else 443
|
||||
normalized_configured = configured.port if configured.port is not None else default_port
|
||||
normalized_remote = remote.port if remote.port is not None else default_port
|
||||
if normalized_configured != normalized_remote:
|
||||
raise SystemExit(1)
|
||||
raise SystemExit(0)
|
||||
PY
|
||||
}
|
||||
|
||||
get_gitea_service_for_host() {
|
||||
@@ -347,6 +372,47 @@ get_gitea_api_host_for_repo_override() {
|
||||
get_host_from_url "${GITEA_URL:-}"
|
||||
}
|
||||
|
||||
# Resolve owner/repo relative to a configured Gitea base URL. HTTP(S) clone
|
||||
# URLs can include the deployment prefix (for example /gitea/owner/repo.git),
|
||||
# but Gitea's /repos API expects only owner/repo. Root-mounted and SSH clone
|
||||
# forms retain their existing owner/repo behavior.
|
||||
get_gitea_repo_slug_for_url() {
|
||||
local configured_url="$1" remote_url
|
||||
remote_url=$(git remote get-url origin 2>/dev/null) || return 1
|
||||
|
||||
if [[ "$remote_url" =~ ^https?:// ]]; then
|
||||
python3 - "$remote_url" "$configured_url" <<'PY'
|
||||
import sys
|
||||
from urllib.parse import urlparse
|
||||
|
||||
remote = urlparse(sys.argv[1])
|
||||
base = urlparse(sys.argv[2])
|
||||
remote_path = remote.path.strip("/")
|
||||
if remote_path.endswith(".git"):
|
||||
remote_path = remote_path[:-4]
|
||||
base_path = base.path.strip("/")
|
||||
remote_parts = [part for part in remote_path.split("/") if part]
|
||||
base_parts = [part for part in base_path.split("/") if part]
|
||||
|
||||
if base_parts and remote_parts[:len(base_parts)] == base_parts:
|
||||
repo_parts = remote_parts[len(base_parts):]
|
||||
elif len(remote_parts) == 2:
|
||||
# Preserve a root-shaped clone URL when provider API configuration carries
|
||||
# a reverse-proxy prefix separately.
|
||||
repo_parts = remote_parts
|
||||
else:
|
||||
raise SystemExit(1)
|
||||
|
||||
if len(repo_parts) != 2:
|
||||
raise SystemExit(1)
|
||||
print("/".join(repo_parts))
|
||||
PY
|
||||
return
|
||||
fi
|
||||
|
||||
get_repo_slug
|
||||
}
|
||||
|
||||
get_gitea_repo_args() {
|
||||
local repo host login
|
||||
repo=$(get_repo_slug) || return 1
|
||||
@@ -370,6 +436,15 @@ get_remote_host() {
|
||||
echo "${host##*@}"
|
||||
return 0
|
||||
fi
|
||||
if [[ "$remote_url" =~ ^ssh://([^/]+)/ ]]; then
|
||||
local host="${BASH_REMATCH[1]}"
|
||||
host="${host##*@}"
|
||||
# Strip an SSH transport port (e.g. "git.example:2222"): it names the
|
||||
# SSH daemon port, not the HTTP(S) provider API port, and must not
|
||||
# feed gitea_url_matches_host's port comparison (#850).
|
||||
echo "${host%%:*}"
|
||||
return 0
|
||||
fi
|
||||
if [[ "$remote_url" =~ ^git@([^:]+): ]]; then
|
||||
echo "${BASH_REMATCH[1]}"
|
||||
return 0
|
||||
@@ -377,6 +452,51 @@ get_remote_host() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# Resolve the configured Gitea base URL for a host from the same credential
|
||||
# source used by get_gitea_token. The scheme and any deployment path prefix are
|
||||
# provider configuration and must not be reconstructed from the git remote.
|
||||
get_gitea_url_for_host() {
|
||||
local host="$1" script_dir cred_loader url
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cred_loader="$script_dir/../_lib/credentials.sh"
|
||||
|
||||
if [[ -f "$cred_loader" ]]; then
|
||||
url=$(
|
||||
# shellcheck source=/dev/null
|
||||
source "$cred_loader"
|
||||
unset GITEA_TOKEN GITEA_URL
|
||||
case "$host" in
|
||||
git.mosaicstack.dev) load_credentials gitea-mosaicstack 2>/dev/null ;;
|
||||
git.uscllc.com) load_credentials gitea-usc 2>/dev/null ;;
|
||||
*)
|
||||
for svc in gitea-mosaicstack gitea-usc; do
|
||||
unset GITEA_TOKEN GITEA_URL
|
||||
load_credentials "$svc" 2>/dev/null || continue
|
||||
if gitea_url_matches_host "${GITEA_URL:-}" "$host"; then
|
||||
break
|
||||
fi
|
||||
unset GITEA_TOKEN GITEA_URL
|
||||
done
|
||||
;;
|
||||
esac
|
||||
if gitea_url_matches_host "${GITEA_URL:-}" "$host"; then
|
||||
printf '%s' "${GITEA_URL%/}"
|
||||
fi
|
||||
)
|
||||
if [[ -n "$url" ]]; then
|
||||
printf '%s\n' "$url"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
if gitea_url_matches_host "${GITEA_URL:-}" "$host"; then
|
||||
printf '%s\n' "${GITEA_URL%/}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# Resolve a Gitea API token for the given host.
|
||||
# Priority: Mosaic credential loader → GITEA_TOKEN env → ~/.git-credentials
|
||||
get_gitea_token() {
|
||||
@@ -403,7 +523,7 @@ get_gitea_token() {
|
||||
for svc in gitea-mosaicstack gitea-usc; do
|
||||
unset GITEA_TOKEN GITEA_URL
|
||||
load_credentials "$svc" 2>/dev/null || continue
|
||||
if [[ "${GITEA_URL:-}" == "https://$host" || "${GITEA_URL:-}" == "http://$host" || "${GITEA_URL:-}" == *"//$host" ]]; then
|
||||
if gitea_url_matches_host "${GITEA_URL:-}" "$host"; then
|
||||
matched=true
|
||||
break
|
||||
fi
|
||||
@@ -423,7 +543,7 @@ get_gitea_token() {
|
||||
|
||||
# 2. GITEA_TOKEN env var (only when GITEA_URL, if present, matches the remote host)
|
||||
if [[ -n "${GITEA_TOKEN:-}" ]]; then
|
||||
if [[ -z "${GITEA_URL:-}" || "${GITEA_URL:-}" == "https://$host" || "${GITEA_URL:-}" == "http://$host" || "${GITEA_URL:-}" == *"//$host" ]]; then
|
||||
if [[ -z "${GITEA_URL:-}" ]] || gitea_url_matches_host "$GITEA_URL" "$host"; then
|
||||
echo "$GITEA_TOKEN"
|
||||
return 0
|
||||
fi
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=packages/mosaic/framework/tools/git/detect-platform.sh
|
||||
source "$SCRIPT_DIR/detect-platform.sh"
|
||||
|
||||
# Parse arguments
|
||||
@@ -55,6 +56,125 @@ fi
|
||||
|
||||
detect_platform >/dev/null
|
||||
|
||||
# Post a review comment body to a Gitea PR via the supported comments REST API
|
||||
# and verify it durably via provider read-back (see docs on durable review
|
||||
# provenance in README.md). Used by the `comment` action and, since `tea`
|
||||
# v0.11.1 defines no `--comment`/`-comment` flag on `pr approve`/`pr reject`,
|
||||
# also by the `approve` and `request-changes` actions to carry an optional
|
||||
# review body that `tea` itself cannot attach.
|
||||
#
|
||||
# Args: $1 = PR number, $2 = comment body
|
||||
# On success: prints only the created comment ID to stdout, returns 0.
|
||||
# On failure: prints an error to stderr, returns 1.
|
||||
gitea_post_verified_comment() {
|
||||
local pr_number="$1" comment_body="$2"
|
||||
local host token configured_url repo api_base payload
|
||||
local write_response_file readback_response_file comment_id
|
||||
|
||||
host=$(get_remote_host)
|
||||
token=$(get_gitea_token "$host") || {
|
||||
echo "Error: Gitea token not found for comment persistence" >&2
|
||||
return 1
|
||||
}
|
||||
configured_url=$(get_gitea_url_for_host "$host") || {
|
||||
echo "Error: Configured Gitea URL not found for comment persistence" >&2
|
||||
return 1
|
||||
}
|
||||
repo=$(get_gitea_repo_slug_for_url "$configured_url") || {
|
||||
echo "Error: Could not resolve Gitea owner/repository relative to configured URL" >&2
|
||||
return 1
|
||||
}
|
||||
api_base="${configured_url%/}/api/v1/repos/$repo"
|
||||
payload=$(COMMENT_BODY="$comment_body" python3 -c '
|
||||
import json
|
||||
import os
|
||||
|
||||
print(json.dumps({"body": os.environ["COMMENT_BODY"]}))
|
||||
')
|
||||
write_response_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-write.XXXXXX")
|
||||
readback_response_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-readback.XXXXXX")
|
||||
trap 'rm -f "$write_response_file" "$readback_response_file"' RETURN
|
||||
|
||||
if ! write_status=$(curl -sS -o "$write_response_file" -w '%{http_code}' \
|
||||
-X POST \
|
||||
-H "Authorization: token $token" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$payload" \
|
||||
"$api_base/issues/$pr_number/comments"); then
|
||||
echo "Error: Gitea comment write transport failed" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ "$write_status" != "201" ]]; then
|
||||
echo "Error: Gitea comment write failed with HTTP $write_status" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
comment_id=$(python3 - "$write_response_file" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
try:
|
||||
with open(sys.argv[1], encoding="utf-8") as response:
|
||||
comment = json.load(response)
|
||||
comment_id = comment.get("id") if isinstance(comment, dict) else None
|
||||
if not isinstance(comment_id, int) or comment_id <= 0:
|
||||
raise ValueError("missing positive comment id")
|
||||
except (OSError, json.JSONDecodeError, ValueError) as error:
|
||||
print(f"Error: could not identify created Gitea comment: {error}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
print(comment_id)
|
||||
PY
|
||||
) || return 1
|
||||
|
||||
if ! readback_status=$(curl -sS -o "$readback_response_file" -w '%{http_code}' \
|
||||
-H "Authorization: token $token" \
|
||||
"$api_base/issues/comments/$comment_id"); then
|
||||
echo "Error: Gitea comment read-back transport failed" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ "$readback_status" != "200" ]]; then
|
||||
echo "Error: Gitea comment read-back failed with HTTP $readback_status" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if EXPECTED_COMMENT_ID="$comment_id" EXPECTED_COMMENT_BODY="$comment_body" EXPECTED_REPO="$repo" EXPECTED_PR_NUMBER="$pr_number" \
|
||||
python3 - "$readback_response_file" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from urllib.parse import urlparse
|
||||
|
||||
try:
|
||||
with open(sys.argv[1], encoding="utf-8") as response:
|
||||
comment = json.load(response)
|
||||
if not isinstance(comment, dict):
|
||||
raise ValueError("response is not a comment object")
|
||||
expected_id = int(os.environ["EXPECTED_COMMENT_ID"])
|
||||
expected_body = os.environ["EXPECTED_COMMENT_BODY"]
|
||||
expected_repo = os.environ["EXPECTED_REPO"]
|
||||
expected_pr = os.environ["EXPECTED_PR_NUMBER"]
|
||||
issue_path = urlparse(comment.get("issue_url", "")).path.rstrip("/")
|
||||
expected_suffix = f"/repos/{expected_repo}/issues/{expected_pr}"
|
||||
if comment.get("id") != expected_id:
|
||||
raise ValueError("comment id mismatch")
|
||||
if comment.get("body") != expected_body:
|
||||
raise ValueError("comment body mismatch")
|
||||
if not issue_path.endswith(expected_suffix):
|
||||
raise ValueError("repository or PR mismatch")
|
||||
except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError) as error:
|
||||
print(f"Error: Gitea comment persistence verification failed: {error}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
PY
|
||||
then
|
||||
true
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "$comment_id"
|
||||
return 0
|
||||
}
|
||||
|
||||
if [[ "$PLATFORM" == "github" ]]; then
|
||||
case $ACTION in
|
||||
approve)
|
||||
@@ -85,24 +205,41 @@ if [[ "$PLATFORM" == "github" ]]; then
|
||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
case $ACTION in
|
||||
approve)
|
||||
tea pr approve "$PR_NUMBER" $(get_gitea_repo_args) ${COMMENT:+--comment "$COMMENT"}
|
||||
repo=$(get_repo_slug)
|
||||
host=$(get_remote_host)
|
||||
login=$(get_gitea_login_for_host "$host")
|
||||
# tea v0.11.1 defines no --comment/-comment flag on `pr approve`;
|
||||
# route any review body via the durable comment API instead (#835).
|
||||
tea pr approve "$PR_NUMBER" --repo "$repo" --login "$login"
|
||||
echo "Approved Gitea PR #$PR_NUMBER"
|
||||
if [[ -n "$COMMENT" ]]; then
|
||||
comment_id=$(gitea_post_verified_comment "$PR_NUMBER" "$COMMENT") || exit 1
|
||||
echo "Added and verified review comment on Gitea PR #$PR_NUMBER (comment ID $comment_id)"
|
||||
fi
|
||||
;;
|
||||
request-changes)
|
||||
if [[ -z "$COMMENT" ]]; then
|
||||
echo "Error: Comment required for request-changes"
|
||||
exit 1
|
||||
fi
|
||||
tea pr reject "$PR_NUMBER" $(get_gitea_repo_args) --comment "$COMMENT"
|
||||
repo=$(get_repo_slug)
|
||||
host=$(get_remote_host)
|
||||
login=$(get_gitea_login_for_host "$host")
|
||||
# tea v0.11.1 defines no --comment/-comment flag on `pr reject`;
|
||||
# route the review body via the durable comment API instead (#835).
|
||||
tea pr reject "$PR_NUMBER" --repo "$repo" --login "$login"
|
||||
echo "Requested changes on Gitea PR #$PR_NUMBER"
|
||||
comment_id=$(gitea_post_verified_comment "$PR_NUMBER" "$COMMENT") || exit 1
|
||||
echo "Added and verified review comment on Gitea PR #$PR_NUMBER (comment ID $comment_id)"
|
||||
;;
|
||||
comment)
|
||||
if [[ -z "$COMMENT" ]]; then
|
||||
echo "Error: Comment required"
|
||||
exit 1
|
||||
fi
|
||||
tea pr comment "$PR_NUMBER" "$COMMENT" $(get_gitea_repo_args)
|
||||
echo "Added comment to Gitea PR #$PR_NUMBER"
|
||||
|
||||
comment_id=$(gitea_post_verified_comment "$PR_NUMBER" "$COMMENT") || exit 1
|
||||
echo "Added and verified comment on Gitea PR #$PR_NUMBER (comment ID $comment_id)"
|
||||
;;
|
||||
*)
|
||||
echo "Error: Unknown action: $ACTION"
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regression harness for durable Gitea PR review comments (#812) and for the
|
||||
# approve/reject `--comment` flag removal (#835). The `tea` stub below rejects
|
||||
# any `-comment`/`--comment` flag on `pr approve`/`pr reject` exactly like real
|
||||
# `tea` v0.11.1 does ("flag provided but not defined: -comment"), so this
|
||||
# harness fails RED against the pre-#835 wrapper (which passed that flag) and
|
||||
# only passes once the wrapper routes the review body through the durable
|
||||
# comment REST API instead.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-review-gitea-comment}"
|
||||
REPO_DIR="$WORK_DIR/repo"
|
||||
BIN_DIR="$WORK_DIR/bin"
|
||||
TEA_LOG="$WORK_DIR/tea.log"
|
||||
CURL_LOG="$WORK_DIR/curl.log"
|
||||
OUTPUT_FILE="$WORK_DIR/output.log"
|
||||
CREDENTIALS_FILE="$WORK_DIR/credentials.json"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$WORK_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mkdir -p "$REPO_DIR" "$BIN_DIR"
|
||||
git -C "$REPO_DIR" init -q
|
||||
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
||||
|
||||
write_credentials() {
|
||||
local configured_url="$1"
|
||||
CONFIGURED_GITEA_URL="$configured_url" python3 - "$CREDENTIALS_FILE" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
with open(sys.argv[1], "w", encoding="utf-8") as credentials:
|
||||
json.dump({
|
||||
"gitea": {
|
||||
"mosaicstack": {
|
||||
"url": os.environ["CONFIGURED_GITEA_URL"],
|
||||
"token": "test-only-placeholder",
|
||||
}
|
||||
}
|
||||
}, credentials)
|
||||
PY
|
||||
}
|
||||
|
||||
cat > "$BIN_DIR/tea" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
printf '%s\n' "$*" >> "$PR_REVIEW_TEA_LOG"
|
||||
|
||||
if [[ "$*" == "login list --output json" ]]; then
|
||||
printf '%s\n' '[{"name":"mosaicstack","url":"https://git.mosaicstack.dev"}]'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# tea v0.11.1 defines no --comment/-comment flag on `pr approve` or `pr
|
||||
# reject`; it fails closed with this exact message and a nonzero exit. Any
|
||||
# regression that reintroduces the flag on those subcommands must hit this
|
||||
# branch and fail RED (#835).
|
||||
if [[ "$*" == *" -comment "* || "$*" == *" --comment "* || "$*" == *" -comment" || "$*" == *" --comment" ]]; then
|
||||
echo "flag provided but not defined: -comment" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "${PR_REVIEW_TEST_MODE:-}" in
|
||||
approve)
|
||||
[[ "$*" == "pr approve 123 --repo mosaicstack/stack --login mosaicstack" ]] || exit 90
|
||||
;;
|
||||
request-changes)
|
||||
[[ "$*" == "pr reject 123 --repo mosaicstack/stack --login mosaicstack" ]] || exit 91
|
||||
;;
|
||||
legacy-fallback|comment-success|http-success|prefix-success|subpath-success|port-success|scp-ssh-success|url-ssh-success|ssh-transport-port-success|explicit-default-port-success|write-transport-failure|write-http-failure|readback-failure)
|
||||
if [[ "$*" == pr\ comment* ]]; then
|
||||
# tea v0.11.1 treats the nonexistent subcommand as `tea pr list` and exits 0.
|
||||
printf '%s\n' 'INDEX TITLE STATE'
|
||||
exit 0
|
||||
fi
|
||||
echo "Unexpected tea command: $*" >&2
|
||||
exit 92
|
||||
;;
|
||||
*)
|
||||
exit 95
|
||||
;;
|
||||
esac
|
||||
SH
|
||||
chmod +x "$BIN_DIR/tea"
|
||||
|
||||
cat > "$BIN_DIR/curl" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
output_file=""
|
||||
method="GET"
|
||||
payload=""
|
||||
url=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-o)
|
||||
output_file="$2"
|
||||
shift 2
|
||||
;;
|
||||
-w|-H)
|
||||
shift 2
|
||||
;;
|
||||
-X)
|
||||
method="$2"
|
||||
shift 2
|
||||
;;
|
||||
-d|--data)
|
||||
payload="$2"
|
||||
shift 2
|
||||
;;
|
||||
-s|-S|-sS)
|
||||
shift
|
||||
;;
|
||||
http://*|https://*)
|
||||
url="$1"
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
printf '%s %s\n' "$method" "$url" >> "$PR_REVIEW_CURL_LOG"
|
||||
|
||||
write_response() {
|
||||
local status="$1" body="$2"
|
||||
[[ -n "$output_file" ]] || exit 96
|
||||
printf '%s' "$body" > "$output_file"
|
||||
printf '%s' "$status"
|
||||
}
|
||||
|
||||
case "${PR_REVIEW_TEST_MODE:-}" in
|
||||
legacy-fallback|write-transport-failure)
|
||||
echo "simulated transport failure" >&2
|
||||
exit 7
|
||||
;;
|
||||
write-http-failure)
|
||||
write_response 500 '{"message":"simulated rejection"}'
|
||||
;;
|
||||
approve|request-changes|comment-success|http-success|prefix-success|subpath-success|port-success|scp-ssh-success|url-ssh-success|ssh-transport-port-success|explicit-default-port-success|readback-failure)
|
||||
if [[ "$method" == "POST" && "$url" == "$PR_REVIEW_EXPECTED_API_BASE/issues/123/comments" ]]; then
|
||||
PR_REVIEW_PAYLOAD="$payload" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
assert json.loads(os.environ["PR_REVIEW_PAYLOAD"]) == {"body": os.environ["PR_REVIEW_EXPECTED_BODY"]}
|
||||
PY
|
||||
response=$(python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
print(json.dumps({"id": 456, "body": os.environ["PR_REVIEW_EXPECTED_BODY"]}))
|
||||
PY
|
||||
)
|
||||
write_response 201 "$response"
|
||||
elif [[ "$method" == "GET" && "$url" == "$PR_REVIEW_EXPECTED_API_BASE/issues/comments/456" ]]; then
|
||||
if [[ "$PR_REVIEW_TEST_MODE" == "readback-failure" ]]; then
|
||||
body="different-body"
|
||||
else
|
||||
body="$PR_REVIEW_EXPECTED_BODY"
|
||||
fi
|
||||
response=$(PR_REVIEW_BODY="$body" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
print(json.dumps({
|
||||
"id": 456,
|
||||
"body": os.environ["PR_REVIEW_BODY"],
|
||||
"issue_url": os.environ["PR_REVIEW_EXPECTED_API_BASE"] + "/issues/123",
|
||||
}))
|
||||
PY
|
||||
)
|
||||
write_response 200 "$response"
|
||||
else
|
||||
echo "Unexpected curl request: $method $url" >&2
|
||||
exit 97
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
exit 98
|
||||
;;
|
||||
esac
|
||||
SH
|
||||
chmod +x "$BIN_DIR/curl"
|
||||
|
||||
run_review() {
|
||||
local mode="$1" action="$2" comment="${3:-}"
|
||||
local configured_url="${4:-https://git.mosaicstack.dev}"
|
||||
local remote_url="${5:-https://git.mosaicstack.dev/mosaicstack/stack.git}"
|
||||
local expected_repo="${6:-mosaicstack/stack}"
|
||||
local expected_api_base="${configured_url%/}/api/v1/repos/$expected_repo"
|
||||
git -C "$REPO_DIR" remote set-url origin "$remote_url"
|
||||
write_credentials "$configured_url"
|
||||
: > "$TEA_LOG"
|
||||
: > "$CURL_LOG"
|
||||
: > "$OUTPUT_FILE"
|
||||
(
|
||||
cd "$REPO_DIR"
|
||||
PATH="$BIN_DIR:$PATH" \
|
||||
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||
PR_REVIEW_TEA_LOG="$TEA_LOG" \
|
||||
PR_REVIEW_CURL_LOG="$CURL_LOG" \
|
||||
PR_REVIEW_TEST_MODE="$mode" \
|
||||
PR_REVIEW_EXPECTED_BODY="$comment" \
|
||||
PR_REVIEW_EXPECTED_API_BASE="$expected_api_base" \
|
||||
"$SCRIPT_DIR/pr-review.sh" -n 123 -a "$action" ${comment:+-c "$comment"}
|
||||
) > "$OUTPUT_FILE" 2>&1
|
||||
}
|
||||
|
||||
run_review approve approve
|
||||
grep -q '^pr approve 123 --repo mosaicstack/stack --login mosaicstack$' "$TEA_LOG"
|
||||
grep -q 'Approved Gitea PR #123' "$OUTPUT_FILE"
|
||||
if grep -q 'comment' "$TEA_LOG"; then
|
||||
echo "Plain approve (no review body) unexpectedly touched comment persistence" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# #835: tea v0.11.1 defines no --comment/-comment flag on `pr approve`. A
|
||||
# review body supplied alongside approve must be routed through the durable
|
||||
# comment REST API instead of being passed to `tea` directly.
|
||||
run_review approve approve approve-note
|
||||
grep -q '^pr approve 123 --repo mosaicstack/stack --login mosaicstack$' "$TEA_LOG"
|
||||
grep -q 'Approved Gitea PR #123' "$OUTPUT_FILE"
|
||||
grep -q '^POST https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues/123/comments$' "$CURL_LOG"
|
||||
grep -q '^GET https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues/comments/456$' "$CURL_LOG"
|
||||
grep -q 'Added and verified review comment on Gitea PR #123 (comment ID 456)' "$OUTPUT_FILE"
|
||||
|
||||
# #835: same for `pr reject` (request-changes), where a comment is required.
|
||||
run_review request-changes request-changes changes-required
|
||||
grep -q '^pr reject 123 --repo mosaicstack/stack --login mosaicstack$' "$TEA_LOG"
|
||||
grep -q 'Requested changes on Gitea PR #123' "$OUTPUT_FILE"
|
||||
grep -q '^POST https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues/123/comments$' "$CURL_LOG"
|
||||
grep -q '^GET https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues/comments/456$' "$CURL_LOG"
|
||||
grep -q 'Added and verified review comment on Gitea PR #123 (comment ID 456)' "$OUTPUT_FILE"
|
||||
|
||||
if run_review legacy-fallback comment durable-body; then
|
||||
echo "The old nonexistent tea pr comment fallback returned success" >&2
|
||||
cat "$OUTPUT_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -q '^pr comment ' "$TEA_LOG"; then
|
||||
echo "Wrapper invoked unsupported tea pr comment" >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -q 'Added comment to Gitea PR' "$OUTPUT_FILE"; then
|
||||
echo "Wrapper reported success without durable persistence" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
complex_body=$'durable "body"\n-- marker'
|
||||
run_review comment-success comment "$complex_body"
|
||||
grep -q '^POST https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues/123/comments$' "$CURL_LOG"
|
||||
grep -q '^GET https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues/comments/456$' "$CURL_LOG"
|
||||
grep -q 'Added and verified comment on Gitea PR #123' "$OUTPUT_FILE"
|
||||
if [[ -s "$TEA_LOG" ]]; then
|
||||
echo "REST comment path unexpectedly invoked tea" >&2
|
||||
cat "$TEA_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
run_review http-success comment durable-body http://git.mosaicstack.dev
|
||||
grep -q '^POST http://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues/123/comments$' "$CURL_LOG"
|
||||
grep -q '^GET http://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues/comments/456$' "$CURL_LOG"
|
||||
|
||||
run_review prefix-success comment durable-body https://git.mosaicstack.dev/gitea/
|
||||
grep -q '^POST https://git.mosaicstack.dev/gitea/api/v1/repos/mosaicstack/stack/issues/123/comments$' "$CURL_LOG"
|
||||
grep -q '^GET https://git.mosaicstack.dev/gitea/api/v1/repos/mosaicstack/stack/issues/comments/456$' "$CURL_LOG"
|
||||
|
||||
run_review subpath-success comment durable-body https://git.example/gitea https://git.example/gitea/owner/repo.git owner/repo
|
||||
grep -q '^POST https://git.example/gitea/api/v1/repos/owner/repo/issues/123/comments$' "$CURL_LOG"
|
||||
grep -q '^GET https://git.example/gitea/api/v1/repos/owner/repo/issues/comments/456$' "$CURL_LOG"
|
||||
if grep -q '/repos/gitea/owner/repo/' "$CURL_LOG"; then
|
||||
echo "Configured Gitea path prefix leaked into the repository slug" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
run_review port-success comment durable-body http://git.example:3000 http://git.example:3000/owner/repo.git owner/repo
|
||||
grep -q '^POST http://git.example:3000/api/v1/repos/owner/repo/issues/123/comments$' "$CURL_LOG"
|
||||
grep -q '^GET http://git.example:3000/api/v1/repos/owner/repo/issues/comments/456$' "$CURL_LOG"
|
||||
|
||||
run_review scp-ssh-success comment durable-body https://git.example git@git.example:owner/repo.git owner/repo
|
||||
grep -q '^POST https://git.example/api/v1/repos/owner/repo/issues/123/comments$' "$CURL_LOG"
|
||||
|
||||
run_review url-ssh-success comment durable-body https://git.example ssh://git@git.example/owner/repo.git owner/repo
|
||||
grep -q '^POST https://git.example/api/v1/repos/owner/repo/issues/123/comments$' "$CURL_LOG"
|
||||
|
||||
# #850 (follow-up to #812): an SSH remote's transport port (e.g. `ssh://
|
||||
# git@host:2222/...`) must NOT be compared against the configured HTTP(S) API
|
||||
# URL's port -- they identify unrelated properties (SSH daemon port vs. HTTP(S)
|
||||
# provider port) of the same Gitea host. Before the fix, host-match required
|
||||
# the configured URL to carry the identical port, so this failed closed even
|
||||
# though both remote and configured URL name the same host.
|
||||
run_review ssh-transport-port-success comment durable-body https://git.example ssh://git@git.example:2222/owner/repo.git owner/repo
|
||||
grep -q '^POST https://git.example/api/v1/repos/owner/repo/issues/123/comments$' "$CURL_LOG"
|
||||
|
||||
# #850 (follow-up to #812): an explicit default HTTP(S) port on the remote
|
||||
# (`https://host:443/...`) must be treated as equal to an implicit
|
||||
# (portless) configured URL on BOTH sides -- the pre-fix comparison only
|
||||
# normalized the default port when the REMOTE side was portless, so the
|
||||
# inverse (explicit remote, implicit configured) form failed closed.
|
||||
run_review explicit-default-port-success comment durable-body https://git.example https://git.example:443/owner/repo.git owner/repo
|
||||
grep -q '^POST https://git.example/api/v1/repos/owner/repo/issues/123/comments$' "$CURL_LOG"
|
||||
|
||||
if run_review write-transport-failure comment durable-body; then
|
||||
echo "Expected provider transport failure to return nonzero" >&2
|
||||
exit 1
|
||||
fi
|
||||
if run_review write-http-failure comment durable-body; then
|
||||
echo "Expected non-201 provider write to return nonzero" >&2
|
||||
exit 1
|
||||
fi
|
||||
if run_review readback-failure comment durable-body; then
|
||||
echo "Expected mismatched provider read-back to return nonzero" >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -q 'Added and verified comment' "$OUTPUT_FILE"; then
|
||||
echo "Read-back mismatch reported durable success" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "pr-review.sh durable Gitea comment regression passed"
|
||||
92
packages/mosaic/framework/tools/orchestrator/README.md
Normal file
92
packages/mosaic/framework/tools/orchestrator/README.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# orchestrator/ tools
|
||||
|
||||
Helper scripts for r0 coordinator / orchestrator sessions — mission lifecycle,
|
||||
session health, continuation, and board maintenance. See
|
||||
`framework/guides/ORCHESTRATOR-PROTOCOL.md` for the surrounding process.
|
||||
|
||||
| Script | Purpose |
|
||||
| -------------------- | ----------------------------------------------------------------------------------------------- |
|
||||
| `mission-init.sh` | Initialize a new orchestration mission (manifest, scratchpad, TASKS.md). |
|
||||
| `mission-status.sh` | Show the mission progress dashboard. |
|
||||
| `session-run.sh` | Generate continuation context and launch the target runtime. |
|
||||
| `session-resume.sh` | Crash recovery for dead orchestrator sessions. |
|
||||
| `session-status.sh` | Check agent session health. |
|
||||
| `continue-prompt.sh` | Generate the continuation prompt for the next session. |
|
||||
| `board-roll.sh` | Keep a LIVE orchestration board under its byte cap by rolling the oldest entries to its LEDGER. |
|
||||
| `smoke-test.sh` | Behavior smoke checks for the coord continue/run workflows. |
|
||||
| `test-board-roll.sh` | Regression harness for `board-roll.sh`. |
|
||||
| `_lib.sh` | Shared functions sourced by the above (state files, TASKS.md parsing, locks). |
|
||||
|
||||
## board-roll.sh
|
||||
|
||||
Coordinator boards (`MOS-ORCHESTRATION-BOARD-LIVE.md`, `MS-LEAD-BOARD-LIVE.md`)
|
||||
follow a **"< 8 KB LIVE"** discipline: the LIVE board is the only file loaded on
|
||||
resume, so it must stay small, and history lives in an append-only LEDGER. When a
|
||||
board write would push LIVE over its cap, coordinators otherwise hand-trim and
|
||||
retry every time — an observed 38 ABORT-OVER-CAP cycles in one 24 h window.
|
||||
`board-roll.sh` automates that trim mechanically and reversibly: the audit trail
|
||||
is moved to the LEDGER instead of being hand-deleted.
|
||||
|
||||
### Contract (conservative — it never guesses what is safe to move)
|
||||
|
||||
The LIVE board opts in by wrapping its aging archival ticks in an explicit roll
|
||||
zone. Everything **outside** the markers (title, protocol blockquote, curated
|
||||
always-current `##` sections) is pinned and never touched:
|
||||
|
||||
```markdown
|
||||
# MOS ORCHESTRATION BOARD — LIVE state
|
||||
|
||||
> protocol blockquote … (pinned)
|
||||
|
||||
## 🟦 Curated always-current section (pinned)
|
||||
|
||||
…
|
||||
|
||||
<!-- BOARD-ROLL:START -->
|
||||
|
||||
### 2026-07-22 (mid²²) — newest tick, stays longest
|
||||
|
||||
…
|
||||
|
||||
### 2026-07-20 (dawn) — oldest tick, rolled first
|
||||
|
||||
…
|
||||
|
||||
<!-- BOARD-ROLL:END -->
|
||||
```
|
||||
|
||||
Inside the zone, entries are delimited by a heading marker (default `### `) and
|
||||
are assumed **newest-first (top) → oldest-last (bottom)**. `board-roll.sh` moves
|
||||
whole oldest (bottom-most) entry blocks out of the zone and appends them verbatim
|
||||
to the LEDGER, one at a time, until LIVE is back under the cap or the zone is
|
||||
empty. If the board has no markers, it exits `3` and changes nothing — adding the
|
||||
markers is a deliberate opt-in by the board owner.
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
board-roll.sh --live <LIVE.md> --ledger <LEDGER.md> [options]
|
||||
|
||||
--live <path> LIVE board file (required)
|
||||
--ledger <path> append-only LEDGER file (required; created if absent)
|
||||
--cap <bytes> size ceiling for LIVE (default 8192)
|
||||
--marker <prefix> entry-heading prefix inside the roll zone (default "### ")
|
||||
--dry-run report what would move; change nothing
|
||||
-h, --help show help and exit 0
|
||||
```
|
||||
|
||||
Only **one** roll zone is supported. If a board carries more than one
|
||||
`BOARD-ROLL:START`/`END` pair, `board-roll.sh` refuses (exit `3`, zero changes)
|
||||
rather than span first-START..last-END and relocate the curated content between
|
||||
the zones — consolidate the ticks into a single zone instead.
|
||||
|
||||
Exit codes: `0` LIVE under cap (already, or after rolling) — on `--dry-run`, a
|
||||
plan exists or nothing to do · `2` usage / argument / IO error · `3` cannot meet
|
||||
the cap (no markers, **more than one marker pair**, or the pinned sections alone
|
||||
exceed the cap and need a manual trim).
|
||||
|
||||
Writes are atomic (temp file + `mv`, LEDGER first) so a failure never leaves a
|
||||
board half-written; line endings are normalized to LF on rewrite. `--dry-run`
|
||||
first is recommended when wiring it into a board update protocol.
|
||||
|
||||
Run the regression suite with `bash test-board-roll.sh`.
|
||||
277
packages/mosaic/framework/tools/orchestrator/board-roll.sh
Normal file
277
packages/mosaic/framework/tools/orchestrator/board-roll.sh
Normal file
@@ -0,0 +1,277 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# board-roll.sh — keep a LIVE orchestration board under its byte cap by rolling
|
||||
# the oldest archival entries out to its append-only LEDGER.
|
||||
#
|
||||
# WHY: coordinator boards (MOS-ORCHESTRATION-BOARD-LIVE.md, MS-LEAD-BOARD-LIVE.md)
|
||||
# enforce a "< 8 KB LIVE" discipline via a self-guard that ABORTs the board write
|
||||
# when the file exceeds the cap. In practice the LIVE board keeps bumping the cap,
|
||||
# so coordinators hand-trim + retry every time (observed: 38 ABORT-OVER-CAP cycles
|
||||
# in a 24h window on one coordinator). This automates that trim, mechanically and
|
||||
# reversibly, so the audit trail is preserved in the LEDGER instead of hand-deleted.
|
||||
#
|
||||
# CONTRACT (conservative by design — it NEVER guesses what is safe to move):
|
||||
# The LIVE board must declare an explicit ROLL ZONE with HTML-comment markers:
|
||||
#
|
||||
# <!-- BOARD-ROLL:START -->
|
||||
# ### 2026-07-22 (newest tick — stays longest)
|
||||
# ...
|
||||
# ### 2026-07-19 (oldest tick — rolled first)
|
||||
# ...
|
||||
# <!-- BOARD-ROLL:END -->
|
||||
#
|
||||
# Everything OUTSIDE the markers (title, protocol blockquote, curated always-current
|
||||
# `##` sections) is PINNED and never touched. Inside the zone, entries are delimited
|
||||
# by a heading marker (default `### `) and are assumed newest-first (top) → oldest-last
|
||||
# (bottom), matching board convention. board-roll moves whole oldest (bottom-most)
|
||||
# entry blocks out of the zone and APPENDS them verbatim to the LEDGER, one block at a
|
||||
# time, until the LIVE file is back under the cap or the zone is empty.
|
||||
#
|
||||
# If no markers are present, it exits 3 without changing anything (safe default —
|
||||
# adding the markers is a deliberate opt-in by the board owner).
|
||||
#
|
||||
# USAGE:
|
||||
# board-roll.sh --live <LIVE.md> --ledger <LEDGER.md> [options]
|
||||
#
|
||||
# OPTIONS:
|
||||
# --live <path> LIVE board file (required)
|
||||
# --ledger <path> append-only LEDGER file (required; created if absent)
|
||||
# --cap <bytes> size ceiling for LIVE (default 8192)
|
||||
# --marker <prefix> entry-heading prefix inside the roll zone (default "### ")
|
||||
# --dry-run report what would move + resulting size; change nothing
|
||||
# -h, --help print usage and exit 0
|
||||
#
|
||||
# EXIT CODES:
|
||||
# 0 LIVE is under cap (already, or after rolling); on --dry-run, 0 = a plan exists
|
||||
# (or nothing to do)
|
||||
# 2 usage / argument / IO error (bad flag, missing file, unwritable target)
|
||||
# 3 cannot satisfy the cap: no roll markers present, MORE THAN ONE marker pair
|
||||
# (multiple zones are refused, not guessed), OR the zone was emptied and LIVE
|
||||
# is still over cap (curated pinned sections need a manual trim)
|
||||
#
|
||||
# NOTE: line endings are normalized to LF on rewrite (boards are LF markdown); a
|
||||
# trailing newline is always ensured. Writes are atomic (temp file + mv) so a
|
||||
# failure never leaves LIVE or LEDGER half-written.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
START_MARK='<!-- BOARD-ROLL:START -->'
|
||||
END_MARK='<!-- BOARD-ROLL:END -->'
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: board-roll.sh --live <LIVE.md> --ledger <LEDGER.md> [options]
|
||||
|
||||
Roll the oldest entries out of a LIVE orchestration board into its LEDGER
|
||||
until the LIVE file is under a byte cap. Conservative: only content inside
|
||||
explicit <!-- BOARD-ROLL:START -->/<!-- BOARD-ROLL:END --> markers is moved.
|
||||
|
||||
Options:
|
||||
--live <path> LIVE board file (required)
|
||||
--ledger <path> append-only LEDGER file (required; created if absent)
|
||||
--cap <bytes> size ceiling for LIVE (default 8192)
|
||||
--marker <prefix> entry-heading prefix inside the roll zone (default "### ")
|
||||
--dry-run report what would move; change nothing
|
||||
-h, --help show this help and exit 0
|
||||
|
||||
Exit: 0 under cap (or dry-run plan) · 2 usage/IO error · 3 cannot meet cap
|
||||
(no markers, or pinned sections alone exceed the cap).
|
||||
EOF
|
||||
}
|
||||
|
||||
die() { echo "board-roll: $*" >&2; exit 2; }
|
||||
|
||||
LIVE=""; LEDGER=""; CAP=8192; MARKER='### '; DRYRUN=0
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--live) LIVE="${2:-}"; shift 2 || die "--live needs a value" ;;
|
||||
--ledger) LEDGER="${2:-}"; shift 2 || die "--ledger needs a value" ;;
|
||||
--cap) CAP="${2:-}"; shift 2 || die "--cap needs a value" ;;
|
||||
--marker) MARKER="${2:-}"; shift 2 || die "--marker needs a value" ;;
|
||||
--dry-run) DRYRUN=1; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) usage >&2; die "unknown option: $1" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -n "$LIVE" ]] || { usage >&2; die "--live is required"; }
|
||||
[[ -n "$LEDGER" ]] || { usage >&2; die "--ledger is required"; }
|
||||
[[ -f "$LIVE" ]] || die "LIVE file not found: $LIVE"
|
||||
[[ "$CAP" =~ ^[0-9]+$ ]] || die "--cap must be a non-negative integer, got: $CAP"
|
||||
|
||||
# --- read LIVE into a line array (newlines stripped; re-added on write) ---------
|
||||
mapfile -t LINES < "$LIVE"
|
||||
|
||||
# byte size of an array rendered as LF-terminated text
|
||||
render_size() {
|
||||
if [[ $# -eq 0 ]]; then printf 0; return; fi
|
||||
printf '%s\n' "$@" | wc -c
|
||||
}
|
||||
|
||||
orig_size=$(render_size "${LINES[@]}")
|
||||
|
||||
# --- already under cap → nothing to do -----------------------------------------
|
||||
if (( orig_size < CAP )); then
|
||||
echo "board-roll: LIVE is ${orig_size}B (< cap ${CAP}B) — nothing to roll."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- locate the roll-zone markers ----------------------------------------------
|
||||
# Exactly ONE marker pair is supported. If a board carries more than one START or
|
||||
# END marker we REFUSE (exit 3, zero changes) rather than guess: a naive
|
||||
# first-START..last-END span would swallow the curated content and the intermediate
|
||||
# markers sitting between two intended zones and silently relocate that pinned text
|
||||
# to the LEDGER — the exact data-loss this tool exists to prevent. Refusing matches
|
||||
# the "no markers = exit 3" conservative posture.
|
||||
start_idx=-1; end_idx=-1; start_count=0; end_count=0
|
||||
for i in "${!LINES[@]}"; do
|
||||
if [[ "${LINES[$i]}" == "$START_MARK" ]]; then
|
||||
if (( start_count == 0 )); then start_idx=$i; fi
|
||||
start_count=$(( start_count + 1 ))
|
||||
fi
|
||||
if [[ "${LINES[$i]}" == "$END_MARK" ]]; then
|
||||
end_idx=$i
|
||||
end_count=$(( end_count + 1 ))
|
||||
fi
|
||||
done
|
||||
if (( start_count > 1 || end_count > 1 )); then
|
||||
echo "board-roll: LIVE is ${orig_size}B (>= cap ${CAP}B) but has ${start_count} START / ${end_count} END" >&2
|
||||
echo " markers — only a SINGLE '$START_MARK' … '$END_MARK' roll zone is supported." >&2
|
||||
echo " Multiple zones are refused (not guessed) so content between zones is never relocated." >&2
|
||||
echo " Consolidate the archival ticks into one zone, or trim manually." >&2
|
||||
exit 3
|
||||
fi
|
||||
if (( start_idx < 0 || end_idx < 0 || end_idx <= start_idx )); then
|
||||
echo "board-roll: LIVE is ${orig_size}B (>= cap ${CAP}B) but no usable roll zone" >&2
|
||||
echo " (need '$START_MARK' then '$END_MARK'). Add the markers around the" >&2
|
||||
echo " archival tick section to opt this board into automatic rolling." >&2
|
||||
exit 3
|
||||
fi
|
||||
|
||||
# preamble = lines [0 .. start_idx] (inclusive of START marker)
|
||||
# zone = lines (start_idx .. end_idx) (exclusive of both markers)
|
||||
# footer = lines [end_idx .. end] (inclusive of END marker)
|
||||
preamble=(); zone=(); footer=()
|
||||
for i in "${!LINES[@]}"; do
|
||||
if (( i <= start_idx )); then preamble+=("${LINES[$i]}")
|
||||
elif (( i < end_idx )); then zone+=("${LINES[$i]}")
|
||||
else footer+=("${LINES[$i]}")
|
||||
fi
|
||||
done
|
||||
|
||||
# --- split the zone into a fixed head + entry blocks ----------------------------
|
||||
# zone_head = any zone lines before the first entry marker (kept, never rolled).
|
||||
# blocks[k] = newline-joined text of entry k (marker line .. line before next marker).
|
||||
zone_head=(); declare -a block_start=()
|
||||
first_block=-1
|
||||
for i in "${!zone[@]}"; do
|
||||
if [[ "${zone[$i]}" == "$MARKER"* ]]; then
|
||||
[[ $first_block -eq -1 ]] && first_block=$i
|
||||
block_start+=("$i")
|
||||
fi
|
||||
done
|
||||
if (( first_block == -1 )); then
|
||||
echo "board-roll: LIVE is ${orig_size}B (>= cap ${CAP}B) but the roll zone has no" >&2
|
||||
echo " '${MARKER}' entries to move. Trim the pinned sections manually." >&2
|
||||
exit 3
|
||||
fi
|
||||
for (( i=0; i<first_block; i++ )); do zone_head+=("${zone[$i]}"); done
|
||||
|
||||
nblocks=${#block_start[@]}
|
||||
# block k spans zone[ block_start[k] .. (block_start[k+1]-1 or end-of-zone) ]
|
||||
block_text() { # $1 = block index → prints the block's lines, LF-joined (no trailing)
|
||||
local k=$1 s e
|
||||
s=${block_start[$k]}
|
||||
if (( k+1 < nblocks )); then e=$(( block_start[$((k+1))] - 1 )); else e=$(( ${#zone[@]} - 1 )); fi
|
||||
local out=()
|
||||
for (( j=s; j<=e; j++ )); do out+=("${zone[$j]}"); done
|
||||
printf '%s\n' "${out[@]}"
|
||||
}
|
||||
|
||||
# --- greedily roll oldest (bottom-most) blocks until under cap ------------------
|
||||
# keep = number of newest blocks retained; start with all, drop from the bottom.
|
||||
keep=$nblocks # blocks [keep .. nblocks-1] are the oldest set that gets moved
|
||||
current_size=$orig_size
|
||||
build_live_size() { # size of LIVE if we keep blocks [0 .. keep-1]
|
||||
local acc=("${preamble[@]}" "${zone_head[@]}")
|
||||
local k s e j
|
||||
for (( k=0; k<keep; k++ )); do
|
||||
s=${block_start[$k]}
|
||||
if (( k+1 < nblocks )); then e=$(( block_start[$((k+1))] - 1 )); else e=$(( ${#zone[@]} - 1 )); fi
|
||||
for (( j=s; j<=e; j++ )); do acc+=("${zone[$j]}"); done
|
||||
done
|
||||
acc+=("${footer[@]}")
|
||||
render_size "${acc[@]}"
|
||||
}
|
||||
while (( current_size >= CAP && keep > 0 )); do
|
||||
keep=$(( keep - 1 ))
|
||||
current_size=$(build_live_size)
|
||||
done
|
||||
|
||||
moved_count=$(( nblocks - keep ))
|
||||
if (( moved_count == 0 )); then
|
||||
# zone had entries but none movable brought us under (shouldn't happen: keep hits 0)
|
||||
echo "board-roll: could not reduce LIVE below cap (${current_size}B >= ${CAP}B)." >&2
|
||||
exit 3
|
||||
fi
|
||||
|
||||
# --- dry-run report -------------------------------------------------------------
|
||||
plan_headers() {
|
||||
local k
|
||||
for (( k=keep; k<nblocks; k++ )); do
|
||||
# first line of each moved block
|
||||
printf ' %s\n' "${zone[${block_start[$k]}]}"
|
||||
done
|
||||
}
|
||||
if (( DRYRUN )); then
|
||||
echo "board-roll: DRY RUN"
|
||||
echo " LIVE now: ${orig_size}B (cap ${CAP}B) — over by $(( orig_size - CAP ))B"
|
||||
echo " would roll: ${moved_count} of ${nblocks} entr$([[ $moved_count -eq 1 ]] && echo y || echo ies) (oldest first):"
|
||||
plan_headers
|
||||
echo " LIVE after: ${current_size}B"
|
||||
if (( current_size >= CAP )); then
|
||||
echo " WARNING: still >= cap after emptying the zone; pinned sections need a manual trim." >&2
|
||||
exit 3
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- commit the roll atomically -------------------------------------------------
|
||||
live_tmp="$(mktemp "${LIVE}.roll.XXXXXX")" || die "cannot create temp next to LIVE"
|
||||
ledger_tmp=""
|
||||
# shellcheck disable=SC2329 # invoked indirectly via `trap cleanup EXIT`
|
||||
cleanup() { rm -f "$live_tmp" "$ledger_tmp" 2>/dev/null || true; }
|
||||
trap cleanup EXIT
|
||||
|
||||
# new LIVE = preamble + zone_head + kept blocks + footer
|
||||
{
|
||||
printf '%s\n' "${preamble[@]}" "${zone_head[@]}"
|
||||
for (( k=0; k<keep; k++ )); do block_text "$k"; done
|
||||
printf '%s\n' "${footer[@]}"
|
||||
} > "$live_tmp"
|
||||
|
||||
# LEDGER gets the moved blocks appended verbatim, in original top→bottom order,
|
||||
# under a provenance separator. LEDGER is append-only, so we only ever add at EOF.
|
||||
ledger_tmp="$(mktemp "${LEDGER}.roll.XXXXXX")" || die "cannot create temp next to LEDGER"
|
||||
if [[ -f "$LEDGER" ]]; then cat "$LEDGER" > "$ledger_tmp"; fi
|
||||
# ensure a trailing newline on existing content before appending
|
||||
if [[ -s "$ledger_tmp" && -n "$(tail -c1 "$ledger_tmp")" ]]; then printf '\n' >> "$ledger_tmp"; fi
|
||||
{
|
||||
printf '\n<!-- board-roll: %d entr%s rolled from %s -->\n' \
|
||||
"$moved_count" "$([[ $moved_count -eq 1 ]] && echo y || echo ies)" "$(basename "$LIVE")"
|
||||
for (( k=keep; k<nblocks; k++ )); do block_text "$k"; done
|
||||
} >> "$ledger_tmp"
|
||||
|
||||
# atomic swap (both, LEDGER first so a crash never drops content that left LIVE)
|
||||
mv "$ledger_tmp" "$LEDGER"; ledger_tmp=""
|
||||
mv "$live_tmp" "$LIVE"; live_tmp=""
|
||||
trap - EXIT
|
||||
|
||||
# read the real on-disk size back (truthful, not the predicted value)
|
||||
final_size=$(wc -c < "$LIVE")
|
||||
echo "board-roll: rolled ${moved_count} entr$([[ $moved_count -eq 1 ]] && echo y || echo ies) to $(basename "$LEDGER"); LIVE ${orig_size}B → ${final_size}B (cap ${CAP}B)."
|
||||
if (( final_size >= CAP )); then
|
||||
echo "board-roll: still >= cap after rolling all zone entries; pinned sections need a manual trim." >&2
|
||||
exit 3
|
||||
fi
|
||||
exit 0
|
||||
156
packages/mosaic/framework/tools/orchestrator/test-board-roll.sh
Normal file
156
packages/mosaic/framework/tools/orchestrator/test-board-roll.sh
Normal file
@@ -0,0 +1,156 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regression harness for board-roll.sh — rolling oldest LIVE-board entries to LEDGER.
|
||||
#
|
||||
# Asserts:
|
||||
# 1. Under cap → no-op, exit 0, files unchanged.
|
||||
# 2. Over cap, no roll markers → exit 3, LIVE unchanged (never guesses).
|
||||
# 3. Over cap, markers present → rolls the fewest oldest entries to get under cap,
|
||||
# LIVE ends under cap, pinned preamble/footer + newest entries preserved.
|
||||
# 4. Rolled blocks land in the LEDGER verbatim, oldest set in original order.
|
||||
# 5. --dry-run changes nothing and reports a plan.
|
||||
# 6. Zone emptied but pinned sections alone exceed cap → exit 3.
|
||||
# 7. --help exits 0 and prints usage; an unknown flag exits nonzero (#701 discipline).
|
||||
# 8. More than one marker pair → exit 3, unchanged; curated content between the two
|
||||
# zones is never relocated to the LEDGER (rev0 #868 regression).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SUT="$SCRIPT_DIR/board-roll.sh"
|
||||
|
||||
fail=0
|
||||
note() { echo "FAIL: $*" >&2; fail=1; }
|
||||
|
||||
WORK="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
|
||||
# builds a LIVE board: pinned preamble + roll zone with N dated entries (newest first),
|
||||
# each entry padded to be individually large so the cap math is predictable.
|
||||
make_board() { # $1 file $2 n_entries $3 with_markers(1/0) $4 pad_bytes
|
||||
local f=$1 n=$2 markers=$3 pad=$4 i padtxt
|
||||
padtxt=$(head -c "$pad" < /dev/zero | tr '\0' 'x')
|
||||
{
|
||||
echo "# BOARD — LIVE"
|
||||
echo "> pinned protocol blockquote, never rolled."
|
||||
echo
|
||||
echo "## Curated always-current section (pinned)"
|
||||
echo "- this stays no matter what"
|
||||
echo
|
||||
[[ "$markers" == 1 ]] && echo '<!-- BOARD-ROLL:START -->'
|
||||
# newest first (i=n .. 1); oldest (i=1) ends at the bottom
|
||||
for (( i=n; i>=1; i-- )); do
|
||||
echo "### 2026-07-$(printf '%02d' $i) tick number $i"
|
||||
echo "- detail $i $padtxt"
|
||||
echo
|
||||
done
|
||||
[[ "$markers" == 1 ]] && echo '<!-- BOARD-ROLL:END -->'
|
||||
} > "$f"
|
||||
return 0
|
||||
}
|
||||
|
||||
# ── 1. under cap → no-op ───────────────────────────────────────────────────────
|
||||
L="$WORK/live1.md"; G="$WORK/ledger1.md"; : > "$G"
|
||||
make_board "$L" 2 1 10
|
||||
before=$(cat "$L")
|
||||
if ! out=$(bash "$SUT" --live "$L" --ledger "$G" --cap 100000 2>&1); then
|
||||
note "under-cap should exit 0 (got nonzero): $out"
|
||||
fi
|
||||
[[ "$(cat "$L")" == "$before" ]] || note "under-cap modified LIVE"
|
||||
[[ -s "$G" ]] && note "under-cap wrote to LEDGER"
|
||||
|
||||
# ── 2. over cap, no markers → exit 3, unchanged ────────────────────────────────
|
||||
L="$WORK/live2.md"; G="$WORK/ledger2.md"; : > "$G"
|
||||
make_board "$L" 6 0 400
|
||||
before=$(cat "$L")
|
||||
set +e; bash "$SUT" --live "$L" --ledger "$G" --cap 800 >/dev/null 2>&1; rc=$?; set -e
|
||||
[[ "$rc" -eq 3 ]] || note "no-markers over-cap should exit 3 (got $rc)"
|
||||
[[ "$(cat "$L")" == "$before" ]] || note "no-markers run modified LIVE (must never guess)"
|
||||
|
||||
# ── 3+4. over cap with markers → rolls oldest, LIVE under cap, LEDGER gets them ─
|
||||
L="$WORK/live3.md"; G="$WORK/ledger3.md"; echo "# LEDGER" > "$G"
|
||||
make_board "$L" 6 1 400 # 6 entries, each ~>400B
|
||||
big=$(wc -c < "$L")
|
||||
[[ "$big" -ge 2000 ]] || note "fixture too small to test rolling ($big B)"
|
||||
if ! out=$(bash "$SUT" --live "$L" --ledger "$G" --cap 2000 2>&1); then
|
||||
note "marker roll should exit 0 when it can get under cap: $out"
|
||||
fi
|
||||
after=$(wc -c < "$L")
|
||||
[[ "$after" -lt 2000 ]] || note "LIVE still >= cap after roll ($after B)"
|
||||
# pinned content survives
|
||||
grep -q "Curated always-current section" "$L" || note "roll dropped pinned section"
|
||||
grep -q 'BOARD-ROLL:START' "$L" || note "roll dropped START marker"
|
||||
grep -q 'BOARD-ROLL:END' "$L" || note "roll dropped END marker"
|
||||
# newest entry (07-06) stays; oldest (07-01) is the first to leave
|
||||
grep -q "### 2026-07-06 tick number 6" "$L" || note "roll dropped the newest entry"
|
||||
grep -q "### 2026-07-01 tick number 1" "$L" && note "oldest entry not rolled out of LIVE"
|
||||
# oldest went to LEDGER
|
||||
grep -q "### 2026-07-01 tick number 1" "$G" || note "oldest entry not appended to LEDGER"
|
||||
grep -q "board-roll:.*rolled from live3.md" "$G" || note "LEDGER missing provenance separator"
|
||||
# a rolled entry must not be duplicated (present in exactly one of LIVE/LEDGER)
|
||||
if grep -q "### 2026-07-01 tick number 1" "$L"; then note "rolled entry duplicated in LIVE"; fi
|
||||
# LEDGER original content preserved
|
||||
grep -q "^# LEDGER" "$G" || note "roll clobbered existing LEDGER content"
|
||||
|
||||
# ── 5. --dry-run changes nothing ───────────────────────────────────────────────
|
||||
L="$WORK/live5.md"; G="$WORK/ledger5.md"; echo "# LEDGER" > "$G"
|
||||
make_board "$L" 6 1 400
|
||||
before_l=$(cat "$L"); before_g=$(cat "$G")
|
||||
out=$(bash "$SUT" --live "$L" --ledger "$G" --cap 2000 --dry-run 2>&1) || note "dry-run exited nonzero: $out"
|
||||
echo "$out" | grep -qi "dry run" || note "dry-run did not announce itself"
|
||||
echo "$out" | grep -q "would roll" || note "dry-run did not report a plan"
|
||||
[[ "$(cat "$L")" == "$before_l" ]] || note "dry-run modified LIVE"
|
||||
[[ "$(cat "$G")" == "$before_g" ]] || note "dry-run modified LEDGER"
|
||||
|
||||
# ── 6. zone emptied, pinned alone over cap → exit 3 ────────────────────────────
|
||||
# cap 120 is below the pinned preamble+footer size (~180B), so even after rolling
|
||||
# every zone entry the LIVE file stays over cap → must report the unsatisfiable case.
|
||||
L="$WORK/live6.md"; G="$WORK/ledger6.md"; echo "# LEDGER" > "$G"
|
||||
make_board "$L" 3 1 50
|
||||
set +e; bash "$SUT" --live "$L" --ledger "$G" --cap 120 >/dev/null 2>&1; rc=$?; set -e
|
||||
[[ "$rc" -eq 3 ]] || note "unsatisfiable cap should exit 3 (got $rc)"
|
||||
|
||||
# ── 7. help exits 0, unknown flag exits nonzero (#701) ─────────────────────────
|
||||
if ! out=$(bash "$SUT" --help 2>&1); then note "--help exited nonzero"; fi
|
||||
[[ "$out" == Usage:* ]] || note "--help did not print usage"
|
||||
bash "$SUT" -h >/dev/null 2>&1 || note "-h exited nonzero"
|
||||
if bash "$SUT" --not-a-real-flag >/dev/null 2>&1; then note "unknown flag was accepted"; fi
|
||||
if bash "$SUT" --live "$WORK/live3.md" >/dev/null 2>&1; then note "missing --ledger was accepted"; fi
|
||||
|
||||
# ── 8. multiple marker pairs → exit 3, unchanged (no cross-zone relocation) ─────
|
||||
# Two separately-marked zones with a curated pinned section BETWEEN them. A naive
|
||||
# first-START..last-END span would sweep that curated section (and the intermediate
|
||||
# markers) into the LEDGER. board-roll must refuse (exit 3) and touch nothing.
|
||||
L="$WORK/live8.md"; G="$WORK/ledger8.md"; echo "# LEDGER" > "$G"
|
||||
pad8=$(head -c 300 < /dev/zero | tr '\0' 'x')
|
||||
{
|
||||
echo "# BOARD — LIVE"
|
||||
echo "> pinned protocol blockquote"
|
||||
echo
|
||||
echo '<!-- BOARD-ROLL:START -->'
|
||||
echo "### 2026-07-10 zone-A newest"
|
||||
echo "- detail A2 $pad8"
|
||||
echo "### 2026-07-09 zone-A oldest"
|
||||
echo "- detail A1 $pad8"
|
||||
echo '<!-- BOARD-ROLL:END -->'
|
||||
echo
|
||||
echo "## Curated-between-zones (pinned — must never move)"
|
||||
echo "- CANARY-BETWEEN keep me"
|
||||
echo
|
||||
echo '<!-- BOARD-ROLL:START -->'
|
||||
echo "### 2026-07-08 zone-B newest"
|
||||
echo "- detail B2 $pad8"
|
||||
echo "### 2026-07-07 zone-B oldest"
|
||||
echo "- detail B1 $pad8"
|
||||
echo '<!-- BOARD-ROLL:END -->'
|
||||
} > "$L"
|
||||
before8=$(cat "$L")
|
||||
set +e; bash "$SUT" --live "$L" --ledger "$G" --cap 80 >/dev/null 2>&1; rc=$?; set -e
|
||||
[[ "$rc" -eq 3 ]] || note "multi-pair board should exit 3 (got $rc)"
|
||||
[[ "$(cat "$L")" == "$before8" ]] || note "multi-pair run modified LIVE (must never guess across zones)"
|
||||
grep -q "CANARY-BETWEEN keep me" "$L" || note "multi-pair run relocated curated between-zones content"
|
||||
grep -q "CANARY-BETWEEN" "$G" && note "curated between-zones content leaked into LEDGER"
|
||||
|
||||
if [[ "$fail" -eq 0 ]]; then
|
||||
echo "board-roll regression passed (8 groups)"
|
||||
fi
|
||||
exit "$fail"
|
||||
@@ -50,6 +50,21 @@ if ! [[ "$FILE_PATH" =~ \.(ts|tsx|js|jsx|mjs|cjs)$ ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Deps preflight (#856): this hook is the common gate-entry seam the delivery
|
||||
# cycle invokes on every Edit/Write/MultiEdit — it fires before any pnpm-based
|
||||
# gate (test/lint/typecheck/format:check) runs against the edited file. In a
|
||||
# freshly created git worktree (pnpm workspaces do NOT share node_modules
|
||||
# across worktrees), node_modules/.bin is empty until `pnpm install` has run,
|
||||
# so gate binaries (tsc/eslint/prettier/vitest) fail with a raw, illegible
|
||||
# `sh: 1: <tool>: not found` that is indistinguishable from a real failure.
|
||||
# Fail legibly here instead, before that raw error has a chance to surface.
|
||||
BIN_DIR="$PROJECT_ROOT/node_modules/.bin"
|
||||
if [ ! -d "$BIN_DIR" ] || [ -z "$(ls -A "$BIN_DIR" 2>/dev/null)" ]; then
|
||||
echo "deps not installed — run pnpm install" >&2
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] [ERROR] deps not installed — run pnpm install ($BIN_DIR is missing or empty)" >> "$LOG_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Call the main QA handler with extracted parameters
|
||||
if [ -f ~/.config/mosaic/tools/qa/qa-hook-handler.sh ]; then
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Calling QA handler for $FILE_PATH" >> "$LOG_FILE"
|
||||
|
||||
116
packages/mosaic/framework/tools/qa/test-deps-preflight.sh
Executable file
116
packages/mosaic/framework/tools/qa/test-deps-preflight.sh
Executable file
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regression harness for #856: worker git-worktrees under a fresh `git worktree
|
||||
# add` have no node_modules until `pnpm install` runs (pnpm workspaces do NOT
|
||||
# share node_modules across worktrees). Before the fix, the gate-entry seam
|
||||
# (qa-hook-stdin.sh, registered as the PostToolUse hook for every Edit/Write/
|
||||
# MultiEdit in runtime/claude/settings.json) silently let a raw
|
||||
# `sh: 1: <tool>: not found` surface from any downstream gate invocation —
|
||||
# indistinguishable from a real test/lint failure (false-red).
|
||||
#
|
||||
# Asserts:
|
||||
# 1. RED (documented): a completely fresh worktree with no node_modules/.bin
|
||||
# at all produces the raw "not found" for a gate binary — this is the
|
||||
# defect the fix prevents from reaching the operator un-annotated.
|
||||
# 2. With node_modules/.bin missing entirely, the seam exits nonzero with
|
||||
# the legible sentinel "deps not installed — run pnpm install" instead
|
||||
# of silently proceeding (exit 0) into a would-be raw not-found.
|
||||
# 3. With node_modules/.bin present but empty, same legible-sentinel
|
||||
# behavior (covers `git worktree add` immediately followed by an
|
||||
# as-yet-incomplete/interrupted install).
|
||||
# 4. Once node_modules/.bin is populated (post `pnpm install`), the seam
|
||||
# proceeds normally (exit 0) — the preflight does not false-positive.
|
||||
# 5. Non-JS/TS files are unaffected (existing skip behavior preserved).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
HOOK="$SCRIPT_DIR/qa-hook-stdin.sh"
|
||||
|
||||
TMP_DIR=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP_DIR"' EXIT
|
||||
|
||||
fail=0
|
||||
|
||||
fail_msg() {
|
||||
echo "FAIL: $*" >&2
|
||||
fail=1
|
||||
}
|
||||
|
||||
run_hook() {
|
||||
local file_path="$1"
|
||||
printf '{"tool_name":"Edit","tool_input":{"file_path":"%s"}}' "$file_path" | "$HOOK"
|
||||
}
|
||||
|
||||
make_fixture_repo() {
|
||||
local dir="$1"
|
||||
mkdir -p "$dir"
|
||||
git -C "$dir" init -q .
|
||||
git -C "$dir" -c user.email=fixture@test -c user.name=fixture commit -q --allow-empty -m init
|
||||
}
|
||||
|
||||
# --- Scenario 1: RED — document the pre-fix raw not-found a gate hits when
|
||||
# node_modules/.bin is entirely absent (this is what the preflight now
|
||||
# intercepts before any gate command runs).
|
||||
RED_DIR="$TMP_DIR/red-fixture"
|
||||
make_fixture_repo "$RED_DIR"
|
||||
RED_OUTPUT=$(PATH="/usr/bin:/bin" sh -c 'tsc --noEmit' 2>&1) && RED_STATUS=0 || RED_STATUS=$?
|
||||
case "$RED_OUTPUT" in
|
||||
*"not found"*) ;;
|
||||
*) fail_msg "expected the raw un-preflighted invocation to demonstrate 'not found'; got: $RED_OUTPUT" ;;
|
||||
esac
|
||||
[[ "$RED_STATUS" -ne 0 ]] || fail_msg "expected raw invocation without deps installed to fail"
|
||||
|
||||
# --- Scenario 2: node_modules/.bin missing entirely -> legible sentinel, nonzero.
|
||||
MISSING_DIR="$TMP_DIR/missing-bin"
|
||||
make_fixture_repo "$MISSING_DIR"
|
||||
echo "console.log(1)" > "$MISSING_DIR/x.ts"
|
||||
OUTPUT=$(cd "$MISSING_DIR" && run_hook "$MISSING_DIR/x.ts" 2>&1) && STATUS=0 || STATUS=$?
|
||||
[[ "$STATUS" -ne 0 ]] || fail_msg "missing node_modules/.bin: expected nonzero exit, got 0"
|
||||
case "$OUTPUT" in
|
||||
*"deps not installed"*"pnpm install"*) ;;
|
||||
*) fail_msg "missing node_modules/.bin: expected legible sentinel, got: $OUTPUT" ;;
|
||||
esac
|
||||
|
||||
# --- Scenario 3: node_modules/.bin present but empty -> legible sentinel, nonzero.
|
||||
EMPTY_DIR="$TMP_DIR/empty-bin"
|
||||
make_fixture_repo "$EMPTY_DIR"
|
||||
mkdir -p "$EMPTY_DIR/node_modules/.bin"
|
||||
echo "console.log(1)" > "$EMPTY_DIR/x.ts"
|
||||
OUTPUT=$(cd "$EMPTY_DIR" && run_hook "$EMPTY_DIR/x.ts" 2>&1) && STATUS=0 || STATUS=$?
|
||||
[[ "$STATUS" -ne 0 ]] || fail_msg "empty node_modules/.bin: expected nonzero exit, got 0"
|
||||
case "$OUTPUT" in
|
||||
*"deps not installed"*"pnpm install"*) ;;
|
||||
*) fail_msg "empty node_modules/.bin: expected legible sentinel, got: $OUTPUT" ;;
|
||||
esac
|
||||
|
||||
# --- Scenario 4: node_modules/.bin populated (post `pnpm install`) -> proceeds normally.
|
||||
OK_DIR="$TMP_DIR/installed-bin"
|
||||
make_fixture_repo "$OK_DIR"
|
||||
mkdir -p "$OK_DIR/node_modules/.bin"
|
||||
printf '#!/bin/sh\necho ok\n' > "$OK_DIR/node_modules/.bin/tsc"
|
||||
chmod +x "$OK_DIR/node_modules/.bin/tsc"
|
||||
echo "console.log(1)" > "$OK_DIR/x.ts"
|
||||
OUTPUT=$(cd "$OK_DIR" && run_hook "$OK_DIR/x.ts" 2>&1) && STATUS=0 || STATUS=$?
|
||||
[[ "$STATUS" -eq 0 ]] || fail_msg "populated node_modules/.bin: expected exit 0, got $STATUS ($OUTPUT)"
|
||||
case "$OUTPUT" in
|
||||
*"deps not installed"*) fail_msg "populated node_modules/.bin: unexpected sentinel fired: $OUTPUT" ;;
|
||||
*) ;;
|
||||
esac
|
||||
|
||||
# --- Scenario 5: non-JS/TS files are unaffected by the preflight (still
|
||||
# skipped before the deps check, regardless of node_modules state).
|
||||
NONJS_DIR="$TMP_DIR/nonjs"
|
||||
make_fixture_repo "$NONJS_DIR"
|
||||
echo "# doc" > "$NONJS_DIR/README.md"
|
||||
OUTPUT=$(cd "$NONJS_DIR" && run_hook "$NONJS_DIR/README.md" 2>&1) && STATUS=0 || STATUS=$?
|
||||
[[ "$STATUS" -eq 0 ]] || fail_msg "non-JS/TS file: expected exit 0 (skip), got $STATUS ($OUTPUT)"
|
||||
case "$OUTPUT" in
|
||||
*"deps not installed"*) fail_msg "non-JS/TS file: preflight incorrectly fired: $OUTPUT" ;;
|
||||
*) ;;
|
||||
esac
|
||||
|
||||
if [[ "$fail" -eq 0 ]]; then
|
||||
echo "deps-preflight regression passed (5/5 scenarios)"
|
||||
fi
|
||||
|
||||
exit "$fail"
|
||||
@@ -25,7 +25,7 @@
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
|
||||
"test:framework-shell": "python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh"
|
||||
"test:framework-shell": "python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mosaicstack/brain": "workspace:*",
|
||||
|
||||
@@ -21,6 +21,7 @@ import { registerRestoreCommand } from './commands/restore.js';
|
||||
import { registerSkillCommand } from './commands/skill.js';
|
||||
// prdy is registered via launch.ts
|
||||
import { registerLaunchCommands } from './commands/launch.js';
|
||||
import { registerLeaseCapabilityProbe } from './commands/lease-activation-probe.js';
|
||||
import { registerAuthCommand } from './commands/auth.js';
|
||||
import { registerFederationCommand } from './commands/federation.js';
|
||||
import { registerGatewayCommand } from './commands/gateway.js';
|
||||
@@ -78,6 +79,10 @@ Command Groups:
|
||||
|
||||
registerLaunchCommands(program);
|
||||
|
||||
// ─── lease activation capability probe (hidden; #869 Point-1 C1) ────────
|
||||
|
||||
registerLeaseCapabilityProbe(program);
|
||||
|
||||
// ─── login ──────────────────────────────────────────────────────────────
|
||||
|
||||
program
|
||||
|
||||
@@ -806,7 +806,14 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
|
||||
process.exit(0); // Unreachable but satisfies never
|
||||
}
|
||||
|
||||
function defaultLeaseBrokerSocket(env: NodeJS.ProcessEnv = process.env): string {
|
||||
/**
|
||||
* Resolve the lease broker's control socket path. Exported (in addition to
|
||||
* being used internally by execLeaseGatedRuntime) so the C1 activation probe
|
||||
* (lease-activation-probe.ts) can perform the same resolution when checking
|
||||
* whether the broker supervisor is reachable — detection only, this never
|
||||
* connects to the socket itself.
|
||||
*/
|
||||
export function defaultLeaseBrokerSocket(env: NodeJS.ProcessEnv = process.env): string {
|
||||
if (env['MOSAIC_LEASE_BROKER_SOCKET']) return env['MOSAIC_LEASE_BROKER_SOCKET'];
|
||||
const runtimeDir = env['XDG_RUNTIME_DIR'];
|
||||
if (runtimeDir) return join(runtimeDir, 'mosaic-lease', 'broker.sock');
|
||||
@@ -895,7 +902,12 @@ function delegateToScript(scriptPath: string, args: string[], env?: Record<strin
|
||||
* bundled in the @mosaicstack/mosaic npm package (always matches the installed
|
||||
* CLI version) over the deployed copy in ~/.config/mosaic/ (may be stale).
|
||||
*/
|
||||
function resolveTool(...segments: string[]): string {
|
||||
/**
|
||||
* Exported so the C1 activation probe (lease-activation-probe.ts) can resolve
|
||||
* the same lease-broker launcher/daemon artifacts execLeaseGatedRuntime()
|
||||
* uses, for detection-only supervisor presence checks.
|
||||
*/
|
||||
export function resolveTool(...segments: string[]): string {
|
||||
try {
|
||||
const req = createRequire(import.meta.url);
|
||||
const mosaicPkg = dirname(req.resolve('@mosaicstack/mosaic/package.json'));
|
||||
|
||||
243
packages/mosaic/src/commands/lease-activation-probe.spec.ts
Normal file
243
packages/mosaic/src/commands/lease-activation-probe.spec.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Command } from 'commander';
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
LEASE_ACTIVATION_CAPABILITY,
|
||||
LEASE_CAPABILITY_PROBE_COMMAND,
|
||||
defaultCapabilityProbe,
|
||||
defaultResolveCliEntry,
|
||||
defaultSupervisorProbe,
|
||||
leaseEnforcementActivatable,
|
||||
registerLeaseCapabilityProbe,
|
||||
type LeaseActivationCapability,
|
||||
type SupervisorProbeResult,
|
||||
} from './lease-activation-probe.js';
|
||||
|
||||
/**
|
||||
* Red-first tests for issue #869 Point-1 C1 — leaseEnforcementActivatable().
|
||||
*
|
||||
* Root cause under test: #828 shipped the lease broker's ENFORCEMENT half
|
||||
* (hooks) and ACTIVATION half (execLeaseGatedRuntime + a running daemon.py
|
||||
* broker) on different channels, and they drifted — the published CLI
|
||||
* tarball lacked the activation half even though it existed in source. The
|
||||
* predicate here must say NO when either half of activation is unavailable,
|
||||
* and only YES when both are genuinely present — never based on "does the
|
||||
* source file exist", but on a real capability signal + real supervisor
|
||||
* detection.
|
||||
*/
|
||||
|
||||
const compatibleCapability: LeaseActivationCapability = { ...LEASE_ACTIVATION_CAPABILITY };
|
||||
const presentSupervisor: SupervisorProbeResult = {
|
||||
supervisorPresent: true,
|
||||
socketPath: '/run/user/1000/mosaic-lease/broker.sock',
|
||||
};
|
||||
|
||||
describe('leaseEnforcementActivatable', () => {
|
||||
it('is false when the activation capability is absent (null)', () => {
|
||||
const result = leaseEnforcementActivatable({
|
||||
getCapability: () => null,
|
||||
probeSupervisor: () => presentSupervisor,
|
||||
});
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('is false when the activation capability name does not match', () => {
|
||||
const result = leaseEnforcementActivatable({
|
||||
getCapability: () => ({
|
||||
name: 'some-other-capability',
|
||||
version: LEASE_ACTIVATION_CAPABILITY.version,
|
||||
}),
|
||||
probeSupervisor: () => presentSupervisor,
|
||||
});
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('is false when the activation capability version is incompatible (stale/newer build)', () => {
|
||||
const result = leaseEnforcementActivatable({
|
||||
getCapability: () => ({
|
||||
name: LEASE_ACTIVATION_CAPABILITY.name,
|
||||
version: LEASE_ACTIVATION_CAPABILITY.version + 1,
|
||||
}),
|
||||
probeSupervisor: () => presentSupervisor,
|
||||
});
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('is false when the supervisor artifacts (launcher/daemon) are not present', () => {
|
||||
const result = leaseEnforcementActivatable({
|
||||
getCapability: () => compatibleCapability,
|
||||
probeSupervisor: () => ({
|
||||
supervisorPresent: false,
|
||||
socketPath: presentSupervisor.socketPath,
|
||||
}),
|
||||
});
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('is false when the supervisor socket path is not resolvable', () => {
|
||||
const result = leaseEnforcementActivatable({
|
||||
getCapability: () => compatibleCapability,
|
||||
probeSupervisor: () => ({ supervisorPresent: true, socketPath: null }),
|
||||
});
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('is false when BOTH capability and supervisor are absent', () => {
|
||||
const result = leaseEnforcementActivatable({
|
||||
getCapability: () => null,
|
||||
probeSupervisor: () => ({ supervisorPresent: false, socketPath: null }),
|
||||
});
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('is true when a compatible capability AND a resolvable supervisor are both present', () => {
|
||||
const result = leaseEnforcementActivatable({
|
||||
getCapability: () => compatibleCapability,
|
||||
probeSupervisor: () => presentSupervisor,
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('uses the real default probes when no deps are injected (does not throw)', () => {
|
||||
// No live broker / built CLI is guaranteed in a test environment, so this
|
||||
// only asserts the predicate degrades to a safe boolean rather than
|
||||
// throwing — the fail-closed behavior itself is covered by the injected
|
||||
// cases above.
|
||||
expect(() => leaseEnforcementActivatable()).not.toThrow();
|
||||
expect(typeof leaseEnforcementActivatable()).toBe('boolean');
|
||||
});
|
||||
});
|
||||
|
||||
describe('defaultCapabilityProbe', () => {
|
||||
it('returns null (fail-closed) when no built CLI artifact is resolvable', () => {
|
||||
// Deterministic regardless of ambient host state (e.g. a host that has
|
||||
// already run `pnpm build`, which would otherwise make this pass or fail
|
||||
// depending on whether dist/cli.js happens to exist) — inject a resolver
|
||||
// pointing at a path that cannot exist, rather than relying on this
|
||||
// checkout being unbuilt. The probe must report "no capability" rather
|
||||
// than fabricate one from source-tree presence — this is the exact
|
||||
// distinction #828's version skew needed: source existing is not the
|
||||
// same as the published artifact advertising the capability.
|
||||
const result = defaultCapabilityProbe({
|
||||
resolveCliEntry: () => '/nonexistent/mosaic-lease-activation-probe-test/cli.js',
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
describe('positive path — injected resolver, isolated scratch dir (never the real dist/)', () => {
|
||||
// A prior version of this test staged the stub cli.js at the package's
|
||||
// REAL resolved dist/ path and relied on afterEach to clean up "only
|
||||
// what it created" — which meant a host with a real pre-built
|
||||
// dist/cli.js (ordinary `pnpm build && pnpm test`) would have its real
|
||||
// ~26KB compiled CLI silently overwritten by an 87-byte stub, with no
|
||||
// restoration of the original content. That is exactly the kind of
|
||||
// build-artifact corruption #869 exists to prevent. This version uses
|
||||
// dependency injection exclusively: defaultCapabilityProbe() is never
|
||||
// called with its default resolver here, so it can never touch the real
|
||||
// package dist/ at all — proven below by asserting that path's
|
||||
// existence is unchanged by the test.
|
||||
it('returns the real {name, version} capability from a stub cli.js in a temp dir, and leaves the real dist/ untouched', () => {
|
||||
const packageRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
const realDistDir = join(packageRoot, 'dist');
|
||||
const realDistPreexisted = existsSync(realDistDir);
|
||||
|
||||
const scratchDir = mkdtempSync(join(tmpdir(), 'mosaic-lease-capability-probe-'));
|
||||
try {
|
||||
const scratchCliPath = join(scratchDir, 'cli.js');
|
||||
// Minimal stand-in for the built CLI's hidden __lease-capability
|
||||
// subcommand — prints exactly what registerLeaseCapabilityProbe()
|
||||
// wires the real `mosaic __lease-capability` command to print.
|
||||
writeFileSync(
|
||||
scratchCliPath,
|
||||
`process.stdout.write(JSON.stringify(${JSON.stringify(LEASE_ACTIVATION_CAPABILITY)}));\n`,
|
||||
);
|
||||
|
||||
const result = defaultCapabilityProbe({ resolveCliEntry: () => scratchCliPath });
|
||||
expect(result).toEqual(LEASE_ACTIVATION_CAPABILITY);
|
||||
|
||||
// The real package dist/ must be byte-for-byte untouched: this test
|
||||
// never invokes the default resolver, so the path's mere existence
|
||||
// (created or not) must be unchanged by having run this test.
|
||||
expect(existsSync(realDistDir)).toBe(realDistPreexisted);
|
||||
} finally {
|
||||
rmSync(scratchDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('defaultResolveCliEntry', () => {
|
||||
it('resolves the bare "@mosaicstack/mosaic" specifier (the exported "." entry), never the non-exported "./package.json" subpath', () => {
|
||||
// Fully isolated from the real filesystem/package state (no dependency
|
||||
// on whether @mosaicstack/mosaic has been built on this host) via an
|
||||
// injected fake resolver that mirrors Node's real behavior: the "."
|
||||
// export resolves fine, but "./package.json" is NOT in package.json's
|
||||
// `exports` map, so real `require.resolve` throws
|
||||
// ERR_PACKAGE_PATH_NOT_EXPORTED for it. This is genuinely red-first
|
||||
// against the reviewer-found bug: the old implementation resolved the
|
||||
// "./package.json" subpath here, which this fake throws on — the new
|
||||
// implementation must resolve only the bare specifier.
|
||||
const requestedSpecifiers: string[] = [];
|
||||
const fakeResolve = (specifier: string): string => {
|
||||
requestedSpecifiers.push(specifier);
|
||||
if (specifier === '@mosaicstack/mosaic') return '/fake/pkg/dist/index.js';
|
||||
throw new Error(`ERR_PACKAGE_PATH_NOT_EXPORTED: ${specifier}`);
|
||||
};
|
||||
|
||||
const result = defaultResolveCliEntry(fakeResolve);
|
||||
|
||||
expect(result).toBe(join('/fake/pkg/dist', 'cli.js'));
|
||||
expect(requestedSpecifiers).toEqual(['@mosaicstack/mosaic']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('defaultSupervisorProbe', () => {
|
||||
it('returns a well-shaped result without starting or connecting to anything', () => {
|
||||
const result = defaultSupervisorProbe({});
|
||||
expect(typeof result.supervisorPresent).toBe('boolean');
|
||||
expect(result.socketPath === null || typeof result.socketPath === 'string').toBe(true);
|
||||
});
|
||||
|
||||
it('resolves a socket path from an explicit MOSAIC_LEASE_BROKER_SOCKET override', () => {
|
||||
const result = defaultSupervisorProbe({ MOSAIC_LEASE_BROKER_SOCKET: '/tmp/explicit.sock' });
|
||||
expect(result.socketPath).toBe('/tmp/explicit.sock');
|
||||
});
|
||||
});
|
||||
|
||||
describe('registerLeaseCapabilityProbe', () => {
|
||||
it('registers a hidden subcommand named __lease-capability', () => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerLeaseCapabilityProbe(program);
|
||||
|
||||
const registered = program.commands.find((c) => c.name() === LEASE_CAPABILITY_PROBE_COMMAND);
|
||||
expect(registered).toBeDefined();
|
||||
// Commander exposes "hidden" only as help-output suppression (no public
|
||||
// getter) — assert the observable behavior instead of a private field.
|
||||
expect(program.helpInformation()).not.toContain(LEASE_CAPABILITY_PROBE_COMMAND);
|
||||
});
|
||||
|
||||
it('prints the capability constant as JSON when invoked', () => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerLeaseCapabilityProbe(program);
|
||||
|
||||
let written = '';
|
||||
const originalWrite = process.stdout.write.bind(process.stdout);
|
||||
process.stdout.write = ((chunk: string) => {
|
||||
written += chunk;
|
||||
return true;
|
||||
}) as typeof process.stdout.write;
|
||||
|
||||
try {
|
||||
program.parse(['node', 'mosaic', LEASE_CAPABILITY_PROBE_COMMAND]);
|
||||
} finally {
|
||||
process.stdout.write = originalWrite;
|
||||
}
|
||||
|
||||
expect(JSON.parse(written)).toEqual(LEASE_ACTIVATION_CAPABILITY);
|
||||
});
|
||||
});
|
||||
232
packages/mosaic/src/commands/lease-activation-probe.ts
Normal file
232
packages/mosaic/src/commands/lease-activation-probe.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Lease-enforcement activation probe (issue #869, Point-1 card C1).
|
||||
*
|
||||
* Root cause this exists to guard against (#828 version skew): the
|
||||
* ENFORCEMENT half of the lease broker (PreToolUse/Stop hooks —
|
||||
* `mutator-gate.py`, `receipt-observer-client.py` — wired via the framework
|
||||
* reseed) and the ACTIVATION half (`execLeaseGatedRuntime()` in `launch.ts`,
|
||||
* which chains the runtime through `launch-runtime.py`, injects
|
||||
* `MOSAIC_LEASE_*`, and requires a running `daemon.py` broker) ship on
|
||||
* different channels. When the published CLI tarball lags behind an
|
||||
* enforcement reseed, the gate correctly fails CLOSED on absent identity —
|
||||
* but every tool call then denies with GATE_UNAVAILABLE. That fail-closed
|
||||
* behavior is intentional and must not change (see the C-REGRESS note in
|
||||
* `runtime_tools_unittest.py`); this module exists so a downstream
|
||||
* install-ordering guard (C2, out of scope here) can refuse to WIRE
|
||||
* enforcement in the first place on a host that cannot ACTIVATE it.
|
||||
*
|
||||
* `leaseEnforcementActivatable()` answers one narrow question: "if
|
||||
* enforcement were wired right now, could activation actually satisfy it?"
|
||||
* It is a real capability probe — not a "does the source file exist" check
|
||||
* — and both of its inputs are injectable so tests can drive every branch
|
||||
* without a live broker or an installed CLI on PATH.
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { dirname, join } from 'node:path';
|
||||
import type { Command } from 'commander';
|
||||
import { defaultLeaseBrokerSocket, resolveTool } from './launch.js';
|
||||
|
||||
// ─── Capability signal (owned by the activation half) ──────────────────────
|
||||
|
||||
/**
|
||||
* Versioned identity for the activation contract `execLeaseGatedRuntime()`
|
||||
* implements. OWNED by the activation half of the lease broker. Bump
|
||||
* `version` only when the activation contract itself changes (env vars
|
||||
* injected, chaining behavior, socket protocol, etc.) — deliberately
|
||||
* independent of the package's npm semver, because #828 happened precisely
|
||||
* because the npm version was NOT bumped even though the shipped artifact
|
||||
* fell out of sync. A build that cannot advertise this exact
|
||||
* `{ name, version }` pair does not implement the contract a caller is
|
||||
* relying on, whatever its package.json claims.
|
||||
*/
|
||||
export interface LeaseActivationCapability {
|
||||
readonly name: string;
|
||||
readonly version: number;
|
||||
}
|
||||
|
||||
export const LEASE_ACTIVATION_CAPABILITY: LeaseActivationCapability = {
|
||||
name: 'lease-runtime-activation',
|
||||
version: 1,
|
||||
};
|
||||
|
||||
/** Hidden CLI probe subcommand name — wired via {@link registerLeaseCapabilityProbe}. */
|
||||
export const LEASE_CAPABILITY_PROBE_COMMAND = '__lease-capability';
|
||||
|
||||
function capabilityMatches(candidate: LeaseActivationCapability | null): boolean {
|
||||
return (
|
||||
candidate !== null &&
|
||||
candidate.name === LEASE_ACTIVATION_CAPABILITY.name &&
|
||||
candidate.version === LEASE_ACTIVATION_CAPABILITY.version
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the hidden `__lease-capability` probe subcommand. Prints the
|
||||
* capability this BUILD advertises as compact JSON to stdout and exits 0.
|
||||
* Deliberately undocumented (hidden from `--help`): it is an internal signal
|
||||
* for {@link defaultCapabilityProbe}, not a user-facing command.
|
||||
*/
|
||||
export function registerLeaseCapabilityProbe(program: Command): void {
|
||||
program
|
||||
.command(LEASE_CAPABILITY_PROBE_COMMAND, { hidden: true })
|
||||
.description('Internal: print the lease-activation capability this build advertises')
|
||||
.action(() => {
|
||||
process.stdout.write(JSON.stringify(LEASE_ACTIVATION_CAPABILITY));
|
||||
});
|
||||
}
|
||||
|
||||
/** Injectable Node module resolver — matches `require.resolve`'s signature
|
||||
* narrowly (specifier in, absolute path out, or throws). Defaults to the
|
||||
* real `createRequire(import.meta.url).resolve`. Injectable so tests can
|
||||
* exercise WHICH specifier {@link defaultResolveCliEntry} resolves (the
|
||||
* reviewer-found bug was resolving the wrong one) without depending on
|
||||
* whether `@mosaicstack/mosaic` has actually been built on the test host —
|
||||
* and without ever touching the real package's `dist/` to find out. */
|
||||
export type ModuleResolver = (specifier: string) => string;
|
||||
|
||||
/**
|
||||
* Resolve the CLI's built entrypoint (`dist/cli.js`). Resolves via the
|
||||
* package's "." export (already present in package.json's `exports` map)
|
||||
* rather than a "./package.json" subpath — the latter is NOT exported, so
|
||||
* `require.resolve('@mosaicstack/mosaic/package.json')` throws
|
||||
* ERR_PACKAGE_PATH_NOT_EXPORTED on every real install. The "." export
|
||||
* resolves to `dist/index.js`; `cli.js` is its sibling in the same built
|
||||
* `dist/` directory (see package.json's `bin.mosaic`).
|
||||
*
|
||||
* Exported standalone (and injectable via {@link CapabilityProbeDeps}) so
|
||||
* tests can exercise this resolution logic in isolation, or point
|
||||
* {@link defaultCapabilityProbe} at a scratch directory instead of ever
|
||||
* touching the real installed package's `dist/` — a test corrupting a real
|
||||
* build artifact is exactly the artifact-integrity failure class this card
|
||||
* exists to prevent (#828).
|
||||
*/
|
||||
export function defaultResolveCliEntry(
|
||||
resolve: ModuleResolver = createRequire(import.meta.url).resolve,
|
||||
): string {
|
||||
const mainEntry = resolve('@mosaicstack/mosaic');
|
||||
return join(dirname(mainEntry), 'cli.js');
|
||||
}
|
||||
|
||||
/** Injectable inputs for {@link defaultCapabilityProbe}. */
|
||||
export interface CapabilityProbeDeps {
|
||||
/** Resolve the CLI entrypoint (`cli.js`) to probe. Defaults to
|
||||
* {@link defaultResolveCliEntry}. Inject to point at an isolated scratch
|
||||
* location in tests — never at the real package's `dist/`. */
|
||||
resolveCliEntry?: () => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Real capability lookup. Resolves the installed `@mosaicstack/mosaic`
|
||||
* package's BUILT entrypoint (`dist/cli.js` — the published artifact a user
|
||||
* actually runs, not this TypeScript source file) and executes its hidden
|
||||
* `__lease-capability` probe subcommand out-of-process. A build that lacks
|
||||
* the subcommand, fails to execute, or reports an incompatible
|
||||
* `{ name, version }` is treated as having NO activation capability.
|
||||
*
|
||||
* This is the check that would have caught #828's version skew: the
|
||||
* source-tree activation half existed, but the published tarball's `dist/`
|
||||
* did not carry it, so this probe — reading the actually-resolvable built
|
||||
* artifact rather than trusting source-tree presence — would report null.
|
||||
*/
|
||||
export function defaultCapabilityProbe(
|
||||
deps: CapabilityProbeDeps = {},
|
||||
): LeaseActivationCapability | null {
|
||||
try {
|
||||
const resolveCliEntry = deps.resolveCliEntry ?? defaultResolveCliEntry;
|
||||
const cliEntry = resolveCliEntry();
|
||||
if (!existsSync(cliEntry)) return null;
|
||||
|
||||
const output = execFileSync(process.execPath, [cliEntry, LEASE_CAPABILITY_PROBE_COMMAND], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 2000,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
});
|
||||
|
||||
const parsed: unknown = JSON.parse(output);
|
||||
if (
|
||||
typeof parsed !== 'object' ||
|
||||
parsed === null ||
|
||||
typeof (parsed as Record<string, unknown>)['name'] !== 'string' ||
|
||||
typeof (parsed as Record<string, unknown>)['version'] !== 'number'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const candidate = parsed as { name: string; version: number };
|
||||
return { name: candidate.name, version: candidate.version };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Supervisor / socket resolution (detection only) ───────────────────────
|
||||
|
||||
/** Detection-only supervisor/socket probe result. Never starts the broker
|
||||
* and never connects to the socket — presence and path resolution only. */
|
||||
export interface SupervisorProbeResult {
|
||||
/** The lease-broker supervisor artifacts (launcher + daemon) are present. */
|
||||
readonly supervisorPresent: boolean;
|
||||
/** Resolved broker socket path, or null if it could not be resolved. */
|
||||
readonly socketPath: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Real supervisor/socket resolution: checks that the lease-broker's launcher
|
||||
* (`launch-runtime.py`) and supervisor (`daemon.py`) artifacts resolve on
|
||||
* disk via the same tool-resolution `execLeaseGatedRuntime()` uses, and that
|
||||
* a broker socket path resolves via the same logic as
|
||||
* `defaultLeaseBrokerSocket()`. Detection only — this never starts the
|
||||
* daemon and never connects to the socket.
|
||||
*/
|
||||
export function defaultSupervisorProbe(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): SupervisorProbeResult {
|
||||
const launcherPath = resolveTool('lease-broker', 'launch-runtime.py');
|
||||
const daemonPath = resolveTool('lease-broker', 'daemon.py');
|
||||
const supervisorPresent = existsSync(launcherPath) && existsSync(daemonPath);
|
||||
|
||||
let socketPath: string | null = null;
|
||||
try {
|
||||
const resolved = defaultLeaseBrokerSocket(env);
|
||||
socketPath = resolved.trim().length > 0 ? resolved : null;
|
||||
} catch {
|
||||
socketPath = null;
|
||||
}
|
||||
|
||||
return { supervisorPresent, socketPath };
|
||||
}
|
||||
|
||||
// ─── Predicate ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Injectable inputs for {@link leaseEnforcementActivatable}, so tests (and
|
||||
* downstream callers such as the C2 install-ordering guard) can drive every
|
||||
* branch without a live broker or an installed CLI on PATH. */
|
||||
export interface ActivationProbeDeps {
|
||||
getCapability?: () => LeaseActivationCapability | null;
|
||||
probeSupervisor?: () => SupervisorProbeResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* True IFF lease enforcement can actually be ACTIVATED on this host:
|
||||
*
|
||||
* (a) the resolvable CLI advertises a {@link LeaseActivationCapability}
|
||||
* compatible with {@link LEASE_ACTIVATION_CAPABILITY}, AND
|
||||
* (b) the broker supervisor is resolvable — launcher + `daemon.py`
|
||||
* artifacts present AND a broker socket path resolves.
|
||||
*
|
||||
* Pure/testable: both probes default to the real, side-effect-free lookups
|
||||
* above but can be injected, so this predicate never itself starts a broker
|
||||
* or performs enforcement — it only reports whether activation *could*
|
||||
* satisfy enforcement if wired.
|
||||
*/
|
||||
export function leaseEnforcementActivatable(deps: ActivationProbeDeps = {}): boolean {
|
||||
const getCapability = deps.getCapability ?? defaultCapabilityProbe;
|
||||
const probeSupervisor = deps.probeSupervisor ?? defaultSupervisorProbe;
|
||||
|
||||
if (!capabilityMatches(getCapability())) return false;
|
||||
|
||||
const supervisor = probeSupervisor();
|
||||
return supervisor.supervisorPresent && supervisor.socketPath !== null;
|
||||
}
|
||||
@@ -31,17 +31,26 @@ PI_EXTENSION = FRAMEWORK / "runtime/pi/mosaic-extension.ts"
|
||||
|
||||
|
||||
def request(socket_path: Path, value: dict[str, object]) -> dict[str, object]:
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
|
||||
connection.settimeout(3.0)
|
||||
connection.connect(str(socket_path))
|
||||
connection.sendall((json.dumps(value, separators=(",", ":")) + "\n").encode())
|
||||
connection.shutdown(socket.SHUT_WR)
|
||||
response = bytearray()
|
||||
while True:
|
||||
chunk = connection.recv(4096)
|
||||
if not chunk:
|
||||
break
|
||||
response.extend(chunk)
|
||||
deadline = time.monotonic() + 5.0
|
||||
while True:
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
|
||||
connection.settimeout(3.0)
|
||||
try:
|
||||
connection.connect(str(socket_path))
|
||||
except ConnectionRefusedError:
|
||||
if time.monotonic() >= deadline:
|
||||
raise
|
||||
time.sleep(0.02)
|
||||
continue
|
||||
connection.sendall((json.dumps(value, separators=(",", ":")) + "\n").encode())
|
||||
connection.shutdown(socket.SHUT_WR)
|
||||
response = bytearray()
|
||||
while True:
|
||||
chunk = connection.recv(4096)
|
||||
if not chunk:
|
||||
break
|
||||
response.extend(chunk)
|
||||
break
|
||||
if not response.endswith(b"\n") or response.count(b"\n") != 1:
|
||||
raise AssertionError(f"unframed broker response: {bytes(response)!r}")
|
||||
reply = json.loads(response[:-1])
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* C-REGRESS (issue #869, Point-1) — proves the fail-closed gate is untouched
|
||||
* by the C1 activation probe added alongside this test.
|
||||
*
|
||||
* `mutator-gate.py`'s fail-closed-on-absent-identity behavior is INTENTIONAL
|
||||
* and TEST-LOCKED: #869 C1 gates the WIRING decision for enforcement (via
|
||||
* `leaseEnforcementActivatable()`), it does not — and must not — touch the
|
||||
* gate's own runtime denial behavior. This spec runs the two test-locked
|
||||
* cases from `runtime_tools_unittest.py` directly (rather than merely
|
||||
* re-asserting the same logic in TypeScript) so a regression in the actual
|
||||
* Python gate is caught here too, not just documented in prose.
|
||||
*/
|
||||
|
||||
const MUTATOR_GATE_DIR = new URL('.', import.meta.url).pathname;
|
||||
const UNITTEST_FILE = join(MUTATOR_GATE_DIR, 'runtime_tools_unittest.py');
|
||||
|
||||
const LOCKED_TEST_CASES = [
|
||||
'ExecutableEntrypointTest.test_gate_entrypoint_denies_when_identity_environment_is_absent',
|
||||
'MutatorGateTest.test_environment_generation_and_request_failures_deny',
|
||||
] as const;
|
||||
|
||||
describe('mutator-gate fail-closed behavior (C-REGRESS, unchanged by #869 C1)', () => {
|
||||
it.each(LOCKED_TEST_CASES)('%s still passes', (testCase) => {
|
||||
const result = spawnSync('python3', ['-m', 'unittest', `${moduleName()}.${testCase}`, '-v'], {
|
||||
cwd: MUTATOR_GATE_DIR,
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
|
||||
expect(result.status, `stderr:\n${result.stderr}`).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
function moduleName(): string {
|
||||
// runtime_tools_unittest.py, addressed as a bare module name for `python3 -m unittest`.
|
||||
return UNITTEST_FILE.split('/').pop()!.replace(/\.py$/, '');
|
||||
}
|
||||
@@ -661,13 +661,27 @@ describe('whole mutator-class lease gate', () => {
|
||||
test('observer revocation and monotonic TTL expiry deny the next mutator', async () => {
|
||||
const { socket } = await startBroker();
|
||||
const sessionId = await register(socket);
|
||||
const pending = await beginVerification(socket, sessionId, 'claude', 1, 1);
|
||||
await promote(socket, sessionId, pending.receipt_challenge!);
|
||||
|
||||
// Establish the lease with a normal (non-racing) TTL first and prove it
|
||||
// authorizes. This "still valid" check is setup, not a TTL-expiry
|
||||
// assertion, so it must not share a lease with a 1-second TTL: on a
|
||||
// contended push-CI host, scheduling delay alone between promote() and
|
||||
// this authorize() call can consume that entire 1-second margin and
|
||||
// spuriously deny it (CI#1945). Using a generous TTL here removes that
|
||||
// real-time race without touching lease-gate security semantics.
|
||||
const pending = await beginVerification(socket, sessionId, 'claude');
|
||||
await promote(socket, sessionId, pending.receipt_challenge!);
|
||||
expect(await authorize(socket, sessionId, 'claude', 'Bash')).toMatchObject({
|
||||
ok: true,
|
||||
decision: 'allow',
|
||||
});
|
||||
|
||||
// A dedicated, isolated short-TTL lease drives the deliberate monotonic
|
||||
// expiry demonstration below. It is never used for anything but the
|
||||
// wait-then-expire assertion, so there is no setup work racing its
|
||||
// 1-second window.
|
||||
const shortLived = await beginVerification(socket, sessionId, 'claude', 1, 1, 2);
|
||||
await promote(socket, sessionId, shortLived.receipt_challenge!);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_100));
|
||||
expect(await authorize(socket, sessionId, 'claude', 'Bash')).toMatchObject({
|
||||
ok: false,
|
||||
@@ -675,7 +689,7 @@ describe('whole mutator-class lease gate', () => {
|
||||
decision: 'deny',
|
||||
});
|
||||
|
||||
const refreshed = await beginVerification(socket, sessionId, 'claude', 1, 300, 2);
|
||||
const refreshed = await beginVerification(socket, sessionId, 'claude', 1, 300, 3);
|
||||
await promote(socket, sessionId, refreshed.receipt_challenge!);
|
||||
expect(
|
||||
await request(socket, {
|
||||
|
||||
@@ -217,12 +217,22 @@ git fetch origin
|
||||
mkdir -p ~/src/${projectName}-worktrees
|
||||
git worktree add ~/src/${projectName}-worktrees/<task-slug> -b <branch-name> origin/main
|
||||
cd ~/src/${projectName}-worktrees/<task-slug>
|
||||
pnpm install --frozen-lockfile --prefer-offline
|
||||
# ... all work happens here ...
|
||||
git push origin <branch-name>
|
||||
cd ~/src/${projectName} && git worktree remove ~/src/${projectName}-worktrees/<task-slug>
|
||||
\`\`\`
|
||||
|
||||
Worktrees path: \`~/src/<repo>-worktrees/<task-slug>\` — NEVER use /tmp.`);
|
||||
Worktrees path: \`~/src/<repo>-worktrees/<task-slug>\` — NEVER use /tmp.
|
||||
|
||||
\`pnpm install --frozen-lockfile --prefer-offline\` MUST run immediately after
|
||||
\`git worktree add\`/\`cd\`, BEFORE any gate (\`pnpm test\`/\`lint\`/\`typecheck\`/\`format:check\`)
|
||||
is invoked. pnpm workspaces do NOT share \`node_modules\` across separate git
|
||||
worktrees — a fresh worktree has an empty \`node_modules/.bin\`, so every gate
|
||||
binary (\`tsc\`/\`eslint\`/\`prettier\`/\`vitest\`) fails \`sh: 1: <tool>: not found\`
|
||||
until deps are installed. That failure is indistinguishable from a real
|
||||
test/lint failure — a false-red gate. Never skip this step and never reorder
|
||||
it after the first gate invocation.`);
|
||||
|
||||
// 6. Completion gates
|
||||
sections.push(`# Completion Gates — ENFORCED
|
||||
|
||||
50
skills/glpi-create/SKILL.md
Normal file
50
skills/glpi-create/SKILL.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# Skill: glpi-create — Open a New GLPI Ticket
|
||||
|
||||
> Create a new GLPI helpdesk ticket. Mutates GLPI — confirm the details before running.
|
||||
|
||||
## When to use
|
||||
|
||||
- Logging a new incident or request that should live in the helpdesk queue.
|
||||
|
||||
## Required information
|
||||
|
||||
- **title** — short subject line.
|
||||
- **content** — description of the issue / request.
|
||||
|
||||
## Optional
|
||||
|
||||
- **priority** — `1`=VeryLow, `2`=Low, `3`=Medium (default), `4`=High, `5`=VeryHigh, `6`=Major.
|
||||
- **type** — `1`=Incident (default), `2`=Request.
|
||||
|
||||
## Command
|
||||
|
||||
Wraps the existing tooling:
|
||||
|
||||
```bash
|
||||
~/.config/mosaic/tools/glpi/ticket-create.sh \
|
||||
-t "<title>" \
|
||||
-c "<content>" \
|
||||
[-p <priority>] \
|
||||
[-y <type>] \
|
||||
[-f json]
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
~/.config/mosaic/tools/glpi/ticket-create.sh \
|
||||
-t "Paint-area camera install" \
|
||||
-c "Ordered 2 cameras for Paint and stock; schedule mounting + NVR config." \
|
||||
-p 3 -y 2
|
||||
```
|
||||
|
||||
## After creating
|
||||
|
||||
- Note the returned **ticket ID** — you'll need it for **[[glpi-followup]]** and
|
||||
**[[glpi-solve]]**.
|
||||
- If it should also be tracked as brain work, add a matching task (see the `add-task` skill).
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Confirm title/content/priority with the user before creating — a ticket is outward-facing.
|
||||
- Never echo GLPI tokens.
|
||||
56
skills/glpi-followup/SKILL.md
Normal file
56
skills/glpi-followup/SKILL.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# Skill: glpi-followup — Add a Followup to a GLPI Ticket
|
||||
|
||||
> Post a followup (comment / progress note / resolution writeup) to a GLPI ticket.
|
||||
> This documents work but does **not** change the ticket status — to close a ticket
|
||||
> out, follow with **[[glpi-solve]]** to set status to Solved.
|
||||
|
||||
## When to use
|
||||
|
||||
- Recording progress, a decision, or a root-cause/resolution note on a ticket.
|
||||
- The documentation step that usually precedes closing a ticket out (`glpi-solve`).
|
||||
|
||||
## Critical quirk
|
||||
|
||||
Use the **top-level `/ITILFollowup` endpoint**, NOT `/Ticket/<id>/ITILFollowup`. The
|
||||
sub-resource path returns permission errors even with a Super-Admin profile.
|
||||
|
||||
## Procedure
|
||||
|
||||
### 1. Session + creds
|
||||
|
||||
```bash
|
||||
SESSION=$(~/.config/mosaic/tools/glpi/session-init.sh -q)
|
||||
source ~/.config/mosaic/tools/_lib/credentials.sh && load_credentials glpi
|
||||
```
|
||||
|
||||
### 2. Post the followup
|
||||
|
||||
```bash
|
||||
TICKET_ID=<id>
|
||||
CONTENT="<the followup text>"
|
||||
curl -sk -X POST "${GLPI_URL}/ITILFollowup" \
|
||||
-H "App-Token: $GLPI_APP_TOKEN" \
|
||||
-H "Session-Token: $SESSION" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(jq -n --argjson id "$TICKET_ID" --arg c "$CONTENT" \
|
||||
'{input:{itemtype:"Ticket", items_id:$id, content:$c}}')"
|
||||
```
|
||||
|
||||
Expect HTTP 201. Building the payload with `jq` keeps quotes/newlines in the content safe.
|
||||
|
||||
### 3. Long or multi-paragraph content
|
||||
|
||||
Write the note to a file first, then read it into the payload:
|
||||
|
||||
```bash
|
||||
curl -sk -X POST "${GLPI_URL}/ITILFollowup" \
|
||||
-H "App-Token: $GLPI_APP_TOKEN" -H "Session-Token: $SESSION" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(jq -n --argjson id "$TICKET_ID" --rawfile c /path/to/note.md \
|
||||
'{input:{itemtype:"Ticket", items_id:$id, content:$c}}')"
|
||||
```
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Never echo the GLPI app/user/session tokens.
|
||||
- A followup alone leaves the ticket open. If the work is done, run **[[glpi-solve]]** next.
|
||||
57
skills/glpi-list/SKILL.md
Normal file
57
skills/glpi-list/SKILL.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# Skill: glpi-list — Query GLPI Tickets
|
||||
|
||||
> Quick lookups of GLPI helpdesk tickets by status or recency. Read-only.
|
||||
|
||||
## When to use
|
||||
|
||||
- "What tickets are open / pending?" · "Show recent tickets" · finding a ticket ID
|
||||
before running **[[glpi-followup]]** or **[[glpi-solve]]**.
|
||||
|
||||
## Command
|
||||
|
||||
Wraps the existing tooling:
|
||||
|
||||
```bash
|
||||
GLPI=~/.config/mosaic/tools/glpi
|
||||
|
||||
# Most recent tickets (default 50, newest first)
|
||||
"$GLPI/ticket-list.sh"
|
||||
|
||||
# Filter by status: new | processing | pending | solved | closed
|
||||
"$GLPI/ticket-list.sh" -s pending
|
||||
|
||||
# JSON output (for parsing / piping to jq) and a custom limit
|
||||
"$GLPI/ticket-list.sh" -s processing -f json -l 20
|
||||
```
|
||||
|
||||
Status IDs: 1 New · 2/3 Processing · 4 Pending · 5 Solved · 6 Closed.
|
||||
|
||||
## Details lookup for one ticket
|
||||
|
||||
When you have an ID and want the full record:
|
||||
|
||||
```bash
|
||||
SESSION=$(~/.config/mosaic/tools/glpi/session-init.sh -q)
|
||||
source ~/.config/mosaic/tools/_lib/credentials.sh && load_credentials glpi
|
||||
curl -sk "${GLPI_URL}/Ticket/<id>?expand_dropdowns=true" \
|
||||
-H "App-Token: $GLPI_APP_TOKEN" -H "Session-Token: $SESSION" \
|
||||
| jq '{id, name, status, date, date_mod}'
|
||||
|
||||
# Followups on a ticket
|
||||
curl -sk "${GLPI_URL}/Ticket/<id>/ITILFollowup" \
|
||||
-H "App-Token: $GLPI_APP_TOKEN" -H "Session-Token: $SESSION" \
|
||||
| jq '.[] | {date, content}'
|
||||
```
|
||||
|
||||
(Reading followups via the sub-resource is fine — only _creating_ them requires the
|
||||
top-level `/ITILFollowup` endpoint. See **[[glpi-followup]]**.)
|
||||
|
||||
## Present to user
|
||||
|
||||
Group by status, one line per ticket: `#<id> · <title> · <status> · <last-modified>`.
|
||||
Use neutral phrasing — no "OVERDUE"/"URGENT".
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Read-only. Never echo GLPI tokens.
|
||||
- To sync tickets into brain data instead, use `python tools/sync_glpi.py` (not this skill).
|
||||
96
skills/glpi-solve/SKILL.md
Normal file
96
skills/glpi-solve/SKILL.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# Skill: glpi-solve — Close Out a GLPI Ticket
|
||||
|
||||
> Properly close out a completed GLPI helpdesk ticket. Completing the work is not
|
||||
> enough — the ticket **status must be set to "Solved"**, which is what triggers
|
||||
> GLPI's config-driven auto-close. Posting a resolution followup documents the work
|
||||
> but does **not** change status, so a ticket left at Solved-less status stays open.
|
||||
|
||||
## When to use
|
||||
|
||||
- Any time work on a GLPI ticket is finished and it should be closed out.
|
||||
- After posting a root-cause / resolution writeup as an `/ITILFollowup`.
|
||||
- During a cleanup sweep of tickets that are done in reality but still open in GLPI.
|
||||
|
||||
## The rule (from an operator, 2026-07-20)
|
||||
|
||||
**"Solved" is the correct terminal state to set — not "Closed."** GLPI is configured
|
||||
to auto-close Solved tickets after its delay. If you only post a followup and never set
|
||||
status, the ticket sits open (this bit us on a real incident where resolution followups
|
||||
were posted but status was never advanced, leaving tickets open, which the operator had
|
||||
to mark Solved by hand).
|
||||
|
||||
Close-out = **followup (optional but preferred) + set status to Solved.**
|
||||
|
||||
## GLPI status IDs
|
||||
|
||||
| ID | Status | |
|
||||
| ----- | --------------------- | -------------------------------------------- |
|
||||
| 1 | New | |
|
||||
| 2 | Processing (assigned) | |
|
||||
| 3 | Processing (planned) | |
|
||||
| 4 | Pending / Waiting | |
|
||||
| **5** | **Solved** | ← set this on close-out |
|
||||
| 6 | Closed | ← happens automatically; do not set manually |
|
||||
|
||||
## Procedure
|
||||
|
||||
### 1. Get a session token
|
||||
|
||||
```bash
|
||||
SESSION=$(~/.config/mosaic/tools/glpi/session-init.sh -q)
|
||||
source ~/.config/mosaic/tools/_lib/credentials.sh && load_credentials glpi
|
||||
```
|
||||
|
||||
### 2. (Preferred) Post the resolution followup
|
||||
|
||||
Use the **top-level `/ITILFollowup` endpoint** — the `/Ticket/<id>/ITILFollowup`
|
||||
sub-resource returns permission errors even as Super-Admin (known GLPI quirk).
|
||||
|
||||
```bash
|
||||
TICKET_ID=<id>
|
||||
curl -sk -X POST "${GLPI_URL}/ITILFollowup" \
|
||||
-H "App-Token: $GLPI_APP_TOKEN" \
|
||||
-H "Session-Token: $SESSION" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"input\":{\"itemtype\":\"Ticket\",\"items_id\":${TICKET_ID},\"content\":\"<resolution summary>\"}}"
|
||||
```
|
||||
|
||||
### 3. Set status to Solved (the step that actually closes it out)
|
||||
|
||||
```bash
|
||||
curl -sk -X PUT "${GLPI_URL}/Ticket/${TICKET_ID}" \
|
||||
-H "App-Token: $GLPI_APP_TOKEN" \
|
||||
-H "Session-Token: $SESSION" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"input\":{\"id\":${TICKET_ID},\"status\":5}}"
|
||||
```
|
||||
|
||||
Expect HTTP 200/201. GLPI will auto-close it later per its config — leave status at 5.
|
||||
|
||||
### 4. Verify
|
||||
|
||||
```bash
|
||||
curl -sk "${GLPI_URL}/Ticket/${TICKET_ID}?expand_dropdowns=true" \
|
||||
-H "App-Token: $GLPI_APP_TOKEN" -H "Session-Token: $SESSION" \
|
||||
| jq '{id, name, status}'
|
||||
```
|
||||
|
||||
`status` should read `Solved` (or `5`).
|
||||
|
||||
## Optional: sweep for done-but-open tickets
|
||||
|
||||
List tickets still open (New/Processing/Pending) to spot ones whose work is actually
|
||||
finished but were never marked Solved:
|
||||
|
||||
```bash
|
||||
~/.config/mosaic/tools/glpi/ticket-list.sh -s processing -f table
|
||||
~/.config/mosaic/tools/glpi/ticket-list.sh -s pending -f table
|
||||
```
|
||||
|
||||
Review each; for any that are genuinely resolved, run steps 2–3.
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Read-only until you intend to close — confirm the ticket is actually done first.
|
||||
- Never echo the GLPI app/user/session tokens.
|
||||
- Set **Solved (5)**, never Closed (6) — auto-close owns that transition.
|
||||
62
skills/glpi-sweep/SKILL.md
Normal file
62
skills/glpi-sweep/SKILL.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# Skill: glpi-sweep — Find Done-But-Open Tickets
|
||||
|
||||
> Read-only sweep for tickets that are finished in reality but still sitting open in
|
||||
> GLPI (never moved to Solved). Surfaces the exact miss an operator caught on 2026-07-20
|
||||
> (a real incident where an affected ticket had resolution followups posted but was left
|
||||
> open). For each one that's genuinely done, close it out with **[[glpi-solve]]**.
|
||||
|
||||
## When to use
|
||||
|
||||
- Periodic hygiene pass (e.g. before a weekly update or month-end).
|
||||
- After a burst of ticket work, to catch any you resolved-in-followup but never Solved.
|
||||
|
||||
## Why this exists
|
||||
|
||||
Posting an `/ITILFollowup` documents work but does **not** change status. Tickets only
|
||||
auto-close once set to **Solved (status 5)**. Anything left at New/Processing/Pending
|
||||
stays open indefinitely. This sweep finds those.
|
||||
|
||||
## Procedure
|
||||
|
||||
### 1. List still-open tickets by status
|
||||
|
||||
```bash
|
||||
GLPI=~/.config/mosaic/tools/glpi
|
||||
"$GLPI/ticket-list.sh" -s new -f table
|
||||
"$GLPI/ticket-list.sh" -s processing -f table
|
||||
"$GLPI/ticket-list.sh" -s pending -f table
|
||||
```
|
||||
|
||||
(GLPI status IDs: 1 New · 2/3 Processing · 4 Pending · 5 Solved · 6 Closed.)
|
||||
|
||||
### 2. Triage
|
||||
|
||||
For each open ticket, judge whether the underlying work is actually finished — check
|
||||
its latest followups and cross-reference brain tasks / recent work. Read-only here;
|
||||
change nothing yet.
|
||||
|
||||
Reasonable "probably done" signals:
|
||||
|
||||
- A resolution/root-cause followup already posted, but status never advanced.
|
||||
- The related brain task is `done`, or the fix shipped and was confirmed.
|
||||
- Requester confirmed resolution but the ticket was never Solved.
|
||||
|
||||
### 3. Present the candidates
|
||||
|
||||
List them for review before touching anything — never bulk-solve blindly:
|
||||
|
||||
```
|
||||
Open tickets that look resolved:
|
||||
- #<id> "<title>" — <why it looks done> → glpi-solve?
|
||||
```
|
||||
|
||||
### 4. Close out the confirmed ones
|
||||
|
||||
For each ticket the user (or clear evidence) confirms is done, run **[[glpi-solve]]**
|
||||
(optionally **[[glpi-followup]]** first if a closing note is warranted).
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Read-only until a ticket is confirmed done — do not auto-solve on a guess.
|
||||
- Never echo GLPI tokens.
|
||||
- Set **Solved (5)**, never Closed (6) — GLPI auto-close owns that transition.
|
||||
Reference in New Issue
Block a user