feat: complete MQ-only dispatcher and OSS upload flow
This commit is contained in:
@@ -12,10 +12,26 @@ 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
|
||||
./dist/sip-go-agent dispatcher --mode mock --db "$tmp/dispatcher.db" >/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
|
||||
|
||||
if ./dist/sip-go-agent dispatcher --mode real --db "$tmp/real.db" >/dev/null 2>&1; then
|
||||
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
|
||||
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
profile=${COVER_PROFILE:-/tmp/go-sip-coverage.out}
|
||||
business_profile=${COVER_BUSINESS_PROFILE:-/tmp/go-sip-coverage-business.out}
|
||||
|
||||
GOMAXPROCS=${GOMAXPROCS:-2} go test -p 1 -coverpkg=./... -coverprofile="$profile" ./...
|
||||
awk 'NR == 1 || index($0, "/gen/") == 0' "$profile" > "$business_profile"
|
||||
go tool cover -func="$business_profile"
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
#!/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"
|
||||
@@ -0,0 +1,278 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Reproduce the project-local v2 draft from approved rules and pinned v1 inputs.
|
||||
|
||||
The v1 input is never changed. Upload delivery ends at the designated durable
|
||||
MQ queue: no upload-session request, verified response or SaaS OSS ID protocol.
|
||||
"""
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
OLD = ROOT / "contracts/upstream/2026-09-19-p1-v1"
|
||||
NEW = ROOT / "contracts/upstream/2026-09-21-p1-v2"
|
||||
BASE = "https://go-sip.local/contracts/2026-09-21-p1-v2/"
|
||||
DID = "c046b893-8628-4589-ae50-619d049248a6"
|
||||
AT = "2026-09-21T00:00:00Z"
|
||||
SCHEMA = "https://json-schema.org/draft/2020-12/schema"
|
||||
WRITTEN = set()
|
||||
|
||||
|
||||
def emit_text(name, text):
|
||||
path = NEW / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(text)
|
||||
WRITTEN.add(name)
|
||||
|
||||
|
||||
def emit(name, value):
|
||||
emit_text(name, json.dumps(value, ensure_ascii=False, indent=2) + "\n")
|
||||
|
||||
|
||||
def load_json(path):
|
||||
try:
|
||||
return json.loads(path.read_text())
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise RuntimeError(f"Cannot load pinned source schema {path}") from exc
|
||||
|
||||
|
||||
def ref(name):
|
||||
return {"$ref": "#/$defs/" + name}
|
||||
|
||||
|
||||
def obj(properties, required=None):
|
||||
return {"type": "object", "additionalProperties": False,
|
||||
"required": list(properties) if required is None else required,
|
||||
"properties": properties}
|
||||
|
||||
|
||||
def rewrite_refs(value, prefix):
|
||||
if isinstance(value, list):
|
||||
return [rewrite_refs(v, prefix) for v in value]
|
||||
if not isinstance(value, dict):
|
||||
return value
|
||||
result = {k: rewrite_refs(v, prefix) for k, v in value.items()}
|
||||
r = result.get("$ref", "")
|
||||
if r.startswith("#/components/schemas/"):
|
||||
result["$ref"] = "#/$defs/" + prefix + r.split("/")[-1]
|
||||
return result
|
||||
|
||||
|
||||
def uploaded_event_schema(value: Any) -> Any:
|
||||
if isinstance(value, list):
|
||||
return [uploaded_event_schema(v) for v in value]
|
||||
if isinstance(value, dict):
|
||||
return {k: uploaded_event_schema(v) for k, v in value.items()}
|
||||
if value == "recording.ready":
|
||||
return "recording.uploaded"
|
||||
if value == "#/$defs/recording_ready":
|
||||
return "#/$defs/recording_uploaded"
|
||||
return value
|
||||
|
||||
|
||||
# Preserve unrelated business schemas; the old package and its IDs stay intact.
|
||||
for path in sorted(OLD.glob("*.schema.json")):
|
||||
schema = load_json(path)
|
||||
schema["$id"] = BASE + path.name
|
||||
emit(path.name, schema)
|
||||
|
||||
mq = load_json(OLD / "mq.schema.json")
|
||||
events = load_json(OLD / "event-payloads.schema.json")
|
||||
defs = mq["$defs"]
|
||||
dispatcher_id = {"type": "string", "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"}
|
||||
tenant_key = {"type": "string", "minLength": 1, "maxLength": 196,
|
||||
"not": {"pattern": "(^|\\.)[*#](\\.|$)"},
|
||||
"$comment": "Runtime also enforces 196 UTF-8 bytes and each AMQP resource's 255-byte budget."}
|
||||
command_types = ["call.execute", "task.control", "call.replay", "command.replay"]
|
||||
|
||||
# Replace ready, rather than falsely reporting SaaS processing or an OSS ID.
|
||||
uploaded = events["$defs"].pop("recording_ready")
|
||||
uploaded["properties"].pop("oss_id")
|
||||
uploaded["required"].remove("oss_id")
|
||||
for name, schema in {"upload_id": ref("id"), "bucket": {"type": "string", "minLength": 1, "maxLength": 63}, "object_key": {"type": "string", "minLength": 1, "maxLength": 1024}}.items():
|
||||
uploaded["properties"][name] = schema
|
||||
uploaded["required"].append(name)
|
||||
events["$defs"]["recording_uploaded"] = uploaded
|
||||
events = uploaded_event_schema(events)
|
||||
if not isinstance(events, dict):
|
||||
raise TypeError("event schema transformation must produce an object")
|
||||
events["$id"] = BASE + "event-payloads.schema.json"
|
||||
events["properties"]["schema_version"] = {"const": "2.0"}
|
||||
events["properties"]["dispatcher_id"] = dispatcher_id
|
||||
events["properties"]["tenant_key"] = tenant_key
|
||||
events["required"].append("dispatcher_id")
|
||||
events["$defs"]["command_result"]["properties"]["command_type"]["enum"] = command_types
|
||||
emit("event-payloads.schema.json", events)
|
||||
|
||||
defs["dispatcherId"] = dispatcher_id
|
||||
defs["tenantKey"] = tenant_key
|
||||
for filename, prefix in [("executor.openapi.yaml", "executor_"), ("ai-config.openapi.yaml", "ai_")]:
|
||||
document = yaml.safe_load((OLD / filename).read_text())
|
||||
for name, schema in document["components"]["schemas"].items():
|
||||
defs[prefix + name] = rewrite_refs(schema, prefix)
|
||||
|
||||
|
||||
def event_collection(kinds):
|
||||
return {"type": "array", "items": {
|
||||
"allOf": [{"$ref": "event-payloads.schema.json"},
|
||||
{"properties": {"event_type": {"enum": kinds}}}]}}
|
||||
|
||||
|
||||
# Approved query gap closure: typed event collections and outbox counts.
|
||||
defs["executor_Command"]["additionalProperties"] = False
|
||||
call = defs["executor_Call"]
|
||||
call["additionalProperties"] = False
|
||||
call["properties"]["attempts"] = event_collection(["call.status"])
|
||||
call["properties"]["transcript"] = obj({"events": event_collection(["transcript.updated", "transcript.failed"])})
|
||||
call["properties"]["recordings"] = event_collection(["recording.uploaded", "recording.failed"])
|
||||
call["properties"]["delivery"] = obj({k: {"type": "integer", "minimum": 0} for k in ["pending", "retry", "dispatching", "published"]})
|
||||
|
||||
command = copy.deepcopy(defs.pop("executeCommand"))
|
||||
command["properties"].update({"schema_version": {"const": "2.0"}, "dispatcher_id": ref("dispatcherId"), "tenant_key": ref("tenantKey"), "command_type": {"enum": command_types}, "payload": {"type": "object"}})
|
||||
command["required"].append("dispatcher_id")
|
||||
control = copy.deepcopy(defs["executor_ControlRequest"])
|
||||
# The existing MQ envelope is the sole command_id authority, not a second body ID.
|
||||
control["properties"].pop("command_id")
|
||||
control["required"].remove("command_id")
|
||||
control["properties"]["task_id"] = ref("id")
|
||||
control["required"].append("task_id")
|
||||
defs["taskControl"] = control
|
||||
for name, target in [("callReplay", "call_id"), ("commandReplay", "source_command_id")]:
|
||||
value = copy.deepcopy(defs["executor_ReplayRequest"])
|
||||
value["properties"].pop("command_id")
|
||||
value["required"].remove("command_id")
|
||||
value["properties"][target] = ref("id")
|
||||
value["required"].append(target)
|
||||
defs[name] = value
|
||||
command["allOf"] = [{"if": {"properties": {"command_type": {"const": kind}}}, "then": {"properties": {"payload": ref(payload)}}} for kind, payload in zip(command_types, ["executePayload", "taskControl", "callReplay", "commandReplay"], strict=True)]
|
||||
defs["command"] = command
|
||||
defs["event"] = {"$ref": "event-payloads.schema.json"}
|
||||
|
||||
# Flatten allOf so the source receipt's closed object permits the known config.
|
||||
version = copy.deepcopy(defs["ai_AgentVersionReceipt"])
|
||||
version["properties"]["config"] = {"$ref": "ai-config.schema.json"}
|
||||
version["required"].append("config")
|
||||
defs["ai_AgentVersion"] = version
|
||||
defs["aiConfigResult"] = obj({"snapshot": ref("ai_AgentVersion"), "authorization": {"$ref": "ai-authorization.schema.json"}})
|
||||
request_payloads = {"command.query": obj({"command_id": ref("id")}), "call.query": obj({"call_id": ref("id")}), "ai.config.request": obj({"agent_version_id": ref("id")})}
|
||||
response_payloads = {"command.query.result": ref("executor_Command"), "call.query.result": ref("executor_Call"), "ai.config.result": ref("aiConfigResult")}
|
||||
common = {"schema_version": {"const": "2.0"}, "message_type": {"type": "string"}, "message_id": ref("id"), "dispatcher_id": ref("dispatcherId"), "tenant_id": ref("id"), "tenant_key": ref("tenantKey"), "trace_id": ref("id"), "issued_at": {"type": "string", "format": "date-time"}}
|
||||
request = obj({**common, "not_after": {"type": "string", "format": "date-time"}, "payload": {"type": "object"}})
|
||||
request["properties"]["message_type"] = {"enum": list(request_payloads)}
|
||||
request["allOf"] = [{"if": {"properties": {"message_type": {"const": kind}}}, "then": {"properties": {"payload": payload}}} for kind, payload in request_payloads.items()]
|
||||
defs["request"] = request
|
||||
reasons = ["invalid_request", "not_found", "conflict", "expired", "not_authorized", "unavailable", "unsupported"]
|
||||
response = obj({**common, "correlation_id": ref("id"), "status": {"enum": ["ok", "pending", "rejected"]}, "reason_code": {"type": "string"}, "payload": {"type": "object"}})
|
||||
response["properties"]["message_type"] = {"enum": list(response_payloads)}
|
||||
response["allOf"] = [
|
||||
{"if": {"properties": {"status": {"const": "pending"}}}, "then": {"properties": {"reason_code": {"const": "waiting"}, "payload": obj({})}}},
|
||||
{"if": {"properties": {"status": {"const": "rejected"}}}, "then": {"properties": {"reason_code": {"enum": reasons}, "payload": obj({"detail": {"type": "string", "maxLength": 1024}, "retryable": {"type": "boolean"}})}}},
|
||||
]
|
||||
for kind, payload in response_payloads.items():
|
||||
response["allOf"].append({"if": {"properties": {"status": {"const": "ok"}, "message_type": {"const": kind}}}, "then": {"properties": {"reason_code": {"const": "ok"}, "payload": payload}}})
|
||||
defs["response"] = response
|
||||
mq["$id"] = BASE + "mq.schema.json"
|
||||
mq["oneOf"] = [ref(k) for k in ["command", "event", "request", "response"]]
|
||||
|
||||
|
||||
def local_refs(value):
|
||||
if isinstance(value, list):
|
||||
return set().union(*(local_refs(v) for v in value))
|
||||
if not isinstance(value, dict):
|
||||
return set()
|
||||
found = set()
|
||||
if value.get("$ref", "").startswith("#/$defs/"):
|
||||
found.add(value["$ref"].split("/")[2])
|
||||
for k, v in value.items():
|
||||
if k != "$defs":
|
||||
found.update(local_refs(v))
|
||||
return found
|
||||
|
||||
|
||||
# Do not publish unused HTTP-specific definitions as a second supported contract.
|
||||
pending, reachable = local_refs(mq), set()
|
||||
while pending:
|
||||
name = pending.pop()
|
||||
if name not in reachable:
|
||||
reachable.add(name)
|
||||
pending.update(local_refs(defs[name]) - reachable)
|
||||
mq["$defs"] = {name: defs[name] for name in sorted(reachable)}
|
||||
emit("mq.schema.json", mq)
|
||||
|
||||
config = obj({"schema_version": {"const": "1.0"}, "dispatcher_id": dispatcher_id, "oss": obj({k: {"type": "string", "minLength": 1} for k in ["endpoint", "region", "bucket", "object_prefix", "access_key_id_env", "access_key_secret_env"]})})
|
||||
config.update({"$schema": SCHEMA, "$id": BASE + "dispatcher-config.schema.json"})
|
||||
config["properties"]["oss"]["properties"]["endpoint"].update({"format": "uri", "pattern": "^https?://"})
|
||||
for key in ["access_key_id_env", "access_key_secret_env"]:
|
||||
config["properties"]["oss"]["properties"][key]["pattern"] = "^[A-Za-z_][A-Za-z0-9_]*$"
|
||||
emit("dispatcher-config.schema.json", config)
|
||||
emit("mq-topology.json", {"version": "2.0", "exchanges": {name: {"type": "topic", "durable": True} for name in ["agent-call.dispatchers.v2", "agent-call.saas.v2", "agent-call.dead-letter.v2"]}, "inbox_queue": "agent-call.d.<dispatcher_id>.t.<tenant_key>.v2", "dead_letter_queue": "agent-call.d.<dispatcher_id>.t.<tenant_key>.dlq.v2", "inbound_key": "d.<dispatcher_id>.t.<tenant_key>.in", "outbound_key": "d.<dispatcher_id>.t.<tenant_key>.out", "saas_queue": "agent-call.saas.events.v2", "owner_queue": "agent-call.d.<dispatcher_id>.owner.v2", "owner_exclusive": True, "business_queues_durable": True, "message_persistent": True, "publish_mandatory": True, "publisher_confirms": True, "tenant_key_max_utf8_bytes": 196, "message_max_bytes": 262144, "service_request_deadline_seconds": 30, "upload_token_seconds": 900})
|
||||
|
||||
cases = []
|
||||
|
||||
|
||||
def fixture(name, value, valid=True, schema="mq.schema.json"):
|
||||
filename = "examples/" + name + ".json"
|
||||
emit(filename, value)
|
||||
cases.append({"file": filename, "schema": schema, "valid": valid})
|
||||
|
||||
|
||||
base = {"schema_version": "2.0", "dispatcher_id": DID, "tenant_id": "tenant-a", "tenant_key": "tenant-a", "trace_id": "trace-v2", "issued_at": AT}
|
||||
execute = {**base, "command_id": "command-a", "command_type": "call.execute", "not_after": "2026-09-21T00:00:30Z", "payload": {"execution_id": "execution-a", "task_id": "task-a", "task_item_id": "item-a", "task_revision": 1, "callee": "15003164745", "route_policy_id": "route-a", "caller_profile_id": "caller-a", "agent_version_id": "version-a", "variables": {}, "ring_timeout_ms": 1000, "max_call_duration_ms": 10000}}
|
||||
fixture("call-execute", execute)
|
||||
for kind, payload in [("task.control", {"task_id": "task-a", "action": "pause", "expected_task_revision": 1, "active_call_policy": "drain", "reason": "local-test"}), ("call.replay", {"call_id": "call-a", "reason": "local-test"}), ("command.replay", {"source_command_id": "command-a", "reason": "local-test"})]:
|
||||
fixture(kind.replace(".", "-"), {**execute, "command_id": kind + "-a", "command_type": kind, "payload": payload})
|
||||
for kind, payload in [("command.query", {"command_id": "command-a"}), ("call.query", {"call_id": "call-a"}), ("ai.config.request", {"agent_version_id": "version-a"})]:
|
||||
fixture(kind.replace(".", "-"), {**base, "message_id": kind + "-a", "message_type": kind, "not_after": "2026-09-21T00:00:30Z", "payload": payload})
|
||||
reply = {**base, "message_id": "query-reply-a", "message_type": "command.query.result", "correlation_id": "command.query-a", "status": "ok", "reason_code": "ok"}
|
||||
fixture("query-pending", {**reply, "status": "pending", "reason_code": "waiting", "payload": {}})
|
||||
fixture("query-rejected", {**reply, "status": "rejected", "reason_code": "not_found", "payload": {"detail": "command not found", "retryable": False}})
|
||||
fixture("dispatcher-config", {"schema_version": "1.0", "dispatcher_id": DID, "oss": {"endpoint": "https://oss.example.invalid", "region": "example-region", "bucket": "example-bucket", "object_prefix": "recordings", "access_key_id_env": "DISPATCHER_OSS_ACCESS_KEY_ID", "access_key_secret_env": "DISPATCHER_OSS_ACCESS_KEY_SECRET"}}, schema="dispatcher-config.schema.json")
|
||||
fixture("invalid-v1", {**execute, "schema_version": "1.0"}, False)
|
||||
fixture("invalid-wildcard", {**execute, "tenant_key": "tenant.#"}, False)
|
||||
fixture("invalid-dispatcher", {**execute, "dispatcher_id": "dispatcher-a"}, False)
|
||||
|
||||
# Every event and successful response has a fixture, not just a compilable schema.
|
||||
event_examples = {}
|
||||
for path in sorted((OLD / "examples").glob("event-*.json")):
|
||||
event = load_json(path)
|
||||
event.update({"schema_version": "2.0", "dispatcher_id": DID, "tenant_id": "tenant-a", "tenant_key": "tenant-a"})
|
||||
if "call_id" in event["payload"]:
|
||||
event["payload"]["call_id"] = "call-a"
|
||||
if event["aggregate_type"] == "call":
|
||||
event["aggregate_id"] = "call-a"
|
||||
name = path.stem
|
||||
if event["event_type"] == "recording.ready":
|
||||
event["event_type"] = "recording.uploaded"
|
||||
event["payload"].pop("oss_id")
|
||||
event["payload"].update({"upload_id": "upload-a", "bucket": "example-bucket", "object_key": "recordings/recording-a.wav"})
|
||||
name = "event-recording-uploaded"
|
||||
event["event_id"] = event["event_type"] + "-event-a"
|
||||
event_examples[event["event_type"]] = event
|
||||
fixture(name, event, schema="event-payloads.schema.json")
|
||||
command_result = {"command_id": "command-a", "command_type": "call.execute", "tenant_id": "tenant-a", "tenant_key": "tenant-a", "status": "accepted", "aggregate_version": 1}
|
||||
fixture("command-query-result", {**reply, "payload": command_result})
|
||||
fixture("invalid-correlation", {k: v for k, v in {**reply, "payload": command_result}.items() if k != "correlation_id"}, False)
|
||||
call_result = {"call_id": "call-a", "execution_id": event_examples["call.status"]["payload"]["execution_id"], "call_state": event_examples["call.status"]["payload"]["call_state"], "call_version": 1, "attempts": [event_examples["call.status"]], "transcript": {"events": [event_examples["transcript.updated"], event_examples["transcript.failed"]]}, "recordings": [event_examples["recording.uploaded"], event_examples["recording.failed"]], "delivery": {"pending": 1, "retry": 0, "dispatching": 0, "published": 0}, "snapshot_at": AT}
|
||||
fixture("call-query-result", {**reply, "message_id": "call-query-reply-a", "message_type": "call.query.result", "correlation_id": "call.query-a", "payload": call_result})
|
||||
invalid_query = copy.deepcopy(call_result)
|
||||
invalid_query["delivery"]["raw"] = {}
|
||||
fixture("invalid-call-query-extra", {**reply, "message_type": "call.query.result", "payload": invalid_query}, False)
|
||||
ai_config = load_json(OLD / "examples/agent-version-full-explicit.json")
|
||||
ai_config["agent_version_id"] = "version-a"
|
||||
config_digest = hashlib.sha256(json.dumps(ai_config, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
||||
snapshot = {"tenant_id": "tenant-a", "agent_version_id": "version-a", "status": "published", "immutable": True, "content_sha256": config_digest, "config": ai_config}
|
||||
authorization = load_json(OLD / "examples/ai-authorization.json")
|
||||
authorization.update({"tenant_id": "tenant-a", "tenant_key": "tenant-a", "agent_version_id": "version-a", "config_sha256": config_digest, "mode": ai_config["mode"]})
|
||||
fixture("ai-config-result", {**reply, "message_id": "ai-config-reply-a", "message_type": "ai.config.result", "correlation_id": "ai.config.request-a", "payload": {"snapshot": snapshot, "authorization": authorization}})
|
||||
emit("fixtures.json", cases)
|
||||
emit_text("README.md", "# Project-local MQ-only v2\n\nApproved design: docs/contracts/mq-only-v2-freeze-proposal.md, amended by the enqueue-only upload goal.\nThis is a local project contract, not external SaaS acceptance. Old v1 files are untouched.\nPublisher: scripts/publish-mq-v2.py. Validator: go test ./contracts -run TestV2.\n\nUpload notification: recording.uploaded, no recording.ready, upload session or verified reply. Completion means persistent delivery to the designated durable MQ queue, mandatory routing and publisher confirm; not SaaS consumption.\nPayload: call_id, recording_id, upload_id, bucket, object_key, format, channels, sample_rate_hz, duration_ms, size_bytes, checksum_sha256. No TOKEN, credentials, signed URL or SaaS OSS ID.\n\nControl/replay command_id belongs solely to the envelope. Query nested structures reuse typed events and delivery counts. AI receipt/config composition is flattened without widening fields.\nSchema limits characters; runtime also checks UTF-8 bytes and aggregate response size. Oversized snapshots fail explicitly, never silently truncate.\n")
|
||||
actual = {str(p.relative_to(NEW)) for p in NEW.rglob("*") if p.is_file() and p.name != "manifest.json"}
|
||||
if extra := actual - WRITTEN:
|
||||
raise RuntimeError(f"Unpublished/stale files remain in draft bundle; inspect explicitly: {sorted(extra)}")
|
||||
files = {name: hashlib.sha256((NEW / name).read_bytes()).hexdigest() for name in sorted(WRITTEN)}
|
||||
emit("manifest.json", {"version": "2026-09-21-p1-v2", "source": "project-approved-mq-only-v2", "derived_from": "2026-09-19-p1-v1", "source_files": {str(p.relative_to(OLD)): hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted(OLD.rglob("*")) if p.is_file()}, "files": files})
|
||||
print(f"Published {len(files)} pinned project-local draft files in {NEW.relative_to(ROOT)}")
|
||||
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Publish the approved JCS digest revision without modifying older bundles."""
|
||||
import hashlib
|
||||
import importlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
import jsonschema
|
||||
from referencing import Registry, Resource
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
OLD_VERSION = "2026-09-21-p1-v2"
|
||||
VERSION = "2026-09-21-p1-v3"
|
||||
OLD = ROOT / "contracts/upstream" / OLD_VERSION
|
||||
NEW = OLD.parent / VERSION
|
||||
MODULE = "github.com/cyberphone/json-canonicalization"
|
||||
REVISION = "v0.0.0-20241213102144-19d51d7fe467"
|
||||
info = json.loads(subprocess.check_output(["go", "mod", "download", "-json", MODULE + "@" + REVISION], cwd=ROOT))
|
||||
if info.get("Error") or info.get("Version") != REVISION:
|
||||
raise RuntimeError("pinned JCS implementation unavailable")
|
||||
sys.path.insert(0, str(Path(info["Dir"]) / "python3/src"))
|
||||
canonicalize = importlib.import_module("org.webpki.json.Canonicalize").canonicalize
|
||||
|
||||
|
||||
def encoded(value):
|
||||
return (json.dumps(value, ensure_ascii=False, indent=2) + "\n").encode()
|
||||
|
||||
|
||||
def digest(value):
|
||||
return hashlib.sha256(canonicalize(value)).hexdigest()
|
||||
|
||||
|
||||
manifest = json.loads((OLD / "manifest.json").read_text())
|
||||
files = {}
|
||||
for name, expected in manifest["files"].items():
|
||||
raw = (OLD / name).read_bytes()
|
||||
if hashlib.sha256(raw).hexdigest() != expected:
|
||||
raise RuntimeError("source bundle hash mismatch: " + name)
|
||||
files[name] = raw.replace(OLD_VERSION.encode(), VERSION.encode())
|
||||
|
||||
result = json.loads(files["examples/ai-config-result.json"])
|
||||
result["payload"]["snapshot"]["content_sha256"] = digest(result["payload"]["snapshot"]["config"])
|
||||
files["examples/ai-config-result.json"] = encoded(result)
|
||||
oldest = ROOT / "contracts/upstream/2026-09-19-p1-v1/examples"
|
||||
for name in ("agent-version.json", "agent-version-asr-only.json", "agent-version-full-explicit.json"):
|
||||
files["examples/" + name] = (oldest / name).read_bytes()
|
||||
authorization = json.loads((oldest / "ai-authorization.json").read_text())
|
||||
authorization["config_sha256"] = digest(json.loads(files["examples/agent-version-asr-only.json"]))
|
||||
files["examples/ai-authorization.json"] = encoded(authorization)
|
||||
|
||||
vectors = [
|
||||
{"name": "numbers", "input": '{"b":1.0,"a":[-0.0,1e30,4.50,2e-3]}'},
|
||||
{"name": "utf16-key-order", "input": '{"\\ue000":1,"\\ud800\\udc00":2}'},
|
||||
{"name": "strings", "input": '{"s":"\\u20ac/\\n","flag":false,"zero":0}'},
|
||||
]
|
||||
for vector in vectors:
|
||||
value = json.loads(vector["input"])
|
||||
vector["canonical"] = canonicalize(value).decode()
|
||||
vector["sha256"] = digest(value)
|
||||
files["jcs-golden.json"] = encoded(vectors)
|
||||
files["ai-digest.json"] = encoded({
|
||||
"scope": "AI snapshot.config and authorization config_sha256 only",
|
||||
"canonicalization": "RFC 8785 (JCS)", "digest": "SHA-256", "encoding": "lowercase hex",
|
||||
"implementation": MODULE, "revision": REVISION,
|
||||
"external_acceptance": False,
|
||||
"invariants": ["No Unicode normalization", "Reject invalid UTF-8 and duplicate object keys",
|
||||
"Preserve explicit zero/false versus absent fields", "No old-digest fallback"],
|
||||
})
|
||||
files["README.md"] = (f"# {VERSION}\n\nProject-local JCS/SHA-256 revision approved by the user. "
|
||||
f"Derived from {OLD_VERSION}; older packages are unchanged. Wire schema_version remains 2.0.\n\n"
|
||||
"AI digests use RFC 8785 canonical UTF-8 bytes followed by SHA-256, lowercase hexadecimal. "
|
||||
"This does not change file checksums, upload facts, command-body identity, or other hashes. "
|
||||
"Numbers follow JCS IEEE-754 rules; no Unicode normalization is performed. "
|
||||
"Explicit zero/false remain distinct from absent fields. No legacy digest fallback.\n\n"
|
||||
"Local agreement and generated fixtures are not external SaaS acceptance.\n").encode()
|
||||
# Validate positive and negative fixtures against an entirely offline registry
|
||||
# before the directory becomes an immutable published bundle.
|
||||
schemas = {name: json.loads(raw) for name, raw in files.items() if name.endswith(".schema.json")}
|
||||
registry = Registry().with_resources((value["$id"], Resource.from_contents(value)) for value in schemas.values())
|
||||
for fixture in json.loads(files["fixtures.json"]):
|
||||
validator = jsonschema.Draft202012Validator(schemas[fixture["schema"]], registry=registry,
|
||||
format_checker=jsonschema.Draft202012Validator.FORMAT_CHECKER)
|
||||
valid = validator.is_valid(json.loads(files[fixture["file"]]))
|
||||
if valid != fixture["valid"]:
|
||||
raise RuntimeError("fixture validation mismatch: " + fixture["file"])
|
||||
for name in ("agent-version.json", "agent-version-asr-only.json", "agent-version-full-explicit.json"):
|
||||
jsonschema.Draft202012Validator(schemas["ai-config.schema.json"], registry=registry).validate(json.loads(files["examples/" + name]))
|
||||
jsonschema.Draft202012Validator(schemas["ai-authorization.schema.json"], registry=registry).validate(authorization)
|
||||
|
||||
files["manifest.json"] = encoded({
|
||||
"version": VERSION, "source": "project-approved-jcs-sha256", "derived_from": OLD_VERSION,
|
||||
"source_manifest_sha256": hashlib.sha256((OLD / "manifest.json").read_bytes()).hexdigest(),
|
||||
"files": {name: hashlib.sha256(raw).hexdigest() for name, raw in sorted(files.items())},
|
||||
})
|
||||
if NEW.exists():
|
||||
existing = {str(p.relative_to(NEW)): p.read_bytes() for p in NEW.rglob("*") if p.is_file()}
|
||||
if existing != files:
|
||||
raise RuntimeError("immutable destination differs; publish a new version instead")
|
||||
else:
|
||||
with tempfile.TemporaryDirectory(prefix=".jcs-publish-", dir=OLD.parent) as temporary:
|
||||
stage = Path(temporary) / VERSION
|
||||
for name, raw in files.items():
|
||||
target = stage / name
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(raw)
|
||||
os.rename(stage, NEW)
|
||||
print(VERSION, hashlib.sha256(files["manifest.json"]).hexdigest())
|
||||
Reference in New Issue
Block a user