Skip to content

Commit 8d541cc

Browse files
committed
Merge remote-tracking branch 'origin/main' into feat/change-toy-tutorial-to-kw-units
2 parents fd04d12 + b74cb05 commit 8d541cc

26 files changed

Lines changed: 1411 additions & 56 deletions

.claude/hooks/worktree-guard.sh

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
#!/usr/bin/env bash
2+
# Blocking PreToolUse hook: the primary checkout is shared by multiple Claude
3+
# Code agents. Branch-mutating git commands (checkout/switch/merge/rebase/
4+
# reset/cherry-pick/stash) run there can switch the branch or rewrite history
5+
# out from under another agent, and `git stash` silently pockets other agents'
6+
# uncommitted work. This hook blocks those commands when they target the
7+
# primary checkout, and allows them when they target a linked `git worktree`,
8+
# where mutating branch state only affects that worktree.
9+
#
10+
# Precision caveat: a PreToolUse hook does NOT run in the Bash tool's
11+
# persistent working directory — it runs in the project dir. So this guard is
12+
# "cwd + -C/--git-dir aware, not shell-state aware": if an agent ran
13+
# `cd <worktree>` in an earlier Bash call and then runs a bare `git switch`,
14+
# we evaluate against the primary checkout and block it. That is the safe
15+
# direction to fail (annoying, not dangerous) — use `git -C <worktree> ...`,
16+
# or `cd <worktree> && git ...` in the same command, to be recognised.
17+
#
18+
# Exit 2 = blocking error (Claude sees stderr and can course-correct).
19+
# Exit 0 = allow. Fails open (exit 0) on any parsing problem — never break
20+
# the user's shell over a malformed hook payload or a missing `jq`.
21+
22+
set -euo pipefail
23+
24+
payload="$(cat)"
25+
26+
command -v jq >/dev/null 2>&1 || exit 0
27+
28+
command="$(echo "$payload" | jq -r '.tool_input.command // empty' 2>/dev/null || true)"
29+
[ -n "$command" ] || exit 0
30+
31+
# Self-filter: only act on git commands that could mutate branch/worktree
32+
# state. The settings-level "if" matcher is not honoured by all Claude Code
33+
# versions, so replicate the filter here. Global options such as `-C <path>`,
34+
# `--git-dir=<path>` or `-c foo=bar` may sit between `git` and the verb, so
35+
# allow a run of option words (each optionally followed by its value) first.
36+
verb_re='\bgit[[:space:]]+(-[^[:space:]]*([[:space:]]+[^-[:space:];&|][^[:space:]]*)?[[:space:]]+)*(checkout|switch|merge|rebase|reset|cherry-pick|stash)\b'
37+
echo "$command" | grep -qE "$verb_re" || exit 0
38+
39+
# `git checkout -- <path>` / `git checkout <ref> -- <path>` only restore
40+
# files from the index/a ref; they never move HEAD or the branch pointer.
41+
# Allow those explicitly even though they match the "checkout" verb above.
42+
if echo "$command" | grep -qE '\bcheckout\b[^;&|]*[[:space:]]--[[:space:]]'; then
43+
exit 0
44+
fi
45+
46+
project_dir="${CLAUDE_PROJECT_DIR:-$(pwd)}"
47+
48+
# Work out which directory the git command actually targets, in priority
49+
# order: an explicit `-C <path>` / `--git-dir=<path>` on the git invocation
50+
# (the idiomatic way to drive another worktree without cd-ing), else a `cd`
51+
# earlier in the same command (same idea as pre-commit-check.sh's target
52+
# check), else the directory this hook runs in.
53+
target=""
54+
if echo "$command" | grep -qE '[[:space:]]-C[[:space:]]'; then
55+
target="$(echo "$command" | grep -oE '[[:space:]]-C[[:space:]]+[^[:space:];&|]+' | head -n1 | sed -E 's/^[[:space:]]*-C[[:space:]]+//')"
56+
elif echo "$command" | grep -qE '\-\-git-dir[=[:space:]]'; then
57+
target="$(echo "$command" | grep -oE '\-\-git-dir[=[:space:]][^[:space:];&|]+' | head -n1 | sed -E 's/^--git-dir[=[:space:]]//')"
58+
elif echo "$command" | grep -qE '(^|[;&|])[[:space:]]*cd[[:space:]]+'; then
59+
target="$(echo "$command" | grep -oE '(^|[;&|])[[:space:]]*cd[[:space:]]+[^;&|]+' | tail -n1 | sed -E 's/^[;&|]?[[:space:]]*cd[[:space:]]+//')"
60+
fi
61+
62+
# Strip surrounding quotes/whitespace and expand ~ where we safely can. Do the
63+
# expansion in a subshell with `set +u` so an unbound variable can't abort us.
64+
target="$(echo "$target" | sed -E "s/^[[:space:]]*['\"]?//; s/['\"]?[[:space:]]*$//")"
65+
if [ -n "$target" ]; then
66+
case "$target" in
67+
*['$~']*)
68+
# The path needs shell expansion. Only `~` is safe to expand here: a
69+
# variable like `-C "$WT"` was set in an earlier Bash call, whose state
70+
# this hook cannot see, so we cannot tell which checkout it points at.
71+
expanded="$(set +u; eval "echo $target" 2>/dev/null || true)"
72+
if [ -n "$expanded" ] && [ -d "$expanded" ]; then
73+
target="$expanded"
74+
else
75+
# Block rather than guess — the safe direction, same as a bare
76+
# `git switch` whose cwd we cannot see.
77+
unresolved="$target"
78+
target="$project_dir"
79+
fi
80+
;;
81+
esac
82+
fi
83+
84+
if [ -n "$target" ]; then
85+
# An absolute target outside the project dir entirely isn't our business.
86+
case "$target" in
87+
"$project_dir"|"$project_dir"/*) : ;;
88+
/*) exit 0 ;;
89+
esac
90+
else
91+
target="$(pwd)"
92+
fi
93+
94+
[ -d "$target" ] || exit 0
95+
96+
if [ -n "${unresolved:-}" ]; then
97+
cat >&2 <<EOF
98+
Blocked: cannot verify where this git command points. Its target path
99+
($unresolved) uses a shell variable this hook cannot see — a PreToolUse hook
100+
does not share your shell's state, so it may well be the shared primary
101+
checkout, where branch switching/merging clobbers other agents' sessions.
102+
103+
Re-run it with a literal path, which this hook can check:
104+
git -C /absolute/path/to/worktree <verb> ...
105+
EOF
106+
exit 2
107+
fi
108+
109+
# Only guard when the target is inside a git repo at all.
110+
git_dir="$(git -C "$target" rev-parse --git-dir 2>/dev/null || true)"
111+
common_dir="$(git -C "$target" rev-parse --git-common-dir 2>/dev/null || true)"
112+
{ [ -n "$git_dir" ] && [ -n "$common_dir" ]; } || exit 0
113+
114+
# Normalize to absolute paths (git may return them relative to the target).
115+
abspath() {
116+
local p="$1"
117+
case "$p" in
118+
/*) : ;;
119+
*) p="$target/$p" ;;
120+
esac
121+
(cd "$(dirname "$p")" 2>/dev/null && echo "$(pwd)/$(basename "$p")") || echo "$p"
122+
}
123+
git_dir_abs="$(abspath "$git_dir")"
124+
common_dir_abs="$(abspath "$common_dir")"
125+
126+
# In a linked worktree, --git-dir (.../.git/worktrees/<name>) differs from
127+
# --git-common-dir (.../.git). In the primary checkout they're the same.
128+
# Mutating git state is safe in a linked worktree, so allow it there.
129+
if [ "$git_dir_abs" != "$common_dir_abs" ]; then
130+
exit 0
131+
fi
132+
133+
cat >&2 <<'EOF'
134+
Blocked: this git command targets the shared primary checkout — other agents
135+
may be working there right now. Do not switch branches, merge, rebase, reset
136+
or cherry-pick in it: that yanks the branch out from under another agent's
137+
session and litters the tree with leftovers. `git stash` is blocked for the
138+
same reason — in a shared checkout it silently pockets *other agents'*
139+
uncommitted work.
140+
141+
Create your own worktree and work there instead, e.g.:
142+
git worktree add --detach <scratchpad>/<name> <ref>
143+
# or, for a new branch:
144+
git worktree add -b <branch> <dir> <base>
145+
146+
Then target it explicitly — both of these forms are recognised:
147+
git -C <worktree> switch <branch>
148+
cd <worktree> && git merge main
149+
150+
Note: this hook cannot see your shell's persistent working directory, so a
151+
bare `git switch` is always judged against the primary checkout even if you
152+
cd'd into a worktree in an earlier call. Use `git -C <worktree> ...`.
153+
154+
Read-only git commands (status, log, diff, show, fetch, worktree, rev-parse,
155+
branch listing, etc.) and file-only `git checkout -- <path>` restores are
156+
fine to run here.
157+
EOF
158+
exit 2

.claude/settings.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,12 @@
4545
"command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/test-reminder.sh\"",
4646
"if": "Bash(git push:*)",
4747
"timeout": 10
48+
},
49+
{
50+
"type": "command",
51+
"command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/worktree-guard.sh\"",
52+
"if": "Bash(git:*)",
53+
"timeout": 10
4854
}
4955
]
5056
}

.github/workflows/docker-publish.yml

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ on:
1616
tag:
1717
description: "Git tag to build/push (e.g. v1.0.0). Required when run manually."
1818
required: false
19+
latest:
20+
description: "Also push as `latest`. Only tick this when re-running a stable release; never for a pre-release."
21+
type: boolean
22+
required: false
23+
default: false
1924

2025
permissions:
2126
contents: read
@@ -42,8 +47,24 @@ jobs:
4247
# on the `release` webhook payload, reflecting whether the "Set as
4348
# a pre-release" checkbox was ticked when the GitHub Release was
4449
# created. We only want stable releases to also claim `:latest`.
50+
#
51+
# On `workflow_dispatch` there is no release payload at all, so that
52+
# field renders as an empty string -- which would sail past a bare
53+
# `!= "true"` check and clobber `:latest` with a manually built image.
54+
# Hence the explicit event check, with an opt-in input to keep the
55+
# documented "re-run a stable release" path working.
56+
if [ "${{ github.event_name }}" = "release" ]; then
57+
if [ "${{ github.event.release.prerelease }}" = "true" ]; then
58+
PUSH_LATEST=false
59+
else
60+
PUSH_LATEST=true
61+
fi
62+
else
63+
PUSH_LATEST="${{ inputs.latest }}"
64+
fi
65+
4566
IMAGE_TAGS="lfenergy/flexmeasures:${TAG}"
46-
if [ "${{ github.event.release.prerelease }}" != "true" ]; then
67+
if [ "$PUSH_LATEST" = "true" ]; then
4768
IMAGE_TAGS="${IMAGE_TAGS}
4869
lfenergy/flexmeasures:latest"
4970
fi

.pre-commit-config.yaml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,14 @@ repos:
2929
pass_filenames: false
3030
language: system
3131
entry: bash -c '[[ "$GITHUB_ACTIONS" == "true" ]] && exit 0 || (uv run poe generate-open-api-specs)'
32+
- repo: local
33+
hooks:
34+
- id: no-image-files
35+
name: Block image files from being committed
36+
language: fail
37+
entry: >-
38+
Image files must not be committed to this repo (they bloat .git forever).
39+
Put screenshots in the FlexMeasures/screenshots repo, or leave them
40+
untracked and attach them to the PR/issue manually.
41+
files: '\.(png|jpe?g|gif|bmp|tiff?|webp|ico|psd)$'
42+
exclude: '^(flexmeasures/ui/static/|documentation/)'

AGENTS.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,14 @@ repeatable lesson was learned — not after every session. When you do update on
108108
`.github/instructions/ui-terminology.instructions.md`.
109109
- **Docstrings/comments**: exactly one space after punctuation — see
110110
`.github/instructions/docstrings.instructions.md`.
111+
- **The primary checkout is shared** by multiple agent sessions. Never `git checkout`/`switch`/
112+
`merge`/`rebase`/`reset`/`cherry-pick`/`stash` there — it can switch the branch or rewrite
113+
history out from under another agent, and `stash` pockets their uncommitted work. Do your work
114+
in your own `git worktree` instead (`git worktree add --detach <scratchpad>/<name> <ref>`), and
115+
target it with a **literal path**: `git -C /abs/path/to/worktree switch <branch>`. Read-only git
116+
commands are fine in the primary checkout. A `PreToolUse` hook
117+
(`.claude/hooks/worktree-guard.sh`) enforces this; it can't see your shell's cwd or variables,
118+
so a bare `git switch` after an earlier `cd`, or a `-C "$VAR"`, is blocked on the safe side.
111119

112120
## Session close checklist
113121

documentation/changelog.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ New features
2424
* Support multiple feeders to a shared storage [see `PR #2001 <https://www.github.com/FlexMeasures/flexmeasures/pull/2001>`_ ]
2525
* The flex-context can now define multiple commodities, each specifying their own prices and grid capacities [see `PR #1946 <https://www.github.com/FlexMeasures/flexmeasures/pull/1946>`_, `PR #2172 <https://www.github.com/FlexMeasures/flexmeasures/pull/2172>`_, `PR #2235 <https://www.github.com/FlexMeasures/flexmeasures/pull/2235>`_ and `PR #2271 <https://www.github.com/FlexMeasures/flexmeasures/pull/2271>`_]
2626
* CLI support for adding/editing account attributes [see `PR #2242 <https://www.github.com/FlexMeasures/flexmeasures/pull/2242>`_]
27+
* Improve chart axis domain for event values not around zero, with a per-sub-chart ``y-axis`` option in ``sensors_to_show`` (default ``zero``, which pads the axis out to include zero) that can be set to ``data`` to fit a sub-chart's y-axis to the values shown, to an explicit ``[min, max]`` domain that the axis will cover at least (expanding to fit data beyond it), or to a strict ``{"min": min, "max": max}`` domain that the axis will never exceed (clamping data beyond it, with a warning when that happens), editable from the graph editor [see `PR #2244 <https://www.github.com/FlexMeasures/flexmeasures/pull/2244>`_]
2728
* Extended ``GET /api/v3_0/jobs/<uuid>`` with a ``result`` field containing ``unresolved`` and ``resolved`` soft state-of-charge constraint analysis (``soc-minima``/``soc-maxima`` violations or satisfied constraints, keyed by asset ID) for scheduling jobs; both arrays are empty when no SoC constraints were defined [see `PR #2072 <https://www.github.com/FlexMeasures/flexmeasures/pull/2072>`_]
2829
* Extended the scheduling job ``result`` field with a ``num-beliefs`` field reporting the total number of beliefs (scheduled values) saved to the database [see `PR #2280 <https://www.github.com/FlexMeasures/flexmeasures/pull/2280>`_]
2930

@@ -40,9 +41,13 @@ Infrastructure / Support
4041
* Automate Docker Hub image publishing and a PyPI install smoke test on release, add a manually-triggered QA workflow that runs the toy tutorials and HEMS walkthrough against a local Docker Compose stack, and add a helper script to list merged PRs since the last tag [see `PR #2260 <https://www.github.com/FlexMeasures/flexmeasures/pull/2260>`_]
4142
* Make toy tutorials robust against pre-existing IDs [see `PR #2269 <https://www.github.com/FlexMeasures/flexmeasures/pull/2269>`_]
4243
* Document multi-tenancy and consultancy tenant structures for hosts [see `PR #2176 <https://www.github.com/FlexMeasures/flexmeasures/pull/2176>`_]
44+
* Warn hosts when the database schema is not at the latest migration, and skip startup template provisioning until migrations are applied [see `PR #2309 <https://www.github.com/FlexMeasures/flexmeasures/pull/2309>`_]
45+
* Stop manual runs of the Docker publishing workflow from overwriting the ``latest`` image tag, and let them opt in to it explicitly [see `PR #2316 <https://www.github.com/FlexMeasures/flexmeasures/pull/2316>`_]
46+
* Add a pre-commit hook that blocks image files (png, jpg, gif, bmp, tiff, webp, ico, psd) from being committed outside of ``flexmeasures/ui/static/`` and ``documentation/``, to protect the git history from binary bloat; screenshots belong in the ``FlexMeasures/screenshots`` repo instead [see `PR #2315 <https://www.github.com/FlexMeasures/flexmeasures/pull/2315>`_]
4347

4448
Bugfixes
4549
-----------
50+
* Fix column sorting on the assets page, including when combined with the search filter [see `PR #2314 <https://www.github.com/FlexMeasures/flexmeasures/pull/2314>`_]
4651
* Fix forecasting with past or future regressors, which raised a ``TypeError`` on pandas 2.2 and higher [see `PR #2303 <https://www.github.com/FlexMeasures/flexmeasures/pull/2303>`_]
4752
* Show why a CLI option was rejected (e.g. "No account found with id 9999") instead of only echoing the offending value [see `PR #2303 <https://www.github.com/FlexMeasures/flexmeasures/pull/2303>`_]
4853
* Fix the task detail page returning a 500 for jobs whose meta data is not JSON-serializable [see `PR #2303 <https://www.github.com/FlexMeasures/flexmeasures/pull/2303>`_]

documentation/dev/release_process.rst

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,9 @@ Publishing the GitHub Release (step 3) triggers two workflows:
5353
Check the Actions tab for both workflow runs to confirm they succeeded; also spot-check that the new version shows up on `PyPI <https://pypi.org/project/flexmeasures>`_, `Docker Hub <https://hub.docker.com/r/lfenergy/flexmeasures>`_, and that the ReadTheDocs build for the new tag completed.
5454

5555
.. note::
56-
The ``docker-publish.yml`` workflow can also be re-run manually (``workflow_dispatch``, with a ``tag`` input) if a run needs to be retried.
56+
The ``docker-publish.yml`` workflow can also be run manually (``workflow_dispatch``, with a ``tag`` input) if a run needs to be retried, or to build an image from a tag that has no GitHub Release (e.g. a pre-release image for a downstream plugin).
57+
A manual run only pushes ``lfenergy/flexmeasures:<tag>``; it claims ``latest`` solely when you tick the ``latest`` input, which you should do only when re-running a *stable* release.
58+
Note that ``workflow_dispatch`` reads the workflow file from the ref you pick in the "Use workflow from" dropdown, whereas the ``tag`` input determines which ref is checked out and built.
5759

5860
5. Announce (manual)
5961
----------------------

flexmeasures/api/v3_0/tests/test_assets_api.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,77 @@ def test_get_assets(
170170
assert turbine["account_id"] == setup_accounts["Supplier"].id
171171

172172

173+
@pytest.mark.parametrize("requesting_user", ["test_admin_user@seita.nl"], indirect=True)
174+
def test_get_assets_sort_by_owner_uses_account_name(
175+
client, setup_api_test_data, setup_accounts, requesting_user
176+
):
177+
"""Sorting by 'owner' should order by the account's name, not its (arbitrary) id."""
178+
account_name_by_id = {
179+
account.id: account.name for account in setup_accounts.values()
180+
}
181+
182+
query = {"all_accessible": True, "sort_by": "owner", "sort_dir": "asc"}
183+
response = client.get(url_for("AssetAPI:index"), query_string=query)
184+
print("Server responded with:\n%s" % response.json)
185+
assert response.status_code == 200
186+
187+
account_names_seen = [
188+
account_name_by_id.get(asset["account_id"])
189+
for asset in response.json
190+
if asset["account_id"] is not None
191+
]
192+
assert account_names_seen == sorted(account_names_seen)
193+
194+
195+
@pytest.mark.parametrize("requesting_user", ["test_admin_user@seita.nl"], indirect=True)
196+
@pytest.mark.parametrize("sort_by", ["id", "name", "owner"])
197+
def test_get_assets_sort_with_search_filter(
198+
client, setup_api_test_data, setup_accounts, sort_by, requesting_user
199+
):
200+
"""Sorting must still apply when combined with a search filter.
201+
202+
Regression test: sorting is applied to a UNION ALL of a private-assets and
203+
a public-assets query; ordering the individual union members doesn't
204+
guarantee the combined result is ordered, and the "owner" sort clause
205+
used to raise a 500 (auto-correlation error) when combined with a filter.
206+
207+
We compare the asc- and desc-sorted asset ID sequences (rather than
208+
asserting a specific order) to sidestep Postgres/Python string collation
209+
differences: what matters here is that both directions are actually
210+
sorted (each other's reverse) and that the filter is respected.
211+
"""
212+
213+
def get_sort_key(asset):
214+
# for ties (e.g. two assets under the same owner), only the sorted-on
215+
# value's order is guaranteed to reverse between asc and desc, not
216+
# the tie-breaking order of ids within a repeated value
217+
if sort_by == "owner":
218+
return asset["account_id"]
219+
return asset[sort_by]
220+
221+
def get_ids_and_values(sort_dir):
222+
query = {
223+
"all_accessible": True,
224+
"filter": "e", # broad filter matching multiple assets/accounts
225+
"sort_by": sort_by,
226+
"sort_dir": sort_dir,
227+
}
228+
response = client.get(url_for("AssetAPI:index"), query_string=query)
229+
print("Server responded with:\n%s" % response.json)
230+
assert response.status_code == 200
231+
ids = [asset["id"] for asset in response.json]
232+
values = [get_sort_key(asset) for asset in response.json]
233+
return ids, values
234+
235+
ids_asc, values_asc = get_ids_and_values("asc")
236+
ids_desc, values_desc = get_ids_and_values("desc")
237+
238+
assert len(ids_asc) > 1 # otherwise sorting isn't actually tested
239+
assert set(ids_asc) == set(ids_desc) # filter is respected either way
240+
assert values_asc == list(reversed(values_desc))
241+
assert values_asc != values_desc # sorting actually took effect
242+
243+
173244
@pytest.mark.parametrize("requesting_user", ["test_admin_user@seita.nl"], indirect=True)
174245
def test_get_assets_filtered_by_asset_type(
175246
client, setup_api_test_data, setup_accounts, requesting_user

0 commit comments

Comments
 (0)