From 36926618f0cb780465575febd31f124ce566990b Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 25 Aug 2026 01:50:58 +0200 Subject: [PATCH 01/35] fix(agentctl): preserve job scratch inside devshells --- pkgs/sinnixd/sinnixd/jobs.py | 16 +++++++++++++++- pkgs/sinnixd/sinnixd/projects.py | 19 ++++++++++++++++++- pkgs/sinnixd/test_service.py | 28 ++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/pkgs/sinnixd/sinnixd/jobs.py b/pkgs/sinnixd/sinnixd/jobs.py index c49cef91..cf453af9 100644 --- a/pkgs/sinnixd/sinnixd/jobs.py +++ b/pkgs/sinnixd/sinnixd/jobs.py @@ -1237,6 +1237,14 @@ def _allocate_scratch(self, kind: str, job_id: str) -> Path | None: _fsync_directory(root) return path.resolve() + def scratch_path_for(self, kind: str, job_id: str) -> Path | None: + """Return the deterministic job-owned scratch path before allocation.""" + if kind == "none": + return None + _ = job_unit_name(job_id) + root = self.tmpfs_scratch_root if kind == "tmpfs" else self.nvme_scratch_root + return (root / job_id).resolve() + def cleanup_scratch(self, record: GenericJobRecord) -> None: if record.scratch_path is None: return @@ -1840,8 +1848,14 @@ def build_spec(lease: ServiceLease | None) -> GenericJobSpec: launch_environment.update({port.environment: str(port.port) for port in lease.ports}) if readiness_path is not None: launch_environment["SINNIXD_SERVICE_READY_FILE"] = str(readiness_path) + scratch_path = self.store.scratch_path_for(operation.scratch, job_id) + payload_overrides: dict[str, str] = {} + if scratch_path is not None: + launch_environment["TMPDIR"] = str(scratch_path) + payload_overrides["TMPDIR"] = str(scratch_path) return GenericJobSpec( - kind="declared-operation", command=(*project.environment.command, *operation_argv), + kind="declared-operation", + command=project.environment.command_for(operation_argv, overrides=payload_overrides), working_directory=str(workdir), environment=launch_environment, project_id=project.project_id, operation=operation.name, parameter_digest=parameter_digest, principal=principal, diff --git a/pkgs/sinnixd/sinnixd/projects.py b/pkgs/sinnixd/sinnixd/projects.py index 679c5f39..ccb4dc18 100644 --- a/pkgs/sinnixd/sinnixd/projects.py +++ b/pkgs/sinnixd/sinnixd/projects.py @@ -7,7 +7,7 @@ import tomllib from dataclasses import dataclass from pathlib import Path -from typing import Any, Iterable, Mapping +from typing import Any, Iterable, Mapping, Sequence from sinnix_mcp import Authority, Lifecycle, OwnerRegistry, OwnerSpec, SinnixRef @@ -199,6 +199,23 @@ class ProjectEnvironment: def values(self) -> dict[str, str]: return build_environment(inherit=self.inherit, unset=self.unset) + def command_for( + self, payload: Sequence[str], *, overrides: Mapping[str, str] | None = None + ) -> tuple[str, ...]: + """Enter the project environment and apply runtime-owned payload variables. + + Nix creates a per-invocation ``nix-shell.*`` TMPDIR while entering a + development shell. That directory is an implementation detail, not a + durable job scratch contract. Place runtime-owned overrides after + ``nix develop --command`` so the payload sees the job-owned path. + """ + assignments = tuple(f"{name}={value}" for name, value in sorted((overrides or {}).items())) + if not assignments: + return (*self.command, *payload) + if self.kind == "nix-develop": + return (*self.command, "env", *assignments, *payload) + return ("env", *assignments, *self.command, *payload) + @dataclass(frozen=True) class ServicePortSlot: diff --git a/pkgs/sinnixd/test_service.py b/pkgs/sinnixd/test_service.py index 694e9b06..90562060 100644 --- a/pkgs/sinnixd/test_service.py +++ b/pkgs/sinnixd/test_service.py @@ -785,6 +785,34 @@ def test_service_lease_is_bounded_public_metadata_and_injects_only_declared_port assert rejected.error.code.value == "INVALID_ARGUMENT" +def test_nix_develop_payload_receives_job_owned_tmpdir_after_environment_entry(tmp_path: Path) -> None: + """The Nix shell's transient TMPDIR must not replace durable job scratch.""" + write_adapter(tmp_path) + descriptor = tmp_path / ".agentctl" / "project.toml" + descriptor.write_text( + descriptor.read_text() + .replace('kind = "fixture"', 'kind = "nix-develop"') + .replace('command = ["fixture-env", "--command"]', 'command = ["nix", "develop", "--command"]') + .replace('exclusive_keys = ["fixture:check"]', 'exclusive_keys = ["fixture:check"]\nscratch = "nvme"') + ) + systemd = FakeSystemdJobs() + jobs = generic_jobs(tmp_path, systemd) + service = SinnixdService(ProjectCatalog([tmp_path]), jobs=jobs) + + started = service.dispatch( + request("job.start", "systemd-jobs", {"project_id": "fixture", "operation": "check"}) + ) + + assert started.ok and started.payload is not None + record = jobs.store.load(started.payload.inline["job_id"]) + assert record.scratch_path is not None + expected = str(record.scratch_path) + command, environment = jobs.store.declared_launch(record.job_id) + assert command == ("nix", "develop", "--command", "env", f"TMPDIR={expected}", "fixture-check") + assert environment["TMPDIR"] == expected + assert systemd.started[0]["environment"]["TMPDIR"] == expected + + def test_declared_service_dependency_supplies_lease_and_unblocks_when_bound( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: From a01c3bf44ef1d5198b88f2abfb1b0b6bdece31fd Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 25 Aug 2026 01:51:09 +0200 Subject: [PATCH 02/35] fix(noctalia): suppress full-height notification effect --- dots/noctalia/config.toml | 9 ++++---- .../noctalia-notification-prewarm.patch | 23 +++++++++++++------ 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/dots/noctalia/config.toml b/dots/noctalia/config.toml index 797302e6..09141bdc 100644 --- a/dots/noctalia/config.toml +++ b/dots/noctalia/config.toml @@ -95,11 +95,10 @@ blacklist = [] blacklist_allow_critical = true collapse_on_dismiss = true # The native v5 renderer keeps one full-height toast surface alive while a -# notification is visible. On Hyprland's overlay level that transparent -# surface darkens its entire 576 px column even though card blur is scoped and -# compositor blur is disabled. The top level preserves toast visibility above -# ordinary windows without the full-height overlay artifact (live A/B capture, -# 2026-08-24). +# notification is visible. Our package patch prevents that surface from +# requesting the compositor background-effect protocol; layer choice only +# controls stacking and cannot constrain the effect to a notification card. +# Keep toasts above ordinary windows without granting overlay-level authority. layer = "top" monitors = [] position = "top-right" diff --git a/modules/features/desktop/noctalia-notification-prewarm.patch b/modules/features/desktop/noctalia-notification-prewarm.patch index 982d7d8c..8ae55569 100644 --- a/modules/features/desktop/noctalia-notification-prewarm.patch +++ b/modules/features/desktop/noctalia-notification-prewarm.patch @@ -2,15 +2,24 @@ diff --git a/src/shell/notification/notification_toast.cpp b/src/shell/notificat index 977b44b..294899f 100644 --- a/src/shell/notification/notification_toast.cpp +++ b/src/shell/notification/notification_toast.cpp -@@ -1985,7 +1985,10 @@ void NotificationToast::ensureSurfaces() { - .keyboard = LayerShellKeyboard::None, - .defaultWidth = surfaceWidth, - .defaultHeight = surfaceHeightForOutput(output.output), +@@ -1988 +1988,4 @@ - .prewarmBlur = true, + // This surface spans the output height. Prewarming applies the + // background effect before the card-only region exists, visibly + // darkening the entire notification column on Hyprland. + .prewarmBlur = false, - }; - - inst->surface = std::make_unique(*m_wayland, std::move(surfaceConfig)); +@@ -2131 +2133,0 @@ +- std::vector blurRects; +@@ -2132,0 +2135,2 @@ ++ // The notification layer spans the output height, so it must never request ++ // a compositor background effect; Hyprland applies it to the whole surface. +@@ -2145,2 +2148,0 @@ +- auto strips = Surface::tessellateRoundedRect(rx, ry, rw, rh, Style::scaledRadiusXl(notificationUiScale(m_config))); +- blurRects.insert(blurRects.end(), strips.begin(), strips.end()); +@@ -2150,5 +2152 @@ +- if (blurRects.empty()) { +- inst.surface->clearBlurRegion(); +- } else { +- inst.surface->setBlurRegion(blurRects); +- } ++ inst.surface->clearBlurRegion(); From f0efb49b394a1f5631fdc6efe99bd3628687436b Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 25 Aug 2026 02:01:14 +0200 Subject: [PATCH 03/35] perf(polylogue): bound verifier tmpfs to measured use --- hosts/sinnix-prime/default.nix | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/hosts/sinnix-prime/default.nix b/hosts/sinnix-prime/default.nix index 412b67eb..9a6d77cc 100644 --- a/hosts/sinnix-prime/default.nix +++ b/hosts/sinnix-prime/default.nix @@ -390,9 +390,9 @@ # Polylogue's managed verifier owns and promptly reclaims per-run trees. # Keep its high-churn fixtures off NVMe while bounding the lane independently # from the shared /tmp mount; failure evidence is copied into its receipts. - # A 12-worker exact-corpus run exceeded 2G with concurrent live fixtures and - # cascaded into 634 ENOSPC outcomes before reclaim; 4G is the measured-safe - # lane ceiling while fixture work continues toward a sub-2G live footprint. + # The current 12-worker exact-corpus run stayed below 800 MiB after prompt + # per-test reclamation. Keep 2x measured headroom while making any renewed + # fixture growth fail locally instead of consuming several GiB of host RAM. fileSystems."/realm/tmp/polylogue-pytest" = { device = "tmpfs"; fsType = "tmpfs"; @@ -400,7 +400,7 @@ "mode=0700" "uid=1000" "gid=100" - "size=4G" + "size=1536M" "nosuid" "nodev" ]; From 1827677f0565d3eb568956eb83460769d70116fe Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 25 Aug 2026 02:22:44 +0200 Subject: [PATCH 04/35] feat(agentctl): accept checkout alias for job workspaces --- pkgs/sinnixd/sinnixd/cli.py | 7 ++++++- pkgs/sinnixd/test_service.py | 8 ++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/pkgs/sinnixd/sinnixd/cli.py b/pkgs/sinnixd/sinnixd/cli.py index a11a605f..999f6198 100644 --- a/pkgs/sinnixd/sinnixd/cli.py +++ b/pkgs/sinnixd/sinnixd/cli.py @@ -106,7 +106,12 @@ def parser() -> argparse.ArgumentParser: start = job_subcommands.add_parser("start") start.add_argument("project_id") start.add_argument("operation") - start.add_argument("--workspace") + start.add_argument( + "--workspace", + "--checkout", + dest="workspace", + help="Managed workspace ID (the --checkout spelling is an equivalent convenience alias).", + ) start.add_argument("--parameters-json", default="{}") get = job_subcommands.add_parser("get") get.add_argument("job_id") diff --git a/pkgs/sinnixd/test_service.py b/pkgs/sinnixd/test_service.py index 90562060..24f622db 100644 --- a/pkgs/sinnixd/test_service.py +++ b/pkgs/sinnixd/test_service.py @@ -244,6 +244,14 @@ def test_agentctl_task_mutations_require_a_stable_request_id() -> None: cli_module.parser().parse_args(["task", "create", "fixture", "title", "--description", "body", "--type", "task", "--priority", "5", "--request-id", "request-1"]) +def test_agentctl_job_start_accepts_checkout_as_workspace_alias() -> None: + arguments = cli_module.parser().parse_args( + ["job", "start", "fixture", "check", "--checkout", "workspace-1"] + ) + + assert arguments.workspace == "workspace-1" + + def test_agentctl_job_list_exposes_service_pagination( monkeypatch: pytest.MonkeyPatch, ) -> None: From 85c8e58f8e7b9a4713728bce1eb086139fd3e08c Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 25 Aug 2026 02:29:41 +0200 Subject: [PATCH 05/35] feat(agentctl): accept explicit task note text --- pkgs/sinnixd/sinnixd/cli.py | 9 +++++++-- pkgs/sinnixd/test_service.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/pkgs/sinnixd/sinnixd/cli.py b/pkgs/sinnixd/sinnixd/cli.py index 999f6198..fb90e86c 100644 --- a/pkgs/sinnixd/sinnixd/cli.py +++ b/pkgs/sinnixd/sinnixd/cli.py @@ -186,7 +186,8 @@ def parser() -> argparse.ArgumentParser: task_note = task_subcommands.add_parser("note") task_note.add_argument("project_id") task_note.add_argument("task_id") - task_note.add_argument("text") + task_note.add_argument("text", nargs="?") + task_note.add_argument("--text", dest="text_option") task_note.add_argument("--request-id", required=True) task_relate = task_subcommands.add_parser("relate") task_relate.add_argument("project_id") @@ -479,7 +480,11 @@ def main() -> int: elif arguments.task_command in {"get", "claim", "complete", "release", "note", "relate"}: task_arguments["task_id"] = arguments.task_id if arguments.task_command == "note": - task_arguments["text"] = arguments.text + if (arguments.text is None) == (arguments.text_option is None): + parser().error("task note requires exactly one of positional text or --text") + task_arguments["text"] = ( + arguments.text_option if arguments.text_option is not None else arguments.text + ) elif arguments.task_command == "relate": task_arguments["related_task_id"] = arguments.related_task_id elif arguments.task_command in {"complete", "release"}: diff --git a/pkgs/sinnixd/test_service.py b/pkgs/sinnixd/test_service.py index 24f622db..eb67ea48 100644 --- a/pkgs/sinnixd/test_service.py +++ b/pkgs/sinnixd/test_service.py @@ -183,6 +183,7 @@ def test_canonical_client_redacts_unrecognized_json_rpc_errors(tmp_path: Path) - (("agentctl", "task", "create", "fixture", "typed title", "--description", "typed description", "--type", "task", "--priority", "2", "--label", "area:agentctl", "--parent", "fixture-parent", "--dependency", "depends-on:fixture-blocker", "--request-id", "request-1"), "task.create", {"project_id": "fixture", "title": "typed title", "description": "typed description", "issue_type": "task", "priority": 2, "labels": ["area:agentctl"], "parent_task_id": "fixture-parent", "dependencies": [{"relation": "depends-on", "task_id": "fixture-blocker"}]}), (("agentctl", "task", "claim", "fixture", "fixture-1", "--request-id", "request-1"), "task.claim", {"project_id": "fixture", "task_id": "fixture-1"}), (("agentctl", "task", "note", "fixture", "fixture-1", "note", "--request-id", "request-1"), "task.note", {"project_id": "fixture", "task_id": "fixture-1", "text": "note"}), + (("agentctl", "task", "note", "fixture", "fixture-1", "--text", "note", "--request-id", "request-1"), "task.note", {"project_id": "fixture", "task_id": "fixture-1", "text": "note"}), (("agentctl", "task", "relate", "fixture", "fixture-1", "fixture-2", "--request-id", "request-1"), "task.relate", {"project_id": "fixture", "task_id": "fixture-1", "related_task_id": "fixture-2"}), (("agentctl", "task", "complete", "fixture", "fixture-1", "--reason", "done", "--merge-sha", "a" * 40, "--request-id", "request-1"), "task.complete", {"project_id": "fixture", "task_id": "fixture-1", "reason": "done", "merge_sha": "a" * 40}), (("agentctl", "task", "release", "fixture", "fixture-1", "--if-assignee", "worker", "--request-id", "request-1"), "task.release", {"project_id": "fixture", "task_id": "fixture-1", "if_assignee": "worker"}), @@ -244,6 +245,33 @@ def test_agentctl_task_mutations_require_a_stable_request_id() -> None: cli_module.parser().parse_args(["task", "create", "fixture", "title", "--description", "body", "--type", "task", "--priority", "5", "--request-id", "request-1"]) +@pytest.mark.parametrize( + "argv", + ( + ["agentctl", "task", "note", "fixture", "fixture-1", "--request-id", "request-1"], + [ + "agentctl", + "task", + "note", + "fixture", + "fixture-1", + "positional", + "--text", + "option", + "--request-id", + "request-1", + ], + ), +) +def test_agentctl_task_note_requires_one_text_spelling( + argv: list[str], monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(sys, "argv", argv) + + with pytest.raises(SystemExit): + cli_module.main() + + def test_agentctl_job_start_accepts_checkout_as_workspace_alias() -> None: arguments = cli_module.parser().parse_args( ["job", "start", "fixture", "check", "--checkout", "workspace-1"] From 4489239cf32ec969c98e7f58229a4929a7eb948e Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 25 Aug 2026 02:40:42 +0200 Subject: [PATCH 06/35] fix(gateway): expose machine action revision --- docs/agent-gateway.md | 4 +- docs/generated/agent-gateway-reference.md | 6 +- dots/_ai/skills/agent-gateway/SKILL.md | 6 +- .../fixtures/v2-examples.json | 2 +- .../sinnix_agent_gateway/gateway_codegen.py | 2 +- .../sinnix_agent_gateway/machine_actions.py | 86 ++++++++++++------- .../sinnix_agent_gateway/registry.py | 2 +- .../sinnix_agent_gateway/server.py | 4 + .../test_machine_actions.py | 45 ++++++++++ pkgs/sinnix-agent-gateway/test_smoke.py | 38 ++++++++ .../sinnix_ops_reducer/server.py | 20 +++++ .../tests/test_server_pages.py | 13 +++ 12 files changed, 187 insertions(+), 41 deletions(-) diff --git a/docs/agent-gateway.md b/docs/agent-gateway.md index db460614..453af4cc 100644 --- a/docs/agent-gateway.md +++ b/docs/agent-gateway.md @@ -72,6 +72,8 @@ The existing `context`, `events`, `wait`, `get`, and `run` verbs carry the g2.10 The catalog publishes canonical templates for project, checkout, bead, job, artifact, receipt, result, machine unit, process, browser page, browser workspace, terminal, desktop, host file, brokered MCP tool, capture lane, capability, session, and context snapshot resources. +For machine effectors, `machine.query` with `operation=actions` reads the ops reducer's bounded authoritative revision endpoint. A cold client combines that revision with a canonical target from the ordinary machine query/get routes when calling `machine.operate`; it does not need direct reducer-socket access, the reducer's potentially large full snapshot, or a guessed precondition. + `beads.changeset` is an operator-only `change` action. It previews or applies a bounded ordered list of existing typed Beads mutations. Each action carries its canonical project or bead reference and may bind a newly created bead with `bind`. A later action refers to that bead as `$bind`. Bindings cannot cross project partitions, and canonical cross-project Beads references are rejected before any mutation. The preview contains the source revision for every project, a digest, partitions, planned compensation hints, and its truthful atomicity: `owner_atomic` only for one owner-validated `bd create --graph` action, `per_step_commits` for ordinary work in one project, or `cross_project_partitioned` for work spanning projects. Apply reports every action as `applied`, `failed`, or `skipped`; `on_error` is explicitly `stop` or `continue`. A changeset never calls a shared-server batch API, rolls back a prior step, claims unrelated writer changes, or creates hidden cross-project edges. Ordinary Beads mutations remain Dolt-only. They do not export `issues.jsonl` and do not create Git bookkeeping commits. `beads.operate` is the separate operator-only route for `snapshot.publish`, `sync.push`, `sync.pull`, `backup.create`, `backup.list`, and `backup.restore`. Snapshot publication writes a deterministic gateway-owned export, returns before and after SHA-256 values plus a bounded unified diff, and still performs no Git commit. Sync uses the owner-native Dolt push or pull command. Backup commands are limited to the pinned owner's declared backup operations. @@ -146,7 +148,7 @@ The old prototype state may be retained under the canonical state root's `legacy ## Generated V2 reference -This section is generated from the canonical gateway registry. Revision `v2-g2.10-context-events`, catalog SHA-256 `4fd6f1e385f1b6747354dfa003d42cef761a629f8ddab79a2f0a6311b5b4acd3`. +This section is generated from the canonical gateway registry. Revision `v2-g2.10-context-events`, catalog SHA-256 `65c2cba708186a4858fca7f3750ea9366782f3072272410a99e6b7dac61100d2`. The full schemas and executable examples are in [the generated gateway reference](generated/agent-gateway-reference.md). The matching agent skill is [agent-gateway](../dots/_ai/skills/agent-gateway/SKILL.md). diff --git a/docs/generated/agent-gateway-reference.md b/docs/generated/agent-gateway-reference.md index d8b51542..f11588f2 100644 --- a/docs/generated/agent-gateway-reference.md +++ b/docs/generated/agent-gateway-reference.md @@ -1,11 +1,11 @@ - + # Sinnix Agent Gateway V2 reference This reference is generated from `sinnix_agent_gateway.registry.REGISTRY`. The catalog hash changes when an action, resource, schema, route, principal, bound, or example changes. -Revision: `v2-g2.10-context-events`. Catalog SHA-256: `4fd6f1e385f1b6747354dfa003d42cef761a629f8ddab79a2f0a6311b5b4acd3`. +Revision: `v2-g2.10-context-events`. Catalog SHA-256: `65c2cba708186a4858fca7f3750ea9366782f3072272410a99e6b7dac61100d2`. ## Ten CLI verbs @@ -2580,7 +2580,7 @@ No example is declared. Discover the live schema before invoking this action. ### `machine.query` -Read one bounded, provenance-carrying machine section; overview replaces the retired whole-machine report. +Read one bounded, provenance-carrying machine section; operation=actions returns the authoritative revision required by machine.operate. Owner route: `observe.machine_query`. Principals: `agent-control, observer, operator`. Typed failures: `deadline, invalid_request, not_found, owner_failed, policy_denied, response_bound, unavailable`. diff --git a/dots/_ai/skills/agent-gateway/SKILL.md b/dots/_ai/skills/agent-gateway/SKILL.md index fa628188..2fa079f3 100644 --- a/dots/_ai/skills/agent-gateway/SKILL.md +++ b/dots/_ai/skills/agent-gateway/SKILL.md @@ -5,7 +5,7 @@ description: Use when invoking, inspecting, or documenting Sinnix Agent Gateway - + # Agent Gateway V2 Use `sinnix-agent-gateway` when a local agent needs the same principal-scoped routes and normalized envelopes as MCP. The complete action schemas and examples are in `docs/generated/agent-gateway-reference.md`. @@ -26,10 +26,10 @@ The CLI invokes the matching MCP verb through the same server runtime and princi - Work or review a bead: use `agent.for_bead` only with the canonical bead ref and explicit checkout. Use `projects.context` with `intent=bead.work` or `bead.review` to inspect assignment and evidence. - Incident orientation: use `machine.query` for one bounded owner-selected section and `audit.events` for recent gateway receipts. Do not reconstruct a whole-machine view locally. - Browser or desktop manipulation: discover or use the canonical gateway-owned browser page or desktop ref, then invoke `browser.operate` or `desktop.operate` as operator. Existing operator tabs are never accepted as implicit targets. -- Machine action: discover a canonical machine target, supply the owner-required revision, reason, idempotency key, and preconditions, then use `machine.operate`. +- Machine action: discover a canonical machine target, query `machine.query` with `operation=actions` for the current owner revision, then supply it with the reason, idempotency key, and preconditions to `machine.operate`. ## Beads direct-owner fallback The gateway is the preferred route for typed, principal-scoped Beads work. The direct owner fallback is `bd 1.1.0-dev` against the project’s canonical standalone Dolt workspace, resolved through the project’s canonical worktree and `.beads/redirect`. Dolt is the authority for ordinary mutations. `issues.jsonl` is an optional JSONL export, not a write authority. Use the gateway `beads.operate` action with `snapshot.publish` when an explicit deterministic snapshot is required. Snapshot publication does not imply a Git commit or a Dolt push. Use `sync.push` or `sync.pull` explicitly for Dolt synchronization. Never hand-author `bd` argv when the gateway catalog exposes the needed action. -Catalog revision: `v2-g2.10-context-events`. Catalog SHA-256: `4fd6f1e385f1b6747354dfa003d42cef761a629f8ddab79a2f0a6311b5b4acd3`. +Catalog revision: `v2-g2.10-context-events`. Catalog SHA-256: `65c2cba708186a4858fca7f3750ea9366782f3072272410a99e6b7dac61100d2`. diff --git a/pkgs/sinnix-agent-gateway/fixtures/v2-examples.json b/pkgs/sinnix-agent-gateway/fixtures/v2-examples.json index d15b28f2..55af1016 100644 --- a/pkgs/sinnix-agent-gateway/fixtures/v2-examples.json +++ b/pkgs/sinnix-agent-gateway/fixtures/v2-examples.json @@ -1,5 +1,5 @@ { - "action_catalog_hash": "4fd6f1e385f1b6747354dfa003d42cef761a629f8ddab79a2f0a6311b5b4acd3", + "action_catalog_hash": "65c2cba708186a4858fca7f3750ea9366782f3072272410a99e6b7dac61100d2", "examples": [ { "action": "gateway.status", diff --git a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/gateway_codegen.py b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/gateway_codegen.py index 89664225..c8dac6bb 100644 --- a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/gateway_codegen.py +++ b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/gateway_codegen.py @@ -154,7 +154,7 @@ def render_skill() -> str: - Work or review a bead: use `{agent}` only with the canonical bead ref and explicit checkout. Use `projects.context` with `intent=bead.work` or `bead.review` to inspect assignment and evidence. - Incident orientation: use `machine.query` for one bounded owner-selected section and `audit.events` for recent gateway receipts. Do not reconstruct a whole-machine view locally. - Browser or desktop manipulation: discover or use the canonical gateway-owned browser page or desktop ref, then invoke `{browser}` or `{desktop}` as operator. Existing operator tabs are never accepted as implicit targets. -- Machine action: discover a canonical machine target, supply the owner-required revision, reason, idempotency key, and preconditions, then use `machine.operate`. +- Machine action: discover a canonical machine target, query `machine.query` with `operation=actions` for the current owner revision, then supply it with the reason, idempotency key, and preconditions to `machine.operate`. ## Beads direct-owner fallback diff --git a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/machine_actions.py b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/machine_actions.py index 48e59a12..51cd66a9 100644 --- a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/machine_actions.py +++ b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/machine_actions.py @@ -36,6 +36,58 @@ def __init__( self.principal = principal self.connection_factory = connection_factory + def _request(self, method: str, path: str, body: bytes | None = None) -> dict[str, Any]: + headers = {"Content-Type": "application/json"} if body is not None else {} + try: + connection = self.connection_factory(str(self.config.ops_socket_path)) + connection.request(method, path, body, headers) + response = connection.getresponse() + payload_bytes = response.read(self.config.max_result_bytes + 1) + except (OSError, http.client.HTTPException) as exc: + raise MachineActionError("ops reducer endpoint is unavailable") from exc + finally: + try: + connection.close() + except (OSError, UnboundLocalError): + pass + if len(payload_bytes) > self.config.max_result_bytes: + raise MachineActionError("ops reducer response exceeded response bound") + try: + payload = json.loads(payload_bytes) + except json.JSONDecodeError as exc: + raise MachineActionError("ops reducer returned a malformed response") from exc + if response.status >= 400: + message = payload.get("error") if isinstance(payload, dict) else None + if isinstance(message, str): + raise MachineActionError(f"ops reducer rejected request: {message}") + raise MachineActionError("ops reducer rejected request") + if not isinstance(payload, dict): + raise MachineActionError("ops reducer returned a malformed response") + return payload + + def snapshot(self) -> dict[str, Any]: + """Read the authority revision required by a subsequent machine action.""" + self.principal.require(Capability.MACHINE_READ) + payload = self._request("GET", "/v1/revision") + if ( + payload.get("schema") != "sinnix-ops-v1" + or isinstance(payload.get("sequence"), bool) + or not isinstance(payload.get("sequence"), int) + or payload["sequence"] < 0 + or not isinstance(payload.get("observed_at"), str) + ): + raise MachineActionError("ops reducer returned a malformed snapshot") + return { + "available": True, + "operation": "actions", + "owner": "ops-reducer", + "schema": payload["schema"], + "observed_at": payload["observed_at"], + "revision": payload["sequence"], + "degradation": payload.get("degradation"), + "sources": payload.get("sources", {}), + } + def execute( self, action: str, @@ -54,34 +106,6 @@ def execute( "operator_reason": operator_reason, "parameters": parameters or {}, } - try: - connection = self.connection_factory(str(self.config.ops_socket_path)) - connection.request( - "POST", - "/v1/actions", - json.dumps(request, separators=(",", ":")), - {"Content-Type": "application/json"}, - ) - response = connection.getresponse() - body = response.read(self.config.max_result_bytes + 1) - except (OSError, http.client.HTTPException) as exc: - raise MachineActionError("ops reducer action endpoint is unavailable") from exc - finally: - try: - connection.close() - except (OSError, UnboundLocalError): - pass - if len(body) > self.config.max_result_bytes: - raise MachineActionError("ops reducer receipt exceeded response bound") - try: - payload = json.loads(body) - except json.JSONDecodeError as exc: - raise MachineActionError("ops reducer returned malformed action response") from exc - if response.status >= 400: - message = payload.get("error") if isinstance(payload, dict) else None - if isinstance(message, str): - raise MachineActionError(f"ops reducer rejected action: {message}") - raise MachineActionError("ops reducer rejected action") - if not isinstance(payload, dict): - raise MachineActionError("ops reducer returned malformed action receipt") - return payload + return self._request( + "POST", "/v1/actions", json.dumps(request, separators=(",", ":")).encode() + ) diff --git a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/registry.py b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/registry.py index e91c4fac..d613185a 100644 --- a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/registry.py +++ b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/registry.py @@ -905,7 +905,7 @@ def _owner_query_actions() -> tuple[ActionSpec, ...]: _owner_query_action("projects.tree", "projects", "projects", "projects.tree", all_principals, ("project", "checkout"), "List a bounded canonical project tree without following symlinks."), _owner_query_action("projects.read", "projects", "projects", "projects.read", all_principals, ("project", "checkout"), "Read a bounded project file through a canonical project or checkout ref."), _owner_query_action("projects.diff", "projects", "projects", "projects.diff", all_principals, ("project", "checkout"), "Read a bounded Git diff through a canonical project or checkout ref."), - _owner_query_action("machine.query", "machine", "machine", "observe.machine_query", all_principals, ("machine_unit", "process"), "Read one bounded, provenance-carrying machine section; overview replaces the retired whole-machine report."), + _owner_query_action("machine.query", "machine", "machine", "observe.machine_query", all_principals, ("machine_unit", "process"), "Read one bounded, provenance-carrying machine section; operation=actions returns the authoritative revision required by machine.operate."), _owner_query_action("capabilities.query", "capabilities", "capability-index", "capability_index.query", all_principals, ("capability",), "Search or exactly describe generated machine capabilities."), _owner_query_action("mcp.query", "mcp", "mcp-broker", "mcp.call.read", observer_operator, ("mcp_tool",), "Discover brokered MCP servers or invoke a declared read-only upstream tool."), _owner_query_action("desktop.query", "desktop", "desktop", "desktop.read", observer_operator, ("desktop",), "Read desktop state or capture output without changing focus."), diff --git a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/server.py b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/server.py index 0efba08f..e5ed1660 100644 --- a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/server.py +++ b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/server.py @@ -118,6 +118,10 @@ async def _query_owner( operation = values.get("operation") if not isinstance(operation, str): raise ProtocolError("invalid_request", "machine.query requires parameters.operation") + if operation == "actions": + if int(values.get("cursor", 0)) != 0: + raise ProtocolError("invalid_request", "machine actions snapshot does not support a cursor") + return runtime.machine_actions.snapshot() return runtime.observe.machine_query(operation, int(values.get("cursor", 0)), int(values.get("limit", 100))) if route is OwnerRoute.CAPABILITY_INDEX_QUERY: if values.get("operation", "search") == "describe": diff --git a/pkgs/sinnix-agent-gateway/test_machine_actions.py b/pkgs/sinnix-agent-gateway/test_machine_actions.py index bf77ff00..94ca654a 100644 --- a/pkgs/sinnix-agent-gateway/test_machine_actions.py +++ b/pkgs/sinnix-agent-gateway/test_machine_actions.py @@ -87,6 +87,51 @@ def test_machine_action_forwards_exact_owner_request(tmp_path: Path) -> None: } +def test_machine_action_snapshot_exposes_bounded_authority_revision(tmp_path: Path) -> None: + actions, connection = service( + tmp_path, + "operator", + FakeResponse( + 200, + { + "schema": "sinnix-ops-v1", + "sequence": 41, + "observed_at": "2026-08-25T00:00:00Z", + "degradation": None, + "sources": {"sinnix-observe": {"status": "healthy"}}, + "state": {"intentionally": "not forwarded"}, + }, + ), + ) + + assert actions.snapshot() == { + "available": True, + "operation": "actions", + "owner": "ops-reducer", + "schema": "sinnix-ops-v1", + "observed_at": "2026-08-25T00:00:00Z", + "revision": 41, + "degradation": None, + "sources": {"sinnix-observe": {"status": "healthy"}}, + } + assert connection.request_args == ("GET", "/v1/revision", None, {}) + assert connection.closed is True + + +def test_machine_action_snapshot_rejects_malformed_revision(tmp_path: Path) -> None: + actions, _ = service( + tmp_path, + "operator", + FakeResponse( + 200, + {"schema": "sinnix-ops-v1", "sequence": True, "observed_at": "now"}, + ), + ) + + with pytest.raises(MachineActionError, match="malformed snapshot"): + actions.snapshot() + + def test_machine_action_returns_owner_rejection(tmp_path: Path) -> None: actions, _ = service( tmp_path, diff --git a/pkgs/sinnix-agent-gateway/test_smoke.py b/pkgs/sinnix-agent-gateway/test_smoke.py index a6e11052..e8523bd7 100644 --- a/pkgs/sinnix-agent-gateway/test_smoke.py +++ b/pkgs/sinnix-agent-gateway/test_smoke.py @@ -728,6 +728,26 @@ class Handler(socketserver.StreamRequestHandler): def handle(self) -> None: try: request = self.rfile.readline().decode() + if request.startswith("GET /v1/revision HTTP/"): + while self.rfile.readline().rstrip(b"\r\n"): + pass + payload = json.dumps( + { + "schema": "sinnix-ops-v1", + "sequence": 17, + "observed_at": "2026-08-25T00:00:00Z", + "degradation": None, + "sources": {"fixture": {"status": "healthy"}}, + "state": {}, + }, + separators=(",", ":"), + ).encode() + self.wfile.write( + b"HTTP/1.1 200 OK\r\n" + + f"Content-Length: {len(payload)}\r\nContent-Type: application/json\r\n\r\n".encode() + + payload + ) + return if not request.startswith("POST /v1/actions HTTP/"): raise AssertionError("unexpected owner request line") headers: dict[str, str] = {} @@ -792,6 +812,14 @@ def log_message(self, *_args: object) -> None: "idempotency_key": "integration-project-change", }, ) + action_snapshot = anyio.run( + gateway.call_tool, + "query", + { + "action_name": "machine.query", + "parameters": {"operation": "actions"}, + }, + ) operate = anyio.run( gateway.call_tool, "operate", @@ -815,6 +843,16 @@ def log_message(self, *_args: object) -> None: "sinnix://projects/fixture/checkouts/default" ) assert target.read_text() == "after\n" + assert action_snapshot.structured_content["data"] == { + "available": True, + "operation": "actions", + "owner": "ops-reducer", + "schema": "sinnix-ops-v1", + "observed_at": "2026-08-25T00:00:00Z", + "revision": 17, + "degradation": None, + "sources": {"fixture": {"status": "healthy"}}, + } assert operate.structured_content["result"]["outcome"] == "ok" assert owner_failures == [] assert owner_requests == [expected_owner_request] diff --git a/pkgs/sinnix-ops-reducer/sinnix_ops_reducer/server.py b/pkgs/sinnix-ops-reducer/sinnix_ops_reducer/server.py index 19348943..fb0ae06f 100644 --- a/pkgs/sinnix-ops-reducer/sinnix_ops_reducer/server.py +++ b/pkgs/sinnix-ops-reducer/sinnix_ops_reducer/server.py @@ -485,6 +485,26 @@ def do_GET(self) -> None: except (OSError, json.JSONDecodeError): value = self.reducer.health() self._write(HTTPStatus.OK, value) + elif self.path == "/v1/revision": + try: + snapshot = json.loads( + self.reducer.snapshot_path.read_text(encoding="utf-8") + ) + except (OSError, json.JSONDecodeError): + snapshot = self.reducer.health() + self._write( + HTTPStatus.OK, + { + key: snapshot.get(key) + for key in ( + "schema", + "sequence", + "observed_at", + "degradation", + "sources", + ) + }, + ) elif self.path == "/v1/events": last = self.headers.get("Last-Event-ID") try: diff --git a/pkgs/sinnix-ops-reducer/tests/test_server_pages.py b/pkgs/sinnix-ops-reducer/tests/test_server_pages.py index aaef7737..121e753b 100644 --- a/pkgs/sinnix-ops-reducer/tests/test_server_pages.py +++ b/pkgs/sinnix-ops-reducer/tests/test_server_pages.py @@ -83,6 +83,19 @@ def test_the_json_api_is_untouched_by_the_page_routes(hub_server: str) -> None: assert json.loads(body)["schema"] == "sinnix-ops-v1" +def test_revision_route_is_a_bounded_projection_of_snapshot(hub_server: str) -> None: + snapshot = json.loads(get(hub_server + "/v1/snapshot")[2]) + status, content_type, body = get(hub_server + "/v1/revision") + + assert status == 200 + assert content_type == "application/json" + assert json.loads(body) == { + key: snapshot.get(key) + for key in ("schema", "sequence", "observed_at", "degradation", "sources") + } + assert "state" not in body + + def test_an_unknown_path_answers_a_browser_in_html(hub_server: str) -> None: status, content_type, _ = get(hub_server + "/nope") assert status == 404 From 9f7371c7e88d56e00b649fc4367dee7ce6bf5551 Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 25 Aug 2026 03:09:32 +0200 Subject: [PATCH 07/35] test(sinnixd): isolate scratch root in sandbox --- pkgs/sinnixd/test_service.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pkgs/sinnixd/test_service.py b/pkgs/sinnixd/test_service.py index eb67ea48..f7af22ed 100644 --- a/pkgs/sinnixd/test_service.py +++ b/pkgs/sinnixd/test_service.py @@ -821,8 +821,11 @@ def test_service_lease_is_bounded_public_metadata_and_injects_only_declared_port assert rejected.error.code.value == "INVALID_ARGUMENT" -def test_nix_develop_payload_receives_job_owned_tmpdir_after_environment_entry(tmp_path: Path) -> None: +def test_nix_develop_payload_receives_job_owned_tmpdir_after_environment_entry( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: """The Nix shell's transient TMPDIR must not replace durable job scratch.""" + monkeypatch.setenv("SINNIXD_NVME_SCRATCH_ROOT", str(tmp_path / "scratch")) write_adapter(tmp_path) descriptor = tmp_path / ".agentctl" / "project.toml" descriptor.write_text( From a79462925e799869bbe3873e6e553be01e4be5a6 Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 25 Aug 2026 03:25:16 +0200 Subject: [PATCH 08/35] docs(polylogue): record measured scratch peak --- hosts/sinnix-prime/default.nix | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/hosts/sinnix-prime/default.nix b/hosts/sinnix-prime/default.nix index 9a6d77cc..c88fa3b3 100644 --- a/hosts/sinnix-prime/default.nix +++ b/hosts/sinnix-prime/default.nix @@ -390,9 +390,10 @@ # Polylogue's managed verifier owns and promptly reclaims per-run trees. # Keep its high-churn fixtures off NVMe while bounding the lane independently # from the shared /tmp mount; failure evidence is copied into its receipts. - # The current 12-worker exact-corpus run stayed below 800 MiB after prompt - # per-test reclamation. Keep 2x measured headroom while making any renewed - # fixture growth fail locally instead of consuming several GiB of host RAM. + # The current 12-worker exact-corpus run peaked at 924 MiB allocated fixture + # scratch after prompt per-test reclamation. Keep roughly 60% headroom while + # making renewed fixture growth fail locally instead of consuming several + # GiB of host RAM. fileSystems."/realm/tmp/polylogue-pytest" = { device = "tmpfs"; fsType = "tmpfs"; From 63df92fc14403d2012fab11cd98865d5655a5506 Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 25 Aug 2026 04:42:26 +0200 Subject: [PATCH 09/35] feat(agentctl): reject incomplete packet results --- docs/sinnixd.md | 17 + pkgs/sinnixd/sinnixd/packet_completion.py | 585 ++++++++++++++++++++++ pkgs/sinnixd/sinnixd/service.py | 49 ++ pkgs/sinnixd/test_packet_completion.py | 260 ++++++++++ pkgs/sinnixd/test_service.py | 45 ++ 5 files changed, 956 insertions(+) create mode 100644 pkgs/sinnixd/sinnixd/packet_completion.py create mode 100644 pkgs/sinnixd/test_packet_completion.py diff --git a/docs/sinnixd.md b/docs/sinnixd.md index 11f36adc..dd5c43cc 100644 --- a/docs/sinnixd.md +++ b/docs/sinnixd.md @@ -223,6 +223,23 @@ If any verification or cutover command fails, leave Sinnixd stopped. Before the Both routes use the same UUID job ID, transient user service, cancellation, reconciliation, `job get/list/logs/result/wait`, and bounded artifact readers as declared operations. Their durable public record contains the principal, job kind, canonical project and checkout identity, redacted argv digest or prompt digest, and bounded artifact references. It never stores raw shell argv arguments after launch, prompt text, environment values, or credentials. +`job.packet-completion` is the generic AgentCTL handoff for an agent packet. It +requires the bound job and workspace IDs, the packet write scope, a structured +worker delivery record, typed verification receipts (or a project-owned receipt +provider), and explicit delegation capability metadata. The inspector composes +the job terminal state, live workspace/Git facts, receipt heads, worker command +results, anti-vacuity evidence, unresolved items, deletion ledger, and optional +independent review into one bounded result. A successful process is only one +input: dirty or untracked work, a missing commit, divergent or out-of-scope +changes, missing/stale final-head receipts, lost worker results, unresolved +structured delegation, or required review failure remains non-complete. + +Evidence-only packets must opt into no-change completion and name immutable +evidence references. Delegation visibility is `supported` with a structured +pending boolean or `unsupported` with no pending claim; last-message prose is +never parsed. Project adapters own semantic receipt production, Beads remains +task authority, and model/backend names have no completion-policy meaning. + Typed jobs accept no environment overlay. The daemon creates the `env -i` environment from the declared project environment and fixed `SINNIXD_*` identity fields. Immediately before execution, the contract runner verifies those fields, rechecks the exact registered project, canonical worktree root, common Git directory, porcelain worktree membership, and recorded HEAD. A changed, missing, symlinked, or spoofed identity fails closed. Agent handoff includes `--registered-project`, `--expected-git-common-dir`, and the canonical checkout path; nested scope creation remains disabled, so the native runner provides backend execution and native attestation while the shared transient user service remains the sole process, cgroup, timeout, and cancellation authority. Private launch inputs are mode 0600, removed before shell execution, and removed after agent handoff or every terminal lifecycle outcome, including confirmed launch failure. Native private logs are removed after handoff; only the bounded shared log and result artifacts remain addressable. Each record is stored under `$XDG_STATE_HOME/sinnixd` and contains safe operation identity, environment key names, and its bounded-read log artifact path. Record replacement fsyncs the containing directory, and newly created state directories are synchronized before they contain durable evidence. The `sinnixd-job-*.service` dynamic runtime surface and its record capture lane are declared with the daemon, rather than with any MCP frontend. Internal foreground argv is launch-only: the durable record has only a SHA-256 digest and constant display metadata, never raw argv or environment values. The systemd-launched capture helper drains output but writes at most 1 MiB per job; it creates its overflow marker with the first discarded byte, so a live log reader can see truncation before the producer exits. It also fsyncs a completion marker only after the captured process exits successfully and all bounded outputs are durable. It does not own a PID, process state, queue, task, workspace, or retry policy. A job ID deterministically derives its unit name. Every `systemd-run` and `systemctl` call has a short finite bound. `job.wait` caps each reconciliation call to its remaining deadline, so a stalled user manager cannot hold a wait or reserved control worker indefinitely. After a daemon restart, `get`, `list`, `wait`, and `cancel` reload the record and reconcile with the user manager. If `systemd-run` loses its reply but `show` finds the transient unit, `job start` returns the reconciled systemd state. If both the launch reply and its first reconciliation are unavailable, `job start` returns a durable nonterminal `launch-unknown` result with the stable job ID and unit. Later `get`, `wait`, and `cancel` use that same identity to reconcile it. A confirmed absent launch becomes terminal `launch-failed`. A confirmed missing unit after launch remains terminal `missing`; an unreachable or timed-out systemd observation is durable nonterminal `observation-unknown` until a later observation repairs it. Cancellation persists its intent before asking systemd to stop the service, then preserves an observed systemd success, timeout, or failure result. A `cancelled` result needs matching systemd signal evidence, or a durably recorded successful stop acknowledgement for the observed invocation when systemd has already garbage-collected the transient unit. If a stop times out and the unit later disappears, the job remains nonterminal `outcome-unknown` instead of treating the missing unit's default success fields as an exit result. A later authoritative systemd observation can repair that state. A typed result can prove semantic success after collection only when its content is valid and the capture completion marker proves the producer exited successfully; an empty, partial, malformed, or unmarked result is not completion evidence. A schema-v3 attested-agent record also carries forward its native completion only when systemd still reports an inactive loaded success, its durable lifecycle is `succeeded` with exit status zero, its bounded last-message artifact is valid, and no cancellation intent exists. Existing false terminal success or cancellation records without this evidence are reopened lazily by `get`, `list`, `wait`, or `cancel` and reconciled under the same rules. Systemd remains authoritative for the process, cgroup, timeout, terminal result, cancellation, and journal evidence. diff --git a/pkgs/sinnixd/sinnixd/packet_completion.py b/pkgs/sinnixd/sinnixd/packet_completion.py new file mode 100644 index 00000000..e0879739 --- /dev/null +++ b/pkgs/sinnixd/sinnixd/packet_completion.py @@ -0,0 +1,585 @@ +"""Provider-neutral completion inspection for AgentCTL delivery packets. + +Process termination is deliberately only one input. This module composes +durable AgentCTL job/workspace state with Git and typed provider receipts; it +does not interpret worker prose or own task/campaign state. +""" + +from __future__ import annotations + +import re +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Literal, Mapping, Protocol, Sequence + + +CompletionReason = Literal[ + "job_not_succeeded", + "job_timeout", + "job_result_loss", + "job_binding_mismatch", + "workspace_unavailable", + "workspace_dirty", + "untracked_work", + "workspace_identity_mismatch", + "head_binding_mismatch", + "divergent_head", + "no_commit", + "out_of_scope_path", + "worker_result_missing", + "worker_result_invalid", + "worker_command_failed", + "anti_vacuity_missing", + "unresolved_items", + "delegated_work_pending", + "verification_missing", + "verification_stale", + "verification_failed", + "evidence_only_not_authorized", + "evidence_only_evidence_missing", + "review_missing", + "review_stale", + "review_rejected", + "deletion_ledger_missing", +] + +_HEAD = re.compile(r"[0-9a-fA-F]{40,64}\Z") + + +def _require_head(value: object, name: str) -> str: + if not isinstance(value, str) or _HEAD.fullmatch(value) is None: + raise ValueError(f"{name} must be a Git object ID") + return value + + +def _require_ref(value: object, name: str) -> str: + if not isinstance(value, str) or not value or len(value) > 512: + raise ValueError(f"{name} must be a bounded non-empty reference") + return value + + +def _relative_path(value: object, name: str) -> str: + if not isinstance(value, str) or not value or value.startswith("/"): + raise ValueError(f"{name} must be a relative path") + parts = value.rstrip("/").split("/") + if any(part in {"", ".", ".."} for part in parts): + raise ValueError(f"{name} must be a normalized relative path") + return value + + +@dataclass(frozen=True) +class PacketContract: + """The immutable delivery requirements declared for one AgentCTL packet.""" + + job_id: str + workspace_id: str + write_scope: tuple[str, ...] + required_verification_refs: tuple[str, ...] = () + allow_evidence_only: bool = False + evidence_only_refs: tuple[str, ...] = () + require_independent_review: bool = False + require_deletion_ledger: bool = True + + def __post_init__(self) -> None: + if not self.job_id or not self.workspace_id: + raise ValueError("packet job_id and workspace_id are required") + if not self.write_scope or len(set(self.write_scope)) != len(self.write_scope): + raise ValueError("packet write_scope must be non-empty and unique") + for path in self.write_scope: + _relative_path(path, "packet write_scope") + refs = (*self.required_verification_refs, *self.evidence_only_refs) + if len(set(refs)) != len(refs): + raise ValueError("packet receipt references must be unique") + for ref in refs: + _require_ref(ref, "packet receipt reference") + if self.evidence_only_refs and not self.allow_evidence_only: + raise ValueError("evidence-only references require an explicit evidence-only contract") + + @classmethod + def from_mapping(cls, value: Mapping[str, Any]) -> PacketContract: + allowed = { + "job_id", "workspace_id", "write_scope", "required_verification_refs", + "allow_evidence_only", "evidence_only_refs", "require_independent_review", + "require_deletion_ledger", + } + if set(value) - allowed: + raise ValueError("packet contract has unknown fields") + try: + return cls( + job_id=value["job_id"], + workspace_id=value["workspace_id"], + write_scope=tuple(value["write_scope"]), + required_verification_refs=tuple(value.get("required_verification_refs", ())), + allow_evidence_only=value.get("allow_evidence_only", False), + evidence_only_refs=tuple(value.get("evidence_only_refs", ())), + require_independent_review=value.get("require_independent_review", False), + require_deletion_ledger=value.get("require_deletion_ledger", True), + ) + except (KeyError, TypeError) as error: + raise ValueError("packet contract is malformed") from error + + +@dataclass(frozen=True) +class DelegationCapability: + """Backend/runtime delegation visibility, never inferred from model text.""" + + visibility: Literal["supported", "unsupported"] + pending: bool | None + + def __post_init__(self) -> None: + if self.visibility not in {"supported", "unsupported"}: + raise ValueError("delegation visibility is invalid") + if self.visibility == "supported" and not isinstance(self.pending, bool): + raise ValueError("supported delegation visibility requires pending state") + if self.visibility == "unsupported" and self.pending is not None: + raise ValueError("unsupported delegation visibility cannot claim pending state") + + @classmethod + def from_mapping(cls, value: Mapping[str, Any]) -> DelegationCapability: + if set(value) != {"visibility", "pending"}: + raise ValueError("delegation capability is malformed") + return cls(visibility=value["visibility"], pending=value["pending"]) + + def to_dict(self) -> dict[str, Any]: + return {"visibility": self.visibility, "pending": self.pending} + + +@dataclass(frozen=True) +class VerificationReceipt: + ref: str + operation: str + head: str + passed: bool + immutable: bool + + def __post_init__(self) -> None: + _require_ref(self.ref, "verification receipt ref") + _require_ref(self.operation, "verification receipt operation") + _require_head(self.head, "verification receipt head") + if not isinstance(self.passed, bool) or not isinstance(self.immutable, bool): + raise ValueError("verification receipt outcome is invalid") + + @classmethod + def from_mapping(cls, value: Mapping[str, Any]) -> VerificationReceipt: + try: + return cls( + ref=value["ref"], + operation=value["operation"], + head=value["head"], + passed=value["passed"], + immutable=value["immutable"], + ) + except (KeyError, TypeError) as error: + raise ValueError("verification receipt is malformed") from error + + +class VerificationReceiptProvider(Protocol): + """Project-owned seam for immutable semantic receipts; no project import is needed.""" + + def get_receipts(self, refs: Sequence[str]) -> Sequence[VerificationReceipt]: + """Return the requested receipts, preserving their provider references.""" + + +@dataclass(frozen=True) +class EvidenceReceipt: + ref: str + head: str + passed: bool + immutable: bool + + def __post_init__(self) -> None: + _require_ref(self.ref, "evidence receipt ref") + _require_head(self.head, "evidence receipt head") + if not isinstance(self.passed, bool) or not isinstance(self.immutable, bool): + raise ValueError("evidence receipt outcome is invalid") + + @classmethod + def from_mapping(cls, value: Mapping[str, Any]) -> EvidenceReceipt: + try: + return cls( + ref=value["ref"], + head=value["head"], + passed=value["passed"], + immutable=value["immutable"], + ) + except (KeyError, TypeError) as error: + raise ValueError("evidence receipt is malformed") from error + + +@dataclass(frozen=True) +class IndependentReviewReceipt: + ref: str + head: str + passed: bool + immutable: bool + + def __post_init__(self) -> None: + _require_ref(self.ref, "review receipt ref") + _require_head(self.head, "review receipt head") + if not isinstance(self.passed, bool) or not isinstance(self.immutable, bool): + raise ValueError("review receipt outcome is invalid") + + @classmethod + def from_mapping(cls, value: Mapping[str, Any]) -> IndependentReviewReceipt: + try: + return cls( + ref=value["ref"], + head=value["head"], + passed=value["passed"], + immutable=value["immutable"], + ) + except (KeyError, TypeError) as error: + raise ValueError("review receipt is malformed") from error + + +@dataclass(frozen=True) +class GitCompletionEvidence: + """A bounded snapshot of Git facts used by completion inspection.""" + + start_head: str + final_head: str + is_descendant: bool + commits: tuple[str, ...] + changed_paths: tuple[str, ...] + working_tree_paths: tuple[str, ...] + untracked_paths: tuple[str, ...] + + def __post_init__(self) -> None: + _require_head(self.start_head, "Git start_head") + _require_head(self.final_head, "Git final_head") + if not isinstance(self.is_descendant, bool): + raise ValueError("Git ancestry evidence is invalid") + for commit in self.commits: + _require_head(commit, "Git commit") + for path in (*self.changed_paths, *self.working_tree_paths, *self.untracked_paths): + _relative_path(path, "Git changed path") + + @property + def dirty(self) -> bool: + return bool(self.working_tree_paths) + + +class GitCompletionEvidenceProvider(Protocol): + def inspect(self, *, path: str, start_head: str, final_head: str) -> GitCompletionEvidence: + """Return one bounded, same-checkout Git snapshot.""" + + +class SubprocessGitCompletionEvidence: + """Read Git authority without making any repository mutation.""" + + def inspect(self, *, path: str, start_head: str, final_head: str) -> GitCompletionEvidence: + root = Path(path) + status = self._run(root, "status", "--porcelain=v1", "--untracked-files=all").stdout + working_tree_paths = tuple( + line[3:].strip() for line in status.splitlines() if len(line) >= 4 and line[3:].strip() + ) + changed = self._run(root, "diff", "--name-only", f"{start_head}..{final_head}", "--").stdout + commits = self._run(root, "rev-list", "--reverse", f"{start_head}..{final_head}").stdout + ancestry = subprocess.run( + ["git", "-C", str(root), "merge-base", "--is-ancestor", start_head, final_head], + capture_output=True, + text=True, + timeout=2, + check=False, + ) + if ancestry.returncode not in {0, 1}: + raise ValueError("could not inspect Git ancestry") + paths = tuple(line for line in changed.splitlines() if line) + working = tuple(path for path in working_tree_paths if path) + return GitCompletionEvidence( + start_head=start_head, + final_head=final_head, + is_descendant=ancestry.returncode == 0, + commits=tuple(line for line in commits.splitlines() if line), + changed_paths=paths, + working_tree_paths=working, + untracked_paths=tuple( + line[3:].strip() for line in status.splitlines() + if line.startswith("?? ") and line[3:].strip() + ), + ) + + @staticmethod + def _run(root: Path, *arguments: str) -> subprocess.CompletedProcess[str]: + try: + result = subprocess.run( + ["git", "-C", str(root), *arguments], + capture_output=True, + text=True, + timeout=2, + check=False, + ) + except (OSError, subprocess.SubprocessError) as error: + raise ValueError("could not inspect Git workspace") from error + if result.returncode != 0: + raise ValueError("could not inspect Git workspace") + return result + + +@dataclass(frozen=True) +class WorkerDeliveryRecord: + """Structured worker handoff; ``last_message`` is intentionally ignored.""" + + result_ref: str + commands: tuple[Mapping[str, Any], ...] + anti_vacuity: Mapping[str, Any] + unresolved_items: tuple[str, ...] + deletion_ledger: tuple[Mapping[str, Any], ...] | None + last_message: str = "" + + def __post_init__(self) -> None: + _require_ref(self.result_ref, "worker result ref") + if not self.commands: + raise ValueError("worker delivery commands are missing") + for command in self.commands: + if ( + not isinstance(command, Mapping) + or set(command) != {"argv", "result"} + or not isinstance(command["argv"], (list, tuple)) + or not command["argv"] + or any(not isinstance(item, str) or not item for item in command["argv"]) + or command["result"] not in {"passed", "failed"} + ): + raise ValueError("worker delivery command result is malformed") + expected = {"checked", "mutation", "passed", "evidence_ref"} + if ( + not isinstance(self.anti_vacuity, Mapping) + or set(self.anti_vacuity) != expected + or not isinstance(self.anti_vacuity["checked"], bool) + or not isinstance(self.anti_vacuity["passed"], bool) + or not isinstance(self.anti_vacuity["mutation"], str) + ): + raise ValueError("worker anti-vacuity evidence is malformed") + _require_ref(self.anti_vacuity["evidence_ref"], "worker anti-vacuity evidence ref") + if any(not isinstance(item, str) or not item for item in self.unresolved_items): + raise ValueError("worker unresolved items are malformed") + if self.deletion_ledger is not None: + for item in self.deletion_ledger: + if not isinstance(item, Mapping) or set(item) != {"path", "action"}: + raise ValueError("worker deletion ledger is malformed") + _relative_path(item["path"], "worker deletion ledger path") + if not isinstance(item["action"], str) or not item["action"]: + raise ValueError("worker deletion ledger action is malformed") + + @classmethod + def from_mapping(cls, value: Mapping[str, Any]) -> WorkerDeliveryRecord: + required = {"result_ref", "commands", "anti_vacuity", "unresolved_items", "deletion_ledger"} + if not isinstance(value, Mapping) or not required <= set(value): + raise ValueError("worker delivery record is missing fields") + try: + return cls( + result_ref=value["result_ref"], + commands=tuple(value["commands"]), + anti_vacuity=value["anti_vacuity"], + unresolved_items=tuple(value["unresolved_items"]), + deletion_ledger=( + tuple(value["deletion_ledger"]) if value["deletion_ledger"] is not None else None + ), + last_message=value.get("last_message", ""), + ) + except (KeyError, TypeError) as error: + raise ValueError("worker delivery record is malformed") from error + + def to_dict(self) -> dict[str, Any]: + return { + "result_ref": self.result_ref, + "commands": [dict(item) for item in self.commands], + "anti_vacuity": dict(self.anti_vacuity), + "unresolved_items": list(self.unresolved_items), + "deletion_ledger": [dict(item) for item in self.deletion_ledger] if self.deletion_ledger is not None else None, + } + + +@dataclass(frozen=True) +class PacketCompletionResult: + complete: bool + reasons: tuple[CompletionReason, ...] + job_id: str + workspace_id: str + start_head: str | None + final_head: str | None + commits: tuple[str, ...] + changed_paths: tuple[str, ...] + write_scope: tuple[str, ...] + dirty: bool | None + divergent: bool | None + worker_delivery: WorkerDeliveryRecord | None + required_verification_refs: tuple[str, ...] + verification_refs: tuple[str, ...] + delegation: DelegationCapability + review_ref: str | None + + def to_dict(self) -> dict[str, Any]: + return { + "complete": self.complete, + "reasons": list(self.reasons), + "job_id": self.job_id, + "workspace_id": self.workspace_id, + "start_head": self.start_head, + "final_head": self.final_head, + "commits": list(self.commits), + "changed_paths": list(self.changed_paths), + "write_scope": list(self.write_scope), + "dirty": self.dirty, + "divergent": self.divergent, + "worker_delivery": self.worker_delivery.to_dict() if self.worker_delivery else None, + "required_verification_refs": list(self.required_verification_refs), + "verification_refs": list(self.verification_refs), + "delegation": self.delegation.to_dict(), + "review_ref": self.review_ref, + } + + +class PacketCompletionInspector: + """Compose one job/workspace snapshot into a typed completion verdict.""" + + def __init__(self, git_provider: GitCompletionEvidenceProvider | None = None) -> None: + self.git_provider = git_provider or SubprocessGitCompletionEvidence() + + def inspect( + self, + *, + job: Mapping[str, Any], + workspace: Mapping[str, Any], + contract: PacketContract, + worker_result: WorkerDeliveryRecord | None, + verification_receipts: Sequence[VerificationReceipt] | None, + delegation: DelegationCapability, + evidence_receipts: Sequence[EvidenceReceipt] = (), + review: IndependentReviewReceipt | None = None, + git: GitCompletionEvidence | None = None, + verification_provider: VerificationReceiptProvider | None = None, + ) -> PacketCompletionResult: + if verification_receipts is None: + if verification_provider is None: + raise ValueError("completion inspection requires verification receipts or a provider") + verification_receipts = verification_provider.get_receipts(contract.required_verification_refs) + reasons: list[CompletionReason] = [] + state = job.get("state") if isinstance(job.get("state"), Mapping) else {} + checkout = job.get("checkout") if isinstance(job.get("checkout"), Mapping) else {} + start_head = checkout.get("head") if isinstance(checkout.get("head"), str) else None + final_head = workspace.get("head") if isinstance(workspace.get("head"), str) else None + if job.get("job_id") != contract.job_id or workspace.get("workspace_id") != contract.workspace_id: + reasons.append("job_binding_mismatch") + if checkout.get("checkout_id") != workspace.get("checkout_id"): + reasons.append("job_binding_mismatch") + + phase = state.get("phase") + if phase == "timed_out": + reasons.append("job_timeout") + if phase != "succeeded" or state.get("terminal") is not True: + reasons.append("job_not_succeeded") + systemd = state.get("systemd") if isinstance(state.get("systemd"), Mapping) else {} + exit_status = systemd.get("ExecMainStatus", state.get("exit_status")) + if exit_status is not None and str(exit_status) != "0": + reasons.append("job_result_loss" if phase == "succeeded" else "job_not_succeeded") + + if workspace.get("state") != "available": + reasons.append("workspace_unavailable") + if workspace.get("dirty") is True: + reasons.append("workspace_dirty") + if workspace.get("identity_matches") is not True: + reasons.append("workspace_identity_mismatch") + + if git is None: + if not isinstance(workspace.get("path"), str) or start_head is None or final_head is None: + git = None + else: + try: + git = self.git_provider.inspect(path=workspace["path"], start_head=start_head, final_head=final_head) + except ValueError: + git = None + if git is not None: + if start_head != git.start_head or final_head != git.final_head: + reasons.append("head_binding_mismatch") + if not git.is_descendant: + reasons.append("divergent_head") + if git.dirty and "workspace_dirty" not in reasons: + reasons.append("workspace_dirty") + if git.untracked_paths: + reasons.append("untracked_work") + if not git.commits and not contract.allow_evidence_only: + reasons.append("no_commit") + if any( + not self._in_scope(path, contract.write_scope) + for path in (*git.changed_paths, *git.working_tree_paths, *git.untracked_paths) + ): + reasons.append("out_of_scope_path") + else: + reasons.append("head_binding_mismatch") + + if worker_result is None: + reasons.append("worker_result_missing") + else: + artifacts = job.get("artifacts") + if isinstance(artifacts, Mapping): + result_artifact = artifacts.get("result") + if result_artifact is None: + reasons.append("job_result_loss") + elif isinstance(result_artifact, Mapping) and result_artifact.get("ref") != worker_result.result_ref: + reasons.append("worker_result_invalid") + if any(command["result"] != "passed" for command in worker_result.commands): + reasons.append("worker_command_failed") + if not worker_result.anti_vacuity["checked"] or not worker_result.anti_vacuity["passed"]: + reasons.append("anti_vacuity_missing") + if worker_result.unresolved_items: + reasons.append("unresolved_items") + if contract.require_deletion_ledger and worker_result.deletion_ledger is None: + reasons.append("deletion_ledger_missing") + if delegation.visibility == "supported" and delegation.pending: + reasons.append("delegated_work_pending") + + receipts = {receipt.ref: receipt for receipt in verification_receipts} + for ref in contract.required_verification_refs: + receipt = receipts.get(ref) + if receipt is None: + reasons.append("verification_missing") + elif git is None or receipt.head != final_head: + reasons.append("verification_stale") + elif not receipt.immutable or not receipt.passed: + reasons.append("verification_failed") + + if git is not None and not git.commits: + if not contract.allow_evidence_only: + reasons.append("evidence_only_not_authorized") + else: + evidence = {receipt.ref: receipt for receipt in evidence_receipts} + for ref in contract.evidence_only_refs: + receipt = evidence.get(ref) + if receipt is None or receipt.head != final_head or not receipt.immutable or not receipt.passed: + reasons.append("evidence_only_evidence_missing") + if not contract.evidence_only_refs: + reasons.append("evidence_only_evidence_missing") + + if contract.require_independent_review: + if review is None: + reasons.append("review_missing") + elif review.head != final_head: + reasons.append("review_stale") + elif not review.immutable or not review.passed: + reasons.append("review_rejected") + + unique_reasons = tuple(dict.fromkeys(reasons)) + return PacketCompletionResult( + complete=not unique_reasons, + reasons=unique_reasons, + job_id=contract.job_id, + workspace_id=contract.workspace_id, + start_head=start_head, + final_head=final_head, + commits=git.commits if git is not None else (), + changed_paths=git.changed_paths if git is not None else (), + write_scope=contract.write_scope, + dirty=git.dirty if git is not None else workspace.get("dirty") if isinstance(workspace.get("dirty"), bool) else None, + divergent=(not git.is_descendant) if git is not None else None, + worker_delivery=worker_result, + required_verification_refs=contract.required_verification_refs, + verification_refs=tuple(receipt.ref for receipt in verification_receipts), + delegation=delegation, + review_ref=review.ref if review is not None else None, + ) + + @staticmethod + def _in_scope(path: str, scopes: Sequence[str]) -> bool: + return any(path == scope.rstrip("/") or path.startswith(scope.rstrip("/") + "/") for scope in scopes) diff --git a/pkgs/sinnixd/sinnixd/service.py b/pkgs/sinnixd/sinnixd/service.py index 0c32aca7..fe5c1b44 100644 --- a/pkgs/sinnixd/sinnixd/service.py +++ b/pkgs/sinnixd/sinnixd/service.py @@ -21,6 +21,15 @@ from .contracts import TypedJobContracts from .delivery import DeliveryError, GitHubDelivery from .owner_adapters import DeclaredOwnerAdapters, OwnerAdapterError +from .packet_completion import ( + DelegationCapability, + EvidenceReceipt, + IndependentReviewReceipt, + PacketCompletionInspector, + PacketContract, + VerificationReceipt, + WorkerDeliveryRecord, +) from .projects import ProjectCatalog from .tasks import TaskError, TaskService from .workspaces import GitWorkspaces, WorkspaceError, WorkspaceStore @@ -51,6 +60,7 @@ class SinnixdService: workspaces: GitWorkspaces | None = None delivery: GitHubDelivery | None = None tasks: TaskService | None = None + packet_completion: PacketCompletionInspector = field(default_factory=PacketCompletionInspector) def __post_init__(self) -> None: if self.workspaces is None: @@ -464,6 +474,45 @@ def _dispatch( if not isinstance(max_bytes, int) or isinstance(max_bytes, bool): raise ValueError("job.result max_bytes must be an integer") return self.jobs.result(job_id, max_bytes=max_bytes) + if operation == "job.packet-completion": + if principal not in {"agent-control", "operator"}: + raise ValueError("job.packet-completion requires agent-control or operator") + required = { + "job_id", "workspace_id", "contract", "worker_result", "verification_receipts", + "delegation", "evidence_receipts", "review", + } + if set(arguments) != required: + raise ValueError( + "job.packet-completion requires job_id, workspace_id, contract, worker_result, " + "verification_receipts, delegation, evidence_receipts, and review" + ) + job_id = self._authorize_job(principal, self._job_argument(arguments, "job_id")) + workspace_id = self._job_argument(arguments, "workspace_id") + contract = PacketContract.from_mapping(arguments["contract"]) + if contract.job_id != job_id or contract.workspace_id != workspace_id: + raise ValueError("job.packet-completion contract binding does not match arguments") + raw_worker = arguments["worker_result"] + worker = None if raw_worker is None else WorkerDeliveryRecord.from_mapping(raw_worker) + raw_verifications = arguments["verification_receipts"] + raw_evidence = arguments["evidence_receipts"] + if not isinstance(raw_verifications, list) or not isinstance(raw_evidence, list): + raise ValueError("job.packet-completion receipts must be lists") + verifications = tuple(VerificationReceipt.from_mapping(item) for item in raw_verifications) + evidence = tuple(EvidenceReceipt.from_mapping(item) for item in raw_evidence) + raw_review = arguments["review"] + review = None if raw_review is None else IndependentReviewReceipt.from_mapping(raw_review) + delegation = DelegationCapability.from_mapping(arguments["delegation"]) + assert self.workspaces is not None + return self.packet_completion.inspect( + job=self.jobs.get(job_id), + workspace=self.workspaces.get(workspace_id), + contract=contract, + worker_result=worker, + verification_receipts=verifications, + evidence_receipts=evidence, + delegation=delegation, + review=review, + ).to_dict() if operation == "job.cancel": return self._cleanup_terminal( self.jobs.cancel( diff --git a/pkgs/sinnixd/test_packet_completion.py b/pkgs/sinnixd/test_packet_completion.py new file mode 100644 index 00000000..027dc78a --- /dev/null +++ b/pkgs/sinnixd/test_packet_completion.py @@ -0,0 +1,260 @@ +from __future__ import annotations + +from dataclasses import replace + +from sinnixd.packet_completion import ( + DelegationCapability, + EvidenceReceipt, + GitCompletionEvidence, + IndependentReviewReceipt, + PacketCompletionInspector, + PacketContract, + VerificationReceipt, + WorkerDeliveryRecord, +) + + +START = "1" * 40 +FINAL = "2" * 40 +OTHER = "3" * 40 + + +def git_evidence(**overrides: object) -> GitCompletionEvidence: + values = { + "start_head": START, + "final_head": FINAL, + "is_descendant": True, + "commits": (FINAL,), + "changed_paths": ("src/changed.py",), + "working_tree_paths": (), + "untracked_paths": (), + } + return GitCompletionEvidence(**{**values, **overrides}) + + +def delivery(**overrides: object) -> WorkerDeliveryRecord: + values = { + "result_ref": "sinnix://jobs/job-1/artifacts/result", + "last_message": "", + "commands": ({"argv": ["devtools", "test", "affected"], "result": "passed"},), + "anti_vacuity": { + "checked": True, + "mutation": "replace_inspector_with_process_exit", + "passed": True, + "evidence_ref": "sinnix://evidence/anti-vacuity-1", + }, + "unresolved_items": (), + "deletion_ledger": ({"path": "retired.py", "action": "retained"},), + } + return WorkerDeliveryRecord(**{**values, **overrides}) + + +def verification(*, ref: str = "receipt-1", head: str = FINAL, passed: bool = True) -> VerificationReceipt: + return VerificationReceipt(ref=ref, operation="verify", head=head, passed=passed, immutable=True) + + +def base_contract(**overrides: object) -> PacketContract: + values = { + "job_id": "job-1", + "workspace_id": "workspace-1", + "write_scope": ("src/",), + "required_verification_refs": ("receipt-1",), + } + return PacketContract(**{**values, **overrides}) + + +def inspect(**overrides: object): + values = { + "job": { + "job_id": "job-1", + "state": {"phase": "succeeded", "terminal": True}, + "checkout": {"checkout_id": "workspace-1", "head": START}, + }, + "workspace": { + "workspace_id": "workspace-1", + "checkout_id": "workspace-1", + "state": "available", + "identity_matches": True, + "head": FINAL, + "dirty": False, + }, + "git": git_evidence(), + "contract": base_contract(), + "worker_result": delivery(), + "verification_receipts": (verification(),), + "delegation": DelegationCapability(visibility="supported", pending=False), + } + return PacketCompletionInspector().inspect(**{**values, **overrides}) + + +def test_clean_committed_exact_head_delivery_is_complete() -> None: + result = inspect() + + assert result.complete + assert result.reasons == () + assert result.job_id == "job-1" + assert result.workspace_id == "workspace-1" + assert result.start_head == START + assert result.final_head == FINAL + assert result.commits == (FINAL,) + assert result.changed_paths == ("src/changed.py",) + + +def test_observed_failed_packet_shape_is_rejected_without_prose_matching() -> None: + result = inspect( + job={ + "job_id": "job-1", + "state": {"phase": "succeeded", "terminal": True}, + "checkout": {"checkout_id": "workspace-1", "head": START}, + }, + git=git_evidence( + final_head=START, + commits=(), + changed_paths=(), + working_tree_paths=("scratch.txt",), + untracked_paths=("scratch.txt",), + ), + worker_result=None, + delegation=DelegationCapability(visibility="supported", pending=True), + ) + + assert not result.complete + assert { + "workspace_dirty", + "untracked_work", + "no_commit", + "worker_result_missing", + "delegated_work_pending", + } <= set(result.reasons) + + +def test_dirty_workspace_is_rejected_even_when_process_succeeded() -> None: + result = inspect(git=git_evidence(working_tree_paths=("src/changed.py",))) + + assert not result.complete + assert "workspace_dirty" in result.reasons + + +def test_stale_receipt_is_distinct_from_missing_receipt() -> None: + stale = inspect(verification_receipts=(verification(head=START),)) + missing = inspect(verification_receipts=()) + + assert "verification_stale" in stale.reasons + assert "verification_missing" in missing.reasons + + +def test_out_of_scope_paths_are_rejected() -> None: + result = inspect(git=git_evidence(changed_paths=("docs/outside.md",))) + + assert not result.complete + assert "out_of_scope_path" in result.reasons + + +def test_divergent_head_is_rejected() -> None: + result = inspect(git=git_evidence(final_head=OTHER, is_descendant=False)) + + assert not result.complete + assert "divergent_head" in result.reasons + + +def test_evidence_only_requires_explicit_contract_and_immutable_evidence() -> None: + evidence = EvidenceReceipt(ref="evidence-1", head=START, passed=True, immutable=True) + result = inspect( + git=git_evidence(final_head=START, commits=(), changed_paths=()), + contract=base_contract( + required_verification_refs=(), + allow_evidence_only=True, + evidence_only_refs=("evidence-1",), + ), + workspace={ + "workspace_id": "workspace-1", + "checkout_id": "workspace-1", + "state": "available", + "identity_matches": True, + "head": START, + "dirty": False, + }, + evidence_receipts=(evidence,), + ) + + assert result.complete + + accidental = inspect( + git=git_evidence(final_head=START, commits=(), changed_paths=()), + contract=base_contract(required_verification_refs=()), + ) + assert not accidental.complete + assert "evidence_only_not_authorized" in accidental.reasons + + +def test_missing_required_review_and_rejected_review_are_structural() -> None: + missing = inspect(contract=base_contract(require_independent_review=True)) + rejected = inspect( + contract=base_contract(require_independent_review=True), + review=IndependentReviewReceipt(ref="review-1", head=FINAL, passed=False, immutable=True), + ) + + assert "review_missing" in missing.reasons + assert "review_rejected" in rejected.reasons + + accepted = inspect( + contract=base_contract(require_independent_review=True), + review=IndependentReviewReceipt(ref="review-1", head=FINAL, passed=True, immutable=True), + ) + assert accepted.complete + + +def test_timeout_and_result_loss_are_not_completion() -> None: + timed_out = inspect( + job={"job_id": "job-1", "state": {"phase": "timed_out", "terminal": True}, "checkout": {"checkout_id": "workspace-1", "head": START}} + ) + lost = inspect(worker_result=None) + + assert "job_timeout" in timed_out.reasons + assert "worker_result_missing" in lost.reasons + + artifact_lost = inspect( + job={ + "job_id": "job-1", + "state": {"phase": "succeeded", "terminal": True}, + "checkout": {"checkout_id": "workspace-1", "head": START}, + "artifacts": {"result": None}, + } + ) + recovered = inspect( + job={ + "job_id": "job-1", + "state": {"phase": "succeeded", "terminal": True}, + "checkout": {"checkout_id": "workspace-1", "head": START}, + "artifacts": {"result": {"ref": "sinnix://jobs/job-1/artifacts/result"}}, + } + ) + assert "job_result_loss" in artifact_lost.reasons + assert recovered.complete + + +def test_required_deletion_ledger_cannot_be_omitted() -> None: + result = inspect(worker_result=replace(delivery(), deletion_ledger=None)) + + assert not result.complete + assert "deletion_ledger_missing" in result.reasons + + +def test_unsupported_delegation_visibility_is_explicit_but_not_prose_inferred() -> None: + result = inspect( + delegation=DelegationCapability(visibility="unsupported", pending=None), + worker_result=replace(delivery(), last_message="waiting for a background task"), + ) + + assert result.complete + assert result.delegation.visibility == "unsupported" + + +def test_pending_delegation_is_consumed_from_structured_capability() -> None: + result = inspect( + delegation=DelegationCapability(visibility="supported", pending=True), + worker_result=replace(delivery(), last_message="completed despite waiting in the text"), + ) + + assert not result.complete + assert "delegated_work_pending" in result.reasons diff --git a/pkgs/sinnixd/test_service.py b/pkgs/sinnixd/test_service.py index 694e9b06..17947ceb 100644 --- a/pkgs/sinnixd/test_service.py +++ b/pkgs/sinnixd/test_service.py @@ -4463,6 +4463,51 @@ def test_declared_job_binds_workspace_and_exact_head(tmp_path: Path) -> None: assert record.spec.checkout["head"] == workspace["head"] +def test_packet_completion_dispatch_composes_job_and_workspace_bindings(tmp_path: Path) -> None: + write_adapter(tmp_path) + initialize_git_checkout(tmp_path) + jobs = generic_jobs(tmp_path) + service = SinnixdService(ProjectCatalog([tmp_path]), jobs=jobs) + workspace = service.workspaces.create( + project_id="fixture", name="packet-lane", branch="feature/packet-lane", base="HEAD" + ) + started = service.dispatch( + request( + "job.start", + "systemd-jobs", + {"project_id": "fixture", "operation": "check", "workspace_id": workspace["workspace_id"]}, + ) + ) + assert started.ok and started.payload is not None + job_id = started.payload.inline["job_id"] + + response = service.dispatch( + request( + "job.packet-completion", + "systemd-jobs", + { + "job_id": job_id, + "workspace_id": workspace["workspace_id"], + "contract": { + "job_id": job_id, + "workspace_id": workspace["workspace_id"], + "write_scope": ["src/"], + "required_verification_refs": [], + }, + "worker_result": None, + "verification_receipts": [], + "delegation": {"visibility": "unsupported", "pending": None}, + "evidence_receipts": [], + "review": None, + }, + ) + ) + + assert response.ok and response.payload is not None + assert not response.payload.inline["complete"] + assert "worker_result_missing" in response.payload.inline["reasons"] + + def test_admission_revalidates_queued_declared_workspace_before_systemd_launch(tmp_path: Path) -> None: """A queued declared service whose checkout HEAD moved must terminalize before it reaches systemd.""" write_adapter(tmp_path) From 369eb96cb9a280c755060c7edf28c557d3a2f70d Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 25 Aug 2026 04:44:51 +0200 Subject: [PATCH 10/35] test(agentctl): cover real packet completion pair --- pkgs/sinnixd/test_packet_completion.py | 46 ++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/pkgs/sinnixd/test_packet_completion.py b/pkgs/sinnixd/test_packet_completion.py index 027dc78a..750a5754 100644 --- a/pkgs/sinnixd/test_packet_completion.py +++ b/pkgs/sinnixd/test_packet_completion.py @@ -1,6 +1,8 @@ from __future__ import annotations +import subprocess from dataclasses import replace +from pathlib import Path from sinnixd.packet_completion import ( DelegationCapability, @@ -258,3 +260,47 @@ def test_pending_delegation_is_consumed_from_structured_capability() -> None: assert not result.complete assert "delegated_work_pending" in result.reasons + + +def test_disposable_real_git_success_and_failed_packet_pair(tmp_path: Path) -> None: + subprocess.run(["git", "init", "--quiet", str(tmp_path)], check=True) + subprocess.run(["git", "-C", str(tmp_path), "config", "user.name", "Fixture"], check=True) + subprocess.run(["git", "-C", str(tmp_path), "config", "user.email", "fixture@example.test"], check=True) + subprocess.run(["git", "-C", str(tmp_path), "commit", "--quiet", "--allow-empty", "-m", "base"], check=True) + start = subprocess.run( + ["git", "-C", str(tmp_path), "rev-parse", "HEAD"], check=True, capture_output=True, text=True + ).stdout.strip() + (tmp_path / "src").mkdir() + (tmp_path / "src" / "changed.py").write_text("pass\n") + subprocess.run(["git", "-C", str(tmp_path), "add", "src/changed.py"], check=True) + subprocess.run(["git", "-C", str(tmp_path), "commit", "--quiet", "-m", "change"], check=True) + final = subprocess.run( + ["git", "-C", str(tmp_path), "rev-parse", "HEAD"], check=True, capture_output=True, text=True + ).stdout.strip() + common = { + "job": { + "job_id": "job-1", + "state": {"phase": "succeeded", "terminal": True}, + "checkout": {"checkout_id": "checkout-1", "head": start}, + }, + "workspace": { + "workspace_id": "workspace-1", + "checkout_id": "checkout-1", + "path": str(tmp_path), + "state": "available", + "identity_matches": True, + "head": final, + "dirty": False, + }, + "contract": base_contract(), + "worker_result": delivery(), + "verification_receipts": (verification(head=final),), + "delegation": DelegationCapability(visibility="supported", pending=False), + } + assert PacketCompletionInspector().inspect(**common).complete + + (tmp_path / "untracked.txt").write_text("unfinished\n") + failed = PacketCompletionInspector().inspect(**common) + assert not failed.complete + assert "workspace_dirty" in failed.reasons + assert "untracked_work" in failed.reasons From ea2e796024d6b0342cd1e6c4d8ddffbec98597cd Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 25 Aug 2026 05:07:22 +0200 Subject: [PATCH 11/35] fix(agentctl): coalesce active non-cacheable operations --- pkgs/sinnixd/sinnixd/jobs.py | 38 +++++++++++++++++++++------------ pkgs/sinnixd/test_service.py | 41 ++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 14 deletions(-) diff --git a/pkgs/sinnixd/sinnixd/jobs.py b/pkgs/sinnixd/sinnixd/jobs.py index cf453af9..2a55887a 100644 --- a/pkgs/sinnixd/sinnixd/jobs.py +++ b/pkgs/sinnixd/sinnixd/jobs.py @@ -542,6 +542,7 @@ class GenericJobSpec: pool: str = "interactive" exclusive_keys: tuple[str, ...] = () dependency_job_ids: tuple[str, ...] = () + coalesce_key: str | None = None cache_key: str | None = None estimate_key: str | None = None estimate_memory_bytes: int | None = None @@ -609,8 +610,9 @@ def __post_init__(self) -> None: raise ValueError("job exclusive keys must be unique") if any(not isinstance(value, str) or not value for value in self.dependency_job_ids): raise ValueError("job dependency IDs are invalid") - if self.cache_key is not None and (len(self.cache_key) != 64 or any(value not in "0123456789abcdef" for value in self.cache_key)): - raise ValueError("job cache key is invalid") + for name, key in (("coalesce", self.coalesce_key), ("cache", self.cache_key)): + if key is not None and (len(key) != 64 or any(value not in "0123456789abcdef" for value in key)): + raise ValueError(f"job {name} key is invalid") if self.estimate_key is not None and (not isinstance(self.estimate_key, str) or not self.estimate_key): raise ValueError("job estimate key is invalid") if self.estimate_memory_bytes is not None and ( @@ -645,6 +647,7 @@ def to_dict(self) -> dict[str, Any]: "pool": self.pool, "exclusive_keys": list(self.exclusive_keys), "dependencies": list(self.dependency_job_ids), + "coalesce_key": self.coalesce_key, "cache_key": self.cache_key, "estimate_key": self.estimate_key, "estimate_memory_bytes": self.estimate_memory_bytes, @@ -713,6 +716,7 @@ def from_dict(cls, value: Mapping[str, Any], *, require_parameter_digest: bool = pool=admission.get("pool", "interactive"), exclusive_keys=tuple(admission.get("exclusive_keys", ())), dependency_job_ids=tuple(admission.get("dependencies", ())), + coalesce_key=admission.get("coalesce_key"), cache_key=admission.get("cache_key"), estimate_key=admission.get("estimate_key"), estimate_memory_bytes=admission.get("estimate_memory_bytes"), @@ -1794,9 +1798,14 @@ def _start_declared_locked( workdir = checkout.path if checkout is not None else project.root environment = project.environment.values() tree = self._cache_tree(workdir) - cache_key = self._cache_key( - project, operation, parameter_digest, principal, environment, tree, checkout + coalesce_key = ( + self._operation_identity_key( + project, operation, parameter_digest, principal, environment, tree, checkout + ) + if operation.service is None or operation.cache == "tree+environment" + else None ) + cache_key = coalesce_key if operation.cache == "tree+environment" else None state = self._admission_state() if cache_key is not None: cached = state["cache"].get(cache_key) @@ -1810,13 +1819,13 @@ def _start_declared_locked( response = self._public(record, record.state) response["reused"] = True return response - if cache_key is not None: - active_id = state["active"].get(cache_key) + if coalesce_key is not None: + active_id = state["active"].get(coalesce_key) if isinstance(active_id, str): try: record = self.store.load(active_id) except JobRecordError: - state["active"].pop(cache_key, None) + state["active"].pop(coalesce_key, None) else: if not record.state.get("terminal"): subscribers = int(record.state.get("subscribers", 1)) + 1 @@ -1863,7 +1872,7 @@ def build_spec(lease: ServiceLease | None) -> GenericJobSpec: checkout=checkout.to_dict() if checkout is not None else None, result_kind={"exit": "exit-status", "json": "json", "pytest": "pytest"}[operation.result], pool=operation.pool, exclusive_keys=operation.exclusive_keys, dependency_job_ids=dependency_ids, - cache_key=cache_key, estimate_key=estimate_key, estimate_memory_bytes=estimate, + coalesce_key=coalesce_key, cache_key=cache_key, estimate_key=estimate_key, estimate_memory_bytes=estimate, scratch=operation.scratch, lease=lease, ) @@ -1891,8 +1900,8 @@ def build_spec(lease: ServiceLease | None) -> GenericJobSpec: "dependencies": list(dependency_ids), "admission": {"pool": spec.pool, "estimate_memory_bytes": self._estimate(spec, state)}, }) self.store.save(queued) - if cache_key is not None: - state["active"][cache_key] = job_id + if coalesce_key is not None: + state["active"][coalesce_key] = job_id self._save_admission_state(state) self._admit_locked() record = self.store.load(job_id) @@ -1910,7 +1919,7 @@ def _cache_tree(path: Path) -> str | None: return None @staticmethod - def _cache_key( + def _operation_identity_key( project: ProjectAdapter, operation: ProjectOperation, parameter_digest: str, @@ -1919,7 +1928,7 @@ def _cache_key( tree: str | None, checkout: RegisteredCheckout | None, ) -> str | None: - if operation.cache != "tree+environment" or tree is None: + if tree is None: return None payload = { "project": project.project_id, @@ -2086,8 +2095,9 @@ def _dependency_block(self, record: GenericJobRecord) -> Mapping[str, Any] | Non return None def _finish_admission(self, record: GenericJobRecord, state: dict[str, Any]) -> None: - if record.spec.cache_key is not None and state["active"].get(record.spec.cache_key) == record.job_id: - state["active"].pop(record.spec.cache_key, None) + active_key = record.spec.coalesce_key or record.spec.cache_key + if active_key is not None and state["active"].get(active_key) == record.job_id: + state["active"].pop(active_key, None) if ( record.state.get("phase") == "succeeded" and record.spec.lease is None diff --git a/pkgs/sinnixd/test_service.py b/pkgs/sinnixd/test_service.py index f7af22ed..6242a174 100644 --- a/pkgs/sinnixd/test_service.py +++ b/pkgs/sinnixd/test_service.py @@ -911,6 +911,47 @@ def test_live_service_leases_never_share_a_port(tmp_path: Path, monkeypatch: pyt assert second.payload.inline["lease"]["ports"][0]["port"] == 41001 +def test_non_cacheable_operation_coalesces_only_while_active(tmp_path: Path) -> None: + """Repeated timers share one active refresh without reusing its completed result.""" + write_adapter(tmp_path) + descriptor = tmp_path / ".agentctl" / "project.toml" + descriptor.write_text( + descriptor.read_text() + + '\n[operations.refresh]\ndescription = "Refresh a derived cache"\nexec = ["fixture-refresh"]\n' + 'pool = "bulk"\nresult = "exit"\ncache = "none"\nexclusive_keys = ["fixture:refresh"]\n' + ) + initialize_git_checkout(tmp_path) + systemd = FakeSystemdJobs() + jobs = GenericJobs( + systemd, + GenericJobStore(tmp_path.parent / f"{tmp_path.name}-runtime-state"), + wait_poll_seconds=0.001, + ) + service = SinnixdService(ProjectCatalog([tmp_path]), jobs=jobs) + arguments = {"project_id": "fixture", "operation": "refresh"} + + first = service.dispatch(request("job.start", "systemd-jobs", arguments)) + duplicate = service.dispatch(request("job.start", "systemd-jobs", arguments)) + + assert first.ok and duplicate.ok and first.payload is not None and duplicate.payload is not None + assert duplicate.payload.inline["job_id"] == first.payload.inline["job_id"] + assert duplicate.payload.inline["coalesced"] is True + assert duplicate.payload.inline["state"]["subscribers"] == 2 + assert len(systemd.started) == 1 + + systemd.properties = { + "LoadState": "loaded", "ActiveState": "inactive", "Result": "success", + "ExecMainStatus": "0", "InvocationID": "fixture-invocation", + } + service.dispatch(request("job.get", "systemd-jobs", {"job_id": first.payload.inline["job_id"]})) + replacement = service.dispatch(request("job.start", "systemd-jobs", arguments)) + + assert replacement.ok and replacement.payload is not None + assert replacement.payload.inline["job_id"] != first.payload.inline["job_id"] + assert "reused" not in replacement.payload.inline + assert len(systemd.started) == 2 + + def test_tree_cached_service_coalesces_within_scope_and_retires_terminal_entries( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: From b169974f85871dcdc8a9e60d242a14828ace5a8c Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 25 Aug 2026 05:09:59 +0200 Subject: [PATCH 12/35] fix(agentctl): gate delivery on exact-head evidence --- docs/sinnixd.md | 17 +- pkgs/sinnixd/sinnixd/contracts.py | 16 +- pkgs/sinnixd/sinnixd/delivery.py | 67 ++- pkgs/sinnixd/sinnixd/packet_completion.py | 585 ---------------------- pkgs/sinnixd/sinnixd/service.py | 49 -- pkgs/sinnixd/sinnixd/workspaces.py | 79 ++- pkgs/sinnixd/test_packet_completion.py | 306 ----------- pkgs/sinnixd/test_service.py | 30 +- 8 files changed, 180 insertions(+), 969 deletions(-) delete mode 100644 pkgs/sinnixd/sinnixd/packet_completion.py delete mode 100644 pkgs/sinnixd/test_packet_completion.py diff --git a/docs/sinnixd.md b/docs/sinnixd.md index dd5c43cc..74635395 100644 --- a/docs/sinnixd.md +++ b/docs/sinnixd.md @@ -223,22 +223,7 @@ If any verification or cutover command fails, leave Sinnixd stopped. Before the Both routes use the same UUID job ID, transient user service, cancellation, reconciliation, `job get/list/logs/result/wait`, and bounded artifact readers as declared operations. Their durable public record contains the principal, job kind, canonical project and checkout identity, redacted argv digest or prompt digest, and bounded artifact references. It never stores raw shell argv arguments after launch, prompt text, environment values, or credentials. -`job.packet-completion` is the generic AgentCTL handoff for an agent packet. It -requires the bound job and workspace IDs, the packet write scope, a structured -worker delivery record, typed verification receipts (or a project-owned receipt -provider), and explicit delegation capability metadata. The inspector composes -the job terminal state, live workspace/Git facts, receipt heads, worker command -results, anti-vacuity evidence, unresolved items, deletion ledger, and optional -independent review into one bounded result. A successful process is only one -input: dirty or untracked work, a missing commit, divergent or out-of-scope -changes, missing/stale final-head receipts, lost worker results, unresolved -structured delegation, or required review failure remains non-complete. - -Evidence-only packets must opt into no-change completion and name immutable -evidence references. Delegation visibility is `supported` with a structured -pending boolean or `unsupported` with no pending claim; last-message prose is -never parsed. Project adapters own semantic receipt production, Beads remains -task authority, and model/backend names have no completion-policy meaning. +Delivery is a precondition of `workspace.publish` and `workspace.land`, not a caller-fed completion route. It reads the declared verification job through `job.result`, snapshots the bound workspace from Git at the job's exact launch head, and repeats that precondition after push and after review inspection. A JSON or pytest result may carry the bounded `delivery` object with only anti-vacuity, unresolved-work, delegation-visibility, deletion-evidence, and evidence-only fields. The packet write scope comes from the immutable Beads binding, never that result. Git owns paths, dirtiness, commits, and heads; the project verifier owns its result artifact; GitHub owns independent review state. A missing Beads write scope means a structured packet cannot be delivered. Beads closure consumes the returned completion artifact reference in its own owner; wiring that external closure consumer is not implemented by Sinnixd. Typed jobs accept no environment overlay. The daemon creates the `env -i` environment from the declared project environment and fixed `SINNIXD_*` identity fields. Immediately before execution, the contract runner verifies those fields, rechecks the exact registered project, canonical worktree root, common Git directory, porcelain worktree membership, and recorded HEAD. A changed, missing, symlinked, or spoofed identity fails closed. Agent handoff includes `--registered-project`, `--expected-git-common-dir`, and the canonical checkout path; nested scope creation remains disabled, so the native runner provides backend execution and native attestation while the shared transient user service remains the sole process, cgroup, timeout, and cancellation authority. Private launch inputs are mode 0600, removed before shell execution, and removed after agent handoff or every terminal lifecycle outcome, including confirmed launch failure. Native private logs are removed after handoff; only the bounded shared log and result artifacts remain addressable. diff --git a/pkgs/sinnixd/sinnixd/contracts.py b/pkgs/sinnixd/sinnixd/contracts.py index d31f92cc..60375f96 100644 --- a/pkgs/sinnixd/sinnixd/contracts.py +++ b/pkgs/sinnixd/sinnixd/contracts.py @@ -250,9 +250,23 @@ def _bead_binding( "bead_ref", "project_ref", "checkout_ref", "task_revision", "task_etag", "claim_ref", "claim_receipt", "request_id", "assignment_ref", } - if not isinstance(value, Mapping) or set(value) != expected: + allowed = expected | {"write_scope"} + if not isinstance(value, Mapping) or (set(value) != expected and set(value) != allowed): raise ContractError("agent bead binding is malformed") binding = dict(value) + scope = binding.get("write_scope") + if scope is not None and ( + not isinstance(scope, list) + or not scope + or any( + not isinstance(path, str) + or not path + or path.startswith("/") + or ".." in Path(path).parts + for path in scope + ) + ): + raise ContractError("agent Beads write scope is malformed") project_ref = f"sinnix://projects/{checkout.project_id}" checkout_ref = f"{project_ref}/checkouts/{checkout.checkout_id}" bead_prefix = f"{project_ref}/beads/" diff --git a/pkgs/sinnixd/sinnixd/delivery.py b/pkgs/sinnixd/sinnixd/delivery.py index 42be55da..3980944b 100644 --- a/pkgs/sinnixd/sinnixd/delivery.py +++ b/pkgs/sinnixd/sinnixd/delivery.py @@ -26,13 +26,14 @@ class GitHubDelivery: run: Run = subprocess.run def publish(self, workspace_id: str, job_id: str, title: str, body: str) -> dict[str, Any]: - workspace, project = self._verified_workspace(workspace_id, job_id) + workspace, project, receipt = self._verified_workspace(workspace_id, job_id) if not title.strip() or len(title) > 256 or len(body.encode()) > 64_000: raise DeliveryError("review title or body exceeds its publication bounds") base = self._base_branch(project.workspace.default_base) path = workspace["path"] branch = workspace["branch"] self._command([*project.environment.command, "git", "-C", path, "push", "-u", "origin", branch], cwd=path) + workspace, project, receipt = self._verified_workspace(workspace_id, job_id) existing = self.run( ["gh", "pr", "view", branch, "--json", "url"], cwd=path, capture_output=True, text=True, timeout=60, check=False, @@ -47,7 +48,7 @@ def publish(self, workspace_id: str, job_id: str, title: str, body: str) -> dict ).stdout.strip() created = True review = self._review_after_push(workspace_id) - return {**review, "published": True, "created": created, "publication_output": publication_output} + return {**review, "published": True, "created": created, "publication_output": publication_output, "completion": receipt} def _review_after_push(self, workspace_id: str) -> dict[str, Any]: for attempt in range(10): @@ -92,11 +93,12 @@ def land(self, workspace_id: str, job_id: str) -> dict[str, Any]: or not self._checks_pass(review["statusCheckRollup"]) ): raise DeliveryError("review is not in a landable GitHub state") + _workspace, _project, receipt = self._verified_workspace(workspace_id, job_id) self._command(["gh", "pr", "merge", str(review["number"]), "--squash"], cwd=self.workspaces.get(workspace_id)["path"]) merged = self.review_status(workspace_id) if merged["review"]["state"] != "MERGED": raise DeliveryError("GitHub did not report the review merged") - return {**merged, "landed": True} + return {**merged, "landed": True, "completion": receipt} def finish(self, workspace_id: str) -> dict[str, Any]: status = self.review_status(workspace_id) @@ -106,9 +108,9 @@ def finish(self, workspace_id: str) -> dict[str, Any]: self._delete_remote_branch(workspace["path"], workspace["branch"]) return self.workspaces.finish_merged(workspace_id, status["head"]) - def _verified_workspace(self, workspace_id: str, job_id: str) -> tuple[dict[str, Any], Any]: + def _verified_workspace(self, workspace_id: str, job_id: str) -> tuple[dict[str, Any], Any, dict[str, Any]]: workspace = self.workspaces.get(workspace_id) - if workspace["state"] != "available" or workspace["dirty"] or not workspace["identity_matches"]: + if workspace["state"] != "available" or not workspace["identity_matches"]: raise DeliveryError("publication requires an available clean identity-matching workspace") project = self.projects.get(workspace["project_id"]) assert project.workspace is not None @@ -119,11 +121,62 @@ def _verified_workspace(self, workspace_id: str, job_id: str) -> tuple[dict[str, job["state"].get("phase") != "succeeded" or checkout is None or checkout.get("checkout_id") != workspace["checkout_id"] - or checkout.get("head") != workspace["head"] or record.spec.operation not in project.workspace.verification_operations ): raise DeliveryError("workspace lacks successful declared verification at its exact HEAD") - return workspace, project + try: + result = self.jobs.result(job_id) + delivery_result = self._is_delivery_result(result) + scope = self._packet_scope(record.spec.contract) + snapshot = self.workspaces.delivery_snapshot(workspace_id, checkout["head"], scope=scope or ()) + except (ValueError, WorkspaceError) as error: + raise DeliveryError("workspace lacks an authoritative exact-head completion receipt") from error + if snapshot["head"] != checkout["head"] or not snapshot["descendant"] or snapshot["dirty"]: + raise DeliveryError("workspace lacks successful declared verification at its exact HEAD") + if delivery_result and (scope is None or not snapshot["in_scope"]): + raise DeliveryError("packet delivery is outside its Beads-owned write scope") + self._validate_delivery_result(result, snapshot) + artifact = result.get("artifact") if isinstance(result, Mapping) else None + return workspace, project, {"ref": artifact.get("ref") if isinstance(artifact, Mapping) else f"sinnix://jobs/{job_id}", "job_id": job_id, "workspace_id": workspace_id, "head": snapshot["head"], "verification_operation": record.spec.operation} + + @staticmethod + def _is_delivery_result(result: Mapping[str, Any]) -> bool: + value = result.get("value") + return result.get("kind") in {"json", "pytest"} and isinstance(value, Mapping) and "delivery" in value + + @staticmethod + def _packet_scope(contract: Mapping[str, Any]) -> tuple[str, ...] | None: + binding = contract.get("bead_binding") + if not isinstance(binding, Mapping) or "write_scope" not in binding: + return None + scope = binding["write_scope"] + if not isinstance(scope, list) or not scope or any(not isinstance(path, str) for path in scope): + raise DeliveryError("Beads-owned write scope is malformed") + return tuple(scope) + + @staticmethod + def _validate_delivery_result(result: Mapping[str, Any], snapshot: Mapping[str, Any]) -> None: + if not GitHubDelivery._is_delivery_result(result): + return + value = result.get("value") + delivery = value.get("delivery") if isinstance(value, Mapping) else None + if not isinstance(delivery, Mapping) or set(delivery) != {"anti_vacuity", "unresolved_work", "delegation", "deletion_evidence", "evidence_only"}: + raise DeliveryError("project delivery result is malformed") + unresolved, delegation, deletions = delivery["unresolved_work"], delivery["delegation"], delivery["deletion_evidence"] + if delivery["anti_vacuity"] is not True or not isinstance(unresolved, list) or unresolved or not isinstance(deletions, list) or not isinstance(delivery["evidence_only"], bool) or not isinstance(delegation, Mapping) or set(delegation) != {"visibility", "pending"}: + raise DeliveryError("project delivery result is incomplete") + visibility, pending = delegation["visibility"], delegation["pending"] + if visibility not in {"supported", "unsupported"} or (visibility == "supported" and pending is not False) or (visibility == "unsupported" and pending is not None): + raise DeliveryError("project delivery delegation visibility is invalid") + changes = snapshot.get("changes") + if not isinstance(changes, list): + raise DeliveryError("workspace delivery snapshot is malformed") + if any(isinstance(change, Mapping) and str(change.get("status", ""))[:1] == "D" for change in changes) and not deletions: + raise DeliveryError("project delivery result omits deletion evidence") + if not changes and not delivery["evidence_only"]: + raise DeliveryError("no-change delivery lacks the evidence-only exception") + if changes and delivery["evidence_only"]: + raise DeliveryError("evidence-only delivery contains code changes") @staticmethod def _base_branch(default_base: str) -> str: diff --git a/pkgs/sinnixd/sinnixd/packet_completion.py b/pkgs/sinnixd/sinnixd/packet_completion.py deleted file mode 100644 index e0879739..00000000 --- a/pkgs/sinnixd/sinnixd/packet_completion.py +++ /dev/null @@ -1,585 +0,0 @@ -"""Provider-neutral completion inspection for AgentCTL delivery packets. - -Process termination is deliberately only one input. This module composes -durable AgentCTL job/workspace state with Git and typed provider receipts; it -does not interpret worker prose or own task/campaign state. -""" - -from __future__ import annotations - -import re -import subprocess -from dataclasses import dataclass -from pathlib import Path -from typing import Any, Literal, Mapping, Protocol, Sequence - - -CompletionReason = Literal[ - "job_not_succeeded", - "job_timeout", - "job_result_loss", - "job_binding_mismatch", - "workspace_unavailable", - "workspace_dirty", - "untracked_work", - "workspace_identity_mismatch", - "head_binding_mismatch", - "divergent_head", - "no_commit", - "out_of_scope_path", - "worker_result_missing", - "worker_result_invalid", - "worker_command_failed", - "anti_vacuity_missing", - "unresolved_items", - "delegated_work_pending", - "verification_missing", - "verification_stale", - "verification_failed", - "evidence_only_not_authorized", - "evidence_only_evidence_missing", - "review_missing", - "review_stale", - "review_rejected", - "deletion_ledger_missing", -] - -_HEAD = re.compile(r"[0-9a-fA-F]{40,64}\Z") - - -def _require_head(value: object, name: str) -> str: - if not isinstance(value, str) or _HEAD.fullmatch(value) is None: - raise ValueError(f"{name} must be a Git object ID") - return value - - -def _require_ref(value: object, name: str) -> str: - if not isinstance(value, str) or not value or len(value) > 512: - raise ValueError(f"{name} must be a bounded non-empty reference") - return value - - -def _relative_path(value: object, name: str) -> str: - if not isinstance(value, str) or not value or value.startswith("/"): - raise ValueError(f"{name} must be a relative path") - parts = value.rstrip("/").split("/") - if any(part in {"", ".", ".."} for part in parts): - raise ValueError(f"{name} must be a normalized relative path") - return value - - -@dataclass(frozen=True) -class PacketContract: - """The immutable delivery requirements declared for one AgentCTL packet.""" - - job_id: str - workspace_id: str - write_scope: tuple[str, ...] - required_verification_refs: tuple[str, ...] = () - allow_evidence_only: bool = False - evidence_only_refs: tuple[str, ...] = () - require_independent_review: bool = False - require_deletion_ledger: bool = True - - def __post_init__(self) -> None: - if not self.job_id or not self.workspace_id: - raise ValueError("packet job_id and workspace_id are required") - if not self.write_scope or len(set(self.write_scope)) != len(self.write_scope): - raise ValueError("packet write_scope must be non-empty and unique") - for path in self.write_scope: - _relative_path(path, "packet write_scope") - refs = (*self.required_verification_refs, *self.evidence_only_refs) - if len(set(refs)) != len(refs): - raise ValueError("packet receipt references must be unique") - for ref in refs: - _require_ref(ref, "packet receipt reference") - if self.evidence_only_refs and not self.allow_evidence_only: - raise ValueError("evidence-only references require an explicit evidence-only contract") - - @classmethod - def from_mapping(cls, value: Mapping[str, Any]) -> PacketContract: - allowed = { - "job_id", "workspace_id", "write_scope", "required_verification_refs", - "allow_evidence_only", "evidence_only_refs", "require_independent_review", - "require_deletion_ledger", - } - if set(value) - allowed: - raise ValueError("packet contract has unknown fields") - try: - return cls( - job_id=value["job_id"], - workspace_id=value["workspace_id"], - write_scope=tuple(value["write_scope"]), - required_verification_refs=tuple(value.get("required_verification_refs", ())), - allow_evidence_only=value.get("allow_evidence_only", False), - evidence_only_refs=tuple(value.get("evidence_only_refs", ())), - require_independent_review=value.get("require_independent_review", False), - require_deletion_ledger=value.get("require_deletion_ledger", True), - ) - except (KeyError, TypeError) as error: - raise ValueError("packet contract is malformed") from error - - -@dataclass(frozen=True) -class DelegationCapability: - """Backend/runtime delegation visibility, never inferred from model text.""" - - visibility: Literal["supported", "unsupported"] - pending: bool | None - - def __post_init__(self) -> None: - if self.visibility not in {"supported", "unsupported"}: - raise ValueError("delegation visibility is invalid") - if self.visibility == "supported" and not isinstance(self.pending, bool): - raise ValueError("supported delegation visibility requires pending state") - if self.visibility == "unsupported" and self.pending is not None: - raise ValueError("unsupported delegation visibility cannot claim pending state") - - @classmethod - def from_mapping(cls, value: Mapping[str, Any]) -> DelegationCapability: - if set(value) != {"visibility", "pending"}: - raise ValueError("delegation capability is malformed") - return cls(visibility=value["visibility"], pending=value["pending"]) - - def to_dict(self) -> dict[str, Any]: - return {"visibility": self.visibility, "pending": self.pending} - - -@dataclass(frozen=True) -class VerificationReceipt: - ref: str - operation: str - head: str - passed: bool - immutable: bool - - def __post_init__(self) -> None: - _require_ref(self.ref, "verification receipt ref") - _require_ref(self.operation, "verification receipt operation") - _require_head(self.head, "verification receipt head") - if not isinstance(self.passed, bool) or not isinstance(self.immutable, bool): - raise ValueError("verification receipt outcome is invalid") - - @classmethod - def from_mapping(cls, value: Mapping[str, Any]) -> VerificationReceipt: - try: - return cls( - ref=value["ref"], - operation=value["operation"], - head=value["head"], - passed=value["passed"], - immutable=value["immutable"], - ) - except (KeyError, TypeError) as error: - raise ValueError("verification receipt is malformed") from error - - -class VerificationReceiptProvider(Protocol): - """Project-owned seam for immutable semantic receipts; no project import is needed.""" - - def get_receipts(self, refs: Sequence[str]) -> Sequence[VerificationReceipt]: - """Return the requested receipts, preserving their provider references.""" - - -@dataclass(frozen=True) -class EvidenceReceipt: - ref: str - head: str - passed: bool - immutable: bool - - def __post_init__(self) -> None: - _require_ref(self.ref, "evidence receipt ref") - _require_head(self.head, "evidence receipt head") - if not isinstance(self.passed, bool) or not isinstance(self.immutable, bool): - raise ValueError("evidence receipt outcome is invalid") - - @classmethod - def from_mapping(cls, value: Mapping[str, Any]) -> EvidenceReceipt: - try: - return cls( - ref=value["ref"], - head=value["head"], - passed=value["passed"], - immutable=value["immutable"], - ) - except (KeyError, TypeError) as error: - raise ValueError("evidence receipt is malformed") from error - - -@dataclass(frozen=True) -class IndependentReviewReceipt: - ref: str - head: str - passed: bool - immutable: bool - - def __post_init__(self) -> None: - _require_ref(self.ref, "review receipt ref") - _require_head(self.head, "review receipt head") - if not isinstance(self.passed, bool) or not isinstance(self.immutable, bool): - raise ValueError("review receipt outcome is invalid") - - @classmethod - def from_mapping(cls, value: Mapping[str, Any]) -> IndependentReviewReceipt: - try: - return cls( - ref=value["ref"], - head=value["head"], - passed=value["passed"], - immutable=value["immutable"], - ) - except (KeyError, TypeError) as error: - raise ValueError("review receipt is malformed") from error - - -@dataclass(frozen=True) -class GitCompletionEvidence: - """A bounded snapshot of Git facts used by completion inspection.""" - - start_head: str - final_head: str - is_descendant: bool - commits: tuple[str, ...] - changed_paths: tuple[str, ...] - working_tree_paths: tuple[str, ...] - untracked_paths: tuple[str, ...] - - def __post_init__(self) -> None: - _require_head(self.start_head, "Git start_head") - _require_head(self.final_head, "Git final_head") - if not isinstance(self.is_descendant, bool): - raise ValueError("Git ancestry evidence is invalid") - for commit in self.commits: - _require_head(commit, "Git commit") - for path in (*self.changed_paths, *self.working_tree_paths, *self.untracked_paths): - _relative_path(path, "Git changed path") - - @property - def dirty(self) -> bool: - return bool(self.working_tree_paths) - - -class GitCompletionEvidenceProvider(Protocol): - def inspect(self, *, path: str, start_head: str, final_head: str) -> GitCompletionEvidence: - """Return one bounded, same-checkout Git snapshot.""" - - -class SubprocessGitCompletionEvidence: - """Read Git authority without making any repository mutation.""" - - def inspect(self, *, path: str, start_head: str, final_head: str) -> GitCompletionEvidence: - root = Path(path) - status = self._run(root, "status", "--porcelain=v1", "--untracked-files=all").stdout - working_tree_paths = tuple( - line[3:].strip() for line in status.splitlines() if len(line) >= 4 and line[3:].strip() - ) - changed = self._run(root, "diff", "--name-only", f"{start_head}..{final_head}", "--").stdout - commits = self._run(root, "rev-list", "--reverse", f"{start_head}..{final_head}").stdout - ancestry = subprocess.run( - ["git", "-C", str(root), "merge-base", "--is-ancestor", start_head, final_head], - capture_output=True, - text=True, - timeout=2, - check=False, - ) - if ancestry.returncode not in {0, 1}: - raise ValueError("could not inspect Git ancestry") - paths = tuple(line for line in changed.splitlines() if line) - working = tuple(path for path in working_tree_paths if path) - return GitCompletionEvidence( - start_head=start_head, - final_head=final_head, - is_descendant=ancestry.returncode == 0, - commits=tuple(line for line in commits.splitlines() if line), - changed_paths=paths, - working_tree_paths=working, - untracked_paths=tuple( - line[3:].strip() for line in status.splitlines() - if line.startswith("?? ") and line[3:].strip() - ), - ) - - @staticmethod - def _run(root: Path, *arguments: str) -> subprocess.CompletedProcess[str]: - try: - result = subprocess.run( - ["git", "-C", str(root), *arguments], - capture_output=True, - text=True, - timeout=2, - check=False, - ) - except (OSError, subprocess.SubprocessError) as error: - raise ValueError("could not inspect Git workspace") from error - if result.returncode != 0: - raise ValueError("could not inspect Git workspace") - return result - - -@dataclass(frozen=True) -class WorkerDeliveryRecord: - """Structured worker handoff; ``last_message`` is intentionally ignored.""" - - result_ref: str - commands: tuple[Mapping[str, Any], ...] - anti_vacuity: Mapping[str, Any] - unresolved_items: tuple[str, ...] - deletion_ledger: tuple[Mapping[str, Any], ...] | None - last_message: str = "" - - def __post_init__(self) -> None: - _require_ref(self.result_ref, "worker result ref") - if not self.commands: - raise ValueError("worker delivery commands are missing") - for command in self.commands: - if ( - not isinstance(command, Mapping) - or set(command) != {"argv", "result"} - or not isinstance(command["argv"], (list, tuple)) - or not command["argv"] - or any(not isinstance(item, str) or not item for item in command["argv"]) - or command["result"] not in {"passed", "failed"} - ): - raise ValueError("worker delivery command result is malformed") - expected = {"checked", "mutation", "passed", "evidence_ref"} - if ( - not isinstance(self.anti_vacuity, Mapping) - or set(self.anti_vacuity) != expected - or not isinstance(self.anti_vacuity["checked"], bool) - or not isinstance(self.anti_vacuity["passed"], bool) - or not isinstance(self.anti_vacuity["mutation"], str) - ): - raise ValueError("worker anti-vacuity evidence is malformed") - _require_ref(self.anti_vacuity["evidence_ref"], "worker anti-vacuity evidence ref") - if any(not isinstance(item, str) or not item for item in self.unresolved_items): - raise ValueError("worker unresolved items are malformed") - if self.deletion_ledger is not None: - for item in self.deletion_ledger: - if not isinstance(item, Mapping) or set(item) != {"path", "action"}: - raise ValueError("worker deletion ledger is malformed") - _relative_path(item["path"], "worker deletion ledger path") - if not isinstance(item["action"], str) or not item["action"]: - raise ValueError("worker deletion ledger action is malformed") - - @classmethod - def from_mapping(cls, value: Mapping[str, Any]) -> WorkerDeliveryRecord: - required = {"result_ref", "commands", "anti_vacuity", "unresolved_items", "deletion_ledger"} - if not isinstance(value, Mapping) or not required <= set(value): - raise ValueError("worker delivery record is missing fields") - try: - return cls( - result_ref=value["result_ref"], - commands=tuple(value["commands"]), - anti_vacuity=value["anti_vacuity"], - unresolved_items=tuple(value["unresolved_items"]), - deletion_ledger=( - tuple(value["deletion_ledger"]) if value["deletion_ledger"] is not None else None - ), - last_message=value.get("last_message", ""), - ) - except (KeyError, TypeError) as error: - raise ValueError("worker delivery record is malformed") from error - - def to_dict(self) -> dict[str, Any]: - return { - "result_ref": self.result_ref, - "commands": [dict(item) for item in self.commands], - "anti_vacuity": dict(self.anti_vacuity), - "unresolved_items": list(self.unresolved_items), - "deletion_ledger": [dict(item) for item in self.deletion_ledger] if self.deletion_ledger is not None else None, - } - - -@dataclass(frozen=True) -class PacketCompletionResult: - complete: bool - reasons: tuple[CompletionReason, ...] - job_id: str - workspace_id: str - start_head: str | None - final_head: str | None - commits: tuple[str, ...] - changed_paths: tuple[str, ...] - write_scope: tuple[str, ...] - dirty: bool | None - divergent: bool | None - worker_delivery: WorkerDeliveryRecord | None - required_verification_refs: tuple[str, ...] - verification_refs: tuple[str, ...] - delegation: DelegationCapability - review_ref: str | None - - def to_dict(self) -> dict[str, Any]: - return { - "complete": self.complete, - "reasons": list(self.reasons), - "job_id": self.job_id, - "workspace_id": self.workspace_id, - "start_head": self.start_head, - "final_head": self.final_head, - "commits": list(self.commits), - "changed_paths": list(self.changed_paths), - "write_scope": list(self.write_scope), - "dirty": self.dirty, - "divergent": self.divergent, - "worker_delivery": self.worker_delivery.to_dict() if self.worker_delivery else None, - "required_verification_refs": list(self.required_verification_refs), - "verification_refs": list(self.verification_refs), - "delegation": self.delegation.to_dict(), - "review_ref": self.review_ref, - } - - -class PacketCompletionInspector: - """Compose one job/workspace snapshot into a typed completion verdict.""" - - def __init__(self, git_provider: GitCompletionEvidenceProvider | None = None) -> None: - self.git_provider = git_provider or SubprocessGitCompletionEvidence() - - def inspect( - self, - *, - job: Mapping[str, Any], - workspace: Mapping[str, Any], - contract: PacketContract, - worker_result: WorkerDeliveryRecord | None, - verification_receipts: Sequence[VerificationReceipt] | None, - delegation: DelegationCapability, - evidence_receipts: Sequence[EvidenceReceipt] = (), - review: IndependentReviewReceipt | None = None, - git: GitCompletionEvidence | None = None, - verification_provider: VerificationReceiptProvider | None = None, - ) -> PacketCompletionResult: - if verification_receipts is None: - if verification_provider is None: - raise ValueError("completion inspection requires verification receipts or a provider") - verification_receipts = verification_provider.get_receipts(contract.required_verification_refs) - reasons: list[CompletionReason] = [] - state = job.get("state") if isinstance(job.get("state"), Mapping) else {} - checkout = job.get("checkout") if isinstance(job.get("checkout"), Mapping) else {} - start_head = checkout.get("head") if isinstance(checkout.get("head"), str) else None - final_head = workspace.get("head") if isinstance(workspace.get("head"), str) else None - if job.get("job_id") != contract.job_id or workspace.get("workspace_id") != contract.workspace_id: - reasons.append("job_binding_mismatch") - if checkout.get("checkout_id") != workspace.get("checkout_id"): - reasons.append("job_binding_mismatch") - - phase = state.get("phase") - if phase == "timed_out": - reasons.append("job_timeout") - if phase != "succeeded" or state.get("terminal") is not True: - reasons.append("job_not_succeeded") - systemd = state.get("systemd") if isinstance(state.get("systemd"), Mapping) else {} - exit_status = systemd.get("ExecMainStatus", state.get("exit_status")) - if exit_status is not None and str(exit_status) != "0": - reasons.append("job_result_loss" if phase == "succeeded" else "job_not_succeeded") - - if workspace.get("state") != "available": - reasons.append("workspace_unavailable") - if workspace.get("dirty") is True: - reasons.append("workspace_dirty") - if workspace.get("identity_matches") is not True: - reasons.append("workspace_identity_mismatch") - - if git is None: - if not isinstance(workspace.get("path"), str) or start_head is None or final_head is None: - git = None - else: - try: - git = self.git_provider.inspect(path=workspace["path"], start_head=start_head, final_head=final_head) - except ValueError: - git = None - if git is not None: - if start_head != git.start_head or final_head != git.final_head: - reasons.append("head_binding_mismatch") - if not git.is_descendant: - reasons.append("divergent_head") - if git.dirty and "workspace_dirty" not in reasons: - reasons.append("workspace_dirty") - if git.untracked_paths: - reasons.append("untracked_work") - if not git.commits and not contract.allow_evidence_only: - reasons.append("no_commit") - if any( - not self._in_scope(path, contract.write_scope) - for path in (*git.changed_paths, *git.working_tree_paths, *git.untracked_paths) - ): - reasons.append("out_of_scope_path") - else: - reasons.append("head_binding_mismatch") - - if worker_result is None: - reasons.append("worker_result_missing") - else: - artifacts = job.get("artifacts") - if isinstance(artifacts, Mapping): - result_artifact = artifacts.get("result") - if result_artifact is None: - reasons.append("job_result_loss") - elif isinstance(result_artifact, Mapping) and result_artifact.get("ref") != worker_result.result_ref: - reasons.append("worker_result_invalid") - if any(command["result"] != "passed" for command in worker_result.commands): - reasons.append("worker_command_failed") - if not worker_result.anti_vacuity["checked"] or not worker_result.anti_vacuity["passed"]: - reasons.append("anti_vacuity_missing") - if worker_result.unresolved_items: - reasons.append("unresolved_items") - if contract.require_deletion_ledger and worker_result.deletion_ledger is None: - reasons.append("deletion_ledger_missing") - if delegation.visibility == "supported" and delegation.pending: - reasons.append("delegated_work_pending") - - receipts = {receipt.ref: receipt for receipt in verification_receipts} - for ref in contract.required_verification_refs: - receipt = receipts.get(ref) - if receipt is None: - reasons.append("verification_missing") - elif git is None or receipt.head != final_head: - reasons.append("verification_stale") - elif not receipt.immutable or not receipt.passed: - reasons.append("verification_failed") - - if git is not None and not git.commits: - if not contract.allow_evidence_only: - reasons.append("evidence_only_not_authorized") - else: - evidence = {receipt.ref: receipt for receipt in evidence_receipts} - for ref in contract.evidence_only_refs: - receipt = evidence.get(ref) - if receipt is None or receipt.head != final_head or not receipt.immutable or not receipt.passed: - reasons.append("evidence_only_evidence_missing") - if not contract.evidence_only_refs: - reasons.append("evidence_only_evidence_missing") - - if contract.require_independent_review: - if review is None: - reasons.append("review_missing") - elif review.head != final_head: - reasons.append("review_stale") - elif not review.immutable or not review.passed: - reasons.append("review_rejected") - - unique_reasons = tuple(dict.fromkeys(reasons)) - return PacketCompletionResult( - complete=not unique_reasons, - reasons=unique_reasons, - job_id=contract.job_id, - workspace_id=contract.workspace_id, - start_head=start_head, - final_head=final_head, - commits=git.commits if git is not None else (), - changed_paths=git.changed_paths if git is not None else (), - write_scope=contract.write_scope, - dirty=git.dirty if git is not None else workspace.get("dirty") if isinstance(workspace.get("dirty"), bool) else None, - divergent=(not git.is_descendant) if git is not None else None, - worker_delivery=worker_result, - required_verification_refs=contract.required_verification_refs, - verification_refs=tuple(receipt.ref for receipt in verification_receipts), - delegation=delegation, - review_ref=review.ref if review is not None else None, - ) - - @staticmethod - def _in_scope(path: str, scopes: Sequence[str]) -> bool: - return any(path == scope.rstrip("/") or path.startswith(scope.rstrip("/") + "/") for scope in scopes) diff --git a/pkgs/sinnixd/sinnixd/service.py b/pkgs/sinnixd/sinnixd/service.py index fe5c1b44..0c32aca7 100644 --- a/pkgs/sinnixd/sinnixd/service.py +++ b/pkgs/sinnixd/sinnixd/service.py @@ -21,15 +21,6 @@ from .contracts import TypedJobContracts from .delivery import DeliveryError, GitHubDelivery from .owner_adapters import DeclaredOwnerAdapters, OwnerAdapterError -from .packet_completion import ( - DelegationCapability, - EvidenceReceipt, - IndependentReviewReceipt, - PacketCompletionInspector, - PacketContract, - VerificationReceipt, - WorkerDeliveryRecord, -) from .projects import ProjectCatalog from .tasks import TaskError, TaskService from .workspaces import GitWorkspaces, WorkspaceError, WorkspaceStore @@ -60,7 +51,6 @@ class SinnixdService: workspaces: GitWorkspaces | None = None delivery: GitHubDelivery | None = None tasks: TaskService | None = None - packet_completion: PacketCompletionInspector = field(default_factory=PacketCompletionInspector) def __post_init__(self) -> None: if self.workspaces is None: @@ -474,45 +464,6 @@ def _dispatch( if not isinstance(max_bytes, int) or isinstance(max_bytes, bool): raise ValueError("job.result max_bytes must be an integer") return self.jobs.result(job_id, max_bytes=max_bytes) - if operation == "job.packet-completion": - if principal not in {"agent-control", "operator"}: - raise ValueError("job.packet-completion requires agent-control or operator") - required = { - "job_id", "workspace_id", "contract", "worker_result", "verification_receipts", - "delegation", "evidence_receipts", "review", - } - if set(arguments) != required: - raise ValueError( - "job.packet-completion requires job_id, workspace_id, contract, worker_result, " - "verification_receipts, delegation, evidence_receipts, and review" - ) - job_id = self._authorize_job(principal, self._job_argument(arguments, "job_id")) - workspace_id = self._job_argument(arguments, "workspace_id") - contract = PacketContract.from_mapping(arguments["contract"]) - if contract.job_id != job_id or contract.workspace_id != workspace_id: - raise ValueError("job.packet-completion contract binding does not match arguments") - raw_worker = arguments["worker_result"] - worker = None if raw_worker is None else WorkerDeliveryRecord.from_mapping(raw_worker) - raw_verifications = arguments["verification_receipts"] - raw_evidence = arguments["evidence_receipts"] - if not isinstance(raw_verifications, list) or not isinstance(raw_evidence, list): - raise ValueError("job.packet-completion receipts must be lists") - verifications = tuple(VerificationReceipt.from_mapping(item) for item in raw_verifications) - evidence = tuple(EvidenceReceipt.from_mapping(item) for item in raw_evidence) - raw_review = arguments["review"] - review = None if raw_review is None else IndependentReviewReceipt.from_mapping(raw_review) - delegation = DelegationCapability.from_mapping(arguments["delegation"]) - assert self.workspaces is not None - return self.packet_completion.inspect( - job=self.jobs.get(job_id), - workspace=self.workspaces.get(workspace_id), - contract=contract, - worker_result=worker, - verification_receipts=verifications, - evidence_receipts=evidence, - delegation=delegation, - review=review, - ).to_dict() if operation == "job.cancel": return self._cleanup_terminal( self.jobs.cancel( diff --git a/pkgs/sinnixd/sinnixd/workspaces.py b/pkgs/sinnixd/sinnixd/workspaces.py index cbfd6cf6..1d7466e8 100644 --- a/pkgs/sinnixd/sinnixd/workspaces.py +++ b/pkgs/sinnixd/sinnixd/workspaces.py @@ -13,7 +13,7 @@ from dataclasses import dataclass from datetime import UTC, datetime from pathlib import Path -from typing import Any, Mapping +from typing import Any, Mapping, Sequence from uuid import uuid4 from sinnix_lib.atomic_json import modify_json, read_json, write_json_atomic @@ -337,6 +337,22 @@ def get(self, workspace_id: str) -> dict[str, Any]: record = self._record(workspace_id) return self._status(record) + def delivery_snapshot(self, workspace_id: str, start_head: str, *, scope: Sequence[str] = ()) -> dict[str, Any]: + """Read one exact-head Git fact set for a delivery precondition.""" + record = self._record(workspace_id) + checkout, _project = self._available(record) + before = self._git(checkout.path, "rev-parse", "HEAD").stdout.strip() + if before != checkout.head: + raise WorkspaceError("workspace HEAD changed during delivery snapshot") + descendant = self._git(checkout.path, "merge-base", "--is-ancestor", start_head, before, check=False).returncode == 0 + changes = self._name_status(checkout.path, start_head, before) + dirty = self._porcelain_status(checkout.path) + after = self._git(checkout.path, "rev-parse", "HEAD").stdout.strip() + if after != before: + raise WorkspaceError("workspace HEAD changed during delivery snapshot") + paths = tuple(path for change in changes for path in change["paths"]) + return {"workspace_id": workspace_id, "checkout_id": checkout.checkout_id, "start_head": start_head, "head": before, "descendant": descendant, "dirty": bool(dirty), "status": dirty, "changes": changes, "in_scope": all(self._scope_contains(path, scope) for path in paths) if scope else True} + def checkout(self, workspace_id: str) -> RegisteredCheckout: record = self._record(workspace_id) checkout, _project = self._available(record) @@ -776,6 +792,67 @@ def _status(self, record: WorkspaceRecord) -> dict[str, Any]: row.update({"state": "missing", "checkout_id": None, "head": None, "current_branch": None, "dirty": None, "identity_matches": False}) return row + @classmethod + def _porcelain_status(cls, path: Path) -> list[dict[str, Any]]: + raw = cls._git_bytes(path, "status", "--porcelain=v1", "-z", "--untracked-files=all") + records = [item for item in raw.split(b"\0") if item] + result: list[dict[str, Any]] = [] + index = 0 + while index < len(records): + entry = records[index] + if len(entry) < 4 or entry[2:3] != b" ": + raise WorkspaceError("Git status porcelain is malformed") + status = entry[:2].decode("ascii", errors="strict") + paths = [cls._decode_git_path(entry[3:])] + index += 1 + if "R" in status or "C" in status: + if index == len(records): + raise WorkspaceError("Git status rename porcelain is malformed") + paths.append(cls._decode_git_path(records[index])) + index += 1 + result.append({"status": status, "paths": paths}) + return result + + @classmethod + def _name_status(cls, path: Path, start_head: str, head: str) -> list[dict[str, Any]]: + raw = cls._git_bytes(path, "diff", "--name-status", "-z", "--find-renames", start_head, head, "--") + records = [item for item in raw.split(b"\0") if item] + result: list[dict[str, Any]] = [] + index = 0 + while index < len(records): + status = records[index].decode("ascii", errors="strict") + if not status or status[0] not in "ACDMRTUXB": + raise WorkspaceError("Git diff name-status porcelain is malformed") + index += 1 + count = 2 if status[0] in {"R", "C"} else 1 + if len(records) - index < count: + raise WorkspaceError("Git diff rename porcelain is malformed") + result.append({"status": status, "paths": [cls._decode_git_path(item) for item in records[index:index + count]]}) + index += count + return result + + @staticmethod + def _decode_git_path(value: bytes) -> str: + try: + path = value.decode() + except UnicodeDecodeError as error: + raise WorkspaceError("Git path is not UTF-8") from error + if not path or Path(path).is_absolute() or ".." in Path(path).parts: + raise WorkspaceError("Git path is unsafe") + return path + + @staticmethod + def _scope_contains(path: str, scope: Sequence[str]) -> bool: + for entry in scope: + if not isinstance(entry, str) or not entry or entry.startswith("/") or ".." in Path(entry).parts: + raise WorkspaceError("delivery scope is unsafe") + if entry.endswith("/"): + if path.startswith(entry): + return True + elif path == entry: + return True + return False + def _record(self, workspace_id: str) -> WorkspaceRecord: for record in self.store.records(): if record.workspace_id == workspace_id: diff --git a/pkgs/sinnixd/test_packet_completion.py b/pkgs/sinnixd/test_packet_completion.py deleted file mode 100644 index 750a5754..00000000 --- a/pkgs/sinnixd/test_packet_completion.py +++ /dev/null @@ -1,306 +0,0 @@ -from __future__ import annotations - -import subprocess -from dataclasses import replace -from pathlib import Path - -from sinnixd.packet_completion import ( - DelegationCapability, - EvidenceReceipt, - GitCompletionEvidence, - IndependentReviewReceipt, - PacketCompletionInspector, - PacketContract, - VerificationReceipt, - WorkerDeliveryRecord, -) - - -START = "1" * 40 -FINAL = "2" * 40 -OTHER = "3" * 40 - - -def git_evidence(**overrides: object) -> GitCompletionEvidence: - values = { - "start_head": START, - "final_head": FINAL, - "is_descendant": True, - "commits": (FINAL,), - "changed_paths": ("src/changed.py",), - "working_tree_paths": (), - "untracked_paths": (), - } - return GitCompletionEvidence(**{**values, **overrides}) - - -def delivery(**overrides: object) -> WorkerDeliveryRecord: - values = { - "result_ref": "sinnix://jobs/job-1/artifacts/result", - "last_message": "", - "commands": ({"argv": ["devtools", "test", "affected"], "result": "passed"},), - "anti_vacuity": { - "checked": True, - "mutation": "replace_inspector_with_process_exit", - "passed": True, - "evidence_ref": "sinnix://evidence/anti-vacuity-1", - }, - "unresolved_items": (), - "deletion_ledger": ({"path": "retired.py", "action": "retained"},), - } - return WorkerDeliveryRecord(**{**values, **overrides}) - - -def verification(*, ref: str = "receipt-1", head: str = FINAL, passed: bool = True) -> VerificationReceipt: - return VerificationReceipt(ref=ref, operation="verify", head=head, passed=passed, immutable=True) - - -def base_contract(**overrides: object) -> PacketContract: - values = { - "job_id": "job-1", - "workspace_id": "workspace-1", - "write_scope": ("src/",), - "required_verification_refs": ("receipt-1",), - } - return PacketContract(**{**values, **overrides}) - - -def inspect(**overrides: object): - values = { - "job": { - "job_id": "job-1", - "state": {"phase": "succeeded", "terminal": True}, - "checkout": {"checkout_id": "workspace-1", "head": START}, - }, - "workspace": { - "workspace_id": "workspace-1", - "checkout_id": "workspace-1", - "state": "available", - "identity_matches": True, - "head": FINAL, - "dirty": False, - }, - "git": git_evidence(), - "contract": base_contract(), - "worker_result": delivery(), - "verification_receipts": (verification(),), - "delegation": DelegationCapability(visibility="supported", pending=False), - } - return PacketCompletionInspector().inspect(**{**values, **overrides}) - - -def test_clean_committed_exact_head_delivery_is_complete() -> None: - result = inspect() - - assert result.complete - assert result.reasons == () - assert result.job_id == "job-1" - assert result.workspace_id == "workspace-1" - assert result.start_head == START - assert result.final_head == FINAL - assert result.commits == (FINAL,) - assert result.changed_paths == ("src/changed.py",) - - -def test_observed_failed_packet_shape_is_rejected_without_prose_matching() -> None: - result = inspect( - job={ - "job_id": "job-1", - "state": {"phase": "succeeded", "terminal": True}, - "checkout": {"checkout_id": "workspace-1", "head": START}, - }, - git=git_evidence( - final_head=START, - commits=(), - changed_paths=(), - working_tree_paths=("scratch.txt",), - untracked_paths=("scratch.txt",), - ), - worker_result=None, - delegation=DelegationCapability(visibility="supported", pending=True), - ) - - assert not result.complete - assert { - "workspace_dirty", - "untracked_work", - "no_commit", - "worker_result_missing", - "delegated_work_pending", - } <= set(result.reasons) - - -def test_dirty_workspace_is_rejected_even_when_process_succeeded() -> None: - result = inspect(git=git_evidence(working_tree_paths=("src/changed.py",))) - - assert not result.complete - assert "workspace_dirty" in result.reasons - - -def test_stale_receipt_is_distinct_from_missing_receipt() -> None: - stale = inspect(verification_receipts=(verification(head=START),)) - missing = inspect(verification_receipts=()) - - assert "verification_stale" in stale.reasons - assert "verification_missing" in missing.reasons - - -def test_out_of_scope_paths_are_rejected() -> None: - result = inspect(git=git_evidence(changed_paths=("docs/outside.md",))) - - assert not result.complete - assert "out_of_scope_path" in result.reasons - - -def test_divergent_head_is_rejected() -> None: - result = inspect(git=git_evidence(final_head=OTHER, is_descendant=False)) - - assert not result.complete - assert "divergent_head" in result.reasons - - -def test_evidence_only_requires_explicit_contract_and_immutable_evidence() -> None: - evidence = EvidenceReceipt(ref="evidence-1", head=START, passed=True, immutable=True) - result = inspect( - git=git_evidence(final_head=START, commits=(), changed_paths=()), - contract=base_contract( - required_verification_refs=(), - allow_evidence_only=True, - evidence_only_refs=("evidence-1",), - ), - workspace={ - "workspace_id": "workspace-1", - "checkout_id": "workspace-1", - "state": "available", - "identity_matches": True, - "head": START, - "dirty": False, - }, - evidence_receipts=(evidence,), - ) - - assert result.complete - - accidental = inspect( - git=git_evidence(final_head=START, commits=(), changed_paths=()), - contract=base_contract(required_verification_refs=()), - ) - assert not accidental.complete - assert "evidence_only_not_authorized" in accidental.reasons - - -def test_missing_required_review_and_rejected_review_are_structural() -> None: - missing = inspect(contract=base_contract(require_independent_review=True)) - rejected = inspect( - contract=base_contract(require_independent_review=True), - review=IndependentReviewReceipt(ref="review-1", head=FINAL, passed=False, immutable=True), - ) - - assert "review_missing" in missing.reasons - assert "review_rejected" in rejected.reasons - - accepted = inspect( - contract=base_contract(require_independent_review=True), - review=IndependentReviewReceipt(ref="review-1", head=FINAL, passed=True, immutable=True), - ) - assert accepted.complete - - -def test_timeout_and_result_loss_are_not_completion() -> None: - timed_out = inspect( - job={"job_id": "job-1", "state": {"phase": "timed_out", "terminal": True}, "checkout": {"checkout_id": "workspace-1", "head": START}} - ) - lost = inspect(worker_result=None) - - assert "job_timeout" in timed_out.reasons - assert "worker_result_missing" in lost.reasons - - artifact_lost = inspect( - job={ - "job_id": "job-1", - "state": {"phase": "succeeded", "terminal": True}, - "checkout": {"checkout_id": "workspace-1", "head": START}, - "artifacts": {"result": None}, - } - ) - recovered = inspect( - job={ - "job_id": "job-1", - "state": {"phase": "succeeded", "terminal": True}, - "checkout": {"checkout_id": "workspace-1", "head": START}, - "artifacts": {"result": {"ref": "sinnix://jobs/job-1/artifacts/result"}}, - } - ) - assert "job_result_loss" in artifact_lost.reasons - assert recovered.complete - - -def test_required_deletion_ledger_cannot_be_omitted() -> None: - result = inspect(worker_result=replace(delivery(), deletion_ledger=None)) - - assert not result.complete - assert "deletion_ledger_missing" in result.reasons - - -def test_unsupported_delegation_visibility_is_explicit_but_not_prose_inferred() -> None: - result = inspect( - delegation=DelegationCapability(visibility="unsupported", pending=None), - worker_result=replace(delivery(), last_message="waiting for a background task"), - ) - - assert result.complete - assert result.delegation.visibility == "unsupported" - - -def test_pending_delegation_is_consumed_from_structured_capability() -> None: - result = inspect( - delegation=DelegationCapability(visibility="supported", pending=True), - worker_result=replace(delivery(), last_message="completed despite waiting in the text"), - ) - - assert not result.complete - assert "delegated_work_pending" in result.reasons - - -def test_disposable_real_git_success_and_failed_packet_pair(tmp_path: Path) -> None: - subprocess.run(["git", "init", "--quiet", str(tmp_path)], check=True) - subprocess.run(["git", "-C", str(tmp_path), "config", "user.name", "Fixture"], check=True) - subprocess.run(["git", "-C", str(tmp_path), "config", "user.email", "fixture@example.test"], check=True) - subprocess.run(["git", "-C", str(tmp_path), "commit", "--quiet", "--allow-empty", "-m", "base"], check=True) - start = subprocess.run( - ["git", "-C", str(tmp_path), "rev-parse", "HEAD"], check=True, capture_output=True, text=True - ).stdout.strip() - (tmp_path / "src").mkdir() - (tmp_path / "src" / "changed.py").write_text("pass\n") - subprocess.run(["git", "-C", str(tmp_path), "add", "src/changed.py"], check=True) - subprocess.run(["git", "-C", str(tmp_path), "commit", "--quiet", "-m", "change"], check=True) - final = subprocess.run( - ["git", "-C", str(tmp_path), "rev-parse", "HEAD"], check=True, capture_output=True, text=True - ).stdout.strip() - common = { - "job": { - "job_id": "job-1", - "state": {"phase": "succeeded", "terminal": True}, - "checkout": {"checkout_id": "checkout-1", "head": start}, - }, - "workspace": { - "workspace_id": "workspace-1", - "checkout_id": "checkout-1", - "path": str(tmp_path), - "state": "available", - "identity_matches": True, - "head": final, - "dirty": False, - }, - "contract": base_contract(), - "worker_result": delivery(), - "verification_receipts": (verification(head=final),), - "delegation": DelegationCapability(visibility="supported", pending=False), - } - assert PacketCompletionInspector().inspect(**common).complete - - (tmp_path / "untracked.txt").write_text("unfinished\n") - failed = PacketCompletionInspector().inspect(**common) - assert not failed.complete - assert "workspace_dirty" in failed.reasons - assert "untracked_work" in failed.reasons diff --git a/pkgs/sinnixd/test_service.py b/pkgs/sinnixd/test_service.py index 17947ceb..2b72d469 100644 --- a/pkgs/sinnixd/test_service.py +++ b/pkgs/sinnixd/test_service.py @@ -4463,7 +4463,7 @@ def test_declared_job_binds_workspace_and_exact_head(tmp_path: Path) -> None: assert record.spec.checkout["head"] == workspace["head"] -def test_packet_completion_dispatch_composes_job_and_workspace_bindings(tmp_path: Path) -> None: +def test_forged_packet_completion_arguments_have_no_service_route(tmp_path: Path) -> None: write_adapter(tmp_path) initialize_git_checkout(tmp_path) jobs = generic_jobs(tmp_path) @@ -4503,9 +4503,31 @@ def test_packet_completion_dispatch_composes_job_and_workspace_bindings(tmp_path ) ) - assert response.ok and response.payload is not None - assert not response.payload.inline["complete"] - assert "worker_result_missing" in response.payload.inline["reasons"] + assert response.error is not None + assert response.error.code.value == "INVALID_ARGUMENT" + + +def test_delivery_snapshot_is_nul_safe_and_exact_file_scope_does_not_include_descendants(tmp_path: Path) -> None: + write_adapter(tmp_path) + service = SinnixdService(ProjectCatalog([tmp_path]), jobs=generic_jobs(tmp_path)) + workspace = service.workspaces.create(project_id="fixture", name="snapshot-lane", branch="feature/snapshot", base="HEAD") + path = Path(workspace["path"]) + (path / "dir").mkdir() + (path / "dir" / "exact").write_text("old\n") + (path / "dir" / "delete\nfile").write_text("delete\n") + subprocess.run(["git", "-C", str(path), "add", "."], check=True) + subprocess.run(["git", "-C", str(path), "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "--quiet", "-m", "seed"], check=True) + start = service.workspaces.get(workspace["workspace_id"])["head"] + subprocess.run(["git", "-C", str(path), "mv", "dir/exact", "dir/renamed\nfile"], check=True) + (path / "dir" / "delete\nfile").unlink() + (path / "dir" / "exact.child").write_text("outside exact-file scope\n") + subprocess.run(["git", "-C", str(path), "add", "-A"], check=True) + subprocess.run(["git", "-C", str(path), "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "--quiet", "-m", "paths"], check=True) + snapshot = service.workspaces.delivery_snapshot(workspace["workspace_id"], start, scope=("dir/exact",)) + assert not snapshot["in_scope"] + assert {change["status"][0] for change in snapshot["changes"]} >= {"D", "R", "A"} + assert any("\n" in item for change in snapshot["changes"] for item in change["paths"]) + assert service.workspaces.delivery_snapshot(workspace["workspace_id"], start, scope=("dir/",))["in_scope"] def test_admission_revalidates_queued_declared_workspace_before_systemd_launch(tmp_path: Path) -> None: From 2a2f30ea75be5f154057be2a68ff2e7a9ef89eeb Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 25 Aug 2026 05:35:10 +0200 Subject: [PATCH 13/35] Make packet delivery runtime-authoritative --- docs/sinnixd.md | 2 +- .../sinnix_agent_gateway/runtime.py | 9 + .../test_execution_jobs.py | 3 +- pkgs/sinnixd/sinnixd/cli.py | 26 ++- pkgs/sinnixd/sinnixd/contracts.py | 12 +- pkgs/sinnixd/sinnixd/delivery.py | 140 ++++++++--- pkgs/sinnixd/sinnixd/jobs.py | 6 +- pkgs/sinnixd/sinnixd/runner.py | 49 +++- pkgs/sinnixd/sinnixd/service.py | 32 ++- pkgs/sinnixd/test_service.py | 217 +++++++++++++++++- 10 files changed, 445 insertions(+), 51 deletions(-) diff --git a/docs/sinnixd.md b/docs/sinnixd.md index 74635395..2ca7dc5b 100644 --- a/docs/sinnixd.md +++ b/docs/sinnixd.md @@ -223,7 +223,7 @@ If any verification or cutover command fails, leave Sinnixd stopped. Before the Both routes use the same UUID job ID, transient user service, cancellation, reconciliation, `job get/list/logs/result/wait`, and bounded artifact readers as declared operations. Their durable public record contains the principal, job kind, canonical project and checkout identity, redacted argv digest or prompt digest, and bounded artifact references. It never stores raw shell argv arguments after launch, prompt text, environment values, or credentials. -Delivery is a precondition of `workspace.publish` and `workspace.land`, not a caller-fed completion route. It reads the declared verification job through `job.result`, snapshots the bound workspace from Git at the job's exact launch head, and repeats that precondition after push and after review inspection. A JSON or pytest result may carry the bounded `delivery` object with only anti-vacuity, unresolved-work, delegation-visibility, deletion-evidence, and evidence-only fields. The packet write scope comes from the immutable Beads binding, never that result. Git owns paths, dirtiness, commits, and heads; the project verifier owns its result artifact; GitHub owns independent review state. A missing Beads write scope means a structured packet cannot be delivered. Beads closure consumes the returned completion artifact reference in its own owner; wiring that external closure consumer is not implemented by Sinnixd. +Delivery is a precondition of `workspace.publish` and `workspace.land`, not a caller-fed completion route. Ordinary delivery reads the exact-head declared verification job through `job.result`. Packet delivery additionally names the Beads-bound attested-agent job with `--packet-job`. The declared verification job receives the same immutable Beads binding at dispatch, including its initial head, bead identity, and write scope; the contract runner seals the worker's structured report to the Git head observed when the runner exits. Delivery requires the bindings to match, the later semantic verifier to succeed at that same final head, and snapshots the initial-to-final Git range. It rejects dirty, divergent, stale, or out-of-scope work and repeats the complete precondition after push and after review inspection. The worker report can only tighten acceptance through bounded anti-vacuity, unresolved-work, delegation-visibility, deletion-evidence, and evidence-only fields. Git owns paths, commits, and heads; the project verifier owns semantic success; GitHub owns independent review state. Beads closure consumes the returned completion artifact and bead references in its own owner; wiring that external closure consumer is not implemented by Sinnixd. Typed jobs accept no environment overlay. The daemon creates the `env -i` environment from the declared project environment and fixed `SINNIXD_*` identity fields. Immediately before execution, the contract runner verifies those fields, rechecks the exact registered project, canonical worktree root, common Git directory, porcelain worktree membership, and recorded HEAD. A changed, missing, symlinked, or spoofed identity fails closed. Agent handoff includes `--registered-project`, `--expected-git-common-dir`, and the canonical checkout path; nested scope creation remains disabled, so the native runner provides backend execution and native attestation while the shared transient user service remains the sole process, cgroup, timeout, and cancellation authority. Private launch inputs are mode 0600, removed before shell execution, and removed after agent handoff or every terminal lifecycle outcome, including confirmed launch failure. Native private logs are removed after handoff; only the bounded shared log and result artifacts remain addressable. diff --git a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/runtime.py b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/runtime.py index 8fb00439..8069c2bb 100644 --- a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/runtime.py +++ b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/runtime.py @@ -875,6 +875,15 @@ def v2_run_for_bead( "request_id": request_id, "assignment_ref": parent_assignment_ref, } + metadata = bead.get("metadata") + encoded_scope = metadata.get("write_scope") if isinstance(metadata, Mapping) else None + if isinstance(encoded_scope, str): + try: + write_scope = json.loads(encoded_scope) + except json.JSONDecodeError: + write_scope = None + if isinstance(write_scope, list): + binding["write_scope"] = write_scope assigned_context = { "bead": bead, "project_ref": project_ref, diff --git a/pkgs/sinnix-agent-gateway/test_execution_jobs.py b/pkgs/sinnix-agent-gateway/test_execution_jobs.py index a2463b7b..7cd57b99 100644 --- a/pkgs/sinnix-agent-gateway/test_execution_jobs.py +++ b/pkgs/sinnix-agent-gateway/test_execution_jobs.py @@ -610,7 +610,7 @@ def test_agent_control_bead_scope_requires_matching_current_assignment( runtime, daemon = runtime_with_daemon(tmp_path, "agent-control") assignment_id = "3b0237a0-32a9-4f6b-a014-2a0ecfd2f75c" assignment_ref = f"sinnix://jobs/{assignment_id}" - bead = {"ref": "sinnix://projects/fixture/beads/fixture-1", "task_revision": "a" * 64, "etag": "b" * 64, "fields": {"title": "assigned"}} + bead = {"ref": "sinnix://projects/fixture/beads/fixture-1", "task_revision": "a" * 64, "etag": "b" * 64, "fields": {"title": "assigned"}, "metadata": {"write_scope": '["pkgs/sinnixd/"]'}} binding = {"bead_ref": bead["ref"], "project_ref": "sinnix://projects/fixture", "checkout_ref": "sinnix://projects/fixture/checkouts/default", "task_revision": "a" * 64, "task_etag": "b" * 64, "claim_ref": None, "claim_receipt": None, "request_id": "2e46daf5-e9b1-4c6e-b99d-bcd46631730b", "assignment_ref": None} daemon.responses["job.get"] = {"job_id": assignment_id, "principal": "agent-control", "state": {"phase": "running"}, "checkout": {"checkout_id": "default", "head": "c" * 40}, "contract": {"bead_binding": binding}, "artifacts": {"result": None}} daemon.responses["job.agent.start"] = {"job_id": "4a42f848-9057-4cef-9d27-80a022c0e16f", "state": {"phase": "running"}} @@ -630,6 +630,7 @@ def test_agent_control_bead_scope_requires_matching_current_assignment( assert started["assignment_ref"] == assignment_ref assert daemon.calls[-1].principal == "agent-control" assert daemon.calls[-1].arguments["bead_binding"]["assignment_ref"] == assignment_ref + assert daemon.calls[-1].arguments["bead_binding"]["write_scope"] == ["pkgs/sinnixd/"] assert "private launch instruction" not in daemon.calls[-1].arguments["bead_binding"].values() foreign = {**binding, "bead_ref": "sinnix://projects/fixture/beads/fixture-2"} diff --git a/pkgs/sinnixd/sinnixd/cli.py b/pkgs/sinnixd/sinnixd/cli.py index a11a605f..5b3517e3 100644 --- a/pkgs/sinnixd/sinnixd/cli.py +++ b/pkgs/sinnixd/sinnixd/cli.py @@ -89,6 +89,7 @@ def parser() -> argparse.ArgumentParser: workspace_publish = workspace_subcommands.add_parser("publish") workspace_publish.add_argument("workspace_id") workspace_publish.add_argument("--job", required=True) + workspace_publish.add_argument("--packet-job") workspace_publish.add_argument("--title", required=True) workspace_publish.add_argument("--body", default="") workspace_review = workspace_subcommands.add_parser("review-status") @@ -96,6 +97,7 @@ def parser() -> argparse.ArgumentParser: workspace_land = workspace_subcommands.add_parser("land") workspace_land.add_argument("workspace_id") workspace_land.add_argument("--job", required=True) + workspace_land.add_argument("--packet-job") workspace_finish = workspace_subcommands.add_parser("finish") workspace_finish.add_argument("workspace_id") workspace_finish_integrated = workspace_subcommands.add_parser("finish-integrated") @@ -108,6 +110,7 @@ def parser() -> argparse.ArgumentParser: start.add_argument("operation") start.add_argument("--workspace") start.add_argument("--parameters-json", default="{}") + start.add_argument("--bead-binding-json") get = job_subcommands.add_parser("get") get.add_argument("job_id") status = job_subcommands.add_parser("status") @@ -366,7 +369,13 @@ def main() -> int: elif arguments.command == "workspace" and arguments.workspace_command == "publish": request = _request( "workspace.publish", "git-workspaces", - {"workspace_id": arguments.workspace_id, "job_id": arguments.job, "title": arguments.title, "body": arguments.body}, + { + "workspace_id": arguments.workspace_id, + "job_id": arguments.job, + "title": arguments.title, + "body": arguments.body, + **({"packet_job_id": arguments.packet_job} if arguments.packet_job else {}), + }, "agent-control", ) elif arguments.command == "workspace" and arguments.workspace_command == "review-status": @@ -374,7 +383,11 @@ def main() -> int: elif arguments.command == "workspace" and arguments.workspace_command == "land": request = _request( "workspace.land", "git-workspaces", - {"workspace_id": arguments.workspace_id, "job_id": arguments.job}, "agent-control", + { + "workspace_id": arguments.workspace_id, + "job_id": arguments.job, + **({"packet_job_id": arguments.packet_job} if arguments.packet_job else {}), + }, "agent-control", ) elif arguments.command == "workspace" and arguments.workspace_command == "finish-integrated": request = _request( @@ -394,6 +407,14 @@ def main() -> int: parser().error(f"--parameters-json must be valid JSON: {error.msg}") if not isinstance(parameters, dict): parser().error("--parameters-json must be a JSON object") + binding = None + if arguments.bead_binding_json is not None: + try: + binding = json.loads(arguments.bead_binding_json) + except json.JSONDecodeError as error: + parser().error(f"--bead-binding-json must be valid JSON: {error.msg}") + if not isinstance(binding, dict): + parser().error("--bead-binding-json must be a JSON object") request = _request( "job.start", "systemd-jobs", @@ -402,6 +423,7 @@ def main() -> int: "operation": arguments.operation, "workspace_id": arguments.workspace, "parameters": parameters, + **({"bead_binding": binding} if binding is not None else {}), }, ) elif arguments.command == "job" and arguments.job_command in {"get", "status"}: diff --git a/pkgs/sinnixd/sinnixd/contracts.py b/pkgs/sinnixd/sinnixd/contracts.py index 60375f96..7259681f 100644 --- a/pkgs/sinnixd/sinnixd/contracts.py +++ b/pkgs/sinnixd/sinnixd/contracts.py @@ -134,7 +134,7 @@ def start_agent( if not self.native_runner.is_file() or not os.access(self.native_runner, os.X_OK): raise ContractError("native agent runner is unavailable") checkout = self.projects.checkout(project_id, checkout_id) - binding = self._bead_binding(bead_binding, checkout) + binding = self.bead_binding(bead_binding, checkout) job_id = str(uuid4()) prompt_path = self.inputs_root / f"{job_id}.prompt" public_contract = { @@ -240,10 +240,10 @@ def _start( return response @staticmethod - def _bead_binding( + def bead_binding( value: Mapping[str, Any] | None, checkout: RegisteredCheckout ) -> dict[str, Any] | None: - """Validate public Beads provenance carried by an attested agent job.""" + """Validate public Beads provenance frozen into a packet job contract.""" if value is None: return None expected = { @@ -258,9 +258,11 @@ def _bead_binding( if scope is not None and ( not isinstance(scope, list) or not scope + or len(scope) > 128 or any( not isinstance(path, str) or not path + or len(path.encode()) > 1024 or path.startswith("/") or ".." in Path(path).parts for path in scope @@ -307,7 +309,9 @@ def _bead_binding( UUID(str(binding["request_id"])) except (TypeError, ValueError, AttributeError) as error: raise ContractError("agent bead binding request_id is malformed") from error - return binding + # The caller retains its request object. Persist an independent JSON value so + # neither it nor a nested claim receipt can mutate a launched job's binding. + return json.loads(json.dumps(binding, sort_keys=True, separators=(",", ":"))) def _environment( self, checkout: RegisteredCheckout, job_id: str, principal: str, timeout_seconds: int diff --git a/pkgs/sinnixd/sinnixd/delivery.py b/pkgs/sinnixd/sinnixd/delivery.py index 3980944b..23ab639e 100644 --- a/pkgs/sinnixd/sinnixd/delivery.py +++ b/pkgs/sinnixd/sinnixd/delivery.py @@ -4,6 +4,7 @@ import subprocess import time from dataclasses import dataclass +from pathlib import Path from typing import Any, Callable, Mapping, Sequence from .jobs import GenericJobs @@ -25,15 +26,17 @@ class GitHubDelivery: jobs: GenericJobs run: Run = subprocess.run - def publish(self, workspace_id: str, job_id: str, title: str, body: str) -> dict[str, Any]: - workspace, project, receipt = self._verified_workspace(workspace_id, job_id) + def publish( + self, workspace_id: str, job_id: str, title: str, body: str, packet_job_id: str | None = None + ) -> dict[str, Any]: + workspace, project, receipt = self._verified_workspace(workspace_id, job_id, packet_job_id) if not title.strip() or len(title) > 256 or len(body.encode()) > 64_000: raise DeliveryError("review title or body exceeds its publication bounds") base = self._base_branch(project.workspace.default_base) path = workspace["path"] branch = workspace["branch"] self._command([*project.environment.command, "git", "-C", path, "push", "-u", "origin", branch], cwd=path) - workspace, project, receipt = self._verified_workspace(workspace_id, job_id) + workspace, project, receipt = self._verified_workspace(workspace_id, job_id, packet_job_id) existing = self.run( ["gh", "pr", "view", branch, "--json", "url"], cwd=path, capture_output=True, text=True, timeout=60, check=False, @@ -82,8 +85,8 @@ def review_status(self, workspace_id: str) -> dict[str, Any]: raise DeliveryError("GitHub review head does not match workspace HEAD") return {"workspace_id": workspace_id, "head": workspace["head"], "review": dict(review)} - def land(self, workspace_id: str, job_id: str) -> dict[str, Any]: - self._verified_workspace(workspace_id, job_id) + def land(self, workspace_id: str, job_id: str, packet_job_id: str | None = None) -> dict[str, Any]: + self._verified_workspace(workspace_id, job_id, packet_job_id) status = self.review_status(workspace_id) review = status["review"] if ( @@ -93,7 +96,7 @@ def land(self, workspace_id: str, job_id: str) -> dict[str, Any]: or not self._checks_pass(review["statusCheckRollup"]) ): raise DeliveryError("review is not in a landable GitHub state") - _workspace, _project, receipt = self._verified_workspace(workspace_id, job_id) + _workspace, _project, receipt = self._verified_workspace(workspace_id, job_id, packet_job_id) self._command(["gh", "pr", "merge", str(review["number"]), "--squash"], cwd=self.workspaces.get(workspace_id)["path"]) merged = self.review_status(workspace_id) if merged["review"]["state"] != "MERGED": @@ -108,7 +111,9 @@ def finish(self, workspace_id: str) -> dict[str, Any]: self._delete_remote_branch(workspace["path"], workspace["branch"]) return self.workspaces.finish_merged(workspace_id, status["head"]) - def _verified_workspace(self, workspace_id: str, job_id: str) -> tuple[dict[str, Any], Any, dict[str, Any]]: + def _verified_workspace( + self, workspace_id: str, job_id: str, packet_job_id: str | None = None + ) -> tuple[dict[str, Any], Any, dict[str, Any]]: workspace = self.workspaces.get(workspace_id) if workspace["state"] != "available" or not workspace["identity_matches"]: raise DeliveryError("publication requires an available clean identity-matching workspace") @@ -125,41 +130,101 @@ def _verified_workspace(self, workspace_id: str, job_id: str) -> tuple[dict[str, ): raise DeliveryError("workspace lacks successful declared verification at its exact HEAD") try: - result = self.jobs.result(job_id) - delivery_result = self._is_delivery_result(result) - scope = self._packet_scope(record.spec.contract) - snapshot = self.workspaces.delivery_snapshot(workspace_id, checkout["head"], scope=scope or ()) + self.jobs.result(job_id) + binding = self._binding(record, checkout, workspace) if packet_job_id is not None else None + packet = self._packet(packet_job_id, workspace, binding) if packet_job_id is not None else None + start_head = packet["start_head"] if packet is not None else checkout["head"] + scope = packet["scope"] if packet is not None else () + snapshot = self.workspaces.delivery_snapshot(workspace_id, start_head, scope=scope) + except DeliveryError: + raise except (ValueError, WorkspaceError) as error: raise DeliveryError("workspace lacks an authoritative exact-head completion receipt") from error if snapshot["head"] != checkout["head"] or not snapshot["descendant"] or snapshot["dirty"]: raise DeliveryError("workspace lacks successful declared verification at its exact HEAD") - if delivery_result and (scope is None or not snapshot["in_scope"]): + if packet is not None and (packet["final_head"] != snapshot["head"] or not snapshot["in_scope"]): raise DeliveryError("packet delivery is outside its Beads-owned write scope") - self._validate_delivery_result(result, snapshot) - artifact = result.get("artifact") if isinstance(result, Mapping) else None - return workspace, project, {"ref": artifact.get("ref") if isinstance(artifact, Mapping) else f"sinnix://jobs/{job_id}", "job_id": job_id, "workspace_id": workspace_id, "head": snapshot["head"], "verification_operation": record.spec.operation} + if packet is not None: + self._validate_delivery_result(packet["delivery"], snapshot) + return workspace, project, { + "ref": packet["artifact_ref"] if packet is not None else f"sinnix://jobs/{job_id}", + "job_id": job_id, + "packet_job_id": packet_job_id, + "bead_ref": packet["bead_ref"] if packet is not None else None, + "workspace_id": workspace_id, + "head": snapshot["head"], + "verification_operation": record.spec.operation, + } - @staticmethod - def _is_delivery_result(result: Mapping[str, Any]) -> bool: - value = result.get("value") - return result.get("kind") in {"json", "pytest"} and isinstance(value, Mapping) and "delivery" in value + def _binding( + self, record: Any, checkout: Mapping[str, Any], workspace: Mapping[str, Any] + ) -> Mapping[str, Any]: + binding = record.spec.contract.get("bead_binding") + scope = binding.get("write_scope") if isinstance(binding, Mapping) else None + if ( + not isinstance(binding, Mapping) + or not isinstance(scope, list) + or not scope + or len(scope) > 128 + or any( + not isinstance(path, str) + or not path + or len(path.encode()) > 1024 + or path.startswith("/") + or ".." in Path(path).parts + for path in scope + ) + or checkout.get("checkout_id") != workspace.get("checkout_id") + ): + raise DeliveryError("declared verification lacks an authoritative Beads packet binding") + return binding - @staticmethod - def _packet_scope(contract: Mapping[str, Any]) -> tuple[str, ...] | None: - binding = contract.get("bead_binding") - if not isinstance(binding, Mapping) or "write_scope" not in binding: - return None - scope = binding["write_scope"] - if not isinstance(scope, list) or not scope or any(not isinstance(path, str) for path in scope): - raise DeliveryError("Beads-owned write scope is malformed") - return tuple(scope) + def _packet( + self, job_id: str, workspace: Mapping[str, Any], binding: Mapping[str, Any] + ) -> dict[str, Any]: + job = self.jobs.get(job_id) + record = self.jobs.store.load(job_id) + checkout = record.spec.checkout + packet_binding = record.spec.contract.get("bead_binding") + result = self.jobs.result(job_id) + if ( + job["state"].get("phase") != "succeeded" + or record.spec.kind != "attested-agent" + or not isinstance(checkout, Mapping) + or checkout.get("checkout_id") != workspace.get("checkout_id") + or packet_binding != binding + or result.get("kind") != "last-message" + or result.get("truncated") is not False + or not isinstance(result.get("content"), str) + ): + raise DeliveryError("packet job lacks an authoritative Beads-bound result") + try: + envelope = json.loads(result["content"]) + except json.JSONDecodeError as error: + raise DeliveryError("packet job result is malformed") from error + if ( + not isinstance(envelope, Mapping) + or set(envelope) != {"schema_version", "job_id", "start_head", "final_head", "delivery"} + or envelope.get("schema_version") != 1 + or envelope.get("job_id") != job_id + or envelope.get("start_head") != checkout.get("head") + or not isinstance(envelope.get("final_head"), str) + or len(envelope["final_head"]) != 40 + or any(value not in "0123456789abcdef" for value in envelope["final_head"]) + ): + raise DeliveryError("packet job result identity is malformed") + artifact = result.get("artifact") + return { + "start_head": envelope["start_head"], + "final_head": envelope["final_head"], + "delivery": envelope["delivery"], + "scope": tuple(binding["write_scope"]), + "bead_ref": binding.get("bead_ref"), + "artifact_ref": artifact.get("ref") if isinstance(artifact, Mapping) else f"sinnix://jobs/{job_id}", + } @staticmethod - def _validate_delivery_result(result: Mapping[str, Any], snapshot: Mapping[str, Any]) -> None: - if not GitHubDelivery._is_delivery_result(result): - return - value = result.get("value") - delivery = value.get("delivery") if isinstance(value, Mapping) else None + def _validate_delivery_result(delivery: Any, snapshot: Mapping[str, Any]) -> None: if not isinstance(delivery, Mapping) or set(delivery) != {"anti_vacuity", "unresolved_work", "delegation", "deletion_evidence", "evidence_only"}: raise DeliveryError("project delivery result is malformed") unresolved, delegation, deletions = delivery["unresolved_work"], delivery["delegation"], delivery["deletion_evidence"] @@ -171,7 +236,14 @@ def _validate_delivery_result(result: Mapping[str, Any], snapshot: Mapping[str, changes = snapshot.get("changes") if not isinstance(changes, list): raise DeliveryError("workspace delivery snapshot is malformed") - if any(isinstance(change, Mapping) and str(change.get("status", ""))[:1] == "D" for change in changes) and not deletions: + deleted = { + path + for change in changes + if isinstance(change, Mapping) and str(change.get("status", ""))[:1] == "D" + for path in change.get("paths", []) + if isinstance(path, str) + } + if deleted and (any(not isinstance(path, str) for path in deletions) or not deleted <= set(deletions)): raise DeliveryError("project delivery result omits deletion evidence") if not changes and not delivery["evidence_only"]: raise DeliveryError("no-change delivery lacks the evidence-only exception") diff --git a/pkgs/sinnixd/sinnixd/jobs.py b/pkgs/sinnixd/sinnixd/jobs.py index c49cef91..a6029133 100644 --- a/pkgs/sinnixd/sinnixd/jobs.py +++ b/pkgs/sinnixd/sinnixd/jobs.py @@ -1735,6 +1735,7 @@ def start_declared( parameters: Mapping[str, Any], checkout: RegisteredCheckout | None = None, principal: str = "operator", + contract: Mapping[str, Any] | None = None, ) -> dict[str, Any]: if principal not in {"agent-control", "operator"}: raise ValueError("declared operations require agent-control or operator principal") @@ -1742,7 +1743,7 @@ def start_declared( raise ValueError("declared job checkout belongs to another project") with self._admission_lock: return self._start_declared_locked( - project, operation, correlation_id, principal, parameters, checkout, () + project, operation, correlation_id, principal, parameters, checkout, (), contract or {} ) def _start_declared_locked( @@ -1754,6 +1755,7 @@ def _start_declared_locked( parameters: Mapping[str, Any], checkout: RegisteredCheckout | None, lineage: tuple[str, ...], + contract: Mapping[str, Any], ) -> dict[str, Any]: if operation.name in lineage: raise ValueError("declared operation dependency cycle") @@ -1766,6 +1768,7 @@ def _start_declared_locked( {}, checkout, (*lineage, operation.name), + {}, ) for name in operation.dependencies ) @@ -1847,6 +1850,7 @@ def build_spec(lease: ServiceLease | None) -> GenericJobSpec: principal=principal, timeout_seconds=operation.timeout_seconds, checkout=checkout.to_dict() if checkout is not None else None, + contract=dict(contract), result_kind={"exit": "exit-status", "json": "json", "pytest": "pytest"}[operation.result], pool=operation.pool, exclusive_keys=operation.exclusive_keys, dependency_job_ids=dependency_ids, cache_key=cache_key, estimate_key=estimate_key, estimate_memory_bytes=estimate, diff --git a/pkgs/sinnixd/sinnixd/runner.py b/pkgs/sinnixd/sinnixd/runner.py index ab27bec5..f53f513e 100644 --- a/pkgs/sinnixd/sinnixd/runner.py +++ b/pkgs/sinnixd/sinnixd/runner.py @@ -7,7 +7,12 @@ from pathlib import Path from typing import Any, Mapping, Sequence -from .jobs import GenericJobStore, JobRecordError, MAX_RESULT_BYTES +from .jobs import ( + GenericJobStore, + JobRecordError, + MAX_RESULT_BYTES, + _open_preallocated_private_artifact, +) from .limits import maximum_timeout_seconds, valid_timeout_seconds from .projects import ProjectConfigError, revalidate_registered_checkout @@ -161,6 +166,9 @@ def _run_agent( ] try: completed = subprocess.run(command, cwd=checkout, check=False) + binding = value.get("bead_binding") + if isinstance(binding, Mapping) and "write_scope" in binding: + _seal_packet_result(value, checkout, result_path) if result_path.exists() and result_path.stat().st_size > MAX_RESULT_BYTES: result_path.write_bytes(result_path.read_bytes()[:MAX_RESULT_BYTES]) return completed.returncode @@ -168,6 +176,45 @@ def _run_agent( prompt_path.unlink(missing_ok=True) +def _seal_packet_result(value: Mapping[str, Any], checkout: Path, result_path: Path) -> None: + """Bind a structured worker report to the runtime-observed terminal Git head.""" + try: + if result_path.stat().st_size > MAX_RESULT_BYTES: + raise RunnerError("packet result exceeds the artifact limit") + raw = result_path.read_bytes() + delivery = json.loads(raw) + except (OSError, json.JSONDecodeError): + delivery = None + observed = subprocess.run( + ["git", "-C", str(checkout), "rev-parse", "HEAD"], + capture_output=True, + text=True, + timeout=10, + check=False, + ) + final_head = observed.stdout.strip() + if observed.returncode != 0 or len(final_head) != 40 or any(value not in "0123456789abcdef" for value in final_head): + raise RunnerError("packet final Git head is unavailable") + envelope = json.dumps( + { + "schema_version": 1, + "job_id": value["job_id"], + "start_head": value["checkout"]["head"], + "final_head": final_head, + "delivery": delivery, + }, + sort_keys=True, + separators=(",", ":"), + ).encode() + if len(envelope) > MAX_RESULT_BYTES: + raise RunnerError("packet result exceeds the artifact limit") + with _open_preallocated_private_artifact(result_path) as result_file: + os.ftruncate(result_file.fileno(), 0) + result_file.write(envelope) + result_file.flush() + os.fsync(result_file.fileno()) + + def main(arguments: Sequence[str] | None = None) -> int: parser = argparse.ArgumentParser(prog="sinnixd-contract-runner") parser.add_argument("--input", type=Path) diff --git a/pkgs/sinnixd/sinnixd/service.py b/pkgs/sinnixd/sinnixd/service.py index 0c32aca7..34d795f5 100644 --- a/pkgs/sinnixd/sinnixd/service.py +++ b/pkgs/sinnixd/sinnixd/service.py @@ -298,24 +298,35 @@ def _dispatch( if operation == "workspace.publish": if principal not in {"agent-control", "operator"}: raise ValueError("workspace publication requires agent-control or operator principal") - if set(arguments) != {"workspace_id", "job_id", "title", "body"}: + if set(arguments) - {"workspace_id", "job_id", "packet_job_id", "title", "body"} or not { + "workspace_id", "job_id", "title", "body" + } <= set(arguments): raise ValueError("workspace.publish requires workspace_id, job_id, title, and body") assert self.delivery is not None - return self.delivery.publish( + publish_arguments = ( self._job_argument(arguments, "workspace_id"), self._job_argument(arguments, "job_id"), self._job_argument(arguments, "title"), arguments.get("body") if isinstance(arguments.get("body"), str) else "", ) + packet_job_id = arguments.get("packet_job_id") + return self.delivery.publish( + *publish_arguments, + **({"packet_job_id": packet_job_id} if isinstance(packet_job_id, str) else {}), + ) if operation == "workspace.review-status": assert self.delivery is not None return self.delivery.review_status(self._single_workspace_id(arguments, "workspace.review-status")) if operation == "workspace.land": - if principal not in {"agent-control", "operator"} or set(arguments) != {"workspace_id", "job_id"}: + if principal not in {"agent-control", "operator"} or set(arguments) - { + "workspace_id", "job_id", "packet_job_id" + } or not {"workspace_id", "job_id"} <= set(arguments): raise ValueError("workspace.land requires agent-control or operator plus workspace_id and job_id") assert self.delivery is not None + packet_job_id = arguments.get("packet_job_id") return self.delivery.land( - self._job_argument(arguments, "workspace_id"), self._job_argument(arguments, "job_id") + self._job_argument(arguments, "workspace_id"), self._job_argument(arguments, "job_id"), + **({"packet_job_id": packet_job_id} if isinstance(packet_job_id, str) else {}), ) if operation == "workspace.finish": if principal not in {"agent-control", "operator"}: @@ -337,8 +348,8 @@ def _dispatch( ) project_id = self._job_argument(arguments, "project_id") operation_name = self._job_argument(arguments, "operation") - if set(arguments) - {"project_id", "operation", "workspace_id", "parameters"}: - raise ValueError("job.start accepts project_id, operation, optional workspace_id, and optional parameters") + if set(arguments) - {"project_id", "operation", "workspace_id", "parameters", "bead_binding"}: + raise ValueError("job.start accepts project_id, operation, optional workspace_id, optional parameters, and optional bead_binding") parameters = arguments.get("parameters", {}) if not isinstance(parameters, Mapping): raise ValueError("job.start parameters must be an object") @@ -352,6 +363,14 @@ def _dispatch( if workspace_id is not None else self.projects.checkout(project_id, "default") ) + binding = arguments.get("bead_binding") + if binding is not None and operation_name not in project.workspace.verification_operations: + raise ValueError("a Beads packet binding requires a declared verification operation") + packet_contract = ( + {"bead_binding": self.job_contracts.bead_binding(binding, checkout)} + if binding is not None + else {} + ) return self._cleanup_terminal(self.jobs.start_declared( project=project, operation=project.operation(operation_name), @@ -359,6 +378,7 @@ def _dispatch( principal=principal, parameters=parameters, checkout=checkout, + contract=packet_contract, )) if operation == "job.shell.start": required = {"project_id", "checkout_id", "argv", "cwd", "timeout_seconds", "result"} diff --git a/pkgs/sinnixd/test_service.py b/pkgs/sinnixd/test_service.py index 2b72d469..53d1a4f9 100644 --- a/pkgs/sinnixd/test_service.py +++ b/pkgs/sinnixd/test_service.py @@ -59,7 +59,14 @@ from sinnixd.limits import MAX_DECLARED_OPERATION_TIMEOUT_SECONDS from sinnixd.owner_adapters import DeclaredOwnerAdapters, OwnerAdapterError from sinnixd.projects import ProjectCatalog, ProjectConfigError, RegisteredCheckout, parse_worktree_records -from sinnixd.runner import RunnerError, _exec_shell, _require_environment, _revalidate_checkout, _run_declared +from sinnixd.runner import ( + RunnerError, + _exec_shell, + _require_environment, + _revalidate_checkout, + _run_declared, + _seal_packet_result, +) from sinnixd.service import SinnixdService from sinnixd.tasks import ( BeadsCommandBoundary, @@ -4530,6 +4537,214 @@ def test_delivery_snapshot_is_nul_safe_and_exact_file_scope_does_not_include_des assert service.workspaces.delivery_snapshot(workspace["workspace_id"], start, scope=("dir/",))["in_scope"] +def test_beads_bound_packet_and_exact_head_verifier_compose_into_delivery(tmp_path: Path) -> None: + """The accepting path joins two authoritative jobs; neither can substitute for the other.""" + write_adapter(tmp_path) + initialize_git_checkout(tmp_path) + native = tmp_path / "native-runner" + native_runner(native) + systemd = FakeSystemdJobs() + jobs = generic_jobs(tmp_path, systemd) + service = SinnixdService(ProjectCatalog([tmp_path]), jobs=jobs, native_runner=native) + workspace = service.workspaces.create( + project_id="fixture", name="packet-delivery", branch="feature/packet-delivery", base="HEAD" + ) + checkout_id = workspace["checkout_id"] + binding = { + "bead_ref": "sinnix://projects/fixture/beads/fixture-1", + "project_ref": "sinnix://projects/fixture", + "checkout_ref": f"sinnix://projects/fixture/checkouts/{checkout_id}", + "task_revision": "a" * 64, + "task_etag": "b" * 64, + "claim_ref": f"sinnix://projects/fixture/beads/fixture-1/claims/{'c' * 64}", + "claim_receipt": { + "ref": f"sinnix://projects/fixture/beads/fixture-1/claims/{'c' * 64}", + "owner_route": "beads.cli", + }, + "request_id": "2e46daf5-e9b1-4c6e-b99d-bcd46631730b", + "assignment_ref": None, + "write_scope": ["delivery.txt", "obsolete.txt"], + } + path = Path(workspace["path"]) + (path / "obsolete.txt").write_text("remove me\n") + subprocess.run(["git", "-C", str(path), "add", "obsolete.txt"], check=True) + subprocess.run( + ["git", "-C", str(path), "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "--quiet", "-m", "seed deletion"], + check=True, + ) + packet = service.dispatch( + request( + "job.agent.start", + "systemd-jobs", + { + "project_id": "fixture", "checkout_id": checkout_id, "prompt": "return structured delivery", + "backend": "codex", "model": "fixture", "effort": "high", + "credential_profile": "subscription", "timeout_seconds": 60, + "result": "last-message", "bead_binding": binding, + }, + "agent-control", + ) + ) + assert packet.ok and packet.payload is not None + packet_id = packet.payload.inline["job_id"] + packet_record = jobs.store.load(packet_id) + start_head = packet_record.spec.checkout["head"] + (path / "delivery.txt").write_text("delivered\n") + (path / "obsolete.txt").unlink() + subprocess.run(["git", "-C", str(path), "add", "-A"], check=True) + subprocess.run( + ["git", "-C", str(path), "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "--quiet", "-m", "delivery"], + check=True, + ) + final_head = subprocess.run( + ["git", "-C", str(path), "rev-parse", "HEAD"], capture_output=True, text=True, check=True + ).stdout.strip() + worker_delivery = { + "anti_vacuity": True, + "unresolved_work": [], + "delegation": {"visibility": "unsupported", "pending": None}, + "deletion_evidence": ["obsolete.txt"], + "evidence_only": False, + } + assert packet_record.result_path is not None + packet_record.result_path.write_text(json.dumps({ + "schema_version": 1, "job_id": packet_id, "start_head": start_head, + "final_head": final_head, "delivery": worker_delivery, + })) + jobs_module._write_private_marker(jobs_module._completion_marker_path(packet_record.log_path)) + systemd.properties = { + "LoadState": "loaded", "ActiveState": "inactive", "Result": "success", + "ExecMainStatus": "0", "InvocationID": "fixture-invocation", + } + assert jobs.get(packet_id)["state"]["phase"] == "succeeded" + verifier = service.dispatch(request( + "job.start", "systemd-jobs", + { + "project_id": "fixture", "operation": "check", "workspace_id": workspace["workspace_id"], + "bead_binding": binding, + }, + )) + assert verifier.ok and verifier.payload is not None + verifier_id = verifier.payload.inline["job_id"] + assert jobs.get(verifier_id)["state"]["phase"] == "succeeded" + + delivery = GitHubDelivery(service.projects, service.workspaces, jobs) + _workspace, _project, receipt = delivery._verified_workspace( + workspace["workspace_id"], verifier_id, packet_id + ) + assert receipt["bead_ref"] == binding["bead_ref"] + assert receipt["head"] == final_head + + (path / "dirty.txt").write_text("uncommitted\n") + with pytest.raises(DeliveryError, match="exact HEAD"): + delivery._verified_workspace(workspace["workspace_id"], verifier_id, packet_id) + (path / "dirty.txt").unlink() + + bad_binding = {**binding, "write_scope": ["other.txt"]} + jobs.store.save(replace( + packet_record, + spec=replace(packet_record.spec, contract={**packet_record.spec.contract, "bead_binding": bad_binding}), + )) + verifier_record = jobs.store.load(verifier_id) + jobs.store.save(replace( + verifier_record, + spec=replace(verifier_record.spec, contract={**verifier_record.spec.contract, "bead_binding": bad_binding}), + )) + with pytest.raises(DeliveryError, match="write scope"): + delivery._verified_workspace(workspace["workspace_id"], verifier_id, packet_id) + jobs.store.save(packet_record) + jobs.store.save(verifier_record) + + for evidence in ([], ["unrelated.txt"]): + packet_record.result_path.write_text(json.dumps({ + "schema_version": 1, "job_id": packet_id, "start_head": start_head, + "final_head": final_head, + "delivery": {**worker_delivery, "deletion_evidence": evidence}, + })) + with pytest.raises(DeliveryError, match="deletion evidence"): + delivery._verified_workspace(workspace["workspace_id"], verifier_id, packet_id) + + packet_record.result_path.write_text(json.dumps({ + "schema_version": 1, "job_id": packet_id, "start_head": start_head, + "final_head": final_head, + "delivery": {**worker_delivery, "unresolved_work": ["still running"]}, + })) + with pytest.raises(DeliveryError, match="incomplete"): + delivery._verified_workspace(workspace["workspace_id"], verifier_id, packet_id) + packet_record.result_path.write_text(json.dumps({ + "schema_version": 1, "job_id": packet_id, "start_head": start_head, + "final_head": final_head, "delivery": worker_delivery, + })) + + packet_record.result_path.write_text(json.dumps({ + "schema_version": 1, "job_id": packet_id, "start_head": start_head, + "final_head": final_head, "delivery": None, + })) + with pytest.raises(DeliveryError, match="malformed"): + delivery._verified_workspace(workspace["workspace_id"], verifier_id, packet_id) + packet_record.result_path.write_text(json.dumps({ + "schema_version": 1, "job_id": packet_id, "start_head": start_head, + "final_head": final_head, "delivery": worker_delivery, + })) + + for scope in (["../outside"], [f"entry-{index}" for index in range(129)]): + rejected = service.dispatch(request( + "job.start", "systemd-jobs", + { + "project_id": "fixture", "operation": "check", "workspace_id": workspace["workspace_id"], + "bead_binding": {**binding, "write_scope": scope}, + }, + )) + assert not rejected.ok + + (path / "later.txt").write_text("post-terminal\n") + subprocess.run(["git", "-C", str(path), "add", "later.txt"], check=True) + subprocess.run( + ["git", "-C", str(path), "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "--quiet", "-m", "later"], + check=True, + ) + with pytest.raises(DeliveryError, match="exact HEAD"): + delivery._verified_workspace(workspace["workspace_id"], verifier_id, packet_id) + + +def test_packet_runner_seals_worker_report_to_runtime_observed_head(tmp_path: Path) -> None: + initialize_git_checkout(tmp_path) + start_head = subprocess.run( + ["git", "-C", str(tmp_path), "rev-parse", "HEAD"], capture_output=True, text=True, check=True + ).stdout.strip() + (tmp_path / "change.txt").write_text("change\n") + subprocess.run(["git", "-C", str(tmp_path), "add", "change.txt"], check=True) + subprocess.run( + ["git", "-C", str(tmp_path), "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "--quiet", "-m", "change"], + check=True, + ) + final_head = subprocess.run( + ["git", "-C", str(tmp_path), "rev-parse", "HEAD"], capture_output=True, text=True, check=True + ).stdout.strip() + result_root = tmp_path / "private-results" + result_root.mkdir(mode=0o700) + result_path = result_root / "packet.result" + result_path.touch(mode=0o600) + delivery = { + "anti_vacuity": True, "unresolved_work": [], + "delegation": {"visibility": "unsupported", "pending": None}, + "deletion_evidence": [], "evidence_only": False, + } + result_path.write_text(json.dumps(delivery)) + + _seal_packet_result( + {"job_id": "packet-job", "checkout": {"head": start_head}}, tmp_path, result_path + ) + + assert json.loads(result_path.read_text()) == { + "schema_version": 1, + "job_id": "packet-job", + "start_head": start_head, + "final_head": final_head, + "delivery": delivery, + } + + def test_admission_revalidates_queued_declared_workspace_before_systemd_launch(tmp_path: Path) -> None: """A queued declared service whose checkout HEAD moved must terminalize before it reaches systemd.""" write_adapter(tmp_path) From 99520cfd0b38d939107da4fb89bea7b35b27ecfa Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 25 Aug 2026 05:41:36 +0200 Subject: [PATCH 14/35] Accept obvious AgentCTL task query aliases --- pkgs/sinnixd/sinnixd/cli.py | 13 ++++++++----- pkgs/sinnixd/test_service.py | 2 ++ 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/pkgs/sinnixd/sinnixd/cli.py b/pkgs/sinnixd/sinnixd/cli.py index 7bd3bc4c..c6292e09 100644 --- a/pkgs/sinnixd/sinnixd/cli.py +++ b/pkgs/sinnixd/sinnixd/cli.py @@ -162,9 +162,11 @@ def parser() -> argparse.ArgumentParser: task_list.add_argument("--reverse", action="store_true") task_list.add_argument("--include-closed", action="store_true") task_list.add_argument("--ready", action="store_true") - task_get = task_subcommands.add_parser("get") - task_get.add_argument("project_id") - task_get.add_argument("task_id") + task_list.add_argument("--json", action="store_true", help=argparse.SUPPRESS) + for command in ("get", "show"): + task_get = task_subcommands.add_parser(command) + task_get.add_argument("project_id") + task_get.add_argument("task_id") task_create = task_subcommands.add_parser("create") task_create.add_argument("project_id") task_create.add_argument("title") @@ -499,7 +501,7 @@ def main() -> int: ) if arguments.parent is not None: task_arguments["parent_task_id"] = arguments.parent - elif arguments.task_command in {"get", "claim", "complete", "release", "note", "relate"}: + elif arguments.task_command in {"get", "show", "claim", "complete", "release", "note", "relate"}: task_arguments["task_id"] = arguments.task_id if arguments.task_command == "note": if (arguments.text is None) == (arguments.text_option is None): @@ -517,8 +519,9 @@ def main() -> int: if arguments.task_command == "release" and arguments.if_assignee is not None: task_arguments["if_assignee"] = arguments.if_assignee mutation_id = getattr(arguments, "request_id", None) + task_operation = "get" if arguments.task_command == "show" else arguments.task_command request = _request( - f"task.{arguments.task_command}", + f"task.{task_operation}", "task-backend", task_arguments, "operator", diff --git a/pkgs/sinnixd/test_service.py b/pkgs/sinnixd/test_service.py index 9829751e..a6df4949 100644 --- a/pkgs/sinnixd/test_service.py +++ b/pkgs/sinnixd/test_service.py @@ -186,7 +186,9 @@ def test_canonical_client_redacts_unrecognized_json_rpc_errors(tmp_path: Path) - ("argv", "operation", "payload"), ( (("agentctl", "task", "list", "fixture", "--status", "open"), "task.list", {"project_id": "fixture", "status": "open", "limit": 100}), + (("agentctl", "task", "list", "fixture", "--status", "open", "--json"), "task.list", {"project_id": "fixture", "status": "open", "limit": 100}), (("agentctl", "task", "get", "fixture", "fixture-1"), "task.get", {"project_id": "fixture", "task_id": "fixture-1"}), + (("agentctl", "task", "show", "fixture", "fixture-1"), "task.get", {"project_id": "fixture", "task_id": "fixture-1"}), (("agentctl", "task", "create", "fixture", "typed title", "--description", "typed description", "--type", "task", "--priority", "2", "--label", "area:agentctl", "--parent", "fixture-parent", "--dependency", "depends-on:fixture-blocker", "--request-id", "request-1"), "task.create", {"project_id": "fixture", "title": "typed title", "description": "typed description", "issue_type": "task", "priority": 2, "labels": ["area:agentctl"], "parent_task_id": "fixture-parent", "dependencies": [{"relation": "depends-on", "task_id": "fixture-blocker"}]}), (("agentctl", "task", "claim", "fixture", "fixture-1", "--request-id", "request-1"), "task.claim", {"project_id": "fixture", "task_id": "fixture-1"}), (("agentctl", "task", "note", "fixture", "fixture-1", "note", "--request-id", "request-1"), "task.note", {"project_id": "fixture", "task_id": "fixture-1", "text": "note"}), From dd7b9f22e35436c7bb3c90829144e2adc6eb06a3 Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 25 Aug 2026 05:43:35 +0200 Subject: [PATCH 15/35] Add typed AgentCTL task metadata updates --- pkgs/sinnixd/sinnixd/cli.py | 16 +++++++++++++++- pkgs/sinnixd/sinnixd/tasks.py | 13 +++++++++++-- pkgs/sinnixd/test_service.py | 4 +++- 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/pkgs/sinnixd/sinnixd/cli.py b/pkgs/sinnixd/sinnixd/cli.py index c6292e09..d9a321c3 100644 --- a/pkgs/sinnixd/sinnixd/cli.py +++ b/pkgs/sinnixd/sinnixd/cli.py @@ -22,6 +22,13 @@ def _dependency_argument(value: str) -> tuple[str, str]: return relation, task_id +def _metadata_argument(value: str) -> tuple[str, str]: + key, separator, metadata_value = value.partition("=") + if not separator or not key: + raise argparse.ArgumentTypeError("--set-metadata must be key=value") + return key, metadata_value + + def default_socket_path() -> Path: return Path(os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")) / "sinnixd.sock" @@ -194,6 +201,11 @@ def parser() -> argparse.ArgumentParser: task_note.add_argument("text", nargs="?") task_note.add_argument("--text", dest="text_option") task_note.add_argument("--request-id", required=True) + task_update = task_subcommands.add_parser("update") + task_update.add_argument("project_id") + task_update.add_argument("task_id") + task_update.add_argument("--set-metadata", action="append", type=_metadata_argument, default=[], required=True) + task_update.add_argument("--request-id", required=True) task_relate = task_subcommands.add_parser("relate") task_relate.add_argument("project_id") task_relate.add_argument("task_id") @@ -501,7 +513,7 @@ def main() -> int: ) if arguments.parent is not None: task_arguments["parent_task_id"] = arguments.parent - elif arguments.task_command in {"get", "show", "claim", "complete", "release", "note", "relate"}: + elif arguments.task_command in {"get", "show", "claim", "complete", "release", "note", "relate", "update"}: task_arguments["task_id"] = arguments.task_id if arguments.task_command == "note": if (arguments.text is None) == (arguments.text_option is None): @@ -511,6 +523,8 @@ def main() -> int: ) elif arguments.task_command == "relate": task_arguments["related_task_id"] = arguments.related_task_id + elif arguments.task_command == "update": + task_arguments["metadata"] = dict(arguments.set_metadata) elif arguments.task_command in {"complete", "release"}: if arguments.reason is not None: task_arguments["reason"] = arguments.reason diff --git a/pkgs/sinnixd/sinnixd/tasks.py b/pkgs/sinnixd/sinnixd/tasks.py index fc60bd36..0ae60f7f 100644 --- a/pkgs/sinnixd/sinnixd/tasks.py +++ b/pkgs/sinnixd/sinnixd/tasks.py @@ -46,8 +46,8 @@ _SHA256_RE = re.compile(r"^sha256:[0-9a-f]{64}$") _READ_PRINCIPALS = frozenset({"observer", "agent-control", "operator"}) _WRITE_PRINCIPALS = frozenset({"agent-control", "operator"}) -_MUTATIONS = frozenset({"task.create", "task.claim", "task.note", "task.relate", "task.complete", "task.release", "task.reconcile"}) -_IDEMPOTENT_MUTATIONS = frozenset({"task.create", "task.claim", "task.note", "task.relate", "task.complete", "task.release"}) +_MUTATIONS = frozenset({"task.create", "task.claim", "task.note", "task.relate", "task.complete", "task.release", "task.update", "task.reconcile"}) +_IDEMPOTENT_MUTATIONS = frozenset({"task.create", "task.claim", "task.note", "task.relate", "task.complete", "task.release", "task.update"}) _MUTATION_STATES = frozenset({"pending", "dispatching", "applied", "failed"}) _ISSUE_TYPES = frozenset({"bug", "feature", "task", "epic", "chore", "decision", "spike", "story", "milestone"}) _DEPENDENCY_RELATIONS = frozenset({"depends-on", "blocks", "tracks", "related", "discovered-from", "until", "caused-by", "validates", "relates-to", "supersedes"}) @@ -599,6 +599,15 @@ def _mutation_command(self, operation: str, arguments: dict[str, Any]) -> tuple[ if operation == "task.note": self._require_exact(arguments, {"project_id", "task_id", "text"}, operation) return ("note", self._task_id(arguments["task_id"]), self._string(arguments["text"], "text", 32_000)) + if operation == "task.update": + self._require_exact(arguments, {"project_id", "task_id", "metadata"}, operation) + metadata = arguments["metadata"] + if not isinstance(metadata, dict) or not metadata or len(metadata) > 32: + raise TaskError(ErrorCode.INVALID_ARGUMENT, "metadata must be a non-empty object with at most 32 entries") + command = ["update", self._task_id(arguments["task_id"])] + for key, value in sorted(metadata.items()): + command.extend(("--set-metadata", f"{self._string(key, 'metadata key', 256)}={self._string(value, 'metadata value', 4_000)}")) + return tuple(command) if operation == "task.relate": self._require_exact(arguments, {"project_id", "task_id", "related_task_id"}, operation) return ("dep", "relate", self._task_id(arguments["task_id"]), self._task_id(arguments["related_task_id"], "related_task_id")) diff --git a/pkgs/sinnixd/test_service.py b/pkgs/sinnixd/test_service.py index a6df4949..31ea7b8a 100644 --- a/pkgs/sinnixd/test_service.py +++ b/pkgs/sinnixd/test_service.py @@ -193,6 +193,7 @@ def test_canonical_client_redacts_unrecognized_json_rpc_errors(tmp_path: Path) - (("agentctl", "task", "claim", "fixture", "fixture-1", "--request-id", "request-1"), "task.claim", {"project_id": "fixture", "task_id": "fixture-1"}), (("agentctl", "task", "note", "fixture", "fixture-1", "note", "--request-id", "request-1"), "task.note", {"project_id": "fixture", "task_id": "fixture-1", "text": "note"}), (("agentctl", "task", "note", "fixture", "fixture-1", "--text", "note", "--request-id", "request-1"), "task.note", {"project_id": "fixture", "task_id": "fixture-1", "text": "note"}), + (("agentctl", "task", "update", "fixture", "fixture-1", "--set-metadata", "write_scope=[\"pkgs/sinnixd/\"]", "--request-id", "request-1"), "task.update", {"project_id": "fixture", "task_id": "fixture-1", "metadata": {"write_scope": '["pkgs/sinnixd/"]'}}), (("agentctl", "task", "relate", "fixture", "fixture-1", "fixture-2", "--request-id", "request-1"), "task.relate", {"project_id": "fixture", "task_id": "fixture-1", "related_task_id": "fixture-2"}), (("agentctl", "task", "complete", "fixture", "fixture-1", "--reason", "done", "--merge-sha", "a" * 40, "--request-id", "request-1"), "task.complete", {"project_id": "fixture", "task_id": "fixture-1", "reason": "done", "merge_sha": "a" * 40}), (("agentctl", "task", "release", "fixture", "fixture-1", "--if-assignee", "worker", "--request-id", "request-1"), "task.release", {"project_id": "fixture", "task_id": "fixture-1", "if_assignee": "worker"}), @@ -218,7 +219,7 @@ def fake_call(socket_path, request_value): assert outbound.owner == "task-backend" assert outbound.principal == "operator" assert dict(outbound.arguments) == payload - expected_key = "request-1" if operation in {"task.create", "task.claim", "task.note", "task.relate", "task.complete", "task.release"} else None + expected_key = "request-1" if operation in {"task.create", "task.claim", "task.note", "task.relate", "task.complete", "task.release", "task.update"} else None assert outbound.idempotency_key == expected_key @@ -2503,6 +2504,7 @@ def test_task_list_service_returns_structured_stale_cursor_error(tmp_path: Path) ( ("task.claim", {"task_id": "fixture-1"}, ("update", "fixture-1", "--claim")), ("task.note", {"task_id": "fixture-1", "text": "append this"}, ("note", "fixture-1", "append this")), + ("task.update", {"task_id": "fixture-1", "metadata": {"write_scope": '["pkgs/sinnixd/"]'}}, ("update", "fixture-1", "--set-metadata", 'write_scope=["pkgs/sinnixd/"]')), ("task.relate", {"task_id": "fixture-1", "related_task_id": "fixture-2"}, ("dep", "relate", "fixture-1", "fixture-2")), ("task.complete", {"task_id": "fixture-1", "merge_sha": "a" * 40, "reason": "verified"}, ("close", "fixture-1", "--reason", "verified")), ("task.release", {"task_id": "fixture-1", "reason": "stopped", "if_assignee": "worker"}, ("unclaim", "fixture-1", "--reason", "stopped", "--if-assignee", "worker")), From e4697adea6e6e18378ba3df9754f027e59666e28 Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 25 Aug 2026 05:56:48 +0200 Subject: [PATCH 16/35] Scope packet delivery to the full publication --- docs/sinnixd.md | 2 +- .../sinnix_agent_gateway/runtime.py | 9 ++++---- .../test_execution_jobs.py | 10 +++++++++ pkgs/sinnixd/sinnixd/delivery.py | 21 +++++++++++++++---- pkgs/sinnixd/sinnixd/runner.py | 4 ++-- pkgs/sinnixd/sinnixd/workspaces.py | 15 +++++++++---- pkgs/sinnixd/test_service.py | 18 ++++++++++++---- 7 files changed, 60 insertions(+), 19 deletions(-) diff --git a/docs/sinnixd.md b/docs/sinnixd.md index 2ca7dc5b..e1dd0daf 100644 --- a/docs/sinnixd.md +++ b/docs/sinnixd.md @@ -223,7 +223,7 @@ If any verification or cutover command fails, leave Sinnixd stopped. Before the Both routes use the same UUID job ID, transient user service, cancellation, reconciliation, `job get/list/logs/result/wait`, and bounded artifact readers as declared operations. Their durable public record contains the principal, job kind, canonical project and checkout identity, redacted argv digest or prompt digest, and bounded artifact references. It never stores raw shell argv arguments after launch, prompt text, environment values, or credentials. -Delivery is a precondition of `workspace.publish` and `workspace.land`, not a caller-fed completion route. Ordinary delivery reads the exact-head declared verification job through `job.result`. Packet delivery additionally names the Beads-bound attested-agent job with `--packet-job`. The declared verification job receives the same immutable Beads binding at dispatch, including its initial head, bead identity, and write scope; the contract runner seals the worker's structured report to the Git head observed when the runner exits. Delivery requires the bindings to match, the later semantic verifier to succeed at that same final head, and snapshots the initial-to-final Git range. It rejects dirty, divergent, stale, or out-of-scope work and repeats the complete precondition after push and after review inspection. The worker report can only tighten acceptance through bounded anti-vacuity, unresolved-work, delegation-visibility, deletion-evidence, and evidence-only fields. Git owns paths, commits, and heads; the project verifier owns semantic success; GitHub owns independent review state. Beads closure consumes the returned completion artifact and bead references in its own owner; wiring that external closure consumer is not implemented by Sinnixd. +Delivery is a precondition of `workspace.publish` and `workspace.land`, not a caller-fed completion route. Ordinary delivery reads the exact-head declared verification job through `job.result`. Packet delivery additionally names the Beads-bound attested-agent job with `--packet-job`. The declared verification job receives the same immutable Beads identity and write-scope binding at dispatch; each job record independently freezes its checkout head, and the contract runner seals the worker's structured report to the Git head observed when the runner exits. Delivery requires the bindings to match, the later semantic verifier to succeed at that same final head, snapshots the packet's initial-to-final Git range, and checks the complete current base-to-head publication diff against the Beads-owned scope. It rejects dirty, divergent, stale, or out-of-scope publication work and repeats the complete precondition after push and after review inspection. The worker report can only tighten acceptance through bounded anti-vacuity, unresolved-work, delegation-visibility, exact deletion evidence, and evidence-only fields. Git owns paths, commits, and heads; the project verifier owns semantic success; GitHub branch protection owns required review state. Beads closure consumes the returned completion artifact and bead references in its own owner; wiring that external closure consumer is not implemented by Sinnixd. Typed jobs accept no environment overlay. The daemon creates the `env -i` environment from the declared project environment and fixed `SINNIXD_*` identity fields. Immediately before execution, the contract runner verifies those fields, rechecks the exact registered project, canonical worktree root, common Git directory, porcelain worktree membership, and recorded HEAD. A changed, missing, symlinked, or spoofed identity fails closed. Agent handoff includes `--registered-project`, `--expected-git-common-dir`, and the canonical checkout path; nested scope creation remains disabled, so the native runner provides backend execution and native attestation while the shared transient user service remains the sole process, cgroup, timeout, and cancellation authority. Private launch inputs are mode 0600, removed before shell execution, and removed after agent handoff or every terminal lifecycle outcome, including confirmed launch failure. Native private logs are removed after handoff; only the bounded shared log and result artifacts remain addressable. diff --git a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/runtime.py b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/runtime.py index 8069c2bb..fd188c5a 100644 --- a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/runtime.py +++ b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/runtime.py @@ -880,10 +880,11 @@ def v2_run_for_bead( if isinstance(encoded_scope, str): try: write_scope = json.loads(encoded_scope) - except json.JSONDecodeError: - write_scope = None - if isinstance(write_scope, list): - binding["write_scope"] = write_scope + except json.JSONDecodeError as error: + raise ProtocolError("invalid_request", "Bead write_scope metadata must be a JSON array") from error + if not isinstance(write_scope, list): + raise ProtocolError("invalid_request", "Bead write_scope metadata must be a JSON array") + binding["write_scope"] = write_scope assigned_context = { "bead": bead, "project_ref": project_ref, diff --git a/pkgs/sinnix-agent-gateway/test_execution_jobs.py b/pkgs/sinnix-agent-gateway/test_execution_jobs.py index 7cd57b99..353408f3 100644 --- a/pkgs/sinnix-agent-gateway/test_execution_jobs.py +++ b/pkgs/sinnix-agent-gateway/test_execution_jobs.py @@ -633,6 +633,16 @@ def test_agent_control_bead_scope_requires_matching_current_assignment( assert daemon.calls[-1].arguments["bead_binding"]["write_scope"] == ["pkgs/sinnixd/"] assert "private launch instruction" not in daemon.calls[-1].arguments["bead_binding"].values() + malformed_scope = {**bead, "metadata": {"write_scope": "not-json"}} + monkeypatch.setattr(runtime.beads, "get", lambda *_args, **_kwargs: malformed_scope) + with pytest.raises(ProtocolError, match="JSON array"): + runtime.v2_run_for_bead( + reference=bead["ref"], checkout_id="default", claim_mode="none", assignment_ref=assignment_ref, + instructions=None, backend="codex", model="gpt-5.6-terra", reasoning_effort="high", + timeout_seconds=60, credential_profile="subscription", request_id="4a42f848-9057-4cef-9d27-80a022c0e16f", + ) + monkeypatch.setattr(runtime.beads, "get", lambda *_args, **_kwargs: bead) + foreign = {**binding, "bead_ref": "sinnix://projects/fixture/beads/fixture-2"} daemon.responses["job.get"] = {**daemon.responses["job.get"], "contract": {"bead_binding": foreign}} with pytest.raises(ProtocolError, match="not the requested"): diff --git a/pkgs/sinnixd/sinnixd/delivery.py b/pkgs/sinnixd/sinnixd/delivery.py index 23ab639e..65680843 100644 --- a/pkgs/sinnixd/sinnixd/delivery.py +++ b/pkgs/sinnixd/sinnixd/delivery.py @@ -135,14 +135,23 @@ def _verified_workspace( packet = self._packet(packet_job_id, workspace, binding) if packet_job_id is not None else None start_head = packet["start_head"] if packet is not None else checkout["head"] scope = packet["scope"] if packet is not None else () - snapshot = self.workspaces.delivery_snapshot(workspace_id, start_head, scope=scope) + snapshot = self.workspaces.delivery_snapshot(workspace_id, start_head) + publication_snapshot = ( + self.workspaces.delivery_snapshot( + workspace_id, project.workspace.default_base, scope=scope, merge_base=True + ) + if packet is not None + else snapshot + ) except DeliveryError: raise except (ValueError, WorkspaceError) as error: raise DeliveryError("workspace lacks an authoritative exact-head completion receipt") from error if snapshot["head"] != checkout["head"] or not snapshot["descendant"] or snapshot["dirty"]: raise DeliveryError("workspace lacks successful declared verification at its exact HEAD") - if packet is not None and (packet["final_head"] != snapshot["head"] or not snapshot["in_scope"]): + if packet is not None and ( + packet["final_head"] != snapshot["head"] or not publication_snapshot["in_scope"] + ): raise DeliveryError("packet delivery is outside its Beads-owned write scope") if packet is not None: self._validate_delivery_result(packet["delivery"], snapshot) @@ -243,8 +252,12 @@ def _validate_delivery_result(delivery: Any, snapshot: Mapping[str, Any]) -> Non for path in change.get("paths", []) if isinstance(path, str) } - if deleted and (any(not isinstance(path, str) for path in deletions) or not deleted <= set(deletions)): - raise DeliveryError("project delivery result omits deletion evidence") + if ( + any(not isinstance(path, str) for path in deletions) + or len(deletions) != len(set(deletions)) + or deleted != set(deletions) + ): + raise DeliveryError("project delivery result does not exactly match deletion evidence") if not changes and not delivery["evidence_only"]: raise DeliveryError("no-change delivery lacks the evidence-only exception") if changes and delivery["evidence_only"]: diff --git a/pkgs/sinnixd/sinnixd/runner.py b/pkgs/sinnixd/sinnixd/runner.py index f53f513e..78172fce 100644 --- a/pkgs/sinnixd/sinnixd/runner.py +++ b/pkgs/sinnixd/sinnixd/runner.py @@ -183,8 +183,8 @@ def _seal_packet_result(value: Mapping[str, Any], checkout: Path, result_path: P raise RunnerError("packet result exceeds the artifact limit") raw = result_path.read_bytes() delivery = json.loads(raw) - except (OSError, json.JSONDecodeError): - delivery = None + except (OSError, json.JSONDecodeError) as error: + raise RunnerError("packet worker result is unavailable or malformed") from error observed = subprocess.run( ["git", "-C", str(checkout), "rev-parse", "HEAD"], capture_output=True, diff --git a/pkgs/sinnixd/sinnixd/workspaces.py b/pkgs/sinnixd/sinnixd/workspaces.py index 1d7466e8..b2e7219f 100644 --- a/pkgs/sinnixd/sinnixd/workspaces.py +++ b/pkgs/sinnixd/sinnixd/workspaces.py @@ -337,21 +337,28 @@ def get(self, workspace_id: str) -> dict[str, Any]: record = self._record(workspace_id) return self._status(record) - def delivery_snapshot(self, workspace_id: str, start_head: str, *, scope: Sequence[str] = ()) -> dict[str, Any]: + def delivery_snapshot( + self, workspace_id: str, start_head: str, *, scope: Sequence[str] = (), merge_base: bool = False + ) -> dict[str, Any]: """Read one exact-head Git fact set for a delivery precondition.""" record = self._record(workspace_id) checkout, _project = self._available(record) before = self._git(checkout.path, "rev-parse", "HEAD").stdout.strip() if before != checkout.head: raise WorkspaceError("workspace HEAD changed during delivery snapshot") - descendant = self._git(checkout.path, "merge-base", "--is-ancestor", start_head, before, check=False).returncode == 0 - changes = self._name_status(checkout.path, start_head, before) + range_start = ( + self._git(checkout.path, "merge-base", start_head, before).stdout.strip() + if merge_base + else start_head + ) + descendant = self._git(checkout.path, "merge-base", "--is-ancestor", range_start, before, check=False).returncode == 0 + changes = self._name_status(checkout.path, range_start, before) dirty = self._porcelain_status(checkout.path) after = self._git(checkout.path, "rev-parse", "HEAD").stdout.strip() if after != before: raise WorkspaceError("workspace HEAD changed during delivery snapshot") paths = tuple(path for change in changes for path in change["paths"]) - return {"workspace_id": workspace_id, "checkout_id": checkout.checkout_id, "start_head": start_head, "head": before, "descendant": descendant, "dirty": bool(dirty), "status": dirty, "changes": changes, "in_scope": all(self._scope_contains(path, scope) for path in paths) if scope else True} + return {"workspace_id": workspace_id, "checkout_id": checkout.checkout_id, "start_head": range_start, "head": before, "descendant": descendant, "dirty": bool(dirty), "status": dirty, "changes": changes, "in_scope": all(self._scope_contains(path, scope) for path in paths) if scope else True} def checkout(self, workspace_id: str) -> RegisteredCheckout: record = self._record(workspace_id) diff --git a/pkgs/sinnixd/test_service.py b/pkgs/sinnixd/test_service.py index 31ea7b8a..be9f7a26 100644 --- a/pkgs/sinnixd/test_service.py +++ b/pkgs/sinnixd/test_service.py @@ -4675,11 +4675,12 @@ def test_beads_bound_packet_and_exact_head_verifier_compose_into_delivery(tmp_pa }, "request_id": "2e46daf5-e9b1-4c6e-b99d-bcd46631730b", "assignment_ref": None, - "write_scope": ["delivery.txt", "obsolete.txt"], + "write_scope": ["delivery.txt", "obsolete.txt", "prior.txt"], } path = Path(workspace["path"]) (path / "obsolete.txt").write_text("remove me\n") - subprocess.run(["git", "-C", str(path), "add", "obsolete.txt"], check=True) + (path / "prior.txt").write_text("already on the packet branch\n") + subprocess.run(["git", "-C", str(path), "add", "obsolete.txt", "prior.txt"], check=True) subprocess.run( ["git", "-C", str(path), "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "--quiet", "-m", "seed deletion"], check=True, @@ -4752,7 +4753,10 @@ def test_beads_bound_packet_and_exact_head_verifier_compose_into_delivery(tmp_pa delivery._verified_workspace(workspace["workspace_id"], verifier_id, packet_id) (path / "dirty.txt").unlink() - bad_binding = {**binding, "write_scope": ["other.txt"]} + # Packet-local changes remain in scope, but prior.txt was already on the + # branch at packet dispatch. The complete publication diff must still + # prevent the packet from laundering it into the PR. + bad_binding = {**binding, "write_scope": ["delivery.txt", "obsolete.txt"]} jobs.store.save(replace( packet_record, spec=replace(packet_record.spec, contract={**packet_record.spec.contract, "bead_binding": bad_binding}), @@ -4767,7 +4771,7 @@ def test_beads_bound_packet_and_exact_head_verifier_compose_into_delivery(tmp_pa jobs.store.save(packet_record) jobs.store.save(verifier_record) - for evidence in ([], ["unrelated.txt"]): + for evidence in ([], ["unrelated.txt"], ["obsolete.txt", "unrelated.txt"]): packet_record.result_path.write_text(json.dumps({ "schema_version": 1, "job_id": packet_id, "start_head": start_head, "final_head": final_head, @@ -4856,6 +4860,12 @@ def test_packet_runner_seals_worker_report_to_runtime_observed_head(tmp_path: Pa "delivery": delivery, } + result_path.write_text("not-json") + with pytest.raises(RunnerError, match="worker result"): + _seal_packet_result( + {"job_id": "packet-job", "checkout": {"head": start_head}}, tmp_path, result_path + ) + def test_admission_revalidates_queued_declared_workspace_before_systemd_launch(tmp_path: Path) -> None: """A queued declared service whose checkout HEAD moved must terminalize before it reaches systemd.""" From 794fb598ffe97d45b057e8229f8ebfab4e5c4744 Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 25 Aug 2026 06:10:01 +0200 Subject: [PATCH 17/35] test(agentctl): compose packet sealing with delivery --- pkgs/sinnixd/test_service.py | 127 +++++++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) diff --git a/pkgs/sinnixd/test_service.py b/pkgs/sinnixd/test_service.py index be9f7a26..26bd7180 100644 --- a/pkgs/sinnixd/test_service.py +++ b/pkgs/sinnixd/test_service.py @@ -4867,6 +4867,133 @@ def test_packet_runner_seals_worker_report_to_runtime_observed_head(tmp_path: Pa ) +def test_seal_output_composes_through_exact_head_into_delivery_validation(tmp_path: Path) -> None: + """Composed: real runner seal output flows through exact-head evidence into delivery acceptance and tamper rejection.""" + write_adapter(tmp_path) + initialize_git_checkout(tmp_path) + native = tmp_path / "native-runner" + native_runner(native) + systemd = FakeSystemdJobs() + jobs = generic_jobs(tmp_path, systemd) + service = SinnixdService(ProjectCatalog([tmp_path]), jobs=jobs, native_runner=native) + workspace = service.workspaces.create( + project_id="fixture", name="seal-compose", branch="feature/seal-compose", base="HEAD" + ) + checkout_id = workspace["checkout_id"] + path = Path(workspace["path"]) + + # Seed a file that will be deleted during the packet range. + (path / "seed.txt").write_text("to be removed\n") + subprocess.run(["git", "-C", str(path), "add", "seed.txt"], check=True) + subprocess.run( + ["git", "-C", str(path), "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", + "commit", "--quiet", "-m", "seed deletion target"], + check=True, + ) + + binding = { + "bead_ref": "sinnix://projects/fixture/beads/seal-test-1", + "project_ref": "sinnix://projects/fixture", + "checkout_ref": f"sinnix://projects/fixture/checkouts/{checkout_id}", + "task_revision": "a" * 64, + "task_etag": "b" * 64, + "claim_ref": f"sinnix://projects/fixture/beads/seal-test-1/claims/{'c' * 64}", + "claim_receipt": { + "ref": f"sinnix://projects/fixture/beads/seal-test-1/claims/{'c' * 64}", + "owner_route": "beads.cli", + }, + "request_id": "9f1a2b3c-0000-4d5e-8f6a-7b8c9d0e1f2a", + "assignment_ref": None, + "write_scope": ["added.txt", "seed.txt"], + } + + packet_response = service.dispatch(request( + "job.agent.start", "systemd-jobs", + { + "project_id": "fixture", "checkout_id": checkout_id, + "prompt": "return structured delivery for seal composition test", + "backend": "codex", "model": "fixture", "effort": "high", + "credential_profile": "subscription", "timeout_seconds": 60, + "result": "last-message", "bead_binding": binding, + }, + "agent-control", + )) + assert packet_response.ok and packet_response.payload is not None + packet_id = packet_response.payload.inline["job_id"] + packet_record = jobs.store.load(packet_id) + start_head = packet_record.spec.checkout["head"] + + # Produce the packet range: one add, one delete. + (path / "added.txt").write_text("new content\n") + (path / "seed.txt").unlink() + subprocess.run(["git", "-C", str(path), "add", "-A"], check=True) + subprocess.run( + ["git", "-C", str(path), "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", + "commit", "--quiet", "-m", "packet range: add+delete"], + check=True, + ) + + # Use the real runner seal on a valid worker delivery report. + assert packet_record.result_path is not None + packet_record.result_path.parent.mkdir(parents=True, exist_ok=True) + packet_record.result_path.touch(mode=0o600) + worker_delivery = { + "anti_vacuity": True, + "unresolved_work": [], + "delegation": {"visibility": "unsupported", "pending": None}, + "deletion_evidence": ["seed.txt"], + "evidence_only": False, + } + packet_record.result_path.write_text(json.dumps(worker_delivery)) + _seal_packet_result( + {"job_id": packet_id, "checkout": {"head": start_head}}, path, packet_record.result_path + ) + sealed = json.loads(packet_record.result_path.read_text()) + final_head = sealed["final_head"] + + # Worker result was sealed by the real runner; mark the job succeeded. + jobs_module._write_private_marker(jobs_module._completion_marker_path(packet_record.log_path)) + systemd.properties = { + "LoadState": "loaded", "ActiveState": "inactive", "Result": "success", + "ExecMainStatus": "0", "InvocationID": "seal-compose-invocation", + } + assert jobs.get(packet_id)["state"]["phase"] == "succeeded" + + # Verifier job runs at final_head with the same binding. + verifier_response = service.dispatch(request( + "job.start", "systemd-jobs", + { + "project_id": "fixture", "operation": "check", + "workspace_id": workspace["workspace_id"], "bead_binding": binding, + }, + )) + assert verifier_response.ok and verifier_response.payload is not None + verifier_id = verifier_response.payload.inline["job_id"] + assert jobs.get(verifier_id)["state"]["phase"] == "succeeded" + + delivery_gate = GitHubDelivery(service.projects, service.workspaces, jobs) + + # Accepting path: real seal output + correct deletion evidence + verifier at final_head. + _workspace, _project, receipt = delivery_gate._verified_workspace( + workspace["workspace_id"], verifier_id, packet_id + ) + assert receipt["head"] == final_head + assert receipt["bead_ref"] == binding["bead_ref"] + + # Tamper: mutate the sealed envelope's final_head to a synthetic value. + tampered = {**sealed, "final_head": "b" * 40} + packet_record.result_path.write_text(json.dumps(tampered)) + with pytest.raises(DeliveryError): + delivery_gate._verified_workspace(workspace["workspace_id"], verifier_id, packet_id) + + # Restore and verify deletion overclaim is now rejected. + packet_record.result_path.write_text(json.dumps(sealed)) + overclaim = {**worker_delivery, "deletion_evidence": ["seed.txt", "unrelated.txt"]} + packet_record.result_path.write_text(json.dumps({**sealed, "delivery": overclaim})) + with pytest.raises(DeliveryError, match="deletion evidence"): + delivery_gate._verified_workspace(workspace["workspace_id"], verifier_id, packet_id) + + def test_admission_revalidates_queued_declared_workspace_before_systemd_launch(tmp_path: Path) -> None: """A queued declared service whose checkout HEAD moved must terminalize before it reaches systemd.""" write_adapter(tmp_path) From a772913a913d59af2a593f2e9969920076697e39 Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 25 Aug 2026 07:38:16 +0200 Subject: [PATCH 18/35] test(runtime): distinguish active admission fixtures --- pkgs/sinnixd/test_admission.py | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/pkgs/sinnixd/test_admission.py b/pkgs/sinnixd/test_admission.py index b7dae85f..c274f48f 100644 --- a/pkgs/sinnixd/test_admission.py +++ b/pkgs/sinnixd/test_admission.py @@ -106,9 +106,21 @@ def test_mixed_workload_injects_light_workers_and_queues_bulk(tmp_path: Path) -> subject = jobs(tmp_path, systemd) first = subject.start_declared(project=adapter, operation=adapter.operation("heavy"), correlation_id="one", parameters={}) - second = subject.start_declared(project=adapter, operation=adapter.operation("heavy"), correlation_id="two", parameters={}) + second = subject.start_declared( + project=adapter, + operation=adapter.operation("heavy"), + correlation_id="two", + principal="agent-control", + parameters={}, + ) light_a = subject.start_declared(project=adapter, operation=adapter.operation("light"), correlation_id="three", parameters={}) - light_b = subject.start_declared(project=adapter, operation=adapter.operation("light"), correlation_id="four", parameters={}) + light_b = subject.start_declared( + project=adapter, + operation=adapter.operation("light"), + correlation_id="four", + principal="agent-control", + parameters={}, + ) assert [entry["command"] for entry in systemd.started] == [("env", "heavy"), ("env", "light"), ("env", "light")] assert subject.get(second["job_id"])["state"]["phase"] == "queued" @@ -322,7 +334,13 @@ def test_queued_job_recreates_aged_scratch_before_launch(tmp_path: Path, monkeyp systemd = FakeSystemd() subject = jobs(tmp_path, systemd) first = subject.start_declared(project=adapter, operation=adapter.operation("heavy"), correlation_id="one", parameters={}) - queued = subject.start_declared(project=adapter, operation=adapter.operation("heavy"), correlation_id="two", parameters={}) + queued = subject.start_declared( + project=adapter, + operation=adapter.operation("heavy"), + correlation_id="two", + principal="agent-control", + parameters={}, + ) queued_record = subject.store.load(queued["job_id"]) assert queued["state"]["phase"] == "queued" assert queued_record.scratch_path is not None From eccb3427f31a38cced0d4b5c4ebdbbc40bf49aff Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 25 Aug 2026 14:50:22 +0200 Subject: [PATCH 19/35] test(runtime): include job scratch in launch contract --- flake/tests/agent-tools.nix | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/flake/tests/agent-tools.nix b/flake/tests/agent-tools.nix index 21752434..83e36829 100644 --- a/flake/tests/agent-tools.nix +++ b/flake/tests/agent-tools.nix @@ -762,9 +762,14 @@ in parameters={}, ) record = jobs.store.load(started["job_id"]) - expected = (*project.environment.command, *operation.command) + expected = project.environment.command_for( + operation.command, + overrides={"TMPDIR": str(jobs.store.scratch_path_for(operation.scratch, record.job_id))}, + ) + declared_command, _ = jobs.store.declared_launch(record.job_id) assert started["kind"] == "declared-operation" assert record.spec.timeout_seconds == 7200 + assert declared_command == expected assert systemd.started[0]["command"] == expected assert systemd.started[0]["timeout_seconds"] == 7200 PY From 47b90193fc4e6636da4cb6c65c9793f39710e128 Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 25 Aug 2026 16:54:47 +0200 Subject: [PATCH 20/35] style(runtime): normalize consolidated control plane --- README.md | 18 +- docs/agent-environment.md | 98 +- docs/agent-gateway.md | 97 +- docs/agent-hook-parity.md | 18 +- docs/agent-skill-executables.md | 32 +- docs/generated/agent-gateway-reference.md | 396 +- docs/local-ai-activation.md | 26 +- dots/_ai/skills/agent-gateway/SKILL.md | 1 + .../agent-runtime/scripts/run_agent_prompt.sh | 72 +- .../_ai/skills/chatgpt-conversations/SKILL.md | 1 + dots/_ai/skills/codebase-design/SKILL.md | 2 +- .../scripts/chrome-control.sh | 4 +- dots/_ai/skills/orchestrate/SKILL.md | 31 +- dots/_ai/skills/task-backend/SKILL.md | 6 +- dots/_ai/skills/writing-for-agents/SKILL.md | 5 +- flake/data/local-models.nix | 3 +- flake/tests/bd-dolt-authority.sh | 4 +- flake/tests/chrome-agent-window.sh | 10 +- flake/tests/runtime.nix | 6 +- modules/features/desktop/activitywatch.nix | 7 +- .../features/desktop/hyprland/bindings.nix | 4 +- .../services/weechat-log-sealer/seal_logs.py | 10 +- .../fixtures/v2-examples.json | 28 +- .../sinnix_agent_gateway/artifacts.py | 25 +- .../sinnix_agent_gateway/beads.py | 1865 +++++-- .../sinnix_agent_gateway/browser.py | 94 +- .../sinnix_agent_gateway/capability_index.py | 4 +- .../sinnix_agent_gateway/captures.py | 14 +- .../sinnix_agent_gateway/cli.py | 54 +- .../sinnix_agent_gateway/cli_support.py | 41 +- .../sinnix_agent_gateway/config.py | 32 +- .../sinnix_agent_gateway/contexts.py | 116 +- .../sinnix_agent_gateway/contracts.py | 34 +- .../sinnix_agent_gateway/desktop.py | 44 +- .../sinnix_agent_gateway/events.py | 268 +- .../sinnix_agent_gateway/files.py | 11 +- .../sinnix_agent_gateway/gateway_codegen.py | 66 +- .../sinnix_agent_gateway/legacy_manifest.py | 10 +- .../legacy_manifest_v1.json | 50 +- .../sinnix_agent_gateway/machine_actions.py | 13 +- .../sinnix_agent_gateway/mcp_broker.py | 96 +- .../sinnix_agent_gateway/memory.py | 22 +- .../sinnix_agent_gateway/observe.py | 3 +- .../sinnix_agent_gateway/parity.py | 31 +- .../sinnix_agent_gateway/project_context.py | 4 +- .../sinnix_agent_gateway/projects.py | 60 +- .../sinnix_agent_gateway/prompts.py | 49 +- .../sinnix_agent_gateway/registry.py | 908 +++- .../sinnix_agent_gateway/results.py | 58 +- .../sinnix_agent_gateway/route_preflight.py | 35 +- .../sinnix_agent_gateway/runtime.py | 1245 ++++- .../sinnix_agent_gateway/server.py | 495 +- .../sinnix_agent_gateway/sessions.py | 6 +- .../sinnix_agent_gateway/terminals.py | 35 +- .../sinnix_agent_gateway/timeline.py | 18 +- .../sinnix_agent_gateway/waits.py | 23 +- pkgs/sinnix-agent-gateway/test_beads.py | 460 +- pkgs/sinnix-agent-gateway/test_bindings.py | 84 +- pkgs/sinnix-agent-gateway/test_browser.py | 43 +- .../test_capability_index.py | 9 +- pkgs/sinnix-agent-gateway/test_captures.py | 71 +- pkgs/sinnix-agent-gateway/test_cli.py | 103 +- pkgs/sinnix-agent-gateway/test_contexts.py | 157 +- pkgs/sinnix-agent-gateway/test_desktop.py | 5 +- pkgs/sinnix-agent-gateway/test_events.py | 165 +- .../test_execution_jobs.py | 439 +- pkgs/sinnix-agent-gateway/test_files.py | 9 +- .../test_gateway_codegen.py | 23 +- .../test_machine_actions.py | 10 +- pkgs/sinnix-agent-gateway/test_mcp_broker.py | 73 +- pkgs/sinnix-agent-gateway/test_memory.py | 26 +- pkgs/sinnix-agent-gateway/test_parity.py | 22 +- .../test_project_authority.py | 32 +- .../test_project_context.py | 31 +- pkgs/sinnix-agent-gateway/test_prompts.py | 51 +- pkgs/sinnix-agent-gateway/test_registry.py | 185 +- pkgs/sinnix-agent-gateway/test_results.py | 100 +- .../test_route_preflight.py | 7 +- pkgs/sinnix-agent-gateway/test_sessions.py | 5 +- pkgs/sinnix-agent-gateway/test_smoke.py | 441 +- .../test_subscriptions.py | 2 - pkgs/sinnix-agent-gateway/test_terminals.py | 11 +- pkgs/sinnix-agent-gateway/test_timeline.py | 24 +- pkgs/sinnix-agent-gateway/test_waits.py | 41 +- .../tools/extract_legacy_gateway_manifest.py | 18 +- .../tools/generate_gateway_artifacts.py | 1 - pkgs/sinnix-mcp/sinnix_mcp/__init__.py | 2 +- pkgs/sinnix-mcp/sinnix_mcp/execution.py | 5 +- pkgs/sinnix-mcp/sinnix_mcp/owners.py | 26 +- pkgs/sinnix-mcp/sinnix_mcp/protocol.py | 58 +- pkgs/sinnix-mcp/sinnix_mcp/refs.py | 43 +- pkgs/sinnix-mcp/test_protocol.py | 1 - .../sinnix_observe/sources/agent_gateway.py | 31 +- pkgs/sinnix-observe/tests/test_smoke.py | 22 +- .../sinnix_ops_reducer/actions.py | 13 +- .../sinnix_ops_reducer/agent_jobs.py | 5 +- .../sinnix_ops_reducer/cli.py | 2 +- .../sinnix_ops_reducer/pages/probes.py | 2 + .../sinnix_ops_reducer/pages/work.py | 24 +- .../sinnix_ops_reducer/pressure.py | 8 +- pkgs/sinnix-ops-reducer/tests/test_actions.py | 4 +- .../tests/test_agent_jobs.py | 4 +- pkgs/sinnix-ops-reducer/tests/test_pages.py | 6 +- pkgs/sinnix-ops-reducer/tests/test_reducer.py | 4 +- pkgs/sinnixd/pkg.nix | 5 +- pkgs/sinnixd/sinnixd/api.py | 67 +- pkgs/sinnixd/sinnixd/cli.py | 171 +- pkgs/sinnixd/sinnixd/contracts.py | 103 +- pkgs/sinnixd/sinnixd/delivery.py | 256 +- pkgs/sinnixd/sinnixd/jobs.py | 1000 +++- pkgs/sinnixd/sinnixd/limits.py | 6 +- pkgs/sinnixd/sinnixd/owner_adapters.py | 96 +- pkgs/sinnixd/sinnixd/projects.py | 473 +- pkgs/sinnixd/sinnixd/runner.py | 76 +- pkgs/sinnixd/sinnixd/service.py | 389 +- pkgs/sinnixd/sinnixd/tasks.py | 1136 ++++- pkgs/sinnixd/sinnixd/workspaces.py | 772 ++- pkgs/sinnixd/test_admission.py | 403 +- pkgs/sinnixd/test_jobs_observation.py | 156 +- pkgs/sinnixd/test_service.py | 4341 ++++++++++++++--- 120 files changed, 14927 insertions(+), 4139 deletions(-) diff --git a/README.md b/README.md index cf8339a6..c79801b4 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ local data systems, and frequent operational changes. | Area | Current scope | | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Workstation | Hyprland and Noctalia desktop, Home Manager configuration, GPU modes, audio, terminal capture, local AI services, development environments, and desktop applications | -| Services | typed service inventory, systemd resource classes, monitoring, and common policy for user and system units | +| Services | typed service inventory, systemd resource classes, monitoring, and common policy for user and system units | | Local data | Sinex, Polylogue, Lynchpin, ActivityWatch, machine telemetry, shell history, and terminal recordings | | Storage and recovery | impermanence, explicit persistence, Btrfs snapshots, Borg archives, restore drills, and separate treatment for durable, rebuildable, and bulk data | | Agent tooling | shared instructions and skills, generated MCP profiles, browser and desktop control, local model backends, and a trusted repository gateway | @@ -131,15 +131,15 @@ nix develop Common commands: -| Command | Purpose | -| ------------- | ----------------------------------------------------------------------------- | -| `check` | run the curated default verification tier sequentially | -| `lint` | run static Nix and shell checks without modifying files | -| `format` | format supported source with treefmt | +| Command | Purpose | +| ------------- | ------------------------------------------------------------------------------ | +| `check` | run the curated default verification tier sequentially | +| `lint` | run static Nix and shell checks without modifying files | +| `format` | format supported source with treefmt | | `switch` | build and activate the workstation through the shared lock and resource policy | -| `boot` | build and register the next boot generation without activating it | -| `test-system` | test activation without changing the boot default | -| `test-vm` | build the NixOS VM smoke test | +| `boot` | build and register the next boot generation without activating it | +| `test-system` | test activation without changing the boot default | +| `test-vm` | build the NixOS VM smoke test | Direct commands stay direct. Submit scheduled or heavy work as a named AgentCTL project operation; `switch` remains the supported activation command. diff --git a/docs/agent-environment.md b/docs/agent-environment.md index 10800c63..3fc19c07 100644 --- a/docs/agent-environment.md +++ b/docs/agent-environment.md @@ -156,58 +156,58 @@ Servers: agent-control, context7, github, polylogue, sinex. ## MCP servers -| Server | Tier | Transport | Command or URL | Clients | -| --- | --- | --- | --- | --- | -| `agent-control` | `agent-control` | `stdio` | `sinnix-agent-control-mcp` | claude, codex, gemini, antigravity, hermes | -| `chrome-devtools` | `browser-mcp` | `stdio` | `mcp-chrome-devtools` | claude, codex, gemini | -| `context7` | `remote-core` | `http` | `https://mcp.context7.com/mcp` | claude, codex, gemini, antigravity, hermes | -| `firecrawl` | `browser-mcp` | `stdio` | `mcp-firecrawl` | claude, hermes | -| `github` | `remote-core` | `stdio` | `npx` | claude, codex, gemini, antigravity, hermes | -| `lynchpin` | `deep-evidence` | `stdio` | `mcp-lynchpin` | codex, claude, gemini, antigravity, hermes | -| `polylogue` | `recall` | `stdio` | `mcp-polylogue` | codex, claude, gemini, antigravity, hermes | -| `sinex` | `recall` | `stdio` | `mcp-sinex` | codex, claude, gemini, antigravity, hermes | +| Server | Tier | Transport | Command or URL | Clients | +| ----------------- | --------------- | --------- | ------------------------------ | ------------------------------------------ | +| `agent-control` | `agent-control` | `stdio` | `sinnix-agent-control-mcp` | claude, codex, gemini, antigravity, hermes | +| `chrome-devtools` | `browser-mcp` | `stdio` | `mcp-chrome-devtools` | claude, codex, gemini | +| `context7` | `remote-core` | `http` | `https://mcp.context7.com/mcp` | claude, codex, gemini, antigravity, hermes | +| `firecrawl` | `browser-mcp` | `stdio` | `mcp-firecrawl` | claude, hermes | +| `github` | `remote-core` | `stdio` | `npx` | claude, codex, gemini, antigravity, hermes | +| `lynchpin` | `deep-evidence` | `stdio` | `mcp-lynchpin` | codex, claude, gemini, antigravity, hermes | +| `polylogue` | `recall` | `stdio` | `mcp-polylogue` | codex, claude, gemini, antigravity, hermes | +| `sinex` | `recall` | `stdio` | `mcp-sinex` | codex, claude, gemini, antigravity, hermes | ## Agent definitions -| Name | Description | Model | Effort | -| --- | --- | --- | --- | -| `boilerplate-scribe` | | | `haiku` | `` | -| `judge` | Headless structured judge with an explicit refutation attempt and honest unsupported path. | `sonnet` | `high` | -| `lane` | Worktree-isolated implementation worker. Dispatch prompts carry only task scope and file ownership. | `sonnet` | `high` | -| `review` | Read-only adversarial reviewer that cites exact evidence and tests the strongest counterclaim. | `opus` | `high` | -| `test-coverage-sprint` | | | `sonnet` | `` | -| `triage` | Read-only evidence worker returning a closed structured verdict. | `haiku` | `medium` | +| Name | Description | Model | Effort | +| ---------------------- | --------------------------------------------------------------------------------------------------- | -------- | -------- | --- | +| `boilerplate-scribe` | | | `haiku` | `` | +| `judge` | Headless structured judge with an explicit refutation attempt and honest unsupported path. | `sonnet` | `high` | +| `lane` | Worktree-isolated implementation worker. Dispatch prompts carry only task scope and file ownership. | `sonnet` | `high` | +| `review` | Read-only adversarial reviewer that cites exact evidence and tests the strongest counterclaim. | `opus` | `high` | +| `test-coverage-sprint` | | | `sonnet` | `` | +| `triage` | Read-only evidence worker returning a closed structured verdict. | `haiku` | `medium` | ## Shared skills -| Skill | When to use | -| --- | --- | -| `agent-gateway` | Use when invoking, inspecting, or documenting Sinnix Agent Gateway V2 resources and actions through its ten-verb CLI or MCP contract. | -| `agent-runtime` | Operate or recover AgentCTL workspaces and jobs, including checkpoints, exact-head execution, logs, results, cancellation, cleanup, and checkpoint-based redispatch. | -| `analyze` | Interactive codebase analysis with user steering (survey → narrate → synthesize) | -| `android-device-control` | Control, configure, debloat, or capture from an unrooted Android phone through adb, Termux, tailnet access, and resilient UI automation, including Xiaomi power-management traps. | -| `bead-authoring` | Write or mature Beads tasks, specifications, acceptance criteria, dependency edges, and campaign slices so implementation can proceed without re-deriving intent. | -| `chatgpt-conversations` | Read, compare, summarize, or continue complete ChatGPT conversations already open in the operator's Chrome without navigating, activating, editing, submitting, or closing tabs. | -| `claude-self-knowledge` | Verify Claude model and harness capabilities, choose dispatch mechanics, locate local state, explain Claude Code behavior, or diagnose compaction, notification, permission, and model-resolution surprises. | -| `claude-sessions` | Extract readable prose from raw Claude Code session JSONL when Polylogue is unavailable, including bounded user and assistant text, optional thinking, and tool summaries. | -| `codebase-design` | Design or restructure modules, interfaces, seams, and adapters; assess module depth; or decide whether apparently unused code should be completed or removed. | -| `desktop-control-plane` | "Control desktop/runtime surfaces for operator workflows: Kitty remote I/O, Hyprland dispatch/inspection, and screenshot diagnostics/workarounds (including HDR washout handling). Use when coding agents need reliable computer-use primitives on Linux Wayland/Hyprland systems." | -| `drive` | Drive autonomous iterative work when the user says keep going, iterate, or take it further: choose the best next move, stress-test it, execute, externalize results, and continue. | -| `enhance` | Rewrite rough requests into high-leverage prompts while preserving intent. Use for quick prompt polishing, executable code/research prompts, external-agent handoffs, or non-overlapping prompt portfolios with fresh context and honest deliverable contracts. | -| `enrichment-pass` | Process a Sinnix runtime, shell, session, Lynchpin, and journal state bundle into a versioned narrative and structured state delta for headless enrichment. | -| `grilling` | Grill the user relentlessly about a plan or design. Use when the user wants to stress-test a plan before building, or uses any 'grill' trigger phrases. | -| `grok` | Audit an entire codebase systematically by measuring and partitioning it, dispatching tiered review lanes, triaging recurring defect patterns, and filing concrete findings when one context cannot cover the target. | -| `html-report` | Produce self-contained interactive HTML reports, reviews, censuses, dashboards, plans, incident timelines, or comparisons for human reading, including workshop-style Claude Artifacts. | -| `investigate` | Investigate bugs, regressions, incidents, performance problems, missing artifacts, and contested claims through reproduction, measurement, evidence preservation, and direct verification. | -| `lynchpin` | Query or develop Lynchpin evidence sources, materialization, DuckDB substrate generations, graphs, analyses, Chisel reports, Polylogue boundaries, and MCP evidence products. | -| `meta` | Meta-level introspection - analyze session, improve setup, persist learnings | -| `orchestrate` | Orchestrate parallel agent implementation, research, or continuous queue work through explicit ownership, model selection, AgentCTL jobs, structural review, and one integrated batch. | -| `polylogue` | Query or develop Polylogue session archives, ingestion, storage tiers, lineage, CLI, MCP, daemon convergence, devtools verification, or historical work reconstruction. | -| `prompting` | Write, review, or diagnose nontrivial prompts for subagents, external models, workflow stages, headless judgment, MCP or skill instructions, reusable templates, and agent definitions. | -| `recap` | Refresh stale session context or prepare a concise handoff after compaction or interruption when current work, decisions, evidence, blockers, and the next action are unclear. | -| `review-land` | Review code or prose, audit acceptance criteria, resolve conflicts, commit, publish, merge, and close work through the repository's verified landing discipline. | -| `skill-authoring` | Design, validate, update, and retire routed Codex skills. Use when creating a skill, repairing weak routing metadata, adding references, or deciding whether an older skill is superseded. | -| `task-backend` | Read or mutate durable Beads task state: find ready work, claim, note, relate, create, complete, release, reconcile, and snapshot registered project tasks. | -| `vocabulary` | Clarify disputed or overloaded terminology, maintain a repository glossary, prevent unnecessary jargon, and record hard-to-reverse vocabulary decisions. | -| `writing-for-agents` | Write or revise skills, CLAUDE.md/AGENTS.md, memory files, and agent-facing references, especially when instructions are stale, bloated, weakly routed, or ignored. | -| `writing-style` | Use when writing or editing GitHub issues, pull requests, review comments, commit messages, chat replies, or prose documentation. | +| Skill | When to use | +| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `agent-gateway` | Use when invoking, inspecting, or documenting Sinnix Agent Gateway V2 resources and actions through its ten-verb CLI or MCP contract. | +| `agent-runtime` | Operate or recover AgentCTL workspaces and jobs, including checkpoints, exact-head execution, logs, results, cancellation, cleanup, and checkpoint-based redispatch. | +| `analyze` | Interactive codebase analysis with user steering (survey → narrate → synthesize) | +| `android-device-control` | Control, configure, debloat, or capture from an unrooted Android phone through adb, Termux, tailnet access, and resilient UI automation, including Xiaomi power-management traps. | +| `bead-authoring` | Write or mature Beads tasks, specifications, acceptance criteria, dependency edges, and campaign slices so implementation can proceed without re-deriving intent. | +| `chatgpt-conversations` | Read, compare, summarize, or continue complete ChatGPT conversations already open in the operator's Chrome without navigating, activating, editing, submitting, or closing tabs. | +| `claude-self-knowledge` | Verify Claude model and harness capabilities, choose dispatch mechanics, locate local state, explain Claude Code behavior, or diagnose compaction, notification, permission, and model-resolution surprises. | +| `claude-sessions` | Extract readable prose from raw Claude Code session JSONL when Polylogue is unavailable, including bounded user and assistant text, optional thinking, and tool summaries. | +| `codebase-design` | Design or restructure modules, interfaces, seams, and adapters; assess module depth; or decide whether apparently unused code should be completed or removed. | +| `desktop-control-plane` | "Control desktop/runtime surfaces for operator workflows: Kitty remote I/O, Hyprland dispatch/inspection, and screenshot diagnostics/workarounds (including HDR washout handling). Use when coding agents need reliable computer-use primitives on Linux Wayland/Hyprland systems." | +| `drive` | Drive autonomous iterative work when the user says keep going, iterate, or take it further: choose the best next move, stress-test it, execute, externalize results, and continue. | +| `enhance` | Rewrite rough requests into high-leverage prompts while preserving intent. Use for quick prompt polishing, executable code/research prompts, external-agent handoffs, or non-overlapping prompt portfolios with fresh context and honest deliverable contracts. | +| `enrichment-pass` | Process a Sinnix runtime, shell, session, Lynchpin, and journal state bundle into a versioned narrative and structured state delta for headless enrichment. | +| `grilling` | Grill the user relentlessly about a plan or design. Use when the user wants to stress-test a plan before building, or uses any 'grill' trigger phrases. | +| `grok` | Audit an entire codebase systematically by measuring and partitioning it, dispatching tiered review lanes, triaging recurring defect patterns, and filing concrete findings when one context cannot cover the target. | +| `html-report` | Produce self-contained interactive HTML reports, reviews, censuses, dashboards, plans, incident timelines, or comparisons for human reading, including workshop-style Claude Artifacts. | +| `investigate` | Investigate bugs, regressions, incidents, performance problems, missing artifacts, and contested claims through reproduction, measurement, evidence preservation, and direct verification. | +| `lynchpin` | Query or develop Lynchpin evidence sources, materialization, DuckDB substrate generations, graphs, analyses, Chisel reports, Polylogue boundaries, and MCP evidence products. | +| `meta` | Meta-level introspection - analyze session, improve setup, persist learnings | +| `orchestrate` | Orchestrate parallel agent implementation, research, or continuous queue work through explicit ownership, model selection, AgentCTL jobs, structural review, and one integrated batch. | +| `polylogue` | Query or develop Polylogue session archives, ingestion, storage tiers, lineage, CLI, MCP, daemon convergence, devtools verification, or historical work reconstruction. | +| `prompting` | Write, review, or diagnose nontrivial prompts for subagents, external models, workflow stages, headless judgment, MCP or skill instructions, reusable templates, and agent definitions. | +| `recap` | Refresh stale session context or prepare a concise handoff after compaction or interruption when current work, decisions, evidence, blockers, and the next action are unclear. | +| `review-land` | Review code or prose, audit acceptance criteria, resolve conflicts, commit, publish, merge, and close work through the repository's verified landing discipline. | +| `skill-authoring` | Design, validate, update, and retire routed Codex skills. Use when creating a skill, repairing weak routing metadata, adding references, or deciding whether an older skill is superseded. | +| `task-backend` | Read or mutate durable Beads task state: find ready work, claim, note, relate, create, complete, release, reconcile, and snapshot registered project tasks. | +| `vocabulary` | Clarify disputed or overloaded terminology, maintain a repository glossary, prevent unnecessary jargon, and record hard-to-reverse vocabulary decisions. | +| `writing-for-agents` | Write or revise skills, CLAUDE.md/AGENTS.md, memory files, and agent-facing references, especially when instructions are stale, bloated, weakly routed, or ignored. | +| `writing-style` | Use when writing or editing GitHub issues, pull requests, review comments, commit messages, chat replies, or prose documentation. | diff --git a/docs/agent-gateway.md b/docs/agent-gateway.md index 453af4cc..e4f15b61 100644 --- a/docs/agent-gateway.md +++ b/docs/agent-gateway.md @@ -28,11 +28,11 @@ The gateway owns no HTTP server and no listening port. The official OpenAI tunne ## Principals -| Principal | Intended caller | Read projects, jobs, artifacts, audit, machine | Launch and cancel jobs | Write projects | -| --- | --- | --- | --- | --- | -| `observer` | Read-only ChatGPT connector and local inspection | Yes, for projects that opt into `observerRead` | No | No | -| `agent-control` | Trusted local coordinators | Yes | Yes | No | -| `operator` | Local testing and a write-capable remote workspace | Yes | Yes | Yes | +| Principal | Intended caller | Read projects, jobs, artifacts, audit, machine | Launch and cancel jobs | Write projects | +| --------------- | -------------------------------------------------- | ---------------------------------------------- | ---------------------- | -------------- | +| `observer` | Read-only ChatGPT connector and local inspection | Yes, for projects that opt into `observerRead` | No | No | +| `agent-control` | Trusted local coordinators | Yes | Yes | No | +| `operator` | Local testing and a write-capable remote workspace | Yes | Yes | Yes | The ten protocol verb names remain stable in `tools/list` for every principal. The principal-filtered catalog omits unauthorized actions, and direct calls to an effectful verb fail with `policy_denied` before any owner callback is dispatched. The underlying service enforces the same capability again. `observer` therefore cannot obtain a write path through `change`, `operate`, or `run`. @@ -145,55 +145,58 @@ The private operator runtime key is the agenix secret `openai-tunnel-runtime-key 6. Record observed connector tool names and manifest hash for each enabled endpoint. This observation is required before claiming connector parity. Repeat the proof independently when the operator endpoint is provisioned and enabled. The old prototype state may be retained under the canonical state root's `legacy/` directory for forensic inspection. It must not be loaded as active jobs, artifacts, repositories, tasks, or audit data. + + ## Generated V2 reference This section is generated from the canonical gateway registry. Revision `v2-g2.10-context-events`, catalog SHA-256 `65c2cba708186a4858fca7f3750ea9366782f3072272410a99e6b7dac61100d2`. The full schemas and executable examples are in [the generated gateway reference](generated/agent-gateway-reference.md). The matching agent skill is [agent-gateway](../dots/_ai/skills/agent-gateway/SKILL.md). -| Action | Verb | Owner | Route | Schema | -| --- | --- | --- | --- | --- | -| `gateway.status` | `status` | `gateway` | `observe.gateway_status` | [`sinnix://gateway/v2/actions/gateway.status`](sinnix://gateway/v2/actions/gateway.status) | -| `gateway.catalog` | `catalog` | `registry` | `registry.search` | [`sinnix://gateway/v2/actions/gateway.catalog`](sinnix://gateway/v2/actions/gateway.catalog) | -| `resources.get` | `get` | `resolver` | `resources.get` | [`sinnix://gateway/v2/actions/resources.get`](sinnix://gateway/v2/actions/resources.get) | -| `projects.query` | `query` | `projects` | `projects.search` | [`sinnix://gateway/v2/actions/projects.query`](sinnix://gateway/v2/actions/projects.query) | -| `beads.query` | `query` | `beads` | `beads.query` | [`sinnix://gateway/v2/actions/beads.query`](sinnix://gateway/v2/actions/beads.query) | -| `projects.context` | `context` | `project-context` | `project_context.context` | [`sinnix://gateway/v2/actions/projects.context`](sinnix://gateway/v2/actions/projects.context) | -| `audit.events` | `events` | `audit` | `audit.tail` | [`sinnix://gateway/v2/actions/audit.events`](sinnix://gateway/v2/actions/audit.events) | -| `jobs.wait` | `wait` | `systemd-jobs` | `job.wait` | [`sinnix://gateway/v2/actions/jobs.wait`](sinnix://gateway/v2/actions/jobs.wait) | -| `projects.change` | `change` | `projects` | `projects.change` | [`sinnix://gateway/v2/actions/projects.change`](sinnix://gateway/v2/actions/projects.change) | -| `files.change` | `change` | `files` | `files.change` | [`sinnix://gateway/v2/actions/files.change`](sinnix://gateway/v2/actions/files.change) | -| `beads.change` | `change` | `beads` | `beads.write` | [`sinnix://gateway/v2/actions/beads.change`](sinnix://gateway/v2/actions/beads.change) | -| `beads.changeset` | `change` | `beads` | `beads.changeset` | [`sinnix://gateway/v2/actions/beads.changeset`](sinnix://gateway/v2/actions/beads.changeset) | -| `beads.operate` | `operate` | `beads` | `beads.maintenance` | [`sinnix://gateway/v2/actions/beads.operate`](sinnix://gateway/v2/actions/beads.operate) | -| `mcp.change` | `change` | `mcp-broker` | `mcp.call.write` | [`sinnix://gateway/v2/actions/mcp.change`](sinnix://gateway/v2/actions/mcp.change) | -| `machine.operate` | `operate` | `ops-reducer` | `ops.actions.execute` | [`sinnix://gateway/v2/actions/machine.operate`](sinnix://gateway/v2/actions/machine.operate) | -| `operations.run` | `run` | `systemd-jobs` | `job.start` | [`sinnix://gateway/v2/actions/operations.run`](sinnix://gateway/v2/actions/operations.run) | -| `agent.for_bead` | `run` | `systemd-jobs` | `job.agent.start` | [`sinnix://gateway/v2/actions/agent.for_bead`](sinnix://gateway/v2/actions/agent.for_bead) | -| `jobs.cancel` | `operate` | `systemd-jobs` | `job.cancel` | [`sinnix://gateway/v2/actions/jobs.cancel`](sinnix://gateway/v2/actions/jobs.cancel) | -| `desktop.operate` | `operate` | `desktop` | `desktop.action` | [`sinnix://gateway/v2/actions/desktop.operate`](sinnix://gateway/v2/actions/desktop.operate) | -| `terminals.operate` | `operate` | `terminals` | `terminals.action` | [`sinnix://gateway/v2/actions/terminals.operate`](sinnix://gateway/v2/actions/terminals.operate) | -| `browser.operate` | `operate` | `browser` | `browser.action` | [`sinnix://gateway/v2/actions/browser.operate`](sinnix://gateway/v2/actions/browser.operate) | -| `shell.run` | `run` | `systemd-jobs` | `job.shell.start` | [`sinnix://gateway/v2/actions/shell.run`](sinnix://gateway/v2/actions/shell.run) | -| `projects.list` | `query` | `projects` | `projects.list` | [`sinnix://gateway/v2/actions/projects.list`](sinnix://gateway/v2/actions/projects.list) | -| `projects.tree` | `query` | `projects` | `projects.tree` | [`sinnix://gateway/v2/actions/projects.tree`](sinnix://gateway/v2/actions/projects.tree) | -| `projects.read` | `query` | `projects` | `projects.read` | [`sinnix://gateway/v2/actions/projects.read`](sinnix://gateway/v2/actions/projects.read) | -| `projects.diff` | `query` | `projects` | `projects.diff` | [`sinnix://gateway/v2/actions/projects.diff`](sinnix://gateway/v2/actions/projects.diff) | -| `machine.query` | `query` | `machine` | `observe.machine_query` | [`sinnix://gateway/v2/actions/machine.query`](sinnix://gateway/v2/actions/machine.query) | -| `capabilities.query` | `query` | `capability-index` | `capability_index.query` | [`sinnix://gateway/v2/actions/capabilities.query`](sinnix://gateway/v2/actions/capabilities.query) | -| `mcp.query` | `query` | `mcp-broker` | `mcp.call.read` | [`sinnix://gateway/v2/actions/mcp.query`](sinnix://gateway/v2/actions/mcp.query) | -| `desktop.query` | `query` | `desktop` | `desktop.read` | [`sinnix://gateway/v2/actions/desktop.query`](sinnix://gateway/v2/actions/desktop.query) | -| `terminals.query` | `query` | `terminals` | `terminals.read` | [`sinnix://gateway/v2/actions/terminals.query`](sinnix://gateway/v2/actions/terminals.query) | -| `browser.query` | `query` | `browser` | `browser.read` | [`sinnix://gateway/v2/actions/browser.query`](sinnix://gateway/v2/actions/browser.query) | -| `files.query` | `query` | `files` | `files.read` | [`sinnix://gateway/v2/actions/files.query`](sinnix://gateway/v2/actions/files.query) | -| `sessions.query` | `query` | `sessions` | `sessions.query` | [`sinnix://gateway/v2/actions/sessions.query`](sinnix://gateway/v2/actions/sessions.query) | -| `memory.query` | `query` | `memory` | `memory.query` | [`sinnix://gateway/v2/actions/memory.query`](sinnix://gateway/v2/actions/memory.query) | -| `timeline.query` | `query` | `timeline` | `timeline.query` | [`sinnix://gateway/v2/actions/timeline.query`](sinnix://gateway/v2/actions/timeline.query) | -| `artifacts.query` | `query` | `artifacts` | `artifacts.query` | [`sinnix://gateway/v2/actions/artifacts.query`](sinnix://gateway/v2/actions/artifacts.query) | -| `audit.verify` | `query` | `audit` | `audit.verify` | [`sinnix://gateway/v2/actions/audit.verify`](sinnix://gateway/v2/actions/audit.verify) | -| `captures.query` | `query` | `captures` | `captures.query` | [`sinnix://gateway/v2/actions/captures.query`](sinnix://gateway/v2/actions/captures.query) | -| `jobs.query` | `query` | `systemd-jobs` | `job.list` | [`sinnix://gateway/v2/actions/jobs.query`](sinnix://gateway/v2/actions/jobs.query) | +| Action | Verb | Owner | Route | Schema | +| -------------------- | --------- | ------------------ | ------------------------- | -------------------------------------------------------------------------------------------------- | +| `gateway.status` | `status` | `gateway` | `observe.gateway_status` | [`sinnix://gateway/v2/actions/gateway.status`](sinnix://gateway/v2/actions/gateway.status) | +| `gateway.catalog` | `catalog` | `registry` | `registry.search` | [`sinnix://gateway/v2/actions/gateway.catalog`](sinnix://gateway/v2/actions/gateway.catalog) | +| `resources.get` | `get` | `resolver` | `resources.get` | [`sinnix://gateway/v2/actions/resources.get`](sinnix://gateway/v2/actions/resources.get) | +| `projects.query` | `query` | `projects` | `projects.search` | [`sinnix://gateway/v2/actions/projects.query`](sinnix://gateway/v2/actions/projects.query) | +| `beads.query` | `query` | `beads` | `beads.query` | [`sinnix://gateway/v2/actions/beads.query`](sinnix://gateway/v2/actions/beads.query) | +| `projects.context` | `context` | `project-context` | `project_context.context` | [`sinnix://gateway/v2/actions/projects.context`](sinnix://gateway/v2/actions/projects.context) | +| `audit.events` | `events` | `audit` | `audit.tail` | [`sinnix://gateway/v2/actions/audit.events`](sinnix://gateway/v2/actions/audit.events) | +| `jobs.wait` | `wait` | `systemd-jobs` | `job.wait` | [`sinnix://gateway/v2/actions/jobs.wait`](sinnix://gateway/v2/actions/jobs.wait) | +| `projects.change` | `change` | `projects` | `projects.change` | [`sinnix://gateway/v2/actions/projects.change`](sinnix://gateway/v2/actions/projects.change) | +| `files.change` | `change` | `files` | `files.change` | [`sinnix://gateway/v2/actions/files.change`](sinnix://gateway/v2/actions/files.change) | +| `beads.change` | `change` | `beads` | `beads.write` | [`sinnix://gateway/v2/actions/beads.change`](sinnix://gateway/v2/actions/beads.change) | +| `beads.changeset` | `change` | `beads` | `beads.changeset` | [`sinnix://gateway/v2/actions/beads.changeset`](sinnix://gateway/v2/actions/beads.changeset) | +| `beads.operate` | `operate` | `beads` | `beads.maintenance` | [`sinnix://gateway/v2/actions/beads.operate`](sinnix://gateway/v2/actions/beads.operate) | +| `mcp.change` | `change` | `mcp-broker` | `mcp.call.write` | [`sinnix://gateway/v2/actions/mcp.change`](sinnix://gateway/v2/actions/mcp.change) | +| `machine.operate` | `operate` | `ops-reducer` | `ops.actions.execute` | [`sinnix://gateway/v2/actions/machine.operate`](sinnix://gateway/v2/actions/machine.operate) | +| `operations.run` | `run` | `systemd-jobs` | `job.start` | [`sinnix://gateway/v2/actions/operations.run`](sinnix://gateway/v2/actions/operations.run) | +| `agent.for_bead` | `run` | `systemd-jobs` | `job.agent.start` | [`sinnix://gateway/v2/actions/agent.for_bead`](sinnix://gateway/v2/actions/agent.for_bead) | +| `jobs.cancel` | `operate` | `systemd-jobs` | `job.cancel` | [`sinnix://gateway/v2/actions/jobs.cancel`](sinnix://gateway/v2/actions/jobs.cancel) | +| `desktop.operate` | `operate` | `desktop` | `desktop.action` | [`sinnix://gateway/v2/actions/desktop.operate`](sinnix://gateway/v2/actions/desktop.operate) | +| `terminals.operate` | `operate` | `terminals` | `terminals.action` | [`sinnix://gateway/v2/actions/terminals.operate`](sinnix://gateway/v2/actions/terminals.operate) | +| `browser.operate` | `operate` | `browser` | `browser.action` | [`sinnix://gateway/v2/actions/browser.operate`](sinnix://gateway/v2/actions/browser.operate) | +| `shell.run` | `run` | `systemd-jobs` | `job.shell.start` | [`sinnix://gateway/v2/actions/shell.run`](sinnix://gateway/v2/actions/shell.run) | +| `projects.list` | `query` | `projects` | `projects.list` | [`sinnix://gateway/v2/actions/projects.list`](sinnix://gateway/v2/actions/projects.list) | +| `projects.tree` | `query` | `projects` | `projects.tree` | [`sinnix://gateway/v2/actions/projects.tree`](sinnix://gateway/v2/actions/projects.tree) | +| `projects.read` | `query` | `projects` | `projects.read` | [`sinnix://gateway/v2/actions/projects.read`](sinnix://gateway/v2/actions/projects.read) | +| `projects.diff` | `query` | `projects` | `projects.diff` | [`sinnix://gateway/v2/actions/projects.diff`](sinnix://gateway/v2/actions/projects.diff) | +| `machine.query` | `query` | `machine` | `observe.machine_query` | [`sinnix://gateway/v2/actions/machine.query`](sinnix://gateway/v2/actions/machine.query) | +| `capabilities.query` | `query` | `capability-index` | `capability_index.query` | [`sinnix://gateway/v2/actions/capabilities.query`](sinnix://gateway/v2/actions/capabilities.query) | +| `mcp.query` | `query` | `mcp-broker` | `mcp.call.read` | [`sinnix://gateway/v2/actions/mcp.query`](sinnix://gateway/v2/actions/mcp.query) | +| `desktop.query` | `query` | `desktop` | `desktop.read` | [`sinnix://gateway/v2/actions/desktop.query`](sinnix://gateway/v2/actions/desktop.query) | +| `terminals.query` | `query` | `terminals` | `terminals.read` | [`sinnix://gateway/v2/actions/terminals.query`](sinnix://gateway/v2/actions/terminals.query) | +| `browser.query` | `query` | `browser` | `browser.read` | [`sinnix://gateway/v2/actions/browser.query`](sinnix://gateway/v2/actions/browser.query) | +| `files.query` | `query` | `files` | `files.read` | [`sinnix://gateway/v2/actions/files.query`](sinnix://gateway/v2/actions/files.query) | +| `sessions.query` | `query` | `sessions` | `sessions.query` | [`sinnix://gateway/v2/actions/sessions.query`](sinnix://gateway/v2/actions/sessions.query) | +| `memory.query` | `query` | `memory` | `memory.query` | [`sinnix://gateway/v2/actions/memory.query`](sinnix://gateway/v2/actions/memory.query) | +| `timeline.query` | `query` | `timeline` | `timeline.query` | [`sinnix://gateway/v2/actions/timeline.query`](sinnix://gateway/v2/actions/timeline.query) | +| `artifacts.query` | `query` | `artifacts` | `artifacts.query` | [`sinnix://gateway/v2/actions/artifacts.query`](sinnix://gateway/v2/actions/artifacts.query) | +| `audit.verify` | `query` | `audit` | `audit.verify` | [`sinnix://gateway/v2/actions/audit.verify`](sinnix://gateway/v2/actions/audit.verify) | +| `captures.query` | `query` | `captures` | `captures.query` | [`sinnix://gateway/v2/actions/captures.query`](sinnix://gateway/v2/actions/captures.query) | +| `jobs.query` | `query` | `systemd-jobs` | `job.list` | [`sinnix://gateway/v2/actions/jobs.query`](sinnix://gateway/v2/actions/jobs.query) | Direct-owner fallback semantics: `bd 1.1.0-dev` uses the canonical standalone Dolt workspace resolved through the canonical worktree and `.beads/redirect`; Dolt remains authoritative, JSONL is an optional export, and snapshot publication is explicit through `beads.operate` with `snapshot.publish`. + diff --git a/docs/agent-hook-parity.md b/docs/agent-hook-parity.md index 9a7329e1..1c52b331 100644 --- a/docs/agent-hook-parity.md +++ b/docs/agent-hook-parity.md @@ -2,14 +2,14 @@ This matrix records the current boundary between Claude Code and Codex hooks. It is reviewed against the generated Codex file at each configuration change. -| Capability | Claude Code | Codex | Evidence and action | -| --- | --- | --- | --- | -| Session start and resume | Enforced | Enforced | Both clients run configured recall and Polylogue session capture. | -| User prompt capture | Enforced | Enforced | Both clients send `UserPromptSubmit` to Polylogue. | -| Pre-compaction handoff | Enforced | Enforced | Both clients run `sinnix-context-handoff`. | -| Tool-use evidence | Enforced | Enforced | Both clients send tool events to Polylogue. | -| Stop capture | Enforced | Enforced | Both clients capture the stop event through Polylogue. | -| Agent model dispatch guard | Enforced | Manual | Claude has the structured `Agent` matcher and `pretooluse-agent-model.sh`; Codex applies the explicit-model contract at AgentCTL launch. | -| Destructive Bash guard | Enforced | Manual | Claude has the structured `Bash` matcher and `pretooluse-bash.sh`; Codex follows the shared Git protocol. | +| Capability | Claude Code | Codex | Evidence and action | +| -------------------------- | ----------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| Session start and resume | Enforced | Enforced | Both clients run configured recall and Polylogue session capture. | +| User prompt capture | Enforced | Enforced | Both clients send `UserPromptSubmit` to Polylogue. | +| Pre-compaction handoff | Enforced | Enforced | Both clients run `sinnix-context-handoff`. | +| Tool-use evidence | Enforced | Enforced | Both clients send tool events to Polylogue. | +| Stop capture | Enforced | Enforced | Both clients capture the stop event through Polylogue. | +| Agent model dispatch guard | Enforced | Manual | Claude has the structured `Agent` matcher and `pretooluse-agent-model.sh`; Codex applies the explicit-model contract at AgentCTL launch. | +| Destructive Bash guard | Enforced | Manual | Claude has the structured `Bash` matcher and `pretooluse-bash.sh`; Codex follows the shared Git protocol. | The generated Codex configuration is the authority for Codex-supported rows. This document is a parity record, not a second hook registry. diff --git a/docs/agent-skill-executables.md b/docs/agent-skill-executables.md index f5fcdc17..590d8f84 100644 --- a/docs/agent-skill-executables.md +++ b/docs/agent-skill-executables.md @@ -2,19 +2,19 @@ This audit records every executable beneath `dots/_ai/skills`. AgentCTL is the only retained lifecycle owner. The allowed native runner is a private argv translator called by Sinnixd; no skill executable owns job state, cancellation, retries, manifests, or process cleanup. -| Executable | Positive caller | Retained role | -| --- | --- | --- | -| `orchestrate/scripts/build_plan_batch_prompts.py` | `orchestrate` skill | Writes operator-reviewed prompt files only. | -| `orchestrate/scripts/probe_agent_runtime.sh` | `orchestrate` skill and runtime-modes reference | Direct vendor and quota availability probe. | -| `agent-runtime/scripts/run_agent_prompt.sh` | Sinnixd service contract | Private backend argv translator for an attested job. | -| `chatgpt-conversations/scripts/sinnix-chatgpt-conversations` | `chatgpt-conversations` skill | Read-only conversation extraction from the shared browser. | -| `desktop-control-plane/scripts/chrome-control.sh` | Agent module and desktop-control-plane skill | Visible browser UI helper. | -| `desktop-control-plane/scripts/hypr-control.sh` | Agent module and desktop-control-plane skill | Visible window-manager UI helper. | -| `desktop-control-plane/scripts/keyboard-control.sh` | Agent module | Explicit keyboard UI helper. | -| `desktop-control-plane/scripts/kitty-remote-control.sh` | Agent module and desktop-control-plane skill | Visible terminal UI helper. | -| `desktop-control-plane/scripts/screenshot-color-lab.sh` | Agent module and capture registry | Screenshot and display diagnostic helper. | -| `grok/scripts/partition_by_size.sh` | `grok` skill | Measured source partitioning for a code audit. | -| `html-report/generators/embed-path-popups.py` | `html-report` skill | HTML report navigation generator. | -| `investigate/scripts/freeze.sh` | `investigate` skill | Evidence preservation before recovery mutation. | -| `investigate/scripts/recover-probe.sh` | `investigate` skill | Read-only recovery authority probe. | -| `skill-authoring/scripts/validate_skill.py` | `skill-authoring` skill and agent-tools check | Skill metadata and structure validation. | +| Executable | Positive caller | Retained role | +| ------------------------------------------------------------ | ----------------------------------------------- | ---------------------------------------------------------- | +| `orchestrate/scripts/build_plan_batch_prompts.py` | `orchestrate` skill | Writes operator-reviewed prompt files only. | +| `orchestrate/scripts/probe_agent_runtime.sh` | `orchestrate` skill and runtime-modes reference | Direct vendor and quota availability probe. | +| `agent-runtime/scripts/run_agent_prompt.sh` | Sinnixd service contract | Private backend argv translator for an attested job. | +| `chatgpt-conversations/scripts/sinnix-chatgpt-conversations` | `chatgpt-conversations` skill | Read-only conversation extraction from the shared browser. | +| `desktop-control-plane/scripts/chrome-control.sh` | Agent module and desktop-control-plane skill | Visible browser UI helper. | +| `desktop-control-plane/scripts/hypr-control.sh` | Agent module and desktop-control-plane skill | Visible window-manager UI helper. | +| `desktop-control-plane/scripts/keyboard-control.sh` | Agent module | Explicit keyboard UI helper. | +| `desktop-control-plane/scripts/kitty-remote-control.sh` | Agent module and desktop-control-plane skill | Visible terminal UI helper. | +| `desktop-control-plane/scripts/screenshot-color-lab.sh` | Agent module and capture registry | Screenshot and display diagnostic helper. | +| `grok/scripts/partition_by_size.sh` | `grok` skill | Measured source partitioning for a code audit. | +| `html-report/generators/embed-path-popups.py` | `html-report` skill | HTML report navigation generator. | +| `investigate/scripts/freeze.sh` | `investigate` skill | Evidence preservation before recovery mutation. | +| `investigate/scripts/recover-probe.sh` | `investigate` skill | Read-only recovery authority probe. | +| `skill-authoring/scripts/validate_skill.py` | `skill-authoring` skill and agent-tools check | Skill metadata and structure validation. | diff --git a/docs/generated/agent-gateway-reference.md b/docs/generated/agent-gateway-reference.md index f11588f2..317ab949 100644 --- a/docs/generated/agent-gateway-reference.md +++ b/docs/generated/agent-gateway-reference.md @@ -1,6 +1,7 @@ + # Sinnix Agent Gateway V2 reference This reference is generated from `sinnix_agent_gateway.registry.REGISTRY`. The catalog hash changes when an action, resource, schema, route, principal, bound, or example changes. @@ -11,88 +12,88 @@ Revision: `v2-g2.10-context-events`. Catalog SHA-256: `65c2cba708186a4858fca7f37 Each verb calls the matching MCP tool through the same runtime and principal. Requests accept `--input`, `--input-file`, or `--stdin`; common request controls can be supplied as flags. Mutating requests require the idempotency key declared by their selected action. -| Verb | CLI subcommand | MCP tool | -| --- | --- | --- | -| `status` | `sinnix-agent-gateway status` | `status` | +| Verb | CLI subcommand | MCP tool | +| --------- | ------------------------------ | --------- | +| `status` | `sinnix-agent-gateway status` | `status` | | `catalog` | `sinnix-agent-gateway catalog` | `catalog` | -| `query` | `sinnix-agent-gateway query` | `query` | -| `get` | `sinnix-agent-gateway get` | `get` | +| `query` | `sinnix-agent-gateway query` | `query` | +| `get` | `sinnix-agent-gateway get` | `get` | | `context` | `sinnix-agent-gateway context` | `context` | -| `events` | `sinnix-agent-gateway events` | `events` | -| `wait` | `sinnix-agent-gateway wait` | `wait` | -| `change` | `sinnix-agent-gateway change` | `change` | +| `events` | `sinnix-agent-gateway events` | `events` | +| `wait` | `sinnix-agent-gateway wait` | `wait` | +| `change` | `sinnix-agent-gateway change` | `change` | | `operate` | `sinnix-agent-gateway operate` | `operate` | -| `run` | `sinnix-agent-gateway run` | `run` | +| `run` | `sinnix-agent-gateway run` | `run` | ## Resources -| Resource | Owner | Canonical reference | Query | -| --- | --- | --- | --- | -| `project` | `projects` | `sinnix://projects/{project_id}` | `true` | -| `checkout` | `projects` | `sinnix://projects/{project_id}/checkouts/{checkout_id}` | `true` | -| `bead` | `beads` | `sinnix://projects/{project_id}/beads/{bead_id}` | `true` | -| `task_authority` | `beads` | `sinnix://projects/{project_id}/task-authority` | `false` | -| `job` | `jobs` | `sinnix://jobs/{job_id}` | `true` | -| `artifact` | `artifacts` | `sinnix://artifacts/{artifact_id}` | `true` | -| `receipt` | `audit` | `sinnix://receipts/{receipt_id}` | `true` | -| `result` | `results` | `sinnix://results/{result_id}` | `true` | -| `machine_unit` | `machine` | `sinnix://machine/units/{manager}/{unit}` | `true` | -| `browser_page` | `browser` | `sinnix://browser/pages/{page_id}` | `true` | -| `browser_workspace` | `browser` | `sinnix://browser/agent-workspace` | `false` | -| `process` | `machine` | `sinnix://processes/{pid}/{start_ticks}` | `true` | -| `terminal` | `terminals` | `sinnix://terminals/{terminal_id}` | `true` | -| `desktop` | `desktop` | `sinnix://desktop/current` | `true` | -| `host_file` | `files` | `sinnix://files/{file_token}` | `true` | -| `mcp_tool` | `mcp-broker` | `sinnix://mcp/{server}/tools/{tool}` | `true` | -| `capture_lane` | `captures` | `sinnix://captures/{lane}` | `true` | -| `capability` | `capability-index` | `sinnix://capabilities/{name}` | `true` | -| `session` | `sessions` | `sinnix://sessions/{provider}/{session_id}` | `true` | -| `context_snapshot` | `context` | `sinnix://contexts/{snapshot_id}` | `true` | +| Resource | Owner | Canonical reference | Query | +| ------------------- | ------------------ | -------------------------------------------------------- | ------- | +| `project` | `projects` | `sinnix://projects/{project_id}` | `true` | +| `checkout` | `projects` | `sinnix://projects/{project_id}/checkouts/{checkout_id}` | `true` | +| `bead` | `beads` | `sinnix://projects/{project_id}/beads/{bead_id}` | `true` | +| `task_authority` | `beads` | `sinnix://projects/{project_id}/task-authority` | `false` | +| `job` | `jobs` | `sinnix://jobs/{job_id}` | `true` | +| `artifact` | `artifacts` | `sinnix://artifacts/{artifact_id}` | `true` | +| `receipt` | `audit` | `sinnix://receipts/{receipt_id}` | `true` | +| `result` | `results` | `sinnix://results/{result_id}` | `true` | +| `machine_unit` | `machine` | `sinnix://machine/units/{manager}/{unit}` | `true` | +| `browser_page` | `browser` | `sinnix://browser/pages/{page_id}` | `true` | +| `browser_workspace` | `browser` | `sinnix://browser/agent-workspace` | `false` | +| `process` | `machine` | `sinnix://processes/{pid}/{start_ticks}` | `true` | +| `terminal` | `terminals` | `sinnix://terminals/{terminal_id}` | `true` | +| `desktop` | `desktop` | `sinnix://desktop/current` | `true` | +| `host_file` | `files` | `sinnix://files/{file_token}` | `true` | +| `mcp_tool` | `mcp-broker` | `sinnix://mcp/{server}/tools/{tool}` | `true` | +| `capture_lane` | `captures` | `sinnix://captures/{lane}` | `true` | +| `capability` | `capability-index` | `sinnix://capabilities/{name}` | `true` | +| `session` | `sessions` | `sinnix://sessions/{provider}/{session_id}` | `true` | +| `context_snapshot` | `context` | `sinnix://contexts/{snapshot_id}` | `true` | ## Actions -| Action | Verb | Owner | Route | Schema | -| --- | --- | --- | --- | --- | -| `gateway.status` | `status` | `gateway` | `observe.gateway_status` | [`sinnix://gateway/v2/actions/gateway.status`](sinnix://gateway/v2/actions/gateway.status) | -| `gateway.catalog` | `catalog` | `registry` | `registry.search` | [`sinnix://gateway/v2/actions/gateway.catalog`](sinnix://gateway/v2/actions/gateway.catalog) | -| `resources.get` | `get` | `resolver` | `resources.get` | [`sinnix://gateway/v2/actions/resources.get`](sinnix://gateway/v2/actions/resources.get) | -| `projects.query` | `query` | `projects` | `projects.search` | [`sinnix://gateway/v2/actions/projects.query`](sinnix://gateway/v2/actions/projects.query) | -| `beads.query` | `query` | `beads` | `beads.query` | [`sinnix://gateway/v2/actions/beads.query`](sinnix://gateway/v2/actions/beads.query) | -| `projects.context` | `context` | `project-context` | `project_context.context` | [`sinnix://gateway/v2/actions/projects.context`](sinnix://gateway/v2/actions/projects.context) | -| `audit.events` | `events` | `audit` | `audit.tail` | [`sinnix://gateway/v2/actions/audit.events`](sinnix://gateway/v2/actions/audit.events) | -| `jobs.wait` | `wait` | `systemd-jobs` | `job.wait` | [`sinnix://gateway/v2/actions/jobs.wait`](sinnix://gateway/v2/actions/jobs.wait) | -| `projects.change` | `change` | `projects` | `projects.change` | [`sinnix://gateway/v2/actions/projects.change`](sinnix://gateway/v2/actions/projects.change) | -| `files.change` | `change` | `files` | `files.change` | [`sinnix://gateway/v2/actions/files.change`](sinnix://gateway/v2/actions/files.change) | -| `beads.change` | `change` | `beads` | `beads.write` | [`sinnix://gateway/v2/actions/beads.change`](sinnix://gateway/v2/actions/beads.change) | -| `beads.changeset` | `change` | `beads` | `beads.changeset` | [`sinnix://gateway/v2/actions/beads.changeset`](sinnix://gateway/v2/actions/beads.changeset) | -| `beads.operate` | `operate` | `beads` | `beads.maintenance` | [`sinnix://gateway/v2/actions/beads.operate`](sinnix://gateway/v2/actions/beads.operate) | -| `mcp.change` | `change` | `mcp-broker` | `mcp.call.write` | [`sinnix://gateway/v2/actions/mcp.change`](sinnix://gateway/v2/actions/mcp.change) | -| `machine.operate` | `operate` | `ops-reducer` | `ops.actions.execute` | [`sinnix://gateway/v2/actions/machine.operate`](sinnix://gateway/v2/actions/machine.operate) | -| `operations.run` | `run` | `systemd-jobs` | `job.start` | [`sinnix://gateway/v2/actions/operations.run`](sinnix://gateway/v2/actions/operations.run) | -| `agent.for_bead` | `run` | `systemd-jobs` | `job.agent.start` | [`sinnix://gateway/v2/actions/agent.for_bead`](sinnix://gateway/v2/actions/agent.for_bead) | -| `jobs.cancel` | `operate` | `systemd-jobs` | `job.cancel` | [`sinnix://gateway/v2/actions/jobs.cancel`](sinnix://gateway/v2/actions/jobs.cancel) | -| `desktop.operate` | `operate` | `desktop` | `desktop.action` | [`sinnix://gateway/v2/actions/desktop.operate`](sinnix://gateway/v2/actions/desktop.operate) | -| `terminals.operate` | `operate` | `terminals` | `terminals.action` | [`sinnix://gateway/v2/actions/terminals.operate`](sinnix://gateway/v2/actions/terminals.operate) | -| `browser.operate` | `operate` | `browser` | `browser.action` | [`sinnix://gateway/v2/actions/browser.operate`](sinnix://gateway/v2/actions/browser.operate) | -| `shell.run` | `run` | `systemd-jobs` | `job.shell.start` | [`sinnix://gateway/v2/actions/shell.run`](sinnix://gateway/v2/actions/shell.run) | -| `projects.list` | `query` | `projects` | `projects.list` | [`sinnix://gateway/v2/actions/projects.list`](sinnix://gateway/v2/actions/projects.list) | -| `projects.tree` | `query` | `projects` | `projects.tree` | [`sinnix://gateway/v2/actions/projects.tree`](sinnix://gateway/v2/actions/projects.tree) | -| `projects.read` | `query` | `projects` | `projects.read` | [`sinnix://gateway/v2/actions/projects.read`](sinnix://gateway/v2/actions/projects.read) | -| `projects.diff` | `query` | `projects` | `projects.diff` | [`sinnix://gateway/v2/actions/projects.diff`](sinnix://gateway/v2/actions/projects.diff) | -| `machine.query` | `query` | `machine` | `observe.machine_query` | [`sinnix://gateway/v2/actions/machine.query`](sinnix://gateway/v2/actions/machine.query) | -| `capabilities.query` | `query` | `capability-index` | `capability_index.query` | [`sinnix://gateway/v2/actions/capabilities.query`](sinnix://gateway/v2/actions/capabilities.query) | -| `mcp.query` | `query` | `mcp-broker` | `mcp.call.read` | [`sinnix://gateway/v2/actions/mcp.query`](sinnix://gateway/v2/actions/mcp.query) | -| `desktop.query` | `query` | `desktop` | `desktop.read` | [`sinnix://gateway/v2/actions/desktop.query`](sinnix://gateway/v2/actions/desktop.query) | -| `terminals.query` | `query` | `terminals` | `terminals.read` | [`sinnix://gateway/v2/actions/terminals.query`](sinnix://gateway/v2/actions/terminals.query) | -| `browser.query` | `query` | `browser` | `browser.read` | [`sinnix://gateway/v2/actions/browser.query`](sinnix://gateway/v2/actions/browser.query) | -| `files.query` | `query` | `files` | `files.read` | [`sinnix://gateway/v2/actions/files.query`](sinnix://gateway/v2/actions/files.query) | -| `sessions.query` | `query` | `sessions` | `sessions.query` | [`sinnix://gateway/v2/actions/sessions.query`](sinnix://gateway/v2/actions/sessions.query) | -| `memory.query` | `query` | `memory` | `memory.query` | [`sinnix://gateway/v2/actions/memory.query`](sinnix://gateway/v2/actions/memory.query) | -| `timeline.query` | `query` | `timeline` | `timeline.query` | [`sinnix://gateway/v2/actions/timeline.query`](sinnix://gateway/v2/actions/timeline.query) | -| `artifacts.query` | `query` | `artifacts` | `artifacts.query` | [`sinnix://gateway/v2/actions/artifacts.query`](sinnix://gateway/v2/actions/artifacts.query) | -| `audit.verify` | `query` | `audit` | `audit.verify` | [`sinnix://gateway/v2/actions/audit.verify`](sinnix://gateway/v2/actions/audit.verify) | -| `captures.query` | `query` | `captures` | `captures.query` | [`sinnix://gateway/v2/actions/captures.query`](sinnix://gateway/v2/actions/captures.query) | -| `jobs.query` | `query` | `systemd-jobs` | `job.list` | [`sinnix://gateway/v2/actions/jobs.query`](sinnix://gateway/v2/actions/jobs.query) | +| Action | Verb | Owner | Route | Schema | +| -------------------- | --------- | ------------------ | ------------------------- | -------------------------------------------------------------------------------------------------- | +| `gateway.status` | `status` | `gateway` | `observe.gateway_status` | [`sinnix://gateway/v2/actions/gateway.status`](sinnix://gateway/v2/actions/gateway.status) | +| `gateway.catalog` | `catalog` | `registry` | `registry.search` | [`sinnix://gateway/v2/actions/gateway.catalog`](sinnix://gateway/v2/actions/gateway.catalog) | +| `resources.get` | `get` | `resolver` | `resources.get` | [`sinnix://gateway/v2/actions/resources.get`](sinnix://gateway/v2/actions/resources.get) | +| `projects.query` | `query` | `projects` | `projects.search` | [`sinnix://gateway/v2/actions/projects.query`](sinnix://gateway/v2/actions/projects.query) | +| `beads.query` | `query` | `beads` | `beads.query` | [`sinnix://gateway/v2/actions/beads.query`](sinnix://gateway/v2/actions/beads.query) | +| `projects.context` | `context` | `project-context` | `project_context.context` | [`sinnix://gateway/v2/actions/projects.context`](sinnix://gateway/v2/actions/projects.context) | +| `audit.events` | `events` | `audit` | `audit.tail` | [`sinnix://gateway/v2/actions/audit.events`](sinnix://gateway/v2/actions/audit.events) | +| `jobs.wait` | `wait` | `systemd-jobs` | `job.wait` | [`sinnix://gateway/v2/actions/jobs.wait`](sinnix://gateway/v2/actions/jobs.wait) | +| `projects.change` | `change` | `projects` | `projects.change` | [`sinnix://gateway/v2/actions/projects.change`](sinnix://gateway/v2/actions/projects.change) | +| `files.change` | `change` | `files` | `files.change` | [`sinnix://gateway/v2/actions/files.change`](sinnix://gateway/v2/actions/files.change) | +| `beads.change` | `change` | `beads` | `beads.write` | [`sinnix://gateway/v2/actions/beads.change`](sinnix://gateway/v2/actions/beads.change) | +| `beads.changeset` | `change` | `beads` | `beads.changeset` | [`sinnix://gateway/v2/actions/beads.changeset`](sinnix://gateway/v2/actions/beads.changeset) | +| `beads.operate` | `operate` | `beads` | `beads.maintenance` | [`sinnix://gateway/v2/actions/beads.operate`](sinnix://gateway/v2/actions/beads.operate) | +| `mcp.change` | `change` | `mcp-broker` | `mcp.call.write` | [`sinnix://gateway/v2/actions/mcp.change`](sinnix://gateway/v2/actions/mcp.change) | +| `machine.operate` | `operate` | `ops-reducer` | `ops.actions.execute` | [`sinnix://gateway/v2/actions/machine.operate`](sinnix://gateway/v2/actions/machine.operate) | +| `operations.run` | `run` | `systemd-jobs` | `job.start` | [`sinnix://gateway/v2/actions/operations.run`](sinnix://gateway/v2/actions/operations.run) | +| `agent.for_bead` | `run` | `systemd-jobs` | `job.agent.start` | [`sinnix://gateway/v2/actions/agent.for_bead`](sinnix://gateway/v2/actions/agent.for_bead) | +| `jobs.cancel` | `operate` | `systemd-jobs` | `job.cancel` | [`sinnix://gateway/v2/actions/jobs.cancel`](sinnix://gateway/v2/actions/jobs.cancel) | +| `desktop.operate` | `operate` | `desktop` | `desktop.action` | [`sinnix://gateway/v2/actions/desktop.operate`](sinnix://gateway/v2/actions/desktop.operate) | +| `terminals.operate` | `operate` | `terminals` | `terminals.action` | [`sinnix://gateway/v2/actions/terminals.operate`](sinnix://gateway/v2/actions/terminals.operate) | +| `browser.operate` | `operate` | `browser` | `browser.action` | [`sinnix://gateway/v2/actions/browser.operate`](sinnix://gateway/v2/actions/browser.operate) | +| `shell.run` | `run` | `systemd-jobs` | `job.shell.start` | [`sinnix://gateway/v2/actions/shell.run`](sinnix://gateway/v2/actions/shell.run) | +| `projects.list` | `query` | `projects` | `projects.list` | [`sinnix://gateway/v2/actions/projects.list`](sinnix://gateway/v2/actions/projects.list) | +| `projects.tree` | `query` | `projects` | `projects.tree` | [`sinnix://gateway/v2/actions/projects.tree`](sinnix://gateway/v2/actions/projects.tree) | +| `projects.read` | `query` | `projects` | `projects.read` | [`sinnix://gateway/v2/actions/projects.read`](sinnix://gateway/v2/actions/projects.read) | +| `projects.diff` | `query` | `projects` | `projects.diff` | [`sinnix://gateway/v2/actions/projects.diff`](sinnix://gateway/v2/actions/projects.diff) | +| `machine.query` | `query` | `machine` | `observe.machine_query` | [`sinnix://gateway/v2/actions/machine.query`](sinnix://gateway/v2/actions/machine.query) | +| `capabilities.query` | `query` | `capability-index` | `capability_index.query` | [`sinnix://gateway/v2/actions/capabilities.query`](sinnix://gateway/v2/actions/capabilities.query) | +| `mcp.query` | `query` | `mcp-broker` | `mcp.call.read` | [`sinnix://gateway/v2/actions/mcp.query`](sinnix://gateway/v2/actions/mcp.query) | +| `desktop.query` | `query` | `desktop` | `desktop.read` | [`sinnix://gateway/v2/actions/desktop.query`](sinnix://gateway/v2/actions/desktop.query) | +| `terminals.query` | `query` | `terminals` | `terminals.read` | [`sinnix://gateway/v2/actions/terminals.query`](sinnix://gateway/v2/actions/terminals.query) | +| `browser.query` | `query` | `browser` | `browser.read` | [`sinnix://gateway/v2/actions/browser.query`](sinnix://gateway/v2/actions/browser.query) | +| `files.query` | `query` | `files` | `files.read` | [`sinnix://gateway/v2/actions/files.query`](sinnix://gateway/v2/actions/files.query) | +| `sessions.query` | `query` | `sessions` | `sessions.query` | [`sinnix://gateway/v2/actions/sessions.query`](sinnix://gateway/v2/actions/sessions.query) | +| `memory.query` | `query` | `memory` | `memory.query` | [`sinnix://gateway/v2/actions/memory.query`](sinnix://gateway/v2/actions/memory.query) | +| `timeline.query` | `query` | `timeline` | `timeline.query` | [`sinnix://gateway/v2/actions/timeline.query`](sinnix://gateway/v2/actions/timeline.query) | +| `artifacts.query` | `query` | `artifacts` | `artifacts.query` | [`sinnix://gateway/v2/actions/artifacts.query`](sinnix://gateway/v2/actions/artifacts.query) | +| `audit.verify` | `query` | `audit` | `audit.verify` | [`sinnix://gateway/v2/actions/audit.verify`](sinnix://gateway/v2/actions/audit.verify) | +| `captures.query` | `query` | `captures` | `captures.query` | [`sinnix://gateway/v2/actions/captures.query`](sinnix://gateway/v2/actions/captures.query) | +| `jobs.query` | `query` | `systemd-jobs` | `job.list` | [`sinnix://gateway/v2/actions/jobs.query`](sinnix://gateway/v2/actions/jobs.query) | ### `gateway.status` @@ -158,10 +159,7 @@ Input schema: "type": "string" }, "availability": { - "enum": [ - "available", - "unavailable" - ] + "enum": ["available", "unavailable"] }, "deadline_at": { "type": "number" @@ -171,12 +169,7 @@ Input schema: "type": "string" }, "effect": { - "enum": [ - "read", - "change", - "operate", - "run" - ] + "enum": ["read", "change", "operate", "run"] }, "idempotency_key": { "maxLength": 256, @@ -293,11 +286,7 @@ Input schema: }, "projection": { "default": "summary", - "enum": [ - "summary", - "log", - "result" - ] + "enum": ["summary", "log", "result"] }, "reason": { "maxLength": 2000, @@ -315,9 +304,7 @@ Input schema: "type": "string" } }, - "required": [ - "ref" - ], + "required": ["ref"], "type": "object" } ``` @@ -383,10 +370,7 @@ Input schema: "type": "string" } }, - "required": [ - "ref", - "query" - ], + "required": ["ref", "query"], "type": "object" } ``` @@ -458,11 +442,7 @@ Input schema: "type": "integer" }, "direction": { - "enum": [ - "down", - "up", - "both" - ] + "enum": ["down", "up", "both"] }, "edge_type": { "type": "string" @@ -579,10 +559,7 @@ Input schema: "type": "string" } }, - "required": [ - "action_name", - "parameters" - ], + "required": ["action_name", "parameters"], "type": "object" } ``` @@ -600,13 +577,9 @@ Examples: }, "status": "open" }, - "includes": [ - "dependencies" - ], + "includes": ["dependencies"], "limit": 50, - "project_ids": [ - "polylogue" - ], + "project_ids": ["polylogue"], "view": "query" } } @@ -672,9 +645,7 @@ Input schema: "type": "string" } }, - "required": [ - "ref" - ], + "required": ["ref"], "type": "object" } ``` @@ -825,9 +796,7 @@ Input schema: "type": "integer" } }, - "required": [ - "ref" - ], + "required": ["ref"], "type": "object" } ``` @@ -867,10 +836,7 @@ Input schema: "type": "string" }, "operation": { - "enum": [ - "apply_patch", - "write" - ] + "enum": ["apply_patch", "write"] }, "parameters": { "maxProperties": 32, @@ -907,12 +873,7 @@ Input schema: "type": "string" } }, - "required": [ - "ref", - "operation", - "parameters", - "idempotency_key" - ], + "required": ["ref", "operation", "parameters", "idempotency_key"], "type": "object" } ``` @@ -960,14 +921,7 @@ Input schema: "type": "string" }, "operation": { - "enum": [ - "append", - "copy", - "mkdir", - "move", - "remove", - "replace" - ] + "enum": ["append", "copy", "mkdir", "move", "remove", "replace"] }, "parameters": { "maxProperties": 32, @@ -1000,12 +954,7 @@ Input schema: "type": "string" } }, - "required": [ - "ref", - "operation", - "parameters", - "idempotency_key" - ], + "required": ["ref", "operation", "parameters", "idempotency_key"], "type": "object" } ``` @@ -1110,10 +1059,7 @@ Input schema: "type": "string" }, "mode": { - "enum": [ - "preview", - "apply" - ] + "enum": ["preview", "apply"] }, "other_id": { "maxLength": 128, @@ -1169,19 +1115,14 @@ Input schema: "additionalProperties": false, "properties": { "mode": { - "enum": [ - "append", - "replace" - ] + "enum": ["append", "replace"] }, "text": { "maxLength": 32000, "type": "string" } }, - "required": [ - "text" - ], + "required": ["text"], "type": "object" }, "set": { @@ -1189,11 +1130,7 @@ Input schema: }, "unset": { "items": { - "enum": [ - "due", - "defer", - "parent" - ] + "enum": ["due", "defer", "parent"] }, "type": "array" } @@ -1237,11 +1174,7 @@ Input schema: "type": "string" }, "verdict": { - "enum": [ - "accepted", - "rejected", - "partial" - ] + "enum": ["accepted", "rejected", "partial"] } }, "type": "object" @@ -1251,10 +1184,7 @@ Input schema: "properties": { "expected_assignee": { "maxLength": 256, - "type": [ - "string", - "null" - ] + "type": ["string", "null"] }, "expected_etag": { "pattern": "^[0-9a-f]{64}$", @@ -1288,12 +1218,7 @@ Input schema: "type": "string" } }, - "required": [ - "ref", - "operation", - "parameters", - "idempotency_key" - ], + "required": ["ref", "operation", "parameters", "idempotency_key"], "type": "object" } ``` @@ -1338,10 +1263,7 @@ Input schema: "type": "string" }, "operation": { - "enum": [ - "apply", - "preview" - ] + "enum": ["apply", "preview"] }, "parameters": { "additionalProperties": false, @@ -1385,10 +1307,7 @@ Input schema: "properties": { "expected_assignee": { "maxLength": 256, - "type": [ - "string", - "null" - ] + "type": ["string", "null"] }, "expected_etag": { "pattern": "^[0-9a-f]{64}$", @@ -1412,11 +1331,7 @@ Input schema: "type": "string" } }, - "required": [ - "ref", - "operation", - "parameters" - ], + "required": ["ref", "operation", "parameters"], "type": "object" }, "maxItems": 128, @@ -1424,19 +1339,14 @@ Input schema: "type": "array" }, "on_error": { - "enum": [ - "stop", - "continue" - ] + "enum": ["stop", "continue"] }, "preview_digest": { "pattern": "^[0-9a-f]{64}$", "type": "string" } }, - "required": [ - "actions" - ], + "required": ["actions"], "type": "object" }, "preconditions": { @@ -1459,12 +1369,7 @@ Input schema: "type": "string" } }, - "required": [ - "ref", - "operation", - "parameters", - "idempotency_key" - ], + "required": ["ref", "operation", "parameters", "idempotency_key"], "type": "object" } ``` @@ -1562,12 +1467,7 @@ Input schema: "type": "string" } }, - "required": [ - "ref", - "operation", - "parameters", - "idempotency_key" - ], + "required": ["ref", "operation", "parameters", "idempotency_key"], "type": "object" } ``` @@ -1609,9 +1509,7 @@ Input schema: "type": "string" }, "operation": { - "enum": [ - "call" - ] + "enum": ["call"] }, "parameters": { "maxProperties": 32, @@ -1634,12 +1532,7 @@ Input schema: "type": "string" } }, - "required": [ - "ref", - "operation", - "parameters", - "idempotency_key" - ], + "required": ["ref", "operation", "parameters", "idempotency_key"], "type": "object" } ``` @@ -1706,9 +1599,7 @@ Input schema: "type": "integer" } }, - "required": [ - "expected_revision" - ], + "required": ["expected_revision"], "type": "object" }, "reason": { @@ -1811,11 +1702,7 @@ Input schema: "type": "string" } }, - "required": [ - "project_id", - "operation", - "idempotency_key" - ], + "required": ["project_id", "operation", "idempotency_key"], "type": "object" } ``` @@ -1855,13 +1742,7 @@ Input schema: "type": "string" }, "backend": { - "enum": [ - "claude", - "codex", - "gemini", - "grok", - "antigravity" - ] + "enum": ["claude", "codex", "gemini", "grok", "antigravity"] }, "checkout_id": { "maxLength": 128, @@ -1870,17 +1751,11 @@ Input schema: }, "claim_mode": { "default": "none", - "enum": [ - "none", - "claim" - ] + "enum": ["none", "claim"] }, "credential_profile": { "default": "subscription", - "enum": [ - "subscription", - "api" - ] + "enum": ["subscription", "api"] }, "deadline_at": { "type": "number" @@ -1988,9 +1863,7 @@ Input schema: "type": "string" } }, - "required": [ - "expected_phase" - ], + "required": ["expected_phase"], "type": "object" }, "reason": { @@ -2010,11 +1883,7 @@ Input schema: "type": "string" } }, - "required": [ - "ref", - "idempotency_key", - "preconditions" - ], + "required": ["ref", "idempotency_key", "preconditions"], "type": "object" } ``` @@ -2087,12 +1956,7 @@ Input schema: "type": "string" } }, - "required": [ - "ref", - "operation", - "parameters", - "idempotency_key" - ], + "required": ["ref", "operation", "parameters", "idempotency_key"], "type": "object" } ``` @@ -2136,12 +2000,7 @@ Input schema: "type": "string" }, "operation": { - "enum": [ - "focus", - "key", - "run", - "send" - ] + "enum": ["focus", "key", "run", "send"] }, "parameters": { "maxProperties": 32, @@ -2164,12 +2023,7 @@ Input schema: "type": "string" } }, - "required": [ - "ref", - "operation", - "parameters", - "idempotency_key" - ], + "required": ["ref", "operation", "parameters", "idempotency_key"], "type": "object" } ``` @@ -2248,12 +2102,7 @@ Input schema: "type": "string" } }, - "required": [ - "ref", - "operation", - "parameters", - "idempotency_key" - ], + "required": ["ref", "operation", "parameters", "idempotency_key"], "type": "object" } ``` @@ -2339,12 +2188,7 @@ Input schema: "type": "integer" } }, - "required": [ - "project_id", - "checkout_id", - "argv", - "idempotency_key" - ], + "required": ["project_id", "checkout_id", "argv", "idempotency_key"], "type": "object" } ``` @@ -2353,11 +2197,7 @@ Examples: ```json { - "argv": [ - "git", - "status", - "--short" - ], + "argv": ["git", "status", "--short"], "checkout_id": "default", "cwd": ".", "idempotency_key": "shell-status-example", diff --git a/docs/local-ai-activation.md b/docs/local-ai-activation.md index f7f8519f..e93492b9 100644 --- a/docs/local-ai-activation.md +++ b/docs/local-ai-activation.md @@ -8,7 +8,7 @@ This host keeps local AI services on demand. The public loopback port is a stabl | ---------------------- | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | ---------------------------------------------------- | | Ollama text and vision | `127.0.0.1:11434` | `local-chat`, `local-vision`, `local-coder`, `local-coder-moe`, `local-reasoner`, `local-thinker`, `local-multimodal-moe`, `local-reader` | on demand | CUDA, one GPU inference occupant | | LiteLLM gateway | `127.0.0.1:4000` | OpenAI `/v1/chat/completions` and Anthropic `/v1/messages` | on demand | translates agent clients to local backends | -| Muse Glimmer | `127.0.0.1:8083` | direct llama.cpp model `muse-glimmer` (abliterated Q4_K_M) | on demand, 15 minute idle window | CUDA plus CPU/RAM hybrid, one GPU inference occupant | +| Muse Glimmer | `127.0.0.1:8083` | direct llama.cpp model `muse-glimmer` (abliterated Q4_K_M) | on demand, 15 minute idle window | CUDA plus CPU/RAM hybrid, one GPU inference occupant | | KoboldCpp | `127.0.0.1:5001` | configured GGUF, KoboldAI Lite UI, text/image APIs | on demand | CUDA, one GPU inference occupant | | Open WebUI | `127.0.0.1:8080` | browser chat over Ollama | configured startup policy | frontend only; currently targets Ollama | | llama.cpp reranker | `127.0.0.1:8081` | `/v1/rerank` | on demand | CPU resident by policy, outside GPU admission | @@ -106,18 +106,18 @@ The gateway path can start both LiteLLM and the selected backend. The first requ The model name is the stable LiteLLM name. The Ollama tag is the storage and pull name: -| LiteLLM name | Ollama tag | Intended use | -| ---------------------- | ----------------------------------- | ------------------------------------------- | -| `local-chat` | `huihui_ai/gemma-4-abliterated:12b` | daily text chat | -| `local-vision` | `gemma4:12b-it-qat` | text plus image/audio input | -| `local-coder` | `qwen2.5-coder:7b` | fast coding and triage | -| `local-coder-moe` | `qwen3-coder:30b` | slower, stronger coding stretch tier | -| `local-reasoner` | `gpt-oss:20b` | general reasoning and agent jobs | -| `local-thinker` | `qwen3:30b` | reasoning-heavy general work | -| `local-multimodal-moe` | `gemma4:26b` | larger multimodal generalist | -| `local-gemma4-26b-abliterated` | `hf.co/TrevorJS/gemma-4-26B-A4B-it-uncensored-GGUF:Q4_K_M` | abliterated larger multimodal MoE | -| `local-reader` | `hf.co/rbehzadan/ReaderLM-v2.gguf` | HTML to Markdown or JSON | -| `local-glimmer` | direct llama.cpp | dense reasoning with CPU/RAM hybrid offload | +| LiteLLM name | Ollama tag | Intended use | +| ------------------------------ | ---------------------------------------------------------- | ------------------------------------------- | +| `local-chat` | `huihui_ai/gemma-4-abliterated:12b` | daily text chat | +| `local-vision` | `gemma4:12b-it-qat` | text plus image/audio input | +| `local-coder` | `qwen2.5-coder:7b` | fast coding and triage | +| `local-coder-moe` | `qwen3-coder:30b` | slower, stronger coding stretch tier | +| `local-reasoner` | `gpt-oss:20b` | general reasoning and agent jobs | +| `local-thinker` | `qwen3:30b` | reasoning-heavy general work | +| `local-multimodal-moe` | `gemma4:26b` | larger multimodal generalist | +| `local-gemma4-26b-abliterated` | `hf.co/TrevorJS/gemma-4-26B-A4B-it-uncensored-GGUF:Q4_K_M` | abliterated larger multimodal MoE | +| `local-reader` | `hf.co/rbehzadan/ReaderLM-v2.gguf` | HTML to Markdown or JSON | +| `local-glimmer` | direct llama.cpp | dense reasoning with CPU/RAM hybrid offload | Use the Ollama API when you need Ollama-specific options or model management: diff --git a/dots/_ai/skills/agent-gateway/SKILL.md b/dots/_ai/skills/agent-gateway/SKILL.md index 2fa079f3..de274b85 100644 --- a/dots/_ai/skills/agent-gateway/SKILL.md +++ b/dots/_ai/skills/agent-gateway/SKILL.md @@ -6,6 +6,7 @@ description: Use when invoking, inspecting, or documenting Sinnix Agent Gateway + # Agent Gateway V2 Use `sinnix-agent-gateway` when a local agent needs the same principal-scoped routes and normalized envelopes as MCP. The complete action schemas and examples are in `docs/generated/agent-gateway-reference.md`. diff --git a/dots/_ai/skills/agent-runtime/scripts/run_agent_prompt.sh b/dots/_ai/skills/agent-runtime/scripts/run_agent_prompt.sh index 062df239..834501c5 100755 --- a/dots/_ai/skills/agent-runtime/scripts/run_agent_prompt.sh +++ b/dots/_ai/skills/agent-runtime/scripts/run_agent_prompt.sh @@ -21,39 +21,79 @@ EOF while [[ $# -gt 0 ]]; do case "$1" in - --agent) agent="${2:?missing backend}"; shift 2 ;; - --workdir) workdir="${2:?missing workdir}"; shift 2 ;; - --prompt-file) prompt_file="${2:?missing prompt file}"; shift 2 ;; - --last-file) last_file="${2:?missing result file}"; shift 2 ;; - --model) model="${2:?missing model}"; shift 2 ;; - --reasoning-effort) reasoning_effort="${2:?missing effort}"; shift 2 ;; - --credential-profile) credential_profile="${2:?missing credential profile}"; shift 2 ;; - -h|--help) usage; exit 0 ;; - *) echo "unknown option: $1" >&2; usage >&2; exit 2 ;; + --agent) + agent="${2:?missing backend}" + shift 2 + ;; + --workdir) + workdir="${2:?missing workdir}" + shift 2 + ;; + --prompt-file) + prompt_file="${2:?missing prompt file}" + shift 2 + ;; + --last-file) + last_file="${2:?missing result file}" + shift 2 + ;; + --model) + model="${2:?missing model}" + shift 2 + ;; + --reasoning-effort) + reasoning_effort="${2:?missing effort}" + shift 2 + ;; + --credential-profile) + credential_profile="${2:?missing credential profile}" + shift 2 + ;; + -h | --help) + usage + exit 0 + ;; + *) + echo "unknown option: $1" >&2 + usage >&2 + exit 2 + ;; esac done -[[ -n $agent && -n $workdir && -n $prompt_file && -n $last_file && -n $model && -n $reasoning_effort ]] || { usage >&2; exit 2; } -[[ -d $workdir && -r $prompt_file ]] || { echo "workdir or prompt is unavailable" >&2; exit 2; } -[[ $credential_profile == subscription || $credential_profile == api ]] || { echo "invalid credential profile" >&2; exit 2; } +[[ -n $agent && -n $workdir && -n $prompt_file && -n $last_file && -n $model && -n $reasoning_effort ]] || { + usage >&2 + exit 2 +} +[[ -d $workdir && -r $prompt_file ]] || { + echo "workdir or prompt is unavailable" >&2 + exit 2 +} +[[ $credential_profile == subscription || $credential_profile == api ]] || { + echo "invalid credential profile" >&2 + exit 2 +} mkdir -p "$(dirname "$last_file")" resolve_agent_bin() { case "$1" in claude) command -v claude-full 2>/dev/null || command -v claude 2>/dev/null ;; - codex|gemini) command -v "$1" ;; + codex | gemini) command -v "$1" ;; grok) command -v grok-sinnix 2>/dev/null || command -v grok ;; antigravity) command -v agy-sinnix 2>/dev/null || command -v agy ;; *) return 1 ;; esac } -agent_bin="$(resolve_agent_bin "$agent")" || { echo "$agent runtime not found" >&2; exit 1; } +agent_bin="$(resolve_agent_bin "$agent")" || { + echo "$agent runtime not found" >&2 + exit 1 +} cd "$workdir" case "$agent" in codex) - exec "$agent_bin" exec -C "$workdir" --model "$model" --output-last-message "$last_file" -c "model_reasoning_effort=\"$reasoning_effort\"" - < "$prompt_file" + exec "$agent_bin" exec -C "$workdir" --model "$model" --output-last-message "$last_file" -c "model_reasoning_effort=\"$reasoning_effort\"" - <"$prompt_file" ;; claude) if [[ $credential_profile == subscription ]]; then @@ -63,7 +103,7 @@ claude) fi ;; gemini) - "$agent_bin" < "$prompt_file" | tee "$last_file" + "$agent_bin" <"$prompt_file" | tee "$last_file" ;; grok) "$agent_bin" --cwd "$workdir" --single "$(<"$prompt_file")" --model "$model" --reasoning-effort "$reasoning_effort" | tee "$last_file" diff --git a/dots/_ai/skills/chatgpt-conversations/SKILL.md b/dots/_ai/skills/chatgpt-conversations/SKILL.md index c3d7f914..a0e3265f 100644 --- a/dots/_ai/skills/chatgpt-conversations/SKILL.md +++ b/dots/_ai/skills/chatgpt-conversations/SKILL.md @@ -28,6 +28,7 @@ reads, and does not alter the browser. Use `--markdown` only when a human-readable handoff is preferable to the structured JSON record. Full reads are the normal choice when the agent must understand the conversation as a whole. + 3. Use `read` only to reduce context usage when a narrower range answers the task: diff --git a/dots/_ai/skills/codebase-design/SKILL.md b/dots/_ai/skills/codebase-design/SKILL.md index 17c29649..d0a04648 100644 --- a/dots/_ai/skills/codebase-design/SKILL.md +++ b/dots/_ai/skills/codebase-design/SKILL.md @@ -45,7 +45,7 @@ language is the point. ## Deletion needs consent, not just a failed deletion test -The deletion test judges a module's *shape*. Whether code should be REMOVED +The deletion test judges a module's _shape_. Whether code should be REMOVED is a different question with its own doctrine, learned expensively (four recorded wrong deletions from grep-level reasoning): diff --git a/dots/_ai/skills/desktop-control-plane/scripts/chrome-control.sh b/dots/_ai/skills/desktop-control-plane/scripts/chrome-control.sh index 09d5ba05..432d7029 100755 --- a/dots/_ai/skills/desktop-control-plane/scripts/chrome-control.sh +++ b/dots/_ai/skills/desktop-control-plane/scripts/chrome-control.sh @@ -240,7 +240,7 @@ cdp_read_response() { fi printf -v remaining_sec '%d.%06d' $((remaining_us / 1000000)) $((remaining_us % 1000000)) if ! IFS= read -r -t "$remaining_sec" line <&"$fd"; then - if (( $(cdp_now_us) >= deadline_us )); then + if (($(cdp_now_us) >= deadline_us)); then printf 'timed out waiting for CDP response id %s (%s) after %ss\n' \ "$expected_id" "$method" "$timeout_sec" >&2 return 124 @@ -602,7 +602,7 @@ agent-window) fullscreen=$(jq -r '.fullscreen' <<<"$client_state") visible=$(hyprctl_call monitors -j 2>/dev/null | jq -r --arg workspace "$AGENT_WORKSPACE" \ 'any(.[]; .activeWorkspace.name == $workspace)') - if [[ $workspace == "$AGENT_WORKSPACE" && $visible == "false" && $floating == "false" && \ + if [[ $workspace == "$AGENT_WORKSPACE" && $visible == "false" && $floating == "false" && $pinned == "false" && $fullscreen == "0" ]]; then ((stable_checks += 1)) if [[ $stable_checks -ge 3 ]]; then diff --git a/dots/_ai/skills/orchestrate/SKILL.md b/dots/_ai/skills/orchestrate/SKILL.md index 2a8b7901..2c0d5e42 100644 --- a/dots/_ai/skills/orchestrate/SKILL.md +++ b/dots/_ai/skills/orchestrate/SKILL.md @@ -11,16 +11,16 @@ judged per merged outcome (rough proxy, not a metric to game). ## Model doctrine -| Role | Route | Model | Effort | -|---|---|---|---| -| Specification, review, integration | this session | (session model) | default | -| Context-carrying analysis | fork | inherited | — | -| Implementation lane (well-specified beads) | `agentctl agent --backend codex` | gpt-5.6-luna | medium | -| Escalated lane (luna floundered) | same | gpt-5.6-terra | high | -| Design / debug / adversarial review | Agent tool or backend claude | claude-opus-5 | high | -| Review alternate (Claude quota tight) | backend codex | gpt-5.6-sol | high | -| Menial coordination (≥3 live lanes) | Agent tool | claude-haiku-4-5 | medium | -| Broad read-only sweeps | Agent tool | sonnet or luna | low/medium | +| Role | Route | Model | Effort | +| ------------------------------------------ | -------------------------------- | ---------------- | ---------- | +| Specification, review, integration | this session | (session model) | default | +| Context-carrying analysis | fork | inherited | — | +| Implementation lane (well-specified beads) | `agentctl agent --backend codex` | gpt-5.6-luna | medium | +| Escalated lane (luna floundered) | same | gpt-5.6-terra | high | +| Design / debug / adversarial review | Agent tool or backend claude | claude-opus-5 | high | +| Review alternate (Claude quota tight) | backend codex | gpt-5.6-sol | high | +| Menial coordination (≥3 live lanes) | Agent tool | claude-haiku-4-5 | medium | +| Broad read-only sweeps | Agent tool | sonnet or luna | low/medium | Rules: every dispatch names its model explicitly (only forks inherit). Luna-first is quota-driven (separate Codex pool) AND review-driven @@ -33,7 +33,7 @@ retry luna against the same failure. - Durable work: `agentctl workspace create` (worktree under /realm/worktrees) then `agentctl agent --project P --checkout C --prompt-file F --backend B - --model M --effort E`. Agent jobs are single-shot (no resume yet) and +--model M --effort E`. Agent jobs are single-shot (no resume yet) and capped at 3600s — for longer arcs, have the lane checkpoint (`agentctl workspace checkpoint`) and re-dispatch a continuation prompt. - Job lifecycle: `agentctl job {get,wait,logs,result,cancel}`. Completion @@ -60,10 +60,11 @@ retry luna against the same failure. ## Structural review (non-negotiable) Never accept a lane on its own report. Review = diff + typed verify result -+ the lane's last message; transcripts only when something smells. Default -cadence: one review per integrated batch, spot-checks per lane, Opus -adversarial review for risky lanes. This applies to EVERY unsupervised -executor regardless of tier — capable models fail confidently too. + +- the lane's last message; transcripts only when something smells. Default + cadence: one review per integrated batch, spot-checks per lane, Opus + adversarial review for risky lanes. This applies to EVERY unsupervised + executor regardless of tier — capable models fail confidently too. ## Batching diff --git a/dots/_ai/skills/task-backend/SKILL.md b/dots/_ai/skills/task-backend/SKILL.md index 03d182d8..7c593a5d 100644 --- a/dots/_ai/skills/task-backend/SKILL.md +++ b/dots/_ai/skills/task-backend/SKILL.md @@ -14,10 +14,10 @@ beads-in-git snapshots are immutable evidence, never live state. ## Reading work - `bd ready` / `bd list --status ...` / `bd show ` / `bd graph --open - ` — the graph is the authority; epic child-counts are not closure +` — the graph is the authority; epic child-counts are not closure evidence (membership is dependency-based). - Through AgentCTL (cross-project, typed): `agentctl task list - [--ready]`, `agentctl task get`. +[--ready]`, `agentctl task get`. - "Ready" means dependency-ready, not necessarily executable now: live-proof and operator-window items are ready-for-a-window, not ready-for-a-lane. Check the bead's design for window/consent requirements before claiming. @@ -38,7 +38,7 @@ beads-in-git snapshots are immutable evidence, never live state. - Complete only with verification evidence: the exact commands run, the PR and merge SHA where applicable (`agentctl task complete --pr N - --merge-sha SHA`). Completion is idempotent after merge and retryable +--merge-sha SHA`). Completion is idempotent after merge and retryable after a backend outage — code merge never embeds tracker transactions. - **Close discipline**: a bead closes when its acceptance criteria are met, not when a harness exists or a mechanism is "structurally tested". Address diff --git a/dots/_ai/skills/writing-for-agents/SKILL.md b/dots/_ai/skills/writing-for-agents/SKILL.md index 1b9e30d3..ada44c7c 100644 --- a/dots/_ai/skills/writing-for-agents/SKILL.md +++ b/dots/_ai/skills/writing-for-agents/SKILL.md @@ -20,6 +20,7 @@ material. A load-bearing target behind weak wording is a variance bug: sharpen the wording first; inline the material only if sharpening fails. Pointer rules (they pay rent every turn, so prune hardest here): + - Front-load the trigger word; one trigger per genuinely distinct branch; collapse synonyms that rename the same branch. - Cut identity the body already carries. @@ -50,6 +51,7 @@ material fragments one meaning across many places. ## Completion criteria End every step on a condition the agent can check. Two levers: + - **Clarity**: "understanding reached" invites stopping early; "every modified surface accounted for" does not. - **Demand**: the criterion's wording drives how much digging happens. @@ -65,6 +67,7 @@ A **leading word** is a compact pretrained concept the agent thinks with (tight loop, red twin, tracer bullet, frontier). Repeated as a token it anchors behavior cheaply. Hunt for restatements a leading word retires. Two cautions, both estate doctrine: + - A coined word recruits no priors — you pay its definition everywhere. This is how the jargon debt (receipt ×8 meanings, authority ×3) accumulated. Before coining, check the [[vocabulary]] glossary; never mint a new noun @@ -79,7 +82,7 @@ Two cautions, both estate doctrine: AGENTS.md is a symlink to CLAUDE.md for exactly this reason. - **The environment is a source of truth**: `devtools --list-commands`, `agentctl --help`, `bd --help`, generated reference docs, `.agentctl/ - project.toml`. A document restating them is a cache that goes stale — the +project.toml`. A document restating them is a cache that goes stale — the estate's recorded failure class (six stale CLAUDE.md claims, skills teaching nonexistent verbs). Cache only what no lookup confesses: the unwritten convention, the reason, the gotcha. diff --git a/flake/data/local-models.nix b/flake/data/local-models.nix index cb773442..6f9faabe 100644 --- a/flake/data/local-models.nix +++ b/flake/data/local-models.nix @@ -209,7 +209,8 @@ rec { litellm_params = { model = if m ? litellmModel then m.litellmModel else "ollama_chat/${m.ollamaTag}"; api_base = if m ? litellmApiBase then m.litellmApiBase else ollamaApiBase; - } // lib.optionalAttrs (m ? litellmApiKey) { + } + // lib.optionalAttrs (m ? litellmApiKey) { api_key = m.litellmApiKey; }; } diff --git a/flake/tests/bd-dolt-authority.sh b/flake/tests/bd-dolt-authority.sh index 6e3d7d7f..36a0f517 100644 --- a/flake/tests/bd-dolt-authority.sh +++ b/flake/tests/bd-dolt-authority.sh @@ -13,7 +13,7 @@ git init --quiet "$test_root/repo" git -C "$test_root/repo" config user.name fixture git -C "$test_root/repo" config user.email fixture@example.invalid -printf 'fixture\n' > "$test_root/repo/README.md" +printf 'fixture\n' >"$test_root/repo/README.md" git -C "$test_root/repo" add README.md git -C "$test_root/repo" commit --quiet -m fixture ( @@ -38,7 +38,7 @@ git -C "$test_root/repo" commit --quiet -m fixture test "$(realpath "$linked_database")" = "$(realpath "$database")" jq --arg issue_id "$issue_id" 'if .id == $issue_id then .title = "forged JSONL export" else . end' \ - .beads/issues.jsonl > .beads/issues.jsonl.tmp + .beads/issues.jsonl >.beads/issues.jsonl.tmp mv .beads/issues.jsonl.tmp .beads/issues.jsonl after="$("$bd" --readonly --json show "$issue_id" | jq -c "$normalize_issue")" diff --git a/flake/tests/chrome-agent-window.sh b/flake/tests/chrome-agent-window.sh index c359e411..09becc01 100644 --- a/flake/tests/chrome-agent-window.sh +++ b/flake/tests/chrome-agent-window.sh @@ -239,11 +239,11 @@ run_with_deadline() { setsid "$helper" agent-window --url https://example.test >"$state/stdout" 2>"$state/stderr" & pid=$! while kill -0 "$pid" 2>/dev/null; do - if (( SECONDS >= deadline_at )); then + if ((SECONDS >= deadline_at)); then kill -- "-$pid" 2>/dev/null || true wait "$pid" 2>/dev/null || true status=124 - elapsed_ms=$(( ($(date +%s%N) - start_ns) / 1000000 )) + elapsed_ms=$((($(date +%s%N) - start_ns) / 1000000)) printf 'case=%s status=%s elapsed_ms=%s target=%s closed=%s navigated=%s\n' \ "$name" "$status" "$elapsed_ms" "$(test -e "$state/agent-target" && printf present || printf absent)" \ "$(wc -l <"$state/closed-targets")" "$(test -e "$state/navigated-url" && printf yes || printf no)" @@ -253,7 +253,7 @@ run_with_deadline() { done wait "$pid" || status=$? status="${status:-0}" - elapsed_ms=$(( ($(date +%s%N) - start_ns) / 1000000 )) + elapsed_ms=$((($(date +%s%N) - start_ns) / 1000000)) printf 'case=%s status=%s elapsed_ms=%s target=%s closed=%s navigated=%s\n' \ "$name" "$status" "$elapsed_ms" "$(test -e "$state/agent-target" && printf present || printf absent)" \ "$(wc -l <"$state/closed-targets")" "$(test -e "$state/navigated-url" && printf yes || printf no)" @@ -269,7 +269,7 @@ assert_fake_wire_rejects_out_of_range_id() { FAKE_STATE="$state" \ FAKE_CDP_SCENARIO=match \ "$fixture_root/bin/websocat" \ - <<< '{"id":2147483648,"method":"Target.createTarget","params":{}}' \ + <<<'{"id":2147483648,"method":"Target.createTarget","params":{}}' \ >"$state/response" 2>"$state/stderr"; then printf 'fake CDP wire accepted an out-of-range request ID\n' >&2 return 1 @@ -308,7 +308,7 @@ assert_activation_before_park_reproduced() { FAKE_CDP_SCENARIO=activation-before-park \ FAKE_ACTIVATION_BEFORE_PARK=true \ "$fixture_root/bin/websocat" \ - <<< '{"id":1,"method":"Target.createTarget","params":{"url":"data:text/html,unprotected","newWindow":true,"background":true}}' \ + <<<'{"id":1,"method":"Target.createTarget","params":{"url":"data:text/html,unprotected","newWindow":true,"background":true}}' \ >"$state/response" test -e "$state/activation-stolen" test "$(cat "$state/active-workspace")" = agentbrowser diff --git a/flake/tests/runtime.nix b/flake/tests/runtime.nix index 1bc8356c..e8b60c0c 100644 --- a/flake/tests/runtime.nix +++ b/flake/tests/runtime.nix @@ -492,8 +492,10 @@ in "LiteLLM's local-glimmer entry must carry the loopback backend credential required by the OpenAI provider"; assert lib.assertMsg ( gemma26AbliteratedEntry != null - && gemma26AbliteratedEntry.litellm_params.model == "ollama_chat/hf.co/TrevorJS/gemma-4-26B-A4B-it-uncensored-GGUF:Q4_K_M" - && lib.elem "hf.co/TrevorJS/gemma-4-26B-A4B-it-uncensored-GGUF:Q4_K_M" localModels.ollamaLoadModels + && + gemma26AbliteratedEntry.litellm_params.model + == "ollama_chat/hf.co/TrevorJS/gemma-4-26B-A4B-it-uncensored-GGUF:Q4_K_M" + && lib.elem "hf.co/TrevorJS/gemma-4-26B-A4B-it-uncensored-GGUF:Q4_K_M" localModels.ollamaLoadModels ) "The Gemma 4 26B abliterated model must be both pulled by Ollama and exposed through LiteLLM"; pkgs.runCommand "local-model-roster-check" { } '' cat > "$out" <<'EOF_ROSTER' diff --git a/modules/features/desktop/activitywatch.nix b/modules/features/desktop/activitywatch.nix index 86eab2b2..d0d83d3e 100644 --- a/modules/features/desktop/activitywatch.nix +++ b/modules/features/desktop/activitywatch.nix @@ -74,7 +74,12 @@ mkFeatureModule { }; home-manager.users.${user} = - { pkgs, lib, config, ... }: + { + pkgs, + lib, + config, + ... + }: { # awatcher (Rust) handles both AFK and window tracking natively on # Wayland; aw-watcher-afk is X11-only. diff --git a/modules/features/desktop/hyprland/bindings.nix b/modules/features/desktop/hyprland/bindings.nix index 297b6409..92264f1c 100644 --- a/modules/features/desktop/hyprland/bindings.nix +++ b/modules/features/desktop/hyprland/bindings.nix @@ -112,7 +112,9 @@ let "${scriptPkgs.sinnix-shader}/bin/sinnix-shader play --random --interval 6 --crossfade 1.5" ) (run "F6" "Toggle the WeeChat scratchpad" "uwsm app -- ${script "toggle-scratch"} weechat") - (run "F7" "Switch to or leave the agent browser workspace" "sinnix-chrome-control toggle-agent-workspace") + (run "F7" "Switch to or leave the agent browser workspace" + "sinnix-chrome-control toggle-agent-workspace" + ) (run "F8" "Toggle the raw-log scratchpad" "uwsm app -- ${script "toggle-scratch"} rawlog") (run "F9" "Emergency stop for runaway builds and background work" "sudo -n ${scriptPkgs.nuke-builds}/bin/nuke-builds" diff --git a/modules/services/weechat-log-sealer/seal_logs.py b/modules/services/weechat-log-sealer/seal_logs.py index d601680a..71b14089 100644 --- a/modules/services/weechat-log-sealer/seal_logs.py +++ b/modules/services/weechat-log-sealer/seal_logs.py @@ -26,6 +26,7 @@ python3 seal_logs.py """ + from __future__ import annotations import hashlib @@ -70,7 +71,10 @@ def seal_file(path: Path) -> Path | None: target = path.with_name(f"{path.stem}.b2-{digest}.log") if target.exists(): # Different fd already produced this; leave both alone for inspection. - print(f" conflict: {target.name} already exists, leaving {path.name}", file=sys.stderr) + print( + f" conflict: {target.name} already exists, leaving {path.name}", + file=sys.stderr, + ) return None path.rename(target) return target @@ -98,7 +102,9 @@ def seal_all(root: Path, buffer_days: int = SEAL_BUFFER_DAYS) -> tuple[int, int] continue if seal_file(log) is not None: sealed_count += 1 - print(f"sealed {sealed_count} files (skipped {skipped_recent} within {buffer_days}-day buffer)") + print( + f"sealed {sealed_count} files (skipped {skipped_recent} within {buffer_days}-day buffer)" + ) return (sealed_count, skipped_recent) diff --git a/pkgs/sinnix-agent-gateway/fixtures/v2-examples.json b/pkgs/sinnix-agent-gateway/fixtures/v2-examples.json index 55af1016..8bdd29d5 100644 --- a/pkgs/sinnix-agent-gateway/fixtures/v2-examples.json +++ b/pkgs/sinnix-agent-gateway/fixtures/v2-examples.json @@ -56,13 +56,9 @@ }, "status": "open" }, - "includes": [ - "dependencies" - ], + "includes": ["dependencies"], "limit": 50, - "project_ids": [ - "polylogue" - ], + "project_ids": ["polylogue"], "view": "query" } }, @@ -76,13 +72,9 @@ }, "status": "open" }, - "includes": [ - "dependencies" - ], + "includes": ["dependencies"], "limit": 50, - "project_ids": [ - "polylogue" - ], + "project_ids": ["polylogue"], "view": "query" } }, @@ -435,11 +427,7 @@ "action": "shell.run", "cli_input": { "action_name": "shell.run", - "argv": [ - "git", - "status", - "--short" - ], + "argv": ["git", "status", "--short"], "checkout_id": "default", "cwd": ".", "idempotency_key": "shell-status-example", @@ -447,11 +435,7 @@ "timeout_seconds": 300 }, "input": { - "argv": [ - "git", - "status", - "--short" - ], + "argv": ["git", "status", "--short"], "checkout_id": "default", "cwd": ".", "idempotency_key": "shell-status-example", diff --git a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/artifacts.py b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/artifacts.py index 32276e8f..d84d6a6f 100644 --- a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/artifacts.py +++ b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/artifacts.py @@ -8,9 +8,10 @@ from pathlib import Path from typing import Any +from sinnix_mcp.execution import ExecutionResult + from .capabilities import Capability, Principal from .config import GatewayConfig -from sinnix_mcp.execution import ExecutionResult from .redaction import redact @@ -64,7 +65,9 @@ def attest_capture( for file in files: file = file.resolve(strict=True) if file.parent != directory or not file.is_file(): - raise ArtifactError("capture file is outside its declared capture directory") + raise ArtifactError( + "capture file is outside its declared capture directory" + ) names.append(file.name) if not names or len(names) != len(set(names)): raise ArtifactError("capture receipt must identify distinct files") @@ -78,7 +81,9 @@ def attest_capture( output = directory / "receipt.json" temporary = directory / f".{output.name}.{uuid.uuid4().hex}.tmp" try: - temporary.write_text(json.dumps(receipt, sort_keys=True, separators=(",", ":"))) + temporary.write_text( + json.dumps(receipt, sort_keys=True, separators=(",", ":")) + ) temporary.chmod(0o600) os.replace(temporary, output) finally: @@ -115,7 +120,9 @@ def record_owner_diagnostic( "files": [source.name], } receipt_path = directory / "receipt.json" - receipt_path.write_text(json.dumps(receipt, sort_keys=True, separators=(",", ":"))) + receipt_path.write_text( + json.dumps(receipt, sort_keys=True, separators=(",", ":")) + ) receipt_path.chmod(0o600) artifact_id = self.register( source, @@ -195,7 +202,10 @@ def _metadata(self, artifact_id: str) -> dict[str, Any]: metadata = json.loads(path.read_text()) except (FileNotFoundError, json.JSONDecodeError) as exc: raise ArtifactError("unknown or malformed artifact") from exc - if self.principal.name != "operator" and metadata.get("principal") != self.principal.name: + if ( + self.principal.name != "operator" + and metadata.get("principal") != self.principal.name + ): raise ArtifactError("artifact is unavailable to this principal") source = Path(metadata["source"]).resolve(strict=True) if not source.is_file() or not self._source_is_attested(source): @@ -215,7 +225,10 @@ def list(self, limit: int = 100) -> dict[str, Any]: if self.principal.name == "operator": rows.append({"artifact_id": path.parent.name, "malformed": True}) continue - if self.principal.name != "operator" and row.get("principal") != self.principal.name: + if ( + self.principal.name != "operator" + and row.get("principal") != self.principal.name + ): continue row.pop("source", None) rows.append(row) diff --git a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/beads.py b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/beads.py index e12f3859..0b0aca87 100644 --- a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/beads.py +++ b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/beads.py @@ -21,28 +21,100 @@ class BeadsError(ProtocolError): """An owner-backed failure which remains typed at the V2 boundary.""" def __init__( - self, message: str, code: str = "invalid_request", *, details: Mapping[str, Any] | None = None + self, + message: str, + code: str = "invalid_request", + *, + details: Mapping[str, Any] | None = None, ) -> None: super().__init__(code, message, details=details) _ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$") -_FIELDS = frozenset({"status", "priority", "type", "assignee", "owner", "label", "title", "description", "notes", "created", "updated", "started", "closed", "id", "spec", "pinned", "ephemeral", "template", "parent", "mol_type"}) +_FIELDS = frozenset( + { + "status", + "priority", + "type", + "assignee", + "owner", + "label", + "title", + "description", + "notes", + "created", + "updated", + "started", + "closed", + "id", + "spec", + "pinned", + "ephemeral", + "template", + "parent", + "mol_type", + } +) _LIST_FLAGS = { - "assignee": "--assignee", "closed_after": "--closed-after", "closed_before": "--closed-before", - "created_after": "--created-after", "created_before": "--created-before", "defer_after": "--defer-after", - "defer_before": "--defer-before", "desc_contains": "--desc-contains", "due_after": "--due-after", - "due_before": "--due-before", "external_contains": "--external-contains", "external_ref": "--external-ref", - "has_metadata_key": "--has-metadata-key", "id": "--id", "label_pattern": "--label-pattern", - "label_regex": "--label-regex", "mol_type": "--mol-type", "notes_contains": "--notes-contains", - "parent": "--parent", "priority": "--priority", "priority_max": "--priority-max", - "priority_min": "--priority-min", "spec": "--spec", "status": "--status", "title": "--title", - "title_contains": "--title-contains", "type": "--type", "updated_after": "--updated-after", - "updated_before": "--updated-before", "wisp_type": "--wisp-type", + "assignee": "--assignee", + "closed_after": "--closed-after", + "closed_before": "--closed-before", + "created_after": "--created-after", + "created_before": "--created-before", + "defer_after": "--defer-after", + "defer_before": "--defer-before", + "desc_contains": "--desc-contains", + "due_after": "--due-after", + "due_before": "--due-before", + "external_contains": "--external-contains", + "external_ref": "--external-ref", + "has_metadata_key": "--has-metadata-key", + "id": "--id", + "label_pattern": "--label-pattern", + "label_regex": "--label-regex", + "mol_type": "--mol-type", + "notes_contains": "--notes-contains", + "parent": "--parent", + "priority": "--priority", + "priority_max": "--priority-max", + "priority_min": "--priority-min", + "spec": "--spec", + "status": "--status", + "title": "--title", + "title_contains": "--title-contains", + "type": "--type", + "updated_after": "--updated-after", + "updated_before": "--updated-before", + "wisp_type": "--wisp-type", +} +_LIST_REPEAT_FLAGS = { + "label": "--label", + "label_any": "--label-any", + "exclude_label": "--exclude-label", + "exclude_type": "--exclude-type", + "metadata_field": "--metadata-field", +} +_LIST_BOOLEAN_FLAGS = { + "all": "--all", + "deferred": "--deferred", + "empty_description": "--empty-description", + "include_gates": "--include-gates", + "include_infra": "--include-infra", + "include_templates": "--include-templates", + "no_assignee": "--no-assignee", + "no_labels": "--no-labels", + "no_parent": "--no-parent", + "no_pinned": "--no-pinned", + "overdue": "--overdue", + "pinned": "--pinned", + "ready": "--ready", +} +_READY_BOOLEAN_FLAGS = { + "gated": "--gated", + "include_deferred": "--include-deferred", + "include_ephemeral": "--include-ephemeral", + "unassigned": "--unassigned", } -_LIST_REPEAT_FLAGS = {"label": "--label", "label_any": "--label-any", "exclude_label": "--exclude-label", "exclude_type": "--exclude-type", "metadata_field": "--metadata-field"} -_LIST_BOOLEAN_FLAGS = {"all": "--all", "deferred": "--deferred", "empty_description": "--empty-description", "include_gates": "--include-gates", "include_infra": "--include-infra", "include_templates": "--include-templates", "no_assignee": "--no-assignee", "no_labels": "--no-labels", "no_parent": "--no-parent", "no_pinned": "--no-pinned", "overdue": "--overdue", "pinned": "--pinned", "ready": "--ready"} -_READY_BOOLEAN_FLAGS = {"gated": "--gated", "include_deferred": "--include-deferred", "include_ephemeral": "--include-ephemeral", "unassigned": "--unassigned"} _MAX_PAGE = 200 _MAX_CHANGESET_ACTIONS = 128 _SYMBOL_RE = re.compile(r"^\$([A-Za-z][A-Za-z0-9_]{0,63})$") @@ -58,7 +130,11 @@ def __init__(self, config: GatewayConfig, principal: Principal): @staticmethod def _string(value: Any, name: str, maximum: int = 8192, empty: bool = False) -> str: - if not isinstance(value, str) or len(value) > maximum or (not empty and not value): + if ( + not isinstance(value, str) + or len(value) > maximum + or (not empty and not value) + ): raise BeadsError(f"{name} must be a bounded string") return value @@ -71,7 +147,11 @@ def _id(self, value: Any, name: str = "id") -> str: @staticmethod def _limit(value: Any, default: int = 50, maximum: int = _MAX_PAGE) -> int: value = default if value is None else value - if isinstance(value, bool) or not isinstance(value, int) or not 1 <= value <= maximum: + if ( + isinstance(value, bool) + or not isinstance(value, int) + or not 1 <= value <= maximum + ): raise BeadsError(f"limit must be 1-{maximum}") return value @@ -95,95 +175,317 @@ def _project(self, project_id: str, write: bool) -> ProjectConfig: raise BeadsError(f"project checkout is unavailable: {project_id}") return project - def _authority(self, project_id: str, write: bool) -> tuple[ProjectConfig, TaskAuthorityConfig]: + def _authority( + self, project_id: str, write: bool + ) -> tuple[ProjectConfig, TaskAuthorityConfig]: project = self._project(project_id, write) if project.task_authority is None: - raise BeadsError(f"project has no declared Beads task authority: {project_id}") + raise BeadsError( + f"project has no declared Beads task authority: {project_id}" + ) return project, project.task_authority - def _run(self, project: ProjectConfig, args: list[str], write: bool, *, text: bool = False) -> Any: - command = [self.config.beads_command, "--directory", str(project.path), "--json"] + def _run( + self, + project: ProjectConfig, + args: list[str], + write: bool, + *, + text: bool = False, + ) -> Any: + command = [ + self.config.beads_command, + "--directory", + str(project.path), + "--json", + ] if not write: command.append("--readonly") command += args - result = self.execution.run(command, ExecutionProfile( - route=OwnerRoute("beads"), timeout_seconds=30, cwd=project.path, - max_stdout_bytes=self.config.max_result_bytes, max_stderr_bytes=self.config.max_result_bytes, - environment={"HOME": str(Path.home()), "LANG": os.environ.get("LANG", "C.UTF-8"), "PATH": os.environ.get("PATH", "/run/current-system/sw/bin"), "BEADS_ACTOR": f"sinnix-gateway:{self.principal.name}"}, - )) - if result.failure_class == "command_timeout": raise BeadsError("Beads operation timed out", "deadline") - if result.failure_class == "command_output_bound": raise BeadsError("Beads response exceeded configured bound", "response_bound") + result = self.execution.run( + command, + ExecutionProfile( + route=OwnerRoute("beads"), + timeout_seconds=30, + cwd=project.path, + max_stdout_bytes=self.config.max_result_bytes, + max_stderr_bytes=self.config.max_result_bytes, + environment={ + "HOME": str(Path.home()), + "LANG": os.environ.get("LANG", "C.UTF-8"), + "PATH": os.environ.get("PATH", "/run/current-system/sw/bin"), + "BEADS_ACTOR": f"sinnix-gateway:{self.principal.name}", + }, + ), + ) + if result.failure_class == "command_timeout": + raise BeadsError("Beads operation timed out", "deadline") + if result.failure_class == "command_output_bound": + raise BeadsError( + "Beads response exceeded configured bound", "response_bound" + ) if result.failure_class: - error = (result.stdout + b"\n" + result.stderr).decode("utf-8", "replace").strip() - code = "precondition_failed" if result.exit_status == 13 or "--if-" in error else "owner_failed" + error = ( + (result.stdout + b"\n" + result.stderr) + .decode("utf-8", "replace") + .strip() + ) + code = ( + "precondition_failed" + if result.exit_status == 13 or "--if-" in error + else "owner_failed" + ) raise BeadsError(error or "Beads operation failed", code) if text: return result.stdout.decode("utf-8", "replace") - try: return json.loads(result.stdout) - except json.JSONDecodeError as exc: raise BeadsError("Beads did not return JSON", "owner_failed") from exc + try: + return json.loads(result.stdout) + except json.JSONDecodeError as exc: + raise BeadsError("Beads did not return JSON", "owner_failed") from exc def task_authority_status(self, project_id: str) -> dict[str, Any]: project, authority = self._authority(project_id, False) - where, status = self._run(project, ["where"], False), self._run(project, ["status"], False) - if not isinstance(where, Mapping) or not isinstance(where.get("path"), str) or not isinstance(where.get("database_path"), str): + where, status = ( + self._run(project, ["where"], False), + self._run(project, ["status"], False), + ) + if ( + not isinstance(where, Mapping) + or not isinstance(where.get("path"), str) + or not isinstance(where.get("database_path"), str) + ): raise BeadsError("Beads where did not return path and database_path") - if Path(where["path"]).resolve() != authority.workspace or Path(where["database_path"]).resolve() != authority.database: - raise BeadsError("task_authority_mismatch: configured Beads workspace or database does not match bd where") - if not isinstance(status, Mapping): raise BeadsError("Beads status did not return an object") - revision = hashlib.sha256(json.dumps(status, sort_keys=True, separators=(",", ":")).encode()).hexdigest() - return {"project_id": project_id, "ref": self.project_ref(project_id), "owner": authority.owner, "publication_policy": authority.publication_policy, "project_uuid": authority.project_uuid, "schema_version": where.get("schema_version"), "revision": revision, "summary": status.get("summary"), "attested": True} + if ( + Path(where["path"]).resolve() != authority.workspace + or Path(where["database_path"]).resolve() != authority.database + ): + raise BeadsError( + "task_authority_mismatch: configured Beads workspace or database does not match bd where" + ) + if not isinstance(status, Mapping): + raise BeadsError("Beads status did not return an object") + revision = hashlib.sha256( + json.dumps(status, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest() + return { + "project_id": project_id, + "ref": self.project_ref(project_id), + "owner": authority.owner, + "publication_policy": authority.publication_policy, + "project_uuid": authority.project_uuid, + "schema_version": where.get("schema_version"), + "revision": revision, + "summary": status.get("summary"), + "attested": True, + } - def _attest(self, project_id: str, write: bool) -> tuple[ProjectConfig, dict[str, Any]]: + def _attest( + self, project_id: str, write: bool + ) -> tuple[ProjectConfig, dict[str, Any]]: project, _ = self._authority(project_id, write) return project, self.task_authority_status(project_id) @staticmethod def _issues(value: Any) -> list[dict[str, Any]]: - rows = value if isinstance(value, list) else value.get("issues") if isinstance(value, Mapping) else None - if rows is None and isinstance(value, Mapping) and isinstance(value.get("issue"), Mapping): rows = [value["issue"]] - if rows is None and isinstance(value, Mapping) and isinstance(value.get("id"), str): rows = [value] - if not isinstance(rows, list) or any(not isinstance(row, Mapping) or not isinstance(row.get("id"), str) for row in rows): + rows = ( + value + if isinstance(value, list) + else value.get("issues") + if isinstance(value, Mapping) + else None + ) + if ( + rows is None + and isinstance(value, Mapping) + and isinstance(value.get("issue"), Mapping) + ): + rows = [value["issue"]] + if ( + rows is None + and isinstance(value, Mapping) + and isinstance(value.get("id"), str) + ): + rows = [value] + if not isinstance(rows, list) or any( + not isinstance(row, Mapping) or not isinstance(row.get("id"), str) + for row in rows + ): raise BeadsError("Beads response omitted normalized issue records") return [dict(row) for row in rows] - def _normalize(self, project_id: str, row: Mapping[str, Any], revision: str) -> dict[str, Any]: + def _normalize( + self, project_id: str, row: Mapping[str, Any], revision: str + ) -> dict[str, Any]: bead_id = self._id(row["id"]) parent = row.get("parent_id", row.get("parent")) - if isinstance(parent, Mapping): parent = parent.get("id") - native_keys = {"id", "title", "description", "design", "acceptance_criteria", "status", "priority", "issue_type", "assignee", "owner", "created_at", "updated_at", "started_at", "closed_at", "close_reason", "labels", "metadata", "notes", "parent", "parent_id", "dependencies", "external_ref", "spec_id", "due_at", "defer_until", "estimate", "ephemeral"} - links = {key: f"{self.bead_ref(project_id, bead_id)}/{key}" for key in ("comments", "history", "events", "dependencies", "dependents", "children", "refs", "jobs", "receipts")} + if isinstance(parent, Mapping): + parent = parent.get("id") + native_keys = { + "id", + "title", + "description", + "design", + "acceptance_criteria", + "status", + "priority", + "issue_type", + "assignee", + "owner", + "created_at", + "updated_at", + "started_at", + "closed_at", + "close_reason", + "labels", + "metadata", + "notes", + "parent", + "parent_id", + "dependencies", + "external_ref", + "spec_id", + "due_at", + "defer_until", + "estimate", + "ephemeral", + } + links = { + key: f"{self.bead_ref(project_id, bead_id)}/{key}" + for key in ( + "comments", + "history", + "events", + "dependencies", + "dependents", + "children", + "refs", + "jobs", + "receipts", + ) + } links["project"] = self.project_ref(project_id) - parent_ref = self.bead_ref(project_id, parent) if isinstance(parent, str) and _ID_RE.fullmatch(parent) else None + parent_ref = ( + self.bead_ref(project_id, parent) + if isinstance(parent, str) and _ID_RE.fullmatch(parent) + else None + ) if parent_ref is not None: links["parent"] = parent_ref - etag = hashlib.sha256(json.dumps(dict(row), sort_keys=True, separators=(",", ":"), default=str).encode()).hexdigest() - return {"ref": self.bead_ref(project_id, bead_id), "id": bead_id, "project_id": project_id, "task_revision": revision, "etag": etag, "fields": {key: row[key] for key in sorted(native_keys - {"id", "parent", "parent_id", "dependencies"}) if key in row}, "parent_ref": parent_ref, "links": links, "native": {key: value for key, value in row.items() if key not in native_keys}} + etag = hashlib.sha256( + json.dumps( + dict(row), sort_keys=True, separators=(",", ":"), default=str + ).encode() + ).hexdigest() + return { + "ref": self.bead_ref(project_id, bead_id), + "id": bead_id, + "project_id": project_id, + "task_revision": revision, + "etag": etag, + "fields": { + key: row[key] + for key in sorted( + native_keys - {"id", "parent", "parent_id", "dependencies"} + ) + if key in row + }, + "parent_ref": parent_ref, + "links": links, + "native": { + key: value for key, value in row.items() if key not in native_keys + }, + } - def _includes(self, project: ProjectConfig, project_id: str, bead_id: str, includes: set[str]) -> dict[str, Any]: - commands = {"comments": ["comments", bead_id], "history": ["history", bead_id, "--limit", "20"], "events": ["history", bead_id, "--events", "--limit", "20"], "dependencies": ["dep", "list", bead_id, "--direction", "down"], "dependents": ["dep", "list", bead_id, "--direction", "up"], "children": ["list", "--parent", bead_id, "--flat", "--limit", str(_MAX_PAGE), "--max-rows", str(_MAX_PAGE)], "refs": ["show", bead_id, "--refs"]} + def _includes( + self, project: ProjectConfig, project_id: str, bead_id: str, includes: set[str] + ) -> dict[str, Any]: + commands = { + "comments": ["comments", bead_id], + "history": ["history", bead_id, "--limit", "20"], + "events": ["history", bead_id, "--events", "--limit", "20"], + "dependencies": ["dep", "list", bead_id, "--direction", "down"], + "dependents": ["dep", "list", bead_id, "--direction", "up"], + "children": [ + "list", + "--parent", + bead_id, + "--flat", + "--limit", + str(_MAX_PAGE), + "--max-rows", + str(_MAX_PAGE), + ], + "refs": ["show", bead_id, "--refs"], + } unsupported = includes - set(commands) unsupported -= {"blockers"} - if unsupported: raise BeadsError(f"unsupported_capability: unsupported Beads includes: {sorted(unsupported)}") - result = {name: self._run(project, commands[name], False) for name in sorted(includes - {"blockers"})} + if unsupported: + raise BeadsError( + f"unsupported_capability: unsupported Beads includes: {sorted(unsupported)}" + ) + result = { + name: self._run(project, commands[name], False) + for name in sorted(includes - {"blockers"}) + } if "blockers" in includes: - rows = self._issues(self._run(project, ["dep", "list", bead_id, "--direction", "down", "--type", "blocks"], False)) - result["blockers"] = {"count": len(rows), "items": [{key: row.get(key) for key in ("id", "title", "status", "priority", "dependency_type")} for row in rows]} + rows = self._issues( + self._run( + project, + ["dep", "list", bead_id, "--direction", "down", "--type", "blocks"], + False, + ) + ) + result["blockers"] = { + "count": len(rows), + "items": [ + { + key: row.get(key) + for key in ( + "id", + "title", + "status", + "priority", + "dependency_type", + ) + } + for row in rows + ], + } return result - def get(self, project_id: str, bead_id: str, *, includes: list[str] | None = None, as_of: str | None = None) -> dict[str, Any]: + def get( + self, + project_id: str, + bead_id: str, + *, + includes: list[str] | None = None, + as_of: str | None = None, + ) -> dict[str, Any]: if as_of is not None: as_of = self._string(as_of, "as_of", 256) - if not isinstance(includes or [], list) or not all(isinstance(item, str) for item in includes or []): raise BeadsError("includes must be a list of strings") + if not isinstance(includes or [], list) or not all( + isinstance(item, str) for item in includes or [] + ): + raise BeadsError("includes must be a list of strings") project, status = self._attest(project_id, False) command = ["show", self._id(bead_id)] requested = set(includes or []) - for include, flag in (("comments", "--include-comments"), ("dependents", "--include-dependents")): + for include, flag in ( + ("comments", "--include-comments"), + ("dependents", "--include-dependents"), + ): if include in requested: command.append(flag) if as_of is not None: command += ["--as-of", as_of] - result = self._normalize(project_id, self._issues(self._run(project, command, False))[0], status["revision"]) - result["includes"] = self._includes(project, project_id, result["id"], requested) + result = self._normalize( + project_id, + self._issues(self._run(project, command, False))[0], + status["revision"], + ) + result["includes"] = self._includes( + project, project_id, result["id"], requested + ) result["as_of"] = as_of return result @@ -191,47 +493,96 @@ def get(self, project_id: str, bead_id: str, *, includes: list[str] | None = Non def _filter_expression(filters: Mapping[str, Any]) -> str: def atom(field: str, value: Any) -> str: if field not in _FIELDS: - raise BeadsError(f"unsupported Beads query field {field!r}", "unsupported_capability") - op, raw = (value.get("op"), value.get("value")) if isinstance(value, Mapping) else ("=", value) + raise BeadsError( + f"unsupported Beads query field {field!r}", "unsupported_capability" + ) + op, raw = ( + (value.get("op"), value.get("value")) + if isinstance(value, Mapping) + else ("=", value) + ) if op not in {"=", "!=", ">", ">=", "<", "<="}: raise BeadsError("filter op is invalid") - if isinstance(raw, bool): encoded = str(raw).lower() - elif isinstance(raw, (int, float)) and not isinstance(raw, bool): encoded = str(raw) - elif isinstance(raw, str) and raw and len(raw) <= 1000: encoded = json.dumps(raw) if any(c.isspace() for c in raw) else raw - else: raise BeadsError(f"filter {field!r} has an invalid value") + if isinstance(raw, bool): + encoded = str(raw).lower() + elif isinstance(raw, (int, float)) and not isinstance(raw, bool): + encoded = str(raw) + elif isinstance(raw, str) and raw and len(raw) <= 1000: + encoded = json.dumps(raw) if any(c.isspace() for c in raw) else raw + else: + raise BeadsError(f"filter {field!r} has an invalid value") return f"{field}{op}{encoded}" def compile_node(node: Any) -> str: if not isinstance(node, Mapping) or not node: raise BeadsError("filter AST node must be a non-empty object") if set(node) == {"and"} or set(node) == {"or"}: - key = next(iter(node)); children = node[key] + key = next(iter(node)) + children = node[key] if not isinstance(children, list) or not children: raise BeadsError(f"filters.{key} must be a non-empty list") - return "(" + f" {key.upper()} ".join(compile_node(item) for item in children) + ")" + return ( + "(" + + f" {key.upper()} ".join(compile_node(item) for item in children) + + ")" + ) if set(node) == {"not"}: return "NOT (" + compile_node(node["not"]) + ")" - return " AND ".join(atom(field, value) for field, value in sorted(node.items())) + return " AND ".join( + atom(field, value) for field, value in sorted(node.items()) + ) - return compile_node(filters) if any(key in filters for key in {"and", "or", "not"}) else " AND ".join(atom(field, value) for field, value in sorted(filters.items())) + return ( + compile_node(filters) + if any(key in filters for key in {"and", "or", "not"}) + else " AND ".join( + atom(field, value) for field, value in sorted(filters.items()) + ) + ) - def _native_list_filters(self, values: Mapping[str, Any], *, view: str) -> list[str]: + def _native_list_filters( + self, values: Mapping[str, Any], *, view: str + ) -> list[str]: if not isinstance(values, Mapping): raise BeadsError("native_filters must be an object") - ready_allowed = {"assignee", "exclude_label", "exclude_type", "has_metadata_key", "label", "label_any", "label_pattern", "label_regex", "metadata_field", "mol", "mol_type", "parent", "priority", "type", *(_READY_BOOLEAN_FLAGS)} + ready_allowed = { + "assignee", + "exclude_label", + "exclude_type", + "has_metadata_key", + "label", + "label_any", + "label_pattern", + "label_regex", + "metadata_field", + "mol", + "mol_type", + "parent", + "priority", + "type", + *(_READY_BOOLEAN_FLAGS), + } if view == "ready" and set(values) - ready_allowed: - raise BeadsError(f"unsupported native ready filters: {sorted(set(values) - ready_allowed)}", "unsupported_capability") + raise BeadsError( + f"unsupported native ready filters: {sorted(set(values) - ready_allowed)}", + "unsupported_capability", + ) command: list[str] = [] for key, flag in _LIST_FLAGS.items(): if key not in values: continue - command += [flag, self._string(str(values[key]), f"native_filters.{key}", 1_000)] + command += [ + flag, + self._string(str(values[key]), f"native_filters.{key}", 1_000), + ] for key, flag in _LIST_REPEAT_FLAGS.items(): if key not in values: continue items = values[key] if not isinstance(items, list) or not items or len(items) > 32: - raise BeadsError(f"native_filters.{key} must be a bounded non-empty string list") + raise BeadsError( + f"native_filters.{key} must be a bounded non-empty string list" + ) for item in items: command += [flag, self._string(item, f"native_filters.{key}", 1_000)] for key, flag in _LIST_BOOLEAN_FLAGS.items(): @@ -244,138 +595,502 @@ def _native_list_filters(self, values: Mapping[str, Any], *, view: str) -> list[ if values[key] is not True: raise BeadsError(f"native_filters.{key} must be true when supplied") command.append(flag) - unknown = set(values) - set(_LIST_FLAGS) - set(_LIST_REPEAT_FLAGS) - set(_LIST_BOOLEAN_FLAGS) - set(_READY_BOOLEAN_FLAGS) - {"mol", "stale_days"} + unknown = ( + set(values) + - set(_LIST_FLAGS) + - set(_LIST_REPEAT_FLAGS) + - set(_LIST_BOOLEAN_FLAGS) + - set(_READY_BOOLEAN_FLAGS) + - {"mol", "stale_days"} + ) if "mol" in values: - command += ["--mol", self._string(str(values["mol"]), "native_filters.mol", 128)] + command += [ + "--mol", + self._string(str(values["mol"]), "native_filters.mol", 128), + ] if unknown: - raise BeadsError(f"unsupported native list filters: {sorted(unknown)}", "unsupported_capability") + raise BeadsError( + f"unsupported native list filters: {sorted(unknown)}", + "unsupported_capability", + ) return command - def _snapshot_page(self, key: str, source_revision: str, rows: list[dict[str, Any]], limit: int, cursor: str | None) -> tuple[list[dict[str, Any]], dict[str, Any]]: - directory = self.config.state_dir / "beads-snapshots"; directory.mkdir(mode=0o700, parents=True, exist_ok=True) + def _snapshot_page( + self, + key: str, + source_revision: str, + rows: list[dict[str, Any]], + limit: int, + cursor: str | None, + ) -> tuple[list[dict[str, Any]], dict[str, Any]]: + directory = self.config.state_dir / "beads-snapshots" + directory.mkdir(mode=0o700, parents=True, exist_ok=True) if cursor is None: - token = hashlib.sha256(f"{key}:{source_revision}:{time.time_ns()}".encode()).hexdigest(); payload = {"key": key, "source_revision": source_revision, "expires_at": time.time() + 300, "rows": rows}; (directory / f"{token}.json").write_text(json.dumps(payload, sort_keys=True, separators=(",", ":"))); offset = 0 + token = hashlib.sha256( + f"{key}:{source_revision}:{time.time_ns()}".encode() + ).hexdigest() + payload = { + "key": key, + "source_revision": source_revision, + "expires_at": time.time() + 300, + "rows": rows, + } + (directory / f"{token}.json").write_text( + json.dumps(payload, sort_keys=True, separators=(",", ":")) + ) + offset = 0 else: try: - token, value = cursor.split(".", 1); offset = int(value); payload = json.loads((directory / f"{token}.json").read_text()) - except (OSError, ValueError, json.JSONDecodeError) as exc: raise BeadsError("stale_cursor: Beads snapshot is unavailable") from exc - if payload.get("expires_at", 0) < time.time() or payload.get("key") != key: raise BeadsError("stale_cursor: Beads snapshot expired or belongs to another query") - if payload.get("source_revision") != source_revision: raise BeadsError("source_changed: Beads source changed during paging") + token, value = cursor.split(".", 1) + offset = int(value) + payload = json.loads((directory / f"{token}.json").read_text()) + except (OSError, ValueError, json.JSONDecodeError) as exc: + raise BeadsError("stale_cursor: Beads snapshot is unavailable") from exc + if payload.get("expires_at", 0) < time.time() or payload.get("key") != key: + raise BeadsError( + "stale_cursor: Beads snapshot expired or belongs to another query" + ) + if payload.get("source_revision") != source_revision: + raise BeadsError("source_changed: Beads source changed during paging") rows = payload["rows"] - page = rows[offset:offset + limit]; next_cursor = f"{token}.{offset + limit}" if offset + limit < len(rows) else None - return page, {"kind": "snapshot", "cursor": cursor, "next_cursor": next_cursor, "offset": offset, "next_offset": offset + limit if next_cursor else None, "total": len(rows), "expires_at": payload["expires_at"], "snapshot_ref": f"sinnix://results/beads-{token}"} - - def query(self, *, project_ids: list[str] | None, view: str = "query", filters: Mapping[str, Any] | None = None, expression: str | None = None, native_filters: Mapping[str, Any] | None = None, order: Mapping[str, Any] | None = None, includes: list[str] | None = None, limit: int | None = None, cursor: str | None = None) -> dict[str, Any]: - project_ids = sorted(self.config.projects) if project_ids is None else project_ids - if not isinstance(project_ids, list) or not project_ids or len(project_ids) > 32 or len(set(project_ids)) != len(project_ids): raise BeadsError("project_ids must contain 1-32 unique projects") - if filters is not None and not isinstance(filters, Mapping): raise BeadsError("filters must be an object") - expression = self._string(expression, "expression", 4000) if expression is not None else None + page = rows[offset : offset + limit] + next_cursor = ( + f"{token}.{offset + limit}" if offset + limit < len(rows) else None + ) + return page, { + "kind": "snapshot", + "cursor": cursor, + "next_cursor": next_cursor, + "offset": offset, + "next_offset": offset + limit if next_cursor else None, + "total": len(rows), + "expires_at": payload["expires_at"], + "snapshot_ref": f"sinnix://results/beads-{token}", + } + + def query( + self, + *, + project_ids: list[str] | None, + view: str = "query", + filters: Mapping[str, Any] | None = None, + expression: str | None = None, + native_filters: Mapping[str, Any] | None = None, + order: Mapping[str, Any] | None = None, + includes: list[str] | None = None, + limit: int | None = None, + cursor: str | None = None, + ) -> dict[str, Any]: + project_ids = ( + sorted(self.config.projects) if project_ids is None else project_ids + ) + if ( + not isinstance(project_ids, list) + or not project_ids + or len(project_ids) > 32 + or len(set(project_ids)) != len(project_ids) + ): + raise BeadsError("project_ids must contain 1-32 unique projects") + if filters is not None and not isinstance(filters, Mapping): + raise BeadsError("filters must be an object") + expression = ( + self._string(expression, "expression", 4000) + if expression is not None + else None + ) generated = self._filter_expression(filters or {}) - expression = f"({generated}) AND ({expression})" if generated and expression else generated or expression - if not isinstance(includes or [], list) or not all(isinstance(item, str) for item in includes or []): raise BeadsError("includes must be strings") + expression = ( + f"({generated}) AND ({expression})" + if generated and expression + else generated or expression + ) + if not isinstance(includes or [], list) or not all( + isinstance(item, str) for item in includes or [] + ): + raise BeadsError("includes must be strings") requested = set(includes or []) native_args = self._native_list_filters(native_filters or {}, view=view) if view == "query" and native_filters: - raise BeadsError("query supports standard filters and native expression, not list-only filters", "unsupported_capability") + raise BeadsError( + "query supports standard filters and native expression, not list-only filters", + "unsupported_capability", + ) if view == "stale_claims" and set(native_filters or {}) - {"stale_days"}: - raise BeadsError("stale_claims supports only native_filters.stale_days", "unsupported_capability") + raise BeadsError( + "stale_claims supports only native_filters.stale_days", + "unsupported_capability", + ) if view == "blocked" and native_filters: - raise BeadsError("blocked has no owner-native filter flags", "unsupported_capability") - rows: list[dict[str, Any]] = []; coverage: dict[str, Any] = {}; revisions: dict[str, str] = {}; parsed: dict[str, Any] = {} + raise BeadsError( + "blocked has no owner-native filter flags", "unsupported_capability" + ) + rows: list[dict[str, Any]] = [] + coverage: dict[str, Any] = {} + revisions: dict[str, str] = {} + parsed: dict[str, Any] = {} for project_id in sorted(project_ids): try: - project, status = self._attest(project_id, False); revisions[project_id] = status["revision"] + project, status = self._attest(project_id, False) + revisions[project_id] = status["revision"] owner_cap = _MAX_PAGE - if expression: parsed[project_id] = self._run(project, ["query", expression, "--parse-only"], False) + if expression: + parsed[project_id] = self._run( + project, ["query", expression, "--parse-only"], False + ) if (expression or view == "query") and native_args: - raise BeadsError("native list filters cannot be combined with the owner query route", "unsupported_capability") + raise BeadsError( + "native list filters cannot be combined with the owner query route", + "unsupported_capability", + ) if view == "ready": owner_cap = self._limit(limit) - command = ["ready", "--limit", str(owner_cap), "--max-rows", str(owner_cap)] - elif view == "blocked": command = ["blocked"] - elif view in {"open", "all", "recent", "overdue", "deferred", "unassigned", "stale_claims", "epic_progress", "changed_since"}: - command = ["list", "--flat", "--limit", str(_MAX_PAGE), "--max-rows", str(_MAX_PAGE)] - command += {"open": ["--status", "open"], "all": ["--all"], "recent": ["--sort", "updated", "--reverse"], "overdue": ["--overdue"], "deferred": ["--deferred"], "unassigned": ["--no-assignee"], "epic_progress": ["--type", "epic"]}.get(view, []) + command = [ + "ready", + "--limit", + str(owner_cap), + "--max-rows", + str(owner_cap), + ] + elif view == "blocked": + command = ["blocked"] + elif view in { + "open", + "all", + "recent", + "overdue", + "deferred", + "unassigned", + "stale_claims", + "epic_progress", + "changed_since", + }: + command = [ + "list", + "--flat", + "--limit", + str(_MAX_PAGE), + "--max-rows", + str(_MAX_PAGE), + ] + command += { + "open": ["--status", "open"], + "all": ["--all"], + "recent": ["--sort", "updated", "--reverse"], + "overdue": ["--overdue"], + "deferred": ["--deferred"], + "unassigned": ["--no-assignee"], + "epic_progress": ["--type", "epic"], + }.get(view, []) if view == "stale_claims": - command = ["stale", "--status", "in_progress", "--limit", str(_MAX_PAGE)] - if "stale_days" in (native_filters or {}): command += ["--days", str(native_filters["stale_days"])] - if view == "changed_since" and "updated_after" not in (native_filters or {}): - raise BeadsError("changed_since requires native_filters.updated_after") + command = [ + "stale", + "--status", + "in_progress", + "--limit", + str(_MAX_PAGE), + ] + if "stale_days" in (native_filters or {}): + command += ["--days", str(native_filters["stale_days"])] + if view == "changed_since" and "updated_after" not in ( + native_filters or {} + ): + raise BeadsError( + "changed_since requires native_filters.updated_after" + ) elif view == "query": - if not expression: raise BeadsError("query view requires filters or expression") + if not expression: + raise BeadsError("query view requires filters or expression") + command = ["query", expression, "--limit", str(_MAX_PAGE)] + else: + raise BeadsError( + f"unsupported_capability: unknown Beads view {view!r}" + ) + if expression and view != "query": command = ["query", expression, "--limit", str(_MAX_PAGE)] - else: raise BeadsError(f"unsupported_capability: unknown Beads view {view!r}") - if expression and view != "query": command = ["query", expression, "--limit", str(_MAX_PAGE)] - elif view != "stale_claims": command += native_args + elif view != "stale_claims": + command += native_args if order: - if not isinstance(order, Mapping) or set(order) - {"field", "reverse"} or order.get("field") not in {"priority", "created", "updated", "closed", "status", "id", "title", "type", "assignee"}: raise BeadsError("order is unsupported") - command += ["--sort", str(order["field"])] + (["--reverse"] if order.get("reverse") else []) - normalized = [self._normalize(project_id, row, status["revision"]) for row in self._issues(self._run(project, command, False))] + if ( + not isinstance(order, Mapping) + or set(order) - {"field", "reverse"} + or order.get("field") + not in { + "priority", + "created", + "updated", + "closed", + "status", + "id", + "title", + "type", + "assignee", + } + ): + raise BeadsError("order is unsupported") + command += ["--sort", str(order["field"])] + ( + ["--reverse"] if order.get("reverse") else [] + ) + normalized = [ + self._normalize(project_id, row, status["revision"]) + for row in self._issues(self._run(project, command, False)) + ] for row in normalized: - if requested: row["includes"] = self._includes(project, project_id, row["id"], requested) - rows += normalized; coverage[project_id] = {"state": "complete", "returned": len(normalized), "total": len(normalized), "total_exact": len(normalized) < owner_cap, "paging": "owner_native_unavailable" if len(normalized) == owner_cap else "complete", "revision": status["revision"]} - except BeadsError as exc: coverage[project_id] = {"state": "partial", "error": str(exc), "code": exc.code} - rows.sort(key=lambda row: (row["project_id"], row["id"])); key = hashlib.sha256(json.dumps({"principal": self.principal.name, "projects": sorted(project_ids), "view": view, "filters": filters or {}, "expression": expression, "order": order or {}, "includes": sorted(requested)}, sort_keys=True, separators=(",", ":")).encode()).hexdigest(); source_revision = hashlib.sha256(json.dumps(revisions, sort_keys=True).encode()).hexdigest(); page_rows, page = self._snapshot_page(key, source_revision, rows, self._limit(limit), cursor) - totals = {"returned": len(rows), "projects": len(project_ids), "healthy_projects": sum(item["state"] == "complete" for item in coverage.values()), "partial_projects": sum(item["state"] == "partial" for item in coverage.values()), "exact": all(item.get("total_exact", False) for item in coverage.values() if item["state"] == "complete") and not any(item["state"] == "partial" for item in coverage.values())} - warnings = ["partial_source" for item in coverage.values() if item["state"] == "partial"] - if any(item.get("paging") == "owner_native_unavailable" for item in coverage.values()): warnings.append("owner_paging_unavailable") - return {"kind": "bead_query", "items": page_rows, "page": page, "coverage": coverage, "totals": totals, "source_revisions": revisions, "native_parse": parsed, "owner_capabilities": {"native_expression_parse": True, "native_offset_paging": False, "exact_query_total": False}, "warnings": warnings} - - def graph(self, project_id: str, bead_id: str, *, direction: str = "down", edge_type: str | None = None, status: str | None = None, depth: int = 1, max_rows: int = 200, mermaid: bool = False) -> dict[str, Any]: - project, authority = self._attest(project_id, False); root = self._id(bead_id); depth = self._limit(depth, 1, 20); max_rows = self._limit(max_rows, 200, 1000) - if direction not in {"down", "up", "both"}: raise BeadsError("direction must be down, up, or both") - if status is not None: status = self._string(status, "status", 64) - tree_command = ["dep", "tree", root, "--direction", direction, "--max-depth", str(depth), "--max-rows", str(max_rows)] - if edge_type is not None: edge_type = self._string(edge_type, "edge_type", 64) - if status is not None: tree_command += ["--status", status] + if requested: + row["includes"] = self._includes( + project, project_id, row["id"], requested + ) + rows += normalized + coverage[project_id] = { + "state": "complete", + "returned": len(normalized), + "total": len(normalized), + "total_exact": len(normalized) < owner_cap, + "paging": "owner_native_unavailable" + if len(normalized) == owner_cap + else "complete", + "revision": status["revision"], + } + except BeadsError as exc: + coverage[project_id] = { + "state": "partial", + "error": str(exc), + "code": exc.code, + } + rows.sort(key=lambda row: (row["project_id"], row["id"])) + key = hashlib.sha256( + json.dumps( + { + "principal": self.principal.name, + "projects": sorted(project_ids), + "view": view, + "filters": filters or {}, + "expression": expression, + "order": order or {}, + "includes": sorted(requested), + }, + sort_keys=True, + separators=(",", ":"), + ).encode() + ).hexdigest() + source_revision = hashlib.sha256( + json.dumps(revisions, sort_keys=True).encode() + ).hexdigest() + page_rows, page = self._snapshot_page( + key, source_revision, rows, self._limit(limit), cursor + ) + totals = { + "returned": len(rows), + "projects": len(project_ids), + "healthy_projects": sum( + item["state"] == "complete" for item in coverage.values() + ), + "partial_projects": sum( + item["state"] == "partial" for item in coverage.values() + ), + "exact": all( + item.get("total_exact", False) + for item in coverage.values() + if item["state"] == "complete" + ) + and not any(item["state"] == "partial" for item in coverage.values()), + } + warnings = [ + "partial_source" for item in coverage.values() if item["state"] == "partial" + ] + if any( + item.get("paging") == "owner_native_unavailable" + for item in coverage.values() + ): + warnings.append("owner_paging_unavailable") + return { + "kind": "bead_query", + "items": page_rows, + "page": page, + "coverage": coverage, + "totals": totals, + "source_revisions": revisions, + "native_parse": parsed, + "owner_capabilities": { + "native_expression_parse": True, + "native_offset_paging": False, + "exact_query_total": False, + }, + "warnings": warnings, + } + + def graph( + self, + project_id: str, + bead_id: str, + *, + direction: str = "down", + edge_type: str | None = None, + status: str | None = None, + depth: int = 1, + max_rows: int = 200, + mermaid: bool = False, + ) -> dict[str, Any]: + project, authority = self._attest(project_id, False) + root = self._id(bead_id) + depth = self._limit(depth, 1, 20) + max_rows = self._limit(max_rows, 200, 1000) + if direction not in {"down", "up", "both"}: + raise BeadsError("direction must be down, up, or both") + if status is not None: + status = self._string(status, "status", 64) + tree_command = [ + "dep", + "tree", + root, + "--direction", + direction, + "--max-depth", + str(depth), + "--max-rows", + str(max_rows), + ] + if edge_type is not None: + edge_type = self._string(edge_type, "edge_type", 64) + if status is not None: + tree_command += ["--status", status] tree = self._run(project, tree_command, False) - mermaid_projection = self._run(project, tree_command + ["--format", "mermaid"], False, text=True) if mermaid else None + mermaid_projection = ( + self._run(project, tree_command + ["--format", "mermaid"], False, text=True) + if mermaid + else None + ) cycle_rows = self._run(project, ["dep", "cycles"], False) queue, nodes, edges, cycles = [(root, 0)], {root}, [], [] while queue: current, level = queue.pop(0) - if level >= depth: continue - commands = (["down", ["dep", "list", current, "--direction", "down"]], ["up", ["dep", "list", current, "--direction", "up"]]) + if level >= depth: + continue + commands = ( + ["down", ["dep", "list", current, "--direction", "down"]], + ["up", ["dep", "list", current, "--direction", "up"]], + ) for relation, command in commands: - if direction not in {relation, "both"}: continue + if direction not in {relation, "both"}: + continue typed_command = command + (["--type", edge_type] if edge_type else []) for row in self._issues(self._run(project, typed_command, False)): - other = self._id(row["id"]); kind = str(row.get("dependency_type", row.get("type", "depends-on"))) - if edge_type and edge_type != kind: continue - edges.append({"from": self.bead_ref(project_id, current), "to": self.bead_ref(project_id, other), "relation": relation, "type": kind}) + other = self._id(row["id"]) + kind = str( + row.get("dependency_type", row.get("type", "depends-on")) + ) + if edge_type and edge_type != kind: + continue + edges.append( + { + "from": self.bead_ref(project_id, current), + "to": self.bead_ref(project_id, other), + "relation": relation, + "type": kind, + } + ) if len(edges) > max_rows: raise BeadsError("graph exceeds max_rows", "response_bound") - if other == root: cycles.append([root, current, root]) - if other not in nodes: nodes.add(other); queue.append((other, level + 1)) - if len(nodes) > max_rows: raise BeadsError("response_bound: graph exceeds max_rows") + if other == root: + cycles.append([root, current, root]) + if other not in nodes: + nodes.add(other) + queue.append((other, level + 1)) + if len(nodes) > max_rows: + raise BeadsError("response_bound: graph exceeds max_rows") owner_cycles = cycle_rows if isinstance(cycle_rows, list) else [] - result = {"ref": self.bead_ref(project_id, root), "task_revision": authority["revision"], "direction": direction, "edge_type": edge_type, "status": status, "depth": depth, "max_rows": max_rows, "nodes": [{"id": item, "ref": self.bead_ref(project_id, item)} for item in sorted(nodes)], "edges": edges, "cycles": cycles, "owner_cycles": owner_cycles, "native_tree": tree, "owner_capabilities": {"tree_type_filter": False, "edge_list_type_filter": True, "native_cycle_detection": True, "native_mermaid": True}, "partial": False} - if mermaid: result["mermaid"] = mermaid_projection + result = { + "ref": self.bead_ref(project_id, root), + "task_revision": authority["revision"], + "direction": direction, + "edge_type": edge_type, + "status": status, + "depth": depth, + "max_rows": max_rows, + "nodes": [ + {"id": item, "ref": self.bead_ref(project_id, item)} + for item in sorted(nodes) + ], + "edges": edges, + "cycles": cycles, + "owner_cycles": owner_cycles, + "native_tree": tree, + "owner_capabilities": { + "tree_type_filter": False, + "edge_list_type_filter": True, + "native_cycle_detection": True, + "native_mermaid": True, + }, + "partial": False, + } + if mermaid: + result["mermaid"] = mermaid_projection return result - def memories(self, project_id: str, *, key: str | None = None, query: str | None = None) -> dict[str, Any]: + def memories( + self, project_id: str, *, key: str | None = None, query: str | None = None + ) -> dict[str, Any]: project, _ = self._attest(project_id, False) - if key and query: raise BeadsError("memory reads accept key or query, not both") - command = ["recall", self._string(key, "key", 256)] if key else ["memories", self._string(query, "query", 1000)] if query else ["memories"] - return {"kind": "bead_memory", "project_id": project_id, "result": self._run(project, command, False)} + if key and query: + raise BeadsError("memory reads accept key or query, not both") + command = ( + ["recall", self._string(key, "key", 256)] + if key + else ["memories", self._string(query, "query", 1000)] + if query + else ["memories"] + ) + return { + "kind": "bead_memory", + "project_id": project_id, + "result": self._run(project, command, False), + } - def _compile(self, operation: str, parameters: Mapping[str, Any]) -> tuple[str | None, list[str]]: + def _compile( + self, operation: str, parameters: Mapping[str, Any] + ) -> tuple[str | None, list[str]]: values = dict(parameters) if operation == "create": command = ["create", self._string(values.pop("title", None), "title", 512)] - flags = {"description": "--description", "design": "--design", "acceptance": "--acceptance", "type": "--type", "priority": "--priority", "assignee": "--assignee", "parent": "--parent", "due": "--due", "defer": "--defer", "external_ref": "--external-ref", "spec_id": "--spec-id", "status": "--status"} + flags = { + "description": "--description", + "design": "--design", + "acceptance": "--acceptance", + "type": "--type", + "priority": "--priority", + "assignee": "--assignee", + "parent": "--parent", + "due": "--due", + "defer": "--defer", + "external_ref": "--external-ref", + "spec_id": "--spec-id", + "status": "--status", + } for key, flag in flags.items(): - if key in values: command += [flag, self._string(values.pop(key), key, 32000)] + if key in values: + command += [flag, self._string(values.pop(key), key, 32000)] for key, flag in (("labels", "--labels"), ("dependencies", "--deps")): if key in values: items = values.pop(key) - if not isinstance(items, list) or not all(isinstance(item, str) and item for item in items): raise BeadsError(f"{key} must be strings") + if not isinstance(items, list) or not all( + isinstance(item, str) and item for item in items + ): + raise BeadsError(f"{key} must be strings") command += [flag, ",".join(items)] notes = values.pop("notes", None) if notes is not None: - if not isinstance(notes, Mapping) or notes.get("mode", "append") != "append": raise BeadsError("create notes use append mode only") - command += ["--append-notes", self._string(notes.get("text"), "notes.text", 32000)] - if values: raise BeadsError(f"create received unsupported parameters: {sorted(values)}") + if ( + not isinstance(notes, Mapping) + or notes.get("mode", "append") != "append" + ): + raise BeadsError("create notes use append mode only") + command += [ + "--append-notes", + self._string(notes.get("text"), "notes.text", 32000), + ] + if values: + raise BeadsError( + f"create received unsupported parameters: {sorted(values)}" + ) return None, command if operation == "graph.create": graph = values.pop("graph", None) @@ -383,104 +1098,335 @@ def _compile(self, operation: str, parameters: Mapping[str, Any]) -> tuple[str | raise BeadsError("graph.create requires a bounded graph object") encoded = json.dumps(graph, sort_keys=True, separators=(",", ":")) if len(encoded.encode()) > 262_144: - raise BeadsError("graph.create graph exceeds the owner input bound", "response_bound") + raise BeadsError( + "graph.create graph exceeds the owner input bound", "response_bound" + ) if values: - raise BeadsError(f"graph.create received unsupported parameters: {sorted(values)}") + raise BeadsError( + f"graph.create received unsupported parameters: {sorted(values)}" + ) return None, ["create", "--graph", "@gateway-json:" + encoded] target = self._id(values.pop("id", None)) if operation == "update": patch = values.pop("patch", None) - if not isinstance(patch, Mapping) or not patch or values: raise BeadsError("update requires only a non-empty structural patch") - command = ["update", target]; scalar = {"title": "--title", "description": "--description", "design": "--design", "acceptance": "--acceptance", "status": "--status", "priority": "--priority", "assignee": "--assignee", "due": "--due", "defer": "--defer", "estimate": "--estimate", "external_ref": "--external-ref", "spec_id": "--spec-id", "parent": "--parent"} + if not isinstance(patch, Mapping) or not patch or values: + raise BeadsError("update requires only a non-empty structural patch") + command = ["update", target] + scalar = { + "title": "--title", + "description": "--description", + "design": "--design", + "acceptance": "--acceptance", + "status": "--status", + "priority": "--priority", + "assignee": "--assignee", + "due": "--due", + "defer": "--defer", + "estimate": "--estimate", + "external_ref": "--external-ref", + "spec_id": "--spec-id", + "parent": "--parent", + } values_set = patch.get("set", {}) - if not isinstance(values_set, Mapping): raise BeadsError("patch.set must be an object") + if not isinstance(values_set, Mapping): + raise BeadsError("patch.set must be an object") for key, value in values_set.items(): - if key not in scalar: raise BeadsError(f"unsupported scalar patch {key!r}") - command += [scalar[key], self._string(str(value), key, 32000, key in {"due", "defer", "parent"})] + if key not in scalar: + raise BeadsError(f"unsupported scalar patch {key!r}") + command += [ + scalar[key], + self._string( + str(value), key, 32000, key in {"due", "defer", "parent"} + ), + ] labels = patch.get("labels", {}) if labels: - if not isinstance(labels, Mapping) or set(labels) - {"add", "remove", "replace"}: raise BeadsError("patch.labels is invalid") - for key, flag in (("add", "--add-label"), ("remove", "--remove-label"), ("replace", "--set-labels")): - for item in labels.get(key, []): command += [flag, self._string(item, f"labels.{key}", 256)] + if not isinstance(labels, Mapping) or set(labels) - { + "add", + "remove", + "replace", + }: + raise BeadsError("patch.labels is invalid") + for key, flag in ( + ("add", "--add-label"), + ("remove", "--remove-label"), + ("replace", "--set-labels"), + ): + for item in labels.get(key, []): + command += [flag, self._string(item, f"labels.{key}", 256)] metadata = patch.get("metadata", {}) if metadata: - if not isinstance(metadata, Mapping) or set(metadata) - {"set", "unset"}: raise BeadsError("patch.metadata is invalid") - for key, value in metadata.get("set", {}).items(): command += ["--set-metadata", f"{self._string(key, 'metadata key', 256)}={self._string(str(value), 'metadata value', 4000)}"] - for key in metadata.get("unset", []): command += ["--unset-metadata", self._string(key, "metadata key", 256)] + if not isinstance(metadata, Mapping) or set(metadata) - { + "set", + "unset", + }: + raise BeadsError("patch.metadata is invalid") + for key, value in metadata.get("set", {}).items(): + command += [ + "--set-metadata", + f"{self._string(key, 'metadata key', 256)}={self._string(str(value), 'metadata value', 4000)}", + ] + for key in metadata.get("unset", []): + command += [ + "--unset-metadata", + self._string(key, "metadata key", 256), + ] notes = patch.get("notes") if notes is not None: - if not isinstance(notes, Mapping) or notes.get("mode", "append") not in {"append", "replace"}: raise BeadsError("patch.notes must explicitly choose append or replace") - command += ["--append-notes" if notes.get("mode", "append") == "append" else "--notes", self._string(notes.get("text"), "patch.notes.text", 32000)] + if not isinstance(notes, Mapping) or notes.get( + "mode", "append" + ) not in {"append", "replace"}: + raise BeadsError( + "patch.notes must explicitly choose append or replace" + ) + command += [ + "--append-notes" + if notes.get("mode", "append") == "append" + else "--notes", + self._string(notes.get("text"), "patch.notes.text", 32000), + ] unset = patch.get("unset", []) if unset: - if not isinstance(unset, list) or any(item not in {"due", "defer", "parent"} for item in unset): raise BeadsError("patch.unset supports due, defer, and parent") - for item in unset: command += [scalar[item], ""] - if set(patch) - {"set", "labels", "metadata", "notes", "unset"} or len(command) == 2: raise BeadsError("update patch contains no supported change") + if not isinstance(unset, list) or any( + item not in {"due", "defer", "parent"} for item in unset + ): + raise BeadsError("patch.unset supports due, defer, and parent") + for item in unset: + command += [scalar[item], ""] + if ( + set(patch) - {"set", "labels", "metadata", "notes", "unset"} + or len(command) == 2 + ): + raise BeadsError("update patch contains no supported change") return target, command - if operation == "claim": command = ["update", target, "--claim"] - elif operation == "unclaim": command = ["unclaim", target] - elif operation == "close": command = ["close", target] - elif operation == "reopen": command = ["reopen", target] - elif operation == "comment": command = ["comments", "add", target, self._string(values.pop("text", None), "text", 32000)] - elif operation == "dependency.add": command = ["dep", "add", target, self._id(values.pop("depends_on", None), "depends_on"), "--type", self._string(values.pop("type", "blocks"), "type", 64)] - elif operation == "dependency.remove": command = ["dep", "remove", target, self._id(values.pop("depends_on", None), "depends_on")] - elif operation == "relate": command = ["dep", "relate", target, self._id(values.pop("other_id", None), "other_id")] - elif operation == "unrelate": command = ["dep", "unrelate", target, self._id(values.pop("other_id", None), "other_id")] - elif operation == "reparent": command = ["update", target, "--parent", self._string(values.pop("parent_id", ""), "parent_id", 128, True)] - elif operation == "memory.remember": command = ["remember", self._string(values.pop("text", None), "text", 32000), "--key", self._string(values.pop("key", None), "key", 256)] - elif operation == "memory.forget": command = ["forget", self._string(values.pop("key", None), "key", 256)] - else: raise BeadsError(f"unsupported_capability: Beads operation {operation!r} is not declared") - if operation in {"unclaim", "close", "reopen"} and "reason" in values: command += ["--reason", self._string(values.pop("reason"), "reason", 32000)] + if operation == "claim": + command = ["update", target, "--claim"] + elif operation == "unclaim": + command = ["unclaim", target] + elif operation == "close": + command = ["close", target] + elif operation == "reopen": + command = ["reopen", target] + elif operation == "comment": + command = [ + "comments", + "add", + target, + self._string(values.pop("text", None), "text", 32000), + ] + elif operation == "dependency.add": + command = [ + "dep", + "add", + target, + self._id(values.pop("depends_on", None), "depends_on"), + "--type", + self._string(values.pop("type", "blocks"), "type", 64), + ] + elif operation == "dependency.remove": + command = [ + "dep", + "remove", + target, + self._id(values.pop("depends_on", None), "depends_on"), + ] + elif operation == "relate": + command = [ + "dep", + "relate", + target, + self._id(values.pop("other_id", None), "other_id"), + ] + elif operation == "unrelate": + command = [ + "dep", + "unrelate", + target, + self._id(values.pop("other_id", None), "other_id"), + ] + elif operation == "reparent": + command = [ + "update", + target, + "--parent", + self._string(values.pop("parent_id", ""), "parent_id", 128, True), + ] + elif operation == "memory.remember": + command = [ + "remember", + self._string(values.pop("text", None), "text", 32000), + "--key", + self._string(values.pop("key", None), "key", 256), + ] + elif operation == "memory.forget": + command = ["forget", self._string(values.pop("key", None), "key", 256)] + else: + raise BeadsError( + f"unsupported_capability: Beads operation {operation!r} is not declared" + ) + if operation in {"unclaim", "close", "reopen"} and "reason" in values: + command += ["--reason", self._string(values.pop("reason"), "reason", 32000)] if operation == "close" and "force" in values: if values.pop("force") is not True: raise BeadsError("close force must be true when supplied") command += ["--force"] - if values: raise BeadsError(f"{operation} received unsupported parameters: {sorted(values)}") + if values: + raise BeadsError( + f"{operation} received unsupported parameters: {sorted(values)}" + ) return target, command def _with_graph_file(self, command: list[str]) -> tuple[list[str], Path | None]: - marker = next((item for item in command if item.startswith("@gateway-json:")), None) + marker = next( + (item for item in command if item.startswith("@gateway-json:")), None + ) if marker is None: return command, None - graph_dir = self.config.state_dir / "beads-graph-inputs"; graph_dir.mkdir(mode=0o700, parents=True, exist_ok=True) - handle = tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=graph_dir, prefix="graph-", suffix=".json", delete=False) + graph_dir = self.config.state_dir / "beads-graph-inputs" + graph_dir.mkdir(mode=0o700, parents=True, exist_ok=True) + handle = tempfile.NamedTemporaryFile( + "w", + encoding="utf-8", + dir=graph_dir, + prefix="graph-", + suffix=".json", + delete=False, + ) try: - handle.write(marker.removeprefix("@gateway-json:")); handle.flush(); os.fsync(handle.fileno()) + handle.write(marker.removeprefix("@gateway-json:")) + handle.flush() + os.fsync(handle.fileno()) finally: handle.close() - return [str(path) if path != marker else str(handle.name) for path in command], Path(handle.name) + return [ + str(path) if path != marker else str(handle.name) for path in command + ], Path(handle.name) @staticmethod def _public_command(command: list[str]) -> list[str]: - return ["" if item.startswith("@gateway-json:") else item for item in command] + return [ + "" if item.startswith("@gateway-json:") else item + for item in command + ] @staticmethod def _atomicity(operation: str, native_validation: str) -> str: - return "owner_atomic" if operation == "graph.create" and native_validation == "dry_run" else "per_step_commits" + return ( + "owner_atomic" + if operation == "graph.create" and native_validation == "dry_run" + else "per_step_commits" + ) - def change(self, project_id: str, operation: str, parameters: Mapping[str, Any], *, mode: str = "apply", preconditions: Mapping[str, Any] | None = None, preview_digest: str | None = None) -> dict[str, Any]: - if mode not in {"preview", "apply"}: raise BeadsError("mode must be preview or apply") - project, before_status = self._attest(project_id, mode == "apply"); target, command = self._compile(operation, parameters) + def change( + self, + project_id: str, + operation: str, + parameters: Mapping[str, Any], + *, + mode: str = "apply", + preconditions: Mapping[str, Any] | None = None, + preview_digest: str | None = None, + ) -> dict[str, Any]: + if mode not in {"preview", "apply"}: + raise BeadsError("mode must be preview or apply") + project, before_status = self._attest(project_id, mode == "apply") + target, command = self._compile(operation, parameters) before = self.get(project_id, target) if target else None if preconditions is not None: - if not isinstance(preconditions, Mapping) or set(preconditions) - {"expected_task_revision", "expected_status", "expected_assignee", "expected_etag"}: raise BeadsError("Beads preconditions are not recognized") - if preconditions.get("expected_task_revision") not in {None, before_status["revision"]}: raise BeadsError("task revision no longer matches", "precondition_failed") - if target and any(key in preconditions for key in {"expected_status", "expected_assignee", "expected_etag"}): + if not isinstance(preconditions, Mapping) or set(preconditions) - { + "expected_task_revision", + "expected_status", + "expected_assignee", + "expected_etag", + }: + raise BeadsError("Beads preconditions are not recognized") + if preconditions.get("expected_task_revision") not in { + None, + before_status["revision"], + }: + raise BeadsError( + "task revision no longer matches", "precondition_failed" + ) + if target and any( + key in preconditions + for key in {"expected_status", "expected_assignee", "expected_etag"} + ): assert before is not None fields = before["fields"] - if "expected_status" in preconditions and fields.get("status") != preconditions["expected_status"]: raise BeadsError("status no longer matches", "precondition_failed") - if "expected_assignee" in preconditions and fields.get("assignee") != preconditions["expected_assignee"]: raise BeadsError("assignee no longer matches", "precondition_failed") - if "expected_etag" in preconditions and before["etag"] != preconditions["expected_etag"]: raise BeadsError("etag no longer matches", "precondition_failed", details={"semantics": "gateway_best_effort"}) + if ( + "expected_status" in preconditions + and fields.get("status") != preconditions["expected_status"] + ): + raise BeadsError("status no longer matches", "precondition_failed") + if ( + "expected_assignee" in preconditions + and fields.get("assignee") != preconditions["expected_assignee"] + ): + raise BeadsError( + "assignee no longer matches", "precondition_failed" + ) + if ( + "expected_etag" in preconditions + and before["etag"] != preconditions["expected_etag"] + ): + raise BeadsError( + "etag no longer matches", + "precondition_failed", + details={"semantics": "gateway_best_effort"}, + ) if operation == "update": - if "expected_status" in preconditions: command += ["--if-status", str(preconditions["expected_status"])] + if "expected_status" in preconditions: + command += [ + "--if-status", + str(preconditions["expected_status"]), + ] if "expected_assignee" in preconditions: expected_assignee = preconditions["expected_assignee"] - if expected_assignee is not None and not isinstance(expected_assignee, str): raise BeadsError("expected_assignee must be a string or null") + if expected_assignee is not None and not isinstance( + expected_assignee, str + ): + raise BeadsError( + "expected_assignee must be a string or null" + ) command += ["--if-assignee", expected_assignee or ""] - elif operation == "unclaim" and isinstance(preconditions.get("expected_assignee"), str): + elif operation == "unclaim" and isinstance( + preconditions.get("expected_assignee"), str + ): command += ["--if-assignee", preconditions["expected_assignee"]] - digest = hashlib.sha256(json.dumps({"project": project_id, "operation": operation, "command": self._public_command(command), "revision": before_status["revision"]}, sort_keys=True, separators=(",", ":")).encode()).hexdigest() - preview = {"mode": "preview", "preview_digest": digest, "project_ref": self.project_ref(project_id), "target_ref": self.bead_ref(project_id, target) if target else None, "owner_route": "beads.cli", "owner_version": before_status.get("schema_version"), "command": self._public_command(command), "preconditions": dict(preconditions or {}), "before": before, "before_revision": before_status["revision"], "precondition_semantics": {"expected_status": "native" if operation == "update" else "gateway_best_effort", "expected_assignee": "native" if operation in {"update", "unclaim"} else "gateway_best_effort", "expected_etag": "gateway_best_effort"}, "atomicity": "per_step_commits"} + digest = hashlib.sha256( + json.dumps( + { + "project": project_id, + "operation": operation, + "command": self._public_command(command), + "revision": before_status["revision"], + }, + sort_keys=True, + separators=(",", ":"), + ).encode() + ).hexdigest() + preview = { + "mode": "preview", + "preview_digest": digest, + "project_ref": self.project_ref(project_id), + "target_ref": self.bead_ref(project_id, target) if target else None, + "owner_route": "beads.cli", + "owner_version": before_status.get("schema_version"), + "command": self._public_command(command), + "preconditions": dict(preconditions or {}), + "before": before, + "before_revision": before_status["revision"], + "precondition_semantics": { + "expected_status": "native" + if operation == "update" + else "gateway_best_effort", + "expected_assignee": "native" + if operation in {"update", "unclaim"} + else "gateway_best_effort", + "expected_etag": "gateway_best_effort", + }, + "atomicity": "per_step_commits", + } native_command, graph_path = self._with_graph_file(command) native_validation = "unavailable" try: @@ -491,20 +1437,45 @@ def change(self, project_id: str, operation: str, parameters: Mapping[str, Any], preview["native_validation"] = native_validation preview["atomicity"] = self._atomicity(operation, native_validation) return preview - if preview_digest is not None and preview_digest != digest: raise BeadsError("preview digest or source revision is stale", "precondition_failed") + if preview_digest is not None and preview_digest != digest: + raise BeadsError( + "preview digest or source revision is stale", "precondition_failed" + ) if operation == "graph.create": self._run(project, native_command + ["--dry-run"], True) native_validation = "dry_run" - native = self._run(project, native_command, True); after_status = self.task_authority_status(project_id) + native = self._run(project, native_command, True) + after_status = self.task_authority_status(project_id) finally: - if graph_path is not None: graph_path.unlink(missing_ok=True) + if graph_path is not None: + graph_path.unlink(missing_ok=True) created: dict[str, Any] | None = None if target is None and operation == "create": created_rows = self._issues(native) - created = self._normalize(project_id, created_rows[0], after_status["revision"]) + created = self._normalize( + project_id, created_rows[0], after_status["revision"] + ) after = self.get(project_id, target) if target else created - history = self._includes(project, project_id, target, {"history"}).get("history") if target else None - return {**preview, "mode": "apply", "before": before, "after": after, "before_revision": before_status["revision"], "after_revision": after_status["revision"], "owner_result": native, "owner_history_ref": f"{self.bead_ref(project_id, target)}/history" if target else (created or {}).get("links", {}).get("history"), "owner_history": history, "native_validation": native_validation, "atomicity": self._atomicity(operation, native_validation)} + history = ( + self._includes(project, project_id, target, {"history"}).get("history") + if target + else None + ) + return { + **preview, + "mode": "apply", + "before": before, + "after": after, + "before_revision": before_status["revision"], + "after_revision": after_status["revision"], + "owner_result": native, + "owner_history_ref": f"{self.bead_ref(project_id, target)}/history" + if target + else (created or {}).get("links", {}).get("history"), + "owner_history": history, + "native_validation": native_validation, + "atomicity": self._atomicity(operation, native_validation), + } @staticmethod def _symbolic_references(value: Any) -> set[str]: @@ -512,9 +1483,24 @@ def _symbolic_references(value: Any) -> set[str]: match = _SYMBOL_RE.fullmatch(value) return {match.group(1)} if match else set() if isinstance(value, Mapping): - return set().union(*(BeadsService._symbolic_references(item) for item in value.values())) if value else set() + return ( + set().union( + *( + BeadsService._symbolic_references(item) + for item in value.values() + ) + ) + if value + else set() + ) if isinstance(value, list): - return set().union(*(BeadsService._symbolic_references(item) for item in value)) if value else set() + return ( + set().union( + *(BeadsService._symbolic_references(item) for item in value) + ) + if value + else set() + ) return set() @staticmethod @@ -522,13 +1508,30 @@ def _canonical_references(value: Any) -> set[str]: if isinstance(value, str): return {value} if value.startswith("sinnix://") else set() if isinstance(value, Mapping): - return set().union(*(BeadsService._canonical_references(item) for item in value.values())) if value else set() + return ( + set().union( + *( + BeadsService._canonical_references(item) + for item in value.values() + ) + ) + if value + else set() + ) if isinstance(value, list): - return set().union(*(BeadsService._canonical_references(item) for item in value)) if value else set() + return ( + set().union( + *(BeadsService._canonical_references(item) for item in value) + ) + if value + else set() + ) return set() @staticmethod - def _replace_symbols(value: Any, symbols: Mapping[str, str], *, placeholders: bool = False) -> Any: + def _replace_symbols( + value: Any, symbols: Mapping[str, str], *, placeholders: bool = False + ) -> Any: if isinstance(value, str): match = _SYMBOL_RE.fullmatch(value) if match is None: @@ -539,49 +1542,102 @@ def _replace_symbols(value: Any, symbols: Mapping[str, str], *, placeholders: bo try: return symbols[name] except KeyError as exc: - raise BeadsError(f"unresolved symbolic reference: ${name}", "precondition_failed") from exc + raise BeadsError( + f"unresolved symbolic reference: ${name}", "precondition_failed" + ) from exc if isinstance(value, Mapping): - return {key: BeadsService._replace_symbols(item, symbols, placeholders=placeholders) for key, item in value.items()} + return { + key: BeadsService._replace_symbols( + item, symbols, placeholders=placeholders + ) + for key, item in value.items() + } if isinstance(value, list): - return [BeadsService._replace_symbols(item, symbols, placeholders=placeholders) for item in value] + return [ + BeadsService._replace_symbols(item, symbols, placeholders=placeholders) + for item in value + ] return value @staticmethod - def _compensation_hint(operation: str, parameters: Mapping[str, Any], result: Mapping[str, Any] | None = None) -> dict[str, Any] | None: + def _compensation_hint( + operation: str, + parameters: Mapping[str, Any], + result: Mapping[str, Any] | None = None, + ) -> dict[str, Any] | None: inverse = { "claim": "unclaim", "close": "reopen", "dependency.add": "dependency.remove", "relate": "unrelate", }.get(operation) - if operation == "create" and result and isinstance(result.get("after"), Mapping): + if ( + operation == "create" + and result + and isinstance(result.get("after"), Mapping) + ): created = result["after"].get("id") if isinstance(created, str): - return {"kind": "suggested_action", "operation": "close", "parameters": {"id": created}, "automatic": False} + return { + "kind": "suggested_action", + "operation": "close", + "parameters": {"id": created}, + "automatic": False, + } if inverse is not None: - fields = {key: parameters[key] for key in ("id", "depends_on", "other_id") if key in parameters} - return {"kind": "suggested_action", "operation": inverse, "parameters": fields, "automatic": False} + fields = { + key: parameters[key] + for key in ("id", "depends_on", "other_id") + if key in parameters + } + return { + "kind": "suggested_action", + "operation": inverse, + "parameters": fields, + "automatic": False, + } if operation == "graph.create": - return {"kind": "manual", "reason": "native graph creation is atomic, but its created nodes require explicit follow-up actions to compensate", "automatic": False} + return { + "kind": "manual", + "reason": "native graph creation is atomic, but its created nodes require explicit follow-up actions to compensate", + "automatic": False, + } return None @staticmethod - def _validate_changeset_preconditions(value: Any, index: int) -> Mapping[str, Any] | None: + def _validate_changeset_preconditions( + value: Any, index: int + ) -> Mapping[str, Any] | None: if value is None: return None - if not isinstance(value, Mapping) or set(value) - {"expected_task_revision", "expected_status", "expected_assignee", "expected_etag"}: - raise BeadsError(f"changeset action {index} preconditions are not recognized") + if not isinstance(value, Mapping) or set(value) - { + "expected_task_revision", + "expected_status", + "expected_assignee", + "expected_etag", + }: + raise BeadsError( + f"changeset action {index} preconditions are not recognized" + ) revision = value.get("expected_task_revision") etag = value.get("expected_etag") status = value.get("expected_status") assignee = value.get("expected_assignee") - if revision is not None and (not isinstance(revision, str) or not re.fullmatch(r"[0-9a-f]{64}", revision)): - raise BeadsError(f"changeset action {index} expected_task_revision is malformed") - if etag is not None and (not isinstance(etag, str) or not re.fullmatch(r"[0-9a-f]{64}", etag)): + if revision is not None and ( + not isinstance(revision, str) or not re.fullmatch(r"[0-9a-f]{64}", revision) + ): + raise BeadsError( + f"changeset action {index} expected_task_revision is malformed" + ) + if etag is not None and ( + not isinstance(etag, str) or not re.fullmatch(r"[0-9a-f]{64}", etag) + ): raise BeadsError(f"changeset action {index} expected_etag is malformed") if status is not None and (not isinstance(status, str) or len(status) > 64): raise BeadsError(f"changeset action {index} expected_status is malformed") - if assignee is not None and (not isinstance(assignee, str) or len(assignee) > 256): + if assignee is not None and ( + not isinstance(assignee, str) or len(assignee) > 256 + ): raise BeadsError(f"changeset action {index} expected_assignee is malformed") return dict(value) @@ -590,58 +1646,143 @@ def _changeset_plan(self, actions: Any, on_error: Any) -> dict[str, Any]: on_error = "stop" if on_error not in {"stop", "continue"}: raise BeadsError("on_error must be stop or continue") - if not isinstance(actions, list) or not actions or len(actions) > _MAX_CHANGESET_ACTIONS: - raise BeadsError(f"actions must contain 1-{_MAX_CHANGESET_ACTIONS} ordered items") + if ( + not isinstance(actions, list) + or not actions + or len(actions) > _MAX_CHANGESET_ACTIONS + ): + raise BeadsError( + f"actions must contain 1-{_MAX_CHANGESET_ACTIONS} ordered items" + ) plan: list[dict[str, Any]] = [] bindings: dict[str, str] = {} source_revisions: dict[str, str] = {} for index, raw in enumerate(actions): - if not isinstance(raw, Mapping) or set(raw) - {"ref", "operation", "parameters", "preconditions", "bind"}: + if not isinstance(raw, Mapping) or set(raw) - { + "ref", + "operation", + "parameters", + "preconditions", + "bind", + }: raise BeadsError(f"changeset action {index} has unsupported fields") if not {"ref", "operation", "parameters"} <= set(raw): - raise BeadsError(f"changeset action {index} requires ref, operation, and parameters") + raise BeadsError( + f"changeset action {index} requires ref, operation, and parameters" + ) match = _PROJECT_REF_RE.fullmatch(str(raw["ref"])) if match is None: - raise BeadsError(f"changeset action {index} ref is not a canonical project or bead reference") + raise BeadsError( + f"changeset action {index} ref is not a canonical project or bead reference" + ) project_id, bead_id = match.groups() - operation = self._string(raw["operation"], f"actions[{index}].operation", 64) + operation = self._string( + raw["operation"], f"actions[{index}].operation", 64 + ) parameters = raw["parameters"] if not isinstance(parameters, Mapping): - raise BeadsError(f"changeset action {index} parameters must be an object") + raise BeadsError( + f"changeset action {index} parameters must be an object" + ) parameters = dict(parameters) - preconditions = self._validate_changeset_preconditions(raw.get("preconditions"), index) + preconditions = self._validate_changeset_preconditions( + raw.get("preconditions"), index + ) if bead_id is not None: parameters.setdefault("id", bead_id) bind = raw.get("bind") if bind is not None: bind = self._string(bind, f"actions[{index}].bind", 64) - if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_]{0,63}", bind) or operation != "create": - raise BeadsError(f"changeset action {index} bind is valid only for create") + if ( + not re.fullmatch(r"[A-Za-z][A-Za-z0-9_]{0,63}", bind) + or operation != "create" + ): + raise BeadsError( + f"changeset action {index} bind is valid only for create" + ) if bind in bindings: raise BeadsError(f"changeset bind is duplicated: {bind}") bindings[bind] = project_id for reference in self._canonical_references(parameters): foreign = _PROJECT_REF_RE.fullmatch(reference) if foreign is not None and foreign.group(1) != project_id: - raise BeadsError("cross-project Beads graph edges are unsupported", "unsupported_capability") - plan.append({"index": index, "project_id": project_id, "ref": str(raw["ref"]), "operation": operation, "parameters": parameters, "preconditions": preconditions, "bind": bind}) + raise BeadsError( + "cross-project Beads graph edges are unsupported", + "unsupported_capability", + ) + plan.append( + { + "index": index, + "project_id": project_id, + "ref": str(raw["ref"]), + "operation": operation, + "parameters": parameters, + "preconditions": preconditions, + "bind": bind, + } + ) if project_id not in source_revisions: - source_revisions[project_id] = self.task_authority_status(project_id)["revision"] + source_revisions[project_id] = self.task_authority_status(project_id)[ + "revision" + ] for item in plan: for symbol in self._symbolic_references(item["parameters"]): if symbol not in bindings: raise BeadsError(f"changeset references unknown symbol: ${symbol}") if bindings[symbol] != item["project_id"]: - raise BeadsError("cross-project Beads graph edges are unsupported", "unsupported_capability") - self._compile(item["operation"], self._replace_symbols(item["parameters"], {}, placeholders=True)) - payload = {"on_error": on_error, "actions": [{key: item[key] for key in ("ref", "operation", "parameters", "preconditions", "bind")} for item in plan], "source_revisions": source_revisions} - digest = hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()).hexdigest() + raise BeadsError( + "cross-project Beads graph edges are unsupported", + "unsupported_capability", + ) + self._compile( + item["operation"], + self._replace_symbols(item["parameters"], {}, placeholders=True), + ) + payload = { + "on_error": on_error, + "actions": [ + { + key: item[key] + for key in ( + "ref", + "operation", + "parameters", + "preconditions", + "bind", + ) + } + for item in plan + ], + "source_revisions": source_revisions, + } + digest = hashlib.sha256( + json.dumps(payload, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest() projects = list(source_revisions) owner_atomic = len(plan) == 1 and plan[0]["operation"] == "graph.create" - atomicity = "owner_atomic" if owner_atomic else "cross_project_partitioned" if len(projects) > 1 else "per_step_commits" - return {"plan": plan, "on_error": on_error, "source_revisions": source_revisions, "preview_digest": digest, "atomicity": atomicity} + atomicity = ( + "owner_atomic" + if owner_atomic + else "cross_project_partitioned" + if len(projects) > 1 + else "per_step_commits" + ) + return { + "plan": plan, + "on_error": on_error, + "source_revisions": source_revisions, + "preview_digest": digest, + "atomicity": atomicity, + } - def changeset(self, actions: Any, *, mode: str, on_error: Any = None, preview_digest: str | None = None) -> dict[str, Any]: + def changeset( + self, + actions: Any, + *, + mode: str, + on_error: Any = None, + preview_digest: str | None = None, + ) -> dict[str, Any]: if mode not in {"preview", "apply"}: raise BeadsError("changeset mode must be preview or apply") prepared = self._changeset_plan(actions, on_error) @@ -649,40 +1790,155 @@ def changeset(self, actions: Any, *, mode: str, on_error: Any = None, preview_di if mode == "preview": for item in prepared["plan"]: if item["operation"] == "graph.create": - validation = self.change(item["project_id"], item["operation"], item["parameters"], mode="preview") + validation = self.change( + item["project_id"], + item["operation"], + item["parameters"], + mode="preview", + ) native_validations[item["index"]] = validation["native_validation"] - owner_atomic = prepared["atomicity"] == "owner_atomic" and (mode == "apply" or native_validations.get(0) == "dry_run") - atomicity = "owner_atomic" if owner_atomic else "per_step_commits" if prepared["atomicity"] == "owner_atomic" else prepared["atomicity"] - public_plan = [{"index": item["index"], "ref": item["ref"], "operation": item["operation"], "bind": item["bind"], "native_validation": native_validations.get(item["index"], "not_required"), "compensation": self._compensation_hint(item["operation"], item["parameters"])} for item in prepared["plan"]] - response = {"mode": mode, "owner_route": "beads.changeset", "source_revisions": prepared["source_revisions"], "preview_digest": prepared["preview_digest"], "on_error": prepared["on_error"], "atomicity": atomicity, "partitions": [{"project_ref": self.project_ref(project_id), "source_revision": revision, "action_indexes": [item["index"] for item in prepared["plan"] if item["project_id"] == project_id]} for project_id, revision in prepared["source_revisions"].items()], "actions": public_plan, "compensation": {"automatic": False, "claim": "No global rollback is attempted. Each applied step includes only a suggested compensation hint."}} + owner_atomic = prepared["atomicity"] == "owner_atomic" and ( + mode == "apply" or native_validations.get(0) == "dry_run" + ) + atomicity = ( + "owner_atomic" + if owner_atomic + else "per_step_commits" + if prepared["atomicity"] == "owner_atomic" + else prepared["atomicity"] + ) + public_plan = [ + { + "index": item["index"], + "ref": item["ref"], + "operation": item["operation"], + "bind": item["bind"], + "native_validation": native_validations.get( + item["index"], "not_required" + ), + "compensation": self._compensation_hint( + item["operation"], item["parameters"] + ), + } + for item in prepared["plan"] + ] + response = { + "mode": mode, + "owner_route": "beads.changeset", + "source_revisions": prepared["source_revisions"], + "preview_digest": prepared["preview_digest"], + "on_error": prepared["on_error"], + "atomicity": atomicity, + "partitions": [ + { + "project_ref": self.project_ref(project_id), + "source_revision": revision, + "action_indexes": [ + item["index"] + for item in prepared["plan"] + if item["project_id"] == project_id + ], + } + for project_id, revision in prepared["source_revisions"].items() + ], + "actions": public_plan, + "compensation": { + "automatic": False, + "claim": "No global rollback is attempted. Each applied step includes only a suggested compensation hint.", + }, + } if mode == "preview": return response if preview_digest is not None and preview_digest != prepared["preview_digest"]: - raise BeadsError("changeset preview digest or per-project source revision is stale", "precondition_failed") + raise BeadsError( + "changeset preview digest or per-project source revision is stale", + "precondition_failed", + ) symbols: dict[str, str] = {} outcomes: list[dict[str, Any]] = [] halted = False for item in prepared["plan"]: - outcome = {"index": item["index"], "ref": item["ref"], "operation": item["operation"]} + outcome = { + "index": item["index"], + "ref": item["ref"], + "operation": item["operation"], + } if halted: - outcomes.append({**outcome, "outcome": "skipped", "reason": "on_error=stop after an earlier failed step"}) + outcomes.append( + { + **outcome, + "outcome": "skipped", + "reason": "on_error=stop after an earlier failed step", + } + ) continue try: parameters = self._replace_symbols(item["parameters"], symbols) - applied = self.change(item["project_id"], item["operation"], parameters, mode="apply", preconditions=item["preconditions"]) + applied = self.change( + item["project_id"], + item["operation"], + parameters, + mode="apply", + preconditions=item["preconditions"], + ) if item["bind"] is not None: after = applied.get("after") - if not isinstance(after, Mapping) or not isinstance(after.get("id"), str): - raise BeadsError("owner create response omitted the bead id required by changeset bind", "owner_failed") + if not isinstance(after, Mapping) or not isinstance( + after.get("id"), str + ): + raise BeadsError( + "owner create response omitted the bead id required by changeset bind", + "owner_failed", + ) symbols[item["bind"]] = after["id"] - outcomes.append({**outcome, "outcome": "applied", "before_revision": applied["before_revision"], "after_revision": applied["after_revision"], "result_ref": applied.get("after", {}).get("ref") if isinstance(applied.get("after"), Mapping) else None, "bound_ref": self.bead_ref(item["project_id"], symbols[item["bind"]]) if item["bind"] is not None else None, "compensation": self._compensation_hint(item["operation"], parameters, applied)}) + outcomes.append( + { + **outcome, + "outcome": "applied", + "before_revision": applied["before_revision"], + "after_revision": applied["after_revision"], + "result_ref": applied.get("after", {}).get("ref") + if isinstance(applied.get("after"), Mapping) + else None, + "bound_ref": self.bead_ref( + item["project_id"], symbols[item["bind"]] + ) + if item["bind"] is not None + else None, + "compensation": self._compensation_hint( + item["operation"], parameters, applied + ), + } + ) except BeadsError as exc: - outcomes.append({**outcome, "outcome": "failed", "error": {"code": exc.code, "message": str(exc)}, "compensation": self._compensation_hint(item["operation"], item["parameters"])}) + outcomes.append( + { + **outcome, + "outcome": "failed", + "error": {"code": exc.code, "message": str(exc)}, + "compensation": self._compensation_hint( + item["operation"], item["parameters"] + ), + } + ) halted = prepared["on_error"] == "stop" - after_revisions = {project_id: self.task_authority_status(project_id)["revision"] for project_id in prepared["source_revisions"]} - return {**response, "outcomes": outcomes, "after_source_revisions": after_revisions, "partial_completion": any(item["outcome"] == "failed" for item in outcomes)} + after_revisions = { + project_id: self.task_authority_status(project_id)["revision"] + for project_id in prepared["source_revisions"] + } + return { + **response, + "outcomes": outcomes, + "after_source_revisions": after_revisions, + "partial_completion": any(item["outcome"] == "failed" for item in outcomes), + } - def operate(self, project_id: str, operation: str, parameters: Mapping[str, Any] | None = None) -> dict[str, Any]: + def operate( + self, + project_id: str, + operation: str, + parameters: Mapping[str, Any] | None = None, + ) -> dict[str, Any]: project, before = self._attest(project_id, True) parameters = dict(parameters or {}) if operation == "snapshot.publish": @@ -692,12 +1948,28 @@ def operate(self, project_id: str, operation: str, parameters: Mapping[str, Any] directory.mkdir(mode=0o700, parents=True, exist_ok=True) destination = directory / "issues.jsonl" before_text = destination.read_text() if destination.exists() else "" - owner_result = self._run(project, ["export", "-o", str(destination)], True, text=True) + owner_result = self._run( + project, ["export", "-o", str(destination)], True, text=True + ) after_text = destination.read_text() if destination.exists() else "" - diff = "".join(unified_diff(before_text.splitlines(keepends=True), after_text.splitlines(keepends=True), fromfile="previous", tofile="published", n=3)) + diff = "".join( + unified_diff( + before_text.splitlines(keepends=True), + after_text.splitlines(keepends=True), + fromfile="previous", + tofile="published", + n=3, + ) + ) if len(diff.encode()) > self.config.max_result_bytes: diff = diff[: self.config.max_result_bytes] + "\n[diff truncated]\n" - publication = {"destination": str(destination), "before_sha256": hashlib.sha256(before_text.encode()).hexdigest(), "after_sha256": hashlib.sha256(after_text.encode()).hexdigest(), "changed": before_text != after_text, "diff": diff} + publication = { + "destination": str(destination), + "before_sha256": hashlib.sha256(before_text.encode()).hexdigest(), + "after_sha256": hashlib.sha256(after_text.encode()).hexdigest(), + "changed": before_text != after_text, + "diff": diff, + } elif operation == "sync.push": if parameters: raise BeadsError("sync.push accepts no parameters") @@ -709,17 +1981,40 @@ def operate(self, project_id: str, operation: str, parameters: Mapping[str, Any] elif operation == "backup.create": if parameters: raise BeadsError("backup.create accepts no parameters") - owner_result, publication = self._run(project, ["backup", "create"], True), None + owner_result, publication = ( + self._run(project, ["backup", "create"], True), + None, + ) elif operation == "backup.list": if parameters: raise BeadsError("backup.list accepts no parameters") - owner_result, publication = self._run(project, ["backup", "list"], False), None + owner_result, publication = ( + self._run(project, ["backup", "list"], False), + None, + ) elif operation == "backup.restore": - backup_id = self._string(parameters.pop("backup_id", None), "backup_id", 256) + backup_id = self._string( + parameters.pop("backup_id", None), "backup_id", 256 + ) if parameters: raise BeadsError("backup.restore accepts only backup_id") - owner_result, publication = self._run(project, ["backup", "restore", backup_id], True), None + owner_result, publication = ( + self._run(project, ["backup", "restore", backup_id], True), + None, + ) else: - raise BeadsError(f"unsupported_capability: Beads maintenance operation {operation!r} is not declared") + raise BeadsError( + f"unsupported_capability: Beads maintenance operation {operation!r} is not declared" + ) after = self.task_authority_status(project_id) - return {"project_ref": self.project_ref(project_id), "owner_route": "beads.maintenance", "operation": operation, "before_revision": before["revision"], "after_revision": after["revision"], "owner_result": owner_result, "publication": publication, "atomicity": "per_step_commits", "git_bookkeeping": "none"} + return { + "project_ref": self.project_ref(project_id), + "owner_route": "beads.maintenance", + "operation": operation, + "before_revision": before["revision"], + "after_revision": after["revision"], + "owner_result": owner_result, + "publication": publication, + "atomicity": "per_step_commits", + "git_bookkeeping": "none", + } diff --git a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/browser.py b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/browser.py index c029d126..f9ce7490 100644 --- a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/browser.py +++ b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/browser.py @@ -7,9 +7,6 @@ from pathlib import Path from typing import Any -from .artifacts import ArtifactService -from .capabilities import Capability, Principal -from .config import GatewayConfig from sinnix_mcp.execution import ( ExecutionProfile, OwnerDiagnosticError, @@ -17,6 +14,10 @@ OwnerRoute, ) +from .artifacts import ArtifactService +from .capabilities import Capability, Principal +from .config import GatewayConfig + class BrowserError(ValueError): pass @@ -81,7 +82,9 @@ def _save_targets(self, targets: dict[str, dict[str, Any]]) -> None: f".{self._targets_path.name}.{uuid.uuid4().hex}.tmp" ) try: - temporary.write_text(json.dumps(targets, sort_keys=True, separators=(",", ":"))) + temporary.write_text( + json.dumps(targets, sort_keys=True, separators=(",", ":")) + ) temporary.chmod(0o600) os.replace(temporary, self._targets_path) finally: @@ -115,7 +118,9 @@ def capture( if not isinstance(full_page, bool): raise BrowserError("full_page must be boolean") if quality is not None and ( - isinstance(quality, bool) or not isinstance(quality, int) or not 1 <= quality <= 100 + isinstance(quality, bool) + or not isinstance(quality, int) + or not 1 <= quality <= 100 ): raise BrowserError("quality must be 1-100") capture_dir = self.config.state_dir / "captures" / uuid.uuid4().hex @@ -137,9 +142,13 @@ def capture( try: source = source.resolve(strict=True) except OSError as exc: - raise BrowserError("Chrome control did not produce its declared screenshot") from exc + raise BrowserError( + "Chrome control did not produce its declared screenshot" + ) from exc if capture_dir.resolve() not in source.parents or not source.is_file(): - raise BrowserError("Chrome control returned a file outside gateway capture state") + raise BrowserError( + "Chrome control returned a file outside gateway capture state" + ) receipt = self.artifacts.attest_capture( capture_dir, source="chrome-cdp", @@ -179,7 +188,9 @@ def read( if operation in {"status", "list", "list_tabs"}: if page_id is not None or selector is not None: raise BrowserError(f"{operation} does not accept page_id or selector") - command = {"status": "status", "list": "list", "list_tabs": "list-tabs"}[operation] + command = {"status": "status", "list": "list", "list_tabs": "list-tabs"}[ + operation + ] return {"operation": operation, **self._run([command])} if operation not in {"info", "get_text", "get_html"}: raise BrowserError( @@ -187,7 +198,9 @@ def read( "['get_html', 'get_text', 'info', 'list', 'list_tabs', 'status']" ) page_id = self._require_owned_target(page_id) - command = {"info": "info", "get_text": "get-text", "get_html": "get-html"}[operation] + command = {"info": "info", "get_text": "get-text", "get_html": "get-html"}[ + operation + ] arguments = [command, page_id] if selector is not None: if operation == "info": @@ -213,7 +226,9 @@ def action(self, operation: str, arguments: dict[str, Any]) -> dict[str, Any]: candidate = json.loads(line) except json.JSONDecodeError: continue - if isinstance(candidate, dict) and isinstance(candidate.get("id"), str): + if isinstance(candidate, dict) and isinstance( + candidate.get("id"), str + ): target = candidate break if not isinstance(target, dict) or not isinstance(target.get("id"), str): @@ -223,7 +238,9 @@ def action(self, operation: str, arguments: dict[str, Any]) -> dict[str, Any]: self._run(["close", target["id"]]) except BrowserError: pass - raise BrowserError("agent-window was not parked on the hidden workspace") + raise BrowserError( + "agent-window was not parked on the hidden workspace" + ) targets = self._load_targets() targets[target["id"]] = target self._save_targets(targets) @@ -233,7 +250,12 @@ def action(self, operation: str, arguments: dict[str, Any]) -> dict[str, Any]: if operation == "navigate": if set(arguments) != {"page_id", "url"}: raise BrowserError("navigate requires page_id and url") - command = ["navigate", page_id, "--url", self._string(arguments["url"], "url")] + command = [ + "navigate", + page_id, + "--url", + self._string(arguments["url"], "url"), + ] elif operation == "reload": if set(arguments) != {"page_id"}: raise BrowserError("reload requires only page_id") @@ -241,7 +263,9 @@ def action(self, operation: str, arguments: dict[str, Any]) -> dict[str, Any]: elif operation == "inject_text": allowed = {"page_id", "text", "selector"} if not {"page_id", "text"} <= set(arguments) or set(arguments) - allowed: - raise BrowserError("inject_text requires page_id, text, and optional selector") + raise BrowserError( + "inject_text requires page_id, text, and optional selector" + ) command = [ "inject-text", page_id, @@ -249,11 +273,21 @@ def action(self, operation: str, arguments: dict[str, Any]) -> dict[str, Any]: self._string(arguments["text"], "text"), ] if "selector" in arguments: - command.extend(["--selector", self._string(arguments["selector"], "selector", 8_192)]) + command.extend( + [ + "--selector", + self._string(arguments["selector"], "selector", 8_192), + ] + ) elif operation == "click": if set(arguments) != {"page_id", "selector"}: raise BrowserError("click requires page_id and selector") - command = ["click", page_id, "--selector", self._string(arguments["selector"], "selector", 8_192)] + command = [ + "click", + page_id, + "--selector", + self._string(arguments["selector"], "selector", 8_192), + ] elif operation == "fill_form": if set(arguments) != {"page_id", "selector", "value"}: raise BrowserError("fill_form requires page_id, selector, and value") @@ -268,10 +302,19 @@ def action(self, operation: str, arguments: dict[str, Any]) -> dict[str, Any]: elif operation in {"evaluate", "await"}: javascript_key = "javascript" allowed = {"page_id", javascript_key, "timeout_seconds"} - if not {"page_id", javascript_key} <= set(arguments) or set(arguments) - allowed: - raise BrowserError(f"{operation} requires page_id, javascript, and optional timeout_seconds") + if ( + not {"page_id", javascript_key} <= set(arguments) + or set(arguments) - allowed + ): + raise BrowserError( + f"{operation} requires page_id, javascript, and optional timeout_seconds" + ) timeout = arguments.get("timeout_seconds", 30) - if isinstance(timeout, bool) or not isinstance(timeout, int) or not 1 <= timeout <= 300: + if ( + isinstance(timeout, bool) + or not isinstance(timeout, int) + or not 1 <= timeout <= 300 + ): raise BrowserError("timeout_seconds must be 1-300") command = [ "evaluate" if operation == "evaluate" else "await", @@ -284,10 +327,19 @@ def action(self, operation: str, arguments: dict[str, Any]) -> dict[str, Any]: execution_timeout = timeout + 10 elif operation == "wait_selector": allowed = {"page_id", "selector", "timeout_seconds"} - if not {"page_id", "selector"} <= set(arguments) or set(arguments) - allowed: - raise BrowserError("wait_selector requires page_id, selector, and optional timeout_seconds") + if ( + not {"page_id", "selector"} <= set(arguments) + or set(arguments) - allowed + ): + raise BrowserError( + "wait_selector requires page_id, selector, and optional timeout_seconds" + ) timeout = arguments.get("timeout_seconds", 30) - if isinstance(timeout, bool) or not isinstance(timeout, int) or not 1 <= timeout <= 300: + if ( + isinstance(timeout, bool) + or not isinstance(timeout, int) + or not 1 <= timeout <= 300 + ): raise BrowserError("timeout_seconds must be 1-300") command = [ "wait-selector", diff --git a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/capability_index.py b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/capability_index.py index 4aac2dce..f460dcd0 100644 --- a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/capability_index.py +++ b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/capability_index.py @@ -109,9 +109,7 @@ def search( "reason": "capability index is unavailable", } rows = [ - row - for row in index["rows"] - if self._matches(row, terms, kind, enabled) + row for row in index["rows"] if self._matches(row, terms, kind, enabled) ] if cursor >= len(rows) and cursor != 0: raise CapabilityIndexError("cursor is beyond matching capability rows") diff --git a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/captures.py b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/captures.py index cd3cf1b7..056297fa 100644 --- a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/captures.py +++ b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/captures.py @@ -6,9 +6,10 @@ from pathlib import Path from typing import Any +from sinnix_mcp.execution import ExecutionProfile, OwnerExecution, OwnerRoute + from .capabilities import Capability, Principal from .config import GatewayConfig -from sinnix_mcp.execution import ExecutionProfile, OwnerExecution, OwnerRoute @dataclass(frozen=True) @@ -114,7 +115,9 @@ def lane(self, name: str) -> dict[str, Any]: try: lane = self._available_lanes()[name] except KeyError as exc: - raise ValueError("capture lane is not declared by runtime inventory") from exc + raise ValueError( + "capture lane is not declared by runtime inventory" + ) from exc return { "ref": f"sinnix://captures/{name}", "name": lane.name, @@ -136,7 +139,8 @@ def query( return {"records": [], "lanes_queried": [], "truncated": False} non_sidecar = [ - name for name in effective_lanes + name + for name in effective_lanes if available[name].native_contract != "sinnix-capture-v1-sidecar" ] if non_sidecar: @@ -205,7 +209,9 @@ def query( } root_records = ( - payload.get("records", payload) if isinstance(payload, dict) else payload + payload.get("records", payload) + if isinstance(payload, dict) + else payload ) if not isinstance(root_records, list): return { diff --git a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/cli.py b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/cli.py index 2d6944b7..3884320b 100644 --- a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/cli.py +++ b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/cli.py @@ -9,16 +9,16 @@ import anyio from .app import canonical_manifest, create_server +from .capabilities import PRINCIPAL_CAPABILITIES from .cli_support import ( CliInputError, build_request, catalog_display, invoke, ) -from .runtime import manifest_measurement -from .capabilities import PRINCIPAL_CAPABILITIES from .config import GatewayConfig from .registry import REGISTRY +from .runtime import manifest_measurement LOCAL_CONFIG_PATH = Path("/etc/sinnix/agent-gateway.json") @@ -32,7 +32,9 @@ def _default_config_path() -> Path | None: async def build_manifest(config: GatewayConfig, principal_name: str) -> dict[str, Any]: - manifest = canonical_manifest(await create_server(config, principal_name).list_tools()) + manifest = canonical_manifest( + await create_server(config, principal_name).list_tools() + ) return {**manifest, "measurement": manifest_measurement(manifest)} @@ -90,7 +92,9 @@ def add_input_flags(command: argparse.ArgumentParser) -> None: source = command.add_mutually_exclusive_group() source.add_argument("--input", help="inline JSON object") source.add_argument("--input-file", type=Path, help="JSON object file") - source.add_argument("--stdin", action="store_true", help="read one JSON object from stdin") + source.add_argument( + "--stdin", action="store_true", help="read one JSON object from stdin" + ) command.add_argument("--action", "--action-name", dest="action_name") command.add_argument("--ref") command.add_argument("--operation") @@ -109,7 +113,17 @@ def add_input_flags(command: argparse.ArgumentParser) -> None: choices=sorted(PRINCIPAL_CAPABILITIES), ) - for verb in ("status", "query", "get", "context", "events", "wait", "change", "operate", "run"): + for verb in ( + "status", + "query", + "get", + "context", + "events", + "wait", + "change", + "operate", + "run", + ): command = subcommands.add_parser(verb) add_input_flags(command) if verb == "change": @@ -130,7 +144,9 @@ def main() -> None: arguments = parser().parse_args() config = GatewayConfig.load(arguments.config) command = arguments.command or "serve" - principal_name = getattr(arguments, "command_principal", None) or arguments.principal + principal_name = ( + getattr(arguments, "command_principal", None) or arguments.principal + ) if command == "serve": create_server(config, arguments.principal).run("stdio") elif command == "manifest": @@ -181,17 +197,21 @@ def main() -> None: complete=arguments.complete, ) print(json.dumps(payload, indent=2, sort_keys=True)) - elif command in { - "status", - "query", - "get", - "context", - "events", - "wait", - "change", - "operate", - "run", - } and arguments.explain: + elif ( + command + in { + "status", + "query", + "get", + "context", + "events", + "wait", + "change", + "operate", + "run", + } + and arguments.explain + ): action_name = arguments.action_name or { "status": "gateway.status", "get": "resources.get", diff --git a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/cli_support.py b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/cli_support.py index 0261cbfa..97681dcf 100644 --- a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/cli_support.py +++ b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/cli_support.py @@ -15,7 +15,6 @@ from .contracts import VerbFamily from .registry import REGISTRY, RegistryError - MAX_INPUT_BYTES = 262_144 VERB_TO_TOOL = {verb.value: verb.value for verb in VerbFamily} @@ -25,7 +24,11 @@ class CliInputError(ValueError): def _read_bounded(stream: Any, limit: int, source: str) -> bytes: - raw = stream.buffer.read(limit + 1) if hasattr(stream, "buffer") else stream.read(limit + 1) + raw = ( + stream.buffer.read(limit + 1) + if hasattr(stream, "buffer") + else stream.read(limit + 1) + ) if isinstance(raw, str): raw = raw.encode() if len(raw) > limit: @@ -47,13 +50,17 @@ def load_json_input( if inline is not None: raw = inline.encode() if len(raw) > max_bytes: - raise CliInputError(f"--input exceeds the {max_bytes}-byte JSON input bound") + raise CliInputError( + f"--input exceeds the {max_bytes}-byte JSON input bound" + ) elif input_file is not None: try: with input_file.open("rb") as handle: raw = handle.read(max_bytes + 1) except OSError as exc: - raise CliInputError(f"cannot read --input-file {input_file}: {exc}") from exc + raise CliInputError( + f"cannot read --input-file {input_file}: {exc}" + ) from exc if len(raw) > max_bytes: raise CliInputError( f"--input-file {input_file} exceeds the {max_bytes}-byte JSON input bound" @@ -75,7 +82,9 @@ def _set_value(payload: dict[str, Any], key: str, value: Any) -> None: if value is None: return if key in payload and payload[key] != value: - raise CliInputError(f"request contains a different value for --{key.replace('_', '-')}") + raise CliInputError( + f"request contains a different value for --{key.replace('_', '-')}" + ) payload[key] = value @@ -157,7 +166,9 @@ def build_request( catalog_search_text(query) if command == "catalog" else query, ) if preconditions is not None: - _set_value(payload, "preconditions", _json_object(preconditions, "--preconditions")) + _set_value( + payload, "preconditions", _json_object(preconditions, "--preconditions") + ) if preview and apply: raise CliInputError("--preview and --apply are mutually exclusive") if command == "change" and (preview or apply): @@ -175,7 +186,9 @@ def build_request( except (TypeError, ValueError) as exc: raise CliInputError("request input must be JSON serializable") from exc if len(encoded) > MAX_INPUT_BYTES: - raise CliInputError(f"request exceeds the {MAX_INPUT_BYTES}-byte JSON input bound") + raise CliInputError( + f"request exceeds the {MAX_INPUT_BYTES}-byte JSON input bound" + ) return payload @@ -216,7 +229,9 @@ def validate_request(command: str, payload: Mapping[str, Any], principal: str) - return candidate = _schema_payload(command, payload) validator = Draft202012Validator(action.input_schema) - errors = sorted(validator.iter_errors(candidate), key=lambda error: list(error.path)) + errors = sorted( + validator.iter_errors(candidate), key=lambda error: list(error.path) + ) if errors: error = errors[0] location = ".".join(str(part) for part in error.path) or "request" @@ -271,7 +286,11 @@ def catalog_display( if action_name is not None and (schema or example or explain): row = _action_row(action_name, principal) if schema: - return {"revision": REGISTRY.revision, "action": row, "schema": row["input_schema"]} + return { + "revision": REGISTRY.revision, + "action": row, + "schema": row["input_schema"], + } if example: return { "revision": REGISTRY.revision, @@ -298,4 +317,6 @@ def catalog_display( if row["schema_ref"].casefold().startswith(prefix) ], } - raise CliInputError("catalog display requires --schema, --example, --explain, or --complete") + raise CliInputError( + "catalog display requires --schema, --example, --explain, or --complete" + ) diff --git a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/config.py b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/config.py index 3e79d043..3eb9bd30 100644 --- a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/config.py +++ b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/config.py @@ -6,17 +6,25 @@ from pathlib import Path from typing import Any + def default_state_dir() -> Path: base = Path(os.environ.get("XDG_STATE_HOME", str(Path.home() / ".local" / "state"))) return base / "sinnix" / "agent-gateway" def default_ops_socket_path() -> Path: - return Path(os.environ.get("XDG_RUNTIME_DIR", "/run/user/1000")) / "sinnix" / "ops.sock" + return ( + Path(os.environ.get("XDG_RUNTIME_DIR", "/run/user/1000")) + / "sinnix" + / "ops.sock" + ) def default_sinnixd_socket_path() -> Path: - return Path(os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")) / "sinnixd.sock" + return ( + Path(os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")) + / "sinnixd.sock" + ) @dataclass(frozen=True) @@ -84,7 +92,9 @@ def load(cls, path: Path | None) -> "GatewayConfig": task_authority: TaskAuthorityConfig | None = None if task_authority_row is not None: if not isinstance(task_authority_row, dict): - raise ValueError(f"project {project_id} taskAuthority must be an object") + raise ValueError( + f"project {project_id} taskAuthority must be an object" + ) allowed_authority_fields = { "owner", "workspace", @@ -122,7 +132,9 @@ def load(cls, path: Path | None) -> "GatewayConfig": raise ValueError( f"project {project_id} taskAuthority projectUuid must be a string" ) - publication_policy = task_authority_row.get("publicationPolicy", "local") + publication_policy = task_authority_row.get( + "publicationPolicy", "local" + ) if publication_policy not in {"local", "dolt-sync"}: raise ValueError( f"project {project_id} taskAuthority publicationPolicy is invalid" @@ -175,7 +187,9 @@ def load(cls, path: Path | None) -> "GatewayConfig": capability_index=Path( raw.get("capabilityIndex", "/etc/sinnix/capability-index.json") ), - sinnixd_socket=Path(raw.get("sinnixdSocket", default_sinnixd_socket_path())), + sinnixd_socket=Path( + raw.get("sinnixdSocket", default_sinnixd_socket_path()) + ), observe_command=raw.get("observeCommand", "sinnix-observe"), max_result_bytes=int(raw.get("maxResultBytes", 262_144)), approved_manifest_hash=raw.get("approvedManifestHash"), @@ -193,8 +207,12 @@ def load(cls, path: Path | None) -> "GatewayConfig": screenshot_control_command=raw.get( "screenshotControlCommand", "sinnix-screenshot-control" ), - kitty_control_command=raw.get("kittyControlCommand", "sinnix-kitty-control"), - chrome_control_command=raw.get("chromeControlCommand", "sinnix-chrome-control"), + kitty_control_command=raw.get( + "kittyControlCommand", "sinnix-kitty-control" + ), + chrome_control_command=raw.get( + "chromeControlCommand", "sinnix-chrome-control" + ), beads_command=raw.get("beadsCommand", "bd"), mcp_broker_servers=broker_servers, capture_command=raw.get("captureCommand", "sinnix-capture"), diff --git a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/contexts.py b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/contexts.py index b48741a0..1db66748 100644 --- a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/contexts.py +++ b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/contexts.py @@ -6,12 +6,14 @@ import uuid from collections import OrderedDict from dataclasses import dataclass -from typing import Any, Callable, Mapping from pathlib import Path +from typing import Any, Callable, Mapping def _canonical(value: Any) -> bytes: - return json.dumps(value, sort_keys=True, separators=(",", ":"), default=str).encode() + return json.dumps( + value, sort_keys=True, separators=(",", ":"), default=str + ).encode() def source_revision(value: Any) -> str: @@ -28,9 +30,13 @@ def source_revision(value: Any) -> str: class ContextSnapshotStore: """Persist a bounded set of immutable, principal-scoped context snapshots.""" - def __init__(self, state_dir: Path, principal: str, *, max_entries: int = 64) -> None: + def __init__( + self, state_dir: Path, principal: str, *, max_entries: int = 64 + ) -> None: if not principal or max_entries < 1: - raise ValueError("context snapshot store requires a principal and positive bound") + raise ValueError( + "context snapshot store requires a principal and positive bound" + ) self.root = state_dir / "contexts" / principal self.root.mkdir(mode=0o700, parents=True, exist_ok=True) self.root.chmod(0o700) @@ -78,12 +84,14 @@ def put(self, snapshot: Mapping[str, Any]) -> str: key=lambda path: (path.stat().st_mtime_ns, path.name), reverse=True, ) - for stale in retained[self.max_entries:]: + for stale in retained[self.max_entries :]: stale.unlink(missing_ok=True) return snapshot_ref def get(self, snapshot_id: str) -> dict[str, Any]: - if len(snapshot_id) != 64 or any(char not in "0123456789abcdef" for char in snapshot_id): + if len(snapshot_id) != 64 or any( + char not in "0123456789abcdef" for char in snapshot_id + ): raise KeyError(snapshot_id) path = self.root / f"{snapshot_id}.json" try: @@ -171,7 +179,9 @@ class ComponentSpec: def __post_init__(self) -> None: if not self.name or self.budget_bytes < 128: - raise ValueError("context components require a name and useful positive budget") + raise ValueError( + "context components require a name and useful positive budget" + ) @dataclass(frozen=True) @@ -183,28 +193,61 @@ class ContextIntentSpec: CONTEXT_INTENTS: dict[str, ContextIntentSpec] = { "project.orientation": ContextIntentSpec( - "project.orientation", 48_000, - (("project", 12_000), ("checkout", 12_000), ("tasks", 16_000), ("authority", 8_000)), + "project.orientation", + 48_000, + ( + ("project", 12_000), + ("checkout", 12_000), + ("tasks", 16_000), + ("authority", 8_000), + ), ), "project.triage": ContextIntentSpec( - "project.triage", 56_000, - (("project", 12_000), ("open_beads", 18_000), ("stale_claims", 14_000), ("changes", 8_000)), + "project.triage", + 56_000, + ( + ("project", 12_000), + ("open_beads", 18_000), + ("stale_claims", 14_000), + ("changes", 8_000), + ), ), "bead.work": ContextIntentSpec( - "bead.work", 64_000, - (("bead", 18_000), ("project", 12_000), ("checkout", 12_000), ("assignment", 14_000), ("blockers", 8_000)), + "bead.work", + 64_000, + ( + ("bead", 18_000), + ("project", 12_000), + ("checkout", 12_000), + ("assignment", 14_000), + ("blockers", 8_000), + ), ), "bead.review": ContextIntentSpec( - "bead.review", 64_000, - (("bead", 16_000), ("job", 16_000), ("checkout", 12_000), ("diff", 12_000), ("evidence", 8_000)), + "bead.review", + 64_000, + ( + ("bead", 16_000), + ("job", 16_000), + ("checkout", 12_000), + ("diff", 12_000), + ("evidence", 8_000), + ), ), "job.review": ContextIntentSpec( - "job.review", 56_000, + "job.review", + 56_000, (("job", 18_000), ("result", 18_000), ("project", 10_000), ("events", 8_000)), ), "incident": ContextIntentSpec( - "incident", 56_000, - (("runtime", 16_000), ("transitions", 14_000), ("receipts", 12_000), ("jobs", 8_000)), + "incident", + 56_000, + ( + ("runtime", 16_000), + ("transitions", 14_000), + ("receipts", 12_000), + ("jobs", 8_000), + ), ), } @@ -217,7 +260,9 @@ def __init__(self, *, max_entries: int = 256, max_bytes: int = 2_000_000) -> Non raise ValueError("context cache bounds must be positive") self.max_entries = max_entries self.max_bytes = max_bytes - self._values: OrderedDict[tuple[str, str], tuple[ComponentResult, int]] = OrderedDict() + self._values: OrderedDict[tuple[str, str], tuple[ComponentResult, int]] = ( + OrderedDict() + ) self._bytes = 0 @staticmethod @@ -288,31 +333,43 @@ def compose( raise ValueError(f"unknown context intent: {intent}") from exc total_budget = total_budget_bytes or declared.total_budget_bytes if total_budget < 512 or total_budget > declared.total_budget_bytes: - raise ValueError("context total budget is outside the declared intent bound") + raise ValueError( + "context total budget is outside the declared intent bound" + ) budgets = dict(declared.components) expected = set(budgets) supplied = {component.name for component in components} if not supplied <= expected: - raise ValueError(f"context contains undeclared components: {sorted(supplied - expected)}") + raise ValueError( + f"context contains undeclared components: {sorted(supplied - expected)}" + ) if len(supplied) != len(components): raise ValueError("context contains duplicate components") supplied_by_name = {component.name: component for component in components} rows: list[ComponentResult] = [] - for name, budget in declared.components: + for name, _budget in declared.components: component = supplied_by_name.get(name) if component is None: - rows.append(ComponentResult.unavailable(name, "component plan was not supplied")) + rows.append( + ComponentResult.unavailable(name, "component plan was not supplied") + ) continue try: result = component.probe() if not isinstance(result, ComponentResult): raise TypeError("context component did not return ComponentResult") except Exception as exc: # component isolation is part of the contract - result = ComponentResult.unavailable(component.name, str(exc) or "owner unavailable") + result = ComponentResult.unavailable( + component.name, str(exc) or "owner unavailable" + ) if result.status == "available" and result.source_revision is not None: cached = self.cache.get(result.name, result.source_revision) result = cached if cached is not None else self.cache.put(result) - rows.append(self._bound_component(result, min(component.budget_bytes, budgets[component.name]))) + rows.append( + self._bound_component( + result, min(component.budget_bytes, budgets[component.name]) + ) + ) provisional = { "schema": "sinnix.gateway-context.v1", @@ -320,7 +377,8 @@ def compose( "target_ref": target_ref, "components": [], "component_plan": [ - {"name": name, "budget_bytes": budget} for name, budget in declared.components + {"name": name, "budget_bytes": budget} + for name, budget in declared.components ], "total_budget_bytes": total_budget, } @@ -335,7 +393,11 @@ def compose( # revisions remain, so a caller can continue each healthy route. for index in sorted( range(len(rows)), - key=lambda item: len(_canonical(rows[item].data)) if rows[item].status == "available" else 0, + key=lambda item: ( + len(_canonical(rows[item].data)) + if rows[item].status == "available" + else 0 + ), reverse=True, ): if rows[index].status != "available": diff --git a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/contracts.py b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/contracts.py index 62c4fee1..fbe43cfb 100644 --- a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/contracts.py +++ b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/contracts.py @@ -206,9 +206,13 @@ def __post_init__(self) -> None: raise ValueError( f"action {self.name!r} names unknown principals: {sorted(unknown_principals)}" ) - if not isinstance(self.input_schema, Mapping) or not self.input_schema.get("type"): + if not isinstance(self.input_schema, Mapping) or not self.input_schema.get( + "type" + ): raise ValueError(f"action {self.name!r} requires an input JSON Schema") - if not isinstance(self.output_schema, Mapping) or not self.output_schema.get("type"): + if not isinstance(self.output_schema, Mapping) or not self.output_schema.get( + "type" + ): raise ValueError(f"action {self.name!r} requires an output JSON Schema") if len(set(self.resource_kinds)) != len(self.resource_kinds): raise ValueError(f"action {self.name!r} repeats resource kinds") @@ -243,16 +247,26 @@ def __post_init__(self) -> None: raise ValueError(f"action {self.name!r} has unknown receipt policy") if not self.storage_effects <= frozenset(StorageEffect): raise ValueError(f"action {self.name!r} has unknown storage effects") - if self.failure_codes is not None and not self.failure_codes <= KNOWN_TYPED_FAILURES: + if ( + self.failure_codes is not None + and not self.failure_codes <= KNOWN_TYPED_FAILURES + ): raise ValueError(f"action {self.name!r} has unknown typed failures") - if self.receipt_policy == "audit" and StorageEffect.AUDIT_APPEND not in self.storage_effects: - raise ValueError(f"action {self.name!r} audit receipts require audit persistence") + if ( + self.receipt_policy == "audit" + and StorageEffect.AUDIT_APPEND not in self.storage_effects + ): + raise ValueError( + f"action {self.name!r} audit receipts require audit persistence" + ) @property def typed_failures(self) -> frozenset[str]: - return (self.failure_codes or KNOWN_TYPED_FAILURES) | ( - {"precondition_failed"} if self.supports_precondition else set() - ) | ({"idempotency_conflict"} if self.supports_idempotency else set()) + return ( + (self.failure_codes or KNOWN_TYPED_FAILURES) + | ({"precondition_failed"} if self.supports_precondition else set()) + | ({"idempotency_conflict"} if self.supports_idempotency else set()) + ) @property def schema_ref(self) -> str: @@ -265,7 +279,9 @@ def catalog_row(self) -> dict[str, Any]: "verb": self.verb.value, "domain": self.domain, "owner": self.owner, - "route": self.route.value if isinstance(self.route, OwnerRoute) else self.route, + "route": self.route.value + if isinstance(self.route, OwnerRoute) + else self.route, "availability": "declared", "effect": self.effect.value, "principals": sorted(self.principals), diff --git a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/desktop.py b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/desktop.py index 31f5057c..1dd2080d 100644 --- a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/desktop.py +++ b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/desktop.py @@ -5,9 +5,6 @@ from pathlib import Path from typing import Any -from .artifacts import ArtifactService -from .capabilities import Capability, Principal -from .config import GatewayConfig from sinnix_mcp.execution import ( EnvironmentProfile, ExecutionProfile, @@ -16,6 +13,10 @@ OwnerRoute, ) +from .artifacts import ArtifactService +from .capabilities import Capability, Principal +from .config import GatewayConfig + class DesktopError(ValueError): pass @@ -84,7 +85,13 @@ def capture_output(self, fix_hdr: bool = True) -> dict[str, Any]: raise DesktopError("fix_hdr must be boolean") capture_dir = self.config.state_dir / "captures" / uuid.uuid4().hex capture_dir.mkdir(mode=0o700, parents=True) - arguments = ["capture-output", "--out-dir", str(capture_dir), "--name", "gateway"] + arguments = [ + "capture-output", + "--out-dir", + str(capture_dir), + "--name", + "gateway", + ] if fix_hdr: arguments.append("--fix-hdr") result = self._run("screenshot", arguments) @@ -95,16 +102,24 @@ def capture_output(self, fix_hdr: bool = True) -> dict[str, Any]: for variant, key in (("raw", "raw_files"), ("corrected", "corrected_files")): files = response.get(key, []) if not isinstance(files, list): - raise DesktopError("screenshot control returned malformed capture metadata") + raise DesktopError( + "screenshot control returned malformed capture metadata" + ) for value in files: if not isinstance(value, str): - raise DesktopError("screenshot control returned malformed capture metadata") + raise DesktopError( + "screenshot control returned malformed capture metadata" + ) try: source = Path(value).resolve(strict=True) except OSError as exc: - raise DesktopError("screenshot control did not produce its declared file") from exc + raise DesktopError( + "screenshot control did not produce its declared file" + ) from exc if capture_dir.resolve() not in source.parents or not source.is_file(): - raise DesktopError("screenshot control returned a file outside gateway capture state") + raise DesktopError( + "screenshot control returned a file outside gateway capture state" + ) files_by_variant.append((variant, source)) if not files_by_variant: raise DesktopError("screenshot control did not produce any capture files") @@ -177,7 +192,9 @@ def action(self, operation: str, arguments: dict[str, Any]) -> dict[str, Any]: if ( not isinstance(extra, list) or len(extra) > 32 - or any(not isinstance(value, str) or len(value) > 8_192 for value in extra) + or any( + not isinstance(value, str) or len(value) > 8_192 for value in extra + ) ): raise DesktopError("dispatch args must contain at most 32 strings") return { @@ -187,7 +204,9 @@ def action(self, operation: str, arguments: dict[str, Any]) -> dict[str, Any]: if operation == "send_shortcut": allowed = {"mods", "key", "window"} if not {"mods", "key"} <= set(arguments) or set(arguments) - allowed: - raise DesktopError("send_shortcut requires mods, key, and optional window") + raise DesktopError( + "send_shortcut requires mods, key, and optional window" + ) command = [ "send-shortcut", self._string(arguments["mods"], "mods", 128), @@ -198,7 +217,10 @@ def action(self, operation: str, arguments: dict[str, Any]) -> dict[str, Any]: return {"operation": operation, **self._run("hypr", command)} if operation == "send_keystate": allowed = {"mods", "key", "state", "window"} - if not {"mods", "key", "state"} <= set(arguments) or set(arguments) - allowed: + if ( + not {"mods", "key", "state"} <= set(arguments) + or set(arguments) - allowed + ): raise DesktopError( "send_keystate requires mods, key, state, and optional window" ) diff --git a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/events.py b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/events.py index b0fa1853..94233a10 100644 --- a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/events.py +++ b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/events.py @@ -14,7 +14,6 @@ from .projects import ProjectService from .results import derive_cursor_key - MAX_CURSOR_BYTES = 4_096 MAX_RESPONSE_BYTES = 262_144 # A cursor carries two independent owner revisions per selected project. The @@ -30,7 +29,9 @@ class EventCursorError(ValueError): def _canonical(value: Any) -> bytes: - return json.dumps(value, sort_keys=True, separators=(",", ":"), default=str).encode() + return json.dumps( + value, sort_keys=True, separators=(",", ":"), default=str + ).encode() def _digest(value: Any) -> str: @@ -49,7 +50,12 @@ def _scope(self, projects: list[str]) -> str: @staticmethod def _initial_state() -> dict[str, Any]: - return {"audit_sequence": 0, "runtime_offset": 0, "owner_revisions": {}, "job_revision": None} + return { + "audit_sequence": 0, + "runtime_offset": 0, + "owner_revisions": {}, + "job_revision": None, + } def encode(self, state: Mapping[str, Any], projects: list[str]) -> str: body = {"v": 1, "scope": self._scope(projects), "state": dict(state)} @@ -63,7 +69,11 @@ def encode(self, state: Mapping[str, Any], projects: list[str]) -> str: def decode(self, value: str | None, projects: list[str]) -> dict[str, Any]: if value is None: return self._initial_state() - if not isinstance(value, str) or len(value.encode()) > MAX_CURSOR_BYTES or "." not in value: + if ( + not isinstance(value, str) + or len(value.encode()) > MAX_CURSOR_BYTES + or "." not in value + ): raise EventCursorError("event cursor is malformed or too large") payload, mac = value.rsplit(".", 1) expected = hmac.new(self._key, payload.encode(), hashlib.sha256).hexdigest() @@ -72,24 +82,55 @@ def decode(self, value: str | None, projects: list[str]) -> dict[str, Any]: try: padded = payload + "=" * (-len(payload) % 4) body = json.loads(base64.urlsafe_b64decode(padded).decode()) - except (ValueError, json.JSONDecodeError, binascii.Error, UnicodeDecodeError) as exc: + except ( + ValueError, + json.JSONDecodeError, + binascii.Error, + UnicodeDecodeError, + ) as exc: raise EventCursorError("event cursor is not valid JSON") from exc - if not isinstance(body, Mapping) or body.get("v") != 1 or body.get("scope") != self._scope(projects): - raise EventCursorError("event cursor scope is stale or belongs to another principal") + if ( + not isinstance(body, Mapping) + or body.get("v") != 1 + or body.get("scope") != self._scope(projects) + ): + raise EventCursorError( + "event cursor scope is stale or belongs to another principal" + ) state = body.get("state") if not isinstance(state, Mapping): raise EventCursorError("event cursor state is malformed") - if set(state) != {"audit_sequence", "runtime_offset", "owner_revisions", "job_revision"}: + if set(state) != { + "audit_sequence", + "runtime_offset", + "owner_revisions", + "job_revision", + }: raise EventCursorError("event cursor state is malformed") - if any(not isinstance(state[key], int) or isinstance(state[key], bool) or state[key] < 0 for key in ("audit_sequence", "runtime_offset")): + if any( + not isinstance(state[key], int) + or isinstance(state[key], bool) + or state[key] < 0 + for key in ("audit_sequence", "runtime_offset") + ): raise EventCursorError("event cursor position is malformed") owner_revisions = state["owner_revisions"] - if not isinstance(owner_revisions, Mapping) or len(owner_revisions) > MAX_OWNER_REVISIONS: + if ( + not isinstance(owner_revisions, Mapping) + or len(owner_revisions) > MAX_OWNER_REVISIONS + ): raise EventCursorError("event cursor owner state is too large") - if any(not isinstance(key, str) or not isinstance(revision, str) or len(revision) > 256 for key, revision in owner_revisions.items()): + if any( + not isinstance(key, str) + or not isinstance(revision, str) + or len(revision) > 256 + for key, revision in owner_revisions.items() + ): raise EventCursorError("event cursor owner state is malformed") job_revision = state["job_revision"] - if job_revision is not None and (not isinstance(job_revision, str) or len(job_revision) > 256): + if job_revision is not None and ( + not isinstance(job_revision, str) or len(job_revision) > 256 + ): raise EventCursorError("event cursor job state is malformed") return dict(state) @@ -157,7 +198,9 @@ def _bounded_event(self, event: dict[str, Any]) -> dict[str, Any]: } return compact - def _fits(self, events: list[dict[str, Any]], sources: Mapping[str, Any], limit: int) -> bool: + def _fits( + self, events: list[dict[str, Any]], sources: Mapping[str, Any], limit: int + ) -> bool: payload = { "schema": "sinnix.gateway-events.v1", "principal": self.principal, @@ -169,7 +212,13 @@ def _fits(self, events: list[dict[str, Any]], sources: Mapping[str, Any], limit: } return len(_canonical(payload)) <= self.max_response_bytes - def _accept(self, events: list[dict[str, Any]], sources: Mapping[str, Any], event: dict[str, Any], limit: int) -> bool: + def _accept( + self, + events: list[dict[str, Any]], + sources: Mapping[str, Any], + event: dict[str, Any], + limit: int, + ) -> bool: if len(events) >= limit: return False candidate = self._bounded_event(event) @@ -193,11 +242,19 @@ def _read_runtime_row(handle: Any) -> tuple[bytes | None, int, bool]: chunks.append(chunk) if chunk.endswith(b"\n"): if total > MAX_RUNTIME_ROW_BYTES: - marker = json.dumps({"truncated": True, "bytes": total, "sha256": digest.hexdigest()}).encode() + marker = json.dumps( + { + "truncated": True, + "bytes": total, + "sha256": digest.hexdigest(), + } + ).encode() return marker, total, True return b"".join(chunks), total, True - def _runtime_events(self, offset: int, limit: int, accept: Callable[[dict[str, Any]], bool]) -> tuple[int, dict[str, Any], bool]: + def _runtime_events( + self, offset: int, limit: int, accept: Callable[[dict[str, Any]], bool] + ) -> tuple[int, dict[str, Any], bool]: try: with self.transitions_path.open("rb") as handle: handle.seek(max(0, offset)) @@ -218,26 +275,55 @@ def _runtime_events(self, offset: int, limit: int, accept: Callable[[dict[str, A next_offset = handle.tell() continue revision = _digest(row) - event = self._event(event_id=str(row.get("event_id") or f"offset:{start}"), kind="runtime_transition", source="ops-reducer.transitions", source_revision=revision, data=row, exact=row.get("schema") == "sinnix-health-transition-v1") + event = self._event( + event_id=str(row.get("event_id") or f"offset:{start}"), + kind="runtime_transition", + source="ops-reducer.transitions", + source_revision=revision, + data=row, + exact=row.get("schema") == "sinnix-health-transition-v1", + ) if not accept(event): - return next_offset, {"availability": "available", "offset": next_offset}, True + return ( + next_offset, + {"availability": "available", "offset": next_offset}, + True, + ) next_offset = handle.tell() if limit <= 0: break probe = handle.read(1) if probe: handle.seek(-1, 1) - return next_offset, {"availability": "available", "offset": next_offset}, bool(probe) + return ( + next_offset, + {"availability": "available", "offset": next_offset}, + bool(probe), + ) except OSError as exc: return offset, {"availability": "unavailable", "reason": str(exc)}, False - def read(self, *, limit: int = 100, cursor: str | None = None, project_ids: list[str] | None = None) -> dict[str, Any]: - if not isinstance(limit, int) or isinstance(limit, bool) or not 1 <= limit <= 1_000: + def read( + self, + *, + limit: int = 100, + cursor: str | None = None, + project_ids: list[str] | None = None, + ) -> dict[str, Any]: + if ( + not isinstance(limit, int) + or isinstance(limit, bool) + or not 1 <= limit <= 1_000 + ): raise ValueError("event limit must be 1-1000") selected = sorted(project_ids or self.projects.config.projects) if not selected or len(selected) > MAX_EVENT_PROJECTS: - raise ValueError(f"event project scope must contain 1-{MAX_EVENT_PROJECTS} projects") - if any(project_id not in self.projects.config.projects for project_id in selected): + raise ValueError( + f"event project scope must contain 1-{MAX_EVENT_PROJECTS} projects" + ) + if any( + project_id not in self.projects.config.projects for project_id in selected + ): raise ValueError("event project scope contains an unknown project") state = self.cursor.decode(cursor, selected) events: list[dict[str, Any]] = [] @@ -248,13 +334,34 @@ def read(self, *, limit: int = 100, cursor: str | None = None, project_ids: list truncated = False audit_rows = self.audit.events_since(audit_sequence, limit) - sources["gateway.audit"] = {"availability": "available", "last_sequence": audit_sequence} + sources["gateway.audit"] = { + "availability": "available", + "last_sequence": audit_sequence, + } for row in audit_rows: payload = row.get("payload", {}) owner = payload.get("owner") if isinstance(payload, Mapping) else None - kind = "ops_receipt" if owner == "ops-reducer" or row.get("operation") == "machine.operate" else "gateway_receipt" - event = self._event(event_id=str(row["event_id"]), kind=kind, source="gateway.audit", source_revision=str(row["entry_hash"]), data=row, exact=True, subject_ref=(payload.get("target_refs", [None])[0] if isinstance(payload, Mapping) and payload.get("target_refs") else None)) - event.update({key: row[key] for key in ("operation", "outcome", "sequence")}) + kind = ( + "ops_receipt" + if owner == "ops-reducer" or row.get("operation") == "machine.operate" + else "gateway_receipt" + ) + event = self._event( + event_id=str(row["event_id"]), + kind=kind, + source="gateway.audit", + source_revision=str(row["entry_hash"]), + data=row, + exact=True, + subject_ref=( + payload.get("target_refs", [None])[0] + if isinstance(payload, Mapping) and payload.get("target_refs") + else None + ), + ) + event.update( + {key: row[key] for key in ("operation", "outcome", "sequence")} + ) if not self._accept(events, sources, event, limit): truncated = True break @@ -268,39 +375,101 @@ def read(self, *, limit: int = 100, cursor: str | None = None, project_ids: list try: summary = self.projects.summary(project_id) git_revision = _digest(summary) - sources[f"git:{project_id}"] = {"availability": "available", "revision": git_revision} + sources[f"git:{project_id}"] = { + "availability": "available", + "revision": git_revision, + } key = f"git:{project_id}" if owner_revisions.get(key) != git_revision: - event = self._event(event_id=f"git:{project_id}:{git_revision}", kind="git_revision", source="git.project", source_revision=git_revision, data={"project_id": project_id, "latest_commit": summary.get("latest_commit"), "changes": summary.get("changes")}, exact=False, subject_ref=project_ref) + event = self._event( + event_id=f"git:{project_id}:{git_revision}", + kind="git_revision", + source="git.project", + source_revision=git_revision, + data={ + "project_id": project_id, + "latest_commit": summary.get("latest_commit"), + "changes": summary.get("changes"), + }, + exact=False, + subject_ref=project_ref, + ) if self._accept(events, sources, event, limit): owner_revisions[key] = git_revision else: truncated = True except Exception as exc: - sources[f"git:{project_id}"] = {"availability": "unavailable", "reason": str(exc)} + sources[f"git:{project_id}"] = { + "availability": "unavailable", + "reason": str(exc), + } try: authority = self.beads.task_authority_status(project_id) bead_revision = str(authority["revision"]) - sources[f"beads:{project_id}"] = {"availability": "available", "revision": bead_revision} + sources[f"beads:{project_id}"] = { + "availability": "available", + "revision": bead_revision, + } key = f"beads:{project_id}" if owner_revisions.get(key) != bead_revision: - event = self._event(event_id=f"beads:{project_id}:{bead_revision}", kind="owner_revision", source="beads.owner", source_revision=bead_revision, data={"project_id": project_id, "revision": bead_revision, "diff": authority.get("diff"), "change": "owner revision changed"}, exact=False, subject_ref=f"{project_ref}/task-authority") + event = self._event( + event_id=f"beads:{project_id}:{bead_revision}", + kind="owner_revision", + source="beads.owner", + source_revision=bead_revision, + data={ + "project_id": project_id, + "revision": bead_revision, + "diff": authority.get("diff"), + "change": "owner revision changed", + }, + exact=False, + subject_ref=f"{project_ref}/task-authority", + ) if self._accept(events, sources, event, limit): owner_revisions[key] = bead_revision else: truncated = True except Exception as exc: - sources[f"beads:{project_id}"] = {"availability": "unavailable", "reason": str(exc)} + sources[f"beads:{project_id}"] = { + "availability": "unavailable", + "reason": str(exc), + } if self.jobs is not None and len(events) < limit: try: page = self.jobs(min(100, limit), None) jobs = page.get("jobs", []) if isinstance(page, Mapping) else [] - observation = {"snapshot": page.get("snapshot") if isinstance(page, Mapping) else None, "jobs": [{"job_id": job.get("job_id"), "state": job.get("state")} for job in jobs if isinstance(job, Mapping) and isinstance(job.get("job_id"), str)]} + observation = { + "snapshot": page.get("snapshot") + if isinstance(page, Mapping) + else None, + "jobs": [ + {"job_id": job.get("job_id"), "state": job.get("state")} + for job in jobs + if isinstance(job, Mapping) + and isinstance(job.get("job_id"), str) + ], + } observed_revision = _digest(observation) - sources["sinnixd.jobs"] = {"availability": "available", "count": len(jobs) if isinstance(jobs, list) else 0, "revision": observed_revision} + sources["sinnixd.jobs"] = { + "availability": "available", + "count": len(jobs) if isinstance(jobs, list) else 0, + "revision": observed_revision, + } if job_revision != observed_revision: - event = self._event(event_id=f"jobs:{observed_revision}", kind="job_state", source="sinnixd.jobs", source_revision=observed_revision, data={"snapshot": observation["snapshot"], "jobs": observation["jobs"]}, exact=False, subject_ref="sinnix://jobs") + event = self._event( + event_id=f"jobs:{observed_revision}", + kind="job_state", + source="sinnixd.jobs", + source_revision=observed_revision, + data={ + "snapshot": observation["snapshot"], + "jobs": observation["jobs"], + }, + exact=False, + subject_ref="sinnix://jobs", + ) if self._accept(events, sources, event, limit): job_revision = observed_revision else: @@ -308,19 +477,38 @@ def read(self, *, limit: int = 100, cursor: str | None = None, project_ids: list if isinstance(page, Mapping) and page.get("next_cursor"): truncated = True except Exception as exc: - sources["sinnixd.jobs"] = {"availability": "unavailable", "reason": str(exc)} + sources["sinnixd.jobs"] = { + "availability": "unavailable", + "reason": str(exc), + } runtime_offset = int(state["runtime_offset"]) if len(events) < limit: - runtime_offset, runtime_source, runtime_more = self._runtime_events(runtime_offset, limit - len(events), lambda event: self._accept(events, sources, event, limit)) + runtime_offset, runtime_source, runtime_more = self._runtime_events( + runtime_offset, + limit - len(events), + lambda event: self._accept(events, sources, event, limit), + ) sources["ops-reducer.transitions"] = runtime_source truncated = truncated or runtime_more else: sources["ops-reducer.transitions"] = {"availability": "not_requested"} - next_state = {"audit_sequence": audit_sequence, "runtime_offset": runtime_offset, "owner_revisions": owner_revisions, "job_revision": job_revision} + next_state = { + "audit_sequence": audit_sequence, + "runtime_offset": runtime_offset, + "owner_revisions": owner_revisions, + "job_revision": job_revision, + } next_cursor = self.cursor.encode(next_state, selected) - response = {"schema": "sinnix.gateway-events.v1", "principal": self.principal, "events": events, "sources": sources, "next_cursor": next_cursor, "truncated": truncated or len(events) >= limit} + response = { + "schema": "sinnix.gateway-events.v1", + "principal": self.principal, + "events": events, + "sources": sources, + "next_cursor": next_cursor, + "truncated": truncated or len(events) >= limit, + } if len(_canonical(response)) > self.max_response_bytes: raise EventCursorError("event response exceeds its size bound") return response diff --git a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/files.py b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/files.py index a91536b3..20af0e8c 100644 --- a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/files.py +++ b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/files.py @@ -42,9 +42,10 @@ def _copy_exclusive(source: Path, destination: Path) -> None: except FileExistsError as exc: raise FileError("destination already exists") from exc try: - with source.open("rb") as input_handle, os.fdopen( - destination_fd, "wb" - ) as output_handle: + with ( + source.open("rb") as input_handle, + os.fdopen(destination_fd, "wb") as output_handle, + ): destination_fd = -1 shutil.copyfileobj(input_handle, output_handle) except Exception: @@ -231,7 +232,9 @@ def write( "removed": operation == "move", } if operation not in {"replace", "append"}: - raise FileError("operation must be replace, append, mkdir, remove, copy, or move") + raise FileError( + "operation must be replace, append, mkdir, remove, copy, or move" + ) if content is None: raise FileError("content is required") encoded = content.encode() diff --git a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/gateway_codegen.py b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/gateway_codegen.py index c8dac6bb..0b94b661 100644 --- a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/gateway_codegen.py +++ b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/gateway_codegen.py @@ -8,7 +8,6 @@ from .contracts import VerbFamily from .registry import REGISTRY - BEGIN_MARKER = "" END_MARKER = "" REFERENCE_PATH = Path("docs/generated/agent-gateway-reference.md") @@ -25,7 +24,10 @@ def catalog_payload() -> dict[str, Any]: rows = REGISTRY.documentation_rows() for row in rows["actions"]: for example in row["examples"]: - if row["name"] == "projects.change" and "parameters" not in example["input"]: + if ( + row["name"] == "projects.change" + and "parameters" not in example["input"] + ): example["input"]["parameters"] = { key: example["input"].pop(key) for key in ("path", "content", "patch") @@ -84,8 +86,22 @@ def render_reference() -> str: "| --- | --- | --- |", ] for verb in VerbFamily: - lines.append(f"| `{verb.value}` | `sinnix-agent-gateway {verb.value}` | `{verb.value}` |") - lines.extend(["", "## Resources", "", *_resource_table(catalog["resources"]), "", "## Actions", "", *_action_table(catalog["actions"]), ""]) + lines.append( + f"| `{verb.value}` | `sinnix-agent-gateway {verb.value}` | `{verb.value}` |" + ) + lines.extend( + [ + "", + "## Resources", + "", + *_resource_table(catalog["resources"]), + "", + "## Actions", + "", + *_action_table(catalog["actions"]), + "", + ] + ) for row in catalog["actions"]: lines.extend( [ @@ -109,7 +125,9 @@ def render_reference() -> str: for example in row["examples"]: lines.extend(["```json", _json(example["input"]), "```", ""]) else: - lines.append("No example is declared. Discover the live schema before invoking this action.\n") + lines.append( + "No example is declared. Discover the live schema before invoking this action.\n" + ) return "\n".join(lines).rstrip() + "\n" @@ -123,17 +141,17 @@ def render_skill() -> str: triage = _action_name(catalog["actions"], "beads.query", "gateway.catalog") changeset = _action_name(catalog["actions"], "beads.changeset", "gateway.catalog") agent = _action_name(catalog["actions"], "agent.for_bead", "gateway.catalog") - machine = _action_name(catalog["actions"], "machine.query", "gateway.catalog") + _action_name(catalog["actions"], "machine.query", "gateway.catalog") browser = _action_name(catalog["actions"], "browser.operate", "gateway.catalog") desktop = _action_name(catalog["actions"], "desktop.operate", "gateway.catalog") - return f'''--- + return f"""--- name: agent-gateway description: Use when invoking, inspecting, or documenting Sinnix Agent Gateway V2 resources and actions through its ten-verb CLI or MCP contract. --- - - + + # Agent Gateway V2 Use `sinnix-agent-gateway` when a local agent needs the same principal-scoped routes and normalized envelopes as MCP. The complete action schemas and examples are in `docs/generated/agent-gateway-reference.md`. @@ -160,8 +178,8 @@ def render_skill() -> str: The gateway is the preferred route for typed, principal-scoped Beads work. The direct owner fallback is `bd 1.1.0-dev` against the project’s canonical standalone Dolt workspace, resolved through the project’s canonical worktree and `.beads/redirect`. Dolt is the authority for ordinary mutations. `issues.jsonl` is an optional JSONL export, not a write authority. Use the gateway `beads.operate` action with `snapshot.publish` when an explicit deterministic snapshot is required. Snapshot publication does not imply a Git commit or a Dolt push. Use `sync.push` or `sync.pull` explicitly for Dolt synchronization. Never hand-author `bd` argv when the gateway catalog exposes the needed action. -Catalog revision: `{catalog['revision']}`. Catalog SHA-256: `{catalog['action_catalog_hash']}`. -''' +Catalog revision: `{catalog["revision"]}`. Catalog SHA-256: `{catalog["action_catalog_hash"]}`. +""" def _fixture_input(row: dict[str, Any]) -> dict[str, Any]: @@ -169,15 +187,21 @@ def _fixture_input(row: dict[str, Any]) -> dict[str, Any]: value = dict(row["examples"][0]["input"]) else: value = {} - if row["verb"] in {verb.value for verb in (VerbFamily.QUERY, VerbFamily.RUN, VerbFamily.CHANGE, VerbFamily.OPERATE)}: + if row["verb"] in { + verb.value + for verb in ( + VerbFamily.QUERY, + VerbFamily.RUN, + VerbFamily.CHANGE, + VerbFamily.OPERATE, + ) + }: value.setdefault("action_name", row["name"]) if row["name"] == "machine.operate" and "action" in value: value["operation"] = value.pop("action") if row["name"] == "projects.change" and "parameters" not in value: value["parameters"] = { - key: value.pop(key) - for key in ("path", "content", "patch") - if key in value + key: value.pop(key) for key in ("path", "content", "patch") if key in value } return value @@ -223,7 +247,9 @@ def update_docs(text: str) -> str: if BEGIN_MARKER in text and END_MARKER in text: before = text.split(BEGIN_MARKER, 1)[0].rstrip("\n") after = text.split(END_MARKER, 1)[1].lstrip("\n") - return f"{before}\n{generated}\n{after}" if after else f"{before}\n{generated}\n" + return ( + f"{before}\n{generated}\n{after}" if after else f"{before}\n{generated}\n" + ) return text.rstrip("\n") + "\n\n" + generated + "\n" @@ -248,12 +274,16 @@ def check_artifacts(root: Path) -> list[str]: if not path.exists(): mismatches.append(f"missing generated artifact: {path.relative_to(root)}") elif path.read_text() != expected: - mismatches.append(f"stale or corrupt generated artifact: {path.relative_to(root)}") + mismatches.append( + f"stale or corrupt generated artifact: {path.relative_to(root)}" + ) return mismatches def main() -> int: - parser = argparse.ArgumentParser(description="Generate gateway V2 docs, skill, and fixtures") + parser = argparse.ArgumentParser( + description="Generate gateway V2 docs, skill, and fixtures" + ) parser.add_argument("--root", type=Path, required=True) mode = parser.add_mutually_exclusive_group(required=True) mode.add_argument("--write", action="store_true") diff --git a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/legacy_manifest.py b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/legacy_manifest.py index 3e82ea0f..d762e7fd 100644 --- a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/legacy_manifest.py +++ b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/legacy_manifest.py @@ -6,12 +6,13 @@ from importlib.resources import files from typing import Any - LEGACY_MANIFEST_SCHEMA = "sinnix.gateway-legacy-tool-list.v1" def load_legacy_manifest() -> dict[str, Any]: - value = json.loads(files(__package__).joinpath("legacy_manifest_v1.json").read_text()) + value = json.loads( + files(__package__).joinpath("legacy_manifest_v1.json").read_text() + ) if value.get("schema") != LEGACY_MANIFEST_SCHEMA: raise ValueError("legacy manifest has an unknown schema") tools = value.get("tools") @@ -19,7 +20,10 @@ def load_legacy_manifest() -> dict[str, Any]: raise ValueError("legacy manifest tools must be a string list") if len(tools) != 49 or len(set(tools)) != len(tools): raise ValueError("legacy manifest must contain the 49 unique retired tools") - if not isinstance(value.get("canonical_bytes"), int) or value["canonical_bytes"] < 1: + if ( + not isinstance(value.get("canonical_bytes"), int) + or value["canonical_bytes"] < 1 + ): raise ValueError("legacy manifest must declare canonical byte accounting") return value diff --git a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/legacy_manifest_v1.json b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/legacy_manifest_v1.json index 89888276..ff36a082 100644 --- a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/legacy_manifest_v1.json +++ b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/legacy_manifest_v1.json @@ -3,6 +3,54 @@ "schema": "sinnix.gateway-legacy-tool-list.v1", "source_commit": "e5980a67eae343f954f695c46a8fadda83961a03", "tools": [ - "gateway_status", "machine_report", "machine_query", "capability_search", "capability_describe", "mcp_catalog", "mcp_read", "mcp_write", "tasks_read", "tasks_write", "machine_action", "desktop_read", "desktop_capture", "desktop_action", "terminal_read", "terminal_action", "browser_read", "browser_capture", "browser_action", "project_list", "project_context", "project_tree", "project_read", "project_search", "project_diff", "files_read", "files_write", "session_list", "session_read", "session_search", "memory_search", "memory_get", "timeline_query", "shell_query", "shell_run", "shell_start", "job_list", "job_status", "job_read_output", "artifact_list", "artifact_read", "audit_tail", "audit_verify", "capture_lanes", "capture_query", "agent_launch", "job_cancel", "project_write", "project_apply_patch" + "gateway_status", + "machine_report", + "machine_query", + "capability_search", + "capability_describe", + "mcp_catalog", + "mcp_read", + "mcp_write", + "tasks_read", + "tasks_write", + "machine_action", + "desktop_read", + "desktop_capture", + "desktop_action", + "terminal_read", + "terminal_action", + "browser_read", + "browser_capture", + "browser_action", + "project_list", + "project_context", + "project_tree", + "project_read", + "project_search", + "project_diff", + "files_read", + "files_write", + "session_list", + "session_read", + "session_search", + "memory_search", + "memory_get", + "timeline_query", + "shell_query", + "shell_run", + "shell_start", + "job_list", + "job_status", + "job_read_output", + "artifact_list", + "artifact_read", + "audit_tail", + "audit_verify", + "capture_lanes", + "capture_query", + "agent_launch", + "job_cancel", + "project_write", + "project_apply_patch" ] } diff --git a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/machine_actions.py b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/machine_actions.py index 51cd66a9..5a65ef6c 100644 --- a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/machine_actions.py +++ b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/machine_actions.py @@ -3,7 +3,6 @@ import http.client import json import socket -from pathlib import Path from typing import Any, Callable from .capabilities import Capability, Principal @@ -30,13 +29,17 @@ def __init__( self, config: GatewayConfig, principal: Principal, - connection_factory: Callable[[str], http.client.HTTPConnection] = UnixConnection, + connection_factory: Callable[ + [str], http.client.HTTPConnection + ] = UnixConnection, ) -> None: self.config = config self.principal = principal self.connection_factory = connection_factory - def _request(self, method: str, path: str, body: bytes | None = None) -> dict[str, Any]: + def _request( + self, method: str, path: str, body: bytes | None = None + ) -> dict[str, Any]: headers = {"Content-Type": "application/json"} if body is not None else {} try: connection = self.connection_factory(str(self.config.ops_socket_path)) @@ -55,7 +58,9 @@ def _request(self, method: str, path: str, body: bytes | None = None) -> dict[st try: payload = json.loads(payload_bytes) except json.JSONDecodeError as exc: - raise MachineActionError("ops reducer returned a malformed response") from exc + raise MachineActionError( + "ops reducer returned a malformed response" + ) from exc if response.status >= 400: message = payload.get("error") if isinstance(payload, dict) else None if isinstance(message, str): diff --git a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/mcp_broker.py b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/mcp_broker.py index 3429d0c5..31043f0d 100644 --- a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/mcp_broker.py +++ b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/mcp_broker.py @@ -9,10 +9,6 @@ from mcp import ClientSession from mcp.client.stdio import StdioServerParameters, stdio_client - -from .artifacts import ArtifactService -from .capabilities import Capability, Principal -from .config import GatewayConfig from sinnix_mcp.execution import ( EnvironmentProfile, ExecutionProfile, @@ -20,6 +16,10 @@ OwnerRoute, ) +from .artifacts import ArtifactService +from .capabilities import Capability, Principal +from .config import GatewayConfig + class McpBrokerError(ValueError): pass @@ -53,13 +53,27 @@ async def catalog(self) -> dict[str, Any]: self.principal.require(Capability.MCP_READ) rows = sorted(self.config.mcp_broker_servers.items()) probes = await asyncio.gather( - *(self._catalog_server(name, row) for name, row in rows if isinstance(row, dict)) + *( + self._catalog_server(name, row) + for name, row in rows + if isinstance(row, dict) + ) ) servers = list(probes) full_response = {"servers": servers} - if len(json.dumps(full_response, sort_keys=True, separators=(",", ":")).encode()) > self.config.max_result_bytes: + if ( + len( + json.dumps( + full_response, sort_keys=True, separators=(",", ":") + ).encode() + ) + > self.config.max_result_bytes + ): catalog_artifact = self._store_json_artifact( - full_response, kind="mcp-catalog", owner_id="mcp-broker", source="mcp-catalog" + full_response, + kind="mcp-catalog", + owner_id="mcp-broker", + source="mcp-catalog", ) else: catalog_artifact = None @@ -68,22 +82,33 @@ async def catalog(self) -> dict[str, Any]: if catalog_artifact is not None: response["truncated"] = True response["catalog_artifact"] = catalog_artifact - if len(json.dumps(response, sort_keys=True, separators=(",", ":")).encode()) <= self.config.max_result_bytes: + if ( + len( + json.dumps(response, sort_keys=True, separators=(",", ":")).encode() + ) + <= self.config.max_result_bytes + ): return response candidates = [ - server for server in servers + server + for server in servers if isinstance(server.get("tools"), list) and server["tools"] ] if not candidates: if catalog_artifact is None: catalog_artifact = self._store_json_artifact( - response, kind="mcp-catalog", owner_id="mcp-broker", source="mcp-catalog" + response, + kind="mcp-catalog", + owner_id="mcp-broker", + source="mcp-catalog", ) return { "truncated": True, "catalog_artifact": catalog_artifact, } - largest = max(candidates, key=lambda server: len(json.dumps(server["tools"]))) + largest = max( + candidates, key=lambda server: len(json.dumps(server["tools"])) + ) largest["tools"].pop() largest["tools_truncated"] = True @@ -148,12 +173,17 @@ async def _probe( async def inspect() -> tuple[list[dict[str, Any]], int]: with stderr_path.open("w", encoding="utf-8") as stderr: - async with stdio_client(parameters, errlog=stderr) as (read, write_stream): + async with stdio_client(parameters, errlog=stderr) as ( + read, + write_stream, + ): async with ClientSession(read, write_stream) as session: await session.initialize() tools = (await session.list_tools()).tools contracts = [self._tool_contract(server_name, tool) for tool in tools] - return contracts, sum(contract["effect"] == "read" for contract in contracts) + return contracts, sum( + contract["effect"] == "read" for contract in contracts + ) try: tools, read_only_tool_count = await asyncio.wait_for(inspect(), timeout=5) @@ -201,14 +231,18 @@ def _tool_contract(self, server_name: str, tool: Any) -> dict[str, Any]: schema = getattr(tool, "inputSchema", getattr(tool, "input_schema", None)) if not isinstance(schema, dict): raise McpBrokerError(f"MCP tool {name!r} has no input schema") - read_only = getattr(getattr(tool, "annotations", None), "read_only_hint", None) is True + read_only = ( + getattr(getattr(tool, "annotations", None), "read_only_hint", None) is True + ) contract: dict[str, Any] = { "name": name, "ref": f"sinnix://mcp/{server_name}/tools/{name}", "description": getattr(tool, "description", None), "effect": "read" if read_only else "change", } - encoded_schema = json.dumps(schema, sort_keys=True, separators=(",", ":")).encode() + encoded_schema = json.dumps( + schema, sort_keys=True, separators=(",", ":") + ).encode() if len(encoded_schema) <= max(1, self.config.max_result_bytes // 2): contract["input_schema"] = schema else: @@ -254,11 +288,13 @@ def _server(self, name: str) -> dict[str, Any]: or not isinstance(args, list) or any(not isinstance(value, str) for value in args) or not isinstance(environment, dict) - or any(not isinstance(key, str) or not isinstance(value, str) for key, value in environment.items()) + or any( + not isinstance(key, str) or not isinstance(value, str) + for key, value in environment.items() + ) or not isinstance(observer_writable_paths, list) or any( - not isinstance(path, str) - or not path.startswith(("/", "%t/")) + not isinstance(path, str) or not path.startswith(("/", "%t/")) for path in observer_writable_paths ) ): @@ -411,7 +447,12 @@ def _store_upstream_stderr( return self.artifacts.register(source, kind="mcp-stderr", owner_id=server_name) async def call( - self, server_name: str, tool_name: str, arguments: dict[str, Any], *, write: bool + self, + server_name: str, + tool_name: str, + arguments: dict[str, Any], + *, + write: bool, ) -> dict[str, Any]: self.principal.require(Capability.MCP_WRITE if write else Capability.MCP_READ) server_name = self._string(server_name, "server", 128) @@ -428,13 +469,18 @@ async def invoke() -> dict[str, Any]: tool: Any | None = None response: Any | None = None with stderr_path.open("w", encoding="utf-8") as stderr: - async with stdio_client(parameters, errlog=stderr) as (read, write_stream): + async with stdio_client(parameters, errlog=stderr) as ( + read, + write_stream, + ): async with ClientSession(read, write_stream) as session: await session.initialize() tool = self._tool((await session.list_tools()).tools, tool_name) if tool is not None: read_only = getattr( - getattr(tool, "annotations", None), "read_only_hint", None + getattr(tool, "annotations", None), + "read_only_hint", + None, ) if (not write and read_only is True) or ( write and read_only is not True @@ -443,13 +489,17 @@ async def invoke() -> dict[str, Any]: if tool is None: raise McpBrokerError(f"MCP server does not expose tool {tool_name!r}") - read_only = getattr(getattr(tool, "annotations", None), "read_only_hint", None) + read_only = getattr( + getattr(tool, "annotations", None), "read_only_hint", None + ) if not write and read_only is not True: raise McpBrokerError( "MCP tool is not explicitly declared read-only; select mcp.change through change" ) if write and read_only is True: - raise McpBrokerError("MCP tool is declared read-only; invoke its read contract") + raise McpBrokerError( + "MCP tool is declared read-only; invoke its read contract" + ) if response is None: raise McpBrokerError("MCP server returned no tool result") return self._response_payload(response) diff --git a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/memory.py b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/memory.py index 11ea81ce..3710c6a6 100644 --- a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/memory.py +++ b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/memory.py @@ -49,10 +49,16 @@ def search( ) -> dict[str, Any]: self.principal.require(Capability.SESSION_READ) query = self._query(query) - if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 500: + if ( + isinstance(limit, bool) + or not isinstance(limit, int) + or not 1 <= limit <= 500 + ): raise MemoryError("limit must be 1-500") requested = self._providers(providers) - raw_requested = [provider for provider in requested if provider in _RAW_PROVIDERS] + raw_requested = [ + provider for provider in requested if provider in _RAW_PROVIDERS + ] per_source_limit = max(1, -(-limit // max(1, len(raw_requested)))) sources = [] matches = [] @@ -67,7 +73,11 @@ def search( } ) continue - source = next(source for source in self.sessions.sources if source.provider == provider) + source = next( + source + for source in self.sessions.sources + if source.provider == provider + ) if not source.root.is_dir(): sources.append( { @@ -104,8 +114,10 @@ def search( "query": query, "sources": sources, "matches": matches[:limit], - "truncated": len(matches) > limit or any( - source.get("coverage", {}).get("truncated") is True for source in sources + "truncated": len(matches) > limit + or any( + source.get("coverage", {}).get("truncated") is True + for source in sources ), } diff --git a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/observe.py b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/observe.py index 42c7a8be..4af05687 100644 --- a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/observe.py +++ b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/observe.py @@ -3,10 +3,11 @@ import json from typing import Any +from sinnix_mcp.execution import ExecutionProfile, OwnerExecution, OwnerRoute + from .artifacts import ArtifactService from .capabilities import Capability, Principal from .config import GatewayConfig -from sinnix_mcp.execution import ExecutionProfile, OwnerExecution, OwnerRoute class ObserveService: diff --git a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/parity.py b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/parity.py index 137e8be2..9c6b0717 100644 --- a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/parity.py +++ b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/parity.py @@ -6,7 +6,7 @@ from typing import Any, Literal from .contracts import OBSERVABILITY_PERSISTENCE, ActionSpec -from .legacy_manifest import LEGACY_MANIFEST, LEGACY_MANIFEST_SCHEMA +from .legacy_manifest import LEGACY_MANIFEST from .registry import CatalogRegistry PARITY_SCHEMA = "sinnix.gateway-legacy-parity.v2" @@ -43,11 +43,16 @@ class LegacyParityRow: def _migration( - route: str, action: str | None = None, *, deletion_verdict: str | None = None, + route: str, + action: str | None = None, + *, + deletion_verdict: str | None = None, semantic_change: str | None = None, ) -> LegacyMigration: if (action is None) == (deletion_verdict is None): - raise ValueError("legacy capability requires exactly one migration or deletion verdict") + raise ValueError( + "legacy capability requires exactly one migration or deletion verdict" + ) return LegacyMigration(route, action, deletion_verdict, semantic_change) @@ -64,7 +69,9 @@ def _migration( ), "machine_query": _migration("observe.machine_query", "machine.query"), "capability_search": _migration("capability_index.search", "capabilities.query"), - "capability_describe": _migration("capability_index.describe", "capabilities.query"), + "capability_describe": _migration( + "capability_index.describe", "capabilities.query" + ), "mcp_catalog": _migration("mcp_broker.catalog", "mcp.query"), "mcp_read": _migration("mcp_broker.call.read", "mcp.query"), "mcp_write": _migration("mcp_broker.call.write", "mcp.change"), @@ -123,7 +130,9 @@ def _migration( } -def _row(legacy_tool: str, migration: LegacyMigration, action: ActionSpec | None) -> LegacyParityRow: +def _row( + legacy_tool: str, migration: LegacyMigration, action: ActionSpec | None +) -> LegacyParityRow: if action is None: assert migration.deletion_verdict is not None return LegacyParityRow( @@ -153,7 +162,11 @@ def _row(legacy_tool: str, migration: LegacyMigration, action: ActionSpec | None def _validate_row(row: LegacyParityRow, registry: CatalogRegistry) -> None: if row.disposition == "deleted": - if row.v2_action is not None or row.v2_route is not None or not row.deletion_verdict: + if ( + row.v2_action is not None + or row.v2_route is not None + or not row.deletion_verdict + ): raise ValueError(f"{row.legacy_tool} has an unexplained deletion") return if row.v2_action is None or row.v2_route is None: @@ -208,8 +221,10 @@ def legacy_parity_contract(registry: CatalogRegistry) -> dict[str, Any]: "migrated": sum(row.disposition == "migrated" for row in rows), "deleted": sum(row.disposition == "deleted" for row in rows), "unexplained": sum( - row.disposition == "migrated" and row.v2_action is None - or row.disposition == "deleted" and not row.deletion_verdict + row.disposition == "migrated" + and row.v2_action is None + or row.disposition == "deleted" + and not row.deletion_verdict for row in rows ), }, diff --git a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/project_context.py b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/project_context.py index 4c652b95..ee445246 100644 --- a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/project_context.py +++ b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/project_context.py @@ -37,7 +37,9 @@ def context(self, project_id: str) -> dict[str, Any]: try: tasks = { "availability": "available", - **self.beads.query(project_ids=[project_id], view="ready", limit=20), + **self.beads.query( + project_ids=[project_id], view="ready", limit=20 + ), } except BeadsError as exc: tasks = { diff --git a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/projects.py b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/projects.py index ebb979c9..685f6235 100644 --- a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/projects.py +++ b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/projects.py @@ -11,10 +11,11 @@ from pathlib import Path from typing import Any, Iterator, Mapping +from sinnix_lib.lock import flock +from sinnix_mcp.execution import ExecutionProfile, OwnerExecution, OwnerRoute + from .capabilities import Capability, Principal from .config import GatewayConfig, ProjectConfig -from sinnix_mcp.execution import ExecutionProfile, OwnerExecution, OwnerRoute -from sinnix_lib.lock import flock class ProjectError(ValueError): @@ -47,10 +48,7 @@ class ProjectPreconditionError(ProjectError): ) LOCAL_ONLY_FILES = frozenset({(".mcp.json",)}) _DIRECTORY_OPEN_FLAGS = ( - os.O_RDONLY - | os.O_DIRECTORY - | os.O_NOFOLLOW - | getattr(os, "O_CLOEXEC", 0) + os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | getattr(os, "O_CLOEXEC", 0) ) @@ -69,7 +67,9 @@ def _mutation_parts(project: ProjectConfig, relative: str) -> tuple[str, ...]: try: candidate = Path(relative) except TypeError as exc: - raise ProjectError("path must be relative and remain inside the project") from exc + raise ProjectError( + "path must be relative and remain inside the project" + ) from exc if candidate.is_absolute() or not candidate.parts or ".." in candidate.parts: raise ProjectError("path must be relative and remain inside the project") if _is_excluded(candidate): @@ -91,7 +91,7 @@ def _open_pinned_directory( child = os.open(part, _DIRECTORY_OPEN_FLAGS, dir_fd=current) except FileNotFoundError: if not create: - raise ProjectError("path does not exist") + raise ProjectError("path does not exist") from None try: os.mkdir(part, 0o700, dir_fd=current) except FileExistsError: @@ -311,7 +311,9 @@ def _checkout_rows(self, project: ProjectConfig) -> list[dict[str, Any]]: "branch": branch, "upstream": upstream, "dirty_sha256": hashlib.sha256(status.encode()).hexdigest(), - "lifecycle": "configured-root" if path == configured_root else "linked-worktree", + "lifecycle": "configured-root" + if path == configured_root + else "linked-worktree", } ) rows.sort(key=lambda row: (row["checkout_id"] != "default", row["checkout_id"])) @@ -353,7 +355,9 @@ def code_checkout( if len(checkouts) == 1: return project choices = ", ".join(row["checkout_id"] for row in checkouts) - raise ProjectError(f"checkout_id is required; available checkouts: {choices}") + raise ProjectError( + f"checkout_id is required; available checkouts: {choices}" + ) if not isinstance(checkout_id, str) or not checkout_id: raise ProjectError("checkout_id must be a non-empty string") for checkout in self._checkout_rows(project): @@ -499,7 +503,9 @@ def _locked_mutation( "preconditioned mutation requires checkout_id" ) if set(preconditions) - {"head", "dirty_sha256"}: - raise ProjectError("project mutation preconditions are not recognized") + raise ProjectError( + "project mutation preconditions are not recognized" + ) checkout = self.checkout(project_id, checkout_id)["checkout"] for name, expected in preconditions.items(): if not isinstance(expected, str) or checkout.get(name) != expected: @@ -822,11 +828,15 @@ def _seed_index_entry( mode = 0o100755 if metadata.st_mode & 0o111 else 0o100644 else: raise ProjectError("git patch target has an unsupported file type") - object_id = self._owner_result( - ["git", "hash-object", "-w", "--stdin"], - root, - stdin_bytes=content, - ).decode().strip() + object_id = ( + self._owner_result( + ["git", "hash-object", "-w", "--stdin"], + root, + stdin_bytes=content, + ) + .decode() + .strip() + ) if not re.fullmatch(r"[0-9a-f]{40,64}", object_id): raise ProjectError("git returned a malformed patch seed object") self._owner_result( @@ -844,11 +854,15 @@ def _seed_index_entry( ) def _index_tree(self, root: Path, index: Path) -> str: - tree = self._owner_result( - ["git", "write-tree"], - root, - environment={"GIT_INDEX_FILE": str(index)}, - ).decode().strip() + tree = ( + self._owner_result( + ["git", "write-tree"], + root, + environment={"GIT_INDEX_FILE": str(index)}, + ) + .decode() + .strip() + ) if not re.fullmatch(r"[0-9a-f]{40,64}", tree): raise ProjectError("git returned a malformed temporary tree") return tree @@ -932,7 +946,9 @@ def apply_patch( except ProjectError as exc: if str(exc) != "path does not exist": raise - with tempfile.TemporaryDirectory(prefix="sinnix-gateway-apply-") as staging: + with tempfile.TemporaryDirectory( + prefix="sinnix-gateway-apply-" + ) as staging: index = Path(staging) / "index" environment = {"GIT_INDEX_FILE": str(index)} try: diff --git a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/prompts.py b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/prompts.py index cbd7685f..842f801d 100644 --- a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/prompts.py +++ b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/prompts.py @@ -20,11 +20,19 @@ class PromptSpec: PROMPT_SPECS = ( - PromptSpec("orient-project", "project.orientation", "Orient on one project before acting."), - PromptSpec("triage-beads", "project.triage", "Triage bounded Beads work for one project."), + PromptSpec( + "orient-project", "project.orientation", "Orient on one project before acting." + ), + PromptSpec( + "triage-beads", "project.triage", "Triage bounded Beads work for one project." + ), PromptSpec("work-bead", "bead.work", "Prepare to work one canonical Beads task."), - PromptSpec("review-job", "job.review", "Review one daemon-owned job and its evidence."), - PromptSpec("incident-orient", "incident", "Orient on current runtime incident evidence."), + PromptSpec( + "review-job", "job.review", "Review one daemon-owned job and its evidence." + ), + PromptSpec( + "incident-orient", "incident", "Orient on current runtime incident evidence." + ), ) PROMPT_KINDS = { @@ -40,7 +48,9 @@ class PromptSpec: class PromptGenerator: """Generate principal-visible guidance from canonical registry references.""" - def __init__(self, *, principal: str, catalog: Callable[[str], Mapping[str, Any]]) -> None: + def __init__( + self, *, principal: str, catalog: Callable[[str], Mapping[str, Any]] + ) -> None: self.principal = principal self.catalog = catalog self._specs = {spec.name: spec for spec in PROMPT_SPECS} @@ -51,8 +61,16 @@ def list(self) -> list[dict[str, Any]]: "name": spec.name, "description": spec.description, "arguments": [ - {"name": "ref", "description": "Canonical Sinnix target reference", "required": True}, - {"name": "job_ref", "description": "Canonical assigned job reference", "required": False}, + { + "name": "ref", + "description": "Canonical Sinnix target reference", + "required": True, + }, + { + "name": "job_ref", + "description": "Canonical assigned job reference", + "required": False, + }, ], } for spec in PROMPT_SPECS @@ -64,20 +82,27 @@ def _resolve_visible(self, reference: str) -> tuple[Any, dict[str, str]]: try: resource, values = REGISTRY.resolve(reference) except (RegistryError, ValueError) as exc: - raise ValueError("prompt ref is not a canonical registry reference") from exc + raise ValueError( + "prompt ref is not a canonical registry reference" + ) from exc if self.principal not in resource.principals: raise ValueError("prompt ref is not visible to this principal") if str(resource.ref_template.format(values)) != reference: raise ValueError("prompt ref is not canonical") return resource, values - def generate(self, name: str, arguments: Mapping[str, Any] | None = None) -> list[dict[str, Any]]: + def generate( + self, name: str, arguments: Mapping[str, Any] | None = None + ) -> list[dict[str, Any]]: try: spec = self._specs[name] except KeyError as exc: raise ValueError(f"unknown gateway prompt: {name}") from exc values = dict(arguments or {}) - if len(json.dumps(values, sort_keys=True, default=str).encode()) > MAX_PROMPT_INPUT_BYTES: + if ( + len(json.dumps(values, sort_keys=True, default=str).encode()) + > MAX_PROMPT_INPUT_BYTES + ): raise ValueError("prompt arguments exceed their input bound") unknown = set(values) - {"ref", "job_ref"} if unknown: @@ -87,7 +112,9 @@ def generate(self, name: str, arguments: Mapping[str, Any] | None = None) -> lis raise ValueError("prompt ref must be a canonical Sinnix reference") resource, _ = self._resolve_visible(ref) if resource.kind not in PROMPT_KINDS[spec.intent]: - raise ValueError(f"prompt {spec.name} does not accept resource kind {resource.kind!r}") + raise ValueError( + f"prompt {spec.name} does not accept resource kind {resource.kind!r}" + ) job_ref = values.get("job_ref") if job_ref is not None: if not isinstance(job_ref, str): diff --git a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/registry.py b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/registry.py index d613185a..c3da2a85 100644 --- a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/registry.py +++ b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/registry.py @@ -1,14 +1,16 @@ from __future__ import annotations -import hashlib -import json import base64 import binascii +import hashlib import hmac +import json from collections.abc import Iterable from dataclasses import dataclass, replace from typing import Any, Callable, Mapping +from sinnix_mcp.refs import RefTemplate, SinnixRef + from .contracts import ( BASE_TYPED_FAILURES, ActionSpec, @@ -16,9 +18,8 @@ ResourceSpec, VerbFamily, ) -from .schemas import V2ToolEnvelope from .results import derive_cursor_key -from sinnix_mcp.refs import RefTemplate, SinnixRef +from .schemas import V2ToolEnvelope class RegistryError(ValueError): @@ -67,7 +68,9 @@ def __init__( ) -> None: self.resources = tuple(resources) self.actions = tuple(actions) - self._resources_by_kind = {resource.kind: resource for resource in self.resources} + self._resources_by_kind = { + resource.kind: resource for resource in self.resources + } self._actions_by_name = {action.name: action for action in self.actions} self._validate() @@ -114,10 +117,14 @@ def action_schema(self, name: str, principal: str | None = None) -> dict[str, An "action": action.catalog_row(), } - def resource_contract(self, kind: str, principal: str | None = None) -> dict[str, Any]: + def resource_contract( + self, kind: str, principal: str | None = None + ) -> dict[str, Any]: resource = self.resource(kind) if principal is not None and principal not in resource.principals: - raise RegistryError(f"principal {principal!r} cannot read resource {kind!r}") + raise RegistryError( + f"principal {principal!r} cannot read resource {kind!r}" + ) return { "revision": self.revision, "resource": resource.catalog_row(), @@ -131,12 +138,21 @@ def documentation_rows(self, principal: str | None = None) -> dict[str, Any]: } def template_page( - self, *, principal: str, cursor_key: bytes, limit: int = 100, cursor: str | None = None + self, + *, + principal: str, + cursor_key: bytes, + limit: int = 100, + cursor: str | None = None, ) -> dict[str, Any]: """Return principal-filtered resource templates with an opaque cursor.""" if principal not in {"observer", "agent-control", "operator"}: raise RegistryError("unknown template principal") - if not isinstance(limit, int) or isinstance(limit, bool) or not 1 <= limit <= 500: + if ( + not isinstance(limit, int) + or isinstance(limit, bool) + or not 1 <= limit <= 500 + ): raise RegistryError("template page limit must be 1-500") rows = [ resource.catalog_row() @@ -153,18 +169,54 @@ def template_page( expected = hmac.new(key, encoded.encode(), hashlib.sha256).hexdigest() if not hmac.compare_digest(mac, expected): raise ValueError - payload = json.loads(base64.urlsafe_b64decode(encoded + "=" * (-len(encoded) % 4)).decode()) - if payload.get("revision") != self.revision or payload.get("principal") != principal: + payload = json.loads( + base64.urlsafe_b64decode( + encoded + "=" * (-len(encoded) % 4) + ).decode() + ) + if ( + payload.get("revision") != self.revision + or payload.get("principal") != principal + ): raise ValueError offset = int(payload["offset"]) - except (ValueError, KeyError, TypeError, json.JSONDecodeError, binascii.Error) as exc: - raise RegistryError("template cursor is stale or belongs to another principal") from exc - page = rows[offset:offset + limit] + except ( + ValueError, + KeyError, + TypeError, + json.JSONDecodeError, + binascii.Error, + ) as exc: + raise RegistryError( + "template cursor is stale or belongs to another principal" + ) from exc + page = rows[offset : offset + limit] next_cursor = None if offset + limit < len(rows): - encoded = base64.urlsafe_b64encode(_canonical_json({"revision": self.revision, "principal": principal, "offset": offset + limit}).encode()).decode().rstrip("=") - next_cursor = encoded + "." + hmac.new(key, encoded.encode(), hashlib.sha256).hexdigest() - return {"templates": page, "limit": limit, "next_cursor": next_cursor, "total": len(rows)} + encoded = ( + base64.urlsafe_b64encode( + _canonical_json( + { + "revision": self.revision, + "principal": principal, + "offset": offset + limit, + } + ).encode() + ) + .decode() + .rstrip("=") + ) + next_cursor = ( + encoded + + "." + + hmac.new(key, encoded.encode(), hashlib.sha256).hexdigest() + ) + return { + "templates": page, + "limit": limit, + "next_cursor": next_cursor, + "total": len(rows), + } def _resource_rows( self, @@ -182,7 +234,10 @@ def _resource_rows( continue if resource_kind is not None and resource.kind != resource_kind: continue - if project is not None and "project_id" not in resource.ref_template.variables: + if ( + project is not None + and "project_id" not in resource.ref_template.variables + ): continue row = resource.catalog_row() searchable = " ".join( @@ -221,7 +276,9 @@ def action_catalog_hash(self, principal: str | None = None) -> str: } return hashlib.sha256(_canonical_json(payload).encode()).hexdigest() - def resolve(self, reference: str | SinnixRef) -> tuple[ResourceSpec, dict[str, str]]: + def resolve( + self, reference: str | SinnixRef + ) -> tuple[ResourceSpec, dict[str, str]]: parsed = SinnixRef.parse(reference) if isinstance(reference, str) else reference matches = [ (resource, values) @@ -236,10 +293,11 @@ def resolve(self, reference: str | SinnixRef) -> tuple[ResourceSpec, dict[str, s def search( self, - search: CatalogSearch = CatalogSearch(), + search: CatalogSearch | None = None, *, availability_resolver: CatalogAvailabilityResolver | None = None, ) -> dict[str, Any]: + search = CatalogSearch() if search is None else search text = search.text.casefold() if search.text else None actions = [] for action in self.actions: @@ -269,7 +327,10 @@ def search( continue if search.effect and action.effect is not search.effect: continue - if search.resource_kind and search.resource_kind not in action.resource_kinds: + if ( + search.resource_kind + and search.resource_kind not in action.resource_kinds + ): continue if search.project and not any( "project_id" in self.resource(kind).ref_template.variables @@ -354,7 +415,22 @@ def _with_request_controls(schema: Mapping[str, Any]) -> dict[str, Any]: "maximum": 262_144, "default": 64_000, }, - "includes": {"type": "array", "maxItems": 8, "items": {"enum": ["blockers", "comments", "history", "events", "dependencies", "dependents", "children", "refs"]}}, + "includes": { + "type": "array", + "maxItems": 8, + "items": { + "enum": [ + "blockers", + "comments", + "history", + "events", + "dependencies", + "dependents", + "children", + "refs", + ] + }, + }, "as_of": {"type": "string", "minLength": 1, "maxLength": 128}, }, } @@ -380,13 +456,23 @@ def _with_request_controls(schema: Mapping[str, Any]) -> dict[str, Any]: }, "target": { "enum": [ - "job_terminal", "bead_status", "bead_revision", "unit_state", - "file_hash", "capture_freshness", "receipt_appearance", + "job_terminal", + "bead_status", + "bead_revision", + "unit_state", + "file_hash", + "capture_freshness", + "receipt_appearance", ], "default": "job_terminal", }, "expected": {"type": "object", "maxProperties": 8}, - "poll_seconds": {"type": "number", "minimum": 0.01, "maximum": 5, "default": 0.25}, + "poll_seconds": { + "type": "number", + "minimum": 0.01, + "maximum": 5, + "default": 0.25, + }, }, } ) @@ -475,10 +561,20 @@ def _with_request_controls(schema: Mapping[str, Any]) -> dict[str, Any]: "request_id", ], "properties": { - "ref": {"type": "string", "minLength": 1, "maxLength": 2_048, "pattern": "^sinnix://projects/[^/]+/beads/[^/]+$"}, + "ref": { + "type": "string", + "minLength": 1, + "maxLength": 2_048, + "pattern": "^sinnix://projects/[^/]+/beads/[^/]+$", + }, "checkout_id": {"type": "string", "minLength": 1, "maxLength": 128}, "claim_mode": {"enum": ["none", "claim"], "default": "none"}, - "assignment_ref": {"type": "string", "minLength": 1, "maxLength": 2_048, "pattern": "^sinnix://jobs/[^/]+$"}, + "assignment_ref": { + "type": "string", + "minLength": 1, + "maxLength": 2_048, + "pattern": "^sinnix://jobs/[^/]+$", + }, "instructions": {"type": "string", "maxLength": 32_000}, "backend": {"enum": ["claude", "codex", "gemini", "grok", "antigravity"]}, "model": {"type": "string", "minLength": 1, "maxLength": 256}, @@ -576,8 +672,24 @@ def _with_request_controls(schema: Mapping[str, Any]) -> dict[str, Any]: "maxLength": 2_048, "pattern": "^sinnix://(?:projects/[^/]+(?:/beads/[^/]+)?|jobs/[^/]+)$", }, - "intent": {"enum": ["project", "project.orientation", "project.triage", "bead.work", "bead.review", "job.review", "incident"], "default": "project.orientation"}, - "job_ref": {"type": "string", "minLength": 1, "maxLength": 2_048, "pattern": "^sinnix://jobs/[^/]+$"}, + "intent": { + "enum": [ + "project", + "project.orientation", + "project.triage", + "bead.work", + "bead.review", + "job.review", + "incident", + ], + "default": "project.orientation", + }, + "job_ref": { + "type": "string", + "minLength": 1, + "maxLength": 2_048, + "pattern": "^sinnix://jobs/[^/]+$", + }, }, } ) @@ -694,38 +806,179 @@ def _owner_change_schema( ) BEADS_QUERY_SCHEMA: dict[str, Any] = _with_request_controls( - {"type": "object", "additionalProperties": False, "required": ["action_name", "parameters"], "properties": { - "action_name": {"const": "beads.query"}, - "parameters": {"type": "object", "additionalProperties": False, "properties": { - "project_ids": {"type": "array", "minItems": 1, "maxItems": 32, "items": {"type": "string", "minLength": 1, "maxLength": 128}}, - "view": {"enum": ["query", "ready", "blocked", "open", "all", "recent", "overdue", "deferred", "unassigned", "stale_claims", "epic_progress", "changed_since"]}, - "filters": {"type": "object", "maxProperties": 32}, "expression": {"type": "string", "minLength": 1, "maxLength": 4000}, "native_filters": {"type": "object", "maxProperties": 40}, - "order": {"type": "object", "additionalProperties": False, "properties": {"field": {"enum": ["priority", "created", "updated", "closed", "status", "id", "title", "type", "assignee"]}, "reverse": {"type": "boolean"}}}, - "includes": {"type": "array", "maxItems": 8, "items": {"enum": ["comments", "history", "events", "dependencies", "dependents", "children", "refs"]}}, - "limit": {"type": "integer", "minimum": 1, "maximum": 200}, "cursor": {"type": "string", "minLength": 1, "maxLength": 256}, - "graph": {"type": "object", "additionalProperties": False, "properties": {"bead_id": {"type": "string"}, "direction": {"enum": ["down", "up", "both"]}, "edge_type": {"type": "string"}, "status": {"type": "string"}, "depth": {"type": "integer", "minimum": 1, "maximum": 20}, "max_rows": {"type": "integer", "minimum": 1, "maximum": 1000}, "mermaid": {"type": "boolean"}}}, - "memory": {"type": "object", "additionalProperties": False, "properties": {"key": {"type": "string"}, "query": {"type": "string"}}}, - }}, - }} + { + "type": "object", + "additionalProperties": False, + "required": ["action_name", "parameters"], + "properties": { + "action_name": {"const": "beads.query"}, + "parameters": { + "type": "object", + "additionalProperties": False, + "properties": { + "project_ids": { + "type": "array", + "minItems": 1, + "maxItems": 32, + "items": {"type": "string", "minLength": 1, "maxLength": 128}, + }, + "view": { + "enum": [ + "query", + "ready", + "blocked", + "open", + "all", + "recent", + "overdue", + "deferred", + "unassigned", + "stale_claims", + "epic_progress", + "changed_since", + ] + }, + "filters": {"type": "object", "maxProperties": 32}, + "expression": {"type": "string", "minLength": 1, "maxLength": 4000}, + "native_filters": {"type": "object", "maxProperties": 40}, + "order": { + "type": "object", + "additionalProperties": False, + "properties": { + "field": { + "enum": [ + "priority", + "created", + "updated", + "closed", + "status", + "id", + "title", + "type", + "assignee", + ] + }, + "reverse": {"type": "boolean"}, + }, + }, + "includes": { + "type": "array", + "maxItems": 8, + "items": { + "enum": [ + "comments", + "history", + "events", + "dependencies", + "dependents", + "children", + "refs", + ] + }, + }, + "limit": {"type": "integer", "minimum": 1, "maximum": 200}, + "cursor": {"type": "string", "minLength": 1, "maxLength": 256}, + "graph": { + "type": "object", + "additionalProperties": False, + "properties": { + "bead_id": {"type": "string"}, + "direction": {"enum": ["down", "up", "both"]}, + "edge_type": {"type": "string"}, + "status": {"type": "string"}, + "depth": {"type": "integer", "minimum": 1, "maximum": 20}, + "max_rows": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + }, + "mermaid": {"type": "boolean"}, + }, + }, + "memory": { + "type": "object", + "additionalProperties": False, + "properties": { + "key": {"type": "string"}, + "query": {"type": "string"}, + }, + }, + }, + }, + }, + } ) BEADS_CHANGE_SCHEMA["properties"]["parameters"] = { - "type": "object", "additionalProperties": False, + "type": "object", + "additionalProperties": False, "properties": { - "id": {"type": "string", "minLength": 1, "maxLength": 128}, "mode": {"enum": ["preview", "apply"]}, - "preview_digest": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, "title": {"type": "string", "maxLength": 512}, - "text": {"type": "string", "maxLength": 32000}, "depends_on": {"type": "string", "maxLength": 128}, - "other_id": {"type": "string", "maxLength": 128}, "parent_id": {"type": "string", "maxLength": 128}, - "type": {"type": "string", "maxLength": 64}, "reason": {"type": "string", "maxLength": 32000}, "key": {"type": "string", "maxLength": 256}, "graph": {"type": "object", "maxProperties": 256}, + "id": {"type": "string", "minLength": 1, "maxLength": 128}, + "mode": {"enum": ["preview", "apply"]}, + "preview_digest": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "title": {"type": "string", "maxLength": 512}, + "text": {"type": "string", "maxLength": 32000}, + "depends_on": {"type": "string", "maxLength": 128}, + "other_id": {"type": "string", "maxLength": 128}, + "parent_id": {"type": "string", "maxLength": 128}, + "type": {"type": "string", "maxLength": 64}, + "reason": {"type": "string", "maxLength": 32000}, + "key": {"type": "string", "maxLength": 256}, + "graph": {"type": "object", "maxProperties": 256}, "force": {"type": "boolean", "const": True}, - "verdict": {"enum": ["accepted", "rejected", "partial"]}, "residuals": {"type": "array", "maxItems": 32, "items": {"type": "string", "maxLength": 2_000}}, "evidence_refs": {"type": "array", "minItems": 1, "maxItems": 32, "items": {"type": "string", "pattern": "^sinnix://"}}, "job_ref": {"type": "string", "pattern": "^sinnix://jobs/[^/]+$"}, "code_revision": {"type": "string", "pattern": "^[0-9a-f]{40,64}$"}, "task_revision": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, "task_etag": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, - "patch": {"type": "object", "additionalProperties": False, "properties": { - "set": {"type": "object"}, - "labels": {"type": "object", "additionalProperties": False, "properties": {"add": {"type": "array", "items": {"type": "string"}}, "remove": {"type": "array", "items": {"type": "string"}}, "replace": {"type": "array", "items": {"type": "string"}}}}, - "metadata": {"type": "object", "additionalProperties": False, "properties": {"set": {"type": "object"}, "unset": {"type": "array", "items": {"type": "string"}}}}, - "notes": {"type": "object", "additionalProperties": False, "required": ["text"], "properties": {"text": {"type": "string", "maxLength": 32000}, "mode": {"enum": ["append", "replace"]}}}, - "unset": {"type": "array", "items": {"enum": ["due", "defer", "parent"]}}, - }}, + "verdict": {"enum": ["accepted", "rejected", "partial"]}, + "residuals": { + "type": "array", + "maxItems": 32, + "items": {"type": "string", "maxLength": 2_000}, + }, + "evidence_refs": { + "type": "array", + "minItems": 1, + "maxItems": 32, + "items": {"type": "string", "pattern": "^sinnix://"}, + }, + "job_ref": {"type": "string", "pattern": "^sinnix://jobs/[^/]+$"}, + "code_revision": {"type": "string", "pattern": "^[0-9a-f]{40,64}$"}, + "task_revision": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "task_etag": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "patch": { + "type": "object", + "additionalProperties": False, + "properties": { + "set": {"type": "object"}, + "labels": { + "type": "object", + "additionalProperties": False, + "properties": { + "add": {"type": "array", "items": {"type": "string"}}, + "remove": {"type": "array", "items": {"type": "string"}}, + "replace": {"type": "array", "items": {"type": "string"}}, + }, + }, + "metadata": { + "type": "object", + "additionalProperties": False, + "properties": { + "set": {"type": "object"}, + "unset": {"type": "array", "items": {"type": "string"}}, + }, + }, + "notes": { + "type": "object", + "additionalProperties": False, + "required": ["text"], + "properties": { + "text": {"type": "string", "maxLength": 32000}, + "mode": {"enum": ["append", "replace"]}, + }, + }, + "unset": { + "type": "array", + "items": {"enum": ["due", "defer", "parent"]}, + }, + }, + }, }, } @@ -753,7 +1006,11 @@ def _owner_change_schema( "maxLength": 8_192, "pattern": r"^sinnix://projects/[^/]+(?:/beads/[^/]+)?$", }, - "operation": {"enum": list(BEADS_CHANGE_SCHEMA["properties"]["operation"]["enum"])}, + "operation": { + "enum": list( + BEADS_CHANGE_SCHEMA["properties"]["operation"]["enum"] + ) + }, "parameters": {"type": "object", "maxProperties": 32}, "preconditions": BEADS_CHANGE_SCHEMA["properties"]["preconditions"], "bind": { @@ -772,7 +1029,14 @@ def _owner_change_schema( BEADS_OPERATE_SCHEMA = _owner_change_schema( ref_pattern=r"^sinnix://projects/[^/]+$", - operations=("backup.create", "backup.list", "backup.restore", "snapshot.publish", "sync.pull", "sync.push"), + operations=( + "backup.create", + "backup.list", + "backup.restore", + "snapshot.publish", + "sync.pull", + "sync.push", + ), ) BEADS_OPERATE_SCHEMA["properties"]["parameters"] = { "type": "object", @@ -789,7 +1053,14 @@ def _owner_change_schema( DESKTOP_OPERATE_SCHEMA = _owner_change_schema( ref_pattern=r"^sinnix://desktop/current$", - operations=("dispatch", "focus_window", "keyword", "paste", "send_keystate", "send_shortcut"), + operations=( + "dispatch", + "focus_window", + "keyword", + "paste", + "send_keystate", + "send_shortcut", + ), ) TERMINAL_OPERATE_SCHEMA = _owner_change_schema( @@ -799,7 +1070,18 @@ def _owner_change_schema( BROWSER_OPERATE_SCHEMA = _owner_change_schema( ref_pattern=r"^sinnix://browser/(?:agent-workspace|pages/[^/]+)$", - operations=("agent_window", "await", "click", "close", "evaluate", "fill_form", "inject_text", "navigate", "reload", "wait_selector"), + operations=( + "agent_window", + "await", + "click", + "close", + "evaluate", + "fill_form", + "inject_text", + "navigate", + "reload", + "wait_selector", + ), ) MACHINE_OPERATE_SCHEMA: dict[str, Any] = _with_request_controls( @@ -821,9 +1103,7 @@ def _owner_change_schema( "maxLength": 2_048, "pattern": "^sinnix://(?:jobs|machine|processes)/", }, - "action": { - "enum": list(MACHINE_OPERATIONS) - }, + "action": {"enum": list(MACHINE_OPERATIONS)}, "parameters": {"type": "object"}, "preconditions": { "type": "object", @@ -850,7 +1130,8 @@ def _owner_change_schema( }, "cursor": {"type": "string", "minLength": 1, "maxLength": 4_096}, "project_ids": { - "type": "array", "maxItems": 16, + "type": "array", + "maxItems": 16, "items": {"type": "string", "minLength": 1, "maxLength": 128}, }, }, @@ -901,23 +1182,159 @@ def _owner_query_actions() -> tuple[ActionSpec, ...]: all_principals = frozenset({"observer", "agent-control", "operator"}) observer_operator = frozenset({"observer", "operator"}) return ( - _owner_query_action("projects.list", "projects", "projects", "projects.list", all_principals, ("project",), "List principal-visible projects without host paths."), - _owner_query_action("projects.tree", "projects", "projects", "projects.tree", all_principals, ("project", "checkout"), "List a bounded canonical project tree without following symlinks."), - _owner_query_action("projects.read", "projects", "projects", "projects.read", all_principals, ("project", "checkout"), "Read a bounded project file through a canonical project or checkout ref."), - _owner_query_action("projects.diff", "projects", "projects", "projects.diff", all_principals, ("project", "checkout"), "Read a bounded Git diff through a canonical project or checkout ref."), - _owner_query_action("machine.query", "machine", "machine", "observe.machine_query", all_principals, ("machine_unit", "process"), "Read one bounded, provenance-carrying machine section; operation=actions returns the authoritative revision required by machine.operate."), - _owner_query_action("capabilities.query", "capabilities", "capability-index", "capability_index.query", all_principals, ("capability",), "Search or exactly describe generated machine capabilities."), - _owner_query_action("mcp.query", "mcp", "mcp-broker", "mcp.call.read", observer_operator, ("mcp_tool",), "Discover brokered MCP servers or invoke a declared read-only upstream tool."), - _owner_query_action("desktop.query", "desktop", "desktop", "desktop.read", observer_operator, ("desktop",), "Read desktop state or capture output without changing focus."), - _owner_query_action("terminals.query", "terminals", "terminals", "terminals.read", observer_operator, ("terminal",), "List terminals or read bounded terminal evidence."), - _owner_query_action("browser.query", "browser", "browser", "browser.read", observer_operator, ("browser_page",), "Read browser state or capture only a registered gateway-owned browser target."), - _owner_query_action("files.query", "files", "files", "files.read", observer_operator, ("host_file",), "Stat, list, or read a bounded principal-authorized host path."), - _owner_query_action("sessions.query", "sessions", "sessions", "sessions.query", observer_operator, ("session",), "List, read, or search bounded provider-scoped coding sessions."), - _owner_query_action("memory.query", "memory", "memory", "memory.query", observer_operator, ("session",), "Search or retrieve semantic memory while retaining source provenance."), - _owner_query_action("timeline.query", "timeline", "timeline", "timeline.query", observer_operator, ("session",), "Query available session evidence without claiming unavailable upstream coverage."), - _owner_query_action("artifacts.query", "artifacts", "artifacts", "artifacts.query", all_principals, ("artifact",), "List opaque artifact metadata or read a bounded artifact range."), - _owner_query_action("audit.verify", "audit", "audit", "audit.verify", all_principals, ("receipt",), "Verify the tamper-evident audit hash chain."), - _owner_query_action("captures.query", "captures", "captures", "captures.query", all_principals, ("capture_lane",), "List visible capture lanes or query their declared native owner roots."), + _owner_query_action( + "projects.list", + "projects", + "projects", + "projects.list", + all_principals, + ("project",), + "List principal-visible projects without host paths.", + ), + _owner_query_action( + "projects.tree", + "projects", + "projects", + "projects.tree", + all_principals, + ("project", "checkout"), + "List a bounded canonical project tree without following symlinks.", + ), + _owner_query_action( + "projects.read", + "projects", + "projects", + "projects.read", + all_principals, + ("project", "checkout"), + "Read a bounded project file through a canonical project or checkout ref.", + ), + _owner_query_action( + "projects.diff", + "projects", + "projects", + "projects.diff", + all_principals, + ("project", "checkout"), + "Read a bounded Git diff through a canonical project or checkout ref.", + ), + _owner_query_action( + "machine.query", + "machine", + "machine", + "observe.machine_query", + all_principals, + ("machine_unit", "process"), + "Read one bounded, provenance-carrying machine section; operation=actions returns the authoritative revision required by machine.operate.", + ), + _owner_query_action( + "capabilities.query", + "capabilities", + "capability-index", + "capability_index.query", + all_principals, + ("capability",), + "Search or exactly describe generated machine capabilities.", + ), + _owner_query_action( + "mcp.query", + "mcp", + "mcp-broker", + "mcp.call.read", + observer_operator, + ("mcp_tool",), + "Discover brokered MCP servers or invoke a declared read-only upstream tool.", + ), + _owner_query_action( + "desktop.query", + "desktop", + "desktop", + "desktop.read", + observer_operator, + ("desktop",), + "Read desktop state or capture output without changing focus.", + ), + _owner_query_action( + "terminals.query", + "terminals", + "terminals", + "terminals.read", + observer_operator, + ("terminal",), + "List terminals or read bounded terminal evidence.", + ), + _owner_query_action( + "browser.query", + "browser", + "browser", + "browser.read", + observer_operator, + ("browser_page",), + "Read browser state or capture only a registered gateway-owned browser target.", + ), + _owner_query_action( + "files.query", + "files", + "files", + "files.read", + observer_operator, + ("host_file",), + "Stat, list, or read a bounded principal-authorized host path.", + ), + _owner_query_action( + "sessions.query", + "sessions", + "sessions", + "sessions.query", + observer_operator, + ("session",), + "List, read, or search bounded provider-scoped coding sessions.", + ), + _owner_query_action( + "memory.query", + "memory", + "memory", + "memory.query", + observer_operator, + ("session",), + "Search or retrieve semantic memory while retaining source provenance.", + ), + _owner_query_action( + "timeline.query", + "timeline", + "timeline", + "timeline.query", + observer_operator, + ("session",), + "Query available session evidence without claiming unavailable upstream coverage.", + ), + _owner_query_action( + "artifacts.query", + "artifacts", + "artifacts", + "artifacts.query", + all_principals, + ("artifact",), + "List opaque artifact metadata or read a bounded artifact range.", + ), + _owner_query_action( + "audit.verify", + "audit", + "audit", + "audit.verify", + all_principals, + ("receipt",), + "Verify the tamper-evident audit hash chain.", + ), + _owner_query_action( + "captures.query", + "captures", + "captures", + "captures.query", + all_principals, + ("capture_lane",), + "List visible capture lanes or query their declared native owner roots.", + ), ActionSpec( name="jobs.query", verb=VerbFamily.QUERY, @@ -938,26 +1355,155 @@ def _owner_query_actions() -> tuple[ActionSpec, ...]: def build_registry() -> CatalogRegistry: resources = ( - ResourceSpec("project", RefTemplate("project", "sinnix://projects/{project_id}"), "projects", ("summary", "git", "tree"), True), - ResourceSpec("checkout", RefTemplate("checkout", "sinnix://projects/{project_id}/checkouts/{checkout_id}"), "projects", ("summary", "git", "files"), True), - ResourceSpec("bead", RefTemplate("bead", "sinnix://projects/{project_id}/beads/{bead_id}"), "beads", ("summary", "history", "graph"), True, principals=frozenset({"observer", "operator"})), - ResourceSpec("task_authority", RefTemplate("task_authority", "sinnix://projects/{project_id}/task-authority"), "beads", ("status",), False), - ResourceSpec("job", RefTemplate("job", "sinnix://jobs/{job_id}"), "jobs", ("summary", "output", "manifest"), True), - ResourceSpec("artifact", RefTemplate("artifact", "sinnix://artifacts/{artifact_id}"), "artifacts", ("metadata", "content"), True), - ResourceSpec("receipt", RefTemplate("receipt", "sinnix://receipts/{receipt_id}"), "audit", ("summary",), True), - ResourceSpec("result", RefTemplate("result", "sinnix://results/{result_id}"), "results", ("metadata", "page"), True), - ResourceSpec("machine_unit", RefTemplate("machine_unit", "sinnix://machine/units/{manager}/{unit}"), "machine", ("status", "health"), True), - ResourceSpec("browser_page", RefTemplate("browser_page", "sinnix://browser/pages/{page_id}"), "browser", ("summary", "content"), True), - ResourceSpec("browser_workspace", RefTemplate("browser_workspace", "sinnix://browser/agent-workspace"), "browser", ("summary",), False, principals=frozenset({"operator"})), - ResourceSpec("process", RefTemplate("process", "sinnix://processes/{pid}/{start_ticks}"), "machine", ("status",), True), - ResourceSpec("terminal", RefTemplate("terminal", "sinnix://terminals/{terminal_id}"), "terminals", ("summary", "scrollback"), True), - ResourceSpec("desktop", RefTemplate("desktop", "sinnix://desktop/current"), "desktop", ("summary",), True, principals=frozenset({"observer", "operator"})), - ResourceSpec("host_file", RefTemplate("host_file", "sinnix://files/{file_token}"), "files", ("summary",), True, principals=frozenset({"observer", "operator"})), - ResourceSpec("mcp_tool", RefTemplate("mcp_tool", "sinnix://mcp/{server}/tools/{tool}"), "mcp-broker", ("summary",), True, principals=frozenset({"observer", "operator"})), - ResourceSpec("capture_lane", RefTemplate("capture_lane", "sinnix://captures/{lane}"), "captures", ("summary", "query"), True), - ResourceSpec("capability", RefTemplate("capability", "sinnix://capabilities/{name}"), "capability-index", ("summary",), True), - ResourceSpec("session", RefTemplate("session", "sinnix://sessions/{provider}/{session_id}"), "sessions", ("summary", "messages"), True), - ResourceSpec("context_snapshot", RefTemplate("context_snapshot", "sinnix://contexts/{snapshot_id}"), "context", ("summary", "sources"), True), + ResourceSpec( + "project", + RefTemplate("project", "sinnix://projects/{project_id}"), + "projects", + ("summary", "git", "tree"), + True, + ), + ResourceSpec( + "checkout", + RefTemplate( + "checkout", "sinnix://projects/{project_id}/checkouts/{checkout_id}" + ), + "projects", + ("summary", "git", "files"), + True, + ), + ResourceSpec( + "bead", + RefTemplate("bead", "sinnix://projects/{project_id}/beads/{bead_id}"), + "beads", + ("summary", "history", "graph"), + True, + principals=frozenset({"observer", "operator"}), + ), + ResourceSpec( + "task_authority", + RefTemplate( + "task_authority", "sinnix://projects/{project_id}/task-authority" + ), + "beads", + ("status",), + False, + ), + ResourceSpec( + "job", + RefTemplate("job", "sinnix://jobs/{job_id}"), + "jobs", + ("summary", "output", "manifest"), + True, + ), + ResourceSpec( + "artifact", + RefTemplate("artifact", "sinnix://artifacts/{artifact_id}"), + "artifacts", + ("metadata", "content"), + True, + ), + ResourceSpec( + "receipt", + RefTemplate("receipt", "sinnix://receipts/{receipt_id}"), + "audit", + ("summary",), + True, + ), + ResourceSpec( + "result", + RefTemplate("result", "sinnix://results/{result_id}"), + "results", + ("metadata", "page"), + True, + ), + ResourceSpec( + "machine_unit", + RefTemplate("machine_unit", "sinnix://machine/units/{manager}/{unit}"), + "machine", + ("status", "health"), + True, + ), + ResourceSpec( + "browser_page", + RefTemplate("browser_page", "sinnix://browser/pages/{page_id}"), + "browser", + ("summary", "content"), + True, + ), + ResourceSpec( + "browser_workspace", + RefTemplate("browser_workspace", "sinnix://browser/agent-workspace"), + "browser", + ("summary",), + False, + principals=frozenset({"operator"}), + ), + ResourceSpec( + "process", + RefTemplate("process", "sinnix://processes/{pid}/{start_ticks}"), + "machine", + ("status",), + True, + ), + ResourceSpec( + "terminal", + RefTemplate("terminal", "sinnix://terminals/{terminal_id}"), + "terminals", + ("summary", "scrollback"), + True, + ), + ResourceSpec( + "desktop", + RefTemplate("desktop", "sinnix://desktop/current"), + "desktop", + ("summary",), + True, + principals=frozenset({"observer", "operator"}), + ), + ResourceSpec( + "host_file", + RefTemplate("host_file", "sinnix://files/{file_token}"), + "files", + ("summary",), + True, + principals=frozenset({"observer", "operator"}), + ), + ResourceSpec( + "mcp_tool", + RefTemplate("mcp_tool", "sinnix://mcp/{server}/tools/{tool}"), + "mcp-broker", + ("summary",), + True, + principals=frozenset({"observer", "operator"}), + ), + ResourceSpec( + "capture_lane", + RefTemplate("capture_lane", "sinnix://captures/{lane}"), + "captures", + ("summary", "query"), + True, + ), + ResourceSpec( + "capability", + RefTemplate("capability", "sinnix://capabilities/{name}"), + "capability-index", + ("summary",), + True, + ), + ResourceSpec( + "session", + RefTemplate("session", "sinnix://sessions/{provider}/{session_id}"), + "sessions", + ("summary", "messages"), + True, + ), + ResourceSpec( + "context_snapshot", + RefTemplate("context_snapshot", "sinnix://contexts/{snapshot_id}"), + "context", + ("summary", "sources"), + True, + ), ) actions = ( ActionSpec( @@ -1005,10 +1551,26 @@ def build_registry() -> CatalogRegistry: input_schema=RESOURCE_GET_SCHEMA, output_schema=V2_ENVELOPE_SCHEMA, resource_kinds=( - "project", "checkout", "bead", "task_authority", "job", - "artifact", "receipt", "result", "machine_unit", "browser_page", - "browser_workspace", "process", "terminal", "desktop", "host_file", - "mcp_tool", "capture_lane", "capability", "session", "context_snapshot", + "project", + "checkout", + "bead", + "task_authority", + "job", + "artifact", + "receipt", + "result", + "machine_unit", + "browser_page", + "browser_workspace", + "process", + "terminal", + "desktop", + "host_file", + "mcp_tool", + "capture_lane", + "capability", + "session", + "context_snapshot", ), examples=({"input": {"ref": "sinnix://projects/sinnix"}},), documentation="Resolve one canonical owner-backed resource through its registered source of truth.", @@ -1046,7 +1608,23 @@ def build_registry() -> CatalogRegistry: input_schema=BEADS_QUERY_SCHEMA, output_schema=V2_ENVELOPE_SCHEMA, resource_kinds=("project", "bead", "task_authority"), - examples=({"input": {"action_name": "beads.query", "parameters": {"project_ids": ["polylogue"], "view": "query", "filters": {"status": "open", "priority": {"op": "<=", "value": 1}}, "includes": ["dependencies"], "limit": 50}}},), + examples=( + { + "input": { + "action_name": "beads.query", + "parameters": { + "project_ids": ["polylogue"], + "view": "query", + "filters": { + "status": "open", + "priority": {"op": "<=", "value": 1}, + }, + "includes": ["dependencies"], + "limit": 50, + }, + } + }, + ), documentation="Query canonical project-qualified Beads resources with bounded snapshot paging and explicit coverage.", ), ActionSpec( @@ -1060,9 +1638,7 @@ def build_registry() -> CatalogRegistry: input_schema=PROJECT_CONTEXT_SCHEMA, output_schema=V2_ENVELOPE_SCHEMA, resource_kinds=("project", "checkout", "bead", "task_authority"), - examples=( - {"input": {"ref": "sinnix://projects/sinnix"}}, - ), + examples=({"input": {"ref": "sinnix://projects/sinnix"}},), documentation="Compose Git and bounded task orientation for one canonical project.", ), ActionSpec( @@ -1143,7 +1719,16 @@ def build_registry() -> CatalogRegistry: supports_idempotency=True, supports_precondition=True, receipt_policy="audit", - examples=({"input": {"ref": "sinnix://files/L3JlYWxtL3RtcC9maWxl", "operation": "replace", "parameters": {"content": "updated content\\n"}, "idempotency_key": "file-replace-example"}},), + examples=( + { + "input": { + "ref": "sinnix://files/L3JlYWxtL3RtcC9maWxl", + "operation": "replace", + "parameters": {"content": "updated content\\n"}, + "idempotency_key": "file-replace-example", + } + }, + ), documentation="Apply one bounded host-file mutation through an opaque canonical file reference.", ), ActionSpec( @@ -1160,7 +1745,19 @@ def build_registry() -> CatalogRegistry: supports_idempotency=True, supports_precondition=True, receipt_policy="audit", - examples=({"input": {"ref": "sinnix://projects/sinnix", "operation": "comment", "parameters": {"id": "sinnix-example", "text": "recorded by the operator"}, "idempotency_key": "bead-comment-example"}},), + examples=( + { + "input": { + "ref": "sinnix://projects/sinnix", + "operation": "comment", + "parameters": { + "id": "sinnix-example", + "text": "recorded by the operator", + }, + "idempotency_key": "bead-comment-example", + } + }, + ), documentation="Perform one structured, attested Beads mutation for a canonical project.", ), ActionSpec( @@ -1177,7 +1774,33 @@ def build_registry() -> CatalogRegistry: supports_idempotency=True, supports_precondition=True, receipt_policy="audit", - examples=({"input": {"ref": "sinnix://projects/sinnix", "operation": "preview", "parameters": {"actions": [{"ref": "sinnix://projects/sinnix", "operation": "create", "parameters": {"title": "parent"}, "bind": "parent"}, {"ref": "sinnix://projects/sinnix", "operation": "create", "parameters": {"title": "child", "parent": "$parent"}}]}, "idempotency_key": "beads-changeset-example"}},), + examples=( + { + "input": { + "ref": "sinnix://projects/sinnix", + "operation": "preview", + "parameters": { + "actions": [ + { + "ref": "sinnix://projects/sinnix", + "operation": "create", + "parameters": {"title": "parent"}, + "bind": "parent", + }, + { + "ref": "sinnix://projects/sinnix", + "operation": "create", + "parameters": { + "title": "child", + "parent": "$parent", + }, + }, + ] + }, + "idempotency_key": "beads-changeset-example", + } + }, + ), documentation="Preview or apply an ordered, project-partitioned Beads changeset with explicit step outcomes and no global rollback claim.", ), ActionSpec( @@ -1193,7 +1816,16 @@ def build_registry() -> CatalogRegistry: resource_kinds=("project", "task_authority"), supports_idempotency=True, receipt_policy="audit", - examples=({"input": {"ref": "sinnix://projects/sinnix", "operation": "snapshot.publish", "parameters": {}, "idempotency_key": "beads-publish-example"}},), + examples=( + { + "input": { + "ref": "sinnix://projects/sinnix", + "operation": "snapshot.publish", + "parameters": {}, + "idempotency_key": "beads-publish-example", + } + }, + ), documentation="Run one explicit Beads publication, Dolt sync, or supported backup operation. Ordinary mutations do not publish JSONL or create Git commits.", ), ActionSpec( @@ -1209,7 +1841,16 @@ def build_registry() -> CatalogRegistry: resource_kinds=("mcp_tool",), supports_idempotency=True, receipt_policy="audit", - examples=({"input": {"ref": "sinnix://mcp/lynchpin/tools/refresh", "operation": "call", "parameters": {}, "idempotency_key": "mcp-refresh-example"}},), + examples=( + { + "input": { + "ref": "sinnix://mcp/lynchpin/tools/refresh", + "operation": "call", + "parameters": {}, + "idempotency_key": "mcp-refresh-example", + } + }, + ), documentation="Call one brokered upstream MCP tool whose live metadata does not declare it read-only.", ), ActionSpec( @@ -1331,7 +1972,16 @@ def build_registry() -> CatalogRegistry: resource_kinds=("desktop",), supports_idempotency=True, receipt_policy="audit", - examples=({"input": {"ref": "sinnix://desktop/current", "operation": "focus_window", "parameters": {"window": "address:0xfixture"}, "idempotency_key": "desktop-focus-example"}},), + examples=( + { + "input": { + "ref": "sinnix://desktop/current", + "operation": "focus_window", + "parameters": {"window": "address:0xfixture"}, + "idempotency_key": "desktop-focus-example", + } + }, + ), documentation="Operate the current desktop through the declared Hyprland owner route.", ), ActionSpec( @@ -1347,7 +1997,16 @@ def build_registry() -> CatalogRegistry: resource_kinds=("terminal",), supports_idempotency=True, receipt_policy="audit", - examples=({"input": {"ref": "sinnix://terminals/7", "operation": "send", "parameters": {"text": "printf fixture", "enter": True}, "idempotency_key": "terminal-send-example"}},), + examples=( + { + "input": { + "ref": "sinnix://terminals/7", + "operation": "send", + "parameters": {"text": "printf fixture", "enter": True}, + "idempotency_key": "terminal-send-example", + } + }, + ), documentation="Operate one canonical Kitty terminal without accepting an arbitrary matcher.", ), ActionSpec( @@ -1363,7 +2022,16 @@ def build_registry() -> CatalogRegistry: resource_kinds=("browser_workspace", "browser_page"), supports_idempotency=True, receipt_policy="audit", - examples=({"input": {"ref": "sinnix://browser/agent-workspace", "operation": "agent_window", "parameters": {"url": "https://example.test"}, "idempotency_key": "browser-window-example"}},), + examples=( + { + "input": { + "ref": "sinnix://browser/agent-workspace", + "operation": "agent_window", + "parameters": {"url": "https://example.test"}, + "idempotency_key": "browser-window-example", + } + }, + ), documentation="Create or operate only a gateway-owned browser target on the hidden agent workspace.", ), ActionSpec( @@ -1394,6 +2062,7 @@ def build_registry() -> CatalogRegistry: documentation="Start one typed operator-shell job and return its daemon-owned handle.", ), ) + def with_failure_contract(action: ActionSpec) -> ActionSpec: failures = set(BASE_TYPED_FAILURES) | {"deadline"} if action.supports_precondition: @@ -1413,7 +2082,10 @@ def with_failure_contract(action: ActionSpec) -> ActionSpec: return CatalogRegistry( resources, - tuple(with_failure_contract(action) for action in (*actions, *_owner_query_actions())), + tuple( + with_failure_contract(action) + for action in (*actions, *_owner_query_actions()) + ), ) diff --git a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/results.py b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/results.py index cdb40261..46dfb9cd 100644 --- a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/results.py +++ b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/results.py @@ -17,7 +17,6 @@ from .config import GatewayConfig from .schemas import V2ToolEnvelope - EXPECTED_ERROR_CODES = frozenset( { "invalid_request", @@ -69,9 +68,16 @@ def derive_cursor_key(master_key: bytes, purpose: str, principal: str) -> bytes: """Derive a purpose and principal bound cursor key from the private key.""" if not isinstance(master_key, bytes) or len(master_key) < 32: raise ResultError("cursor key is malformed", "unavailable") - if not isinstance(purpose, str) or not purpose or not isinstance(principal, str) or not principal: + if ( + not isinstance(purpose, str) + or not purpose + or not isinstance(principal, str) + or not principal + ): raise ResultError("cursor key derivation scope is malformed", "unavailable") - message = b"sinnix-gateway-cursor-v1\0" + purpose.encode() + b"\0" + principal.encode() + message = ( + b"sinnix-gateway-cursor-v1\0" + purpose.encode() + b"\0" + principal.encode() + ) return hmac.new(master_key, message, hashlib.sha256).digest() @@ -151,9 +157,13 @@ def append(self, row: Any) -> None: try: encoded = _canonical(row) except (TypeError, ValueError) as exc: - raise ResultError("JSONL owner row is not serializable", "owner_failed") from exc + raise ResultError( + "JSONL owner row is not serializable", "owner_failed" + ) from exc if len(encoded) > self.service.config.max_result_bytes: - raise ResultError("JSONL owner row exceeded response bound", "response_bound") + raise ResultError( + "JSONL owner row exceeded response bound", "response_bound" + ) line = encoded + b"\n" self.handle.write(line) self.hasher.update(line) @@ -329,7 +339,9 @@ def _decode_cursor(self, cursor: str) -> dict[str, Any]: encoded_text, signature_text = cursor.split(".", 1) encoded = encoded_text.encode() padding = b"=" * (-len(encoded) % 4) - signature = base64.urlsafe_b64decode(signature_text + "=" * (-len(signature_text) % 4)) + signature = base64.urlsafe_b64decode( + signature_text + "=" * (-len(signature_text) % 4) + ) expected = hmac.new(self.cursor_key, encoded, hashlib.sha256).digest() if not hmac.compare_digest(signature, expected): raise ValueError @@ -385,7 +397,9 @@ def _snapshot_page( try: rows.append(json.loads(line)) except json.JSONDecodeError as exc: - raise ResultError("snapshot row is malformed", "unavailable") from exc + raise ResultError( + "snapshot row is malformed", "unavailable" + ) from exc return { "rows": rows, "row_count": metadata["row_count"], @@ -413,11 +427,17 @@ def continue_snapshot( ): raise ResultError("cursor does not match this request", "stale_cursor") metadata, directory = self._snapshot_metadata(snapshot_id) - if payload.get("expires_at") != metadata["expires_at"] or time.time() >= metadata["expires_at"]: + if ( + payload.get("expires_at") != metadata["expires_at"] + or time.time() >= metadata["expires_at"] + ): raise ResultError("cursor has expired", "stale_cursor") if metadata["query_sha256"] != query_sha256: raise ResultError("cursor query does not match snapshot", "stale_cursor") - if source_revision is not None and source_revision != metadata["source_revision"]: + if ( + source_revision is not None + and source_revision != metadata["source_revision"] + ): raise ResultError("source changed after snapshot", "source_changed") page = self._snapshot_page(metadata, directory, offset, page_size=page_size) page["snapshot_ref"] = f"sinnix://results/{snapshot_id}" @@ -449,7 +469,9 @@ def require_payload_bound(self, payload: Any) -> None: ) from exc metadata_budget = min(4_096, max(1, self.config.max_result_bytes // 2)) if len(payload_bytes) > max(1, self.config.max_result_bytes - metadata_budget): - raise ResultError("owner response exceeded V2 result bound", "response_bound") + raise ResultError( + "owner response exceeded V2 result bound", "response_bound" + ) def record( self, @@ -466,9 +488,7 @@ def record( ) -> dict[str, Any]: if outcome not in {"ok", "error"}: raise ResultError("result outcome must be ok or error", "invalid_request") - request = request or RequestContext.create( - hashlib.sha256(b"{}").hexdigest() - ) + request = request or RequestContext.create(hashlib.sha256(b"{}").hexdigest()) artifact: dict[str, Any] | None = None try: self.require_payload_bound(payload) @@ -526,10 +546,14 @@ def record( try: V2ToolEnvelope.model_validate(envelope) except ValueError as exc: - raise ResultError("V2 result envelope is malformed", "owner_failed") from exc + raise ResultError( + "V2 result envelope is malformed", "owner_failed" + ) from exc encoded = _canonical(envelope) if len(encoded) > max(self.config.max_result_bytes, 4_096): - raise ResultError("V2 result envelope exceeded response bound", "response_bound") + raise ResultError( + "V2 result envelope exceeded response bound", "response_bound" + ) destination = self._path(result_id) temporary = self.root / f".{result_id}.{uuid.uuid4().hex}.tmp" try: @@ -565,7 +589,9 @@ def record_snapshot( "expires_at": metadata["expires_at"], } ) - next_cursor = initial_cursor if metadata["row_count"] > writer.page_size else None + next_cursor = ( + initial_cursor if metadata["row_count"] > writer.page_size else None + ) return self.record( action=action, owner=owner, diff --git a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/route_preflight.py b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/route_preflight.py index 7223f5fb..df325185 100644 --- a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/route_preflight.py +++ b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/route_preflight.py @@ -3,8 +3,6 @@ import json from typing import Any, Callable -from .captures import queryable_capture_lanes -from .config import GatewayConfig from sinnix_mcp.execution import ( EnvironmentProfile, ExecutionProfile, @@ -13,6 +11,9 @@ OwnerRoute, ) +from .captures import queryable_capture_lanes +from .config import GatewayConfig + class GatewayRoutePreflight: """Probe bounded direct owner routes without returning owner payloads.""" @@ -93,7 +94,10 @@ def _capture_probe(self) -> dict[str, Any]: captures_by_root = {} for lane in sorted(lanes): capture = lanes[lane] - if capture.native_contract != "sinnix-capture-v1-sidecar" or capture.root is None: + if ( + capture.native_contract != "sinnix-capture-v1-sidecar" + or capture.root is None + ): continue captures_by_root.setdefault(capture.root, capture) @@ -127,10 +131,12 @@ def _capture_probe(self) -> dict[str, Any]: OwnerRoute("capture-query"), "json_lane_summary_list", ExecutionResult.decode_json, - lambda value: isinstance(value, list) - and any( - isinstance(record, dict) and record.get("lane") == lane - for record in value + lambda value, lane=lane: ( + isinstance(value, list) + and any( + isinstance(record, dict) and record.get("lane") == lane + for record in value + ) ), ) ) @@ -140,7 +146,9 @@ def _capture_probe(self) -> dict[str, Any]: return { "route": "capture.query", "status": ( - "pass" if all(probe["status"] == "pass" for probe in probes) else "degraded" + "pass" + if all(probe["status"] == "pass" for probe in probes) + else "degraded" ), "probed_roots": [str(capture.root) for capture in selected_captures], "probes": probes, @@ -206,7 +214,9 @@ def run(self) -> dict[str, Any]: lambda value: isinstance(value, str) and bool(value.strip()), ), ] - if any(server.get("brokered") for server in self.config.mcp_broker_servers.values()): + if any( + server.get("brokered") for server in self.config.mcp_broker_servers.values() + ): environment, missing = self.execution.environment_for( OwnerRoute("mcp-user-bus", EnvironmentProfile.USER_BUS) ) @@ -217,13 +227,16 @@ def run(self) -> dict[str, Any]: "failure_class": ( None if missing is None else "user_bus_environment_missing" ), - "dbus_session_bus_address": "DBUS_SESSION_BUS_ADDRESS" in environment, + "dbus_session_bus_address": "DBUS_SESSION_BUS_ADDRESS" + in environment, "xdg_runtime_dir": "XDG_RUNTIME_DIR" in environment, } ) return { "status": ( - "ready" if all(row["status"] == "pass" for row in routes) else "degraded" + "ready" + if all(row["status"] == "pass" for row in routes) + else "degraded" ), "routes": routes, } diff --git a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/runtime.py b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/runtime.py index fd188c5a..2de45e96 100644 --- a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/runtime.py +++ b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/runtime.py @@ -5,48 +5,53 @@ import hashlib import json import time -import anyio from dataclasses import dataclass, field from pathlib import Path from typing import Any, Awaitable, Callable, Mapping, TypeVar, cast from uuid import uuid4 -from mcp.server import MCPServer +import anyio from mcp.types import CallToolResult, TextContent, ToolAnnotations +from sinnix_mcp import ErrorCode, RequestEnvelope +from sinnix_mcp.execution import ExecutionProfile, OwnerDiagnosticError, OwnerExecution +from sinnixd.api import SinnixdClient, SinnixdClientError from .artifacts import ArtifactService from .audit import AuditService from .beads import BeadsError, BeadsService -from .bindings import TargetToolBinding, TargetToolBindings from .browser import BrowserService from .capabilities import Capability, PolicyError, Principal from .capability_index import CapabilityIndexService from .captures import CaptureService from .config import GatewayConfig -from .contexts import ComponentResult, ComponentSpec, ContextComposer, ContextSnapshotStore, CONTEXT_INTENTS, source_revision -from .contracts import ActionSpec, EffectMode, VerbFamily +from .contexts import ( + CONTEXT_INTENTS, + ComponentResult, + ComponentSpec, + ContextComposer, + ContextSnapshotStore, + source_revision, +) +from .contracts import ActionSpec, EffectMode from .desktop import DesktopService -from .legacy_manifest import LEGACY_MANIFEST -from sinnix_mcp.execution import ExecutionProfile, OwnerDiagnosticError, OwnerExecution +from .events import EventCursorError, NormalizedEventService from .files import HostFileService +from .legacy_manifest import LEGACY_MANIFEST from .machine_actions import MachineActionService from .mcp_broker import McpBrokerService from .memory import MemoryService from .observe import ObserveService from .project_context import ProjectContextService from .projects import ProjectPreconditionError, ProjectService -from .events import EventCursorError, NormalizedEventService from .redaction import public_error +from .registry import MACHINE_OPERATIONS, REGISTRY, CatalogSearch, RegistryError from .results import ProtocolError, RequestContext, ResultError, ResultService from .route_preflight import GatewayRoutePreflight -from .registry import CatalogSearch, MACHINE_OPERATIONS, REGISTRY, RegistryError from .schemas import AgentLaunchRequest, V2ToolEnvelope from .sessions import SessionLogService from .terminals import TerminalService from .timeline import TimelineService from .waits import BoundedWaitService, WaitEvidence, WaitRequest, WaitTarget -from sinnix_mcp import ErrorCode, RequestEnvelope -from sinnixd.api import SinnixdClient, SinnixdClientError T = TypeVar("T") @@ -92,8 +97,17 @@ def _orientation_task_summary(result: Mapping[str, Any]) -> dict[str, Any]: if not isinstance(items, list): return dict(result) fields = ( - "id", "ref", "title", "status", "priority", "issue_type", - "assignee", "labels", "parent", "task_revision", "etag", + "id", + "ref", + "title", + "status", + "priority", + "issue_type", + "assignee", + "labels", + "parent", + "task_revision", + "etag", ) return { **result, @@ -234,7 +248,9 @@ def create(cls, config: GatewayConfig, principal_name: str) -> "Runtime": {"limit": limit, **({"cursor": cursor} if cursor else {})} ), ) - runtime.context_snapshots = ContextSnapshotStore(config.state_dir, principal_name) + runtime.context_snapshots = ContextSnapshotStore( + config.state_dir, principal_name + ) runtime.waits = BoundedWaitService(runtime._resolve_wait) return runtime @@ -244,17 +260,29 @@ def owner_revision_observations(self) -> dict[str, str]: for project_id in self.config.projects: try: summary = self.projects.summary(project_id) - latest = summary.get("latest_commit") if isinstance(summary, Mapping) else None + latest = ( + summary.get("latest_commit") + if isinstance(summary, Mapping) + else None + ) revision = latest.get("id") if isinstance(latest, Mapping) else None if isinstance(revision, str) and revision: - observations[REGISTRY.reference("project", {"project_id": project_id})] = revision + observations[ + REGISTRY.reference("project", {"project_id": project_id}) + ] = revision except Exception: continue try: authority = self.beads.task_authority_status(project_id) - revision = authority.get("revision") if isinstance(authority, Mapping) else None + revision = ( + authority.get("revision") + if isinstance(authority, Mapping) + else None + ) if isinstance(revision, str) and revision: - observations[f"sinnix://projects/{project_id}/task-authority"] = revision + observations[f"sinnix://projects/{project_id}/task-authority"] = ( + revision + ) except Exception: continue return observations @@ -312,7 +340,10 @@ def resolve_availability(kind: str, name: str) -> tuple[str, str | None]: for action in REGISTRY.actions ): return "available", None - return "unavailable", "no migrated V2 action currently exposes this resource" + return ( + "unavailable", + "no migrated V2 action currently exposes this resource", + ) selected_project: dict[str, Any] | None = None if search.project is not None: @@ -328,9 +359,7 @@ def resolve_availability(kind: str, name: str) -> tuple[str, str | None]: raise ProtocolError( "unavailable", "project is unavailable to this principal" ) - catalog = REGISTRY.search( - search, availability_resolver=resolve_availability - ) + catalog = REGISTRY.search(search, availability_resolver=resolve_availability) if selected_project is not None: catalog["project"] = { **selected_project, @@ -394,10 +423,14 @@ def _project_reference( try: resource, values = REGISTRY.resolve(reference) except RegistryError as exc: - raise ProtocolError("not_found", "canonical project resource was not found") from exc + raise ProtocolError( + "not_found", "canonical project resource was not found" + ) from exc allowed = {"project", "checkout"} if allow_checkout else {"project"} if resource.kind not in allowed: - raise ProtocolError("invalid_request", "ref does not identify the required project resource") + raise ProtocolError( + "invalid_request", "ref does not identify the required project resource" + ) return ( values["project_id"], values.get("checkout_id"), @@ -458,9 +491,7 @@ def _bounded_project_context(self, project_id: str) -> dict[str, Any]: }, } - def v2_query( - self, reference: str, query: str, max_matches: int - ) -> dict[str, Any]: + def v2_query(self, reference: str, query: str, max_matches: int) -> dict[str, Any]: project_id, checkout_id, canonical_ref = self._project_reference( reference, allow_checkout=True ) @@ -495,44 +526,75 @@ def compose_context( raise ProtocolError("not_found", "context target is not canonical") from exc if intent in {"project.orientation", "project.triage", "incident"}: if resource.kind not in {"project", "checkout"}: - raise ProtocolError("invalid_request", f"{intent} requires a project reference") + raise ProtocolError( + "invalid_request", f"{intent} requires a project reference" + ) project_id = values["project_id"] target_ref = str(resource.ref_template.format(values)) elif intent in {"bead.work", "bead.review"}: if resource.kind != "bead": - raise ProtocolError("invalid_request", f"{intent} requires a Beads reference") + raise ProtocolError( + "invalid_request", f"{intent} requires a Beads reference" + ) project_id = values["project_id"] target_ref = str(resource.ref_template.format(values)) else: if resource.kind != "job": - raise ProtocolError("invalid_request", "job.review requires a job reference") + raise ProtocolError( + "invalid_request", "job.review requires a job reference" + ) project_id = None target_ref = str(resource.ref_template.format(values)) - if self.principal.name == "agent-control" and intent in {"project.orientation", "project.triage"}: - raise PolicyError("agent-control project context is limited to an assigned Beads job") + if self.principal.name == "agent-control" and intent in { + "project.orientation", + "project.triage", + }: + raise PolicyError( + "agent-control project context is limited to an assigned Beads job" + ) declared = dict(CONTEXT_INTENTS[intent].components) - def component(name: str, fn: Callable[[], Any], source_ref: str | None = None) -> ComponentSpec: + def component( + name: str, fn: Callable[[], Any], source_ref: str | None = None + ) -> ComponentSpec: def probe() -> ComponentResult: try: value = fn() except ProtocolError as exc: - if exc.code in {"invalid_request", "not_found", "policy_denied", "precondition_failed"}: + if exc.code in { + "invalid_request", + "not_found", + "policy_denied", + "precondition_failed", + }: raise - return ComponentResult.unavailable(name, public_error(exc), source_ref=source_ref) + return ComponentResult.unavailable( + name, public_error(exc), source_ref=source_ref + ) except Exception as exc: - return ComponentResult.unavailable(name, public_error(exc), source_ref=source_ref) - revision = value.get("source_revision") if isinstance(value, Mapping) else None + return ComponentResult.unavailable( + name, public_error(exc), source_ref=source_ref + ) + revision = ( + value.get("source_revision") if isinstance(value, Mapping) else None + ) return ComponentResult.available( name, value, - revision=revision if isinstance(revision, str) else source_revision(value), + revision=revision + if isinstance(revision, str) + else source_revision(value), source_ref=source_ref, ) + return ComponentSpec(name, declared[name], probe) - project_ref = REGISTRY.reference("project", {"project_id": project_id}) if project_id else None + project_ref = ( + REGISTRY.reference("project", {"project_id": project_id}) + if project_id + else None + ) assigned_bead: Mapping[str, Any] | None = None review_job_observation: dict[str, Any] | None = None if intent == "bead.review" and self.principal.name != "agent-control": @@ -541,15 +603,22 @@ def probe() -> ComponentResult: _job_resource, job_values, _canonical_job_ref = self._resource_reference( job_ref, {"job"}, "bead.review requires a canonical job reference" ) - review_job_observation = self._sinnixd_job("job.get", {"job_id": job_values["job_id"]}) + review_job_observation = self._sinnixd_job( + "job.get", {"job_id": job_values["job_id"]} + ) binding = review_job_observation.get("contract", {}).get("bead_binding") if ( not isinstance(binding, Mapping) or binding.get("bead_ref") != target_ref or binding.get("project_ref") != project_ref ): - raise ProtocolError("precondition_failed", "job is not the requested Beads assignment") - if intent in {"bead.work", "bead.review"} and self.principal.name == "agent-control": + raise ProtocolError( + "precondition_failed", "job is not the requested Beads assignment" + ) + if ( + intent in {"bead.work", "bead.review"} + and self.principal.name == "agent-control" + ): self._assigned_bead_job(target_ref, project_id, None, job_ref) assigned_bead = self.beads.get( project_id, @@ -565,33 +634,113 @@ def probe() -> ComponentResult: if intent == "project.orientation": assert project_id is not None components = [ - component("project", lambda: self.projects.summary(project_id), project_ref), - component("checkout", lambda: self.projects.checkout(project_id, "default"), REGISTRY.reference("checkout", {"project_id": project_id, "checkout_id": "default"})), - component("tasks", lambda: _orientation_task_summary(self.beads.query(project_ids=[project_id], view="ready", limit=20)), f"{project_ref}/beads"), - component("authority", lambda: self.project_authority(project_id), f"{project_ref}/task-authority"), + component( + "project", lambda: self.projects.summary(project_id), project_ref + ), + component( + "checkout", + lambda: self.projects.checkout(project_id, "default"), + REGISTRY.reference( + "checkout", {"project_id": project_id, "checkout_id": "default"} + ), + ), + component( + "tasks", + lambda: _orientation_task_summary( + self.beads.query( + project_ids=[project_id], view="ready", limit=20 + ) + ), + f"{project_ref}/beads", + ), + component( + "authority", + lambda: self.project_authority(project_id), + f"{project_ref}/task-authority", + ), ] elif intent == "project.triage": assert project_id is not None components = [ - component("project", lambda: self.projects.summary(project_id), project_ref), - component("open_beads", lambda: self.beads.query(project_ids=[project_id], view="open", limit=50), f"{project_ref}/beads"), - component("stale_claims", lambda: self.beads.query(project_ids=[project_id], view="stale_claims", limit=50), f"{project_ref}/beads"), - component("changes", lambda: self.projects.diff(project_id, None, None), project_ref), + component( + "project", lambda: self.projects.summary(project_id), project_ref + ), + component( + "open_beads", + lambda: self.beads.query( + project_ids=[project_id], view="open", limit=50 + ), + f"{project_ref}/beads", + ), + component( + "stale_claims", + lambda: self.beads.query( + project_ids=[project_id], view="stale_claims", limit=50 + ), + f"{project_ref}/beads", + ), + component( + "changes", + lambda: self.projects.diff(project_id, None, None), + project_ref, + ), ] elif intent == "bead.work": bead_ref = target_ref bead_id = values["bead_id"] components = [ - component("bead", lambda: assigned_bead if assigned_bead is not None else self.beads.get(project_id, bead_id, includes=["blockers", "dependencies", "dependents", "children", "refs"]), bead_ref), - component("project", lambda: self.projects.summary(project_id), project_ref), - component("checkout", lambda: self.projects.checkout(project_id, "default"), REGISTRY.reference("checkout", {"project_id": project_id, "checkout_id": "default"})), - component("assignment", lambda: self._context_assignment(bead_ref, project_id, job_ref), job_ref), - component("blockers", lambda: self.beads.graph(project_id, bead_id, direction="down", edge_type="blocks", max_rows=50), f"{bead_ref}/blockers"), + component( + "bead", + lambda: ( + assigned_bead + if assigned_bead is not None + else self.beads.get( + project_id, + bead_id, + includes=[ + "blockers", + "dependencies", + "dependents", + "children", + "refs", + ], + ) + ), + bead_ref, + ), + component( + "project", lambda: self.projects.summary(project_id), project_ref + ), + component( + "checkout", + lambda: self.projects.checkout(project_id, "default"), + REGISTRY.reference( + "checkout", {"project_id": project_id, "checkout_id": "default"} + ), + ), + component( + "assignment", + lambda: self._context_assignment(bead_ref, project_id, job_ref), + job_ref, + ), + component( + "blockers", + lambda: self.beads.graph( + project_id, + bead_id, + direction="down", + edge_type="blocks", + max_rows=50, + ), + f"{bead_ref}/blockers", + ), ] elif intent == "bead.review": if not isinstance(job_ref, str): raise ProtocolError("invalid_request", "bead.review requires job_ref") - _job_resource, job_values, canonical_job_ref = self._resource_reference(job_ref, {"job"}, "bead.review requires a canonical job reference") + _job_resource, job_values, canonical_job_ref = self._resource_reference( + job_ref, {"job"}, "bead.review requires a canonical job reference" + ) review_bead = assigned_bead bead_id = values["bead_id"] job_observation: dict[str, Any] | None = review_job_observation @@ -599,15 +748,49 @@ def probe() -> ComponentResult: def job() -> dict[str, Any]: nonlocal job_observation if job_observation is None: - job_observation = self._sinnixd_job("job.get", {"job_id": job_values["job_id"]}) + job_observation = self._sinnixd_job( + "job.get", {"job_id": job_values["job_id"]} + ) return job_observation components = [ - component("bead", lambda: review_bead if review_bead is not None else self.beads.get(project_id, bead_id, includes=["history", "events", "dependencies", "dependents", "refs"]), target_ref), + component( + "bead", + lambda: ( + review_bead + if review_bead is not None + else self.beads.get( + project_id, + bead_id, + includes=[ + "history", + "events", + "dependencies", + "dependents", + "refs", + ], + ) + ), + target_ref, + ), component("job", job, canonical_job_ref), - component("checkout", lambda: self.projects.checkout(project_id, "default"), REGISTRY.reference("checkout", {"project_id": project_id, "checkout_id": "default"})), - component("diff", lambda: self.projects.diff(project_id, None, None), project_ref), - component("evidence", lambda: self._review_evidence(job_values["job_id"], job()), canonical_job_ref), + component( + "checkout", + lambda: self.projects.checkout(project_id, "default"), + REGISTRY.reference( + "checkout", {"project_id": project_id, "checkout_id": "default"} + ), + ), + component( + "diff", + lambda: self.projects.diff(project_id, None, None), + project_ref, + ), + component( + "evidence", + lambda: self._review_evidence(job_values["job_id"], job()), + canonical_job_ref, + ), ] elif intent == "job.review": job_id = values["job_id"] @@ -621,24 +804,60 @@ def job_value() -> dict[str, Any]: components = [ component("job", job_value, target_ref), - component("result", lambda: self._sinnixd_job("job.result", {"job_id": job_id, "max_bytes": 64_000}), target_ref), - component("project", lambda: self.projects.summary(str(job_value().get("project_id"))), project_ref), - component("events", lambda: self.audit.tail(50), f"sinnix://receipts"), + component( + "result", + lambda: self._sinnixd_job( + "job.result", {"job_id": job_id, "max_bytes": 64_000} + ), + target_ref, + ), + component( + "project", + lambda: self.projects.summary(str(job_value().get("project_id"))), + project_ref, + ), + component("events", lambda: self.audit.tail(50), "sinnix://receipts"), ] else: components = [ - component("runtime", lambda: self.observe.machine_query("overview"), "sinnix://machine/overview"), - component("transitions", lambda: (self.normalized_events.read(limit=50) if self.normalized_events else {"events": []}), "sinnix://events"), + component( + "runtime", + lambda: self.observe.machine_query("overview"), + "sinnix://machine/overview", + ), + component( + "transitions", + lambda: ( + self.normalized_events.read(limit=50) + if self.normalized_events + else {"events": []} + ), + "sinnix://events", + ), component("receipts", lambda: self.audit.tail(50), "sinnix://receipts"), - component("jobs", lambda: self.v2_jobs_query({"limit": 50}), "sinnix://jobs"), + component( + "jobs", lambda: self.v2_jobs_query({"limit": 50}), "sinnix://jobs" + ), ] context = self.context_composer.compose(intent, target_ref, components) by_name = {row["name"]: row for row in context["components"]} - if intent == "bead.review" and by_name.get("job", {}).get("status") == "available": + if ( + intent == "bead.review" + and by_name.get("job", {}).get("status") == "available" + ): job_data = by_name["job"].get("data") - binding = job_data.get("contract", {}).get("bead_binding") if isinstance(job_data, Mapping) else None - if not isinstance(binding, Mapping) or binding.get("bead_ref") != target_ref: - raise ProtocolError("precondition_failed", "job is not the requested Beads assignment") + binding = ( + job_data.get("contract", {}).get("bead_binding") + if isinstance(job_data, Mapping) + else None + ) + if ( + not isinstance(binding, Mapping) + or binding.get("bead_ref") != target_ref + ): + raise ProtocolError( + "precondition_failed", "job is not the requested Beads assignment" + ) compatibility: dict[str, Any] = {} if intent == "project.orientation" and all( by_name.get(name, {}).get("status") == "available" @@ -660,18 +879,38 @@ def job_value() -> dict[str, Any]: } ) elif intent == "bead.review": - if all(by_name.get(name, {}).get("status") == "available" for name in ("bead", "job", "checkout", "evidence")): + if all( + by_name.get(name, {}).get("status") == "available" + for name in ("bead", "job", "checkout", "evidence") + ): bead_data = by_name["bead"]["data"] job_data = by_name["job"]["data"] checkout_data = by_name["checkout"]["data"] - binding = job_data.get("contract", {}).get("bead_binding") if isinstance(job_data, Mapping) else None - launch_checkout = job_data.get("checkout") if isinstance(job_data, Mapping) else None - current_checkout = checkout_data.get("checkout") if isinstance(checkout_data, Mapping) else None - if isinstance(binding, Mapping) and isinstance(launch_checkout, Mapping) and isinstance(current_checkout, Mapping): + binding = ( + job_data.get("contract", {}).get("bead_binding") + if isinstance(job_data, Mapping) + else None + ) + launch_checkout = ( + job_data.get("checkout") if isinstance(job_data, Mapping) else None + ) + current_checkout = ( + checkout_data.get("checkout") + if isinstance(checkout_data, Mapping) + else None + ) + if ( + isinstance(binding, Mapping) + and isinstance(launch_checkout, Mapping) + and isinstance(current_checkout, Mapping) + ): try: compatibility = { "bead": {"launch": dict(binding), "current": bead_data}, - "job": {"ref": by_name["job"].get("source_ref"), **job_data}, + "job": { + "ref": by_name["job"].get("source_ref"), + **job_data, + }, "checkout": { "launch": dict(launch_checkout), "current": dict(current_checkout), @@ -684,18 +923,30 @@ def job_value() -> dict[str, Any]: }, "evidence": by_name["evidence"]["data"], "revision_mismatch": { - "task_revision": binding.get("task_revision") != bead_data.get("task_revision"), - "task_etag": binding.get("task_etag") != bead_data.get("etag"), - "code_revision": launch_checkout.get("head") != current_checkout.get("head"), + "task_revision": binding.get("task_revision") + != bead_data.get("task_revision"), + "task_etag": binding.get("task_etag") + != bead_data.get("etag"), + "code_revision": launch_checkout.get("head") + != current_checkout.get("head"), }, } except (KeyError, TypeError, ValueError): compatibility = {} if compatibility: candidate = {**context, **compatibility} - if len(json.dumps(candidate, sort_keys=True, separators=(",", ":")).encode()) <= context["total_budget_bytes"]: + if ( + len( + json.dumps( + candidate, sort_keys=True, separators=(",", ":") + ).encode() + ) + <= context["total_budget_bytes"] + ): context = candidate - snapshot_body = {key: value for key, value in context.items() if key != "snapshot_ref"} + snapshot_body = { + key: value for key, value in context.items() if key != "snapshot_ref" + } snapshot_body["components"] = [ {**component, "snapshot_ref": "pending"} for component in context["components"] @@ -709,12 +960,28 @@ def job_value() -> dict[str, Any]: self.context_snapshots.put(context) return {"ref": target_ref, **context} - def _context_assignment(self, bead_ref: str, project_id: str, job_ref: str | None) -> dict[str, Any]: + def _context_assignment( + self, bead_ref: str, project_id: str, job_ref: str | None + ) -> dict[str, Any]: if self.principal.name == "agent-control": - job, binding, checkout, assignment_ref = self._assigned_bead_job(bead_ref, project_id, None, job_ref) - return {"ref": assignment_ref, "job": job, "binding": dict(binding), "checkout": checkout} + job, binding, checkout, assignment_ref = self._assigned_bead_job( + bead_ref, project_id, None, job_ref + ) + return { + "ref": assignment_ref, + "job": job, + "binding": dict(binding), + "checkout": checkout, + } jobs = self.v2_jobs_query({"limit": 50}) - return {"jobs": [job for job in jobs.get("jobs", []) if isinstance(job.get("contract"), Mapping) and job["contract"].get("bead_binding", {}).get("bead_ref") == bead_ref]} + return { + "jobs": [ + job + for job in jobs.get("jobs", []) + if isinstance(job.get("contract"), Mapping) + and job["contract"].get("bead_binding", {}).get("bead_ref") == bead_ref + ] + } def v2_context( self, reference: str, intent: str = "project", job_ref: str | None = None @@ -729,9 +996,14 @@ def _assigned_bead_job( job_ref: str | None, ) -> tuple[dict[str, Any], Mapping[str, Any], dict[str, Any], str]: if not isinstance(job_ref, str): - raise ProtocolError("precondition_failed", "agent-control Beads context requires an assignment job ref") + raise ProtocolError( + "precondition_failed", + "agent-control Beads context requires an assignment job ref", + ) _resource, values, canonical_job_ref = self._resource_reference( - job_ref, {"job"}, "agent-control assignment requires a canonical job reference" + job_ref, + {"job"}, + "agent-control assignment requires a canonical job reference", ) job = self._sinnixd_job("job.get", {"job_id": values["job_id"]}) binding = job.get("contract", {}).get("bead_binding") @@ -740,40 +1012,56 @@ def _assigned_bead_job( job.get("principal") != "agent-control" or not isinstance(binding, Mapping) or binding.get("bead_ref") != bead_ref - or binding.get("project_ref") != REGISTRY.reference("project", {"project_id": project_id}) + or binding.get("project_ref") + != REGISTRY.reference("project", {"project_id": project_id}) or not isinstance(checkout, Mapping) or not isinstance(checkout.get("checkout_id"), str) - or binding.get("checkout_ref") != REGISTRY.reference( - "checkout", {"project_id": project_id, "checkout_id": checkout["checkout_id"]} + or binding.get("checkout_ref") + != REGISTRY.reference( + "checkout", + {"project_id": project_id, "checkout_id": checkout["checkout_id"]}, ) ): - raise ProtocolError("precondition_failed", "job is not the requested agent-control Beads assignment") + raise ProtocolError( + "precondition_failed", + "job is not the requested agent-control Beads assignment", + ) if bead is not None: self._assert_assignment_current(binding, bead) return job, binding, dict(checkout), canonical_job_ref @staticmethod - def _assert_assignment_current(binding: Mapping[str, Any], bead: Mapping[str, Any]) -> None: - if ( - binding.get("task_revision") != bead.get("task_revision") - or binding.get("task_etag") != bead.get("etag") - ): - raise ProtocolError("precondition_failed", "agent-control Beads assignment is stale") + def _assert_assignment_current( + binding: Mapping[str, Any], bead: Mapping[str, Any] + ) -> None: + if binding.get("task_revision") != bead.get("task_revision") or binding.get( + "task_etag" + ) != bead.get("etag"): + raise ProtocolError( + "precondition_failed", "agent-control Beads assignment is stale" + ) def _review_evidence(self, job_id: str, job: Mapping[str, Any]) -> dict[str, Any]: artifacts = job.get("artifacts") - declared_result = artifacts.get("result") if isinstance(artifacts, Mapping) else None + declared_result = ( + artifacts.get("result") if isinstance(artifacts, Mapping) else None + ) tests = { "availability": "unavailable", "reason": "bead-bound attested-agent jobs declare no structured test result", } if not isinstance(declared_result, Mapping): return { - "result": {"availability": "unavailable", "reason": "job declares no result artifact"}, + "result": { + "availability": "unavailable", + "reason": "job declares no result artifact", + }, "tests": tests, } try: - observed = self._sinnixd_job("job.result", {"job_id": job_id, "max_bytes": 64_000}) + observed = self._sinnixd_job( + "job.result", {"job_id": job_id, "max_bytes": 64_000} + ) except ProtocolError as exc: return { "result": { @@ -784,10 +1072,18 @@ def _review_evidence(self, job_id: str, job: Mapping[str, Any]) -> dict[str, Any "tests": tests, } if observed.get("job_id") != job_id: - raise ProtocolError("owner_failed", "sinnixd result response does not match the reviewed job") + raise ProtocolError( + "owner_failed", + "sinnixd result response does not match the reviewed job", + ) artifact = observed.get("artifact") - if not isinstance(artifact, Mapping) or artifact.get("ref") != declared_result.get("ref"): - raise ProtocolError("owner_failed", "sinnixd result does not match the declared job artifact") + if not isinstance(artifact, Mapping) or artifact.get( + "ref" + ) != declared_result.get("ref"): + raise ProtocolError( + "owner_failed", + "sinnixd result does not match the declared job artifact", + ) return { "result": { "availability": "available", @@ -814,32 +1110,53 @@ def v2_run_for_bead( ) -> dict[str, Any]: self.principal.require(Capability.JOB_START) if self.principal.name not in {"agent-control", "operator"}: - raise PolicyError("bead-bound agent jobs require agent-control or operator authority") + raise PolicyError( + "bead-bound agent jobs require agent-control or operator authority" + ) if not isinstance(request_id, str): - raise ProtocolError("invalid_request", "bead-bound agent launch requires request_id") + raise ProtocolError( + "invalid_request", "bead-bound agent launch requires request_id" + ) _resource, values, bead_ref = self._resource_reference( - reference or "", {"bead"}, "bead-bound agent launch requires a canonical Beads reference" + reference or "", + {"bead"}, + "bead-bound agent launch requires a canonical Beads reference", ) if not isinstance(checkout_id, str) or not checkout_id: - raise ProtocolError("invalid_request", "bead-bound agent launch requires an explicit checkout_id") + raise ProtocolError( + "invalid_request", + "bead-bound agent launch requires an explicit checkout_id", + ) if claim_mode not in {"none", "claim"}: raise ProtocolError("invalid_request", "claim_mode must be none or claim") project_id, bead_id = values["project_id"], values["bead_id"] - checkout = self.projects.checkout(project_id, checkout_id)["checkout"] + self.projects.checkout(project_id, checkout_id)["checkout"] project_ref = REGISTRY.reference("project", {"project_id": project_id}) - checkout_ref = REGISTRY.reference("checkout", {"project_id": project_id, "checkout_id": checkout_id}) + checkout_ref = REGISTRY.reference( + "checkout", {"project_id": project_id, "checkout_id": checkout_id} + ) claim_receipt: dict[str, Any] | None = None claim_ref: str | None = None parent_assignment_ref: str | None = None if self.principal.name == "agent-control": if claim_mode != "none": raise PolicyError("agent-control cannot claim Beads tasks") - _assignment_job, binding, assigned_checkout, parent_assignment_ref = self._assigned_bead_job( - bead_ref, project_id, None, assignment_ref + _assignment_job, binding, assigned_checkout, parent_assignment_ref = ( + self._assigned_bead_job(bead_ref, project_id, None, assignment_ref) ) - if assigned_checkout["checkout_id"] != checkout_id or binding.get("checkout_ref") != checkout_ref: - raise ProtocolError("precondition_failed", "agent-control launch must use its assigned checkout") - bead = self.beads.get(project_id, bead_id, includes=["blockers", "dependencies", "dependents", "children", "refs"]) + if ( + assigned_checkout["checkout_id"] != checkout_id + or binding.get("checkout_ref") != checkout_ref + ): + raise ProtocolError( + "precondition_failed", + "agent-control launch must use its assigned checkout", + ) + bead = self.beads.get( + project_id, + bead_id, + includes=["blockers", "dependencies", "dependents", "children", "refs"], + ) if self.principal.name == "agent-control": self._assert_assignment_current(binding, bead) if claim_mode == "claim": @@ -854,7 +1171,9 @@ def v2_run_for_bead( ) after = claim.get("after") if not isinstance(after, Mapping): - raise ProtocolError("owner_failed", "Beads claim omitted its after state") + raise ProtocolError( + "owner_failed", "Beads claim omitted its after state" + ) bead = dict(after) claim_ref = f"{bead_ref}/claims/{bead['etag']}" claim_receipt = { @@ -876,14 +1195,20 @@ def v2_run_for_bead( "assignment_ref": parent_assignment_ref, } metadata = bead.get("metadata") - encoded_scope = metadata.get("write_scope") if isinstance(metadata, Mapping) else None + encoded_scope = ( + metadata.get("write_scope") if isinstance(metadata, Mapping) else None + ) if isinstance(encoded_scope, str): try: write_scope = json.loads(encoded_scope) except json.JSONDecodeError as error: - raise ProtocolError("invalid_request", "Bead write_scope metadata must be a JSON array") from error + raise ProtocolError( + "invalid_request", "Bead write_scope metadata must be a JSON array" + ) from error if not isinstance(write_scope, list): - raise ProtocolError("invalid_request", "Bead write_scope metadata must be a JSON array") + raise ProtocolError( + "invalid_request", "Bead write_scope metadata must be a JSON array" + ) binding["write_scope"] = write_scope assigned_context = { "bead": bead, @@ -894,7 +1219,11 @@ def v2_run_for_bead( prompt = ( "Work the assigned canonical Beads task. Read the supplied context, make and verify the requested code change in the assigned checkout, and report evidence plus residuals. Do not mutate or close Beads.\n\n" + json.dumps(assigned_context, sort_keys=True, separators=(",", ":")) - + ("\n\nOperator instructions:\n" + instructions if isinstance(instructions, str) and instructions else "") + + ( + "\n\nOperator instructions:\n" + instructions + if isinstance(instructions, str) and instructions + else "" + ) ) request = AgentLaunchRequest( project_id=project_id, @@ -953,7 +1282,9 @@ def v2_run_for_bead( "request_id": request_id, } if isinstance(job_id, str) and job_id: - launch_details["job_ref"] = REGISTRY.reference("job", {"job_id": job_id}) + launch_details["job_ref"] = REGISTRY.reference( + "job", {"job_id": job_id} + ) raise ProtocolError( "partial_completion" if claim_ref else "owner_failed", "Beads claim succeeded but agent launch failed" @@ -963,7 +1294,9 @@ def v2_run_for_bead( ) job_id = result.get("job_id") if not isinstance(job_id, str) or not job_id: - raise ProtocolError("owner_failed", "sinnixd bead-agent start response omitted the job ID") + raise ProtocolError( + "owner_failed", "sinnixd bead-agent start response omitted the job ID" + ) return { **result, "ref": REGISTRY.reference("job", {"job_id": job_id}), @@ -973,7 +1306,9 @@ def v2_run_for_bead( "claim_ref": claim_ref, "claim_receipt": claim_receipt, "assignment_ref": parent_assignment_ref, - "atomicity": "native_claim_then_daemon_launch" if claim_ref else "daemon_launch", + "atomicity": "native_claim_then_daemon_launch" + if claim_ref + else "daemon_launch", } def v2_events( @@ -1002,7 +1337,9 @@ def v2_events( for event in result["events"]: row = dict(event) if event.get("exact") is True and event.get("source") == "gateway.audit": - row["ref"] = REGISTRY.reference("receipt", {"receipt_id": event["event_id"]}) + row["ref"] = REGISTRY.reference( + "receipt", {"receipt_id": event["event_id"]} + ) elif isinstance(event.get("subject_ref"), str): row["ref"] = event["subject_ref"] rows.append(row) @@ -1021,12 +1358,18 @@ def v2_get( try: resource, values = REGISTRY.resolve(reference) except RegistryError as exc: - raise ProtocolError("not_found", "canonical resource was not found") from exc + raise ProtocolError( + "not_found", "canonical resource was not found" + ) from exc canonical_ref = str(resource.ref_template.format(values)) if self.principal.name not in resource.principals: - raise PolicyError(f"principal {self.principal.name} cannot read {resource.kind} resources") + raise PolicyError( + f"principal {self.principal.name} cannot read {resource.kind} resources" + ) if projection not in {"summary", "log", "result"}: - raise ProtocolError("invalid_request", "resource projection is not recognized") + raise ProtocolError( + "invalid_request", "resource projection is not recognized" + ) if not isinstance(offset, int) or isinstance(offset, bool) or offset < 0: raise ProtocolError("invalid_request", "resource offset is malformed") if ( @@ -1034,10 +1377,13 @@ def v2_get( or isinstance(max_bytes, bool) or not 1 <= max_bytes <= 262_144 ): - raise ProtocolError("invalid_request", "resource max_bytes must be 1-262144") + raise ProtocolError( + "invalid_request", "resource max_bytes must be 1-262144" + ) if resource.kind != "job" and projection != "summary": raise ProtocolError( - "invalid_request", "resource projection requires a canonical job reference" + "invalid_request", + "resource projection requires a canonical job reference", ) if resource.kind == "project": return { @@ -1057,7 +1403,12 @@ def v2_get( return { "ref": canonical_ref, "kind": resource.kind, - "bead": self.beads.get(values["project_id"], values["bead_id"], includes=includes, as_of=as_of), + "bead": self.beads.get( + values["project_id"], + values["bead_id"], + includes=includes, + as_of=as_of, + ), } if resource.kind == "task_authority": return { @@ -1079,13 +1430,16 @@ def v2_get( ) else: if offset: - raise ProtocolError("invalid_request", "job result does not support offsets") + raise ProtocolError( + "invalid_request", "job result does not support offsets" + ) result = self._sinnixd_job( "job.result", {"job_id": job_id, "max_bytes": max_bytes} ) if result.get("job_id") != job_id: raise ProtocolError( - "owner_failed", "sinnixd get response does not match the requested job" + "owner_failed", + "sinnixd get response does not match the requested job", ) return { "ref": canonical_ref, @@ -1097,7 +1451,9 @@ def v2_get( return { "ref": canonical_ref, "kind": resource.kind, - "artifact": self.artifacts.read(values["artifact_id"], offset, max_bytes), + "artifact": self.artifacts.read( + values["artifact_id"], offset, max_bytes + ), } if resource.kind == "receipt": return { @@ -1118,7 +1474,8 @@ def v2_get( raise ProtocolError("unavailable", "machine unit owner is unavailable") unit = next( ( - row for row in rows + row + for row in rows if isinstance(row, Mapping) and row.get("unit") == values["unit"] and row.get("manager", values["manager"]) == values["manager"] @@ -1126,8 +1483,15 @@ def v2_get( None, ) if unit is None: - raise ProtocolError("not_found", "machine unit is not in the current bounded owner page") - return {"ref": canonical_ref, "kind": resource.kind, "unit": dict(unit), "source": page.get("source")} + raise ProtocolError( + "not_found", "machine unit is not in the current bounded owner page" + ) + return { + "ref": canonical_ref, + "kind": resource.kind, + "unit": dict(unit), + "source": page.get("source"), + } if resource.kind == "process": page = self.observe.machine_query("workloads", limit=500) rows = page.get("rows") if isinstance(page, Mapping) else None @@ -1135,33 +1499,62 @@ def v2_get( raise ProtocolError("unavailable", "process owner is unavailable") process = next( ( - row for row in rows + row + for row in rows if isinstance(row, Mapping) and str(row.get("pid")) == values["pid"] - and str(row.get("start_ticks", row.get("start_time", ""))) == values["start_ticks"] + and str(row.get("start_ticks", row.get("start_time", ""))) + == values["start_ticks"] ), None, ) if process is None: - raise ProtocolError("not_found", "process is not in the current bounded owner page") - return {"ref": canonical_ref, "kind": resource.kind, "process": dict(process), "source": page.get("source")} + raise ProtocolError( + "not_found", "process is not in the current bounded owner page" + ) + return { + "ref": canonical_ref, + "kind": resource.kind, + "process": dict(process), + "source": page.get("source"), + } if resource.kind == "browser_page": - return {"ref": canonical_ref, "kind": resource.kind, "page": self.browser.describe_target(values["page_id"])} + return { + "ref": canonical_ref, + "kind": resource.kind, + "page": self.browser.describe_target(values["page_id"]), + } if resource.kind == "browser_workspace": - return {"ref": canonical_ref, "kind": resource.kind, "workspace": self.browser.read("status")} + return { + "ref": canonical_ref, + "kind": resource.kind, + "workspace": self.browser.read("status"), + } if resource.kind == "terminal": return { "ref": canonical_ref, "kind": resource.kind, - "terminal": self.terminals.read("capture", {"match": f"id:{values['terminal_id']}", "extent": "last_non_empty_output"}), + "terminal": self.terminals.read( + "capture", + { + "match": f"id:{values['terminal_id']}", + "extent": "last_non_empty_output", + }, + ), } if resource.kind == "desktop": - return {"ref": canonical_ref, "kind": resource.kind, "desktop": self.desktop.read("status")} + return { + "ref": canonical_ref, + "kind": resource.kind, + "desktop": self.desktop.read("status"), + } if resource.kind == "host_file": return { "ref": canonical_ref, "kind": resource.kind, - "file": self.files.read("stat", self._decode_file_token(values["file_token"])), + "file": self.files.read( + "stat", self._decode_file_token(values["file_token"]) + ), } if resource.kind == "mcp_tool": return { @@ -1175,22 +1568,36 @@ def v2_get( }, } if resource.kind == "capture_lane": - return {"ref": canonical_ref, "kind": resource.kind, "lane": self.captures.lane(values["lane"])} + return { + "ref": canonical_ref, + "kind": resource.kind, + "lane": self.captures.lane(values["lane"]), + } if resource.kind == "capability": - return {"ref": canonical_ref, "kind": resource.kind, "capability": self.capability_index.describe(values["name"])} + return { + "ref": canonical_ref, + "kind": resource.kind, + "capability": self.capability_index.describe(values["name"]), + } if resource.kind == "session": return { "ref": canonical_ref, "kind": resource.kind, - "session": self.sessions.read(f"{values['provider']}:{values['session_id']}", offset, max_bytes), + "session": self.sessions.read( + f"{values['provider']}:{values['session_id']}", offset, max_bytes + ), } if resource.kind == "context_snapshot": if self.context_snapshots is None: - raise ProtocolError("unavailable", "context snapshot store is unavailable") + raise ProtocolError( + "unavailable", "context snapshot store is unavailable" + ) try: snapshot = self.context_snapshots.get(values["snapshot_id"]) except KeyError as exc: - raise ProtocolError("not_found", "context snapshot is not retained") from exc + raise ProtocolError( + "not_found", "context snapshot is not retained" + ) from exc return {"ref": canonical_ref, "kind": resource.kind, "snapshot": snapshot} raise ValueError(f"V2 get does not support resource kind {resource.kind!r}") @@ -1243,7 +1650,9 @@ def v2_run_shell( ) job_id = result.get("job_id") if not isinstance(job_id, str) or not job_id: - raise ProtocolError("owner_failed", "sinnixd start response omitted the job ID") + raise ProtocolError( + "owner_failed", "sinnixd start response omitted the job ID" + ) return { **result, "ref": REGISTRY.reference("job", {"job_id": job_id}), @@ -1259,7 +1668,9 @@ def v2_run_declared_operation( ) -> dict[str, Any]: self.principal.require(Capability.JOB_START) if self.principal.name not in {"agent-control", "operator"}: - raise PolicyError("declared operations require agent-control or operator principal") + raise PolicyError( + "declared operations require agent-control or operator principal" + ) if not isinstance(project_id, str) or not 1 <= len(project_id) <= 128: raise ProtocolError("invalid_request", "project_id is malformed") if not isinstance(operation, str) or not 1 <= len(operation) <= 128: @@ -1280,7 +1691,10 @@ def v2_run_declared_operation( result = self._sinnixd_job("job.start", arguments) job_id = result.get("job_id") if not isinstance(job_id, str) or not job_id: - raise ProtocolError("owner_failed", "sinnixd declared-operation start response omitted the job ID") + raise ProtocolError( + "owner_failed", + "sinnixd declared-operation start response omitted the job ID", + ) return {**result, "ref": REGISTRY.reference("job", {"job_id": job_id})} @staticmethod @@ -1289,10 +1703,14 @@ def _required_preconditions( allowed: set[str], ) -> dict[str, Any]: if not isinstance(preconditions, Mapping) or not preconditions: - raise ProtocolError("precondition_failed", "mutation requires preconditions") + raise ProtocolError( + "precondition_failed", "mutation requires preconditions" + ) values = dict(preconditions) if set(values) - allowed: - raise ProtocolError("invalid_request", "mutation preconditions are not recognized") + raise ProtocolError( + "invalid_request", "mutation preconditions are not recognized" + ) return values def _project_change_preconditions( @@ -1329,7 +1747,9 @@ def v2_change( ) if operation == "write": if not isinstance(path, str) or not path or not isinstance(content, str): - raise ProtocolError("invalid_request", "write requires path and content") + raise ProtocolError( + "invalid_request", "write requires path and content" + ) if patch is not None: raise ProtocolError("invalid_request", "write does not accept patch") try: @@ -1346,7 +1766,9 @@ def v2_change( if not isinstance(patch, str) or not patch: raise ProtocolError("invalid_request", "apply_patch requires patch") if path is not None or content is not None: - raise ProtocolError("invalid_request", "apply_patch does not accept path or content") + raise ProtocolError( + "invalid_request", "apply_patch does not accept path or content" + ) try: result = self.projects.apply_patch( project_id, @@ -1357,7 +1779,9 @@ def v2_change( except ProjectPreconditionError as exc: raise ProtocolError("precondition_failed", str(exc)) from exc else: - raise ProtocolError("invalid_request", "project change operation is not recognized") + raise ProtocolError( + "invalid_request", "project change operation is not recognized" + ) return { "ref": canonical_ref, "project_ref": REGISTRY.reference("project", {"project_id": project_id}), @@ -1394,7 +1818,9 @@ def _decode_file_token(token: str) -> str: padded.encode(), altchars=b"-_", validate=True ).decode("utf-8") except (ValueError, UnicodeDecodeError, binascii.Error) as exc: - raise ProtocolError("invalid_request", "file reference is malformed") from exc + raise ProtocolError( + "invalid_request", "file reference is malformed" + ) from exc if not path or len(path) > 4_096 or not path.startswith("/"): raise ProtocolError("invalid_request", "file reference is malformed") return path @@ -1420,9 +1846,13 @@ def v2_file_change( "replace": {"content"}, } if operation not in allowed: - raise ProtocolError("unsupported_capability", "file operation is not declared") + raise ProtocolError( + "unsupported_capability", "file operation is not declared" + ) if set(arguments) - allowed[operation]: - raise ProtocolError("invalid_request", "file parameters are not valid for this operation") + raise ProtocolError( + "invalid_request", "file parameters are not valid for this operation" + ) if operation in {"append", "replace"} and not isinstance( arguments.get("content"), str ): @@ -1431,7 +1861,9 @@ def v2_file_change( if operation in {"copy", "move"}: destination_ref = arguments.get("destination_ref") if not isinstance(destination_ref, str): - raise ProtocolError("invalid_request", "file operation requires destination_ref") + raise ProtocolError( + "invalid_request", "file operation requires destination_ref" + ) _destination, destination_values, _ = self._resource_reference( destination_ref, {"host_file"}, @@ -1440,12 +1872,14 @@ def v2_file_change( destination = self._decode_file_token(destination_values["file_token"]) expected_sha256: str | None = None if preconditions is not None: - if not isinstance(preconditions, Mapping) or set(preconditions) - {"expected_sha256"}: - raise ProtocolError("invalid_request", "file preconditions are not recognized") + if not isinstance(preconditions, Mapping) or set(preconditions) - { + "expected_sha256" + }: + raise ProtocolError( + "invalid_request", "file preconditions are not recognized" + ) value = preconditions.get("expected_sha256") - if value is not None and ( - not isinstance(value, str) or len(value) != 64 - ): + if value is not None and (not isinstance(value, str) or len(value) != 64): raise ProtocolError("invalid_request", "expected_sha256 is malformed") expected_sha256 = value result = self.files.write( @@ -1457,7 +1891,9 @@ def v2_file_change( ) response = {"ref": canonical_ref, **result} if destination is not None: - destination_token = base64.urlsafe_b64encode(destination.encode()).decode().rstrip("=") + destination_token = ( + base64.urlsafe_b64encode(destination.encode()).decode().rstrip("=") + ) response["destination_ref"] = REGISTRY.reference( "host_file", {"file_token": destination_token} ) @@ -1465,19 +1901,35 @@ def v2_file_change( def v2_beads_query(self, parameters: Mapping[str, Any]) -> dict[str, Any]: if self.principal.name == "agent-control": - raise PolicyError("agent-control Beads reads require an assigned Beads context") + raise PolicyError( + "agent-control Beads reads require an assigned Beads context" + ) values = self._parameters(parameters) graph = values.pop("graph", None) memory = values.pop("memory", None) if graph is not None: projects = values.pop("project_ids", None) - if not isinstance(graph, Mapping) or not isinstance(projects, list) or len(projects) != 1: - raise ProtocolError("invalid_request", "Beads graph requires one project_id and graph object") + if ( + not isinstance(graph, Mapping) + or not isinstance(projects, list) + or len(projects) != 1 + ): + raise ProtocolError( + "invalid_request", + "Beads graph requires one project_id and graph object", + ) return self.beads.graph(projects[0], **dict(graph)) if memory is not None: projects = values.pop("project_ids", None) - if not isinstance(memory, Mapping) or not isinstance(projects, list) or len(projects) != 1: - raise ProtocolError("invalid_request", "Beads memory requires one project_id and memory object") + if ( + not isinstance(memory, Mapping) + or not isinstance(projects, list) + or len(projects) != 1 + ): + raise ProtocolError( + "invalid_request", + "Beads memory requires one project_id and memory object", + ) return self.beads.memories(projects[0], **dict(memory)) return self.beads.query(**values) @@ -1490,18 +1942,27 @@ def v2_beads_change( preconditions: Mapping[str, Any] | None, ) -> dict[str, Any]: resource, values, canonical_ref = self._resource_reference( - reference, {"project", "bead"}, "ref does not identify a canonical project or bead" + reference, + {"project", "bead"}, + "ref does not identify a canonical project or bead", ) mutation = self._parameters(parameters) if operation == "close_with_evidence": if resource.kind != "bead": - raise ProtocolError("invalid_request", "close_with_evidence requires a canonical Beads ref") - result = self._close_with_evidence(values["project_id"], values["bead_id"], canonical_ref, mutation) + raise ProtocolError( + "invalid_request", + "close_with_evidence requires a canonical Beads ref", + ) + result = self._close_with_evidence( + values["project_id"], values["bead_id"], canonical_ref, mutation + ) return {"ref": canonical_ref, **result} if resource.kind == "bead": mutation.setdefault("id", values["bead_id"]) result = self.beads.change( - values["project_id"], operation, mutation, + values["project_id"], + operation, + mutation, mode=str(mutation.pop("mode", "apply")), preconditions=preconditions, preview_digest=mutation.pop("preview_digest", None), @@ -1521,28 +1982,52 @@ def _close_with_evidence( "task_etag", } if set(values) != required: - raise ProtocolError("invalid_request", "close_with_evidence requires a complete evidence record") + raise ProtocolError( + "invalid_request", + "close_with_evidence requires a complete evidence record", + ) job_ref = values["job_ref"] _resource, job_values, canonical_job_ref = self._resource_reference( job_ref, {"job"}, "close_with_evidence requires a canonical job ref" ) job = self._sinnixd_job("job.get", {"job_id": job_values["job_id"]}) if job.get("state", {}).get("phase") != "succeeded": - raise ProtocolError("precondition_failed", "failed or cancelled jobs cannot close a bead") + raise ProtocolError( + "precondition_failed", "failed or cancelled jobs cannot close a bead" + ) binding = job.get("contract", {}).get("bead_binding") if not isinstance(binding, Mapping) or binding.get("bead_ref") != bead_ref: - raise ProtocolError("precondition_failed", "job is not bound to the requested bead") + raise ProtocolError( + "precondition_failed", "job is not bound to the requested bead" + ) checkout = job.get("checkout") - if not isinstance(checkout, Mapping) or not isinstance(checkout.get("checkout_id"), str): - raise ProtocolError("owner_failed", "bead-bound job omitted its checkout identity") - current_checkout = self.projects.checkout(project_id, checkout["checkout_id"])["checkout"] + if not isinstance(checkout, Mapping) or not isinstance( + checkout.get("checkout_id"), str + ): + raise ProtocolError( + "owner_failed", "bead-bound job omitted its checkout identity" + ) + current_checkout = self.projects.checkout(project_id, checkout["checkout_id"])[ + "checkout" + ] if values["code_revision"] != current_checkout.get("head"): - raise ProtocolError("precondition_failed", "code_revision does not match the current checkout") + raise ProtocolError( + "precondition_failed", + "code_revision does not match the current checkout", + ) current = self.beads.get(project_id, bead_id) - if values["task_revision"] != current.get("task_revision") or values["task_etag"] != current.get("etag"): - raise ProtocolError("precondition_failed", "task revision does not match the current bead") - if not isinstance(values["residuals"], list) or not isinstance(values["evidence_refs"], list): - raise ProtocolError("invalid_request", "closure residuals and evidence_refs must be lists") + if values["task_revision"] != current.get("task_revision") or values[ + "task_etag" + ] != current.get("etag"): + raise ProtocolError( + "precondition_failed", "task revision does not match the current bead" + ) + if not isinstance(values["residuals"], list) or not isinstance( + values["evidence_refs"], list + ): + raise ProtocolError( + "invalid_request", "closure residuals and evidence_refs must be lists" + ) evidence = { "schema": "sinnix.bead-close-evidence.v1", "verdict": values["verdict"], @@ -1564,7 +2049,10 @@ def _close_with_evidence( "reason": json.dumps(evidence, sort_keys=True, separators=(",", ":")), "force": True, }, - preconditions={"expected_task_revision": current["task_revision"], "expected_etag": current["etag"]}, + preconditions={ + "expected_task_revision": current["task_revision"], + "expected_etag": current["etag"], + }, ) return {"closure": evidence, "bead": mutation} @@ -1581,10 +2069,14 @@ def v2_beads_changeset( mutation = self._parameters(parameters) actions = mutation.pop("actions", None) if not isinstance(actions, list) or not actions: - raise ProtocolError("invalid_request", "Beads changeset requires ordered actions") + raise ProtocolError( + "invalid_request", "Beads changeset requires ordered actions" + ) first = actions[0] if not isinstance(first, Mapping) or first.get("ref") != canonical_ref: - raise ProtocolError("invalid_request", "changeset ref must anchor its first action") + raise ProtocolError( + "invalid_request", "changeset ref must anchor its first action" + ) result = self.beads.changeset( actions, mode=operation, @@ -1592,7 +2084,9 @@ def v2_beads_changeset( preview_digest=mutation.pop("preview_digest", None), ) if mutation: - raise ProtocolError("invalid_request", "Beads changeset received unsupported parameters") + raise ProtocolError( + "invalid_request", "Beads changeset received unsupported parameters" + ) return {"ref": canonical_ref, **result} def v2_beads_operate( @@ -1605,7 +2099,12 @@ def v2_beads_operate( _resource, values, canonical_ref = self._resource_reference( reference, {"project"}, "ref does not identify a canonical project" ) - return {"ref": canonical_ref, **self.beads.operate(values["project_id"], operation, self._parameters(parameters))} + return { + "ref": canonical_ref, + **self.beads.operate( + values["project_id"], operation, self._parameters(parameters) + ), + } async def v2_mcp_change( self, @@ -1618,7 +2117,9 @@ async def v2_mcp_change( reference, {"mcp_tool"}, "ref does not identify a canonical MCP tool" ) if operation != "call": - raise ProtocolError("unsupported_capability", "MCP operation is not declared") + raise ProtocolError( + "unsupported_capability", "MCP operation is not declared" + ) result = await self.mcp_broker.call( values["server"], values["tool"], self._parameters(parameters), write=True ) @@ -1630,7 +2131,10 @@ def v2_desktop_operate( _resource, _values, canonical_ref = self._resource_reference( reference, {"desktop"}, "ref does not identify the canonical desktop" ) - return {"ref": canonical_ref, **self.desktop.action(operation, self._parameters(parameters))} + return { + "ref": canonical_ref, + **self.desktop.action(operation, self._parameters(parameters)), + } def v2_terminal_operate( self, *, reference: str, operation: str, parameters: Mapping[str, Any] | None @@ -1640,7 +2144,9 @@ def v2_terminal_operate( ) arguments = self._parameters(parameters) if "match" in arguments: - raise ProtocolError("invalid_request", "terminal match is derived from the canonical ref") + raise ProtocolError( + "invalid_request", "terminal match is derived from the canonical ref" + ) return { "ref": canonical_ref, **self.terminals.action( @@ -1659,12 +2165,20 @@ def v2_browser_operate( arguments = self._parameters(parameters) if operation == "agent_window": if resource.kind != "browser_workspace": - raise ProtocolError("invalid_request", "agent_window requires the browser workspace ref") + raise ProtocolError( + "invalid_request", "agent_window requires the browser workspace ref" + ) else: if resource.kind != "browser_page": - raise ProtocolError("invalid_request", "browser operation requires a gateway-owned page ref") + raise ProtocolError( + "invalid_request", + "browser operation requires a gateway-owned page ref", + ) if "page_id" in arguments: - raise ProtocolError("invalid_request", "browser page_id is derived from the canonical ref") + raise ProtocolError( + "invalid_request", + "browser page_id is derived from the canonical ref", + ) arguments = {"page_id": values["page_id"], **arguments} result = self.browser.action(operation, arguments) response = {"ref": canonical_ref, **result} @@ -1679,24 +2193,32 @@ def _machine_target(self, reference: str) -> tuple[str, dict[str, Any]]: try: resource, values = REGISTRY.resolve(reference) except RegistryError as exc: - raise ProtocolError("not_found", "canonical machine target was not found") from exc + raise ProtocolError( + "not_found", "canonical machine target was not found" + ) from exc canonical_ref = str(resource.ref_template.format(values)) if resource.kind == "job": return canonical_ref, {"job_id": values["job_id"]} if resource.kind == "machine_unit": if values["manager"] not in {"user", "system"}: - raise ProtocolError("invalid_request", "machine unit manager is not recognized") + raise ProtocolError( + "invalid_request", "machine unit manager is not recognized" + ) return canonical_ref, {"unit": values["unit"]} if resource.kind == "process": try: pid = int(values["pid"]) start_ticks = int(values["start_ticks"]) except ValueError as exc: - raise ProtocolError("invalid_request", "process reference is malformed") from exc + raise ProtocolError( + "invalid_request", "process reference is malformed" + ) from exc if pid <= 1 or start_ticks < 0: raise ProtocolError("invalid_request", "process reference is malformed") return canonical_ref, {"process": {"pid": pid, "start_ticks": start_ticks}} - raise ProtocolError("invalid_request", "reference does not identify an operable machine target") + raise ProtocolError( + "invalid_request", "reference does not identify an operable machine target" + ) @staticmethod def _machine_receipt( @@ -1733,7 +2255,8 @@ def _machine_receipt( or receipt["operator_reason"] != operator_reason ): raise ProtocolError( - "owner_failed", "ops reducer receipt does not match the submitted action" + "owner_failed", + "ops reducer receipt does not match the submitted action", ) return { key: receipt[key] @@ -1768,16 +2291,26 @@ def v2_operate( canonical_ref, target = self._machine_target(reference) values = self._required_preconditions(preconditions, {"expected_revision"}) expected_revision = values.get("expected_revision") - if isinstance(expected_revision, bool) or not isinstance(expected_revision, int) or expected_revision < 0: - raise ProtocolError("invalid_request", "expected_revision must be a non-negative integer") + if ( + isinstance(expected_revision, bool) + or not isinstance(expected_revision, int) + or expected_revision < 0 + ): + raise ProtocolError( + "invalid_request", "expected_revision must be a non-negative integer" + ) if not isinstance(action, str) or action not in MACHINE_OPERATIONS: raise ProtocolError("invalid_request", "machine action is not recognized") if not isinstance(parameters, Mapping): - raise ProtocolError("invalid_request", "machine parameters must be an object") + raise ProtocolError( + "invalid_request", "machine parameters must be an object" + ) if not isinstance(reason, str) or not reason: raise ProtocolError("invalid_request", "machine operation requires reason") if not isinstance(idempotency_key, str) or not idempotency_key: - raise ProtocolError("invalid_request", "machine operation requires idempotency_key") + raise ProtocolError( + "invalid_request", "machine operation requires idempotency_key" + ) owner_receipt = self._machine_receipt( self.machine_actions.execute( action, @@ -1804,9 +2337,13 @@ def v2_cancel_job( try: resource, values = REGISTRY.resolve(reference) except RegistryError as exc: - raise ProtocolError("not_found", "canonical job resource was not found") from exc + raise ProtocolError( + "not_found", "canonical job resource was not found" + ) from exc if resource.kind != "job": - raise ProtocolError("invalid_request", "cancel requires a canonical job reference") + raise ProtocolError( + "invalid_request", "cancel requires a canonical job reference" + ) expected_phase = self._required_preconditions( preconditions, {"expected_phase"} ).get("expected_phase") @@ -1817,7 +2354,8 @@ def v2_cancel_job( status = self._sinnixd_job("job.get", {"job_id": job_id}) if status.get("job_id") != job_id: raise ProtocolError( - "owner_failed", "sinnixd status response does not match the requested job" + "owner_failed", + "sinnixd status response does not match the requested job", ) state = status.get("state") phase = state.get("phase") if isinstance(state, Mapping) else None @@ -1828,7 +2366,8 @@ def v2_cancel_job( cancelled.get("cancel_requested"), bool ): raise ProtocolError( - "owner_failed", "sinnixd cancel response does not prove cancellation truth" + "owner_failed", + "sinnixd cancel response does not prove cancellation truth", ) return { "ref": str(resource.ref_template.format(values)), @@ -1857,14 +2396,20 @@ def v2_wait( try: resource, values = REGISTRY.resolve(reference) except RegistryError as exc: - raise ProtocolError("not_found", "canonical job resource was not found") from exc + raise ProtocolError( + "not_found", "canonical job resource was not found" + ) from exc try: wait_target = WaitTarget(target) except ValueError as exc: - raise ProtocolError("invalid_request", "wait target is not recognized") from exc + raise ProtocolError( + "invalid_request", "wait target is not recognized" + ) from exc if wait_target is WaitTarget.JOB_TERMINAL: if resource.kind != "job": - raise ProtocolError("invalid_request", "job_terminal requires a canonical job reference") + raise ProtocolError( + "invalid_request", "job_terminal requires a canonical job reference" + ) job_id = values["job_id"] self.principal.require(Capability.JOB_READ) result = self._sinnixd_job( @@ -1872,18 +2417,27 @@ def v2_wait( ) if result.get("job_id") != job_id: raise ProtocolError( - "owner_failed", "sinnixd wait response does not match the requested job" + "owner_failed", + "sinnixd wait response does not match the requested job", ) if result.get("timed_out") is True: evidence = result.get("state", result) result = { **result, "outcome": "timeout", - "evidence": evidence if isinstance(evidence, Mapping) else {"value": evidence}, + "evidence": evidence + if isinstance(evidence, Mapping) + else {"value": evidence}, "source_revision": source_revision(evidence), - "continuation": source_revision({"ref": reference, "evidence": evidence}), + "continuation": source_revision( + {"ref": reference, "evidence": evidence} + ), } - return {**result, "ref": REGISTRY.reference("job", {"job_id": job_id}), "target": wait_target.value} + return { + **result, + "ref": REGISTRY.reference("job", {"job_id": job_id}), + "target": wait_target.value, + } if self.waits is None: raise ProtocolError("unavailable", "wait owner is unavailable") try: @@ -1913,13 +2467,26 @@ async def v2_wait_async( resource, values = REGISTRY.resolve(reference) wait_target = WaitTarget(target) except (RegistryError, ValueError) as exc: - raise ProtocolError("invalid_request", "wait target or reference is not recognized") from exc + raise ProtocolError( + "invalid_request", "wait target or reference is not recognized" + ) from exc if wait_target is WaitTarget.JOB_TERMINAL: if resource.kind != "job": - raise ProtocolError("invalid_request", "job_terminal requires a canonical job reference") + raise ProtocolError( + "invalid_request", "job_terminal requires a canonical job reference" + ) self.principal.require(Capability.JOB_READ) if cancelled is not None and cancelled(): - return {"schema": "sinnix.gateway-wait.v1", "outcome": "cancelled", "target": wait_target.value, "ref": reference, "polls": 0, "evidence": {}, "source_revision": "cancelled", "continuation": source_revision({"ref": reference})} + return { + "schema": "sinnix.gateway-wait.v1", + "outcome": "cancelled", + "target": wait_target.value, + "ref": reference, + "polls": 0, + "evidence": {}, + "source_revision": "cancelled", + "continuation": source_revision({"ref": reference}), + } if cancelled is None: result = await anyio.to_thread.run_sync( self._sinnixd_job, @@ -1935,7 +2502,10 @@ async def wait_for_owner() -> None: result_box["result"] = await anyio.to_thread.run_sync( self._sinnixd_job, "job.wait", - {"job_id": values["job_id"], "timeout_seconds": timeout_seconds}, + { + "job_id": values["job_id"], + "timeout_seconds": timeout_seconds, + }, abandon_on_cancel=True, ) task_group.cancel_scope.cancel() @@ -1951,14 +2521,43 @@ async def watch_request() -> None: task_group.start_soon(wait_for_owner) task_group.start_soon(watch_request) if request_cancelled: - return {"schema": "sinnix.gateway-wait.v1", "outcome": "cancelled", "target": wait_target.value, "ref": reference, "polls": 0, "evidence": {}, "source_revision": "cancelled", "continuation": source_revision({"ref": reference})} + return { + "schema": "sinnix.gateway-wait.v1", + "outcome": "cancelled", + "target": wait_target.value, + "ref": reference, + "polls": 0, + "evidence": {}, + "source_revision": "cancelled", + "continuation": source_revision({"ref": reference}), + } result = result_box["result"] if cancelled is not None and cancelled(): evidence = result.get("state", result) - return {"schema": "sinnix.gateway-wait.v1", "outcome": "cancelled", "target": wait_target.value, "ref": reference, "polls": 0, "evidence": evidence if isinstance(evidence, Mapping) else {"value": evidence}, "source_revision": source_revision(evidence), "continuation": source_revision({"ref": reference, "evidence": evidence})} + return { + "schema": "sinnix.gateway-wait.v1", + "outcome": "cancelled", + "target": wait_target.value, + "ref": reference, + "polls": 0, + "evidence": evidence + if isinstance(evidence, Mapping) + else {"value": evidence}, + "source_revision": source_revision(evidence), + "continuation": source_revision( + {"ref": reference, "evidence": evidence} + ), + } if result.get("job_id") != values["job_id"]: - raise ProtocolError("owner_failed", "sinnixd wait response does not match the requested job") - return {**result, "ref": REGISTRY.reference("job", {"job_id": values["job_id"]}), "target": wait_target.value} + raise ProtocolError( + "owner_failed", + "sinnixd wait response does not match the requested job", + ) + return { + **result, + "ref": REGISTRY.reference("job", {"job_id": values["job_id"]}), + "target": wait_target.value, + } if self.waits is None: raise ProtocolError("unavailable", "wait owner is unavailable") try: @@ -1976,53 +2575,104 @@ async def watch_request() -> None: def _resolve_wait(self, request: WaitRequest) -> WaitEvidence: resource, values = REGISTRY.resolve(request.reference) expected = dict(request.expected) - if request.target is WaitTarget.BEAD_STATUS or request.target is WaitTarget.BEAD_REVISION: + if ( + request.target is WaitTarget.BEAD_STATUS + or request.target is WaitTarget.BEAD_REVISION + ): if resource.kind != "bead": raise ValueError("Beads waits require a canonical bead reference") bead = self.beads.get(values["project_id"], values["bead_id"]) - fields = bead.get("fields", {}) if isinstance(bead.get("fields"), Mapping) else {} - current = fields.get("status") if request.target is WaitTarget.BEAD_STATUS else bead.get("task_revision") - wanted = expected.get("status") if request.target is WaitTarget.BEAD_STATUS else expected.get("revision", expected.get("task_revision")) - return WaitEvidence(current == wanted, {"current": current, "bead": bead}, str(bead.get("task_revision", source_revision(bead)))) + fields = ( + bead.get("fields", {}) + if isinstance(bead.get("fields"), Mapping) + else {} + ) + current = ( + fields.get("status") + if request.target is WaitTarget.BEAD_STATUS + else bead.get("task_revision") + ) + wanted = ( + expected.get("status") + if request.target is WaitTarget.BEAD_STATUS + else expected.get("revision", expected.get("task_revision")) + ) + return WaitEvidence( + current == wanted, + {"current": current, "bead": bead}, + str(bead.get("task_revision", source_revision(bead))), + ) if request.target is WaitTarget.UNIT_STATE: if resource.kind != "machine_unit": - raise ValueError("unit waits require a canonical machine unit reference") + raise ValueError( + "unit waits require a canonical machine unit reference" + ) current = self.v2_get(request.reference)["unit"] - state = current.get("active_state", current.get("state")) if isinstance(current, Mapping) else None + state = ( + current.get("active_state", current.get("state")) + if isinstance(current, Mapping) + else None + ) wanted = expected.get("state", expected.get("active_state")) - return WaitEvidence(state == wanted, {"state": state, "unit": current}, source_revision(current)) + return WaitEvidence( + state == wanted, + {"state": state, "unit": current}, + source_revision(current), + ) if request.target is WaitTarget.FILE_HASH: if resource.kind != "host_file": raise ValueError("file waits require a canonical host file reference") - current = self.files.read("stat", self._decode_file_token(values["file_token"])) + current = self.files.read( + "stat", self._decode_file_token(values["file_token"]) + ) wanted = expected.get("sha256") or expected.get("hash") - return WaitEvidence(current.get("sha256") == wanted, current, str(current.get("sha256"))) + return WaitEvidence( + current.get("sha256") == wanted, current, str(current.get("sha256")) + ) if request.target is WaitTarget.CAPTURE_FRESHNESS: if resource.kind != "capture_lane": - raise ValueError("capture waits require a canonical capture lane reference") + raise ValueError( + "capture waits require a canonical capture lane reference" + ) lane = self.captures.lane(values["lane"]) path = Path(str(lane["path"])) mtime = path.stat().st_mtime if path.exists() else 0.0 age = max(0.0, time.time() - mtime) if mtime else None max_age = float(expected.get("max_age_seconds", 0)) - return WaitEvidence(age is not None and age <= max_age, {"mtime": mtime, "age_seconds": age, "lane": lane}, source_revision({"mtime": mtime, "lane": lane.get("name")})) + return WaitEvidence( + age is not None and age <= max_age, + {"mtime": mtime, "age_seconds": age, "lane": lane}, + source_revision({"mtime": mtime, "lane": lane.get("name")}), + ) if request.target is WaitTarget.RECEIPT_APPEARANCE: if resource.kind != "receipt": raise ValueError("receipt waits require a canonical receipt reference") try: receipt = self.audit.receipt(values["receipt_id"]) except ValueError: - return WaitEvidence(False, {"available": False, "ref": request.reference}, "missing") - return WaitEvidence(True, {"available": True, "receipt": receipt}, str(receipt["entry_hash"])) + return WaitEvidence( + False, {"available": False, "ref": request.reference}, "missing" + ) + return WaitEvidence( + True, + {"available": True, "receipt": receipt}, + str(receipt["entry_hash"]), + ) raise ValueError(f"wait target {request.target.value} is not implemented") def v2_jobs_query(self, parameters: Mapping[str, Any] | None) -> dict[str, Any]: values = self._parameters(parameters) if set(values) - {"limit", "cursor"}: - raise ProtocolError("invalid_request", "jobs.query parameters are not recognized") + raise ProtocolError( + "invalid_request", "jobs.query parameters are not recognized" + ) limit = values.get("limit", 100) cursor = values.get("cursor") - if not isinstance(limit, int) or isinstance(limit, bool) or not 1 <= limit <= 1_000: + if ( + not isinstance(limit, int) + or isinstance(limit, bool) + or not 1 <= limit <= 1_000 + ): raise ProtocolError("invalid_request", "jobs.query limit must be 1-1000") if cursor is not None and ( not isinstance(cursor, str) or not 1 <= len(cursor.encode()) <= 512 @@ -2034,10 +2684,16 @@ def v2_jobs_query(self, parameters: Mapping[str, Any] | None) -> dict[str, Any]: arguments["cursor"] = cursor response = self._sinnixd_job("job.list", arguments) jobs = response.get("jobs") - if not isinstance(jobs, list) or any(not isinstance(job, Mapping) for job in jobs): - raise ProtocolError("owner_failed", "sinnixd job list response is malformed") + if not isinstance(jobs, list) or any( + not isinstance(job, Mapping) for job in jobs + ): + raise ProtocolError( + "owner_failed", "sinnixd job list response is malformed" + ) if len(jobs) > limit: - raise ProtocolError("owner_failed", "sinnixd job list response exceeds its bound") + raise ProtocolError( + "owner_failed", "sinnixd job list response exceeds its bound" + ) total = response.get("total") truncated = response.get("truncated") next_cursor = response.get("next_cursor") @@ -2059,14 +2715,20 @@ def v2_jobs_query(self, parameters: Mapping[str, Any] | None) -> dict[str, Any]: or len(snapshot["ceiling"]) != 2 or any(not isinstance(value, str) for value in snapshot["ceiling"]) ): - raise ProtocolError("owner_failed", "sinnixd job list response omits paging metadata") + raise ProtocolError( + "owner_failed", "sinnixd job list response omits paging metadata" + ) if truncated != (next_cursor is not None): - raise ProtocolError("owner_failed", "sinnixd job list paging metadata is inconsistent") + raise ProtocolError( + "owner_failed", "sinnixd job list paging metadata is inconsistent" + ) rows: list[dict[str, Any]] = [] for job in jobs: job_id = job.get("job_id") if not isinstance(job_id, str) or not job_id: - raise ProtocolError("owner_failed", "sinnixd job list response omitted a job ID") + raise ProtocolError( + "owner_failed", "sinnixd job list response omitted a job ID" + ) rows.append({"ref": REGISTRY.reference("job", {"job_id": job_id}), **job}) return { "jobs": rows, @@ -2164,7 +2826,9 @@ def _record_v2_receipt( created_objects: list[str] = [] if isinstance(result, Mapping): for key, value in result.items(): - if key in {"artifact_id", "diagnostic_artifact_id"} and isinstance(value, str): + if key in {"artifact_id", "diagnostic_artifact_id"} and isinstance( + value, str + ): artifact_refs.append(f"sinnix://artifacts/{value}") elif key == "created" and value is True: created_objects.append(action.name) @@ -2197,14 +2861,20 @@ def _record_v2_receipt( "idempotency_key": context.idempotency_key, "target_refs": sorted(set(target_refs)), "owner": action.owner, - "owner_route": owner_route if isinstance(owner_route, str) else action.route, - "owner_version": owner_version if isinstance(owner_version, (str, int)) else REGISTRY.revision, + "owner_route": owner_route + if isinstance(owner_route, str) + else action.route, + "owner_version": owner_version + if isinstance(owner_version, (str, int)) + else REGISTRY.revision, "preconditions": dict(context.preconditions or {}), "before_refs": before_refs, "after_refs": after_refs, "before_revision": before_revision, "after_revision": after_revision, - "owner_history_ref": owner_history_ref if isinstance(owner_history_ref, str) else None, + "owner_history_ref": owner_history_ref + if isinstance(owner_history_ref, str) + else None, "effects": sorted(effect.value for effect in action.storage_effects), "created_objects": created_objects, "artifact_refs": sorted(set(artifact_refs)), @@ -2228,7 +2898,9 @@ def _record_v2_receipt( (isinstance(result, Mapping) and result.get("partial_completion")) or (error and error.get("code") == "partial_completion") ), - "compensation": result.get("compensation") if isinstance(result, Mapping) else None, + "compensation": result.get("compensation") + if isinstance(result, Mapping) + else None, "error": dict(error or {}), } return self.audit.append(action.name, outcome, payload) @@ -2259,17 +2931,27 @@ def _request_context(request: Mapping[str, Any]) -> RequestContext: preconditions = raw.get("preconditions") if request_id is not None and not isinstance(request_id, str): raise ProtocolError("invalid_request", "request_id must be a string") - for name, value in (("actor", actor), ("reason", reason), ("idempotency_key", idempotency_key)): + for name, value in ( + ("actor", actor), + ("reason", reason), + ("idempotency_key", idempotency_key), + ): if value is not None and (not isinstance(value, str) or not value): - raise ProtocolError("invalid_request", f"{name} must be a non-empty string") + raise ProtocolError( + "invalid_request", f"{name} must be a non-empty string" + ) if deadline_at is not None and not isinstance(deadline_at, (int, float)): - raise ProtocolError("invalid_request", "deadline_at must be a Unix timestamp") + raise ProtocolError( + "invalid_request", "deadline_at must be a Unix timestamp" + ) if preconditions is not None and not isinstance(preconditions, Mapping): raise ProtocolError("invalid_request", "preconditions must be an object") try: encoded = json.dumps(raw, sort_keys=True, separators=(",", ":")).encode() except (TypeError, ValueError) as exc: - raise ProtocolError("invalid_request", "V2 request is not JSON serializable") from exc + raise ProtocolError( + "invalid_request", "V2 request is not JSON serializable" + ) from exc return RequestContext.create( hashlib.sha256(encoded).hexdigest(), request_id=request_id, @@ -2380,10 +3062,13 @@ def _claim_v2_idempotency( return None if not action.supports_idempotency: raise ProtocolError( - "invalid_request", "mutating action does not declare idempotency support" + "invalid_request", + "mutating action does not declare idempotency support", ) if context.idempotency_key is None: - raise ProtocolError("invalid_request", "mutating action requires idempotency_key") + raise ProtocolError( + "invalid_request", "mutating action requires idempotency_key" + ) state, response = self.audit.claim_idempotency( action.name, context.idempotency_key, context.request_sha256 ) @@ -2391,7 +3076,9 @@ def _claim_v2_idempotency( return None if state == "replay": if not isinstance(response, dict): - raise ProtocolError("unavailable", "stored idempotency response is malformed") + raise ProtocolError( + "unavailable", "stored idempotency response is malformed" + ) return response if state == "conflict": raise ProtocolError( @@ -2432,9 +3119,13 @@ def execute_v2( f"principal {self.principal_name!r} cannot invoke action {action.name!r}" ) if context.preconditions and not action.supports_precondition: - raise ProtocolError("invalid_request", "action does not support preconditions") + raise ProtocolError( + "invalid_request", "action does not support preconditions" + ) if context.deadline_at is not None and time.time() >= context.deadline_at: - raise ProtocolError("deadline", "request deadline elapsed before execution") + raise ProtocolError( + "deadline", "request deadline elapsed before execution" + ) replay = self._claim_v2_idempotency(action, context) if replay is not None: return replay @@ -2470,9 +3161,13 @@ async def execute_v2_async( f"principal {self.principal_name!r} cannot invoke action {action.name!r}" ) if context.preconditions and not action.supports_precondition: - raise ProtocolError("invalid_request", "action does not support preconditions") + raise ProtocolError( + "invalid_request", "action does not support preconditions" + ) if context.deadline_at is not None and time.time() >= context.deadline_at: - raise ProtocolError("deadline", "request deadline elapsed before execution") + raise ProtocolError( + "deadline", "request deadline elapsed before execution" + ) replay = self._claim_v2_idempotency(action, context) if replay is not None: return replay @@ -2506,7 +3201,9 @@ def execute_v2_jsonl( try: context = self._request_context(request) if context.deadline_at is not None and time.time() >= context.deadline_at: - raise ProtocolError("deadline", "request deadline elapsed before execution") + raise ProtocolError( + "deadline", "request deadline elapsed before execution" + ) writer = self.results.start_snapshot( query_sha256=context.request_sha256, source_revision=source_revision, @@ -2530,7 +3227,13 @@ def execute_v2_jsonl( receipt=receipt, request=context, ) - except (OwnerDiagnosticError, ProtocolError, ResultError, PolicyError, ValueError) as exc: + except ( + OwnerDiagnosticError, + ProtocolError, + ResultError, + PolicyError, + ValueError, + ) as exc: if writer is not None: writer.abort() return self._v2_failure(action, exc, context) @@ -2539,7 +3242,9 @@ def execute(self, operation: str, callback: Callable[[], T]) -> T: try: result = callback() except OwnerDiagnosticError as exc: - self.audit.append(operation, "error", self._diagnostic_payload(exc.response)) + self.audit.append( + operation, "error", self._diagnostic_payload(exc.response) + ) return cast(T, {"operation": operation, **exc.response}) except Exception as exc: message = public_error(exc) @@ -2554,7 +3259,9 @@ async def execute_async( try: result = await callback() except OwnerDiagnosticError as exc: - self.audit.append(operation, "error", self._diagnostic_payload(exc.response)) + self.audit.append( + operation, "error", self._diagnostic_payload(exc.response) + ) return cast(T, {"operation": operation, **exc.response}) except Exception as exc: message = public_error(exc) diff --git a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/server.py b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/server.py index e5ed1660..5eff329d 100644 --- a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/server.py +++ b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/server.py @@ -10,18 +10,23 @@ from typing import Any, Callable, Mapping, cast import anyio - from mcp.server import MCPServer from mcp.server.mcpserver.context import Context from mcp.server.subscriptions import InMemorySubscriptionBus -from mcp.types import ListResourceTemplatesResult, PaginatedRequestParams, ResourceTemplate +from mcp.types import ( + ListResourceTemplatesResult, + PaginatedRequestParams, + ResourceTemplate, +) from .bindings import TargetToolBinding, TargetToolBindings from .config import GatewayConfig from .contracts import ActionSpec, EffectMode, OwnerRoute, VerbFamily -from .registry import CatalogSearch, REGISTRY, RegistryError -from .results import ProtocolError -from .results import derive_cursor_key +from .mcp_broker import McpEnvironmentError +from .parity import legacy_parity_contract +from .prompts import PROMPT_SPECS, PromptGenerator +from .registry import REGISTRY, CatalogSearch, RegistryError +from .results import ProtocolError, derive_cursor_key from .runtime import ( AUDITED_READ_TOOL, IDEMPOTENT_MUTATION_TOOL, @@ -31,9 +36,6 @@ canonical_manifest, v2_tool_result, ) -from .parity import legacy_parity_contract -from .mcp_broker import McpEnvironmentError -from .prompts import PromptGenerator, PROMPT_SPECS from .schemas import V2ManifestEnvelope from .subscriptions import OwnerRevisionPublisher @@ -55,7 +57,9 @@ def _bounded_resource_json(runtime: Runtime, payload: Any, kind: str) -> str: "truncated": True, "artifact": artifact, } - encoded_envelope = json.dumps(envelope, sort_keys=True, separators=(",", ":")).encode() + encoded_envelope = json.dumps( + envelope, sort_keys=True, separators=(",", ":") + ).encode() if len(encoded_envelope) > runtime.config.max_result_bytes: return json.dumps( {"artifact_id": artifact["artifact_id"], "truncated": True}, @@ -92,7 +96,9 @@ async def _query_owner( route = action.route if route is OwnerRoute.PROJECTS_SEARCH: if not isinstance(reference, str) or not isinstance(text, str): - raise ProtocolError("invalid_request", "projects.query requires ref and query") + raise ProtocolError( + "invalid_request", "projects.query requires ref and query" + ) return runtime.v2_query(reference, text, max_matches) if route is OwnerRoute.BEADS_QUERY: return runtime.v2_beads_query(values) @@ -104,43 +110,94 @@ async def _query_owner( OwnerRoute.PROJECTS_DIFF, }: if not isinstance(reference, str): - raise ProtocolError("invalid_request", f"{action.name} requires a canonical ref") - project_id, checkout_id, canonical_ref = runtime._project_reference(reference, allow_checkout=True) + raise ProtocolError( + "invalid_request", f"{action.name} requires a canonical ref" + ) + project_id, checkout_id, canonical_ref = runtime._project_reference( + reference, allow_checkout=True + ) if route is OwnerRoute.PROJECTS_TREE: - return {"ref": canonical_ref, **runtime.projects.tree(project_id, str(values.get("path", ".")), int(values.get("max_entries", 500)), checkout_id)} + return { + "ref": canonical_ref, + **runtime.projects.tree( + project_id, + str(values.get("path", ".")), + int(values.get("max_entries", 500)), + checkout_id, + ), + } if route is OwnerRoute.PROJECTS_READ: path = values.get("path") if not isinstance(path, str): - raise ProtocolError("invalid_request", "projects.read requires parameters.path") - return {"ref": canonical_ref, **runtime.projects.read(project_id, path, int(values.get("start_line", 1)), values.get("end_line"), int(values.get("max_bytes", 64_000)), checkout_id)} - return {"ref": canonical_ref, **runtime.projects.diff(project_id, values.get("git_ref"), checkout_id)} + raise ProtocolError( + "invalid_request", "projects.read requires parameters.path" + ) + return { + "ref": canonical_ref, + **runtime.projects.read( + project_id, + path, + int(values.get("start_line", 1)), + values.get("end_line"), + int(values.get("max_bytes", 64_000)), + checkout_id, + ), + } + return { + "ref": canonical_ref, + **runtime.projects.diff(project_id, values.get("git_ref"), checkout_id), + } if route is OwnerRoute.OBSERVE_MACHINE_QUERY: operation = values.get("operation") if not isinstance(operation, str): - raise ProtocolError("invalid_request", "machine.query requires parameters.operation") + raise ProtocolError( + "invalid_request", "machine.query requires parameters.operation" + ) if operation == "actions": if int(values.get("cursor", 0)) != 0: - raise ProtocolError("invalid_request", "machine actions snapshot does not support a cursor") + raise ProtocolError( + "invalid_request", + "machine actions snapshot does not support a cursor", + ) return runtime.machine_actions.snapshot() - return runtime.observe.machine_query(operation, int(values.get("cursor", 0)), int(values.get("limit", 100))) + return runtime.observe.machine_query( + operation, int(values.get("cursor", 0)), int(values.get("limit", 100)) + ) if route is OwnerRoute.CAPABILITY_INDEX_QUERY: if values.get("operation", "search") == "describe": name = values.get("name") if not isinstance(name, str): - raise ProtocolError("invalid_request", "capability description requires parameters.name") + raise ProtocolError( + "invalid_request", "capability description requires parameters.name" + ) return runtime.capability_index.describe(name, values.get("kind")) - return runtime.capability_index.search(str(values.get("query", "")), values.get("kind"), values.get("enabled"), int(values.get("cursor", 0)), int(values.get("limit", 100))) + return runtime.capability_index.search( + str(values.get("query", "")), + values.get("kind"), + values.get("enabled"), + int(values.get("cursor", 0)), + int(values.get("limit", 100)), + ) if route is OwnerRoute.MCP_CALL_READ: if values.get("operation", "catalog") == "catalog": if reference is not None: - raise ProtocolError("invalid_request", "MCP catalog does not accept a target ref") + raise ProtocolError( + "invalid_request", "MCP catalog does not accept a target ref" + ) if set(values) - {"operation"}: - raise ProtocolError("invalid_request", "MCP catalog accepts no tool arguments") + raise ProtocolError( + "invalid_request", "MCP catalog accepts no tool arguments" + ) return await runtime.mcp_broker.catalog() if values.get("operation") != "call" or not isinstance(reference, str): - raise ProtocolError("invalid_request", "MCP calls require operation=call and a canonical tool ref") + raise ProtocolError( + "invalid_request", + "MCP calls require operation=call and a canonical tool ref", + ) if set(values) - {"operation", "arguments"}: - raise ProtocolError("invalid_request", "MCP calls accept only declared tool arguments") + raise ProtocolError( + "invalid_request", "MCP calls accept only declared tool arguments" + ) arguments = values.get("arguments") if not isinstance(arguments, Mapping): raise ProtocolError("invalid_request", "MCP calls require arguments") @@ -156,7 +213,9 @@ async def _query_owner( return {"ref": _canonical_ref, **result} if route is OwnerRoute.DESKTOP_READ: if not isinstance(reference, str): - raise ProtocolError("invalid_request", "desktop.query requires the canonical desktop ref") + raise ProtocolError( + "invalid_request", "desktop.query requires the canonical desktop ref" + ) _resource, _target, canonical_ref = runtime._resource_reference( reference, {"desktop"}, "desktop.query requires the canonical desktop ref" ) @@ -167,26 +226,38 @@ async def _query_owner( } operation = values.get("operation") if not isinstance(operation, str): - raise ProtocolError("invalid_request", "desktop.query requires parameters.operation") + raise ProtocolError( + "invalid_request", "desktop.query requires parameters.operation" + ) return {"ref": canonical_ref, **runtime.desktop.read(operation)} if route is OwnerRoute.TERMINALS_READ: operation = values.get("operation") if not isinstance(operation, str): - raise ProtocolError("invalid_request", "terminals.query requires parameters.operation") + raise ProtocolError( + "invalid_request", "terminals.query requires parameters.operation" + ) if operation == "list": if reference is not None: - raise ProtocolError("invalid_request", "terminal list does not accept a target ref") + raise ProtocolError( + "invalid_request", "terminal list does not accept a target ref" + ) return runtime.terminals.read(operation) if operation != "capture" or not isinstance(reference, str): - raise ProtocolError("invalid_request", "terminal capture requires a canonical terminal ref") + raise ProtocolError( + "invalid_request", "terminal capture requires a canonical terminal ref" + ) _resource, target, canonical_ref = runtime._resource_reference( - reference, {"terminal"}, "terminal capture requires a canonical terminal ref" + reference, + {"terminal"}, + "terminal capture requires a canonical terminal ref", ) arguments = values.get("arguments") if not isinstance(arguments, Mapping): arguments = {} if "match" in arguments: - raise ProtocolError("invalid_request", "terminal match is derived from the canonical ref") + raise ProtocolError( + "invalid_request", "terminal match is derived from the canonical ref" + ) return { "ref": canonical_ref, **runtime.terminals.read( @@ -197,18 +268,30 @@ async def _query_owner( if route is OwnerRoute.BROWSER_READ: operation = values.get("operation") if not isinstance(operation, str): - raise ProtocolError("invalid_request", "browser.query requires parameters.operation") + raise ProtocolError( + "invalid_request", "browser.query requires parameters.operation" + ) if operation in {"status", "list", "list_tabs"}: if reference is not None: - raise ProtocolError("invalid_request", f"browser {operation} does not accept a target ref") + raise ProtocolError( + "invalid_request", + f"browser {operation} does not accept a target ref", + ) return runtime.browser.read(operation) if not isinstance(reference, str): - raise ProtocolError("invalid_request", "browser target reads require a canonical browser page ref") + raise ProtocolError( + "invalid_request", + "browser target reads require a canonical browser page ref", + ) _resource, target, canonical_ref = runtime._resource_reference( - reference, {"browser_page"}, "browser target reads require a canonical browser page ref" + reference, + {"browser_page"}, + "browser target reads require a canonical browser page ref", ) if values.get("page_id") is not None: - raise ProtocolError("invalid_request", "browser page_id is derived from the canonical ref") + raise ProtocolError( + "invalid_request", "browser page_id is derived from the canonical ref" + ) page_id = target["page_id"] if operation == "capture": return { @@ -227,12 +310,17 @@ async def _query_owner( if route is OwnerRoute.FILES_READ: operation = values.get("operation") if not isinstance(operation, str) or not isinstance(reference, str): - raise ProtocolError("invalid_request", "files.query requires operation and a canonical host-file ref") + raise ProtocolError( + "invalid_request", + "files.query requires operation and a canonical host-file ref", + ) _resource, target, canonical_ref = runtime._resource_reference( reference, {"host_file"}, "files.query requires a canonical host-file ref" ) if values.get("path") is not None: - raise ProtocolError("invalid_request", "file path is derived from the canonical ref") + raise ProtocolError( + "invalid_request", "file path is derived from the canonical ref" + ) return { "ref": canonical_ref, **runtime.files.read( @@ -246,24 +334,56 @@ async def _query_owner( if route is OwnerRoute.SESSIONS_QUERY: operation = values.get("operation") if operation == "list": - return runtime.sessions.list(str(values.get("provider")), int(values.get("limit", 100))) + return runtime.sessions.list( + str(values.get("provider")), int(values.get("limit", 100)) + ) if operation == "read": - return runtime.sessions.read(str(values.get("reference")), int(values.get("offset", 0)), int(values.get("max_bytes", 64_000))) + return runtime.sessions.read( + str(values.get("reference")), + int(values.get("offset", 0)), + int(values.get("max_bytes", 64_000)), + ) if operation == "search": - return runtime.sessions.search(str(values.get("provider")), str(values.get("query")), int(values.get("max_results", 100))) - raise ProtocolError("invalid_request", "sessions.query operation is not recognized") + return runtime.sessions.search( + str(values.get("provider")), + str(values.get("query")), + int(values.get("max_results", 100)), + ) + raise ProtocolError( + "invalid_request", "sessions.query operation is not recognized" + ) if route is OwnerRoute.MEMORY_QUERY: if values.get("operation", "search") == "get": - return runtime.memory.get(str(values.get("reference")), int(values.get("offset", 0)), int(values.get("max_bytes", 64_000))) - return runtime.memory.search(str(values.get("query")), values.get("providers"), int(values.get("limit", 100))) + return runtime.memory.get( + str(values.get("reference")), + int(values.get("offset", 0)), + int(values.get("max_bytes", 64_000)), + ) + return runtime.memory.search( + str(values.get("query")), + values.get("providers"), + int(values.get("limit", 100)), + ) if route is OwnerRoute.TIMELINE_QUERY: - return runtime.timeline.query(values.get("start"), values.get("end"), values.get("query"), values.get("providers"), int(values.get("limit", 100))) + return runtime.timeline.query( + values.get("start"), + values.get("end"), + values.get("query"), + values.get("providers"), + int(values.get("limit", 100)), + ) if route is OwnerRoute.ARTIFACTS_QUERY: if values.get("operation", "list") == "read": artifact_id = values.get("artifact_id") if not isinstance(artifact_id, str): - raise ProtocolError("invalid_request", "artifact read requires parameters.artifact_id") - return runtime.artifacts.read(artifact_id, int(values.get("offset", 0)), int(values.get("max_bytes", 64_000))) + raise ProtocolError( + "invalid_request", "artifact read requires parameters.artifact_id" + ) + return runtime.artifacts.read( + artifact_id, + int(values.get("offset", 0)), + int(values.get("max_bytes", 64_000)), + ) return runtime.artifacts.list(int(values.get("limit", 100))) if route is OwnerRoute.AUDIT_VERIFY: return runtime.audit.verify() @@ -273,15 +393,24 @@ async def _query_owner( operation = values.pop("operation", "lanes") if operation == "lanes": if values: - raise ProtocolError("invalid_request", "capture-lane listing accepts no other parameters") + raise ProtocolError( + "invalid_request", + "capture-lane listing accepts no other parameters", + ) return runtime.captures.lanes_visible() if operation != "query": - raise ProtocolError("invalid_request", "captures.query operation is not recognized") + raise ProtocolError( + "invalid_request", "captures.query operation is not recognized" + ) unsupported = set(values).difference({"lanes", "since", "limit"}) if unsupported: - raise ProtocolError("invalid_request", "captures.query received unsupported parameters") + raise ProtocolError( + "invalid_request", "captures.query received unsupported parameters" + ) return runtime.captures.query(**values) - raise ProtocolError("unsupported_capability", f"query action {action.name!r} has no owner handler") + raise ProtocolError( + "unsupported_capability", f"query action {action.name!r} has no owner handler" + ) def create_server(config: GatewayConfig, principal_name: str) -> MCPServer: @@ -359,17 +488,24 @@ def gateway_v2_resource_contract(resource_kind: str) -> str: @mcp.resource("sinnix://results/{result_id}") def gateway_v2_result(result_id: str) -> str: """Return one immutable V2 result snapshot for the active principal.""" - return _bounded_resource_json(runtime, runtime.results.read(result_id), "result") + return _bounded_resource_json( + runtime, runtime.results.read(result_id), "result" + ) @mcp.resource("sinnix://receipts/{receipt_id}") def gateway_v2_receipt(receipt_id: str) -> str: """Return one principal-scoped audit receipt behind its canonical ref.""" - return _bounded_resource_json(runtime, runtime.audit.receipt(receipt_id), "receipt") + return _bounded_resource_json( + runtime, runtime.audit.receipt(receipt_id), "receipt" + ) def register_canonical_templates() -> None: """Register only principal-visible canonical owner templates.""" for resource in REGISTRY.resources: - if principal_name not in resource.principals or resource.kind in {"result", "receipt"}: + if principal_name not in resource.principals or resource.kind in { + "result", + "receipt", + }: continue variables = resource.ref_template.variables @@ -388,7 +524,10 @@ async def read_template(_resource=resource, **values: str) -> str: None, ) if match is None: - raise ProtocolError("not_found", "MCP tool is not in the current admitted catalog") + raise ProtocolError( + "not_found", + "MCP tool is not in the current admitted catalog", + ) payload = {"ref": reference, "kind": "mcp_tool", "tool": match} else: payload = runtime.v2_get(reference) @@ -418,15 +557,23 @@ async def read_template(_resource=resource, **values: str) -> str: def template_cursor(offset: int) -> str: body = json.dumps( - {"revision": REGISTRY.revision, "principal": principal_name, "offset": offset}, + { + "revision": REGISTRY.revision, + "principal": principal_name, + "offset": offset, + }, sort_keys=True, separators=(",", ":"), ).encode() encoded = base64.urlsafe_b64encode(body).decode().rstrip("=") - mac = hmac.new(template_cursor_key, encoded.encode(), hashlib.sha256).hexdigest() + mac = hmac.new( + template_cursor_key, encoded.encode(), hashlib.sha256 + ).hexdigest() cursor = f"{encoded}.{mac}" if len(cursor.encode()) > 4_096: - raise ProtocolError("response_bound", "resource template cursor exceeds its size bound") + raise ProtocolError( + "response_bound", "resource template cursor exceeds its size bound" + ) return cursor def template_offset(cursor: str | None) -> int: @@ -436,27 +583,46 @@ def template_offset(cursor: str | None) -> int: if len(cursor.encode()) > 4_096: raise ValueError encoded, mac = cursor.rsplit(".", 1) - expected = hmac.new(template_cursor_key, encoded.encode(), hashlib.sha256).hexdigest() + expected = hmac.new( + template_cursor_key, encoded.encode(), hashlib.sha256 + ).hexdigest() if not hmac.compare_digest(mac, expected): raise ValueError body = json.loads( base64.urlsafe_b64decode(encoded + "=" * (-len(encoded) % 4)).decode() ) - if body.get("revision") != REGISTRY.revision or body.get("principal") != principal_name: + if ( + body.get("revision") != REGISTRY.revision + or body.get("principal") != principal_name + ): raise ValueError offset = body.get("offset") if not isinstance(offset, int) or isinstance(offset, bool) or offset < 0: raise ValueError return offset - except (ValueError, TypeError, json.JSONDecodeError, UnicodeDecodeError, binascii.Error) as exc: - raise ProtocolError("stale_cursor", "resource template cursor is stale or out of scope") from exc - - async def list_resource_templates(_ctx: Any, params: PaginatedRequestParams) -> ListResourceTemplatesResult: + except ( + ValueError, + TypeError, + json.JSONDecodeError, + UnicodeDecodeError, + binascii.Error, + ) as exc: + raise ProtocolError( + "stale_cursor", "resource template cursor is stale or out of scope" + ) from exc + + async def list_resource_templates( + _ctx: Any, params: PaginatedRequestParams + ) -> ListResourceTemplatesResult: templates = await mcp.list_resource_templates() offset = template_offset(params.cursor) page_size = 16 - page = templates[offset:offset + page_size] - next_cursor = template_cursor(offset + page_size) if offset + page_size < len(templates) else None + page = templates[offset : offset + page_size] + next_cursor = ( + template_cursor(offset + page_size) + if offset + page_size < len(templates) + else None + ) return ListResourceTemplatesResult( resource_templates=[ ResourceTemplate( @@ -485,8 +651,11 @@ async def list_resource_templates(_ctx: Any, params: PaginatedRequestParams) -> catalog=lambda principal: REGISTRY.search(CatalogSearch(principal=principal)), ) for prompt_spec in PROMPT_SPECS: + def make_prompt(name: str): - def generated_prompt(ref: str, job_ref: str | None = None) -> list[dict[str, Any]]: + def generated_prompt( + ref: str, job_ref: str | None = None + ) -> list[dict[str, Any]]: return prompt_generator.generate(name, {"ref": ref, "job_ref": job_ref}) generated_prompt.__name__ = name @@ -573,7 +742,9 @@ def catalog( preconditions: dict[str, Any] | None = None, ) -> V2ManifestEnvelope: """Search the principal-filtered V2 resource and executable action catalog.""" - action = target_bindings.action_for_tool("catalog", principal=principal_name) + action = target_bindings.action_for_tool( + "catalog", principal=principal_name + ) response = runtime.execute_v2( action, lambda: runtime.catalog( @@ -627,7 +798,9 @@ def get( action = target_bindings.action_for_tool("get", principal=principal_name) response = runtime.execute_v2( action, - lambda: runtime.v2_get(ref, projection, offset, max_bytes, includes, as_of), + lambda: runtime.v2_get( + ref, projection, offset, max_bytes, includes, as_of + ), { "ref": ref, "projection": projection, @@ -669,7 +842,9 @@ async def query( """ selector_error: ProtocolError | None = None try: - action = target_bindings.action_for_tool("query", action_name, principal_name) + action = target_bindings.action_for_tool( + "query", action_name, principal_name + ) except RegistryError as error: action = target_bindings.fallback_for_tool("query", principal_name) failure = selector_failure("query", error) @@ -720,7 +895,10 @@ async def context( preconditions: dict[str, Any] | None = None, ) -> V2ManifestEnvelope: """Compose project, assigned Beads-task, or evidence-review context.""" - action = target_bindings.action_for_tool("context", principal=principal_name) + action = target_bindings.action_for_tool( + "context", principal=principal_name + ) + async def callback() -> dict[str, Any]: return runtime.v2_context(ref, intent, job_ref) @@ -757,6 +935,7 @@ async def events( ) -> V2ManifestEnvelope: """Read bounded audit events visible to the active principal.""" action = target_bindings.action_for_tool("events", principal=principal_name) + async def callback() -> dict[str, Any]: return runtime.v2_events(limit, cursor, project_ids) @@ -893,54 +1072,71 @@ def callback() -> dict[str, Any]: else: if action.route is OwnerRoute.JOB_SHELL_START: - callback = lambda: runtime.v2_run_shell( - project_id=project_id, - checkout_id=checkout_id, - argv=argv, - cwd=cwd, - timeout_seconds=3_600 if timeout_seconds is None else timeout_seconds, - ) + + def callback(): + return runtime.v2_run_shell( + project_id=project_id, + checkout_id=checkout_id, + argv=argv, + cwd=cwd, + timeout_seconds=3_600 + if timeout_seconds is None + else timeout_seconds, + ) elif action.route is OwnerRoute.JOB_AGENT_START: - callback = lambda: runtime.v2_run_for_bead( - reference=ref, - checkout_id=checkout_id, - claim_mode=claim_mode, - assignment_ref=assignment_ref, - instructions=instructions, - backend=backend, - model=model, - reasoning_effort=reasoning_effort, - timeout_seconds=3_600 if timeout_seconds is None else timeout_seconds, - credential_profile=credential_profile, - request_id=request_id, - ) + + def callback(): + return runtime.v2_run_for_bead( + reference=ref, + checkout_id=checkout_id, + claim_mode=claim_mode, + assignment_ref=assignment_ref, + instructions=instructions, + backend=backend, + model=model, + reasoning_effort=reasoning_effort, + timeout_seconds=3_600 + if timeout_seconds is None + else timeout_seconds, + credential_profile=credential_profile, + request_id=request_id, + ) elif action.route is OwnerRoute.JOB_START: - if any( - value is not None - for value in ( - checkout_id, - argv, - prompt, - backend, - model, - reasoning_effort, - timeout_seconds, + if ( + any( + value is not None + for value in ( + checkout_id, + argv, + prompt, + backend, + model, + reasoning_effort, + timeout_seconds, + ) ) - ) or credential_profile != "subscription" or cwd != ".": + or credential_profile != "subscription" + or cwd != "." + ): + def callback() -> dict[str, Any]: raise ProtocolError( "invalid_request", "declared operations do not accept command, agent, or timeout overlays", ) else: - callback = lambda: runtime.v2_run_declared_operation( - project_id=project_id, - operation=operation, - workspace_id=workspace_id, - parameters=parameters, - ) + + def callback(): + return runtime.v2_run_declared_operation( + project_id=project_id, + operation=operation, + workspace_id=workspace_id, + parameters=parameters, + ) else: - raise RegistryError(f"run action {action.name!r} is not implemented") + raise RegistryError( + f"run action {action.name!r} is not implemented" + ) response = runtime.execute_v2( action, callback, request, selector_error=selector_error ) @@ -988,6 +1184,7 @@ async def callback() -> dict[str, Any]: raise failure else: + async def callback() -> dict[str, Any]: if action.route is OwnerRoute.PROJECTS_CHANGE: return runtime.v2_change( @@ -1014,7 +1211,10 @@ async def callback() -> dict[str, Any]: ) if action.route is OwnerRoute.BEADS_CHANGESET: if preconditions is not None: - raise ProtocolError("invalid_request", "Beads changeset preconditions belong to individual actions") + raise ProtocolError( + "invalid_request", + "Beads changeset preconditions belong to individual actions", + ) return runtime.v2_beads_changeset( reference=ref, operation=operation, @@ -1024,7 +1224,9 @@ async def callback() -> dict[str, Any]: return await runtime.v2_mcp_change( reference=ref, operation=operation, parameters=parameters ) - raise RegistryError(f"change action {action.name!r} is not implemented") + raise RegistryError( + f"change action {action.name!r} is not implemented" + ) response = await runtime.execute_v2_async( action, callback, request, selector_error=selector_error @@ -1033,7 +1235,10 @@ async def callback() -> dict[str, Any]: if target_bindings.is_visible("operate", principal_name): - @mcp.tool(title="Operate canonical machine target", annotations=IDEMPOTENT_MUTATION_TOOL) + @mcp.tool( + title="Operate canonical machine target", + annotations=IDEMPOTENT_MUTATION_TOOL, + ) def operate( action_name: str, ref: str, @@ -1074,37 +1279,51 @@ def callback() -> dict[str, Any]: else: if contract.route is OwnerRoute.OPS_ACTIONS_EXECUTE: - callback = lambda: runtime.v2_operate( - reference=ref, - action=operation, - parameters=parameters, - reason=reason, - idempotency_key=idempotency_key, - preconditions=preconditions, - ) + + def callback(): + return runtime.v2_operate( + reference=ref, + action=operation, + parameters=parameters, + reason=reason, + idempotency_key=idempotency_key, + preconditions=preconditions, + ) elif contract.route is OwnerRoute.JOB_CANCEL: - callback = lambda: runtime.v2_cancel_job( - reference=ref, - preconditions=preconditions, - ) + + def callback(): + return runtime.v2_cancel_job( + reference=ref, + preconditions=preconditions, + ) elif contract.route is OwnerRoute.DESKTOP_ACTION: - callback = lambda: runtime.v2_desktop_operate( - reference=ref, operation=operation, parameters=parameters - ) + + def callback(): + return runtime.v2_desktop_operate( + reference=ref, operation=operation, parameters=parameters + ) elif contract.route is OwnerRoute.TERMINALS_ACTION: - callback = lambda: runtime.v2_terminal_operate( - reference=ref, operation=operation, parameters=parameters - ) + + def callback(): + return runtime.v2_terminal_operate( + reference=ref, operation=operation, parameters=parameters + ) elif contract.route is OwnerRoute.BROWSER_ACTION: - callback = lambda: runtime.v2_browser_operate( - reference=ref, operation=operation, parameters=parameters - ) + + def callback(): + return runtime.v2_browser_operate( + reference=ref, operation=operation, parameters=parameters + ) elif contract.route is OwnerRoute.BEADS_MAINTENANCE: - callback = lambda: runtime.v2_beads_operate( - reference=ref, operation=operation, parameters=parameters - ) + + def callback(): + return runtime.v2_beads_operate( + reference=ref, operation=operation, parameters=parameters + ) else: - raise RegistryError(f"operate action {contract.name!r} is not implemented") + raise RegistryError( + f"operate action {contract.name!r} is not implemented" + ) response = runtime.execute_v2( contract, callback, request, selector_error=selector_error ) diff --git a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/sessions.py b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/sessions.py index 4f803acf..14420fdb 100644 --- a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/sessions.py +++ b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/sessions.py @@ -214,11 +214,7 @@ def timeline( scanned_bytes += len(data) text = data.decode("utf-8", errors="replace") matching_line = next( - ( - line - for line in text.splitlines() - if query in line - ), + (line for line in text.splitlines() if query in line), None, ) if matching_line is None: diff --git a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/terminals.py b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/terminals.py index 59b1f312..d62c3b46 100644 --- a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/terminals.py +++ b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/terminals.py @@ -2,9 +2,6 @@ from typing import Any -from .artifacts import ArtifactService -from .capabilities import Capability, Principal -from .config import GatewayConfig from sinnix_mcp.execution import ( EnvironmentProfile, ExecutionProfile, @@ -13,6 +10,10 @@ OwnerRoute, ) +from .artifacts import ArtifactService +from .capabilities import Capability, Principal +from .config import GatewayConfig + class TerminalError(ValueError): pass @@ -66,7 +67,9 @@ def _string(value: Any, name: str, maximum: int = 64_000) -> str: raise TerminalError(f"{name} must be a non-empty string") return value - def read(self, operation: str, arguments: dict[str, Any] | None = None) -> dict[str, Any]: + def read( + self, operation: str, arguments: dict[str, Any] | None = None + ) -> dict[str, Any]: self.principal.require(Capability.TERMINAL_READ) if arguments is None: arguments = {} @@ -79,7 +82,9 @@ def read(self, operation: str, arguments: dict[str, Any] | None = None) -> dict[ if operation == "capture": allowed = {"match", "extent", "ansi"} if "match" not in arguments or set(arguments) - allowed: - raise TerminalError("capture requires match and optional extent or ansi") + raise TerminalError( + "capture requires match and optional extent or ansi" + ) extent = arguments.get("extent", "last_cmd_output") if extent not in self._CAPTURE_EXTENTS: raise TerminalError(f"unsupported capture extent: {extent!r}") @@ -95,7 +100,9 @@ def read(self, operation: str, arguments: dict[str, Any] | None = None) -> dict[ elif "ansi" in arguments and not isinstance(arguments["ansi"], bool): raise TerminalError("ansi must be boolean") return {"operation": operation, **self._run(command)} - raise TerminalError("unknown terminal read operation; available: ['capture', 'list']") + raise TerminalError( + "unknown terminal read operation; available: ['capture', 'list']" + ) def action(self, operation: str, arguments: dict[str, Any]) -> dict[str, Any]: self.principal.require(Capability.TERMINAL_ACTION) @@ -105,7 +112,11 @@ def action(self, operation: str, arguments: dict[str, Any]) -> dict[str, Any]: if set(arguments) != {"match"}: raise TerminalError("focus requires only match") match = self._string(arguments["match"], "match", 512) - return {"operation": operation, "target": match, **self._run(["focus", "--match", match])} + return { + "operation": operation, + "target": match, + **self._run(["focus", "--match", match]), + } if operation == "send": allowed = {"match", "text", "enter", "bracketed_paste"} if not {"match", "text"} <= set(arguments) or set(arguments) - allowed: @@ -119,7 +130,10 @@ def action(self, operation: str, arguments: dict[str, Any]) -> dict[str, Any]: "--text", self._string(arguments["text"], "text"), ] - for key, flag in (("enter", "--enter"), ("bracketed_paste", "--bracketed-paste")): + for key, flag in ( + ("enter", "--enter"), + ("bracketed_paste", "--bracketed-paste"), + ): if arguments.get(key) is True: command.append(flag) elif key in arguments and not isinstance(arguments[key], bool): @@ -133,7 +147,10 @@ def action(self, operation: str, arguments: dict[str, Any]) -> dict[str, Any]: not isinstance(keys, list) or not keys or len(keys) > 16 - or any(not isinstance(key, str) or not key or len(key) > 128 for key in keys) + or any( + not isinstance(key, str) or not key or len(key) > 128 + for key in keys + ) ): raise TerminalError("keys must contain 1-16 non-empty strings") return { diff --git a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/timeline.py b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/timeline.py index 036944c4..962b792f 100644 --- a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/timeline.py +++ b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/timeline.py @@ -74,12 +74,20 @@ def query( end_ns = self._timestamp(end, "end") if start_ns is not None and end_ns is not None and start_ns > end_ns: raise TimelineError("start must not be after end") - if query is not None and (not isinstance(query, str) or not query or len(query) > 1_000): + if query is not None and ( + not isinstance(query, str) or not query or len(query) > 1_000 + ): raise TimelineError("query must contain 1-1000 characters") - if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 500: + if ( + isinstance(limit, bool) + or not isinstance(limit, int) + or not 1 <= limit <= 500 + ): raise TimelineError("limit must be 1-500") requested = self._providers(providers) - raw_requested = [provider for provider in requested if provider in _RAW_PROVIDERS] + raw_requested = [ + provider for provider in requested if provider in _RAW_PROVIDERS + ] per_source_limit = max(1, -(-limit // max(1, len(raw_requested)))) sources = [] entries = [] @@ -151,7 +159,9 @@ def query( "entries": entries, "truncated": truncated, } - encoded = json.dumps(response, sort_keys=True, separators=(",", ":")).encode() + encoded = json.dumps( + response, sort_keys=True, separators=(",", ":") + ).encode() if len(encoded) <= self.sessions.config.max_result_bytes: return response if not entries: diff --git a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/waits.py b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/waits.py index c642473d..503aeb27 100644 --- a/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/waits.py +++ b/pkgs/sinnix-agent-gateway/sinnix_agent_gateway/waits.py @@ -3,11 +3,12 @@ import hashlib import json import time -import anyio from dataclasses import dataclass, field from enum import StrEnum from typing import Any, Callable, Mapping +import anyio + class WaitTarget(StrEnum): JOB_TERMINAL = "job_terminal" @@ -32,7 +33,11 @@ def __post_init__(self) -> None: raise ValueError("wait reference is required") if not isinstance(self.expected, Mapping): raise ValueError("wait expected state must be an object") - if not isinstance(self.timeout_seconds, int) or isinstance(self.timeout_seconds, bool) or not 1 <= self.timeout_seconds <= 300: + if ( + not isinstance(self.timeout_seconds, int) + or isinstance(self.timeout_seconds, bool) + or not 1 <= self.timeout_seconds <= 300 + ): raise ValueError("wait timeout_seconds must be 1-300") if ( not isinstance(self.poll_seconds, (int, float)) @@ -71,7 +76,11 @@ def _continuation(request: WaitRequest, evidence: WaitEvidence) -> str: "expected": dict(request.expected), "source_revision": evidence.source_revision, } - return hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str).encode()).hexdigest() + return hashlib.sha256( + json.dumps( + payload, sort_keys=True, separators=(",", ":"), default=str + ).encode() + ).hexdigest() def wait( self, @@ -132,7 +141,9 @@ async def wait_async( polls = 0 async def resolve() -> WaitEvidence: - return await anyio.to_thread.run_sync(self.resolver, request, abandon_on_cancel=True) + return await anyio.to_thread.run_sync( + self.resolver, request, abandon_on_cancel=True + ) if cancelled is not None and cancelled(): return { @@ -143,7 +154,9 @@ async def resolve() -> WaitEvidence: "polls": 0, "evidence": {}, "source_revision": "cancelled", - "continuation": self._continuation(request, WaitEvidence(False, {}, "cancelled")), + "continuation": self._continuation( + request, WaitEvidence(False, {}, "cancelled") + ), } current = await resolve() while True: diff --git a/pkgs/sinnix-agent-gateway/test_beads.py b/pkgs/sinnix-agent-gateway/test_beads.py index 9d695933..d14f436a 100644 --- a/pkgs/sinnix-agent-gateway/test_beads.py +++ b/pkgs/sinnix-agent-gateway/test_beads.py @@ -6,17 +6,25 @@ from pathlib import Path import pytest - from sinnix_agent_gateway.beads import BeadsError, BeadsService from sinnix_agent_gateway.capabilities import PolicyError, Principal -from sinnix_agent_gateway.config import GatewayConfig, ProjectConfig, TaskAuthorityConfig +from sinnix_agent_gateway.config import ( + GatewayConfig, + ProjectConfig, + TaskAuthorityConfig, +) -def beads_service(tmp_path: Path, principal: str = "operator") -> tuple[BeadsService, Path]: +def beads_service( + tmp_path: Path, principal: str = "operator" +) -> tuple[BeadsService, Path]: tmp_path.mkdir(parents=True, exist_ok=True) - project = tmp_path / "project"; project.mkdir() - other_project = tmp_path / "other-project"; other_project.mkdir() - log = tmp_path / "commands.jsonl"; runner = tmp_path / "bd" + project = tmp_path / "project" + project.mkdir() + other_project = tmp_path / "other-project" + other_project.mkdir() + log = tmp_path / "commands.jsonl" + runner = tmp_path / "bd" runner.write_text( f"#!{sys.executable}\nimport json, pathlib, sys\n" f"log=pathlib.Path({str(log)!r}); log.open('a').write(json.dumps(sys.argv[1:])+'\\n')\n" @@ -46,11 +54,34 @@ def beads_service(tmp_path: Path, principal: str = "operator") -> tuple[BeadsSer " if '--readonly' not in args and any(item in args for item in ('update','unclaim','close','reopen','comments','remember','forget','dolt','backup')):\n" " state['writes'] += 1; pathlib.Path(state_path).write_text(json.dumps(state))\n" " print(json.dumps({'issues':[{'id':'fixture-1','title':'first','status':'open'},{'id':'fixture-2','title':'second','status':'open'}]}))\n" - ); runner.chmod(0o700) - cfg = GatewayConfig(state_dir=tmp_path / "state", projects={ - "fixture": ProjectConfig(project_id="fixture", path=project, observer_read=True, task_authority=TaskAuthorityConfig(owner="beads", workspace=project / ".beads", database=project / ".beads" / "dolt")), - "other": ProjectConfig(project_id="other", path=other_project, observer_read=True, task_authority=TaskAuthorityConfig(owner="beads", workspace=other_project / ".beads", database=other_project / ".beads" / "dolt")), - }, beads_command=str(runner)) + ) + runner.chmod(0o700) + cfg = GatewayConfig( + state_dir=tmp_path / "state", + projects={ + "fixture": ProjectConfig( + project_id="fixture", + path=project, + observer_read=True, + task_authority=TaskAuthorityConfig( + owner="beads", + workspace=project / ".beads", + database=project / ".beads" / "dolt", + ), + ), + "other": ProjectConfig( + project_id="other", + path=other_project, + observer_read=True, + task_authority=TaskAuthorityConfig( + owner="beads", + workspace=other_project / ".beads", + database=other_project / ".beads" / "dolt", + ), + ), + }, + beads_command=str(runner), + ) return BeadsService(cfg, Principal.for_name(principal)), log @@ -58,10 +89,23 @@ def commands(log: Path) -> list[list[str]]: return [json.loads(line) for line in log.read_text().splitlines()] -def test_query_normalizes_project_qualified_resources_and_snapshot_pages(tmp_path: Path) -> None: +def test_query_normalizes_project_qualified_resources_and_snapshot_pages( + tmp_path: Path, +) -> None: beads, log = beads_service(tmp_path, "observer") - first = beads.query(project_ids=["fixture"], filters={"status": "open", "priority": {"op": "<=", "value": 1}}, includes=["comments"], limit=1) - second = beads.query(project_ids=["fixture"], filters={"status": "open", "priority": {"op": "<=", "value": 1}}, includes=["comments"], limit=1, cursor=first["page"]["next_cursor"]) + first = beads.query( + project_ids=["fixture"], + filters={"status": "open", "priority": {"op": "<=", "value": 1}}, + includes=["comments"], + limit=1, + ) + second = beads.query( + project_ids=["fixture"], + filters={"status": "open", "priority": {"op": "<=", "value": 1}}, + includes=["comments"], + limit=1, + cursor=first["page"]["next_cursor"], + ) assert first["items"][0]["ref"] == "sinnix://projects/fixture/beads/fixture-1" assert first["items"][0]["links"]["history"].endswith("/history") assert first["coverage"]["fixture"]["state"] == "complete" @@ -69,7 +113,9 @@ def test_query_normalizes_project_qualified_resources_and_snapshot_pages(tmp_pat assert any("--parse-only" in command for command in commands(log)) -def test_query_compiles_native_list_filters_and_records_parse_parity(tmp_path: Path) -> None: +def test_query_compiles_native_list_filters_and_records_parse_parity( + tmp_path: Path, +) -> None: beads, log = beads_service(tmp_path) beads.query( project_ids=["fixture"], @@ -79,16 +125,26 @@ def test_query_compiles_native_list_filters_and_records_parse_parity(tmp_path: P result = beads.query( project_ids=["fixture"], view="open", - native_filters={"updated_after": "7d", "exclude_label": ["needs:operator"], "priority_max": "P1"}, + native_filters={ + "updated_after": "7d", + "exclude_label": ["needs:operator"], + "priority_max": "P1", + }, includes=["dependencies"], ) - query = next(command for command in commands(log) if "list" in command and "--updated-after" in command) + query = next( + command + for command in commands(log) + if "list" in command and "--updated-after" in command + ) assert "--limit" in query and "--max-rows" in query assert result["totals"]["returned"] == 2 assert result["owner_capabilities"]["native_offset_paging"] is False -def test_ready_query_requests_issue_rows_without_unbounded_explanation(tmp_path: Path) -> None: +def test_ready_query_requests_issue_rows_without_unbounded_explanation( + tmp_path: Path, +) -> None: beads, log = beads_service(tmp_path) result = beads.query(project_ids=["fixture"], view="ready", limit=20) @@ -102,8 +158,29 @@ def test_ready_query_requests_issue_rows_without_unbounded_explanation(tmp_path: def test_get_graph_and_memory_keep_owner_features_explicit(tmp_path: Path) -> None: beads, log = beads_service(tmp_path) - item = beads.get("fixture", "fixture-1", includes=["blockers", "comments", "history", "dependencies", "dependents", "children", "refs"], as_of="HEAD") - graph = beads.graph("fixture", "fixture-1", direction="both", edge_type="blocks", status="open", max_rows=10, mermaid=True) + item = beads.get( + "fixture", + "fixture-1", + includes=[ + "blockers", + "comments", + "history", + "dependencies", + "dependents", + "children", + "refs", + ], + as_of="HEAD", + ) + graph = beads.graph( + "fixture", + "fixture-1", + direction="both", + edge_type="blocks", + status="open", + max_rows=10, + mermaid=True, + ) assert item["task_revision"] and item["includes"]["history"] assert graph["nodes"][0]["ref"].startswith("sinnix://projects/fixture/beads/") assert "flowchart TD" in graph["mermaid"] @@ -113,43 +190,89 @@ def test_get_graph_and_memory_keep_owner_features_explicit(tmp_path: Path) -> No assert any(command[-2:] == ["--limit", "20"] for command in commands(log)) -def test_preview_never_writes_and_apply_protects_notes_by_default(tmp_path: Path) -> None: +def test_preview_never_writes_and_apply_protects_notes_by_default( + tmp_path: Path, +) -> None: beads, log = beads_service(tmp_path) - preview = beads.change("fixture", "update", {"id":"fixture-1", "patch":{"notes":{"text":"new context"}}}, mode="preview") + preview = beads.change( + "fixture", + "update", + {"id": "fixture-1", "patch": {"notes": {"text": "new context"}}}, + mode="preview", + ) assert preview["mode"] == "preview" and "--append-notes" in preview["command"] assert all("update" not in command for command in commands(log)) - result = beads.change("fixture", "update", {"id":"fixture-1", "patch":{"notes":{"text":"new context"}}}, preview_digest=preview["preview_digest"]) + result = beads.change( + "fixture", + "update", + {"id": "fixture-1", "patch": {"notes": {"text": "new context"}}}, + preview_digest=preview["preview_digest"], + ) assert result["before"]["fields"]["notes"] == "long existing notes" - assert any("update" in command and "--append-notes" in command for command in commands(log)) + assert any( + "update" in command and "--append-notes" in command for command in commands(log) + ) with pytest.raises(BeadsError, match="stale"): - beads.change("fixture", "update", {"id":"fixture-1", "patch":{"notes":{"text":"x"}}}, preview_digest="0" * 64) + beads.change( + "fixture", + "update", + {"id": "fixture-1", "patch": {"notes": {"text": "x"}}}, + preview_digest="0" * 64, + ) -def test_replace_notes_requires_explicit_mode_and_cas_is_forwarded(tmp_path: Path) -> None: +def test_replace_notes_requires_explicit_mode_and_cas_is_forwarded( + tmp_path: Path, +) -> None: beads, log = beads_service(tmp_path) - preview = beads.change("fixture", "update", {"id":"fixture-1", "patch":{"notes":{"text":"intentional replacement", "mode":"replace"}}}, mode="preview", preconditions={"expected_status":"open", "expected_assignee":None}) - assert "--notes" in preview["command"] and "--append-notes" not in preview["command"] + preview = beads.change( + "fixture", + "update", + { + "id": "fixture-1", + "patch": {"notes": {"text": "intentional replacement", "mode": "replace"}}, + }, + mode="preview", + preconditions={"expected_status": "open", "expected_assignee": None}, + ) + assert ( + "--notes" in preview["command"] and "--append-notes" not in preview["command"] + ) assert "--if-status" in preview["command"] with pytest.raises(BeadsError, match="status no longer matches"): - beads.change("fixture", "update", {"id":"fixture-1", "patch":{"set":{"status":"closed"}}}, mode="preview", preconditions={"expected_status":"closed"}) + beads.change( + "fixture", + "update", + {"id": "fixture-1", "patch": {"set": {"status": "closed"}}}, + mode="preview", + preconditions={"expected_status": "closed"}, + ) assert any("show" in command for command in commands(log)) def test_typed_mutations_and_owner_authority_are_enforced(tmp_path: Path) -> None: beads, log = beads_service(tmp_path) - beads.change("fixture", "dependency.add", {"id":"fixture-1", "depends_on":"fixture-2"}) - beads.change("fixture", "memory.remember", {"id":"fixture-1", "key":"gateway", "text":"fact"}) + beads.change( + "fixture", "dependency.add", {"id": "fixture-1", "depends_on": "fixture-2"} + ) + beads.change( + "fixture", + "memory.remember", + {"id": "fixture-1", "key": "gateway", "text": "fact"}, + ) assert any("dep" in command and "add" in command for command in commands(log)) observer, _ = beads_service(tmp_path / "observer", "observer") with pytest.raises(PolicyError, match="task.write"): - observer.change("fixture", "claim", {"id":"fixture-1"}) + observer.change("fixture", "claim", {"id": "fixture-1"}) worker, _ = beads_service(tmp_path / "worker", "agent-control") assert worker.get("fixture", "fixture-1")["ref"].endswith("fixture-1") with pytest.raises(PolicyError, match="task.write"): - worker.change("fixture", "close", {"id":"fixture-1"}) + worker.change("fixture", "close", {"id": "fixture-1"}) -def test_operator_close_requires_an_explicit_true_force_override(tmp_path: Path) -> None: +def test_operator_close_requires_an_explicit_true_force_override( + tmp_path: Path, +) -> None: beads, _log = beads_service(tmp_path) preview = beads.change( @@ -161,135 +284,271 @@ def test_operator_close_requires_an_explicit_true_force_override(tmp_path: Path) assert preview["command"][-1] == "--force" with pytest.raises(BeadsError, match="force must be true"): - beads.change("fixture", "close", {"id": "fixture-1", "force": False}, mode="preview") - - -@pytest.mark.parametrize(("operation", "parameters"), [ - ("create", {"title": "created"}), - ("update", {"id": "fixture-1", "patch": {"set": {"title": "updated"}}}), - ("claim", {"id": "fixture-1"}), ("unclaim", {"id": "fixture-1"}), - ("close", {"id": "fixture-1"}), ("reopen", {"id": "fixture-1"}), - ("comment", {"id": "fixture-1", "text": "comment"}), - ("dependency.add", {"id": "fixture-1", "depends_on": "fixture-2"}), - ("dependency.remove", {"id": "fixture-1", "depends_on": "fixture-2"}), - ("relate", {"id": "fixture-1", "other_id": "fixture-2"}), - ("unrelate", {"id": "fixture-1", "other_id": "fixture-2"}), - ("reparent", {"id": "fixture-1", "parent_id": "fixture-2"}), - ("memory.remember", {"id": "fixture-1", "key": "fact", "text": "memory"}), - ("memory.forget", {"id": "fixture-1", "key": "fact"}), - ("graph.create", {"graph": {"issues": [{"title": "child"}]}}), -]) -def test_every_declared_non_admin_mutation_has_preview_and_apply(tmp_path: Path, operation: str, parameters: dict[str, object]) -> None: + beads.change( + "fixture", "close", {"id": "fixture-1", "force": False}, mode="preview" + ) + + +@pytest.mark.parametrize( + ("operation", "parameters"), + [ + ("create", {"title": "created"}), + ("update", {"id": "fixture-1", "patch": {"set": {"title": "updated"}}}), + ("claim", {"id": "fixture-1"}), + ("unclaim", {"id": "fixture-1"}), + ("close", {"id": "fixture-1"}), + ("reopen", {"id": "fixture-1"}), + ("comment", {"id": "fixture-1", "text": "comment"}), + ("dependency.add", {"id": "fixture-1", "depends_on": "fixture-2"}), + ("dependency.remove", {"id": "fixture-1", "depends_on": "fixture-2"}), + ("relate", {"id": "fixture-1", "other_id": "fixture-2"}), + ("unrelate", {"id": "fixture-1", "other_id": "fixture-2"}), + ("reparent", {"id": "fixture-1", "parent_id": "fixture-2"}), + ("memory.remember", {"id": "fixture-1", "key": "fact", "text": "memory"}), + ("memory.forget", {"id": "fixture-1", "key": "fact"}), + ("graph.create", {"graph": {"issues": [{"title": "child"}]}}), + ], +) +def test_every_declared_non_admin_mutation_has_preview_and_apply( + tmp_path: Path, operation: str, parameters: dict[str, object] +) -> None: beads, _ = beads_service(tmp_path) preview = beads.change("fixture", operation, parameters, mode="preview") - applied = beads.change("fixture", operation, parameters, preview_digest=preview["preview_digest"]) + applied = beads.change( + "fixture", operation, parameters, preview_digest=preview["preview_digest"] + ) assert preview["command"] and applied["mode"] == "apply" def test_create_graph_uses_native_dry_run_and_cleans_its_input(tmp_path: Path) -> None: beads, log = beads_service(tmp_path) - preview = beads.change("fixture", "graph.create", {"graph": {"issues": [{"title": "one"}]}}, mode="preview") + preview = beads.change( + "fixture", + "graph.create", + {"graph": {"issues": [{"title": "one"}]}}, + mode="preview", + ) assert preview["native_validation"] == "dry_run" - assert all(not path.exists() for path in (tmp_path / "state" / "beads-graph-inputs").glob("*")) - assert any("--graph" in command and "--dry-run" in command for command in commands(log)) + assert all( + not path.exists() + for path in (tmp_path / "state" / "beads-graph-inputs").glob("*") + ) + assert any( + "--graph" in command and "--dry-run" in command for command in commands(log) + ) def test_query_partial_sources_do_not_erase_healthy_projects(tmp_path: Path) -> None: beads, _ = beads_service(tmp_path) - result = beads.query(project_ids=["fixture", "missing"], filters={"status":"open"}) + result = beads.query(project_ids=["fixture", "missing"], filters={"status": "open"}) assert result["items"] and result["coverage"]["fixture"]["state"] == "complete" assert result["coverage"]["missing"]["state"] == "partial" -def test_changeset_preview_is_non_mutating_and_binds_created_beads(tmp_path: Path) -> None: +def test_changeset_preview_is_non_mutating_and_binds_created_beads( + tmp_path: Path, +) -> None: beads, log = beads_service(tmp_path) actions = [ - {"ref": "sinnix://projects/fixture", "operation": "create", "parameters": {"title": "parent"}, "bind": "parent"}, - {"ref": "sinnix://projects/fixture", "operation": "create", "parameters": {"title": "child", "parent": "$parent"}, "bind": "child"}, - {"ref": "sinnix://projects/fixture", "operation": "dependency.add", "parameters": {"id": "$child", "depends_on": "$parent"}}, + { + "ref": "sinnix://projects/fixture", + "operation": "create", + "parameters": {"title": "parent"}, + "bind": "parent", + }, + { + "ref": "sinnix://projects/fixture", + "operation": "create", + "parameters": {"title": "child", "parent": "$parent"}, + "bind": "child", + }, + { + "ref": "sinnix://projects/fixture", + "operation": "dependency.add", + "parameters": {"id": "$child", "depends_on": "$parent"}, + }, ] preview = beads.changeset(actions, mode="preview") assert preview["atomicity"] == "per_step_commits" assert all("outcome" not in item for item in preview["actions"]) - assert all("create" not in command or "--dry-run" in command for command in commands(log)) - applied = beads.changeset(actions, mode="apply", preview_digest=preview["preview_digest"]) - assert [item["outcome"] for item in applied["outcomes"]] == ["applied", "applied", "applied"] + assert all( + "create" not in command or "--dry-run" in command for command in commands(log) + ) + applied = beads.changeset( + actions, mode="apply", preview_digest=preview["preview_digest"] + ) + assert [item["outcome"] for item in applied["outcomes"]] == [ + "applied", + "applied", + "applied", + ] assert applied["outcomes"][1]["bound_ref"].endswith("fixture-created-2") - dependency = next(command for command in commands(log) if "dep" in command and "add" in command) + dependency = next( + command for command in commands(log) if "dep" in command and "add" in command + ) assert "fixture-created-2" in dependency and "fixture-created-1" in dependency -def test_changeset_reports_failure_skips_and_never_sweeps_an_unrelated_writer(tmp_path: Path) -> None: +def test_changeset_reports_failure_skips_and_never_sweeps_an_unrelated_writer( + tmp_path: Path, +) -> None: beads, log = beads_service(tmp_path) actions = [ - {"ref": "sinnix://projects/fixture", "operation": "create", "parameters": {"title": "first"}}, - {"ref": "sinnix://projects/fixture", "operation": "create", "parameters": {"title": "force-fail"}}, - {"ref": "sinnix://projects/fixture", "operation": "create", "parameters": {"title": "skipped"}}, + { + "ref": "sinnix://projects/fixture", + "operation": "create", + "parameters": {"title": "first"}, + }, + { + "ref": "sinnix://projects/fixture", + "operation": "create", + "parameters": {"title": "force-fail"}, + }, + { + "ref": "sinnix://projects/fixture", + "operation": "create", + "parameters": {"title": "skipped"}, + }, ] result = beads.changeset(actions, mode="apply") - assert [item["outcome"] for item in result["outcomes"]] == ["applied", "failed", "skipped"] - continued = beads.changeset([ - {"ref": "sinnix://projects/fixture", "operation": "create", "parameters": {"title": "force-fail"}}, - {"ref": "sinnix://projects/fixture", "operation": "create", "parameters": {"title": "continues"}}, - ], mode="apply", on_error="continue") + assert [item["outcome"] for item in result["outcomes"]] == [ + "applied", + "failed", + "skipped", + ] + continued = beads.changeset( + [ + { + "ref": "sinnix://projects/fixture", + "operation": "create", + "parameters": {"title": "force-fail"}, + }, + { + "ref": "sinnix://projects/fixture", + "operation": "create", + "parameters": {"title": "continues"}, + }, + ], + mode="apply", + on_error="continue", + ) assert [item["outcome"] for item in continued["outcomes"]] == ["failed", "applied"] runner = tmp_path / "bd" - subprocess.run([str(runner), "unrelated-write"], check=True, capture_output=True, text=True) + subprocess.run( + [str(runner), "unrelated-write"], check=True, capture_output=True, text=True + ) preview = beads.changeset(actions[:1], mode="preview") - subprocess.run([str(runner), "unrelated-write"], check=True, capture_output=True, text=True) + subprocess.run( + [str(runner), "unrelated-write"], check=True, capture_output=True, text=True + ) with pytest.raises(BeadsError, match="stale"): - beads.changeset(actions[:1], mode="apply", preview_digest=preview["preview_digest"]) + beads.changeset( + actions[:1], mode="apply", preview_digest=preview["preview_digest"] + ) assert all(item["operation"] == "create" for item in result["outcomes"]) assert any("unrelated-write" in command for command in commands(log)) -def test_changeset_rejects_cross_project_graph_edges_before_mutation(tmp_path: Path) -> None: +def test_changeset_rejects_cross_project_graph_edges_before_mutation( + tmp_path: Path, +) -> None: beads, log = beads_service(tmp_path) with pytest.raises(BeadsError, match="cross-project"): - beads.changeset([ - {"ref": "sinnix://projects/fixture", "operation": "create", "parameters": {"title": "first"}, "bind": "first"}, - {"ref": "sinnix://projects/other", "operation": "create", "parameters": {"title": "second", "parent": "$first"}}, - ], mode="preview") + beads.changeset( + [ + { + "ref": "sinnix://projects/fixture", + "operation": "create", + "parameters": {"title": "first"}, + "bind": "first", + }, + { + "ref": "sinnix://projects/other", + "operation": "create", + "parameters": {"title": "second", "parent": "$first"}, + }, + ], + mode="preview", + ) assert all("create" not in command for command in commands(log)) -def test_changeset_partitions_independent_projects_and_validates_every_precondition(tmp_path: Path) -> None: +def test_changeset_partitions_independent_projects_and_validates_every_precondition( + tmp_path: Path, +) -> None: beads, _ = beads_service(tmp_path) actions = [ - {"ref": "sinnix://projects/fixture", "operation": "create", "parameters": {"title": "one"}}, - {"ref": "sinnix://projects/other", "operation": "create", "parameters": {"title": "two"}}, + { + "ref": "sinnix://projects/fixture", + "operation": "create", + "parameters": {"title": "one"}, + }, + { + "ref": "sinnix://projects/other", + "operation": "create", + "parameters": {"title": "two"}, + }, ] applied = beads.changeset(actions, mode="apply") assert applied["atomicity"] == "cross_project_partitioned" assert set(applied["source_revisions"]) == {"fixture", "other"} assert [item["outcome"] for item in applied["outcomes"]] == ["applied", "applied"] with pytest.raises(BeadsError, match="expected_task_revision"): - beads.changeset([ - {"ref": "sinnix://projects/fixture", "operation": "create", "parameters": {"title": "blocked"}}, - {"ref": "sinnix://projects/fixture", "operation": "create", "parameters": {"title": "bad"}, "preconditions": {"expected_task_revision": "not-a-revision"}}, - ], mode="apply") - - -def test_graph_changeset_is_owner_atomic_only_after_native_validation(tmp_path: Path) -> None: + beads.changeset( + [ + { + "ref": "sinnix://projects/fixture", + "operation": "create", + "parameters": {"title": "blocked"}, + }, + { + "ref": "sinnix://projects/fixture", + "operation": "create", + "parameters": {"title": "bad"}, + "preconditions": {"expected_task_revision": "not-a-revision"}, + }, + ], + mode="apply", + ) + + +def test_graph_changeset_is_owner_atomic_only_after_native_validation( + tmp_path: Path, +) -> None: beads, log = beads_service(tmp_path) actions = [ - {"ref": "sinnix://projects/fixture", "operation": "graph.create", "parameters": {"graph": {"issues": [{"title": "parent"}, {"title": "child"}]}}}, + { + "ref": "sinnix://projects/fixture", + "operation": "graph.create", + "parameters": { + "graph": {"issues": [{"title": "parent"}, {"title": "child"}]} + }, + }, ] preview = beads.changeset(actions, mode="preview") assert preview["atomicity"] == "owner_atomic" assert preview["actions"][0]["native_validation"] == "dry_run" - result = beads.changeset(actions, mode="apply", preview_digest=preview["preview_digest"]) + result = beads.changeset( + actions, mode="apply", preview_digest=preview["preview_digest"] + ) assert result["atomicity"] == "owner_atomic" assert result["outcomes"][0]["outcome"] == "applied" - assert any("--graph" in command and "--dry-run" in command for command in commands(log)) + assert any( + "--graph" in command and "--dry-run" in command for command in commands(log) + ) -def test_explicit_maintenance_operations_publish_a_deterministic_snapshot_receipt(tmp_path: Path) -> None: +def test_explicit_maintenance_operations_publish_a_deterministic_snapshot_receipt( + tmp_path: Path, +) -> None: beads, log = beads_service(tmp_path) first = beads.operate("fixture", "snapshot.publish") second = beads.operate("fixture", "snapshot.publish") assert first["publication"]["after_sha256"] == second["publication"]["after_sha256"] - assert second["publication"]["changed"] is False and second["publication"]["diff"] == "" + assert ( + second["publication"]["changed"] is False + and second["publication"]["diff"] == "" + ) assert first["git_bookkeeping"] == "none" beads.operate("fixture", "sync.push") beads.operate("fixture", "sync.pull") @@ -299,4 +558,7 @@ def test_explicit_maintenance_operations_publish_a_deterministic_snapshot_receip assert any("dolt" in command and "push" in command for command in commands(log)) assert any("dolt" in command and "pull" in command for command in commands(log)) assert any("backup" in command and "create" in command for command in commands(log)) - assert any("backup" in command and "restore" in command and "fixture-backup" in command for command in commands(log)) + assert any( + "backup" in command and "restore" in command and "fixture-backup" in command + for command in commands(log) + ) diff --git a/pkgs/sinnix-agent-gateway/test_bindings.py b/pkgs/sinnix-agent-gateway/test_bindings.py index 97dec116..395f4caf 100644 --- a/pkgs/sinnix-agent-gateway/test_bindings.py +++ b/pkgs/sinnix-agent-gateway/test_bindings.py @@ -3,10 +3,8 @@ from dataclasses import replace import pytest - from sinnix_agent_gateway.bindings import TargetToolBinding, TargetToolBindings -from sinnix_agent_gateway.registry import CatalogRegistry, REGISTRY, RegistryError - +from sinnix_agent_gateway.registry import REGISTRY, CatalogRegistry, RegistryError VALID_BINDINGS = tuple( TargetToolBinding(action.verb.value, action.name, action.owner, action.route) @@ -20,31 +18,63 @@ def test_target_tool_bindings_cover_every_declared_action() -> None: assert bindings.action_for_tool("status") is REGISTRY.action("gateway.status") assert bindings.action_for_tool("catalog") is REGISTRY.action("gateway.catalog") assert bindings.action_for_tool("get") is REGISTRY.action("resources.get") - assert bindings.action_for_tool("query", "projects.query") is REGISTRY.action("projects.query") - assert bindings.action_for_tool("query", "beads.query") is REGISTRY.action("beads.query") - assert bindings.action_for_tool("query", "machine.query") is REGISTRY.action("machine.query") + assert bindings.action_for_tool("query", "projects.query") is REGISTRY.action( + "projects.query" + ) + assert bindings.action_for_tool("query", "beads.query") is REGISTRY.action( + "beads.query" + ) + assert bindings.action_for_tool("query", "machine.query") is REGISTRY.action( + "machine.query" + ) with pytest.raises(RegistryError, match="requires a declared action selector"): bindings.action_for_tool("query") assert bindings.action_for_tool("context") is REGISTRY.action("projects.context") assert bindings.action_for_tool("events") is REGISTRY.action("audit.events") assert bindings.action_for_tool("wait") is REGISTRY.action("jobs.wait") assert bindings.action_for_tool("run", "shell.run") is REGISTRY.action("shell.run") - assert bindings.action_for_tool("run", "agent.for_bead") is REGISTRY.action("agent.for_bead") - assert bindings.action_for_tool("change", "projects.change") is REGISTRY.action("projects.change") - assert bindings.action_for_tool("change", "files.change") is REGISTRY.action("files.change") - assert bindings.action_for_tool("change", "beads.change") is REGISTRY.action("beads.change") - assert bindings.action_for_tool("change", "beads.changeset") is REGISTRY.action("beads.changeset") - assert bindings.action_for_tool("change", "mcp.change") is REGISTRY.action("mcp.change") - assert bindings.action_for_tool("operate", "machine.operate") is REGISTRY.action("machine.operate") - assert bindings.action_for_tool("operate", "beads.operate") is REGISTRY.action("beads.operate") - assert bindings.action_for_tool("operate", "jobs.cancel") is REGISTRY.action("jobs.cancel") - assert bindings.action_for_tool("operate", "desktop.operate") is REGISTRY.action("desktop.operate") - assert bindings.action_for_tool("operate", "terminals.operate") is REGISTRY.action("terminals.operate") - assert bindings.action_for_tool("operate", "browser.operate") is REGISTRY.action("browser.operate") + assert bindings.action_for_tool("run", "agent.for_bead") is REGISTRY.action( + "agent.for_bead" + ) + assert bindings.action_for_tool("change", "projects.change") is REGISTRY.action( + "projects.change" + ) + assert bindings.action_for_tool("change", "files.change") is REGISTRY.action( + "files.change" + ) + assert bindings.action_for_tool("change", "beads.change") is REGISTRY.action( + "beads.change" + ) + assert bindings.action_for_tool("change", "beads.changeset") is REGISTRY.action( + "beads.changeset" + ) + assert bindings.action_for_tool("change", "mcp.change") is REGISTRY.action( + "mcp.change" + ) + assert bindings.action_for_tool("operate", "machine.operate") is REGISTRY.action( + "machine.operate" + ) + assert bindings.action_for_tool("operate", "beads.operate") is REGISTRY.action( + "beads.operate" + ) + assert bindings.action_for_tool("operate", "jobs.cancel") is REGISTRY.action( + "jobs.cancel" + ) + assert bindings.action_for_tool("operate", "desktop.operate") is REGISTRY.action( + "desktop.operate" + ) + assert bindings.action_for_tool("operate", "terminals.operate") is REGISTRY.action( + "terminals.operate" + ) + assert bindings.action_for_tool("operate", "browser.operate") is REGISTRY.action( + "browser.operate" + ) def test_target_tool_bindings_enforce_declared_principal() -> None: - status = replace(REGISTRY.action("gateway.status"), principals=frozenset({"observer"})) + status = replace( + REGISTRY.action("gateway.status"), principals=frozenset({"observer"}) + ) registry = CatalogRegistry( REGISTRY.resources, tuple( @@ -77,7 +107,9 @@ def test_target_tool_bindings_enforce_declared_principal() -> None: (VALID_BINDINGS[:1], "missing target tool bindings"), ( ( - TargetToolBinding("status", "gateway.status", "registry", "observe.gateway_status"), + TargetToolBinding( + "status", "gateway.status", "registry", "observe.gateway_status" + ), VALID_BINDINGS[1], VALID_BINDINGS[2], *VALID_BINDINGS[3:], @@ -86,7 +118,9 @@ def test_target_tool_bindings_enforce_declared_principal() -> None: ), ( ( - TargetToolBinding("status", "gateway.status", "gateway", "registry.search"), + TargetToolBinding( + "status", "gateway.status", "gateway", "registry.search" + ), VALID_BINDINGS[1], VALID_BINDINGS[2], *VALID_BINDINGS[3:], @@ -95,7 +129,9 @@ def test_target_tool_bindings_enforce_declared_principal() -> None: ), ( ( - TargetToolBinding("status", "gateway.unknown", "gateway", "gateway.unknown"), + TargetToolBinding( + "status", "gateway.unknown", "gateway", "gateway.unknown" + ), VALID_BINDINGS[1], VALID_BINDINGS[2], *VALID_BINDINGS[3:], @@ -104,7 +140,9 @@ def test_target_tool_bindings_enforce_declared_principal() -> None: ), ( tuple( - TargetToolBinding("operate", "agent.for_bead", "systemd-jobs", "job.agent.start") + TargetToolBinding( + "operate", "agent.for_bead", "systemd-jobs", "job.agent.start" + ) if binding.action_name == "agent.for_bead" else binding for binding in VALID_BINDINGS diff --git a/pkgs/sinnix-agent-gateway/test_browser.py b/pkgs/sinnix-agent-gateway/test_browser.py index 35e7dfa4..54124604 100644 --- a/pkgs/sinnix-agent-gateway/test_browser.py +++ b/pkgs/sinnix-agent-gateway/test_browser.py @@ -5,9 +5,12 @@ from pathlib import Path import pytest - from sinnix_agent_gateway.artifacts import ArtifactService -from sinnix_agent_gateway.browser import BrowserDiagnosticError, BrowserError, BrowserService +from sinnix_agent_gateway.browser import ( + BrowserDiagnosticError, + BrowserError, + BrowserService, +) from sinnix_agent_gateway.capabilities import PolicyError, Principal from sinnix_agent_gateway.config import GatewayConfig from sinnix_mcp.execution import ExecutionResult @@ -36,14 +39,18 @@ def browser_service(tmp_path: Path, principal_name: str) -> tuple[BrowserService chrome_control_command=str(runner), ) principal = Principal.for_name(principal_name) - return BrowserService(config, principal, ArtifactService(config, principal)), captured + return BrowserService( + config, principal, ArtifactService(config, principal) + ), captured def commands(path: Path) -> list[list[str]]: return [json.loads(line) for line in path.read_text().splitlines()] -def test_observer_can_read_browser_tabs_without_action_registration(tmp_path: Path) -> None: +def test_observer_can_read_browser_tabs_without_action_registration( + tmp_path: Path, +) -> None: browser, captured = browser_service(tmp_path, "observer") result = browser.read("list_tabs") @@ -69,13 +76,19 @@ def test_operator_actions_require_gateway_created_agent_target(tmp_path: Path) - ] -def test_canonical_browser_target_read_requires_registered_agent_window(tmp_path: Path) -> None: +def test_canonical_browser_target_read_requires_registered_agent_window( + tmp_path: Path, +) -> None: browser, captured = browser_service(tmp_path, "operator") browser.action("agent_window", {}) result = browser.describe_target("agent-target") - assert result == {"operation": "info", "page_id": "agent-target", "result": {"ok": True}} + assert result == { + "operation": "info", + "page_id": "agent-target", + "result": {"ok": True}, + } assert commands(captured) == [["agent-window"], ["info", "agent-target"]] with pytest.raises(BrowserError, match="gateway-created agent window"): browser.describe_target("operator-page") @@ -101,7 +114,11 @@ def test_browser_owner_failure_is_attested_as_a_diagnostic( browser.execution, "run", lambda command, profile: ExecutionResult( - tuple(command), None, b"", b"chrome missing", failure_class="command_unavailable:FileNotFoundError" + tuple(command), + None, + b"", + b"chrome missing", + failure_class="command_unavailable:FileNotFoundError", ), ) @@ -122,9 +139,7 @@ def test_agent_window_rejects_visible_target_after_wrapper_warning( def run(arguments: list[str]) -> dict[str, object]: commands.append(arguments) - return { - "result": '{"id":"agent-target","parked":false}\nnote: visible window' - } + return {"result": '{"id":"agent-target","parked":false}\nnote: visible window'} monkeypatch.setattr(browser, "_run", run) @@ -151,7 +166,9 @@ def test_observer_cannot_create_or_operate_browser_window(tmp_path: Path) -> Non browser.action("agent_window", {}) -def test_browser_capture_registers_only_owned_target_as_artifact(tmp_path: Path) -> None: +def test_browser_capture_registers_only_owned_target_as_artifact( + tmp_path: Path, +) -> None: browser, captured = browser_service(tmp_path, "operator") browser.action("agent_window", {}) @@ -174,9 +191,7 @@ def test_browser_capture_registers_only_owned_target_as_artifact(tmp_path: Path) "--format", "png", "--out", - str( - next((tmp_path / "state" / "captures").glob("*/browser.png")) - ), + str(next((tmp_path / "state" / "captures").glob("*/browser.png"))), "--full-page", ], ] diff --git a/pkgs/sinnix-agent-gateway/test_capability_index.py b/pkgs/sinnix-agent-gateway/test_capability_index.py index 0bab48a3..eaadc83b 100644 --- a/pkgs/sinnix-agent-gateway/test_capability_index.py +++ b/pkgs/sinnix-agent-gateway/test_capability_index.py @@ -4,7 +4,6 @@ from pathlib import Path import pytest - from sinnix_agent_gateway.capabilities import PolicyError, Principal from sinnix_agent_gateway.capability_index import ( CapabilityIndexError, @@ -45,7 +44,9 @@ def service( ) -def test_search_filters_index_rows_with_provenance_and_pagination(tmp_path: Path) -> None: +def test_search_filters_index_rows_with_provenance_and_pagination( + tmp_path: Path, +) -> None: capability_index = service( tmp_path, [ @@ -72,9 +73,7 @@ def test_search_filters_index_rows_with_provenance_and_pagination(tmp_path: Path ], ) - result = capability_index.search( - "inspect", kind="script", enabled=True, limit=1 - ) + result = capability_index.search("inspect", kind="script", enabled=True, limit=1) assert result["available"] is True assert result["source"] == { diff --git a/pkgs/sinnix-agent-gateway/test_captures.py b/pkgs/sinnix-agent-gateway/test_captures.py index ffe50953..f67e6e7f 100644 --- a/pkgs/sinnix-agent-gateway/test_captures.py +++ b/pkgs/sinnix-agent-gateway/test_captures.py @@ -5,7 +5,6 @@ from pathlib import Path import pytest - from sinnix_agent_gateway.capabilities import Capability, PolicyError, Principal from sinnix_agent_gateway.captures import CaptureService from sinnix_agent_gateway.config import GatewayConfig @@ -58,7 +57,9 @@ def test_principals_have_full_operator_authorized_capture_read_access( principal.require_lane("clipboard") -def test_capture_lanes_tool_lists_runtime_declared_envelope_lanes(tmp_path: Path) -> None: +def test_capture_lanes_tool_lists_runtime_declared_envelope_lanes( + tmp_path: Path, +) -> None: gateway_config, lane_paths = config(tmp_path) service = CaptureService(gateway_config, Principal.for_name("observer")) @@ -66,9 +67,30 @@ def test_capture_lanes_tool_lists_runtime_declared_envelope_lanes(tmp_path: Path assert result == { "lanes": [ - {"name": "clipboard", "ref": "sinnix://captures/clipboard", "path": str(lane_paths["clipboard"]), "capture_root": str(lane_paths["clipboard"].parent), "native_lane": "clipboard", "native_contract": "sinnix-capture-v1-sidecar"}, - {"name": "mpris", "ref": "sinnix://captures/mpris", "path": str(lane_paths["mpris"]), "capture_root": str(lane_paths["mpris"].parent), "native_lane": "mpris", "native_contract": "sinnix-capture-v1-sidecar"}, - {"name": "router", "ref": "sinnix://captures/router", "path": str(lane_paths["router"]), "capture_root": str(lane_paths["router"].parent), "native_lane": "router", "native_contract": "sinnix-capture-v1-sidecar"}, + { + "name": "clipboard", + "ref": "sinnix://captures/clipboard", + "path": str(lane_paths["clipboard"]), + "capture_root": str(lane_paths["clipboard"].parent), + "native_lane": "clipboard", + "native_contract": "sinnix-capture-v1-sidecar", + }, + { + "name": "mpris", + "ref": "sinnix://captures/mpris", + "path": str(lane_paths["mpris"]), + "capture_root": str(lane_paths["mpris"].parent), + "native_lane": "mpris", + "native_contract": "sinnix-capture-v1-sidecar", + }, + { + "name": "router", + "ref": "sinnix://captures/router", + "path": str(lane_paths["router"]), + "capture_root": str(lane_paths["router"].parent), + "native_lane": "router", + "native_contract": "sinnix-capture-v1-sidecar", + }, ], "total_declared_lanes": 3, } @@ -135,7 +157,9 @@ def test_capture_query_groups_declared_lanes_by_inventory_root(tmp_path: Path) - ] -def test_capture_query_reports_missing_collector_for_declared_lane(tmp_path: Path) -> None: +def test_capture_query_reports_missing_collector_for_declared_lane( + tmp_path: Path, +) -> None: gateway_config, lane_paths = config(tmp_path) service = CaptureService( GatewayConfig( @@ -166,15 +190,21 @@ def test_capture_query_reports_missing_collector_for_declared_lane(tmp_path: Pat } -def test_declared_file_lane_remains_visible_without_a_sidecar_guess(tmp_path: Path) -> None: +def test_declared_file_lane_remains_visible_without_a_sidecar_guess( + tmp_path: Path, +) -> None: inventory = tmp_path / "runtime-inventory.json" lane = tmp_path / "machine" / "telemetry.jsonl" lane.parent.mkdir(parents=True) lane.write_text("{}\n") - inventory.write_text(json.dumps({"captures": [{"name": "telemetry", "path": str(lane)}]})) + inventory.write_text( + json.dumps({"captures": [{"name": "telemetry", "path": str(lane)}]}) + ) service = CaptureService( - GatewayConfig(state_dir=tmp_path / "state", projects={}, runtime_inventory=inventory), + GatewayConfig( + state_dir=tmp_path / "state", projects={}, runtime_inventory=inventory + ), Principal.for_name("observer"), ) @@ -200,7 +230,9 @@ def test_capture_query_uses_the_native_lane_derived_from_a_nested_declared_path( path.mkdir(parents=True) (path / "logitech-index.jsonl").write_text('{"ts": 1, "seq": 1}\n') inventory = tmp_path / "runtime-inventory.json" - inventory.write_text(json.dumps({"captures": [{"name": "peripherals-logitech", "path": str(path)}]})) + inventory.write_text( + json.dumps({"captures": [{"name": "peripherals-logitech", "path": str(path)}]}) + ) captured = tmp_path / "collector-commands.jsonl" collector = tmp_path / "sinnix-capture" collector.write_text( @@ -211,15 +243,28 @@ def test_capture_query_uses_the_native_lane_derived_from_a_nested_declared_path( ) collector.chmod(0o700) service = CaptureService( - GatewayConfig(state_dir=tmp_path / "state", projects={}, runtime_inventory=inventory, capture_command=str(collector)), + GatewayConfig( + state_dir=tmp_path / "state", + projects={}, + runtime_inventory=inventory, + capture_command=str(collector), + ), Principal.for_name("observer"), ) assert service.query(["peripherals-logitech"]) == { - "records": [], "lanes_queried": ["peripherals-logitech"], "truncated": False, + "records": [], + "lanes_queried": ["peripherals-logitech"], + "truncated": False, } assert json.loads(captured.read_text()) == [ - "query", "--capture-root", str(root), "--since", "0.0", "--lane", "logitech", + "query", + "--capture-root", + str(root), + "--since", + "0.0", + "--lane", + "logitech", ] diff --git a/pkgs/sinnix-agent-gateway/test_cli.py b/pkgs/sinnix-agent-gateway/test_cli.py index 6cbd4be6..c420cb7b 100644 --- a/pkgs/sinnix-agent-gateway/test_cli.py +++ b/pkgs/sinnix-agent-gateway/test_cli.py @@ -6,9 +6,12 @@ import anyio import pytest - from sinnix_agent_gateway import cli, cli_support -from sinnix_agent_gateway.cli_support import CliInputError, build_request, load_json_input +from sinnix_agent_gateway.cli_support import ( + CliInputError, + build_request, + load_json_input, +) from sinnix_agent_gateway.config import GatewayConfig from sinnix_agent_gateway.gateway_codegen import FIXTURE_PATH @@ -28,7 +31,10 @@ def test_cli_defaults_to_the_deployed_local_estate_contract( assert cli.parser().parse_args(["status"]).config == local_config explicit = tmp_path / "explicit.json" - assert cli.parser().parse_args(["--config", str(explicit), "status"]).config == explicit + assert ( + cli.parser().parse_args(["--config", str(explicit), "status"]).config + == explicit + ) def test_cli_config_environment_overrides_the_deployed_default( @@ -44,7 +50,9 @@ class FakeServer: def __init__(self, calls: list[tuple[str, dict[str, object]]]) -> None: self.calls = calls - async def call_tool(self, name: str, arguments: dict[str, object]) -> dict[str, object]: + async def call_tool( + self, name: str, arguments: dict[str, object] + ) -> dict[str, object]: self.calls.append((name, arguments)) return {"schema": "sinnix.gateway-result.v3", "result": {"outcome": "ok"}} @@ -54,22 +62,63 @@ async def call_tool(self, name: str, arguments: dict[str, object]) -> dict[str, [ ("status", {}), ("catalog", {}), - ("query", {"action_name": "projects.query", "ref": "sinnix://projects/fixture", "query": "fixture"}), + ( + "query", + { + "action_name": "projects.query", + "ref": "sinnix://projects/fixture", + "query": "fixture", + }, + ), ("get", {"ref": "sinnix://projects/fixture"}), ("context", {"ref": "sinnix://projects/fixture"}), ("events", {}), ("wait", {"ref": "sinnix://jobs/job-fixture"}), - ("change", {"action_name": "beads.change", "ref": "sinnix://projects/fixture", "operation": "comment", "parameters": {"id": "fixture-1", "text": "fixture"}, "idempotency_key": "cli-change"}), - ("operate", {"action_name": "beads.operate", "ref": "sinnix://projects/fixture", "operation": "snapshot.publish", "parameters": {}, "idempotency_key": "cli-operate"}), - ("run", {"action_name": "operations.run", "project_id": "fixture", "operation": "check", "parameters": {}, "idempotency_key": "cli-run"}), + ( + "change", + { + "action_name": "beads.change", + "ref": "sinnix://projects/fixture", + "operation": "comment", + "parameters": {"id": "fixture-1", "text": "fixture"}, + "idempotency_key": "cli-change", + }, + ), + ( + "operate", + { + "action_name": "beads.operate", + "ref": "sinnix://projects/fixture", + "operation": "snapshot.publish", + "parameters": {}, + "idempotency_key": "cli-operate", + }, + ), + ( + "run", + { + "action_name": "operations.run", + "project_id": "fixture", + "operation": "check", + "parameters": {}, + "idempotency_key": "cli-run", + }, + ), ], ) def test_every_v2_verb_replays_through_the_matching_mcp_tool( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, verb: str, payload: dict[str, object] + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + verb: str, + payload: dict[str, object], ) -> None: calls: list[tuple[str, dict[str, object]]] = [] - monkeypatch.setattr(cli_support, "create_server", lambda _config, _principal: FakeServer(calls)) - response = anyio.run(cli_support.invoke_mcp, _config(tmp_path), "operator", verb, payload) + monkeypatch.setattr( + cli_support, "create_server", lambda _config, _principal: FakeServer(calls) + ) + response = anyio.run( + cli_support.invoke_mcp, _config(tmp_path), "operator", verb, payload + ) assert response["schema"] == "sinnix.gateway-result.v3" assert calls == [(verb, payload)] @@ -86,13 +135,21 @@ def test_input_sources_are_bounded_and_require_a_json_object(tmp_path: Path) -> with pytest.raises(CliInputError, match="input bound"): load_json_input(input_file=oversized) with pytest.raises(CliInputError, match="input bound"): - load_json_input(use_stdin=True, stdin=SimpleNamespace(read=lambda limit: "x" * limit)) + load_json_input( + use_stdin=True, stdin=SimpleNamespace(read=lambda limit: "x" * limit) + ) def test_input_flags_merge_without_allowing_conflicting_values() -> None: request = build_request( "change", - inline=json.dumps({"action_name": "beads.changeset", "ref": "sinnix://projects/fixture", "parameters": {"actions": []}}), + inline=json.dumps( + { + "action_name": "beads.changeset", + "ref": "sinnix://projects/fixture", + "parameters": {"actions": []}, + } + ), operation="preview", idempotency_key="changeset", ) @@ -106,9 +163,15 @@ def test_input_flags_merge_without_allowing_conflicting_values() -> None: def test_catalog_display_exposes_schema_example_and_resource_completion() -> None: - schema = cli_support.catalog_display(principal="operator", action_name="beads.change", schema=True) - example = cli_support.catalog_display(principal="operator", action_name="beads.change", example=True) - completion = cli_support.catalog_display(principal="operator", complete="sinnix://gateway/v2/actions/beads.") + schema = cli_support.catalog_display( + principal="operator", action_name="beads.change", schema=True + ) + example = cli_support.catalog_display( + principal="operator", action_name="beads.change", example=True + ) + completion = cli_support.catalog_display( + principal="operator", complete="sinnix://gateway/v2/actions/beads." + ) assert schema["schema"]["type"] == "object" assert example["examples"] assert completion["actions"] @@ -125,7 +188,9 @@ def test_generated_cli_examples_are_replayable_against_a_fixture_server( fixture_path = Path(__file__).parent / "fixtures" / FIXTURE_PATH.name fixtures = json.loads(fixture_path.read_text()) calls: list[tuple[str, dict[str, object]]] = [] - monkeypatch.setattr(cli_support, "create_server", lambda _config, _principal: FakeServer(calls)) + monkeypatch.setattr( + cli_support, "create_server", lambda _config, _principal: FakeServer(calls) + ) for fixture in fixtures["examples"]: anyio.run( cli_support.invoke_mcp, @@ -134,4 +199,6 @@ def test_generated_cli_examples_are_replayable_against_a_fixture_server( fixture["verb"], fixture["cli_input"], ) - assert [name for name, _ in calls] == [fixture["verb"] for fixture in fixtures["examples"]] + assert [name for name, _ in calls] == [ + fixture["verb"] for fixture in fixtures["examples"] + ] diff --git a/pkgs/sinnix-agent-gateway/test_contexts.py b/pkgs/sinnix-agent-gateway/test_contexts.py index fb915492..5a4580d7 100644 --- a/pkgs/sinnix-agent-gateway/test_contexts.py +++ b/pkgs/sinnix-agent-gateway/test_contexts.py @@ -4,7 +4,6 @@ from pathlib import Path import pytest - from sinnix_agent_gateway.contexts import ( CONTEXT_INTENTS, ComponentResult, @@ -17,40 +16,64 @@ def test_orientation_task_summary_retains_routing_and_drops_large_bodies() -> None: - result = _orientation_task_summary({ - "items": [{ + result = _orientation_task_summary( + { + "items": [ + { + "id": "fixture-1", + "ref": "sinnix://projects/fixture/beads/fixture-1", + "title": "Do the work", + "priority": 1, + "task_revision": "a" * 64, + "description": "x" * 100_000, + "acceptance_criteria": "y" * 100_000, + } + ], + "page": {"total": 1}, + "coverage": {"fixture": {"state": "complete"}}, + } + ) + + assert result["items"] == [ + { "id": "fixture-1", "ref": "sinnix://projects/fixture/beads/fixture-1", "title": "Do the work", "priority": 1, "task_revision": "a" * 64, - "description": "x" * 100_000, - "acceptance_criteria": "y" * 100_000, - }], - "page": {"total": 1}, - "coverage": {"fixture": {"state": "complete"}}, - }) - - assert result["items"] == [{ - "id": "fixture-1", - "ref": "sinnix://projects/fixture/beads/fixture-1", - "title": "Do the work", - "priority": 1, - "task_revision": "a" * 64, - }] + } + ] assert result["page"] == {"total": 1} assert len(json.dumps(result)) < 1_000 -def test_context_snapshot_survives_store_recreation_and_rejects_tampering(tmp_path: Path) -> None: +def test_context_snapshot_survives_store_recreation_and_rejects_tampering( + tmp_path: Path, +) -> None: snapshot = ContextComposer().compose( "project.orientation", "sinnix://projects/fixture", [ - ComponentSpec("project", 12_000, lambda: ComponentResult.available("project", {"head": "a"})), - ComponentSpec("checkout", 12_000, lambda: ComponentResult.available("checkout", {"head": "a"})), - ComponentSpec("tasks", 16_000, lambda: ComponentResult.available("tasks", {"items": []})), - ComponentSpec("authority", 8_000, lambda: ComponentResult.available("authority", {"revision": "a"})), + ComponentSpec( + "project", + 12_000, + lambda: ComponentResult.available("project", {"head": "a"}), + ), + ComponentSpec( + "checkout", + 12_000, + lambda: ComponentResult.available("checkout", {"head": "a"}), + ), + ComponentSpec( + "tasks", + 16_000, + lambda: ComponentResult.available("tasks", {"items": []}), + ), + ComponentSpec( + "authority", + 8_000, + lambda: ComponentResult.available("authority", {"revision": "a"}), + ), ], ) snapshot_id = snapshot["snapshot_ref"].rsplit("/", 1)[1] @@ -70,19 +93,49 @@ def test_declared_contexts_are_bounded_and_isolate_unavailable_components() -> N "project.orientation", "sinnix://projects/fixture", [ - ComponentSpec("project", 12_000, lambda: ComponentResult.available("project", {"head": "a"})), - ComponentSpec("checkout", 12_000, lambda: ComponentResult.unavailable("checkout", "checkout owner offline")), - ComponentSpec("tasks", 16_000, lambda: ComponentResult.available("tasks", {"items": [1, 2]})), - ComponentSpec("authority", 8_000, lambda: ComponentResult.unavailable("authority", "owner freshness evidence is unavailable", revision="b")), + ComponentSpec( + "project", + 12_000, + lambda: ComponentResult.available("project", {"head": "a"}), + ), + ComponentSpec( + "checkout", + 12_000, + lambda: ComponentResult.unavailable( + "checkout", "checkout owner offline" + ), + ), + ComponentSpec( + "tasks", + 16_000, + lambda: ComponentResult.available("tasks", {"items": [1, 2]}), + ), + ComponentSpec( + "authority", + 8_000, + lambda: ComponentResult.unavailable( + "authority", "owner freshness evidence is unavailable", revision="b" + ), + ), ], ) assert set(result) >= {"intent", "component_plan", "components", "snapshot_ref"} assert result["snapshot_ref"].startswith("sinnix://contexts/") - assert len(json.dumps(result, separators=(",", ":")).encode()) <= CONTEXT_INTENTS["project.orientation"].total_budget_bytes + assert ( + len(json.dumps(result, separators=(",", ":")).encode()) + <= CONTEXT_INTENTS["project.orientation"].total_budget_bytes + ) states = {row["name"]: row["status"] for row in result["components"]} - assert states == {"project": "available", "checkout": "unavailable", "tasks": "available", "authority": "unavailable"} - assert all(row["snapshot_ref"] == result["snapshot_ref"] for row in result["components"]) + assert states == { + "project": "available", + "checkout": "unavailable", + "tasks": "available", + "authority": "unavailable", + } + assert all( + row["snapshot_ref"] == result["snapshot_ref"] for row in result["components"] + ) def test_component_budget_marks_only_the_oversized_component_unavailable() -> None: @@ -90,11 +143,31 @@ def test_component_budget_marks_only_the_oversized_component_unavailable() -> No "bead.work", "sinnix://projects/fixture/beads/fixture-1", [ - ComponentSpec("bead", 128, lambda: ComponentResult.available("bead", {"body": "x" * 1_000})), - ComponentSpec("project", 12_000, lambda: ComponentResult.available("project", {"ok": True})), - ComponentSpec("checkout", 12_000, lambda: ComponentResult.available("checkout", {"ok": True})), - ComponentSpec("assignment", 14_000, lambda: ComponentResult.available("assignment", {"ok": True})), - ComponentSpec("blockers", 8_000, lambda: ComponentResult.available("blockers", {"ok": True})), + ComponentSpec( + "bead", + 128, + lambda: ComponentResult.available("bead", {"body": "x" * 1_000}), + ), + ComponentSpec( + "project", + 12_000, + lambda: ComponentResult.available("project", {"ok": True}), + ), + ComponentSpec( + "checkout", + 12_000, + lambda: ComponentResult.available("checkout", {"ok": True}), + ), + ComponentSpec( + "assignment", + 14_000, + lambda: ComponentResult.available("assignment", {"ok": True}), + ), + ComponentSpec( + "blockers", + 8_000, + lambda: ComponentResult.available("blockers", {"ok": True}), + ), ], ) rows = {row["name"]: row for row in result["components"]} @@ -130,7 +203,9 @@ def test_revision_cache_evicts_by_entries_and_bytes_before_growth() -> None: def test_revision_cache_rejects_oversized_component_without_insertion() -> None: cache = RevisionReuseCache(max_entries=4, max_bytes=128) - oversized = ComponentResult.available("large", {"body": "x" * 10_000}, revision="large") + oversized = ComponentResult.available( + "large", {"body": "x" * 10_000}, revision="large" + ) cache.put(oversized) assert cache.get("large", "large") is None @@ -139,7 +214,15 @@ def test_missing_declared_component_is_explicitly_unavailable() -> None: result = ContextComposer().compose( "project.triage", "sinnix://projects/fixture", - [ComponentSpec("project", 12_000, lambda: ComponentResult.available("project", {"ok": True}))], + [ + ComponentSpec( + "project", + 12_000, + lambda: ComponentResult.available("project", {"ok": True}), + ) + ], ) rows = {row["name"]: row for row in result["components"]} - assert {row["status"] for name, row in rows.items() if name != "project"} == {"unavailable"} + assert {row["status"] for name, row in rows.items() if name != "project"} == { + "unavailable" + } diff --git a/pkgs/sinnix-agent-gateway/test_desktop.py b/pkgs/sinnix-agent-gateway/test_desktop.py index 15ab8043..98219fbc 100644 --- a/pkgs/sinnix-agent-gateway/test_desktop.py +++ b/pkgs/sinnix-agent-gateway/test_desktop.py @@ -5,7 +5,6 @@ from pathlib import Path import pytest - from sinnix_agent_gateway.artifacts import ArtifactError, ArtifactService from sinnix_agent_gateway.capabilities import PolicyError, Principal from sinnix_agent_gateway.config import GatewayConfig @@ -133,7 +132,9 @@ def test_desktop_capture_registers_raw_and_corrected_artifacts(tmp_path: Path) - desktop, captured = desktop_service(tmp_path, "observer") result = desktop.capture_output() - artifacts = [desktop.artifacts.read(artifact_id) for artifact_id in result["artifact_ids"]] + artifacts = [ + desktop.artifacts.read(artifact_id) for artifact_id in result["artifact_ids"] + ] bounded = desktop.artifacts.read(result["artifact_ids"][0], max_bytes=3) assert result["capture"]["fix_hdr"] is True diff --git a/pkgs/sinnix-agent-gateway/test_events.py b/pkgs/sinnix-agent-gateway/test_events.py index 6d7911f2..a984a289 100644 --- a/pkgs/sinnix-agent-gateway/test_events.py +++ b/pkgs/sinnix-agent-gateway/test_events.py @@ -1,19 +1,24 @@ from __future__ import annotations -from pathlib import Path import json +from pathlib import Path import pytest - from sinnix_agent_gateway.audit import AuditService from sinnix_agent_gateway.capabilities import Principal from sinnix_agent_gateway.config import GatewayConfig, ProjectConfig -from sinnix_agent_gateway.events import MAX_EVENT_PROJECTS, EventCursorError, NormalizedEventService +from sinnix_agent_gateway.events import ( + MAX_EVENT_PROJECTS, + EventCursorError, + NormalizedEventService, +) class FakeProjects: def __init__(self, project: Path): - self.config = type("Config", (), {"projects": {"fixture": ProjectConfig("fixture", project)}})() + self.config = type( + "Config", (), {"projects": {"fixture": ProjectConfig("fixture", project)}} + )() self.revision = "git-a" def summary(self, project_id: str) -> dict[str, object]: @@ -28,39 +33,77 @@ def task_authority_status(self, project_id: str) -> dict[str, object]: return {"project_id": project_id, "revision": self.revision} -def service(tmp_path: Path) -> tuple[NormalizedEventService, FakeProjects, FakeBeads, AuditService]: +def service( + tmp_path: Path, +) -> tuple[NormalizedEventService, FakeProjects, FakeBeads, AuditService]: projects = FakeProjects(tmp_path / "project") beads = FakeBeads() - config = GatewayConfig(state_dir=tmp_path / "state", projects={"fixture": projects.config.projects["fixture"]}) + config = GatewayConfig( + state_dir=tmp_path / "state", + projects={"fixture": projects.config.projects["fixture"]}, + ) audit = AuditService(config, Principal.for_name("observer")) transitions = tmp_path / "transitions.jsonl" - transitions.write_text('{"schema":"sinnix-health-transition-v1","event_id":"transition-1"}\n') - return NormalizedEventService( - principal="observer", - cursor_key=b"e" * 32, - projects=projects, # type: ignore[arg-type] - beads=beads, # type: ignore[arg-type] - audit=audit, - transitions_path=transitions, - ), projects, beads, audit + transitions.write_text( + '{"schema":"sinnix-health-transition-v1","event_id":"transition-1"}\n' + ) + return ( + NormalizedEventService( + principal="observer", + cursor_key=b"e" * 32, + projects=projects, # type: ignore[arg-type] + beads=beads, # type: ignore[arg-type] + audit=audit, + transitions_path=transitions, + ), + projects, + beads, + audit, + ) -def test_events_normalize_owner_revisions_without_fabricating_exact_events(tmp_path: Path) -> None: +def test_events_normalize_owner_revisions_without_fabricating_exact_events( + tmp_path: Path, +) -> None: events, projects, beads, audit = service(tmp_path) audit.append("fixture.read", "ok", {"target_refs": ["sinnix://projects/fixture"]}) first = events.read(limit=20) - assert {row["kind"] for row in first["events"]} >= {"gateway_receipt", "git_revision", "owner_revision", "runtime_transition"} - assert all(row["exact"] is True for row in first["events"] if row["kind"] in {"gateway_receipt", "runtime_transition"}) - assert all(row["exact"] is False for row in first["events"] if row["kind"] in {"git_revision", "owner_revision"}) - assert all("ref" not in row for row in first["events"] if row["kind"] == "runtime_transition") + assert {row["kind"] for row in first["events"]} >= { + "gateway_receipt", + "git_revision", + "owner_revision", + "runtime_transition", + } + assert all( + row["exact"] is True + for row in first["events"] + if row["kind"] in {"gateway_receipt", "runtime_transition"} + ) + assert all( + row["exact"] is False + for row in first["events"] + if row["kind"] in {"git_revision", "owner_revision"} + ) + assert all( + "ref" not in row + for row in first["events"] + if row["kind"] == "runtime_transition" + ) second = events.read(limit=20, cursor=first["next_cursor"]) - assert not [row for row in second["events"] if row["kind"] in {"git_revision", "owner_revision"}] + assert not [ + row + for row in second["events"] + if row["kind"] in {"git_revision", "owner_revision"} + ] projects.revision = "git-b" beads.revision = "beads-b" third = events.read(limit=20, cursor=second["next_cursor"]) - assert {row["kind"] for row in third["events"]} >= {"git_revision", "owner_revision"} + assert {row["kind"] for row in third["events"]} >= { + "git_revision", + "owner_revision", + } def test_event_cursor_is_opaque_tamper_and_scope_bound(tmp_path: Path) -> None: @@ -70,10 +113,24 @@ def test_event_cursor_is_opaque_tamper_and_scope_bound(tmp_path: Path) -> None: with pytest.raises(EventCursorError, match="authentication"): events.read(limit=2, cursor=altered) with pytest.raises(EventCursorError, match="scope"): - events.read(limit=2, project_ids=["fixture"], cursor=events.cursor.encode({"audit_sequence": 0, "runtime_offset": 0, "owner_revisions": {}, "job_revision": None}, ["other"])) + events.read( + limit=2, + project_ids=["fixture"], + cursor=events.cursor.encode( + { + "audit_sequence": 0, + "runtime_offset": 0, + "owner_revisions": {}, + "job_revision": None, + }, + ["other"], + ), + ) -def test_event_cursor_secret_is_private_principal_bound_and_bounded(tmp_path: Path) -> None: +def test_event_cursor_secret_is_private_principal_bound_and_bounded( + tmp_path: Path, +) -> None: events, _projects, _beads, _audit = service(tmp_path) cursor = events.read(limit=2)["next_cursor"] other = NormalizedEventService( @@ -108,7 +165,9 @@ def test_event_scope_bound_matches_cursor_capacity(tmp_path: Path) -> None: for project_id in project_ids } beads = FakeBeads() - config = GatewayConfig(state_dir=tmp_path / "state", projects=projects.config.projects) + config = GatewayConfig( + state_dir=tmp_path / "state", projects=projects.config.projects + ) audit = AuditService(config, Principal.for_name("observer")) events = NormalizedEventService( principal="observer", @@ -130,14 +189,25 @@ def test_event_scope_bound_matches_cursor_capacity(tmp_path: Path) -> None: events.read(limit=1_000, project_ids=project_ids) -def test_event_cursor_state_and_runtime_continuation_preserve_rows(tmp_path: Path) -> None: +def test_event_cursor_state_and_runtime_continuation_preserve_rows( + tmp_path: Path, +) -> None: events, _projects, _beads, _audit = service(tmp_path) - events.transitions_path.write_text("".join(f'{{"schema":"sinnix-health-transition-v1","event_id":"row-{i}","data":"{i}"}}\n' for i in range(4))) + events.transitions_path.write_text( + "".join( + f'{{"schema":"sinnix-health-transition-v1","event_id":"row-{i}","data":"{i}"}}\n' + for i in range(4) + ) + ) seen: list[str] = [] cursor = None for _ in range(8): page = events.read(limit=1, cursor=cursor) - seen.extend(row["event_id"] for row in page["events"] if row["event_id"].startswith("row-")) + seen.extend( + row["event_id"] + for row in page["events"] + if row["event_id"].startswith("row-") + ) cursor = page["next_cursor"] assert len(cursor.encode()) <= 4_096 if not page["truncated"]: @@ -146,10 +216,18 @@ def test_event_cursor_state_and_runtime_continuation_preserve_rows(tmp_path: Pat assert len(seen) == len(set(seen)) -def test_event_cursor_state_is_bounded_independently_of_job_population(tmp_path: Path) -> None: +def test_event_cursor_state_is_bounded_independently_of_job_population( + tmp_path: Path, +) -> None: events, _projects, _beads, _audit = service(tmp_path) - jobs = [{"job_id": f"job-{index}", "state": {"phase": "running"}} for index in range(10_000)] - events.jobs = lambda _limit, _cursor: {"jobs": jobs, "snapshot": {"ordering": "created_at_desc_job_id_desc", "ceiling": ["", ""]}} + jobs = [ + {"job_id": f"job-{index}", "state": {"phase": "running"}} + for index in range(10_000) + ] + events.jobs = lambda _limit, _cursor: { + "jobs": jobs, + "snapshot": {"ordering": "created_at_desc_job_id_desc", "ceiling": ["", ""]}, + } response = events.read(limit=1_000) assert len(response["next_cursor"].encode()) <= 4_096 @@ -158,11 +236,21 @@ def test_event_cursor_state_is_bounded_independently_of_job_population(tmp_path: assert job_events[0]["data"]["truncated"] is True -def test_oversized_runtime_row_is_compacted_without_advancing_past_next_row(tmp_path: Path) -> None: +def test_oversized_runtime_row_is_compacted_without_advancing_past_next_row( + tmp_path: Path, +) -> None: events, _projects, _beads, _audit = service(tmp_path) events.transitions_path.write_text( - json.dumps({"schema": "sinnix-health-transition-v1", "event_id": "huge", "data": "x" * 1_100_000}) + "\n" - + json.dumps({"schema": "sinnix-health-transition-v1", "event_id": "after"}) + "\n" + json.dumps( + { + "schema": "sinnix-health-transition-v1", + "event_id": "huge", + "data": "x" * 1_100_000, + } + ) + + "\n" + + json.dumps({"schema": "sinnix-health-transition-v1", "event_id": "after"}) + + "\n" ) cursor = None runtime = None @@ -170,10 +258,15 @@ def test_oversized_runtime_row_is_compacted_without_advancing_past_next_row(tmp_ for _ in range(10): first = events.read(limit=1, cursor=cursor) cursor = first["next_cursor"] - runtime = next((row for row in first["events"] if row["data"].get("truncated") is True), None) + runtime = next( + (row for row in first["events"] if row["data"].get("truncated") is True), + None, + ) if runtime is not None: break assert runtime is not None assert runtime["data"]["truncated"] is True second = events.read(limit=1, cursor=cursor) - assert [row["event_id"] for row in second["events"] if row["event_id"] == "after"] == ["after"] + assert [ + row["event_id"] for row in second["events"] if row["event_id"] == "after" + ] == ["after"] diff --git a/pkgs/sinnix-agent-gateway/test_execution_jobs.py b/pkgs/sinnix-agent-gateway/test_execution_jobs.py index 353408f3..23eec71b 100644 --- a/pkgs/sinnix-agent-gateway/test_execution_jobs.py +++ b/pkgs/sinnix-agent-gateway/test_execution_jobs.py @@ -1,13 +1,12 @@ from __future__ import annotations -from dataclasses import dataclass, field import json +from dataclasses import dataclass, field from pathlib import Path from typing import Any import anyio import pytest - from sinnix_agent_gateway.app import Runtime, create_server from sinnix_agent_gateway.config import GatewayConfig, ProjectConfig from sinnix_agent_gateway.registry import REGISTRY @@ -100,26 +99,30 @@ def test_public_v2_job_verbs_dispatch_catalog_bound_owner( monkeypatch.setattr( runtime.beads, "change", - lambda _project, operation, parameters, **kwargs: claim_calls.append( - {"operation": operation, "parameters": parameters, **kwargs} - ) - or { - "after": { - "ref": "sinnix://projects/fixture/beads/fixture-1", - "task_revision": "c" * 64, - "etag": "d" * 64, - "fields": {"title": "fixture", "status": "in_progress"}, - }, - "owner_route": "beads.change", - "before_revision": "a" * 64, - "after_revision": "c" * 64, - "owner_history_ref": "sinnix://projects/fixture/beads/fixture-1/history/claim", - }, + lambda _project, operation, parameters, **kwargs: ( + claim_calls.append( + {"operation": operation, "parameters": parameters, **kwargs} + ) + or { + "after": { + "ref": "sinnix://projects/fixture/beads/fixture-1", + "task_revision": "c" * 64, + "etag": "d" * 64, + "fields": {"title": "fixture", "status": "in_progress"}, + }, + "owner_route": "beads.change", + "before_revision": "a" * 64, + "after_revision": "c" * 64, + "owner_history_ref": "sinnix://projects/fixture/beads/fixture-1/history/claim", + } + ), ) monkeypatch.setattr( runtime.projects, "checkout", - lambda _project, _checkout: {"checkout": {"checkout_id": "default", "head": "c" * 40}}, + lambda _project, _checkout: { + "checkout": {"checkout_id": "default", "head": "c" * 40} + }, ) server = create_server(runtime.config, "operator") @@ -160,7 +163,10 @@ async def invoke( assert started["result"]["action"] == "agent.for_bead" assert started["data"]["ref"] == f"sinnix://jobs/{job_id}" assert started["data"]["bead_ref"] == "sinnix://projects/fixture/beads/fixture-1" - assert started["data"]["claim_ref"] == "sinnix://projects/fixture/beads/fixture-1/claims/" + "d" * 64 + assert ( + started["data"]["claim_ref"] + == "sinnix://projects/fixture/beads/fixture-1/claims/" + "d" * 64 + ) assert started["data"]["atomicity"] == "native_claim_then_daemon_launch" assert cancelled["result"]["action"] == "jobs.cancel" assert cancelled["data"]["cancel"]["cancel_requested"] is False @@ -188,11 +194,16 @@ async def invoke( "request_id": "2e46daf5-e9b1-4c6e-b99d-bcd46631730b", "assignment_ref": None, } - assert claim_calls == [{ - "operation": "claim", - "parameters": {"id": "fixture-1"}, - "preconditions": {"expected_task_revision": "a" * 64, "expected_etag": "b" * 64}, - }] + assert claim_calls == [ + { + "operation": "claim", + "parameters": {"id": "fixture-1"}, + "preconditions": { + "expected_task_revision": "a" * 64, + "expected_etag": "b" * 64, + }, + } + ] def test_v2_shell_run_wait_and_get_forward_one_daemon_job_identity( @@ -276,7 +287,9 @@ def test_v2_job_summary_preserves_daemon_service_lease_metadata(tmp_path: Path) "readiness": "project-command", "lifetime": "job", "state": "active", - "ports": [{"name": "http", "environment": "FIXTURE_HTTP_PORT", "port": 41000}], + "ports": [ + {"name": "http", "environment": "FIXTURE_HTTP_PORT", "port": 41000} + ], }, } @@ -300,14 +313,24 @@ def test_v2_declared_operation_run_routes_only_typed_contract_and_job_projection "readiness": "project-command", "lifetime": "job", "state": "active", - "ports": [{"name": "http", "environment": "FIXTURE_HTTP_PORT", "port": 41000}], + "ports": [ + {"name": "http", "environment": "FIXTURE_HTTP_PORT", "port": 41000} + ], }, } - daemon.responses = {"job.start": projection, "job.get": projection, "job.cancel": {"job_id": job_id, "cancel_requested": False}} - monkeypatch.setattr(Runtime, "create", classmethod(lambda _cls, _config, _principal: runtime)) + daemon.responses = { + "job.start": projection, + "job.get": projection, + "job.cancel": {"job_id": job_id, "cancel_requested": False}, + } + monkeypatch.setattr( + Runtime, "create", classmethod(lambda _cls, _config, _principal: runtime) + ) server = create_server(runtime.config, "agent-control") - async def invoke(target: Any, name: str, arguments: dict[str, Any]) -> dict[str, Any]: + async def invoke( + target: Any, name: str, arguments: dict[str, Any] + ) -> dict[str, Any]: response = await target.call_tool(name, arguments) assert response.structured_content is not None return response.structured_content @@ -341,7 +364,12 @@ async def invoke(target: Any, name: str, arguments: dict[str, Any]) -> dict[str, assert started["data"]["lease"]["ports"][0]["port"] == 41000 assert status["data"]["job"]["lease"] == projection["lease"] assert cancelled["data"]["cancel"]["cancel_requested"] is False - assert [request.operation for request in daemon.calls] == ["job.start", "job.get", "job.get", "job.cancel"] + assert [request.operation for request in daemon.calls] == [ + "job.start", + "job.get", + "job.get", + "job.cancel", + ] assert daemon.calls[0].arguments == { "project_id": "fixture", "operation": "service", @@ -354,7 +382,9 @@ def test_v2_declared_operation_run_rejects_overlays_and_preserves_daemon_errors( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: runtime, daemon = runtime_with_daemon(tmp_path, "operator") - monkeypatch.setattr(Runtime, "create", classmethod(lambda _cls, _config, _principal: runtime)) + monkeypatch.setattr( + Runtime, "create", classmethod(lambda _cls, _config, _principal: runtime) + ) server = create_server(runtime.config, "operator") async def reject_overlay() -> dict[str, Any]: @@ -424,7 +454,9 @@ def test_v2_bead_agent_run_and_cancel_preserve_daemon_cancellation_truth( monkeypatch.setattr( runtime.projects, "checkout", - lambda _project, _checkout: {"checkout": {"checkout_id": "default", "head": "c" * 40}}, + lambda _project, _checkout: { + "checkout": {"checkout_id": "default", "head": "c" * 40} + }, ) started = runtime.execute_v2( REGISTRY.action("agent.for_bead"), @@ -489,7 +521,9 @@ def test_claimed_bead_launch_failure_is_partial_completion_and_idempotent( monkeypatch.setattr( runtime.projects, "checkout", - lambda _project, _checkout: {"checkout": {"checkout_id": "default", "head": "c" * 40}}, + lambda _project, _checkout: { + "checkout": {"checkout_id": "default", "head": "c" * 40} + }, ) monkeypatch.setattr( runtime.beads, @@ -519,10 +553,17 @@ def test_claimed_bead_launch_failure_is_partial_completion_and_idempotent( first = runtime.execute_v2( REGISTRY.action("agent.for_bead"), lambda: runtime.v2_run_for_bead( - reference=request["ref"], checkout_id="default", claim_mode="claim", - assignment_ref=None, instructions=None, backend="codex", model="gpt-5.6-terra", - reasoning_effort="high", timeout_seconds=3_600, - credential_profile="subscription", request_id=request["request_id"], + reference=request["ref"], + checkout_id="default", + claim_mode="claim", + assignment_ref=None, + instructions=None, + backend="codex", + model="gpt-5.6-terra", + reasoning_effort="high", + timeout_seconds=3_600, + credential_profile="subscription", + request_id=request["request_id"], ), request, ) @@ -543,23 +584,117 @@ def test_bead_review_and_evidence_close_require_bound_successful_job( ) -> None: runtime, daemon = runtime_with_daemon(tmp_path, "operator") job_id = "3b0237a0-32a9-4f6b-a014-2a0ecfd2f75c" - bead = {"ref": "sinnix://projects/fixture/beads/fixture-1", "task_revision": "a" * 64, "etag": "b" * 64, "fields": {"title": "fixture", "status": "open"}} - binding = {"bead_ref": bead["ref"], "project_ref": "sinnix://projects/fixture", "checkout_ref": "sinnix://projects/fixture/checkouts/default", "task_revision": "z" * 64, "task_etag": "y" * 64, "claim_ref": None, "claim_receipt": None, "request_id": "2e46daf5-e9b1-4c6e-b99d-bcd46631730b", "assignment_ref": None} - daemon.responses["job.get"] = {"job_id": job_id, "state": {"phase": "succeeded"}, "checkout": {"checkout_id": "default", "head": "c" * 40}, "contract": {"bead_binding": binding}, "artifacts": {"result": {"ref": f"sinnix://jobs/{job_id}/artifacts/result", "kind": "last-message"}}} - daemon.responses["job.result"] = {"job_id": job_id, "kind": "last-message", "content": "untrusted prose", "truncated": False, "artifact": {"ref": f"sinnix://jobs/{job_id}/artifacts/result", "kind": "last-message"}} + bead = { + "ref": "sinnix://projects/fixture/beads/fixture-1", + "task_revision": "a" * 64, + "etag": "b" * 64, + "fields": {"title": "fixture", "status": "open"}, + } + binding = { + "bead_ref": bead["ref"], + "project_ref": "sinnix://projects/fixture", + "checkout_ref": "sinnix://projects/fixture/checkouts/default", + "task_revision": "z" * 64, + "task_etag": "y" * 64, + "claim_ref": None, + "claim_receipt": None, + "request_id": "2e46daf5-e9b1-4c6e-b99d-bcd46631730b", + "assignment_ref": None, + } + daemon.responses["job.get"] = { + "job_id": job_id, + "state": {"phase": "succeeded"}, + "checkout": {"checkout_id": "default", "head": "c" * 40}, + "contract": {"bead_binding": binding}, + "artifacts": { + "result": { + "ref": f"sinnix://jobs/{job_id}/artifacts/result", + "kind": "last-message", + } + }, + } + daemon.responses["job.result"] = { + "job_id": job_id, + "kind": "last-message", + "content": "untrusted prose", + "truncated": False, + "artifact": { + "ref": f"sinnix://jobs/{job_id}/artifacts/result", + "kind": "last-message", + }, + } monkeypatch.setattr(runtime.beads, "get", lambda *_args, **_kwargs: bead) changes: list[dict[str, Any]] = [] - monkeypatch.setattr(runtime.beads, "change", lambda _project, operation, parameters, **kwargs: changes.append({"operation": operation, "parameters": parameters, **kwargs}) or {"after": bead}) - monkeypatch.setattr(runtime.projects, "checkout", lambda *_args: {"checkout": {"checkout_id": "default", "head": "d" * 40}}) + monkeypatch.setattr( + runtime.beads, + "change", + lambda _project, operation, parameters, **kwargs: ( + changes.append({"operation": operation, "parameters": parameters, **kwargs}) + or {"after": bead} + ), + ) + monkeypatch.setattr( + runtime.projects, + "checkout", + lambda *_args: {"checkout": {"checkout_id": "default", "head": "d" * 40}}, + ) monkeypatch.setattr(runtime.projects, "summary", lambda *_args: {"head": "d" * 40}) - monkeypatch.setattr(runtime.projects, "commit_range", lambda *_args, **_kwargs: {"base_revision": "c" * 40, "head_revision": "d" * 40, "range": f"{'c' * 40}..{'d' * 40}", "relation": "base_is_ancestor", "merge_base": "c" * 40, "diff": "fixture diff", "truncated": False}) + monkeypatch.setattr( + runtime.projects, + "commit_range", + lambda *_args, **_kwargs: { + "base_revision": "c" * 40, + "head_revision": "d" * 40, + "range": f"{'c' * 40}..{'d' * 40}", + "relation": "base_is_ancestor", + "merge_base": "c" * 40, + "diff": "fixture diff", + "truncated": False, + }, + ) review = runtime.v2_context(bead["ref"], "bead.review", f"sinnix://jobs/{job_id}") with pytest.raises(ProtocolError, match="current checkout"): - runtime.v2_beads_change(reference=bead["ref"], operation="close_with_evidence", parameters={"verdict": "accepted", "residuals": [], "evidence_refs": [f"sinnix://jobs/{job_id}", f"sinnix://jobs/{job_id}/artifacts/result"], "job_ref": f"sinnix://jobs/{job_id}", "code_revision": "c" * 40, "task_revision": "a" * 64, "task_etag": "b" * 64}, preconditions=None) - closed = runtime.v2_beads_change(reference=bead["ref"], operation="close_with_evidence", parameters={"verdict": "accepted", "residuals": [], "evidence_refs": [f"sinnix://jobs/{job_id}", f"sinnix://jobs/{job_id}/artifacts/result"], "job_ref": f"sinnix://jobs/{job_id}", "code_revision": "d" * 40, "task_revision": "a" * 64, "task_etag": "b" * 64}, preconditions=None) + runtime.v2_beads_change( + reference=bead["ref"], + operation="close_with_evidence", + parameters={ + "verdict": "accepted", + "residuals": [], + "evidence_refs": [ + f"sinnix://jobs/{job_id}", + f"sinnix://jobs/{job_id}/artifacts/result", + ], + "job_ref": f"sinnix://jobs/{job_id}", + "code_revision": "c" * 40, + "task_revision": "a" * 64, + "task_etag": "b" * 64, + }, + preconditions=None, + ) + closed = runtime.v2_beads_change( + reference=bead["ref"], + operation="close_with_evidence", + parameters={ + "verdict": "accepted", + "residuals": [], + "evidence_refs": [ + f"sinnix://jobs/{job_id}", + f"sinnix://jobs/{job_id}/artifacts/result", + ], + "job_ref": f"sinnix://jobs/{job_id}", + "code_revision": "d" * 40, + "task_revision": "a" * 64, + "task_etag": "b" * 64, + }, + preconditions=None, + ) - assert review["revision_mismatch"] == {"task_revision": True, "task_etag": True, "code_revision": True} + assert review["revision_mismatch"] == { + "task_revision": True, + "task_etag": True, + "code_revision": True, + } assert review["checkout"]["commit_range"]["range"] == f"{'c' * 40}..{'d' * 40}" assert review["evidence"]["result"]["availability"] == "available" assert review["evidence"]["tests"]["availability"] == "unavailable" @@ -568,9 +703,25 @@ def test_bead_review_and_evidence_close_require_bound_successful_job( assert changes[0]["parameters"]["force"] is True assert json.loads(changes[0]["parameters"]["reason"])["code_revision"] == "d" * 40 - daemon.responses["job.get"] = {**daemon.responses["job.get"], "state": {"phase": "cancelled"}} + daemon.responses["job.get"] = { + **daemon.responses["job.get"], + "state": {"phase": "cancelled"}, + } with pytest.raises(ProtocolError, match="cannot close"): - runtime.v2_beads_change(reference=bead["ref"], operation="close_with_evidence", parameters={"verdict": "accepted", "residuals": [], "evidence_refs": [f"sinnix://jobs/{job_id}"], "job_ref": f"sinnix://jobs/{job_id}", "code_revision": "d" * 40, "task_revision": "a" * 64, "task_etag": "b" * 64}, preconditions=None) + runtime.v2_beads_change( + reference=bead["ref"], + operation="close_with_evidence", + parameters={ + "verdict": "accepted", + "residuals": [], + "evidence_refs": [f"sinnix://jobs/{job_id}"], + "job_ref": f"sinnix://jobs/{job_id}", + "code_revision": "d" * 40, + "task_revision": "a" * 64, + "task_etag": "b" * 64, + }, + preconditions=None, + ) def test_bead_review_exposes_exact_range_and_absent_result_truth( @@ -578,12 +729,48 @@ def test_bead_review_exposes_exact_range_and_absent_result_truth( ) -> None: runtime, daemon = runtime_with_daemon(tmp_path, "operator") job_id = "3b0237a0-32a9-4f6b-a014-2a0ecfd2f75c" - bead = {"ref": "sinnix://projects/fixture/beads/fixture-1", "task_revision": "a" * 64, "etag": "b" * 64} - binding = {"bead_ref": bead["ref"], "project_ref": "sinnix://projects/fixture", "checkout_ref": "sinnix://projects/fixture/checkouts/default", "task_revision": "a" * 64, "task_etag": "b" * 64, "claim_ref": None, "claim_receipt": None, "request_id": "2e46daf5-e9b1-4c6e-b99d-bcd46631730b", "assignment_ref": None} - daemon.responses["job.get"] = {"job_id": job_id, "state": {"phase": "succeeded"}, "checkout": {"checkout_id": "default", "head": "c" * 40}, "contract": {"bead_binding": binding}, "artifacts": {"result": None}} + bead = { + "ref": "sinnix://projects/fixture/beads/fixture-1", + "task_revision": "a" * 64, + "etag": "b" * 64, + } + binding = { + "bead_ref": bead["ref"], + "project_ref": "sinnix://projects/fixture", + "checkout_ref": "sinnix://projects/fixture/checkouts/default", + "task_revision": "a" * 64, + "task_etag": "b" * 64, + "claim_ref": None, + "claim_receipt": None, + "request_id": "2e46daf5-e9b1-4c6e-b99d-bcd46631730b", + "assignment_ref": None, + } + daemon.responses["job.get"] = { + "job_id": job_id, + "state": {"phase": "succeeded"}, + "checkout": {"checkout_id": "default", "head": "c" * 40}, + "contract": {"bead_binding": binding}, + "artifacts": {"result": None}, + } monkeypatch.setattr(runtime.beads, "get", lambda *_args, **_kwargs: bead) - monkeypatch.setattr(runtime.projects, "checkout", lambda *_args: {"checkout": {"checkout_id": "default", "head": "d" * 40}}) - monkeypatch.setattr(runtime.projects, "commit_range", lambda *_args, **_kwargs: {"base_revision": "c" * 40, "head_revision": "d" * 40, "range": f"{'c' * 40}..{'d' * 40}", "relation": "base_is_ancestor", "merge_base": "c" * 40, "diff": "exact fixture diff", "truncated": False}) + monkeypatch.setattr( + runtime.projects, + "checkout", + lambda *_args: {"checkout": {"checkout_id": "default", "head": "d" * 40}}, + ) + monkeypatch.setattr( + runtime.projects, + "commit_range", + lambda *_args, **_kwargs: { + "base_revision": "c" * 40, + "head_revision": "d" * 40, + "range": f"{'c' * 40}..{'d' * 40}", + "relation": "base_is_ancestor", + "merge_base": "c" * 40, + "diff": "exact fixture diff", + "truncated": False, + }, + ) review = runtime.v2_context(bead["ref"], "bead.review", f"sinnix://jobs/{job_id}") @@ -597,8 +784,14 @@ def test_bead_review_exposes_exact_range_and_absent_result_truth( "truncated": False, } assert review["evidence"] == { - "result": {"availability": "unavailable", "reason": "job declares no result artifact"}, - "tests": {"availability": "unavailable", "reason": "bead-bound attested-agent jobs declare no structured test result"}, + "result": { + "availability": "unavailable", + "reason": "job declares no result artifact", + }, + "tests": { + "availability": "unavailable", + "reason": "bead-bound attested-agent jobs declare no structured test result", + }, } assert "tests_and_artifacts" not in review assert [request.operation for request in daemon.calls] == ["job.get"] @@ -610,58 +803,132 @@ def test_agent_control_bead_scope_requires_matching_current_assignment( runtime, daemon = runtime_with_daemon(tmp_path, "agent-control") assignment_id = "3b0237a0-32a9-4f6b-a014-2a0ecfd2f75c" assignment_ref = f"sinnix://jobs/{assignment_id}" - bead = {"ref": "sinnix://projects/fixture/beads/fixture-1", "task_revision": "a" * 64, "etag": "b" * 64, "fields": {"title": "assigned"}, "metadata": {"write_scope": '["pkgs/sinnixd/"]'}} - binding = {"bead_ref": bead["ref"], "project_ref": "sinnix://projects/fixture", "checkout_ref": "sinnix://projects/fixture/checkouts/default", "task_revision": "a" * 64, "task_etag": "b" * 64, "claim_ref": None, "claim_receipt": None, "request_id": "2e46daf5-e9b1-4c6e-b99d-bcd46631730b", "assignment_ref": None} - daemon.responses["job.get"] = {"job_id": assignment_id, "principal": "agent-control", "state": {"phase": "running"}, "checkout": {"checkout_id": "default", "head": "c" * 40}, "contract": {"bead_binding": binding}, "artifacts": {"result": None}} - daemon.responses["job.agent.start"] = {"job_id": "4a42f848-9057-4cef-9d27-80a022c0e16f", "state": {"phase": "running"}} + bead = { + "ref": "sinnix://projects/fixture/beads/fixture-1", + "task_revision": "a" * 64, + "etag": "b" * 64, + "fields": {"title": "assigned"}, + "metadata": {"write_scope": '["pkgs/sinnixd/"]'}, + } + binding = { + "bead_ref": bead["ref"], + "project_ref": "sinnix://projects/fixture", + "checkout_ref": "sinnix://projects/fixture/checkouts/default", + "task_revision": "a" * 64, + "task_etag": "b" * 64, + "claim_ref": None, + "claim_receipt": None, + "request_id": "2e46daf5-e9b1-4c6e-b99d-bcd46631730b", + "assignment_ref": None, + } + daemon.responses["job.get"] = { + "job_id": assignment_id, + "principal": "agent-control", + "state": {"phase": "running"}, + "checkout": {"checkout_id": "default", "head": "c" * 40}, + "contract": {"bead_binding": binding}, + "artifacts": {"result": None}, + } + daemon.responses["job.agent.start"] = { + "job_id": "4a42f848-9057-4cef-9d27-80a022c0e16f", + "state": {"phase": "running"}, + } monkeypatch.setattr(runtime.beads, "get", lambda *_args, **_kwargs: bead) - monkeypatch.setattr(runtime.projects, "checkout", lambda *_args: {"checkout": {"checkout_id": "default", "head": "c" * 40}}) + monkeypatch.setattr( + runtime.projects, + "checkout", + lambda *_args: {"checkout": {"checkout_id": "default", "head": "c" * 40}}, + ) monkeypatch.setattr(runtime.projects, "summary", lambda *_args: {"head": "c" * 40}) context = runtime.v2_context(bead["ref"], "bead.work", assignment_ref) started = runtime.v2_run_for_bead( - reference=bead["ref"], checkout_id="default", claim_mode="none", assignment_ref=assignment_ref, - instructions="private launch instruction", backend="codex", model="gpt-5.6-terra", - reasoning_effort="high", timeout_seconds=60, credential_profile="subscription", + reference=bead["ref"], + checkout_id="default", + claim_mode="none", + assignment_ref=assignment_ref, + instructions="private launch instruction", + backend="codex", + model="gpt-5.6-terra", + reasoning_effort="high", + timeout_seconds=60, + credential_profile="subscription", request_id="4a42f848-9057-4cef-9d27-80a022c0e16f", ) assert context["assignment"]["ref"] == assignment_ref assert started["assignment_ref"] == assignment_ref assert daemon.calls[-1].principal == "agent-control" - assert daemon.calls[-1].arguments["bead_binding"]["assignment_ref"] == assignment_ref - assert daemon.calls[-1].arguments["bead_binding"]["write_scope"] == ["pkgs/sinnixd/"] - assert "private launch instruction" not in daemon.calls[-1].arguments["bead_binding"].values() + assert ( + daemon.calls[-1].arguments["bead_binding"]["assignment_ref"] == assignment_ref + ) + assert daemon.calls[-1].arguments["bead_binding"]["write_scope"] == [ + "pkgs/sinnixd/" + ] + assert ( + "private launch instruction" + not in daemon.calls[-1].arguments["bead_binding"].values() + ) malformed_scope = {**bead, "metadata": {"write_scope": "not-json"}} monkeypatch.setattr(runtime.beads, "get", lambda *_args, **_kwargs: malformed_scope) with pytest.raises(ProtocolError, match="JSON array"): runtime.v2_run_for_bead( - reference=bead["ref"], checkout_id="default", claim_mode="none", assignment_ref=assignment_ref, - instructions=None, backend="codex", model="gpt-5.6-terra", reasoning_effort="high", - timeout_seconds=60, credential_profile="subscription", request_id="4a42f848-9057-4cef-9d27-80a022c0e16f", + reference=bead["ref"], + checkout_id="default", + claim_mode="none", + assignment_ref=assignment_ref, + instructions=None, + backend="codex", + model="gpt-5.6-terra", + reasoning_effort="high", + timeout_seconds=60, + credential_profile="subscription", + request_id="4a42f848-9057-4cef-9d27-80a022c0e16f", ) monkeypatch.setattr(runtime.beads, "get", lambda *_args, **_kwargs: bead) foreign = {**binding, "bead_ref": "sinnix://projects/fixture/beads/fixture-2"} - daemon.responses["job.get"] = {**daemon.responses["job.get"], "contract": {"bead_binding": foreign}} + daemon.responses["job.get"] = { + **daemon.responses["job.get"], + "contract": {"bead_binding": foreign}, + } with pytest.raises(ProtocolError, match="not the requested"): runtime.v2_context(bead["ref"], "bead.work", assignment_ref) stale = {**binding, "task_etag": "d" * 64} - daemon.responses["job.get"] = {**daemon.responses["job.get"], "contract": {"bead_binding": stale}} + daemon.responses["job.get"] = { + **daemon.responses["job.get"], + "contract": {"bead_binding": stale}, + } with pytest.raises(ProtocolError, match="stale"): runtime.v2_run_for_bead( - reference=bead["ref"], checkout_id="default", claim_mode="none", assignment_ref=assignment_ref, - instructions=None, backend="codex", model="gpt-5.6-terra", reasoning_effort="high", - timeout_seconds=60, credential_profile="subscription", request_id="4a42f848-9057-4cef-9d27-80a022c0e16f", + reference=bead["ref"], + checkout_id="default", + claim_mode="none", + assignment_ref=assignment_ref, + instructions=None, + backend="codex", + model="gpt-5.6-terra", + reasoning_effort="high", + timeout_seconds=60, + credential_profile="subscription", + request_id="4a42f848-9057-4cef-9d27-80a022c0e16f", ) with pytest.raises(ProtocolError, match="requires an assignment"): runtime.v2_run_for_bead( - reference=bead["ref"], checkout_id="default", claim_mode="none", assignment_ref=None, - instructions=None, backend="codex", model="gpt-5.6-terra", reasoning_effort="high", - timeout_seconds=60, credential_profile="subscription", request_id="4a42f848-9057-4cef-9d27-80a022c0e16f", + reference=bead["ref"], + checkout_id="default", + claim_mode="none", + assignment_ref=None, + instructions=None, + backend="codex", + model="gpt-5.6-terra", + reasoning_effort="high", + timeout_seconds=60, + credential_profile="subscription", + request_id="4a42f848-9057-4cef-9d27-80a022c0e16f", ) @@ -686,13 +953,21 @@ def test_agent_control_bead_review_authorizes_assignment_before_bead_read( "contract": {"bead_binding": binding}, "artifacts": {"result": None}, } - monkeypatch.setattr(runtime.beads, "get", lambda *_args, **_kwargs: pytest.fail("bead owner was read before assignment authorization")) + monkeypatch.setattr( + runtime.beads, + "get", + lambda *_args, **_kwargs: pytest.fail( + "bead owner was read before assignment authorization" + ), + ) with pytest.raises(ProtocolError, match="not the requested"): runtime.v2_context(bead_ref, "bead.review", f"sinnix://jobs/{assignment_id}") -def test_v2_jobs_query_bounds_daemon_job_list_and_preserves_job_refs(tmp_path: Path) -> None: +def test_v2_jobs_query_bounds_daemon_job_list_and_preserves_job_refs( + tmp_path: Path, +) -> None: runtime, daemon = runtime_with_daemon(tmp_path, "observer") daemon.responses = { "job.list": { @@ -754,9 +1029,7 @@ def test_v2_jobs_query_emits_a_declared_typed_failure_for_an_invalid_bound( def test_v2_job_routes_preserve_principal_policy(tmp_path: Path) -> None: - observer, daemon = runtime_with_daemon( - tmp_path, "observer", max_result_bytes=1_024 - ) + observer, daemon = runtime_with_daemon(tmp_path, "observer", max_result_bytes=1_024) denied = observer.execute_v2( REGISTRY.action("shell.run"), lambda: observer.v2_run_shell( diff --git a/pkgs/sinnix-agent-gateway/test_files.py b/pkgs/sinnix-agent-gateway/test_files.py index c64a2803..ab0fb162 100644 --- a/pkgs/sinnix-agent-gateway/test_files.py +++ b/pkgs/sinnix-agent-gateway/test_files.py @@ -3,7 +3,6 @@ from pathlib import Path import pytest - from sinnix_agent_gateway.capabilities import PolicyError, Principal from sinnix_agent_gateway.config import GatewayConfig from sinnix_agent_gateway.files import FileError, HostFileService @@ -53,9 +52,7 @@ def test_operator_write_uses_compare_and_swap_and_receipts(tmp_path: Path) -> No operator.write( "replace", str(target), content="lost update", expected_sha256=before ) - removed = operator.write( - "remove", str(target), expected_sha256=appended["sha256"] - ) + removed = operator.write("remove", str(target), expected_sha256=appended["sha256"]) assert removed["removed"] is True assert not target.exists() @@ -99,7 +96,9 @@ def test_operator_rejects_symlink_mutation(tmp_path: Path) -> None: assert target.read_text() == "before" -def test_observer_cannot_read_secret_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: +def test_observer_cannot_read_secret_root( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: from sinnix_agent_gateway import files secret_root = tmp_path / "secret-root" diff --git a/pkgs/sinnix-agent-gateway/test_gateway_codegen.py b/pkgs/sinnix-agent-gateway/test_gateway_codegen.py index 7b852ad8..14e6fe7f 100644 --- a/pkgs/sinnix-agent-gateway/test_gateway_codegen.py +++ b/pkgs/sinnix-agent-gateway/test_gateway_codegen.py @@ -4,24 +4,26 @@ from pathlib import Path from jsonschema import Draft202012Validator - from sinnix_agent_gateway.gateway_codegen import ( DOCS_PATH, FIXTURE_PATH, REFERENCE_PATH, SKILL_PATH, - check_artifacts, catalog_payload, + check_artifacts, render_fixtures, render_reference, render_skill, update_docs, ) - candidate_root = Path(__file__).resolve().parents[2] ROOT = candidate_root if (candidate_root / DOCS_PATH).exists() else None -FIXTURE_FILE = (ROOT / FIXTURE_PATH) if ROOT is not None else Path(__file__).parent / "fixtures" / FIXTURE_PATH.name +FIXTURE_FILE = ( + (ROOT / FIXTURE_PATH) + if ROOT is not None + else Path(__file__).parent / "fixtures" / FIXTURE_PATH.name +) def test_generated_artifacts_are_current_and_deterministic() -> None: @@ -33,7 +35,10 @@ def test_generated_artifacts_are_current_and_deterministic() -> None: "---\nname: agent-gateway\ndescription: Use when invoking, inspecting, or documenting " ) assert render_fixtures() == render_fixtures() - assert json.loads(FIXTURE_FILE.read_text())["action_catalog_hash"] == catalog_payload()["action_catalog_hash"] + assert ( + json.loads(FIXTURE_FILE.read_text())["action_catalog_hash"] + == catalog_payload()["action_catalog_hash"] + ) def test_every_generated_example_validates_against_the_live_action_schema() -> None: @@ -45,7 +50,9 @@ def test_every_generated_example_validates_against_the_live_action_schema() -> N assert errors == [], (fixture["action"], errors) -def test_corrupting_an_action_name_or_field_fails_generation_check(tmp_path: Path) -> None: +def test_corrupting_an_action_name_or_field_fails_generation_check( + tmp_path: Path, +) -> None: artifacts = { REFERENCE_PATH: render_reference(), SKILL_PATH: render_skill(), @@ -57,7 +64,9 @@ def test_corrupting_an_action_name_or_field_fails_generation_check(tmp_path: Pat target.parent.mkdir(parents=True, exist_ok=True) target.write_text(content) reference = tmp_path / REFERENCE_PATH - reference.write_text(reference.read_text().replace("`gateway.status`", "`gateway.corrupt`", 1)) + reference.write_text( + reference.read_text().replace("`gateway.status`", "`gateway.corrupt`", 1) + ) assert check_artifacts(tmp_path) reference.write_text(render_reference().replace("Catalog SHA-256", "Corrupt field")) diff --git a/pkgs/sinnix-agent-gateway/test_machine_actions.py b/pkgs/sinnix-agent-gateway/test_machine_actions.py index 94ca654a..9e8fa4f4 100644 --- a/pkgs/sinnix-agent-gateway/test_machine_actions.py +++ b/pkgs/sinnix-agent-gateway/test_machine_actions.py @@ -4,10 +4,12 @@ from pathlib import Path import pytest - from sinnix_agent_gateway.capabilities import PolicyError, Principal from sinnix_agent_gateway.config import GatewayConfig -from sinnix_agent_gateway.machine_actions import MachineActionError, MachineActionService +from sinnix_agent_gateway.machine_actions import ( + MachineActionError, + MachineActionService, +) class FakeResponse: @@ -87,7 +89,9 @@ def test_machine_action_forwards_exact_owner_request(tmp_path: Path) -> None: } -def test_machine_action_snapshot_exposes_bounded_authority_revision(tmp_path: Path) -> None: +def test_machine_action_snapshot_exposes_bounded_authority_revision( + tmp_path: Path, +) -> None: actions, connection = service( tmp_path, "operator", diff --git a/pkgs/sinnix-agent-gateway/test_mcp_broker.py b/pkgs/sinnix-agent-gateway/test_mcp_broker.py index 164b5455..a6a95a34 100644 --- a/pkgs/sinnix-agent-gateway/test_mcp_broker.py +++ b/pkgs/sinnix-agent-gateway/test_mcp_broker.py @@ -9,17 +9,16 @@ import anyio import pytest - from sinnix_agent_gateway.artifacts import ArtifactService from sinnix_agent_gateway.capabilities import Principal from sinnix_agent_gateway.config import GatewayConfig +from sinnix_agent_gateway.mcp_broker import McpBrokerError, McpBrokerService from sinnix_mcp.execution import ( EnvironmentProfile, ExecutionProfile, ExecutionResult, OwnerExecution, ) -from sinnix_agent_gateway.mcp_broker import McpBrokerError, McpBrokerService class FakeTransport: @@ -72,7 +71,10 @@ async def list_tools(self) -> object: SimpleNamespace( name="lookup", description="Fixture lookup", - inputSchema={"type": "object", "properties": {"query": {"type": "string"}}}, + inputSchema={ + "type": "object", + "properties": {"query": {"type": "string"}}, + }, annotations=SimpleNamespace(read_only_hint=True), ) ] @@ -87,7 +89,9 @@ async def call_tool(self, name: str, arguments: dict[str, object]) -> object: ) -def broker_service(tmp_path: Path, principal_name: str, max_bytes: int = 262_144) -> McpBrokerService: +def broker_service( + tmp_path: Path, principal_name: str, max_bytes: int = 262_144 +) -> McpBrokerService: config = GatewayConfig( state_dir=tmp_path / "state", projects={}, @@ -143,7 +147,9 @@ def test_observer_catalog_reports_missing_user_bus_environment( broker = broker_service(tmp_path, "observer") catalog = anyio.run(broker.catalog) - fixture = next(server for server in catalog["servers"] if server["name"] == "fixture") + fixture = next( + server for server in catalog["servers"] if server["name"] == "fixture" + ) assert fixture["availability"] == "unavailable" assert fixture["failure_class"] == "environment_unavailable" @@ -157,7 +163,9 @@ async def list_tools(self) -> object: description="Fixture lookup", inputSchema={ "type": "object", - "properties": {"query": {"type": "string", "description": "x" * 8_000}}, + "properties": { + "query": {"type": "string", "description": "x" * 8_000} + }, }, annotations=SimpleNamespace(read_only_hint=True), ) @@ -175,11 +183,18 @@ def test_catalog_artifactizes_an_oversized_tool_schema( "sinnix_agent_gateway.mcp_broker.stdio_client", lambda _params, **_kwargs: FakeTransport(), ) - monkeypatch.setattr("sinnix_agent_gateway.mcp_broker.ClientSession", LargeSchemaSession) + monkeypatch.setattr( + "sinnix_agent_gateway.mcp_broker.ClientSession", LargeSchemaSession + ) catalog = anyio.run(broker.catalog) - assert len(json.dumps(catalog, separators=(",", ":")).encode()) <= broker.config.max_result_bytes - fixture = next(server for server in catalog["servers"] if server["name"] == "fixture") + assert ( + len(json.dumps(catalog, separators=(",", ":")).encode()) + <= broker.config.max_result_bytes + ) + fixture = next( + server for server in catalog["servers"] if server["name"] == "fixture" + ) tool = fixture["tools"][0] assert tool["input_schema"]["x-sinnix-schema-truncated"] is True assert tool["input_schema_artifact"]["ref"].startswith("sinnix://artifacts/") @@ -216,7 +231,18 @@ def test_catalog_probes_admitted_servers_and_keeps_exclusions_static( "availability": "available", "tool_count": 1, "read_only_tool_count": 1, - "tools": [{"name": "lookup", "ref": "sinnix://mcp/fixture/tools/lookup", "description": "Fixture lookup", "input_schema": {"type": "object", "properties": {"query": {"type": "string"}}}, "effect": "read"}], + "tools": [ + { + "name": "lookup", + "ref": "sinnix://mcp/fixture/tools/lookup", + "description": "Fixture lookup", + "input_schema": { + "type": "object", + "properties": {"query": {"type": "string"}}, + }, + "effect": "read", + } + ], }, ] } @@ -272,7 +298,15 @@ def test_catalog_probes_real_stdio_mcp_fixture(tmp_path: Path) -> None: "availability": "available", "tool_count": 1, "read_only_tool_count": 1, - "tools": [{"name": "fixture_read", "ref": "sinnix://mcp/fixture/tools/fixture_read", "description": "Fixture read tool", "input_schema": {"type": "object", "properties": {}}, "effect": "read"}], + "tools": [ + { + "name": "fixture_read", + "ref": "sinnix://mcp/fixture/tools/fixture_read", + "description": "Fixture read tool", + "input_schema": {"type": "object", "properties": {}}, + "effect": "read", + } + ], } @@ -339,14 +373,19 @@ def stdio(parameters: object, **_kwargs: object) -> FakeTransport: monkeypatch.setattr("sinnix_agent_gateway.mcp_broker.stdio_client", stdio) monkeypatch.setattr("sinnix_agent_gateway.mcp_broker.ClientSession", FakeSession) - anyio.run(lambda: broker.call("fixture", "lookup", {"query": "fixture"}, write=False)) + anyio.run( + lambda: broker.call("fixture", "lookup", {"query": "fixture"}, write=False) + ) assert captured[0].command == broker.config.systemd_run_command assert "--property=ReadOnlyPaths=/" in captured[0].args assert "--property=ReadWritePaths=/run/user/1000/fixture-locks" in captured[0].args assert "--property=PrivateNetwork=true" in captured[0].args assert "--property=InaccessiblePaths=/run/user" not in captured[0].args - assert "--setenv=DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus" in captured[0].args + assert ( + "--setenv=DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus" + in captured[0].args + ) assert "--setenv=XDG_RUNTIME_DIR=/run/user/1000" in captured[0].args assert captured[0].env["DBUS_SESSION_BUS_ADDRESS"] == "unix:path=/run/user/1000/bus" assert captured[0].env["XDG_RUNTIME_DIR"] == "/run/user/1000" @@ -362,7 +401,8 @@ def test_observer_broker_stops_failed_read_only_unit( monkeypatch.setenv("XDG_RUNTIME_DIR", "/run/user/1000") stopped = [] monkeypatch.setattr( - "sinnix_agent_gateway.mcp_broker.stdio_client", lambda _params, **_kwargs: FakeTransport() + "sinnix_agent_gateway.mcp_broker.stdio_client", + lambda _params, **_kwargs: FakeTransport(), ) monkeypatch.setattr("sinnix_agent_gateway.mcp_broker.ClientSession", FakeSession) monkeypatch.setattr(broker, "_stop", stopped.append) @@ -401,7 +441,10 @@ def test_broker_artifactizes_large_upstream_response( broker = broker_service(tmp_path, "observer", max_bytes=10) monkeypatch.setenv("DBUS_SESSION_BUS_ADDRESS", "unix:path=/run/user/1000/bus") monkeypatch.setenv("XDG_RUNTIME_DIR", "/run/user/1000") - monkeypatch.setattr("sinnix_agent_gateway.mcp_broker.stdio_client", lambda _params, **_kwargs: FakeTransport()) + monkeypatch.setattr( + "sinnix_agent_gateway.mcp_broker.stdio_client", + lambda _params, **_kwargs: FakeTransport(), + ) monkeypatch.setattr("sinnix_agent_gateway.mcp_broker.ClientSession", FakeSession) result = anyio.run( diff --git a/pkgs/sinnix-agent-gateway/test_memory.py b/pkgs/sinnix-agent-gateway/test_memory.py index a83d4fd4..6dff8fc3 100644 --- a/pkgs/sinnix-agent-gateway/test_memory.py +++ b/pkgs/sinnix-agent-gateway/test_memory.py @@ -3,7 +3,6 @@ from pathlib import Path import pytest - from sinnix_agent_gateway.capabilities import PolicyError, Principal from sinnix_agent_gateway.config import GatewayConfig from sinnix_agent_gateway.memory import MemoryError, MemoryService @@ -42,10 +41,23 @@ def test_memory_search_preserves_raw_source_provenance_and_unavailability( match["authority"] == "authoritative-local-session-jsonl" for match in result["matches"] ) - unavailable = {row["source"]: row for row in result["sources"] if row["availability"] == "unavailable"} - assert unavailable["polylogue"]["reason"] == "upstream is intentionally unavailable on this host" - assert unavailable["sinex"]["reason"] == "upstream is intentionally unavailable on this host" - assert unavailable["lynchpin"]["reason"] == "no gateway semantic adapter is registered yet" + unavailable = { + row["source"]: row + for row in result["sources"] + if row["availability"] == "unavailable" + } + assert ( + unavailable["polylogue"]["reason"] + == "upstream is intentionally unavailable on this host" + ) + assert ( + unavailable["sinex"]["reason"] + == "upstream is intentionally unavailable on this host" + ) + assert ( + unavailable["lynchpin"]["reason"] + == "no gateway semantic adapter is registered yet" + ) def test_memory_get_returns_bounded_source_object(tmp_path: Path) -> None: @@ -62,7 +74,9 @@ def test_memory_get_returns_bounded_source_object(tmp_path: Path) -> None: assert result["bytes"] == 8 -def test_memory_search_rejects_unknown_source_and_denied_principal(tmp_path: Path) -> None: +def test_memory_search_rejects_unknown_source_and_denied_principal( + tmp_path: Path, +) -> None: memory = memory_service(tmp_path, "operator") with pytest.raises(MemoryError, match="unknown memory source"): diff --git a/pkgs/sinnix-agent-gateway/test_parity.py b/pkgs/sinnix-agent-gateway/test_parity.py index 85aac523..9671f2b1 100644 --- a/pkgs/sinnix-agent-gateway/test_parity.py +++ b/pkgs/sinnix-agent-gateway/test_parity.py @@ -15,7 +15,9 @@ def test_legacy_to_v2_parity_is_exhaustive_and_registry_bound() -> None: contract = legacy_parity_contract(REGISTRY) manifest = json.loads( - (Path(__file__).parent / "sinnix_agent_gateway" / "legacy_manifest_v1.json").read_text() + ( + Path(__file__).parent / "sinnix_agent_gateway" / "legacy_manifest_v1.json" + ).read_text() ) assert contract["schema"] == PARITY_SCHEMA @@ -46,7 +48,11 @@ def test_legacy_to_v2_parity_is_exhaustive_and_registry_bound() -> None: for row in contract["rows"] if row["disposition"] == "migrated" ) - assert {row["receipt_policy"] for row in contract["rows"] if row["disposition"] == "migrated"} == {"audit", "owner"} + assert { + row["receipt_policy"] + for row in contract["rows"] + if row["disposition"] == "migrated" + } == {"audit", "owner"} def test_parity_map_preserves_the_checked_in_historical_order() -> None: @@ -71,9 +77,9 @@ def test_job_list_and_agent_launch_have_visible_v2_replacements() -> None: def test_shell_query_semantic_change_is_explicit() -> None: - row = { - row["legacy_tool"]: row for row in legacy_parity_contract(REGISTRY)["rows"] - }["shell_query"] + row = {row["legacy_tool"]: row for row in legacy_parity_contract(REGISTRY)["rows"]}[ + "shell_query" + ] assert row["v2_action"] == "shell.run" assert row["required_principals"] == ("operator",) @@ -84,9 +90,9 @@ def test_shell_query_semantic_change_is_explicit() -> None: def test_machine_report_has_an_evidence_backed_deletion_verdict() -> None: - row = { - row["legacy_tool"]: row for row in legacy_parity_contract(REGISTRY)["rows"] - }["machine_report"] + row = {row["legacy_tool"]: row for row in legacy_parity_contract(REGISTRY)["rows"]}[ + "machine_report" + ] assert row["disposition"] == "deleted" assert row["v2_action"] is None diff --git a/pkgs/sinnix-agent-gateway/test_project_authority.py b/pkgs/sinnix-agent-gateway/test_project_authority.py index 983bf961..7f4769aa 100644 --- a/pkgs/sinnix-agent-gateway/test_project_authority.py +++ b/pkgs/sinnix-agent-gateway/test_project_authority.py @@ -1,11 +1,10 @@ from __future__ import annotations -import subprocess import stat +import subprocess from pathlib import Path import pytest - from sinnix_agent_gateway import projects as projects_module from sinnix_agent_gateway.capabilities import Principal from sinnix_agent_gateway.config import GatewayConfig, ProjectConfig @@ -13,7 +12,9 @@ def git(path: Path, *arguments: str) -> None: - subprocess.run(["git", "-C", str(path), *arguments], check=True, capture_output=True) + subprocess.run( + ["git", "-C", str(path), *arguments], check=True, capture_output=True + ) def project_service(tmp_path: Path) -> tuple[ProjectService, Path, Path]: @@ -92,7 +93,9 @@ def test_project_mutation_publishes_through_pinned_parent_when_replaced_by_symli original_open = projects_module._open_pinned_directory replaced = False - def hostile_open(config: ProjectConfig, parts: tuple[str, ...], *, create: bool) -> int: + def hostile_open( + config: ProjectConfig, parts: tuple[str, ...], *, create: bool + ) -> int: nonlocal replaced descriptor = original_open(config, parts, create=create) if not replaced and parts == ("safe",): @@ -160,7 +163,9 @@ def recording_fsync(descriptor: int) -> None: assert synced_kinds.count("directory") >= 2 -def test_gateway_temporary_artifacts_are_excluded_from_project_apis(tmp_path: Path) -> None: +def test_gateway_temporary_artifacts_are_excluded_from_project_apis( + tmp_path: Path, +) -> None: _observer, project, _linked = project_service(tmp_path) observer = ProjectService(_observer.config, Principal.for_name("observer")) temporary = project / ".tracked.gateway-tmp-fixture" @@ -168,7 +173,9 @@ def test_gateway_temporary_artifacts_are_excluded_from_project_apis(tmp_path: Pa with pytest.raises(ProjectError, match="excluded by project policy"): observer.read("fixture", temporary.name, checkout_id="default") - assert all(row["path"] != temporary.name for row in observer.tree("fixture")["entries"]) + assert all( + row["path"] != temporary.name for row in observer.tree("fixture")["entries"] + ) assert observer.search("fixture", "private temporary")["matches"] == [] @@ -199,11 +206,14 @@ def test_project_patch_rename_removes_source_without_ingesting_ignored_files( assert not source.exists() assert (project / "new.txt").read_text() == "tracked\n" assert ignored.read_text() == "must never enter the object database" - assert subprocess.run( - ["git", "-C", str(project), "cat-file", "-e", private_object], - check=False, - capture_output=True, - ).returncode != 0 + assert ( + subprocess.run( + ["git", "-C", str(project), "cat-file", "-e", private_object], + check=False, + capture_output=True, + ).returncode + != 0 + ) def git_stdout(path: Path, *arguments: str) -> str: diff --git a/pkgs/sinnix-agent-gateway/test_project_context.py b/pkgs/sinnix-agent-gateway/test_project_context.py index 564fe991..fffafe48 100644 --- a/pkgs/sinnix-agent-gateway/test_project_context.py +++ b/pkgs/sinnix-agent-gateway/test_project_context.py @@ -81,16 +81,35 @@ def test_commit_range_uses_immutable_two_commit_relation(tmp_path: Path) -> None projects = project_service(tmp_path, "operator") project = projects.config.projects["fixture"].path base = subprocess.run( - ["git", "rev-parse", "HEAD"], cwd=project, check=True, capture_output=True, text=True + ["git", "rev-parse", "HEAD"], + cwd=project, + check=True, + capture_output=True, + text=True, ).stdout.strip() (project / "tracked.txt").write_text("committed change\n") subprocess.run(["git", "add", "tracked.txt"], cwd=project, check=True) subprocess.run( - ["git", "-c", "user.name=Gateway Fixture", "-c", "user.email=gateway@example.invalid", "commit", "-m", "second gateway fixture"], - cwd=project, check=True, stdout=subprocess.DEVNULL, + [ + "git", + "-c", + "user.name=Gateway Fixture", + "-c", + "user.email=gateway@example.invalid", + "commit", + "-m", + "second gateway fixture", + ], + cwd=project, + check=True, + stdout=subprocess.DEVNULL, ) head = subprocess.run( - ["git", "rev-parse", "HEAD"], cwd=project, check=True, capture_output=True, text=True + ["git", "rev-parse", "HEAD"], + cwd=project, + check=True, + capture_output=True, + text=True, ).stdout.strip() result = projects.commit_range("fixture", "default", base, head) @@ -141,7 +160,9 @@ def test_project_context_does_not_expose_ready_tasks_to_agent_control( ) -> None: projects = project_service(tmp_path, "agent-control") beads = FakeBeads({"unexpected": True}) - context = ProjectContextService(Principal.for_name("agent-control"), projects, beads) # type: ignore[arg-type] + context = ProjectContextService( + Principal.for_name("agent-control"), projects, beads + ) # type: ignore[arg-type] result = context.context("fixture") diff --git a/pkgs/sinnix-agent-gateway/test_prompts.py b/pkgs/sinnix-agent-gateway/test_prompts.py index 104ce42f..5eb36caf 100644 --- a/pkgs/sinnix-agent-gateway/test_prompts.py +++ b/pkgs/sinnix-agent-gateway/test_prompts.py @@ -1,16 +1,38 @@ from __future__ import annotations import json -import pytest -from sinnix_agent_gateway.prompts import PromptGenerator, PROMPT_SPECS +import pytest +from sinnix_agent_gateway.prompts import PROMPT_SPECS, PromptGenerator def test_generated_prompts_use_canonical_refs_and_principal_filtered_catalog() -> None: - catalog = lambda principal: {"revision": "catalog-rev", "actions": [{"name": "beads.query", "verb": "query", "effect": "read", "route": "beads.query", "resource_kinds": ["bead"]}, {"name": "beads.change", "verb": "change", "effect": "change", "route": "beads.write", "resource_kinds": ["bead"]}]} # noqa: E731 + def catalog(principal): + return { + "revision": "catalog-rev", + "actions": [ + { + "name": "beads.query", + "verb": "query", + "effect": "read", + "route": "beads.query", + "resource_kinds": ["bead"], + }, + { + "name": "beads.change", + "verb": "change", + "effect": "change", + "route": "beads.write", + "resource_kinds": ["bead"], + }, + ], + } # noqa: E731 + generator = PromptGenerator(principal="observer", catalog=catalog) - assert {row["name"] for row in generator.list()} == {spec.name for spec in PROMPT_SPECS} + assert {row["name"] for row in generator.list()} == { + spec.name for spec in PROMPT_SPECS + } messages = generator.generate("work-bead", {"ref": "sinnix://projects/p/beads/b"}) body = json.loads(messages[0]["content"]["text"]) assert body["target_ref"] == "sinnix://projects/p/beads/b" @@ -20,7 +42,9 @@ def test_generated_prompts_use_canonical_refs_and_principal_filtered_catalog() - def test_prompt_rejects_noncanonical_or_unknown_inputs() -> None: - generator = PromptGenerator(principal="observer", catalog=lambda _principal: {"actions": []}) + generator = PromptGenerator( + principal="observer", catalog=lambda _principal: {"actions": []} + ) try: generator.generate("orient-project", {"ref": "/tmp/project"}) except ValueError as exc: @@ -30,12 +54,21 @@ def test_prompt_rejects_noncanonical_or_unknown_inputs() -> None: def test_prompt_registry_visibility_intent_kind_and_bounds_are_enforced() -> None: - generator = PromptGenerator(principal="observer", catalog=lambda _principal: {"actions": []}) + generator = PromptGenerator( + principal="observer", catalog=lambda _principal: {"actions": []} + ) with pytest.raises(ValueError, match="resource kind"): generator.generate("orient-project", {"ref": "sinnix://jobs/job-1"}) with pytest.raises(ValueError, match="visible"): - generator.generate("orient-project", {"ref": "sinnix://browser/agent-workspace"}) + generator.generate( + "orient-project", {"ref": "sinnix://browser/agent-workspace"} + ) with pytest.raises(ValueError, match="job_ref"): - generator.generate("work-bead", {"ref": "sinnix://projects/p/beads/b", "job_ref": "sinnix://projects/p"}) + generator.generate( + "work-bead", + {"ref": "sinnix://projects/p/beads/b", "job_ref": "sinnix://projects/p"}, + ) with pytest.raises(ValueError, match="input bound"): - generator.generate("orient-project", {"ref": "sinnix://projects/" + "x" * 2_100}) + generator.generate( + "orient-project", {"ref": "sinnix://projects/" + "x" * 2_100} + ) diff --git a/pkgs/sinnix-agent-gateway/test_registry.py b/pkgs/sinnix-agent-gateway/test_registry.py index d2e3e546..8d4a3f93 100644 --- a/pkgs/sinnix-agent-gateway/test_registry.py +++ b/pkgs/sinnix-agent-gateway/test_registry.py @@ -1,7 +1,6 @@ from __future__ import annotations import pytest - from sinnix_agent_gateway.contracts import ( BASE_TYPED_FAILURES, ActionSpec, @@ -10,9 +9,13 @@ ResourceSpec, VerbFamily, ) -from sinnix_mcp.refs import RefTemplate, ReferenceError, SinnixRef -from sinnix_agent_gateway.registry import CatalogRegistry, CatalogSearch, RegistryError, REGISTRY - +from sinnix_agent_gateway.registry import ( + REGISTRY, + CatalogRegistry, + CatalogSearch, + RegistryError, +) +from sinnix_mcp.refs import ReferenceError, RefTemplate, SinnixRef RETAINED_OWNER_ACTIONS = { "project inspection": "projects.read", @@ -106,8 +109,8 @@ def test_catalog_is_principal_filtered_and_hashes_actions() -> None: "shell.run", "projects.change", "files.change", - "beads.change", - "beads.changeset", + "beads.change", + "beads.changeset", "agent.for_bead", "mcp.change", "machine.operate", @@ -122,7 +125,10 @@ def test_catalog_is_principal_filtered_and_hashes_actions() -> None: } <= operator_actions assert "shell.run" not in observer_actions assert "projects.change" not in observer_actions - assert observer_catalog["action_catalog_hash"] != operator_catalog["action_catalog_hash"] + assert ( + observer_catalog["action_catalog_hash"] + != operator_catalog["action_catalog_hash"] + ) assert {row["kind"] for row in observer_catalog["resources"]} >= { "project", "checkout", @@ -144,7 +150,9 @@ def test_catalog_is_principal_filtered_and_hashes_actions() -> None: } -def test_action_failure_contracts_follow_public_controls_and_owner_capabilities() -> None: +def test_action_failure_contracts_follow_public_controls_and_owner_capabilities() -> ( + None +): read_failures = BASE_TYPED_FAILURES | {"deadline"} assert REGISTRY.action("jobs.query").typed_failures == read_failures @@ -176,13 +184,28 @@ def test_resource_template_pages_are_principal_scoped_and_cursor_bound() -> None assert {row["kind"] for row in first["templates"]}.isdisjoint( {row["kind"] for row in second["templates"]} ) - assert all("browser_workspace" != row["kind"] for row in first["templates"] + second["templates"]) + assert all( + "browser_workspace" != row["kind"] + for row in first["templates"] + second["templates"] + ) with pytest.raises(RegistryError, match="principal"): - REGISTRY.template_page(principal="operator", cursor_key=key, limit=2, cursor=first["next_cursor"]) + REGISTRY.template_page( + principal="operator", cursor_key=key, limit=2, cursor=first["next_cursor"] + ) with pytest.raises(RegistryError, match="principal"): - REGISTRY.template_page(principal="operator", cursor_key=b"o" * 32, limit=2, cursor=first["next_cursor"]) + REGISTRY.template_page( + principal="operator", + cursor_key=b"o" * 32, + limit=2, + cursor=first["next_cursor"], + ) with pytest.raises(RegistryError, match="stale"): - REGISTRY.template_page(principal="observer", cursor_key=b"r" * 32, limit=2, cursor=first["next_cursor"]) + REGISTRY.template_page( + principal="observer", + cursor_key=b"r" * 32, + limit=2, + cursor=first["next_cursor"], + ) def test_every_retained_owner_capability_has_a_read_action_and_resource_route() -> None: @@ -191,7 +214,9 @@ def test_every_retained_owner_capability_has_a_read_action_and_resource_route() assert action.effect is EffectMode.READ, capability assert action.verb is VerbFamily.QUERY, capability assert action.resource_kinds, capability - assert all(REGISTRY.resource(kind) for kind in action.resource_kinds), capability + assert all(REGISTRY.resource(kind) for kind in action.resource_kinds), ( + capability + ) def test_resource_contracts_and_discovery_are_principal_filtered() -> None: @@ -218,9 +243,10 @@ def test_resource_contracts_and_discovery_are_principal_filtered() -> None: assert [row["kind"] for row in observer_documentation["resources"]] == ["shared"] with pytest.raises(RegistryError, match="cannot read resource"): registry.resource_contract("operator_only", "observer") - assert registry.resource_contract("operator_only", "operator")["resource"][ - "kind" - ] == "operator_only" + assert ( + registry.resource_contract("operator_only", "operator")["resource"]["kind"] + == "operator_only" + ) def test_action_catalog_hash_changes_when_authority_changes() -> None: @@ -247,9 +273,10 @@ def test_action_catalog_hash_changes_when_authority_changes() -> None: output_schema={"type": "object"}, ) - assert CatalogRegistry((), (base,)).action_catalog_hash() != CatalogRegistry( - (), (widened,) - ).action_catalog_hash() + assert ( + CatalogRegistry((), (base,)).action_catalog_hash() + != CatalogRegistry((), (widened,)).action_catalog_hash() + ) def test_catalog_contract_resources_preserve_generated_schema_metadata() -> None: @@ -322,9 +349,26 @@ def test_resource_get_contract_formats_canonical_project_relationships() -> None assert action["verb"] == "get" assert action["resource_kinds"] == [ - "project", "checkout", "bead", "task_authority", "job", "artifact", - "receipt", "result", "machine_unit", "browser_page", "browser_workspace", - "process", "terminal", "desktop", "host_file", "mcp_tool", "capture_lane", "capability", "session", "context_snapshot", + "project", + "checkout", + "bead", + "task_authority", + "job", + "artifact", + "receipt", + "result", + "machine_unit", + "browser_page", + "browser_workspace", + "process", + "terminal", + "desktop", + "host_file", + "mcp_tool", + "capture_lane", + "capability", + "session", + "context_snapshot", ] assert action["input_schema"]["required"] == ["ref"] assert action["input_schema"]["properties"]["projection"]["enum"] == [ @@ -332,9 +376,12 @@ def test_resource_get_contract_formats_canonical_project_relationships() -> None "log", "result", ] - assert REGISTRY.reference( - "checkout", {"project_id": "sinnix main", "checkout_id": "default"} - ) == "sinnix://projects/sinnix%20main/checkouts/default" + assert ( + REGISTRY.reference( + "checkout", {"project_id": "sinnix main", "checkout_id": "default"} + ) + == "sinnix://projects/sinnix%20main/checkouts/default" + ) def test_catalog_search_filters_resource_kind_and_text() -> None: @@ -345,10 +392,10 @@ def test_catalog_search_filters_resource_kind_and_text() -> None: "resources.get", "beads.query", "projects.context", - "beads.change", - "beads.changeset", - "agent.for_bead", - ] + "beads.change", + "beads.changeset", + "agent.for_bead", + ] assert result["resources"] == [ { "kind": "bead", @@ -445,7 +492,9 @@ def test_run_and_wait_contracts_are_closed_and_authority_scoped() -> None: assert wait["input_schema"]["properties"]["timeout_seconds"]["maximum"] == 300 with pytest.raises(RegistryError, match="cannot read action"): REGISTRY.action_schema("shell.run", "observer") - assert REGISTRY.action_schema("agent.for_bead", "agent-control")["action"]["principals"] == ["agent-control", "operator"] + assert REGISTRY.action_schema("agent.for_bead", "agent-control")["action"][ + "principals" + ] == ["agent-control", "operator"] def test_catalog_exposes_bead_workflow_without_a_legacy_agent_selector() -> None: @@ -478,7 +527,10 @@ def test_change_and_operate_contracts_bind_closed_canonical_owner_targets() -> N assert change["input_schema"]["properties"]["operation"] == { "enum": ["apply_patch", "write"] } - assert change["input_schema"]["properties"]["preconditions"]["additionalProperties"] is False + assert ( + change["input_schema"]["properties"]["preconditions"]["additionalProperties"] + is False + ) assert operate["verb"] == "operate" assert operate["effect"] == "operate" @@ -517,18 +569,42 @@ def test_change_and_operate_contracts_bind_closed_canonical_owner_targets() -> N def test_collapsed_mutation_contracts_are_operator_only_and_canonical() -> None: expected = { "files.change": ("change", "files", "files.change", ["host_file"]), - "beads.change": ("change", "beads", "beads.write", ["project", "bead", "task_authority"]), - "beads.changeset": ("change", "beads", "beads.changeset", ["project", "bead", "task_authority"]), + "beads.change": ( + "change", + "beads", + "beads.write", + ["project", "bead", "task_authority"], + ), + "beads.changeset": ( + "change", + "beads", + "beads.changeset", + ["project", "bead", "task_authority"], + ), "mcp.change": ("change", "mcp-broker", "mcp.call.write", ["mcp_tool"]), - "beads.operate": ("operate", "beads", "beads.maintenance", ["project", "task_authority"]), + "beads.operate": ( + "operate", + "beads", + "beads.maintenance", + ["project", "task_authority"], + ), "desktop.operate": ("operate", "desktop", "desktop.action", ["desktop"]), "terminals.operate": ("operate", "terminals", "terminals.action", ["terminal"]), - "browser.operate": ("operate", "browser", "browser.action", ["browser_workspace", "browser_page"]), + "browser.operate": ( + "operate", + "browser", + "browser.action", + ["browser_workspace", "browser_page"], + ), } for action_name, (verb, owner, route, resources) in expected.items(): action = REGISTRY.action_schema(action_name, "operator")["action"] - assert (action["verb"], action["owner"], action["route"]) == (verb, owner, route) + assert (action["verb"], action["owner"], action["route"]) == ( + verb, + owner, + route, + ) assert action["resource_kinds"] == resources assert action["supports_idempotency"] is True assert action["input_schema"]["required"] == [ @@ -560,7 +636,12 @@ def test_query_context_and_events_contracts_bind_existing_read_owners() -> None: assert beads["route"] == "beads.query" with pytest.raises(RegistryError, match="cannot read action"): REGISTRY.action_schema("beads.query", "agent-control") - assert beads["input_schema"]["properties"]["parameters"]["properties"]["cursor"]["maxLength"] == 256 + assert ( + beads["input_schema"]["properties"]["parameters"]["properties"]["cursor"][ + "maxLength" + ] + == 256 + ) bead_change = REGISTRY.action_schema("beads.change", "operator")["action"] assert bead_change["supports_precondition"] is True assert bead_change["input_schema"]["properties"]["preconditions"]["properties"] == { @@ -569,15 +650,35 @@ def test_query_context_and_events_contracts_bind_existing_read_owners() -> None: "expected_assignee": {"type": ["string", "null"], "maxLength": 256}, "expected_etag": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, } - assert "preview_digest" in bead_change["input_schema"]["properties"]["parameters"]["properties"] + assert ( + "preview_digest" + in bead_change["input_schema"]["properties"]["parameters"]["properties"] + ) changeset = REGISTRY.action_schema("beads.changeset", "operator")["action"] assert changeset["route"] == "beads.changeset" - assert changeset["input_schema"]["properties"]["operation"]["enum"] == ["apply", "preview"] - assert changeset["input_schema"]["properties"]["parameters"]["properties"]["actions"]["maxItems"] == 128 - assert changeset["input_schema"]["properties"]["parameters"]["properties"]["on_error"]["enum"] == ["stop", "continue"] + assert changeset["input_schema"]["properties"]["operation"]["enum"] == [ + "apply", + "preview", + ] + assert ( + changeset["input_schema"]["properties"]["parameters"]["properties"]["actions"][ + "maxItems" + ] + == 128 + ) + assert changeset["input_schema"]["properties"]["parameters"]["properties"][ + "on_error" + ]["enum"] == ["stop", "continue"] maintenance = REGISTRY.action_schema("beads.operate", "operator")["action"] assert maintenance["route"] == "beads.maintenance" - assert maintenance["input_schema"]["properties"]["operation"]["enum"] == ["backup.create", "backup.list", "backup.restore", "snapshot.publish", "sync.pull", "sync.push"] + assert maintenance["input_schema"]["properties"]["operation"]["enum"] == [ + "backup.create", + "backup.list", + "backup.restore", + "snapshot.publish", + "sync.pull", + "sync.push", + ] assert context["verb"] == "context" assert context["owner"] == "project-context" assert context["route"] == "project_context.context" diff --git a/pkgs/sinnix-agent-gateway/test_results.py b/pkgs/sinnix-agent-gateway/test_results.py index 168ecd8f..668f6ce8 100644 --- a/pkgs/sinnix-agent-gateway/test_results.py +++ b/pkgs/sinnix-agent-gateway/test_results.py @@ -11,13 +11,11 @@ from pathlib import Path import pytest - from sinnix_agent_gateway.app import Runtime from sinnix_agent_gateway.audit import AuditService from sinnix_agent_gateway.capabilities import Principal from sinnix_agent_gateway.config import GatewayConfig, ProjectConfig from sinnix_agent_gateway.contracts import ActionSpec, EffectMode, VerbFamily -from sinnix_mcp.execution import ExecutionProfile, OwnerRoute from sinnix_agent_gateway.registry import REGISTRY from sinnix_agent_gateway.results import ( EXPECTED_ERROR_CODES, @@ -25,6 +23,7 @@ ResultError, ResultService, ) +from sinnix_mcp.execution import ExecutionProfile, OwnerRoute def config(tmp_path, *, max_result_bytes: int = 262_144): @@ -124,12 +123,19 @@ def test_runtime_v2_envelopes_success_and_public_error(tmp_path) -> None: "diagnostic_refs": [], } assert runtime.audit.receipt(failure["receipt"]["receipt_id"])["outcome"] == "error" - assert failure["result"]["request_sha256"] == hashlib.sha256( - json.dumps({"verb": "invalid"}, sort_keys=True, separators=(",", ":")).encode() - ).hexdigest() + assert ( + failure["result"]["request_sha256"] + == hashlib.sha256( + json.dumps( + {"verb": "invalid"}, sort_keys=True, separators=(",", ":") + ).encode() + ).hexdigest() + ) -def test_runtime_v2_replaces_an_oversized_owner_payload_with_an_artifact(tmp_path) -> None: +def test_runtime_v2_replaces_an_oversized_owner_payload_with_an_artifact( + tmp_path, +) -> None: runtime = Runtime.create(config(tmp_path, max_result_bytes=1_024), "observer") action = REGISTRY.action("gateway.catalog") @@ -170,10 +176,15 @@ def test_runtime_v2_keeps_each_expected_failure_in_a_typed_envelope(tmp_path) -> assert response["result"]["outcome"] == "error" assert response["error"]["code"] == code assert response["error"]["message"] == f"safe {code} failure" - assert runtime.audit.receipt(response["receipt"]["receipt_id"])["outcome"] == "error" + assert ( + runtime.audit.receipt(response["receipt"]["receipt_id"])["outcome"] + == "error" + ) -def test_jsonl_snapshot_pages_a_million_rows_without_logical_result_buffering(tmp_path) -> None: +def test_jsonl_snapshot_pages_a_million_rows_without_logical_result_buffering( + tmp_path, +) -> None: runtime = Runtime.create(config(tmp_path), "observer") action = REGISTRY.action("gateway.catalog") command = [ @@ -215,7 +226,9 @@ def test_jsonl_snapshot_pages_a_million_rows_without_logical_result_buffering(tm source_revision="fixture-revision-2", ) with pytest.raises(ResultError, match="does not match"): - ResultService(config(tmp_path), Principal.for_name("operator")).continue_snapshot( + ResultService( + config(tmp_path), Principal.for_name("operator") + ).continue_snapshot( response["page"]["next_cursor"], query_sha256=response["result"]["request_sha256"], ) @@ -266,7 +279,9 @@ def test_jsonl_stream_failure_cancels_child_and_removes_temp_writer(tmp_path) -> assert list(runtime.results.snapshots_root.glob(".*.writing")) == [] -def test_mutation_idempotency_replays_receipt_without_second_owner_write(tmp_path) -> None: +def test_mutation_idempotency_replays_receipt_without_second_owner_write( + tmp_path, +) -> None: runtime = Runtime.create(config(tmp_path), "operator") action = ActionSpec( name="fixture.change", @@ -299,7 +314,9 @@ def write() -> dict[str, object]: assert conflict["error"]["code"] == "idempotency_conflict" -def test_declared_deadline_and_idempotency_failures_persist_bounded_envelopes(tmp_path) -> None: +def test_declared_deadline_and_idempotency_failures_persist_bounded_envelopes( + tmp_path, +) -> None: runtime = Runtime.create(config(tmp_path), "operator") action = REGISTRY.action("agent.for_bead") request = { @@ -341,27 +358,44 @@ def test_declared_deadline_and_idempotency_failures_persist_bounded_envelopes(tm assert runtime.results.read(response["result"]["result_id"]) == response -def test_concurrent_matching_idempotency_returns_conflict_then_replays(tmp_path) -> None: +def test_concurrent_matching_idempotency_returns_conflict_then_replays( + tmp_path, +) -> None: runtime = Runtime.create(config(tmp_path), "operator") action = ActionSpec( - name="fixture.concurrent-change", verb=VerbFamily.CHANGE, domain="fixture", - owner="fixture", route="fixture.write", effect=EffectMode.CHANGE, - principals=frozenset({"operator"}), input_schema={"type": "object"}, output_schema={"type": "object"}, - supports_idempotency=True, receipt_policy="audit", + name="fixture.concurrent-change", + verb=VerbFamily.CHANGE, + domain="fixture", + owner="fixture", + route="fixture.write", + effect=EffectMode.CHANGE, + principals=frozenset({"operator"}), + input_schema={"type": "object"}, + output_schema={"type": "object"}, + supports_idempotency=True, + receipt_policy="audit", ) started, release = threading.Event(), threading.Event() writes: list[str] = [] def write() -> dict[str, str]: - writes.append("write"); started.set(); assert release.wait(5) + writes.append("write") + started.set() + assert release.wait(5) return {"ref": "sinnix://projects/fixture", "created": True} first_result: dict[str, object] = {} - thread = threading.Thread(target=lambda: first_result.setdefault("value", runtime.execute_v2(action, write, {"idempotency_key": "same"}))) - thread.start(); assert started.wait(5) + thread = threading.Thread( + target=lambda: first_result.setdefault( + "value", runtime.execute_v2(action, write, {"idempotency_key": "same"}) + ) + ) + thread.start() + assert started.wait(5) concurrent = runtime.execute_v2(action, write, {"idempotency_key": "same"}) assert concurrent["error"]["code"] == "conflict" - release.set(); thread.join(5) + release.set() + thread.join(5) replay = runtime.execute_v2(action, write, {"idempotency_key": "same"}) assert replay == first_result["value"] assert writes == ["write"] @@ -414,7 +448,9 @@ def _project_runtime(tmp_path: Path) -> Runtime: project = tmp_path / "project" project.mkdir() subprocess.run(["git", "init", "--quiet", project], check=True) - subprocess.run(["git", "config", "user.name", "Gateway Test"], cwd=project, check=True) + subprocess.run( + ["git", "config", "user.name", "Gateway Test"], cwd=project, check=True + ) subprocess.run( ["git", "config", "user.email", "gateway-test@example.invalid"], cwd=project, @@ -437,7 +473,9 @@ def _checkout_preconditions(runtime: Runtime) -> dict[str, str]: return {"head": checkout["head"], "dirty_sha256": checkout["dirty_sha256"]} -def test_v2_change_uses_canonical_checkout_preconditions_and_idempotency(tmp_path) -> None: +def test_v2_change_uses_canonical_checkout_preconditions_and_idempotency( + tmp_path, +) -> None: runtime = _project_runtime(tmp_path) action = REGISTRY.action("projects.change") reference = "sinnix://projects/fixture/checkouts/default" @@ -493,7 +531,9 @@ def test_v2_change_uses_canonical_checkout_preconditions_and_idempotency(tmp_pat assert (tmp_path / "project" / "tracked.txt").read_text() == "after\n" -def test_v2_change_project_root_selects_default_checkout_with_worktrees(tmp_path) -> None: +def test_v2_change_project_root_selects_default_checkout_with_worktrees( + tmp_path, +) -> None: runtime = _project_runtime(tmp_path) project = tmp_path / "project" linked = tmp_path / "linked" @@ -531,7 +571,9 @@ def test_v2_change_project_root_selects_default_checkout_with_worktrees(tmp_path assert (linked / "tracked.txt").read_text() == "before\n" -def test_v2_change_rechecks_preconditions_atomically_for_concurrent_mutations(tmp_path) -> None: +def test_v2_change_rechecks_preconditions_atomically_for_concurrent_mutations( + tmp_path, +) -> None: runtime = _project_runtime(tmp_path) action = REGISTRY.action("projects.change") reference = "sinnix://projects/fixture/checkouts/default" @@ -610,7 +652,10 @@ def test_v2_change_preserves_project_patch_owner_contract(tmp_path) -> None: }, ) - assert response["data"]["owner_result"] == {"project_id": "fixture", "applied": True} + assert response["data"]["owner_result"] == { + "project_id": "fixture", + "applied": True, + } assert (tmp_path / "project" / "tracked.txt").read_text() == "patched\n" @@ -674,7 +719,10 @@ def execute( assert calls == [{"target": target}] assert response["data"]["ref"] == reference assert response["data"]["owner_receipt"]["target"] == target - assert response["data"]["owner_receipt"]["operator_reason"] == "exercise typed operation" + assert ( + response["data"]["owner_receipt"]["operator_reason"] + == "exercise typed operation" + ) receipt = runtime.audit.receipt(response["receipt"]["receipt_id"]) assert receipt["payload"]["owner_receipt_id"] == "owner-receipt" diff --git a/pkgs/sinnix-agent-gateway/test_route_preflight.py b/pkgs/sinnix-agent-gateway/test_route_preflight.py index 81d1187b..4aafb1bc 100644 --- a/pkgs/sinnix-agent-gateway/test_route_preflight.py +++ b/pkgs/sinnix-agent-gateway/test_route_preflight.py @@ -6,10 +6,9 @@ from pathlib import Path import pytest - from sinnix_agent_gateway.config import GatewayConfig -from sinnix_mcp.execution import OwnerExecution from sinnix_agent_gateway.route_preflight import GatewayRoutePreflight +from sinnix_mcp.execution import OwnerExecution def make_inventory(tmp_path: Path) -> tuple[Path, Path]: @@ -259,7 +258,9 @@ def test_route_preflight_marks_required_owner_environment_unavailable( assert row["failure_class"] == f"environment_unavailable:{missing}" -def test_route_preflight_reports_missing_broker_environment(tmp_path: Path, monkeypatch) -> None: +def test_route_preflight_reports_missing_broker_environment( + tmp_path: Path, monkeypatch +) -> None: monkeypatch.delenv("DBUS_SESSION_BUS_ADDRESS", raising=False) monkeypatch.delenv("XDG_RUNTIME_DIR", raising=False) diff --git a/pkgs/sinnix-agent-gateway/test_sessions.py b/pkgs/sinnix-agent-gateway/test_sessions.py index b5f41e30..04343033 100644 --- a/pkgs/sinnix-agent-gateway/test_sessions.py +++ b/pkgs/sinnix-agent-gateway/test_sessions.py @@ -3,7 +3,6 @@ from pathlib import Path import pytest - from sinnix_agent_gateway.capabilities import Principal from sinnix_agent_gateway.config import GatewayConfig from sinnix_agent_gateway.sessions import SessionError, SessionLogService, SessionSource @@ -41,7 +40,9 @@ def test_default_codex_source_is_the_canonical_sessions_root(tmp_path: Path) -> ] -def test_session_list_read_and_search_preserve_provider_reference(tmp_path: Path) -> None: +def test_session_list_read_and_search_preserve_provider_reference( + tmp_path: Path, +) -> None: service, root = session_service(tmp_path) session = root / "project" / "session.jsonl" session.parent.mkdir() diff --git a/pkgs/sinnix-agent-gateway/test_smoke.py b/pkgs/sinnix-agent-gateway/test_smoke.py index e8523bd7..6e27bbdd 100644 --- a/pkgs/sinnix-agent-gateway/test_smoke.py +++ b/pkgs/sinnix-agent-gateway/test_smoke.py @@ -9,18 +9,16 @@ import subprocess import sys import threading -from types import SimpleNamespace from pathlib import Path +from types import SimpleNamespace import anyio import pytest from mcp import ClientSession +from mcp.client.stdio import StdioServerParameters, stdio_client from mcp.server.mcpserver.context import Context from mcp.types import PaginatedRequestParams -from mcp.client.stdio import StdioServerParameters, stdio_client from sinnix_agent_gateway.app import Runtime, create_server -from sinnix_agent_gateway.server import _bounded_resource_json, _query_owner -from sinnix_mcp.execution import ExecutionResult, OwnerDiagnosticError from sinnix_agent_gateway.artifacts import ArtifactError from sinnix_agent_gateway.capabilities import PolicyError from sinnix_agent_gateway.cli import build_manifest, parser, verify_approval @@ -28,6 +26,8 @@ from sinnix_agent_gateway.projects import ProjectError from sinnix_agent_gateway.registry import REGISTRY from sinnix_agent_gateway.results import ProtocolError +from sinnix_agent_gateway.server import _bounded_resource_json, _query_owner +from sinnix_mcp.execution import ExecutionResult, OwnerDiagnosticError def config(tmp_path: Path, *, observer_read: bool = True) -> GatewayConfig: @@ -55,37 +55,60 @@ def test_official_sdk_principals_expose_only_protocol_verbs(tmp_path: Path) -> N names = {row["name"] for row in operator["tools"]} assert names == { - "status", "catalog", "query", "get", "context", "events", "wait", - "change", "operate", "run", + "status", + "catalog", + "query", + "get", + "context", + "events", + "wait", + "change", + "operate", + "run", } assert {row["name"] for row in observer["tools"]} == names assert {row["name"] for row in agent_control["tools"]} == names assert { row["name"] for row in operator["tools"] - if row["annotations"] == { - "readOnlyHint": True, "destructiveHint": False, "idempotentHint": True, + if row["annotations"] + == { + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, "openWorldHint": False, } } == {"status", "catalog", "query", "get", "context", "events", "wait"} - assert next(row for row in operator["tools"] if row["name"] == "change")["annotations"] == { - "readOnlyHint": False, "destructiveHint": True, "idempotentHint": True, + assert next(row for row in operator["tools"] if row["name"] == "change")[ + "annotations" + ] == { + "readOnlyHint": False, + "destructiveHint": True, + "idempotentHint": True, "openWorldHint": False, } - assert next(row for row in operator["tools"] if row["name"] == "operate")["annotations"] == { - "readOnlyHint": False, "destructiveHint": True, "idempotentHint": True, + assert next(row for row in operator["tools"] if row["name"] == "operate")[ + "annotations" + ] == { + "readOnlyHint": False, + "destructiveHint": True, + "idempotentHint": True, "openWorldHint": False, } - assert next(row for row in operator["tools"] if row["name"] == "run")["annotations"] == { - "readOnlyHint": False, "destructiveHint": False, "idempotentHint": True, + assert next(row for row in operator["tools"] if row["name"] == "run")[ + "annotations" + ] == { + "readOnlyHint": False, + "destructiveHint": False, + "idempotentHint": True, "openWorldHint": True, } - assert { - row["name"]: row["annotations"] for row in observer["tools"] - } == { + assert {row["name"]: row["annotations"] for row in observer["tools"]} == { row["name"]: row["annotations"] for row in operator["tools"] } - assert all("inputSchema" in row and "outputSchema" in row for row in operator["tools"]) + assert all( + "inputSchema" in row and "outputSchema" in row for row in operator["tools"] + ) assert observer["sha256"] == agent_control["sha256"] == operator["sha256"] assert operator["measurement"] == { "schema": "sinnix.gateway-schema-measurement.v1", @@ -153,10 +176,15 @@ async def probe() -> None: ) assert { template.uri_template for template in templates.resource_templates - }.isdisjoint({ - template.uri_template for template in continued_templates.resource_templates - }) - all_templates = list(templates.resource_templates) + list(continued_templates.resource_templates) + }.isdisjoint( + { + template.uri_template + for template in continued_templates.resource_templates + } + ) + all_templates = list(templates.resource_templates) + list( + continued_templates.resource_templates + ) template_cursor = continued_templates.next_cursor while template_cursor: page = await session.list_resource_templates( @@ -167,8 +195,16 @@ async def probe() -> None: names = {tool.name for tool in tools.tools} assert initialized.server_info.name == "sinnix-agent-gateway" assert names == { - "status", "catalog", "query", "get", "context", "events", "wait", - "change", "operate", "run", + "status", + "catalog", + "query", + "get", + "context", + "events", + "wait", + "change", + "operate", + "run", } denied_change = await session.call_tool( "change", @@ -196,7 +232,10 @@ async def probe() -> None: ) for denied in (denied_change, denied_operate, denied_run): assert denied.is_error is True - assert json.loads(denied.content[0].text)["error"]["code"] == "policy_denied" + assert ( + json.loads(denied.content[0].text)["error"]["code"] + == "policy_denied" + ) assert { "sinnix://gateway/v2/actions/{action_name}", "sinnix://gateway/v2/resources/{resource_kind}", @@ -215,7 +254,9 @@ async def probe() -> None: "sinnix://gateway/v2/documentation" ) documentation_rows = json.loads(documentation.contents[0].text) - parity = await session.read_resource("sinnix://gateway/v2/legacy-parity") + parity = await session.read_resource( + "sinnix://gateway/v2/legacy-parity" + ) parity_rows = json.loads(parity.contents[0].text) assert action_contract["action"]["schema_ref"] == ( "sinnix://gateway/v2/actions/gateway.catalog" @@ -258,16 +299,22 @@ async def probe() -> None: assert status_envelope["schema"] == "sinnix.gateway-result.v3" assert result.is_error is False assert result.structured_content == status_envelope - status_tool = next(tool for tool in tools.tools if tool.name == "status") - assert status_tool.output_schema != REGISTRY.action( - "gateway.status" - ).output_schema - assert action_contract["action"]["output_schema"] == REGISTRY.action( - "gateway.status" - ).output_schema + status_tool = next( + tool for tool in tools.tools if tool.name == "status" + ) + assert ( + status_tool.output_schema + != REGISTRY.action("gateway.status").output_schema + ) + assert ( + action_contract["action"]["output_schema"] + == REGISTRY.action("gateway.status").output_schema + ) assert status_envelope["result"]["action"] == "gateway.status" assert status_envelope["result"]["outcome"] == "ok" - assert status_envelope["receipt"]["ref"].startswith("sinnix://receipts/") + assert status_envelope["receipt"]["ref"].startswith( + "sinnix://receipts/" + ) assert status["principal"] == "observer" assert status["route_preflight"]["routes"] assert status["manifests"]["live_server"]["sha256"] @@ -295,12 +342,17 @@ async def probe() -> None: query_tool = next(tool for tool in tools.tools if tool.name == "query") assert "action_name" in query_tool.input_schema["required"] - assert "default" not in query_tool.input_schema["properties"]["action_name"] + assert ( + "default" + not in query_tool.input_schema["properties"]["action_name"] + ) assert "projects.list" in (query_tool.description or "") project_catalog_result = await session.call_tool( "catalog", {"project": "fixture"} ) - project_catalog = json.loads(project_catalog_result.content[0].text)["data"] + project_catalog = json.loads(project_catalog_result.content[0].text)[ + "data" + ] assert project_catalog["project"] == { "project_id": "fixture", "available": True, @@ -309,7 +361,9 @@ async def probe() -> None: "writable": False, "ref": "sinnix://projects/fixture", } - assert {resource["kind"] for resource in project_catalog["resources"]} == { + assert { + resource["kind"] for resource in project_catalog["resources"] + } == { "project", "checkout", "bead", @@ -338,11 +392,18 @@ async def probe() -> None: checkout = project_resource["checkouts"][0] assert checkout["ref"] == "sinnix://projects/fixture/checkouts/default" assert checkout["checkout_id"] == "default" - assert project_resource["task_authority"]["availability"] == "unavailable" - checkout_result = await session.call_tool("get", {"ref": checkout["ref"]}) + assert ( + project_resource["task_authority"]["availability"] == "unavailable" + ) + checkout_result = await session.call_tool( + "get", {"ref": checkout["ref"]} + ) checkout_resource = json.loads(checkout_result.content[0].text)["data"] assert checkout_resource["kind"] == "checkout" - assert checkout_resource["checkout"]["checkout"]["checkout_id"] == "default" + assert ( + checkout_resource["checkout"]["checkout"]["checkout_id"] + == "default" + ) query_result = await session.call_tool( "query", { @@ -354,7 +415,9 @@ async def probe() -> None: query_envelope = json.loads(query_result.content[0].text) assert query_envelope["result"]["action"] == "projects.query" assert query_envelope["data"]["ref"] == checkout["ref"] - assert query_envelope["data"]["project_ref"] == "sinnix://projects/fixture" + assert ( + query_envelope["data"]["project_ref"] == "sinnix://projects/fixture" + ) assert query_envelope["meta"]["resource_refs"] == [ "sinnix://projects/fixture", checkout["ref"], @@ -377,13 +440,19 @@ async def probe() -> None: assert context["ref"] == "sinnix://projects/fixture" assert context["authority"]["canonical_checkout_ref"] == checkout["ref"] assert len(context["authority"]["code_revision"]) == 64 - assert context["authority"]["task_authority"]["availability"] == "unavailable" + assert ( + context["authority"]["task_authority"]["availability"] + == "unavailable" + ) assert all( component["status"] != "available" or isinstance(component.get("source_revision"), str) for component in context["components"] ) - assert all(component["status"] in {"available", "unavailable"} for component in context["components"]) + assert all( + component["status"] in {"available", "unavailable"} + for component in context["components"] + ) events_result = await session.call_tool("events", {"limit": 100}) events_envelope = json.loads(events_result.content[0].text) assert events_envelope["result"]["action"] == "audit.events" @@ -405,14 +474,22 @@ async def probe() -> None: "sinnix://receipts/" ) mcp_catalog_result = await session.call_tool( - "query", {"action_name": "mcp.query", "parameters": {"operation": "catalog"}} + "query", + { + "action_name": "mcp.query", + "parameters": {"operation": "catalog"}, + }, ) - assert json.loads(mcp_catalog_result.content[0].text)["data"] == {"servers": []} + assert json.loads(mcp_catalog_result.content[0].text)["data"] == { + "servers": [] + } anyio.run(probe) -def test_production_wait_route_observes_mcp_request_cancellation(tmp_path: Path) -> None: +def test_production_wait_route_observes_mcp_request_cancellation( + tmp_path: Path, +) -> None: server = create_server(config(tmp_path), "observer") cancelled = anyio.Event() cancelled.set() @@ -441,7 +518,9 @@ async def invoke() -> dict[str, object]: assert response["data"]["outcome"] == "cancelled", response -def test_production_wait_route_runs_without_mcp_request_context(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: +def test_production_wait_route_runs_without_mcp_request_context( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: server = create_server(config(tmp_path), "observer") runtime = server._sinnix_revision_publisher.runtime monkeypatch.setattr( @@ -464,15 +543,21 @@ async def invoke() -> dict[str, object]: response = anyio.run(invoke) assert response["result"]["outcome"] == "ok", response assert response["data"]["job_id"] == "fixture-job", response - assert response["data"]["state"] == {"phase": "succeeded", "terminal": True}, response + assert response["data"]["state"] == {"phase": "succeeded", "terminal": True}, ( + response + ) -def test_production_job_wait_route_cancels_owner_wait(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: +def test_production_job_wait_route_cancels_owner_wait( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: server = create_server(config(tmp_path), "observer") runtime = server._sinnix_revision_publisher.runtime owner_started = threading.Event() - def delayed_owner(operation: str, _arguments: dict[str, object]) -> dict[str, object]: + def delayed_owner( + operation: str, _arguments: dict[str, object] + ) -> dict[str, object]: assert operation == "job.wait" owner_started.set() threading.Event().wait(0.5) @@ -519,36 +604,63 @@ def test_public_v2_mutation_verbs_preserve_owner_routes( runtime = Runtime.create(cfg, "operator") calls: dict[str, object] = {} - def beads_change(project_id: str, operation: str, arguments: dict[str, object], **_kwargs: object) -> dict[str, object]: + def beads_change( + project_id: str, operation: str, arguments: dict[str, object], **_kwargs: object + ) -> dict[str, object]: calls["beads"] = (project_id, operation, arguments) - return {"project_id": project_id, "operation": operation, "mode": "apply", "atomicity": "owner_native"} + return { + "project_id": project_id, + "operation": operation, + "mode": "apply", + "atomicity": "owner_native", + } async def mcp_call( server: str, tool: str, arguments: dict[str, object], *, write: bool ) -> dict[str, object]: calls["mcp"] = (server, tool, arguments, write) - return {"server": server, "tool": tool, "mode": "write", "response": {"ok": True}} + return { + "server": server, + "tool": tool, + "mode": "write", + "response": {"ok": True}, + } - def desktop_owner(operation: str, arguments: dict[str, object]) -> dict[str, object]: + def desktop_owner( + operation: str, arguments: dict[str, object] + ) -> dict[str, object]: calls["desktop"] = (operation, arguments) return {"operation": operation, "result": {"ok": True}} - def terminal_owner(operation: str, arguments: dict[str, object]) -> dict[str, object]: + def terminal_owner( + operation: str, arguments: dict[str, object] + ) -> dict[str, object]: calls["terminal"] = (operation, arguments) return {"operation": operation, "result": {"ok": True}} - def browser_owner(operation: str, arguments: dict[str, object]) -> dict[str, object]: + def browser_owner( + operation: str, arguments: dict[str, object] + ) -> dict[str, object]: calls.setdefault("browser", []).append((operation, arguments)) if operation == "agent_window": - return {"operation": operation, "target": {"id": "agent-target", "parked": True}} - return {"operation": operation, "page_id": arguments["page_id"], "result": {"ok": True}} + return { + "operation": operation, + "target": {"id": "agent-target", "parked": True}, + } + return { + "operation": operation, + "page_id": arguments["page_id"], + "result": {"ok": True}, + } monkeypatch.setattr(runtime.beads, "change", beads_change) monkeypatch.setattr(runtime.mcp_broker, "call", mcp_call) monkeypatch.setattr(runtime.desktop, "action", desktop_owner) monkeypatch.setattr(runtime.terminals, "action", terminal_owner) monkeypatch.setattr(runtime.browser, "action", browser_owner) - monkeypatch.setattr(Runtime, "create", classmethod(lambda _cls, _cfg, _principal: runtime)) + monkeypatch.setattr( + Runtime, "create", classmethod(lambda _cls, _cfg, _principal: runtime) + ) server = create_server(cfg, "operator") target = tmp_path / "public-route.txt" file_token = base64.urlsafe_b64encode(str(target).encode()).decode().rstrip("=") @@ -671,17 +783,32 @@ async def invoke(name: str, arguments: dict[str, object]) -> dict[str, object]: ) assert target.read_text() == "public route\n" - assert calls["beads"] == ("fixture", "comment", {"id": "fixture-1", "text": "public route"}) + assert calls["beads"] == ( + "fixture", + "comment", + {"id": "fixture-1", "text": "public route"}, + ) assert calls["mcp"] == ("fixture", "mutate", {"value": "public route"}, True) assert calls["desktop"] == ("focus_window", {"window": "address:0xfixture"}) - assert calls["terminal"] == ("send", {"match": "id:7", "text": "printf fixture", "enter": True}) + assert calls["terminal"] == ( + "send", + {"match": "id:7", "text": "printf fixture", "enter": True}, + ) assert calls["browser"] == [ ("agent_window", {"url": "https://example.test"}), ("navigate", {"page_id": "agent-target", "url": "https://example.test/next"}), ] assert [ result["result"]["action"] - for result in (file_result, beads_result, mcp_result, desktop_result, terminal_result, window_result, browser_result) + for result in ( + file_result, + beads_result, + mcp_result, + desktop_result, + terminal_result, + window_result, + browser_result, + ) ] == [ "files.change", "beads.change", @@ -698,11 +825,15 @@ async def invoke(name: str, arguments: dict[str, object]) -> dict[str, object]: assert wrong_verb_with_preconditions["error"]["code"] == "unsupported_capability" -def test_mcp_dispatches_v2_change_and_operate_through_real_owners(tmp_path: Path) -> None: +def test_mcp_dispatches_v2_change_and_operate_through_real_owners( + tmp_path: Path, +) -> None: cfg = config(tmp_path) project = cfg.projects["fixture"].path subprocess.run(["git", "init", "--quiet", project], check=True) - subprocess.run(["git", "config", "user.name", "Gateway Test"], cwd=project, check=True) + subprocess.run( + ["git", "config", "user.name", "Gateway Test"], cwd=project, check=True + ) subprocess.run( ["git", "config", "user.email", "gateway-test@example.invalid"], cwd=project, @@ -900,7 +1031,9 @@ def test_mcp_query_derives_server_and_tool_from_a_canonical_ref( runtime = Runtime.create(config(tmp_path), "observer") captured: dict[str, object] = {} - async def call(server: str, tool: str, arguments: dict[str, object], *, write: bool) -> dict[str, object]: + async def call( + server: str, tool: str, arguments: dict[str, object], *, write: bool + ) -> dict[str, object]: captured.update(server=server, tool=tool, arguments=arguments, write=write) return {"response": {"ok": True}} @@ -915,7 +1048,10 @@ async def call(server: str, tool: str, arguments: dict[str, object], *, write: b {"operation": "call", "arguments": {"query": "fixture"}}, ) - assert result == {"ref": "sinnix://mcp/fixture/tools/lookup", "response": {"ok": True}} + assert result == { + "ref": "sinnix://mcp/fixture/tools/lookup", + "response": {"ok": True}, + } assert captured == { "server": "fixture", "tool": "lookup", @@ -929,16 +1065,70 @@ def test_target_query_adapters_round_trip_canonical_refs( ) -> None: runtime = Runtime.create(config(tmp_path), "observer") calls: dict[str, object] = {} - monkeypatch.setattr(runtime.desktop, "read", lambda operation: calls.update(desktop=operation) or {"ok": True}) - monkeypatch.setattr(runtime.terminals, "read", lambda operation, arguments=None: calls.update(terminal=(operation, arguments)) or {"ok": True}) - monkeypatch.setattr(runtime.browser, "read", lambda operation, page_id=None, selector=None: calls.update(browser=(operation, page_id, selector)) or {"ok": True}) - monkeypatch.setattr(runtime.files, "read", lambda operation, path, **kwargs: calls.update(files=(operation, path, kwargs)) or {"ok": True}) + monkeypatch.setattr( + runtime.desktop, + "read", + lambda operation: calls.update(desktop=operation) or {"ok": True}, + ) + monkeypatch.setattr( + runtime.terminals, + "read", + lambda operation, arguments=None: ( + calls.update(terminal=(operation, arguments)) or {"ok": True} + ), + ) + monkeypatch.setattr( + runtime.browser, + "read", + lambda operation, page_id=None, selector=None: ( + calls.update(browser=(operation, page_id, selector)) or {"ok": True} + ), + ) + monkeypatch.setattr( + runtime.files, + "read", + lambda operation, path, **kwargs: ( + calls.update(files=(operation, path, kwargs)) or {"ok": True} + ), + ) file_token = base64.urlsafe_b64encode(b"/realm/fixture.txt").decode().rstrip("=") - desktop = anyio.run(_query_owner, runtime, "desktop.query", "sinnix://desktop/current", None, 200, {"operation": "status"}) - terminal = anyio.run(_query_owner, runtime, "terminals.query", "sinnix://terminals/7", None, 200, {"operation": "capture", "arguments": {"extent": "screen"}}) - browser = anyio.run(_query_owner, runtime, "browser.query", "sinnix://browser/pages/agent-target", None, 200, {"operation": "info"}) - host_file = anyio.run(_query_owner, runtime, "files.query", f"sinnix://files/{file_token}", None, 200, {"operation": "stat"}) + desktop = anyio.run( + _query_owner, + runtime, + "desktop.query", + "sinnix://desktop/current", + None, + 200, + {"operation": "status"}, + ) + terminal = anyio.run( + _query_owner, + runtime, + "terminals.query", + "sinnix://terminals/7", + None, + 200, + {"operation": "capture", "arguments": {"extent": "screen"}}, + ) + browser = anyio.run( + _query_owner, + runtime, + "browser.query", + "sinnix://browser/pages/agent-target", + None, + 200, + {"operation": "info"}, + ) + host_file = anyio.run( + _query_owner, + runtime, + "files.query", + f"sinnix://files/{file_token}", + None, + 200, + {"operation": "stat"}, + ) assert desktop["ref"] == "sinnix://desktop/current" assert terminal["ref"] == "sinnix://terminals/7" @@ -948,10 +1138,22 @@ def test_target_query_adapters_round_trip_canonical_refs( "desktop": "status", "terminal": ("capture", {"match": "id:7", "extent": "screen"}), "browser": ("info", "agent-target", None), - "files": ("stat", "/realm/fixture.txt", {"offset": 0, "max_bytes": 64_000, "max_entries": 200}), + "files": ( + "stat", + "/realm/fixture.txt", + {"offset": 0, "max_bytes": 64_000, "max_entries": 200}, + ), } with pytest.raises(ProtocolError, match="canonical terminal ref"): - anyio.run(_query_owner, runtime, "terminals.query", None, None, 200, {"operation": "capture", "arguments": {"match": "id:7"}}) + anyio.run( + _query_owner, + runtime, + "terminals.query", + None, + None, + 200, + {"operation": "capture", "arguments": {"match": "id:7"}}, + ) def test_missing_observer_mcp_environment_is_a_typed_unavailable_failure( @@ -992,7 +1194,11 @@ def test_browser_owner_failure_keeps_exact_diagnostic_ref_in_v2_envelope( runtime.browser.execution, "run", lambda command, profile: ExecutionResult( - tuple(command), None, b"", b"chrome missing", failure_class="command_unavailable:FileNotFoundError" + tuple(command), + None, + b"", + b"chrome missing", + failure_class="command_unavailable:FileNotFoundError", ), ) @@ -1008,8 +1214,12 @@ def test_browser_owner_failure_keeps_exact_diagnostic_ref_in_v2_envelope( assert runtime.artifacts.read(artifact_id)["kind"] == "owner-diagnostic" -def test_gateway_resource_bounds_fall_back_to_an_attested_artifact(tmp_path: Path) -> None: - runtime = Runtime.create(dataclasses.replace(config(tmp_path), max_result_bytes=2_048), "observer") +def test_gateway_resource_bounds_fall_back_to_an_attested_artifact( + tmp_path: Path, +) -> None: + runtime = Runtime.create( + dataclasses.replace(config(tmp_path), max_result_bytes=2_048), "observer" + ) encoded = _bounded_resource_json(runtime, {"payload": "x" * 4_000}, "fixture") envelope = json.loads(encoded) @@ -1227,7 +1437,9 @@ def test_v2_events_are_principal_scoped_and_receipted(tmp_path: Path) -> None: assert response["receipt"]["ref"].startswith("sinnix://receipts/") -def test_runtime_returns_owner_diagnostic_and_audits_its_reference(tmp_path: Path) -> None: +def test_runtime_returns_owner_diagnostic_and_audits_its_reference( + tmp_path: Path, +) -> None: runtime = Runtime.create(config(tmp_path), "observer") response = { "available": False, @@ -1356,7 +1568,11 @@ def test_gateway_status_reports_distinct_manifest_provenance(tmp_path: Path) -> cfg = config(tmp_path) runtime = Runtime.create(cfg, "observer") status = runtime.observe.gateway_status( - "observer", "capability-hash", "approved-fixture-hash", "catalog-hash", "v2-test" + "observer", + "capability-hash", + "approved-fixture-hash", + "catalog-hash", + "v2-test", ) assert status["principal_contract_hash"] == "capability-hash" assert status["tool_manifest_hash"] == "approved-fixture-hash" @@ -1396,7 +1612,11 @@ def test_gateway_status_reports_distinct_manifest_provenance(tmp_path: Path) -> ) ) status = runtime.observe.gateway_status( - "observer", "capability-hash", "approved-fixture-hash", "catalog-hash", "v2-test" + "observer", + "capability-hash", + "approved-fixture-hash", + "catalog-hash", + "v2-test", ) assert set(status["manifests"]["comparisons"].values()) == {"match"} assert status["catalog"]["chatgpt_observed"] == { @@ -1415,7 +1635,11 @@ def test_gateway_status_reports_distinct_manifest_provenance(tmp_path: Path) -> ) ) status = runtime.observe.gateway_status( - "observer", "capability-hash", "approved-fixture-hash", "catalog-hash", "v2-test" + "observer", + "capability-hash", + "approved-fixture-hash", + "catalog-hash", + "v2-test", ) assert status["manifests"]["comparisons"] == { "live_to_nix_approved": "match", @@ -1431,7 +1655,11 @@ def test_gateway_status_reports_catalog_approval_drift(tmp_path: Path) -> None: runtime = Runtime.create(cfg, "observer") status = runtime.observe.gateway_status( - "observer", "capability-hash", "approved-fixture-hash", "live-catalog-hash", "v2-test" + "observer", + "capability-hash", + "approved-fixture-hash", + "live-catalog-hash", + "v2-test", ) assert status["catalog"]["nix_approved"] == { @@ -1574,8 +1802,14 @@ def test_artifacts_are_scoped_to_the_creating_principal(tmp_path: Path) -> None: source="test.observer", target={"id": "observer"}, ) - assert observer.artifacts.read(observer_artifact["artifact_id"])["kind"] == "observer-fixture" - assert operator.artifacts.read(observer_artifact["artifact_id"])["kind"] == "observer-fixture" + assert ( + observer.artifacts.read(observer_artifact["artifact_id"])["kind"] + == "observer-fixture" + ) + assert ( + operator.artifacts.read(observer_artifact["artifact_id"])["kind"] + == "observer-fixture" + ) def test_unknown_principal_is_rejected_before_server_creation(tmp_path: Path) -> None: @@ -1644,7 +1878,10 @@ def test_config_load_uses_one_project_contract(tmp_path: Path) -> None: assert loaded.projects["fixture"].observer_read is True assert loaded.projects["fixture"].devtools_entrypoint == "nix develop" assert loaded.projects["fixture"].task_authority is not None - assert loaded.projects["fixture"].task_authority.database == project / ".beads" / "dolt" + assert ( + loaded.projects["fixture"].task_authority.database + == project / ".beads" / "dolt" + ) def test_config_rejects_retired_project_visibility_fields(tmp_path: Path) -> None: @@ -1740,14 +1977,23 @@ def test_machine_query_selects_and_pages_large_collector_report(tmp_path: Path) def test_machine_section_overflow_is_retained_as_an_attested_artifact( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - runtime = Runtime.create(dataclasses.replace(config(tmp_path), max_result_bytes=1_024), "observer") + runtime = Runtime.create( + dataclasses.replace(config(tmp_path), max_result_bytes=1_024), "observer" + ) report = { "schema": "sinnix.observe.v1", "generated_at": "2026-08-21T00:00:00Z", "window": {}, "live_pressure": {"detail": "x" * 4_000}, } - monkeypatch.setattr(runtime.observe, "_collect_report", lambda operation, cursor=0, page_limit=None: {"available": True, "report": report}) + monkeypatch.setattr( + runtime.observe, + "_collect_report", + lambda operation, cursor=0, page_limit=None: { + "available": True, + "report": report, + }, + ) result = runtime.observe.machine_query("pressure") @@ -1770,12 +2016,14 @@ def test_machine_query_requests_owner_selected_section( monkeypatch.setattr( runtime.observe.execution, "run", - lambda command, _profile: calls.append(command) - or ExecutionResult( - command=tuple(command), - exit_status=0, - stdout=json.dumps(report).encode(), - stderr=b"", + lambda command, _profile: ( + calls.append(command) + or ExecutionResult( + command=tuple(command), + exit_status=0, + stdout=json.dumps(report).encode(), + stderr=b"", + ) ), ) @@ -1811,8 +2059,7 @@ def test_machine_query_reduces_page_to_response_bound( "observer", ) rows = [ - {"unit": f"fixture-{index}.service", "detail": "x" * 700} - for index in range(3) + {"unit": f"fixture-{index}.service", "detail": "x" * 700} for index in range(3) ] def collect(_operation: str, cursor: int, page_limit: int) -> dict[str, object]: diff --git a/pkgs/sinnix-agent-gateway/test_subscriptions.py b/pkgs/sinnix-agent-gateway/test_subscriptions.py index bf5b1a5b..6be34f13 100644 --- a/pkgs/sinnix-agent-gateway/test_subscriptions.py +++ b/pkgs/sinnix-agent-gateway/test_subscriptions.py @@ -1,9 +1,7 @@ from __future__ import annotations import anyio - from mcp.shared.subscriptions import ResourceUpdated - from sinnix_agent_gateway.subscriptions import OwnerRevisionPublisher diff --git a/pkgs/sinnix-agent-gateway/test_terminals.py b/pkgs/sinnix-agent-gateway/test_terminals.py index d93da5a5..2b78463d 100644 --- a/pkgs/sinnix-agent-gateway/test_terminals.py +++ b/pkgs/sinnix-agent-gateway/test_terminals.py @@ -6,19 +6,20 @@ from pathlib import Path import pytest - from sinnix_agent_gateway.artifacts import ArtifactService from sinnix_agent_gateway.capabilities import PolicyError, Principal from sinnix_agent_gateway.config import GatewayConfig -from sinnix_mcp.execution import OwnerExecution from sinnix_agent_gateway.terminals import ( TerminalDiagnosticError, TerminalError, TerminalService, ) +from sinnix_mcp.execution import OwnerExecution -def terminal_service(tmp_path: Path, principal_name: str) -> tuple[TerminalService, Path]: +def terminal_service( + tmp_path: Path, principal_name: str +) -> tuple[TerminalService, Path]: captured = tmp_path / "terminal-commands.jsonl" runner = tmp_path / "kitty-control" runner.write_text( @@ -43,7 +44,9 @@ def terminal_service(tmp_path: Path, principal_name: str) -> tuple[TerminalServi } ) principal = Principal.for_name(principal_name) - return TerminalService(config, principal, ArtifactService(config, principal), execution), captured + return TerminalService( + config, principal, ArtifactService(config, principal), execution + ), captured def commands(path: Path) -> list[list[str]]: diff --git a/pkgs/sinnix-agent-gateway/test_timeline.py b/pkgs/sinnix-agent-gateway/test_timeline.py index 38bd6b95..ebcd406d 100644 --- a/pkgs/sinnix-agent-gateway/test_timeline.py +++ b/pkgs/sinnix-agent-gateway/test_timeline.py @@ -5,7 +5,6 @@ from pathlib import Path import pytest - from sinnix_agent_gateway.capabilities import PolicyError, Principal from sinnix_agent_gateway.config import GatewayConfig from sinnix_agent_gateway.sessions import SessionLogService, SessionSource @@ -60,11 +59,22 @@ def test_timeline_query_preserves_raw_source_time_basis_and_unavailability( ) assert result["entries"][0]["object_reference"] == "codex:fixture.jsonl" unavailable = { - row["source"]: row for row in result["sources"] if row["availability"] == "unavailable" + row["source"]: row + for row in result["sources"] + if row["availability"] == "unavailable" } - assert unavailable["polylogue"]["reason"] == "upstream is intentionally unavailable on this host" - assert unavailable["sinex"]["reason"] == "upstream is intentionally unavailable on this host" - assert unavailable["lynchpin"]["reason"] == "no gateway semantic adapter is registered yet" + assert ( + unavailable["polylogue"]["reason"] + == "upstream is intentionally unavailable on this host" + ) + assert ( + unavailable["sinex"]["reason"] + == "upstream is intentionally unavailable on this host" + ) + assert ( + unavailable["lynchpin"]["reason"] + == "no gateway semantic adapter is registered yet" + ) def test_timeline_query_bounds_large_snippets(tmp_path: Path) -> None: @@ -85,7 +95,9 @@ def test_timeline_query_bounds_large_snippets(tmp_path: Path) -> None: assert len(result["entries"]) < 2 -def test_timeline_query_rejects_ambiguous_range_and_unknown_source(tmp_path: Path) -> None: +def test_timeline_query_rejects_ambiguous_range_and_unknown_source( + tmp_path: Path, +) -> None: timeline = timeline_service(tmp_path, "operator") with pytest.raises(TimelineError, match="timezone"): diff --git a/pkgs/sinnix-agent-gateway/test_waits.py b/pkgs/sinnix-agent-gateway/test_waits.py index 761e5945..689ada64 100644 --- a/pkgs/sinnix-agent-gateway/test_waits.py +++ b/pkgs/sinnix-agent-gateway/test_waits.py @@ -2,8 +2,12 @@ import anyio import pytest - -from sinnix_agent_gateway.waits import BoundedWaitService, WaitEvidence, WaitRequest, WaitTarget +from sinnix_agent_gateway.waits import ( + BoundedWaitService, + WaitEvidence, + WaitRequest, + WaitTarget, +) @pytest.mark.parametrize("expected", [[], "open", 3]) @@ -15,7 +19,9 @@ def test_wait_request_rejects_non_object_expectations(expected: object) -> None: @pytest.mark.parametrize("poll_seconds", [True, "0.25", 0, 5.1]) def test_wait_request_rejects_invalid_poll_interval(poll_seconds: object) -> None: with pytest.raises(ValueError, match="poll_seconds must be 0.01-5"): - WaitRequest(WaitTarget.BEAD_STATUS, "sinnix://beads/example", poll_seconds=poll_seconds) # type: ignore[arg-type] + WaitRequest( + WaitTarget.BEAD_STATUS, "sinnix://beads/example", poll_seconds=poll_seconds + ) # type: ignore[arg-type] class Clock: @@ -29,7 +35,9 @@ def sleep(self, seconds: float) -> None: self.value += seconds -def test_timeout_returns_current_evidence_and_continuation_without_background_work() -> None: +def test_timeout_returns_current_evidence_and_continuation_without_background_work() -> ( + None +): clock = Clock() service = BoundedWaitService( lambda _request: WaitEvidence(False, {"phase": "running"}, "rev-running"), @@ -37,7 +45,14 @@ def test_timeout_returns_current_evidence_and_continuation_without_background_wo sleeper=clock.sleep, ) - result = service.wait(WaitRequest(WaitTarget.JOB_TERMINAL, "sinnix://jobs/job-1", timeout_seconds=1, poll_seconds=0.4)) + result = service.wait( + WaitRequest( + WaitTarget.JOB_TERMINAL, + "sinnix://jobs/job-1", + timeout_seconds=1, + poll_seconds=0.4, + ) + ) assert result["outcome"] == "timeout" assert result["evidence"] == {"phase": "running"} @@ -46,9 +61,14 @@ def test_timeout_returns_current_evidence_and_continuation_without_background_wo def test_cancellation_returns_evidence_without_spawning_work() -> None: - service = BoundedWaitService(lambda _request: WaitEvidence(False, {"status": "open"}, "rev"), sleeper=lambda _seconds: None) + service = BoundedWaitService( + lambda _request: WaitEvidence(False, {"status": "open"}, "rev"), + sleeper=lambda _seconds: None, + ) result = service.wait( - WaitRequest(WaitTarget.BEAD_STATUS, "sinnix://projects/p/beads/b", timeout_seconds=10), + WaitRequest( + WaitTarget.BEAD_STATUS, "sinnix://projects/p/beads/b", timeout_seconds=10 + ), cancelled=lambda: True, ) assert result["outcome"] == "cancelled" @@ -68,7 +88,12 @@ def resolve(_request: WaitRequest) -> WaitEvidence: async def scenario() -> None: result = await service.wait_async( - WaitRequest(WaitTarget.BEAD_STATUS, "sinnix://projects/p/beads/b", timeout_seconds=2, poll_seconds=0.01), + WaitRequest( + WaitTarget.BEAD_STATUS, + "sinnix://projects/p/beads/b", + timeout_seconds=2, + poll_seconds=0.01, + ), cancelled=lambda: calls >= 2, ) assert result["outcome"] == "cancelled" diff --git a/pkgs/sinnix-agent-gateway/tools/extract_legacy_gateway_manifest.py b/pkgs/sinnix-agent-gateway/tools/extract_legacy_gateway_manifest.py index f1ba44e2..85602409 100644 --- a/pkgs/sinnix-agent-gateway/tools/extract_legacy_gateway_manifest.py +++ b/pkgs/sinnix-agent-gateway/tools/extract_legacy_gateway_manifest.py @@ -9,7 +9,6 @@ import subprocess from pathlib import Path - LEGACY_COMMIT = "e5980a67eae343f954f695c46a8fadda83961a03" LEGACY_APP_PATH = "pkgs/sinnix-agent-gateway/sinnix_agent_gateway/app.py" @@ -65,7 +64,10 @@ def migration_tool_names(parity_module: Path) -> list[str]: for node in module.body: if not ( isinstance(node, ast.Assign) - and any(isinstance(target, ast.Name) and target.id == "V2_MIGRATIONS" for target in node.targets) + and any( + isinstance(target, ast.Name) and target.id == "V2_MIGRATIONS" + for target in node.targets + ) and isinstance(node.value, ast.Dict) ): continue @@ -82,7 +84,9 @@ def canonical_manifest_bytes(manifest: object) -> int: if not isinstance(manifest, dict): raise ValueError("legacy operator manifest must be an object") tools = manifest.get("tools") - if manifest.get("schema") != "sinnix.gateway-tools.v1" or not isinstance(tools, list): + if manifest.get("schema") != "sinnix.gateway-tools.v1" or not isinstance( + tools, list + ): raise ValueError("legacy operator manifest must contain schema and tools") payload = json.dumps( {"schema": manifest["schema"], "tools": tools}, @@ -126,11 +130,15 @@ def main() -> None: if arguments.verify is not None: expected = json.loads(arguments.verify.read_text()) if manifest != expected: - raise SystemExit("checked-in legacy manifest does not match the pinned source") + raise SystemExit( + "checked-in legacy manifest does not match the pinned source" + ) migration_tools = migration_tool_names(arguments.verify.parent / "parity.py") historical_tools = manifest["tools"] if migration_tools != historical_tools: - raise SystemExit("checked-in parity map does not match the pinned source in order") + raise SystemExit( + "checked-in parity map does not match the pinned source in order" + ) print(f"verified {measured_bytes} canonical legacy manifest bytes") print(f"verified {len(manifest['tools'])} legacy Gateway V1 tools") print("verified parity map names and order against the pinned source") diff --git a/pkgs/sinnix-agent-gateway/tools/generate_gateway_artifacts.py b/pkgs/sinnix-agent-gateway/tools/generate_gateway_artifacts.py index 68aed5fb..538c6215 100644 --- a/pkgs/sinnix-agent-gateway/tools/generate_gateway_artifacts.py +++ b/pkgs/sinnix-agent-gateway/tools/generate_gateway_artifacts.py @@ -1,5 +1,4 @@ from sinnix_agent_gateway.gateway_codegen import main - if __name__ == "__main__": raise SystemExit(main()) diff --git a/pkgs/sinnix-mcp/sinnix_mcp/__init__.py b/pkgs/sinnix-mcp/sinnix_mcp/__init__.py index 1704088b..d30c6179 100644 --- a/pkgs/sinnix-mcp/sinnix_mcp/__init__.py +++ b/pkgs/sinnix-mcp/sinnix_mcp/__init__.py @@ -12,7 +12,7 @@ SourceBinding, response_envelope_from_dict, ) -from .refs import RefTemplate, ReferenceError, SinnixRef +from .refs import ReferenceError, RefTemplate, SinnixRef __all__ = [ "Authority", diff --git a/pkgs/sinnix-mcp/sinnix_mcp/execution.py b/pkgs/sinnix-mcp/sinnix_mcp/execution.py index d68fce9d..b14b59b1 100644 --- a/pkgs/sinnix-mcp/sinnix_mcp/execution.py +++ b/pkgs/sinnix-mcp/sinnix_mcp/execution.py @@ -51,7 +51,10 @@ def __post_init__(self) -> None: raise ValueError("execution timeout must be positive") if self.max_stdout_bytes < 1 or self.max_stderr_bytes < 1: raise ValueError("execution output bounds must be positive") - if self.max_combined_output_bytes is not None and self.max_combined_output_bytes < 1: + if ( + self.max_combined_output_bytes is not None + and self.max_combined_output_bytes < 1 + ): raise ValueError("combined execution output bound must be positive") diff --git a/pkgs/sinnix-mcp/sinnix_mcp/owners.py b/pkgs/sinnix-mcp/sinnix_mcp/owners.py index b4db1e3a..27be45d4 100644 --- a/pkgs/sinnix-mcp/sinnix_mcp/owners.py +++ b/pkgs/sinnix-mcp/sinnix_mcp/owners.py @@ -42,13 +42,19 @@ class OwnerSpec: def __post_init__(self) -> None: parts = self.namespace.split(".") if not parts or any(not part.isidentifier() for part in parts): - raise ValueError(f"namespace must be dotted identifiers: {self.namespace!r}") + raise ValueError( + f"namespace must be dotted identifiers: {self.namespace!r}" + ) if not self.owner: raise ValueError("owner registration requires an owner") if not self.versions or any(version < 1 for version in self.versions): - raise ValueError("owner registration requires one or more positive protocol versions") + raise ValueError( + "owner registration requires one or more positive protocol versions" + ) if self.source_scoped and self.authority is not Authority.OWNER: - raise ValueError("source-scoped owners must retain their own source authority") + raise ValueError( + "source-scoped owners must retain their own source authority" + ) def supports(self, operation: str, version: int) -> bool: return ( @@ -80,7 +86,9 @@ def register(self, owner: OwnerSpec) -> None: if owner.namespace in self._owners: raise ValueError(f"duplicate owner namespace: {owner.namespace}") for existing in self._owners.values(): - if owner.namespace.startswith(existing.namespace + ".") or existing.namespace.startswith(owner.namespace + "."): + if owner.namespace.startswith( + existing.namespace + "." + ) or existing.namespace.startswith(owner.namespace + "."): raise ValueError( "owner namespaces cannot overlap: " f"{owner.namespace!r} and {existing.namespace!r}" @@ -88,9 +96,15 @@ def register(self, owner: OwnerSpec) -> None: self._owners[owner.namespace] = owner def resolve(self, operation: str, version: int = 1) -> OwnerSpec: - matches = [owner for owner in self._owners.values() if owner.supports(operation, version)] + matches = [ + owner + for owner in self._owners.values() + if owner.supports(operation, version) + ] if not matches: - raise KeyError(f"no owner supports {operation!r} at protocol version {version}") + raise KeyError( + f"no owner supports {operation!r} at protocol version {version}" + ) if len(matches) != 1: raise ValueError(f"ambiguous owner registration for {operation!r}") return matches[0] diff --git a/pkgs/sinnix-mcp/sinnix_mcp/protocol.py b/pkgs/sinnix-mcp/sinnix_mcp/protocol.py index 2f4ba4e9..c3d46be3 100644 --- a/pkgs/sinnix-mcp/sinnix_mcp/protocol.py +++ b/pkgs/sinnix-mcp/sinnix_mcp/protocol.py @@ -29,7 +29,9 @@ class ErrorCode(StrEnum): def _canonical_json(value: Any) -> bytes: - return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode() + return json.dumps( + value, sort_keys=True, separators=(",", ":"), ensure_ascii=False + ).encode() def _validate_uuid(name: str, value: str) -> None: @@ -72,7 +74,9 @@ class OpaquePayload: digest: str | None = None media_type: str | None = None size_bytes: int | None = None - inline_limit: int = field(default=DEFAULT_INLINE_PAYLOAD_BYTES, repr=False, compare=False) + inline_limit: int = field( + default=DEFAULT_INLINE_PAYLOAD_BYTES, repr=False, compare=False + ) def __post_init__(self) -> None: inline_present = self.inline is not None @@ -82,10 +86,17 @@ def __post_init__(self) -> None: if inline_present: if len(_canonical_json(self.inline)) > self.inline_limit: raise ValueError("inline payload exceeds its configured bound") - if any(value is not None for value in (self.digest, self.media_type, self.size_bytes)): + if any( + value is not None + for value in (self.digest, self.media_type, self.size_bytes) + ): raise ValueError("inline payload cannot carry opaque artifact metadata") else: - if self.digest is None or not self.digest.startswith("sha256:") or len(self.digest) != 71: + if ( + self.digest is None + or not self.digest.startswith("sha256:") + or len(self.digest) != 71 + ): raise ValueError("opaque payload requires a sha256: digest") if not self.media_type: raise ValueError("opaque payload requires a media_type") @@ -93,7 +104,9 @@ def __post_init__(self) -> None: raise ValueError("opaque payload requires a non-negative size_bytes") @classmethod - def bounded(cls, value: Any, *, limit: int = DEFAULT_INLINE_PAYLOAD_BYTES) -> "OpaquePayload": + def bounded( + cls, value: Any, *, limit: int = DEFAULT_INLINE_PAYLOAD_BYTES + ) -> "OpaquePayload": return cls(inline=value, inline_limit=limit) def to_dict(self) -> dict[str, Any]: @@ -126,7 +139,11 @@ def __post_init__(self) -> None: raise ValueError(f"unsupported request schema: {self.schema}") _validate_uuid("request_id", self.request_id) _validate_uuid("correlation_id", self.correlation_id) - if not isinstance(self.operation, str) or not self.operation or "." not in self.operation: + if ( + not isinstance(self.operation, str) + or not self.operation + or "." not in self.operation + ): raise ValueError("operation must be a dotted canonical name") if not isinstance(self.owner, str) or not isinstance(self.principal, str): raise ValueError("request requires owner and principal") @@ -240,7 +257,11 @@ def _opaque_payload_from_dict(value: Any) -> OpaquePayload: digest = value["digest"] media_type = value["media_type"] size_bytes = value["size_bytes"] - if not isinstance(ref, str) or not isinstance(digest, str) or not isinstance(media_type, str): + if ( + not isinstance(ref, str) + or not isinstance(digest, str) + or not isinstance(media_type, str) + ): raise ValueError("opaque payload fields must be strings") if not isinstance(size_bytes, int) or isinstance(size_bytes, bool): raise ValueError("opaque payload size_bytes must be an integer") @@ -272,9 +293,9 @@ def response_envelope_from_dict(value: Any) -> ResponseEnvelope: schema = value["schema"] if not isinstance(schema, int) or isinstance(schema, bool): raise ValueError("response schema must be an integer") - for field in ("request_id", "correlation_id", "owner"): - if not isinstance(value[field], str): - raise ValueError(f"response {field} must be a string") + for field_name in ("request_id", "correlation_id", "owner"): + if not isinstance(value[field_name], str): + raise ValueError(f"response {field_name} must be a string") ok = value["ok"] if not isinstance(ok, bool): raise ValueError("response ok must be a boolean") @@ -289,12 +310,18 @@ def response_envelope_from_dict(value: Any) -> ResponseEnvelope: raise ValueError("response source_bindings must be a list") source_bindings: list[SourceBinding] = [] for binding in source_values: - if not isinstance(binding, Mapping) or set(binding) != {"source_ref", "generation", "root_digest"}: + if not isinstance(binding, Mapping) or set(binding) != { + "source_ref", + "generation", + "root_digest", + }: raise ValueError("response source binding has invalid fields") source_ref = binding["source_ref"] generation = binding["generation"] root_digest = binding["root_digest"] - if not all(isinstance(item, str) for item in (source_ref, generation, root_digest)): + if not all( + isinstance(item, str) for item in (source_ref, generation, root_digest) + ): raise ValueError("response source binding fields must be strings") source_bindings.append( SourceBinding( @@ -318,7 +345,12 @@ def response_envelope_from_dict(value: Any) -> ResponseEnvelope: schema=value["schema"], ) error_value = value["error"] - if not isinstance(error_value, Mapping) or set(error_value) != {"schema", "code", "message", "details"}: + if not isinstance(error_value, Mapping) or set(error_value) != { + "schema", + "code", + "message", + "details", + }: raise ValueError("response error has invalid fields") error_schema = error_value["schema"] if not isinstance(error_schema, int) or isinstance(error_schema, bool): diff --git a/pkgs/sinnix-mcp/sinnix_mcp/refs.py b/pkgs/sinnix-mcp/sinnix_mcp/refs.py index 87029241..8c418979 100644 --- a/pkgs/sinnix-mcp/sinnix_mcp/refs.py +++ b/pkgs/sinnix-mcp/sinnix_mcp/refs.py @@ -26,8 +26,16 @@ def parse(cls, value: str) -> "SinnixRef": parsed = urlsplit(value) if parsed.scheme != cls.scheme: raise ReferenceError(f"reference must use {cls.scheme}://") - if parsed.query or parsed.fragment or parsed.username or parsed.password or parsed.port: - raise ReferenceError("resource references cannot contain authority details, query, or fragment") + if ( + parsed.query + or parsed.fragment + or parsed.username + or parsed.password + or parsed.port + ): + raise ReferenceError( + "resource references cannot contain authority details, query, or fragment" + ) if parsed.netloc: raw_segments = [parsed.netloc, *parsed.path.split("/")] else: @@ -36,9 +44,16 @@ def parse(cls, value: str) -> "SinnixRef": if not segments: raise ReferenceError("resource reference must name a resource") if any(segment in {".", ".."} for segment in segments): - raise ReferenceError("resource reference cannot contain relative path segments") - if any("/" in segment or "\\" in segment or "\x00" in segment for segment in segments): - raise ReferenceError("resource reference segments cannot contain path separators or NUL") + raise ReferenceError( + "resource reference cannot contain relative path segments" + ) + if any( + "/" in segment or "\\" in segment or "\x00" in segment + for segment in segments + ): + raise ReferenceError( + "resource reference segments cannot contain path separators or NUL" + ) return cls(segments) def __str__(self) -> str: @@ -57,7 +72,9 @@ class RefTemplate: template: str def __post_init__(self) -> None: - parsed = SinnixRef.parse(self.template.replace("{", "template-").replace("}", "")) + parsed = SinnixRef.parse( + self.template.replace("{", "template-").replace("}", "") + ) if not self.kind: raise ReferenceError("resource kind cannot be empty") if not parsed.segments: @@ -66,7 +83,11 @@ def __post_init__(self) -> None: @property def segments(self) -> tuple[str, ...]: - return tuple(segment for segment in self.template.removeprefix("sinnix://").split("/") if segment) + return tuple( + segment + for segment in self.template.removeprefix("sinnix://").split("/") + if segment + ) @property def variables(self) -> tuple[str, ...]: @@ -78,7 +99,9 @@ def variables(self) -> tuple[str, ...]: raise ReferenceError(f"invalid template variable: {name!r}") variables.append(name) elif "{" in segment or "}" in segment: - raise ReferenceError(f"template variables must occupy a full segment: {segment!r}") + raise ReferenceError( + f"template variables must occupy a full segment: {segment!r}" + ) if len(set(variables)) != len(variables): raise ReferenceError(f"template repeats variable(s): {self.template}") return tuple(variables) @@ -103,7 +126,9 @@ def format(self, values: Mapping[str, str]) -> SinnixRef: ) if any(not segment for segment in segments): raise ReferenceError(f"cannot format {self.kind}: empty resource segment") - return SinnixRef.parse(f"sinnix://{'/'.join(quote(segment, safe='') for segment in segments)}") + return SinnixRef.parse( + f"sinnix://{'/'.join(quote(segment, safe='') for segment in segments)}" + ) def match(self, reference: SinnixRef) -> dict[str, str] | None: if len(reference.segments) != len(self.segments): diff --git a/pkgs/sinnix-mcp/test_protocol.py b/pkgs/sinnix-mcp/test_protocol.py index f1e5af1e..06c141b5 100644 --- a/pkgs/sinnix-mcp/test_protocol.py +++ b/pkgs/sinnix-mcp/test_protocol.py @@ -3,7 +3,6 @@ from uuid import uuid4 import pytest - from sinnix_mcp import ( Authority, ErrorCode, diff --git a/pkgs/sinnix-observe/sinnix_observe/sources/agent_gateway.py b/pkgs/sinnix-observe/sinnix_observe/sources/agent_gateway.py index 248ed44e..e8314389 100644 --- a/pkgs/sinnix-observe/sinnix_observe/sources/agent_gateway.py +++ b/pkgs/sinnix-observe/sinnix_observe/sources/agent_gateway.py @@ -24,8 +24,12 @@ def _json(path: Path, bound: int = 262_144) -> dict[str, Any] | None: return None -def _polylogue_sessions(job_ids: list[str]) -> tuple[dict[str, dict[str, Any]], str | None]: - db = Path(os.environ.get("SINNIX_POLYLOGUE_INDEX_DB", "/realm/data/ai/polylogue/index.db")) +def _polylogue_sessions( + job_ids: list[str], +) -> tuple[dict[str, dict[str, Any]], str | None]: + db = Path( + os.environ.get("SINNIX_POLYLOGUE_INDEX_DB", "/realm/data/ai/polylogue/index.db") + ) if not db.is_file(): return {}, "polylogue_archive_unavailable" found: dict[str, dict[str, Any]] = {} @@ -33,14 +37,19 @@ def _polylogue_sessions(job_ids: list[str]) -> tuple[dict[str, dict[str, Any]], try: connection = sqlite3.connect(f"file:{db}?mode=ro", uri=True, timeout=0.25) connection.execute("pragma query_only=on") - connection.set_progress_handler(lambda: 1 if time.monotonic() - started > 0.5 else 0, 1000) + connection.set_progress_handler( + lambda: 1 if time.monotonic() - started > 0.5 else 0, 1000 + ) for job_id in job_ids: row = connection.execute( "select session_id from messages_fts where messages_fts match ? limit 1", ('"' + job_id.replace('"', '""') + '"',), ).fetchone() if row: - found[job_id] = {"session_id": row[0], "source": "polylogue:index.db/messages_fts"} + found[job_id] = { + "session_id": row[0], + "source": "polylogue:index.db/messages_fts", + } connection.close() except sqlite3.Error: return found, "polylogue_index_unreadable" @@ -87,16 +96,24 @@ def _is_canonical_job_record(value: dict[str, Any]) -> bool: ) -def collect_agent_gateway(limit: int = 20, below: dict[str, Any] | None = None) -> dict[str, Any]: +def collect_agent_gateway( + limit: int = 20, below: dict[str, Any] | None = None +) -> dict[str, Any]: """Read daemon-owned job records. `below` remains a caller-compatible input.""" _ = below - root = Path(os.environ.get("SINNIXD_STATE_DIR", str(Path.home() / ".local/state/sinnixd"))) + root = Path( + os.environ.get("SINNIXD_STATE_DIR", str(Path.home() / ".local/state/sinnixd")) + ) records_root = root / "jobs" malformed: list[str] = [] jobs: list[dict[str, Any]] = [] try: - paths = sorted(records_root.glob("*.json"), key=lambda path: path.stat().st_mtime, reverse=True)[: max(1, min(limit, 100))] + paths = sorted( + records_root.glob("*.json"), + key=lambda path: path.stat().st_mtime, + reverse=True, + )[: max(1, min(limit, 100))] except OSError: paths = [] for path in paths: diff --git a/pkgs/sinnix-observe/tests/test_smoke.py b/pkgs/sinnix-observe/tests/test_smoke.py index 4ce11428..f4882970 100644 --- a/pkgs/sinnix-observe/tests/test_smoke.py +++ b/pkgs/sinnix-observe/tests/test_smoke.py @@ -4,7 +4,6 @@ import argparse import json -import os import sqlite3 import pytest @@ -389,9 +388,7 @@ def test_cli_collect_report_offline() -> None: assert "gaps_summary" in report -def test_agent_gateway_reads_canonical_agentctl_records( - tmp_path, monkeypatch -) -> None: +def test_agent_gateway_reads_canonical_agentctl_records(tmp_path, monkeypatch) -> None: root = tmp_path / "sinnixd" jobs = root / "jobs" jobs.mkdir(parents=True) @@ -408,9 +405,17 @@ def test_agent_gateway_reads_canonical_agentctl_records( "project_id": "sinnix", "timeout_seconds": 60, "checkout": {"path": "/realm/worktrees/fixture"}, - "contract": {"backend": "codex", "model": "fixture", "effort": "high"}, + "contract": { + "backend": "codex", + "model": "fixture", + "effort": "high", + }, + }, + "state": { + "phase": "succeeded", + "terminal": True, + "systemd": {"ControlGroup": "/agent.slice/x"}, }, - "state": {"phase": "succeeded", "terminal": True, "systemd": {"ControlGroup": "/agent.slice/x"}}, } ) ) @@ -471,7 +476,10 @@ def test_gateway_rows_use_agentctl_record_fields() -> None: "effort": "high", "checkout": {"path": "/realm/worktrees/j"}, "contract": {"backend": "codex"}, - "state": {"phase": "running", "systemd": {"ControlGroup": "/agent.slice/j"}}, + "state": { + "phase": "running", + "systemd": {"ControlGroup": "/agent.slice/j"}, + }, } ], }, diff --git a/pkgs/sinnix-ops-reducer/sinnix_ops_reducer/actions.py b/pkgs/sinnix-ops-reducer/sinnix_ops_reducer/actions.py index eade74e9..b9b475d1 100644 --- a/pkgs/sinnix-ops-reducer/sinnix_ops_reducer/actions.py +++ b/pkgs/sinnix-ops-reducer/sinnix_ops_reducer/actions.py @@ -14,8 +14,8 @@ from sinnix_lib.ledger import append_jsonl, iter_jsonl from sinnix_lib.systemd import show_units -from .agent_jobs import AgentCtlClient, AgentCtlError from . import pressure as pressure_model +from .agent_jobs import AgentCtlClient, AgentCtlError from .reducer import now_iso # Schema of the one-time marker record written to the receipts ledger the @@ -235,8 +235,7 @@ def validate_request(value: Any) -> dict[str, Any]: raise ActionError("target must contain only job_id, unit, or process") if sum(key in target for key in ("job_id", "unit", "process")) != 1: raise ActionError( - "target must identify exactly one attested job, runtime unit, " - "or process" + "target must identify exactly one attested job, runtime unit, or process" ) if "process" in target: process = target["process"] @@ -424,7 +423,9 @@ def _resolve( try: job = self.agent_jobs.get(target["job_id"]) except AgentCtlError as error: - raise ActionError(f"AgentCTL job lookup failed: {error}", 503) from error + raise ActionError( + f"AgentCTL job lookup failed: {error}", 503 + ) from error if job.get("kind") != "attested-agent": raise ActionError("job target is not an attested agent job", 403) return {"kind": "job", "job": job} @@ -734,7 +735,9 @@ def _live_adapter( try: return {"name": action, "job": self.agent_jobs.cancel(job_id)} except AgentCtlError as error: - raise ActionError(f"AgentCTL cancellation failed: {error}", 503) from error + raise ActionError( + f"AgentCTL cancellation failed: {error}", 503 + ) from error elif resolved.get("kind") == "process": # Not a systemd unit -- there is nothing for systemctl to # target, so this is the one adapter branch that does not shell diff --git a/pkgs/sinnix-ops-reducer/sinnix_ops_reducer/agent_jobs.py b/pkgs/sinnix-ops-reducer/sinnix_ops_reducer/agent_jobs.py index 2db144d2..bddcf29a 100644 --- a/pkgs/sinnix-ops-reducer/sinnix_ops_reducer/agent_jobs.py +++ b/pkgs/sinnix-ops-reducer/sinnix_ops_reducer/agent_jobs.py @@ -5,7 +5,6 @@ from collections.abc import Callable, Sequence from typing import Any - MAX_AGENTCTL_RESPONSE_BYTES = 1_048_576 MAX_SNAPSHOT_JOBS = 100 DEFAULT_TIMEOUT_SECONDS = 5.0 @@ -75,7 +74,9 @@ def _call(self, arguments: Sequence[str]) -> dict[str, Any]: check=False, ) except (OSError, subprocess.TimeoutExpired) as error: - raise AgentCtlError(f"AgentCTL is unavailable: {type(error).__name__}") from error + raise AgentCtlError( + f"AgentCTL is unavailable: {type(error).__name__}" + ) from error if result.returncode != 0: raise AgentCtlError("AgentCTL rejected the job request") output = result.stdout diff --git a/pkgs/sinnix-ops-reducer/sinnix_ops_reducer/cli.py b/pkgs/sinnix-ops-reducer/sinnix_ops_reducer/cli.py index 680edb41..808e16d4 100644 --- a/pkgs/sinnix-ops-reducer/sinnix_ops_reducer/cli.py +++ b/pkgs/sinnix-ops-reducer/sinnix_ops_reducer/cli.py @@ -9,8 +9,8 @@ from pathlib import Path from . import capabilities, feedback, health, pages -from .agent_jobs import AgentCtlClient from .actions import ActionService +from .agent_jobs import AgentCtlClient from .ambient import product_source from .feedback import CoalescingTrigger, FeedbackSpool from .reducer import Reducer, observe_source diff --git a/pkgs/sinnix-ops-reducer/sinnix_ops_reducer/pages/probes.py b/pkgs/sinnix-ops-reducer/sinnix_ops_reducer/pages/probes.py index 7dc0e8e9..defce321 100644 --- a/pkgs/sinnix-ops-reducer/sinnix_ops_reducer/pages/probes.py +++ b/pkgs/sinnix-ops-reducer/sinnix_ops_reducer/pages/probes.py @@ -13,6 +13,7 @@ from pathlib import Path from typing import Any, Iterable + def load_json(path: Path) -> tuple[dict[str, Any] | None, str | None]: try: value = json.loads(path.read_text(encoding="utf-8")) @@ -80,6 +81,7 @@ def show_units( UNIT_PROPERTIES = ("ActiveState", "SubState", "UnitFileState", "LoadState") + def unit_states(units: list[tuple[str, str]]) -> dict[str, dict[str, str]]: states: dict[str, dict[str, str]] = {} for manager in {manager for manager, _ in units}: diff --git a/pkgs/sinnix-ops-reducer/sinnix_ops_reducer/pages/work.py b/pkgs/sinnix-ops-reducer/sinnix_ops_reducer/pages/work.py index 207bd4e4..5118ef38 100644 --- a/pkgs/sinnix-ops-reducer/sinnix_ops_reducer/pages/work.py +++ b/pkgs/sinnix-ops-reducer/sinnix_ops_reducer/pages/work.py @@ -24,6 +24,7 @@ tile, ) + def slice_rows(state: dict[str, Any]) -> list[dict[str, Any]]: slices = state.get("resource_slices") if not isinstance(slices, list): @@ -72,7 +73,11 @@ def work_verdict(in_flight: list[Any], failed_recent: int) -> tuple[str, str, st f"{len(in_flight)} command{'s' if len(in_flight) != 1 else ''} in flight", "submitted as named project operations", ) - return "muted", "Nothing running", "no project operation in flight and no recent failures" + return ( + "muted", + "Nothing running", + "no project operation in flight and no recent failures", + ) def ledger_rows(state: dict[str, Any]) -> list[dict[str, Any]]: @@ -92,13 +97,7 @@ def agentctl_jobs(state: dict[str, Any]) -> list[dict[str, Any]]: agentctl = state.get("agentctl") jobs = agentctl.get("jobs") if isinstance(agentctl, dict) else None return ( - [ - job - for job in jobs - if isinstance(job, dict) - ] - if isinstance(jobs, list) - else [] + [job for job in jobs if isinstance(job, dict)] if isinstance(jobs, list) else [] ) @@ -220,9 +219,14 @@ def agent_jobs_card(state: dict[str, Any], now: dt.datetime) -> str: tone = {"succeeded": "ok", "failed": "bad", "cancelled": "muted"}.get( lifecycle, "info" ) - label = contract.get("backend") or job.get("operation") or job.get("kind") or "job" + label = ( + contract.get("backend") or job.get("operation") or job.get("kind") or "job" + ) headline = f"{esc(label)} {esc(project_of(checkout.get('path')) or job.get('project_id') or '?')}" - meta = [badge(lifecycle, tone), esc(age_since(state.get("observed_at") or job.get("created_at"), now))] + meta = [ + badge(lifecycle, tone), + esc(age_since(state.get("observed_at") or job.get("created_at"), now)), + ] if contract.get("effort"): meta.append(f"effort {esc(contract['effort'])}") controls = "" diff --git a/pkgs/sinnix-ops-reducer/sinnix_ops_reducer/pressure.py b/pkgs/sinnix-ops-reducer/sinnix_ops_reducer/pressure.py index 587dd93a..c6b666ab 100644 --- a/pkgs/sinnix-ops-reducer/sinnix_ops_reducer/pressure.py +++ b/pkgs/sinnix-ops-reducer/sinnix_ops_reducer/pressure.py @@ -781,8 +781,12 @@ def lane_of( if job_match: job = (jobs_by_id or {}).get(job_match.group("job")) if isinstance(job, dict): - checkout = job.get("checkout") if isinstance(job.get("checkout"), dict) else {} - contract = job.get("contract") if isinstance(job.get("contract"), dict) else {} + checkout = ( + job.get("checkout") if isinstance(job.get("checkout"), dict) else {} + ) + contract = ( + job.get("contract") if isinstance(job.get("contract"), dict) else {} + ) worktree = str(checkout.get("path") or job.get("worktree") or "") where = Path(worktree).name if worktree else "" named = " ".join( diff --git a/pkgs/sinnix-ops-reducer/tests/test_actions.py b/pkgs/sinnix-ops-reducer/tests/test_actions.py index 76d5f86d..db4fb3b6 100644 --- a/pkgs/sinnix-ops-reducer/tests/test_actions.py +++ b/pkgs/sinnix-ops-reducer/tests/test_actions.py @@ -291,7 +291,9 @@ def test_valid_rejected_action_leaves_a_receipt(tmp_path: Path) -> None: assert receipt["status"] == "rejected" -def test_interrupt_uses_agentctl_and_records_its_cancellation_truth(tmp_path: Path) -> None: +def test_interrupt_uses_agentctl_and_records_its_cancellation_truth( + tmp_path: Path, +) -> None: inventory_path = tmp_path / "inventory.json" inventory(inventory_path) job = { diff --git a/pkgs/sinnix-ops-reducer/tests/test_agent_jobs.py b/pkgs/sinnix-ops-reducer/tests/test_agent_jobs.py index 582a5773..a640869f 100644 --- a/pkgs/sinnix-ops-reducer/tests/test_agent_jobs.py +++ b/pkgs/sinnix-ops-reducer/tests/test_agent_jobs.py @@ -58,7 +58,9 @@ def runner(command, **_kwargs): ], ) def test_list_rejects_contradictory_or_unbounded_pages(value: dict) -> None: - client = AgentCtlClient("fixture-agentctl", runner=lambda *_args, **_kwargs: response(value)) + client = AgentCtlClient( + "fixture-agentctl", runner=lambda *_args, **_kwargs: response(value) + ) with pytest.raises(AgentCtlError): client.list() diff --git a/pkgs/sinnix-ops-reducer/tests/test_pages.py b/pkgs/sinnix-ops-reducer/tests/test_pages.py index fbc21665..43dc51ff 100644 --- a/pkgs/sinnix-ops-reducer/tests/test_pages.py +++ b/pkgs/sinnix-ops-reducer/tests/test_pages.py @@ -132,7 +132,11 @@ def test_work_page_uses_agentctl_lifecycle_and_keeps_live_job_interrupts() -> No "project_id": "sinnix", "created_at": "2026-08-23T10:00:00Z", "checkout": {"path": "/realm/project/sinnix"}, - "contract": {"backend": "codex", "model": "fixture", "effort": "high"}, + "contract": { + "backend": "codex", + "model": "fixture", + "effort": "high", + }, "state": {"phase": "running", "terminal": False}, }, { diff --git a/pkgs/sinnix-ops-reducer/tests/test_reducer.py b/pkgs/sinnix-ops-reducer/tests/test_reducer.py index 1e5484d1..3d186502 100644 --- a/pkgs/sinnix-ops-reducer/tests/test_reducer.py +++ b/pkgs/sinnix-ops-reducer/tests/test_reducer.py @@ -66,7 +66,9 @@ def test_agentctl_failure_degrades_only_the_job_source(tmp_path: Path) -> None: tmp_path / "status.json", tmp_path / "token", lambda: {"report": 1}, - agent_jobs_source=lambda: (_ for _ in ()).throw(RuntimeError("socket unavailable")), + agent_jobs_source=lambda: (_ for _ in ()).throw( + RuntimeError("socket unavailable") + ), ) snapshot = reducer.refresh() assert snapshot["sources"]["sinnix-observe"]["status"] == "healthy" diff --git a/pkgs/sinnixd/pkg.nix b/pkgs/sinnixd/pkg.nix index 430a9d62..f55946dd 100644 --- a/pkgs/sinnixd/pkg.nix +++ b/pkgs/sinnixd/pkg.nix @@ -13,7 +13,10 @@ python3Packages.buildPythonApplication { src = ./.; build-system = [ python3Packages.setuptools ]; - dependencies = [ sinnix-mcp sinnix-lib ]; + dependencies = [ + sinnix-mcp + sinnix-lib + ]; nativeCheckInputs = [ python3Packages.pytest git diff --git a/pkgs/sinnixd/sinnixd/api.py b/pkgs/sinnixd/sinnixd/api.py index cb9f2967..de025b2c 100644 --- a/pkgs/sinnixd/sinnixd/api.py +++ b/pkgs/sinnixd/sinnixd/api.py @@ -9,7 +9,13 @@ from threading import BoundedSemaphore, Event from typing import Any, Callable -from sinnix_mcp import ErrorCode, ErrorEnvelope, RequestEnvelope, ResponseEnvelope, response_envelope_from_dict +from sinnix_mcp import ( + ErrorCode, + ErrorEnvelope, + RequestEnvelope, + ResponseEnvelope, + response_envelope_from_dict, +) from .jobs import DEFAULT_WAIT_SECONDS, MAX_WAIT_SECONDS from .service import SinnixdService @@ -96,7 +102,9 @@ def send_frame(connection: socket.socket, value: dict[str, Any]) -> None: def _response_timeout_seconds(request: RequestEnvelope) -> float: if request.operation != "job.wait": - return CONTROL_OPERATION_RESPONSE_TIMEOUT_SECONDS.get(request.operation, CONNECTION_TIMEOUT_SECONDS) + return CONTROL_OPERATION_RESPONSE_TIMEOUT_SECONDS.get( + request.operation, CONNECTION_TIMEOUT_SECONDS + ) timeout_seconds = request.arguments.get("timeout_seconds", DEFAULT_WAIT_SECONDS) if ( not isinstance(timeout_seconds, int) @@ -113,8 +121,13 @@ def _json_rpc_error_from_dict(value: Any) -> JsonRpcErrorEnvelope: return JsonRpcErrorEnvelope(code=value["code"], message=value["message"]) -def _response_from_json_rpc_error(request: RequestEnvelope, error: JsonRpcErrorEnvelope) -> dict[str, Any]: - if error.code == JSON_RPC_INVALID_REQUEST and error.message == WAIT_CAPACITY_EXHAUSTED_MESSAGE: +def _response_from_json_rpc_error( + request: RequestEnvelope, error: JsonRpcErrorEnvelope +) -> dict[str, Any]: + if ( + error.code == JSON_RPC_INVALID_REQUEST + and error.message == WAIT_CAPACITY_EXHAUSTED_MESSAGE + ): return ResponseEnvelope( request_id=request.request_id, correlation_id=request.correlation_id, @@ -124,18 +137,24 @@ def _response_from_json_rpc_error(request: RequestEnvelope, error: JsonRpcErrorE raise SinnixdClientError("sinnixd is unavailable") -def _response_result_from_json_rpc_frame(request: RequestEnvelope, response: dict[str, Any]) -> dict[str, Any]: +def _response_result_from_json_rpc_frame( + request: RequestEnvelope, response: dict[str, Any] +) -> dict[str, Any]: if response.get("jsonrpc") != "2.0" or response.get("id") != request.request_id: raise ProtocolError("response does not match the request") has_result = "result" in response has_error = "error" in response if has_result == has_error: raise ProtocolError("response requires exactly one of result or error") - expected_fields = {"jsonrpc", "id", "error"} if has_error else {"jsonrpc", "id", "result"} + expected_fields = ( + {"jsonrpc", "id", "error"} if has_error else {"jsonrpc", "id", "result"} + ) if set(response) != expected_fields: raise ProtocolError("response has invalid fields") if has_error: - return _response_from_json_rpc_error(request, _json_rpc_error_from_dict(response["error"])) + return _response_from_json_rpc_error( + request, _json_rpc_error_from_dict(response["error"]) + ) result = response["result"] if not isinstance(result, dict): raise ProtocolError("response requires an object result") @@ -160,7 +179,9 @@ def serve_once(self) -> None: with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as listener: self._bind(listener) try: - with ThreadPoolExecutor(max_workers=1, thread_name_prefix="sinnixd-rpc") as executor: + with ThreadPoolExecutor( + max_workers=1, thread_name_prefix="sinnixd-rpc" + ) as executor: self._accept_connection(listener, executor, BoundedSemaphore(1)) finally: self.socket_path.unlink(missing_ok=True) @@ -220,7 +241,9 @@ def _accept_connection( except OSError: permits.release() raise - executor.submit(self._serve_connection, connection, permits, wait_executor, wait_permits) + executor.submit( + self._serve_connection, connection, permits, wait_executor, wait_permits + ) def _serve_connection( self, @@ -243,15 +266,31 @@ def _serve_connection( request = RequestEnvelope(**params) if request.request_id != request_id: raise ProtocolError("JSON-RPC id must equal envelope request_id") - if request.operation == "job.wait" and wait_executor is not None and wait_permits is not None: + if ( + request.operation == "job.wait" + and wait_executor is not None + and wait_permits is not None + ): if not wait_permits.acquire(blocking=False): raise ProtocolError("job.wait capacity is exhausted") connection.settimeout(_response_timeout_seconds(request)) - wait_executor.submit(self._serve_wait_connection, connection, request_id, request, wait_permits) + wait_executor.submit( + self._serve_wait_connection, + connection, + request_id, + request, + wait_permits, + ) handed_off = True else: self._send_response(connection, request_id, request) - except (ConnectionError, OSError, ProtocolError, TypeError, ValueError) as error: + except ( + ConnectionError, + OSError, + ProtocolError, + TypeError, + ValueError, + ) as error: try: send_frame( connection, @@ -283,7 +322,9 @@ def _serve_wait_connection( connection.close() permits.release() - def _send_response(self, connection: socket.socket, request_id: str, request: RequestEnvelope) -> None: + def _send_response( + self, connection: socket.socket, request_id: str, request: RequestEnvelope + ) -> None: response = self.service.dispatch(request) send_frame( connection, diff --git a/pkgs/sinnixd/sinnixd/cli.py b/pkgs/sinnixd/sinnixd/cli.py index d9a321c3..1eadf6f4 100644 --- a/pkgs/sinnixd/sinnixd/cli.py +++ b/pkgs/sinnixd/sinnixd/cli.py @@ -9,7 +9,7 @@ from sinnix_mcp import ErrorCode, ErrorEnvelope, RequestEnvelope, ResponseEnvelope from .api import ProtocolError, SinnixdClientError, UnixSocketServer, call -from .jobs import GenericJobStore, GenericJobs, UserSystemdJobs, default_state_dir +from .jobs import GenericJobs, GenericJobStore, UserSystemdJobs, default_state_dir from .limits import DEFAULT_TIMEOUT_SECONDS from .projects import ProjectCatalog from .service import SinnixdService @@ -30,7 +30,10 @@ def _metadata_argument(value: str) -> tuple[str, str]: def default_socket_path() -> Path: - return Path(os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")) / "sinnixd.sock" + return ( + Path(os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")) + / "sinnixd.sock" + ) def parser() -> argparse.ArgumentParser: @@ -61,7 +64,9 @@ def parser() -> argparse.ArgumentParser: operations = project_subcommands.add_parser("operations") operations.add_argument("project_id") workspace = subcommands.add_parser("workspace") - workspace_subcommands = workspace.add_subparsers(dest="workspace_command", required=True) + workspace_subcommands = workspace.add_subparsers( + dest="workspace_command", required=True + ) workspace_list = workspace_subcommands.add_parser("list") workspace_list.add_argument("--project") workspace_get = workspace_subcommands.add_parser("get") @@ -165,7 +170,20 @@ def parser() -> argparse.ArgumentParser: task_list.add_argument("--label") task_list.add_argument("--limit", type=int, default=100) task_list.add_argument("--cursor") - task_list.add_argument("--sort", choices=("priority", "created", "updated", "closed", "status", "id", "title", "type", "assignee")) + task_list.add_argument( + "--sort", + choices=( + "priority", + "created", + "updated", + "closed", + "status", + "id", + "title", + "type", + "assignee", + ), + ) task_list.add_argument("--reverse", action="store_true") task_list.add_argument("--include-closed", action="store_true") task_list.add_argument("--ready", action="store_true") @@ -178,11 +196,28 @@ def parser() -> argparse.ArgumentParser: task_create.add_argument("project_id") task_create.add_argument("title") task_create.add_argument("--description", required=True) - task_create.add_argument("--type", dest="issue_type", choices=("bug", "feature", "task", "epic", "chore", "decision", "spike", "story", "milestone"), required=True) + task_create.add_argument( + "--type", + dest="issue_type", + choices=( + "bug", + "feature", + "task", + "epic", + "chore", + "decision", + "spike", + "story", + "milestone", + ), + required=True, + ) task_create.add_argument("--priority", type=int, choices=range(5), required=True) task_create.add_argument("--label", action="append", default=[]) task_create.add_argument("--parent") - task_create.add_argument("--dependency", action="append", type=_dependency_argument, default=[]) + task_create.add_argument( + "--dependency", action="append", type=_dependency_argument, default=[] + ) task_create.add_argument("--request-id", required=True) for command in ("claim", "complete", "release"): command_parser = task_subcommands.add_parser(command) @@ -204,7 +239,13 @@ def parser() -> argparse.ArgumentParser: task_update = task_subcommands.add_parser("update") task_update.add_argument("project_id") task_update.add_argument("task_id") - task_update.add_argument("--set-metadata", action="append", type=_metadata_argument, default=[], required=True) + task_update.add_argument( + "--set-metadata", + action="append", + type=_metadata_argument, + default=[], + required=True, + ) task_update.add_argument("--request-id", required=True) task_relate = task_subcommands.add_parser("relate") task_relate.add_argument("project_id") @@ -259,7 +300,11 @@ def main() -> int: if arguments.command == "status": request = _request("runtime.status", "sinnixd", {}) elif arguments.command == "shell": - shell_argv = arguments.argv[1:] if arguments.argv and arguments.argv[0] == "--" else arguments.argv + shell_argv = ( + arguments.argv[1:] + if arguments.argv and arguments.argv[0] == "--" + else arguments.argv + ) if not shell_argv: parser().error("shell requires a command after --") request = _request( @@ -301,16 +346,22 @@ def main() -> int: elif arguments.command == "project" and arguments.project_command == "list": request = _request("project.list", "project-adapters", {}) elif arguments.command == "project" and arguments.project_command == "get": - request = _request("project.get", "project-adapters", {"project_id": arguments.project_id}) + request = _request( + "project.get", "project-adapters", {"project_id": arguments.project_id} + ) elif arguments.command == "project": request = _request( - "project.operations", "project-adapters", {"project_id": arguments.project_id} + "project.operations", + "project-adapters", + {"project_id": arguments.project_id}, ) elif arguments.command == "workspace" and arguments.workspace_command == "list": payload = {"project_id": arguments.project} if arguments.project else {} request = _request("workspace.list", "git-workspaces", payload) elif arguments.command == "workspace" and arguments.workspace_command == "get": - request = _request("workspace.get", "git-workspaces", {"workspace_id": arguments.workspace_id}) + request = _request( + "workspace.get", "git-workspaces", {"workspace_id": arguments.workspace_id} + ) elif arguments.command == "workspace" and arguments.workspace_command == "create": request = _request( "workspace.create", @@ -348,7 +399,9 @@ def main() -> int: {"workspace_id": arguments.workspace_id}, "agent-control", ) - elif arguments.command == "workspace" and arguments.workspace_command == "checkpoint": + elif ( + arguments.command == "workspace" and arguments.workspace_command == "checkpoint" + ): request = _request( "workspace.checkpoint", "git-workspaces", @@ -359,13 +412,20 @@ def main() -> int: request = _request( "workspace.restore", "git-workspaces", - {"workspace_id": arguments.workspace_id, "checkpoint_id": arguments.checkpoint_id}, + { + "workspace_id": arguments.workspace_id, + "checkpoint_id": arguments.checkpoint_id, + }, "agent-control", ) elif arguments.command == "workspace" and arguments.workspace_command == "recover": request = _request( - "workspace.recover", "git-workspaces", - {"workspace_id": arguments.workspace_id, "checkpoint_id": arguments.checkpoint_id}, + "workspace.recover", + "git-workspaces", + { + "workspace_id": arguments.workspace_id, + "checkpoint_id": arguments.checkpoint_id, + }, "agent-control", ) elif arguments.command == "workspace" and arguments.workspace_command == "stack": @@ -388,28 +448,49 @@ def main() -> int: ) elif arguments.command == "workspace" and arguments.workspace_command == "publish": request = _request( - "workspace.publish", "git-workspaces", + "workspace.publish", + "git-workspaces", { "workspace_id": arguments.workspace_id, "job_id": arguments.job, "title": arguments.title, "body": arguments.body, - **({"packet_job_id": arguments.packet_job} if arguments.packet_job else {}), + **( + {"packet_job_id": arguments.packet_job} + if arguments.packet_job + else {} + ), }, "agent-control", ) - elif arguments.command == "workspace" and arguments.workspace_command == "review-status": - request = _request("workspace.review-status", "git-workspaces", {"workspace_id": arguments.workspace_id}) + elif ( + arguments.command == "workspace" + and arguments.workspace_command == "review-status" + ): + request = _request( + "workspace.review-status", + "git-workspaces", + {"workspace_id": arguments.workspace_id}, + ) elif arguments.command == "workspace" and arguments.workspace_command == "land": request = _request( - "workspace.land", "git-workspaces", + "workspace.land", + "git-workspaces", { "workspace_id": arguments.workspace_id, "job_id": arguments.job, - **({"packet_job_id": arguments.packet_job} if arguments.packet_job else {}), - }, "agent-control", + **( + {"packet_job_id": arguments.packet_job} + if arguments.packet_job + else {} + ), + }, + "agent-control", ) - elif arguments.command == "workspace" and arguments.workspace_command == "finish-integrated": + elif ( + arguments.command == "workspace" + and arguments.workspace_command == "finish-integrated" + ): request = _request( "workspace.finish-integrated", "git-workspaces", @@ -418,7 +499,10 @@ def main() -> int: ) elif arguments.command == "workspace": request = _request( - "workspace.finish", "git-workspaces", {"workspace_id": arguments.workspace_id}, "agent-control" + "workspace.finish", + "git-workspaces", + {"workspace_id": arguments.workspace_id}, + "agent-control", ) elif arguments.command == "job" and arguments.job_command == "start": try: @@ -470,7 +554,11 @@ def main() -> int: request = _request( "job.logs", "systemd-jobs", - {"job_id": arguments.job_id, "offset": arguments.offset, "max_bytes": arguments.max_bytes}, + { + "job_id": arguments.job_id, + "offset": arguments.offset, + "max_bytes": arguments.max_bytes, + }, ) elif arguments.command == "job" and arguments.job_command == "result": request = _request( @@ -491,7 +579,10 @@ def main() -> int: if value is not None: task_arguments[name] = value if arguments.sort is not None: - task_arguments["order"] = {"field": arguments.sort, "reverse": arguments.reverse} + task_arguments["order"] = { + "field": arguments.sort, + "reverse": arguments.reverse, + } elif arguments.reverse: parser().error("--reverse requires --sort") for name in ("include_closed", "ready"): @@ -513,13 +604,26 @@ def main() -> int: ) if arguments.parent is not None: task_arguments["parent_task_id"] = arguments.parent - elif arguments.task_command in {"get", "show", "claim", "complete", "release", "note", "relate", "update"}: + elif arguments.task_command in { + "get", + "show", + "claim", + "complete", + "release", + "note", + "relate", + "update", + }: task_arguments["task_id"] = arguments.task_id if arguments.task_command == "note": if (arguments.text is None) == (arguments.text_option is None): - parser().error("task note requires exactly one of positional text or --text") + parser().error( + "task note requires exactly one of positional text or --text" + ) task_arguments["text"] = ( - arguments.text_option if arguments.text_option is not None else arguments.text + arguments.text_option + if arguments.text_option is not None + else arguments.text ) elif arguments.task_command == "relate": task_arguments["related_task_id"] = arguments.related_task_id @@ -530,10 +634,15 @@ def main() -> int: task_arguments["reason"] = arguments.reason if arguments.task_command == "complete": task_arguments["merge_sha"] = arguments.merge_sha - if arguments.task_command == "release" and arguments.if_assignee is not None: + if ( + arguments.task_command == "release" + and arguments.if_assignee is not None + ): task_arguments["if_assignee"] = arguments.if_assignee mutation_id = getattr(arguments, "request_id", None) - task_operation = "get" if arguments.task_command == "show" else arguments.task_command + task_operation = ( + "get" if arguments.task_command == "show" else arguments.task_command + ) request = _request( f"task.{task_operation}", "task-backend", diff --git a/pkgs/sinnixd/sinnixd/contracts.py b/pkgs/sinnixd/sinnixd/contracts.py index 7259681f..236d6206 100644 --- a/pkgs/sinnixd/sinnixd/contracts.py +++ b/pkgs/sinnixd/sinnixd/contracts.py @@ -10,11 +10,10 @@ from typing import Any, Mapping, Sequence from uuid import UUID, uuid4 -from .jobs import GenericJobSpec, GenericJobs +from .jobs import GenericJobs, GenericJobSpec from .limits import maximum_timeout_seconds, valid_timeout_seconds from .projects import ProjectCatalog, RegisteredCheckout - MAX_PROMPT_BYTES = 200_000 AGENT_BACKENDS = frozenset({"claude", "codex", "gemini", "grok", "antigravity"}) CREDENTIAL_PROFILES = frozenset({"subscription", "api"}) @@ -61,7 +60,11 @@ def start_shell( raise ContractError("operator shell jobs require the operator principal") if result != "exit-status": raise ContractError("operator shell jobs require an exit-status result") - if not argv or len(argv) > 128 or any(not isinstance(item, str) or not item for item in argv): + if ( + not argv + or len(argv) > 128 + or any(not isinstance(item, str) or not item for item in argv) + ): raise ContractError("shell argv must contain 1-128 non-empty strings") if sum(len(item) for item in argv) > 32_768: raise ContractError("shell argv exceeds the configured bound") @@ -122,16 +125,28 @@ def start_agent( if backend not in AGENT_BACKENDS: raise ContractError("agent backend is invalid") if not isinstance(model, str) or not model or len(model) > 256: - raise ContractError("agent model must be a non-empty string up to 256 characters") + raise ContractError( + "agent model must be a non-empty string up to 256 characters" + ) if not isinstance(effort, str) or not effort or len(effort) > 32: - raise ContractError("agent effort must be a non-empty string up to 32 characters") + raise ContractError( + "agent effort must be a non-empty string up to 32 characters" + ) if credential_profile not in CREDENTIAL_PROFILES: raise ContractError("agent credential profile is invalid") if result != "last-message": raise ContractError("attested agent jobs require a last-message result") - if not isinstance(prompt, str) or not prompt or len(prompt.encode()) > MAX_PROMPT_BYTES: - raise ContractError(f"agent prompt must be non-empty and at most {MAX_PROMPT_BYTES} bytes") - if not self.native_runner.is_file() or not os.access(self.native_runner, os.X_OK): + if ( + not isinstance(prompt, str) + or not prompt + or len(prompt.encode()) > MAX_PROMPT_BYTES + ): + raise ContractError( + f"agent prompt must be non-empty and at most {MAX_PROMPT_BYTES} bytes" + ) + if not self.native_runner.is_file() or not os.access( + self.native_runner, os.X_OK + ): raise ContractError("native agent runner is unavailable") checkout = self.projects.checkout(project_id, checkout_id) binding = self.bead_binding(bead_binding, checkout) @@ -142,7 +157,10 @@ def start_agent( "model": model, "effort": effort, "credential_profile": credential_profile, - "prompt": {"sha256": hashlib.sha256(prompt.encode()).hexdigest(), "bytes": len(prompt.encode())}, + "prompt": { + "sha256": hashlib.sha256(prompt.encode()).hexdigest(), + "bytes": len(prompt.encode()), + }, "result": result, **({"bead_binding": binding} if binding is not None else {}), } @@ -189,9 +207,14 @@ def _start( ) -> dict[str, Any]: maximum_timeout = maximum_timeout_seconds(kind) if not valid_timeout_seconds(timeout_seconds, kind=kind): - raise ContractError(f"job timeout_seconds must be between 1 and {maximum_timeout}") + raise ContractError( + f"job timeout_seconds must be between 1 and {maximum_timeout}" + ) input_path = self.inputs_root / f"{job_id}.json" - self._write_private(input_path, json.dumps(private, sort_keys=True, separators=(",", ":")).encode()) + self._write_private( + input_path, + json.dumps(private, sort_keys=True, separators=(",", ":")).encode(), + ) environment = self._environment(checkout, job_id, principal, timeout_seconds) command = ( str(contract_runner_executable()), @@ -209,7 +232,10 @@ def _start( result_path = self.jobs.store.results_root / f"{job_id}.result" if result_kind == "last-message": private = {**private, "result_path": str(result_path)} - self._write_private(input_path, json.dumps(private, sort_keys=True, separators=(",", ":")).encode()) + self._write_private( + input_path, + json.dumps(private, sort_keys=True, separators=(",", ":")).encode(), + ) try: response = self.jobs.start( GenericJobSpec( @@ -247,11 +273,20 @@ def bead_binding( if value is None: return None expected = { - "bead_ref", "project_ref", "checkout_ref", "task_revision", - "task_etag", "claim_ref", "claim_receipt", "request_id", "assignment_ref", + "bead_ref", + "project_ref", + "checkout_ref", + "task_revision", + "task_etag", + "claim_ref", + "claim_receipt", + "request_id", + "assignment_ref", } allowed = expected | {"write_scope"} - if not isinstance(value, Mapping) or (set(value) != expected and set(value) != allowed): + if not isinstance(value, Mapping) or ( + set(value) != expected and set(value) != allowed + ): raise ContractError("agent bead binding is malformed") binding = dict(value) scope = binding.get("write_scope") @@ -284,9 +319,12 @@ def bead_binding( or len(binding["task_revision"]) != 64 or not isinstance(binding["task_etag"], str) or len(binding["task_etag"]) != 64 - or binding["assignment_ref"] is not None and ( + or binding["assignment_ref"] is not None + and ( not isinstance(binding["assignment_ref"], str) - or not re.fullmatch(r"sinnix://jobs/[0-9a-f-]{36}", binding["assignment_ref"]) + or not re.fullmatch( + r"sinnix://jobs/[0-9a-f-]{36}", binding["assignment_ref"] + ) ) ): raise ContractError("agent bead binding is malformed") @@ -303,7 +341,10 @@ def bead_binding( or claim_receipt.get("ref") != claim_ref ): raise ContractError("agent bead binding claim is malformed") - if any(character not in "0123456789abcdef" for character in binding["task_revision"] + binding["task_etag"]): + if any( + character not in "0123456789abcdef" + for character in binding["task_revision"] + binding["task_etag"] + ): raise ContractError("agent bead binding is malformed") try: UUID(str(binding["request_id"])) @@ -314,13 +355,19 @@ def bead_binding( return json.loads(json.dumps(binding, sort_keys=True, separators=(",", ":"))) def _environment( - self, checkout: RegisteredCheckout, job_id: str, principal: str, timeout_seconds: int + self, + checkout: RegisteredCheckout, + job_id: str, + principal: str, + timeout_seconds: int, ) -> dict[str, str]: project = self.projects.get(checkout.project_id) environment = project.environment.values() forbidden = sorted(name for name in environment if name.startswith("SINNIX")) if forbidden: - raise ContractError("project environment cannot supply SINNIX identity variables") + raise ContractError( + "project environment cannot supply SINNIX identity variables" + ) environment.update( { "SINNIXD_JOB_ID": job_id, @@ -336,18 +383,26 @@ def _environment( def _working_directory(checkout: RegisteredCheckout, cwd: str) -> Path: candidate = Path(cwd) if candidate.is_absolute() or ".." in candidate.parts: - raise ContractError("shell cwd must be a relative path inside the registered checkout") + raise ContractError( + "shell cwd must be a relative path inside the registered checkout" + ) try: resolved = (checkout.path / candidate).resolve(strict=True) except FileNotFoundError as error: raise ContractError("shell cwd does not exist") from error - if not resolved.is_dir() or (resolved != checkout.path and checkout.path not in resolved.parents): - raise ContractError("shell cwd must be a directory inside the registered checkout") + if not resolved.is_dir() or ( + resolved != checkout.path and checkout.path not in resolved.parents + ): + raise ContractError( + "shell cwd must be a directory inside the registered checkout" + ) return resolved def _write_private(self, path: Path, content: bytes) -> None: path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) - descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + descriptor, temporary = tempfile.mkstemp( + prefix=f".{path.name}.", dir=path.parent + ) try: with os.fdopen(descriptor, "wb") as handle: handle.write(content) diff --git a/pkgs/sinnixd/sinnixd/delivery.py b/pkgs/sinnixd/sinnixd/delivery.py index 65680843..59d34d3f 100644 --- a/pkgs/sinnixd/sinnixd/delivery.py +++ b/pkgs/sinnixd/sinnixd/delivery.py @@ -27,31 +27,74 @@ class GitHubDelivery: run: Run = subprocess.run def publish( - self, workspace_id: str, job_id: str, title: str, body: str, packet_job_id: str | None = None + self, + workspace_id: str, + job_id: str, + title: str, + body: str, + packet_job_id: str | None = None, ) -> dict[str, Any]: - workspace, project, receipt = self._verified_workspace(workspace_id, job_id, packet_job_id) + workspace, project, receipt = self._verified_workspace( + workspace_id, job_id, packet_job_id + ) if not title.strip() or len(title) > 256 or len(body.encode()) > 64_000: raise DeliveryError("review title or body exceeds its publication bounds") base = self._base_branch(project.workspace.default_base) path = workspace["path"] branch = workspace["branch"] - self._command([*project.environment.command, "git", "-C", path, "push", "-u", "origin", branch], cwd=path) - workspace, project, receipt = self._verified_workspace(workspace_id, job_id, packet_job_id) + self._command( + [ + *project.environment.command, + "git", + "-C", + path, + "push", + "-u", + "origin", + branch, + ], + cwd=path, + ) + workspace, project, receipt = self._verified_workspace( + workspace_id, job_id, packet_job_id + ) existing = self.run( ["gh", "pr", "view", branch, "--json", "url"], - cwd=path, capture_output=True, text=True, timeout=60, check=False, + cwd=path, + capture_output=True, + text=True, + timeout=60, + check=False, ) if existing.returncode == 0: publication_output = existing.stdout.strip() created = False else: publication_output = self._command( - ["gh", "pr", "create", "--head", branch, "--base", base, "--title", title, "--body", body], + [ + "gh", + "pr", + "create", + "--head", + branch, + "--base", + base, + "--title", + title, + "--body", + body, + ], cwd=path, ).stdout.strip() created = True review = self._review_after_push(workspace_id) - return {**review, "published": True, "created": created, "publication_output": publication_output, "completion": receipt} + return { + **review, + "published": True, + "created": created, + "publication_output": publication_output, + "completion": receipt, + } def _review_after_push(self, workspace_id: str) -> dict[str, Any]: for attempt in range(10): @@ -69,7 +112,11 @@ def review_status(self, workspace_id: str) -> dict[str, Any]: raise DeliveryError("workspace is unavailable") result = self._command( [ - "gh", "pr", "view", workspace["branch"], "--json", + "gh", + "pr", + "view", + workspace["branch"], + "--json", "number,url,state,isDraft,mergeStateStatus,headRefOid,baseRefName,statusCheckRollup", ], cwd=workspace["path"], @@ -78,14 +125,29 @@ def review_status(self, workspace_id: str) -> dict[str, Any]: review = json.loads(result.stdout) except json.JSONDecodeError as error: raise DeliveryError("GitHub returned malformed review state") from error - required = {"number", "url", "state", "isDraft", "mergeStateStatus", "headRefOid", "baseRefName", "statusCheckRollup"} + required = { + "number", + "url", + "state", + "isDraft", + "mergeStateStatus", + "headRefOid", + "baseRefName", + "statusCheckRollup", + } if not isinstance(review, Mapping) or set(review) != required: raise DeliveryError("GitHub review state schema is invalid") if review["headRefOid"] != workspace["head"]: raise DeliveryError("GitHub review head does not match workspace HEAD") - return {"workspace_id": workspace_id, "head": workspace["head"], "review": dict(review)} + return { + "workspace_id": workspace_id, + "head": workspace["head"], + "review": dict(review), + } - def land(self, workspace_id: str, job_id: str, packet_job_id: str | None = None) -> dict[str, Any]: + def land( + self, workspace_id: str, job_id: str, packet_job_id: str | None = None + ) -> dict[str, Any]: self._verified_workspace(workspace_id, job_id, packet_job_id) status = self.review_status(workspace_id) review = status["review"] @@ -96,8 +158,13 @@ def land(self, workspace_id: str, job_id: str, packet_job_id: str | None = None) or not self._checks_pass(review["statusCheckRollup"]) ): raise DeliveryError("review is not in a landable GitHub state") - _workspace, _project, receipt = self._verified_workspace(workspace_id, job_id, packet_job_id) - self._command(["gh", "pr", "merge", str(review["number"]), "--squash"], cwd=self.workspaces.get(workspace_id)["path"]) + _workspace, _project, receipt = self._verified_workspace( + workspace_id, job_id, packet_job_id + ) + self._command( + ["gh", "pr", "merge", str(review["number"]), "--squash"], + cwd=self.workspaces.get(workspace_id)["path"], + ) merged = self.review_status(workspace_id) if merged["review"]["state"] != "MERGED": raise DeliveryError("GitHub did not report the review merged") @@ -116,7 +183,9 @@ def _verified_workspace( ) -> tuple[dict[str, Any], Any, dict[str, Any]]: workspace = self.workspaces.get(workspace_id) if workspace["state"] != "available" or not workspace["identity_matches"]: - raise DeliveryError("publication requires an available clean identity-matching workspace") + raise DeliveryError( + "publication requires an available clean identity-matching workspace" + ) project = self.projects.get(workspace["project_id"]) assert project.workspace is not None job = self.jobs.get(job_id) @@ -128,17 +197,32 @@ def _verified_workspace( or checkout.get("checkout_id") != workspace["checkout_id"] or record.spec.operation not in project.workspace.verification_operations ): - raise DeliveryError("workspace lacks successful declared verification at its exact HEAD") + raise DeliveryError( + "workspace lacks successful declared verification at its exact HEAD" + ) try: self.jobs.result(job_id) - binding = self._binding(record, checkout, workspace) if packet_job_id is not None else None - packet = self._packet(packet_job_id, workspace, binding) if packet_job_id is not None else None - start_head = packet["start_head"] if packet is not None else checkout["head"] + binding = ( + self._binding(record, checkout, workspace) + if packet_job_id is not None + else None + ) + packet = ( + self._packet(packet_job_id, workspace, binding) + if packet_job_id is not None + else None + ) + start_head = ( + packet["start_head"] if packet is not None else checkout["head"] + ) scope = packet["scope"] if packet is not None else () snapshot = self.workspaces.delivery_snapshot(workspace_id, start_head) publication_snapshot = ( self.workspaces.delivery_snapshot( - workspace_id, project.workspace.default_base, scope=scope, merge_base=True + workspace_id, + project.workspace.default_base, + scope=scope, + merge_base=True, ) if packet is not None else snapshot @@ -146,24 +230,41 @@ def _verified_workspace( except DeliveryError: raise except (ValueError, WorkspaceError) as error: - raise DeliveryError("workspace lacks an authoritative exact-head completion receipt") from error - if snapshot["head"] != checkout["head"] or not snapshot["descendant"] or snapshot["dirty"]: - raise DeliveryError("workspace lacks successful declared verification at its exact HEAD") + raise DeliveryError( + "workspace lacks an authoritative exact-head completion receipt" + ) from error + if ( + snapshot["head"] != checkout["head"] + or not snapshot["descendant"] + or snapshot["dirty"] + ): + raise DeliveryError( + "workspace lacks successful declared verification at its exact HEAD" + ) if packet is not None and ( - packet["final_head"] != snapshot["head"] or not publication_snapshot["in_scope"] + packet["final_head"] != snapshot["head"] + or not publication_snapshot["in_scope"] ): - raise DeliveryError("packet delivery is outside its Beads-owned write scope") + raise DeliveryError( + "packet delivery is outside its Beads-owned write scope" + ) if packet is not None: self._validate_delivery_result(packet["delivery"], snapshot) - return workspace, project, { - "ref": packet["artifact_ref"] if packet is not None else f"sinnix://jobs/{job_id}", - "job_id": job_id, - "packet_job_id": packet_job_id, - "bead_ref": packet["bead_ref"] if packet is not None else None, - "workspace_id": workspace_id, - "head": snapshot["head"], - "verification_operation": record.spec.operation, - } + return ( + workspace, + project, + { + "ref": packet["artifact_ref"] + if packet is not None + else f"sinnix://jobs/{job_id}", + "job_id": job_id, + "packet_job_id": packet_job_id, + "bead_ref": packet["bead_ref"] if packet is not None else None, + "workspace_id": workspace_id, + "head": snapshot["head"], + "verification_operation": record.spec.operation, + }, + ) def _binding( self, record: Any, checkout: Mapping[str, Any], workspace: Mapping[str, Any] @@ -185,7 +286,9 @@ def _binding( ) or checkout.get("checkout_id") != workspace.get("checkout_id") ): - raise DeliveryError("declared verification lacks an authoritative Beads packet binding") + raise DeliveryError( + "declared verification lacks an authoritative Beads packet binding" + ) return binding def _packet( @@ -213,7 +316,8 @@ def _packet( raise DeliveryError("packet job result is malformed") from error if ( not isinstance(envelope, Mapping) - or set(envelope) != {"schema_version", "job_id", "start_head", "final_head", "delivery"} + or set(envelope) + != {"schema_version", "job_id", "start_head", "final_head", "delivery"} or envelope.get("schema_version") != 1 or envelope.get("job_id") != job_id or envelope.get("start_head") != checkout.get("head") @@ -229,18 +333,42 @@ def _packet( "delivery": envelope["delivery"], "scope": tuple(binding["write_scope"]), "bead_ref": binding.get("bead_ref"), - "artifact_ref": artifact.get("ref") if isinstance(artifact, Mapping) else f"sinnix://jobs/{job_id}", + "artifact_ref": artifact.get("ref") + if isinstance(artifact, Mapping) + else f"sinnix://jobs/{job_id}", } @staticmethod def _validate_delivery_result(delivery: Any, snapshot: Mapping[str, Any]) -> None: - if not isinstance(delivery, Mapping) or set(delivery) != {"anti_vacuity", "unresolved_work", "delegation", "deletion_evidence", "evidence_only"}: + if not isinstance(delivery, Mapping) or set(delivery) != { + "anti_vacuity", + "unresolved_work", + "delegation", + "deletion_evidence", + "evidence_only", + }: raise DeliveryError("project delivery result is malformed") - unresolved, delegation, deletions = delivery["unresolved_work"], delivery["delegation"], delivery["deletion_evidence"] - if delivery["anti_vacuity"] is not True or not isinstance(unresolved, list) or unresolved or not isinstance(deletions, list) or not isinstance(delivery["evidence_only"], bool) or not isinstance(delegation, Mapping) or set(delegation) != {"visibility", "pending"}: + unresolved, delegation, deletions = ( + delivery["unresolved_work"], + delivery["delegation"], + delivery["deletion_evidence"], + ) + if ( + delivery["anti_vacuity"] is not True + or not isinstance(unresolved, list) + or unresolved + or not isinstance(deletions, list) + or not isinstance(delivery["evidence_only"], bool) + or not isinstance(delegation, Mapping) + or set(delegation) != {"visibility", "pending"} + ): raise DeliveryError("project delivery result is incomplete") visibility, pending = delegation["visibility"], delegation["pending"] - if visibility not in {"supported", "unsupported"} or (visibility == "supported" and pending is not False) or (visibility == "unsupported" and pending is not None): + if ( + visibility not in {"supported", "unsupported"} + or (visibility == "supported" and pending is not False) + or (visibility == "unsupported" and pending is not None) + ): raise DeliveryError("project delivery delegation visibility is invalid") changes = snapshot.get("changes") if not isinstance(changes, list): @@ -257,7 +385,9 @@ def _validate_delivery_result(delivery: Any, snapshot: Mapping[str, Any]) -> Non or len(deletions) != len(set(deletions)) or deleted != set(deletions) ): - raise DeliveryError("project delivery result does not exactly match deletion evidence") + raise DeliveryError( + "project delivery result does not exactly match deletion evidence" + ) if not changes and not delivery["evidence_only"]: raise DeliveryError("no-change delivery lacks the evidence-only exception") if changes and delivery["evidence_only"]: @@ -267,27 +397,49 @@ def _validate_delivery_result(delivery: Any, snapshot: Mapping[str, Any]) -> Non def _base_branch(default_base: str) -> str: remote, separator, branch = default_base.partition("/") if remote != "origin" or not separator or not branch: - raise DeliveryError("publication requires workspace.default_base in origin/ form") + raise DeliveryError( + "publication requires workspace.default_base in origin/ form" + ) return branch - def _command(self, argv: Sequence[str], *, cwd: str | None = None) -> subprocess.CompletedProcess[str]: + def _command( + self, argv: Sequence[str], *, cwd: str | None = None + ) -> subprocess.CompletedProcess[str]: try: - result = self.run(argv, cwd=cwd, capture_output=True, text=True, timeout=60, check=False) + result = self.run( + argv, cwd=cwd, capture_output=True, text=True, timeout=60, check=False + ) except (OSError, subprocess.SubprocessError) as error: raise DeliveryError("GitHub delivery command failed") from error if result.returncode != 0: - raise DeliveryError(result.stderr.strip() or "GitHub delivery command failed") + raise DeliveryError( + result.stderr.strip() or "GitHub delivery command failed" + ) return result def _delete_remote_branch(self, path: str, branch: str) -> None: probe = self.run( - ["git", "-C", path, "ls-remote", "--exit-code", "--heads", "origin", f"refs/heads/{branch}"], - capture_output=True, text=True, timeout=60, check=False, + [ + "git", + "-C", + path, + "ls-remote", + "--exit-code", + "--heads", + "origin", + f"refs/heads/{branch}", + ], + capture_output=True, + text=True, + timeout=60, + check=False, ) if probe.returncode == 2: return if probe.returncode != 0: - raise DeliveryError(probe.stderr.strip() or "could not inspect remote branch") + raise DeliveryError( + probe.stderr.strip() or "could not inspect remote branch" + ) self._command(["git", "-C", path, "push", "origin", "--delete", branch]) @staticmethod @@ -301,8 +453,12 @@ def _checks_pass(checks: Any) -> bool: if check.get("state") != "SUCCESS": return False elif check.get("__typename") == "CheckRun": - if check.get("status") != "COMPLETED" or check.get("conclusion") not in { - "SUCCESS", "NEUTRAL", "SKIPPED", + if check.get("status") != "COMPLETED" or check.get( + "conclusion" + ) not in { + "SUCCESS", + "NEUTRAL", + "SKIPPED", }: return False else: diff --git a/pkgs/sinnixd/sinnixd/jobs.py b/pkgs/sinnixd/sinnixd/jobs.py index d9ab5f59..ac932a06 100644 --- a/pkgs/sinnixd/sinnixd/jobs.py +++ b/pkgs/sinnixd/sinnixd/jobs.py @@ -6,15 +6,15 @@ import hashlib import json import os -import shutil import selectors +import shutil import socket import stat import subprocess import sys import time -from contextlib import contextmanager from collections.abc import Callable, Mapping, Sequence +from contextlib import contextmanager from dataclasses import dataclass, field, replace from datetime import UTC, datetime from pathlib import Path @@ -22,7 +22,11 @@ from typing import Any, Iterator, Protocol from uuid import UUID, uuid4 -from .limits import DEFAULT_TIMEOUT_SECONDS, maximum_timeout_seconds, valid_timeout_seconds +from .limits import ( + DEFAULT_TIMEOUT_SECONDS, + maximum_timeout_seconds, + valid_timeout_seconds, +) from .projects import ( OperationService, ProjectAdapter, @@ -49,14 +53,29 @@ MAX_ADMISSION_ESTIMATES = 128 MIB = 1024 * 1024 POOL_POLICIES = { - "interactive": {"workers": 4, "memory_budget": 3 * 1024 * MIB, "default_estimate": 256 * MIB}, - "normal": {"workers": 3, "memory_budget": 8 * 1024 * MIB, "default_estimate": 1024 * MIB}, - "bulk": {"workers": 1, "memory_budget": 18 * 1024 * MIB, "default_estimate": 8 * 1024 * MIB}, + "interactive": { + "workers": 4, + "memory_budget": 3 * 1024 * MIB, + "default_estimate": 256 * MIB, + }, + "normal": { + "workers": 3, + "memory_budget": 8 * 1024 * MIB, + "default_estimate": 1024 * MIB, + }, + "bulk": { + "workers": 1, + "memory_budget": 18 * 1024 * MIB, + "default_estimate": 8 * 1024 * MIB, + }, } def default_state_dir() -> Path: - return Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local" / "state")) / "sinnixd" + return ( + Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local" / "state")) + / "sinnixd" + ) class SystemdJobError(RuntimeError): @@ -156,7 +175,9 @@ def _open_private_parent(path: Path) -> int: return parent_descriptor -def _private_regular_artifact(descriptor: int, path: Path, *, require_private_mode: bool = True) -> None: +def _private_regular_artifact( + descriptor: int, path: Path, *, require_private_mode: bool = True +) -> None: artifact = os.fstat(descriptor) if ( artifact.st_uid != os.getuid() @@ -191,7 +212,9 @@ def _open_preallocated_private_artifact(path: Path) -> Any: """Open the store-reserved log artifact without accepting a replacement link.""" parent_descriptor = _open_private_parent(path) try: - descriptor = os.open(path.name, os.O_WRONLY | os.O_NOFOLLOW, dir_fd=parent_descriptor) + descriptor = os.open( + path.name, os.O_WRONLY | os.O_NOFOLLOW, dir_fd=parent_descriptor + ) finally: os.close(parent_descriptor) try: @@ -203,7 +226,9 @@ def _open_preallocated_private_artifact(path: Path) -> Any: return os.fdopen(descriptor, "wb") -def _read_private_artifact(path: Path, max_bytes: int, *, offset: int = 0) -> bytes | None: +def _read_private_artifact( + path: Path, max_bytes: int, *, offset: int = 0 +) -> bytes | None: """Read one bounded, private regular artifact without following a replacement link.""" try: parent_descriptor = _open_private_parent(path) @@ -211,7 +236,9 @@ def _read_private_artifact(path: Path, max_bytes: int, *, offset: int = 0) -> by return None try: try: - descriptor = os.open(path.name, os.O_RDONLY | os.O_NOFOLLOW, dir_fd=parent_descriptor) + descriptor = os.open( + path.name, os.O_RDONLY | os.O_NOFOLLOW, dir_fd=parent_descriptor + ) except OSError: return None try: @@ -376,7 +403,9 @@ def stop(self, unit: str) -> None: self._run(["systemctl", "--user", "stop", unit]) @staticmethod - def _run(args: Sequence[str], *, timeout_seconds: float = SYSTEMD_COMMAND_TIMEOUT_SECONDS) -> str: + def _run( + args: Sequence[str], *, timeout_seconds: float = SYSTEMD_COMMAND_TIMEOUT_SECONDS + ) -> str: timeout_seconds = min(timeout_seconds, SYSTEMD_COMMAND_TIMEOUT_SECONDS) if timeout_seconds <= 0: raise ValueError("systemd command timeout must be positive") @@ -389,11 +418,15 @@ def _run(args: Sequence[str], *, timeout_seconds: float = SYSTEMD_COMMAND_TIMEOU timeout=timeout_seconds, ) except FileNotFoundError as error: - raise SystemdJobError(f"systemd command is unavailable: {args[0]}") from error + raise SystemdJobError( + f"systemd command is unavailable: {args[0]}" + ) from error except subprocess.TimeoutExpired as error: raise SystemdJobTimeout("systemd command timed out") from error except OSError as error: - raise SystemdJobError(f"systemd command failed: {args[0]}: {error}") from error + raise SystemdJobError( + f"systemd command failed: {args[0]}: {error}" + ) from error except subprocess.CalledProcessError as error: detail = error.stderr.strip() or error.stdout.strip() or str(error) raise SystemdJobError(detail) from error @@ -461,7 +494,11 @@ def __post_init__(self) -> None: UUID(self.lease_id) except (ValueError, AttributeError) as error: raise ValueError("service lease ID is invalid") from error - if self.host != "127.0.0.1" or self.readiness not in {"none", "project-command"} or self.lifetime != "job": + if ( + self.host != "127.0.0.1" + or self.readiness not in {"none", "project-command"} + or self.lifetime != "job" + ): raise ValueError("service lease metadata is invalid") if not self.ports or len(self.ports) > 8: raise ValueError("service lease ports are invalid") @@ -503,10 +540,16 @@ def from_dict(cls, value: Mapping[str, Any]) -> ServiceLease: raise JobRecordError("service lease ports are invalid") ports: list[ServiceLeasePort] = [] for port in raw_ports: - if not isinstance(port, Mapping) or set(port) != {"name", "environment", "port"}: + if not isinstance(port, Mapping) or set(port) != { + "name", + "environment", + "port", + }: raise JobRecordError("service lease ports are invalid") try: - ports.append(ServiceLeasePort(port["name"], port["environment"], port["port"])) + ports.append( + ServiceLeasePort(port["name"], port["environment"], port["port"]) + ) except (KeyError, TypeError) as error: raise JobRecordError("service lease ports are invalid") from error try: @@ -550,18 +593,27 @@ class GenericJobSpec: lease: ServiceLease | None = None def __post_init__(self) -> None: - if self.kind not in {"declared-operation", "foreground-command", "operator-shell", "attested-agent"}: + if self.kind not in { + "declared-operation", + "foreground-command", + "operator-shell", + "attested-agent", + }: raise ValueError("job kind is invalid") if not self.command and not self.command_digest: raise ValueError("job needs a launch command or command digest") - if self.command and any(not isinstance(value, str) or not value for value in self.command): + if self.command and any( + not isinstance(value, str) or not value for value in self.command + ): raise ValueError("job command must be non-empty strings") if self.command_digest is not None and ( - len(self.command_digest) != 64 or any(value not in "0123456789abcdef" for value in self.command_digest) + len(self.command_digest) != 64 + or any(value not in "0123456789abcdef" for value in self.command_digest) ): raise ValueError("job command digest is invalid") if self.parameter_digest is not None and ( - len(self.parameter_digest) != 64 or any(value not in "0123456789abcdef" for value in self.parameter_digest) + len(self.parameter_digest) != 64 + or any(value not in "0123456789abcdef" for value in self.parameter_digest) ): raise ValueError("job parameter digest is invalid") if self.kind == "declared-operation" and self.parameter_digest is None: @@ -572,7 +624,9 @@ def __post_init__(self) -> None: raise ValueError("job working_directory must be non-empty") maximum_timeout = maximum_timeout_seconds(self.kind) if not valid_timeout_seconds(self.timeout_seconds, kind=self.kind): - raise ValueError(f"job timeout_seconds must be between 1 and {maximum_timeout}") + raise ValueError( + f"job timeout_seconds must be between 1 and {maximum_timeout}" + ) if any( not isinstance(key, str) or not key or not isinstance(value, str) for key, value in self.environment.items() @@ -580,7 +634,10 @@ def __post_init__(self) -> None: raise ValueError("job environment must be string key/value pairs") if any(not isinstance(key, str) or not key for key in self.environment_keys): raise ValueError("job environment metadata must be non-empty strings") - if self.principal is not None and self.principal not in {"operator", "agent-control"}: + if self.principal is not None and self.principal not in { + "operator", + "agent-control", + }: raise ValueError("job principal is invalid") if self.kind == "operator-shell" and self.principal != "operator": raise ValueError("operator shell jobs require the operator principal") @@ -594,11 +651,24 @@ def __post_init__(self) -> None: if self.kind in {"operator-shell", "attested-agent"} and not self.checkout: raise ValueError("typed jobs require a registered checkout") if self.checkout is not None and ( - set(self.checkout) != {"project_id", "project_path", "checkout_id", "path", "git_common_dir", "head"} - or any(not isinstance(value, str) or not value for value in self.checkout.values()) + set(self.checkout) + != { + "project_id", + "project_path", + "checkout_id", + "path", + "git_common_dir", + "head", + } + or any( + not isinstance(value, str) or not value + for value in self.checkout.values() + ) ): raise ValueError("job checkout identity is invalid") - if not isinstance(self.contract, Mapping) or any(not isinstance(key, str) or not key for key in self.contract): + if not isinstance(self.contract, Mapping) or any( + not isinstance(key, str) or not key for key in self.contract + ): raise ValueError("job contract is invalid") if self.result_kind not in {"exit-status", "last-message", "json", "pytest"}: raise ValueError("job result kind is invalid") @@ -608,21 +678,31 @@ def __post_init__(self) -> None: raise ValueError("job exclusive keys are invalid") if len(set(self.exclusive_keys)) != len(self.exclusive_keys): raise ValueError("job exclusive keys must be unique") - if any(not isinstance(value, str) or not value for value in self.dependency_job_ids): + if any( + not isinstance(value, str) or not value for value in self.dependency_job_ids + ): raise ValueError("job dependency IDs are invalid") for name, key in (("coalesce", self.coalesce_key), ("cache", self.cache_key)): - if key is not None and (len(key) != 64 or any(value not in "0123456789abcdef" for value in key)): + if key is not None and ( + len(key) != 64 or any(value not in "0123456789abcdef" for value in key) + ): raise ValueError(f"job {name} key is invalid") - if self.estimate_key is not None and (not isinstance(self.estimate_key, str) or not self.estimate_key): + if self.estimate_key is not None and ( + not isinstance(self.estimate_key, str) or not self.estimate_key + ): raise ValueError("job estimate key is invalid") if self.estimate_memory_bytes is not None and ( - not isinstance(self.estimate_memory_bytes, int) or isinstance(self.estimate_memory_bytes, bool) or self.estimate_memory_bytes < 1 + not isinstance(self.estimate_memory_bytes, int) + or isinstance(self.estimate_memory_bytes, bool) + or self.estimate_memory_bytes < 1 ): raise ValueError("job memory estimate is invalid") if self.scratch not in {"none", "tmpfs", "nvme"}: raise ValueError("job scratch is invalid") if self.lease is not None and ( - self.kind != "declared-operation" or not self.project_id or not self.operation + self.kind != "declared-operation" + or not self.project_id + or not self.operation ): raise ValueError("only declared operations may own service leases") if self.kind == "operator-shell" and self.result_kind != "exit-status": @@ -634,7 +714,9 @@ def to_dict(self) -> dict[str, Any]: result: dict[str, Any] = { "kind": self.kind, "working_directory": self.working_directory, - "environment_keys": sorted(set(self.environment) | set(self.environment_keys)), + "environment_keys": sorted( + set(self.environment) | set(self.environment_keys) + ), "timeout_seconds": self.timeout_seconds, "project_id": self.project_id, "operation": self.operation, @@ -657,7 +739,9 @@ def to_dict(self) -> dict[str, Any]: if self.kind != "declared-operation": result["command"] = { "digest": self.command_digest or _command_digest(self.command), - "display": "synthetic foreground command" if self.kind == "foreground-command" else f"{self.kind} contract runner", + "display": "synthetic foreground command" + if self.kind == "foreground-command" + else f"{self.kind} contract runner", } else: result["command"] = { @@ -667,11 +751,15 @@ def to_dict(self) -> dict[str, Any]: return result @classmethod - def from_dict(cls, value: Mapping[str, Any], *, require_parameter_digest: bool = False) -> GenericJobSpec: + def from_dict( + cls, value: Mapping[str, Any], *, require_parameter_digest: bool = False + ) -> GenericJobSpec: command = value.get("command") environment_keys = value.get("environment_keys") - if not isinstance(command, Mapping) or not isinstance(environment_keys, list) or any( - not isinstance(key, str) or not key for key in environment_keys + if ( + not isinstance(command, Mapping) + or not isinstance(environment_keys, list) + or any(not isinstance(key, str) or not key for key in environment_keys) ): raise JobRecordError("job spec has invalid command or environment metadata") kind = value.get("kind") @@ -685,7 +773,9 @@ def from_dict(cls, value: Mapping[str, Any], *, require_parameter_digest: bool = if kind == "declared-operation": if raw_parameters is None and not require_parameter_digest: parameter_digest = _parameter_digest({}) - elif not isinstance(raw_parameters, Mapping) or set(raw_parameters) != {"digest"}: + elif not isinstance(raw_parameters, Mapping) or set(raw_parameters) != { + "digest" + }: raise JobRecordError("declared job spec has invalid parameter metadata") else: parameter_digest = raw_parameters.get("digest") @@ -721,7 +811,9 @@ def from_dict(cls, value: Mapping[str, Any], *, require_parameter_digest: bool = estimate_key=admission.get("estimate_key"), estimate_memory_bytes=admission.get("estimate_memory_bytes"), scratch=admission.get("scratch", "none"), - lease=ServiceLease.from_dict(raw_lease) if raw_lease is not None else None, + lease=ServiceLease.from_dict(raw_lease) + if raw_lease is not None + else None, ) except ValueError as error: raise JobRecordError(str(error)) from error @@ -750,8 +842,12 @@ def to_dict(self) -> dict[str, Any]: "spec": self.spec.to_dict(), "artifacts": { "log": str(self.log_path), - "result": str(self.result_path) if self.result_path is not None else None, - "scratch": str(self.scratch_path) if self.scratch_path is not None else None, + "result": str(self.result_path) + if self.result_path is not None + else None, + "scratch": str(self.scratch_path) + if self.scratch_path is not None + else None, }, "created_at": self.created_at, "cancel_requested_at": self.cancel_requested_at, @@ -767,11 +863,15 @@ def from_dict(cls, value: Mapping[str, Any], root: Path) -> GenericJobRecord: unit = value.get("unit") artifacts = value.get("artifacts") schema_version = value.get("schema_version") - if schema_version not in {2, 3, 4, JOB_SCHEMA_VERSION} or not isinstance(job_id, str): + if schema_version not in {2, 3, 4, JOB_SCHEMA_VERSION} or not isinstance( + job_id, str + ): raise JobRecordError("job record schema or ID is invalid") if unit != job_unit_name(job_id): raise JobRecordError("job record unit does not match its ID") - if not isinstance(artifacts, Mapping) or not isinstance(artifacts.get("log"), str): + if not isinstance(artifacts, Mapping) or not isinstance( + artifacts.get("log"), str + ): raise JobRecordError("job record log artifact is invalid") log_path = Path(artifacts["log"]).resolve() logs_root = (root / "logs").resolve() @@ -805,13 +905,20 @@ def from_dict(cls, value: Mapping[str, Any], root: Path) -> GenericJobRecord: not isinstance(created_at, str) or (cancelled is not None and not isinstance(cancelled, str)) or (invocation is not None and not isinstance(invocation, str)) - or (stop_acknowledged is not None and not isinstance(stop_acknowledged, str)) + or ( + stop_acknowledged is not None and not isinstance(stop_acknowledged, str) + ) or (stop_invocation is not None and not isinstance(stop_invocation, str)) or (stop_acknowledged is None) != (stop_invocation is None) - or (stop_acknowledged is not None and (cancelled is None or stop_invocation != invocation)) + or ( + stop_acknowledged is not None + and (cancelled is None or stop_invocation != invocation) + ) ): raise JobRecordError("job record timestamps are invalid") - parsed_spec = GenericJobSpec.from_dict(spec, require_parameter_digest=schema_version >= 4) + parsed_spec = GenericJobSpec.from_dict( + spec, require_parameter_digest=schema_version >= 4 + ) if parsed_spec.lease is not None and parsed_spec.lease.lease_id != job_id: raise JobRecordError("service lease ID does not match its job") return cls( @@ -924,7 +1031,9 @@ def locked_service_leases(self) -> Iterator[None]: fcntl.flock(descriptor, fcntl.LOCK_UN) os.close(descriptor) - def allocate_service_lease(self, job_id: str, service: OperationService) -> ServiceLease: + def allocate_service_lease( + self, job_id: str, service: OperationService + ) -> ServiceLease: _ = job_unit_name(job_id) with self.locked(job_id): with self.locked_service_leases(): @@ -950,12 +1059,18 @@ def create_declared_service_record( lease = self._allocate_service_lease_locked(job_id, service) record = self.create(build_spec(lease), job_id) try: - self.write_declared_launch(record.job_id, record.spec.command, record.spec.environment) + self.write_declared_launch( + record.job_id, record.spec.command, record.spec.environment + ) self._save_service_lease(lease) except BaseException: failed = replace( record, - state={"phase": "launch-failed", "terminal": True, "observed_at": _timestamp()}, + state={ + "phase": "launch-failed", + "terminal": True, + "observed_at": _timestamp(), + }, ) self.save(failed) self.cleanup_scratch(failed) @@ -1024,13 +1139,17 @@ def _reconcile_service_leases_locked( for lease in protected.values(): ports = {port.port for port in lease.ports} if occupied.intersection(ports): - raise JobRecordError("protected service lease ports collide during recovery") + raise JobRecordError( + "protected service lease ports collide during recovery" + ) occupied.update(ports) for lease_id, lease in sorted(active.items()): assert lease is not None ports = {port.port for port in lease.ports} if occupied.intersection(ports): - raise JobRecordError("active service lease ports collide during recovery") + raise JobRecordError( + "active service lease ports collide during recovery" + ) occupied.update(ports) self._service_lease_released_path(lease_id).unlink(missing_ok=True) if existing.get(lease_id) != lease: @@ -1056,15 +1175,20 @@ def _terminal_service_lease_releasable(record: GenericJobRecord) -> bool: cancellation = record.state.get("cancellation") return ( phase in {"missing", "launch-failed"} - or (phase == "succeeded" and record.state.get("result_evidence") == "completed") + or ( + phase == "succeeded" + and record.state.get("result_evidence") == "completed" + ) or ( phase == "cancelled" - and record.cancel_stop_acknowledged_invocation_id == record.cancel_requested_invocation_id + and record.cancel_stop_acknowledged_invocation_id + == record.cancel_requested_invocation_id and record.cancel_stop_acknowledged_invocation_id is not None ) or ( phase == "outcome-unknown" - and record.state.get("outcome_evidence") == "unit-collected-after-cancellation-grace" + and record.state.get("outcome_evidence") + == "unit-collected-after-cancellation-grace" and record.cancel_requested_at is not None and isinstance(cancellation, Mapping) and cancellation.get("requested_at") == record.cancel_requested_at @@ -1091,7 +1215,11 @@ def _terminal_service_lease_releasable(record: GenericJobRecord) -> bool: and result == "signal" and record.cancel_stop_acknowledged_invocation_id == invocation ) - return phase == "failed" and active in {"inactive", "failed"} and status not in {None, "0"} + return ( + phase == "failed" + and active in {"inactive", "failed"} + and status not in {None, "0"} + ) def release_terminal_service_lease(self, record: GenericJobRecord) -> None: if not self._terminal_service_lease_releasable(record): @@ -1100,7 +1228,9 @@ def release_terminal_service_lease(self, record: GenericJobRecord) -> None: with self.locked_service_leases(): self._mark_service_lease_released(record.spec.lease.lease_id) - def _allocate_service_lease_locked(self, job_id: str, service: OperationService) -> ServiceLease: + def _allocate_service_lease_locked( + self, job_id: str, service: OperationService + ) -> ServiceLease: occupied = { port.port for lease in self._service_leases().values() @@ -1120,10 +1250,14 @@ def _allocate_service_lease_locked(self, job_id: str, service: OperationService) None, ) if port is None: - raise JobRecordError(f"no loopback port is available for service slot {slot.name}") + raise JobRecordError( + f"no loopback port is available for service slot {slot.name}" + ) occupied.add(port) allocations.append(ServiceLeasePort(slot.name, slot.environment, port)) - return ServiceLease(job_id, service.readiness, service.lifetime, tuple(allocations)) + return ServiceLease( + job_id, service.readiness, service.lifetime, tuple(allocations) + ) def service_lease_ports_available(self, lease: ServiceLease | None) -> bool: """Confirm the descriptor-owned ports were not claimed before launch. @@ -1158,7 +1292,9 @@ def _service_leases(self) -> dict[str, ServiceLease]: def _save_service_lease(self, lease: ServiceLease) -> None: path = self._service_lease_path(lease.lease_id) temporary = path.with_suffix(".json.tmp") - descriptor = os.open(temporary, os.O_CREAT | os.O_TRUNC | os.O_WRONLY | os.O_NOFOLLOW, 0o600) + descriptor = os.open( + temporary, os.O_CREAT | os.O_TRUNC | os.O_WRONLY | os.O_NOFOLLOW, 0o600 + ) try: with os.fdopen(descriptor, "w") as handle: json.dump(lease.to_dict(), handle, sort_keys=True) @@ -1179,7 +1315,10 @@ def _service_lease_released_path(self, lease_id: str) -> Path: return self.leases_root / f"{lease_id}.released" def _service_lease_released(self, lease_id: str) -> bool: - return _read_private_artifact(self._service_lease_released_path(lease_id), 1) == b"" + return ( + _read_private_artifact(self._service_lease_released_path(lease_id), 1) + == b"" + ) def _mark_service_lease_released(self, lease_id: str) -> None: self._service_lease_path(lease_id).unlink(missing_ok=True) @@ -1190,12 +1329,16 @@ def _mark_service_lease_released(self, lease_id: str) -> None: self._set_service_lease_record(lease_id, active=False) _fsync_directory(self.leases_root) - def create(self, spec: GenericJobSpec, job_id: str | None = None) -> GenericJobRecord: + def create( + self, spec: GenericJobSpec, job_id: str | None = None + ) -> GenericJobRecord: _ensure_durable_directory(self.records_root) _ensure_durable_directory(self.logs_root) if spec.result_kind in {"last-message", "json", "pytest"}: _ensure_durable_directory(self.results_root) - candidates = (job_id,) if job_id is not None else tuple(str(uuid4()) for _ in range(8)) + candidates = ( + (job_id,) if job_id is not None else tuple(str(uuid4()) for _ in range(8)) + ) for candidate in candidates: _ = job_unit_name(candidate) path = self._record_path(candidate) @@ -1222,7 +1365,11 @@ def create(self, spec: GenericJobSpec, job_id: str | None = None) -> GenericJobR result_path=result_path.resolve() if result_path is not None else None, scratch_path=scratch_path, created_at=_timestamp(), - state={"phase": "launching", "terminal": False, "observed_at": _timestamp()}, + state={ + "phase": "launching", + "terminal": False, + "observed_at": _timestamp(), + }, ) self.save(record) return record @@ -1252,7 +1399,11 @@ def scratch_path_for(self, kind: str, job_id: str) -> Path | None: def cleanup_scratch(self, record: GenericJobRecord) -> None: if record.scratch_path is None: return - root = self.tmpfs_scratch_root.resolve() if record.spec.scratch == "tmpfs" else self.nvme_scratch_root.resolve() + root = ( + self.tmpfs_scratch_root.resolve() + if record.spec.scratch == "tmpfs" + else self.nvme_scratch_root.resolve() + ) path = record.scratch_path.resolve() if path.parent != root or path.name != record.job_id: raise JobRecordError("job scratch artifact escapes owned root") @@ -1261,7 +1412,11 @@ def cleanup_scratch(self, record: GenericJobRecord) -> None: def prepare_scratch(self, record: GenericJobRecord) -> Path | None: if record.scratch_path is None: return None - root = self.tmpfs_scratch_root if record.spec.scratch == "tmpfs" else self.nvme_scratch_root + root = ( + self.tmpfs_scratch_root + if record.spec.scratch == "tmpfs" + else self.nvme_scratch_root + ) _ensure_durable_directory(root) root = root.resolve() path = record.scratch_path @@ -1271,14 +1426,22 @@ def prepare_scratch(self, record: GenericJobRecord) -> Path | None: path.mkdir(mode=0o700) except FileExistsError: artifact = path.lstat() - if artifact.st_uid != os.getuid() or artifact.st_mode & 0o077 or not stat.S_ISDIR(artifact.st_mode): - raise JobRecordError("job scratch artifact is not a private directory") + if ( + artifact.st_uid != os.getuid() + or artifact.st_mode & 0o077 + or not stat.S_ISDIR(artifact.st_mode) + ): + raise JobRecordError( + "job scratch artifact is not a private directory" + ) from None else: _fsync_directory(root) return path def cleanup_inactive_scratch(self, records: Sequence[GenericJobRecord]) -> None: - active = {record.job_id for record in records if not record.state.get("terminal")} + active = { + record.job_id for record in records if not record.state.get("terminal") + } for root in (self.tmpfs_scratch_root, self.nvme_scratch_root): if not root.exists(): continue @@ -1302,7 +1465,11 @@ def service_ready(self, job_id: str) -> bool: _ = job_unit_name(job_id) path = self.readiness_root / job_id try: - return not path.is_symlink() and path.is_file() and path.read_text() == f"{job_id}\n" + return ( + not path.is_symlink() + and path.is_file() + and path.read_text() == f"{job_id}\n" + ) except OSError: return False @@ -1316,7 +1483,9 @@ def cleanup_service_readiness(self, job_id: str) -> None: def cleanup_inactive_readiness(self, records: Sequence[GenericJobRecord]) -> None: if not self.readiness_root.exists(): return - active = {record.job_id for record in records if not record.state.get("terminal")} + active = { + record.job_id for record in records if not record.state.get("terminal") + } for path in sorted(self.readiness_root.iterdir()): try: _ = job_unit_name(path.name) @@ -1335,19 +1504,30 @@ def _cleanup_scratch_path(root: Path, path: Path) -> None: path.chmod(path.stat().st_mode | stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR) for directory, names, _files in os.walk(path, followlinks=False): current = Path(directory) - current.chmod(current.stat().st_mode | stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR) + current.chmod( + current.stat().st_mode | stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR + ) for name in names: child = current / name if not child.is_symlink(): - child.chmod(child.stat().st_mode | stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR) + child.chmod( + child.stat().st_mode + | stat.S_IRUSR + | stat.S_IWUSR + | stat.S_IXUSR + ) shutil.rmtree(path) _fsync_directory(root) - def write_declared_launch(self, job_id: str, command: Sequence[str], environment: Mapping[str, str]) -> None: + def write_declared_launch( + self, job_id: str, command: Sequence[str], environment: Mapping[str, str] + ) -> None: _ = job_unit_name(job_id) _ensure_durable_directory(self.inputs_root) path = self.inputs_root / f"{job_id}.launch" - payload = json.dumps({"command": list(command), "environment": dict(environment)}, sort_keys=True).encode() + payload = json.dumps( + {"command": list(command), "environment": dict(environment)}, sort_keys=True + ).encode() with _open_private_artifact(path) as handle: handle.write(payload) handle.flush() @@ -1356,7 +1536,9 @@ def write_declared_launch(self, job_id: str, command: Sequence[str], environment def declared_launch(self, job_id: str) -> tuple[tuple[str, ...], dict[str, str]]: _ = job_unit_name(job_id) - content = _read_private_artifact(self.inputs_root / f"{job_id}.launch", 128 * 1024) + content = _read_private_artifact( + self.inputs_root / f"{job_id}.launch", 128 * 1024 + ) try: value = json.loads(content.decode()) if content is not None else None except (UnicodeDecodeError, json.JSONDecodeError) as error: @@ -1366,8 +1548,14 @@ def declared_launch(self, job_id: str) -> tuple[tuple[str, ...], dict[str, str]] command = value["command"] environment = value["environment"] if ( - not isinstance(command, list) or not command or any(not isinstance(item, str) or not item for item in command) - or not isinstance(environment, Mapping) or any(not isinstance(key, str) or not key or not isinstance(item, str) for key, item in environment.items()) + not isinstance(command, list) + or not command + or any(not isinstance(item, str) or not item for item in command) + or not isinstance(environment, Mapping) + or any( + not isinstance(key, str) or not key or not isinstance(item, str) + for key, item in environment.items() + ) ): raise JobRecordError("declared job launch input is invalid") return tuple(command), dict(environment) @@ -1386,7 +1574,9 @@ def locked(self, job_id: str) -> Iterator[None]: with lock: _ensure_durable_directory(self.locks_root) lock_path = self.locks_root / f"{job_id}.lock" - descriptor = os.open(lock_path, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600) + descriptor = os.open( + lock_path, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600 + ) try: fcntl.flock(descriptor, fcntl.LOCK_EX) yield @@ -1438,15 +1628,20 @@ def active_records(self) -> list[GenericJobRecord]: job_ids = self._active_record_ids() if job_ids is None: all_records = self.list() - records = [record for record in all_records if not record.state.get("terminal")] + records = [ + record for record in all_records if not record.state.get("terminal") + ] self._write_active_record_ids({record.job_id for record in records}) if self._service_lease_record_ids() is None: with self.locked_service_lease_records(): - self._write_service_lease_record_ids({ - record.job_id - for record in all_records - if record.spec.lease is not None and not self._service_lease_released(record.job_id) - }) + self._write_service_lease_record_ids( + { + record.job_id + for record in all_records + if record.spec.lease is not None + and not self._service_lease_released(record.job_id) + } + ) return records records: list[GenericJobRecord] = [] recovered_ids: set[str] = set() @@ -1471,8 +1666,14 @@ def _active_record_ids(self) -> set[str] | None: value = json.loads(path.read_text()) except (OSError, json.JSONDecodeError): return None - raw_ids = value.get("jobs") if isinstance(value, Mapping) and value.get("schema_version") == 1 else None - if not isinstance(raw_ids, list) or any(not isinstance(job_id, str) for job_id in raw_ids): + raw_ids = ( + value.get("jobs") + if isinstance(value, Mapping) and value.get("schema_version") == 1 + else None + ) + if not isinstance(raw_ids, list) or any( + not isinstance(job_id, str) for job_id in raw_ids + ): return None try: return {str(UUID(job_id)) for job_id in raw_ids} @@ -1492,10 +1693,16 @@ def _set_active_record(self, job_id: str, *, active: bool) -> None: def _write_active_record_ids(self, job_ids: set[str]) -> None: path = self.active_records_path temporary = path.with_suffix(".json.tmp") - descriptor = os.open(temporary, os.O_CREAT | os.O_TRUNC | os.O_WRONLY | os.O_NOFOLLOW, 0o600) + descriptor = os.open( + temporary, os.O_CREAT | os.O_TRUNC | os.O_WRONLY | os.O_NOFOLLOW, 0o600 + ) try: with os.fdopen(descriptor, "w") as handle: - json.dump({"schema_version": 1, "jobs": sorted(job_ids)}, handle, sort_keys=True) + json.dump( + {"schema_version": 1, "jobs": sorted(job_ids)}, + handle, + sort_keys=True, + ) handle.write("\n") handle.flush() os.fsync(handle.fileno()) @@ -1512,9 +1719,12 @@ def service_lease_records(self) -> list[GenericJobRecord]: records = [ record for record in self.list() - if record.spec.lease is not None and not self._service_lease_released(record.job_id) + if record.spec.lease is not None + and not self._service_lease_released(record.job_id) ] - self._write_service_lease_record_ids({record.job_id for record in records}) + self._write_service_lease_record_ids( + {record.job_id for record in records} + ) return records records: list[GenericJobRecord] = [] recovered_ids: set[str] = set() @@ -1539,8 +1749,14 @@ def _service_lease_record_ids(self) -> set[str] | None: value = json.loads(path.read_text()) except (OSError, json.JSONDecodeError): return None - raw_ids = value.get("jobs") if isinstance(value, Mapping) and value.get("schema_version") == 1 else None - if not isinstance(raw_ids, list) or any(not isinstance(job_id, str) for job_id in raw_ids): + raw_ids = ( + value.get("jobs") + if isinstance(value, Mapping) and value.get("schema_version") == 1 + else None + ) + if not isinstance(raw_ids, list) or any( + not isinstance(job_id, str) for job_id in raw_ids + ): return None try: return {str(UUID(job_id)) for job_id in raw_ids} @@ -1560,10 +1776,16 @@ def _set_service_lease_record(self, job_id: str, *, active: bool) -> None: def _write_service_lease_record_ids(self, job_ids: set[str]) -> None: path = self.service_lease_records_path temporary = path.with_suffix(".json.tmp") - descriptor = os.open(temporary, os.O_CREAT | os.O_TRUNC | os.O_WRONLY | os.O_NOFOLLOW, 0o600) + descriptor = os.open( + temporary, os.O_CREAT | os.O_TRUNC | os.O_WRONLY | os.O_NOFOLLOW, 0o600 + ) try: with os.fdopen(descriptor, "w") as handle: - json.dump({"schema_version": 1, "jobs": sorted(job_ids)}, handle, sort_keys=True) + json.dump( + {"schema_version": 1, "jobs": sorted(job_ids)}, + handle, + sort_keys=True, + ) handle.write("\n") handle.flush() os.fsync(handle.fileno()) @@ -1579,7 +1801,9 @@ def save(self, record: GenericJobRecord) -> None: self._set_active_record(record.job_id, active=True) if not self.service_lease_records_path.exists(): self._set_service_lease_record(record.job_id, active=False) - if record.spec.lease is not None and not self._service_lease_released(record.job_id): + if record.spec.lease is not None and not self._service_lease_released( + record.job_id + ): self._set_service_lease_record(record.job_id, active=True) temporary = path.with_suffix(".json.tmp") descriptor = os.open(temporary, os.O_CREAT | os.O_TRUNC | os.O_WRONLY, 0o600) @@ -1627,7 +1851,10 @@ def __post_init__(self) -> None: for record in records: with self.store.locked(record.job_id): record = self.store.load(record.job_id) - if record.spec.kind == "declared-operation" and record.state.get("phase") == "launching": + if ( + record.spec.kind == "declared-operation" + and record.state.get("phase") == "launching" + ): record = self._recover_unpublished_declared_locked(record) if record.state.get("terminal"): self._terminal_cleanup(record) @@ -1642,7 +1869,9 @@ def __post_init__(self) -> None: for record in finalized: self._finish_admission(record, state) - def _recover_unpublished_declared_locked(self, record: GenericJobRecord) -> GenericJobRecord: + def _recover_unpublished_declared_locked( + self, record: GenericJobRecord + ) -> GenericJobRecord: """Recover the record/input publication window without guessing at systemd. A complete private input proves the durable intent can be queued. An @@ -1678,7 +1907,12 @@ def _recover_unpublished_declared_locked(self, record: GenericJobRecord) -> Gene "observed_at": _timestamp(), "subscribers": 1, "dependencies": list(record.spec.dependency_job_ids), - "admission": {"pool": record.spec.pool, "estimate_memory_bytes": self._estimate(record.spec, self._admission_state())}, + "admission": { + "pool": record.spec.pool, + "estimate_memory_bytes": self._estimate( + record.spec, self._admission_state() + ), + }, }, ) self.store.save(queued) @@ -1687,18 +1921,47 @@ def _recover_unpublished_declared_locked(self, record: GenericJobRecord) -> Gene def _admission_state(self) -> dict[str, Any]: path = self.store.admission_path if not path.exists(): - return {"schema_version": ADMISSION_SCHEMA_VERSION, "active": {}, "cache": {}, "estimates": {}} + return { + "schema_version": ADMISSION_SCHEMA_VERSION, + "active": {}, + "cache": {}, + "estimates": {}, + } try: value = json.loads(path.read_text()) except (OSError, json.JSONDecodeError): # Conservatively recover by forgetting optimizations. Existing # records/systemd evidence still determine all real jobs. - return {"schema_version": ADMISSION_SCHEMA_VERSION, "active": {}, "cache": {}, "estimates": {}} - if not isinstance(value, Mapping) or value.get("schema_version") != ADMISSION_SCHEMA_VERSION: - return {"schema_version": ADMISSION_SCHEMA_VERSION, "active": {}, "cache": {}, "estimates": {}} - if not all(isinstance(value.get(key), Mapping) for key in ("active", "cache", "estimates")): - return {"schema_version": ADMISSION_SCHEMA_VERSION, "active": {}, "cache": {}, "estimates": {}} - return {"schema_version": ADMISSION_SCHEMA_VERSION, **{key: dict(value[key]) for key in ("active", "cache", "estimates")}} + return { + "schema_version": ADMISSION_SCHEMA_VERSION, + "active": {}, + "cache": {}, + "estimates": {}, + } + if ( + not isinstance(value, Mapping) + or value.get("schema_version") != ADMISSION_SCHEMA_VERSION + ): + return { + "schema_version": ADMISSION_SCHEMA_VERSION, + "active": {}, + "cache": {}, + "estimates": {}, + } + if not all( + isinstance(value.get(key), Mapping) + for key in ("active", "cache", "estimates") + ): + return { + "schema_version": ADMISSION_SCHEMA_VERSION, + "active": {}, + "cache": {}, + "estimates": {}, + } + return { + "schema_version": ADMISSION_SCHEMA_VERSION, + **{key: dict(value[key]) for key in ("active", "cache", "estimates")}, + } def _save_admission_state(self, value: Mapping[str, Any]) -> None: path = self.store.admission_path @@ -1714,7 +1977,16 @@ def _save_admission_state(self, value: Mapping[str, Any]) -> None: @staticmethod def _bounded(mapping: Mapping[str, Any], limit: int) -> dict[str, Any]: - return dict(sorted(mapping.items(), key=lambda item: str(item[1].get("touched_at", "")) if isinstance(item[1], Mapping) else "")[-limit:]) + return dict( + sorted( + mapping.items(), + key=lambda item: ( + str(item[1].get("touched_at", "")) + if isinstance(item[1], Mapping) + else "" + ), + )[-limit:] + ) def start(self, spec: GenericJobSpec, job_id: str | None = None) -> dict[str, Any]: candidate = job_id or str(uuid4()) @@ -1722,7 +1994,9 @@ def start(self, spec: GenericJobSpec, job_id: str | None = None) -> dict[str, An record = self.store.create(spec, candidate) try: if not self.store.service_lease_ports_available(spec.lease): - raise SystemdJobError("leased loopback port became unavailable before launch") + raise SystemdJobError( + "leased loopback port became unavailable before launch" + ) self.systemd.start( unit=record.unit, command=spec.command, @@ -1730,11 +2004,16 @@ def start(self, spec: GenericJobSpec, job_id: str | None = None) -> dict[str, An environment=spec.environment, timeout_seconds=spec.timeout_seconds, log_path=record.log_path, - json_result_path=record.result_path if spec.result_kind in {"json", "pytest"} else None, + json_result_path=record.result_path + if spec.result_kind in {"json", "pytest"} + else None, ) except SystemdJobError: return self._reconcile_launch_error(record) - submitted = self._with_state(record, {"phase": "submitted", "terminal": False, "observed_at": _timestamp()}) + submitted = self._with_state( + record, + {"phase": "submitted", "terminal": False, "observed_at": _timestamp()}, + ) self.store.save(submitted) return self._public(submitted, submitted.state) @@ -1750,12 +2029,21 @@ def start_declared( contract: Mapping[str, Any] | None = None, ) -> dict[str, Any]: if principal not in {"agent-control", "operator"}: - raise ValueError("declared operations require agent-control or operator principal") + raise ValueError( + "declared operations require agent-control or operator principal" + ) if checkout is not None and checkout.project_id != project.project_id: raise ValueError("declared job checkout belongs to another project") with self._admission_lock: return self._start_declared_locked( - project, operation, correlation_id, principal, parameters, checkout, (), contract or {} + project, + operation, + correlation_id, + principal, + parameters, + checkout, + (), + contract or {}, ) def _start_declared_locked( @@ -1803,7 +2091,13 @@ def _start_declared_locked( tree = self._cache_tree(workdir) coalesce_key = ( self._operation_identity_key( - project, operation, parameter_digest, principal, environment, tree, checkout + project, + operation, + parameter_digest, + principal, + environment, + tree, + checkout, ) if operation.service is None or operation.cache == "tree+environment" else None @@ -1818,7 +2112,9 @@ def _start_declared_locked( except JobRecordError: state["cache"].pop(cache_key, None) else: - if record.state.get("phase") == "succeeded" and record.state.get("terminal"): + if record.state.get("phase") == "succeeded" and record.state.get( + "terminal" + ): response = self._public(record, record.state) response["reused"] = True return response @@ -1832,7 +2128,14 @@ def _start_declared_locked( else: if not record.state.get("terminal"): subscribers = int(record.state.get("subscribers", 1)) + 1 - updated = self._with_state(record, {**record.state, "subscribers": subscribers, "coalesced": True}) + updated = self._with_state( + record, + { + **record.state, + "subscribers": subscribers, + "coalesced": True, + }, + ) self.store.save(updated) response = self._public(updated, updated.state) response["coalesced"] = True @@ -1840,26 +2143,44 @@ def _start_declared_locked( job_id = str(uuid4()) readiness_path = ( self.store.prepare_service_readiness(job_id) - if operation.service is not None and operation.service.readiness == "project-command" + if operation.service is not None + and operation.service.readiness == "project-command" else None ) - environment.update({ - "SINNIXD_JOB_ID": job_id, "SINNIXD_CORRELATION_ID": correlation_id, - "SINNIXD_PROJECT_ID": project.project_id, "SINNIXD_OPERATION": operation.name, - }) + environment.update( + { + "SINNIXD_JOB_ID": job_id, + "SINNIXD_CORRELATION_ID": correlation_id, + "SINNIXD_PROJECT_ID": project.project_id, + "SINNIXD_OPERATION": operation.name, + } + ) if checkout is not None: - environment.update({"SINNIXD_CHECKOUT_ID": checkout.checkout_id, "SINNIXD_CHECKOUT_HEAD": checkout.head}) + environment.update( + { + "SINNIXD_CHECKOUT_ID": checkout.checkout_id, + "SINNIXD_CHECKOUT_HEAD": checkout.head, + } + ) estimate_key = f"{project.project_id}:{operation.name}" learned = state["estimates"].get(estimate_key) - estimate = learned.get("bytes") if isinstance(learned, Mapping) else operation.estimate_memory_bytes + estimate = ( + learned.get("bytes") + if isinstance(learned, Mapping) + else operation.estimate_memory_bytes + ) def build_spec(lease: ServiceLease | None) -> GenericJobSpec: launch_environment = dict(environment) launch_environment.update(dependency_environment) if lease is not None: - launch_environment.update({port.environment: str(port.port) for port in lease.ports}) + launch_environment.update( + {port.environment: str(port.port) for port in lease.ports} + ) if readiness_path is not None: - launch_environment["SINNIXD_SERVICE_READY_FILE"] = str(readiness_path) + launch_environment["SINNIXD_SERVICE_READY_FILE"] = str( + readiness_path + ) scratch_path = self.store.scratch_path_for(operation.scratch, job_id) payload_overrides: dict[str, str] = {} if scratch_path is not None: @@ -1867,16 +2188,28 @@ def build_spec(lease: ServiceLease | None) -> GenericJobSpec: payload_overrides["TMPDIR"] = str(scratch_path) return GenericJobSpec( kind="declared-operation", - command=project.environment.command_for(operation_argv, overrides=payload_overrides), - working_directory=str(workdir), environment=launch_environment, project_id=project.project_id, - operation=operation.name, parameter_digest=parameter_digest, + command=project.environment.command_for( + operation_argv, overrides=payload_overrides + ), + working_directory=str(workdir), + environment=launch_environment, + project_id=project.project_id, + operation=operation.name, + parameter_digest=parameter_digest, principal=principal, timeout_seconds=operation.timeout_seconds, checkout=checkout.to_dict() if checkout is not None else None, contract=dict(contract), - result_kind={"exit": "exit-status", "json": "json", "pytest": "pytest"}[operation.result], - pool=operation.pool, exclusive_keys=operation.exclusive_keys, dependency_job_ids=dependency_ids, - coalesce_key=coalesce_key, cache_key=cache_key, estimate_key=estimate_key, estimate_memory_bytes=estimate, + result_kind={"exit": "exit-status", "json": "json", "pytest": "pytest"}[ + operation.result + ], + pool=operation.pool, + exclusive_keys=operation.exclusive_keys, + dependency_job_ids=dependency_ids, + coalesce_key=coalesce_key, + cache_key=cache_key, + estimate_key=estimate_key, + estimate_memory_bytes=estimate, scratch=operation.scratch, lease=lease, ) @@ -1895,14 +2228,26 @@ def build_spec(lease: ServiceLease | None) -> GenericJobSpec: raise try: if operation.service is None: - self.store.write_declared_launch(job_id, record.spec.command, record.spec.environment) + self.store.write_declared_launch( + job_id, record.spec.command, record.spec.environment + ) except BaseException: self.store.cleanup_scratch(record) raise - queued = self._with_state(record, { - "phase": "queued", "terminal": False, "observed_at": _timestamp(), "subscribers": 1, - "dependencies": list(dependency_ids), "admission": {"pool": spec.pool, "estimate_memory_bytes": self._estimate(spec, state)}, - }) + queued = self._with_state( + record, + { + "phase": "queued", + "terminal": False, + "observed_at": _timestamp(), + "subscribers": 1, + "dependencies": list(dependency_ids), + "admission": { + "pool": spec.pool, + "estimate_memory_bytes": self._estimate(spec, state), + }, + }, + ) self.store.save(queued) if coalesce_key is not None: state["active"][coalesce_key] = job_id @@ -1914,11 +2259,25 @@ def build_spec(lease: ServiceLease | None) -> GenericJobSpec: @staticmethod def _cache_tree(path: Path) -> str | None: try: - clean = subprocess.run(["git", "-C", str(path), "status", "--porcelain"], capture_output=True, text=True, timeout=2) + clean = subprocess.run( + ["git", "-C", str(path), "status", "--porcelain"], + capture_output=True, + text=True, + timeout=2, + ) if clean.returncode != 0 or clean.stdout: return None - tree = subprocess.run(["git", "-C", str(path), "rev-parse", "HEAD^{tree}"], capture_output=True, text=True, timeout=2) - return tree.stdout.strip() if tree.returncode == 0 and len(tree.stdout.strip()) == 40 else None + tree = subprocess.run( + ["git", "-C", str(path), "rev-parse", "HEAD^{tree}"], + capture_output=True, + text=True, + timeout=2, + ) + return ( + tree.stdout.strip() + if tree.returncode == 0 and len(tree.stdout.strip()) == 40 + else None + ) except (OSError, subprocess.TimeoutExpired): return None @@ -1947,7 +2306,9 @@ def _operation_identity_key( "project_root": str(project.root.resolve()), "checkout": checkout.to_dict() if checkout is not None else None, } - return hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()).hexdigest() + return hashlib.sha256( + json.dumps(payload, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest() @staticmethod def _estimate(spec: GenericJobSpec, state: Mapping[str, Any]) -> int: @@ -1967,7 +2328,20 @@ def _admit_locked(self) -> None: records = self.store.active_records() active: dict[str, list[GenericJobRecord]] = {pool: [] for pool in POOL_POLICIES} for record in records: - if record.spec.kind == "declared-operation" and not record.state.get("terminal") and record.state.get("phase") in {"submitted", "running", "cancelling", "stopping", "launch-unknown", "observation-unknown", "outcome-unknown"}: + if ( + record.spec.kind == "declared-operation" + and not record.state.get("terminal") + and record.state.get("phase") + in { + "submitted", + "running", + "cancelling", + "stopping", + "launch-unknown", + "observation-unknown", + "outcome-unknown", + } + ): active[record.spec.pool].append(record) pressure = self.pressure_probe() for snapshot in records: @@ -1979,7 +2353,10 @@ def _admit_locked(self) -> None: blocked = self._dependency_block(snapshot) with self.store.locked(snapshot.job_id): record = self.store.load(snapshot.job_id) - if record.state.get("terminal") or record.state.get("phase") not in {"queued", "waiting-dependencies"}: + if record.state.get("terminal") or record.state.get("phase") not in { + "queued", + "waiting-dependencies", + }: continue if blocked is not None: updated = self._with_state(record, blocked) @@ -1990,8 +2367,16 @@ def _admit_locked(self) -> None: continue policy = POOL_POLICIES[record.spec.pool] estimate = self._estimate(record.spec, state) - occupied = sum(self._estimate(item.spec, state) for item in active[record.spec.pool]) - exclusive = {key for pool_records in active.values() for item in pool_records for key in item.spec.exclusive_keys} + occupied = sum( + self._estimate(item.spec, state) + for item in active[record.spec.pool] + ) + exclusive = { + key + for pool_records in active.values() + for item in pool_records + for key in item.spec.exclusive_keys + } pressure_blocked = ( record.spec.pool != "interactive" and estimate >= policy["memory_budget"] // 2 @@ -2010,11 +2395,16 @@ def _admit_locked(self) -> None: submitted: GenericJobRecord | None = None with self.store.locked(record.job_id): current = self.store.load(record.job_id) - if current.state.get("terminal") or current.state.get("phase") not in {"queued", "waiting-dependencies"}: + if current.state.get("terminal") or current.state.get("phase") not in { + "queued", + "waiting-dependencies", + }: continue try: if not self.store.service_lease_ports_available(current.spec.lease): - raise SystemdJobError("leased loopback port became unavailable before launch") + raise SystemdJobError( + "leased loopback port became unavailable before launch" + ) command, environment = self.store.declared_launch(current.job_id) if scratch_path := self.store.prepare_scratch(current): environment["TMPDIR"] = str(scratch_path) @@ -2028,13 +2418,25 @@ def _admit_locked(self) -> None: from .contracts import contract_runner_executable command = ( - str(contract_runner_executable()), "--declared", "--job-id", current.job_id, - "--unit", current.unit, "--state-root", str(self.store.root), + str(contract_runner_executable()), + "--declared", + "--job-id", + current.job_id, + "--unit", + current.unit, + "--state-root", + str(self.store.root), ) self.systemd.start( - unit=current.unit, command=command, working_directory=current.spec.working_directory, - environment=environment, timeout_seconds=current.spec.timeout_seconds, log_path=current.log_path, - json_result_path=current.result_path if current.spec.result_kind in {"json", "pytest"} else None, + unit=current.unit, + command=command, + working_directory=current.spec.working_directory, + environment=environment, + timeout_seconds=current.spec.timeout_seconds, + log_path=current.log_path, + json_result_path=current.result_path + if current.spec.result_kind in {"json", "pytest"} + else None, ) except SystemdJobError: self._reconcile_launch_error(current) @@ -2053,7 +2455,13 @@ def _admit_locked(self) -> None: self._terminal_cleanup(terminal) else: submitted = self._with_state( - current, {**current.state, "phase": "submitted", "terminal": False, "observed_at": _timestamp()} + current, + { + **current.state, + "phase": "submitted", + "terminal": False, + "observed_at": _timestamp(), + }, ) self.store.save(submitted) if terminal is not None and terminal.state.get("terminal"): @@ -2074,7 +2482,10 @@ def _dependency_block(self, record: GenericJobRecord) -> Mapping[str, Any] | Non "launch_evidence": "not-started", "observed_at": _timestamp(), } - if dependency["state"].get("terminal") and dependency["state"].get("phase") != "succeeded": + if ( + dependency["state"].get("terminal") + and dependency["state"].get("phase") != "succeeded" + ): return { "phase": "dependency-failed", "terminal": True, @@ -2088,17 +2499,23 @@ def _dependency_block(self, record: GenericJobRecord) -> Mapping[str, Any] | Non if ( lease is not None and dependency["state"].get("phase") in {"submitted", "running"} - and ( - lease.readiness == "none" - or self.store.service_ready(job_id) + and (lease.readiness == "none" or self.store.service_ready(job_id)) + and all( + not _loopback_port_available(port.port) for port in lease.ports ) - and all(not _loopback_port_available(port.port) for port in lease.ports) ): continue - return {"phase": "waiting-dependencies", "terminal": False, "observed_at": _timestamp(), "dependencies": list(record.spec.dependency_job_ids)} + return { + "phase": "waiting-dependencies", + "terminal": False, + "observed_at": _timestamp(), + "dependencies": list(record.spec.dependency_job_ids), + } return None - def _finish_admission(self, record: GenericJobRecord, state: dict[str, Any]) -> None: + def _finish_admission( + self, record: GenericJobRecord, state: dict[str, Any] + ) -> None: active_key = record.spec.coalesce_key or record.spec.cache_key if active_key is not None and state["active"].get(active_key) == record.job_id: state["active"].pop(active_key, None) @@ -2106,14 +2523,29 @@ def _finish_admission(self, record: GenericJobRecord, state: dict[str, Any]) -> record.state.get("phase") == "succeeded" and record.spec.lease is None and record.spec.cache_key is not None - and (record.spec.result_kind == "exit-status" or self._has_authoritative_result(record)) + and ( + record.spec.result_kind == "exit-status" + or self._has_authoritative_result(record) + ) ): - state["cache"][record.spec.cache_key] = {"job_id": record.job_id, "touched_at": _timestamp()} + state["cache"][record.spec.cache_key] = { + "job_id": record.job_id, + "touched_at": _timestamp(), + } state["cache"] = self._bounded(state["cache"], MAX_ADMISSION_CACHE_ENTRIES) peak = self._memory_peak(record.state.get("systemd", {})) - if record.state.get("phase") == "succeeded" and peak is not None and record.spec.estimate_key is not None: - state["estimates"][record.spec.estimate_key] = {"bytes": peak, "touched_at": _timestamp()} - state["estimates"] = self._bounded(state["estimates"], MAX_ADMISSION_ESTIMATES) + if ( + record.state.get("phase") == "succeeded" + and peak is not None + and record.spec.estimate_key is not None + ): + state["estimates"][record.spec.estimate_key] = { + "bytes": peak, + "touched_at": _timestamp(), + } + state["estimates"] = self._bounded( + state["estimates"], MAX_ADMISSION_ESTIMATES + ) self._save_admission_state(state) def _terminal_cleanup(self, record: GenericJobRecord) -> None: @@ -2179,7 +2611,9 @@ def list( ) -> dict[str, Any]: if not 1 <= limit <= 1_000: raise ValueError("job list limit must be between 1 and 1000") - if project_id is not None and (not isinstance(project_id, str) or not project_id): + if project_id is not None and ( + not isinstance(project_id, str) or not project_id + ): raise ValueError("job list project_id must be a non-empty string") if any(not isinstance(phase, str) or not phase for phase in phases): raise ValueError("job list phases must be non-empty strings") @@ -2214,9 +2648,7 @@ def list( ] if after is not None: snapshot_records = [ - record - for record in snapshot_records - if _job_order_key(record) < after + record for record in snapshot_records if _job_order_key(record) < after ] page_records = snapshot_records[: limit + 1] has_more = len(page_records) > limit @@ -2233,7 +2665,9 @@ def list( ) return { "jobs": [ - self._public(record, record.state) if record.state.get("terminal") else self.get(record.job_id) + self._public(record, record.state) + if record.state.get("terminal") + else self.get(record.job_id) for record in records ], "limit": limit, @@ -2247,20 +2681,29 @@ def list( }, } - def wait(self, job_id: str, timeout_seconds: int = DEFAULT_WAIT_SECONDS) -> dict[str, Any]: + def wait( + self, job_id: str, timeout_seconds: int = DEFAULT_WAIT_SECONDS + ) -> dict[str, Any]: if not 1 <= timeout_seconds <= MAX_WAIT_SECONDS: - raise ValueError(f"wait timeout_seconds must be between 1 and {MAX_WAIT_SECONDS}") + raise ValueError( + f"wait timeout_seconds must be between 1 and {MAX_WAIT_SECONDS}" + ) deadline = time.monotonic() + timeout_seconds while True: remaining = deadline - time.monotonic() if remaining <= 0: with self.store.locked(job_id): record = self.store.load(job_id) - return {**self._public(record, record.state), "wait_timed_out": True} + return { + **self._public(record, record.state), + "wait_timed_out": True, + } with self.store.locked(job_id): status = self._get_locked( job_id, - systemd_timeout_seconds=min(SYSTEMD_COMMAND_TIMEOUT_SECONDS, remaining), + systemd_timeout_seconds=min( + SYSTEMD_COMMAND_TIMEOUT_SECONDS, remaining + ), wait_deadline=deadline, ) with self._admission_lock: @@ -2269,7 +2712,9 @@ def wait(self, job_id: str, timeout_seconds: int = DEFAULT_WAIT_SECONDS) -> dict return status if time.monotonic() >= deadline: return {**status, "wait_timed_out": True} - time.sleep(min(self.wait_poll_seconds, max(0.0, deadline - time.monotonic()))) + time.sleep( + min(self.wait_poll_seconds, max(0.0, deadline - time.monotonic())) + ) def cancel(self, job_id: str) -> dict[str, Any]: terminal: GenericJobRecord | None = None @@ -2290,9 +2735,15 @@ def cancel(self, job_id: str) -> dict[str, Any]: ) self.store.save(cancelled) terminal = cancelled - response = {**self._public(cancelled, cancelled.state), "cancel_requested": True, "already_terminal": False} + response = { + **self._public(cancelled, cancelled.state), + "cancel_requested": True, + "already_terminal": False, + } else: - intent = self._with_cancel_intent(record, status["state"].get("systemd", {}).get("InvocationID")) + intent = self._with_cancel_intent( + record, status["state"].get("systemd", {}).get("InvocationID") + ) self.store.save(intent) self.systemd.stop(intent.unit) acknowledged = self._with_stop_acknowledgement( @@ -2300,7 +2751,11 @@ def cancel(self, job_id: str) -> dict[str, Any]: status["state"].get("systemd", {}).get("InvocationID"), ) self.store.save(acknowledged) - response = {**self._get_locked(job_id), "cancel_requested": True, "already_terminal": False} + response = { + **self._get_locked(job_id), + "cancel_requested": True, + "already_terminal": False, + } with self._admission_lock: if terminal is not None: self._terminal_cleanup(terminal) @@ -2310,9 +2765,13 @@ def cancel(self, job_id: str) -> dict[str, Any]: self._admit_locked() return response - def logs(self, job_id: str, *, offset: int = 0, max_bytes: int = MAX_LOG_BYTES) -> dict[str, Any]: + def logs( + self, job_id: str, *, offset: int = 0, max_bytes: int = MAX_LOG_BYTES + ) -> dict[str, Any]: if offset < 0 or not 1 <= max_bytes <= MAX_LOG_BYTES: - raise ValueError(f"log range must use offset >= 0 and max_bytes between 1 and {MAX_LOG_BYTES}") + raise ValueError( + f"log range must use offset >= 0 and max_bytes between 1 and {MAX_LOG_BYTES}" + ) with self.store.locked(job_id): record = self.store.load(job_id) content = _read_private_artifact(record.log_path, max_bytes, offset=offset) @@ -2328,9 +2787,13 @@ def logs(self, job_id: str, *, offset: int = 0, max_bytes: int = MAX_LOG_BYTES) "artifact_truncated": overflowed, } - def result(self, job_id: str, *, max_bytes: int = MAX_RESULT_BYTES) -> dict[str, Any]: + def result( + self, job_id: str, *, max_bytes: int = MAX_RESULT_BYTES + ) -> dict[str, Any]: if not 1 <= max_bytes <= MAX_RESULT_BYTES: - raise ValueError(f"result max_bytes must be between 1 and {MAX_RESULT_BYTES}") + raise ValueError( + f"result max_bytes must be between 1 and {MAX_RESULT_BYTES}" + ) with self.store.locked(job_id): record = self.store.load(job_id) if record.result_path is None: @@ -2341,7 +2804,11 @@ def result(self, job_id: str, *, max_bytes: int = MAX_RESULT_BYTES) -> dict[str, if not record.state.get("terminal"): self._get_locked(job_id) record = self.store.load(job_id) - return {"job_id": job_id, "kind": "exit-status", "value": self._parse_exit_result(record)} + return { + "job_id": job_id, + "kind": "exit-status", + "value": self._parse_exit_result(record), + } content = _read_private_artifact(record.result_path, MAX_RESULT_BYTES) if content is None: raise JobResultError("job result artifact is unavailable") @@ -2350,13 +2817,23 @@ def result(self, job_id: str, *, max_bytes: int = MAX_RESULT_BYTES) -> dict[str, "max_bytes": MAX_RESULT_BYTES, "kind": record.spec.result_kind, } - if len(content) > MAX_RESULT_BYTES or record.result_path.with_suffix(".overflow").exists(): + if ( + len(content) > MAX_RESULT_BYTES + or record.result_path.with_suffix(".overflow").exists() + ): raise JobResultError("job result exceeds the artifact limit") if record.spec.result_kind in {"json", "pytest"}: if len(content) > max_bytes: - raise JobResultLimitError("job JSON result exceeds the requested response limit") + raise JobResultLimitError( + "job JSON result exceeds the requested response limit" + ) value = self._parse_json_result(content) - return {"job_id": job_id, "kind": record.spec.result_kind, "value": value, "artifact": artifact} + return { + "job_id": job_id, + "kind": record.spec.result_kind, + "value": value, + "artifact": artifact, + } return { "job_id": job_id, "kind": record.spec.result_kind, @@ -2379,12 +2856,17 @@ def _parse_exit_result(self, record: GenericJobRecord) -> dict[str, Any]: state = record.state properties = state.get("systemd") phase = state.get("phase") - if isinstance(properties, Mapping) and properties.get("LoadState") == "loaded" and phase in { - "succeeded", - "failed", - "timed_out", - "cancelled", - }: + if ( + isinstance(properties, Mapping) + and properties.get("LoadState") == "loaded" + and phase + in { + "succeeded", + "failed", + "timed_out", + "cancelled", + } + ): status = properties.get("ExecMainStatus") result = properties.get("Result") if not isinstance(result, str) or not result: @@ -2411,7 +2893,9 @@ def _get_locked( record = self.store.load(job_id) if record.state.get("phase") in {"queued", "waiting-dependencies"}: return self._public(record, record.state) - if record.state.get("terminal") and not self._terminal_state_requires_reconciliation(record): + if record.state.get( + "terminal" + ) and not self._terminal_state_requires_reconciliation(record): self._terminal_cleanup(record) return self._public(record, record.state) try: @@ -2474,11 +2958,15 @@ def _observation_unknown_state() -> dict[str, Any]: "observed_at": _timestamp(), } - def _classify(self, properties: Mapping[str, str], record: GenericJobRecord) -> dict[str, Any]: + def _classify( + self, properties: Mapping[str, str], record: GenericJobRecord + ) -> dict[str, Any]: if self._is_authoritative_not_started_cancellation(record): return dict(record.state) if properties.get("LoadState") != "loaded": - if record.spec.kind == "declared-operation" and record.state.get("phase") in { + if record.spec.kind == "declared-operation" and record.state.get( + "phase" + ) in { "launching", "queued", "waiting-dependencies", @@ -2527,7 +3015,12 @@ def _classify(self, properties: Mapping[str, str], record: GenericJobRecord) -> ), "observed_at": _timestamp(), } - return {"phase": "missing", "terminal": True, "systemd": dict(properties), "observed_at": _timestamp()} + return { + "phase": "missing", + "terminal": True, + "systemd": dict(properties), + "observed_at": _timestamp(), + } if record.spec.lease is not None: bound = record.state.get("lease_invocation_id") invocation = properties.get("InvocationID") @@ -2541,21 +3034,30 @@ def _classify(self, properties: Mapping[str, str], record: GenericJobRecord) -> "observed_at": _timestamp(), } if self._has_schema_v3_native_success(record, properties): - return self._with_service_lease_invocation(record, properties, { - "phase": "succeeded", - "terminal": True, - "systemd": dict(properties), - "result_evidence": "native-v3", - "observed_at": _timestamp(), - }) + return self._with_service_lease_invocation( + record, + properties, + { + "phase": "succeeded", + "terminal": True, + "systemd": dict(properties), + "result_evidence": "native-v3", + "observed_at": _timestamp(), + }, + ) active = properties.get("ActiveState", "unknown") if active in {"active", "activating", "reloading"}: phase = "running" terminal = False elif active == "deactivating": - phase = "cancelling" if record.cancel_requested_at is not None else "stopping" + phase = ( + "cancelling" if record.cancel_requested_at is not None else "stopping" + ) terminal = False - elif properties.get("Result") == "success" and properties.get("ExecMainStatus") == "0": + elif ( + properties.get("Result") == "success" + and properties.get("ExecMainStatus") == "0" + ): phase = "succeeded" terminal = True elif properties.get("Result") == "timeout": @@ -2567,12 +3069,16 @@ def _classify(self, properties: Mapping[str, str], record: GenericJobRecord) -> else: phase = "failed" terminal = True - return self._with_service_lease_invocation(record, properties, { - "phase": phase, - "terminal": terminal, - "systemd": dict(properties), - "observed_at": _timestamp(), - }) + return self._with_service_lease_invocation( + record, + properties, + { + "phase": phase, + "terminal": terminal, + "systemd": dict(properties), + "observed_at": _timestamp(), + }, + ) @staticmethod def _with_service_lease_invocation( @@ -2597,7 +3103,9 @@ def _cancellation_reconciliation_grace_expired(record: GenericJobRecord) -> bool return False if requested_at.tzinfo is None: return False - return (datetime.now(UTC) - requested_at).total_seconds() >= CANCEL_OUTCOME_RECONCILIATION_GRACE_SECONDS + return ( + datetime.now(UTC) - requested_at + ).total_seconds() >= CANCEL_OUTCOME_RECONCILIATION_GRACE_SECONDS def _terminal_state_requires_reconciliation(self, record: GenericJobRecord) -> bool: if self._is_authoritative_not_started_cancellation(record): @@ -2615,7 +3123,9 @@ def _terminal_state_requires_reconciliation(self, record: GenericJobRecord) -> b if phase == "succeeded" and properties.get("LoadState") != "loaded": return not self._has_authoritative_result(record) if phase == "cancelled" and not self._stop_acknowledgement_matches(record): - return properties.get("LoadState") != "loaded" or not self._cancel_matches(properties, record) + return properties.get("LoadState") != "loaded" or not self._cancel_matches( + properties, record + ) return False @staticmethod @@ -2646,7 +3156,9 @@ def _has_authoritative_result(self, record: GenericJobRecord) -> bool: return completed return completed and self._has_valid_result_artifact(record) - def _has_schema_v3_native_success(self, record: GenericJobRecord, properties: Mapping[str, str]) -> bool: + def _has_schema_v3_native_success( + self, record: GenericJobRecord, properties: Mapping[str, str] + ) -> bool: return ( record.spec.kind == "attested-agent" and record.spec.result_kind == "last-message" @@ -2660,7 +3172,9 @@ def _has_schema_v3_native_success(self, record: GenericJobRecord, properties: Ma ) @staticmethod - def _with_state(record: GenericJobRecord, state: Mapping[str, Any]) -> GenericJobRecord: + def _with_state( + record: GenericJobRecord, state: Mapping[str, Any] + ) -> GenericJobRecord: return GenericJobRecord( job_id=record.job_id, unit=record.unit, @@ -2677,9 +3191,13 @@ def _with_state(record: GenericJobRecord, state: Mapping[str, Any]) -> GenericJo ) @staticmethod - def _with_cancel_intent(record: GenericJobRecord, invocation_id: Any) -> GenericJobRecord: + def _with_cancel_intent( + record: GenericJobRecord, invocation_id: Any + ) -> GenericJobRecord: existing_intent = record.cancel_requested_at is not None - observed_invocation = invocation_id if isinstance(invocation_id, str) and invocation_id else None + observed_invocation = ( + invocation_id if isinstance(invocation_id, str) and invocation_id else None + ) return GenericJobRecord( job_id=record.job_id, unit=record.unit, @@ -2690,7 +3208,9 @@ def _with_cancel_intent(record: GenericJobRecord, invocation_id: Any) -> Generic created_at=record.created_at, cancel_requested_at=record.cancel_requested_at or _timestamp(), cancel_requested_invocation_id=( - record.cancel_requested_invocation_id if existing_intent else observed_invocation + record.cancel_requested_invocation_id + if existing_intent + else observed_invocation ), cancel_stop_acknowledged_at=record.cancel_stop_acknowledged_at, cancel_stop_acknowledged_invocation_id=record.cancel_stop_acknowledged_invocation_id, @@ -2698,7 +3218,9 @@ def _with_cancel_intent(record: GenericJobRecord, invocation_id: Any) -> Generic ) @staticmethod - def _with_stop_acknowledgement(record: GenericJobRecord, invocation_id: Any) -> GenericJobRecord: + def _with_stop_acknowledgement( + record: GenericJobRecord, invocation_id: Any + ) -> GenericJobRecord: invocation = invocation_id if isinstance(invocation_id, str) else None if invocation is None or invocation != record.cancel_requested_invocation_id: return record @@ -2718,7 +3240,9 @@ def _with_stop_acknowledgement(record: GenericJobRecord, invocation_id: Any) -> ) @staticmethod - def _cancel_matches(properties: Mapping[str, str], record: GenericJobRecord) -> bool: + def _cancel_matches( + properties: Mapping[str, str], record: GenericJobRecord + ) -> bool: if record.cancel_requested_at is None: return False invocation = properties.get("InvocationID") @@ -2733,7 +3257,8 @@ def _stop_acknowledgement_matches(record: GenericJobRecord) -> bool: return ( record.cancel_stop_acknowledged_at is not None and record.cancel_stop_acknowledged_invocation_id is not None - and record.cancel_stop_acknowledged_invocation_id == record.cancel_requested_invocation_id + and record.cancel_stop_acknowledged_invocation_id + == record.cancel_requested_invocation_id ) @staticmethod @@ -2761,7 +3286,9 @@ def _cancel_intent(record: GenericJobRecord) -> dict[str, str]: intent["invocation_id"] = record.cancel_requested_invocation_id return intent - def _public(self, record: GenericJobRecord, state: Mapping[str, Any]) -> dict[str, Any]: + def _public( + self, record: GenericJobRecord, state: Mapping[str, Any] + ) -> dict[str, Any]: return { "job_id": record.job_id, "unit": record.unit, @@ -2774,7 +3301,9 @@ def _public(self, record: GenericJobRecord, state: Mapping[str, Any]) -> dict[st else None ), "principal": record.spec.principal, - "checkout": dict(record.spec.checkout) if record.spec.checkout is not None else None, + "checkout": dict(record.spec.checkout) + if record.spec.checkout is not None + else None, "contract": dict(record.spec.contract), "created_at": record.created_at, "timeout_seconds": record.spec.timeout_seconds, @@ -2784,7 +3313,9 @@ def _public(self, record: GenericJobRecord, state: Mapping[str, Any]) -> dict[st "state": ( "released" if state.get("terminal") - and self.store._service_lease_released(record.spec.lease.lease_id) + and self.store._service_lease_released( + record.spec.lease.lease_id + ) else "active" ), } @@ -2792,7 +3323,10 @@ def _public(self, record: GenericJobRecord, state: Mapping[str, Any]) -> dict[st else None ), "artifacts": { - "log": {"ref": f"sinnix://jobs/{record.job_id}/artifacts/log", "max_bytes": MAX_LOG_BYTES}, + "log": { + "ref": f"sinnix://jobs/{record.job_id}/artifacts/log", + "max_bytes": MAX_LOG_BYTES, + }, "result": ( { "ref": f"sinnix://jobs/{record.job_id}/artifacts/result", @@ -2813,7 +3347,9 @@ def _command_digest(command: Sequence[str]) -> str: def _parameter_digest(parameters: Mapping[str, Any]) -> str: return hashlib.sha256( - json.dumps(parameters, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode() + json.dumps( + parameters, sort_keys=True, separators=(",", ":"), ensure_ascii=True + ).encode() ).hexdigest() @@ -2839,19 +3375,27 @@ def capture_main(arguments: Sequence[str] | None = None) -> int: or not 1 <= parsed.max_bytes <= MAX_LOG_ARTIFACT_BYTES or (parsed.result_path is None) != (parsed.result_overflow_path is None) ): - parser.error("requires --max-bytes within the artifact cap and a command after --") + parser.error( + "requires --max-bytes within the artifact cap and a command after --" + ) command = parsed.command[1:] remaining = parsed.max_bytes overflowed = False log_handle = _open_preallocated_private_artifact(parsed.log_path) - result_handle = _open_private_artifact(parsed.result_path) if parsed.result_path is not None else None + result_handle = ( + _open_private_artifact(parsed.result_path) + if parsed.result_path is not None + else None + ) result_remaining = MAX_RESULT_BYTES result_overflowed = False try: process = subprocess.Popen( command, stdout=subprocess.PIPE, - stderr=subprocess.PIPE if parsed.result_path is not None else subprocess.STDOUT, + stderr=subprocess.PIPE + if parsed.result_path is not None + else subprocess.STDOUT, ) assert process.stdout is not None streams = selectors.DefaultSelector() @@ -2901,4 +3445,8 @@ def capture_cli() -> None: if __name__ == "__main__": - raise SystemExit(capture_main(sys.argv[2:] if len(sys.argv) > 1 and sys.argv[1] == "capture" else None)) + raise SystemExit( + capture_main( + sys.argv[2:] if len(sys.argv) > 1 and sys.argv[1] == "capture" else None + ) + ) diff --git a/pkgs/sinnixd/sinnixd/limits.py b/pkgs/sinnixd/sinnixd/limits.py index a2bff576..d4c467fe 100644 --- a/pkgs/sinnixd/sinnixd/limits.py +++ b/pkgs/sinnixd/sinnixd/limits.py @@ -6,7 +6,11 @@ def maximum_timeout_seconds(kind: str) -> int: """Return the one timeout ceiling for a durable job kind.""" - return MAX_DECLARED_OPERATION_TIMEOUT_SECONDS if kind == "declared-operation" else DEFAULT_TIMEOUT_SECONDS + return ( + MAX_DECLARED_OPERATION_TIMEOUT_SECONDS + if kind == "declared-operation" + else DEFAULT_TIMEOUT_SECONDS + ) def valid_timeout_seconds(value: object, *, kind: str) -> bool: diff --git a/pkgs/sinnixd/sinnixd/owner_adapters.py b/pkgs/sinnixd/sinnixd/owner_adapters.py index b43c400c..43e0e641 100644 --- a/pkgs/sinnixd/sinnixd/owner_adapters.py +++ b/pkgs/sinnixd/sinnixd/owner_adapters.py @@ -2,11 +2,22 @@ import json from dataclasses import dataclass, replace -from typing import Any, Mapping +from typing import Mapping -from sinnix_mcp import RequestEnvelope, ResponseEnvelope, SourceBinding, SinnixRef, response_envelope_from_dict +from sinnix_mcp import ( + RequestEnvelope, + ResponseEnvelope, + SinnixRef, + SourceBinding, + response_envelope_from_dict, +) +from sinnix_mcp.execution import ( + EnvironmentProfile, + ExecutionProfile, + OwnerExecution, + OwnerRoute, +) from sinnix_mcp.protocol import DEFAULT_INLINE_PAYLOAD_BYTES -from sinnix_mcp.execution import EnvironmentProfile, ExecutionProfile, OwnerExecution, OwnerRoute from .projects import ProjectAdapter, ProjectOwnerAdapter @@ -71,9 +82,7 @@ def call( result = self.execution.run( command, ExecutionProfile( - route=OwnerRoute( - "declared-owner-adapter", EnvironmentProfile.USER_BUS - ), + route=OwnerRoute("declared-owner-adapter", EnvironmentProfile.USER_BUS), timeout_seconds=adapter.timeout_seconds + 5, max_stdout_bytes=self.max_response_bytes, max_stderr_bytes=8_192, @@ -85,28 +94,50 @@ def call( ), ) if result.failure_class is not None: - code = "owner_unavailable" if result.failure_class.startswith("command_unavailable") else "operation_failed" - raise OwnerAdapterError(code, f"owner adapter {adapter.spec.owner!r} failed: {result.failure_class}") + code = ( + "owner_unavailable" + if result.failure_class.startswith("command_unavailable") + else "operation_failed" + ) + raise OwnerAdapterError( + code, + f"owner adapter {adapter.spec.owner!r} failed: {result.failure_class}", + ) try: response = response_envelope_from_dict(result.decode_json()) except (TypeError, ValueError, json.JSONDecodeError) as error: - raise OwnerAdapterError("result_invalid", f"owner adapter {adapter.spec.owner!r} returned an invalid response") from error + raise OwnerAdapterError( + "result_invalid", + f"owner adapter {adapter.spec.owner!r} returned an invalid response", + ) from error self._validate_response(adapter, request, response, expected_source_binding) return response @staticmethod - def _forward_request(request: RequestEnvelope) -> tuple[RequestEnvelope, SourceBinding | None]: + def _forward_request( + request: RequestEnvelope, + ) -> tuple[RequestEnvelope, SourceBinding | None]: arguments = dict(request.arguments) expected = arguments.pop("expected_source_binding", None) if expected is None: return request, None - if not isinstance(expected, Mapping) or set(expected) != {"source_ref", "generation", "root_digest"}: - raise OwnerAdapterError("invalid_argument", "expected_source_binding has invalid fields") + if not isinstance(expected, Mapping) or set(expected) != { + "source_ref", + "generation", + "root_digest", + }: + raise OwnerAdapterError( + "invalid_argument", "expected_source_binding has invalid fields" + ) source_ref = expected["source_ref"] generation = expected["generation"] root_digest = expected["root_digest"] - if not all(isinstance(value, str) for value in (source_ref, generation, root_digest)): - raise OwnerAdapterError("invalid_argument", "expected_source_binding fields must be strings") + if not all( + isinstance(value, str) for value in (source_ref, generation, root_digest) + ): + raise OwnerAdapterError( + "invalid_argument", "expected_source_binding fields must be strings" + ) try: binding = SourceBinding( source_ref=SinnixRef.parse(source_ref), @@ -114,7 +145,9 @@ def _forward_request(request: RequestEnvelope) -> tuple[RequestEnvelope, SourceB root_digest=root_digest, ) except ValueError as error: - raise OwnerAdapterError("invalid_argument", f"expected_source_binding is invalid: {error}") from error + raise OwnerAdapterError( + "invalid_argument", f"expected_source_binding is invalid: {error}" + ) from error return replace(request, arguments=arguments), binding @staticmethod @@ -124,15 +157,34 @@ def _validate_response( response: ResponseEnvelope, expected_source_binding: SourceBinding | None, ) -> None: - if response.request_id != request.request_id or response.correlation_id != request.correlation_id: - raise OwnerAdapterError("result_invalid", "owner adapter response does not match the request") + if ( + response.request_id != request.request_id + or response.correlation_id != request.correlation_id + ): + raise OwnerAdapterError( + "result_invalid", "owner adapter response does not match the request" + ) if response.owner != adapter.spec.owner: - raise OwnerAdapterError("authority_mismatch", "owner adapter response names the wrong owner") + raise OwnerAdapterError( + "authority_mismatch", "owner adapter response names the wrong owner" + ) if response.ok: if len(response.source_bindings) != 1: - raise OwnerAdapterError("result_invalid", "source-scoped owner responses require exactly one source binding") + raise OwnerAdapterError( + "result_invalid", + "source-scoped owner responses require exactly one source binding", + ) binding = response.source_bindings[0] if binding.source_ref != adapter.source_ref: - raise OwnerAdapterError("authority_mismatch", "owner adapter response names the wrong source") - if expected_source_binding is not None and binding != expected_source_binding: - raise OwnerAdapterError("authority_mismatch", "owner adapter response does not match expected source binding") + raise OwnerAdapterError( + "authority_mismatch", + "owner adapter response names the wrong source", + ) + if ( + expected_source_binding is not None + and binding != expected_source_binding + ): + raise OwnerAdapterError( + "authority_mismatch", + "owner adapter response does not match expected source binding", + ) diff --git a/pkgs/sinnixd/sinnixd/projects.py b/pkgs/sinnixd/sinnixd/projects.py index ccb4dc18..d173e95b 100644 --- a/pkgs/sinnixd/sinnixd/projects.py +++ b/pkgs/sinnixd/sinnixd/projects.py @@ -4,15 +4,19 @@ import json import re import subprocess -import tomllib from dataclasses import dataclass from pathlib import Path from typing import Any, Iterable, Mapping, Sequence +import tomllib from sinnix_mcp import Authority, Lifecycle, OwnerRegistry, OwnerSpec, SinnixRef from .environment import build_environment -from .limits import DEFAULT_TIMEOUT_SECONDS, MAX_DECLARED_OPERATION_TIMEOUT_SECONDS, valid_timeout_seconds +from .limits import ( + DEFAULT_TIMEOUT_SECONDS, + MAX_DECLARED_OPERATION_TIMEOUT_SECONDS, + valid_timeout_seconds, +) class ProjectConfigError(ValueError): @@ -41,7 +45,9 @@ class ProjectConfigError(ValueError): def _parameter_digest(parameters: Mapping[str, Any]) -> str: return hashlib.sha256( - json.dumps(parameters, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode() + json.dumps( + parameters, sort_keys=True, separators=(",", ":"), ensure_ascii=True + ).encode() ).hexdigest() @@ -86,14 +92,26 @@ def _checkout_git(path: Path, *arguments: str) -> str: timeout=2, ) except (OSError, subprocess.SubprocessError) as error: - raise ProjectConfigError(f"could not verify registered checkout {path}") from error + raise ProjectConfigError( + f"could not verify registered checkout {path}" + ) from error return result.stdout def revalidate_registered_checkout(checkout: Mapping[str, Any]) -> Path: """Prove a durable checkout binding still names the same registered Git worktree.""" - expected = {"project_id", "project_path", "checkout_id", "path", "git_common_dir", "head"} - if set(checkout) != expected or any(not isinstance(checkout.get(field), str) or not checkout[field] for field in expected): + expected = { + "project_id", + "project_path", + "checkout_id", + "path", + "git_common_dir", + "head", + } + if set(checkout) != expected or any( + not isinstance(checkout.get(field), str) or not checkout[field] + for field in expected + ): raise ProjectConfigError("registered checkout identity is invalid") recorded_path = Path(checkout["path"]) project_path = Path(checkout["project_path"]) @@ -109,13 +127,21 @@ def revalidate_registered_checkout(checkout: Mapping[str, Any]) -> Path: if not (project_root / ".agentctl" / "project.toml").is_file(): raise ProjectConfigError("registered project is unavailable") try: - top_level = Path(_checkout_git(path, "rev-parse", "--show-toplevel").strip()).resolve(strict=True) + top_level = Path( + _checkout_git(path, "rev-parse", "--show-toplevel").strip() + ).resolve(strict=True) common_dir = Path( - _checkout_git(path, "rev-parse", "--path-format=absolute", "--git-common-dir").strip() + _checkout_git( + path, "rev-parse", "--path-format=absolute", "--git-common-dir" + ).strip() + ).resolve(strict=True) + project_top_level = Path( + _checkout_git(project_root, "rev-parse", "--show-toplevel").strip() ).resolve(strict=True) - project_top_level = Path(_checkout_git(project_root, "rev-parse", "--show-toplevel").strip()).resolve(strict=True) project_common_dir = Path( - _checkout_git(project_root, "rev-parse", "--path-format=absolute", "--git-common-dir").strip() + _checkout_git( + project_root, "rev-parse", "--path-format=absolute", "--git-common-dir" + ).strip() ).resolve(strict=True) except OSError as error: raise ProjectConfigError("registered checkout is unavailable") from error @@ -127,8 +153,13 @@ def revalidate_registered_checkout(checkout: Mapping[str, Any]) -> Path: or _registered_checkout_id(path, project_root) != checkout["checkout_id"] ): raise ProjectConfigError("registered checkout identity changed") - records = parse_worktree_records(_checkout_git(project_root, "worktree", "list", "--porcelain")) - if not any(record.get("worktree") == str(path) and record.get("HEAD") == checkout["head"] for record in records): + records = parse_worktree_records( + _checkout_git(project_root, "worktree", "list", "--porcelain") + ) + if not any( + record.get("worktree") == str(path) and record.get("HEAD") == checkout["head"] + for record in records + ): raise ProjectConfigError("checkout is no longer a registered Git worktree") return path @@ -185,7 +216,9 @@ def catalog_row(self) -> dict[str, Any]: return { "exact_files": list(self.exact_files), "generated_surfaces": list(self.generated_surfaces), - "semantic_slots": {name: list(paths) for name, paths in self.semantic_slots.items()}, + "semantic_slots": { + name: list(paths) for name, paths in self.semantic_slots.items() + }, } @@ -209,7 +242,9 @@ def command_for( durable job scratch contract. Place runtime-owned overrides after ``nix develop --command`` so the payload sees the job-owned path. """ - assignments = tuple(f"{name}={value}" for name, value in sorted((overrides or {}).items())) + assignments = tuple( + f"{name}={value}" for name, value in sorted((overrides or {}).items()) + ) if not assignments: return (*self.command, *payload) if self.kind == "nix-develop": @@ -304,7 +339,9 @@ def canonicalize(self, value: Any) -> bool | int | str | tuple[str, ...]: if self.kind in {"string", "enum"}: self._validate_string(value) if self.kind == "enum" and value not in self.values: - raise ValueError(f"parameter {self.name} must be one of its declared values") + raise ValueError( + f"parameter {self.name} must be one of its declared values" + ) return value if not isinstance(value, list) or not value: raise ValueError(f"parameter {self.name} must be a non-empty list") @@ -314,15 +351,22 @@ def canonicalize(self, value: Any) -> bool | int | str | tuple[str, ...]: for item in value: self._validate_string(item) if self.kind == "enum-list" and item not in self.values: - raise ValueError(f"parameter {self.name} must contain only declared values") + raise ValueError( + f"parameter {self.name} must contain only declared values" + ) return tuple(sorted(set(value))) def _validate_string(self, value: Any) -> None: if not isinstance(value, str) or not value: raise ValueError(f"parameter {self.name} must be a non-empty string") assert self.max_length is not None and self.grammar is not None - if len(value) > self.max_length or _PARAMETER_GRAMMARS[self.grammar].fullmatch(value) is None: - raise ValueError(f"parameter {self.name} contains an unsafe or malformed string") + if ( + len(value) > self.max_length + or _PARAMETER_GRAMMARS[self.grammar].fullmatch(value) is None + ): + raise ValueError( + f"parameter {self.name} contains an unsafe or malformed string" + ) @dataclass(frozen=True) @@ -341,20 +385,27 @@ class ProjectOperation: parameters: tuple[OperationParameter, ...] = () service: OperationService | None = None - def derive_argv(self, raw_parameters: Mapping[str, Any]) -> tuple[tuple[str, ...], str]: + def derive_argv( + self, raw_parameters: Mapping[str, Any] + ) -> tuple[tuple[str, ...], str]: if not isinstance(raw_parameters, Mapping): raise ValueError("declared job parameters must be an object") parameter_by_name = {parameter.name: parameter for parameter in self.parameters} unknown = set(raw_parameters) - set(parameter_by_name) if unknown: - raise ValueError("declared job parameters contain unknown field(s): " + ", ".join(sorted(unknown))) + raise ValueError( + "declared job parameters contain unknown field(s): " + + ", ".join(sorted(unknown)) + ) canonical: dict[str, Any] = {} positional_argv: list[tuple[int, str]] = [] flag_argv: list[str] = [] for parameter in self.parameters: if parameter.name not in raw_parameters: if parameter.required: - raise ValueError(f"declared job parameters omit required field: {parameter.name}") + raise ValueError( + f"declared job parameters omit required field: {parameter.name}" + ) continue value = parameter.canonicalize(raw_parameters[parameter.name]) if parameter.position is not None: @@ -438,7 +489,9 @@ def operation(self, name: str) -> ProjectOperation: def descriptor_status(self) -> dict[str, Any]: try: - on_disk_digest = "sha256:" + hashlib.sha256(self.descriptor.read_bytes()).hexdigest() + on_disk_digest = ( + "sha256:" + hashlib.sha256(self.descriptor.read_bytes()).hexdigest() + ) except OSError: on_disk_digest = None return { @@ -455,23 +508,35 @@ def catalog_row(self) -> dict[str, Any]: "descriptor": str(self.descriptor), "digest": self.digest, "descriptor_status": self.descriptor_status(), - "workspace": self.workspace.catalog_row() if self.workspace is not None else None, + "workspace": self.workspace.catalog_row() + if self.workspace is not None + else None, "conflicts": self.conflicts.catalog_row(), "operations": [operation.catalog_row() for operation in self.operations], - "owner_adapters": [adapter.catalog_row() for adapter in self.owner_adapters], + "owner_adapters": [ + adapter.catalog_row() for adapter in self.owner_adapters + ], } def _string_list(value: Any, field: str) -> tuple[str, ...]: - if not isinstance(value, list) or not value or any(not isinstance(item, str) or not item for item in value): - raise ProjectConfigError(f"{field} must be a non-empty list of non-empty strings") + if ( + not isinstance(value, list) + or not value + or any(not isinstance(item, str) or not item for item in value) + ): + raise ProjectConfigError( + f"{field} must be a non-empty list of non-empty strings" + ) return tuple(value) def _optional_string_list(value: Any, field: str) -> tuple[str, ...]: if value is None: return () - if not isinstance(value, list) or any(not isinstance(item, str) or not item for item in value): + if not isinstance(value, list) or any( + not isinstance(item, str) or not item for item in value + ): raise ProjectConfigError(f"{field} must be a list of non-empty strings") return tuple(value) @@ -483,15 +548,30 @@ def _operation_parameters(value: Any, field: str) -> tuple[OperationParameter, . raise ProjectConfigError(f"{field} must be a bounded table") parameters: list[OperationParameter] = [] for name, definition in value.items(): - if not isinstance(name, str) or not name.isidentifier() or not isinstance(definition, Mapping): - raise ProjectConfigError(f"{field} contains an invalid parameter declaration") + if ( + not isinstance(name, str) + or not name.isidentifier() + or not isinstance(definition, Mapping) + ): + raise ProjectConfigError( + f"{field} contains an invalid parameter declaration" + ) kind = definition.get("type") - if kind not in {"bool", "string", "enum", "integer", "string-list", "enum-list"}: + if kind not in { + "bool", + "string", + "enum", + "integer", + "string-list", + "enum-list", + }: raise ProjectConfigError(f"{field}.{name} has an invalid type") has_flag = "flag" in definition has_position = "position" in definition if has_flag == has_position: - raise ProjectConfigError(f"{field}.{name} must declare exactly one flag or position") + raise ProjectConfigError( + f"{field}.{name} must declare exactly one flag or position" + ) flag: str | None = None position: int | None = None required = False @@ -509,16 +589,22 @@ def _operation_parameters(value: Any, field: str) -> tuple[OperationParameter, . or not 1 <= position <= MAX_OPERATION_PARAMETERS or required is not True ): - raise ProjectConfigError(f"{field}.{name} has an invalid required positional declaration") + raise ProjectConfigError( + f"{field}.{name} has an invalid required positional declaration" + ) mapping_fields = {"flag"} if flag is not None else {"position", "required"} if kind == "bool": if set(definition) != {"type", *mapping_fields}: - raise ProjectConfigError(f"{field}.{name} bool parameters only accept type and flag") + raise ProjectConfigError( + f"{field}.{name} bool parameters only accept type and flag" + ) parameters.append(OperationParameter(name=name, kind=kind, flag=flag)) continue if kind == "integer": if set(definition) != {"type", *mapping_fields, "min", "max"}: - raise ProjectConfigError(f"{field}.{name} integer parameters require min and max") + raise ProjectConfigError( + f"{field}.{name} integer parameters require min and max" + ) minimum = definition.get("min") maximum = definition.get("max") if ( @@ -526,13 +612,21 @@ def _operation_parameters(value: Any, field: str) -> tuple[OperationParameter, . or isinstance(minimum, bool) or not isinstance(maximum, int) or isinstance(maximum, bool) - or not MIN_PARAMETER_INTEGER <= minimum <= maximum <= MAX_PARAMETER_INTEGER + or not MIN_PARAMETER_INTEGER + <= minimum + <= maximum + <= MAX_PARAMETER_INTEGER ): raise ProjectConfigError(f"{field}.{name} has invalid integer bounds") parameters.append( OperationParameter( - name=name, kind=kind, flag=flag, position=position, required=required, - minimum=minimum, maximum=maximum, + name=name, + kind=kind, + flag=flag, + position=position, + required=required, + minimum=minimum, + maximum=maximum, ) ) continue @@ -541,18 +635,28 @@ def _operation_parameters(value: Any, field: str) -> tuple[OperationParameter, . if kind == "enum-list": allowed.add("max_items") if set(definition) != allowed: - raise ProjectConfigError(f"{field}.{name} {kind} parameters require declared values and bounds") + raise ProjectConfigError( + f"{field}.{name} {kind} parameters require declared values and bounds" + ) values = definition.get("values") - if not isinstance(values, list) or not 1 <= len(values) <= MAX_PARAMETER_ENUM_VALUES: - raise ProjectConfigError(f"{field}.{name} must declare bounded enum values") + if ( + not isinstance(values, list) + or not 1 <= len(values) <= MAX_PARAMETER_ENUM_VALUES + ): + raise ProjectConfigError( + f"{field}.{name} must declare bounded enum values" + ) if any( not isinstance(item, str) or not item or len(item) > MAX_PARAMETER_STRING_LENGTH - or _PARAMETER_GRAMMARS[DEFAULT_PARAMETER_GRAMMAR].fullmatch(item) is None + or _PARAMETER_GRAMMARS[DEFAULT_PARAMETER_GRAMMAR].fullmatch(item) + is None for item in values ) or len(set(values)) != len(values): - raise ProjectConfigError(f"{field}.{name} has invalid or duplicate enum values") + raise ProjectConfigError( + f"{field}.{name} has invalid or duplicate enum values" + ) max_items = None if kind == "enum-list": max_items = definition.get("max_items") @@ -576,7 +680,9 @@ def _operation_parameters(value: Any, field: str) -> tuple[OperationParameter, . if kind == "string-list": allowed.add("max_items") if set(definition) != allowed - {"grammar"} and set(definition) != allowed: - raise ProjectConfigError(f"{field}.{name} {kind} parameters require explicit bounds") + raise ProjectConfigError( + f"{field}.{name} {kind} parameters require explicit bounds" + ) max_length = definition.get("max_length") grammar = definition.get("grammar", DEFAULT_PARAMETER_GRAMMAR) if ( @@ -605,19 +711,31 @@ def _operation_parameters(value: Any, field: str) -> tuple[OperationParameter, . flags = [parameter.flag for parameter in parameters if parameter.flag is not None] if len(set(flags)) != len(flags): raise ProjectConfigError(f"{field} parameter flags must be unique") - positions = [parameter.position for parameter in parameters if parameter.position is not None] + positions = [ + parameter.position for parameter in parameters if parameter.position is not None + ] if len(set(positions)) != len(positions): - raise ProjectConfigError(f"{field} positional parameter positions must be unique") + raise ProjectConfigError( + f"{field} positional parameter positions must be unique" + ) if positions and set(positions) != set(range(1, len(positions) + 1)): - raise ProjectConfigError(f"{field} positional parameter positions must be contiguous from 1") + raise ProjectConfigError( + f"{field} positional parameter positions must be contiguous from 1" + ) return tuple(parameters) def _operation_service(value: Any, field: str) -> OperationService | None: if value is None: return None - if not isinstance(value, Mapping) or set(value) != {"readiness", "lifetime", "ports"}: - raise ProjectConfigError(f"{field} must contain only readiness, lifetime, and ports") + if not isinstance(value, Mapping) or set(value) != { + "readiness", + "lifetime", + "ports", + }: + raise ProjectConfigError( + f"{field} must contain only readiness, lifetime, and ports" + ) readiness = value.get("readiness") lifetime = value.get("lifetime") ports = value.get("ports") @@ -649,29 +767,49 @@ def _operation_service(value: Any, field: str) -> OperationService | None: if ( not isinstance(port_range, list) or len(port_range) != 2 - or any(not isinstance(port, int) or isinstance(port, bool) for port in port_range) + or any( + not isinstance(port, int) or isinstance(port, bool) + for port in port_range + ) ): raise ProjectConfigError(f"{field}.ports.{name}.range is invalid") minimum, maximum = port_range - if not 1024 <= minimum <= maximum <= 65535 or maximum - minimum + 1 > MAX_SERVICE_PORT_RANGE: + if ( + not 1024 <= minimum <= maximum <= 65535 + or maximum - minimum + 1 > MAX_SERVICE_PORT_RANGE + ): raise ProjectConfigError(f"{field}.ports.{name}.range is invalid") environments.add(environment) - slots.append(ServicePortSlot(name=name, environment=environment, minimum=minimum, maximum=maximum)) + slots.append( + ServicePortSlot( + name=name, environment=environment, minimum=minimum, maximum=maximum + ) + ) return OperationService(readiness=readiness, lifetime=lifetime, ports=tuple(slots)) def _bounded_parameter_count(value: Any, maximum: int) -> bool: - return isinstance(value, int) and not isinstance(value, bool) and 1 <= value <= maximum + return ( + isinstance(value, int) and not isinstance(value, bool) and 1 <= value <= maximum + ) -def _owner_adapters(raw: Mapping[str, Any], descriptor: Path) -> tuple[ProjectOwnerAdapter, ...]: +def _owner_adapters( + raw: Mapping[str, Any], descriptor: Path +) -> tuple[ProjectOwnerAdapter, ...]: definitions = raw.get("owner_adapters", {}) if not isinstance(definitions, Mapping): raise ProjectConfigError(f"{descriptor} [owner_adapters] must be a table") adapters: list[ProjectOwnerAdapter] = [] for name, definition in sorted(definitions.items()): - if not isinstance(name, str) or not name.isidentifier() or not isinstance(definition, Mapping): - raise ProjectConfigError(f"{descriptor} contains an invalid owner adapter declaration") + if ( + not isinstance(name, str) + or not name.isidentifier() + or not isinstance(definition, Mapping) + ): + raise ProjectConfigError( + f"{descriptor} contains an invalid owner adapter declaration" + ) allowed = { "namespace", "owner", @@ -691,21 +829,42 @@ def _owner_adapters(raw: Mapping[str, Any], descriptor: Path) -> tuple[ProjectOw documentation = definition.get("documentation", "") versions = definition.get("protocol_versions") if not isinstance(namespace, str) or not isinstance(owner, str): - raise ProjectConfigError(f"owner_adapters.{name} requires namespace and owner") + raise ProjectConfigError( + f"owner_adapters.{name} requires namespace and owner" + ) if not isinstance(documentation, str): - raise ProjectConfigError(f"owner_adapters.{name}.documentation must be a string") - if not isinstance(versions, list) or not versions or any( - not isinstance(version, int) or isinstance(version, bool) for version in versions + raise ProjectConfigError( + f"owner_adapters.{name}.documentation must be a string" + ) + if ( + not isinstance(versions, list) + or not versions + or any( + not isinstance(version, int) or isinstance(version, bool) + for version in versions + ) ): - raise ProjectConfigError(f"owner_adapters.{name}.protocol_versions must be non-empty integers") + raise ProjectConfigError( + f"owner_adapters.{name}.protocol_versions must be non-empty integers" + ) if definition.get("source_scoped") is not True: - raise ProjectConfigError(f"owner_adapters.{name} must declare source_scoped = true") + raise ProjectConfigError( + f"owner_adapters.{name} must declare source_scoped = true" + ) source_ref = definition.get("source_ref") if not isinstance(source_ref, str): - raise ProjectConfigError(f"owner_adapters.{name}.source_ref must be a string") + raise ProjectConfigError( + f"owner_adapters.{name}.source_ref must be a string" + ) timeout_seconds = definition.get("timeout_seconds", 30) - if not isinstance(timeout_seconds, int) or isinstance(timeout_seconds, bool) or not 1 <= timeout_seconds <= 300: - raise ProjectConfigError(f"owner_adapters.{name}.timeout_seconds must be between 1 and 300") + if ( + not isinstance(timeout_seconds, int) + or isinstance(timeout_seconds, bool) + or not 1 <= timeout_seconds <= 300 + ): + raise ProjectConfigError( + f"owner_adapters.{name}.timeout_seconds must be between 1 and 300" + ) try: spec = OwnerSpec( namespace=namespace, @@ -718,11 +877,15 @@ def _owner_adapters(raw: Mapping[str, Any], descriptor: Path) -> tuple[ProjectOw ) parsed_source_ref = SinnixRef.parse(source_ref) except (TypeError, ValueError) as error: - raise ProjectConfigError(f"owner_adapters.{name} is invalid: {error}") from error + raise ProjectConfigError( + f"owner_adapters.{name} is invalid: {error}" + ) from error adapters.append( ProjectOwnerAdapter( spec=spec, - command=_string_list(definition.get("exec"), f"owner_adapters.{name}.exec"), + command=_string_list( + definition.get("exec"), f"owner_adapters.{name}.exec" + ), source_ref=parsed_source_ref, timeout_seconds=timeout_seconds, ) @@ -730,7 +893,9 @@ def _owner_adapters(raw: Mapping[str, Any], descriptor: Path) -> tuple[ProjectOw try: OwnerRegistry(adapter.spec for adapter in adapters) except ValueError as error: - raise ProjectConfigError(f"{descriptor} owner adapters overlap: {error}") from error + raise ProjectConfigError( + f"{descriptor} owner adapters overlap: {error}" + ) from error return tuple(adapters) @@ -744,7 +909,9 @@ def load_project_adapter(root: Path) -> ProjectAdapter: try: raw = tomllib.loads(raw_bytes.decode()) except (UnicodeDecodeError, tomllib.TOMLDecodeError) as error: - raise ProjectConfigError(f"invalid project adapter {descriptor}: {error}") from error + raise ProjectConfigError( + f"invalid project adapter {descriptor}: {error}" + ) from error if raw.get("schema") != 1: raise ProjectConfigError(f"{descriptor} must declare schema = 1") project = raw.get("project") @@ -759,7 +926,9 @@ def load_project_adapter(root: Path) -> ProjectAdapter: markers = _string_list(project.get("root_markers"), "project.root_markers") missing_markers = [marker for marker in markers if not (root / marker).exists()] if missing_markers: - raise ProjectConfigError(f"{descriptor} root marker(s) missing: {', '.join(missing_markers)}") + raise ProjectConfigError( + f"{descriptor} root marker(s) missing: {', '.join(missing_markers)}" + ) environment = raw.get("environment") if not isinstance(environment, Mapping): @@ -770,7 +939,9 @@ def load_project_adapter(root: Path) -> ProjectAdapter: execution_environment = ProjectEnvironment( kind=environment_kind, command=_string_list(environment.get("command"), "environment.command"), - inherit=_optional_string_list(environment.get("inherit"), "environment.inherit"), + inherit=_optional_string_list( + environment.get("inherit"), "environment.inherit" + ), unset=_optional_string_list(environment.get("unset"), "environment.unset"), ) @@ -780,45 +951,69 @@ def load_project_adapter(root: Path) -> ProjectAdapter: if not isinstance(raw_workspace, Mapping): raise ProjectConfigError(f"{descriptor} [workspace] must be a table") allowed_workspace = { - "provider", "root", "default_base", "identity_check", "checkpoint_untracked", + "provider", + "root", + "default_base", + "identity_check", + "checkpoint_untracked", "verification_operations", } if set(raw_workspace) - allowed_workspace: - raise ProjectConfigError(f"{descriptor} [workspace] contains unknown fields") + raise ProjectConfigError( + f"{descriptor} [workspace] contains unknown fields" + ) provider = raw_workspace.get("provider") workspace_root = raw_workspace.get("root") default_base = raw_workspace.get("default_base") checkpoint_untracked = raw_workspace.get("checkpoint_untracked") if provider != "git-worktree": - raise ProjectConfigError(f"{descriptor} workspace.provider must be git-worktree") - if not isinstance(workspace_root, str) or not Path(workspace_root).is_absolute(): - raise ProjectConfigError(f"{descriptor} workspace.root must be an absolute path") + raise ProjectConfigError( + f"{descriptor} workspace.provider must be git-worktree" + ) + if ( + not isinstance(workspace_root, str) + or not Path(workspace_root).is_absolute() + ): + raise ProjectConfigError( + f"{descriptor} workspace.root must be an absolute path" + ) if not isinstance(default_base, str) or not default_base: - raise ProjectConfigError(f"{descriptor} workspace.default_base must be non-empty") + raise ProjectConfigError( + f"{descriptor} workspace.default_base must be non-empty" + ) if not isinstance(checkpoint_untracked, bool): - raise ProjectConfigError(f"{descriptor} workspace.checkpoint_untracked must be boolean") + raise ProjectConfigError( + f"{descriptor} workspace.checkpoint_untracked must be boolean" + ) workspace = WorkspacePolicy( provider=provider, root=Path(workspace_root), default_base=default_base, - identity_check=_string_list(raw_workspace.get("identity_check"), "workspace.identity_check"), + identity_check=_string_list( + raw_workspace.get("identity_check"), "workspace.identity_check" + ), checkpoint_untracked=checkpoint_untracked, verification_operations=_optional_string_list( - raw_workspace.get("verification_operations"), "workspace.verification_operations" + raw_workspace.get("verification_operations"), + "workspace.verification_operations", ), ) raw_conflicts = raw.get("conflicts", {}) if not isinstance(raw_conflicts, Mapping) or set(raw_conflicts) - { - "exact_files", "generated_surfaces", "semantic_slots" + "exact_files", + "generated_surfaces", + "semantic_slots", }: raise ProjectConfigError(f"{descriptor} [conflicts] is invalid") raw_semantic_slots = raw_conflicts.get("semantic_slots", {}) if isinstance(raw_semantic_slots, list): - semantic_slots = { - name: () for name in _optional_string_list(raw_semantic_slots, "conflicts.semantic_slots") - } - elif isinstance(raw_semantic_slots, Mapping) and all(isinstance(name, str) and name for name in raw_semantic_slots): + semantic_slots = dict.fromkeys( + _optional_string_list(raw_semantic_slots, "conflicts.semantic_slots"), () + ) + elif isinstance(raw_semantic_slots, Mapping) and all( + isinstance(name, str) and name for name in raw_semantic_slots + ): semantic_slots = { name: _string_list(paths, f"conflicts.semantic_slots.{name}") for name, paths in sorted(raw_semantic_slots.items()) @@ -826,7 +1021,9 @@ def load_project_adapter(root: Path) -> ProjectAdapter: else: raise ProjectConfigError(f"{descriptor} conflicts.semantic_slots is invalid") conflicts = ConflictPolicy( - exact_files=_optional_string_list(raw_conflicts.get("exact_files"), "conflicts.exact_files"), + exact_files=_optional_string_list( + raw_conflicts.get("exact_files"), "conflicts.exact_files" + ), generated_surfaces=_optional_string_list( raw_conflicts.get("generated_surfaces"), "conflicts.generated_surfaces" ), @@ -839,17 +1036,37 @@ def load_project_adapter(root: Path) -> ProjectAdapter: raise ProjectConfigError(f"{descriptor} [operations] must be a table") operations: list[ProjectOperation] = [] for name, definition in sorted(raw_operations.items()): - if not isinstance(name, str) or not name.isidentifier() or not isinstance(definition, Mapping): - raise ProjectConfigError(f"{descriptor} contains an invalid operation declaration") + if ( + not isinstance(name, str) + or not name.isidentifier() + or not isinstance(definition, Mapping) + ): + raise ProjectConfigError( + f"{descriptor} contains an invalid operation declaration" + ) allowed_operation = { - "description", "exec", "pool", "result", "cache", "exclusive_keys", - "dependencies", "estimate_memory_bytes", "scratch", "parameters", "timeout_seconds", "service", + "description", + "exec", + "pool", + "result", + "cache", + "exclusive_keys", + "dependencies", + "estimate_memory_bytes", + "scratch", + "parameters", + "timeout_seconds", + "service", } if set(definition) - allowed_operation: - raise ProjectConfigError(f"{descriptor} operation {name} contains unknown fields") + raise ProjectConfigError( + f"{descriptor} operation {name} contains unknown fields" + ) description = definition.get("description") if not isinstance(description, str) or not description: - raise ProjectConfigError(f"{descriptor} operation {name} requires description") + raise ProjectConfigError( + f"{descriptor} operation {name} requires description" + ) command = _string_list(definition.get("exec"), f"operations.{name}.exec") pool = definition.get("pool", "normal") result = definition.get("result", "exit") @@ -868,7 +1085,9 @@ def load_project_adapter(root: Path) -> ProjectAdapter: f"operations.{name}.timeout_seconds must be between 1 and " f"{MAX_DECLARED_OPERATION_TIMEOUT_SECONDS}" ) - dependencies = _optional_string_list(definition.get("dependencies"), f"operations.{name}.dependencies") + dependencies = _optional_string_list( + definition.get("dependencies"), f"operations.{name}.dependencies" + ) if name in dependencies or len(set(dependencies)) != len(dependencies): raise ProjectConfigError(f"operations.{name}.dependencies is invalid") estimate_memory_bytes = definition.get("estimate_memory_bytes") @@ -877,11 +1096,15 @@ def load_project_adapter(root: Path) -> ProjectAdapter: or isinstance(estimate_memory_bytes, bool) or not 1 <= estimate_memory_bytes <= 128 * 1024 * 1024 * 1024 ): - raise ProjectConfigError(f"operations.{name}.estimate_memory_bytes is invalid") + raise ProjectConfigError( + f"operations.{name}.estimate_memory_bytes is invalid" + ) scratch = definition.get("scratch", "none") if scratch not in {"none", "tmpfs", "nvme"}: raise ProjectConfigError(f"operations.{name}.scratch is invalid") - service = _operation_service(definition.get("service"), f"operations.{name}.service") + service = _operation_service( + definition.get("service"), f"operations.{name}.service" + ) operations.append( ProjectOperation( name=name, @@ -892,12 +1115,15 @@ def load_project_adapter(root: Path) -> ProjectAdapter: cache=cache, timeout_seconds=timeout_seconds, exclusive_keys=_optional_string_list( - definition.get("exclusive_keys"), f"operations.{name}.exclusive_keys" + definition.get("exclusive_keys"), + f"operations.{name}.exclusive_keys", ), dependencies=dependencies, estimate_memory_bytes=estimate_memory_bytes, scratch=scratch, - parameters=_operation_parameters(definition.get("parameters"), f"operations.{name}.parameters"), + parameters=_operation_parameters( + definition.get("parameters"), f"operations.{name}.parameters" + ), service=service, ) ) @@ -910,7 +1136,8 @@ def load_project_adapter(root: Path) -> ProjectAdapter: } if unknown_dependencies: raise ProjectConfigError( - f"{descriptor} operation dependency/dependencies are undeclared: " + ", ".join(sorted(unknown_dependencies)) + f"{descriptor} operation dependency/dependencies are undeclared: " + + ", ".join(sorted(unknown_dependencies)) ) required_parameter_operations = { operation.name @@ -970,7 +1197,10 @@ def __init__(self, roots: Iterable[Path]) -> None: self._adapters = by_id def list(self) -> list[dict[str, Any]]: - return [self._adapters[project_id].catalog_row() for project_id in sorted(self._adapters)] + return [ + self._adapters[project_id].catalog_row() + for project_id in sorted(self._adapters) + ] def get(self, project_id: str) -> ProjectAdapter: try: @@ -989,20 +1219,30 @@ def _checkout_id(path: Path, configured_root: Path) -> str: def checkouts(self, project_id: str) -> tuple[RegisteredCheckout, ...]: project = self.get(project_id) root = project.root.resolve(strict=True) - records = parse_worktree_records(self._git(root, "worktree", "list", "--porcelain")) + records = parse_worktree_records( + self._git(root, "worktree", "list", "--porcelain") + ) checkouts: list[RegisteredCheckout] = [] for record in records: raw_path = record.get("worktree") head = record.get("HEAD") if raw_path is None or head is None: - raise ProjectConfigError("git worktree record is missing worktree or HEAD") + raise ProjectConfigError( + "git worktree record is missing worktree or HEAD" + ) path = Path(raw_path).resolve(strict=True) - top_level = Path(self._git(path, "rev-parse", "--show-toplevel").strip()).resolve(strict=True) + top_level = Path( + self._git(path, "rev-parse", "--show-toplevel").strip() + ).resolve(strict=True) common_dir = Path( - self._git(path, "rev-parse", "--path-format=absolute", "--git-common-dir").strip() + self._git( + path, "rev-parse", "--path-format=absolute", "--git-common-dir" + ).strip() ).resolve(strict=True) if top_level != path: - raise ProjectConfigError("registered checkout has a non-canonical worktree root") + raise ProjectConfigError( + "registered checkout has a non-canonical worktree root" + ) checkouts.append( RegisteredCheckout( project_id=project.project_id, @@ -1013,9 +1253,22 @@ def checkouts(self, project_id: str) -> tuple[RegisteredCheckout, ...]: head=head, ) ) - if not any(checkout.checkout_id == "default" and checkout.path == root for checkout in checkouts): - raise ProjectConfigError("configured project root is not a registered Git worktree") - return tuple(sorted(checkouts, key=lambda checkout: (checkout.checkout_id != "default", checkout.checkout_id))) + if not any( + checkout.checkout_id == "default" and checkout.path == root + for checkout in checkouts + ): + raise ProjectConfigError( + "configured project root is not a registered Git worktree" + ) + return tuple( + sorted( + checkouts, + key=lambda checkout: ( + checkout.checkout_id != "default", + checkout.checkout_id, + ), + ) + ) def checkout(self, project_id: str, checkout_id: str) -> RegisteredCheckout: if not isinstance(checkout_id, str) or not checkout_id: @@ -1034,10 +1287,14 @@ def owner_adapters(self) -> tuple[ProjectOwnerAdapter, ...]: try: OwnerRegistry(adapter.spec for adapter in adapters) except ValueError as error: - raise ProjectConfigError(f"project owner adapters overlap: {error}") from error + raise ProjectConfigError( + f"project owner adapters overlap: {error}" + ) from error return adapters - def owner_adapter(self, operation: str) -> tuple[ProjectAdapter, ProjectOwnerAdapter]: + def owner_adapter( + self, operation: str + ) -> tuple[ProjectAdapter, ProjectOwnerAdapter]: registry = OwnerRegistry(adapter.spec for adapter in self.owner_adapters()) spec = registry.resolve(operation) for project in self._adapters.values(): diff --git a/pkgs/sinnixd/sinnixd/runner.py b/pkgs/sinnixd/sinnixd/runner.py index 78172fce..7c321599 100644 --- a/pkgs/sinnixd/sinnixd/runner.py +++ b/pkgs/sinnixd/sinnixd/runner.py @@ -8,12 +8,12 @@ from typing import Any, Mapping, Sequence from .jobs import ( + MAX_RESULT_BYTES, GenericJobStore, JobRecordError, - MAX_RESULT_BYTES, _open_preallocated_private_artifact, ) -from .limits import maximum_timeout_seconds, valid_timeout_seconds +from .limits import valid_timeout_seconds from .projects import ProjectConfigError, revalidate_registered_checkout @@ -22,7 +22,9 @@ class RunnerError(ValueError): def _require_strings(value: Mapping[str, Any], fields: Sequence[str]) -> None: - if any(not isinstance(value.get(field), str) or not value[field] for field in fields): + if any( + not isinstance(value.get(field), str) or not value[field] for field in fields + ): raise RunnerError("private typed-job input is invalid") @@ -31,7 +33,11 @@ def _load(path: Path, job_id: str) -> dict[str, Any]: value = json.loads(path.read_text()) except (OSError, json.JSONDecodeError) as error: raise RunnerError("private typed-job input is unavailable") from error - if not isinstance(value, dict) or value.get("schema_version") != 1 or value.get("job_id") != job_id: + if ( + not isinstance(value, dict) + or value.get("schema_version") != 1 + or value.get("job_id") != job_id + ): raise RunnerError("private typed-job identity is invalid") if value.get("kind") not in {"operator-shell", "attested-agent"}: raise RunnerError("private typed-job kind is invalid") @@ -65,9 +71,14 @@ def _require_environment(job_id: str, unit: str, value: Mapping[str, Any]) -> No "SINNIXD_TIMEOUT_SECONDS": os.environ.get("SINNIXD_TIMEOUT_SECONDS", ""), } timeout_seconds = expected["SINNIXD_TIMEOUT_SECONDS"] - if not timeout_seconds.isdecimal() or not valid_timeout_seconds(int(timeout_seconds), kind=value["kind"]): + if not timeout_seconds.isdecimal() or not valid_timeout_seconds( + int(timeout_seconds), kind=value["kind"] + ): raise RunnerError("typed-job timeout identity is invalid") - if any(os.environ.get(key) != expected_value for key, expected_value in expected.items()): + if any( + os.environ.get(key) != expected_value + for key, expected_value in expected.items() + ): raise RunnerError("typed-job environment identity is invalid") if any(key.startswith("SINNIX") and key not in expected for key in os.environ): raise RunnerError("typed-job environment contains an untrusted SINNIX identity") @@ -98,7 +109,10 @@ def _run_declared(state_root: Path, job_id: str, unit: str) -> None: "SINNIXD_CHECKOUT_ID": checkout.get("checkout_id"), "SINNIXD_CHECKOUT_HEAD": checkout.get("head"), } - if any(not isinstance(value, str) or os.environ.get(key) != value for key, value in expected.items()): + if any( + not isinstance(value, str) or os.environ.get(key) != value + for key, value in expected.items() + ): raise RunnerError("declared-job environment identity is invalid") checkout_path = _revalidate_checkout(checkout) os.chdir(checkout_path) @@ -109,18 +123,25 @@ def _exec_shell(value: Mapping[str, Any], checkout: Path) -> None: argv = value.get("argv") environment_command = value.get("environment_command") cwd = value.get("cwd") - if value.get("principal") != "operator" or not isinstance(argv, list) or not argv or any( - not isinstance(item, str) or not item for item in argv + if ( + value.get("principal") != "operator" + or not isinstance(argv, list) + or not argv + or any(not isinstance(item, str) or not item for item in argv) ): raise RunnerError("operator shell contract is invalid") - if not isinstance(environment_command, list) or not environment_command or any( - not isinstance(item, str) or not item for item in environment_command + if ( + not isinstance(environment_command, list) + or not environment_command + or any(not isinstance(item, str) or not item for item in environment_command) ): raise RunnerError("operator shell project environment is invalid") if not isinstance(cwd, str): raise RunnerError("operator shell cwd is invalid") workdir = Path(cwd).resolve(strict=True) - if not workdir.is_dir() or (workdir != checkout and checkout not in workdir.parents): + if not workdir.is_dir() or ( + workdir != checkout and checkout not in workdir.parents + ): raise RunnerError("operator shell cwd escaped the registered checkout") command = [*environment_command, *argv] os.chdir(workdir) @@ -134,14 +155,29 @@ def _run_agent( native_runner: Path, state_root: Path, ) -> int: - _require_strings(value, ("backend", "model", "effort", "credential_profile", "prompt_path", "result_path")) - if value.get("principal") not in {"agent-control", "operator"} or value["backend"] not in {"claude", "codex", "gemini", "grok", "antigravity"}: + _require_strings( + value, + ( + "backend", + "model", + "effort", + "credential_profile", + "prompt_path", + "result_path", + ), + ) + if value.get("principal") not in {"agent-control", "operator"} or value[ + "backend" + ] not in {"claude", "codex", "gemini", "grok", "antigravity"}: raise RunnerError("attested agent contract is invalid") if value["credential_profile"] not in {"subscription", "api"}: raise RunnerError("attested agent credential profile is invalid") prompt_path = Path(value["prompt_path"]).resolve(strict=True) result_path = Path(value["result_path"]).resolve() - if not prompt_path.is_file() or (state_root / "inputs").resolve() not in prompt_path.parents: + if ( + not prompt_path.is_file() + or (state_root / "inputs").resolve() not in prompt_path.parents + ): raise RunnerError("attested agent prompt input is invalid") if (state_root / "results").resolve() not in result_path.parents: raise RunnerError("attested agent result artifact is invalid") @@ -176,7 +212,9 @@ def _run_agent( prompt_path.unlink(missing_ok=True) -def _seal_packet_result(value: Mapping[str, Any], checkout: Path, result_path: Path) -> None: +def _seal_packet_result( + value: Mapping[str, Any], checkout: Path, result_path: Path +) -> None: """Bind a structured worker report to the runtime-observed terminal Git head.""" try: if result_path.stat().st_size > MAX_RESULT_BYTES: @@ -193,7 +231,11 @@ def _seal_packet_result(value: Mapping[str, Any], checkout: Path, result_path: P check=False, ) final_head = observed.stdout.strip() - if observed.returncode != 0 or len(final_head) != 40 or any(value not in "0123456789abcdef" for value in final_head): + if ( + observed.returncode != 0 + or len(final_head) != 40 + or any(value not in "0123456789abcdef" for value in final_head) + ): raise RunnerError("packet final Git head is unavailable") envelope = json.dumps( { diff --git a/pkgs/sinnixd/sinnixd/service.py b/pkgs/sinnixd/sinnixd/service.py index 34d795f5..8a1d1a1a 100644 --- a/pkgs/sinnixd/sinnixd/service.py +++ b/pkgs/sinnixd/sinnixd/service.py @@ -17,9 +17,19 @@ ) from sinnix_mcp.execution import OwnerExecution -from .jobs import GenericJobStore, GenericJobs, JobPageCursorError, JobRecordError, JobResultError, JobResultLimitError, SystemdJobError, UserSystemdJobs, default_state_dir from .contracts import TypedJobContracts from .delivery import DeliveryError, GitHubDelivery +from .jobs import ( + GenericJobs, + GenericJobStore, + JobPageCursorError, + JobRecordError, + JobResultError, + JobResultLimitError, + SystemdJobError, + UserSystemdJobs, + default_state_dir, +) from .owner_adapters import DeclaredOwnerAdapters, OwnerAdapterError from .projects import ProjectCatalog from .tasks import TaskError, TaskService @@ -41,25 +51,39 @@ class SinnixdService: projects: ProjectCatalog jobs: GenericJobs = field( - default_factory=lambda: GenericJobs(UserSystemdJobs(), GenericJobStore(default_state_dir())) + default_factory=lambda: GenericJobs( + UserSystemdJobs(), GenericJobStore(default_state_dir()) + ) ) owner_adapters: DeclaredOwnerAdapters = field( default_factory=lambda: DeclaredOwnerAdapters(OwnerExecution()) ) version: str = "0.2.0" - native_runner: Path = Path("/home/sinity/.config/hermes/skills/agent-runtime/scripts/run_agent_prompt.sh") + native_runner: Path = Path( + "/home/sinity/.config/hermes/skills/agent-runtime/scripts/run_agent_prompt.sh" + ) workspaces: GitWorkspaces | None = None delivery: GitHubDelivery | None = None tasks: TaskService | None = None def __post_init__(self) -> None: if self.workspaces is None: - object.__setattr__(self, "workspaces", GitWorkspaces(self.projects, WorkspaceStore(self.jobs.store.root))) + object.__setattr__( + self, + "workspaces", + GitWorkspaces(self.projects, WorkspaceStore(self.jobs.store.root)), + ) if self.delivery is None: assert self.workspaces is not None - object.__setattr__(self, "delivery", GitHubDelivery(self.projects, self.workspaces, self.jobs)) + object.__setattr__( + self, + "delivery", + GitHubDelivery(self.projects, self.workspaces, self.jobs), + ) if self.tasks is None: - object.__setattr__(self, "tasks", TaskService(self.projects, jobs=self.jobs)) + object.__setattr__( + self, "tasks", TaskService(self.projects, jobs=self.jobs) + ) _ = self.owners @property @@ -106,7 +130,9 @@ def owners(self) -> OwnerRegistry: documentation="Backend-neutral AgentCTL task operations through the current task authority.", ), ) - return OwnerRegistry((*builtin, *(adapter.spec for adapter in self.projects.owner_adapters()))) + return OwnerRegistry( + (*builtin, *(adapter.spec for adapter in self.projects.owner_adapters())) + ) def dispatch(self, request: RequestEnvelope) -> ResponseEnvelope: owner_name = "sinnixd" @@ -122,7 +148,9 @@ def dispatch(self, request: RequestEnvelope) -> ResponseEnvelope: ) if owner.source_scoped: project, adapter = self.projects.owner_adapter(request.operation) - return self.owner_adapters.call(project=project, adapter=adapter, request=request) + return self.owner_adapters.call( + project=project, adapter=adapter, request=request + ) payload = self._dispatch( request.operation, request.arguments, @@ -131,7 +159,9 @@ def dispatch(self, request: RequestEnvelope) -> ResponseEnvelope: request.idempotency_key, ) except KeyError as error: - return self._error(request, owner_name, ErrorCode.INVALID_ARGUMENT, str(error)) + return self._error( + request, owner_name, ErrorCode.INVALID_ARGUMENT, str(error) + ) except OwnerAdapterError as error: return self._error( request, @@ -140,25 +170,39 @@ def dispatch(self, request: RequestEnvelope) -> ResponseEnvelope: str(error), ) except JobResultLimitError as error: - return self._error(request, owner_name, ErrorCode.RESOURCE_EXHAUSTED, str(error)) + return self._error( + request, owner_name, ErrorCode.RESOURCE_EXHAUSTED, str(error) + ) except JobResultError as error: - return self._error(request, owner_name, ErrorCode.RESULT_INVALID, str(error)) + return self._error( + request, owner_name, ErrorCode.RESULT_INVALID, str(error) + ) except JobAuthorizationError as error: return self._error(request, owner_name, ErrorCode.POLICY_DENIED, str(error)) except JobPageCursorError as error: - return self._error(request, owner_name, ErrorCode.INVALID_ARGUMENT, str(error)) + return self._error( + request, owner_name, ErrorCode.INVALID_ARGUMENT, str(error) + ) except (JobRecordError, SystemdJobError) as error: - return self._error(request, owner_name, ErrorCode.OPERATION_FAILED, str(error)) + return self._error( + request, owner_name, ErrorCode.OPERATION_FAILED, str(error) + ) except (WorkspaceError, DeliveryError) as error: - return self._error(request, owner_name, ErrorCode.INVALID_ARGUMENT, str(error)) + return self._error( + request, owner_name, ErrorCode.INVALID_ARGUMENT, str(error) + ) except TaskError as error: return self._error(request, owner_name, error.code, str(error)) except ValueError as error: - return self._error(request, owner_name, ErrorCode.INVALID_ARGUMENT, str(error)) + return self._error( + request, owner_name, ErrorCode.INVALID_ARGUMENT, str(error) + ) try: bounded_payload = OpaquePayload.bounded(payload) except ValueError as error: - return self._error(request, owner_name, ErrorCode.RESOURCE_EXHAUSTED, str(error)) + return self._error( + request, owner_name, ErrorCode.RESOURCE_EXHAUSTED, str(error) + ) return ResponseEnvelope( request_id=request.request_id, correlation_id=request.correlation_id, @@ -195,7 +239,9 @@ def _dispatch( return { "project_id": project.project_id, "descriptor_status": project.descriptor_status(), - "operations": [operation.catalog_row() for operation in project.operations], + "operations": [ + operation.catalog_row() for operation in project.operations + ], } if operation.startswith("task."): assert self.tasks is not None @@ -209,19 +255,27 @@ def _dispatch( if set(arguments) - {"project_id"}: raise ValueError("workspace.list accepts optional project_id") project_id = arguments.get("project_id") - if project_id is not None and (not isinstance(project_id, str) or not project_id): + if project_id is not None and ( + not isinstance(project_id, str) or not project_id + ): raise ValueError("workspace.list project_id must be non-empty") assert self.workspaces is not None return self.workspaces.list(project_id) if operation == "workspace.get": assert self.workspaces is not None - return self.workspaces.get(self._single_workspace_id(arguments, "workspace.get")) + return self.workspaces.get( + self._single_workspace_id(arguments, "workspace.get") + ) if operation == "workspace.create": if principal not in {"agent-control", "operator"}: - raise ValueError("workspace creation requires agent-control or operator principal") + raise ValueError( + "workspace creation requires agent-control or operator principal" + ) required = {"project_id", "name", "branch", "base"} if set(arguments) != required: - raise ValueError("workspace.create requires project_id, name, branch, and nullable base") + raise ValueError( + "workspace.create requires project_id, name, branch, and nullable base" + ) base = arguments.get("base") if base is not None and (not isinstance(base, str) or not base): raise ValueError("workspace.create base must be null or non-empty") @@ -234,10 +288,14 @@ def _dispatch( ) if operation == "workspace.adopt": if principal not in {"agent-control", "operator"}: - raise ValueError("workspace adoption requires agent-control or operator principal") + raise ValueError( + "workspace adoption requires agent-control or operator principal" + ) required = {"project_id", "checkout_id", "name"} if set(arguments) != required: - raise ValueError("workspace.adopt requires project_id, checkout_id, and name") + raise ValueError( + "workspace.adopt requires project_id, checkout_id, and name" + ) assert self.workspaces is not None return self.workspaces.adopt( project_id=self._job_argument(arguments, "project_id"), @@ -246,24 +304,40 @@ def _dispatch( ) if operation == "workspace.reap": if principal not in {"agent-control", "operator"}: - raise ValueError("workspace reap requires agent-control or operator principal") + raise ValueError( + "workspace reap requires agent-control or operator principal" + ) assert self.workspaces is not None - return self.workspaces.reap(self._single_workspace_id(arguments, "workspace.reap")) + return self.workspaces.reap( + self._single_workspace_id(arguments, "workspace.reap") + ) if operation == "workspace.dispose": if principal not in {"agent-control", "operator"}: - raise ValueError("workspace disposal requires agent-control or operator principal") + raise ValueError( + "workspace disposal requires agent-control or operator principal" + ) assert self.workspaces is not None - return self.workspaces.dispose(self._single_workspace_id(arguments, "workspace.dispose")) + return self.workspaces.dispose( + self._single_workspace_id(arguments, "workspace.dispose") + ) if operation == "workspace.checkpoint": if principal not in {"agent-control", "operator"}: - raise ValueError("workspace checkpoint requires agent-control or operator principal") + raise ValueError( + "workspace checkpoint requires agent-control or operator principal" + ) assert self.workspaces is not None - return self.workspaces.checkpoint(self._single_workspace_id(arguments, "workspace.checkpoint")) + return self.workspaces.checkpoint( + self._single_workspace_id(arguments, "workspace.checkpoint") + ) if operation == "workspace.restore": if principal not in {"agent-control", "operator"}: - raise ValueError("workspace restore requires agent-control or operator principal") + raise ValueError( + "workspace restore requires agent-control or operator principal" + ) if set(arguments) != {"workspace_id", "checkpoint_id"}: - raise ValueError("workspace.restore requires workspace_id and checkpoint_id") + raise ValueError( + "workspace.restore requires workspace_id and checkpoint_id" + ) assert self.workspaces is not None return self.workspaces.restore( self._job_argument(arguments, "workspace_id"), @@ -271,9 +345,13 @@ def _dispatch( ) if operation == "workspace.recover": if principal not in {"agent-control", "operator"}: - raise ValueError("workspace recovery requires agent-control or operator principal") + raise ValueError( + "workspace recovery requires agent-control or operator principal" + ) if set(arguments) != {"workspace_id", "checkpoint_id"}: - raise ValueError("workspace.recover requires workspace_id and checkpoint_id") + raise ValueError( + "workspace.recover requires workspace_id and checkpoint_id" + ) assert self.workspaces is not None return self.workspaces.recover( self._job_argument(arguments, "workspace_id"), @@ -281,27 +359,45 @@ def _dispatch( ) if operation == "workspace.stack": if principal not in {"agent-control", "operator"}: - raise ValueError("workspace stacking requires agent-control or operator principal") + raise ValueError( + "workspace stacking requires agent-control or operator principal" + ) if set(arguments) != {"parent_workspace_id", "name", "branch"}: - raise ValueError("workspace.stack requires parent_workspace_id, name, and branch") + raise ValueError( + "workspace.stack requires parent_workspace_id, name, and branch" + ) assert self.workspaces is not None return self.workspaces.stack( - parent_workspace_id=self._job_argument(arguments, "parent_workspace_id"), + parent_workspace_id=self._job_argument( + arguments, "parent_workspace_id" + ), name=self._job_argument(arguments, "name"), branch=self._job_argument(arguments, "branch"), ) if operation == "workspace.restack": if principal not in {"agent-control", "operator"}: - raise ValueError("workspace restacking requires agent-control or operator principal") + raise ValueError( + "workspace restacking requires agent-control or operator principal" + ) assert self.workspaces is not None - return self.workspaces.restack(self._single_workspace_id(arguments, "workspace.restack")) + return self.workspaces.restack( + self._single_workspace_id(arguments, "workspace.restack") + ) if operation == "workspace.publish": if principal not in {"agent-control", "operator"}: - raise ValueError("workspace publication requires agent-control or operator principal") - if set(arguments) - {"workspace_id", "job_id", "packet_job_id", "title", "body"} or not { - "workspace_id", "job_id", "title", "body" - } <= set(arguments): - raise ValueError("workspace.publish requires workspace_id, job_id, title, and body") + raise ValueError( + "workspace publication requires agent-control or operator principal" + ) + if set(arguments) - { + "workspace_id", + "job_id", + "packet_job_id", + "title", + "body", + } or not {"workspace_id", "job_id", "title", "body"} <= set(arguments): + raise ValueError( + "workspace.publish requires workspace_id, job_id, title, and body" + ) assert self.delivery is not None publish_arguments = ( self._job_argument(arguments, "workspace_id"), @@ -312,30 +408,54 @@ def _dispatch( packet_job_id = arguments.get("packet_job_id") return self.delivery.publish( *publish_arguments, - **({"packet_job_id": packet_job_id} if isinstance(packet_job_id, str) else {}), + **( + {"packet_job_id": packet_job_id} + if isinstance(packet_job_id, str) + else {} + ), ) if operation == "workspace.review-status": assert self.delivery is not None - return self.delivery.review_status(self._single_workspace_id(arguments, "workspace.review-status")) + return self.delivery.review_status( + self._single_workspace_id(arguments, "workspace.review-status") + ) if operation == "workspace.land": - if principal not in {"agent-control", "operator"} or set(arguments) - { - "workspace_id", "job_id", "packet_job_id" - } or not {"workspace_id", "job_id"} <= set(arguments): - raise ValueError("workspace.land requires agent-control or operator plus workspace_id and job_id") + if ( + principal not in {"agent-control", "operator"} + or set(arguments) - {"workspace_id", "job_id", "packet_job_id"} + or not {"workspace_id", "job_id"} <= set(arguments) + ): + raise ValueError( + "workspace.land requires agent-control or operator plus workspace_id and job_id" + ) assert self.delivery is not None packet_job_id = arguments.get("packet_job_id") return self.delivery.land( - self._job_argument(arguments, "workspace_id"), self._job_argument(arguments, "job_id"), - **({"packet_job_id": packet_job_id} if isinstance(packet_job_id, str) else {}), + self._job_argument(arguments, "workspace_id"), + self._job_argument(arguments, "job_id"), + **( + {"packet_job_id": packet_job_id} + if isinstance(packet_job_id, str) + else {} + ), ) if operation == "workspace.finish": if principal not in {"agent-control", "operator"}: - raise ValueError("workspace finish requires agent-control or operator principal") + raise ValueError( + "workspace finish requires agent-control or operator principal" + ) assert self.delivery is not None - return self.delivery.finish(self._single_workspace_id(arguments, "workspace.finish")) + return self.delivery.finish( + self._single_workspace_id(arguments, "workspace.finish") + ) if operation == "workspace.finish-integrated": - if principal not in {"agent-control", "operator"} or set(arguments) != {"workspace_id", "target_ref"}: - raise ValueError("workspace.finish-integrated requires agent-control or operator plus workspace_id and target_ref") + if principal not in {"agent-control", "operator"} or set(arguments) != { + "workspace_id", + "target_ref", + }: + raise ValueError( + "workspace.finish-integrated requires agent-control or operator plus workspace_id and target_ref" + ) assert self.workspaces is not None return self.workspaces.finish_integrated( self._job_argument(arguments, "workspace_id"), @@ -348,14 +468,24 @@ def _dispatch( ) project_id = self._job_argument(arguments, "project_id") operation_name = self._job_argument(arguments, "operation") - if set(arguments) - {"project_id", "operation", "workspace_id", "parameters", "bead_binding"}: - raise ValueError("job.start accepts project_id, operation, optional workspace_id, optional parameters, and optional bead_binding") + if set(arguments) - { + "project_id", + "operation", + "workspace_id", + "parameters", + "bead_binding", + }: + raise ValueError( + "job.start accepts project_id, operation, optional workspace_id, optional parameters, and optional bead_binding" + ) parameters = arguments.get("parameters", {}) if not isinstance(parameters, Mapping): raise ValueError("job.start parameters must be an object") project = self.projects.get(project_id) workspace_id = arguments.get("workspace_id") - if workspace_id is not None and (not isinstance(workspace_id, str) or not workspace_id): + if workspace_id is not None and ( + not isinstance(workspace_id, str) or not workspace_id + ): raise ValueError("job.start workspace_id must be null or non-empty") assert self.workspaces is not None checkout = ( @@ -364,57 +494,95 @@ def _dispatch( else self.projects.checkout(project_id, "default") ) binding = arguments.get("bead_binding") - if binding is not None and operation_name not in project.workspace.verification_operations: - raise ValueError("a Beads packet binding requires a declared verification operation") + if ( + binding is not None + and operation_name not in project.workspace.verification_operations + ): + raise ValueError( + "a Beads packet binding requires a declared verification operation" + ) packet_contract = ( {"bead_binding": self.job_contracts.bead_binding(binding, checkout)} if binding is not None else {} ) - return self._cleanup_terminal(self.jobs.start_declared( - project=project, - operation=project.operation(operation_name), - correlation_id=correlation_id, - principal=principal, - parameters=parameters, - checkout=checkout, - contract=packet_contract, - )) + return self._cleanup_terminal( + self.jobs.start_declared( + project=project, + operation=project.operation(operation_name), + correlation_id=correlation_id, + principal=principal, + parameters=parameters, + checkout=checkout, + contract=packet_contract, + ) + ) if operation == "job.shell.start": - required = {"project_id", "checkout_id", "argv", "cwd", "timeout_seconds", "result"} + required = { + "project_id", + "checkout_id", + "argv", + "cwd", + "timeout_seconds", + "result", + } if set(arguments) != required: - raise ValueError("job.shell.start requires project_id, checkout_id, argv, cwd, timeout_seconds, and result") + raise ValueError( + "job.shell.start requires project_id, checkout_id, argv, cwd, timeout_seconds, and result" + ) argv = arguments["argv"] if not isinstance(argv, list): raise ValueError("job.shell.start argv must be a list") - return self._cleanup_terminal(self.job_contracts.start_shell( - principal=principal, - project_id=self._job_argument(arguments, "project_id"), - checkout_id=self._job_argument(arguments, "checkout_id"), - argv=argv, - cwd=self._job_argument(arguments, "cwd"), - timeout_seconds=self._integer_argument(arguments, "timeout_seconds"), - result=self._job_argument(arguments, "result"), - )) + return self._cleanup_terminal( + self.job_contracts.start_shell( + principal=principal, + project_id=self._job_argument(arguments, "project_id"), + checkout_id=self._job_argument(arguments, "checkout_id"), + argv=argv, + cwd=self._job_argument(arguments, "cwd"), + timeout_seconds=self._integer_argument( + arguments, "timeout_seconds" + ), + result=self._job_argument(arguments, "result"), + ) + ) if operation == "job.agent.start": required = { - "project_id", "checkout_id", "prompt", "backend", "model", "effort", "credential_profile", "timeout_seconds", "result" + "project_id", + "checkout_id", + "prompt", + "backend", + "model", + "effort", + "credential_profile", + "timeout_seconds", + "result", } - if not required <= set(arguments) or set(arguments) - (required | {"bead_binding"}): - raise ValueError("job.agent.start requires the complete typed agent contract") - return self._cleanup_terminal(self.job_contracts.start_agent( - principal=principal, - project_id=self._job_argument(arguments, "project_id"), - checkout_id=self._job_argument(arguments, "checkout_id"), - prompt=self._job_argument(arguments, "prompt"), - backend=self._job_argument(arguments, "backend"), - model=self._job_argument(arguments, "model"), - effort=self._job_argument(arguments, "effort"), - credential_profile=self._job_argument(arguments, "credential_profile"), - timeout_seconds=self._integer_argument(arguments, "timeout_seconds"), - result=self._job_argument(arguments, "result"), - bead_binding=arguments.get("bead_binding"), - )) + if not required <= set(arguments) or set(arguments) - ( + required | {"bead_binding"} + ): + raise ValueError( + "job.agent.start requires the complete typed agent contract" + ) + return self._cleanup_terminal( + self.job_contracts.start_agent( + principal=principal, + project_id=self._job_argument(arguments, "project_id"), + checkout_id=self._job_argument(arguments, "checkout_id"), + prompt=self._job_argument(arguments, "prompt"), + backend=self._job_argument(arguments, "backend"), + model=self._job_argument(arguments, "model"), + effort=self._job_argument(arguments, "effort"), + credential_profile=self._job_argument( + arguments, "credential_profile" + ), + timeout_seconds=self._integer_argument( + arguments, "timeout_seconds" + ), + result=self._job_argument(arguments, "result"), + bead_binding=arguments.get("bead_binding"), + ) + ) if operation == "job.get": return self._cleanup_terminal( self.jobs.get( @@ -431,7 +599,9 @@ def _dispatch( "phases", "active_only", }: - raise ValueError("job.list accepts only pagination and filter arguments") + raise ValueError( + "job.list accepts only pagination and filter arguments" + ) limit = arguments.get("limit", 100) if not isinstance(limit, int) or isinstance(limit, bool): raise ValueError("job.list limit must be an integer") @@ -460,24 +630,37 @@ def _dispatch( ) ) if operation == "job.wait": - job_id = self._authorize_job(principal, self._job_argument(arguments, "job_id")) + job_id = self._authorize_job( + principal, self._job_argument(arguments, "job_id") + ) timeout_seconds = arguments.get("timeout_seconds", 30) if set(arguments) - {"job_id", "timeout_seconds"}: raise ValueError("job.wait accepts job_id and optional timeout_seconds") - if not isinstance(timeout_seconds, int) or isinstance(timeout_seconds, bool): + if not isinstance(timeout_seconds, int) or isinstance( + timeout_seconds, bool + ): raise ValueError("job.wait timeout_seconds must be an integer") return self._cleanup_terminal(self.jobs.wait(job_id, timeout_seconds)) if operation == "job.logs": - job_id = self._authorize_job(principal, self._job_argument(arguments, "job_id")) + job_id = self._authorize_job( + principal, self._job_argument(arguments, "job_id") + ) offset = arguments.get("offset", 0) max_bytes = arguments.get("max_bytes", 64_000) if set(arguments) - {"job_id", "offset", "max_bytes"}: - raise ValueError("job.logs accepts job_id, optional offset, and optional max_bytes") - if any(not isinstance(value, int) or isinstance(value, bool) for value in (offset, max_bytes)): + raise ValueError( + "job.logs accepts job_id, optional offset, and optional max_bytes" + ) + if any( + not isinstance(value, int) or isinstance(value, bool) + for value in (offset, max_bytes) + ): raise ValueError("job.logs offset and max_bytes must be integers") return self.jobs.logs(job_id, offset=offset, max_bytes=max_bytes) if operation == "job.result": - job_id = self._authorize_job(principal, self._job_argument(arguments, "job_id")) + job_id = self._authorize_job( + principal, self._job_argument(arguments, "job_id") + ) max_bytes = arguments.get("max_bytes", 64_000) if set(arguments) - {"job_id", "max_bytes"}: raise ValueError("job.result accepts job_id and optional max_bytes") diff --git a/pkgs/sinnixd/sinnixd/tasks.py b/pkgs/sinnixd/sinnixd/tasks.py index 0ae60f7f..8d6d07e7 100644 --- a/pkgs/sinnixd/sinnixd/tasks.py +++ b/pkgs/sinnixd/sinnixd/tasks.py @@ -15,14 +15,24 @@ from typing import Any, Protocol from uuid import uuid4 -from sinnix_mcp import ErrorCode, SinnixRef -from sinnix_mcp.execution import ExecutionProfile, ExecutionResult, OwnerExecution, OwnerRoute from sinnix_lib.lock import flock - -from .jobs import DEFAULT_TIMEOUT_SECONDS, GenericJobSpec, GenericJobs, _ensure_durable_directory, _fsync_directory +from sinnix_mcp import ErrorCode, SinnixRef +from sinnix_mcp.execution import ( + ExecutionProfile, + ExecutionResult, + OwnerExecution, + OwnerRoute, +) + +from .jobs import ( + DEFAULT_TIMEOUT_SECONDS, + GenericJobs, + GenericJobSpec, + _ensure_durable_directory, + _fsync_directory, +) from .projects import ProjectAdapter, ProjectCatalog - MAX_TASK_OUTPUT_BYTES = 200_000 MAX_TASK_STDERR_BYTES = 8_192 MAX_TASK_LIST_SOURCE_BYTES = 8_000_000 @@ -46,11 +56,57 @@ _SHA256_RE = re.compile(r"^sha256:[0-9a-f]{64}$") _READ_PRINCIPALS = frozenset({"observer", "agent-control", "operator"}) _WRITE_PRINCIPALS = frozenset({"agent-control", "operator"}) -_MUTATIONS = frozenset({"task.create", "task.claim", "task.note", "task.relate", "task.complete", "task.release", "task.update", "task.reconcile"}) -_IDEMPOTENT_MUTATIONS = frozenset({"task.create", "task.claim", "task.note", "task.relate", "task.complete", "task.release", "task.update"}) +_MUTATIONS = frozenset( + { + "task.create", + "task.claim", + "task.note", + "task.relate", + "task.complete", + "task.release", + "task.update", + "task.reconcile", + } +) +_IDEMPOTENT_MUTATIONS = frozenset( + { + "task.create", + "task.claim", + "task.note", + "task.relate", + "task.complete", + "task.release", + "task.update", + } +) _MUTATION_STATES = frozenset({"pending", "dispatching", "applied", "failed"}) -_ISSUE_TYPES = frozenset({"bug", "feature", "task", "epic", "chore", "decision", "spike", "story", "milestone"}) -_DEPENDENCY_RELATIONS = frozenset({"depends-on", "blocks", "tracks", "related", "discovered-from", "until", "caused-by", "validates", "relates-to", "supersedes"}) +_ISSUE_TYPES = frozenset( + { + "bug", + "feature", + "task", + "epic", + "chore", + "decision", + "spike", + "story", + "milestone", + } +) +_DEPENDENCY_RELATIONS = frozenset( + { + "depends-on", + "blocks", + "tracks", + "related", + "discovered-from", + "until", + "caused-by", + "validates", + "relates-to", + "supersedes", + } +) _LABEL_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:/-]{0,63}$") @@ -67,13 +123,20 @@ class TaskListCursorError(TaskError): """A task-list cursor was malformed, mismatched, or no longer usable.""" def __init__(self, message: str, *, stale: bool = False): - super().__init__(ErrorCode.STALE_CURSOR if stale else ErrorCode.INVALID_ARGUMENT, message) + super().__init__( + ErrorCode.STALE_CURSOR if stale else ErrorCode.INVALID_ARGUMENT, message + ) def _canonical_digest(value: Any) -> str: - return "sha256:" + hashlib.sha256( - json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode() - ).hexdigest() + return ( + "sha256:" + + hashlib.sha256( + json.dumps( + value, sort_keys=True, separators=(",", ":"), ensure_ascii=False + ).encode() + ).hexdigest() + ) def default_task_state_root() -> Path: @@ -96,19 +159,49 @@ def load(cls, state_root: Path, project_id: str) -> TaskAuthority: try: receipt = json.loads((root / TASK_AUTHORITY_RECEIPT).read_text()) except FileNotFoundError as error: - raise TaskError(ErrorCode.OWNER_UNAVAILABLE, "task authority is not activated") from error + raise TaskError( + ErrorCode.OWNER_UNAVAILABLE, "task authority is not activated" + ) from error except (OSError, json.JSONDecodeError) as error: - raise TaskError(ErrorCode.OPERATION_FAILED, "task authority receipt is unavailable") from error - expected = {"schema", "project_id", "database", "source_database", "verification"} - if not isinstance(receipt, dict) or set(receipt) != expected or receipt["schema"] != 1: - raise TaskError(ErrorCode.OPERATION_FAILED, "task authority receipt is malformed") + raise TaskError( + ErrorCode.OPERATION_FAILED, "task authority receipt is unavailable" + ) from error + expected = { + "schema", + "project_id", + "database", + "source_database", + "verification", + } + if ( + not isinstance(receipt, dict) + or set(receipt) != expected + or receipt["schema"] != 1 + ): + raise TaskError( + ErrorCode.OPERATION_FAILED, "task authority receipt is malformed" + ) source_value = receipt["source_database"] - if receipt["project_id"] != project_id or receipt["database"] != str(database) or not isinstance(source_value, str) or not Path(source_value).is_absolute(): - raise TaskError(ErrorCode.OPERATION_FAILED, "task authority receipt is malformed") + if ( + receipt["project_id"] != project_id + or receipt["database"] != str(database) + or not isinstance(source_value, str) + or not Path(source_value).is_absolute() + ): + raise TaskError( + ErrorCode.OPERATION_FAILED, "task authority receipt is malformed" + ) verification = receipt["verification"] - verification_keys = {"source_export_sha256", "destination_export_sha256", "source_rows", "destination_rows"} + verification_keys = { + "source_export_sha256", + "destination_export_sha256", + "source_rows", + "destination_rows", + } if not isinstance(verification, dict) or set(verification) != verification_keys: - raise TaskError(ErrorCode.OPERATION_FAILED, "task authority receipt is malformed") + raise TaskError( + ErrorCode.OPERATION_FAILED, "task authority receipt is malformed" + ) source_digest = verification["source_export_sha256"] destination_digest = verification["destination_export_sha256"] source_rows = verification["source_rows"] @@ -125,15 +218,27 @@ def load(cls, state_root: Path, project_id: str) -> TaskAuthority: or database.is_symlink() or not database.is_dir() ): - raise TaskError(ErrorCode.OPERATION_FAILED, "task authority verification is incomplete") + raise TaskError( + ErrorCode.OPERATION_FAILED, "task authority verification is incomplete" + ) try: canonical_database = database.resolve(strict=True) source_database = Path(source_value) - redirect_target = Path((source_database.parent / "redirect").read_text().strip()).resolve(strict=True) + redirect_target = Path( + (source_database.parent / "redirect").read_text().strip() + ).resolve(strict=True) except (OSError, ValueError) as error: - raise TaskError(ErrorCode.OPERATION_FAILED, "task authority cutover is ambiguous") from error - if source_database.exists() or source_database.is_symlink() or redirect_target != canonical_database.parent: - raise TaskError(ErrorCode.OPERATION_FAILED, "task authority cutover is ambiguous") + raise TaskError( + ErrorCode.OPERATION_FAILED, "task authority cutover is ambiguous" + ) from error + if ( + source_database.exists() + or source_database.is_symlink() + or redirect_target != canonical_database.parent + ): + raise TaskError( + ErrorCode.OPERATION_FAILED, "task authority cutover is ambiguous" + ) return cls(project_id, root, database, source_database) @@ -147,7 +252,9 @@ class TaskMutationIdentity: idempotency_sha256: str @classmethod - def create(cls, project_id: str, operation: str, task_id: str, idempotency_key: str) -> TaskMutationIdentity: + def create( + cls, project_id: str, operation: str, task_id: str, idempotency_key: str + ) -> TaskMutationIdentity: return cls(project_id, operation, task_id, cls.digest(idempotency_key)) def public(self) -> dict[str, str]: @@ -159,7 +266,9 @@ def public(self) -> dict[str, str]: } def record_key(self) -> str: - return self.digest(json.dumps(self.public(), sort_keys=True, separators=(",", ":"))).removeprefix("sha256:") + return self.digest( + json.dumps(self.public(), sort_keys=True, separators=(",", ":")) + ).removeprefix("sha256:") @staticmethod def digest(value: str) -> str: @@ -203,26 +312,52 @@ def records_root(self) -> Path: def intents_root(self) -> Path: return self.root / "intents" - def load(self, identity: TaskMutationIdentity, arguments_sha256: str) -> TaskMutationRecord | None: + def load( + self, identity: TaskMutationIdentity, arguments_sha256: str + ) -> TaskMutationRecord | None: record = self._load(identity) if record is not None and record.arguments_sha256 != arguments_sha256: - raise TaskError(ErrorCode.INVALID_ARGUMENT, "idempotency identity belongs to a different task mutation") + raise TaskError( + ErrorCode.INVALID_ARGUMENT, + "idempotency identity belongs to a different task mutation", + ) return record - def create(self, identity: TaskMutationIdentity, arguments_sha256: str, command: tuple[str, ...]) -> TaskMutationRecord: + def create( + self, + identity: TaskMutationIdentity, + arguments_sha256: str, + command: tuple[str, ...], + ) -> TaskMutationRecord: if self._load(identity) is not None: - raise TaskError(ErrorCode.OPERATION_FAILED, "task mutation journal changed during submission") + raise TaskError( + ErrorCode.OPERATION_FAILED, + "task mutation journal changed during submission", + ) if len(tuple(self.records_root.glob("*.json"))) >= self.max_records: - raise TaskError(ErrorCode.RESOURCE_EXHAUSTED, "task mutation journal is full") + raise TaskError( + ErrorCode.RESOURCE_EXHAUSTED, "task mutation journal is full" + ) try: - intent = json.dumps({"schema": 1, "command": list(command)}, sort_keys=True, separators=(",", ":")).encode() + intent = json.dumps( + {"schema": 1, "command": list(command)}, + sort_keys=True, + separators=(",", ":"), + ).encode() except (TypeError, ValueError) as error: - raise TaskError(ErrorCode.INVALID_ARGUMENT, "task mutation intent is invalid") from error + raise TaskError( + ErrorCode.INVALID_ARGUMENT, "task mutation intent is invalid" + ) from error if len(intent) > MAX_TASK_MUTATION_INTENT_BYTES: - raise TaskError(ErrorCode.RESOURCE_EXHAUSTED, "task mutation intent exceeds the storage bound") + raise TaskError( + ErrorCode.RESOURCE_EXHAUSTED, + "task mutation intent exceeds the storage bound", + ) intent_sha256 = "sha256:" + hashlib.sha256(intent + b"\n").hexdigest() self._write(self._intent_path(identity), intent) - record = TaskMutationRecord(identity, arguments_sha256, "pending", 0, intent_sha256, None, None) + record = TaskMutationRecord( + identity, arguments_sha256, "pending", 0, intent_sha256, None, None + ) self.save(record) return record @@ -240,16 +375,23 @@ def save(self, record: TaskMutationRecord) -> None: } encoded = json.dumps(value, sort_keys=True, separators=(",", ":")).encode() if len(encoded) > MAX_TASK_MUTATION_RECORD_BYTES: - raise TaskError(ErrorCode.RESOURCE_EXHAUSTED, "task mutation receipt exceeds the storage bound") + raise TaskError( + ErrorCode.RESOURCE_EXHAUSTED, + "task mutation receipt exceeds the storage bound", + ) self._write(self._record_path(record.identity), encoded) def records(self) -> tuple[TaskMutationRecord, ...]: try: paths = sorted(self.records_root.glob("*.json")) except OSError as error: - raise TaskError(ErrorCode.OPERATION_FAILED, "task mutation journal is unavailable") from error + raise TaskError( + ErrorCode.OPERATION_FAILED, "task mutation journal is unavailable" + ) from error if len(paths) > self.max_records: - raise TaskError(ErrorCode.OPERATION_FAILED, "task mutation journal exceeds its bound") + raise TaskError( + ErrorCode.OPERATION_FAILED, "task mutation journal exceeds its bound" + ) return tuple(self._read(path) for path in paths) def intent(self, record: TaskMutationRecord) -> tuple[str, ...]: @@ -257,32 +399,67 @@ def intent(self, record: TaskMutationRecord) -> tuple[str, ...]: encoded = self._intent_path(record.identity).read_bytes() value = json.loads(encoded) except FileNotFoundError as error: - raise TaskError(ErrorCode.OPERATION_FAILED, "task mutation intent is missing") from error + raise TaskError( + ErrorCode.OPERATION_FAILED, "task mutation intent is missing" + ) from error except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error: - raise TaskError(ErrorCode.OPERATION_FAILED, "task mutation intent is unavailable") from error + raise TaskError( + ErrorCode.OPERATION_FAILED, "task mutation intent is unavailable" + ) from error if "sha256:" + hashlib.sha256(encoded).hexdigest() != record.intent_sha256: - raise TaskError(ErrorCode.OPERATION_FAILED, "task mutation intent is malformed") - command = value.get("command") if isinstance(value, dict) and value.get("schema") == 1 else None - if not isinstance(command, list) or not command or len(command) > 32 or any(not isinstance(item, str) or not item or len(item) > 32_000 for item in command): - raise TaskError(ErrorCode.OPERATION_FAILED, "task mutation intent is malformed") + raise TaskError( + ErrorCode.OPERATION_FAILED, "task mutation intent is malformed" + ) + command = ( + value.get("command") + if isinstance(value, dict) and value.get("schema") == 1 + else None + ) + if ( + not isinstance(command, list) + or not command + or len(command) > 32 + or any( + not isinstance(item, str) or not item or len(item) > 32_000 + for item in command + ) + ): + raise TaskError( + ErrorCode.OPERATION_FAILED, "task mutation intent is malformed" + ) return tuple(command) def dispatching(self, record: TaskMutationRecord) -> TaskMutationRecord: - updated = replace(record, state="dispatching", attempts=record.attempts + 1, failure=None) + updated = replace( + record, state="dispatching", attempts=record.attempts + 1, failure=None + ) self.save(updated) return updated - def pending(self, record: TaskMutationRecord, error: TaskError) -> TaskMutationRecord: + def pending( + self, record: TaskMutationRecord, error: TaskError + ) -> TaskMutationRecord: updated = replace(record, state="pending", failure={"code": error.code.value}) self.save(updated) return updated - def applied(self, record: TaskMutationRecord, result: Any, *, created_task_id: str | None = None) -> TaskMutationRecord: + def applied( + self, + record: TaskMutationRecord, + result: Any, + *, + created_task_id: str | None = None, + ) -> TaskMutationRecord: try: encoded = json.dumps(result, sort_keys=True, separators=(",", ":")).encode() except (TypeError, ValueError) as error: - raise TaskError(ErrorCode.RESULT_INVALID, "task backend returned invalid JSON") from error - evidence: dict[str, Any] = {"sha256": "sha256:" + hashlib.sha256(encoded).hexdigest(), "bytes": len(encoded)} + raise TaskError( + ErrorCode.RESULT_INVALID, "task backend returned invalid JSON" + ) from error + evidence: dict[str, Any] = { + "sha256": "sha256:" + hashlib.sha256(encoded).hexdigest(), + "bytes": len(encoded), + } if created_task_id is not None: evidence["created_task_id"] = created_task_id updated = replace(record, state="applied", result=evidence, failure=None) @@ -291,10 +468,14 @@ def applied(self, record: TaskMutationRecord, result: Any, *, created_task_id: s self._intent_path(updated.identity).unlink(missing_ok=True) _fsync_directory(self.intents_root) except OSError as error: - raise TaskError(ErrorCode.OPERATION_FAILED, "task mutation intent cleanup failed") from error + raise TaskError( + ErrorCode.OPERATION_FAILED, "task mutation intent cleanup failed" + ) from error return updated - def failed(self, record: TaskMutationRecord, error: TaskError) -> TaskMutationRecord: + def failed( + self, record: TaskMutationRecord, error: TaskError + ) -> TaskMutationRecord: updated = replace(record, state="failed", failure={"code": error.code.value}) self.save(updated) return updated @@ -312,15 +493,47 @@ def _read(self, path: Path) -> TaskMutationRecord: except FileNotFoundError: raise except (OSError, json.JSONDecodeError) as error: - raise TaskError(ErrorCode.OPERATION_FAILED, "task mutation journal is malformed") from error - expected = {"schema", "identity", "arguments_sha256", "state", "attempts", "intent_sha256", "result", "failure"} - if not isinstance(value, dict) or set(value) != expected or value["schema"] != 1: - raise TaskError(ErrorCode.OPERATION_FAILED, "task mutation journal is malformed") + raise TaskError( + ErrorCode.OPERATION_FAILED, "task mutation journal is malformed" + ) from error + expected = { + "schema", + "identity", + "arguments_sha256", + "state", + "attempts", + "intent_sha256", + "result", + "failure", + } + if ( + not isinstance(value, dict) + or set(value) != expected + or value["schema"] != 1 + ): + raise TaskError( + ErrorCode.OPERATION_FAILED, "task mutation journal is malformed" + ) identity_value = value["identity"] - if not isinstance(identity_value, dict) or set(identity_value) != {"project_id", "operation", "task_id", "idempotency_sha256"}: - raise TaskError(ErrorCode.OPERATION_FAILED, "task mutation journal is malformed") + if not isinstance(identity_value, dict) or set(identity_value) != { + "project_id", + "operation", + "task_id", + "idempotency_sha256", + }: + raise TaskError( + ErrorCode.OPERATION_FAILED, "task mutation journal is malformed" + ) identity = TaskMutationIdentity(**identity_value) - record = TaskMutationRecord(identity, value["arguments_sha256"], value["state"], value["attempts"], value["intent_sha256"], value["result"], value["failure"]) + record = TaskMutationRecord( + identity, + value["arguments_sha256"], + value["state"], + value["attempts"], + value["intent_sha256"], + value["result"], + value["failure"], + ) self._validate(record) return record @@ -338,33 +551,64 @@ def _validate(record: TaskMutationRecord) -> None: or not 0 <= record.attempts <= 1_000 or not _SHA256_RE.fullmatch(record.intent_sha256) ): - raise TaskError(ErrorCode.OPERATION_FAILED, "task mutation journal is malformed") + raise TaskError( + ErrorCode.OPERATION_FAILED, "task mutation journal is malformed" + ) if record.result is not None and ( not isinstance(record.result, dict) - or set(record.result) not in ({"sha256", "bytes"}, {"sha256", "bytes", "created_task_id"}) + or set(record.result) + not in ({"sha256", "bytes"}, {"sha256", "bytes", "created_task_id"}) or not isinstance(record.result["sha256"], str) or not _SHA256_RE.fullmatch(record.result["sha256"]) or isinstance(record.result["bytes"], bool) or not isinstance(record.result["bytes"], int) or not 0 <= record.result["bytes"] <= MAX_TASK_OUTPUT_BYTES ): - raise TaskError(ErrorCode.OPERATION_FAILED, "task mutation journal is malformed") - if "created_task_id" in (record.result or {}) and not _ID_RE.fullmatch(record.result["created_task_id"]): - raise TaskError(ErrorCode.OPERATION_FAILED, "task mutation journal is malformed") - if record.identity.operation == "task.create" and record.state == "applied" and "created_task_id" not in (record.result or {}): - raise TaskError(ErrorCode.OPERATION_FAILED, "task mutation journal is malformed") - if record.identity.operation != "task.create" and "created_task_id" in (record.result or {}): - raise TaskError(ErrorCode.OPERATION_FAILED, "task mutation journal is malformed") + raise TaskError( + ErrorCode.OPERATION_FAILED, "task mutation journal is malformed" + ) + if "created_task_id" in (record.result or {}) and not _ID_RE.fullmatch( + record.result["created_task_id"] + ): + raise TaskError( + ErrorCode.OPERATION_FAILED, "task mutation journal is malformed" + ) + if ( + record.identity.operation == "task.create" + and record.state == "applied" + and "created_task_id" not in (record.result or {}) + ): + raise TaskError( + ErrorCode.OPERATION_FAILED, "task mutation journal is malformed" + ) + if record.identity.operation != "task.create" and "created_task_id" in ( + record.result or {} + ): + raise TaskError( + ErrorCode.OPERATION_FAILED, "task mutation journal is malformed" + ) if record.failure is not None and ( - not isinstance(record.failure, dict) or set(record.failure) != {"code"} or record.failure["code"] not in {code.value for code in ErrorCode} + not isinstance(record.failure, dict) + or set(record.failure) != {"code"} + or record.failure["code"] not in {code.value for code in ErrorCode} ): - raise TaskError(ErrorCode.OPERATION_FAILED, "task mutation journal is malformed") - if record.state == "applied" and (record.result is None or record.failure is not None): - raise TaskError(ErrorCode.OPERATION_FAILED, "task mutation journal is malformed") + raise TaskError( + ErrorCode.OPERATION_FAILED, "task mutation journal is malformed" + ) + if record.state == "applied" and ( + record.result is None or record.failure is not None + ): + raise TaskError( + ErrorCode.OPERATION_FAILED, "task mutation journal is malformed" + ) if record.state == "pending" and record.result is not None: - raise TaskError(ErrorCode.OPERATION_FAILED, "task mutation journal is malformed") + raise TaskError( + ErrorCode.OPERATION_FAILED, "task mutation journal is malformed" + ) if record.state == "failed" and record.failure is None: - raise TaskError(ErrorCode.OPERATION_FAILED, "task mutation journal is malformed") + raise TaskError( + ErrorCode.OPERATION_FAILED, "task mutation journal is malformed" + ) def _record_path(self, identity: TaskMutationIdentity) -> Path: return self.records_root / f"{identity.record_key()}.json" @@ -377,7 +621,9 @@ def _write(path: Path, encoded: bytes) -> None: _ensure_durable_directory(path.parent) temporary = path.with_suffix(path.suffix + ".tmp") try: - descriptor = os.open(temporary, os.O_CREAT | os.O_TRUNC | os.O_WRONLY, 0o600) + descriptor = os.open( + temporary, os.O_CREAT | os.O_TRUNC | os.O_WRONLY, 0o600 + ) with os.fdopen(descriptor, "wb") as handle: handle.write(encoded) handle.write(b"\n") @@ -415,10 +661,29 @@ def run( lock_path: Path | None = None, max_stdout_bytes: int | None = None, ) -> ExecutionResult: - command = (self.executable, *argv) if lock_path is None else (FLOCK_EXECUTABLE, "--exclusive", str(lock_path), self.executable, *argv) + command = ( + (self.executable, *argv) + if lock_path is None + else ( + FLOCK_EXECUTABLE, + "--exclusive", + str(lock_path), + self.executable, + *argv, + ) + ) return self.execution.run( command, - ExecutionProfile(route=OwnerRoute("task-backend"), timeout_seconds=TASK_TIMEOUT_SECONDS, max_stdout_bytes=max_stdout_bytes or MAX_TASK_OUTPUT_BYTES, max_stderr_bytes=MAX_TASK_STDERR_BYTES, max_combined_output_bytes=(max_stdout_bytes or MAX_TASK_OUTPUT_BYTES) + MAX_TASK_STDERR_BYTES, cwd=cwd, environment=environment), + ExecutionProfile( + route=OwnerRoute("task-backend"), + timeout_seconds=TASK_TIMEOUT_SECONDS, + max_stdout_bytes=max_stdout_bytes or MAX_TASK_OUTPUT_BYTES, + max_stderr_bytes=MAX_TASK_STDERR_BYTES, + max_combined_output_bytes=(max_stdout_bytes or MAX_TASK_OUTPUT_BYTES) + + MAX_TASK_STDERR_BYTES, + cwd=cwd, + environment=environment, + ), ) @@ -436,45 +701,86 @@ def _run_task_command( argv=("--json", *(("--readonly",) if readonly else ()), *command), cwd=cwd, environment={"BEADS_DIR": str(authority.root / ".beads")}, - **({"max_stdout_bytes": max_stdout_bytes} if max_stdout_bytes != MAX_TASK_OUTPUT_BYTES else {}), + **( + {"max_stdout_bytes": max_stdout_bytes} + if max_stdout_bytes != MAX_TASK_OUTPUT_BYTES + else {} + ), ) if result.timed_out or result.failure_class == "command_timeout": raise TaskError(ErrorCode.OWNER_UNAVAILABLE, "task backend timed out") - if result.output_exceeded or result.failure_class == "command_output_bound" or len(result.stdout) > max_stdout_bytes or len(result.stderr) > MAX_TASK_STDERR_BYTES: - raise TaskError(ErrorCode.RESOURCE_EXHAUSTED, "task backend response exceeded the output bound") + if ( + result.output_exceeded + or result.failure_class == "command_output_bound" + or len(result.stdout) > max_stdout_bytes + or len(result.stderr) > MAX_TASK_STDERR_BYTES + ): + raise TaskError( + ErrorCode.RESOURCE_EXHAUSTED, + "task backend response exceeded the output bound", + ) if result.failure_class is not None: if result.failure_class.startswith("command_unavailable"): - raise TaskError(ErrorCode.OWNER_UNAVAILABLE, "task backend is unavailable", retryable=True) + raise TaskError( + ErrorCode.OWNER_UNAVAILABLE, + "task backend is unavailable", + retryable=True, + ) raise TaskError(ErrorCode.OPERATION_FAILED, "task backend command failed") if result.exit_status != 0: raise TaskError(ErrorCode.OPERATION_FAILED, "task backend command failed") try: if json_lines: - records = [json.loads(line) for line in result.stdout.decode().splitlines() if line] + records = [ + json.loads(line) for line in result.stdout.decode().splitlines() if line + ] if not all(isinstance(record, dict) for record in records): raise ValueError return records return json.loads(result.stdout) except (UnicodeDecodeError, json.JSONDecodeError, ValueError) as error: - raise TaskError(ErrorCode.RESULT_INVALID, "task backend returned invalid JSON") from error + raise TaskError( + ErrorCode.RESULT_INVALID, "task backend returned invalid JSON" + ) from error -def reconcile_task_mutations(*, journal: TaskMutationJournal, authority: TaskAuthority, cwd: Path, boundary: TaskCommandBoundary) -> tuple[dict[str, Any], ...]: +def reconcile_task_mutations( + *, + journal: TaskMutationJournal, + authority: TaskAuthority, + cwd: Path, + boundary: TaskCommandBoundary, +) -> tuple[dict[str, Any], ...]: """Replay pending intents; a crash after dispatch stays failed rather than duplicating it.""" receipts: list[dict[str, Any]] = [] for record in journal.records(): if record.state == "dispatching": - record = journal.failed(record, TaskError(ErrorCode.OWNER_UNAVAILABLE, "task outcome is unknown")) + record = journal.failed( + record, + TaskError(ErrorCode.OWNER_UNAVAILABLE, "task outcome is unknown"), + ) elif record.state == "pending": record = journal.dispatching(record) try: - result = _run_task_command(boundary, authority, cwd, journal.intent(record), readonly=False) - created_task_id = TaskService._created_task_id(result) if record.identity.operation == "task.create" else None + result = _run_task_command( + boundary, authority, cwd, journal.intent(record), readonly=False + ) + created_task_id = ( + TaskService._created_task_id(result) + if record.identity.operation == "task.create" + else None + ) except TaskError as error: - record = journal.pending(record, error) if error.retryable else journal.failed(record, error) + record = ( + journal.pending(record, error) + if error.retryable + else journal.failed(record, error) + ) else: - record = journal.applied(record, result, created_task_id=created_task_id) + record = journal.applied( + record, result, created_task_id=created_task_id + ) receipts.append(record.receipt()) return tuple(receipts) @@ -485,16 +791,29 @@ class TaskService: jobs: GenericJobs boundary: TaskCommandBoundary = field(default_factory=BeadsCommandBoundary) task_state_root: Path = field(default_factory=default_task_state_root) - _locks_guard: threading.Lock = field(default_factory=threading.Lock, init=False, repr=False) - _project_locks: dict[str, threading.Lock] = field(default_factory=dict, init=False, repr=False) + _locks_guard: threading.Lock = field( + default_factory=threading.Lock, init=False, repr=False + ) + _project_locks: dict[str, threading.Lock] = field( + default_factory=dict, init=False, repr=False + ) def __post_init__(self) -> None: if not self.task_state_root.is_absolute(): raise ValueError("task state root must be absolute") - def execute(self, *, operation: str, arguments: dict[str, Any] | Any, principal: str, mutation_id: str | None = None) -> dict[str, Any]: + def execute( + self, + *, + operation: str, + arguments: dict[str, Any] | Any, + principal: str, + mutation_id: str | None = None, + ) -> dict[str, Any]: if not isinstance(arguments, dict): - raise TaskError(ErrorCode.INVALID_ARGUMENT, "task arguments must be an object") + raise TaskError( + ErrorCode.INVALID_ARGUMENT, "task arguments must be an object" + ) write = operation in _MUTATIONS self._authorize(principal, write=write) project = self._project(arguments) @@ -502,30 +821,71 @@ def execute(self, *, operation: str, arguments: dict[str, Any] | Any, principal: return self._execute(project, operation, arguments, principal=principal) with self._lock_for(project.project_id), flock(self._lock_path(project)): if operation in _IDEMPOTENT_MUTATIONS: - return self._submit_mutation(project, operation, arguments, self._mutation_id(mutation_id)) + return self._submit_mutation( + project, operation, arguments, self._mutation_id(mutation_id) + ) return self._execute(project, operation, arguments, principal=principal) - def _submit_mutation(self, project: ProjectAdapter, operation: str, arguments: dict[str, Any], request_id: str) -> dict[str, Any]: + def _submit_mutation( + self, + project: ProjectAdapter, + operation: str, + arguments: dict[str, Any], + request_id: str, + ) -> dict[str, Any]: command = self._mutation_command(operation, arguments) - task_id = "create" if operation == "task.create" else self._task_id(arguments["task_id"]) - idempotency_key = self._merge_sha(arguments["merge_sha"]) if operation == "task.complete" else request_id - identity = TaskMutationIdentity.create(project.project_id, operation, task_id, idempotency_key) - arguments_sha256 = TaskMutationIdentity.digest(json.dumps(arguments, sort_keys=True, separators=(",", ":"))) + task_id = ( + "create" + if operation == "task.create" + else self._task_id(arguments["task_id"]) + ) + idempotency_key = ( + self._merge_sha(arguments["merge_sha"]) + if operation == "task.complete" + else request_id + ) + identity = TaskMutationIdentity.create( + project.project_id, operation, task_id, idempotency_key + ) + arguments_sha256 = TaskMutationIdentity.digest( + json.dumps(arguments, sort_keys=True, separators=(",", ":")) + ) authority = self._authority(project) journal = TaskMutationJournal(authority.root / TASK_MUTATION_JOURNAL_DIRECTORY) - record = journal.load(identity, arguments_sha256) or journal.create(identity, arguments_sha256, command) + record = journal.load(identity, arguments_sha256) or journal.create( + identity, arguments_sha256, command + ) if record.state == "dispatching": - record = journal.failed(record, TaskError(ErrorCode.OWNER_UNAVAILABLE, "task outcome is unknown")) + record = journal.failed( + record, + TaskError(ErrorCode.OWNER_UNAVAILABLE, "task outcome is unknown"), + ) elif record.state == "pending": record = journal.dispatching(record) try: - result = _run_task_command(self.boundary, authority, project.root, command, readonly=False) - created_task_id = self._created_task_id(result) if operation == "task.create" else None + result = _run_task_command( + self.boundary, authority, project.root, command, readonly=False + ) + created_task_id = ( + self._created_task_id(result) + if operation == "task.create" + else None + ) except TaskError as error: - record = journal.pending(record, error) if error.retryable else journal.failed(record, error) + record = ( + journal.pending(record, error) + if error.retryable + else journal.failed(record, error) + ) else: - record = journal.applied(record, result, created_task_id=created_task_id) - response = {"project_id": project.project_id, "operation": operation, "result": record.receipt()} + record = journal.applied( + record, result, created_task_id=created_task_id + ) + response = { + "project_id": project.project_id, + "operation": operation, + "result": record.receipt(), + } if operation == "task.create": response["owner_evidence"] = { "owner": "task-backend", @@ -536,7 +896,9 @@ def _submit_mutation(self, project: ProjectAdapter, operation: str, arguments: d } if record.state == "applied": assert record.result is not None - response["task_ref"] = self._task_ref(project.project_id, record.result["created_task_id"]) + response["task_ref"] = self._task_ref( + project.project_id, record.result["created_task_id"] + ) if record.state == "failed": assert record.failure is not None raise TaskError( @@ -545,13 +907,24 @@ def _submit_mutation(self, project: ProjectAdapter, operation: str, arguments: d ) return response - def _execute(self, project: ProjectAdapter, operation: str, arguments: dict[str, Any], *, principal: str) -> dict[str, Any]: + def _execute( + self, + project: ProjectAdapter, + operation: str, + arguments: dict[str, Any], + *, + principal: str, + ) -> dict[str, Any]: if operation == "task.list": result = self._list(project, arguments, principal=principal) elif operation == "task.get": self._require_exact(arguments, {"project_id", "task_id"}, operation) result = self._single_task_result( - self._run(project, ("show", self._task_id(arguments["task_id"])), readonly=True), + self._run( + project, + ("show", self._task_id(arguments["task_id"])), + readonly=True, + ), operation="task.get", ) elif operation == "task.reconcile": @@ -561,18 +934,55 @@ def _execute(self, project: ProjectAdapter, operation: str, arguments: dict[str, self._require_exact(arguments, {"project_id"}, operation) result = self._run(project, ("export",), readonly=True, json_lines=True) else: - raise TaskError(ErrorCode.INVALID_ARGUMENT, f"unsupported task operation: {operation}") - return {"project_id": project.project_id, "operation": operation, "result": result} + raise TaskError( + ErrorCode.INVALID_ARGUMENT, f"unsupported task operation: {operation}" + ) + return { + "project_id": project.project_id, + "operation": operation, + "result": result, + } - def _mutation_command(self, operation: str, arguments: dict[str, Any]) -> tuple[str, ...]: + def _mutation_command( + self, operation: str, arguments: dict[str, Any] + ) -> tuple[str, ...]: if operation == "task.create": - self._require_allowed(arguments, {"project_id", "title", "description", "issue_type", "priority", "labels", "parent_task_id", "dependencies"}, {"project_id", "title", "description", "issue_type", "priority", "labels", "dependencies"}, operation) + self._require_allowed( + arguments, + { + "project_id", + "title", + "description", + "issue_type", + "priority", + "labels", + "parent_task_id", + "dependencies", + }, + { + "project_id", + "title", + "description", + "issue_type", + "priority", + "labels", + "dependencies", + }, + operation, + ) issue_type = self._string(arguments["issue_type"], "issue_type", 32) if issue_type not in _ISSUE_TYPES: raise TaskError(ErrorCode.INVALID_ARGUMENT, "issue_type is unsupported") priority = arguments["priority"] - if isinstance(priority, bool) or not isinstance(priority, int) or not 0 <= priority <= 4: - raise TaskError(ErrorCode.INVALID_ARGUMENT, "priority must be an integer from 0 through 4") + if ( + isinstance(priority, bool) + or not isinstance(priority, int) + or not 0 <= priority <= 4 + ): + raise TaskError( + ErrorCode.INVALID_ARGUMENT, + "priority must be an integer from 0 through 4", + ) command = [ "create", "--title", @@ -588,43 +998,96 @@ def _mutation_command(self, operation: str, arguments: dict[str, Any]) -> tuple[ if labels: command.extend(("--labels", ",".join(labels))) if "parent_task_id" in arguments: - command.extend(("--parent", self._task_id(arguments["parent_task_id"], "parent_task_id"))) + command.extend( + ( + "--parent", + self._task_id(arguments["parent_task_id"], "parent_task_id"), + ) + ) dependencies = self._dependencies(arguments["dependencies"]) if dependencies: - command.extend(("--deps", ",".join(f"{relation}:{task_id}" for relation, task_id in dependencies))) + command.extend( + ( + "--deps", + ",".join( + f"{relation}:{task_id}" + for relation, task_id in dependencies + ), + ) + ) return tuple(command) if operation == "task.claim": self._require_exact(arguments, {"project_id", "task_id"}, operation) return ("update", self._task_id(arguments["task_id"]), "--claim") if operation == "task.note": self._require_exact(arguments, {"project_id", "task_id", "text"}, operation) - return ("note", self._task_id(arguments["task_id"]), self._string(arguments["text"], "text", 32_000)) + return ( + "note", + self._task_id(arguments["task_id"]), + self._string(arguments["text"], "text", 32_000), + ) if operation == "task.update": - self._require_exact(arguments, {"project_id", "task_id", "metadata"}, operation) + self._require_exact( + arguments, {"project_id", "task_id", "metadata"}, operation + ) metadata = arguments["metadata"] if not isinstance(metadata, dict) or not metadata or len(metadata) > 32: - raise TaskError(ErrorCode.INVALID_ARGUMENT, "metadata must be a non-empty object with at most 32 entries") + raise TaskError( + ErrorCode.INVALID_ARGUMENT, + "metadata must be a non-empty object with at most 32 entries", + ) command = ["update", self._task_id(arguments["task_id"])] for key, value in sorted(metadata.items()): - command.extend(("--set-metadata", f"{self._string(key, 'metadata key', 256)}={self._string(value, 'metadata value', 4_000)}")) + command.extend( + ( + "--set-metadata", + f"{self._string(key, 'metadata key', 256)}={self._string(value, 'metadata value', 4_000)}", + ) + ) return tuple(command) if operation == "task.relate": - self._require_exact(arguments, {"project_id", "task_id", "related_task_id"}, operation) - return ("dep", "relate", self._task_id(arguments["task_id"]), self._task_id(arguments["related_task_id"], "related_task_id")) + self._require_exact( + arguments, {"project_id", "task_id", "related_task_id"}, operation + ) + return ( + "dep", + "relate", + self._task_id(arguments["task_id"]), + self._task_id(arguments["related_task_id"], "related_task_id"), + ) if operation == "task.complete": - self._require_allowed(arguments, {"project_id", "task_id", "merge_sha", "reason"}, {"project_id", "task_id", "merge_sha"}, operation) + self._require_allowed( + arguments, + {"project_id", "task_id", "merge_sha", "reason"}, + {"project_id", "task_id", "merge_sha"}, + operation, + ) command = ["close", self._task_id(arguments["task_id"])] self._merge_sha(arguments["merge_sha"]) if "reason" in arguments: - command.extend(("--reason", self._string(arguments["reason"], "reason", 32_000))) + command.extend( + ("--reason", self._string(arguments["reason"], "reason", 32_000)) + ) return tuple(command) if operation == "task.release": - self._require_allowed(arguments, {"project_id", "task_id", "reason", "if_assignee"}, {"project_id", "task_id"}, operation) + self._require_allowed( + arguments, + {"project_id", "task_id", "reason", "if_assignee"}, + {"project_id", "task_id"}, + operation, + ) command = ["unclaim", self._task_id(arguments["task_id"])] if "reason" in arguments: - command.extend(("--reason", self._string(arguments["reason"], "reason", 32_000))) + command.extend( + ("--reason", self._string(arguments["reason"], "reason", 32_000)) + ) if "if_assignee" in arguments: - command.extend(("--if-assignee", self._string(arguments["if_assignee"], "if_assignee", 256))) + command.extend( + ( + "--if-assignee", + self._string(arguments["if_assignee"], "if_assignee", 256), + ) + ) return tuple(command) raise AssertionError(f"unsupported idempotent mutation: {operation}") @@ -651,17 +1114,41 @@ def _start_reconcile(self, project: ProjectAdapter) -> dict[str, Any]: job_id = str(uuid4()) authority = self._authority(project) environment = project.environment.values() - environment.update({"SINNIXD_JOB_ID": job_id, "SINNIXD_PROJECT_ID": project.project_id, "SINNIXD_OPERATION": "task.reconcile", "BEADS_DIR": str(authority.root / ".beads")}) + environment.update( + { + "SINNIXD_JOB_ID": job_id, + "SINNIXD_PROJECT_ID": project.project_id, + "SINNIXD_OPERATION": "task.reconcile", + "BEADS_DIR": str(authority.root / ".beads"), + } + ) return self.jobs.start( GenericJobSpec( kind="foreground-command", - command=(FLOCK_EXECUTABLE, "--exclusive", str(self._lock_path(project)), "sinnixd-task-reconcile", "--project-id", project.project_id, "--project-root", str(project.root), "--task-state-root", str(self.task_state_root)), - working_directory=str(project.root), environment=environment, timeout_seconds=TASK_RECONCILE_TIMEOUT_SECONDS, project_id=project.project_id, operation="task.reconcile", + command=( + FLOCK_EXECUTABLE, + "--exclusive", + str(self._lock_path(project)), + "sinnixd-task-reconcile", + "--project-id", + project.project_id, + "--project-root", + str(project.root), + "--task-state-root", + str(self.task_state_root), + ), + working_directory=str(project.root), + environment=environment, + timeout_seconds=TASK_RECONCILE_TIMEOUT_SECONDS, + project_id=project.project_id, + operation="task.reconcile", ), job_id, ) - def _list(self, project: ProjectAdapter, arguments: dict[str, Any], *, principal: str) -> dict[str, Any]: + def _list( + self, project: ProjectAdapter, arguments: dict[str, Any], *, principal: str + ) -> dict[str, Any]: limit, order, query = self._list_query(arguments) query_sha256 = _canonical_digest(query) cursor = arguments.get("cursor") @@ -684,10 +1171,17 @@ def _list(self, project: ProjectAdapter, arguments: dict[str, Any], *, principal max_stdout_bytes=MAX_TASK_LIST_SOURCE_BYTES, ) if not isinstance(result, list): - raise TaskError(ErrorCode.RESULT_INVALID, "task backend returned an invalid list result") + raise TaskError( + ErrorCode.RESULT_INVALID, + "task backend returned an invalid list result", + ) rows = result - if len(rows) > MAX_TASK_LIST_ROWS or any(not isinstance(row, dict) for row in rows): - raise TaskError(ErrorCode.RESULT_INVALID, "task backend returned invalid list rows") + if len(rows) > MAX_TASK_LIST_ROWS or any( + not isinstance(row, dict) for row in rows + ): + raise TaskError( + ErrorCode.RESULT_INVALID, "task backend returned invalid list rows" + ) snapshot = self._create_task_list_snapshot( project, principal=principal, query_sha256=query_sha256, rows=rows ) @@ -695,7 +1189,9 @@ def _list(self, project: ProjectAdapter, arguments: dict[str, Any], *, principal rows = snapshot["rows"] if offset > len(rows): - raise TaskListCursorError("task list cursor is beyond its snapshot", stale=True) + raise TaskListCursorError( + "task list cursor is beyond its snapshot", stale=True + ) page_rows = rows[offset : offset + limit] next_offset = offset + len(page_rows) next_cursor = ( @@ -731,20 +1227,49 @@ def _list(self, project: ProjectAdapter, arguments: dict[str, Any], *, principal "complete": next_cursor is None, }, } - if len(json.dumps(page, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()) > MAX_TASK_OUTPUT_BYTES: - raise TaskError(ErrorCode.RESOURCE_EXHAUSTED, "task list page exceeds the response bound") + if ( + len( + json.dumps( + page, sort_keys=True, separators=(",", ":"), ensure_ascii=False + ).encode() + ) + > MAX_TASK_OUTPUT_BYTES + ): + raise TaskError( + ErrorCode.RESOURCE_EXHAUSTED, + "task list page exceeds the response bound", + ) return page - def _list_query(self, arguments: dict[str, Any]) -> tuple[int, dict[str, Any] | None, dict[str, Any]]: + def _list_query( + self, arguments: dict[str, Any] + ) -> tuple[int, dict[str, Any] | None, dict[str, Any]]: self._require_allowed( arguments, - {"project_id", "status", "assignee", "label", "limit", "include_closed", "ready", "order", "cursor"}, + { + "project_id", + "status", + "assignee", + "label", + "limit", + "include_closed", + "ready", + "order", + "cursor", + }, {"project_id"}, "task.list", ) limit = arguments.get("limit", 100) - if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 1_000: - raise TaskError(ErrorCode.INVALID_ARGUMENT, "limit must be an integer from 1 through 1000") + if ( + isinstance(limit, bool) + or not isinstance(limit, int) + or not 1 <= limit <= 1_000 + ): + raise TaskError( + ErrorCode.INVALID_ARGUMENT, + "limit must be an integer from 1 through 1000", + ) filters: dict[str, Any] = {} for name in ("status", "assignee", "label"): if name in arguments: @@ -757,11 +1282,30 @@ def _list_query(self, arguments: dict[str, Any]) -> tuple[int, dict[str, Any] | filters[name] = True order = arguments.get("order") if order is not None: - if not isinstance(order, Mapping) or set(order) - {"field", "reverse"} or "field" not in order: - raise TaskError(ErrorCode.INVALID_ARGUMENT, "order must contain a supported field and optional reverse") + if ( + not isinstance(order, Mapping) + or set(order) - {"field", "reverse"} + or "field" not in order + ): + raise TaskError( + ErrorCode.INVALID_ARGUMENT, + "order must contain a supported field and optional reverse", + ) field_name = order["field"] - if not isinstance(field_name, str) or field_name not in {"priority", "created", "updated", "closed", "status", "id", "title", "type", "assignee"}: - raise TaskError(ErrorCode.INVALID_ARGUMENT, "order field is unsupported") + if not isinstance(field_name, str) or field_name not in { + "priority", + "created", + "updated", + "closed", + "status", + "id", + "title", + "type", + "assignee", + }: + raise TaskError( + ErrorCode.INVALID_ARGUMENT, "order field is unsupported" + ) reverse = order.get("reverse", False) if not isinstance(reverse, bool): raise TaskError(ErrorCode.INVALID_ARGUMENT, "order is malformed") @@ -769,10 +1313,19 @@ def _list_query(self, arguments: dict[str, Any]) -> tuple[int, dict[str, Any] | if "cursor" in arguments: cursor = arguments["cursor"] if not isinstance(cursor, str) or not cursor: - raise TaskError(ErrorCode.INVALID_ARGUMENT, "task list cursor must be a string") + raise TaskError( + ErrorCode.INVALID_ARGUMENT, "task list cursor must be a string" + ) if len(cursor.encode()) > MAX_TASK_LIST_CURSOR_BYTES: - raise TaskError(ErrorCode.INVALID_ARGUMENT, "task list cursor exceeds its bound") - query = {"project_id": arguments["project_id"], "filters": filters, "limit": limit, "order": order} + raise TaskError( + ErrorCode.INVALID_ARGUMENT, "task list cursor exceeds its bound" + ) + query = { + "project_id": arguments["project_id"], + "filters": filters, + "limit": limit, + "order": order, + } return limit, order, query def _list_command(self, arguments: dict[str, Any]) -> tuple[str, ...]: @@ -781,7 +1334,11 @@ def _list_command(self, arguments: dict[str, Any]) -> tuple[str, ...]: for name, flag in (("include_closed", "--all"), ("ready", "--ready")): if arguments.get(name, False): command.append(flag) - for name, flag in (("status", "--status"), ("assignee", "--assignee"), ("label", "--label")): + for name, flag in ( + ("status", "--status"), + ("assignee", "--assignee"), + ("label", "--label"), + ): if name in arguments: command.extend((flag, self._string(arguments[name], name, 256))) if order is not None: @@ -801,7 +1358,9 @@ def _task_list_cursor_key(self, project: ProjectAdapter) -> bytes: if key.endswith(b"\n"): key = key[:-1] if len(key) != 32: - raise TaskError(ErrorCode.OPERATION_FAILED, "task list cursor key is malformed") + raise TaskError( + ErrorCode.OPERATION_FAILED, "task list cursor key is malformed" + ) return key def _encode_task_list_cursor( @@ -826,10 +1385,16 @@ def _encode_task_list_cursor( separators=(",", ":"), ).encode() encoded = base64.urlsafe_b64encode(payload).decode().rstrip("=") - signature = hmac.new(self._task_list_cursor_key(project), encoded.encode(), hashlib.sha256).digest() - cursor = encoded + "." + base64.urlsafe_b64encode(signature).decode().rstrip("=") + signature = hmac.new( + self._task_list_cursor_key(project), encoded.encode(), hashlib.sha256 + ).digest() + cursor = ( + encoded + "." + base64.urlsafe_b64encode(signature).decode().rstrip("=") + ) if len(cursor.encode()) > MAX_TASK_LIST_CURSOR_BYTES: - raise TaskError(ErrorCode.RESOURCE_EXHAUSTED, "task list cursor exceeds its bound") + raise TaskError( + ErrorCode.RESOURCE_EXHAUSTED, "task list cursor exceeds its bound" + ) return cursor def _decode_task_list_cursor( @@ -840,19 +1405,47 @@ def _decode_task_list_cursor( query_sha256: str, cursor: Any, ) -> tuple[str, int, str]: - if not isinstance(cursor, str) or not cursor or len(cursor.encode()) > MAX_TASK_LIST_CURSOR_BYTES: + if ( + not isinstance(cursor, str) + or not cursor + or len(cursor.encode()) > MAX_TASK_LIST_CURSOR_BYTES + ): raise TaskListCursorError("task list cursor is malformed") encoded, separator, supplied_signature = cursor.partition(".") if not separator or not encoded or not supplied_signature: raise TaskListCursorError("task list cursor is malformed") - expected_signature = base64.urlsafe_b64encode(hmac.new(self._task_list_cursor_key(project), encoded.encode(), hashlib.sha256).digest()).decode().rstrip("=") + expected_signature = ( + base64.urlsafe_b64encode( + hmac.new( + self._task_list_cursor_key(project), + encoded.encode(), + hashlib.sha256, + ).digest() + ) + .decode() + .rstrip("=") + ) if not hmac.compare_digest(supplied_signature, expected_signature): raise TaskListCursorError("task list cursor signature is invalid") try: - payload = json.loads(base64.urlsafe_b64decode(encoded + "=" * (-len(encoded) % 4))) + payload = json.loads( + base64.urlsafe_b64decode(encoded + "=" * (-len(encoded) % 4)) + ) except (ValueError, UnicodeDecodeError, json.JSONDecodeError) as error: raise TaskListCursorError("task list cursor is malformed") from error - if not isinstance(payload, dict) or set(payload) != {"schema", "snapshot_id", "offset", "principal", "query_sha256", "source_revision"} or payload["schema"] != 1: + if ( + not isinstance(payload, dict) + or set(payload) + != { + "schema", + "snapshot_id", + "offset", + "principal", + "query_sha256", + "source_revision", + } + or payload["schema"] != 1 + ): raise TaskListCursorError("task list cursor is malformed") snapshot_id = payload["snapshot_id"] offset = payload["offset"] @@ -874,12 +1467,21 @@ def _decode_task_list_cursor( ): raise TaskListCursorError("task list cursor is malformed") if cursor_principal != principal: - raise TaskListCursorError("task list cursor does not belong to this principal") + raise TaskListCursorError( + "task list cursor does not belong to this principal" + ) if cursor_query_sha256 != query_sha256: raise TaskListCursorError("task list cursor does not match this query") return snapshot_id, offset, source_revision - def _create_task_list_snapshot(self, project: ProjectAdapter, *, principal: str, query_sha256: str, rows: list[Any]) -> dict[str, Any]: + def _create_task_list_snapshot( + self, + project: ProjectAdapter, + *, + principal: str, + query_sha256: str, + rows: list[Any], + ) -> dict[str, Any]: source_revision = _canonical_digest(rows) snapshot_id = secrets.token_hex(16) snapshot = { @@ -891,15 +1493,23 @@ def _create_task_list_snapshot(self, project: ProjectAdapter, *, principal: str, "source_revision": source_revision, "rows": rows, } - encoded = json.dumps(snapshot, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode() + encoded = json.dumps( + snapshot, sort_keys=True, separators=(",", ":"), ensure_ascii=False + ).encode() if len(encoded) + 1 > MAX_TASK_LIST_SNAPSHOT_BYTES: - raise TaskError(ErrorCode.RESOURCE_EXHAUSTED, "task list snapshot exceeds its bound") + raise TaskError( + ErrorCode.RESOURCE_EXHAUSTED, "task list snapshot exceeds its bound" + ) root = self._authority(project).root / TASK_LIST_SNAPSHOT_DIRECTORY _ensure_durable_directory(root) - snapshots = sorted(root.glob("*.json"), key=lambda path: path.stat().st_mtime_ns) + snapshots = sorted( + root.glob("*.json"), key=lambda path: path.stat().st_mtime_ns + ) for old in snapshots[: max(0, len(snapshots) - MAX_TASK_LIST_SNAPSHOTS + 1)]: old.unlink(missing_ok=True) - self._write_bounded(self._task_list_snapshot_path(project, snapshot_id), encoded) + self._write_bounded( + self._task_list_snapshot_path(project, snapshot_id), encoded + ) return snapshot def _load_task_list_snapshot( @@ -911,15 +1521,23 @@ def _load_task_list_snapshot( query_sha256: str, source_revision: str, ) -> dict[str, Any]: - snapshot = self._read_task_list_snapshot(self._task_list_snapshot_path(project, snapshot_id)) + snapshot = self._read_task_list_snapshot( + self._task_list_snapshot_path(project, snapshot_id) + ) if snapshot["principal"] != principal: - raise TaskListCursorError("task list cursor does not belong to this principal") + raise TaskListCursorError( + "task list cursor does not belong to this principal" + ) if snapshot["project_id"] != project.project_id: - raise TaskListCursorError("task list cursor does not belong to this project") + raise TaskListCursorError( + "task list cursor does not belong to this project" + ) if snapshot["query_sha256"] != query_sha256: raise TaskListCursorError("task list cursor does not match this query") if snapshot["source_revision"] != source_revision: - raise TaskListCursorError("task list cursor source revision is stale", stale=True) + raise TaskListCursorError( + "task list cursor source revision is stale", stale=True + ) return snapshot def _read_task_list_snapshot(self, path: Path) -> dict[str, Any]: @@ -929,26 +1547,69 @@ def _read_task_list_snapshot(self, path: Path) -> dict[str, Any]: raise ValueError snapshot = json.loads(encoded) except (OSError, UnicodeDecodeError, json.JSONDecodeError, ValueError) as error: - raise TaskListCursorError("task list cursor snapshot is unavailable", stale=True) from error - if not isinstance(snapshot, dict) or set(snapshot) != {"schema", "snapshot_id", "principal", "project_id", "query_sha256", "source_revision", "rows"} or snapshot["schema"] != 1: - raise TaskListCursorError("task list cursor snapshot is malformed", stale=True) - if not isinstance(snapshot["snapshot_id"], str) or not re.fullmatch(r"[0-9a-f]{32}", snapshot["snapshot_id"]): - raise TaskListCursorError("task list cursor snapshot is malformed", stale=True) - if not isinstance(snapshot["principal"], str) or not isinstance(snapshot["project_id"], str) or not isinstance(snapshot["query_sha256"], str) or not isinstance(snapshot["source_revision"], str) or not _SHA256_RE.fullmatch(snapshot["query_sha256"]) or not _SHA256_RE.fullmatch(snapshot["source_revision"]) or not isinstance(snapshot["rows"], list) or len(snapshot["rows"]) > MAX_TASK_LIST_ROWS or any(not isinstance(row, dict) for row in snapshot["rows"]): - raise TaskListCursorError("task list cursor snapshot is malformed", stale=True) + raise TaskListCursorError( + "task list cursor snapshot is unavailable", stale=True + ) from error + if ( + not isinstance(snapshot, dict) + or set(snapshot) + != { + "schema", + "snapshot_id", + "principal", + "project_id", + "query_sha256", + "source_revision", + "rows", + } + or snapshot["schema"] != 1 + ): + raise TaskListCursorError( + "task list cursor snapshot is malformed", stale=True + ) + if not isinstance(snapshot["snapshot_id"], str) or not re.fullmatch( + r"[0-9a-f]{32}", snapshot["snapshot_id"] + ): + raise TaskListCursorError( + "task list cursor snapshot is malformed", stale=True + ) + if ( + not isinstance(snapshot["principal"], str) + or not isinstance(snapshot["project_id"], str) + or not isinstance(snapshot["query_sha256"], str) + or not isinstance(snapshot["source_revision"], str) + or not _SHA256_RE.fullmatch(snapshot["query_sha256"]) + or not _SHA256_RE.fullmatch(snapshot["source_revision"]) + or not isinstance(snapshot["rows"], list) + or len(snapshot["rows"]) > MAX_TASK_LIST_ROWS + or any(not isinstance(row, dict) for row in snapshot["rows"]) + ): + raise TaskListCursorError( + "task list cursor snapshot is malformed", stale=True + ) if _canonical_digest(snapshot["rows"]) != snapshot["source_revision"]: - raise TaskListCursorError("task list cursor snapshot content is stale", stale=True) + raise TaskListCursorError( + "task list cursor snapshot content is stale", stale=True + ) return snapshot - def _task_list_snapshot_path(self, project: ProjectAdapter, snapshot_id: str) -> Path: + def _task_list_snapshot_path( + self, project: ProjectAdapter, snapshot_id: str + ) -> Path: if not re.fullmatch(r"[0-9a-f]{32}", snapshot_id): raise TaskListCursorError("task list cursor snapshot is malformed") - return self._authority(project).root / TASK_LIST_SNAPSHOT_DIRECTORY / f"{snapshot_id}.json" + return ( + self._authority(project).root + / TASK_LIST_SNAPSHOT_DIRECTORY + / f"{snapshot_id}.json" + ) @staticmethod def _write_bounded(path: Path, encoded: bytes) -> None: if len(encoded) > MAX_TASK_LIST_SNAPSHOT_BYTES: - raise TaskError(ErrorCode.RESOURCE_EXHAUSTED, "task list state exceeds its bound") + raise TaskError( + ErrorCode.RESOURCE_EXHAUSTED, "task list state exceeds its bound" + ) TaskMutationJournal._write(path, encoded) def _project(self, arguments: dict[str, Any]) -> ProjectAdapter: @@ -977,28 +1638,46 @@ def _mutation_id(self, value: str | None) -> str: def _merge_sha(self, value: Any) -> str: value = self._string(value, "merge_sha", 64) if not _MERGE_SHA_RE.fullmatch(value): - raise TaskError(ErrorCode.INVALID_ARGUMENT, "merge_sha must be a lowercase Git SHA") + raise TaskError( + ErrorCode.INVALID_ARGUMENT, "merge_sha must be a lowercase Git SHA" + ) return value @staticmethod def _authorize(principal: str, *, write: bool) -> None: if principal not in (_WRITE_PRINCIPALS if write else _READ_PRINCIPALS): - raise TaskError(ErrorCode.POLICY_DENIED, "task mutation requires an authorized principal" if write else "task read requires an authorized principal") + raise TaskError( + ErrorCode.POLICY_DENIED, + "task mutation requires an authorized principal" + if write + else "task read requires an authorized principal", + ) @staticmethod - def _require_exact(arguments: dict[str, Any], expected: set[str], operation: str) -> None: + def _require_exact( + arguments: dict[str, Any], expected: set[str], operation: str + ) -> None: if set(arguments) != expected: - raise TaskError(ErrorCode.INVALID_ARGUMENT, f"{operation} requires exactly: {', '.join(sorted(expected))}") + raise TaskError( + ErrorCode.INVALID_ARGUMENT, + f"{operation} requires exactly: {', '.join(sorted(expected))}", + ) @staticmethod - def _require_allowed(arguments: dict[str, Any], allowed: set[str], required: set[str], operation: str) -> None: + def _require_allowed( + arguments: dict[str, Any], allowed: set[str], required: set[str], operation: str + ) -> None: if set(arguments) - allowed or not required <= set(arguments): - raise TaskError(ErrorCode.INVALID_ARGUMENT, f"{operation} received invalid arguments") + raise TaskError( + ErrorCode.INVALID_ARGUMENT, f"{operation} received invalid arguments" + ) @staticmethod def _string(value: Any, name: str, maximum: int = 8_192) -> str: if not isinstance(value, str) or not value or len(value) > maximum: - raise TaskError(ErrorCode.INVALID_ARGUMENT, f"{name} must be a non-empty bounded string") + raise TaskError( + ErrorCode.INVALID_ARGUMENT, f"{name} must be a non-empty bounded string" + ) return value def _task_id(self, value: Any, name: str = "task_id") -> str: @@ -1009,22 +1688,37 @@ def _task_id(self, value: Any, name: str = "task_id") -> str: def _labels(self, value: Any) -> tuple[str, ...]: if not isinstance(value, list) or len(value) > 32: - raise TaskError(ErrorCode.INVALID_ARGUMENT, "labels must be a list of at most 32 labels") - if any(not isinstance(label, str) or not _LABEL_RE.fullmatch(label) for label in value) or len(set(value)) != len(value): + raise TaskError( + ErrorCode.INVALID_ARGUMENT, "labels must be a list of at most 32 labels" + ) + if any( + not isinstance(label, str) or not _LABEL_RE.fullmatch(label) + for label in value + ) or len(set(value)) != len(value): raise TaskError(ErrorCode.INVALID_ARGUMENT, "labels are malformed") return tuple(value) def _dependencies(self, value: Any) -> tuple[tuple[str, str], ...]: if not isinstance(value, list) or len(value) > 32: - raise TaskError(ErrorCode.INVALID_ARGUMENT, "dependencies must be a list of at most 32 relations") + raise TaskError( + ErrorCode.INVALID_ARGUMENT, + "dependencies must be a list of at most 32 relations", + ) dependencies: list[tuple[str, str]] = [] for dependency in value: - if not isinstance(dependency, dict) or set(dependency) != {"relation", "task_id"}: + if not isinstance(dependency, dict) or set(dependency) != { + "relation", + "task_id", + }: raise TaskError(ErrorCode.INVALID_ARGUMENT, "dependency is malformed") relation = self._string(dependency["relation"], "dependency relation", 32) if relation not in _DEPENDENCY_RELATIONS: - raise TaskError(ErrorCode.INVALID_ARGUMENT, "dependency relation is unsupported") - dependencies.append((relation, self._task_id(dependency["task_id"], "dependency task_id"))) + raise TaskError( + ErrorCode.INVALID_ARGUMENT, "dependency relation is unsupported" + ) + dependencies.append( + (relation, self._task_id(dependency["task_id"], "dependency task_id")) + ) if len(set(dependencies)) != len(dependencies): raise TaskError(ErrorCode.INVALID_ARGUMENT, "dependencies must be unique") return tuple(dependencies) @@ -1032,16 +1726,28 @@ def _dependencies(self, value: Any) -> tuple[tuple[str, str], ...]: @staticmethod def _created_task_id(result: Any) -> str: if not isinstance(result, dict): - raise TaskError(ErrorCode.RESULT_INVALID, "task backend returned an invalid task.create result") + raise TaskError( + ErrorCode.RESULT_INVALID, + "task backend returned an invalid task.create result", + ) value = result.get("id") if not isinstance(value, str) or not _ID_RE.fullmatch(value): - raise TaskError(ErrorCode.RESULT_INVALID, "task backend omitted the created task") + raise TaskError( + ErrorCode.RESULT_INVALID, "task backend omitted the created task" + ) return value @staticmethod def _single_task_result(result: Any, *, operation: str) -> dict[str, Any]: - if not isinstance(result, list) or len(result) != 1 or not isinstance(result[0], dict): - raise TaskError(ErrorCode.RESULT_INVALID, f"task backend returned an invalid {operation} result") + if ( + not isinstance(result, list) + or len(result) != 1 + or not isinstance(result[0], dict) + ): + raise TaskError( + ErrorCode.RESULT_INVALID, + f"task backend returned an invalid {operation} result", + ) return result[0] @staticmethod @@ -1057,15 +1763,41 @@ def task_reconcile_main(argv: list[str] | None = None) -> int: parser.add_argument("--project-root", type=Path, required=True) parser.add_argument("--task-state-root", type=Path, required=True) arguments = parser.parse_args(argv) - if not _ID_RE.fullmatch(arguments.project_id) or not arguments.project_root.is_absolute() or not arguments.task_state_root.is_absolute(): + if ( + not _ID_RE.fullmatch(arguments.project_id) + or not arguments.project_root.is_absolute() + or not arguments.task_state_root.is_absolute() + ): parser.error("project ID and paths are invalid") try: authority = TaskAuthority.load(arguments.task_state_root, arguments.project_id) journal = TaskMutationJournal(authority.root / TASK_MUTATION_JOURNAL_DIRECTORY) - receipts = reconcile_task_mutations(journal=journal, authority=authority, cwd=arguments.project_root, boundary=BeadsCommandBoundary()) - sync = _run_task_command(BeadsCommandBoundary(), authority, arguments.project_root, ("sync", "--no-adopt"), readonly=False) + receipts = reconcile_task_mutations( + journal=journal, + authority=authority, + cwd=arguments.project_root, + boundary=BeadsCommandBoundary(), + ) + sync = _run_task_command( + BeadsCommandBoundary(), + authority, + arguments.project_root, + ("sync", "--no-adopt"), + readonly=False, + ) except TaskError as error: print(json.dumps({"state": "failed", "code": error.code.value}, sort_keys=True)) return 1 - print(json.dumps({"state": "applied", "mutations": list(receipts), "sync_sha256": TaskMutationIdentity.digest(json.dumps(sync, sort_keys=True, separators=(",", ":")))}, sort_keys=True)) + print( + json.dumps( + { + "state": "applied", + "mutations": list(receipts), + "sync_sha256": TaskMutationIdentity.digest( + json.dumps(sync, sort_keys=True, separators=(",", ":")) + ), + }, + sort_keys=True, + ) + ) return 0 diff --git a/pkgs/sinnixd/sinnixd/workspaces.py b/pkgs/sinnixd/sinnixd/workspaces.py index b2e7219f..59de32a1 100644 --- a/pkgs/sinnixd/sinnixd/workspaces.py +++ b/pkgs/sinnixd/sinnixd/workspaces.py @@ -4,14 +4,14 @@ import io import os import re -import stat -from fnmatch import fnmatch import shutil +import stat import subprocess import tarfile import tempfile from dataclasses import dataclass from datetime import UTC, datetime +from fnmatch import fnmatch from pathlib import Path from typing import Any, Mapping, Sequence from uuid import uuid4 @@ -21,7 +21,6 @@ from .projects import ProjectAdapter, ProjectCatalog, RegisteredCheckout - WORKSPACE_SCHEMA_VERSION = 1 CHECKPOINT_SCHEMA_VERSION = 1 STACK_SCHEMA_VERSION = 2 @@ -61,12 +60,27 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls, value: Mapping[str, Any]) -> WorkspaceRecord: required = { - "schema_version", "workspace_id", "project_id", "name", "path", "branch", "base", "created_at", "managed" + "schema_version", + "workspace_id", + "project_id", + "name", + "path", + "branch", + "base", + "created_at", + "managed", } - if set(value) != required or value.get("schema_version") != WORKSPACE_SCHEMA_VERSION: + if ( + set(value) != required + or value.get("schema_version") != WORKSPACE_SCHEMA_VERSION + ): raise WorkspaceError("workspace record schema is invalid") - strings = {key: value.get(key) for key in required - {"schema_version", "managed"}} - if any(not isinstance(item, str) or not item for item in strings.values()) or not isinstance(value.get("managed"), bool): + strings = { + key: value.get(key) for key in required - {"schema_version", "managed"} + } + if any( + not isinstance(item, str) or not item for item in strings.values() + ) or not isinstance(value.get("managed"), bool): raise WorkspaceError("workspace record fields are invalid") return cls( workspace_id=strings["workspace_id"], @@ -127,10 +141,27 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls, value: Mapping[str, Any]) -> StackRecord: - required = {"schema_version", "child_workspace_id", "parent_workspace_id", "created_at", "parent_head"} - if set(value) != required or value.get("schema_version") != STACK_SCHEMA_VERSION: + required = { + "schema_version", + "child_workspace_id", + "parent_workspace_id", + "created_at", + "parent_head", + } + if ( + set(value) != required + or value.get("schema_version") != STACK_SCHEMA_VERSION + ): raise WorkspaceError("workspace stack record schema is invalid") - fields = tuple(value.get(key) for key in ("child_workspace_id", "parent_workspace_id", "created_at", "parent_head")) + fields = tuple( + value.get(key) + for key in ( + "child_workspace_id", + "parent_workspace_id", + "created_at", + "parent_head", + ) + ) if any(not isinstance(item, str) or not item for item in fields): raise WorkspaceError("workspace stack record fields are invalid") return cls(*fields) @@ -144,18 +175,28 @@ def __init__(self, root: Path) -> None: self.stacks = self.root / "stacks.json" def records(self) -> tuple[WorkspaceRecord, ...]: - payload = read_json(self.index, {"schema_version": WORKSPACE_SCHEMA_VERSION, "workspaces": []}) - if not isinstance(payload, Mapping) or payload.get("schema_version") != WORKSPACE_SCHEMA_VERSION: + payload = read_json( + self.index, {"schema_version": WORKSPACE_SCHEMA_VERSION, "workspaces": []} + ) + if ( + not isinstance(payload, Mapping) + or payload.get("schema_version") != WORKSPACE_SCHEMA_VERSION + ): raise WorkspaceError("workspace index schema is invalid") rows = payload.get("workspaces") - if not isinstance(rows, list) or any(not isinstance(row, Mapping) for row in rows): + if not isinstance(rows, list) or any( + not isinstance(row, Mapping) for row in rows + ): raise WorkspaceError("workspace index rows are invalid") return tuple(WorkspaceRecord.from_dict(row) for row in rows) def put(self, record: WorkspaceRecord) -> None: default = {"schema_version": WORKSPACE_SCHEMA_VERSION, "workspaces": []} with modify_json(self.index, default, mode=0o600) as payload: - if not isinstance(payload, dict) or payload.get("schema_version") != WORKSPACE_SCHEMA_VERSION: + if ( + not isinstance(payload, dict) + or payload.get("schema_version") != WORKSPACE_SCHEMA_VERSION + ): raise WorkspaceError("workspace index schema is invalid") rows = payload.get("workspaces") if not isinstance(rows, list): @@ -163,24 +204,36 @@ def put(self, record: WorkspaceRecord) -> None: existing = [WorkspaceRecord.from_dict(row) for row in rows] if any(item.workspace_id == record.workspace_id for item in existing): raise WorkspaceError("workspace ID already exists") - if any(item.project_id == record.project_id and (item.name == record.name or item.path == record.path) for item in existing): + if any( + item.project_id == record.project_id + and (item.name == record.name or item.path == record.path) + for item in existing + ): raise WorkspaceError("workspace name or path is already registered") rows.append(record.to_dict()) def remove(self, workspace_id: str) -> WorkspaceRecord: default = {"schema_version": WORKSPACE_SCHEMA_VERSION, "workspaces": []} with modify_json(self.index, default, mode=0o600) as payload: - if not isinstance(payload, dict) or payload.get("schema_version") != WORKSPACE_SCHEMA_VERSION: + if ( + not isinstance(payload, dict) + or payload.get("schema_version") != WORKSPACE_SCHEMA_VERSION + ): raise WorkspaceError("workspace index schema is invalid") rows = payload.get("workspaces") if not isinstance(rows, list): raise WorkspaceError("workspace index rows are invalid") records = [WorkspaceRecord.from_dict(row) for row in rows] - removed = next((record for record in records if record.workspace_id == workspace_id), None) + removed = next( + (record for record in records if record.workspace_id == workspace_id), + None, + ) if removed is None: raise KeyError(f"unknown workspace: {workspace_id}") payload["workspaces"] = [ - record.to_dict() for record in records if record.workspace_id != workspace_id + record.to_dict() + for record in records + if record.workspace_id != workspace_id ] shutil.rmtree(self.checkpoints_root / workspace_id, ignore_errors=True) return removed @@ -189,11 +242,15 @@ def checkpoint_path(self, workspace_id: str, checkpoint_id: str) -> Path: return self.checkpoints_root / workspace_id / checkpoint_id def stack_records(self) -> tuple[StackRecord, ...]: - payload = read_json(self.stacks, {"schema_version": STACK_SCHEMA_VERSION, "stacks": []}) + payload = read_json( + self.stacks, {"schema_version": STACK_SCHEMA_VERSION, "stacks": []} + ) if not isinstance(payload, Mapping): raise WorkspaceError("workspace stack index schema is invalid") rows = payload.get("stacks") - if not isinstance(rows, list) or any(not isinstance(row, Mapping) for row in rows): + if not isinstance(rows, list) or any( + not isinstance(row, Mapping) for row in rows + ): raise WorkspaceError("workspace stack index rows are invalid") if payload.get("schema_version") == 1 and not rows: return () @@ -210,7 +267,10 @@ def put_stack(self, record: StackRecord) -> None: if payload.get("schema_version") == 1 and not rows: payload["schema_version"] = STACK_SCHEMA_VERSION existing = [StackRecord.from_dict(row) for row in rows] - if any(item.child_workspace_id == record.child_workspace_id for item in existing): + if any( + item.child_workspace_id == record.child_workspace_id + for item in existing + ): raise WorkspaceError("workspace already has a stack parent") rows.append(record.to_dict()) @@ -223,13 +283,16 @@ def remove_stack_references(self, workspace_id: str) -> None: if payload.get("schema_version") == 1 and not rows: payload["schema_version"] = STACK_SCHEMA_VERSION payload["stacks"] = [ - row for row in rows + row + for row in rows if isinstance(row, Mapping) and row.get("child_workspace_id") != workspace_id and row.get("parent_workspace_id") != workspace_id ] - def update_stack_parent_head(self, child_workspace_id: str, parent_head: str) -> None: + def update_stack_parent_head( + self, child_workspace_id: str, parent_head: str + ) -> None: default = {"schema_version": STACK_SCHEMA_VERSION, "stacks": []} with modify_json(self.stacks, default, mode=0o600) as payload: rows = payload.get("stacks") if isinstance(payload, dict) else None @@ -238,14 +301,18 @@ def update_stack_parent_head(self, child_workspace_id: str, parent_head: str) -> if payload.get("schema_version") == 1 and not rows: payload["schema_version"] = STACK_SCHEMA_VERSION records = [StackRecord.from_dict(row) for row in rows] - if not any(record.child_workspace_id == child_workspace_id for record in records): + if not any( + record.child_workspace_id == child_workspace_id for record in records + ): raise WorkspaceError("workspace is not a stacked child") payload["stacks"] = [ StackRecord( child_workspace_id=record.child_workspace_id, parent_workspace_id=record.parent_workspace_id, created_at=record.created_at, - parent_head=parent_head if record.child_workspace_id == child_workspace_id else record.parent_head, + parent_head=parent_head + if record.child_workspace_id == child_workspace_id + else record.parent_head, ).to_dict() for record in records ] @@ -258,32 +325,63 @@ def remove_stack_child(self, child_workspace_id: str) -> None: raise WorkspaceError("workspace stack index rows are invalid") if payload.get("schema_version") == 1 and not rows: payload["schema_version"] = STACK_SCHEMA_VERSION - payload["stacks"] = [row for row in rows if row.get("child_workspace_id") != child_workspace_id] + payload["stacks"] = [ + row + for row in rows + if row.get("child_workspace_id") != child_workspace_id + ] - def put_checkpoint(self, record: CheckpointRecord, staged: bytes, unstaged: bytes, untracked: bytes) -> None: + def put_checkpoint( + self, record: CheckpointRecord, staged: bytes, unstaged: bytes, untracked: bytes + ) -> None: root = self.checkpoint_path(record.workspace_id, record.checkpoint_id) root.mkdir(mode=0o700, parents=True) - for name, content in (("staged.patch", staged), ("unstaged.patch", unstaged), ("untracked.tar", untracked)): + for name, content in ( + ("staged.patch", staged), + ("unstaged.patch", unstaged), + ("untracked.tar", untracked), + ): self._write_private(root / name, content) - write_json_atomic(root / "record.json", record.to_dict(), mode=0o600, fsync=True) + write_json_atomic( + root / "record.json", record.to_dict(), mode=0o600, fsync=True + ) - def checkpoint(self, workspace_id: str, checkpoint_id: str) -> tuple[CheckpointRecord, Path]: + def checkpoint( + self, workspace_id: str, checkpoint_id: str + ) -> tuple[CheckpointRecord, Path]: root = self.checkpoint_path(workspace_id, checkpoint_id) value = read_json(root / "record.json") - if not isinstance(value, Mapping) or value.get("schema_version") != CHECKPOINT_SCHEMA_VERSION: + if ( + not isinstance(value, Mapping) + or value.get("schema_version") != CHECKPOINT_SCHEMA_VERSION + ): raise WorkspaceError("checkpoint record is unavailable or invalid") files = value.get("untracked_files") fields = ( - "checkpoint_id", "workspace_id", "project_id", "head", "branch", "created_at", - "staged_sha256", "unstaged_sha256", "untracked_sha256", + "checkpoint_id", + "workspace_id", + "project_id", + "head", + "branch", + "created_at", + "staged_sha256", + "unstaged_sha256", + "untracked_sha256", ) - if any(not isinstance(value.get(field), str) or not value[field] for field in fields): + if any( + not isinstance(value.get(field), str) or not value[field] + for field in fields + ): raise WorkspaceError("checkpoint record fields are invalid") - if not isinstance(files, list) or any(not isinstance(item, str) or not item for item in files): + if not isinstance(files, list) or any( + not isinstance(item, str) or not item for item in files + ): raise WorkspaceError("checkpoint untracked manifest is invalid") return CheckpointRecord(*(value[field] for field in fields), tuple(files)), root - def checkpoints(self, workspace_id: str) -> tuple[tuple[CheckpointRecord, Path], ...]: + def checkpoints( + self, workspace_id: str + ) -> tuple[tuple[CheckpointRecord, Path], ...]: root = self.checkpoints_root / workspace_id if not root.exists(): return () @@ -305,7 +403,9 @@ def checkpoints(self, workspace_id: str) -> tuple[tuple[CheckpointRecord, Path], @staticmethod def _write_private(path: Path, content: bytes) -> None: - descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + descriptor, temporary = tempfile.mkstemp( + prefix=f".{path.name}.", dir=path.parent + ) try: with os.fdopen(descriptor, "wb") as handle: handle.write(content) @@ -330,7 +430,9 @@ def list(self, project_id: str | None = None) -> dict[str, Any]: records = self.store.records() if project_id is not None: self.projects.get(project_id) - records = tuple(record for record in records if record.project_id == project_id) + records = tuple( + record for record in records if record.project_id == project_id + ) return {"workspaces": [self._status(record) for record in records]} def get(self, workspace_id: str) -> dict[str, Any]: @@ -338,7 +440,12 @@ def get(self, workspace_id: str) -> dict[str, Any]: return self._status(record) def delivery_snapshot( - self, workspace_id: str, start_head: str, *, scope: Sequence[str] = (), merge_base: bool = False + self, + workspace_id: str, + start_head: str, + *, + scope: Sequence[str] = (), + merge_base: bool = False, ) -> dict[str, Any]: """Read one exact-head Git fact set for a delivery precondition.""" record = self._record(workspace_id) @@ -351,14 +458,36 @@ def delivery_snapshot( if merge_base else start_head ) - descendant = self._git(checkout.path, "merge-base", "--is-ancestor", range_start, before, check=False).returncode == 0 + descendant = ( + self._git( + checkout.path, + "merge-base", + "--is-ancestor", + range_start, + before, + check=False, + ).returncode + == 0 + ) changes = self._name_status(checkout.path, range_start, before) dirty = self._porcelain_status(checkout.path) after = self._git(checkout.path, "rev-parse", "HEAD").stdout.strip() if after != before: raise WorkspaceError("workspace HEAD changed during delivery snapshot") paths = tuple(path for change in changes for path in change["paths"]) - return {"workspace_id": workspace_id, "checkout_id": checkout.checkout_id, "start_head": range_start, "head": before, "descendant": descendant, "dirty": bool(dirty), "status": dirty, "changes": changes, "in_scope": all(self._scope_contains(path, scope) for path in paths) if scope else True} + return { + "workspace_id": workspace_id, + "checkout_id": checkout.checkout_id, + "start_head": range_start, + "head": before, + "descendant": descendant, + "dirty": bool(dirty), + "status": dirty, + "changes": changes, + "in_scope": all(self._scope_contains(path, scope) for path in paths) + if scope + else True, + } def checkout(self, workspace_id: str) -> RegisteredCheckout: record = self._record(workspace_id) @@ -367,8 +496,14 @@ def checkout(self, workspace_id: str) -> RegisteredCheckout: def resolve_checkout(self, project_id: str, reference: str) -> RegisteredCheckout: self._project(project_id) - records = tuple(record for record in self.store.records() if record.project_id == project_id) - matches = [record for record in records if reference in {record.workspace_id, record.name}] + records = tuple( + record for record in self.store.records() if record.project_id == project_id + ) + matches = [ + record + for record in records + if reference in {record.workspace_id, record.name} + ] if not matches: for record in records: try: @@ -389,20 +524,35 @@ def finish_merged(self, workspace_id: str, expected_head: str) -> dict[str, Any] record = self._record(workspace_id) if not record.managed: raise WorkspaceError("adopted workspaces cannot be finished") - if any(stack.parent_workspace_id == workspace_id for stack in self.store.stack_records()): - raise WorkspaceError("workspace cannot be finished while stacked children exist") + if any( + stack.parent_workspace_id == workspace_id + for stack in self.store.stack_records() + ): + raise WorkspaceError( + "workspace cannot be finished while stacked children exist" + ) checkout, project = self._available(record) if checkout.head != expected_head: - raise WorkspaceError("merged review head no longer matches workspace HEAD") - if self._git(checkout.path, "status", "--porcelain", "--untracked-files=all").stdout: + raise WorkspaceError( + "merged review head no longer matches workspace HEAD" + ) + if self._git( + checkout.path, "status", "--porcelain", "--untracked-files=all" + ).stdout: raise WorkspaceError("merged workspace must be clean before finish") removed = self._remove_worktree(project, record, checkout) if removed.returncode != 0: - raise WorkspaceError(removed.stderr.strip() or "git worktree remove failed") + raise WorkspaceError( + removed.stderr.strip() or "git worktree remove failed" + ) self._git(project.root, "branch", "-D", record.branch, check=False) self.store.remove_stack_references(workspace_id) self.store.remove(workspace_id) - return {"workspace_id": workspace_id, "finished": True, "head": expected_head} + return { + "workspace_id": workspace_id, + "finished": True, + "head": expected_head, + } def finish_integrated(self, workspace_id: str, target_ref: str) -> dict[str, Any]: """Remove a clean workspace whose tree contribution is present in a declared-base commit.""" @@ -410,27 +560,43 @@ def finish_integrated(self, workspace_id: str, target_ref: str) -> dict[str, Any record = self._record(workspace_id) if not record.managed: raise WorkspaceError("adopted workspaces cannot be finished") - if any(stack.parent_workspace_id == workspace_id for stack in self.store.stack_records()): - raise WorkspaceError("workspace cannot be finished while stacked children exist") + if any( + stack.parent_workspace_id == workspace_id + for stack in self.store.stack_records() + ): + raise WorkspaceError( + "workspace cannot be finished while stacked children exist" + ) checkout, project = self._available(record) - if self._git(checkout.path, "status", "--porcelain", "--untracked-files=all").stdout: + if self._git( + checkout.path, "status", "--porcelain", "--untracked-files=all" + ).stdout: raise WorkspaceError("integrated workspace must be clean before finish") self._verify_ref(project.root, target_ref, "integration target") assert project.workspace is not None - if self._git( - project.root, - "merge-base", - "--is-ancestor", - target_ref, - project.workspace.default_base, - check=False, - ).returncode != 0: - raise WorkspaceError("integration target is not contained in the declared default base") + if ( + self._git( + project.root, + "merge-base", + "--is-ancestor", + target_ref, + project.workspace.default_base, + check=False, + ).returncode + != 0 + ): + raise WorkspaceError( + "integration target is not contained in the declared default base" + ) if not self._tree_equivalent(project.root, target_ref, checkout.head): - raise WorkspaceError("workspace changes are not fully represented by the integration target") + raise WorkspaceError( + "workspace changes are not fully represented by the integration target" + ) removed = self._remove_worktree(project, record, checkout) if removed.returncode != 0: - raise WorkspaceError(removed.stderr.strip() or "git worktree remove failed") + raise WorkspaceError( + removed.stderr.strip() or "git worktree remove failed" + ) self._git(project.root, "branch", "-D", record.branch, check=False) self.store.remove_stack_references(workspace_id) self.store.remove(workspace_id) @@ -438,14 +604,22 @@ def finish_integrated(self, workspace_id: str, target_ref: str) -> dict[str, Any "workspace_id": workspace_id, "finished": True, "head": checkout.head, - "integration_target": self._git(project.root, "rev-parse", f"{target_ref}^{{commit}}").stdout.strip(), + "integration_target": self._git( + project.root, "rev-parse", f"{target_ref}^{{commit}}" + ).stdout.strip(), } - def create(self, *, project_id: str, name: str, branch: str, base: str | None) -> dict[str, Any]: + def create( + self, *, project_id: str, name: str, branch: str, base: str | None + ) -> dict[str, Any]: with flock(self.mutation_lock): - return self._create_locked(project_id=project_id, name=name, branch=branch, base=base) + return self._create_locked( + project_id=project_id, name=name, branch=branch, base=base + ) - def _create_locked(self, *, project_id: str, name: str, branch: str, base: str | None) -> dict[str, Any]: + def _create_locked( + self, *, project_id: str, name: str, branch: str, base: str | None + ) -> dict[str, Any]: project = self._project(project_id) policy = project.workspace assert policy is not None @@ -455,9 +629,22 @@ def _create_locked(self, *, project_id: str, name: str, branch: str, base: str | self._verify_ref(project.root, resolved_base, "base") path = policy.root / name self._validate_target(policy.root, path) - if path.exists() or any(record.project_id == project_id and record.name == name for record in self.store.records()): + if path.exists() or any( + record.project_id == project_id and record.name == name + for record in self.store.records() + ): raise WorkspaceError("workspace target or name already exists") - branch_exists = self._git(project.root, "show-ref", "--verify", "--quiet", f"refs/heads/{branch}", check=False).returncode == 0 + branch_exists = ( + self._git( + project.root, + "show-ref", + "--verify", + "--quiet", + f"refs/heads/{branch}", + check=False, + ).returncode + == 0 + ) arguments = ["worktree", "add"] if not branch_exists: arguments.extend(["-b", branch]) @@ -483,9 +670,13 @@ def adopt(self, *, project_id: str, checkout_id: str, name: str) -> dict[str, An with flock(self.mutation_lock): checkout = self.projects.checkout(project_id, checkout_id) if checkout.path == project.root: - raise WorkspaceError("the configured project root cannot be adopted as a managed workspace") + raise WorkspaceError( + "the configured project root cannot be adopted as a managed workspace" + ) branch = self._branch(checkout.path) - record = self._new_record(project, name, checkout, checkout.head, managed=False, branch=branch) + record = self._new_record( + project, name, checkout, checkout.head, managed=False, branch=branch + ) self.store.put(record) return self._status(record) @@ -493,24 +684,41 @@ def reap(self, workspace_id: str) -> dict[str, Any]: with flock(self.mutation_lock): record = self._record(workspace_id) status = self._status(record) - if any(stack.parent_workspace_id == workspace_id for stack in self.store.stack_records()): - raise WorkspaceError("workspace cannot be reaped while stacked children exist") + if any( + stack.parent_workspace_id == workspace_id + for stack in self.store.stack_records() + ): + raise WorkspaceError( + "workspace cannot be reaped while stacked children exist" + ) if status["state"] == "missing": self.store.remove_stack_references(workspace_id) self.store.remove(workspace_id) - return {"workspace_id": workspace_id, "reaped": True, "relationship_only": True} + return { + "workspace_id": workspace_id, + "reaped": True, + "relationship_only": True, + } if not record.managed: raise WorkspaceError("adopted workspaces cannot be reaped") if status["dirty"] or not status["identity_matches"]: - raise WorkspaceError("workspace is dirty or its branch identity changed") + raise WorkspaceError( + "workspace is dirty or its branch identity changed" + ) project = self._project(record.project_id) assert project.workspace is not None head = status["head"] - if not isinstance(head, str) or not self._head_is_contained_in_declared_base(project, head): - raise WorkspaceError("workspace HEAD is not contained in the declared base") + if not isinstance( + head, str + ) or not self._head_is_contained_in_declared_base(project, head): + raise WorkspaceError( + "workspace HEAD is not contained in the declared base" + ) removed = self._remove_worktree(project, record) if removed.returncode != 0: - raise WorkspaceError(removed.stderr.strip() or "git worktree remove failed") + raise WorkspaceError( + removed.stderr.strip() or "git worktree remove failed" + ) self.store.remove_stack_references(workspace_id) self.store.remove(workspace_id) return { @@ -526,11 +734,18 @@ def dispose(self, workspace_id: str) -> dict[str, Any]: record = self._record(workspace_id) if not record.managed: raise WorkspaceError("adopted workspaces cannot be disposed") - if any(stack.parent_workspace_id == workspace_id for stack in self.store.stack_records()): - raise WorkspaceError("workspace cannot be disposed while stacked children exist") + if any( + stack.parent_workspace_id == workspace_id + for stack in self.store.stack_records() + ): + raise WorkspaceError( + "workspace cannot be disposed while stacked children exist" + ) status = self._status(record) if status["state"] != "available" or not status["identity_matches"]: - raise WorkspaceError("workspace is unavailable or its branch identity changed") + raise WorkspaceError( + "workspace is unavailable or its branch identity changed" + ) if status["dirty"]: raise WorkspaceError("workspace must be clean before disposal") checkout, project = self._available(record) @@ -539,10 +754,14 @@ def dispose(self, workspace_id: str) -> dict[str, Any]: self._verify_disposable_checkpoints(workspace_id) removed = self._remove_worktree(project, record, checkout) if removed.returncode != 0: - raise WorkspaceError(removed.stderr.strip() or "git worktree remove failed") + raise WorkspaceError( + removed.stderr.strip() or "git worktree remove failed" + ) branch = self._git(project.root, "branch", "-D", record.branch, check=False) if branch.returncode != 0: - raise WorkspaceError(branch.stderr.strip() or "git branch deletion failed") + raise WorkspaceError( + branch.stderr.strip() or "git branch deletion failed" + ) self.store.remove_stack_references(workspace_id) self.store.remove(workspace_id) return { @@ -552,7 +771,9 @@ def dispose(self, workspace_id: str) -> dict[str, Any]: "deleted_branch": record.branch, } - def stack(self, *, parent_workspace_id: str, name: str, branch: str) -> dict[str, Any]: + def stack( + self, *, parent_workspace_id: str, name: str, branch: str + ) -> dict[str, Any]: with flock(self.mutation_lock): parent = self._record(parent_workspace_id) parent_checkout, _project = self._available(parent) @@ -581,7 +802,11 @@ def stack(self, *, parent_workspace_id: str, name: str, branch: str) -> dict[str def restack(self, workspace_id: str) -> dict[str, Any]: with flock(self.mutation_lock): stack = next( - (item for item in self.store.stack_records() if item.child_workspace_id == workspace_id), + ( + item + for item in self.store.stack_records() + if item.child_workspace_id == workspace_id + ), None, ) if stack is None: @@ -591,7 +816,9 @@ def restack(self, workspace_id: str) -> dict[str, Any]: if child.project_id != parent.project_id: raise WorkspaceError("stack parent belongs to another project") child_checkout, project = self._available(child) - if self._git(child_checkout.path, "status", "--porcelain", "--untracked-files=all").stdout: + if self._git( + child_checkout.path, "status", "--porcelain", "--untracked-files=all" + ).stdout: raise WorkspaceError("restack requires a clean child workspace") parent_status = self._status(parent) detached_parent = parent_status["state"] == "missing" @@ -600,7 +827,9 @@ def restack(self, workspace_id: str) -> dict[str, Any]: target_ref = project.workspace.default_base parent_head = stack.parent_head if not self._tree_equivalent(project.root, target_ref, parent_head): - raise WorkspaceError("missing stack parent is not represented in the declared base") + raise WorkspaceError( + "missing stack parent is not represented in the declared base" + ) else: parent_checkout, _ = self._available(parent) target_ref = parent.branch @@ -616,7 +845,11 @@ def restack(self, workspace_id: str) -> dict[str, Any]: "collisions": collisions, } before = child_checkout.head - arguments = ("rebase", "--onto", target_ref, stack.parent_head) if detached_parent else ("rebase", target_ref) + arguments = ( + ("rebase", "--onto", target_ref, stack.parent_head) + if detached_parent + else ("rebase", target_ref) + ) result = self._git(child_checkout.path, *arguments, check=False) if result.returncode != 0: self._git(child_checkout.path, "rebase", "--abort", check=False) @@ -638,16 +871,37 @@ def restack(self, workspace_id: str) -> dict[str, Any]: } def _declared_collisions( - self, project: ProjectAdapter, child_ref: str, parent_ref: str, *, base_ref: str | None = None + self, + project: ProjectAdapter, + child_ref: str, + parent_ref: str, + *, + base_ref: str | None = None, ) -> list[dict[str, str]]: - base = base_ref or self._git(project.root, "merge-base", child_ref, parent_ref).stdout.strip() - child_paths = set(self._git(project.root, "diff", "--name-only", base, child_ref, "--").stdout.splitlines()) - parent_paths = set(self._git(project.root, "diff", "--name-only", base, parent_ref, "--").stdout.splitlines()) + base = ( + base_ref + or self._git( + project.root, "merge-base", child_ref, parent_ref + ).stdout.strip() + ) + child_paths = set( + self._git( + project.root, "diff", "--name-only", base, child_ref, "--" + ).stdout.splitlines() + ) + parent_paths = set( + self._git( + project.root, "diff", "--name-only", base, parent_ref, "--" + ).stdout.splitlines() + ) overlap = child_paths & parent_paths exact = set(project.conflicts.exact_files) generated = set(project.conflicts.generated_surfaces) collisions = [ - {"path": path, "class": "exact-file" if path in exact else "generated-surface"} + { + "path": path, + "class": "exact-file" if path in exact else "generated-surface", + } for path in sorted(overlap) if path in exact or path in generated ] @@ -656,8 +910,16 @@ def _declared_collisions( {"path": path, "class": "hard"} for path in sorted(overlap - classified) ) for slot, patterns in project.conflicts.semantic_slots.items(): - child_slot = sorted(path for path in child_paths if any(fnmatch(path, pattern) for pattern in patterns)) - parent_slot = sorted(path for path in parent_paths if any(fnmatch(path, pattern) for pattern in patterns)) + child_slot = sorted( + path + for path in child_paths + if any(fnmatch(path, pattern) for pattern in patterns) + ) + parent_slot = sorted( + path + for path in parent_paths + if any(fnmatch(path, pattern) for pattern in patterns) + ) if child_slot and parent_slot: collisions.append( { @@ -670,48 +932,69 @@ def _declared_collisions( return collisions def _tree_equivalent(self, root: Path, target_ref: str, source_ref: str) -> bool: - target_tree = self._git(root, "rev-parse", f"{target_ref}^{{tree}}", check=False) - merged_tree = self._git(root, "merge-tree", "--write-tree", target_ref, source_ref, check=False) + target_tree = self._git( + root, "rev-parse", f"{target_ref}^{{tree}}", check=False + ) + merged_tree = self._git( + root, "merge-tree", "--write-tree", target_ref, source_ref, check=False + ) return ( target_tree.returncode == 0 and merged_tree.returncode == 0 and target_tree.stdout.strip() == merged_tree.stdout.strip() ) - def _head_is_contained_in_declared_base(self, project: ProjectAdapter, head: str) -> bool: + def _head_is_contained_in_declared_base( + self, project: ProjectAdapter, head: str + ) -> bool: assert project.workspace is not None - return self._git( - project.root, - "merge-base", - "--is-ancestor", - head, - project.workspace.default_base, - check=False, - ).returncode == 0 + return ( + self._git( + project.root, + "merge-base", + "--is-ancestor", + head, + project.workspace.default_base, + check=False, + ).returncode + == 0 + ) def _verify_disposable_checkpoints(self, workspace_id: str) -> None: for checkpoint, root in self.store.checkpoints(workspace_id): - staged = self._verified_artifact(root / "staged.patch", checkpoint.staged_sha256) - unstaged = self._verified_artifact(root / "unstaged.patch", checkpoint.unstaged_sha256) - untracked = self._verified_artifact(root / "untracked.tar", checkpoint.untracked_sha256) + staged = self._verified_artifact( + root / "staged.patch", checkpoint.staged_sha256 + ) + unstaged = self._verified_artifact( + root / "unstaged.patch", checkpoint.unstaged_sha256 + ) + untracked = self._verified_artifact( + root / "untracked.tar", checkpoint.untracked_sha256 + ) try: with tarfile.open(fileobj=io.BytesIO(untracked), mode="r:") as archive: archive_members = archive.getmembers() except tarfile.TarError as error: raise WorkspaceError("checkpoint archive is invalid") from error if staged or unstaged or checkpoint.untracked_files or archive_members: - raise WorkspaceError("workspace checkpoint retains content that must be preserved") + raise WorkspaceError( + "workspace checkpoint retains content that must be preserved" + ) def checkpoint(self, workspace_id: str) -> dict[str, Any]: with flock(self.mutation_lock): record = self._record(workspace_id) checkout, project = self._available(record) assert project.workspace is not None - staged = self._git_bytes(checkout.path, "diff", "--cached", "--binary", "HEAD", "--") + staged = self._git_bytes( + checkout.path, "diff", "--cached", "--binary", "HEAD", "--" + ) unstaged = self._git_bytes(checkout.path, "diff", "--binary", "--") untracked_files = self._untracked(checkout.path) if untracked_files and not project.workspace.checkpoint_untracked: - raise WorkspaceError("project policy forbids checkpointing untracked files") + raise WorkspaceError( + "project policy forbids checkpointing untracked files" + ) untracked = self._archive_untracked(checkout.path, untracked_files) if sum(map(len, (staged, unstaged, untracked))) > MAX_CHECKPOINT_BYTES: raise WorkspaceError("checkpoint exceeds the configured byte bound") @@ -738,20 +1021,37 @@ def _restore_locked(self, workspace_id: str, checkpoint_id: str) -> dict[str, An record = self._record(workspace_id) checkout, project = self._available(record) checkpoint, root = self.store.checkpoint(workspace_id, checkpoint_id) - if checkpoint.workspace_id != record.workspace_id or checkpoint.project_id != record.project_id: + if ( + checkpoint.workspace_id != record.workspace_id + or checkpoint.project_id != record.project_id + ): raise WorkspaceError("checkpoint authority does not match workspace") if checkout.head != checkpoint.head or record.branch != checkpoint.branch: - raise WorkspaceError("checkpoint source HEAD or branch no longer matches workspace") - if self._git(checkout.path, "status", "--porcelain", "--untracked-files=all").stdout: + raise WorkspaceError( + "checkpoint source HEAD or branch no longer matches workspace" + ) + if self._git( + checkout.path, "status", "--porcelain", "--untracked-files=all" + ).stdout: raise WorkspaceError("checkpoint restore requires a clean workspace") self._identity_check(project, checkout.path) - staged = self._verified_artifact(root / "staged.patch", checkpoint.staged_sha256) - unstaged = self._verified_artifact(root / "unstaged.patch", checkpoint.unstaged_sha256) - untracked = self._verified_artifact(root / "untracked.tar", checkpoint.untracked_sha256) + staged = self._verified_artifact( + root / "staged.patch", checkpoint.staged_sha256 + ) + unstaged = self._verified_artifact( + root / "unstaged.patch", checkpoint.unstaged_sha256 + ) + untracked = self._verified_artifact( + root / "untracked.tar", checkpoint.untracked_sha256 + ) self._apply_patch(checkout.path, staged, index=True) self._apply_patch(checkout.path, unstaged, index=False) self._extract_untracked(checkout.path, untracked, checkpoint.untracked_files) - return {"workspace_id": workspace_id, "checkpoint_id": checkpoint_id, "restored": True} + return { + "workspace_id": workspace_id, + "checkpoint_id": checkpoint_id, + "restored": True, + } def recover(self, workspace_id: str, checkpoint_id: str) -> dict[str, Any]: with flock(self.mutation_lock): @@ -762,16 +1062,32 @@ def recover(self, workspace_id: str, checkpoint_id: str) -> dict[str, Any]: if status["state"] != "missing": raise WorkspaceError("recover requires a missing managed workspace") checkpoint, _root = self.store.checkpoint(workspace_id, checkpoint_id) - if checkpoint.project_id != record.project_id or checkpoint.branch != record.branch: + if ( + checkpoint.project_id != record.project_id + or checkpoint.branch != record.branch + ): raise WorkspaceError("checkpoint authority does not match workspace") project = self._project(record.project_id) - branch_head = self._git(project.root, "rev-parse", "--verify", f"{record.branch}^{{commit}}").stdout.strip() + branch_head = self._git( + project.root, "rev-parse", "--verify", f"{record.branch}^{{commit}}" + ).stdout.strip() if branch_head != checkpoint.head: - raise WorkspaceError("workspace branch no longer matches checkpoint HEAD") + raise WorkspaceError( + "workspace branch no longer matches checkpoint HEAD" + ) self._validate_target(project.workspace.root, record.path) - result = self._git(project.root, "worktree", "add", str(record.path), record.branch, check=False) + result = self._git( + project.root, + "worktree", + "add", + str(record.path), + record.branch, + check=False, + ) if result.returncode != 0: - raise WorkspaceError(result.stderr.strip() or "Git workspace recovery failed") + raise WorkspaceError( + result.stderr.strip() or "Git workspace recovery failed" + ) try: restored = self._restore_locked(workspace_id, checkpoint_id) except BaseException: @@ -784,7 +1100,11 @@ def _status(self, record: WorkspaceRecord) -> dict[str, Any]: try: checkout = self._checkout_by_path(record.project_id, record.path) branch = self._branch(checkout.path) - dirty = bool(self._git(checkout.path, "status", "--porcelain", "--untracked-files=all").stdout) + dirty = bool( + self._git( + checkout.path, "status", "--porcelain", "--untracked-files=all" + ).stdout + ) row.update( { "state": "available", @@ -796,12 +1116,23 @@ def _status(self, record: WorkspaceRecord) -> dict[str, Any]: } ) except (FileNotFoundError, KeyError, WorkspaceError): - row.update({"state": "missing", "checkout_id": None, "head": None, "current_branch": None, "dirty": None, "identity_matches": False}) + row.update( + { + "state": "missing", + "checkout_id": None, + "head": None, + "current_branch": None, + "dirty": None, + "identity_matches": False, + } + ) return row @classmethod def _porcelain_status(cls, path: Path) -> list[dict[str, Any]]: - raw = cls._git_bytes(path, "status", "--porcelain=v1", "-z", "--untracked-files=all") + raw = cls._git_bytes( + path, "status", "--porcelain=v1", "-z", "--untracked-files=all" + ) records = [item for item in raw.split(b"\0") if item] result: list[dict[str, Any]] = [] index = 0 @@ -821,8 +1152,19 @@ def _porcelain_status(cls, path: Path) -> list[dict[str, Any]]: return result @classmethod - def _name_status(cls, path: Path, start_head: str, head: str) -> list[dict[str, Any]]: - raw = cls._git_bytes(path, "diff", "--name-status", "-z", "--find-renames", start_head, head, "--") + def _name_status( + cls, path: Path, start_head: str, head: str + ) -> list[dict[str, Any]]: + raw = cls._git_bytes( + path, + "diff", + "--name-status", + "-z", + "--find-renames", + start_head, + head, + "--", + ) records = [item for item in raw.split(b"\0") if item] result: list[dict[str, Any]] = [] index = 0 @@ -834,7 +1176,15 @@ def _name_status(cls, path: Path, start_head: str, head: str) -> list[dict[str, count = 2 if status[0] in {"R", "C"} else 1 if len(records) - index < count: raise WorkspaceError("Git diff rename porcelain is malformed") - result.append({"status": status, "paths": [cls._decode_git_path(item) for item in records[index:index + count]]}) + result.append( + { + "status": status, + "paths": [ + cls._decode_git_path(item) + for item in records[index : index + count] + ], + } + ) index += count return result @@ -851,7 +1201,12 @@ def _decode_git_path(value: bytes) -> str: @staticmethod def _scope_contains(path: str, scope: Sequence[str]) -> bool: for entry in scope: - if not isinstance(entry, str) or not entry or entry.startswith("/") or ".." in Path(entry).parts: + if ( + not isinstance(entry, str) + or not entry + or entry.startswith("/") + or ".." in Path(entry).parts + ): raise WorkspaceError("delivery scope is unsafe") if entry.endswith("/"): if path.startswith(entry): @@ -869,10 +1224,14 @@ def _record(self, workspace_id: str) -> WorkspaceRecord: def _project(self, project_id: str) -> ProjectAdapter: project = self.projects.get(project_id) if project.workspace is None: - raise WorkspaceError(f"project {project_id!r} does not declare workspace policy") + raise WorkspaceError( + f"project {project_id!r} does not declare workspace policy" + ) return project - def _available(self, record: WorkspaceRecord) -> tuple[RegisteredCheckout, ProjectAdapter]: + def _available( + self, record: WorkspaceRecord + ) -> tuple[RegisteredCheckout, ProjectAdapter]: project = self._project(record.project_id) checkout = self._checkout_by_path(record.project_id, record.path) if self._branch(checkout.path) != record.branch: @@ -880,16 +1239,23 @@ def _available(self, record: WorkspaceRecord) -> tuple[RegisteredCheckout, Proje return checkout, project def _remove_worktree( - self, project: ProjectAdapter, record: WorkspaceRecord, *arguments: str | RegisteredCheckout + self, + project: ProjectAdapter, + record: WorkspaceRecord, + *arguments: str | RegisteredCheckout, ) -> subprocess.CompletedProcess[str]: - checkout = next((item for item in arguments if isinstance(item, RegisteredCheckout)), None) + checkout = next( + (item for item in arguments if isinstance(item, RegisteredCheckout)), None + ) flags = tuple(item for item in arguments if isinstance(item, str)) if checkout is None: checkout = self._checkout_by_path(record.project_id, record.path) if checkout.path != record.path: raise WorkspaceError("registered checkout does not match workspace record") self._canonicalize_gitfile_symlink(checkout) - return self._git(project.root, "worktree", "remove", *flags, str(record.path), check=False) + return self._git( + project.root, "worktree", "remove", *flags, str(record.path), check=False + ) @staticmethod def _canonicalize_gitfile_symlink(checkout: RegisteredCheckout) -> None: @@ -910,7 +1276,9 @@ def _canonicalize_gitfile_symlink(checkout: RegisteredCheckout) -> None: try: target = gitfile.resolve(strict=True) - worktrees_root = (checkout.git_common_dir / "worktrees").resolve(strict=True) + worktrees_root = (checkout.git_common_dir / "worktrees").resolve( + strict=True + ) except FileNotFoundError as error: raise WorkspaceError("workspace .git symlink is broken") from error except OSError as error: @@ -918,10 +1286,14 @@ def _canonicalize_gitfile_symlink(checkout: RegisteredCheckout) -> None: try: target.relative_to(worktrees_root) except ValueError as error: - raise WorkspaceError("workspace .git symlink target is outside the repository worktrees area") from error + raise WorkspaceError( + "workspace .git symlink target is outside the repository worktrees area" + ) from error expected = GitWorkspaces._registered_worktree_gitdir(checkout, worktrees_root) if target != expected: - raise WorkspaceError("workspace .git symlink target does not match its registered worktree gitdir") + raise WorkspaceError( + "workspace .git symlink target does not match its registered worktree gitdir" + ) descriptor, temporary = tempfile.mkstemp(prefix=".git.", dir=gitfile.parent) try: @@ -932,12 +1304,16 @@ def _canonicalize_gitfile_symlink(checkout: RegisteredCheckout) -> None: os.chmod(temporary, 0o644) os.replace(temporary, gitfile) except OSError as error: - raise WorkspaceError("could not canonicalize workspace .git symlink") from error + raise WorkspaceError( + "could not canonicalize workspace .git symlink" + ) from error finally: Path(temporary).unlink(missing_ok=True) @staticmethod - def _registered_worktree_gitdir(checkout: RegisteredCheckout, worktrees_root: Path) -> Path: + def _registered_worktree_gitdir( + checkout: RegisteredCheckout, worktrees_root: Path + ) -> Path: expected_gitfile = checkout.path / ".git" try: entries = tuple(worktrees_root.iterdir()) @@ -958,28 +1334,48 @@ def _registered_worktree_gitdir(checkout: RegisteredCheckout, worktrees_root: Pa try: candidate_target = candidate.resolve(strict=True) except OSError as error: - raise WorkspaceError("registered worktree gitdir is unavailable") from error - if candidate_target.parent != worktrees_root or not candidate_target.is_dir(): - raise WorkspaceError("registered worktree gitdir is outside the repository worktrees area") + raise WorkspaceError( + "registered worktree gitdir is unavailable" + ) from error + if ( + candidate_target.parent != worktrees_root + or not candidate_target.is_dir() + ): + raise WorkspaceError( + "registered worktree gitdir is outside the repository worktrees area" + ) matches.append(candidate_target) if len(matches) != 1: - raise WorkspaceError("registered worktree gitdir is unavailable or ambiguous") + raise WorkspaceError( + "registered worktree gitdir is unavailable or ambiguous" + ) return matches[0] def _identity_check(self, project: ProjectAdapter, path: Path) -> None: assert project.workspace is not None - result = subprocess.run(project.workspace.identity_check, cwd=path, capture_output=True, text=True, timeout=30) + result = subprocess.run( + project.workspace.identity_check, + cwd=path, + capture_output=True, + text=True, + timeout=30, + ) if result.returncode != 0: raise WorkspaceError("workspace identity check failed") @classmethod def _git_bytes(cls, path: Path, *arguments: str) -> bytes: try: - result = subprocess.run(["git", "-C", str(path), *arguments], capture_output=True, timeout=30) + result = subprocess.run( + ["git", "-C", str(path), *arguments], capture_output=True, timeout=30 + ) except (OSError, subprocess.SubprocessError) as error: raise WorkspaceError("Git checkpoint operation failed") from error if result.returncode != 0: - raise WorkspaceError(result.stderr.decode(errors="replace").strip() or "Git checkpoint operation failed") + raise WorkspaceError( + result.stderr.decode(errors="replace").strip() + or "Git checkpoint operation failed" + ) return result.stdout @classmethod @@ -989,8 +1385,12 @@ def _untracked(cls, path: Path) -> tuple[str, ...]: files = tuple(item.decode() for item in raw.split(b"\0") if item) except UnicodeDecodeError as error: raise WorkspaceError("untracked checkpoint paths must be UTF-8") from error - if len(files) > MAX_UNTRACKED_FILES or any(Path(item).is_absolute() or ".." in Path(item).parts for item in files): - raise WorkspaceError("untracked checkpoint manifest exceeds its safety bounds") + if len(files) > MAX_UNTRACKED_FILES or any( + Path(item).is_absolute() or ".." in Path(item).parts for item in files + ): + raise WorkspaceError( + "untracked checkpoint manifest exceeds its safety bounds" + ) return files @staticmethod @@ -1002,23 +1402,40 @@ def _archive_untracked(root: Path, files: tuple[str, ...]) -> bytes: try: metadata = source.lstat() except OSError as error: - raise WorkspaceError("untracked checkpoint file disappeared") from error - if not source.is_file() or source.is_symlink() or metadata.st_size > MAX_CHECKPOINT_BYTES: - raise WorkspaceError("untracked checkpoint entries must be bounded regular files") + raise WorkspaceError( + "untracked checkpoint file disappeared" + ) from error + if ( + not source.is_file() + or source.is_symlink() + or metadata.st_size > MAX_CHECKPOINT_BYTES + ): + raise WorkspaceError( + "untracked checkpoint entries must be bounded regular files" + ) archive.add(source, arcname=relative, recursive=False) if buffer.tell() > MAX_CHECKPOINT_BYTES: - raise WorkspaceError("untracked checkpoint archive exceeds its byte bound") + raise WorkspaceError( + "untracked checkpoint archive exceeds its byte bound" + ) return buffer.getvalue() @staticmethod - def _extract_untracked(root: Path, content: bytes, expected: tuple[str, ...]) -> None: + def _extract_untracked( + root: Path, content: bytes, expected: tuple[str, ...] + ) -> None: with tarfile.open(fileobj=io.BytesIO(content), mode="r:") as archive: members = archive.getmembers() if tuple(member.name for member in members) != expected: raise WorkspaceError("checkpoint archive does not match its manifest") for member in members: target = (root / member.name).resolve(strict=False) - if root.resolve() not in target.parents or member.issym() or member.islnk() or not member.isfile(): + if ( + root.resolve() not in target.parents + or member.issym() + or member.islnk() + or not member.isfile() + ): raise WorkspaceError("checkpoint archive contains an unsafe entry") archive.extractall(root, members=members, filter="data") @@ -1029,9 +1446,14 @@ def _apply_patch(cls, root: Path, content: bytes, *, index: bool) -> None: arguments = ["git", "-C", str(root), "apply"] if index: arguments.append("--index") - result = subprocess.run(arguments, input=content, capture_output=True, timeout=30) + result = subprocess.run( + arguments, input=content, capture_output=True, timeout=30 + ) if result.returncode != 0: - raise WorkspaceError(result.stderr.decode(errors="replace").strip() or "checkpoint patch failed") + raise WorkspaceError( + result.stderr.decode(errors="replace").strip() + or "checkpoint patch failed" + ) @staticmethod def _verified_artifact(path: Path, digest: str) -> bytes: @@ -1078,7 +1500,9 @@ def _new_record( @staticmethod def _validate_name(name: str) -> None: if not isinstance(name, str) or not _NAME.fullmatch(name): - raise WorkspaceError("workspace name must be a lowercase path-safe identifier up to 64 characters") + raise WorkspaceError( + "workspace name must be a lowercase path-safe identifier up to 64 characters" + ) @staticmethod def _validate_target(root: Path, path: Path) -> None: @@ -1088,15 +1512,23 @@ def _validate_target(root: Path, path: Path) -> None: raise WorkspaceError("workspace target escapes the declared workspace root") @staticmethod - def _git(path: Path, *arguments: str, check: bool = True) -> subprocess.CompletedProcess[str]: + def _git( + path: Path, *arguments: str, check: bool = True + ) -> subprocess.CompletedProcess[str]: try: result = subprocess.run( - ["git", "-C", str(path), *arguments], capture_output=True, text=True, timeout=10, check=False + ["git", "-C", str(path), *arguments], + capture_output=True, + text=True, + timeout=10, + check=False, ) except (OSError, subprocess.SubprocessError) as error: raise WorkspaceError("Git workspace operation failed") from error if check and result.returncode != 0: - raise WorkspaceError(result.stderr.strip() or "Git workspace operation failed") + raise WorkspaceError( + result.stderr.strip() or "Git workspace operation failed" + ) return result @classmethod @@ -1111,12 +1543,24 @@ def _validate_branch(cls, root: Path, branch: str) -> None: def _verify_ref(cls, root: Path, ref: str, label: str) -> None: if not isinstance(ref, str) or not ref or ref.startswith("-"): raise WorkspaceError(f"workspace {label} is invalid") - if cls._git(root, "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}", check=False).returncode != 0: + if ( + cls._git( + root, + "rev-parse", + "--verify", + "--quiet", + f"{ref}^{{commit}}", + check=False, + ).returncode + != 0 + ): raise WorkspaceError(f"workspace {label} does not resolve to a commit") @classmethod def _branch(cls, path: Path) -> str: - branch = cls._git(path, "symbolic-ref", "--quiet", "--short", "HEAD", check=False).stdout.strip() + branch = cls._git( + path, "symbolic-ref", "--quiet", "--short", "HEAD", check=False + ).stdout.strip() if not branch: raise WorkspaceError("detached worktrees cannot be managed workspaces") return branch diff --git a/pkgs/sinnixd/test_admission.py b/pkgs/sinnixd/test_admission.py index c274f48f..99d2a9b5 100644 --- a/pkgs/sinnixd/test_admission.py +++ b/pkgs/sinnixd/test_admission.py @@ -5,18 +5,29 @@ from pathlib import Path import pytest - -from sinnixd.jobs import GenericJobSpec, GenericJobStore, GenericJobs -from sinnixd.projects import ConflictPolicy, ProjectAdapter, ProjectEnvironment, ProjectOperation, load_project_adapter +from sinnixd.jobs import GenericJobs, GenericJobSpec, GenericJobStore +from sinnixd.projects import ( + ConflictPolicy, + ProjectAdapter, + ProjectEnvironment, + ProjectOperation, + load_project_adapter, +) @dataclass class FakeSystemd: started: list[dict[str, object]] = field(default_factory=list) stopped: list[str] = field(default_factory=list) - properties: dict[str, str] = field(default_factory=lambda: { - "LoadState": "loaded", "ActiveState": "active", "Result": "success", "ExecMainStatus": "0", "MemoryPeak": "0", - }) + properties: dict[str, str] = field( + default_factory=lambda: { + "LoadState": "loaded", + "ActiveState": "active", + "Result": "success", + "ExecMainStatus": "0", + "MemoryPeak": "0", + } + ) def start(self, **kwargs: object) -> None: self.started.append(dict(kwargs)) @@ -28,7 +39,11 @@ def show(self, unit: str, *, timeout_seconds: float = 0.25) -> dict[str, str]: def stop(self, unit: str) -> None: self.stopped.append(unit) self.properties = { - "LoadState": "loaded", "ActiveState": "inactive", "Result": "signal", "ExecMainStatus": "15", "InvocationID": "fixture", + "LoadState": "loaded", + "ActiveState": "inactive", + "Result": "signal", + "ExecMainStatus": "15", + "InvocationID": "fixture", } @@ -37,11 +52,32 @@ def project(root: Path, operations: tuple[ProjectOperation, ...]) -> ProjectAdap (root / "tracked").write_text("fixture\n") subprocess.run(["git", "init", "--quiet", str(root)], check=True) subprocess.run(["git", "-C", str(root), "add", "tracked"], check=True) - subprocess.run(["git", "-C", str(root), "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "--quiet", "-m", "fixture"], check=True) + subprocess.run( + [ + "git", + "-C", + str(root), + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.test", + "commit", + "--quiet", + "-m", + "fixture", + ], + check=True, + ) return ProjectAdapter( - project_id="fixture", display_name="Fixture", root=root, descriptor=root / "project.toml", digest="sha256:" + "0" * 64, - environment=ProjectEnvironment("fixture", ("env",), (), ()), workspace=None, - conflicts=ConflictPolicy((), (), {}), operations=operations, + project_id="fixture", + display_name="Fixture", + root=root, + descriptor=root / "project.toml", + digest="sha256:" + "0" * 64, + environment=ProjectEnvironment("fixture", ("env",), (), ()), + workspace=None, + conflicts=ConflictPolicy((), (), {}), + operations=operations, ) @@ -53,7 +89,9 @@ def operation(name: str, **kwargs: object) -> ProjectOperation: def jobs(tmp_path: Path, systemd: FakeSystemd, pressure: float = 0.0) -> GenericJobs: return GenericJobs( - systemd, GenericJobStore(tmp_path / "state"), wait_poll_seconds=0.001, + systemd, + GenericJobStore(tmp_path / "state"), + wait_poll_seconds=0.001, pressure_probe=lambda: {"memory_full_avg10": pressure}, ) @@ -64,7 +102,7 @@ def test_descriptor_loads_typed_admission_controls(tmp_path: Path) -> None: (root / "marker").touch() descriptor = root / ".agentctl" descriptor.mkdir() - (descriptor / "project.toml").write_text(''' + (descriptor / "project.toml").write_text(""" schema = 1 [project] id = "fixture" @@ -90,7 +128,7 @@ def test_descriptor_loads_typed_admission_controls(tmp_path: Path) -> None: exclusive_keys = ["fixture:store"] estimate_memory_bytes = 1048576 scratch = "nvme" -''') +""") check = load_project_adapter(root).operation("check") assert check.dependencies == ("prepare",) assert check.exclusive_keys == ("fixture:store",) @@ -98,14 +136,26 @@ def test_descriptor_loads_typed_admission_controls(tmp_path: Path) -> None: def test_mixed_workload_injects_light_workers_and_queues_bulk(tmp_path: Path) -> None: - adapter = project(tmp_path / "project", ( - operation("heavy", pool="bulk", estimate_memory_bytes=12 * 1024 * 1024 * 1024), - operation("light", pool="interactive", estimate_memory_bytes=64 * 1024 * 1024), - )) + adapter = project( + tmp_path / "project", + ( + operation( + "heavy", pool="bulk", estimate_memory_bytes=12 * 1024 * 1024 * 1024 + ), + operation( + "light", pool="interactive", estimate_memory_bytes=64 * 1024 * 1024 + ), + ), + ) systemd = FakeSystemd() subject = jobs(tmp_path, systemd) - first = subject.start_declared(project=adapter, operation=adapter.operation("heavy"), correlation_id="one", parameters={}) + first = subject.start_declared( + project=adapter, + operation=adapter.operation("heavy"), + correlation_id="one", + parameters={}, + ) second = subject.start_declared( project=adapter, operation=adapter.operation("heavy"), @@ -113,7 +163,12 @@ def test_mixed_workload_injects_light_workers_and_queues_bulk(tmp_path: Path) -> principal="agent-control", parameters={}, ) - light_a = subject.start_declared(project=adapter, operation=adapter.operation("light"), correlation_id="three", parameters={}) + light_a = subject.start_declared( + project=adapter, + operation=adapter.operation("light"), + correlation_id="three", + parameters={}, + ) light_b = subject.start_declared( project=adapter, operation=adapter.operation("light"), @@ -122,16 +177,27 @@ def test_mixed_workload_injects_light_workers_and_queues_bulk(tmp_path: Path) -> parameters={}, ) - assert [entry["command"] for entry in systemd.started] == [("env", "heavy"), ("env", "light"), ("env", "light")] + assert [entry["command"] for entry in systemd.started] == [ + ("env", "heavy"), + ("env", "light"), + ("env", "light"), + ] assert subject.get(second["job_id"])["state"]["phase"] == "queued" assert light_a["state"]["phase"] == light_b["state"]["phase"] == "submitted" assert first["state"]["phase"] == "submitted" -def test_lone_job_larger_than_pool_budget_is_not_permanently_starved(tmp_path: Path) -> None: - adapter = project(tmp_path / "project", ( - operation("oversized", pool="bulk", estimate_memory_bytes=24 * 1024 * 1024 * 1024), - )) +def test_lone_job_larger_than_pool_budget_is_not_permanently_starved( + tmp_path: Path, +) -> None: + adapter = project( + tmp_path / "project", + ( + operation( + "oversized", pool="bulk", estimate_memory_bytes=24 * 1024 * 1024 * 1024 + ), + ), + ) systemd = FakeSystemd() subject = jobs(tmp_path, systemd) @@ -143,18 +209,32 @@ def test_lone_job_larger_than_pool_budget_is_not_permanently_starved(tmp_path: P ) assert started["state"]["phase"] == "submitted" - assert started["state"]["admission"]["estimate_memory_bytes"] == 18 * 1024 * 1024 * 1024 + assert ( + started["state"]["admission"]["estimate_memory_bytes"] + == 18 * 1024 * 1024 * 1024 + ) assert [entry["command"] for entry in systemd.started] == [("env", "oversized")] -def test_failed_launch_peak_does_not_replace_declared_memory_estimate(tmp_path: Path) -> None: +def test_failed_launch_peak_does_not_replace_declared_memory_estimate( + tmp_path: Path, +) -> None: adapter = project( tmp_path / "project", - (operation("heavy", pool="bulk", estimate_memory_bytes=12 * 1024 * 1024 * 1024),), + ( + operation( + "heavy", pool="bulk", estimate_memory_bytes=12 * 1024 * 1024 * 1024 + ), + ), ) systemd = FakeSystemd() subject = jobs(tmp_path, systemd) - started = subject.start_declared(project=adapter, operation=adapter.operation("heavy"), correlation_id="failed", parameters={}) + started = subject.start_declared( + project=adapter, + operation=adapter.operation("heavy"), + correlation_id="failed", + parameters={}, + ) systemd.properties = { "LoadState": "loaded", "ActiveState": "inactive", @@ -164,13 +244,23 @@ def test_failed_launch_peak_does_not_replace_declared_memory_estimate(tmp_path: } subject.get(started["job_id"]) - repeated = subject.start_declared(project=adapter, operation=adapter.operation("heavy"), correlation_id="retry", parameters={}) + repeated = subject.start_declared( + project=adapter, + operation=adapter.operation("heavy"), + correlation_id="retry", + parameters={}, + ) - assert repeated["state"]["admission"]["estimate_memory_bytes"] == 12 * 1024 * 1024 * 1024 + assert ( + repeated["state"]["admission"]["estimate_memory_bytes"] + == 12 * 1024 * 1024 * 1024 + ) def test_cache_and_coalescing_are_principal_isolated(tmp_path: Path) -> None: - adapter = project(tmp_path / "project", (operation("check", cache="tree+environment"),)) + adapter = project( + tmp_path / "project", (operation("check", cache="tree+environment"),) + ) systemd = FakeSystemd() subject = jobs(tmp_path, systemd) @@ -216,7 +306,12 @@ def test_cache_and_coalescing_are_principal_isolated(tmp_path: Path) -> None: assert agent_record.spec.principal == "agent-control" assert operator_record.spec.cache_key != agent_record.spec.cache_key - systemd.properties = {"LoadState": "loaded", "ActiveState": "inactive", "Result": "success", "ExecMainStatus": "0"} + systemd.properties = { + "LoadState": "loaded", + "ActiveState": "inactive", + "Result": "success", + "ExecMainStatus": "0", + } assert subject.get(operator_first["job_id"])["state"]["phase"] == "succeeded" assert subject.get(agent_first["job_id"])["state"]["phase"] == "succeeded" @@ -234,7 +329,10 @@ def test_cache_and_coalescing_are_principal_isolated(tmp_path: Path) -> None: principal="agent-control", parameters={}, ) - assert operator_cached["job_id"] == operator_first["job_id"] and operator_cached["reused"] + assert ( + operator_cached["job_id"] == operator_first["job_id"] + and operator_cached["reused"] + ) assert agent_cached["job_id"] == agent_first["job_id"] and agent_cached["reused"] (adapter.root / "tracked").write_text("changed\n") @@ -248,92 +346,217 @@ def test_cache_and_coalescing_are_principal_isolated(tmp_path: Path) -> None: assert uncached["job_id"] != operator_first["job_id"] and len(systemd.started) == 3 -def test_dependencies_exclusive_keys_learned_peaks_and_pressure_gate(tmp_path: Path) -> None: - adapter = project(tmp_path / "project", ( - operation("prepare", estimate_memory_bytes=64 * 1024 * 1024), - operation("check", dependencies=("prepare",), cache="none", exclusive_keys=("fixture:store",)), - operation("other", exclusive_keys=("fixture:store",)), - operation("heavy", pool="bulk", estimate_memory_bytes=12 * 1024 * 1024 * 1024), - operation("interactive", pool="interactive"), - )) +def test_dependencies_exclusive_keys_learned_peaks_and_pressure_gate( + tmp_path: Path, +) -> None: + adapter = project( + tmp_path / "project", + ( + operation("prepare", estimate_memory_bytes=64 * 1024 * 1024), + operation( + "check", + dependencies=("prepare",), + cache="none", + exclusive_keys=("fixture:store",), + ), + operation("other", exclusive_keys=("fixture:store",)), + operation( + "heavy", pool="bulk", estimate_memory_bytes=12 * 1024 * 1024 * 1024 + ), + operation("interactive", pool="interactive"), + ), + ) systemd = FakeSystemd() subject = jobs(tmp_path, systemd, pressure=0.5) - heavy = subject.start_declared(project=adapter, operation=adapter.operation("heavy"), correlation_id="heavy", parameters={}) - interactive = subject.start_declared(project=adapter, operation=adapter.operation("interactive"), correlation_id="interactive", parameters={}) + heavy = subject.start_declared( + project=adapter, + operation=adapter.operation("heavy"), + correlation_id="heavy", + parameters={}, + ) + interactive = subject.start_declared( + project=adapter, + operation=adapter.operation("interactive"), + correlation_id="interactive", + parameters={}, + ) assert heavy["state"]["phase"] == "queued" assert interactive["state"]["phase"] == "submitted" assert not systemd.stopped subject.pressure_probe = lambda: {"memory_full_avg10": 0.0} - primary = subject.start_declared(project=adapter, operation=adapter.operation("check"), correlation_id="check", parameters={}) + primary = subject.start_declared( + project=adapter, + operation=adapter.operation("check"), + correlation_id="check", + parameters={}, + ) prepare_id = primary["state"]["dependencies"][0] - systemd.properties = {"LoadState": "loaded", "ActiveState": "inactive", "Result": "success", "ExecMainStatus": "0", "MemoryPeak": str(777 * 1024 * 1024)} + systemd.properties = { + "LoadState": "loaded", + "ActiveState": "inactive", + "Result": "success", + "ExecMainStatus": "0", + "MemoryPeak": str(777 * 1024 * 1024), + } subject.get(prepare_id) - systemd.properties = {"LoadState": "loaded", "ActiveState": "active", "Result": "success", "ExecMainStatus": "0"} + systemd.properties = { + "LoadState": "loaded", + "ActiveState": "active", + "Result": "success", + "ExecMainStatus": "0", + } primary_state = subject.get(primary["job_id"]) - competing = subject.start_declared(project=adapter, operation=adapter.operation("other"), correlation_id="other", parameters={}) + competing = subject.start_declared( + project=adapter, + operation=adapter.operation("other"), + correlation_id="other", + parameters={}, + ) assert primary_state["state"]["phase"] in {"submitted", "running"} assert subject.get(competing["job_id"])["state"]["phase"] == "queued" - systemd.properties = {"LoadState": "loaded", "ActiveState": "inactive", "Result": "success", "ExecMainStatus": "0", "MemoryPeak": str(777 * 1024 * 1024)} + systemd.properties = { + "LoadState": "loaded", + "ActiveState": "inactive", + "Result": "success", + "ExecMainStatus": "0", + "MemoryPeak": str(777 * 1024 * 1024), + } subject.get(primary["job_id"]) - repeated = subject.start_declared(project=adapter, operation=adapter.operation("check"), correlation_id="again", parameters={}) + repeated = subject.start_declared( + project=adapter, + operation=adapter.operation("check"), + correlation_id="again", + parameters={}, + ) assert repeated["state"]["admission"]["estimate_memory_bytes"] == 777 * 1024 * 1024 @pytest.mark.parametrize("scratch", ("tmpfs", "nvme")) -def test_owned_scratch_is_injected_cleaned_on_terminal_and_recovered(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, scratch: str) -> None: +def test_owned_scratch_is_injected_cleaned_on_terminal_and_recovered( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, scratch: str +) -> None: monkeypatch.setenv("SINNIXD_TMPFS_SCRATCH_ROOT", str(tmp_path / "tmpfs")) monkeypatch.setenv("SINNIXD_NVME_SCRATCH_ROOT", str(tmp_path / "nvme")) adapter = project(tmp_path / "project", (operation("scratch", scratch=scratch),)) systemd = FakeSystemd() subject = jobs(tmp_path, systemd) - started = subject.start_declared(project=adapter, operation=adapter.operation("scratch"), correlation_id="one", parameters={}) + started = subject.start_declared( + project=adapter, + operation=adapter.operation("scratch"), + correlation_id="one", + parameters={}, + ) record = subject.store.load(started["job_id"]) assert record.scratch_path is not None and record.scratch_path.exists() assert systemd.started[0]["environment"]["TMPDIR"] == str(record.scratch_path) - systemd.properties = {"LoadState": "loaded", "ActiveState": "inactive", "Result": "failed", "ExecMainStatus": "1"} + systemd.properties = { + "LoadState": "loaded", + "ActiveState": "inactive", + "Result": "failed", + "ExecMainStatus": "1", + } subject.get(started["job_id"]) assert not record.scratch_path.exists() - succeeded = subject.start_declared(project=adapter, operation=adapter.operation("scratch"), correlation_id="two", parameters={}) + succeeded = subject.start_declared( + project=adapter, + operation=adapter.operation("scratch"), + correlation_id="two", + parameters={}, + ) success_record = subject.store.load(succeeded["job_id"]) - assert success_record.scratch_path is not None and success_record.scratch_path.exists() - systemd.properties = {"LoadState": "loaded", "ActiveState": "inactive", "Result": "success", "ExecMainStatus": "0"} + assert ( + success_record.scratch_path is not None and success_record.scratch_path.exists() + ) + systemd.properties = { + "LoadState": "loaded", + "ActiveState": "inactive", + "Result": "success", + "ExecMainStatus": "0", + } subject.get(succeeded["job_id"]) assert not success_record.scratch_path.exists() - cancelled = subject.start_declared(project=adapter, operation=adapter.operation("scratch"), correlation_id="three", parameters={}) + cancelled = subject.start_declared( + project=adapter, + operation=adapter.operation("scratch"), + correlation_id="three", + parameters={}, + ) cancel_record = subject.store.load(cancelled["job_id"]) - assert cancel_record.scratch_path is not None and cancel_record.scratch_path.exists() - systemd.properties = {"LoadState": "loaded", "ActiveState": "active", "Result": "success", "ExecMainStatus": "0", "InvocationID": "fixture"} + assert ( + cancel_record.scratch_path is not None and cancel_record.scratch_path.exists() + ) + systemd.properties = { + "LoadState": "loaded", + "ActiveState": "active", + "Result": "success", + "ExecMainStatus": "0", + "InvocationID": "fixture", + } subject.cancel(cancelled["job_id"]) assert not cancel_record.scratch_path.exists() - recovered = subject.store.create(GenericJobSpec(kind="foreground-command", command=("fixture",), working_directory=str(tmp_path), environment={}, scratch=scratch)) + recovered = subject.store.create( + GenericJobSpec( + kind="foreground-command", + command=("fixture",), + working_directory=str(tmp_path), + environment={}, + scratch=scratch, + ) + ) assert recovered.scratch_path is not None and recovered.scratch_path.exists() - subject.store.save(subject._with_state(recovered, {"phase": "failed", "terminal": True})) + subject.store.save( + subject._with_state(recovered, {"phase": "failed", "terminal": True}) + ) GenericJobs(systemd, subject.store) assert not recovered.scratch_path.exists() - protected = subject.store.create(GenericJobSpec(kind="foreground-command", command=("fixture",), working_directory=str(tmp_path), environment={}, scratch=scratch)) + protected = subject.store.create( + GenericJobSpec( + kind="foreground-command", + command=("fixture",), + working_directory=str(tmp_path), + environment={}, + scratch=scratch, + ) + ) assert protected.scratch_path is not None nested = protected.scratch_path / "pytest-fixture" / "cache" nested.mkdir(parents=True) (nested / "payload").write_text("fixture") nested.chmod(0o500) nested.parent.chmod(0o500) - subject.store.save(subject._with_state(protected, {"phase": "timed_out", "terminal": True})) + subject.store.save( + subject._with_state(protected, {"phase": "timed_out", "terminal": True}) + ) GenericJobs(systemd, subject.store) assert not protected.scratch_path.exists() -def test_queued_job_recreates_aged_scratch_before_launch(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: +def test_queued_job_recreates_aged_scratch_before_launch( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: monkeypatch.setenv("SINNIXD_NVME_SCRATCH_ROOT", str(tmp_path / "nvme")) adapter = project( tmp_path / "project", - (operation("heavy", pool="bulk", estimate_memory_bytes=12 * 1024 * 1024 * 1024, scratch="nvme"),), + ( + operation( + "heavy", + pool="bulk", + estimate_memory_bytes=12 * 1024 * 1024 * 1024, + scratch="nvme", + ), + ), ) systemd = FakeSystemd() subject = jobs(tmp_path, systemd) - first = subject.start_declared(project=adapter, operation=adapter.operation("heavy"), correlation_id="one", parameters={}) + first = subject.start_declared( + project=adapter, + operation=adapter.operation("heavy"), + correlation_id="one", + parameters={}, + ) queued = subject.start_declared( project=adapter, operation=adapter.operation("heavy"), @@ -354,23 +577,55 @@ def test_queued_job_recreates_aged_scratch_before_launch(tmp_path: Path, monkeyp "MemoryPeak": str(12 * 1024 * 1024 * 1024), } subject.get(first["job_id"]) - systemd.properties = {"LoadState": "loaded", "ActiveState": "active", "Result": "success", "ExecMainStatus": "0"} + systemd.properties = { + "LoadState": "loaded", + "ActiveState": "active", + "Result": "success", + "ExecMainStatus": "0", + } launched = subject.get(queued["job_id"]) assert launched["state"]["phase"] in {"submitted", "running"} assert queued_record.scratch_path.is_dir() - assert systemd.started[-1]["environment"]["TMPDIR"] == str(queued_record.scratch_path) + assert systemd.started[-1]["environment"]["TMPDIR"] == str( + queued_record.scratch_path + ) -def test_exit_json_pytest_and_agent_result_parsers_are_contract_specific(tmp_path: Path) -> None: - systemd = FakeSystemd(properties={"LoadState": "loaded", "ActiveState": "inactive", "Result": "success", "ExecMainStatus": "0"}) + +def test_exit_json_pytest_and_agent_result_parsers_are_contract_specific( + tmp_path: Path, +) -> None: + systemd = FakeSystemd( + properties={ + "LoadState": "loaded", + "ActiveState": "inactive", + "Result": "success", + "ExecMainStatus": "0", + } + ) subject = jobs(tmp_path, systemd) - exit_job = subject.start_foreground(command=("fixture",), working_directory=str(tmp_path), environment={}) - assert subject.result(exit_job["job_id"])["value"] == {"code": 0, "result": "success"} + exit_job = subject.start_foreground( + command=("fixture",), working_directory=str(tmp_path), environment={} + ) + assert subject.result(exit_job["job_id"])["value"] == { + "code": 0, + "result": "success", + } for kind in ("json", "pytest", "last-message"): - started = subject.start(GenericJobSpec(kind="foreground-command", command=("fixture",), working_directory=str(tmp_path), environment={}, result_kind=kind)) + started = subject.start( + GenericJobSpec( + kind="foreground-command", + command=("fixture",), + working_directory=str(tmp_path), + environment={}, + result_kind=kind, + ) + ) record = subject.store.load(started["job_id"]) assert record.result_path is not None - record.result_path.write_bytes(b'{"receipt":"ok"}' if kind != "last-message" else b"agent result") + record.result_path.write_bytes( + b'{"receipt":"ok"}' if kind != "last-message" else b"agent result" + ) result = subject.result(started["job_id"]) assert result["kind"] == kind if kind == "last-message": diff --git a/pkgs/sinnixd/test_jobs_observation.py b/pkgs/sinnixd/test_jobs_observation.py index d59398ad..9ab5811a 100644 --- a/pkgs/sinnixd/test_jobs_observation.py +++ b/pkgs/sinnixd/test_jobs_observation.py @@ -1,16 +1,15 @@ from __future__ import annotations -from dataclasses import dataclass, field, replace import json +from dataclasses import dataclass, field, replace from pathlib import Path import pytest - from sinnixd.jobs import ( SYSTEMD_COMMAND_TIMEOUT_SECONDS, + GenericJobs, GenericJobSpec, GenericJobStore, - GenericJobs, JobResultError, SystemdJobError, SystemdJobTimeout, @@ -50,7 +49,9 @@ def stop(self, unit: str) -> None: def generic_jobs(tmp_path: Path, systemd: FakeSystemdJobs) -> GenericJobs: - return GenericJobs(systemd, GenericJobStore(tmp_path / "state"), wait_poll_seconds=0.1) + return GenericJobs( + systemd, GenericJobStore(tmp_path / "state"), wait_poll_seconds=0.1 + ) def test_observation_timeout_remains_retryable_until_systemd_recovers( @@ -65,7 +66,9 @@ def test_observation_timeout_remains_retryable_until_systemd_recovers( ) systemd = FakeSystemdJobs(show_unavailable=True) jobs = generic_jobs(tmp_path, systemd) - started = jobs.start_foreground(command=("fixture",), working_directory=str(tmp_path), environment={}) + started = jobs.start_foreground( + command=("fixture",), working_directory=str(tmp_path), environment={} + ) unknown = jobs.get(started["job_id"]) persisted = (tmp_path / "state" / "jobs" / f"{started['job_id']}.json").read_text() @@ -124,7 +127,9 @@ def show( monkeypatch.setattr("sinnixd.jobs.time.monotonic", lambda: clock[0]) systemd = FirstLiveThenDeadlineExpires() jobs = generic_jobs(tmp_path, systemd) - started = jobs.start_foreground(command=("fixture",), working_directory=str(tmp_path), environment={}) + started = jobs.start_foreground( + command=("fixture",), working_directory=str(tmp_path), environment={} + ) first = jobs.wait(started["job_id"], timeout_seconds=1) clock[0] = 0.0 @@ -166,7 +171,9 @@ def show( monkeypatch.setattr("sinnixd.jobs.time.monotonic", lambda: clock[0]) systemd = FirstLiveThenUnavailable() jobs = generic_jobs(tmp_path, systemd) - started = jobs.start_foreground(command=("fixture",), working_directory=str(tmp_path), environment={}) + started = jobs.start_foreground( + command=("fixture",), working_directory=str(tmp_path), environment={} + ) first = jobs.wait(started["job_id"], timeout_seconds=1) clock[0] = 0.0 @@ -189,6 +196,7 @@ def test_cancel_reconciles_the_systemd_semantic_terminal_result( tmp_path: Path, result: str, status: str, expected: str ) -> None: """Anti-vacuity: a stop request must not override systemd's observed terminal result.""" + class TerminalOnStop(FakeSystemdJobs): def stop(self, unit: str) -> None: self.stopped.append(unit) @@ -202,7 +210,9 @@ def stop(self, unit: str) -> None: systemd = TerminalOnStop() jobs = generic_jobs(tmp_path, systemd) - started = jobs.start_foreground(command=("fixture",), working_directory=str(tmp_path), environment={}) + started = jobs.start_foreground( + command=("fixture",), working_directory=str(tmp_path), environment={} + ) terminal = jobs.cancel(started["job_id"]) record = jobs.store.load(started["job_id"]) @@ -213,8 +223,11 @@ def stop(self, unit: str) -> None: assert terminal["state"]["terminal"] -def test_stop_timeout_then_collected_unit_reconciles_after_restart(tmp_path: Path) -> None: +def test_stop_timeout_then_collected_unit_reconciles_after_restart( + tmp_path: Path, +) -> None: """Anti-vacuity: not-found defaults must not turn persisted cancel uncertainty into success.""" + class StopTimesOutThenCollects(FakeSystemdJobs): def stop(self, unit: str) -> None: self.stopped.append(unit) @@ -231,7 +244,9 @@ def stop(self, unit: str) -> None: systemd = StopTimesOutThenCollects() jobs = generic_jobs(tmp_path, systemd) - started = jobs.start_foreground(command=("fixture",), working_directory=str(tmp_path), environment={}) + started = jobs.start_foreground( + command=("fixture",), working_directory=str(tmp_path), environment={} + ) with pytest.raises(SystemdJobError, match="timed out"): jobs.cancel(started["job_id"]) @@ -254,7 +269,9 @@ def stop(self, unit: str) -> None: }, ) jobs.store.save(legacy_false_success) - restarted = GenericJobs(systemd, GenericJobStore(jobs.store.root), wait_poll_seconds=0.1) + restarted = GenericJobs( + systemd, GenericJobStore(jobs.store.root), wait_poll_seconds=0.1 + ) repaired = restarted.get(started["job_id"]) assert repaired["state"]["phase"] == "outcome-unknown" @@ -272,7 +289,9 @@ def stop(self, unit: str) -> None: assert cancelled["state"]["terminal"] -def test_collected_cancel_without_ack_terminalizes_after_reconciliation_grace(tmp_path: Path) -> None: +def test_collected_cancel_without_ack_terminalizes_after_reconciliation_grace( + tmp_path: Path, +) -> None: systemd = FakeSystemdJobs( properties={ "LoadState": "not-found", @@ -283,7 +302,9 @@ def test_collected_cancel_without_ack_terminalizes_after_reconciliation_grace(tm } ) jobs = generic_jobs(tmp_path, systemd) - started = jobs.start_foreground(command=("fixture",), working_directory=str(tmp_path), environment={}) + started = jobs.start_foreground( + command=("fixture",), working_directory=str(tmp_path), environment={} + ) record = jobs.store.load(started["job_id"]) jobs.store.save( replace( @@ -293,11 +314,16 @@ def test_collected_cancel_without_ack_terminalizes_after_reconciliation_grace(tm ) terminal = jobs.get(started["job_id"]) - restarted = GenericJobs(systemd, GenericJobStore(jobs.store.root), wait_poll_seconds=0.1) + restarted = GenericJobs( + systemd, GenericJobStore(jobs.store.root), wait_poll_seconds=0.1 + ) assert terminal["state"]["phase"] == "outcome-unknown" assert terminal["state"]["terminal"] - assert terminal["state"]["outcome_evidence"] == "unit-collected-after-cancellation-grace" + assert ( + terminal["state"]["outcome_evidence"] + == "unit-collected-after-cancellation-grace" + ) assert restarted.get(started["job_id"])["state"] == terminal["state"] @@ -332,7 +358,9 @@ def test_natural_success_requires_a_loaded_systemd_result( ) -> None: systemd = FakeSystemdJobs(properties=properties) jobs = generic_jobs(tmp_path, systemd) - started = jobs.start_foreground(command=("fixture",), working_directory=str(tmp_path), environment={}) + started = jobs.start_foreground( + command=("fixture",), working_directory=str(tmp_path), environment={} + ) terminal = jobs.get(started["job_id"]) @@ -411,7 +439,9 @@ def test_capture_completion_marker_requires_zero_exit( assert log_path.with_suffix(".complete").exists() is completed -def test_collected_exit_status_job_uses_capture_completion_marker(tmp_path: Path) -> None: +def test_collected_exit_status_job_uses_capture_completion_marker( + tmp_path: Path, +) -> None: """A successful short command remains succeeded after systemd collects its unit.""" systemd = FakeSystemdJobs( properties={ @@ -424,7 +454,9 @@ def test_collected_exit_status_job_uses_capture_completion_marker(tmp_path: Path } ) jobs = generic_jobs(tmp_path, systemd) - started = jobs.start_foreground(command=("fixture",), working_directory=str(tmp_path), environment={}) + started = jobs.start_foreground( + command=("fixture",), working_directory=str(tmp_path), environment={} + ) record = jobs.store.load(started["job_id"]) record.log_path.with_suffix(".complete").touch(mode=0o600) @@ -442,7 +474,11 @@ def test_collected_exit_status_job_uses_capture_completion_marker(tmp_path: Path { "phase": "outcome-unknown", "terminal": True, - "systemd": {"LoadState": "not-found", "ExecMainStatus": "0", "Result": "success"}, + "systemd": { + "LoadState": "not-found", + "ExecMainStatus": "0", + "Result": "success", + }, }, "unavailable", ), @@ -450,7 +486,11 @@ def test_collected_exit_status_job_uses_capture_completion_marker(tmp_path: Path { "phase": "missing", "terminal": True, - "systemd": {"LoadState": "not-found", "ExecMainStatus": "0", "Result": "success"}, + "systemd": { + "LoadState": "not-found", + "ExecMainStatus": "0", + "Result": "success", + }, }, "unavailable", ), @@ -458,12 +498,20 @@ def test_collected_exit_status_job_uses_capture_completion_marker(tmp_path: Path { "phase": "launch-failed", "terminal": True, - "systemd": {"LoadState": "not-found", "ExecMainStatus": "0", "Result": "success"}, + "systemd": { + "LoadState": "not-found", + "ExecMainStatus": "0", + "Result": "success", + }, }, "unavailable", ), ( - {"phase": "launch-failed", "terminal": True, "launch_evidence": "not-started"}, + { + "phase": "launch-failed", + "terminal": True, + "launch_evidence": "not-started", + }, "unavailable", ), ], @@ -473,7 +521,9 @@ def test_exit_result_rejects_default_success_without_authoritative_completion( ) -> None: """The result route must not promote systemd's absent-unit default status to success.""" jobs = generic_jobs(tmp_path, FakeSystemdJobs()) - started = jobs.start_foreground(command=("fixture",), working_directory=str(tmp_path), environment={}) + started = jobs.start_foreground( + command=("fixture",), working_directory=str(tmp_path), environment={} + ) record = jobs.store.load(started["job_id"]) jobs.store.save(jobs._with_state(record, state)) @@ -484,10 +534,42 @@ def test_exit_result_rejects_default_success_without_authoritative_completion( @pytest.mark.parametrize( ("properties", "expected"), [ - ({"LoadState": "loaded", "ActiveState": "inactive", "Result": "success", "ExecMainStatus": "0"}, {"code": 0, "result": "success"}), - ({"LoadState": "loaded", "ActiveState": "failed", "Result": "exit-code", "ExecMainStatus": "7"}, {"code": 7, "result": "exit-code"}), - ({"LoadState": "loaded", "ActiveState": "failed", "Result": "timeout", "ExecMainStatus": "1"}, {"code": 1, "result": "timeout"}), - ({"LoadState": "loaded", "ActiveState": "inactive", "Result": "signal", "ExecMainStatus": "15"}, {"code": 15, "result": "signal"}), + ( + { + "LoadState": "loaded", + "ActiveState": "inactive", + "Result": "success", + "ExecMainStatus": "0", + }, + {"code": 0, "result": "success"}, + ), + ( + { + "LoadState": "loaded", + "ActiveState": "failed", + "Result": "exit-code", + "ExecMainStatus": "7", + }, + {"code": 7, "result": "exit-code"}, + ), + ( + { + "LoadState": "loaded", + "ActiveState": "failed", + "Result": "timeout", + "ExecMainStatus": "1", + }, + {"code": 1, "result": "timeout"}, + ), + ( + { + "LoadState": "loaded", + "ActiveState": "inactive", + "Result": "signal", + "ExecMainStatus": "15", + }, + {"code": 15, "result": "signal"}, + ), ], ) def test_exit_result_preserves_authoritative_observed_outcomes( @@ -496,12 +578,16 @@ def test_exit_result_preserves_authoritative_observed_outcomes( """Exact loaded systemd outcomes remain the public exit result.""" systemd = FakeSystemdJobs(properties=properties) jobs = generic_jobs(tmp_path, systemd) - started = jobs.start_foreground(command=("fixture",), working_directory=str(tmp_path), environment={}) + started = jobs.start_foreground( + command=("fixture",), working_directory=str(tmp_path), environment={} + ) assert jobs.result(started["job_id"])["value"] == expected -def test_schema_v3_native_success_reconciles_after_restart_without_exec_main_status(tmp_path: Path) -> None: +def test_schema_v3_native_success_reconciles_after_restart_without_exec_main_status( + tmp_path: Path, +) -> None: """Evidence harness: a retained inactive unit must retain schema-v3 native completion evidence.""" job_id = "74e64cb4-282e-4b27-b4b1-af052b268161" systemd = FakeSystemdJobs( @@ -547,7 +633,9 @@ def test_schema_v3_native_success_reconciles_after_restart_without_exec_main_sta } record_path.write_text(json.dumps(legacy)) - restarted = GenericJobs(systemd, GenericJobStore(jobs.store.root), wait_poll_seconds=0.1) + restarted = GenericJobs( + systemd, GenericJobStore(jobs.store.root), wait_poll_seconds=0.1 + ) reconciled = restarted.get(job_id) assert reconciled["state"]["phase"] == "succeeded" @@ -557,7 +645,9 @@ def test_schema_v3_native_success_reconciles_after_restart_without_exec_main_sta @pytest.mark.parametrize("artifact", ("log", "result")) -def test_malformed_artifacts_fail_closed_without_exposing_private_paths(tmp_path: Path, artifact: str) -> None: +def test_malformed_artifacts_fail_closed_without_exposing_private_paths( + tmp_path: Path, artifact: str +) -> None: """Evidence harness: a malformed durable artifact must not expose its path through retrieval.""" jobs = generic_jobs(tmp_path, FakeSystemdJobs()) started = jobs.start( @@ -589,7 +679,9 @@ def test_log_reader_passes_the_requested_bounded_range_to_the_safe_artifact_read ) -> None: """Anti-vacuity: log offsets must seek before reading instead of expanding the read bound.""" jobs = generic_jobs(tmp_path, FakeSystemdJobs()) - started = jobs.start_foreground(command=("fixture",), working_directory=str(tmp_path), environment={}) + started = jobs.start_foreground( + command=("fixture",), working_directory=str(tmp_path), environment={} + ) observed: list[tuple[int, int]] = [] def read_window(path: Path, max_bytes: int, *, offset: int = 0) -> bytes: diff --git a/pkgs/sinnixd/test_service.py b/pkgs/sinnixd/test_service.py index 26bd7180..7dc1c91b 100644 --- a/pkgs/sinnixd/test_service.py +++ b/pkgs/sinnixd/test_service.py @@ -1,7 +1,7 @@ from __future__ import annotations -import json import hashlib +import json import os import shutil import socket @@ -17,19 +17,24 @@ from uuid import uuid4 import pytest - import sinnixd.api as api_module import sinnixd.cli as cli_module import sinnixd.jobs as jobs_module -from sinnix_mcp import ErrorCode, OpaquePayload, RequestEnvelope, ResponseEnvelope, SinnixRef, SourceBinding +from sinnix_mcp import ( + ErrorCode, + OpaquePayload, + RequestEnvelope, + ResponseEnvelope, + SinnixRef, + SourceBinding, +) from sinnix_mcp.execution import EnvironmentProfile, ExecutionResult, OwnerExecution - from sinnixd.api import ( CONNECTION_TIMEOUT_SECONDS, CONTROL_OPERATION_RESPONSE_TIMEOUT_SECONDS, MAX_JSON_RPC_ERROR_MESSAGE_BYTES, - ProtocolError, WAIT_TRANSPORT_MARGIN_SECONDS, + ProtocolError, SinnixdClient, SinnixdClientError, UnixSocketServer, @@ -38,15 +43,15 @@ receive_frame, send_frame, ) -from sinnixd.environment import build_environment from sinnixd.delivery import DeliveryError, GitHubDelivery +from sinnixd.environment import build_environment from sinnixd.jobs import ( DEFAULT_TIMEOUT_SECONDS, MAX_LOG_ARTIFACT_BYTES, SYSTEMD_COMMAND_TIMEOUT_SECONDS, + GenericJobs, GenericJobSpec, GenericJobStore, - GenericJobs, JobRecordError, JobResultError, JobResultLimitError, @@ -58,7 +63,12 @@ ) from sinnixd.limits import MAX_DECLARED_OPERATION_TIMEOUT_SECONDS from sinnixd.owner_adapters import DeclaredOwnerAdapters, OwnerAdapterError -from sinnixd.projects import ProjectCatalog, ProjectConfigError, RegisteredCheckout, parse_worktree_records +from sinnixd.projects import ( + ProjectCatalog, + ProjectConfigError, + RegisteredCheckout, + parse_worktree_records, +) from sinnixd.runner import ( RunnerError, _exec_shell, @@ -69,22 +79,25 @@ ) from sinnixd.service import SinnixdService from sinnixd.tasks import ( - BeadsCommandBoundary, FLOCK_EXECUTABLE, MAX_TASK_OUTPUT_BYTES, TASK_MUTATION_JOURNAL_DIRECTORY, + BeadsCommandBoundary, TaskAuthority, TaskError, TaskMutationJournal, TaskService, reconcile_task_mutations, ) -from sinnixd.workspaces import GitWorkspaces, WorkspaceError, WorkspaceStore +from sinnixd.workspaces import WorkspaceError @pytest.mark.parametrize(("ok", "expected"), ((True, 0), (False, 1))) def test_agentctl_exit_status_matches_response_envelope( - ok: bool, expected: int, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] + ok: bool, + expected: int, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], ) -> None: response = {"schema": 1, "ok": ok} monkeypatch.setattr(sys, "argv", ["agentctl", "status"]) @@ -145,10 +158,23 @@ def test_call_preserves_request_matching_and_rejects_malformed_json_rpc_errors( def reply(raw: dict[str, object]) -> dict[str, object]: if shape == "mismatched-id": - return {"jsonrpc": "2.0", "id": "not-the-request-id", "result": {"ok": True}} + return { + "jsonrpc": "2.0", + "id": "not-the-request-id", + "result": {"ok": True}, + } if shape == "unexpected-response-field": - return {"jsonrpc": "2.0", "id": raw["id"], "result": {"ok": True}, "extra": "rejected"} - message = "server-secret" if shape == "unexpected-error-field" else "x" * (MAX_JSON_RPC_ERROR_MESSAGE_BYTES + 1) + return { + "jsonrpc": "2.0", + "id": raw["id"], + "result": {"ok": True}, + "extra": "rejected", + } + message = ( + "server-secret" + if shape == "unexpected-error-field" + else "x" * (MAX_JSON_RPC_ERROR_MESSAGE_BYTES + 1) + ) error_value: dict[str, object] = {"code": -32600, "message": message} if shape == "unexpected-error-field": error_value["data"] = "must-not-be-accepted" @@ -185,24 +211,195 @@ def test_canonical_client_redacts_unrecognized_json_rpc_errors(tmp_path: Path) - @pytest.mark.parametrize( ("argv", "operation", "payload"), ( - (("agentctl", "task", "list", "fixture", "--status", "open"), "task.list", {"project_id": "fixture", "status": "open", "limit": 100}), - (("agentctl", "task", "list", "fixture", "--status", "open", "--json"), "task.list", {"project_id": "fixture", "status": "open", "limit": 100}), - (("agentctl", "task", "get", "fixture", "fixture-1"), "task.get", {"project_id": "fixture", "task_id": "fixture-1"}), - (("agentctl", "task", "show", "fixture", "fixture-1"), "task.get", {"project_id": "fixture", "task_id": "fixture-1"}), - (("agentctl", "task", "create", "fixture", "typed title", "--description", "typed description", "--type", "task", "--priority", "2", "--label", "area:agentctl", "--parent", "fixture-parent", "--dependency", "depends-on:fixture-blocker", "--request-id", "request-1"), "task.create", {"project_id": "fixture", "title": "typed title", "description": "typed description", "issue_type": "task", "priority": 2, "labels": ["area:agentctl"], "parent_task_id": "fixture-parent", "dependencies": [{"relation": "depends-on", "task_id": "fixture-blocker"}]}), - (("agentctl", "task", "claim", "fixture", "fixture-1", "--request-id", "request-1"), "task.claim", {"project_id": "fixture", "task_id": "fixture-1"}), - (("agentctl", "task", "note", "fixture", "fixture-1", "note", "--request-id", "request-1"), "task.note", {"project_id": "fixture", "task_id": "fixture-1", "text": "note"}), - (("agentctl", "task", "note", "fixture", "fixture-1", "--text", "note", "--request-id", "request-1"), "task.note", {"project_id": "fixture", "task_id": "fixture-1", "text": "note"}), - (("agentctl", "task", "update", "fixture", "fixture-1", "--set-metadata", "write_scope=[\"pkgs/sinnixd/\"]", "--request-id", "request-1"), "task.update", {"project_id": "fixture", "task_id": "fixture-1", "metadata": {"write_scope": '["pkgs/sinnixd/"]'}}), - (("agentctl", "task", "relate", "fixture", "fixture-1", "fixture-2", "--request-id", "request-1"), "task.relate", {"project_id": "fixture", "task_id": "fixture-1", "related_task_id": "fixture-2"}), - (("agentctl", "task", "complete", "fixture", "fixture-1", "--reason", "done", "--merge-sha", "a" * 40, "--request-id", "request-1"), "task.complete", {"project_id": "fixture", "task_id": "fixture-1", "reason": "done", "merge_sha": "a" * 40}), - (("agentctl", "task", "release", "fixture", "fixture-1", "--if-assignee", "worker", "--request-id", "request-1"), "task.release", {"project_id": "fixture", "task_id": "fixture-1", "if_assignee": "worker"}), - (("agentctl", "task", "reconcile", "fixture"), "task.reconcile", {"project_id": "fixture"}), - (("agentctl", "task", "snapshot", "fixture"), "task.snapshot", {"project_id": "fixture"}), + ( + ("agentctl", "task", "list", "fixture", "--status", "open"), + "task.list", + {"project_id": "fixture", "status": "open", "limit": 100}, + ), + ( + ("agentctl", "task", "list", "fixture", "--status", "open", "--json"), + "task.list", + {"project_id": "fixture", "status": "open", "limit": 100}, + ), + ( + ("agentctl", "task", "get", "fixture", "fixture-1"), + "task.get", + {"project_id": "fixture", "task_id": "fixture-1"}, + ), + ( + ("agentctl", "task", "show", "fixture", "fixture-1"), + "task.get", + {"project_id": "fixture", "task_id": "fixture-1"}, + ), + ( + ( + "agentctl", + "task", + "create", + "fixture", + "typed title", + "--description", + "typed description", + "--type", + "task", + "--priority", + "2", + "--label", + "area:agentctl", + "--parent", + "fixture-parent", + "--dependency", + "depends-on:fixture-blocker", + "--request-id", + "request-1", + ), + "task.create", + { + "project_id": "fixture", + "title": "typed title", + "description": "typed description", + "issue_type": "task", + "priority": 2, + "labels": ["area:agentctl"], + "parent_task_id": "fixture-parent", + "dependencies": [ + {"relation": "depends-on", "task_id": "fixture-blocker"} + ], + }, + ), + ( + ( + "agentctl", + "task", + "claim", + "fixture", + "fixture-1", + "--request-id", + "request-1", + ), + "task.claim", + {"project_id": "fixture", "task_id": "fixture-1"}, + ), + ( + ( + "agentctl", + "task", + "note", + "fixture", + "fixture-1", + "note", + "--request-id", + "request-1", + ), + "task.note", + {"project_id": "fixture", "task_id": "fixture-1", "text": "note"}, + ), + ( + ( + "agentctl", + "task", + "note", + "fixture", + "fixture-1", + "--text", + "note", + "--request-id", + "request-1", + ), + "task.note", + {"project_id": "fixture", "task_id": "fixture-1", "text": "note"}, + ), + ( + ( + "agentctl", + "task", + "update", + "fixture", + "fixture-1", + "--set-metadata", + 'write_scope=["pkgs/sinnixd/"]', + "--request-id", + "request-1", + ), + "task.update", + { + "project_id": "fixture", + "task_id": "fixture-1", + "metadata": {"write_scope": '["pkgs/sinnixd/"]'}, + }, + ), + ( + ( + "agentctl", + "task", + "relate", + "fixture", + "fixture-1", + "fixture-2", + "--request-id", + "request-1", + ), + "task.relate", + { + "project_id": "fixture", + "task_id": "fixture-1", + "related_task_id": "fixture-2", + }, + ), + ( + ( + "agentctl", + "task", + "complete", + "fixture", + "fixture-1", + "--reason", + "done", + "--merge-sha", + "a" * 40, + "--request-id", + "request-1", + ), + "task.complete", + { + "project_id": "fixture", + "task_id": "fixture-1", + "reason": "done", + "merge_sha": "a" * 40, + }, + ), + ( + ( + "agentctl", + "task", + "release", + "fixture", + "fixture-1", + "--if-assignee", + "worker", + "--request-id", + "request-1", + ), + "task.release", + {"project_id": "fixture", "task_id": "fixture-1", "if_assignee": "worker"}, + ), + ( + ("agentctl", "task", "reconcile", "fixture"), + "task.reconcile", + {"project_id": "fixture"}, + ), + ( + ("agentctl", "task", "snapshot", "fixture"), + "task.snapshot", + {"project_id": "fixture"}, + ), ), ) def test_agentctl_task_commands_map_to_task_envelopes( - argv: tuple[str, ...], operation: str, payload: dict[str, object], monkeypatch: pytest.MonkeyPatch + argv: tuple[str, ...], + operation: str, + payload: dict[str, object], + monkeypatch: pytest.MonkeyPatch, ) -> None: captured: dict[str, RequestEnvelope] = {} @@ -219,7 +416,20 @@ def fake_call(socket_path, request_value): assert outbound.owner == "task-backend" assert outbound.principal == "operator" assert dict(outbound.arguments) == payload - expected_key = "request-1" if operation in {"task.create", "task.claim", "task.note", "task.relate", "task.complete", "task.release", "task.update"} else None + expected_key = ( + "request-1" + if operation + in { + "task.create", + "task.claim", + "task.note", + "task.relate", + "task.complete", + "task.release", + "task.update", + } + else None + ) assert outbound.idempotency_key == expected_key @@ -248,17 +458,55 @@ def test_agentctl_task_mutations_require_a_stable_request_id() -> None: with pytest.raises(SystemExit): cli_module.parser().parse_args(["task", "claim", "fixture", "fixture-1"]) with pytest.raises(SystemExit): - cli_module.parser().parse_args(["task", "complete", "fixture", "fixture-1", "--request-id", "request-1"]) + cli_module.parser().parse_args( + ["task", "complete", "fixture", "fixture-1", "--request-id", "request-1"] + ) with pytest.raises(SystemExit): - cli_module.parser().parse_args(["task", "create", "fixture", "title", "--description", "body", "--type", "task", "--priority", "2"]) + cli_module.parser().parse_args( + [ + "task", + "create", + "fixture", + "title", + "--description", + "body", + "--type", + "task", + "--priority", + "2", + ] + ) with pytest.raises(SystemExit): - cli_module.parser().parse_args(["task", "create", "fixture", "title", "--description", "body", "--type", "task", "--priority", "5", "--request-id", "request-1"]) + cli_module.parser().parse_args( + [ + "task", + "create", + "fixture", + "title", + "--description", + "body", + "--type", + "task", + "--priority", + "5", + "--request-id", + "request-1", + ] + ) @pytest.mark.parametrize( "argv", ( - ["agentctl", "task", "note", "fixture", "fixture-1", "--request-id", "request-1"], + [ + "agentctl", + "task", + "note", + "fixture", + "fixture-1", + "--request-id", + "request-1", + ], [ "agentctl", "task", @@ -346,7 +594,19 @@ def fake_call(socket_path, request_value): monkeypatch.setattr( sys, "argv", - ["agentctl", "task", "list", "fixture", "--limit", "2", "--cursor", "cursor-fixture", "--sort", "id", "--reverse"], + [ + "agentctl", + "task", + "list", + "fixture", + "--limit", + "2", + "--cursor", + "cursor-fixture", + "--sort", + "id", + "--reverse", + ], ) monkeypatch.setattr(cli_module, "call", fake_call) @@ -369,7 +629,9 @@ def fake_call(socket_path, request_value): captured["request"] = request_value return {"schema": 1, "ok": True} - monkeypatch.setattr(sys, "argv", ["agentctl", "workspace", "dispose", "workspace-1"]) + monkeypatch.setattr( + sys, "argv", ["agentctl", "workspace", "dispose", "workspace-1"] + ) monkeypatch.setattr(cli_module, "call", fake_call) assert cli_module.main() == 0 @@ -392,7 +654,14 @@ def fake_call(socket_path, request_value): monkeypatch.setattr( sys, "argv", - ["agentctl", "workspace", "finish-integrated", "workspace-1", "--target", "abc123"], + [ + "agentctl", + "workspace", + "finish-integrated", + "workspace-1", + "--target", + "abc123", + ], ) monkeypatch.setattr(cli_module, "call", fake_call) @@ -401,7 +670,10 @@ def fake_call(socket_path, request_value): assert outbound.operation == "workspace.finish-integrated" assert outbound.owner == "git-workspaces" assert outbound.principal == "agent-control" - assert dict(outbound.arguments) == {"workspace_id": "workspace-1", "target_ref": "abc123"} + assert dict(outbound.arguments) == { + "workspace_id": "workspace-1", + "target_ref": "abc123", + } def test_agentctl_job_start_maps_parameters_json_to_the_typed_request( @@ -416,7 +688,15 @@ def fake_call(socket_path, request_value): monkeypatch.setattr( sys, "argv", - ["agentctl", "job", "start", "fixture", "parameterized", "--parameters-json", '{"package":["xtask","sinexd"],"full":true}'], + [ + "agentctl", + "job", + "start", + "fixture", + "parameterized", + "--parameters-json", + '{"package":["xtask","sinexd"],"full":true}', + ], ) monkeypatch.setattr(cli_module, "call", fake_call) @@ -449,7 +729,7 @@ def write_adapter(root: Path, *, project_id: str = "fixture") -> None: [workspace] provider = "git-worktree" -root = "{root / 'worktrees'}" +root = "{root / "worktrees"}" default_base = "origin/master" identity_check = ["git", "diff", "--quiet"] checkpoint_untracked = true @@ -617,22 +897,24 @@ def write_owner_adapter(root: Path) -> None: "fragment", ( "unknown = true\n", - "[operations.parameterized.parameters.broken]\ntype = \"integer\"\nflag = \"--broken\"\nmin = 1\n", - "[operations.parameterized.parameters.unbounded]\ntype = \"string-list\"\nflag = \"--unbounded\"\nmax_items = 4\n", - "[operations.parameterized.parameters.unknown_string]\ntype = \"string\"\nflag = \"--string\"\nmax_length = 4\ngrammar = \"shell\"\n", - "[operations.parameterized.parameters.boolean_integer]\ntype = \"integer\"\nflag = \"--integer\"\nmin = true\nmax = 4\n", - "[operations.parameterized.parameters.empty_enum]\ntype = \"enum\"\nflag = \"--enum\"\nvalues = []\n", - "[operations.parameterized.parameters.duplicate_enum]\ntype = \"enum\"\nflag = \"--enum\"\nvalues = [\"same\", \"same\"]\n", - "[operations.parameterized.parameters.unbounded_enum_list]\ntype = \"enum-list\"\nflag = \"--enum-list\"\nvalues = [\"one\"]\n", - "[operations.parameterized.parameters.duplicate_flag]\ntype = \"string\"\nflag = \"--full\"\nmax_length = 4\n", - "[operations.verify_closure.parameters.ambiguous]\ntype = \"string\"\nflag = \"--ambiguous\"\nposition = 2\nrequired = true\nmax_length = 4\n", - "[operations.verify_closure.parameters.optional]\ntype = \"string\"\nposition = 2\nrequired = false\nmax_length = 4\n", - "[operations.verify_closure.parameters.duplicate_position]\ntype = \"string\"\nposition = 1\nrequired = true\nmax_length = 4\n", - "[operations.verify_closure.parameters.gapped_position]\ntype = \"string\"\nposition = 3\nrequired = true\nmax_length = 4\n", - "[operations.verify_closure.parameters.list_position]\ntype = \"string-list\"\nposition = 2\nrequired = true\nmax_items = 1\nmax_length = 4\n", + '[operations.parameterized.parameters.broken]\ntype = "integer"\nflag = "--broken"\nmin = 1\n', + '[operations.parameterized.parameters.unbounded]\ntype = "string-list"\nflag = "--unbounded"\nmax_items = 4\n', + '[operations.parameterized.parameters.unknown_string]\ntype = "string"\nflag = "--string"\nmax_length = 4\ngrammar = "shell"\n', + '[operations.parameterized.parameters.boolean_integer]\ntype = "integer"\nflag = "--integer"\nmin = true\nmax = 4\n', + '[operations.parameterized.parameters.empty_enum]\ntype = "enum"\nflag = "--enum"\nvalues = []\n', + '[operations.parameterized.parameters.duplicate_enum]\ntype = "enum"\nflag = "--enum"\nvalues = ["same", "same"]\n', + '[operations.parameterized.parameters.unbounded_enum_list]\ntype = "enum-list"\nflag = "--enum-list"\nvalues = ["one"]\n', + '[operations.parameterized.parameters.duplicate_flag]\ntype = "string"\nflag = "--full"\nmax_length = 4\n', + '[operations.verify_closure.parameters.ambiguous]\ntype = "string"\nflag = "--ambiguous"\nposition = 2\nrequired = true\nmax_length = 4\n', + '[operations.verify_closure.parameters.optional]\ntype = "string"\nposition = 2\nrequired = false\nmax_length = 4\n', + '[operations.verify_closure.parameters.duplicate_position]\ntype = "string"\nposition = 1\nrequired = true\nmax_length = 4\n', + '[operations.verify_closure.parameters.gapped_position]\ntype = "string"\nposition = 3\nrequired = true\nmax_length = 4\n', + '[operations.verify_closure.parameters.list_position]\ntype = "string-list"\nposition = 2\nrequired = true\nmax_items = 1\nmax_length = 4\n', ), ) -def test_project_operation_parameter_schema_is_closed_and_bounded(tmp_path: Path, fragment: str) -> None: +def test_project_operation_parameter_schema_is_closed_and_bounded( + tmp_path: Path, fragment: str +) -> None: write_adapter(tmp_path) descriptor = tmp_path / ".agentctl" / "project.toml" descriptor.write_text(descriptor.read_text() + fragment) @@ -641,7 +923,9 @@ def test_project_operation_parameter_schema_is_closed_and_bounded(tmp_path: Path ProjectCatalog([tmp_path]) -def test_project_operation_parameter_count_supports_broad_typed_clis(tmp_path: Path) -> None: +def test_project_operation_parameter_count_supports_broad_typed_clis( + tmp_path: Path, +) -> None: write_adapter(tmp_path) descriptor = tmp_path / ".agentctl" / "project.toml" parameters = "".join( @@ -676,7 +960,9 @@ def test_project_operation_parameter_count_remains_bounded(tmp_path: Path) -> No ProjectCatalog([tmp_path]) -def test_operation_dependencies_reject_required_parameter_targets(tmp_path: Path) -> None: +def test_operation_dependencies_reject_required_parameter_targets( + tmp_path: Path, +) -> None: """Anti-vacuity: dependencies have no parameter payload to satisfy required inputs.""" write_adapter(tmp_path) descriptor = tmp_path / ".agentctl" / "project.toml" @@ -721,7 +1007,10 @@ def test_required_parameter_operations_reject_dependencies(tmp_path: Path) -> No """ ) - with pytest.raises(ProjectConfigError, match="cannot declare dependencies.*required_with_dependency"): + with pytest.raises( + ProjectConfigError, + match="cannot declare dependencies.*required_with_dependency", + ): ProjectCatalog([tmp_path]) @@ -729,7 +1018,9 @@ def test_required_parameter_operations_reject_dependencies(tmp_path: Path) -> No "value", ("true", '"3600"', "0", "-1", str(MAX_DECLARED_OPERATION_TIMEOUT_SECONDS + 1)), ) -def test_declared_operation_timeout_must_be_a_positive_bounded_integer(tmp_path: Path, value: str) -> None: +def test_declared_operation_timeout_must_be_a_positive_bounded_integer( + tmp_path: Path, value: str +) -> None: write_adapter(tmp_path) descriptor = tmp_path / ".agentctl" / "project.toml" descriptor.write_text( @@ -743,7 +1034,9 @@ def test_declared_operation_timeout_must_be_a_positive_bounded_integer(tmp_path: ProjectCatalog([tmp_path]) -def test_declared_operation_timeout_defaults_and_survives_launch_recovery(tmp_path: Path) -> None: +def test_declared_operation_timeout_defaults_and_survives_launch_recovery( + tmp_path: Path, +) -> None: write_adapter(tmp_path) descriptor = tmp_path / ".agentctl" / "project.toml" descriptor.write_text( @@ -755,25 +1048,45 @@ def test_declared_operation_timeout_defaults_and_survives_launch_recovery(tmp_pa catalog = ProjectCatalog([tmp_path]) check = catalog.get("fixture").operation("check") assert check.timeout_seconds == MAX_DECLARED_OPERATION_TIMEOUT_SECONDS - assert catalog.get("fixture").operation("parameterized").timeout_seconds == DEFAULT_TIMEOUT_SECONDS - assert check.catalog_row()["timeout_seconds"] == MAX_DECLARED_OPERATION_TIMEOUT_SECONDS + assert ( + catalog.get("fixture").operation("parameterized").timeout_seconds + == DEFAULT_TIMEOUT_SECONDS + ) + assert ( + check.catalog_row()["timeout_seconds"] == MAX_DECLARED_OPERATION_TIMEOUT_SECONDS + ) systemd = FakeSystemdJobs() jobs = generic_jobs(tmp_path, systemd) service = SinnixdService(catalog, jobs=jobs) - response = service.dispatch(request("job.start", "systemd-jobs", {"project_id": "fixture", "operation": "check"})) + response = service.dispatch( + request( + "job.start", "systemd-jobs", {"project_id": "fixture", "operation": "check"} + ) + ) assert response.ok and response.payload is not None launched = response.payload.inline assert launched["timeout_seconds"] == MAX_DECLARED_OPERATION_TIMEOUT_SECONDS - assert systemd.started[0]["timeout_seconds"] == MAX_DECLARED_OPERATION_TIMEOUT_SECONDS + assert ( + systemd.started[0]["timeout_seconds"] == MAX_DECLARED_OPERATION_TIMEOUT_SECONDS + ) record = jobs.store.load(launched["job_id"]) assert record.spec.timeout_seconds == MAX_DECLARED_OPERATION_TIMEOUT_SECONDS recovered = GenericJobs(systemd, jobs.store, wait_poll_seconds=0.001) - assert recovered.get(launched["job_id"])["timeout_seconds"] == MAX_DECLARED_OPERATION_TIMEOUT_SECONDS - assert GenericJobSpec( - kind="foreground-command", command=("fixture",), working_directory=str(tmp_path), environment={} - ).timeout_seconds == DEFAULT_TIMEOUT_SECONDS + assert ( + recovered.get(launched["job_id"])["timeout_seconds"] + == MAX_DECLARED_OPERATION_TIMEOUT_SECONDS + ) + assert ( + GenericJobSpec( + kind="foreground-command", + command=("fixture",), + working_directory=str(tmp_path), + environment={}, + ).timeout_seconds + == DEFAULT_TIMEOUT_SECONDS + ) @pytest.mark.parametrize( @@ -783,28 +1096,42 @@ def test_declared_operation_timeout_defaults_and_survives_launch_recovery(tmp_pa 'lifetime = "daemon"', 'environment = "SINNIXD_JOB_ID"', 'environment = "HOME"', - 'range = [1023, 1024]', - 'range = [41000, 41256]', + "range = [1023, 1024]", + "range = [41000, 41256]", ), ) -def test_service_declaration_is_closed_and_bounded(tmp_path: Path, replacement: str) -> None: +def test_service_declaration_is_closed_and_bounded( + tmp_path: Path, replacement: str +) -> None: """Anti-vacuity: a service descriptor cannot become a caller-controlled launch overlay.""" write_adapter(tmp_path) descriptor = tmp_path / ".agentctl" / "project.toml" if replacement.startswith("readiness"): - descriptor.write_text(descriptor.read_text().replace('readiness = "project-command"', replacement)) + descriptor.write_text( + descriptor.read_text().replace('readiness = "project-command"', replacement) + ) elif replacement.startswith("lifetime"): - descriptor.write_text(descriptor.read_text().replace('lifetime = "job"', replacement)) + descriptor.write_text( + descriptor.read_text().replace('lifetime = "job"', replacement) + ) elif replacement.startswith("environment"): - descriptor.write_text(descriptor.read_text().replace('environment = "FIXTURE_HTTP_PORT"', replacement)) + descriptor.write_text( + descriptor.read_text().replace( + 'environment = "FIXTURE_HTTP_PORT"', replacement + ) + ) else: - descriptor.write_text(descriptor.read_text().replace('range = [41000, 41001]', replacement)) + descriptor.write_text( + descriptor.read_text().replace("range = [41000, 41001]", replacement) + ) with pytest.raises(ProjectConfigError, match="operations.service.service"): ProjectCatalog([tmp_path]) -def test_service_lease_is_bounded_public_metadata_and_injects_only_declared_port(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: +def test_service_lease_is_bounded_public_metadata_and_injects_only_declared_port( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: """Anti-vacuity: a service lease must reach the generic launch without persisting arbitrary environment input.""" monkeypatch.setattr("sinnixd.jobs._loopback_port_available", lambda _port: True) write_adapter(tmp_path) @@ -812,20 +1139,36 @@ def test_service_lease_is_bounded_public_metadata_and_injects_only_declared_port jobs = generic_jobs(tmp_path, systemd) service = SinnixdService(ProjectCatalog([tmp_path]), jobs=jobs) - started = service.dispatch(request("job.start", "systemd-jobs", {"project_id": "fixture", "operation": "service"})) + started = service.dispatch( + request( + "job.start", + "systemd-jobs", + {"project_id": "fixture", "operation": "service"}, + ) + ) assert started.ok and started.payload is not None lease = started.payload.inline["lease"] assert lease is not None assert lease["id"] == started.payload.inline["job_id"] assert lease["host"] == "127.0.0.1" - assert lease["ports"] == [{"name": "http", "environment": "FIXTURE_HTTP_PORT", "port": 41000}] + assert lease["ports"] == [ + {"name": "http", "environment": "FIXTURE_HTTP_PORT", "port": 41000} + ] assert systemd.started[0]["environment"]["FIXTURE_HTTP_PORT"] == "41000" persisted = (tmp_path / "state" / "jobs" / f"{lease['id']}.json").read_text() assert "fixture-service" not in persisted assert "FIXTURE_HTTP_PORT" in persisted rejected = service.dispatch( - request("job.start", "systemd-jobs", {"project_id": "fixture", "operation": "service", "environment": {"SECRET": "value"}}) + request( + "job.start", + "systemd-jobs", + { + "project_id": "fixture", + "operation": "service", + "environment": {"SECRET": "value"}, + }, + ) ) assert rejected.error is not None assert rejected.error.code.value == "INVALID_ARGUMENT" @@ -841,15 +1184,23 @@ def test_nix_develop_payload_receives_job_owned_tmpdir_after_environment_entry( descriptor.write_text( descriptor.read_text() .replace('kind = "fixture"', 'kind = "nix-develop"') - .replace('command = ["fixture-env", "--command"]', 'command = ["nix", "develop", "--command"]') - .replace('exclusive_keys = ["fixture:check"]', 'exclusive_keys = ["fixture:check"]\nscratch = "nvme"') + .replace( + 'command = ["fixture-env", "--command"]', + 'command = ["nix", "develop", "--command"]', + ) + .replace( + 'exclusive_keys = ["fixture:check"]', + 'exclusive_keys = ["fixture:check"]\nscratch = "nvme"', + ) ) systemd = FakeSystemdJobs() jobs = generic_jobs(tmp_path, systemd) service = SinnixdService(ProjectCatalog([tmp_path]), jobs=jobs) started = service.dispatch( - request("job.start", "systemd-jobs", {"project_id": "fixture", "operation": "check"}) + request( + "job.start", "systemd-jobs", {"project_id": "fixture", "operation": "check"} + ) ) assert started.ok and started.payload is not None @@ -857,7 +1208,14 @@ def test_nix_develop_payload_receives_job_owned_tmpdir_after_environment_entry( assert record.scratch_path is not None expected = str(record.scratch_path) command, environment = jobs.store.declared_launch(record.job_id) - assert command == ("nix", "develop", "--command", "env", f"TMPDIR={expected}", "fixture-check") + assert command == ( + "nix", + "develop", + "--command", + "env", + f"TMPDIR={expected}", + "fixture-check", + ) assert environment["TMPDIR"] == expected assert systemd.started[0]["environment"]["TMPDIR"] == expected @@ -867,7 +1225,9 @@ def test_declared_service_dependency_supplies_lease_and_unblocks_when_bound( ) -> None: """A dependent operation receives its service lease and starts only after the port is bound.""" port_available = True - monkeypatch.setattr("sinnixd.jobs._loopback_port_available", lambda _port: port_available) + monkeypatch.setattr( + "sinnixd.jobs._loopback_port_available", lambda _port: port_available + ) write_adapter(tmp_path) descriptor = tmp_path / ".agentctl" / "project.toml" descriptor.write_text( @@ -881,7 +1241,9 @@ def test_declared_service_dependency_supplies_lease_and_unblocks_when_bound( service = SinnixdService(ProjectCatalog([tmp_path]), jobs=jobs) started = service.dispatch( - request("job.start", "systemd-jobs", {"project_id": "fixture", "operation": "check"}) + request( + "job.start", "systemd-jobs", {"project_id": "fixture", "operation": "check"} + ) ) assert started.ok and started.payload is not None @@ -891,7 +1253,9 @@ def test_declared_service_dependency_supplies_lease_and_unblocks_when_bound( assert launch_environment["FIXTURE_HTTP_PORT"] == "41000" assert check_record.state["phase"] == "waiting-dependencies" dependency_id = check_record.spec.dependency_job_ids[0] - dependency_command, dependency_environment = jobs.store.declared_launch(dependency_id) + dependency_command, dependency_environment = jobs.store.declared_launch( + dependency_id + ) assert dependency_command == ("fixture-env", "--command", "fixture-service") assert len(systemd.started) == 1 @@ -907,16 +1271,35 @@ def test_declared_service_dependency_supplies_lease_and_unblocks_when_bound( assert len(systemd.started) == 2 -def test_live_service_leases_never_share_a_port(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: +def test_live_service_leases_never_share_a_port( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: """Anti-vacuity: two live declared jobs must allocate different port slots from one range.""" monkeypatch.setattr("sinnixd.jobs._loopback_port_available", lambda _port: True) write_adapter(tmp_path) service = SinnixdService(ProjectCatalog([tmp_path]), jobs=generic_jobs(tmp_path)) - first = service.dispatch(request("job.start", "systemd-jobs", {"project_id": "fixture", "operation": "service"})) - second = service.dispatch(request("job.start", "systemd-jobs", {"project_id": "fixture", "operation": "service"})) + first = service.dispatch( + request( + "job.start", + "systemd-jobs", + {"project_id": "fixture", "operation": "service"}, + ) + ) + second = service.dispatch( + request( + "job.start", + "systemd-jobs", + {"project_id": "fixture", "operation": "service"}, + ) + ) - assert first.ok and second.ok and first.payload is not None and second.payload is not None + assert ( + first.ok + and second.ok + and first.payload is not None + and second.payload is not None + ) assert first.payload.inline["lease"]["ports"][0]["port"] == 41000 assert second.payload.inline["lease"]["ports"][0]["port"] == 41001 @@ -943,17 +1326,27 @@ def test_non_cacheable_operation_coalesces_only_while_active(tmp_path: Path) -> first = service.dispatch(request("job.start", "systemd-jobs", arguments)) duplicate = service.dispatch(request("job.start", "systemd-jobs", arguments)) - assert first.ok and duplicate.ok and first.payload is not None and duplicate.payload is not None + assert ( + first.ok + and duplicate.ok + and first.payload is not None + and duplicate.payload is not None + ) assert duplicate.payload.inline["job_id"] == first.payload.inline["job_id"] assert duplicate.payload.inline["coalesced"] is True assert duplicate.payload.inline["state"]["subscribers"] == 2 assert len(systemd.started) == 1 systemd.properties = { - "LoadState": "loaded", "ActiveState": "inactive", "Result": "success", - "ExecMainStatus": "0", "InvocationID": "fixture-invocation", + "LoadState": "loaded", + "ActiveState": "inactive", + "Result": "success", + "ExecMainStatus": "0", + "InvocationID": "fixture-invocation", } - service.dispatch(request("job.get", "systemd-jobs", {"job_id": first.payload.inline["job_id"]})) + service.dispatch( + request("job.get", "systemd-jobs", {"job_id": first.payload.inline["job_id"]}) + ) replacement = service.dispatch(request("job.start", "systemd-jobs", arguments)) assert replacement.ok and replacement.payload is not None @@ -971,13 +1364,26 @@ def test_tree_cached_service_coalesces_within_scope_and_retires_terminal_entries descriptor = tmp_path / ".agentctl" / "project.toml" descriptor.write_text( descriptor.read_text() - .replace('cache = "none"\n\n[operations.service.service]', 'cache = "tree+environment"\n\n[operations.service.service]') + .replace( + 'cache = "none"\n\n[operations.service.service]', + 'cache = "tree+environment"\n\n[operations.service.service]', + ) .replace("range = [41000, 41001]", "range = [41000, 41003]") ) initialize_git_checkout(tmp_path) other_checkout = tmp_path.parent / "other-checkout" subprocess.run( - ["git", "-C", str(tmp_path), "worktree", "add", "--quiet", "--detach", str(other_checkout), "HEAD"], + [ + "git", + "-C", + str(tmp_path), + "worktree", + "add", + "--quiet", + "--detach", + str(other_checkout), + "HEAD", + ], check=True, ) @@ -985,7 +1391,9 @@ def test_tree_cached_service_coalesces_within_scope_and_retires_terminal_entries project = catalog.get("fixture") default_checkout = catalog.checkout("fixture", "default") other_checkout_record = next( - checkout for checkout in catalog.checkouts("fixture") if checkout.path == other_checkout.resolve() + checkout + for checkout in catalog.checkouts("fixture") + if checkout.path == other_checkout.resolve() ) jobs = generic_jobs(tmp_path.parent / "job-state") operation = project.operation("service") @@ -1003,7 +1411,10 @@ def start(checkout: RegisteredCheckout | None) -> dict[str, object]: root_first = start(None) root_record = jobs.store.load(root_first["job_id"]) assert root_record.spec.cache_key is not None - assert jobs._admission_state()["active"][root_record.spec.cache_key] == root_first["job_id"] + assert ( + jobs._admission_state()["active"][root_record.spec.cache_key] + == root_first["job_id"] + ) root_duplicate = start(None) assert root_duplicate["job_id"] == root_first["job_id"] assert root_duplicate["coalesced"] @@ -1018,12 +1429,17 @@ def start(checkout: RegisteredCheckout | None) -> dict[str, object]: assert len(list(jobs.store.leases_root.glob("*.json"))) == 2 other_started = start(other_checkout_record) assert default_started["job_id"] != root_first["job_id"] - assert other_started["job_id"] not in {root_first["job_id"], default_started["job_id"]} + assert other_started["job_id"] not in { + root_first["job_id"], + default_started["job_id"], + } default_record = jobs.store.load(default_started["job_id"]) other_record = jobs.store.load(other_started["job_id"]) assert default_record.spec.cache_key != other_record.spec.cache_key assert default_record.spec.lease is not None and other_record.spec.lease is not None - assert default_record.spec.lease.ports[0].port != other_record.spec.lease.ports[0].port + assert ( + default_record.spec.lease.ports[0].port != other_record.spec.lease.ports[0].port + ) assert len(list(jobs.store.leases_root.glob("*.json"))) == 3 jobs.systemd.properties = { @@ -1079,7 +1495,13 @@ def test_tree_cached_service_retires_after_cancellation( jobs = generic_jobs(tmp_path, systemd) service = SinnixdService(ProjectCatalog([tmp_path]), jobs=jobs) - started = service.dispatch(request("job.start", "systemd-jobs", {"project_id": "fixture", "operation": "service"})) + started = service.dispatch( + request( + "job.start", + "systemd-jobs", + {"project_id": "fixture", "operation": "service"}, + ) + ) assert started.ok and started.payload is not None job_id = started.payload.inline["job_id"] record = jobs.store.load(job_id) @@ -1087,7 +1509,9 @@ def test_tree_cached_service_retires_after_cancellation( cache_key = record.spec.cache_key assert jobs._admission_state()["active"][cache_key] == job_id - cancelled = service.dispatch(request("job.cancel", "systemd-jobs", {"job_id": job_id})) + cancelled = service.dispatch( + request("job.cancel", "systemd-jobs", {"job_id": job_id}) + ) assert cancelled.ok and cancelled.payload is not None assert cancelled.payload.inline["state"]["phase"] == "cancelled" admission = jobs._admission_state() @@ -1097,7 +1521,11 @@ def test_tree_cached_service_retires_after_cancellation( assert not (jobs.store.leases_root / f"{job_id}.json").exists() replacement = service.dispatch( - request("job.start", "systemd-jobs", {"project_id": "fixture", "operation": "service"}) + request( + "job.start", + "systemd-jobs", + {"project_id": "fixture", "operation": "service"}, + ) ) assert replacement.ok and replacement.payload is not None assert replacement.payload.inline["job_id"] != job_id @@ -1143,7 +1571,13 @@ def stop(self, unit: str) -> None: jobs = generic_jobs(tmp_path, systemd) service = SinnixdService(ProjectCatalog([tmp_path]), jobs=jobs) - started = service.dispatch(request("job.start", "systemd-jobs", {"project_id": "fixture", "operation": "service"})) + started = service.dispatch( + request( + "job.start", + "systemd-jobs", + {"project_id": "fixture", "operation": "service"}, + ) + ) assert started.ok and started.payload is not None job_id = started.payload.inline["job_id"] record = jobs.store.load(job_id) @@ -1151,11 +1585,19 @@ def stop(self, unit: str) -> None: cache_key = record.spec.cache_key assert jobs._admission_state()["active"][cache_key] == job_id - cancelled = service.dispatch(request("job.cancel", "systemd-jobs", {"job_id": job_id})) + cancelled = service.dispatch( + request("job.cancel", "systemd-jobs", {"job_id": job_id}) + ) assert cancelled.error is not None - jobs.store.save(replace(jobs.store.load(job_id), cancel_requested_at="2000-01-01T00:00:00+00:00")) + jobs.store.save( + replace( + jobs.store.load(job_id), cancel_requested_at="2000-01-01T00:00:00+00:00" + ) + ) - restarted = GenericJobs(systemd, GenericJobStore(jobs.store.root), wait_poll_seconds=0.001) + restarted = GenericJobs( + systemd, GenericJobStore(jobs.store.root), wait_poll_seconds=0.001 + ) reconciled = restarted.get(job_id) assert reconciled["state"]["phase"] == "outcome-unknown" assert reconciled["state"]["terminal"] @@ -1167,7 +1609,11 @@ def stop(self, unit: str) -> None: assert (restarted.store.leases_root / f"{job_id}.released").exists() replacement = SinnixdService(ProjectCatalog([tmp_path]), jobs=restarted).dispatch( - request("job.start", "systemd-jobs", {"project_id": "fixture", "operation": "service"}) + request( + "job.start", + "systemd-jobs", + {"project_id": "fixture", "operation": "service"}, + ) ) assert replacement.ok and replacement.payload is not None assert replacement.payload.inline["job_id"] != job_id @@ -1196,7 +1642,9 @@ def test_tree_cached_services_isolate_distinct_project_roots_in_one_store( first_project = ProjectCatalog([roots[0]]).get("fixture") second_project = ProjectCatalog([roots[1]]).get("fixture") - assert GenericJobs._cache_tree(first_project.root) == GenericJobs._cache_tree(second_project.root) + assert GenericJobs._cache_tree(first_project.root) == GenericJobs._cache_tree( + second_project.root + ) assert first_project.environment.values() == second_project.environment.values() systemd = FakeSystemdJobs() @@ -1204,15 +1652,27 @@ def test_tree_cached_services_isolate_distinct_project_roots_in_one_store( first_service = SinnixdService(ProjectCatalog([roots[0]]), jobs=jobs) second_service = SinnixdService(ProjectCatalog([roots[1]]), jobs=jobs) start_arguments = {"project_id": "fixture", "operation": "service"} - first = first_service.dispatch(request("job.start", "systemd-jobs", start_arguments)) - second = second_service.dispatch(request("job.start", "systemd-jobs", start_arguments)) - assert first.ok and second.ok and first.payload is not None and second.payload is not None + first = first_service.dispatch( + request("job.start", "systemd-jobs", start_arguments) + ) + second = second_service.dispatch( + request("job.start", "systemd-jobs", start_arguments) + ) + assert ( + first.ok + and second.ok + and first.payload is not None + and second.payload is not None + ) first_id = first.payload.inline["job_id"] second_id = second.payload.inline["job_id"] assert first_id != second_id first_record = jobs.store.load(first_id) second_record = jobs.store.load(second_id) - assert first_record.spec.cache_key is not None and second_record.spec.cache_key is not None + assert ( + first_record.spec.cache_key is not None + and second_record.spec.cache_key is not None + ) assert first_record.spec.cache_key != second_record.spec.cache_key assert first_record.spec.lease is not None and second_record.spec.lease is not None assert first_record.spec.lease.ports[0].port == 41000 @@ -1222,8 +1682,12 @@ def test_tree_cached_services_isolate_distinct_project_roots_in_one_store( second_record.spec.cache_key: second_id, } - first_duplicate = first_service.dispatch(request("job.start", "systemd-jobs", start_arguments)) - second_duplicate = second_service.dispatch(request("job.start", "systemd-jobs", start_arguments)) + first_duplicate = first_service.dispatch( + request("job.start", "systemd-jobs", start_arguments) + ) + second_duplicate = second_service.dispatch( + request("job.start", "systemd-jobs", start_arguments) + ) assert first_duplicate.ok and second_duplicate.ok assert first_duplicate.payload is not None and second_duplicate.payload is not None assert first_duplicate.payload.inline["job_id"] == first_id @@ -1233,53 +1697,120 @@ def test_tree_cached_services_isolate_distinct_project_roots_in_one_store( assert len(systemd.started) == 2 -def test_terminal_service_jobs_release_port_leases_for_success_failure_timeout_and_cancellation(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: +def test_terminal_service_jobs_release_port_leases_for_success_failure_timeout_and_cancellation( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: """Anti-vacuity: every terminal systemd outcome must make the port available to the next declared service.""" monkeypatch.setattr("sinnixd.jobs._loopback_port_available", lambda _port: True) for name, properties in ( - ("success", {"LoadState": "loaded", "ActiveState": "inactive", "Result": "success", "ExecMainStatus": "0", "InvocationID": "fixture-invocation"}), - ("failure", {"LoadState": "loaded", "ActiveState": "failed", "Result": "exit-code", "ExecMainStatus": "1", "InvocationID": "fixture-invocation"}), - ("timeout", {"LoadState": "loaded", "ActiveState": "failed", "Result": "timeout", "ExecMainStatus": "1", "InvocationID": "fixture-invocation"}), - ): - case = tmp_path / name - write_adapter(case) - systemd = FakeSystemdJobs() - jobs = generic_jobs(case, systemd) + ( + "success", + { + "LoadState": "loaded", + "ActiveState": "inactive", + "Result": "success", + "ExecMainStatus": "0", + "InvocationID": "fixture-invocation", + }, + ), + ( + "failure", + { + "LoadState": "loaded", + "ActiveState": "failed", + "Result": "exit-code", + "ExecMainStatus": "1", + "InvocationID": "fixture-invocation", + }, + ), + ( + "timeout", + { + "LoadState": "loaded", + "ActiveState": "failed", + "Result": "timeout", + "ExecMainStatus": "1", + "InvocationID": "fixture-invocation", + }, + ), + ): + case = tmp_path / name + write_adapter(case) + systemd = FakeSystemdJobs() + jobs = generic_jobs(case, systemd) service = SinnixdService(ProjectCatalog([case]), jobs=jobs) - started = service.dispatch(request("job.start", "systemd-jobs", {"project_id": "fixture", "operation": "service"})) + started = service.dispatch( + request( + "job.start", + "systemd-jobs", + {"project_id": "fixture", "operation": "service"}, + ) + ) assert started.ok and started.payload is not None job_id = started.payload.inline["job_id"] systemd.properties = properties - terminal = service.dispatch(request("job.get", "systemd-jobs", {"job_id": job_id})) + terminal = service.dispatch( + request("job.get", "systemd-jobs", {"job_id": job_id}) + ) assert terminal.ok and terminal.payload is not None assert terminal.payload.inline["lease"]["state"] == "released" assert not (case / "state" / "leases" / f"{job_id}.json").exists() case = tmp_path / "cancelled" write_adapter(case) - systemd = FakeSystemdJobs(properties={"LoadState": "loaded", "ActiveState": "active", "InvocationID": "fixture-invocation"}) + systemd = FakeSystemdJobs( + properties={ + "LoadState": "loaded", + "ActiveState": "active", + "InvocationID": "fixture-invocation", + } + ) jobs = generic_jobs(case, systemd) service = SinnixdService(ProjectCatalog([case]), jobs=jobs) - started = service.dispatch(request("job.start", "systemd-jobs", {"project_id": "fixture", "operation": "service"})) + started = service.dispatch( + request( + "job.start", + "systemd-jobs", + {"project_id": "fixture", "operation": "service"}, + ) + ) assert started.ok and started.payload is not None - cancelled = service.dispatch(request("job.cancel", "systemd-jobs", {"job_id": started.payload.inline["job_id"]})) + cancelled = service.dispatch( + request( + "job.cancel", "systemd-jobs", {"job_id": started.payload.inline["job_id"]} + ) + ) assert cancelled.ok and cancelled.payload is not None assert cancelled.payload.inline["state"]["phase"] == "cancelled" assert cancelled.payload.inline["lease"]["state"] == "released" - replacement = service.dispatch(request("job.start", "systemd-jobs", {"project_id": "fixture", "operation": "service"})) + replacement = service.dispatch( + request( + "job.start", + "systemd-jobs", + {"project_id": "fixture", "operation": "service"}, + ) + ) assert replacement.ok and replacement.payload is not None assert replacement.payload.inline["lease"]["ports"][0]["port"] == 41000 -def test_service_lease_recovery_reconstructs_live_ownership_and_expires_missing_units(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: +def test_service_lease_recovery_reconstructs_live_ownership_and_expires_missing_units( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: """Anti-vacuity: restart must rebuild a valid active lease and discard one systemd proves stale.""" monkeypatch.setattr("sinnixd.jobs._loopback_port_available", lambda _port: True) write_adapter(tmp_path) systemd = FakeSystemdJobs() jobs = generic_jobs(tmp_path, systemd) service = SinnixdService(ProjectCatalog([tmp_path]), jobs=jobs) - started = service.dispatch(request("job.start", "systemd-jobs", {"project_id": "fixture", "operation": "service"})) + started = service.dispatch( + request( + "job.start", + "systemd-jobs", + {"project_id": "fixture", "operation": "service"}, + ) + ) assert started.ok and started.payload is not None job_id = started.payload.inline["job_id"] lease_path = tmp_path / "state" / "leases" / f"{job_id}.json" @@ -1323,31 +1854,50 @@ def stop(self, unit: str) -> None: systemd = StopTimesOutThenCollects() jobs = generic_jobs(tmp_path, systemd) service = SinnixdService(ProjectCatalog([tmp_path]), jobs=jobs) - started = service.dispatch(request("job.start", "systemd-jobs", {"project_id": "fixture", "operation": "service"})) + started = service.dispatch( + request( + "job.start", + "systemd-jobs", + {"project_id": "fixture", "operation": "service"}, + ) + ) assert started.ok and started.payload is not None job_id = started.payload.inline["job_id"] - cancelled = service.dispatch(request("job.cancel", "systemd-jobs", {"job_id": job_id})) + cancelled = service.dispatch( + request("job.cancel", "systemd-jobs", {"job_id": job_id}) + ) assert cancelled.error is not None record = jobs.store.load(job_id) jobs.store.save(replace(record, cancel_requested_at="2000-01-01T00:00:00+00:00")) - restarted = GenericJobs(systemd, GenericJobStore(jobs.store.root), wait_poll_seconds=0.001) + restarted = GenericJobs( + systemd, GenericJobStore(jobs.store.root), wait_poll_seconds=0.001 + ) reconciled = restarted.get(job_id) assert reconciled["state"]["phase"] == "outcome-unknown" assert reconciled["state"]["terminal"] - assert reconciled["state"]["outcome_evidence"] == "unit-collected-after-cancellation-grace" + assert ( + reconciled["state"]["outcome_evidence"] + == "unit-collected-after-cancellation-grace" + ) assert restarted.store.service_lease_records() == [] assert not (tmp_path / "state" / "leases" / f"{job_id}.json").exists() assert (tmp_path / "state" / "leases" / f"{job_id}.released").exists() replacement_service = SinnixdService(ProjectCatalog([tmp_path]), jobs=restarted) - repeated = replacement_service.dispatch(request("job.cancel", "systemd-jobs", {"job_id": job_id})) + repeated = replacement_service.dispatch( + request("job.cancel", "systemd-jobs", {"job_id": job_id}) + ) assert repeated.ok and repeated.payload is not None assert repeated.payload.inline["already_terminal"] assert systemd.stopped == [f"sinnixd-job-{job_id}.service"] replacement = replacement_service.dispatch( - request("job.start", "systemd-jobs", {"project_id": "fixture", "operation": "service"}) + request( + "job.start", + "systemd-jobs", + {"project_id": "fixture", "operation": "service"}, + ) ) assert replacement.ok and replacement.payload is not None assert replacement.payload.inline["lease"]["ports"][0]["port"] == 41000 @@ -1370,7 +1920,13 @@ def test_loaded_outcome_unknown_restart_keeps_uncertain_lease_reserved( ) jobs = generic_jobs(tmp_path, systemd) service = SinnixdService(ProjectCatalog([tmp_path]), jobs=jobs) - started = service.dispatch(request("job.start", "systemd-jobs", {"project_id": "fixture", "operation": "service"})) + started = service.dispatch( + request( + "job.start", + "systemd-jobs", + {"project_id": "fixture", "operation": "service"}, + ) + ) assert started.ok and started.payload is not None job_id = started.payload.inline["job_id"] record = jobs.store.load(job_id) @@ -1382,7 +1938,10 @@ def test_loaded_outcome_unknown_restart_keeps_uncertain_lease_reserved( "phase": "outcome-unknown", "terminal": True, "systemd": dict(systemd.properties), - "cancellation": {"requested_at": "2000-01-01T00:00:00+00:00", "invocation_id": "fixture-invocation"}, + "cancellation": { + "requested_at": "2000-01-01T00:00:00+00:00", + "invocation_id": "fixture-invocation", + }, "outcome_evidence": "unit-collected-after-cancellation-grace", "observed_at": "fixture", }, @@ -1401,9 +1960,17 @@ def test_loaded_outcome_unknown_restart_keeps_uncertain_lease_reserved( assert replacement.ports[0].port == 41001 -def test_restart_finalizes_admission_for_a_newly_terminal_active_record(tmp_path: Path) -> None: +def test_restart_finalizes_admission_for_a_newly_terminal_active_record( + tmp_path: Path, +) -> None: """A restart must retire only the active cache entry it observes terminal, not rescan historical jobs.""" - systemd = FakeSystemdJobs(properties={"LoadState": "loaded", "ActiveState": "active", "InvocationID": "fixture-invocation"}) + systemd = FakeSystemdJobs( + properties={ + "LoadState": "loaded", + "ActiveState": "active", + "InvocationID": "fixture-invocation", + } + ) store = GenericJobStore(tmp_path / "state") original = GenericJobs(systemd, store, wait_poll_seconds=0.001) record = store.create( @@ -1418,8 +1985,19 @@ def test_restart_finalizes_admission_for_a_newly_terminal_active_record(tmp_path cache_key="a" * 64, ) ) - store.save(original._with_state(record, {"phase": "submitted", "terminal": False, "observed_at": "fixture"})) - original._save_admission_state({"schema_version": 1, "active": {record.spec.cache_key: record.job_id}, "cache": {}, "estimates": {}}) + store.save( + original._with_state( + record, {"phase": "submitted", "terminal": False, "observed_at": "fixture"} + ) + ) + original._save_admission_state( + { + "schema_version": 1, + "active": {record.spec.cache_key: record.job_id}, + "cache": {}, + "estimates": {}, + } + ) systemd.properties = { "LoadState": "loaded", "ActiveState": "inactive", @@ -1444,10 +2022,18 @@ def test_recovery_and_get_skip_historical_terminal_jobs_but_release_failed_loade class CountingSystemd(FakeSystemdJobs): def __init__(self) -> None: - super().__init__(properties={"LoadState": "loaded", "ActiveState": "active", "InvocationID": "active-invocation"}) + super().__init__( + properties={ + "LoadState": "loaded", + "ActiveState": "active", + "InvocationID": "active-invocation", + } + ) self.observed: list[str] = [] - def show(self, unit: str, *, timeout_seconds: float = SYSTEMD_COMMAND_TIMEOUT_SECONDS) -> dict[str, str]: + def show( + self, unit: str, *, timeout_seconds: float = SYSTEMD_COMMAND_TIMEOUT_SECONDS + ) -> dict[str, str]: self.observed.append(unit) return super().show(unit, timeout_seconds=timeout_seconds) @@ -1455,12 +2041,27 @@ def show(self, unit: str, *, timeout_seconds: float = SYSTEMD_COMMAND_TIMEOUT_SE terminal_ids: set[str] = set() for _ in range(128): record = store.create( - GenericJobSpec(kind="foreground-command", command=("fixture",), working_directory=str(tmp_path), environment={}) + GenericJobSpec( + kind="foreground-command", + command=("fixture",), + working_directory=str(tmp_path), + environment={}, + ) ) terminal_ids.add(record.job_id) - store.save(GenericJobs._with_state(record, {"phase": "succeeded", "terminal": True, "observed_at": "fixture"})) + store.save( + GenericJobs._with_state( + record, + {"phase": "succeeded", "terminal": True, "observed_at": "fixture"}, + ) + ) active = store.create( - GenericJobSpec(kind="foreground-command", command=("fixture",), working_directory=str(tmp_path), environment={}) + GenericJobSpec( + kind="foreground-command", + command=("fixture",), + working_directory=str(tmp_path), + environment={}, + ) ) operation = ProjectCatalog([tmp_path]).get("fixture").operation("service") assert operation.service is not None @@ -1497,12 +2098,18 @@ def show(self, unit: str, *, timeout_seconds: float = SYSTEMD_COMMAND_TIMEOUT_SE }, ) ) - locks_before = {path.name for path in store.locks_root.glob("*.lock")} if store.locks_root.exists() else set() + locks_before = ( + {path.name for path in store.locks_root.glob("*.lock")} + if store.locks_root.exists() + else set() + ) systemd = CountingSystemd() monkeypatch.setattr( store, "list", - lambda: (_ for _ in ()).throw(AssertionError("startup must not scan the terminal corpus")), + lambda: (_ for _ in ()).throw( + AssertionError("startup must not scan the terminal corpus") + ), ) recovered = GenericJobs(systemd, store, wait_poll_seconds=0.001) @@ -1518,13 +2125,29 @@ def show(self, unit: str, *, timeout_seconds: float = SYSTEMD_COMMAND_TIMEOUT_SE assert not {f"{job_id}.lock" for job_id in terminal_ids}.intersection(locks_after) -def test_service_lease_invocation_mismatch_keeps_the_reservation_active(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: +def test_service_lease_invocation_mismatch_keeps_the_reservation_active( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: """Anti-vacuity: a later unit invocation cannot inherit an earlier observation's release authority.""" monkeypatch.setattr("sinnixd.jobs._loopback_port_available", lambda _port: True) write_adapter(tmp_path) - systemd = FakeSystemdJobs(properties={"LoadState": "loaded", "ActiveState": "active", "InvocationID": "first-invocation"}) - service = SinnixdService(ProjectCatalog([tmp_path]), jobs=generic_jobs(tmp_path, systemd)) - started = service.dispatch(request("job.start", "systemd-jobs", {"project_id": "fixture", "operation": "service"})) + systemd = FakeSystemdJobs( + properties={ + "LoadState": "loaded", + "ActiveState": "active", + "InvocationID": "first-invocation", + } + ) + service = SinnixdService( + ProjectCatalog([tmp_path]), jobs=generic_jobs(tmp_path, systemd) + ) + started = service.dispatch( + request( + "job.start", + "systemd-jobs", + {"project_id": "fixture", "operation": "service"}, + ) + ) assert started.ok and started.payload is not None job_id = started.payload.inline["job_id"] assert service.dispatch(request("job.get", "systemd-jobs", {"job_id": job_id})).ok @@ -1536,7 +2159,9 @@ def test_service_lease_invocation_mismatch_keeps_the_reservation_active(tmp_path "ExecMainStatus": "1", "InvocationID": "newer-invocation", } - mismatched = service.dispatch(request("job.get", "systemd-jobs", {"job_id": job_id})) + mismatched = service.dispatch( + request("job.get", "systemd-jobs", {"job_id": job_id}) + ) assert mismatched.ok and mismatched.payload is not None assert mismatched.payload.inline["state"]["phase"] == "observation-unknown" @@ -1544,7 +2169,9 @@ def test_service_lease_invocation_mismatch_keeps_the_reservation_active(tmp_path assert (tmp_path / "state" / "leases" / f"{job_id}.json").exists() -def test_concurrent_start_and_get_do_not_take_historical_terminal_locks(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: +def test_concurrent_start_and_get_do_not_take_historical_terminal_locks( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: """Live failure harness: concurrent client routes finish without waiting on terminal-record lock files.""" monkeypatch.setattr("sinnixd.jobs._loopback_port_available", lambda _port: True) write_adapter(tmp_path) @@ -1552,14 +2179,34 @@ def test_concurrent_start_and_get_do_not_take_historical_terminal_locks(tmp_path terminal_ids: set[str] = set() for _ in range(64): record = store.create( - GenericJobSpec(kind="foreground-command", command=("fixture",), working_directory=str(tmp_path), environment={}) + GenericJobSpec( + kind="foreground-command", + command=("fixture",), + working_directory=str(tmp_path), + environment={}, + ) ) terminal_ids.add(record.job_id) - store.save(GenericJobs._with_state(record, {"phase": "failed", "terminal": True, "observed_at": "fixture"})) + store.save( + GenericJobs._with_state( + record, {"phase": "failed", "terminal": True, "observed_at": "fixture"} + ) + ) active = store.create( - GenericJobSpec(kind="foreground-command", command=("fixture",), working_directory=str(tmp_path), environment={}) + GenericJobSpec( + kind="foreground-command", + command=("fixture",), + working_directory=str(tmp_path), + environment={}, + ) + ) + systemd = FakeSystemdJobs( + properties={ + "LoadState": "loaded", + "ActiveState": "active", + "InvocationID": "fixture-invocation", + } ) - systemd = FakeSystemdJobs(properties={"LoadState": "loaded", "ActiveState": "active", "InvocationID": "fixture-invocation"}) jobs = GenericJobs(systemd, store, wait_poll_seconds=0.001) project = ProjectCatalog([tmp_path]).get("fixture") original_locked = store.locked @@ -1576,14 +2223,23 @@ def counted_locked(job_id: str): monkeypatch.setattr( store, "list", - lambda: (_ for _ in ()).throw(AssertionError("start/get must not scan the terminal corpus")), + lambda: (_ for _ in ()).throw( + AssertionError("start/get must not scan the terminal corpus") + ), ) started: list[dict[str, object]] = [] failures: list[BaseException] = [] def start_service() -> None: try: - started.append(jobs.start_declared(project=project, operation=project.operation("service"), correlation_id="concurrent", parameters={})) + started.append( + jobs.start_declared( + project=project, + operation=project.operation("service"), + correlation_id="concurrent", + parameters={}, + ) + ) except BaseException as error: failures.append(error) @@ -1601,13 +2257,19 @@ def start_service() -> None: monkeypatch.setattr(store, "list", original_list) -def test_service_lease_creation_is_atomic_against_concurrent_recovery(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: +def test_service_lease_creation_is_atomic_against_concurrent_recovery( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: """A second daemon cannot collect an in-flight reservation and reallocate its port.""" monkeypatch.setattr("sinnixd.jobs._loopback_port_available", lambda _port: True) write_adapter(tmp_path) store = GenericJobStore(tmp_path / "state") first = GenericJobs(FakeSystemdJobs(), store, wait_poll_seconds=0.001) - entered, release, recovered = threading.Event(), threading.Event(), threading.Event() + entered, release, recovered = ( + threading.Event(), + threading.Event(), + threading.Event(), + ) original_create = store.create adapter = ProjectCatalog([tmp_path]).get("fixture") @@ -1620,7 +2282,12 @@ def blocked_create(spec: GenericJobSpec, job_id: str | None = None): first_result: list[dict[str, object]] = [] first_thread = threading.Thread( target=lambda: first_result.append( - first.start_declared(project=adapter, operation=adapter.operation("service"), correlation_id="first", parameters={}) + first.start_declared( + project=adapter, + operation=adapter.operation("service"), + correlation_id="first", + parameters={}, + ) ) ) first_thread.start() @@ -1641,8 +2308,15 @@ def recover() -> None: first_lease = first_result[0]["lease"] assert isinstance(first_lease, dict) assert first_lease["ports"][0]["port"] == 41000 - second = GenericJobs(FakeSystemdJobs(), GenericJobStore(store.root), wait_poll_seconds=0.001) - replacement = second.start_declared(project=adapter, operation=adapter.operation("service"), correlation_id="second", parameters={}) + second = GenericJobs( + FakeSystemdJobs(), GenericJobStore(store.root), wait_poll_seconds=0.001 + ) + replacement = second.start_declared( + project=adapter, + operation=adapter.operation("service"), + correlation_id="second", + parameters={}, + ) assert replacement["lease"]["ports"][0]["port"] == 41001 @@ -1666,7 +2340,9 @@ def test_orphaned_valid_service_lease_recovery_requires_authoritative_absence( write_adapter(tmp_path) class UnavailableSystemd(FakeSystemdJobs): - def show(self, unit: str, *, timeout_seconds: float = SYSTEMD_COMMAND_TIMEOUT_SECONDS) -> dict[str, str]: + def show( + self, unit: str, *, timeout_seconds: float = SYSTEMD_COMMAND_TIMEOUT_SECONDS + ) -> dict[str, str]: raise SystemdJobError("systemd unavailable") store = GenericJobStore(tmp_path / "state") @@ -1677,7 +2353,11 @@ def show(self, unit: str, *, timeout_seconds: float = SYSTEMD_COMMAND_TIMEOUT_SE if record_state == "malformed": record_path.parent.mkdir(parents=True) record_path.write_text("{") - systemd = UnavailableSystemd() if systemd_state is None else FakeSystemdJobs(properties=systemd_state) + systemd = ( + UnavailableSystemd() + if systemd_state is None + else FakeSystemdJobs(properties=systemd_state) + ) _ = GenericJobs(systemd, store, wait_poll_seconds=0.001) lease_path = store.leases_root / f"{lease.lease_id}.json" @@ -1688,7 +2368,9 @@ def show(self, unit: str, *, timeout_seconds: float = SYSTEMD_COMMAND_TIMEOUT_SE assert replacement.ports[0].port == (41001 if preserved else 41000) -def test_failed_service_launch_releases_its_lease(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: +def test_failed_service_launch_releases_its_lease( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: """Anti-vacuity: a rejected transient service must not strand its reserved loopback port.""" monkeypatch.setattr("sinnixd.jobs._loopback_port_available", lambda _port: True) @@ -1697,9 +2379,19 @@ def start(self, **_kwargs) -> None: raise SystemdJobError("fixture launch failure") write_adapter(tmp_path) - systemd = FailedStart(properties={"LoadState": "not-found", "ActiveState": "inactive"}) - service = SinnixdService(ProjectCatalog([tmp_path]), jobs=generic_jobs(tmp_path, systemd)) - failed = service.dispatch(request("job.start", "systemd-jobs", {"project_id": "fixture", "operation": "service"})) + systemd = FailedStart( + properties={"LoadState": "not-found", "ActiveState": "inactive"} + ) + service = SinnixdService( + ProjectCatalog([tmp_path]), jobs=generic_jobs(tmp_path, systemd) + ) + failed = service.dispatch( + request( + "job.start", + "systemd-jobs", + {"project_id": "fixture", "operation": "service"}, + ) + ) assert failed.ok and failed.payload is not None assert failed.payload.inline["state"]["phase"] == "launch-failed" @@ -1707,15 +2399,29 @@ def start(self, **_kwargs) -> None: assert not list((tmp_path / "state" / "leases").glob("*.json")) -def test_service_lease_claimed_between_allocation_and_launch_releases_it(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: +def test_service_lease_claimed_between_allocation_and_launch_releases_it( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: """Anti-vacuity: a port claimed after allocation cannot produce an active lease.""" availability = iter((True, False)) - monkeypatch.setattr("sinnixd.jobs._loopback_port_available", lambda _port: next(availability)) + monkeypatch.setattr( + "sinnixd.jobs._loopback_port_available", lambda _port: next(availability) + ) write_adapter(tmp_path) - systemd = FakeSystemdJobs(properties={"LoadState": "not-found", "ActiveState": "inactive"}) - service = SinnixdService(ProjectCatalog([tmp_path]), jobs=generic_jobs(tmp_path, systemd)) + systemd = FakeSystemdJobs( + properties={"LoadState": "not-found", "ActiveState": "inactive"} + ) + service = SinnixdService( + ProjectCatalog([tmp_path]), jobs=generic_jobs(tmp_path, systemd) + ) - failed = service.dispatch(request("job.start", "systemd-jobs", {"project_id": "fixture", "operation": "service"})) + failed = service.dispatch( + request( + "job.start", + "systemd-jobs", + {"project_id": "fixture", "operation": "service"}, + ) + ) assert failed.ok and failed.payload is not None assert failed.payload.inline["state"]["phase"] == "launch-failed" @@ -1736,7 +2442,9 @@ def test_queued_service_cancellation_wins_the_admission_start_interleaving( 'cache = "none"\nestimate_memory_bytes = 4294967296\n\n[operations.service.service]', ) ) - systemd = FakeSystemdJobs(properties={"LoadState": "not-found", "ActiveState": "inactive"}) + systemd = FakeSystemdJobs( + properties={"LoadState": "not-found", "ActiveState": "inactive"} + ) store = GenericJobStore(tmp_path / "state") jobs = GenericJobs( systemd, @@ -1745,12 +2453,22 @@ def test_queued_service_cancellation_wins_the_admission_start_interleaving( pressure_probe=lambda: {"memory_full_avg10": 0.2}, ) service = SinnixdService(ProjectCatalog([tmp_path]), jobs=jobs) - started = service.dispatch(request("job.start", "systemd-jobs", {"project_id": "fixture", "operation": "service"})) + started = service.dispatch( + request( + "job.start", + "systemd-jobs", + {"project_id": "fixture", "operation": "service"}, + ) + ) assert started.ok and started.payload is not None job_id = started.payload.inline["job_id"] assert started.payload.inline["state"]["phase"] == "queued" - before_start, resume_admission, cancellation_saved = threading.Event(), threading.Event(), threading.Event() + before_start, resume_admission, cancellation_saved = ( + threading.Event(), + threading.Event(), + threading.Event(), + ) original_save = store.save def save_and_signal(record): @@ -1766,6 +2484,7 @@ def pause_before_start(candidate: str) -> None: monkeypatch.setattr(store, "save", save_and_signal) jobs.before_admission_start = pause_before_start jobs.pressure_probe = lambda: {"memory_full_avg10": 0.0} + def admit() -> None: with jobs._admission_lock: jobs._admit_locked() @@ -1800,7 +2519,9 @@ def test_queued_declared_cancellation_survives_service_refresh_and_restart( 'cache = "none"\nestimate_memory_bytes = 4294967296\n\n[operations.service.service]', ) ) - systemd = FakeSystemdJobs(properties={"LoadState": "not-found", "ActiveState": "inactive"}) + systemd = FakeSystemdJobs( + properties={"LoadState": "not-found", "ActiveState": "inactive"} + ) store = GenericJobStore(tmp_path / "state") jobs = GenericJobs( systemd, @@ -1810,29 +2531,44 @@ def test_queued_declared_cancellation_survives_service_refresh_and_restart( ) service = SinnixdService(ProjectCatalog([tmp_path]), jobs=jobs) started = service.dispatch( - request("job.start", "systemd-jobs", {"project_id": "fixture", "operation": "service"}) + request( + "job.start", + "systemd-jobs", + {"project_id": "fixture", "operation": "service"}, + ) ) assert started.ok and started.payload is not None job_id = started.payload.inline["job_id"] assert started.payload.inline["state"]["phase"] == "queued" - cancelled = service.dispatch(request("job.cancel", "systemd-jobs", {"job_id": job_id})) + cancelled = service.dispatch( + request("job.cancel", "systemd-jobs", {"job_id": job_id}) + ) assert cancelled.ok and cancelled.payload is not None assert cancelled.payload.inline["state"]["phase"] == "cancelled" assert cancelled.payload.inline["state"]["launch_evidence"] == "not-started" def assert_cancelled_truth(current: SinnixdService) -> None: for _ in range(2): - refreshed = current.dispatch(request("job.get", "systemd-jobs", {"job_id": job_id})) + refreshed = current.dispatch( + request("job.get", "systemd-jobs", {"job_id": job_id}) + ) assert refreshed.ok and refreshed.payload is not None - assert refreshed.payload.inline["state"] == cancelled.payload.inline["state"] + assert ( + refreshed.payload.inline["state"] == cancelled.payload.inline["state"] + ) listed = current.dispatch(request("job.list", "systemd-jobs", {})) assert listed.ok and listed.payload is not None assert listed.payload.inline["jobs"][0]["job_id"] == job_id - assert listed.payload.inline["jobs"][0]["state"] == cancelled.payload.inline["state"] + assert ( + listed.payload.inline["jobs"][0]["state"] + == cancelled.payload.inline["state"] + ) - result = current.dispatch(request("job.result", "systemd-jobs", {"job_id": job_id})) + result = current.dispatch( + request("job.result", "systemd-jobs", {"job_id": job_id}) + ) assert not result.ok assert result.error is not None assert result.error.code.value == "RESULT_INVALID" @@ -1844,7 +2580,9 @@ def assert_cancelled_truth(current: SinnixdService) -> None: restarted = SinnixdService( ProjectCatalog([tmp_path]), jobs=GenericJobs( - FakeSystemdJobs(properties={"LoadState": "not-found", "ActiveState": "inactive"}), + FakeSystemdJobs( + properties={"LoadState": "not-found", "ActiveState": "inactive"} + ), GenericJobStore(store.root), wait_poll_seconds=0.001, pressure_probe=lambda: {"memory_full_avg10": 0.0}, @@ -1858,11 +2596,17 @@ def test_declared_missing_unit_without_cancellation_evidence_stays_missing( ) -> None: """Anti-vacuity: an absent post-launch declared unit is not inferred cancelled.""" write_adapter(tmp_path) - systemd = FakeSystemdJobs(properties={"LoadState": "not-found", "ActiveState": "inactive"}) - service = SinnixdService(ProjectCatalog([tmp_path]), jobs=generic_jobs(tmp_path, systemd)) + systemd = FakeSystemdJobs( + properties={"LoadState": "not-found", "ActiveState": "inactive"} + ) + service = SinnixdService( + ProjectCatalog([tmp_path]), jobs=generic_jobs(tmp_path, systemd) + ) started = service.dispatch( - request("job.start", "systemd-jobs", {"project_id": "fixture", "operation": "check"}) + request( + "job.start", "systemd-jobs", {"project_id": "fixture", "operation": "check"} + ) ) assert started.ok and started.payload is not None job_id = started.payload.inline["job_id"] @@ -1884,7 +2628,9 @@ def test_declared_missing_unit_without_cancellation_evidence_stays_missing( assert result.error.message == "job exit result is unavailable" -def test_dependency_admission_never_nests_candidate_and_dependency_job_locks(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: +def test_dependency_admission_never_nests_candidate_and_dependency_job_locks( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: """Malformed durable dependency cycles cannot create a cross-process job-lock cycle during admission.""" store = GenericJobStore(tmp_path / "state") jobs = GenericJobs(FakeSystemdJobs(), store, wait_poll_seconds=0.001) @@ -1905,8 +2651,14 @@ def queued_record(job_id: str, dependency_id: str): ), job_id, ) - store.write_declared_launch(job_id, record.spec.command, record.spec.environment) - store.save(jobs._with_state(record, {"phase": "queued", "terminal": False, "observed_at": "fixture"})) + store.write_declared_launch( + job_id, record.spec.command, record.spec.environment + ) + store.save( + jobs._with_state( + record, {"phase": "queued", "terminal": False, "observed_at": "fixture"} + ) + ) queued_record(first_id, second_id) queued_record(second_id, first_id) @@ -1942,7 +2694,9 @@ def test_service_input_write_failure_terminalizes_the_record_and_removes_partial project = ProjectCatalog([tmp_path]).get("fixture") original_write = store.write_declared_launch - def partial_then_fail(job_id: str, command: tuple[str, ...], environment: dict[str, str]) -> None: + def partial_then_fail( + job_id: str, command: tuple[str, ...], environment: dict[str, str] + ) -> None: _ = command, environment store.inputs_root.mkdir(parents=True, exist_ok=True) (store.inputs_root / f"{job_id}.launch").write_text("{") @@ -1950,7 +2704,12 @@ def partial_then_fail(job_id: str, command: tuple[str, ...], environment: dict[s monkeypatch.setattr(store, "write_declared_launch", partial_then_fail) with pytest.raises(OSError, match="fixture input persistence failure"): - jobs.start_declared(project=project, operation=project.operation("service"), correlation_id="write-failure", parameters={}) + jobs.start_declared( + project=project, + operation=project.operation("service"), + correlation_id="write-failure", + parameters={}, + ) monkeypatch.setattr(store, "write_declared_launch", original_write) [record] = store.list() @@ -1992,7 +2751,9 @@ def test_restart_terminalizes_truncated_unpublished_service_input_after_unit_abs (store.inputs_root).mkdir(parents=True, exist_ok=True) (store.inputs_root / f"{job_id}.launch").write_text("{") _ = GenericJobs( - FakeSystemdJobs(properties={"LoadState": "not-found", "ActiveState": "inactive"}), + FakeSystemdJobs( + properties={"LoadState": "not-found", "ActiveState": "inactive"} + ), store, wait_poll_seconds=0.001, ) @@ -2022,7 +2783,9 @@ def test_record_owns_ports_when_its_lease_artifact_is_missing_or_truncated( ) ) systemd = ( - FakeSystemdJobs(properties={"LoadState": "not-found", "ActiveState": "inactive"}) + FakeSystemdJobs( + properties={"LoadState": "not-found", "ActiveState": "inactive"} + ) if phase == "queued" else FakeSystemdJobs() ) @@ -2033,13 +2796,21 @@ def test_record_owns_ports_when_its_lease_artifact_is_missing_or_truncated( pressure_probe=lambda: {"memory_full_avg10": 0.2 if phase == "queued" else 0.0}, ) project = ProjectCatalog([tmp_path]).get("fixture") - started = jobs.start_declared(project=project, operation=project.operation("service"), correlation_id=f"{phase}-{artifact}", parameters={}) + started = jobs.start_declared( + project=project, + operation=project.operation("service"), + correlation_id=f"{phase}-{artifact}", + parameters={}, + ) assert started["state"]["phase"] == ("queued" if phase == "queued" else "submitted") job_id = started["job_id"] if phase == "terminal": record = jobs.store.load(job_id) jobs.store.save( - jobs._with_state(record, {"phase": "launch-failed", "terminal": True, "observed_at": "fixture"}) + jobs._with_state( + record, + {"phase": "launch-failed", "terminal": True, "observed_at": "fixture"}, + ) ) lease_path = jobs.store.leases_root / f"{job_id}.json" if artifact == "missing": @@ -2049,19 +2820,27 @@ def test_record_owns_ports_when_its_lease_artifact_is_missing_or_truncated( operation = project.operation("service") assert operation.service is not None - second = GenericJobStore(jobs.store.root).allocate_service_lease(str(uuid4()), operation.service) + second = GenericJobStore(jobs.store.root).allocate_service_lease( + str(uuid4()), operation.service + ) assert second.ports[0].port == 41001 if phase != "terminal": jobs.cancel(job_id) systemd.properties = {"LoadState": "not-found", "ActiveState": "inactive"} jobs.get(job_id) - reclaimed = GenericJobStore(jobs.store.root).allocate_service_lease(str(uuid4()), operation.service) + reclaimed = GenericJobStore(jobs.store.root).allocate_service_lease( + str(uuid4()), operation.service + ) assert reclaimed.ports[0].port == 41000 -def test_typed_runner_keeps_the_one_hour_timeout_identity_bound(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("SINNIXD_TIMEOUT_SECONDS", str(MAX_DECLARED_OPERATION_TIMEOUT_SECONDS)) +def test_typed_runner_keeps_the_one_hour_timeout_identity_bound( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv( + "SINNIXD_TIMEOUT_SECONDS", str(MAX_DECLARED_OPERATION_TIMEOUT_SECONDS) + ) with pytest.raises(RunnerError, match="typed-job timeout identity is invalid"): _require_environment( @@ -2075,13 +2854,19 @@ def test_typed_runner_keeps_the_one_hour_timeout_identity_bound(monkeypatch: pyt ) -def test_project_operation_result_must_have_an_executable_declared_contract(tmp_path: Path) -> None: +def test_project_operation_result_must_have_an_executable_declared_contract( + tmp_path: Path, +) -> None: """Anti-vacuity: descriptor result metadata cannot be accepted and ignored.""" write_adapter(tmp_path) descriptor = tmp_path / ".agentctl" / "project.toml" - descriptor.write_text(descriptor.read_text().replace('result = "json"', 'result = "agent"')) + descriptor.write_text( + descriptor.read_text().replace('result = "json"', 'result = "agent"') + ) - with pytest.raises(ProjectConfigError, match="operations.parameterized.result is invalid"): + with pytest.raises( + ProjectConfigError, match="operations.parameterized.result is invalid" + ): ProjectCatalog([tmp_path]) @@ -2163,22 +2948,42 @@ def stop(self, unit: str) -> None: def generic_jobs(tmp_path: Path, systemd: FakeSystemdJobs | None = None) -> GenericJobs: - return GenericJobs(systemd or FakeSystemdJobs(), GenericJobStore(tmp_path / "state"), wait_poll_seconds=0.001) - - + return GenericJobs( + systemd or FakeSystemdJobs(), + GenericJobStore(tmp_path / "state"), + wait_poll_seconds=0.001, + ) + + def initialize_git_checkout(root: Path) -> None: for arguments in ( ("git", "init", "--quiet", str(root)), ("git", "-C", str(root), "add", "."), - ("git", "-C", str(root), "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "--quiet", "--allow-empty", "-m", "fixture"), + ( + "git", + "-C", + str(root), + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.test", + "commit", + "--quiet", + "--allow-empty", + "-m", + "fixture", + ), ): subprocess.run(arguments, check=True) subprocess.run( - ["git", "-C", str(root), "update-ref", "refs/remotes/origin/master", "HEAD"], check=True + ["git", "-C", str(root), "update-ref", "refs/remotes/origin/master", "HEAD"], + check=True, ) -def replace_worktree_gitfile_with_symlink(worktree: Path, target: Path | None = None) -> Path: +def replace_worktree_gitfile_with_symlink( + worktree: Path, target: Path | None = None +) -> Path: """Model the managed-worktree layout that Git refuses to remove directly.""" gitfile = worktree / ".git" if target is None: @@ -2192,7 +2997,9 @@ def replace_worktree_gitfile_with_symlink(worktree: Path, target: Path | None = def test_worktree_porcelain_parser_accepts_flags_and_rejects_unknown_shapes() -> None: parsed = parse_worktree_records( - "worktree /repo\nHEAD " + "a" * 40 + "\ndetached\nlocked operator reason\nprunable stale\n\n" + "worktree /repo\nHEAD " + + "a" * 40 + + "\ndetached\nlocked operator reason\nprunable stale\n\n" ) assert parsed == ( @@ -2213,12 +3020,12 @@ def native_runner(path: Path) -> None: "#!/bin/sh\n" "set -eu\n" "last= prompt=\n" - "if [ -n \"${RUNNER_ARGS:-}\" ]; then printf '%s\\n' \"$@\" > \"$RUNNER_ARGS\"; fi\n" + 'if [ -n "${RUNNER_ARGS:-}" ]; then printf \'%s\\n\' "$@" > "$RUNNER_ARGS"; fi\n' "while [ $# -gt 0 ]; do\n" " case $1 in --last-file) last=$2; shift 2 ;; --prompt-file) prompt=$2; shift 2 ;; *) shift ;; esac\n" "done\n" - "test -f \"$prompt\"\n" - "printf native-fixture-result > \"$last\"\n" + 'test -f "$prompt"\n' + 'printf native-fixture-result > "$last"\n' "printf native-fixture-log\n" ) path.chmod(0o700) @@ -2255,7 +3062,15 @@ class FakeTaskBoundary: max_active: int = 0 _guard: threading.Lock = field(default_factory=threading.Lock) - def run(self, *, argv: tuple[str, ...], cwd: Path, environment: dict[str, str], lock_path: Path | None = None, max_stdout_bytes: int | None = None) -> ExecutionResult: + def run( + self, + *, + argv: tuple[str, ...], + cwd: Path, + environment: dict[str, str], + lock_path: Path | None = None, + max_stdout_bytes: int | None = None, + ) -> ExecutionResult: self.calls.append((argv, cwd)) self.lock_paths.append(lock_path) with self._guard: @@ -2276,7 +3091,15 @@ class CanonicalTaskBoundary: calls: list[tuple[tuple[str, ...], Path]] = field(default_factory=list) databases: list[Path] = field(default_factory=list) - def run(self, *, argv: tuple[str, ...], cwd: Path, environment: dict[str, str], lock_path: Path | None = None, max_stdout_bytes: int | None = None) -> ExecutionResult: + def run( + self, + *, + argv: tuple[str, ...], + cwd: Path, + environment: dict[str, str], + lock_path: Path | None = None, + max_stdout_bytes: int | None = None, + ) -> ExecutionResult: self.calls.append((argv, cwd)) database = Path(environment["BEADS_DIR"]) / "dolt" self.databases.append(database) @@ -2336,7 +3159,9 @@ def activate_task_authority( return database -def task_service(tmp_path: Path, boundary: FakeTaskBoundary | None = None) -> tuple[TaskService, FakeTaskBoundary]: +def task_service( + tmp_path: Path, boundary: FakeTaskBoundary | None = None +) -> tuple[TaskService, FakeTaskBoundary]: write_adapter(tmp_path) fake = boundary or FakeTaskBoundary([task_result({"ok": True})]) task_state_root = tmp_path / "task-state" @@ -2357,10 +3182,14 @@ def isolate_job_scratch(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None monkeypatch.setenv("SINNIXD_NVME_SCRATCH_ROOT", str(tmp_path / "nvme-scratch")) -def test_task_reads_resolve_catalog_projects_and_use_readonly_fixed_argv(tmp_path: Path) -> None: +def test_task_reads_resolve_catalog_projects_and_use_readonly_fixed_argv( + tmp_path: Path, +) -> None: service, boundary = task_service( tmp_path, - FakeTaskBoundary([task_result([{"id": "fixture-1"}]), task_result([{"id": "fixture-1"}])]), + FakeTaskBoundary( + [task_result([{"id": "fixture-1"}]), task_result([{"id": "fixture-1"}])] + ), ) listed = service.execute( @@ -2380,18 +3209,37 @@ def test_task_reads_resolve_catalog_projects_and_use_readonly_fixed_argv(tmp_pat assert listed["result"]["coverage"]["total_exact"] is True assert fetched["result"] == {"id": "fixture-1"} authority_root = tmp_path / "task-state" / "fixture" - database = authority_root / ".beads" / "dolt" + authority_root / ".beads" / "dolt" prefix = ("--json", "--readonly") assert boundary.calls == [ - ((*prefix, "list", "--flat", "--status", "open", "--limit", "0", "--max-rows", "100000"), tmp_path), + ( + ( + *prefix, + "list", + "--flat", + "--status", + "open", + "--limit", + "0", + "--max-rows", + "100000", + ), + tmp_path, + ), ((*prefix, "show", "fixture-1"), tmp_path), ] -def test_task_list_traverses_real_pages_from_one_immutable_snapshot(tmp_path: Path) -> None: +def test_task_list_traverses_real_pages_from_one_immutable_snapshot( + tmp_path: Path, +) -> None: rows = [{"id": f"fixture-{index}", "status": "open"} for index in range(1, 6)] service, boundary = task_service(tmp_path, FakeTaskBoundary([task_result(rows)])) - arguments: dict[str, object] = {"project_id": "fixture", "status": "open", "limit": 2} + arguments: dict[str, object] = { + "project_id": "fixture", + "status": "open", + "limit": 2, + } seen: list[str] = [] source_revision: str | None = None cursor: str | None = None @@ -2400,7 +3248,9 @@ def test_task_list_traverses_real_pages_from_one_immutable_snapshot(tmp_path: Pa page_arguments = dict(arguments) if cursor is not None: page_arguments["cursor"] = cursor - page = service.execute(operation="task.list", arguments=page_arguments, principal="observer") + page = service.execute( + operation="task.list", arguments=page_arguments, principal="observer" + ) result = page["result"] assert result["coverage"] == { "state": "complete", @@ -2423,10 +3273,17 @@ def test_task_list_traverses_real_pages_from_one_immutable_snapshot(tmp_path: Pa assert boundary.calls[0][0][-4:] == ("--limit", "0", "--max-rows", "100000") -def test_task_list_cursor_rejects_negative_cases_before_owner_dispatch(tmp_path: Path) -> None: - service, boundary = task_service(tmp_path, FakeTaskBoundary([task_result([{"id": "fixture-1"}, {"id": "fixture-2"}])])) +def test_task_list_cursor_rejects_negative_cases_before_owner_dispatch( + tmp_path: Path, +) -> None: + service, boundary = task_service( + tmp_path, + FakeTaskBoundary([task_result([{"id": "fixture-1"}, {"id": "fixture-2"}])]), + ) first = service.execute( - operation="task.list", arguments={"project_id": "fixture", "limit": 1}, principal="observer" + operation="task.list", + arguments={"project_id": "fixture", "limit": 1}, + principal="observer", )["result"] cursor = first["next_cursor"] assert isinstance(cursor, str) @@ -2467,33 +3324,63 @@ def test_task_list_cursor_rejects_negative_cases_before_owner_dispatch(tmp_path: with pytest.raises(TaskError, match="query") as mismatched: service.execute( operation="task.list", - arguments={"project_id": "fixture", "status": "closed", "limit": 1, "cursor": cursor}, + arguments={ + "project_id": "fixture", + "status": "closed", + "limit": 1, + "cursor": cursor, + }, principal="observer", ) assert mismatched.value.code.value == "INVALID_ARGUMENT" assert len(boundary.calls) == owner_calls - snapshot = next((tmp_path / "task-state" / "fixture" / "sinnixd-task-list-snapshots").glob("*.json")) + snapshot = next( + (tmp_path / "task-state" / "fixture" / "sinnixd-task-list-snapshots").glob( + "*.json" + ) + ) snapshot.unlink() with pytest.raises(TaskError) as missing: service.execute( - operation="task.list", arguments={"project_id": "fixture", "limit": 1, "cursor": cursor}, principal="observer" + operation="task.list", + arguments={"project_id": "fixture", "limit": 1, "cursor": cursor}, + principal="observer", ) assert missing.value.code.value == "STALE_CURSOR" assert len(boundary.calls) == owner_calls -def test_task_list_service_returns_structured_stale_cursor_error(tmp_path: Path) -> None: - service, _ = task_service(tmp_path, FakeTaskBoundary([task_result([{"id": "fixture-1"}, {"id": "fixture-2"}])])) +def test_task_list_service_returns_structured_stale_cursor_error( + tmp_path: Path, +) -> None: + service, _ = task_service( + tmp_path, + FakeTaskBoundary([task_result([{"id": "fixture-1"}, {"id": "fixture-2"}])]), + ) daemon = SinnixdService(ProjectCatalog([tmp_path]), tasks=service) first = daemon.dispatch( - request("task.list", "task-backend", {"project_id": "fixture", "limit": 1}, "observer") + request( + "task.list", + "task-backend", + {"project_id": "fixture", "limit": 1}, + "observer", + ) ) assert first.ok and first.payload is not None cursor = first.payload.inline["result"]["next_cursor"] - next((tmp_path / "task-state" / "fixture" / "sinnixd-task-list-snapshots").glob("*.json")).unlink() + next( + (tmp_path / "task-state" / "fixture" / "sinnixd-task-list-snapshots").glob( + "*.json" + ) + ).unlink() response = daemon.dispatch( - request("task.list", "task-backend", {"project_id": "fixture", "limit": 1, "cursor": cursor}, "observer") + request( + "task.list", + "task-backend", + {"project_id": "fixture", "limit": 1, "cursor": cursor}, + "observer", + ) ) assert response.error is not None assert response.error.code is ErrorCode.STALE_CURSOR @@ -2503,17 +3390,39 @@ def test_task_list_service_returns_structured_stale_cursor_error(tmp_path: Path) ("operation", "arguments", "expected"), ( ("task.claim", {"task_id": "fixture-1"}, ("update", "fixture-1", "--claim")), - ("task.note", {"task_id": "fixture-1", "text": "append this"}, ("note", "fixture-1", "append this")), - ("task.update", {"task_id": "fixture-1", "metadata": {"write_scope": '["pkgs/sinnixd/"]'}}, ("update", "fixture-1", "--set-metadata", 'write_scope=["pkgs/sinnixd/"]')), - ("task.relate", {"task_id": "fixture-1", "related_task_id": "fixture-2"}, ("dep", "relate", "fixture-1", "fixture-2")), - ("task.complete", {"task_id": "fixture-1", "merge_sha": "a" * 40, "reason": "verified"}, ("close", "fixture-1", "--reason", "verified")), - ("task.release", {"task_id": "fixture-1", "reason": "stopped", "if_assignee": "worker"}, ("unclaim", "fixture-1", "--reason", "stopped", "--if-assignee", "worker")), + ( + "task.note", + {"task_id": "fixture-1", "text": "append this"}, + ("note", "fixture-1", "append this"), + ), + ( + "task.update", + {"task_id": "fixture-1", "metadata": {"write_scope": '["pkgs/sinnixd/"]'}}, + ("update", "fixture-1", "--set-metadata", 'write_scope=["pkgs/sinnixd/"]'), + ), + ( + "task.relate", + {"task_id": "fixture-1", "related_task_id": "fixture-2"}, + ("dep", "relate", "fixture-1", "fixture-2"), + ), + ( + "task.complete", + {"task_id": "fixture-1", "merge_sha": "a" * 40, "reason": "verified"}, + ("close", "fixture-1", "--reason", "verified"), + ), + ( + "task.release", + {"task_id": "fixture-1", "reason": "stopped", "if_assignee": "worker"}, + ("unclaim", "fixture-1", "--reason", "stopped", "--if-assignee", "worker"), + ), ), ) def test_task_mutations_map_to_fixed_beads_argv( tmp_path: Path, operation: str, arguments: dict[str, str], expected: tuple[str, ...] ) -> None: - service, boundary = task_service(tmp_path, FakeTaskBoundary([task_result({"ok": True})])) + service, boundary = task_service( + tmp_path, FakeTaskBoundary([task_result({"ok": True})]) + ) result = service.execute( operation=operation, @@ -2525,18 +3434,22 @@ def test_task_mutations_map_to_fixed_beads_argv( assert result["project_id"] == "fixture" assert result["operation"] == operation assert result["result"]["state"] == "applied" - assert result["result"]["result"]["bytes"] == len(json.dumps({"ok": True}, sort_keys=True, separators=(",", ":")).encode()) + assert result["result"]["result"]["bytes"] == len( + json.dumps({"ok": True}, sort_keys=True, separators=(",", ":")).encode() + ) authority_root = tmp_path / "task-state" / "fixture" - database = authority_root / ".beads" / "dolt" - assert boundary.calls == [ - (("--json", *expected), tmp_path) - ] + authority_root / ".beads" / "dolt" + assert boundary.calls == [(("--json", *expected), tmp_path)] -def test_task_create_returns_a_replay_safe_canonical_ref_and_owner_evidence(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: +def test_task_create_returns_a_replay_safe_canonical_ref_and_owner_evidence( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: """Anti-vacuity: the replay reads the durable receipt, so a second backend create would fail this test.""" isolate_job_scratch(monkeypatch, tmp_path) - boundary = FakeTaskBoundary([task_result({"id": "fixture-new", "title": "backend-only"})]) + boundary = FakeTaskBoundary( + [task_result({"id": "fixture-new", "title": "backend-only"})] + ) service, _ = task_service(tmp_path, boundary) arguments = { "project_id": "fixture", @@ -2552,8 +3465,18 @@ def test_task_create_returns_a_replay_safe_canonical_ref_and_owner_evidence(tmp_ ], } - first = service.execute(operation="task.create", arguments=arguments, principal="agent-control", mutation_id="request-1") - replayed = service.execute(operation="task.create", arguments=arguments, principal="agent-control", mutation_id="request-1") + first = service.execute( + operation="task.create", + arguments=arguments, + principal="agent-control", + mutation_id="request-1", + ) + replayed = service.execute( + operation="task.create", + arguments=arguments, + principal="agent-control", + mutation_id="request-1", + ) assert first == replayed assert first["task_ref"] == "sinnix://projects/fixture/beads/fixture-new" @@ -2561,18 +3484,58 @@ def test_task_create_returns_a_replay_safe_canonical_ref_and_owner_evidence(tmp_ "owner": "task-backend", "state": "applied", "attempts": 1, - "result": {"sha256": TaskMutationJournal(TaskAuthority.load(tmp_path / "task-state", "fixture").root / TASK_MUTATION_JOURNAL_DIRECTORY).records()[0].result["sha256"], "bytes": len(json.dumps({"id": "fixture-new", "title": "backend-only"}, sort_keys=True, separators=(",", ":")).encode()), "created_task_id": "fixture-new"}, + "result": { + "sha256": TaskMutationJournal( + TaskAuthority.load(tmp_path / "task-state", "fixture").root + / TASK_MUTATION_JOURNAL_DIRECTORY + ) + .records()[0] + .result["sha256"], + "bytes": len( + json.dumps( + {"id": "fixture-new", "title": "backend-only"}, + sort_keys=True, + separators=(",", ":"), + ).encode() + ), + "created_task_id": "fixture-new", + }, "failure": None, } assert boundary.calls == [ - (("--json", "create", "--title", "typed title", "--description", "private create description", "--type", "feature", "--priority", "1", "--labels", "area:agentctl,lane:agents", "--parent", "fixture-parent", "--deps", "depends-on:fixture-blocker,relates-to:fixture-peer"), tmp_path) + ( + ( + "--json", + "create", + "--title", + "typed title", + "--description", + "private create description", + "--type", + "feature", + "--priority", + "1", + "--labels", + "area:agentctl,lane:agents", + "--parent", + "fixture-parent", + "--deps", + "depends-on:fixture-blocker,relates-to:fixture-peer", + ), + tmp_path, + ) ] - journal = TaskMutationJournal(TaskAuthority.load(tmp_path / "task-state", "fixture").root / TASK_MUTATION_JOURNAL_DIRECTORY) + journal = TaskMutationJournal( + TaskAuthority.load(tmp_path / "task-state", "fixture").root + / TASK_MUTATION_JOURNAL_DIRECTORY + ) public_record = next(journal.records_root.glob("*.json")) assert "private create description" not in public_record.read_text() -def test_task_create_uses_the_real_beads_result_shape_and_replays_once(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: +def test_task_create_uses_the_real_beads_result_shape_and_replays_once( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: """Production boundary: current bd emits one object for create, and replay must not create twice.""" isolate_job_scratch(monkeypatch, tmp_path) project_root = tmp_path / "project" @@ -2589,7 +3552,15 @@ def test_task_create_uses_the_real_beads_result_shape_and_replays_once(tmp_path: "XDG_STATE_HOME": str(authority_root / ".local" / "state"), } subprocess.run( - ["bd", "init", "--skip-agents", "--skip-hooks", "--non-interactive", "--prefix", "fixture"], + [ + "bd", + "init", + "--skip-agents", + "--skip-hooks", + "--non-interactive", + "--prefix", + "fixture", + ], cwd=authority_root, check=True, capture_output=True, @@ -2633,75 +3604,223 @@ def test_task_create_uses_the_real_beads_result_shape_and_replays_once(tmp_path: assert first == replayed assert first["task_ref"].startswith("sinnix://projects/fixture/beads/fixture-") - assert first["owner_evidence"]["result"]["created_task_id"] == first["task_ref"].rsplit("/", 1)[1] + assert ( + first["owner_evidence"]["result"]["created_task_id"] + == first["task_ref"].rsplit("/", 1)[1] + ) assert listed["result"]["total"] == 1 - assert [issue["title"] for issue in listed["result"]["issues"]] == ["real boundary task"] + assert [issue["title"] for issue in listed["result"]["issues"]] == [ + "real boundary task" + ] -def test_task_create_outage_replays_without_dirtying_the_registered_checkout(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: +def test_task_create_outage_replays_without_dirtying_the_registered_checkout( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: """Anti-vacuity: creation stays pending across an outage, then replays once from the private intent.""" isolate_job_scratch(monkeypatch, tmp_path) write_adapter(tmp_path) initialize_git_checkout(tmp_path) task_state_root = tmp_path.parent / f"task-state-{tmp_path.name}" - source_database = tmp_path.parent / f"legacy-task-source-{tmp_path.name}" / ".beads" / "dolt" + source_database = ( + tmp_path.parent / f"legacy-task-source-{tmp_path.name}" / ".beads" / "dolt" + ) activate_task_authority(tmp_path, task_state_root, source_database=source_database) - arguments = {"project_id": "fixture", "title": "replayed task", "description": "private replay description", "issue_type": "task", "priority": 2, "labels": [], "dependencies": []} - unavailable = ExecutionResult(command=(), exit_status=None, stdout=b"", stderr=b"", failure_class="command_unavailable:FileNotFoundError") + arguments = { + "project_id": "fixture", + "title": "replayed task", + "description": "private replay description", + "issue_type": "task", + "priority": 2, + "labels": [], + "dependencies": [], + } + unavailable = ExecutionResult( + command=(), + exit_status=None, + stdout=b"", + stderr=b"", + failure_class="command_unavailable:FileNotFoundError", + ) first_boundary = FakeTaskBoundary([unavailable]) - first = TaskService(ProjectCatalog([tmp_path]), generic_jobs(tmp_path.parent / f"first-jobs-{tmp_path.name}"), first_boundary, task_state_root=task_state_root) + first = TaskService( + ProjectCatalog([tmp_path]), + generic_jobs(tmp_path.parent / f"first-jobs-{tmp_path.name}"), + first_boundary, + task_state_root=task_state_root, + ) - pending = first.execute(operation="task.create", arguments=arguments, principal="agent-control", mutation_id="request-1") + pending = first.execute( + operation="task.create", + arguments=arguments, + principal="agent-control", + mutation_id="request-1", + ) - assert pending["owner_evidence"] == {"owner": "task-backend", "state": "pending", "attempts": 1, "result": None, "failure": {"code": "OWNER_UNAVAILABLE"}} + assert pending["owner_evidence"] == { + "owner": "task-backend", + "state": "pending", + "attempts": 1, + "result": None, + "failure": {"code": "OWNER_UNAVAILABLE"}, + } assert "task_ref" not in pending authority = TaskAuthority.load(task_state_root, "fixture") journal = TaskMutationJournal(authority.root / TASK_MUTATION_JOURNAL_DIRECTORY) - assert "private replay description" not in next(journal.records_root.glob("*.json")).read_text() - assert subprocess.run(["git", "-C", str(tmp_path), "status", "--porcelain"], capture_output=True, text=True, check=True).stdout == "" + assert ( + "private replay description" + not in next(journal.records_root.glob("*.json")).read_text() + ) + assert ( + subprocess.run( + ["git", "-C", str(tmp_path), "status", "--porcelain"], + capture_output=True, + text=True, + check=True, + ).stdout + == "" + ) second_boundary = FakeTaskBoundary([task_result({"id": "fixture-replayed"})]) - receipts = reconcile_task_mutations(journal=journal, authority=authority, cwd=tmp_path, boundary=second_boundary) - restarted = TaskService(ProjectCatalog([tmp_path]), generic_jobs(tmp_path.parent / f"second-jobs-{tmp_path.name}"), second_boundary, task_state_root=task_state_root) - replayed = restarted.execute(operation="task.create", arguments=arguments, principal="agent-control", mutation_id="request-1") + receipts = reconcile_task_mutations( + journal=journal, authority=authority, cwd=tmp_path, boundary=second_boundary + ) + restarted = TaskService( + ProjectCatalog([tmp_path]), + generic_jobs(tmp_path.parent / f"second-jobs-{tmp_path.name}"), + second_boundary, + task_state_root=task_state_root, + ) + replayed = restarted.execute( + operation="task.create", + arguments=arguments, + principal="agent-control", + mutation_id="request-1", + ) assert receipts[0]["state"] == "applied" assert replayed["task_ref"] == "sinnix://projects/fixture/beads/fixture-replayed" assert len(first_boundary.calls) == 1 assert len(second_boundary.calls) == 1 - assert subprocess.run(["git", "-C", str(tmp_path), "status", "--porcelain"], capture_output=True, text=True, check=True).stdout == "" + assert ( + subprocess.run( + ["git", "-C", str(tmp_path), "status", "--porcelain"], + capture_output=True, + text=True, + check=True, + ).stdout + == "" + ) @pytest.mark.parametrize( "arguments", ( - {"project_id": "missing", "title": "title", "description": "body", "issue_type": "task", "priority": 2, "labels": [], "dependencies": []}, - {"project_id": "fixture", "title": "title", "description": "body", "issue_type": "unknown", "priority": 2, "labels": [], "dependencies": []}, - {"project_id": "fixture", "title": "title", "description": "body", "issue_type": "task", "priority": True, "labels": [], "dependencies": []}, - {"project_id": "fixture", "title": "title", "description": "body", "issue_type": "task", "priority": 2, "labels": ["bad,label"], "dependencies": []}, - {"project_id": "fixture", "title": "title", "description": "body", "issue_type": "task", "priority": 2, "labels": [], "parent_task_id": "--invalid", "dependencies": []}, - {"project_id": "fixture", "title": "title", "description": "body", "issue_type": "task", "priority": 2, "labels": [], "dependencies": [{"relation": "not-a-relation", "task_id": "fixture-1"}]}, + { + "project_id": "missing", + "title": "title", + "description": "body", + "issue_type": "task", + "priority": 2, + "labels": [], + "dependencies": [], + }, + { + "project_id": "fixture", + "title": "title", + "description": "body", + "issue_type": "unknown", + "priority": 2, + "labels": [], + "dependencies": [], + }, + { + "project_id": "fixture", + "title": "title", + "description": "body", + "issue_type": "task", + "priority": True, + "labels": [], + "dependencies": [], + }, + { + "project_id": "fixture", + "title": "title", + "description": "body", + "issue_type": "task", + "priority": 2, + "labels": ["bad,label"], + "dependencies": [], + }, + { + "project_id": "fixture", + "title": "title", + "description": "body", + "issue_type": "task", + "priority": 2, + "labels": [], + "parent_task_id": "--invalid", + "dependencies": [], + }, + { + "project_id": "fixture", + "title": "title", + "description": "body", + "issue_type": "task", + "priority": 2, + "labels": [], + "dependencies": [{"relation": "not-a-relation", "task_id": "fixture-1"}], + }, ), ) -def test_task_create_rejects_invalid_project_parent_and_typed_input(tmp_path: Path, arguments: dict[str, object], monkeypatch: pytest.MonkeyPatch) -> None: +def test_task_create_rejects_invalid_project_parent_and_typed_input( + tmp_path: Path, arguments: dict[str, object], monkeypatch: pytest.MonkeyPatch +) -> None: isolate_job_scratch(monkeypatch, tmp_path) service, boundary = task_service(tmp_path) with pytest.raises(TaskError) as error: - service.execute(operation="task.create", arguments=arguments, principal="agent-control", mutation_id="request-1") + service.execute( + operation="task.create", + arguments=arguments, + principal="agent-control", + mutation_id="request-1", + ) assert error.value.code == ErrorCode.INVALID_ARGUMENT assert not boundary.calls -def test_task_create_relation_failure_is_a_failed_journalled_receipt(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: +def test_task_create_relation_failure_is_a_failed_journalled_receipt( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: isolate_job_scratch(monkeypatch, tmp_path) - service, boundary = task_service(tmp_path, FakeTaskBoundary([ExecutionResult(command=(), exit_status=1, stdout=b"", stderr=b"parent missing")])) + service, boundary = task_service( + tmp_path, + FakeTaskBoundary( + [ + ExecutionResult( + command=(), exit_status=1, stdout=b"", stderr=b"parent missing" + ) + ] + ), + ) response = SinnixdService(ProjectCatalog([tmp_path]), tasks=service).dispatch( request( "task.create", "task-backend", - {"project_id": "fixture", "title": "relation failure", "description": "body", "issue_type": "task", "priority": 2, "labels": [], "parent_task_id": "fixture-parent", "dependencies": [{"relation": "depends-on", "task_id": "fixture-blocker"}]}, + { + "project_id": "fixture", + "title": "relation failure", + "description": "body", + "issue_type": "task", + "priority": 2, + "labels": [], + "parent_task_id": "fixture-parent", + "dependencies": [ + {"relation": "depends-on", "task_id": "fixture-blocker"} + ], + }, "agent-control", idempotency_key="request-1", ) @@ -2715,22 +3834,64 @@ def test_task_create_relation_failure_is_a_failed_journalled_receipt(tmp_path: P ).records()[0] assert record.state == "failed" assert record.failure == {"code": "OPERATION_FAILED"} - assert boundary.calls == [(("--json", "create", "--title", "relation failure", "--description", "body", "--type", "task", "--priority", "2", "--parent", "fixture-parent", "--deps", "depends-on:fixture-blocker"), tmp_path)] + assert boundary.calls == [ + ( + ( + "--json", + "create", + "--title", + "relation failure", + "--description", + "body", + "--type", + "task", + "--priority", + "2", + "--parent", + "fixture-parent", + "--deps", + "depends-on:fixture-blocker", + ), + tmp_path, + ) + ] -def test_task_mutation_outage_survives_restart_and_reconciles_without_git_dirtying(tmp_path: Path) -> None: +def test_task_mutation_outage_survives_restart_and_reconciles_without_git_dirtying( + tmp_path: Path, +) -> None: """Anti-vacuity: an unavailable command becomes pending, then a new service replays it exactly once.""" write_adapter(tmp_path) initialize_git_checkout(tmp_path) task_state_root = tmp_path.parent / "task-state" source_database = tmp_path.parent / "legacy-task-source" / ".beads" / "dolt" activate_task_authority(tmp_path, task_state_root, source_database=source_database) - unavailable = ExecutionResult(command=(), exit_status=None, stdout=b"", stderr=b"", failure_class="command_unavailable:FileNotFoundError") + unavailable = ExecutionResult( + command=(), + exit_status=None, + stdout=b"", + stderr=b"", + failure_class="command_unavailable:FileNotFoundError", + ) first_boundary = FakeTaskBoundary([unavailable]) - first = TaskService(ProjectCatalog([tmp_path]), generic_jobs(tmp_path.parent / "first-jobs"), first_boundary, task_state_root=task_state_root) - payload = {"project_id": "fixture", "task_id": "fixture-1", "text": "private replay payload"} + first = TaskService( + ProjectCatalog([tmp_path]), + generic_jobs(tmp_path.parent / "first-jobs"), + first_boundary, + task_state_root=task_state_root, + ) + payload = { + "project_id": "fixture", + "task_id": "fixture-1", + "text": "private replay payload", + } - pending = first.execute(operation="task.note", arguments=payload, principal="agent-control", mutation_id="request-1") + pending = first.execute( + operation="task.note", + arguments=payload, + principal="agent-control", + mutation_id="request-1", + ) assert pending["result"]["state"] == "pending" assert pending["result"]["failure"] == {"code": "OWNER_UNAVAILABLE"} @@ -2739,27 +3900,74 @@ def test_task_mutation_outage_survives_restart_and_reconciles_without_git_dirtyi public_records = list(journal.records_root.glob("*.json")) assert len(public_records) == 1 assert "private replay payload" not in public_records[0].read_text() - assert subprocess.run(["git", "-C", str(tmp_path), "status", "--porcelain"], capture_output=True, text=True, check=True).stdout == "" + assert ( + subprocess.run( + ["git", "-C", str(tmp_path), "status", "--porcelain"], + capture_output=True, + text=True, + check=True, + ).stdout + == "" + ) second_boundary = FakeTaskBoundary([task_result({"reconciled": True})]) - receipts = reconcile_task_mutations(journal=journal, authority=authority, cwd=tmp_path, boundary=second_boundary) - restarted = TaskService(ProjectCatalog([tmp_path]), generic_jobs(tmp_path.parent / "restart-jobs"), second_boundary, task_state_root=task_state_root) - replayed = restarted.execute(operation="task.note", arguments=payload, principal="agent-control", mutation_id="request-1") + receipts = reconcile_task_mutations( + journal=journal, authority=authority, cwd=tmp_path, boundary=second_boundary + ) + restarted = TaskService( + ProjectCatalog([tmp_path]), + generic_jobs(tmp_path.parent / "restart-jobs"), + second_boundary, + task_state_root=task_state_root, + ) + replayed = restarted.execute( + operation="task.note", + arguments=payload, + principal="agent-control", + mutation_id="request-1", + ) assert receipts[0]["state"] == "applied" assert replayed["result"]["state"] == "applied" assert len(first_boundary.calls) == 1 assert len(second_boundary.calls) == 1 assert not list(journal.intents_root.glob("*.json")) - assert subprocess.run(["git", "-C", str(tmp_path), "status", "--porcelain"], capture_output=True, text=True, check=True).stdout == "" + assert ( + subprocess.run( + ["git", "-C", str(tmp_path), "status", "--porcelain"], + capture_output=True, + text=True, + check=True, + ).stdout + == "" + ) -def test_task_completion_is_idempotent_by_project_task_and_merge_sha(tmp_path: Path) -> None: - service, boundary = task_service(tmp_path, FakeTaskBoundary([task_result({"closed": True})])) - arguments = {"project_id": "fixture", "task_id": "fixture-1", "merge_sha": "b" * 40, "reason": "merged"} +def test_task_completion_is_idempotent_by_project_task_and_merge_sha( + tmp_path: Path, +) -> None: + service, boundary = task_service( + tmp_path, FakeTaskBoundary([task_result({"closed": True})]) + ) + arguments = { + "project_id": "fixture", + "task_id": "fixture-1", + "merge_sha": "b" * 40, + "reason": "merged", + } - first = service.execute(operation="task.complete", arguments=arguments, principal="agent-control", mutation_id="request-1") - replayed = service.execute(operation="task.complete", arguments=arguments, principal="agent-control", mutation_id="request-2") + first = service.execute( + operation="task.complete", + arguments=arguments, + principal="agent-control", + mutation_id="request-1", + ) + replayed = service.execute( + operation="task.complete", + arguments=arguments, + principal="agent-control", + mutation_id="request-2", + ) assert first["result"]["state"] == "applied" assert replayed["result"]["state"] == "applied" @@ -2805,7 +4013,9 @@ def test_task_reconcile_returns_a_durable_fixed_command_receipt(tmp_path: Path) boundary = FakeTaskBoundary([task_result({"ok": True})]) task_state_root = tmp_path / "task-state" activate_task_authority(tmp_path, task_state_root) - tasks = TaskService(ProjectCatalog([tmp_path]), jobs, boundary, task_state_root=task_state_root) + tasks = TaskService( + ProjectCatalog([tmp_path]), jobs, boundary, task_state_root=task_state_root + ) service = SinnixdService(ProjectCatalog([tmp_path]), jobs=jobs, tasks=tasks) claimed = tasks.execute( @@ -2814,7 +4024,11 @@ def test_task_reconcile_returns_a_durable_fixed_command_receipt(tmp_path: Path) principal="agent-control", mutation_id="request-1", ) - response = service.dispatch(request("task.reconcile", "task-backend", {"project_id": "fixture"}, "agent-control")) + response = service.dispatch( + request( + "task.reconcile", "task-backend", {"project_id": "fixture"}, "agent-control" + ) + ) assert claimed["result"]["state"] == "applied" assert response.ok and response.payload is not None @@ -2828,16 +4042,16 @@ def test_task_reconcile_returns_a_durable_fixed_command_receipt(tmp_path: Path) lock_path = task_state_root / "fixture" / "sinnixd-task-mutations.lock" assert boundary.lock_paths == [None] authority_root = task_state_root / "fixture" - database = authority_root / ".beads" / "dolt" + authority_root / ".beads" / "dolt" assert boundary.calls == [ ( ( - "--json", + "--json", "update", "fixture-1", "--claim", ), - tmp_path, + tmp_path, ) ] assert len(systemd.started) == 1 @@ -2879,7 +4093,9 @@ def test_task_snapshot_parses_jsonl_without_writing_a_store(tmp_path: Path) -> N service, boundary = task_service(tmp_path, FakeTaskBoundary([snapshot])) result = service.execute( - operation="task.snapshot", arguments={"project_id": "fixture"}, principal="observer" + operation="task.snapshot", + arguments={"project_id": "fixture"}, + principal="observer", ) assert result == { @@ -2888,11 +4104,11 @@ def test_task_snapshot_parses_jsonl_without_writing_a_store(tmp_path: Path) -> N "result": [{"id": "fixture-1"}, {"id": "fixture-2"}], } authority_root = tmp_path / "task-state" / "fixture" - database = authority_root / ".beads" / "dolt" + authority_root / ".beads" / "dolt" assert boundary.calls == [ ( - ("--json", "--readonly", "export"), - tmp_path, + ("--json", "--readonly", "export"), + tmp_path, ) ] @@ -2900,12 +4116,26 @@ def test_task_snapshot_parses_jsonl_without_writing_a_store(tmp_path: Path) -> N def test_task_mutations_are_serialized_per_project(tmp_path: Path) -> None: entered = threading.Event() release = threading.Event() - boundary = FakeTaskBoundary([task_result({"ok": 1}), task_result({"ok": 2})], entered=entered, release=release) + boundary = FakeTaskBoundary( + [task_result({"ok": 1}), task_result({"ok": 2})], + entered=entered, + release=release, + ) write_adapter(tmp_path) task_state_root = tmp_path / "task-state" activate_task_authority(tmp_path, task_state_root) - first_service = TaskService(ProjectCatalog([tmp_path]), generic_jobs(tmp_path / "first-jobs"), boundary, task_state_root=task_state_root) - second_service = TaskService(ProjectCatalog([tmp_path]), generic_jobs(tmp_path / "second-jobs"), boundary, task_state_root=task_state_root) + first_service = TaskService( + ProjectCatalog([tmp_path]), + generic_jobs(tmp_path / "first-jobs"), + boundary, + task_state_root=task_state_root, + ) + second_service = TaskService( + ProjectCatalog([tmp_path]), + generic_jobs(tmp_path / "second-jobs"), + boundary, + task_state_root=task_state_root, + ) errors: list[BaseException] = [] def mutate(service: TaskService, task_id: str) -> None: @@ -2934,7 +4164,9 @@ def mutate(service: TaskService, task_id: str) -> None: assert len(boundary.calls) == 2 -def test_divergent_worktrees_share_canonical_authority_and_ignore_stale_jsonl(tmp_path: Path) -> None: +def test_divergent_worktrees_share_canonical_authority_and_ignore_stale_jsonl( + tmp_path: Path, +) -> None: repository = tmp_path / "repository" repository.mkdir() write_adapter(repository) @@ -2943,9 +4175,13 @@ def test_divergent_worktrees_share_canonical_authority_and_ignore_stale_jsonl(tm snapshots.parent.mkdir() snapshots.write_text('{"id":"fixture-1","status":"open"}\n') initialize_git_checkout(repository) - subprocess.run(["git", "-C", str(repository), "branch", "stale-checkout"], check=True) + subprocess.run( + ["git", "-C", str(repository), "branch", "stale-checkout"], check=True + ) snapshots.write_text('{"id":"fixture-1","status":"closed"}\n') - subprocess.run(["git", "-C", str(repository), "add", ".beads/issues.jsonl"], check=True) + subprocess.run( + ["git", "-C", str(repository), "add", ".beads/issues.jsonl"], check=True + ) subprocess.run( [ "git", @@ -2964,14 +4200,28 @@ def test_divergent_worktrees_share_canonical_authority_and_ignore_stale_jsonl(tm ) stale_checkout = tmp_path / "stale-checkout" subprocess.run( - ["git", "-C", str(repository), "worktree", "add", "--quiet", str(stale_checkout), "stale-checkout"], - check=True, - ) - assert snapshots.read_text() != (stale_checkout / ".beads" / "issues.jsonl").read_text() + [ + "git", + "-C", + str(repository), + "worktree", + "add", + "--quiet", + str(stale_checkout), + "stale-checkout", + ], + check=True, + ) + assert ( + snapshots.read_text() + != (stale_checkout / ".beads" / "issues.jsonl").read_text() + ) task_state_root = tmp_path / "task-state" database = activate_task_authority(repository, task_state_root) - boundary = CanonicalTaskBoundary({"fixture-1": {"id": "fixture-1", "status": "closed"}}) + boundary = CanonicalTaskBoundary( + {"fixture-1": {"id": "fixture-1", "status": "closed"}} + ) primary = TaskService( ProjectCatalog([repository]), generic_jobs(tmp_path / "primary-jobs"), @@ -3000,7 +4250,10 @@ def test_divergent_worktrees_share_canonical_authority_and_ignore_stale_jsonl(tm assert observed["result"] == {"id": "fixture-1", "status": "claimed"} assert boundary.databases == [database, database] assert [cwd for _, cwd in boundary.calls] == [repository, stale_checkout] - assert json.loads((stale_checkout / ".beads" / "issues.jsonl").read_text())["status"] == "open" + assert ( + json.loads((stale_checkout / ".beads" / "issues.jsonl").read_text())["status"] + == "open" + ) def test_task_authority_refuses_unverified_or_dual_authority(tmp_path: Path) -> None: @@ -3046,10 +4299,31 @@ def test_task_authority_refuses_unverified_or_dual_authority(tmp_path: Path) -> @pytest.mark.parametrize( ("backend_result", "expected_code"), ( - (ExecutionResult(command=(), exit_status=None, stdout=b"", stderr=b"", timed_out=True), "OWNER_UNAVAILABLE"), - (ExecutionResult(command=(), exit_status=0, stdout=b"x" * (MAX_TASK_OUTPUT_BYTES + 1), stderr=b""), "RESOURCE_EXHAUSTED"), - (ExecutionResult(command=(), exit_status=1, stdout=b"", stderr=b"private backend detail"), "OPERATION_FAILED"), - (ExecutionResult(command=(), exit_status=0, stdout=b"not-json", stderr=b""), "RESULT_INVALID"), + ( + ExecutionResult( + command=(), exit_status=None, stdout=b"", stderr=b"", timed_out=True + ), + "OWNER_UNAVAILABLE", + ), + ( + ExecutionResult( + command=(), + exit_status=0, + stdout=b"x" * (MAX_TASK_OUTPUT_BYTES + 1), + stderr=b"", + ), + "RESOURCE_EXHAUSTED", + ), + ( + ExecutionResult( + command=(), exit_status=1, stdout=b"", stderr=b"private backend detail" + ), + "OPERATION_FAILED", + ), + ( + ExecutionResult(command=(), exit_status=0, stdout=b"not-json", stderr=b""), + "RESULT_INVALID", + ), ), ) def test_task_backend_failures_map_to_clean_error_envelopes( @@ -3067,7 +4341,12 @@ def test_task_backend_failures_map_to_clean_error_envelopes( service = SinnixdService(ProjectCatalog([tmp_path]), tasks=tasks) response = service.dispatch( - request("task.get", "task-backend", {"project_id": "fixture", "task_id": "fixture-1"}, "observer") + request( + "task.get", + "task-backend", + {"project_id": "fixture", "task_id": "fixture-1"}, + "observer", + ) ) assert response.error is not None @@ -3075,7 +4354,9 @@ def test_task_backend_failures_map_to_clean_error_envelopes( assert "private backend detail" not in response.error.message -def test_task_rejects_unauthorized_principals_and_invalid_arguments(tmp_path: Path) -> None: +def test_task_rejects_unauthorized_principals_and_invalid_arguments( + tmp_path: Path, +) -> None: write_adapter(tmp_path) task_state_root = tmp_path / "task-state" activate_task_authority(tmp_path, task_state_root) @@ -3088,13 +4369,28 @@ def test_task_rejects_unauthorized_principals_and_invalid_arguments(tmp_path: Pa service = SinnixdService(ProjectCatalog([tmp_path]), tasks=tasks) denied = service.dispatch( - request("task.claim", "task-backend", {"project_id": "fixture", "task_id": "fixture-1"}, "observer") + request( + "task.claim", + "task-backend", + {"project_id": "fixture", "task_id": "fixture-1"}, + "observer", + ) ) invalid = service.dispatch( - request("task.get", "task-backend", {"project_id": "fixture", "task_id": "--bad", "extra": True}, "observer") + request( + "task.get", + "task-backend", + {"project_id": "fixture", "task_id": "--bad", "extra": True}, + "observer", + ) ) missing_request_id = service.dispatch( - request("task.claim", "task-backend", {"project_id": "fixture", "task_id": "fixture-1"}, "agent-control") + request( + "task.claim", + "task-backend", + {"project_id": "fixture", "task_id": "fixture-1"}, + "agent-control", + ) ) unknown = service.dispatch( request("task.list", "task-backend", {"project_id": "missing"}, "observer") @@ -3102,7 +4398,10 @@ def test_task_rejects_unauthorized_principals_and_invalid_arguments(tmp_path: Pa assert denied.error is not None and denied.error.code.value == "POLICY_DENIED" assert invalid.error is not None and invalid.error.code.value == "INVALID_ARGUMENT" - assert missing_request_id.error is not None and missing_request_id.error.code.value == "INVALID_ARGUMENT" + assert ( + missing_request_id.error is not None + and missing_request_id.error.code.value == "INVALID_ARGUMENT" + ) assert unknown.error is not None and unknown.error.code.value == "INVALID_ARGUMENT" assert not tasks.boundary.calls @@ -3142,11 +4441,15 @@ def serve() -> None: return thread -def test_project_catalog_is_explicit_and_operation_catalog_is_bounded(tmp_path: Path) -> None: +def test_project_catalog_is_explicit_and_operation_catalog_is_bounded( + tmp_path: Path, +) -> None: write_adapter(tmp_path) service = SinnixdService(ProjectCatalog([tmp_path])) - response = service.dispatch(request("project.operations", "project-adapters", {"project_id": "fixture"})) + response = service.dispatch( + request("project.operations", "project-adapters", {"project_id": "fixture"}) + ) assert response.ok assert response.payload is not None @@ -3180,7 +4483,13 @@ def test_project_catalog_is_explicit_and_operation_catalog_is_bounded(tmp_path: "max_length": 128, "grammar": "safe-token", }, - {"name": "attempts", "type": "integer", "flag": "--attempts", "min": 1, "max": 16}, + { + "name": "attempts", + "type": "integer", + "flag": "--attempts", + "min": 1, + "max": 16, + }, { "name": "feature", "type": "string-list", @@ -3243,7 +4552,9 @@ def test_project_operations_reports_descriptor_drift(tmp_path: Path) -> None: descriptor = tmp_path / ".agentctl" / "project.toml" descriptor.write_text(descriptor.read_text() + "\n# changed after daemon startup\n") - response = service.dispatch(request("project.operations", "project-adapters", {"project_id": "fixture"})) + response = service.dispatch( + request("project.operations", "project-adapters", {"project_id": "fixture"}) + ) assert response.ok assert response.payload is not None @@ -3275,7 +4586,9 @@ def test_owner_mismatch_is_a_typed_error(tmp_path: Path) -> None: assert missing.error.code.value == "INVALID_ARGUMENT" -def test_user_systemd_jobs_starts_a_retained_service_with_log_boundary(monkeypatch, tmp_path: Path) -> None: +def test_user_systemd_jobs_starts_a_retained_service_with_log_boundary( + monkeypatch, tmp_path: Path +) -> None: calls: list[tuple[list[str], dict[str, object]]] = [] def fake_run(args, **kwargs): @@ -3379,7 +4692,9 @@ def timed_out(args, **kwargs): systemd.show("sinnixd-job-00000000-0000-0000-0000-000000000001.service") -def test_user_systemd_os_error_reconciles_without_persisting_raw_error(monkeypatch, tmp_path: Path) -> None: +def test_user_systemd_os_error_reconciles_without_persisting_raw_error( + monkeypatch, tmp_path: Path +) -> None: """Anti-vacuity: raw subprocess OSErrors must enter the systemd reconciliation path.""" secret = "systemd-run-os-error-do-not-persist" calls: list[str] = [] @@ -3388,12 +4703,18 @@ def fake_run(args, **_kwargs): calls.append(args[0]) if args[0] == "systemd-run": raise OSError(secret) - return SimpleNamespace(stdout="LoadState=loaded\nActiveState=active\nResult=success\n") + return SimpleNamespace( + stdout="LoadState=loaded\nActiveState=active\nResult=success\n" + ) monkeypatch.setattr("sinnixd.jobs.subprocess.run", fake_run) - jobs = GenericJobs(UserSystemdJobs(), GenericJobStore(tmp_path / "state"), wait_poll_seconds=0.001) + jobs = GenericJobs( + UserSystemdJobs(), GenericJobStore(tmp_path / "state"), wait_poll_seconds=0.001 + ) - started = jobs.start_foreground(command=("fixture",), working_directory=str(tmp_path), environment={}) + started = jobs.start_foreground( + command=("fixture",), working_directory=str(tmp_path), environment={} + ) persisted = (tmp_path / "state" / "jobs" / f"{started['job_id']}.json").read_text() assert calls == ["systemd-run", "systemctl"] @@ -3430,7 +4751,10 @@ def test_declared_and_foreground_jobs_share_the_generic_route(tmp_path: Path) -> assert systemd.started[0]["environment"]["SINNIXD_JOB_ID"] == launch["job_id"] assert systemd.started[0]["environment"]["SINNIXD_OPERATION"] == "check" assert systemd.started[0]["environment"]["SINNIXD_CHECKOUT_ID"] == "default" - assert systemd.started[0]["environment"]["SINNIXD_CHECKOUT_HEAD"] == launch["checkout"]["head"] + assert ( + systemd.started[0]["environment"]["SINNIXD_CHECKOUT_HEAD"] + == launch["checkout"]["head"] + ) assert launch["checkout"]["path"] == str(tmp_path.resolve()) foreground = service.start_foreground( @@ -3442,13 +4766,20 @@ def test_declared_and_foreground_jobs_share_the_generic_route(tmp_path: Path) -> assert foreground["kind"] == "foreground-command" assert len(systemd.started) == 2 assert service.jobs.store.declared_launch(launch["job_id"])[0] == ( - "fixture-env", "--command", "fixture-check" + "fixture-env", + "--command", + "fixture-check", ) assert systemd.started[1]["command"] == ("fixture-foreground",) foreground_record = service.jobs.store.load(foreground["job_id"]) - assert foreground_record.spec.to_dict()["environment_keys"] == ["EMPTY", "SINNIXD_JOB_ID"] + assert foreground_record.spec.to_dict()["environment_keys"] == [ + "EMPTY", + "SINNIXD_JOB_ID", + ] - status = service.dispatch(request("job.get", "systemd-jobs", {"job_id": launch["job_id"]})) + status = service.dispatch( + request("job.get", "systemd-jobs", {"job_id": launch["job_id"]}) + ) cancelled = service.dispatch( request("job.cancel", "systemd-jobs", {"job_id": launch["job_id"]}) ) @@ -3477,21 +4808,31 @@ def test_declared_operation_timeout_contract_reaches_systemd(tmp_path: Path) -> """ ) systemd = FakeSystemdJobs() - service = SinnixdService(ProjectCatalog([tmp_path]), jobs=generic_jobs(tmp_path, systemd)) + service = SinnixdService( + ProjectCatalog([tmp_path]), jobs=generic_jobs(tmp_path, systemd) + ) started = service.dispatch( - request("job.start", "systemd-jobs", {"project_id": "fixture", "operation": "long_running"}) + request( + "job.start", + "systemd-jobs", + {"project_id": "fixture", "operation": "long_running"}, + ) ) assert started.ok and started.payload is not None assert started.payload.inline["timeout_seconds"] == 7200 assert service.jobs.store.declared_launch(started.payload.inline["job_id"])[0] == ( - "fixture-env", "--command", "fixture-long" + "fixture-env", + "--command", + "fixture-long", ) assert systemd.started[0]["timeout_seconds"] == 7200 -def test_declared_parameters_canonicalize_argv_and_persist_only_the_digest(tmp_path: Path) -> None: +def test_declared_parameters_canonicalize_argv_and_persist_only_the_digest( + tmp_path: Path, +) -> None: """Anti-vacuity: parameter ordering must affect neither argv identity nor durable record contents.""" write_adapter(tmp_path) systemd = FakeSystemdJobs() @@ -3514,7 +4855,14 @@ def test_declared_parameters_canonicalize_argv_and_persist_only_the_digest(tmp_p launch = started.payload.inline digest = hashlib.sha256(b'{"full":true,"package":["sinexd","xtask"]}').hexdigest() assert jobs.store.declared_launch(launch["job_id"])[0] == ( - "fixture-env", "--command", "fixture-check", "--full", "--package", "sinexd", "--package", "xtask" + "fixture-env", + "--command", + "fixture-check", + "--full", + "--package", + "sinexd", + "--package", + "xtask", ) assert launch["parameters"] == {"digest": digest} persisted = (jobs.store.records_root / f"{launch['job_id']}.json").read_text() @@ -3538,13 +4886,19 @@ def test_declared_parameters_reject_unknown_malformed_and_unbounded_input( ) -> None: write_adapter(tmp_path) systemd = FakeSystemdJobs() - service = SinnixdService(ProjectCatalog([tmp_path]), jobs=generic_jobs(tmp_path, systemd)) + service = SinnixdService( + ProjectCatalog([tmp_path]), jobs=generic_jobs(tmp_path, systemd) + ) response = service.dispatch( request( "job.start", "systemd-jobs", - {"project_id": "fixture", "operation": "parameterized", "parameters": parameters}, + { + "project_id": "fixture", + "operation": "parameterized", + "parameters": parameters, + }, ) ) @@ -3570,13 +4924,19 @@ def test_generic_extended_parameters_reject_invalid_values_before_launch( ) -> None: write_adapter(tmp_path) systemd = FakeSystemdJobs() - service = SinnixdService(ProjectCatalog([tmp_path]), jobs=generic_jobs(tmp_path, systemd)) + service = SinnixdService( + ProjectCatalog([tmp_path]), jobs=generic_jobs(tmp_path, systemd) + ) response = service.dispatch( request( "job.start", "systemd-jobs", - {"project_id": "fixture", "operation": "generic_extended_parameters", "parameters": parameters}, + { + "project_id": "fixture", + "operation": "generic_extended_parameters", + "parameters": parameters, + }, ) ) @@ -3585,7 +4945,9 @@ def test_generic_extended_parameters_reject_invalid_values_before_launch( assert systemd.started == [] -def test_generic_extended_parameters_derive_canonical_argv_and_digest(tmp_path: Path) -> None: +def test_generic_extended_parameters_derive_canonical_argv_and_digest( + tmp_path: Path, +) -> None: write_adapter(tmp_path) systemd = FakeSystemdJobs() jobs = generic_jobs(tmp_path, systemd) @@ -3616,14 +4978,30 @@ def test_generic_extended_parameters_derive_canonical_argv_and_digest(tmp_path: "profile": "strict", } assert jobs.store.declared_launch(started.payload.inline["job_id"])[0] == ( - "fixture-env", "--command", "fixture-check", - "--profile", "strict", "--attempts", "4", - "--features", "serde", "--features", "tokio", - "--package", "sinexd", "--package", "xtask", + "fixture-env", + "--command", + "fixture-check", + "--profile", + "strict", + "--attempts", + "4", + "--features", + "serde", + "--features", + "tokio", + "--package", + "sinexd", + "--package", + "xtask", ) assert started.payload.inline["parameters"] == { "digest": hashlib.sha256( - json.dumps(expected_canonical, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode() + json.dumps( + expected_canonical, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + ).encode() ).hexdigest() } @@ -3643,13 +5021,19 @@ def test_sinex_all_sources_fixture_rejects_invalid_values_before_launch( ) -> None: write_adapter(tmp_path) systemd = FakeSystemdJobs() - service = SinnixdService(ProjectCatalog([tmp_path]), jobs=generic_jobs(tmp_path, systemd)) + service = SinnixdService( + ProjectCatalog([tmp_path]), jobs=generic_jobs(tmp_path, systemd) + ) response = service.dispatch( request( "job.start", "systemd-jobs", - {"project_id": "fixture", "operation": "sinex_all_sources", "parameters": parameters}, + { + "project_id": "fixture", + "operation": "sinex_all_sources", + "parameters": parameters, + }, ) ) @@ -3658,7 +5042,9 @@ def test_sinex_all_sources_fixture_rejects_invalid_values_before_launch( assert systemd.started == [] -def test_sinex_all_sources_fixture_derives_exact_argv_and_digest(tmp_path: Path) -> None: +def test_sinex_all_sources_fixture_derives_exact_argv_and_digest( + tmp_path: Path, +) -> None: """The fixture follows xtask run all-sources' current foreground flags.""" write_adapter(tmp_path) systemd = FakeSystemdJobs() @@ -3690,15 +5076,26 @@ def test_sinex_all_sources_fixture_derives_exact_argv_and_digest(tmp_path: Path) "service_name": "source-driver-browser.history-3", } assert jobs.store.declared_launch(started.payload.inline["job_id"])[0] == ( - "fixture-env", "--command", "xtask", "run", "all-sources", - "--instance-id", "operator-source-driver-browser.history-3", + "fixture-env", + "--command", + "xtask", + "run", + "all-sources", + "--instance-id", + "operator-source-driver-browser.history-3", "--reconcile", - "--service-name", "source-driver-browser.history-3", + "--service-name", + "source-driver-browser.history-3", "--include-default-excluded", ) assert started.payload.inline["parameters"] == { "digest": hashlib.sha256( - json.dumps(expected_canonical, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode() + json.dumps( + expected_canonical, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + ).encode() ).hexdigest() } @@ -3726,10 +5123,19 @@ def test_required_positional_parameter_derives_before_optional_flags_and_contrib assert started.ok and started.payload is not None assert jobs.store.declared_launch(started.payload.inline["job_id"])[0] == ( - "fixture-env", "--command", "xtask", "verify", "closure", "sinex-a1b2", "--json", "--dry-run", + "fixture-env", + "--command", + "xtask", + "verify", + "closure", + "sinex-a1b2", + "--json", + "--dry-run", ) assert started.payload.inline["parameters"] == { - "digest": hashlib.sha256(b'{"bead_id":"sinex-a1b2","dry_run":true,"json":true}').hexdigest() + "digest": hashlib.sha256( + b'{"bead_id":"sinex-a1b2","dry_run":true,"json":true}' + ).hexdigest() } @@ -3747,13 +5153,19 @@ def test_required_positional_parameter_rejects_missing_or_invalid_values_before_ """Anti-vacuity: rejected required positionals must not create a systemd job.""" write_adapter(tmp_path) systemd = FakeSystemdJobs() - service = SinnixdService(ProjectCatalog([tmp_path]), jobs=generic_jobs(tmp_path, systemd)) + service = SinnixdService( + ProjectCatalog([tmp_path]), jobs=generic_jobs(tmp_path, systemd) + ) response = service.dispatch( request( "job.start", "systemd-jobs", - {"project_id": "fixture", "operation": "verify_closure", "parameters": parameters}, + { + "project_id": "fixture", + "operation": "verify_closure", + "parameters": parameters, + }, ) ) @@ -3762,26 +5174,40 @@ def test_required_positional_parameter_rejects_missing_or_invalid_values_before_ assert systemd.started == [] -def test_fixed_operation_rejects_parameters_and_retains_its_declared_argv(tmp_path: Path) -> None: +def test_fixed_operation_rejects_parameters_and_retains_its_declared_argv( + tmp_path: Path, +) -> None: """Anti-vacuity: parameters must not create an argv authority for fixed operations.""" write_adapter(tmp_path) systemd = FakeSystemdJobs() - service = SinnixdService(ProjectCatalog([tmp_path]), jobs=generic_jobs(tmp_path, systemd)) + service = SinnixdService( + ProjectCatalog([tmp_path]), jobs=generic_jobs(tmp_path, systemd) + ) fixed = service.dispatch( - request("job.start", "systemd-jobs", {"project_id": "fixture", "operation": "check", "parameters": {}}) + request( + "job.start", + "systemd-jobs", + {"project_id": "fixture", "operation": "check", "parameters": {}}, + ) ) rejected = service.dispatch( request( "job.start", "systemd-jobs", - {"project_id": "fixture", "operation": "check", "parameters": {"full": True}}, + { + "project_id": "fixture", + "operation": "check", + "parameters": {"full": True}, + }, ) ) assert fixed.ok and fixed.payload is not None assert service.jobs.store.declared_launch(fixed.payload.inline["job_id"])[0] == ( - "fixture-env", "--command", "fixture-check" + "fixture-env", + "--command", + "fixture-check", ) assert rejected.error is not None assert rejected.error.code.value == "INVALID_ARGUMENT" @@ -3799,13 +5225,23 @@ def test_fixed_operation_rejects_parameters_and_retains_its_declared_argv(tmp_pa ), ) def test_declared_json_results_are_bounded_and_validated( - tmp_path: Path, operation: str, content: bytes, overflowed: bool, expected: dict[str, str] | None + tmp_path: Path, + operation: str, + content: bytes, + overflowed: bool, + expected: dict[str, str] | None, ) -> None: """Anti-vacuity: result artifacts must reject injected, malformed, and overflowed JSON.""" write_adapter(tmp_path) jobs = generic_jobs(tmp_path) service = SinnixdService(ProjectCatalog([tmp_path]), jobs=jobs) - started = service.dispatch(request("job.start", "systemd-jobs", {"project_id": "fixture", "operation": operation})) + started = service.dispatch( + request( + "job.start", + "systemd-jobs", + {"project_id": "fixture", "operation": operation}, + ) + ) assert started.ok and started.payload is not None job_id = started.payload.inline["job_id"] record = jobs.store.load(job_id) @@ -3820,22 +5256,36 @@ def test_declared_json_results_are_bounded_and_validated( "job_id": job_id, "kind": kind, "value": expected, - "artifact": {"ref": f"sinnix://jobs/{job_id}/artifacts/result", "max_bytes": 64_000, "kind": kind}, + "artifact": { + "ref": f"sinnix://jobs/{job_id}/artifacts/result", + "max_bytes": 64_000, + "kind": kind, + }, } else: with pytest.raises(JobResultError): jobs.result(job_id) - response = service.dispatch(request("job.result", "systemd-jobs", {"job_id": job_id})) + response = service.dispatch( + request("job.result", "systemd-jobs", {"job_id": job_id}) + ) assert response.error is not None assert response.error.code.value == "RESULT_INVALID" -def test_declared_json_result_respects_the_callers_response_budget(tmp_path: Path) -> None: +def test_declared_json_result_respects_the_callers_response_budget( + tmp_path: Path, +) -> None: """Anti-vacuity: typed JSON must not bypass job.result's max_bytes contract.""" write_adapter(tmp_path) jobs = generic_jobs(tmp_path) service = SinnixdService(ProjectCatalog([tmp_path]), jobs=jobs) - started = service.dispatch(request("job.start", "systemd-jobs", {"project_id": "fixture", "operation": "parameterized"})) + started = service.dispatch( + request( + "job.start", + "systemd-jobs", + {"project_id": "fixture", "operation": "parameterized"}, + ) + ) assert started.ok and started.payload is not None job_id = started.payload.inline["job_id"] record = jobs.store.load(job_id) @@ -3844,7 +5294,9 @@ def test_declared_json_result_respects_the_callers_response_budget(tmp_path: Pat with pytest.raises(JobResultLimitError, match="requested response limit"): jobs.result(job_id, max_bytes=8) - response = service.dispatch(request("job.result", "systemd-jobs", {"job_id": job_id, "max_bytes": 8})) + response = service.dispatch( + request("job.result", "systemd-jobs", {"job_id": job_id, "max_bytes": 8}) + ) assert response.error is not None assert response.error.code.value == "RESOURCE_EXHAUSTED" @@ -3853,13 +5305,27 @@ def test_capture_separates_json_stdout_from_logs(tmp_path: Path) -> None: log_path = tmp_path / "job.log" result_path = tmp_path / "job.result" log_path.touch(mode=0o600) - assert capture_main( - ( - "--log-path", str(log_path), "--overflow-path", str(tmp_path / "job.overflow"), "--max-bytes", "64", - "--result-path", str(result_path), "--result-overflow-path", str(tmp_path / "result.overflow"), - "--", "/bin/sh", "-c", "printf '{\"receipt\":true}'; printf diagnostic >&2", + assert ( + capture_main( + ( + "--log-path", + str(log_path), + "--overflow-path", + str(tmp_path / "job.overflow"), + "--max-bytes", + "64", + "--result-path", + str(result_path), + "--result-overflow-path", + str(tmp_path / "result.overflow"), + "--", + "/bin/sh", + "-c", + "printf '{\"receipt\":true}'; printf diagnostic >&2", + ) ) - ) == 0 + == 0 + ) assert json.loads(result_path.read_text()) == {"receipt": True} assert "diagnostic" in log_path.read_text() @@ -3867,16 +5333,29 @@ def test_capture_separates_json_stdout_from_logs(tmp_path: Path) -> None: def test_capture_writes_to_the_store_preallocated_log_artifact(tmp_path: Path) -> None: """Anti-vacuity: the real GenericJobStore log reservation remains capturable.""" jobs = generic_jobs(tmp_path) - started = jobs.start_foreground(command=("fixture",), working_directory=str(tmp_path), environment={}) + started = jobs.start_foreground( + command=("fixture",), working_directory=str(tmp_path), environment={} + ) record = jobs.store.load(started["job_id"]) assert record.log_path.exists() - assert capture_main( - ( - "--log-path", str(record.log_path), "--overflow-path", str(record.log_path.with_suffix(".overflow")), - "--max-bytes", "64", "--", "/bin/sh", "-c", "printf captured-log", + assert ( + capture_main( + ( + "--log-path", + str(record.log_path), + "--overflow-path", + str(record.log_path.with_suffix(".overflow")), + "--max-bytes", + "64", + "--", + "/bin/sh", + "-c", + "printf captured-log", + ) ) - ) == 0 + == 0 + ) assert record.log_path.read_text() == "captured-log" @@ -3892,24 +5371,45 @@ def test_capture_refuses_hostile_artifact_symlinks(tmp_path: Path) -> None: with pytest.raises(FileExistsError): capture_main( ( - "--log-path", str(log_path), "--overflow-path", str(tmp_path / "job.overflow"), "--max-bytes", "64", - "--result-path", str(result_path), "--result-overflow-path", str(tmp_path / "result.overflow"), - "--", "/bin/true", + "--log-path", + str(log_path), + "--overflow-path", + str(tmp_path / "job.overflow"), + "--max-bytes", + "64", + "--result-path", + str(result_path), + "--result-overflow-path", + str(tmp_path / "result.overflow"), + "--", + "/bin/true", ) ) assert protected.read_text() == "keep" -def test_job_reconciliation_marks_missing_units_without_daemon_owned_state(tmp_path: Path) -> None: +def test_job_reconciliation_marks_missing_units_without_daemon_owned_state( + tmp_path: Path, +) -> None: """Anti-vacuity: deleting GenericJobs.get's systemd.show call loses the missing phase.""" write_adapter(tmp_path) - systemd = FakeSystemdJobs(properties={"LoadState": "not-found", "ActiveState": "inactive"}) - service = SinnixdService(ProjectCatalog([tmp_path]), jobs=generic_jobs(tmp_path, systemd)) + systemd = FakeSystemdJobs( + properties={"LoadState": "not-found", "ActiveState": "inactive"} + ) + service = SinnixdService( + ProjectCatalog([tmp_path]), jobs=generic_jobs(tmp_path, systemd) + ) - started = service.dispatch(request("job.start", "systemd-jobs", {"project_id": "fixture", "operation": "check"})) + started = service.dispatch( + request( + "job.start", "systemd-jobs", {"project_id": "fixture", "operation": "check"} + ) + ) assert started.payload is not None - response = service.dispatch(request("job.get", "systemd-jobs", {"job_id": started.payload.inline["job_id"]})) + response = service.dispatch( + request("job.get", "systemd-jobs", {"job_id": started.payload.inline["job_id"]}) + ) assert response.ok assert response.payload is not None @@ -3952,7 +5452,9 @@ def test_declared_project_job_rejects_arbitrary_execution(tmp_path: Path) -> Non assert direct_argv.error.code.value == "INVALID_ARGUMENT" -def test_workspace_create_is_git_derived_durable_and_restart_safe(tmp_path: Path) -> None: +def test_workspace_create_is_git_derived_durable_and_restart_safe( + tmp_path: Path, +) -> None: """Anti-vacuity: create must reach Git worktree authority and survive service restart.""" write_adapter(tmp_path) initialize_git_checkout(tmp_path) @@ -3990,31 +5492,53 @@ def test_workspace_create_is_git_derived_durable_and_restart_safe(tmp_path: Path restarted = SinnixdService(ProjectCatalog([tmp_path]), jobs=generic_jobs(tmp_path)) recovered = restarted.dispatch( - request("workspace.get", "git-workspaces", {"workspace_id": workspace["workspace_id"]}) + request( + "workspace.get", + "git-workspaces", + {"workspace_id": workspace["workspace_id"]}, + ) ) assert recovered.ok and recovered.payload is not None assert recovered.payload.inline["head"] == workspace["head"] assert recovered.payload.inline["checkout_id"].startswith("worktree-") -def test_workspace_adopt_uses_existing_linked_checkout_without_claiming_creation(tmp_path: Path) -> None: +def test_workspace_adopt_uses_existing_linked_checkout_without_claiming_creation( + tmp_path: Path, +) -> None: write_adapter(tmp_path) initialize_git_checkout(tmp_path) linked = tmp_path / "external-linked" subprocess.run( - ["git", "-C", str(tmp_path), "worktree", "add", "-b", "feature/adopted", str(linked), "HEAD"], + [ + "git", + "-C", + str(tmp_path), + "worktree", + "add", + "-b", + "feature/adopted", + str(linked), + "HEAD", + ], check=True, capture_output=True, ) catalog = ProjectCatalog([tmp_path]) - checkout = next(item for item in catalog.checkouts("fixture") if item.path == linked) + checkout = next( + item for item in catalog.checkouts("fixture") if item.path == linked + ) service = SinnixdService(catalog, jobs=generic_jobs(tmp_path)) adopted = service.dispatch( request( "workspace.adopt", "git-workspaces", - {"project_id": "fixture", "checkout_id": checkout.checkout_id, "name": "adopted-lane"}, + { + "project_id": "fixture", + "checkout_id": checkout.checkout_id, + "name": "adopted-lane", + }, "operator", ) ) @@ -4025,7 +5549,9 @@ def test_workspace_adopt_uses_existing_linked_checkout_without_claiming_creation assert adopted.payload.inline["current_branch"] == "feature/adopted" -def test_workspace_mutations_reject_weak_principals_paths_refs_and_duplicates(tmp_path: Path) -> None: +def test_workspace_mutations_reject_weak_principals_paths_refs_and_duplicates( + tmp_path: Path, +) -> None: write_adapter(tmp_path) initialize_git_checkout(tmp_path) service = SinnixdService(ProjectCatalog([tmp_path]), jobs=generic_jobs(tmp_path)) @@ -4036,15 +5562,31 @@ def test_workspace_mutations_reject_weak_principals_paths_refs_and_duplicates(tm "base": "HEAD", } - weak = service.dispatch(request("workspace.create", "git-workspaces", arguments, "observer")) + weak = service.dispatch( + request("workspace.create", "git-workspaces", arguments, "observer") + ) escaped = service.dispatch( - request("workspace.create", "git-workspaces", {**arguments, "name": "../escape"}, "agent-control") + request( + "workspace.create", + "git-workspaces", + {**arguments, "name": "../escape"}, + "agent-control", + ) ) invalid_ref = service.dispatch( - request("workspace.create", "git-workspaces", {**arguments, "base": "missing-ref"}, "agent-control") + request( + "workspace.create", + "git-workspaces", + {**arguments, "base": "missing-ref"}, + "agent-control", + ) + ) + created = service.dispatch( + request("workspace.create", "git-workspaces", arguments, "agent-control") + ) + duplicate = service.dispatch( + request("workspace.create", "git-workspaces", arguments, "agent-control") ) - created = service.dispatch(request("workspace.create", "git-workspaces", arguments, "agent-control")) - duplicate = service.dispatch(request("workspace.create", "git-workspaces", arguments, "agent-control")) adopt_root = service.dispatch( request( "workspace.adopt", @@ -4065,11 +5607,16 @@ def test_workspace_status_exposes_branch_drift_and_dirty_state(tmp_path: Path) - initialize_git_checkout(tmp_path) service = SinnixdService(ProjectCatalog([tmp_path]), jobs=generic_jobs(tmp_path)) created = service.workspaces.create( - project_id="fixture", name="drift-lane", branch="feature/drift-lane", base="HEAD" + project_id="fixture", + name="drift-lane", + branch="feature/drift-lane", + base="HEAD", ) path = Path(created["path"]) (path / "untracked.txt").write_text("operator work\n") - subprocess.run(["git", "-C", str(path), "switch", "--detach"], check=True, capture_output=True) + subprocess.run( + ["git", "-C", str(path), "switch", "--detach"], check=True, capture_output=True + ) observed = service.workspaces.get(created["workspace_id"]) @@ -4078,12 +5625,17 @@ def test_workspace_status_exposes_branch_drift_and_dirty_state(tmp_path: Path) - assert not observed["identity_matches"] -def test_workspace_reap_forgets_missing_and_removes_only_clean_contained_managed_worktrees(tmp_path: Path) -> None: +def test_workspace_reap_forgets_missing_and_removes_only_clean_contained_managed_worktrees( + tmp_path: Path, +) -> None: write_adapter(tmp_path) initialize_git_checkout(tmp_path) service = SinnixdService(ProjectCatalog([tmp_path]), jobs=generic_jobs(tmp_path)) missing = service.workspaces.create( - project_id="fixture", name="missing-lane", branch="feature/missing-lane", base="HEAD" + project_id="fixture", + name="missing-lane", + branch="feature/missing-lane", + base="HEAD", ) subprocess.run( ["git", "-C", str(tmp_path), "worktree", "remove", missing["path"]], @@ -4100,7 +5652,10 @@ def test_workspace_reap_forgets_missing_and_removes_only_clean_contained_managed ) ) clean = service.workspaces.create( - project_id="fixture", name="clean-lane", branch="feature/clean-lane", base="HEAD" + project_id="fixture", + name="clean-lane", + branch="feature/clean-lane", + base="HEAD", ) reaped = service.workspaces.reap(clean["workspace_id"]) @@ -4123,38 +5678,75 @@ def test_delivery_rejects_pending_hosted_checks() -> None: ) -def test_workspace_reap_preserves_dirty_divergent_and_adopted_worktrees(tmp_path: Path) -> None: +def test_workspace_reap_preserves_dirty_divergent_and_adopted_worktrees( + tmp_path: Path, +) -> None: write_adapter(tmp_path) initialize_git_checkout(tmp_path) service = SinnixdService(ProjectCatalog([tmp_path]), jobs=generic_jobs(tmp_path)) dirty = service.workspaces.create( - project_id="fixture", name="dirty-lane", branch="feature/dirty-lane", base="HEAD" + project_id="fixture", + name="dirty-lane", + branch="feature/dirty-lane", + base="HEAD", ) dirty_path = Path(dirty["path"]) (dirty_path / "operator.txt").write_text("preserve\n") divergent = service.workspaces.create( - project_id="fixture", name="divergent-lane", branch="feature/divergent-lane", base="HEAD" + project_id="fixture", + name="divergent-lane", + branch="feature/divergent-lane", + base="HEAD", ) divergent_path = Path(divergent["path"]) (divergent_path / "committed.txt").write_text("unique\n") - subprocess.run(["git", "-C", str(divergent_path), "add", "committed.txt"], check=True) + subprocess.run( + ["git", "-C", str(divergent_path), "add", "committed.txt"], check=True + ) subprocess.run( [ - "git", "-C", str(divergent_path), "-c", "user.name=Fixture", "-c", - "user.email=fixture@example.test", "commit", "--quiet", "-m", "diverge", + "git", + "-C", + str(divergent_path), + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.test", + "commit", + "--quiet", + "-m", + "diverge", ], check=True, ) external = tmp_path / "external-reap" subprocess.run( - ["git", "-C", str(tmp_path), "worktree", "add", "-b", "feature/external-reap", str(external), "HEAD"], + [ + "git", + "-C", + str(tmp_path), + "worktree", + "add", + "-b", + "feature/external-reap", + str(external), + "HEAD", + ], check=True, capture_output=True, ) - checkout = next(item for item in service.projects.checkouts("fixture") if item.path == external) - adopted = service.workspaces.adopt(project_id="fixture", checkout_id=checkout.checkout_id, name="adopted-reap") + checkout = next( + item for item in service.projects.checkouts("fixture") if item.path == external + ) + adopted = service.workspaces.adopt( + project_id="fixture", checkout_id=checkout.checkout_id, name="adopted-reap" + ) - for workspace_id in (dirty["workspace_id"], divergent["workspace_id"], adopted["workspace_id"]): + for workspace_id in ( + dirty["workspace_id"], + divergent["workspace_id"], + adopted["workspace_id"], + ): with pytest.raises(ValueError): service.workspaces.reap(workspace_id) @@ -4163,13 +5755,18 @@ def test_workspace_reap_preserves_dirty_divergent_and_adopted_worktrees(tmp_path assert external.is_dir() -def test_workspace_dispose_deletes_a_clean_no_pr_branch_without_checkpoint_content(tmp_path: Path) -> None: +def test_workspace_dispose_deletes_a_clean_no_pr_branch_without_checkpoint_content( + tmp_path: Path, +) -> None: """Anti-vacuity: disposal must remove both Git objects, not just the workspace record.""" write_adapter(tmp_path) initialize_git_checkout(tmp_path) service = SinnixdService(ProjectCatalog([tmp_path]), jobs=generic_jobs(tmp_path)) workspace = service.workspaces.create( - project_id="fixture", name="verification-lane", branch="feature/verification-lane", base="HEAD" + project_id="fixture", + name="verification-lane", + branch="feature/verification-lane", + base="HEAD", ) checkpoint = service.workspaces.checkpoint(workspace["workspace_id"]) gitdir = replace_worktree_gitfile_with_symlink(Path(workspace["path"])) @@ -4188,41 +5785,80 @@ def test_workspace_dispose_deletes_a_clean_no_pr_branch_without_checkpoint_conte assert disposed.payload.inline["disposed"] assert disposed.payload.inline["deleted_branch"] == workspace["branch"] assert not Path(workspace["path"]).exists() - assert not (service.workspaces.store.checkpoints_root / workspace["workspace_id"] / checkpoint["checkpoint_id"]).exists() + assert not ( + service.workspaces.store.checkpoints_root + / workspace["workspace_id"] + / checkpoint["checkpoint_id"] + ).exists() assert service.workspaces.list("fixture") == {"workspaces": []} -def test_workspace_finish_integrated_accepts_cherry_picked_tree_and_rejects_missing_change(tmp_path: Path) -> None: +def test_workspace_finish_integrated_accepts_cherry_picked_tree_and_rejects_missing_change( + tmp_path: Path, +) -> None: write_adapter(tmp_path) initialize_git_checkout(tmp_path) - subprocess.run(["git", "-C", str(tmp_path), "config", "user.name", "Fixture"], check=True) - subprocess.run(["git", "-C", str(tmp_path), "config", "user.email", "fixture@example.test"], check=True) + subprocess.run( + ["git", "-C", str(tmp_path), "config", "user.name", "Fixture"], check=True + ) + subprocess.run( + ["git", "-C", str(tmp_path), "config", "user.email", "fixture@example.test"], + check=True, + ) service = SinnixdService(ProjectCatalog([tmp_path]), jobs=generic_jobs(tmp_path)) workspace = service.workspaces.create( - project_id="fixture", name="integrated", branch="feature/integrated", base="HEAD" + project_id="fixture", + name="integrated", + branch="feature/integrated", + base="HEAD", ) workspace_path = Path(workspace["path"]) (workspace_path / "integrated.txt").write_text("represented exactly\n") - subprocess.run(["git", "-C", str(workspace_path), "add", "integrated.txt"], check=True) - subprocess.run(["git", "-C", str(workspace_path), "commit", "--quiet", "-m", "integrated"], check=True) + subprocess.run( + ["git", "-C", str(workspace_path), "add", "integrated.txt"], check=True + ) + subprocess.run( + ["git", "-C", str(workspace_path), "commit", "--quiet", "-m", "integrated"], + check=True, + ) with pytest.raises(WorkspaceError, match="not fully represented"): service.workspaces.finish_integrated(workspace["workspace_id"], "master") source_head = subprocess.run( - ["git", "-C", str(workspace_path), "rev-parse", "HEAD"], check=True, capture_output=True, text=True + ["git", "-C", str(workspace_path), "rev-parse", "HEAD"], + check=True, + capture_output=True, + text=True, ).stdout.strip() - subprocess.run(["git", "-C", str(tmp_path), "cherry-pick", source_head], check=True, capture_output=True) + subprocess.run( + ["git", "-C", str(tmp_path), "cherry-pick", source_head], + check=True, + capture_output=True, + ) target_head = subprocess.run( - ["git", "-C", str(tmp_path), "rev-parse", "HEAD"], check=True, capture_output=True, text=True + ["git", "-C", str(tmp_path), "rev-parse", "HEAD"], + check=True, + capture_output=True, + text=True, ).stdout.strip() subprocess.run( - ["git", "-C", str(tmp_path), "update-ref", "refs/remotes/origin/master", target_head], check=True + [ + "git", + "-C", + str(tmp_path), + "update-ref", + "refs/remotes/origin/master", + target_head, + ], + check=True, ) gitdir = replace_worktree_gitfile_with_symlink(workspace_path) assert gitdir.is_dir() - finished = service.workspaces.finish_integrated(workspace["workspace_id"], target_head) + finished = service.workspaces.finish_integrated( + workspace["workspace_id"], target_head + ) assert finished == { "workspace_id": workspace["workspace_id"], @@ -4232,30 +5868,57 @@ def test_workspace_finish_integrated_accepts_cherry_picked_tree_and_rejects_miss } assert not workspace_path.exists() assert not service.workspaces.list()["workspaces"] - assert subprocess.run( - ["git", "-C", str(tmp_path), "show-ref", "--verify", "--quiet", f"refs/heads/{workspace['branch']}"] - ).returncode == 1 + assert ( + subprocess.run( + [ + "git", + "-C", + str(tmp_path), + "show-ref", + "--verify", + "--quiet", + f"refs/heads/{workspace['branch']}", + ] + ).returncode + == 1 + ) -def test_workspace_gitfile_symlink_rejects_mismatched_and_outside_targets_without_mutation(tmp_path: Path) -> None: +def test_workspace_gitfile_symlink_rejects_mismatched_and_outside_targets_without_mutation( + tmp_path: Path, +) -> None: """Anti-vacuity: only the exact registered administrative gitdir may be canonicalized.""" write_adapter(tmp_path) initialize_git_checkout(tmp_path) service = SinnixdService(ProjectCatalog([tmp_path]), jobs=generic_jobs(tmp_path)) first = service.workspaces.create( - project_id="fixture", name="symlink-first", branch="feature/symlink-first", base="HEAD" + project_id="fixture", + name="symlink-first", + branch="feature/symlink-first", + base="HEAD", ) second = service.workspaces.create( - project_id="fixture", name="symlink-second", branch="feature/symlink-second", base="HEAD" + project_id="fixture", + name="symlink-second", + branch="feature/symlink-second", + base="HEAD", ) first_path = Path(first["path"]) - first_checkout = next(item for item in service.projects.checkouts("fixture") if item.path == first_path) - second_gitdir = Path((Path(second["path"]) / ".git").read_text().strip().removeprefix("gitdir: ")) + first_checkout = next( + item + for item in service.projects.checkouts("fixture") + if item.path == first_path + ) + second_gitdir = Path( + (Path(second["path"]) / ".git").read_text().strip().removeprefix("gitdir: ") + ) first_gitfile = first_path / ".git" first_gitfile.unlink() first_gitfile.symlink_to(second_gitdir) - with pytest.raises(WorkspaceError, match="does not match its registered worktree gitdir"): + with pytest.raises( + WorkspaceError, match="does not match its registered worktree gitdir" + ): service.workspaces._canonicalize_gitfile_symlink(first_checkout) assert first_gitfile.is_symlink() assert first_gitfile.resolve(strict=True) == second_gitdir @@ -4273,34 +5936,62 @@ def test_workspace_gitfile_symlink_rejects_mismatched_and_outside_targets_withou assert Path(second["path"]).is_dir() -def test_workspace_dispose_refuses_dirty_divergent_unpublished_and_checkpoint_only_content(tmp_path: Path) -> None: +def test_workspace_dispose_refuses_dirty_divergent_unpublished_and_checkpoint_only_content( + tmp_path: Path, +) -> None: """Anti-vacuity: each rejection leaves the managed worktree and branch available for recovery.""" write_adapter(tmp_path) initialize_git_checkout(tmp_path) service = SinnixdService(ProjectCatalog([tmp_path]), jobs=generic_jobs(tmp_path)) dirty = service.workspaces.create( - project_id="fixture", name="dispose-dirty", branch="feature/dispose-dirty", base="HEAD" + project_id="fixture", + name="dispose-dirty", + branch="feature/dispose-dirty", + base="HEAD", ) (Path(dirty["path"]) / "operator.txt").write_text("preserve\n") divergent = service.workspaces.create( - project_id="fixture", name="dispose-divergent", branch="feature/dispose-divergent", base="HEAD" + project_id="fixture", + name="dispose-divergent", + branch="feature/dispose-divergent", + base="HEAD", + ) + subprocess.run( + ["git", "-C", divergent["path"], "switch", "-c", "feature/dispose-replaced"], + check=True, ) - subprocess.run(["git", "-C", divergent["path"], "switch", "-c", "feature/dispose-replaced"], check=True) unpublished = service.workspaces.create( - project_id="fixture", name="dispose-unpublished", branch="feature/dispose-unpublished", base="HEAD" + project_id="fixture", + name="dispose-unpublished", + branch="feature/dispose-unpublished", + base="HEAD", ) unpublished_path = Path(unpublished["path"]) (unpublished_path / "unpublished.txt").write_text("preserve\n") - subprocess.run(["git", "-C", str(unpublished_path), "add", "unpublished.txt"], check=True) subprocess.run( - [ - "git", "-C", str(unpublished_path), "-c", "user.name=Fixture", "-c", - "user.email=fixture@example.test", "commit", "--quiet", "-m", "unpublished", - ], - check=True, - ) - checkpoint_only = service.workspaces.create( - project_id="fixture", name="dispose-checkpoint", branch="feature/dispose-checkpoint", base="HEAD" + ["git", "-C", str(unpublished_path), "add", "unpublished.txt"], check=True + ) + subprocess.run( + [ + "git", + "-C", + str(unpublished_path), + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.test", + "commit", + "--quiet", + "-m", + "unpublished", + ], + check=True, + ) + checkpoint_only = service.workspaces.create( + project_id="fixture", + name="dispose-checkpoint", + branch="feature/dispose-checkpoint", + base="HEAD", ) checkpoint_path = Path(checkpoint_only["path"]) (checkpoint_path / "recoverable.txt").write_text("preserve\n") @@ -4311,18 +6002,34 @@ def test_workspace_dispose_refuses_dirty_divergent_unpublished_and_checkpoint_on with pytest.raises(ValueError): service.workspaces.dispose(workspace["workspace_id"]) assert Path(workspace["path"]).is_dir() - assert subprocess.run( - ["git", "-C", str(tmp_path), "show-ref", "--verify", "--quiet", f"refs/heads/{workspace['branch']}"] - ).returncode == 0 + assert ( + subprocess.run( + [ + "git", + "-C", + str(tmp_path), + "show-ref", + "--verify", + "--quiet", + f"refs/heads/{workspace['branch']}", + ] + ).returncode + == 0 + ) -def test_workspace_checkpoint_restore_round_trips_index_worktree_and_untracked_state(tmp_path: Path) -> None: +def test_workspace_checkpoint_restore_round_trips_index_worktree_and_untracked_state( + tmp_path: Path, +) -> None: """Anti-vacuity: dropping any artifact loses one of the three asserted Git states.""" write_adapter(tmp_path) initialize_git_checkout(tmp_path) service = SinnixdService(ProjectCatalog([tmp_path]), jobs=generic_jobs(tmp_path)) created = service.workspaces.create( - project_id="fixture", name="checkpoint-lane", branch="feature/checkpoint-lane", base="HEAD" + project_id="fixture", + name="checkpoint-lane", + branch="feature/checkpoint-lane", + base="HEAD", ) path = Path(created["path"]) (path / "flake.nix").write_text('{"staged": true}\n') @@ -4332,19 +6039,34 @@ def test_workspace_checkpoint_restore_round_trips_index_worktree_and_untracked_s (path / "untracked.txt").write_text("untracked payload\n") checkpoint = service.workspaces.checkpoint(created["workspace_id"]) - subprocess.run(["git", "-C", str(path), "reset", "--hard", "HEAD"], check=True, capture_output=True) + subprocess.run( + ["git", "-C", str(path), "reset", "--hard", "HEAD"], + check=True, + capture_output=True, + ) (path / "untracked.txt").unlink() - restored = service.workspaces.restore(created["workspace_id"], checkpoint["checkpoint_id"]) + restored = service.workspaces.restore( + created["workspace_id"], checkpoint["checkpoint_id"] + ) assert restored["restored"] assert (path / "flake.nix").read_text() == '{"staged": true}\nunstaged\n' assert (path / "untracked.txt").read_text() == "untracked payload\n" - assert "staged" in subprocess.run( - ["git", "-C", str(path), "diff", "--cached"], check=True, capture_output=True, text=True - ).stdout - assert "unstaged" in subprocess.run( - ["git", "-C", str(path), "diff"], check=True, capture_output=True, text=True - ).stdout + assert ( + "staged" + in subprocess.run( + ["git", "-C", str(path), "diff", "--cached"], + check=True, + capture_output=True, + text=True, + ).stdout + ) + assert ( + "unstaged" + in subprocess.run( + ["git", "-C", str(path), "diff"], check=True, capture_output=True, text=True + ).stdout + ) def test_workspace_restore_rejects_dirty_or_stale_head_targets(tmp_path: Path) -> None: @@ -4352,7 +6074,10 @@ def test_workspace_restore_rejects_dirty_or_stale_head_targets(tmp_path: Path) - initialize_git_checkout(tmp_path) service = SinnixdService(ProjectCatalog([tmp_path]), jobs=generic_jobs(tmp_path)) created = service.workspaces.create( - project_id="fixture", name="restore-guards", branch="feature/restore-guards", base="HEAD" + project_id="fixture", + name="restore-guards", + branch="feature/restore-guards", + base="HEAD", ) path = Path(created["path"]) (path / "untracked.txt").write_text("checkpoint\n") @@ -4366,8 +6091,17 @@ def test_workspace_restore_rejects_dirty_or_stale_head_targets(tmp_path: Path) - subprocess.run(["git", "-C", str(path), "add", "advance.txt"], check=True) subprocess.run( [ - "git", "-C", str(path), "-c", "user.name=Fixture", "-c", - "user.email=fixture@example.test", "commit", "--quiet", "-m", "advance", + "git", + "-C", + str(path), + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.test", + "commit", + "--quiet", + "-m", + "advance", ], check=True, ) @@ -4375,12 +6109,17 @@ def test_workspace_restore_rejects_dirty_or_stale_head_targets(tmp_path: Path) - service.workspaces.restore(created["workspace_id"], checkpoint["checkpoint_id"]) -def test_workspace_recover_recreates_missing_exact_head_and_restores_checkpoint(tmp_path: Path) -> None: +def test_workspace_recover_recreates_missing_exact_head_and_restores_checkpoint( + tmp_path: Path, +) -> None: write_adapter(tmp_path) initialize_git_checkout(tmp_path) service = SinnixdService(ProjectCatalog([tmp_path]), jobs=generic_jobs(tmp_path)) created = service.workspaces.create( - project_id="fixture", name="recover-lane", branch="feature/recover-lane", base="HEAD" + project_id="fixture", + name="recover-lane", + branch="feature/recover-lane", + base="HEAD", ) path = Path(created["path"]) (path / "flake.nix").write_text('{"recovered": true}\n') @@ -4392,44 +6131,88 @@ def test_workspace_recover_recreates_missing_exact_head_and_restores_checkpoint( check=True, ) - recovered = service.workspaces.recover(created["workspace_id"], checkpoint["checkpoint_id"]) + recovered = service.workspaces.recover( + created["workspace_id"], checkpoint["checkpoint_id"] + ) assert recovered["recovered"] and recovered["path"] == str(path) assert (path / "flake.nix").read_text() == '{"recovered": true}\n' assert (path / "untracked.txt").read_text() == "preserved\n" - assert "recovered" in subprocess.run( - ["git", "-C", str(path), "diff", "--cached"], check=True, capture_output=True, text=True - ).stdout + assert ( + "recovered" + in subprocess.run( + ["git", "-C", str(path), "diff", "--cached"], + check=True, + capture_output=True, + text=True, + ).stdout + ) -def test_workspace_stack_restacks_child_onto_parent_and_survives_restart(tmp_path: Path) -> None: +def test_workspace_stack_restacks_child_onto_parent_and_survives_restart( + tmp_path: Path, +) -> None: """Anti-vacuity: the durable parent edge drives a real Git rebase after restart.""" write_adapter(tmp_path) initialize_git_checkout(tmp_path) - subprocess.run(["git", "-C", str(tmp_path), "config", "user.name", "Fixture"], check=True) - subprocess.run(["git", "-C", str(tmp_path), "config", "user.email", "fixture@example.test"], check=True) + subprocess.run( + ["git", "-C", str(tmp_path), "config", "user.name", "Fixture"], check=True + ) + subprocess.run( + ["git", "-C", str(tmp_path), "config", "user.email", "fixture@example.test"], + check=True, + ) service = SinnixdService(ProjectCatalog([tmp_path]), jobs=generic_jobs(tmp_path)) parent = service.workspaces.create( project_id="fixture", name="parent-lane", branch="feature/parent", base="HEAD" ) child = service.workspaces.stack( - parent_workspace_id=parent["workspace_id"], name="child-lane", branch="feature/child" + parent_workspace_id=parent["workspace_id"], + name="child-lane", + branch="feature/child", ) parent_path = Path(parent["path"]) child_path = Path(child["path"]) (child_path / "child.txt").write_text("child\n") subprocess.run(["git", "-C", str(child_path), "add", "child.txt"], check=True) subprocess.run( - ["git", "-C", str(child_path), "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "--quiet", "-m", "child"], + [ + "git", + "-C", + str(child_path), + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.test", + "commit", + "--quiet", + "-m", + "child", + ], check=True, ) child_before = subprocess.run( - ["git", "-C", str(child_path), "rev-parse", "HEAD"], check=True, capture_output=True, text=True + ["git", "-C", str(child_path), "rev-parse", "HEAD"], + check=True, + capture_output=True, + text=True, ).stdout.strip() (parent_path / "parent.txt").write_text("parent\n") subprocess.run(["git", "-C", str(parent_path), "add", "parent.txt"], check=True) subprocess.run( - ["git", "-C", str(parent_path), "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "--quiet", "-m", "parent"], + [ + "git", + "-C", + str(parent_path), + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.test", + "commit", + "--quiet", + "-m", + "parent", + ], check=True, ) @@ -4444,35 +6227,69 @@ def test_workspace_stack_restacks_child_onto_parent_and_survives_restart(tmp_pat restarted.workspaces.reap(parent["workspace_id"]) -def test_workspace_restack_detaches_child_after_squash_equivalent_parent_disappears(tmp_path: Path) -> None: +def test_workspace_restack_detaches_child_after_squash_equivalent_parent_disappears( + tmp_path: Path, +) -> None: write_adapter(tmp_path) initialize_git_checkout(tmp_path) - subprocess.run(["git", "-C", str(tmp_path), "config", "user.name", "Fixture"], check=True) - subprocess.run(["git", "-C", str(tmp_path), "config", "user.email", "fixture@example.test"], check=True) + subprocess.run( + ["git", "-C", str(tmp_path), "config", "user.name", "Fixture"], check=True + ) + subprocess.run( + ["git", "-C", str(tmp_path), "config", "user.email", "fixture@example.test"], + check=True, + ) service = SinnixdService(ProjectCatalog([tmp_path]), jobs=generic_jobs(tmp_path)) parent = service.workspaces.create( - project_id="fixture", name="merged-parent", branch="feature/merged-parent", base="HEAD" + project_id="fixture", + name="merged-parent", + branch="feature/merged-parent", + base="HEAD", ) child = service.workspaces.stack( - parent_workspace_id=parent["workspace_id"], name="surviving-child", branch="feature/surviving-child" + parent_workspace_id=parent["workspace_id"], + name="surviving-child", + branch="feature/surviving-child", ) child_path = Path(child["path"]) (child_path / "child.txt").write_text("child\n") subprocess.run(["git", "-C", str(child_path), "add", "child.txt"], check=True) - subprocess.run(["git", "-C", str(child_path), "commit", "--quiet", "-m", "child"], check=True) + subprocess.run( + ["git", "-C", str(child_path), "commit", "--quiet", "-m", "child"], check=True + ) parent_path = Path(parent["path"]) (parent_path / "parent.txt").write_text("parent\n") subprocess.run(["git", "-C", str(parent_path), "add", "parent.txt"], check=True) - subprocess.run(["git", "-C", str(parent_path), "commit", "--quiet", "-m", "parent"], check=True) - subprocess.run(["git", "-C", str(tmp_path), "merge", "--squash", parent["branch"]], check=True, capture_output=True) - subprocess.run(["git", "-C", str(tmp_path), "commit", "--quiet", "-m", "merged parent"], check=True) subprocess.run( - ["git", "-C", str(tmp_path), "update-ref", "refs/remotes/origin/master", "HEAD"], check=True + ["git", "-C", str(parent_path), "commit", "--quiet", "-m", "parent"], check=True + ) + subprocess.run( + ["git", "-C", str(tmp_path), "merge", "--squash", parent["branch"]], + check=True, + capture_output=True, + ) + subprocess.run( + ["git", "-C", str(tmp_path), "commit", "--quiet", "-m", "merged parent"], + check=True, + ) + subprocess.run( + [ + "git", + "-C", + str(tmp_path), + "update-ref", + "refs/remotes/origin/master", + "HEAD", + ], + check=True, + ) + subprocess.run( + ["git", "-C", str(tmp_path), "worktree", "remove", "--force", str(parent_path)], + check=True, ) subprocess.run( - ["git", "-C", str(tmp_path), "worktree", "remove", "--force", str(parent_path)], check=True + ["git", "-C", str(tmp_path), "branch", "-D", parent["branch"]], check=True ) - subprocess.run(["git", "-C", str(tmp_path), "branch", "-D", parent["branch"]], check=True) restacked = service.workspaces.restack(child["workspace_id"]) @@ -4485,7 +6302,11 @@ def test_workspace_restack_detaches_child_after_squash_equivalent_parent_disappe @pytest.mark.parametrize( ("conflict_path", "expected_class"), - [("fixture.lock", "exact-file"), ("generated.json", "generated-surface"), ("ordinary.txt", "hard")], + [ + ("fixture.lock", "exact-file"), + ("generated.json", "generated-surface"), + ("ordinary.txt", "hard"), + ], ) def test_workspace_restack_reports_declared_collision_without_mutating_child( tmp_path: Path, conflict_path: str, expected_class: str @@ -4494,41 +6315,77 @@ def test_workspace_restack_reports_declared_collision_without_mutating_child( initialize_git_checkout(tmp_path) service = SinnixdService(ProjectCatalog([tmp_path]), jobs=generic_jobs(tmp_path)) parent = service.workspaces.create( - project_id="fixture", name="collision-parent", branch="feature/collision-parent", base="HEAD" + project_id="fixture", + name="collision-parent", + branch="feature/collision-parent", + base="HEAD", ) child = service.workspaces.stack( - parent_workspace_id=parent["workspace_id"], name="collision-child", branch="feature/collision-child" + parent_workspace_id=parent["workspace_id"], + name="collision-child", + branch="feature/collision-child", ) - for workspace, content, message in ((parent, "parent\n", "parent lock"), (child, "child\n", "child lock")): + for workspace, content, message in ( + (parent, "parent\n", "parent lock"), + (child, "child\n", "child lock"), + ): path = Path(workspace["path"]) (path / conflict_path).write_text(content) subprocess.run(["git", "-C", str(path), "add", conflict_path], check=True) subprocess.run( - ["git", "-C", str(path), "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "--quiet", "-m", message], + [ + "git", + "-C", + str(path), + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.test", + "commit", + "--quiet", + "-m", + message, + ], check=True, ) child_head = subprocess.run( - ["git", "-C", child["path"], "rev-parse", "HEAD"], check=True, capture_output=True, text=True + ["git", "-C", child["path"], "rev-parse", "HEAD"], + check=True, + capture_output=True, + text=True, ).stdout.strip() result = service.workspaces.restack(child["workspace_id"]) assert not result["restacked"] assert result["collisions"] == [{"path": conflict_path, "class": expected_class}] - assert subprocess.run( - ["git", "-C", child["path"], "rev-parse", "HEAD"], check=True, capture_output=True, text=True - ).stdout.strip() == child_head + assert ( + subprocess.run( + ["git", "-C", child["path"], "rev-parse", "HEAD"], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + == child_head + ) -def test_workspace_restack_reports_semantic_slot_collision_across_different_paths(tmp_path: Path) -> None: +def test_workspace_restack_reports_semantic_slot_collision_across_different_paths( + tmp_path: Path, +) -> None: write_adapter(tmp_path) initialize_git_checkout(tmp_path) service = SinnixdService(ProjectCatalog([tmp_path]), jobs=generic_jobs(tmp_path)) parent = service.workspaces.create( - project_id="fixture", name="slot-parent", branch="feature/slot-parent", base="HEAD" + project_id="fixture", + name="slot-parent", + branch="feature/slot-parent", + base="HEAD", ) child = service.workspaces.stack( - parent_workspace_id=parent["workspace_id"], name="slot-child", branch="feature/slot-child" + parent_workspace_id=parent["workspace_id"], + name="slot-child", + branch="feature/slot-child", ) for workspace, relative, content in ( (parent, "registry/parent.toml", "parent = true\n"), @@ -4539,7 +6396,19 @@ def test_workspace_restack_reports_semantic_slot_collision_across_different_path (path / relative).write_text(content) subprocess.run(["git", "-C", str(path), "add", relative], check=True) subprocess.run( - ["git", "-C", str(path), "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "--quiet", "-m", relative], + [ + "git", + "-C", + str(path), + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.test", + "commit", + "--quiet", + "-m", + relative, + ], check=True, ) @@ -4562,15 +6431,24 @@ def test_declared_job_binds_workspace_and_exact_head(tmp_path: Path) -> None: jobs = generic_jobs(tmp_path) service = SinnixdService(ProjectCatalog([tmp_path]), jobs=jobs) workspace = service.workspaces.create( - project_id="fixture", name="verify-lane", branch="feature/verify-lane", base="HEAD" + project_id="fixture", + name="verify-lane", + branch="feature/verify-lane", + base="HEAD", ) - assert service.workspaces.resolve_checkout("fixture", workspace["checkout_id"]).path == Path(workspace["path"]) + assert service.workspaces.resolve_checkout( + "fixture", workspace["checkout_id"] + ).path == Path(workspace["path"]) started = service.dispatch( request( "job.start", "systemd-jobs", - {"project_id": "fixture", "operation": "check", "workspace_id": "verify-lane"}, + { + "project_id": "fixture", + "operation": "check", + "workspace_id": "verify-lane", + }, ) ) @@ -4582,19 +6460,28 @@ def test_declared_job_binds_workspace_and_exact_head(tmp_path: Path) -> None: assert record.spec.checkout["head"] == workspace["head"] -def test_forged_packet_completion_arguments_have_no_service_route(tmp_path: Path) -> None: +def test_forged_packet_completion_arguments_have_no_service_route( + tmp_path: Path, +) -> None: write_adapter(tmp_path) initialize_git_checkout(tmp_path) jobs = generic_jobs(tmp_path) service = SinnixdService(ProjectCatalog([tmp_path]), jobs=jobs) workspace = service.workspaces.create( - project_id="fixture", name="packet-lane", branch="feature/packet-lane", base="HEAD" + project_id="fixture", + name="packet-lane", + branch="feature/packet-lane", + base="HEAD", ) started = service.dispatch( request( "job.start", "systemd-jobs", - {"project_id": "fixture", "operation": "check", "workspace_id": workspace["workspace_id"]}, + { + "project_id": "fixture", + "operation": "check", + "workspace_id": workspace["workspace_id"], + }, ) ) assert started.ok and started.payload is not None @@ -4626,30 +6513,77 @@ def test_forged_packet_completion_arguments_have_no_service_route(tmp_path: Path assert response.error.code.value == "INVALID_ARGUMENT" -def test_delivery_snapshot_is_nul_safe_and_exact_file_scope_does_not_include_descendants(tmp_path: Path) -> None: +def test_delivery_snapshot_is_nul_safe_and_exact_file_scope_does_not_include_descendants( + tmp_path: Path, +) -> None: write_adapter(tmp_path) service = SinnixdService(ProjectCatalog([tmp_path]), jobs=generic_jobs(tmp_path)) - workspace = service.workspaces.create(project_id="fixture", name="snapshot-lane", branch="feature/snapshot", base="HEAD") + workspace = service.workspaces.create( + project_id="fixture", + name="snapshot-lane", + branch="feature/snapshot", + base="HEAD", + ) path = Path(workspace["path"]) (path / "dir").mkdir() (path / "dir" / "exact").write_text("old\n") (path / "dir" / "delete\nfile").write_text("delete\n") subprocess.run(["git", "-C", str(path), "add", "."], check=True) - subprocess.run(["git", "-C", str(path), "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "--quiet", "-m", "seed"], check=True) + subprocess.run( + [ + "git", + "-C", + str(path), + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.test", + "commit", + "--quiet", + "-m", + "seed", + ], + check=True, + ) start = service.workspaces.get(workspace["workspace_id"])["head"] - subprocess.run(["git", "-C", str(path), "mv", "dir/exact", "dir/renamed\nfile"], check=True) + subprocess.run( + ["git", "-C", str(path), "mv", "dir/exact", "dir/renamed\nfile"], check=True + ) (path / "dir" / "delete\nfile").unlink() (path / "dir" / "exact.child").write_text("outside exact-file scope\n") subprocess.run(["git", "-C", str(path), "add", "-A"], check=True) - subprocess.run(["git", "-C", str(path), "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "--quiet", "-m", "paths"], check=True) - snapshot = service.workspaces.delivery_snapshot(workspace["workspace_id"], start, scope=("dir/exact",)) + subprocess.run( + [ + "git", + "-C", + str(path), + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.test", + "commit", + "--quiet", + "-m", + "paths", + ], + check=True, + ) + snapshot = service.workspaces.delivery_snapshot( + workspace["workspace_id"], start, scope=("dir/exact",) + ) assert not snapshot["in_scope"] assert {change["status"][0] for change in snapshot["changes"]} >= {"D", "R", "A"} - assert any("\n" in item for change in snapshot["changes"] for item in change["paths"]) - assert service.workspaces.delivery_snapshot(workspace["workspace_id"], start, scope=("dir/",))["in_scope"] + assert any( + "\n" in item for change in snapshot["changes"] for item in change["paths"] + ) + assert service.workspaces.delivery_snapshot( + workspace["workspace_id"], start, scope=("dir/",) + )["in_scope"] -def test_beads_bound_packet_and_exact_head_verifier_compose_into_delivery(tmp_path: Path) -> None: +def test_beads_bound_packet_and_exact_head_verifier_compose_into_delivery( + tmp_path: Path, +) -> None: """The accepting path joins two authoritative jobs; neither can substitute for the other.""" write_adapter(tmp_path) initialize_git_checkout(tmp_path) @@ -4657,9 +6591,14 @@ def test_beads_bound_packet_and_exact_head_verifier_compose_into_delivery(tmp_pa native_runner(native) systemd = FakeSystemdJobs() jobs = generic_jobs(tmp_path, systemd) - service = SinnixdService(ProjectCatalog([tmp_path]), jobs=jobs, native_runner=native) + service = SinnixdService( + ProjectCatalog([tmp_path]), jobs=jobs, native_runner=native + ) workspace = service.workspaces.create( - project_id="fixture", name="packet-delivery", branch="feature/packet-delivery", base="HEAD" + project_id="fixture", + name="packet-delivery", + branch="feature/packet-delivery", + base="HEAD", ) checkout_id = workspace["checkout_id"] binding = { @@ -4680,9 +6619,23 @@ def test_beads_bound_packet_and_exact_head_verifier_compose_into_delivery(tmp_pa path = Path(workspace["path"]) (path / "obsolete.txt").write_text("remove me\n") (path / "prior.txt").write_text("already on the packet branch\n") - subprocess.run(["git", "-C", str(path), "add", "obsolete.txt", "prior.txt"], check=True) subprocess.run( - ["git", "-C", str(path), "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "--quiet", "-m", "seed deletion"], + ["git", "-C", str(path), "add", "obsolete.txt", "prior.txt"], check=True + ) + subprocess.run( + [ + "git", + "-C", + str(path), + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.test", + "commit", + "--quiet", + "-m", + "seed deletion", + ], check=True, ) packet = service.dispatch( @@ -4690,10 +6643,16 @@ def test_beads_bound_packet_and_exact_head_verifier_compose_into_delivery(tmp_pa "job.agent.start", "systemd-jobs", { - "project_id": "fixture", "checkout_id": checkout_id, "prompt": "return structured delivery", - "backend": "codex", "model": "fixture", "effort": "high", - "credential_profile": "subscription", "timeout_seconds": 60, - "result": "last-message", "bead_binding": binding, + "project_id": "fixture", + "checkout_id": checkout_id, + "prompt": "return structured delivery", + "backend": "codex", + "model": "fixture", + "effort": "high", + "credential_profile": "subscription", + "timeout_seconds": 60, + "result": "last-message", + "bead_binding": binding, }, "agent-control", ) @@ -4706,11 +6665,26 @@ def test_beads_bound_packet_and_exact_head_verifier_compose_into_delivery(tmp_pa (path / "obsolete.txt").unlink() subprocess.run(["git", "-C", str(path), "add", "-A"], check=True) subprocess.run( - ["git", "-C", str(path), "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "--quiet", "-m", "delivery"], + [ + "git", + "-C", + str(path), + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.test", + "commit", + "--quiet", + "-m", + "delivery", + ], check=True, ) final_head = subprocess.run( - ["git", "-C", str(path), "rev-parse", "HEAD"], capture_output=True, text=True, check=True + ["git", "-C", str(path), "rev-parse", "HEAD"], + capture_output=True, + text=True, + check=True, ).stdout.strip() worker_delivery = { "anti_vacuity": True, @@ -4720,23 +6694,40 @@ def test_beads_bound_packet_and_exact_head_verifier_compose_into_delivery(tmp_pa "evidence_only": False, } assert packet_record.result_path is not None - packet_record.result_path.write_text(json.dumps({ - "schema_version": 1, "job_id": packet_id, "start_head": start_head, - "final_head": final_head, "delivery": worker_delivery, - })) - jobs_module._write_private_marker(jobs_module._completion_marker_path(packet_record.log_path)) + packet_record.result_path.write_text( + json.dumps( + { + "schema_version": 1, + "job_id": packet_id, + "start_head": start_head, + "final_head": final_head, + "delivery": worker_delivery, + } + ) + ) + jobs_module._write_private_marker( + jobs_module._completion_marker_path(packet_record.log_path) + ) systemd.properties = { - "LoadState": "loaded", "ActiveState": "inactive", "Result": "success", - "ExecMainStatus": "0", "InvocationID": "fixture-invocation", + "LoadState": "loaded", + "ActiveState": "inactive", + "Result": "success", + "ExecMainStatus": "0", + "InvocationID": "fixture-invocation", } assert jobs.get(packet_id)["state"]["phase"] == "succeeded" - verifier = service.dispatch(request( - "job.start", "systemd-jobs", - { - "project_id": "fixture", "operation": "check", "workspace_id": workspace["workspace_id"], - "bead_binding": binding, - }, - )) + verifier = service.dispatch( + request( + "job.start", + "systemd-jobs", + { + "project_id": "fixture", + "operation": "check", + "workspace_id": workspace["workspace_id"], + "bead_binding": binding, + }, + ) + ) assert verifier.ok and verifier.payload is not None verifier_id = verifier.payload.inline["job_id"] assert jobs.get(verifier_id)["state"]["phase"] == "succeeded" @@ -4757,99 +6748,185 @@ def test_beads_bound_packet_and_exact_head_verifier_compose_into_delivery(tmp_pa # branch at packet dispatch. The complete publication diff must still # prevent the packet from laundering it into the PR. bad_binding = {**binding, "write_scope": ["delivery.txt", "obsolete.txt"]} - jobs.store.save(replace( - packet_record, - spec=replace(packet_record.spec, contract={**packet_record.spec.contract, "bead_binding": bad_binding}), - )) + jobs.store.save( + replace( + packet_record, + spec=replace( + packet_record.spec, + contract={**packet_record.spec.contract, "bead_binding": bad_binding}, + ), + ) + ) verifier_record = jobs.store.load(verifier_id) - jobs.store.save(replace( - verifier_record, - spec=replace(verifier_record.spec, contract={**verifier_record.spec.contract, "bead_binding": bad_binding}), - )) + jobs.store.save( + replace( + verifier_record, + spec=replace( + verifier_record.spec, + contract={**verifier_record.spec.contract, "bead_binding": bad_binding}, + ), + ) + ) with pytest.raises(DeliveryError, match="write scope"): delivery._verified_workspace(workspace["workspace_id"], verifier_id, packet_id) jobs.store.save(packet_record) jobs.store.save(verifier_record) for evidence in ([], ["unrelated.txt"], ["obsolete.txt", "unrelated.txt"]): - packet_record.result_path.write_text(json.dumps({ - "schema_version": 1, "job_id": packet_id, "start_head": start_head, - "final_head": final_head, - "delivery": {**worker_delivery, "deletion_evidence": evidence}, - })) + packet_record.result_path.write_text( + json.dumps( + { + "schema_version": 1, + "job_id": packet_id, + "start_head": start_head, + "final_head": final_head, + "delivery": {**worker_delivery, "deletion_evidence": evidence}, + } + ) + ) with pytest.raises(DeliveryError, match="deletion evidence"): - delivery._verified_workspace(workspace["workspace_id"], verifier_id, packet_id) + delivery._verified_workspace( + workspace["workspace_id"], verifier_id, packet_id + ) - packet_record.result_path.write_text(json.dumps({ - "schema_version": 1, "job_id": packet_id, "start_head": start_head, - "final_head": final_head, - "delivery": {**worker_delivery, "unresolved_work": ["still running"]}, - })) + packet_record.result_path.write_text( + json.dumps( + { + "schema_version": 1, + "job_id": packet_id, + "start_head": start_head, + "final_head": final_head, + "delivery": {**worker_delivery, "unresolved_work": ["still running"]}, + } + ) + ) with pytest.raises(DeliveryError, match="incomplete"): delivery._verified_workspace(workspace["workspace_id"], verifier_id, packet_id) - packet_record.result_path.write_text(json.dumps({ - "schema_version": 1, "job_id": packet_id, "start_head": start_head, - "final_head": final_head, "delivery": worker_delivery, - })) - - packet_record.result_path.write_text(json.dumps({ - "schema_version": 1, "job_id": packet_id, "start_head": start_head, - "final_head": final_head, "delivery": None, - })) + packet_record.result_path.write_text( + json.dumps( + { + "schema_version": 1, + "job_id": packet_id, + "start_head": start_head, + "final_head": final_head, + "delivery": worker_delivery, + } + ) + ) + + packet_record.result_path.write_text( + json.dumps( + { + "schema_version": 1, + "job_id": packet_id, + "start_head": start_head, + "final_head": final_head, + "delivery": None, + } + ) + ) with pytest.raises(DeliveryError, match="malformed"): delivery._verified_workspace(workspace["workspace_id"], verifier_id, packet_id) - packet_record.result_path.write_text(json.dumps({ - "schema_version": 1, "job_id": packet_id, "start_head": start_head, - "final_head": final_head, "delivery": worker_delivery, - })) - - for scope in (["../outside"], [f"entry-{index}" for index in range(129)]): - rejected = service.dispatch(request( - "job.start", "systemd-jobs", + packet_record.result_path.write_text( + json.dumps( { - "project_id": "fixture", "operation": "check", "workspace_id": workspace["workspace_id"], - "bead_binding": {**binding, "write_scope": scope}, - }, - )) + "schema_version": 1, + "job_id": packet_id, + "start_head": start_head, + "final_head": final_head, + "delivery": worker_delivery, + } + ) + ) + + for scope in (["../outside"], [f"entry-{index}" for index in range(129)]): + rejected = service.dispatch( + request( + "job.start", + "systemd-jobs", + { + "project_id": "fixture", + "operation": "check", + "workspace_id": workspace["workspace_id"], + "bead_binding": {**binding, "write_scope": scope}, + }, + ) + ) assert not rejected.ok (path / "later.txt").write_text("post-terminal\n") subprocess.run(["git", "-C", str(path), "add", "later.txt"], check=True) subprocess.run( - ["git", "-C", str(path), "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "--quiet", "-m", "later"], + [ + "git", + "-C", + str(path), + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.test", + "commit", + "--quiet", + "-m", + "later", + ], check=True, ) with pytest.raises(DeliveryError, match="exact HEAD"): delivery._verified_workspace(workspace["workspace_id"], verifier_id, packet_id) -def test_packet_runner_seals_worker_report_to_runtime_observed_head(tmp_path: Path) -> None: +def test_packet_runner_seals_worker_report_to_runtime_observed_head( + tmp_path: Path, +) -> None: initialize_git_checkout(tmp_path) start_head = subprocess.run( - ["git", "-C", str(tmp_path), "rev-parse", "HEAD"], capture_output=True, text=True, check=True + ["git", "-C", str(tmp_path), "rev-parse", "HEAD"], + capture_output=True, + text=True, + check=True, ).stdout.strip() (tmp_path / "change.txt").write_text("change\n") subprocess.run(["git", "-C", str(tmp_path), "add", "change.txt"], check=True) subprocess.run( - ["git", "-C", str(tmp_path), "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "--quiet", "-m", "change"], + [ + "git", + "-C", + str(tmp_path), + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.test", + "commit", + "--quiet", + "-m", + "change", + ], check=True, ) final_head = subprocess.run( - ["git", "-C", str(tmp_path), "rev-parse", "HEAD"], capture_output=True, text=True, check=True + ["git", "-C", str(tmp_path), "rev-parse", "HEAD"], + capture_output=True, + text=True, + check=True, ).stdout.strip() result_root = tmp_path / "private-results" result_root.mkdir(mode=0o700) result_path = result_root / "packet.result" result_path.touch(mode=0o600) delivery = { - "anti_vacuity": True, "unresolved_work": [], + "anti_vacuity": True, + "unresolved_work": [], "delegation": {"visibility": "unsupported", "pending": None}, - "deletion_evidence": [], "evidence_only": False, + "deletion_evidence": [], + "evidence_only": False, } result_path.write_text(json.dumps(delivery)) _seal_packet_result( - {"job_id": "packet-job", "checkout": {"head": start_head}}, tmp_path, result_path + {"job_id": "packet-job", "checkout": {"head": start_head}}, + tmp_path, + result_path, ) assert json.loads(result_path.read_text()) == { @@ -4863,11 +6940,15 @@ def test_packet_runner_seals_worker_report_to_runtime_observed_head(tmp_path: Pa result_path.write_text("not-json") with pytest.raises(RunnerError, match="worker result"): _seal_packet_result( - {"job_id": "packet-job", "checkout": {"head": start_head}}, tmp_path, result_path + {"job_id": "packet-job", "checkout": {"head": start_head}}, + tmp_path, + result_path, ) -def test_seal_output_composes_through_exact_head_into_delivery_validation(tmp_path: Path) -> None: +def test_seal_output_composes_through_exact_head_into_delivery_validation( + tmp_path: Path, +) -> None: """Composed: real runner seal output flows through exact-head evidence into delivery acceptance and tamper rejection.""" write_adapter(tmp_path) initialize_git_checkout(tmp_path) @@ -4875,9 +6956,14 @@ def test_seal_output_composes_through_exact_head_into_delivery_validation(tmp_pa native_runner(native) systemd = FakeSystemdJobs() jobs = generic_jobs(tmp_path, systemd) - service = SinnixdService(ProjectCatalog([tmp_path]), jobs=jobs, native_runner=native) + service = SinnixdService( + ProjectCatalog([tmp_path]), jobs=jobs, native_runner=native + ) workspace = service.workspaces.create( - project_id="fixture", name="seal-compose", branch="feature/seal-compose", base="HEAD" + project_id="fixture", + name="seal-compose", + branch="feature/seal-compose", + base="HEAD", ) checkout_id = workspace["checkout_id"] path = Path(workspace["path"]) @@ -4886,8 +6972,19 @@ def test_seal_output_composes_through_exact_head_into_delivery_validation(tmp_pa (path / "seed.txt").write_text("to be removed\n") subprocess.run(["git", "-C", str(path), "add", "seed.txt"], check=True) subprocess.run( - ["git", "-C", str(path), "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", - "commit", "--quiet", "-m", "seed deletion target"], + [ + "git", + "-C", + str(path), + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.test", + "commit", + "--quiet", + "-m", + "seed deletion target", + ], check=True, ) @@ -4907,17 +7004,25 @@ def test_seal_output_composes_through_exact_head_into_delivery_validation(tmp_pa "write_scope": ["added.txt", "seed.txt"], } - packet_response = service.dispatch(request( - "job.agent.start", "systemd-jobs", - { - "project_id": "fixture", "checkout_id": checkout_id, - "prompt": "return structured delivery for seal composition test", - "backend": "codex", "model": "fixture", "effort": "high", - "credential_profile": "subscription", "timeout_seconds": 60, - "result": "last-message", "bead_binding": binding, - }, - "agent-control", - )) + packet_response = service.dispatch( + request( + "job.agent.start", + "systemd-jobs", + { + "project_id": "fixture", + "checkout_id": checkout_id, + "prompt": "return structured delivery for seal composition test", + "backend": "codex", + "model": "fixture", + "effort": "high", + "credential_profile": "subscription", + "timeout_seconds": 60, + "result": "last-message", + "bead_binding": binding, + }, + "agent-control", + ) + ) assert packet_response.ok and packet_response.payload is not None packet_id = packet_response.payload.inline["job_id"] packet_record = jobs.store.load(packet_id) @@ -4928,8 +7033,19 @@ def test_seal_output_composes_through_exact_head_into_delivery_validation(tmp_pa (path / "seed.txt").unlink() subprocess.run(["git", "-C", str(path), "add", "-A"], check=True) subprocess.run( - ["git", "-C", str(path), "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", - "commit", "--quiet", "-m", "packet range: add+delete"], + [ + "git", + "-C", + str(path), + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.test", + "commit", + "--quiet", + "-m", + "packet range: add+delete", + ], check=True, ) @@ -4946,27 +7062,39 @@ def test_seal_output_composes_through_exact_head_into_delivery_validation(tmp_pa } packet_record.result_path.write_text(json.dumps(worker_delivery)) _seal_packet_result( - {"job_id": packet_id, "checkout": {"head": start_head}}, path, packet_record.result_path + {"job_id": packet_id, "checkout": {"head": start_head}}, + path, + packet_record.result_path, ) sealed = json.loads(packet_record.result_path.read_text()) final_head = sealed["final_head"] # Worker result was sealed by the real runner; mark the job succeeded. - jobs_module._write_private_marker(jobs_module._completion_marker_path(packet_record.log_path)) + jobs_module._write_private_marker( + jobs_module._completion_marker_path(packet_record.log_path) + ) systemd.properties = { - "LoadState": "loaded", "ActiveState": "inactive", "Result": "success", - "ExecMainStatus": "0", "InvocationID": "seal-compose-invocation", + "LoadState": "loaded", + "ActiveState": "inactive", + "Result": "success", + "ExecMainStatus": "0", + "InvocationID": "seal-compose-invocation", } assert jobs.get(packet_id)["state"]["phase"] == "succeeded" # Verifier job runs at final_head with the same binding. - verifier_response = service.dispatch(request( - "job.start", "systemd-jobs", - { - "project_id": "fixture", "operation": "check", - "workspace_id": workspace["workspace_id"], "bead_binding": binding, - }, - )) + verifier_response = service.dispatch( + request( + "job.start", + "systemd-jobs", + { + "project_id": "fixture", + "operation": "check", + "workspace_id": workspace["workspace_id"], + "bead_binding": binding, + }, + ) + ) assert verifier_response.ok and verifier_response.payload is not None verifier_id = verifier_response.payload.inline["job_id"] assert jobs.get(verifier_id)["state"]["phase"] == "succeeded" @@ -4984,17 +7112,23 @@ def test_seal_output_composes_through_exact_head_into_delivery_validation(tmp_pa tampered = {**sealed, "final_head": "b" * 40} packet_record.result_path.write_text(json.dumps(tampered)) with pytest.raises(DeliveryError): - delivery_gate._verified_workspace(workspace["workspace_id"], verifier_id, packet_id) + delivery_gate._verified_workspace( + workspace["workspace_id"], verifier_id, packet_id + ) # Restore and verify deletion overclaim is now rejected. packet_record.result_path.write_text(json.dumps(sealed)) overclaim = {**worker_delivery, "deletion_evidence": ["seed.txt", "unrelated.txt"]} packet_record.result_path.write_text(json.dumps({**sealed, "delivery": overclaim})) with pytest.raises(DeliveryError, match="deletion evidence"): - delivery_gate._verified_workspace(workspace["workspace_id"], verifier_id, packet_id) + delivery_gate._verified_workspace( + workspace["workspace_id"], verifier_id, packet_id + ) -def test_admission_revalidates_queued_declared_workspace_before_systemd_launch(tmp_path: Path) -> None: +def test_admission_revalidates_queued_declared_workspace_before_systemd_launch( + tmp_path: Path, +) -> None: """A queued declared service whose checkout HEAD moved must terminalize before it reaches systemd.""" write_adapter(tmp_path) descriptor = tmp_path / ".agentctl" / "project.toml" @@ -5005,7 +7139,9 @@ def test_admission_revalidates_queued_declared_workspace_before_systemd_launch(t ) ) initialize_git_checkout(tmp_path) - systemd = FakeSystemdJobs(properties={"LoadState": "not-found", "ActiveState": "inactive"}) + systemd = FakeSystemdJobs( + properties={"LoadState": "not-found", "ActiveState": "inactive"} + ) jobs = GenericJobs( systemd, GenericJobStore(tmp_path / "state"), @@ -5014,13 +7150,20 @@ def test_admission_revalidates_queued_declared_workspace_before_systemd_launch(t ) service = SinnixdService(ProjectCatalog([tmp_path]), jobs=jobs) workspace = service.workspaces.create( - project_id="fixture", name="queued-drift", branch="feature/queued-drift", base="HEAD" + project_id="fixture", + name="queued-drift", + branch="feature/queued-drift", + base="HEAD", ) started = service.dispatch( request( "job.start", "systemd-jobs", - {"project_id": "fixture", "operation": "service", "workspace_id": workspace["workspace_id"]}, + { + "project_id": "fixture", + "operation": "service", + "workspace_id": workspace["workspace_id"], + }, ) ) assert started.ok and started.payload is not None @@ -5029,7 +7172,19 @@ def test_admission_revalidates_queued_declared_workspace_before_systemd_launch(t (path / "drift.txt").write_text("changed HEAD before admission\n") subprocess.run(["git", "-C", str(path), "add", "drift.txt"], check=True) subprocess.run( - ["git", "-C", str(path), "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "--quiet", "-m", "drift"], + [ + "git", + "-C", + str(path), + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.test", + "commit", + "--quiet", + "-m", + "drift", + ], check=True, ) jobs.pressure_probe = lambda: {"memory_full_avg10": 0.0} @@ -5048,7 +7203,9 @@ def test_declared_runner_revalidates_bound_checkout_at_payload_exec_boundary( """A checkout mutation after systemd admission cannot reach the project payload.""" write_adapter(tmp_path) initialize_git_checkout(tmp_path) - systemd = FakeSystemdJobs(properties={"LoadState": "loaded", "ActiveState": "active"}) + systemd = FakeSystemdJobs( + properties={"LoadState": "loaded", "ActiveState": "active"} + ) jobs = generic_jobs(tmp_path, systemd) catalog = ProjectCatalog([tmp_path]) project = catalog.get("fixture") @@ -5064,7 +7221,19 @@ def test_declared_runner_revalidates_bound_checkout_at_payload_exec_boundary( _, launch_environment = jobs.store.declared_launch(record.job_id) assert systemd.started[0]["command"][1] == "--declared" subprocess.run( - ["git", "-C", str(tmp_path), "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "--allow-empty", "-m", "moved"], + [ + "git", + "-C", + str(tmp_path), + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.test", + "commit", + "--allow-empty", + "-m", + "moved", + ], check=True, capture_output=True, text=True, @@ -5081,32 +7250,59 @@ def test_declared_runner_revalidates_bound_checkout_at_payload_exec_boundary( _run_declared(jobs.store.root, record.job_id, record.unit) -def test_exact_head_verified_workspace_publishes_lands_and_finishes_without_a_pr_ledger(tmp_path: Path) -> None: +def test_exact_head_verified_workspace_publishes_lands_and_finishes_without_a_pr_ledger( + tmp_path: Path, +) -> None: write_adapter(tmp_path) initialize_git_checkout(tmp_path) systemd = FakeSystemdJobs() jobs = generic_jobs(tmp_path, systemd) service = SinnixdService(ProjectCatalog([tmp_path]), jobs=jobs) workspace = service.workspaces.create( - project_id="fixture", name="delivery-lane", branch="feature/delivery", base="HEAD" + project_id="fixture", + name="delivery-lane", + branch="feature/delivery", + base="HEAD", ) path = Path(workspace["path"]) (path / "delivery.txt").write_text("deliver\n") subprocess.run(["git", "-C", str(path), "add", "delivery.txt"], check=True) subprocess.run( - ["git", "-C", str(path), "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "--quiet", "-m", "delivery"], + [ + "git", + "-C", + str(path), + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.test", + "commit", + "--quiet", + "-m", + "delivery", + ], check=True, ) workspace = service.workspaces.get(workspace["workspace_id"]) started = service.dispatch( request( - "job.start", "systemd-jobs", - {"project_id": "fixture", "operation": "check", "workspace_id": workspace["workspace_id"]}, + "job.start", + "systemd-jobs", + { + "project_id": "fixture", + "operation": "check", + "workspace_id": workspace["workspace_id"], + }, ) ) assert started.ok and started.payload is not None job_id = started.payload.inline["job_id"] - systemd.properties = {"LoadState": "loaded", "ActiveState": "inactive", "Result": "success", "ExecMainStatus": "0"} + systemd.properties = { + "LoadState": "loaded", + "ActiveState": "inactive", + "Result": "success", + "ExecMainStatus": "0", + } calls: list[list[str]] = [] merged = False created = False @@ -5121,11 +7317,16 @@ def fake_run(argv, **_kwargs): return subprocess.CompletedProcess(command, 0, "", "") if command[:3] == ["gh", "pr", "create"]: created = True - return subprocess.CompletedProcess(command, 0, "https://github.test/example/pull/17\n", "") + return subprocess.CompletedProcess( + command, 0, "https://github.test/example/pull/17\n", "" + ) if command[:3] == ["gh", "pr", "view"]: if command[-1] == "url": return subprocess.CompletedProcess( - command, 0 if created else 1, json.dumps({"url": "https://github.test/example/pull/17"}), "missing" + command, + 0 if created else 1, + json.dumps({"url": "https://github.test/example/pull/17"}), + "missing", ) review_reads += 1 payload = { @@ -5139,13 +7340,27 @@ def fake_run(argv, **_kwargs): "statusCheckRollup": [], } return subprocess.CompletedProcess(command, 0, json.dumps(payload), "") - return subprocess.CompletedProcess(command, 0, "https://github.test/example/pull/17\n", "") + return subprocess.CompletedProcess( + command, 0, "https://github.test/example/pull/17\n", "" + ) delivery = GitHubDelivery(service.projects, service.workspaces, jobs, run=fake_run) (path / "late.txt").write_text("late change\n") subprocess.run(["git", "-C", str(path), "add", "late.txt"], check=True) subprocess.run( - ["git", "-C", str(path), "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "--quiet", "-m", "late"], + [ + "git", + "-C", + str(path), + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.test", + "commit", + "--quiet", + "-m", + "late", + ], check=True, ) workspace = service.workspaces.get(workspace["workspace_id"]) @@ -5154,21 +7369,33 @@ def fake_run(argv, **_kwargs): assert calls == [] replacement = service.dispatch( request( - "job.start", "systemd-jobs", - {"project_id": "fixture", "operation": "check", "workspace_id": workspace["workspace_id"]}, + "job.start", + "systemd-jobs", + { + "project_id": "fixture", + "operation": "check", + "workspace_id": workspace["workspace_id"], + }, ) ) assert replacement.ok and replacement.payload is not None job_id = replacement.payload.inline["job_id"] - published = delivery.publish(workspace["workspace_id"], job_id, "Deliver fixture", "Verified body") - reconciled = delivery.publish(workspace["workspace_id"], job_id, "Deliver fixture", "Verified body") + published = delivery.publish( + workspace["workspace_id"], job_id, "Deliver fixture", "Verified body" + ) + reconciled = delivery.publish( + workspace["workspace_id"], job_id, "Deliver fixture", "Verified body" + ) landed = delivery.land(workspace["workspace_id"], job_id) finished = delivery.finish(workspace["workspace_id"]) assert published["published"] and published["created"] assert reconciled["published"] and not reconciled["created"] assert landed["landed"] and finished["finished"] - assert any(command[-7:-4] == ["git", "-C", str(path)] and "push" in command for command in calls) + assert any( + command[-7:-4] == ["git", "-C", str(path)] and "push" in command + for command in calls + ) assert any(command[:3] == ["gh", "pr", "create"] for command in calls) assert any(command[:3] == ["gh", "pr", "merge"] for command in calls) assert not path.exists() @@ -5176,22 +7403,40 @@ def fake_run(argv, **_kwargs): assert service.workspaces.list("fixture") == {"workspaces": []} -def test_typed_shell_and_agent_contracts_share_generic_job_lifecycle(tmp_path: Path) -> None: +def test_typed_shell_and_agent_contracts_share_generic_job_lifecycle( + tmp_path: Path, +) -> None: """Anti-vacuity: typed contracts must reach GenericJobs, not a second controller.""" write_adapter(tmp_path) initialize_git_checkout(tmp_path) runner = tmp_path / "native-runner" native_runner(runner) systemd = FakeSystemdJobs( - properties={"LoadState": "loaded", "ActiveState": "inactive", "Result": "success", "ExecMainStatus": "0"} + properties={ + "LoadState": "loaded", + "ActiveState": "inactive", + "Result": "success", + "ExecMainStatus": "0", + } + ) + service = SinnixdService( + ProjectCatalog([tmp_path]), + jobs=generic_jobs(tmp_path, systemd), + native_runner=runner, ) - service = SinnixdService(ProjectCatalog([tmp_path]), jobs=generic_jobs(tmp_path, systemd), native_runner=runner) shell = service.dispatch( request( "job.shell.start", "systemd-jobs", - {"project_id": "fixture", "checkout_id": "default", "argv": ["printf", "shell-secret"], "cwd": ".", "timeout_seconds": 60, "result": "exit-status"}, + { + "project_id": "fixture", + "checkout_id": "default", + "argv": ["printf", "shell-secret"], + "cwd": ".", + "timeout_seconds": 60, + "result": "exit-status", + }, "operator", ) ) @@ -5199,7 +7444,31 @@ def test_typed_shell_and_agent_contracts_share_generic_job_lifecycle(tmp_path: P request( "job.agent.start", "systemd-jobs", - {"project_id": "fixture", "checkout_id": "default", "prompt": "private prompt", "backend": "codex", "model": "fixture", "effort": "high", "credential_profile": "subscription", "timeout_seconds": 60, "result": "last-message", "bead_binding": {"bead_ref": "sinnix://projects/fixture/beads/fixture-1", "project_ref": "sinnix://projects/fixture", "checkout_ref": "sinnix://projects/fixture/checkouts/default", "task_revision": "a" * 64, "task_etag": "b" * 64, "claim_ref": f"sinnix://projects/fixture/beads/fixture-1/claims/{'b' * 64}", "claim_receipt": {"ref": f"sinnix://projects/fixture/beads/fixture-1/claims/{'b' * 64}", "owner_route": "beads.cli"}, "request_id": "2e46daf5-e9b1-4c6e-b99d-bcd46631730b", "assignment_ref": None}}, + { + "project_id": "fixture", + "checkout_id": "default", + "prompt": "private prompt", + "backend": "codex", + "model": "fixture", + "effort": "high", + "credential_profile": "subscription", + "timeout_seconds": 60, + "result": "last-message", + "bead_binding": { + "bead_ref": "sinnix://projects/fixture/beads/fixture-1", + "project_ref": "sinnix://projects/fixture", + "checkout_ref": "sinnix://projects/fixture/checkouts/default", + "task_revision": "a" * 64, + "task_etag": "b" * 64, + "claim_ref": f"sinnix://projects/fixture/beads/fixture-1/claims/{'b' * 64}", + "claim_receipt": { + "ref": f"sinnix://projects/fixture/beads/fixture-1/claims/{'b' * 64}", + "owner_route": "beads.cli", + }, + "request_id": "2e46daf5-e9b1-4c6e-b99d-bcd46631730b", + "assignment_ref": None, + }, + }, "agent-control", ) ) @@ -5207,27 +7476,51 @@ def test_typed_shell_and_agent_contracts_share_generic_job_lifecycle(tmp_path: P request( "job.agent.start", "systemd-jobs", - {"project_id": "fixture", "checkout_id": "default", "prompt": "operator prompt", "backend": "codex", "model": "fixture", "effort": "high", "credential_profile": "subscription", "timeout_seconds": 60, "result": "last-message"}, + { + "project_id": "fixture", + "checkout_id": "default", + "prompt": "operator prompt", + "backend": "codex", + "model": "fixture", + "effort": "high", + "credential_profile": "subscription", + "timeout_seconds": 60, + "result": "last-message", + }, "operator", ) ) assert shell.ok and agent.ok and operator_agent.ok - assert shell.payload is not None and agent.payload is not None and operator_agent.payload is not None + assert ( + shell.payload is not None + and agent.payload is not None + and operator_agent.payload is not None + ) shell_job = shell.payload.inline agent_job = agent.payload.inline assert shell_job["kind"] == "operator-shell" assert shell_job["principal"] == "operator" assert shell_job["contract"]["argv"]["executable"] == "printf" - shell_input = json.loads((tmp_path / "state" / "inputs" / f"{shell_job['job_id']}.json").read_text()) + shell_input = json.loads( + (tmp_path / "state" / "inputs" / f"{shell_job['job_id']}.json").read_text() + ) assert shell_input["environment_command"] == ["fixture-env", "--command"] assert agent_job["kind"] == "attested-agent" assert agent_job["principal"] == "agent-control" assert agent_job["contract"]["backend"] == "codex" - assert agent_job["contract"]["bead_binding"]["bead_ref"] == "sinnix://projects/fixture/beads/fixture-1" - assert agent_job["contract"]["bead_binding"]["request_id"] == "2e46daf5-e9b1-4c6e-b99d-bcd46631730b" + assert ( + agent_job["contract"]["bead_binding"]["bead_ref"] + == "sinnix://projects/fixture/beads/fixture-1" + ) + assert ( + agent_job["contract"]["bead_binding"]["request_id"] + == "2e46daf5-e9b1-4c6e-b99d-bcd46631730b" + ) assert agent_job["artifacts"]["result"]["max_bytes"] == 64_000 - persisted = (tmp_path / "state" / "jobs" / f"{agent_job['job_id']}.json").read_text() + persisted = ( + tmp_path / "state" / "jobs" / f"{agent_job['job_id']}.json" + ).read_text() assert "private prompt" not in persisted assert "shell-secret" not in persisted assert "display only" not in persisted @@ -5270,48 +7563,162 @@ def execute(executable: str, argv: list[str], environment: dict[str, str]) -> No checkout, ) - assert observed["cwd"] == workdir - assert observed["executable"] == "nix" - assert observed["argv"] == ["nix", "develop", "--command", "python", "-m", "fixture"] - - -def test_typed_contracts_refuse_spoofed_principals_checkout_backend_environment_and_results(tmp_path: Path) -> None: - write_adapter(tmp_path) - initialize_git_checkout(tmp_path) - runner = tmp_path / "native-runner" - native_runner(runner) - service = SinnixdService(ProjectCatalog([tmp_path]), jobs=generic_jobs(tmp_path), native_runner=runner) - shell_arguments = { - "project_id": "fixture", "checkout_id": "default", "argv": ["true"], "cwd": ".", "timeout_seconds": 60, "result": "exit-status" - } - agent_arguments = { - "project_id": "fixture", "checkout_id": "default", "prompt": "prompt", "backend": "codex", "model": "fixture", "effort": "high", "credential_profile": "subscription", "timeout_seconds": 60, "result": "last-message" - } - invalid_principal = service.dispatch(request("job.shell.start", "systemd-jobs", shell_arguments, "observer")) - invalid_checkout = service.dispatch(request("job.shell.start", "systemd-jobs", {**shell_arguments, "checkout_id": "absent"}, "operator")) - invalid_backend = service.dispatch(request("job.agent.start", "systemd-jobs", {**agent_arguments, "backend": "unknown"}, "agent-control")) - invalid_bead_binding = service.dispatch(request("job.agent.start", "systemd-jobs", {**agent_arguments, "bead_binding": {"bead_ref": "sinnix://projects/fixture/beads/fixture-1", "project_ref": "sinnix://projects/fixture", "checkout_ref": "sinnix://projects/fixture/checkouts/default", "task_revision": "a" * 64, "task_etag": "b" * 64, "claim_ref": "sinnix://projects/fixture/beads/other/claims/receipt", "claim_receipt": {"ref": "sinnix://projects/fixture/beads/other/claims/receipt"}, "request_id": "2e46daf5-e9b1-4c6e-b99d-bcd46631730b", "assignment_ref": None}}, "agent-control")) - legacy_work_item_binding = service.dispatch(request("job.agent.start", "systemd-jobs", {**agent_arguments, "bead_binding": {"bead_ref": "sinnix://projects/fixture/beads/fixture-1", "project_ref": "sinnix://projects/fixture", "checkout_ref": "sinnix://projects/fixture/checkouts/default", "task_revision": "a" * 64, "task_etag": "b" * 64, "claim_ref": None, "claim_receipt": None, "request_id": "2e46daf5-e9b1-4c6e-b99d-bcd46631730b", "assignment_ref": None, "work_item": "private task prose"}}, "agent-control")) - invalid_environment = service.dispatch(request("job.shell.start", "systemd-jobs", {**shell_arguments, "environment": {"SINNIXD_JOB_ID": "spoof"}}, "operator")) - invalid_result = service.dispatch(request("job.agent.start", "systemd-jobs", {**agent_arguments, "result": "exit-status"}, "agent-control")) - - for response in (invalid_principal, invalid_checkout, invalid_backend, invalid_bead_binding, legacy_work_item_binding, invalid_environment, invalid_result): + assert observed["cwd"] == workdir + assert observed["executable"] == "nix" + assert observed["argv"] == [ + "nix", + "develop", + "--command", + "python", + "-m", + "fixture", + ] + + +def test_typed_contracts_refuse_spoofed_principals_checkout_backend_environment_and_results( + tmp_path: Path, +) -> None: + write_adapter(tmp_path) + initialize_git_checkout(tmp_path) + runner = tmp_path / "native-runner" + native_runner(runner) + service = SinnixdService( + ProjectCatalog([tmp_path]), jobs=generic_jobs(tmp_path), native_runner=runner + ) + shell_arguments = { + "project_id": "fixture", + "checkout_id": "default", + "argv": ["true"], + "cwd": ".", + "timeout_seconds": 60, + "result": "exit-status", + } + agent_arguments = { + "project_id": "fixture", + "checkout_id": "default", + "prompt": "prompt", + "backend": "codex", + "model": "fixture", + "effort": "high", + "credential_profile": "subscription", + "timeout_seconds": 60, + "result": "last-message", + } + invalid_principal = service.dispatch( + request("job.shell.start", "systemd-jobs", shell_arguments, "observer") + ) + invalid_checkout = service.dispatch( + request( + "job.shell.start", + "systemd-jobs", + {**shell_arguments, "checkout_id": "absent"}, + "operator", + ) + ) + invalid_backend = service.dispatch( + request( + "job.agent.start", + "systemd-jobs", + {**agent_arguments, "backend": "unknown"}, + "agent-control", + ) + ) + invalid_bead_binding = service.dispatch( + request( + "job.agent.start", + "systemd-jobs", + { + **agent_arguments, + "bead_binding": { + "bead_ref": "sinnix://projects/fixture/beads/fixture-1", + "project_ref": "sinnix://projects/fixture", + "checkout_ref": "sinnix://projects/fixture/checkouts/default", + "task_revision": "a" * 64, + "task_etag": "b" * 64, + "claim_ref": "sinnix://projects/fixture/beads/other/claims/receipt", + "claim_receipt": { + "ref": "sinnix://projects/fixture/beads/other/claims/receipt" + }, + "request_id": "2e46daf5-e9b1-4c6e-b99d-bcd46631730b", + "assignment_ref": None, + }, + }, + "agent-control", + ) + ) + legacy_work_item_binding = service.dispatch( + request( + "job.agent.start", + "systemd-jobs", + { + **agent_arguments, + "bead_binding": { + "bead_ref": "sinnix://projects/fixture/beads/fixture-1", + "project_ref": "sinnix://projects/fixture", + "checkout_ref": "sinnix://projects/fixture/checkouts/default", + "task_revision": "a" * 64, + "task_etag": "b" * 64, + "claim_ref": None, + "claim_receipt": None, + "request_id": "2e46daf5-e9b1-4c6e-b99d-bcd46631730b", + "assignment_ref": None, + "work_item": "private task prose", + }, + }, + "agent-control", + ) + ) + invalid_environment = service.dispatch( + request( + "job.shell.start", + "systemd-jobs", + {**shell_arguments, "environment": {"SINNIXD_JOB_ID": "spoof"}}, + "operator", + ) + ) + invalid_result = service.dispatch( + request( + "job.agent.start", + "systemd-jobs", + {**agent_arguments, "result": "exit-status"}, + "agent-control", + ) + ) + + for response in ( + invalid_principal, + invalid_checkout, + invalid_backend, + invalid_bead_binding, + legacy_work_item_binding, + invalid_environment, + invalid_result, + ): assert response.error is not None assert response.error.code.value == "INVALID_ARGUMENT" -def test_failed_agent_launch_removes_private_prompt_and_contract_input(tmp_path: Path) -> None: +def test_failed_agent_launch_removes_private_prompt_and_contract_input( + tmp_path: Path, +) -> None: """Anti-vacuity: a rejected launch cannot leave prompt material in durable state.""" write_adapter(tmp_path) initialize_git_checkout(tmp_path) runner = tmp_path / "native-runner" native_runner(runner) + class ConfirmedAbsent(FakeSystemdJobs): def start(self, **kwargs) -> None: raise SystemdJobError("fixture launch rejected") - systemd = ConfirmedAbsent(properties={"LoadState": "not-found", "ActiveState": "inactive"}) - service = SinnixdService(ProjectCatalog([tmp_path]), jobs=generic_jobs(tmp_path, systemd), native_runner=runner) + systemd = ConfirmedAbsent( + properties={"LoadState": "not-found", "ActiveState": "inactive"} + ) + service = SinnixdService( + ProjectCatalog([tmp_path]), + jobs=generic_jobs(tmp_path, systemd), + native_runner=runner, + ) response = service.dispatch( request( @@ -5338,7 +7745,9 @@ def start(self, **kwargs) -> None: assert not list((tmp_path / "state" / "inputs").iterdir()) -def test_runner_rejects_changed_or_unregistered_checkout_identities(tmp_path: Path) -> None: +def test_runner_rejects_changed_or_unregistered_checkout_identities( + tmp_path: Path, +) -> None: write_adapter(tmp_path) initialize_git_checkout(tmp_path) checkout = ProjectCatalog([tmp_path]).checkout("fixture", "default").to_dict() @@ -5361,7 +7770,9 @@ def test_runner_rejects_changed_or_unregistered_checkout_identities(tmp_path: Pa _revalidate_checkout({**checkout, "path": str(loop)}) -def test_agent_runner_revalidates_checkout_and_writes_a_bounded_result_fixture(tmp_path: Path) -> None: +def test_agent_runner_revalidates_checkout_and_writes_a_bounded_result_fixture( + tmp_path: Path, +) -> None: """Anti-vacuity: the native runner only executes after Git identity and env checks pass.""" write_adapter(tmp_path) initialize_git_checkout(tmp_path) @@ -5402,7 +7813,21 @@ def test_agent_runner_revalidates_checkout_and_writes_a_bounded_result_fixture(t "RUNNER_ARGS": str(runner_arguments), } result = subprocess.run( - [sys.executable, "-m", "sinnixd.runner", "--input", str(input_path), "--job-id", payload["job_id"], "--unit", f"sinnixd-job-{payload['job_id']}.service", "--native-runner", str(runner), "--state-root", str(state)], + [ + sys.executable, + "-m", + "sinnixd.runner", + "--input", + str(input_path), + "--job-id", + payload["job_id"], + "--unit", + f"sinnixd-job-{payload['job_id']}.service", + "--native-runner", + str(runner), + "--state-root", + str(state), + ], env=environment, capture_output=True, text=True, @@ -5462,7 +7887,21 @@ def test_runner_rejects_forged_sinnix_environment(tmp_path: Path) -> None: } result = subprocess.run( - [sys.executable, "-m", "sinnixd.runner", "--input", str(input_path), "--job-id", job_id, "--unit", f"sinnixd-job-{job_id}.service", "--native-runner", str(runner), "--state-root", str(state)], + [ + sys.executable, + "-m", + "sinnixd.runner", + "--input", + str(input_path), + "--job-id", + job_id, + "--unit", + f"sinnixd-job-{job_id}.service", + "--native-runner", + str(runner), + "--state-root", + str(state), + ], env=environment, capture_output=True, text=True, @@ -5487,9 +7926,33 @@ def test_environment_builder_keeps_empty_values_distinct_from_unset() -> None: @pytest.mark.parametrize( ("properties", "expected"), [ - ({"LoadState": "loaded", "ActiveState": "inactive", "Result": "success", "ExecMainStatus": "0"}, "succeeded"), - ({"LoadState": "loaded", "ActiveState": "inactive", "Result": "timeout", "ExecMainStatus": "9"}, "timed_out"), - ({"LoadState": "loaded", "ActiveState": "failed", "Result": "exit-code", "ExecMainStatus": "1"}, "failed"), + ( + { + "LoadState": "loaded", + "ActiveState": "inactive", + "Result": "success", + "ExecMainStatus": "0", + }, + "succeeded", + ), + ( + { + "LoadState": "loaded", + "ActiveState": "inactive", + "Result": "timeout", + "ExecMainStatus": "9", + }, + "timed_out", + ), + ( + { + "LoadState": "loaded", + "ActiveState": "failed", + "Result": "exit-code", + "ExecMainStatus": "1", + }, + "failed", + ), ], ) def test_terminal_result_classification_comes_from_systemd( @@ -5510,10 +7973,17 @@ def test_terminal_result_classification_comes_from_systemd( assert status["state"]["terminal"] -def test_logs_are_bounded_and_restart_reconciles_the_same_record(tmp_path: Path) -> None: +def test_logs_are_bounded_and_restart_reconciles_the_same_record( + tmp_path: Path, +) -> None: """Anti-vacuity: deleting the persisted record or GenericJobs.logs breaks restart reads.""" systemd = FakeSystemdJobs( - properties={"LoadState": "loaded", "ActiveState": "inactive", "Result": "success", "ExecMainStatus": "0"} + properties={ + "LoadState": "loaded", + "ActiveState": "inactive", + "Result": "success", + "ExecMainStatus": "0", + } ) jobs = generic_jobs(tmp_path, systemd) started = jobs.start_foreground( @@ -5541,7 +8011,9 @@ def test_logs_are_bounded_and_restart_reconciles_the_same_record(tmp_path: Path) assert waited["state"]["phase"] == "succeeded" -def test_capture_caps_persistent_artifacts_and_reports_producer_overflow(tmp_path: Path) -> None: +def test_capture_caps_persistent_artifacts_and_reports_producer_overflow( + tmp_path: Path, +) -> None: """Anti-vacuity: delayed marker writes fail while the producer is still running.""" log_path = tmp_path / "overflow.log" overflow_path = tmp_path / "overflow.overflow" @@ -5552,7 +8024,18 @@ def test_capture_caps_persistent_artifacts_and_reports_producer_overflow(tmp_pat thread = threading.Thread( target=lambda: result.setdefault( "exit_code", - capture_main(("--log-path", str(log_path), "--overflow-path", str(overflow_path), "--max-bytes", "4", "--", *producer)), + capture_main( + ( + "--log-path", + str(log_path), + "--overflow-path", + str(overflow_path), + "--max-bytes", + "4", + "--", + *producer, + ) + ), ), daemon=True, ) @@ -5569,7 +8052,9 @@ def test_capture_caps_persistent_artifacts_and_reports_producer_overflow(tmp_pat assert log_path.stat().st_size == 4 jobs = generic_jobs(tmp_path) - started = jobs.start_foreground(command=("fixture",), working_directory=str(tmp_path), environment={}) + started = jobs.start_foreground( + command=("fixture",), working_directory=str(tmp_path), environment={} + ) record = jobs.store.load(started["job_id"]) record.log_path.write_bytes(b"0123") record.log_path.with_suffix(".overflow").touch() @@ -5580,16 +8065,22 @@ def test_capture_caps_persistent_artifacts_and_reports_producer_overflow(tmp_pat assert log["artifact_truncated"] -def test_logs_report_marker_created_during_read(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: +def test_logs_report_marker_created_during_read( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: """Anti-vacuity: sampling overflow before reading misses this interleaving.""" jobs = generic_jobs(tmp_path) - started = jobs.start_foreground(command=("fixture",), working_directory=str(tmp_path), environment={}) + started = jobs.start_foreground( + command=("fixture",), working_directory=str(tmp_path), environment={} + ) record = jobs.store.load(started["job_id"]) record.log_path.write_bytes(b"0123") overflow_path = record.log_path.with_suffix(".overflow") original_read = jobs_module._read_private_artifact - def marker_after_read(path: Path, max_bytes: int, *, offset: int = 0) -> bytes | None: + def marker_after_read( + path: Path, max_bytes: int, *, offset: int = 0 + ) -> bytes | None: content = original_read(path, max_bytes, offset=offset) if path == record.log_path: overflow_path.touch() @@ -5621,11 +8112,18 @@ def test_foreground_specs_redact_argv_and_environment_from_disk(tmp_path: Path) assert len(persisted["spec"]["command"]["digest"]) == 64 -def test_job_store_fsyncs_parent_after_replacing_record(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: +def test_job_store_fsyncs_parent_after_replacing_record( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: """Anti-vacuity: a file fsync before rename cannot make the renamed entry crash-durable.""" store = GenericJobStore(tmp_path / "state") record = store.create( - GenericJobSpec(kind="foreground-command", command=("fixture",), working_directory=str(tmp_path), environment={}), + GenericJobSpec( + kind="foreground-command", + command=("fixture",), + working_directory=str(tmp_path), + environment={}, + ), "00000000-0000-0000-0000-000000000001", ) directory_fd = 10_000 @@ -5641,7 +8139,12 @@ def tracked_open(path, flags, *args): return original_open(path, flags, *args) def tracked_fsync(descriptor: int) -> None: - events.append(("fsync-directory" if descriptor == directory_fd else "fsync-file", descriptor)) + events.append( + ( + "fsync-directory" if descriptor == directory_fd else "fsync-file", + descriptor, + ) + ) def tracked_close(descriptor: int) -> None: if descriptor == directory_fd: @@ -5660,12 +8163,21 @@ def tracked_replace(source, destination) -> None: store.save(record) - replace_index = events.index(("replace", store.records_root / f"{record.job_id}.json")) - file_fsync_index = max(index for index, event in enumerate(events[:replace_index]) if event[0] == "fsync-file") + replace_index = events.index( + ("replace", store.records_root / f"{record.job_id}.json") + ) + file_fsync_index = max( + index + for index, event in enumerate(events[:replace_index]) + if event[0] == "fsync-file" + ) directory_fsync_index = next( index - for index, event in enumerate(events[replace_index + 1 :], start=replace_index + 1) - if event == ("fsync-directory", directory_fd) and events[index - 1] == ("open-directory", store.records_root) + for index, event in enumerate( + events[replace_index + 1 :], start=replace_index + 1 + ) + if event == ("fsync-directory", directory_fd) + and events[index - 1] == ("open-directory", store.records_root) ) assert file_fsync_index < replace_index < directory_fsync_index assert events[directory_fsync_index - 1] == ("open-directory", store.records_root) @@ -5686,11 +8198,24 @@ def tracked_fsync_directory(path: Path) -> None: store = GenericJobStore(tmp_path / "state") store.create( - GenericJobSpec(kind="foreground-command", command=("fixture",), working_directory=str(tmp_path), environment={}), + GenericJobSpec( + kind="foreground-command", + command=("fixture",), + working_directory=str(tmp_path), + environment={}, + ), "00000000-0000-0000-0000-000000000002", ) - assert synchronized == [tmp_path, store.root, store.root, store.logs_root, store.root, store.root, store.records_root] + assert synchronized == [ + tmp_path, + store.root, + store.root, + store.logs_root, + store.root, + store.root, + store.records_root, + ] @pytest.mark.parametrize( @@ -5705,7 +8230,9 @@ def test_confirmed_absence_and_launch_failure_are_distinct_terminal_outcomes( """Anti-vacuity: post-launch loss, missing units, and launch failures have distinct terminal records.""" systemd = FakeSystemdJobs(properties=properties or {}) jobs = generic_jobs(tmp_path, systemd) - status = jobs.start_foreground(command=("fixture",), working_directory=str(tmp_path), environment={}) + status = jobs.start_foreground( + command=("fixture",), working_directory=str(tmp_path), environment={} + ) status = jobs.get(status["job_id"]) cancelled = jobs.cancel(status["job_id"]) waited = jobs.wait(status["job_id"], timeout_seconds=1) @@ -5716,7 +8243,9 @@ def test_confirmed_absence_and_launch_failure_are_distinct_terminal_outcomes( assert waited["state"]["phase"] == expected -def test_start_returns_systemd_state_when_accepted_reply_is_lost(tmp_path: Path) -> None: +def test_start_returns_systemd_state_when_accepted_reply_is_lost( + tmp_path: Path, +) -> None: """Anti-vacuity: an accepted transient unit must not become launch-failed when its reply is lost.""" secret = "accepted-but-reply-lost" @@ -5727,7 +8256,9 @@ def start(self, **kwargs) -> None: jobs = generic_jobs(tmp_path, ReplyLostAfterAccept()) - started = jobs.start_foreground(command=("fixture",), working_directory=str(tmp_path), environment={}) + started = jobs.start_foreground( + command=("fixture",), working_directory=str(tmp_path), environment={} + ) persisted = (tmp_path / "state" / "jobs" / f"{started['job_id']}.json").read_text() assert started["state"]["phase"] == "running" @@ -5735,7 +8266,9 @@ def start(self, **kwargs) -> None: assert secret not in persisted -def test_start_persists_launch_failed_only_when_systemd_confirms_absence(tmp_path: Path) -> None: +def test_start_persists_launch_failed_only_when_systemd_confirms_absence( + tmp_path: Path, +) -> None: """Anti-vacuity: a launch error alone is insufficient evidence that systemd rejected the unit.""" secret = "confirmed-absent-launch-error" @@ -5743,9 +8276,16 @@ class ConfirmedAbsent(FakeSystemdJobs): def start(self, **kwargs) -> None: raise SystemdJobError(secret) - jobs = generic_jobs(tmp_path, ConfirmedAbsent(properties={"LoadState": "not-found", "ActiveState": "inactive"})) + jobs = generic_jobs( + tmp_path, + ConfirmedAbsent( + properties={"LoadState": "not-found", "ActiveState": "inactive"} + ), + ) - result = jobs.start_foreground(command=("fixture",), working_directory=str(tmp_path), environment={}) + result = jobs.start_foreground( + command=("fixture",), working_directory=str(tmp_path), environment={} + ) record = jobs.store.load(result["job_id"]) persisted = (tmp_path / "state" / "jobs" / f"{record.job_id}.json").read_text() assert result["unit"] == record.unit @@ -5777,12 +8317,18 @@ def start(self, **kwargs) -> None: tmp_path, FailingShow() if mode == "observation-unknown" - else FailingStart(properties={"LoadState": "not-found", "ActiveState": "inactive"}), + else FailingStart( + properties={"LoadState": "not-found", "ActiveState": "inactive"} + ), ) if mode == "launch-failed": - status = jobs.start_foreground(command=("fixture",), working_directory=str(tmp_path), environment={}) + status = jobs.start_foreground( + command=("fixture",), working_directory=str(tmp_path), environment={} + ) else: - started = jobs.start_foreground(command=("fixture",), working_directory=str(tmp_path), environment={}) + started = jobs.start_foreground( + command=("fixture",), working_directory=str(tmp_path), environment={} + ) status = jobs.get(started["job_id"]) persisted = (tmp_path / "state" / "jobs" / f"{status['job_id']}.json").read_text() @@ -5794,8 +8340,11 @@ def start(self, **kwargs) -> None: assert '"message"' not in persisted -def test_launch_unknown_reconciles_to_observed_success_through_get_and_wait(tmp_path: Path) -> None: +def test_launch_unknown_reconciles_to_observed_success_through_get_and_wait( + tmp_path: Path, +) -> None: """Anti-vacuity: launch uncertainty must retain its identity until systemd later answers.""" + class ReplyAndFirstShowLost(FakeSystemdJobs): show_is_unavailable = True @@ -5815,7 +8364,9 @@ def show( systemd = ReplyAndFirstShowLost() jobs = generic_jobs(tmp_path, systemd) - uncertain = jobs.start_foreground(command=("fixture",), working_directory=str(tmp_path), environment={}) + uncertain = jobs.start_foreground( + command=("fixture",), working_directory=str(tmp_path), environment={} + ) persisted = jobs.store.load(uncertain["job_id"]) assert uncertain["unit"] == persisted.unit @@ -5824,7 +8375,11 @@ def show( assert not uncertain["state"]["terminal"] systemd.show_is_unavailable = False - systemd.properties = {"LoadState": "loaded", "ActiveState": "active", "Result": "success"} + systemd.properties = { + "LoadState": "loaded", + "ActiveState": "active", + "Result": "success", + } running = jobs.get(uncertain["job_id"]) systemd.properties = { "LoadState": "loaded", @@ -5845,6 +8400,7 @@ def test_launch_unknown_reconciles_to_launch_failed_when_systemd_confirms_absenc tmp_path: Path, ) -> None: """Anti-vacuity: unavailable reconciliation must not relabel confirmed absence as ordinary missing.""" + class ReplyAndFirstShowLost(FakeSystemdJobs): show_is_unavailable = True @@ -5863,7 +8419,9 @@ def show( systemd = ReplyAndFirstShowLost() jobs = generic_jobs(tmp_path, systemd) - uncertain = jobs.start_foreground(command=("fixture",), working_directory=str(tmp_path), environment={}) + uncertain = jobs.start_foreground( + command=("fixture",), working_directory=str(tmp_path), environment={} + ) systemd.show_is_unavailable = False systemd.properties = {"LoadState": "not-found", "ActiveState": "inactive"} @@ -5877,6 +8435,7 @@ def show( def test_launch_unknown_cancel_reconciles_the_same_job_id(tmp_path: Path) -> None: """Anti-vacuity: cancellation must operate on the durable uncertain launch record.""" + class ReplyAndFirstShowLost(FakeSystemdJobs): show_is_unavailable = True @@ -5894,10 +8453,16 @@ def show( return super().show(unit, timeout_seconds=timeout_seconds) systemd = ReplyAndFirstShowLost( - properties={"LoadState": "loaded", "ActiveState": "active", "InvocationID": "fixture-invocation"} + properties={ + "LoadState": "loaded", + "ActiveState": "active", + "InvocationID": "fixture-invocation", + } ) jobs = generic_jobs(tmp_path, systemd) - uncertain = jobs.start_foreground(command=("fixture",), working_directory=str(tmp_path), environment={}) + uncertain = jobs.start_foreground( + command=("fixture",), working_directory=str(tmp_path), environment={} + ) systemd.show_is_unavailable = False cancelled = jobs.cancel(uncertain["job_id"]) @@ -5932,10 +8497,17 @@ def show( raise SystemdJobError("manager unavailable") monkeypatch.setattr("sinnixd.jobs.time.monotonic", lambda: clock[0]) - monkeypatch.setattr("sinnixd.jobs.time.sleep", lambda seconds: clock.__setitem__(0, clock[0] + seconds)) + monkeypatch.setattr( + "sinnixd.jobs.time.sleep", + lambda seconds: clock.__setitem__(0, clock[0] + seconds), + ) systemd = UnavailableSystemd() - jobs = GenericJobs(systemd, GenericJobStore(tmp_path / "state"), wait_poll_seconds=0.1) - uncertain = jobs.start_foreground(command=("fixture",), working_directory=str(tmp_path), environment={}) + jobs = GenericJobs( + systemd, GenericJobStore(tmp_path / "state"), wait_poll_seconds=0.1 + ) + uncertain = jobs.start_foreground( + command=("fixture",), working_directory=str(tmp_path), environment={} + ) systemd.timeouts.clear() clock[0] = 0.0 @@ -5945,7 +8517,9 @@ def show( assert timed_out["state"]["phase"] == "observation-unknown" assert timed_out["wait_timed_out"] assert systemd.timeouts - assert all(0 < timeout <= SYSTEMD_COMMAND_TIMEOUT_SECONDS for timeout in systemd.timeouts) + assert all( + 0 < timeout <= SYSTEMD_COMMAND_TIMEOUT_SECONDS for timeout in systemd.timeouts + ) assert clock[0] == 1.0 @@ -5955,13 +8529,21 @@ def show( ({"Result": "success", "ExecMainStatus": "0"}, "succeeded"), ({"Result": "exit-code", "ExecMainStatus": "1"}, "failed"), ({"Result": "timeout", "ExecMainStatus": "9"}, "timed_out"), - ({"Result": "signal", "ExecMainStatus": "15", "InvocationID": "different-invocation"}, "failed"), + ( + { + "Result": "signal", + "ExecMainStatus": "15", + "InvocationID": "different-invocation", + }, + "failed", + ), ], ) def test_cancel_persists_intent_and_preserves_systemd_exit_races( tmp_path: Path, terminal: dict[str, str], expected: str ) -> None: """Anti-vacuity: intent-only cancellation would relabel these terminal systemd results.""" + class TerminalDuringStop(FakeSystemdJobs): def stop(self, unit: str) -> None: self.stopped.append(unit) @@ -5979,15 +8561,37 @@ def stop(self, unit: str) -> None: terminal_jobs = generic_jobs( tmp_path / expected, - TerminalDuringStop(properties={"LoadState": "loaded", "ActiveState": "active", "InvocationID": "fixture-invocation"}), + TerminalDuringStop( + properties={ + "LoadState": "loaded", + "ActiveState": "active", + "InvocationID": "fixture-invocation", + } + ), + ) + started = terminal_jobs.start_foreground( + command=("fixture",), working_directory=str(tmp_path), environment={} ) - started = terminal_jobs.start_foreground(command=("fixture",), working_directory=str(tmp_path), environment={}) cancelled = terminal_jobs.cancel(started["job_id"]) assert cancelled["state"]["phase"] == expected - assert terminal_jobs.store.load(started["job_id"]).cancel_stop_acknowledged_at is not None + assert ( + terminal_jobs.store.load(started["job_id"]).cancel_stop_acknowledged_at + is not None + ) - crashing = generic_jobs(tmp_path / "crash", StopFails(properties={"LoadState": "loaded", "ActiveState": "active", "InvocationID": "fixture-invocation"})) - started = crashing.start_foreground(command=("fixture",), working_directory=str(tmp_path), environment={}) + crashing = generic_jobs( + tmp_path / "crash", + StopFails( + properties={ + "LoadState": "loaded", + "ActiveState": "active", + "InvocationID": "fixture-invocation", + } + ), + ) + started = crashing.start_foreground( + command=("fixture",), working_directory=str(tmp_path), environment={} + ) with pytest.raises(SystemdJobError): crashing.cancel(started["job_id"]) record = crashing.store.load(started["job_id"]) @@ -5995,29 +8599,46 @@ def stop(self, unit: str) -> None: assert record.cancel_requested_invocation_id == "fixture-invocation" -def test_cancelled_missing_unit_distinguishes_acknowledged_and_ambiguous_stop(tmp_path: Path) -> None: +def test_cancelled_missing_unit_distinguishes_acknowledged_and_ambiguous_stop( + tmp_path: Path, +) -> None: """Anti-vacuity: cancellation intent alone must leave an absent unit retryable.""" + class CollectedDuringStop(FakeSystemdJobs): def stop(self, unit: str) -> None: self.stopped.append(unit) self.properties = {"LoadState": "not-found", "ActiveState": "inactive"} systemd = CollectedDuringStop( - properties={"LoadState": "loaded", "ActiveState": "active", "InvocationID": "fixture-invocation"} + properties={ + "LoadState": "loaded", + "ActiveState": "active", + "InvocationID": "fixture-invocation", + } ) jobs = generic_jobs(tmp_path / "acknowledged", systemd) - started = jobs.start_foreground(command=("fixture",), working_directory=str(tmp_path), environment={}) + started = jobs.start_foreground( + command=("fixture",), working_directory=str(tmp_path), environment={} + ) cancelled = jobs.cancel(started["job_id"]) assert cancelled["state"]["phase"] == "cancelled" assert cancelled["state"]["cancellation"]["invocation_id"] == "fixture-invocation" missing_systemd = FakeSystemdJobs( - properties={"LoadState": "loaded", "ActiveState": "active", "InvocationID": "fixture-invocation"} + properties={ + "LoadState": "loaded", + "ActiveState": "active", + "InvocationID": "fixture-invocation", + } ) missing_jobs = generic_jobs(tmp_path / "intent-only", missing_systemd) - started = missing_jobs.start_foreground(command=("fixture",), working_directory=str(tmp_path), environment={}) + started = missing_jobs.start_foreground( + command=("fixture",), working_directory=str(tmp_path), environment={} + ) record = missing_jobs.store.load(started["job_id"]) - missing_jobs.store.save(missing_jobs._with_cancel_intent(record, "fixture-invocation")) + missing_jobs.store.save( + missing_jobs._with_cancel_intent(record, "fixture-invocation") + ) missing_systemd.properties = {"LoadState": "not-found", "ActiveState": "inactive"} intent_only = missing_jobs.get(started["job_id"]) assert intent_only["state"]["phase"] == "outcome-unknown" @@ -6027,16 +8648,27 @@ class CrashAfterStopStore(GenericJobStore): crash_on_acknowledgement: bool = False def save(self, record) -> None: - if self.crash_on_acknowledgement and record.cancel_stop_acknowledged_at is not None: + if ( + self.crash_on_acknowledgement + and record.cancel_stop_acknowledged_at is not None + ): raise OSError("simulated daemon crash after systemd stop") super().save(record) crashing_systemd = CollectedDuringStop( - properties={"LoadState": "loaded", "ActiveState": "active", "InvocationID": "fixture-invocation"} + properties={ + "LoadState": "loaded", + "ActiveState": "active", + "InvocationID": "fixture-invocation", + } ) crashing_store = CrashAfterStopStore(tmp_path / "ack-crash" / "state") - crashing_jobs = GenericJobs(crashing_systemd, crashing_store, wait_poll_seconds=0.001) - started = crashing_jobs.start_foreground(command=("fixture",), working_directory=str(tmp_path), environment={}) + crashing_jobs = GenericJobs( + crashing_systemd, crashing_store, wait_poll_seconds=0.001 + ) + started = crashing_jobs.start_foreground( + command=("fixture",), working_directory=str(tmp_path), environment={} + ) crashing_store.crash_on_acknowledgement = True with pytest.raises(OSError, match="simulated daemon crash"): crashing_jobs.cancel(started["job_id"]) @@ -6053,7 +8685,13 @@ def test_agentctl_wait_returns_a_timed_out_envelope_past_control_timeout( ) -> None: """Anti-vacuity: the CLI must decode a normal timed-out wait response after control framing has expired.""" write_adapter(tmp_path) - systemd = FakeSystemdJobs(properties={"LoadState": "loaded", "ActiveState": "active", "InvocationID": "fixture-invocation"}) + systemd = FakeSystemdJobs( + properties={ + "LoadState": "loaded", + "ActiveState": "active", + "InvocationID": "fixture-invocation", + } + ) jobs = generic_jobs(tmp_path, systemd) service = SinnixdService(ProjectCatalog([tmp_path]), jobs=jobs) socket_path = tmp_path / "sinnixd.sock" @@ -6068,11 +8706,22 @@ def observe_wait_connection(*args, **kwargs) -> None: server._serve_wait_connection = observe_wait_connection # type: ignore[method-assign] thread = start_server(server, stop_event=stop_event) - started = jobs.start_foreground(command=("fixture",), working_directory=str(tmp_path), environment={}) + started = jobs.start_foreground( + command=("fixture",), working_directory=str(tmp_path), environment={} + ) monkeypatch.setattr( sys, "argv", - ["agentctl", "--socket", str(socket_path), "job", "wait", started["job_id"], "--timeout-seconds", "1"], + [ + "agentctl", + "--socket", + str(socket_path), + "job", + "wait", + started["job_id"], + "--timeout-seconds", + "1", + ], ) try: assert cli_module.main() == 0 @@ -6098,9 +8747,17 @@ def test_delivery_operations_have_truthful_bounded_response_timeouts() -> None: } assert CONTROL_OPERATION_RESPONSE_TIMEOUT_SECONDS == expected for operation, timeout in expected.items(): - assert _response_timeout_seconds(request(operation, "git-workspaces")) == timeout - assert _response_timeout_seconds(request("workspace.get", "git-workspaces")) == CONNECTION_TIMEOUT_SECONDS - assert _response_timeout_seconds(request("unknown.slow-effect", "git-workspaces")) == CONNECTION_TIMEOUT_SECONDS + assert ( + _response_timeout_seconds(request(operation, "git-workspaces")) == timeout + ) + assert ( + _response_timeout_seconds(request("workspace.get", "git-workspaces")) + == CONNECTION_TIMEOUT_SECONDS + ) + assert ( + _response_timeout_seconds(request("unknown.slow-effect", "git-workspaces")) + == CONNECTION_TIMEOUT_SECONDS + ) def test_slow_delivery_response_outlives_the_ordinary_control_deadline( @@ -6108,10 +8765,14 @@ def test_slow_delivery_response_outlives_the_ordinary_control_deadline( ) -> None: """A remote effect that is still running must not be reported as daemon loss.""" write_adapter(tmp_path) - service = SinnixdService(ProjectCatalog([tmp_path]), jobs=generic_jobs(tmp_path, FakeSystemdJobs())) + service = SinnixdService( + ProjectCatalog([tmp_path]), jobs=generic_jobs(tmp_path, FakeSystemdJobs()) + ) assert service.delivery is not None - def slow_publish(_workspace_id: str, _job_id: str, _title: str, _body: str) -> dict[str, object]: + def slow_publish( + _workspace_id: str, _job_id: str, _title: str, _body: str + ) -> dict[str, object]: time.sleep(0.1) return {"published": True, "publication_output": "https://github.test/pull/17"} @@ -6124,7 +8785,12 @@ def slow_publish(_workspace_id: str, _job_id: str, _title: str, _body: str) -> d publication = request( "workspace.publish", "git-workspaces", - {"workspace_id": "fixture", "job_id": "verified", "title": "Fixture", "body": "body"}, + { + "workspace_id": "fixture", + "job_id": "verified", + "title": "Fixture", + "body": "body", + }, "agent-control", ) try: @@ -6146,7 +8812,13 @@ def test_agentctl_wait_reports_capacity_exhaustion_while_all_wait_workers_are_oc ) -> None: """Anti-vacuity: raw framing cannot cover the agentctl capacity-error path.""" write_adapter(tmp_path) - systemd = FakeSystemdJobs(properties={"LoadState": "loaded", "ActiveState": "active", "InvocationID": "fixture-invocation"}) + systemd = FakeSystemdJobs( + properties={ + "LoadState": "loaded", + "ActiveState": "active", + "InvocationID": "fixture-invocation", + } + ) jobs = generic_jobs(tmp_path, systemd) wait_started = threading.Event() wait_lock = threading.Lock() @@ -6155,7 +8827,9 @@ def test_agentctl_wait_reports_capacity_exhaustion_while_all_wait_workers_are_oc service = SinnixdService(ProjectCatalog([tmp_path]), jobs=jobs) socket_path = tmp_path / "sinnixd.sock" stop_event = threading.Event() - server = UnixSocketServer(socket_path, service, connection_timeout_seconds=0.05, max_workers=8) + server = UnixSocketServer( + socket_path, service, connection_timeout_seconds=0.05, max_workers=8 + ) wait_timeouts: list[float | None] = [] original_wait_connection = server._serve_wait_connection @@ -6174,25 +8848,48 @@ def observe_wait_connection(*args, **kwargs) -> None: jobs.wait = counted_wait # type: ignore[method-assign] server._serve_wait_connection = observe_wait_connection # type: ignore[method-assign] thread = start_server(server, stop_event=stop_event) - started = jobs.start_foreground(command=("fixture",), working_directory=str(tmp_path), environment={}) + started = jobs.start_foreground( + command=("fixture",), working_directory=str(tmp_path), environment={} + ) job_id = started["job_id"] wait_results: list[dict[str, object]] = [] wait_errors: list[Exception] = [] def run_wait() -> None: try: - wait_results.append(call(socket_path, request("job.wait", "systemd-jobs", {"job_id": job_id, "timeout_seconds": 1}))) + wait_results.append( + call( + socket_path, + request( + "job.wait", + "systemd-jobs", + {"job_id": job_id, "timeout_seconds": 1}, + ), + ) + ) except Exception as error: wait_errors.append(error) - waiters = [threading.Thread(target=run_wait, daemon=True) for _ in range(server.wait_worker_count)] + waiters = [ + threading.Thread(target=run_wait, daemon=True) + for _ in range(server.wait_worker_count) + ] for waiter in waiters: waiter.start() assert wait_started.wait(timeout=1) monkeypatch.setattr( sys, "argv", - ["agentctl", "--socket", str(socket_path), "job", "wait", job_id, "--timeout-seconds", "1"], + [ + "agentctl", + "--socket", + str(socket_path), + "job", + "wait", + job_id, + "--timeout-seconds", + "1", + ], ) assert cli_module.main() == 1 capacity = json.loads(capsys.readouterr().out) @@ -6210,8 +8907,13 @@ def run_wait() -> None: assert all(not waiter.is_alive() for waiter in waiters) assert len(wait_results) == server.wait_worker_count assert not thread.is_alive() - assert wait_timeouts == [1 + WAIT_TRANSPORT_MARGIN_SECONDS] * server.wait_worker_count - assert all(result["payload"]["value"]["state"]["phase"] == "running" for result in wait_results) + assert ( + wait_timeouts == [1 + WAIT_TRANSPORT_MARGIN_SECONDS] * server.wait_worker_count + ) + assert all( + result["payload"]["value"]["state"]["phase"] == "running" + for result in wait_results + ) assert all(result["payload"]["value"]["wait_timed_out"] for result in wait_results) @@ -6219,18 +8921,35 @@ def test_job_rpc_get_list_wait_logs_and_cancel_share_one_record(tmp_path: Path) """Anti-vacuity: deleting any RPC route prevents its shared job ID from resolving.""" write_adapter(tmp_path) systemd = FakeSystemdJobs( - properties={"LoadState": "loaded", "ActiveState": "inactive", "Result": "success", "ExecMainStatus": "0"} + properties={ + "LoadState": "loaded", + "ActiveState": "inactive", + "Result": "success", + "ExecMainStatus": "0", + } + ) + service = SinnixdService( + ProjectCatalog([tmp_path]), jobs=generic_jobs(tmp_path, systemd) + ) + started = service.dispatch( + request( + "job.start", "systemd-jobs", {"project_id": "fixture", "operation": "check"} + ) ) - service = SinnixdService(ProjectCatalog([tmp_path]), jobs=generic_jobs(tmp_path, systemd)) - started = service.dispatch(request("job.start", "systemd-jobs", {"project_id": "fixture", "operation": "check"})) assert started.payload is not None job_id = started.payload.inline["job_id"] get = service.dispatch(request("job.get", "systemd-jobs", {"job_id": job_id})) listed = service.dispatch(request("job.list", "systemd-jobs", {"limit": 1})) - waited = service.dispatch(request("job.wait", "systemd-jobs", {"job_id": job_id, "timeout_seconds": 1})) - logs = service.dispatch(request("job.logs", "systemd-jobs", {"job_id": job_id, "max_bytes": 10})) - cancelled = service.dispatch(request("job.cancel", "systemd-jobs", {"job_id": job_id})) + waited = service.dispatch( + request("job.wait", "systemd-jobs", {"job_id": job_id, "timeout_seconds": 1}) + ) + logs = service.dispatch( + request("job.logs", "systemd-jobs", {"job_id": job_id, "max_bytes": 10}) + ) + cancelled = service.dispatch( + request("job.cancel", "systemd-jobs", {"job_id": job_id}) + ) assert all(response.ok for response in (get, listed, waited, logs, cancelled)) assert listed.payload is not None @@ -6294,10 +9013,17 @@ def start(principal: str) -> str: assert agent_list.ok and agent_list.payload is not None assert [row["job_id"] for row in agent_list.payload.inline["jobs"]] == [agent_job] assert service.dispatch( - request("job.get", "systemd-jobs", {"job_id": agent_job}, principal="agent-control") + request( + "job.get", "systemd-jobs", {"job_id": agent_job}, principal="agent-control" + ) ).ok agent_denied = service.dispatch( - request("job.get", "systemd-jobs", {"job_id": operator_jobs[0]}, principal="agent-control") + request( + "job.get", + "systemd-jobs", + {"job_id": operator_jobs[0]}, + principal="agent-control", + ) ) assert agent_denied.error is not None assert agent_denied.error.code is ErrorCode.POLICY_DENIED @@ -6420,15 +9146,24 @@ def test_real_user_systemd_service_cgroup_cancels_descendants(tmp_path: Path) -> """Anti-vacuity: this enters systemd-run/systemctl; replacing the launcher with a subprocess leaves the child alive.""" if shutil.which("systemd-run") is None or shutil.which("systemctl") is None: pytest.skip("systemd user tools are unavailable") - manager = subprocess.run(["systemctl", "--user", "show-environment"], capture_output=True, text=True, check=False) + manager = subprocess.run( + ["systemctl", "--user", "show-environment"], + capture_output=True, + text=True, + check=False, + ) if manager.returncode != 0: pytest.skip("a usable user systemd manager is unavailable") child_pid = tmp_path / "child.pid" script = tmp_path / "spawn-child.sh" - script.write_text("#!/bin/sh\nsleep 30 &\necho $! > \"$1\"\necho lifecycle-output\nwait\n") + script.write_text( + '#!/bin/sh\nsleep 30 &\necho $! > "$1"\necho lifecycle-output\nwait\n' + ) script.chmod(0o700) - jobs = GenericJobs(UserSystemdJobs(), GenericJobStore(tmp_path / "state"), wait_poll_seconds=0.05) + jobs = GenericJobs( + UserSystemdJobs(), GenericJobStore(tmp_path / "state"), wait_poll_seconds=0.05 + ) started: dict[str, object] | None = None try: started = jobs.start_foreground( @@ -6458,7 +9193,9 @@ def test_real_user_systemd_service_cgroup_cancels_descendants(tmp_path: Path) -> pass -def test_source_scoped_owner_adapter_is_registered_and_forwards_exact_response(tmp_path: Path) -> None: +def test_source_scoped_owner_adapter_is_registered_and_forwards_exact_response( + tmp_path: Path, +) -> None: write_owner_adapter(tmp_path) source = SourceBinding( source_ref=SinnixRef.parse("sinnix://polylogue/archive"), @@ -6500,15 +9237,21 @@ def test_owner_adapters_reject_duplicate_authority_namespaces(tmp_path: Path) -> write_owner_adapter(first) write_owner_adapter(second) descriptor = second / ".agentctl" / "project.toml" - descriptor.write_text(descriptor.read_text().replace('id = "fixture"', 'id = "second"')) + descriptor.write_text( + descriptor.read_text().replace('id = "fixture"', 'id = "second"') + ) with pytest.raises(ProjectConfigError, match="duplicate owner namespace"): SinnixdService(ProjectCatalog([first, second])) -def test_declared_owner_adapter_runs_fixed_command_and_enforces_source_binding(tmp_path: Path) -> None: +def test_declared_owner_adapter_runs_fixed_command_and_enforces_source_binding( + tmp_path: Path, +) -> None: write_owner_adapter(tmp_path) - project, adapter = ProjectCatalog([tmp_path]).owner_adapter("polylogue.archive.status") + project, adapter = ProjectCatalog([tmp_path]).owner_adapter( + "polylogue.archive.status" + ) source = SourceBinding( source_ref=SinnixRef.parse("sinnix://polylogue/archive"), generation="fixture-generation", @@ -6618,10 +9361,14 @@ def test_unix_socket_server_round_trips_the_common_envelope(tmp_path: Path) -> N assert response["payload"]["value"]["projects"] == 1 -def test_unix_socket_server_returns_json_rpc_errors_without_crashing(tmp_path: Path) -> None: +def test_unix_socket_server_returns_json_rpc_errors_without_crashing( + tmp_path: Path, +) -> None: write_adapter(tmp_path / "project") socket_path = tmp_path / "sinnixd.sock" - server = UnixSocketServer(socket_path, SinnixdService(ProjectCatalog([tmp_path / "project"]))) + server = UnixSocketServer( + socket_path, SinnixdService(ProjectCatalog([tmp_path / "project"])) + ) thread = start_server(server, once=True) with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection: @@ -6649,7 +9396,9 @@ def test_unix_socket_server_returns_json_rpc_errors_without_crashing(tmp_path: P } -def test_unix_socket_server_continues_after_malformed_and_stalled_clients(tmp_path: Path) -> None: +def test_unix_socket_server_continues_after_malformed_and_stalled_clients( + tmp_path: Path, +) -> None: write_adapter(tmp_path / "project") socket_path = tmp_path / "sinnixd.sock" server = UnixSocketServer( From 12645c41581ba4f84de90e24523bdf1f5f3d31e5 Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 25 Aug 2026 17:09:15 +0200 Subject: [PATCH 21/35] test(gateway): import legacy manifest schema from owner --- pkgs/sinnix-agent-gateway/test_parity.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/pkgs/sinnix-agent-gateway/test_parity.py b/pkgs/sinnix-agent-gateway/test_parity.py index 9671f2b1..842c215b 100644 --- a/pkgs/sinnix-agent-gateway/test_parity.py +++ b/pkgs/sinnix-agent-gateway/test_parity.py @@ -3,12 +3,8 @@ import json from pathlib import Path -from sinnix_agent_gateway.parity import ( - LEGACY_MANIFEST_SCHEMA, - PARITY_SCHEMA, - V2_MIGRATIONS, - legacy_parity_contract, -) +from sinnix_agent_gateway.legacy_manifest import LEGACY_MANIFEST_SCHEMA +from sinnix_agent_gateway.parity import PARITY_SCHEMA, V2_MIGRATIONS, legacy_parity_contract from sinnix_agent_gateway.registry import REGISTRY From 039b02fd2f6014ec310740eeb3379739f4c853ce Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 25 Aug 2026 17:16:59 +0200 Subject: [PATCH 22/35] style(gateway): format parity import --- pkgs/sinnix-agent-gateway/test_parity.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pkgs/sinnix-agent-gateway/test_parity.py b/pkgs/sinnix-agent-gateway/test_parity.py index 842c215b..17d5452c 100644 --- a/pkgs/sinnix-agent-gateway/test_parity.py +++ b/pkgs/sinnix-agent-gateway/test_parity.py @@ -4,7 +4,11 @@ from pathlib import Path from sinnix_agent_gateway.legacy_manifest import LEGACY_MANIFEST_SCHEMA -from sinnix_agent_gateway.parity import PARITY_SCHEMA, V2_MIGRATIONS, legacy_parity_contract +from sinnix_agent_gateway.parity import ( + PARITY_SCHEMA, + V2_MIGRATIONS, + legacy_parity_contract, +) from sinnix_agent_gateway.registry import REGISTRY From 4425cbd3727531f0d90a56175720c36df240b8fe Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 25 Aug 2026 17:25:22 +0200 Subject: [PATCH 23/35] fix(gateway): approve consolidated action catalog --- hosts/sinnix-prime/default.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hosts/sinnix-prime/default.nix b/hosts/sinnix-prime/default.nix index c88fa3b3..9739c165 100644 --- a/hosts/sinnix-prime/default.nix +++ b/hosts/sinnix-prime/default.nix @@ -65,7 +65,7 @@ ]; }; approvedManifestHash = "78d04f9a59828ec9c5283058b5b4f39f9fbdf4bc6ee2b2db22861221e51fc87e"; - approvedActionCatalogHash = "738942447310f3d10a9c09046c1572e1bc408f0f620bba88d4ee37de9182c06e"; + approvedActionCatalogHash = "1290616ae8088b94b609af056d1b7314989b216bdaad678423204b6bc47ff93e"; }; }; }; From 9a0d2fe1e2fa9b94496b39e4f2172a3392d630c0 Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 25 Aug 2026 17:36:41 +0200 Subject: [PATCH 24/35] fix(agentctl): close packet result hardening gaps --- pkgs/sinnixd/sinnixd/contracts.py | 2 +- pkgs/sinnixd/sinnixd/runner.py | 4 ++-- pkgs/sinnixd/test_service.py | 11 ++++++++++- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/pkgs/sinnixd/sinnixd/contracts.py b/pkgs/sinnixd/sinnixd/contracts.py index 236d6206..1722fcd6 100644 --- a/pkgs/sinnixd/sinnixd/contracts.py +++ b/pkgs/sinnixd/sinnixd/contracts.py @@ -290,7 +290,7 @@ def bead_binding( raise ContractError("agent bead binding is malformed") binding = dict(value) scope = binding.get("write_scope") - if scope is not None and ( + if "write_scope" in binding and ( not isinstance(scope, list) or not scope or len(scope) > 128 diff --git a/pkgs/sinnixd/sinnixd/runner.py b/pkgs/sinnixd/sinnixd/runner.py index 7c321599..1590523d 100644 --- a/pkgs/sinnixd/sinnixd/runner.py +++ b/pkgs/sinnixd/sinnixd/runner.py @@ -31,7 +31,7 @@ def _require_strings(value: Mapping[str, Any], fields: Sequence[str]) -> None: def _load(path: Path, job_id: str) -> dict[str, Any]: try: value = json.loads(path.read_text()) - except (OSError, json.JSONDecodeError) as error: + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error: raise RunnerError("private typed-job input is unavailable") from error if ( not isinstance(value, dict) @@ -221,7 +221,7 @@ def _seal_packet_result( raise RunnerError("packet result exceeds the artifact limit") raw = result_path.read_bytes() delivery = json.loads(raw) - except (OSError, json.JSONDecodeError) as error: + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error: raise RunnerError("packet worker result is unavailable or malformed") from error observed = subprocess.run( ["git", "-C", str(checkout), "rev-parse", "HEAD"], diff --git a/pkgs/sinnixd/test_service.py b/pkgs/sinnixd/test_service.py index 7dc1c91b..75972bea 100644 --- a/pkgs/sinnixd/test_service.py +++ b/pkgs/sinnixd/test_service.py @@ -6839,7 +6839,7 @@ def test_beads_bound_packet_and_exact_head_verifier_compose_into_delivery( ) ) - for scope in (["../outside"], [f"entry-{index}" for index in range(129)]): + for scope in (None, ["../outside"], [f"entry-{index}" for index in range(129)]): rejected = service.dispatch( request( "job.start", @@ -6945,6 +6945,15 @@ def test_packet_runner_seals_worker_report_to_runtime_observed_head( result_path, ) + result_path.write_bytes(b"\xff") + with pytest.raises(RunnerError, match="worker result") as caught: + _seal_packet_result( + {"job_id": "packet-job", "checkout": {"head": start_head}}, + tmp_path, + result_path, + ) + assert isinstance(caught.value.__cause__, UnicodeDecodeError) + def test_seal_output_composes_through_exact_head_into_delivery_validation( tmp_path: Path, From b12e0484e92c85f52c0a4b09f8a0282f5bbe9f2f Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 25 Aug 2026 16:45:05 +0200 Subject: [PATCH 25/35] fix(agentctl): bind agent jobs to project environments --- docs/sinnixd.md | 4 +- pkgs/sinnixd/sinnixd/contracts.py | 7 + pkgs/sinnixd/sinnixd/projects.py | 6 + pkgs/sinnixd/sinnixd/runner.py | 27 ++++ pkgs/sinnixd/test_service.py | 210 ++++++++++++++++++++++++++++++ 5 files changed, 252 insertions(+), 2 deletions(-) diff --git a/docs/sinnixd.md b/docs/sinnixd.md index e1dd0daf..333f728b 100644 --- a/docs/sinnixd.md +++ b/docs/sinnixd.md @@ -41,7 +41,7 @@ agentctl agent --project sinnix --checkout default --prompt-file ./prompt.md --b agentctl task create sinnix 'Follow-up title' --description 'Bounded task description.' --type task --priority 2 --label area:agentctl --parent sinnix-oy37 --dependency depends-on:sinnix-oy37.9 --request-id 2d8f1e3a-61d0-4d3c-bb9a-9e8baa3a5cac ``` -The service passes a declarative, non-empty `sinnix.services.sinnixd.projectRoots` list as repeated `--project-root` arguments. It defaults to the Sinnix, Polylogue, and Sinex roots. Sinnixd loads only those `.agentctl/project.toml` adapters and does not scan arbitrary directories. Each descriptor is schema-versioned, identifies its repository root markers, declares the execution environment, and publishes named operation metadata. +The service passes a declarative, non-empty `sinnix.services.sinnixd.projectRoots` list as repeated `--project-root` arguments. It defaults to the Sinnix, Polylogue, and Sinex roots. Sinnixd loads only those `.agentctl/project.toml` adapters and does not scan arbitrary directories. Each descriptor is schema-versioned, identifies its repository root markers, declares the execution environment, and publishes named operation metadata. An agent-capable environment may also declare `preflight`, a fixed command run inside the environment before the backend starts. `job start` accepts a project ID, one declared operation name, an optional workspace binding, and an optional JSON parameters object. It never accepts an arbitrary command. Its optional workspace binding launches in that registered checkout and durably records the checkout ID and exact starting HEAD, so later publication can reject stale verification. Declared operations and internal synthetic foreground commands construct the same durable generic-job spec, record, transient user `.service` launch, log artifact, reconciliation, wait, and cancellation route. A descriptor may set a bounded `timeout_seconds`; that becomes the transient service limit, rather than a caller-controlled duration. The typed attested-agent contract also accepts an optional validated Beads binding from the gateway. It is public durable provenance, not prompt material: canonical bead/project/checkout refs, launch task revision and etag, optional claim receipt, request ID, and display-only work item. The only additional public starts are the constrained typed contracts below. @@ -225,7 +225,7 @@ Both routes use the same UUID job ID, transient user service, cancellation, reco Delivery is a precondition of `workspace.publish` and `workspace.land`, not a caller-fed completion route. Ordinary delivery reads the exact-head declared verification job through `job.result`. Packet delivery additionally names the Beads-bound attested-agent job with `--packet-job`. The declared verification job receives the same immutable Beads identity and write-scope binding at dispatch; each job record independently freezes its checkout head, and the contract runner seals the worker's structured report to the Git head observed when the runner exits. Delivery requires the bindings to match, the later semantic verifier to succeed at that same final head, snapshots the packet's initial-to-final Git range, and checks the complete current base-to-head publication diff against the Beads-owned scope. It rejects dirty, divergent, stale, or out-of-scope publication work and repeats the complete precondition after push and after review inspection. The worker report can only tighten acceptance through bounded anti-vacuity, unresolved-work, delegation-visibility, exact deletion evidence, and evidence-only fields. Git owns paths, commits, and heads; the project verifier owns semantic success; GitHub branch protection owns required review state. Beads closure consumes the returned completion artifact and bead references in its own owner; wiring that external closure consumer is not implemented by Sinnixd. -Typed jobs accept no environment overlay. The daemon creates the `env -i` environment from the declared project environment and fixed `SINNIXD_*` identity fields. Immediately before execution, the contract runner verifies those fields, rechecks the exact registered project, canonical worktree root, common Git directory, porcelain worktree membership, and recorded HEAD. A changed, missing, symlinked, or spoofed identity fails closed. Agent handoff includes `--registered-project`, `--expected-git-common-dir`, and the canonical checkout path; nested scope creation remains disabled, so the native runner provides backend execution and native attestation while the shared transient user service remains the sole process, cgroup, timeout, and cancellation authority. Private launch inputs are mode 0600, removed before shell execution, and removed after agent handoff or every terminal lifecycle outcome, including confirmed launch failure. Native private logs are removed after handoff; only the bounded shared log and result artifacts remain addressable. +Typed jobs accept no environment overlay. The daemon creates the `env -i` environment from the declared project environment and fixed `SINNIXD_*` identity fields. Immediately before execution, the contract runner verifies those fields, rechecks the exact registered project, canonical worktree root, common Git directory, porcelain worktree membership, and recorded HEAD. A changed, missing, symlinked, or spoofed identity fails closed. An attested agent runs the descriptor-owned `environment.preflight` from the revalidated checkout, then invokes the native backend through the same descriptor-owned `environment.command`; a missing or failed preflight terminates before backend implementation starts with an actionable bounded error. Agent handoff includes the registered project, expected common Git directory, and canonical checkout path; nested scope creation remains disabled, while the shared transient user service remains the sole process, cgroup, timeout, and cancellation authority. Private launch inputs are mode 0600 and removed after handoff or every terminal lifecycle outcome. Native private logs are removed after handoff; only the bounded shared log and result artifacts remain addressable. Each record is stored under `$XDG_STATE_HOME/sinnixd` and contains safe operation identity, environment key names, and its bounded-read log artifact path. Record replacement fsyncs the containing directory, and newly created state directories are synchronized before they contain durable evidence. The `sinnixd-job-*.service` dynamic runtime surface and its record capture lane are declared with the daemon, rather than with any MCP frontend. Internal foreground argv is launch-only: the durable record has only a SHA-256 digest and constant display metadata, never raw argv or environment values. The systemd-launched capture helper drains output but writes at most 1 MiB per job; it creates its overflow marker with the first discarded byte, so a live log reader can see truncation before the producer exits. It also fsyncs a completion marker only after the captured process exits successfully and all bounded outputs are durable. It does not own a PID, process state, queue, task, workspace, or retry policy. A job ID deterministically derives its unit name. Every `systemd-run` and `systemctl` call has a short finite bound. `job.wait` caps each reconciliation call to its remaining deadline, so a stalled user manager cannot hold a wait or reserved control worker indefinitely. After a daemon restart, `get`, `list`, `wait`, and `cancel` reload the record and reconcile with the user manager. If `systemd-run` loses its reply but `show` finds the transient unit, `job start` returns the reconciled systemd state. If both the launch reply and its first reconciliation are unavailable, `job start` returns a durable nonterminal `launch-unknown` result with the stable job ID and unit. Later `get`, `wait`, and `cancel` use that same identity to reconcile it. A confirmed absent launch becomes terminal `launch-failed`. A confirmed missing unit after launch remains terminal `missing`; an unreachable or timed-out systemd observation is durable nonterminal `observation-unknown` until a later observation repairs it. Cancellation persists its intent before asking systemd to stop the service, then preserves an observed systemd success, timeout, or failure result. A `cancelled` result needs matching systemd signal evidence, or a durably recorded successful stop acknowledgement for the observed invocation when systemd has already garbage-collected the transient unit. If a stop times out and the unit later disappears, the job remains nonterminal `outcome-unknown` instead of treating the missing unit's default success fields as an exit result. A later authoritative systemd observation can repair that state. A typed result can prove semantic success after collection only when its content is valid and the capture completion marker proves the producer exited successfully; an empty, partial, malformed, or unmarked result is not completion evidence. A schema-v3 attested-agent record also carries forward its native completion only when systemd still reports an inactive loaded success, its durable lifecycle is `succeeded` with exit status zero, its bounded last-message artifact is valid, and no cancellation intent exists. Existing false terminal success or cancellation records without this evidence are reopened lazily by `get`, `list`, `wait`, or `cancel` and reconciled under the same rules. Systemd remains authoritative for the process, cgroup, timeout, terminal result, cancellation, and journal evidence. diff --git a/pkgs/sinnixd/sinnixd/contracts.py b/pkgs/sinnixd/sinnixd/contracts.py index 1722fcd6..b65c4863 100644 --- a/pkgs/sinnixd/sinnixd/contracts.py +++ b/pkgs/sinnixd/sinnixd/contracts.py @@ -149,6 +149,11 @@ def start_agent( ): raise ContractError("native agent runner is unavailable") checkout = self.projects.checkout(project_id, checkout_id) + project = self.projects.get(project_id) + if not project.environment.preflight: + raise ContractError( + f"project {project_id} does not declare an agent environment preflight" + ) binding = self.bead_binding(bead_binding, checkout) job_id = str(uuid4()) prompt_path = self.inputs_root / f"{job_id}.prompt" @@ -170,6 +175,8 @@ def start_agent( "kind": "attested-agent", "principal": principal, "checkout": checkout.to_dict(), + "environment_command": list(project.environment.command), + "environment_preflight": list(project.environment.preflight), "backend": backend, "model": model, "effort": effort, diff --git a/pkgs/sinnixd/sinnixd/projects.py b/pkgs/sinnixd/sinnixd/projects.py index d173e95b..eb44552c 100644 --- a/pkgs/sinnixd/sinnixd/projects.py +++ b/pkgs/sinnixd/sinnixd/projects.py @@ -228,6 +228,7 @@ class ProjectEnvironment: command: tuple[str, ...] inherit: tuple[str, ...] unset: tuple[str, ...] + preflight: tuple[str, ...] = () def values(self) -> dict[str, str]: return build_environment(inherit=self.inherit, unset=self.unset) @@ -943,6 +944,11 @@ def load_project_adapter(root: Path) -> ProjectAdapter: environment.get("inherit"), "environment.inherit" ), unset=_optional_string_list(environment.get("unset"), "environment.unset"), + preflight=( + _string_list(environment["preflight"], "environment.preflight") + if "preflight" in environment + else () + ), ) raw_workspace = raw.get("workspace") diff --git a/pkgs/sinnixd/sinnixd/runner.py b/pkgs/sinnixd/sinnixd/runner.py index 1590523d..ea47ec82 100644 --- a/pkgs/sinnixd/sinnixd/runner.py +++ b/pkgs/sinnixd/sinnixd/runner.py @@ -183,7 +183,34 @@ def _run_agent( raise RunnerError("attested agent result artifact is invalid") if not native_runner.is_file() or not os.access(native_runner, os.X_OK): raise RunnerError("native agent runner is unavailable") + environment_command = value.get("environment_command") + environment_preflight = value.get("environment_preflight") + if not isinstance(environment_command, list) or not environment_command or any( + not isinstance(item, str) or not item for item in environment_command + ): + raise RunnerError( + "typed agent project environment is missing; declare a non-empty environment.command" + ) + if not isinstance(environment_preflight, list) or not environment_preflight or any( + not isinstance(item, str) or not item for item in environment_preflight + ): + raise RunnerError( + "typed agent project environment is missing; declare a non-empty environment.preflight" + ) + preflight_command = [*environment_command, *environment_preflight] + try: + preflight = subprocess.run(preflight_command, cwd=checkout, check=False) + except OSError as error: + raise RunnerError( + "project environment preflight is unavailable; repair environment.command and retry" + ) from error + if preflight.returncode != 0: + raise RunnerError( + "project environment preflight failed before agent implementation " + f"(exit status {preflight.returncode}); repair the declared environment and retry" + ) command = [ + *environment_command, str(native_runner), "--agent", value["backend"], diff --git a/pkgs/sinnixd/test_service.py b/pkgs/sinnixd/test_service.py index 75972bea..758f1e9f 100644 --- a/pkgs/sinnixd/test_service.py +++ b/pkgs/sinnixd/test_service.py @@ -74,6 +74,7 @@ _exec_shell, _require_environment, _revalidate_checkout, + _run_agent, _run_declared, _seal_packet_result, ) @@ -724,6 +725,7 @@ def write_adapter(root: Path, *, project_id: str = "fixture") -> None: [environment] kind = "fixture" command = ["fixture-env", "--command"] +preflight = ["devtools", "status", "--stderr"] inherit = ["HOME"] unset = ["PYTHONPATH"] @@ -7584,6 +7586,210 @@ def execute(executable: str, argv: list[str], environment: dict[str, str]) -> No ] +def test_agent_production_route_binds_environment_to_assigned_checkout_and_interpreter( + tmp_path: Path, +) -> None: + """A real typed input reaches the contract runner before the native backend.""" + write_adapter(tmp_path) + environment = tmp_path / "fixture-environment" + environment.write_text( + "#!/bin/sh\n" + "set -eu\n" + "printf 'entered\\n' >> environment.calls\n" + "export PATH=\"$PWD/.venv/bin:/run/current-system/sw/bin\"\n" + "export PYTHONPATH=\"$PWD\"\n" + "exec \"$@\"\n" + ) + environment.chmod(0o700) + descriptor = tmp_path / ".agentctl" / "project.toml" + descriptor.write_text( + descriptor.read_text().replace( + 'command = ["fixture-env", "--command"]', + f'command = ["{environment}"]', + ) + ) + (tmp_path / ".venv" / "bin").mkdir(parents=True) + (tmp_path / ".venv" / "bin" / "python").symlink_to(sys.executable) + (tmp_path / "devtools").mkdir() + (tmp_path / "devtools" / "__init__.py").write_text("") + (tmp_path / "devtools" / "__main__.py").write_text( + "from pathlib import Path\n" + "import sys\n" + "with Path('devtools.calls').open('a') as handle:\n" + " handle.write(' '.join(sys.argv[1:]) + '\\n')\n" + ) + (tmp_path / "polylogue").mkdir() + (tmp_path / "polylogue" / "__init__.py").write_text("CHECKOUT = __file__\n") + devtools = tmp_path / ".venv" / "bin" / "devtools" + devtools.write_text("#!/bin/sh\nexec python -m devtools \"$@\"\n") + devtools.chmod(0o700) + initialize_git_checkout(tmp_path) + + native = tmp_path / "native-runner" + native.write_text( + "#!/bin/sh\n" + "set -eu\n" + "last=\n" + "while [ $# -gt 0 ]; do\n" + " case $1 in --last-file) last=$2; shift 2 ;; *) shift ;; esac\n" + "done\n" + "test \"$(command -v devtools)\" = \"$PWD/.venv/bin/devtools\"\n" + "devtools status\n" + "devtools test tests/fixture.py::test_noop\n" + "devtools verify --quick\n" + "python -c 'import polylogue; assert polylogue.CHECKOUT'\n" + "printf native-started > native.started\n" + "printf native-result > \"$last\"\n" + ) + native.chmod(0o700) + + systemd = FakeSystemdJobs() + service = SinnixdService(ProjectCatalog([tmp_path]), jobs=generic_jobs(tmp_path, systemd), native_runner=native) + response = service.dispatch( + request( + "job.agent.start", + "systemd-jobs", + { + "project_id": "fixture", + "checkout_id": "default", + "prompt": "fixture prompt", + "backend": "codex", + "model": "fixture", + "effort": "high", + "credential_profile": "subscription", + "timeout_seconds": 60, + "result": "last-message", + }, + "agent-control", + ) + ) + assert response.ok and response.payload is not None + job_id = response.payload.inline["job_id"] + launch = systemd.started[0] + private = json.loads((tmp_path / "state" / "inputs" / f"{job_id}.json").read_text()) + assert private["environment_command"] == [str(environment)] + assert private["environment_preflight"] == ["devtools", "status", "--stderr"] + + poisoned = {str(key): str(value) for key, value in launch["environment"].items()} + poisoned["PATH"] = ":".join( + [str(tmp_path / "another-checkout" / ".venv" / "bin"), poisoned["PATH"]] + ) + poisoned["PYTHONPATH"] = os.environ.get("PYTHONPATH", "") + mutant = subprocess.run( + [str(native), "--last-file", str(tmp_path / "state" / "results" / "mutant.result")], + cwd=tmp_path, + env=poisoned, + capture_output=True, + text=True, + check=False, + ) + assert mutant.returncode != 0 + assert not (tmp_path / "native.started").exists() + result = subprocess.run( + [ + sys.executable, + "-m", + "sinnixd.runner", + "--input", + str(tmp_path / "state" / "inputs" / f"{job_id}.json"), + "--job-id", + job_id, + "--unit", + f"sinnixd-job-{job_id}.service", + "--native-runner", + str(native), + "--state-root", + str(tmp_path / "state"), + ], + cwd=Path.cwd(), + env=poisoned, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr + assert (tmp_path / "native.started").read_text() == "native-started" + assert (tmp_path / "state" / "results" / f"{job_id}.result").read_text() == "native-result" + assert (tmp_path / "environment.calls").read_text().splitlines() == ["entered", "entered"] + assert (tmp_path / "devtools.calls").read_text().splitlines() == [ + "status --stderr", + "status", + "test tests/fixture.py::test_noop", + "verify --quick", + ] + + +def test_agent_environment_preflight_refuses_missing_declaration_before_launch(tmp_path: Path) -> None: + write_adapter(tmp_path) + descriptor = tmp_path / ".agentctl" / "project.toml" + descriptor.write_text(descriptor.read_text().replace('preflight = ["devtools", "status", "--stderr"]\n', "")) + initialize_git_checkout(tmp_path) + runner = tmp_path / "native-runner" + native_runner(runner) + systemd = FakeSystemdJobs() + service = SinnixdService(ProjectCatalog([tmp_path]), jobs=generic_jobs(tmp_path, systemd), native_runner=runner) + + response = service.dispatch( + request( + "job.agent.start", + "systemd-jobs", + { + "project_id": "fixture", + "checkout_id": "default", + "prompt": "prompt", + "backend": "codex", + "model": "fixture", + "effort": "high", + "credential_profile": "subscription", + "timeout_seconds": 60, + "result": "last-message", + }, + "agent-control", + ) + ) + assert not response.ok + assert response.error is not None + assert "agent environment preflight" in response.error.message + assert systemd.started == [] + + +def test_agent_environment_preflight_refuses_corrupt_environment_before_native_runner(tmp_path: Path) -> None: + write_adapter(tmp_path) + initialize_git_checkout(tmp_path) + runner = tmp_path / "native-runner" + native_runner(runner) + state = tmp_path / "state" + inputs = state / "inputs" + results = state / "results" + inputs.mkdir(parents=True) + results.mkdir() + prompt = inputs / "fixture.prompt" + prompt.write_text("prompt") + job_id = "11111111-1111-1111-1111-111111111111" + payload = { + "schema_version": 1, + "job_id": job_id, + "kind": "attested-agent", + "principal": "agent-control", + "checkout": ProjectCatalog([tmp_path]).checkout("fixture", "default").to_dict(), + "environment_command": [sys.executable, "-c", "raise SystemExit(17)"], + "environment_preflight": ["fixture-preflight"], + "backend": "codex", + "model": "fixture", + "effort": "high", + "credential_profile": "subscription", + "prompt_path": str(prompt), + "result_path": str(results / "fixture.result"), + } + + with pytest.raises( + RunnerError, + match="project environment preflight failed before agent implementation.*17", + ): + _run_agent(payload, tmp_path, native_runner=runner, state_root=state) + assert not (results / "fixture.result").exists() + + def test_typed_contracts_refuse_spoofed_principals_checkout_backend_environment_and_results( tmp_path: Path, ) -> None: @@ -7803,6 +8009,8 @@ def test_agent_runner_revalidates_checkout_and_writes_a_bounded_result_fixture( "kind": "attested-agent", "principal": "agent-control", "checkout": checkout.to_dict(), + "environment_command": ["env"], + "environment_preflight": ["true"], "backend": "codex", "model": "fixture", "effort": "high", @@ -7876,6 +8084,8 @@ def test_runner_rejects_forged_sinnix_environment(tmp_path: Path) -> None: "kind": "attested-agent", "principal": "agent-control", "checkout": checkout.to_dict(), + "environment_command": ["env"], + "environment_preflight": ["true"], "backend": "codex", "model": "fixture", "effort": "high", From 34e81287843b06c6f6d7e9d1b71324dd29ada715 Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 25 Aug 2026 17:27:07 +0200 Subject: [PATCH 26/35] fix(agentctl): gate agent environment rollout --- .agentctl/project.toml | 1 + docs/sinnixd.md | 6 +- flake/command-registry.nix | 19 +++++ flake/dev-shell.nix | 2 + pkgs/sinnixd/pyproject.toml | 1 + pkgs/sinnixd/sinnixd/contracts.py | 2 +- pkgs/sinnixd/sinnixd/projects.py | 55 +++++++++++++ pkgs/sinnixd/sinnixd/runner.py | 57 ++++++++------ pkgs/sinnixd/test_service.py | 124 ++++++++++++++++++++++++++---- 9 files changed, 225 insertions(+), 42 deletions(-) diff --git a/.agentctl/project.toml b/.agentctl/project.toml index bd23ec7c..158460cb 100644 --- a/.agentctl/project.toml +++ b/.agentctl/project.toml @@ -8,6 +8,7 @@ root_markers = ["flake.nix", "modules", "dots"] [environment] kind = "nix-develop" command = ["nix", "develop", "--accept-flake-config", "--command"] +preflight = ["true"] inherit = ["HOME", "USER", "LANG", "TERM", "SSH_AUTH_SOCK", "XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS", "WAYLAND_DISPLAY", "DISPLAY"] unset = ["PYTHONPATH", "PYTHONHOME", "VIRTUAL_ENV", "_PYTHON_SYSCONFIGDATA_NAME", "_PYTHON_HOST_PLATFORM", "PYTHONPYCACHEPREFIX"] diff --git a/docs/sinnixd.md b/docs/sinnixd.md index 333f728b..f2e6823c 100644 --- a/docs/sinnixd.md +++ b/docs/sinnixd.md @@ -41,7 +41,9 @@ agentctl agent --project sinnix --checkout default --prompt-file ./prompt.md --b agentctl task create sinnix 'Follow-up title' --description 'Bounded task description.' --type task --priority 2 --label area:agentctl --parent sinnix-oy37 --dependency depends-on:sinnix-oy37.9 --request-id 2d8f1e3a-61d0-4d3c-bb9a-9e8baa3a5cac ``` -The service passes a declarative, non-empty `sinnix.services.sinnixd.projectRoots` list as repeated `--project-root` arguments. It defaults to the Sinnix, Polylogue, and Sinex roots. Sinnixd loads only those `.agentctl/project.toml` adapters and does not scan arbitrary directories. Each descriptor is schema-versioned, identifies its repository root markers, declares the execution environment, and publishes named operation metadata. An agent-capable environment may also declare `preflight`, a fixed command run inside the environment before the backend starts. +The service passes a declarative, non-empty `sinnix.services.sinnixd.projectRoots` list as repeated `--project-root` arguments. It defaults to the registered Sinnix project entries. Sinnixd loads only those `.agentctl/project.toml` adapters and does not scan arbitrary directories. Each descriptor is schema-versioned, identifies its repository root markers, declares the execution environment, and publishes named operation metadata. A descriptor with a `[workspace]` is agent-capable and must declare non-empty shell-free argv lists for both `environment.command` and `environment.preflight`; the latter runs inside the former from the revalidated checkout before any backend starts. `agentctl project get ` publishes that capability and both public argv lists. + +Before `switch`, `boot`, or the devshell rebuild wrapper evaluates the configured `sinnix.services.sinnixd.projectRoots` and runs `sinnixd-project-environment-check` against the real descriptors. It reports every project that is missing or has an invalid required declaration and refuses the activation before the NixOS build. Roll out descriptor commits first, then this Sinnix commit. The minimum exact declaration for every external agent-capable descriptor is `preflight = ["true"]` under `[environment]`; a project may use a stronger cheap project-native readiness argv instead. The current external prerequisites are Polylogue, Sinex, and Lynchpin; do not switch this commit until all three descriptors satisfy that contract. `job start` accepts a project ID, one declared operation name, an optional workspace binding, and an optional JSON parameters object. It never accepts an arbitrary command. Its optional workspace binding launches in that registered checkout and durably records the checkout ID and exact starting HEAD, so later publication can reject stale verification. Declared operations and internal synthetic foreground commands construct the same durable generic-job spec, record, transient user `.service` launch, log artifact, reconciliation, wait, and cancellation route. A descriptor may set a bounded `timeout_seconds`; that becomes the transient service limit, rather than a caller-controlled duration. The typed attested-agent contract also accepts an optional validated Beads binding from the gateway. It is public durable provenance, not prompt material: canonical bead/project/checkout refs, launch task revision and etag, optional claim receipt, request ID, and display-only work item. The only additional public starts are the constrained typed contracts below. @@ -225,7 +227,7 @@ Both routes use the same UUID job ID, transient user service, cancellation, reco Delivery is a precondition of `workspace.publish` and `workspace.land`, not a caller-fed completion route. Ordinary delivery reads the exact-head declared verification job through `job.result`. Packet delivery additionally names the Beads-bound attested-agent job with `--packet-job`. The declared verification job receives the same immutable Beads identity and write-scope binding at dispatch; each job record independently freezes its checkout head, and the contract runner seals the worker's structured report to the Git head observed when the runner exits. Delivery requires the bindings to match, the later semantic verifier to succeed at that same final head, snapshots the packet's initial-to-final Git range, and checks the complete current base-to-head publication diff against the Beads-owned scope. It rejects dirty, divergent, stale, or out-of-scope publication work and repeats the complete precondition after push and after review inspection. The worker report can only tighten acceptance through bounded anti-vacuity, unresolved-work, delegation-visibility, exact deletion evidence, and evidence-only fields. Git owns paths, commits, and heads; the project verifier owns semantic success; GitHub branch protection owns required review state. Beads closure consumes the returned completion artifact and bead references in its own owner; wiring that external closure consumer is not implemented by Sinnixd. -Typed jobs accept no environment overlay. The daemon creates the `env -i` environment from the declared project environment and fixed `SINNIXD_*` identity fields. Immediately before execution, the contract runner verifies those fields, rechecks the exact registered project, canonical worktree root, common Git directory, porcelain worktree membership, and recorded HEAD. A changed, missing, symlinked, or spoofed identity fails closed. An attested agent runs the descriptor-owned `environment.preflight` from the revalidated checkout, then invokes the native backend through the same descriptor-owned `environment.command`; a missing or failed preflight terminates before backend implementation starts with an actionable bounded error. Agent handoff includes the registered project, expected common Git directory, and canonical checkout path; nested scope creation remains disabled, while the shared transient user service remains the sole process, cgroup, timeout, and cancellation authority. Private launch inputs are mode 0600 and removed after handoff or every terminal lifecycle outcome. Native private logs are removed after handoff; only the bounded shared log and result artifacts remain addressable. +Typed jobs accept no environment overlay. The daemon creates the `env -i` environment from the declared project environment and fixed `SINNIXD_*` identity fields. Immediately before execution, the contract runner verifies those fields, rechecks the exact registered project, canonical worktree root, common Git directory, porcelain worktree membership, and recorded HEAD. A changed, missing, symlinked, or spoofed identity fails closed. Every attested agent runs its mandatory environment `preflight` from the revalidated checkout, then invokes the native backend through the same descriptor-owned `environment.command`. A missing, failed, unavailable, or 30-second `agent-preflight-timeout` preflight terminates the typed job before backend implementation starts and retains an actionable runner error in the bounded log. Attested-agent private inputs use schema v2; v1 records fail closed as stale contract input and must be relaunched. The shared transient user service remains the sole process, cgroup, timeout, and cancellation authority. Private launch inputs are mode 0600, removed before shell execution, and removed after handoff or every terminal lifecycle outcome, including confirmed launch failure. Native private logs are removed after handoff; only the bounded shared log and result artifacts remain addressable. Each record is stored under `$XDG_STATE_HOME/sinnixd` and contains safe operation identity, environment key names, and its bounded-read log artifact path. Record replacement fsyncs the containing directory, and newly created state directories are synchronized before they contain durable evidence. The `sinnixd-job-*.service` dynamic runtime surface and its record capture lane are declared with the daemon, rather than with any MCP frontend. Internal foreground argv is launch-only: the durable record has only a SHA-256 digest and constant display metadata, never raw argv or environment values. The systemd-launched capture helper drains output but writes at most 1 MiB per job; it creates its overflow marker with the first discarded byte, so a live log reader can see truncation before the producer exits. It also fsyncs a completion marker only after the captured process exits successfully and all bounded outputs are durable. It does not own a PID, process state, queue, task, workspace, or retry policy. A job ID deterministically derives its unit name. Every `systemd-run` and `systemctl` call has a short finite bound. `job.wait` caps each reconciliation call to its remaining deadline, so a stalled user manager cannot hold a wait or reserved control worker indefinitely. After a daemon restart, `get`, `list`, `wait`, and `cancel` reload the record and reconcile with the user manager. If `systemd-run` loses its reply but `show` finds the transient unit, `job start` returns the reconciled systemd state. If both the launch reply and its first reconciliation are unavailable, `job start` returns a durable nonterminal `launch-unknown` result with the stable job ID and unit. Later `get`, `wait`, and `cancel` use that same identity to reconcile it. A confirmed absent launch becomes terminal `launch-failed`. A confirmed missing unit after launch remains terminal `missing`; an unreachable or timed-out systemd observation is durable nonterminal `observation-unknown` until a later observation repairs it. Cancellation persists its intent before asking systemd to stop the service, then preserves an observed systemd success, timeout, or failure result. A `cancelled` result needs matching systemd signal evidence, or a durably recorded successful stop acknowledgement for the observed invocation when systemd has already garbage-collected the transient unit. If a stop times out and the unit later disappears, the job remains nonterminal `outcome-unknown` instead of treating the missing unit's default success fields as an exit result. A later authoritative systemd observation can repair that state. A typed result can prove semantic success after collection only when its content is valid and the capture completion marker proves the producer exited successfully; an empty, partial, malformed, or unmarked result is not completion evidence. A schema-v3 attested-agent record also carries forward its native completion only when systemd still reports an inactive loaded success, its durable lifecycle is `succeeded` with exit status zero, its bounded last-message artifact is valid, and no cancellation intent exists. Existing false terminal success or cancellation records without this evidence are reopened lazily by `get`, `list`, `wait`, or `cancel` and reconciled under the same rules. Systemd remains authoritative for the process, cgroup, timeout, terminal result, cancellation, and journal evidence. diff --git a/flake/command-registry.nix b/flake/command-registry.nix index 0bcc7cf1..5b1c4b45 100644 --- a/flake/command-registry.nix +++ b/flake/command-registry.nix @@ -112,6 +112,22 @@ let done < <(sinnix-rebuild-override consume) fi ''; + agentEnvironmentContract = '' + mapfile -t agentctl_project_roots < <( + ${pkgs.nix}/bin/nix eval \ + "$_flake_dir#nixosConfigurations.sinnix-prime.config.sinnix.services.sinnixd.projectRoots" \ + --json \ + --impure \ + "''${nix_override_args[@]}" \ + | ${pkgs.jq}/bin/jq -r '.[]' + ) + agentctl_environment_arguments=() + for agentctl_project_root in "''${agentctl_project_roots[@]}"; do + agentctl_environment_arguments+=(--project-root "$agentctl_project_root") + done + ${scriptPkgs.sinnixd}/bin/sinnixd-project-environment-check \ + "''${agentctl_environment_arguments[@]}" + ''; # Single source of truth for rebuild concurrency + resource containment, so # `nix run .#switch` (this file's appCommands) and the devshell `switch` # binary (flake/dev-shell.nix's mkNhCommand) can't drift apart: both must @@ -403,6 +419,7 @@ in rebuildLock rebuildContainmentFlags rebuildDefaultArgs + agentEnvironmentContract rebuildServicePath localInputOverrideArgs avoidRepoCwdForActivation @@ -546,6 +563,7 @@ in ${avoidRepoCwdForActivation} ${localInputOverrideArgs} ${rebuildDefaultArgs} + ${agentEnvironmentContract} ${scriptPkgs.sinnix-preflight}/bin/sinnix-preflight switch _rebuild_status=0 ${pkgs.systemd}/bin/systemd-run \ @@ -572,6 +590,7 @@ in ${avoidRepoCwdForActivation} ${localInputOverrideArgs} ${rebuildDefaultArgs} + ${agentEnvironmentContract} ${scriptPkgs.sinnix-preflight}/bin/sinnix-preflight switch _rebuild_status=0 ${pkgs.systemd}/bin/systemd-run \ diff --git a/flake/dev-shell.nix b/flake/dev-shell.nix index e112183e..524aee86 100644 --- a/flake/dev-shell.nix +++ b/flake/dev-shell.nix @@ -37,6 +37,7 @@ rebuildServicePath localInputOverrideArgs resolveFlakeDir + agentEnvironmentContract avoidRepoCwdForActivation switchFallback ; @@ -49,6 +50,7 @@ ${avoidRepoCwdForActivation} ${localInputOverrideArgs} ${commandRegistry.rebuildDefaultArgs} + ${agentEnvironmentContract} ${scriptPkgs.sinnix-preflight}/bin/sinnix-preflight switch _rebuild_status=0 diff --git a/pkgs/sinnixd/pyproject.toml b/pkgs/sinnixd/pyproject.toml index 83d0eab8..05d7ff26 100644 --- a/pkgs/sinnixd/pyproject.toml +++ b/pkgs/sinnixd/pyproject.toml @@ -14,6 +14,7 @@ agentctl = "sinnixd.cli:main" sinnixd = "sinnixd.cli:daemon_main" sinnixd-capture = "sinnixd.jobs:capture_cli" sinnixd-contract-runner = "sinnixd.runner:main" +sinnixd-project-environment-check = "sinnixd.projects:project_environment_check_main" sinnixd-task-reconcile = "sinnixd.tasks:task_reconcile_main" [tool.setuptools.packages.find] diff --git a/pkgs/sinnixd/sinnixd/contracts.py b/pkgs/sinnixd/sinnixd/contracts.py index b65c4863..f787b7a9 100644 --- a/pkgs/sinnixd/sinnixd/contracts.py +++ b/pkgs/sinnixd/sinnixd/contracts.py @@ -170,7 +170,7 @@ def start_agent( **({"bead_binding": binding} if binding is not None else {}), } private = { - "schema_version": 1, + "schema_version": 2, "job_id": job_id, "kind": "attested-agent", "principal": principal, diff --git a/pkgs/sinnixd/sinnixd/projects.py b/pkgs/sinnixd/sinnixd/projects.py index eb44552c..e05913e4 100644 --- a/pkgs/sinnixd/sinnixd/projects.py +++ b/pkgs/sinnixd/sinnixd/projects.py @@ -1,5 +1,6 @@ from __future__ import annotations +import argparse import hashlib import json import re @@ -252,6 +253,14 @@ def command_for( return (*self.command, "env", *assignments, *payload) return ("env", *assignments, *self.command, *payload) + def catalog_row(self, *, agent_capable: bool) -> dict[str, Any]: + return { + "kind": self.kind, + "command": list(self.command), + "preflight": list(self.preflight), + "agent_capable": agent_capable, + } + @dataclass(frozen=True) class ServicePortSlot: @@ -509,6 +518,9 @@ def catalog_row(self) -> dict[str, Any]: "descriptor": str(self.descriptor), "digest": self.digest, "descriptor_status": self.descriptor_status(), + "environment": self.environment.catalog_row( + agent_capable=self.workspace is not None + ), "workspace": self.workspace.catalog_row() if self.workspace is not None else None, @@ -1308,3 +1320,46 @@ def owner_adapter( if adapter.spec == spec: return project, adapter raise KeyError(f"no project owner adapter for {operation!r}") + + +def validate_agent_environment_descriptors(roots: Iterable[Path]) -> None: + """Require a declared command and preflight for every checkout-capable project.""" + diagnostics: list[str] = [] + for root in roots: + descriptor = root / ".agentctl" / "project.toml" + project_name = str(root) + try: + raw = tomllib.loads(descriptor.read_text()) + project = raw.get("project") + if isinstance(project, Mapping) and isinstance(project.get("id"), str): + project_name = project["id"] + adapter = load_project_adapter(root) + except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError, ProjectConfigError) as error: + diagnostics.append(f"{project_name}: invalid descriptor {descriptor}: {error}") + continue + if adapter.workspace is None: + continue + if not adapter.environment.command: + diagnostics.append( + f"{adapter.project_id}: {descriptor} must declare a non-empty environment.command" + ) + if not adapter.environment.preflight: + diagnostics.append( + f"{adapter.project_id}: {descriptor} must declare a non-empty environment.preflight" + ) + if diagnostics: + raise ProjectConfigError( + "agent-capable project environment contract failed:\n" + + "\n".join(f"- {diagnostic}" for diagnostic in diagnostics) + ) + + +def project_environment_check_main(arguments: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="sinnixd-project-environment-check") + parser.add_argument("--project-root", type=Path, action="append", required=True) + args = parser.parse_args(arguments) + try: + validate_agent_environment_descriptors(args.project_root) + except ProjectConfigError as error: + parser.error(str(error)) + return 0 diff --git a/pkgs/sinnixd/sinnixd/runner.py b/pkgs/sinnixd/sinnixd/runner.py index ea47ec82..45ef6b43 100644 --- a/pkgs/sinnixd/sinnixd/runner.py +++ b/pkgs/sinnixd/sinnixd/runner.py @@ -16,6 +16,8 @@ from .limits import valid_timeout_seconds from .projects import ProjectConfigError, revalidate_registered_checkout +AGENT_PREFLIGHT_TIMEOUT_SECONDS = 30 + class RunnerError(ValueError): pass @@ -28,19 +30,28 @@ def _require_strings(value: Mapping[str, Any], fields: Sequence[str]) -> None: raise RunnerError("private typed-job input is invalid") +def _non_empty_argv(value: Any) -> bool: + return isinstance(value, list) and bool(value) and all( + isinstance(item, str) and item for item in value + ) + + def _load(path: Path, job_id: str) -> dict[str, Any]: try: value = json.loads(path.read_text()) except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error: raise RunnerError("private typed-job input is unavailable") from error - if ( - not isinstance(value, dict) - or value.get("schema_version") != 1 - or value.get("job_id") != job_id - ): + if not isinstance(value, dict) or value.get("job_id") != job_id: raise RunnerError("private typed-job identity is invalid") - if value.get("kind") not in {"operator-shell", "attested-agent"}: + kind = value.get("kind") + if kind not in {"operator-shell", "attested-agent"}: raise RunnerError("private typed-job kind is invalid") + if kind == "attested-agent" and value.get("schema_version") != 2: + raise RunnerError( + "stale attested-agent private input schema; retry the agent launch after the environment contract upgrade" + ) + if kind == "operator-shell" and value.get("schema_version") != 1: + raise RunnerError("private typed-job schema is invalid") checkout = value.get("checkout") if not isinstance(checkout, dict) or set(checkout) != { "project_id", @@ -123,18 +134,9 @@ def _exec_shell(value: Mapping[str, Any], checkout: Path) -> None: argv = value.get("argv") environment_command = value.get("environment_command") cwd = value.get("cwd") - if ( - value.get("principal") != "operator" - or not isinstance(argv, list) - or not argv - or any(not isinstance(item, str) or not item for item in argv) - ): + if value.get("principal") != "operator" or not _non_empty_argv(argv): raise RunnerError("operator shell contract is invalid") - if ( - not isinstance(environment_command, list) - or not environment_command - or any(not isinstance(item, str) or not item for item in environment_command) - ): + if not _non_empty_argv(environment_command): raise RunnerError("operator shell project environment is invalid") if not isinstance(cwd, str): raise RunnerError("operator shell cwd is invalid") @@ -185,21 +187,28 @@ def _run_agent( raise RunnerError("native agent runner is unavailable") environment_command = value.get("environment_command") environment_preflight = value.get("environment_preflight") - if not isinstance(environment_command, list) or not environment_command or any( - not isinstance(item, str) or not item for item in environment_command - ): + if not _non_empty_argv(environment_command): raise RunnerError( "typed agent project environment is missing; declare a non-empty environment.command" ) - if not isinstance(environment_preflight, list) or not environment_preflight or any( - not isinstance(item, str) or not item for item in environment_preflight - ): + if not _non_empty_argv(environment_preflight): raise RunnerError( "typed agent project environment is missing; declare a non-empty environment.preflight" ) preflight_command = [*environment_command, *environment_preflight] try: - preflight = subprocess.run(preflight_command, cwd=checkout, check=False) + preflight = subprocess.run( + preflight_command, + cwd=checkout, + check=False, + timeout=AGENT_PREFLIGHT_TIMEOUT_SECONDS, + ) + except subprocess.TimeoutExpired as error: + raise RunnerError( + "agent-preflight-timeout: project environment preflight exceeded " + f"{AGENT_PREFLIGHT_TIMEOUT_SECONDS} seconds before agent implementation; " + "inspect the declared preflight and retry" + ) from error except OSError as error: raise RunnerError( "project environment preflight is unavailable; repair environment.command and retry" diff --git a/pkgs/sinnixd/test_service.py b/pkgs/sinnixd/test_service.py index 758f1e9f..98f40d20 100644 --- a/pkgs/sinnixd/test_service.py +++ b/pkgs/sinnixd/test_service.py @@ -20,6 +20,7 @@ import sinnixd.api as api_module import sinnixd.cli as cli_module import sinnixd.jobs as jobs_module +import sinnixd.runner as runner_module from sinnix_mcp import ( ErrorCode, OpaquePayload, @@ -68,9 +69,11 @@ ProjectConfigError, RegisteredCheckout, parse_worktree_records, + validate_agent_environment_descriptors, ) from sinnixd.runner import ( RunnerError, + _load, _exec_shell, _require_environment, _revalidate_checkout, @@ -7586,17 +7589,17 @@ def execute(executable: str, argv: list[str], environment: dict[str, str]) -> No ] -def test_agent_production_route_binds_environment_to_assigned_checkout_and_interpreter( +def test_agent_production_route_uses_declared_environment_over_poisoned_ambient_imports( tmp_path: Path, ) -> None: - """A real typed input reaches the contract runner before the native backend.""" + """A real typed input gets its PATH and import root only from the declared environment.""" write_adapter(tmp_path) environment = tmp_path / "fixture-environment" environment.write_text( "#!/bin/sh\n" "set -eu\n" "printf 'entered\\n' >> environment.calls\n" - "export PATH=\"$PWD/.venv/bin:/run/current-system/sw/bin\"\n" + "export PATH=\"$PWD/project-bin:/run/current-system/sw/bin\"\n" "export PYTHONPATH=\"$PWD\"\n" "exec \"$@\"\n" ) @@ -7608,8 +7611,7 @@ def test_agent_production_route_binds_environment_to_assigned_checkout_and_inter f'command = ["{environment}"]', ) ) - (tmp_path / ".venv" / "bin").mkdir(parents=True) - (tmp_path / ".venv" / "bin" / "python").symlink_to(sys.executable) + (tmp_path / "project-bin").mkdir() (tmp_path / "devtools").mkdir() (tmp_path / "devtools" / "__init__.py").write_text("") (tmp_path / "devtools" / "__main__.py").write_text( @@ -7618,10 +7620,10 @@ def test_agent_production_route_binds_environment_to_assigned_checkout_and_inter "with Path('devtools.calls').open('a') as handle:\n" " handle.write(' '.join(sys.argv[1:]) + '\\n')\n" ) - (tmp_path / "polylogue").mkdir() - (tmp_path / "polylogue" / "__init__.py").write_text("CHECKOUT = __file__\n") - devtools = tmp_path / ".venv" / "bin" / "devtools" - devtools.write_text("#!/bin/sh\nexec python -m devtools \"$@\"\n") + (tmp_path / "fixture_package").mkdir() + (tmp_path / "fixture_package" / "__init__.py").write_text("CHECKOUT = __file__\n") + devtools = tmp_path / "project-bin" / "devtools" + devtools.write_text(f"#!/bin/sh\nexec {sys.executable} -m devtools \"$@\"\n") devtools.chmod(0o700) initialize_git_checkout(tmp_path) @@ -7633,11 +7635,11 @@ def test_agent_production_route_binds_environment_to_assigned_checkout_and_inter "while [ $# -gt 0 ]; do\n" " case $1 in --last-file) last=$2; shift 2 ;; *) shift ;; esac\n" "done\n" - "test \"$(command -v devtools)\" = \"$PWD/.venv/bin/devtools\"\n" + "test \"$(command -v devtools)\" = \"$PWD/project-bin/devtools\"\n" "devtools status\n" "devtools test tests/fixture.py::test_noop\n" "devtools verify --quick\n" - "python -c 'import polylogue; assert polylogue.CHECKOUT'\n" + f"{sys.executable} -c 'import fixture_package; assert fixture_package.CHECKOUT'\n" "printf native-started > native.started\n" "printf native-result > \"$last\"\n" ) @@ -7669,10 +7671,11 @@ def test_agent_production_route_binds_environment_to_assigned_checkout_and_inter private = json.loads((tmp_path / "state" / "inputs" / f"{job_id}.json").read_text()) assert private["environment_command"] == [str(environment)] assert private["environment_preflight"] == ["devtools", "status", "--stderr"] + assert private["schema_version"] == 2 poisoned = {str(key): str(value) for key, value in launch["environment"].items()} poisoned["PATH"] = ":".join( - [str(tmp_path / "another-checkout" / ".venv" / "bin"), poisoned["PATH"]] + [str(tmp_path / "another-checkout" / "project-bin"), poisoned["PATH"]] ) poisoned["PYTHONPATH"] = os.environ.get("PYTHONPATH", "") mutant = subprocess.run( @@ -7767,7 +7770,7 @@ def test_agent_environment_preflight_refuses_corrupt_environment_before_native_r prompt.write_text("prompt") job_id = "11111111-1111-1111-1111-111111111111" payload = { - "schema_version": 1, + "schema_version": 2, "job_id": job_id, "kind": "attested-agent", "principal": "agent-control", @@ -7790,6 +7793,97 @@ def test_agent_environment_preflight_refuses_corrupt_environment_before_native_r assert not (results / "fixture.result").exists() +def test_agent_environment_preflight_timeout_is_distinct_and_prevents_native_runner( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + state = tmp_path / "state" + inputs = state / "inputs" + results = state / "results" + inputs.mkdir(parents=True) + results.mkdir() + prompt = inputs / "fixture.prompt" + prompt.write_text("prompt") + runner = tmp_path / "native-runner" + native_runner(runner) + payload = { + "schema_version": 2, + "job_id": "11111111-1111-1111-1111-111111111111", + "kind": "attested-agent", + "principal": "agent-control", + "environment_command": ["fixture-environment"], + "environment_preflight": ["status"], + "backend": "codex", + "model": "fixture", + "effort": "high", + "credential_profile": "subscription", + "prompt_path": str(prompt), + "result_path": str(results / "fixture.result"), + } + + def timeout(*_args: object, **_kwargs: object) -> None: + raise subprocess.TimeoutExpired("fixture-environment", 30) + + monkeypatch.setattr(runner_module.subprocess, "run", timeout) + with pytest.raises(RunnerError, match="agent-preflight-timeout.*30 seconds"): + _run_agent(payload, tmp_path, native_runner=runner, state_root=state) + assert not (results / "fixture.result").exists() + + +def test_pre_upgrade_attested_agent_input_fails_closed_with_stale_schema(tmp_path: Path) -> None: + input_path = tmp_path / "legacy-agent.json" + input_path.write_text( + json.dumps( + { + "schema_version": 1, + "job_id": "11111111-1111-1111-1111-111111111111", + "kind": "attested-agent", + "principal": "agent-control", + } + ) + ) + with pytest.raises(RunnerError, match="stale attested-agent private input schema"): + _load(input_path, "11111111-1111-1111-1111-111111111111") + + +def test_agent_environment_descriptor_audit_reports_each_registered_project(tmp_path: Path) -> None: + fixture = tmp_path / "fixture" + missing_command = tmp_path / "missing-command" + write_adapter(fixture, project_id="fixture") + write_adapter(missing_command, project_id="missing_command") + descriptor = fixture / ".agentctl" / "project.toml" + descriptor.write_text(descriptor.read_text().replace('preflight = ["devtools", "status", "--stderr"]\n', "")) + descriptor = missing_command / ".agentctl" / "project.toml" + descriptor.write_text(descriptor.read_text().replace('command = ["fixture-env", "--command"]', "command = []")) + + with pytest.raises(ProjectConfigError, match="agent-capable project environment contract failed") as error: + validate_agent_environment_descriptors([fixture, missing_command]) + message = str(error.value) + assert "fixture:" in message + assert "environment.preflight" in message + assert "missing_command:" in message + assert "environment.command" in message + + +def test_project_get_publishes_agent_environment_capability(tmp_path: Path) -> None: + write_adapter(tmp_path) + runner = tmp_path / "native-runner" + native_runner(runner) + service = SinnixdService(ProjectCatalog([tmp_path]), jobs=generic_jobs(tmp_path), native_runner=runner) + + response = service.dispatch( + request("project.get", "project-adapters", {"project_id": "fixture"}, "observer") + ) + + assert response.ok and response.payload is not None + environment = response.payload.inline["environment"] + assert environment == { + "kind": "fixture", + "command": ["fixture-env", "--command"], + "preflight": ["devtools", "status", "--stderr"], + "agent_capable": True, + } + + def test_typed_contracts_refuse_spoofed_principals_checkout_backend_environment_and_results( tmp_path: Path, ) -> None: @@ -8004,7 +8098,7 @@ def test_agent_runner_revalidates_checkout_and_writes_a_bounded_result_fixture( prompt = inputs / "fixture.prompt" prompt.write_text("private fixture prompt") payload = { - "schema_version": 1, + "schema_version": 2, "job_id": "11111111-1111-1111-1111-111111111111", "kind": "attested-agent", "principal": "agent-control", @@ -8079,7 +8173,7 @@ def test_runner_rejects_forged_sinnix_environment(tmp_path: Path) -> None: input_path.write_text( json.dumps( { - "schema_version": 1, + "schema_version": 2, "job_id": job_id, "kind": "attested-agent", "principal": "agent-control", From 0f6b595e305a68c522983ac4ca1352fd3ee593e8 Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 25 Aug 2026 17:41:19 +0200 Subject: [PATCH 27/35] style(agentctl): format environment integration --- pkgs/sinnixd/sinnixd/projects.py | 11 +++- pkgs/sinnixd/sinnixd/runner.py | 6 ++- pkgs/sinnixd/test_service.py | 87 ++++++++++++++++++++++++-------- 3 files changed, 78 insertions(+), 26 deletions(-) diff --git a/pkgs/sinnixd/sinnixd/projects.py b/pkgs/sinnixd/sinnixd/projects.py index e05913e4..844f9dd1 100644 --- a/pkgs/sinnixd/sinnixd/projects.py +++ b/pkgs/sinnixd/sinnixd/projects.py @@ -1334,8 +1334,15 @@ def validate_agent_environment_descriptors(roots: Iterable[Path]) -> None: if isinstance(project, Mapping) and isinstance(project.get("id"), str): project_name = project["id"] adapter = load_project_adapter(root) - except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError, ProjectConfigError) as error: - diagnostics.append(f"{project_name}: invalid descriptor {descriptor}: {error}") + except ( + OSError, + UnicodeDecodeError, + tomllib.TOMLDecodeError, + ProjectConfigError, + ) as error: + diagnostics.append( + f"{project_name}: invalid descriptor {descriptor}: {error}" + ) continue if adapter.workspace is None: continue diff --git a/pkgs/sinnixd/sinnixd/runner.py b/pkgs/sinnixd/sinnixd/runner.py index 45ef6b43..260c1c7f 100644 --- a/pkgs/sinnixd/sinnixd/runner.py +++ b/pkgs/sinnixd/sinnixd/runner.py @@ -31,8 +31,10 @@ def _require_strings(value: Mapping[str, Any], fields: Sequence[str]) -> None: def _non_empty_argv(value: Any) -> bool: - return isinstance(value, list) and bool(value) and all( - isinstance(item, str) and item for item in value + return ( + isinstance(value, list) + and bool(value) + and all(isinstance(item, str) and item for item in value) ) diff --git a/pkgs/sinnixd/test_service.py b/pkgs/sinnixd/test_service.py index 98f40d20..76bbfcdd 100644 --- a/pkgs/sinnixd/test_service.py +++ b/pkgs/sinnixd/test_service.py @@ -73,8 +73,8 @@ ) from sinnixd.runner import ( RunnerError, - _load, _exec_shell, + _load, _require_environment, _revalidate_checkout, _run_agent, @@ -7599,9 +7599,9 @@ def test_agent_production_route_uses_declared_environment_over_poisoned_ambient_ "#!/bin/sh\n" "set -eu\n" "printf 'entered\\n' >> environment.calls\n" - "export PATH=\"$PWD/project-bin:/run/current-system/sw/bin\"\n" - "export PYTHONPATH=\"$PWD\"\n" - "exec \"$@\"\n" + 'export PATH="$PWD/project-bin:/run/current-system/sw/bin"\n' + 'export PYTHONPATH="$PWD"\n' + 'exec "$@"\n' ) environment.chmod(0o700) descriptor = tmp_path / ".agentctl" / "project.toml" @@ -7623,7 +7623,7 @@ def test_agent_production_route_uses_declared_environment_over_poisoned_ambient_ (tmp_path / "fixture_package").mkdir() (tmp_path / "fixture_package" / "__init__.py").write_text("CHECKOUT = __file__\n") devtools = tmp_path / "project-bin" / "devtools" - devtools.write_text(f"#!/bin/sh\nexec {sys.executable} -m devtools \"$@\"\n") + devtools.write_text(f'#!/bin/sh\nexec {sys.executable} -m devtools "$@"\n') devtools.chmod(0o700) initialize_git_checkout(tmp_path) @@ -7635,18 +7635,22 @@ def test_agent_production_route_uses_declared_environment_over_poisoned_ambient_ "while [ $# -gt 0 ]; do\n" " case $1 in --last-file) last=$2; shift 2 ;; *) shift ;; esac\n" "done\n" - "test \"$(command -v devtools)\" = \"$PWD/project-bin/devtools\"\n" + 'test "$(command -v devtools)" = "$PWD/project-bin/devtools"\n' "devtools status\n" "devtools test tests/fixture.py::test_noop\n" "devtools verify --quick\n" f"{sys.executable} -c 'import fixture_package; assert fixture_package.CHECKOUT'\n" "printf native-started > native.started\n" - "printf native-result > \"$last\"\n" + 'printf native-result > "$last"\n' ) native.chmod(0o700) systemd = FakeSystemdJobs() - service = SinnixdService(ProjectCatalog([tmp_path]), jobs=generic_jobs(tmp_path, systemd), native_runner=native) + service = SinnixdService( + ProjectCatalog([tmp_path]), + jobs=generic_jobs(tmp_path, systemd), + native_runner=native, + ) response = service.dispatch( request( "job.agent.start", @@ -7679,7 +7683,11 @@ def test_agent_production_route_uses_declared_environment_over_poisoned_ambient_ ) poisoned["PYTHONPATH"] = os.environ.get("PYTHONPATH", "") mutant = subprocess.run( - [str(native), "--last-file", str(tmp_path / "state" / "results" / "mutant.result")], + [ + str(native), + "--last-file", + str(tmp_path / "state" / "results" / "mutant.result"), + ], cwd=tmp_path, env=poisoned, capture_output=True, @@ -7712,8 +7720,13 @@ def test_agent_production_route_uses_declared_environment_over_poisoned_ambient_ ) assert result.returncode == 0, result.stderr assert (tmp_path / "native.started").read_text() == "native-started" - assert (tmp_path / "state" / "results" / f"{job_id}.result").read_text() == "native-result" - assert (tmp_path / "environment.calls").read_text().splitlines() == ["entered", "entered"] + assert ( + tmp_path / "state" / "results" / f"{job_id}.result" + ).read_text() == "native-result" + assert (tmp_path / "environment.calls").read_text().splitlines() == [ + "entered", + "entered", + ] assert (tmp_path / "devtools.calls").read_text().splitlines() == [ "status --stderr", "status", @@ -7722,15 +7735,25 @@ def test_agent_production_route_uses_declared_environment_over_poisoned_ambient_ ] -def test_agent_environment_preflight_refuses_missing_declaration_before_launch(tmp_path: Path) -> None: +def test_agent_environment_preflight_refuses_missing_declaration_before_launch( + tmp_path: Path, +) -> None: write_adapter(tmp_path) descriptor = tmp_path / ".agentctl" / "project.toml" - descriptor.write_text(descriptor.read_text().replace('preflight = ["devtools", "status", "--stderr"]\n', "")) + descriptor.write_text( + descriptor.read_text().replace( + 'preflight = ["devtools", "status", "--stderr"]\n', "" + ) + ) initialize_git_checkout(tmp_path) runner = tmp_path / "native-runner" native_runner(runner) systemd = FakeSystemdJobs() - service = SinnixdService(ProjectCatalog([tmp_path]), jobs=generic_jobs(tmp_path, systemd), native_runner=runner) + service = SinnixdService( + ProjectCatalog([tmp_path]), + jobs=generic_jobs(tmp_path, systemd), + native_runner=runner, + ) response = service.dispatch( request( @@ -7756,7 +7779,9 @@ def test_agent_environment_preflight_refuses_missing_declaration_before_launch(t assert systemd.started == [] -def test_agent_environment_preflight_refuses_corrupt_environment_before_native_runner(tmp_path: Path) -> None: +def test_agent_environment_preflight_refuses_corrupt_environment_before_native_runner( + tmp_path: Path, +) -> None: write_adapter(tmp_path) initialize_git_checkout(tmp_path) runner = tmp_path / "native-runner" @@ -7829,7 +7854,9 @@ def timeout(*_args: object, **_kwargs: object) -> None: assert not (results / "fixture.result").exists() -def test_pre_upgrade_attested_agent_input_fails_closed_with_stale_schema(tmp_path: Path) -> None: +def test_pre_upgrade_attested_agent_input_fails_closed_with_stale_schema( + tmp_path: Path, +) -> None: input_path = tmp_path / "legacy-agent.json" input_path.write_text( json.dumps( @@ -7845,17 +7872,29 @@ def test_pre_upgrade_attested_agent_input_fails_closed_with_stale_schema(tmp_pat _load(input_path, "11111111-1111-1111-1111-111111111111") -def test_agent_environment_descriptor_audit_reports_each_registered_project(tmp_path: Path) -> None: +def test_agent_environment_descriptor_audit_reports_each_registered_project( + tmp_path: Path, +) -> None: fixture = tmp_path / "fixture" missing_command = tmp_path / "missing-command" write_adapter(fixture, project_id="fixture") write_adapter(missing_command, project_id="missing_command") descriptor = fixture / ".agentctl" / "project.toml" - descriptor.write_text(descriptor.read_text().replace('preflight = ["devtools", "status", "--stderr"]\n', "")) + descriptor.write_text( + descriptor.read_text().replace( + 'preflight = ["devtools", "status", "--stderr"]\n', "" + ) + ) descriptor = missing_command / ".agentctl" / "project.toml" - descriptor.write_text(descriptor.read_text().replace('command = ["fixture-env", "--command"]', "command = []")) + descriptor.write_text( + descriptor.read_text().replace( + 'command = ["fixture-env", "--command"]', "command = []" + ) + ) - with pytest.raises(ProjectConfigError, match="agent-capable project environment contract failed") as error: + with pytest.raises( + ProjectConfigError, match="agent-capable project environment contract failed" + ) as error: validate_agent_environment_descriptors([fixture, missing_command]) message = str(error.value) assert "fixture:" in message @@ -7868,10 +7907,14 @@ def test_project_get_publishes_agent_environment_capability(tmp_path: Path) -> N write_adapter(tmp_path) runner = tmp_path / "native-runner" native_runner(runner) - service = SinnixdService(ProjectCatalog([tmp_path]), jobs=generic_jobs(tmp_path), native_runner=runner) + service = SinnixdService( + ProjectCatalog([tmp_path]), jobs=generic_jobs(tmp_path), native_runner=runner + ) response = service.dispatch( - request("project.get", "project-adapters", {"project_id": "fixture"}, "observer") + request( + "project.get", "project-adapters", {"project_id": "fixture"}, "observer" + ) ) assert response.ok and response.payload is not None From 3f0c680fb84f045985332150e78e49d8371d56eb Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 25 Aug 2026 12:48:21 +0200 Subject: [PATCH 28/35] test(agents): enforce Polylogue writer destination parity --- flake/tests/agent-parity.nix | 66 +++++++++++++++++++++++++++++++++--- 1 file changed, 62 insertions(+), 4 deletions(-) diff --git a/flake/tests/agent-parity.nix b/flake/tests/agent-parity.nix index 49392cc4..441f7d26 100644 --- a/flake/tests/agent-parity.nix +++ b/flake/tests/agent-parity.nix @@ -5,8 +5,9 @@ # # Provably fails when: a `polylogue-hook ` lane present in Claude's # settings is dropped from the generated Codex hooks (verified by removing -# the PostToolUse entry from modules/features/dev/agents/hooks.nix), or when -# either client loses the pre-compaction handoff or the shared hook coverage. +# the PostToolUse entry from modules/features/dev/agents/hooks.nix), when a +# writer loses the canonical primary sidecar destination, or when either +# client loses the pre-compaction handoff or the shared hook coverage. { inputs, ... }: { perSystem = @@ -18,13 +19,19 @@ dotsRoot = inputs.self + "/dots"; }; claudeHooks = ../../dots/claude/managed-settings.json; + polylogueHook = inputs.polylogue.packages.${system}.default; + primarySidecarDir = "/realm/state/polylogue/hooks"; in { checks.agent-hook-parity = pkgs.runCommand "agent-hook-parity-check" { - inherit codexHooks claudeHooks; - nativeBuildInputs = [ pkgs.jq ]; + inherit codexHooks claudeHooks polylogueHook primarySidecarDir; + nativeBuildInputs = [ + pkgs.coreutils + pkgs.jq + pkgs.strace + ]; } '' # The evidence lanes: every lifecycle event on which a client @@ -52,6 +59,57 @@ exit 1 fi + # Every actual Polylogue writer declaration must carry the same + # derived primary spool. This parses the commands themselves, so + # a missing argument and a stale literal both fail. + writer_rows() { + jq -r ' + .hooks + | to_entries[] + | .key as $event + | .value[]?.hooks[]?.command + | select(type == "string" and startswith("polylogue-hook ")) + | [$event, (capture(" --sidecar-dir (?[^ ]+)$").destination)] + | @tsv + ' "$1" + } + writer_rows "$claudeHooks" | sort > claude-writers + writer_rows "$codexHooks" | sort > codex-writers + test -s claude-writers + test -s codex-writers + diff -u claude-writers codex-writers + while IFS=$'\t' read -r event destination; do + test "$destination" = "$primarySidecarDir" || { + echo "$event writer targets '$destination', expected '$primarySidecarDir'" >&2 + exit 1 + } + done < claude-writers + + # Fresh-writer smoke: isolate every ambient resolution input and + # trace file access. The explicit primary spool must receive the + # event while archive/XDG fallback paths and the real legacy roots + # remain untouched. + smoke_root="$TMPDIR/polylogue-hook-smoke" + primary="$smoke_root/primary-hooks" + archive="$smoke_root/archive" + legacy="$smoke_root/legacy-home" + mkdir -p "$smoke_root/home" "$smoke_root/xdg" "$primary" "$archive" "$legacy" + trace="$smoke_root/access.trace" + printf '%s' '{"session_id":"parity-smoke","source":"codex","turn_id":"turn-1"}' \ + | HOME="$smoke_root/home" \ + XDG_DATA_HOME="$smoke_root/xdg" \ + POLYLOGUE_ARCHIVE_ROOT="$archive" \ + strace -f -e trace=file -o "$trace" \ + "$polylogueHook/bin/polylogue-hook" UserPromptSubmit \ + --provider codex --sidecar-dir "$primary" + test -n "$(find "$primary" -type f -print -quit)" + grep -R -F 'parity-smoke' "$primary" >/dev/null + test -z "$(find "$archive" "$legacy" "$smoke_root/home" "$smoke_root/xdg" -type f -print -quit)" + if grep -F -e '/realm/state/polylogue' -e '/home/sinity/.local/share/polylogue' "$trace"; then + echo 'isolated Polylogue hook smoke accessed a live or legacy root' >&2 + exit 1 + fi + # The shared context handoff is configured independently for both # clients, so this verifies the intended cross-client agreement. for command in sinnix-context-handoff; do From dbfa55faffb9ce7112ed08f732d20b45b8995e29 Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 25 Aug 2026 12:50:04 +0200 Subject: [PATCH 29/35] fix(agents): align Polylogue writers with service archive --- dots/claude/managed-settings.json | 10 +++++----- flake/tests/agent-parity.nix | 4 +++- modules/features/dev/agents/hooks.nix | 12 ++++++------ modules/features/dev/agents/mcp-tools.nix | 13 ++----------- modules/features/dev/agents/mcp.nix | 1 + 5 files changed, 17 insertions(+), 23 deletions(-) diff --git a/dots/claude/managed-settings.json b/dots/claude/managed-settings.json index 3631501e..52321b6e 100644 --- a/dots/claude/managed-settings.json +++ b/dots/claude/managed-settings.json @@ -72,7 +72,7 @@ "hooks": [ { "type": "command", - "command": "polylogue-hook PreToolUse --provider claude-code", + "command": "polylogue-hook PreToolUse --provider claude-code --sidecar-dir /realm/state/polylogue/hooks", "timeout": 5 } ] @@ -83,7 +83,7 @@ "hooks": [ { "type": "command", - "command": "polylogue-hook PostToolUse --provider claude-code", + "command": "polylogue-hook PostToolUse --provider claude-code --sidecar-dir /realm/state/polylogue/hooks", "timeout": 5 } ] @@ -94,7 +94,7 @@ "hooks": [ { "type": "command", - "command": "polylogue-hook Stop --provider claude-code", + "command": "polylogue-hook Stop --provider claude-code --sidecar-dir /realm/state/polylogue/hooks", "timeout": 5 } ] @@ -125,7 +125,7 @@ }, { "type": "command", - "command": "polylogue-hook SessionStart --provider claude-code", + "command": "polylogue-hook SessionStart --provider claude-code --sidecar-dir /realm/state/polylogue/hooks", "timeout": 5 } ] @@ -136,7 +136,7 @@ "hooks": [ { "type": "command", - "command": "polylogue-hook UserPromptSubmit --provider claude-code", + "command": "polylogue-hook UserPromptSubmit --provider claude-code --sidecar-dir /realm/state/polylogue/hooks", "timeout": 5 } ] diff --git a/flake/tests/agent-parity.nix b/flake/tests/agent-parity.nix index 441f7d26..c054336c 100644 --- a/flake/tests/agent-parity.nix +++ b/flake/tests/agent-parity.nix @@ -14,13 +14,15 @@ { system, ... }: let pkgs = inputs.nixpkgs.legacyPackages.${system}; + canonicalDataDir = "/realm/state/polylogue"; codexHooks = import ../../modules/features/dev/agents/hooks.nix { inherit pkgs; dotsRoot = inputs.self + "/dots"; + dataDir = canonicalDataDir; }; claudeHooks = ../../dots/claude/managed-settings.json; polylogueHook = inputs.polylogue.packages.${system}.default; - primarySidecarDir = "/realm/state/polylogue/hooks"; + primarySidecarDir = "${canonicalDataDir}/hooks"; in { checks.agent-hook-parity = diff --git a/modules/features/dev/agents/hooks.nix b/modules/features/dev/agents/hooks.nix index 97ace407..1e5087c3 100644 --- a/modules/features/dev/agents/hooks.nix +++ b/modules/features/dev/agents/hooks.nix @@ -3,7 +3,7 @@ # imported directly by mcp.nix's # configFn; the generated file is exposed via the mcp-servers.codexHooksSource # option for tests. -{ pkgs, dotsRoot }: +{ pkgs, dotsRoot, dataDir }: let jsonFormat = pkgs.formats.json { }; in @@ -19,7 +19,7 @@ jsonFormat.generate "codex-hooks.json" { } { type = "command"; - command = "polylogue-hook SessionStart --provider codex --sidecar-dir /home/sinity/.local/share/polylogue/hooks"; + command = "polylogue-hook SessionStart --provider codex --sidecar-dir ${dataDir}/hooks"; } ]; } @@ -29,7 +29,7 @@ jsonFormat.generate "codex-hooks.json" { hooks = [ { type = "command"; - command = "polylogue-hook UserPromptSubmit --provider codex --sidecar-dir /home/sinity/.local/share/polylogue/hooks"; + command = "polylogue-hook UserPromptSubmit --provider codex --sidecar-dir ${dataDir}/hooks"; } ]; } @@ -49,7 +49,7 @@ jsonFormat.generate "codex-hooks.json" { hooks = [ { type = "command"; - command = "polylogue-hook PreToolUse --provider codex --sidecar-dir /home/sinity/.local/share/polylogue/hooks"; + command = "polylogue-hook PreToolUse --provider codex --sidecar-dir ${dataDir}/hooks"; } ]; } @@ -59,7 +59,7 @@ jsonFormat.generate "codex-hooks.json" { hooks = [ { type = "command"; - command = "polylogue-hook PostToolUse --provider codex --sidecar-dir /home/sinity/.local/share/polylogue/hooks"; + command = "polylogue-hook PostToolUse --provider codex --sidecar-dir ${dataDir}/hooks"; } ]; } @@ -69,7 +69,7 @@ jsonFormat.generate "codex-hooks.json" { hooks = [ { type = "command"; - command = "polylogue-hook Stop --provider codex --sidecar-dir /home/sinity/.local/share/polylogue/hooks"; + command = "polylogue-hook Stop --provider codex --sidecar-dir ${dataDir}/hooks"; } ]; } diff --git a/modules/features/dev/agents/mcp-tools.nix b/modules/features/dev/agents/mcp-tools.nix index 8ef5cfa0..cf38969e 100644 --- a/modules/features/dev/agents/mcp-tools.nix +++ b/modules/features/dev/agents/mcp-tools.nix @@ -49,17 +49,8 @@ let ''; mcpPolylogueBin = pkgs.writeShellScriptBin "mcp-polylogue" '' set -euo pipefail - # The polylogue repo's .claude/settings.json pins POLYLOGUE_ARCHIVE_ROOT - # to the cloud-lane fixture (/tmp/polylogue-archive), and that env leaks - # into locally-launched MCP servers, pointing recall at an empty archive. - # Drop any leaked override that does not resolve to a real directory — - # testing existence rather than the one known literal also catches other - # stale overrides, while preserving a deliberate override to a real path. - # It cannot un-stick a server process already running with the leak in its - # inherited environment; that needs the MCP connection restarted. - if [ -n "''${POLYLOGUE_ARCHIVE_ROOT:-}" ] && [ ! -d "''${POLYLOGUE_ARCHIVE_ROOT}" ]; then - unset POLYLOGUE_ARCHIVE_ROOT - fi + # The configured service data directory is the sole archive-root owner. + export POLYLOGUE_ARCHIVE_ROOT=${lib.escapeShellArg config.sinnix.services.polylogue.dataDir} exec ${scriptPkgs.polylogue-cli}/bin/polylogue-mcp "$@" ''; # The user-facing files stay tiny out-of-store launchers, while the gateway diff --git a/modules/features/dev/agents/mcp.nix b/modules/features/dev/agents/mcp.nix index 36d72930..186aad05 100644 --- a/modules/features/dev/agents/mcp.nix +++ b/modules/features/dev/agents/mcp.nix @@ -119,6 +119,7 @@ mkFeatureModule { codexHooksFile = import ./hooks.nix { inherit pkgs; dotsRoot = config.sinnix.paths.dotsRoot; + dataDir = config.sinnix.services.polylogue.dataDir; }; inherit (browser) mcpChromeDevtoolsBin From 3a58138089f289c68d5eac9a634c03fbe0ec04d7 Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 25 Aug 2026 12:53:11 +0200 Subject: [PATCH 30/35] docs(agents): note Polylogue spool parity --- docs/agent-hook-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/agent-hook-parity.md b/docs/agent-hook-parity.md index 1c52b331..e1f04dc4 100644 --- a/docs/agent-hook-parity.md +++ b/docs/agent-hook-parity.md @@ -1,6 +1,6 @@ # Agent hook parity -This matrix records the current boundary between Claude Code and Codex hooks. It is reviewed against the generated Codex file at each configuration change. +This matrix records the current boundary between Claude Code and Codex hooks. It is reviewed against the generated Codex file at each configuration change, including the configured primary Polylogue hooks spool shared by every writer. | Capability | Claude Code | Codex | Evidence and action | | -------------------------- | ----------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------- | From 56551870311e9a2874997d16985e7b52ef794b50 Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 25 Aug 2026 13:23:01 +0200 Subject: [PATCH 31/35] fix(agents): derive Polylogue hook spool from config --- dots/claude/managed-settings.json | 10 +-- flake/tests/agent-parity.nix | 65 +++++++++++++------ flake/tests/agent-tools.nix | 25 ++++++- modules/features/dev/agents/hooks.nix | 12 ++-- modules/features/dev/agents/mcp.nix | 20 ++++++ .../features/dev/agents/polylogue-hook.nix | 11 ++++ 6 files changed, 110 insertions(+), 33 deletions(-) create mode 100644 modules/features/dev/agents/polylogue-hook.nix diff --git a/dots/claude/managed-settings.json b/dots/claude/managed-settings.json index 52321b6e..a7b8a3cb 100644 --- a/dots/claude/managed-settings.json +++ b/dots/claude/managed-settings.json @@ -72,7 +72,7 @@ "hooks": [ { "type": "command", - "command": "polylogue-hook PreToolUse --provider claude-code --sidecar-dir /realm/state/polylogue/hooks", + "command": "sinnix-polylogue-hook PreToolUse --provider claude-code", "timeout": 5 } ] @@ -83,7 +83,7 @@ "hooks": [ { "type": "command", - "command": "polylogue-hook PostToolUse --provider claude-code --sidecar-dir /realm/state/polylogue/hooks", + "command": "sinnix-polylogue-hook PostToolUse --provider claude-code", "timeout": 5 } ] @@ -94,7 +94,7 @@ "hooks": [ { "type": "command", - "command": "polylogue-hook Stop --provider claude-code --sidecar-dir /realm/state/polylogue/hooks", + "command": "sinnix-polylogue-hook Stop --provider claude-code", "timeout": 5 } ] @@ -125,7 +125,7 @@ }, { "type": "command", - "command": "polylogue-hook SessionStart --provider claude-code --sidecar-dir /realm/state/polylogue/hooks", + "command": "sinnix-polylogue-hook SessionStart --provider claude-code", "timeout": 5 } ] @@ -136,7 +136,7 @@ "hooks": [ { "type": "command", - "command": "polylogue-hook UserPromptSubmit --provider claude-code --sidecar-dir /realm/state/polylogue/hooks", + "command": "sinnix-polylogue-hook UserPromptSubmit --provider claude-code", "timeout": 5 } ] diff --git a/flake/tests/agent-parity.nix b/flake/tests/agent-parity.nix index c054336c..40b548d5 100644 --- a/flake/tests/agent-parity.nix +++ b/flake/tests/agent-parity.nix @@ -1,9 +1,10 @@ # Claude/Codex hook parity: the rows docs/agent-hook-parity.md records as # "Enforced" on both clients are checked against the two real hook sources # (dots/claude/managed-settings.json and the generated Codex hooks), not -# restated as a list of expected names here. +# restated as a list of expected names here. Both clients name the generated +# `sinnix-polylogue-hook` adapter; its configured root is checked below. # -# Provably fails when: a `polylogue-hook ` lane present in Claude's +# Provably fails when: a `sinnix-polylogue-hook ` lane present in Claude's # settings is dropped from the generated Codex hooks (verified by removing # the PostToolUse entry from modules/features/dev/agents/hooks.nix), when a # writer loses the canonical primary sidecar destination, or when either @@ -14,21 +15,31 @@ { system, ... }: let pkgs = inputs.nixpkgs.legacyPackages.${system}; - canonicalDataDir = "/realm/state/polylogue"; + sentinelDataDir = "/tmp/sinnix-polylogue-parity-sentinel"; codexHooks = import ../../modules/features/dev/agents/hooks.nix { inherit pkgs; dotsRoot = inputs.self + "/dots"; - dataDir = canonicalDataDir; }; claudeHooks = ../../dots/claude/managed-settings.json; polylogueHook = inputs.polylogue.packages.${system}.default; - primarySidecarDir = "${canonicalDataDir}/hooks"; + polylogueHookBin = import ../../modules/features/dev/agents/polylogue-hook.nix { + inherit (pkgs) lib; + inherit pkgs; + dataDir = sentinelDataDir; + inherit polylogueHook; + }; in { checks.agent-hook-parity = pkgs.runCommand "agent-hook-parity-check" { - inherit codexHooks claudeHooks polylogueHook primarySidecarDir; + inherit + codexHooks + claudeHooks + polylogueHook + polylogueHookBin + sentinelDataDir + ; nativeBuildInputs = [ pkgs.coreutils pkgs.jq @@ -46,7 +57,7 @@ | to_entries | map(select( .key as $event - | any(.value[]?.hooks[]?.command; startswith("polylogue-hook " + $event)) + | any(.value[]?.hooks[]?.command; startswith("sinnix-polylogue-hook " + $event)) )) | map(.key) | sort[] @@ -61,17 +72,16 @@ exit 1 fi - # Every actual Polylogue writer declaration must carry the same - # derived primary spool. This parses the commands themselves, so - # a missing argument and a stale literal both fail. + # Every actual Polylogue writer declaration must use the same + # generated executable and provider-specific payload shape. writer_rows() { jq -r ' .hooks | to_entries[] | .key as $event | .value[]?.hooks[]?.command - | select(type == "string" and startswith("polylogue-hook ")) - | [$event, (capture(" --sidecar-dir (?[^ ]+)$").destination)] + | select(type == "string" and startswith("sinnix-polylogue-hook ")) + | [$event, (capture(" --provider (?[^ ]+)$").provider)] | @tsv ' "$1" } @@ -79,13 +89,28 @@ writer_rows "$codexHooks" | sort > codex-writers test -s claude-writers test -s codex-writers - diff -u claude-writers codex-writers - while IFS=$'\t' read -r event destination; do - test "$destination" = "$primarySidecarDir" || { - echo "$event writer targets '$destination', expected '$primarySidecarDir'" >&2 - exit 1 - } - done < claude-writers + cut -f1 claude-writers > claude-writer-lanes + cut -f1 codex-writers > codex-writer-lanes + diff -u claude-writer-lanes codex-writer-lanes + grep -Fx $'PreToolUse\tclaude-code' claude-writers + grep -Fx $'PostToolUse\tclaude-code' claude-writers + grep -Fx $'SessionStart\tclaude-code' claude-writers + grep -Fx $'Stop\tclaude-code' claude-writers + grep -Fx $'UserPromptSubmit\tclaude-code' claude-writers + grep -Fx $'PreToolUse\tcodex' codex-writers + grep -Fx $'PostToolUse\tcodex' codex-writers + grep -Fx $'SessionStart\tcodex' codex-writers + grep -Fx $'Stop\tcodex' codex-writers + grep -Fx $'UserPromptSubmit\tcodex' codex-writers + + # This custom-root assertion is separate from command comparison. + # A default-root wrapper or an unconfigured packaged writer must + # fail even when both payloads agree. + grep -F -- "$sentinelDataDir/hooks" "$polylogueHookBin/bin/sinnix-polylogue-hook" + if grep -F -e '/realm/state/polylogue' -e '/home/sinity/.local/share/polylogue' "$polylogueHookBin/bin/sinnix-polylogue-hook"; then + echo 'generated Polylogue hook wrapper contains a default or legacy root' >&2 + exit 1 + fi # Fresh-writer smoke: isolate every ambient resolution input and # trace file access. The explicit primary spool must receive the @@ -107,7 +132,7 @@ test -n "$(find "$primary" -type f -print -quit)" grep -R -F 'parity-smoke' "$primary" >/dev/null test -z "$(find "$archive" "$legacy" "$smoke_root/home" "$smoke_root/xdg" -type f -print -quit)" - if grep -F -e '/realm/state/polylogue' -e '/home/sinity/.local/share/polylogue' "$trace"; then + if grep -F -e '/realm/state/polylogue' -e '/home/sinity/.local/share/polylogue' -e '/realm/db/polylogue' "$trace"; then echo 'isolated Polylogue hook smoke accessed a live or legacy root' >&2 exit 1 fi diff --git a/flake/tests/agent-tools.nix b/flake/tests/agent-tools.nix index 83e36829..a5d91988 100644 --- a/flake/tests/agent-tools.nix +++ b/flake/tests/agent-tools.nix @@ -8,6 +8,7 @@ in { system, ... }: let pkgs = inputs.nixpkgs.legacyPackages.${system}; + polylogueSentinelDataDir = "/tmp/sinnix-polylogue-agent-tools-sentinel"; # The packaged binary, not a copy of the source with a hand-patched # shebang: the wrapper and the withPackages interpreter that discovery # builds are part of what these fixtures are testing, and a fixture that @@ -95,6 +96,7 @@ in sinnix.features.dev.shell.enable = true; sinnix.features.dev.mcp-servers.enable = true; sinnix.services.clodex.enable = true; + sinnix.services.polylogue.dataDir = polylogueSentinelDataDir; }) ]; assertions = @@ -181,6 +183,7 @@ in ".local/bin/agy-sinnix" ".local/bin/hermes" ".local/bin/mcp-firecrawl" + ".local/bin/sinnix-polylogue-hook" ".local/bin/mcp-chrome-devtools" ".local/bin/mcp-polylogue" ".local/bin/mcp-sinex" @@ -258,6 +261,10 @@ in agentToolsRuntimeConfig.sinnix.features.dev.mcp-servers.codexLocalConfigSource; agentToolsCodexHooksSource = agentToolsRuntimeConfig.sinnix.features.dev.mcp-servers.codexHooksSource; + agentToolsPolylogueHookSource = + agentToolsRuntimeConfig.sinnix.features.dev.mcp-servers.polylogueHookSource; + agentToolsMcpPolylogueSource = + agentToolsRuntimeConfig.sinnix.features.dev.mcp-servers.mcpPolylogueSource; agentToolsAntigravityMcpConfigSource = agentToolsRuntimeConfig.sinnix.features.dev.mcp-servers.antigravityMcpConfigSource; agentToolsHermesConfigSource = @@ -469,6 +476,8 @@ in bash -n "$HOME/.local/bin/clodex" bash -n "$HOME/.local/bin/clodex-claude" bash -n "$HOME/.local/bin/sinnix-clodex-server" + test -x "$HOME/.local/bin/sinnix-polylogue-hook" + bash -n "$HOME/.local/bin/sinnix-polylogue-hook" for wrapper in \ ${lib.concatMapStringsSep " \\\n " (f: ''"$HOME/${f}"'') laneWrapperFiles} \ "$HOME/.local/bin/gemini" \ @@ -484,9 +493,21 @@ in ([.hooks.SessionStart[].hooks[].command] | any(contains("sessionstart-sinex-recall.sh"))) and ([.hooks.Stop[].hooks[].command] - | any(contains("polylogue-hook Stop --provider claude-code"))) + | any(. == "sinnix-polylogue-hook Stop --provider claude-code")) ' ${inputs.self}/dots/claude/managed-settings.json >/dev/null + # The live managed policy keeps its source in dots, while the + # executable it names is generated from the evaluated service + # option. The sentinel prevents a default-root wrapper from + # passing as a correctly derived one. + grep -F -- '${polylogueSentinelDataDir}/hooks' "$HOME/.local/bin/sinnix-polylogue-hook" + grep -F -- '${polylogueSentinelDataDir}' "${agentToolsPolylogueHookSource}/bin/sinnix-polylogue-hook" + grep -F -- '${polylogueSentinelDataDir}' "${agentToolsMcpPolylogueSource}/bin/mcp-polylogue" + if grep -F -e '/realm/state/polylogue' -e '/home/sinity/.local/share/polylogue' "${agentToolsPolylogueHookSource}/bin/sinnix-polylogue-hook" "${agentToolsMcpPolylogueSource}/bin/mcp-polylogue"; then + echo 'agent writer wrapper contains a default or legacy Polylogue root' >&2 + exit 1 + fi + # Rendered profile configs must match the registry's own computed # selection -- membership is derived from mcp-registry.nix at eval # time, never frozen as literals. @@ -571,7 +592,7 @@ in jq -e ' [.hooks.Stop[].hooks[].command] - | any(contains("polylogue-hook Stop --provider codex")) + | any(. == "sinnix-polylogue-hook Stop --provider codex") ' "$HOME/.codex/hooks.json" >/dev/null jq -e ' [.hooks.SessionStart[].hooks[].command] | any(contains("sessionstart-sinex-recall.sh")) diff --git a/modules/features/dev/agents/hooks.nix b/modules/features/dev/agents/hooks.nix index 1e5087c3..7ad48c7b 100644 --- a/modules/features/dev/agents/hooks.nix +++ b/modules/features/dev/agents/hooks.nix @@ -3,7 +3,7 @@ # imported directly by mcp.nix's # configFn; the generated file is exposed via the mcp-servers.codexHooksSource # option for tests. -{ pkgs, dotsRoot, dataDir }: +{ pkgs, dotsRoot }: let jsonFormat = pkgs.formats.json { }; in @@ -19,7 +19,7 @@ jsonFormat.generate "codex-hooks.json" { } { type = "command"; - command = "polylogue-hook SessionStart --provider codex --sidecar-dir ${dataDir}/hooks"; + command = "sinnix-polylogue-hook SessionStart --provider codex"; } ]; } @@ -29,7 +29,7 @@ jsonFormat.generate "codex-hooks.json" { hooks = [ { type = "command"; - command = "polylogue-hook UserPromptSubmit --provider codex --sidecar-dir ${dataDir}/hooks"; + command = "sinnix-polylogue-hook UserPromptSubmit --provider codex"; } ]; } @@ -49,7 +49,7 @@ jsonFormat.generate "codex-hooks.json" { hooks = [ { type = "command"; - command = "polylogue-hook PreToolUse --provider codex --sidecar-dir ${dataDir}/hooks"; + command = "sinnix-polylogue-hook PreToolUse --provider codex"; } ]; } @@ -59,7 +59,7 @@ jsonFormat.generate "codex-hooks.json" { hooks = [ { type = "command"; - command = "polylogue-hook PostToolUse --provider codex --sidecar-dir ${dataDir}/hooks"; + command = "sinnix-polylogue-hook PostToolUse --provider codex"; } ]; } @@ -69,7 +69,7 @@ jsonFormat.generate "codex-hooks.json" { hooks = [ { type = "command"; - command = "polylogue-hook Stop --provider codex --sidecar-dir ${dataDir}/hooks"; + command = "sinnix-polylogue-hook Stop --provider codex"; } ]; } diff --git a/modules/features/dev/agents/mcp.nix b/modules/features/dev/agents/mcp.nix index 186aad05..74d4d6db 100644 --- a/modules/features/dev/agents/mcp.nix +++ b/modules/features/dev/agents/mcp.nix @@ -60,6 +60,16 @@ mkFeatureModule { internal = true; description = "Path to the generated Codex hooks derivation (for tests)"; }; + polylogueHookSource = lib.mkOption { + type = lib.types.path; + internal = true; + description = "Path to the configured Polylogue hook wrapper (for tests)"; + }; + mcpPolylogueSource = lib.mkOption { + type = lib.types.path; + internal = true; + description = "Path to the configured Polylogue MCP wrapper (for tests)"; + }; antigravityMcpConfigSource = lib.mkOption { type = lib.types.path; internal = true; @@ -119,7 +129,11 @@ mkFeatureModule { codexHooksFile = import ./hooks.nix { inherit pkgs; dotsRoot = config.sinnix.paths.dotsRoot; + }; + polylogueHookBin = import ./polylogue-hook.nix { + inherit lib pkgs; dataDir = config.sinnix.services.polylogue.dataDir; + polylogueHook = scriptPkgs.polylogue-cli; }; inherit (browser) mcpChromeDevtoolsBin @@ -183,6 +197,8 @@ mkFeatureModule { sinnix.features.dev.mcp-servers.codexDeepseekConfigSource = codexDeepseekConfigFile; sinnix.features.dev.mcp-servers.codexLocalConfigSource = codexLocalConfigFile; sinnix.features.dev.mcp-servers.codexHooksSource = codexHooksFile; + sinnix.features.dev.mcp-servers.polylogueHookSource = polylogueHookBin; + sinnix.features.dev.mcp-servers.mcpPolylogueSource = mcpTools.mcpPolylogueBin; sinnix.features.dev.mcp-servers.antigravityMcpConfigSource = antigravityMcpConfigFile; sinnix.persistence.home.directories = [ ".local/state/sinnix/settings-env-lint" @@ -319,6 +335,10 @@ mkFeatureModule { force = true; text = mcpPolylogueText; }; + ".local/bin/sinnix-polylogue-hook" = { + source = "${polylogueHookBin}/bin/sinnix-polylogue-hook"; + force = true; + }; ".local/bin/mcp-sinex" = { source = "${scriptPkgs.sinnix-mcp-sinex}/bin/sinnix-mcp-sinex"; force = true; diff --git a/modules/features/dev/agents/polylogue-hook.nix b/modules/features/dev/agents/polylogue-hook.nix new file mode 100644 index 00000000..4bcfbd6c --- /dev/null +++ b/modules/features/dev/agents/polylogue-hook.nix @@ -0,0 +1,11 @@ +# Config-derived Polylogue hook adapter. Plain helper, not a module. +{ + lib, + pkgs, + dataDir, + polylogueHook, +}: +pkgs.writeShellScriptBin "sinnix-polylogue-hook" '' + set -euo pipefail + exec ${polylogueHook}/bin/polylogue-hook "$@" --sidecar-dir ${lib.escapeShellArg "${dataDir}/hooks"} +'' From 1c68020215c6944678cac824594b1dbaba9658bc Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 25 Aug 2026 13:55:13 +0200 Subject: [PATCH 32/35] fix(agents): close Polylogue root parity gaps --- flake/tests/agent-parity.nix | 94 +++++++++++++++++++++------- flake/tests/polylogue.nix | 59 +++++++++++++++++ modules/features/dev/agents/mcp.nix | 8 --- modules/lib/systemd-hardening.nix | 7 ++- modules/services/enrichment-loop.nix | 6 +- modules/services/polylogue.nix | 8 +++ scripts/sinnix-enrich-dump | 12 +++- 7 files changed, 157 insertions(+), 37 deletions(-) diff --git a/flake/tests/agent-parity.nix b/flake/tests/agent-parity.nix index 40b548d5..dc707196 100644 --- a/flake/tests/agent-parity.nix +++ b/flake/tests/agent-parity.nix @@ -16,6 +16,7 @@ let pkgs = inputs.nixpkgs.legacyPackages.${system}; sentinelDataDir = "/tmp/sinnix-polylogue-parity-sentinel"; + enrichDumpSource = ../../scripts/sinnix-enrich-dump; codexHooks = import ../../modules/features/dev/agents/hooks.nix { inherit pkgs; dotsRoot = inputs.self + "/dots"; @@ -36,6 +37,7 @@ inherit codexHooks claudeHooks + enrichDumpSource polylogueHook polylogueHookBin sentinelDataDir @@ -47,6 +49,38 @@ ]; } '' + require_text() { + needle="$1" + file="$2" + label="$3" + if ! grep -Fq -- "$needle" "$file"; then + echo "missing $label: $needle in $file" >&2 + exit 1 + fi + } + + reject_text() { + needle="$1" + file="$2" + label="$3" + if grep -Fq -- "$needle" "$file"; then + echo "forbidden $label: $needle in $file" >&2 + grep -Fn -- "$needle" "$file" >&2 + exit 1 + fi + } + + require_row() { + row="$1" + file="$2" + label="$3" + if ! grep -Fxq -- "$row" "$file"; then + echo "missing $label row: $row in $file" >&2 + sed -n '1,120p' "$file" >&2 + exit 1 + fi + } + # The evidence lanes: every lifecycle event on which a client # ships a session to Polylogue. Codex must cover at least what # Claude does, or half the machine's agent history stops being @@ -92,50 +126,64 @@ cut -f1 claude-writers > claude-writer-lanes cut -f1 codex-writers > codex-writer-lanes diff -u claude-writer-lanes codex-writer-lanes - grep -Fx $'PreToolUse\tclaude-code' claude-writers - grep -Fx $'PostToolUse\tclaude-code' claude-writers - grep -Fx $'SessionStart\tclaude-code' claude-writers - grep -Fx $'Stop\tclaude-code' claude-writers - grep -Fx $'UserPromptSubmit\tclaude-code' claude-writers - grep -Fx $'PreToolUse\tcodex' codex-writers - grep -Fx $'PostToolUse\tcodex' codex-writers - grep -Fx $'SessionStart\tcodex' codex-writers - grep -Fx $'Stop\tcodex' codex-writers - grep -Fx $'UserPromptSubmit\tcodex' codex-writers + require_row $'PreToolUse\tclaude-code' claude-writers claude-pretool + require_row $'PostToolUse\tclaude-code' claude-writers claude-posttool + require_row $'SessionStart\tclaude-code' claude-writers claude-sessionstart + require_row $'Stop\tclaude-code' claude-writers claude-stop + require_row $'UserPromptSubmit\tclaude-code' claude-writers claude-prompt + require_row $'PreToolUse\tcodex' codex-writers codex-pretool + require_row $'PostToolUse\tcodex' codex-writers codex-posttool + require_row $'SessionStart\tcodex' codex-writers codex-sessionstart + require_row $'Stop\tcodex' codex-writers codex-stop + require_row $'UserPromptSubmit\tcodex' codex-writers codex-prompt # This custom-root assertion is separate from command comparison. # A default-root wrapper or an unconfigured packaged writer must # fail even when both payloads agree. - grep -F -- "$sentinelDataDir/hooks" "$polylogueHookBin/bin/sinnix-polylogue-hook" - if grep -F -e '/realm/state/polylogue' -e '/home/sinity/.local/share/polylogue' "$polylogueHookBin/bin/sinnix-polylogue-hook"; then - echo 'generated Polylogue hook wrapper contains a default or legacy root' >&2 + require_text "$sentinelDataDir/hooks" "$polylogueHookBin/bin/sinnix-polylogue-hook" configured-hook-root + reject_text '/realm/state/polylogue' "$polylogueHookBin/bin/sinnix-polylogue-hook" default-hook-root + reject_text '/home/sinity/.local/share/polylogue' "$polylogueHookBin/bin/sinnix-polylogue-hook" legacy-hook-root + + # Standalone enrichment must fail before assembling or mutating + # any output when its archive-root contract is not configured. + if env -u POLYLOGUE_ARCHIVE_ROOT "${pkgs.bash}/bin/bash" "$enrichDumpSource" \ + >/dev/null 2>missing-enrich-root.stderr; then + echo 'enrichment dump accepted a missing Polylogue archive root' >&2 exit 1 fi + require_text POLYLOGUE_ARCHIVE_ROOT missing-enrich-root.stderr missing-enrich-root-diagnostic # Fresh-writer smoke: isolate every ambient resolution input and # trace file access. The explicit primary spool must receive the # event while archive/XDG fallback paths and the real legacy roots # remain untouched. smoke_root="$TMPDIR/polylogue-hook-smoke" - primary="$smoke_root/primary-hooks" + primary="$sentinelDataDir/hooks" archive="$smoke_root/archive" legacy="$smoke_root/legacy-home" - mkdir -p "$smoke_root/home" "$smoke_root/xdg" "$primary" "$archive" "$legacy" + mkdir -p "$smoke_root/home" "$smoke_root/xdg" "$archive" "$legacy" trace="$smoke_root/access.trace" printf '%s' '{"session_id":"parity-smoke","source":"codex","turn_id":"turn-1"}' \ | HOME="$smoke_root/home" \ XDG_DATA_HOME="$smoke_root/xdg" \ POLYLOGUE_ARCHIVE_ROOT="$archive" \ strace -f -e trace=file -o "$trace" \ - "$polylogueHook/bin/polylogue-hook" UserPromptSubmit \ - --provider codex --sidecar-dir "$primary" + "$polylogueHookBin/bin/sinnix-polylogue-hook" UserPromptSubmit \ + --provider codex test -n "$(find "$primary" -type f -print -quit)" - grep -R -F 'parity-smoke' "$primary" >/dev/null - test -z "$(find "$archive" "$legacy" "$smoke_root/home" "$smoke_root/xdg" -type f -print -quit)" - if grep -F -e '/realm/state/polylogue' -e '/home/sinity/.local/share/polylogue' -e '/realm/db/polylogue' "$trace"; then - echo 'isolated Polylogue hook smoke accessed a live or legacy root' >&2 - exit 1 - fi + record="$(find "$primary" -type f -name 'codex-parity-smoke.jsonl' -print -quit)" + test -n "$record" + jq -e '.event_type == "UserPromptSubmit" and .provider == "codex" and .payload.session_id == "parity-smoke"' "$record" >/dev/null \ + || { echo "wrapper did not preserve event/provider payload in $record" >&2; exit 1; } + require_text 'UserPromptSubmit' "$trace" forwarded-event + require_text '--provider' "$trace" forwarded-provider-flag + require_text 'codex' "$trace" forwarded-provider + require_text "$primary" "$trace" trailing-sidecar-destination + test -z "$(find "$archive" "$legacy" "$smoke_root/home" "$smoke_root/xdg" -mindepth 1 -print -quit)" \ + || { echo 'isolated Polylogue hook smoke wrote an ambient root' >&2; exit 1; } + reject_text '/realm/state/polylogue' "$trace" live-hook-root + reject_text '/home/sinity/.local/share/polylogue' "$trace" default-hook-root + reject_text '/realm/db/polylogue' "$trace" legacy-hook-root # The shared context handoff is configured independently for both # clients, so this verifies the intended cross-client agreement. diff --git a/flake/tests/polylogue.nix b/flake/tests/polylogue.nix index 469ab1a2..7c395106 100644 --- a/flake/tests/polylogue.nix +++ b/flake/tests/polylogue.nix @@ -54,6 +54,34 @@ in touch "$out" ''; + sentinelDataDir = "/tmp/sinnix-polylogue-service-sentinel"; + archiveRootSpec = mkServiceTest { + name = "polylogue-archive-root"; + service = "polylogue"; + extraModules = [ + (_: { + sinnix.services.polylogue.dataDir = sentinelDataDir; + }) + ]; + assertions = _config: [ ]; + }; + archiveRootEvaluated = evalTestSpec system archiveRootSpec; + enrichmentSpec = mkServiceTest { + name = "enrichment-polylogue-root"; + service = "enrichment-loop"; + extraModules = [ + (_: { + sinnix.services.polylogue.dataDir = sentinelDataDir; + }) + ]; + assertions = _config: [ ]; + }; + enrichmentEvaluated = evalTestSpec system enrichmentSpec; + enrichmentService = enrichmentEvaluated.config.systemd.user.services.sinnix-enrichment-loop; + polylogueTmpfiles = archiveRootEvaluated.config.systemd.tmpfiles.rules; + enrichmentReadWritePaths = enrichmentService.serviceConfig.ReadWritePaths; + enrichmentArchiveRoot = enrichmentService.environment.POLYLOGUE_ARCHIVE_ROOT; + overriddenSpec = mkServiceTest { name = "polylogue-memory-budget"; service = "polylogue"; @@ -74,6 +102,37 @@ in expectedMax = "27G"; expectedBudgetBytes = "25769803776"; }; + polylogue-archive-root = + pkgs.runCommand "sinnix-polylogue-archive-root-check" + { + inherit sentinelDataDir; + nativeBuildInputs = [ pkgs.jq ]; + actualTmpfiles = builtins.toJSON polylogueTmpfiles; + } + '' + jq -e --arg root "$sentinelDataDir" ' + index("d \($root)/inbox 0755 sinity users -") != null and + index("L+ \($root)/inbox/chatgpt - - - - /realm/data/ai/chatlog/raw/chatgpt") != null and + index("L+ \($root)/inbox/claude - - - - /realm/data/ai/chatlog/raw/claude") != null + ' <<<"$actualTmpfiles" >/dev/null + touch "$out" + ''; + enrichment-polylogue-root = + pkgs.runCommand "sinnix-enrichment-polylogue-root-check" + { + inherit sentinelDataDir enrichmentArchiveRoot; + nativeBuildInputs = [ pkgs.jq ]; + actualReadWritePaths = builtins.toJSON enrichmentReadWritePaths; + } + '' + test "$enrichmentArchiveRoot" = "$sentinelDataDir" + jq -e --arg hook "$sentinelDataDir/hooks" 'index($hook) != null' <<<"$actualReadWritePaths" >/dev/null + if jq -e 'index("/realm/state/polylogue/hooks") != null' <<<"$actualReadWritePaths" >/dev/null; then + echo "enrichment hardening retained the default Polylogue hook root" >&2 + exit 1 + fi + touch "$out" + ''; }; }; } diff --git a/modules/features/dev/agents/mcp.nix b/modules/features/dev/agents/mcp.nix index 74d4d6db..8053cf16 100644 --- a/modules/features/dev/agents/mcp.nix +++ b/modules/features/dev/agents/mcp.nix @@ -343,14 +343,6 @@ mkFeatureModule { source = "${scriptPkgs.sinnix-mcp-sinex}/bin/sinnix-mcp-sinex"; force = true; }; - ".local/share/polylogue/inbox/chatgpt" = { - source = config.lib.file.mkOutOfStoreSymlink "/realm/data/ai/chatlog/raw/chatgpt"; - force = true; - }; - ".local/share/polylogue/inbox/claude" = { - source = config.lib.file.mkOutOfStoreSymlink "/realm/data/ai/chatlog/raw/claude"; - force = true; - }; }; }; } diff --git a/modules/lib/systemd-hardening.nix b/modules/lib/systemd-hardening.nix index 22f0ff47..89304f2c 100644 --- a/modules/lib/systemd-hardening.nix +++ b/modules/lib/systemd-hardening.nix @@ -5,13 +5,16 @@ # ReadWritePaths silently discards that agent's session transcript and hook # spool. Any such unit must union this in and leave ProtectHome off. agentRuntimeWritePaths = - { home }: + { + home, + polylogueDataDir, + }: [ "${home}/.claude" "${home}/.codex" "${home}/.cache/claude-cli-nodejs" "${home}/.local/state/claude-code" - "/realm/state/polylogue/hooks" + "${polylogueDataDir}/hooks" ]; mkRestartPolicy = diff --git a/modules/services/enrichment-loop.nix b/modules/services/enrichment-loop.nix index 6b56796f..97e58b61 100644 --- a/modules/services/enrichment-loop.nix +++ b/modules/services/enrichment-loop.nix @@ -81,12 +81,16 @@ mkServiceModule { # workspace makes that input fail rather than be absent. "/realm/project/steering" ] - ++ lib.sinnix.systemd.agentRuntimeWritePaths { home = homeDir; }; + ++ lib.sinnix.systemd.agentRuntimeWritePaths { + home = homeDir; + polylogueDataDir = config.sinnix.services.polylogue.dataDir; + }; # Clear of the observed run distribution (healthy passes run # 1-3min) but under the hourly interval, so a slow pass still # cannot overlap its successor. TimeoutStartSec = "600s"; }; + environment.POLYLOGUE_ARCHIVE_ROOT = config.sinnix.services.polylogue.dataDir; timer = { onBootSec = "5min"; onUnitActiveSec = "${toString cfg.intervalMinutes}min"; diff --git a/modules/services/polylogue.nix b/modules/services/polylogue.nix index 1b01a5fb..7ca94469 100644 --- a/modules/services/polylogue.nix +++ b/modules/services/polylogue.nix @@ -154,6 +154,14 @@ mkServiceModule { } ]; + # These are Polylogue archive inputs, so their destination must follow + # the same archive-root option as the daemon and hook spool. + systemd.tmpfiles.rules = [ + "d ${cfg.dataDir}/inbox 0755 ${userName} users -" + "L+ ${cfg.dataDir}/inbox/chatgpt - - - - /realm/data/ai/chatlog/raw/chatgpt" + "L+ ${cfg.dataDir}/inbox/claude - - - - /realm/data/ai/chatlog/raw/claude" + ]; + # ── Import the upstream Home Manager module ──────────────────── home-manager.users.${userName} = { imports = [ inputs.polylogue.homeManagerModules.default ]; diff --git a/scripts/sinnix-enrich-dump b/scripts/sinnix-enrich-dump index 96d00017..ecb69833 100644 --- a/scripts/sinnix-enrich-dump +++ b/scripts/sinnix-enrich-dump @@ -10,8 +10,14 @@ set -euo pipefail # Single-writer invariant: writes only the output root and the watermark at # /realm/state/cursors/enrichment/; never polylogue's or sinex's stores. # -# Every input is optional. Absent or empty means nothing changed since the -# watermark, not a fault -- the skill tells the model to treat it that way. +# The service exports POLYLOGUE_ARCHIVE_ROOT from its configured dataDir. +# Standalone callers must export the same variable; guessing a root would make +# an apparently successful pass read the wrong archive. + +if [[ -z "${POLYLOGUE_ARCHIVE_ROOT:-}" ]]; then + echo "sinnix-enrich-dump: POLYLOGUE_ARCHIVE_ROOT is required (set it to the configured Polylogue dataDir)" >&2 + exit 2 +fi WATERMARK_FILE="/realm/state/cursors/enrichment/last-run" OUTPUT_ROOT="/realm/data/derived/reports/enrichment" @@ -103,7 +109,7 @@ fi # timestamp then selects the rows actually in-window (mtime alone over-read # this ~33x). Uncapped -- the model reads by offset, so a wide window costs # turns rather than truncated evidence. -POLYLOGUE_HOOKS_DIR="/realm/state/polylogue/hooks" +POLYLOGUE_HOOKS_DIR="${POLYLOGUE_ARCHIVE_ROOT}/hooks" if [[ -d "$POLYLOGUE_HOOKS_DIR" ]]; then : >"$BUNDLE/polylogue-hooks.jsonl" find "$POLYLOGUE_HOOKS_DIR" -name '*.jsonl' -newermt "$SINCE_ISO" -exec cat {} + 2>/dev/null \ From 8d3e12d03915a7ae36c203dfedcf07463cf0b545 Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 25 Aug 2026 14:26:22 +0200 Subject: [PATCH 33/35] fix(agents): restore upstream Polylogue hook command Use the installed polylogue-hook for Claude and Codex without baked sidecar paths. Make parity tests exercise the generated archive root and remove the wrapper-only Home Manager surface. --- dots/claude/managed-settings.json | 10 +- flake/tests/agent-parity.nix | 160 ++++++++++++------ flake/tests/agent-tools.nix | 23 +-- modules/features/dev/agents/hooks.nix | 10 +- modules/features/dev/agents/mcp.nix | 21 --- .../features/dev/agents/polylogue-hook.nix | 11 -- 6 files changed, 123 insertions(+), 112 deletions(-) delete mode 100644 modules/features/dev/agents/polylogue-hook.nix diff --git a/dots/claude/managed-settings.json b/dots/claude/managed-settings.json index a7b8a3cb..3631501e 100644 --- a/dots/claude/managed-settings.json +++ b/dots/claude/managed-settings.json @@ -72,7 +72,7 @@ "hooks": [ { "type": "command", - "command": "sinnix-polylogue-hook PreToolUse --provider claude-code", + "command": "polylogue-hook PreToolUse --provider claude-code", "timeout": 5 } ] @@ -83,7 +83,7 @@ "hooks": [ { "type": "command", - "command": "sinnix-polylogue-hook PostToolUse --provider claude-code", + "command": "polylogue-hook PostToolUse --provider claude-code", "timeout": 5 } ] @@ -94,7 +94,7 @@ "hooks": [ { "type": "command", - "command": "sinnix-polylogue-hook Stop --provider claude-code", + "command": "polylogue-hook Stop --provider claude-code", "timeout": 5 } ] @@ -125,7 +125,7 @@ }, { "type": "command", - "command": "sinnix-polylogue-hook SessionStart --provider claude-code", + "command": "polylogue-hook SessionStart --provider claude-code", "timeout": 5 } ] @@ -136,7 +136,7 @@ "hooks": [ { "type": "command", - "command": "sinnix-polylogue-hook UserPromptSubmit --provider claude-code", + "command": "polylogue-hook UserPromptSubmit --provider claude-code", "timeout": 5 } ] diff --git a/flake/tests/agent-parity.nix b/flake/tests/agent-parity.nix index dc707196..f9764698 100644 --- a/flake/tests/agent-parity.nix +++ b/flake/tests/agent-parity.nix @@ -1,15 +1,18 @@ # Claude/Codex hook parity: the rows docs/agent-hook-parity.md records as # "Enforced" on both clients are checked against the two real hook sources # (dots/claude/managed-settings.json and the generated Codex hooks), not -# restated as a list of expected names here. Both clients name the generated -# `sinnix-polylogue-hook` adapter; its configured root is checked below. +# restated as a list of expected names here. Both clients invoke the installed +# upstream `polylogue-hook`; its archive root comes from generated +# `polylogue.toml`. # -# Provably fails when: a `sinnix-polylogue-hook ` lane present in Claude's -# settings is dropped from the generated Codex hooks (verified by removing -# the PostToolUse entry from modules/features/dev/agents/hooks.nix), when a -# writer loses the canonical primary sidecar destination, or when either -# client loses the pre-compaction handoff or the shared hook coverage. +# Provably fails when: a `polylogue-hook ` lane present in Claude's +# settings is dropped from the generated Codex hooks, when a writer bakes a +# sidecar path, when generated archive.root stops following dataDir, or when +# either client loses the pre-compaction handoff or shared hook coverage. { inputs, ... }: +let + inherit (inputs.nixpkgs) lib; +in { perSystem = { system, ... }: @@ -17,34 +20,51 @@ pkgs = inputs.nixpkgs.legacyPackages.${system}; sentinelDataDir = "/tmp/sinnix-polylogue-parity-sentinel"; enrichDumpSource = ../../scripts/sinnix-enrich-dump; + repoRoot = inputs.self; codexHooks = import ../../modules/features/dev/agents/hooks.nix { inherit pkgs; dotsRoot = inputs.self + "/dots"; }; claudeHooks = ../../dots/claude/managed-settings.json; polylogueHook = inputs.polylogue.packages.${system}.default; - polylogueHookBin = import ../../modules/features/dev/agents/polylogue-hook.nix { - inherit (pkgs) lib; - inherit pkgs; - dataDir = sentinelDataDir; - inherit polylogueHook; + testLib = import ../test-lib.nix { inherit inputs lib; }; + inherit (testLib) + evalTestSpec + hmFor + mkServiceTest + ; + polylogueSpec = mkServiceTest { + name = "polylogue-agent-hook-parity"; + service = "polylogue"; + extraModules = [ + (_: { + sinnix.services.polylogue.dataDir = sentinelDataDir; + }) + ]; + assertions = _config: [ ]; }; + polylogueEvaluated = evalTestSpec system polylogueSpec; + polylogueConfigSource = + (hmFor polylogueEvaluated.config).xdg.configFile."polylogue/polylogue.toml".source; in { checks.agent-hook-parity = pkgs.runCommand "agent-hook-parity-check" { inherit - codexHooks claudeHooks + codexHooks enrichDumpSource + polylogueConfigSource polylogueHook - polylogueHookBin + repoRoot sentinelDataDir ; nativeBuildInputs = [ pkgs.coreutils + pkgs.gnugrep pkgs.jq + pkgs.ripgrep pkgs.strace ]; } @@ -81,6 +101,8 @@ fi } + test -x "$polylogueHook/bin/polylogue-hook" + # The evidence lanes: every lifecycle event on which a client # ships a session to Polylogue. Codex must cover at least what # Claude does, or half the machine's agent history stops being @@ -91,7 +113,7 @@ | to_entries | map(select( .key as $event - | any(.value[]?.hooks[]?.command; startswith("sinnix-polylogue-hook " + $event)) + | any(.value[]?.hooks[]?.command; startswith("polylogue-hook " + $event)) )) | map(.key) | sort[] @@ -106,15 +128,16 @@ exit 1 fi - # Every actual Polylogue writer declaration must use the same - # generated executable and provider-specific payload shape. + # Every actual Polylogue writer declaration must use the upstream + # executable with event/provider arguments only. In particular, + # the anchored provider capture rejects any trailing sidecar flag. writer_rows() { jq -r ' .hooks | to_entries[] | .key as $event | .value[]?.hooks[]?.command - | select(type == "string" and startswith("sinnix-polylogue-hook ")) + | select(type == "string" and startswith("polylogue-hook ")) | [$event, (capture(" --provider (?[^ ]+)$").provider)] | @tsv ' "$1" @@ -137,12 +160,39 @@ require_row $'Stop\tcodex' codex-writers codex-stop require_row $'UserPromptSubmit\tcodex' codex-writers codex-prompt - # This custom-root assertion is separate from command comparison. - # A default-root wrapper or an unconfigured packaged writer must - # fail even when both payloads agree. - require_text "$sentinelDataDir/hooks" "$polylogueHookBin/bin/sinnix-polylogue-hook" configured-hook-root - reject_text '/realm/state/polylogue' "$polylogueHookBin/bin/sinnix-polylogue-hook" default-hook-root - reject_text '/home/sinity/.local/share/polylogue' "$polylogueHookBin/bin/sinnix-polylogue-hook" legacy-hook-root + # Check the generated production config, not a test-only wrapper. + # This is the authority consumed by a flag-less upstream hook. + awk -v expected="root = \"$sentinelDataDir\"" ' + $0 == "[archive]" { in_archive = 1; next } + /^\[/ { in_archive = 0 } + in_archive && $0 == expected { found = 1 } + END { exit(found ? 0 : 1) } + ' "$polylogueConfigSource" || { + echo "generated polylogue.toml does not derive archive.root from dataDir" >&2 + sed -n '1,40p' "$polylogueConfigSource" >&2 + exit 1 + } + reject_text '/realm/state/polylogue' "$polylogueConfigSource" default-config-root + reject_text '/home/sinity/.local/share/polylogue' "$polylogueConfigSource" legacy-config-root + + # No repository hook command may bake a sidecar path. Keep this + # source census scoped to the actual hook declarations so this + # assertion cannot pass because a test copied a command literal. + sidecar_flag="--sidecar"-dir + if rg -n --fixed-strings -- "$sidecar_flag" \ + "$repoRoot/dots/claude/managed-settings.json" \ + "$repoRoot/modules/features/dev/agents/hooks.nix"; then + echo 'repository hook command bakes a Polylogue sidecar path' >&2 + exit 1 + fi + test ! -e "$repoRoot/modules/features/dev/agents/polylogue-hook.nix" + + # The managed Claude file is an out-of-store dots symlink. It and + # generated Codex config both retain the continuously installed + # upstream command name, so landing cannot create an activation + # interval where the named executable is absent. + require_text 'polylogue-hook Stop --provider claude-code' "$claudeHooks" claude-command-name + require_text 'polylogue-hook Stop --provider codex' "$codexHooks" codex-command-name # Standalone enrichment must fail before assembling or mutating # any output when its archive-root contract is not configured. @@ -153,37 +203,49 @@ fi require_text POLYLOGUE_ARCHIVE_ROOT missing-enrich-root.stderr missing-enrich-root-diagnostic - # Fresh-writer smoke: isolate every ambient resolution input and - # trace file access. The explicit primary spool must receive the - # event while archive/XDG fallback paths and the real legacy roots - # remain untouched. + # Fresh-writer smoke: install the generated TOML at the same XDG + # path Home Manager uses in production, leave POLYLOGUE_ARCHIVE_ROOT + # unset, and execute the actual upstream hook for both providers. + # HOME, XDG data/state, and the absent archive-root environment are + # decoys. Only the configured archive root may receive sidecars. smoke_root="$TMPDIR/polylogue-hook-smoke" primary="$sentinelDataDir/hooks" - archive="$smoke_root/archive" - legacy="$smoke_root/legacy-home" - mkdir -p "$smoke_root/home" "$smoke_root/xdg" "$archive" "$legacy" + config_home="$smoke_root/config" + archive_decoy="$smoke_root/archive-decoy" + mkdir -p "$smoke_root/home" "$config_home/polylogue" "$smoke_root/xdg-data" "$smoke_root/xdg-state" "$archive_decoy" + cp "$polylogueConfigSource" "$config_home/polylogue/polylogue.toml" trace="$smoke_root/access.trace" - printf '%s' '{"session_id":"parity-smoke","source":"codex","turn_id":"turn-1"}' \ - | HOME="$smoke_root/home" \ - XDG_DATA_HOME="$smoke_root/xdg" \ - POLYLOGUE_ARCHIVE_ROOT="$archive" \ + printf '%s' '{"session_id":"parity-codex","source":"codex","turn_id":"turn-1"}' \ + | env -u POLYLOGUE_ARCHIVE_ROOT -u POLYLOGUE_CONFIG \ + HOME="$smoke_root/home" \ + XDG_CONFIG_HOME="$config_home" \ + XDG_DATA_HOME="$smoke_root/xdg-data" \ + XDG_STATE_HOME="$smoke_root/xdg-state" \ strace -f -e trace=file -o "$trace" \ - "$polylogueHookBin/bin/sinnix-polylogue-hook" UserPromptSubmit \ + "$polylogueHook/bin/polylogue-hook" UserPromptSubmit \ --provider codex - test -n "$(find "$primary" -type f -print -quit)" - record="$(find "$primary" -type f -name 'codex-parity-smoke.jsonl' -print -quit)" - test -n "$record" - jq -e '.event_type == "UserPromptSubmit" and .provider == "codex" and .payload.session_id == "parity-smoke"' "$record" >/dev/null \ - || { echo "wrapper did not preserve event/provider payload in $record" >&2; exit 1; } - require_text 'UserPromptSubmit' "$trace" forwarded-event - require_text '--provider' "$trace" forwarded-provider-flag - require_text 'codex' "$trace" forwarded-provider - require_text "$primary" "$trace" trailing-sidecar-destination - test -z "$(find "$archive" "$legacy" "$smoke_root/home" "$smoke_root/xdg" -mindepth 1 -print -quit)" \ - || { echo 'isolated Polylogue hook smoke wrote an ambient root' >&2; exit 1; } + printf '%s' '{"session_id":"parity-claude","source":"claude","turn_id":"turn-2"}' \ + | env -u POLYLOGUE_ARCHIVE_ROOT -u POLYLOGUE_CONFIG \ + HOME="$smoke_root/home" \ + XDG_CONFIG_HOME="$config_home" \ + XDG_DATA_HOME="$smoke_root/xdg-data" \ + XDG_STATE_HOME="$smoke_root/xdg-state" \ + strace -f -e trace=file -o "$smoke_root/claude.trace" \ + "$polylogueHook/bin/polylogue-hook" Stop \ + --provider claude-code + test -n "$(find "$primary" -type f -name 'codex-*.jsonl' -print -quit)" + test -n "$(find "$primary" -type f -name 'claude-code-*.jsonl' -print -quit)" + require_text UserPromptSubmit "$trace" codex-event + require_text --provider "$trace" provider-flag + require_text codex "$trace" codex-provider + require_text Stop "$smoke_root/claude.trace" claude-event + require_text claude-code "$smoke_root/claude.trace" claude-provider + test -z "$(find "$archive_decoy" "$smoke_root/home" "$smoke_root/xdg-data" "$smoke_root/xdg-state" -mindepth 1 -print -quit)" \ + || { echo 'isolated Polylogue hook smoke wrote a decoy root' >&2; exit 1; } reject_text '/realm/state/polylogue' "$trace" live-hook-root - reject_text '/home/sinity/.local/share/polylogue' "$trace" default-hook-root - reject_text '/realm/db/polylogue' "$trace" legacy-hook-root + reject_text '/home/sinity/.local/share/polylogue' "$trace" legacy-hook-root + reject_text '/realm/state/polylogue' "$smoke_root/claude.trace" claude-live-hook-root + reject_text '/home/sinity/.local/share/polylogue' "$smoke_root/claude.trace" claude-legacy-hook-root # The shared context handoff is configured independently for both # clients, so this verifies the intended cross-client agreement. diff --git a/flake/tests/agent-tools.nix b/flake/tests/agent-tools.nix index a5d91988..779965e2 100644 --- a/flake/tests/agent-tools.nix +++ b/flake/tests/agent-tools.nix @@ -183,7 +183,6 @@ in ".local/bin/agy-sinnix" ".local/bin/hermes" ".local/bin/mcp-firecrawl" - ".local/bin/sinnix-polylogue-hook" ".local/bin/mcp-chrome-devtools" ".local/bin/mcp-polylogue" ".local/bin/mcp-sinex" @@ -261,10 +260,6 @@ in agentToolsRuntimeConfig.sinnix.features.dev.mcp-servers.codexLocalConfigSource; agentToolsCodexHooksSource = agentToolsRuntimeConfig.sinnix.features.dev.mcp-servers.codexHooksSource; - agentToolsPolylogueHookSource = - agentToolsRuntimeConfig.sinnix.features.dev.mcp-servers.polylogueHookSource; - agentToolsMcpPolylogueSource = - agentToolsRuntimeConfig.sinnix.features.dev.mcp-servers.mcpPolylogueSource; agentToolsAntigravityMcpConfigSource = agentToolsRuntimeConfig.sinnix.features.dev.mcp-servers.antigravityMcpConfigSource; agentToolsHermesConfigSource = @@ -476,8 +471,6 @@ in bash -n "$HOME/.local/bin/clodex" bash -n "$HOME/.local/bin/clodex-claude" bash -n "$HOME/.local/bin/sinnix-clodex-server" - test -x "$HOME/.local/bin/sinnix-polylogue-hook" - bash -n "$HOME/.local/bin/sinnix-polylogue-hook" for wrapper in \ ${lib.concatMapStringsSep " \\\n " (f: ''"$HOME/${f}"'') laneWrapperFiles} \ "$HOME/.local/bin/gemini" \ @@ -493,21 +486,9 @@ in ([.hooks.SessionStart[].hooks[].command] | any(contains("sessionstart-sinex-recall.sh"))) and ([.hooks.Stop[].hooks[].command] - | any(. == "sinnix-polylogue-hook Stop --provider claude-code")) + | any(. == "polylogue-hook Stop --provider claude-code")) ' ${inputs.self}/dots/claude/managed-settings.json >/dev/null - # The live managed policy keeps its source in dots, while the - # executable it names is generated from the evaluated service - # option. The sentinel prevents a default-root wrapper from - # passing as a correctly derived one. - grep -F -- '${polylogueSentinelDataDir}/hooks' "$HOME/.local/bin/sinnix-polylogue-hook" - grep -F -- '${polylogueSentinelDataDir}' "${agentToolsPolylogueHookSource}/bin/sinnix-polylogue-hook" - grep -F -- '${polylogueSentinelDataDir}' "${agentToolsMcpPolylogueSource}/bin/mcp-polylogue" - if grep -F -e '/realm/state/polylogue' -e '/home/sinity/.local/share/polylogue' "${agentToolsPolylogueHookSource}/bin/sinnix-polylogue-hook" "${agentToolsMcpPolylogueSource}/bin/mcp-polylogue"; then - echo 'agent writer wrapper contains a default or legacy Polylogue root' >&2 - exit 1 - fi - # Rendered profile configs must match the registry's own computed # selection -- membership is derived from mcp-registry.nix at eval # time, never frozen as literals. @@ -592,7 +573,7 @@ in jq -e ' [.hooks.Stop[].hooks[].command] - | any(. == "sinnix-polylogue-hook Stop --provider codex") + | any(. == "polylogue-hook Stop --provider codex") ' "$HOME/.codex/hooks.json" >/dev/null jq -e ' [.hooks.SessionStart[].hooks[].command] | any(contains("sessionstart-sinex-recall.sh")) diff --git a/modules/features/dev/agents/hooks.nix b/modules/features/dev/agents/hooks.nix index 7ad48c7b..9611ff7e 100644 --- a/modules/features/dev/agents/hooks.nix +++ b/modules/features/dev/agents/hooks.nix @@ -19,7 +19,7 @@ jsonFormat.generate "codex-hooks.json" { } { type = "command"; - command = "sinnix-polylogue-hook SessionStart --provider codex"; + command = "polylogue-hook SessionStart --provider codex"; } ]; } @@ -29,7 +29,7 @@ jsonFormat.generate "codex-hooks.json" { hooks = [ { type = "command"; - command = "sinnix-polylogue-hook UserPromptSubmit --provider codex"; + command = "polylogue-hook UserPromptSubmit --provider codex"; } ]; } @@ -49,7 +49,7 @@ jsonFormat.generate "codex-hooks.json" { hooks = [ { type = "command"; - command = "sinnix-polylogue-hook PreToolUse --provider codex"; + command = "polylogue-hook PreToolUse --provider codex"; } ]; } @@ -59,7 +59,7 @@ jsonFormat.generate "codex-hooks.json" { hooks = [ { type = "command"; - command = "sinnix-polylogue-hook PostToolUse --provider codex"; + command = "polylogue-hook PostToolUse --provider codex"; } ]; } @@ -69,7 +69,7 @@ jsonFormat.generate "codex-hooks.json" { hooks = [ { type = "command"; - command = "sinnix-polylogue-hook Stop --provider codex"; + command = "polylogue-hook Stop --provider codex"; } ]; } diff --git a/modules/features/dev/agents/mcp.nix b/modules/features/dev/agents/mcp.nix index 8053cf16..3f99b0cb 100644 --- a/modules/features/dev/agents/mcp.nix +++ b/modules/features/dev/agents/mcp.nix @@ -60,16 +60,6 @@ mkFeatureModule { internal = true; description = "Path to the generated Codex hooks derivation (for tests)"; }; - polylogueHookSource = lib.mkOption { - type = lib.types.path; - internal = true; - description = "Path to the configured Polylogue hook wrapper (for tests)"; - }; - mcpPolylogueSource = lib.mkOption { - type = lib.types.path; - internal = true; - description = "Path to the configured Polylogue MCP wrapper (for tests)"; - }; antigravityMcpConfigSource = lib.mkOption { type = lib.types.path; internal = true; @@ -130,11 +120,6 @@ mkFeatureModule { inherit pkgs; dotsRoot = config.sinnix.paths.dotsRoot; }; - polylogueHookBin = import ./polylogue-hook.nix { - inherit lib pkgs; - dataDir = config.sinnix.services.polylogue.dataDir; - polylogueHook = scriptPkgs.polylogue-cli; - }; inherit (browser) mcpChromeDevtoolsBin desktopControlScripts @@ -197,8 +182,6 @@ mkFeatureModule { sinnix.features.dev.mcp-servers.codexDeepseekConfigSource = codexDeepseekConfigFile; sinnix.features.dev.mcp-servers.codexLocalConfigSource = codexLocalConfigFile; sinnix.features.dev.mcp-servers.codexHooksSource = codexHooksFile; - sinnix.features.dev.mcp-servers.polylogueHookSource = polylogueHookBin; - sinnix.features.dev.mcp-servers.mcpPolylogueSource = mcpTools.mcpPolylogueBin; sinnix.features.dev.mcp-servers.antigravityMcpConfigSource = antigravityMcpConfigFile; sinnix.persistence.home.directories = [ ".local/state/sinnix/settings-env-lint" @@ -335,10 +318,6 @@ mkFeatureModule { force = true; text = mcpPolylogueText; }; - ".local/bin/sinnix-polylogue-hook" = { - source = "${polylogueHookBin}/bin/sinnix-polylogue-hook"; - force = true; - }; ".local/bin/mcp-sinex" = { source = "${scriptPkgs.sinnix-mcp-sinex}/bin/sinnix-mcp-sinex"; force = true; diff --git a/modules/features/dev/agents/polylogue-hook.nix b/modules/features/dev/agents/polylogue-hook.nix deleted file mode 100644 index 4bcfbd6c..00000000 --- a/modules/features/dev/agents/polylogue-hook.nix +++ /dev/null @@ -1,11 +0,0 @@ -# Config-derived Polylogue hook adapter. Plain helper, not a module. -{ - lib, - pkgs, - dataDir, - polylogueHook, -}: -pkgs.writeShellScriptBin "sinnix-polylogue-hook" '' - set -euo pipefail - exec ${polylogueHook}/bin/polylogue-hook "$@" --sidecar-dir ${lib.escapeShellArg "${dataDir}/hooks"} -'' From 95847267fd81829cb57f25c7027fbd19afe9b716 Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 25 Aug 2026 18:05:58 +0200 Subject: [PATCH 34/35] fix(agentctl): close environment rollout gaps --- docs/sinnixd.md | 2 +- flake/command-registry.nix | 1 + pkgs/sinnixd/sinnixd/projects.py | 41 +++++++++++++++++++++++++------- pkgs/sinnixd/test_service.py | 6 ++++- 4 files changed, 40 insertions(+), 10 deletions(-) diff --git a/docs/sinnixd.md b/docs/sinnixd.md index f2e6823c..5f4196af 100644 --- a/docs/sinnixd.md +++ b/docs/sinnixd.md @@ -43,7 +43,7 @@ agentctl task create sinnix 'Follow-up title' --description 'Bounded task descri The service passes a declarative, non-empty `sinnix.services.sinnixd.projectRoots` list as repeated `--project-root` arguments. It defaults to the registered Sinnix project entries. Sinnixd loads only those `.agentctl/project.toml` adapters and does not scan arbitrary directories. Each descriptor is schema-versioned, identifies its repository root markers, declares the execution environment, and publishes named operation metadata. A descriptor with a `[workspace]` is agent-capable and must declare non-empty shell-free argv lists for both `environment.command` and `environment.preflight`; the latter runs inside the former from the revalidated checkout before any backend starts. `agentctl project get ` publishes that capability and both public argv lists. -Before `switch`, `boot`, or the devshell rebuild wrapper evaluates the configured `sinnix.services.sinnixd.projectRoots` and runs `sinnixd-project-environment-check` against the real descriptors. It reports every project that is missing or has an invalid required declaration and refuses the activation before the NixOS build. Roll out descriptor commits first, then this Sinnix commit. The minimum exact declaration for every external agent-capable descriptor is `preflight = ["true"]` under `[environment]`; a project may use a stronger cheap project-native readiness argv instead. The current external prerequisites are Polylogue, Sinex, and Lynchpin; do not switch this commit until all three descriptors satisfy that contract. +The `switch`, `boot`, and `test-system` commands, including their devshell wrappers, evaluate the configured `sinnix.services.sinnixd.projectRoots` and run `sinnixd-project-environment-check` against the real descriptors. The check reports every project that is missing or has an invalid required declaration and refuses activation before the NixOS build. Roll out descriptor commits first, then this Sinnix commit. The minimum exact declaration for every external agent-capable descriptor is `preflight = ["true"]` under `[environment]`; a project may use a stronger cheap project-native readiness argv instead. The current external prerequisites are Polylogue, Sinex, and Lynchpin; do not switch this commit until all three descriptors satisfy that contract. `job start` accepts a project ID, one declared operation name, an optional workspace binding, and an optional JSON parameters object. It never accepts an arbitrary command. Its optional workspace binding launches in that registered checkout and durably records the checkout ID and exact starting HEAD, so later publication can reject stale verification. Declared operations and internal synthetic foreground commands construct the same durable generic-job spec, record, transient user `.service` launch, log artifact, reconciliation, wait, and cancellation route. A descriptor may set a bounded `timeout_seconds`; that becomes the transient service limit, rather than a caller-controlled duration. The typed attested-agent contract also accepts an optional validated Beads binding from the gateway. It is public durable provenance, not prompt material: canonical bead/project/checkout refs, launch task revision and etag, optional claim receipt, request ID, and display-only work item. The only additional public starts are the constrained typed contracts below. diff --git a/flake/command-registry.nix b/flake/command-registry.nix index 5b1c4b45..2aa6d169 100644 --- a/flake/command-registry.nix +++ b/flake/command-registry.nix @@ -540,6 +540,7 @@ in ${avoidRepoCwdForActivation} ${localInputOverrideArgs} ${rebuildDefaultArgs} + ${agentEnvironmentContract} ${pkgs.systemd}/bin/systemd-run \ --user \ --quiet --collect --pipe --service-type=exec --wait \ diff --git a/pkgs/sinnixd/sinnixd/projects.py b/pkgs/sinnixd/sinnixd/projects.py index 844f9dd1..717c2bc8 100644 --- a/pkgs/sinnixd/sinnixd/projects.py +++ b/pkgs/sinnixd/sinnixd/projects.py @@ -1333,6 +1333,39 @@ def validate_agent_environment_descriptors(roots: Iterable[Path]) -> None: project = raw.get("project") if isinstance(project, Mapping) and isinstance(project.get("id"), str): project_name = project["id"] + if isinstance(raw.get("workspace"), Mapping): + environment = raw.get("environment") + command = ( + environment.get("command") + if isinstance(environment, Mapping) + else None + ) + preflight = ( + environment.get("preflight") + if isinstance(environment, Mapping) + else None + ) + invalid_environment = False + if not ( + isinstance(command, list) + and command + and all(isinstance(value, str) and value for value in command) + ): + diagnostics.append( + f"{project_name}: {descriptor} must declare a non-empty environment.command" + ) + invalid_environment = True + if not ( + isinstance(preflight, list) + and preflight + and all(isinstance(value, str) and value for value in preflight) + ): + diagnostics.append( + f"{project_name}: {descriptor} must declare a non-empty environment.preflight" + ) + invalid_environment = True + if invalid_environment: + continue adapter = load_project_adapter(root) except ( OSError, @@ -1346,14 +1379,6 @@ def validate_agent_environment_descriptors(roots: Iterable[Path]) -> None: continue if adapter.workspace is None: continue - if not adapter.environment.command: - diagnostics.append( - f"{adapter.project_id}: {descriptor} must declare a non-empty environment.command" - ) - if not adapter.environment.preflight: - diagnostics.append( - f"{adapter.project_id}: {descriptor} must declare a non-empty environment.preflight" - ) if diagnostics: raise ProjectConfigError( "agent-capable project environment contract failed:\n" diff --git a/pkgs/sinnixd/test_service.py b/pkgs/sinnixd/test_service.py index 76bbfcdd..dc4ddc25 100644 --- a/pkgs/sinnixd/test_service.py +++ b/pkgs/sinnixd/test_service.py @@ -7845,12 +7845,16 @@ def test_agent_environment_preflight_timeout_is_distinct_and_prevents_native_run "result_path": str(results / "fixture.result"), } - def timeout(*_args: object, **_kwargs: object) -> None: + observed: dict[str, object] = {} + + def timeout(*_args: object, **kwargs: object) -> None: + observed.update(kwargs) raise subprocess.TimeoutExpired("fixture-environment", 30) monkeypatch.setattr(runner_module.subprocess, "run", timeout) with pytest.raises(RunnerError, match="agent-preflight-timeout.*30 seconds"): _run_agent(payload, tmp_path, native_runner=runner, state_root=state) + assert observed["timeout"] == 30 assert not (results / "fixture.result").exists() From ea9753fd390400c1fd6aa14dd7465cff7eb8bddb Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 25 Aug 2026 18:40:50 +0200 Subject: [PATCH 35/35] test(agentctl): cover every activating environment gate --- flake/command-registry.nix | 8 ++--- flake/tests.nix | 1 + flake/tests/command-registry.nix | 55 ++++++++++++++++++++++++++++++++ pkgs/sinnixd/sinnixd/projects.py | 12 ++++--- pkgs/sinnixd/test_service.py | 5 +-- 5 files changed, 70 insertions(+), 11 deletions(-) create mode 100644 flake/tests/command-registry.nix diff --git a/flake/command-registry.nix b/flake/command-registry.nix index 2aa6d169..987c57af 100644 --- a/flake/command-registry.nix +++ b/flake/command-registry.nix @@ -533,7 +533,7 @@ in }; test-system = { - description = "Test configuration without applying it to the system (nh os test)"; + description = "Build and activate a temporary host configuration with nh os test"; script = '' ${resolveFlakeDir} ${rebuildLock "test-system"} @@ -557,7 +557,7 @@ in }; boot = { - description = "Build + set boot default, activate on next reboot (nh os boot)"; + description = "Build and set the boot default for activation on the next reboot (nh os boot)"; script = '' ${resolveFlakeDir} ${rebuildLock "boot"} @@ -673,12 +673,12 @@ in { name = "boot"; category = "Core"; - description = "Build + set boot default — safer, reboot to activate (nh os boot)"; + description = "Build and set the boot default for activation on the next reboot (nh os boot)"; } { name = "test-system"; category = "Core"; - description = "Test host config without persisting (nh os test)"; + description = "Build and activate a temporary host configuration with nh os test"; } { name = "test-vm"; diff --git a/flake/tests.nix b/flake/tests.nix index e23b3544..2c0dabf6 100644 --- a/flake/tests.nix +++ b/flake/tests.nix @@ -31,6 +31,7 @@ in ./tests/capture-clipboard.nix ./tests/capture-primary.nix ./tests/backup.nix + ./tests/command-registry.nix ./tests/agent-environment.nix ./tests/agent-parity.nix ./tests/swarm-fixture.nix diff --git a/flake/tests/command-registry.nix b/flake/tests/command-registry.nix new file mode 100644 index 00000000..c4639bcd --- /dev/null +++ b/flake/tests/command-registry.nix @@ -0,0 +1,55 @@ +# Provably fails when an activating rebuild entrypoint loses or misorders the +# project-environment gate, or when the build-only VM route gains that gate. +{ inputs, ... }: +{ + perSystem = + { + pkgs, + system, + sinnixScriptRegistry, + ... + }: + let + commandRegistry = import ../command-registry.nix { + inherit + inputs + pkgs + system + sinnixScriptRegistry + ; + }; + rendered = pkgs.runCommand "command-registry-environment-gate" { } '' + cat > "$out" <<'EOF' + --- switch --- + ${commandRegistry.appCommands.switch.script} + --- boot --- + ${commandRegistry.appCommands.boot.script} + --- test-system --- + ${commandRegistry.appCommands.test-system.script} + --- test-vm --- + ${commandRegistry.appCommands.test-vm.script} + EOF + test "$(grep -c 'sinnixd-project-environment-check' "$out")" = 3 + for entrypoint in switch boot test-system; do + command="nh os $entrypoint" + test "$entrypoint" != test-system || command='nh os test' + if awk -v start="--- $entrypoint ---" -v command="$command" ' + $0 == start { section = 1; next } + /^--- / && section { section = 0 } + section && /sinnixd-project-environment-check/ { gate = NR } + section && index($0, command) { action = NR } + END { exit !(gate && action && gate < action) } + ' "$out"; then :; else exit 1; fi + done + if awk '/--- test-vm ---/{section=1} section && /sinnixd-project-environment-check/{bad=1} END{exit bad}' "$out"; then :; else exit 1; fi + ''; + in + { + checks.command-registry-environment-gate = + pkgs.runCommand "command-registry-environment-gate-check" { inherit rendered; } + '' + test -s "$rendered" + touch "$out" + ''; + }; +} diff --git a/pkgs/sinnixd/sinnixd/projects.py b/pkgs/sinnixd/sinnixd/projects.py index 717c2bc8..530ddcb0 100644 --- a/pkgs/sinnixd/sinnixd/projects.py +++ b/pkgs/sinnixd/sinnixd/projects.py @@ -491,6 +491,10 @@ class ProjectAdapter: operations: tuple[ProjectOperation, ...] owner_adapters: tuple[ProjectOwnerAdapter, ...] = () + @property + def agent_capable(self) -> bool: + return self.workspace is not None + def operation(self, name: str) -> ProjectOperation: for operation in self.operations: if operation.name == name: @@ -519,11 +523,9 @@ def catalog_row(self) -> dict[str, Any]: "digest": self.digest, "descriptor_status": self.descriptor_status(), "environment": self.environment.catalog_row( - agent_capable=self.workspace is not None + agent_capable=self.agent_capable ), - "workspace": self.workspace.catalog_row() - if self.workspace is not None - else None, + "workspace": self.workspace.catalog_row() if self.agent_capable else None, "conflicts": self.conflicts.catalog_row(), "operations": [operation.catalog_row() for operation in self.operations], "owner_adapters": [ @@ -1377,7 +1379,7 @@ def validate_agent_environment_descriptors(roots: Iterable[Path]) -> None: f"{project_name}: invalid descriptor {descriptor}: {error}" ) continue - if adapter.workspace is None: + if not adapter.agent_capable: continue if diagnostics: raise ProjectConfigError( diff --git a/pkgs/sinnixd/test_service.py b/pkgs/sinnixd/test_service.py index dc4ddc25..7df123b7 100644 --- a/pkgs/sinnixd/test_service.py +++ b/pkgs/sinnixd/test_service.py @@ -7494,7 +7494,7 @@ def test_typed_shell_and_agent_contracts_share_generic_job_lifecycle( "project_id": "fixture", "checkout_id": "default", "prompt": "operator prompt", - "backend": "codex", + "backend": "claude", "model": "fixture", "effort": "high", "credential_profile": "subscription", @@ -7539,6 +7539,7 @@ def test_typed_shell_and_agent_contracts_share_generic_job_lifecycle( assert "shell-secret" not in persisted assert "display only" not in persisted assert operator_agent.payload.inline["principal"] == "operator" + assert operator_agent.payload.inline["contract"]["backend"] == "claude" assert len(systemd.started) == 3 assert all(start["unit"].startswith("sinnixd-job-") for start in systemd.started) restarted = GenericJobs(systemd, service.jobs.store, wait_poll_seconds=0.001) @@ -7854,7 +7855,7 @@ def timeout(*_args: object, **kwargs: object) -> None: monkeypatch.setattr(runner_module.subprocess, "run", timeout) with pytest.raises(RunnerError, match="agent-preflight-timeout.*30 seconds"): _run_agent(payload, tmp_path, native_runner=runner, state_root=state) - assert observed["timeout"] == 30 + assert observed == {"cwd": tmp_path, "check": False, "timeout": 30} assert not (results / "fixture.result").exists()