Compare commits

...
2 changed files with 397 additions and 37 deletions
+339 -37
View File
@@ -7,6 +7,7 @@ from typing import Any
sys.path.insert(0, str(Path(os.getenv('AIOS_GITOPS_WORKSPACE', '/data/aios-gitops/workspace')) / 'runtime' / 'agent-os'))
from orchestrator.action_monitor import ActionMonitor
import threading
ROOT = Path(os.getenv('AIOS_GITOPS_ROOT', '/data/aios-gitops'))
WS = Path(os.getenv('AIOS_GITOPS_WORKSPACE', str(ROOT / 'workspace')))
@@ -17,6 +18,7 @@ PORT = int(os.getenv('AIOS_GITOPS_PORT', '18890'))
OPENAPI = WS / 'docs' / 'gitops' / 'openapi-chatgpt-actions.yaml'
BARE_REPO = Path(os.getenv('AIOS_GITOPS_BARE_REPO', str(ROOT / 'runtime' / 'gitea-bin' / 'data' / 'repositories' / 'aios' / 'aios.git')))
monitor = ActionMonitor()
WRITE_LOCK = threading.RLock()
BASE_MODELS = [
{'id':'base.deepseekv4-flash','provider':'one-api','model':'deepseekv4-flash','display_name':'DeepSeek V4 Flash','scope':'global'},
@@ -117,28 +119,211 @@ def write_file(path, content):
if content_has_secret(content): raise ValueError('secret_rejected: use secret_ref; plaintext API keys must not enter Git')
r=relpath(path); t=WS/r; t.parent.mkdir(parents=True, exist_ok=True); t.write_text(content, encoding='utf-8'); return r
def write_json(path, data): return write_file(path, json.dumps(data, ensure_ascii=False, indent=2) + '\n')
def controller_status() -> dict:
branch = sh(
['branch', '--show-current']
)['stdout'] or None
head = sh(
['rev-parse', 'HEAD'],
True,
)['stdout']
dirty = bool(
sh(
['status', '--porcelain']
)['stdout']
)
return {
'workspace_mode': (
'branch'
if branch
else 'detached'
),
'workspace_branch': branch,
'workspace_ref': head,
'workspace_dirty': dirty,
}
def detach_controller() -> dict:
state = controller_status()
if state['workspace_branch']:
sh(
[
'switch',
'--detach',
'HEAD',
],
True,
)
return controller_status()
def sync_branch(branch: str) -> dict:
if not branch or branch in ('main','master') or branch.startswith('main/') or branch.startswith('master/'):
return {'status':'skipped','reason':'protected_branch','branch':branch}
if (
not branch
or branch in ('main', 'master')
or branch.startswith('main/')
or branch.startswith('master/')
):
return {
'status': 'skipped',
'reason': 'protected_branch',
'branch': branch,
}
state = detach_controller()
common_git_dir = Path(
sh(
[
'rev-parse',
'--path-format=absolute',
'--git-common-dir',
],
True,
)['stdout']
).resolve()
if BARE_REPO.exists():
ref=f'refs/heads/{branch}:refs/heads/{branch}'
p=subprocess.run(['git','--git-dir',str(BARE_REPO),'fetch',str(WS),ref], text=True, capture_output=True)
if p.returncode != 0: raise RuntimeError(p.stderr or p.stdout or f'git sync failed for {branch}')
return {'status':'synced','target':'bare','branch':branch,'stdout':p.stdout.strip(),'stderr':p.stderr.strip()}
p=subprocess.run(['git','push','origin',f'HEAD:refs/heads/{branch}'], cwd=str(WS), text=True, capture_output=True)
if p.returncode != 0: raise RuntimeError(p.stderr or p.stdout or f'git push failed for {branch}')
return {'status':'synced','target':'origin','branch':branch,'stdout':p.stdout.strip(),'stderr':p.stderr.strip()}
target = BARE_REPO.resolve()
if target == common_git_dir:
return {
'status': 'synced',
'target': 'common_git_dir',
'branch': branch,
**state,
}
proc = subprocess.run(
[
'git',
'push',
'--porcelain',
str(target),
f'HEAD:refs/heads/{branch}',
],
cwd=str(WS),
text=True,
capture_output=True,
)
else:
proc = subprocess.run(
[
'git',
'push',
'--porcelain',
'origin',
f'HEAD:refs/heads/{branch}',
],
cwd=str(WS),
text=True,
capture_output=True,
)
if proc.returncode != 0:
raise RuntimeError(
proc.stderr
or proc.stdout
or (
'git push failed for branch '
+ branch
)
)
return {
'status': 'synced',
'target': (
str(BARE_REPO.resolve())
if BARE_REPO.exists()
else 'origin'
),
'branch': branch,
'stdout': proc.stdout.strip(),
'stderr': proc.stderr.strip(),
**state,
}
def sync_tag(tag: str) -> dict:
if not tag: return {'status':'skipped','reason':'empty_tag'}
state = detach_controller()
common_git_dir = Path(
sh(
[
'rev-parse',
'--path-format=absolute',
'--git-common-dir',
],
True,
)['stdout']
).resolve()
tag_ref = f'refs/tags/{tag}'
if BARE_REPO.exists():
ref=f'refs/tags/{tag}:refs/tags/{tag}'
p=subprocess.run(['git','--git-dir',str(BARE_REPO),'fetch',str(WS),ref], text=True, capture_output=True)
if p.returncode != 0: raise RuntimeError(p.stderr or p.stdout or f'git tag sync failed for {tag}')
return {'status':'synced','target':'bare','tag':tag,'stdout':p.stdout.strip(),'stderr':p.stderr.strip()}
p=subprocess.run(['git','push','origin',tag], cwd=str(WS), text=True, capture_output=True)
if p.returncode != 0: raise RuntimeError(p.stderr or p.stdout or f'git tag push failed for {tag}')
return {'status':'synced','target':'origin','tag':tag,'stdout':p.stdout.strip(),'stderr':p.stderr.strip()}
target = BARE_REPO.resolve()
if target == common_git_dir:
return {
'status': 'synced',
'target': 'common_git_dir',
'tag': tag,
**state,
}
proc = subprocess.run(
[
'git',
'push',
'--porcelain',
str(target),
f'{tag_ref}:{tag_ref}',
],
cwd=str(WS),
text=True,
capture_output=True,
)
else:
proc = subprocess.run(
[
'git',
'push',
'--porcelain',
'origin',
f'{tag_ref}:{tag_ref}',
],
cwd=str(WS),
text=True,
capture_output=True,
)
if proc.returncode != 0:
raise RuntimeError(
proc.stderr
or proc.stdout
or (
'git push failed for tag '
+ tag
)
)
return {
'status': 'synced',
'target': (
str(BARE_REPO.resolve())
if BARE_REPO.exists()
else 'origin'
),
'tag': tag,
'stdout': proc.stdout.strip(),
'stderr': proc.stderr.strip(),
**state,
}
def commit(paths, msg):
ensure_git(); paths = [relpath(p) for p in paths] if paths else ['.']; sh(['add'] + paths, True)
@@ -210,25 +395,123 @@ class H(BaseHTTPRequestHandler):
monitor.record_failure('do_GET', str(e), {'path':path})
return self.sendj({'error':str(e)},500)
def do_PUT(self):
path=urllib.parse.urlparse(self.path).path.rstrip('/'); data=self.body()
if path == '/api/model-priority': return self.model_priority(data)
return self.sendj({'error':'not_found','path':path},404)
def do_POST(self):
path=urllib.parse.urlparse(self.path).path.rstrip('/'); data=self.body()
path = urllib.parse.urlparse(
self.path
).path.rstrip('/')
data = self.body()
try:
if path in ('/api/branch','/api/create-branch'): return self.branch(data)
if path in ('/api/file','/api/commit-file'): return self.file(data)
if path == '/api/commit': return self.commit(data)
if path in ('/api/patch','/api/create-patch'): return self.patch(data)
if path in ('/api/tag','/api/create-tag'): return self.tag(data)
if path == '/api/rollback': return self.rollback(data)
if path == '/api/model': return self.model(data)
if path == '/api/model-priority': return self.model_priority(data)
if path == '/api/nl': return self.nl(data)
return self.sendj({'error':'not_found','path':path},404)
except Exception as e:
monitor.record_failure('do_POST', str(e), {'path':path})
return self.sendj({'error':str(e)},500)
with WRITE_LOCK:
if path == '/api/model-priority':
return self.model_priority(data)
return self.sendj(
{
'error': 'not_found',
'path': path,
},
404,
)
except Exception as exc:
monitor.record_failure(
'do_PUT',
str(exc),
{'path': path},
)
return self.sendj(
{'error': str(exc)},
500,
)
finally:
try:
detach_controller()
except Exception as detach_error:
monitor.record_failure(
'detach_controller',
str(detach_error),
{
'path': path,
'method': 'PUT',
},
)
def do_POST(self):
path = urllib.parse.urlparse(
self.path
).path.rstrip('/')
data = self.body()
try:
with WRITE_LOCK:
if path in (
'/api/branch',
'/api/create-branch',
):
return self.branch(data)
if path in (
'/api/file',
'/api/commit-file',
):
return self.file(data)
if path == '/api/commit':
return self.commit(data)
if path in (
'/api/patch',
'/api/create-patch',
):
return self.patch(data)
if path in (
'/api/tag',
'/api/create-tag',
):
return self.tag(data)
if path == '/api/rollback':
return self.rollback(data)
if path == '/api/model':
return self.model(data)
if path == '/api/model-priority':
return self.model_priority(data)
if path == '/api/nl':
return self.nl(data)
return self.sendj(
{
'error': 'not_found',
'path': path,
},
404,
)
except Exception as exc:
monitor.record_failure(
'do_POST',
str(exc),
{'path': path},
)
return self.sendj(
{'error': str(exc)},
500,
)
finally:
try:
detach_controller()
except Exception as detach_error:
monitor.record_failure(
'detach_controller',
str(detach_error),
{
'path': path,
'method': 'POST',
},
)
def branch(self,data):
a=self.need('branch:create');
if not a: return
@@ -393,6 +676,25 @@ 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 /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'],'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}}
def main():
print(f'AIOS ChatGPT GitOps adapter listening on {HOST}:{PORT}')
ThreadingHTTPServer((HOST,PORT), H).serve_forever()
state = controller_status()
if state['workspace_dirty']:
raise RuntimeError(
'controller workspace is dirty; '
'refusing to start GitOps writes'
)
state = detach_controller()
print(
'AIOS ChatGPT GitOps adapter listening '
f'on {HOST}:{PORT}; '
f'workspace_mode={state["workspace_mode"]}; '
f'workspace_ref={state["workspace_ref"]}'
)
ThreadingHTTPServer(
(HOST, PORT),
H,
).serve_forever()
if __name__ == '__main__': main()
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env bash
set -Eeuo pipefail
WT="${AIOS_GITOPS_WORKSPACE:-/data/aios-gitops/worktrees/controller}"
BRANCH="${AIOS_GITOPS_CONTROLLER_BRANCH:-main}"
git -C "$WT" rev-parse \
--is-inside-work-tree >/dev/null
DIRTY="$(
git -C "$WT" status --porcelain=v1
)"
if [[ -n "$DIRTY" ]]; then
echo "controller workspace is dirty" >&2
echo "$DIRTY" >&2
exit 73
fi
git -C "$WT" fetch --prune origin \
"+refs/heads/${BRANCH}:refs/remotes/origin/${BRANCH}"
git -C "$WT" switch --detach \
"refs/remotes/origin/${BRANCH}"
COMMON="$(
git -C "$WT" \
rev-parse \
--path-format=absolute \
--git-common-dir
)"
git --git-dir="$COMMON" \
worktree prune --expire now
CURRENT_BRANCH="$(
git -C "$WT" branch --show-current
)"
if [[ -n "$CURRENT_BRANCH" ]]; then
echo "controller remains attached: $CURRENT_BRANCH" >&2
exit 74
fi
DIRTY_AFTER="$(
git -C "$WT" status --porcelain=v1
)"
if [[ -n "$DIRTY_AFTER" ]]; then
echo "controller became dirty" >&2
echo "$DIRTY_AFTER" >&2
exit 75
fi
echo "workspace_mode=detached"
echo "workspace_branch=null"
echo "workspace_dirty=false"
echo "workspace_ref=$(git -C "$WT" rev-parse --short HEAD)"