Compare commits
9 Commits
fix/850-de
...
69092718d3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69092718d3 | ||
|
|
597b06e10d | ||
|
|
9d2b12ca71 | ||
| b79336a8c1 | |||
| 4e5af23214 | |||
|
|
880c28b191 | ||
|
|
7bc2dfb6c8 | ||
| b0d78d8632 | |||
| 344d86a635 |
@@ -639,6 +639,10 @@ reconcile_framework_files
|
|||||||
# Ensure tool scripts are executable
|
# Ensure tool scripts are executable
|
||||||
find "$TARGET_DIR/tools" -name "*.sh" -exec chmod +x {} + 2>/dev/null || true
|
find "$TARGET_DIR/tools" -name "*.sh" -exec chmod +x {} + 2>/dev/null || true
|
||||||
find "$TARGET_DIR/tools/_scripts" -type f -exec chmod +x {} + 2>/dev/null || true
|
find "$TARGET_DIR/tools/_scripts" -type f -exec chmod +x {} + 2>/dev/null || true
|
||||||
|
# git-credential-mosaic (per-agent Gitea identity helper) ships without a .sh
|
||||||
|
# suffix — git resolves credential helpers by exact name/path, not extension —
|
||||||
|
# so the *.sh glob above does not cover it; chmod it explicitly.
|
||||||
|
[[ -f "$TARGET_DIR/tools/git/git-credential-mosaic" ]] && chmod +x "$TARGET_DIR/tools/git/git-credential-mosaic" 2>/dev/null || true
|
||||||
|
|
||||||
ok "Framework synced to $TARGET_DIR"
|
ok "Framework synced to $TARGET_DIR"
|
||||||
|
|
||||||
|
|||||||
@@ -7,3 +7,64 @@ These scripts provide host-aware GitHub and Gitea issue, pull-request, milestone
|
|||||||
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).
|
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.
|
`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.
|
||||||
|
|
||||||
|
## Per-agent Gitea identity (Gate-16 author≠reviewer)
|
||||||
|
|
||||||
|
By default, git push/fetch (via `git-credential-mosaic`) and the API wrappers above (via
|
||||||
|
`detect-platform.sh`'s `get_gitea_token`) all authenticate as the single shared Gitea
|
||||||
|
account/token configured through `tools/_lib/credentials.sh`. That means every agent in a
|
||||||
|
fleet commits, pushes, and opens PRs under one identity — with no cryptographic
|
||||||
|
separation between an author and a reviewer.
|
||||||
|
|
||||||
|
Both `git-credential-mosaic` and `get_gitea_token()` resolve an optional **per-agent
|
||||||
|
identity** before falling back to the shared account:
|
||||||
|
|
||||||
|
1. `MOSAIC_GIT_IDENTITY` environment variable, or
|
||||||
|
2. `git config --get mosaic.gitIdentity` (set per-worktree; persists on disk across
|
||||||
|
non-persistent shells — `git config mosaic.gitIdentity <agent-id>`), or
|
||||||
|
3. (git-credential-mosaic only) the username git itself supplies for the credential
|
||||||
|
request.
|
||||||
|
|
||||||
|
If the resolved identity has a token file at
|
||||||
|
`~/.config/mosaic/secrets/gitea-tokens/gitea-{usc,mosaicstack}-<agent-id>.token`, that
|
||||||
|
identity + token is used. **Nothing configured → nothing changes**: with no per-slot
|
||||||
|
token file present, both tools fall through to the existing shared-account path
|
||||||
|
unchanged, so this feature is a no-op on any host that hasn't provisioned per-slot
|
||||||
|
tokens.
|
||||||
|
|
||||||
|
### Enabling it for a clone
|
||||||
|
|
||||||
|
The framework installer syncs `git-credential-mosaic` to
|
||||||
|
`~/.config/mosaic/tools/git/git-credential-mosaic` (executable) on every install/update,
|
||||||
|
but does **not** register it as git's credential helper automatically. Registration is a
|
||||||
|
one-time, explicit step:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Per-repo (recommended — scopes the helper to this clone only):
|
||||||
|
git config credential.helper "$HOME/.config/mosaic/tools/git/git-credential-mosaic"
|
||||||
|
|
||||||
|
# Per-worktree identity pin (Gate-16 separation):
|
||||||
|
git config mosaic.gitIdentity <agent-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
This is deliberately **not** auto-registered on install/update: `credential.helper` is
|
||||||
|
global, order-sensitive git config (`~/.gitconfig`) that can already hold an
|
||||||
|
operator-chosen credential manager (keychain, `store`, `manager-core`, …) for
|
||||||
|
repositories unrelated to Mosaic. Silently inserting an entry on every framework
|
||||||
|
install/upgrade risks reordering or shadowing that operator-owned surface across the
|
||||||
|
whole host — the same operator-owned config the installer's manifest system is
|
||||||
|
otherwise careful never to touch. Because identity is already resolved per-worktree
|
||||||
|
(`mosaic.gitIdentity`), the correct granularity for registering the helper is per-clone
|
||||||
|
too, so a documented manual step is the right shape here, not a global auto-write.
|
||||||
|
|
||||||
|
### PowerShell parity
|
||||||
|
|
||||||
|
`detect-platform.ps1`'s Gitea wrappers authenticate through `tea` CLI logins
|
||||||
|
(`Get-GiteaLoginForHost`), not a raw-token `get_gitea_token`-equivalent function — there
|
||||||
|
is nothing to prepend the identity-resolution block to on the PowerShell side. A native
|
||||||
|
PowerShell git-credential helper is also unnecessary: `git-credential-mosaic` is invoked
|
||||||
|
by git's credential-helper protocol (stdin/stdout), which works identically under Git for
|
||||||
|
Windows' bundled `bash`/`sh` when configured via `credential.helper`, without a `.ps1`
|
||||||
|
counterpart. A `tea`-login-based per-agent identity for the PowerShell wrappers is a
|
||||||
|
separate, larger design (mapping identities to `tea login` profiles) and is out of scope
|
||||||
|
here.
|
||||||
|
|||||||
@@ -91,13 +91,19 @@ remote = urlparse(f"//{remote_host}")
|
|||||||
if configured.scheme not in {"http", "https"} or configured.hostname != remote.hostname:
|
if configured.scheme not in {"http", "https"} or configured.hostname != remote.hostname:
|
||||||
raise SystemExit(1)
|
raise SystemExit(1)
|
||||||
|
|
||||||
configured_port = configured.port
|
# Normalize by scheme: an implicit (portless) HTTP(S) URL and its explicit
|
||||||
remote_port = remote.port
|
# default-port form (":80" for http, ":443" for https) name the same
|
||||||
if remote_port is None:
|
# 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
|
default_port = 80 if configured.scheme == "http" else 443
|
||||||
if configured_port not in {None, default_port}:
|
normalized_configured = configured.port if configured.port is not None else default_port
|
||||||
raise SystemExit(1)
|
normalized_remote = remote.port if remote.port is not None else default_port
|
||||||
elif configured_port != remote_port:
|
if normalized_configured != normalized_remote:
|
||||||
raise SystemExit(1)
|
raise SystemExit(1)
|
||||||
raise SystemExit(0)
|
raise SystemExit(0)
|
||||||
PY
|
PY
|
||||||
@@ -432,7 +438,11 @@ get_remote_host() {
|
|||||||
fi
|
fi
|
||||||
if [[ "$remote_url" =~ ^ssh://([^/]+)/ ]]; then
|
if [[ "$remote_url" =~ ^ssh://([^/]+)/ ]]; then
|
||||||
local host="${BASH_REMATCH[1]}"
|
local host="${BASH_REMATCH[1]}"
|
||||||
echo "${host##*@}"
|
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
|
return 0
|
||||||
fi
|
fi
|
||||||
if [[ "$remote_url" =~ ^git@([^:]+): ]]; then
|
if [[ "$remote_url" =~ ^git@([^:]+): ]]; then
|
||||||
@@ -495,6 +505,28 @@ get_gitea_token() {
|
|||||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
local cred_loader="$script_dir/../_lib/credentials.sh"
|
local cred_loader="$script_dir/../_lib/credentials.sh"
|
||||||
|
|
||||||
|
# 0. Per-agent identity (Gate-16 author≠reviewer). If MOSAIC_GIT_IDENTITY, or the
|
||||||
|
# per-worktree `git config mosaic.gitIdentity`, resolves to an agent that has a
|
||||||
|
# stored per-slot token for this host, act AS that agent so API tooling
|
||||||
|
# (pr-create, issue-create, …) authors under the right identity — matching the
|
||||||
|
# git credential helper. Backward-compatible: nothing resolvable → shared logic below.
|
||||||
|
local _ident="${MOSAIC_GIT_IDENTITY:-}"
|
||||||
|
[[ -z "$_ident" ]] && _ident="$(git config --get mosaic.gitIdentity 2>/dev/null || true)"
|
||||||
|
if [[ -n "$_ident" ]]; then
|
||||||
|
local _idpfx=""
|
||||||
|
case "$host" in
|
||||||
|
git.uscllc.com) _idpfx=gitea-usc ;;
|
||||||
|
git.mosaicstack.dev) _idpfx=gitea-mosaicstack ;;
|
||||||
|
esac
|
||||||
|
if [[ -n "$_idpfx" ]]; then
|
||||||
|
local _idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${_idpfx}-${_ident}.token"
|
||||||
|
if [[ -r "$_idtok" ]]; then
|
||||||
|
cat "$_idtok"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# 1. Mosaic credential loader (host → service mapping, run in subshell to avoid polluting env)
|
# 1. Mosaic credential loader (host → service mapping, run in subshell to avoid polluting env)
|
||||||
if [[ -f "$cred_loader" ]]; then
|
if [[ -f "$cred_loader" ]]; then
|
||||||
local token
|
local token
|
||||||
|
|||||||
69
packages/mosaic/framework/tools/git/git-credential-mosaic
Executable file
69
packages/mosaic/framework/tools/git/git-credential-mosaic
Executable file
@@ -0,0 +1,69 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# git-credential-mosaic — git credential helper — resolves Gitea tokens from
|
||||||
|
# the Mosaic credential store at runtime so remote URLs never embed secrets.
|
||||||
|
#
|
||||||
|
# Install (one-time, per clone or globally):
|
||||||
|
# git config credential.helper "$HOME/.config/mosaic/tools/git/git-credential-mosaic"
|
||||||
|
# # or, fleet-wide: git config --global credential.helper "$HOME/.config/mosaic/tools/git/git-credential-mosaic"
|
||||||
|
#
|
||||||
|
# Per-agent Gate-16 identity (author != reviewer separation):
|
||||||
|
# git config mosaic.gitIdentity <agent-id> # per-worktree, persists on disk
|
||||||
|
# # or: export MOSAIC_GIT_IDENTITY=<agent-id>
|
||||||
|
#
|
||||||
|
# Resolution priority: MOSAIC_GIT_IDENTITY env > git config mosaic.gitIdentity
|
||||||
|
# (per-worktree, survives across non-persistent shells) > git-supplied username
|
||||||
|
# (credential.username / URL). When the resolved identity has a matching
|
||||||
|
# per-agent token file, use it instead of the shared account. Backward
|
||||||
|
# compatible: nothing resolvable -> shared token (unchanged behavior).
|
||||||
|
[ "$1" = "get" ] || exit 0
|
||||||
|
host=""; username_in=""
|
||||||
|
while IFS= read -r line; do
|
||||||
|
[ -z "$line" ] && break
|
||||||
|
case "$line" in
|
||||||
|
host=*) host=${line#host=};;
|
||||||
|
username=*) username_in=${line#username=};;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
# Per-agent identity resolution (Gate-16 author≠reviewer separation).
|
||||||
|
# Priority: MOSAIC_GIT_IDENTITY env > git config mosaic.gitIdentity (per-worktree,
|
||||||
|
# survives across non-persistent shells) > git-supplied username (credential.username
|
||||||
|
# / URL). When the resolved identity has a matching per-agent token, use it instead of
|
||||||
|
# the shared account. Backward-compatible: nothing resolvable → shared token.
|
||||||
|
ident="$MOSAIC_GIT_IDENTITY"
|
||||||
|
[ -z "$ident" ] && ident=$(git config --get mosaic.gitIdentity 2>/dev/null)
|
||||||
|
[ -z "$ident" ] && ident="$username_in"
|
||||||
|
if [ -n "$ident" ]; then
|
||||||
|
case "$host" in
|
||||||
|
git.uscllc.com) idpfx=gitea-usc;;
|
||||||
|
git.mosaicstack.dev) idpfx=gitea-mosaicstack;;
|
||||||
|
*) idpfx="";;
|
||||||
|
esac
|
||||||
|
if [ -n "$idpfx" ]; then
|
||||||
|
idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${idpfx}-${ident}.token"
|
||||||
|
if [ -r "$idtok" ]; then
|
||||||
|
echo "username=${ident}"
|
||||||
|
echo "password=$(cat "$idtok")"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
case "$host" in
|
||||||
|
git.uscllc.com) svc=gitea-usc;;
|
||||||
|
git.mosaicstack.dev) svc=gitea-mosaicstack;;
|
||||||
|
*) exit 0;;
|
||||||
|
esac
|
||||||
|
# Script-relative (not $HOME-absolute) so this resolves correctly regardless
|
||||||
|
# of where the framework installer places tools/ under $HOME — mirrors
|
||||||
|
# detect-platform.sh's own cred_loader resolution in this same directory.
|
||||||
|
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# shellcheck source=../_lib/credentials.sh
|
||||||
|
source "$script_dir/../_lib/credentials.sh"
|
||||||
|
load_credentials "$svc" >/dev/null 2>&1 || exit 0
|
||||||
|
# GITEA_USER is not populated by load_credentials (it only exports
|
||||||
|
# GITEA_URL/GITEA_TOKEN for gitea-*), so this fallback is normally taken. Gitea's
|
||||||
|
# git-over-HTTP auth authenticates from the token itself (the password field),
|
||||||
|
# not from the username string, so any non-empty placeholder works here — this
|
||||||
|
# is deliberately NOT a real account name (framework files must stay
|
||||||
|
# operator-agnostic; see tools/quality/scripts/verify-sanitized.sh).
|
||||||
|
echo "username=${GITEA_USER:-git}"
|
||||||
|
echo "password=$GITEA_TOKEN"
|
||||||
161
packages/mosaic/framework/tools/git/test-git-credential-mosaic.sh
Executable file
161
packages/mosaic/framework/tools/git/test-git-credential-mosaic.sh
Executable file
@@ -0,0 +1,161 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Regression harness for `git-credential-mosaic` — per-agent Gitea identity
|
||||||
|
# resolution (Gate-16 author≠reviewer separation).
|
||||||
|
#
|
||||||
|
# Covers:
|
||||||
|
# 1. Identity resolution priority: MOSAIC_GIT_IDENTITY env > git config
|
||||||
|
# mosaic.gitIdentity (per-worktree) > git-supplied username.
|
||||||
|
# 2. Correct per-slot token file path chosen per host
|
||||||
|
# (gitea-usc-<id>.token vs gitea-mosaicstack-<id>.token).
|
||||||
|
# 3. Per-slot token present -> emits that identity + token.
|
||||||
|
# 4. Per-slot token absent -> falls back to the shared account
|
||||||
|
# (backward-compat / no-op for hosts without per-slot tokens).
|
||||||
|
# 5. Unknown/unrelated host -> exits 0 with no output (passthrough).
|
||||||
|
#
|
||||||
|
# Uses stubbed token files under a fake HOME + a real (throwaway) git repo.
|
||||||
|
# NEVER reads real secrets or touches the real ~/.config/mosaic/secrets.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/git-credential-mosaic}"
|
||||||
|
FAKE_HOME="$WORK_DIR/home"
|
||||||
|
REPO_DIR="$WORK_DIR/repo"
|
||||||
|
# Mirror the real deployed layout (~/.config/mosaic/tools/{git,_lib}/) under the
|
||||||
|
# fake HOME: git-credential-mosaic resolves its credentials.sh sibling via a
|
||||||
|
# script-relative path (BASH_SOURCE), so the copy must live next to a stubbed
|
||||||
|
# _lib/credentials.sh, not the real one, to keep this test hermetic.
|
||||||
|
HELPER="$FAKE_HOME/.config/mosaic/tools/git/git-credential-mosaic"
|
||||||
|
|
||||||
|
rm -rf "$WORK_DIR"
|
||||||
|
mkdir -p "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens" \
|
||||||
|
"$FAKE_HOME/.config/mosaic/tools/git" \
|
||||||
|
"$FAKE_HOME/.config/mosaic/tools/_lib" \
|
||||||
|
"$REPO_DIR"
|
||||||
|
|
||||||
|
cp "$SCRIPT_DIR/git-credential-mosaic" "$HELPER"
|
||||||
|
chmod +x "$HELPER"
|
||||||
|
|
||||||
|
git -C "$REPO_DIR" init -q
|
||||||
|
git -C "$REPO_DIR" config user.email "test@example.invalid"
|
||||||
|
git -C "$REPO_DIR" config user.name "Test"
|
||||||
|
|
||||||
|
# Fake shared-account credential loader — stands in for
|
||||||
|
# tools/_lib/credentials.sh's load_credentials(), scoped to this test only.
|
||||||
|
cat > "$FAKE_HOME/.config/mosaic/tools/_lib/credentials.sh" <<'SH'
|
||||||
|
load_credentials() {
|
||||||
|
case "$1" in
|
||||||
|
gitea-mosaicstack) GITEA_URL="https://git.mosaicstack.dev"; GITEA_TOKEN="shared-mosaicstack-token"; export GITEA_URL GITEA_TOKEN; return 0 ;;
|
||||||
|
gitea-usc) GITEA_URL="https://git.uscllc.com"; GITEA_TOKEN="shared-usc-token"; export GITEA_URL GITEA_TOKEN; return 0 ;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
SH
|
||||||
|
|
||||||
|
fail=0
|
||||||
|
assert_eq() {
|
||||||
|
local desc="$1" expected="$2" actual="$3"
|
||||||
|
if [[ "$expected" != "$actual" ]]; then
|
||||||
|
echo "FAIL: $desc — expected '$expected', got '$actual'" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Feed "host=<h>\nusername=<u>\n\n" on stdin (mirrors git's credential protocol)
|
||||||
|
# and run the helper with the fake HOME, inside REPO_DIR (so `git config
|
||||||
|
# mosaic.gitIdentity` resolves per-worktree), plus any extra env passed in $@.
|
||||||
|
run_helper() {
|
||||||
|
local host="$1" username_in="$2"; shift 2
|
||||||
|
(
|
||||||
|
cd "$REPO_DIR"
|
||||||
|
env -i HOME="$FAKE_HOME" PATH="$PATH" "$@" bash "$HELPER" get <<EOF
|
||||||
|
host=$host
|
||||||
|
username=$username_in
|
||||||
|
|
||||||
|
EOF
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. No identity resolvable anywhere, no per-slot token -> shared fallback
|
||||||
|
# (backward-compat: unchanged behavior when nothing is configured).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
git -C "$REPO_DIR" config --unset mosaic.gitIdentity 2>/dev/null || true
|
||||||
|
out=$(run_helper "git.mosaicstack.dev" "")
|
||||||
|
assert_eq "shared fallback: username" "username=git" "$(echo "$out" | grep '^username=')"
|
||||||
|
assert_eq "shared fallback: password" "password=shared-mosaicstack-token" "$(echo "$out" | grep '^password=')"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2. git-supplied username resolves to an identity WITH a per-slot token ->
|
||||||
|
# that identity + token wins over the shared account.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
echo -n "agentA-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentA.token"
|
||||||
|
out=$(run_helper "git.mosaicstack.dev" "agentA")
|
||||||
|
assert_eq "username-resolved identity: username" "username=agentA" "$(echo "$out" | grep '^username=')"
|
||||||
|
assert_eq "username-resolved identity: password" "password=agentA-mosaicstack-token" "$(echo "$out" | grep '^password=')"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3. git config mosaic.gitIdentity (per-worktree) beats git-supplied username.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
echo -n "agentB-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentB.token"
|
||||||
|
git -C "$REPO_DIR" config mosaic.gitIdentity agentB
|
||||||
|
out=$(run_helper "git.mosaicstack.dev" "agentA")
|
||||||
|
assert_eq "git-config beats username: username" "username=agentB" "$(echo "$out" | grep '^username=')"
|
||||||
|
assert_eq "git-config beats username: password" "password=agentB-mosaicstack-token" "$(echo "$out" | grep '^password=')"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 4. MOSAIC_GIT_IDENTITY env beats git config mosaic.gitIdentity.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
echo -n "agentC-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentC.token"
|
||||||
|
out=$(run_helper "git.mosaicstack.dev" "agentA" MOSAIC_GIT_IDENTITY=agentC)
|
||||||
|
assert_eq "env beats git-config: username" "username=agentC" "$(echo "$out" | grep '^username=')"
|
||||||
|
assert_eq "env beats git-config: password" "password=agentC-mosaicstack-token" "$(echo "$out" | grep '^password=')"
|
||||||
|
git -C "$REPO_DIR" config --unset mosaic.gitIdentity
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 5. Identity resolves, but no matching per-slot token file -> falls back to
|
||||||
|
# the shared account (per-agent identity is opt-in, not a hard requirement).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
out=$(run_helper "git.mosaicstack.dev" "no-such-agent")
|
||||||
|
assert_eq "no per-slot token: username" "username=git" "$(echo "$out" | grep '^username=')"
|
||||||
|
assert_eq "no per-slot token: password" "password=shared-mosaicstack-token" "$(echo "$out" | grep '^password=')"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 6. Correct per-slot token PATH is chosen per host: same agent id, different
|
||||||
|
# host prefix (gitea-usc- vs gitea-mosaicstack-).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
echo -n "agentD-usc-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-usc-agentD.token"
|
||||||
|
out=$(run_helper "git.uscllc.com" "agentD")
|
||||||
|
assert_eq "host-scoped token path (usc): username" "username=agentD" "$(echo "$out" | grep '^username=')"
|
||||||
|
assert_eq "host-scoped token path (usc): password" "password=agentD-usc-token" "$(echo "$out" | grep '^password=')"
|
||||||
|
# agentD has NO mosaicstack token -> must fall back to shared mosaicstack, not
|
||||||
|
# leak the usc token across hosts.
|
||||||
|
out=$(run_helper "git.mosaicstack.dev" "agentD")
|
||||||
|
assert_eq "host-scoped token path (cross-host must not leak): username" "username=git" "$(echo "$out" | grep '^username=')"
|
||||||
|
assert_eq "host-scoped token path (cross-host must not leak): password" "password=shared-mosaicstack-token" "$(echo "$out" | grep '^password=')"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 7. Unrelated/unknown host -> exit 0, no output (passthrough for non-Gitea
|
||||||
|
# remotes, e.g. github.com via a different credential helper).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
out=$(run_helper "github.com" "agentA")
|
||||||
|
assert_eq "unknown host: no output" "" "$out"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 8. Non-"get" verb (store/erase) -> exit 0, no output (git-credential
|
||||||
|
# protocol: this helper only implements get).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
store_out=$(cd "$REPO_DIR" && env -i HOME="$FAKE_HOME" PATH="$PATH" bash "$HELPER" store <<EOF
|
||||||
|
host=git.mosaicstack.dev
|
||||||
|
username=agentA
|
||||||
|
password=whatever
|
||||||
|
|
||||||
|
EOF
|
||||||
|
)
|
||||||
|
assert_eq "store verb: no output" "" "$store_out"
|
||||||
|
|
||||||
|
if [[ "$fail" -eq 0 ]]; then
|
||||||
|
echo "git-credential-mosaic identity resolution regression passed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit "$fail"
|
||||||
122
packages/mosaic/framework/tools/git/test-gitea-token-identity.sh
Executable file
122
packages/mosaic/framework/tools/git/test-gitea-token-identity.sh
Executable file
@@ -0,0 +1,122 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Regression harness for detect-platform.sh's get_gitea_token() per-agent
|
||||||
|
# identity resolution (Gate-16 author≠reviewer separation) — the API-tooling
|
||||||
|
# counterpart to git-credential-mosaic, so pr-create.sh/issue-create.sh/etc.
|
||||||
|
# open records under the resolved agent identity, not the shared account.
|
||||||
|
#
|
||||||
|
# Covers:
|
||||||
|
# 1. Identity resolution priority: MOSAIC_GIT_IDENTITY env > git config
|
||||||
|
# mosaic.gitIdentity (per-worktree).
|
||||||
|
# 2. Correct per-slot token file path chosen per host
|
||||||
|
# (gitea-usc-<id>.token vs gitea-mosaicstack-<id>.token).
|
||||||
|
# 3. Per-slot token present -> that token is returned (agent-authored calls).
|
||||||
|
# 4. Per-slot token absent -> falls back to the shared credential-loader
|
||||||
|
# token (backward-compat / no-op for hosts without per-slot tokens).
|
||||||
|
# 5. Unrelated host with no shared credentials configured -> failure
|
||||||
|
# (unchanged, existing behavior).
|
||||||
|
#
|
||||||
|
# Uses a stubbed credentials.json + stubbed per-slot token files under a fake
|
||||||
|
# HOME. NEVER reads real secrets or touches the real ~/.config/mosaic/secrets.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/gitea-token-identity}"
|
||||||
|
FAKE_HOME="$WORK_DIR/home"
|
||||||
|
REPO_DIR="$WORK_DIR/repo"
|
||||||
|
CREDENTIALS_FILE="$FAKE_HOME/.config/mosaic/credentials.json"
|
||||||
|
|
||||||
|
rm -rf "$WORK_DIR"
|
||||||
|
mkdir -p "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens" "$REPO_DIR"
|
||||||
|
|
||||||
|
git -C "$REPO_DIR" init -q
|
||||||
|
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
||||||
|
|
||||||
|
cat > "$CREDENTIALS_FILE" <<'JSON'
|
||||||
|
{
|
||||||
|
"gitea": {
|
||||||
|
"mosaicstack": {
|
||||||
|
"url": "https://git.mosaicstack.dev",
|
||||||
|
"token": "shared-mosaicstack-token"
|
||||||
|
},
|
||||||
|
"usc": {
|
||||||
|
"url": "https://git.uscllc.com",
|
||||||
|
"token": "shared-usc-token"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
|
||||||
|
fail=0
|
||||||
|
assert_eq() {
|
||||||
|
local desc="$1" expected="$2" actual="$3"
|
||||||
|
if [[ "$expected" != "$actual" ]]; then
|
||||||
|
echo "FAIL: $desc — expected '$expected', got '$actual'" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Runs get_gitea_token for $1=host inside REPO_DIR (per-worktree git config
|
||||||
|
# resolves there) with a fake HOME + the stub credentials.json, plus any
|
||||||
|
# extra env passed in $@.
|
||||||
|
call_get_gitea_token() {
|
||||||
|
local host="$1"; shift
|
||||||
|
(
|
||||||
|
cd "$REPO_DIR"
|
||||||
|
# shellcheck disable=SC2016 # deliberately deferred: $DETECT_PLATFORM_SH is
|
||||||
|
# expanded by the INNER bash -c (via the exported env var below), not here.
|
||||||
|
env -i HOME="$FAKE_HOME" PATH="$PATH" MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||||
|
DETECT_PLATFORM_SH="$SCRIPT_DIR/detect-platform.sh" "$@" \
|
||||||
|
bash -c 'source "$DETECT_PLATFORM_SH"; get_gitea_token "$1"' _ "$host"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. No identity resolvable -> shared credential-loader token (unchanged).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
git -C "$REPO_DIR" config --unset mosaic.gitIdentity 2>/dev/null || true
|
||||||
|
out=$(call_get_gitea_token "git.mosaicstack.dev")
|
||||||
|
assert_eq "shared fallback (no identity)" "shared-mosaicstack-token" "$out"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2. git config mosaic.gitIdentity resolves to an agent WITH a per-slot
|
||||||
|
# token -> that token wins over the shared account.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
echo -n "agentA-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentA.token"
|
||||||
|
git -C "$REPO_DIR" config mosaic.gitIdentity agentA
|
||||||
|
out=$(call_get_gitea_token "git.mosaicstack.dev")
|
||||||
|
assert_eq "git-config identity token" "agentA-mosaicstack-token" "$out"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3. MOSAIC_GIT_IDENTITY env beats git config mosaic.gitIdentity.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
echo -n "agentB-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentB.token"
|
||||||
|
out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_GIT_IDENTITY=agentB)
|
||||||
|
assert_eq "env beats git-config identity token" "agentB-mosaicstack-token" "$out"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 4. Identity resolves but has no per-slot token for THIS host -> falls back
|
||||||
|
# to the shared token (per-agent identity is opt-in per host).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
git -C "$REPO_DIR" config mosaic.gitIdentity no-such-agent
|
||||||
|
out=$(call_get_gitea_token "git.mosaicstack.dev")
|
||||||
|
assert_eq "no per-slot token falls back to shared" "shared-mosaicstack-token" "$out"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 5. Correct per-slot token PATH per host: same agent id, only a usc token
|
||||||
|
# exists -> usc host returns it, mosaicstack host must NOT leak it and
|
||||||
|
# instead falls back to the shared mosaicstack token.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
echo -n "agentD-usc-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-usc-agentD.token"
|
||||||
|
git -C "$REPO_DIR" config mosaic.gitIdentity agentD
|
||||||
|
out=$(call_get_gitea_token "git.uscllc.com")
|
||||||
|
assert_eq "host-scoped token path (usc)" "agentD-usc-token" "$out"
|
||||||
|
out=$(call_get_gitea_token "git.mosaicstack.dev")
|
||||||
|
assert_eq "host-scoped token path (no cross-host leak)" "shared-mosaicstack-token" "$out"
|
||||||
|
git -C "$REPO_DIR" config --unset mosaic.gitIdentity
|
||||||
|
|
||||||
|
if [[ "$fail" -eq 0 ]]; then
|
||||||
|
echo "get_gitea_token identity resolution regression passed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit "$fail"
|
||||||
@@ -73,7 +73,7 @@ case "${PR_REVIEW_TEST_MODE:-}" in
|
|||||||
request-changes)
|
request-changes)
|
||||||
[[ "$*" == "pr reject 123 --repo mosaicstack/stack --login mosaicstack" ]] || exit 91
|
[[ "$*" == "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|write-transport-failure|write-http-failure|readback-failure)
|
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
|
if [[ "$*" == pr\ comment* ]]; then
|
||||||
# tea v0.11.1 treats the nonexistent subcommand as `tea pr list` and exits 0.
|
# tea v0.11.1 treats the nonexistent subcommand as `tea pr list` and exits 0.
|
||||||
printf '%s\n' 'INDEX TITLE STATE'
|
printf '%s\n' 'INDEX TITLE STATE'
|
||||||
@@ -144,7 +144,7 @@ case "${PR_REVIEW_TEST_MODE:-}" in
|
|||||||
write-http-failure)
|
write-http-failure)
|
||||||
write_response 500 '{"message":"simulated rejection"}'
|
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|readback-failure)
|
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
|
if [[ "$method" == "POST" && "$url" == "$PR_REVIEW_EXPECTED_API_BASE/issues/123/comments" ]]; then
|
||||||
PR_REVIEW_PAYLOAD="$payload" python3 - <<'PY'
|
PR_REVIEW_PAYLOAD="$payload" python3 - <<'PY'
|
||||||
import json
|
import json
|
||||||
@@ -291,6 +291,23 @@ grep -q '^POST https://git.example/api/v1/repos/owner/repo/issues/123/comments$'
|
|||||||
run_review url-ssh-success comment durable-body https://git.example ssh://git@git.example/owner/repo.git owner/repo
|
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"
|
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
|
if run_review write-transport-failure comment durable-body; then
|
||||||
echo "Expected provider transport failure to return nonzero" >&2
|
echo "Expected provider transport failure to return nonzero" >&2
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
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"
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
"lint": "eslint src",
|
"lint": "eslint src",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
|
"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 && bash framework/tools/qa/test-deps-preflight.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 && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mosaicstack/brain": "workspace:*",
|
"@mosaicstack/brain": "workspace:*",
|
||||||
|
|||||||
@@ -661,13 +661,27 @@ describe('whole mutator-class lease gate', () => {
|
|||||||
test('observer revocation and monotonic TTL expiry deny the next mutator', async () => {
|
test('observer revocation and monotonic TTL expiry deny the next mutator', async () => {
|
||||||
const { socket } = await startBroker();
|
const { socket } = await startBroker();
|
||||||
const sessionId = await register(socket);
|
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({
|
expect(await authorize(socket, sessionId, 'claude', 'Bash')).toMatchObject({
|
||||||
ok: true,
|
ok: true,
|
||||||
decision: 'allow',
|
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));
|
await new Promise((resolve) => setTimeout(resolve, 1_100));
|
||||||
expect(await authorize(socket, sessionId, 'claude', 'Bash')).toMatchObject({
|
expect(await authorize(socket, sessionId, 'claude', 'Bash')).toMatchObject({
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -675,7 +689,7 @@ describe('whole mutator-class lease gate', () => {
|
|||||||
decision: 'deny',
|
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!);
|
await promote(socket, sessionId, refreshed.receipt_challenge!);
|
||||||
expect(
|
expect(
|
||||||
await request(socket, {
|
await request(socket, {
|
||||||
|
|||||||
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