Skip to content
36 changes: 23 additions & 13 deletions src/quorumgit/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,18 @@
import json
import sys

from . import __version__, audit, config, gate, handoff, registry, store, trees, work
from . import (
__version__,
audit,
config,
gate,
handoff,
receive,
registry,
store,
trees,
work,
)


def _agent(args, cfg) -> str:
Expand Down Expand Up @@ -159,8 +170,6 @@ def cmd_claim(args, cfg) -> int:
takeover_operation = None
takeover_approval = None
if args.takeover:
# Bind the approval to a stable incumbent. claim_task() and every
# release take this same task lock, closing the stale-holder window.
work.lock_task(conn, args.task_id)
holder = work.active_claim_for_task(conn, args.task_id)
if holder and not holder["expired"]:
Expand All @@ -184,7 +193,7 @@ def cmd_claim(args, cfg) -> int:
f"{json.dumps(takeover_operation, sort_keys=True)}",
file=sys.stderr,
)
conn.commit() # keep the conflict/audit trail
conn.commit()
return 1
try:
claim_id, classification, _ = work.claim_task(
Expand All @@ -198,15 +207,9 @@ def cmd_claim(args, cfg) -> int:
takeover_approved=takeover_operation is not None,
)
except work.ClaimRefused as exc:
# A refused claim is non-destructive: the holder was not released
# and the approval was not consumed. Commit only the conflict
# event and audit trail.
conn.commit()
print(f"[quorumgit] REFUSED: {exc}", file=sys.stderr)
return 1
# The takeover succeeded: consume the approval in the same
# transaction, so replacement claim + holder release + consumption
# commit together or not at all.
if takeover_operation is not None:
try:
assert takeover_approval is not None
Expand Down Expand Up @@ -391,15 +394,20 @@ def cmd_approve_hash(args, cfg) -> int:

def cmd_hook_install(args, cfg) -> int:
with store.connect(cfg) as conn:
path = gate.install_hook(conn, args.repo)
path = receive.install_hooks(conn, args.repo)
conn.commit()
print(f"pre-receive hook installed: {path}")
print(f"receive hooks installed (pre-receive: {path})")
return 0


def cmd_hook_pre_receive(args, cfg) -> int:
with store.connect(cfg) as conn:
return gate.run_pre_receive(conn, args.repo, sys.stdin)
return receive.run_pre_receive(conn, args.repo, sys.stdin)


def cmd_hook_post_receive(args, cfg) -> int:
with store.connect(cfg) as conn:
return receive.run_post_receive(conn, args.repo, sys.stdin)


# -------------------------------------------------------------------- audit
Expand Down Expand Up @@ -541,6 +549,8 @@ def add(name, fn, configure=None, parent=None):
lambda sp: sp.add_argument("--repo", required=True), parent=hook)
add("pre-receive", cmd_hook_pre_receive,
lambda sp: sp.add_argument("--repo", required=True), parent=hook)
add("post-receive", cmd_hook_post_receive,
lambda sp: sp.add_argument("--repo", required=True), parent=hook)

add("audit", cmd_audit, lambda sp: (
sp.add_argument("--entity"),
Expand Down
111 changes: 111 additions & 0 deletions src/quorumgit/migrations/003_git_mutations.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
-- Reserve exact incoming Git mutations between pre-receive validation and
-- post-receive completion. Reservations freeze governance for the affected ref
-- while Git is in flight and bind any protected-operation approval instance to
-- that exact mutation until Git either completes or the reservation expires.

CREATE TABLE git_mutations (
id INTEGER PRIMARY KEY,
repository_id INTEGER NOT NULL REFERENCES repositories(id),
refname TEXT NOT NULL CHECK (refname <> ''),
oldrev TEXT NOT NULL CHECK (oldrev <> ''),
newrev TEXT NOT NULL CHECK (newrev <> ''),
mutation_hash TEXT NOT NULL CHECK (
length(mutation_hash) = 71
AND substr(mutation_hash, 1, 7) = 'sha256:'
AND substr(mutation_hash, 8) NOT GLOB '*[^0-9a-f]*'
),
agent_id INTEGER NOT NULL REFERENCES agents(id),
approval_id INTEGER REFERENCES approvals(id),
status TEXT NOT NULL DEFAULT 'reserved'
CHECK (status IN ('reserved', 'completed', 'expired')),
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
expires_at INTEGER NOT NULL,
completed_at INTEGER,
CHECK (status = 'completed' OR completed_at IS NULL),
CHECK (status <> 'completed' OR completed_at IS NOT NULL)
);

-- quorumgit-statement
CREATE UNIQUE INDEX git_mutations_one_reserved_ref
ON git_mutations(repository_id, refname)
WHERE status = 'reserved';

-- quorumgit-statement
CREATE UNIQUE INDEX git_mutations_one_reserved_exact
ON git_mutations(mutation_hash)
WHERE status = 'reserved';

-- quorumgit-statement
CREATE UNIQUE INDEX git_mutations_one_reserved_approval
ON git_mutations(approval_id)
WHERE status = 'reserved' AND approval_id IS NOT NULL;

-- quorumgit-statement
CREATE INDEX git_mutations_by_repository_status
ON git_mutations(repository_id, status, expires_at);

-- quorumgit-statement
CREATE TRIGGER claims_blocked_by_git_mutation_insert
BEFORE INSERT ON claims
WHEN EXISTS (
SELECT 1
FROM tasks t
JOIN git_mutations m ON m.repository_id = t.repository_id
WHERE t.id = NEW.task_id
AND m.refname = 'refs/heads/' || NEW.branch
AND m.status = 'reserved'
AND m.expires_at >= unixepoch()
)
BEGIN
SELECT RAISE(ABORT, 'branch has an in-flight Git mutation');
END;

-- quorumgit-statement
CREATE TRIGGER claims_blocked_by_git_mutation_update
BEFORE UPDATE OF lease_expires_at, released_at, release_reason ON claims
WHEN EXISTS (
SELECT 1
FROM tasks t
JOIN git_mutations m ON m.repository_id = t.repository_id
WHERE t.id = OLD.task_id
AND m.refname = 'refs/heads/' || OLD.branch
AND m.status = 'reserved'
AND m.expires_at >= unixepoch()
)
BEGIN
SELECT RAISE(ABORT, 'branch has an in-flight Git mutation');
END;

-- quorumgit-statement
CREATE TRIGGER handoffs_blocked_by_git_mutation_insert
BEFORE INSERT ON handoffs
WHEN EXISTS (
SELECT 1
FROM claims c
JOIN tasks t ON t.id = c.task_id
JOIN git_mutations m ON m.repository_id = t.repository_id
WHERE c.id = NEW.from_claim_id
AND m.refname = 'refs/heads/' || c.branch
AND m.status = 'reserved'
AND m.expires_at >= unixepoch()
)
BEGIN
SELECT RAISE(ABORT, 'branch has an in-flight Git mutation');
END;

-- quorumgit-statement
CREATE TRIGGER handoffs_blocked_by_git_mutation_update
BEFORE UPDATE OF status, to_agent_id, resolved_at ON handoffs
WHEN EXISTS (
SELECT 1
FROM claims c
JOIN tasks t ON t.id = c.task_id
JOIN git_mutations m ON m.repository_id = t.repository_id
WHERE c.id = OLD.from_claim_id
AND m.refname = 'refs/heads/' || c.branch
AND m.status = 'reserved'
AND m.expires_at >= unixepoch()
)
BEGIN
SELECT RAISE(ABORT, 'branch has an in-flight Git mutation');
END;
Loading
Loading