taskops-cli 0.4.2__tar.gz → 0.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ARCHITECTURE.md +80 -10
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/CHANGELOG.md +43 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/PKG-INFO +7 -5
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/README.md +6 -4
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/_version.py +1 -1
- taskops_cli-0.5.0/src/taskops/gitwork/mirror.py +132 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/http/gitdoor.py +29 -48
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/http/handler.py +28 -7
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/http/mounts.py +11 -12
- taskops_cli-0.5.0/src/taskops/http/repos.py +98 -0
- taskops_cli-0.5.0/src/taskops/http/stale.py +62 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/http/static.py +30 -15
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/ui/app.js +3 -3
- taskops_cli-0.5.0/tests/test_mirror.py +164 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/tests/test_topology.py +187 -6
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/smoke/sections/git-diff.tsx +6 -2
- taskops_cli-0.5.0/ui/smoke/sections/hosted-window.tsx +96 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/card/Patch.tsx +1 -1
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/links.tsx +5 -3
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/pages/Reports.tsx +6 -5
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/.claude/settings.json +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/.gitattributes +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/.github/workflows/ci.yml +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/.github/workflows/workflow.yml +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/.gitignore +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/.mcp.json +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/.taskops/board/cache.sqlite +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/.taskops/board/cache.sqlite-shm +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/.taskops/board/cache.sqlite-wal +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/.taskops/board/events.jsonl +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/.taskops/board/live.sqlite +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/.taskops/board/live.sqlite-shm +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/.taskops/board/live.sqlite-wal +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/.taskops/board.json +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/.taskops/reports/lifecycle-chapter.html +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/.taskops/reports/reports-chapter.html +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/.taskops/ui.lock +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/CLAUDE.md +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/pyproject.toml +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/__init__.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/_clock.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/_errors.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/_ids.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/_json.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/_locate.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/_wire.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/board.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/cli/__init__.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/cli/__main__.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/cli/admin.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/cli/claude.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/cli/commands.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/cli/enrol.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/cli/github.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/cli/grants.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/cli/hooks.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/cli/main.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/cli/operate.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/cli/paging.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/cli/parser.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/cli/pull.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/cli/push.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/cli/remote.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/cli/rm.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/cli/serving.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/cli/team.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/cli/watch.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/cli/window.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/cli/wording.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/core/__init__.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/core/actors.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/core/challenge.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/core/chapters.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/core/event.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/core/forge.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/core/graph.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/core/holding.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/core/hours.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/core/kinds.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/core/machine.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/core/mentions.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/core/replay.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/core/reports.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/core/review.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/core/scope.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/core/seams.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/core/types.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/gitwork/__init__.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/gitwork/bind.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/gitwork/catchup.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/gitwork/claudefiles.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/gitwork/diff.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/gitwork/install.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/gitwork/landing.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/gitwork/patch.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/gitwork/remote.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/gitwork/run.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/gitwork/sig.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/gitwork/trailer.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/gitwork/trees.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/http/__init__.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/http/admin.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/http/auth.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/http/feed.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/http/grants.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/http/ingest.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/http/login.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/http/members.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/http/removal.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/http/rpc.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/http/scoped.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/http/server.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/http/upstream.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/http/watcher.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/identity.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/mcp/__init__.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/mcp/__main__.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/mcp/activity.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/mcp/before.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/mcp/boards.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/mcp/boardview.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/mcp/brief.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/mcp/chapter.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/mcp/dossier.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/mcp/fields.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/mcp/gitmoves.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/mcp/hello.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/mcp/integrate.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/mcp/orders.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/mcp/render.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/mcp/schema.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/mcp/server.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/mcp/thread.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/mcp/tools.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/py.typed +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/session.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/store/__init__.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/store/cache.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/store/creds.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/store/handover.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/store/live.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/store/log.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/store/pubkeys.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/store/reviews.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/store/server.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/store/stores.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/ui/index.html +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/ui/style.css +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/verbs/__init__.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/verbs/_args.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/verbs/_cards.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/verbs/_chapter.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/verbs/_context.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/verbs/_facts.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/verbs/_mentions.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/verbs/_rows.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/verbs/_stories.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/verbs/_waiting.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/verbs/_windows.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/verbs/activity.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/verbs/assign.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/verbs/card.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/verbs/events.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/verbs/filed.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/verbs/plan.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/verbs/project.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/verbs/pulse.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/verbs/record.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/verbs/report.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/verbs/review.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/verbs/take.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/src/taskops/verbs/update.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/tests/__init__.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/tests/conftest.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/tests/test_architecture.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/tests/test_claude.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/tests/test_core.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/tests/test_git.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/tests/test_mcp.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/tests/test_report.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/tests/test_store.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/tests/test_ui.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/tests/test_verbs.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/tests/test_window.py +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/tests/ui_harness.js +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/build.mjs +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/index.html +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/package-lock.json +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/package.json +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/smoke/fixture.json +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/smoke/main.tsx +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/smoke/run.mjs +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/smoke/sections/actors-window-filter.tsx +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/smoke/sections/actors.tsx +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/smoke/sections/all-chapters-scope.tsx +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/smoke/sections/board-columns.tsx +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/smoke/sections/board-flow-view.tsx +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/smoke/sections/board-motion-and-avatars.tsx +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/smoke/sections/chapter-completion-derivation.tsx +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/smoke/sections/chapter-landed.tsx +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/smoke/sections/chapter-story-grid.tsx +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/smoke/sections/chapters-open.tsx +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/smoke/sections/comment-box.tsx +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/smoke/sections/comment-toasts-model.tsx +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/smoke/sections/comment-toasts-stack.tsx +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/smoke/sections/diff-page.tsx +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/smoke/sections/dossier.tsx +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/smoke/sections/event-stream.tsx +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/smoke/sections/feed-reconnect.tsx +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/smoke/sections/forge-links.tsx +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/smoke/sections/forge-opens-the-board.tsx +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/smoke/sections/monitor-panes.tsx +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/smoke/sections/overlay-escape.tsx +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/smoke/sections/report-markdown.tsx +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/smoke/sections/report-sandbox.tsx +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/smoke/sections/reviewing-skew.tsx +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/smoke/sections/section.ts +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/smoke/sections/swarm-topology.tsx +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/smoke/sections/thread-closing-note.tsx +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/smoke/sections/worktrees-index.tsx +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/smoke/sections.mjs +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/App.tsx +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/client.ts +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/completed.ts +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/actors/Daysheet.tsx +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/actors/DevPanel.tsx +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/board/CardTile.tsx +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/board/Column.tsx +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/board/ViewToggle.tsx +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/board/flip.ts +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/board/useFlip.ts +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/card/CommentBox.tsx +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/card/Drawer.tsx +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/card/Sections.tsx +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/card/Thread.tsx +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/card/split.ts +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/card/tokens.ts +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/chrome/AvatarStack.tsx +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/chrome/Header.tsx +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/chrome/KpiRail.tsx +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/chrome/MilestonePicker.tsx +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/chrome/TabNav.tsx +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/flow/FlowView.tsx +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/flow/layout.ts +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/monitor/Chapter.tsx +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/monitor/DependencyChain.tsx +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/monitor/EditSurface.tsx +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/monitor/EventStream.tsx +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/monitor/LeaseHealth.tsx +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/monitor/LiveLeases.tsx +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/monitor/Mentions.tsx +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/monitor/Pane.tsx +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/monitor/Swarm.tsx +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/monitor/Throughput.tsx +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/monitor/panels.ts +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/reports/ReportFrame.tsx +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/shared/Avatar.tsx +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/shared/Markdown.tsx +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/shared/Overlay.tsx +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/shared/Segmented.tsx +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/shared/overlayStack.ts +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/story/ChapterStory.tsx +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/story/stats.ts +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/toasts/ToastStack.tsx +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/toasts/model.ts +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/components/toasts/useToasts.ts +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/format.ts +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/hoursWindow.ts +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/main.tsx +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/markdown.ts +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/pages/Actors.tsx +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/pages/Board.tsx +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/pages/Monitor.tsx +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/pages/WorktreeDiff.tsx +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/pages/Worktrees.tsx +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/theme/theme.ts +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/theme/tokens.css +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/types.ts +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/useBoard.ts +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/src/useEvents.ts +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/styles/index.css +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/ui/tsconfig.json +0 -0
- {taskops_cli-0.4.2 → taskops_cli-0.5.0}/uv.lock +0 -0
|
@@ -1012,7 +1012,8 @@ Both remaining items are done. The migration ran against all four v1 boards
|
|
|
1012
1012
|
directory of board logs, so it is `pip install` into a venv and one pm2 entry
|
|
1013
1013
|
(README, "Deployed"). **The box runs this tree** since 2026-08-09 (tk-df8e64,
|
|
1014
1014
|
§17), so `https://taskops.bernardocastro.dev/<board>/ui/` answers `410` and one
|
|
1015
|
-
sentence, exactly as the trunk does (§16, §17)
|
|
1015
|
+
sentence, exactly as the trunk does (§16, §17) — until a board with a declared
|
|
1016
|
+
forge gets its hosted window (§16, "The hosted window"). Re-derivable:
|
|
1016
1017
|
`curl -s https://taskops.bernardocastro.dev/healthz` and
|
|
1017
1018
|
`curl -w '%{http_code}' https://taskops.bernardocastro.dev/axion/ui/`
|
|
1018
1019
|
— measured `{"boards": 4}` (after all four boards had been addressed through
|
|
@@ -1505,15 +1506,18 @@ construction and never sniffed per request** (`Mounts(repo=…)`,
|
|
|
1505
1506
|
| `taskops serve` | a directory of boards, no checkout | **404 with the reason spelled out**, and nothing faked | **410 and one sentence** |
|
|
1506
1507
|
|
|
1507
1508
|
The `/ui/` column arrived later, and it is the same `repo` deciding both — see
|
|
1508
|
-
"API ONLY is now literal" below.
|
|
1509
|
+
"API ONLY is now literal" below. Both cells of the `serve` row are narrowed a
|
|
1510
|
+
second time by "The hosted window" below: a board that DECLARED a forge may be
|
|
1511
|
+
answered from a bare read-only mirror of it, and only then.
|
|
1509
1512
|
|
|
1510
1513
|
A viewer meets that gap whenever this clone cannot answer — a ref not fetched
|
|
1511
1514
|
yet, a shallow checkout, a commit from before this repo had the branch — and
|
|
1512
1515
|
there the UI's declared cascade takes over: numstat from the event → patch from
|
|
1513
1516
|
/git → the forge link if a slug exists → one honest sentence. No dead anchor, no
|
|
1514
1517
|
empty pane pretending. (The gap used to have a second, worse shape: a browser
|
|
1515
|
-
with no clone at all, pointed at the hosted page.
|
|
1516
|
-
|
|
1518
|
+
with no clone at all, pointed at the hosted page. That page was withdrawn — see
|
|
1519
|
+
below — and comes back only standing on a real history: the forge mirror, in
|
|
1520
|
+
"The hosted window".)
|
|
1517
1521
|
|
|
1518
1522
|
That cascade is ONE function — `ui/src/links.tsx::cascade`, beside the slug and
|
|
1519
1523
|
the link templates it already owned — and `ui/src/components/card/Patch.tsx`
|
|
@@ -1595,9 +1599,11 @@ doubled separator and the directory itself, and never repairs one: a traversal
|
|
|
1595
1599
|
normalised into something acceptable is the bug. Anything outside
|
|
1596
1600
|
`.taskops/reports/` is a 400 that names the directory, and `tests/test_topology.py`
|
|
1597
1601
|
walks every shape (a real secret one directory up included, asserting its
|
|
1598
|
-
contents appear nowhere in the answer). `taskops serve`
|
|
1599
|
-
|
|
1600
|
-
question underneath it opens no door.
|
|
1602
|
+
contents appear nowhere in the answer). `taskops serve` mounts no `/git`
|
|
1603
|
+
from a checkout it does not have: the switch is `Mounts.repo`, decided once at
|
|
1604
|
+
construction, and a new question underneath it opens no door. The hosted-window
|
|
1605
|
+
amendment below changes what `repo` may point AT — a bare mirror of the
|
|
1606
|
+
declared forge — never the switch itself.
|
|
1601
1607
|
|
|
1602
1608
|
### The window: `taskops ui` serves locally even for a REMOTE board (2026-08-08)
|
|
1603
1609
|
|
|
@@ -1636,7 +1642,7 @@ The routes do not change, so the committed bundle is untouched by any of this:
|
|
|
1636
1642
|
decision). The server used to serve the bundle too, from a `--ui` flag with a
|
|
1637
1643
|
packaged default — so the box above answered `/ui/` with a real dashboard, and
|
|
1638
1644
|
that dashboard could not draw a single patch, because the machine it was served
|
|
1639
|
-
from deliberately
|
|
1645
|
+
from deliberately had no clone. It was a degraded window presented as *the*
|
|
1640
1646
|
window; the redirect above merely pointed people at it. **The binary serves the
|
|
1641
1647
|
window; the server serves the truth.**
|
|
1642
1648
|
|
|
@@ -1644,7 +1650,9 @@ So: `Mounts.ui` is not a parameter, it is `repo`'s shadow — one construction-t
|
|
|
1644
1650
|
switch mounts `/git` AND the bundle, and only a process standing in a checkout
|
|
1645
1651
|
has either. `/ui/` on a board host answers `static.py::NO_UI` as plain text with
|
|
1646
1652
|
**410 Gone**, not 404: 404 says "no idea, maybe later", and this page was
|
|
1647
|
-
withdrawn on purpose and is never coming back to a host with no clone
|
|
1653
|
+
withdrawn on purpose and is never coming back to a host with no clone — and a
|
|
1654
|
+
host holding the board's forge MIRROR is no longer that host ("The hosted
|
|
1655
|
+
window", below). Plain
|
|
1648
1656
|
text and not the JSON envelope because the only reader who reaches that door is
|
|
1649
1657
|
a human with a browser. The `--ui` flag is REMOVED, not deprecated: there is no
|
|
1650
1658
|
command line that puts the decision back. The bundle still ships inside the
|
|
@@ -1709,6 +1717,66 @@ move a branch under a worktree somebody is sitting in.
|
|
|
1709
1717
|
|
|
1710
1718
|
A repo joined to nothing behaves exactly as it did.
|
|
1711
1719
|
|
|
1720
|
+
### The hosted window: the host may hold a MIRROR of the declared forge (decided 2026-08-30)
|
|
1721
|
+
|
|
1722
|
+
The first amendment narrowed "no server reads a repo" to "a host that sits in a
|
|
1723
|
+
repo may read it". This one narrows the other clause — *the server deliberately
|
|
1724
|
+
has no clone* — and, as before, the change is recorded here rather than quietly
|
|
1725
|
+
made:
|
|
1726
|
+
|
|
1727
|
+
> **A board host MAY hold a bare, READ-ONLY mirror of the board's DECLARED
|
|
1728
|
+
> forge — and only then does it answer `/git` and `/ui/`.** A board with no
|
|
1729
|
+
> declared forge is exactly what it was: 404 on `/git`, 410 on `/ui/`, each
|
|
1730
|
+
> refusal naming `taskops board forge <owner>/<repo>` as the door.
|
|
1731
|
+
|
|
1732
|
+
Why this is not the degraded window "API ONLY" withdrew. That window's flaw was
|
|
1733
|
+
never that a server drew a dashboard — it was that the machine drawing it had
|
|
1734
|
+
NO git at all, so every diff fell through the whole cascade to a forge link or
|
|
1735
|
+
a sentence, a blank presented as *the* window. The mirror removes the flaw, not
|
|
1736
|
+
the caution: it is **the same history the forge already holds** — `git clone
|
|
1737
|
+
--mirror` of the repo the owner declared with `board forge`, refreshed by ONE
|
|
1738
|
+
bounded on-demand fetch when a requested ref is missing, then answered stale
|
|
1739
|
+
rather than blocking — so a diff served from it is the diff GitHub would show,
|
|
1740
|
+
read by the same `gitwork/diff.py` doors, behind the same visibility rules as
|
|
1741
|
+
`/rpc`. And the dev-side story does not move an inch: `taskops ui` still serves
|
|
1742
|
+
the window from the checkout it stands in, reading the dev's OWN clone,
|
|
1743
|
+
including refs the forge has never seen. The hosted window is for the reader
|
|
1744
|
+
who has no clone — the case the local window, by construction, cannot serve.
|
|
1745
|
+
|
|
1746
|
+
**Where it lives**: `<root>/<board>/mirror.git`, a bare repo beside the board's
|
|
1747
|
+
`events.jsonl` — later cards follow this wording. It is DERIVED and disposable
|
|
1748
|
+
in exactly the sense `cache.sqlite` is: delete it and it re-clones from the
|
|
1749
|
+
declared forge; nothing in it is truth the forge does not hold. Its ONLY source
|
|
1750
|
+
is that forge. It takes no client pushes — the host still exposes no receive
|
|
1751
|
+
door of any kind — and it is not a second trunk: nothing lands there, nothing
|
|
1752
|
+
is read back into the log from it, and `taskops_merge` still happens in the
|
|
1753
|
+
dev's shared checkout.
|
|
1754
|
+
|
|
1755
|
+
**The credential story** is §19's, unchanged in kind. A public repo needs no
|
|
1756
|
+
credential at all. A private repo is fetched with an **ssh deploy key on the
|
|
1757
|
+
host's filesystem** — read-only, minted and installed by the OWNER, revocable
|
|
1758
|
+
on the forge like any deploy key — which is the ONE new credential this chapter
|
|
1759
|
+
adds, and it is the owner's business exactly as `board forge` itself is. What
|
|
1760
|
+
remains banned is what §11 and §19 already ban: a stored GitHub token (the
|
|
1761
|
+
mirror's remote is an address, never a token), a write credential in any form,
|
|
1762
|
+
GitHub as a second identity system, and any dev credential travelling to the
|
|
1763
|
+
host — nobody's `gh auth token` is involved, because the owner already declared
|
|
1764
|
+
the repo and the mirror pulls from the outside like any anonymous (or
|
|
1765
|
+
deploy-keyed) clone.
|
|
1766
|
+
|
|
1767
|
+
**Still banned, spelled out**: client pushes to the host and git replication
|
|
1768
|
+
between clones (the mirror replicates FROM the forge, one direction, and no
|
|
1769
|
+
clone ever pushes to it); a write credential on the host; a second trunk; a
|
|
1770
|
+
`--ui` flag or any per-request sniffing (whether the mirror exists is decided
|
|
1771
|
+
at mount time, the same `Mounts.repo` switch, now pointable at `mirror.git`);
|
|
1772
|
+
and anonymous writes — a public board's hosted window is a READ, and it leaves
|
|
1773
|
+
no `presence` row.
|
|
1774
|
+
|
|
1775
|
+
So `static.py::NO_UI` and `gitdoor.py::NO_REPO` change meaning, not words of
|
|
1776
|
+
intent: each stops saying "this host never has a repo" and starts saying "this
|
|
1777
|
+
BOARD declared no forge, so this host holds no mirror to read from" — a state
|
|
1778
|
+
the owner ends with one `taskops board forge`. The refusal still names the door.
|
|
1779
|
+
|
|
1712
1780
|
### A concept named by two cards is a seam — the fan-out rule, third notch
|
|
1713
1781
|
|
|
1714
1782
|
`gitwork/remote.py` was born TWICE in one wave: two workers, each over the
|
|
@@ -1803,7 +1871,9 @@ request (`http/mounts.py::stores`), so a just-restarted process says `1`, not
|
|
|
1803
1871
|
|
|
1804
1872
|
**That md5-of-the-served-bundle check is history, not the procedure any more.**
|
|
1805
1873
|
Since §16's "API ONLY" amendment the host serves no bundle: `/<board>/ui/`
|
|
1806
|
-
answers `410` and one sentence
|
|
1874
|
+
answers `410` and one sentence (until the hosted-window chapter deploys a board
|
|
1875
|
+
whose forge is declared — §16, "The hosted window"), so there is nothing at the
|
|
1876
|
+
domain to md5. The
|
|
1807
1877
|
wheel is still checked for its markers *before* it ships — it is the same wheel
|
|
1808
1878
|
teammates install to get `taskops ui` — and the domain is verified by DATA,
|
|
1809
1879
|
through `/rpc`.
|
|
@@ -3,6 +3,49 @@
|
|
|
3
3
|
The source of truth for release notes — GitHub Releases are extracted from
|
|
4
4
|
here, never written twice.
|
|
5
5
|
|
|
6
|
+
## 0.5.0 — the hosted window
|
|
7
|
+
|
|
8
|
+
- **A board can be READ without a clone.** Until now the dashboard was only ever
|
|
9
|
+
served by `taskops ui`, a binary standing in a checkout: a host running
|
|
10
|
+
`taskops serve` answered `/git` with a 404 and `/ui/` with a 410, because a
|
|
11
|
+
dashboard draws DIFFS and the host deliberately had no repository to read them
|
|
12
|
+
from. Serving a page that could not draw a single patch was a degraded window
|
|
13
|
+
presented as *the* window, so it was withdrawn. That reasoning had one
|
|
14
|
+
unexamined premise — that the host can never have git — and this release
|
|
15
|
+
removes it, argued in ARCHITECTURE §16 ("The hosted window") before a line of
|
|
16
|
+
code moved.
|
|
17
|
+
- **The host may hold a bare, READ-ONLY mirror of the board's DECLARED forge**,
|
|
18
|
+
at `<root>/<board>/mirror.git`, and only then does it answer `/git` and
|
|
19
|
+
`/ui/`. The mirror is derived and disposable in exactly the sense
|
|
20
|
+
`cache.sqlite` is: delete it and it re-clones; nothing in it is truth the
|
|
21
|
+
forge does not already hold. Its ONLY source is the repo the owner declared
|
|
22
|
+
with `taskops board forge` — it takes no client pushes, it is not a second
|
|
23
|
+
trunk, and nothing is ever read back into the log from it.
|
|
24
|
+
- **A board that declared no forge is exactly what it was**: 404 on `/git`,
|
|
25
|
+
410 on `/ui/`, and each refusal now names the door — `taskops board forge
|
|
26
|
+
<owner>/<repo>` — instead of stranding the reader in a sentence about a host
|
|
27
|
+
they cannot change.
|
|
28
|
+
- **No new credential type.** A public repo is mirrored anonymously; a private
|
|
29
|
+
one is fetched with an ssh deploy key on the host's filesystem, installed by
|
|
30
|
+
the OWNER, revocable on the forge — the one new credential this chapter adds,
|
|
31
|
+
and it is the owner's business exactly as `board forge` itself is. No stored
|
|
32
|
+
GitHub token, no dev credential travelling, no second identity system.
|
|
33
|
+
- **The page opens on the FACT, never on a clone.** `/ui/` is granted by the
|
|
34
|
+
declared forge alone: the bundle's bytes come from the installed package, so
|
|
35
|
+
putting a network clone in front of `index.html` would take a static page down
|
|
36
|
+
whenever the forge is unreachable. The mirror resolves lazily the moment a
|
|
37
|
+
diff is actually read — which is where a failure belongs, in the diff pane, in
|
|
38
|
+
the door's own words.
|
|
39
|
+
- **A missing ref buys exactly ONE bounded fetch**, then answers stale rather
|
|
40
|
+
than blocking a page on a forge that is down — and only on a mirror. A
|
|
41
|
+
window's own clone still fetches nothing on the reader's behalf: a background
|
|
42
|
+
fetch inside a read-only door would move a branch under a worktree somebody is
|
|
43
|
+
sitting in.
|
|
44
|
+
- **Reads stay reads.** `/ui/` sits behind the credential `/rpc` asks for —
|
|
45
|
+
a `public` board serves the page to an anonymous reader, a private one refuses
|
|
46
|
+
with the same join sentence — and an anonymous page load writes nothing at
|
|
47
|
+
all, not even the invisible write: no presence row.
|
|
48
|
+
|
|
6
49
|
## 0.4.2 — the filter that filtered nothing, and the window that stopped signing in
|
|
7
50
|
|
|
8
51
|
- **"All chapters" is an ARGUMENT now, and it really shows every chapter.** The
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: taskops-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: A shared work board for teams of coding agents, with a human who decides.
|
|
5
5
|
Project-URL: Repository, https://github.com/bernatch22/taskops
|
|
6
6
|
License-Expression: MIT
|
|
@@ -70,7 +70,7 @@ importable leaves commits un-stamped, silently.
|
|
|
70
70
|
taskops init a local board in this repo
|
|
71
71
|
taskops join [<name>] [--invite <id>] join a board, install the hooks
|
|
72
72
|
taskops remote add <url> the host this checkout operates, like git's origin
|
|
73
|
-
taskops serve host boards —
|
|
73
|
+
taskops serve host boards — /ui and /git open per declared forge
|
|
74
74
|
taskops server init bootstrap THIS host: its owner and their ssh key
|
|
75
75
|
taskops board create|ls|push|pull|rm the boards on a host
|
|
76
76
|
taskops board visibility|forge who may read one · which repo's team is enrolled
|
|
@@ -383,9 +383,11 @@ to bytes that are not in history yet is a pointer to nothing.
|
|
|
383
383
|
```
|
|
384
384
|
|
|
385
385
|
Then `taskops ui` lists it under the chapter's **Reports** tab and renders it
|
|
386
|
-
full width, read out of **your own clone** at that sha — the dashboard
|
|
387
|
-
asks the server for the bytes
|
|
388
|
-
|
|
386
|
+
full width, read out of **your own clone** at that sha — the local dashboard
|
|
387
|
+
never asks the server for the bytes. A host running `taskops serve` answers
|
|
388
|
+
`/git` too, from a bare read-only mirror of the board's DECLARED forge — the
|
|
389
|
+
hosted window, for the reader with no clone. A board that never declared one
|
|
390
|
+
refuses with a sentence naming `taskops board forge <owner>/<repo>`.
|
|
389
391
|
|
|
390
392
|
**Reading one, from the other side.** Everything above is the author's half; a
|
|
391
393
|
reader needs no ceremony at all. Three doors onto the same bytes:
|
|
@@ -58,7 +58,7 @@ importable leaves commits un-stamped, silently.
|
|
|
58
58
|
taskops init a local board in this repo
|
|
59
59
|
taskops join [<name>] [--invite <id>] join a board, install the hooks
|
|
60
60
|
taskops remote add <url> the host this checkout operates, like git's origin
|
|
61
|
-
taskops serve host boards —
|
|
61
|
+
taskops serve host boards — /ui and /git open per declared forge
|
|
62
62
|
taskops server init bootstrap THIS host: its owner and their ssh key
|
|
63
63
|
taskops board create|ls|push|pull|rm the boards on a host
|
|
64
64
|
taskops board visibility|forge who may read one · which repo's team is enrolled
|
|
@@ -371,9 +371,11 @@ to bytes that are not in history yet is a pointer to nothing.
|
|
|
371
371
|
```
|
|
372
372
|
|
|
373
373
|
Then `taskops ui` lists it under the chapter's **Reports** tab and renders it
|
|
374
|
-
full width, read out of **your own clone** at that sha — the dashboard
|
|
375
|
-
asks the server for the bytes
|
|
376
|
-
|
|
374
|
+
full width, read out of **your own clone** at that sha — the local dashboard
|
|
375
|
+
never asks the server for the bytes. A host running `taskops serve` answers
|
|
376
|
+
`/git` too, from a bare read-only mirror of the board's DECLARED forge — the
|
|
377
|
+
hosted window, for the reader with no clone. A board that never declared one
|
|
378
|
+
refuses with a sentence naming `taskops board forge <owner>/<repo>`.
|
|
377
379
|
|
|
378
380
|
**Reading one, from the other side.** Everything above is the author's half; a
|
|
379
381
|
reader needs no ceremony at all. Three doors onto the same bytes:
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""The board's bare mirror — the forge's history, held so a host can answer.
|
|
2
|
+
|
|
3
|
+
ARCHITECTURE §16 ("The hosted window") decided this on 2026-08-30: a board
|
|
4
|
+
host MAY hold `<root>/<board>/mirror.git`, a bare `git clone --mirror` of the
|
|
5
|
+
forge the owner declared with `board forge` — and only then does it answer
|
|
6
|
+
`/git` and `/ui/`. The mirror is DERIVED and disposable in exactly the sense
|
|
7
|
+
`cache.sqlite` is: delete it and `ensure` re-clones; nothing in it is truth
|
|
8
|
+
the forge does not hold. Its ONLY source is that forge — it takes no client
|
|
9
|
+
pushes, it is not a second trunk, and nothing here ever writes to it beyond
|
|
10
|
+
what `clone` and `fetch` are.
|
|
11
|
+
|
|
12
|
+
**The address is derived, never a credential.** The default remote is
|
|
13
|
+
`https://<host>/<repo>.git`, spelled straight out of the declared fact
|
|
14
|
+
(`core/forge.py` owns that shape) — an anonymous clone, which is all a public
|
|
15
|
+
repo needs. A private repo is the owner's business: they point the mirror at
|
|
16
|
+
an ssh remote backed by a deploy key on the host's filesystem (`url=` at
|
|
17
|
+
clone time, or `git remote set-url` in a mirror that already exists), and
|
|
18
|
+
this module neither knows nor stores anything about it. What §11 bans stays
|
|
19
|
+
banned: no stored token, no write credential, no dev credential travelling.
|
|
20
|
+
|
|
21
|
+
**Failures return None/False, never raise into a request.** These functions
|
|
22
|
+
sit behind HTTP doors serving readers who cannot fix the host's network; a
|
|
23
|
+
mirror that cannot answer is a stale page, not a 500. The same reasoning
|
|
24
|
+
gave `remote.py::push` its 10-second budget, and `fetch` inherits the number:
|
|
25
|
+
a refresh is never a gate, so it may never cost more than a moment. The one
|
|
26
|
+
bounded on-demand fetch lives in `refresh_if_missing` — a requested ref that
|
|
27
|
+
is absent buys exactly one fetch, then the answer is whatever is true, stale
|
|
28
|
+
included. Answering stale beats blocking a page on a forge that is down.
|
|
29
|
+
|
|
30
|
+
All subprocess goes through `run.py`, the one module allowed to import it —
|
|
31
|
+
and `run.git` RAISES on a timeout (right for workers, wrong for a request),
|
|
32
|
+
so the catch here is part of the contract, not belt-and-braces.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
|
|
39
|
+
from . import run
|
|
40
|
+
from ..core import forge as forge_facts
|
|
41
|
+
from .._errors import TaskopsError
|
|
42
|
+
|
|
43
|
+
__all__ = ["ensure", "fetch", "refresh_if_missing"]
|
|
44
|
+
|
|
45
|
+
MIRROR = "mirror.git"
|
|
46
|
+
"""§16's wording, verbatim: `<root>/<board>/mirror.git`, beside events.jsonl."""
|
|
47
|
+
|
|
48
|
+
FETCH_TIMEOUT = 10.0
|
|
49
|
+
"""`remote.py::PUSH_TIMEOUT`'s argument, pointing the other way: a refresh is
|
|
50
|
+
never a gate. Worst case a reader waits ten seconds ONCE and gets the truth
|
|
51
|
+
about this disk — which is what `gitdoor` answers anyway."""
|
|
52
|
+
|
|
53
|
+
CLONE_TIMEOUT = 120.0
|
|
54
|
+
"""A first clone moves the whole history, once, at mount time — not per
|
|
55
|
+
request — so it gets `run.TIMEOUT`'s patience rather than `fetch`'s."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _url(forge: object) -> str:
|
|
59
|
+
"""The declared fact → the anonymous https remote, or "".
|
|
60
|
+
|
|
61
|
+
`forge_facts.understood` already collapses absent, cleared and
|
|
62
|
+
unintelligible to None, so an https URL is only ever built from a fact a
|
|
63
|
+
door would also act on. No token, no user@ — the address IS the whole
|
|
64
|
+
credential story for a public repo.
|
|
65
|
+
"""
|
|
66
|
+
fact = forge_facts.understood(forge)
|
|
67
|
+
if fact is None:
|
|
68
|
+
return ""
|
|
69
|
+
return f"https://{fact['host']}/{fact['repo']}.git"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def ensure(board_dir: Path, forge: object, *, url: str = "") -> Path | None:
|
|
73
|
+
"""The mirror's path — cloning it into existence if this is the first ask.
|
|
74
|
+
|
|
75
|
+
An EXISTING `mirror.git` is returned untouched, whatever remote it holds:
|
|
76
|
+
that is how an owner's ssh remote (a deploy key's address) survives every
|
|
77
|
+
later call. `url=` overrides the derived address at clone time — the same
|
|
78
|
+
owner's move, and how tests clone from a local fixture instead of the
|
|
79
|
+
network. No forge and no url means no mirror, which is the state every
|
|
80
|
+
board is born in; a clone that fails leaves nothing behind, so the next
|
|
81
|
+
ask is a clean retry rather than a corpse mistaken for a mirror.
|
|
82
|
+
"""
|
|
83
|
+
dest = board_dir / MIRROR
|
|
84
|
+
if dest.is_dir():
|
|
85
|
+
return dest
|
|
86
|
+
remote = url or _url(forge)
|
|
87
|
+
if not remote:
|
|
88
|
+
return None
|
|
89
|
+
try:
|
|
90
|
+
result = run.git("clone", "--mirror", remote, str(dest), timeout=CLONE_TIMEOUT)
|
|
91
|
+
except TaskopsError:
|
|
92
|
+
result = None # timeout, or no git at all — a request never hears it
|
|
93
|
+
if result is None or not result.ok:
|
|
94
|
+
return None
|
|
95
|
+
return dest
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def fetch(mirror: Path) -> bool:
|
|
99
|
+
"""One bounded fetch from the mirror's own remote. True means it ran clean.
|
|
100
|
+
|
|
101
|
+
A `--mirror` clone's fetch refspec already maps every ref one-to-one, so
|
|
102
|
+
plain `git fetch` IS the whole refresh — no refspec spelled here that
|
|
103
|
+
could drift from what clone configured. False covers everything a reader
|
|
104
|
+
cannot fix: a dead remote, a timeout, a directory that stopped being a
|
|
105
|
+
repo. The caller answers with what it has.
|
|
106
|
+
"""
|
|
107
|
+
try:
|
|
108
|
+
return run.git("fetch", cwd=mirror, timeout=FETCH_TIMEOUT).ok
|
|
109
|
+
except TaskopsError: # timeout, or no git at all — never the reader's problem
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _has(mirror: Path, ref: str) -> bool:
|
|
114
|
+
"""`^{commit}` is load-bearing: bare `rev-parse --verify <40-hex>` answers
|
|
115
|
+
ok for ANY well-formed sha, present or not — a ref arriving as a full sha
|
|
116
|
+
(which is what a diff door holds) would always read as here."""
|
|
117
|
+
return run.git("rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}", cwd=mirror).ok
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def refresh_if_missing(mirror: Path, ref: str) -> bool:
|
|
121
|
+
"""Is `ref` here — after at most ONE fetch if it was not?
|
|
122
|
+
|
|
123
|
+
§16's exact promise: a missing ref triggers one bounded on-demand fetch
|
|
124
|
+
before answering stale. A ref already present costs nothing — no network
|
|
125
|
+
touched, so a page over known history never waits on a forge. A ref still
|
|
126
|
+
absent after the fetch is answered False, truthfully: "not here" is the
|
|
127
|
+
truth about this disk, and the door already knows how to say so.
|
|
128
|
+
"""
|
|
129
|
+
if _has(mirror, ref):
|
|
130
|
+
return True
|
|
131
|
+
fetch(mirror)
|
|
132
|
+
return _has(mirror, ref)
|
|
@@ -11,12 +11,14 @@ there is no second credential system, and `server.py` checks the credential
|
|
|
11
11
|
before this module is reached.
|
|
12
12
|
|
|
13
13
|
**Whether the door exists at all is decided ONCE, at construction**, never
|
|
14
|
-
re-derived per request
|
|
14
|
+
re-derived per request (`http/repos.py` resolves it per board). `taskops ui` sits
|
|
15
15
|
inside a repo (its root is `<repo>/.taskops`) and passes it; `taskops serve`
|
|
16
|
-
sits in a boards directory and passes nothing
|
|
17
|
-
404 whose message
|
|
18
|
-
through its cascade
|
|
19
|
-
|
|
16
|
+
sits in a boards directory and passes nothing FOR A BOARD WITH NO DECLARED
|
|
17
|
+
FORGE, so /git there is a 404 whose message says which case it is — the UI falls
|
|
18
|
+
through its cascade rather than a dead pane. A declared forge may instead mount
|
|
19
|
+
a bare read-only mirror (`<root>/<board>/mirror.git` — §16, "The hosted
|
|
20
|
+
window"): `NO_REPO` means "no checkout here and no mirror for this board",
|
|
21
|
+
never "this host can never read git".
|
|
20
22
|
|
|
21
23
|
This module only routes and refuses. Everything about git is `gitwork/diff.py`,
|
|
22
24
|
which is where the ref validation lives.
|
|
@@ -39,6 +41,7 @@ from typing import Any
|
|
|
39
41
|
from pathlib import Path
|
|
40
42
|
from urllib.parse import unquote
|
|
41
43
|
|
|
44
|
+
from . import stale
|
|
42
45
|
from ..core import reports
|
|
43
46
|
from .._errors import NotFound, BadRequest
|
|
44
47
|
from ..gitwork import diff, patch
|
|
@@ -46,26 +49,12 @@ from ..gitwork import diff, patch
|
|
|
46
49
|
NO_REPO = (
|
|
47
50
|
"this host serves boards, not a repository — it was started outside a "
|
|
48
51
|
"checkout (taskops serve), so there is no clone here to read a diff from. "
|
|
49
|
-
"A host that sits in a repo (taskops ui) answers /git
|
|
52
|
+
"A host that sits in a repo (taskops ui) answers /git; a board with a "
|
|
53
|
+
"declared forge answers from its mirror when the host can hold one."
|
|
50
54
|
)
|
|
51
55
|
|
|
52
56
|
SEPARATOR = "..."
|
|
53
57
|
|
|
54
|
-
STALE = (
|
|
55
|
-
"{refs} not in your clone yet — `{fetch}` brings {them}. The board is shared and "
|
|
56
|
-
"the code is not: a card's branch reaches origin when it closes, and this "
|
|
57
|
-
"window reads only the checkout it stands in. Nothing is fetched for you."
|
|
58
|
-
)
|
|
59
|
-
"""The MISSING-REF case, in its own words. It is not an error and must not read
|
|
60
|
-
like one: on a shared board most refs belong to somebody else's card, and until
|
|
61
|
-
you fetch, "not here yet" is simply the truth about your disk. Naming the exact
|
|
62
|
-
command is this codebase's habit — every refusal names the call that works —
|
|
63
|
-
and it is also the reason nothing fetches on the reader's behalf: a background
|
|
64
|
-
`git fetch` inside a read-only door would move a branch under a worktree
|
|
65
|
-
somebody is sitting in."""
|
|
66
|
-
|
|
67
|
-
SHA = "0123456789abcdef"
|
|
68
|
-
|
|
69
58
|
NOT_A_REPORT = (
|
|
70
59
|
"{path} is not a report. This door serves committed files under "
|
|
71
60
|
f"{reports.DIR} and nothing else — it is not a file server, and a read "
|
|
@@ -97,24 +86,34 @@ TEXT = "text/plain"
|
|
|
97
86
|
MARKDOWN = "text/markdown"
|
|
98
87
|
|
|
99
88
|
|
|
100
|
-
def answer(
|
|
101
|
-
|
|
89
|
+
def answer(
|
|
90
|
+
repo: Path | None, tail: str, query: str, *, mirrored: bool = False
|
|
91
|
+
) -> dict[str, Any]:
|
|
92
|
+
"""The whole door. `tail` is the path after `<board>/git/`. `mirrored`
|
|
93
|
+
says the repo is the board's mirror (`http/repos.py`), which is the one
|
|
94
|
+
case a missing ref buys a bounded fetch (`http/stale.py`)."""
|
|
102
95
|
if repo is None:
|
|
103
96
|
raise NotFound(NO_REPO)
|
|
104
97
|
kind, _, rest = tail.partition("/")
|
|
105
98
|
path = _param(query, "path") or None
|
|
106
99
|
if kind == "file" and rest:
|
|
107
|
-
return _file(repo, unquote(rest), path or "")
|
|
100
|
+
return _file(repo, unquote(rest), path or "", mirrored)
|
|
108
101
|
if kind == "commit" and rest:
|
|
109
102
|
ref = unquote(rest)
|
|
110
103
|
found = diff.commit_range(repo, ref)
|
|
104
|
+
if found is None and stale.refreshed(repo, mirrored, [ref]):
|
|
105
|
+
found = diff.commit_range(repo, ref)
|
|
111
106
|
if found is None:
|
|
112
|
-
raise NotFound(
|
|
107
|
+
raise NotFound(stale.sentence(ref))
|
|
113
108
|
elif kind == "compare" and SEPARATOR in rest:
|
|
114
109
|
left, _, right = unquote(rest).partition(SEPARATOR)
|
|
115
110
|
found = diff.compare_range(repo, left, right)
|
|
116
111
|
if found is None:
|
|
117
|
-
|
|
112
|
+
missing = [r for r in (left, right) if not diff.resolve(repo, r)]
|
|
113
|
+
if stale.refreshed(repo, mirrored, missing):
|
|
114
|
+
found = diff.compare_range(repo, left, right)
|
|
115
|
+
if found is None:
|
|
116
|
+
raise NotFound(stale.sentence(*missing))
|
|
118
117
|
else:
|
|
119
118
|
raise BadRequest(
|
|
120
119
|
"git/commit/<ref>, git/compare/<a>...<b>, or git/file/<rev>?path=<file>"
|
|
@@ -122,7 +121,7 @@ def answer(repo: Path | None, tail: str, query: str) -> dict[str, Any]:
|
|
|
122
121
|
return patch.between(repo, found[0], found[1], path)
|
|
123
122
|
|
|
124
123
|
|
|
125
|
-
def _file(repo: Path, rev: str, wanted: str) -> dict[str, Any]:
|
|
124
|
+
def _file(repo: Path, rev: str, wanted: str, mirrored: bool) -> dict[str, Any]:
|
|
126
125
|
"""One committed file at a rev — read-only, shape-guarded, capped.
|
|
127
126
|
|
|
128
127
|
Three walls, in this order: the path is a REPORT path (`under()`, which
|
|
@@ -144,8 +143,10 @@ def _file(repo: Path, rev: str, wanted: str) -> dict[str, Any]:
|
|
|
144
143
|
if not diff.usable(path):
|
|
145
144
|
raise BadRequest(ODD_SHAPE.format(path=path, dir=reports.DIR))
|
|
146
145
|
sha = diff.resolve(repo, rev)
|
|
146
|
+
if sha is None and stale.refreshed(repo, mirrored, [rev]):
|
|
147
|
+
sha = diff.resolve(repo, rev)
|
|
147
148
|
if sha is None:
|
|
148
|
-
raise NotFound(
|
|
149
|
+
raise NotFound(stale.sentence(rev))
|
|
149
150
|
got = patch.show(repo, sha, path)
|
|
150
151
|
if got is None:
|
|
151
152
|
raise NotFound(ABSENT.format(path=path, sha=sha[:12]))
|
|
@@ -160,26 +161,6 @@ def _file(repo: Path, rev: str, wanted: str) -> dict[str, Any]:
|
|
|
160
161
|
}
|
|
161
162
|
|
|
162
163
|
|
|
163
|
-
def _stale(*refs: str) -> str:
|
|
164
|
-
"""Which refs are missing, and the one command that brings them.
|
|
165
|
-
|
|
166
|
-
A sha is asked for WITHOUT a refspec — `git fetch origin <40 hex>` is
|
|
167
|
-
refused by most servers unless they allow it — while a branch is named, so
|
|
168
|
-
the reader can paste the line and get exactly what the pane wanted."""
|
|
169
|
-
names = [ref for ref in refs if ref] or ["that ref"]
|
|
170
|
-
branches = [ref for ref in names if not _looks_like_a_sha(ref)]
|
|
171
|
-
many = len(names) > 1
|
|
172
|
-
return STALE.format(
|
|
173
|
-
refs=f"{' and '.join(names)} {'are' if many else 'is'}",
|
|
174
|
-
fetch=" ".join(["git fetch origin", *branches]),
|
|
175
|
-
them="them" if many else "it",
|
|
176
|
-
)
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
def _looks_like_a_sha(ref: str) -> bool:
|
|
180
|
-
return len(ref) >= 7 and all(char in SHA for char in ref.lower())
|
|
181
|
-
|
|
182
|
-
|
|
183
164
|
def _param(query: str, key: str) -> str:
|
|
184
165
|
for part in query.split("&"):
|
|
185
166
|
name, _, value = part.partition("=")
|
|
@@ -67,7 +67,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
67
67
|
elif tail.startswith("git/"):
|
|
68
68
|
self._git(board, tail[4:])
|
|
69
69
|
elif tail.startswith("ui"):
|
|
70
|
-
self._static(tail[2:])
|
|
70
|
+
self._static(board, tail[2:])
|
|
71
71
|
elif (page := static.at_root(self.mounts.ui, self.path)) is not None:
|
|
72
72
|
self._send(200, *page) # a WINDOW serves its page at the ROOT
|
|
73
73
|
else:
|
|
@@ -121,14 +121,35 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
121
121
|
self._answer(lambda _: self._diff(board, rest))
|
|
122
122
|
|
|
123
123
|
def _diff(self, board: str, rest: str) -> dict[str, Any]:
|
|
124
|
-
"""
|
|
125
|
-
|
|
124
|
+
"""A window answers from its own LOCAL clone; a serve-mode host from
|
|
125
|
+
the board's forge mirror — `repos.py` decides which, per board (§16)."""
|
|
126
126
|
self.mounts.check(board)
|
|
127
127
|
self._credential(board, "read")
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
128
|
+
repo, mirrored = self.mounts.repos.for_board(board)
|
|
129
|
+
return gitdoor.answer(repo, rest, self.path.partition("?")[2], mirrored=mirrored)
|
|
130
|
+
|
|
131
|
+
def _static(self, board: str, rest: str) -> None:
|
|
132
|
+
"""The /ui door. A WINDOW serves its bundle to whoever reaches the port,
|
|
133
|
+
unchanged. A serve-mode HOST serves the SAME packaged bundle for a board
|
|
134
|
+
whose owner declared a forge (`repos.backed` — the fact, never a clone),
|
|
135
|
+
behind the credential /rpc asks for: public board, anonymous READ;
|
|
136
|
+
private board, the join refusal. The 410 comes BEFORE the credential on
|
|
137
|
+
purpose — it says nothing about the board a login would guard, and the
|
|
138
|
+
no-forge sentence predates keys on this door. A GET here runs no verb,
|
|
139
|
+
so an anonymous page load writes nothing — no presence row (§11)."""
|
|
140
|
+
if self.mounts.ui is not None:
|
|
141
|
+
self._send(*static.answer(self.mounts.ui, rest))
|
|
142
|
+
return
|
|
143
|
+
try:
|
|
144
|
+
self.mounts.check(board)
|
|
145
|
+
if not self.mounts.repos.backed(board):
|
|
146
|
+
self._send(*static.answer(None, rest))
|
|
147
|
+
return
|
|
148
|
+
self._credential(board, "read")
|
|
149
|
+
except TaskopsError as err:
|
|
150
|
+
self._fail(rpc.status_for(rpc.failure(err)), err)
|
|
151
|
+
return
|
|
152
|
+
self._send(*static.answer(static.PACKAGED, rest))
|
|
132
153
|
|
|
133
154
|
# ── plumbing ────────────────────────────────────────────────────────────
|
|
134
155
|
|
|
@@ -11,9 +11,10 @@ import re
|
|
|
11
11
|
from pathlib import Path
|
|
12
12
|
from threading import Lock
|
|
13
13
|
|
|
14
|
-
from . import feed, watcher
|
|
14
|
+
from . import feed, static, watcher
|
|
15
15
|
from .. import verbs, _clock
|
|
16
16
|
from .login import Host
|
|
17
|
+
from .repos import Repos
|
|
17
18
|
from ..verbs import project
|
|
18
19
|
from .._errors import NotFound, BadRequest
|
|
19
20
|
from .upstream import Upstream, seq_of
|
|
@@ -22,8 +23,6 @@ from ..store.stores import Stores
|
|
|
22
23
|
|
|
23
24
|
NAME = re.compile(r"^[a-z0-9][a-z0-9-]{0,39}$")
|
|
24
25
|
|
|
25
|
-
_PACKAGED_UI = Path(__file__).resolve().parent.parent / "ui"
|
|
26
|
-
|
|
27
26
|
class Mounts:
|
|
28
27
|
"""The boards this process serves, opened once and kept.
|
|
29
28
|
|
|
@@ -45,16 +44,16 @@ class Mounts:
|
|
|
45
44
|
) -> None:
|
|
46
45
|
self.root = root
|
|
47
46
|
self.upstream = upstream
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
#
|
|
47
|
+
# Whether this process can read a repo is decided by the CALLER at
|
|
48
|
+
# construction — a window's own checkout — or, on a serve-mode host,
|
|
49
|
+
# per BOARD from its declared forge's mirror (`repos.py` carries it).
|
|
51
50
|
self.repo = repo
|
|
52
|
-
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
#
|
|
56
|
-
#
|
|
57
|
-
self.ui =
|
|
51
|
+
self.repos = Repos(root, repo, self.stores)
|
|
52
|
+
# ONE switch, not two: the same `repo` that mounts /git mounts the bundle at
|
|
53
|
+
# the WINDOW's root. On a serve-mode host `ui` stays None and the /ui door asks
|
|
54
|
+
# `repos.backed()` per board instead — the forge fact, the same one that opens
|
|
55
|
+
# /git — see `static.py` and `repos.py` for the whole argument.
|
|
56
|
+
self.ui = static.PACKAGED if repo is not None else None
|
|
58
57
|
self.credentials = Credentials(root / "live.sqlite")
|
|
59
58
|
# The HOST's own identity, and it opens NOTHING until a login asks
|
|
60
59
|
# (`login.py::Host` says why lazily is a rule here, not a taste).
|