Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2dcc3b372 | ||
|
|
05025480ad |
@@ -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
@@ -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)"
|
||||
Reference in New Issue
Block a user