feat: implement local P1 contracts and Mock call flow

This commit is contained in:
2026-09-25 16:38:22 +08:00
parent ef84a0663d
commit 0b4b901d85
174 changed files with 18044 additions and 1740 deletions
+23 -29
View File
@@ -1,39 +1,33 @@
#!/bin/sh
set -eu
#!/usr/bin/env bash
set -euo pipefail
root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
cd "$root"
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
cd "$ROOT"
unformatted=$(find contracts internal cmd -type f -name '*.go' -print0 | xargs -0 gofmt -l)
if [[ -n "$unformatted" ]]; then
printf 'Go files need gofmt:\n%s\n' "$unformatted" >&2
exit 1
fi
if grep -R -n -E -- '--tenant-key|--consume|DISPATCHER_TENANT_KEY' deploys/systemd deploys/env; then
echo "obsolete tenant-queue CLI configuration remains in deployment templates" >&2
exit 1
fi
if [[ -e scripts/mq-only-acceptance-local.sh ]]; then
echo "obsolete MQ-only acceptance runner remains" >&2
exit 1
fi
./scripts/check-contracts.sh
go mod verify
go test -race ./...
go test -race ./... -count=1
go vet ./...
go build -trimpath -buildvcs=false -o dist/sip-go-agent ./cmd/sip-go-agent
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
cp deploys/config/dispatcher.json.example "$tmp/dispatcher.json"
chmod 600 "$tmp/dispatcher.json"
# The strict Dispatcher file requires explicitly referenced credentials even in
# mock mode. These are local-test values and never reach an OSS endpoint.
export GO_SIP_OSS_ACCESS_KEY_ID=local-mock-access-key
export GO_SIP_OSS_ACCESS_KEY_SECRET=local-mock-access-secret
./dist/sip-go-agent agent --mode mock --spool "$tmp/spool" >/dev/null
rabbit_url=${GO_SIP_LOCAL_MQ_URL:-${RABBITMQ_URL:-}}
if [ -z "$rabbit_url" ]; then
echo "GO_SIP_LOCAL_MQ_URL or RABBITMQ_URL is required for Dispatcher acceptance" >&2
exit 1
fi
export GO_SIP_LOCAL_MQ_URL=${GO_SIP_LOCAL_MQ_URL:-$rabbit_url}
export GO_SIP_LOCAL_QUERY_MQ_URL=${GO_SIP_LOCAL_QUERY_MQ_URL:-$rabbit_url}
export GO_SIP_LOCAL_UPLOAD_MQ_URL=${GO_SIP_LOCAL_UPLOAD_MQ_URL:-$rabbit_url}
export RABBITMQ_URL=${RABBITMQ_URL:-$rabbit_url}
./dist/sip-go-agent dispatcher --mode mock --config "$tmp/dispatcher.json" --db "$tmp/dispatcher.db" --rabbit-url "$rabbit_url" --tenant-key local-acceptance-tenant --once >/dev/null
./scripts/mq-only-acceptance-local.sh >/dev/null
go build -trimpath -buildvcs=false -o "$tmp/sip-go-agent" ./cmd/sip-go-agent
if env -u GO_SIP_LOCAL_MQ_URL -u GO_SIP_LOCAL_QUERY_MQ_URL -u GO_SIP_LOCAL_UPLOAD_MQ_URL -u RABBITMQ_URL ./dist/sip-go-agent dispatcher --mode real --config "$tmp/dispatcher.json" --db "$tmp/real.db" --tenant-key local-acceptance-tenant --once >/dev/null 2>&1; then
echo "real mode unexpectedly started without broker credentials" >&2
exit 1
fi
./scripts/mq-integration-local.sh
echo "local P1 acceptance passed for the current single-node/Cell/tenant scope; external production gates are intentionally deferred to phase two"
echo "P1 local F09 checks passed (business-module coverage only; see docs/evidence/f09-local-acceptance-v0.2.md); external SaaS/management, cloud, supplier, SIP, F06 deployment, and production acceptance remain unverified"
+1
View File
@@ -10,4 +10,5 @@ bundle=$(sed -n 's/^active_bundle=//p' "$manifest")
cd "$root"
grep '^sha256=' "$manifest" | sed 's/^sha256=//' | sha256sum -c -
python3 scripts/validate-local-contracts.py
go test ./contracts ./internal/contract ./internal/ai
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
mkdir -p contracts/local/v0.1 contracts/local/v0.2
rm -f contracts/local/v0.1/task-discovery-v0.1-proposal.schema.json
cp docs/contracts/config-read-v0.1.schema.json docs/contracts/command-next-v0.1-proposal.schema.json docs/contracts/call-result-v0.1-proposal.schema.json docs/contracts/local-mock-recording-failure-v0.1.schema.json contracts/local/v0.1/
cp docs/contracts/task-discovery-v0.2-proposal.schema.json contracts/local/v0.2/
+15 -2
View File
@@ -6,8 +6,15 @@ cd -- "$ROOT"
IMAGE=${RABBITMQ_IMAGE:-rabbitmq:4.1-management-alpine}
NAME="sip-go-agent-rabbit-poc-${$}"
PORT=""
command -v docker >/dev/null 2>&1 || { echo "docker is required for local RabbitMQ tests" >&2; exit 1; }
if ! docker image inspect "$IMAGE" >/dev/null 2>&1; then
echo "RabbitMQ image $IMAGE is not cached locally; refusing an implicit image pull" >&2
exit 1
fi
RABBIT_USER=${RABBITMQ_TEST_USER:-agent_call_integration}
RABBIT_PASSWORD=${RABBITMQ_TEST_PASSWORD:-$(openssl rand -hex 16)}
DISPATCHER_USER="sip_go_agent_no_config_${$}"
DISPATCHER_PASSWORD=$(openssl rand -hex 16)
RABBIT_UID=$(docker run --rm "$IMAGE" id -u rabbitmq)
RABBIT_GID=$(docker run --rm "$IMAGE" id -g rabbitmq)
@@ -36,5 +43,11 @@ if [[ -z "$PORT" ]]; then
exit 1
fi
IMAGE_ID=$(docker image inspect --format '{{.Id}}' "$IMAGE")
printf 'local RabbitMQ image=%s id=%s uid=%s gid=%s port=%s\n' "$IMAGE" "$IMAGE_ID" "$RABBIT_UID" "$RABBIT_GID" "$PORT"
RABBITMQ_URL="amqp://${RABBIT_USER}:${RABBIT_PASSWORD}@127.0.0.1:${PORT}/" go test -tags=integration ./internal/mq ./internal/dispatcher
docker exec "$NAME" rabbitmqctl add_user "$DISPATCHER_USER" "$DISPATCHER_PASSWORD" >/dev/null
docker exec "$NAME" rabbitmqctl set_permissions -p / "$DISPATCHER_USER" '^$' '.*' '.*' >/dev/null
ADMIN_URL="amqp://${RABBIT_USER}:${RABBIT_PASSWORD}@127.0.0.1:${PORT}/"
DISPATCHER_URL="amqp://${DISPATCHER_USER}:${DISPATCHER_PASSWORD}@127.0.0.1:${PORT}/"
printf 'local RabbitMQ image=%s id=%s uid=%s gid=%s port=%s; restricted Dispatcher configure permission=none\n' "$IMAGE" "$IMAGE_ID" "$RABBIT_UID" "$RABBIT_GID" "$PORT"
RABBITMQ_URL="$ADMIN_URL" RABBITMQ_PROVISIONER_URL="$ADMIN_URL" go test -race -cover -tags=integration ./internal/mq ./internal/dispatcher
RABBITMQ_URL="$DISPATCHER_URL" RABBITMQ_PROVISIONER_URL="$ADMIN_URL" RABBITMQ_EXPECT_NO_CONFIG=1 go test -race -tags=integration ./internal/mq -run '^TestV3BrokerConsumesSaaSOwnedQueueAndPublishesConfirmedResult$' -count=1
RABBITMQ_URL="$DISPATCHER_URL" RABBITMQ_PROVISIONER_URL="$ADMIN_URL" go test -race -tags=integration ./internal/dispatcher -run '^TestLocalDispatcherMockSaaSEndToEndWithOutboxRecovery$' -count=1
-36
View File
@@ -1,36 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
cd "$ROOT"
# A missing broker must fail the command, never turn these tests into skips.
BASE_URL=${GO_SIP_LOCAL_MQ_URL:-${RABBITMQ_URL:-amqp://guest:guest@127.0.0.1:33252/}}
export GO_SIP_LOCAL_MQ_URL="$BASE_URL"
export GO_SIP_LOCAL_QUERY_MQ_URL="${GO_SIP_LOCAL_QUERY_MQ_URL:-$BASE_URL}"
export GO_SIP_LOCAL_UPLOAD_MQ_URL="${GO_SIP_LOCAL_UPLOAD_MQ_URL:-$BASE_URL}"
export RABBITMQ_URL="${RABBITMQ_URL:-$BASE_URL}"
LOG_FILE=${MQ_ONLY_ACCEPTANCE_LOG:-/tmp/go-sip-mq-only-acceptance.log}
: > "$LOG_FILE"
run() {
printf '$'
printf ' %q' "$@"
printf '\n' | tee -a "$LOG_FILE"
"$@" 2>&1 | tee -a "$LOG_FILE"
}
run env GOMAXPROCS=2 GOTOOLCHAIN=local go test -race -p 1 -v ./internal/dispatcher \
-run 'TestLocalMQ(AIConfigurationAuthorizationRoundTrip|ActiveControlReplyRecovery|RequestRoundTrip)$' -count=1
run env GOMAXPROCS=2 GOTOOLCHAIN=local go test -race -p 1 -v ./internal/rpc \
-run '^TestLocalUploadNoticeSurvivesUnroutableAndRestart$' -count=1
run env GOMAXPROCS=2 GOTOOLCHAIN=local go test -race -p 1 -v -tags integration ./internal/mq \
-run 'TestV2LocalBrokerIdentityIsolationAndReliableRouting|TestLocalRabbitMQConfirmAckAndDeadLetter|TestV2LocalBrokerDisconnectStopsPublisher' -count=1
run env GOMAXPROCS=2 GOTOOLCHAIN=local go test -race -p 1 -v ./internal/store \
-run 'TestMQControlRejectsLateRevisionWithoutRegressingTask|TestMQReplayOnlyRequeuesOriginalBusinessFacts' -count=1
if grep -Eq -- '--- SKIP:|\(no tests to run\)' "$LOG_FILE"; then
echo "targeted MQ acceptance unexpectedly skipped a test; see $LOG_FILE" >&2
exit 1
fi
echo "targeted MQ acceptance passed without skipped tests; log=$LOG_FILE"
+353
View File
@@ -0,0 +1,353 @@
#!/usr/bin/env python3
"""Developer-only validation for local F01/F07 contracts, examples, and hashes."""
import hashlib
import json
import re
from pathlib import Path
try:
from jsonschema import Draft202012Validator, FormatChecker, ValidationError
from referencing import Registry, Resource
except ImportError as exc:
raise SystemExit("requires the developer tool jsonschema 4.x; not a Go runtime dependency") from exc
ROOT = Path(__file__).resolve().parents[1]
DOC = ROOT / "docs/thirds/第三方对接事件与请求消费顺序_v0.1.md"
SCHEMA_PATHS = {
"config-read": ROOT / "docs/contracts/config-read-v0.1.schema.json",
"task-discovery": ROOT / "docs/contracts/task-discovery-v0.1-proposal.schema.json",
"command-next": ROOT / "docs/contracts/command-next-v0.1-proposal.schema.json",
"call-result": ROOT / "docs/contracts/call-result-v0.1-proposal.schema.json",
}
EXAMPLES = ROOT / "docs/contracts/examples"
MANIFEST_PATH = ROOT / "docs/contracts/local-contract-manifest-v0.1.json"
STATUS_FIXTURE = EXAMPLES / "config-read-http-statuses-v0.1.json"
TASK_STATUS_FIXTURE = EXAMPLES / "task-discovery-http-statuses-v0.1.json"
def load_json(path):
return json.loads(path.read_text(encoding="utf-8"))
registry = Registry()
for path in (ROOT / "contracts/upstream/v1").glob("*.schema.json"):
schema = load_json(path)
if "$id" in schema:
registry = registry.with_resource(schema["$id"], Resource.from_contents(schema))
def schema_versions(node):
found = set()
if isinstance(node, dict):
version = node.get("properties", {}).get("schema_version", {}).get("const")
if isinstance(version, str):
found.add(version)
for value in node.values():
found.update(schema_versions(value))
elif isinstance(node, list):
for value in node:
found.update(schema_versions(value))
return found
validators = {}
versions = {}
for name, path in SCHEMA_PATHS.items():
schema = load_json(path)
Draft202012Validator.check_schema(schema)
schema_version_values = schema_versions(schema)
if not schema_version_values:
raise SystemExit(f"no schema_version const found: {path.relative_to(ROOT)}")
for version in schema_version_values:
if version in versions and versions[version] != name:
raise SystemExit(f"duplicate schema_version {version}")
versions[version] = name
validators[name] = Draft202012Validator(
schema,
registry=registry,
format_checker=FormatChecker(),
)
positive_counts = {name: 0 for name in SCHEMA_PATHS}
doc = DOC.read_text(encoding="utf-8")
for index, match in enumerate(re.finditer(r"```json\s*(.*?)\s*```", doc, re.DOTALL), 1):
try:
sample = json.loads(match.group(1))
except json.JSONDecodeError as exc:
if any(version in match.group(1) for version in versions):
raise SystemExit(f"invalid JSON in local-schema Markdown example {index}: {exc}") from exc
continue
if not isinstance(sample, dict) or sample.get("schema_version") not in versions:
continue
name = versions[sample["schema_version"]]
validators[name].validate(sample)
positive_counts[name] += 1
for path in sorted(EXAMPLES.glob("config-read-*.json")):
if "invalid" in path.name or path == STATUS_FIXTURE:
continue
validators["config-read"].validate(load_json(path))
positive_counts["config-read"] += 1
for path in sorted(EXAMPLES.glob("task-discovery-*-v0.1.json")):
if "invalid" in path.name or path == TASK_STATUS_FIXTURE:
continue
validators["task-discovery"].validate(load_json(path))
positive_counts["task-discovery"] += 1
for path in sorted(EXAMPLES.glob("call-result-*.json")):
if "invalid" in path.name:
continue
validators["call-result"].validate(load_json(path))
positive_counts["call-result"] += 1
CONFIG_READ_HTTP_ERROR_CODES = {
400: {"invalid_request"},
401: {"unauthorized"},
403: {"dispatcher_not_authorized"},
404: {"resource_not_found"},
503: {"tenant_quota_unavailable", "service_unavailable"},
}
TASK_DISCOVERY_HTTP_ERROR_CODES = {
400: {"invalid_cursor", "invalid_page_token"},
401: {"unauthorized"},
403: {"dispatcher_not_authorized"},
410: {"cursor_expired", "snapshot_expired"},
503: {"service_unavailable"},
}
def validate_status_fixture(path, schema_name, fixture_version, status_codes):
fixture = load_json(path)
if not isinstance(fixture, dict) or set(fixture) != {"fixture_version", "responses"}:
raise SystemExit(f"invalid HTTP status fixture structure: {path.relative_to(ROOT)}")
if fixture["fixture_version"] != fixture_version or not isinstance(fixture["responses"], list):
raise SystemExit(f"unexpected HTTP status fixture version or responses: {path.relative_to(ROOT)}")
expected = {(status, code) for status, codes in status_codes.items() for code in codes}
actual = set()
for response in fixture["responses"]:
if not isinstance(response, dict) or set(response) != {"status", "body"}:
raise SystemExit(f"invalid HTTP error fixture entry: {response}")
status = response["status"]
body = response["body"]
if type(status) is not int or not isinstance(body, dict):
raise SystemExit(f"invalid HTTP error fixture status/body: {response}")
validators[schema_name].validate(body)
code = body["error"]["code"]
if code not in status_codes.get(status, set()):
raise SystemExit(f"HTTP {status} does not match local {schema_name} error code {code}")
pair = (status, code)
if pair in actual:
raise SystemExit(f"duplicate HTTP error case: {pair}")
actual.add(pair)
if actual != expected:
raise SystemExit(f"HTTP error cases differ from local {schema_name} mapping: missing={expected - actual}, extra={actual - expected}")
return len(actual)
positive_counts["config-read"] += validate_status_fixture(
STATUS_FIXTURE, "config-read", "config-read-http-statuses.v0.1", CONFIG_READ_HTTP_ERROR_CODES
)
positive_counts["task-discovery"] += validate_status_fixture(
TASK_STATUS_FIXTURE, "task-discovery", "task-discovery-http-statuses.v0.1", TASK_DISCOVERY_HTTP_ERROR_CODES
)
if any(count == 0 for count in positive_counts.values()):
raise SystemExit(f"missing positive contract example: {positive_counts}")
invalid_prefixes = {
"config-read-invalid-": "config-read",
"task-discovery-invalid-": "task-discovery",
"command-next-invalid-": "command-next",
"call-result-invalid-": "call-result",
}
negative_counts = {name: 0 for name in SCHEMA_PATHS}
for path in sorted(EXAMPLES.glob("*-invalid-*.json")):
name = next((schema for prefix, schema in invalid_prefixes.items() if path.name.startswith(prefix)), None)
if name is None or (name == "task-discovery" and not path.name.endswith("-v0.1.json")):
continue
sample = load_json(path)
try:
validators[name].validate(sample)
except ValidationError:
negative_counts[name] += 1
else:
raise SystemExit(f"negative fixture unexpectedly valid: {path.relative_to(ROOT)}")
if any(count == 0 for count in negative_counts.values()):
raise SystemExit(f"missing negative fixture per local schema: {negative_counts}")
def validate_manifest():
if not MANIFEST_PATH.is_file():
raise SystemExit(f"required local contract manifest is missing: {MANIFEST_PATH.relative_to(ROOT)}")
manifest = load_json(MANIFEST_PATH)
expected_source = DOC.relative_to(ROOT).as_posix()
if manifest.get("manifest_version") != "local-contract-manifest.v0.1":
raise SystemExit("unexpected local contract manifest version")
if manifest.get("hash_algorithm") != "SHA-256":
raise SystemExit("local contract manifest must use SHA-256")
source = manifest.get("source")
if not isinstance(source, dict) or source.get("path") != expected_source:
raise SystemExit(f"local contract manifest source must be {expected_source}")
artifacts = manifest.get("artifacts")
if not isinstance(artifacts, list):
raise SystemExit("local contract manifest artifacts must be a list")
required_artifacts = {path.relative_to(ROOT).as_posix() for path in SCHEMA_PATHS.values()}
required_artifacts.update({
"docs/contracts/config-read-fields-v0.1-proposal.md",
"docs/contracts/mq-topology-v0.1-proposal.json",
})
required_artifacts.update(
path.relative_to(ROOT).as_posix()
for path in EXAMPLES.glob("*.json")
if path.name.startswith(("config-read-", "command-next-", "call-result-"))
or (path.name.startswith("task-discovery-") and path.name.endswith("-v0.1.json"))
)
artifact_paths = [entry.get("path") for entry in artifacts if isinstance(entry, dict)]
if len(artifact_paths) != len(artifacts) or len(set(artifact_paths)) != len(artifact_paths):
raise SystemExit("local contract manifest has malformed or duplicate artifacts")
if set(artifact_paths) != required_artifacts:
raise SystemExit(f"local contract manifest artifact set mismatch: missing={required_artifacts - set(artifact_paths)}, extra={set(artifact_paths) - required_artifacts}")
entries = [source, *artifacts]
for entry in entries:
relative = Path(entry.get("path", ""))
expected_hash = entry.get("sha256")
if relative.is_absolute() or ".." in relative.parts or not re.fullmatch(r"[0-9a-f]{64}", str(expected_hash)):
raise SystemExit(f"invalid path or SHA-256 in local contract manifest: {entry}")
path = ROOT / relative
if not path.is_file():
raise SystemExit(f"manifest file is missing: {relative}")
actual_hash = hashlib.sha256(path.read_bytes()).hexdigest()
if actual_hash != expected_hash:
raise SystemExit(f"SHA-256 mismatch for {relative}: expected {expected_hash}, got {actual_hash}")
return len(entries)
manifest_file_count = validate_manifest()
print(f"Local schemas: {len(validators)} valid; positive examples: {positive_counts}; negative fixtures rejected: {negative_counts}; SHA-256 manifest files verified: {manifest_file_count}")
# Validate the separate v0.2 task-discovery proposal without changing the
# v0.1 runtime baseline or the historical v0.1 manifest.
proposal_path = ROOT / "docs/contracts/task-discovery-v0.2-proposal.schema.json"
proposal_schema = load_json(proposal_path)
Draft202012Validator.check_schema(proposal_schema)
if schema_versions(proposal_schema) != {"task-discovery.v0.2-proposal"}:
raise SystemExit("unexpected task-discovery v0.2 schema version")
validators["task-discovery-v0.2"] = Draft202012Validator(
proposal_schema, registry=registry, format_checker=FormatChecker()
)
proposal = validators["task-discovery-v0.2"]
proposal_examples = sorted(EXAMPLES.glob("task-discovery-*-v0.2.json"))
proposal_status = EXAMPLES / "task-discovery-http-statuses-v0.2.json"
proposal_positive = 0
proposal_negative = 0
for path in proposal_examples:
if path == proposal_status:
continue
sample = load_json(path)
if "invalid" in path.name:
try:
proposal.validate(sample)
except ValidationError:
proposal_negative += 1
else:
raise SystemExit(f"negative v0.2 fixture unexpectedly valid: {path.relative_to(ROOT)}")
else:
proposal.validate(sample)
proposal_positive += 1
proposal_positive += validate_status_fixture(
proposal_status, "task-discovery-v0.2", "task-discovery-http-statuses.v0.2",
{400: {"invalid_cursor"}, 401: {"unauthorized"},
403: {"dispatcher_not_authorized"}, 410: {"cursor_expired"},
503: {"service_unavailable"}},
)
if proposal_positive < 8 or proposal_negative < 2:
raise SystemExit("missing v0.2 positive/negative contract cases")
for example in ("snapshot", "changes"):
payload = load_json(EXAMPLES / f"task-discovery-{example}-v0.2.json")
field = "tasks" if example == "snapshot" else "changes"
payload[field] = [payload[field][0]] * 256
proposal.validate(payload)
payload[field].append(payload[field][0])
try:
proposal.validate(payload)
except ValidationError:
proposal_negative += 1
else:
raise SystemExit(f"v0.2 {field} accepted more than 256 items")
proposal_doc = ROOT / "docs/thirds/v0.2.md"
proposal_versions = {**versions, "task-discovery.v0.2-proposal": "task-discovery-v0.2"}
for match in re.finditer(r"```json\s*(.*?)\s*```", proposal_doc.read_text(encoding="utf-8"), re.DOTALL):
sample = json.loads(match.group(1))
if isinstance(sample, dict) and sample.get("schema_version") in proposal_versions:
validators[proposal_versions[sample["schema_version"]]].validate(sample)
proposal_positive += 1
proposal_manifest = load_json(ROOT / "docs/contracts/local-contract-manifest-v0.2.json")
if proposal_manifest.get("manifest_version") != "local-contract-manifest.v0.2" or proposal_manifest.get("hash_algorithm") != "SHA-256":
raise SystemExit("invalid v0.2 manifest version or hash algorithm")
proposal_artifacts = {
proposal_path.relative_to(ROOT).as_posix(),
*(path.relative_to(ROOT).as_posix() for path in proposal_examples),
*(SCHEMA_PATHS[name].relative_to(ROOT).as_posix() for name in ("config-read", "command-next", "call-result")),
"docs/contracts/mq-topology-v0.1-proposal.json",
}
source = proposal_manifest.get("source", {})
artifacts = proposal_manifest.get("artifacts", [])
if source.get("path") != proposal_doc.relative_to(ROOT).as_posix() or not isinstance(artifacts, list):
raise SystemExit("v0.2 manifest source/artifacts mismatch")
paths = [entry.get("path") for entry in artifacts if isinstance(entry, dict)]
if len(paths) != len(artifacts) or len(paths) != len(set(paths)) or set(paths) != proposal_artifacts:
raise SystemExit("v0.2 manifest artifact set mismatch")
for entry in [source, *artifacts]:
relative = Path(entry.get("path", ""))
digest = entry.get("sha256", "")
if relative.is_absolute() or ".." in relative.parts or not re.fullmatch(r"[0-9a-f]{64}", str(digest)):
raise SystemExit(f"invalid v0.2 manifest entry: {entry}")
if not (ROOT / relative).is_file() or hashlib.sha256((ROOT / relative).read_bytes()).hexdigest() != digest:
raise SystemExit(f"v0.2 manifest SHA-256 mismatch: {relative}")
print(f"Task discovery v0.2 proposal: positive={proposal_positive}, negative={proposal_negative}, manifest files={1 + len(artifacts)}")
# The approved Agent→Dispatcher failure fact is Mock-only. Its independent
# manifest must not alter the historical SaaS v0.1 or discovery v0.2 baselines.
mock_doc = ROOT / "docs/contracts/local-mock-recording-failure-v0.1.md"
mock_schema_path = ROOT / "docs/contracts/local-mock-recording-failure-v0.1.schema.json"
mock_schema = load_json(mock_schema_path)
embedded_mock_schema = ROOT / "contracts/local/v0.1/local-mock-recording-failure-v0.1.schema.json"
if embedded_mock_schema.read_bytes() != mock_schema_path.read_bytes():
raise SystemExit("embedded Mock failure schema differs from its hashed project source")
Draft202012Validator.check_schema(mock_schema)
if schema_versions(mock_schema) != {"local-mock-recording-failure.v0.1"}:
raise SystemExit("unexpected local Mock recording-failure schema version")
mock_validator = Draft202012Validator(mock_schema, registry=registry, format_checker=FormatChecker())
mock_examples = sorted(EXAMPLES.glob("local-mock-recording-failure-*-v0.1.json"))
mock_positive = mock_negative = 0
for path in mock_examples:
sample = load_json(path)
if "invalid" in path.name:
try:
mock_validator.validate(sample)
except ValidationError:
mock_negative += 1
else:
raise SystemExit(f"invalid Mock failure fixture passed: {path.relative_to(ROOT)}")
else:
mock_validator.validate(sample)
mock_positive += 1
if mock_positive < 2 or mock_negative < 2:
raise SystemExit("missing positive/negative Mock recording-failure examples")
mock_manifest = load_json(ROOT / "docs/contracts/local-mock-recording-failure-manifest-v0.1.json")
if mock_manifest.get("manifest_version") != "local-mock-recording-failure-manifest.v0.1" or mock_manifest.get("hash_algorithm") != "SHA-256":
raise SystemExit("invalid Mock failure manifest version or hash algorithm")
mock_source = mock_manifest.get("source", {})
mock_artifacts = mock_manifest.get("artifacts", [])
mock_paths = [entry.get("path") for entry in mock_artifacts if isinstance(entry, dict)]
required_mock_paths = {mock_schema_path.relative_to(ROOT).as_posix(), *(path.relative_to(ROOT).as_posix() for path in mock_examples)}
if mock_source.get("path") != mock_doc.relative_to(ROOT).as_posix() or len(mock_paths) != len(mock_artifacts) or len(mock_paths) != len(set(mock_paths)) or set(mock_paths) != required_mock_paths:
raise SystemExit("Mock failure manifest source/artifact set mismatch")
for entry in [mock_source, *mock_artifacts]:
relative = Path(entry.get("path", ""))
digest = entry.get("sha256", "")
if relative.is_absolute() or ".." in relative.parts or not re.fullmatch(r"[0-9a-f]{64}", str(digest)):
raise SystemExit(f"invalid Mock failure manifest entry: {entry}")
if not (ROOT / relative).is_file() or hashlib.sha256((ROOT / relative).read_bytes()).hexdigest() != digest:
raise SystemExit(f"Mock failure manifest SHA-256 mismatch: {relative}")
print(f"Mock recording failure v0.1: positive={mock_positive}, negative={mock_negative}, manifest files={1 + len(mock_artifacts)}")