mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-11 15:54:11 +01:00
Compare commits
67 Commits
sql-drills
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b0fad2b2e5 | |||
| c83d41a594 | |||
| 2bbfbd9439 | |||
| 2d341dd41e | |||
| 3084080d74 | |||
| 9d6b17fd28 | |||
| ae56037e41 | |||
| eef7af4c86 | |||
| 9daa376254 | |||
| 17e300af0a | |||
| afcc1a47c7 | |||
| 0527bc680d | |||
| 21a0b5ce1c | |||
| 4c1ffd900b | |||
| 5a87868763 | |||
| fe4e34287a | |||
| 2f4f79bd6e | |||
| 51b747a364 | |||
| 89f464828a | |||
| eaf7282ac1 | |||
| 6641d85f05 | |||
| 46ab26fa15 | |||
| f71b0532fd | |||
| 3017bbf220 | |||
| fd81332f27 | |||
| ebedbf4b27 | |||
| d4fca83c9f | |||
| 3121639732 | |||
| 59b33949e4 | |||
| 027e67b197 | |||
| 8dfc4fb55f | |||
| 4fee294aa0 | |||
| 1a74345c40 | |||
| 71ea92f3c2 | |||
| 7f3f79f58d | |||
| 97d043d424 | |||
| 7af6afe51a | |||
| c2165e9fb8 | |||
| 26079c34c2 | |||
| 22fdf2c344 | |||
| de7d5d17eb | |||
| 640985a421 | |||
| 70c8aa8fbb | |||
| ba4dd58545 | |||
| b50e245ceb | |||
| 88e4be5042 | |||
| 737bf624ca | |||
| 716231a7f3 | |||
| 11cb1d81d9 | |||
| e0c9befdeb | |||
| 612b0b3c0a | |||
| 1bfacdf51f | |||
| 99f4e171e3 | |||
| 2c926928dc | |||
| a78c1e971b | |||
| 2531f9eeb3 | |||
| f47ed80584 | |||
| 2a9732a312 | |||
| e06df74caf | |||
| 2d2d8a41f8 | |||
| 6343499ed1 | |||
| ebf7ca624e | |||
| c22b14389a | |||
| c0e902e5cc | |||
| 28c243d066 | |||
| 96a0d8af07 | |||
| b730cffaf4 |
73
.github/workflows/holds-precommit.yml
vendored
Normal file
73
.github/workflows/holds-precommit.yml
vendored
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
name: holds pre-commit
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
# The workflow's own file counts as holds: editing only this file would
|
||||||
|
# otherwise trigger nothing, so a fix to the job itself could never prove
|
||||||
|
# itself.
|
||||||
|
paths: ['holds/**', '.github/workflows/holds-precommit.yml']
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
paths: ['holds/**', '.github/workflows/holds-precommit.yml']
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
pre-commit:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.x"
|
||||||
|
# Only pre-commit itself. Every linting hook runs in its own isolated
|
||||||
|
# env -- ruff, mypy, pylint, bandit and codespell each come from their
|
||||||
|
# own repo+rev, and the local hooks need nothing beyond bash and
|
||||||
|
# python3 -- so installing the full requirements.txt spent 9s a run on
|
||||||
|
# packages no pre-commit-stage hook imports. Verified by running all
|
||||||
|
# three configs green in a venv holding pre-commit and nothing else.
|
||||||
|
# The pin is read out of requirements.txt rather than repeated here, so
|
||||||
|
# it cannot drift from what the dependency-freshness gate governs.
|
||||||
|
- name: Install pre-commit
|
||||||
|
run: |
|
||||||
|
pip install "$(grep '^pre-commit==' holds/requirements.txt)"
|
||||||
|
|
||||||
|
# Keyed on THIS package's config. pre-commit/action@v3.0.1 caches on
|
||||||
|
# hashFiles('.pre-commit-config.yaml') -- the repo ROOT's -- so all three
|
||||||
|
# package jobs shared one key, raced to overwrite it with hook sets built
|
||||||
|
# from four different configs, and editing a per-package config did not
|
||||||
|
# invalidate it. The cache was therefore silently wrong rather than
|
||||||
|
# missing, and the hooks reinstalled from scratch on every run (38-49s).
|
||||||
|
- uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.cache/pre-commit
|
||||||
|
key: >-
|
||||||
|
pre-commit-holds-${{ env.pythonLocation }}-${{
|
||||||
|
hashFiles('holds/.pre-commit-config.yaml') }}
|
||||||
|
# Prefix fallback, or this fix would evaporate on the very
|
||||||
|
# event that motivated it: bumping a hook rev changes the
|
||||||
|
# exact key, and a total miss means reinstalling every hook
|
||||||
|
# again. pre-commit's store is keyed internally by hook
|
||||||
|
# repo+rev, so a superseded entry is ignored disk rather
|
||||||
|
# than a wrong hook, and cache still re-saves under the new
|
||||||
|
# exact key after a prefix-only hit.
|
||||||
|
restore-keys: pre-commit-holds-${{ env.pythonLocation }}-
|
||||||
|
# mypy re-analyses the stdlib and every third-party stub from scratch
|
||||||
|
# without this, which measured 10s of the hook step in CI against 0.25s
|
||||||
|
# warm locally -- a 19MB cache dir does the whole difference. Keyed on
|
||||||
|
# the config, not the sources: mypy's own cache metadata records the
|
||||||
|
# interpreter, the flag set and a hash per source file, so it re-checks
|
||||||
|
# what changed and cannot pass stale code. Deliberately NOT keyed on
|
||||||
|
# github.sha -- that re-saves 19MB x3 every commit and would evict the
|
||||||
|
# 68MB hook caches under the repo's 10GB LRU budget. A fixed key keeps
|
||||||
|
# the expensive part, the dependency analysis, which is what costs 10s.
|
||||||
|
- uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: .mypy_cache
|
||||||
|
key: >-
|
||||||
|
mypy-holds-${{ env.pythonLocation }}-${{
|
||||||
|
hashFiles('holds/.pre-commit-config.yaml') }}
|
||||||
|
restore-keys: mypy-holds-${{ env.pythonLocation }}-
|
||||||
|
- name: Run the hooks
|
||||||
|
run: >-
|
||||||
|
pre-commit run --config holds/.pre-commit-config.yaml
|
||||||
|
--all-files --show-diff-on-failure
|
||||||
34
.github/workflows/holds-tests.yml
vendored
Normal file
34
.github/workflows/holds-tests.yml
vendored
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
name: holds tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
# The workflow's own file counts as holds: editing only this file would
|
||||||
|
# otherwise trigger nothing, so a fix to the job itself could never prove
|
||||||
|
# itself.
|
||||||
|
paths: ['holds/**', '.github/workflows/holds-tests.yml']
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
paths: ['holds/**', '.github/workflows/holds-tests.yml']
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: holds
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.x"
|
||||||
|
cache: pip
|
||||||
|
cache-dependency-path: holds/requirements.txt
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pip install -r requirements.txt && pip install --no-deps -e .
|
||||||
|
|
||||||
|
- name: Run tests with coverage
|
||||||
|
run: python -m pytest -v
|
||||||
73
.github/workflows/leetcode-coach-precommit.yml
vendored
Normal file
73
.github/workflows/leetcode-coach-precommit.yml
vendored
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
name: leetcode-coach pre-commit
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
# The workflow's own file counts as leetcode_coach: editing only this file would
|
||||||
|
# otherwise trigger nothing, so a fix to the job itself could never prove
|
||||||
|
# itself.
|
||||||
|
paths: ['leetcode_coach/**', '.github/workflows/leetcode-coach-precommit.yml']
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
paths: ['leetcode_coach/**', '.github/workflows/leetcode-coach-precommit.yml']
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
pre-commit:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.x"
|
||||||
|
# Only pre-commit itself. Every linting hook runs in its own isolated
|
||||||
|
# env -- ruff, mypy, pylint, bandit and codespell each come from their
|
||||||
|
# own repo+rev, and the local hooks need nothing beyond bash and
|
||||||
|
# python3 -- so installing the full requirements.txt spent 9s a run on
|
||||||
|
# packages no pre-commit-stage hook imports. Verified by running all
|
||||||
|
# three configs green in a venv holding pre-commit and nothing else.
|
||||||
|
# The pin is read out of requirements.txt rather than repeated here, so
|
||||||
|
# it cannot drift from what the dependency-freshness gate governs.
|
||||||
|
- name: Install pre-commit
|
||||||
|
run: |
|
||||||
|
pip install "$(grep '^pre-commit==' leetcode_coach/requirements.txt)"
|
||||||
|
|
||||||
|
# Keyed on THIS package's config. pre-commit/action@v3.0.1 caches on
|
||||||
|
# hashFiles('.pre-commit-config.yaml') -- the repo ROOT's -- so all three
|
||||||
|
# package jobs shared one key, raced to overwrite it with hook sets built
|
||||||
|
# from four different configs, and editing a per-package config did not
|
||||||
|
# invalidate it. The cache was therefore silently wrong rather than
|
||||||
|
# missing, and the hooks reinstalled from scratch on every run (38-49s).
|
||||||
|
- uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.cache/pre-commit
|
||||||
|
key: >-
|
||||||
|
pre-commit-leetcode-coach-${{ env.pythonLocation }}-${{
|
||||||
|
hashFiles('leetcode_coach/.pre-commit-config.yaml') }}
|
||||||
|
# Prefix fallback, or this fix would evaporate on the very
|
||||||
|
# event that motivated it: bumping a hook rev changes the
|
||||||
|
# exact key, and a total miss means reinstalling every hook
|
||||||
|
# again. pre-commit's store is keyed internally by hook
|
||||||
|
# repo+rev, so a superseded entry is ignored disk rather
|
||||||
|
# than a wrong hook, and cache still re-saves under the new
|
||||||
|
# exact key after a prefix-only hit.
|
||||||
|
restore-keys: pre-commit-leetcode-coach-${{ env.pythonLocation }}-
|
||||||
|
# mypy re-analyses the stdlib and every third-party stub from scratch
|
||||||
|
# without this, which measured 10s of the hook step in CI against 0.25s
|
||||||
|
# warm locally -- a 19MB cache dir does the whole difference. Keyed on
|
||||||
|
# the config, not the sources: mypy's own cache metadata records the
|
||||||
|
# interpreter, the flag set and a hash per source file, so it re-checks
|
||||||
|
# what changed and cannot pass stale code. Deliberately NOT keyed on
|
||||||
|
# github.sha -- that re-saves 19MB x3 every commit and would evict the
|
||||||
|
# 68MB hook caches under the repo's 10GB LRU budget. A fixed key keeps
|
||||||
|
# the expensive part, the dependency analysis, which is what costs 10s.
|
||||||
|
- uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: .mypy_cache
|
||||||
|
key: >-
|
||||||
|
mypy-leetcode-coach-${{ env.pythonLocation }}-${{
|
||||||
|
hashFiles('leetcode_coach/.pre-commit-config.yaml') }}
|
||||||
|
restore-keys: mypy-leetcode-coach-${{ env.pythonLocation }}-
|
||||||
|
- name: Run the hooks
|
||||||
|
run: >-
|
||||||
|
pre-commit run --config leetcode_coach/.pre-commit-config.yaml
|
||||||
|
--all-files --show-diff-on-failure
|
||||||
34
.github/workflows/leetcode-coach-tests.yml
vendored
Normal file
34
.github/workflows/leetcode-coach-tests.yml
vendored
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
name: leetcode-coach tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
# The workflow's own file counts as leetcode_coach: editing only this file would
|
||||||
|
# otherwise trigger nothing, so a fix to the job itself could never prove
|
||||||
|
# itself.
|
||||||
|
paths: ['leetcode_coach/**', '.github/workflows/leetcode-coach-tests.yml']
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
paths: ['leetcode_coach/**', '.github/workflows/leetcode-coach-tests.yml']
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: leetcode_coach
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.x"
|
||||||
|
cache: pip
|
||||||
|
cache-dependency-path: leetcode_coach/requirements.txt
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pip install -r requirements.txt && pip install --no-deps -e .
|
||||||
|
|
||||||
|
- name: Run tests with coverage
|
||||||
|
run: python -m pytest -v
|
||||||
2
.github/workflows/music-theory-tests.yml
vendored
2
.github/workflows/music-theory-tests.yml
vendored
@ -28,4 +28,4 @@ jobs:
|
|||||||
run: pip install -r requirements.txt
|
run: pip install -r requirements.txt
|
||||||
|
|
||||||
- name: Run tests with coverage
|
- name: Run tests with coverage
|
||||||
run: python -m pytest -v
|
run: python -m pytest -v --cov=music_theory
|
||||||
|
|||||||
73
.github/workflows/omniroute_offload-precommit.yml
vendored
Normal file
73
.github/workflows/omniroute_offload-precommit.yml
vendored
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
name: omniroute_offload pre-commit
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
# The workflow's own file counts as omniroute_offload: editing only this file would
|
||||||
|
# otherwise trigger nothing, so a fix to the job itself could never prove
|
||||||
|
# itself.
|
||||||
|
paths: ['omniroute_offload/**', '.github/workflows/omniroute_offload-precommit.yml']
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
paths: ['omniroute_offload/**', '.github/workflows/omniroute_offload-precommit.yml']
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
pre-commit:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.x"
|
||||||
|
# Only pre-commit itself. Every linting hook runs in its own isolated
|
||||||
|
# env -- ruff, mypy, pylint, bandit and codespell each come from their
|
||||||
|
# own repo+rev, and the local hooks need nothing beyond bash and
|
||||||
|
# python3 -- so installing the full requirements.txt spent 9s a run on
|
||||||
|
# packages no pre-commit-stage hook imports. Verified by running all
|
||||||
|
# three configs green in a venv holding pre-commit and nothing else.
|
||||||
|
# The pin is read out of requirements.txt rather than repeated here, so
|
||||||
|
# it cannot drift from what the dependency-freshness gate governs.
|
||||||
|
- name: Install pre-commit
|
||||||
|
run: |
|
||||||
|
pip install "$(grep '^pre-commit==' omniroute_offload/requirements.txt)"
|
||||||
|
|
||||||
|
# Keyed on THIS package's config. pre-commit/action@v3.0.1 caches on
|
||||||
|
# hashFiles('.pre-commit-config.yaml') -- the repo ROOT's -- so all three
|
||||||
|
# package jobs shared one key, raced to overwrite it with hook sets built
|
||||||
|
# from four different configs, and editing a per-package config did not
|
||||||
|
# invalidate it. The cache was therefore silently wrong rather than
|
||||||
|
# missing, and the hooks reinstalled from scratch on every run (38-49s).
|
||||||
|
- uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.cache/pre-commit
|
||||||
|
key: >-
|
||||||
|
pre-commit-omniroute_offload-${{ env.pythonLocation }}-${{
|
||||||
|
hashFiles('omniroute_offload/.pre-commit-config.yaml') }}
|
||||||
|
# Prefix fallback, or this fix would evaporate on the very
|
||||||
|
# event that motivated it: bumping a hook rev changes the
|
||||||
|
# exact key, and a total miss means reinstalling every hook
|
||||||
|
# again. pre-commit's store is keyed internally by hook
|
||||||
|
# repo+rev, so a superseded entry is ignored disk rather
|
||||||
|
# than a wrong hook, and cache still re-saves under the new
|
||||||
|
# exact key after a prefix-only hit.
|
||||||
|
restore-keys: pre-commit-omniroute_offload-${{ env.pythonLocation }}-
|
||||||
|
# mypy re-analyses the stdlib and every third-party stub from scratch
|
||||||
|
# without this, which measured 10s of the hook step in CI against 0.25s
|
||||||
|
# warm locally -- a 19MB cache dir does the whole difference. Keyed on
|
||||||
|
# the config, not the sources: mypy's own cache metadata records the
|
||||||
|
# interpreter, the flag set and a hash per source file, so it re-checks
|
||||||
|
# what changed and cannot pass stale code. Deliberately NOT keyed on
|
||||||
|
# github.sha -- that re-saves 19MB x3 every commit and would evict the
|
||||||
|
# 68MB hook caches under the repo's 10GB LRU budget. A fixed key keeps
|
||||||
|
# the expensive part, the dependency analysis, which is what costs 10s.
|
||||||
|
- uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: .mypy_cache
|
||||||
|
key: >-
|
||||||
|
mypy-omniroute_offload-${{ env.pythonLocation }}-${{
|
||||||
|
hashFiles('omniroute_offload/.pre-commit-config.yaml') }}
|
||||||
|
restore-keys: mypy-omniroute_offload-${{ env.pythonLocation }}-
|
||||||
|
- name: Run the hooks
|
||||||
|
run: >-
|
||||||
|
pre-commit run --config omniroute_offload/.pre-commit-config.yaml
|
||||||
|
--all-files --show-diff-on-failure
|
||||||
@ -13,7 +13,7 @@
|
|||||||
# linters, and carry this same hook so committing with one of those configs is
|
# linters, and carry this same hook so committing with one of those configs is
|
||||||
# gated too. This root config is what `pre-commit install` wires up, and it is
|
# gated too. This root config is what `pre-commit install` wires up, and it is
|
||||||
# the only one that covers the subprojects without a config of their own
|
# the only one that covers the subprojects without a config of their own
|
||||||
# (aur-publish/, guard-lib/, crdt_sync_dart/, file_length/, ...).
|
# (guard-lib/, crdt_sync_dart/, file_length/, ...).
|
||||||
#
|
#
|
||||||
# The path is repo-relative on purpose. pre-commit always runs hooks from the
|
# The path is repo-relative on purpose. pre-commit always runs hooks from the
|
||||||
# git root, so this resolves the same locally and on a CI runner -- an absolute
|
# git root, so this resolves the same locally and on a CI runner -- an absolute
|
||||||
@ -66,9 +66,14 @@ repos:
|
|||||||
# answer and passes with a loud banner; `--no-verify` is banned here, so
|
# answer and passes with a loud banner; `--no-verify` is banned here, so
|
||||||
# a hook that hard-failed on a flaky connection would leave no way to
|
# a hook that hard-failed on a flaky connection would leave no way to
|
||||||
# commit at all. CI, always online, is the strict authority.
|
# commit at all. CI, always online, is the strict authority.
|
||||||
|
#
|
||||||
|
# Scoped to manifests this branch changed (vs merge-base with main), so
|
||||||
|
# `--all-files` asks "did THIS change leave a stale pin?" and not "is
|
||||||
|
# every project in the monorepo current?". CI `--all --strict` keeps
|
||||||
|
# the repo-wide answer. See scripts/dep_freshness_changed.sh.
|
||||||
- id: dependency-freshness
|
- id: dependency-freshness
|
||||||
name: dependencies on newest stable
|
name: dependencies on newest stable
|
||||||
entry: bash scripts/check_dependency_freshness.sh
|
entry: bash scripts/dep_freshness_changed.sh
|
||||||
language: system
|
language: system
|
||||||
pass_filenames: true
|
pass_filenames: true
|
||||||
files: >-
|
files: >-
|
||||||
|
|||||||
@ -16,3 +16,4 @@ overwrite it with `repo_contract/templates/check_repo_contract.shim.sh`.
|
|||||||
- lint: `ruff check agent_ready coverage_gaps flutter_web_drive repo_contract`
|
- lint: `ruff check agent_ready coverage_gaps flutter_web_drive repo_contract`
|
||||||
- coverage: `PYTHONPATH=. python -m pytest -q agent_ready/tests flutter_web_drive/tests repo_contract/tests unified-design-system/tests --cov=agent_ready --cov=flutter_web_drive --cov=repo_contract --cov=conformance --cov=conformance_diff --cov-branch --cov-report=xml --cov-fail-under=100`
|
- coverage: `PYTHONPATH=. python -m pytest -q agent_ready/tests flutter_web_drive/tests repo_contract/tests unified-design-system/tests --cov=agent_ready --cov=flutter_web_drive --cov=repo_contract --cov=conformance --cov=conformance_diff --cov-branch --cov-report=xml --cov-fail-under=100`
|
||||||
- coverage-gaps: `coverage-gaps coverage.xml`
|
- coverage-gaps: `coverage-gaps coverage.xml`
|
||||||
|
- deploy: `git -C ~/src/utils pull --ff-only && ~/src/utils/scripts/deploy.sh` (installs the sickdays CLI + launcher from the live checkout, then exits 1 unless every released library's version tag is on origin and matches HEAD and the live `sickdays` runs the text-free checkout; `--verify-only` for the health check alone)
|
||||||
|
|||||||
@ -1,45 +0,0 @@
|
|||||||
# aur-publish
|
|
||||||
|
|
||||||
Hands-off publishing of kuhy's Flutter apps to the AUR.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
~/src/utils/aur-publish/publish.sh # wait for registration, then publish
|
|
||||||
~/src/utils/aur-publish/publish.sh --dry-run # everything except the push
|
|
||||||
~/src/utils/aur-publish/publish.sh --skip-wait # account already works
|
|
||||||
~/src/utils/aur-publish/publish.sh --only habit-stack
|
|
||||||
```
|
|
||||||
|
|
||||||
## What it does
|
|
||||||
|
|
||||||
1. Polls `/register` until AUR signup reopens, opens it in a browser and
|
|
||||||
prints the SSH key to paste. **Signup is the only manual step** — it
|
|
||||||
requires email confirmation.
|
|
||||||
2. Waits for `ssh aur@aur.archlinux.org` to authenticate.
|
|
||||||
3. Then, unattended, per package: resolve the newest `vX.Y.Z` tag → set
|
|
||||||
`pkgver` → `updpkgsums` → `makepkg -C` → `namcap` → AUR rule check →
|
|
||||||
`pacman -U` → launch check → `.SRCINFO` → commit → push.
|
|
||||||
|
|
||||||
Every package is built, installed and **launched** before it is pushed, so a
|
|
||||||
broken package cannot reach the AUR. A package failing any gate is skipped and
|
|
||||||
reported; the others still publish. Safe to re-run.
|
|
||||||
|
|
||||||
## anubis_gate.py
|
|
||||||
|
|
||||||
The AUR sits behind [Anubis](https://anubis.techaro.lol/), which answers HTML
|
|
||||||
requests with a proof-of-work challenge page carrying **HTTP 200**. A plain
|
|
||||||
`curl` therefore reports 200 for a page that is actually unreachable — which is
|
|
||||||
exactly how "is registration back?" gets answered wrong. This module solves the
|
|
||||||
challenge and reports the status of the *real* page behind it.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 anubis_gate.py /register # prints the true status code
|
|
||||||
```
|
|
||||||
|
|
||||||
## AUR rules enforced before pushing
|
|
||||||
|
|
||||||
The AUR does not review uploads, but per the submission guidelines "packages
|
|
||||||
that violate the rules may be deleted without warning". `check_aur_rules()`
|
|
||||||
blocks a push on the mechanically checkable ones: `# Maintainer:` line,
|
|
||||||
`x86_64` in `arch`, no `replaces=` (reserved for renames — use `conflicts`),
|
|
||||||
not already in an official repo, `pkgdesc`/`url`/`license` present, and
|
|
||||||
`.SRCINFO` matching the PKGBUILD.
|
|
||||||
@ -1,138 +0,0 @@
|
|||||||
"""Fetch an AUR URL from behind the Anubis anti-bot gate.
|
|
||||||
|
|
||||||
The AUR sits behind Anubis (https://anubis.techaro.lol/), which answers every
|
|
||||||
HTML request with a proof-of-work challenge page carrying HTTP **200**. A bare
|
|
||||||
`curl` therefore reports 200 for a page that is actually unreachable, which is
|
|
||||||
exactly how "is /register back up?" gets answered wrong.
|
|
||||||
|
|
||||||
This module solves the challenge and reports the status of the *real* page
|
|
||||||
behind it, so the caller can distinguish:
|
|
||||||
|
|
||||||
* 200 -> genuinely available
|
|
||||||
* 503 -> reached the site; that endpoint is disabled server-side
|
|
||||||
* other-> whatever the app actually said
|
|
||||||
|
|
||||||
Exit codes mirror the HTTP status so shell callers can branch on them:
|
|
||||||
0 for 200, 1 for anything else, 2 for a local/transport failure.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import hashlib
|
|
||||||
import http.cookiejar
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
import urllib.error
|
|
||||||
import urllib.parse
|
|
||||||
import urllib.request
|
|
||||||
|
|
||||||
BASE = "https://aur.archlinux.org"
|
|
||||||
UA = "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0"
|
|
||||||
CHALLENGE_RE = re.compile(
|
|
||||||
r'<script id="anubis_challenge" type="application/json">(.*?)</script>',
|
|
||||||
re.S,
|
|
||||||
)
|
|
||||||
PASS_PATH = "/.within.website/x/cmd/anubis/api/pass-challenge"
|
|
||||||
# A solve at difficulty 4 takes ~0.01s; this ceiling only guards a server-side
|
|
||||||
# difficulty spike, so the poller degrades instead of spinning forever.
|
|
||||||
MAX_NONCE = 50_000_000
|
|
||||||
|
|
||||||
|
|
||||||
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
||||||
"""Keep redirects visible instead of silently following them."""
|
|
||||||
|
|
||||||
def redirect_request(self, *_args, **_kwargs): # noqa: D102 - see class doc
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _opener() -> urllib.request.OpenerDirector:
|
|
||||||
jar = http.cookiejar.CookieJar()
|
|
||||||
opener = urllib.request.build_opener(
|
|
||||||
urllib.request.HTTPCookieProcessor(jar), _NoRedirect
|
|
||||||
)
|
|
||||||
opener.addheaders = [
|
|
||||||
("User-Agent", UA),
|
|
||||||
("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"),
|
|
||||||
]
|
|
||||||
return opener
|
|
||||||
|
|
||||||
|
|
||||||
def _solve(random_data: str, difficulty: int) -> tuple[int, str, float]:
|
|
||||||
"""Find a nonce whose sha256 digest starts with `difficulty` zero hex chars."""
|
|
||||||
prefix = "0" * difficulty
|
|
||||||
started = time.time()
|
|
||||||
for nonce in range(MAX_NONCE):
|
|
||||||
digest = hashlib.sha256(f"{random_data}{nonce}".encode()).hexdigest()
|
|
||||||
if digest.startswith(prefix):
|
|
||||||
return nonce, digest, time.time() - started
|
|
||||||
raise RuntimeError(f"no nonce found below {MAX_NONCE} at difficulty {difficulty}")
|
|
||||||
|
|
||||||
|
|
||||||
def fetch(path: str, timeout: int = 30) -> tuple[int, str]:
|
|
||||||
"""Return (status, body) for `path`, clearing Anubis first if challenged."""
|
|
||||||
opener = _opener()
|
|
||||||
url = f"{BASE}{path}"
|
|
||||||
|
|
||||||
try:
|
|
||||||
with opener.open(url, timeout=timeout) as resp:
|
|
||||||
body = resp.read().decode("utf-8", "replace")
|
|
||||||
status = resp.status
|
|
||||||
except urllib.error.HTTPError as exc:
|
|
||||||
return exc.code, exc.read().decode("utf-8", "replace")
|
|
||||||
|
|
||||||
match = CHALLENGE_RE.search(body)
|
|
||||||
if not match:
|
|
||||||
return status, body
|
|
||||||
|
|
||||||
challenge = json.loads(match.group(1))["challenge"]
|
|
||||||
nonce, digest, elapsed = _solve(
|
|
||||||
challenge["randomData"], int(challenge["difficulty"])
|
|
||||||
)
|
|
||||||
query = urllib.parse.urlencode(
|
|
||||||
{
|
|
||||||
"id": challenge["id"],
|
|
||||||
"response": digest,
|
|
||||||
"nonce": str(nonce),
|
|
||||||
"redir": path,
|
|
||||||
"elapsedTime": str(int(elapsed * 1000)),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
opener.open(f"{BASE}{PASS_PATH}?{query}", timeout=timeout)
|
|
||||||
except urllib.error.HTTPError:
|
|
||||||
# A redirect or non-2xx here is fine; what matters is the cookie the
|
|
||||||
# opener's jar just captured.
|
|
||||||
pass
|
|
||||||
|
|
||||||
try:
|
|
||||||
with opener.open(url, timeout=timeout) as resp:
|
|
||||||
return resp.status, resp.read().decode("utf-8", "replace")
|
|
||||||
except urllib.error.HTTPError as exc:
|
|
||||||
return exc.code, exc.read().decode("utf-8", "replace")
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
|
||||||
parser.add_argument("path", help="AUR path, e.g. /register")
|
|
||||||
parser.add_argument(
|
|
||||||
"--print-body", action="store_true", help="write the page body to stdout"
|
|
||||||
)
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
try:
|
|
||||||
status, body = fetch(args.path)
|
|
||||||
except (urllib.error.URLError, RuntimeError, TimeoutError) as exc:
|
|
||||||
print(f"transport error: {exc}", file=sys.stderr)
|
|
||||||
return 2
|
|
||||||
|
|
||||||
print(status)
|
|
||||||
if args.print_body:
|
|
||||||
sys.stdout.write(body)
|
|
||||||
return 0 if status == 200 else 1
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
@ -1,107 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Building, verifying and pushing one package to the AUR.
|
|
||||||
#
|
|
||||||
# Sourced by publish.sh, never executed: these functions read the readonly
|
|
||||||
# config (AUR_HOST, AUR_ROOT, DRY_RUN, PUBLISHED, FAILED) that the entry script declares, and
|
|
||||||
# a `readonly` re-run inside a twice-sourced lib would abort under `set -e`.
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
publish_one() {
|
|
||||||
local pkg="$1" repo="$2" wrapper="$3" binary="$4" expected="$5"
|
|
||||||
local dir="$AUR_ROOT/$pkg"
|
|
||||||
local version
|
|
||||||
|
|
||||||
log "$pkg"
|
|
||||||
|
|
||||||
[[ -d "$repo" ]] || { warn "$pkg: no repo at $repo"; return 1; }
|
|
||||||
[[ -f "$dir/PKGBUILD" ]] || { warn "$pkg: no PKGBUILD at $dir"; return 1; }
|
|
||||||
|
|
||||||
version="$(newest_tag "$repo")"
|
|
||||||
[[ -n "$version" ]] || { warn "$pkg: no vX.Y.Z tag on the remote"; return 1; }
|
|
||||||
info "newest release tag: v$version"
|
|
||||||
|
|
||||||
cd "$dir"
|
|
||||||
sed -i -E "s/^pkgver=.*/pkgver=$version/" PKGBUILD
|
|
||||||
# The tarball for a tag contains the PKGBUILD, so the checksum can only be
|
|
||||||
# computed after the tag exists — never committed ahead of time.
|
|
||||||
updpkgsums >/dev/null 2>&1 || { warn "$pkg: updpkgsums failed (tag missing?)"; return 1; }
|
|
||||||
|
|
||||||
info "building"
|
|
||||||
if ! env PATH=/usr/bin:/bin:/usr/local/bin makepkg -Cf --noconfirm >/dev/null 2>&1; then
|
|
||||||
warn "$pkg: makepkg failed"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
local built
|
|
||||||
built="$(find . -maxdepth 1 -name "$pkg-$version-*.pkg.tar.zst" | head -1)"
|
|
||||||
[[ -n "$built" ]] || { warn "$pkg: no package produced"; return 1; }
|
|
||||||
|
|
||||||
# namcap's "ELF files outside of a valid path ('opt/')" is a false positive
|
|
||||||
# for /opt-installed apps, so only genuinely new errors are worth failing on.
|
|
||||||
local findings
|
|
||||||
findings="$(namcap "$built" 2>&1 | grep -E ' E: ' | grep -v "outside of a valid path" || true)"
|
|
||||||
if [[ -n "$findings" ]]; then
|
|
||||||
warn "$pkg: namcap errors:"
|
|
||||||
printf ' %s\n' "$findings"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
info "installing and launching"
|
|
||||||
sudo pacman -U --noconfirm "$built" >/dev/null 2>&1 \
|
|
||||||
|| { warn "$pkg: pacman -U failed"; return 1; }
|
|
||||||
verify_installed "$binary" "$expected" "$wrapper" || { warn "$pkg: launch check failed"; return 1; }
|
|
||||||
|
|
||||||
# Generated before the rule check, which verifies it matches the PKGBUILD.
|
|
||||||
makepkg --printsrcinfo > .SRCINFO
|
|
||||||
check_aur_rules "$pkg" || return 1
|
|
||||||
|
|
||||||
# The AUR repo is a separate remote living alongside the build dir; adding
|
|
||||||
# it here (rather than cloning into a fresh path) keeps the verified
|
|
||||||
# PKGBUILD and the thing being pushed byte-identical.
|
|
||||||
if [[ ! -d .git ]]; then
|
|
||||||
git init -q -b master .
|
|
||||||
git remote add origin "ssh://aur@$AUR_HOST/$pkg.git"
|
|
||||||
fi
|
|
||||||
git remote get-url origin >/dev/null 2>&1 \
|
|
||||||
|| git remote add origin "ssh://aur@$AUR_HOST/$pkg.git"
|
|
||||||
|
|
||||||
# Fetch first: an existing AUR package must be built on, not overwritten.
|
|
||||||
if git fetch -q origin master 2>/dev/null; then
|
|
||||||
git reset -q --soft FETCH_HEAD 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
cat > .gitignore <<'EOF'
|
|
||||||
# Build products; the AUR tracks only the recipe.
|
|
||||||
*.pkg.tar.zst
|
|
||||||
*.tar.gz
|
|
||||||
src/
|
|
||||||
pkg/
|
|
||||||
EOF
|
|
||||||
|
|
||||||
git add PKGBUILD .SRCINFO .gitignore
|
|
||||||
if git diff --cached --quiet; then
|
|
||||||
info "already up to date on the AUR; nothing to push"
|
|
||||||
PUBLISHED+=("$pkg v$version (unchanged)")
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
git -c user.name='Krzysztof Rudnicki' \
|
|
||||||
-c user.email='krzysztofrudnicki0@gmail.com' \
|
|
||||||
commit -q -m "$pkg $version-1"
|
|
||||||
|
|
||||||
if ((DRY_RUN)); then
|
|
||||||
info "DRY RUN — not pushing"
|
|
||||||
PUBLISHED+=("$pkg v$version (dry run)")
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
info "pushing to the AUR"
|
|
||||||
git push -q origin master 2>&1 | tail -3 || { warn "$pkg: push failed"; return 1; }
|
|
||||||
PUBLISHED+=("$pkg v$version")
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,107 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Waiting for AUR registration and SSH access to come up.
|
|
||||||
#
|
|
||||||
# Sourced by publish.sh, never executed: these functions read the readonly
|
|
||||||
# config (AUR_HOST, SSH_KEY, GATE, POLL_SECONDS) that the entry script declares, and
|
|
||||||
# a `readonly` re-run inside a twice-sourced lib would abort under `set -e`.
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
register_status() {
|
|
||||||
python3 "$GATE" /register 2>/dev/null | head -1
|
|
||||||
}
|
|
||||||
|
|
||||||
open_browser() {
|
|
||||||
local url="$1"
|
|
||||||
local browser
|
|
||||||
for browser in xdg-open thorium-browser chromium google-chrome-stable firefox librewolf; do
|
|
||||||
if command -v "$browser" >/dev/null 2>&1; then
|
|
||||||
"$browser" "$url" >/dev/null 2>&1 &
|
|
||||||
info "Opened $url in $browser"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
warn "No browser found; open this yourself: $url"
|
|
||||||
}
|
|
||||||
|
|
||||||
ensure_key() {
|
|
||||||
if [[ ! -f "$SSH_KEY" ]]; then
|
|
||||||
log "Generating an AUR SSH key (none at $SSH_KEY)"
|
|
||||||
ssh-keygen -t ed25519 -f "$SSH_KEY" -N '' -C 'kuhy@aur'
|
|
||||||
fi
|
|
||||||
if ! grep -q "Host $AUR_HOST" "$HOME/.ssh/config" 2>/dev/null; then
|
|
||||||
log "Adding $AUR_HOST to ~/.ssh/config"
|
|
||||||
mkdir -p "$HOME/.ssh"
|
|
||||||
cat >> "$HOME/.ssh/config" <<EOF
|
|
||||||
|
|
||||||
# AUR uses a dedicated key so it stays separate from the GitHub identity.
|
|
||||||
Host $AUR_HOST
|
|
||||||
User aur
|
|
||||||
IdentityFile $SSH_KEY
|
|
||||||
IdentitiesOnly yes
|
|
||||||
EOF
|
|
||||||
chmod 600 "$HOME/.ssh/config"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
ssh_works() {
|
|
||||||
# The AUR refuses interactive shells; "Interactive shell is disabled" on a
|
|
||||||
# successful key auth is the success signal, not an error.
|
|
||||||
ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new \
|
|
||||||
-o ConnectTimeout=15 "aur@$AUR_HOST" help 2>&1 |
|
|
||||||
grep -qiE 'interactive shell is disabled|Welcome to AUR'
|
|
||||||
}
|
|
||||||
|
|
||||||
wait_for_registration() {
|
|
||||||
local status
|
|
||||||
status="$(register_status)"
|
|
||||||
if [[ "$status" == "200" ]]; then
|
|
||||||
log "AUR registration is OPEN"
|
|
||||||
else
|
|
||||||
log "AUR registration is closed (HTTP $status). Polling every ${POLL_SECONDS}s."
|
|
||||||
info "Ctrl-C to stop; re-run any time, nothing is lost."
|
|
||||||
while :; do
|
|
||||||
sleep "$POLL_SECONDS"
|
|
||||||
status="$(register_status)"
|
|
||||||
if [[ "$status" == "200" ]]; then
|
|
||||||
log "Registration is BACK (HTTP 200)"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
printf ' %s still HTTP %s\n' "$(date +%H:%M)" "$status"
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
|
|
||||||
open_browser "https://$AUR_HOST/register"
|
|
||||||
cat <<EOF
|
|
||||||
|
|
||||||
------------------------------------------------------------------
|
|
||||||
Sign up, and paste this into the "SSH Public Key" field:
|
|
||||||
|
|
||||||
$(sed 's/^/ /' "$SSH_KEY.pub")
|
|
||||||
|
|
||||||
You will need to confirm the address from your email before the
|
|
||||||
account works. This script waits for that automatically.
|
|
||||||
------------------------------------------------------------------
|
|
||||||
EOF
|
|
||||||
}
|
|
||||||
|
|
||||||
wait_for_ssh() {
|
|
||||||
log "Waiting for the SSH key to authenticate against the AUR"
|
|
||||||
local waited=0
|
|
||||||
until ssh_works; do
|
|
||||||
sleep 30
|
|
||||||
waited=$((waited + 30))
|
|
||||||
if ((waited % 300 == 0)); then
|
|
||||||
info "still waiting ($((waited / 60)) min) — key not accepted yet"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
log "SSH authentication OK"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Phase 2: build, verify and publish one package
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@ -1,122 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Gates a package must pass before it is allowed near the AUR.
|
|
||||||
#
|
|
||||||
# Sourced by publish.sh, never executed: these functions read the readonly
|
|
||||||
# config (AUR_ROOT, DRY_RUN) that the entry script declares, and
|
|
||||||
# a `readonly` re-run inside a twice-sourced lib would abort under `set -e`.
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
newest_tag() {
|
|
||||||
# Newest clean vX.Y.Z on the remote. Build tags (vX.Y.Z-build.N) and the
|
|
||||||
# grafted upstream Flutter SDK tags in diet-guard are excluded on purpose.
|
|
||||||
git -C "$1" ls-remote --tags origin 2>/dev/null |
|
|
||||||
awk '{print $2}' |
|
|
||||||
sed 's|refs/tags/||' |
|
|
||||||
grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' |
|
|
||||||
sed 's/^v//' |
|
|
||||||
sort -V |
|
|
||||||
tail -1
|
|
||||||
}
|
|
||||||
|
|
||||||
verify_installed() {
|
|
||||||
local binary="$1" expected="$2" wrapper="$3" output
|
|
||||||
|
|
||||||
# An instance left over from a previous run would make the wrapper print
|
|
||||||
# "already running" and hand off, which passes trivially and turns the
|
|
||||||
# strongest gate in this script into the weakest. Clear it first so the
|
|
||||||
# check always exercises a real cold start.
|
|
||||||
#
|
|
||||||
# Resolved through /proc/<pid>/exe rather than any form of name or
|
|
||||||
# command-line matching. Every argv-based approach selects the wrong
|
|
||||||
# processes here:
|
|
||||||
# * `pkill -x` compares against the kernel's 15-character comm field, and
|
|
||||||
# two of these wrappers are longer (habit_stack_desktop is 19), so a
|
|
||||||
# name match would silently never fire.
|
|
||||||
# * `pkill -f`, and equally `ps -eo args` piped through a pattern, match
|
|
||||||
# any process whose command line merely *mentions* the path — this
|
|
||||||
# script, the shell running it, and the matching tool itself all
|
|
||||||
# qualify, so the run kills its own shell instead of the app. A
|
|
||||||
# $$/$PPID guard does not cover it, because the offending shell is
|
|
||||||
# often a more distant ancestor.
|
|
||||||
# /proc/<pid>/exe is a kernel-maintained symlink to the binary actually
|
|
||||||
# being executed, so it cannot collide with an incidental mention.
|
|
||||||
# The wrapper name is unique per app, so the /opt/<pkg>/ segment is left as
|
|
||||||
# a glob instead of threading the package name through another parameter.
|
|
||||||
#
|
|
||||||
# The link is read without `-f`: it is already fully resolved, and by the
|
|
||||||
# time this runs `pacman -U` has replaced the binary, so a leftover process
|
|
||||||
# points at an unlinked inode that the kernel reports with a " (deleted)"
|
|
||||||
# suffix. That suffix has to be stripped or the match silently misses the
|
|
||||||
# one process this exists to kill.
|
|
||||||
local pid exe killed=()
|
|
||||||
for pid in /proc/[0-9]*; do
|
|
||||||
pid="${pid#/proc/}"
|
|
||||||
exe="$(readlink "/proc/$pid/exe" 2>/dev/null)" || continue
|
|
||||||
exe="${exe% (deleted)}"
|
|
||||||
[[ "$exe" == /opt/*/bin/"$wrapper" ]] || continue
|
|
||||||
kill -TERM "$pid" 2>/dev/null && killed+=("$pid")
|
|
||||||
done
|
|
||||||
if ((${#killed[@]})); then
|
|
||||||
info "stopped a running instance so the launch check is meaningful"
|
|
||||||
# `kill` only reports that the signal was sent. Waiting for the process
|
|
||||||
# to actually go keeps a slow exit from failing the cold start below on
|
|
||||||
# a port that is still bound.
|
|
||||||
local waited=0
|
|
||||||
while ((waited < 100)) && kill -0 "${killed[@]}" 2>/dev/null; do
|
|
||||||
sleep 0.1
|
|
||||||
((waited++))
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
|
|
||||||
output="$(timeout 15 "/usr/bin/$binary" 2>&1 | head -3 || true)"
|
|
||||||
if grep -qF "$expected" <<<"$output"; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
warn "unexpected startup output: $output"
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# The AUR does not review uploads, but "packages that violate the rules may be
|
|
||||||
# deleted without warning" (AUR submission guidelines). These are the rules that
|
|
||||||
# are mechanically checkable, enforced here so a violation blocks the push
|
|
||||||
# rather than surfacing later as a deletion request.
|
|
||||||
check_aur_rules() {
|
|
||||||
local pkg="$1" problems=()
|
|
||||||
|
|
||||||
grep -q '^# Maintainer:' PKGBUILD \
|
|
||||||
|| problems+=("missing '# Maintainer:' comment on line 1")
|
|
||||||
|
|
||||||
grep -qE "^arch=\(.*x86_64" PKGBUILD \
|
|
||||||
|| problems+=("x86_64 not in arch=() — packages without it are not allowed")
|
|
||||||
|
|
||||||
# `replaces` makes pacman rip out the named package on the next -Sy, which
|
|
||||||
# is only correct for a genuine rename. `conflicts` is the right tool.
|
|
||||||
grep -q '^replaces=' PKGBUILD \
|
|
||||||
&& problems+=("uses replaces=; AUR rules reserve it for renames — use conflicts=")
|
|
||||||
|
|
||||||
# A package already in an official repo must never be duplicated.
|
|
||||||
if pacman -Si "$pkg" >/dev/null 2>&1; then
|
|
||||||
problems+=("$pkg exists in an official repo — must not be submitted")
|
|
||||||
fi
|
|
||||||
|
|
||||||
grep -qE '^(pkgdesc|url|license)=' PKGBUILD \
|
|
||||||
|| problems+=("missing pkgdesc/url/license")
|
|
||||||
|
|
||||||
# A stale .SRCINFO is the single most common upload rejection.
|
|
||||||
if ! diff -q <(makepkg --printsrcinfo) .SRCINFO >/dev/null 2>&1; then
|
|
||||||
problems+=(".SRCINFO does not match PKGBUILD")
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ((${#problems[@]})); then
|
|
||||||
warn "$pkg: AUR rule violations:"
|
|
||||||
printf ' - %s\n' "${problems[@]}"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
info "AUR rule checks passed"
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,148 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Publish kuhy's three Flutter apps to the AUR, hands-off.
|
|
||||||
#
|
|
||||||
# Run this once AUR registration is back. It:
|
|
||||||
# 1. Waits for https://aur.archlinux.org/register to stop returning 503,
|
|
||||||
# then opens it in a browser and prints the SSH key to paste.
|
|
||||||
# (Signup is the ONLY manual step: it needs an email confirmation, and
|
|
||||||
# creating accounts is not something this script will do for you.)
|
|
||||||
# 2. Waits for `ssh aur@aur.archlinux.org` to authenticate.
|
|
||||||
# 3. From there, fully unattended, for each package:
|
|
||||||
# newest release tag -> pkgver -> updpkgsums -> makepkg -C -> namcap
|
|
||||||
# -> pacman -U -> launch check -> .SRCINFO -> clone AUR repo
|
|
||||||
# -> commit -> push.
|
|
||||||
#
|
|
||||||
# Every package is BUILT, INSTALLED and LAUNCHED before it is pushed, so a
|
|
||||||
# broken package cannot reach the AUR. A package that fails any gate is
|
|
||||||
# skipped and reported; the others still publish.
|
|
||||||
#
|
|
||||||
# Idempotent and resumable: re-running skips work that is already done, and
|
|
||||||
# an already-published package is updated rather than duplicated.
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
readonly SCRIPT_DIR
|
|
||||||
readonly GATE="$SCRIPT_DIR/anubis_gate.py"
|
|
||||||
readonly AUR_HOST="aur.archlinux.org"
|
|
||||||
readonly SSH_KEY="$HOME/.ssh/aur"
|
|
||||||
readonly AUR_ROOT="$HOME/sdk/aur"
|
|
||||||
|
|
||||||
# package:repo:wrapper-process:cli-name:expected-startup-string
|
|
||||||
# The wrapper process name is NOT derivable from the package name
|
|
||||||
# (diet-guard-app runs diet_guard_desktop), so it is listed explicitly.
|
|
||||||
readonly PACKAGES=(
|
|
||||||
"todo-flutter:$HOME/src/todo:todo_desktop:todo:serving on http://localhost:8730"
|
|
||||||
"habit-stack:$HOME/src/habit_stack:habit_stack_desktop:habit-stack:serving on http://localhost:8731"
|
|
||||||
"diet-guard-app:$HOME/src/diet-guard:diet_guard_desktop:diet-guard-app:serving on http://localhost:8732"
|
|
||||||
)
|
|
||||||
|
|
||||||
POLL_SECONDS=300
|
|
||||||
SKIP_WAIT=0
|
|
||||||
DRY_RUN=0
|
|
||||||
ONLY=""
|
|
||||||
FAILED=()
|
|
||||||
PUBLISHED=()
|
|
||||||
|
|
||||||
usage() {
|
|
||||||
cat <<EOF
|
|
||||||
Usage: $(basename "$0") [OPTIONS]
|
|
||||||
|
|
||||||
Options:
|
|
||||||
--skip-wait Assume the account + SSH key already work; go straight
|
|
||||||
to building and publishing.
|
|
||||||
--dry-run Do everything except the final 'git push' to the AUR.
|
|
||||||
--only NAME Publish just one package (todo-flutter, habit-stack,
|
|
||||||
diet-guard-app).
|
|
||||||
--poll SECONDS How often to re-check /register (default: $POLL_SECONDS).
|
|
||||||
-h, --help Show this help.
|
|
||||||
EOF
|
|
||||||
exit 0
|
|
||||||
}
|
|
||||||
|
|
||||||
log() { printf '\n\033[1;34m==>\033[0m %s\n' "$*"; }
|
|
||||||
info() { printf ' %s\n' "$*"; }
|
|
||||||
warn() { printf '\033[1;33m ! %s\033[0m\n' "$*" >&2; }
|
|
||||||
die() { printf '\033[1;31m==> ERROR: %s\033[0m\n' "$*" >&2; exit 1; }
|
|
||||||
|
|
||||||
require_tools() {
|
|
||||||
local missing=()
|
|
||||||
local tool
|
|
||||||
for tool in git makepkg updpkgsums namcap ssh python3 curl; do
|
|
||||||
command -v "$tool" >/dev/null 2>&1 || missing+=("$tool")
|
|
||||||
done
|
|
||||||
if ((${#missing[@]})); then
|
|
||||||
info "Installing missing tools: ${missing[*]}"
|
|
||||||
# namcap/updpkgsums come from namcap + pacman-contrib; the rest are base.
|
|
||||||
sudo pacman -S --needed --noconfirm namcap pacman-contrib git openssh python \
|
|
||||||
|| die "could not install: ${missing[*]}"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Phase 1: wait for registration, then for the key to work
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
# The rest lives in lib/, sourced (not executed) so every function sees the
|
|
||||||
# readonly config above and the mutable FAILED/PUBLISHED arrays main() reads.
|
|
||||||
# shellcheck source=lib/aur_registration.sh
|
|
||||||
source "$SCRIPT_DIR/lib/aur_registration.sh"
|
|
||||||
# shellcheck source=lib/aur_verify.sh
|
|
||||||
source "$SCRIPT_DIR/lib/aur_verify.sh"
|
|
||||||
# shellcheck source=lib/aur_publish_one.sh
|
|
||||||
source "$SCRIPT_DIR/lib/aur_publish_one.sh"
|
|
||||||
|
|
||||||
main() {
|
|
||||||
require_tools
|
|
||||||
[[ -f "$GATE" ]] || die "missing $GATE"
|
|
||||||
mkdir -p "$AUR_ROOT"
|
|
||||||
|
|
||||||
ensure_key
|
|
||||||
if ((SKIP_WAIT)); then
|
|
||||||
ssh_works || die "SSH key is not accepted yet; drop --skip-wait to wait for it"
|
|
||||||
else
|
|
||||||
wait_for_registration
|
|
||||||
wait_for_ssh
|
|
||||||
fi
|
|
||||||
|
|
||||||
local entry pkg repo wrapper binary expected
|
|
||||||
for entry in "${PACKAGES[@]}"; do
|
|
||||||
IFS=: read -r pkg repo wrapper binary expected <<<"$entry"
|
|
||||||
[[ -z "$ONLY" || "$ONLY" == "$pkg" ]] || continue
|
|
||||||
if ! publish_one "$pkg" "$repo" "$wrapper" "$binary" "$expected"; then
|
|
||||||
FAILED+=("$pkg")
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
log "Summary"
|
|
||||||
if ((${#PUBLISHED[@]})); then
|
|
||||||
printf ' published: %s\n' "${PUBLISHED[@]}"
|
|
||||||
fi
|
|
||||||
if ((${#FAILED[@]})); then
|
|
||||||
printf '\033[1;31m FAILED: %s\033[0m\n' "${FAILED[@]}"
|
|
||||||
info "Nothing broken was pushed; fix and re-run (safe to repeat)."
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
if ((${#PUBLISHED[@]} == 0)); then
|
|
||||||
warn "nothing to do"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
printf '\n Check: https://aur.archlinux.org/packages/todo-flutter\n'
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
|
||||||
case $1 in
|
|
||||||
--skip-wait) SKIP_WAIT=1; shift ;;
|
|
||||||
--dry-run) DRY_RUN=1; shift ;;
|
|
||||||
--only) ONLY="$2"; shift 2 ;;
|
|
||||||
--poll) POLL_SECONDS="$2"; shift 2 ;;
|
|
||||||
-h|--help) usage ;;
|
|
||||||
*) die "Unknown option: $1" ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
main "$@"
|
|
||||||
@ -118,6 +118,16 @@ The public/private split is not cosmetic: every repo in this fleet is public.
|
|||||||
APK — the security rules, not secrecy, protect the data. The account email and
|
APK — the security rules, not secrecy, protect the data. The account email and
|
||||||
password identify *a person* and are entered once per device.
|
password identify *a person* and are entered once per device.
|
||||||
|
|
||||||
|
### Sandbox namespace for test builds
|
||||||
|
|
||||||
|
`--dart-define=SYNC_NAMESPACE=sandbox/slot3` moves every RTDB read and write
|
||||||
|
under `/sandbox/slot3/...`, so a test build never touches real data. Sign-in
|
||||||
|
is unchanged (the real account). Empty, the default, keeps the exact current
|
||||||
|
paths. Only `^sandbox/[a-z0-9_-]+$` is accepted; anything else throws
|
||||||
|
`ConfigException` at startup (`firebaseClientFor` / `FirebaseRestClient`), never
|
||||||
|
falling back to production paths. The shipped rules (root-level uid pin) already
|
||||||
|
allow these paths.
|
||||||
|
|
||||||
### `password: ''` is legal
|
### `password: ''` is legal
|
||||||
|
|
||||||
The Google one-tap path and the seeded-session path both store an empty
|
The Google one-tap path and the seeded-session path both store an empty
|
||||||
|
|||||||
@ -20,4 +20,5 @@ export 'src/remote_store.dart';
|
|||||||
export 'src/secure_credential_store.dart';
|
export 'src/secure_credential_store.dart';
|
||||||
export 'src/store.dart';
|
export 'src/store.dart';
|
||||||
export 'src/sync.dart';
|
export 'src/sync.dart';
|
||||||
|
export 'src/sync_namespace.dart';
|
||||||
export 'src/sync_state.dart';
|
export 'src/sync_state.dart';
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import 'firebase_auth_rest.dart';
|
|||||||
import 'firebase_errors.dart';
|
import 'firebase_errors.dart';
|
||||||
import 'firebase_keys.dart';
|
import 'firebase_keys.dart';
|
||||||
import 'remote_store.dart';
|
import 'remote_store.dart';
|
||||||
|
import 'sync_namespace.dart';
|
||||||
|
|
||||||
const _httpUnauthorized = 401;
|
const _httpUnauthorized = 401;
|
||||||
const _httpForbidden = 403;
|
const _httpForbidden = 403;
|
||||||
@ -33,13 +34,22 @@ class FirebaseRestClient implements RemoteStore, BulkMapReader {
|
|||||||
required String databaseUrl,
|
required String databaseUrl,
|
||||||
required this.auth,
|
required this.auth,
|
||||||
http.Client? httpClient,
|
http.Client? httpClient,
|
||||||
|
String namespace = syncNamespace,
|
||||||
}) : _databaseUrl = databaseUrl.endsWith('/')
|
}) : _databaseUrl = databaseUrl.endsWith('/')
|
||||||
? databaseUrl.substring(0, databaseUrl.length - 1)
|
? databaseUrl.substring(0, databaseUrl.length - 1)
|
||||||
: databaseUrl,
|
: databaseUrl,
|
||||||
|
_prefix = _prefixFor(checkSyncNamespace(namespace)),
|
||||||
_http = httpClient ?? http.Client();
|
_http = httpClient ?? http.Client();
|
||||||
|
|
||||||
final String _databaseUrl;
|
final String _databaseUrl;
|
||||||
|
|
||||||
|
/// `<namespace>/` or empty: put in front of every RTDB path. Every request
|
||||||
|
/// goes through [_uri], so this is the single choke point.
|
||||||
|
final String _prefix;
|
||||||
|
|
||||||
|
static String _prefixFor(String namespace) =>
|
||||||
|
namespace.isEmpty ? '' : '$namespace/';
|
||||||
|
|
||||||
/// Mints the ID token every request carries. Public so a settings screen
|
/// Mints the ID token every request carries. Public so a settings screen
|
||||||
/// can drive `signIn` / `signOut` through the same instance the client uses.
|
/// can drive `signIn` / `signOut` through the same instance the client uses.
|
||||||
final FirebaseTokenProvider auth;
|
final FirebaseTokenProvider auth;
|
||||||
@ -47,8 +57,12 @@ class FirebaseRestClient implements RemoteStore, BulkMapReader {
|
|||||||
|
|
||||||
Future<Uri> _uri(String path, {Map<String, String> query = const {}}) async {
|
Future<Uri> _uri(String path, {Map<String, String> query = const {}}) async {
|
||||||
final encoded = encodePath(path);
|
final encoded = encodePath(path);
|
||||||
|
// A bare namespace (the root probe) must not leave a trailing slash.
|
||||||
|
final full = encoded.isEmpty && _prefix.isNotEmpty
|
||||||
|
? _prefix.substring(0, _prefix.length - 1)
|
||||||
|
: '$_prefix$encoded';
|
||||||
return Uri.parse(
|
return Uri.parse(
|
||||||
'$_databaseUrl/$encoded.json',
|
'$_databaseUrl/$full.json',
|
||||||
).replace(queryParameters: {'auth': await auth.idToken(), ...query});
|
).replace(queryParameters: {'auth': await auth.idToken(), ...query});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -20,6 +20,7 @@ import 'firebase_credentials.dart';
|
|||||||
import 'firebase_client.dart';
|
import 'firebase_client.dart';
|
||||||
import 'mirror_store.dart';
|
import 'mirror_store.dart';
|
||||||
import 'remote_store.dart';
|
import 'remote_store.dart';
|
||||||
|
import 'sync_namespace.dart';
|
||||||
|
|
||||||
/// The shared config is missing a field, or still holds a scaffold placeholder.
|
/// The shared config is missing a field, or still holds a scaffold placeholder.
|
||||||
class ConfigException implements Exception {
|
class ConfigException implements Exception {
|
||||||
@ -121,6 +122,8 @@ Future<FirebaseRestClient> firebaseClientFor({
|
|||||||
String? expectedUid,
|
String? expectedUid,
|
||||||
http.Client? httpClient,
|
http.Client? httpClient,
|
||||||
}) async {
|
}) async {
|
||||||
|
// Before any sign-in: a bad SYNC_NAMESPACE must stop the app at startup.
|
||||||
|
checkSyncNamespace(syncNamespace);
|
||||||
final auth = FirebaseTokenProvider(
|
final auth = FirebaseTokenProvider(
|
||||||
apiKey: config.apiKey,
|
apiKey: config.apiKey,
|
||||||
store: store,
|
store: store,
|
||||||
|
|||||||
@ -7,8 +7,11 @@ import 'store.dart';
|
|||||||
/// Writes atomically -- to a per-process temp file, then `rename`d over the
|
/// Writes atomically -- to a per-process temp file, then `rename`d over the
|
||||||
/// real path -- so a concurrent reader (e.g. a background sync isolate writing
|
/// real path -- so a concurrent reader (e.g. a background sync isolate writing
|
||||||
/// while the foreground app also writes) never observes a half-written file.
|
/// while the foreground app also writes) never observes a half-written file.
|
||||||
/// The pid in the temp name keeps two writers from colliding. This generalizes
|
/// The pid in the temp name keeps two processes from colliding; within one
|
||||||
/// diet_guard's hand-rolled `LogStorageService` write scheme.
|
/// instance, writes are serialized in issue order (last issued wins), so
|
||||||
|
/// overlapping calls (e.g. per-keystroke saves) never share the temp file
|
||||||
|
/// mid-flight. This generalizes diet_guard's hand-rolled `LogStorageService`
|
||||||
|
/// write scheme.
|
||||||
///
|
///
|
||||||
/// Lives behind the `package:crdt_sync/crdt_sync_io.dart` entrypoint, not the
|
/// Lives behind the `package:crdt_sync/crdt_sync_io.dart` entrypoint, not the
|
||||||
/// main barrel, because it imports `dart:io` and so is unavailable on web --
|
/// main barrel, because it imports `dart:io` and so is unavailable on web --
|
||||||
@ -20,6 +23,10 @@ class FileLogPersistence implements LogPersistence {
|
|||||||
|
|
||||||
final File _file;
|
final File _file;
|
||||||
|
|
||||||
|
/// Completes when the most recently issued write has settled. Never
|
||||||
|
/// rejects, so one failed write cannot block the ones queued after it.
|
||||||
|
Future<void> _tail = Future<void>.value();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<String?> read() async {
|
Future<String?> read() async {
|
||||||
if (!_file.existsSync()) return null;
|
if (!_file.existsSync()) return null;
|
||||||
@ -31,7 +38,14 @@ class FileLogPersistence implements LogPersistence {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> write(String text) async {
|
Future<void> write(String text) {
|
||||||
|
final result = _tail.then((_) => _writeNow(text));
|
||||||
|
// The caller still sees its own error via [result]; the chain ignores it.
|
||||||
|
_tail = result.then((_) {}, onError: (Object _) {});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _writeNow(String text) async {
|
||||||
await _file.parent.create(recursive: true);
|
await _file.parent.create(recursive: true);
|
||||||
final tmp = File('${_file.path}.$pid.tmp');
|
final tmp = File('${_file.path}.$pid.tmp');
|
||||||
await tmp.writeAsString(text);
|
await tmp.writeAsString(text);
|
||||||
|
|||||||
26
crdt_sync_dart/lib/src/sync_namespace.dart
Normal file
26
crdt_sync_dart/lib/src/sync_namespace.dart
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
/// Compile-time sandbox namespace for the Realtime Database.
|
||||||
|
///
|
||||||
|
/// Build a test app with `--dart-define=SYNC_NAMESPACE=sandbox/slot3` and every
|
||||||
|
/// RTDB path the library reads or writes moves under `/sandbox/slot3/`, so a
|
||||||
|
/// test build can never touch real data. Sign-in is unchanged (real account).
|
||||||
|
/// Empty, the default, leaves every path exactly as it was.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'firebase_config.dart';
|
||||||
|
|
||||||
|
/// The `SYNC_NAMESPACE` dart-define; empty means production paths.
|
||||||
|
const String syncNamespace = String.fromEnvironment('SYNC_NAMESPACE');
|
||||||
|
|
||||||
|
final RegExp _allowed = RegExp(r'^sandbox/[a-z0-9_-]+$');
|
||||||
|
|
||||||
|
/// Returns [namespace] when it is empty or `sandbox/<slot>`, else throws.
|
||||||
|
///
|
||||||
|
/// Fails closed: a typo must stop the app at startup, never quietly fall back
|
||||||
|
/// to production paths. A trailing newline is rejected too.
|
||||||
|
String checkSyncNamespace(String namespace) {
|
||||||
|
if (namespace.isEmpty || _allowed.hasMatch(namespace)) return namespace;
|
||||||
|
throw ConfigException(
|
||||||
|
'SYNC_NAMESPACE "$namespace" is invalid: it must be empty or match '
|
||||||
|
r'^sandbox/[a-z0-9_-]+$ (e.g. sandbox/slot3)',
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -2,7 +2,7 @@ name: crdt_sync
|
|||||||
description: >-
|
description: >-
|
||||||
Shared CRDT merge scheme + GitHub-Contents-API sync transport for
|
Shared CRDT merge scheme + GitHub-Contents-API sync transport for
|
||||||
cross-device apps. Pure Dart, no Flutter dependency.
|
cross-device apps. Pure Dart, no Flutter dependency.
|
||||||
version: 0.11.3
|
version: 0.12.1
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@ -128,5 +128,32 @@ void main() {
|
|||||||
await store.write('{"hello":"world"}');
|
await store.write('{"hello":"world"}');
|
||||||
expect(await store.read(), '{"hello":"world"}');
|
expect(await store.read(), '{"hello":"world"}');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('50 overlapping writes all succeed and the last issued wins',
|
||||||
|
() async {
|
||||||
|
// Regression: every call shared one `<file>.<pid>.tmp`, so the first
|
||||||
|
// rename moved it away and a later rename threw PathNotFoundException.
|
||||||
|
final store = FileLogPersistence(File('${dir.path}/log.json'));
|
||||||
|
await Future.wait([
|
||||||
|
for (var i = 0; i < 50; i++) store.write('{"n":$i}'),
|
||||||
|
]);
|
||||||
|
expect(await store.read(), '{"n":49}');
|
||||||
|
expect(dir.listSync().map((e) => e.uri.pathSegments.last),
|
||||||
|
['log.json']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a failed write rejects its caller but does not block later writes',
|
||||||
|
() async {
|
||||||
|
final target = File('${dir.path}/log.json');
|
||||||
|
// A directory at the target path makes the rename fail.
|
||||||
|
Directory(target.path).createSync();
|
||||||
|
final store = FileLogPersistence(target);
|
||||||
|
await expectLater(
|
||||||
|
store.write('{"n":0}'), throwsA(isA<FileSystemException>()));
|
||||||
|
|
||||||
|
Directory(target.path).deleteSync();
|
||||||
|
await store.write('{"n":1}');
|
||||||
|
expect(await store.read(), '{"n":1}');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
170
crdt_sync_dart/test/sync_namespace_test.dart
Normal file
170
crdt_sync_dart/test/sync_namespace_test.dart
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:crdt_sync/crdt_sync.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:http/testing.dart' as http_testing;
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
const _databaseUrl = 'https://example-default-rtdb.firebasedatabase.app';
|
||||||
|
|
||||||
|
FirebaseTokenProvider _auth() => FirebaseTokenProvider(
|
||||||
|
apiKey: 'fake-api-key',
|
||||||
|
store: InMemoryCredentialStore(
|
||||||
|
FirebaseCredentials(
|
||||||
|
idToken: 'id-token',
|
||||||
|
refreshToken: 'refresh-token',
|
||||||
|
expiresAt: DateTime.utc(2099),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
httpClient: http_testing.MockClient(
|
||||||
|
(_) async => throw StateError('auth must not be contacted'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Client over an in-memory transport that records every request path.
|
||||||
|
(FirebaseRestClient, List<http.Request>) _client(String? namespace) {
|
||||||
|
final requests = <http.Request>[];
|
||||||
|
final mock = http_testing.MockClient((request) async {
|
||||||
|
requests.add(request);
|
||||||
|
return http.Response(jsonEncode(request.method == 'GET' ? null : 'x'), 200);
|
||||||
|
});
|
||||||
|
final client = namespace == null
|
||||||
|
? FirebaseRestClient(
|
||||||
|
databaseUrl: _databaseUrl,
|
||||||
|
auth: _auth(),
|
||||||
|
httpClient: mock,
|
||||||
|
)
|
||||||
|
: FirebaseRestClient(
|
||||||
|
databaseUrl: _databaseUrl,
|
||||||
|
auth: _auth(),
|
||||||
|
httpClient: mock,
|
||||||
|
namespace: namespace,
|
||||||
|
);
|
||||||
|
return (client, requests);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('compile-time default', () {
|
||||||
|
test('is empty when SYNC_NAMESPACE is not defined', () {
|
||||||
|
expect(syncNamespace, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty namespace leaves paths byte-for-byte unchanged', () async {
|
||||||
|
final (client, requests) = _client(null);
|
||||||
|
await client.getFileText('devices/pc/log.json');
|
||||||
|
expect(requests.single.url.path, '/devices/pc/log~2Ejson.json');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('prefixing', () {
|
||||||
|
test('every operation lands under the namespace', () async {
|
||||||
|
final (client, requests) = _client('sandbox/slot3');
|
||||||
|
await client.getFileText('devices/pc/log.json');
|
||||||
|
await client.putFileText('devices/pc/log.json', 't', message: 'm');
|
||||||
|
await client.listDirectory('devices');
|
||||||
|
await client.getStringMap('revs');
|
||||||
|
await client.patchValues('revs', {'a': 'b'});
|
||||||
|
await client.deleteFile('devices/pc/log.json');
|
||||||
|
await client.canAccessRemote();
|
||||||
|
expect(requests.map((r) => r.url.path), [
|
||||||
|
'/sandbox/slot3/devices/pc/log~2Ejson.json',
|
||||||
|
'/sandbox/slot3/devices/pc/log~2Ejson.json',
|
||||||
|
'/sandbox/slot3/devices.json',
|
||||||
|
'/sandbox/slot3/revs.json',
|
||||||
|
'/sandbox/slot3/revs.json',
|
||||||
|
'/sandbox/slot3/devices/pc/log~2Ejson.json',
|
||||||
|
'/sandbox/slot3.json',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an explicitly empty namespace is the unprefixed path', () async {
|
||||||
|
final (client, requests) = _client('');
|
||||||
|
await client.getStringMap('revs');
|
||||||
|
expect(requests.single.url.path, '/revs.json');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('validation (fail closed)', () {
|
||||||
|
const bad = [
|
||||||
|
'sandbox',
|
||||||
|
'sandbox/',
|
||||||
|
'sandbox/Slot3',
|
||||||
|
'sandbox/slot 3',
|
||||||
|
'sandbox/slot3/',
|
||||||
|
'sandbox/a/b',
|
||||||
|
'/sandbox/slot3',
|
||||||
|
'prod/slot3',
|
||||||
|
'sandbox/slot3\n',
|
||||||
|
'Sandbox/slot3',
|
||||||
|
'sandbox/slot.3',
|
||||||
|
'sandbox/..',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (final value in bad) {
|
||||||
|
test('rejects ${jsonEncode(value)}', () {
|
||||||
|
expect(
|
||||||
|
() => checkSyncNamespace(value),
|
||||||
|
throwsA(isA<ConfigException>()),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
() => FirebaseRestClient(
|
||||||
|
databaseUrl: _databaseUrl,
|
||||||
|
auth: _auth(),
|
||||||
|
namespace: value,
|
||||||
|
),
|
||||||
|
throwsA(isA<ConfigException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('the message names the define and the required shape', () {
|
||||||
|
expect(
|
||||||
|
() => checkSyncNamespace('typo'),
|
||||||
|
throwsA(
|
||||||
|
isA<ConfigException>().having(
|
||||||
|
(e) => e.message,
|
||||||
|
'message',
|
||||||
|
allOf(
|
||||||
|
contains('SYNC_NAMESPACE'),
|
||||||
|
contains(r'^sandbox/[a-z0-9_-]+$'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accepts the allowed alphabet', () {
|
||||||
|
expect(() => checkSyncNamespace('sandbox/slot3'), returnsNormally);
|
||||||
|
expect(() => checkSyncNamespace('sandbox/a_b-c9'), returnsNormally);
|
||||||
|
expect(() => checkSyncNamespace(''), returnsNormally);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'a bad compile-time define stops firebaseClientFor at startup',
|
||||||
|
() async {
|
||||||
|
// The define is compile-time, so only a fresh process can set it.
|
||||||
|
final result = await Process.run(Platform.resolvedExecutable, [
|
||||||
|
'-DSYNC_NAMESPACE=sandbox/Typo',
|
||||||
|
'run',
|
||||||
|
'tool/namespace_probe.dart',
|
||||||
|
]);
|
||||||
|
expect(result.exitCode, isNot(0));
|
||||||
|
expect(
|
||||||
|
result.stderr,
|
||||||
|
contains('SYNC_NAMESPACE "sandbox/Typo" is invalid'),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('a good compile-time define reaches the request path', () async {
|
||||||
|
final result = await Process.run(Platform.resolvedExecutable, [
|
||||||
|
'-DSYNC_NAMESPACE=sandbox/slot3',
|
||||||
|
'run',
|
||||||
|
'tool/namespace_probe.dart',
|
||||||
|
]);
|
||||||
|
expect(result.stderr, isEmpty);
|
||||||
|
expect(result.stdout, '/sandbox/slot3/devices.json\n');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
31
crdt_sync_dart/tool/namespace_probe.dart
Normal file
31
crdt_sync_dart/tool/namespace_probe.dart
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
// Helper for test/sync_namespace_test.dart: the SYNC_NAMESPACE define is
|
||||||
|
// compile-time, so the test runs this in a fresh process with -D set. It signs
|
||||||
|
// in from an in-memory session (no network) and prints the first request path.
|
||||||
|
import 'package:crdt_sync/crdt_sync.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:http/testing.dart' as http_testing;
|
||||||
|
|
||||||
|
Future<void> main() async {
|
||||||
|
final mock = http_testing.MockClient((request) async {
|
||||||
|
print(request.url.path);
|
||||||
|
return http.Response('null', 200);
|
||||||
|
});
|
||||||
|
final client = await firebaseClientFor(
|
||||||
|
config: const FirebaseConfig(
|
||||||
|
apiKey: 'k',
|
||||||
|
databaseUrl: 'https://example.firebasedatabase.app',
|
||||||
|
projectId: 'p',
|
||||||
|
uid: 'u',
|
||||||
|
email: 'e@x.y',
|
||||||
|
),
|
||||||
|
store: InMemoryCredentialStore(
|
||||||
|
FirebaseCredentials(
|
||||||
|
idToken: 'i',
|
||||||
|
refreshToken: 'r',
|
||||||
|
expiresAt: DateTime.utc(2099),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
httpClient: mock,
|
||||||
|
);
|
||||||
|
await client.listDirectory('devices');
|
||||||
|
}
|
||||||
@ -61,11 +61,11 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
path: crdt_sync_dart
|
path: crdt_sync_dart
|
||||||
ref: "crdt_sync_dart-v0.11.3"
|
ref: "crdt_sync_dart-v0.12.0"
|
||||||
resolved-ref: a2e7334dfec5131170a084510d70361d53dc40bc
|
resolved-ref: "59b33949e4a130bb76c77e7434ddf4bbf8cde3a3"
|
||||||
url: "https://github.com/kuhyx/utils"
|
url: "https://github.com/kuhyx/utils"
|
||||||
source: git
|
source: git
|
||||||
version: "0.11.3"
|
version: "0.12.0"
|
||||||
crypto:
|
crypto:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
@ -4,7 +4,7 @@ description: >-
|
|||||||
persisted per-install device id, and a one-call bootstrap. Removes the
|
persisted per-install device id, and a one-call bootstrap. Removes the
|
||||||
~150 lines of firebase_backend.dart every consuming app was copying.
|
~150 lines of firebase_backend.dart every consuming app was copying.
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 0.4.0
|
version: 0.5.1
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.12.2
|
sdk: ^3.12.2
|
||||||
@ -13,7 +13,7 @@ dependencies:
|
|||||||
crdt_sync:
|
crdt_sync:
|
||||||
git:
|
git:
|
||||||
url: https://github.com/kuhyx/utils
|
url: https://github.com/kuhyx/utils
|
||||||
ref: crdt_sync_dart-v0.11.3
|
ref: crdt_sync_dart-v0.12.1
|
||||||
path: crdt_sync_dart
|
path: crdt_sync_dart
|
||||||
crypto: 3.0.8
|
crypto: 3.0.8
|
||||||
flutter:
|
flutter:
|
||||||
|
|||||||
@ -11,7 +11,7 @@ dependencies:
|
|||||||
data_transfer_flutter:
|
data_transfer_flutter:
|
||||||
git:
|
git:
|
||||||
url: https://github.com/kuhyx/utils
|
url: https://github.com/kuhyx/utils
|
||||||
ref: data_transfer_flutter-v0.2.0
|
ref: data_transfer_flutter-v0.2.1
|
||||||
path: data_transfer_flutter
|
path: data_transfer_flutter
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@ -4,13 +4,13 @@ description: >-
|
|||||||
Flutter apps: system Save-as on Android/iOS (never cloud-only), a save
|
Flutter apps: system Save-as on Android/iOS (never cloud-only), a save
|
||||||
dialog on desktop, a download on web, plus a versioned export envelope.
|
dialog on desktop, a download on web, plus a versioned export envelope.
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 0.2.0
|
version: 0.2.2
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.12.2
|
sdk: ^3.12.2
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
file_picker: 13.3.1
|
file_picker: 13.3.3
|
||||||
file_selector: 1.1.0
|
file_selector: 1.1.0
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|||||||
@ -91,6 +91,8 @@ NPM_ACCEPT: Final = "application/vnd.npm.install-v1+json"
|
|||||||
# --- Cache ------------------------------------------------------------------
|
# --- Cache ------------------------------------------------------------------
|
||||||
|
|
||||||
CACHE_PATH_ENV: Final = "DEP_FRESHNESS_CACHE"
|
CACHE_PATH_ENV: Final = "DEP_FRESHNESS_CACHE"
|
||||||
|
# Seconds; overrides every ecosystem TTL (a repo that wants a once-a-day gate).
|
||||||
|
TTL_ENV: Final = "DEP_FRESHNESS_TTL_SECONDS"
|
||||||
DEFAULT_CACHE_DIR: Final = "~/.cache/dep-freshness"
|
DEFAULT_CACHE_DIR: Final = "~/.cache/dep-freshness"
|
||||||
CACHE_FILE: Final = "registry.json"
|
CACHE_FILE: Final = "registry.json"
|
||||||
TTL_SECONDS: Final = 6 * 3600
|
TTL_SECONDS: Final = 6 * 3600
|
||||||
|
|||||||
@ -21,6 +21,7 @@ from dep_freshness._tables import (
|
|||||||
DEFAULT_CACHE_DIR,
|
DEFAULT_CACHE_DIR,
|
||||||
GITCOMMIT,
|
GITCOMMIT,
|
||||||
GITTAG,
|
GITTAG,
|
||||||
|
TTL_ENV,
|
||||||
TTL_GITTAG_SECONDS,
|
TTL_GITTAG_SECONDS,
|
||||||
TTL_SECONDS,
|
TTL_SECONDS,
|
||||||
)
|
)
|
||||||
@ -33,7 +34,14 @@ def cache_dir() -> Path:
|
|||||||
|
|
||||||
|
|
||||||
def ttl_for(ecosystem: str) -> int:
|
def ttl_for(ecosystem: str) -> int:
|
||||||
"""Git refs move rarely and cost a subprocess, so they cache longer."""
|
"""Git refs move rarely and cost a subprocess, so they cache longer.
|
||||||
|
|
||||||
|
`DEP_FRESHNESS_TTL_SECONDS` overrides both; garbage is ignored, because a
|
||||||
|
typo in an env var must not break a commit hook.
|
||||||
|
"""
|
||||||
|
override = os.environ.get(TTL_ENV, "")
|
||||||
|
if override.isdigit() and int(override) > 0:
|
||||||
|
return int(override)
|
||||||
return TTL_GITTAG_SECONDS if ecosystem in (GITTAG, GITCOMMIT) else TTL_SECONDS
|
return TTL_GITTAG_SECONDS if ecosystem in (GITTAG, GITCOMMIT) else TTL_SECONDS
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -12,6 +12,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from dep_freshness._tables import TTL_SECONDS
|
||||||
from dep_freshness.cache import Cache, Entry, ttl_for
|
from dep_freshness.cache import Cache, Entry, ttl_for
|
||||||
from dep_freshness.models import Dep
|
from dep_freshness.models import Dep
|
||||||
from dep_freshness.registries import http
|
from dep_freshness.registries import http
|
||||||
@ -139,3 +140,16 @@ def _offline(*_args):
|
|||||||
|
|
||||||
def _explode(*_args):
|
def _explode(*_args):
|
||||||
raise RuntimeError("boom")
|
raise RuntimeError("boom")
|
||||||
|
|
||||||
|
|
||||||
|
def test_ttl_env_overrides_the_registry_ttl(monkeypatch) -> None:
|
||||||
|
"""A repo can stretch the refresh window (once-a-day pre-push gates)."""
|
||||||
|
monkeypatch.setenv("DEP_FRESHNESS_TTL_SECONDS", "86400")
|
||||||
|
assert ttl_for("npm") == 86400
|
||||||
|
assert ttl_for("gittag") == 86400
|
||||||
|
|
||||||
|
|
||||||
|
def test_ttl_env_garbage_falls_back_to_the_default(monkeypatch) -> None:
|
||||||
|
"""A malformed value must never break a commit hook."""
|
||||||
|
monkeypatch.setenv("DEP_FRESHNESS_TTL_SECONDS", "soon")
|
||||||
|
assert ttl_for("npm") == TTL_SECONDS
|
||||||
|
|||||||
@ -8,9 +8,10 @@ import 'package:flutter/material.dart';
|
|||||||
///
|
///
|
||||||
/// Rows per page come from the height the parent gives it; the overflow moves
|
/// Rows per page come from the height the parent gives it; the overflow moves
|
||||||
/// to further pages in a horizontal [PageView], marked by dots once there is
|
/// to further pages in a horizontal [PageView], marked by dots once there is
|
||||||
/// more than one. [itemBuilder] gets the global item index and must fill the
|
/// more than one (a "n / N" counter past 12 pages). [itemBuilder] gets the
|
||||||
/// cell it is given: a square of up to column width plus [labelHeight], the
|
/// global item index and must fill the cell it is given: a square of up to
|
||||||
/// rows stretching to use up the leftover height.
|
/// column width plus [labelHeight], the rows stretching to use up the
|
||||||
|
/// leftover height.
|
||||||
class FitGrid extends StatefulWidget {
|
class FitGrid extends StatefulWidget {
|
||||||
/// Creates the grid.
|
/// Creates the grid.
|
||||||
const FitGrid({
|
const FitGrid({
|
||||||
@ -54,6 +55,10 @@ class FitGrid extends StatefulWidget {
|
|||||||
const double _dotSize = AppSpacing.sm;
|
const double _dotSize = AppSpacing.sm;
|
||||||
const double _dotsHeight = AppSpacing.md + AppSpacing.xs;
|
const double _dotsHeight = AppSpacing.md + AppSpacing.xs;
|
||||||
|
|
||||||
|
// Above this many pages a row of dots outgrows a phone width, so the
|
||||||
|
// indicator becomes a "n / N" counter instead.
|
||||||
|
const int _maxDots = 12;
|
||||||
|
|
||||||
class _FitGridState extends State<FitGrid> {
|
class _FitGridState extends State<FitGrid> {
|
||||||
final _controller = PageController();
|
final _controller = PageController();
|
||||||
var _page = 0;
|
var _page = 0;
|
||||||
@ -159,22 +164,35 @@ class _Dots extends StatelessWidget {
|
|||||||
return ExcludeSemantics(
|
return ExcludeSemantics(
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
height: _dotsHeight,
|
height: _dotsHeight,
|
||||||
child: Row(
|
child: count > _maxDots
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
? Center(
|
||||||
children: [
|
child: Text(
|
||||||
for (var i = 0; i < count; i++)
|
'${current + 1} / $count',
|
||||||
Container(
|
key: const ValueKey<String>('fit-grid-page-counter'),
|
||||||
key: ValueKey<String>('fit-grid-dot-$i'),
|
style: Theme.of(context).textTheme.labelSmall,
|
||||||
width: _dotSize,
|
textScaler: TextScaler.noScaling,
|
||||||
height: _dotSize,
|
|
||||||
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.xs),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
color: i == current ? scheme.primary : scheme.outlineVariant,
|
|
||||||
),
|
),
|
||||||
|
)
|
||||||
|
: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
for (var i = 0; i < count; i++)
|
||||||
|
Container(
|
||||||
|
key: ValueKey<String>('fit-grid-dot-$i'),
|
||||||
|
width: _dotSize,
|
||||||
|
height: _dotSize,
|
||||||
|
margin: const EdgeInsets.symmetric(
|
||||||
|
horizontal: AppSpacing.xs,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: i == current
|
||||||
|
? scheme.primary
|
||||||
|
: scheme.outlineVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,7 @@ description: >-
|
|||||||
helpers that were each reimplemented in three or more repos. Owns the tokens
|
helpers that were each reimplemented in three or more repos. Owns the tokens
|
||||||
-- consumers import them rather than transcribing the token table by hand.
|
-- consumers import them rather than transcribing the token table by hand.
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 0.3.0
|
version: 0.3.1
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.12.2
|
sdk: ^3.12.2
|
||||||
|
|||||||
@ -126,6 +126,30 @@ void main() {
|
|||||||
expect(find.text('item $perPage').hitTestable(), findsNothing);
|
expect(find.text('item $perPage').hitTestable(), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('more than 12 pages: "n / N" counter instead of dots', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await _pump(tester, count: 30);
|
||||||
|
final perPage = _onScreen(tester);
|
||||||
|
final count = perPage * 13;
|
||||||
|
await _pump(tester, count: count);
|
||||||
|
expect(_dot, findsNothing);
|
||||||
|
final counter = find.byKey(const ValueKey('fit-grid-page-counter'));
|
||||||
|
expect(tester.widget<Text>(counter).data, '1 / 13');
|
||||||
|
await tester.fling(find.byType(PageView), const Offset(-250, 0), 1000);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(tester.widget<Text>(counter).data, '2 / 13');
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('exactly 12 pages still shows dots', (tester) async {
|
||||||
|
await _pump(tester, count: 30);
|
||||||
|
final perPage = _onScreen(tester);
|
||||||
|
await _pump(tester, count: perPage * 12);
|
||||||
|
expect(_dot, findsNWidgets(12));
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('swiping left shows the next page and moves the dot', (
|
testWidgets('swiping left shows the next page and moves the dot', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
|
|||||||
@ -1,165 +0,0 @@
|
|||||||
# Dopamine UX — session prompts
|
|
||||||
|
|
||||||
Ten pasteable prompts derived from *"The Dopamine Architecture: How UX Designers
|
|
||||||
Hold User Attention in 2026"* (london-post.co.uk), applied to kuhy's repos.
|
|
||||||
|
|
||||||
Paste **one file's contents** into a fresh Claude session. Each is
|
|
||||||
self-contained: it restates the findings it depends on, so no session needs the
|
|
||||||
context of another.
|
|
||||||
|
|
||||||
## The two tests — every change must pass both
|
|
||||||
|
|
||||||
The article's four mechanics (variable rewards, micro-animations, sound design,
|
|
||||||
near-miss) are in scope, aimed at **kuhy's own stated goals**. They are gated by
|
|
||||||
two questions, not by an allow/deny list:
|
|
||||||
|
|
||||||
1. **Is the displayed state true?** A progress ring at 93% is legitimate *iff*
|
|
||||||
you are actually at 93%. A streak is legitimate iff it counts real days.
|
|
||||||
Dopamine may come from framing, timing and salience — **never from a
|
|
||||||
fabricated number**.
|
|
||||||
2. **Is the variance real?** A random pick from a real pool (next workout, next
|
|
||||||
task) is legitimate. Injected random bonus points to manufacture uncertainty
|
|
||||||
are not.
|
|
||||||
|
|
||||||
### The load-bearing corollary
|
|
||||||
|
|
||||||
**Reward the recording, never the number recorded.** In diet-guard the
|
|
||||||
satisfying moment fires on *logging a meal*, not on hitting a calorie target —
|
|
||||||
otherwise the app rewards eating less rather than recording honestly, and
|
|
||||||
corrupts its own data. Same shape in workout_app (reward logging a workout, not
|
|
||||||
beating a PB) and todo (reward capture, not completion count).
|
|
||||||
|
|
||||||
## Running order
|
|
||||||
|
|
||||||
`01` gates everything. `07` gates `08`. Everything else is independent.
|
|
||||||
|
|
||||||
| # | File | Repo | Findings |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 01 | `01-motion-tokens.md` | `~/src/utils` | 3 |
|
|
||||||
| 02 | `02-structural-check.md` | `~/src/utils` | 2 |
|
|
||||||
| 03 | todo note `85ca329b-9809-412c-a47f-eddf055322cf` | `~/src/diet-guard` | 4 |
|
|
||||||
| 04 | todo note `73a20837-6480-4b31-b966-1898a4e7272e` | `~/src/screen-locker` | 3 |
|
|
||||||
| 05 | todo note `e50d6a03-ae7d-4da5-bbd3-c33b17f6f074` | `~/src/screen-locker/stronglift_replacement/workout_app` | 4 |
|
|
||||||
| 06 | `06-todo.md` | `~/src/todo` | 3 |
|
|
||||||
| 07 | `07-dufs-theme.md` | `~/src/dufs-cloud` | 2 |
|
|
||||||
| 08 | todo note `e301e090-b2ee-4e26-8387-00f356b63521` | `~/src/dufs-cloud` | 4 |
|
|
||||||
| 09 | todo note `9e1e4922-9f29-4bad-bcf4-96826aa8ffa4` | `~/src/wake-alarm` | 2 |
|
|
||||||
|
|
||||||
**27 ranked findings total.**
|
|
||||||
|
|
||||||
### Dependency graph
|
|
||||||
|
|
||||||
```
|
|
||||||
01-motion-tokens ──┬─→ 03, 04, 05, 09 (transcribe tokens locally)
|
|
||||||
├─→ 06 (bump design_system-v0.2.0)
|
|
||||||
└─→ 07 ─→ 08 (bump web_ui-v0.3.2)
|
|
||||||
02-structural-check (independent; run after 01 so it has motion tokens to check)
|
|
||||||
```
|
|
||||||
|
|
||||||
## The top three findings fleet-wide
|
|
||||||
|
|
||||||
Ranked by value-over-effort from the survey data:
|
|
||||||
|
|
||||||
1. **screen-locker streak render** (prompt 04) — `snapshot.streak` is already
|
|
||||||
populated and no renderer reads it. Zero new state, zero conftest change,
|
|
||||||
zero new dependency.
|
|
||||||
2. **diet-guard `_onLogMeal` feedback** (prompt 03) — the flagship case.
|
|
||||||
Dependency-free via `HapticFeedback` from `flutter/services.dart`.
|
|
||||||
3. **workout_app finish-workout cue** (prompt 05) — `audioplayers`, `vibration`,
|
|
||||||
the `assets:` block and the VIBRATE permission are **already present**. Only
|
|
||||||
the call is missing.
|
|
||||||
|
|
||||||
## Distributed copies, and the completion protocol
|
|
||||||
|
|
||||||
Each prompt is **also copied into the repo it acts on**, at
|
|
||||||
`<repo>/prompts/dopamine-ux-*.md`, so you can open a repo and say
|
|
||||||
"do dopamine-ux-<name>" without going hunting. **This directory stays the source
|
|
||||||
of truth** — if a prompt needs correcting, fix it here and re-distribute.
|
|
||||||
|
|
||||||
| Prompt | Distributed to |
|
|
||||||
|---|---|
|
|
||||||
| 01 | `~/src/utils/prompts/dopamine-ux-01-motion-tokens.md` — deleted (done) |
|
|
||||||
| 02 | `~/src/utils/prompts/dopamine-ux-02-structural-check.md` |
|
|
||||||
| 03 | `~/src/diet-guard/prompts/dopamine-ux-diet-guard.md` |
|
|
||||||
| 04 | `~/src/screen-locker/prompts/dopamine-ux-04-screen-locker.md` — deleted (done) |
|
|
||||||
| 05 | `~/src/screen-locker/prompts/dopamine-ux-05-workout-app.md` — deleted (done) |
|
|
||||||
| 06 | `~/src/todo/prompts/dopamine-ux-todo.md` |
|
|
||||||
| 07 | `~/src/dufs-cloud/prompts/dopamine-ux-07-theme.md` |
|
|
||||||
| 08 | `~/src/dufs-cloud/prompts/dopamine-ux-08-motion.md` |
|
|
||||||
| 09 | `~/src/wake-alarm/prompts/dopamine-ux-wake-alarm.md` |
|
|
||||||
|
|
||||||
When a session finishes a prompt it **deletes its repo copy** (in the same
|
|
||||||
commit as the implementation) and **appends a row below**. A finished prompt left
|
|
||||||
in a repo is indistinguishable from a pending one, and the next session re-runs
|
|
||||||
it. Partial completion is never recorded as DONE — the file is edited to state
|
|
||||||
what remains instead.
|
|
||||||
|
|
||||||
### Completion log
|
|
||||||
|
|
||||||
| Prompt | Status | Impl commit | Note |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 01-motion-tokens.md | DONE 2026-08-22 | `f741896` | `motion.md` frozen; `AppDuration`/`AppCurve` + `--duration-*`/`--ease-*` shipped. Tags: `design_system-v0.2.0`, `web_ui-v0.3.2`. |
|
|
||||||
| 02-structural-check.md | not started | — | — |
|
|
||||||
| todo note `85ca329b-9809-412c-a47f-eddf055322cf` | not started | — | — |
|
|
||||||
| todo note `73a20837-6480-4b31-b966-1898a4e7272e` | DONE 2026-11-01 | `89c65bd` | Status window `_section_streak` + week-transition reward text (kept in `extra_benefits_state.json`); the dead 09:00 early-bird claim is not shown. Per kuhy: no daily-streak state, no shutdown time, no freezes. Commit is on a worktree branch, unpushed. |
|
|
||||||
| todo note `e50d6a03-ae7d-4da5-bbd3-c33b17f6f074` | DONE 2026-11-01 | `e938201` | Set/warmup `selectionClick`, finish `mediumImpact` + generated chime (music_theory, pinned tag) + dialog arrival, `AppDuration`/`AppCurve` in theme.dart, streak counts up, reduced motion honoured, one Settings "Sound" switch for chime and break-end (foreground service too). Worktree commits, unpushed; `62c1c2c` covers old export/import gaps. |
|
|
||||||
| 06-todo.md | PARTIAL 2026-11-02 (step 1 DONE; step 2 sound REJECTED by kuhy) | `kuhyx/todo-app 2ffae26` | haptic + checkmark on capture, appRoute motion tokens, animated wizard bar; no sound by decision |
|
|
||||||
| 07-dufs-theme.md | not started | — | — |
|
|
||||||
| todo note `e301e090-b2ee-4e26-8387-00f356b63521` | not started | — | — |
|
|
||||||
| todo note `9e1e4922-9f29-4bad-bcf4-96826aa8ffa4` | not started | — | — |
|
|
||||||
|
|
||||||
## Keeping these prompts honest
|
|
||||||
|
|
||||||
Every path and symbol cited across these files is machine-checked:
|
|
||||||
|
|
||||||
```
|
|
||||||
python3 ~/src/utils/dopamine-ux/verify_prompts.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Exit 0 means every cited path resolves and every cited symbol is still greppable
|
|
||||||
in the file it is attributed to. **Re-run it after any refactor of the surveyed
|
|
||||||
repos** — a prompt pointing at a renamed function is worse than no prompt, and a
|
|
||||||
path check alone cannot catch that (which is why it greps symbols too).
|
|
||||||
|
|
||||||
Verified 2026-08-16: 146 paths, 30 symbols, all passing.
|
|
||||||
|
|
||||||
## Rules that apply to every prompt
|
|
||||||
|
|
||||||
1. **Tag cuts and version bumps are explicit steps.** `todo` consumes
|
|
||||||
`design_system` at git ref `design_system-v0.1.0`; `dufs-cloud/web` consumes
|
|
||||||
`@kuhyx/web-ui` at `web_ui-v0.3.1`. Adding tokens to those packages does
|
|
||||||
nothing for consumers until a new tag is cut. Prompt 01 ends by cutting
|
|
||||||
`design_system-v0.2.0` and `web_ui-v0.3.2`; prompts 06 and 08 begin by
|
|
||||||
bumping to those literal refs.
|
|
||||||
2. **Anchor on symbols, not line numbers.** Line numbers in these prompts are
|
|
||||||
"as of 2026-08-16" and will drift. The symbol name is the real anchor —
|
|
||||||
grep for it rather than trusting the line.
|
|
||||||
3. **Reduce-motion is mandatory and is NOT the sound toggle.** Web:
|
|
||||||
`@media (prefers-reduced-motion: reduce)`. Flutter:
|
|
||||||
`MediaQuery.of(context).disableAnimations`. The OS motion preference is
|
|
||||||
honoured automatically; the sound opt-out is a separate user-facing toggle.
|
|
||||||
4. **Motion and haptics first, sound second.** Haptics need no dependency;
|
|
||||||
sound needs a pubspec dep, an `assets:` block, and a settings toggle. Each
|
|
||||||
prompt stages them separately so a short session still lands the valuable
|
|
||||||
half, and sound stays independently revertable.
|
|
||||||
5. **Sound ships on (opt-out), never in screen-locker enforcement paths.**
|
|
||||||
6. **250-line cap** applies to code and prose alike (`~/src/utils/file_length`).
|
|
||||||
|
|
||||||
## Source
|
|
||||||
|
|
||||||
Article findings, verbatim from the piece:
|
|
||||||
|
|
||||||
- **Variable reward schedules** — unpredictable outcomes produce stronger
|
|
||||||
dopaminergic activity than guaranteed ones. Duolingo streaks, feed ordering,
|
|
||||||
pull-to-refresh.
|
|
||||||
- **Micro-animations** — "a button that bounces on press, a like counter that
|
|
||||||
ticks upward with a slight delay". Replaces latency with feedback, so waiting
|
|
||||||
reads as responsiveness rather than frustration.
|
|
||||||
- **Sound design** — "operates below the threshold of conscious attention";
|
|
||||||
the article's judgement is that it is more powerful than visual design and
|
|
||||||
gets less critical attention.
|
|
||||||
- **Near-miss effects** — a fitness ring at ninety-three per cent creates
|
|
||||||
discomfort that drives re-engagement, and works *even when the user is
|
|
||||||
explicitly told the mechanism*.
|
|
||||||
- Core claim: **dopamine is released in anticipation of a reward, not on
|
|
||||||
receipt. The uncertainty is the trigger.**
|
|
||||||
@ -1,209 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Verify every path and symbol cited in the dopamine-ux prompt files.
|
|
||||||
|
|
||||||
A copy-pasteable prompt that points at a file which does not exist is worse than
|
|
||||||
no prompt, so this gate adjudicates with an exit code rather than by eye.
|
|
||||||
|
|
||||||
Two checks:
|
|
||||||
1. Every cited on-disk path resolves.
|
|
||||||
2. Every cited symbol is greppable in the file it is attributed to. A path
|
|
||||||
check alone cannot catch a stale line number; a symbol grep can.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
PROMPT_DIR = Path.home() / "src/utils" / "dopamine-ux"
|
|
||||||
HOME = Path.home()
|
|
||||||
|
|
||||||
PATH_RE = re.compile(r"`(~?/[^`\s]+|[\w.\-]+/[\w./\-]+\.\w+)`")
|
|
||||||
|
|
||||||
# Not on-disk paths despite matching the shape above.
|
|
||||||
SKIP_SUBSTRINGS = (
|
|
||||||
"://",
|
|
||||||
"$",
|
|
||||||
"*",
|
|
||||||
"<",
|
|
||||||
">",
|
|
||||||
"…",
|
|
||||||
"devices/", # Firebase/crdt key path
|
|
||||||
"settings/advanced", # Firebase key path
|
|
||||||
"android.permission",
|
|
||||||
"android.intent",
|
|
||||||
"/sdcard/",
|
|
||||||
"/etc/",
|
|
||||||
"/dev/input/",
|
|
||||||
"/opt/",
|
|
||||||
"com.kuhy",
|
|
||||||
"app-release.apk",
|
|
||||||
"build/app/outputs", # build outputs
|
|
||||||
"flutter/services.dart",
|
|
||||||
"flutter/material.dart", # package imports
|
|
||||||
)
|
|
||||||
|
|
||||||
# Files a prompt CREATES. Citing them forward is correct, so absence is expected
|
|
||||||
# -- but pairing each with its creating prompt still catches a typo.
|
|
||||||
CREATED_BY_PROMPT = {
|
|
||||||
"~/src/utils/unified-design-system/motion.md": "01",
|
|
||||||
"~/src/utils/unified-design-system/scripts/structural_check.py": "02",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Cited precisely to state that they do NOT exist.
|
|
||||||
ASSERTED_ABSENT = {
|
|
||||||
"~/gatelock",
|
|
||||||
# Distributed prompt copies are `git rm`d on completion (see the protocol in
|
|
||||||
# 00-INDEX.md), but 00-INDEX.md keeps citing them in its "Distributed copies"
|
|
||||||
# table. Asserting ABSENT rather than dropping the row keeps the check
|
|
||||||
# two-directional: a finished prompt reappearing in a repo is itself a
|
|
||||||
# failure, because the next session would re-run it. Move a row here as each
|
|
||||||
# prompt completes.
|
|
||||||
"~/src/utils/prompts/dopamine-ux-01-motion-tokens.md",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Generic prose fragments rather than citations of one specific file.
|
|
||||||
PROSE_FRAGMENTS = {"lib/ui/theme.dart", "lib/main.dart", "pubspec.yaml"}
|
|
||||||
|
|
||||||
# Symbols each prompt claims exist, as (repo-relative file, symbol).
|
|
||||||
SYMBOL_CLAIMS = [
|
|
||||||
("~/src/diet-guard/app/lib/screens/log_meal_screen.dart", "_onLogMeal"),
|
|
||||||
("~/src/diet-guard/app/lib/widgets/today_progress_card.dart", "TodayProgressCard"),
|
|
||||||
("~/src/diet-guard/app/lib/widgets/streak_summary_row.dart", "StreakSummaryRow"),
|
|
||||||
("~/src/diet-guard/app/lib/screens/log_meal_progress.dart", "buildTodayProgress"),
|
|
||||||
("~/src/diet-guard/app/lib/services/app_settings_service.dart", "_writeToDisk"),
|
|
||||||
("~/src/screen-locker/screen_locker/_status_types.py", "StatusSnapshot"),
|
|
||||||
("~/src/screen-locker/screen_locker/_extra_benefits.py", "process_week_transition"),
|
|
||||||
("~/src/screen-locker/screen_locker/_extra_benefits.py", "current_streak"),
|
|
||||||
("~/src/screen-locker/screen_locker/_unlock_view.py", "unlock_screen"),
|
|
||||||
("~/src/screen-locker/screen_locker/_log_mixin.py", "write_signed_entry"),
|
|
||||||
("~/src/screen-locker/screen_locker/tests/conftest.py", "_ISOLATED_STATE"),
|
|
||||||
("~/src/screen-locker/screen_locker/_weekly_check.py", "WEEKLY_WORKOUT_MINIMUM"),
|
|
||||||
(
|
|
||||||
"~/src/screen-locker/stronglift_replacement/workout_app/lib/screens/"
|
|
||||||
"workout_screen_session.dart",
|
|
||||||
"_playBreakEndCue",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"~/src/screen-locker/stronglift_replacement/workout_app/lib/screens/"
|
|
||||||
"workout_screen_finish.dart",
|
|
||||||
"_persistFinishedWorkout",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"~/src/screen-locker/stronglift_replacement/workout_app/lib/widgets/"
|
|
||||||
"workout_summary_dialog.dart",
|
|
||||||
"WorkoutSummaryDialog",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"~/src/screen-locker/stronglift_replacement/workout_app/lib/services/"
|
|
||||||
"storage_service.dart",
|
|
||||||
"_getSetting",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"~/src/screen-locker/stronglift_replacement/workout_app/lib/widgets/"
|
|
||||||
"exercise_tile_rows.dart",
|
|
||||||
"AnimatedContainer",
|
|
||||||
),
|
|
||||||
("~/src/todo/lib/data/app_settings.dart", "withAdvancedMode"),
|
|
||||||
("~/src/todo/lib/ui/settings_screen.dart", "SwitchListTile"),
|
|
||||||
("~/src/dufs-cloud/web/src/hooks/use-cloud-index.ts", "walkInto"),
|
|
||||||
("~/src/dufs-cloud/app/lib/services/cloud_index.dart", "buildCloudIndex"),
|
|
||||||
("~/src/dufs-cloud/web/src/lib/download.ts", "buildSelectionZip"),
|
|
||||||
("~/src/dufs-cloud/app/lib/services/download_zip.dart", "buildSelectionZip"),
|
|
||||||
("~/src/wake-alarm/phone_app/lib/screens/home_screen.dart", "_setPhoneAlarm"),
|
|
||||||
("~/src/wake-alarm/wake_alarm/_audio.py", "_play_on_all_sinks"),
|
|
||||||
("~/src/utils/unified-design-system/scripts/palette_check.py", "NON_COLOUR_CSS"),
|
|
||||||
("~/src/utils/unified-design-system/scripts/palette_map.py", "PALETTE"),
|
|
||||||
("~/src/utils/design_system/lib/src/feedback.dart", "showToast"),
|
|
||||||
("~/src/utils/design_system/lib/src/tokens.dart", "AppSpacing"),
|
|
||||||
("~/src/utils/gatelock/gatelock/_window.py", "LockConfig"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def expand(raw: str) -> Path:
|
|
||||||
return HOME / raw[2:] if raw.startswith("~/") else Path(raw)
|
|
||||||
|
|
||||||
|
|
||||||
def candidate_paths(text: str) -> set[str]:
|
|
||||||
return {
|
|
||||||
raw
|
|
||||||
for raw in PATH_RE.findall(text)
|
|
||||||
if not any(s in raw for s in SKIP_SUBSTRINGS)
|
|
||||||
and raw not in PROSE_FRAGMENTS
|
|
||||||
and not re.search(r"\.\w+:\d+$", raw) # trailing :line suffix
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def prompt_roots(text: str) -> list[Path]:
|
|
||||||
roots = []
|
|
||||||
for label in ("Repo", "App", "Flutter app"):
|
|
||||||
for m in re.finditer(rf"{label}: `(~/[^`]+)`", text):
|
|
||||||
roots.append(expand(m.group(1)))
|
|
||||||
return roots
|
|
||||||
|
|
||||||
|
|
||||||
def check_paths() -> list[str]:
|
|
||||||
failures = []
|
|
||||||
checked = 0
|
|
||||||
for prompt in sorted(PROMPT_DIR.glob("*.md")):
|
|
||||||
text = prompt.read_text()
|
|
||||||
roots = prompt_roots(text)
|
|
||||||
for raw in sorted(candidate_paths(text)):
|
|
||||||
if raw in ASSERTED_ABSENT:
|
|
||||||
if expand(raw).exists():
|
|
||||||
failures.append(
|
|
||||||
f"{prompt.name}: `{raw}` is asserted ABSENT but EXISTS"
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
if raw in CREATED_BY_PROMPT:
|
|
||||||
owner = CREATED_BY_PROMPT[raw]
|
|
||||||
cites_owner = re.search(rf"[Pp]rompt {owner}", text) is not None
|
|
||||||
if not prompt.name.startswith(owner) and not cites_owner:
|
|
||||||
failures.append(
|
|
||||||
f"{prompt.name}: cites `{raw}` without noting prompt "
|
|
||||||
f"{owner} creates it"
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
checked += 1
|
|
||||||
if raw.startswith(("~/", "/")):
|
|
||||||
if not expand(raw).exists():
|
|
||||||
failures.append(f"{prompt.name}: `{raw}` MISSING")
|
|
||||||
elif not any((r / raw).exists() for r in roots):
|
|
||||||
failures.append(
|
|
||||||
f"{prompt.name}: `{raw}` not found under any of "
|
|
||||||
f"{[str(r) for r in roots]}"
|
|
||||||
)
|
|
||||||
print(f"paths: checked {checked}")
|
|
||||||
return failures
|
|
||||||
|
|
||||||
|
|
||||||
def check_symbols() -> list[str]:
|
|
||||||
failures = []
|
|
||||||
for raw_file, symbol in SYMBOL_CLAIMS:
|
|
||||||
path = expand(raw_file)
|
|
||||||
if not path.exists():
|
|
||||||
failures.append(f"symbol source MISSING: {raw_file}")
|
|
||||||
continue
|
|
||||||
if symbol not in path.read_text(errors="replace"):
|
|
||||||
failures.append(f"symbol `{symbol}` NOT FOUND in {raw_file}")
|
|
||||||
print(f"symbols: checked {len(SYMBOL_CLAIMS)}")
|
|
||||||
return failures
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
n_prompts = len(list(PROMPT_DIR.glob("*.md")))
|
|
||||||
print(f"verifying {n_prompts} prompt files in {PROMPT_DIR}")
|
|
||||||
failures = check_paths() + check_symbols()
|
|
||||||
if failures:
|
|
||||||
print(f"\nFAIL -- {len(failures)} problem(s):")
|
|
||||||
for f in failures:
|
|
||||||
print(f" {f}")
|
|
||||||
return 1
|
|
||||||
print("\nPASS -- every cited path exists and every cited symbol is present")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
@ -189,16 +189,17 @@ How far to trust a gate is computed from its ledger, never listed by name:
|
|||||||
`workout_match` counts a `runnerup_tcx` row by `completed_at`, a `rest_day` row
|
`workout_match` counts a `runnerup_tcx` row by `completed_at`, a `rest_day` row
|
||||||
on its `day` only if `detail.declared_at` predates that day (else logged, no).
|
on its `day` only if `detail.declared_at` predates that day (else logged, no).
|
||||||
|
|
||||||
## The focus project (0.19.0, cut 180 since 0.20.0)
|
## The focus project (0.19.0, cut 180 since 0.20.0, pays 240 since 0.21.0)
|
||||||
|
|
||||||
While `focus_project.active(day)` returns a project, `earners_for` drops the
|
While `focus_project.active(day)` returns a project, `earners_for` drops the
|
||||||
gates it `replaces` (`book` = `reading`) and adds one counted `focus` earner
|
gates it `replaces` (`book` = `reading`) and adds one counted `focus` earner
|
||||||
(`max_units` = the day's `daily_on`) paying what they paid (shutdown by their rungs,
|
(`max_units` = the day's `daily_on`). Shutdown pays their rungs (180/120 min); gaming
|
||||||
gaming by `max_gaming_minutes`: 60+60+60 over 120 min = 1.5/min):
|
their `max_gaming_minutes` + pure bonuses again (240/120 min = 2/min; full day = 8 h):
|
||||||
`floor(summed minutes * pay / daily_minutes)`, never per row. The penalty runs
|
`floor(summed minutes * pay / daily_minutes)`, never per row. The penalty runs
|
||||||
from the start day, cutting all of it, 180 with LeetCode's formerly free hour (0.20.0); maturity never delays it. Ledger `~/.local/share/focus_project/ledger.json`:
|
from the start day, cutting all of it, 180 with LeetCode's formerly free hour (0.20.0); maturity never delays it. Ledger `~/.local/share/focus_project/ledger.json`:
|
||||||
signed `credit` rows, `entry_id` `<project>-<YYYYMMDD>-m<n>`, `detail.minutes` (positive int),
|
signed `credit` rows, `entry_id` `<project>-<YYYYMMDD>-m<n>`, `detail.minutes` (positive int),
|
||||||
`detail.ended_at` (else `day`). `focus_project` unusable = no project (logged).
|
`detail.ended_at` (else `day`). `focus_project` unusable = no project (logged).
|
||||||
|
Unbacked timer time pays 0 from 2026-11-08 (focus_project 0.10.0).
|
||||||
|
|
||||||
## Adding a gate
|
## Adding a gate
|
||||||
|
|
||||||
|
|||||||
@ -31,9 +31,13 @@ _warned: set[str] = set()
|
|||||||
class FocusEarner(Earner):
|
class FocusEarner(Earner):
|
||||||
"""Counted; ``gaming_minutes``/``shutdown_minutes`` pay a FULL day.
|
"""Counted; ``gaming_minutes``/``shutdown_minutes`` pay a FULL day.
|
||||||
|
|
||||||
The gaming base cut is all of it, LeetCode's free hour too (kuhy 2026-11-02).
|
``cut_gaming_minutes``: the gaming base cut, all the replaced gates paid,
|
||||||
|
LeetCode's free hour too (0.20.0). The full-day gaming pay adds that free
|
||||||
|
hour once more, so a full day matches the old one (kuhy 2026-11-02, 0.21.0).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
cut_gaming_minutes: int = 0
|
||||||
|
|
||||||
def gaming_for(self, units: int) -> int:
|
def gaming_for(self, units: int) -> int:
|
||||||
"""Gaming minutes of ``units`` focused minutes, floored."""
|
"""Gaming minutes of ``units`` focused minutes, floored."""
|
||||||
return self.capped(units) * self.gaming_minutes // (self.max_units or 1)
|
return self.capped(units) * self.gaming_minutes // (self.max_units or 1)
|
||||||
@ -66,11 +70,18 @@ def daily_on(project: Any, day: date) -> int: # noqa: ANN401
|
|||||||
|
|
||||||
|
|
||||||
def focus_earner(project: Any, dropped: tuple[Earner, ...], day: date) -> Earner: # noqa: ANN401
|
def focus_earner(project: Any, dropped: tuple[Earner, ...], day: date) -> Earner: # noqa: ANN401
|
||||||
"""The ``focus`` earner paying what ``dropped`` paid, over ``project``."""
|
"""The ``focus`` earner paying what ``dropped`` paid, over ``project``.
|
||||||
|
|
||||||
|
Gaming: the cut is every dropped gate's full pay; a full day pays that plus
|
||||||
|
the pure bonuses (no ``penalty_from``) again: 180 + 60 = 240 at 120 min.
|
||||||
|
"""
|
||||||
|
cut = sum(e.max_gaming_minutes for e in dropped)
|
||||||
|
free = tuple(e for e in dropped if e.penalty_from is None)
|
||||||
return FocusEarner(
|
return FocusEarner(
|
||||||
name="focus",
|
name="focus",
|
||||||
label="Focus",
|
label="Focus",
|
||||||
gaming_minutes=sum(e.max_gaming_minutes for e in dropped),
|
gaming_minutes=cut + sum(e.max_gaming_minutes for e in free),
|
||||||
|
cut_gaming_minutes=cut,
|
||||||
shutdown_minutes=sum(
|
shutdown_minutes=sum(
|
||||||
shutdown_units_minutes(e, e.max_units or 1, day) for e in dropped
|
shutdown_units_minutes(e, e.max_units or 1, day) for e in dropped
|
||||||
),
|
),
|
||||||
|
|||||||
@ -25,6 +25,7 @@ from datetime import UTC, date, datetime
|
|||||||
import logging
|
import logging
|
||||||
from typing import TYPE_CHECKING, Final
|
from typing import TYPE_CHECKING, Final
|
||||||
|
|
||||||
|
from earned_time._focus import FocusEarner
|
||||||
from earned_time._ladder import on_ladder, shutdown_ceiling_for
|
from earned_time._ladder import on_ladder, shutdown_ceiling_for
|
||||||
from earned_time._leisure import base_minutes, ceiling_minutes, is_leisure
|
from earned_time._leisure import base_minutes, ceiling_minutes, is_leisure
|
||||||
from earned_time._maturity import confirmed_start, penalty_start
|
from earned_time._maturity import confirmed_start, penalty_start
|
||||||
@ -45,6 +46,16 @@ def _today() -> date:
|
|||||||
return datetime.now(tz=UTC).astimezone().date()
|
return datetime.now(tz=UTC).astimezone().date()
|
||||||
|
|
||||||
|
|
||||||
|
def _gaming_cut(item: Earner) -> int:
|
||||||
|
"""What a penalty in force takes off the gaming base: all ``item`` can pay.
|
||||||
|
|
||||||
|
A focus earner cuts what the gates it replaced paid; it pays more (0.21.0).
|
||||||
|
"""
|
||||||
|
if isinstance(item, FocusEarner):
|
||||||
|
return item.cut_gaming_minutes
|
||||||
|
return item.max_gaming_minutes
|
||||||
|
|
||||||
|
|
||||||
def _penalised(
|
def _penalised(
|
||||||
item: Earner, day: date, first_credits: Mapping[str, date | None] | None
|
item: Earner, day: date, first_credits: Mapping[str, date | None] | None
|
||||||
) -> bool:
|
) -> bool:
|
||||||
@ -117,7 +128,7 @@ def _base(
|
|||||||
penalised = [e for e in registry if _penalised(e, target, first_credits)]
|
penalised = [e for e in registry if _penalised(e, target, first_credits)]
|
||||||
return Base(
|
return Base(
|
||||||
gaming_minutes=base_minutes(leisure=on)
|
gaming_minutes=base_minutes(leisure=on)
|
||||||
- sum(e.max_gaming_minutes for e in penalised),
|
- sum(_gaming_cut(e) for e in penalised),
|
||||||
shutdown_minutes=_shutdown_floor(target, registry, penalised),
|
shutdown_minutes=_shutdown_floor(target, registry, penalised),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -22,7 +22,7 @@ from earned_time import (
|
|||||||
)
|
)
|
||||||
from earned_time._focus import focus_earner
|
from earned_time._focus import focus_earner
|
||||||
from earned_time.tests._focus_fakes import DAY, START, activate, focus, project, row
|
from earned_time.tests._focus_fakes import DAY, START, activate, focus, project, row
|
||||||
from earned_time.tests._maturity_rows import ledger_file
|
from earned_time.tests._maturity_rows import ledger_file, signed
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@ -61,20 +61,24 @@ def test_a_project_that_changes_nothing_leaves_the_registry(
|
|||||||
assert earners_for(DAY) is TUTOR_EARNERS
|
assert earners_for(DAY) is TUTOR_EARNERS
|
||||||
|
|
||||||
|
|
||||||
def test_a_full_day_pays_what_the_replaced_gates_paid(
|
def test_a_full_day_pays_the_cut_plus_the_free_leetcode_hour(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""kuhy 2026-11-02 (0.21.0): 2 gaming minutes per focus minute, 240 a day."""
|
||||||
item = focus(monkeypatch)
|
item = focus(monkeypatch)
|
||||||
assert item.max_units == 120
|
assert item.max_units == 120
|
||||||
assert (item.gaming_for(120), item.shutdown_for(120, DAY)) == (180, 180)
|
assert (item.gaming_for(120), item.shutdown_for(120, DAY)) == (240, 180)
|
||||||
assert (item.gaming_for(999), item.shutdown_for(999, DAY)) == (180, 180)
|
assert (item.gaming_for(999), item.shutdown_for(999, DAY)) == (240, 180)
|
||||||
assert (item.max_gaming_minutes, item.gaming_for(-5)) == (180, 0)
|
assert (item.max_gaming_minutes, item.gaming_for(-5)) == (240, 0)
|
||||||
|
assert item.cut_gaming_minutes == 180
|
||||||
|
|
||||||
|
|
||||||
def test_pay_is_floored_on_the_summed_minutes(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_pay_is_floored_on_the_summed_minutes(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
item = focus(monkeypatch)
|
item = focus(monkeypatch)
|
||||||
assert [item.gaming_for(n) for n in (1, 2, 3, 7)] == [1, 3, 4, 10]
|
assert [item.gaming_for(n) for n in (1, 2, 3, 7)] == [2, 4, 6, 14]
|
||||||
assert [item.shutdown_for(n, DAY) for n in (1, 2, 3, 7)] == [1, 3, 4, 10]
|
assert [item.shutdown_for(n, DAY) for n in (1, 2, 3, 7)] == [1, 3, 4, 10]
|
||||||
|
odd = focus(monkeypatch, daily_minutes=90)
|
||||||
|
assert [odd.gaming_for(n) for n in (1, 2, 90)] == [2, 5, 240]
|
||||||
|
|
||||||
|
|
||||||
def test_many_small_rows_pay_what_one_row_pays(
|
def test_many_small_rows_pay_what_one_row_pays(
|
||||||
@ -86,7 +90,21 @@ def test_many_small_rows_pay_what_one_row_pays(
|
|||||||
one.write_text(ledger_file(tmp_path, [row(7, 7)]).read_text(encoding="utf-8"))
|
one.write_text(ledger_file(tmp_path, [row(7, 7)]).read_text(encoding="utf-8"))
|
||||||
units = credit_units(item, many, key_file, DAY)
|
units = credit_units(item, many, key_file, DAY)
|
||||||
assert units == credit_units(item, one, key_file, DAY) == 7
|
assert units == credit_units(item, one, key_file, DAY) == 7
|
||||||
assert item.gaming_for(units or 0) == 10
|
assert item.gaming_for(units or 0) == 14
|
||||||
|
|
||||||
|
|
||||||
|
def test_carry_rows_pay_like_own_rows(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, key_file: Path
|
||||||
|
) -> None:
|
||||||
|
"""focus_project 0.6.0 banks overtime as ``<project>-<YYYYMMDD>-carry`` rows."""
|
||||||
|
item = focus(monkeypatch)
|
||||||
|
carry = row(30, 0, carry=True)
|
||||||
|
carry["entry_id"] = f"hr-{DAY:%Y%m%d}-carry"
|
||||||
|
top_up = row(5, 0, carry=True)
|
||||||
|
top_up["entry_id"] = f"hr-{DAY:%Y%m%d}-carry-m35"
|
||||||
|
rows = [signed(carry), signed(top_up), row(85, 85)]
|
||||||
|
ledger = ledger_file(tmp_path, rows)
|
||||||
|
assert credit_units(item, ledger, key_file, DAY) == 120
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_prices_base_terms_and_ceiling(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_resolve_prices_base_terms_and_ceiling(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
@ -95,26 +113,25 @@ def test_resolve_prices_base_terms_and_ceiling(monkeypatch: pytest.MonkeyPatch)
|
|||||||
assert nothing.base.gaming_minutes == 300 - 180
|
assert nothing.base.gaming_minutes == 300 - 180
|
||||||
assert nothing.base.shutdown_minutes == 18 * 60
|
assert nothing.base.shutdown_minutes == 18 * 60
|
||||||
half = resolve({"workout": 0, "focus": 60}, DAY, leisure=False)
|
half = resolve({"workout": 0, "focus": 60}, DAY, leisure=False)
|
||||||
assert half.gaming_minutes == 120 + 90
|
assert half.gaming_minutes == 120 + 120
|
||||||
assert half.shutdown_minutes == 18 * 60 + 90
|
assert half.shutdown_minutes == 18 * 60 + 90
|
||||||
full = resolve({"workout": 1, "focus": 120}, DAY, leisure=False)
|
full = resolve({"workout": 1, "focus": 120}, DAY, leisure=False)
|
||||||
assert full.shutdown_minutes == 23 * 60
|
assert full.shutdown_minutes == 23 * 60
|
||||||
|
assert full.gaming_minutes == 300 - 180 + 240 + 120 == 8 * 60
|
||||||
|
|
||||||
|
|
||||||
def test_a_full_project_day_pays_what_the_replaced_gates_paid(
|
def test_a_full_project_day_pays_what_the_replaced_gates_paid(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Shutdown matches the old full day; gaming loses LeetCode's free hour."""
|
"""A full project day equals the old full day, LeetCode's free hour too."""
|
||||||
activate(monkeypatch, project())
|
activate(monkeypatch, project())
|
||||||
done = {"workout": 1, "leetcode": 1, "reading": 1, "tutor": 60, "focus": 120}
|
done = {"workout": 1, "leetcode": 1, "reading": 1, "tutor": 60, "focus": 120}
|
||||||
old = resolve(done, DAY, TUTOR_EARNERS, leisure=False)
|
old = resolve(done, DAY, TUTOR_EARNERS, leisure=False)
|
||||||
new = resolve(done, DAY, leisure=False)
|
new = resolve(done, DAY, leisure=False)
|
||||||
assert new.shutdown_minutes == old.shutdown_minutes
|
assert new.shutdown_minutes == old.shutdown_minutes
|
||||||
# kuhy 2026-11-02: LeetCode's bonus hour was free on top of the base; the
|
# kuhy 2026-11-02 (0.21.0): the day cuts 180 but pays 240, so a full day
|
||||||
# project day cuts it and pays it back, so the full day nets 60 less.
|
# is the old full day (8 h); only an idle day is worse.
|
||||||
assert new.gaming_minutes == old.gaming_minutes - 60
|
assert new.gaming_minutes == old.gaming_minutes == 8 * 60
|
||||||
unsolved = resolve({**done, "leetcode": 0}, DAY, TUTOR_EARNERS, leisure=False)
|
|
||||||
assert new.gaming_minutes == unsolved.gaming_minutes
|
|
||||||
|
|
||||||
|
|
||||||
def test_an_idle_project_day_also_cuts_the_free_leetcode_hour(
|
def test_an_idle_project_day_also_cuts_the_free_leetcode_hour(
|
||||||
@ -166,4 +183,5 @@ def test_the_path_unit_watches_the_focus_ledger() -> None:
|
|||||||
|
|
||||||
def test_focus_pays_only_replaced_gates_that_exist() -> None:
|
def test_focus_pays_only_replaced_gates_that_exist() -> None:
|
||||||
item = focus_earner(project(), (earned_time.LEETCODE,), DAY)
|
item = focus_earner(project(), (earned_time.LEETCODE,), DAY)
|
||||||
assert (item.gaming_minutes, item.shutdown_minutes) == (60, 60)
|
assert (item.gaming_minutes, item.shutdown_minutes) == (120, 60)
|
||||||
|
assert item.cut_gaming_minutes == 60
|
||||||
|
|||||||
88
earned_time/earned_time/tests/test_focus_backing.py
Normal file
88
earned_time/earned_time/tests/test_focus_backing.py
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""Regression: a timer-only focus day after the anti-cheat cutover pays 0.
|
||||||
|
|
||||||
|
Runs the REAL ``focus_project`` (the sibling package of this monorepo):
|
||||||
|
timer rows -> harvester -> signed ledger -> ``credit_units``. earned_time
|
||||||
|
itself does not change; this pins that the gate reaches pay.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime
|
||||||
|
import importlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from earned_time import credit_units, earners_for
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from types import ModuleType
|
||||||
|
|
||||||
|
DAY = date(2026, 11, 9) # after focus_project.BACKING_FROM
|
||||||
|
_SIBLING = Path(__file__).resolve().parents[3] / "focus_project"
|
||||||
|
|
||||||
|
|
||||||
|
def at(hour: int, minute: int) -> float:
|
||||||
|
return datetime(2026, 11, 9, hour, minute).astimezone().timestamp()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def real_fp(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, key_file: Path
|
||||||
|
) -> ModuleType:
|
||||||
|
"""The real focus_project on redirected state (HOME, data dir, key)."""
|
||||||
|
monkeypatch.setenv("HOME", str(tmp_path / "home"))
|
||||||
|
monkeypatch.setenv("FOCUS_PROJECT_DATA", str(tmp_path / "data"))
|
||||||
|
monkeypatch.setenv("FOCUS_PROJECT_KEY", str(key_file))
|
||||||
|
monkeypatch.setenv("FOCUS_PROJECT_LEETCODE_LEDGER", str(tmp_path / "lc.json"))
|
||||||
|
monkeypatch.delitem(sys.modules, "focus_project") # the conftest's fake
|
||||||
|
monkeypatch.syspath_prepend(str(_SIBLING))
|
||||||
|
fp = importlib.import_module("focus_project")
|
||||||
|
fp.create_project(
|
||||||
|
"hr",
|
||||||
|
name="HR",
|
||||||
|
start=DAY,
|
||||||
|
end=DAY,
|
||||||
|
replaces=["leetcode"],
|
||||||
|
daily_minutes=120,
|
||||||
|
unlock_minutes=30,
|
||||||
|
)
|
||||||
|
return fp
|
||||||
|
|
||||||
|
|
||||||
|
def pay(fp: ModuleType, tmp_path: Path, key_file: Path, *rows: object) -> int | None:
|
||||||
|
log = tmp_path / "data" / "activity.jsonl"
|
||||||
|
log.write_text("".join(json.dumps(r) + "\n" for r in rows), encoding="utf-8")
|
||||||
|
harvest = importlib.import_module("focus_project.harvest")
|
||||||
|
assert harvest.harvest(now=at(23, 0)) == 0
|
||||||
|
(item,) = (e for e in earners_for(DAY) if e.name == "focus")
|
||||||
|
return credit_units(item, tmp_path / "data" / "ledger.json", key_file, DAY)
|
||||||
|
|
||||||
|
|
||||||
|
def row(source: str, kind: str, ts: float) -> dict[str, object]:
|
||||||
|
return {"ts": ts, "source": source, "kind": kind, "project": "hr"}
|
||||||
|
|
||||||
|
|
||||||
|
TIMER = (
|
||||||
|
row("timer", "start", at(9, 0)),
|
||||||
|
row("timer", "checkin", at(9, 5)),
|
||||||
|
row("timer", "stop", at(9, 10)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_timer_only_day_pays_zero(
|
||||||
|
real_fp: ModuleType, tmp_path: Path, key_file: Path
|
||||||
|
) -> None:
|
||||||
|
assert date(2026, 11, 8) == real_fp.BACKING_FROM
|
||||||
|
assert pay(real_fp, tmp_path, key_file, *TIMER) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_backed_timer_day_still_pays(
|
||||||
|
real_fp: ModuleType, tmp_path: Path, key_file: Path
|
||||||
|
) -> None:
|
||||||
|
beat = row("tutor", "heartbeat", at(9, 5))
|
||||||
|
assert pay(real_fp, tmp_path, key_file, *TIMER, beat) == 10
|
||||||
@ -70,8 +70,8 @@ def test_a_target_edit_never_reprices_past_days(
|
|||||||
activate(monkeypatch, project(daily_on=edited, daily_minutes=60))
|
activate(monkeypatch, project(daily_on=edited, daily_minutes=60))
|
||||||
before = earners_for(DAY)[-1]
|
before = earners_for(DAY)[-1]
|
||||||
after = earners_for(DAY + timedelta(days=1))[-1]
|
after = earners_for(DAY + timedelta(days=1))[-1]
|
||||||
assert (before.max_units, before.gaming_for(60)) == (120, 90)
|
assert (before.max_units, before.gaming_for(60)) == (120, 120)
|
||||||
assert (after.max_units, after.gaming_for(60)) == (60, 180)
|
assert (after.max_units, after.gaming_for(60)) == (60, 240)
|
||||||
assert (before.shutdown_for(60, DAY), after.shutdown_for(30, DAY)) == (90, 90)
|
assert (before.shutdown_for(60, DAY), after.shutdown_for(30, DAY)) == (90, 90)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "earned-time"
|
name = "earned-time"
|
||||||
version = "0.20.0"
|
version = "0.21.0"
|
||||||
description = "Penalty-then-reward registry: what every gate app earns in gaming time and shutdown time"
|
description = "Penalty-then-reward registry: what every gate app earns in gaming time and shutdown time"
|
||||||
requires-python = ">=3.14"
|
requires-python = ">=3.14"
|
||||||
dependencies = []
|
dependencies = []
|
||||||
|
|||||||
143
feedback_inbox/.pre-commit-config.yaml
Normal file
143
feedback_inbox/.pre-commit-config.yaml
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
# Pre-commit Configuration for feedback_inbox
|
||||||
|
# Install: pre-commit install && pre-commit install --hook-type pre-push
|
||||||
|
# Run: pre-commit run --all-files
|
||||||
|
# Update: pre-commit autoupdate
|
||||||
|
|
||||||
|
# NOTE: this subproject lives inside the ~/src/utils monorepo, whose git root is
|
||||||
|
# one level up. pre-commit always runs hooks from the git root, so every path
|
||||||
|
# below is written relative to THAT, not to this directory, and the top-level
|
||||||
|
# `files` key keeps these hooks off the sibling subprojects. CI invokes this
|
||||||
|
# config exactly the same way:
|
||||||
|
# pre-commit run --config feedback_inbox/.pre-commit-config.yaml --all-files
|
||||||
|
files: ^feedback_inbox/
|
||||||
|
|
||||||
|
default_language_version:
|
||||||
|
python: python3
|
||||||
|
default_stages: [pre-commit]
|
||||||
|
fail_fast: false
|
||||||
|
|
||||||
|
repos:
|
||||||
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
|
rev: v4.6.0
|
||||||
|
hooks:
|
||||||
|
- id: trailing-whitespace
|
||||||
|
args: [--markdown-linebreak-ext=md]
|
||||||
|
- id: end-of-file-fixer
|
||||||
|
- id: check-yaml
|
||||||
|
- id: check-json
|
||||||
|
- id: check-toml
|
||||||
|
- id: check-added-large-files
|
||||||
|
args: [--maxkb=2000]
|
||||||
|
- id: check-merge-conflict
|
||||||
|
- id: detect-private-key
|
||||||
|
- id: debug-statements
|
||||||
|
- id: name-tests-test
|
||||||
|
args: [--pytest-test-first]
|
||||||
|
- id: check-ast
|
||||||
|
- id: mixed-line-ending
|
||||||
|
args: [--fix=lf]
|
||||||
|
- id: requirements-txt-fixer
|
||||||
|
|
||||||
|
- repo: local
|
||||||
|
hooks:
|
||||||
|
- id: file-length-cap
|
||||||
|
name: file length <= 250 lines
|
||||||
|
entry: bash scripts/check_file_length.sh
|
||||||
|
language: system
|
||||||
|
pass_filenames: true
|
||||||
|
- id: subproject-scope
|
||||||
|
name: subproject scope regex still matches
|
||||||
|
entry: scripts/check_subproject_scope.sh feedback_inbox
|
||||||
|
language: script
|
||||||
|
# always_run so the top-level `files` filter cannot skip the one hook
|
||||||
|
# whose job is to notice that filter has stopped matching anything.
|
||||||
|
always_run: true
|
||||||
|
pass_filenames: false
|
||||||
|
- id: no-noqa
|
||||||
|
name: Block noqa comments
|
||||||
|
entry: '(?i)#\s*(noqa|type:\s*ignore)'
|
||||||
|
language: pygrep
|
||||||
|
types: [python]
|
||||||
|
- id: no-ruff-noqa
|
||||||
|
name: Block ruff noqa file-level comments
|
||||||
|
entry: '(?i)#\s*ruff:\s*noqa'
|
||||||
|
language: pygrep
|
||||||
|
types: [python]
|
||||||
|
|
||||||
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
|
rev: v0.17.4
|
||||||
|
hooks:
|
||||||
|
- id: ruff
|
||||||
|
args: [--fix, --unsafe-fixes, --exit-non-zero-on-fix, --show-fixes]
|
||||||
|
types_or: [python, pyi]
|
||||||
|
- id: ruff-format
|
||||||
|
types_or: [python, pyi]
|
||||||
|
|
||||||
|
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||||
|
rev: v1.13.0
|
||||||
|
hooks:
|
||||||
|
- id: mypy
|
||||||
|
args:
|
||||||
|
- --ignore-missing-imports
|
||||||
|
- --no-error-summary
|
||||||
|
- --disable-error-code=no-untyped-def
|
||||||
|
- --disable-error-code=no-untyped-call
|
||||||
|
- --disable-error-code=var-annotated
|
||||||
|
- --disable-error-code=no-any-unimported
|
||||||
|
- --disable-error-code=type-arg
|
||||||
|
- --disable-error-code=no-any-return
|
||||||
|
- --disable-error-code=misc
|
||||||
|
- --disable-error-code=unused-ignore
|
||||||
|
- --disable-error-code=unreachable
|
||||||
|
- --disable-error-code=assignment
|
||||||
|
- --disable-error-code=no-redef
|
||||||
|
- --disable-error-code=attr-defined
|
||||||
|
- --disable-error-code=arg-type
|
||||||
|
- --disable-error-code=union-attr
|
||||||
|
- --disable-error-code=call-overload
|
||||||
|
- --disable-error-code=return-value
|
||||||
|
- --disable-error-code=redundant-cast
|
||||||
|
- --disable-error-code=empty-body
|
||||||
|
- --disable-error-code=list-item
|
||||||
|
|
||||||
|
- repo: https://github.com/pylint-dev/pylint
|
||||||
|
rev: v4.0.8
|
||||||
|
hooks:
|
||||||
|
- id: pylint
|
||||||
|
args:
|
||||||
|
- --rcfile=feedback_inbox/pyproject.toml
|
||||||
|
- --fail-under=10
|
||||||
|
- --jobs=4
|
||||||
|
additional_dependencies:
|
||||||
|
- pytest
|
||||||
|
|
||||||
|
- repo: https://github.com/PyCQA/bandit
|
||||||
|
rev: 1.9.4
|
||||||
|
hooks:
|
||||||
|
- id: bandit
|
||||||
|
args:
|
||||||
|
- -c
|
||||||
|
- feedback_inbox/pyproject.toml
|
||||||
|
- --severity-level=high
|
||||||
|
- --confidence-level=medium
|
||||||
|
- --skip=B113
|
||||||
|
additional_dependencies: ["bandit[toml]"]
|
||||||
|
exclude: ^(feedback_inbox/feedback_inbox/tests/|.*test.*\.py$)
|
||||||
|
|
||||||
|
- repo: local
|
||||||
|
hooks:
|
||||||
|
- id: pytest-coverage
|
||||||
|
name: pytest with coverage enforcement
|
||||||
|
entry: scripts/run_subproject_tests.sh feedback_inbox
|
||||||
|
language: script
|
||||||
|
types: [python]
|
||||||
|
pass_filenames: false
|
||||||
|
require_serial: true
|
||||||
|
stages: [pre-push]
|
||||||
|
|
||||||
|
- repo: https://github.com/codespell-project/codespell
|
||||||
|
rev: v2.3.0
|
||||||
|
hooks:
|
||||||
|
- id: codespell
|
||||||
|
args:
|
||||||
|
- --skip=*.json,*.lock,.git,__pycache__,.venv
|
||||||
70
feedback_inbox/README.md
Normal file
70
feedback_inbox/README.md
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
# feedback_inbox
|
||||||
|
|
||||||
|
Turns every game's dev-feedback bundle into one todo-app task, so feedback
|
||||||
|
kuhy saves with F12 is never forgotten.
|
||||||
|
|
||||||
|
A game saves `~/data/<repo>_binaries/feedback/<stamp>/` (`report.json`,
|
||||||
|
screenshots, logs) and appends a ready-to-run Claude prompt to
|
||||||
|
`feedback-<date>.md` beside it. This package imports each complete bundle once
|
||||||
|
as a task in the todo app for `kuhyx/<repo>`.
|
||||||
|
|
||||||
|
## Task shape
|
||||||
|
|
||||||
|
- Title: `MANUAL WORK REQUIRED: <repo> feedback: <first line of his note>` (note capped
|
||||||
|
at 120 characters). The prefix makes todoImplementer list it as a human part and never
|
||||||
|
run it. No note: `... feedback: <game> <combo> <HH:MM>`. A `report.json` that does
|
||||||
|
not parse still gets a task, tagged `[corrupt report.json]`; the note then
|
||||||
|
comes from the markdown entry.
|
||||||
|
- Body: a `Due: <next day 10:00>` line, then the bundle's markdown entry (the
|
||||||
|
prompt with absolute paths).
|
||||||
|
- The task is its own note (`add_task` with `new_note`). The server stores that as
|
||||||
|
`<repo> todos: <title>` plus a `## <title>` section, so right after adding, the inbox
|
||||||
|
rewrites it with `update_task` to the exact title and a section-free body (the entry's
|
||||||
|
own `##` headings become `###`). todoImplementer splits notes at `## ` headings, and a
|
||||||
|
title that does not start with `MANUAL WORK REQUIRED: ` would make a runnable part.
|
||||||
|
The marker records `format: 2` once the rewrite worked; a failed rewrite is retried on
|
||||||
|
the next sweep (never a second task), and markers without it are repaired the same way.
|
||||||
|
- Closing the task cancels the feedback. "Act on feedback" in a Claude session
|
||||||
|
means working every open `<repo> feedback:` task.
|
||||||
|
|
||||||
|
The todo app has no due-date or reminder field yet, so the due time is only
|
||||||
|
text in the body.
|
||||||
|
|
||||||
|
## How it runs
|
||||||
|
|
||||||
|
| Unit | Does |
|
||||||
|
|---|---|
|
||||||
|
| `feedback-inbox.path` | `PathChanged` on every existing `*_binaries/feedback` folder; runs `feedback-inbox run` |
|
||||||
|
| `feedback-inbox-sync.path` | `PathChanged` on `~/data` and every `*_binaries` folder; runs `sync` then `run` |
|
||||||
|
| `feedback-inbox-sync.timer` | every 5 min, the same sweep (events during a run are lost by `.path` units) |
|
||||||
|
|
||||||
|
`.path` units take no globs: `feedback-inbox sync` writes the folder list into
|
||||||
|
the drop-ins `feedback-inbox{,-sync}.path.d/dirs.conf` and reloads systemd only
|
||||||
|
when the set of games changed.
|
||||||
|
|
||||||
|
A bundle is complete when `report.json` is at least 2 s old and its markdown
|
||||||
|
entry exists (matched by the stamp folder in the entry's paths). Incomplete
|
||||||
|
bundles are retried; `run` waits up to 15 s for them.
|
||||||
|
|
||||||
|
The import marker is `.inbox-imported.json` inside the bundle (task id, title,
|
||||||
|
due). It is written only after the todo server accepted the task, so a server
|
||||||
|
outage leaves no marker, exits non-zero, and the next sweep retries.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/run_subproject_tests.sh feedback_inbox # from the monorepo root
|
||||||
|
feedback_inbox/install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
`install.sh` builds a stdlib-only venv, links `feedback-inbox` into
|
||||||
|
`~/.local/bin`, mints a todo-server token into `~/.config/feedback-inbox/token`
|
||||||
|
(0600; revoke by deleting its line in `~/.config/todo-mcp/tokens`) and enables
|
||||||
|
the units. Enabling sweeps once, so existing bundles are imported then.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
`pytest` from this directory (100 % branch coverage enforced). Tests use a temp
|
||||||
|
`HOME`, a fake todo client, and a local HTTP server for the MCP client; the
|
||||||
|
autouse `conftest.py` redirects the one path resolver and points the todo URL
|
||||||
|
at a dead port.
|
||||||
2
feedback_inbox/feedback_inbox/__init__.py
Normal file
2
feedback_inbox/feedback_inbox/__init__.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""Turns every game dev-feedback bundle into one todo-app task."""
|
||||||
15
feedback_inbox/feedback_inbox/__main__.py
Normal file
15
feedback_inbox/feedback_inbox/__main__.py
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""``python -m feedback_inbox``."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from feedback_inbox._cli import main
|
||||||
|
|
||||||
|
|
||||||
|
def run() -> int:
|
||||||
|
"""Run the CLI on ``sys.argv``."""
|
||||||
|
return main()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(run())
|
||||||
57
feedback_inbox/feedback_inbox/_bundle.py
Normal file
57
feedback_inbox/feedback_inbox/_bundle.py
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""Finding games' feedback folders and bundles, and the stamp arithmetic."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import re
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
BINARIES_SUFFIX = "_binaries"
|
||||||
|
STAMP_FORMAT = "%Y-%m-%dT%H-%M-%S"
|
||||||
|
_STAMP = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}$")
|
||||||
|
_REMINDER_HOUR = 10
|
||||||
|
|
||||||
|
|
||||||
|
def binaries_dirs(data_root: Path) -> list[Path]:
|
||||||
|
"""Every ``<game>_binaries`` directory directly under ``data_root``."""
|
||||||
|
if not data_root.is_dir():
|
||||||
|
return []
|
||||||
|
return sorted(
|
||||||
|
p
|
||||||
|
for p in data_root.iterdir()
|
||||||
|
if p.name.endswith(BINARIES_SUFFIX) and p.is_dir()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def feedback_dirs(data_root: Path) -> list[Path]:
|
||||||
|
"""Every existing ``<game>_binaries/feedback`` directory."""
|
||||||
|
return [
|
||||||
|
d / "feedback" for d in binaries_dirs(data_root) if (d / "feedback").is_dir()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def repo_name(feedback_dir: Path) -> str:
|
||||||
|
"""The game's repo name: ``cleanup-plate_binaries`` -> ``cleanup-plate``."""
|
||||||
|
return feedback_dir.parent.name.removesuffix(BINARIES_SUFFIX)
|
||||||
|
|
||||||
|
|
||||||
|
def bundles_in(feedback_dir: Path) -> list[Path]:
|
||||||
|
"""Bundle folders (named by a millisecond stamp), oldest first."""
|
||||||
|
return sorted(
|
||||||
|
p for p in feedback_dir.iterdir() if p.is_dir() and _STAMP.match(p.name)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_stamp(stamp: str) -> datetime:
|
||||||
|
"""The local time a bundle was saved, from its folder name."""
|
||||||
|
return datetime.strptime(stamp[:19], STAMP_FORMAT)
|
||||||
|
|
||||||
|
|
||||||
|
def due_at(stamp: str) -> datetime:
|
||||||
|
"""The reminder time: the day after the bundle, at 10:00 local."""
|
||||||
|
day = parse_stamp(stamp) + timedelta(days=1)
|
||||||
|
return day.replace(hour=_REMINDER_HOUR, minute=0, second=0, microsecond=0)
|
||||||
80
feedback_inbox/feedback_inbox/_cli.py
Normal file
80
feedback_inbox/feedback_inbox/_cli.py
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""``feedback-inbox run | sync``: what the systemd units execute."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import fcntl
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from feedback_inbox._inbox import Importer, Outcome, Waiter, run_settled
|
||||||
|
from feedback_inbox._paths import resolve_paths
|
||||||
|
from feedback_inbox._todo import McpTodoClient
|
||||||
|
from feedback_inbox._units import run_systemctl, sync_units
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Callable, Sequence
|
||||||
|
|
||||||
|
from feedback_inbox._todo import TodoClient
|
||||||
|
|
||||||
|
LOG = logging.getLogger("feedback_inbox")
|
||||||
|
OWNER = "kuhyx"
|
||||||
|
DEFAULT_WAIT = 15.0
|
||||||
|
|
||||||
|
|
||||||
|
def _parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(prog="feedback-inbox")
|
||||||
|
sub = parser.add_subparsers(dest="command", required=True)
|
||||||
|
run = sub.add_parser("run", help="import new bundles as todo tasks")
|
||||||
|
run.add_argument("--wait", type=float, default=DEFAULT_WAIT)
|
||||||
|
sub.add_parser("sync", help="re-arm the path units for the current games")
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def _run(wait: float, client: TodoClient | None) -> int:
|
||||||
|
paths = resolve_paths()
|
||||||
|
todo = client or McpTodoClient(paths.mcp_url, paths.token_file)
|
||||||
|
paths.lock_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
# Blocking lock: a second run waits, then finds the markers and skips.
|
||||||
|
with paths.lock_file.open("a", encoding="utf-8") as lock:
|
||||||
|
fcntl.flock(lock, fcntl.LOCK_EX)
|
||||||
|
results = run_settled(
|
||||||
|
paths.data_root,
|
||||||
|
Importer(todo, OWNER),
|
||||||
|
Waiter(wait, time.time, time.sleep),
|
||||||
|
)
|
||||||
|
failed = 0
|
||||||
|
for result in results:
|
||||||
|
if result.outcome is Outcome.FAILED:
|
||||||
|
failed += 1
|
||||||
|
LOG.error("failed %s: %s", result.bundle, result.detail)
|
||||||
|
elif result.outcome is not Outcome.SKIPPED:
|
||||||
|
LOG.info("%s %s %s", result.outcome, result.bundle, result.task_id or "")
|
||||||
|
return 1 if failed else 0
|
||||||
|
|
||||||
|
|
||||||
|
def _sync(systemctl: Callable[[Sequence[str]], int]) -> int:
|
||||||
|
paths = resolve_paths()
|
||||||
|
try:
|
||||||
|
changed = sync_units(paths.data_root, paths.unit_dir, systemctl)
|
||||||
|
except RuntimeError:
|
||||||
|
LOG.exception("sync failed")
|
||||||
|
return 1
|
||||||
|
LOG.info("watch list %s", "rewritten" if changed else "unchanged")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main(
|
||||||
|
argv: Sequence[str] | None = None,
|
||||||
|
*,
|
||||||
|
client: TodoClient | None = None,
|
||||||
|
systemctl: Callable[[Sequence[str]], int] = run_systemctl,
|
||||||
|
) -> int:
|
||||||
|
"""Entry point; ``client`` and ``systemctl`` are test seams."""
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
|
||||||
|
args = _parser().parse_args(argv)
|
||||||
|
if args.command == "run":
|
||||||
|
return _run(args.wait, client)
|
||||||
|
return _sync(systemctl)
|
||||||
74
feedback_inbox/feedback_inbox/_entry.py
Normal file
74
feedback_inbox/feedback_inbox/_entry.py
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""A bundle's markdown entry and its report.json."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_SECTION = re.compile(r"(?m)^(?=## )")
|
||||||
|
_GAME = re.compile(r"Game `([^`]*)`, combo `([^`]*)`")
|
||||||
|
_BODY_END = re.compile(r"(?m)^- Screenshot")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Report:
|
||||||
|
"""The fields of report.json the inbox needs."""
|
||||||
|
|
||||||
|
note: str
|
||||||
|
game: str
|
||||||
|
combo: str
|
||||||
|
|
||||||
|
|
||||||
|
def find_entry(feedback_dir: Path, bundle: Path) -> str | None:
|
||||||
|
"""The ``feedback-<date>.md`` section whose paths point into ``bundle``.
|
||||||
|
|
||||||
|
Matched by the stamp folder in its paths, not by the heading time (which
|
||||||
|
drops the milliseconds), so a bundle never picks up a neighbour's entry.
|
||||||
|
"""
|
||||||
|
needle = f"/{bundle.name}/"
|
||||||
|
for md in sorted(feedback_dir.glob("feedback-*.md")):
|
||||||
|
try:
|
||||||
|
text = md.read_text(encoding="utf-8")
|
||||||
|
except OSError, UnicodeDecodeError:
|
||||||
|
continue
|
||||||
|
for section in _SECTION.split(text):
|
||||||
|
if needle in section:
|
||||||
|
return section.strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def read_report(path: Path) -> Report | None:
|
||||||
|
"""Parse report.json; ``None`` when it cannot be read as a JSON object."""
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except OSError, UnicodeDecodeError, json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return None
|
||||||
|
return Report(
|
||||||
|
note=_text(data.get("note")),
|
||||||
|
game=_text(data.get("game")),
|
||||||
|
combo=_text(data.get("combo")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _text(value: object) -> str:
|
||||||
|
return value if isinstance(value, str) else ""
|
||||||
|
|
||||||
|
|
||||||
|
def note_from_entry(entry: str) -> str:
|
||||||
|
"""His note, as written between the heading and the file list."""
|
||||||
|
_head, _, rest = entry.partition("\n")
|
||||||
|
return _BODY_END.split(rest, maxsplit=1)[0].strip()
|
||||||
|
|
||||||
|
|
||||||
|
def game_and_combo(entry: str) -> tuple[str, str]:
|
||||||
|
"""Game and combo from the entry's ``Game ..., combo ...`` line."""
|
||||||
|
found = _GAME.search(entry)
|
||||||
|
return (found.group(1), found.group(2)) if found else ("", "")
|
||||||
235
feedback_inbox/feedback_inbox/_inbox.py
Normal file
235
feedback_inbox/feedback_inbox/_inbox.py
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""Turn complete feedback bundles into todo tasks, once each."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import StrEnum
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from feedback_inbox._bundle import (
|
||||||
|
bundles_in,
|
||||||
|
due_at,
|
||||||
|
feedback_dirs,
|
||||||
|
parse_stamp,
|
||||||
|
repo_name,
|
||||||
|
)
|
||||||
|
from feedback_inbox._entry import (
|
||||||
|
Report,
|
||||||
|
find_entry,
|
||||||
|
game_and_combo,
|
||||||
|
note_from_entry,
|
||||||
|
read_report,
|
||||||
|
)
|
||||||
|
from feedback_inbox._todo import TodoError
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Callable
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from feedback_inbox._todo import TodoClient
|
||||||
|
|
||||||
|
LOG = logging.getLogger("feedback_inbox")
|
||||||
|
MARKER = ".inbox-imported.json"
|
||||||
|
# Marker format 2: the stored note was rewritten to our exact title and body.
|
||||||
|
FORMAT = 2
|
||||||
|
SETTLE_SECONDS = 2.0
|
||||||
|
TITLE_CAP = 120
|
||||||
|
MANUAL_PREFIX = "MANUAL WORK REQUIRED: "
|
||||||
|
CORRUPT_TAG = " [corrupt report.json]"
|
||||||
|
CORRUPT_LINE = "Warning: report.json is corrupt; the note below comes from the entry."
|
||||||
|
|
||||||
|
|
||||||
|
class Outcome(StrEnum):
|
||||||
|
"""What happened to one bundle in a run."""
|
||||||
|
|
||||||
|
IMPORTED = "imported"
|
||||||
|
SKIPPED = "skipped"
|
||||||
|
PENDING = "pending"
|
||||||
|
FAILED = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Result:
|
||||||
|
"""One bundle's outcome, for the journal and the exit code."""
|
||||||
|
|
||||||
|
bundle: Path
|
||||||
|
outcome: Outcome
|
||||||
|
task_id: str | None = None
|
||||||
|
detail: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TaskDraft:
|
||||||
|
"""The task to create: title, body and due time."""
|
||||||
|
|
||||||
|
title: str
|
||||||
|
body: str
|
||||||
|
due: str
|
||||||
|
|
||||||
|
|
||||||
|
def _demote(entry: str) -> str:
|
||||||
|
"""Turn ``## `` headings into ``### ``: no section may start a todo part.
|
||||||
|
|
||||||
|
The todo app splits a note into runnable parts at every ``## `` heading,
|
||||||
|
so the entry's own headings must not look like sections.
|
||||||
|
"""
|
||||||
|
return re.sub(r"(?m)^##(?=\s)", "###", entry)
|
||||||
|
|
||||||
|
|
||||||
|
def _first_line(note: str, fallback: str) -> str:
|
||||||
|
first = next((x.strip() for x in note.splitlines() if x.strip()), "")
|
||||||
|
if len(first) > TITLE_CAP:
|
||||||
|
first = first[: TITLE_CAP - 1] + "…"
|
||||||
|
return first or fallback
|
||||||
|
|
||||||
|
|
||||||
|
def build_task(repo: str, stamp: str, entry: str, report: Report | None) -> TaskDraft:
|
||||||
|
"""Compose the title and body from the entry and (maybe corrupt) report."""
|
||||||
|
found_game, found_combo = game_and_combo(entry)
|
||||||
|
note = note_from_entry(entry) if report is None else report.note
|
||||||
|
game = (report.game if report else "") or found_game
|
||||||
|
combo = (report.combo if report else "") or found_combo
|
||||||
|
clock = parse_stamp(stamp).strftime("%H:%M")
|
||||||
|
fallback = " ".join(x for x in (game, combo, clock) if x)
|
||||||
|
suffix = CORRUPT_TAG if report is None else ""
|
||||||
|
title = f"{MANUAL_PREFIX}{repo} feedback: {_first_line(note, fallback)}{suffix}"
|
||||||
|
due = due_at(stamp)
|
||||||
|
lines = [f"Due: {due:%Y-%m-%d %H:%M}", ""]
|
||||||
|
if report is None:
|
||||||
|
lines += [CORRUPT_LINE, ""]
|
||||||
|
return TaskDraft(title, "\n".join([*lines, _demote(entry)]), due.isoformat())
|
||||||
|
|
||||||
|
|
||||||
|
def _write_marker(bundle: Path, payload: dict[str, object]) -> None:
|
||||||
|
tmp = bundle / f"{MARKER}.tmp"
|
||||||
|
tmp.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
||||||
|
tmp.replace(bundle / MARKER)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_marker(bundle: Path) -> dict[str, object] | None:
|
||||||
|
"""The bundle's marker, or None when it is not a usable marker."""
|
||||||
|
try:
|
||||||
|
data = json.loads((bundle / MARKER).read_text(encoding="utf-8"))
|
||||||
|
except OSError, UnicodeDecodeError, json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
ok = isinstance(data, dict) and isinstance(data.get("task_id"), str)
|
||||||
|
return data if ok and isinstance(data.get("title"), str) else None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Importer:
|
||||||
|
"""Who files the tasks (``client``) under which GitHub owner."""
|
||||||
|
|
||||||
|
client: TodoClient
|
||||||
|
owner: str
|
||||||
|
settle: float = SETTLE_SECONDS
|
||||||
|
|
||||||
|
def import_bundle(self, feedback_dir: Path, bundle: Path, now: float) -> Result:
|
||||||
|
"""Import one bundle if complete; repair the title of one already filed."""
|
||||||
|
if (bundle / MARKER).exists():
|
||||||
|
return self._repair(feedback_dir, bundle)
|
||||||
|
report_path = bundle / "report.json"
|
||||||
|
entry = find_entry(feedback_dir, bundle)
|
||||||
|
if entry is None or not report_path.is_file():
|
||||||
|
return Result(bundle, Outcome.PENDING, detail="report or entry missing")
|
||||||
|
if now - report_path.stat().st_mtime < self.settle:
|
||||||
|
return Result(bundle, Outcome.PENDING, detail="report.json settling")
|
||||||
|
repo = repo_name(feedback_dir)
|
||||||
|
draft = build_task(repo, bundle.name, entry, read_report(report_path))
|
||||||
|
return self._file(bundle, f"{self.owner}/{repo}", draft)
|
||||||
|
|
||||||
|
def _file(self, bundle: Path, repo: str, draft: TaskDraft) -> Result:
|
||||||
|
"""Create the task, write the marker, then fix the title."""
|
||||||
|
try:
|
||||||
|
ref = self.client.add_task(repo, draft.title, draft.body)
|
||||||
|
except TodoError as exc:
|
||||||
|
return Result(bundle, Outcome.FAILED, detail=str(exc))
|
||||||
|
# Marker first: once the task exists a retry must never add a second.
|
||||||
|
# A merged task belongs to another note, which is not ours to rewrite.
|
||||||
|
payload: dict[str, object] = {
|
||||||
|
"task_id": ref.task_id,
|
||||||
|
"title": draft.title,
|
||||||
|
"due": draft.due,
|
||||||
|
"merged": ref.merged,
|
||||||
|
}
|
||||||
|
if ref.merged:
|
||||||
|
payload["format"] = FORMAT
|
||||||
|
try:
|
||||||
|
_write_marker(bundle, payload)
|
||||||
|
except OSError as exc:
|
||||||
|
return Result(
|
||||||
|
bundle,
|
||||||
|
Outcome.FAILED,
|
||||||
|
ref.task_id,
|
||||||
|
f"task {ref.task_id} filed but marker not written: {exc}",
|
||||||
|
)
|
||||||
|
if ref.merged:
|
||||||
|
return Result(
|
||||||
|
bundle, Outcome.IMPORTED, ref.task_id, "merged into an existing note"
|
||||||
|
)
|
||||||
|
return self._finish(bundle, payload, draft)
|
||||||
|
|
||||||
|
def _repair(self, feedback_dir: Path, bundle: Path) -> Result:
|
||||||
|
"""Finish a filed task whose rewrite never completed (or predates it)."""
|
||||||
|
data = _read_marker(bundle)
|
||||||
|
if data is None or data.get("merged") or data.get("format") == FORMAT:
|
||||||
|
return Result(bundle, Outcome.SKIPPED)
|
||||||
|
entry = find_entry(feedback_dir, bundle)
|
||||||
|
if entry is None:
|
||||||
|
return Result(bundle, Outcome.PENDING, detail="entry missing for repair")
|
||||||
|
report = read_report(bundle / "report.json")
|
||||||
|
draft = build_task(repo_name(feedback_dir), bundle.name, entry, report)
|
||||||
|
return self._finish(bundle, data, draft)
|
||||||
|
|
||||||
|
def _finish(
|
||||||
|
self, bundle: Path, data: dict[str, object], draft: TaskDraft
|
||||||
|
) -> Result:
|
||||||
|
"""Rewrite the stored note to the exact title/body, then mark it done."""
|
||||||
|
task_id = str(data["task_id"])
|
||||||
|
try:
|
||||||
|
self.client.update_task(task_id, draft.title, draft.body)
|
||||||
|
except TodoError as exc:
|
||||||
|
return Result(bundle, Outcome.FAILED, task_id, f"update: {exc}")
|
||||||
|
marked = {**data, "title": draft.title, "due": draft.due, "format": FORMAT}
|
||||||
|
try:
|
||||||
|
_write_marker(bundle, marked)
|
||||||
|
except OSError as exc:
|
||||||
|
return Result(bundle, Outcome.FAILED, task_id, f"marker not written: {exc}")
|
||||||
|
return Result(bundle, Outcome.IMPORTED, task_id)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Waiter:
|
||||||
|
"""How long to wait for half-written bundles, and the clock to do it by."""
|
||||||
|
|
||||||
|
wait: float
|
||||||
|
clock: Callable[[], float]
|
||||||
|
sleep: Callable[[float], None]
|
||||||
|
|
||||||
|
|
||||||
|
def run_once(data_root: Path, importer: Importer, now: float) -> list[Result]:
|
||||||
|
"""Import every complete, not yet imported bundle of every game."""
|
||||||
|
return [
|
||||||
|
importer.import_bundle(folder, bundle, now)
|
||||||
|
for folder in feedback_dirs(data_root)
|
||||||
|
for bundle in bundles_in(folder)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def run_settled(data_root: Path, importer: Importer, waiter: Waiter) -> list[Result]:
|
||||||
|
"""Like :func:`run_once`, retrying up to ``wait`` s while bundles are pending.
|
||||||
|
|
||||||
|
A bundle is written in pieces; the watch fires on the first piece, so a
|
||||||
|
short wait lets the rest land instead of leaving it to the timer.
|
||||||
|
"""
|
||||||
|
deadline = waiter.clock() + waiter.wait
|
||||||
|
while True:
|
||||||
|
results = run_once(data_root, importer, waiter.clock())
|
||||||
|
pending = any(r.outcome is Outcome.PENDING for r in results)
|
||||||
|
if not pending or waiter.clock() >= deadline:
|
||||||
|
return results
|
||||||
|
waiter.sleep(1.0)
|
||||||
51
feedback_inbox/feedback_inbox/_paths.py
Normal file
51
feedback_inbox/feedback_inbox/_paths.py
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""Every on-disk and network location, resolved in one place.
|
||||||
|
|
||||||
|
Tests redirect :func:`resolve_paths` once; nothing else names a real path.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
MCP_URL = "http://127.0.0.1:8735/mcp"
|
||||||
|
# Port 9 (discard) refuses connections: a test that forgets to inject a client
|
||||||
|
# fails closed instead of reaching the live todo server.
|
||||||
|
DEAD_URL = "http://127.0.0.1:9/mcp"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Paths:
|
||||||
|
"""Where the inbox reads games, its token, its lock and its units."""
|
||||||
|
|
||||||
|
data_root: Path
|
||||||
|
token_file: Path
|
||||||
|
lock_file: Path
|
||||||
|
unit_dir: Path
|
||||||
|
mcp_url: str
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def under(cls, root: Path) -> Paths:
|
||||||
|
"""A throwaway layout below ``root`` that can never reach the live app."""
|
||||||
|
return cls(
|
||||||
|
data_root=root / "data",
|
||||||
|
token_file=root / "token",
|
||||||
|
lock_file=root / "state" / "lock",
|
||||||
|
unit_dir=root / "units",
|
||||||
|
mcp_url=DEAD_URL,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_paths(given: Paths | None = None) -> Paths:
|
||||||
|
"""Return ``given`` or the real layout under the user's home."""
|
||||||
|
if given is not None:
|
||||||
|
return given
|
||||||
|
home = Path.home()
|
||||||
|
return Paths(
|
||||||
|
data_root=home / "data",
|
||||||
|
token_file=home / ".config" / "feedback-inbox" / "token",
|
||||||
|
lock_file=home / ".local" / "share" / "feedback-inbox" / "lock",
|
||||||
|
unit_dir=home / ".config" / "systemd" / "user",
|
||||||
|
mcp_url=MCP_URL,
|
||||||
|
)
|
||||||
165
feedback_inbox/feedback_inbox/_todo.py
Normal file
165
feedback_inbox/feedback_inbox/_todo.py
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""The todo app's MCP server as a minimal ``add_task`` client."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from typing import TYPE_CHECKING, Protocol
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_PROTOCOL = "2025-03-26"
|
||||||
|
_TIMEOUT = 20
|
||||||
|
_REPLY = re.compile(r"^(added|merged into|appended to) (\S+)")
|
||||||
|
|
||||||
|
|
||||||
|
class TodoError(Exception):
|
||||||
|
"""The todo server could not take the task."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TaskRef:
|
||||||
|
"""The task a call created (or the note it was merged into)."""
|
||||||
|
|
||||||
|
task_id: str
|
||||||
|
merged: bool
|
||||||
|
|
||||||
|
|
||||||
|
class TodoClient(Protocol):
|
||||||
|
"""What the inbox needs from a todo app."""
|
||||||
|
|
||||||
|
def add_task(self, repo: str, title: str, body: str) -> TaskRef:
|
||||||
|
"""Create one task in its own note and return its id."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def update_task(self, task_id: str, title: str, body: str) -> None:
|
||||||
|
"""Replace the task's stored title and body with exactly these."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class McpTodoClient:
|
||||||
|
"""Streamable-HTTP MCP client: initialize, then one ``add_task`` call."""
|
||||||
|
|
||||||
|
def __init__(self, url: str, token_file: Path) -> None:
|
||||||
|
"""Remember where the server and the (lazily read) token are."""
|
||||||
|
self._url = url
|
||||||
|
self._token_file = token_file
|
||||||
|
|
||||||
|
def add_task(self, repo: str, title: str, body: str) -> TaskRef:
|
||||||
|
"""Create a task with its own note via ``add_task``.
|
||||||
|
|
||||||
|
The server stores a ``new_note`` title as ``<name> todos: <title>``;
|
||||||
|
:meth:`update_task` restores the exact title (and drops the
|
||||||
|
``## <title>`` section the server adds) afterwards.
|
||||||
|
"""
|
||||||
|
arguments = {"repo": repo, "title": title, "body": body, "new_note": True}
|
||||||
|
said = self._call("add_task", arguments)
|
||||||
|
found = _REPLY.match(said)
|
||||||
|
if not found:
|
||||||
|
msg = f"add_task refused: {said[:120]}"
|
||||||
|
raise TodoError(msg)
|
||||||
|
return TaskRef(task_id=found.group(2), merged=found.group(1) != "added")
|
||||||
|
|
||||||
|
def update_task(self, task_id: str, title: str, body: str) -> None:
|
||||||
|
"""Rewrite via ``update_task``: exact title, and body with no sections."""
|
||||||
|
said = self._call("update_task", {"id": task_id, "title": title, "body": body})
|
||||||
|
if not said.startswith("updated"):
|
||||||
|
msg = f"update_task refused: {said[:120]}"
|
||||||
|
raise TodoError(msg)
|
||||||
|
|
||||||
|
def _call(self, tool: str, arguments: dict[str, object]) -> str:
|
||||||
|
token = self._token()
|
||||||
|
session = self._initialize(token)
|
||||||
|
payload = {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 2,
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": {"name": tool, "arguments": arguments},
|
||||||
|
}
|
||||||
|
return _tool_text(self._post(token, payload, session)[1])
|
||||||
|
|
||||||
|
def _token(self) -> str:
|
||||||
|
try:
|
||||||
|
return self._token_file.read_text(encoding="utf-8").strip()
|
||||||
|
except OSError as exc:
|
||||||
|
msg = f"token file unreadable: {self._token_file}"
|
||||||
|
raise TodoError(msg) from exc
|
||||||
|
|
||||||
|
def _initialize(self, token: str) -> str:
|
||||||
|
init = {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 1,
|
||||||
|
"method": "initialize",
|
||||||
|
"params": {
|
||||||
|
"protocolVersion": _PROTOCOL,
|
||||||
|
"capabilities": {},
|
||||||
|
"clientInfo": {"name": "feedback-inbox", "version": "0.1.0"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
headers, _body = self._post(token, init, None)
|
||||||
|
session = headers.get("mcp-session-id")
|
||||||
|
if not session:
|
||||||
|
msg = "todo server sent no session id"
|
||||||
|
raise TodoError(msg)
|
||||||
|
self._post(
|
||||||
|
token,
|
||||||
|
{"jsonrpc": "2.0", "method": "notifications/initialized"},
|
||||||
|
session,
|
||||||
|
)
|
||||||
|
return session
|
||||||
|
|
||||||
|
def _post(
|
||||||
|
self,
|
||||||
|
token: str,
|
||||||
|
payload: dict[str, object],
|
||||||
|
session: str | None,
|
||||||
|
) -> tuple[dict[str, str], str]:
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json, text/event-stream",
|
||||||
|
}
|
||||||
|
if session:
|
||||||
|
headers["mcp-session-id"] = session
|
||||||
|
request = urllib.request.Request(
|
||||||
|
self._url,
|
||||||
|
data=json.dumps(payload).encode(),
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=_TIMEOUT) as resp:
|
||||||
|
return (
|
||||||
|
{k.lower(): v for k, v in resp.headers.items()},
|
||||||
|
resp.read().decode(),
|
||||||
|
)
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
exc.close()
|
||||||
|
msg = f"todo server answered HTTP {exc.code}"
|
||||||
|
raise TodoError(msg) from exc
|
||||||
|
except OSError as exc:
|
||||||
|
msg = f"todo server unreachable: {exc}"
|
||||||
|
raise TodoError(msg) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _tool_text(body: str) -> str:
|
||||||
|
"""The text a tool call answered; an MCP-level error becomes TodoError."""
|
||||||
|
text = body.strip()
|
||||||
|
if not text.startswith("{"):
|
||||||
|
lines = [x[5:].strip() for x in text.splitlines() if x.startswith("data:")]
|
||||||
|
text = next((x for x in lines if x.startswith("{")), "")
|
||||||
|
try:
|
||||||
|
result = json.loads(text)["result"]
|
||||||
|
said = str(result["content"][0]["text"])
|
||||||
|
failed = bool(result.get("isError"))
|
||||||
|
except (ValueError, KeyError, IndexError, TypeError) as exc:
|
||||||
|
msg = f"unparsable MCP reply: {body[:80]!r}"
|
||||||
|
raise TodoError(msg) from exc
|
||||||
|
if failed:
|
||||||
|
msg = f"tool refused: {said[:120]}"
|
||||||
|
raise TodoError(msg)
|
||||||
|
return said
|
||||||
67
feedback_inbox/feedback_inbox/_units.py
Normal file
67
feedback_inbox/feedback_inbox/_units.py
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""Keep the path units' watch lists equal to the games that exist.
|
||||||
|
|
||||||
|
systemd ``.path`` units take no globs, so each ``*_binaries`` folder is listed
|
||||||
|
in a generated drop-in. The set changes only when a game is added or removed;
|
||||||
|
the drop-in is rewritten, and systemd reloaded, only then (a reload per tick
|
||||||
|
would be the daemon-reload loop the fleet rules forbid).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from feedback_inbox._bundle import binaries_dirs, feedback_dirs
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Callable, Sequence
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
WATCH_PATH = "feedback-inbox.path"
|
||||||
|
SYNC_PATH = "feedback-inbox-sync.path"
|
||||||
|
SYSTEMCTL = "/usr/bin/systemctl"
|
||||||
|
_HEADER = "# Generated by `feedback-inbox sync`; do not edit.\n[Path]\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _dropin(unit_dir: Path, unit: str) -> Path:
|
||||||
|
return unit_dir / f"{unit}.d" / "dirs.conf"
|
||||||
|
|
||||||
|
|
||||||
|
def _render(folders: list[Path]) -> str:
|
||||||
|
return _HEADER + "".join(f"PathChanged={d}\n" for d in folders)
|
||||||
|
|
||||||
|
|
||||||
|
def desired(data_root: Path, unit_dir: Path) -> dict[Path, str]:
|
||||||
|
"""The drop-ins that should exist: feedback folders, and game folders."""
|
||||||
|
return {
|
||||||
|
_dropin(unit_dir, WATCH_PATH): _render(feedback_dirs(data_root)),
|
||||||
|
_dropin(unit_dir, SYNC_PATH): _render(binaries_dirs(data_root)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def run_systemctl(args: Sequence[str]) -> int:
|
||||||
|
"""Run ``systemctl --user <args>`` and return its exit code."""
|
||||||
|
return subprocess.run([SYSTEMCTL, "--user", *args], check=False).returncode
|
||||||
|
|
||||||
|
|
||||||
|
def sync_units(
|
||||||
|
data_root: Path,
|
||||||
|
unit_dir: Path,
|
||||||
|
systemctl: Callable[[Sequence[str]], int],
|
||||||
|
) -> bool:
|
||||||
|
"""Rewrite drifted drop-ins, then reload and re-arm once; True if changed."""
|
||||||
|
changed = False
|
||||||
|
for path, text in desired(data_root, unit_dir).items():
|
||||||
|
if path.is_file() and path.read_text(encoding="utf-8") == text:
|
||||||
|
continue
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(text, encoding="utf-8")
|
||||||
|
changed = True
|
||||||
|
if not changed:
|
||||||
|
return False
|
||||||
|
for args in (["daemon-reload"], ["restart", WATCH_PATH, SYNC_PATH]):
|
||||||
|
if systemctl(args):
|
||||||
|
msg = f"systemctl --user {' '.join(args)} failed"
|
||||||
|
raise RuntimeError(msg)
|
||||||
|
return True
|
||||||
2
feedback_inbox/feedback_inbox/tests/__init__.py
Normal file
2
feedback_inbox/feedback_inbox/tests/__init__.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""Tests for feedback_inbox."""
|
||||||
31
feedback_inbox/feedback_inbox/tests/conftest.py
Normal file
31
feedback_inbox/feedback_inbox/tests/conftest.py
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""Redirect the one path resolver, so no test can touch real user state.
|
||||||
|
|
||||||
|
Every writer resolves its paths through ``feedback_inbox._paths.resolve_paths``;
|
||||||
|
redirecting that one function moves all of them (kuhy's real ``~/data``,
|
||||||
|
token file, lock and systemd unit dir included).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from feedback_inbox._paths import Paths
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def paths(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Paths:
|
||||||
|
"""Point every default path at ``tmp_path`` before any test runs."""
|
||||||
|
redirected = Paths.under(tmp_path / "home")
|
||||||
|
|
||||||
|
def _resolve(given: Paths | None = None) -> Paths:
|
||||||
|
return given if given is not None else redirected
|
||||||
|
|
||||||
|
monkeypatch.setattr("feedback_inbox._cli.resolve_paths", _resolve)
|
||||||
|
redirected.data_root.mkdir(parents=True)
|
||||||
|
return redirected
|
||||||
42
feedback_inbox/feedback_inbox/tests/test_bundle.py
Normal file
42
feedback_inbox/feedback_inbox/tests/test_bundle.py
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""Discovery, stamps and due dates."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from feedback_inbox._bundle import bundles_in, due_at, feedback_dirs, repo_name
|
||||||
|
from feedback_inbox.tests.test_support import make_bundle
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from feedback_inbox._paths import Paths
|
||||||
|
|
||||||
|
|
||||||
|
def test_feedback_dirs_covers_every_game(paths: Paths) -> None:
|
||||||
|
make_bundle(paths.data_root, game="cleanup-plate")
|
||||||
|
make_bundle(paths.data_root, game="dread-grid")
|
||||||
|
(paths.data_root / "other" / "feedback").mkdir(parents=True)
|
||||||
|
(paths.data_root / "empty_binaries").mkdir()
|
||||||
|
(paths.data_root / "file_binaries").write_text("x", encoding="utf-8")
|
||||||
|
found = feedback_dirs(paths.data_root)
|
||||||
|
assert [repo_name(d) for d in found] == ["cleanup-plate", "dread-grid"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_feedback_dirs_without_data_root(paths: Paths) -> None:
|
||||||
|
assert feedback_dirs(paths.data_root / "missing") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_bundles_in_keeps_only_stamp_folders(paths: Paths) -> None:
|
||||||
|
first = make_bundle(paths.data_root, stamp="2026-11-11T13-53-19-124")
|
||||||
|
make_bundle(paths.data_root, stamp="2026-11-11T13-52-33-162")
|
||||||
|
feedback = first.parent
|
||||||
|
(feedback / "notes").mkdir()
|
||||||
|
(feedback / "2026-11-11T13-52-33-999").write_text("x", encoding="utf-8")
|
||||||
|
names = [b.name for b in bundles_in(feedback)]
|
||||||
|
assert names == ["2026-11-11T13-52-33-162", "2026-11-11T13-53-19-124"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_due_is_next_day_ten(paths: Paths) -> None:
|
||||||
|
assert due_at("2026-11-11T13-52-33-162") == datetime(2026, 11, 12, 10, 0)
|
||||||
|
assert due_at("2026-12-31T23-59-59-001") == datetime(2027, 1, 1, 10, 0)
|
||||||
103
feedback_inbox/feedback_inbox/tests/test_cli.py
Normal file
103
feedback_inbox/feedback_inbox/tests/test_cli.py
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""The command line: run, sync, exit codes, lock."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from feedback_inbox import __main__ as entry
|
||||||
|
from feedback_inbox._cli import main
|
||||||
|
from feedback_inbox._inbox import MARKER
|
||||||
|
from feedback_inbox._paths import Paths, resolve_paths
|
||||||
|
from feedback_inbox.tests.test_support import FakeTodo, make_bundle
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_imports_and_exits_zero(
|
||||||
|
paths: Paths, caplog: pytest.LogCaptureFixture
|
||||||
|
) -> None:
|
||||||
|
bundle = make_bundle(paths.data_root)
|
||||||
|
todo = FakeTodo()
|
||||||
|
with caplog.at_level(logging.INFO):
|
||||||
|
assert main(["run", "--wait", "0"], client=todo) == 0
|
||||||
|
assert (bundle / MARKER).exists()
|
||||||
|
assert len(todo.calls) == 1
|
||||||
|
assert "imported" in caplog.text
|
||||||
|
assert main(["run", "--wait", "0"], client=todo) == 0
|
||||||
|
assert len(todo.calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_fails_loudly_when_todo_is_down(
|
||||||
|
paths: Paths, caplog: pytest.LogCaptureFixture
|
||||||
|
) -> None:
|
||||||
|
bundle = make_bundle(paths.data_root)
|
||||||
|
assert main(["run", "--wait", "0"], client=FakeTodo(fail=True)) == 1
|
||||||
|
assert not (bundle / MARKER).exists()
|
||||||
|
assert "todo down" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_pending_is_not_an_error(paths: Paths) -> None:
|
||||||
|
make_bundle(paths.data_root, entry=False)
|
||||||
|
assert main(["run", "--wait", "0"], client=FakeTodo()) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_builds_the_real_client_from_paths(paths: Paths) -> None:
|
||||||
|
make_bundle(paths.data_root)
|
||||||
|
assert main(["run", "--wait", "0"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_with_nothing_to_do_needs_no_token(paths: Paths) -> None:
|
||||||
|
assert main(["run", "--wait", "0"]) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_takes_the_lock_file(paths: Paths) -> None:
|
||||||
|
make_bundle(paths.data_root)
|
||||||
|
assert main(["run", "--wait", "0"], client=FakeTodo()) == 0
|
||||||
|
assert paths.lock_file.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_command(paths: Paths) -> None:
|
||||||
|
make_bundle(paths.data_root)
|
||||||
|
calls: list[list[str]] = []
|
||||||
|
|
||||||
|
def fake(args: Sequence[str]) -> int:
|
||||||
|
calls.append(list(args))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
assert main(["sync"], systemctl=fake) == 0
|
||||||
|
assert calls[0] == ["daemon-reload"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_failure_exit_code(paths: Paths, caplog: pytest.LogCaptureFixture) -> None:
|
||||||
|
make_bundle(paths.data_root)
|
||||||
|
assert main(["sync"], systemctl=lambda _a: 1) == 1
|
||||||
|
assert "daemon-reload" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_usage_error() -> None:
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
main([])
|
||||||
|
|
||||||
|
|
||||||
|
def test_real_paths_are_under_home(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr("feedback_inbox._paths.Path.home", lambda: tmp_path)
|
||||||
|
real = resolve_paths()
|
||||||
|
assert real.data_root == tmp_path / "data"
|
||||||
|
assert real.token_file == tmp_path / ".config" / "feedback-inbox" / "token"
|
||||||
|
assert real.unit_dir == tmp_path / ".config" / "systemd" / "user"
|
||||||
|
assert real.mcp_url == "http://127.0.0.1:8735/mcp"
|
||||||
|
given = Paths.under(tmp_path)
|
||||||
|
assert resolve_paths(given) is given
|
||||||
|
|
||||||
|
|
||||||
|
def test_module_entry_point(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr("sys.argv", ["feedback-inbox", "run", "--wait", "0"])
|
||||||
|
assert entry.run() == 0
|
||||||
68
feedback_inbox/feedback_inbox/tests/test_entry.py
Normal file
68
feedback_inbox/feedback_inbox/tests/test_entry.py
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""Finding a bundle's markdown entry and reading its report."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from feedback_inbox._entry import (
|
||||||
|
find_entry,
|
||||||
|
game_and_combo,
|
||||||
|
note_from_entry,
|
||||||
|
read_report,
|
||||||
|
)
|
||||||
|
from feedback_inbox.tests.test_support import make_bundle
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from feedback_inbox._paths import Paths
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_entry_picks_the_matching_section(paths: Paths) -> None:
|
||||||
|
first = make_bundle(paths.data_root, stamp="2026-11-11T13-52-33-162", note="alpha")
|
||||||
|
second = make_bundle(paths.data_root, stamp="2026-11-11T13-53-19-124", note="beta")
|
||||||
|
entry = find_entry(first.parent, second)
|
||||||
|
assert entry is not None
|
||||||
|
assert "beta" in entry
|
||||||
|
assert "alpha" not in entry
|
||||||
|
assert find_entry(first.parent, first) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_entry_missing(paths: Paths) -> None:
|
||||||
|
bundle = make_bundle(paths.data_root, entry=False)
|
||||||
|
assert find_entry(bundle.parent, bundle) is None
|
||||||
|
(bundle.parent / "feedback-2026-11-11.md").write_bytes(b"\xff\xfe")
|
||||||
|
assert find_entry(bundle.parent, bundle) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_report(paths: Paths) -> None:
|
||||||
|
bundle = make_bundle(paths.data_root, note=" hello \nworld")
|
||||||
|
report = read_report(bundle / "report.json")
|
||||||
|
assert report is not None
|
||||||
|
assert report.note == " hello \nworld"
|
||||||
|
assert (report.game, report.combo) == ("museum", "loop=P2")
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_report_corrupt_forms(tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / "report.json"
|
||||||
|
for bad in ("{broken", "[1]", '{"note": 3, "game": null}'):
|
||||||
|
path.write_text(bad, encoding="utf-8")
|
||||||
|
report = read_report(path)
|
||||||
|
if bad == '{"note": 3, "game": null}':
|
||||||
|
assert report is not None
|
||||||
|
assert (report.note, report.game, report.combo) == ("", "", "")
|
||||||
|
else:
|
||||||
|
assert report is None
|
||||||
|
path.write_bytes(b"\xff\xfe")
|
||||||
|
assert read_report(path) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_entry_fallbacks(paths: Paths) -> None:
|
||||||
|
bundle = make_bundle(paths.data_root, note="line one\nline two")
|
||||||
|
entry = find_entry(bundle.parent, bundle)
|
||||||
|
assert entry is not None
|
||||||
|
assert note_from_entry(entry) == "line one\nline two"
|
||||||
|
assert game_and_combo(entry) == ("museum", "loop=P2,carry=d,id=c")
|
||||||
|
assert note_from_entry("## head only") == ""
|
||||||
|
assert game_and_combo("nothing here") == ("", "")
|
||||||
194
feedback_inbox/feedback_inbox/tests/test_inbox.py
Normal file
194
feedback_inbox/feedback_inbox/tests/test_inbox.py
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""Import rules: complete, incomplete, idempotent, titles, due date, failures."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from feedback_inbox._entry import Report
|
||||||
|
from feedback_inbox._inbox import (
|
||||||
|
MARKER,
|
||||||
|
Importer,
|
||||||
|
Outcome,
|
||||||
|
Waiter,
|
||||||
|
build_task,
|
||||||
|
run_once,
|
||||||
|
run_settled,
|
||||||
|
)
|
||||||
|
from feedback_inbox.tests.test_support import STAMP, FakeTodo, make_bundle
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from feedback_inbox._paths import Paths
|
||||||
|
|
||||||
|
NOW = 1_000_000.0
|
||||||
|
OWNER = "kuhyx"
|
||||||
|
MANUAL = "MANUAL WORK REQUIRED: "
|
||||||
|
|
||||||
|
|
||||||
|
def _run(paths: Paths, todo: FakeTodo) -> list[Outcome]:
|
||||||
|
return [r.outcome for r in run_once(paths.data_root, Importer(todo, OWNER), NOW)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_complete_bundle_becomes_one_task(paths: Paths) -> None:
|
||||||
|
bundle = make_bundle(paths.data_root)
|
||||||
|
todo = FakeTodo()
|
||||||
|
assert _run(paths, todo) == [Outcome.IMPORTED]
|
||||||
|
((repo, title, body),) = todo.calls
|
||||||
|
assert repo == "kuhyx/cleanup-plate"
|
||||||
|
assert (
|
||||||
|
title
|
||||||
|
== MANUAL + "cleanup-plate feedback: I would like to see the painting IN FULL"
|
||||||
|
)
|
||||||
|
assert body.startswith("Due: 2026-11-12 10:00\n\n### 2026-11-11T13:52:33")
|
||||||
|
assert f"{bundle}/report.json" in body
|
||||||
|
marker = json.loads((bundle / MARKER).read_text(encoding="utf-8"))
|
||||||
|
assert marker["task_id"] == "id0001" + "0" * 28
|
||||||
|
assert marker["due"] == "2026-11-12T10:00:00"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rerun_never_duplicates(paths: Paths) -> None:
|
||||||
|
make_bundle(paths.data_root)
|
||||||
|
todo = FakeTodo()
|
||||||
|
assert _run(paths, todo) == [Outcome.IMPORTED]
|
||||||
|
assert _run(paths, todo) == [Outcome.SKIPPED]
|
||||||
|
assert len(todo.calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_incomplete_bundles_wait(paths: Paths) -> None:
|
||||||
|
make_bundle(paths.data_root, stamp="2026-11-11T10-00-00-001", report=None)
|
||||||
|
make_bundle(paths.data_root, stamp="2026-11-11T10-00-01-001", entry=False)
|
||||||
|
make_bundle(paths.data_root, stamp="2026-11-11T10-00-02-001", age=0.5)
|
||||||
|
todo = FakeTodo()
|
||||||
|
assert _run(paths, todo) == [Outcome.PENDING] * 3
|
||||||
|
assert todo.calls == []
|
||||||
|
later = run_once(paths.data_root, Importer(todo, OWNER), NOW + 10)
|
||||||
|
assert [r.outcome for r in later] == [
|
||||||
|
Outcome.PENDING,
|
||||||
|
Outcome.PENDING,
|
||||||
|
Outcome.IMPORTED,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_multiple_games(paths: Paths) -> None:
|
||||||
|
make_bundle(paths.data_root, game="cleanup-plate", note="a")
|
||||||
|
make_bundle(paths.data_root, game="dread-grid", note="b")
|
||||||
|
todo = FakeTodo()
|
||||||
|
assert _run(paths, todo) == [Outcome.IMPORTED] * 2
|
||||||
|
assert [(c[0], c[1]) for c in todo.calls] == [
|
||||||
|
("kuhyx/cleanup-plate", "MANUAL WORK REQUIRED: cleanup-plate feedback: a"),
|
||||||
|
("kuhyx/dread-grid", "MANUAL WORK REQUIRED: dread-grid feedback: b"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_corrupt_report_still_a_task(paths: Paths) -> None:
|
||||||
|
bundle = make_bundle(paths.data_root, report="{broken", note="keep me")
|
||||||
|
todo = FakeTodo()
|
||||||
|
assert _run(paths, todo) == [Outcome.IMPORTED]
|
||||||
|
((_, title, body),) = todo.calls
|
||||||
|
assert (
|
||||||
|
title
|
||||||
|
== "MANUAL WORK REQUIRED: cleanup-plate feedback: keep me [corrupt report.json]"
|
||||||
|
)
|
||||||
|
assert "report.json is corrupt" in body
|
||||||
|
assert (bundle / MARKER).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_corrupt_report_without_note(paths: Paths) -> None:
|
||||||
|
make_bundle(paths.data_root, report="[]", note="")
|
||||||
|
todo = FakeTodo()
|
||||||
|
assert _run(paths, todo) == [Outcome.IMPORTED]
|
||||||
|
title = todo.calls[0][1]
|
||||||
|
game_part = "museum loop=P2,carry=d,id=c 13:52"
|
||||||
|
assert title == f"{MANUAL}cleanup-plate feedback: {game_part} [corrupt report.json]"
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_note_title_names_game_combo_time(paths: Paths) -> None:
|
||||||
|
make_bundle(paths.data_root, note="")
|
||||||
|
todo = FakeTodo()
|
||||||
|
assert _run(paths, todo) == [Outcome.IMPORTED]
|
||||||
|
assert (
|
||||||
|
todo.calls[0][1]
|
||||||
|
== "MANUAL WORK REQUIRED: cleanup-plate feedback: museum loop=P2 13:52"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_title_uses_first_line_and_caps_length() -> None:
|
||||||
|
report = Report(note="\n " + "x" * 300 + "\nsecond", game="g", combo="c")
|
||||||
|
draft = build_task("repo", STAMP, "## e", report)
|
||||||
|
assert draft.title == "MANUAL WORK REQUIRED: repo feedback: " + "x" * 119 + "…"
|
||||||
|
bare = build_task("repo", STAMP, "## e", Report(note="", game="", combo=""))
|
||||||
|
assert bare.title == "MANUAL WORK REQUIRED: repo feedback: 13:52"
|
||||||
|
|
||||||
|
|
||||||
|
def test_todo_failure_leaves_no_marker(paths: Paths) -> None:
|
||||||
|
bundle = make_bundle(paths.data_root)
|
||||||
|
todo = FakeTodo(fail=True)
|
||||||
|
results = run_once(paths.data_root, Importer(todo, OWNER), NOW)
|
||||||
|
assert [r.outcome for r in results] == [Outcome.FAILED]
|
||||||
|
assert "todo down" in results[0].detail
|
||||||
|
assert not (bundle / MARKER).exists()
|
||||||
|
assert _run(paths, FakeTodo()) == [Outcome.IMPORTED]
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_is_flagged_but_marked(paths: Paths) -> None:
|
||||||
|
make_bundle(paths.data_root)
|
||||||
|
results = run_once(paths.data_root, Importer(FakeTodo(merged=True), OWNER), NOW)
|
||||||
|
assert results[0].outcome is Outcome.IMPORTED
|
||||||
|
assert "merged" in results[0].detail
|
||||||
|
|
||||||
|
|
||||||
|
def test_marker_write_failure_is_reported(paths: Paths) -> None:
|
||||||
|
bundle = make_bundle(paths.data_root)
|
||||||
|
(bundle / MARKER).mkdir()
|
||||||
|
results = run_once(paths.data_root, Importer(FakeTodo(), OWNER), NOW)
|
||||||
|
assert results[0].outcome is Outcome.SKIPPED
|
||||||
|
(bundle / MARKER).rmdir()
|
||||||
|
bundle.chmod(0o500)
|
||||||
|
try:
|
||||||
|
results = run_once(paths.data_root, Importer(FakeTodo(), OWNER), NOW)
|
||||||
|
finally:
|
||||||
|
bundle.chmod(0o700)
|
||||||
|
assert results[0].outcome is Outcome.FAILED
|
||||||
|
assert "marker" in results[0].detail
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_settled_waits_then_gives_up(paths: Paths, tmp_path: Path) -> None:
|
||||||
|
make_bundle(paths.data_root, age=0.0, now=NOW)
|
||||||
|
clock = [NOW]
|
||||||
|
slept: list[float] = []
|
||||||
|
|
||||||
|
def sleep(seconds: float) -> None:
|
||||||
|
slept.append(seconds)
|
||||||
|
clock[0] += seconds
|
||||||
|
|
||||||
|
todo = FakeTodo()
|
||||||
|
results = run_settled(
|
||||||
|
paths.data_root, Importer(todo, OWNER), Waiter(5.0, lambda: clock[0], sleep)
|
||||||
|
)
|
||||||
|
assert [r.outcome for r in results] == [Outcome.IMPORTED]
|
||||||
|
assert slept == [1.0, 1.0]
|
||||||
|
|
||||||
|
other = tmp_path / "other"
|
||||||
|
other.mkdir()
|
||||||
|
make_bundle(other, report=None)
|
||||||
|
slept.clear()
|
||||||
|
results = run_settled(
|
||||||
|
other, Importer(todo, OWNER), Waiter(3.0, lambda: clock[0], sleep)
|
||||||
|
)
|
||||||
|
assert [r.outcome for r in results] == [Outcome.PENDING]
|
||||||
|
assert slept == [1.0, 1.0, 1.0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_title_starts_with_the_manual_prefix(paths: Paths) -> None:
|
||||||
|
make_bundle(paths.data_root, stamp="2026-11-11T10-00-00-001", note="a")
|
||||||
|
make_bundle(paths.data_root, stamp="2026-11-11T10-00-01-001", note="")
|
||||||
|
make_bundle(paths.data_root, stamp="2026-11-11T10-00-02-001", report="{")
|
||||||
|
make_bundle(paths.data_root, game="dread-grid", note="b")
|
||||||
|
todo = FakeTodo()
|
||||||
|
assert _run(paths, todo) == [Outcome.IMPORTED] * 4
|
||||||
|
titles = [call[1] for call in todo.calls]
|
||||||
|
assert len(titles) == 4
|
||||||
|
assert all(t.startswith("MANUAL WORK REQUIRED: ") for t in titles)
|
||||||
145
feedback_inbox/feedback_inbox/tests/test_inbox_repair.py
Normal file
145
feedback_inbox/feedback_inbox/tests/test_inbox_repair.py
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""Exact titles, section-free bodies, and repair of half-finished tasks."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from feedback_inbox._entry import Report
|
||||||
|
from feedback_inbox._inbox import (
|
||||||
|
FORMAT,
|
||||||
|
MARKER,
|
||||||
|
Importer,
|
||||||
|
Outcome,
|
||||||
|
build_task,
|
||||||
|
run_once,
|
||||||
|
)
|
||||||
|
from feedback_inbox.tests.test_support import STAMP, FakeTodo, make_bundle
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from feedback_inbox._paths import Paths
|
||||||
|
|
||||||
|
NOW = 1_000_000.0
|
||||||
|
OWNER = "kuhyx"
|
||||||
|
MANUAL = "MANUAL WORK REQUIRED: "
|
||||||
|
|
||||||
|
|
||||||
|
def _run(paths: Paths, todo: FakeTodo) -> list[Outcome]:
|
||||||
|
return [r.outcome for r in run_once(paths.data_root, Importer(todo, OWNER), NOW)]
|
||||||
|
|
||||||
|
|
||||||
|
def _marker(bundle: Path) -> dict[str, object]:
|
||||||
|
data: dict[str, object] = json.loads((bundle / MARKER).read_text(encoding="utf-8"))
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _no_extra_sections(text: str) -> bool:
|
||||||
|
return not any(line.startswith("## ") for line in text.splitlines())
|
||||||
|
|
||||||
|
|
||||||
|
def test_stored_note_is_one_manual_part(paths: Paths) -> None:
|
||||||
|
bundle = make_bundle(paths.data_root)
|
||||||
|
todo = FakeTodo()
|
||||||
|
assert _run(paths, todo) == [Outcome.IMPORTED]
|
||||||
|
(stored,) = todo.stored.values()
|
||||||
|
assert stored.startswith(MANUAL)
|
||||||
|
assert _no_extra_sections(stored)
|
||||||
|
assert "### 2026-11-11T13:52:33" in stored
|
||||||
|
assert _marker(bundle)["format"] == FORMAT
|
||||||
|
|
||||||
|
|
||||||
|
def test_entry_headings_never_become_sections() -> None:
|
||||||
|
entry = "## a heading\ntext\n## another\n### kept"
|
||||||
|
draft = build_task("r", STAMP, entry, Report(note="n", game="", combo=""))
|
||||||
|
assert _no_extra_sections(draft.body)
|
||||||
|
assert "### a heading\ntext\n### another\n### kept" in draft.body
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_update_keeps_marker_and_retries_without_a_second_task(
|
||||||
|
paths: Paths,
|
||||||
|
) -> None:
|
||||||
|
bundle = make_bundle(paths.data_root)
|
||||||
|
todo = FakeTodo(fail_update=True)
|
||||||
|
results = run_once(paths.data_root, Importer(todo, OWNER), NOW)
|
||||||
|
assert [r.outcome for r in results] == [Outcome.FAILED]
|
||||||
|
assert "update refused" in results[0].detail
|
||||||
|
assert "format" not in _marker(bundle)
|
||||||
|
todo.fail_update = False
|
||||||
|
assert _run(paths, todo) == [Outcome.IMPORTED]
|
||||||
|
assert len(todo.calls) == 1
|
||||||
|
(stored,) = todo.stored.values()
|
||||||
|
assert stored.startswith(MANUAL)
|
||||||
|
assert _no_extra_sections(stored)
|
||||||
|
assert _run(paths, todo) == [Outcome.SKIPPED]
|
||||||
|
|
||||||
|
|
||||||
|
def test_old_marker_is_repaired_from_the_bundle(paths: Paths) -> None:
|
||||||
|
bundle = make_bundle(paths.data_root)
|
||||||
|
task_id = "abc" + "0" * 33
|
||||||
|
(bundle / MARKER).write_text(
|
||||||
|
json.dumps({"task_id": task_id, "title": "old", "title_fixed": True}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
todo = FakeTodo()
|
||||||
|
assert _run(paths, todo) == [Outcome.IMPORTED]
|
||||||
|
assert todo.calls == []
|
||||||
|
assert todo.stored[task_id].startswith(MANUAL)
|
||||||
|
assert _no_extra_sections(todo.stored[task_id])
|
||||||
|
assert _marker(bundle)["format"] == FORMAT
|
||||||
|
|
||||||
|
|
||||||
|
def test_old_marker_whose_entry_vanished_waits(paths: Paths) -> None:
|
||||||
|
bundle = make_bundle(paths.data_root, entry=False)
|
||||||
|
(bundle / MARKER).write_text(
|
||||||
|
json.dumps({"task_id": "t" * 36, "title": "old"}), encoding="utf-8"
|
||||||
|
)
|
||||||
|
todo = FakeTodo()
|
||||||
|
assert _run(paths, todo) == [Outcome.PENDING]
|
||||||
|
assert todo.updated == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_merged_task_is_not_rewritten(paths: Paths) -> None:
|
||||||
|
bundle = make_bundle(paths.data_root)
|
||||||
|
todo = FakeTodo(merged=True)
|
||||||
|
assert _run(paths, todo) == [Outcome.IMPORTED]
|
||||||
|
assert todo.updated == []
|
||||||
|
assert _marker(bundle)["format"] == FORMAT
|
||||||
|
assert _run(paths, todo) == [Outcome.SKIPPED]
|
||||||
|
|
||||||
|
|
||||||
|
def test_old_merged_marker_is_left_alone(paths: Paths) -> None:
|
||||||
|
bundle = make_bundle(paths.data_root)
|
||||||
|
(bundle / MARKER).write_text(
|
||||||
|
json.dumps({"task_id": "t" * 36, "title": "x", "merged": True}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
todo = FakeTodo()
|
||||||
|
assert _run(paths, todo) == [Outcome.SKIPPED]
|
||||||
|
assert todo.updated == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_unreadable_marker_is_left_alone(paths: Paths) -> None:
|
||||||
|
bundle = make_bundle(paths.data_root)
|
||||||
|
for junk in ("{broken", "[1]", '{"task_id": 5}'):
|
||||||
|
(bundle / MARKER).write_text(junk, encoding="utf-8")
|
||||||
|
todo = FakeTodo()
|
||||||
|
assert _run(paths, todo) == [Outcome.SKIPPED]
|
||||||
|
assert todo.calls == []
|
||||||
|
assert todo.updated == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_repair_reports_an_unwritable_marker(paths: Paths) -> None:
|
||||||
|
bundle = make_bundle(paths.data_root)
|
||||||
|
(bundle / MARKER).write_text(
|
||||||
|
json.dumps({"task_id": "t" * 36, "title": "T"}), encoding="utf-8"
|
||||||
|
)
|
||||||
|
bundle.chmod(0o500)
|
||||||
|
try:
|
||||||
|
results = run_once(paths.data_root, Importer(FakeTodo(), OWNER), NOW)
|
||||||
|
finally:
|
||||||
|
bundle.chmod(0o700)
|
||||||
|
assert results[0].outcome is Outcome.FAILED
|
||||||
|
assert "marker not written" in results[0].detail
|
||||||
95
feedback_inbox/feedback_inbox/tests/test_support.py
Normal file
95
feedback_inbox/feedback_inbox/tests/test_support.py
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""Builders for fake feedback bundles and a fake todo client."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from feedback_inbox._todo import TaskRef, TodoError
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
STAMP = "2026-11-11T13-52-33-162"
|
||||||
|
NOTE = "I would like to see the painting IN FULL\n\nsecond paragraph"
|
||||||
|
|
||||||
|
|
||||||
|
def entry_text(bundle: Path, note: str = NOTE, game: str = "museum") -> str:
|
||||||
|
"""The markdown entry a game appends for ``bundle``."""
|
||||||
|
head = note.splitlines()[0][:70] if note else ""
|
||||||
|
parts = [f"## 2026-11-11T13:52:33: {head}", ""]
|
||||||
|
if note:
|
||||||
|
parts += [note, ""]
|
||||||
|
parts += [
|
||||||
|
f"- Screenshot: `{bundle}/annotated.png`",
|
||||||
|
f"- Report: `{bundle}/report.json`",
|
||||||
|
f"- Game `{game}`, combo `loop=P2,carry=d,id=c`, build abc, session `s`",
|
||||||
|
"",
|
||||||
|
"Task: Read the annotated screenshot and fix it.",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def make_bundle(
|
||||||
|
data_root: Path,
|
||||||
|
*,
|
||||||
|
game: str = "cleanup-plate",
|
||||||
|
stamp: str = STAMP,
|
||||||
|
note: str = NOTE,
|
||||||
|
report: str | None = "ok",
|
||||||
|
entry: bool = True,
|
||||||
|
age: float = 60.0,
|
||||||
|
now: float = 1_000_000.0,
|
||||||
|
) -> Path:
|
||||||
|
"""Create ``<game>_binaries/feedback/<stamp>/`` and its md entry."""
|
||||||
|
feedback = data_root / f"{game}_binaries" / "feedback"
|
||||||
|
bundle = feedback / stamp
|
||||||
|
bundle.mkdir(parents=True, exist_ok=True)
|
||||||
|
if report is not None:
|
||||||
|
path = bundle / "report.json"
|
||||||
|
if report == "ok":
|
||||||
|
body = json.dumps({"note": note, "game": "museum", "combo": "loop=P2"})
|
||||||
|
else:
|
||||||
|
body = report
|
||||||
|
path.write_text(body, encoding="utf-8")
|
||||||
|
os.utime(path, (now - age, now - age))
|
||||||
|
if entry:
|
||||||
|
md = feedback / f"feedback-{stamp[:10]}.md"
|
||||||
|
with md.open("a", encoding="utf-8") as handle:
|
||||||
|
handle.write(entry_text(bundle, note))
|
||||||
|
return bundle
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FakeTodo:
|
||||||
|
"""A todo server stand-in that stores notes the way the real one does."""
|
||||||
|
|
||||||
|
calls: list[tuple[str, str, str]] = field(default_factory=list)
|
||||||
|
stored: dict[str, str] = field(default_factory=dict)
|
||||||
|
fail: bool = False
|
||||||
|
fail_update: bool = False
|
||||||
|
merged: bool = False
|
||||||
|
updated: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
def add_task(self, repo: str, title: str, body: str) -> TaskRef:
|
||||||
|
if self.fail:
|
||||||
|
msg = "todo down"
|
||||||
|
raise TodoError(msg)
|
||||||
|
self.calls.append((repo, title, body))
|
||||||
|
task_id = f"id{len(self.calls):04d}" + "0" * 28
|
||||||
|
# A new_note task: title "<name> todos: <title>", then a "## <title>"
|
||||||
|
# section holding the body.
|
||||||
|
name = repo.rsplit("/", maxsplit=1)[-1]
|
||||||
|
self.stored[task_id] = f"{name} todos: {title}\n\n## {title}\n{body}"
|
||||||
|
return TaskRef(task_id=task_id, merged=self.merged)
|
||||||
|
|
||||||
|
def update_task(self, task_id: str, title: str, body: str) -> None:
|
||||||
|
if self.fail_update:
|
||||||
|
msg = "update refused"
|
||||||
|
raise TodoError(msg)
|
||||||
|
self.updated.append(task_id)
|
||||||
|
self.stored[task_id] = f"{title}\n\n{body}"
|
||||||
200
feedback_inbox/feedback_inbox/tests/test_todo.py
Normal file
200
feedback_inbox/feedback_inbox/tests/test_todo.py
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""The MCP client against a real local HTTP server (never the live todo app)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
from typing import TYPE_CHECKING, ClassVar
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from feedback_inbox._todo import McpTodoClient, TodoError
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
FULL_ID = "a1b2c3d4" + "e" * 28
|
||||||
|
|
||||||
|
|
||||||
|
class _Handler(BaseHTTPRequestHandler):
|
||||||
|
seen: ClassVar[list[dict[str, object]]] = []
|
||||||
|
headers_seen: ClassVar[list[dict[str, str]]] = []
|
||||||
|
tool_text: ClassVar[str] = f"added {FULL_ID}\nline"
|
||||||
|
is_error: ClassVar[bool] = False
|
||||||
|
status: ClassVar[int] = 200
|
||||||
|
sse: ClassVar[bool] = True
|
||||||
|
|
||||||
|
def do_POST(self) -> None:
|
||||||
|
length = int(self.headers["Content-Length"])
|
||||||
|
body = json.loads(self.rfile.read(length))
|
||||||
|
type(self).seen.append(body)
|
||||||
|
type(self).headers_seen.append({k.lower(): v for k, v in self.headers.items()})
|
||||||
|
if type(self).status != 200:
|
||||||
|
self.send_response(type(self).status)
|
||||||
|
self.end_headers()
|
||||||
|
return
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("mcp-session-id", "sess-1")
|
||||||
|
self.end_headers()
|
||||||
|
method = body.get("method")
|
||||||
|
if method == "initialize":
|
||||||
|
self.wfile.write(
|
||||||
|
b'event: message\ndata: {"jsonrpc":"2.0","id":1,"result":{}}\n\n'
|
||||||
|
)
|
||||||
|
elif method == "tools/call":
|
||||||
|
result: dict[str, object] = {
|
||||||
|
"content": [{"type": "text", "text": type(self).tool_text}],
|
||||||
|
}
|
||||||
|
if type(self).is_error:
|
||||||
|
result["isError"] = True
|
||||||
|
reply = json.dumps({"jsonrpc": "2.0", "id": 2, "result": result})
|
||||||
|
out = f"event: message\ndata: {reply}\n\n" if type(self).sse else reply
|
||||||
|
self.wfile.write(out.encode())
|
||||||
|
|
||||||
|
def log_message(self, *_args: object) -> None:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def server() -> Iterator[str]:
|
||||||
|
_Handler.seen = []
|
||||||
|
_Handler.headers_seen = []
|
||||||
|
_Handler.tool_text = f"added {FULL_ID}\nline"
|
||||||
|
_Handler.is_error = False
|
||||||
|
_Handler.status = 200
|
||||||
|
_Handler.sse = True
|
||||||
|
httpd = HTTPServer(("127.0.0.1", 0), _Handler)
|
||||||
|
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
yield f"http://127.0.0.1:{httpd.server_port}/mcp"
|
||||||
|
httpd.shutdown()
|
||||||
|
httpd.server_close()
|
||||||
|
thread.join()
|
||||||
|
|
||||||
|
|
||||||
|
def _client(url: str, tmp_path: Path) -> McpTodoClient:
|
||||||
|
token = tmp_path / "token"
|
||||||
|
token.write_text("secret-token\n", encoding="utf-8")
|
||||||
|
return McpTodoClient(url, token)
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_task_round_trip(server: str, tmp_path: Path) -> None:
|
||||||
|
ref = _client(server, tmp_path).add_task("kuhyx/cleanup-plate", "t", "b")
|
||||||
|
assert (ref.task_id, ref.merged) == (FULL_ID, False)
|
||||||
|
methods = [m.get("method") for m in _Handler.seen]
|
||||||
|
assert methods == ["initialize", "notifications/initialized", "tools/call"]
|
||||||
|
call = _Handler.seen[2]["params"]
|
||||||
|
assert call == {
|
||||||
|
"name": "add_task",
|
||||||
|
"arguments": {
|
||||||
|
"repo": "kuhyx/cleanup-plate",
|
||||||
|
"title": "t",
|
||||||
|
"body": "b",
|
||||||
|
"new_note": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
assert _Handler.headers_seen[0]["authorization"] == "Bearer secret-token"
|
||||||
|
assert _Handler.headers_seen[2]["mcp-session-id"] == "sess-1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_plain_json_reply_and_merge(server: str, tmp_path: Path) -> None:
|
||||||
|
_Handler.sse = False
|
||||||
|
_Handler.tool_text = f"merged into {FULL_ID}\nstart: skipped"
|
||||||
|
ref = _client(server, tmp_path).add_task("r/x", "t", "b")
|
||||||
|
assert (ref.task_id, ref.merged) == (FULL_ID, True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_tool_error_is_reported(server: str, tmp_path: Path) -> None:
|
||||||
|
_Handler.is_error = True
|
||||||
|
_Handler.tool_text = "boom"
|
||||||
|
with pytest.raises(TodoError, match="boom"):
|
||||||
|
_client(server, tmp_path).add_task("r/x", "t", "b")
|
||||||
|
|
||||||
|
|
||||||
|
def test_unexpected_reply_text(server: str, tmp_path: Path) -> None:
|
||||||
|
_Handler.tool_text = "who knows"
|
||||||
|
with pytest.raises(TodoError, match="who knows"):
|
||||||
|
_client(server, tmp_path).add_task("r/x", "t", "b")
|
||||||
|
|
||||||
|
|
||||||
|
def test_http_error(server: str, tmp_path: Path) -> None:
|
||||||
|
_Handler.status = 403
|
||||||
|
with pytest.raises(TodoError, match="403"):
|
||||||
|
_client(server, tmp_path).add_task("r/x", "t", "b")
|
||||||
|
|
||||||
|
|
||||||
|
def test_unreachable_server(tmp_path: Path) -> None:
|
||||||
|
with pytest.raises(TodoError, match="unreachable"):
|
||||||
|
_client("http://127.0.0.1:9/mcp", tmp_path).add_task("r/x", "t", "b")
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_token(tmp_path: Path) -> None:
|
||||||
|
client = McpTodoClient("http://127.0.0.1:9/mcp", tmp_path / "none")
|
||||||
|
with pytest.raises(TodoError, match="token"):
|
||||||
|
client.add_task("r/x", "t", "b")
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_session_header(server: str, tmp_path: Path) -> None:
|
||||||
|
class _NoSession(_Handler):
|
||||||
|
def do_POST(self) -> None:
|
||||||
|
self.rfile.read(int(self.headers["Content-Length"]))
|
||||||
|
self.send_response(200)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(b"{}")
|
||||||
|
|
||||||
|
httpd = HTTPServer(("127.0.0.1", 0), _NoSession)
|
||||||
|
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
try:
|
||||||
|
client = _client(f"http://127.0.0.1:{httpd.server_port}/mcp", tmp_path)
|
||||||
|
with pytest.raises(TodoError, match="session"):
|
||||||
|
client.add_task("r/x", "t", "b")
|
||||||
|
finally:
|
||||||
|
httpd.shutdown()
|
||||||
|
httpd.server_close()
|
||||||
|
thread.join()
|
||||||
|
|
||||||
|
|
||||||
|
def test_garbage_json(server: str, tmp_path: Path) -> None:
|
||||||
|
_Handler.sse = False
|
||||||
|
_Handler.tool_text = "x"
|
||||||
|
|
||||||
|
class _Bad(_Handler):
|
||||||
|
def do_POST(self) -> None:
|
||||||
|
body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("mcp-session-id", "s")
|
||||||
|
self.end_headers()
|
||||||
|
if body.get("method") == "tools/call":
|
||||||
|
self.wfile.write(b"not json")
|
||||||
|
|
||||||
|
httpd = HTTPServer(("127.0.0.1", 0), _Bad)
|
||||||
|
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
try:
|
||||||
|
client = _client(f"http://127.0.0.1:{httpd.server_port}/mcp", tmp_path)
|
||||||
|
with pytest.raises(TodoError, match="unparsable"):
|
||||||
|
client.add_task("r/x", "t", "b")
|
||||||
|
finally:
|
||||||
|
httpd.shutdown()
|
||||||
|
httpd.server_close()
|
||||||
|
thread.join()
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_task_replaces_title_and_body(server: str, tmp_path: Path) -> None:
|
||||||
|
_Handler.tool_text = f"updated {FULL_ID}\nline"
|
||||||
|
_client(server, tmp_path).update_task(FULL_ID, "MANUAL WORK REQUIRED: x", "b")
|
||||||
|
call = _Handler.seen[2]["params"]
|
||||||
|
assert call == {
|
||||||
|
"name": "update_task",
|
||||||
|
"arguments": {"id": FULL_ID, "title": "MANUAL WORK REQUIRED: x", "body": "b"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_task_refused(server: str, tmp_path: Path) -> None:
|
||||||
|
_Handler.tool_text = "no such task"
|
||||||
|
with pytest.raises(TodoError, match="no such task"):
|
||||||
|
_client(server, tmp_path).update_task(FULL_ID, "t", "b")
|
||||||
97
feedback_inbox/feedback_inbox/tests/test_units.py
Normal file
97
feedback_inbox/feedback_inbox/tests/test_units.py
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""The path-unit drop-ins follow the set of game folders, and only then reload."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from feedback_inbox._units import (
|
||||||
|
SYNC_PATH,
|
||||||
|
WATCH_PATH,
|
||||||
|
desired,
|
||||||
|
run_systemctl,
|
||||||
|
sync_units,
|
||||||
|
)
|
||||||
|
from feedback_inbox.tests.test_support import make_bundle
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from feedback_inbox._paths import Paths
|
||||||
|
|
||||||
|
|
||||||
|
class _Systemctl:
|
||||||
|
def __init__(self, code: int = 0) -> None:
|
||||||
|
self.calls: list[list[str]] = []
|
||||||
|
self.code = code
|
||||||
|
|
||||||
|
def __call__(self, args: Sequence[str]) -> int:
|
||||||
|
self.calls.append(list(args))
|
||||||
|
return self.code
|
||||||
|
|
||||||
|
|
||||||
|
def test_desired_lists_feedback_and_binaries_dirs(paths: Paths) -> None:
|
||||||
|
make_bundle(paths.data_root, game="cleanup-plate")
|
||||||
|
(paths.data_root / "dread-grid_binaries").mkdir()
|
||||||
|
wanted = desired(paths.data_root, paths.unit_dir)
|
||||||
|
watch = wanted[paths.unit_dir / f"{WATCH_PATH}.d" / "dirs.conf"]
|
||||||
|
sync = wanted[paths.unit_dir / f"{SYNC_PATH}.d" / "dirs.conf"]
|
||||||
|
feedback = paths.data_root / "cleanup-plate_binaries" / "feedback"
|
||||||
|
assert f"PathChanged={feedback}\n" in watch
|
||||||
|
assert "dread-grid" not in watch
|
||||||
|
assert f"PathChanged={paths.data_root}/cleanup-plate_binaries\n" in sync
|
||||||
|
assert f"PathChanged={paths.data_root}/dread-grid_binaries\n" in sync
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_writes_reloads_and_restarts_once(paths: Paths) -> None:
|
||||||
|
make_bundle(paths.data_root)
|
||||||
|
ctl = _Systemctl()
|
||||||
|
assert sync_units(paths.data_root, paths.unit_dir, ctl) is True
|
||||||
|
assert ctl.calls == [
|
||||||
|
["daemon-reload"],
|
||||||
|
["restart", WATCH_PATH, SYNC_PATH],
|
||||||
|
]
|
||||||
|
again = _Systemctl()
|
||||||
|
assert sync_units(paths.data_root, paths.unit_dir, again) is False
|
||||||
|
assert again.calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_game_triggers_a_rewrite(paths: Paths) -> None:
|
||||||
|
make_bundle(paths.data_root, game="cleanup-plate")
|
||||||
|
sync_units(paths.data_root, paths.unit_dir, _Systemctl())
|
||||||
|
make_bundle(paths.data_root, game="dread-grid")
|
||||||
|
ctl = _Systemctl()
|
||||||
|
assert sync_units(paths.data_root, paths.unit_dir, ctl) is True
|
||||||
|
text = (paths.unit_dir / f"{WATCH_PATH}.d" / "dirs.conf").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
assert "dread-grid_binaries/feedback" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_systemctl_failure_is_an_error(paths: Paths) -> None:
|
||||||
|
make_bundle(paths.data_root)
|
||||||
|
ctl = _Systemctl(code=1)
|
||||||
|
with pytest.raises(RuntimeError, match="daemon-reload"):
|
||||||
|
sync_units(paths.data_root, paths.unit_dir, ctl)
|
||||||
|
assert ctl.calls == [["daemon-reload"]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_systemctl_uses_the_user_manager(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
seen: list[list[str]] = []
|
||||||
|
|
||||||
|
class _Done:
|
||||||
|
returncode = 3
|
||||||
|
|
||||||
|
def fake_run(cmd: list[str], *, check: bool) -> _Done:
|
||||||
|
assert check is False
|
||||||
|
seen.append(cmd)
|
||||||
|
return _Done()
|
||||||
|
|
||||||
|
monkeypatch.setattr("feedback_inbox._units.subprocess.run", fake_run)
|
||||||
|
assert run_systemctl(["restart", "x"]) == 3
|
||||||
|
assert seen == [["/usr/bin/systemctl", "--user", "restart", "x"]]
|
||||||
87
feedback_inbox/install.sh
Executable file
87
feedback_inbox/install.sh
Executable file
@ -0,0 +1,87 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Install feedback-inbox for the current user: its own venv, the CLI on PATH,
|
||||||
|
# a todo-server token, and the systemd user units that watch every game's
|
||||||
|
# ~/data/<game>_binaries/feedback/ folder. Idempotent; rerun after any change.
|
||||||
|
#
|
||||||
|
# Enabling the units runs the inbox once (the sweep timer fires at once), so
|
||||||
|
# every bundle already on disk becomes a task: that is the backfill.
|
||||||
|
# Run the test suite first (scripts/run_subproject_tests.sh feedback_inbox).
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
readonly REPO_DIR
|
||||||
|
readonly STATE_DIR="${HOME}/.local/share/feedback-inbox"
|
||||||
|
readonly VENV_DIR="${STATE_DIR}/venv"
|
||||||
|
readonly BIN_DIR="${HOME}/.local/bin"
|
||||||
|
readonly UNIT_DIR="${HOME}/.config/systemd/user"
|
||||||
|
readonly CONFIG_DIR="${HOME}/.config/feedback-inbox"
|
||||||
|
readonly TOKEN_FILE="${CONFIG_DIR}/token"
|
||||||
|
readonly MINT="${HOME}/src/todo/mcp/mint_token.sh"
|
||||||
|
readonly SYSTEM_PYTHON="/usr/bin/python3"
|
||||||
|
readonly UNITS=(
|
||||||
|
feedback-inbox.service feedback-inbox.path
|
||||||
|
feedback-inbox-sync.service feedback-inbox-sync.path feedback-inbox-sync.timer
|
||||||
|
)
|
||||||
|
|
||||||
|
log() { printf 'install: %s\n' "$1" >&2; }
|
||||||
|
fail() { printf 'install: FAILED -- %s\n' "$1" >&2; exit 1; }
|
||||||
|
|
||||||
|
build_venv() {
|
||||||
|
log "building the venv at $VENV_DIR"
|
||||||
|
mkdir -p "$STATE_DIR"
|
||||||
|
[[ -x "$VENV_DIR/bin/python" ]] || "$SYSTEM_PYTHON" -m venv "$VENV_DIR" \
|
||||||
|
|| fail "could not create the venv"
|
||||||
|
"$VENV_DIR/bin/pip" install --quiet --no-deps -e "$REPO_DIR" \
|
||||||
|
|| fail "feedback-inbox itself (stdlib only, no other packages)"
|
||||||
|
mkdir -p "$BIN_DIR"
|
||||||
|
ln -sf "$VENV_DIR/bin/feedback-inbox" "$BIN_DIR/feedback-inbox"
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_token() {
|
||||||
|
# The service has no shell, so TODO_MCP_TOKEN from ~/.zshenv is invisible
|
||||||
|
# to it. It gets its own revocable token (delete its line in
|
||||||
|
# ~/.config/todo-mcp/tokens); the value goes straight to a 0600 file.
|
||||||
|
[[ -s "$TOKEN_FILE" ]] && return 0
|
||||||
|
[[ -x "$MINT" ]] || fail "$MINT is missing (the todo repo)"
|
||||||
|
log "minting a todo-server token for feedback-inbox"
|
||||||
|
mkdir -p "$CONFIG_DIR"
|
||||||
|
(umask 077 && "$MINT" feedback-inbox >"$TOKEN_FILE") || fail "token mint"
|
||||||
|
[[ -s "$TOKEN_FILE" ]] || fail "token file is empty"
|
||||||
|
}
|
||||||
|
|
||||||
|
install_units() {
|
||||||
|
log "installing and enabling the units"
|
||||||
|
mkdir -p "$UNIT_DIR"
|
||||||
|
local unit
|
||||||
|
for unit in "${UNITS[@]}"; do
|
||||||
|
install -m 644 "$REPO_DIR/systemd/$unit" "$UNIT_DIR/"
|
||||||
|
done
|
||||||
|
systemctl --user daemon-reload || fail "daemon-reload"
|
||||||
|
# Drop-ins first, so the paths are armed with every current game folder.
|
||||||
|
"$VENV_DIR/bin/feedback-inbox" sync || fail "writing the watch lists"
|
||||||
|
systemctl --user enable --now feedback-inbox.path feedback-inbox-sync.path \
|
||||||
|
feedback-inbox-sync.timer || fail "enabling the units"
|
||||||
|
}
|
||||||
|
|
||||||
|
verify() {
|
||||||
|
local unit
|
||||||
|
for unit in feedback-inbox.path feedback-inbox-sync.path feedback-inbox-sync.timer; do
|
||||||
|
systemctl --user is-active --quiet "$unit" || fail "$unit is not active"
|
||||||
|
done
|
||||||
|
local folders
|
||||||
|
folders="$(systemctl --user show feedback-inbox.path -p Paths | grep -c '/feedback (PathChanged)' || true)"
|
||||||
|
log "ok: watching ${folders} feedback folder(s)"
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
build_venv
|
||||||
|
ensure_token
|
||||||
|
install_units
|
||||||
|
verify
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
175
feedback_inbox/pyproject.toml
Normal file
175
feedback_inbox/pyproject.toml
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=61"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "feedback-inbox"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Turns every game dev-feedback bundle into one todo-app task"
|
||||||
|
requires-python = ">=3.14"
|
||||||
|
dependencies = []
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
feedback-inbox = "feedback_inbox._cli:main"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
include = ["feedback_inbox*"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
target-version = "py314"
|
||||||
|
include = ["*.py", "**/*.py"]
|
||||||
|
exclude = [".git", ".venv", "__pycache__", "build", "dist", ".eggs"]
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["ALL"]
|
||||||
|
ignore = [
|
||||||
|
"D203",
|
||||||
|
"D213",
|
||||||
|
"COM812",
|
||||||
|
"ISC001",
|
||||||
|
"S603",
|
||||||
|
# Stamps are local wall-clock names the games write; naive on purpose.
|
||||||
|
"DTZ001",
|
||||||
|
"DTZ007",
|
||||||
|
# The todo server is loopback http by design (MCP_URL), never user input.
|
||||||
|
"S310",
|
||||||
|
]
|
||||||
|
fixable = ["ALL"]
|
||||||
|
unfixable = []
|
||||||
|
|
||||||
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
"**/tests/**/*.py" = ["ARG", "D", "PLC0415", "N802", "PLR0913", "PLR2004", "S101", "SLF001"]
|
||||||
|
"**/test_*.py" = ["ARG", "D", "PLC0415", "N802", "PLR0913", "PLR2004", "S101", "SLF001"]
|
||||||
|
|
||||||
|
[tool.ruff.lint.pydocstyle]
|
||||||
|
convention = "google"
|
||||||
|
|
||||||
|
[tool.ruff.lint.isort]
|
||||||
|
force-single-line = false
|
||||||
|
force-sort-within-sections = true
|
||||||
|
known-first-party = ["feedback_inbox"]
|
||||||
|
|
||||||
|
[tool.ruff.lint.flake8-quotes]
|
||||||
|
docstring-quotes = "double"
|
||||||
|
inline-quotes = "double"
|
||||||
|
|
||||||
|
[tool.ruff.lint.flake8-tidy-imports]
|
||||||
|
ban-relative-imports = "all"
|
||||||
|
|
||||||
|
[tool.ruff.format]
|
||||||
|
quote-style = "double"
|
||||||
|
indent-style = "space"
|
||||||
|
skip-magic-trailing-comma = false
|
||||||
|
line-ending = "auto"
|
||||||
|
docstring-code-format = true
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "3.14"
|
||||||
|
strict = true
|
||||||
|
warn_return_any = true
|
||||||
|
warn_unused_configs = true
|
||||||
|
disallow_untyped_defs = true
|
||||||
|
disallow_incomplete_defs = true
|
||||||
|
check_untyped_defs = true
|
||||||
|
disallow_untyped_decorators = true
|
||||||
|
no_implicit_optional = true
|
||||||
|
warn_redundant_casts = true
|
||||||
|
warn_unused_ignores = true
|
||||||
|
warn_no_return = true
|
||||||
|
warn_unreachable = true
|
||||||
|
disallow_any_unimported = true
|
||||||
|
disallow_any_explicit = false
|
||||||
|
disallow_any_generics = true
|
||||||
|
disallow_subclassing_any = true
|
||||||
|
strict_equality = true
|
||||||
|
extra_checks = true
|
||||||
|
ignore_missing_imports = true
|
||||||
|
show_error_codes = true
|
||||||
|
color_output = true
|
||||||
|
exclude = [".venv/"]
|
||||||
|
|
||||||
|
[tool.pylint.main]
|
||||||
|
# The repo root on the path, so pylint resolves the package's own imports the
|
||||||
|
# same way pytest does instead of reporting import-error for them.
|
||||||
|
init-hook = "import sys; sys.path.insert(0, 'feedback_inbox')"
|
||||||
|
analyse-fallback-blocks = true
|
||||||
|
persistent = true
|
||||||
|
jobs = 0
|
||||||
|
py-version = "3.14"
|
||||||
|
ignore = [".venv", "__pycache__"]
|
||||||
|
# Test suites intentionally use patterns (protected-access, fixture-arg
|
||||||
|
# shadowing) that don't apply to source code; ruff's per-file-ignores carry
|
||||||
|
# the equivalent exemptions, so tests stay linted -- by ruff. This is the same
|
||||||
|
# source-only pylint scope wake-alarm / diet-guard / testsAndMisc use. It has
|
||||||
|
# to be `ignore-paths`, not `ignore = ["tests"]`: `ignore` only matches the
|
||||||
|
# basename of what pylint is *given*, and pre-commit hands it explicit file
|
||||||
|
# paths, so a directory name in `ignore` never fires under the hook.
|
||||||
|
ignore-paths = ["^(.*/)?tests/.*$"]
|
||||||
|
ignore-patterns = [".*\\.pyi$"]
|
||||||
|
|
||||||
|
[tool.pylint.messages_control]
|
||||||
|
enable = "all"
|
||||||
|
disable = []
|
||||||
|
|
||||||
|
[tool.pylint.design]
|
||||||
|
min-public-methods = 0
|
||||||
|
max-module-lines = 1000
|
||||||
|
max-attributes = 10
|
||||||
|
|
||||||
|
[tool.pylint.typecheck]
|
||||||
|
generated-members = [
|
||||||
|
".*\\.assert_called_once_with",
|
||||||
|
".*\\.assert_called_once",
|
||||||
|
".*\\.assert_called",
|
||||||
|
".*\\.assert_not_called",
|
||||||
|
".*\\.assert_any_call",
|
||||||
|
".*\\.call_args",
|
||||||
|
".*\\.call_args_list",
|
||||||
|
".*\\.call_count",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.bandit]
|
||||||
|
exclude_dirs = ["tests", ".venv"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["feedback_inbox/tests"]
|
||||||
|
python_files = ["test_*.py", "*_test.py"]
|
||||||
|
python_classes = ["Test*"]
|
||||||
|
python_functions = ["test_*"]
|
||||||
|
addopts = [
|
||||||
|
"-v",
|
||||||
|
"--strict-markers",
|
||||||
|
"--strict-config",
|
||||||
|
"-ra",
|
||||||
|
"--cov=feedback_inbox",
|
||||||
|
"--cov-branch",
|
||||||
|
"--cov-report=term-missing",
|
||||||
|
"--cov-report=lcov",
|
||||||
|
]
|
||||||
|
filterwarnings = [
|
||||||
|
"error",
|
||||||
|
"ignore::DeprecationWarning",
|
||||||
|
"default::pytest.PytestUnraisableExceptionWarning",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.coverage.run]
|
||||||
|
source = ["feedback_inbox"]
|
||||||
|
branch = true
|
||||||
|
omit = [
|
||||||
|
"*/__pycache__/*",
|
||||||
|
"*/tests/*",
|
||||||
|
"*/.venv/*",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.coverage.report]
|
||||||
|
fail_under = 100
|
||||||
|
show_missing = true
|
||||||
|
skip_covered = false
|
||||||
|
exclude_lines = [
|
||||||
|
"pragma: no cover",
|
||||||
|
"raise NotImplementedError",
|
||||||
|
"raise AssertionError",
|
||||||
|
"if TYPE_CHECKING:",
|
||||||
|
'if __name__ == "__main__":',
|
||||||
|
]
|
||||||
|
partial_branches = ["pragma: no branch"]
|
||||||
10
feedback_inbox/requirements.txt
Normal file
10
feedback_inbox/requirements.txt
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
# Exact pins, enforced by the shared dependency-freshness gate: a `>=`
|
||||||
|
# floor is satisfied by anything newer, so it tells a checker nothing
|
||||||
|
# about whether this repo is actually current.
|
||||||
|
bandit[toml]==1.9.4
|
||||||
|
mypy==2.4.1
|
||||||
|
pre-commit==4.7.0
|
||||||
|
pylint==4.1.3
|
||||||
|
pytest==9.2.0
|
||||||
|
pytest-cov==7.1.0
|
||||||
|
ruff==0.17.4
|
||||||
12
feedback_inbox/systemd/feedback-inbox-sync.path
Normal file
12
feedback_inbox/systemd/feedback-inbox-sync.path
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=feedback-inbox: a game folder appeared or went away
|
||||||
|
|
||||||
|
[Path]
|
||||||
|
# ~/data fires when a *_binaries folder is created; the generated drop-in
|
||||||
|
# feedback-inbox-sync.path.d/dirs.conf adds each existing *_binaries folder, so
|
||||||
|
# a game's later `feedback/` subfolder is seen too.
|
||||||
|
PathChanged=%h/data
|
||||||
|
Unit=feedback-inbox-sync.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
12
feedback_inbox/systemd/feedback-inbox-sync.service
Normal file
12
feedback_inbox/systemd/feedback-inbox-sync.service
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=feedback-inbox: re-arm the watches for new games, then import
|
||||||
|
Documentation=file:///home/kuhy/src/utils/feedback_inbox/README.md
|
||||||
|
StartLimitIntervalSec=0
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
WorkingDirectory=/
|
||||||
|
ExecStart=%h/.local/share/feedback-inbox/venv/bin/feedback-inbox sync
|
||||||
|
ExecStart=%h/.local/share/feedback-inbox/venv/bin/feedback-inbox run --wait 0
|
||||||
|
Nice=10
|
||||||
|
TimeoutStartSec=2min
|
||||||
12
feedback_inbox/systemd/feedback-inbox-sync.timer
Normal file
12
feedback_inbox/systemd/feedback-inbox-sync.timer
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=feedback-inbox: fallback sweep for events the path units missed
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
# Events that land while a run is active are lost by .path units; this catches
|
||||||
|
# them. No Persistent=: a missed sweep is harmless, the next one runs soon.
|
||||||
|
OnBootSec=2min
|
||||||
|
OnUnitActiveSec=5min
|
||||||
|
Unit=feedback-inbox-sync.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
12
feedback_inbox/systemd/feedback-inbox.path
Normal file
12
feedback_inbox/systemd/feedback-inbox.path
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=feedback-inbox: watch every game's feedback folder
|
||||||
|
|
||||||
|
[Path]
|
||||||
|
# systemd paths take no globs. The folders themselves are listed in the
|
||||||
|
# generated drop-in feedback-inbox.path.d/dirs.conf (`feedback-inbox sync`);
|
||||||
|
# this baseline is the token file, which exists once installed and never churns.
|
||||||
|
PathChanged=%h/.config/feedback-inbox/token
|
||||||
|
Unit=feedback-inbox.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
15
feedback_inbox/systemd/feedback-inbox.service
Normal file
15
feedback_inbox/systemd/feedback-inbox.service
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=feedback-inbox: turn new game feedback bundles into todo tasks
|
||||||
|
Documentation=file:///home/kuhy/src/utils/feedback_inbox/README.md
|
||||||
|
# The .path unit fires on every write in a feedback folder; bursts (a bundle is
|
||||||
|
# written in pieces) must not trip the default start limit.
|
||||||
|
StartLimitIntervalSec=0
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
WorkingDirectory=/
|
||||||
|
# Waits up to 15 s for a half-written bundle to finish before leaving it to the
|
||||||
|
# timer. Exits non-zero when the todo server refuses, so the failure is visible.
|
||||||
|
ExecStart=%h/.local/share/feedback-inbox/venv/bin/feedback-inbox run --wait 15
|
||||||
|
Nice=10
|
||||||
|
TimeoutStartSec=2min
|
||||||
@ -16,8 +16,38 @@ Data: `$FOCUS_PROJECT_DATA` or `~/.local/share/focus_project/` (`projects.json`,
|
|||||||
`activity.jsonl`). `minutes.py` is owned by the minutes/harvester agent.
|
`activity.jsonl`). `minutes.py` is owned by the minutes/harvester agent.
|
||||||
Time rules: `minutes_for` clips every interval at "now" (an event stamped ahead of
|
Time rules: `minutes_for` clips every interval at "now" (an event stamped ahead of
|
||||||
the clock earns only once the clock reaches it); `record_activity` rejects a `ts`
|
the clock earns only once the clock reaches it); `record_activity` rejects a `ts`
|
||||||
more than 120 s ahead (ValueError). `end_project(id, day)` rejects a past `day`
|
more than 120 s ahead (ValueError). Timer segments (`start` -> `checkin` ... -> `stop`)
|
||||||
|
lapse after a 600 s gap; a `tutor`/`heartbeat` is a beat too (0.8.0: a graded check-in card
|
||||||
|
extends the open segment, never opens one). `end_project(id, day)` rejects a past `day`
|
||||||
(ValueError); today or later is fine. The harvester pays every project day of the
|
(ValueError); today or later is fine. The harvester pays every project day of the
|
||||||
last 31 days, so late-synced events for older days are credited (idempotent).
|
last 31 days, so late-synced events for older days are credited (idempotent).
|
||||||
|
Overtime carries over: own minutes past a day's target bank (max 60) and pay on the
|
||||||
|
next project day; the bank never crosses the project's end. `minutes_for(day)` is the
|
||||||
|
capped pay figure, `status(day)["minutes"]` the real count (121/120) with `carried_in`,
|
||||||
|
`carry_out`, `own_minutes`, `counted_minutes`. Carry ledger rows: `<project>-<YYYYMMDD>-carry`
|
||||||
|
(`detail.carry`), never over `daily_minutes` together with own rows.
|
||||||
|
Edits only ratchet (0.7.0): `update_project`/`apply_update(id, today=...)` never lowers
|
||||||
|
today's obligation. A lower `daily_minutes`/`unlock_minutes`, or a gate added to `replaces`,
|
||||||
|
on a started project holds from TOMORROW (a `history` row ends today with the old values,
|
||||||
|
`replaces_on(day)`/`daily_on`/`unlock_on` read it); a mixed edit defers as a whole. Raises,
|
||||||
|
gates removed and edits to a project that has not started hold from today.
|
||||||
|
`apply_update(...).effective_from` says which. `end` cannot be before today, so an edit never
|
||||||
|
uncovers today. A NEW project starting today may replace gates and set any targets: it is a
|
||||||
|
new obligation, not a reduction (but see the floor).
|
||||||
|
Floor (0.7.1, coordinator decision for kuhy): `daily_minutes >= 30 x replaced gates` (min 30; `book` = `reading`),
|
||||||
|
enforced on create and update (`daily_floor(replaces)`), so a day cannot be made cheap.
|
||||||
|
Backed timer (0.10.0, days from `BACKING_FROM` = 2026-11-08): a timer span earns only if a
|
||||||
|
same-project `tutor`/`sql_drills`/`leetcode` beat (or accepted submission) is in it, 5 min
|
||||||
|
before it, or bridges it (beats <= 20 min apart). `unbacked_minutes_for(day)` /
|
||||||
|
`status()["unbacked_minutes"]` show the rest. A beat backs only the spans it touches (the tutor chain makes a graded card back the whole stretch). Known gap: any coach turn backs.
|
||||||
Open the work: `python3 -m focus_project.open [--no-site]` (tutor focus screen, `work_url`).
|
Open the work: `python3 -m focus_project.open [--no-site]` (tutor focus screen, `work_url`).
|
||||||
Install: `./install.sh` (stdlib only, editable user install for `/usr/bin/python3`).
|
Install: `./install.sh` (stdlib only, editable user install for `/usr/bin/python3`).
|
||||||
|
|
||||||
|
Interview prep (`kind="interview"`, 0.9): `create_project(..., kind="interview", test_at=, invite_url=, trips=, weekend_answers=)`;
|
||||||
|
`end` must be the test day. Budget: 30 min weekdays / 120 Sat-Sun (`daily_on`), exempt from `daily_floor`, unlock clamped.
|
||||||
|
Milestones are generated, never stored: `milestones(project, now)` -> `rules`, `trip_ask:<sat>`, `phone:<trip start>`, `mock`, `slot`
|
||||||
|
with states pending/done/missed/late_done/unknown. `owed_lock(now)` = earliest overdue uncompleted one (never on test day; wire it
|
||||||
|
after focus-guard's sick/free-day stand-down). `status(day)` gains `milestones` and `missed`.
|
||||||
|
Evidence is append-only (`evidence/<id>.jsonl` + copied files): `record_evidence(id, milestone, kind, value, file=, now=)`
|
||||||
|
(`file`, `tap`, `score`, `slot`, `slot_confirm`, tutor's `phone_check`). `add_trip` / `answer_weekend` only get stricter (ValueError).
|
||||||
|
`run_checks(id)` runs the `phone` source (reads the latest `phone_check` row) and latches changes; `register_source` swaps it.
|
||||||
|
|||||||
@ -17,28 +17,50 @@ from __future__ import annotations
|
|||||||
from focus_project._api import (
|
from focus_project._api import (
|
||||||
active,
|
active,
|
||||||
create_project,
|
create_project,
|
||||||
|
daily_floor,
|
||||||
end_project,
|
end_project,
|
||||||
list_projects,
|
list_projects,
|
||||||
stands_down,
|
stands_down,
|
||||||
)
|
)
|
||||||
from focus_project._errors import RegistryError
|
from focus_project._errors import RegistryError
|
||||||
|
from focus_project._milestones import Milestone, milestones, owed_lock
|
||||||
from focus_project._model import Limits, Project
|
from focus_project._model import Limits, Project
|
||||||
from focus_project._update import update_project
|
from focus_project._mutate import add_trip, answer_weekend, record_evidence
|
||||||
|
from focus_project._trips import Trip
|
||||||
|
from focus_project._update import Update, apply_update, update_project
|
||||||
from focus_project.activity import record_activity
|
from focus_project.activity import record_activity
|
||||||
from focus_project.minutes import minutes_for
|
from focus_project.checks import register_source, run_checks
|
||||||
|
from focus_project.evidence import Evidence
|
||||||
|
from focus_project.minutes import minutes_for, unbacked_minutes_for
|
||||||
|
from focus_project.sources.activity import BACKING_FROM
|
||||||
from focus_project.status import status
|
from focus_project.status import status
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"BACKING_FROM",
|
||||||
|
"Evidence",
|
||||||
"Limits",
|
"Limits",
|
||||||
|
"Milestone",
|
||||||
"Project",
|
"Project",
|
||||||
"RegistryError",
|
"RegistryError",
|
||||||
|
"Trip",
|
||||||
|
"Update",
|
||||||
"active",
|
"active",
|
||||||
|
"add_trip",
|
||||||
|
"answer_weekend",
|
||||||
|
"apply_update",
|
||||||
"create_project",
|
"create_project",
|
||||||
|
"daily_floor",
|
||||||
"end_project",
|
"end_project",
|
||||||
"list_projects",
|
"list_projects",
|
||||||
|
"milestones",
|
||||||
"minutes_for",
|
"minutes_for",
|
||||||
|
"owed_lock",
|
||||||
"record_activity",
|
"record_activity",
|
||||||
|
"record_evidence",
|
||||||
|
"register_source",
|
||||||
|
"run_checks",
|
||||||
"stands_down",
|
"stands_down",
|
||||||
"status",
|
"status",
|
||||||
|
"unbacked_minutes_for",
|
||||||
"update_project",
|
"update_project",
|
||||||
]
|
]
|
||||||
|
|||||||
@ -4,19 +4,26 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import replace
|
from dataclasses import replace
|
||||||
from datetime import date # noqa: TC003
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
from urllib.parse import urlsplit
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from focus_project import _clock, _store
|
from focus_project import _clock, _interview, _store
|
||||||
from focus_project._errors import RegistryError
|
from focus_project._errors import RegistryError
|
||||||
|
from focus_project._kind import FOCUS
|
||||||
from focus_project._model import GATES, Project, normalize_gate
|
from focus_project._model import GATES, Project, normalize_gate
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
from focus_project._trips import Trip
|
||||||
|
|
||||||
_LOG = logging.getLogger(__name__)
|
_LOG = logging.getLogger(__name__)
|
||||||
_ID = re.compile(r"[a-z0-9][a-z0-9-]*")
|
_ID = re.compile(r"[a-z0-9][a-z0-9-]*")
|
||||||
MAX_WORK_URL = 2000
|
MAX_WORK_URL = _interview.MAX_URL
|
||||||
|
# A project must ask real work: the focus earner pays per minute of ``daily``.
|
||||||
|
MIN_DAILY_PER_GATE = 30
|
||||||
|
|
||||||
|
|
||||||
def list_projects() -> list[Project]:
|
def list_projects() -> list[Project]:
|
||||||
@ -46,22 +53,17 @@ def stands_down(gate: str, day: date) -> bool:
|
|||||||
"focus_project registry unreadable, gate %s stays on: %s", gate, exc
|
"focus_project registry unreadable, gate %s stays on: %s", gate, exc
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
return project is not None and project.replaces_gate(gate)
|
return project is not None and project.replaces_gate(gate, day)
|
||||||
|
|
||||||
|
|
||||||
def _validate_work_url(url: str) -> None:
|
def _validate_work_url(url: str) -> None:
|
||||||
"""Empty, or a plain http(s) URL with a host: it goes to ``xdg-open``."""
|
"""Empty, or a plain http(s) URL with a host: it goes to ``xdg-open``."""
|
||||||
if not url:
|
_interview.check_http_url(url, "work_url")
|
||||||
return
|
|
||||||
parts = urlsplit(url) if url.isprintable() and " " not in url else None
|
|
||||||
if (
|
def daily_floor(replaces: tuple[str, ...]) -> int:
|
||||||
parts is None
|
"""The least ``daily_minutes`` a project replacing these gates may ask."""
|
||||||
or len(url) > MAX_WORK_URL
|
return MIN_DAILY_PER_GATE * max(1, len(replaces))
|
||||||
or parts.scheme not in {"http", "https"}
|
|
||||||
or not parts.netloc
|
|
||||||
):
|
|
||||||
msg = "work_url must be an http(s) URL without spaces (or empty)"
|
|
||||||
raise ValueError(msg)
|
|
||||||
|
|
||||||
|
|
||||||
def _validate(project: Project) -> None:
|
def _validate(project: Project) -> None:
|
||||||
@ -78,8 +80,13 @@ def _validate(project: Project) -> None:
|
|||||||
if not project.replaces or not set(project.replaces) <= GATES:
|
if not project.replaces or not set(project.replaces) <= GATES:
|
||||||
msg = f"replaces must be a non-empty subset of gates {sorted(GATES)}"
|
msg = f"replaces must be a non-empty subset of gates {sorted(GATES)}"
|
||||||
raise ValueError(msg)
|
raise ValueError(msg)
|
||||||
if project.daily_minutes <= 0:
|
_interview.validate(project)
|
||||||
msg = "daily_minutes must be positive"
|
floor = daily_floor(project.replaces)
|
||||||
|
if project.kind == FOCUS and project.daily_minutes < floor:
|
||||||
|
msg = (
|
||||||
|
f"daily_minutes must be at least {floor} "
|
||||||
|
f"({MIN_DAILY_PER_GATE} per replaced gate)"
|
||||||
|
)
|
||||||
raise ValueError(msg)
|
raise ValueError(msg)
|
||||||
if not 0 <= project.unlock_minutes <= project.daily_minutes:
|
if not 0 <= project.unlock_minutes <= project.daily_minutes:
|
||||||
msg = "unlock_minutes must be within 0..daily_minutes"
|
msg = "unlock_minutes must be within 0..daily_minutes"
|
||||||
@ -98,8 +105,19 @@ def create_project( # noqa: PLR0913
|
|||||||
subjects: list[str] | None = None,
|
subjects: list[str] | None = None,
|
||||||
site_patterns: list[str] | None = None,
|
site_patterns: list[str] | None = None,
|
||||||
work_url: str = "",
|
work_url: str = "",
|
||||||
|
kind: str = FOCUS,
|
||||||
|
test_at: datetime | None = None,
|
||||||
|
invite_url: str = "",
|
||||||
|
trips: list[Trip] | None = None,
|
||||||
|
weekend_answers: dict[date, bool] | None = None,
|
||||||
) -> Project:
|
) -> Project:
|
||||||
"""Add a project; ValueError on bad input, a duplicate id or an overlap."""
|
"""Add a project; ValueError on bad input, a duplicate id or an overlap.
|
||||||
|
|
||||||
|
``kind="interview"`` takes ``test_at`` (naive local; its date must be
|
||||||
|
``end``), ``invite_url``, ``trips`` and ``weekend_answers``; an interview
|
||||||
|
is exempt from :func:`daily_floor` and runs the 30/120 budget.
|
||||||
|
"""
|
||||||
|
created_ms = time.time_ns() // 1_000_000
|
||||||
project = Project(
|
project = Project(
|
||||||
id=project_id,
|
id=project_id,
|
||||||
name=name,
|
name=name,
|
||||||
@ -111,7 +129,15 @@ def create_project( # noqa: PLR0913
|
|||||||
subjects=tuple(subjects or ()),
|
subjects=tuple(subjects or ()),
|
||||||
site_patterns=tuple(site_patterns or ()),
|
site_patterns=tuple(site_patterns or ()),
|
||||||
work_url=work_url,
|
work_url=work_url,
|
||||||
created_ms=time.time_ns() // 1_000_000,
|
created_ms=created_ms,
|
||||||
|
kind=kind,
|
||||||
|
test_at=test_at,
|
||||||
|
invite_url=invite_url,
|
||||||
|
trips=tuple(
|
||||||
|
t if t.entered_ms else replace(t, entered_ms=created_ms)
|
||||||
|
for t in trips or ()
|
||||||
|
),
|
||||||
|
weekend_answers=tuple(sorted((weekend_answers or {}).items())),
|
||||||
)
|
)
|
||||||
_validate(project)
|
_validate(project)
|
||||||
with _store.locked():
|
with _store.locked():
|
||||||
|
|||||||
89
focus_project/focus_project/_interview.py
Normal file
89
focus_project/focus_project/_interview.py
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""Validation of the interview-only fields."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
from focus_project._kind import FOCUS, KINDS, WEEKDAY_MINUTES
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from focus_project._model import Project
|
||||||
|
|
||||||
|
MAX_URL = 2000
|
||||||
|
SATURDAY = 5
|
||||||
|
|
||||||
|
|
||||||
|
def check_http_url(url: str, label: str) -> None:
|
||||||
|
"""Empty, or a plain http(s) URL with a host: it goes to ``xdg-open``."""
|
||||||
|
if not url:
|
||||||
|
return
|
||||||
|
parts = urlsplit(url) if url.isprintable() and " " not in url else None
|
||||||
|
if (
|
||||||
|
parts is None
|
||||||
|
or len(url) > MAX_URL
|
||||||
|
or parts.scheme not in {"http", "https"}
|
||||||
|
or not parts.netloc
|
||||||
|
):
|
||||||
|
msg = f"{label} must be an http(s) URL without spaces (or empty)"
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def saturdays(project: Project) -> list[date]:
|
||||||
|
"""The Saturdays whose weekend lies inside D1..T-1."""
|
||||||
|
if project.test_at is None:
|
||||||
|
return []
|
||||||
|
last = project.test_at.date() - timedelta(days=1)
|
||||||
|
day = project.start + timedelta(days=(SATURDAY - project.start.weekday()) % 7)
|
||||||
|
found: list[date] = []
|
||||||
|
while day <= last:
|
||||||
|
found.append(day)
|
||||||
|
day += timedelta(days=7)
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def _has_interview_fields(project: Project) -> bool:
|
||||||
|
return bool(
|
||||||
|
project.test_at
|
||||||
|
or project.invite_url
|
||||||
|
or project.trips
|
||||||
|
or project.weekend_answers
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_interview(project: Project) -> None:
|
||||||
|
if project.test_at is None or project.test_at.tzinfo is not None:
|
||||||
|
msg = "an interview needs test_at as a naive local datetime"
|
||||||
|
raise ValueError(msg)
|
||||||
|
test_day = project.test_at.date()
|
||||||
|
if project.end != test_day:
|
||||||
|
msg = f"end must be the test day {test_day}"
|
||||||
|
raise ValueError(msg)
|
||||||
|
if project.daily_minutes < WEEKDAY_MINUTES:
|
||||||
|
msg = f"daily_minutes must be at least {WEEKDAY_MINUTES}"
|
||||||
|
raise ValueError(msg)
|
||||||
|
for trip in project.trips:
|
||||||
|
if trip.end < trip.start or trip.start > test_day:
|
||||||
|
msg = f"trip {trip.start}..{trip.end} must start by the test day"
|
||||||
|
raise ValueError(msg)
|
||||||
|
weekends = saturdays(project)
|
||||||
|
for day, _ in project.weekend_answers:
|
||||||
|
if day not in weekends:
|
||||||
|
msg = f"{day} is not a weekend inside the project before the test day"
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def validate(project: Project) -> None:
|
||||||
|
"""ValueError unless the kind and its interview fields are coherent."""
|
||||||
|
if project.kind not in KINDS:
|
||||||
|
msg = f"kind must be one of {sorted(KINDS)}"
|
||||||
|
raise ValueError(msg)
|
||||||
|
check_http_url(project.invite_url, "invite_url")
|
||||||
|
if project.kind == FOCUS:
|
||||||
|
if _has_interview_fields(project):
|
||||||
|
msg = "test_at, invite_url, trips, weekend_answers need kind='interview'"
|
||||||
|
raise ValueError(msg)
|
||||||
|
return
|
||||||
|
_validate_interview(project)
|
||||||
23
focus_project/focus_project/_kind.py
Normal file
23
focus_project/focus_project/_kind.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""Project kinds and the interview budget (the one place the numbers live)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
FOCUS = "focus"
|
||||||
|
INTERVIEW = "interview"
|
||||||
|
KINDS = frozenset({FOCUS, INTERVIEW})
|
||||||
|
|
||||||
|
# kuhy, 2026-11-10: near-zero weekday energy, weekends are for learning.
|
||||||
|
WEEKDAY_MINUTES = 30
|
||||||
|
WEEKEND_MINUTES = 120
|
||||||
|
_FIRST_WEEKEND_DAY = 5 # Saturday
|
||||||
|
|
||||||
|
|
||||||
|
def interview_minutes(day: date) -> int:
|
||||||
|
"""The daily target of an interview project on ``day`` (Sat/Sun = 120)."""
|
||||||
|
return WEEKEND_MINUTES if day.weekday() >= _FIRST_WEEKEND_DAY else WEEKDAY_MINUTES
|
||||||
235
focus_project/focus_project/_milestones.py
Normal file
235
focus_project/focus_project/_milestones.py
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""Interview milestones: generated from the project, judged from evidence.
|
||||||
|
|
||||||
|
Nothing here is stored or editable. ``due`` and the state are recomputed from
|
||||||
|
the project (kind, test_at, trips, weekend answers) and the append-only
|
||||||
|
evidence rows, so a milestone cannot be moved or ticked off by editing a row.
|
||||||
|
|
||||||
|
Failure modes (R8): an unanswered trip question reads as a trip (fail
|
||||||
|
closed); a check that could not run is ``unknown`` and counts as not done
|
||||||
|
(fail closed); an unreadable registry or evidence file arms no lock (fail
|
||||||
|
open, logged), matching focus-guard's "unreadable registry" rule.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date, datetime, time, timedelta
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING, Any, Literal
|
||||||
|
|
||||||
|
from focus_project import _api, _interview, evidence
|
||||||
|
from focus_project._errors import RegistryError
|
||||||
|
from focus_project._kind import INTERVIEW
|
||||||
|
from focus_project.sources import activity
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
from focus_project._model import Project
|
||||||
|
|
||||||
|
_LOG = logging.getLogger(__name__)
|
||||||
|
State = Literal["pending", "done", "missed", "late_done", "unknown"]
|
||||||
|
PHONE_LEAD = timedelta(hours=12)
|
||||||
|
MOCK_SESSION_S = 90 * 60
|
||||||
|
_RED = frozenset({"missed", "unknown"})
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Milestone:
|
||||||
|
"""One generated obligation of an interview project."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
due: datetime
|
||||||
|
state: State
|
||||||
|
completed: bool
|
||||||
|
|
||||||
|
@property
|
||||||
|
def red(self) -> bool:
|
||||||
|
"""Past due and not done (also: the check could not run)."""
|
||||||
|
return self.state in _RED
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
"""The status() row: id, due ISO, state."""
|
||||||
|
return {"id": self.id, "due": self.due.isoformat(), "state": self.state}
|
||||||
|
|
||||||
|
|
||||||
|
def _at(day: date, hour: int, minute: int = 0) -> datetime:
|
||||||
|
return datetime.combine(day, time(hour, minute))
|
||||||
|
|
||||||
|
|
||||||
|
def _trip_due(trip_start: date, entered_ms: int) -> datetime:
|
||||||
|
"""Start - 12 h, or the moment it was entered if that left < 12 h."""
|
||||||
|
nominal = _at(trip_start, 0) - PHONE_LEAD
|
||||||
|
if entered_ms > 0:
|
||||||
|
entered = evidence.from_ms(entered_ms)
|
||||||
|
if nominal - entered < PHONE_LEAD:
|
||||||
|
return entered
|
||||||
|
return nominal
|
||||||
|
|
||||||
|
|
||||||
|
def _is_trip(
|
||||||
|
*,
|
||||||
|
answered: bool | None,
|
||||||
|
answer_ms: int | None,
|
||||||
|
ask_due: datetime,
|
||||||
|
phone_due: datetime,
|
||||||
|
now: datetime,
|
||||||
|
) -> bool:
|
||||||
|
"""Whether the weekend counts as a trip (fail closed).
|
||||||
|
|
||||||
|
Unanswered once the question is due = a trip. A no-trip answer given
|
||||||
|
only after the phone prep was already due cannot erase that overdue
|
||||||
|
milestone: the answer row's own timestamp decides, not a stored flag.
|
||||||
|
"""
|
||||||
|
if answered is None:
|
||||||
|
return now >= ask_due
|
||||||
|
if answered:
|
||||||
|
return True
|
||||||
|
return answer_ms is not None and answer_ms >= evidence.to_ms(phone_due)
|
||||||
|
|
||||||
|
|
||||||
|
def plan(
|
||||||
|
project: Project, test_day: date, now: datetime, rows: list[evidence.Evidence]
|
||||||
|
) -> dict[str, datetime]:
|
||||||
|
"""Milestone id -> due; ``project`` must be a validated interview."""
|
||||||
|
dues = {
|
||||||
|
"rules": _at(project.start, 23, 59),
|
||||||
|
"mock": _at(test_day - timedelta(days=2), 23, 59),
|
||||||
|
"slot": _at(test_day - timedelta(days=1), 12),
|
||||||
|
}
|
||||||
|
answers = dict(project.weekend_answers)
|
||||||
|
for sat in _interview.saturdays(project):
|
||||||
|
ask_due = _at(sat - timedelta(days=2), 23, 59)
|
||||||
|
phone_due = _at(sat, 0) - PHONE_LEAD
|
||||||
|
dues[f"trip_ask:{sat}"] = ask_due
|
||||||
|
covered = any(t.covers(sat) for t in project.trips)
|
||||||
|
row = evidence.first(rows, f"trip_ask:{sat}", "answer")
|
||||||
|
answer_ms = None if row is None else row.ms
|
||||||
|
if not covered and _is_trip(
|
||||||
|
answered=answers.get(sat),
|
||||||
|
answer_ms=answer_ms,
|
||||||
|
ask_due=ask_due,
|
||||||
|
phone_due=phone_due,
|
||||||
|
now=now,
|
||||||
|
):
|
||||||
|
dues[f"phone:{sat}"] = phone_due
|
||||||
|
for trip_row in project.trips:
|
||||||
|
dues[f"phone:{trip_row.start}"] = _trip_due(trip_row.start, trip_row.entered_ms)
|
||||||
|
return dues
|
||||||
|
|
||||||
|
|
||||||
|
def _when(row: evidence.Evidence | None) -> datetime | None:
|
||||||
|
return None if row is None else row.at
|
||||||
|
|
||||||
|
|
||||||
|
Rows = list[evidence.Evidence]
|
||||||
|
Completion = tuple[datetime | None, bool]
|
||||||
|
|
||||||
|
|
||||||
|
def _by_evidence(kind: str) -> Callable[[str, Rows, Project], Completion]:
|
||||||
|
"""Done at the first evidence row of ``kind`` (rules file, mock score)."""
|
||||||
|
|
||||||
|
def judge(mid: str, rows: Rows, _project: Project) -> Completion:
|
||||||
|
return _when(evidence.first(rows, mid, kind)), False
|
||||||
|
|
||||||
|
return judge
|
||||||
|
|
||||||
|
|
||||||
|
def _mock(mid: str, rows: Rows, project: Project) -> Completion:
|
||||||
|
"""Done at the first score row that follows a >= 90 min focus session.
|
||||||
|
|
||||||
|
The session (contiguous beats, :func:`activity.sessions`) must END at or
|
||||||
|
before the score row; a score with no such session completes nothing.
|
||||||
|
"""
|
||||||
|
ends = [hi for lo, hi in activity.sessions(project.id) if hi - lo >= MOCK_SESSION_S]
|
||||||
|
for row in rows:
|
||||||
|
if (row.milestone, row.kind) == (mid, "score") and any(
|
||||||
|
end <= row.ms / 1000 for end in ends
|
||||||
|
):
|
||||||
|
return row.at, False
|
||||||
|
return None, False
|
||||||
|
|
||||||
|
|
||||||
|
def _trip_ask(mid: str, rows: Rows, project: Project) -> Completion:
|
||||||
|
"""Done at the answer row; an answer with no row was given at creation."""
|
||||||
|
if date.fromisoformat(mid.partition(":")[2]) not in dict(project.weekend_answers):
|
||||||
|
return None, False
|
||||||
|
return _when(evidence.first(rows, mid, "answer")) or evidence.from_ms(0), False
|
||||||
|
|
||||||
|
|
||||||
|
def _slot(mid: str, rows: Rows, _project: Project) -> Completion:
|
||||||
|
"""Done once the latest slot entry has a confirm tap at or after it."""
|
||||||
|
slot = evidence.last(rows, mid, "slot")
|
||||||
|
if slot is None:
|
||||||
|
return None, False
|
||||||
|
confirm = evidence.first(rows, mid, "slot_confirm", since_ms=slot.ms)
|
||||||
|
return (None if confirm is None else max(slot.at, confirm.at)), False
|
||||||
|
|
||||||
|
|
||||||
|
def _phone(mid: str, rows: Rows, _project: Project) -> Completion:
|
||||||
|
"""Done when the offline tap and a passing auto check both exist.
|
||||||
|
|
||||||
|
A tap with no verdict at all (or a dark source) is ``unknown``.
|
||||||
|
"""
|
||||||
|
tap = evidence.first(rows, mid, "tap")
|
||||||
|
passed, latest, _ = evidence.check_summary(rows, mid)
|
||||||
|
if tap is not None and passed is not None:
|
||||||
|
return max(tap.at, evidence.from_ms(passed)), False
|
||||||
|
return None, tap is not None and latest is None
|
||||||
|
|
||||||
|
|
||||||
|
_JUDGES = {
|
||||||
|
"rules": _by_evidence("file"),
|
||||||
|
"mock": _mock,
|
||||||
|
"trip_ask": _trip_ask,
|
||||||
|
"slot": _slot,
|
||||||
|
"phone": _phone,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _judge(
|
||||||
|
mid: str, due: datetime, rows: Rows, project: Project, now: datetime
|
||||||
|
) -> Milestone:
|
||||||
|
done_at, unknown = _JUDGES[mid.partition(":")[0]](mid, rows, project)
|
||||||
|
if done_at is not None:
|
||||||
|
state: State = "done" if done_at <= due else "late_done"
|
||||||
|
return Milestone(mid, due, state, completed=True)
|
||||||
|
if now < due:
|
||||||
|
return Milestone(mid, due, "pending", completed=False)
|
||||||
|
return Milestone(mid, due, "unknown" if unknown else "missed", completed=False)
|
||||||
|
|
||||||
|
|
||||||
|
def milestones(project: Project, now: datetime) -> list[Milestone]:
|
||||||
|
"""The project's milestones at ``now`` (naive local), earliest due first.
|
||||||
|
|
||||||
|
Empty for a focus project. May raise OSError reading the evidence file.
|
||||||
|
"""
|
||||||
|
if project.kind != INTERVIEW or project.test_at is None:
|
||||||
|
return []
|
||||||
|
rows = evidence.read(project.id)
|
||||||
|
dues = plan(project, project.test_at.date(), now, rows)
|
||||||
|
found = [_judge(mid, due, rows, project, now) for mid, due in dues.items()]
|
||||||
|
return sorted(found, key=lambda m: (m.due, m.id))
|
||||||
|
|
||||||
|
|
||||||
|
def owed_lock(now: datetime) -> Milestone | None:
|
||||||
|
"""The earliest overdue, uncompleted milestone that locks, or None.
|
||||||
|
|
||||||
|
Locks (missed or unknown) from the moment it is due, every day of the
|
||||||
|
project except the test day itself, where nothing locks. It does not
|
||||||
|
consult sickdays/freedays: focus-guard's ``_off(today)`` precondition
|
||||||
|
already stands the lock down on those days, so wire this after it.
|
||||||
|
Unreadable state arms nothing (fail open, logged).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
project = _api.active(now.date())
|
||||||
|
if project is None or project.test_at is None:
|
||||||
|
return None
|
||||||
|
if now.date() >= project.test_at.date():
|
||||||
|
return None
|
||||||
|
owed = [m for m in milestones(project, now) if not m.completed and now >= m.due]
|
||||||
|
except (RegistryError, OSError) as exc:
|
||||||
|
_LOG.warning("interview state unreadable, no milestone lock: %s", exc)
|
||||||
|
return None
|
||||||
|
return owed[0] if owed else None
|
||||||
@ -4,10 +4,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import date
|
from datetime import date, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from focus_project._errors import RegistryError
|
from focus_project._errors import RegistryError
|
||||||
|
from focus_project._kind import FOCUS, INTERVIEW, interview_minutes
|
||||||
|
from focus_project._trips import Trip
|
||||||
|
|
||||||
GATES = frozenset({"leetcode", "reading", "tutor"})
|
GATES = frozenset({"leetcode", "reading", "tutor"})
|
||||||
_ALIASES = {"book": "reading"}
|
_ALIASES = {"book": "reading"}
|
||||||
@ -18,6 +20,17 @@ def normalize_gate(gate: str) -> str:
|
|||||||
return _ALIASES.get(gate, gate)
|
return _ALIASES.get(gate, gate)
|
||||||
|
|
||||||
|
|
||||||
|
def _answers(raw: dict[str, Any] | None) -> tuple[tuple[date, bool], ...]:
|
||||||
|
"""Parse the saturday -> bool map; a non-bool answer is a bad row."""
|
||||||
|
pairs = []
|
||||||
|
for key, value in (raw or {}).items():
|
||||||
|
if not isinstance(value, bool):
|
||||||
|
msg = f"weekend answer for {key} is not true/false"
|
||||||
|
raise TypeError(msg)
|
||||||
|
pairs.append((date.fromisoformat(key), value))
|
||||||
|
return tuple(sorted(pairs))
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class Limits:
|
class Limits:
|
||||||
"""The minute targets that held up to and including ``until``."""
|
"""The minute targets that held up to and including ``until``."""
|
||||||
@ -25,22 +38,29 @@ class Limits:
|
|||||||
until: date
|
until: date
|
||||||
daily_minutes: int
|
daily_minutes: int
|
||||||
unlock_minutes: int
|
unlock_minutes: int
|
||||||
|
# None = a row from before 0.7.0: the project's current gates apply.
|
||||||
|
replaces: tuple[str, ...] | None = None
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, Any]:
|
def to_dict(self) -> dict[str, Any]:
|
||||||
"""The history row in projects.json."""
|
"""The history row in projects.json."""
|
||||||
return {
|
row: dict[str, Any] = {
|
||||||
"until": self.until.isoformat(),
|
"until": self.until.isoformat(),
|
||||||
"daily_minutes": self.daily_minutes,
|
"daily_minutes": self.daily_minutes,
|
||||||
"unlock_minutes": self.unlock_minutes,
|
"unlock_minutes": self.unlock_minutes,
|
||||||
}
|
}
|
||||||
|
if self.replaces is not None:
|
||||||
|
row["replaces"] = list(self.replaces)
|
||||||
|
return row
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, row: Any) -> Limits: # noqa: ANN401
|
def from_dict(cls, row: Any) -> Limits: # noqa: ANN401
|
||||||
"""Parse a history row (errors surface as RegistryError upstream)."""
|
"""Parse a history row (errors surface as RegistryError upstream)."""
|
||||||
|
gates = row.get("replaces")
|
||||||
return cls(
|
return cls(
|
||||||
until=date.fromisoformat(row["until"]),
|
until=date.fromisoformat(row["until"]),
|
||||||
daily_minutes=int(row["daily_minutes"]),
|
daily_minutes=int(row["daily_minutes"]),
|
||||||
unlock_minutes=int(row["unlock_minutes"]),
|
unlock_minutes=int(row["unlock_minutes"]),
|
||||||
|
replaces=None if gates is None else tuple(gates),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -63,19 +83,35 @@ class Project:
|
|||||||
work_url: str = ""
|
work_url: str = ""
|
||||||
# Earlier targets, oldest first: an edit applies from its day, never back.
|
# Earlier targets, oldest first: an edit applies from its day, never back.
|
||||||
history: tuple[Limits, ...] = ()
|
history: tuple[Limits, ...] = ()
|
||||||
|
# "focus" (every pre-interview row) or "interview"; the rest is interview-only.
|
||||||
|
kind: str = FOCUS
|
||||||
|
# Local naive datetime of the test; its date is the project's last day.
|
||||||
|
test_at: datetime | None = None
|
||||||
|
invite_url: str = ""
|
||||||
|
trips: tuple[Trip, ...] = ()
|
||||||
|
# Saturday -> True (trip that weekend) / False (no trip); sorted pairs.
|
||||||
|
weekend_answers: tuple[tuple[date, bool], ...] = ()
|
||||||
|
|
||||||
def _limits_on(self, day: date) -> Limits | None:
|
def _limits_on(self, day: date) -> Limits | None:
|
||||||
return next((h for h in self.history if day <= h.until), None)
|
return next((h for h in self.history if day <= h.until), None)
|
||||||
|
|
||||||
def daily_on(self, day: date) -> int:
|
def daily_on(self, day: date) -> int:
|
||||||
"""The daily target that held on ``day``."""
|
"""The daily target that held on ``day``."""
|
||||||
|
if self.kind == INTERVIEW:
|
||||||
|
return interview_minutes(day)
|
||||||
past = self._limits_on(day)
|
past = self._limits_on(day)
|
||||||
return self.daily_minutes if past is None else past.daily_minutes
|
return self.daily_minutes if past is None else past.daily_minutes
|
||||||
|
|
||||||
def unlock_on(self, day: date) -> int:
|
def unlock_on(self, day: date) -> int:
|
||||||
"""The unlock target that held on ``day``."""
|
"""The unlock target that held on ``day`` (clamped for an interview)."""
|
||||||
past = self._limits_on(day)
|
past = self._limits_on(day)
|
||||||
return self.unlock_minutes if past is None else past.unlock_minutes
|
held = self.unlock_minutes if past is None else past.unlock_minutes
|
||||||
|
return min(held, interview_minutes(day)) if self.kind == INTERVIEW else held
|
||||||
|
|
||||||
|
def replaces_on(self, day: date) -> tuple[str, ...]:
|
||||||
|
"""The gates this project switched off on ``day``."""
|
||||||
|
past = self._limits_on(day)
|
||||||
|
return self.replaces if past is None or past.replaces is None else past.replaces
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def last_day(self) -> date:
|
def last_day(self) -> date:
|
||||||
@ -86,13 +122,13 @@ class Project:
|
|||||||
"""Whether this project is active on ``day``."""
|
"""Whether this project is active on ``day``."""
|
||||||
return self.start <= day <= self.last_day
|
return self.start <= day <= self.last_day
|
||||||
|
|
||||||
def replaces_gate(self, gate: str) -> bool:
|
def replaces_gate(self, gate: str, day: date) -> bool:
|
||||||
"""Whether this project switches ``gate`` off."""
|
"""Whether this project switches ``gate`` off on ``day``."""
|
||||||
return normalize_gate(gate) in self.replaces
|
return normalize_gate(gate) in self.replaces_on(day)
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, Any]:
|
def to_dict(self) -> dict[str, Any]:
|
||||||
"""The projects.json row."""
|
"""The projects.json row."""
|
||||||
return {
|
row: dict[str, Any] = {
|
||||||
"id": self.id,
|
"id": self.id,
|
||||||
"name": self.name,
|
"name": self.name,
|
||||||
"start": self.start.isoformat(),
|
"start": self.start.isoformat(),
|
||||||
@ -106,13 +142,23 @@ class Project:
|
|||||||
"created_ms": self.created_ms,
|
"created_ms": self.created_ms,
|
||||||
"work_url": self.work_url,
|
"work_url": self.work_url,
|
||||||
"history": [h.to_dict() for h in self.history],
|
"history": [h.to_dict() for h in self.history],
|
||||||
|
"kind": self.kind,
|
||||||
}
|
}
|
||||||
|
if self.kind == INTERVIEW:
|
||||||
|
row["test_at"] = self.test_at.isoformat() if self.test_at else None
|
||||||
|
row["invite_url"] = self.invite_url
|
||||||
|
row["trips"] = [t.to_dict() for t in self.trips]
|
||||||
|
row["weekend_answers"] = {
|
||||||
|
day.isoformat(): trip for day, trip in self.weekend_answers
|
||||||
|
}
|
||||||
|
return row
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, row: Any) -> Project: # noqa: ANN401
|
def from_dict(cls, row: Any) -> Project: # noqa: ANN401
|
||||||
"""Parse a registry row, or raise :class:`RegistryError`."""
|
"""Parse a registry row, or raise :class:`RegistryError`."""
|
||||||
try:
|
try:
|
||||||
ended = row.get("ended_on")
|
ended = row.get("ended_on")
|
||||||
|
test_at = row.get("test_at")
|
||||||
return cls(
|
return cls(
|
||||||
id=str(row["id"]),
|
id=str(row["id"]),
|
||||||
name=str(row["name"]),
|
name=str(row["name"]),
|
||||||
@ -127,6 +173,11 @@ class Project:
|
|||||||
created_ms=int(row.get("created_ms", 0)),
|
created_ms=int(row.get("created_ms", 0)),
|
||||||
work_url=str(row.get("work_url") or ""),
|
work_url=str(row.get("work_url") or ""),
|
||||||
history=tuple(Limits.from_dict(h) for h in row.get("history", ())),
|
history=tuple(Limits.from_dict(h) for h in row.get("history", ())),
|
||||||
|
kind=str(row.get("kind") or FOCUS),
|
||||||
|
test_at=datetime.fromisoformat(test_at) if test_at else None,
|
||||||
|
invite_url=str(row.get("invite_url") or ""),
|
||||||
|
trips=tuple(Trip.from_dict(t) for t in row.get("trips", ())),
|
||||||
|
weekend_answers=_answers(row.get("weekend_answers")),
|
||||||
)
|
)
|
||||||
except (KeyError, TypeError, ValueError, AttributeError) as exc:
|
except (KeyError, TypeError, ValueError, AttributeError) as exc:
|
||||||
msg = f"bad project row {row!r}: {exc}"
|
msg = f"bad project row {row!r}: {exc}"
|
||||||
|
|||||||
178
focus_project/focus_project/_mutate.py
Normal file
178
focus_project/focus_project/_mutate.py
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""Interview mutations: trips, weekend answers, evidence. Stricter only."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import replace
|
||||||
|
from datetime import date, datetime
|
||||||
|
import math
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from focus_project import _interview, _milestones, _store, evidence
|
||||||
|
from focus_project._kind import INTERVIEW
|
||||||
|
from focus_project._trips import Trip, add
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from focus_project._model import Project
|
||||||
|
|
||||||
|
# Which evidence kinds each milestone family accepts from a caller.
|
||||||
|
# ``check`` and ``answer`` are internal: written by run_checks/answer_weekend.
|
||||||
|
_ACCEPTS = {
|
||||||
|
"rules": {"file"},
|
||||||
|
"mock": {"score"},
|
||||||
|
"slot": {"slot", "slot_confirm"},
|
||||||
|
"phone": {"tap"},
|
||||||
|
}
|
||||||
|
# tutor's worker reports phone readiness under the bare id "phone".
|
||||||
|
_PHONE_REPORT = "phone"
|
||||||
|
|
||||||
|
|
||||||
|
def local_now(now: datetime | None) -> datetime:
|
||||||
|
"""``now`` or the clock's local time."""
|
||||||
|
return evidence.clock_now() if now is None else now
|
||||||
|
|
||||||
|
|
||||||
|
def find(existing: list[Project], project_id: str) -> tuple[int, Project]:
|
||||||
|
"""Index and interview project; KeyError unknown, ValueError focus kind."""
|
||||||
|
for i, project in enumerate(existing):
|
||||||
|
if project.id == project_id:
|
||||||
|
if project.kind != INTERVIEW:
|
||||||
|
msg = f"{project_id!r} is not an interview project (no milestones)"
|
||||||
|
raise ValueError(msg)
|
||||||
|
return i, project
|
||||||
|
raise KeyError(project_id)
|
||||||
|
|
||||||
|
|
||||||
|
def add_trip(
|
||||||
|
project_id: str, start: date, end: date, *, now: datetime | None = None
|
||||||
|
) -> Project:
|
||||||
|
"""Announce a trip; it may only grow (see :func:`focus_project._trips.add`)."""
|
||||||
|
stamp = local_now(now)
|
||||||
|
with _store.locked():
|
||||||
|
existing = _store.read_all()
|
||||||
|
i, project = find(existing, project_id)
|
||||||
|
trips = add(project.trips, Trip(start, end, evidence.to_ms(stamp)))
|
||||||
|
updated = replace(project, trips=trips)
|
||||||
|
_interview.validate(updated)
|
||||||
|
existing[i] = updated
|
||||||
|
_store.write_all(existing)
|
||||||
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
def answer_weekend(
|
||||||
|
project_id: str, saturday: date, *, trip: bool, now: datetime | None = None
|
||||||
|
) -> Project:
|
||||||
|
"""Answer the trip question of a weekend; True never flips back to False."""
|
||||||
|
stamp = local_now(now)
|
||||||
|
with _store.locked():
|
||||||
|
existing = _store.read_all()
|
||||||
|
i, project = find(existing, project_id)
|
||||||
|
if saturday not in _interview.saturdays(project):
|
||||||
|
msg = f"{saturday} is not a weekend of {project_id!r} before its test day"
|
||||||
|
raise ValueError(msg)
|
||||||
|
answers = dict(project.weekend_answers)
|
||||||
|
current = answers.get(saturday)
|
||||||
|
if current is trip:
|
||||||
|
return project
|
||||||
|
if current is True:
|
||||||
|
msg = "a trip answer may only get stricter: it stays True"
|
||||||
|
raise ValueError(msg)
|
||||||
|
if not trip and any(t.covers(saturday) for t in project.trips):
|
||||||
|
msg = f"an announced trip covers {saturday}: it cannot be a no-trip weekend"
|
||||||
|
raise ValueError(msg)
|
||||||
|
updated = replace(
|
||||||
|
project, weekend_answers=tuple(sorted({**answers, saturday: trip}.items()))
|
||||||
|
)
|
||||||
|
# Evidence first: a crash between the two leaves the question open
|
||||||
|
# (fail closed), never an answer with no time.
|
||||||
|
evidence.append(project_id, f"trip_ask:{saturday}", "answer", trip, stamp)
|
||||||
|
existing[i] = updated
|
||||||
|
_store.write_all(existing)
|
||||||
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
def _number(value: object) -> float:
|
||||||
|
if (
|
||||||
|
isinstance(value, bool)
|
||||||
|
or not isinstance(value, int | float)
|
||||||
|
or not math.isfinite(value)
|
||||||
|
or value < 0
|
||||||
|
):
|
||||||
|
msg = "a score must be a finite, non-negative number"
|
||||||
|
raise ValueError(msg)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _count(value: object, label: str) -> int:
|
||||||
|
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
||||||
|
msg = f"{label} must be a non-negative integer"
|
||||||
|
raise ValueError(msg)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _clean(
|
||||||
|
project_id: str, kind: str, value: object, file: Path | None, stamp: datetime
|
||||||
|
) -> object:
|
||||||
|
"""The value to store for ``kind``; ValueError if it is not acceptable."""
|
||||||
|
if kind == "file":
|
||||||
|
if file is None or not file.is_file() or not file.stat().st_size:
|
||||||
|
msg = "a non-empty file is required"
|
||||||
|
raise ValueError(msg)
|
||||||
|
return evidence.store_file(project_id, file, stamp)
|
||||||
|
if kind == "score":
|
||||||
|
return _number(value)
|
||||||
|
if kind == "slot":
|
||||||
|
moment = datetime.fromisoformat(str(value)) if isinstance(value, str) else None
|
||||||
|
if moment is None or moment.tzinfo is not None:
|
||||||
|
msg = "slot must be a naive local ISO datetime"
|
||||||
|
raise ValueError(msg)
|
||||||
|
return moment.isoformat()
|
||||||
|
if kind == "phone_check":
|
||||||
|
if not isinstance(value, dict) or not isinstance(value.get("plan"), bool):
|
||||||
|
msg = "phone_check needs {plan: bool, due: int, seen_ms: int}"
|
||||||
|
raise ValueError(msg)
|
||||||
|
return {
|
||||||
|
"plan": value["plan"],
|
||||||
|
"due": _count(value.get("due"), "due"),
|
||||||
|
"seen_ms": _count(value.get("seen_ms"), "seen_ms"),
|
||||||
|
}
|
||||||
|
if kind == "slot_confirm" and value not in {True, None}:
|
||||||
|
msg = "slot_confirm is a tap: value True"
|
||||||
|
raise ValueError(msg)
|
||||||
|
return True # tap, slot_confirm
|
||||||
|
|
||||||
|
|
||||||
|
def record_evidence( # noqa: PLR0913
|
||||||
|
project_id: str,
|
||||||
|
milestone: str,
|
||||||
|
kind: str,
|
||||||
|
value: object = None,
|
||||||
|
*,
|
||||||
|
file: Path | None = None,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> evidence.Evidence:
|
||||||
|
"""Append evidence for a milestone; ValueError if it does not fit.
|
||||||
|
|
||||||
|
Kinds: ``file`` (rules, copied from ``file``), ``tap`` (phone:*),
|
||||||
|
``score`` (mock), ``slot`` (ISO datetime) and ``slot_confirm`` (slot),
|
||||||
|
``phone_check`` ({plan, due, seen_ms}, milestone "phone", from tutor).
|
||||||
|
There is no way to delete a row.
|
||||||
|
"""
|
||||||
|
stamp = local_now(now)
|
||||||
|
_, project = find(_store.read_all(), project_id)
|
||||||
|
if milestone == _PHONE_REPORT:
|
||||||
|
accepted = {"phone_check"}
|
||||||
|
else:
|
||||||
|
known = {m.id for m in _milestones.milestones(project, stamp)}
|
||||||
|
accepted = _ACCEPTS.get(milestone.partition(":")[0], set())
|
||||||
|
if milestone not in known:
|
||||||
|
msg = f"{milestone!r} is not a milestone of {project_id!r}"
|
||||||
|
raise ValueError(msg)
|
||||||
|
if kind not in accepted:
|
||||||
|
msg = f"{milestone!r} takes evidence kinds {sorted(accepted)}, not {kind!r}"
|
||||||
|
raise ValueError(msg)
|
||||||
|
return evidence.append(
|
||||||
|
project_id, milestone, kind, _clean(project_id, kind, value, file, stamp), stamp
|
||||||
|
)
|
||||||
31
focus_project/focus_project/_own.py
Normal file
31
focus_project/focus_project/_own.py
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""A project day's own worked minutes: the union of every source's intervals."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from focus_project import sources
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from focus_project._model import Project
|
||||||
|
|
||||||
|
|
||||||
|
def union_seconds(spans: list[sources.Interval]) -> float:
|
||||||
|
"""Total length of the union of ``spans``."""
|
||||||
|
total = 0.0
|
||||||
|
end = float("-inf")
|
||||||
|
for lo, hi in sorted(spans):
|
||||||
|
if hi <= end:
|
||||||
|
continue
|
||||||
|
total += hi - max(lo, end)
|
||||||
|
end = hi
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
def own_minutes_of(project: Project, day: date, moment: float) -> int:
|
||||||
|
"""Whole minutes worked on ``day``, uncapped, nothing after ``moment``."""
|
||||||
|
spans = sources.clip(sources.collect(day, project), (float("-inf"), moment))
|
||||||
|
return int(union_seconds(spans) // 60)
|
||||||
@ -30,3 +30,18 @@ def activity_path() -> Path:
|
|||||||
def lock_path() -> Path:
|
def lock_path() -> Path:
|
||||||
"""The registry's writer lock."""
|
"""The registry's writer lock."""
|
||||||
return data_dir() / "projects.lock"
|
return data_dir() / "projects.lock"
|
||||||
|
|
||||||
|
|
||||||
|
def evidence_dir() -> Path:
|
||||||
|
"""Interview-prep evidence: one jsonl per project plus copied files."""
|
||||||
|
return data_dir() / "evidence"
|
||||||
|
|
||||||
|
|
||||||
|
def evidence_path(project_id: str) -> Path:
|
||||||
|
"""The append-only evidence rows of one project."""
|
||||||
|
return evidence_dir() / f"{project_id}.jsonl"
|
||||||
|
|
||||||
|
|
||||||
|
def evidence_files_dir(project_id: str) -> Path:
|
||||||
|
"""Where evidence files (screenshots) of one project are copied."""
|
||||||
|
return evidence_dir() / project_id
|
||||||
|
|||||||
61
focus_project/focus_project/_trips.py
Normal file
61
focus_project/focus_project/_trips.py
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""Trips: days away from the PC. They can only grow (ratchet)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, replace
|
||||||
|
from datetime import date
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Trip:
|
||||||
|
"""An inclusive away range; ``entered_ms`` is when it was announced."""
|
||||||
|
|
||||||
|
start: date
|
||||||
|
end: date
|
||||||
|
entered_ms: int = 0
|
||||||
|
|
||||||
|
def covers(self, day: date) -> bool:
|
||||||
|
"""Whether ``day`` is inside the trip."""
|
||||||
|
return self.start <= day <= self.end
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
"""The projects.json row."""
|
||||||
|
return {
|
||||||
|
"start": self.start.isoformat(),
|
||||||
|
"end": self.end.isoformat(),
|
||||||
|
"entered_ms": self.entered_ms,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, row: dict[str, Any]) -> Trip:
|
||||||
|
"""Parse a trip row (errors surface as RegistryError upstream)."""
|
||||||
|
return cls(
|
||||||
|
start=date.fromisoformat(row["start"]),
|
||||||
|
end=date.fromisoformat(row["end"]),
|
||||||
|
entered_ms=int(row.get("entered_ms", 0)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def add(trips: tuple[Trip, ...], new: Trip) -> tuple[Trip, ...]:
|
||||||
|
"""``trips`` plus ``new``; ValueError if that would loosen an old trip.
|
||||||
|
|
||||||
|
A new range touching an old trip must cover it: it replaces the old one
|
||||||
|
(keeping its entry time when the start did not move earlier). Anything
|
||||||
|
that starts later or ends earlier than what is on file is refused.
|
||||||
|
"""
|
||||||
|
kept: list[Trip] = []
|
||||||
|
for old in trips:
|
||||||
|
if old.start > new.end or new.start > old.end:
|
||||||
|
kept.append(old)
|
||||||
|
elif new.start <= old.start and new.end >= old.end:
|
||||||
|
if new.start == old.start:
|
||||||
|
new = replace(new, entered_ms=old.entered_ms)
|
||||||
|
else:
|
||||||
|
msg = (
|
||||||
|
f"a trip may only get stricter: pass a range that covers "
|
||||||
|
f"{old.start}..{old.end}"
|
||||||
|
)
|
||||||
|
raise ValueError(msg)
|
||||||
|
return tuple(sorted([*kept, new], key=lambda t: t.start))
|
||||||
@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import replace
|
from dataclasses import dataclass, replace
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@ -27,7 +27,7 @@ _EDITABLE = frozenset(
|
|||||||
_TUPLES = ("subjects", "site_patterns")
|
_TUPLES = ("subjects", "site_patterns")
|
||||||
|
|
||||||
|
|
||||||
def _apply(project: Project, changes: dict[str, Any], today: date) -> Project:
|
def _apply(project: Project, changes: dict[str, Any], today: date) -> Update:
|
||||||
"""The edited project; ValueError for what an edit may not touch."""
|
"""The edited project; ValueError for what an edit may not touch."""
|
||||||
if project.ended_on is not None:
|
if project.ended_on is not None:
|
||||||
msg = f"project {project.id!r} ended on {project.ended_on}"
|
msg = f"project {project.id!r} ended on {project.ended_on}"
|
||||||
@ -50,37 +50,81 @@ def _apply(project: Project, changes: dict[str, Any], today: date) -> Project:
|
|||||||
if "end" in changes and changes["end"] < today:
|
if "end" in changes and changes["end"] < today:
|
||||||
msg = f"end {changes['end']} is in the past"
|
msg = f"end {changes['end']} is in the past"
|
||||||
raise ValueError(msg)
|
raise ValueError(msg)
|
||||||
edited = replace(project, **fields)
|
return _schedule(project, replace(project, **fields), today)
|
||||||
return _keep_past_limits(project, edited, today)
|
|
||||||
|
|
||||||
|
|
||||||
def _keep_past_limits(old: Project, new: Project, today: date) -> Project:
|
def _held(project: Project, day: date) -> Limits:
|
||||||
"""Freeze the old targets for the days before ``today`` if they changed."""
|
"""What held on ``day``: both targets and the replaced gates."""
|
||||||
changed = (old.daily_minutes, old.unlock_minutes) != (
|
return Limits(
|
||||||
|
day,
|
||||||
|
project.daily_on(day),
|
||||||
|
project.unlock_on(day),
|
||||||
|
project.replaces_on(day),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _reduces(held: Limits, new: Project) -> bool:
|
||||||
|
"""Whether ``new`` asks less than ``held``: lower target or a gate more."""
|
||||||
|
return (
|
||||||
|
new.daily_minutes < held.daily_minutes
|
||||||
|
or new.unlock_minutes < held.unlock_minutes
|
||||||
|
or not set(new.replaces) <= set(held.replaces or ())
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _schedule(old: Project, new: Project, today: date) -> Update:
|
||||||
|
"""Date the edit: a reduction of a started project waits for tomorrow.
|
||||||
|
|
||||||
|
A raise (or a project that has not started) applies from ``today``; the
|
||||||
|
days before keep what held. Anything that lowers a target or switches a
|
||||||
|
gate off defers the whole edit one day, so no edit can undo a lock or
|
||||||
|
cheapen the pay of a day already running.
|
||||||
|
"""
|
||||||
|
if (old.daily_minutes, old.unlock_minutes, old.replaces) == (
|
||||||
new.daily_minutes,
|
new.daily_minutes,
|
||||||
new.unlock_minutes,
|
new.unlock_minutes,
|
||||||
|
new.replaces,
|
||||||
|
):
|
||||||
|
return Update(new, today)
|
||||||
|
deferred = today >= old.start and _reduces(_held(old, today), new)
|
||||||
|
first = today + timedelta(days=1) if deferred else today
|
||||||
|
last = first - timedelta(days=1)
|
||||||
|
history = [h for h in old.history if h.until < first]
|
||||||
|
held = _held(old, last)
|
||||||
|
same = (held.daily_minutes, held.unlock_minutes, held.replaces) == (
|
||||||
|
new.daily_minutes,
|
||||||
|
new.unlock_minutes,
|
||||||
|
new.replaces,
|
||||||
)
|
)
|
||||||
cutoff = today - timedelta(days=1)
|
if last >= old.start and not same and not (history and history[-1].until == last):
|
||||||
if not changed or cutoff < old.start:
|
history.append(held)
|
||||||
return new
|
return Update(replace(new, history=tuple(history)), first)
|
||||||
if old.history and old.history[-1].until == cutoff:
|
|
||||||
return new
|
|
||||||
row = Limits(cutoff, old.daily_minutes, old.unlock_minutes)
|
|
||||||
return replace(new, history=(*old.history, row))
|
|
||||||
|
|
||||||
|
|
||||||
def update_project(
|
@dataclass(frozen=True)
|
||||||
|
class Update:
|
||||||
|
"""An edited project and the first day its new targets and gates hold."""
|
||||||
|
|
||||||
|
project: Project
|
||||||
|
effective_from: date
|
||||||
|
|
||||||
|
|
||||||
|
def apply_update(
|
||||||
project_id: str,
|
project_id: str,
|
||||||
*,
|
*,
|
||||||
today: date | None = None,
|
today: date | None = None,
|
||||||
**changes: Any, # noqa: ANN401
|
**changes: Any, # noqa: ANN401
|
||||||
) -> Project:
|
) -> Update:
|
||||||
"""Edit ``project_id``; ValueError on bad input, overlap or a past edit.
|
"""Edit ``project_id``; ValueError on bad input, overlap or a past edit.
|
||||||
|
|
||||||
Editable: name, start, end, replaces, daily_minutes, unlock_minutes,
|
Editable: name, start, end, replaces, daily_minutes, unlock_minutes,
|
||||||
subjects, site_patterns, work_url. A started project's start cannot move; changed
|
subjects, site_patterns, work_url. A started project's start cannot move.
|
||||||
minute targets apply from ``today`` (default: now) on, earlier days keep
|
An edit never lowers today's obligation (0.7.0): a lower daily or unlock
|
||||||
theirs. KeyError for an unknown id.
|
target, or a gate added to ``replaces``, holds from tomorrow, the earlier
|
||||||
|
days (today included) keep theirs. Raises, gates removed and edits to a
|
||||||
|
project that has not started hold from ``today`` (default: now). The
|
||||||
|
result says which: ``effective_from``. A mixed edit defers as a whole.
|
||||||
|
KeyError for an unknown id.
|
||||||
"""
|
"""
|
||||||
unknown = sorted(set(changes) - _EDITABLE)
|
unknown = sorted(set(changes) - _EDITABLE)
|
||||||
if unknown:
|
if unknown:
|
||||||
@ -92,7 +136,8 @@ def update_project(
|
|||||||
for i, project in enumerate(existing):
|
for i, project in enumerate(existing):
|
||||||
if project.id != project_id:
|
if project.id != project_id:
|
||||||
continue
|
continue
|
||||||
edited = _apply(project, changes, today)
|
done = _apply(project, changes, today)
|
||||||
|
edited = done.project
|
||||||
_validate(edited)
|
_validate(edited)
|
||||||
for other in existing:
|
for other in existing:
|
||||||
if other.id != project_id and (
|
if other.id != project_id and (
|
||||||
@ -102,5 +147,15 @@ def update_project(
|
|||||||
raise ValueError(msg)
|
raise ValueError(msg)
|
||||||
existing[i] = edited
|
existing[i] = edited
|
||||||
_store.write_all(existing)
|
_store.write_all(existing)
|
||||||
return edited
|
return done
|
||||||
raise KeyError(project_id)
|
raise KeyError(project_id)
|
||||||
|
|
||||||
|
|
||||||
|
def update_project(
|
||||||
|
project_id: str,
|
||||||
|
*,
|
||||||
|
today: date | None = None,
|
||||||
|
**changes: Any, # noqa: ANN401
|
||||||
|
) -> Project:
|
||||||
|
""":func:`apply_update` returning just the project."""
|
||||||
|
return apply_update(project_id, today=today, **changes).project
|
||||||
|
|||||||
75
focus_project/focus_project/carry.py
Normal file
75
focus_project/focus_project/carry.py
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""Overtime carries over: minutes past a day's target bank for the next day.
|
||||||
|
|
||||||
|
Contract (kuhy, 2026-11-02): own minutes beyond ``daily_on(day)`` are banked
|
||||||
|
(at most :data:`BANK_CAP`) and pay on the NEXT project day. The bank never
|
||||||
|
crosses the project's last day (what is left then expires) and a carry never
|
||||||
|
pays on a day with no project. The balance is derived from the activity
|
||||||
|
record, never stored, so it cannot drift; a project day's counted minutes
|
||||||
|
are ``min(daily, own + carried_in)``. The bank is spent first, so what a day
|
||||||
|
takes from it is fixed when the day starts and only own minutes grow.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date, timedelta
|
||||||
|
import logging
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
|
from focus_project import _api, _clock
|
||||||
|
from focus_project._errors import RegistryError
|
||||||
|
from focus_project._own import own_minutes_of
|
||||||
|
|
||||||
|
BANK_CAP: Final = 60
|
||||||
|
_LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Balance:
|
||||||
|
"""One project day: worked minutes, bank taken in, bank left for tomorrow."""
|
||||||
|
|
||||||
|
own: int
|
||||||
|
carried_in: int
|
||||||
|
carry_out: int
|
||||||
|
|
||||||
|
def paid_bank(self, daily: int) -> int:
|
||||||
|
"""Minutes of the day paid from the bank (fixed at the day's start)."""
|
||||||
|
return min(self.carried_in, daily)
|
||||||
|
|
||||||
|
def paid_own(self, daily: int) -> int:
|
||||||
|
"""Minutes of the day paid from own work, after the bank."""
|
||||||
|
return min(self.own, daily - self.paid_bank(daily))
|
||||||
|
|
||||||
|
def counted(self, daily: int) -> int:
|
||||||
|
"""Minutes that count on the day: own plus carried, capped at ``daily``."""
|
||||||
|
return min(daily, self.own + self.carried_in)
|
||||||
|
|
||||||
|
|
||||||
|
_NONE: Final = Balance(0, 0, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def balance(day: date, now: float | None = None) -> Balance:
|
||||||
|
"""The balance of ``day`` (zeros with no project or an unreadable registry)."""
|
||||||
|
moment = _clock.now() if now is None else now
|
||||||
|
try:
|
||||||
|
project = _api.active(day)
|
||||||
|
if project is None:
|
||||||
|
return _NONE
|
||||||
|
bank = 0
|
||||||
|
cursor = project.start
|
||||||
|
while True:
|
||||||
|
own = own_minutes_of(project, cursor, moment)
|
||||||
|
total = own + bank
|
||||||
|
left = min(BANK_CAP, max(0, total - project.daily_on(cursor)))
|
||||||
|
if cursor >= day:
|
||||||
|
return Balance(own, bank, left)
|
||||||
|
bank, cursor = left, cursor + timedelta(days=1)
|
||||||
|
except (RegistryError, OSError) as exc:
|
||||||
|
_LOG.warning("focus_project registry unreadable, nothing banked: %s", exc)
|
||||||
|
return _NONE
|
||||||
|
|
||||||
|
|
||||||
|
def own_minutes(day: date, now: float | None = None) -> int:
|
||||||
|
"""Minutes worked on ``day``, not capped at the target (0 with no project)."""
|
||||||
|
return balance(day, now).own
|
||||||
106
focus_project/focus_project/checks.py
Normal file
106
focus_project/focus_project/checks.py
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""Pluggable check sources for the auto-checked milestones, and latching.
|
||||||
|
|
||||||
|
A source is ``fn(project, now) -> True | False | None``; ``None`` means "could
|
||||||
|
not check" (logged) and counts as not done. Only ``phone`` is auto-checked
|
||||||
|
(the calendar slot check was dropped, 2026-11-10: ``slot`` is two taps).
|
||||||
|
|
||||||
|
:func:`run_checks` is the only place sources run: status()/milestones() stay
|
||||||
|
cheap and read the latched ``check`` rows it appends (only on a change).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from focus_project import _milestones, _mutate, _store, evidence
|
||||||
|
from focus_project._model import Project
|
||||||
|
|
||||||
|
_LOG = logging.getLogger(__name__)
|
||||||
|
CheckSource = Callable[[Project, datetime], bool | None]
|
||||||
|
DAY_MS = 24 * 3600 * 1000
|
||||||
|
# A phone check starts 24 h before the milestone is due.
|
||||||
|
WINDOW = timedelta(hours=24)
|
||||||
|
|
||||||
|
|
||||||
|
def _report_ok(value: object, report_ms: int) -> bool:
|
||||||
|
"""A plan exists, cards are due, the phone was seen <= 24 h before the report."""
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return bool(
|
||||||
|
value["plan"] is True
|
||||||
|
and int(value["due"]) > 0
|
||||||
|
and report_ms - int(value["seen_ms"]) <= DAY_MS
|
||||||
|
)
|
||||||
|
except KeyError, TypeError, ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def phone_source(project: Project, now: datetime) -> bool | None:
|
||||||
|
"""Read tutor's latest ``phone_check`` report (<= 24 h old).
|
||||||
|
|
||||||
|
No report, a stale one or one stamped ahead of the clock = ``None``
|
||||||
|
(the worker is not reporting). A fresh one passes only if the plan
|
||||||
|
exists, cards are due and the phone was seen within 24 h of the report.
|
||||||
|
"""
|
||||||
|
report = evidence.last(evidence.read(project.id), "phone", "phone_check")
|
||||||
|
if report is None:
|
||||||
|
_LOG.warning("phone check: no tutor report for %s", project.id)
|
||||||
|
return None
|
||||||
|
if not 0 <= evidence.to_ms(now) - report.ms <= DAY_MS:
|
||||||
|
_LOG.warning("phone check: tutor report for %s is stale or ahead", project.id)
|
||||||
|
return None
|
||||||
|
return _report_ok(report.value, report.ms)
|
||||||
|
|
||||||
|
|
||||||
|
SOURCES: dict[str, CheckSource] = {"phone": phone_source}
|
||||||
|
|
||||||
|
|
||||||
|
def register_source(name: str, source: CheckSource) -> None:
|
||||||
|
"""Replace the callable behind an auto check (``phone``)."""
|
||||||
|
if name not in SOURCES:
|
||||||
|
msg = f"unknown check source {name!r}; known: {sorted(SOURCES)}"
|
||||||
|
raise ValueError(msg)
|
||||||
|
SOURCES[name] = source
|
||||||
|
|
||||||
|
|
||||||
|
def _run(project: Project, now: datetime) -> bool | None:
|
||||||
|
try:
|
||||||
|
return SOURCES["phone"](project, now)
|
||||||
|
except Exception as exc: # noqa: BLE001 (pluggable code: any failure = unknown)
|
||||||
|
_LOG.warning("phone check source failed: %s", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def run_checks(
|
||||||
|
project_id: str, *, now: datetime | None = None
|
||||||
|
) -> dict[str, bool | None]:
|
||||||
|
"""Run the auto checks of open phone milestones; latch what changed.
|
||||||
|
|
||||||
|
Returns milestone id -> verdict for the ones checked (inside their
|
||||||
|
24 h window, not yet done). KeyError for an unknown project; a focus
|
||||||
|
project has nothing to check.
|
||||||
|
"""
|
||||||
|
stamp = _mutate.local_now(now)
|
||||||
|
project = next((p for p in _store.read_all() if p.id == project_id), None)
|
||||||
|
if project is None:
|
||||||
|
raise KeyError(project_id)
|
||||||
|
open_ids = [
|
||||||
|
m.id
|
||||||
|
for m in _milestones.milestones(project, stamp)
|
||||||
|
if m.id.startswith("phone:")
|
||||||
|
and m.state not in {"done", "late_done"}
|
||||||
|
and stamp >= m.due - WINDOW
|
||||||
|
]
|
||||||
|
if not open_ids:
|
||||||
|
return {}
|
||||||
|
verdict = _run(project, stamp)
|
||||||
|
rows = evidence.read(project_id)
|
||||||
|
for mid in open_ids:
|
||||||
|
_, latest, seen = evidence.check_summary(rows, mid)
|
||||||
|
if verdict != latest and not (verdict is None and not seen):
|
||||||
|
evidence.append(project_id, mid, "check", verdict, stamp)
|
||||||
|
return dict.fromkeys(open_ids, verdict)
|
||||||
159
focus_project/focus_project/evidence.py
Normal file
159
focus_project/focus_project/evidence.py
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""The interview evidence store: append-only rows plus copied files.
|
||||||
|
|
||||||
|
One jsonl per project (``{ms, milestone, kind, value}``) under the
|
||||||
|
:mod:`focus_project._paths` seam. There is deliberately no delete or rewrite
|
||||||
|
function: a milestone once evidenced stays evidenced.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
import itertools
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from focus_project import _clock, _paths
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Evidence:
|
||||||
|
"""One evidence row."""
|
||||||
|
|
||||||
|
ms: int
|
||||||
|
milestone: str
|
||||||
|
kind: str
|
||||||
|
value: object
|
||||||
|
|
||||||
|
@property
|
||||||
|
def at(self) -> datetime:
|
||||||
|
"""When it was recorded, as a naive local datetime."""
|
||||||
|
return from_ms(self.ms)
|
||||||
|
|
||||||
|
|
||||||
|
def to_ms(moment: datetime) -> int:
|
||||||
|
"""Unix milliseconds of a naive local datetime."""
|
||||||
|
return int(moment.timestamp() * 1000)
|
||||||
|
|
||||||
|
|
||||||
|
def from_ms(ms: int) -> datetime:
|
||||||
|
"""Naive local datetime of unix milliseconds."""
|
||||||
|
return datetime.fromtimestamp(ms / 1000, tz=UTC).astimezone().replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
|
def clock_now() -> datetime:
|
||||||
|
"""The clock seam (:mod:`focus_project._clock`) as a naive local datetime."""
|
||||||
|
return from_ms(int(_clock.now() * 1000))
|
||||||
|
|
||||||
|
|
||||||
|
def append(
|
||||||
|
project_id: str, milestone: str, kind: str, value: object, now: datetime
|
||||||
|
) -> Evidence:
|
||||||
|
"""Append one row (fsynced) and return it."""
|
||||||
|
row = Evidence(to_ms(now), milestone, kind, value)
|
||||||
|
path = _paths.evidence_path(project_id)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
line = json.dumps(
|
||||||
|
{"ms": row.ms, "milestone": milestone, "kind": kind, "value": value}
|
||||||
|
)
|
||||||
|
with path.open("a", encoding="utf-8") as handle:
|
||||||
|
handle.write(line + "\n")
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def store_file(project_id: str, source: Path, now: datetime) -> str:
|
||||||
|
"""Copy ``source`` beside the rows under a name that never collides."""
|
||||||
|
target = _paths.evidence_files_dir(project_id)
|
||||||
|
target.mkdir(parents=True, exist_ok=True)
|
||||||
|
safe = re.sub(r"[^A-Za-z0-9._-]", "_", source.name)[:80]
|
||||||
|
stamp = to_ms(now)
|
||||||
|
for attempt in itertools.count():
|
||||||
|
name = f"{stamp}-{safe}" if not attempt else f"{stamp}-{attempt}-{safe}"
|
||||||
|
try:
|
||||||
|
with (target / name).open("xb") as out, source.open("rb") as src:
|
||||||
|
shutil.copyfileobj(src, out)
|
||||||
|
out.flush()
|
||||||
|
os.fsync(out.fileno())
|
||||||
|
except FileExistsError:
|
||||||
|
continue
|
||||||
|
return name
|
||||||
|
raise AssertionError # pragma: no cover (itertools.count never ends)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse(line: str) -> Evidence | None:
|
||||||
|
try:
|
||||||
|
row = json.loads(line)
|
||||||
|
if not isinstance(row, dict) or isinstance(row["ms"], bool):
|
||||||
|
return None
|
||||||
|
return Evidence(
|
||||||
|
int(row["ms"]), str(row["milestone"]), str(row["kind"]), row["value"]
|
||||||
|
)
|
||||||
|
except ValueError, KeyError, TypeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def read(project_id: str) -> list[Evidence]:
|
||||||
|
"""Every row in file order; a missing file is empty, bad lines are skipped.
|
||||||
|
|
||||||
|
A torn last line (crash mid-append) must not wipe out the whole history,
|
||||||
|
so malformed lines are logged and ignored; an unreadable file raises.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
path = _paths.evidence_path(project_id)
|
||||||
|
text = path.read_text(encoding="utf-8", errors="replace")
|
||||||
|
except FileNotFoundError:
|
||||||
|
return []
|
||||||
|
rows: list[Evidence] = []
|
||||||
|
for number, line in enumerate(text.splitlines(), start=1):
|
||||||
|
parsed = _parse(line)
|
||||||
|
if parsed is None:
|
||||||
|
_LOG.warning(
|
||||||
|
"evidence %s line %d: skipping malformed row", project_id, number
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
rows.append(parsed)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def first(
|
||||||
|
rows: list[Evidence], milestone: str, kind: str, since_ms: int = 0
|
||||||
|
) -> Evidence | None:
|
||||||
|
"""The earliest matching row at or after ``since_ms``."""
|
||||||
|
return next(
|
||||||
|
(
|
||||||
|
r
|
||||||
|
for r in rows
|
||||||
|
if (r.milestone, r.kind) == (milestone, kind) and r.ms >= since_ms
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def last(rows: list[Evidence], milestone: str, kind: str) -> Evidence | None:
|
||||||
|
"""The latest matching row."""
|
||||||
|
return next(
|
||||||
|
(r for r in reversed(rows) if (r.milestone, r.kind) == (milestone, kind)),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def check_summary(
|
||||||
|
rows: list[Evidence], milestone: str
|
||||||
|
) -> tuple[int | None, bool | None, bool]:
|
||||||
|
"""``(first passing ms, latest verdict, any check row)`` for a milestone."""
|
||||||
|
checks = [r for r in rows if (r.milestone, r.kind) == (milestone, "check")]
|
||||||
|
passed = next((r.ms for r in checks if r.value is True), None)
|
||||||
|
latest = checks[-1].value if checks else None
|
||||||
|
return passed, (latest if isinstance(latest, bool) else None), bool(checks)
|
||||||
@ -7,6 +7,9 @@ one-minute ``.timer``). Harvests every project day of the last
|
|||||||
after the fact) is paid once a run sees it; ``credit_to`` is idempotent, so
|
after the fact) is paid once a run sees it; ``credit_to`` is idempotent, so
|
||||||
settled days cost one read and write nothing. Failure modes, by path:
|
settled days cost one read and write nothing. Failure modes, by path:
|
||||||
|
|
||||||
|
Overtime past a day's target banks for the next day (``carry``): that day's
|
||||||
|
carry row pays it from the day's start, own-work rows pay the rest.
|
||||||
|
|
||||||
* a source that errors: logged, contributes 0, the run continues (fail closed);
|
* a source that errors: logged, contributes 0, the run continues (fail closed);
|
||||||
* an unreadable registry, signing key or ledger: logged, exit status 1, so
|
* an unreadable registry, signing key or ledger: logged, exit status 1, so
|
||||||
the unit shows ``failed`` instead of silently crediting nothing.
|
the unit shows ``failed`` instead of silently crediting nothing.
|
||||||
@ -18,9 +21,8 @@ from datetime import date, timedelta
|
|||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from focus_project import _api, _clock, ledger
|
from focus_project import _api, _clock, carry, ledger
|
||||||
from focus_project._errors import RegistryError
|
from focus_project._errors import RegistryError
|
||||||
from focus_project.minutes import minutes_for
|
|
||||||
|
|
||||||
BACKFILL_DAYS = 31 # bounds the per-run cost; the relay accepts events <= 48 h old
|
BACKFILL_DAYS = 31 # bounds the per-run cost; the relay accepts events <= 48 h old
|
||||||
_LOG = logging.getLogger("focus_project.harvest")
|
_LOG = logging.getLogger("focus_project.harvest")
|
||||||
@ -43,11 +45,18 @@ def harvest_day(day: date, now: float) -> int:
|
|||||||
project = _api.active(day)
|
project = _api.active(day)
|
||||||
if project is None:
|
if project is None:
|
||||||
return 0
|
return 0
|
||||||
total = minutes_for(day, now=now)
|
daily = project.daily_on(day)
|
||||||
added = ledger.credit_to(project, day, total, now=now)
|
bank = carry.balance(day, now)
|
||||||
|
added = ledger.credit_to(project, day, bank.paid_bank(daily), now=now, carry=True)
|
||||||
|
added += ledger.credit_to(project, day, bank.paid_own(daily), now=now)
|
||||||
if added:
|
if added:
|
||||||
_LOG.info(
|
_LOG.info(
|
||||||
"credited %d min to %s on %s (total %d)", added, project.id, day, total
|
"credited %d min to %s on %s (total %d, %d from the bank)",
|
||||||
|
added,
|
||||||
|
project.id,
|
||||||
|
day,
|
||||||
|
bank.counted(daily),
|
||||||
|
bank.paid_bank(daily),
|
||||||
)
|
)
|
||||||
return added
|
return added
|
||||||
|
|
||||||
|
|||||||
@ -93,10 +93,19 @@ def _write_rows(path: Path, rows: list[Any]) -> None:
|
|||||||
Path(handle.name).replace(path)
|
Path(handle.name).replace(path)
|
||||||
|
|
||||||
|
|
||||||
def paid_minutes(rows: list[Any], key: bytes, project_id: str, day: date) -> int:
|
def paid_minutes(
|
||||||
|
rows: list[Any],
|
||||||
|
key: bytes,
|
||||||
|
project_id: str,
|
||||||
|
day: date,
|
||||||
|
*,
|
||||||
|
carry: bool | None = None,
|
||||||
|
) -> int:
|
||||||
"""Minutes already paid for ``project_id`` on ``day`` by verified rows.
|
"""Minutes already paid for ``project_id`` on ``day`` by verified rows.
|
||||||
|
|
||||||
A repeated ``entry_id`` pays once, at its smallest count.
|
``carry`` picks one class (True: banked-overtime rows, False: own-work
|
||||||
|
rows); None sums both. A repeated ``entry_id`` pays once, at its smallest
|
||||||
|
count.
|
||||||
"""
|
"""
|
||||||
by_id: dict[str, int] = {}
|
by_id: dict[str, int] = {}
|
||||||
for row in rows:
|
for row in rows:
|
||||||
@ -112,6 +121,7 @@ def paid_minutes(rows: list[Any], key: bytes, project_id: str, day: date) -> int
|
|||||||
or not isinstance(minutes, int)
|
or not isinstance(minutes, int)
|
||||||
or isinstance(minutes, bool)
|
or isinstance(minutes, bool)
|
||||||
or minutes < 1
|
or minutes < 1
|
||||||
|
or (carry is not None and bool(detail.get("carry")) != carry)
|
||||||
or not earned_time.verified(row, key)
|
or not earned_time.verified(row, key)
|
||||||
):
|
):
|
||||||
continue
|
continue
|
||||||
@ -121,13 +131,31 @@ def paid_minutes(rows: list[Any], key: bytes, project_id: str, day: date) -> int
|
|||||||
|
|
||||||
|
|
||||||
def build_row( # noqa: PLR0913
|
def build_row( # noqa: PLR0913
|
||||||
project_id: str, day: date, total: int, minutes: int, key: bytes, *, now: float
|
project_id: str,
|
||||||
|
day: date,
|
||||||
|
total: int,
|
||||||
|
minutes: int,
|
||||||
|
key: bytes,
|
||||||
|
*,
|
||||||
|
now: float,
|
||||||
|
carry: bool = False,
|
||||||
|
first_carry: bool = True,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""The signed row paying ``minutes`` that bring the day's total to ``total``."""
|
"""The signed row paying ``minutes`` that bring the day's total to ``total``.
|
||||||
|
|
||||||
|
A ``carry`` row pays banked overtime: ``<project>-<YYYYMMDD>-carry``, or
|
||||||
|
``-carry-m<total>`` when it tops up an earlier carry row (late data for
|
||||||
|
the day before grew the bank).
|
||||||
|
"""
|
||||||
ended = min(now, day_bounds(day)[1] - 1)
|
ended = min(now, day_bounds(day)[1] - 1)
|
||||||
|
stem = f"{project_id}-{day:%Y%m%d}"
|
||||||
|
if not carry:
|
||||||
|
entry_id = f"{stem}-m{total}"
|
||||||
|
else:
|
||||||
|
entry_id = f"{stem}-carry" if first_carry else f"{stem}-carry-m{total}"
|
||||||
row: dict[str, Any] = {
|
row: dict[str, Any] = {
|
||||||
"kind": CREDIT,
|
"kind": CREDIT,
|
||||||
"entry_id": f"{project_id}-{day:%Y%m%d}-m{total}",
|
"entry_id": entry_id,
|
||||||
"day": day.isoformat(),
|
"day": day.isoformat(),
|
||||||
"created_at": float(now),
|
"created_at": float(now),
|
||||||
"detail": {
|
"detail": {
|
||||||
@ -135,6 +163,7 @@ def build_row( # noqa: PLR0913
|
|||||||
"minutes": int(minutes),
|
"minutes": int(minutes),
|
||||||
"total": int(total),
|
"total": int(total),
|
||||||
"ended_at": float(ended),
|
"ended_at": float(ended),
|
||||||
|
**({"carry": True} if carry else {}),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
row["hmac"] = str(earned_time.entry_signature(row, key))
|
row["hmac"] = str(earned_time.entry_signature(row, key))
|
||||||
@ -142,10 +171,18 @@ def build_row( # noqa: PLR0913
|
|||||||
|
|
||||||
|
|
||||||
def credit_to(
|
def credit_to(
|
||||||
project: Project, day: date, total: int, *, now: float | None = None
|
project: Project,
|
||||||
|
day: date,
|
||||||
|
total: int,
|
||||||
|
*,
|
||||||
|
now: float | None = None,
|
||||||
|
carry: bool = False,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Pay the day up to ``total`` cumulative minutes; return minutes added.
|
"""Pay the day up to ``total`` cumulative minutes; return minutes added.
|
||||||
|
|
||||||
|
``carry`` pays the banked-overtime class and counts only its own rows;
|
||||||
|
the default pays (and counts) own work.
|
||||||
|
|
||||||
Idempotent: nothing is written when the ledger already pays ``total`` or
|
Idempotent: nothing is written when the ledger already pays ``total`` or
|
||||||
more. Raises LedgerError (unreadable ledger, unreadable key, unverifiable
|
more. Raises LedgerError (unreadable ledger, unreadable key, unverifiable
|
||||||
write) so the harvester can fail loudly instead of crediting nothing quietly.
|
write) so the harvester can fail loudly instead of crediting nothing quietly.
|
||||||
@ -159,11 +196,18 @@ def credit_to(
|
|||||||
with path.with_suffix(".lock").open("w") as lock:
|
with path.with_suffix(".lock").open("w") as lock:
|
||||||
fcntl.flock(lock, fcntl.LOCK_EX)
|
fcntl.flock(lock, fcntl.LOCK_EX)
|
||||||
rows = read_rows(path)
|
rows = read_rows(path)
|
||||||
paid = paid_minutes(rows, key, project.id, day)
|
paid = paid_minutes(rows, key, project.id, day, carry=carry)
|
||||||
if total <= paid:
|
if total <= paid:
|
||||||
return 0
|
return 0
|
||||||
row = build_row(
|
row = build_row(
|
||||||
project.id, day, total, total - paid, key, now=now or time.time()
|
project.id,
|
||||||
|
day,
|
||||||
|
total,
|
||||||
|
total - paid,
|
||||||
|
key,
|
||||||
|
now=now or time.time(),
|
||||||
|
carry=carry,
|
||||||
|
first_carry=paid == 0,
|
||||||
)
|
)
|
||||||
if any(
|
if any(
|
||||||
isinstance(r, dict) and r.get("entry_id") == row["entry_id"] for r in rows
|
isinstance(r, dict) and r.get("entry_id") == row["entry_id"] for r in rows
|
||||||
@ -172,7 +216,7 @@ def credit_to(
|
|||||||
raise LedgerError(msg)
|
raise LedgerError(msg)
|
||||||
_write_rows(path, [*rows, row])
|
_write_rows(path, [*rows, row])
|
||||||
stored = read_rows(path)
|
stored = read_rows(path)
|
||||||
if paid_minutes(stored, key, project.id, day) != total:
|
if paid_minutes(stored, key, project.id, day, carry=carry) != total:
|
||||||
msg = f"row {row['entry_id']} not found verified on read-back"
|
msg = f"row {row['entry_id']} not found verified on read-back"
|
||||||
raise LedgerError(msg)
|
raise LedgerError(msg)
|
||||||
return total - paid
|
return total - paid
|
||||||
|
|||||||
@ -1,12 +1,17 @@
|
|||||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
"""Credited focus minutes per day: the union of every source's intervals.
|
"""Credited focus minutes per day: the union of every source's intervals.
|
||||||
|
|
||||||
Contract: union of activity intervals on the local calendar day, capped at
|
Contract: union of activity intervals on the local calendar day plus the
|
||||||
the active project's ``daily_minutes``; overlaps never double count; whole
|
overtime carried in from the day before (:mod:`focus_project.carry`), capped
|
||||||
|
at the active project's ``daily_minutes``; overlaps never double count; whole
|
||||||
minutes (floor). Time not yet lived is never credited: every interval is
|
minutes (floor). Time not yet lived is never credited: every interval is
|
||||||
clipped at "now", so an event stamped ahead of the clock (a fast phone clock)
|
clipped at "now", so an event stamped ahead of the clock (a fast phone clock)
|
||||||
earns only once the real clock reaches it. An unreadable registry reads as 0
|
earns only once the real clock reaches it. An unreadable registry reads as 0
|
||||||
(logged): fail closed.
|
(logged): fail closed.
|
||||||
|
|
||||||
|
Unbacked minutes (0.10.0): timer time the anti-cheat gate refused
|
||||||
|
(:mod:`focus_project.sources.activity`) and no other source covers, on the
|
||||||
|
local day up to "now"; carry and the cap play no part. Shown, never paid.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@ -14,24 +19,14 @@ from __future__ import annotations
|
|||||||
from datetime import date # noqa: TC003
|
from datetime import date # noqa: TC003
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from focus_project import _api, _clock, sources
|
from focus_project import _api, _clock, carry, sources
|
||||||
from focus_project._errors import RegistryError
|
from focus_project._errors import RegistryError
|
||||||
|
from focus_project._own import union_seconds
|
||||||
|
from focus_project.sources import activity
|
||||||
|
|
||||||
_LOG = logging.getLogger(__name__)
|
_LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def union_seconds(spans: list[sources.Interval]) -> float:
|
|
||||||
"""Total length of the union of ``spans``."""
|
|
||||||
total = 0.0
|
|
||||||
end = float("-inf")
|
|
||||||
for lo, hi in sorted(spans):
|
|
||||||
if hi <= end:
|
|
||||||
continue
|
|
||||||
total += hi - max(lo, end)
|
|
||||||
end = hi
|
|
||||||
return total
|
|
||||||
|
|
||||||
|
|
||||||
def minutes_for(day: date, now: float | None = None) -> int:
|
def minutes_for(day: date, now: float | None = None) -> int:
|
||||||
"""Minutes of focus work credited on ``day`` (0 with no project active).
|
"""Minutes of focus work credited on ``day`` (0 with no project active).
|
||||||
|
|
||||||
@ -45,6 +40,29 @@ def minutes_for(day: date, now: float | None = None) -> int:
|
|||||||
if project is None:
|
if project is None:
|
||||||
return 0
|
return 0
|
||||||
moment = _clock.now() if now is None else now
|
moment = _clock.now() if now is None else now
|
||||||
spans = sources.clip(sources.collect(day, project), (float("-inf"), moment))
|
return carry.balance(day, moment).counted(project.daily_on(day))
|
||||||
seconds = union_seconds(spans)
|
|
||||||
return min(int(seconds // 60), project.daily_on(day))
|
|
||||||
|
def unbacked_minutes_for(day: date, now: float | None = None) -> int:
|
||||||
|
"""Whole minutes of timer time on ``day`` that the gate did not count.
|
||||||
|
|
||||||
|
0 with no project, before :data:`~focus_project.sources.activity.BACKING_FROM`,
|
||||||
|
or with an unreadable registry (logged).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
project = _api.active(day)
|
||||||
|
if project is None:
|
||||||
|
return 0
|
||||||
|
lo, hi = sources.day_bounds(day)
|
||||||
|
bounds = (lo, min(hi, _clock.now() if now is None else now))
|
||||||
|
unbacked = sources.clip(activity.split_timer(day, project)[1], bounds)
|
||||||
|
counted = sources.clip(sources.collect(day, project), bounds)
|
||||||
|
except (RegistryError, OSError) as exc:
|
||||||
|
_LOG.warning("focus_project registry unreadable, 0 unbacked: %s", exc)
|
||||||
|
return 0
|
||||||
|
# |unbacked minus counted| = |unbacked union counted| - |counted|.
|
||||||
|
gap = union_seconds(unbacked + counted) - union_seconds(counted)
|
||||||
|
return int(gap // 60)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["minutes_for", "unbacked_minutes_for", "union_seconds"]
|
||||||
|
|||||||
@ -8,23 +8,41 @@ starts a fresh one. Time after the last check-in is not credited until the
|
|||||||
next event vouches for it. Every other source (tutor, sql_drills, ...) is a
|
next event vouches for it. Every other source (tutor, sql_drills, ...) is a
|
||||||
heartbeat covering the five minutes before it. Only events recorded for
|
heartbeat covering the five minutes before it. Only events recorded for
|
||||||
``project`` count.
|
``project`` count.
|
||||||
|
|
||||||
|
0.8.0: a ``tutor`` ``heartbeat`` (a graded check-in card) is also a beat of
|
||||||
|
the open segment, like a ``checkin``: within :data:`MAX_GAP_S` of the last
|
||||||
|
beat it extends the segment; later it ends the segment at that last beat. It
|
||||||
|
never opens a segment. Other heartbeat sources never chain the timer.
|
||||||
|
|
||||||
|
0.10.0 anti-cheat (days from :data:`BACKING_FROM`): a timer span earns only
|
||||||
|
if real activity of the same project backs it, whole span or nothing. A
|
||||||
|
backing event is a ``tutor``/``sql_drills``/``leetcode`` heartbeat or an
|
||||||
|
accepted LeetCode submission (leetcode projects only); ``browser`` never
|
||||||
|
backs. A span ``(a, b)`` is backed if a backing event lies in
|
||||||
|
``[a - 300, b]``, or between two consecutive backing events at most
|
||||||
|
:data:`BRIDGE_S` apart. Check-ins alone earn 0. Earlier days keep the old
|
||||||
|
rule (every span counts), so paid days never change.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
import importlib
|
||||||
|
from itertools import pairwise
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any, Final
|
||||||
|
|
||||||
from focus_project._paths import activity_path
|
from focus_project._paths import activity_path
|
||||||
from focus_project.sources import Interval, heartbeat_interval
|
from focus_project.sources import HEARTBEAT_WINDOW_S, Interval, heartbeat_interval
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from datetime import date
|
|
||||||
|
|
||||||
from focus_project._model import Project
|
from focus_project._model import Project
|
||||||
|
|
||||||
MAX_GAP_S = 600.0
|
MAX_GAP_S = 600.0
|
||||||
|
BACKING_FROM: Final = date(2026, 11, 8)
|
||||||
|
BACKING_SOURCES: Final = frozenset({"tutor", "sql_drills", "leetcode"})
|
||||||
|
BRIDGE_S: Final = 1200.0
|
||||||
_LOG = logging.getLogger(__name__)
|
_LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@ -55,26 +73,92 @@ def read_events(project_id: str) -> list[dict[str, Any]]:
|
|||||||
|
|
||||||
|
|
||||||
def timer_intervals(events: list[dict[str, Any]]) -> list[Interval]:
|
def timer_intervals(events: list[dict[str, Any]]) -> list[Interval]:
|
||||||
"""Credited spans of the ``timer`` events (already time-ordered)."""
|
"""Credited spans of the ``timer`` events and tutor beats (time-ordered)."""
|
||||||
spans: list[Interval] = []
|
spans: list[Interval] = []
|
||||||
last: float | None = None # the last event of the open segment
|
last: float | None = None # the last beat of the open segment
|
||||||
for event in events:
|
for event in events:
|
||||||
ts = float(event["ts"])
|
ts = float(event["ts"])
|
||||||
kind = event.get("kind")
|
kind = event.get("kind")
|
||||||
if kind not in {"start", "checkin", "stop"}:
|
beat = _is_beat(event)
|
||||||
|
if not beat and kind not in {"start", "checkin", "stop"}:
|
||||||
continue
|
continue
|
||||||
|
on_time = False
|
||||||
if last is not None and ts - last <= MAX_GAP_S:
|
if last is not None and ts - last <= MAX_GAP_S:
|
||||||
spans.append((last, ts))
|
spans.append((last, ts))
|
||||||
last = None if kind == "stop" else ts
|
on_time = True
|
||||||
|
# A beat never opens a segment; a stop closes it; start/checkin open it.
|
||||||
|
keep = on_time if beat else kind != "stop"
|
||||||
|
last = ts if keep else None
|
||||||
return spans
|
return spans
|
||||||
|
|
||||||
|
|
||||||
def intervals(day: date, project: Project) -> list[Interval]: # noqa: ARG001
|
def _is_beat(event: dict[str, Any]) -> bool:
|
||||||
"""Timer segments plus heartbeats of ``project`` (the registry clips)."""
|
"""A tutor heartbeat: it chains the timer segment (0.8.0)."""
|
||||||
|
return event.get("source") == "tutor" and event.get("kind") == "heartbeat"
|
||||||
|
|
||||||
|
|
||||||
|
def sessions(project_id: str) -> list[Interval]:
|
||||||
|
"""Contiguous focus sessions of ``project_id``, time-ordered, as (start, end).
|
||||||
|
|
||||||
|
Built from the same timer/tutor-beat segments (and so the same
|
||||||
|
:data:`MAX_GAP_S` rule) that credit minutes; spans that touch are one
|
||||||
|
session, which ends at its last beat.
|
||||||
|
"""
|
||||||
|
events = read_events(project_id)
|
||||||
|
timer = [e for e in events if e.get("source") == "timer" or _is_beat(e)]
|
||||||
|
merged: list[Interval] = []
|
||||||
|
for lo, hi in timer_intervals(timer):
|
||||||
|
if merged and lo <= merged[-1][1]:
|
||||||
|
merged[-1] = (merged[-1][0], max(hi, merged[-1][1]))
|
||||||
|
else:
|
||||||
|
merged.append((lo, hi))
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def backing_times(events: list[dict[str, Any]], project: Project) -> list[float]:
|
||||||
|
"""Sorted times of the events that may back a timer span.
|
||||||
|
|
||||||
|
The LeetCode ledger is read in its own guard: if it fails, only the
|
||||||
|
submissions are lost, never the heartbeats (fail closed, narrow).
|
||||||
|
"""
|
||||||
|
times = [float(e["ts"]) for e in events if e.get("source") in BACKING_SOURCES]
|
||||||
|
try:
|
||||||
|
# Imported lazily: it pulls in earned_time, and the timer must keep
|
||||||
|
# working (heartbeat backing only) where that is not installed.
|
||||||
|
leetcode = importlib.import_module("focus_project.sources.leetcode")
|
||||||
|
times += leetcode.submission_times(project)
|
||||||
|
except Exception: # noqa: BLE001 (lose the submissions, keep the beats)
|
||||||
|
_LOG.exception("leetcode backing unavailable, heartbeats only")
|
||||||
|
return sorted(times)
|
||||||
|
|
||||||
|
|
||||||
|
def is_backed(span: Interval, times: list[float]) -> bool:
|
||||||
|
"""Whether a backing event lies in or just before ``span``, or bridges it."""
|
||||||
|
lo, hi = span
|
||||||
|
if any(lo - HEARTBEAT_WINDOW_S <= t <= hi for t in times):
|
||||||
|
return True
|
||||||
|
return any(a <= lo and hi <= b and b - a <= BRIDGE_S for a, b in pairwise(times))
|
||||||
|
|
||||||
|
|
||||||
|
def split_timer(
|
||||||
|
day: date, project: Project, events: list[dict[str, Any]] | None = None
|
||||||
|
) -> tuple[list[Interval], list[Interval]]:
|
||||||
|
"""``(backed, unbacked)`` timer spans of ``project``, judged for ``day``."""
|
||||||
|
found = read_events(project.id) if events is None else events
|
||||||
|
chain = [e for e in found if e.get("source") == "timer" or _is_beat(e)]
|
||||||
|
spans = timer_intervals(chain)
|
||||||
|
if day < BACKING_FROM:
|
||||||
|
return spans, []
|
||||||
|
times = backing_times(found, project)
|
||||||
|
backed = [s for s in spans if is_backed(s, times)]
|
||||||
|
return backed, [s for s in spans if s not in backed]
|
||||||
|
|
||||||
|
|
||||||
|
def intervals(day: date, project: Project) -> list[Interval]:
|
||||||
|
"""Backed timer segments plus heartbeats of ``project`` (the registry clips)."""
|
||||||
events = read_events(project.id)
|
events = read_events(project.id)
|
||||||
timer = [e for e in events if e.get("source") == "timer"]
|
|
||||||
beats = [e for e in events if e.get("source") != "timer"]
|
beats = [e for e in events if e.get("source") != "timer"]
|
||||||
return [
|
return [
|
||||||
*timer_intervals(timer),
|
*split_timer(day, project, events)[0],
|
||||||
*(heartbeat_interval(float(e["ts"])) for e in beats),
|
*(heartbeat_interval(float(e["ts"])) for e in beats),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -55,8 +55,8 @@ def _submitted_at(row: object, key: bytes) -> float | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def intervals(day: date, project: Project) -> list[Interval]: # noqa: ARG001
|
def submission_times(project: Project) -> list[float]:
|
||||||
"""One heartbeat per accepted submission (the registry clips to the day)."""
|
"""Times of verified accepted submissions; [] for a non-leetcode project."""
|
||||||
if not any("leetcode" in pattern.lower() for pattern in project.site_patterns):
|
if not any("leetcode" in pattern.lower() for pattern in project.site_patterns):
|
||||||
return []
|
return []
|
||||||
key = ledger.read_key()
|
key = ledger.read_key()
|
||||||
@ -64,4 +64,9 @@ def intervals(day: date, project: Project) -> list[Interval]: # noqa: ARG001
|
|||||||
_LOG.warning("leetcode source: no signing key, contributes 0")
|
_LOG.warning("leetcode source: no signing key, contributes 0")
|
||||||
return []
|
return []
|
||||||
stamps = (_submitted_at(row, key) for row in ledger.read_rows(ledger_file()))
|
stamps = (_submitted_at(row, key) for row in ledger.read_rows(ledger_file()))
|
||||||
return [heartbeat_interval(ts) for ts in stamps if ts is not None]
|
return [ts for ts in stamps if ts is not None]
|
||||||
|
|
||||||
|
|
||||||
|
def intervals(day: date, project: Project) -> list[Interval]: # noqa: ARG001
|
||||||
|
"""One heartbeat per accepted submission (the registry clips to the day)."""
|
||||||
|
return [heartbeat_interval(ts) for ts in submission_times(project)]
|
||||||
|
|||||||
@ -3,31 +3,79 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import date # noqa: TC003
|
import logging
|
||||||
from typing import Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from focus_project import _api, minutes
|
from focus_project import _api, _milestones, carry, evidence, minutes
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from focus_project._model import Project
|
||||||
|
|
||||||
|
_LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _milestone_block(project: Project) -> tuple[list[dict[str, Any]], bool]:
|
||||||
|
"""Milestone rows and whether any is red; unreadable evidence shows none.
|
||||||
|
|
||||||
|
Display path, so it fails open (logged): the lock path, ``owed_lock``,
|
||||||
|
has its own handling.
|
||||||
|
"""
|
||||||
|
now = evidence.clock_now()
|
||||||
|
try:
|
||||||
|
items = _milestones.milestones(project, now)
|
||||||
|
except OSError as exc:
|
||||||
|
_LOG.warning("interview evidence unreadable, no milestones shown: %s", exc)
|
||||||
|
return [], False
|
||||||
|
return [m.to_dict() for m in items], any(m.red for m in items)
|
||||||
|
|
||||||
|
|
||||||
def status(day: date) -> dict[str, Any]:
|
def status(day: date) -> dict[str, Any]:
|
||||||
"""Progress of the project active on ``day``; ``project`` is None if none."""
|
"""Progress of the project active on ``day``; ``project`` is None if none.
|
||||||
|
|
||||||
|
``minutes`` is what was really worked plus what the day took from the
|
||||||
|
bank, so it may exceed ``daily_minutes`` (121/120); ``counted_minutes``
|
||||||
|
is the capped figure that pays. ``unbacked_minutes`` is timer time the
|
||||||
|
anti-cheat gate refused (shown as "not counted", never paid).
|
||||||
|
"""
|
||||||
project = _api.active(day)
|
project = _api.active(day)
|
||||||
if project is None:
|
if project is None:
|
||||||
return {
|
return {
|
||||||
"project": None,
|
"project": None,
|
||||||
"name": None,
|
"name": None,
|
||||||
"minutes": 0,
|
"minutes": 0,
|
||||||
|
"own_minutes": 0,
|
||||||
|
"counted_minutes": 0,
|
||||||
|
"carried_in": 0,
|
||||||
|
"carry_out": 0,
|
||||||
"daily_minutes": None,
|
"daily_minutes": None,
|
||||||
"unlock_minutes": None,
|
"unlock_minutes": None,
|
||||||
"ends": None,
|
"ends": None,
|
||||||
"replaces": [],
|
"replaces": [],
|
||||||
|
"unbacked_minutes": 0,
|
||||||
|
"milestones": [],
|
||||||
|
"missed": False,
|
||||||
}
|
}
|
||||||
|
daily = project.daily_on(day)
|
||||||
|
bank = carry.balance(day)
|
||||||
|
rows, missed = _milestone_block(project)
|
||||||
return {
|
return {
|
||||||
"project": project.id,
|
"project": project.id,
|
||||||
"name": project.name,
|
"name": project.name,
|
||||||
"minutes": minutes.minutes_for(day),
|
# Real minutes behind the day (own + banked in), never capped: the
|
||||||
"daily_minutes": project.daily_on(day),
|
# number to show. Pay is ``counted_minutes``; earned_time caps it.
|
||||||
|
"minutes": bank.own + bank.carried_in,
|
||||||
|
"own_minutes": bank.own,
|
||||||
|
"counted_minutes": bank.counted(daily),
|
||||||
|
"carried_in": bank.carried_in,
|
||||||
|
"carry_out": bank.carry_out,
|
||||||
|
"daily_minutes": daily,
|
||||||
"unlock_minutes": project.unlock_on(day),
|
"unlock_minutes": project.unlock_on(day),
|
||||||
"ends": project.last_day.isoformat(),
|
"ends": project.last_day.isoformat(),
|
||||||
"replaces": list(project.replaces),
|
"replaces": list(project.replaces_on(day)),
|
||||||
|
"unbacked_minutes": minutes.unbacked_minutes_for(day),
|
||||||
|
# Interview projects: [{id, due ISO, state}] and whether any is red.
|
||||||
|
"milestones": rows,
|
||||||
|
"missed": missed,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,7 +11,7 @@ from typing import TYPE_CHECKING
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from focus_project import _clock
|
from focus_project import _clock, _paths, checks
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@ -40,3 +40,16 @@ def _far_future_clock(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||||||
def data(tmp_path: Path) -> Path:
|
def data(tmp_path: Path) -> Path:
|
||||||
"""The redirected data directory (may not exist yet)."""
|
"""The redirected data directory (may not exist yet)."""
|
||||||
return tmp_path / "data"
|
return tmp_path / "data"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _interview_isolation(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, _redirect_state: None
|
||||||
|
) -> None:
|
||||||
|
"""Evidence lives under the redirected data dir; sources are a private copy.
|
||||||
|
|
||||||
|
Fails closed (R3): if the path seam ever resolves outside ``tmp_path`` the
|
||||||
|
test errors before it can write a production evidence row.
|
||||||
|
"""
|
||||||
|
assert _paths.evidence_dir().is_relative_to(tmp_path)
|
||||||
|
monkeypatch.setattr(checks, "SOURCES", dict(checks.SOURCES))
|
||||||
|
|||||||
65
focus_project/focus_project/tests/helpers.py
Normal file
65
focus_project/focus_project/tests/helpers.py
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""Shared fixtures-as-functions for the interview-prep tests."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime, time, timedelta
|
||||||
|
import json
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import focus_project as fp
|
||||||
|
from focus_project._paths import activity_path
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
START = date(2026, 11, 10) # Tuesday: D1
|
||||||
|
TEST_DAY = date(2026, 11, 27) # Friday: T
|
||||||
|
TEST_AT = datetime.combine(TEST_DAY, time(10, 0))
|
||||||
|
SAT1 = date(2026, 11, 14)
|
||||||
|
SAT2 = date(2026, 11, 21)
|
||||||
|
|
||||||
|
|
||||||
|
def at(day: int, hour: int = 0, minute: int = 0) -> datetime:
|
||||||
|
"""A November 2026 local datetime."""
|
||||||
|
return datetime.combine(date(2026, 11, day), time(hour, minute))
|
||||||
|
|
||||||
|
|
||||||
|
def make(pid: str = "iv", /, **kw: object) -> fp.Project:
|
||||||
|
"""Create an interview project D1 = Nov 10, T = Nov 27."""
|
||||||
|
args: dict[str, object] = {
|
||||||
|
"name": "Interview",
|
||||||
|
"start": START,
|
||||||
|
"end": TEST_DAY,
|
||||||
|
"replaces": ["leetcode", "reading", "tutor"],
|
||||||
|
"daily_minutes": 120,
|
||||||
|
"unlock_minutes": 30,
|
||||||
|
"kind": "interview",
|
||||||
|
"test_at": TEST_AT,
|
||||||
|
"invite_url": "https://app.codility.com/c/run/abc",
|
||||||
|
}
|
||||||
|
args.update(kw)
|
||||||
|
return fp.create_project(pid, **args) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
def shot(tmp_path: Path, name: str = "rules.png") -> Path:
|
||||||
|
"""A non-empty screenshot stand-in."""
|
||||||
|
path = tmp_path / name
|
||||||
|
path.write_bytes(b"\x89PNG rules")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def states(project: fp.Project, now: datetime) -> dict[str, str]:
|
||||||
|
"""Milestone id -> state."""
|
||||||
|
return {m.id: m.state for m in fp.milestones(project, now)}
|
||||||
|
|
||||||
|
|
||||||
|
def session(start: datetime, minutes: int, *, step: int = 5, pid: str = "iv") -> None:
|
||||||
|
"""A timer session start -> check-in every ``step`` min -> stop."""
|
||||||
|
activity_path().parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with activity_path().open("a", encoding="utf-8") as handle:
|
||||||
|
for offset in range(0, minutes + 1, step):
|
||||||
|
kind = "start" if offset == 0 else "checkin"
|
||||||
|
when = start + timedelta(minutes=offset)
|
||||||
|
row = {"ts": when.timestamp(), "source": "timer", "kind": kind}
|
||||||
|
handle.write(json.dumps({**row, "project": pid, "dev": "pc"}) + "\n")
|
||||||
@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import date
|
from datetime import date, datetime
|
||||||
import importlib
|
import importlib
|
||||||
import json
|
import json
|
||||||
from pathlib import Path # noqa: TC003
|
from pathlib import Path # noqa: TC003
|
||||||
@ -13,12 +13,16 @@ import time
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
import focus_project as fp
|
import focus_project as fp
|
||||||
from focus_project import _clock, minutes
|
from focus_project import _clock
|
||||||
from focus_project._paths import data_dir
|
from focus_project._paths import data_dir
|
||||||
|
|
||||||
D1 = date(2026, 11, 2)
|
D1 = date(2026, 11, 2)
|
||||||
|
|
||||||
|
|
||||||
|
def _at(hour: int, minute: int) -> float:
|
||||||
|
return datetime(2026, 11, 2, hour, minute).astimezone().timestamp()
|
||||||
|
|
||||||
|
|
||||||
def _project() -> None:
|
def _project() -> None:
|
||||||
fp.create_project(
|
fp.create_project(
|
||||||
"hr",
|
"hr",
|
||||||
@ -115,19 +119,27 @@ def test_record_activity_concurrent_appends(data: Path) -> None:
|
|||||||
assert all(json.loads(x)["source"] == "tutor" for x in lines)
|
assert all(json.loads(x)["source"] == "tutor" for x in lines)
|
||||||
|
|
||||||
|
|
||||||
def test_status_active_uses_minutes_module(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_status_active_reports_the_worked_minutes() -> None:
|
||||||
_project()
|
_project()
|
||||||
assert fp.status(D1)["minutes"] == 0 # the stub
|
assert fp.status(D1)["minutes"] == 0
|
||||||
monkeypatch.setattr(minutes, "minutes_for", lambda _day: 45)
|
for k in range(1, 10): # nine heartbeats, five minutes each
|
||||||
|
fp.record_activity("tutor", "heartbeat", ts=_at(8, 5 * k))
|
||||||
st = fp.status(D1)
|
st = fp.status(D1)
|
||||||
assert st == {
|
assert st == {
|
||||||
"project": "hr",
|
"project": "hr",
|
||||||
"name": "HackerRank prep",
|
"name": "HackerRank prep",
|
||||||
"minutes": 45,
|
"minutes": 45,
|
||||||
|
"own_minutes": 45,
|
||||||
|
"counted_minutes": 45,
|
||||||
|
"carried_in": 0,
|
||||||
|
"carry_out": 0,
|
||||||
"daily_minutes": 120,
|
"daily_minutes": 120,
|
||||||
"unlock_minutes": 30,
|
"unlock_minutes": 30,
|
||||||
"ends": "2026-11-05",
|
"ends": "2026-11-05",
|
||||||
"replaces": ["leetcode"],
|
"replaces": ["leetcode"],
|
||||||
|
"unbacked_minutes": 0,
|
||||||
|
"milestones": [],
|
||||||
|
"missed": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -141,10 +153,17 @@ def test_status_inactive() -> None:
|
|||||||
"project",
|
"project",
|
||||||
"name",
|
"name",
|
||||||
"minutes",
|
"minutes",
|
||||||
|
"own_minutes",
|
||||||
|
"counted_minutes",
|
||||||
|
"carried_in",
|
||||||
|
"carry_out",
|
||||||
"daily_minutes",
|
"daily_minutes",
|
||||||
"unlock_minutes",
|
"unlock_minutes",
|
||||||
"ends",
|
"ends",
|
||||||
"replaces",
|
"replaces",
|
||||||
|
"unbacked_minutes",
|
||||||
|
"milestones",
|
||||||
|
"missed",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
181
focus_project/focus_project/tests/test_backing.py
Normal file
181
focus_project/focus_project/tests/test_backing.py
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""0.10.0: a timer span earns only if a same-project beat backs it."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import focus_project as fp
|
||||||
|
from focus_project import _api, minutes
|
||||||
|
from focus_project.sources import activity
|
||||||
|
from focus_project.tests import test_minutes as tm
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
DAY = date(2026, 11, 8) # BACKING_FROM
|
||||||
|
|
||||||
|
|
||||||
|
def at(hour: int, minute: int = 0) -> float:
|
||||||
|
return tm.at(hour, minute, day=DAY)
|
||||||
|
|
||||||
|
|
||||||
|
def proj() -> None:
|
||||||
|
fp.create_project(
|
||||||
|
"hr",
|
||||||
|
name="HR prep",
|
||||||
|
start=DAY,
|
||||||
|
end=date(2026, 11, 12),
|
||||||
|
replaces=["leetcode"],
|
||||||
|
daily_minutes=120,
|
||||||
|
unlock_minutes=30,
|
||||||
|
site_patterns=["leetcode.com"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def rows(*items: tuple[str, str, float]) -> None:
|
||||||
|
tm.events(*items)
|
||||||
|
|
||||||
|
|
||||||
|
def timer_only() -> None:
|
||||||
|
rows(
|
||||||
|
("timer", "start", at(9)),
|
||||||
|
("timer", "checkin", at(9, 5)),
|
||||||
|
("timer", "stop", at(9, 10)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_timer_only_stretch_pays_zero_and_shows_unbacked() -> None:
|
||||||
|
proj()
|
||||||
|
timer_only()
|
||||||
|
assert minutes.minutes_for(DAY) == 0
|
||||||
|
assert minutes.unbacked_minutes_for(DAY) == 10
|
||||||
|
assert fp.status(DAY)["unbacked_minutes"] == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_beat_in_the_span_backs_all_of_it() -> None:
|
||||||
|
proj()
|
||||||
|
timer_only()
|
||||||
|
rows(("sql_drills", "heartbeat", at(9, 5)))
|
||||||
|
assert minutes.unbacked_minutes_for(DAY) == 0
|
||||||
|
assert minutes.minutes_for(DAY) == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_beat_backs_only_the_spans_it_touches() -> None:
|
||||||
|
proj()
|
||||||
|
timer_only()
|
||||||
|
rows(("sql_drills", "heartbeat", at(9, 7)))
|
||||||
|
# 9:00-9:05 stays unbacked, but the beat itself pays 9:02-9:07.
|
||||||
|
assert minutes.unbacked_minutes_for(DAY) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_beat_at_the_end_of_the_span_backs_it() -> None:
|
||||||
|
proj()
|
||||||
|
rows(
|
||||||
|
("timer", "start", at(9)),
|
||||||
|
("timer", "stop", at(9, 10)),
|
||||||
|
("sql_drills", "heartbeat", at(9, 10)),
|
||||||
|
)
|
||||||
|
assert minutes.unbacked_minutes_for(DAY) == 0
|
||||||
|
assert minutes.minutes_for(DAY) == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_beat_just_before_the_span_backs_it() -> None:
|
||||||
|
proj()
|
||||||
|
rows(
|
||||||
|
("sql_drills", "heartbeat", at(8, 56)), # within 300 s before 9:00
|
||||||
|
("timer", "start", at(9)),
|
||||||
|
("timer", "stop", at(9, 5)),
|
||||||
|
)
|
||||||
|
assert minutes.unbacked_minutes_for(DAY) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_late_beat_does_not_back_it() -> None:
|
||||||
|
proj()
|
||||||
|
timer_only()
|
||||||
|
rows(("sql_drills", "heartbeat", at(9, 40)))
|
||||||
|
assert minutes.unbacked_minutes_for(DAY) == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_beats_up_to_twenty_minutes_apart_bridge_the_span() -> None:
|
||||||
|
proj()
|
||||||
|
rows(
|
||||||
|
("tutor", "heartbeat", at(8, 50)),
|
||||||
|
("timer", "start", at(9)),
|
||||||
|
("timer", "checkin", at(9, 5)),
|
||||||
|
("timer", "stop", at(9, 8)),
|
||||||
|
("tutor", "heartbeat", at(9, 10)),
|
||||||
|
)
|
||||||
|
assert minutes.unbacked_minutes_for(DAY) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_beats_over_twenty_minutes_apart_do_not_bridge() -> None:
|
||||||
|
proj()
|
||||||
|
rows(
|
||||||
|
("tutor", "heartbeat", at(8, 30)),
|
||||||
|
("timer", "start", at(9)),
|
||||||
|
("timer", "stop", at(9, 8)),
|
||||||
|
("tutor", "heartbeat", at(9, 40)),
|
||||||
|
)
|
||||||
|
assert minutes.unbacked_minutes_for(DAY) == 8
|
||||||
|
|
||||||
|
|
||||||
|
def test_browser_never_backs() -> None:
|
||||||
|
proj()
|
||||||
|
timer_only()
|
||||||
|
rows(("browser", "heartbeat", at(9, 7)))
|
||||||
|
# The beat pays its own 9:02-9:07 but backs no timer span.
|
||||||
|
assert minutes.unbacked_minutes_for(DAY) == 5
|
||||||
|
assert minutes.minutes_for(DAY) == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_days_before_backing_from_keep_the_old_rule() -> None:
|
||||||
|
fp.create_project(
|
||||||
|
"hr",
|
||||||
|
name="Old",
|
||||||
|
start=date(2026, 11, 2),
|
||||||
|
end=date(2026, 11, 5),
|
||||||
|
replaces=["leetcode"],
|
||||||
|
daily_minutes=120,
|
||||||
|
unlock_minutes=30,
|
||||||
|
site_patterns=["leetcode.com"],
|
||||||
|
)
|
||||||
|
old = date(2026, 11, 2)
|
||||||
|
rows(("timer", "start", tm.at(9)), ("timer", "stop", tm.at(9, 10)))
|
||||||
|
assert minutes.minutes_for(old) == 10
|
||||||
|
assert minutes.unbacked_minutes_for(old) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_project_is_zero_unbacked() -> None:
|
||||||
|
assert minutes.unbacked_minutes_for(DAY) == 0
|
||||||
|
assert fp.status(DAY)["unbacked_minutes"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_unbacked_is_clipped_at_now() -> None:
|
||||||
|
proj()
|
||||||
|
timer_only()
|
||||||
|
assert minutes.unbacked_minutes_for(DAY, now=at(9, 4)) == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_split_timer_returns_backed_and_unbacked() -> None:
|
||||||
|
proj()
|
||||||
|
timer_only()
|
||||||
|
project = _api.active(DAY)
|
||||||
|
assert project is not None
|
||||||
|
backed, unbacked = activity.split_timer(DAY, project)
|
||||||
|
assert backed == []
|
||||||
|
assert unbacked == [(at(9), at(9, 5)), (at(9, 5), at(9, 10))]
|
||||||
|
|
||||||
|
|
||||||
|
def test_backing_from_is_exported() -> None:
|
||||||
|
assert date(2026, 11, 8) == fp.BACKING_FROM
|
||||||
|
assert fp.unbacked_minutes_for is minutes.unbacked_minutes_for
|
||||||
|
|
||||||
|
|
||||||
|
def test_registry_error_reads_zero(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
def boom(_day: date) -> None:
|
||||||
|
raise OSError
|
||||||
|
|
||||||
|
monkeypatch.setattr("focus_project._api.active", boom)
|
||||||
|
assert minutes.unbacked_minutes_for(DAY) == 0
|
||||||
131
focus_project/focus_project/tests/test_backing_edges.py
Normal file
131
focus_project/focus_project/tests/test_backing_edges.py
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""More 0.10.0 backing cases: sources, projects, the cutover midnight, chains.
|
||||||
|
|
||||||
|
The core rule lives in ``test_backing.py``; these pin the edges the spec
|
||||||
|
named (C5-C7) with leetcode-ledger and other-project fixtures.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
import json
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import focus_project as fp
|
||||||
|
from focus_project import minutes
|
||||||
|
from focus_project._paths import activity_path
|
||||||
|
from focus_project.tests.test_leetcode_source import ac_row, write
|
||||||
|
from focus_project.tests.test_minutes import at
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
DAY = date(2026, 11, 9) # after the cutover
|
||||||
|
PRE = date(2026, 11, 7) # before it
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
|
"""Nothing may fall back to the real home directory."""
|
||||||
|
monkeypatch.setenv("HOME", str(tmp_path / "home"))
|
||||||
|
|
||||||
|
|
||||||
|
def make(pid: str = "hr", patterns: tuple[str, ...] = ("leetcode.com",)) -> None:
|
||||||
|
fp.create_project(
|
||||||
|
pid,
|
||||||
|
name=pid,
|
||||||
|
start=date(2026, 11, 6),
|
||||||
|
end=date(2026, 11, 12),
|
||||||
|
replaces=["leetcode"],
|
||||||
|
daily_minutes=240,
|
||||||
|
unlock_minutes=30,
|
||||||
|
site_patterns=list(patterns),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def rows(*items: tuple[str, str, float] | tuple[str, str, float, str]) -> None:
|
||||||
|
"""Append ``(source, kind, ts[, project])`` events (project default hr)."""
|
||||||
|
activity_path().parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with activity_path().open("a", encoding="utf-8") as handle:
|
||||||
|
for item in items:
|
||||||
|
source, kind, ts = item[:3]
|
||||||
|
pid = item[3] if len(item) == 4 else "hr"
|
||||||
|
row = {"ts": ts, "source": source, "kind": kind, "project": pid}
|
||||||
|
handle.write(json.dumps(row) + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
def timer(day: date, *marks: tuple[int, int]) -> None:
|
||||||
|
"""A segment: start at the first mark, check-ins, stop at the last."""
|
||||||
|
kinds = ["start", *["checkin"] * (len(marks) - 2), "stop"]
|
||||||
|
rows(
|
||||||
|
*(
|
||||||
|
("timer", k, at(h, m, day=day))
|
||||||
|
for k, (h, m) in zip(kinds, marks, strict=True)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def beat(source: str, h: int, m: int, day: date = DAY, pid: str = "hr") -> None:
|
||||||
|
rows((source, "heartbeat", at(h, m, day=day), pid))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("source", ["sql_drills", "leetcode"])
|
||||||
|
def test_sql_drills_and_leetcode_heartbeats_back_too(source: str) -> None:
|
||||||
|
make()
|
||||||
|
timer(DAY, (9, 0), (9, 5), (9, 10))
|
||||||
|
beat(source, 9, 5)
|
||||||
|
assert minutes.minutes_for(DAY) == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_browser_and_other_project_beats_do_not_back() -> None:
|
||||||
|
make() # "other" needs no registry entry: rows are filtered by id
|
||||||
|
timer(DAY, (9, 0), (9, 5), (9, 10))
|
||||||
|
beat("browser", 9, 4)
|
||||||
|
beat("tutor", 9, 4, pid="other")
|
||||||
|
assert minutes.minutes_for(DAY) == 5 # the browser beat's own window only
|
||||||
|
assert minutes.unbacked_minutes_for(DAY) == 6 # 9:04-9:10
|
||||||
|
|
||||||
|
|
||||||
|
def test_accepted_submission_backs_a_leetcode_project(tmp_path: Path) -> None:
|
||||||
|
make()
|
||||||
|
timer(DAY, (9, 0), (9, 5), (9, 10))
|
||||||
|
write(tmp_path, ac_row(at(9, 4, day=DAY)))
|
||||||
|
assert minutes.minutes_for(DAY) == 11 # 8:59-9:10
|
||||||
|
|
||||||
|
|
||||||
|
def test_submission_does_not_back_a_non_leetcode_project(tmp_path: Path) -> None:
|
||||||
|
make("hr", ("sqlzoo.net",))
|
||||||
|
timer(DAY, (9, 0), (9, 5), (9, 10))
|
||||||
|
write(tmp_path, ac_row(at(9, 4, day=DAY)))
|
||||||
|
assert minutes.minutes_for(DAY) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_span_across_the_cutover_midnight_follows_the_day() -> None:
|
||||||
|
make()
|
||||||
|
rows(
|
||||||
|
("timer", "start", at(23, 58, day=date(2026, 11, 7))),
|
||||||
|
("timer", "checkin", at(0, 3, day=date(2026, 11, 8))),
|
||||||
|
("timer", "stop", at(0, 5, day=date(2026, 11, 8))),
|
||||||
|
)
|
||||||
|
assert minutes.minutes_for(date(2026, 11, 7)) == 2
|
||||||
|
assert minutes.minutes_for(date(2026, 11, 8)) == 0
|
||||||
|
assert minutes.unbacked_minutes_for(date(2026, 11, 8)) == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_tutor_chained_span_is_backed_but_earlier_reading_is_not() -> None:
|
||||||
|
"""A tutor beat chains the segment (0.8.0) and backs the span it ends.
|
||||||
|
|
||||||
|
start 9:00, checkin 9:08, tutor beat 9:16: the span 9:08-9:16 ends at a
|
||||||
|
backing beat and earns; 9:00-9:08 has no beat in or 5 min before it and
|
||||||
|
nothing bridges it, so it shows as unbacked.
|
||||||
|
"""
|
||||||
|
make()
|
||||||
|
rows(
|
||||||
|
("timer", "start", at(9, 0, day=DAY)),
|
||||||
|
("timer", "checkin", at(9, 8, day=DAY)),
|
||||||
|
)
|
||||||
|
beat("tutor", 9, 16)
|
||||||
|
assert minutes.minutes_for(DAY) == 8
|
||||||
|
assert minutes.unbacked_minutes_for(DAY) == 8
|
||||||
183
focus_project/focus_project/tests/test_carry.py
Normal file
183
focus_project/focus_project/tests/test_carry.py
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""Overtime carries over: own minutes, the bank, pay and the carry ledger rows."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, timedelta
|
||||||
|
import json
|
||||||
|
from pathlib import Path # noqa: TC003
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import earned_time
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import focus_project as fp
|
||||||
|
from focus_project import carry, harvest, ledger, minutes
|
||||||
|
from focus_project.tests.test_minutes import D1, at, events, project
|
||||||
|
|
||||||
|
KEY = b"k" * 32
|
||||||
|
D2 = D1 + timedelta(days=1)
|
||||||
|
D3 = D2 + timedelta(days=1)
|
||||||
|
D4 = D3 + timedelta(days=1) # the last day of project()
|
||||||
|
D5 = D4 + timedelta(days=1) # after the end
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _key(tmp_path: Path) -> None:
|
||||||
|
(tmp_path / "hmac.key").write_bytes(KEY)
|
||||||
|
|
||||||
|
|
||||||
|
def work(day: date, mins: int) -> None:
|
||||||
|
"""``mins`` focused minutes on ``day``: tutor heartbeats every five minutes."""
|
||||||
|
events(
|
||||||
|
*(
|
||||||
|
("tutor", "heartbeat", at(8, 0, day=day) + 300 * k)
|
||||||
|
for k in range(1, mins // 5 + 1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def rows() -> list[dict[str, Any]]:
|
||||||
|
out: list[dict[str, Any]] = json.loads(ledger.ledger_path().read_text())["entries"]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def test_own_minutes_are_not_capped_but_pay_is() -> None:
|
||||||
|
project()
|
||||||
|
work(D1, 125)
|
||||||
|
assert carry.own_minutes(D1) == 125
|
||||||
|
assert minutes.minutes_for(D1) == 120
|
||||||
|
|
||||||
|
|
||||||
|
def test_overtime_banks_into_the_next_day() -> None:
|
||||||
|
project()
|
||||||
|
work(D1, 125)
|
||||||
|
assert carry.balance(D1) == carry.Balance(own=125, carried_in=0, carry_out=5)
|
||||||
|
assert carry.balance(D2) == carry.Balance(own=0, carried_in=5, carry_out=0)
|
||||||
|
assert minutes.minutes_for(D2) == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_carry_adds_to_own_minutes_on_the_next_day() -> None:
|
||||||
|
project()
|
||||||
|
work(D1, 125)
|
||||||
|
work(D2, 100)
|
||||||
|
assert minutes.minutes_for(D2) == 105
|
||||||
|
assert carry.balance(D2).carry_out == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_bank_is_capped_at_sixty() -> None:
|
||||||
|
project()
|
||||||
|
work(D1, 200)
|
||||||
|
assert carry.balance(D1).carry_out == carry.BANK_CAP == 60
|
||||||
|
|
||||||
|
|
||||||
|
def test_overtime_chains_over_days() -> None:
|
||||||
|
project()
|
||||||
|
work(D1, 130)
|
||||||
|
work(D2, 125)
|
||||||
|
assert carry.balance(D2) == carry.Balance(own=125, carried_in=10, carry_out=15)
|
||||||
|
assert minutes.minutes_for(D3) == 15
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_bank_never_crosses_the_project_end() -> None:
|
||||||
|
project()
|
||||||
|
work(D4, 150)
|
||||||
|
assert carry.balance(D4).carry_out == 30
|
||||||
|
assert minutes.minutes_for(D5) == 0
|
||||||
|
assert carry.balance(D5) == carry.Balance(0, 0, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_first_day_has_nothing_banked_in() -> None:
|
||||||
|
project()
|
||||||
|
assert carry.balance(D1 - timedelta(days=1)).carried_in == 0
|
||||||
|
assert carry.balance(D1).carried_in == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_past_target_is_used_for_a_past_day() -> None:
|
||||||
|
project()
|
||||||
|
work(D1, 125)
|
||||||
|
fp.update_project("hr", today=D2, daily_minutes=100, unlock_minutes=30)
|
||||||
|
assert carry.balance(D1).carry_out == 5 # D1 still judged at 120
|
||||||
|
assert carry.balance(D2).carried_in == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_reports_real_minutes_and_the_bank() -> None:
|
||||||
|
project()
|
||||||
|
work(D1, 125)
|
||||||
|
shown = fp.status(D1)
|
||||||
|
assert shown["minutes"] == 125
|
||||||
|
assert shown["own_minutes"] == 125
|
||||||
|
assert shown["counted_minutes"] == 120
|
||||||
|
assert (shown["carried_in"], shown["carry_out"]) == (0, 5)
|
||||||
|
nxt = fp.status(D2)
|
||||||
|
assert (nxt["minutes"], nxt["carried_in"]) == (5, 5)
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_without_a_project_has_zero_bank() -> None:
|
||||||
|
shown = fp.status(D1)
|
||||||
|
assert (shown["own_minutes"], shown["carried_in"], shown["carry_out"]) == (0, 0, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unreadable_registry_banks_nothing(data: Path) -> None:
|
||||||
|
data.mkdir(parents=True)
|
||||||
|
(data / "projects.json").write_text("{")
|
||||||
|
assert carry.balance(D2) == carry.Balance(0, 0, 0)
|
||||||
|
assert carry.own_minutes(D2) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_harvest_pays_own_rows_then_one_carry_row() -> None:
|
||||||
|
project()
|
||||||
|
work(D1, 125)
|
||||||
|
work(D2, 100)
|
||||||
|
harvest.harvest(now=at(23, 0, day=D2))
|
||||||
|
got = {r["entry_id"]: r for r in rows()}
|
||||||
|
assert got["hr-20261102-m120"]["detail"]["minutes"] == 120
|
||||||
|
assert got["hr-20261103-carry"]["detail"]["minutes"] == 5
|
||||||
|
assert got["hr-20261103-carry"]["detail"]["carry"] is True
|
||||||
|
assert got["hr-20261103-m100"]["detail"]["minutes"] == 100
|
||||||
|
assert all(earned_time.verified(r, KEY) for r in got.values())
|
||||||
|
assert ledger.paid_minutes(rows(), KEY, "hr", D2) == 105
|
||||||
|
|
||||||
|
|
||||||
|
def test_pay_never_exceeds_the_daily_target() -> None:
|
||||||
|
project()
|
||||||
|
work(D1, 125)
|
||||||
|
work(D2, 130)
|
||||||
|
harvest.harvest(now=at(23, 0, day=D2))
|
||||||
|
assert ledger.paid_minutes(rows(), KEY, "hr", D2) == 120
|
||||||
|
assert minutes.minutes_for(D2) == 120
|
||||||
|
|
||||||
|
|
||||||
|
def test_carry_is_paid_at_the_start_of_the_day_and_not_again() -> None:
|
||||||
|
project()
|
||||||
|
work(D1, 125)
|
||||||
|
harvest.harvest(now=at(0, 5, day=D2))
|
||||||
|
before = rows()
|
||||||
|
assert [r["entry_id"] for r in before if r["day"] == "2026-11-03"] == [
|
||||||
|
"hr-20261103-carry"
|
||||||
|
]
|
||||||
|
harvest.harvest(now=at(0, 6, day=D2))
|
||||||
|
assert rows() == before
|
||||||
|
|
||||||
|
|
||||||
|
def test_late_data_for_yesterday_pays_only_the_carry_delta() -> None:
|
||||||
|
project()
|
||||||
|
work(D1, 125)
|
||||||
|
harvest.harvest(now=at(9, 0, day=D2))
|
||||||
|
work(D1, 130) # a late phone sync: five more minutes
|
||||||
|
harvest.harvest(now=at(9, 5, day=D2))
|
||||||
|
paid = [r for r in rows() if r["detail"].get("carry")]
|
||||||
|
assert [r["entry_id"] for r in paid] == [
|
||||||
|
"hr-20261103-carry",
|
||||||
|
"hr-20261103-carry-m10",
|
||||||
|
]
|
||||||
|
assert ledger.paid_minutes(rows(), KEY, "hr", D2) == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_own_rows_leave_room_for_the_carry() -> None:
|
||||||
|
project()
|
||||||
|
work(D1, 125)
|
||||||
|
work(D2, 120)
|
||||||
|
harvest.harvest(now=at(23, 0, day=D2))
|
||||||
|
assert ledger.paid_minutes(rows(), KEY, "hr", D2) == 120
|
||||||
|
assert "hr-20261103-m115" in {r["entry_id"] for r in rows()}
|
||||||
193
focus_project/focus_project/tests/test_checks.py
Normal file
193
focus_project/focus_project/tests/test_checks.py
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""Pluggable check sources, the phone source, and latching."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import focus_project as fp
|
||||||
|
from focus_project import _paths, checks, evidence
|
||||||
|
from focus_project.tests.helpers import SAT1, at, make
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Callable
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
MID = "phone:2026-11-14"
|
||||||
|
|
||||||
|
|
||||||
|
def _ms(moment: datetime) -> int:
|
||||||
|
return int(moment.timestamp() * 1000)
|
||||||
|
|
||||||
|
|
||||||
|
def _report(
|
||||||
|
moment: datetime,
|
||||||
|
*,
|
||||||
|
plan: bool = True,
|
||||||
|
due: int = 5,
|
||||||
|
seen: datetime | None = None,
|
||||||
|
) -> None:
|
||||||
|
value = {"plan": plan, "due": due, "seen_ms": _ms(seen or moment)}
|
||||||
|
fp.record_evidence("iv", "phone", "phone_check", value, now=moment)
|
||||||
|
|
||||||
|
|
||||||
|
def _phone(now: datetime) -> bool | None:
|
||||||
|
return checks.phone_source(fp.list_projects()[0], now)
|
||||||
|
|
||||||
|
|
||||||
|
def test_phone_source_without_a_report_is_unknown(
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
make()
|
||||||
|
with caplog.at_level("WARNING"):
|
||||||
|
assert _phone(at(13, 9)) is None
|
||||||
|
assert "phone" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_phone_source_passes_on_a_fresh_good_report() -> None:
|
||||||
|
make()
|
||||||
|
_report(at(13, 8), seen=at(12, 20))
|
||||||
|
assert _phone(at(13, 9)) is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"bad",
|
||||||
|
[{"plan": False}, {"due": 0}, {"seen": at(11, 7)}],
|
||||||
|
)
|
||||||
|
def test_phone_source_fails_on_any_bad_part(bad: dict[str, object]) -> None:
|
||||||
|
make()
|
||||||
|
_report(at(13, 8), **bad) # type: ignore[arg-type]
|
||||||
|
assert _phone(at(13, 9)) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_phone_source_stale_or_future_report_is_unknown() -> None:
|
||||||
|
make()
|
||||||
|
_report(at(13, 8))
|
||||||
|
assert _phone(at(14, 8, 1)) is None # the worker stopped reporting
|
||||||
|
assert _phone(at(13, 7)) is None # stamped ahead of the clock
|
||||||
|
|
||||||
|
|
||||||
|
def test_phone_source_uses_the_latest_report() -> None:
|
||||||
|
make()
|
||||||
|
_report(at(13, 8), plan=False)
|
||||||
|
_report(at(13, 9))
|
||||||
|
assert _phone(at(13, 10)) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_phone_source_hand_edited_row_is_a_failure() -> None:
|
||||||
|
make()
|
||||||
|
_report(at(13, 8))
|
||||||
|
path = _paths.evidence_path("iv")
|
||||||
|
row = {"ms": _ms(at(13, 9)), "milestone": "phone", "kind": "phone_check"}
|
||||||
|
path.write_text(path.read_text() + json.dumps({**row, "value": "ok"}) + "\n")
|
||||||
|
assert _phone(at(13, 10)) is False
|
||||||
|
|
||||||
|
|
||||||
|
def _patch(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, fn: Callable[[fp.Project, datetime], bool | None]
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setitem(checks.SOURCES, "phone", fn)
|
||||||
|
|
||||||
|
|
||||||
|
def _trip_project() -> None:
|
||||||
|
make()
|
||||||
|
fp.answer_weekend("iv", SAT1, trip=True, now=at(11, 9))
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_checks_latches_a_pass(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
_trip_project()
|
||||||
|
_patch(monkeypatch, lambda p, n: True)
|
||||||
|
assert fp.run_checks("iv", now=at(13, 9)) == {MID: True}
|
||||||
|
assert fp.run_checks("iv", now=at(13, 10)) == {MID: True}
|
||||||
|
rows = [r for r in evidence.read("iv") if r.kind == "check"]
|
||||||
|
assert [(r.milestone, r.value) for r in rows] == [(MID, True)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_checks_only_inside_the_window(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
_trip_project()
|
||||||
|
_patch(monkeypatch, lambda p, n: True)
|
||||||
|
assert fp.run_checks("iv", now=at(12, 11, 59)) == {} # >24 h before due
|
||||||
|
assert fp.run_checks("iv", now=at(12, 12)) == {MID: True}
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_checks_records_changes_not_every_tick(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
_trip_project()
|
||||||
|
answers: list[bool | None] = [None, False, False, None, None]
|
||||||
|
_patch(monkeypatch, lambda p, n: answers.pop(0))
|
||||||
|
for minute in range(5):
|
||||||
|
fp.run_checks("iv", now=at(13, 9, minute))
|
||||||
|
rows = [r.value for r in evidence.read("iv") if r.kind == "check"]
|
||||||
|
assert rows == [False, None] # leading None writes nothing
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_checks_source_crash_is_unknown_and_loud(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||||
|
) -> None:
|
||||||
|
_trip_project()
|
||||||
|
|
||||||
|
def boom(p: fp.Project, n: datetime) -> bool:
|
||||||
|
msg = "tutor down"
|
||||||
|
raise OSError(msg)
|
||||||
|
|
||||||
|
_patch(monkeypatch, boom)
|
||||||
|
with caplog.at_level("WARNING"):
|
||||||
|
assert fp.run_checks("iv", now=at(13, 9)) == {MID: None}
|
||||||
|
assert "tutor down" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_checks_skips_finished_milestones(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
_trip_project()
|
||||||
|
fp.record_evidence("iv", MID, "tap", value=True, now=at(12, 9))
|
||||||
|
evidence.append("iv", MID, "check", True, at(12, 10))
|
||||||
|
_patch(monkeypatch, lambda p, n: pytest.fail("already done"))
|
||||||
|
assert fp.run_checks("iv", now=at(13, 9)) == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_checks_ignores_focus_projects() -> None:
|
||||||
|
fp.create_project(
|
||||||
|
"f",
|
||||||
|
name="F",
|
||||||
|
start=at(10).date(),
|
||||||
|
end=at(12).date(),
|
||||||
|
replaces=["leetcode"],
|
||||||
|
daily_minutes=30,
|
||||||
|
unlock_minutes=10,
|
||||||
|
)
|
||||||
|
assert fp.run_checks("f", now=at(11, 9)) == {}
|
||||||
|
with pytest.raises(KeyError):
|
||||||
|
fp.run_checks("ghost", now=at(11, 9))
|
||||||
|
|
||||||
|
|
||||||
|
def test_register_source_swaps_the_callable(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(checks, "SOURCES", dict(checks.SOURCES))
|
||||||
|
|
||||||
|
def fn(p: fp.Project, n: datetime) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
fp.register_source("phone", fn)
|
||||||
|
assert checks.SOURCES["phone"] is fn
|
||||||
|
with pytest.raises(ValueError, match="source"):
|
||||||
|
fp.register_source("calendar", fn)
|
||||||
|
|
||||||
|
|
||||||
|
def test_end_to_end_phone_milestone_goes_done() -> None:
|
||||||
|
_trip_project()
|
||||||
|
fp.record_evidence("iv", MID, "tap", value=True, now=at(12, 9))
|
||||||
|
_report(at(13, 9))
|
||||||
|
assert fp.run_checks("iv", now=at(13, 9, 30)) == {MID: True}
|
||||||
|
state = {m.id: m.state for m in fp.milestones(fp.list_projects()[0], at(13, 10))}
|
||||||
|
assert state[MID] == "done"
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_missing_a_field_fails() -> None:
|
||||||
|
make()
|
||||||
|
path = _paths.evidence_path("iv")
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
row = {"ms": _ms(at(13, 8)), "milestone": "phone", "kind": "phone_check"}
|
||||||
|
path.write_text(json.dumps({**row, "value": {"plan": True}}) + "\n")
|
||||||
|
assert _phone(at(13, 9)) is False
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user