diff --git a/.github/workflows/postrelease.yml b/.github/workflows/postrelease.yml index 557f5b8..276d35a 100644 --- a/.github/workflows/postrelease.yml +++ b/.github/workflows/postrelease.yml @@ -1,19 +1,26 @@ name: Post-release checks -# After a release is published, prove the artifact is actually installable from -# real PyPI and runs end-to-end (creds-free) — the manual step 4 of -# docs/roadmap/local-verification-checklist.md, automated. +# After a release is *published to PyPI*, prove the artifact is actually +# installable from real PyPI and runs end-to-end (creds-free) — the manual +# step 4 of docs/roadmap/local-verification-checklist.md, automated. # # Triggers: -# - release: published -> verify the just-released version. -# - workflow_dispatch -> verify an explicit version on demand. +# - workflow_run on "Release" (completed) -> verify the version Release just +# published. Keyed off the Release workflow actually FINISHING, not the +# GitHub `release: published` event — our publish is on-demand OIDC +# (release.yml is dispatched + approved AFTER the GitHub release exists), so +# `release: published` fires long before the artifact is on PyPI and the poll +# would 404 forever (issue #148). `workflow_run` runs only once the Release +# run (build + OIDC publish) completed successfully. +# - workflow_dispatch -> verify an explicit version on demand. # Polls PyPI (index lag is ~1 min after upload), then installs into a clean venv -# from PyPI and runs the CLI smoke. On the publish trigger it then opens an issue -# if anything fails, so a bad release is loud. +# from PyPI and runs the CLI smoke. On the automatic path it opens an issue if +# anything fails, so a bad release is loud. on: - release: - types: [published] + workflow_run: + workflows: ["Release"] + types: [completed] workflow_dispatch: inputs: version: @@ -21,7 +28,7 @@ on: required: true concurrency: - group: postrelease-${{ github.ref }} + group: postrelease-${{ github.event.workflow_run.head_branch || inputs.version }} cancel-in-progress: false env: @@ -34,6 +41,10 @@ jobs: verify-pypi: name: install from PyPI + smoke runs-on: ubuntu-latest + # Only run once Release actually succeeded (build + OIDC publish). A dispatch + # that was never approved leaves Release without a success conclusion, so this + # never fires on a publish that did not happen. + if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }} permissions: contents: read # Needed by the github-script step that opens an issue on failure. @@ -51,13 +62,14 @@ jobs: - name: Resolve target version id: ver - # release event -> the tag (strip a leading v); manual -> the input. + # workflow_run -> the tag Release ran on (head_branch = the vX.Y.Z ref it + # was dispatched with; strip a leading v); manual -> the input. run: | set -euo pipefail if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then v="${{ inputs.version }}" else - tag="${{ github.event.release.tag_name }}" + tag="${{ github.event.workflow_run.head_branch }}" v="${tag#v}" fi case "$v" in @@ -98,7 +110,7 @@ jobs: - name: Open an issue if the release smoke failed # Only on the automatic publish path — a manual dispatch failure is # visible to the person who ran it. - if: ${{ failure() && github.event_name == 'release' }} + if: ${{ failure() && github.event_name == 'workflow_run' }} uses: actions/github-script@v7 with: script: | diff --git a/CHANGELOG.md b/CHANGELOG.md index e981e57..d4da4ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,42 @@ on a schema mismatch is **rebuild** (ADR-0006). ## [Unreleased] +### Added + +- **feat-014 — watch mode + CI-triggered central indexing (freshness / ops).** + feat-004 made re-indexing cheap; this adds the two automatic *triggers* that + keep the graph fresh, one per topology, with a hard wall between them. + - **`ckg watch`** (opt-in, local embedded store only) re-runs the incremental + `refresh()` on a configurable trigger — `on-commit` (default; refreshes on a + commit / branch switch, ignores ordinary saves), `on-idle`, `on-save`, + `interval`, or `manual`. Debounced + single-flight so bursts coalesce; a + watch refresh is **structural-only** by default (`embed_on_watch` / + `enrich_on_watch` off) so it never burns embedding/LLM spend per save. Branch + gating (`watch.branches`) watches feature branches and skips `main` / + `release/*`. `ckg watch --status` reports trigger · store · freshness; + `ckg watch --once` runs one refresh and exits. + - **`ckg watch` refuses a central (`store.central_root`) or read-only store** — + that topology's freshness is CI's job. This local/central split is enforced + in the engine (a developer's watch loop must never race writes into the + shared, authoritative graph). + - **`ckg ci init`** scaffolds a self-contained + `.github/workflows/ckg-index.yml` (managed-marker, idempotent, `--print`, + `--force`) that indexes into the central store on merge-to-`main` + nightly, + single-writer (`concurrency` group). CI is the sole authoritative writer, so + read-only consumers (ENH-018) can trust it. + - New `[watch]` PyPI extra (`watchfiles`, lazy-loaded so the base install stays + lean) and a `watch:` config block. See + [guide 12](docs/guides/12-watch-and-ci-indexing.md). + +### Fixed + +- **Post-release verification no longer false-alarms (#148).** `postrelease.yml` + now runs off the **Release workflow completing successfully** (`workflow_run`), + not the GitHub `release: published` event. Under the on-demand OIDC publish + model the release is published before the artifact reaches PyPI, so the old + trigger polled PyPI too early, 404'd for ~10 min, and auto-opened a spurious + "post-release smoke failed" issue every release. + ## [0.6.2] — 2026-06-27 ### Added diff --git a/README.md b/README.md index 395cbc1..7bc74a7 100644 --- a/README.md +++ b/README.md @@ -56,10 +56,14 @@ ckg serve-mcp --repo . # → 10 read-only tools for your agent - 🔎 **Hybrid retrieval** — vector search **entry** → typed **graph expansion**. Ask in natural language, get *connected* context: the symbol, its callers, *and* its governing decision. -- ⚡ **Incremental by default** — re-index only the diff. Edit 3 files in a 5k-file - repo → seconds, not minutes. Embeddings/enrichment recompute only what changed. -- 🤖 **Agent-native** — served read-only over **MCP (10 tools)** or as a native - AgentForge toolset. Every response carries a staleness envelope. +- ⚡ **Incremental & always-fresh** — re-index only the diff (edit 3 files in a + 5k-file repo → seconds, not minutes; embeddings/enrichment recompute only what + changed). Keep it fresh automatically: **`ckg watch`** re-indexes your working + copy on a trigger you choose (commit / idle / save), and **`ckg ci init`** + scaffolds a workflow that keeps the shared central index fresh on every merge. +- 🤖 **Agent-native, wired in one command** — served read-only over **MCP (10 + tools)** or as a native AgentForge toolset, every response carrying a staleness + envelope. **`ckg setup`** writes your agent's MCP config for you. - 🧠 **LLM enrichment, budgeted & opt-in** — design-pattern tags (*"this class is a Repository,"* with confidence + rationale) and bottom-up module summaries, all `llm`-provenance. **CI needs no model calls or cloud creds.** @@ -194,6 +198,19 @@ by default), shows a diff first, and is reversible with `--undo` — see [guide 11](docs/guides/11-agent-auto-configuration.md). Over HTTP, point any MCP client at the URL: `{"mcpServers": {"ckg": {"url": "http://127.0.0.1:8765/mcp"}}}`. +### Keep it fresh + +```bash +ckg watch # local: re-index on a trigger (commit/idle/save) +ckg ci init # central: scaffold a CI workflow that indexes on merge +``` + +`ckg watch` (opt-in, `pip install 'agentforge-graph[watch]'`) keeps your working +copy's graph current — `on-commit` by default, so it won't churn on every save — +and refuses a central / read-only store. `ckg ci init` writes a single-writer +`.github/workflows/ckg-index.yml` so the shared index refreshes deterministically +on merge-to-`main`. See [guide 12](docs/guides/12-watch-and-ci-indexing.md). + Or as a native AgentForge toolset: ```python diff --git a/docs/design/design-014-watch-and-ci-indexing.md b/docs/design/design-014-watch-and-ci-indexing.md new file mode 100644 index 0000000..2fe561b --- /dev/null +++ b/docs/design/design-014-watch-and-ci-indexing.md @@ -0,0 +1,177 @@ +# design-014: Watch mode + CI-triggered central indexing + +Mirrors [feat-014](../features/feat-014-watch-and-ci-indexing.md). The *how*: +file layout, types, resolved decisions. + +## Guiding constraints + +- **Framework-free** (ADR-0001): the watch package lives under + `ingest.watch`, orchestrating the deterministic `CodeGraph.refresh()`. No + `agentforge` import. +- **The trigger core is pure and clock-injected** so the whole policy matrix is + unit-tested with no filesystem, no sleeps, no `watchfiles`. The fs-watch loop + is a thin adapter over that core. +- **The local/central split is enforced in code**, not documented — `ckg watch` + refuses a central/read-only store before opening anything. + +## Package layout + +``` +src/agentforge_graph/ingest/watch/ + __init__.py # exports: Event, EventKind, TriggerPolicy, WatchSettings, + # WatchGuardError, ensure_watchable, branch_active, + # WatchRunner, WatchStatus, run_watch + policy.py # Event, EventKind, WatchSettings, TriggerPolicy (pure core) + gitwatch.py # head_ref(repo) -> str; branch_active(branch, inc, exc) + guard.py # WatchGuardError; ensure_watchable(store_cfg, read_only) + filter.py # WatchFilter: classify a changed path (ignored/git/source) + runner.py # WatchRunner (loop) + WatchStatus + run_watch() + source.py # watchfiles-backed async event source (lazy import) + +src/agentforge_graph/ci/ + __init__.py # exports: scaffold_workflow, render_workflow, CiInitResult + scaffold.py # provider dispatch + managed-marker write/idempotency + github.py # the GitHub Actions workflow template +``` + +## The trigger core (`policy.py`) + +```python +class EventKind(Enum): + FILE = "file" # a source file changed (already ignore-filtered) + GIT = "git" # .git/HEAD or refs changed (commit / branch switch) + +@dataclass(frozen=True) +class Event: + kind: EventKind + path: str = "" + +@dataclass(frozen=True) +class WatchSettings: # resolved from WatchConfig + CLI overrides + trigger: str # on-commit | on-idle | on-save | interval | manual + debounce_ms: int + idle_ms: int + interval_ms: int +``` + +`TriggerPolicy` holds `_pending: bool` and timestamps (`_last_event`, +`_last_git`, `_last_fire`), all in **seconds** (float). Methods, all taking an +injected `now: float`: + +- `observe(event, now)` — decide if the event counts for this mode: + - `manual`: ignore everything. + - `on-commit`: ignore FILE; on GIT set pending + `_last_git = now`. + - `on-idle` / `on-save`: FILE and GIT set pending + `_last_event = now` + (a commit is a real change too). + - `interval`: any event sets pending + `_last_event = now`. +- `due(now) -> bool` — should a refresh fire now? + - `on-commit`: pending and `now - _last_git >= debounce_ms` (small debounce + coalesces a branch-switch storm). + - `on-idle`: pending and `now - _last_event >= idle_ms`. + - `on-save`: pending and `now - _last_event >= debounce_ms`. + - `interval`: pending and `now - _last_fire >= interval_ms`. + - `manual`: always False. +- `next_due_in(now) -> float | None` — seconds until `due` flips True given + current pending state, or `None` when nothing is pending (wait indefinitely). + Drives the loop's timeout so the idle/interval window elapses even with no new + events. +- `reset(now)` — clear pending, `_last_fire = now`. Called after each refresh. + +This is the entire testable surface: feed `observe`/`due`/`next_due_in` a +scripted clock and assert timing, with no I/O. + +## The loop (`runner.py`) + +`WatchRunner` is constructed with the resolved settings, a `refresh` coroutine, +a `now` callable (default `time.monotonic`), and a `pull` coroutine +(`async (timeout: float | None) -> Event | None`, `None` = timeout elapsed). The +default `pull` wraps `source.py` (watchfiles); tests inject a scripted `pull`. + +``` +run(): + active = branch_active(current_branch, include, exclude) # may be False → idle-watch for a switch + while not stopped: + timeout = policy.next_due_in(now()) + ev = await pull(timeout) # None on timeout + t = now() + if ev and ev.kind is GIT: + # branch switch may flip the gate + active = branch_active(current_branch, include, exclude) + if active and ev: + policy.observe(ev, t) + if active and policy.due(t): + await self._do_refresh() # CodeGraph.refresh() [+ embed if embed_on_watch] + policy.reset(now()) +``` + +**Single-flight** falls out of the sequential `await`: events during a refresh +are buffered by watchfiles / the OS and observed on the next `pull`. + +`--once` short-circuits the loop: one `CodeGraph.refresh()` (+ optional embed), +print the report, exit — no watcher constructed. `--status` reads `IndexMeta` +(indexed commit + mtime) + git head to print trigger mode · store · last commit +· dirty?, needing no running process (no IPC). + +## Guard (`guard.py`) + +```python +def ensure_watchable(store_cfg: StoreConfig, read_only: bool) -> None: + if store_cfg.central_root: + raise WatchGuardError("central store … build it in CI (ckg ci init)") + if read_only or store_cfg.read_only: + raise WatchGuardError("read-only store … watch only a writable embedded index") +``` + +The CLI handler calls this first and maps `WatchGuardError` to a stderr message ++ exit 2, reusing the ENH-018 read-only wiring already in `cli.py`. + +## Ignore filter (`filter.py`) + +`WatchFilter.classify(rel_path) -> EventKind | None`: +- path under `.git/` → is it `HEAD` or under `refs/` or `packed-refs`? → `GIT`; + any other `.git/` churn → `None` (ignored). +- matches an exclude glob (`IngestConfig.exclude` ∪ `DEFAULT_EXCLUDES` ∪ + `watch.ignore`) via `PurePosixPath(rel).full_match(glob)` → `None`. +- otherwise, does a language pack claim the extension? no → `None`; yes → `FILE`. + +Reuses the exact `full_match` matching `RepoSource` uses, so what watch reacts to +== what the indexer would ingest. `watchfiles` is given this as its filter so +ignored churn never even wakes the loop. + +## `watchfiles` dependency (`source.py`, `[watch]` extra) + +`watchfiles` (Rust `notify`, cross-platform, async `awatch`) is a new optional +dependency behind a `[watch]` extra — base install and CI stay lean, mirroring +`[rerank]`. `source.py` lazy-imports it; a missing dep raises a clear +"`pip install agentforge-graph[watch]`" hint (not a traceback). Where events are +unreliable, `watchfiles` already falls back to polling internally. + +## CI scaffolder (`ci/`) + +`ckg ci init` renders `.github/workflows/ckg-index.yml` from `github.py`: a +self-contained workflow (`pip install agentforge-graph[]` + `ckg index` +against `${{ secrets.CKG_CENTRAL_STORE_URL }}`), triggered on push-to-`main`, +nightly cron, and manual dispatch, with a `concurrency.group: ckg-central-index` +(single writer). The file opens with a managed marker comment +(`# managed-by: agentforge-graph`); `scaffold.py` refuses to overwrite a file +lacking the marker unless `--force`, is idempotent when the content matches, and +supports `--print` (render to stdout, write nothing) — the same discipline as +feat-013's `_managed_by` merge. A separately-versioned `ckg-index-action` is +explicitly out of scope (feat-014 §6); the self-contained workflow ships today. + +## Config (`WatchConfig`, in `config.py`) + +A new `_Block` with `KEY = "watch"` and a nested `BranchGate(include, exclude)` +(pydantic sub-model like `GraphCfg`). Auto-discovered by `block_keys()`. CLI +flags (`--trigger`, `--idle-ms`, `--debounce-ms`, `--interval-ms`, `--once`, +`--status`, `--embed`) override the loaded block into a `WatchSettings`. + +## Test plan + +`tests/watch/`: `test_policy.py` (the full trigger matrix, injected clock), +`test_filter.py` (git-meta vs source vs ignored), `test_gitwatch.py` +(branch_active globs), `test_guard.py` (central/read-only refusal), +`test_runner.py` (scripted `pull` + fake `refresh`: single-flight coalescing, +branch-gate activation, `--once`), `test_cli_watch.py` (flag→settings, guard +exit 2, `--status`). `tests/ci/test_scaffold.py` (render validity, idempotency, +clobber refusal, `--print`). Fake embedder / `asyncio_mode=auto` throughout. diff --git a/docs/features/TRACKER.md b/docs/features/TRACKER.md index ef0a484..836a781 100644 --- a/docs/features/TRACKER.md +++ b/docs/features/TRACKER.md @@ -58,6 +58,7 @@ Legend: `proposed` → `accepted` → `in-progress` → `shipped` (also | [011](feat-011-framework-extractors.md) | Framework-aware extractors | 3 diff | 0.4 | shipped v0.3.0 (7 packs: routes/ORM/DI) | 002 | — | 🟢 | | [012](feat-012-llm-enrichment.md) | LLM enrichment (summaries, tags) | 3 diff | 0.4 | shipped (tags + summaries) | 006 | — | ✅ | | [013](feat-013-agent-auto-configuration.md) | Agent auto-configuration & frictionless first run | 4 adoption | 0.6.2 | **shipped (0.6.2)** | 008 | — | ✅ | +| [014](feat-014-watch-and-ci-indexing.md) | Watch mode (local) + CI-triggered central indexing | 4 adoption | 0.6.3 | **shipped (0.6.3)** | 004 | — | ✅ | --- @@ -140,6 +141,7 @@ ride alongside, not on, this chain. | **0.5** ✅ RELEASED | **Org-level central knowledge** — CKG as a shared, org-wide code brain for devs + agents | ENH-018, ENH-019, ENH-020 | Knowledge hostable centrally + consumed read-only; zero-config repo discovery; **one federated MCP endpoint** across services with cross-service tracing — see [theme](../enhancements/THEME-org-central-knowledge.md) | | **0.6** ✅ RELEASED | **Workspace build** — the build/setup side of org-central knowledge | ENH-021, ENH-022, ENH-023, ENH-024, ENH-026 | Stand up a multi-repo CKG from one `workspace.yaml` + one config + `ckg build --workspace` (members local or git URL); fail-fast `ckg doctor`; config/CLI-controlled logging/tracing | | **0.6.2** ✅ RELEASED | **Adoption** — shrink "installed" → "an agent is querying my repo" | feat-013 (FA-002 + FA-001 P1) | `ckg setup` auto-writes the agent's MCP config (Claude Code first) + optional `--hooks` nudges toward the graph tools; `uvx`/`pipx` zero-install trial blessed | +| **0.6.3** ✅ RELEASED | **Freshness / ops** — keep the graph current without remembering to re-index | feat-014 (FA-005) | `ckg watch` re-indexes the local working copy on a conditional trigger (commit/idle/save, structural-only by default), refusing central/read-only stores; `ckg ci init` scaffolds a single-writer CI workflow that refreshes the central index on merge | --- diff --git a/docs/features/feat-014-watch-and-ci-indexing.md b/docs/features/feat-014-watch-and-ci-indexing.md new file mode 100644 index 0000000..566f061 --- /dev/null +++ b/docs/features/feat-014-watch-and-ci-indexing.md @@ -0,0 +1,189 @@ +# feat-014: Watch mode (local) + CI-triggered central indexing + +## Metadata + +| Field | Value | +|---|---| +| **ID** | feat-014 | +| **Title** | Watch mode (local, conditional triggers) + CI-triggered central indexing | +| **Status** | shipped (0.6.3) | +| **Target version** | 0.6.3 | +| **Layer** | 4 adoption (freshness / ops) | +| **Area** | `ingest.watch` (watch loop + trigger policy) · `ci` (workflow scaffolder) · CLI | +| **Depends on** | feat-004 (incremental `refresh()`), feat-003 (store + `meta.json`), ENH-018 (read-only consumers), ENH-019 (store discovery) | +| **Graduated from** | [FA-005](../feature-analysis/FA-005-watch-mode-and-ci-indexing.md) | +| **Relates to** | feat-013 (`ckg setup` wires the *local* agent; `ckg ci init` wires the *central* pipeline) | + +--- + +## 1. Why this feature + +feat-004 made re-indexing cheap and incremental, but the **trigger** is still +manual — someone must run `ckg index`. That leaves the graph stale in the two +moments it matters most, one per deployment topology: + +- **Local dev:** an agent working a repo wants the graph to track the + developer's edits without anyone remembering to re-index — but a naive + "re-index on every save" loop would thrash on burst-saves and burn + embedding/LLM spend. The real need is **conditional, configurable triggers**. +- **Central / shared:** a server-backed index (`store.central_root`, ENH-018) + is the authoritative, read-mostly graph many agents consume. Its freshness + must come from a **deterministic CI trigger** (merge to `main`, nightly), + *never* from developers' machines. + +These are **different freshness mechanisms for different stores** and must not +share a trigger path. feat-014 ships both, with a hard guardrail keeping them +separate. + +## 2. Why it ships in the engine + +- `refresh()` is already the right primitive (feat-004); only the trigger is + missing. Watch and CI are thin orchestration over it — building them in each + consumer would re-invent staleness logic. +- **The local/central split is a correctness boundary, not a preference.** + Letting watch write a shared store creates write contention and + non-deterministic graphs; the engine *enforces* the split (§8), so it lives + in core. +- A deterministic central index (CI-as-sole-writer) is what makes read-only + consumers (ENH-018) trustworthy. + +Watch stays framework-free (ADR-0001): it's `ingest.watch`, orchestrating the +deterministic `CodeGraph.refresh()`; no `agentforge` import. + +## 3. How consumers benefit + +- **Local developer:** `ckg watch` — the agent's view tracks edits + automatically, gated by a trigger policy you choose. +- **Team / org:** `ckg ci init` scaffolds a workflow; the central index + refreshes deterministically on merge-to-`main`, and every agent querying it + (read-only) sees a known commit. +- **Both at once (ideal):** local watch keeps *your* branch fresh in `.ckg/`; + the central index serves canonical `main` read-only — neither pollutes the + other. + +## 4. Specification + +### 4.1 CLI + +```bash +ckg watch # start the watch loop (configured trigger policy) +ckg watch --trigger on-idle --idle-ms 3000 +ckg watch --trigger on-commit # refresh on git commit / branch switch only (default) +ckg watch --once # run one refresh if dirty, then exit (no loop) +ckg watch --status # trigger mode · store · last indexed commit · dirty? + +ckg ci init # scaffold .github/workflows/ckg-index.yml +ckg ci init --print # print the workflow to stdout, write nothing +ckg ci init --mode full --embed --enrich --force +``` + +### 4.2 Contract + +- **`ckg watch`** — runs feat-004 `refresh()` on the configured trigger. + **Refuses** (clear error, exit 2) when the resolved store is central + (`store.central_root`) or read-only (`store.read_only` / `--read-only` / + `$CKG_READ_ONLY`). Requires the `[watch]` extra (`watchfiles`); a missing + dependency is a clear install hint, not a traceback. +- **`ckg ci init`** — writes a self-contained GitHub Actions workflow (managed + marker comment; idempotent; refuses to clobber an unmanaged file without + `--force`). GitHub first; the scaffolder is provider-pluggable like feat-013's + agent adapters. +- **Trigger policy** is a locked enum (§4.4). Adding a policy is a minor change. + +### 4.3 Watch mechanics + +1. **Filesystem events** (`watchfiles`, native fs-watch) over the repo, + filtered by the same excludes the indexer uses (`.gitignore`-style globs + + `.ckgignore` + `watch.ignore`); `.ckg/`, `.git/`, `node_modules`, `.venv` + never trigger. +2. **Trigger policy** decides *when* a batch of events becomes a refresh: + - `on-commit` **(default)** — refresh only on git events (`.git/HEAD` + + refs change: a commit or branch switch). Ordinary saves do **not** trigger. + Zero churn while editing; the graph tracks committed state. + - `on-idle` — refresh after editing goes quiet for `idle_ms`. + - `on-save` — refresh after a `debounce_ms` window (coalesce burst-saves). + - `interval` — periodic refresh if dirty, every `interval_ms`. + - `manual` — off (explicit `ckg index` only). +3. **Branch gating** — `branches.include`/`exclude` globs decide whether to + watch on the current branch (default: watch all but `main` / `release/*`). + Re-evaluated on branch switch. +4. **Cheap by default** — a watch refresh does the **structural** re-extract + + re-resolve only. Embeddings and LLM enrichment do **not** run per trigger + (`embed_on_watch` / `enrich_on_watch` off); the dirty set (feat-004) drains + on the next explicit `ckg embed` or when `embed_on_watch` is enabled. +5. **Single-flight** — the loop refreshes sequentially; events arriving during a + refresh coalesce into the next batch (no pile-up). + +### 4.4 Config (`watch:` block) + +```yaml +watch: + enabled: false # opt-in; LOCAL EMBEDDED STORE ONLY (refuses central/read_only) + trigger: on-commit # on-commit | on-idle | on-save | interval | manual + debounce_ms: 1000 # on-save: coalesce bursts + idle_ms: 3000 # on-idle: quiet period before refresh + interval_ms: 60000 # interval: periodic refresh if dirty + branches: + include: ["*"] + exclude: ["main", "release/*"] + ignore: [] # extra globs beyond the indexer's excludes + embed_on_watch: false # keep watch cheap: defer embeddings + enrich_on_watch: false # never auto-run LLM enrichment on a trigger +``` + +CI behavior is configured by the scaffolded workflow's inputs, not this block. + +### 4.5 Central CI indexing + +`ckg ci init` scaffolds `.github/workflows/ckg-index.yml`: installs +`agentforge-graph`, runs `ckg index` against the central store, on +push-to-`main` + nightly + manual dispatch. A `concurrency.group` ensures one +writer at a time (no central write races). Incremental works directly — the +central store's `meta.json` indexed-commit lets CI refresh only the diff. +Embedding/enrichment run server-side in CI (creds from secrets) so consumers +read a complete graph. + +## 5. Test strategy + +- **Trigger-policy units:** simulated event streams + injected clock assert + `on-save`/`on-idle` debounce, `on-commit` ignores saves and fires on a git + event, `interval` fires only when dirty, `manual` never fires. +- **Central-refusal (load-bearing):** `ckg watch` against a central / read-only + store fails fast, writes nothing. +- **Cheap-by-default:** a watch refresh re-extracts structure but does not embed + unless `embed_on_watch`. +- **Branch-gating:** inactive on an excluded branch; switching to an included + branch activates it. +- **Single-flight:** a burst during an in-flight refresh coalesces to one + follow-up refresh. +- **Ignore:** edits under excludes / `watch.ignore` / `.ckg/` trigger nothing. +- **CI scaffold:** renders a valid workflow with the repo's store config; + idempotent; refuses to clobber unmanaged; `--print` writes nothing. + +## 6. Out of scope + +- A long-running multi-repo indexing **daemon** (watch is per-repo). +- CI providers beyond GitHub at first (pluggable, like feat-013 adapters). +- A separately-published `agentforge/ckg-index-action` (the scaffolded workflow + is self-contained via `pip install` + `ckg index`; a versioned Action is a + follow-up). +- Pushing local watch results *into* the central store — explicitly forbidden. + +## 7. The mental model (the feature in one glance) + +| | Local dev | Central / shared | +|---|---|---| +| Store | embedded `.ckg/` | server backend (`central_root`) | +| Writer | the developer (single) | **CI only** (single authoritative) | +| Trigger | **`ckg watch`** (opt-in, conditional) | **CI workflow** on merge / nightly | +| Reflects | uncommitted working edits | committed `main` (deterministic) | +| Consumers | that developer's agent | many agents, **read-only** (ENH-018) | +| Cost | incremental, structural-only by default | incremental per run; embeds server-side | + +## Implementation status + +Shipped in 0.6.3. `agentforge_graph.ingest.watch` (policy · git-watch · branch +gate · guard · watchfiles loop) + `agentforge_graph.ci` (workflow scaffolder); +`ckg watch` / `ckg watch --status` / `ckg ci init`; `watch:` config block; +`[watch]` PyPI extra. See [design-014](../design/design-014-watch-and-ci-indexing.md) +and guide [`12-watch-and-ci-indexing.md`](../guides/12-watch-and-ci-indexing.md). diff --git a/docs/guides/12-watch-and-ci-indexing.md b/docs/guides/12-watch-and-ci-indexing.md new file mode 100644 index 0000000..c3ed44e --- /dev/null +++ b/docs/guides/12-watch-and-ci-indexing.md @@ -0,0 +1,177 @@ +# 12 — Watch mode & CI indexing (keep the graph fresh) + +> **TL;DR** — Indexing is cheap and incremental (feat-004), but you still have to +> *trigger* it. feat-014 adds the two automatic triggers: **`ckg watch`** keeps +> your local working copy fresh; a **`ckg ci init`** workflow keeps the shared / +> central index fresh. They target different stores and the engine keeps them +> apart — a developer's watch loop can never write the central graph. + +This guide is both the how-to and the operational reference. If you only ever +run `ckg index` by hand, you don't need it — reach for it when you want the graph +to track edits without remembering to re-index. + +--- + +## The one-glance mental model + +| | Local dev | Central / shared | +|---|---|---| +| Store | embedded `.ckg/` | server backend (`store.central_root`) | +| Writer | you (single) | **CI only** (single authoritative) | +| Trigger | **`ckg watch`** (opt-in, conditional) | **CI workflow** on merge / nightly | +| Reflects | your uncommitted working edits | committed `main` (deterministic) | +| Consumers | your agent | many agents, **read-only** (ENH-018) | +| Cost | incremental, structural-only by default | incremental per run; embeds server-side | + +The split is load-bearing: `ckg watch` **refuses** to run against a central or +read-only store. Central freshness must come from CI so every consumer sees a +known commit. + +--- + +## Part A — Local watch mode (`ckg watch`) + +### Install & run + +Watch needs a cross-platform file watcher, shipped as an optional extra: + +```bash +pip install "agentforge-graph[watch]" # or: uv sync --extra watch +ckg watch # start watching the current repo +``` + +`ckg watch` re-runs the incremental refresh whenever its **trigger** says a +change is worth re-indexing. It is **off until you run it** (or set +`watch.enabled`), and it only ever writes your local `.ckg/`. + +### "Will it re-index on every save?" — no, unless you ask + +The default trigger is **`on-commit`**: the graph refreshes when you commit or +switch branches, and ordinary file saves do nothing. Zero churn while you type; +the graph tracks committed state. Pick a different trigger when you want the +agent to see uncommitted work: + +| Trigger | Fires when… | Use it for | +|---|---|---| +| `on-commit` *(default)* | you commit / switch branch | predictable, no mid-edit churn | +| `on-idle` | editing goes quiet for `idle_ms` (3s) | reflect WIP without per-save churn | +| `on-save` | saves stop for `debounce_ms` (1s) | aggressively track every saved edit | +| `interval` | periodically, if anything changed | steady cadence regardless of activity | +| `manual` | never (explicit `ckg index` only) | turn the loop off but keep config | + +```bash +ckg watch --trigger on-idle --idle-ms 3000 +ckg watch --trigger on-save --debounce-ms 500 +ckg watch --once # one refresh if dirty, then exit (handy in scripts) +ckg watch --status # trigger · store · last indexed commit · dirty? +``` + +### It stays cheap + +A watch refresh does the **structural** re-extract + re-resolve only. +**Embeddings and LLM enrichment are not run on a trigger** — they'd cost money on +every save. The changed symbols are dirty-tracked; drain them when you choose: + +```bash +ckg embed . # embed what watch dirtied, on your terms +ckg watch --embed # or: also drain embeddings on each refresh +``` + +Bursts coalesce (debounce + single-flight): a flurry of saves becomes one +refresh, and events during an in-flight refresh fold into the next one. + +### Branch gating + +By default watch is active on feature branches and **skips `main` / `release/*`** +— you rarely want to re-index over a protected branch you're not editing. On a +branch switch the gate is re-evaluated; `ckg watch --status` tells you whether +watch is active on the current branch. + +### Configuration (`watch:` block) + +```yaml +watch: + enabled: false # opt-in; LOCAL EMBEDDED STORE ONLY + trigger: on-commit # on-commit | on-idle | on-save | interval | manual + debounce_ms: 1000 # on-save: coalesce burst-saves + idle_ms: 3000 # on-idle: quiet period before a refresh + interval_ms: 60000 # interval: periodic refresh if dirty + branches: + include: ["*"] + exclude: ["main", "release/*"] + ignore: [] # extra globs beyond the indexer's excludes + embed_on_watch: false # keep watch cheap: defer embeddings + enrich_on_watch: false # never auto-run LLM enrichment on a trigger +``` + +CLI flags (`--trigger`, `--idle-ms`, `--debounce-ms`, `--interval-ms`, +`--embed`) override the block for one run. + +### What it ignores + +Watch reacts to exactly what the indexer would ingest — source files a language +pack claims, honoring the same excludes plus `watch.ignore`. It never triggers on +`.ckg/`, `.git/` internals, `node_modules`, `.venv`, or non-source files. It does +watch `.git/HEAD` + refs — that's how `on-commit` sees commits and branch +switches. + +--- + +## Part B — Central indexing in CI (`ckg ci init`) + +A shared index that many agents consume `--read-only` must be built by **one +authoritative writer on a deterministic trigger** — CI — never from developers' +machines. Scaffold that: + +```bash +ckg ci init # write .github/workflows/ckg-index.yml +ckg ci init --print # preview, write nothing +ckg ci init --mode full --extra bedrock --enrich +ckg ci init --force # replace an existing (unmanaged) file +``` + +The generated workflow: + +- runs on **push to `main`**, a **nightly cron**, and manual dispatch; +- installs `agentforge-graph` and runs `ckg index` (+ `ckg embed`) against your + central store; +- uses a **`concurrency` group** so exactly one indexing job writes at a time — + no central write races; +- is **incremental** — the central store's `meta.json` indexed-commit means CI + refreshes only the diff since the last run. + +It opens with a managed-marker comment; re-running `ckg ci init` is idempotent +and won't clobber a workflow you've hand-edited unless you pass `--force`. + +### Wiring the central store + +1. Configure the shared store in your committed `agentforge.yaml` + (`store.central_root` + the graph/vectors backend — see + [guide 09](09-storage-backends.md) and + [getting started 3](getting-started/3-central-store.md)). +2. Add the provider creds your embed/enrich step needs as repo **secrets** + (e.g. `AWS_*` for Bedrock, `OPENAI_API_KEY`), plus `CKG_CENTRAL_STORE_URL` + if your backend uses one. +3. Commit the workflow. On the next merge to `main`, CI refreshes the central + index and every read-only consumer sees the new commit. + +--- + +## Both at once (the ideal) + +Local watch keeps *your* working branch fresh in `.ckg/`; the CI workflow serves +canonical `main` read-only to the team. Neither pollutes the other — the engine +guarantees it. This pairs with [`ckg setup`](11-agent-auto-configuration.md), +which wires your *local* agent to the CKG; `ckg ci init` wires the *central* +pipeline. + +## Troubleshooting + +| Symptom | Cause / fix | +|---|---| +| `ckg watch` exits 2: "refusing to watch a central store" | Your store is `central_root` — build it in CI (`ckg ci init`), not a watch loop. | +| `ckg watch` exits 2: "read-only store" | `store.read_only` / `--read-only` / `$CKG_READ_ONLY` is set; watch a writable embedded index. | +| `ckg watch` errors asking for the `watch` extra | `pip install "agentforge-graph[watch]"`. | +| Watch seems idle while editing | Default `on-commit` ignores saves — commit, or use `--trigger on-idle`. | +| `--status` says "branch gated out" | You're on `main`/`release/*` (excluded by default); switch branches or edit `watch.branches`. | +| Search returns stale results after a watch refresh | Watch is structural-only by default — run `ckg embed .` (or `--embed`). | diff --git a/docs/guides/README.md b/docs/guides/README.md index 94bf789..3c64d59 100644 --- a/docs/guides/README.md +++ b/docs/guides/README.md @@ -31,6 +31,7 @@ setup. They build on each other in order. | 09 | [Storage backends](09-storage-backends.md) | Embedded by default; switch to Neo4j / pgvector / SurrealDB via config. | | 10 | [Using over MCP](10-using-over-mcp.md) | Serve the CKG to an agent over 10 read-only tools (stdio/HTTP) or in-process. | | 11 | [Agent auto-configuration](11-agent-auto-configuration.md) | `ckg setup` writes your agent's MCP config (repo `.mcp.json` / `--scope user`) + `--hooks` nudge; reversible. | +| 12 | [Watch mode & CI indexing](12-watch-and-ci-indexing.md) | Keep the graph fresh: `ckg watch` (local, conditional triggers) + `ckg ci init` (central, CI-as-sole-writer). | > New here? Start with **[Getting started → a single repo](getting-started/1-single-repo.md)**. > Building an agent on top? Jump to **[10 — Using over MCP](10-using-over-mcp.md)**. diff --git a/docs/release/0.6.3.md b/docs/release/0.6.3.md new file mode 100644 index 0000000..313f2aa --- /dev/null +++ b/docs/release/0.6.3.md @@ -0,0 +1,57 @@ +# agentforge-graph 0.6.3 — keep the graph fresh, automatically + +**Theme: freshness / ops.** 0.6.2 made it one command to *connect* the graph to +your agent; 0.6.3 makes it automatic to *keep it current*. Re-indexing was +already cheap and incremental — now you don't have to remember to run it. A local +**`ckg watch`** loop tracks your working copy; a scaffolded CI workflow keeps the +shared central index fresh. The two target different stores, and the engine keeps +them apart. + +## Highlights + +### `ckg watch` — local freshness on a trigger you choose (feat-014) + +```bash +pip install "agentforge-graph[watch]" +ckg watch # re-index on a trigger (on-commit by default) +ckg watch --trigger on-idle # or reflect uncommitted edits when you pause +ckg watch --status # trigger · store · last indexed commit · dirty? +ckg watch --once # one refresh if dirty, then exit +``` + +- **Won't churn or burn spend.** Default trigger is `on-commit` — ordinary saves + do nothing; the graph tracks committed state. `on-idle` / `on-save` / + `interval` / `manual` are there when you want them, all debounced and + single-flight so bursts coalesce. A watch refresh is **structural-only** by + default (`embed_on_watch` / `enrich_on_watch` off), so it never runs embeddings + or LLM enrichment per save. +- **Branch-gated.** Watches feature branches, skips `main` / `release/*` by + default (`watch.branches`). +- **Can't corrupt a shared store.** `ckg watch` refuses a central + (`store.central_root`) or read-only store — that split is enforced in the + engine, so a developer's loop never races writes into the authoritative graph. + +### `ckg ci init` — deterministic central indexing (feat-014) + +```bash +ckg ci init # write .github/workflows/ckg-index.yml +ckg ci init --print # preview, write nothing +ckg ci init --extra bedrock --enrich +``` + +Scaffolds a self-contained workflow that indexes into the central store on +merge-to-`main` + nightly, **single-writer** (`concurrency` group, no write +races), incremental (only the diff since the last indexed commit). CI is the sole +authoritative writer, so read-only consumers can trust it. Managed-marker + +idempotent; won't clobber a hand-edited file without `--force`. + +See the new guide: **[Watch mode & CI indexing](../guides/12-watch-and-ci-indexing.md)**. + +## Fixed + +- **Post-release verification no longer false-alarms (#148).** `postrelease.yml` + now triggers off the **Release workflow completing successfully** + (`workflow_run`) instead of the GitHub `release: published` event — under + on-demand OIDC publishing the release exists before the artifact reaches PyPI, + so the old trigger polled too early and auto-opened a spurious failure issue + each release. diff --git a/docs/release/README.md b/docs/release/README.md index 4a165f2..943dbc5 100644 --- a/docs/release/README.md +++ b/docs/release/README.md @@ -8,4 +8,5 @@ narrative. | Version | Notes | Theme | |---|---|---| +| [0.6.3](0.6.3.md) | `ckg watch` + `ckg ci init` — keep the graph fresh | freshness / ops | | [0.6.2](0.6.2.md) | `ckg setup` — agent auto-configuration | adoption | diff --git a/pyproject.toml b/pyproject.toml index 69016bf..71fca35 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,6 +57,9 @@ engine = [] # Optional cross-encoder reranker (ENH-009, OFF by default — pulls torch). # `uv sync --extra rerank`; lazy-loaded, so the base install / CI stay torch-free. rerank = ["sentence-transformers>=2.2"] +# Local watch mode (feat-014). `ckg watch` needs a cross-platform fs-watcher; +# lazy-loaded so the base install stays lean. `uv sync --extra watch`. +watch = ["watchfiles>=0.24"] # Opt-in server graph backend: Neo4j (ENH-004 / ADR-0006). `--extra neo4j`. neo4j = ["neo4j>=5"] # Opt-in server vector backend: Postgres + pgvector (ENH-004). `--extra pgvector`. @@ -137,6 +140,7 @@ module = [ "pgvector.*", # ENH-004 pgvector backend; in the `pgvector` extra "surrealdb.*", # ENH-010 SurrealDB backend; in the `surrealdb` extra "sentence_transformers.*", # ENH-009 cross-encoder rerank; in the `rerank` extra + "watchfiles.*", # feat-014 watch mode; in the `watch` extra, lazy-loaded "agentforge.*", "agentforge_core.*", "agentforge_mcp.*", diff --git a/src/agentforge_graph/ci/__init__.py b/src/agentforge_graph/ci/__init__.py new file mode 100644 index 0000000..cead7fc --- /dev/null +++ b/src/agentforge_graph/ci/__init__.py @@ -0,0 +1,18 @@ +"""feat-014: ``ckg ci init`` — scaffold a CI workflow that keeps the shared, +central index fresh (CI as the single authoritative writer). GitHub first; the +scaffolder is provider-pluggable. Framework-free (ADR-0001).""" + +from __future__ import annotations + +from .github import MARKER, WORKFLOW_REL_PATH, render_workflow +from .scaffold import PROVIDERS, CiInitError, CiInitResult, scaffold_workflow + +__all__ = [ + "MARKER", + "WORKFLOW_REL_PATH", + "render_workflow", + "PROVIDERS", + "CiInitError", + "CiInitResult", + "scaffold_workflow", +] diff --git a/src/agentforge_graph/ci/github.py b/src/agentforge_graph/ci/github.py new file mode 100644 index 0000000..37b551d --- /dev/null +++ b/src/agentforge_graph/ci/github.py @@ -0,0 +1,86 @@ +"""feat-014: the GitHub Actions workflow template for central indexing. + +Renders a **self-contained** workflow — ``pip install agentforge-graph`` + +``ckg index`` against the repo's configured (central) store — triggered on +push-to-``main``, a nightly cron, and manual dispatch, with a ``concurrency`` +group so exactly one indexing job writes the shared store at a time. A +separately-versioned ``ckg-index-action`` is out of scope (feat-014 §6); this +ships today with no external action to publish first. +""" + +from __future__ import annotations + +MARKER = "# managed-by: agentforge-graph (ckg ci init)" +WORKFLOW_REL_PATH = ".github/workflows/ckg-index.yml" + + +def render_workflow( + *, + mode: str = "incremental", + embed: bool = True, + enrich: bool = False, + extras: list[str] | None = None, +) -> str: + """Render the workflow YAML. ``mode`` ``full`` forces a full re-index; + ``incremental`` (default) refreshes only the diff since the last indexed + commit. ``extras`` are PyPI extras to install (e.g. ``["bedrock"]`` for + server-side embeddings).""" + if mode not in ("incremental", "full"): + raise ValueError(f"mode must be 'incremental' or 'full', got {mode!r}") + spec = "agentforge-graph" + if extras: + spec = f"agentforge-graph[{','.join(extras)}]" + full_flag = " --full" if mode == "full" else "" + steps = [f" ckg index .{full_flag}"] + if embed: + steps.append(" ckg embed .") + if enrich: + steps.append(" ckg enrich .") + index_block = "\n".join(steps) + return f"""{MARKER} — safe to edit; re-run `ckg ci init --force` to regenerate. +name: CKG central index + +# Deterministic, single-writer freshness for the shared/central index +# (store.central_root). CI is the ONLY writer — developers' machines never write +# the central store (that split is enforced by `ckg watch`). See feat-014. + +on: + push: + branches: [main] + schedule: + - cron: "0 3 * * *" # nightly safety net + workflow_dispatch: {{}} + +concurrency: + group: ckg-central-index # one writer at a time — no central write races + cancel-in-progress: false + +permissions: + contents: read + +jobs: + index: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 # full history so the temporal layer can seed + + - uses: actions/setup-python@v6 + with: + python-version: "3.13" + + - name: Install agentforge-graph + run: pip install "{spec}" + + # Point at the shared/central store and provide provider creds via repo + # secrets. Configure the store in the committed agentforge.yaml + # (store.central_root + graph/vectors backend), or via the env below. + - name: Index into the central store ({mode}) + env: + CKG_CENTRAL_STORE_URL: ${{{{ secrets.CKG_CENTRAL_STORE_URL }}}} + # e.g. AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY for Bedrock embeddings, + # or OPENAI_API_KEY — add whatever your embed/enrich provider needs. + run: | +{index_block} +""" diff --git a/src/agentforge_graph/ci/scaffold.py b/src/agentforge_graph/ci/scaffold.py new file mode 100644 index 0000000..5234da8 --- /dev/null +++ b/src/agentforge_graph/ci/scaffold.py @@ -0,0 +1,62 @@ +"""feat-014: write the scaffolded CI workflow with feat-013's managed-marker +discipline — idempotent, and never clobbering an unmanaged file without +``--force``. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +from .github import MARKER, WORKFLOW_REL_PATH, render_workflow + +PROVIDERS = ("github",) + + +class CiInitError(Exception): + """Raised when the scaffolder refuses to act (unknown provider, or would + clobber an unmanaged file without ``--force``).""" + + +@dataclass +class CiInitResult: + path: Path + action: str # created | updated | noop | overwritten | printed + content: str + + +def scaffold_workflow( + repo: str | Path, + *, + provider: str = "github", + mode: str = "incremental", + embed: bool = True, + enrich: bool = False, + extras: list[str] | None = None, + force: bool = False, + print_only: bool = False, +) -> CiInitResult: + if provider not in PROVIDERS: + raise CiInitError(f"unknown CI provider {provider!r}; supported: {', '.join(PROVIDERS)}") + content = render_workflow(mode=mode, embed=embed, enrich=enrich, extras=extras) + target = Path(repo) / WORKFLOW_REL_PATH + + if print_only: + return CiInitResult(target, "printed", content) + + if target.exists(): + existing = target.read_text(encoding="utf-8") + if MARKER not in existing and not force: + raise CiInitError( + f"{target} exists and is not managed by agentforge-graph. " + "Refusing to overwrite it — pass --force to replace it." + ) + if existing == content: + return CiInitResult(target, "noop", content) + action = "overwritten" if MARKER not in existing else "updated" + target.write_text(content, encoding="utf-8") + return CiInitResult(target, action, content) + + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content, encoding="utf-8") + return CiInitResult(target, "created", content) diff --git a/src/agentforge_graph/cli.py b/src/agentforge_graph/cli.py index 555dbda..51d21c4 100644 --- a/src/agentforge_graph/cli.py +++ b/src/agentforge_graph/cli.py @@ -13,7 +13,7 @@ import sys from collections.abc import Sequence from pathlib import Path -from typing import Any +from typing import Any, cast from agentforge_graph.embed import EmbedReport from agentforge_graph.ingest import CodeGraph @@ -1086,6 +1086,74 @@ def build_parser() -> argparse.ArgumentParser: ) setup_p.set_defaults(func=_setup) + wt = sub.add_parser( + "watch", + help="watch the repo and re-index on a trigger (feat-014; local store only)", + ) + _add_repo_arg(wt) + wt.add_argument("--config", default=None, help="path to ckg.yaml") + wt.add_argument( + "--trigger", + choices=["on-commit", "on-idle", "on-save", "interval", "manual"], + default=None, + help="on-commit (default) | on-idle | on-save | interval | manual", + ) + wt.add_argument( + "--idle-ms", dest="idle_ms", type=int, default=None, help="on-idle: quiet period (ms)" + ) + wt.add_argument( + "--debounce-ms", + dest="debounce_ms", + type=int, + default=None, + help="on-save: burst-coalesce window (ms)", + ) + wt.add_argument( + "--interval-ms", + dest="interval_ms", + type=int, + default=None, + help="interval: periodic refresh window (ms)", + ) + wt.add_argument( + "--embed", + action="store_true", + help="also drain embeddings on each refresh (default: structural only)", + ) + wt.add_argument("--once", action="store_true", help="run one refresh if dirty, then exit") + wt.add_argument("--status", action="store_true", help="print trigger/store/freshness and exit") + wt.set_defaults(func=_watch) + + ci_p = sub.add_parser("ci", help="scaffold CI to keep a central index fresh (feat-014)") + ci_sub = ci_p.add_subparsers(dest="ci_cmd", required=True) + ci_init = ci_sub.add_parser("init", help="write .github/workflows/ckg-index.yml") + _add_repo_arg(ci_init) + ci_init.add_argument("--provider", choices=["github"], default="github", help="CI provider") + ci_init.add_argument( + "--mode", + choices=["incremental", "full"], + default="incremental", + help="incremental (default; diff-only) | full", + ) + ci_init.add_argument("--no-embed", action="store_true", help="omit the ckg embed step") + ci_init.add_argument("--enrich", action="store_true", help="add an LLM enrichment step") + ci_init.add_argument( + "--extra", + action="append", + default=[], + help="PyPI extra to install (repeatable), e.g. --extra bedrock", + ) + ci_init.add_argument( + "--force", action="store_true", help="overwrite an unmanaged workflow file" + ) + ci_init.add_argument( + "--print", + dest="print_only", + action="store_true", + help="print the workflow, write nothing", + ) + ci_init.set_defaults(func=_ci_init) + smap = sub.add_parser( "services-map", help="cross-service call graph over a workspace (ENH-020)" ) @@ -1138,6 +1206,122 @@ def build_parser() -> argparse.ArgumentParser: return parser +async def _watch(args: argparse.Namespace) -> int: + """feat-014: watch the repo and re-run the incremental refresh on a trigger. + Local embedded store only — refuses a central / read-only store.""" + from agentforge_graph.config import StoreConfig, WatchConfig, resolve_config + from agentforge_graph.ingest.watch import ( + WatchGuardError, + WatchSettings, + ensure_watchable, + run_once, + run_watch, + ) + from agentforge_graph.ingest.watch import status as watch_status + from agentforge_graph.ingest.watch.source import WatchDependencyError + + cfg = resolve_config(args.config, args.path) + wcfg = WatchConfig.load(cfg) + try: + settings = WatchSettings( + trigger=args.trigger or wcfg.trigger, + debounce_ms=args.debounce_ms or wcfg.debounce_ms, + idle_ms=args.idle_ms or wcfg.idle_ms, + interval_ms=args.interval_ms or wcfg.interval_ms, + ) + except ValueError as exc: + print(f"ckg watch: {exc}", file=sys.stderr) + return 2 + + if args.status: + print(watch_status(args.path, args.config, settings).render()) + return 0 + + # Load-bearing guardrail: watch may only write a local, writable, embedded + # index. Central / read-only stores are CI's job (ckg ci init). + try: + ensure_watchable(StoreConfig.load(cfg), _is_read_only(args)) + except WatchGuardError as exc: + print(f"ckg watch: {exc}", file=sys.stderr) + return 2 + + embed_on = args.embed or wcfg.embed_on_watch + if args.once: + report = await run_once(args.path, args.config, embed=embed_on, enrich=wcfg.enrich_on_watch) + print(_format_report(cast(IndexReport, report))) + return 0 + + def on_refresh(report: object) -> None: + print(_format_report(cast(IndexReport, report))) + + def on_gate(active: bool, branch: str) -> None: + if active: + print( + f"ckg watch: watching (trigger={settings.trigger}, " + f"branch={branch or 'detached'}) — Ctrl-C to stop", + file=sys.stderr, + ) + else: + print( + f"ckg watch: branch {branch!r} is gated out by watch.branches; " + "idle until you switch to a watched branch", + file=sys.stderr, + ) + + try: + await run_watch( + args.path, + args.config, + settings, + include=wcfg.branches.include, + exclude=wcfg.branches.exclude, + extra_ignore=wcfg.ignore, + embed_on_watch=embed_on, + enrich_on_watch=wcfg.enrich_on_watch, + on_refresh=on_refresh, + on_gate=on_gate, + ) + except WatchDependencyError as exc: + print(f"ckg watch: {exc}", file=sys.stderr) + return 2 + except KeyboardInterrupt: + print("\nckg watch: stopped", file=sys.stderr) + return 0 + + +async def _ci_init(args: argparse.Namespace) -> int: + """feat-014: scaffold a CI workflow that keeps the central index fresh.""" + from agentforge_graph.ci import CiInitError, scaffold_workflow + + try: + res = scaffold_workflow( + args.path, + provider=args.provider, + mode=args.mode, + embed=not args.no_embed, + enrich=args.enrich, + extras=args.extra or None, + force=args.force, + print_only=args.print_only, + ) + except (CiInitError, ValueError) as exc: + print(f"ckg ci init: {exc}", file=sys.stderr) + return 2 + + if res.action == "printed": + print(res.content, end="") + return 0 + print(f"ckg ci init: {res.action} {res.path}") + if res.action in ("created", "updated", "overwritten"): + print( + " next: commit this workflow, set the CKG_CENTRAL_STORE_URL secret + " + "provider creds,\n" + " and set store.central_root in agentforge.yaml so CI writes the " + "central index." + ) + return 0 + + def _configure_logging(args: argparse.Namespace) -> None: """Set engine log verbosity from flags → $CKG_LOG_LEVEL → ckg.yaml → warning.""" from agentforge_graph.config import LoggingConfig, resolve_config diff --git a/src/agentforge_graph/config.py b/src/agentforge_graph/config.py index aec21fe..9b9ca9f 100644 --- a/src/agentforge_graph/config.py +++ b/src/agentforge_graph/config.py @@ -325,6 +325,36 @@ class SetupConfig(_Block): agents: list[str] = Field(default_factory=list) +class BranchGate(BaseModel): + """feat-014: which branches ``ckg watch`` is active on (globs, ``full_match``). + Default watches everything except the shared/protected branches a developer + should not be locally re-indexing over.""" + + include: list[str] = Field(default_factory=lambda: ["*"]) + exclude: list[str] = Field(default_factory=lambda: ["main", "release/*"]) + + +class WatchConfig(_Block): + """The ``watch:`` block (feat-014 — local watch-mode freshness). + + Drives ``ckg watch``, which re-runs the feat-004 incremental ``refresh()`` + on a configurable trigger. **Local embedded store only** — the CLI refuses a + central (`store.central_root`) or read-only store; that path's freshness is + CI's job (``ckg ci init``). Framework-free (ADR-0001).""" + + KEY: ClassVar[str] = "watch" + enabled: bool = False # opt-in + # on-commit (default) | on-idle | on-save | interval | manual + trigger: str = "on-commit" + debounce_ms: int = 1000 # on-save: coalesce burst-saves + idle_ms: int = 3000 # on-idle: quiet period before a refresh + interval_ms: int = 60000 # interval: periodic refresh if dirty + branches: BranchGate = Field(default_factory=BranchGate) + ignore: list[str] = Field(default_factory=list) # extra globs beyond the indexer excludes + embed_on_watch: bool = False # keep watch cheap: defer embeddings by default + enrich_on_watch: bool = False # never auto-run LLM enrichment on a trigger by default + + class FrameworksConfig(_Block): """The ``frameworks:`` block of ckg.yaml (feat-011).""" diff --git a/src/agentforge_graph/ingest/watch/__init__.py b/src/agentforge_graph/ingest/watch/__init__.py new file mode 100644 index 0000000..7c7dee7 --- /dev/null +++ b/src/agentforge_graph/ingest/watch/__init__.py @@ -0,0 +1,42 @@ +"""feat-014: local watch mode — re-run the feat-004 incremental ``refresh()`` on +a configurable trigger. + +The pure trigger core (:mod:`.policy`) is clock-injected and fully unit-tested; +the fs-watch loop (:mod:`.runner` + :mod:`.source`) is a thin adapter over it. +``ckg watch`` refuses a central / read-only store (:mod:`.guard`) — that +topology's freshness is CI's job (``ckg ci init``). Framework-free (ADR-0001). +""" + +from __future__ import annotations + +from .filter import WatchFilter +from .gitwatch import branch_active, current_branch, head_ref +from .guard import WatchGuardError, ensure_watchable +from .policy import Event, EventKind, TriggerPolicy, WatchSettings +from .runner import ( + WatchRunner, + WatchStatus, + WatchStopped, + run_once, + run_watch, + status, +) + +__all__ = [ + "Event", + "EventKind", + "TriggerPolicy", + "WatchSettings", + "WatchFilter", + "WatchGuardError", + "ensure_watchable", + "branch_active", + "current_branch", + "head_ref", + "WatchRunner", + "WatchStatus", + "WatchStopped", + "run_watch", + "run_once", + "status", +] diff --git a/src/agentforge_graph/ingest/watch/filter.py b/src/agentforge_graph/ingest/watch/filter.py new file mode 100644 index 0000000..cc04be3 --- /dev/null +++ b/src/agentforge_graph/ingest/watch/filter.py @@ -0,0 +1,73 @@ +"""feat-014: classify a changed path into a watch :class:`Event` (or ignore it). + +The single source of truth for "what does watch react to". It reacts to exactly +what the indexer would ingest — reusing the same ``full_match`` glob matching +:class:`~agentforge_graph.ingest.source.RepoSource` uses — plus git metadata +(``HEAD`` / refs) so the ``on-commit`` trigger can see commits and branch +switches. Everything else (``.git`` internals, ``node_modules``, ``.venv``, the +``.ckg`` index itself, non-source files) is ignored, so ignored churn never even +wakes the loop. +""" + +from __future__ import annotations + +from pathlib import Path, PurePosixPath + +from agentforge_graph.config import DEFAULT_EXCLUDES +from agentforge_graph.ingest.pack import PackRegistry + +from .policy import Event, EventKind + +# git metadata whose change means "the commit/branch moved": HEAD itself, any +# ref under refs/, and the packed-refs file (branch switches / commits touch one). +_GIT_REF_HINTS = ("HEAD", "packed-refs") + + +class WatchFilter: + def __init__( + self, + registry: PackRegistry, + *, + excludes: list[str] | None = None, + extra_ignore: list[str] | None = None, + ) -> None: + self.registry = registry + base = list(DEFAULT_EXCLUDES) if excludes is None else list(excludes) + self.excludes = base + list(extra_ignore or []) + + def classify(self, rel: str) -> Event | None: + """Map a repo-relative posix path to the event it should raise, or None to + ignore it. Git metadata is checked *before* excludes (``.git`` is excluded + for ingestion but its HEAD/refs are exactly what ``on-commit`` needs).""" + parts = PurePosixPath(rel).parts + if parts and parts[0] == ".git": + if _is_git_ref(parts): + return Event(EventKind.GIT, rel) + return None + if self._excluded(rel): + return None + if self.registry.for_extension(PurePosixPath(rel).suffix) is None: + return None + return Event(EventKind.FILE, rel) + + def keep(self, rel: str) -> bool: + """watchfiles filter form: keep (wake the loop) iff the path classifies.""" + return self.classify(rel) is not None + + def relative(self, root: str | Path, path: str | Path) -> str | None: + """Repo-relative posix form of an absolute event path, or None if outside + the repo (watchfiles only reports inside-root paths, but be defensive).""" + try: + return Path(path).resolve().relative_to(Path(root).resolve()).as_posix() + except ValueError: + return None + + def _excluded(self, rel: str) -> bool: + return any(PurePosixPath(rel).full_match(glob) for glob in self.excludes) + + +def _is_git_ref(parts: tuple[str, ...]) -> bool: + # parts[0] == ".git" + if len(parts) >= 2 and parts[1] in _GIT_REF_HINTS: + return True + return len(parts) >= 2 and parts[1] == "refs" diff --git a/src/agentforge_graph/ingest/watch/gitwatch.py b/src/agentforge_graph/ingest/watch/gitwatch.py new file mode 100644 index 0000000..f9e78ef --- /dev/null +++ b/src/agentforge_graph/ingest/watch/gitwatch.py @@ -0,0 +1,53 @@ +"""feat-014: git-branch helpers for watch mode. + +Read the current branch/HEAD and decide whether watch is active on it. Pure +stdlib + a light git read; framework-free (ADR-0001). +""" + +from __future__ import annotations + +from fnmatch import fnmatch +from pathlib import Path + + +def current_branch(repo: str | Path) -> str: + """The current branch name, or "" when detached / not a git repo. + + Read straight from ``.git/HEAD`` (``ref: refs/heads/``) so no + subprocess is needed — the watch loop polls this cheaply on git events.""" + head = Path(repo) / ".git" / "HEAD" + try: + text = head.read_text(encoding="utf-8").strip() + except OSError: + return "" + prefix = "ref: refs/heads/" + return text[len(prefix) :] if text.startswith(prefix) else "" + + +def head_ref(repo: str | Path) -> str: + """The raw HEAD contents (branch ref or detached sha) — the token the loop + diffs to detect a commit / branch switch.""" + head = Path(repo) / ".git" / "HEAD" + try: + return head.read_text(encoding="utf-8").strip() + except OSError: + return "" + + +def branch_active(branch: str, include: list[str], exclude: list[str]) -> bool: + """Whether watch should run on ``branch`` given include/exclude globs. + + Exclude wins over include. An empty branch (detached HEAD / no git) is treated + as active — the developer explicitly started ``ckg watch`` there, and there is + no branch name to gate on.""" + if not branch: + return True + if any(_match(branch, g) for g in exclude): + return False + return any(_match(branch, g) for g in include) + + +def _match(branch: str, glob: str) -> bool: + # fnmatch (not pathlib full_match): a branch is not a path — its `*` must + # cross `/` so `*` matches `feature/x` and `release/*` matches `release/0.6.3`. + return fnmatch(branch, glob) diff --git a/src/agentforge_graph/ingest/watch/guard.py b/src/agentforge_graph/ingest/watch/guard.py new file mode 100644 index 0000000..42147b3 --- /dev/null +++ b/src/agentforge_graph/ingest/watch/guard.py @@ -0,0 +1,32 @@ +"""feat-014: the local/central guardrail. + +``ckg watch`` writes the working-copy graph and therefore may only run against a +**local, writable, embedded** store. A central store (`store.central_root`) is +CI's job (`ckg ci init`); a read-only store is consume-only (ENH-018). Enforcing +that split here — not in docs — is load-bearing: it is what keeps developers' +machines from racing writes into the shared, authoritative graph. +""" + +from __future__ import annotations + +from agentforge_graph.config import StoreConfig + + +class WatchGuardError(Exception): + """Raised when ``ckg watch`` is pointed at a store it must not write.""" + + +def ensure_watchable(store_cfg: StoreConfig, read_only: bool) -> None: + """Refuse (raise) unless the store is a local, writable, embedded index.""" + if store_cfg.central_root: + raise WatchGuardError( + "refusing to watch a central store (store.central_root is set). " + "A shared/central index must be built by CI, not a developer's watch " + "loop — scaffold that with `ckg ci init`. Watch only a local .ckg/ index." + ) + if read_only or store_cfg.read_only: + raise WatchGuardError( + "refusing to watch a read-only store " + "(store.read_only / --read-only / $CKG_READ_ONLY). " + "This index is consume-only; watch a writable embedded index instead." + ) diff --git a/src/agentforge_graph/ingest/watch/policy.py b/src/agentforge_graph/ingest/watch/policy.py new file mode 100644 index 0000000..1030d49 --- /dev/null +++ b/src/agentforge_graph/ingest/watch/policy.py @@ -0,0 +1,128 @@ +"""feat-014: the pure trigger-policy core. + +Given a stream of :class:`Event` and an injected clock, ``TriggerPolicy`` +decides *when* a batch of changes becomes a refresh. It holds no filesystem, no +timers, and never sleeps — the whole policy matrix is unit-tested by feeding +``observe`` / ``due`` / ``next_due_in`` a scripted ``now`` (seconds, float). The +fs-watch loop (:mod:`.runner`) is a thin adapter that turns real time and real +file events into calls on this object. + +Zero ``agentforge`` imports (ADR-0001). +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum + +TRIGGERS = ("on-commit", "on-idle", "on-save", "interval", "manual") + +# Slack on the "window elapsed" comparison. When the loop waits exactly +# ``next_due_in`` seconds and the clock lands a float-ulp short of the threshold, +# treat it as elapsed anyway — a sub-microsecond boundary is never meaningful for +# a debounce/idle window, and without it a timeout can spuriously not fire. +_EPS = 1e-6 + + +class EventKind(Enum): + FILE = "file" # a source file changed (already ignore-filtered) + GIT = "git" # .git/HEAD or refs changed (commit / branch switch) + + +@dataclass(frozen=True) +class Event: + kind: EventKind + path: str = "" + + +@dataclass(frozen=True) +class WatchSettings: + """Resolved trigger settings (from ``WatchConfig`` + CLI overrides).""" + + trigger: str = "on-commit" + debounce_ms: int = 1000 + idle_ms: int = 3000 + interval_ms: int = 60000 + + def __post_init__(self) -> None: + if self.trigger not in TRIGGERS: + raise ValueError(f"unknown trigger {self.trigger!r}; expected one of {TRIGGERS}") + + +class TriggerPolicy: + """Trigger state machine. All times are seconds (float); the caller owns the + clock so tests are deterministic.""" + + def __init__(self, settings: WatchSettings, *, now: float = 0.0) -> None: + self.settings = settings + self._pending = False + self._last_event = 0.0 # last counted FILE/GIT event (debounce/idle) + self._last_git = 0.0 # last GIT event (on-commit) + self._last_fire = now # last refresh (interval) + + # --- inputs ----------------------------------------------------------- + + def observe(self, event: Event, now: float) -> None: + """Record an event if it counts for the active trigger.""" + t = self.settings.trigger + if t == "manual": + return + if t == "on-commit": + if event.kind is EventKind.GIT: + self._pending = True + self._last_git = now + return + # on-idle / on-save / interval: any (already-filtered) event counts. A + # commit is a real change too, so GIT counts here as well. + self._pending = True + self._last_event = now + + # --- decisions -------------------------------------------------------- + + def due(self, now: float) -> bool: + """Whether a refresh should fire at ``now`` given pending state.""" + if not self._pending: + return False + t = self.settings.trigger + if t == "manual": + return False + if t == "on-commit": + return now - self._last_git >= self._sec("debounce_ms") - _EPS + if t == "on-idle": + return now - self._last_event >= self._sec("idle_ms") - _EPS + if t == "on-save": + return now - self._last_event >= self._sec("debounce_ms") - _EPS + if t == "interval": + return now - self._last_fire >= self._sec("interval_ms") - _EPS + return False + + def next_due_in(self, now: float) -> float | None: + """Seconds until :meth:`due` flips True given the current pending state, + clamped at 0; ``None`` when nothing is pending (wait indefinitely). Drives + the loop timeout so an idle/interval window elapses without a new event.""" + if not self._pending or self.settings.trigger == "manual": + return None + t = self.settings.trigger + if t == "on-commit": + target = self._last_git + self._sec("debounce_ms") + elif t == "on-idle": + target = self._last_event + self._sec("idle_ms") + elif t == "on-save": + target = self._last_event + self._sec("debounce_ms") + elif t == "interval": + target = self._last_fire + self._sec("interval_ms") + else: # pragma: no cover - exhaustive + return None + return max(0.0, target - now) + + def reset(self, now: float) -> None: + """Clear pending state after a refresh fired.""" + self._pending = False + self._last_fire = now + + @property + def pending(self) -> bool: + return self._pending + + def _sec(self, field: str) -> float: + return float(getattr(self.settings, field)) / 1000.0 diff --git a/src/agentforge_graph/ingest/watch/runner.py b/src/agentforge_graph/ingest/watch/runner.py new file mode 100644 index 0000000..50b522b --- /dev/null +++ b/src/agentforge_graph/ingest/watch/runner.py @@ -0,0 +1,240 @@ +"""feat-014: the watch loop and its static status reader. + +``WatchRunner`` is the thin adapter that turns real time + real file events into +calls on the pure :class:`~agentforge_graph.ingest.watch.policy.TriggerPolicy`. +It is constructed with injectable dependencies — a ``pull`` coroutine (the event +source), a ``refresh`` coroutine, a ``now`` clock, and a ``branch_of`` reader — +so the whole loop is unit-tested with a scripted event stream, a fake refresh and +a fake clock: no filesystem, no ``watchfiles``, no sleeping. + +``run_watch`` is the real wiring the CLI uses; ``status`` powers ``--status``. +Framework-free (ADR-0001). +""" + +from __future__ import annotations + +import time +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from pathlib import Path + +from .gitwatch import branch_active, current_branch +from .policy import Event, EventKind, TriggerPolicy, WatchSettings + + +class WatchStopped(Exception): + """Raised by a ``pull`` to end the loop cleanly (Ctrl-C in real use, an + exhausted script in tests).""" + + +# Types: pull(timeout|None) -> Event|None (None = the timeout elapsed with no +# event); refresh() -> anything (the IndexReport, opaque to the loop). +Pull = Callable[[float | None], Awaitable[Event | None]] +Refresh = Callable[[], Awaitable[object]] +Now = Callable[[], float] + + +class WatchRunner: + def __init__( + self, + settings: WatchSettings, + *, + pull: Pull, + refresh: Refresh, + branch_of: Callable[[], str] = lambda: "", + include: list[str] | None = None, + exclude: list[str] | None = None, + now: Now = time.monotonic, + on_refresh: Callable[[object], None] | None = None, + on_gate: Callable[[bool, str], None] | None = None, + ) -> None: + self.settings = settings + self.policy = TriggerPolicy(settings, now=now()) + self._pull = pull + self._refresh = refresh + self._branch_of = branch_of + self._include = include if include is not None else ["*"] + self._exclude = exclude if exclude is not None else [] + self._now = now + self._on_refresh = on_refresh + self._on_gate = on_gate + self.refreshes = 0 # count (tests / status) + + def _active(self) -> bool: + return branch_active(self._branch_of(), self._include, self._exclude) + + async def run(self) -> int: + """Loop until the ``pull`` source ends (``WatchStopped``). Returns the + number of refreshes performed.""" + active = self._active() + if self._on_gate: + self._on_gate(active, self._branch_of()) + try: + while True: + timeout = self.policy.next_due_in(self._now()) if active else None + ev = await self._pull(timeout) + t = self._now() + if ev is not None and ev.kind is EventKind.GIT: + # a branch switch can flip the gate; re-evaluate on git events + was, active = active, self._active() + if self._on_gate and active != was: + self._on_gate(active, self._branch_of()) + if active and ev is not None: + self.policy.observe(ev, t) + if active and self.policy.due(t): + await self._do_refresh() + except WatchStopped: + return self.refreshes + return self.refreshes + + async def _do_refresh(self) -> None: + # Single-flight: the loop awaits the refresh, so events arriving during it + # are buffered by the source and observed on the next pull. + report = await self._refresh() + self.refreshes += 1 + self.policy.reset(self._now()) + if self._on_refresh: + self._on_refresh(report) + + +async def _build_refresh(cg: object, *, embed_on_watch: bool, enrich_on_watch: bool) -> Refresh: + """A refresh coroutine over an already-open ``CodeGraph``: structural + ``refresh()`` always; embeddings/enrichment only when explicitly enabled + (watch stays cheap by default — feat-014 §4.3).""" + + async def _refresh() -> object: + report = await cg.refresh() # type: ignore[attr-defined] + if embed_on_watch: + await cg.embed(only_dirty=True) # type: ignore[attr-defined] + if enrich_on_watch: + await cg.enrich() # type: ignore[attr-defined] + return report + + return _refresh + + +async def run_watch( + repo: str | Path, + config: str | None, + settings: WatchSettings, + *, + include: list[str], + exclude: list[str], + extra_ignore: list[str], + embed_on_watch: bool = False, + enrich_on_watch: bool = False, + on_refresh: Callable[[object], None] | None = None, + on_gate: Callable[[bool, str], None] | None = None, +) -> int: + """Open the repo once, watch it, and refresh on the configured trigger until + interrupted. Returns the number of refreshes performed. The caller must have + already run the guard (:func:`.guard.ensure_watchable`).""" + from agentforge_graph.ingest import CodeGraph + + from ..codegraph import _source_registry + from .filter import WatchFilter + from .source import WatchfilesSource + + _source, registry = _source_registry(str(repo), config, None) + wfilter = WatchFilter(registry, extra_ignore=extra_ignore) + source = WatchfilesSource(repo, wfilter) + source.start() + cg = await CodeGraph.open(repo_path=str(repo), config=config) + try: + refresh = await _build_refresh( + cg, embed_on_watch=embed_on_watch, enrich_on_watch=enrich_on_watch + ) + runner = WatchRunner( + settings, + pull=source.pull, + refresh=refresh, + branch_of=lambda: current_branch(repo), + include=include, + exclude=exclude, + on_refresh=on_refresh, + on_gate=on_gate, + ) + return await runner.run() + finally: + await source.aclose() + await cg.close() + + +async def run_once( + repo: str | Path, + config: str | None, + *, + embed: bool = False, + enrich: bool = False, +) -> object: + """One refresh (+ optional embed/enrich), then return the report — the + ``--once`` path. No watcher is constructed.""" + from agentforge_graph.ingest import CodeGraph + + cg = await CodeGraph.open(repo_path=str(repo), config=config) + try: + report = await cg.refresh() + if embed: + await cg.embed(only_dirty=True) + if enrich: + await cg.enrich() + return report + finally: + await cg.close() + + +@dataclass +class WatchStatus: + trigger: str + store_root: str + indexed_commit: str + head_commit: str + dirty: bool + branch: str + active: bool + central: bool + read_only: bool + + def render(self) -> str: + lines = [ + f"trigger: {self.trigger}", + f"store: {self.store_root}" + + (" (central)" if self.central else "") + + (" (read-only)" if self.read_only else ""), + f"branch: {self.branch or '(detached / no git)'}", + f"watch active: {'yes' if self.active else 'no — branch gated out'}", + f"indexed commit: {self.indexed_commit or '(none)'}", + f"head commit: {self.head_commit or '(not a git repo)'}", + f"dirty: {'yes — a refresh would re-index' if self.dirty else 'no'}", + ] + return "\n".join(lines) + + +def status(repo: str | Path, config: str | None, settings: WatchSettings) -> WatchStatus: + """Static status — no running watcher needed (no IPC). Reads the feat-004 + ``IndexMeta`` (indexed commit) + git HEAD to report freshness, and the + ``watch.branches`` gate to report whether watch would run on this branch.""" + from agentforge_graph.config import StoreConfig, WatchConfig, resolve_config + from agentforge_graph.ingest.codegraph import _git_commit + from agentforge_graph.ingest.incremental import IndexMeta + from agentforge_graph.store import resolve_root + + cfg = resolve_config(config, str(repo)) + store_cfg = StoreConfig.load(cfg) + watch_cfg = WatchConfig.load(cfg) + root = resolve_root(str(repo), store_cfg) + meta = IndexMeta.load(root) + head = _git_commit(repo) + branch = current_branch(repo) + dirty = bool(head) and bool(meta.indexed_commit) and head != meta.indexed_commit + return WatchStatus( + trigger=settings.trigger, + store_root=str(root), + indexed_commit=meta.indexed_commit, + head_commit=head, + dirty=dirty, + branch=branch, + active=branch_active(branch, watch_cfg.branches.include, watch_cfg.branches.exclude), + central=bool(store_cfg.central_root), + read_only=store_cfg.read_only, + ) diff --git a/src/agentforge_graph/ingest/watch/source.py b/src/agentforge_graph/ingest/watch/source.py new file mode 100644 index 0000000..6ba5cc2 --- /dev/null +++ b/src/agentforge_graph/ingest/watch/source.py @@ -0,0 +1,78 @@ +"""feat-014: the ``watchfiles``-backed event source (the only place that touches +the fs-watch dependency). + +Lazy-imports ``watchfiles`` so the base install stays lean; a missing dep is a +clear ``pip install agentforge-graph[watch]`` hint, not a traceback. Converts the +raw change stream into the single-event ``pull(timeout)`` shape ``WatchRunner`` +consumes: an :class:`Event`, or ``None`` when the timeout elapsed with no event. +""" + +from __future__ import annotations + +import asyncio +import contextlib +from pathlib import Path + +from .filter import WatchFilter +from .policy import Event + +_INSTALL_HINT = ( + "ckg watch needs the 'watch' extra. Install it with:\n" + " pip install 'agentforge-graph[watch]' (or: uv sync --extra watch)" +) + + +def _import_watchfiles() -> object: + try: + import watchfiles # noqa: F401 + except ImportError as exc: # pragma: no cover - exercised via the CLI hint + raise WatchDependencyError(_INSTALL_HINT) from exc + return watchfiles + + +class WatchDependencyError(Exception): + """The optional ``watchfiles`` dependency is not installed.""" + + +class WatchfilesSource: + """Runs ``watchfiles.awatch`` in a background task, classifying each change + into an :class:`Event` and enqueuing it for :meth:`pull`.""" + + def __init__(self, root: str | Path, wfilter: WatchFilter) -> None: + self.root = str(Path(root).resolve()) + self.filter = wfilter + self._queue: asyncio.Queue[Event] = asyncio.Queue() + self._task: asyncio.Task[None] | None = None + + def start(self) -> None: + wf = _import_watchfiles() + awatch = wf.awatch # type: ignore[attr-defined] + + def _keep(_change: object, path: str) -> bool: + rel = self.filter.relative(self.root, path) + return rel is not None and self.filter.keep(rel) + + async def _consume() -> None: + async for changes in awatch(self.root, watch_filter=_keep): + for _change, path in changes: + rel = self.filter.relative(self.root, path) + if rel is None: + continue + ev = self.filter.classify(rel) + if ev is not None: + self._queue.put_nowait(ev) + + self._task = asyncio.ensure_future(_consume()) + + async def pull(self, timeout: float | None) -> Event | None: + try: + return await asyncio.wait_for(self._queue.get(), timeout) + except TimeoutError: + return None + + async def aclose(self) -> None: + if self._task is not None: + self._task.cancel() + with contextlib.suppress(asyncio.CancelledError, Exception): + await self._task + self._task = None diff --git a/tests/ci/__init__.py b/tests/ci/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/ci/test_scaffold.py b/tests/ci/test_scaffold.py new file mode 100644 index 0000000..c423b4e --- /dev/null +++ b/tests/ci/test_scaffold.py @@ -0,0 +1,90 @@ +"""feat-014: ckg ci init — workflow rendering + managed-marker discipline.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from agentforge_graph.ci import ( + MARKER, + WORKFLOW_REL_PATH, + CiInitError, + render_workflow, + scaffold_workflow, +) + + +def test_render_has_marker_and_single_writer() -> None: + wf = render_workflow() + assert wf.startswith(MARKER) + assert "concurrency:" in wf + assert "group: ckg-central-index" in wf + assert "branches: [main]" in wf + assert "ckg index ." in wf + assert "ckg embed ." in wf # embed on by default + + +def test_render_full_mode_and_no_embed() -> None: + wf = render_workflow(mode="full", embed=False) + assert "ckg index . --full" in wf + assert "ckg embed ." not in wf + + +def test_render_extras_and_enrich() -> None: + wf = render_workflow(extras=["bedrock"], enrich=True) + assert 'pip install "agentforge-graph[bedrock]"' in wf + assert "ckg enrich ." in wf + + +def test_render_rejects_bad_mode() -> None: + with pytest.raises(ValueError): + render_workflow(mode="sideways") + + +def test_creates_workflow_file(tmp_path: Path) -> None: + res = scaffold_workflow(tmp_path) + assert res.action == "created" + assert res.path == tmp_path / WORKFLOW_REL_PATH + assert res.path.read_text().startswith(MARKER) + + +def test_idempotent_noop(tmp_path: Path) -> None: + scaffold_workflow(tmp_path) + res = scaffold_workflow(tmp_path) + assert res.action == "noop" + + +def test_update_when_options_change(tmp_path: Path) -> None: + scaffold_workflow(tmp_path, mode="incremental") + res = scaffold_workflow(tmp_path, mode="full") + assert res.action == "updated" + assert "ckg index . --full" in res.path.read_text() + + +def test_refuses_to_clobber_unmanaged(tmp_path: Path) -> None: + target = tmp_path / WORKFLOW_REL_PATH + target.parent.mkdir(parents=True) + target.write_text("name: my hand-written workflow\n") + with pytest.raises(CiInitError, match="not managed"): + scaffold_workflow(tmp_path) + + +def test_force_overwrites_unmanaged(tmp_path: Path) -> None: + target = tmp_path / WORKFLOW_REL_PATH + target.parent.mkdir(parents=True) + target.write_text("name: my hand-written workflow\n") + res = scaffold_workflow(tmp_path, force=True) + assert res.action == "overwritten" + assert res.path.read_text().startswith(MARKER) + + +def test_print_only_writes_nothing(tmp_path: Path) -> None: + res = scaffold_workflow(tmp_path, print_only=True) + assert res.action == "printed" + assert not (tmp_path / WORKFLOW_REL_PATH).exists() + + +def test_unknown_provider(tmp_path: Path) -> None: + with pytest.raises(CiInitError, match="unknown CI provider"): + scaffold_workflow(tmp_path, provider="jenkins") diff --git a/tests/watch/__init__.py b/tests/watch/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/watch/test_cli_watch.py b/tests/watch/test_cli_watch.py new file mode 100644 index 0000000..ca4dc9f --- /dev/null +++ b/tests/watch/test_cli_watch.py @@ -0,0 +1,78 @@ +"""feat-014: CLI wiring for `ckg watch` and `ckg ci init` (sync — main() owns the +event loop).""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from agentforge_graph.ci import MARKER, WORKFLOW_REL_PATH +from agentforge_graph.cli import main + + +@pytest.fixture(autouse=True) +def _clean_env(): # type: ignore[no-untyped-def] + # main() bridges --read-only to the process env; keep tests isolated so a + # --read-only case never leaks CKG_READ_ONLY into the next test's store. + os.environ.pop("CKG_READ_ONLY", None) + yield + os.environ.pop("CKG_READ_ONLY", None) + + +def _tiny_repo(root: Path) -> Path: + (root / "pkg").mkdir(parents=True) + (root / "pkg" / "mod.py").write_text("def a():\n return 1\n") + return root + + +def test_ci_init_print_writes_nothing(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + rc = main(["ci", "init", "--print", "--path", str(tmp_path)]) + assert rc == 0 + assert MARKER in capsys.readouterr().out + assert not (tmp_path / WORKFLOW_REL_PATH).exists() + + +def test_ci_init_creates_file(tmp_path: Path) -> None: + rc = main(["ci", "init", "--path", str(tmp_path)]) + assert rc == 0 + assert (tmp_path / WORKFLOW_REL_PATH).read_text().startswith(MARKER) + + +def test_watch_status(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + repo = _tiny_repo(tmp_path) + assert main(["index", str(repo)]) == 0 + capsys.readouterr() # drop index output + rc = main(["watch", "--status", "--path", str(repo)]) + assert rc == 0 + out = capsys.readouterr().out + assert "trigger:" in out + assert "on-commit" in out # the default + + +def test_watch_once_refreshes(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + repo = _tiny_repo(tmp_path) + assert main(["index", str(repo)]) == 0 + (repo / "pkg" / "extra.py").write_text("def b():\n return 2\n") + capsys.readouterr() + rc = main(["watch", "--once", "--path", str(repo)]) + assert rc == 0 + + +def test_watch_refuses_central_store(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + repo = _tiny_repo(tmp_path) + cfg = tmp_path / "agentforge.yaml" + cfg.write_text(f"app:\n store:\n central_root: {tmp_path / 'central'}\n") + rc = main(["watch", "--path", str(repo), "--config", str(cfg)]) + assert rc == 2 + assert "central" in capsys.readouterr().err + + +def test_watch_refuses_read_only(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + repo = _tiny_repo(tmp_path) + assert main(["index", str(repo)]) == 0 + capsys.readouterr() + rc = main(["watch", "--read-only", "--path", str(repo)]) + assert rc == 2 + assert "read-only" in capsys.readouterr().err diff --git a/tests/watch/test_filter.py b/tests/watch/test_filter.py new file mode 100644 index 0000000..6136a3a --- /dev/null +++ b/tests/watch/test_filter.py @@ -0,0 +1,59 @@ +"""feat-014: WatchFilter classifies paths the same way the indexer would ingest, +plus git metadata for on-commit.""" + +from __future__ import annotations + +from agentforge_graph.ingest.packs import builtin_registry +from agentforge_graph.ingest.watch import EventKind +from agentforge_graph.ingest.watch.filter import WatchFilter + + +def _f(extra: list[str] | None = None) -> WatchFilter: + return WatchFilter(builtin_registry(), extra_ignore=extra) + + +def test_source_file_is_a_file_event() -> None: + ev = _f().classify("pkg/mod.py") + assert ev is not None and ev.kind is EventKind.FILE + + +def test_git_head_and_refs_are_git_events() -> None: + f = _f() + assert f.classify(".git/HEAD").kind is EventKind.GIT # type: ignore[union-attr] + assert f.classify(".git/refs/heads/main").kind is EventKind.GIT # type: ignore[union-attr] + assert f.classify(".git/packed-refs").kind is EventKind.GIT # type: ignore[union-attr] + + +def test_other_git_churn_ignored() -> None: + assert _f().classify(".git/objects/ab/cdef") is None + assert _f().classify(".git/index") is None + + +def test_non_source_ignored() -> None: + assert _f().classify("README.md") is None # not an indexed language + assert _f().classify("data.bin") is None + + +def test_default_excludes_ignored() -> None: + f = _f() + assert f.classify("node_modules/x/y.py") is None + assert f.classify(".venv/lib/z.py") is None + assert f.classify(".ckg/meta.json") is None + + +def test_extra_ignore_globs_applied() -> None: + f = _f(extra=["**/generated/**"]) + assert f.classify("app/generated/models.py") is None + assert f.classify("app/real/models.py") is not None + + +def test_keep_is_classify_truthiness() -> None: + f = _f() + assert f.keep("pkg/mod.py") is True + assert f.keep(".git/objects/aa") is False + + +def test_relative_outside_root_is_none(tmp_path) -> None: # type: ignore[no-untyped-def] + f = _f() + assert f.relative(tmp_path, "/somewhere/else/x.py") is None + assert f.relative(tmp_path, tmp_path / "a" / "b.py") == "a/b.py" diff --git a/tests/watch/test_gitwatch.py b/tests/watch/test_gitwatch.py new file mode 100644 index 0000000..6f4f774 --- /dev/null +++ b/tests/watch/test_gitwatch.py @@ -0,0 +1,50 @@ +"""feat-014: branch gating + branch reading.""" + +from __future__ import annotations + +from pathlib import Path + +from agentforge_graph.ingest.watch import branch_active, current_branch, head_ref + + +def test_branch_active_include_all_exclude_protected() -> None: + inc, exc = ["*"], ["main", "release/*"] + assert branch_active("feature/x", inc, exc) is True + assert branch_active("fix/y", inc, exc) is True + assert branch_active("main", inc, exc) is False + assert branch_active("release/0.6.3", inc, exc) is False + + +def test_exclude_wins_over_include() -> None: + assert branch_active("main", ["main"], ["main"]) is False + + +def test_include_narrowing() -> None: + assert branch_active("feature/x", ["feature/*"], []) is True + assert branch_active("hotfix/x", ["feature/*"], []) is False + + +def test_detached_head_is_active() -> None: + # no branch name to gate on → the explicit `ckg watch` wins + assert branch_active("", ["feature/*"], ["*"]) is True + + +def test_read_branch_from_head(tmp_path: Path) -> None: + git = tmp_path / ".git" + git.mkdir() + (git / "HEAD").write_text("ref: refs/heads/feature/abc\n") + assert current_branch(tmp_path) == "feature/abc" + assert head_ref(tmp_path) == "ref: refs/heads/feature/abc" + + +def test_detached_head_reads_empty_branch(tmp_path: Path) -> None: + git = tmp_path / ".git" + git.mkdir() + (git / "HEAD").write_text("a1b2c3d4\n") + assert current_branch(tmp_path) == "" + assert head_ref(tmp_path) == "a1b2c3d4" + + +def test_no_git_dir() -> None: + assert current_branch("/nonexistent/path/xyz") == "" + assert head_ref("/nonexistent/path/xyz") == "" diff --git a/tests/watch/test_guard.py b/tests/watch/test_guard.py new file mode 100644 index 0000000..695e406 --- /dev/null +++ b/tests/watch/test_guard.py @@ -0,0 +1,35 @@ +"""feat-014: the load-bearing local/central guardrail.""" + +from __future__ import annotations + +import pytest + +from agentforge_graph.config import StoreConfig +from agentforge_graph.ingest.watch import WatchGuardError, ensure_watchable + + +def test_local_writable_store_ok() -> None: + ensure_watchable(StoreConfig(), read_only=False) # does not raise + + +def test_central_store_refused() -> None: + cfg = StoreConfig(central_root="/srv/ckg") + with pytest.raises(WatchGuardError, match="central"): + ensure_watchable(cfg, read_only=False) + + +def test_read_only_config_refused() -> None: + cfg = StoreConfig(read_only=True) + with pytest.raises(WatchGuardError, match="read-only"): + ensure_watchable(cfg, read_only=False) + + +def test_read_only_flag_refused() -> None: + with pytest.raises(WatchGuardError, match="read-only"): + ensure_watchable(StoreConfig(), read_only=True) + + +def test_central_takes_precedence_over_read_only() -> None: + cfg = StoreConfig(central_root="/srv/ckg", read_only=True) + with pytest.raises(WatchGuardError, match="central"): + ensure_watchable(cfg, read_only=True) diff --git a/tests/watch/test_policy.py b/tests/watch/test_policy.py new file mode 100644 index 0000000..512cb14 --- /dev/null +++ b/tests/watch/test_policy.py @@ -0,0 +1,76 @@ +"""feat-014: the trigger-policy matrix, with an injected clock (no I/O, no sleep).""" + +from __future__ import annotations + +from agentforge_graph.ingest.watch import Event, EventKind, TriggerPolicy, WatchSettings + +FILE = Event(EventKind.FILE, "a.py") +GIT = Event(EventKind.GIT, ".git/HEAD") + + +def _p(trigger: str, **kw: int) -> TriggerPolicy: + return TriggerPolicy(WatchSettings(trigger=trigger, **kw), now=0.0) + + +def test_manual_never_fires() -> None: + p = _p("manual") + p.observe(FILE, 1.0) + p.observe(GIT, 2.0) + assert p.due(1_000.0) is False + assert p.next_due_in(1_000.0) is None + + +def test_on_save_debounces_bursts() -> None: + p = _p("on-save", debounce_ms=1000) + p.observe(FILE, 10.0) # last_event = 10.0, window 1s + assert p.due(10.5) is False # still within debounce + p.observe(FILE, 10.4) # a burst save resets the window + assert p.due(11.0) is False # 11.0 - 10.4 = 0.6 < 1.0 + assert p.due(11.4) is True # quiet for 1s → fire + assert p.next_due_in(10.4) == 1.0 + + +def test_on_idle_uses_idle_window() -> None: + p = _p("on-idle", idle_ms=3000) + p.observe(FILE, 5.0) + assert p.due(7.0) is False # 2s < 3s + assert p.due(8.0) is True + assert p.next_due_in(6.0) == 2.0 + + +def test_on_commit_ignores_saves_fires_on_git() -> None: + p = _p("on-commit", debounce_ms=1000) + p.observe(FILE, 1.0) # a plain save must NOT arm the trigger + assert p.pending is False + assert p.next_due_in(100.0) is None + p.observe(GIT, 10.0) # a commit / branch switch arms it + assert p.pending is True + assert p.due(10.5) is False # small debounce coalesces a switch storm + assert p.due(11.0) is True + + +def test_interval_fires_only_when_dirty() -> None: + p = _p("interval", interval_ms=60000) # last_fire = 0.0 + assert p.due(100.0) is False # nothing pending, never fires + p.observe(FILE, 5.0) + assert p.due(30.0) is False # 30 - 0 < 60 + assert p.due(61.0) is True # 61 - last_fire(0) >= 60 + p.reset(61.0) + assert p.due(100.0) is False # cleared until dirtied again + + +def test_reset_clears_pending_and_sets_last_fire() -> None: + p = _p("interval", interval_ms=1000) + p.observe(FILE, 1.0) + p.reset(5.0) + assert p.pending is False + p.observe(FILE, 5.5) + assert p.due(5.9) is False # 5.9 - 5.0 < 1.0 + assert p.due(6.0) is True + + +def test_bad_trigger_rejected() -> None: + import pytest + + with pytest.raises(ValueError): + WatchSettings(trigger="nope") diff --git a/tests/watch/test_runner.py b/tests/watch/test_runner.py new file mode 100644 index 0000000..19a2f1c --- /dev/null +++ b/tests/watch/test_runner.py @@ -0,0 +1,119 @@ +"""feat-014: the watch loop, driven by a scripted event stream + a fake clock. + +No filesystem, no watchfiles, no sleeping — a scripted ``pull`` advances a fake +clock so debounce/idle windows elapse deterministically, and a fake ``refresh`` +records how many times (and when) the loop fired. +""" + +from __future__ import annotations + +from agentforge_graph.ingest.watch import Event, EventKind, WatchSettings +from agentforge_graph.ingest.watch.runner import WatchRunner, WatchStopped + + +class Clock: + def __init__(self) -> None: + self.t = 0.0 + + def __call__(self) -> float: + return self.t + + +class ScriptedPull: + """Steps: ("file", t[, path]) | ("git", t) | ("switch", t, branch) | ("wait",). + A ``wait`` honors the loop's requested timeout (the window elapses); an + exhausted script raises WatchStopped to end the loop.""" + + def __init__(self, clock: Clock, steps: list[tuple], branch: list[str]) -> None: + self.clock = clock + self.steps = steps + self.branch = branch # 1-element mutable holder + self.i = 0 + + async def __call__(self, timeout: float | None) -> Event | None: + if self.i >= len(self.steps): + raise WatchStopped + step = self.steps[self.i] + self.i += 1 + kind = step[0] + if kind == "file": + self.clock.t = step[1] + return Event(EventKind.FILE, step[2] if len(step) > 2 else "a.py") + if kind == "git": + self.clock.t = step[1] + return Event(EventKind.GIT, ".git/HEAD") + if kind == "switch": + self.clock.t = step[1] + self.branch[0] = step[2] + return Event(EventKind.GIT, ".git/HEAD") + if kind == "wait": + assert timeout is not None, "wait step but nothing is pending (timeout=None)" + self.clock.t += timeout + return None + raise AssertionError(kind) + + +def _make(settings, steps, *, branch="", include=None, exclude=None): # type: ignore[no-untyped-def] + clock = Clock() + holder = [branch] + fired: list[float] = [] + gates: list[tuple[bool, str]] = [] + + async def refresh() -> object: + fired.append(clock.t) + return object() + + runner = WatchRunner( + settings, + pull=ScriptedPull(clock, steps, holder), + refresh=refresh, + branch_of=lambda: holder[0], + include=include if include is not None else ["*"], + exclude=exclude if exclude is not None else [], + now=clock, + on_refresh=lambda r: None, + on_gate=lambda a, b: gates.append((a, b)), + ) + return runner, fired, gates + + +async def test_burst_coalesces_to_one_refresh() -> None: + # two saves inside one idle window → a single refresh (single-flight/debounce) + settings = WatchSettings(trigger="on-idle", idle_ms=100) + steps = [("file", 1.0), ("file", 1.05), ("wait",), ("file", 5.0), ("wait",)] + runner, fired, _ = _make(settings, steps) + n = await runner.run() + assert n == 2 + assert len(fired) == 2 + + +async def test_on_commit_ignores_saves_fires_on_git() -> None: + settings = WatchSettings(trigger="on-commit", debounce_ms=100) + steps = [("file", 1.0), ("file", 2.0), ("git", 3.0), ("wait",)] + runner, fired, _ = _make(settings, steps) + n = await runner.run() + assert n == 1 # the two saves did nothing; the commit fired once + + +async def test_branch_gate_activates_on_switch() -> None: + # start on an excluded branch (idle), switch to a watched branch, then edit + settings = WatchSettings(trigger="on-idle", idle_ms=100) + steps = [ + ("file", 1.0), # ignored — gated out on main + ("switch", 2.0, "feature/x"), # git event flips the gate active + ("file", 3.0), + ("wait",), + ] + runner, fired, gates = _make(settings, steps, branch="main", include=["*"], exclude=["main"]) + n = await runner.run() + assert n == 1 + assert gates[0] == (False, "main") # started gated out + assert (True, "feature/x") in gates # activated on switch + + +async def test_manual_never_refreshes() -> None: + settings = WatchSettings(trigger="manual") + steps = [("file", 1.0), ("git", 2.0)] + runner, fired, _ = _make(settings, steps) + n = await runner.run() + assert n == 0 diff --git a/tests/watch/test_watch_live.py b/tests/watch/test_watch_live.py new file mode 100644 index 0000000..4f0e5aa --- /dev/null +++ b/tests/watch/test_watch_live.py @@ -0,0 +1,61 @@ +"""feat-014: a REAL end-to-end watch run — actual watchfiles, actual fs events, +actual incremental refresh. Skipped where the optional `watch` extra is not +installed (e.g. CI's base env); exercised locally and anywhere `[watch]` is present. +""" + +from __future__ import annotations + +import asyncio +import contextlib +from pathlib import Path + +import pytest + +pytest.importorskip("watchfiles") + +from agentforge_graph.core import GraphQuery, SymbolID # noqa: E402 +from agentforge_graph.ingest import CodeGraph # noqa: E402 +from agentforge_graph.ingest.watch import WatchSettings, run_watch # noqa: E402 + + +async def _descriptors(repo: Path) -> set[str]: + cg = await CodeGraph.open(repo_path=str(repo)) + try: + nodes = (await cg.store.graph.query(GraphQuery(limit=100_000))).nodes + return {SymbolID.parse(n.id).descriptor for n in nodes} + finally: + await cg.close() + + +async def test_watch_live_refreshes_on_save(tmp_path: Path) -> None: + repo = tmp_path / "proj" + (repo / "pkg").mkdir(parents=True) + (repo / "pkg" / "mod.py").write_text("def alpha():\n return 1\n") + cg = await CodeGraph.index(repo_path=str(repo)) + await cg.close() + assert not any(d.startswith("zeta") for d in await _descriptors(repo)) + + got = asyncio.Event() + settings = WatchSettings(trigger="on-save", debounce_ms=150) + task = asyncio.create_task( + run_watch( + repo, + None, + settings, + include=["*"], + exclude=[], + extra_ignore=[], + on_refresh=lambda _r: got.set(), + ) + ) + try: + await asyncio.sleep(0.6) # let the watcher spin up + (repo / "pkg" / "added.py").write_text("def zeta():\n return 9\n") + await asyncio.wait_for(got.wait(), timeout=10) + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + + # the new symbol was indexed live by the real fs-watch → refresh path + assert any(d.startswith("zeta") for d in await _descriptors(repo)) diff --git a/uv.lock b/uv.lock index e5cba5d..72c7b16 100644 --- a/uv.lock +++ b/uv.lock @@ -89,6 +89,9 @@ surrealdb = [ voyage = [ { name = "agentforge-voyage" }, ] +watch = [ + { name = "watchfiles" }, +] [package.metadata] requires-dist = [ @@ -117,8 +120,9 @@ requires-dist = [ { name = "tree-sitter", specifier = ">=0.23" }, { name = "tree-sitter-language-pack", specifier = ">=0.9" }, { name = "types-pyyaml", marker = "extra == 'dev'", specifier = ">=6" }, + { name = "watchfiles", marker = "extra == 'watch'", specifier = ">=0.24" }, ] -provides-extras = ["engine", "rerank", "neo4j", "pgvector", "surrealdb", "voyage", "bedrock", "openai", "otel", "dev"] +provides-extras = ["engine", "rerank", "watch", "neo4j", "pgvector", "surrealdb", "voyage", "bedrock", "openai", "otel", "dev"] [[package]] name = "agentforge-mcp" @@ -3043,6 +3047,78 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376, upload-time = "2026-06-03T22:01:29.037Z" }, ] +[[package]] +name = "watchfiles" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" }, + { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" }, + { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" }, + { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" }, + { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" }, + { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" }, + { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" }, + { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" }, + { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" }, + { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" }, + { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" }, + { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" }, + { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" }, + { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" }, + { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" }, + { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" }, + { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" }, + { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" }, + { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" }, + { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" }, + { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" }, + { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" }, + { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" }, + { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" }, + { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" }, + { url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" }, + { url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" }, + { url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" }, + { url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" }, + { url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" }, + { url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" }, + { url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" }, +] + [[package]] name = "wcwidth" version = "0.8.1"