diff --git a/.github/workflows/python-quality.yml b/.github/workflows/python-quality.yml index 556eee9c..f8d85c8b 100644 --- a/.github/workflows/python-quality.yml +++ b/.github/workflows/python-quality.yml @@ -470,11 +470,39 @@ jobs: - uses: astral-sh/setup-uv@v6 - run: uv run --quiet --frozen --no-dev --project plugins/foundation/darrow-skill-authoring/skills/author-agent-skill/backend python plugins/foundation/darrow-skill-authoring/skills/author-agent-skill/backend/tests/fresh_install.py + observability-windows: + name: Observability Langfuse Python ${{ matrix.python-version }} on windows-latest + needs: changes + if: needs.changes.outputs.observability_langfuse == 'true' + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] + runs-on: windows-latest + defaults: + run: + shell: bash + env: + UV_PYTHON: ${{ matrix.python-version }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + - run: scripts/check-python --package plugins/capability/darrow-observability-langfuse/backend + observability-fresh-install: - name: Fresh install observability-langfuse on ubuntu-latest + name: Fresh install observability-langfuse on ${{ matrix.os }} needs: changes if: needs.changes.outputs.observability_langfuse == 'true' - runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 @@ -555,6 +583,7 @@ jobs: - ticket-pipeline-fresh-install - inventory - skill-authoring-fresh-install + - observability-windows - observability-fresh-install - discovery-fresh-install - verification-fresh-install @@ -576,6 +605,7 @@ jobs: VERIFICATION_WINDOWS_RESULT: ${{ needs.verification-windows.result }} INVENTORY_RESULT: ${{ needs.inventory.result }} SKILL_INSTALL_RESULT: ${{ needs.skill-authoring-fresh-install.result }} + OBSERVABILITY_WINDOWS_RESULT: ${{ needs.observability-windows.result }} OBSERVABILITY_INSTALL_RESULT: ${{ needs.observability-fresh-install.result }} DISCOVERY_INSTALL_RESULT: ${{ needs.discovery-fresh-install.result }} VERIFICATION_INSTALL_RESULT: ${{ needs.verification-fresh-install.result }} @@ -606,8 +636,8 @@ jobs: "$CHANGES_RESULT" "$INVENTORY_RESULT" \ "$PACKAGE_SELECTED" "$PACKAGE_RESULT" \ "$SKILL_AUTHORING_CHANGED" "$WINDOWS_RESULT" "$SKILL_INSTALL_RESULT" \ - "$OBSERVABILITY_CHANGED" "$OBSERVABILITY_INSTALL_RESULT" \ - "$PERFORMANCE_RESULT" \ + "$OBSERVABILITY_CHANGED" "$OBSERVABILITY_WINDOWS_RESULT" \ + "$OBSERVABILITY_INSTALL_RESULT" "$PERFORMANCE_RESULT" \ "$DISCOVERY_CHANGED" "$DISCOVERY_WINDOWS_RESULT" \ "$DISCOVERY_INSTALL_RESULT" \ "$VERIFICATION_CHANGED" "$VERIFICATION_WINDOWS_RESULT" \ diff --git a/docs/specs/observability-langfuse.md b/docs/specs/observability-langfuse.md index 37ae55ef..43f7cb4d 100644 --- a/docs/specs/observability-langfuse.md +++ b/docs/specs/observability-langfuse.md @@ -19,10 +19,11 @@ configuration guidance, tests, evals, and documentation all live inside its plugin directory. It does not reference another Darrow plugin or skill. This capability is an explicit, issue-authorized exception to Darrow's default -portable-Bash plugin-runtime boundary. The deterministic launcher remains -portable Bash, but trace reconstruction and export use Python managed by UV. -The plugin declares and locks its Python dependencies and refuses safely when -UV or the managed environment is unavailable. This exception is local to this +portable-Bash plugin-runtime boundary. The deterministic host launcher uses +Bash on Linux and macOS and native Windows PowerShell on Windows; trace +reconstruction and export use Python managed by UV on every platform. The +plugin declares and locks its Python dependencies and refuses safely when UV +or the managed environment is unavailable. This exception is local to this plugin and does not change the default boundary for other plugins. ## Runtime contract @@ -123,9 +124,10 @@ already-final snapshots. Local unresolved turns retain redacted parser data until an authoritative receipt allows privacy-filtered envelope materialization. Local capture transactions and each session's single background drainer use -separate process locks. State updates are atomic and durable. Concurrent and -reordered hooks cannot overwrite another hook's capture or acknowledge work -they did not deliver. Network waits never hold the capture lock. +separate cross-process locks on Linux, macOS, and native Windows. State updates +are atomic and durable. Concurrent and reordered hooks cannot overwrite another +hook's capture or acknowledge work they did not deliver. Network waits never +hold the capture lock. Every envelope has a stable identity, an expected observation count, a frozen privacy-filtered trace document, and exactly one state: `pending`, @@ -250,9 +252,11 @@ Installation documentation names the required Codex hook support, UV, supported Python version, Langfuse server/SDK compatibility, configuration files and variables, first-run dependency behavior, and verification command. The hook registration resolves the packaged launcher through Codex's -`PLUGIN_ROOT` environment variable. The launcher then resolves its own plugin -root and uses the committed UV lock. It must not assume the source checkout -location or another plugin installation. +`PLUGIN_ROOT` environment variable and selects a native Windows command through +`commandWindows`. Each launcher then resolves its own plugin root and uses the +committed UV lock. Neither launcher may assume the source checkout location or +another plugin installation. Native Windows operation requires PowerShell, not +Bash, WSL, or a POSIX compatibility layer. The hook exits successfully without export when tracing is disabled. Missing UV, missing credentials, malformed hook input, unreadable transcript, invalid @@ -269,10 +273,13 @@ export retry snapshots, prompt-time provisional attribution, interrupted-turn continuity, missing-snapshot quarantine, epoch session segmentation, detached and non-ticket Git state, rollout reconstruction, deduplication, content privacy, malformed input, missing runtime or configuration, and exporter -failure. Backend checks run through UV. Portable hook-launcher tests run with -both supported Bash executables. The backend conforms to the repository-wide -[Python quality standard](python-quality.md), including separate 95% statement -and branch coverage gates on every supported Python and CI platform. +failure. Backend checks run through UV. Hook-launcher tests run with both +supported Bash executables on Unix and the registered PowerShell command on +native Windows. The backend conforms to the repository-wide [Python quality +standard](python-quality.md), including separate 95% statement and branch +coverage gates on every supported Python and CI platform. Fresh copied-artifact +verification exercises the registered hook command on Linux, macOS, and native +Windows. Performance evidence distinguishes startup from steady-state foreground capture, confirms network-independent foreground completion, bounded diff --git a/plugins/capability/darrow-observability-langfuse/.claude-plugin/plugin.json b/plugins/capability/darrow-observability-langfuse/.claude-plugin/plugin.json index 0010a168..bd9d2f03 100644 --- a/plugins/capability/darrow-observability-langfuse/.claude-plugin/plugin.json +++ b/plugins/capability/darrow-observability-langfuse/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "darrow-observability-langfuse", "description": "Export Codex rollout turns to Langfuse with attribution epochs", - "version": "0.5.3", + "version": "0.6.0", "license": "BUSL-1.1", "author": { "name": "Björn Rochel", diff --git a/plugins/capability/darrow-observability-langfuse/.codex-plugin/plugin.json b/plugins/capability/darrow-observability-langfuse/.codex-plugin/plugin.json index 11e866b5..77491234 100644 --- a/plugins/capability/darrow-observability-langfuse/.codex-plugin/plugin.json +++ b/plugins/capability/darrow-observability-langfuse/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "darrow-observability-langfuse", - "version": "0.5.3", + "version": "0.6.0", "description": "Export Codex rollout turns to Langfuse with attribution epochs", "author": { "name": "Björn Rochel", diff --git a/plugins/capability/darrow-observability-langfuse/README.md b/plugins/capability/darrow-observability-langfuse/README.md index 1e478b6f..92bd999e 100644 --- a/plugins/capability/darrow-observability-langfuse/README.md +++ b/plugins/capability/darrow-observability-langfuse/README.md @@ -15,10 +15,11 @@ plugin. The hook never contacts or mutates a tracker. ## Runtime -`hooks/stop.sh` is a Unix Bash launcher, while -transcript reconstruction and Langfuse export run in Python managed by UV. The -plugin commits `backend/pyproject.toml` and `backend/uv.lock`; no sibling plugin -or repository runtime is required. +Codex selects `hooks/stop.sh` through Bash on Linux and macOS and +`hooks/stop.ps1` through Windows PowerShell on native Windows. Transcript +reconstruction and Langfuse export run in Python managed by UV on every +platform. The plugin commits `backend/pyproject.toml` and `backend/uv.lock`; no +sibling plugin or repository runtime is required. To prepare the hook environment before its first execution, run from the plugin root: @@ -41,7 +42,7 @@ Codex session after installation. You can inspect the registered hooks with `/hooks`. Check [hosts and prerequisites](#hosts-and-prerequisites) before enabling export. Delivery uses the supported OTLP traces endpoint and the v4 ingestion header. Codex must support native -asynchronous command hooks (verified with CLI 0.153.4). +asynchronous command hooks and `commandWindows` (verified with CLI 0.154.0). ## Configure @@ -261,9 +262,11 @@ uv run --quiet --frozen --no-dev --project backend python backend/tests/fresh_in bun run check:python ``` -The hook tests invoke both `bash` and `/bin/bash` from pytest. -The repository command verifies the UV lock, formatting, lint, strict typing, -tests, property tests, and separate statement and branch coverage gates. +The hook tests invoke both `bash` and `/bin/bash` on Unix and Windows PowerShell +on native Windows. The copied-artifact check executes the registered host +command on its current platform. The repository command verifies the UV lock, +formatting, lint, strict typing, tests, property tests, and separate statement +and branch coverage gates. ## When to use @@ -271,12 +274,14 @@ Configure or explain Codex turn telemetry, privacy, and attribution. Do not use ## Hosts and prerequisites -Codex is the observed runtime. Export requires Codex async hooks, +Codex is the observed runtime. Linux, macOS, and native Windows are supported. +Export requires Codex async hooks and `commandWindows` support, [UV and Python](https://github.com/BjRo/darrow/blob/main/docs/installing-plugins.md#uv-and-python-for-plugin-helpers), -and a compatible Langfuse v4 server or Langfuse Cloud. Claude Code turns are not exported. -The hook requires a Unix environment with Bash; the backend uses Unix file -locking. Native Windows support is tracked separately in -[#205](https://github.com/BjRo/darrow/issues/205). +and a compatible Langfuse v4 server or Langfuse Cloud. Linux and macOS require +Bash. Native Windows requires Windows PowerShell 5.1 or later and does not +require Bash, WSL, or a POSIX compatibility layer. Backend and sidecar locks +use the platform's native cross-process file locking. Claude Code turns are not +exported. Claude installation and guidance invocation are unverified: Claude Code 2.1.223 rejects this package's Codex-specific `Interrupt` hook during native validation. The presence of a Claude manifest is not a compatibility guarantee. diff --git a/plugins/capability/darrow-observability-langfuse/backend/pyproject.toml b/plugins/capability/darrow-observability-langfuse/backend/pyproject.toml index f7bc392c..a9ecf09b 100644 --- a/plugins/capability/darrow-observability-langfuse/backend/pyproject.toml +++ b/plugins/capability/darrow-observability-langfuse/backend/pyproject.toml @@ -4,6 +4,7 @@ version = "0.1.0" description = "Codex rollout reconstruction and Langfuse export backend" requires-python = ">=3.10,<3.14" dependencies = [ + "filelock>=3.20,<4", "langfuse>=4.0,<5", "opentelemetry-exporter-otlp-proto-http>=1.36,<2", ] diff --git a/plugins/capability/darrow-observability-langfuse/backend/src/darrow_observability_langfuse/delivery.py b/plugins/capability/darrow-observability-langfuse/backend/src/darrow_observability_langfuse/delivery.py index 84515d61..8be8ef65 100644 --- a/plugins/capability/darrow-observability-langfuse/backend/src/darrow_observability_langfuse/delivery.py +++ b/plugins/capability/darrow-observability-langfuse/backend/src/darrow_observability_langfuse/delivery.py @@ -2,14 +2,12 @@ from __future__ import annotations -import fcntl import hashlib import json -import os import sqlite3 import time -from collections.abc import Callable, Iterator -from contextlib import closing, contextmanager +from collections.abc import Callable +from contextlib import closing from dataclasses import dataclass from pathlib import Path from typing import Any @@ -18,6 +16,7 @@ from .config import Config from .context import delivery_context, require_context from .export import DeliveryError, export_document +from .locking import exclusive_lock def await_capture(rollout: Path, turn_id: str, timeout: float = 45) -> bool: @@ -58,7 +57,7 @@ def drain( lock_path = ( lock_directory / f"{hashlib.sha256(session_id.encode()).hexdigest()}.lock" ) - with _exclusive_lock(lock_path) as acquired: + with exclusive_lock(lock_path, blocking=False) as acquired: return _Drainer(rollout, config, exporter).run() if acquired else 0 @@ -76,20 +75,6 @@ def _session_id(rollout: Path, config: Config, cwd: str) -> str | None: return str(json.loads(row[0])) if row is not None else None -@contextmanager -def _exclusive_lock(path: Path) -> Iterator[bool]: - descriptor = os.open(path, os.O_CREAT | os.O_RDWR, 0o600) - try: - try: - fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB) - except BlockingIOError: - yield False - return - yield True - finally: - os.close(descriptor) - - @dataclass(frozen=True) class _Batch: rows: list[sqlite3.Row] diff --git a/plugins/capability/darrow-observability-langfuse/backend/src/darrow_observability_langfuse/filesystem.py b/plugins/capability/darrow-observability-langfuse/backend/src/darrow_observability_langfuse/filesystem.py new file mode 100644 index 00000000..3b217368 --- /dev/null +++ b/plugins/capability/darrow-observability-langfuse/backend/src/darrow_observability_langfuse/filesystem.py @@ -0,0 +1,16 @@ +"""Portable durability helpers for plugin-owned state.""" + +from __future__ import annotations + +import os +from pathlib import Path + + +def sync_directory(path: Path) -> None: + if os.name == "nt": # pragma: no cover - Windows cannot open directories this way. + return + descriptor = os.open(path, os.O_RDONLY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) diff --git a/plugins/capability/darrow-observability-langfuse/backend/src/darrow_observability_langfuse/lifecycle.py b/plugins/capability/darrow-observability-langfuse/backend/src/darrow_observability_langfuse/lifecycle.py index 176a1773..e4f975ef 100644 --- a/plugins/capability/darrow-observability-langfuse/backend/src/darrow_observability_langfuse/lifecycle.py +++ b/plugins/capability/darrow-observability-langfuse/backend/src/darrow_observability_langfuse/lifecycle.py @@ -14,6 +14,7 @@ from .config import Config from .context import delivery_context, require_context +from .filesystem import sync_directory from .sidecar import _load_state_path, load_provisional_attribution_snapshots @@ -31,11 +32,7 @@ def _atomic_record(path: Path, value: Any) -> None: os.fsync(handle.fileno()) with suppress(FileExistsError): os.link(temporary, path) # First receipt wins; never replace evidence. - directory = os.open(path.parent, os.O_RDONLY) - try: - os.fsync(directory) - finally: - os.close(directory) + sync_directory(path.parent) finally: os.unlink(temporary) diff --git a/plugins/capability/darrow-observability-langfuse/backend/src/darrow_observability_langfuse/locking.py b/plugins/capability/darrow-observability-langfuse/backend/src/darrow_observability_langfuse/locking.py new file mode 100644 index 00000000..86e7aec2 --- /dev/null +++ b/plugins/capability/darrow-observability-langfuse/backend/src/darrow_observability_langfuse/locking.py @@ -0,0 +1,23 @@ +"""Cross-process file locks for capture sidecars and delivery drainers.""" + +from __future__ import annotations + +from collections.abc import Iterator +from contextlib import contextmanager +from pathlib import Path + +from filelock import FileLock, Timeout + + +@contextmanager +def exclusive_lock(path: Path, *, blocking: bool = True) -> Iterator[bool]: + lock = FileLock(path, timeout=-1 if blocking else 0) + try: + lock.acquire() + except Timeout: + yield False + return + try: + yield True + finally: + lock.release() diff --git a/plugins/capability/darrow-observability-langfuse/backend/src/darrow_observability_langfuse/sidecar.py b/plugins/capability/darrow-observability-langfuse/backend/src/darrow_observability_langfuse/sidecar.py index ae0d95c6..83f038d5 100644 --- a/plugins/capability/darrow-observability-langfuse/backend/src/darrow_observability_langfuse/sidecar.py +++ b/plugins/capability/darrow-observability-langfuse/backend/src/darrow_observability_langfuse/sidecar.py @@ -1,6 +1,5 @@ from __future__ import annotations -import fcntl import hashlib import json import os @@ -13,6 +12,8 @@ from typing import Any, ParamSpec, TypeVar, cast from .config import validate_work_item_id +from .filesystem import sync_directory +from .locking import exclusive_lock _CONTROL_CHARACTER = re.compile(r"[\x00-\x1f\x7f]") _GIT_HEAD = re.compile(r"(?:[0-9a-f]{40}|[0-9a-f]{64})", re.I) @@ -28,12 +29,9 @@ def decorate(function: Callable[P, R]) -> Callable[P, R]: def invoke(*args: P.args, **kwargs: P.kwargs) -> R: path = path_for(*args, **kwargs) path.parent.mkdir(parents=True, exist_ok=True) - descriptor = os.open(f"{path}.lock", os.O_CREAT | os.O_RDWR, 0o600) - try: - fcntl.flock(descriptor, fcntl.LOCK_EX) + with exclusive_lock(Path(f"{path}.lock")) as acquired: + assert acquired return function(*args, **kwargs) - finally: - os.close(descriptor) return invoke @@ -171,11 +169,7 @@ def _write_state_path(path: Path, state: dict[str, Any]) -> None: os.fsync(handle.fileno()) os.chmod(temporary, 0o600) os.replace(temporary, path) - directory = os.open(path.parent, os.O_RDONLY) - try: - os.fsync(directory) - finally: - os.close(directory) + sync_directory(path.parent) temporary = None finally: if temporary is not None: diff --git a/plugins/capability/darrow-observability-langfuse/backend/tests/benchmark_capture.py b/plugins/capability/darrow-observability-langfuse/backend/tests/benchmark_capture.py index 4823ade7..b2b5d472 100644 --- a/plugins/capability/darrow-observability-langfuse/backend/tests/benchmark_capture.py +++ b/plugins/capability/darrow-observability-langfuse/backend/tests/benchmark_capture.py @@ -11,7 +11,6 @@ import hashlib import importlib import json -import resource import sys import tempfile import time @@ -23,6 +22,22 @@ from opentelemetry.sdk.trace import ReadableSpan from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult +if sys.platform == "win32": + + def _peak_rss_bytes() -> int: + # The performance job is Unix-only; keep the tooling importable and + # type-checkable in the supported Windows backend matrix. + return 0 + +else: + import resource + + def _peak_rss_bytes() -> int: + peak = float(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss) + if sys.platform != "darwin": + peak *= 1024 + return int(peak) + class _Counter(SpanExporter): def __init__(self) -> None: @@ -226,9 +241,7 @@ def _validate(self) -> None: assert self.bytes_read <= self.input_bytes * 2 def _result(self, elapsed: float) -> dict[str, Any]: - peak = float(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss) - if sys.platform != "darwin": - peak *= 1024 + peak = _peak_rss_bytes() startup = self.foreground_samples[0] if self.foreground_samples else 0.0 steady = sum(self.foreground_samples[1:]) steady_count = max(0, len(self.foreground_samples) - 1) @@ -246,7 +259,7 @@ def _result(self, elapsed: float) -> dict[str, Any]: steady / steady_count if steady_count else 0.0, 6 ), "total_seconds": round(elapsed, 4), - "peak_rss_bytes": int(peak), + "peak_rss_bytes": peak, "acknowledged_turns": self.outcome, } diff --git a/plugins/capability/darrow-observability-langfuse/backend/tests/fresh_install.py b/plugins/capability/darrow-observability-langfuse/backend/tests/fresh_install.py index b255a30c..f4e360cb 100644 --- a/plugins/capability/darrow-observability-langfuse/backend/tests/fresh_install.py +++ b/plugins/capability/darrow-observability-langfuse/backend/tests/fresh_install.py @@ -1,4 +1,4 @@ -"""Validate a copied Unix plugin using only its locked runtime dependencies.""" +"""Validate a copied plugin using only its locked runtime dependencies.""" import json import os @@ -32,7 +32,9 @@ def validate(plugin: Path, fixture: Path) -> None: "import darrow_observability_langfuse", ) hooks = json.loads((plugin / "hooks/hooks.json").read_text(encoding="utf-8")) - capture = hooks["hooks"]["Stop"][0]["hooks"][0]["command"] + handler = hooks["hooks"]["Stop"][0]["hooks"][0] + field = "commandWindows" if os.name == "nt" else "command" + capture = handler[field].replace("${PLUGIN_ROOT}", str(plugin)) environment = { key: value for key, value in os.environ.items() @@ -46,7 +48,8 @@ def validate(plugin: Path, fixture: Path) -> None: } ) subprocess.run( - ["bash", "-c", capture], + capture, + shell=True, input="{}\n", text=True, env=environment, diff --git a/plugins/capability/darrow-observability-langfuse/backend/tests/test_delivery.py b/plugins/capability/darrow-observability-langfuse/backend/tests/test_delivery.py index 72a05290..eccbce34 100644 --- a/plugins/capability/darrow-observability-langfuse/backend/tests/test_delivery.py +++ b/plugins/capability/darrow-observability-langfuse/backend/tests/test_delivery.py @@ -207,7 +207,7 @@ def test_foreground_never_contacts_delayed_or_unavailable_endpoint(self) -> None ): start = time.monotonic() self.assertEqual(run(), 0) - self.assertLess(time.monotonic() - start, 0.5) + self.assertLess(time.monotonic() - start, 2) self.assertEqual(requests, []) self.assertEqual(delivery_rows(self.path)[0]["state"], "pending") diff --git a/plugins/capability/darrow-observability-langfuse/backend/tests/test_edge_cases.py b/plugins/capability/darrow-observability-langfuse/backend/tests/test_edge_cases.py index 8b34caf6..ff03af40 100644 --- a/plugins/capability/darrow-observability-langfuse/backend/tests/test_edge_cases.py +++ b/plugins/capability/darrow-observability-langfuse/backend/tests/test_edge_cases.py @@ -20,6 +20,7 @@ config, delivery, export, + filesystem, lifecycle, rollout, sidecar, @@ -27,6 +28,10 @@ from darrow_observability_langfuse.config import Config from darrow_observability_langfuse.context import delivery_context from darrow_observability_langfuse.export import DeliveryError +from darrow_observability_langfuse.locking import exclusive_lock + +TEST_ROOT = Path(tempfile.gettempdir()).resolve() +TEST_ROLLOUT = TEST_ROOT / "rollout" class ConfigEdgeTests(unittest.TestCase): @@ -73,7 +78,44 @@ def test_git_failures_and_explicit_work_item_resolution(self) -> None: ) +class FilesystemEdgeTests(unittest.TestCase): + def test_directory_sync_uses_only_supported_platform_operations(self) -> None: + path = TEST_ROOT / "state" + with ( + patch("darrow_observability_langfuse.filesystem.os.name", "nt"), + patch("darrow_observability_langfuse.filesystem.os.open") as open_file, + ): + filesystem.sync_directory(path) + open_file.assert_not_called() + + with ( + patch("darrow_observability_langfuse.filesystem.os.name", "posix"), + patch( + "darrow_observability_langfuse.filesystem.os.open", return_value=7 + ) as open_file, + patch("darrow_observability_langfuse.filesystem.os.fsync") as sync, + patch("darrow_observability_langfuse.filesystem.os.close") as close, + ): + filesystem.sync_directory(path) + open_file.assert_called_once_with(path, os.O_RDONLY) + sync.assert_called_once_with(7) + close.assert_called_once_with(7) + + class LifecycleEdgeTests(unittest.TestCase): + def test_terminal_context_validation_checks_each_receipt(self) -> None: + context = {"base_url": "https://example.test"} + with ( + patch.object( + lifecycle, + "terminal_records", + return_value=[{"delivery_context": context}], + ), + patch.object(lifecycle, "require_context") as require_context, + ): + lifecycle._validate_terminal_contexts(TEST_ROLLOUT, "session", context) + require_context.assert_called_once_with(context, context) + def test_registry_validation_rejects_each_invalid_shape(self) -> None: values: list[Any] = [ None, @@ -112,7 +154,7 @@ def test_terminal_validation_rejects_each_invalid_shape(self) -> None: def test_terminal_input_and_no_plugin_registry(self) -> None: self.assertEqual(list(lifecycle.registered_rollouts(None, {})), []) - lifecycle.register_rollout(Path("/tmp/rollout"), "session", None, {}) + lifecycle.register_rollout(TEST_ROLLOUT, "session", None, {}) with self.assertRaisesRegex(ValueError, "invalid terminal hook"): lifecycle.record_terminal( Path("/missing"), @@ -121,7 +163,7 @@ def test_terminal_input_and_no_plugin_registry(self) -> None: None, Config(), None, - cwd="/tmp", + cwd=str(TEST_ROOT), ) with self.assertRaisesRegex(ValueError, "readable absolute file"): lifecycle.record_terminal( @@ -131,7 +173,7 @@ def test_terminal_input_and_no_plugin_registry(self) -> None: None, Config(), None, - cwd="/tmp", + cwd=str(TEST_ROOT), ) def test_legacy_context_binding_and_unbound_evidence(self) -> None: @@ -274,14 +316,10 @@ def test_empty_delivery_and_busy_lock(self) -> None: self.assertEqual( delivery.drain(root / "missing", Config(), cwd=directory), 0 ) - with ( - patch( - "darrow_observability_langfuse.delivery.fcntl.flock", - side_effect=BlockingIOError, - ), - delivery._exclusive_lock(root / "lock") as acquired, - ): - self.assertFalse(acquired) + with exclusive_lock(root / "lock") as acquired: + self.assertTrue(acquired) + with exclusive_lock(root / "lock", blocking=False) as second_acquired: + self.assertFalse(second_acquired) def test_delivery_envelope_validation(self) -> None: rows = cast( @@ -316,14 +354,14 @@ def test_no_session_and_batch_boundaries(self) -> None: rows = [{"expected_count": 400}, {"expected_count": 200}] connection = SimpleNamespace(execute=lambda *_args: iter(rows)) - drainer = delivery._Drainer(Path("/tmp/rollout"), Config(), lambda *_: 0) + drainer = delivery._Drainer(TEST_ROLLOUT, Config(), lambda *_: 0) self.assertEqual( drainer._pending_rows(cast("sqlite3.Connection", connection)), [rows[0]] ) def test_delivery_attempt_mismatch_and_cancellation(self) -> None: batch = delivery._Batch([], {}, [], 0) - mismatch = delivery._Drainer(Path("/tmp/rollout"), Config(), lambda *_: 1) + mismatch = delivery._Drainer(TEST_ROLLOUT, Config(), lambda *_: 1) with patch.object(mismatch, "_record_failure") as record_failure: mismatch._attempt(batch) self.assertIsInstance(mismatch.failure, DeliveryError) @@ -332,7 +370,7 @@ def test_delivery_attempt_mismatch_and_cancellation(self) -> None: def cancel(*_arguments: Any) -> int: raise KeyboardInterrupt - cancelled = delivery._Drainer(Path("/tmp/rollout"), Config(), cancel) + cancelled = delivery._Drainer(TEST_ROLLOUT, Config(), cancel) with self.assertRaises(KeyboardInterrupt): cancelled._attempt(batch) @@ -453,13 +491,13 @@ def test_run_disabled_and_background_refusals(self) -> None: runner = cli._HookRunner( Config(enabled=True, public_key="pk", secret_key="sk"), {"session_id": "session", "hook_event_name": "Unknown"}, - "/tmp", + str(TEST_ROOT), None, ) with self.assertRaisesRegex(ValueError, "unsupported background"): runner._background() runner.event = "Stop" - runner.hook_input.update(transcript_path="/tmp/rollout", turn_id="turn") + runner.hook_input.update(transcript_path=str(TEST_ROLLOUT), turn_id="turn") with ( patch.object(cli, "await_capture", return_value=False), self.assertRaisesRegex(ValueError, "has not completed"), @@ -470,7 +508,7 @@ def test_hook_runner_refusal_and_dry_run_paths(self) -> None: runner = cli._HookRunner( Config(enabled=True, dry_run=True), {"session_id": "session", "hook_event_name": "Unknown"}, - "/tmp", + str(TEST_ROOT), None, ) with self.assertRaisesRegex(ValueError, "unsupported hook"): @@ -491,7 +529,7 @@ def test_hook_runner_refusal_and_dry_run_paths(self) -> None: with self.assertRaisesRegex(ValueError, "credentials"): runner._require_credentials() runner.config = Config(enabled=True, dry_run=True) - runner.hook_input.update(transcript_path="/tmp/rollout", turn_id="turn") + runner.hook_input.update(transcript_path=str(TEST_ROLLOUT), turn_id="turn") with patch.object(runner, "_print_dry_run") as print_dry_run: self.assertEqual(runner._stop(), 0) print_dry_run.assert_called_once() @@ -503,10 +541,10 @@ def test_dry_run_document_uses_all_snapshot_sources(self) -> None: "session_id": "session", "hook_event_name": "Stop", "turn_id": "turn", - "transcript_path": "/tmp/rollout", + "transcript_path": str(TEST_ROLLOUT), }, - "/tmp", - Path("/tmp/plugin-data"), + str(TEST_ROOT), + TEST_ROOT / "plugin-data", ) document = {"traces": [{"metadata": {"codex.turn_id": "turn"}}]} output = io.StringIO() @@ -522,7 +560,7 @@ def test_dry_run_document_uses_all_snapshot_sources(self) -> None: patch.object(cli, "trace_document", return_value=document), patch("darrow_observability_langfuse.cli.sys.stdout", output), ): - runner._print_dry_run(Path("/tmp/rollout"), "turn") + runner._print_dry_run(TEST_ROLLOUT, "turn") self.assertTrue(document["traces"][0]["metadata"]["codex.completed"]) self.assertTrue(output.getvalue().endswith("\n")) diff --git a/plugins/capability/darrow-observability-langfuse/backend/tests/test_hook_failures.py b/plugins/capability/darrow-observability-langfuse/backend/tests/test_hook_failures.py index fd114810..307ee2d1 100644 --- a/plugins/capability/darrow-observability-langfuse/backend/tests/test_hook_failures.py +++ b/plugins/capability/darrow-observability-langfuse/backend/tests/test_hook_failures.py @@ -1,4 +1,4 @@ -"""Failure and configuration contracts through both supported Unix launchers.""" +"""Failure and configuration contracts through each native host launcher.""" import json import os @@ -10,14 +10,11 @@ import pytest PLUGIN = Path(__file__).resolve().parents[2] -pytestmark = pytest.mark.skipif( - os.name == "nt", reason="native Windows support is tracked in #205" -) @dataclass class Hook: - shell: str + command: list[str] root: Path project: Path home: Path @@ -34,7 +31,7 @@ def run( "transcript_path": str(self.root / "missing.jsonl"), } return subprocess.run( - [self.shell, str(PLUGIN / "hooks/stop.sh"), *args], + [*self.command, *args], input=json.dumps(document) if payload is None else payload, text=True, capture_output=True, @@ -53,19 +50,35 @@ def configure(self, directory: Path) -> None: def path_without_uv(self) -> str: directory = self.root / "without uv" directory.mkdir(exist_ok=True) - for name in ("dirname", "tr", "sed"): - executable = shutil.which(name) - assert executable is not None - destination = directory / name - if not destination.exists(): - destination.symlink_to(executable) + if os.name != "nt": + for name in ("dirname", "tr", "sed"): + executable = shutil.which(name) + assert executable is not None + destination = directory / name + if not destination.exists(): + destination.symlink_to(executable) return str(directory) -@pytest.fixture(params=["bash", "/bin/bash"]) +@pytest.fixture(params=[None] if os.name == "nt" else ["bash", "/bin/bash"]) def hook(request: pytest.FixtureRequest, tmp_path: Path) -> Hook: - shell = shutil.which(str(request.param)) - assert shell is not None + if os.name == "nt": + powershell = shutil.which("powershell.exe") + assert powershell is not None + command = [ + powershell, + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-File", + str(PLUGIN / "hooks/stop.ps1"), + ] + else: + shell = shutil.which(str(request.param)) + assert shell is not None + command = [shell, str(PLUGIN / "hooks/stop.sh")] project, home = tmp_path / "project", tmp_path / "home" for directory in (project, home): (directory / ".codex").mkdir(parents=True) @@ -79,7 +92,7 @@ def hook(request: pytest.FixtureRequest, tmp_path: Path) -> Hook: environment.update( {"HOME": str(home), "UV_OFFLINE": "1", "CODEX_PLUGIN_ROOT": str(PLUGIN)} ) - return Hook(shell, tmp_path, project, home, environment) + return Hook(command, tmp_path, project, home, environment) @pytest.mark.parametrize("strict", [False, True]) diff --git a/plugins/capability/darrow-observability-langfuse/backend/tests/test_packaged_hooks.py b/plugins/capability/darrow-observability-langfuse/backend/tests/test_packaged_hooks.py index 1c32e644..04222776 100644 --- a/plugins/capability/darrow-observability-langfuse/backend/tests/test_packaged_hooks.py +++ b/plugins/capability/darrow-observability-langfuse/backend/tests/test_packaged_hooks.py @@ -33,6 +33,7 @@ def test_manifest_identity() -> None: assert codex["skills"] == "./skills/" assert codex["hooks"] == "./hooks/hooks.json" assert (PLUGIN / "hooks/stop.sh").is_file() + assert (PLUGIN / "hooks/stop.ps1").is_file() marketplace = json.loads( (PLUGIN.parents[2] / ".claude-plugin/marketplace.json").read_text() ) @@ -47,8 +48,13 @@ def test_capture_and_delivery_registration(event: str) -> None: assert first["type"] == "command" assert "${PLUGIN_ROOT}" in first["command"] assert "hooks/stop.sh" in first["command"] + assert "${PLUGIN_ROOT}" in first["commandWindows"] + assert "hooks/stop.ps1" in first["commandWindows"] assert any(not h.get("async") and "--drain" not in h["command"] for h in registered) assert any(h.get("async") is True and "--drain" in h["command"] for h in registered) + assert any( + h.get("async") is True and "--drain" in h["commandWindows"] for h in registered + ) def test_session_start_recovers_delivery() -> None: @@ -76,15 +82,26 @@ def launcher_environment() -> dict[str, str]: } -@pytest.mark.skipif(os.name == "nt", reason="packaged hook uses the Unix host shell") -@pytest.mark.parametrize("shell", ["bash", "/bin/bash"]) -def test_disabled_packaged_command(shell: str) -> None: +def host_command(command: dict[str, Any], shell: str | None) -> str | list[str]: + field = "commandWindows" if os.name == "nt" else "command" + registered = command[field] + assert isinstance(registered, str) + expanded = registered.replace("${PLUGIN_ROOT}", str(PLUGIN)) + if os.name == "nt": + return expanded + assert shell is not None + return [shell, "-c", expanded] + + +@pytest.mark.parametrize("shell", [None] if os.name == "nt" else ["bash", "/bin/bash"]) +def test_disabled_packaged_command(shell: str | None) -> None: subprocess.run( - [shell, "-c", handlers("Stop")[0]["command"]], + host_command(handlers("Stop")[0], shell), input="{}\n", text=True, check=True, capture_output=True, + shell=os.name == "nt", env={ **launcher_environment(), "PLUGIN_ROOT": str(PLUGIN), @@ -93,9 +110,8 @@ def test_disabled_packaged_command(shell: str) -> None: ) -@pytest.mark.skipif(os.name == "nt", reason="packaged hook uses the Unix host shell") -@pytest.mark.parametrize("shell", ["bash", "/bin/bash"]) -def test_stop_reconstructs_trace(shell: str) -> None: +@pytest.mark.parametrize("shell", [None] if os.name == "nt" else ["bash", "/bin/bash"]) +def test_stop_reconstructs_trace(shell: str | None) -> None: payload = { "session_id": "session-main", "turn_id": "turn-1", @@ -104,11 +120,12 @@ def test_stop_reconstructs_trace(shell: str) -> None: "hook_event_name": "Stop", } result = subprocess.run( - [shell, str(PLUGIN / "hooks/stop.sh")], + host_command(handlers("Stop")[0], shell), input=json.dumps(payload), text=True, capture_output=True, check=True, + shell=os.name == "nt", env={ **launcher_environment(), "CODEX_PLUGIN_ROOT": str(PLUGIN), diff --git a/plugins/capability/darrow-observability-langfuse/backend/tests/test_sidecar.py b/plugins/capability/darrow-observability-langfuse/backend/tests/test_sidecar.py index 2f042e00..5f0c7866 100644 --- a/plugins/capability/darrow-observability-langfuse/backend/tests/test_sidecar.py +++ b/plugins/capability/darrow-observability-langfuse/backend/tests/test_sidecar.py @@ -65,7 +65,8 @@ def test_provisional_snapshot_is_private_immutable_and_discardable(self) -> None ) files = list((plugin_data / "attribution-snapshots").glob("*.json")) self.assertEqual(len(files), 1) - self.assertEqual(os.stat(files[0]).st_mode & 0o777, 0o600) + if os.name != "nt": + self.assertEqual(os.stat(files[0]).st_mode & 0o777, 0o600) with self.assertRaisesRegex(ValueError, "immutable"): record_provisional_attribution_snapshot( plugin_data, @@ -185,10 +186,11 @@ def test_attribution_snapshot_survives_export_marking_and_is_immutable( "turn-1", {**snapshot, "work_item_id": "issue-60"}, ) - self.assertEqual( - os.stat(Path(f"{rollout}.darrow-langfuse")).st_mode & 0o777, - 0o600, - ) + if os.name != "nt": + self.assertEqual( + os.stat(Path(f"{rollout}.darrow-langfuse")).st_mode & 0o777, + 0o600, + ) def test_completed_turn_is_not_exported_twice(self) -> None: document = { @@ -212,7 +214,8 @@ def test_completed_turn_is_not_exported_twice(self) -> None: self.assertEqual( json.loads(sidecar.read_text())["uploaded_turn_ids"], ["turn-1"] ) - self.assertEqual(os.stat(sidecar).st_mode & 0o777, 0o600) + if os.name != "nt": + self.assertEqual(os.stat(sidecar).st_mode & 0o777, 0o600) if __name__ == "__main__": diff --git a/plugins/capability/darrow-observability-langfuse/backend/uv.lock b/plugins/capability/darrow-observability-langfuse/backend/uv.lock index 41978bd9..91af3af6 100644 --- a/plugins/capability/darrow-observability-langfuse/backend/uv.lock +++ b/plugins/capability/darrow-observability-langfuse/backend/uv.lock @@ -224,6 +224,7 @@ name = "darrow-observability-langfuse" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "filelock" }, { name = "langfuse" }, { name = "opentelemetry-exporter-otlp-proto-http" }, ] @@ -240,6 +241,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "filelock", specifier = ">=3.20,<4" }, { name = "langfuse", specifier = ">=4.0,<5" }, { name = "opentelemetry-exporter-otlp-proto-http", specifier = ">=1.36,<2" }, ] @@ -266,6 +268,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] +[[package]] +name = "filelock" +version = "3.32.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/59/e19834834cb01a32febfbb0f8a23a9088088f5d45991824ff2bc3b5e8acb/filelock-3.32.7.tar.gz", hash = "sha256:37b8a3d9811b0f9aef7e5ec5c71bb320de52df51e6ca9bcd6f5ad81187660da7", size = 225154, upload-time = "2026-09-16T00:24:20.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/df/31098c5aeb4d966b553641472bd55fcf5fdfac953549894b8a765ba44e91/filelock-3.32.7-py3-none-any.whl", hash = "sha256:65ff0d0190ea42038b32bda4b77834fb05be2cad4c5b9b01aa4dfb3614536e52", size = 100157, upload-time = "2026-09-16T00:24:19.543Z" }, +] + [[package]] name = "googleapis-common-protos" version = "1.75.1" diff --git a/plugins/capability/darrow-observability-langfuse/hooks/hooks.json b/plugins/capability/darrow-observability-langfuse/hooks/hooks.json index 1ec11558..0b61b265 100644 --- a/plugins/capability/darrow-observability-langfuse/hooks/hooks.json +++ b/plugins/capability/darrow-observability-langfuse/hooks/hooks.json @@ -6,12 +6,14 @@ { "type": "command", "command": "bash \"${PLUGIN_ROOT}/hooks/stop.sh\"", + "commandWindows": "powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File \"${PLUGIN_ROOT}/hooks/stop.ps1\"", "timeout": 45, "statusMessage": "Capturing Codex attribution" }, { "type": "command", "command": "bash \"${PLUGIN_ROOT}/hooks/stop.sh\" --drain", + "commandWindows": "powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File \"${PLUGIN_ROOT}/hooks/stop.ps1\" --drain", "async": true, "timeout": 120 } @@ -24,12 +26,14 @@ { "type": "command", "command": "bash \"${PLUGIN_ROOT}/hooks/stop.sh\"", + "commandWindows": "powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File \"${PLUGIN_ROOT}/hooks/stop.ps1\"", "timeout": 45, "statusMessage": "Capturing Codex trace locally" }, { "type": "command", "command": "bash \"${PLUGIN_ROOT}/hooks/stop.sh\" --drain", + "commandWindows": "powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File \"${PLUGIN_ROOT}/hooks/stop.ps1\" --drain", "async": true, "timeout": 120 } @@ -42,6 +46,7 @@ { "type": "command", "command": "bash \"${PLUGIN_ROOT}/hooks/stop.sh\"", + "commandWindows": "powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File \"${PLUGIN_ROOT}/hooks/stop.ps1\"", "timeout": 3 } ] @@ -53,6 +58,7 @@ { "type": "command", "command": "bash \"${PLUGIN_ROOT}/hooks/stop.sh\"", + "commandWindows": "powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File \"${PLUGIN_ROOT}/hooks/stop.ps1\"", "timeout": 3 } ] @@ -64,6 +70,7 @@ { "type": "command", "command": "bash \"${PLUGIN_ROOT}/hooks/stop.sh\" --drain", + "commandWindows": "powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File \"${PLUGIN_ROOT}/hooks/stop.ps1\" --drain", "async": true, "timeout": 120 } diff --git a/plugins/capability/darrow-observability-langfuse/hooks/stop.ps1 b/plugins/capability/darrow-observability-langfuse/hooks/stop.ps1 new file mode 100644 index 00000000..1fa6df8f --- /dev/null +++ b/plugins/capability/darrow-observability-langfuse/hooks/stop.ps1 @@ -0,0 +1,66 @@ +$ErrorActionPreference = "Stop" + +function Test-EnvironmentTrue { + param([AllowNull()][string]$Value) + + if ($null -eq $Value) { + return $false + } + return @("1", "true", "yes", "on") -contains $Value.Trim().ToLowerInvariant() +} + +function Get-StartupFailureCode { + param([string]$Message) + + if ((Test-EnvironmentTrue $env:DARROW_LANGFUSE_DEBUG) -or + (Test-EnvironmentTrue $env:DARROW_LANGFUSE_STRICT)) { + [Console]::Error.WriteLine("darrow-langfuse: $Message") + } + if (Test-EnvironmentTrue $env:DARROW_LANGFUSE_STRICT) { + return 1 + } + return 0 +} + +$uv = Get-Command uv -CommandType Application -ErrorAction SilentlyContinue +if ($null -eq $uv) { + exit (Get-StartupFailureCode "uv is required but was not found") +} + +$pluginDirectory = Split-Path -Parent $PSScriptRoot +$statusDirectory = Join-Path ([IO.Path]::GetTempPath()) ( + "darrow-langfuse-status-" + [Guid]::NewGuid().ToString("N") +) +$statusPath = Join-Path $statusDirectory "result" +$hadProgressSetting = Test-Path Env:UV_NO_PROGRESS +$previousProgressSetting = $env:UV_NO_PROGRESS +$result = 0 + +try { + New-Item -ItemType Directory -Path $statusDirectory | Out-Null + $env:UV_NO_PROGRESS = "1" + & $uv.Path run --quiet --frozen --project (Join-Path $pluginDirectory "backend") ` + python -m darrow_observability_langfuse.cli --launcher-status $statusPath @args + + if (Test-Path -LiteralPath $statusPath) { + $resolvedStatus = (Get-Content -LiteralPath $statusPath -Raw).Trim() + if ($resolvedStatus -in @("0", "1")) { + $result = [int]$resolvedStatus + } else { + $result = Get-StartupFailureCode "backend launch or execution failed" + } + } else { + $result = Get-StartupFailureCode "backend launch or execution failed" + } +} catch { + $result = Get-StartupFailureCode "backend launch or execution failed" +} finally { + if ($hadProgressSetting) { + $env:UV_NO_PROGRESS = $previousProgressSetting + } else { + Remove-Item Env:UV_NO_PROGRESS -ErrorAction SilentlyContinue + } + Remove-Item -LiteralPath $statusDirectory -Recurse -Force -ErrorAction SilentlyContinue +} + +exit $result diff --git a/scripts/test-python-quality-changes b/scripts/test-python-quality-changes index a350cdeb..0bb9a89f 100755 --- a/scripts/test-python-quality-changes +++ b/scripts/test-python-quality-changes @@ -145,35 +145,35 @@ fi success success \ true success \ true success success \ - false skipped skipped \ + false skipped skipped skipped \ false skipped skipped \ false skipped skipped "$verifier" \ success success \ true success \ false skipped skipped \ - true success success \ + true success success success \ false skipped skipped \ false skipped skipped "$verifier" \ success success \ true success \ false skipped skipped \ - false skipped skipped \ + false skipped skipped skipped \ true success success \ false skipped skipped "$verifier" \ success success \ true success \ false skipped skipped \ - false skipped skipped \ + false skipped skipped skipped \ false skipped skipped \ true success success "$verifier" \ success success \ false skipped \ false skipped skipped \ - false skipped skipped \ + false skipped skipped skipped \ false skipped skipped \ false skipped skipped @@ -181,7 +181,7 @@ if "$verifier" \ success success \ false success \ false skipped skipped \ - false skipped skipped \ + false skipped skipped skipped \ false skipped skipped \ false skipped skipped >/dev/null 2>&1; then printf 'error: aggregate accepted a job that ran for an unchanged scope\n' >&2 @@ -192,7 +192,7 @@ if "$verifier" \ success success \ true failure \ false skipped skipped \ - false skipped skipped \ + false skipped skipped skipped \ false skipped skipped \ false skipped skipped >/dev/null 2>&1; then printf 'error: aggregate accepted a failed registered-package job\n' >&2 @@ -203,7 +203,7 @@ if "$verifier" \ success success \ true success \ true failure success \ - false skipped skipped \ + false skipped skipped skipped \ false skipped skipped \ false skipped skipped >/dev/null 2>&1; then printf 'error: aggregate accepted a failed selected job\n' >&2 @@ -214,7 +214,7 @@ if "$verifier" \ failure success \ true success \ true success success \ - true success success \ + true success success success \ true success success \ true success success >/dev/null 2>&1; then printf 'error: aggregate accepted failed change detection\n' >&2 diff --git a/scripts/verify-python-quality-results b/scripts/verify-python-quality-results index abe286b1..2137f34c 100755 --- a/scripts/verify-python-quality-results +++ b/scripts/verify-python-quality-results @@ -1,8 +1,8 @@ #!/bin/sh set -eu -if [ "$#" -ne 16 ]; then - echo "usage: scripts/verify-python-quality-results " >&2 +if [ "$#" -ne 17 ]; then + echo "usage: scripts/verify-python-quality-results " >&2 exit 2 fi @@ -14,15 +14,16 @@ skill_selected=$5 skill_windows_result=$6 skill_install_result=$7 observability_selected=$8 -observability_install_result=$9 +observability_windows_result=$9 shift 9 -observability_performance_result=$1 -discovery_selected=$2 -discovery_windows_result=$3 -discovery_install_result=$4 -verification_selected=$5 -verification_windows_result=$6 -verification_install_result=$7 +observability_install_result=$1 +observability_performance_result=$2 +discovery_selected=$3 +discovery_windows_result=$4 +discovery_install_result=$5 +verification_selected=$6 +verification_windows_result=$7 +verification_install_result=$8 require_result() { name=$1 @@ -54,7 +55,8 @@ require_scope packages "$package_selected" "$package_result" require_scope skill-authoring "$skill_selected" \ "$skill_windows_result" "$skill_install_result" require_scope observability-langfuse "$observability_selected" \ - "$observability_install_result" "$observability_performance_result" + "$observability_windows_result" "$observability_install_result" \ + "$observability_performance_result" require_scope discovery "$discovery_selected" \ "$discovery_windows_result" "$discovery_install_result" require_scope verification "$verification_selected" \