Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dde0a8bbb5 | ||
|
|
83f0cff756 | ||
|
|
26d1b73eb1 | ||
|
|
191f62af72 | ||
|
|
f8be2b3abd | ||
|
|
e7c094782a | ||
|
|
5855d4b53e | ||
|
|
ee910d9977 | ||
|
|
0468860aa7 |
@@ -13,6 +13,7 @@ from hashlib import sha256
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import json
|
import json
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from message_handoff import stable_event_id
|
||||||
|
|
||||||
|
|
||||||
def utc_now() -> str:
|
def utc_now() -> str:
|
||||||
@@ -55,13 +56,18 @@ class AgentEventBus:
|
|||||||
payload: dict[str, Any],
|
payload: dict[str, Any],
|
||||||
correlation_id: str | None = None,
|
correlation_id: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
event_id = "evt_" + sha256(
|
event_id = stable_event_id(
|
||||||
json.dumps(
|
source=source, target=target, message_type=kind, payload=payload,
|
||||||
{"kind": kind, "source": source, "summary": summary, "payload": payload},
|
tenant_id=(payload.get('tenant_id') if isinstance(payload, dict) else None),
|
||||||
sort_keys=True,
|
user_id=(payload.get('user_id') if isinstance(payload, dict) else None),
|
||||||
ensure_ascii=False,
|
project_id=(payload.get('project_id') if isinstance(payload, dict) else None),
|
||||||
).encode("utf-8")
|
conversation_id=(payload.get('conversation_id') if isinstance(payload, dict) else None),
|
||||||
).hexdigest()[:16]
|
task_id=(payload.get('task_id') if isinstance(payload, dict) else None),
|
||||||
|
run_id=(payload.get('run_id') if isinstance(payload, dict) else None),
|
||||||
|
causation_id=(payload.get('causation_id') if isinstance(payload, dict) else None),
|
||||||
|
sequence=(payload.get('sequence') if isinstance(payload, dict) else None),
|
||||||
|
idempotency_key=(payload.get('idempotency_key') if isinstance(payload, dict) else None),
|
||||||
|
)
|
||||||
event = AgentEvent(event_id, kind, source, target, summary, payload, correlation_id=correlation_id)
|
event = AgentEvent(event_id, kind, source, target, summary, payload, correlation_id=correlation_id)
|
||||||
self._write("pending", event.to_dict())
|
self._write("pending", event.to_dict())
|
||||||
self._audit("created", event.to_dict())
|
self._audit("created", event.to_dict())
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
import os
|
import os
|
||||||
import base64
|
|
||||||
import hashlib
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -60,82 +58,6 @@ class AuthorityGitExecutor:
|
|||||||
value = self._git(["rev-parse", "--verify", ref], check=False)
|
value = self._git(["rev-parse", "--verify", ref], check=False)
|
||||||
return value if value and OID_RE.fullmatch(value) else None
|
return value if value and OID_RE.fullmatch(value) else None
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _read_path(path: str) -> str:
|
|
||||||
if not isinstance(path, str) or not path or len(path) > 512:
|
|
||||||
raise AuthorityGitError("SOURCE_PATH_INVALID")
|
|
||||||
if "\\" in path or "\x00" in path:
|
|
||||||
raise AuthorityGitError("SOURCE_PATH_INVALID")
|
|
||||||
parts = Path(path).parts
|
|
||||||
if not parts or path.startswith("/") or ".." in parts or ".git" in parts:
|
|
||||||
raise AuthorityGitError("SOURCE_PATH_INVALID")
|
|
||||||
return "/".join(parts)
|
|
||||||
|
|
||||||
def _read_revision(self, ref: str) -> str:
|
|
||||||
if not isinstance(ref, str) or not ref or len(ref) > 256 or "\x00" in ref:
|
|
||||||
raise AuthorityGitError("SOURCE_REF_INVALID")
|
|
||||||
if OID_RE.fullmatch(ref):
|
|
||||||
if not self.commit_exists(ref):
|
|
||||||
raise AuthorityGitError("SOURCE_REF_NOT_FOUND")
|
|
||||||
return ref
|
|
||||||
if ref == "main":
|
|
||||||
ref = "refs/heads/main"
|
|
||||||
elif ref.startswith("agent/"):
|
|
||||||
ref = "refs/heads/" + ref
|
|
||||||
elif ref.startswith("refs/heads/agent/"):
|
|
||||||
pass
|
|
||||||
elif ref == "V2R2C1":
|
|
||||||
ref = "refs/tags/V2R2C1"
|
|
||||||
elif ref.startswith("refs/tags/V"):
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
raise AuthorityGitError("SOURCE_REF_SCOPE_DENIED")
|
|
||||||
if ".." in ref or not re.fullmatch(r"refs/(heads/(?:agent/[A-Za-z0-9._/-]+|main)|tags/V[0-9]+R[0-9]+C[0-9]+)", ref):
|
|
||||||
raise AuthorityGitError("SOURCE_REF_SCOPE_DENIED")
|
|
||||||
value = self._git(["rev-parse", "--verify", ref + "^{commit}"], check=False)
|
|
||||||
if not OID_RE.fullmatch(value):
|
|
||||||
raise AuthorityGitError("SOURCE_REF_NOT_FOUND")
|
|
||||||
return value
|
|
||||||
|
|
||||||
def read_file(self, ref: str, path: str, max_bytes: int) -> dict[str, object]:
|
|
||||||
if not isinstance(max_bytes, int) or isinstance(max_bytes, bool) or not 1 <= max_bytes <= 262_144:
|
|
||||||
raise AuthorityGitError("SOURCE_MAX_BYTES_INVALID")
|
|
||||||
revision = self._read_revision(ref)
|
|
||||||
relative = self._read_path(path)
|
|
||||||
spec = f"{revision}:{relative}"
|
|
||||||
size_result = subprocess.run(
|
|
||||||
["git", f"--git-dir={self.git_dir}", "cat-file", "-s", spec],
|
|
||||||
text=True, capture_output=True, check=False,
|
|
||||||
)
|
|
||||||
if size_result.returncode != 0:
|
|
||||||
raise AuthorityGitError("SOURCE_FILE_NOT_FOUND")
|
|
||||||
try:
|
|
||||||
size = int(size_result.stdout.strip())
|
|
||||||
except ValueError as exc:
|
|
||||||
raise AuthorityGitError("SOURCE_FILE_READ_FAILED") from exc
|
|
||||||
if size > max_bytes:
|
|
||||||
raise AuthorityGitError("SOURCE_FILE_TOO_LARGE")
|
|
||||||
result = subprocess.run(
|
|
||||||
["git", f"--git-dir={self.git_dir}", "show", spec],
|
|
||||||
capture_output=True, check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
raise AuthorityGitError("SOURCE_FILE_NOT_FOUND")
|
|
||||||
content = result.stdout
|
|
||||||
if len(content) != size:
|
|
||||||
raise AuthorityGitError("SOURCE_FILE_READ_FAILED")
|
|
||||||
return {
|
|
||||||
"repository": "aios/aios",
|
|
||||||
"ref": ref,
|
|
||||||
"resolved_commit_oid": revision,
|
|
||||||
"path": relative,
|
|
||||||
"size_bytes": size,
|
|
||||||
"sha256": hashlib.sha256(content).hexdigest(),
|
|
||||||
"content_base64": base64.b64encode(content).decode("ascii"),
|
|
||||||
"truncated": False,
|
|
||||||
"read_only": True,
|
|
||||||
}
|
|
||||||
|
|
||||||
def commit_exists(self, oid: str) -> bool:
|
def commit_exists(self, oid: str) -> bool:
|
||||||
_oid(oid, "commit_oid")
|
_oid(oid, "commit_oid")
|
||||||
result = subprocess.run(["git", f"--git-dir={self.git_dir}", "cat-file", "-e", oid + "^{commit}"], capture_output=True, check=False)
|
result = subprocess.run(["git", f"--git-dir={self.git_dir}", "cat-file", "-e", oid + "^{commit}"], capture_output=True, check=False)
|
||||||
@@ -172,23 +94,89 @@ class AuthorityGitExecutor:
|
|||||||
self._git(["worktree", "add", "--detach", str(path), base_oid])
|
self._git(["worktree", "add", "--detach", str(path), base_oid])
|
||||||
return path
|
return path
|
||||||
|
|
||||||
def create_task_branch(self, branch: str, base_oid: str) -> str:
|
def recover_failed_detached_worktree_exact(
|
||||||
"""Create an isolated agent branch once, using an empty-ref CAS."""
|
self,
|
||||||
ref = branch if branch.startswith("refs/heads/") else "refs/heads/" + branch
|
*,
|
||||||
if not TASK_BRANCH_RE.fullmatch(ref):
|
path: Path,
|
||||||
|
expected_oid: str,
|
||||||
|
stale_oid: str,
|
||||||
|
target_ref: str,
|
||||||
|
) -> str:
|
||||||
|
"""Recreate one exact clean, detached, single-attempt worktree at its durable head."""
|
||||||
|
_oid(expected_oid, "expected_oid")
|
||||||
|
_oid(stale_oid, "stale_oid")
|
||||||
|
if not TASK_BRANCH_RE.fullmatch(target_ref):
|
||||||
raise AuthorityGitError("TASK_BRANCH_SCOPE_DENIED")
|
raise AuthorityGitError("TASK_BRANCH_SCOPE_DENIED")
|
||||||
_oid(base_oid, "base_oid")
|
if self.read_ref(target_ref) is not None:
|
||||||
if not self.commit_exists(base_oid):
|
raise AuthorityGitError("AUTHORITY_TARGET_BRANCH_ALREADY_EXISTS")
|
||||||
raise AuthorityGitError("BASE_COMMIT_NOT_FOUND")
|
if (
|
||||||
current = self.read_ref(ref)
|
not path.is_absolute()
|
||||||
|
or path.parent != self.worktree_root
|
||||||
|
or path.name in {"", ".", ".."}
|
||||||
|
or path.is_symlink()
|
||||||
|
or not path.is_dir()
|
||||||
|
):
|
||||||
|
raise AuthorityGitError("WORKTREE_RECOVERY_PATH_INVALID")
|
||||||
|
head = subprocess.run(
|
||||||
|
["git", "-C", str(path), "rev-parse", "HEAD"],
|
||||||
|
text=True, capture_output=True, check=False,
|
||||||
|
)
|
||||||
|
if head.returncode != 0 or head.stdout.strip() != stale_oid or stale_oid == expected_oid:
|
||||||
|
raise AuthorityGitError("WORKTREE_RECOVERY_HEAD_INVALID")
|
||||||
|
symbolic = subprocess.run(
|
||||||
|
["git", "-C", str(path), "symbolic-ref", "-q", "HEAD"],
|
||||||
|
text=True, capture_output=True, check=False,
|
||||||
|
)
|
||||||
|
if symbolic.returncode != 1:
|
||||||
|
raise AuthorityGitError("WORKTREE_RECOVERY_NOT_DETACHED")
|
||||||
|
status = subprocess.run(
|
||||||
|
["git", "-C", str(path), "status", "--porcelain=v1", "--untracked-files=all"],
|
||||||
|
text=True, capture_output=True, check=False,
|
||||||
|
)
|
||||||
|
if status.returncode != 0 or status.stdout:
|
||||||
|
raise AuthorityGitError("WORKTREE_RECOVERY_NOT_CLEAN")
|
||||||
|
parents = self._git(["rev-list", "--parents", "-n", "1", stale_oid]).split()
|
||||||
|
if parents != [stale_oid, expected_oid]:
|
||||||
|
raise AuthorityGitError("WORKTREE_RECOVERY_LINEAGE_INVALID")
|
||||||
|
registered = [
|
||||||
|
line.removeprefix("worktree ")
|
||||||
|
for line in self._git(["worktree", "list", "--porcelain"]).splitlines()
|
||||||
|
if line.startswith("worktree ")
|
||||||
|
]
|
||||||
|
if registered.count(str(path)) != 1:
|
||||||
|
raise AuthorityGitError("WORKTREE_RECOVERY_REGISTRATION_INVALID")
|
||||||
|
self._git(["worktree", "remove", "--force", str(path)])
|
||||||
|
if path.exists() or path.is_symlink():
|
||||||
|
raise AuthorityGitError("WORKTREE_RECOVERY_REMOVE_FAILED")
|
||||||
|
recreated = self.create_detached_worktree(expected_oid, path.name, path.parent)
|
||||||
|
readback = subprocess.run(
|
||||||
|
["git", "-C", str(recreated), "rev-parse", "HEAD"],
|
||||||
|
text=True, capture_output=True, check=False,
|
||||||
|
)
|
||||||
|
detached = subprocess.run(
|
||||||
|
["git", "-C", str(recreated), "symbolic-ref", "-q", "HEAD"],
|
||||||
|
text=True, capture_output=True, check=False,
|
||||||
|
)
|
||||||
|
if readback.returncode != 0 or readback.stdout.strip() != expected_oid or detached.returncode != 1:
|
||||||
|
raise AuthorityGitError("WORKTREE_RECOVERY_READBACK_MISMATCH")
|
||||||
|
return stale_oid
|
||||||
|
|
||||||
|
def create_task_branch_CAS(self, branch: str, expected_base_oid: str, new_oid: str) -> str:
|
||||||
|
if not TASK_BRANCH_RE.fullmatch(branch):
|
||||||
|
raise AuthorityGitError("TASK_BRANCH_SCOPE_DENIED")
|
||||||
|
_oid(expected_base_oid, "expected_base_oid")
|
||||||
|
_oid(new_oid, "new_oid")
|
||||||
|
current = self.read_ref(branch)
|
||||||
if current is not None:
|
if current is not None:
|
||||||
if current == base_oid:
|
if current == new_oid:
|
||||||
return current
|
return new_oid
|
||||||
raise AuthorityGitError("AUTHORITY_BRANCH_EXPECTATION_MISMATCH")
|
raise AuthorityGitError("AUTHORITY_TARGET_BRANCH_ALREADY_EXISTS")
|
||||||
self._git(["update-ref", ref, base_oid, "0" * 40])
|
if not self.ancestry_check(expected_base_oid, new_oid):
|
||||||
readback = self.read_ref(ref)
|
raise AuthorityGitError("NEW_OID_NOT_DESCENDANT")
|
||||||
if readback != base_oid:
|
self._git(["update-ref", branch, new_oid, "0" * 40])
|
||||||
raise AuthorityGitError("AUTHORITY_CAS_READBACK_MISMATCH")
|
readback = self.read_ref(branch)
|
||||||
|
if readback != new_oid:
|
||||||
|
raise AuthorityGitError("AUTHORITY_CREATE_READBACK_MISMATCH")
|
||||||
return readback
|
return readback
|
||||||
|
|
||||||
def advance_task_branch_CAS(self, branch: str, expected_oid: str, new_oid: str) -> str:
|
def advance_task_branch_CAS(self, branch: str, expected_oid: str, new_oid: str) -> str:
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ from gitea_tag_publication import (
|
|||||||
TagPublisher,
|
TagPublisher,
|
||||||
redact,
|
redact,
|
||||||
validate_version,
|
validate_version,
|
||||||
validate_gitea_api_url,
|
|
||||||
)
|
)
|
||||||
from orchestrator.action_monitor import ActionMonitor
|
from orchestrator.action_monitor import ActionMonitor
|
||||||
from silent_release_hardening import (
|
from silent_release_hardening import (
|
||||||
@@ -309,7 +308,8 @@ def resolve_commit_oid(value: str | None, field: str = 'target_oid') -> str:
|
|||||||
raise ValueError(f'{field} does not resolve to the exact approved commit') from exc
|
raise ValueError(f'{field} does not resolve to the exact approved commit') from exc
|
||||||
|
|
||||||
def existing_tag_target(tag: str) -> str | None:
|
def existing_tag_target(tag: str) -> str | None:
|
||||||
validate_version(tag)
|
if not re.fullmatch(r'V\d+R\d+C\d+(?:-[A-Za-z0-9_.-]+)?$', str(tag or '')):
|
||||||
|
raise ValueError('invalid release version')
|
||||||
try:
|
try:
|
||||||
result = run_authoritative_git(
|
result = run_authoritative_git(
|
||||||
['rev-parse', '--verify', f'refs/tags/{tag}^{{commit}}'],
|
['rev-parse', '--verify', f'refs/tags/{tag}^{{commit}}'],
|
||||||
@@ -344,7 +344,7 @@ def required_authoritative_git_dir() -> Path:
|
|||||||
|
|
||||||
def publication_publisher() -> TagPublisher:
|
def publication_publisher() -> TagPublisher:
|
||||||
authoritative_repo = required_authoritative_git_dir()
|
authoritative_repo = required_authoritative_git_dir()
|
||||||
publisher = TagPublisher(
|
return TagPublisher(
|
||||||
WS,
|
WS,
|
||||||
GitTagBackend(WS, authoritative_repo=authoritative_repo),
|
GitTagBackend(WS, authoritative_repo=authoritative_repo),
|
||||||
GiteaTagClient(
|
GiteaTagClient(
|
||||||
@@ -355,10 +355,6 @@ def publication_publisher() -> TagPublisher:
|
|||||||
),
|
),
|
||||||
os.getenv('AIOS_GITOPS_GITEA_REMOTE','gitea'),
|
os.getenv('AIOS_GITOPS_GITEA_REMOTE','gitea'),
|
||||||
)
|
)
|
||||||
publisher.baseline_path = Path(os.getenv(
|
|
||||||
'AIOS_GITOPS_BASELINE_AUTHORITY', str(WS / 'baseline' / 'current.json')
|
|
||||||
))
|
|
||||||
return publisher
|
|
||||||
|
|
||||||
def commit(paths, msg):
|
def commit(paths, msg):
|
||||||
ensure_git(); paths = [relpath(p) for p in paths] if paths else ['.']; sh(['add'] + paths, True)
|
ensure_git(); paths = [relpath(p) for p in paths] if paths else ['.']; sh(['add'] + paths, True)
|
||||||
@@ -514,15 +510,7 @@ class H(BaseHTTPRequestHandler):
|
|||||||
return self.sendj({'error':redact(exc)},413)
|
return self.sendj({'error':redact(exc)},413)
|
||||||
except (AuthoritativeGitError, RuntimeError) as exc:
|
except (AuthoritativeGitError, RuntimeError) as exc:
|
||||||
return self.sendj({'error':redact(exc)},503)
|
return self.sendj({'error':redact(exc)},503)
|
||||||
payload = {'from': frm, 'to': to, 'diff': diff}
|
return self.sendj({'from':frm,'to':to,'diff':diff})
|
||||||
payload.update({
|
|
||||||
key: diff[key]
|
|
||||||
for key in (
|
|
||||||
'from_oid', 'to_oid', 'changed_paths', 'stats',
|
|
||||||
'total_size_bytes', 'truncated', 'patch_limit_bytes',
|
|
||||||
)
|
|
||||||
})
|
|
||||||
return self.sendj(payload)
|
|
||||||
if path in ('/api/patch','/api/patches','/api/branches','/patch'):
|
if path in ('/api/patch','/api/patches','/api/branches','/patch'):
|
||||||
snapshot = repository_snapshot(
|
snapshot = repository_snapshot(
|
||||||
git_dir=AUTHORITATIVE_GIT_DIR,
|
git_dir=AUTHORITATIVE_GIT_DIR,
|
||||||
@@ -634,8 +622,7 @@ class H(BaseHTTPRequestHandler):
|
|||||||
return self.sendj(
|
return self.sendj(
|
||||||
service.get_capability_status(candidate_id)
|
service.get_capability_status(candidate_id)
|
||||||
)
|
)
|
||||||
result = service.get_release_saga(candidate_id)
|
return self.sendj(service.get_release_saga(candidate_id))
|
||||||
return self.sendj(result, 404 if result.get('status') == 'NOT_FOUND' else 200)
|
|
||||||
if path == '/getRecoveryStatus':
|
if path == '/getRecoveryStatus':
|
||||||
return self.sendj(
|
return self.sendj(
|
||||||
silent_release_service().get_recovery_status()
|
silent_release_service().get_recovery_status()
|
||||||
@@ -786,10 +773,7 @@ class H(BaseHTTPRequestHandler):
|
|||||||
a=self.need('tag:create');
|
a=self.need('tag:create');
|
||||||
if not a: return
|
if not a: return
|
||||||
ver=data.get('version') or data.get('tag')
|
ver=data.get('version') or data.get('tag')
|
||||||
try:
|
if not re.match(r'^V\d+R\d+C\d+(-[A-Za-z0-9_.-]+)?$', ver or ''): return self.sendj({'error':'version must match VxRxCx'},400)
|
||||||
validate_version(ver)
|
|
||||||
except ValueError:
|
|
||||||
return self.sendj({'error':'version must match VxRxCx'},400)
|
|
||||||
if not data.get('target_oid') or not data.get('evidence_closure_oid') or not data.get('approval_plan_id'):
|
if not data.get('target_oid') or not data.get('evidence_closure_oid') or not data.get('approval_plan_id'):
|
||||||
return self.sendj({
|
return self.sendj({
|
||||||
'error':'production_tag_evidence_required',
|
'error':'production_tag_evidence_required',
|
||||||
@@ -963,7 +947,6 @@ def api_spec():
|
|||||||
return {'version':'V1R1C0-chatgpt-actions','openapi':'/api/openapi.yaml','read':['GET /api/base','GET /api/ver','GET /api/diff','GET /api/patch','GET /api/model','GET /api/run','GET /getReleaseCandidate','GET /getApprovalEnvelope','GET /getReleaseSaga','GET /admin/ai-evolution'],'write':['POST /api/branch','POST /api/file','POST /api/commit','POST /api/patch','POST /api/tag','POST /api/rollback','POST /api/model','PUT /api/model-priority','POST /api/nl','POST /createReleaseCandidate','POST /submitOwnerApproval','POST /startDeployment'],'policy':{'bearer_required_for_write':True,'direct_main_push':False,'delete_tag':False,'owner_approval_required_for_v_level':True,'api_keys_in_git':False,'git_secret_field':'secret_ref','online_git_sync':True,'silent_release_execution_enabled':SILENT_RELEASE_EXECUTION_ENABLED,'silent_release_shadow_default':True}}
|
return {'version':'V1R1C0-chatgpt-actions','openapi':'/api/openapi.yaml','read':['GET /api/base','GET /api/ver','GET /api/diff','GET /api/patch','GET /api/model','GET /api/run','GET /getReleaseCandidate','GET /getApprovalEnvelope','GET /getReleaseSaga','GET /admin/ai-evolution'],'write':['POST /api/branch','POST /api/file','POST /api/commit','POST /api/patch','POST /api/tag','POST /api/rollback','POST /api/model','PUT /api/model-priority','POST /api/nl','POST /createReleaseCandidate','POST /submitOwnerApproval','POST /startDeployment'],'policy':{'bearer_required_for_write':True,'direct_main_push':False,'delete_tag':False,'owner_approval_required_for_v_level':True,'api_keys_in_git':False,'git_secret_field':'secret_ref','online_git_sync':True,'silent_release_execution_enabled':SILENT_RELEASE_EXECUTION_ENABLED,'silent_release_shadow_default':True}}
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
validate_gitea_api_url(os.getenv('AIOS_GITOPS_GITEA_API_URL', ''))
|
|
||||||
print(f'AIOS ChatGPT GitOps adapter listening on {HOST}:{PORT}')
|
print(f'AIOS ChatGPT GitOps adapter listening on {HOST}:{PORT}')
|
||||||
ThreadingHTTPServer((HOST,PORT), H).serve_forever()
|
ThreadingHTTPServer((HOST,PORT), H).serve_forever()
|
||||||
if __name__ == '__main__': main()
|
if __name__ == '__main__': main()
|
||||||
|
|||||||
@@ -11,9 +11,12 @@ BRIDGE_SCHEMA_VERSION = "aios.chatgpt-gitops-bridge.v2"
|
|||||||
BRIDGE_OPERATIONS = (
|
BRIDGE_OPERATIONS = (
|
||||||
"getRuntimeStatus",
|
"getRuntimeStatus",
|
||||||
"getGovernedWriteCapabilities",
|
"getGovernedWriteCapabilities",
|
||||||
"readRepositoryFile",
|
|
||||||
"createGovernedTask",
|
"createGovernedTask",
|
||||||
|
"projectOnlineAgentExecution",
|
||||||
|
"registerOnlineAgentExecutionPlan",
|
||||||
"acquireWriterLease",
|
"acquireWriterLease",
|
||||||
|
"bootstrapWriterLease",
|
||||||
|
"renewWriterLease",
|
||||||
"commitFileGoverned",
|
"commitFileGoverned",
|
||||||
"commitFilesGoverned",
|
"commitFilesGoverned",
|
||||||
"getGitOpsJob",
|
"getGitOpsJob",
|
||||||
@@ -31,7 +34,14 @@ BRIDGE_OPERATIONS = (
|
|||||||
|
|
||||||
OPERATION_REQUIRED_FIELDS = {
|
OPERATION_REQUIRED_FIELDS = {
|
||||||
"createGovernedTask": ("task_id", "repository", "base_oid", "target_branch", "risk", "description", "idempotency_key"),
|
"createGovernedTask": ("task_id", "repository", "base_oid", "target_branch", "risk", "description", "idempotency_key"),
|
||||||
|
"projectOnlineAgentExecution": ("task_id",),
|
||||||
|
"registerOnlineAgentExecutionPlan": (
|
||||||
|
"schema", "task_id", "job_id", "source_head_oid", "generation",
|
||||||
|
"idempotency_key", "execution",
|
||||||
|
),
|
||||||
"acquireWriterLease": ("schema", "task_id", "repository", "branch_mode", "base_oid", "expected_branch_oid", "target_branch", "generation", "lease_seconds"),
|
"acquireWriterLease": ("schema", "task_id", "repository", "branch_mode", "base_oid", "expected_branch_oid", "target_branch", "generation", "lease_seconds"),
|
||||||
|
"bootstrapWriterLease": ("schema", "task_id", "repository", "branch_mode", "base_oid", "expected_branch_oid", "target_branch", "generation", "lease_seconds"),
|
||||||
|
"renewWriterLease": ("schema", "task_id", "repository", "branch_mode", "base_oid", "expected_branch_oid", "target_branch", "generation", "lease_seconds"),
|
||||||
"commitFileGoverned": ("task_id", "repository", "expected_branch_oid", "target_branch", "generation", "idempotency_key", "path", "content_base64", "message"),
|
"commitFileGoverned": ("task_id", "repository", "expected_branch_oid", "target_branch", "generation", "idempotency_key", "path", "content_base64", "message"),
|
||||||
"commitFilesGoverned": ("task_id", "repository", "expected_branch_oid", "target_branch", "generation", "idempotency_key", "files", "message"),
|
"commitFilesGoverned": ("task_id", "repository", "expected_branch_oid", "target_branch", "generation", "idempotency_key", "files", "message"),
|
||||||
"getGitOpsJob": ("job_id",),
|
"getGitOpsJob": ("job_id",),
|
||||||
@@ -45,83 +55,71 @@ OPERATION_REQUIRED_FIELDS = {
|
|||||||
"getReleaseConsistency": ("version", "target_oid", "evidence_closure_oid", "approval_plan_id"),
|
"getReleaseConsistency": ("version", "target_oid", "evidence_closure_oid", "approval_plan_id"),
|
||||||
"runReleaseAcceptance": ("version", "target_oid", "evidence_closure_oid", "approval_plan_id"),
|
"runReleaseAcceptance": ("version", "target_oid", "evidence_closure_oid", "approval_plan_id"),
|
||||||
"getControlPlaneSnapshot": (),
|
"getControlPlaneSnapshot": (),
|
||||||
"readRepositoryFile": ("repository", "ref", "path", "max_bytes"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_OPERATION_PATHS = {
|
BRIDGE_OPERATION_CONTRACTS = {
|
||||||
"createGovernedTask": "/v2/governed-task",
|
"projectOnlineAgentExecution": {
|
||||||
"acquireWriterLease": "/v2/writer-lease",
|
"method": "POST",
|
||||||
"commitFileGoverned": "/v2/commit-file",
|
"path": "/v2/execution-envelope",
|
||||||
"commitFilesGoverned": "/v2/commit-files",
|
"required_fields": OPERATION_REQUIRED_FIELDS["projectOnlineAgentExecution"],
|
||||||
"getGitOpsJob": "/v2/job",
|
"response_fields": (
|
||||||
"resumeGitOpsJob": "/v2/job/resume",
|
"schema", "task_id", "job_id", "repository", "base_oid",
|
||||||
"reconcileGitOpsJob": "/v2/job/reconcile",
|
"expected_branch_oid", "target_branch", "branch_mode", "generation",
|
||||||
"getTagPublicationStatus": "/v2/tag/status",
|
"idempotency_key", "action", "execution_digest",
|
||||||
"getReleaseConsistency": "/v2/release/consistency",
|
),
|
||||||
"runReleaseAcceptance": "/v2/release/acceptance",
|
},
|
||||||
"readRepositoryFile": "/v2/source/file",
|
"registerOnlineAgentExecutionPlan": {
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/v2/execution-plan",
|
||||||
|
"required_fields": OPERATION_REQUIRED_FIELDS["registerOnlineAgentExecutionPlan"],
|
||||||
|
"response_fields": (
|
||||||
|
"schema", "task_id", "job_id", "source_head_oid", "generation",
|
||||||
|
"idempotency_key", "plan_digest", "execution", "execution_digest",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"acquireWriterLease": {
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/v2/writer-lease",
|
||||||
|
"required_fields": OPERATION_REQUIRED_FIELDS["acquireWriterLease"],
|
||||||
|
"response_fields": (
|
||||||
|
"task_id", "job_id", "generation", "lease_seconds", "lease_expires_at",
|
||||||
|
"create_target_branch", "worktree_mode", "cas_ref_update_required",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"bootstrapWriterLease": {
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/v2/writer-lease/bootstrap",
|
||||||
|
"required_fields": OPERATION_REQUIRED_FIELDS["bootstrapWriterLease"],
|
||||||
|
"response_fields": (
|
||||||
|
"task_id", "job_id", "generation", "lease_seconds", "lease_expires_at",
|
||||||
|
"create_target_branch", "worktree_mode", "cas_ref_update_required",
|
||||||
|
"recovered", "bootstrapped", "runtime_rehydrated",
|
||||||
|
"durable_branch_lease_present", "idempotent",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"renewWriterLease": {
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/v2/writer-lease/renew",
|
||||||
|
"required_fields": OPERATION_REQUIRED_FIELDS["renewWriterLease"],
|
||||||
|
"response_fields": (
|
||||||
|
"task_id", "job_id", "generation", "lease_seconds", "lease_expires_at",
|
||||||
|
"recovered", "bootstrapped", "runtime_rehydrated",
|
||||||
|
"durable_branch_lease_present", "idempotent",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"commitFileGoverned": {
|
||||||
|
"method": "POST", "path": "/v2/commit-file",
|
||||||
|
"required_fields": OPERATION_REQUIRED_FIELDS["commitFileGoverned"],
|
||||||
|
"response_fields": ("job_id", "commit_oid", "commit_visible", "branch_mode", "target_branch", "generation"),
|
||||||
|
},
|
||||||
|
"commitFilesGoverned": {
|
||||||
|
"method": "POST", "path": "/v2/commit-files",
|
||||||
|
"required_fields": OPERATION_REQUIRED_FIELDS["commitFilesGoverned"],
|
||||||
|
"response_fields": ("job_id", "commit_oid", "commit_visible", "branch_mode", "target_branch", "generation"),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def operation_contracts() -> dict[str, dict[str, object]]:
|
|
||||||
"""Return the runtime contract consumed by adapters and schema tooling."""
|
|
||||||
return {
|
|
||||||
operation: {
|
|
||||||
"method": "POST",
|
|
||||||
"path": _OPERATION_PATHS.get(operation),
|
|
||||||
"required_fields": list(fields),
|
|
||||||
}
|
|
||||||
for operation, fields in OPERATION_REQUIRED_FIELDS.items()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def render_openapi_contract_fragment() -> str:
|
|
||||||
required = "\n".join(
|
|
||||||
f" - {field}"
|
|
||||||
for field in OPERATION_REQUIRED_FIELDS["commitFilesGoverned"]
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
" /v2/commit-files:\n"
|
|
||||||
" post:\n"
|
|
||||||
" operationId: commitFilesGoverned\n"
|
|
||||||
" summary: Submit one atomic governed commit containing multiple files\n"
|
|
||||||
" security: [{bearerAuth: []}]\n"
|
|
||||||
" requestBody:\n"
|
|
||||||
" required: true\n"
|
|
||||||
" content:\n"
|
|
||||||
" application/json:\n"
|
|
||||||
" schema:\n"
|
|
||||||
" type: object\n"
|
|
||||||
" additionalProperties: false\n"
|
|
||||||
" required:\n"
|
|
||||||
f"{required}\n"
|
|
||||||
" properties:\n"
|
|
||||||
" task_id: {type: string}\n"
|
|
||||||
" repository: {type: string, example: aios/aios}\n"
|
|
||||||
" expected_branch_oid: {type: string, pattern: '^[0-9a-f]{40}$'}\n"
|
|
||||||
" target_branch: {type: string, pattern: '^agent/[A-Za-z0-9._/-]+$'}\n"
|
|
||||||
" generation: {type: integer, minimum: 1}\n"
|
|
||||||
" idempotency_key: {type: string, minLength: 8, maxLength: 128, pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$'}\n"
|
|
||||||
" files:\n"
|
|
||||||
" type: array\n"
|
|
||||||
" minItems: 1\n"
|
|
||||||
" maxItems: 50\n"
|
|
||||||
" items:\n"
|
|
||||||
" type: object\n"
|
|
||||||
" additionalProperties: false\n"
|
|
||||||
" required: [path, content_base64]\n"
|
|
||||||
" properties:\n"
|
|
||||||
" path: {type: string, minLength: 1, maxLength: 512}\n"
|
|
||||||
" content_base64: {type: string}\n"
|
|
||||||
" message: {type: string, minLength: 1, maxLength: 200}\n"
|
|
||||||
" responses:\n"
|
|
||||||
" '200': {description: Governed commit job and verified branch result}\n"
|
|
||||||
" '400': {description: Contract or policy rejection}\n"
|
|
||||||
" '409': {description: Branch CAS or idempotency conflict}\n"
|
|
||||||
" x-openai-isConsequential: true\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class RuntimeDriftError(RuntimeError):
|
class RuntimeDriftError(RuntimeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -197,7 +195,14 @@ class BridgeV2Adapter:
|
|||||||
"legacy_writes_enabled": False,
|
"legacy_writes_enabled": False,
|
||||||
"governed_writes_enabled": True,
|
"governed_writes_enabled": True,
|
||||||
"operations": list(BRIDGE_OPERATIONS),
|
"operations": list(BRIDGE_OPERATIONS),
|
||||||
"operation_contracts": operation_contracts(),
|
"operation_contracts": {
|
||||||
|
operation: {
|
||||||
|
**contract,
|
||||||
|
"required_fields": list(contract["required_fields"]),
|
||||||
|
"response_fields": list(contract["response_fields"]),
|
||||||
|
}
|
||||||
|
for operation, contract in BRIDGE_OPERATION_CONTRACTS.items()
|
||||||
|
},
|
||||||
"force_push": False,
|
"force_push": False,
|
||||||
"main_write": False,
|
"main_write": False,
|
||||||
"tag_write": False,
|
"tag_write": False,
|
||||||
@@ -226,10 +231,13 @@ class BridgeV2Adapter:
|
|||||||
return handler(dict(request))
|
return handler(dict(request))
|
||||||
|
|
||||||
def createGovernedTask(self, request: Mapping[str, Any]) -> Any: return self._call("createGovernedTask", request)
|
def createGovernedTask(self, request: Mapping[str, Any]) -> Any: return self._call("createGovernedTask", request)
|
||||||
|
def projectOnlineAgentExecution(self, request: Mapping[str, Any]) -> Any: return self._call("projectOnlineAgentExecution", request)
|
||||||
|
def registerOnlineAgentExecutionPlan(self, request: Mapping[str, Any]) -> Any: return self._call("registerOnlineAgentExecutionPlan", request)
|
||||||
def acquireWriterLease(self, request: Mapping[str, Any]) -> Any: return self._call("acquireWriterLease", request)
|
def acquireWriterLease(self, request: Mapping[str, Any]) -> Any: return self._call("acquireWriterLease", request)
|
||||||
|
def bootstrapWriterLease(self, request: Mapping[str, Any]) -> Any: return self._call("bootstrapWriterLease", request)
|
||||||
|
def renewWriterLease(self, request: Mapping[str, Any]) -> Any: return self._call("renewWriterLease", request)
|
||||||
def commitFileGoverned(self, request: Mapping[str, Any]) -> Any: return self._call("commitFileGoverned", request)
|
def commitFileGoverned(self, request: Mapping[str, Any]) -> Any: return self._call("commitFileGoverned", request)
|
||||||
def commitFilesGoverned(self, request: Mapping[str, Any]) -> Any: return self._call("commitFilesGoverned", request)
|
def commitFilesGoverned(self, request: Mapping[str, Any]) -> Any: return self._call("commitFilesGoverned", request)
|
||||||
def readRepositoryFile(self, request: Mapping[str, Any]) -> Any: return self._call("readRepositoryFile", request)
|
|
||||||
def getGitOpsJob(self, request: Mapping[str, Any]) -> Any: return self._call("getGitOpsJob", request)
|
def getGitOpsJob(self, request: Mapping[str, Any]) -> Any: return self._call("getGitOpsJob", request)
|
||||||
def resumeGitOpsJob(self, request: Mapping[str, Any]) -> Any: return self._call("resumeGitOpsJob", request)
|
def resumeGitOpsJob(self, request: Mapping[str, Any]) -> Any: return self._call("resumeGitOpsJob", request)
|
||||||
def reconcileGitOpsJob(self, request: Mapping[str, Any]) -> Any: return self._call("reconcileGitOpsJob", request)
|
def reconcileGitOpsJob(self, request: Mapping[str, Any]) -> Any: return self._call("reconcileGitOpsJob", request)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -51,12 +51,14 @@ class AuthorityPort(Protocol):
|
|||||||
def branch_exists(self, branch: str) -> bool: ...
|
def branch_exists(self, branch: str) -> bool: ...
|
||||||
def branch_head(self, branch: str) -> str: ...
|
def branch_head(self, branch: str) -> str: ...
|
||||||
def is_ancestor(self, older: str, newer: str) -> bool: ...
|
def is_ancestor(self, older: str, newer: str) -> bool: ...
|
||||||
|
def create_task_branch_cas(self, branch: str, expected_base: str, new: str) -> str: ...
|
||||||
def compare_and_swap_branch(self, branch: str, expected: str, new: str) -> str: ...
|
def compare_and_swap_branch(self, branch: str, expected: str, new: str) -> str: ...
|
||||||
|
|
||||||
|
|
||||||
class RemotePort(Protocol):
|
class RemotePort(Protocol):
|
||||||
def repository_identity(self) -> str: ...
|
def repository_identity(self) -> str: ...
|
||||||
def push_exact(self, branch: str, expected: str, new: str, *, allow_absent: bool = False) -> None: ...
|
def push_new_exact(self, branch: str, new: str) -> None: ...
|
||||||
|
def push_exact(self, branch: str, expected: str, new: str) -> None: ...
|
||||||
def read_git_ref(self, branch: str) -> str | None: ...
|
def read_git_ref(self, branch: str) -> str | None: ...
|
||||||
def read_api_branch(self, branch: str) -> str | None: ...
|
def read_api_branch(self, branch: str) -> str | None: ...
|
||||||
def commit_visible(self, oid: str) -> bool: ...
|
def commit_visible(self, oid: str) -> bool: ...
|
||||||
@@ -103,6 +105,9 @@ class AuthorityGitAdapter:
|
|||||||
def is_ancestor(self, older: str, newer: str) -> bool:
|
def is_ancestor(self, older: str, newer: str) -> bool:
|
||||||
return self.executor.ancestry_check(older, newer)
|
return self.executor.ancestry_check(older, newer)
|
||||||
|
|
||||||
|
def create_task_branch_cas(self, branch: str, expected_base: str, new: str) -> str:
|
||||||
|
return self.executor.create_task_branch_CAS(self._ref(branch), expected_base, new)
|
||||||
|
|
||||||
def compare_and_swap_branch(self, branch: str, expected: str, new: str) -> str:
|
def compare_and_swap_branch(self, branch: str, expected: str, new: str) -> str:
|
||||||
return self.executor.advance_task_branch_CAS(self._ref(branch), expected, new)
|
return self.executor.advance_task_branch_CAS(self._ref(branch), expected, new)
|
||||||
|
|
||||||
@@ -152,7 +157,7 @@ class DetachedGitWorkspace:
|
|||||||
def materialize_files(self, files: Mapping[str, bytes]) -> None:
|
def materialize_files(self, files: Mapping[str, bytes]) -> None:
|
||||||
dirty = self._dirty_paths()
|
dirty = self._dirty_paths()
|
||||||
expected_paths = set(files)
|
expected_paths = set(files)
|
||||||
if dirty and dirty != expected_paths:
|
if dirty and not dirty.issubset(expected_paths):
|
||||||
raise ContinuationExecutionError("WORKTREE_DIRTY_SCOPE_MISMATCH")
|
raise ContinuationExecutionError("WORKTREE_DIRTY_SCOPE_MISMATCH")
|
||||||
for relative, content in files.items():
|
for relative, content in files.items():
|
||||||
target = (self.path / relative).resolve()
|
target = (self.path / relative).resolve()
|
||||||
@@ -258,11 +263,19 @@ class GiteaContinuationRemote:
|
|||||||
raise ContinuationExecutionError("GITEA_GIT_READBACK_INVALID")
|
raise ContinuationExecutionError("GITEA_GIT_READBACK_INVALID")
|
||||||
return oid
|
return oid
|
||||||
|
|
||||||
def push_exact(self, branch: str, expected: str, new: str, *, allow_absent: bool = False) -> None:
|
def push_new_exact(self, branch: str, new: str) -> None:
|
||||||
self._require_projected()
|
self._require_projected()
|
||||||
self.repository_identity()
|
self.repository_identity()
|
||||||
current = self.read_git_ref(branch)
|
if self.read_git_ref(branch) is not None:
|
||||||
if current != expected and not (allow_absent and current is None):
|
raise ContinuationExecutionError("GITEA_TARGET_BRANCH_ALREADY_EXISTS")
|
||||||
|
_run(["git", "push", self.remote_name, f"{new}:refs/heads/{branch}"], cwd=self.workspace)
|
||||||
|
if self.read_git_ref(branch) != new:
|
||||||
|
raise ContinuationExecutionError("GITEA_CREATE_READBACK_MISMATCH")
|
||||||
|
|
||||||
|
def push_exact(self, branch: str, expected: str, new: str) -> None:
|
||||||
|
self._require_projected()
|
||||||
|
self.repository_identity()
|
||||||
|
if self.read_git_ref(branch) != expected:
|
||||||
raise ContinuationExecutionError("GITEA_BRANCH_EXPECTATION_MISMATCH")
|
raise ContinuationExecutionError("GITEA_BRANCH_EXPECTATION_MISMATCH")
|
||||||
_run(["git", "push", self.remote_name, f"{new}:refs/heads/{branch}"], cwd=self.workspace)
|
_run(["git", "push", self.remote_name, f"{new}:refs/heads/{branch}"], cwd=self.workspace)
|
||||||
|
|
||||||
@@ -330,13 +343,13 @@ class ContinuationPermissions:
|
|||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class ContinuationRuntimeBinding:
|
class ContinuationRuntimeBinding:
|
||||||
request: WriterLeaseRequestV2
|
request: WriterLeaseRequestV2
|
||||||
|
job_id: str
|
||||||
workspace_path: Path
|
workspace_path: Path
|
||||||
lease_not_before: datetime
|
lease_not_before: datetime
|
||||||
lease_expires_at: datetime
|
lease_expires_at: datetime
|
||||||
original_receipt_valid: bool
|
original_receipt_valid: bool
|
||||||
original_grant_consumed: bool
|
original_grant_consumed: bool
|
||||||
permissions: ContinuationPermissions
|
permissions: ContinuationPermissions
|
||||||
remote_branch_create_allowed: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -391,13 +404,20 @@ class ContinuationExecutor:
|
|||||||
def observe_generation(self, branch: str, generation: int) -> None:
|
def observe_generation(self, branch: str, generation: int) -> None:
|
||||||
self._observed_generations[branch] = max(generation, self._observed_generations.get(branch, 0))
|
self._observed_generations[branch] = max(generation, self._observed_generations.get(branch, 0))
|
||||||
|
|
||||||
def _max_generation(self, branch: str) -> int:
|
def _max_generation(self, branch: str, *, exclude_job_id: str | None = None) -> int:
|
||||||
observed = self._observed_generations.get(branch, 0)
|
observed = self._observed_generations.get(branch, 0)
|
||||||
with self.controller.store._connect() as connection:
|
with self.controller.store._connect() as connection:
|
||||||
row = connection.execute(
|
if exclude_job_id is None:
|
||||||
"SELECT MAX(generation) AS generation FROM branch_leases WHERE branch=?",
|
row = connection.execute(
|
||||||
(branch,),
|
"SELECT MAX(generation) AS generation FROM branch_leases WHERE branch=?",
|
||||||
).fetchone()
|
(branch,),
|
||||||
|
).fetchone()
|
||||||
|
else:
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT MAX(generation) AS generation FROM branch_leases "
|
||||||
|
"WHERE branch=? AND job_id!=?",
|
||||||
|
(branch, exclude_job_id),
|
||||||
|
).fetchone()
|
||||||
durable = int(row["generation"] or 0)
|
durable = int(row["generation"] or 0)
|
||||||
return max(observed, durable)
|
return max(observed, durable)
|
||||||
|
|
||||||
@@ -406,6 +426,7 @@ class ContinuationExecutor:
|
|||||||
return {
|
return {
|
||||||
"schema": WriterLeaseRequestV2.schema,
|
"schema": WriterLeaseRequestV2.schema,
|
||||||
"task_id": request.task_id,
|
"task_id": request.task_id,
|
||||||
|
"job_id": request.job_id,
|
||||||
"repository": request.repository,
|
"repository": request.repository,
|
||||||
"branch_mode": request.branch_mode,
|
"branch_mode": request.branch_mode,
|
||||||
"base_oid": request.base_oid,
|
"base_oid": request.base_oid,
|
||||||
@@ -415,10 +436,21 @@ class ContinuationExecutor:
|
|||||||
"lease_seconds": request.lease_seconds,
|
"lease_seconds": request.lease_seconds,
|
||||||
}
|
}
|
||||||
|
|
||||||
def _validate_preflight(self, binding: ContinuationRuntimeBinding) -> None:
|
def _validate_preflight(self, binding: ContinuationRuntimeBinding, *, job_id: str | None = None) -> None:
|
||||||
request = binding.request
|
request = binding.request
|
||||||
normalized = validate_writer_lease_request_v2(self._request_mapping(request), self.authority)
|
request_mapping = self._request_mapping(request)
|
||||||
if normalized != {**self._request_mapping(request), "create_target_branch": False, "worktree_mode": "detached_at_expected_head", "cas_ref_update_required": True}:
|
normalized = validate_writer_lease_request_v2(request_mapping, self.authority)
|
||||||
|
expected_normalized = {
|
||||||
|
**request_mapping,
|
||||||
|
"create_target_branch": request.branch_mode == "CREATE_NEW",
|
||||||
|
"worktree_mode": (
|
||||||
|
"detached_at_base_oid"
|
||||||
|
if request.branch_mode == "CREATE_NEW"
|
||||||
|
else "detached_at_expected_head"
|
||||||
|
),
|
||||||
|
"cas_ref_update_required": True,
|
||||||
|
}
|
||||||
|
if normalized != expected_normalized:
|
||||||
raise ContinuationExecutionError("CONTINUATION_NORMALIZATION_INVALID")
|
raise ContinuationExecutionError("CONTINUATION_NORMALIZATION_INVALID")
|
||||||
if not binding.original_receipt_valid or not binding.original_grant_consumed:
|
if not binding.original_receipt_valid or not binding.original_grant_consumed:
|
||||||
raise ContinuationExecutionError("ORIGINAL_GRANT_LINEAGE_INVALID")
|
raise ContinuationExecutionError("ORIGINAL_GRANT_LINEAGE_INVALID")
|
||||||
@@ -428,7 +460,7 @@ class ContinuationExecutor:
|
|||||||
raise ContinuationExecutionError("LEASE_NOT_ACTIVE")
|
raise ContinuationExecutionError("LEASE_NOT_ACTIVE")
|
||||||
if now >= binding.lease_expires_at:
|
if now >= binding.lease_expires_at:
|
||||||
raise ContinuationExecutionError("LEASE_EXPIRED")
|
raise ContinuationExecutionError("LEASE_EXPIRED")
|
||||||
if request.generation <= self._max_generation(request.target_branch):
|
if request.generation <= self._max_generation(request.target_branch, exclude_job_id=job_id):
|
||||||
raise ContinuationExecutionError("GENERATION_NOT_MONOTONIC")
|
raise ContinuationExecutionError("GENERATION_NOT_MONOTONIC")
|
||||||
if not self.workspace.is_detached():
|
if not self.workspace.is_detached():
|
||||||
raise ContinuationExecutionError("WORKTREE_NOT_DETACHED")
|
raise ContinuationExecutionError("WORKTREE_NOT_DETACHED")
|
||||||
@@ -452,60 +484,129 @@ class ContinuationExecutor:
|
|||||||
"payload_digest": hashlib.sha256(payload).hexdigest(),
|
"payload_digest": hashlib.sha256(payload).hexdigest(),
|
||||||
"lineage_anchor_oid": request.base_oid,
|
"lineage_anchor_oid": request.base_oid,
|
||||||
"generation": request.generation,
|
"generation": request.generation,
|
||||||
"remote_branch_create_allowed": binding.remote_branch_create_allowed,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def execute(self, binding: ContinuationRuntimeBinding, commit: ContinuationCommit) -> dict[str, object]:
|
def execute(
|
||||||
payload = commit.payload()
|
self,
|
||||||
submitted = self.controller.submit(self._job_request(binding, commit, payload), payload=payload)
|
binding: ContinuationRuntimeBinding,
|
||||||
current = self.controller.store.read(submitted.job_id)
|
commit: ContinuationCommit,
|
||||||
if JobState(current["state"]) == JobState.COMPLETED:
|
*,
|
||||||
return self.controller.store._receipt(current, duplicate=True).as_dict()
|
before_complete: Callable[[str], None] | None = None,
|
||||||
self._validate_preflight(binding)
|
) -> dict[str, object]:
|
||||||
|
commit.payload()
|
||||||
|
if not binding.job_id or binding.request.job_id != binding.job_id:
|
||||||
|
raise ContinuationExecutionError("RUNTIME_LINEAGE_MISSING_JOB_ID")
|
||||||
|
current = self.controller.store.read(binding.job_id)
|
||||||
|
current_state = JobState(current["state"])
|
||||||
|
if current_state == JobState.COMPLETED:
|
||||||
|
cycle = self.controller.begin_continuation_cycle(
|
||||||
|
binding.job_id,
|
||||||
|
idempotency_key=commit.idempotency_key,
|
||||||
|
expected_head_oid=binding.request.expected_branch_oid,
|
||||||
|
generation=binding.request.generation,
|
||||||
|
expected_revision=int(current["revision"]),
|
||||||
|
)
|
||||||
|
if cycle.get("completed"):
|
||||||
|
return self.controller.store._receipt(
|
||||||
|
self.controller.store.read(binding.job_id), duplicate=True
|
||||||
|
).as_dict()
|
||||||
|
current = self.controller.store.read(binding.job_id)
|
||||||
|
current_state = JobState(current["state"])
|
||||||
|
if current_state == JobState.REMOTE_VERIFIED:
|
||||||
|
receipt = json.loads(str(current["receipt_json"] or "{}"))
|
||||||
|
commit_oid = str(receipt.get("authority_oid") or "")
|
||||||
|
if not OID_RE.fullmatch(commit_oid):
|
||||||
|
raise ContinuationExecutionError("GOVERNED_COMMIT_OID_INVALID")
|
||||||
|
if before_complete is not None:
|
||||||
|
before_complete(commit_oid)
|
||||||
|
result = self.controller.advance(
|
||||||
|
binding.job_id,
|
||||||
|
JobState.COMPLETED,
|
||||||
|
side_effect_state="REMOTE_VERIFIED",
|
||||||
|
receipt=receipt,
|
||||||
|
).as_dict()
|
||||||
|
self.controller.complete_continuation_cycle(
|
||||||
|
binding.job_id,
|
||||||
|
idempotency_key=commit.idempotency_key,
|
||||||
|
commit_oid=commit_oid,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
self._validate_preflight(binding, job_id=binding.job_id)
|
||||||
request = binding.request
|
request = binding.request
|
||||||
projected = False
|
projected = False
|
||||||
try:
|
try:
|
||||||
self.credential.project()
|
self.credential.project()
|
||||||
projected = True
|
projected = True
|
||||||
row = self.controller.store.read(submitted.job_id)
|
row = self.controller.store.read(binding.job_id)
|
||||||
if JobState(row["state"]) == JobState.REQUESTED:
|
state = JobState(row["state"])
|
||||||
|
if state == JobState.REQUESTED:
|
||||||
self.controller.advance(
|
self.controller.advance(
|
||||||
submitted.job_id,
|
binding.job_id,
|
||||||
JobState.AUTHORIZED,
|
JobState.AUTHORIZED,
|
||||||
side_effect_state="AUTHORIZED",
|
side_effect_state="AUTHORIZED",
|
||||||
receipt={"task_id": request.task_id, "original_grant_consumed": True},
|
receipt={"task_id": request.task_id, "original_grant_consumed": True},
|
||||||
|
expected_revision=int(row["revision"]),
|
||||||
)
|
)
|
||||||
row = self.controller.store.read(submitted.job_id)
|
row = self.controller.store.read(binding.job_id)
|
||||||
if JobState(row["state"]) == JobState.AUTHORIZED:
|
state = JobState(row["state"])
|
||||||
|
if state == JobState.AUTHORIZED:
|
||||||
self.controller.lease_job(
|
self.controller.lease_job(
|
||||||
submitted.job_id,
|
binding.job_id,
|
||||||
lease_id=f"lease-{submitted.job_id}",
|
lease_id=f"lease-{binding.job_id}",
|
||||||
lineage_anchor_oid=request.base_oid,
|
lineage_anchor_oid=request.base_oid,
|
||||||
current_head_oid=request.expected_branch_oid,
|
current_head_oid=request.expected_branch_oid,
|
||||||
generation=request.generation,
|
generation=request.generation,
|
||||||
lease_expires_at=binding.lease_expires_at.astimezone(timezone.utc).isoformat(),
|
lease_expires_at=binding.lease_expires_at.astimezone(timezone.utc).isoformat(),
|
||||||
expected_revision=int(row["revision"]),
|
expected_revision=int(row["revision"]),
|
||||||
)
|
)
|
||||||
self.controller.advance(submitted.job_id, JobState.WORKTREE_READY, side_effect_state="WORKTREE_READY")
|
row = self.controller.store.read(binding.job_id)
|
||||||
self.controller.advance(submitted.job_id, JobState.EXECUTING, side_effect_state="IN_PROGRESS")
|
state = JobState(row["state"])
|
||||||
|
if state == JobState.LEASED:
|
||||||
|
self.controller.advance(binding.job_id, JobState.WORKTREE_READY,
|
||||||
|
side_effect_state="WORKTREE_READY", expected_revision=int(row["revision"]))
|
||||||
|
row = self.controller.store.read(binding.job_id)
|
||||||
|
state = JobState(row["state"])
|
||||||
|
if state == JobState.WORKTREE_READY:
|
||||||
|
self.controller.advance(binding.job_id, JobState.EXECUTING,
|
||||||
|
side_effect_state="IN_PROGRESS", expected_revision=int(row["revision"]))
|
||||||
|
elif state != JobState.EXECUTING:
|
||||||
|
raise ContinuationExecutionError("JOB_EXECUTION_STATE_INVALID")
|
||||||
new_oid = self.workspace.commit_files(commit.files, commit.message)
|
new_oid = self.workspace.commit_files(commit.files, commit.message)
|
||||||
if not OID_RE.fullmatch(new_oid) or not self.authority.is_ancestor(request.expected_branch_oid, new_oid):
|
if not OID_RE.fullmatch(new_oid) or not self.authority.is_ancestor(request.expected_branch_oid, new_oid):
|
||||||
raise ContinuationExecutionError("NON_FAST_FORWARD_COMMIT")
|
raise ContinuationExecutionError("NON_FAST_FORWARD_COMMIT")
|
||||||
authority_oid = self.authority.compare_and_swap_branch(request.target_branch, request.expected_branch_oid, new_oid)
|
if request.branch_mode == "CREATE_NEW":
|
||||||
|
authority_oid = self.authority.create_task_branch_cas(request.target_branch, request.base_oid, new_oid)
|
||||||
|
else:
|
||||||
|
authority_oid = self.authority.compare_and_swap_branch(request.target_branch, request.expected_branch_oid, new_oid)
|
||||||
if authority_oid != new_oid:
|
if authority_oid != new_oid:
|
||||||
raise ContinuationExecutionError("AUTHORITY_CAS_READBACK_MISMATCH")
|
raise ContinuationExecutionError("AUTHORITY_CAS_READBACK_MISMATCH")
|
||||||
self.controller.advance(
|
self.controller.advance(
|
||||||
submitted.job_id,
|
binding.job_id,
|
||||||
JobState.LOCAL_VERIFIED,
|
JobState.LOCAL_VERIFIED,
|
||||||
side_effect_state="LOCAL_VERIFIED",
|
side_effect_state="LOCAL_VERIFIED",
|
||||||
receipt={"authority_oid": authority_oid, "force_push": False},
|
receipt={"authority_oid": authority_oid, "force_push": False},
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
self.controller.advance(submitted.job_id, JobState.REMOTE_SYNCING, side_effect_state="IN_PROGRESS")
|
self.controller.advance(binding.job_id, JobState.REMOTE_SYNCING, side_effect_state="IN_PROGRESS")
|
||||||
self.remote.push_exact(request.target_branch, request.expected_branch_oid, new_oid, allow_absent=binding.remote_branch_create_allowed)
|
if request.branch_mode == "CREATE_NEW":
|
||||||
|
self.remote.push_new_exact(request.target_branch, new_oid)
|
||||||
|
else:
|
||||||
|
self.remote.push_exact(request.target_branch, request.expected_branch_oid, new_oid)
|
||||||
except Exception:
|
except Exception:
|
||||||
return self._remote_pending(submitted.job_id, authority_oid, new_oid)
|
return self._remote_pending(binding.job_id, authority_oid, new_oid)
|
||||||
return self._finish_remote_verification(submitted.job_id, authority_oid, new_oid)
|
result = self._finish_remote_verification(
|
||||||
|
binding.job_id,
|
||||||
|
authority_oid,
|
||||||
|
new_oid,
|
||||||
|
before_complete=before_complete,
|
||||||
|
)
|
||||||
|
if str(result.get("state") or "") == JobState.COMPLETED.value:
|
||||||
|
self.controller.complete_continuation_cycle(
|
||||||
|
binding.job_id,
|
||||||
|
idempotency_key=commit.idempotency_key,
|
||||||
|
commit_oid=new_oid,
|
||||||
|
)
|
||||||
|
return result
|
||||||
finally:
|
finally:
|
||||||
if projected:
|
if projected:
|
||||||
self.credential.revoke()
|
self.credential.revoke()
|
||||||
@@ -528,7 +629,14 @@ class ContinuationExecutor:
|
|||||||
)
|
)
|
||||||
return result.as_dict()
|
return result.as_dict()
|
||||||
|
|
||||||
def _finish_remote_verification(self, job_id: str, authority_oid: str, new_oid: str) -> dict[str, object]:
|
def _finish_remote_verification(
|
||||||
|
self,
|
||||||
|
job_id: str,
|
||||||
|
authority_oid: str,
|
||||||
|
new_oid: str,
|
||||||
|
*,
|
||||||
|
before_complete: Callable[[str], None] | None = None,
|
||||||
|
) -> dict[str, object]:
|
||||||
row = self.controller.store.read(job_id)
|
row = self.controller.store.read(job_id)
|
||||||
branch = row["branch"]
|
branch = row["branch"]
|
||||||
request = json.loads(row["request_json"])["binding"]
|
request = json.loads(row["request_json"])["binding"]
|
||||||
@@ -545,8 +653,20 @@ class ContinuationExecutor:
|
|||||||
"reconciliation_decision": decision.action.value,
|
"reconciliation_decision": decision.action.value,
|
||||||
"force_push": False,
|
"force_push": False,
|
||||||
}
|
}
|
||||||
self.controller.advance(job_id, JobState.REMOTE_VERIFIED, side_effect_state="REMOTE_VERIFIED", receipt=receipt)
|
self.controller.advance(
|
||||||
return self.controller.advance(job_id, JobState.COMPLETED, side_effect_state="REMOTE_VERIFIED", receipt=receipt).as_dict()
|
job_id,
|
||||||
|
JobState.REMOTE_VERIFIED,
|
||||||
|
side_effect_state="REMOTE_VERIFIED",
|
||||||
|
receipt=receipt,
|
||||||
|
)
|
||||||
|
if before_complete is not None:
|
||||||
|
before_complete(new_oid)
|
||||||
|
return self.controller.advance(
|
||||||
|
job_id,
|
||||||
|
JobState.COMPLETED,
|
||||||
|
side_effect_state="REMOTE_VERIFIED",
|
||||||
|
receipt=receipt,
|
||||||
|
).as_dict()
|
||||||
|
|
||||||
def reconcile(self, job_id: str) -> dict[str, object]:
|
def reconcile(self, job_id: str) -> dict[str, object]:
|
||||||
row = self.controller.store.read(job_id)
|
row = self.controller.store.read(job_id)
|
||||||
@@ -586,14 +706,11 @@ class ContinuationExecutor:
|
|||||||
and self.authority.is_ancestor(git_oid, new_oid)
|
and self.authority.is_ancestor(git_oid, new_oid)
|
||||||
):
|
):
|
||||||
self.controller.advance(job_id, JobState.RECONCILING, side_effect_state="OBSERVATION_FIRST_CHAIN")
|
self.controller.advance(job_id, JobState.RECONCILING, side_effect_state="OBSERVATION_FIRST_CHAIN")
|
||||||
self.remote.push_exact(row["branch"], git_oid, new_oid, allow_absent=bool(request.get("remote_branch_create_allowed")) or request.get("branch_mode") == "CREATE_NEW")
|
self.remote.push_exact(row["branch"], git_oid, new_oid)
|
||||||
return self._finish_remote_verification(job_id, new_oid, new_oid)
|
return self._finish_remote_verification(job_id, new_oid, new_oid)
|
||||||
allow_remote_create = bool(request.get("remote_branch_create_allowed")) or request.get("branch_mode") == "CREATE_NEW" or (git_oid is None and api_oid is None)
|
if authority_oid == new_oid and git_oid == api_oid == expected_oid:
|
||||||
remote_matches_expected = git_oid == api_oid == expected_oid
|
|
||||||
remote_absent_for_create = git_oid is None and api_oid is None
|
|
||||||
if authority_oid == new_oid and (remote_matches_expected or remote_absent_for_create):
|
|
||||||
self.controller.advance(job_id, JobState.RECONCILING, side_effect_state="OBSERVATION_FIRST_RETRY")
|
self.controller.advance(job_id, JobState.RECONCILING, side_effect_state="OBSERVATION_FIRST_RETRY")
|
||||||
self.remote.push_exact(row["branch"], expected_oid, new_oid, allow_absent=allow_remote_create)
|
self.remote.push_exact(row["branch"], expected_oid, new_oid)
|
||||||
return self._finish_remote_verification(job_id, new_oid, new_oid)
|
return self._finish_remote_verification(job_id, new_oid, new_oid)
|
||||||
return self.controller.store._receipt(row, duplicate=True).as_dict()
|
return self.controller.store._receipt(row, duplicate=True).as_dict()
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ class WriterLeaseRequestV2:
|
|||||||
target_branch: str
|
target_branch: str
|
||||||
generation: int
|
generation: int
|
||||||
lease_seconds: int = 900
|
lease_seconds: int = 900
|
||||||
|
job_id: str = ""
|
||||||
|
|
||||||
schema = "aios.task-writer-lease.v2"
|
schema = "aios.task-writer-lease.v2"
|
||||||
accepted_schemas = frozenset({"aios.task-writer-lease.v2", "aios.task-writer-lease-request.v2"})
|
accepted_schemas = frozenset({"aios.task-writer-lease.v2", "aios.task-writer-lease-request.v2"})
|
||||||
@@ -56,6 +57,8 @@ class WriterLeaseRequestV2:
|
|||||||
def validate(self, probe: BranchProbe) -> None:
|
def validate(self, probe: BranchProbe) -> None:
|
||||||
if not self.task_id or self.repository != "aios/aios":
|
if not self.task_id or self.repository != "aios/aios":
|
||||||
raise ContinuationError("request_identity_invalid")
|
raise ContinuationError("request_identity_invalid")
|
||||||
|
if not self.job_id or not self.job_id.startswith("job-"):
|
||||||
|
raise ContinuationError("job_id_invalid")
|
||||||
if self.branch_mode not in {"CREATE_NEW", "CONTINUE_EXISTING"}:
|
if self.branch_mode not in {"CREATE_NEW", "CONTINUE_EXISTING"}:
|
||||||
raise ContinuationError("branch_mode_invalid")
|
raise ContinuationError("branch_mode_invalid")
|
||||||
_oid(self.base_oid, "base_oid")
|
_oid(self.base_oid, "base_oid")
|
||||||
|
|||||||
@@ -27,6 +27,19 @@ class ServiceUnavailable(JobError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class LeaseRenewalResult:
|
||||||
|
"""Durable outcome of one writer-lease recovery attempt."""
|
||||||
|
|
||||||
|
renewed: bool
|
||||||
|
idempotent: bool
|
||||||
|
durable: bool
|
||||||
|
lease_expires_at: str | None
|
||||||
|
|
||||||
|
def __bool__(self) -> bool:
|
||||||
|
return self.renewed or self.idempotent
|
||||||
|
|
||||||
|
|
||||||
class BindingConflict(JobError):
|
class BindingConflict(JobError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -41,8 +54,6 @@ class JobState(str, Enum):
|
|||||||
REMOTE_SYNCING = "REMOTE_SYNCING"
|
REMOTE_SYNCING = "REMOTE_SYNCING"
|
||||||
REMOTE_VERIFIED = "REMOTE_VERIFIED"
|
REMOTE_VERIFIED = "REMOTE_VERIFIED"
|
||||||
COMPLETED = "COMPLETED"
|
COMPLETED = "COMPLETED"
|
||||||
FAILED = "FAILED"
|
|
||||||
CANCELLED = "CANCELLED"
|
|
||||||
RECOVERABLE_FAILURE = "RECOVERABLE_FAILURE"
|
RECOVERABLE_FAILURE = "RECOVERABLE_FAILURE"
|
||||||
RECONCILING = "RECONCILING"
|
RECONCILING = "RECONCILING"
|
||||||
RECONCILIATION_REQUIRED = "RECONCILIATION_REQUIRED"
|
RECONCILIATION_REQUIRED = "RECONCILIATION_REQUIRED"
|
||||||
@@ -61,18 +72,21 @@ _ORDER = {
|
|||||||
JobState.COMPLETED: 8,
|
JobState.COMPLETED: 8,
|
||||||
}
|
}
|
||||||
_RECOVERABLE = {JobState.RECOVERABLE_FAILURE, JobState.RECONCILIATION_REQUIRED}
|
_RECOVERABLE = {JobState.RECOVERABLE_FAILURE, JobState.RECONCILIATION_REQUIRED}
|
||||||
_TERMINAL = {
|
_TERMINAL = {JobState.COMPLETED, JobState.MANUAL_OWNER_REVIEW_REQUIRED}
|
||||||
JobState.COMPLETED,
|
|
||||||
JobState.FAILED,
|
|
||||||
JobState.CANCELLED,
|
|
||||||
JobState.MANUAL_OWNER_REVIEW_REQUIRED,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def utc_now() -> str:
|
def utc_now() -> str:
|
||||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_utc(value: str) -> datetime:
|
||||||
|
normalized = value[:-1] + "+00:00" if value.endswith("Z") else value
|
||||||
|
parsed = datetime.fromisoformat(normalized)
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
raise ValueError("LEASE_EXPIRY_INVALID")
|
||||||
|
return parsed.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
def canonical_json(value: Any) -> bytes:
|
def canonical_json(value: Any) -> bytes:
|
||||||
return (
|
return (
|
||||||
json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + "\n"
|
json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + "\n"
|
||||||
@@ -216,7 +230,15 @@ class JobStore:
|
|||||||
kind TEXT NOT NULL, digest TEXT NOT NULL, payload_json TEXT NOT NULL,
|
kind TEXT NOT NULL, digest TEXT NOT NULL, payload_json TEXT NOT NULL,
|
||||||
created_at TEXT NOT NULL, FOREIGN KEY(job_id) REFERENCES jobs(job_id)
|
created_at TEXT NOT NULL, FOREIGN KEY(job_id) REFERENCES jobs(job_id)
|
||||||
);
|
);
|
||||||
INSERT OR REPLACE INTO metadata(key, value) VALUES('schema_version', '1');
|
CREATE TABLE IF NOT EXISTS continuation_cycles (
|
||||||
|
job_id TEXT NOT NULL, idempotency_key TEXT NOT NULL,
|
||||||
|
sequence INTEGER NOT NULL, expected_head_oid TEXT NOT NULL,
|
||||||
|
generation INTEGER NOT NULL, state TEXT NOT NULL,
|
||||||
|
commit_oid TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY(job_id,idempotency_key), UNIQUE(job_id,sequence),
|
||||||
|
FOREIGN KEY(job_id) REFERENCES jobs(job_id)
|
||||||
|
);
|
||||||
|
INSERT OR REPLACE INTO metadata(key, value) VALUES('schema_version', '2');
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
except sqlite3.Error as exc:
|
except sqlite3.Error as exc:
|
||||||
@@ -411,13 +433,16 @@ class GitOpsJobController:
|
|||||||
self.store._mirror(result)
|
self.store._mirror(result)
|
||||||
return self.store._receipt(result, duplicate=True)
|
return self.store._receipt(result, duplicate=True)
|
||||||
|
|
||||||
def update_lease(self, lease_id: str, *, expected_revision: int, current_head_oid: str, generation: int, state: str = "ACTIVE") -> None:
|
def update_lease(self, lease_id: str, *, expected_revision: int, current_head_oid: str, generation: int, state: str = "ACTIVE", lease_expires_at: str | None = None) -> None:
|
||||||
if len(current_head_oid) != 40 or generation < 1:
|
if len(current_head_oid) != 40 or generation < 1:
|
||||||
raise JobError("LEASE_BINDING_INVALID")
|
raise JobError("LEASE_BINDING_INVALID")
|
||||||
try:
|
try:
|
||||||
with self.store._connect() as connection:
|
with self.store._connect() as connection:
|
||||||
connection.execute("BEGIN IMMEDIATE")
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
updated = connection.execute("UPDATE branch_leases SET current_head_oid=?,generation=?,state=?,revision=revision+1 WHERE lease_id=? AND revision=?", (current_head_oid, generation, state, lease_id, expected_revision))
|
if lease_expires_at is None:
|
||||||
|
updated = connection.execute("UPDATE branch_leases SET current_head_oid=?,generation=?,state=?,revision=revision+1 WHERE lease_id=? AND revision=?", (current_head_oid, generation, state, lease_id, expected_revision))
|
||||||
|
else:
|
||||||
|
updated = connection.execute("UPDATE branch_leases SET current_head_oid=?,generation=?,lease_expires_at=?,state=?,revision=revision+1 WHERE lease_id=? AND revision=?", (current_head_oid, generation, lease_expires_at, state, lease_id, expected_revision))
|
||||||
if updated.rowcount != 1:
|
if updated.rowcount != 1:
|
||||||
raise JobError("LEASE_REVISION_CAS_CONFLICT")
|
raise JobError("LEASE_REVISION_CAS_CONFLICT")
|
||||||
connection.commit()
|
connection.commit()
|
||||||
@@ -426,6 +451,271 @@ class GitOpsJobController:
|
|||||||
except sqlite3.Error as exc:
|
except sqlite3.Error as exc:
|
||||||
raise ServiceUnavailable("JOB_DB_UNAVAILABLE") from exc
|
raise ServiceUnavailable("JOB_DB_UNAVAILABLE") from exc
|
||||||
|
|
||||||
|
def renew_lease(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
branch: str,
|
||||||
|
lineage_anchor_oid: str,
|
||||||
|
current_head_oid: str,
|
||||||
|
generation: int,
|
||||||
|
lease_expires_at: str,
|
||||||
|
) -> LeaseRenewalResult:
|
||||||
|
if (
|
||||||
|
not branch
|
||||||
|
or len(lineage_anchor_oid) != 40
|
||||||
|
or len(current_head_oid) != 40
|
||||||
|
or generation < 1
|
||||||
|
or not lease_expires_at.strip()
|
||||||
|
):
|
||||||
|
raise JobError("LEASE_BINDING_INVALID")
|
||||||
|
try:
|
||||||
|
with self.store._connect() as connection:
|
||||||
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
|
rows = connection.execute(
|
||||||
|
"SELECT lease_id, revision, generation, state, lease_expires_at "
|
||||||
|
"FROM branch_leases "
|
||||||
|
"WHERE branch=? AND lineage_anchor_oid=? AND current_head_oid=? "
|
||||||
|
"AND generation=? AND state='ACTIVE' ORDER BY revision DESC",
|
||||||
|
(branch, lineage_anchor_oid, current_head_oid, generation),
|
||||||
|
).fetchall()
|
||||||
|
if not rows:
|
||||||
|
connection.commit()
|
||||||
|
return LeaseRenewalResult(False, False, False, None)
|
||||||
|
if len(rows) != 1:
|
||||||
|
raise JobError("LEASE_BINDING_CONFLICT")
|
||||||
|
row = rows[0]
|
||||||
|
try:
|
||||||
|
current_expires_at = parse_utc(str(row["lease_expires_at"]))
|
||||||
|
requested_expires_at = parse_utc(lease_expires_at)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise JobError("LEASE_EXPIRY_INVALID") from exc
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
if requested_expires_at <= now:
|
||||||
|
raise JobError("LEASE_EXPIRY_INVALID")
|
||||||
|
if current_expires_at > now:
|
||||||
|
connection.commit()
|
||||||
|
return LeaseRenewalResult(
|
||||||
|
False,
|
||||||
|
True,
|
||||||
|
True,
|
||||||
|
str(row["lease_expires_at"]),
|
||||||
|
)
|
||||||
|
updated = connection.execute(
|
||||||
|
"UPDATE branch_leases SET lease_expires_at=?,revision=revision+1 "
|
||||||
|
"WHERE lease_id=? AND revision=? AND generation=? "
|
||||||
|
"AND state='ACTIVE' AND lease_expires_at=?",
|
||||||
|
(
|
||||||
|
lease_expires_at,
|
||||||
|
row["lease_id"],
|
||||||
|
int(row["revision"]),
|
||||||
|
generation,
|
||||||
|
row["lease_expires_at"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if updated.rowcount != 1:
|
||||||
|
current = connection.execute(
|
||||||
|
"SELECT lease_expires_at, generation, state FROM branch_leases "
|
||||||
|
"WHERE lease_id=?",
|
||||||
|
(row["lease_id"],),
|
||||||
|
).fetchone()
|
||||||
|
if (
|
||||||
|
current is not None
|
||||||
|
and int(current["generation"]) == generation
|
||||||
|
and current["state"] == "ACTIVE"
|
||||||
|
and parse_utc(str(current["lease_expires_at"])) > now
|
||||||
|
):
|
||||||
|
connection.commit()
|
||||||
|
return LeaseRenewalResult(
|
||||||
|
False,
|
||||||
|
True,
|
||||||
|
True,
|
||||||
|
str(current["lease_expires_at"]),
|
||||||
|
)
|
||||||
|
raise JobError("LEASE_REVISION_CAS_CONFLICT")
|
||||||
|
connection.commit()
|
||||||
|
return LeaseRenewalResult(True, False, True, lease_expires_at)
|
||||||
|
except JobError:
|
||||||
|
raise
|
||||||
|
except sqlite3.Error as exc:
|
||||||
|
raise ServiceUnavailable("JOB_DB_UNAVAILABLE") from exc
|
||||||
|
|
||||||
|
def begin_continuation_cycle(
|
||||||
|
self,
|
||||||
|
job_id: str,
|
||||||
|
*,
|
||||||
|
idempotency_key: str,
|
||||||
|
expected_head_oid: str,
|
||||||
|
generation: int,
|
||||||
|
expected_revision: int,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if (
|
||||||
|
not idempotency_key
|
||||||
|
or len(idempotency_key) > 128
|
||||||
|
or not all(character.isalnum() or character in "._:-" for character in idempotency_key)
|
||||||
|
or len(expected_head_oid) != 40
|
||||||
|
or any(character not in "0123456789abcdef" for character in expected_head_oid)
|
||||||
|
or generation < 1
|
||||||
|
):
|
||||||
|
raise JobError("CONTINUATION_CYCLE_BINDING_INVALID")
|
||||||
|
try:
|
||||||
|
with self.store._connect() as connection:
|
||||||
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
|
row = connection.execute("SELECT * FROM jobs WHERE job_id=?", (job_id,)).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise JobError("JOB_NOT_FOUND")
|
||||||
|
existing = connection.execute(
|
||||||
|
"SELECT * FROM continuation_cycles WHERE job_id=? AND idempotency_key=?",
|
||||||
|
(job_id, idempotency_key),
|
||||||
|
).fetchone()
|
||||||
|
if existing is not None:
|
||||||
|
if str(existing["expected_head_oid"]) != expected_head_oid or int(existing["generation"]) != generation:
|
||||||
|
raise JobError("CONTINUATION_CYCLE_BINDING_CONFLICT")
|
||||||
|
if str(existing["state"]) == "COMPLETED":
|
||||||
|
connection.commit()
|
||||||
|
return {"completed": True, "commit_oid": str(existing["commit_oid"] or ""), "idempotent": True}
|
||||||
|
if str(row["state"]) == JobState.COMPLETED.value:
|
||||||
|
receipt = json.loads(str(row["receipt_json"] or "{}"))
|
||||||
|
commit_oid = str(receipt.get("authority_oid") or "")
|
||||||
|
lease = connection.execute(
|
||||||
|
"SELECT current_head_oid FROM branch_leases WHERE job_id=? AND generation=? AND state='ACTIVE'",
|
||||||
|
(job_id, generation),
|
||||||
|
).fetchall()
|
||||||
|
if (
|
||||||
|
len(lease) == 1
|
||||||
|
and len(commit_oid) == 40
|
||||||
|
and str(lease[0]["current_head_oid"] or "") == commit_oid
|
||||||
|
and receipt.get("remote_verified") is True
|
||||||
|
):
|
||||||
|
now = utc_now()
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE continuation_cycles SET state='COMPLETED',commit_oid=?,updated_at=? "
|
||||||
|
"WHERE job_id=? AND idempotency_key=? AND state='ACTIVE'",
|
||||||
|
(commit_oid, now, job_id, idempotency_key),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
return {"completed": True, "commit_oid": commit_oid, "idempotent": True}
|
||||||
|
raise JobError("CONTINUATION_CYCLE_RECOVERY_REQUIRED")
|
||||||
|
connection.commit()
|
||||||
|
return {"completed": False, "commit_oid": None, "idempotent": True}
|
||||||
|
if int(row["revision"]) != expected_revision:
|
||||||
|
raise JobError("REVISION_CAS_CONFLICT")
|
||||||
|
if str(row["state"]) != JobState.COMPLETED.value:
|
||||||
|
raise JobError("CONTINUATION_CYCLE_JOB_STATE_INVALID")
|
||||||
|
if str(row["branch"]) in {"main", "master"} or str(row["branch"]).startswith(("refs/", "tags/")):
|
||||||
|
raise JobError("PROTECTED_BRANCH_REJECTED")
|
||||||
|
receipt = json.loads(str(row["receipt_json"] or "{}"))
|
||||||
|
if receipt.get("remote_verified") is not True or any(
|
||||||
|
str(receipt.get(field) or "") != expected_head_oid
|
||||||
|
for field in ("authority_oid", "api_branch_oid", "git_remote_oid")
|
||||||
|
):
|
||||||
|
raise JobError("CONTINUATION_CYCLE_RECEIPT_INVALID")
|
||||||
|
leases = connection.execute(
|
||||||
|
"SELECT * FROM branch_leases WHERE job_id=? AND branch=? AND generation=? AND state='ACTIVE'",
|
||||||
|
(job_id, str(row["branch"]), generation),
|
||||||
|
).fetchall()
|
||||||
|
if len(leases) != 1 or str(leases[0]["current_head_oid"] or "") != expected_head_oid:
|
||||||
|
raise JobError("CONTINUATION_CYCLE_LEASE_INVALID")
|
||||||
|
if connection.execute(
|
||||||
|
"SELECT 1 FROM continuation_cycles WHERE job_id=? AND state='ACTIVE' LIMIT 1",
|
||||||
|
(job_id,),
|
||||||
|
).fetchone() is not None:
|
||||||
|
raise JobError("CONTINUATION_CYCLE_ACTIVE_CONFLICT")
|
||||||
|
sequence = int(connection.execute(
|
||||||
|
"SELECT COALESCE(MAX(sequence),0)+1 FROM continuation_cycles WHERE job_id=?",
|
||||||
|
(job_id,),
|
||||||
|
).fetchone()[0])
|
||||||
|
now = utc_now()
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO continuation_cycles(job_id,idempotency_key,sequence,expected_head_oid,generation,state,commit_oid,created_at,updated_at) "
|
||||||
|
"VALUES(?,?,?,?,?,'ACTIVE',NULL,?,?)",
|
||||||
|
(job_id, idempotency_key, sequence, expected_head_oid, generation, now, now),
|
||||||
|
)
|
||||||
|
next_revision = expected_revision + 1
|
||||||
|
updated = connection.execute(
|
||||||
|
"UPDATE jobs SET state=?,side_effect_state=?,revision=?,updated_at=? WHERE job_id=? AND revision=?",
|
||||||
|
(JobState.LEASED.value, "CONTINUATION_LEASED", next_revision, now, job_id, expected_revision),
|
||||||
|
)
|
||||||
|
if updated.rowcount != 1:
|
||||||
|
raise JobError("REVISION_CAS_CONFLICT")
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO state_transitions(job_id,from_state,to_state,revision,side_effect_state,created_at) VALUES(?,?,?,?,?,?)",
|
||||||
|
(job_id, JobState.COMPLETED.value, JobState.LEASED.value, next_revision, "CONTINUATION_LEASED", now),
|
||||||
|
)
|
||||||
|
cycle_receipt = {
|
||||||
|
"idempotency_key": idempotency_key,
|
||||||
|
"expected_head_oid": expected_head_oid,
|
||||||
|
"generation": generation,
|
||||||
|
"sequence": sequence,
|
||||||
|
}
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO receipts(receipt_id,job_id,kind,digest,payload_json,created_at) VALUES(?,?,?,?,?,?)",
|
||||||
|
(
|
||||||
|
f"receipt-{job_id}-{next_revision}",
|
||||||
|
job_id,
|
||||||
|
"CONTINUATION_LEASED",
|
||||||
|
hashlib.sha256(canonical_json(cycle_receipt)).hexdigest(),
|
||||||
|
json.dumps(cycle_receipt, sort_keys=True),
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
result = dict(connection.execute("SELECT * FROM jobs WHERE job_id=?", (job_id,)).fetchone())
|
||||||
|
connection.commit()
|
||||||
|
except (JobError, BindingConflict):
|
||||||
|
raise
|
||||||
|
except sqlite3.Error as exc:
|
||||||
|
raise ServiceUnavailable("JOB_DB_UNAVAILABLE") from exc
|
||||||
|
result["request"] = json.loads(result["request_json"])
|
||||||
|
self.store._mirror(result)
|
||||||
|
return {"completed": False, "commit_oid": None, "idempotent": False}
|
||||||
|
|
||||||
|
def complete_continuation_cycle(self, job_id: str, *, idempotency_key: str, commit_oid: str) -> bool:
|
||||||
|
if len(commit_oid) != 40 or any(character not in "0123456789abcdef" for character in commit_oid):
|
||||||
|
raise JobError("CONTINUATION_CYCLE_COMMIT_OID_INVALID")
|
||||||
|
try:
|
||||||
|
with self.store._connect() as connection:
|
||||||
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
|
cycle = connection.execute(
|
||||||
|
"SELECT * FROM continuation_cycles WHERE job_id=? AND idempotency_key=?",
|
||||||
|
(job_id, idempotency_key),
|
||||||
|
).fetchone()
|
||||||
|
if cycle is None:
|
||||||
|
connection.commit()
|
||||||
|
return False
|
||||||
|
if str(cycle["state"]) == "COMPLETED":
|
||||||
|
if str(cycle["commit_oid"] or "") != commit_oid:
|
||||||
|
raise JobError("CONTINUATION_CYCLE_COMMIT_CONFLICT")
|
||||||
|
connection.commit()
|
||||||
|
return True
|
||||||
|
row = connection.execute("SELECT * FROM jobs WHERE job_id=?", (job_id,)).fetchone()
|
||||||
|
leases = connection.execute(
|
||||||
|
"SELECT current_head_oid FROM branch_leases WHERE job_id=? AND generation=? AND state='ACTIVE'",
|
||||||
|
(job_id, int(cycle["generation"])),
|
||||||
|
).fetchall()
|
||||||
|
receipt = json.loads(str(row["receipt_json"] or "{}")) if row is not None else {}
|
||||||
|
if (
|
||||||
|
row is None
|
||||||
|
or str(row["state"]) != JobState.COMPLETED.value
|
||||||
|
or receipt.get("remote_verified") is not True
|
||||||
|
or str(receipt.get("authority_oid") or "") != commit_oid
|
||||||
|
or len(leases) != 1
|
||||||
|
or str(leases[0]["current_head_oid"] or "") != commit_oid
|
||||||
|
):
|
||||||
|
raise JobError("CONTINUATION_CYCLE_COMPLETION_EVIDENCE_INVALID")
|
||||||
|
now = utc_now()
|
||||||
|
updated = connection.execute(
|
||||||
|
"UPDATE continuation_cycles SET state='COMPLETED',commit_oid=?,updated_at=? "
|
||||||
|
"WHERE job_id=? AND idempotency_key=? AND state='ACTIVE'",
|
||||||
|
(commit_oid, now, job_id, idempotency_key),
|
||||||
|
)
|
||||||
|
if updated.rowcount != 1:
|
||||||
|
raise JobError("CONTINUATION_CYCLE_CAS_CONFLICT")
|
||||||
|
connection.commit()
|
||||||
|
return True
|
||||||
|
except (JobError, BindingConflict):
|
||||||
|
raise
|
||||||
|
except sqlite3.Error as exc:
|
||||||
|
raise ServiceUnavailable("JOB_DB_UNAVAILABLE") from exc
|
||||||
|
|
||||||
def advance(self, job_id: str, state: JobState, *, side_effect_state: str, receipt: Mapping[str, Any] | None = None, expected_revision: int | None = None) -> JobReceipt:
|
def advance(self, job_id: str, state: JobState, *, side_effect_state: str, receipt: Mapping[str, Any] | None = None, expected_revision: int | None = None) -> JobReceipt:
|
||||||
return self._advance(
|
return self._advance(
|
||||||
job_id,
|
job_id,
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from release_version import validate_release_version
|
|
||||||
|
|
||||||
ROOT = Path(os.getenv('AIOS_GITOPS_ROOT', '/data/aios-gitops'))
|
ROOT = Path(os.getenv('AIOS_GITOPS_ROOT', '/data/aios-gitops'))
|
||||||
WORKSPACE = Path(os.getenv('AIOS_GITOPS_WORKSPACE', str(ROOT / 'workspace')))
|
WORKSPACE = Path(os.getenv('AIOS_GITOPS_WORKSPACE', str(ROOT / 'workspace')))
|
||||||
DB_PATH = Path(os.getenv('AIOS_GITOPS_DB', str(ROOT / 'runtime' / 'gitops_model.sqlite3')))
|
DB_PATH = Path(os.getenv('AIOS_GITOPS_DB', str(ROOT / 'runtime' / 'gitops_model.sqlite3')))
|
||||||
@@ -220,11 +218,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
a = self.require_write(['owner']);
|
a = self.require_write(['owner']);
|
||||||
if not a: return
|
if not a: return
|
||||||
ver=data.get('version') or data.get('tag')
|
ver=data.get('version') or data.get('tag')
|
||||||
try:
|
if not re.match(r'^V\d+R\d+C\d+(-[a-zA-Z0-9_.-]+)?$', ver or ''): self.send_json({'error':'version must match VxRxCx'},400); return
|
||||||
validate_release_version(ver)
|
|
||||||
except ValueError:
|
|
||||||
self.send_json({'error':'version must match VxRxCx'},400)
|
|
||||||
return
|
|
||||||
msg=data.get('message') or f'release {ver}'; out=git_cmd(['tag','-a',ver,'-m',msg]);
|
msg=data.get('message') or f'release {ver}'; out=git_cmd(['tag','-a',ver,'-m',msg]);
|
||||||
if data.get('push',False): git_cmd(['push','origin',ver])
|
if data.get('push',False): git_cmd(['push','origin',ver])
|
||||||
level='V' if re.match(r'^V[1-9]\d*R0C0', ver) else ('R' if re.match(r'^V\d+R[1-9]\d*C0', ver) else 'C'); exec_sql('INSERT OR REPLACE INTO gitops_versions(version,tag,level,summary,status,created_by,created_at) VALUES(?,?,?,?,?,?,?)',(ver,ver,level,msg,'ready',a['username'],now())); exec_sql('UPDATE gitops_baseline SET active_version=?,active_tag=?,updated_at=? WHERE id=?',(ver,ver,now(),'current')); audit(a['username'],'git_tag',ver,{'level':level}); self.send_json({'status':'tagged','version':ver,'git':out}); return
|
level='V' if re.match(r'^V[1-9]\d*R0C0', ver) else ('R' if re.match(r'^V\d+R[1-9]\d*C0', ver) else 'C'); exec_sql('INSERT OR REPLACE INTO gitops_versions(version,tag,level,summary,status,created_by,created_at) VALUES(?,?,?,?,?,?,?)',(ver,ver,level,msg,'ready',a['username'],now())); exec_sql('UPDATE gitops_baseline SET active_version=?,active_tag=?,updated_at=? WHERE id=?',(ver,ver,now(),'current')); audit(a['username'],'git_tag',ver,{'level':level}); self.send_json({'status':'tagged','version':ver,'git':out}); return
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass, asdict
|
from dataclasses import dataclass, asdict
|
||||||
|
from contextlib import contextmanager
|
||||||
|
import fcntl
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -9,6 +11,11 @@ import re
|
|||||||
from typing import Any, Mapping
|
from typing import Any, Mapping
|
||||||
|
|
||||||
from aios_gitops_job_controller import GitOpsJobController, JobStore
|
from aios_gitops_job_controller import GitOpsJobController, JobStore
|
||||||
|
from aios_online_agent_execution import (
|
||||||
|
GovernedExecutionBinding,
|
||||||
|
project_execution_envelope,
|
||||||
|
project_online_task,
|
||||||
|
)
|
||||||
|
|
||||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||||
BRANCH_RE = re.compile(r"^agent/[A-Za-z0-9][A-Za-z0-9._/-]{2,179}$")
|
BRANCH_RE = re.compile(r"^agent/[A-Za-z0-9][A-Za-z0-9._/-]{2,179}$")
|
||||||
@@ -36,35 +43,161 @@ class GovernedTaskBinding:
|
|||||||
return {"task_id": self.task_id, "repository": self.repository, "base_oid": self.base_oid, "target_branch": self.target_branch, "expected_branch_oid": self.expected_branch_oid, "generation": self.generation, "branch_mode": self.branch_mode, "create_target_branch": self.branch_mode == "CREATE_NEW", "worktree_mode": "detached_at_base_oid" if self.branch_mode == "CREATE_NEW" else "detached_at_expected_head", "cas_ref_update_required": True, "idempotent": idempotent, "state": "BOUND", "job_id": self.job_id}
|
return {"task_id": self.task_id, "repository": self.repository, "base_oid": self.base_oid, "target_branch": self.target_branch, "expected_branch_oid": self.expected_branch_oid, "generation": self.generation, "branch_mode": self.branch_mode, "create_target_branch": self.branch_mode == "CREATE_NEW", "worktree_mode": "detached_at_base_oid" if self.branch_mode == "CREATE_NEW" else "detached_at_expected_head", "cas_ref_update_required": True, "idempotent": idempotent, "state": "BOUND", "job_id": self.job_id}
|
||||||
|
|
||||||
|
|
||||||
def _safe(value: str) -> str:
|
def _legacy_safe(value: str) -> str:
|
||||||
return re.sub(r"[^A-Za-z0-9_.-]+", "-", value).strip("-")[:120] or "task"
|
return re.sub(r"[^A-Za-z0-9_.-]+", "-", value).strip("-")[:120] or "task"
|
||||||
|
|
||||||
|
|
||||||
|
def _task_key(value: str) -> str:
|
||||||
|
readable = _legacy_safe(value)[:72]
|
||||||
|
return f"{readable}-{hashlib.sha256(value.encode('utf-8')).hexdigest()[:24]}"
|
||||||
|
|
||||||
|
|
||||||
class GovernedTaskBootstrap:
|
class GovernedTaskBootstrap:
|
||||||
def __init__(self, authority: Any, root: Path, *, remote_name: str, token_path: Path, ssh_key_path: Path, api_base: str = "https://g.cnnetmkt.top/git/api/v1") -> None:
|
def __init__(self, authority: Any, root: Path, *, remote_name: str, token_path: Path, ssh_key_path: Path, api_base: str = "https://g.cnnetmkt.top/git/api/v1") -> None:
|
||||||
self.authority = authority; self.root = Path(root); self.remote_name = remote_name; self.token_path = Path(token_path); self.ssh_key_path = Path(ssh_key_path); self.api_base = api_base; self.task_meta_root = self.root / "task-bindings"; self.job_root = self.root / "jobs"; self.task_meta_root.mkdir(parents=True, exist_ok=True); self.job_root.mkdir(parents=True, exist_ok=True)
|
self.authority = authority; self.root = Path(root); self.remote_name = remote_name; self.token_path = Path(token_path); self.ssh_key_path = Path(ssh_key_path); self.api_base = api_base; self.task_meta_root = self.root / "task-bindings"; self.job_root = self.root / "jobs"; self.lock_root = self.root / "task-locks"; self.task_meta_root.mkdir(parents=True, exist_ok=True); self.job_root.mkdir(parents=True, exist_ok=True); self.lock_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
def _store(self, task_id: str) -> tuple[JobStore, GitOpsJobController]:
|
def _store(self, task_id: str) -> tuple[JobStore, GitOpsJobController]:
|
||||||
root = self.job_root / _safe(task_id); store = JobStore(root, payload_root=root / "payloads"); return store, GitOpsJobController(store)
|
legacy = self.job_root / _legacy_safe(task_id)
|
||||||
|
keyed = self.job_root / _task_key(task_id)
|
||||||
|
root = legacy if legacy.is_dir() and not keyed.exists() else keyed
|
||||||
|
store = JobStore(root, payload_root=root / "payloads"); return store, GitOpsJobController(store)
|
||||||
|
|
||||||
def _metadata(self, task_id: str) -> Path:
|
def _metadata(self, task_id: str) -> Path:
|
||||||
return self.task_meta_root / (_safe(task_id) + ".json")
|
return self.task_meta_root / (_task_key(task_id) + ".json")
|
||||||
|
|
||||||
|
def _legacy_metadata(self, task_id: str) -> Path:
|
||||||
|
return self.task_meta_root / (_legacy_safe(task_id) + ".json")
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _task_lock(self, task_id: str):
|
||||||
|
path = self.lock_root / (_task_key(task_id) + ".lock")
|
||||||
|
with path.open("a+b") as handle:
|
||||||
|
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _branch_lock(self, repository: str, target_branch: str):
|
||||||
|
key = hashlib.sha256((repository + "\0" + target_branch).encode("utf-8")).hexdigest()
|
||||||
|
path = self.lock_root / ("branch-" + key + ".lock")
|
||||||
|
with path.open("a+b") as handle:
|
||||||
|
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
||||||
|
|
||||||
def _read_existing(self, task_id: str) -> GovernedTaskBinding | None:
|
def _read_existing(self, task_id: str) -> GovernedTaskBinding | None:
|
||||||
path = self._metadata(task_id)
|
path = self._metadata(task_id)
|
||||||
|
if not path.exists():
|
||||||
|
path = self._legacy_metadata(task_id)
|
||||||
if not path.is_file() or path.is_symlink(): return None
|
if not path.is_file() or path.is_symlink(): return None
|
||||||
return GovernedTaskBinding(**json.loads(path.read_text(encoding="utf-8")))
|
binding = GovernedTaskBinding(**json.loads(path.read_text(encoding="utf-8")))
|
||||||
|
if binding.task_id != task_id:
|
||||||
|
raise BootstrapError("TASK_BINDING_KEY_COLLISION")
|
||||||
|
return binding
|
||||||
|
|
||||||
|
def get_binding(self, task_id: str) -> GovernedTaskBinding:
|
||||||
|
"""Return the exact durable binding; never synthesize missing lineage."""
|
||||||
|
existing = self._read_existing(str(task_id))
|
||||||
|
if existing is None or not existing.job_id:
|
||||||
|
raise BootstrapError("TASK_EXECUTION_PROJECTION_MISSING")
|
||||||
|
return existing
|
||||||
|
|
||||||
|
def execution_binding(self, binding: GovernedTaskBinding) -> GovernedExecutionBinding:
|
||||||
|
expected = binding.expected_branch_oid
|
||||||
|
mode = binding.branch_mode
|
||||||
|
if mode == "CREATE_NEW":
|
||||||
|
current = self.authority.read_ref("refs/heads/" + binding.target_branch)
|
||||||
|
if current is not None:
|
||||||
|
store, _ = self._store(binding.task_id)
|
||||||
|
with store._connect() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
"SELECT current_head_oid,generation,state,lineage_anchor_oid "
|
||||||
|
"FROM branch_leases WHERE job_id=? AND branch=? AND state='ACTIVE' "
|
||||||
|
"ORDER BY revision DESC",
|
||||||
|
(binding.job_id, binding.target_branch),
|
||||||
|
).fetchall()
|
||||||
|
if len(rows) != 1:
|
||||||
|
raise BootstrapError("SAME_JOB_CONTINUATION_LEASE_INVALID")
|
||||||
|
row = rows[0]
|
||||||
|
if (
|
||||||
|
str(row["current_head_oid"] or "") != current
|
||||||
|
or int(row["generation"] or 0) != binding.generation
|
||||||
|
or str(row["lineage_anchor_oid"] or "") != binding.base_oid
|
||||||
|
):
|
||||||
|
raise BootstrapError("SAME_JOB_CONTINUATION_BINDING_INVALID")
|
||||||
|
expected = current
|
||||||
|
mode = "CONTINUE_EXISTING"
|
||||||
|
return GovernedExecutionBinding.from_mapping(
|
||||||
|
{
|
||||||
|
"task_id": binding.task_id,
|
||||||
|
"job_id": binding.job_id,
|
||||||
|
"repository": binding.repository,
|
||||||
|
"base_oid": binding.base_oid,
|
||||||
|
"expected_branch_oid": expected,
|
||||||
|
"target_branch": binding.target_branch,
|
||||||
|
"branch_mode": mode,
|
||||||
|
"generation": binding.generation,
|
||||||
|
"idempotency_key": binding.idempotency_key,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def execution_envelope(
|
||||||
|
self,
|
||||||
|
task_id: str,
|
||||||
|
execution_request: Mapping[str, Any] | None = None,
|
||||||
|
*,
|
||||||
|
required_capabilities: tuple[str, ...] = (),
|
||||||
|
expected_job_id: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
binding = self.get_binding(task_id)
|
||||||
|
if expected_job_id and binding.job_id != expected_job_id:
|
||||||
|
raise BootstrapError("JOB_BINDING_CONFLICT")
|
||||||
|
return project_execution_envelope(
|
||||||
|
self.execution_binding(binding),
|
||||||
|
execution_request,
|
||||||
|
required_capabilities=required_capabilities,
|
||||||
|
)
|
||||||
|
|
||||||
|
def online_task_projection(
|
||||||
|
self,
|
||||||
|
task_id: str,
|
||||||
|
task_request: Mapping[str, Any],
|
||||||
|
execution_request: Mapping[str, Any] | None = None,
|
||||||
|
*,
|
||||||
|
expected_job_id: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
binding = self.get_binding(task_id)
|
||||||
|
if expected_job_id and binding.job_id != expected_job_id:
|
||||||
|
raise BootstrapError("JOB_BINDING_CONFLICT")
|
||||||
|
return project_online_task(self.execution_binding(binding), task_request, execution_request)
|
||||||
|
|
||||||
def _write(self, binding: GovernedTaskBinding) -> None:
|
def _write(self, binding: GovernedTaskBinding) -> None:
|
||||||
path = self._metadata(binding.task_id); temporary = path.with_suffix(".tmp"); temporary.write_text(json.dumps(asdict(binding), sort_keys=True), encoding="utf-8"); temporary.replace(path)
|
path = self._metadata(binding.task_id); temporary = path.with_suffix(".tmp"); temporary.write_text(json.dumps(asdict(binding), sort_keys=True), encoding="utf-8"); temporary.replace(path)
|
||||||
|
|
||||||
def create(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
def create(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
||||||
|
task_id = str(request.get("task_id") or "")
|
||||||
|
if not task_id:
|
||||||
|
raise BootstrapError("TASK_BINDING_INVALID")
|
||||||
|
repository = str(request.get("repository") or "")
|
||||||
|
target_branch = str(request.get("target_branch") or "")
|
||||||
|
with self._task_lock(task_id):
|
||||||
|
with self._branch_lock(repository, target_branch):
|
||||||
|
return self._create_locked(request)
|
||||||
|
|
||||||
|
def _create_locked(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
||||||
values = {key: str(request[key]) for key in ("task_id", "repository", "base_oid", "target_branch", "risk", "description", "idempotency_key")}
|
values = {key: str(request[key]) for key in ("task_id", "repository", "base_oid", "target_branch", "risk", "description", "idempotency_key")}
|
||||||
if values["repository"] != "aios/aios" or not OID_RE.fullmatch(values["base_oid"]): raise BootstrapError("TASK_BINDING_INVALID")
|
if values["repository"] != "aios/aios" or not OID_RE.fullmatch(values["base_oid"]): raise BootstrapError("TASK_BINDING_INVALID")
|
||||||
if not BRANCH_RE.fullmatch(values["target_branch"]): raise BootstrapError("TARGET_BRANCH_INVALID")
|
if not BRANCH_RE.fullmatch(values["target_branch"]): raise BootstrapError("TARGET_BRANCH_INVALID")
|
||||||
existing = self._read_existing(values["task_id"])
|
existing = self._read_existing(values["task_id"])
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
if any(getattr(existing, key) != values[key] for key in ("repository", "base_oid", "target_branch", "risk", "description")): raise BootstrapError("TASK_BINDING_CONFLICT")
|
if any(getattr(existing, key) != values[key] for key in ("repository", "base_oid", "target_branch", "risk", "description", "idempotency_key")): raise BootstrapError("TASK_BINDING_CONFLICT")
|
||||||
|
optional = {"expected_branch_oid": existing.expected_branch_oid, "branch_mode": existing.branch_mode, "generation": existing.generation}
|
||||||
|
for key, durable in optional.items():
|
||||||
|
if key in request and str(request[key]) != str(durable): raise BootstrapError("TASK_BINDING_CONFLICT")
|
||||||
return existing.response(idempotent=True)
|
return existing.response(idempotent=True)
|
||||||
if not self.authority.commit_exists(values["base_oid"]): raise BootstrapError("BASE_COMMIT_NOT_FOUND")
|
if not self.authority.commit_exists(values["base_oid"]): raise BootstrapError("BASE_COMMIT_NOT_FOUND")
|
||||||
current = self.authority.read_ref("refs/heads/" + values["target_branch"])
|
current = self.authority.read_ref("refs/heads/" + values["target_branch"])
|
||||||
@@ -74,8 +207,24 @@ class GovernedTaskBootstrap:
|
|||||||
if expected != values["base_oid"]: raise BootstrapError("EXPECTED_BASE_CONFLICT")
|
if expected != values["base_oid"]: raise BootstrapError("EXPECTED_BASE_CONFLICT")
|
||||||
else:
|
else:
|
||||||
if expected != current: raise BootstrapError("EXISTING_BRANCH_OID_CONFLICT")
|
if expected != current: raise BootstrapError("EXISTING_BRANCH_OID_CONFLICT")
|
||||||
mode, generation = "CONTINUE_EXISTING", int(request.get("generation", 2))
|
parent_task_id = str(request.get("parent_task_id") or "")
|
||||||
if generation < 1: raise BootstrapError("GENERATION_INVALID")
|
parent_job_id = str(request.get("parent_job_id") or "")
|
||||||
|
if not parent_task_id or not parent_job_id:
|
||||||
|
raise BootstrapError("EXISTING_BRANCH_OWNERSHIP_REQUIRED")
|
||||||
|
parent = self._read_existing(parent_task_id)
|
||||||
|
if parent is None or parent.job_id != parent_job_id or parent.repository != values["repository"] or parent.target_branch != values["target_branch"]:
|
||||||
|
raise BootstrapError("EXISTING_BRANCH_OWNERSHIP_CONFLICT")
|
||||||
|
store, _ = self._store(parent_task_id)
|
||||||
|
with store._connect() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
"SELECT current_head_oid,generation,state FROM branch_leases WHERE job_id=? AND branch=? AND state='ACTIVE' ORDER BY revision DESC",
|
||||||
|
(parent_job_id, values["target_branch"]),
|
||||||
|
).fetchall()
|
||||||
|
if len(rows) != 1 or str(rows[0]["current_head_oid"] or "") != current:
|
||||||
|
raise BootstrapError("EXISTING_BRANCH_OWNERSHIP_CONFLICT")
|
||||||
|
mode, generation = "CONTINUE_EXISTING", int(rows[0]["generation"]) + 1
|
||||||
|
if "generation" in request and int(request["generation"]) != generation:
|
||||||
|
raise BootstrapError("GENERATION_BINDING_CONFLICT")
|
||||||
payload = json.dumps(values, sort_keys=True, separators=(",", ":")).encode()
|
payload = json.dumps(values, sort_keys=True, separators=(",", ":")).encode()
|
||||||
_, controller = self._store(values["task_id"])
|
_, controller = self._store(values["task_id"])
|
||||||
receipt = controller.submit({"idempotency_key": values["idempotency_key"], "campaign_id": values["task_id"], "task_id": values["task_id"], "repository": values["repository"], "operation": "GOVERNED_TASK_CREATE", "branch": values["target_branch"], "expected_branch_oid": expected, "payload_digest": hashlib.sha256(payload).hexdigest(), "lineage_anchor_oid": values["base_oid"]}, payload=payload)
|
receipt = controller.submit({"idempotency_key": values["idempotency_key"], "campaign_id": values["task_id"], "task_id": values["task_id"], "repository": values["repository"], "operation": "GOVERNED_TASK_CREATE", "branch": values["target_branch"], "expected_branch_oid": expected, "payload_digest": hashlib.sha256(payload).hexdigest(), "lineage_anchor_oid": values["base_oid"]}, payload=payload)
|
||||||
|
|||||||
@@ -0,0 +1,306 @@
|
|||||||
|
"""Uncredentialed deterministic executor for governed Online Agent changes.
|
||||||
|
|
||||||
|
This process never receives Agent/Owner/Bridge credentials and never writes Git refs.
|
||||||
|
It operates only on an exchange bundle and returns hash-bound changed files + test evidence.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import errno
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import tarfile
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from typing import Any, Mapping, Sequence
|
||||||
|
|
||||||
|
ALLOWED_PROGRAMS = {"/usr/bin/python3", "/usr/bin/git", "/bin/true", "/usr/bin/true"}
|
||||||
|
MAX_ARCHIVE_BYTES = 64 * 1024 * 1024
|
||||||
|
MAX_FILE_BYTES = 4 * 1024 * 1024
|
||||||
|
SECRET_ENV_TOKENS = ("TOKEN", "SECRET", "PASSWORD", "CREDENTIAL", "AUTHORIZATION", "PRIVATE_KEY")
|
||||||
|
REQUEST_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{15,254}$")
|
||||||
|
SENSITIVE_VALUE_RE = re.compile(
|
||||||
|
r"(?i)(authorization\s*[:=]\s*bearer\s+\S+|authorization\s+bearer\s+\S+|"
|
||||||
|
r"(?:password|passwd|secret|token|api[_-]?key)\s*[:=]\s*\S+)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class IsolatedExecutorError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def sha256(data: bytes) -> str:
|
||||||
|
return hashlib.sha256(data).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def canonical(value: object) -> bytes:
|
||||||
|
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def safe_relative(value: object) -> str:
|
||||||
|
if not isinstance(value, str) or not value or value.startswith("/"):
|
||||||
|
raise IsolatedExecutorError("PATH_INVALID")
|
||||||
|
path = Path(value)
|
||||||
|
if ".." in path.parts or "." in path.parts or any(part in {"", ".git"} for part in path.parts):
|
||||||
|
raise IsolatedExecutorError("PATH_INVALID")
|
||||||
|
return path.as_posix()
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_under(root: Path, relative: str) -> Path:
|
||||||
|
root = root.resolve()
|
||||||
|
path = (root / safe_relative(relative)).resolve(strict=False)
|
||||||
|
if path == root or root not in path.parents:
|
||||||
|
raise IsolatedExecutorError("PATH_ESCAPE")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _credential_exposure(directory: Path) -> str | None:
|
||||||
|
"""Return an exposure code; EACCES is the intended credential isolation proof."""
|
||||||
|
try:
|
||||||
|
if not directory.exists():
|
||||||
|
return None
|
||||||
|
if directory.is_symlink():
|
||||||
|
return "CREDENTIAL_MOUNT_SYMLINK"
|
||||||
|
if os.access(directory, os.R_OK):
|
||||||
|
return "CREDENTIAL_MOUNT_ACCESSIBLE"
|
||||||
|
if directory.is_dir():
|
||||||
|
for child in directory.iterdir():
|
||||||
|
if child.is_file() and os.access(child, os.R_OK):
|
||||||
|
return "CREDENTIAL_FILE_ACCESSIBLE"
|
||||||
|
except PermissionError:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def assert_uncredentialed() -> None:
|
||||||
|
credentials = Path("/run/credentials")
|
||||||
|
candidates = {credentials}
|
||||||
|
credentials_directory = Path(os.environ.get("CREDENTIALS_DIRECTORY", "/run/credentials"))
|
||||||
|
candidates.add(credentials_directory)
|
||||||
|
leaked = sorted(
|
||||||
|
key for key, value in os.environ.items()
|
||||||
|
if value
|
||||||
|
and (any(token in key.upper() for token in SECRET_ENV_TOKENS) or SENSITIVE_VALUE_RE.search(str(value)))
|
||||||
|
)
|
||||||
|
if leaked:
|
||||||
|
raise IsolatedExecutorError("CREDENTIAL_ENV_EXPOSED:" + ",".join(leaked))
|
||||||
|
for directory in candidates:
|
||||||
|
exposure = _credential_exposure(directory)
|
||||||
|
if exposure is not None:
|
||||||
|
raise IsolatedExecutorError(exposure)
|
||||||
|
# In the intended PrivateNetwork namespace host loopback services are unreachable.
|
||||||
|
try:
|
||||||
|
probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
except OSError as exc:
|
||||||
|
if exc.errno in {errno.EACCES, errno.EPERM, errno.EAFNOSUPPORT}:
|
||||||
|
return
|
||||||
|
raise IsolatedExecutorError("NETWORK_ISOLATION_PROBE_FAILED") from exc
|
||||||
|
probe.settimeout(0.15)
|
||||||
|
try:
|
||||||
|
if probe.connect_ex(("127.0.0.1", 18892)) == 0:
|
||||||
|
raise IsolatedExecutorError("GOVERNED_BRIDGE_REACHABLE")
|
||||||
|
finally:
|
||||||
|
probe.close()
|
||||||
|
|
||||||
|
|
||||||
|
def extract_source(archive: Path, root: Path) -> None:
|
||||||
|
if archive.is_symlink() or not archive.is_file() or archive.stat().st_size > MAX_ARCHIVE_BYTES:
|
||||||
|
raise IsolatedExecutorError("SOURCE_ARCHIVE_INVALID")
|
||||||
|
with tarfile.open(archive, "r:gz") as tf:
|
||||||
|
for member in tf.getmembers():
|
||||||
|
path = Path(member.name)
|
||||||
|
if path.is_absolute() or ".." in path.parts or ".git" in path.parts:
|
||||||
|
raise IsolatedExecutorError("SOURCE_ARCHIVE_PATH_INVALID")
|
||||||
|
if member.issym() or member.islnk():
|
||||||
|
raise IsolatedExecutorError("SOURCE_ARCHIVE_LINK_DENY")
|
||||||
|
tf.extractall(root, filter="data")
|
||||||
|
|
||||||
|
|
||||||
|
def run_command(item: Mapping[str, Any], root: Path) -> dict[str, Any]:
|
||||||
|
argv = item.get("argv")
|
||||||
|
if not isinstance(argv, Sequence) or isinstance(argv, (str, bytes)) or not argv:
|
||||||
|
raise IsolatedExecutorError("COMMAND_INVALID")
|
||||||
|
argv = [str(part) for part in argv]
|
||||||
|
if argv[0] not in ALLOWED_PROGRAMS:
|
||||||
|
raise IsolatedExecutorError("COMMAND_PROGRAM_DENY")
|
||||||
|
if argv[0] == "/usr/bin/python3" and "-m" in argv:
|
||||||
|
index = argv.index("-m")
|
||||||
|
if index + 1 >= len(argv) or argv[index + 1] not in {"pytest", "py_compile"}:
|
||||||
|
raise IsolatedExecutorError("PYTHON_MODULE_DENY")
|
||||||
|
if argv[0] == "/usr/bin/git" and any(
|
||||||
|
token in {"push", "update-ref", "commit", "merge", "rebase", "reset", "clean", "branch"}
|
||||||
|
for token in argv[1:]
|
||||||
|
):
|
||||||
|
raise IsolatedExecutorError("GIT_MUTATION_DENY")
|
||||||
|
timeout = int(item.get("timeout_seconds") or 300)
|
||||||
|
if not 1 <= timeout <= 1800:
|
||||||
|
raise IsolatedExecutorError("COMMAND_TIMEOUT_INVALID")
|
||||||
|
cwd = root if item.get("cwd", ".") == "." else resolve_under(root, str(item.get("cwd")))
|
||||||
|
started = time.monotonic()
|
||||||
|
completed = subprocess.run(
|
||||||
|
argv,
|
||||||
|
cwd=cwd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
timeout=timeout,
|
||||||
|
env={"PATH": "/usr/bin:/bin", "LC_ALL": "C.UTF-8"},
|
||||||
|
)
|
||||||
|
result = {
|
||||||
|
"label": str(item.get("label") or "command"),
|
||||||
|
"exit_code": completed.returncode,
|
||||||
|
"stdout_sha256": sha256(completed.stdout.encode("utf-8", "replace")),
|
||||||
|
"stderr_sha256": sha256(completed.stderr.encode("utf-8", "replace")),
|
||||||
|
"duration_ms": int((time.monotonic() - started) * 1000),
|
||||||
|
}
|
||||||
|
if completed.returncode != 0:
|
||||||
|
raise IsolatedExecutorError(
|
||||||
|
"COMMAND_FAILED:" + result["label"] + ":" + str(completed.returncode)
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def apply_changes(request: Mapping[str, Any], root: Path) -> list[str]:
|
||||||
|
changes = request.get("change_set")
|
||||||
|
if not isinstance(changes, list) or not changes:
|
||||||
|
raise IsolatedExecutorError("CHANGE_SET_INVALID")
|
||||||
|
changed: list[str] = []
|
||||||
|
for change in changes:
|
||||||
|
if not isinstance(change, Mapping):
|
||||||
|
raise IsolatedExecutorError("CHANGE_INVALID")
|
||||||
|
relative = safe_relative(change.get("path"))
|
||||||
|
path = resolve_under(root, relative)
|
||||||
|
current = path.read_bytes() if path.is_file() else None
|
||||||
|
expected = change.get("expected_sha256")
|
||||||
|
if expected is not None and (current is None or sha256(current) != expected):
|
||||||
|
raise IsolatedExecutorError("SOURCE_DRIFT:" + relative)
|
||||||
|
operation = change.get("operation")
|
||||||
|
if operation == "write_file":
|
||||||
|
content = change.get("content")
|
||||||
|
if not isinstance(content, str):
|
||||||
|
raise IsolatedExecutorError("WRITE_CONTENT_INVALID")
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(content, encoding="utf-8")
|
||||||
|
elif operation == "replace_text":
|
||||||
|
if current is None:
|
||||||
|
raise IsolatedExecutorError("REPLACE_SOURCE_MISSING")
|
||||||
|
old = str(change.get("old"))
|
||||||
|
new = str(change.get("new"))
|
||||||
|
count = int(change.get("count", 1))
|
||||||
|
text = current.decode("utf-8")
|
||||||
|
if text.count(old) != count:
|
||||||
|
raise IsolatedExecutorError("REPLACE_COUNT_INVALID:" + relative)
|
||||||
|
path.write_text(text.replace(old, new), encoding="utf-8")
|
||||||
|
elif operation == "delete_file":
|
||||||
|
if path.exists():
|
||||||
|
path.unlink()
|
||||||
|
else:
|
||||||
|
raise IsolatedExecutorError("CHANGE_OPERATION_INVALID")
|
||||||
|
changed.append(relative)
|
||||||
|
return sorted(set(changed))
|
||||||
|
|
||||||
|
|
||||||
|
def changed_bundle(root: Path, changed: list[str]) -> list[dict[str, Any]]:
|
||||||
|
files = []
|
||||||
|
for relative in changed:
|
||||||
|
path = resolve_under(root, relative)
|
||||||
|
if path.exists():
|
||||||
|
data = path.read_bytes()
|
||||||
|
if len(data) > MAX_FILE_BYTES:
|
||||||
|
raise IsolatedExecutorError("CHANGED_FILE_TOO_LARGE:" + relative)
|
||||||
|
files.append({
|
||||||
|
"path": relative,
|
||||||
|
"deleted": False,
|
||||||
|
"sha256": sha256(data),
|
||||||
|
"content_b64": base64.b64encode(data).decode("ascii"),
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
files.append({"path": relative, "deleted": True, "sha256": None, "content_b64": None})
|
||||||
|
return files
|
||||||
|
|
||||||
|
|
||||||
|
def execute_request(request_path: Path, result_path: Path) -> None:
|
||||||
|
assert_uncredentialed()
|
||||||
|
request = json.loads(request_path.read_text(encoding="utf-8"))
|
||||||
|
if request.get("schema") != "aios.isolated-execution.request.v1":
|
||||||
|
raise IsolatedExecutorError("REQUEST_SCHEMA_INVALID")
|
||||||
|
request_id = request.get("request_id")
|
||||||
|
if not isinstance(request_id, str) or not REQUEST_ID_RE.fullmatch(request_id):
|
||||||
|
raise IsolatedExecutorError("REQUEST_ID_INVALID")
|
||||||
|
source_archive = Path(str(request.get("source_archive") or ""))
|
||||||
|
if not source_archive.is_absolute():
|
||||||
|
raise IsolatedExecutorError("SOURCE_ARCHIVE_PATH_INVALID")
|
||||||
|
expected_archive_sha = str(request.get("source_archive_sha256") or "")
|
||||||
|
if sha256(source_archive.read_bytes()) != expected_archive_sha:
|
||||||
|
raise IsolatedExecutorError("SOURCE_ARCHIVE_SHA_MISMATCH")
|
||||||
|
with tempfile.TemporaryDirectory(prefix="aios-isolated-executor-") as temporary:
|
||||||
|
root = Path(temporary) / "workspace"
|
||||||
|
root.mkdir()
|
||||||
|
extract_source(source_archive, root)
|
||||||
|
changed = apply_changes(request, root)
|
||||||
|
commands = [
|
||||||
|
run_command(item, root)
|
||||||
|
for item in request.get("verification", [])
|
||||||
|
if isinstance(item, Mapping)
|
||||||
|
]
|
||||||
|
result = {
|
||||||
|
"schema": "aios.isolated-execution.result.v1",
|
||||||
|
"request_id": request_id,
|
||||||
|
"request_sha256": sha256(canonical(request)),
|
||||||
|
"changed_files": changed_bundle(root, changed),
|
||||||
|
"verification": commands,
|
||||||
|
"credential_isolation": {
|
||||||
|
"credential_env_absent": True,
|
||||||
|
"credential_mount_inaccessible": True,
|
||||||
|
"governed_bridge_unreachable": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
result["result_sha256"] = sha256(canonical(result))
|
||||||
|
temporary_result = result_path.with_suffix(".tmp")
|
||||||
|
temporary_result.write_text(json.dumps(result, sort_keys=True, separators=(",", ":")), encoding="utf-8")
|
||||||
|
os.replace(temporary_result, result_path)
|
||||||
|
|
||||||
|
|
||||||
|
def serve(root: Path) -> None:
|
||||||
|
assert_uncredentialed()
|
||||||
|
requests = root / "requests"
|
||||||
|
results = root / "results"
|
||||||
|
failed = root / "failed"
|
||||||
|
for path in (requests, results, failed):
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
while True:
|
||||||
|
for request in sorted(requests.glob("*.json")):
|
||||||
|
if request.is_symlink():
|
||||||
|
continue
|
||||||
|
result = results / request.name
|
||||||
|
try:
|
||||||
|
execute_request(request, result)
|
||||||
|
request.unlink()
|
||||||
|
except Exception as exc:
|
||||||
|
(failed / request.name).write_text(
|
||||||
|
json.dumps({"error": str(exc) or type(exc).__name__}, sort_keys=True),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
request.unlink(missing_ok=True)
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import argparse
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--root", default="/var/lib/aios-online-agent-executor")
|
||||||
|
parser.add_argument("--request")
|
||||||
|
parser.add_argument("--result")
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.request and args.result:
|
||||||
|
execute_request(Path(args.request), Path(args.result))
|
||||||
|
else:
|
||||||
|
serve(Path(args.root))
|
||||||
@@ -23,7 +23,6 @@ from gitea_tag_publication import (
|
|||||||
TagPublisher,
|
TagPublisher,
|
||||||
redact,
|
redact,
|
||||||
validate_version,
|
validate_version,
|
||||||
validate_gitea_api_url,
|
|
||||||
)
|
)
|
||||||
from orchestrator.action_monitor import ActionMonitor
|
from orchestrator.action_monitor import ActionMonitor
|
||||||
from silent_release_hardening import (
|
from silent_release_hardening import (
|
||||||
@@ -124,19 +123,12 @@ SILENT_RELEASE_POST_PATHS = frozenset({
|
|||||||
'/submitOwnerApproval',
|
'/submitOwnerApproval',
|
||||||
'/startDeployment',
|
'/startDeployment',
|
||||||
})
|
})
|
||||||
BRIDGE_CONTROL_POST_PATHS = frozenset({
|
|
||||||
'/readRepositoryFile',
|
|
||||||
'/acquireWriterLease',
|
|
||||||
'/commitFilesGoverned',
|
|
||||||
})
|
|
||||||
|
|
||||||
def reject_non_read_http(handler, method: str, path: str) -> bool:
|
def reject_non_read_http(handler, method: str, path: str) -> bool:
|
||||||
if method == 'GET':
|
if method == 'GET':
|
||||||
return False
|
return False
|
||||||
if path in SILENT_RELEASE_POST_PATHS:
|
if path in SILENT_RELEASE_POST_PATHS:
|
||||||
return False
|
return False
|
||||||
if method == 'POST' and path in BRIDGE_CONTROL_POST_PATHS:
|
|
||||||
return False
|
|
||||||
if (
|
if (
|
||||||
method == 'POST'
|
method == 'POST'
|
||||||
and path in ('/api/file', '/api/commit-file')
|
and path in ('/api/file', '/api/commit-file')
|
||||||
@@ -327,7 +319,8 @@ def resolve_commit_oid(value: str | None, field: str = 'target_oid') -> str:
|
|||||||
raise ValueError(f'{field} does not resolve to the exact approved commit') from exc
|
raise ValueError(f'{field} does not resolve to the exact approved commit') from exc
|
||||||
|
|
||||||
def existing_tag_target(tag: str) -> str | None:
|
def existing_tag_target(tag: str) -> str | None:
|
||||||
validate_version(tag)
|
if not re.fullmatch(r'V\d+R\d+C\d+(?:-[A-Za-z0-9_.-]+)?$', str(tag or '')):
|
||||||
|
raise ValueError('invalid release version')
|
||||||
try:
|
try:
|
||||||
result = run_authoritative_git(
|
result = run_authoritative_git(
|
||||||
['rev-parse', '--verify', f'refs/tags/{tag}^{{commit}}'],
|
['rev-parse', '--verify', f'refs/tags/{tag}^{{commit}}'],
|
||||||
@@ -350,16 +343,6 @@ def publication_request_from_query(q: dict[str, list[str]]) -> PublicationReques
|
|||||||
(q['approval_plan_id'])[0],
|
(q['approval_plan_id'])[0],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def governed_release_query_payload(q: dict[str, list[str]]) -> dict[str, str]:
|
|
||||||
request = publication_request_from_query(q)
|
|
||||||
return {
|
|
||||||
'version': request.version,
|
|
||||||
'target_oid': request.target_oid,
|
|
||||||
'evidence_closure_oid': request.evidence_closure_oid,
|
|
||||||
'approval_plan_id': request.approval_plan_id,
|
|
||||||
}
|
|
||||||
|
|
||||||
def required_authoritative_git_dir() -> Path:
|
def required_authoritative_git_dir() -> Path:
|
||||||
snapshot = repository_snapshot(
|
snapshot = repository_snapshot(
|
||||||
git_dir=AUTHORITATIVE_GIT_DIR,
|
git_dir=AUTHORITATIVE_GIT_DIR,
|
||||||
@@ -372,7 +355,7 @@ def required_authoritative_git_dir() -> Path:
|
|||||||
|
|
||||||
def publication_publisher() -> TagPublisher:
|
def publication_publisher() -> TagPublisher:
|
||||||
authoritative_repo = required_authoritative_git_dir()
|
authoritative_repo = required_authoritative_git_dir()
|
||||||
publisher = TagPublisher(
|
return TagPublisher(
|
||||||
WS,
|
WS,
|
||||||
GitTagBackend(WS, authoritative_repo=authoritative_repo),
|
GitTagBackend(WS, authoritative_repo=authoritative_repo),
|
||||||
GiteaTagClient(
|
GiteaTagClient(
|
||||||
@@ -383,10 +366,6 @@ def publication_publisher() -> TagPublisher:
|
|||||||
),
|
),
|
||||||
os.getenv('AIOS_GITOPS_GITEA_REMOTE','gitea'),
|
os.getenv('AIOS_GITOPS_GITEA_REMOTE','gitea'),
|
||||||
)
|
)
|
||||||
publisher.baseline_path = Path(os.getenv(
|
|
||||||
'AIOS_GITOPS_BASELINE_AUTHORITY', str(WS / 'baseline' / 'current.json')
|
|
||||||
))
|
|
||||||
return publisher
|
|
||||||
|
|
||||||
def commit(paths, msg):
|
def commit(paths, msg):
|
||||||
ensure_git(); paths = [relpath(p) for p in paths] if paths else ['.']; sh(['add'] + paths, True)
|
ensure_git(); paths = [relpath(p) for p in paths] if paths else ['.']; sh(['add'] + paths, True)
|
||||||
@@ -452,10 +431,7 @@ class H(BaseHTTPRequestHandler):
|
|||||||
request = urllib.request.Request(
|
request = urllib.request.Request(
|
||||||
base + route,
|
base + route,
|
||||||
data=json.dumps(data, sort_keys=True, separators=(',', ':')).encode('utf-8'),
|
data=json.dumps(data, sort_keys=True, separators=(',', ':')).encode('utf-8'),
|
||||||
headers={
|
headers={'Content-Type': 'application/json'},
|
||||||
'Content-Type': 'application/json',
|
|
||||||
**({k: self.headers[k] for k in ('Authorization', 'X-AIOS-Token') if self.headers.get(k)}),
|
|
||||||
},
|
|
||||||
method='POST',
|
method='POST',
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
@@ -470,36 +446,6 @@ class H(BaseHTTPRequestHandler):
|
|||||||
return self.sendj(body, exc.code)
|
return self.sendj(body, exc.code)
|
||||||
except (urllib.error.URLError, TimeoutError) as exc:
|
except (urllib.error.URLError, TimeoutError) as exc:
|
||||||
return self.sendj({'error': 'GOVERNED_BRIDGE_UNAVAILABLE', 'detail': type(exc).__name__}, 503)
|
return self.sendj({'error': 'GOVERNED_BRIDGE_UNAVAILABLE', 'detail': type(exc).__name__}, 503)
|
||||||
def bridge_control(self, path, data):
|
|
||||||
route = {
|
|
||||||
'/readRepositoryFile': '/v2/source/file',
|
|
||||||
'/acquireWriterLease': '/v2/writer-lease',
|
|
||||||
'/commitFilesGoverned': '/v2/commit-files',
|
|
||||||
}.get(path)
|
|
||||||
if route is None:
|
|
||||||
return self.sendj({'error': 'governed_control_route_invalid'}, 400)
|
|
||||||
base = os.getenv('AIOS_BRIDGE_V2_URL', 'http://127.0.0.1:18892').rstrip('/')
|
|
||||||
request = urllib.request.Request(
|
|
||||||
base + route,
|
|
||||||
data=json.dumps(data, sort_keys=True, separators=(',', ':')).encode('utf-8'),
|
|
||||||
headers={
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
**({k: self.headers[k] for k in ('Authorization', 'X-AIOS-Token') if self.headers.get(k)}),
|
|
||||||
},
|
|
||||||
method='POST',
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(request, timeout=60) as response:
|
|
||||||
result = json.loads(response.read().decode('utf-8'))
|
|
||||||
return self.sendj(result, response.status)
|
|
||||||
except urllib.error.HTTPError as exc:
|
|
||||||
try:
|
|
||||||
body = json.loads(exc.read().decode('utf-8'))
|
|
||||||
except Exception:
|
|
||||||
body = {'error': 'bridge_http_error'}
|
|
||||||
return self.sendj(body, exc.code)
|
|
||||||
except (urllib.error.URLError, TimeoutError) as exc:
|
|
||||||
return self.sendj({'error': 'GOVERNED_BRIDGE_UNAVAILABLE', 'detail': type(exc).__name__}, 503)
|
|
||||||
def do_POST(self):
|
def do_POST(self):
|
||||||
path = urllib.parse.urlparse(self.path).path
|
path = urllib.parse.urlparse(self.path).path
|
||||||
if path in {
|
if path in {
|
||||||
@@ -607,15 +553,7 @@ class H(BaseHTTPRequestHandler):
|
|||||||
return self.sendj({'error':redact(exc)},413)
|
return self.sendj({'error':redact(exc)},413)
|
||||||
except (AuthoritativeGitError, RuntimeError) as exc:
|
except (AuthoritativeGitError, RuntimeError) as exc:
|
||||||
return self.sendj({'error':redact(exc)},503)
|
return self.sendj({'error':redact(exc)},503)
|
||||||
payload = {'from': frm, 'to': to, 'diff': diff}
|
return self.sendj({'from':frm,'to':to,'diff':diff})
|
||||||
payload.update({
|
|
||||||
key: diff[key]
|
|
||||||
for key in (
|
|
||||||
'from_oid', 'to_oid', 'changed_paths', 'stats',
|
|
||||||
'total_size_bytes', 'truncated', 'patch_limit_bytes',
|
|
||||||
)
|
|
||||||
})
|
|
||||||
return self.sendj(payload)
|
|
||||||
if path in ('/api/patch','/api/patches','/api/branches','/patch'):
|
if path in ('/api/patch','/api/patches','/api/branches','/patch'):
|
||||||
snapshot = repository_snapshot(
|
snapshot = repository_snapshot(
|
||||||
git_dir=AUTHORITATIVE_GIT_DIR,
|
git_dir=AUTHORITATIVE_GIT_DIR,
|
||||||
@@ -700,15 +638,11 @@ class H(BaseHTTPRequestHandler):
|
|||||||
return self.sendj({'error':'invalid release version'},400)
|
return self.sendj({'error':'invalid release version'},400)
|
||||||
return self.sendj(status)
|
return self.sendj(status)
|
||||||
if path == '/api/release-consistency':
|
if path == '/api/release-consistency':
|
||||||
return self.bridge_release(
|
request=publication_request_from_query(q)
|
||||||
'/getReleaseConsistency',
|
return self.sendj(publication_publisher().get_release_consistency(request))
|
||||||
governed_release_query_payload(q),
|
|
||||||
)
|
|
||||||
if path == '/api/release-acceptance':
|
if path == '/api/release-acceptance':
|
||||||
return self.bridge_release(
|
request=publication_request_from_query(q)
|
||||||
'/runReleaseAcceptance',
|
return self.sendj(publication_publisher().run_release_acceptance(request))
|
||||||
governed_release_query_payload(q),
|
|
||||||
)
|
|
||||||
if path in (
|
if path in (
|
||||||
'/getReleaseCandidate',
|
'/getReleaseCandidate',
|
||||||
'/getReleasePlan',
|
'/getReleasePlan',
|
||||||
@@ -737,8 +671,7 @@ class H(BaseHTTPRequestHandler):
|
|||||||
return self.sendj(
|
return self.sendj(
|
||||||
service.get_capability_status(candidate_id)
|
service.get_capability_status(candidate_id)
|
||||||
)
|
)
|
||||||
result = service.get_release_saga(candidate_id)
|
return self.sendj(service.get_release_saga(candidate_id))
|
||||||
return self.sendj(result, 404 if result.get('status') == 'NOT_FOUND' else 200)
|
|
||||||
if path == '/getRecoveryStatus':
|
if path == '/getRecoveryStatus':
|
||||||
return self.sendj(
|
return self.sendj(
|
||||||
silent_release_service().get_recovery_status()
|
silent_release_service().get_recovery_status()
|
||||||
@@ -770,18 +703,6 @@ class H(BaseHTTPRequestHandler):
|
|||||||
if reject_non_read_http(self, 'POST', path): return
|
if reject_non_read_http(self, 'POST', path): return
|
||||||
data=self.body()
|
data=self.body()
|
||||||
try:
|
try:
|
||||||
if path == '/readRepositoryFile':
|
|
||||||
a=self.need('file:write')
|
|
||||||
if not a: return
|
|
||||||
return self.bridge_control(path, data)
|
|
||||||
if path == '/acquireWriterLease':
|
|
||||||
a=self.need('agent-task:control')
|
|
||||||
if not a: return
|
|
||||||
return self.bridge_control(path, data)
|
|
||||||
if path == '/commitFilesGoverned':
|
|
||||||
a=self.need('file:write')
|
|
||||||
if not a: return
|
|
||||||
return self.bridge_control(path, data)
|
|
||||||
if path == '/createReleaseCandidate':
|
if path == '/createReleaseCandidate':
|
||||||
a=self.need('release:candidate')
|
a=self.need('release:candidate')
|
||||||
if not a: return
|
if not a: return
|
||||||
@@ -901,10 +822,7 @@ class H(BaseHTTPRequestHandler):
|
|||||||
a=self.need('tag:create');
|
a=self.need('tag:create');
|
||||||
if not a: return
|
if not a: return
|
||||||
ver=data.get('version') or data.get('tag')
|
ver=data.get('version') or data.get('tag')
|
||||||
try:
|
if not re.match(r'^V\d+R\d+C\d+(-[A-Za-z0-9_.-]+)?$', ver or ''): return self.sendj({'error':'version must match VxRxCx'},400)
|
||||||
validate_version(ver)
|
|
||||||
except ValueError:
|
|
||||||
return self.sendj({'error':'version must match VxRxCx'},400)
|
|
||||||
if not data.get('target_oid') or not data.get('evidence_closure_oid') or not data.get('approval_plan_id'):
|
if not data.get('target_oid') or not data.get('evidence_closure_oid') or not data.get('approval_plan_id'):
|
||||||
return self.sendj({
|
return self.sendj({
|
||||||
'error':'production_tag_evidence_required',
|
'error':'production_tag_evidence_required',
|
||||||
@@ -1098,7 +1016,7 @@ def api_spec():
|
|||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
validate_gitea_api_url(os.getenv('AIOS_GITOPS_GITEA_API_URL', ''))
|
|
||||||
print(f'AIOS ChatGPT GitOps adapter listening on {HOST}:{PORT}')
|
print(f'AIOS ChatGPT GitOps adapter listening on {HOST}:{PORT}')
|
||||||
ThreadingHTTPServer((HOST,PORT), H).serve_forever()
|
ThreadingHTTPServer((HOST,PORT), H).serve_forever()
|
||||||
if __name__ == '__main__': main()
|
if __name__ == '__main__': main()
|
||||||
|
|
||||||
|
|||||||
@@ -1,229 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Small staging-only response overlay for the legacy listPatches route.
|
|
||||||
|
|
||||||
The base staging module remains the source of truth for routing, auth, and
|
|
||||||
governed write behavior. This entrypoint only replaces the handler's JSON
|
|
||||||
serializer for the patch-read aliases, projecting the legacy payload into a
|
|
||||||
bounded, cursor-pageable summary while leaving every other route untouched.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import urllib.parse
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import aios_model_api_staging as base
|
|
||||||
|
|
||||||
|
|
||||||
PATCH_PATHS = frozenset({"/api/patch", "/api/patches", "/api/branches", "/patch"})
|
|
||||||
PATCH_DEFAULT_LIMIT = 20
|
|
||||||
PATCH_MAX_LIMIT = 100
|
|
||||||
PATCH_RESPONSE_BUDGET = 128 * 1024
|
|
||||||
PATCH_FIELD_MAX_CHARS = 256
|
|
||||||
PATCH_SUMMARY_MAX_CHARS = 1024
|
|
||||||
PATCH_COMPAT_LIST_LIMIT = 20
|
|
||||||
_TOKEN_RE = re.compile(r"(?i)(?:sk-|ghp_|bearer\s+)[A-Za-z0-9._:/+=-]{12,}")
|
|
||||||
|
|
||||||
|
|
||||||
def _text(value: Any, limit: int = PATCH_FIELD_MAX_CHARS) -> str:
|
|
||||||
if value is None:
|
|
||||||
return ""
|
|
||||||
if isinstance(value, (dict, list)):
|
|
||||||
value = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
||||||
value = _TOKEN_RE.sub("[REDACTED]", str(value))
|
|
||||||
return value if len(value) <= limit else value[: max(1, limit - 1)] + "…"
|
|
||||||
|
|
||||||
|
|
||||||
def _record_id(record: dict[str, Any], index: int) -> str:
|
|
||||||
return _text(
|
|
||||||
record.get("patch_id")
|
|
||||||
or record.get("id")
|
|
||||||
or record.get("patch_name")
|
|
||||||
or record.get("branch")
|
|
||||||
or f"patch-{index}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _record_key(record: dict[str, Any], index: int) -> tuple[bool, str, str, int]:
|
|
||||||
timestamp = _text(
|
|
||||||
record.get("updated_at")
|
|
||||||
or record.get("updatedAt")
|
|
||||||
or record.get("created_at")
|
|
||||||
or record.get("createdAt")
|
|
||||||
)
|
|
||||||
return bool(timestamp), timestamp, _record_id(record, index), index
|
|
||||||
|
|
||||||
|
|
||||||
def _summary(record: dict[str, Any], index: int, summary_limit: int) -> dict[str, Any]:
|
|
||||||
patch_id = _record_id(record, index)
|
|
||||||
raw_summary = next(
|
|
||||||
(
|
|
||||||
record.get(key)
|
|
||||||
for key in ("summary", "description", "text", "intent")
|
|
||||||
if record.get(key) not in (None, "")
|
|
||||||
),
|
|
||||||
"",
|
|
||||||
)
|
|
||||||
summary = _text(raw_summary, summary_limit)
|
|
||||||
item = {
|
|
||||||
"patch_id": patch_id,
|
|
||||||
"name": _text(record.get("name") or record.get("title") or record.get("patch_name") or patch_id),
|
|
||||||
"branch": _text(record.get("branch")) or None,
|
|
||||||
"ref": _text(record.get("ref") or record.get("branch")) or None,
|
|
||||||
"status": _text(record.get("status") or record.get("state") or "recorded"),
|
|
||||||
"risk": _text(record.get("risk")) or None,
|
|
||||||
"created_at": _text(record.get("created_at") or record.get("createdAt")) or None,
|
|
||||||
"updated_at": _text(record.get("updated_at") or record.get("updatedAt")) or None,
|
|
||||||
"commit_oid": _text(record.get("commit_oid") or record.get("commit") or record.get("oid"), 128) or None,
|
|
||||||
"summary": summary,
|
|
||||||
}
|
|
||||||
if len(_text(raw_summary, PATCH_SUMMARY_MAX_CHARS + 1)) > summary_limit:
|
|
||||||
item["summary_truncated"] = True
|
|
||||||
return item
|
|
||||||
|
|
||||||
|
|
||||||
def _branch_summary(record: Any, index: int) -> Any:
|
|
||||||
if not isinstance(record, dict):
|
|
||||||
return _text(record)
|
|
||||||
return {
|
|
||||||
"name": _text(record.get("name") or record.get("branch") or f"branch-{index}"),
|
|
||||||
"ref": _text(record.get("ref") or record.get("name") or record.get("branch")) or None,
|
|
||||||
"oid": _text(record.get("oid") or record.get("commit_oid") or record.get("commit"), 128) or None,
|
|
||||||
"updated_at": _text(record.get("updated_at") or record.get("updatedAt")) or None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _encode_cursor(key: tuple[bool, str, str, int]) -> str:
|
|
||||||
raw = json.dumps(
|
|
||||||
{"has_timestamp": key[0], "timestamp": key[1], "patch_id": key[2], "index": key[3]},
|
|
||||||
separators=(",", ":"),
|
|
||||||
).encode("utf-8")
|
|
||||||
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
|
||||||
|
|
||||||
|
|
||||||
def _decode_cursor(value: Any) -> tuple[bool, str, str, int]:
|
|
||||||
if not isinstance(value, str) or not value or len(value) > 256 or re.fullmatch(r"[A-Za-z0-9_-]+", value) is None:
|
|
||||||
raise ValueError("invalid_cursor")
|
|
||||||
try:
|
|
||||||
padded = value + "=" * (-len(value) % 4)
|
|
||||||
decoded = json.loads(base64.urlsafe_b64decode(padded.encode("ascii")).decode("utf-8"))
|
|
||||||
except Exception as exc:
|
|
||||||
raise ValueError("invalid_cursor") from exc
|
|
||||||
if (
|
|
||||||
not isinstance(decoded, dict)
|
|
||||||
or set(decoded) != {"has_timestamp", "timestamp", "patch_id", "index"}
|
|
||||||
or not isinstance(decoded["has_timestamp"], bool)
|
|
||||||
or not isinstance(decoded["timestamp"], str)
|
|
||||||
or not isinstance(decoded["patch_id"], str)
|
|
||||||
or not isinstance(decoded["index"], int)
|
|
||||||
or decoded["index"] < 0
|
|
||||||
):
|
|
||||||
raise ValueError("invalid_cursor")
|
|
||||||
return decoded["has_timestamp"], decoded["timestamp"], decoded["patch_id"], decoded["index"]
|
|
||||||
|
|
||||||
|
|
||||||
def _limit(query: dict[str, list[str]]) -> int:
|
|
||||||
raw = (query.get("limit") or [str(PATCH_DEFAULT_LIMIT)])[0]
|
|
||||||
try:
|
|
||||||
result = int(raw)
|
|
||||||
except (TypeError, ValueError) as exc:
|
|
||||||
raise ValueError("invalid_limit") from exc
|
|
||||||
if not 1 <= result <= PATCH_MAX_LIMIT:
|
|
||||||
raise ValueError("invalid_limit")
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _page(records: list[Any], query: dict[str, list[str]], summary_limit: int = PATCH_SUMMARY_MAX_CHARS) -> dict[str, Any]:
|
|
||||||
limit = _limit(query)
|
|
||||||
rows = [
|
|
||||||
(record, index, _record_key(record, index))
|
|
||||||
for index, record in enumerate(records)
|
|
||||||
if isinstance(record, dict)
|
|
||||||
]
|
|
||||||
rows.sort(key=lambda row: row[2], reverse=True)
|
|
||||||
cursor = (query.get("cursor") or [None])[0]
|
|
||||||
if cursor:
|
|
||||||
cursor_key = _decode_cursor(cursor)
|
|
||||||
if cursor_key not in {row[2] for row in rows}:
|
|
||||||
raise ValueError("invalid_cursor")
|
|
||||||
rows = [row for row in rows if row[2] < cursor_key]
|
|
||||||
selected = rows[:limit]
|
|
||||||
return {
|
|
||||||
"items": [_summary(record, index, summary_limit) for record, index, _ in selected],
|
|
||||||
"next_cursor": _encode_cursor(selected[-1][2]) if len(rows) > len(selected) and selected else None,
|
|
||||||
"has_more": len(rows) > len(selected),
|
|
||||||
"count": len(selected),
|
|
||||||
"_cursor_keys": [key for _, _, key in selected],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _bounded_payload(data: dict[str, Any], query: dict[str, list[str]]) -> dict[str, Any]:
|
|
||||||
records = data.get("metadata_patches")
|
|
||||||
if not isinstance(records, list):
|
|
||||||
records = data.get("patches") if isinstance(data.get("patches"), list) else []
|
|
||||||
page = _page(records, query)
|
|
||||||
cursor_keys = page.pop("_cursor_keys")
|
|
||||||
payload = {
|
|
||||||
key: data[key]
|
|
||||||
for key in ("repository_available", "query_error")
|
|
||||||
if key in data
|
|
||||||
}
|
|
||||||
payload["metadata_patches"] = page["items"]
|
|
||||||
payload["patches"] = page["items"]
|
|
||||||
for key in ("orphan_metadata", "branches_without_metadata"):
|
|
||||||
values = data.get(key)
|
|
||||||
payload[key] = [
|
|
||||||
_summary(value, index, PATCH_SUMMARY_MAX_CHARS)
|
|
||||||
if isinstance(value, dict)
|
|
||||||
else _text(value)
|
|
||||||
for index, value in enumerate(values[:PATCH_COMPAT_LIST_LIMIT])
|
|
||||||
] if isinstance(values, list) else []
|
|
||||||
branches = data.get("authoritative_branches")
|
|
||||||
if isinstance(branches, list):
|
|
||||||
payload["authoritative_branches"] = [
|
|
||||||
_branch_summary(value, index) for index, value in enumerate(branches[:PATCH_COMPAT_LIST_LIMIT])
|
|
||||||
]
|
|
||||||
if isinstance(data.get("branches"), list):
|
|
||||||
payload["branches"] = [_text(value) for value in data["branches"][:PATCH_COMPAT_LIST_LIMIT]]
|
|
||||||
payload.update(page)
|
|
||||||
payload["list_patches_overlay"] = "bounded-summary-v1"
|
|
||||||
|
|
||||||
while len(json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8")) > PATCH_RESPONSE_BUDGET and payload["items"]:
|
|
||||||
payload["items"] = payload["items"][:-1]
|
|
||||||
cursor_keys = cursor_keys[:-1]
|
|
||||||
payload["metadata_patches"] = payload["items"]
|
|
||||||
payload["patches"] = payload["items"]
|
|
||||||
payload["count"] = len(payload["items"])
|
|
||||||
payload["has_more"] = True
|
|
||||||
payload["next_cursor"] = (
|
|
||||||
_encode_cursor(cursor_keys[-1])
|
|
||||||
if cursor_keys
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
return payload
|
|
||||||
|
|
||||||
|
|
||||||
class ListPatchesOverlayHandler(base.H):
|
|
||||||
"""Delegate all behavior except bounded serialization of patch reads."""
|
|
||||||
|
|
||||||
def sendj(self, data: Any, code: int = 200):
|
|
||||||
path = urllib.parse.urlparse(self.path).path.rstrip("/")
|
|
||||||
if path in PATCH_PATHS and code in (200, 503) and isinstance(data, dict):
|
|
||||||
try:
|
|
||||||
data = _bounded_payload(data, urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query))
|
|
||||||
except ValueError as exc:
|
|
||||||
return super().sendj(
|
|
||||||
{"error": str(exc), "min_limit": 1, "max_limit": PATCH_MAX_LIMIT},
|
|
||||||
400,
|
|
||||||
)
|
|
||||||
return super().sendj(data, code)
|
|
||||||
|
|
||||||
|
|
||||||
base.H = ListPatchesOverlayHandler
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
base.main()
|
|
||||||
@@ -0,0 +1,397 @@
|
|||||||
|
"""Canonical governed execution projection for Online Agent Tasks.
|
||||||
|
|
||||||
|
The durable governed binding is authoritative. Natural-language task fields and
|
||||||
|
worker configuration are never allowed to invent repository, ref, generation, or
|
||||||
|
idempotency coordinates.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Mapping, Sequence
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
|
||||||
|
EXECUTION_SCHEMA = "aios.online-agent.execution.v2"
|
||||||
|
PROJECTION_SCHEMA = "aios.online-agent.task-projection.v1"
|
||||||
|
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||||
|
TASK_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{2,179}$")
|
||||||
|
BRANCH_RE = re.compile(r"^agent/[A-Za-z0-9][A-Za-z0-9._/-]{2,179}$")
|
||||||
|
KEY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{7,159}$")
|
||||||
|
PROTECTED_PREFIXES = ("main", "master", "refs/", "tags/", "tag/", "release/", "baseline/", "production/")
|
||||||
|
MUTATION_ACTIONS = frozenset({"deterministic"})
|
||||||
|
SUPPORTED_ACTIONS = frozenset({"deterministic", "source_capture"})
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutionProjectionError(RuntimeError):
|
||||||
|
"""A canonical fail-closed projection or validation error."""
|
||||||
|
|
||||||
|
|
||||||
|
class OnlineTaskProjectionClient:
|
||||||
|
"""Official HTTP projection client using only a control credential reference."""
|
||||||
|
|
||||||
|
def __init__(self, api_base: str, control_token_path: Path) -> None:
|
||||||
|
self.api_base = api_base.rstrip("/")
|
||||||
|
self.control_token_path = Path(control_token_path)
|
||||||
|
|
||||||
|
def _token(self) -> str:
|
||||||
|
path = self.control_token_path
|
||||||
|
if path.is_symlink() or not path.is_file():
|
||||||
|
_error("ONLINE_TASK_PROJECTION_CONTROL_UNAVAILABLE")
|
||||||
|
value = path.read_text(encoding="utf-8").strip()
|
||||||
|
if len(value) < 32:
|
||||||
|
_error("ONLINE_TASK_PROJECTION_CONTROL_INVALID")
|
||||||
|
return value
|
||||||
|
|
||||||
|
def create(
|
||||||
|
self,
|
||||||
|
projected_task: Mapping[str, Any],
|
||||||
|
*,
|
||||||
|
binding: "GovernedExecutionBinding",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
body = dict(projected_task)
|
||||||
|
payload = _canonical(body).encode("utf-8")
|
||||||
|
request = urllib.request.Request(
|
||||||
|
self.api_base + "/v1/tasks",
|
||||||
|
method="POST",
|
||||||
|
data=payload,
|
||||||
|
headers={
|
||||||
|
"Authorization": "Bearer " + self._token(),
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=15) as response:
|
||||||
|
result = json.loads(response.read().decode("utf-8"))
|
||||||
|
status = response.status
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
raw = exc.read(8192).decode("utf-8", "replace")
|
||||||
|
try:
|
||||||
|
error = json.loads(raw).get("error")
|
||||||
|
except (AttributeError, json.JSONDecodeError):
|
||||||
|
error = "HTTP_ERROR"
|
||||||
|
raise ExecutionProjectionError(f"ONLINE_TASK_PROJECTION_FAILED:{exc.code}:{error}") from exc
|
||||||
|
except (OSError, urllib.error.URLError, TimeoutError) as exc:
|
||||||
|
raise ExecutionProjectionError("ONLINE_TASK_PROJECTION_UNAVAILABLE") from exc
|
||||||
|
if status not in {200, 201} or not isinstance(result, dict):
|
||||||
|
_error("ONLINE_TASK_PROJECTION_RESPONSE_INVALID")
|
||||||
|
if result.get("task_id") != binding.task_id or result.get("status") not in {"QUEUED", "VERIFYING", "SUCCEEDED"}:
|
||||||
|
_error("ONLINE_TASK_PROJECTION_RESPONSE_INVALID")
|
||||||
|
metadata = result.get("metadata")
|
||||||
|
if not isinstance(metadata, Mapping):
|
||||||
|
_error("ONLINE_TASK_PROJECTION_RESPONSE_INVALID")
|
||||||
|
returned_execution = validate_execution_envelope(
|
||||||
|
_object(metadata.get("execution"), "ONLINE_TASK_PROJECTION_RESPONSE_INVALID"),
|
||||||
|
binding=binding,
|
||||||
|
)
|
||||||
|
submitted_metadata = _object(body.get("metadata"), "ONLINE_TASK_PROJECTION_REQUEST_INVALID")
|
||||||
|
submitted_execution = validate_execution_envelope(
|
||||||
|
_object(submitted_metadata.get("execution"), "ONLINE_TASK_PROJECTION_REQUEST_INVALID"),
|
||||||
|
binding=binding,
|
||||||
|
)
|
||||||
|
if _canonical(returned_execution) != _canonical(submitted_execution):
|
||||||
|
_error("ONLINE_TASK_PROJECTION_RESPONSE_BINDING_INVALID")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _error(code: str) -> None:
|
||||||
|
raise ExecutionProjectionError(code)
|
||||||
|
|
||||||
|
|
||||||
|
def _object(value: object, code: str) -> dict[str, Any]:
|
||||||
|
if not isinstance(value, Mapping):
|
||||||
|
_error(code)
|
||||||
|
return dict(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _string(value: object, code: str) -> str:
|
||||||
|
if not isinstance(value, str) or not value.strip():
|
||||||
|
_error(code)
|
||||||
|
return value.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _task_id(value: object) -> str:
|
||||||
|
value = _string(value, "TASK_ID_INVALID")
|
||||||
|
if TASK_RE.fullmatch(value) is None:
|
||||||
|
_error("TASK_ID_INVALID")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _oid(value: object, code: str) -> str:
|
||||||
|
value = _string(value, code)
|
||||||
|
if OID_RE.fullmatch(value) is None:
|
||||||
|
_error(code)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _branch(value: object) -> str:
|
||||||
|
value = _string(value, "TARGET_BRANCH_INVALID")
|
||||||
|
if BRANCH_RE.fullmatch(value) is None or value.startswith(PROTECTED_PREFIXES):
|
||||||
|
_error("TARGET_BRANCH_INVALID")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _generation(value: object) -> int:
|
||||||
|
if not isinstance(value, int) or isinstance(value, bool) or value < 1:
|
||||||
|
_error("GENERATION_INVALID")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical(value: object) -> str:
|
||||||
|
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||||
|
|
||||||
|
|
||||||
|
def _digest(value: object) -> str:
|
||||||
|
return hashlib.sha256(_canonical(value).encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _source_signals(value: object) -> tuple[str, ...]:
|
||||||
|
"""Normalize untrusted natural-language hints before they enter a signed envelope."""
|
||||||
|
if value is None:
|
||||||
|
return ()
|
||||||
|
if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
|
||||||
|
_error("SOURCE_CAPTURE_SIGNALS_INVALID")
|
||||||
|
result: list[str] = []
|
||||||
|
for item in value:
|
||||||
|
if not isinstance(item, str):
|
||||||
|
_error("SOURCE_CAPTURE_SIGNALS_INVALID")
|
||||||
|
text = " ".join(item.split()).strip()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
# Projection may accept long task prose, but execution envelopes stay bounded.
|
||||||
|
text = text[:512].rstrip()
|
||||||
|
if text not in result:
|
||||||
|
result.append(text)
|
||||||
|
if len(result) == 16:
|
||||||
|
break
|
||||||
|
return tuple(result)
|
||||||
|
|
||||||
|
|
||||||
|
def _capabilities(value: object) -> tuple[str, ...]:
|
||||||
|
if value is None:
|
||||||
|
return ()
|
||||||
|
if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
|
||||||
|
_error("CAPABILITY_SCOPE_INVALID")
|
||||||
|
result = tuple(sorted({_string(item, "CAPABILITY_SCOPE_INVALID") for item in value}))
|
||||||
|
if any(len(item) > 120 for item in result):
|
||||||
|
_error("CAPABILITY_SCOPE_INVALID")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class GovernedExecutionBinding:
|
||||||
|
task_id: str
|
||||||
|
job_id: str
|
||||||
|
repository: str
|
||||||
|
base_oid: str
|
||||||
|
expected_branch_oid: str
|
||||||
|
target_branch: str
|
||||||
|
branch_mode: str
|
||||||
|
generation: int
|
||||||
|
idempotency_key: str
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_mapping(cls, value: Mapping[str, Any]) -> "GovernedExecutionBinding":
|
||||||
|
data = dict(value)
|
||||||
|
task_id = _task_id(data.get("task_id"))
|
||||||
|
job_id = _string(data.get("job_id"), "JOB_ID_REQUIRED")
|
||||||
|
if TASK_RE.fullmatch(job_id) is None:
|
||||||
|
_error("JOB_ID_INVALID")
|
||||||
|
repository = _string(data.get("repository"), "REPOSITORY_INVALID")
|
||||||
|
if repository != "aios/aios":
|
||||||
|
_error("WRONG_REPOSITORY_ENVELOPE_DENY")
|
||||||
|
base_oid = _oid(data.get("base_oid"), "BASE_OID_INVALID")
|
||||||
|
expected = _oid(data.get("expected_branch_oid"), "EXPECTED_BRANCH_OID_INVALID")
|
||||||
|
target = _branch(data.get("target_branch"))
|
||||||
|
mode = _string(data.get("branch_mode"), "BRANCH_MODE_MISSING")
|
||||||
|
if mode not in {"CREATE_NEW", "CONTINUE_EXISTING"}:
|
||||||
|
_error("BRANCH_MODE_INVALID")
|
||||||
|
generation = _generation(data.get("generation"))
|
||||||
|
if mode == "CREATE_NEW" and expected != base_oid:
|
||||||
|
_error("EXPECTED_BASE_CONFLICT")
|
||||||
|
key = _string(data.get("idempotency_key"), "IDEMPOTENCY_KEY_INVALID")
|
||||||
|
if KEY_RE.fullmatch(key) is None:
|
||||||
|
_error("IDEMPOTENCY_KEY_INVALID")
|
||||||
|
return cls(task_id, job_id, repository, base_oid, expected, target, mode, generation, key)
|
||||||
|
|
||||||
|
def public(self) -> dict[str, Any]:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
|
||||||
|
def binding_from_bootstrap(value: Mapping[str, Any]) -> GovernedExecutionBinding:
|
||||||
|
return GovernedExecutionBinding.from_mapping(value)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_execution_envelope(
|
||||||
|
value: Mapping[str, Any],
|
||||||
|
*,
|
||||||
|
binding: GovernedExecutionBinding | None = None,
|
||||||
|
worker_capabilities: Sequence[str] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
envelope = dict(value)
|
||||||
|
if envelope.get("schema") != EXECUTION_SCHEMA:
|
||||||
|
_error("EXECUTION_ENVELOPE_SCHEMA_VALIDATION")
|
||||||
|
candidate = GovernedExecutionBinding.from_mapping(envelope)
|
||||||
|
if binding is not None and candidate != binding:
|
||||||
|
comparisons = (
|
||||||
|
(candidate.task_id, binding.task_id, "TASK_BINDING_CONFLICT"),
|
||||||
|
(candidate.job_id, binding.job_id, "JOB_BINDING_CONFLICT"),
|
||||||
|
(candidate.repository, binding.repository, "WRONG_REPOSITORY_ENVELOPE_DENY"),
|
||||||
|
(candidate.base_oid, binding.base_oid, "WRONG_BASE_ENVELOPE_DENY"),
|
||||||
|
(candidate.expected_branch_oid, binding.expected_branch_oid, "WRONG_EXPECTED_HEAD_ENVELOPE_DENY"),
|
||||||
|
(candidate.target_branch, binding.target_branch, "WRONG_BRANCH_ENVELOPE_DENY"),
|
||||||
|
(candidate.generation, binding.generation, "WRONG_GENERATION_ENVELOPE_DENY"),
|
||||||
|
(candidate.branch_mode, binding.branch_mode, "BRANCH_MODE_BINDING_CONFLICT"),
|
||||||
|
(candidate.idempotency_key, binding.idempotency_key, "IDEMPOTENCY_BINDING_CONFLICT"),
|
||||||
|
)
|
||||||
|
for actual, expected, code in comparisons:
|
||||||
|
if actual != expected:
|
||||||
|
_error(code)
|
||||||
|
action = _string(envelope.get("action"), "EXECUTION_ACTION_MISSING")
|
||||||
|
if action not in SUPPORTED_ACTIONS:
|
||||||
|
_error("EXECUTION_ACTION_UNSUPPORTED")
|
||||||
|
capability_scope = _capabilities(envelope.get("capability_scope"))
|
||||||
|
if worker_capabilities is not None and not set(capability_scope).issubset(set(worker_capabilities)):
|
||||||
|
_error("CAPABILITY_MISMATCH_DENY")
|
||||||
|
workspace = _object(envelope.get("workspace"), "WORKSPACE_POLICY_REQUIRED")
|
||||||
|
if workspace.get("authority_git_dir") != "/data/aios-gitops/repo.git" or workspace.get("read_only_repository") is not True:
|
||||||
|
_error("WORKSPACE_POLICY_DENY")
|
||||||
|
governance = _object(envelope.get("governance"), "GOVERNANCE_POLICY_REQUIRED")
|
||||||
|
governed_coordinates = (
|
||||||
|
(governance.get("task_id"), candidate.task_id, "TASK_BINDING_CONFLICT"),
|
||||||
|
(governance.get("job_id"), candidate.job_id, "JOB_BINDING_CONFLICT"),
|
||||||
|
(governance.get("idempotency_key"), candidate.idempotency_key, "IDEMPOTENCY_BINDING_CONFLICT"),
|
||||||
|
)
|
||||||
|
for actual_coordinate, expected_coordinate, code in governed_coordinates:
|
||||||
|
if actual_coordinate != expected_coordinate:
|
||||||
|
_error(code)
|
||||||
|
commit_key = _string(governance.get("commit_idempotency_key"), "COMMIT_IDEMPOTENCY_KEY_INVALID")
|
||||||
|
if KEY_RE.fullmatch(commit_key) is None:
|
||||||
|
_error("COMMIT_IDEMPOTENCY_KEY_INVALID")
|
||||||
|
if governance.get("writer_lease") is not (action in MUTATION_ACTIONS):
|
||||||
|
_error("WRITER_LEASE_POLICY_DENY")
|
||||||
|
for field in ("main_write", "production_write", "tag_write", "baseline_write", "force_push", "protected_ref_bypass"):
|
||||||
|
if governance.get(field, False) is not False:
|
||||||
|
_error("POLICY_DENY")
|
||||||
|
changes = envelope.get("change_set")
|
||||||
|
verification = envelope.get("verification")
|
||||||
|
if not isinstance(changes, list) or not isinstance(verification, list):
|
||||||
|
_error("EXECUTION_OPERATIONS_INVALID")
|
||||||
|
if action == "source_capture":
|
||||||
|
if changes or verification:
|
||||||
|
_error("SOURCE_CAPTURE_MUTATION_DENY")
|
||||||
|
signals = list(_source_signals(envelope.get("source_signals") or []))
|
||||||
|
if envelope.get("source_signals") != signals:
|
||||||
|
_error("SOURCE_CAPTURE_SIGNALS_INVALID")
|
||||||
|
elif not changes:
|
||||||
|
_error("CHANGE_SET_INVALID")
|
||||||
|
declared = str(envelope.get("execution_digest") or "")
|
||||||
|
material = {key: item for key, item in envelope.items() if key != "execution_digest"}
|
||||||
|
actual = _digest(material)
|
||||||
|
if declared != actual:
|
||||||
|
_error("EXECUTION_DIGEST_INVALID")
|
||||||
|
return {**material, "execution_digest": actual}
|
||||||
|
|
||||||
|
|
||||||
|
def project_execution_envelope(
|
||||||
|
binding: GovernedExecutionBinding,
|
||||||
|
execution_request: Mapping[str, Any] | None = None,
|
||||||
|
*,
|
||||||
|
required_capabilities: Sequence[str] = (),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
request = dict(execution_request or {})
|
||||||
|
action = str(request.get("action") or "source_capture")
|
||||||
|
if action not in SUPPORTED_ACTIONS:
|
||||||
|
_error("EXECUTION_ACTION_UNSUPPORTED")
|
||||||
|
capabilities = _capabilities(request.get("capability_scope", required_capabilities))
|
||||||
|
governance = {
|
||||||
|
"task_id": binding.task_id,
|
||||||
|
"job_id": binding.job_id,
|
||||||
|
"idempotency_key": binding.idempotency_key,
|
||||||
|
"commit_idempotency_key": str(request.get("commit_idempotency_key") or f"{binding.task_id}-commit-g{binding.generation}"),
|
||||||
|
"writer_lease": action in MUTATION_ACTIONS,
|
||||||
|
"main_write": False,
|
||||||
|
"production_write": False,
|
||||||
|
"tag_write": False,
|
||||||
|
"baseline_write": False,
|
||||||
|
"force_push": False,
|
||||||
|
"protected_ref_bypass": False,
|
||||||
|
}
|
||||||
|
supplied_governance = request.get("governance")
|
||||||
|
if supplied_governance is not None:
|
||||||
|
governance.update(_object(supplied_governance, "GOVERNANCE_POLICY_REQUIRED"))
|
||||||
|
material: dict[str, Any] = {
|
||||||
|
"schema": EXECUTION_SCHEMA,
|
||||||
|
**binding.public(),
|
||||||
|
"action": action,
|
||||||
|
"adapter": str(request.get("adapter") or ("deterministic" if action == "deterministic" else "source_capture")),
|
||||||
|
"capability_scope": list(capabilities),
|
||||||
|
"change_set": list(request.get("change_set") or []),
|
||||||
|
"verification": list(request.get("verification") or []),
|
||||||
|
"source_signals": list(_source_signals(request.get("source_signals") or [])) if action == "source_capture" else [],
|
||||||
|
"governance": governance,
|
||||||
|
"workspace": {
|
||||||
|
"authority_git_dir": "/data/aios-gitops/repo.git",
|
||||||
|
"read_only_repository": True,
|
||||||
|
"cleanup_policy": "always",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
envelope = {**material, "execution_digest": _digest(material)}
|
||||||
|
return validate_execution_envelope(envelope, binding=binding)
|
||||||
|
|
||||||
|
|
||||||
|
def project_online_task(
|
||||||
|
binding: GovernedExecutionBinding,
|
||||||
|
task_request: Mapping[str, Any],
|
||||||
|
execution_request: Mapping[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
request = dict(task_request)
|
||||||
|
required = _capabilities(request.get("required_capabilities"))
|
||||||
|
execution = project_execution_envelope(binding, execution_request, required_capabilities=required)
|
||||||
|
metadata = dict(request.get("metadata") or {})
|
||||||
|
if "execution" in metadata:
|
||||||
|
validate_execution_envelope(_object(metadata["execution"], "EXECUTION_ENVELOPE_INVALID"), binding=binding)
|
||||||
|
if metadata["execution"] != execution:
|
||||||
|
_error("EXECUTION_PROJECTION_CONFLICT")
|
||||||
|
metadata["execution"] = execution
|
||||||
|
metadata["execution_projection"] = {
|
||||||
|
"schema": PROJECTION_SCHEMA,
|
||||||
|
"task_id": binding.task_id,
|
||||||
|
"job_id": binding.job_id,
|
||||||
|
"execution_digest": execution["execution_digest"],
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
**request,
|
||||||
|
"task_id": binding.task_id,
|
||||||
|
"metadata": metadata,
|
||||||
|
"required_capabilities": list(required),
|
||||||
|
"idempotency_key": str(request.get("idempotency_key") or f"online-task:{binding.idempotency_key}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_task_execution(
|
||||||
|
task: Mapping[str, Any],
|
||||||
|
binding: GovernedExecutionBinding,
|
||||||
|
*,
|
||||||
|
worker_capabilities: Sequence[str] | None = None,
|
||||||
|
) -> tuple[dict[str, Any], str]:
|
||||||
|
metadata = task.get("metadata")
|
||||||
|
if isinstance(metadata, Mapping) and "execution" in metadata:
|
||||||
|
value = validate_execution_envelope(
|
||||||
|
_object(metadata["execution"], "EXECUTION_ENVELOPE_INVALID"),
|
||||||
|
binding=binding,
|
||||||
|
worker_capabilities=worker_capabilities,
|
||||||
|
)
|
||||||
|
return value, "persisted"
|
||||||
|
projected = project_execution_envelope(
|
||||||
|
binding,
|
||||||
|
{"action": "source_capture", "capability_scope": task.get("required_capabilities") or []},
|
||||||
|
required_capabilities=task.get("required_capabilities") or [],
|
||||||
|
)
|
||||||
|
validate_execution_envelope(projected, binding=binding, worker_capabilities=worker_capabilities)
|
||||||
|
return projected, "recovered_projection"
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
"""Durable, authority-bound deterministic execution plans for Online Agent Tasks."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import fcntl
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
from aios_online_agent_execution import (
|
||||||
|
GovernedExecutionBinding,
|
||||||
|
project_execution_envelope,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutionPlanError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical(value: object) -> bytes:
|
||||||
|
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _digest(value: object) -> str:
|
||||||
|
return hashlib.sha256(_canonical(value)).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _task_key(task_id: str) -> str:
|
||||||
|
readable = "".join(ch if ch.isalnum() or ch in "._-" else "-" for ch in task_id).strip("-")[:72] or "task"
|
||||||
|
return readable + "-" + hashlib.sha256(task_id.encode("utf-8")).hexdigest()[:24]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ExecutionPlanRecord:
|
||||||
|
task_id: str
|
||||||
|
job_id: str
|
||||||
|
source_head_oid: str
|
||||||
|
generation: int
|
||||||
|
idempotency_key: str
|
||||||
|
plan_digest: str
|
||||||
|
execution: dict[str, Any]
|
||||||
|
|
||||||
|
def public(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"schema": "aios.online-agent.plan-record.v1",
|
||||||
|
"task_id": self.task_id,
|
||||||
|
"job_id": self.job_id,
|
||||||
|
"source_head_oid": self.source_head_oid,
|
||||||
|
"generation": self.generation,
|
||||||
|
"idempotency_key": self.idempotency_key,
|
||||||
|
"plan_digest": self.plan_digest,
|
||||||
|
"execution": dict(self.execution),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutionPlanStore:
|
||||||
|
def __init__(self, root: Path) -> None:
|
||||||
|
self.root = Path(root)
|
||||||
|
self.root.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def _path(self, task_id: str) -> Path:
|
||||||
|
return self.root / (_task_key(task_id) + ".json")
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _lock(self, task_id: str):
|
||||||
|
path = self.root / (_task_key(task_id) + ".lock")
|
||||||
|
with path.open("a+b") as handle:
|
||||||
|
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _source_head(binding: GovernedExecutionBinding) -> str:
|
||||||
|
return binding.expected_branch_oid if binding.branch_mode == "CONTINUE_EXISTING" else binding.base_oid
|
||||||
|
|
||||||
|
def _read(self, task_id: str) -> ExecutionPlanRecord | None:
|
||||||
|
path = self._path(task_id)
|
||||||
|
if not path.is_file() or path.is_symlink():
|
||||||
|
return None
|
||||||
|
value = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
if not isinstance(value, Mapping) or value.get("schema") != "aios.online-agent.plan-record.v1":
|
||||||
|
raise ExecutionPlanError("EXECUTION_PLAN_RECORD_INVALID")
|
||||||
|
execution = value.get("execution")
|
||||||
|
if not isinstance(execution, Mapping):
|
||||||
|
raise ExecutionPlanError("EXECUTION_PLAN_RECORD_INVALID")
|
||||||
|
return ExecutionPlanRecord(
|
||||||
|
task_id=str(value.get("task_id") or ""),
|
||||||
|
job_id=str(value.get("job_id") or ""),
|
||||||
|
source_head_oid=str(value.get("source_head_oid") or ""),
|
||||||
|
generation=int(value.get("generation") or 0),
|
||||||
|
idempotency_key=str(value.get("idempotency_key") or ""),
|
||||||
|
plan_digest=str(value.get("plan_digest") or ""),
|
||||||
|
execution=dict(execution),
|
||||||
|
)
|
||||||
|
|
||||||
|
def register(
|
||||||
|
self,
|
||||||
|
binding: GovernedExecutionBinding,
|
||||||
|
request: Mapping[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
with self._lock(binding.task_id):
|
||||||
|
return self._register_locked(binding, request)
|
||||||
|
|
||||||
|
def _register_locked(
|
||||||
|
self,
|
||||||
|
binding: GovernedExecutionBinding,
|
||||||
|
request: Mapping[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if request.get("schema") != "aios.online-agent.plan.v1":
|
||||||
|
raise ExecutionPlanError("EXECUTION_PLAN_SCHEMA_INVALID")
|
||||||
|
if request.get("task_id") != binding.task_id or request.get("job_id") != binding.job_id:
|
||||||
|
raise ExecutionPlanError("EXECUTION_PLAN_BINDING_CONFLICT")
|
||||||
|
if int(request.get("generation") or 0) != binding.generation:
|
||||||
|
raise ExecutionPlanError("EXECUTION_PLAN_GENERATION_CONFLICT")
|
||||||
|
source_head = str(request.get("source_head_oid") or "")
|
||||||
|
if source_head != self._source_head(binding):
|
||||||
|
raise ExecutionPlanError("EXECUTION_PLAN_SOURCE_HEAD_CONFLICT")
|
||||||
|
key = str(request.get("idempotency_key") or "")
|
||||||
|
if len(key) < 8:
|
||||||
|
raise ExecutionPlanError("EXECUTION_PLAN_IDEMPOTENCY_INVALID")
|
||||||
|
execution_request = request.get("execution")
|
||||||
|
if not isinstance(execution_request, Mapping) or execution_request.get("action") != "deterministic":
|
||||||
|
raise ExecutionPlanError("EXECUTION_PLAN_DETERMINISTIC_REQUIRED")
|
||||||
|
envelope = project_execution_envelope(binding, execution_request)
|
||||||
|
material = {
|
||||||
|
"task_id": binding.task_id,
|
||||||
|
"job_id": binding.job_id,
|
||||||
|
"source_head_oid": source_head,
|
||||||
|
"generation": binding.generation,
|
||||||
|
"idempotency_key": key,
|
||||||
|
"execution": dict(execution_request),
|
||||||
|
}
|
||||||
|
plan_digest = _digest(material)
|
||||||
|
existing = self._read(binding.task_id)
|
||||||
|
expected_previous = request.get("expected_previous_plan_digest")
|
||||||
|
if existing is not None:
|
||||||
|
if existing.idempotency_key == key:
|
||||||
|
if existing.plan_digest != plan_digest:
|
||||||
|
raise ExecutionPlanError("EXECUTION_PLAN_IDEMPOTENCY_CONFLICT")
|
||||||
|
return {**existing.public(), "idempotent": True, "execution_digest": envelope["execution_digest"]}
|
||||||
|
if expected_previous != existing.plan_digest:
|
||||||
|
raise ExecutionPlanError("EXECUTION_PLAN_REPLACEMENT_CAS_MISMATCH")
|
||||||
|
if existing.source_head_oid != source_head:
|
||||||
|
raise ExecutionPlanError("EXECUTION_PLAN_STALE_REPLACEMENT_DENY")
|
||||||
|
elif expected_previous not in (None, ""):
|
||||||
|
raise ExecutionPlanError("EXECUTION_PLAN_REPLACEMENT_CAS_MISMATCH")
|
||||||
|
|
||||||
|
record = ExecutionPlanRecord(
|
||||||
|
task_id=binding.task_id,
|
||||||
|
job_id=binding.job_id,
|
||||||
|
source_head_oid=source_head,
|
||||||
|
generation=binding.generation,
|
||||||
|
idempotency_key=key,
|
||||||
|
plan_digest=plan_digest,
|
||||||
|
execution=dict(execution_request),
|
||||||
|
)
|
||||||
|
path = self._path(binding.task_id)
|
||||||
|
temporary = path.with_suffix(".tmp")
|
||||||
|
temporary.write_text(
|
||||||
|
json.dumps(record.public(), sort_keys=True, separators=(",", ":")),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
temporary.replace(path)
|
||||||
|
return {**record.public(), "idempotent": False, "execution_digest": envelope["execution_digest"]}
|
||||||
|
|
||||||
|
def resolve(self, binding: GovernedExecutionBinding) -> dict[str, Any] | None:
|
||||||
|
record = self._read(binding.task_id)
|
||||||
|
if record is None:
|
||||||
|
return None
|
||||||
|
if (
|
||||||
|
record.task_id != binding.task_id
|
||||||
|
or record.job_id != binding.job_id
|
||||||
|
or record.generation != binding.generation
|
||||||
|
):
|
||||||
|
raise ExecutionPlanError("EXECUTION_PLAN_BINDING_CONFLICT")
|
||||||
|
if record.source_head_oid != self._source_head(binding):
|
||||||
|
raise ExecutionPlanError("EXECUTION_PLAN_SOURCE_HEAD_DRIFT")
|
||||||
|
envelope = project_execution_envelope(binding, record.execution)
|
||||||
|
return {
|
||||||
|
"execution": dict(record.execution),
|
||||||
|
"plan_digest": record.plan_digest,
|
||||||
|
"execution_digest": envelope["execution_digest"],
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,6 @@ from __future__ import annotations
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Iterable
|
from typing import Iterable
|
||||||
@@ -307,76 +306,14 @@ def authoritative_diff(
|
|||||||
from_oid = validate_git_revision(from_revision, "from", git_dir=path)
|
from_oid = validate_git_revision(from_revision, "from", git_dir=path)
|
||||||
to_oid = validate_git_revision(to_revision, "to", git_dir=path)
|
to_oid = validate_git_revision(to_revision, "to", git_dir=path)
|
||||||
args = ["diff", "--no-ext-diff", "--no-textconv", from_oid, to_oid]
|
args = ["diff", "--no-ext-diff", "--no-textconv", from_oid, to_oid]
|
||||||
patch_limit = max(1, min(_MAX_OUTPUT, 256 * 1024))
|
result = run_authoritative_git(args, git_dir=path)
|
||||||
process = subprocess.Popen(
|
|
||||||
["git", "--git-dir", str(path), *args],
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.PIPE,
|
|
||||||
env={**os.environ, "GIT_OPTIONAL_LOCKS": "0"},
|
|
||||||
)
|
|
||||||
chunks: list[bytes] = []
|
|
||||||
stored_bytes = 0
|
|
||||||
total_bytes = 0
|
|
||||||
started = time.monotonic()
|
|
||||||
try:
|
|
||||||
while process.stdout is not None:
|
|
||||||
chunk = process.stdout.read(64 * 1024)
|
|
||||||
if not chunk:
|
|
||||||
break
|
|
||||||
total_bytes += len(chunk)
|
|
||||||
if stored_bytes < patch_limit:
|
|
||||||
remaining = patch_limit - stored_bytes
|
|
||||||
selected = chunk[:remaining]
|
|
||||||
chunks.append(selected)
|
|
||||||
stored_bytes += len(selected)
|
|
||||||
if time.monotonic() - started > _TIMEOUT:
|
|
||||||
process.kill()
|
|
||||||
raise AuthoritativeGitError("GIT_REF_QUERY_TIMEOUT")
|
|
||||||
stderr = (
|
|
||||||
process.stderr.read(8192).decode("utf-8", errors="replace")
|
|
||||||
if process.stderr is not None
|
|
||||||
else ""
|
|
||||||
)
|
|
||||||
returncode = process.wait(timeout=max(0.1, _TIMEOUT - (time.monotonic() - started)))
|
|
||||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
||||||
process.kill()
|
|
||||||
process.wait()
|
|
||||||
raise AuthoritativeGitError("GIT_REF_QUERY_FAILED") from exc
|
|
||||||
if returncode != 0:
|
|
||||||
raise AuthoritativeGitError(_redact(stderr.strip() or "GIT_REF_QUERY_FAILED")[:200])
|
|
||||||
try:
|
|
||||||
names_result = run_authoritative_git(
|
|
||||||
["diff", "--no-ext-diff", "--no-textconv", "--name-status", from_oid, to_oid],
|
|
||||||
git_dir=path,
|
|
||||||
)
|
|
||||||
changed_paths = [
|
|
||||||
line.split("\t", 1)[-1]
|
|
||||||
for line in names_result.stdout.splitlines()
|
|
||||||
if line.strip()
|
|
||||||
]
|
|
||||||
except AuthoritativeGitOutputTooLarge:
|
|
||||||
changed_paths = []
|
|
||||||
try:
|
|
||||||
stats_result = run_authoritative_git(
|
|
||||||
["diff", "--no-ext-diff", "--no-textconv", "--stat", from_oid, to_oid],
|
|
||||||
git_dir=path,
|
|
||||||
)
|
|
||||||
stats = stats_result.stdout.strip()
|
|
||||||
except AuthoritativeGitOutputTooLarge:
|
|
||||||
stats = "GIT_STAT_OUTPUT_TRUNCATED"
|
|
||||||
patch = b"".join(chunks).decode("utf-8", errors="replace")
|
|
||||||
return {
|
return {
|
||||||
"code": 0,
|
"code": 0,
|
||||||
"stdout": patch,
|
"stdout": result.stdout,
|
||||||
"stderr": "",
|
"stderr": "",
|
||||||
"cmd": ["git", f"--git-dir={path}", *args],
|
"cmd": ["git", f"--git-dir={path}", *args],
|
||||||
"from_oid": from_oid,
|
"from_oid": from_oid,
|
||||||
"to_oid": to_oid,
|
"to_oid": to_oid,
|
||||||
"changed_paths": changed_paths,
|
|
||||||
"stats": stats,
|
|
||||||
"total_size_bytes": total_bytes,
|
|
||||||
"truncated": total_bytes > patch_limit,
|
|
||||||
"patch_limit_bytes": patch_limit,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -964,6 +964,7 @@ def validate_writer_lease_request_v2(raw: Mapping[str, Any], probe: Any) -> dict
|
|||||||
required = {
|
required = {
|
||||||
"schema",
|
"schema",
|
||||||
"task_id",
|
"task_id",
|
||||||
|
"job_id",
|
||||||
"repository",
|
"repository",
|
||||||
"branch_mode",
|
"branch_mode",
|
||||||
"base_oid",
|
"base_oid",
|
||||||
@@ -976,6 +977,7 @@ def validate_writer_lease_request_v2(raw: Mapping[str, Any], probe: Any) -> dict
|
|||||||
raise ProvisioningError("LEASE_REQUEST_V2_FIELDS_REJECTED", 409)
|
raise ProvisioningError("LEASE_REQUEST_V2_FIELDS_REJECTED", 409)
|
||||||
request = WriterLeaseRequestV2(
|
request = WriterLeaseRequestV2(
|
||||||
task_id=str(raw["task_id"]),
|
task_id=str(raw["task_id"]),
|
||||||
|
job_id=str(raw["job_id"]),
|
||||||
repository=str(raw["repository"]),
|
repository=str(raw["repository"]),
|
||||||
branch_mode=str(raw["branch_mode"]),
|
branch_mode=str(raw["branch_mode"]),
|
||||||
base_oid=str(raw["base_oid"]),
|
base_oid=str(raw["base_oid"]),
|
||||||
@@ -988,6 +990,7 @@ def validate_writer_lease_request_v2(raw: Mapping[str, Any], probe: Any) -> dict
|
|||||||
return {
|
return {
|
||||||
"schema": WriterLeaseRequestV2.schema,
|
"schema": WriterLeaseRequestV2.schema,
|
||||||
"task_id": request.task_id,
|
"task_id": request.task_id,
|
||||||
|
"job_id": request.job_id,
|
||||||
"repository": request.repository,
|
"repository": request.repository,
|
||||||
"branch_mode": request.branch_mode,
|
"branch_mode": request.branch_mode,
|
||||||
"base_oid": request.base_oid,
|
"base_oid": request.base_oid,
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import json
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
@@ -24,15 +23,10 @@ try:
|
|||||||
from authoritative_git import AuthoritativeGitError, run_authoritative_git
|
from authoritative_git import AuthoritativeGitError, run_authoritative_git
|
||||||
except ModuleNotFoundError:
|
except ModuleNotFoundError:
|
||||||
from .authoritative_git import AuthoritativeGitError, run_authoritative_git
|
from .authoritative_git import AuthoritativeGitError, run_authoritative_git
|
||||||
try:
|
|
||||||
from release_version import RELEASE_VERSION_RE, validate_release_version
|
|
||||||
except ModuleNotFoundError:
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
|
||||||
from release_version import RELEASE_VERSION_RE, validate_release_version
|
|
||||||
|
|
||||||
|
|
||||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||||
VERSION_RE = RELEASE_VERSION_RE
|
VERSION_RE = re.compile(r"^V\d+R\d+C\d+(?:-[A-Za-z0-9_.-]+)?$")
|
||||||
DEPLOYMENT_EVIDENCE_RE = re.compile(r"^(?:sha256:[0-9a-f]{64}|git:[0-9a-f]{40})$")
|
DEPLOYMENT_EVIDENCE_RE = re.compile(r"^(?:sha256:[0-9a-f]{64}|git:[0-9a-f]{40})$")
|
||||||
FINGERPRINT_FIELDS = (
|
FINGERPRINT_FIELDS = (
|
||||||
"version",
|
"version",
|
||||||
@@ -89,14 +83,9 @@ class PublicationManualOwnerReviewRequired(
|
|||||||
|
|
||||||
|
|
||||||
def validate_version(version: str) -> str:
|
def validate_version(version: str) -> str:
|
||||||
return validate_release_version(version)
|
value = str(version or "")
|
||||||
|
if not VERSION_RE.fullmatch(value):
|
||||||
|
raise ValueError("invalid release version")
|
||||||
def validate_gitea_api_url(api_url: str) -> str:
|
|
||||||
value = str(api_url or "").strip().rstrip("/")
|
|
||||||
parsed = urllib.parse.urlsplit(value)
|
|
||||||
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
|
||||||
raise ValueError("Gitea API URL must be HTTP(S)")
|
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
@@ -422,11 +411,13 @@ class GiteaTagClient:
|
|||||||
timeout: float = 15,
|
timeout: float = 15,
|
||||||
token_line: int = 1,
|
token_line: int = 1,
|
||||||
):
|
):
|
||||||
self.api_url = validate_gitea_api_url(api_url)
|
self.api_url = api_url.rstrip("/")
|
||||||
self.repository = repository.strip("/")
|
self.repository = repository.strip("/")
|
||||||
self.token_file = token_file
|
self.token_file = token_file
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
self.token_line = token_line
|
self.token_line = token_line
|
||||||
|
if not self.api_url.startswith(("https://", "http://")):
|
||||||
|
raise ValueError("Gitea API URL must be HTTP(S)")
|
||||||
if self.repository.count("/") != 1:
|
if self.repository.count("/") != 1:
|
||||||
raise ValueError("Gitea repository must be owner/name")
|
raise ValueError("Gitea repository must be owner/name")
|
||||||
|
|
||||||
|
|||||||
@@ -406,14 +406,6 @@ class GovernedProductionReleaseExecutor:
|
|||||||
config.remote_name,
|
config.remote_name,
|
||||||
state_root=self.root / "tag-publication",
|
state_root=self.root / "tag-publication",
|
||||||
)
|
)
|
||||||
# Release checks must use the same explicitly configured staging baseline
|
|
||||||
# as the Action/model runtime; do not fall back to the publication workspace.
|
|
||||||
self.publisher.baseline_path = Path(
|
|
||||||
os.environ.get("AIOS_GITOPS_BASELINE_AUTHORITY", str(config.baseline_path))
|
|
||||||
)
|
|
||||||
# The immutable runtime package is the source of the version projection;
|
|
||||||
# the mutable state workspace only stores publication state.
|
|
||||||
self.publisher.versions_root = Path(__file__).resolve().parents[1] / "versions"
|
|
||||||
|
|
||||||
def _request(self, request: Mapping[str, Any]) -> PublicationRequest:
|
def _request(self, request: Mapping[str, Any]) -> PublicationRequest:
|
||||||
binding = self.binding
|
binding = self.binding
|
||||||
@@ -519,11 +511,7 @@ class GovernedProductionReleaseExecutor:
|
|||||||
|
|
||||||
def acceptance(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
def acceptance(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
||||||
result = self.consistency(request)
|
result = self.consistency(request)
|
||||||
result["accepted"] = (
|
result["accepted"] = result.get("status") == "PASS" and result.get("production_health", {}).get("status") == "ACTIVE_VERIFIED"
|
||||||
result.get("status") == "PASS"
|
|
||||||
and result.get("production_health", {}).get("verification_status")
|
|
||||||
== "ACTIVE_VERIFIED"
|
|
||||||
)
|
|
||||||
self.state.record("ACCEPTED" if result["accepted"] else "RECONCILIATION_REQUIRED", "acceptance", result)
|
self.state.record("ACCEPTED" if result["accepted"] else "RECONCILIATION_REQUIRED", "acceptance", result)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -545,11 +533,7 @@ def _health(url: str, binding: ReleaseBinding, artifact: Mapping[str, Any]) -> d
|
|||||||
body = json.loads(response.read().decode("utf-8"))
|
body = json.loads(response.read().decode("utf-8"))
|
||||||
status = response.status
|
status = response.status
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return {
|
return {"status": "UNAVAILABLE", "error": type(exc).__name__}
|
||||||
"status": "UNAVAILABLE",
|
|
||||||
"verification_status": "UNAVAILABLE",
|
|
||||||
"error": type(exc).__name__,
|
|
||||||
}
|
|
||||||
expected_artifact = artifact.get("artifact_sha256")
|
expected_artifact = artifact.get("artifact_sha256")
|
||||||
exact = (
|
exact = (
|
||||||
status == 200
|
status == 200
|
||||||
@@ -559,11 +543,7 @@ def _health(url: str, binding: ReleaseBinding, artifact: Mapping[str, Any]) -> d
|
|||||||
and body.get("deployment_state") == "ACTIVE"
|
and body.get("deployment_state") == "ACTIVE"
|
||||||
and body.get("production_deployed") is True
|
and body.get("production_deployed") is True
|
||||||
)
|
)
|
||||||
return {
|
return {"status": "ACTIVE_VERIFIED" if exact else "CONFLICT", "http_status": status, **body}
|
||||||
**body,
|
|
||||||
"http_status": status,
|
|
||||||
"verification_status": "ACTIVE_VERIFIED" if exact else "CONFLICT",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def build_executor_config(config: Any) -> GovernedProductionReleaseExecutor:
|
def build_executor_config(config: Any) -> GovernedProductionReleaseExecutor:
|
||||||
|
|||||||
@@ -34,8 +34,6 @@ from gitea_tag_publication import (
|
|||||||
TagPublisher,
|
TagPublisher,
|
||||||
)
|
)
|
||||||
|
|
||||||
from release_version import validate_release_version
|
|
||||||
|
|
||||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||||
PLAN_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
|
PLAN_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
|
||||||
|
|
||||||
@@ -46,10 +44,8 @@ def load_request() -> dict[str, Any]:
|
|||||||
raise ValueError("request_object_required")
|
raise ValueError("request_object_required")
|
||||||
if value.get("schema") != "aios.production-executor-request.v1":
|
if value.get("schema") != "aios.production-executor-request.v1":
|
||||||
raise ValueError("request_schema_invalid")
|
raise ValueError("request_schema_invalid")
|
||||||
try:
|
if not re.fullmatch(r"V\d+R\d+C\d+(?:-[A-Za-z0-9_.-]+)?", str(value.get("version", ""))):
|
||||||
validate_release_version(value.get("version", ""))
|
raise ValueError("version_invalid")
|
||||||
except ValueError as exc:
|
|
||||||
raise ValueError("version_invalid") from exc
|
|
||||||
for name in ("target_oid", "evidence_closure_oid"):
|
for name in ("target_oid", "evidence_closure_oid"):
|
||||||
if not OID_RE.fullmatch(str(value.get(name, ""))):
|
if not OID_RE.fullmatch(str(value.get(name, ""))):
|
||||||
raise ValueError(f"{name}_invalid")
|
raise ValueError(f"{name}_invalid")
|
||||||
|
|||||||
@@ -27,8 +27,6 @@ import urllib.request
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Mapping
|
from typing import Any, Mapping
|
||||||
|
|
||||||
from release_version import validate_release_version
|
|
||||||
|
|
||||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||||
SHA_RE = re.compile(r"^[0-9a-f]{64}$")
|
SHA_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||||
PLAN_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
|
PLAN_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
|
||||||
@@ -88,10 +86,8 @@ def load_request() -> dict[str, Any]:
|
|||||||
raise ValueError("request_schema_invalid")
|
raise ValueError("request_schema_invalid")
|
||||||
if value.get("repository_identity") != "aios/aios":
|
if value.get("repository_identity") != "aios/aios":
|
||||||
raise ValueError("repository_identity_invalid")
|
raise ValueError("repository_identity_invalid")
|
||||||
try:
|
if not re.fullmatch(r"V\d+R\d+C\d+(?:-[A-Za-z0-9_.-]+)?", str(value.get("version", ""))):
|
||||||
validate_release_version(value.get("version", ""))
|
raise ValueError("version_invalid")
|
||||||
except ValueError as exc:
|
|
||||||
raise ValueError("version_invalid") from exc
|
|
||||||
for name in ("target_oid", "evidence_closure_oid"):
|
for name in ("target_oid", "evidence_closure_oid"):
|
||||||
if not OID_RE.fullmatch(str(value.get(name, ""))):
|
if not OID_RE.fullmatch(str(value.get(name, ""))):
|
||||||
raise ValueError(f"{name}_invalid")
|
raise ValueError(f"{name}_invalid")
|
||||||
|
|||||||
@@ -1,184 +0,0 @@
|
|||||||
"""Durable, generation-scoped leases for the staging governed writer."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
import sqlite3
|
|
||||||
from typing import Any, Mapping
|
|
||||||
|
|
||||||
|
|
||||||
def _now() -> datetime:
|
|
||||||
return datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
def _iso(value: datetime) -> str:
|
|
||||||
return value.isoformat()
|
|
||||||
|
|
||||||
|
|
||||||
def _expired(value: str) -> bool:
|
|
||||||
return datetime.fromisoformat(value).astimezone(timezone.utc) <= _now()
|
|
||||||
|
|
||||||
|
|
||||||
class ScopedLeaseError(RuntimeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class ScopedLeaseStore:
|
|
||||||
"""A small SQLite CAS boundary; no repository or filesystem writes."""
|
|
||||||
|
|
||||||
def __init__(self, path: Path) -> None:
|
|
||||||
if not path.is_absolute() or path.is_symlink():
|
|
||||||
raise ScopedLeaseError("LEASE_STORE_INVALID")
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
self.path = path
|
|
||||||
with self._connect() as db:
|
|
||||||
db.executescript(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS scoped_leases (
|
|
||||||
lease_key TEXT PRIMARY KEY,
|
|
||||||
task_id TEXT NOT NULL,
|
|
||||||
repository TEXT NOT NULL,
|
|
||||||
target_branch TEXT NOT NULL,
|
|
||||||
expected_branch_oid TEXT NOT NULL,
|
|
||||||
generation INTEGER NOT NULL,
|
|
||||||
binding_json TEXT NOT NULL,
|
|
||||||
expires_at TEXT NOT NULL,
|
|
||||||
state TEXT NOT NULL,
|
|
||||||
idempotency_key TEXT,
|
|
||||||
result_json TEXT,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
updated_at TEXT NOT NULL
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS scoped_leases_binding
|
|
||||||
ON scoped_leases(task_id, repository, target_branch, generation);
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
def _connect(self) -> sqlite3.Connection:
|
|
||||||
db = sqlite3.connect(self.path, timeout=30.0)
|
|
||||||
db.row_factory = sqlite3.Row
|
|
||||||
db.execute("PRAGMA journal_mode=WAL")
|
|
||||||
db.execute("PRAGMA synchronous=FULL")
|
|
||||||
db.execute("PRAGMA busy_timeout=30000")
|
|
||||||
return db
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def key(request: Mapping[str, Any]) -> str:
|
|
||||||
binding = {
|
|
||||||
field: request[field]
|
|
||||||
for field in (
|
|
||||||
"task_id", "repository", "branch_mode", "base_oid",
|
|
||||||
"expected_branch_oid", "target_branch", "generation",
|
|
||||||
"lease_seconds",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return hashlib.sha256(
|
|
||||||
json.dumps(binding, sort_keys=True, separators=(",", ":")).encode()
|
|
||||||
).hexdigest()
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _row(row: sqlite3.Row) -> dict[str, Any]:
|
|
||||||
value = dict(row)
|
|
||||||
value["binding"] = json.loads(value.pop("binding_json"))
|
|
||||||
if value.get("result_json"):
|
|
||||||
value["result"] = json.loads(value.pop("result_json"))
|
|
||||||
else:
|
|
||||||
value.pop("result_json", None)
|
|
||||||
return value
|
|
||||||
|
|
||||||
def acquire(self, request: Mapping[str, Any]) -> tuple[dict[str, Any], bool]:
|
|
||||||
key = self.key(request)
|
|
||||||
now = _now()
|
|
||||||
expires = now.timestamp() + int(request["lease_seconds"])
|
|
||||||
expires_at = _iso(datetime.fromtimestamp(expires, timezone.utc))
|
|
||||||
binding_json = json.dumps(dict(request), sort_keys=True, separators=(",", ":"))
|
|
||||||
try:
|
|
||||||
with self._connect() as db:
|
|
||||||
db.execute("BEGIN IMMEDIATE")
|
|
||||||
exact = db.execute(
|
|
||||||
"SELECT * FROM scoped_leases WHERE lease_key=?", (key,)
|
|
||||||
).fetchone()
|
|
||||||
if exact is not None:
|
|
||||||
if exact["state"] == "ACTIVE" and _expired(exact["expires_at"]):
|
|
||||||
db.execute(
|
|
||||||
"UPDATE scoped_leases SET state='EXPIRED',updated_at=? WHERE lease_key=?",
|
|
||||||
(_iso(now), key),
|
|
||||||
)
|
|
||||||
exact = db.execute(
|
|
||||||
"SELECT * FROM scoped_leases WHERE lease_key=?", (key,)
|
|
||||||
).fetchone()
|
|
||||||
db.commit()
|
|
||||||
return self._row(exact), False
|
|
||||||
active = db.execute(
|
|
||||||
"SELECT * FROM scoped_leases WHERE task_id=? AND repository=? AND target_branch=? AND state='ACTIVE'",
|
|
||||||
(str(request["task_id"]), str(request["repository"]), str(request["target_branch"])),
|
|
||||||
).fetchall()
|
|
||||||
for row in active:
|
|
||||||
if _expired(row["expires_at"]):
|
|
||||||
db.execute(
|
|
||||||
"UPDATE scoped_leases SET state='EXPIRED',updated_at=? WHERE lease_key=?",
|
|
||||||
(_iso(now), row["lease_key"]),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
raise ScopedLeaseError("LEASE_BINDING_CONFLICT")
|
|
||||||
db.execute(
|
|
||||||
"INSERT INTO scoped_leases(lease_key,task_id,repository,target_branch,expected_branch_oid,generation,binding_json,expires_at,state,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
|
|
||||||
(
|
|
||||||
key, request["task_id"], request["repository"],
|
|
||||||
request["target_branch"], request["expected_branch_oid"],
|
|
||||||
int(request["generation"]), binding_json, expires_at,
|
|
||||||
"ACTIVE", _iso(now), _iso(now),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
row = db.execute(
|
|
||||||
"SELECT * FROM scoped_leases WHERE lease_key=?", (key,)
|
|
||||||
).fetchone()
|
|
||||||
db.commit()
|
|
||||||
return self._row(row), True
|
|
||||||
except ScopedLeaseError:
|
|
||||||
raise
|
|
||||||
except sqlite3.Error as exc:
|
|
||||||
raise ScopedLeaseError("LEASE_STORE_UNAVAILABLE") from exc
|
|
||||||
|
|
||||||
def for_commit(self, request: Mapping[str, Any]) -> dict[str, Any] | None:
|
|
||||||
try:
|
|
||||||
with self._connect() as db:
|
|
||||||
row = db.execute(
|
|
||||||
"SELECT * FROM scoped_leases WHERE task_id=? AND repository=? AND target_branch=? AND expected_branch_oid=? AND generation=? ORDER BY created_at DESC LIMIT 1",
|
|
||||||
(
|
|
||||||
request["task_id"], request["repository"],
|
|
||||||
request["target_branch"], request["expected_branch_oid"],
|
|
||||||
int(request["generation"]),
|
|
||||||
),
|
|
||||||
).fetchone()
|
|
||||||
if row is None:
|
|
||||||
return None
|
|
||||||
if row["state"] == "ACTIVE" and _expired(row["expires_at"]):
|
|
||||||
db.execute(
|
|
||||||
"UPDATE scoped_leases SET state='EXPIRED',updated_at=? WHERE lease_key=?",
|
|
||||||
(_iso(_now()), row["lease_key"]),
|
|
||||||
)
|
|
||||||
row = db.execute(
|
|
||||||
"SELECT * FROM scoped_leases WHERE lease_key=?", (row["lease_key"],)
|
|
||||||
).fetchone()
|
|
||||||
return self._row(row)
|
|
||||||
except sqlite3.Error as exc:
|
|
||||||
raise ScopedLeaseError("LEASE_STORE_UNAVAILABLE") from exc
|
|
||||||
|
|
||||||
def mark_used(self, lease_key: str, idempotency_key: str, result: Mapping[str, Any]) -> None:
|
|
||||||
with self._connect() as db:
|
|
||||||
updated = db.execute(
|
|
||||||
"UPDATE scoped_leases SET state='USED',idempotency_key=?,result_json=?,updated_at=? WHERE lease_key=? AND state='ACTIVE'",
|
|
||||||
(
|
|
||||||
idempotency_key, json.dumps(dict(result), sort_keys=True),
|
|
||||||
_iso(_now()), lease_key,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if updated.rowcount != 1:
|
|
||||||
raise ScopedLeaseError("LEASE_ALREADY_USED")
|
|
||||||
|
|
||||||
def delete(self, lease_key: str) -> None:
|
|
||||||
with self._connect() as db:
|
|
||||||
db.execute("DELETE FROM scoped_leases WHERE lease_key=? AND state='ACTIVE'", (lease_key,))
|
|
||||||
@@ -22,12 +22,11 @@ from enum import Enum
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Protocol
|
from typing import Any, Protocol
|
||||||
|
|
||||||
from release_version import validate_release_version
|
|
||||||
|
|
||||||
|
|
||||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||||
SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
|
SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||||
CANDIDATE_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{2,127}$")
|
CANDIDATE_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{2,127}$")
|
||||||
|
VERSION_RE = re.compile(r"^V\d+R\d+C\d+(?:-[A-Za-z0-9_.-]+)?$")
|
||||||
SECRET_PATTERNS = (
|
SECRET_PATTERNS = (
|
||||||
re.compile(r"(?i)(authorization\s*[:=]\s*)(?:bearer|token)\s+\S+"),
|
re.compile(r"(?i)(authorization\s*[:=]\s*)(?:bearer|token)\s+\S+"),
|
||||||
re.compile(r"(?i)((?:access_)?token\s*[:=]\s*)\S+"),
|
re.compile(r"(?i)((?:access_)?token\s*[:=]\s*)\S+"),
|
||||||
@@ -770,10 +769,8 @@ class TagApprovalEnvelope:
|
|||||||
def validate(self) -> None:
|
def validate(self) -> None:
|
||||||
if self.action != "production_tag":
|
if self.action != "production_tag":
|
||||||
raise ApprovalError("approval does not authorize production tag")
|
raise ApprovalError("approval does not authorize production tag")
|
||||||
try:
|
if not VERSION_RE.fullmatch(self.version):
|
||||||
validate_release_version(self.version)
|
raise ApprovalError("invalid release version")
|
||||||
except ValueError as exc:
|
|
||||||
raise ApprovalError("invalid release version") from exc
|
|
||||||
_validate_oid("tag_oid", self.tag_oid)
|
_validate_oid("tag_oid", self.tag_oid)
|
||||||
_validate_oid("target_oid", self.target_oid)
|
_validate_oid("target_oid", self.target_oid)
|
||||||
if _parse_utc(self.expires_at) <= datetime.now(timezone.utc):
|
if _parse_utc(self.expires_at) <= datetime.now(timezone.utc):
|
||||||
|
|||||||
@@ -3457,19 +3457,6 @@ class SilentReleaseActionService:
|
|||||||
}
|
}
|
||||||
|
|
||||||
def get_release_saga(self, candidate_id: str) -> dict[str, Any]:
|
def get_release_saga(self, candidate_id: str) -> dict[str, Any]:
|
||||||
# A read of a never-created candidate is a normal empty-state. Do
|
|
||||||
# not turn a missing journal into a durable manual-review snapshot.
|
|
||||||
saga_path = self.sagas._path(candidate_id)
|
|
||||||
journal_path = self.sagas._journal(candidate_id)
|
|
||||||
if not saga_path.exists() and not journal_path.exists():
|
|
||||||
return {
|
|
||||||
"candidate_id": candidate_id,
|
|
||||||
"status": "NOT_FOUND",
|
|
||||||
"state": "NOT_FOUND",
|
|
||||||
"execution_enabled": self.execution_enabled,
|
|
||||||
"shadow_mode": not self.execution_enabled,
|
|
||||||
"side_effects_executed": False,
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
**self.sagas.load(candidate_id).to_dict(),
|
**self.sagas.load(candidate_id).to_dict(),
|
||||||
"execution_enabled": self.execution_enabled,
|
"execution_enabled": self.execution_enabled,
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
[Service]
|
||||||
|
LoadCredential=agent_task_control_token:/etc/aios-gitops/agent-task-control.token
|
||||||
|
Environment=AIOS_AGENT_TASK_API_URL=http://127.0.0.1:18940
|
||||||
|
Environment=AIOS_ONLINE_TASK_PROJECTION_ENABLED=1
|
||||||
|
Environment=AIOS_AGENT_TASK_CONTROL_TOKEN_FILE=%d/agent_task_control_token
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
[Service]
|
|
||||||
User=aiman
|
|
||||||
Group=gitops
|
|
||||||
WorkingDirectory=/opt/aios/gitops-model-api/@SOURCE_OID@-minimal-autonomous-loop/runtime
|
|
||||||
ExecStart=
|
|
||||||
ExecStart=/usr/bin/python3 -B /opt/aios/gitops-model-api/@SOURCE_OID@-minimal-autonomous-loop/runtime/app/aios_chatgpt_bridge_v2_server.py
|
|
||||||
Environment=AIOS_CONTROL_PLANE_TARGET_OID=@SOURCE_OID@
|
|
||||||
Environment=AIOS_CONTROL_PLANE_TARGET_VERSION=V2R2C1
|
|
||||||
Environment=AIOS_CONTROL_PLANE_RUNTIME_PATH=/opt/aios/gitops-model-api/@SOURCE_OID@-minimal-autonomous-loop/runtime
|
|
||||||
Environment=AIOS_CONTROL_PLANE_ARTIFACT=/opt/aios/gitops-model-api/@SOURCE_OID@-minimal-autonomous-loop/runtime/manifest.json
|
|
||||||
Environment=AIOS_CONTROL_PLANE_ARTIFACT_SHA256=@ARTIFACT_SHA256@
|
|
||||||
Environment=AIOS_GITOPS_WRITES_ENABLED=false
|
|
||||||
Environment=AIOS_GITOPS_DURABLE_JOB_EXECUTION_ENABLED=true
|
|
||||||
Environment=AIOS_GITOPS_TOKEN_FILE=%d/action_tokens
|
|
||||||
Environment=AIOS_GITEA_API_BASE=https://g.cnnetmkt.top/git/api/v1
|
|
||||||
Environment=AIOS_BRIDGE_V2_PORT=18892
|
|
||||||
Environment=PYTHONPATH=/opt/aios/gitops-model-api/@SOURCE_OID@-minimal-autonomous-loop/runtime:/opt/aios/gitops-model-api/@SOURCE_OID@-minimal-autonomous-loop/runtime/app:/opt/aios/gitops-model-api/@SOURCE_OID@-minimal-autonomous-loop/runtime/runtime-agent-os
|
|
||||||
LoadCredential=action_tokens:/data/aios-gitops/runtime/secrets/gitops.tokens
|
|
||||||
ReadOnlyPaths=/opt/aios/gitops-model-api/@SOURCE_OID@-minimal-autonomous-loop/runtime /data/aios-gitops/repo.git
|
|
||||||
ReadWritePaths=/var/lib/aios-gitops/bridge-v2-staging
|
|
||||||
@@ -4,32 +4,19 @@ After=network-online.target
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
User=aiman
|
User=aios-bridge-staging
|
||||||
Group=gitops
|
Group=aios-bridge-staging
|
||||||
PermissionsStartOnly=true
|
WorkingDirectory=/opt/aios/gitops-control-plane/current
|
||||||
WorkingDirectory=@RUNTIME_ROOT@
|
|
||||||
Environment=AIOS_BRIDGE_SCHEMA_VERSION=aios.chatgpt-gitops-bridge.v2
|
Environment=AIOS_BRIDGE_SCHEMA_VERSION=aios.chatgpt-gitops-bridge.v2
|
||||||
Environment=AIOS_GITOPS_WRITES_ENABLED=false
|
Environment=AIOS_GITOPS_WRITES_ENABLED=false
|
||||||
Environment=AIOS_GITOPS_DURABLE_JOB_EXECUTION_ENABLED=true
|
Environment=AIOS_GITOPS_DURABLE_JOB_EXECUTION_ENABLED=true
|
||||||
Environment=AIOS_GITOPS_WRITES_ENABLED=false
|
|
||||||
Environment=AIOS_GITEA_API_BASE=https://g.cnnetmkt.top/git/api/v1
|
|
||||||
Environment=AIOS_GITEA_TOKEN_FILE=/run/credentials/aios-chatgpt-bridge-v2-staging.service/gitea_token
|
|
||||||
Environment=AIOS_GITEA_REMOTE=gitea-http
|
|
||||||
Environment=AIOS_GITOPS_BASELINE_AUTHORITY=/var/lib/aios-gitops/model-api-staging/baseline/current.json
|
|
||||||
Environment=AIOS_CONTROL_PLANE_TARGET_VERSION=V2R2C1
|
|
||||||
Environment=AIOS_CONTROL_PLANE_TARGET_OID=@SOURCE_OID@
|
|
||||||
Environment=AIOS_CONTROL_PLANE_ARTIFACT_SHA256=@ARTIFACT_SHA256@
|
|
||||||
Environment=PYTHONPATH=@RUNTIME_ROOT@:@RUNTIME_ROOT@/app:@RUNTIME_ROOT@/runtime-agent-os
|
|
||||||
Environment=AIOS_DEPLOYMENT_STATE=STAGING
|
Environment=AIOS_DEPLOYMENT_STATE=STAGING
|
||||||
LoadCredential=gitea_token:/run/credentials/aios-governance-executor.service/gitea-governance-executor-token
|
ExecStart=/usr/bin/python3 /opt/aios/gitops-control-plane/current/bridge-v2-staging-server.py
|
||||||
ExecStartPre=/usr/bin/install -d -o aiman -g gitops -m 0750 /var/lib/aios-gitops/bridge-v2-staging
|
|
||||||
ExecStartPre=/usr/bin/setfacl -m u:aiman:--x /usr/local/libexec
|
|
||||||
ExecStart=/usr/bin/python3 -B @RUNTIME_ROOT@/app/aios_chatgpt_bridge_v2_server.py
|
|
||||||
NoNewPrivileges=true
|
NoNewPrivileges=true
|
||||||
PrivateTmp=true
|
PrivateTmp=true
|
||||||
ProtectSystem=strict
|
ProtectSystem=strict
|
||||||
ProtectHome=true
|
ProtectHome=true
|
||||||
ReadWritePaths=/var/lib/aios-gitops/bridge-v2-staging /data/aios-gitops/repo.git
|
ReadWritePaths=/var/lib/aios-gitops/bridge-v2-staging
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
|
|||||||
-22
@@ -1,22 +0,0 @@
|
|||||||
[Service]
|
|
||||||
User=aiman
|
|
||||||
Group=gitops
|
|
||||||
WorkingDirectory=/opt/aios/gitops-model-api/@SOURCE_OID@-minimal-autonomous-loop/runtime
|
|
||||||
ExecStart=
|
|
||||||
ExecStart=/usr/bin/python3 -B /opt/aios/gitops-model-api/@SOURCE_OID@-minimal-autonomous-loop/runtime/app/aios_model_api_staging_list_patches_overlay.py
|
|
||||||
Environment=AIOS_GITOPS_WORKSPACE=/opt/aios/gitops-model-api/@SOURCE_OID@-minimal-autonomous-loop/runtime
|
|
||||||
Environment=AIOS_GITOPS_RUNTIME_OID=@SOURCE_OID@
|
|
||||||
Environment=AIOS_GITOPS_ARTIFACT=/opt/aios/gitops-model-api/@SOURCE_OID@-minimal-autonomous-loop/runtime/manifest.json
|
|
||||||
Environment=AIOS_GITOPS_ARTIFACT_SHA256=@ARTIFACT_SHA256@
|
|
||||||
Environment=AIOS_GITOPS_DEPLOYMENT_STATE=STAGING
|
|
||||||
Environment=AIOS_GITOPS_WRITES_ENABLED=false
|
|
||||||
Environment=AIOS_GITOPS_DURABLE_JOB_EXECUTION_ENABLED=true
|
|
||||||
Environment=AIOS_GITOPS_JOB_CONTROLLER_ENABLED=false
|
|
||||||
Environment=AIOS_GITOPS_TOKEN_FILE=%d/action_tokens
|
|
||||||
Environment=AIOS_GITOPS_GITEA_API_URL=https://g.cnnetmkt.top/git/api/v1
|
|
||||||
Environment=AIOS_BRIDGE_V2_URL=http://127.0.0.1:18892
|
|
||||||
Environment=AIOS_GITOPS_PORT=18893
|
|
||||||
Environment=PYTHONPATH=/opt/aios/gitops-model-api/@SOURCE_OID@-minimal-autonomous-loop/runtime:/opt/aios/gitops-model-api/@SOURCE_OID@-minimal-autonomous-loop/runtime/app:/opt/aios/gitops-model-api/@SOURCE_OID@-minimal-autonomous-loop/runtime/runtime-agent-os
|
|
||||||
LoadCredential=action_tokens:/data/aios-gitops/runtime/secrets/gitops.tokens
|
|
||||||
ReadOnlyPaths=/opt/aios/gitops-model-api/@SOURCE_OID@-minimal-autonomous-loop/runtime /data/aios-gitops/repo.git
|
|
||||||
ReadWritePaths=/var/lib/aios-gitops/model-api-staging
|
|
||||||
@@ -7,11 +7,6 @@ Wants=network-online.target
|
|||||||
Type=simple
|
Type=simple
|
||||||
User=aiman
|
User=aiman
|
||||||
Group=gitops
|
Group=gitops
|
||||||
PermissionsStartOnly=true
|
|
||||||
ExecStartPre=/usr/bin/install -d -o aiman -g gitops -m 0750 /var/lib/aios-gitops/model-api-staging
|
|
||||||
ExecStartPre=/usr/bin/install -d -o aiman -g gitops -m 0750 /var/lib/aios-gitops/model-api-staging/silent-release
|
|
||||||
ExecStartPre=/usr/bin/install -d -o root -g gitops -m 0750 /var/lib/aios-gitops/model-api-staging/baseline
|
|
||||||
ExecStartPre=/usr/bin/setfacl -m u:aiman:--x /usr/local/libexec
|
|
||||||
WorkingDirectory=@RUNTIME_ROOT@
|
WorkingDirectory=@RUNTIME_ROOT@
|
||||||
Environment=PYTHONUNBUFFERED=1
|
Environment=PYTHONUNBUFFERED=1
|
||||||
Environment=PYTHONDONTWRITEBYTECODE=1
|
Environment=PYTHONDONTWRITEBYTECODE=1
|
||||||
@@ -21,7 +16,7 @@ Environment=AIOS_GITOPS_REPO=/data/aios-gitops/repo.git
|
|||||||
Environment=AIOS_GITOPS_BARE_REPO=/data/aios-gitops/repo.git
|
Environment=AIOS_GITOPS_BARE_REPO=/data/aios-gitops/repo.git
|
||||||
Environment=AIOS_GITOPS_AUTHORITATIVE_GIT_DIR=/data/aios-gitops/repo.git
|
Environment=AIOS_GITOPS_AUTHORITATIVE_GIT_DIR=/data/aios-gitops/repo.git
|
||||||
Environment=AIOS_GITOPS_REPOSITORY_IDENTITY=aios/aios
|
Environment=AIOS_GITOPS_REPOSITORY_IDENTITY=aios/aios
|
||||||
Environment=AIOS_GITOPS_BASELINE_AUTHORITY=/var/lib/aios-gitops/model-api-staging/baseline/current.json
|
Environment=AIOS_GITOPS_BASELINE_AUTHORITY=/var/lib/aios-gitops-runtime-anchor/baseline/current.json
|
||||||
Environment=AIOS_GITOPS_WRITES_ENABLED=false
|
Environment=AIOS_GITOPS_WRITES_ENABLED=false
|
||||||
Environment=AIOS_GITOPS_DURABLE_JOB_EXECUTION_ENABLED=false
|
Environment=AIOS_GITOPS_DURABLE_JOB_EXECUTION_ENABLED=false
|
||||||
Environment=AIOS_GITOPS_JOB_CONTROLLER_ENABLED=false
|
Environment=AIOS_GITOPS_JOB_CONTROLLER_ENABLED=false
|
||||||
@@ -32,21 +27,10 @@ Environment=AIOS_GITOPS_DEPLOYMENT_STATE=STAGING
|
|||||||
Environment=AIOS_GITOPS_MODEL_API_SERVICE=aios-gitops-model-api-v2r2c1-staging.service
|
Environment=AIOS_GITOPS_MODEL_API_SERVICE=aios-gitops-model-api-v2r2c1-staging.service
|
||||||
Environment=AIOS_SILENT_RELEASE_EXECUTION_ENABLED=false
|
Environment=AIOS_SILENT_RELEASE_EXECUTION_ENABLED=false
|
||||||
Environment=AIOS_SILENT_RELEASE_STATE_ROOT=/var/lib/aios-gitops/model-api-staging/silent-release
|
Environment=AIOS_SILENT_RELEASE_STATE_ROOT=/var/lib/aios-gitops/model-api-staging/silent-release
|
||||||
Environment=AIOS_GITOPS_GITEA_API_URL=https://g.cnnetmkt.top/git/api/v1
|
|
||||||
Environment=AIOS_GITOPS_GITEA_REPOSITORY=aios/aios
|
|
||||||
Environment=AIOS_GITOPS_GITEA_REMOTE=gitea-http
|
|
||||||
Environment=AIOS_GITOPS_GITEA_TOKEN_FILE=/run/credentials/aios-gitops-model-api-v2r2c1-staging.service/gitea_token
|
|
||||||
Environment=GIT_ASKPASS=/var/lib/aios-gitops/bridge-v2-staging/bridge-v2-askpass.sh
|
|
||||||
Environment=GIT_ASKPASS_REQUIRE=force
|
|
||||||
Environment=GIT_TERMINAL_PROMPT=0
|
|
||||||
Environment=GITEA_MACHINE_ACTOR=aios-autonomy-agent
|
|
||||||
Environment=CREDENTIAL_FILE=/run/credentials/aios-gitops-model-api-v2r2c1-staging.service/gitea_token
|
|
||||||
Environment=AIOS_GITOPS_GITEA_TOKEN_LINE=1
|
|
||||||
LoadCredential=gitea_token:/run/credentials/aios-governance-executor.service/gitea-governance-executor-token
|
|
||||||
Environment=AIOS_BRIDGE_V2_URL=http://127.0.0.1:18892
|
Environment=AIOS_BRIDGE_V2_URL=http://127.0.0.1:18892
|
||||||
Environment=AIOS_GITOPS_HOST=127.0.0.1
|
Environment=AIOS_GITOPS_HOST=127.0.0.1
|
||||||
Environment=AIOS_GITOPS_PORT=18893
|
Environment=AIOS_GITOPS_PORT=18893
|
||||||
Environment=PYTHONPATH=@RUNTIME_ROOT@:@RUNTIME_ROOT@/app:@RUNTIME_ROOT@/runtime-agent-os
|
Environment=PYTHONPATH=@RUNTIME_ROOT@/app:@RUNTIME_ROOT@/runtime-agent-os
|
||||||
ExecStart=/usr/bin/python3 @RUNTIME_ROOT@/app/aios_model_api_staging.py
|
ExecStart=/usr/bin/python3 @RUNTIME_ROOT@/app/aios_model_api_staging.py
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=2
|
RestartSec=2
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=AIOS uncredentialed deterministic Planner/Executor
|
||||||
|
After=local-fs.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=aios-engineering-executor
|
||||||
|
Group=aios-executor-exchange
|
||||||
|
ExecStart=/usr/bin/python3 -B /opt/aios/current/active-runtime/app/aios_isolated_executor.py --root /var/lib/aios-online-agent-executor
|
||||||
|
Restart=always
|
||||||
|
RestartSec=2
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
PrivateNetwork=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
ProtectKernelTunables=true
|
||||||
|
ProtectKernelModules=true
|
||||||
|
ProtectControlGroups=true
|
||||||
|
LockPersonality=true
|
||||||
|
RestrictSUIDSGID=true
|
||||||
|
RestrictRealtime=true
|
||||||
|
RestrictNamespaces=true
|
||||||
|
MemoryDenyWriteExecute=true
|
||||||
|
RestrictAddressFamilies=AF_UNIX AF_INET
|
||||||
|
IPAddressDeny=any
|
||||||
|
IPAddressAllow=localhost
|
||||||
|
InaccessiblePaths=/run/credentials
|
||||||
|
ReadWritePaths=/var/lib/aios-online-agent-executor
|
||||||
|
UMask=0027
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=AIOS governed Online Agent Worker
|
||||||
|
After=aios-agent-task-api.service aios-chatgpt-bridge-v2-staging.service
|
||||||
|
Wants=aios-agent-task-api.service aios-chatgpt-bridge-v2-staging.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=aios-engineering-worker
|
||||||
|
Group=aios-engineering-worker
|
||||||
|
SupplementaryGroups=gitops aios-executor-exchange
|
||||||
|
LoadCredentialEncrypted=agent_token:/etc/credstore.encrypted/aios-online-agent-worker-20260815/agent_token
|
||||||
|
Environment=PYTHONUNBUFFERED=1
|
||||||
|
Environment=AIOS_AGENT_TASK_API_URL=http://127.0.0.1:18940
|
||||||
|
Environment=AIOS_GOVERNED_WRITE_API_URL=http://127.0.0.1:18892
|
||||||
|
Environment=AIOS_AGENT_ID=online-engineering-worker
|
||||||
|
Environment=AIOS_AGENT_TYPE=trusted-online-engineering-worker
|
||||||
|
Environment=AIOS_AGENT_VERSION=2026-08-17.1-dynamic
|
||||||
|
Environment=AIOS_WORKER_CAPABILITIES=git.read,git.test,governed.write.branch,governed.write.file
|
||||||
|
Environment=AIOS_AUTHORITY_GIT_DIR=/data/aios-gitops/repo.git
|
||||||
|
Environment=AIOS_ISOLATED_EXECUTOR_ROOT=/var/lib/aios-online-agent-executor
|
||||||
|
Environment=AIOS_WORKER_STATE_ROOT=/var/lib/aios-online-agent-worker
|
||||||
|
Environment=AIOS_WORKER_WORKSPACE_ROOT=/var/lib/aios-online-agent-worker/workspaces
|
||||||
|
ExecStart=/usr/bin/python3 -B /opt/aios/current/active-runtime/app/aios_online_agent_worker.py
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
TimeoutStopSec=20
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
ProtectKernelTunables=true
|
||||||
|
ProtectKernelModules=true
|
||||||
|
ProtectControlGroups=true
|
||||||
|
LockPersonality=true
|
||||||
|
RestrictSUIDSGID=true
|
||||||
|
RestrictRealtime=true
|
||||||
|
RestrictNamespaces=true
|
||||||
|
MemoryDenyWriteExecute=true
|
||||||
|
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||||
|
ReadOnlyPaths=/data/aios-gitops/repo.git
|
||||||
|
ReadWritePaths=/var/lib/aios-online-agent-worker
|
||||||
|
UMask=0027
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=AIOS trusted dynamic engineering consumer
|
||||||
|
After=aios-agent-task-api.service aios-chatgpt-bridge-v2-staging.service
|
||||||
|
Wants=aios-agent-task-api.service aios-chatgpt-bridge-v2-staging.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=aios-engineering-worker
|
||||||
|
Group=aios-engineering-worker
|
||||||
|
SupplementaryGroups=gitops aios-executor-exchange
|
||||||
|
LoadCredentialEncrypted=agent_token:/etc/credstore.encrypted/aios-online-engineering-20260815/agent_token
|
||||||
|
Environment=PYTHONUNBUFFERED=1
|
||||||
|
Environment=AIOS_AGENT_TASK_API_URL=http://127.0.0.1:18940
|
||||||
|
Environment=AIOS_GOVERNED_WRITE_API_URL=http://127.0.0.1:18892
|
||||||
|
Environment=AIOS_AGENT_ID=online-engineering-consumer
|
||||||
|
Environment=AIOS_AGENT_TYPE=trusted-online-engineering-worker
|
||||||
|
Environment=AIOS_AGENT_VERSION=2026-08-17.1-dynamic
|
||||||
|
Environment=AIOS_WORKER_CAPABILITIES=git.read,git.test,governed.write.branch,governed.write.file
|
||||||
|
Environment=AIOS_AUTHORITY_GIT_DIR=/data/aios-gitops/repo.git
|
||||||
|
Environment=AIOS_ISOLATED_EXECUTOR_ROOT=/var/lib/aios-online-agent-executor
|
||||||
|
Environment=AIOS_WORKER_STATE_ROOT=/var/lib/aios-online-engineering-consumer
|
||||||
|
Environment=AIOS_WORKER_WORKSPACE_ROOT=/var/lib/aios-online-engineering-consumer/workspaces
|
||||||
|
ExecStart=/usr/bin/python3 -B /opt/aios/current/active-runtime/app/aios_online_agent_worker.py
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
TimeoutStopSec=20
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
ProtectKernelTunables=true
|
||||||
|
ProtectKernelModules=true
|
||||||
|
ProtectControlGroups=true
|
||||||
|
LockPersonality=true
|
||||||
|
RestrictSUIDSGID=true
|
||||||
|
RestrictRealtime=true
|
||||||
|
RestrictNamespaces=true
|
||||||
|
MemoryDenyWriteExecute=true
|
||||||
|
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||||
|
ReadOnlyPaths=/data/aios-gitops/repo.git
|
||||||
|
ReadWritePaths=/var/lib/aios-online-engineering-consumer
|
||||||
|
UMask=0027
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
g aios-executor-exchange -
|
||||||
|
u aios-engineering-executor - "AIOS isolated engineering executor" /var/lib/aios-online-agent-executor /usr/sbin/nologin
|
||||||
|
m aios-engineering-executor aios-executor-exchange
|
||||||
|
m aios-engineering-worker aios-executor-exchange
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
d /var/lib/aios-online-agent-executor 2770 aios-engineering-executor aios-executor-exchange -
|
||||||
|
d /var/lib/aios-online-agent-executor/requests 2770 aios-engineering-worker aios-executor-exchange -
|
||||||
|
d /var/lib/aios-online-agent-executor/results 2770 aios-engineering-executor aios-executor-exchange -
|
||||||
|
d /var/lib/aios-online-agent-executor/failed 2770 aios-engineering-executor aios-executor-exchange -
|
||||||
|
d /var/lib/aios-online-agent-executor/sources 2770 aios-engineering-worker aios-executor-exchange -
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
AIOS governed Action loop smoke
|
|
||||||
task=fix-action-contracts-20260825-1407
|
|
||||||
generation=5
|
|
||||||
expected_parent=f104e1244fcbc36d1debf1aa0c6720f734ea04cf
|
|
||||||
purpose=verify read-lease-commit-idempotency-cas
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
openapi: 3.1.0
|
openapi: 3.1.0
|
||||||
info:
|
info:
|
||||||
title: AIOS GitOps Controlled Access API
|
title: AIOS GitOps Controlled Access API
|
||||||
version: V2R2C1-chatgpt-actions-28ops
|
version: V1R1C0-chatgpt-actions
|
||||||
description: Controlled ChatGPT Actions API for AIOS online GitOps baseline. Read endpoints are public. Write endpoints require Bearer token and create Git commits on non-main branches.
|
description: Controlled ChatGPT Actions API for AIOS online GitOps baseline. Read endpoints are public. Write endpoints require Bearer token and create Git commits on non-main branches.
|
||||||
servers:
|
servers:
|
||||||
- url: https://cnnetmkt.top
|
- url: https://cnnetmkt.top
|
||||||
@@ -240,103 +240,115 @@ components:
|
|||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
properties:
|
properties:
|
||||||
candidate_id: {type: string}
|
candidate_id: {type: string}
|
||||||
|
BridgeWriterLeaseRecoveryRequest:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [schema, task_id, repository, branch_mode, base_oid, expected_branch_oid, target_branch, generation, lease_seconds]
|
||||||
|
properties:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [aios.task-writer-lease.v2, aios.task-writer-lease-request.v2]
|
||||||
|
task_id: {type: string, minLength: 1, maxLength: 256}
|
||||||
|
job_id: {type: string, pattern: '^job-[A-Za-z0-9][A-Za-z0-9._-]{7,255}$'}
|
||||||
|
repository: {type: string, const: aios/aios}
|
||||||
|
branch_mode: {type: string, enum: [CREATE_NEW, CONTINUE_EXISTING]}
|
||||||
|
base_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
||||||
|
expected_branch_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
||||||
|
target_branch: {type: string, pattern: '^agent/[A-Za-z0-9][A-Za-z0-9._/-]{2,179}$'}
|
||||||
|
generation: {type: integer, minimum: 1}
|
||||||
|
lease_seconds: {type: integer, minimum: 1, maximum: 86400}
|
||||||
|
BridgeWriterLeaseResponse:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [schema, task_id, job_id, repository, branch_mode, base_oid, expected_branch_oid, target_branch, generation, lease_seconds, create_target_branch, worktree_mode, cas_ref_update_required]
|
||||||
|
properties:
|
||||||
|
schema: {type: string, enum: [aios.task-writer-lease.v2]}
|
||||||
|
task_id: {type: string}
|
||||||
|
job_id: {type: string, pattern: '^job-[A-Za-z0-9][A-Za-z0-9._-]{7,255}$'}
|
||||||
|
repository: {type: string, const: aios/aios}
|
||||||
|
branch_mode: {type: string, enum: [CREATE_NEW, CONTINUE_EXISTING]}
|
||||||
|
base_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
||||||
|
expected_branch_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
||||||
|
target_branch: {type: string, pattern: '^agent/[A-Za-z0-9][A-Za-z0-9._/-]{2,179}$'}
|
||||||
|
generation: {type: integer, minimum: 1}
|
||||||
|
lease_seconds: {type: integer, minimum: 1, maximum: 86400}
|
||||||
|
create_target_branch: {type: boolean}
|
||||||
|
worktree_mode: {type: string, enum: [detached_at_base_oid, detached_at_expected_head]}
|
||||||
|
cas_ref_update_required: {type: boolean, const: true}
|
||||||
|
lease_expires_at: {type: string, format: date-time}
|
||||||
|
BridgeWriterLeaseRecoveryResponse:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [schema, task_id, job_id, repository, branch_mode, base_oid, expected_branch_oid, target_branch, generation, lease_seconds, lease_expires_at, create_target_branch, worktree_mode, cas_ref_update_required, recovered, bootstrapped, idempotent, runtime_rehydrated, durable_branch_lease_present]
|
||||||
|
properties:
|
||||||
|
schema: {type: string, enum: [aios.task-writer-lease.v2]}
|
||||||
|
task_id: {type: string}
|
||||||
|
job_id: {type: string, pattern: '^job-[A-Za-z0-9][A-Za-z0-9._-]{7,255}$'}
|
||||||
|
repository: {type: string, const: aios/aios}
|
||||||
|
branch_mode: {type: string, enum: [CREATE_NEW, CONTINUE_EXISTING]}
|
||||||
|
base_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
||||||
|
expected_branch_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
||||||
|
target_branch: {type: string, pattern: '^agent/[A-Za-z0-9][A-Za-z0-9._/-]{2,179}$'}
|
||||||
|
generation: {type: integer, minimum: 1}
|
||||||
|
lease_seconds: {type: integer, minimum: 1, maximum: 86400}
|
||||||
|
lease_expires_at: {type: string, format: date-time}
|
||||||
|
create_target_branch: {type: boolean, const: false}
|
||||||
|
worktree_mode: {type: string, enum: [detached_at_base_oid, detached_at_expected_head]}
|
||||||
|
cas_ref_update_required: {type: boolean, const: true}
|
||||||
|
recovered: {type: boolean}
|
||||||
|
bootstrapped: {type: boolean}
|
||||||
|
idempotent: {type: boolean}
|
||||||
|
runtime_rehydrated: {type: boolean}
|
||||||
|
durable_branch_lease_present: {type: boolean}
|
||||||
|
BridgeCommitFileRequest:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [task_id, repository, expected_branch_oid, target_branch, generation, idempotency_key, path, content_base64, message]
|
||||||
|
properties:
|
||||||
|
task_id: {type: string, minLength: 1}
|
||||||
|
job_id: {type: string, pattern: '^job-[A-Za-z0-9][A-Za-z0-9._-]{7,255}$'}
|
||||||
|
repository: {type: string, const: aios/aios}
|
||||||
|
expected_branch_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
||||||
|
target_branch: {type: string, pattern: '^agent/[A-Za-z0-9][A-Za-z0-9._/-]{2,179}$'}
|
||||||
|
generation: {type: integer, minimum: 1}
|
||||||
|
idempotency_key: {type: string, minLength: 1, maxLength: 256}
|
||||||
|
path: {type: string, minLength: 1, maxLength: 512}
|
||||||
|
content_base64: {type: string, format: byte}
|
||||||
|
message: {type: string, minLength: 1, maxLength: 200}
|
||||||
|
BridgeCommitFilesRequest:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [task_id, repository, expected_branch_oid, target_branch, generation, idempotency_key, files, message]
|
||||||
|
properties:
|
||||||
|
task_id: {type: string, minLength: 1}
|
||||||
|
job_id: {type: string, pattern: '^job-[A-Za-z0-9][A-Za-z0-9._-]{7,255}$'}
|
||||||
|
repository: {type: string, const: aios/aios}
|
||||||
|
expected_branch_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
||||||
|
target_branch: {type: string, pattern: '^agent/[A-Za-z0-9][A-Za-z0-9._/-]{2,179}$'}
|
||||||
|
generation: {type: integer, minimum: 1}
|
||||||
|
idempotency_key: {type: string, minLength: 1, maxLength: 256}
|
||||||
|
files: {type: object, additionalProperties: {type: string, format: byte}, minProperties: 1}
|
||||||
|
message: {type: string, minLength: 1, maxLength: 200}
|
||||||
|
BridgeCommitResponse:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
required: [job_id, commit_oid, commit_visible, branch_mode, target_branch, generation]
|
||||||
|
properties:
|
||||||
|
job_id: {type: string, pattern: '^job-[A-Za-z0-9][A-Za-z0-9._-]{7,255}$'}
|
||||||
|
commit_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
||||||
|
commit_visible: {type: boolean}
|
||||||
|
branch_mode: {type: string, enum: [CONTINUE_EXISTING]}
|
||||||
|
target_branch: {type: string, pattern: '^agent/[A-Za-z0-9][A-Za-z0-9._/-]{2,179}$'}
|
||||||
|
generation: {type: integer, minimum: 1}
|
||||||
|
BridgeGovernedCapabilities:
|
||||||
|
type: object
|
||||||
|
required: [bridge_schema_version, governed_writes_enabled, operations, operation_contracts]
|
||||||
|
properties:
|
||||||
|
bridge_schema_version: {type: string, const: aios.chatgpt-gitops-bridge.v2}
|
||||||
|
governed_writes_enabled: {type: boolean, const: true}
|
||||||
|
operations: {type: array, items: {type: string}}
|
||||||
|
operation_contracts: {type: object, additionalProperties: true}
|
||||||
paths:
|
paths:
|
||||||
/readRepositoryFile:
|
|
||||||
post:
|
|
||||||
operationId: readRepositoryFile
|
|
||||||
summary: Read one bounded file from an approved repository ref
|
|
||||||
security: [{bearerAuth: []}]
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
additionalProperties: false
|
|
||||||
required: [repository, ref, path, max_bytes]
|
|
||||||
properties:
|
|
||||||
repository: {type: string, enum: [aios/aios]}
|
|
||||||
ref: {type: string, description: agent branch, main, V2R2C1, or full commit OID}
|
|
||||||
path: {type: string, minLength: 1, maxLength: 512}
|
|
||||||
max_bytes: {type: integer, minimum: 1, maximum: 262144}
|
|
||||||
responses:
|
|
||||||
'200': {description: Bounded read-only source content}
|
|
||||||
'400': {description: Invalid repository, ref, path, or size}
|
|
||||||
'404': {description: Ref or file not found}
|
|
||||||
x-openai-isConsequential: false
|
|
||||||
/acquireWriterLease:
|
|
||||||
post:
|
|
||||||
operationId: acquireWriterLease
|
|
||||||
summary: Acquire a short-lived generation-scoped writer lease
|
|
||||||
security: [{bearerAuth: []}]
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
additionalProperties: false
|
|
||||||
required: [schema, task_id, repository, branch_mode, base_oid, expected_branch_oid, target_branch, generation, lease_seconds]
|
|
||||||
properties:
|
|
||||||
schema: {type: string, enum: [aios.task-writer-lease.v2]}
|
|
||||||
task_id: {type: string, minLength: 1, maxLength: 200}
|
|
||||||
repository: {type: string, enum: [aios/aios]}
|
|
||||||
branch_mode: {type: string, enum: [CONTINUE_EXISTING]}
|
|
||||||
base_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
|
||||||
expected_branch_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
|
||||||
target_branch: {type: string, pattern: '^agent/[A-Za-z0-9._/-]+$'}
|
|
||||||
generation: {type: integer, minimum: 1}
|
|
||||||
lease_seconds: {type: integer, minimum: 1, maximum: 3600}
|
|
||||||
responses:
|
|
||||||
'200': {description: Acquired or idempotently replayed scoped lease}
|
|
||||||
'409': {description: Binding, stale, expired, or already-used lease}
|
|
||||||
'423': {description: Governed writes disabled}
|
|
||||||
x-openai-isConsequential: true
|
|
||||||
/v2/commit-files:
|
|
||||||
post:
|
|
||||||
operationId: commitFilesGoverned
|
|
||||||
summary: Submit one atomic governed commit containing multiple files
|
|
||||||
security: [{bearerAuth: []}]
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
additionalProperties: false
|
|
||||||
required:
|
|
||||||
- task_id
|
|
||||||
- repository
|
|
||||||
- expected_branch_oid
|
|
||||||
- target_branch
|
|
||||||
- generation
|
|
||||||
- idempotency_key
|
|
||||||
- files
|
|
||||||
- message
|
|
||||||
properties:
|
|
||||||
task_id: {type: string}
|
|
||||||
repository: {type: string, example: aios/aios}
|
|
||||||
expected_branch_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
|
||||||
target_branch: {type: string, pattern: '^agent/[A-Za-z0-9._/-]+$'}
|
|
||||||
generation: {type: integer, minimum: 1}
|
|
||||||
idempotency_key: {type: string, minLength: 8, maxLength: 128, pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$'}
|
|
||||||
files:
|
|
||||||
type: array
|
|
||||||
minItems: 1
|
|
||||||
maxItems: 50
|
|
||||||
items:
|
|
||||||
type: object
|
|
||||||
additionalProperties: false
|
|
||||||
required: [path, content_base64]
|
|
||||||
properties:
|
|
||||||
path: {type: string, minLength: 1, maxLength: 512}
|
|
||||||
content_base64: {type: string}
|
|
||||||
message: {type: string, minLength: 1, maxLength: 200}
|
|
||||||
responses:
|
|
||||||
'200': {description: Governed commit job and verified branch result}
|
|
||||||
'400': {description: Contract or policy rejection}
|
|
||||||
'409': {description: Branch CAS or idempotency conflict}
|
|
||||||
x-openai-isConsequential: true
|
|
||||||
/createReleaseIntent:
|
/createReleaseIntent:
|
||||||
post:
|
post:
|
||||||
operationId: createReleaseIntent
|
operationId: createReleaseIntent
|
||||||
@@ -416,6 +428,20 @@ paths:
|
|||||||
'403': {description: Signature, key, expiry, or revocation failure}
|
'403': {description: Signature, key, expiry, or revocation failure}
|
||||||
'409': {description: Conflicting release plan}
|
'409': {description: Conflicting release plan}
|
||||||
x-openai-isConsequential: true
|
x-openai-isConsequential: true
|
||||||
|
/submitSignedApproval:
|
||||||
|
post:
|
||||||
|
operationId: submitSignedApproval
|
||||||
|
summary: Alias for immutable signed owner approval submission
|
||||||
|
security: [{bearerAuth: []}]
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {$ref: '#/components/schemas/SignedReleasePlanApproval'}
|
||||||
|
responses:
|
||||||
|
'200': {description: Signature verified and approval stored}
|
||||||
|
'403': {description: Production approval backend unavailable or signature invalid}
|
||||||
|
x-openai-isConsequential: true
|
||||||
/startDeployment:
|
/startDeployment:
|
||||||
post:
|
post:
|
||||||
operationId: startDeployment
|
operationId: startDeployment
|
||||||
@@ -479,6 +505,17 @@ paths:
|
|||||||
operationId: listPatches
|
operationId: listPatches
|
||||||
summary: List patch records
|
summary: List patch records
|
||||||
responses: {'200': {description: OK}}
|
responses: {'200': {description: OK}}
|
||||||
|
post:
|
||||||
|
operationId: createPatch
|
||||||
|
summary: Create a patch branch and commit metadata
|
||||||
|
security: [{bearerAuth: []}]
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {$ref: '#/components/schemas/PatchRequest'}
|
||||||
|
responses: {'200': {description: Patch branch committed}, '401': {description: Unauthorized}, '403': {description: Forbidden}}
|
||||||
|
x-openai-isConsequential: true
|
||||||
/api/model:
|
/api/model:
|
||||||
get:
|
get:
|
||||||
operationId: readModelRegistry
|
operationId: readModelRegistry
|
||||||
@@ -500,6 +537,61 @@ paths:
|
|||||||
operationId: getRuntimeStatus
|
operationId: getRuntimeStatus
|
||||||
summary: Read runtime status
|
summary: Read runtime status
|
||||||
responses: {'200': {description: OK}}
|
responses: {'200': {description: OK}}
|
||||||
|
/api/branch:
|
||||||
|
post:
|
||||||
|
operationId: createBranch
|
||||||
|
summary: Create a non-main branch and commit branch metadata
|
||||||
|
security: [{bearerAuth: []}]
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {$ref: '#/components/schemas/BranchRequest'}
|
||||||
|
responses: {'200': {description: Branch created}, '401': {description: Unauthorized}, '403': {description: Forbidden}}
|
||||||
|
x-openai-isConsequential: true
|
||||||
|
/api/file:
|
||||||
|
post:
|
||||||
|
operationId: commitFile
|
||||||
|
summary: Write a file and create a Git commit on a non-main branch
|
||||||
|
security: [{bearerAuth: []}]
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {$ref: '#/components/schemas/FileCommitRequest'}
|
||||||
|
responses: {'200': {description: File committed}, '400': {description: Secret rejected or invalid path}, '401': {description: Unauthorized}}
|
||||||
|
x-openai-isConsequential: true
|
||||||
|
/api/commit-file:
|
||||||
|
post:
|
||||||
|
operationId: commitFileAtomic
|
||||||
|
summary: Atomically write one allowlisted file, commit, push, and verify using Token Policy V2
|
||||||
|
security: [{bearerAuth: []}]
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {$ref: '#/components/schemas/AtomicFileCommitRequest'}
|
||||||
|
responses:
|
||||||
|
'200': {description: Completed or idempotently replayed}
|
||||||
|
'400': {description: Invalid canonical request}
|
||||||
|
'401': {description: Missing, invalid, expired, or revoked Token Policy V2 grant}
|
||||||
|
'403': {description: Audience, source, branch, operation, path, or policy denied}
|
||||||
|
'409': {description: Idempotency conflict or manual review required}
|
||||||
|
'413': {description: Request too large}
|
||||||
|
'503': {description: Push or remote verification unavailable; recoverable}
|
||||||
|
x-openai-isConsequential: true
|
||||||
|
/api/commit:
|
||||||
|
post:
|
||||||
|
operationId: commitChanges
|
||||||
|
summary: Commit existing workspace changes on a non-main branch
|
||||||
|
security: [{bearerAuth: []}]
|
||||||
|
requestBody:
|
||||||
|
required: false
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {$ref: '#/components/schemas/CommitRequest'}
|
||||||
|
responses: {'200': {description: Commit result}, '401': {description: Unauthorized}}
|
||||||
|
x-openai-isConsequential: true
|
||||||
/api/tag-publication-status:
|
/api/tag-publication-status:
|
||||||
get:
|
get:
|
||||||
operationId: getTagPublicationStatus
|
operationId: getTagPublicationStatus
|
||||||
@@ -583,3 +675,206 @@ paths:
|
|||||||
'200': {description: Safe GitOps plan}
|
'200': {description: Safe GitOps plan}
|
||||||
'401': {description: Unauthorized}
|
'401': {description: Unauthorized}
|
||||||
x-openai-isConsequential: true
|
x-openai-isConsequential: true
|
||||||
|
/v2/capabilities:
|
||||||
|
get:
|
||||||
|
operationId: getGovernedWriteCapabilities
|
||||||
|
summary: Read the governed Bridge v2 capability contract
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Governed operations and their request/response contracts
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {$ref: '#/components/schemas/BridgeGovernedCapabilities'}
|
||||||
|
/v2/writer-lease:
|
||||||
|
post:
|
||||||
|
operationId: acquireWriterLease
|
||||||
|
summary: Acquire the exact governed writer lease for a task generation
|
||||||
|
security: [{bearerAuth: []}]
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {$ref: '#/components/schemas/BridgeWriterLeaseRecoveryRequest'}
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Exact detached worktree lease binding
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {$ref: '#/components/schemas/BridgeWriterLeaseResponse'}
|
||||||
|
'400': {description: Invalid lease binding}
|
||||||
|
'409': {description: Branch, base, or generation conflict}
|
||||||
|
x-openai-isConsequential: true
|
||||||
|
/v2/writer-lease/bootstrap:
|
||||||
|
post:
|
||||||
|
operationId: bootstrapWriterLease
|
||||||
|
summary: Bootstrap a governed writer runtime without creating a durable lease
|
||||||
|
description: >
|
||||||
|
Staging-only runtime recovery. It validates the exact task, repository,
|
||||||
|
base OID, expected branch OID, target branch, and generation. It must
|
||||||
|
not create a branch, insert a branch lease, or consume a generation.
|
||||||
|
security: [{bearerAuth: []}]
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {$ref: '#/components/schemas/BridgeWriterLeaseRecoveryRequest'}
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Runtime binding bootstrapped or returned idempotently
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {$ref: '#/components/schemas/BridgeWriterLeaseRecoveryResponse'}
|
||||||
|
'400': {description: Invalid lease binding}
|
||||||
|
'409': {description: A matching durable lease already exists or binding conflicts}
|
||||||
|
'503': {description: Runtime or durable state unavailable}
|
||||||
|
x-openai-isConsequential: true
|
||||||
|
/v2/writer-lease/renew:
|
||||||
|
post:
|
||||||
|
operationId: renewWriterLease
|
||||||
|
summary: Recover an expired governed writer lease without changing its binding
|
||||||
|
description: >
|
||||||
|
Renews only the current task generation and exact repository, base OID,
|
||||||
|
expected branch OID, and target branch. Durable renewal is revision-CAS
|
||||||
|
protected; runtime rehydration is reported separately.
|
||||||
|
security: [{bearerAuth: []}]
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {$ref: '#/components/schemas/BridgeWriterLeaseRecoveryRequest'}
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Lease recovered or idempotently observed as already active
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {$ref: '#/components/schemas/BridgeWriterLeaseRecoveryResponse'}
|
||||||
|
'400': {description: Invalid schema, generation, or lease duration}
|
||||||
|
'409': {description: Task, base, repository, branch, generation, or durable lease conflict}
|
||||||
|
'503': {description: Durable job store or runtime unavailable}
|
||||||
|
x-openai-isConsequential: true
|
||||||
|
/v2/commit-file:
|
||||||
|
post:
|
||||||
|
operationId: commitFileGoverned
|
||||||
|
summary: Commit one file through the governed writer lease
|
||||||
|
security: [{bearerAuth: []}]
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {$ref: '#/components/schemas/BridgeCommitFileRequest'}
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Authority and remote visibility receipt
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {$ref: '#/components/schemas/BridgeCommitResponse'}
|
||||||
|
'409': {description: Lease, expected-head, idempotency, or repository conflict}
|
||||||
|
'503': {description: Reconciliation required}
|
||||||
|
x-openai-isConsequential: true
|
||||||
|
/v2/commit-files:
|
||||||
|
post:
|
||||||
|
operationId: commitFilesGoverned
|
||||||
|
summary: Atomically commit a bounded set of files through the governed writer lease
|
||||||
|
security: [{bearerAuth: []}]
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {$ref: '#/components/schemas/BridgeCommitFilesRequest'}
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Authority and remote visibility receipt
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {$ref: '#/components/schemas/BridgeCommitResponse'}
|
||||||
|
'409': {description: Lease, expected-head, idempotency, or repository conflict}
|
||||||
|
'503': {description: Reconciliation required}
|
||||||
|
x-openai-isConsequential: true
|
||||||
|
|
||||||
|
/v2/execution-envelope:
|
||||||
|
post:
|
||||||
|
operationId: projectOnlineAgentExecution
|
||||||
|
summary: Project the canonical execution envelope for an existing governed task
|
||||||
|
description: Returns the durable task binding and canonical source_capture or deterministic projection.
|
||||||
|
security: [{bearerAuth: []}]
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [task_id]
|
||||||
|
properties:
|
||||||
|
task_id: {type: string, minLength: 3}
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Canonical execution envelope
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [schema, task_id, job_id, action, execution_digest]
|
||||||
|
properties:
|
||||||
|
schema: {type: string, enum: [aios.online-agent.execution.v2]}
|
||||||
|
task_id: {type: string}
|
||||||
|
job_id: {type: string}
|
||||||
|
action: {type: string, enum: [source_capture, deterministic]}
|
||||||
|
execution_digest: {type: string, pattern: '^[0-9a-f]{64}$'}
|
||||||
|
execution: {type: object, additionalProperties: true}
|
||||||
|
'400': {description: Missing or invalid task binding}
|
||||||
|
'409': {description: Canonical projection conflict}
|
||||||
|
x-openai-isConsequential: true
|
||||||
|
/v2/execution-plan:
|
||||||
|
post:
|
||||||
|
operationId: registerOnlineAgentExecutionPlan
|
||||||
|
summary: Register a deterministic plan for an existing governed task
|
||||||
|
description: Registers a non-empty deterministic change set and bounded verification plan against the exact source head.
|
||||||
|
security: [{bearerAuth: []}]
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [schema, task_id, job_id, source_head_oid, generation, idempotency_key, execution]
|
||||||
|
properties:
|
||||||
|
schema: {type: string, enum: [aios.online-agent.plan.v1]}
|
||||||
|
task_id: {type: string, minLength: 3}
|
||||||
|
job_id: {type: string, minLength: 8}
|
||||||
|
source_head_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
||||||
|
generation: {type: integer, minimum: 1}
|
||||||
|
idempotency_key: {type: string, minLength: 8}
|
||||||
|
expected_previous_plan_digest: {type: string, pattern: '^[0-9a-f]{64}$'}
|
||||||
|
execution:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [action, change_set, verification]
|
||||||
|
properties:
|
||||||
|
action: {type: string, const: deterministic}
|
||||||
|
adapter: {type: string}
|
||||||
|
capability_scope: {type: array, items: {type: string}}
|
||||||
|
change_set: {type: array, minItems: 1, maxItems: 128, items: {type: object, additionalProperties: true}}
|
||||||
|
verification: {type: array, maxItems: 128, items: {type: object, additionalProperties: true}}
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Deterministic plan registered or returned idempotently
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [schema, task_id, job_id, source_head_oid, generation, idempotency_key, plan_digest, execution]
|
||||||
|
properties:
|
||||||
|
schema: {type: string, enum: [aios-online-agent.plan-record.v1]}
|
||||||
|
task_id: {type: string}
|
||||||
|
job_id: {type: string}
|
||||||
|
source_head_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
||||||
|
generation: {type: integer}
|
||||||
|
idempotency_key: {type: string}
|
||||||
|
plan_digest: {type: string, pattern: '^[0-9a-f]{64}$'}
|
||||||
|
execution: {type: object, additionalProperties: true}
|
||||||
|
execution_digest: {type: string, pattern: '^[0-9a-f]{64}$'}
|
||||||
|
'400': {description: Invalid deterministic plan or source head}
|
||||||
|
'409': {description: Binding, idempotency, or replacement CAS conflict}
|
||||||
|
x-openai-isConsequential: true
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
# Online Agent Automatic Claim Infrastructure
|
||||||
|
|
||||||
|
## Status and fixed boundary
|
||||||
|
|
||||||
|
This candidate targets `p0-claim-online-agent-auto-execution-20260817` on governed Job
|
||||||
|
`job-2031a9e5682a4238f98e3ac7aee8a593`, base
|
||||||
|
`a642b045775b9154a00ae78543037fbef4fb447b`, target branch
|
||||||
|
`agent/p0-claim-online-agent-auto-execution-20260817`, generation 1.
|
||||||
|
|
||||||
|
It does not authorize protected-main, Production, tag, baseline, raw Git, Task DB, or
|
||||||
|
credential mutations.
|
||||||
|
|
||||||
|
## Considered approaches
|
||||||
|
|
||||||
|
1. Require every caller to write `metadata.execution`. This is simple but preserves the
|
||||||
|
current operational gap: a valid governed Job can still project an unexecutable Online
|
||||||
|
Task.
|
||||||
|
2. Let workers infer repository and WriterLease coordinates from natural-language Task
|
||||||
|
fields. This removes the skip but violates fail-closed lineage and can bind the wrong Job,
|
||||||
|
branch, base, or generation.
|
||||||
|
3. Use a canonical server-side projector plus a task-agnostic worker resolver. This is the
|
||||||
|
selected approach. The governed binding remains the only authority for repository, Job,
|
||||||
|
branch, base, expected head, generation, and idempotency. Producers can persist the
|
||||||
|
projected envelope when creating the Online Task. A worker may recover an omitted envelope
|
||||||
|
only from the same canonical projector and durable binding; it never guesses coordinates.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### Canonical execution contract
|
||||||
|
|
||||||
|
`aios_online_agent_execution.py` owns schema `aios.online-agent.execution.v2`. It validates
|
||||||
|
the complete governed lineage and supports two explicit actions:
|
||||||
|
|
||||||
|
- `deterministic`: a hash-bound change set, verification commands, and governed commit;
|
||||||
|
- `source_capture`: a read-only first attempt that returns `NEEDS_REWORK_SOURCE_CAPTURE` and
|
||||||
|
cannot acquire WriterLease or mutate Git.
|
||||||
|
|
||||||
|
Wrong task, Job, repository, base, expected head, branch, generation, capability scope, or
|
||||||
|
governance policy fails closed with a canonical diagnostic.
|
||||||
|
|
||||||
|
### Governed Job to Online Task projection
|
||||||
|
|
||||||
|
`GovernedTaskBootstrap` exposes an exact durable binding lookup and builds Online Task
|
||||||
|
projection data through the canonical contract. When the Bridge is configured with the
|
||||||
|
official Agent Task control credential reference, governed Job creation submits that complete
|
||||||
|
projection through the official Task HTTP API. The Bridge also exposes a loopback envelope
|
||||||
|
projection operation used for recovery. The persisted projection includes the versioned
|
||||||
|
envelope rather than relying on manual metadata edits.
|
||||||
|
|
||||||
|
Projection delivery is a durable outbox, not a best-effort tail call. The outbox record is
|
||||||
|
written before HTTP delivery and a Bridge reconciler retries it after transient API failure or
|
||||||
|
process restart. A response is accepted only when its complete execution envelope equals the
|
||||||
|
submitted projection. Requests that explicitly require projection are rejected before governed
|
||||||
|
bootstrap when the projection principal is unavailable.
|
||||||
|
|
||||||
|
Loopback is not treated as an identity boundary. Every POST route requires an exact
|
||||||
|
runtime-principal bearer credential supplied by systemd credential projection; unauthenticated
|
||||||
|
local callers are denied before request dispatch. The control-plane credential used to create
|
||||||
|
Online Tasks remains a separate Bridge-only reference and is never projected into the worker.
|
||||||
|
|
||||||
|
For legacy Online Tasks with an omitted envelope, the generic worker asks the same Bridge
|
||||||
|
operation for a read-only `source_capture` envelope. Projection succeeds only when the exact
|
||||||
|
durable binding exists. An omitted envelope is therefore recoverable; an invalid envelope is
|
||||||
|
never replaced or weakened.
|
||||||
|
|
||||||
|
Persisted envelopes are also reprojected through the Bridge before claim and compared by exact
|
||||||
|
digest and Job lineage. A self-consistent client-generated envelope is therefore not authority.
|
||||||
|
|
||||||
|
### Dynamic worker and trusted consumer
|
||||||
|
|
||||||
|
The repository gains a deployable generic worker and systemd unit definitions as governed
|
||||||
|
source files. The worker validates or
|
||||||
|
resolves an envelope before claim, revalidates after atomic claim, starts heartbeat, and then
|
||||||
|
executes the selected action. The trusted engineering consumer uses this same task-agnostic
|
||||||
|
entrypoint. No compiled worker contains a Task ID, base OID, branch, generation, or plan path.
|
||||||
|
|
||||||
|
Skip observability distinguishes:
|
||||||
|
|
||||||
|
- `TASK_SKIPPED_PROJECTION_MISSING`;
|
||||||
|
- `TASK_SKIPPED_ENVELOPE_INVALID`;
|
||||||
|
- `TASK_SKIPPED_CAPABILITY_MISMATCH`;
|
||||||
|
- `TASK_SKIPPED_POLICY_DENY`;
|
||||||
|
- `TASK_POLL_FAILED` / worker unavailable.
|
||||||
|
|
||||||
|
## State flow
|
||||||
|
|
||||||
|
```text
|
||||||
|
Governed Job BOUND
|
||||||
|
-> canonical durable binding
|
||||||
|
-> Online Task projection with execution.v2
|
||||||
|
-> capability-filtered poll
|
||||||
|
-> envelope and lineage validation
|
||||||
|
-> atomic claim
|
||||||
|
-> initial heartbeat/start
|
||||||
|
-> refetch Task and re-resolve envelope
|
||||||
|
-> compare execution digest and Job
|
||||||
|
-> source capture OR deterministic governed execution
|
||||||
|
-> result construction
|
||||||
|
-> result submission
|
||||||
|
-> external server-side verification gate
|
||||||
|
```
|
||||||
|
|
||||||
|
Legacy omission uses `binding -> source_capture projection` between poll and validation. It
|
||||||
|
does not write Task metadata and does not convert a malformed envelope into a valid one.
|
||||||
|
|
||||||
|
## Recovery and safety
|
||||||
|
|
||||||
|
- Projection is deterministic and digest-bound.
|
||||||
|
- Governed Task creation is serialized by a cross-process, Task-ID-digest lock. Durable paths
|
||||||
|
use collision-resistant exact Task keys while retaining read compatibility with verified
|
||||||
|
legacy bindings.
|
||||||
|
- A new Task cannot adopt an existing branch from caller-selected generation data. It must name
|
||||||
|
an exact durable parent Task/Job whose single ACTIVE lease owns the branch head; generation is
|
||||||
|
then derived from that lease.
|
||||||
|
- Claim is attempted only after validation; claimed payload drift is denied.
|
||||||
|
- Source capture has no WriterLease or commit path.
|
||||||
|
- Deterministic execution retains detached worktree, exact WriterLease, governed commit,
|
||||||
|
durable current-head CAS, and receipt convergence.
|
||||||
|
- Local deterministic recovery is keyed by Task and execution digest; live acceptance must
|
||||||
|
independently reconverge Job, lease, receipt, and branch OIDs before verification.
|
||||||
|
- An ambiguous commit transport or server outcome becomes `RECONCILIATION_REQUIRED`; the
|
||||||
|
worker does not submit a canonical failure until exact Job receipt and head evidence is
|
||||||
|
observed.
|
||||||
|
- A completed read-only `source_capture` state is ignored when a later attempt has a changed
|
||||||
|
digest and no governed state exists; all other digest drift is denied.
|
||||||
|
- Protected refs are never worker targets.
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
The implementation must pass the named projection, schema, claimability, dynamic-binding,
|
||||||
|
wrong-lineage, capability, P0-A, and P0-B tests. Runtime acceptance additionally requires a
|
||||||
|
real Online Task to be claimed and heartbeated without per-task worker or systemd plan
|
||||||
|
injection, plus governed receipt/OID/CAS convergence for this implementation Task.
|
||||||
|
Unit tests and deployable unit definitions do not by themselves satisfy runtime acceptance.
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# P0-CLAIM Scheme A follow-up
|
||||||
|
|
||||||
|
This follow-up continues the existing candidate `71523aef6e48b1e2fa516c456ca189b7a039104b`.
|
||||||
|
It does not create a replacement Task, Job, branch or generation.
|
||||||
|
|
||||||
|
## Retry extension
|
||||||
|
|
||||||
|
A versioned Owner-controlled loopback API provides
|
||||||
|
`POST /v1/tasks/{task_id}/retry-extension`.
|
||||||
|
|
||||||
|
The request binds FAILED status, revision, exhausted attempt/max_attempts, next max attempts,
|
||||||
|
previous verification SHA256, Owner authorization ID and idempotency key. The service performs one
|
||||||
|
SQLite `BEGIN IMMEDIATE` transaction containing the CAS update, durable retry-extension record and
|
||||||
|
Task event. Each authorization extends the boundary by exactly one attempt.
|
||||||
|
|
||||||
|
## Same-Job continuation
|
||||||
|
|
||||||
|
The durable GovernedTask binding remains the lineage anchor. After the first CREATE_NEW commit,
|
||||||
|
`execution_binding()` may normalize that SAME Job to `CONTINUE_EXISTING` only when exactly one
|
||||||
|
ACTIVE WriterLease for the Job owns the current authority branch head, generation and lineage
|
||||||
|
anchor. The lineage `base_oid` stays unchanged; only `expected_branch_oid` becomes the current head.
|
||||||
|
|
||||||
|
GovernedTask creation is also serialized by a branch-digest lock, preventing sibling continuation
|
||||||
|
tasks from racing generation admission.
|
||||||
|
|
||||||
|
## Projection and recovery
|
||||||
|
|
||||||
|
Natural-language source signals are normalized server-side to at most 16 unique 512-byte values.
|
||||||
|
Malformed persisted envelopes still fail closed.
|
||||||
|
|
||||||
|
Fresh commits and recovery require a COMPLETED Job, `remote_verified=true`, exact
|
||||||
|
authority/API/remote receipt OIDs, and exactly one ACTIVE WriterLease whose durable current head
|
||||||
|
matches the commit. Delayed local ref visibility becomes `RECONCILIATION_REQUIRED`, not a terminal
|
||||||
|
canonical failure.
|
||||||
|
|
||||||
|
## Credential isolation
|
||||||
|
|
||||||
|
The credentialed Coordinator performs only Task/Bridge coordination and governed commit. All
|
||||||
|
deterministic repository modification and test commands run in
|
||||||
|
`aios-online-agent-isolated-executor` under a separate system user with:
|
||||||
|
|
||||||
|
- no Agent/Owner credential projection;
|
||||||
|
- no gitops supplementary group;
|
||||||
|
- `PrivateNetwork=true`;
|
||||||
|
- `RestrictAddressFamilies=AF_UNIX AF_INET`, with `IPAddressDeny=any` and
|
||||||
|
`IPAddressAllow=localhost`, so regression tests may use an isolated loopback
|
||||||
|
HTTP server without gaining host or external network reachability;
|
||||||
|
- `InaccessiblePaths=/run/credentials`.
|
||||||
|
|
||||||
|
The executor receives a hash-bound source archive and deterministic change set through a filesystem
|
||||||
|
exchange and returns only changed file bytes/hashes plus command evidence. The Coordinator validates
|
||||||
|
that response before materializing exact allowed files and calling the governed Bridge.
|
||||||
|
|
||||||
|
Codex/Hermes mutation adapters remain disabled until a separately audited uncredentialed planner
|
||||||
|
backend exists. Under the current operating model, implementation code is authored before Online
|
||||||
|
Agent execution and is carried as a deterministic plan.
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -Eeuo pipefail
|
|
||||||
umask 027
|
|
||||||
|
|
||||||
ROOT="/data/aios-gitops"
|
|
||||||
NGINX_SITE="/etc/nginx/sites-enabled/aios-user-portal.conf"
|
|
||||||
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
|
|
||||||
BACKUP_DIR="$ROOT/backups/action-surface-final-$STAMP"
|
|
||||||
LOG="$ROOT/logs/action-surface-final-$STAMP.log"
|
|
||||||
|
|
||||||
mkdir -p "$BACKUP_DIR" "$ROOT/logs"
|
|
||||||
exec > >(tee -a "$LOG") 2>&1
|
|
||||||
|
|
||||||
sudo cp -a "$NGINX_SITE" "$BACKUP_DIR/aios-user-portal.conf"
|
|
||||||
SITE_BACKUP="$BACKUP_DIR/aios-user-portal.conf"
|
|
||||||
|
|
||||||
python3 - "$SITE_BACKUP" "$BACKUP_DIR/aios-user-portal.conf.next" <<'PY'
|
|
||||||
from pathlib import Path
|
|
||||||
import sys
|
|
||||||
|
|
||||||
source, output = map(Path, sys.argv[1:])
|
|
||||||
text = source.read_text(encoding="utf-8")
|
|
||||||
bridge_marker = " # AIOS external Action Bridge v2 staging route"
|
|
||||||
bridge_block = r''' # AIOS external Action Bridge v2 staging route
|
|
||||||
# The published Action schema uses the site-root /v2 paths and must reach
|
|
||||||
# the non-production Bridge v2 listener, never the production 18920 unit.
|
|
||||||
location ^~ /v2/ {
|
|
||||||
proxy_pass http://127.0.0.1:18892;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header Authorization $http_authorization;
|
|
||||||
proxy_set_header X-AIOS-Token $http_x_aios_token;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
client_max_body_size 1100k;
|
|
||||||
proxy_connect_timeout 5s;
|
|
||||||
proxy_send_timeout 120s;
|
|
||||||
proxy_read_timeout 120s;
|
|
||||||
}
|
|
||||||
'''
|
|
||||||
if bridge_marker not in text:
|
|
||||||
needle = " # BEGIN AIOS CHATGPT AUTONOMOUS RELEASE TASK ROUTE"
|
|
||||||
if text.count(needle) != 1:
|
|
||||||
raise SystemExit("expected exactly one legacy Action route marker")
|
|
||||||
text = text.replace(needle, bridge_block + "\n" + needle, 1)
|
|
||||||
elif "location ^~ /v2/" not in text or "proxy_pass http://127.0.0.1:18892;" not in text:
|
|
||||||
raise SystemExit("staging Bridge v2 route marker exists but route is incomplete")
|
|
||||||
|
|
||||||
marker = " # AIOS external Action governed-read ownership"
|
|
||||||
block = r''' # AIOS external Action governed-read ownership
|
|
||||||
# These reads are owned by the authenticated 18890 facade, not the 18893 model API.
|
|
||||||
location = /api/governed-write-capabilities {
|
|
||||||
proxy_pass http://127.0.0.1:18890/api/governed-write-capabilities;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_set_header Authorization $http_authorization;
|
|
||||||
proxy_set_header X-AIOS-Token $http_x_aios_token;
|
|
||||||
}
|
|
||||||
location = /api/control-plane-snapshot {
|
|
||||||
proxy_pass http://127.0.0.1:18890/api/control-plane-snapshot;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_set_header Authorization $http_authorization;
|
|
||||||
proxy_set_header X-AIOS-Token $http_x_aios_token;
|
|
||||||
}
|
|
||||||
location = /api/online-agent-task {
|
|
||||||
proxy_pass http://127.0.0.1:18890/api/online-agent-task;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_set_header Authorization $http_authorization;
|
|
||||||
proxy_set_header X-AIOS-Token $http_x_aios_token;
|
|
||||||
}
|
|
||||||
location = /api/online-agent-task-events {
|
|
||||||
proxy_pass http://127.0.0.1:18890/api/online-agent-task-events;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_set_header Authorization $http_authorization;
|
|
||||||
proxy_set_header X-AIOS-Token $http_x_aios_token;
|
|
||||||
}
|
|
||||||
'''
|
|
||||||
needle = " location /api/ {"
|
|
||||||
if text.count(needle) != 1:
|
|
||||||
raise SystemExit("expected exactly one generic /api/ location")
|
|
||||||
if marker not in text:
|
|
||||||
text = text.replace(needle, block + "\n" + needle, 1)
|
|
||||||
output.write_text(text, encoding="utf-8")
|
|
||||||
PY
|
|
||||||
|
|
||||||
sudo cp "$BACKUP_DIR/aios-user-portal.conf.next" "$NGINX_SITE"
|
|
||||||
sudo nginx -t
|
|
||||||
sudo systemctl reload nginx
|
|
||||||
|
|
||||||
printf 'ACTION_SURFACE_FINAL_OK backup=%s log=%s\n' "$BACKUP_DIR" "$LOG"
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
"""Canonical AIOS release-version parser shared by runtime and release tools."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
|
|
||||||
|
|
||||||
# The suffix preserves already-supported annotated release names while the
|
|
||||||
# canonical VnRnCn namespace remains accepted exactly.
|
|
||||||
RELEASE_VERSION_PATTERN = r"^V[0-9]+R[0-9]+C[0-9]+(?:-[A-Za-z0-9_.-]+)?$"
|
|
||||||
RELEASE_VERSION_RE = re.compile(RELEASE_VERSION_PATTERN)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_release_version(version: object) -> str:
|
|
||||||
value = str(version or "").strip()
|
|
||||||
if not RELEASE_VERSION_RE.fullmatch(value):
|
|
||||||
raise ValueError("invalid release version")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def validate_release_version(version: object) -> str:
|
|
||||||
return parse_release_version(version)
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
# AIOS Action Connector Schema Final Closure — 2026-08-25
|
|
||||||
|
|
||||||
## Disposition
|
|
||||||
|
|
||||||
`NEEDS_REWORK`.
|
|
||||||
|
|
||||||
The staging runtime and published OpenAPI contract are repaired and the public `/v2` staging route is now reachable. The final P0-C acceptance cannot be claimed because this execution environment has no callable ChatGPT Action/connector inspection or registration tool, and the deployed repository intentionally has no connector acknowledgement/write endpoint. Therefore the required proof of the actual callable tool schema, a real public two-file batch commit, and its public terminal reconcile are not available. The task must not be marked `SUCCEEDED`.
|
|
||||||
|
|
||||||
No main, protected tag, baseline registry, or production write was performed.
|
|
||||||
|
|
||||||
## Scope and live identity
|
|
||||||
|
|
||||||
- Repository: `aios/aios`
|
|
||||||
- Task: `fix-action-contracts-20260825-1407`
|
|
||||||
- Branch: `agent/fix-action-contracts-20260825-1407`
|
|
||||||
- Source worktree: `/var/lib/aios-gitops/bridge-v2-staging/worktrees/fix-action-contracts-20260825-1407`
|
|
||||||
- Source HEAD before this closure patch: `eb72fcd8dce242abaef15f03a21b43ba2c5197fd`
|
|
||||||
- Deployed staging Bridge runtime commit: `2ec49005bde44f6981b14351500847d594e0276c`
|
|
||||||
- Deployed Bridge artifact SHA-256: `f67aed6cc0cce1f08197485c3eea204d3ef4d3989f44dc98e8ea2691e5886cbf`
|
|
||||||
- Bridge unit: `aios-chatgpt-bridge-v2-staging.service`, `aiman:gitops`, `127.0.0.1:18892`
|
|
||||||
- Model API unit: `aios-gitops-model-api-v2r2c1-staging.service`, `127.0.0.1:18893`
|
|
||||||
- Gitea API base projected by staging systemd: `https://g.cnnetmkt.top/git/api/v1`
|
|
||||||
- Runtime state: `STAGING`, `drift_status=PASS`, `legacy_global_writes_enabled=false`, `main_write=false`, `tag_write=false`, `baseline_write=false`, `production_write=false`
|
|
||||||
|
|
||||||
## Root causes and contract chain
|
|
||||||
|
|
||||||
1. Runtime source was already repaired to require `files` for `commitFilesGoverned`. `active-runtime/app/aios_chatgpt_bridge_v2.py` declares the canonical required tuple and `render_openapi_contract_fragment()` emits the operation fragment.
|
|
||||||
2. `scripts/build_v2r2c1_model_api_staging.py` copies the canonical `docs/gitops/openapi-chatgpt-actions.yaml` into the immutable staging artifact and rejects a build missing `/v2/commit-files`.
|
|
||||||
3. The deployed public OpenAPI is served from the model API workspace at `/api/openapi-chatgpt-actions.yaml`; its SHA-256 was `a0775b0719a76e08a95f3257a3d3b2a73b70e1c55c162914250f80dd28da022d`. It contains `/v2/commit-files`, `files`, and item fields `path` and `content_base64`.
|
|
||||||
4. The missing deployment link was Nginx: OpenAPI `servers.url` is `https://cnnetmkt.top` and its operation paths are `/v2/...`, but the live site had no `/v2/` location and the old `/api/v2/` location pointed at absent production port `18920`. The old `/api/file` route is an inactive legacy single-file router.
|
|
||||||
5. A repository-wide and host-side inspection found no formal external ChatGPT connector registration source, schema cache, re-registration command, or acknowledgement endpoint. The existing durable-owner design explicitly states that no connector acknowledgement/write endpoint is provided and the only valid state is `PENDING_EXTERNAL_CONNECTOR`. This is the remaining external blocker, not a reason to create a parallel local schema or reuse another credential.
|
|
||||||
|
|
||||||
## Changes made
|
|
||||||
|
|
||||||
- `ops/gitops-migration/bind_external_action_surface_final_staging.sh`
|
|
||||||
- added an idempotent exact `/v2/` route to staging Bridge `127.0.0.1:18892`;
|
|
||||||
- forwards `Authorization` and `X-AIOS-Token`;
|
|
||||||
- keeps the existing exact governed-read routes on `18890` and generic `/api/` on `18893`;
|
|
||||||
- refuses an incomplete existing staging route and never adds a route to production `18920`.
|
|
||||||
- `tests/test_action_contract_regressions.py`
|
|
||||||
- added a parity/deployment test tying published `/v2/commit-files` to the staging route and asserting no `18920` target in the staging installer.
|
|
||||||
- `runbooks/action-connector-schema-refresh-20260825.md`
|
|
||||||
- added the durable connector refresh and fail-closed acceptance procedure.
|
|
||||||
- `reports/action-connector-schema-final-closure-20260825.md`
|
|
||||||
- this evidence report.
|
|
||||||
|
|
||||||
The source/runtime contract parity test remains in place and still compares runtime required fields, generated fragment, and published schema. The live connector layer cannot be parity-tested because no connector layer is deployed or callable from this environment; the gate therefore remains fail-closed rather than accepting a fixture as proof.
|
|
||||||
|
|
||||||
## Deployment and restart evidence
|
|
||||||
|
|
||||||
The existing staging installer was run after `git diff --check` and focused tests. It created the Nginx backup `/data/aios-gitops/backups/action-surface-final-20260825T105656Z`, passed `nginx -t`, and reloaded Nginx. Public readback then returned HTTP 200 for:
|
|
||||||
|
|
||||||
- `https://cnnetmkt.top/v2/runtime`
|
|
||||||
- `https://cnnetmkt.top/v2/capabilities`
|
|
||||||
- `https://cnnetmkt.top/api/openapi-chatgpt-actions.yaml`
|
|
||||||
|
|
||||||
The staging Bridge and Model API were subsequently restarted once. Both `systemctl is-active` checks returned `active`. Post-restart `/v2/runtime` retained the staging identity and artifact SHA above; `/v2/capabilities` retained `commitFilesGoverned.required_fields` including `files`.
|
|
||||||
|
|
||||||
## Tests and live smoke
|
|
||||||
|
|
||||||
Passing results:
|
|
||||||
|
|
||||||
- `python3 -m pytest -q tests/test_action_contract_regressions.py tests/test_bridge_v2_governed_http_bootstrap.py` — `17 passed`.
|
|
||||||
- Mandatory suite — `264 passed, 28 subtests passed in 4.81s`.
|
|
||||||
- Full repository suite — `720 passed, 2 skipped, 2 failed, 154 subtests passed in 34.02s`. The two failures are unchanged known failures: stale `/tmp/aios-agent-events-test` fixture and deployed signer self-test return code `42`.
|
|
||||||
|
|
||||||
Post-restart live results:
|
|
||||||
|
|
||||||
- `getReleaseConsistency(V2R2C1, ...)`: HTTP 200 structured consistency response; no Gitea URL validation failure. Gitea listing and peeled target readback were visible.
|
|
||||||
- `runReleaseAcceptance(V2R2C1, ...)`: HTTP 200 structured policy result with `accepted=false`; it reached policy evaluation rather than failing URL validation.
|
|
||||||
- `getTagPublicationStatus("V2R2C1")`: HTTP 200 structured state (`REMOTE_PUSH_PENDING` on Bridge v2; the legacy model read surface returned structured `UNKNOWN`).
|
|
||||||
- `getReleaseSaga(nonexistent)`: HTTP 404, normal NOT_FOUND behavior.
|
|
||||||
- Existing terminal job `job-93138017f3afd7f709f691fd05c77923`: HTTP 200, `COMPLETED`, revision `8`; terminal `/v2/job/reconcile` returned HTTP 200 with the same `COMPLETED` receipt and Authority/API/Git remote OID `904e7ea0cd376d204bad19628c36a9dd90ca6621`.
|
|
||||||
- Empty diff: HTTP 200, zero paths/size, `truncated=false`.
|
|
||||||
- Non-empty diff: HTTP 200 with identities, changed paths, stats, total size, patch limit `262144`, and bounded response fields.
|
|
||||||
- Public `POST /v2/commit-files` probes returned `BRIDGE_REQUEST_FIELDS_MISSING` for incomplete bodies; the body containing `files` was not rejected as an unknown field. No write side effect occurred.
|
|
||||||
|
|
||||||
## Missing final acceptance
|
|
||||||
|
|
||||||
The following required evidence is not available and is intentionally not inferred:
|
|
||||||
|
|
||||||
- actual ChatGPT callable Action tool schema showing required `files`;
|
|
||||||
- formal connector re-register/cache invalidation acknowledgement;
|
|
||||||
- an authorized public `commitFilesGoverned` request containing two files;
|
|
||||||
- Authority/Gitea/API remote OID equality for that new batch commit;
|
|
||||||
- its batch Job `COMPLETED` readback and public terminal reconcile;
|
|
||||||
- post-batch connector callable smoke.
|
|
||||||
|
|
||||||
The prior single-file public commit `904e7ea0cd376d204bad19628c36a9dd90ca6621` and its terminal reconcile are preserved as baseline evidence only; they are not substituted for the required batch proof.
|
|
||||||
|
|
||||||
## Protected boundaries and rollback
|
|
||||||
|
|
||||||
No source or deployment operation wrote `main`, a protected tag, the baseline registry, or production. The production-facing `18920` route was not changed; the new `/v2/` location targets only the staging Bridge listener. No secret value was printed, rotated, or reused. Automatic rollback was not triggered because Nginx syntax, staging service health, runtime identity, Gitea readback, and protected-ref boundaries remained valid. If a future connector refresh breaks these checks, restore the Nginx backup and the preceding staging artifact through the existing governed installer, then re-run the same probes.
|
|
||||||
|
|
||||||
## Next required owner/platform action
|
|
||||||
|
|
||||||
Provide the supported staging external Action connector lifecycle (registration/re-registration/cache invalidation) and an authorized staging Action bearer, or have the legitimate external Agent claim the existing continuation task and produce its durable callable-schema and batch-commit evidence. Until then the correct disposition remains `NEEDS_REWORK`; do not mark `SUCCEEDED` based on the server-side OpenAPI alone.
|
|
||||||
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
# Action Contract Regression Remediation — 2026-08-25
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
Governed task: `fix-action-contracts-20260825-1407`
|
|
||||||
Base: `3244f0f17f8845592ac94d585f5ebd65693c9f29`
|
|
||||||
Target branch: `agent/fix-action-contracts-20260825-1407`
|
|
||||||
|
|
||||||
## Verified regressions
|
|
||||||
|
|
||||||
1. `getReleaseConsistency` and `runReleaseAcceptance` fail before evaluation with:
|
|
||||||
`Gitea API URL must be HTTP(S)`.
|
|
||||||
V2R2C1 release evidence at `a8b906d1bc24088c19647e98de6a679268f6d0a2`
|
|
||||||
proves Gitea API readback passed for target `998d30304787e31e5ae607c0bd86b5edb2b0e5ad`.
|
|
||||||
Therefore current staging/contract overlay configuration has regressed.
|
|
||||||
|
|
||||||
2. `getReleaseSaga` fails with:
|
|
||||||
`Permission denied: '/var/lib/aios-gitops/silent-release'`.
|
|
||||||
Runtime service identity must have read/traverse access to the durable release state root.
|
|
||||||
Fix ownership/group/ACL at deployment time; do not broaden to world-writable permissions.
|
|
||||||
|
|
||||||
3. `getTagPublicationStatus` rejects the authoritative approved version `V2R2C1`
|
|
||||||
as `invalid release version`, while `getGovernedWriteCapabilities` returns the same
|
|
||||||
version as the approved release scope. The two Action contracts are internally inconsistent.
|
|
||||||
Release-version validation must accept the canonical AIOS `^V[0-9]+R[0-9]+C[0-9]+$`
|
|
||||||
namespace (or, preferably, use one shared validator for all release Actions).
|
|
||||||
|
|
||||||
4. `commitFilesGoverned` is unusable from the published Action schema.
|
|
||||||
Runtime capability metadata declares required fields:
|
|
||||||
`task_id, repository, expected_branch_oid, target_branch, generation, idempotency_key, files, message`,
|
|
||||||
but the exposed tool contract has no `files` input. Regenerate/publish the Action schema from
|
|
||||||
the same operation contract source and add a contract-parity test.
|
|
||||||
|
|
||||||
5. `reconcileGitOpsJob` returned `DURABLE_BINDING_INVALID` for a completed governed
|
|
||||||
single-file job that was otherwise read back as `COMPLETED`.
|
|
||||||
Reconciliation of a valid terminal job should be idempotent/read-only and must not require
|
|
||||||
a stale or absent active lease. Add a regression case for terminal completed jobs.
|
|
||||||
|
|
||||||
6. `getDiff` returns `ResponseTooLargeError` / `GIT_OUTPUT_LIMIT_EXCEEDED` for large diffs.
|
|
||||||
Add server-side bounded output: path/stat summary plus truncation metadata, or pagination.
|
|
||||||
The Action must never fail merely because a valid repository diff is large.
|
|
||||||
|
|
||||||
## Required implementation changes
|
|
||||||
|
|
||||||
- Centralize release-version parsing and reuse it in tag status, candidate, consistency, and acceptance.
|
|
||||||
- Validate/normalize Gitea API URL at service startup and fail health checks with a configuration-specific diagnostic.
|
|
||||||
- Ensure the deployment unit projects a valid HTTP(S) Gitea API base URL into the staging overlay.
|
|
||||||
- Correct durable release-state directory ownership/ACL for the runtime service account.
|
|
||||||
- Generate the external Action schema from the runtime operation contract; parity-test required fields.
|
|
||||||
- Make terminal reconciliation idempotent and independent of expired active writer leases.
|
|
||||||
- Bound diff output and return `truncated=true` with deterministic metadata rather than transport failure.
|
|
||||||
|
|
||||||
## Acceptance gates
|
|
||||||
|
|
||||||
All must pass on the same deployed overlay:
|
|
||||||
|
|
||||||
- `getReleaseConsistency(V2R2C1, 998d303..., a8b906d..., approved_plan)` returns a structured result, not URL error.
|
|
||||||
- `runReleaseAcceptance(...)` reaches policy evaluation, not URL error.
|
|
||||||
- `getTagPublicationStatus("V2R2C1")` returns structured publication state.
|
|
||||||
- `getReleaseSaga(nonexistent_candidate)` returns NOT_FOUND/empty-state semantics, never filesystem permission failure.
|
|
||||||
- Published `commitFilesGoverned` schema exposes `files` and a two-file isolated branch commit succeeds.
|
|
||||||
- Reconcile of a completed governed job is idempotent.
|
|
||||||
- Large diff returns bounded/truncated data rather than transport exception.
|
|
||||||
- Existing protected-ref controls remain unchanged: no direct main/tag/baseline/production write.
|
|
||||||
|
|
||||||
## Proven stable reference
|
|
||||||
|
|
||||||
V2R2C1 evidence closure (`a8b906d1bc24088c19647e98de6a679268f6d0a2`) records:
|
|
||||||
- Gitea Git/API readback PASS
|
|
||||||
- staging runtime PASS
|
|
||||||
- restart recovery PASS
|
|
||||||
- 59 mandatory tests passed / 0 failed
|
|
||||||
|
|
||||||
This evidence should be used as the compatibility baseline when repairing the current
|
|
||||||
`contract-overlay-v2` / `scheme-a-323b615b...` staging layer.
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
# AIOS Action contract repair evidence — 2026-08-25
|
|
||||||
|
|
||||||
## Disposition
|
|
||||||
|
|
||||||
Final disposition: `NEEDS_REWORK`.
|
|
||||||
|
|
||||||
The contract repair is deployed to staging and the focused acceptance checks are green. The task is not eligible for `SUCCEEDED`: `runReleaseAcceptance` returns structured policy output with `accepted=false` because the existing publication state for protected tag `V2R2C1` is `REMOTE_PUSH_PENDING`, and the repository-wide suite still has the two pre-existing signer/event-bus failures recorded below. The online Task API row is still `QUEUED`; it was not changed by direct database manipulation or an unauthorized synthetic claim/result.
|
|
||||||
|
|
||||||
No production release, `main`, release tag, baseline registry, or production service was directly modified.
|
|
||||||
|
|
||||||
## Scope and pre-change facts
|
|
||||||
|
|
||||||
- Repository: `aios/aios`; governed branch: `agent/fix-action-contracts-20260825-1407`.
|
|
||||||
- Initial task branch HEAD: `35eec9afe1432e8466d60040b2fb1049bb41242a`.
|
|
||||||
- Stable reference: `V2R2C1`; source commit `998d30304787e31e5ae607c0bd86b5edb2b0e5ad`; evidence closure `a8b906d1bc24088c19647e98de6a679268f6d0a2`.
|
|
||||||
- Pre-change refs: authority `main=3244f0f17f8845592ac94d585f5ebd65693c9f29`, tag `V2R2C1=07fc2746b688f5f243f29b39de39f32edc3dd9d2`; these remained unchanged.
|
|
||||||
- Staging services run as `User=aiman`, `Group=gitops`. The actual staging Gitea API base is `https://g.cnnetmkt.top/git/api/v1`, supplied by systemd environment rather than business-code host constants.
|
|
||||||
- Pre-change full test baseline: `707 passed, 2 skipped, 154 subtests passed, 2 failed`. The failures were the stale `/tmp/aios-agent-events-test` event-bus fixture and the real signer self-test.
|
|
||||||
|
|
||||||
## Root causes and repairs
|
|
||||||
|
|
||||||
1. Gitea URL validation occurred at release-operation time while the staging overlay's API environment was not consistently projected into all runtime paths. The canonical HTTP(S) validator is now called during model and Bridge startup; `getReleaseConsistency` and `runReleaseAcceptance` use the actual staging environment URL.
|
|
||||||
|
|
||||||
2. `silent-release` was not consistently writable by the real service identity, and the credential helper parent directory blocked `aiman` traversal. The persisted staging systemd overlay creates the state directory as `aiman:gitops` mode `0750` and grants only `u:aiman:--x` on `/usr/local/libexec`; no world-writable mode was used. The staging bridge also has the minimum repository write path required by the governed AuthorityGitExecutor/CAS flow.
|
|
||||||
|
|
||||||
3. Release version parsing was split across callers and rejected `V2R2C1`. A shared canonical parser now accepts `^V[0-9]+R[0-9]+C[0-9]+$` plus the existing bounded suffix form, and is used by capabilities, release candidate, consistency, acceptance, tag publication, and release/tag queries.
|
|
||||||
|
|
||||||
4. The runtime operation contract required `files` but the published Action schema omitted it. `commitFilesGoverned` now has one contract source and published schema projection, with parity tests. A real governed two-file commit completed on isolated branch `agent/action-contract-integration-20260825-1407`; authority, Git remote, and Gitea API all read back OID `0345cd8c70967940bc8e9132dff7d6ea11efa304`.
|
|
||||||
|
|
||||||
5. `reconcileGitOpsJob` reconstructed active lease state before checking terminal state. Terminal `COMPLETED`, `FAILED`, and `CANCELLED` jobs now short-circuit to idempotent read-back; active jobs retain strict lease validation. The two-file job reconciled as `COMPLETED` and a second reconcile returned the same receipt.
|
|
||||||
|
|
||||||
6. `getDiff` returned unbounded patch data and could fail the transport. It now returns identities, changed paths, stats, total bytes, bounded patch output, patch limit, and `truncated`. The large smoke returned `total_size_bytes=350222`, `stdout_bytes=262144`, `patch_limit_bytes=262144`, `truncated=true`; no `ResponseTooLargeError` or `GIT_OUTPUT_LIMIT_EXCEEDED` escaped.
|
|
||||||
|
|
||||||
During live integration, two additional staging-only contract defects were found and repaired: CREATE_NEW branch normalization/CAS recovery, and release executor projection of the configured staging baseline plus immutable runtime `versions/V2R2C1.json`. The latter removed the missing publication-workspace baseline/version errors without writing production state.
|
|
||||||
|
|
||||||
## Source, test, and operational changes
|
|
||||||
|
|
||||||
Source commits on the governed branch include the six contract repairs, focused regression tests, `versions/V2R2C1.json`, governed CREATE_NEW/reconcile recovery, and final runtime projection commits. The final source before this evidence commit is `2ec49005bde44f6981b14351500847d594e0276c`.
|
|
||||||
|
|
||||||
The runbook update is in `runbooks/agent-fix-action-contracts-20260825.md`. This report is the required durable evidence artifact. Existing untracked `runtime/agent-observability/` and `runtime/task-center/` directories were preserved and were not staged.
|
|
||||||
|
|
||||||
Tests run on the final source:
|
|
||||||
|
|
||||||
```text
|
|
||||||
PYTHONPATH=active-runtime/app python3 -m pytest -q tests/test_silent_release_execution_readiness.py tests/test_silent_release_controller.py tests/test_silent_release_hardening.py tests/test_silent_release_production_contract.py tests/test_chatgpt_actions_tag.py tests/test_gitea_tag_publication.py tests/test_authoritative_git_context.py
|
|
||||||
264 passed, 28 subtests passed in 4.78s
|
|
||||||
|
|
||||||
PYTHONPATH=active-runtime/app python3 -m pytest -q tests
|
|
||||||
717 passed, 2 skipped, 154 subtests passed, 2 failed in 33.98s
|
|
||||||
```
|
|
||||||
|
|
||||||
The full-suite failures remain the known event-bus fixture failure and the real signer adapter self-test (`returncode=42`); no new mandatory-suite regression was observed. Focused contract/reconcile tests and the exact mandatory suite passed.
|
|
||||||
|
|
||||||
## Staging deployment identity
|
|
||||||
|
|
||||||
- Final deployed source OID at the last completed runtime smoke: `2ec49005bde44f6981b14351500847d594e0276c`.
|
|
||||||
- Final deployed artifact: `/opt/aios/gitops-model-api/2ec49005bde44f6981b14351500847d594e0276c-action-contract-repair/runtime`.
|
|
||||||
- Artifact SHA256: `f67aed6cc0cce1f08197485c3eea204d3ef4d3989f44dc98e8ea2691e5886cbf`.
|
|
||||||
- Deployment state: `STAGING`; production-deployed flag: `false`.
|
|
||||||
- A lexically highest staging-only systemd drop-in was installed for each affected service, followed by `daemon-reload` and restart. `systemctl is-active` returned `active` for both services, and `systemctl show` confirmed the final OID, artifact SHA, API URL, and baseline authority.
|
|
||||||
|
|
||||||
## Runtime smoke evidence
|
|
||||||
|
|
||||||
- Model health returned `status=ok`, `version=V2R2C1`, final commit/artifact identity, `deployment_state=STAGING`, and `production_deployed=false`.
|
|
||||||
- Bridge/API readback was available; the bridge's `/health` path is not exposed by this runtime and returned `404`, while its governed endpoints were healthy.
|
|
||||||
- `getReleaseConsistency("V2R2C1", ...)` returned structured `status=PASS`, with Gitea listing/peeled target and tag object matching the stable reference.
|
|
||||||
- `runReleaseAcceptance(...)` entered policy evaluation and returned structured `accepted=false`; it did not fail URL validation. The independent pre-existing tag publication state is `REMOTE_PUSH_PENDING`, with the remote rejection explicitly reporting protected tag `V2R2C1`.
|
|
||||||
- `getTagPublicationStatus("V2R2C1")` returned structured `REMOTE_PUSH_PENDING`, not `invalid release version`.
|
|
||||||
- `getReleaseSaga` for a nonexistent candidate returned HTTP `404` with `status=NOT_FOUND`, `state=NOT_FOUND`, `execution_enabled=false`, and no filesystem permission error.
|
|
||||||
- `commitFilesGoverned` published schema includes required `files`, with per-file `path` and `content_base64`.
|
|
||||||
- Two-file governed commit: branch `agent/action-contract-integration-20260825-1407`, OID `0345cd8c70967940bc8e9132dff7d6ea11efa304` at authority/Git remote/API; protected-ref flags remained false.
|
|
||||||
- Terminal reconcile: `COMPLETED` read-back was idempotent across two calls; focused tests cover `FAILED` and `CANCELLED` terminal semantics.
|
|
||||||
- Small diff returned bounded non-truncated output; empty diff returned zero paths and zero bytes; large diff returned bounded/truncated output as recorded above.
|
|
||||||
- Main, protected tag, baseline registry, and production OIDs were re-read after deployment and remained unchanged.
|
|
||||||
|
|
||||||
## Restart persistence and remaining risk
|
|
||||||
|
|
||||||
The final staging services were restarted after deployment and health/runtime identity were re-read. The post-restart `systemctl is-active` result was `active` for both services; model health again reported OID `2ec49005bde44f6981b14351500847d594e0276c`, artifact SHA `f67aed6cc0cce1f08197485c3eea204d3ef4d3989f44dc98e8ea2691e5886cbf`, staging mode, and `production_deployed=false`. Post-restart consistency remained `PASS`, acceptance remained structured `accepted=false` for the pre-existing protected-tag publication state, saga remained `NOT_FOUND` for the nonexistent candidate, and the large diff remained bounded/truncated. The task cannot be promoted to `SUCCEEDED` until the protected-tag publication state and the two known repository-wide failures are resolved through their respective governed owners. No automatic rollback was performed because the staging services stayed healthy, mandatory tests passed, Gitea readback passed, and protected-ref boundaries remained intact.
|
|
||||||
|
|
||||||
Task disposition is `NEEDS_REWORK`, not `SUCCEEDED`: the staging contract repair is materially complete, but the deployed acceptance result is not all-green and the task row has not been legitimately claimed/verified by the online task worker.
|
|
||||||
|
|
||||||
## External Action Surface final closure evidence — 2026-08-25
|
|
||||||
|
|
||||||
### Disposition
|
|
||||||
NEEDS_REWORK. Staging is deployed and P0-A/P0-B have live closure evidence. P0-C is blocked: no formal staging external Action bearer issuance path was available, so the required real public two-file commit and terminal reconcile were not authorized or executed. Do not mark SUCCEEDED. No main, tag, baseline, or production write occurred; untracked runtime/agent-observability and runtime/task-center were preserved.
|
|
||||||
|
|
||||||
### Identity and before-change facts
|
|
||||||
Authority /data/aios-gitops/repo.git; worktree /var/lib/aios-gitops/bridge-v2-staging/worktrees/fix-action-contracts-20260825-1407; branch agent/fix-action-contracts-20260825-1407; initial HEAD 35eec9afe1432e8466d60040b2fb1049bb41242a1. Stable V2R2C1 source 998d30304787e31e5ae607c0bd86b5edb2b0e5ad; evidence closure a8b906d1bc24088c19647e98de6a679268f6d0a2. Repair commit 174e64dd0218cef6d49dd13d58bbd90c78fecadd; protected refs unchanged.
|
|
||||||
|
|
||||||
### P0-A governed-read ownership
|
|
||||||
Root cause: generic /api/ sent governed-write-capabilities to model API 18893 (404), while governed facade 18890 owned it. Fix: ops/gitops-migration/bind_external_action_surface_final_staging.sh installs exact locations for governed-write-capabilities, control-plane-snapshot, online-agent-task, and online-agent-task-events to 18890; generic /api/ remains 18893; auth headers forward and no business host is hard-coded. Backup /data/aios-gitops/backups/action-surface-final-20260825T101714Z; log /data/aios-gitops/logs/action-surface-final-20260825T101714Z.log. nginx -t passed, Nginx active, public GET /api/governed-write-capabilities HTTP 200. Contract reports governed writes enabled but legacy/main/tag/baseline/production writes false.
|
|
||||||
|
|
||||||
### P0-B release semantics
|
|
||||||
Root cause: public model GET used local TagPublisher/PublicationStore lacking tag object binding, so consistency was FAIL/tag_object=false; Bridge v2 had authoritative durable binding. Fix: active-runtime/app/aios_model_api_staging.py constructs one canonical publication request and routes release-consistency/release-acceptance through Bridge v2 getReleaseConsistency/runReleaseAcceptance; tests/test_action_contract_regressions.py covers this. Live V2R2C1 consistency with target 998d30304787e31e5ae607c0bd86b5edb2b0e5ad, closure a8b906d1bc24088c19647e98de6a679268f6d0a2, plan sha256:c0df52008ae7f64cec5ee2d28947aa7b892066c670ab8295e4549140b1a7500e returned HTTP 200 PASS, all checks true, tag object 07fc2746b688f5f243f29b39de39f32edc3dd9d2 and peeled target correct. Acceptance entered structured policy evaluation with accepted=false, not URL failure. Tag publication V2R2C1 returned structured UNKNOWN, not invalid version.
|
|
||||||
|
|
||||||
### P0-C credential and commit blocker
|
|
||||||
Hash-only Action record: account chatgpt-gitops-bot; id chatgpt-action-0a3e55b7659a4d4e8125feabce5e8bf5; kind gpt_action_api_key; consumer aios-gitops-model-api.service; issuer owner_ssh_breakglass_rotation; issued 2026-08-10T04:51:58.900488Z; expires 2026-09-09T04:51:58.900488Z; scopes file:write and agent-task:control; disabled false; owner approval required true. Plaintext was not printed/reused. agent_task_control_token is not an Action bearer. Inspected Writer grants, Gitea consumer, SSH, task broker and old token candidates provide no formal issuer; no unrelated credential substituted. Empty public POST /api/commitFilesGoverned reached staging and returned HTTP 423 gitops_writes_disabled with AIOS_GITOPS_WRITES_ENABLED=false. Writes were not enabled. No real public two-file commit or COMPLETED reconcile is claimed. Blocker: FORMAL_STAGING_ACTION_BEARER_ISSUANCE_NOT_AVAILABLE.
|
|
||||||
|
|
||||||
### Schema/deployment/runtime
|
|
||||||
Source 174e64dd0218cef6d49dd13d58bbd90c78fecadd. Artifact /data/aios-gitops/artifacts/action-contract-final-174e64d/aios-v2r2c1-model-api-174e64dd0218cef6d49dd13d58bbd90c78fecadd.tar.gz; SHA256 8fc811cfb9dbb22d6eb7ca418d58f9ada1d6fdd12e566e3e8f5a02bec47c4f37. Runtime /opt/aios/gitops-model-api/174e64dd0218cef6d49dd13d58bbd90c78fecadd-action-surface-final/runtime. Drop-in /etc/systemd/system/aios-gitops-model-api-v2r2c1-staging.service.d/zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz-action-contract-zzzz-final-999.conf; backup /data/aios-gitops/backups/action-contract-final-deploy-20260825T101959Z/drop-in-directory. User/Group aiman:gitops; API base from systemd https://g.cnnetmkt.top/git/api/v1; writes false; Bridge 18892; STAGING; production_deployed=false. /api/run proved active runtime/readable authority /data/aios-gitops/repo.git and main 3244f0f17f8845592ac94d585f5ebd65693c9f29. /api/openapi.yaml required commitFilesGoverned files, each with path/content_base64.
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
Before mandatory: 264 passed, 28 subtests, 4.82s. After mandatory: 264 passed, 28 subtests, 4.80s. Focused action regression: 11 passed, 0.12s. Full: 719 passed, 2 skipped, 154 subtests, 2 failed, 34.03s. Same known failures: stale /tmp/aios-agent-events-test in test_unified_event_flow; signer adapter self-test return 42 in test_real_signer_help_and_self_test_are_available.
|
|
||||||
|
|
||||||
### Restart and smoke
|
|
||||||
After first deployment, model and Bridge staging services were restarted and remained active. Post-restart health retained source/artifact identity, V2R2C1, STAGING, production false. Capability HTTP 200; consistency HTTP 200/PASS; tag status structured UNKNOWN; nonexistent saga HTTP 404/NOT_FOUND with execution false, shadow true, side effects false. Empty diff HTTP 200, zero paths/size, truncated false. Bounded non-empty diff returned identities, paths/stats, bounded output and patch limit 262144; local pipe-to-head caused only expected curl broken-pipe. Existing large-diff behavior remains bounded/truncated with no transport exception. Gitea readback proven by consistency PASS; protected-ref boundary remained fail-closed.
|
|
||||||
|
|
||||||
### Task continuity and remaining work
|
|
||||||
Earlier execution observed TASK_NOT_FOUND and created continuation fix-action-contracts-20260825-1407-external-surface-closure with parent/source/OID and staging constraints. Later recheck returned parent HTTP 200 QUEUED; continuation also QUEUED, revision 1, claimed_by null, result null. Neither has claim, execution, terminal result, or success evidence. Required rework is formal bearer lifecycle, isolated real two-file public commit and OID/reconcile verification, disposition of the two pre-existing full-suite failures, then full smoke before SUCCEEDED.
|
|
||||||
### Health endpoint clarification
|
|
||||||
|
|
||||||
The service health contract is /api/health: local model port 18893 and public https://cnnetmkt.top/api/health both returned HTTP 200 with the deployed source/artifact identity, STAGING, and production_deployed=false. Public root /health returned HTTP 502 because the existing Nginx root location proxies the separate portal upstream on 127.0.0.1:3000; it is not the Action model health route. This unrelated portal-ingress 502 is recorded as an observed remaining staging risk, and was not changed because this task scope is the Action/Bridge/release surface.
|
|
||||||
|
|
||||||
\n
|
|
||||||
### Formal task-state boundary
|
|
||||||
|
|
||||||
The deployed Task API exposes verification decisions SUCCEEDED, NEEDS_REWORK, and FAILED only through POST /v1/tasks/{id}/verify while the task is VERIFYING. Both the parent and continuation were QUEUED with no claim, so a direct NEEDS_REWORK transition was not a legal API operation. No cancel, database edit, fake claim, or lifecycle bypass was performed. This report is the authoritative disposition for this run: NEEDS_REWORK.
|
|
||||||
|
|
||||||
\n
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
# Action 最小自治修复闭环证据(2026-08-25)
|
|
||||||
|
|
||||||
## 最终状态
|
|
||||||
|
|
||||||
`BLOCKED`(staging 代码/配置已部署并健康,但缺少本次可用的正式 Action bearer,未宣称真实 governed commit 或完整外部 smoke 成功)。
|
|
||||||
|
|
||||||
本轮没有写入 production、`main`、release tag 或 baseline。
|
|
||||||
|
|
||||||
## 修改前事实
|
|
||||||
|
|
||||||
- repository: `aios/aios`
|
|
||||||
- branch: `agent/fix-action-contracts-20260825-1407`
|
|
||||||
- 修改前 HEAD: `d7a0d0db09826d513cb19451a4f018c998c00191`
|
|
||||||
- production baseline: `V2R2C1`
|
|
||||||
- baseline source commit: `998d30304787e31e5ae607c0bd86b5edb2b0e5ad`
|
|
||||||
- Action schema 已限制为 26 operations,但没有外部 read-source/lease 入口;Bridge lease 主要是进程内 runtime binding;Bridge 未做 operation-specific bearer 校验。
|
|
||||||
- 真实 staging model-api source/artifact 在本轮前为 `7e5525cfcbbaa3898c2e5ed3dc9c13e321908e61` / `2163c2e0467f3bd0a97f8d2b95e3277391c47dcf3ebafb8fa372a01c3f530ac0`。
|
|
||||||
|
|
||||||
## 根因与修复
|
|
||||||
|
|
||||||
1. Autonomous Agent 没有安全 read seam:增加 `AuthorityGitExecutor.read_file` 和 `readRepositoryFile`,只读 authoritative bare Git object database,固定响应上限并返回 resolved commit/OID、size、SHA-256、base64。
|
|
||||||
2. Lease 没有跨重启 durable binding:增加 `ScopedLeaseStore` SQLite CAS 状态,绑定 task/repository/branch/expected OID/generation/TTL,支持同 binding 幂等、冲突、过期和 USED replay;commit 先校验 lease,再走既有 detached worktree/Job Controller/Authority CAS chain。
|
|
||||||
3. Lease 曾可由调用者声明治理身份:现在必须命中 durable task binding,公开路径只接受 `CONTINUE_EXISTING`,并要求 expected OID 等于当前 authority branch head。
|
|
||||||
4. Auth scope 曾混合:Bridge 按 read/lease/commit operation 分别检查 scope;model-api facade 认证后透传 bearer。
|
|
||||||
5. runtime contract 与 published schema:增加 read/lease operation,保留 `commitFilesGoverned.files[] = {path, content_base64}`,builder 将 `scoped_lease_store.py` 纳入 immutable artifact。
|
|
||||||
|
|
||||||
## 修改文件与配置
|
|
||||||
|
|
||||||
- `active-runtime/app/aios_authority_git_executor.py`:安全 ref/path/size/read helper。
|
|
||||||
- `active-runtime/app/scoped_lease_store.py`:SQLite durable lease state。
|
|
||||||
- `active-runtime/app/aios_chatgpt_bridge_v2.py`:read operation contract。
|
|
||||||
- `active-runtime/app/aios_chatgpt_bridge_v2_server.py`:read route、task binding、lease store、Bearer/scope matrix、structured errors。
|
|
||||||
- `active-runtime/app/aios_model_api_staging.py`:认证 facade 与 Bridge control proxy。
|
|
||||||
- `docs/gitops/openapi-chatgpt-actions.yaml`:新增 `readRepositoryFile`、`acquireWriterLease`,公开 operation 数 28。
|
|
||||||
- `scripts/build_v2r2c1_model_api_staging.py`:纳入 `scoped_lease_store.py`。
|
|
||||||
- `tests/test_minimum_autonomous_loop.py`:read/path/size、contract、lease 幂等/冲突测试。
|
|
||||||
- `runbooks/agent-minimum-autonomous-loop-20260825.md`:部署与验收手册。
|
|
||||||
- `deploy/*minimal-autonomous-loop.conf`:待随最终 source OID 渲染的 staging unit 配置模板;实际 drop-in 使用 systemd `LoadCredential` 投影 `/data/aios-gitops/runtime/secrets/gitops.tokens`,不复制 token。
|
|
||||||
|
|
||||||
## Git 与构建结果
|
|
||||||
|
|
||||||
- 第一实现提交:`f3a4485be7c803c3769c518578cf4bf86db32c6e`。
|
|
||||||
- 绑定与 scope 收紧提交:`04b69beb369365a1522268e16c3ac7cd1dd745ce`。
|
|
||||||
- 最终实现/部署 source OID:`9f32b26eba5bdaaa0e7c89042ce336426d465dbd`。
|
|
||||||
- build artifact:`/var/lib/aios-gitops/bridge-v2-staging/candidate-artifacts/min-autonomous-9f32b26/aios-v2r2c1-model-api-9f32b26eba5bdaaa0e7c89042ce336426d465dbd.tar.gz`
|
|
||||||
- artifact SHA-256:`ff2031b9ef8a39811c15086c2577d33cc6f187fd060136b7be7a8d6b4152e9f1`
|
|
||||||
- artifact source OID:`9f32b26eba5bdaaa0e7c89042ce336426d465dbd`
|
|
||||||
|
|
||||||
## 测试与真实 staging 结果
|
|
||||||
|
|
||||||
已通过:
|
|
||||||
|
|
||||||
```text
|
|
||||||
pytest -q tests/test_minimum_autonomous_loop.py tests/test_action_contract_regressions.py tests/test_bridge_v2_governed_http_bootstrap.py
|
|
||||||
21 passed
|
|
||||||
python3 -m py_compile ... # changed runtime modules
|
|
||||||
git diff --check
|
|
||||||
```
|
|
||||||
|
|
||||||
完整 mandatory suite(使用仓库需要的 `PYTHONPATH=.:services/aios-release-controller`)结果:
|
|
||||||
|
|
||||||
`724 passed, 2 skipped, 2 failed, 154 subtests passed`。
|
|
||||||
|
|
||||||
两个失败与本轮改动无关,和修改前已知基线一致:`tests/test_agent_event_bus.py::test_unified_event_flow` 受残留 `/tmp/aios-agent-events-test` 数据污染;`tests/test_signer_contract_compatibility.py::SignerContractCompatibilityTests::test_real_signer_help_and_self_test_are_available` 的已部署 signer self-test 返回码为 `42`。
|
|
||||||
|
|
||||||
已验证:
|
|
||||||
|
|
||||||
- `aios-chatgpt-bridge-v2-staging.service`: restart 后 active,ExecStart 指向 source `9f32b26...` runtime,PID `2061371`。
|
|
||||||
- `aios-gitops-model-api-v2r2c1-staging.service`: restart 后 active,ExecStart 指向 source `9f32b26...` runtime,PID `2061686`。
|
|
||||||
- `/api/health` 与 `/v2/runtime`: restart 后 HTTP 200,`STAGING`、`production_deployed=false`、runtime OID `9f32b26...`、artifact SHA `ff2031...`,`drift_status=PASS`。
|
|
||||||
- 实际 staging `/api/openapi-chatgpt-actions.yaml`: HTTP 200,版本 `V2R2C1-chatgpt-actions-28ops`,28 个 `operationId`,含 `readRepositoryFile`、`acquireWriterLease`、`commitFilesGoverned`;`commitFilesGoverned.files` required 且每项 required `path`、`content_base64`。
|
|
||||||
- 无 Bearer 的 model-api read 请求和 loopback Bridge read 请求均返回 HTTP 401;writes runtime flag 保持 false。
|
|
||||||
- systemd effective User/Group 为 `aiman:gitops`;Gitea API 来源为 staging service effective `AIOS_GITOPS_GITEA_API_URL=https://g.cnnetmkt.top/git/api/v1` 与 Bridge `AIOS_GITEA_API_BASE=https://g.cnnetmkt.top/git/api/v1`,不是业务代码硬编码。
|
|
||||||
- effective silent-release root 为 `/var/lib/aios-gitops/model-api-staging/silent-release`,ownership/mode 为 `aiman:gitops 750`,父目录同为 `aiman:gitops 750`;旧报错路径 `/var/lib/aios-gitops/silent-release` 不存在,未被错误地当作当前 runtime 路径。
|
|
||||||
- systemd 使用 `LoadCredential=action_tokens:/data/aios-gitops/runtime/secrets/gitops.tokens` 和 `%d/action_tokens` 投影;registry 文件 `root:root 600`,未打印或复制 bearer。
|
|
||||||
- 当前 token registry 是 hash-only 记录;本次没有打印、重用 Gitea/SSH/service token,也没有启用 dev auth bypass。
|
|
||||||
|
|
||||||
重启后的 service restart acceptance 已完成:URL、权限、Action schema、version parser 代码产物和 writes=false 均保持有效;production service 未重启。
|
|
||||||
|
|
||||||
未通过/未执行:
|
|
||||||
|
|
||||||
- 无法执行正式 bearer 下的 `read -> lease -> commitFilesGoverned`,因此没有伪造两文件 commit OID、lease USED、Gitea Git/API readback 或 idempotency replay 证据。
|
|
||||||
- 未执行临时 writes=true governed commit;staging 最终保持 `AIOS_GITOPS_WRITES_ENABLED=false`。
|
|
||||||
- 未宣称完整 Action smoke、restart 后 governed commit 或 `TASK_VERIFIED`。
|
|
||||||
|
|
||||||
## 保护边界
|
|
||||||
|
|
||||||
本轮只安装 staging artifact/drop-in;production service 未重启。`main`、`V2R2C1` tag、baseline target/evidence closure 未写入。两个既有 untracked 目录 `runtime/agent-observability/`、`runtime/task-center/` 原样保留。
|
|
||||||
|
|
||||||
## 剩余风险与下一步
|
|
||||||
|
|
||||||
主要闭环阻塞是:需要 Owner/正式 Action broker 提供当前有效 bearer(不在聊天中传递)。此外 mandatory suite 仍有上述两个已知环境/部署失败。拿到 bearer 后应从当前 branch HEAD 重新读取 source OID,按 runbook 执行一次真实两文件 governed commit、Gitea 三方 OID readback、idempotency/stale-OID 负例、staging restart 后复测;在此之前 Task 必须保持 `BLOCKED/NEEDS_REWORK`,不能标记 `SUCCEEDED`。
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
# AIOS Action OpenAPI 操作数上限修复证据
|
|
||||||
|
|
||||||
## 结论
|
|
||||||
|
|
||||||
staging 上的 `https://cnnetmkt.top/api/openapi-chatgpt-actions.yaml` 已从 32 个
|
|
||||||
实际 OpenAPI operations 收缩到 26 个,低于 ChatGPT Action 的 30 操作限制。
|
|
||||||
公网重新抓取返回 HTTP 200,schema version 为
|
|
||||||
`V2R2C1-chatgpt-actions-26ops`。`commitFilesGoverned` 仍是公开的批量 governed
|
|
||||||
writer,并且现在在 OpenAPI 中显式要求 `bearerAuth`、标记为
|
|
||||||
`x-openai-isConsequential: true`,required `files` 契约保持一致。
|
|
||||||
|
|
||||||
本次只修改 branch/staging overlay;没有写入 production service、main、release
|
|
||||||
tag 或 baseline registry。ChatGPT Action 编辑器仍需要重新导入或刷新这个 URL,
|
|
||||||
因为客户端可能缓存旧的 32-operation schema。
|
|
||||||
|
|
||||||
## 修改前事实
|
|
||||||
|
|
||||||
- Repository:`aios/aios`
|
|
||||||
- Branch:`agent/fix-action-contracts-20260825-1407`
|
|
||||||
- 修复前 branch tip:`767c7ff6a975fe18a701b95cd6dacca8600eddc0`
|
|
||||||
- 本轮最终 HEAD:`7e5525cfcbbaa3898c2e5ed3dc9c13e321908e61`
|
|
||||||
- staging model-api unit:`aios-gitops-model-api-v2r2c1-staging.service`
|
|
||||||
- staging model-api port:`127.0.0.1:18893`
|
|
||||||
- staging Bridge port:`127.0.0.1:18892`
|
|
||||||
- systemd Gitea API URL:`https://g.cnnetmkt.top/git/api/v1`
|
|
||||||
- 当前 service User/Group:`aiman:gitops`
|
|
||||||
- 修复前公网 schema:HTTP 200,24,721 bytes,SHA256
|
|
||||||
`a0775b0719a76e08a95f3257a3d3b2a73b70e1c55c162914250f80dd28da022d`;30 paths、
|
|
||||||
32 operations。两个 path 同时有多个 method,因此 path 数不是 operation 数。
|
|
||||||
- 修复前触发错误:`OpenAPI 规范最多可以包含 30 个操作`。
|
|
||||||
|
|
||||||
## 根因与决策
|
|
||||||
|
|
||||||
根因不是 URL host 或 YAML 传输错误,而是公开 Action projection 中混入了旧式
|
|
||||||
Git 写原语和同义 approval alias。公开 schema 同时暴露 legacy primitives 与
|
|
||||||
`commitFilesGoverned`,最终 operation 数为 32。
|
|
||||||
|
|
||||||
本次投影收缩为 26 个 operation:
|
|
||||||
|
|
||||||
- 先前修复中移除粗粒度旧别名 `POST /api/file` (`commitFile`);
|
|
||||||
- 先前修复中移除当前 isolated-writer policy 明确禁止的 `POST /api/commit`
|
|
||||||
(`commitChanges`);
|
|
||||||
- 本轮移除 `submitSignedApproval`,因为它是 `submitOwnerApproval` 的 alias;
|
|
||||||
- 本轮从 Actions projection 隐藏 `createPatch`、`createBranch`、
|
|
||||||
`commitFileAtomic` 三个旧式 Git 写原语。runtime 兼容处理没有删除,canonical
|
|
||||||
对外批量写入为 `/v2/commit-files`;
|
|
||||||
- 保留 release candidate、release consistency、release acceptance、tag
|
|
||||||
publication status、release saga、getDiff 和 recovery 相关既有能力,满足本任务
|
|
||||||
的既有 smoke contract。尚未实现的 `getReleaseSnapshot`/`getReleaseVerification`
|
|
||||||
聚合 API 不在本次冒险重构范围内。
|
|
||||||
|
|
||||||
此外,`commitFilesGoverned` 的 runtime fragment 生成器现在生成:
|
|
||||||
|
|
||||||
- `security: [{bearerAuth: []}]`;
|
|
||||||
- `x-openai-isConsequential: true`;
|
|
||||||
- `files.maxItems: 50`;
|
|
||||||
- `idempotency_key` 的长度和字符约束。
|
|
||||||
|
|
||||||
## 修改文件与提交
|
|
||||||
|
|
||||||
- `docs/gitops/openapi-chatgpt-actions.yaml`:canonical Action projection,26-op
|
|
||||||
allowlist、移除 legacy/alias、补齐 batch writer security。
|
|
||||||
- `active-runtime/app/aios_chatgpt_bridge_v2.py`:runtime contract fragment 的
|
|
||||||
单一生成来源与公开 security/batch constraints。
|
|
||||||
- `tests/test_action_contract_regressions.py`:26-operation gate、legacy operation
|
|
||||||
negative assertions、runtime/schema parity、bearer/consequential assertions。
|
|
||||||
- `runbooks/action-openapi-operation-limit-20260825.md`:操作上限、projection
|
|
||||||
allowlist、构建和刷新手册。
|
|
||||||
- `ops/gitops-migration/bind_external_action_surface_final_staging.sh` 及先前的
|
|
||||||
staging binding 测试/报告:本任务分支已将 `/v2/` 公开路由绑定到 staging Bridge
|
|
||||||
`127.0.0.1:18892`,没有使用 production `18920`。
|
|
||||||
|
|
||||||
提交:
|
|
||||||
|
|
||||||
- `4313f3e44a1e4be69d2608fb1fc18833aa0c82e8`
|
|
||||||
`fix(action): keep public schema within operation limit`
|
|
||||||
- `7e5525cfcbbaa3898c2e5ed3dc9c13e321908e61`
|
|
||||||
`fix(action): project governed core within 26 operations`
|
|
||||||
|
|
||||||
保留的未跟踪目录 `runtime/agent-observability/`、`runtime/task-center/` 未被加入
|
|
||||||
提交或删除。
|
|
||||||
|
|
||||||
## 测试结果
|
|
||||||
|
|
||||||
修改后 focused regression:
|
|
||||||
|
|
||||||
```text
|
|
||||||
13 passed in 0.15s
|
|
||||||
```
|
|
||||||
|
|
||||||
包含既有 Action/release/staging focused tests:
|
|
||||||
|
|
||||||
```text
|
|
||||||
32 passed, 11 subtests passed in 0.36s
|
|
||||||
```
|
|
||||||
|
|
||||||
mandatory suite:
|
|
||||||
|
|
||||||
```text
|
|
||||||
264 passed, 28 subtests passed in 4.79s
|
|
||||||
```
|
|
||||||
|
|
||||||
完整 suite:
|
|
||||||
|
|
||||||
```text
|
|
||||||
721 passed, 2 skipped, 2 failed, 154 subtests passed in 34.02s
|
|
||||||
```
|
|
||||||
|
|
||||||
两个失败与本次变更无关且在修改前已复现:
|
|
||||||
|
|
||||||
1. `tests/test_agent_event_bus.py::test_unified_event_flow` 使用了残留的
|
|
||||||
`/tmp/aios-agent-events-test`,已有 1 个旧 event,断言得到 2;
|
|
||||||
2. `tests/test_signer_contract_compatibility.py::test_real_signer_help_and_self_test_are_available`
|
|
||||||
的已部署 signer `self-test` 返回 42。`sign --help` 仍正常,live signer integration
|
|
||||||
另行 skip。
|
|
||||||
|
|
||||||
没有出现由本次 schema/runtime 改动引入的 mandatory regression。
|
|
||||||
|
|
||||||
## staging 构建与部署
|
|
||||||
|
|
||||||
使用仓库现有流程:
|
|
||||||
|
|
||||||
```text
|
|
||||||
scripts/build_v2r2c1_model_api_staging.py
|
|
||||||
source_oid: 7e5525cfcbbaa3898c2e5ed3dc9c13e321908e61
|
|
||||||
artifact: /data/aios-gitops/artifacts/action-openapi-limit-20260825-7e5525cf/aios-v2r2c1-model-api-7e5525cfcbbaa3898c2e5ed3dc9c13e321908e61.tar.gz
|
|
||||||
artifact_sha256: 2163c2e0467f3bd0a97f8d2b95e3277391c47dcf3ebafb8fa372a01c3f530ac0
|
|
||||||
deployment_state: STAGING
|
|
||||||
```
|
|
||||||
|
|
||||||
systemd 新 drop-in 持久化了 artifact/runtime identity、Gitea URL、`STAGING`、
|
|
||||||
`AIOS_GITOPS_WRITES_ENABLED=false`、`aiman:gitops` 和精确的 read/write paths。
|
|
||||||
当前生效 runtime:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/opt/aios/gitops-model-api/7e5525cfcbbaa3898c2e5ed3dc9c13e321908e61-action-openapi-limit/runtime
|
|
||||||
```
|
|
||||||
|
|
||||||
silent-release 权限在两次重启后均为:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/var/lib/aios-gitops/model-api-staging/silent-release aiman:gitops 750
|
|
||||||
ACL: user::rwx, group::r-x, other::---
|
|
||||||
```
|
|
||||||
|
|
||||||
部署前的原生 final drop-in 已备份至:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/data/aios-gitops/backups/action-openapi-limit-deploy-20260825T112700Z/
|
|
||||||
```
|
|
||||||
|
|
||||||
第一次 restart 后 MainPID 为 `1775067`;第二次按要求 restart 后 MainPID 为
|
|
||||||
`1788253`,两次均 active/running。
|
|
||||||
|
|
||||||
## 公网 Action smoke
|
|
||||||
|
|
||||||
最终公网抓取:
|
|
||||||
|
|
||||||
```text
|
|
||||||
HTTP 200
|
|
||||||
Content-Type: application/yaml; charset=utf-8
|
|
||||||
bytes: 21553
|
|
||||||
SHA256: 798cac0ebd40097c63e5e01ab52c9fdbc255707c4f3820b77161c83349cd5199
|
|
||||||
info.version: V2R2C1-chatgpt-actions-26ops
|
|
||||||
paths: 25
|
|
||||||
operations: 26
|
|
||||||
```
|
|
||||||
|
|
||||||
关键 operation contract:
|
|
||||||
|
|
||||||
```text
|
|
||||||
commitFilesGoverned required:
|
|
||||||
task_id, repository, expected_branch_oid, target_branch, generation,
|
|
||||||
idempotency_key, files, message
|
|
||||||
files item required: path, content_base64
|
|
||||||
security: bearerAuth
|
|
||||||
x-openai-isConsequential: true
|
|
||||||
```
|
|
||||||
|
|
||||||
restart 后复测:
|
|
||||||
|
|
||||||
- `/api/health`:200,`status=ok`,`deployment_state=STAGING`,runtime OID 为
|
|
||||||
`7e5525cf…`,artifact SHA 与 receipt 一致,`production_deployed=false`;
|
|
||||||
- `/v2/runtime`:200,active,Bridge schema `aios.chatgpt-gitops-bridge.v2`;
|
|
||||||
- `/v2/capabilities`:200,governed writes enabled、legacy/main/baseline writes
|
|
||||||
false;
|
|
||||||
- `/api/release-consistency?version=V2R2C1...`:200,`status=PASS`,包含
|
|
||||||
Gitea listing/tag/OID readback;
|
|
||||||
- `/api/release-acceptance?version=V2R2C1...`:200,进入 policy evaluation,
|
|
||||||
`accepted=false` 是既有 acceptance policy 结果,不是 URL 校验错误;
|
|
||||||
- `/api/tag-publication-status?version=V2R2C1`:200,结构化 `UNKNOWN` 状态;
|
|
||||||
- `/getReleaseSaga?candidate_id=nonexistent-action-limit-20260825`:404,结构化
|
|
||||||
`NOT_FOUND`/empty-state,不是权限错误;
|
|
||||||
- empty diff:200,`total_size_bytes=0`、`truncated=false`;
|
|
||||||
- non-empty diff:200,31 changed paths、208,639 bytes;
|
|
||||||
- large diff:200,732 changed paths、3,577,251 bytes、`truncated=true`、patch
|
|
||||||
bounded at 262,144 bytes,无 transport exception。
|
|
||||||
|
|
||||||
保护边界复测:
|
|
||||||
|
|
||||||
```text
|
|
||||||
refs/heads/main = 3244f0f17f8845592ac94d585f5ebd65693c9f29
|
|
||||||
refs/tags/V2R2C1 = 07fc2746b688f5f243f29b39de39f32edc3dd9d2
|
|
||||||
service writes_enabled = false
|
|
||||||
service deployment_state = STAGING
|
|
||||||
```
|
|
||||||
|
|
||||||
main/tag/baseline registry/production 未被本次部署写入。
|
|
||||||
|
|
||||||
## 剩余风险与后续动作
|
|
||||||
|
|
||||||
- ChatGPT 客户端是否已刷新不能由 staging 服务端证明;请在 Action 编辑器重新导入
|
|
||||||
URL。若仍显示旧数量,删除旧 schema 后重新导入,确认 `V2R2C1-chatgpt-actions-26ops`。
|
|
||||||
- 完整 suite 的两个环境失败仍待独立清理 `/tmp` fixture 和修复 signer self-test;
|
|
||||||
它们不是本次 Action schema 修复的回归。
|
|
||||||
- 未来若实现正式 `getReleaseSnapshot`、`getReleaseVerification` 和 governed task
|
|
||||||
Action projection,应单独设计/测试,不应通过隐藏本任务仍要求的 release read paths
|
|
||||||
来制造假收缩。
|
|
||||||
|
|
||||||
## 状态
|
|
||||||
|
|
||||||
`STAGING_REPAIR_PASS`:公网 staging schema、runtime、release/Gitea readback、
|
|
||||||
restart 后 smoke 和保护边界均通过;production 未触碰。外部 ChatGPT Action UI
|
|
||||||
刷新仍是客户端侧动作,不作为服务端部署失败。
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
# Runbook Addendum — External Action Connector Schema Refresh
|
|
||||||
|
|
||||||
This addendum is staging-only and applies to the `fix-action-contracts-20260825-1407` closure.
|
|
||||||
|
|
||||||
## Contract source
|
|
||||||
|
|
||||||
Use `active-runtime/app/aios_chatgpt_bridge_v2.py` as the runtime source of required fields. Use `render_openapi_contract_fragment()` and `docs/gitops/openapi-chatgpt-actions.yaml` for the generated/published projection. `commitFilesGoverned` must require `files`, and each item must require `path` and `content_base64`.
|
|
||||||
|
|
||||||
The public OpenAPI server is the site root and its Bridge v2 paths are `/v2/...`. Install the route with `ops/gitops-migration/bind_external_action_surface_final_staging.sh`; it must target `127.0.0.1:18892`, preserve the Authorization headers, pass `nginx -t`, reload Nginx, and never target production `18920`.
|
|
||||||
|
|
||||||
## Connector lifecycle gate
|
|
||||||
|
|
||||||
The external Action connector must be refreshed through its supported registration or re-registration lifecycle. Record the connector identity, schema source URL, fetched schema digest, cache invalidation or registration acknowledgement, and a callable-tool inspection showing required `files`. Do not make a local fixture or a second hand-written schema a substitute for this evidence.
|
|
||||||
|
|
||||||
If the connector registration API, cache lifecycle, callable inspection, or formal staging Action bearer is unavailable, stop at `PENDING_EXTERNAL_CONNECTOR` and record `FORMAL_STAGING_ACTION_BEARER_ISSUANCE_NOT_AVAILABLE`. Never reuse Gitea, SSH, task-control, Writer, or old token credentials.
|
|
||||||
|
|
||||||
## Acceptance
|
|
||||||
|
|
||||||
After connector refresh, use the actual callable Action once to submit two files through `commitFilesGoverned` on an isolated `agent/*` branch. Verify the task/lease lineage, Authority ref, Git remote ref, and Gitea API branch OID are equal. Read the durable job, require `COMPLETED`, then call terminal reconcile and require an idempotent successful read-back. Restart only staging Action/Bridge/model services and repeat callable schema inspection and the batch/reconcile smoke.
|
|
||||||
|
|
||||||
Do not mark `SUCCEEDED` until the callable schema and batch evidence exist. If refresh breaks health, routes, auth, schema, or protected-ref boundaries, restore the recorded Nginx backup and previous staging artifact through the governed deployment procedure, then diagnose.
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
# ChatGPT Action OpenAPI 操作数上限运行手册
|
|
||||||
|
|
||||||
## 目的
|
|
||||||
|
|
||||||
`https://cnnetmkt.top/api/openapi-chatgpt-actions.yaml` 必须保持不超过 ChatGPT
|
|
||||||
Action 导入支持的 30 个实际操作;当前投影硬门槛收紧为 26,为未来治理、诊断
|
|
||||||
和任务协议保留余量。OpenAPI 中同一路径的多个 HTTP method 分别计数;路径数量
|
|
||||||
不等于操作数量。
|
|
||||||
|
|
||||||
## 单一来源与构建
|
|
||||||
|
|
||||||
规范唯一来源是 `docs/gitops/openapi-chatgpt-actions.yaml`。模型 API staging
|
|
||||||
构建脚本 `scripts/build_v2r2c1_model_api_staging.py` 将该文件复制到 immutable
|
|
||||||
artifact,并同时校验 `/v2/commit-files` runtime contract。禁止直接编辑已部署
|
|
||||||
artifact 或 production 文件。
|
|
||||||
|
|
||||||
变更后至少执行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 - <<'PY'
|
|
||||||
import yaml
|
|
||||||
from pathlib import Path
|
|
||||||
doc = yaml.safe_load(Path('docs/gitops/openapi-chatgpt-actions.yaml').read_text())
|
|
||||||
methods = {'get', 'post', 'put', 'patch', 'delete', 'head', 'options'}
|
|
||||||
operations = [(p, m) for p, item in doc['paths'].items() for m in item if m in methods]
|
|
||||||
assert len(operations) <= 26, len(operations)
|
|
||||||
print(f'paths={len(doc["paths"])} operations={len(operations)}')
|
|
||||||
PY
|
|
||||||
PYTHONPATH=active-runtime/app python3 -m pytest -q tests/test_action_contract_regressions.py
|
|
||||||
```
|
|
||||||
|
|
||||||
## 公开面选择
|
|
||||||
|
|
||||||
公开 schema 保留受治理的 `/v2/commit-files` 以及 release、
|
|
||||||
tag publication、consistency、acceptance、saga 和 diff 操作。粗粒度旧别名
|
|
||||||
`/api/file`、`/api/commit`、`/api/branch`、`/api/patch` POST、`/api/commit-file`
|
|
||||||
以及 `/submitSignedApproval` 等旧别名/旧写原语不得重新加入公开 Action schema;
|
|
||||||
批量写入使用 `/v2/commit-files`。完整 runtime 兼容路由不因此删除。
|
|
||||||
|
|
||||||
`commitFilesGoverned` 必须显式声明 `security: [{bearerAuth: []}]` 和
|
|
||||||
`x-openai-isConsequential: true`;schema 与 Bridge runtime 的 required fields、
|
|
||||||
batch limits 必须由同一生成链维护。
|
|
||||||
|
|
||||||
## 部署与复验
|
|
||||||
|
|
||||||
只使用 `scripts/build_v2r2c1_model_api_staging.py` 生成 staging artifact,保留
|
|
||||||
当前 systemd drop-in 备份,更新 staging model-api 的 artifact 路径后执行
|
|
||||||
`daemon-reload` 和 staging unit restart。部署后从公网重新抓取 schema,验证:
|
|
||||||
|
|
||||||
- HTTP 200,Content-Type 为 YAML;
|
|
||||||
- 实际操作数为 30 或更少;
|
|
||||||
- `commitFilesGoverned` 仍包含 required `files`,每项包含 `path`、`content_base64`;
|
|
||||||
- `/v2/runtime`、capabilities、release consistency/acceptance、tag status、
|
|
||||||
saga empty-state、diff small/empty/large smoke 均正常;
|
|
||||||
- staging unit active,production/main/tag/baseline 未写入。
|
|
||||||
|
|
||||||
Action 客户端可能缓存旧规范。公网 schema 复验通过后,在 ChatGPT Action 编辑器
|
|
||||||
重新导入该 URL 或执行刷新;服务端无法替代客户端已保存的旧 schema cache。
|
|
||||||
@@ -1,225 +0,0 @@
|
|||||||
# Agent Execution Contract — Action Contract Regression Repair
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Fully repair the current AIOS Action runtime/contract overlay regressions on the single server, without modifying protected refs directly and without introducing new infrastructure. The task is not complete until the deployed staging/action overlay passes all acceptance gates.
|
|
||||||
|
|
||||||
## Authority and scope
|
|
||||||
|
|
||||||
- Repository: `aios/aios`
|
|
||||||
- Base at task creation: `3244f0f17f8845592ac94d585f5ebd65693c9f29`
|
|
||||||
- Governed task: `fix-action-contracts-20260825-1407`
|
|
||||||
- Target branch: `agent/fix-action-contracts-20260825-1407`
|
|
||||||
- Protected refs must not be directly written: `main`, release tags, baseline registry, production runtime.
|
|
||||||
- No new SaaS, database, agent framework, or monitoring platform may be introduced.
|
|
||||||
|
|
||||||
## Non-interactive rules
|
|
||||||
|
|
||||||
The Agent must run the whole flow unnattended:
|
|
||||||
|
|
||||||
1. Collect facts.
|
|
||||||
2. Determine root causes.
|
|
||||||
3. Make the smallest correct changes.
|
|
||||||
4. Run unit/integration/contract tests.
|
|
||||||
5. Deploy to staging only.
|
|
||||||
6. Restart only the affected non-production services.
|
|
||||||
7. Run the full Action smoke suite.
|
|
||||||
8. On failure, automatically collect logs, diagnose, fix, and retry.
|
|
||||||
9. If a change causes a new regression, automatically roll back the staging change to the previous known-good configuration and record the failure.
|
|
||||||
10. Stop only if an owner decision is required for a high-risk, irreversible, or production-boundary action.
|
|
||||||
|
|
||||||
## Fact collection — required before editing
|
|
||||||
|
|
||||||
Record all of the following in the final evidence bundle:
|
|
||||||
|
|
||||||
- `id`, `hostname`, `uname -a`, `date --iso-seconds`
|
|
||||||
- `git rev-parse --show HEAD` for authority worktree and running overlay source
|
|
||||||
- `git status --porcelaine=v2`
|
|
||||||
- `git remote -v`
|
|
||||||
- current systemd service unit(s), drop-ins, Environment/EnvironmentFile sources for GitOps/Bredge/release services
|
|
||||||
- `systemctl show <affected-service>` including `User=`, `Group=`, `Environment=`, `EnvironmentFile=`, `ExecStart=`
|
|
||||||
- resolved Gitea API base URL after env/config expansion; redact secrets but not scheme/host
|
|
||||||
- `stat -c, `namei -l`, `getfacl`, als -ld on `/var/lib/aios-gitops/silent-release` and its parents
|
|
||||||
- find Action OpenAPI/schema generator and runtime operation definitions for `commitFilesGoverned`
|
|
||||||
- find the release version validator(s) and all callers of `getTagPublicationStatus`
|
|
||||||
- find `reconcileGitOpsJob` implementation and durable binding validation logic
|
|
||||||
- find `getDiff implementation and transport output limits
|
|
||||||
|
|
||||||
Do not edit any file until the actual implementation and configuration sources have been located.
|
|
||||||
|
|
||||||
## Repair 1 — Gitea API URL
|
|
||||||
|
|
||||||
Symptoms:
|
|
||||||
|
|
||||||
- `getReleaseConsistency` → `Gitea API URL must be HTTP(S)`
|
|
||||||
- `runReleaseAcceptance` → `Gitea API URL must be HTTP(ÊX
|
|
||||||
|
|
||||||
Required behavior:
|
|
||||||
|
|
||||||
- Determine which env/config key is projected into the current contract overlay.
|
|
||||||
- Compare the deployed overlay with V2R2C1 known-good configuration/deployment manifests.
|
|
||||||
- Restore a valid `HTTP://` or `HTTPS://` Gitea API base URL.
|
|
||||||
- Add startup config validation so invalid schemes fail at health/startup with a config-specific message, not during a release check.
|
|
||||||
- Never hard-code a different Gitea host if the existing deployment already has a single authoritative source.
|
|
||||||
|
|
||||||
## Repair 2 — durable release state permissions
|
|
||||||
|
|
||||||
Symptom:
|
|
||||||
|
|
||||||
- `getReleaseSaga` → `Permission denied: '/var/lib/aios-gitops/silent-release'`
|
|
||||||
|
|
||||||
Required behavior:
|
|
||||||
|
|
||||||
- Identify the actual runtime service User/Group.
|
|
||||||
- Grant only the required traverse/read/write permissions for the durable state directories the service must use.
|
|
||||||
- Prefer ownership + group mode or a specific ACL; keep the directory non-world-writable.
|
|
||||||
- Persist the permission in deployment/install logic so restart/reboot does not regress.
|
|
||||||
- Verify create/read/update of a staging-only saga state file under the service identity.
|
|
||||||
|
|
||||||
## Repair 3 — release version contract
|
|
||||||
|
|
||||||
Symptom:
|
|
||||||
|
|
||||||
- `getTagPublicationStatus("V2R2C1")` → `invalid release version`
|
|
||||||
- The same runtime exposes `V2R2C1` as `approved_release_version`.
|
|
||||||
|
|
||||||
Required behavior:
|
|
||||||
|
|
||||||
- Locate all version validators; consolidate onto one canonical parser.
|
|
||||||
- Accept the AIOS canonical namespace `^V[0-9]+R[0-9]+C[0-9]+$`.
|
|
||||||
- Preserve any additional already-supported legacy formats unless they are security-sensitive.
|
|
||||||
- Add contract tests proving that the version accepted by capabilities is accepted by status, candidate, consistency, acceptance, and tag publication.
|
|
||||||
|
|
||||||
## Repair 4 — `commitFilesGoverned` schema parity
|
|
||||||
|
|
||||||
Symptom:
|
|
||||||
|
|
||||||
- Runtime capability metadata requires `files`.
|
|
||||||
- Published Action tool schema does not expose `files`.
|
|
||||||
- Call reaches Bridge and fails `BRIDGE_REQUEST_FIELDS_MISSING`.
|
|
||||||
|
|
||||||
Required behavior:
|
|
||||||
|
|
||||||
- Find the canonical runtime operation contract.
|
|
||||||
- Generate the external tool schema from the same source of truth; do not manually copy a parallel schema.
|
|
||||||
- `files` must be required and must represent a list of file objects with at least `path` and `content_base64`.
|
|
||||||
- Add a parity test that compares runtime required fields with published Action required fields.
|
|
||||||
- Add a real governed commit test that writes two test files to an isolated `agent/` ibranch and verifies remote OID.
|
|
||||||
|
|
||||||
## Repair 5 — terminal reconciliation
|
|
||||||
|
|
||||||
Symptom:
|
|
||||||
|
|
||||||
- A governed job read back as `COMPLETED' but `reconcileGitOpsJob` failed `DURABLE_BINDING_INVALID`.
|
|
||||||
|
|
||||||
Required behavior:
|
|
||||||
|
|
||||||
- Reconciling a terminal successful job must be idempotent.
|
|
||||||
- An expired writer lease must not invalidate a durable binding for a job that is already terminal.
|
|
||||||
- Active-job reconciliation may require a live lease; terminal read-back must not.
|
|
||||||
- Add cases for COMPLETED, FAILED, CANCELLED / equivalent terminal states.
|
|
||||||
|
|
||||||
## Repair 6 — bounded `getDiff` output
|
|
||||||
|
|
||||||
Symptom:
|
|
||||||
|
|
||||||
- Large valid diffs fail with `ResponseTooLargeError` or `GIT_OUTPUT_LIMIT_EXCEEDED`.
|
|
||||||
|
|
||||||
Required behavior:
|
|
||||||
|
|
||||||
- Do not load unbounded diff text into the Action response.
|
|
||||||
- Return bounded summary data: commit identities, changed paths/stats, total bytes, and `truncated: true` when the full patch exceeds the safe limit.
|
|
||||||
- If pagination already exists in the contract use it; otherwise prefer deterministic truncation over a breaking schema change.
|
|
||||||
- Add tests for small, empty, and oversize diffs.
|
|
||||||
|
|
||||||
## Staging deployment procedure
|
|
||||||
|
|
||||||
1. Run the existing mandatory test suite before modification; record baseline.
|
|
||||||
2. Apply code and config changes only on the governed branch/worktree.
|
|
||||||
3. Run focused regression tests.
|
|
||||||
4. Run the full mandatory suite.
|
|
||||||
5. Build the staging contract overlay using the repository's existing build/deploy path.
|
|
||||||
6. Deploy to staging/non-production only.
|
|
||||||
7. If systemd units are changed: `systemctl daemon-reload`, then restart only the affected staging services.
|
|
||||||
8. Wait for the existing health endpoint/runtime status to become healthy.
|
|
||||||
9. Run the acceptance suite below.
|
|
||||||
|
|
||||||
If any step fails:
|
|
||||||
- collect `journalctl` logs and relevant app logs;
|
|
||||||
- diagnose root cause;
|
|
||||||
- automatically repair and retry when non-destructive;
|
|
||||||
- if a new change breaks staging, restore the previous staging artifact/config and restart the affected service;
|
|
||||||
- never silently continue after a failed gate.
|
|
||||||
|
|
||||||
## Acceptance suite
|
|
||||||
|
|
||||||
All of the following must pass on the same deployed staging overlay:
|
|
||||||
|
|
||||||
- Runtime status is `active` and healthy after restart.
|
|
||||||
- Gitea Git/API readback works from the runtime service identity.
|
|
||||||
- `getReleaseConsistency(V2R2C1, 998d30304787e31e5ae607c0bd86b5edb2b0e5ad, a8b906d1bc24088c19647e98de6a679268f6d0a2, approved_plan) returns a structured result and reaches evaluation.
|
|
||||||
- `runReleaseAcceptance` for the same identities reaches policy evaluation, not URL validation error.
|
|
||||||
- `getTagPublicationStatus("V2R2C1")` returns structured state, not `invalid release version`.
|
|
||||||
- `getReleaseSaga` for a nonexistent candidate returns NOT_FOUND/empty-state semantics, not filesystem permission failure.
|
|
||||||
- `commitFilesGoverned` exposes its required `files` parameter in the published Action schema.
|
|
||||||
- A real two-file governed commit to an isolated `agent/` branch succeeds and remote branch OID matches.
|
|
||||||
- Resuming a COMPLETED job returns the existing `JOB_TERMINAL` semantics or idempotent success as designed.
|
|
||||||
- Reconciling a COMPLETED governed job is successful/idempotent and does not require an active lease.
|
|
||||||
- Small/empty `getDiff still works.
|
|
||||||
- Large `getDiff` returns bounded output with truncation metadata instead of a transport exception.
|
|
||||||
- Existing protected-ref controls remain unchange: no direct main/tag/baseline/production write.
|
|
||||||
|
|
||||||
## Restart / reboot persistence
|
|
||||||
|
|
||||||
After the first full pass:
|
|
||||||
|
|
||||||
- Restart the staging Action/Bridge/release services one more time without changing files.
|
|
||||||
- Rerun the acceptance suite.
|
|
||||||
- Permissions, Gitea URL, and schema must still be correct after restart.
|
|
||||||
- If the repository already has a safe staging reboot test, run it as well.
|
|
||||||
|
|
||||||
## Evidence and completion
|
|
||||||
|
|
||||||
Create a single durable report in the repository, e.g. `reports/action-contract-repair-evidence-20260825.md`, containing:
|
|
||||||
|
|
||||||
- pre-change facts
|
|
||||||
- root cause for each regression
|
|
||||||
- files changed and why
|
|
||||||
- config/permission changes
|
|
||||||
- test commands and exact results
|
|
||||||
- staging deployment identity (commit/artifact/config digest)
|
|
||||||
- restart-persistence results
|
|
||||||
- final Action smoke results
|
|
||||||
- any remaining risks
|
|
||||||
|
|
||||||
Commit all source, test, deployment config, and runbook changes to the governed task branch. Do not mark the task SUCCEEDED until all acceptance gates pass on the actually deployed staging overlay.
|
|
||||||
|
|
||||||
## Automatic rollback conditions
|
|
||||||
|
|
||||||
Automatically roll back the staging deployment if any of the following occur:
|
|
||||||
|
|
||||||
- service fails to become healthy after restart
|
|
||||||
- protected-ref write boundary is broken
|
|
||||||
- Gitea readback that worked before the change stops working
|
|
||||||
- durable saga state cannot be read or recovered
|
|
||||||
- mandatory test suite regresses below the pre-change baseline
|
|
||||||
|
|
||||||
Do not automatically roll back an existing production release. Rollback here refers only to the staging overlay/service configuration changes made by this task.
|
|
||||||
|
|
||||||
## 2026-08-25 execution record
|
|
||||||
|
|
||||||
The repair branch was deployed through the existing immutable staging builder. The final source code before the evidence commit was `2ec49005bde44f6981b14351500847d594e0276c`; the deployed artifact was built from that source with SHA256 `f67aed6cc0cce1f08197485c3eea204d3ef4d3989f44dc98e8ea2691e5886cbf`. The final staging drop-ins use a lexically highest `zzzz...-999.conf` layer so they override earlier diagnostic overlays.
|
|
||||||
|
|
||||||
The runtime configuration is staging-only: `User=aiman`, `Group=gitops`, API base `https://g.cnnetmkt.top/git/api/v1`, baseline authority `/var/lib/aios-gitops/model-api-staging/baseline/current.json`, and read/write access to the staging state plus the authoritative bare repository required by the governed Writer/CAS path. No `main`, protected tag, baseline registry, or production write is enabled.
|
|
||||||
|
|
||||||
The release executor now binds both the configured staging baseline and the immutable runtime package's `versions/V2R2C1.json` projection. The bridge passes `AIOS_GITOPS_BASELINE_AUTHORITY` into `RuntimeConfig`; this prevents release consistency and acceptance from falling back to missing publication-workspace files.
|
|
||||||
|
|
||||||
The final acceptance result is recorded in `reports/action-contract-repair-evidence-20260825.md`. Contract smoke is green, and release consistency is `PASS`; release acceptance is structured policy output but remains `accepted=false` because the pre-existing publication state is `REMOTE_PUSH_PENDING` for the protected `V2R2C1` tag. The task must therefore remain `NEEDS_REWORK` until an authorized owner resolves that independent protected-tag state.
|
|
||||||
|
|
||||||
## External Action Surface final closure attempt (2026-08-25)
|
|
||||||
|
|
||||||
本轮仅部署 staging/non-production;没有直接写入 main�release tag�baseline registry 或 production。
|
|
||||||
|
|
||||||
Public route ownership: /api/governed-write-capabilities, /api/control-plane-snapshot, /api/online-agent-task, and /api/online-agent-task-events are exact-location proxied to 127.0.0.1:18890; generic /api/ remains on 127.0.0.1:18893. The durable installer is ops/gitops-migration/bind_external_action_surface_final_staging.sh; it backs up Nginx, runs nginx -t, reloads, and forwards Authorization/X-AIOS-Token.
|
|
||||||
|
|
||||||
A task-control token, local Writer grant, Gitea consumer, SSH credential, or old token file is not an external Action bearer. If formal issuance is unavailable, do not print or rotate secrets, enable disabled writes, or manufacture commit evidence; record FORMAL_STAGING_ACTION_BEARER_ISSUANCE_NOT_AVAILABLE. Public release-consistency and release-acceptance use the canonical Bridge v2 evaluator. A Task API continuation remains QUEUED until a legitimate Agent claims it and gives durable result evidence.
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
# AIOS 最小自治修复闭环运行手册
|
|
||||||
|
|
||||||
## 范围
|
|
||||||
|
|
||||||
本手册只覆盖 staging/non-production 的最小链路:
|
|
||||||
|
|
||||||
```text
|
|
||||||
readRepositoryFile -> acquireWriterLease -> commitFilesGoverned
|
|
||||||
-> immutable staging build/reload -> runtime/read-back
|
|
||||||
```
|
|
||||||
|
|
||||||
不覆盖 release/recovery/listPatches 语义,不得写入 `main`、release tag、baseline
|
|
||||||
或 production。
|
|
||||||
|
|
||||||
## Action contract
|
|
||||||
|
|
||||||
公开 Action schema 的 canonical URL 是:
|
|
||||||
`https://cnnetmkt.top/api/openapi-chatgpt-actions.yaml`。
|
|
||||||
|
|
||||||
本轮增加两个 operation,公开面共 28 个,保留 30 个上限余量:
|
|
||||||
|
|
||||||
- `readRepositoryFile`:`POST /readRepositoryFile`,必填 `repository`, `ref`, `path`, `max_bytes`;只从 authoritative bare Git object database 读取,禁止绝对路径、`..`、反斜线、NUL 和 `.git`,单次最多 262144 bytes。
|
|
||||||
- `acquireWriterLease`:`POST /acquireWriterLease`,只接受 `CONTINUE_EXISTING`,必须绑定 durable task、repository、target branch、base OID、当前 expected branch OID、generation 和短 TTL。
|
|
||||||
- `commitFilesGoverned`:既有 `POST /v2/commit-files`,必须先有同一 task/generation/expected OID 的 lease;`files` 为数组,每项必须含 `path` 与 `content_base64`。
|
|
||||||
|
|
||||||
model-api facade 对上述三个入口执行 Action bearer 认证,并把 Authorization/X-AIOS-Token 透传给 loopback Bridge。Bridge 再执行 operation-specific scope:read 允许 `repository:read` 或兼容的 `file:write`,lease 需要 `agent-task:control`,commit 需要 `file:write`。
|
|
||||||
|
|
||||||
## 构建与部署
|
|
||||||
|
|
||||||
从当前隔离 agent branch 的完整 source OID 构建:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 scripts/build_v2r2c1_model_api_staging.py \
|
|
||||||
--source-root . \
|
|
||||||
--output-root /var/lib/aios-gitops/bridge-v2-staging/candidate-artifacts/<id> \
|
|
||||||
--source-oid <40-hex-source-oid>
|
|
||||||
```
|
|
||||||
|
|
||||||
构建 receipt 必须声明 `STAGING`、`aios/aios`、`legacy_global_writes_enabled=false`,并记录 artifact SHA-256。使用 versioned systemd drop-in template,将 source OID、artifact SHA-256 和 immutable runtime path 渲染到 staging 两个 unit;同时使用:
|
|
||||||
|
|
||||||
```ini
|
|
||||||
LoadCredential=action_tokens:/data/aios-gitops/runtime/secrets/gitops.tokens
|
|
||||||
Environment=AIOS_GITOPS_TOKEN_FILE=%d/action_tokens
|
|
||||||
```
|
|
||||||
|
|
||||||
这样由 systemd 投影已有 Action token registry,不复制或打印 token,不使用 Gitea token 代替 Action bearer。部署后只执行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
systemctl daemon-reload
|
|
||||||
systemctl restart aios-chatgpt-bridge-v2-staging.service
|
|
||||||
systemctl restart aios-gitops-model-api-v2r2c1-staging.service
|
|
||||||
```
|
|
||||||
|
|
||||||
## 验收顺序
|
|
||||||
|
|
||||||
1. `systemctl is-active` 两个 staging unit;读取 `/api/health`、`/v2/runtime`。
|
|
||||||
2. 从公网 URL 重新抓取 YAML,解析 operation 数量,确认 `<=30`,并确认 `files`/`path`/`content_base64` required parity。
|
|
||||||
3. 使用正式 Action bearer:
|
|
||||||
|
|
||||||
```text
|
|
||||||
readRepositoryFile(repository, ref=agent/<branch> or exact OID, path, max_bytes)
|
|
||||||
acquireWriterLease(task_id, repository, CONTINUE_EXISTING, base_oid,
|
|
||||||
expected_branch_oid=<read resolved OID>, target_branch,
|
|
||||||
generation, lease_seconds)
|
|
||||||
commitFilesGoverned(task_id, repository, expected_branch_oid, target_branch,
|
|
||||||
generation, idempotency_key, two files, message)
|
|
||||||
```
|
|
||||||
|
|
||||||
4. 校验 authority branch OID、Gitea Git ref OID、Gitea API branch OID 和 `commit_visible` 一致;重复相同 idempotency key 不得生成第二个 commit,旧 expected OID 必须拒绝。
|
|
||||||
5. 验证 staging runtime source OID/artifact SHA-256 与 build receipt 一致,再重复完整 smoke。
|
|
||||||
6. 若 bearer 不可用,状态必须是 `BLOCKED`,不能用 SSH、root、Gitea、旧 token、service credential 或 dev bypass 代替。
|
|
||||||
|
|
||||||
完整 suite 使用:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=.:services/aios-release-controller pytest -p no:cacheprovider -q
|
|
||||||
```
|
|
||||||
|
|
||||||
## 回滚边界
|
|
||||||
|
|
||||||
只回滚本轮创建的 staging artifact/drop-in;不得回滚 production release。若 health、Gitea readback、protected-ref boundary 或 mandatory suite 回归,恢复前一份 staging drop-in,采集 `journalctl -u` 日志,再继续诊断。
|
|
||||||
@@ -5,7 +5,6 @@ import hashlib
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import sys
|
|
||||||
import tarfile
|
import tarfile
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
@@ -19,23 +18,9 @@ REQUIRED_APP = (
|
|||||||
'silent_release_controller.py',
|
'silent_release_controller.py',
|
||||||
'silent_release_hardening.py',
|
'silent_release_hardening.py',
|
||||||
'aios_gitops_job_controller.py',
|
'aios_gitops_job_controller.py',
|
||||||
'aios_chatgpt_bridge_v2.py',
|
|
||||||
'aios_chatgpt_bridge_v2_server.py',
|
|
||||||
'governed_production_release.py',
|
|
||||||
'v2r2c1_production_adapter.py',
|
|
||||||
'v2r1c7_production_adapter.py',
|
|
||||||
'aios_governed_task_bootstrap.py',
|
|
||||||
'aios_continuation_executor.py',
|
|
||||||
'aios_continuation_lease.py',
|
|
||||||
'aios_authority_git_executor.py',
|
|
||||||
'scoped_lease_store.py',
|
|
||||||
'aios_gitops_reconciliation_core.py',
|
|
||||||
'authorized_task_writer_provisioning.py',
|
|
||||||
'signer_contract_compatibility.py',
|
|
||||||
)
|
)
|
||||||
OPTIONAL_APP = (
|
OPTIONAL_APP = (
|
||||||
'aios_chatgpt_actions_api.py',
|
'aios_chatgpt_actions_api.py',
|
||||||
'aios_model_api_staging_list_patches_overlay.py',
|
|
||||||
'aios_continuation_executor.py',
|
'aios_continuation_executor.py',
|
||||||
'aios_continuation_lease.py',
|
'aios_continuation_lease.py',
|
||||||
'aios_control_plane_snapshot.py',
|
'aios_control_plane_snapshot.py',
|
||||||
@@ -71,18 +56,11 @@ def main() -> int:
|
|||||||
source = args.source_root.resolve(strict=True)
|
source = args.source_root.resolve(strict=True)
|
||||||
output = args.output_root.resolve()
|
output = args.output_root.resolve()
|
||||||
app = source / 'active-runtime' / 'app'
|
app = source / 'active-runtime' / 'app'
|
||||||
sys.path.insert(0, str(app))
|
|
||||||
from aios_chatgpt_bridge_v2 import render_openapi_contract_fragment
|
|
||||||
|
|
||||||
runtime = source / 'runtime' / 'agent-os'
|
runtime = source / 'runtime' / 'agent-os'
|
||||||
docs = source / 'docs' / 'gitops'
|
docs = source / 'docs' / 'gitops'
|
||||||
if not app.is_dir():
|
if not app.is_dir():
|
||||||
raise SystemExit('active-runtime/app missing')
|
raise SystemExit('active-runtime/app missing')
|
||||||
paths = []
|
paths = []
|
||||||
root_version = source / 'release_version.py'
|
|
||||||
if not root_version.is_file():
|
|
||||||
raise SystemExit('required source dependency missing: release_version.py')
|
|
||||||
paths.append((Path('release_version.py'), root_version))
|
|
||||||
for name in REQUIRED_APP:
|
for name in REQUIRED_APP:
|
||||||
path = app / name
|
path = app / name
|
||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
@@ -99,8 +77,6 @@ def main() -> int:
|
|||||||
paths.append((Path('runtime-agent-os') / rel, path))
|
paths.append((Path('runtime-agent-os') / rel, path))
|
||||||
openapi = docs / 'openapi-chatgpt-actions.yaml'
|
openapi = docs / 'openapi-chatgpt-actions.yaml'
|
||||||
if openapi.is_file():
|
if openapi.is_file():
|
||||||
if ' /v2/commit-files:' not in openapi.read_text(encoding='utf-8'):
|
|
||||||
raise SystemExit('published OpenAPI is missing generated commitFilesGoverned contract')
|
|
||||||
paths.append((Path('docs') / 'gitops' / 'openapi-chatgpt-actions.yaml', openapi))
|
paths.append((Path('docs') / 'gitops' / 'openapi-chatgpt-actions.yaml', openapi))
|
||||||
for folder in ('config/model_registry', 'config/model_priority', 'patches', 'versions'):
|
for folder in ('config/model_registry', 'config/model_priority', 'patches', 'versions'):
|
||||||
root = source / folder
|
root = source / folder
|
||||||
@@ -119,7 +95,7 @@ def main() -> int:
|
|||||||
'repository_identity': 'aios/aios',
|
'repository_identity': 'aios/aios',
|
||||||
'deployment_state': 'STAGING',
|
'deployment_state': 'STAGING',
|
||||||
'runtime_mode': 'IMMUTABLE_STAGING',
|
'runtime_mode': 'IMMUTABLE_STAGING',
|
||||||
'runtime_entrypoint': 'app/aios_model_api_staging_list_patches_overlay.py',
|
'runtime_entrypoint': 'app/aios_model_api_staging.py',
|
||||||
'legacy_global_writes_enabled': False,
|
'legacy_global_writes_enabled': False,
|
||||||
'governed_release_routing': 'bridge-v2',
|
'governed_release_routing': 'bridge-v2',
|
||||||
'artifact_sha256': 'provided-by-build-receipt-and-install-manifest',
|
'artifact_sha256': 'provided-by-build-receipt-and-install-manifest',
|
||||||
|
|||||||
@@ -9,16 +9,10 @@ WORKSPACE="${AIOS_GITOPS_WORKSPACE:-/data/aios-gitops/workspace}"
|
|||||||
TAG_REMOTE="${AIOS_GITOPS_GITEA_REMOTE:-gitea}"
|
TAG_REMOTE="${AIOS_GITOPS_GITEA_REMOTE:-gitea}"
|
||||||
BRANCH_REMOTE="${AIOS_GITOPS_BRANCH_REMOTE:-gitea}"
|
BRANCH_REMOTE="${AIOS_GITOPS_BRANCH_REMOTE:-gitea}"
|
||||||
|
|
||||||
SCRIPT_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
case "$VERSION" in
|
||||||
if ! PYTHONPATH="$SCRIPT_ROOT:${PYTHONPATH:-}" python3 - "$VERSION" <<'PY'
|
V[0-9]*R[0-9]*C[0-9]*) ;;
|
||||||
import sys
|
*) echo "ERROR: version must match VxRxCx" >&2; exit 64 ;;
|
||||||
from release_version import validate_release_version
|
esac
|
||||||
validate_release_version(sys.argv[1])
|
|
||||||
PY
|
|
||||||
then
|
|
||||||
echo "ERROR: version must match VxRxCx" >&2
|
|
||||||
exit 64
|
|
||||||
fi
|
|
||||||
|
|
||||||
for value in "$TARGET_OID" "$EVIDENCE_CLOSURE_OID"; do
|
for value in "$TARGET_OID" "$EVIDENCE_CLOSURE_OID"; do
|
||||||
[[ "$value" =~ ^[0-9a-f]{40}$ ]] || {
|
[[ "$value" =~ ^[0-9a-f]{40}$ ]] || {
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import hashlib,json,re,tarfile
|
import hashlib,json,re,tarfile
|
||||||
from release_version import RELEASE_VERSION_RE
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
OID_RE=re.compile(r"^[0-9a-f]{40}$"); SHA_RE=re.compile(r"^[0-9a-f]{64}$"); VERSION_RE=RELEASE_VERSION_RE
|
OID_RE=re.compile(r"^[0-9a-f]{40}$"); SHA_RE=re.compile(r"^[0-9a-f]{64}$"); VERSION_RE=re.compile(r"^V[0-9]+R[0-9]+C[0-9]+(?:-[A-Za-z0-9_.-]+)?$")
|
||||||
REQUIRED_SOURCE={"schema","version","repository","base_ref","base_oid","binding_mode","release_readiness","production_deployed","tag_created","baseline_changed","production_service","production_port","rollback_metadata","controller_schema","required_checks"}
|
REQUIRED_SOURCE={"schema","version","repository","base_ref","base_oid","binding_mode","release_readiness","production_deployed","tag_created","baseline_changed","production_service","production_port","rollback_metadata","controller_schema","required_checks"}
|
||||||
REQUEST_KEYS={"schema","version","repository","base_oid","target_oid","artifact_uri","artifact_sha256","artifact_size","evidence_closure_oid","approval_plan_id","execute","production_deployed","production_service","production_port","rollback_metadata","current_tag_oid","source_manifest_sha256"}
|
REQUEST_KEYS={"schema","version","repository","base_oid","target_oid","artifact_uri","artifact_sha256","artifact_size","evidence_closure_oid","approval_plan_id","execute","production_deployed","production_service","production_port","rollback_metadata","current_tag_oid","source_manifest_sha256"}
|
||||||
class ManifestValidationError(ValueError):
|
class ManifestValidationError(ValueError):
|
||||||
|
|||||||
@@ -1,217 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import inspect
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
|
||||||
sys.path.insert(0, str(ROOT))
|
|
||||||
sys.path.insert(0, str(ROOT / "runtime/agent-os"))
|
|
||||||
|
|
||||||
from aios_chatgpt_bridge_v2 import ( # noqa: E402
|
|
||||||
OPERATION_REQUIRED_FIELDS,
|
|
||||||
operation_contracts,
|
|
||||||
render_openapi_contract_fragment,
|
|
||||||
)
|
|
||||||
from aios_chatgpt_bridge_v2_server import GovernedRuntime # noqa: E402
|
|
||||||
from aios_gitops_job_controller import JobState # noqa: E402
|
|
||||||
from gitea_tag_publication import validate_gitea_api_url # noqa: E402
|
|
||||||
from silent_release_hardening import ReleaseSagaStore, SilentReleaseActionService # noqa: E402
|
|
||||||
from release_version import validate_release_version # noqa: E402
|
|
||||||
import aios_model_api_staging as model_api # noqa: E402
|
|
||||||
|
|
||||||
|
|
||||||
def test_canonical_release_version_is_shared_and_accepts_v2r2c1():
|
|
||||||
assert validate_release_version("V2R2C1") == "V2R2C1"
|
|
||||||
assert validate_release_version("V2R2C1-repair") == "V2R2C1-repair"
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
validate_release_version("v2r2c1")
|
|
||||||
|
|
||||||
|
|
||||||
def test_gitea_url_validation_is_startup_safe():
|
|
||||||
assert validate_gitea_api_url("https://g.cnnetmkt.top/git/api/v1") == (
|
|
||||||
"https://g.cnnetmkt.top/git/api/v1"
|
|
||||||
)
|
|
||||||
with pytest.raises(ValueError, match="HTTP"):
|
|
||||||
validate_gitea_api_url("g.cnnetmkt.top/git/api/v1")
|
|
||||||
|
|
||||||
|
|
||||||
def test_public_release_reads_use_the_bridge_canonical_evaluator():
|
|
||||||
query = {
|
|
||||||
"version": ["V2R2C1"],
|
|
||||||
"target_oid": ["998d30304787e31e5ae607c0bd86b5edb2b0e5ad"],
|
|
||||||
"evidence_closure_oid": ["a8b906d1bc24088c19647e98de6a679268f6d0a2"],
|
|
||||||
"approval_plan_id": ["sha256:plan"],
|
|
||||||
}
|
|
||||||
assert model_api.governed_release_query_payload(query) == {
|
|
||||||
"version": "V2R2C1",
|
|
||||||
"target_oid": "998d30304787e31e5ae607c0bd86b5edb2b0e5ad",
|
|
||||||
"evidence_closure_oid": "a8b906d1bc24088c19647e98de6a679268f6d0a2",
|
|
||||||
"approval_plan_id": "sha256:plan",
|
|
||||||
}
|
|
||||||
source = inspect.getsource(model_api.H.do_GET)
|
|
||||||
assert "self.bridge_release(" in source
|
|
||||||
assert "'/getReleaseConsistency'" in source
|
|
||||||
assert "'/runReleaseAcceptance'" in source
|
|
||||||
|
|
||||||
|
|
||||||
def test_governed_read_routes_are_bound_to_authenticated_facade():
|
|
||||||
route_script = (
|
|
||||||
ROOT / "ops/gitops-migration/bind_external_action_surface_final_staging.sh"
|
|
||||||
).read_text()
|
|
||||||
for path in (
|
|
||||||
"/api/governed-write-capabilities",
|
|
||||||
"/api/control-plane-snapshot",
|
|
||||||
"/api/online-agent-task",
|
|
||||||
"/api/online-agent-task-events",
|
|
||||||
):
|
|
||||||
assert f"location = {path}" in route_script
|
|
||||||
assert f"proxy_pass http://127.0.0.1:18890{path};" in route_script
|
|
||||||
assert "location /api/ {" in route_script
|
|
||||||
assert "Authorization $http_authorization" in route_script
|
|
||||||
|
|
||||||
|
|
||||||
def test_published_action_v2_paths_are_bound_to_staging_bridge():
|
|
||||||
route_script = (
|
|
||||||
ROOT / "ops/gitops-migration/bind_external_action_surface_final_staging.sh"
|
|
||||||
).read_text()
|
|
||||||
schema = (ROOT / "docs/gitops/openapi-chatgpt-actions.yaml").read_text()
|
|
||||||
assert "servers:" in schema
|
|
||||||
assert "- url: https://cnnetmkt.top" in schema
|
|
||||||
assert " /v2/commit-files:" in schema
|
|
||||||
assert "location ^~ /v2/" in route_script
|
|
||||||
assert "proxy_pass http://127.0.0.1:18892;" in route_script
|
|
||||||
assert "proxy_pass http://127.0.0.1:18920;" not in route_script
|
|
||||||
assert "Authorization $http_authorization" in route_script
|
|
||||||
|
|
||||||
|
|
||||||
def test_published_action_schema_stays_within_chatgpt_operation_limit():
|
|
||||||
schema = yaml.safe_load(
|
|
||||||
(ROOT / "docs/gitops/openapi-chatgpt-actions.yaml").read_text()
|
|
||||||
)
|
|
||||||
operations = [
|
|
||||||
(path, method, operation)
|
|
||||||
for path, item in schema["paths"].items()
|
|
||||||
for method, operation in item.items()
|
|
||||||
if method.lower() in {"get", "post", "put", "patch", "delete", "head", "options"}
|
|
||||||
]
|
|
||||||
assert len(operations) <= 30
|
|
||||||
assert len(operations) == 28
|
|
||||||
assert "commitFilesGoverned" in {
|
|
||||||
operation["operationId"] for _, _, operation in operations
|
|
||||||
}
|
|
||||||
assert {"readRepositoryFile", "acquireWriterLease"}.issubset(
|
|
||||||
{operation["operationId"] for _, _, operation in operations}
|
|
||||||
)
|
|
||||||
assert "/api/file" not in schema["paths"]
|
|
||||||
assert "/api/commit" not in schema["paths"]
|
|
||||||
operation_ids = {operation["operationId"] for _, _, operation in operations}
|
|
||||||
assert {
|
|
||||||
"submitSignedApproval",
|
|
||||||
"createPatch",
|
|
||||||
"createBranch",
|
|
||||||
"commitFileAtomic",
|
|
||||||
"commitFile",
|
|
||||||
"commitChanges",
|
|
||||||
}.isdisjoint(operation_ids)
|
|
||||||
governed = next(
|
|
||||||
operation for _, _, operation in operations
|
|
||||||
if operation["operationId"] == "commitFilesGoverned"
|
|
||||||
)
|
|
||||||
assert governed["security"] == [{"bearerAuth": []}]
|
|
||||||
assert governed["x-openai-isConsequential"] is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_commit_files_runtime_and_published_schema_have_same_required_fields():
|
|
||||||
runtime = operation_contracts()["commitFilesGoverned"]["required_fields"]
|
|
||||||
assert runtime == list(OPERATION_REQUIRED_FIELDS["commitFilesGoverned"])
|
|
||||||
fragment = render_openapi_contract_fragment()
|
|
||||||
published = (ROOT / "docs/gitops/openapi-chatgpt-actions.yaml").read_text()
|
|
||||||
assert fragment in published
|
|
||||||
for field in runtime:
|
|
||||||
assert f" - {field}" in fragment
|
|
||||||
assert "required: [path, content_base64]" in fragment
|
|
||||||
|
|
||||||
|
|
||||||
class _Store:
|
|
||||||
def __init__(self, row):
|
|
||||||
self.row = row
|
|
||||||
self.receipt_calls = 0
|
|
||||||
|
|
||||||
def read(self, _job_id):
|
|
||||||
return self.row
|
|
||||||
|
|
||||||
def _receipt(self, row, duplicate=False):
|
|
||||||
self.receipt_calls += 1
|
|
||||||
return type("Receipt", (), {"as_dict": lambda _self: {
|
|
||||||
"job_id": row["job_id"],
|
|
||||||
"state": row["state"],
|
|
||||||
"duplicate": duplicate,
|
|
||||||
}})()
|
|
||||||
|
|
||||||
|
|
||||||
class _Controller:
|
|
||||||
def __init__(self, row):
|
|
||||||
self.store = _Store(row)
|
|
||||||
|
|
||||||
|
|
||||||
def _runtime_for(row):
|
|
||||||
runtime = object.__new__(GovernedRuntime)
|
|
||||||
controller = _Controller(row)
|
|
||||||
runtime._controller_for_job = lambda _job_id: controller
|
|
||||||
runtime._executor_from_row = lambda *_args: (_ for _ in ()).throw(
|
|
||||||
AssertionError("terminal reconcile must not reconstruct an executor")
|
|
||||||
)
|
|
||||||
return runtime, controller
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"state",
|
|
||||||
[
|
|
||||||
JobState.COMPLETED.value,
|
|
||||||
JobState.FAILED.value,
|
|
||||||
JobState.CANCELLED.value,
|
|
||||||
JobState.MANUAL_OWNER_REVIEW_REQUIRED.value,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_terminal_reconcile_is_idempotent_without_durable_binding(state):
|
|
||||||
row = {
|
|
||||||
"job_id": "job-terminal",
|
|
||||||
"state": state,
|
|
||||||
"request_json": json.dumps({}),
|
|
||||||
}
|
|
||||||
runtime, controller = _runtime_for(row)
|
|
||||||
result = GovernedRuntime.job(runtime, {"job_id": row["job_id"]}, "reconcile")
|
|
||||||
assert result["duplicate"] is True
|
|
||||||
assert controller.store.receipt_calls == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_active_reconcile_still_uses_executor():
|
|
||||||
row = {
|
|
||||||
"job_id": "job-active",
|
|
||||||
"state": JobState.RECONCILIATION_REQUIRED.value,
|
|
||||||
"request_json": json.dumps({}),
|
|
||||||
}
|
|
||||||
runtime, controller = _runtime_for(row)
|
|
||||||
class Executor:
|
|
||||||
def reconcile(self, job_id):
|
|
||||||
return {"job_id": job_id, "state": "COMPLETED"}
|
|
||||||
runtime._executor_from_row = lambda *_args: Executor()
|
|
||||||
assert GovernedRuntime.job(runtime, {"job_id": row["job_id"]}, "reconcile") == {
|
|
||||||
"job_id": "job-active",
|
|
||||||
"state": "COMPLETED",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_missing_release_saga_is_a_not_found_empty_state(tmp_path):
|
|
||||||
service = object.__new__(SilentReleaseActionService)
|
|
||||||
service.sagas = ReleaseSagaStore(tmp_path)
|
|
||||||
service.execution_enabled = False
|
|
||||||
result = service.get_release_saga("never-created-action-contract")
|
|
||||||
assert result["status"] == "NOT_FOUND"
|
|
||||||
assert result["state"] == "NOT_FOUND"
|
|
||||||
assert not list(tmp_path.rglob("*"))
|
|
||||||
@@ -20,3 +20,12 @@ def test_unified_event_flow():
|
|||||||
|
|
||||||
dashboard = AgentEventDashboard(bus).overview()
|
dashboard = AgentEventDashboard(bus).overview()
|
||||||
assert dashboard["audit"]["enabled"] is True
|
assert dashboard["audit"]["enabled"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_target_changes_event_identity(tmp_path):
|
||||||
|
bus = AgentEventBus(tmp_path)
|
||||||
|
first = bus.publish('test', 'same', 'user-a', 'same summary', {'value': 1})
|
||||||
|
second = bus.publish('test', 'same', 'user-b', 'same summary', {'value': 1})
|
||||||
|
first_id = first.get('id') if isinstance(first, dict) else getattr(first, 'id', first)
|
||||||
|
second_id = second.get('id') if isinstance(second, dict) else getattr(second, 'id', second)
|
||||||
|
assert first_id != second_id
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import base64
|
import base64
|
||||||
import subprocess
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from aios_authority_git_executor import AuthorityGitExecutor
|
from aios_authority_git_executor import AuthorityGitExecutor
|
||||||
|
from aios_chatgpt_bridge_v2 import BridgeV2Adapter, OPERATION_REQUIRED_FIELDS
|
||||||
from aios_chatgpt_bridge_v2_server import BridgeHandler, RuntimeConfig
|
from aios_chatgpt_bridge_v2_server import BridgeHandler, RuntimeConfig
|
||||||
from aios_continuation_executor import GiteaContinuationRemote, ServerSideCredentialProjection
|
from aios_continuation_executor import GiteaContinuationRemote, ServerSideCredentialProjection
|
||||||
from aios_continuation_lease import WriterLeaseRequestV2
|
from aios_continuation_lease import WriterLeaseRequestV2
|
||||||
@@ -25,24 +25,9 @@ class Probe:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def test_authority_create_task_branch_is_agent_scoped_and_idempotent(tmp_path):
|
|
||||||
bare = tmp_path / "repo.git"
|
|
||||||
subprocess.run(["git", "init", "--bare", str(bare)], check=True, capture_output=True)
|
|
||||||
source = tmp_path / "source"
|
|
||||||
subprocess.run(["git", "init", str(source)], check=True, capture_output=True)
|
|
||||||
(source / "README.md").write_text("fixture\n", encoding="utf-8")
|
|
||||||
subprocess.run(["git", "-C", str(source), "add", "README.md"], check=True)
|
|
||||||
subprocess.run(["git", "-C", str(source), "-c", "user.name=test", "-c", "user.email=test@example.invalid", "commit", "-m", "fixture"], check=True, capture_output=True)
|
|
||||||
base = subprocess.check_output(["git", "-C", str(source), "rev-parse", "HEAD"], text=True).strip()
|
|
||||||
executor = AuthorityGitExecutor(bare, tmp_path / "worktrees")
|
|
||||||
subprocess.run(["git", "-C", str(source), "push", str(bare), "HEAD:refs/heads/main"], check=True, capture_output=True)
|
|
||||||
assert executor.create_task_branch("refs/heads/agent/contract-test", base) == base
|
|
||||||
assert executor.create_task_branch("refs/heads/agent/contract-test", base) == base
|
|
||||||
assert executor.read_ref("refs/heads/agent/contract-test") == base
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_new_lease_is_exact_base_and_absent():
|
def test_create_new_lease_is_exact_base_and_absent():
|
||||||
request = WriterLeaseRequestV2(
|
request = WriterLeaseRequestV2(
|
||||||
|
job_id="job-bridge-runtime-lineage-test",
|
||||||
task_id="TASK-P0-BRIDGE-V21-DURABLE-RECONCILIATION-20260809",
|
task_id="TASK-P0-BRIDGE-V21-DURABLE-RECONCILIATION-20260809",
|
||||||
repository="aios/aios",
|
repository="aios/aios",
|
||||||
branch_mode="CREATE_NEW",
|
branch_mode="CREATE_NEW",
|
||||||
@@ -56,6 +41,7 @@ def test_create_new_lease_is_exact_base_and_absent():
|
|||||||
|
|
||||||
def test_create_new_lease_rejects_existing_target():
|
def test_create_new_lease_rejects_existing_target():
|
||||||
request = WriterLeaseRequestV2(
|
request = WriterLeaseRequestV2(
|
||||||
|
job_id="job-bridge-runtime-lineage-test",
|
||||||
task_id="task",
|
task_id="task",
|
||||||
repository="aios/aios",
|
repository="aios/aios",
|
||||||
branch_mode="CREATE_NEW",
|
branch_mode="CREATE_NEW",
|
||||||
@@ -91,9 +77,8 @@ def test_slash_branch_uses_paginated_collection():
|
|||||||
|
|
||||||
def test_http_route_contract_is_complete():
|
def test_http_route_contract_is_complete():
|
||||||
expected = {
|
expected = {
|
||||||
"/v2/runtime", "/v2/capabilities", "/v2/governed-task",
|
"/v2/runtime", "/v2/capabilities", "/v2/governed-task", "/v2/execution-envelope", "/v2/execution-plan",
|
||||||
"/v2/writer-lease", "/v2/commit-file", "/v2/commit-files",
|
"/v2/writer-lease", "/v2/writer-lease/bootstrap", "/v2/writer-lease/renew", "/v2/commit-file", "/v2/commit-files",
|
||||||
"/v2/source/file",
|
|
||||||
"/v2/job", "/v2/job/resume", "/v2/job/reconcile",
|
"/v2/job", "/v2/job/resume", "/v2/job/reconcile",
|
||||||
"/v2/tag/approval-request",
|
"/v2/tag/approval-request",
|
||||||
"/v2/tag/publish",
|
"/v2/tag/publish",
|
||||||
@@ -106,3 +91,150 @@ def test_http_route_contract_is_complete():
|
|||||||
from aios_chatgpt_bridge_v2_server import ROUTES
|
from aios_chatgpt_bridge_v2_server import ROUTES
|
||||||
|
|
||||||
assert set(ROUTES) == expected
|
assert set(ROUTES) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_renew_writer_lease_is_exposed_in_action_and_capability_contracts():
|
||||||
|
adapter = BridgeV2Adapter(runtime_status={})
|
||||||
|
capabilities = adapter.getGovernedWriteCapabilities()
|
||||||
|
contract = capabilities["operation_contracts"]["renewWriterLease"]
|
||||||
|
|
||||||
|
assert contract["method"] == "POST"
|
||||||
|
assert contract["path"] == "/v2/writer-lease/renew"
|
||||||
|
assert tuple(contract["required_fields"]) == OPERATION_REQUIRED_FIELDS["renewWriterLease"]
|
||||||
|
assert "runtime_rehydrated" in contract["response_fields"]
|
||||||
|
|
||||||
|
action_schema = Path("docs/gitops/openapi-chatgpt-actions.yaml").read_text(encoding="utf-8")
|
||||||
|
assert "/v2/writer-lease/renew:" in action_schema
|
||||||
|
assert "operationId: renewWriterLease" in action_schema
|
||||||
|
assert "BridgeWriterLeaseRecoveryRequest" in action_schema
|
||||||
|
assert "BridgeWriterLeaseRecoveryResponse" in action_schema
|
||||||
|
assert {
|
||||||
|
capabilities["operation_contracts"][operation]["path"]
|
||||||
|
for operation in (
|
||||||
|
"acquireWriterLease",
|
||||||
|
"renewWriterLease",
|
||||||
|
"commitFileGoverned",
|
||||||
|
"commitFilesGoverned",
|
||||||
|
)
|
||||||
|
} == {
|
||||||
|
"/v2/writer-lease",
|
||||||
|
"/v2/writer-lease/renew",
|
||||||
|
"/v2/commit-file",
|
||||||
|
"/v2/commit-files",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_bootstrap_writer_lease_is_exposed_in_action_and_capability_contracts():
|
||||||
|
adapter = BridgeV2Adapter(runtime_status={})
|
||||||
|
capabilities = adapter.getGovernedWriteCapabilities()
|
||||||
|
contract = capabilities["operation_contracts"]["bootstrapWriterLease"]
|
||||||
|
|
||||||
|
assert contract["method"] == "POST"
|
||||||
|
assert contract["path"] == "/v2/writer-lease/bootstrap"
|
||||||
|
assert tuple(contract["required_fields"]) == OPERATION_REQUIRED_FIELDS["bootstrapWriterLease"]
|
||||||
|
assert "bootstrapped" in contract["response_fields"]
|
||||||
|
assert "durable_branch_lease_present" in contract["response_fields"]
|
||||||
|
|
||||||
|
action_schema = Path("docs/gitops/openapi-chatgpt-actions.yaml").read_text(encoding="utf-8")
|
||||||
|
assert "/v2/writer-lease/bootstrap:" in action_schema
|
||||||
|
assert "operationId: bootstrapWriterLease" in action_schema
|
||||||
|
assert "bootstrapped: {type: boolean}" in action_schema
|
||||||
|
assert "durable_branch_lease_present: {type: boolean}" in action_schema
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_http_allows_explicit_job_id_on_lease_and_commit_requests():
|
||||||
|
handler = BridgeHandler.__new__(BridgeHandler)
|
||||||
|
lease_body = {
|
||||||
|
"schema": WriterLeaseRequestV2.schema,
|
||||||
|
"task_id": "task",
|
||||||
|
"job_id": "job-explicit-runtime-lineage",
|
||||||
|
"repository": "aios/aios",
|
||||||
|
"branch_mode": "CONTINUE_EXISTING",
|
||||||
|
"base_oid": "a" * 40,
|
||||||
|
"expected_branch_oid": "a" * 40,
|
||||||
|
"target_branch": "agent/explicit-runtime-lineage",
|
||||||
|
"generation": 2,
|
||||||
|
"lease_seconds": 120,
|
||||||
|
}
|
||||||
|
handler._allowed("acquireWriterLease", lease_body)
|
||||||
|
handler._allowed(
|
||||||
|
"commitFilesGoverned",
|
||||||
|
{
|
||||||
|
"task_id": "task",
|
||||||
|
"job_id": "job-explicit-runtime-lineage",
|
||||||
|
"repository": "aios/aios",
|
||||||
|
"expected_branch_oid": "a" * 40,
|
||||||
|
"target_branch": "agent/explicit-runtime-lineage",
|
||||||
|
"generation": 2,
|
||||||
|
"idempotency_key": "commit-explicit-runtime-lineage",
|
||||||
|
"files": {"docs/x.txt": "eA=="},
|
||||||
|
"message": "test",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_http_allows_omitted_job_id_for_durable_server_resolution():
|
||||||
|
handler = BridgeHandler.__new__(BridgeHandler)
|
||||||
|
lease_body = {
|
||||||
|
"schema": WriterLeaseRequestV2.schema,
|
||||||
|
"task_id": "task",
|
||||||
|
"repository": "aios/aios",
|
||||||
|
"branch_mode": "CONTINUE_EXISTING",
|
||||||
|
"base_oid": "a" * 40,
|
||||||
|
"expected_branch_oid": "a" * 40,
|
||||||
|
"target_branch": "agent/implicit-runtime-lineage",
|
||||||
|
"generation": 2,
|
||||||
|
"lease_seconds": 120,
|
||||||
|
}
|
||||||
|
handler._allowed("acquireWriterLease", lease_body)
|
||||||
|
handler._allowed(
|
||||||
|
"commitFilesGoverned",
|
||||||
|
{
|
||||||
|
"task_id": "task",
|
||||||
|
"repository": "aios/aios",
|
||||||
|
"expected_branch_oid": "a" * 40,
|
||||||
|
"target_branch": "agent/implicit-runtime-lineage",
|
||||||
|
"generation": 2,
|
||||||
|
"idempotency_key": "commit-implicit-runtime-lineage",
|
||||||
|
"files": {"docs/x.txt": "eA=="},
|
||||||
|
"message": "test",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_http_mutation_routes_require_exact_runtime_principal():
|
||||||
|
handler = BridgeHandler.__new__(BridgeHandler)
|
||||||
|
runtime_principal = "runtime-" "agent-principal-token-1234"
|
||||||
|
handler.control_token = runtime_principal
|
||||||
|
handler.agent_task_api_url = "http://127.0.0.1:1"
|
||||||
|
handler.headers = {"Authorization": f"Bearer {runtime_principal}"}
|
||||||
|
assert handler._authorized("createGovernedTask", {}) is True
|
||||||
|
handler.headers = {"Authorization": "Bearer wrong-runtime-principal-token"}
|
||||||
|
assert handler._authorized("createGovernedTask", {}) is False
|
||||||
|
handler.headers = {}
|
||||||
|
assert handler._authorized("createGovernedTask", {}) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_capability_contracts_return_exact_job_id_lineage():
|
||||||
|
contracts = BridgeV2Adapter(runtime_status={}).getGovernedWriteCapabilities()["operation_contracts"]
|
||||||
|
for operation in ("acquireWriterLease", "bootstrapWriterLease", "renewWriterLease"):
|
||||||
|
assert "job_id" in contracts[operation]["response_fields"]
|
||||||
|
for operation in ("commitFileGoverned", "commitFilesGoverned"):
|
||||||
|
assert "job_id" in contracts[operation]["response_fields"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_online_agent_execution_routes_and_action_projection_are_exposed():
|
||||||
|
from aios_chatgpt_bridge_v2 import BridgeV2Adapter, OPERATION_REQUIRED_FIELDS
|
||||||
|
from aios_chatgpt_bridge_v2_server import ROUTES
|
||||||
|
assert ROUTES["/v2/execution-envelope"] == "projectOnlineAgentExecution"
|
||||||
|
assert ROUTES["/v2/execution-plan"] == "registerOnlineAgentExecutionPlan"
|
||||||
|
adapter = BridgeV2Adapter(runtime_status={})
|
||||||
|
contracts = adapter.getGovernedWriteCapabilities()["operation_contracts"]
|
||||||
|
assert tuple(contracts["projectOnlineAgentExecution"]["required_fields"]) == OPERATION_REQUIRED_FIELDS["projectOnlineAgentExecution"]
|
||||||
|
assert tuple(contracts["registerOnlineAgentExecutionPlan"]["required_fields"]) == OPERATION_REQUIRED_FIELDS["registerOnlineAgentExecutionPlan"]
|
||||||
|
schema = Path("docs/gitops/openapi-chatgpt-actions.yaml").read_text(encoding="utf-8")
|
||||||
|
assert "/v2/execution-envelope:" in schema
|
||||||
|
assert "operationId: projectOnlineAgentExecution" in schema
|
||||||
|
assert "/v2/execution-plan:" in schema
|
||||||
|
assert "operationId: registerOnlineAgentExecutionPlan" in schema
|
||||||
|
|||||||
@@ -0,0 +1,307 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import json
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from aios_chatgpt_bridge_v2_server import GovernedRuntime
|
||||||
|
from aios_continuation_executor import ContinuationExecutor
|
||||||
|
from aios_continuation_lease import WriterLeaseRequestV2
|
||||||
|
from aios_gitops_job_controller import JobState
|
||||||
|
|
||||||
|
|
||||||
|
TASK = "continuation-normalization-test"
|
||||||
|
JOB = "job-continuation-normalization-test"
|
||||||
|
REPO = "aios/aios"
|
||||||
|
BASE = "a" * 40
|
||||||
|
BRANCH = "agent/continuation-normalization-test"
|
||||||
|
|
||||||
|
|
||||||
|
class Authority:
|
||||||
|
def __init__(self, head: str | None) -> None:
|
||||||
|
self.head = head
|
||||||
|
|
||||||
|
def read_ref(self, ref: str) -> str | None:
|
||||||
|
assert ref == "refs/heads/" + BRANCH
|
||||||
|
return self.head
|
||||||
|
|
||||||
|
def commit_exists(self, oid: str) -> bool:
|
||||||
|
return oid == BASE
|
||||||
|
|
||||||
|
def branch_exists(self, branch: str) -> bool:
|
||||||
|
assert branch == BRANCH
|
||||||
|
return self.head is not None
|
||||||
|
|
||||||
|
def branch_head(self, branch: str) -> str:
|
||||||
|
assert branch == BRANCH and self.head is not None
|
||||||
|
return self.head
|
||||||
|
|
||||||
|
def is_ancestor(self, older: str, newer: str) -> bool:
|
||||||
|
return older == BASE and newer == BASE
|
||||||
|
|
||||||
|
|
||||||
|
def lease(**overrides: object) -> WriterLeaseRequestV2:
|
||||||
|
values = {
|
||||||
|
"task_id": TASK,
|
||||||
|
"job_id": JOB,
|
||||||
|
"repository": REPO,
|
||||||
|
"branch_mode": "CREATE_NEW",
|
||||||
|
"base_oid": BASE,
|
||||||
|
"expected_branch_oid": BASE,
|
||||||
|
"target_branch": BRANCH,
|
||||||
|
"generation": 1,
|
||||||
|
"lease_seconds": 120,
|
||||||
|
}
|
||||||
|
values.update(overrides)
|
||||||
|
return WriterLeaseRequestV2(**values)
|
||||||
|
|
||||||
|
|
||||||
|
def runtime(*, head: str | None = BASE, **binding_overrides: object) -> GovernedRuntime:
|
||||||
|
value = GovernedRuntime.__new__(GovernedRuntime)
|
||||||
|
value.authority = Authority(head)
|
||||||
|
binding = {
|
||||||
|
"task_id": TASK,
|
||||||
|
"job_id": JOB,
|
||||||
|
"repository": REPO,
|
||||||
|
"base_oid": BASE,
|
||||||
|
"expected_branch_oid": BASE,
|
||||||
|
"target_branch": BRANCH,
|
||||||
|
"generation": 1,
|
||||||
|
"branch_mode": "CREATE_NEW",
|
||||||
|
}
|
||||||
|
binding.update(binding_overrides)
|
||||||
|
value.bindings = {TASK: binding}
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def test_same_lineage_create_new_normalizes_to_continue_existing() -> None:
|
||||||
|
normalized = runtime()._normalize_runtime_lease(lease())
|
||||||
|
assert normalized.branch_mode == "CONTINUE_EXISTING"
|
||||||
|
assert normalized.task_id == TASK
|
||||||
|
assert normalized.job_id == JOB
|
||||||
|
assert normalized.repository == REPO
|
||||||
|
assert normalized.target_branch == BRANCH
|
||||||
|
assert normalized.expected_branch_oid == BASE
|
||||||
|
assert normalized.generation == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_unprovisioned_create_new_stays_create_new() -> None:
|
||||||
|
assert runtime(head=None)._normalize_runtime_lease(lease()).branch_mode == "CREATE_NEW"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("field", "value"),
|
||||||
|
(
|
||||||
|
("repository", "other/repo"),
|
||||||
|
("base_oid", "b" * 40),
|
||||||
|
("expected_branch_oid", "b" * 40),
|
||||||
|
("target_branch", "agent/foreign"),
|
||||||
|
("generation", 2),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def test_binding_mismatch_fails_closed(field: str, value: object) -> None:
|
||||||
|
with pytest.raises(RuntimeError, match="LEASE_BINDING_CONFLICT"):
|
||||||
|
runtime(**{field: value})._normalize_runtime_lease(lease())
|
||||||
|
|
||||||
|
|
||||||
|
def test_drifted_existing_head_fails_closed() -> None:
|
||||||
|
with pytest.raises(RuntimeError, match="EXPECTED_BRANCH_OID_CONFLICT"):
|
||||||
|
runtime(head="b" * 40)._normalize_runtime_lease(lease())
|
||||||
|
|
||||||
|
|
||||||
|
def test_openapi_contract_parses_and_requires_exact_commit_lineage() -> None:
|
||||||
|
document = yaml.safe_load(
|
||||||
|
Path("docs/gitops/openapi-chatgpt-actions.yaml").read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
schema = document["components"]["schemas"]["BridgeCommitResponse"]
|
||||||
|
assert {
|
||||||
|
"job_id",
|
||||||
|
"commit_oid",
|
||||||
|
"commit_visible",
|
||||||
|
"branch_mode",
|
||||||
|
"target_branch",
|
||||||
|
"generation",
|
||||||
|
} <= set(schema["required"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_changed_production_files_pass_secret_scan() -> None:
|
||||||
|
paths = [
|
||||||
|
Path("active-runtime/app/aios_authority_git_executor.py"),
|
||||||
|
Path("active-runtime/app/aios_chatgpt_bridge_v2_server.py"),
|
||||||
|
Path("active-runtime/app/aios_continuation_executor.py"),
|
||||||
|
Path("active-runtime/app/aios_continuation_lease.py"),
|
||||||
|
Path("active-runtime/app/authorized_task_writer_provisioning.py"),
|
||||||
|
Path("active-runtime/app/aios_chatgpt_bridge_v2.py"),
|
||||||
|
Path("docs/gitops/openapi-chatgpt-actions.yaml"),
|
||||||
|
]
|
||||||
|
expression = re.compile(
|
||||||
|
r"(?i)(BEGIN (RSA|OPENSSH|EC) PRIVATE KEY|"
|
||||||
|
r"authorization:\\s*bearer\\s+[A-Za-z0-9._-]{12,}|"
|
||||||
|
r"api[_-]?key\\s*[:=]\\s*['\\\"][^'\\\"]{12,})"
|
||||||
|
)
|
||||||
|
hits = []
|
||||||
|
for path in paths:
|
||||||
|
for number, line in enumerate(
|
||||||
|
path.read_text(encoding="utf-8", errors="replace").splitlines(), 1
|
||||||
|
):
|
||||||
|
if expression.search(line):
|
||||||
|
hits.append(f"{path}:{number}")
|
||||||
|
assert hits == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_remote_verified_job_is_not_completed_when_head_cas_callback_fails() -> None:
|
||||||
|
class Store:
|
||||||
|
def read(self, _job_id):
|
||||||
|
return {
|
||||||
|
"branch": BRANCH,
|
||||||
|
"request_json": json.dumps(
|
||||||
|
{"binding": {"expected_branch_oid": BASE}}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
class Controller:
|
||||||
|
def __init__(self):
|
||||||
|
self.store = Store()
|
||||||
|
self.states = []
|
||||||
|
|
||||||
|
def advance(self, _job_id, state, **_kwargs):
|
||||||
|
self.states.append(state)
|
||||||
|
return SimpleNamespace(as_dict=lambda: {"state": state.value})
|
||||||
|
|
||||||
|
executor = ContinuationExecutor.__new__(ContinuationExecutor)
|
||||||
|
executor.controller = Controller()
|
||||||
|
executor._observe_remote = lambda _branch, oid: (oid, oid, True)
|
||||||
|
|
||||||
|
def fail_cas(_oid):
|
||||||
|
raise RuntimeError("LEASE_CAS_CONFLICT")
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="LEASE_CAS_CONFLICT"):
|
||||||
|
executor._finish_remote_verification(
|
||||||
|
JOB,
|
||||||
|
"b" * 40,
|
||||||
|
"b" * 40,
|
||||||
|
before_complete=fail_cas,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert executor.controller.states == [JobState.REMOTE_VERIFIED]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("branch_mode", ("CREATE_NEW", "CONTINUE_EXISTING"))
|
||||||
|
def test_executor_preflight_accepts_the_exact_mode_dependent_normalized_shape(
|
||||||
|
branch_mode: str,
|
||||||
|
) -> None:
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from aios_continuation_executor import (
|
||||||
|
ContinuationPermissions,
|
||||||
|
ContinuationRuntimeBinding,
|
||||||
|
)
|
||||||
|
|
||||||
|
request = lease(branch_mode=branch_mode)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
executor = ContinuationExecutor.__new__(ContinuationExecutor)
|
||||||
|
executor.authority = Authority(None if branch_mode == "CREATE_NEW" else BASE)
|
||||||
|
executor.now = lambda: now
|
||||||
|
executor._max_generation = lambda _branch, *, exclude_job_id=None: 0
|
||||||
|
executor.workspace = SimpleNamespace(
|
||||||
|
is_detached=lambda: True,
|
||||||
|
head_oid=lambda: BASE,
|
||||||
|
)
|
||||||
|
executor.remote = SimpleNamespace(repository_identity=lambda: REPO)
|
||||||
|
binding = ContinuationRuntimeBinding(
|
||||||
|
request=request,
|
||||||
|
job_id=JOB,
|
||||||
|
workspace_path=Path("/tmp/continuation-normalization-test"),
|
||||||
|
lease_not_before=now - timedelta(seconds=1),
|
||||||
|
lease_expires_at=now + timedelta(seconds=60),
|
||||||
|
original_receipt_valid=True,
|
||||||
|
original_grant_consumed=True,
|
||||||
|
permissions=ContinuationPermissions(),
|
||||||
|
)
|
||||||
|
|
||||||
|
executor._validate_preflight(binding, job_id=JOB)
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_new_executor_resumes_executing_and_uses_absence_bound_writers() -> None:
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from aios_continuation_executor import (
|
||||||
|
ContinuationCommit,
|
||||||
|
ContinuationPermissions,
|
||||||
|
ContinuationRuntimeBinding,
|
||||||
|
)
|
||||||
|
|
||||||
|
new_oid = "b" * 40
|
||||||
|
|
||||||
|
class Store:
|
||||||
|
def read(self, _job_id: str) -> dict[str, object]:
|
||||||
|
return {"state": "EXECUTING", "revision": 4}
|
||||||
|
|
||||||
|
class Controller:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.store = Store()
|
||||||
|
self.advanced: list[str] = []
|
||||||
|
|
||||||
|
def advance(self, _job_id: str, state: JobState, **_kwargs: object) -> object:
|
||||||
|
self.advanced.append(state.value)
|
||||||
|
return SimpleNamespace(as_dict=lambda: {"state": state.value})
|
||||||
|
|
||||||
|
def complete_continuation_cycle(self, *_args: object, **_kwargs: object) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
class Workspace:
|
||||||
|
def commit_files(self, _files: object, _message: str) -> str:
|
||||||
|
return new_oid
|
||||||
|
|
||||||
|
class CreateAuthority:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.created: list[tuple[str, str, str]] = []
|
||||||
|
|
||||||
|
def is_ancestor(self, older: str, newer: str) -> bool:
|
||||||
|
return older == BASE and newer == new_oid
|
||||||
|
|
||||||
|
def create_task_branch_cas(self, branch: str, base: str, new: str) -> str:
|
||||||
|
self.created.append((branch, base, new))
|
||||||
|
return new
|
||||||
|
|
||||||
|
def compare_and_swap_branch(self, *_args: object) -> str:
|
||||||
|
raise AssertionError("existing-branch CAS must not serve CREATE_NEW")
|
||||||
|
|
||||||
|
class CreateRemote:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.created: list[tuple[str, str]] = []
|
||||||
|
|
||||||
|
def push_new_exact(self, branch: str, new: str) -> None:
|
||||||
|
self.created.append((branch, new))
|
||||||
|
|
||||||
|
def push_exact(self, *_args: object) -> None:
|
||||||
|
raise AssertionError("existing-branch push must not serve CREATE_NEW")
|
||||||
|
|
||||||
|
class Credential:
|
||||||
|
def project(self) -> None: pass
|
||||||
|
def revoke(self) -> None: pass
|
||||||
|
|
||||||
|
controller=Controller(); authority=CreateAuthority(); remote=CreateRemote()
|
||||||
|
executor=ContinuationExecutor(controller,authority,Workspace(),remote,Credential())
|
||||||
|
executor._validate_preflight=lambda *_args,**_kwargs: None
|
||||||
|
executor._finish_remote_verification=lambda *_args,**_kwargs: {
|
||||||
|
"job_id":JOB,"state":"COMPLETED","receipt":{"authority_oid":new_oid,"commit_visible":True}
|
||||||
|
}
|
||||||
|
now=datetime.now(timezone.utc)
|
||||||
|
binding=ContinuationRuntimeBinding(
|
||||||
|
request=lease(branch_mode="CREATE_NEW"),job_id=JOB,
|
||||||
|
workspace_path=Path("/tmp/create-new-executor-resume"),
|
||||||
|
lease_not_before=now-timedelta(seconds=1),lease_expires_at=now+timedelta(seconds=60),
|
||||||
|
original_receipt_valid=True,original_grant_consumed=True,
|
||||||
|
permissions=ContinuationPermissions(),
|
||||||
|
)
|
||||||
|
result=executor.execute(
|
||||||
|
binding,ContinuationCommit("create-new-test",{"docs/test.txt":b"ok"},"test")
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["state"]=="COMPLETED"
|
||||||
|
assert authority.created==[(BRANCH,BASE,new_oid)]
|
||||||
|
assert remote.created==[(BRANCH,new_oid)]
|
||||||
|
assert controller.advanced==["LOCAL_VERIFIED","REMOTE_SYNCING"]
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
import importlib.util
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
from copy import deepcopy
|
|
||||||
from pathlib import Path
|
|
||||||
from types import SimpleNamespace
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
|
|
||||||
MODULE_PATH = (
|
|
||||||
Path(__file__).parents[1]
|
|
||||||
/ "active-runtime"
|
|
||||||
/ "app"
|
|
||||||
/ "governed_production_release.py"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _load_module():
|
|
||||||
dependency = types.ModuleType("gitea_tag_publication")
|
|
||||||
for name in (
|
|
||||||
"GiteaTagClient",
|
|
||||||
"GitTagBackend",
|
|
||||||
"PublicationManualOwnerReviewRequired",
|
|
||||||
"PublicationPending",
|
|
||||||
"PublicationReconciliationRequired",
|
|
||||||
"PublicationRequest",
|
|
||||||
"TagPublisher",
|
|
||||||
):
|
|
||||||
setattr(dependency, name, type(name, (), {}))
|
|
||||||
sys.modules["gitea_tag_publication"] = dependency
|
|
||||||
|
|
||||||
spec = importlib.util.spec_from_file_location(
|
|
||||||
"governed_production_release_under_test", MODULE_PATH
|
|
||||||
)
|
|
||||||
module = importlib.util.module_from_spec(spec)
|
|
||||||
assert spec.loader is not None
|
|
||||||
sys.modules[spec.name] = module
|
|
||||||
spec.loader.exec_module(module)
|
|
||||||
return module
|
|
||||||
|
|
||||||
|
|
||||||
release_module = _load_module()
|
|
||||||
|
|
||||||
|
|
||||||
class FakeResponse:
|
|
||||||
status = 200
|
|
||||||
|
|
||||||
def __init__(self, body):
|
|
||||||
self._payload = json.dumps(body).encode("utf-8")
|
|
||||||
|
|
||||||
def __enter__(self):
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __exit__(self, exc_type, exc_value, traceback):
|
|
||||||
return False
|
|
||||||
|
|
||||||
def read(self):
|
|
||||||
return self._payload
|
|
||||||
|
|
||||||
|
|
||||||
def _health_body(binding, artifact_sha256):
|
|
||||||
return {
|
|
||||||
"status": "ok",
|
|
||||||
"version": binding.version,
|
|
||||||
"commit_oid": binding.target_oid,
|
|
||||||
"artifact_sha256": artifact_sha256,
|
|
||||||
"deployment_state": "ACTIVE",
|
|
||||||
"production_deployed": True,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _run_acceptance(result):
|
|
||||||
executor = object.__new__(release_module.GovernedProductionReleaseExecutor)
|
|
||||||
records = []
|
|
||||||
executor.state = SimpleNamespace(
|
|
||||||
record=lambda *args: records.append(args),
|
|
||||||
)
|
|
||||||
executor.consistency = lambda request: deepcopy(result)
|
|
||||||
return executor.acceptance({}), records
|
|
||||||
|
|
||||||
|
|
||||||
def test_all_checks_true_preserves_health_status_and_accepts():
|
|
||||||
binding = release_module.ReleaseBinding()
|
|
||||||
artifact_sha256 = "a" * 64
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"urllib.request.urlopen",
|
|
||||||
return_value=FakeResponse(_health_body(binding, artifact_sha256)),
|
|
||||||
):
|
|
||||||
health = release_module._health(
|
|
||||||
"https://example.test/health",
|
|
||||||
binding,
|
|
||||||
{"artifact_sha256": artifact_sha256},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert health["status"] == "ok"
|
|
||||||
assert health["verification_status"] == "ACTIVE_VERIFIED"
|
|
||||||
assert health["http_status"] == 200
|
|
||||||
|
|
||||||
result, records = _run_acceptance(
|
|
||||||
{"status": "PASS", "production_health": health}
|
|
||||||
)
|
|
||||||
assert result["accepted"] is True
|
|
||||||
assert records[-1][0] == "ACCEPTED"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("field", "value", "artifact_sha256"),
|
|
||||||
[
|
|
||||||
("commit_oid", "0" * 40, "a" * 64),
|
|
||||||
("artifact_sha256", "b" * 64, "a" * 64),
|
|
||||||
("deployment_state", "STAGING", "a" * 64),
|
|
||||||
],
|
|
||||||
ids=["commit-mismatch", "artifact-mismatch", "deployment-not-active"],
|
|
||||||
)
|
|
||||||
def test_health_conflicts_keep_external_status_and_reject(field, value, artifact_sha256):
|
|
||||||
binding = release_module.ReleaseBinding()
|
|
||||||
body = _health_body(binding, artifact_sha256)
|
|
||||||
body[field] = value
|
|
||||||
|
|
||||||
with patch("urllib.request.urlopen", return_value=FakeResponse(body)):
|
|
||||||
health = release_module._health(
|
|
||||||
"https://example.test/health",
|
|
||||||
binding,
|
|
||||||
{"artifact_sha256": artifact_sha256},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert health["status"] == "ok"
|
|
||||||
assert health["verification_status"] == "CONFLICT"
|
|
||||||
result, _ = _run_acceptance(
|
|
||||||
{"status": "PASS", "production_health": health}
|
|
||||||
)
|
|
||||||
assert result["accepted"] is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_health_unavailable_has_internal_verdict_and_rejects():
|
|
||||||
binding = release_module.ReleaseBinding()
|
|
||||||
|
|
||||||
with patch("urllib.request.urlopen", side_effect=TimeoutError("boom")):
|
|
||||||
health = release_module._health(
|
|
||||||
"https://example.test/health",
|
|
||||||
binding,
|
|
||||||
{"artifact_sha256": "a" * 64},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert health["status"] == "UNAVAILABLE"
|
|
||||||
assert health["verification_status"] == "UNAVAILABLE"
|
|
||||||
assert health["error"] == "TimeoutError"
|
|
||||||
result, _ = _run_acceptance(
|
|
||||||
{"status": "PASS", "production_health": health}
|
|
||||||
)
|
|
||||||
assert result["accepted"] is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_acceptance_rejects_when_overall_check_is_not_pass():
|
|
||||||
result, records = _run_acceptance(
|
|
||||||
{
|
|
||||||
"status": "CONFLICT",
|
|
||||||
"production_health": {
|
|
||||||
"status": "ok",
|
|
||||||
"verification_status": "ACTIVE_VERIFIED",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result["accepted"] is False
|
|
||||||
assert records[-1][0] == "RECONCILIATION_REQUIRED"
|
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
import hashlib,json,os,tarfile,sys,tempfile
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
ROOT=Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0,str(ROOT/"active-runtime/app"))
|
||||||
|
import aios_isolated_executor as mod
|
||||||
|
|
||||||
|
def make_archive(tmp_path):
|
||||||
|
root=tmp_path/"src"; root.mkdir()
|
||||||
|
(root/"hello.py").write_text("VALUE = 1\n")
|
||||||
|
archive=tmp_path/"source.tgz"
|
||||||
|
with tarfile.open(archive,"w:gz") as tf: tf.add(root/"hello.py",arcname="hello.py")
|
||||||
|
return archive
|
||||||
|
|
||||||
|
def test_isolated_executor_applies_hash_bound_change(tmp_path,monkeypatch):
|
||||||
|
archive=make_archive(tmp_path)
|
||||||
|
monkeypatch.delenv("CREDENTIALS_DIRECTORY",raising=False)
|
||||||
|
monkeypatch.setattr(mod,"assert_uncredentialed",lambda:None)
|
||||||
|
request={
|
||||||
|
"schema":"aios.isolated-execution.request.v1",
|
||||||
|
"request_id":"task-req-0000000000000000",
|
||||||
|
"source_archive":str(archive),
|
||||||
|
"source_archive_sha256":mod.sha256(archive.read_bytes()),
|
||||||
|
"change_set":[{
|
||||||
|
"path":"hello.py","operation":"replace_text","old":"VALUE = 1","new":"VALUE = 2","count":1,
|
||||||
|
"expected_sha256":mod.sha256(b"VALUE = 1\n")
|
||||||
|
}],
|
||||||
|
"verification":[{"label":"compile","argv":["/usr/bin/python3","-m","py_compile","hello.py"],"cwd":".","timeout_seconds":30}]
|
||||||
|
}
|
||||||
|
req=tmp_path/"req.json"; res=tmp_path/"res.json"
|
||||||
|
req.write_text(json.dumps(request))
|
||||||
|
mod.execute_request(req,res)
|
||||||
|
out=json.loads(res.read_text())
|
||||||
|
assert out["request_id"]=="task-req-0000000000000000"
|
||||||
|
assert out["credential_isolation"]["credential_env_absent"] is True
|
||||||
|
changed=out["changed_files"][0]
|
||||||
|
assert changed["path"]=="hello.py" and changed["deleted"] is False
|
||||||
|
|
||||||
|
def test_isolated_executor_source_drift_denied(tmp_path,monkeypatch):
|
||||||
|
archive=make_archive(tmp_path); monkeypatch.setattr(mod,"assert_uncredentialed",lambda:None)
|
||||||
|
request={
|
||||||
|
"schema":"aios.isolated-execution.request.v1","request_id":"task-req-0000000000000001",
|
||||||
|
"source_archive":str(archive),
|
||||||
|
"source_archive_sha256":mod.sha256(archive.read_bytes()),
|
||||||
|
"change_set":[{"path":"hello.py","operation":"write_file","content":"x\n","expected_sha256":"0"*64}],
|
||||||
|
"verification":[]
|
||||||
|
}
|
||||||
|
req=tmp_path/"req.json"; res=tmp_path/"res.json"; req.write_text(json.dumps(request))
|
||||||
|
with pytest.raises(mod.IsolatedExecutorError,match="SOURCE_DRIFT"):
|
||||||
|
mod.execute_request(req,res)
|
||||||
|
|
||||||
|
def test_credential_boundary_eacces_is_accepted(monkeypatch):
|
||||||
|
class DeniedDirectory:
|
||||||
|
def exists(self): return True
|
||||||
|
def is_symlink(self): return False
|
||||||
|
def is_dir(self): return True
|
||||||
|
def iterdir(self): raise PermissionError(13,"permission denied")
|
||||||
|
monkeypatch.setattr(mod.os,"access",lambda *_args,**_kwargs:False)
|
||||||
|
assert mod._credential_exposure(DeniedDirectory()) is None
|
||||||
|
|
||||||
|
def test_systemd_unit_has_no_credentials_and_private_network():
|
||||||
|
unit=(ROOT/"deploy/systemd/aios-online-agent-isolated-executor.service").read_text()
|
||||||
|
assert "PrivateNetwork=true" in unit
|
||||||
|
assert "InaccessiblePaths=/run/credentials" in unit
|
||||||
|
assert "LoadCredential" not in unit
|
||||||
|
assert "SupplementaryGroups=gitops" not in unit
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import importlib.util
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
from types import ModuleType
|
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
|
||||||
OVERLAY = ROOT / "active-runtime" / "app" / "aios_model_api_staging_list_patches_overlay.py"
|
|
||||||
|
|
||||||
|
|
||||||
class StubHandler:
|
|
||||||
def sendj(self, data, code=200):
|
|
||||||
self.sent = (data, code)
|
|
||||||
return self.sent
|
|
||||||
|
|
||||||
|
|
||||||
def load_overlay() -> ModuleType:
|
|
||||||
base = ModuleType("aios_model_api_staging")
|
|
||||||
base.H = StubHandler
|
|
||||||
base.main = lambda: None
|
|
||||||
previous = sys.modules.get("aios_model_api_staging")
|
|
||||||
sys.modules["aios_model_api_staging"] = base
|
|
||||||
try:
|
|
||||||
spec = importlib.util.spec_from_file_location("list_patches_overlay_test", OVERLAY)
|
|
||||||
assert spec and spec.loader
|
|
||||||
module = importlib.util.module_from_spec(spec)
|
|
||||||
spec.loader.exec_module(module)
|
|
||||||
return module
|
|
||||||
finally:
|
|
||||||
if previous is None:
|
|
||||||
sys.modules.pop("aios_model_api_staging", None)
|
|
||||||
else:
|
|
||||||
sys.modules["aios_model_api_staging"] = previous
|
|
||||||
|
|
||||||
|
|
||||||
def test_overlay_keeps_legacy_keys_but_only_emits_bounded_summaries():
|
|
||||||
overlay = load_overlay()
|
|
||||||
records = [
|
|
||||||
{
|
|
||||||
"id": "oversized",
|
|
||||||
"branch": "agent/oversized",
|
|
||||||
"created_at": "2026-08-26T15:00:00Z",
|
|
||||||
"text": "X" * 2_000_000,
|
|
||||||
"body": "B" * 2_000_000,
|
|
||||||
"diff": "D" * 2_000_000,
|
|
||||||
"evidence": {"raw": "E" * 2_000_000},
|
|
||||||
}
|
|
||||||
] + [
|
|
||||||
{"id": f"patch-{index:03d}", "created_at": f"2026-08-25T{index % 24:02d}:00:00Z", "description": "summary"}
|
|
||||||
for index in range(1, 25)
|
|
||||||
]
|
|
||||||
handler = overlay.ListPatchesOverlayHandler.__new__(overlay.ListPatchesOverlayHandler)
|
|
||||||
handler.path = "/api/patch"
|
|
||||||
result = handler.sendj(
|
|
||||||
{
|
|
||||||
"metadata_patches": records,
|
|
||||||
"patches": records,
|
|
||||||
"authoritative_branches": [{"name": "agent/example", "oid": "a" * 40, "secret": "drop"}],
|
|
||||||
"orphan_metadata": records,
|
|
||||||
"branches_without_metadata": records,
|
|
||||||
"branches": [f"agent/{index}" for index in range(40)],
|
|
||||||
"repository_available": True,
|
|
||||||
"query_error": None,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
payload, code = result
|
|
||||||
assert code == 200
|
|
||||||
assert payload["list_patches_overlay"] == "bounded-summary-v1"
|
|
||||||
assert payload["count"] <= overlay.PATCH_DEFAULT_LIMIT
|
|
||||||
assert payload["has_more"] is True
|
|
||||||
assert payload["next_cursor"]
|
|
||||||
encoded = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
||||||
assert len(encoded) <= overlay.PATCH_RESPONSE_BUDGET
|
|
||||||
assert all(set(item) <= {
|
|
||||||
"patch_id", "name", "branch", "ref", "status", "risk", "created_at", "updated_at",
|
|
||||||
"commit_oid", "summary", "summary_truncated",
|
|
||||||
} for item in payload["items"])
|
|
||||||
assert "body" not in json.dumps(payload, ensure_ascii=False)
|
|
||||||
assert len(payload["branches"]) <= overlay.PATCH_COMPAT_LIST_LIMIT
|
|
||||||
|
|
||||||
|
|
||||||
def test_overlay_cursor_pagination_and_invalid_limit_are_deterministic():
|
|
||||||
overlay = load_overlay()
|
|
||||||
records = [
|
|
||||||
{"id": f"p-{index:02d}", "created_at": f"2026-08-26T{index:02d}:00:00Z"}
|
|
||||||
for index in range(1, 26)
|
|
||||||
]
|
|
||||||
first = overlay._page(records, {"limit": ["1"]})
|
|
||||||
second = overlay._page(records, {"limit": ["1"], "cursor": [first["next_cursor"]]})
|
|
||||||
assert first["count"] == second["count"] == 1
|
|
||||||
assert first["items"][0]["patch_id"] != second["items"][0]["patch_id"]
|
|
||||||
assert first["has_more"] and second["has_more"]
|
|
||||||
for value in ("0", "101", "not-an-int"):
|
|
||||||
try:
|
|
||||||
overlay._page(records, {"limit": [value]})
|
|
||||||
except ValueError as exc:
|
|
||||||
assert str(exc) == "invalid_limit"
|
|
||||||
else:
|
|
||||||
raise AssertionError("invalid limit accepted")
|
|
||||||
try:
|
|
||||||
overlay._page(records, {"limit": ["1"], "cursor": ["bad"]})
|
|
||||||
except ValueError as exc:
|
|
||||||
assert str(exc) == "invalid_cursor"
|
|
||||||
else:
|
|
||||||
raise AssertionError("invalid cursor accepted")
|
|
||||||
|
|
||||||
|
|
||||||
def test_overlay_delegates_non_patch_routes_unchanged():
|
|
||||||
overlay = load_overlay()
|
|
||||||
handler = overlay.ListPatchesOverlayHandler.__new__(overlay.ListPatchesOverlayHandler)
|
|
||||||
handler.path = "/api/health"
|
|
||||||
result = handler.sendj({"status": "ok"}, 200)
|
|
||||||
assert result == ({"status": "ok"}, 200)
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
|
||||||
sys.path.insert(0, str(ROOT / "active-runtime" / "app"))
|
|
||||||
|
|
||||||
from aios_authority_git_executor import AuthorityGitError, AuthorityGitExecutor
|
|
||||||
from aios_chatgpt_bridge_v2 import OPERATION_REQUIRED_FIELDS, operation_contracts
|
|
||||||
from scoped_lease_store import ScopedLeaseStore, ScopedLeaseError
|
|
||||||
|
|
||||||
|
|
||||||
def _fixture_repo(tmp_path: Path) -> tuple[Path, str]:
|
|
||||||
bare = tmp_path / "repo.git"
|
|
||||||
source = tmp_path / "source"
|
|
||||||
subprocess.run(["git", "init", "--bare", str(bare)], check=True, capture_output=True)
|
|
||||||
subprocess.run(["git", "init", str(source)], check=True, capture_output=True)
|
|
||||||
(source / "README.md").write_text("fixture\n", encoding="utf-8")
|
|
||||||
subprocess.run(["git", "-C", str(source), "add", "README.md"], check=True)
|
|
||||||
subprocess.run(
|
|
||||||
["git", "-C", str(source), "-c", "user.name=test", "-c", "user.email=test@example.invalid", "commit", "-m", "fixture"],
|
|
||||||
check=True, capture_output=True,
|
|
||||||
)
|
|
||||||
oid = subprocess.check_output(["git", "-C", str(source), "rev-parse", "HEAD"], text=True).strip()
|
|
||||||
subprocess.run(["git", "-C", str(source), "push", str(bare), "HEAD:refs/heads/main"], check=True, capture_output=True)
|
|
||||||
return bare, oid
|
|
||||||
|
|
||||||
|
|
||||||
def test_read_repository_file_is_bounded_and_ref_scoped(tmp_path):
|
|
||||||
bare, oid = _fixture_repo(tmp_path)
|
|
||||||
executor = AuthorityGitExecutor(bare, tmp_path / "worktrees")
|
|
||||||
result = executor.read_file("main", "README.md", 1024)
|
|
||||||
assert result["resolved_commit_oid"] == oid
|
|
||||||
assert base64.b64decode(result["content_base64"]) == b"fixture\n"
|
|
||||||
assert result["read_only"] is True
|
|
||||||
with pytest.raises(AuthorityGitError, match="SOURCE_PATH_INVALID"):
|
|
||||||
executor.read_file("main", "../README.md", 1024)
|
|
||||||
with pytest.raises(AuthorityGitError, match="SOURCE_FILE_TOO_LARGE"):
|
|
||||||
executor.read_file("main", "README.md", 1)
|
|
||||||
with pytest.raises(AuthorityGitError, match="SOURCE_REF_SCOPE_DENIED"):
|
|
||||||
executor.read_file("refs/remotes/origin/main", "README.md", 1024)
|
|
||||||
|
|
||||||
|
|
||||||
def test_runtime_and_published_contract_keep_read_and_commit_fields_explicit():
|
|
||||||
contracts = operation_contracts()
|
|
||||||
assert set(OPERATION_REQUIRED_FIELDS["readRepositoryFile"]) == {"repository", "ref", "path", "max_bytes"}
|
|
||||||
assert contracts["commitFilesGoverned"]["required_fields"] == list(OPERATION_REQUIRED_FIELDS["commitFilesGoverned"])
|
|
||||||
assert "files" in contracts["commitFilesGoverned"]["required_fields"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_scoped_lease_is_idempotent_and_rejects_cross_binding(tmp_path):
|
|
||||||
store = ScopedLeaseStore(tmp_path / "leases.sqlite3")
|
|
||||||
request = {
|
|
||||||
"schema": "aios.task-writer-lease.v2",
|
|
||||||
"task_id": "task-min-loop",
|
|
||||||
"repository": "aios/aios",
|
|
||||||
"branch_mode": "CONTINUE_EXISTING",
|
|
||||||
"base_oid": "a" * 40,
|
|
||||||
"expected_branch_oid": "a" * 40,
|
|
||||||
"target_branch": "agent/min-loop",
|
|
||||||
"generation": 1,
|
|
||||||
"lease_seconds": 60,
|
|
||||||
}
|
|
||||||
first, created = store.acquire(request)
|
|
||||||
assert created is True and first["state"] == "ACTIVE"
|
|
||||||
replay, created = store.acquire(request)
|
|
||||||
assert created is False and replay["lease_key"] == first["lease_key"]
|
|
||||||
conflict = dict(request, expected_branch_oid="b" * 40)
|
|
||||||
with pytest.raises(ScopedLeaseError, match="LEASE_BINDING_CONFLICT"):
|
|
||||||
store.acquire(conflict)
|
|
||||||
|
|
||||||
@@ -0,0 +1,524 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
APP = ROOT / "active-runtime" / "app"
|
||||||
|
sys.path.insert(0, str(APP))
|
||||||
|
|
||||||
|
from aios_chatgpt_bridge_v2 import ( # noqa: E402
|
||||||
|
BRIDGE_OPERATION_CONTRACTS,
|
||||||
|
BridgeV2Adapter,
|
||||||
|
)
|
||||||
|
from aios_chatgpt_bridge_v2_server import GovernedRuntime # noqa: E402
|
||||||
|
from aios_governed_task_bootstrap import BootstrapError, GovernedTaskBinding, GovernedTaskBootstrap, _task_key # noqa: E402
|
||||||
|
from aios_online_agent_execution import ( # noqa: E402
|
||||||
|
ExecutionProjectionError,
|
||||||
|
GovernedExecutionBinding,
|
||||||
|
OnlineTaskProjectionClient,
|
||||||
|
project_execution_envelope,
|
||||||
|
project_online_task,
|
||||||
|
validate_execution_envelope,
|
||||||
|
)
|
||||||
|
import aios_online_agent_worker as worker_module # noqa: E402
|
||||||
|
from aios_online_agent_worker import ExecutionEnvelope, WorkerError, WorkerSupervisor, governed_commit, load_task_state # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
BASE = "a642b045775b9154a00ae78543037fbef4fb447b"
|
||||||
|
TASK = "p0-claim-online-agent-auto-execution-20260817"
|
||||||
|
JOB = "job-2031a9e5682a4238f98e3ac7aee8a593"
|
||||||
|
BRANCH = "agent/p0-claim-online-agent-auto-execution-20260817"
|
||||||
|
|
||||||
|
|
||||||
|
def binding() -> GovernedExecutionBinding:
|
||||||
|
return GovernedExecutionBinding.from_mapping(
|
||||||
|
{
|
||||||
|
"task_id": TASK,
|
||||||
|
"job_id": JOB,
|
||||||
|
"repository": "aios/aios",
|
||||||
|
"base_oid": BASE,
|
||||||
|
"expected_branch_oid": BASE,
|
||||||
|
"target_branch": BRANCH,
|
||||||
|
"branch_mode": "CREATE_NEW",
|
||||||
|
"generation": 1,
|
||||||
|
"idempotency_key": "p0-claim-online-agent-auto-execution-v1",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def task_without_envelope() -> dict:
|
||||||
|
return {
|
||||||
|
"task_id": TASK,
|
||||||
|
"goal": "make governed Online Tasks automatically executable",
|
||||||
|
"instructions": "project, claim, heartbeat, and preserve exact lineage",
|
||||||
|
"required_capabilities": ["git.read", "git.test"],
|
||||||
|
"metadata": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_project_online_task_builds_execution_metadata_from_binding() -> None:
|
||||||
|
projected = project_online_task(binding(), task_without_envelope())
|
||||||
|
execution = projected["metadata"]["execution"]
|
||||||
|
assert execution["schema"] == "aios.online-agent.execution.v2"
|
||||||
|
assert execution["job_id"] == JOB
|
||||||
|
assert execution["task_id"] == TASK
|
||||||
|
assert execution["repository"] == "aios/aios"
|
||||||
|
assert execution["base_oid"] == BASE
|
||||||
|
assert execution["expected_branch_oid"] == BASE
|
||||||
|
assert execution["target_branch"] == BRANCH
|
||||||
|
assert execution["generation"] == 1
|
||||||
|
assert projected["metadata"]["execution_projection"]["execution_digest"] == execution["execution_digest"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_job_to_task_execution_projection(tmp_path: Path) -> None:
|
||||||
|
exact = binding()
|
||||||
|
durable = GovernedTaskBinding(
|
||||||
|
task_id=exact.task_id,
|
||||||
|
job_id=exact.job_id,
|
||||||
|
repository=exact.repository,
|
||||||
|
base_oid=exact.base_oid,
|
||||||
|
expected_branch_oid=exact.expected_branch_oid,
|
||||||
|
target_branch=exact.target_branch,
|
||||||
|
branch_mode=exact.branch_mode,
|
||||||
|
generation=exact.generation,
|
||||||
|
idempotency_key=exact.idempotency_key,
|
||||||
|
risk="engineering",
|
||||||
|
description="automatic claim infrastructure",
|
||||||
|
)
|
||||||
|
|
||||||
|
class Bootstrap:
|
||||||
|
def create(self, _request: dict) -> dict:
|
||||||
|
return durable.response()
|
||||||
|
|
||||||
|
def online_task_projection(self, _task_id: str, online: dict, execution: dict | None, **_kwargs: object) -> dict:
|
||||||
|
return project_online_task(exact, online, execution)
|
||||||
|
|
||||||
|
def get_binding(self, _task_id: str) -> GovernedTaskBinding:
|
||||||
|
return durable
|
||||||
|
|
||||||
|
def execution_binding(self, _binding: GovernedTaskBinding) -> GovernedExecutionBinding:
|
||||||
|
return exact
|
||||||
|
|
||||||
|
class Projector:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.calls = 0
|
||||||
|
|
||||||
|
def create(self, projected: dict, *, binding: GovernedExecutionBinding) -> dict:
|
||||||
|
self.calls += 1
|
||||||
|
assert binding == exact
|
||||||
|
return {**projected, "status": "QUEUED"}
|
||||||
|
|
||||||
|
runtime = GovernedRuntime.__new__(GovernedRuntime)
|
||||||
|
runtime.bootstrap = Bootstrap()
|
||||||
|
runtime.bindings = {}
|
||||||
|
runtime.online_task_projector = Projector()
|
||||||
|
runtime.projection_outbox_root = tmp_path / "projection-outbox"
|
||||||
|
runtime.projection_outbox_root.mkdir()
|
||||||
|
response = GovernedRuntime.create_task(
|
||||||
|
runtime,
|
||||||
|
{
|
||||||
|
"task_id": TASK,
|
||||||
|
"repository": "aios/aios",
|
||||||
|
"base_oid": BASE,
|
||||||
|
"target_branch": BRANCH,
|
||||||
|
"risk": "engineering",
|
||||||
|
"description": "automatic claim infrastructure",
|
||||||
|
"idempotency_key": exact.idempotency_key,
|
||||||
|
"online_task": task_without_envelope(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response["online_task_projection"]["status"] == "PROJECTED"
|
||||||
|
assert response["online_task_projection"]["task_status"] == "QUEUED"
|
||||||
|
assert runtime.online_task_projector.calls == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_execution_envelope_schema_validation() -> None:
|
||||||
|
envelope = project_execution_envelope(binding())
|
||||||
|
validate_execution_envelope(envelope, binding=binding())
|
||||||
|
malformed = dict(envelope)
|
||||||
|
malformed["schema"] = "aios.online-agent.execution.v1"
|
||||||
|
with pytest.raises(ExecutionProjectionError, match="EXECUTION_ENVELOPE_SCHEMA_VALIDATION"):
|
||||||
|
validate_execution_envelope(malformed, binding=binding())
|
||||||
|
|
||||||
|
|
||||||
|
def test_projection_response_must_equal_submitted_execution(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
|
submitted = project_online_task(binding(), task_without_envelope(), {"action": "source_capture", "source_signals": ["submitted"]})
|
||||||
|
returned = project_online_task(binding(), task_without_envelope(), {"action": "source_capture", "source_signals": ["different"]})
|
||||||
|
returned.update({"status": "QUEUED"})
|
||||||
|
|
||||||
|
class Response:
|
||||||
|
status = 201
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_args: object) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def read(self) -> bytes:
|
||||||
|
return json.dumps(returned).encode("utf-8")
|
||||||
|
|
||||||
|
monkeypatch.setattr("aios_online_agent_execution.urllib.request.urlopen", lambda *_args, **_kwargs: Response())
|
||||||
|
token = tmp_path / "control.token"
|
||||||
|
token.write_text("x" * 40, encoding="utf-8")
|
||||||
|
with pytest.raises(ExecutionProjectionError, match="ONLINE_TASK_PROJECTION_RESPONSE_BINDING_INVALID"):
|
||||||
|
OnlineTaskProjectionClient("http://127.0.0.1:1", token).create(submitted, binding=binding())
|
||||||
|
|
||||||
|
|
||||||
|
class _ProjectedBridge:
|
||||||
|
def __init__(self, value: dict) -> None:
|
||||||
|
self.value = value
|
||||||
|
self.calls = 0
|
||||||
|
|
||||||
|
def project_execution(self, _task: dict, _execution: dict | None = None) -> tuple[int, dict]:
|
||||||
|
self.calls += 1
|
||||||
|
return 200, self.value
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeAgent:
|
||||||
|
def __init__(self, tasks: list[dict]) -> None:
|
||||||
|
self.tasks = tasks
|
||||||
|
self.claimed: list[str] = []
|
||||||
|
|
||||||
|
def poll(self) -> list[dict]:
|
||||||
|
return self.tasks
|
||||||
|
|
||||||
|
def claim(self, task_id: str) -> tuple[int, dict]:
|
||||||
|
self.claimed.append(task_id)
|
||||||
|
return 200, {"task_id": task_id, "lease_token": "x" * 32, "attempt": 1, "revision": 2}
|
||||||
|
|
||||||
|
|
||||||
|
def test_task_visible_to_worker_claimable(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
|
monkeypatch.setattr(worker_module, "STATE_ROOT", tmp_path)
|
||||||
|
supervisor = WorkerSupervisor("runtime-token-not-secret")
|
||||||
|
supervisor.bridge = _ProjectedBridge(project_execution_envelope(binding()))
|
||||||
|
envelope, source = supervisor.resolve_envelope(task_without_envelope())
|
||||||
|
assert source == "recovered_projection"
|
||||||
|
assert envelope.task_id == TASK
|
||||||
|
assert envelope.job_id == JOB
|
||||||
|
assert envelope.action == "source_capture"
|
||||||
|
|
||||||
|
|
||||||
|
def test_task_claim_after_projection(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
|
monkeypatch.setattr(worker_module, "STATE_ROOT", tmp_path)
|
||||||
|
supervisor = WorkerSupervisor("runtime-token-not-secret")
|
||||||
|
fake_agent = _FakeAgent([task_without_envelope()])
|
||||||
|
supervisor.agent = fake_agent
|
||||||
|
supervisor.bridge = _ProjectedBridge(project_execution_envelope(binding()))
|
||||||
|
processed: list[tuple[str, str]] = []
|
||||||
|
supervisor.process_claim = lambda task, claim, envelope: processed.append((task["task_id"], envelope.execution_digest if hasattr(envelope, "execution_digest") else envelope.digest)) # type: ignore[method-assign]
|
||||||
|
assert supervisor.once() == 0
|
||||||
|
assert fake_agent.claimed == [TASK]
|
||||||
|
assert processed and processed[0][0] == TASK
|
||||||
|
|
||||||
|
|
||||||
|
def test_claim_heartbeat_source_capture_and_result_lifecycle(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
|
execution = project_execution_envelope(binding(), {"action": "source_capture", "source_signals": ["agent"]})
|
||||||
|
task = task_without_envelope()
|
||||||
|
task["metadata"] = {"execution": execution}
|
||||||
|
|
||||||
|
class Agent:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.heartbeats: list[str] = []
|
||||||
|
self.results: list[dict] = []
|
||||||
|
|
||||||
|
def heartbeat(self, _task_id: str, _lease_token: str, stage: str) -> tuple[int, dict]:
|
||||||
|
self.heartbeats.append(stage)
|
||||||
|
return 200, {}
|
||||||
|
|
||||||
|
def get(self, _task_id: str) -> tuple[int, dict]:
|
||||||
|
return 200, task
|
||||||
|
|
||||||
|
def result(self, _task_id: str, _lease_token: str, _key: str, result: dict) -> tuple[int, dict]:
|
||||||
|
self.results.append(result)
|
||||||
|
return 200, {}
|
||||||
|
|
||||||
|
worktree = tmp_path / "worktree"
|
||||||
|
source = worktree / "active-runtime" / "app" / "agent_auto_fixture.py"
|
||||||
|
source.parent.mkdir(parents=True)
|
||||||
|
source.write_text("VALUE = 1\n", encoding="utf-8")
|
||||||
|
monkeypatch.setattr(worker_module, "STATE_ROOT", tmp_path / "state")
|
||||||
|
monkeypatch.setattr(worker_module, "new_run_root", lambda: tmp_path / "run")
|
||||||
|
monkeypatch.setattr(worker_module, "prepare_worktree", lambda *_args, **_kwargs: worktree)
|
||||||
|
monkeypatch.setattr(worker_module, "cleanup_run_root", lambda *_args, **_kwargs: None)
|
||||||
|
agent = Agent()
|
||||||
|
supervisor = WorkerSupervisor("runtime-token-not-secret")
|
||||||
|
supervisor.agent = agent
|
||||||
|
supervisor.bridge = _ProjectedBridge(execution)
|
||||||
|
envelope, _ = supervisor.resolve_envelope(task)
|
||||||
|
supervisor.process_claim(task, {"task_id": TASK, "lease_token": "l" * 32, "attempt": 2, "revision": 8}, envelope)
|
||||||
|
assert agent.heartbeats and agent.heartbeats[0] == "claimed"
|
||||||
|
assert len(agent.results) == 1
|
||||||
|
assert agent.results[0]["status"] == "SUCCEEDED"
|
||||||
|
assert agent.results[0]["classification"] == "NEEDS_REWORK_SOURCE_CAPTURE"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dynamic_task_binding_no_hardcoded_task_id() -> None:
|
||||||
|
worker = (APP / "aios_online_agent_worker.py").read_text(encoding="utf-8")
|
||||||
|
assert TASK not in worker
|
||||||
|
assert BASE not in worker
|
||||||
|
for name in ("aios-online-agent-worker.service", "aios-online-engineering-consumer.service"):
|
||||||
|
unit = (ROOT / "deploy" / "systemd" / name).read_text(encoding="utf-8")
|
||||||
|
assert TASK not in unit
|
||||||
|
assert JOB not in unit
|
||||||
|
assert BASE not in unit
|
||||||
|
assert BRANCH not in unit
|
||||||
|
assert "AIOS_ENGINEERING_PLAN_FILE" not in unit
|
||||||
|
assert "aios_online_agent_worker.py" in unit
|
||||||
|
projection = (ROOT / "deploy" / "systemd" / "aios-chatgpt-bridge-v2-agent-task-projection.conf").read_text(encoding="utf-8")
|
||||||
|
assert "AIOS_ONLINE_TASK_PROJECTION_ENABLED=1" in projection
|
||||||
|
assert "LoadCredential=agent_task_control_token:" in projection
|
||||||
|
assert "LoadCredentialEncrypted=" not in projection
|
||||||
|
assert "Bearer " not in projection
|
||||||
|
|
||||||
|
|
||||||
|
def test_durable_task_key_has_collision_resistant_exact_identity() -> None:
|
||||||
|
assert _task_key("task:a") != _task_key("task-a")
|
||||||
|
assert _task_key("x" * 140 + "a") != _task_key("x" * 140 + "b")
|
||||||
|
|
||||||
|
|
||||||
|
def test_existing_binding_rejects_idempotency_head_and_generation_drift(tmp_path: Path) -> None:
|
||||||
|
class Authority:
|
||||||
|
@staticmethod
|
||||||
|
def commit_exists(_oid: str) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def read_ref(_ref: str) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
bootstrap = GovernedTaskBootstrap(Authority(), tmp_path, remote_name="origin", token_path=tmp_path / "token", ssh_key_path=tmp_path / "key")
|
||||||
|
request = {
|
||||||
|
"task_id": TASK,
|
||||||
|
"repository": "aios/aios",
|
||||||
|
"base_oid": BASE,
|
||||||
|
"target_branch": BRANCH,
|
||||||
|
"risk": "engineering",
|
||||||
|
"description": "automatic claim infrastructure",
|
||||||
|
"idempotency_key": "p0-claim-online-agent-auto-execution-v1",
|
||||||
|
"expected_branch_oid": BASE,
|
||||||
|
"generation": 1,
|
||||||
|
}
|
||||||
|
bootstrap.create(request)
|
||||||
|
for drift in (
|
||||||
|
{"idempotency_key": "p0-claim-online-agent-auto-execution-v2"},
|
||||||
|
{"expected_branch_oid": "b" * 40},
|
||||||
|
{"generation": 2},
|
||||||
|
):
|
||||||
|
with pytest.raises(BootstrapError, match="TASK_BINDING_CONFLICT"):
|
||||||
|
bootstrap.create({**request, **drift})
|
||||||
|
|
||||||
|
|
||||||
|
def test_existing_branch_without_durable_parent_ownership_is_denied(tmp_path: Path) -> None:
|
||||||
|
class Authority:
|
||||||
|
@staticmethod
|
||||||
|
def commit_exists(_oid: str) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def read_ref(_ref: str) -> str:
|
||||||
|
return BASE
|
||||||
|
|
||||||
|
bootstrap = GovernedTaskBootstrap(Authority(), tmp_path, remote_name="origin", token_path=tmp_path / "token", ssh_key_path=tmp_path / "key")
|
||||||
|
with pytest.raises(BootstrapError, match="EXISTING_BRANCH_OWNERSHIP_REQUIRED"):
|
||||||
|
bootstrap.create({
|
||||||
|
"task_id": TASK,
|
||||||
|
"repository": "aios/aios",
|
||||||
|
"base_oid": BASE,
|
||||||
|
"target_branch": BRANCH,
|
||||||
|
"risk": "engineering",
|
||||||
|
"description": "automatic claim infrastructure",
|
||||||
|
"idempotency_key": "p0-claim-online-agent-auto-execution-v1",
|
||||||
|
"expected_branch_oid": BASE,
|
||||||
|
"generation": 2,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("field", "value", "error"),
|
||||||
|
[
|
||||||
|
("repository", "other/repo", "WRONG_REPOSITORY_ENVELOPE_DENY"),
|
||||||
|
("target_branch", "agent/wrong-branch", "WRONG_BRANCH_ENVELOPE_DENY"),
|
||||||
|
("generation", 2, "WRONG_GENERATION_ENVELOPE_DENY"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_wrong_lineage_envelope_deny(field: str, value: object, error: str) -> None:
|
||||||
|
envelope = project_execution_envelope(binding())
|
||||||
|
envelope[field] = value
|
||||||
|
with pytest.raises(ExecutionProjectionError, match=error):
|
||||||
|
validate_execution_envelope(envelope, binding=binding())
|
||||||
|
|
||||||
|
|
||||||
|
def test_capability_mismatch_deny() -> None:
|
||||||
|
envelope = project_execution_envelope(
|
||||||
|
binding(),
|
||||||
|
{"action": "source_capture", "capability_scope": ["git.read", "governed.write.file"]},
|
||||||
|
)
|
||||||
|
with pytest.raises(ExecutionProjectionError, match="CAPABILITY_MISMATCH_DENY"):
|
||||||
|
validate_execution_envelope(envelope, binding=binding(), worker_capabilities=["git.read"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_governance_lineage_override_deny() -> None:
|
||||||
|
envelope = project_execution_envelope(binding())
|
||||||
|
envelope["governance"] = {**envelope["governance"], "job_id": "job-wrong-lineage"}
|
||||||
|
with pytest.raises(ExecutionProjectionError, match="JOB_BINDING_CONFLICT"):
|
||||||
|
validate_execution_envelope(envelope, binding=binding())
|
||||||
|
|
||||||
|
|
||||||
|
def test_malformed_persisted_envelope_does_not_fallback() -> None:
|
||||||
|
task = task_without_envelope()
|
||||||
|
task["metadata"] = {"execution": {"schema": "broken"}}
|
||||||
|
bridge = _ProjectedBridge(project_execution_envelope(binding()))
|
||||||
|
supervisor = WorkerSupervisor("runtime-token-not-secret")
|
||||||
|
supervisor.bridge = bridge
|
||||||
|
with pytest.raises(WorkerError, match="EXECUTION_ENVELOPE_SCHEMA_VALIDATION"):
|
||||||
|
supervisor.resolve_envelope(task)
|
||||||
|
assert bridge.calls == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_self_consistent_persisted_wrong_lineage_is_denied_by_authority() -> None:
|
||||||
|
task = task_without_envelope()
|
||||||
|
wrong = binding()
|
||||||
|
wrong = GovernedExecutionBinding.from_mapping({**wrong.public(), "job_id": "job-00000000000000000000000000000000"})
|
||||||
|
task["metadata"] = {"execution": project_execution_envelope(wrong)}
|
||||||
|
supervisor = WorkerSupervisor("runtime-token-not-secret")
|
||||||
|
supervisor.bridge = _ProjectedBridge(project_execution_envelope(binding()))
|
||||||
|
with pytest.raises(WorkerError, match="TASK_PERSISTED_BINDING_CONFLICT"):
|
||||||
|
supervisor.resolve_envelope(task)
|
||||||
|
|
||||||
|
|
||||||
|
def test_projection_control_missing_denies_before_governed_side_effect() -> None:
|
||||||
|
class Bootstrap:
|
||||||
|
called = False
|
||||||
|
|
||||||
|
def create(self, _request: dict) -> dict:
|
||||||
|
self.called = True
|
||||||
|
raise AssertionError("must not create governed state")
|
||||||
|
|
||||||
|
runtime = GovernedRuntime.__new__(GovernedRuntime)
|
||||||
|
runtime.bootstrap = Bootstrap()
|
||||||
|
runtime.online_task_projector = None
|
||||||
|
with pytest.raises(RuntimeError, match="ONLINE_TASK_PROJECTION_CONTROL_UNAVAILABLE"):
|
||||||
|
GovernedRuntime.create_task(runtime, {"task_id": TASK, "require_online_task_projection": True})
|
||||||
|
assert runtime.bootstrap.called is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_source_capture_projection_cannot_write() -> None:
|
||||||
|
envelope = project_execution_envelope(binding())
|
||||||
|
assert envelope["action"] == "source_capture"
|
||||||
|
assert envelope["change_set"] == []
|
||||||
|
assert envelope["verification"] == []
|
||||||
|
assert envelope["governance"]["writer_lease"] is False
|
||||||
|
assert all(envelope["governance"][field] is False for field in ("main_write", "production_write", "tag_write", "baseline_write", "force_push", "protected_ref_bypass"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_source_capture_state_allows_next_attempt_deterministic_digest(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
|
monkeypatch.setattr(worker_module, "STATE_ROOT", tmp_path)
|
||||||
|
state_path = tmp_path / "tasks" / f"{TASK}.json"
|
||||||
|
state_path.parent.mkdir(parents=True)
|
||||||
|
state_path.write_text(json.dumps({"task_id": TASK, "execution_digest": "a" * 64, "phase": "source_capture_done"}), encoding="utf-8")
|
||||||
|
assert load_task_state(TASK, "b" * 64) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_bridge_exposes_controlled_projection_operation() -> None:
|
||||||
|
seen: list[dict] = []
|
||||||
|
adapter = BridgeV2Adapter(
|
||||||
|
runtime_status={},
|
||||||
|
handlers={"projectOnlineAgentExecution": lambda request: seen.append(dict(request)) or project_execution_envelope(binding())},
|
||||||
|
)
|
||||||
|
response = adapter.projectOnlineAgentExecution({"task_id": TASK})
|
||||||
|
assert response["job_id"] == JOB
|
||||||
|
assert seen == [{"task_id": TASK}]
|
||||||
|
assert BRIDGE_OPERATION_CONTRACTS["projectOnlineAgentExecution"]["path"] == "/v2/execution-envelope"
|
||||||
|
|
||||||
|
|
||||||
|
def test_execution_digest_is_canonical() -> None:
|
||||||
|
envelope = project_execution_envelope(binding())
|
||||||
|
material = {key: value for key, value in envelope.items() if key != "execution_digest"}
|
||||||
|
expected = hashlib.sha256(json.dumps(material, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
||||||
|
assert envelope["execution_digest"] == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_governed_commit_uses_existing_exact_job_without_recreating(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
|
execution = project_execution_envelope(
|
||||||
|
binding(),
|
||||||
|
{
|
||||||
|
"action": "deterministic",
|
||||||
|
"change_set": [{"operation": "write_file", "path": "result.txt", "content": "ok\n"}],
|
||||||
|
"governance": {"allowed_files": ["result.txt"]},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
envelope = ExecutionEnvelope.from_task({"task_id": TASK, "metadata": {"execution": execution}})
|
||||||
|
(tmp_path / "result.txt").write_text("ok\n", encoding="utf-8")
|
||||||
|
commit_oid = "b" * 40
|
||||||
|
|
||||||
|
class Bridge:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.routes: list[str] = []
|
||||||
|
|
||||||
|
def call(self, route: str, body: dict) -> tuple[int, dict]:
|
||||||
|
self.routes.append(route)
|
||||||
|
if route == "/v2/job":
|
||||||
|
assert body == {"job_id": JOB}
|
||||||
|
else:
|
||||||
|
assert body["task_id"] == TASK
|
||||||
|
assert body["job_id"] == JOB
|
||||||
|
if route == "/v2/writer-lease":
|
||||||
|
return 200, {
|
||||||
|
"task_id": TASK,
|
||||||
|
"job_id": JOB,
|
||||||
|
"repository": "aios/aios",
|
||||||
|
"base_oid": BASE,
|
||||||
|
"expected_branch_oid": BASE,
|
||||||
|
"branch_mode": "CREATE_NEW",
|
||||||
|
"generation": 1,
|
||||||
|
"target_branch": BRANCH,
|
||||||
|
}
|
||||||
|
if route == "/v2/commit-files":
|
||||||
|
return 200, {
|
||||||
|
"job_id": JOB,
|
||||||
|
"target_branch": BRANCH,
|
||||||
|
"generation": 1,
|
||||||
|
"branch_mode": "CONTINUE_EXISTING",
|
||||||
|
"commit_oid": commit_oid,
|
||||||
|
"commit_visible": True,
|
||||||
|
"receipt": {
|
||||||
|
"remote_verified": True,
|
||||||
|
"authority_oid": commit_oid,
|
||||||
|
"api_branch_oid": commit_oid,
|
||||||
|
"git_remote_oid": commit_oid,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if route == "/v2/job":
|
||||||
|
return 200, {
|
||||||
|
"state": "COMPLETED",
|
||||||
|
"receipt": {
|
||||||
|
"remote_verified": True,
|
||||||
|
"authority_oid": commit_oid,
|
||||||
|
"api_branch_oid": commit_oid,
|
||||||
|
"git_remote_oid": commit_oid,
|
||||||
|
},
|
||||||
|
"branch_leases": [{
|
||||||
|
"job_id": JOB,
|
||||||
|
"branch": BRANCH,
|
||||||
|
"state": "ACTIVE",
|
||||||
|
"generation": 1,
|
||||||
|
"current_head_oid": commit_oid,
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
raise AssertionError(route)
|
||||||
|
|
||||||
|
bridge = Bridge()
|
||||||
|
monkeypatch.setattr(worker_module.subprocess, "run", lambda *args, **kwargs: SimpleNamespace(returncode=0, stdout=commit_oid + "\n", stderr=""))
|
||||||
|
heartbeat = SimpleNamespace(ensure=lambda: None)
|
||||||
|
result = governed_commit(envelope, tmp_path, bridge, heartbeat)
|
||||||
|
assert bridge.routes == ["/v2/writer-lease", "/v2/commit-files", "/v2/job"]
|
||||||
|
assert result["job_id"] == JOB
|
||||||
|
assert result["commit_oid"] == commit_oid
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
ROOT=Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0,str(ROOT/"active-runtime/app"))
|
||||||
|
|
||||||
|
from aios_online_agent_execution import GovernedExecutionBinding
|
||||||
|
from aios_online_agent_plan_store import ExecutionPlanStore, ExecutionPlanError
|
||||||
|
|
||||||
|
BASE="a642b045775b9154a00ae78543037fbef4fb447b"
|
||||||
|
HEAD="71523aef6e48b1e2fa516c456ca189b7a039104b"
|
||||||
|
|
||||||
|
def binding(head=HEAD):
|
||||||
|
return GovernedExecutionBinding.from_mapping({
|
||||||
|
"task_id":"task-plan-a","job_id":"job-plan-a","repository":"aios/aios","base_oid":BASE,
|
||||||
|
"expected_branch_oid":head,"target_branch":"agent/task-plan-a","branch_mode":"CONTINUE_EXISTING",
|
||||||
|
"generation":1,"idempotency_key":"governed-plan-a",
|
||||||
|
})
|
||||||
|
|
||||||
|
def request():
|
||||||
|
return {
|
||||||
|
"schema":"aios.online-agent.plan.v1","task_id":"task-plan-a","job_id":"job-plan-a",
|
||||||
|
"source_head_oid":HEAD,"generation":1,"idempotency_key":"plan-task-a-v1",
|
||||||
|
"execution":{
|
||||||
|
"action":"deterministic",
|
||||||
|
"change_set":[{"path":"x.py","operation":"write_file","content":"x=1\n"}],
|
||||||
|
"verification":[{"label":"compile","argv":["/usr/bin/python3","-m","py_compile","x.py"],"cwd":".","timeout_seconds":30}],
|
||||||
|
"capability_scope":["git.read","git.test"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_plan_register_and_resolve(tmp_path):
|
||||||
|
store=ExecutionPlanStore(tmp_path)
|
||||||
|
created=store.register(binding(),request())
|
||||||
|
assert created["idempotent"] is False
|
||||||
|
resolved=store.resolve(binding())
|
||||||
|
assert resolved["plan_digest"]==created["plan_digest"]
|
||||||
|
assert resolved["execution"]["action"]=="deterministic"
|
||||||
|
|
||||||
|
def test_plan_is_idempotent(tmp_path):
|
||||||
|
store=ExecutionPlanStore(tmp_path); req=request()
|
||||||
|
first=store.register(binding(),req); second=store.register(binding(),req)
|
||||||
|
assert second["idempotent"] is True
|
||||||
|
assert first["plan_digest"]==second["plan_digest"]
|
||||||
|
|
||||||
|
def test_plan_source_head_drift_denied(tmp_path):
|
||||||
|
store=ExecutionPlanStore(tmp_path); store.register(binding(),request())
|
||||||
|
with pytest.raises(ExecutionPlanError,match="SOURCE_HEAD_DRIFT"):
|
||||||
|
store.resolve(binding("b"*40))
|
||||||
|
|
||||||
|
def test_plan_replacement_requires_cas(tmp_path):
|
||||||
|
store=ExecutionPlanStore(tmp_path); first=store.register(binding(),request())
|
||||||
|
changed=request(); changed["idempotency_key"]="plan-task-a-v2"
|
||||||
|
changed["execution"]["change_set"][0]["content"]="x=2\n"
|
||||||
|
with pytest.raises(ExecutionPlanError,match="REPLACEMENT_CAS_MISMATCH"):
|
||||||
|
store.register(binding(),changed)
|
||||||
|
changed["expected_previous_plan_digest"]=first["plan_digest"]
|
||||||
|
second=store.register(binding(),changed)
|
||||||
|
assert second["plan_digest"]!=first["plan_digest"]
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
ROOT=Path(__file__).resolve().parents[1]
|
||||||
|
APP=ROOT/"active-runtime/app"
|
||||||
|
sys.path.insert(0,str(APP))
|
||||||
|
|
||||||
|
from aios_online_agent_execution import GovernedExecutionBinding, project_execution_envelope
|
||||||
|
from aios_governed_task_bootstrap import GovernedTaskBinding, GovernedTaskBootstrap
|
||||||
|
from aios_gitops_job_controller import GitOpsJobController, JobState, JobStore
|
||||||
|
from aios_continuation_executor import ContinuationExecutionError, DetachedGitWorkspace
|
||||||
|
from aios_online_agent_worker import (
|
||||||
|
BridgeClient,
|
||||||
|
IsolatedDeterministicExecutor,
|
||||||
|
load_task_state,
|
||||||
|
_claim_order,
|
||||||
|
_isolated_executor_wait_seconds,
|
||||||
|
_verify_converged_job,
|
||||||
|
WorkerError,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
BASE="a642b045775b9154a00ae78543037fbef4fb447b"
|
||||||
|
HEAD="71523aef6e48b1e2fa516c456ca189b7a039104b"
|
||||||
|
TASK="p0-claim-online-agent-auto-execution-20260817"
|
||||||
|
JOB="job-2031a9e5682a4238f98e3ac7aee8a593"
|
||||||
|
BRANCH="agent/p0-claim-online-agent-auto-execution-20260817"
|
||||||
|
|
||||||
|
|
||||||
|
def test_source_signals_are_bounded_by_projection():
|
||||||
|
binding=GovernedExecutionBinding.from_mapping({
|
||||||
|
"task_id":TASK,"job_id":JOB,"repository":"aios/aios","base_oid":BASE,
|
||||||
|
"expected_branch_oid":BASE,"target_branch":BRANCH,"branch_mode":"CREATE_NEW",
|
||||||
|
"generation":1,"idempotency_key":"p0-claim-online-agent-auto-execution-a642b04-v1",
|
||||||
|
})
|
||||||
|
execution=project_execution_envelope(binding,{
|
||||||
|
"action":"source_capture",
|
||||||
|
"source_signals":["x"*5000,"x"*5000,"trace credential tool "*100],
|
||||||
|
})
|
||||||
|
assert len(execution["source_signals"]) <= 16
|
||||||
|
assert all(0 < len(item) <= 512 for item in execution["source_signals"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_same_job_create_new_binding_normalizes_to_current_head():
|
||||||
|
binding=GovernedTaskBinding(
|
||||||
|
task_id=TASK,job_id=JOB,repository="aios/aios",base_oid=BASE,
|
||||||
|
expected_branch_oid=BASE,target_branch=BRANCH,branch_mode="CREATE_NEW",
|
||||||
|
generation=1,idempotency_key="p0-claim-online-agent-auto-execution-a642b04-v1",
|
||||||
|
risk="engineering",description="automatic claim",
|
||||||
|
)
|
||||||
|
bootstrap=GovernedTaskBootstrap.__new__(GovernedTaskBootstrap)
|
||||||
|
bootstrap.authority=SimpleNamespace(read_ref=lambda ref: HEAD)
|
||||||
|
class Connection:
|
||||||
|
def __enter__(self): return self
|
||||||
|
def __exit__(self,*a): return False
|
||||||
|
def execute(self,*a,**k):
|
||||||
|
return SimpleNamespace(fetchall=lambda:[{
|
||||||
|
"current_head_oid":HEAD,"generation":1,"state":"ACTIVE","lineage_anchor_oid":BASE
|
||||||
|
}])
|
||||||
|
bootstrap._store=lambda _task:(SimpleNamespace(_connect=lambda:Connection()),None)
|
||||||
|
normalized=bootstrap.execution_binding(binding)
|
||||||
|
assert normalized.base_oid == BASE
|
||||||
|
assert normalized.expected_branch_oid == HEAD
|
||||||
|
assert normalized.branch_mode == "CONTINUE_EXISTING"
|
||||||
|
assert normalized.generation == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_recovery_receipt_requires_exact_oid_and_active_lease():
|
||||||
|
envelope=SimpleNamespace(job_id=JOB,target_branch=BRANCH,generation=1)
|
||||||
|
response={
|
||||||
|
"state":"COMPLETED",
|
||||||
|
"receipt":{"remote_verified":True,"authority_oid":HEAD,"api_branch_oid":HEAD,"git_remote_oid":HEAD},
|
||||||
|
"branch_leases":[{
|
||||||
|
"job_id":JOB,"branch":BRANCH,"state":"ACTIVE","generation":1,"current_head_oid":HEAD
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
_verify_converged_job(response,envelope,HEAD)
|
||||||
|
bad={**response,"receipt":{**response["receipt"],"git_remote_oid":BASE}}
|
||||||
|
with pytest.raises(WorkerError,match="GOVERNED_RECEIPT_OID_MISMATCH"):
|
||||||
|
_verify_converged_job(bad,envelope,HEAD)
|
||||||
|
|
||||||
|
|
||||||
|
def test_executor_unit_is_uncredentialed():
|
||||||
|
unit=(ROOT/"deploy/systemd/aios-online-agent-isolated-executor.service").read_text()
|
||||||
|
assert "PrivateNetwork=true" in unit
|
||||||
|
assert "RestrictAddressFamilies=AF_UNIX AF_INET" in unit
|
||||||
|
assert "IPAddressDeny=any" in unit
|
||||||
|
assert "IPAddressAllow=localhost" in unit
|
||||||
|
assert "InaccessiblePaths=/run/credentials" in unit
|
||||||
|
assert "LoadCredential" not in unit
|
||||||
|
assert "SupplementaryGroups=gitops" not in unit
|
||||||
|
|
||||||
|
|
||||||
|
def test_isolated_executor_wait_budget_includes_safety_margin():
|
||||||
|
assert _isolated_executor_wait_seconds(({"timeout_seconds":1200},)) == 1230
|
||||||
|
|
||||||
|
|
||||||
|
def test_prepared_deterministic_plan_precedes_source_capture_at_same_priority():
|
||||||
|
deterministic=SimpleNamespace(action="deterministic",task_id="task-deterministic")
|
||||||
|
source_capture=SimpleNamespace(action="source_capture",task_id="task-source-capture")
|
||||||
|
task={"priority":100,"created_at":"2026-08-17T00:00:00Z"}
|
||||||
|
ordered=sorted(((task,source_capture),(task,deterministic)),key=_claim_order)
|
||||||
|
assert ordered[0][1] is deterministic
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_registry_gets_precedence_when_task_has_no_execution(monkeypatch):
|
||||||
|
client=BridgeClient("agent-token-placeholder")
|
||||||
|
seen=[]
|
||||||
|
monkeypatch.setattr(client,"call",lambda route,body:(seen.append((route,body)) or (200,{})))
|
||||||
|
task={"task_id":TASK,"required_capabilities":["git.read"]}
|
||||||
|
client.project_execution(task)
|
||||||
|
assert seen == [("/v2/execution-envelope",{"task_id":TASK,"required_capabilities":["git.read"]})]
|
||||||
|
client.project_execution(task,{"action":"source_capture","source_signals":["trace"]})
|
||||||
|
assert seen[-1][1]["execution"]["action"] == "source_capture"
|
||||||
|
|
||||||
|
|
||||||
|
def test_executor_exchange_files_are_group_readable_and_directories_setgid(tmp_path):
|
||||||
|
request=tmp_path/"request.json"
|
||||||
|
IsolatedDeterministicExecutor._atomic(request,b"{}")
|
||||||
|
assert request.stat().st_mode & 0o777 == 0o640
|
||||||
|
tmpfiles=(ROOT/"deploy/tmpfiles.d/aios-online-agent-executor.conf").read_text()
|
||||||
|
assert tmpfiles.count(" 2770 ") == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_precommit_state_recovery_requires_a_later_official_attempt(monkeypatch,tmp_path):
|
||||||
|
import aios_online_agent_worker as worker_module
|
||||||
|
monkeypatch.setattr(worker_module,"STATE_ROOT",tmp_path)
|
||||||
|
state_path=tmp_path/"tasks"/f"{TASK}.json"
|
||||||
|
state_path.parent.mkdir(parents=True)
|
||||||
|
state_path.write_text(json.dumps({"task_id":TASK,"execution_digest":"a"*64,"phase":"workspace_prepared"}))
|
||||||
|
assert load_task_state(TASK,"b"*64) is None
|
||||||
|
state_path.write_text(json.dumps({"task_id":TASK,"execution_digest":"a"*64,"phase":"verification_passed","attempt":16}))
|
||||||
|
with pytest.raises(WorkerError,match="TASK_PAYLOAD_DRIFT"):
|
||||||
|
load_task_state(TASK,"b"*64,attempt=16)
|
||||||
|
assert load_task_state(TASK,"b"*64,attempt=17) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_reconciliation_state_recovery_requires_later_attempt_and_same_job(monkeypatch,tmp_path):
|
||||||
|
import aios_online_agent_worker as worker_module
|
||||||
|
monkeypatch.setattr(worker_module,"STATE_ROOT",tmp_path)
|
||||||
|
state_path=tmp_path/"tasks"/f"{TASK}.json"
|
||||||
|
state_path.parent.mkdir(parents=True)
|
||||||
|
state_path.write_text(json.dumps({
|
||||||
|
"task_id":TASK,
|
||||||
|
"execution_digest":"a"*64,
|
||||||
|
"phase":"reconciliation_required",
|
||||||
|
"attempt":18,
|
||||||
|
"job_id":JOB,
|
||||||
|
"details":{"api_status":503,"error":"WORKTREE_DIRTY_SCOPE_MISMATCH"},
|
||||||
|
}))
|
||||||
|
with pytest.raises(WorkerError,match="TASK_PAYLOAD_DRIFT"):
|
||||||
|
load_task_state(TASK,"b"*64,attempt=18,continuation_job_id=JOB,continuation_head_oid=HEAD)
|
||||||
|
assert load_task_state(TASK,"b"*64,attempt=19,continuation_job_id=JOB,continuation_head_oid=HEAD) is None
|
||||||
|
with pytest.raises(WorkerError,match="TASK_PAYLOAD_DRIFT"):
|
||||||
|
load_task_state(TASK,"b"*64,attempt=19,continuation_job_id="job-other",continuation_head_oid=HEAD)
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_dirty_scope_reconciliation_state_is_exactly_bounded(monkeypatch,tmp_path):
|
||||||
|
import aios_online_agent_worker as worker_module
|
||||||
|
monkeypatch.setattr(worker_module,"STATE_ROOT",tmp_path)
|
||||||
|
state_path=tmp_path/"tasks"/f"{TASK}.json"
|
||||||
|
state_path.parent.mkdir(parents=True)
|
||||||
|
state_path.write_text(json.dumps({
|
||||||
|
"task_id":TASK,
|
||||||
|
"execution_digest":"a"*64,
|
||||||
|
"phase":"reconciliation_required",
|
||||||
|
"job_id":JOB,
|
||||||
|
"details":{"api_status":503,"error":"WORKTREE_DIRTY_SCOPE_MISMATCH"},
|
||||||
|
}))
|
||||||
|
assert load_task_state(TASK,"b"*64,attempt=19,continuation_job_id=JOB,continuation_head_oid=HEAD) is None
|
||||||
|
with pytest.raises(WorkerError,match="TASK_PAYLOAD_DRIFT"):
|
||||||
|
load_task_state(TASK,"b"*64,attempt=19,continuation_job_id=JOB,continuation_head_oid="not-an-oid")
|
||||||
|
|
||||||
|
|
||||||
|
def test_completed_same_job_begins_one_idempotent_continuation_cycle(tmp_path):
|
||||||
|
controller=GitOpsJobController(JobStore(tmp_path/"jobs",payload_root=tmp_path/"payloads"))
|
||||||
|
submitted=controller.submit({
|
||||||
|
"idempotency_key":"same-job-continuation-test",
|
||||||
|
"campaign_id":TASK,
|
||||||
|
"task_id":TASK,
|
||||||
|
"repository":"aios/aios",
|
||||||
|
"operation":"GOVERNED_TASK_CREATE",
|
||||||
|
"branch":BRANCH,
|
||||||
|
"expected_branch_oid":HEAD,
|
||||||
|
"payload_digest":"a"*64,
|
||||||
|
})
|
||||||
|
job_id=submitted.job_id
|
||||||
|
controller.advance(job_id,JobState.AUTHORIZED,side_effect_state="AUTHORIZED",expected_revision=0)
|
||||||
|
controller.lease_job(
|
||||||
|
job_id,
|
||||||
|
lease_id="lease-"+job_id,
|
||||||
|
lineage_anchor_oid=BASE,
|
||||||
|
current_head_oid=HEAD,
|
||||||
|
generation=1,
|
||||||
|
lease_expires_at="2099-01-01T00:00:00+00:00",
|
||||||
|
expected_revision=1,
|
||||||
|
)
|
||||||
|
controller.advance(job_id,JobState.WORKTREE_READY,side_effect_state="WORKTREE_READY",expected_revision=2)
|
||||||
|
controller.advance(job_id,JobState.EXECUTING,side_effect_state="IN_PROGRESS",expected_revision=3)
|
||||||
|
controller.advance(job_id,JobState.LOCAL_VERIFIED,side_effect_state="LOCAL_VERIFIED",receipt={"authority_oid":HEAD},expected_revision=4)
|
||||||
|
controller.advance(job_id,JobState.REMOTE_SYNCING,side_effect_state="IN_PROGRESS",expected_revision=5)
|
||||||
|
receipt={
|
||||||
|
"remote_verified":True,
|
||||||
|
"commit_visible":True,
|
||||||
|
"authority_oid":HEAD,
|
||||||
|
"api_branch_oid":HEAD,
|
||||||
|
"git_remote_oid":HEAD,
|
||||||
|
}
|
||||||
|
controller.advance(job_id,JobState.REMOTE_VERIFIED,side_effect_state="REMOTE_VERIFIED",receipt=receipt,expected_revision=6)
|
||||||
|
controller.advance(job_id,JobState.COMPLETED,side_effect_state="REMOTE_VERIFIED",receipt=receipt,expected_revision=7)
|
||||||
|
first=controller.begin_continuation_cycle(
|
||||||
|
job_id,
|
||||||
|
idempotency_key="same-job-continuation-commit-v1",
|
||||||
|
expected_head_oid=HEAD,
|
||||||
|
generation=1,
|
||||||
|
expected_revision=8,
|
||||||
|
)
|
||||||
|
assert first == {"completed":False,"commit_oid":None,"idempotent":False}
|
||||||
|
assert controller.store.read(job_id)["state"] == "LEASED"
|
||||||
|
replay=controller.begin_continuation_cycle(
|
||||||
|
job_id,
|
||||||
|
idempotency_key="same-job-continuation-commit-v1",
|
||||||
|
expected_head_oid=HEAD,
|
||||||
|
generation=1,
|
||||||
|
expected_revision=8,
|
||||||
|
)
|
||||||
|
assert replay == {"completed":False,"commit_oid":None,"idempotent":True}
|
||||||
|
|
||||||
|
|
||||||
|
def test_deterministic_materialization_recovers_only_an_in_scope_dirty_subset(monkeypatch,tmp_path):
|
||||||
|
workspace=object.__new__(DetachedGitWorkspace)
|
||||||
|
workspace.path=tmp_path
|
||||||
|
monkeypatch.setattr(DetachedGitWorkspace,"_dirty_paths",lambda _self:{"old.py"})
|
||||||
|
workspace.materialize_files({"old.py":b"new-old\n","new.py":b"new\n"})
|
||||||
|
assert (tmp_path/"old.py").read_bytes() == b"new-old\n"
|
||||||
|
assert (tmp_path/"new.py").read_bytes() == b"new\n"
|
||||||
|
monkeypatch.setattr(DetachedGitWorkspace,"_dirty_paths",lambda _self:{"outside.py"})
|
||||||
|
with pytest.raises(ContinuationExecutionError,match="WORKTREE_DIRTY_SCOPE_MISMATCH"):
|
||||||
|
workspace.materialize_files({"old.py":b"again\n"})
|
||||||
@@ -0,0 +1,763 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
import hashlib
|
||||||
|
from dataclasses import asdict
|
||||||
|
from pathlib import Path
|
||||||
|
import sqlite3
|
||||||
|
import json
|
||||||
|
from types import SimpleNamespace
|
||||||
|
import threading
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from aios_chatgpt_bridge_v2_server import GovernedRuntime
|
||||||
|
from aios_continuation_executor import ContinuationPermissions, ContinuationRuntimeBinding
|
||||||
|
from aios_continuation_lease import WriterLeaseRequestV2
|
||||||
|
from aios_gitops_job_controller import GitOpsJobController, JobError, JobStore
|
||||||
|
from aios_governed_task_bootstrap import GovernedTaskBootstrap
|
||||||
|
|
||||||
|
|
||||||
|
TASK_ID = "gov-writer-lease-recovery-test"
|
||||||
|
BRANCH = "agent/writer-lease-recovery-test"
|
||||||
|
BASE = "a" * 40
|
||||||
|
JOB_ID_PREFIX = "writer-lease-runtime-lineage"
|
||||||
|
|
||||||
|
|
||||||
|
class LocalAuthority:
|
||||||
|
def __init__(self, head: str | None = None) -> None:
|
||||||
|
self.head = head
|
||||||
|
|
||||||
|
def read_ref(self, _ref: str) -> str | None:
|
||||||
|
return self.head
|
||||||
|
|
||||||
|
def commit_exists(self, _oid: str) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
def ancestry_check(self, _older: str, _newer: str) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def writer_request(task_id: str = TASK_ID, **overrides: object) -> dict[str, object]:
|
||||||
|
request: dict[str, object] = {
|
||||||
|
"schema": WriterLeaseRequestV2.schema,
|
||||||
|
"task_id": task_id,
|
||||||
|
"repository": "aios/aios",
|
||||||
|
"branch_mode": "CREATE_NEW",
|
||||||
|
"base_oid": BASE,
|
||||||
|
"expected_branch_oid": BASE,
|
||||||
|
"target_branch": BRANCH,
|
||||||
|
"generation": 1,
|
||||||
|
"lease_seconds": 120,
|
||||||
|
}
|
||||||
|
request.update(overrides)
|
||||||
|
return request
|
||||||
|
|
||||||
|
|
||||||
|
def make_runtime(tmp_path: Path, task_id: str = TASK_ID, *, authority_head: str | None = None) -> GovernedRuntime:
|
||||||
|
runtime = GovernedRuntime.__new__(GovernedRuntime)
|
||||||
|
runtime.config = SimpleNamespace(repository="aios/aios", state_root=tmp_path)
|
||||||
|
runtime.authority = LocalAuthority(authority_head)
|
||||||
|
runtime.bindings = {
|
||||||
|
task_id: {
|
||||||
|
"task_id": task_id,
|
||||||
|
"repository": "aios/aios",
|
||||||
|
"base_oid": BASE,
|
||||||
|
"expected_branch_oid": BASE,
|
||||||
|
"target_branch": BRANCH,
|
||||||
|
"branch_mode": "CREATE_NEW",
|
||||||
|
"generation": 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
runtime.runtimes = {}
|
||||||
|
runtime.controllers = {}
|
||||||
|
runtime.bootstrap = GovernedTaskBootstrap(
|
||||||
|
runtime.authority,
|
||||||
|
tmp_path,
|
||||||
|
remote_name="test",
|
||||||
|
token_path=tmp_path / "token",
|
||||||
|
ssh_key_path=tmp_path / "key",
|
||||||
|
)
|
||||||
|
runtime.bindings = {}
|
||||||
|
runtime.create_task(
|
||||||
|
{
|
||||||
|
"task_id": task_id,
|
||||||
|
"repository": "aios/aios",
|
||||||
|
"base_oid": BASE,
|
||||||
|
"expected_branch_oid": BASE,
|
||||||
|
"target_branch": BRANCH,
|
||||||
|
"risk": "engineering",
|
||||||
|
"description": "governed runtime lineage test",
|
||||||
|
"idempotency_key": JOB_ID_PREFIX + "-" + task_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
job_root = runtime._job_root(task_id)
|
||||||
|
runtime.controllers[task_id] = GitOpsJobController(
|
||||||
|
JobStore(job_root, payload_root=job_root / "payloads")
|
||||||
|
)
|
||||||
|
|
||||||
|
def build(
|
||||||
|
lease: WriterLeaseRequestV2,
|
||||||
|
*,
|
||||||
|
job_id: str,
|
||||||
|
lease_not_before: datetime | None = None,
|
||||||
|
lease_expires_at: datetime | None = None,
|
||||||
|
) -> tuple[ContinuationRuntimeBinding, GitOpsJobController, object]:
|
||||||
|
controller = runtime.controllers.get(lease.task_id)
|
||||||
|
if controller is None:
|
||||||
|
root = runtime._job_root(lease.task_id)
|
||||||
|
controller = GitOpsJobController(JobStore(root, payload_root=root / "payloads"))
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
return (
|
||||||
|
ContinuationRuntimeBinding(
|
||||||
|
request=lease,
|
||||||
|
job_id=job_id,
|
||||||
|
workspace_path=tmp_path / "worktrees" / lease.task_id,
|
||||||
|
lease_not_before=lease_not_before or now - timedelta(seconds=1),
|
||||||
|
lease_expires_at=lease_expires_at or now + timedelta(seconds=lease.lease_seconds),
|
||||||
|
original_receipt_valid=True,
|
||||||
|
original_grant_consumed=True,
|
||||||
|
permissions=ContinuationPermissions(),
|
||||||
|
),
|
||||||
|
controller,
|
||||||
|
object(),
|
||||||
|
)
|
||||||
|
|
||||||
|
runtime._build_runtime = build
|
||||||
|
return runtime
|
||||||
|
|
||||||
|
|
||||||
|
def create_durable_lease(
|
||||||
|
runtime: GovernedRuntime,
|
||||||
|
*,
|
||||||
|
expires_at: str = "2020-01-01T00:00:00+00:00",
|
||||||
|
) -> GitOpsJobController:
|
||||||
|
root = runtime._job_root(TASK_ID)
|
||||||
|
controller = GitOpsJobController(JobStore(root, payload_root=root / "payloads"))
|
||||||
|
receipt = SimpleNamespace(job_id=runtime.bindings[TASK_ID]["job_id"])
|
||||||
|
controller.lease_job(
|
||||||
|
receipt.job_id,
|
||||||
|
lease_id="writer-lease-recovery",
|
||||||
|
lineage_anchor_oid=BASE,
|
||||||
|
current_head_oid=BASE,
|
||||||
|
generation=1,
|
||||||
|
lease_expires_at=expires_at,
|
||||||
|
expected_revision=0,
|
||||||
|
)
|
||||||
|
runtime.controllers[TASK_ID] = controller
|
||||||
|
return controller
|
||||||
|
|
||||||
|
|
||||||
|
def durable_row(runtime: GovernedRuntime) -> tuple[str, int]:
|
||||||
|
database = runtime._job_root(TASK_ID) / "state.sqlite3"
|
||||||
|
with sqlite3.connect(f"file:{database}?mode=ro", uri=True) as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT lease_expires_at, revision FROM branch_leases WHERE lease_id=?",
|
||||||
|
("writer-lease-recovery",),
|
||||||
|
).fetchone()
|
||||||
|
assert row is not None
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def test_first_bootstrap_refreshes_only_runtime_without_durable_lease(tmp_path: Path) -> None:
|
||||||
|
runtime = make_runtime(tmp_path)
|
||||||
|
|
||||||
|
result = runtime.bootstrap_writer_lease(writer_request())
|
||||||
|
|
||||||
|
assert result["bootstrapped"] is True
|
||||||
|
assert result["runtime_rehydrated"] is False
|
||||||
|
assert result["durable_branch_lease_present"] is False
|
||||||
|
assert result["idempotent"] is False
|
||||||
|
database = runtime._job_root(TASK_ID) / "state.sqlite3"
|
||||||
|
with sqlite3.connect(f"file:{database}?mode=ro", uri=True) as connection:
|
||||||
|
assert connection.execute("SELECT COUNT(*) FROM branch_leases").fetchone()[0] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_bootstrap_is_idempotent_without_consuming_generation(tmp_path: Path) -> None:
|
||||||
|
runtime = make_runtime(tmp_path)
|
||||||
|
|
||||||
|
first = runtime.bootstrap_writer_lease(writer_request())
|
||||||
|
second = runtime.bootstrap_writer_lease(writer_request())
|
||||||
|
|
||||||
|
assert first["bootstrapped"] is True
|
||||||
|
assert first["generation"] == 1
|
||||||
|
assert second["bootstrapped"] is False
|
||||||
|
assert second["idempotent"] is True
|
||||||
|
assert second["generation"] == 1
|
||||||
|
database = runtime._job_root(TASK_ID) / "state.sqlite3"
|
||||||
|
with sqlite3.connect(f"file:{database}?mode=ro", uri=True) as connection:
|
||||||
|
assert connection.execute("SELECT COUNT(*) FROM branch_leases").fetchone()[0] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_commit_after_bootstrap_reuses_the_exact_runtime_binding(tmp_path: Path) -> None:
|
||||||
|
runtime = make_runtime(tmp_path)
|
||||||
|
|
||||||
|
class RecordingWorkspace:
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.files: dict[str, bytes] = {}
|
||||||
|
|
||||||
|
def materialize_files(self, files: dict[str, bytes]) -> None:
|
||||||
|
self.files = dict(files)
|
||||||
|
|
||||||
|
class RecordingExecutor:
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.workspace = RecordingWorkspace()
|
||||||
|
self.binding = None
|
||||||
|
self.commit = None
|
||||||
|
|
||||||
|
def execute(self, binding: object, commit: object) -> dict[str, object]:
|
||||||
|
self.binding = binding
|
||||||
|
self.commit = commit
|
||||||
|
return {'task_id': binding.request.task_id, 'generation': binding.request.generation, 'files': sorted(self.workspace.files)}
|
||||||
|
executor = RecordingExecutor()
|
||||||
|
original_build = runtime._build_runtime
|
||||||
|
|
||||||
|
def build(lease: WriterLeaseRequestV2, *, job_id: str, lease_not_before: datetime | None=None, lease_expires_at: datetime | None=None) -> tuple[ContinuationRuntimeBinding, GitOpsJobController, object]:
|
||||||
|
binding, controller, _ = original_build(lease, job_id=job_id, lease_not_before=lease_not_before, lease_expires_at=lease_expires_at)
|
||||||
|
return (binding, controller, executor)
|
||||||
|
runtime._build_runtime = build
|
||||||
|
runtime.bootstrap_writer_lease(writer_request())
|
||||||
|
lease_response = runtime.acquire_lease(writer_request())
|
||||||
|
binding, controller, executor = runtime.runtimes[TASK_ID, 1]
|
||||||
|
assert lease_response['job_id'] == binding.job_id
|
||||||
|
assert lease_response['durable_branch_lease_present'] is True
|
||||||
|
_original_execute = executor.execute
|
||||||
|
|
||||||
|
def _execute_with_governed_commit_contract(*args, **kwargs):
|
||||||
|
_before_complete = kwargs.pop('before_complete', None)
|
||||||
|
_legacy = dict(_original_execute(*args, **kwargs))
|
||||||
|
_result = {**_legacy, 'job_id': binding.job_id, 'state': 'COMPLETED', 'side_effect_state': 'REMOTE_VERIFIED', 'receipt': {'authority_oid': 'b' * 40, 'git_remote_oid': 'b' * 40, 'api_branch_oid': 'b' * 40, 'commit_visible': True, 'remote_verified': True, 'force_push': False}}
|
||||||
|
if _before_complete is not None:
|
||||||
|
_before_complete('b' * 40)
|
||||||
|
return _result
|
||||||
|
executor.execute = _execute_with_governed_commit_contract
|
||||||
|
_bound_job_id = binding.job_id
|
||||||
|
result = runtime.commit_files({'task_id': TASK_ID, 'repository': 'aios/aios', 'generation': 1, 'target_branch': BRANCH, 'expected_branch_oid': BASE, 'idempotency_key': 'commit-after-bootstrap-v1', 'path': 'docs/recovery.txt', 'content_base64': 'YQ==', 'message': 'test commit after bootstrap'})
|
||||||
|
assert result['task_id'] == TASK_ID
|
||||||
|
assert result['generation'] == 1
|
||||||
|
assert result['files'] == ['docs/recovery.txt']
|
||||||
|
assert executor.binding.request.task_id == TASK_ID
|
||||||
|
assert executor.binding.request.generation == 1
|
||||||
|
database = runtime._job_root(TASK_ID) / 'state.sqlite3'
|
||||||
|
with sqlite3.connect(f'file:{database}?mode=ro', uri=True) as connection:
|
||||||
|
assert connection.execute('SELECT COUNT(*) FROM branch_leases').fetchone()[0] == 1
|
||||||
|
assert connection.execute('PRAGMA foreign_key_check(branch_leases)').fetchall() == []
|
||||||
|
assert result['commit_oid'] == 'b' * 40
|
||||||
|
assert result['commit_visible'] is True
|
||||||
|
assert result['branch_mode'] == 'CONTINUE_EXISTING'
|
||||||
|
assert result['target_branch'] == BRANCH
|
||||||
|
assert int(result['generation']) == 1
|
||||||
|
with controller.store._connect() as connection:
|
||||||
|
_persisted = connection.execute('SELECT current_head_oid,revision FROM branch_leases WHERE lease_id=?', ('lease-' + str(_bound_job_id),)).fetchone()
|
||||||
|
assert _persisted is not None
|
||||||
|
assert _persisted[0] == 'b' * 40
|
||||||
|
assert int(_persisted[1]) == 1
|
||||||
|
fresh_runtime = make_runtime(tmp_path, authority_head='b' * 40)
|
||||||
|
fresh_response = fresh_runtime.bootstrap_writer_lease(writer_request(branch_mode='CONTINUE_EXISTING', expected_branch_oid='b' * 40))
|
||||||
|
fresh_binding, _, _ = fresh_runtime.runtimes[TASK_ID, 1]
|
||||||
|
assert fresh_response['job_id'] == str(_bound_job_id)
|
||||||
|
assert fresh_response['runtime_rehydrated'] is True
|
||||||
|
assert fresh_binding.job_id == str(_bound_job_id)
|
||||||
|
assert fresh_binding.request.expected_branch_oid == 'b' * 40
|
||||||
|
with fresh_runtime.controllers[TASK_ID].store._connect() as connection:
|
||||||
|
_fresh_lease = connection.execute('SELECT job_id,current_head_oid,revision FROM branch_leases WHERE lease_id=?', ('lease-' + str(_bound_job_id),)).fetchone()
|
||||||
|
assert tuple(_fresh_lease) == (str(_bound_job_id), 'b' * 40, 1)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("field", "value", "error"),
|
||||||
|
(
|
||||||
|
("repository", "other/repository", "LEASE_BINDING_CONFLICT"),
|
||||||
|
("base_oid", "b" * 40, "LEASE_BINDING_CONFLICT"),
|
||||||
|
("expected_branch_oid", "b" * 40, "LEASE_BINDING_CONFLICT"),
|
||||||
|
("target_branch", "agent/wrong-target", "LEASE_BINDING_CONFLICT"),
|
||||||
|
("generation", 2, "GENERATION_BINDING_CONFLICT"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def test_bootstrap_rejects_any_nonmatching_binding_field(
|
||||||
|
tmp_path: Path, field: str, value: object, error: str
|
||||||
|
) -> None:
|
||||||
|
runtime = make_runtime(tmp_path)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match=error):
|
||||||
|
runtime.bootstrap_writer_lease(writer_request(**{field: value}))
|
||||||
|
|
||||||
|
|
||||||
|
def test_bootstrap_rejects_unknown_task_ownership(tmp_path: Path) -> None:
|
||||||
|
runtime = make_runtime(tmp_path)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="TASK_OWNERSHIP_INVALID"):
|
||||||
|
runtime.bootstrap_writer_lease(writer_request(task_id="unknown-task"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_renew_missing_durable_lease_is_not_bootstrap(tmp_path: Path) -> None:
|
||||||
|
runtime = make_runtime(tmp_path)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="LEASE_NOT_FOUND"):
|
||||||
|
runtime.renew_writer_lease(writer_request())
|
||||||
|
|
||||||
|
|
||||||
|
def test_renew_is_generation_scoped_before_durable_lookup(tmp_path: Path) -> None:
|
||||||
|
runtime = make_runtime(tmp_path)
|
||||||
|
create_durable_lease(runtime)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="GENERATION_BINDING_CONFLICT"):
|
||||||
|
runtime.renew_writer_lease(writer_request(generation=2))
|
||||||
|
assert durable_row(runtime) == ("2020-01-01T00:00:00+00:00", 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_restart_rehydrates_only_from_matching_durable_lease(tmp_path: Path) -> None:
|
||||||
|
runtime = make_runtime(tmp_path)
|
||||||
|
create_durable_lease(runtime)
|
||||||
|
runtime.runtimes.clear()
|
||||||
|
|
||||||
|
result = runtime.renew_writer_lease(writer_request())
|
||||||
|
|
||||||
|
expires_at, revision = durable_row(runtime)
|
||||||
|
assert result["recovered"] is True
|
||||||
|
assert result["runtime_rehydrated"] is True
|
||||||
|
assert result["durable_branch_lease_present"] is True
|
||||||
|
assert revision == 1
|
||||||
|
assert datetime.fromisoformat(expires_at) > datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_renewal_is_idempotent_after_first_durable_recovery(tmp_path: Path) -> None:
|
||||||
|
runtime = make_runtime(tmp_path)
|
||||||
|
create_durable_lease(runtime)
|
||||||
|
|
||||||
|
first = runtime.renew_writer_lease(writer_request())
|
||||||
|
second = runtime.renew_writer_lease(writer_request())
|
||||||
|
|
||||||
|
assert first["recovered"] is True
|
||||||
|
assert first["idempotent"] is False
|
||||||
|
assert second["recovered"] is False
|
||||||
|
assert second["idempotent"] is True
|
||||||
|
assert durable_row(runtime)[1] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_lease_keeps_expiry_without_optional_argument_and_keeps_cas(tmp_path: Path) -> None:
|
||||||
|
runtime = make_runtime(tmp_path)
|
||||||
|
controller = create_durable_lease(runtime)
|
||||||
|
|
||||||
|
controller.update_lease(
|
||||||
|
"writer-lease-recovery",
|
||||||
|
expected_revision=0,
|
||||||
|
current_head_oid=BASE,
|
||||||
|
generation=1,
|
||||||
|
)
|
||||||
|
assert durable_row(runtime) == ("2020-01-01T00:00:00+00:00", 1)
|
||||||
|
with pytest.raises(JobError, match="LEASE_REVISION_CAS_CONFLICT"):
|
||||||
|
controller.update_lease(
|
||||||
|
"writer-lease-recovery",
|
||||||
|
expected_revision=0,
|
||||||
|
current_head_oid=BASE,
|
||||||
|
generation=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_two_runtime_connections_resolve_renewal_cas_race_idempotently(tmp_path: Path) -> None:
|
||||||
|
first = make_runtime(tmp_path)
|
||||||
|
first_controller = create_durable_lease(first)
|
||||||
|
second = make_runtime(tmp_path)
|
||||||
|
second_controller = GitOpsJobController(
|
||||||
|
JobStore(second._job_root(TASK_ID), payload_root=second._job_root(TASK_ID) / "payloads")
|
||||||
|
)
|
||||||
|
second.controllers[TASK_ID] = second_controller
|
||||||
|
gate = threading.Barrier(2)
|
||||||
|
|
||||||
|
def gate_update(controller: GitOpsJobController) -> None:
|
||||||
|
original = controller.update_lease
|
||||||
|
|
||||||
|
def update(*args: object, **kwargs: object) -> None:
|
||||||
|
gate.wait(timeout=5)
|
||||||
|
original(*args, **kwargs)
|
||||||
|
|
||||||
|
controller.update_lease = update
|
||||||
|
|
||||||
|
gate_update(first_controller)
|
||||||
|
gate_update(second_controller)
|
||||||
|
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||||
|
results = list(pool.map(lambda runtime: runtime.renew_writer_lease(writer_request()), (first, second)))
|
||||||
|
|
||||||
|
assert sorted((item["recovered"], item["idempotent"]) for item in results) == [
|
||||||
|
(False, True),
|
||||||
|
(True, False),
|
||||||
|
]
|
||||||
|
assert durable_row(first)[1] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_bootstrap_preserves_exact_governed_job_lineage(tmp_path: Path) -> None:
|
||||||
|
runtime = make_runtime(tmp_path)
|
||||||
|
expected_job_id = runtime.bindings[TASK_ID]["job_id"]
|
||||||
|
|
||||||
|
response = runtime.bootstrap_writer_lease(writer_request())
|
||||||
|
binding, _, _ = runtime.runtimes[(TASK_ID, 1)]
|
||||||
|
|
||||||
|
assert response["job_id"] == expected_job_id
|
||||||
|
assert binding.job_id == expected_job_id
|
||||||
|
assert binding.request.job_id == expected_job_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_restart_rehydrate_preserves_exact_governed_job_lineage(tmp_path: Path) -> None:
|
||||||
|
first = make_runtime(tmp_path)
|
||||||
|
expected_job_id = first.bindings[TASK_ID]["job_id"]
|
||||||
|
first.acquire_lease(writer_request())
|
||||||
|
|
||||||
|
runtime = make_runtime(tmp_path)
|
||||||
|
assert runtime is not first
|
||||||
|
assert runtime.runtimes == {}
|
||||||
|
response = runtime.bootstrap_writer_lease(writer_request())
|
||||||
|
binding, _, _ = runtime.runtimes[(TASK_ID, 1)]
|
||||||
|
|
||||||
|
assert response["job_id"] == expected_job_id
|
||||||
|
assert binding.job_id == expected_job_id
|
||||||
|
assert binding.request.job_id == expected_job_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrong_governed_job_id_is_denied_before_runtime_or_lease_side_effect(tmp_path: Path) -> None:
|
||||||
|
runtime = make_runtime(tmp_path)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="LEASE_BINDING_CONFLICT"):
|
||||||
|
runtime.bootstrap_writer_lease(writer_request(job_id="job-wrong-lineage"))
|
||||||
|
|
||||||
|
assert runtime.runtimes == {}
|
||||||
|
database = runtime._job_root(TASK_ID) / "state.sqlite3"
|
||||||
|
with sqlite3.connect(f"file:{database}?mode=ro", uri=True) as connection:
|
||||||
|
assert connection.execute("SELECT COUNT(*) FROM branch_leases").fetchone()[0] == 0
|
||||||
|
assert connection.execute("PRAGMA foreign_key_check(branch_leases)").fetchall() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_acquire_creates_exact_governed_writer_lease_with_fk_and_cas(tmp_path: Path) -> None:
|
||||||
|
runtime = make_runtime(tmp_path)
|
||||||
|
expected_job_id = runtime.bindings[TASK_ID]["job_id"]
|
||||||
|
|
||||||
|
response = runtime.acquire_lease(writer_request())
|
||||||
|
|
||||||
|
assert response["job_id"] == expected_job_id
|
||||||
|
assert response["durable_branch_lease_present"] is True
|
||||||
|
assert response["idempotent"] is False
|
||||||
|
database = runtime._job_root(TASK_ID) / "state.sqlite3"
|
||||||
|
with sqlite3.connect(f"file:{database}?mode=ro", uri=True) as connection:
|
||||||
|
foreign_keys = connection.execute(
|
||||||
|
"PRAGMA foreign_key_list(branch_leases)"
|
||||||
|
).fetchall()
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT job_id,branch,lineage_anchor_oid,current_head_oid,generation,state,revision "
|
||||||
|
"FROM branch_leases"
|
||||||
|
).fetchone()
|
||||||
|
job = connection.execute(
|
||||||
|
"SELECT state,side_effect_state,revision FROM jobs WHERE job_id=?",
|
||||||
|
(expected_job_id,),
|
||||||
|
).fetchone()
|
||||||
|
assert connection.execute("PRAGMA foreign_key_check(branch_leases)").fetchall() == []
|
||||||
|
assert any(
|
||||||
|
fk[2] == "jobs" and fk[3] == "job_id" and fk[4] == "job_id"
|
||||||
|
for fk in foreign_keys
|
||||||
|
)
|
||||||
|
assert row == (expected_job_id, BRANCH, BASE, BASE, 1, "ACTIVE", 0)
|
||||||
|
assert job == ("LEASED", "LEASED", 2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_acquire_observes_same_durable_writer_lease(tmp_path: Path) -> None:
|
||||||
|
runtime = make_runtime(tmp_path)
|
||||||
|
expected_job_id = runtime.bindings[TASK_ID]["job_id"]
|
||||||
|
|
||||||
|
first = runtime.acquire_lease(writer_request())
|
||||||
|
second = runtime.acquire_lease(writer_request())
|
||||||
|
|
||||||
|
assert first["job_id"] == expected_job_id
|
||||||
|
assert second["job_id"] == expected_job_id
|
||||||
|
assert second["idempotent"] is True
|
||||||
|
database = runtime._job_root(TASK_ID) / "state.sqlite3"
|
||||||
|
with sqlite3.connect(f"file:{database}?mode=ro", uri=True) as connection:
|
||||||
|
leases = connection.execute(
|
||||||
|
"SELECT lease_id,job_id,revision FROM branch_leases"
|
||||||
|
).fetchall()
|
||||||
|
job = connection.execute(
|
||||||
|
"SELECT state,revision FROM jobs WHERE job_id=?",
|
||||||
|
(expected_job_id,),
|
||||||
|
).fetchone()
|
||||||
|
assert leases == [("lease-" + expected_job_id, expected_job_id, 0)]
|
||||||
|
assert job == ("LEASED", 2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrong_job_acquire_is_denied_before_authorize_or_lease(tmp_path: Path) -> None:
|
||||||
|
runtime = make_runtime(tmp_path)
|
||||||
|
expected_job_id = runtime.bindings[TASK_ID]["job_id"]
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="LEASE_BINDING_CONFLICT"):
|
||||||
|
runtime.acquire_lease(writer_request(job_id="job-wrong-lineage"))
|
||||||
|
|
||||||
|
database = runtime._job_root(TASK_ID) / "state.sqlite3"
|
||||||
|
with sqlite3.connect(f"file:{database}?mode=ro", uri=True) as connection:
|
||||||
|
job = connection.execute(
|
||||||
|
"SELECT state,side_effect_state,revision FROM jobs WHERE job_id=?",
|
||||||
|
(expected_job_id,),
|
||||||
|
).fetchone()
|
||||||
|
lease_count = connection.execute("SELECT COUNT(*) FROM branch_leases").fetchone()[0]
|
||||||
|
assert job == ("REQUESTED", "NONE", 0)
|
||||||
|
assert lease_count == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_omitted_request_job_id_resolves_the_exact_durable_parent(tmp_path: Path) -> None:
|
||||||
|
runtime = make_runtime(tmp_path)
|
||||||
|
expected_job_id = runtime.bindings[TASK_ID]["job_id"]
|
||||||
|
raw = dict(writer_request())
|
||||||
|
|
||||||
|
response = runtime.acquire_lease(raw)
|
||||||
|
binding, _, _ = runtime.runtimes[(TASK_ID, 1)]
|
||||||
|
|
||||||
|
assert response["job_id"] == expected_job_id
|
||||||
|
assert binding.job_id == expected_job_id
|
||||||
|
assert binding.request.job_id == expected_job_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_durable_job_expected_head_mismatch_is_denied(tmp_path: Path) -> None:
|
||||||
|
runtime = make_runtime(tmp_path)
|
||||||
|
expected_job_id = runtime.bindings[TASK_ID]["job_id"]
|
||||||
|
controller = runtime.controllers[TASK_ID]
|
||||||
|
with controller.store._connect() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT request_json FROM jobs WHERE job_id=?", (expected_job_id,)
|
||||||
|
).fetchone()
|
||||||
|
request = json.loads(row[0])
|
||||||
|
request["expected_branch_oid"] = "c" * 40
|
||||||
|
request["binding"]["expected_branch_oid"] = "c" * 40
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE jobs SET request_json=? WHERE job_id=?",
|
||||||
|
(json.dumps(request, sort_keys=True, separators=(",", ":")), expected_job_id),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="RUNTIME_GOVERNED_JOB_UNRESOLVED"):
|
||||||
|
runtime.bootstrap_writer_lease(writer_request())
|
||||||
|
|
||||||
|
assert runtime.runtimes == {}
|
||||||
|
|
||||||
|
|
||||||
|
def _commit_ready_runtime(tmp_path: Path):
|
||||||
|
runtime = make_runtime(tmp_path)
|
||||||
|
runtime.bootstrap_writer_lease(writer_request())
|
||||||
|
binding, controller, _ = runtime.runtimes[(TASK_ID, 1)]
|
||||||
|
|
||||||
|
class Workspace:
|
||||||
|
def materialize_files(self, _files):
|
||||||
|
return None
|
||||||
|
|
||||||
|
class Executor:
|
||||||
|
def __init__(self):
|
||||||
|
self.workspace = Workspace()
|
||||||
|
self.calls = 0
|
||||||
|
|
||||||
|
def execute(self, _binding, _commit, *, before_complete=None):
|
||||||
|
self.calls += 1
|
||||||
|
result = {
|
||||||
|
"job_id": binding.job_id,
|
||||||
|
"state": "COMPLETED",
|
||||||
|
"side_effect_state": "REMOTE_VERIFIED",
|
||||||
|
"receipt": {
|
||||||
|
"authority_oid": "b" * 40,
|
||||||
|
"git_remote_oid": "b" * 40,
|
||||||
|
"api_branch_oid": "b" * 40,
|
||||||
|
"commit_visible": True,
|
||||||
|
"remote_verified": True,
|
||||||
|
"force_push": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if before_complete is not None:
|
||||||
|
before_complete("b" * 40)
|
||||||
|
return result
|
||||||
|
|
||||||
|
executor = Executor()
|
||||||
|
runtime.runtimes[(TASK_ID, 1)] = (binding, controller, executor)
|
||||||
|
runtime.acquire_lease(writer_request())
|
||||||
|
return runtime, binding, controller, executor
|
||||||
|
|
||||||
|
|
||||||
|
def _commit_request():
|
||||||
|
return {
|
||||||
|
"task_id": TASK_ID,
|
||||||
|
"repository": "aios/aios",
|
||||||
|
"generation": 1,
|
||||||
|
"target_branch": BRANCH,
|
||||||
|
"expected_branch_oid": BASE,
|
||||||
|
"idempotency_key": "commit-cas-test-v1",
|
||||||
|
"path": "docs/cas.txt",
|
||||||
|
"content_base64": "YQ==",
|
||||||
|
"message": "test commit cas",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_commit_lease_revision_cas_conflict_is_preserved(tmp_path: Path) -> None:
|
||||||
|
runtime, _binding, controller, _executor = _commit_ready_runtime(tmp_path)
|
||||||
|
|
||||||
|
def conflict(*_args, **_kwargs):
|
||||||
|
raise JobError("LEASE_REVISION_CAS_CONFLICT")
|
||||||
|
|
||||||
|
controller.update_lease = conflict
|
||||||
|
with pytest.raises(RuntimeError, match="LEASE_CAS_CONFLICT"):
|
||||||
|
runtime.commit_files(_commit_request())
|
||||||
|
|
||||||
|
|
||||||
|
def test_commit_divergent_durable_head_fails_before_executor(tmp_path: Path) -> None:
|
||||||
|
runtime, binding, controller, executor = _commit_ready_runtime(tmp_path)
|
||||||
|
controller.update_lease(
|
||||||
|
"lease-" + binding.job_id,
|
||||||
|
expected_revision=0,
|
||||||
|
current_head_oid="c" * 40,
|
||||||
|
generation=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="LEASE_HEAD_RECONCILIATION_REQUIRED"):
|
||||||
|
runtime.commit_files(_commit_request())
|
||||||
|
assert executor.calls == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_remote_pending_never_advances_durable_head(tmp_path: Path) -> None:
|
||||||
|
runtime, binding, controller, executor = _commit_ready_runtime(tmp_path)
|
||||||
|
|
||||||
|
def pending(_binding, _commit, **_kwargs):
|
||||||
|
executor.calls += 1
|
||||||
|
return {
|
||||||
|
"job_id": binding.job_id,
|
||||||
|
"state": "RECONCILIATION_REQUIRED",
|
||||||
|
"side_effect_state": "REMOTE_PUSH_PENDING",
|
||||||
|
"receipt": {
|
||||||
|
"authority_oid": "b" * 40,
|
||||||
|
"desired_remote_oid": "b" * 40,
|
||||||
|
"remote_verified": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
executor.execute = pending
|
||||||
|
with pytest.raises(RuntimeError, match="RECONCILIATION_REQUIRED"):
|
||||||
|
runtime.commit_files(_commit_request())
|
||||||
|
with controller.store._connect() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT current_head_oid,revision FROM branch_leases WHERE lease_id=?",
|
||||||
|
("lease-" + binding.job_id,),
|
||||||
|
).fetchone()
|
||||||
|
assert tuple(row) == (BASE, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_retry_accepts_exact_head_already_advanced_at_remote_verified_boundary(tmp_path: Path) -> None:
|
||||||
|
runtime, binding, controller, executor = _commit_ready_runtime(tmp_path)
|
||||||
|
commit_oid = "b" * 40
|
||||||
|
controller.update_lease(
|
||||||
|
"lease-" + binding.job_id,
|
||||||
|
expected_revision=0,
|
||||||
|
current_head_oid=commit_oid,
|
||||||
|
generation=1,
|
||||||
|
)
|
||||||
|
receipt = {
|
||||||
|
"authority_oid": commit_oid,
|
||||||
|
"git_remote_oid": commit_oid,
|
||||||
|
"api_branch_oid": commit_oid,
|
||||||
|
"commit_visible": True,
|
||||||
|
"remote_verified": True,
|
||||||
|
"force_push": False,
|
||||||
|
}
|
||||||
|
with controller.store._connect() as connection:
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE jobs SET state='REMOTE_VERIFIED',side_effect_state='REMOTE_VERIFIED',"
|
||||||
|
"receipt_json=? WHERE job_id=?",
|
||||||
|
(json.dumps(receipt, sort_keys=True, separators=(",", ":")), binding.job_id),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
|
||||||
|
result = runtime.commit_files(_commit_request())
|
||||||
|
|
||||||
|
assert result["commit_oid"] == commit_oid
|
||||||
|
assert executor.calls == 1
|
||||||
|
with controller.store._connect() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT current_head_oid,revision FROM branch_leases WHERE lease_id=?",
|
||||||
|
("lease-" + binding.job_id,),
|
||||||
|
).fetchone()
|
||||||
|
assert tuple(row) == (commit_oid, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_completed_response_with_invalid_oid_uses_canonical_oid_error(tmp_path: Path) -> None:
|
||||||
|
runtime, binding, controller, executor = _commit_ready_runtime(tmp_path)
|
||||||
|
|
||||||
|
def invalid_oid(_binding, _commit, **_kwargs):
|
||||||
|
executor.calls += 1
|
||||||
|
return {
|
||||||
|
"job_id": binding.job_id,
|
||||||
|
"state": "COMPLETED",
|
||||||
|
"side_effect_state": "REMOTE_VERIFIED",
|
||||||
|
"receipt": {
|
||||||
|
"authority_oid": "not-an-oid",
|
||||||
|
"commit_visible": True,
|
||||||
|
"remote_verified": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
executor.execute = invalid_oid
|
||||||
|
with pytest.raises(RuntimeError, match="GOVERNED_COMMIT_OID_INVALID"):
|
||||||
|
runtime.commit_files(_commit_request())
|
||||||
|
with controller.store._connect() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT current_head_oid,revision FROM branch_leases WHERE lease_id=?",
|
||||||
|
("lease-" + binding.job_id,),
|
||||||
|
).fetchone()
|
||||||
|
assert tuple(row) == (BASE, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_acquire_renews_an_expired_matching_durable_lease_with_revision_cas(tmp_path: Path) -> None:
|
||||||
|
runtime = make_runtime(tmp_path)
|
||||||
|
create_durable_lease(runtime)
|
||||||
|
runtime.runtimes.clear()
|
||||||
|
|
||||||
|
result = runtime.acquire_lease(writer_request())
|
||||||
|
|
||||||
|
expires_at, revision = durable_row(runtime)
|
||||||
|
assert result["recovered"] is True
|
||||||
|
assert result["runtime_rehydrated"] is False
|
||||||
|
assert result["durable_branch_lease_present"] is True
|
||||||
|
assert revision == 1
|
||||||
|
assert datetime.fromisoformat(expires_at) > datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def test_authority_recovers_only_clean_detached_single_attempt_worktree(tmp_path: Path) -> None:
|
||||||
|
import subprocess
|
||||||
|
from aios_authority_git_executor import AuthorityGitExecutor
|
||||||
|
|
||||||
|
bare = tmp_path / "authority.git"
|
||||||
|
seed = tmp_path / "seed"
|
||||||
|
subprocess.run(["git", "init", "--bare", str(bare)], check=True, capture_output=True)
|
||||||
|
subprocess.run(["git", "init", str(seed)], check=True, capture_output=True)
|
||||||
|
(seed / "base.txt").write_text("base\n", encoding="utf-8")
|
||||||
|
subprocess.run(["git", "-C", str(seed), "add", "base.txt"], check=True)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", str(seed), "-c", "user.name=test", "-c", "user.email=test@example.invalid",
|
||||||
|
"commit", "-m", "base"], check=True, capture_output=True,
|
||||||
|
)
|
||||||
|
base = subprocess.check_output(["git", "-C", str(seed), "rev-parse", "HEAD"], text=True).strip()
|
||||||
|
subprocess.run(["git", "-C", str(seed), "push", str(bare), "HEAD:refs/heads/main"], check=True, capture_output=True)
|
||||||
|
root = tmp_path / "worktrees"
|
||||||
|
authority = AuthorityGitExecutor(bare, root)
|
||||||
|
worktree = authority.create_detached_worktree(base, "task")
|
||||||
|
(worktree / "attempt.txt").write_text("attempt\n", encoding="utf-8")
|
||||||
|
subprocess.run(["git", "-C", str(worktree), "add", "attempt.txt"], check=True)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", str(worktree), "-c", "user.name=test", "-c", "user.email=test@example.invalid",
|
||||||
|
"commit", "-m", "failed attempt"], check=True, capture_output=True,
|
||||||
|
)
|
||||||
|
stale = subprocess.check_output(["git", "-C", str(worktree), "rev-parse", "HEAD"], text=True).strip()
|
||||||
|
|
||||||
|
recovered = authority.recover_failed_detached_worktree_exact(
|
||||||
|
path=worktree,
|
||||||
|
expected_oid=base,
|
||||||
|
stale_oid=stale,
|
||||||
|
target_ref="refs/heads/agent/recovery-test",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert recovered == stale
|
||||||
|
assert subprocess.check_output(["git", "-C", str(worktree), "rev-parse", "HEAD"], text=True).strip() == base
|
||||||
|
assert subprocess.run(["git", "-C", str(worktree), "symbolic-ref", "-q", "HEAD"]).returncode == 1
|
||||||
|
assert subprocess.check_output(["git", "-C", str(worktree), "status", "--porcelain"], text=True) == ""
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import ast
|
||||||
|
import pathlib
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
UTC = timezone.utc
|
||||||
|
|
||||||
|
def die(msg: str) -> None:
|
||||||
|
print(f"ERROR: {msg}", file=sys.stderr)
|
||||||
|
raise SystemExit(2)
|
||||||
|
|
||||||
|
def backup(path: pathlib.Path) -> pathlib.Path:
|
||||||
|
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
|
||||||
|
out = path.with_name(path.name + f".bak.{stamp}")
|
||||||
|
shutil.copy2(path, out)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser(
|
||||||
|
description="Repair misplaced subprocess import in execution-rebind runtime."
|
||||||
|
)
|
||||||
|
ap.add_argument("--rebind", required=True)
|
||||||
|
args = ap.parse_args()
|
||||||
|
path = pathlib.Path(args.rebind).resolve()
|
||||||
|
if not path.is_file():
|
||||||
|
die(f"missing rebind source: {path}")
|
||||||
|
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
marker = "from __future__ import annotations\n"
|
||||||
|
if marker not in text:
|
||||||
|
die("future import marker missing")
|
||||||
|
|
||||||
|
lines = text.splitlines(keepends=True)
|
||||||
|
filtered = [line for line in lines if line.strip() != "import subprocess"]
|
||||||
|
text2 = "".join(filtered)
|
||||||
|
|
||||||
|
future_index = text2.find(marker)
|
||||||
|
if future_index < 0:
|
||||||
|
die("future import marker missing after normalization")
|
||||||
|
|
||||||
|
insert_at = future_index + len(marker)
|
||||||
|
normalized = text2[:insert_at] + "\nimport subprocess\n" + text2[insert_at:]
|
||||||
|
|
||||||
|
if normalized == text:
|
||||||
|
print("OK import order already normalized")
|
||||||
|
else:
|
||||||
|
b = backup(path)
|
||||||
|
path.write_text(normalized, encoding="utf-8")
|
||||||
|
print(f"PATCHED import order; backup={b}")
|
||||||
|
|
||||||
|
ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||||
|
print(f"OK syntax: {path}")
|
||||||
|
|
||||||
|
current = path.read_text(encoding="utf-8")
|
||||||
|
required_markers = (
|
||||||
|
"EXECUTION_REBIND_NON_FAST_FORWARD_DENY",
|
||||||
|
"old_expected_branch_oid",
|
||||||
|
"new_expected_branch_oid",
|
||||||
|
)
|
||||||
|
missing = [m for m in required_markers if m not in current]
|
||||||
|
if missing:
|
||||||
|
die("re-anchor patch markers missing: " + ",".join(missing))
|
||||||
|
|
||||||
|
print("RESULT: IMPORT_ORDER_REPAIRED")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import ast
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import pathlib
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
|
||||||
|
UTC = timezone.utc
|
||||||
|
|
||||||
|
def die(msg: str) -> None:
|
||||||
|
print(f"ERROR: {msg}", file=sys.stderr)
|
||||||
|
raise SystemExit(2)
|
||||||
|
|
||||||
|
def backup(path: pathlib.Path) -> pathlib.Path:
|
||||||
|
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
|
||||||
|
out = path.with_name(path.name + f".bak.{stamp}")
|
||||||
|
shutil.copy2(path, out)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def ensure_import(path: pathlib.Path, import_line: str) -> None:
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
if import_line + "\n" in text:
|
||||||
|
return
|
||||||
|
lines = text.splitlines(keepends=True)
|
||||||
|
at = 0
|
||||||
|
if lines and lines[0].startswith("#!"):
|
||||||
|
at = 1
|
||||||
|
if at < len(lines) and lines[at].startswith("from __future__ import"):
|
||||||
|
at += 1
|
||||||
|
lines.insert(at, import_line + "\n")
|
||||||
|
path.write_text("".join(lines), encoding="utf-8")
|
||||||
|
|
||||||
|
def replace_once(path: pathlib.Path, old: str, new: str, label: str) -> None:
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
if new in text:
|
||||||
|
print(f"OK already patched: {label}")
|
||||||
|
return
|
||||||
|
count = text.count(old)
|
||||||
|
if count != 1:
|
||||||
|
die(f"{label}: expected exactly one match, found {count}")
|
||||||
|
b = backup(path)
|
||||||
|
path.write_text(text.replace(old, new, 1), encoding="utf-8")
|
||||||
|
print(f"PATCHED {label}; backup={b}")
|
||||||
|
|
||||||
|
def syntax_check(path: pathlib.Path) -> None:
|
||||||
|
ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||||
|
print(f"OK syntax: {path}")
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser(description="Add guarded same-job execution re-anchor support.")
|
||||||
|
ap.add_argument("--rebind", required=True)
|
||||||
|
args = ap.parse_args()
|
||||||
|
path = pathlib.Path(args.rebind).resolve()
|
||||||
|
if not path.is_file():
|
||||||
|
die(f"missing rebind source: {path}")
|
||||||
|
|
||||||
|
ensure_import(path, "import subprocess")
|
||||||
|
|
||||||
|
old = ''' binding = GovernedExecutionBinding.from_mapping(old_execution)
|
||||||
|
resolved = dict(self.resolver(task_id, capabilities))
|
||||||
|
new_execution = validate_execution_envelope(resolved, binding=binding)
|
||||||
|
if new_execution.get("action") != "deterministic":
|
||||||
|
raise ExecutionRebindError("EXECUTION_REBIND_DETERMINISTIC_PLAN_REQUIRED", 409)
|
||||||
|
if new_execution["execution_digest"] != request.expected_new_execution_digest:
|
||||||
|
raise ExecutionRebindError("EXECUTION_REBIND_NEW_DIGEST_DRIFT", 409)
|
||||||
|
if new_execution["execution_digest"] == old_execution["execution_digest"]:
|
||||||
|
raise ExecutionRebindError("EXECUTION_REBIND_DIGEST_UNCHANGED", 409)
|
||||||
|
'''
|
||||||
|
new = ''' binding = GovernedExecutionBinding.from_mapping(old_execution)
|
||||||
|
resolved = dict(self.resolver(task_id, capabilities))
|
||||||
|
candidate_binding = GovernedExecutionBinding.from_mapping(resolved)
|
||||||
|
|
||||||
|
# Same-job continuation may advance only the branch head (and may
|
||||||
|
# transition CREATE_NEW -> CONTINUE_EXISTING after the branch exists).
|
||||||
|
immutable_fields = (
|
||||||
|
"task_id",
|
||||||
|
"job_id",
|
||||||
|
"repository",
|
||||||
|
"base_oid",
|
||||||
|
"target_branch",
|
||||||
|
"generation",
|
||||||
|
"idempotency_key",
|
||||||
|
)
|
||||||
|
for field in immutable_fields:
|
||||||
|
if getattr(candidate_binding, field) != getattr(binding, field):
|
||||||
|
raise ExecutionRebindError("EXECUTION_REBIND_BINDING_CONFLICT", 409)
|
||||||
|
|
||||||
|
head_advanced = candidate_binding.expected_branch_oid != binding.expected_branch_oid
|
||||||
|
if head_advanced:
|
||||||
|
allowed_modes = {
|
||||||
|
(binding.branch_mode, binding.branch_mode),
|
||||||
|
("CREATE_NEW", "CONTINUE_EXISTING"),
|
||||||
|
}
|
||||||
|
if (binding.branch_mode, candidate_binding.branch_mode) not in allowed_modes:
|
||||||
|
raise ExecutionRebindError("EXECUTION_REBIND_BRANCH_MODE_CONFLICT", 409)
|
||||||
|
|
||||||
|
authority_git_dir = "/data/aios-gitops/repo.git"
|
||||||
|
authority_ref = f"refs/heads/{binding.target_branch}"
|
||||||
|
try:
|
||||||
|
authority_head = subprocess.check_output(
|
||||||
|
[
|
||||||
|
"/usr/bin/git",
|
||||||
|
f"--git-dir={authority_git_dir}",
|
||||||
|
"rev-parse",
|
||||||
|
authority_ref,
|
||||||
|
],
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
).strip()
|
||||||
|
ancestry = subprocess.run(
|
||||||
|
[
|
||||||
|
"/usr/bin/git",
|
||||||
|
f"--git-dir={authority_git_dir}",
|
||||||
|
"merge-base",
|
||||||
|
"--is-ancestor",
|
||||||
|
binding.expected_branch_oid,
|
||||||
|
candidate_binding.expected_branch_oid,
|
||||||
|
],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
timeout=5,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.SubprocessError) as exc:
|
||||||
|
raise ExecutionRebindError("EXECUTION_REBIND_AUTHORITY_GIT_UNAVAILABLE", 503) from exc
|
||||||
|
|
||||||
|
if authority_head != candidate_binding.expected_branch_oid:
|
||||||
|
raise ExecutionRebindError("EXECUTION_REBIND_CURRENT_HEAD_MISMATCH", 409)
|
||||||
|
if ancestry.returncode != 0:
|
||||||
|
raise ExecutionRebindError("EXECUTION_REBIND_NON_FAST_FORWARD_DENY", 409)
|
||||||
|
elif candidate_binding.branch_mode != binding.branch_mode:
|
||||||
|
raise ExecutionRebindError("EXECUTION_REBIND_BRANCH_MODE_CONFLICT", 409)
|
||||||
|
|
||||||
|
# Validate the canonical envelope on its own after the continuation
|
||||||
|
# binding proof above. Worker-side strict equality remains unchanged.
|
||||||
|
new_execution = validate_execution_envelope(resolved)
|
||||||
|
|
||||||
|
# Re-anchor may legitimately remain source_capture when no deterministic
|
||||||
|
# plan is registered yet. A non-head-changing rebind must still advance
|
||||||
|
# source_capture -> deterministic.
|
||||||
|
if new_execution.get("action") not in {"source_capture", "deterministic"}:
|
||||||
|
raise ExecutionRebindError("EXECUTION_REBIND_ACTION_INVALID", 409)
|
||||||
|
if not head_advanced and new_execution.get("action") != "deterministic":
|
||||||
|
raise ExecutionRebindError("EXECUTION_REBIND_DETERMINISTIC_PLAN_REQUIRED", 409)
|
||||||
|
if new_execution["execution_digest"] != request.expected_new_execution_digest:
|
||||||
|
raise ExecutionRebindError("EXECUTION_REBIND_NEW_DIGEST_DRIFT", 409)
|
||||||
|
if new_execution["execution_digest"] == old_execution["execution_digest"]:
|
||||||
|
raise ExecutionRebindError("EXECUTION_REBIND_DIGEST_UNCHANGED", 409)
|
||||||
|
'''
|
||||||
|
replace_once(path, old, new, "same-job fast-forward execution re-anchor")
|
||||||
|
|
||||||
|
old_event = ''' "old_action": old_execution.get("action"),
|
||||||
|
"new_action": new_execution.get("action"),
|
||||||
|
"previous_revision": request.expected_revision,
|
||||||
|
'''
|
||||||
|
new_event = ''' "old_action": old_execution.get("action"),
|
||||||
|
"new_action": new_execution.get("action"),
|
||||||
|
"old_expected_branch_oid": binding.expected_branch_oid,
|
||||||
|
"new_expected_branch_oid": candidate_binding.expected_branch_oid,
|
||||||
|
"head_advanced": head_advanced,
|
||||||
|
"previous_revision": request.expected_revision,
|
||||||
|
'''
|
||||||
|
replace_once(path, old_event, new_event, "re-anchor audit evidence")
|
||||||
|
syntax_check(path)
|
||||||
|
print("RESULT: PATCH_OK")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import ast
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import pathlib
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
|
||||||
|
UTC = timezone.utc
|
||||||
|
|
||||||
|
def die(msg: str) -> None:
|
||||||
|
print(f"ERROR: {msg}", file=sys.stderr)
|
||||||
|
raise SystemExit(2)
|
||||||
|
|
||||||
|
def backup(path: pathlib.Path) -> pathlib.Path:
|
||||||
|
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
|
||||||
|
out = path.with_name(path.name + f".bak.{stamp}")
|
||||||
|
shutil.copy2(path, out)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def ensure_datetime_import(path: pathlib.Path) -> bool:
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
target = "from datetime import datetime, timezone\n"
|
||||||
|
if target in text:
|
||||||
|
return False
|
||||||
|
lines = text.splitlines(keepends=True)
|
||||||
|
at = 0
|
||||||
|
if lines and lines[0].startswith("#!"):
|
||||||
|
at = 1
|
||||||
|
if at < len(lines) and lines[at].startswith("from __future__ import"):
|
||||||
|
at += 1
|
||||||
|
b = backup(path)
|
||||||
|
lines.insert(at, target)
|
||||||
|
path.write_text("".join(lines), encoding="utf-8")
|
||||||
|
print(f"PATCHED datetime import: {path}; backup={b}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def replace_once(path: pathlib.Path, old: str, new: str, label: str) -> bool:
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
if new in text:
|
||||||
|
print(f"OK already patched: {label}: {path}")
|
||||||
|
return False
|
||||||
|
n = text.count(old)
|
||||||
|
if n != 1:
|
||||||
|
die(f"{label}: expected exactly 1 match, found {n}: {path}")
|
||||||
|
b = backup(path)
|
||||||
|
path.write_text(text.replace(old, new, 1), encoding="utf-8")
|
||||||
|
print(f"PATCHED {label}: {path}; backup={b}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def syntax_check(path: pathlib.Path) -> None:
|
||||||
|
ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||||
|
print(f"OK syntax: {path}")
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--rebind", required=True)
|
||||||
|
ap.add_argument("--worker", required=True)
|
||||||
|
ap.add_argument("--bridge", required=True)
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
rebind = pathlib.Path(args.rebind).resolve()
|
||||||
|
worker = pathlib.Path(args.worker).resolve()
|
||||||
|
bridge = pathlib.Path(args.bridge).resolve()
|
||||||
|
for p in (rebind, worker, bridge):
|
||||||
|
if not p.is_file():
|
||||||
|
die(f"missing file: {p}")
|
||||||
|
|
||||||
|
old_rebind = ''' if task["lease_token_hash"] not in (None, "") or task["lease_expires_at"] not in (None, ""):
|
||||||
|
raise ExecutionRebindError("EXECUTION_REBIND_ACTIVE_LEASE_DENY", 409)
|
||||||
|
'''
|
||||||
|
new_rebind = ''' # A persisted lease token is not by itself proof of an active lease.
|
||||||
|
lease_expires_at = task["lease_expires_at"]
|
||||||
|
if lease_expires_at not in (None, ""):
|
||||||
|
try:
|
||||||
|
parsed_lease_expires_at = datetime.fromisoformat(
|
||||||
|
str(lease_expires_at).replace("Z", "+00:00")
|
||||||
|
).astimezone(timezone.utc)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise ExecutionRebindError("EXECUTION_REBIND_LEASE_EXPIRY_INVALID", 409) from exc
|
||||||
|
if parsed_lease_expires_at > datetime.now(timezone.utc):
|
||||||
|
raise ExecutionRebindError("EXECUTION_REBIND_ACTIVE_LEASE_DENY", 409)
|
||||||
|
'''
|
||||||
|
|
||||||
|
old_worker = ''' active = [
|
||||||
|
item for item in leases
|
||||||
|
if isinstance(item, Mapping)
|
||||||
|
and item.get("state") == "ACTIVE"
|
||||||
|
and item.get("job_id") == envelope.job_id
|
||||||
|
and item.get("branch") == envelope.target_branch
|
||||||
|
]
|
||||||
|
'''
|
||||||
|
new_worker = ''' now = datetime.now(timezone.utc)
|
||||||
|
active = []
|
||||||
|
for item in leases:
|
||||||
|
if (
|
||||||
|
not isinstance(item, Mapping)
|
||||||
|
or item.get("state") != "ACTIVE"
|
||||||
|
or item.get("job_id") != envelope.job_id
|
||||||
|
or item.get("branch") != envelope.target_branch
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
expires_at = datetime.fromisoformat(
|
||||||
|
str(item.get("lease_expires_at") or "").replace("Z", "+00:00")
|
||||||
|
).astimezone(timezone.utc)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if expires_at > now:
|
||||||
|
active.append(item)
|
||||||
|
'''
|
||||||
|
|
||||||
|
old_bridge = ''' if durable_expires_at <= datetime.now(timezone.utc):
|
||||||
|
return self.renew_writer_lease(request)
|
||||||
|
'''
|
||||||
|
new_bridge = ''' # Expired durable lease is recoverable on the same task/job/generation.
|
||||||
|
if durable_expires_at <= datetime.now(timezone.utc):
|
||||||
|
return self.renew_writer_lease(request)
|
||||||
|
'''
|
||||||
|
|
||||||
|
changed = []
|
||||||
|
for p in (rebind, worker):
|
||||||
|
if ensure_datetime_import(p):
|
||||||
|
changed.append(p)
|
||||||
|
if replace_once(rebind, old_rebind, new_rebind, "execution-rebind effective lease expiry"):
|
||||||
|
changed.append(rebind)
|
||||||
|
if replace_once(worker, old_worker, new_worker, "worker effective lease expiry"):
|
||||||
|
changed.append(worker)
|
||||||
|
if replace_once(bridge, old_bridge, new_bridge, "bridge actual-runtime marker"):
|
||||||
|
changed.append(bridge)
|
||||||
|
|
||||||
|
for p in (rebind, worker, bridge):
|
||||||
|
syntax_check(p)
|
||||||
|
print("RESULT: PATCH_OK")
|
||||||
|
print("CHANGED:", ",".join(dict.fromkeys(str(p) for p in changed)) if changed else "none")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import ast
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import pathlib
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
|
||||||
|
UTC = timezone.utc
|
||||||
|
|
||||||
|
def die(msg: str) -> None:
|
||||||
|
print(f"ERROR: {msg}", file=sys.stderr)
|
||||||
|
raise SystemExit(2)
|
||||||
|
|
||||||
|
def backup(path: pathlib.Path) -> pathlib.Path:
|
||||||
|
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
|
||||||
|
out = path.with_name(path.name + f".bak.{stamp}")
|
||||||
|
shutil.copy2(path, out)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser(description="Allow pre-claim execution rebind at attempt=0.")
|
||||||
|
ap.add_argument("--rebind", required=True)
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
path = pathlib.Path(args.rebind).resolve()
|
||||||
|
if not path.is_file():
|
||||||
|
die(f"missing rebind source: {path}")
|
||||||
|
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
old = " or attempt < 1\n"
|
||||||
|
new = " or attempt < 0\n"
|
||||||
|
|
||||||
|
if new in text:
|
||||||
|
print("OK already patched: pre-claim attempt=0")
|
||||||
|
else:
|
||||||
|
count = text.count(old)
|
||||||
|
if count != 1:
|
||||||
|
die(f"expected exactly one attempt<1 guard, found {count}")
|
||||||
|
b = backup(path)
|
||||||
|
path.write_text(text.replace(old, new, 1), encoding="utf-8")
|
||||||
|
print(f"PATCHED pre-claim attempt=0; backup={b}")
|
||||||
|
|
||||||
|
ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||||
|
|
||||||
|
current = path.read_text(encoding="utf-8")
|
||||||
|
for marker in (
|
||||||
|
"or attempt < 0",
|
||||||
|
"EXECUTION_REBIND_NON_FAST_FORWARD_DENY",
|
||||||
|
"old_expected_branch_oid",
|
||||||
|
"new_expected_branch_oid",
|
||||||
|
):
|
||||||
|
if marker not in current:
|
||||||
|
die(f"required marker missing: {marker}")
|
||||||
|
|
||||||
|
print(f"OK syntax: {path}")
|
||||||
|
print("RESULT: PRECLAIM_REBIND_PATCH_OK")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import ast
|
||||||
|
import datetime as dt
|
||||||
|
import pathlib
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
|
||||||
|
UTC = dt.timezone.utc
|
||||||
|
|
||||||
|
def die(msg: str) -> None:
|
||||||
|
print(f"ERROR: {msg}", file=sys.stderr)
|
||||||
|
raise SystemExit(2)
|
||||||
|
|
||||||
|
def backup(path: pathlib.Path) -> pathlib.Path:
|
||||||
|
stamp = dt.datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
|
||||||
|
out = path.with_suffix(path.suffix + f".bak.{stamp}")
|
||||||
|
shutil.copy2(path, out)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def replace_once(path: pathlib.Path, old: str, new: str, label: str) -> bool:
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
if new in text:
|
||||||
|
print(f"OK already patched: {label}")
|
||||||
|
return False
|
||||||
|
count = text.count(old)
|
||||||
|
if count != 1:
|
||||||
|
die(f"{label}: expected exactly 1 match, found {count} in {path}")
|
||||||
|
backup_path = backup(path)
|
||||||
|
path.write_text(text.replace(old, new, 1), encoding="utf-8")
|
||||||
|
print(f"PATCHED {label}; backup={backup_path}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def ensure_datetime_import(path: pathlib.Path) -> bool:
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
target = "from datetime import datetime, timezone\n"
|
||||||
|
if target in text:
|
||||||
|
return False
|
||||||
|
lines = text.splitlines(keepends=True)
|
||||||
|
insert_at = 0
|
||||||
|
if lines and lines[0].startswith("#!"):
|
||||||
|
insert_at = 1
|
||||||
|
if insert_at < len(lines) and lines[insert_at].startswith("from __future__ import"):
|
||||||
|
insert_at += 1
|
||||||
|
lines.insert(insert_at, target)
|
||||||
|
backup_path = backup(path)
|
||||||
|
path.write_text("".join(lines), encoding="utf-8")
|
||||||
|
print(f"PATCHED datetime import: {path}; backup={backup_path}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def syntax_check(path: pathlib.Path) -> None:
|
||||||
|
ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||||
|
print(f"OK syntax: {path}")
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser(description="Repair stale governed writer lease handling.")
|
||||||
|
ap.add_argument("--app-dir", default="/opt/aios/current/active-runtime/app")
|
||||||
|
args = ap.parse_args()
|
||||||
|
app = pathlib.Path(args.app_dir)
|
||||||
|
|
||||||
|
rebind = app / "aios_agent_task_execution_rebind.py"
|
||||||
|
worker = app / "aios_online_agent_worker.py"
|
||||||
|
bridge = app / "aios_chatgpt_bridge_v2_server.py"
|
||||||
|
for p in (rebind, worker, bridge):
|
||||||
|
if not p.is_file():
|
||||||
|
die(f"missing file: {p}")
|
||||||
|
|
||||||
|
old_rebind = ''' if task["lease_token_hash"] not in (None, "") or task["lease_expires_at"] not in (None, ""):
|
||||||
|
raise ExecutionRebindError("EXECUTION_REBIND_ACTIVE_LEASE_DENY", 409)
|
||||||
|
'''
|
||||||
|
new_rebind = ''' # A persisted lease token is not by itself proof of an active lease.
|
||||||
|
# Treat an unexpired lease as active; stale lease projection is recoverable.
|
||||||
|
lease_expires_at = task["lease_expires_at"]
|
||||||
|
if lease_expires_at not in (None, ""):
|
||||||
|
try:
|
||||||
|
parsed_lease_expires_at = datetime.fromisoformat(
|
||||||
|
str(lease_expires_at).replace("Z", "+00:00")
|
||||||
|
).astimezone(timezone.utc)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise ExecutionRebindError("EXECUTION_REBIND_LEASE_EXPIRY_INVALID", 409) from exc
|
||||||
|
if parsed_lease_expires_at > datetime.now(timezone.utc):
|
||||||
|
raise ExecutionRebindError("EXECUTION_REBIND_ACTIVE_LEASE_DENY", 409)
|
||||||
|
'''
|
||||||
|
|
||||||
|
old_worker = ''' active = [
|
||||||
|
item for item in leases
|
||||||
|
if isinstance(item, Mapping)
|
||||||
|
and item.get("state") == "ACTIVE"
|
||||||
|
and item.get("job_id") == envelope.job_id
|
||||||
|
and item.get("branch") == envelope.target_branch
|
||||||
|
]
|
||||||
|
'''
|
||||||
|
new_worker = ''' now = datetime.now(timezone.utc)
|
||||||
|
active = []
|
||||||
|
for item in leases:
|
||||||
|
if (
|
||||||
|
not isinstance(item, Mapping)
|
||||||
|
or item.get("state") != "ACTIVE"
|
||||||
|
or item.get("job_id") != envelope.job_id
|
||||||
|
or item.get("branch") != envelope.target_branch
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
expires_at = datetime.fromisoformat(
|
||||||
|
str(item.get("lease_expires_at") or "").replace("Z", "+00:00")
|
||||||
|
).astimezone(timezone.utc)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if expires_at > now:
|
||||||
|
active.append(item)
|
||||||
|
'''
|
||||||
|
|
||||||
|
old_bridge = ''' if durable_expires_at <= datetime.now(timezone.utc):
|
||||||
|
return self.renew_writer_lease(request)
|
||||||
|
'''
|
||||||
|
new_bridge = ''' # An expired durable lease is recoverable on the same governed
|
||||||
|
# task/job/generation. Renew it instead of treating stale ACTIVE state
|
||||||
|
# as a durable-binding failure.
|
||||||
|
if durable_expires_at <= datetime.now(timezone.utc):
|
||||||
|
return self.renew_writer_lease(request)
|
||||||
|
'''
|
||||||
|
|
||||||
|
changed = []
|
||||||
|
if ensure_datetime_import(rebind):
|
||||||
|
changed.append(rebind)
|
||||||
|
if ensure_datetime_import(worker):
|
||||||
|
changed.append(worker)
|
||||||
|
if replace_once(rebind, old_rebind, new_rebind, "execution-rebind effective lease expiry"):
|
||||||
|
changed.append(rebind)
|
||||||
|
if replace_once(worker, old_worker, new_worker, "worker converged-job effective lease expiry"):
|
||||||
|
changed.append(worker)
|
||||||
|
if replace_once(bridge, old_bridge, new_bridge, "bridge expired-lease recovery contract"):
|
||||||
|
changed.append(bridge)
|
||||||
|
|
||||||
|
for p in (rebind, worker, bridge):
|
||||||
|
syntax_check(p)
|
||||||
|
|
||||||
|
print("RESULT: PATCH_OK")
|
||||||
|
print("CHANGED:", ",".join(dict.fromkeys(str(p) for p in changed)) if changed else "none")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"approval_plan_id": "sha256:c0df52008ae7f64cec5ee2d28947aa7b892066c670ab8295e4549140b1a7500e",
|
|
||||||
"artifact_sha256": "ab4ae257346c82863f05abf5e309381a1c86612957608c3a4f4ab5cc9233f052",
|
|
||||||
"deployment_state": "ACTIVE",
|
|
||||||
"enabled": true,
|
|
||||||
"evidence_closure_oid": "a8b906d1bc24088c19647e98de6a679268f6d0a2",
|
|
||||||
"peeled_target_oid": "998d30304787e31e5ae607c0bd86b5edb2b0e5ad",
|
|
||||||
"production_deployed": true,
|
|
||||||
"production_port": 18927,
|
|
||||||
"production_service": "aios-v2r1c7-production.service",
|
|
||||||
"registered": true,
|
|
||||||
"repository": "aios/aios",
|
|
||||||
"schema": "aios.production-registry.v2",
|
|
||||||
"status": "PRODUCTION_DEPLOYED",
|
|
||||||
"tag": "V2R2C1",
|
|
||||||
"tag_object": "07fc2746b688f5f243f29b39de39f32edc3dd9d2",
|
|
||||||
"tag_oid": "07fc2746b688f5f243f29b39de39f32edc3dd9d2",
|
|
||||||
"target_oid": "998d30304787e31e5ae607c0bd86b5edb2b0e5ad",
|
|
||||||
"updated_at": "2026-08-09T09:13:13Z",
|
|
||||||
"version": "V2R2C1"
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user