@topolo/cli 0.12.33 → 0.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -0
- package/agent-packs/README.md +495 -0
- package/agent-packs/pyproject.toml +16 -0
- package/agent-packs/requirements-test.txt +1 -0
- package/agent-packs/src/topolo_agent_pack/__init__.py +1 -0
- package/agent-packs/src/topolo_agent_pack/blueprint.py +57 -0
- package/agent-packs/src/topolo_agent_pack/claude.py +265 -0
- package/agent-packs/src/topolo_agent_pack/cli.py +443 -0
- package/agent-packs/src/topolo_agent_pack/execution.py +154 -0
- package/agent-packs/src/topolo_agent_pack/fresh.py +158 -0
- package/agent-packs/src/topolo_agent_pack/live.py +403 -0
- package/agent-packs/src/topolo_agent_pack/native.py +645 -0
- package/agent-packs/src/topolo_agent_pack/policy.py +92 -0
- package/agent-packs/src/topolo_agent_pack/storage.py +271 -0
- package/agent-packs/src/topolo_agent_pack/topolo.py +321 -0
- package/agent-packs/templates/career/AGENTS.md +17 -0
- package/agent-packs/templates/career/README.md +14 -0
- package/agent-packs/templates/career/agents/intakes/career-intake/AGENTS.md +21 -0
- package/agent-packs/templates/career/agents/specialists/application-preparation/AGENTS.md +21 -0
- package/agent-packs/templates/career/agents/specialists/application-tracker/AGENTS.md +21 -0
- package/agent-packs/templates/career/agents/specialists/interview-coach/AGENTS.md +21 -0
- package/agent-packs/templates/career/agents/specialists/opportunity-research/AGENTS.md +21 -0
- package/agent-packs/templates/career/agents/specialists/profile-cv/AGENTS.md +21 -0
- package/agent-packs/templates/career/pack.json +124 -0
- package/agent-packs/templates/career/shared/PACK.md +22 -0
- package/agent-packs/templates/career/shared/TOPOLO.md +13 -0
- package/agent-packs/templates/career/shared/WORKFLOW.md +13 -0
- package/agent-packs/templates/career/skills/topolo/SKILL.md +305 -0
- package/agent-packs/templates/clients/AGENTS.md +17 -0
- package/agent-packs/templates/clients/README.md +14 -0
- package/agent-packs/templates/clients/agents/intakes/client-intake/AGENTS.md +21 -0
- package/agent-packs/templates/clients/agents/specialists/acceptance-review/AGENTS.md +21 -0
- package/agent-packs/templates/clients/agents/specialists/delivery-planner/AGENTS.md +21 -0
- package/agent-packs/templates/clients/agents/specialists/discovery/AGENTS.md +21 -0
- package/agent-packs/templates/clients/agents/specialists/proposal-writer/AGENTS.md +21 -0
- package/agent-packs/templates/clients/agents/specialists/solution-design/AGENTS.md +21 -0
- package/agent-packs/templates/clients/pack.json +126 -0
- package/agent-packs/templates/clients/shared/PACK.md +22 -0
- package/agent-packs/templates/clients/shared/TOPOLO.md +13 -0
- package/agent-packs/templates/clients/shared/WORKFLOW.md +13 -0
- package/agent-packs/templates/clients/skills/topolo/SKILL.md +305 -0
- package/agent-packs/templates/creative/AGENTS.md +17 -0
- package/agent-packs/templates/creative/README.md +14 -0
- package/agent-packs/templates/creative/agents/intakes/creative-director/AGENTS.md +21 -0
- package/agent-packs/templates/creative/agents/specialists/brand-copy/AGENTS.md +21 -0
- package/agent-packs/templates/creative/agents/specialists/media-production/AGENTS.md +21 -0
- package/agent-packs/templates/creative/agents/specialists/quality-review/AGENTS.md +21 -0
- package/agent-packs/templates/creative/agents/specialists/storytelling/AGENTS.md +21 -0
- package/agent-packs/templates/creative/agents/specialists/visual-design/AGENTS.md +21 -0
- package/agent-packs/templates/creative/pack.json +128 -0
- package/agent-packs/templates/creative/shared/PACK.md +22 -0
- package/agent-packs/templates/creative/shared/TOPOLO.md +13 -0
- package/agent-packs/templates/creative/shared/WORKFLOW.md +13 -0
- package/agent-packs/templates/creative/skills/topolo/SKILL.md +305 -0
- package/agent-packs/templates/founder/AGENTS.md +17 -0
- package/agent-packs/templates/founder/README.md +14 -0
- package/agent-packs/templates/founder/agents/intakes/founder-intake/AGENTS.md +21 -0
- package/agent-packs/templates/founder/agents/specialists/commercial-modelling/AGENTS.md +21 -0
- package/agent-packs/templates/founder/agents/specialists/customer-research/AGENTS.md +21 -0
- package/agent-packs/templates/founder/agents/specialists/decision-review/AGENTS.md +21 -0
- package/agent-packs/templates/founder/agents/specialists/partnerships/AGENTS.md +21 -0
- package/agent-packs/templates/founder/agents/specialists/strategy/AGENTS.md +21 -0
- package/agent-packs/templates/founder/pack.json +126 -0
- package/agent-packs/templates/founder/shared/PACK.md +22 -0
- package/agent-packs/templates/founder/shared/TOPOLO.md +13 -0
- package/agent-packs/templates/founder/shared/WORKFLOW.md +13 -0
- package/agent-packs/templates/founder/skills/topolo/SKILL.md +305 -0
- package/agent-packs/templates/learning/AGENTS.md +17 -0
- package/agent-packs/templates/learning/README.md +14 -0
- package/agent-packs/templates/learning/agents/intakes/learning-intake/AGENTS.md +21 -0
- package/agent-packs/templates/learning/agents/specialists/critical-reviewer/AGENTS.md +21 -0
- package/agent-packs/templates/learning/agents/specialists/curriculum-designer/AGENTS.md +21 -0
- package/agent-packs/templates/learning/agents/specialists/knowledge-steward/AGENTS.md +21 -0
- package/agent-packs/templates/learning/agents/specialists/practice-coach/AGENTS.md +21 -0
- package/agent-packs/templates/learning/agents/specialists/tutor/AGENTS.md +21 -0
- package/agent-packs/templates/learning/pack.json +124 -0
- package/agent-packs/templates/learning/shared/PACK.md +22 -0
- package/agent-packs/templates/learning/shared/TOPOLO.md +13 -0
- package/agent-packs/templates/learning/shared/WORKFLOW.md +13 -0
- package/agent-packs/templates/learning/skills/topolo/SKILL.md +305 -0
- package/agent-packs/templates/personal/AGENTS.md +17 -0
- package/agent-packs/templates/personal/README.md +14 -0
- package/agent-packs/templates/personal/agents/intakes/personal-intake/AGENTS.md +21 -0
- package/agent-packs/templates/personal/agents/specialists/correspondence/AGENTS.md +21 -0
- package/agent-packs/templates/personal/agents/specialists/document-assistant/AGENTS.md +21 -0
- package/agent-packs/templates/personal/agents/specialists/planning/AGENTS.md +21 -0
- package/agent-packs/templates/personal/agents/specialists/research/AGENTS.md +21 -0
- package/agent-packs/templates/personal/agents/specialists/travel-logistics/AGENTS.md +21 -0
- package/agent-packs/templates/personal/pack.json +125 -0
- package/agent-packs/templates/personal/shared/PACK.md +22 -0
- package/agent-packs/templates/personal/shared/TOPOLO.md +13 -0
- package/agent-packs/templates/personal/shared/WORKFLOW.md +13 -0
- package/agent-packs/templates/personal/skills/topolo/SKILL.md +305 -0
- package/agent-packs/templates/research/AGENTS.md +17 -0
- package/agent-packs/templates/research/README.md +14 -0
- package/agent-packs/templates/research/agents/intakes/research-intake/AGENTS.md +21 -0
- package/agent-packs/templates/research/agents/specialists/adversarial-review/AGENTS.md +21 -0
- package/agent-packs/templates/research/agents/specialists/data-analysis/AGENTS.md +21 -0
- package/agent-packs/templates/research/agents/specialists/source-research/AGENTS.md +21 -0
- package/agent-packs/templates/research/agents/specialists/synthesis/AGENTS.md +21 -0
- package/agent-packs/templates/research/agents/specialists/technical-research/AGENTS.md +21 -0
- package/agent-packs/templates/research/pack.json +124 -0
- package/agent-packs/templates/research/shared/PACK.md +22 -0
- package/agent-packs/templates/research/shared/TOPOLO.md +13 -0
- package/agent-packs/templates/research/shared/WORKFLOW.md +13 -0
- package/agent-packs/templates/research/skills/topolo/SKILL.md +305 -0
- package/agent-packs/templates/ventures/AGENTS.md +17 -0
- package/agent-packs/templates/ventures/README.md +14 -0
- package/agent-packs/templates/ventures/agents/intakes/venture-intake/AGENTS.md +21 -0
- package/agent-packs/templates/ventures/agents/specialists/customer-discovery/AGENTS.md +21 -0
- package/agent-packs/templates/ventures/agents/specialists/economics/AGENTS.md +21 -0
- package/agent-packs/templates/ventures/agents/specialists/experiment-design/AGENTS.md +21 -0
- package/agent-packs/templates/ventures/agents/specialists/market-research/AGENTS.md +21 -0
- package/agent-packs/templates/ventures/agents/specialists/product-concept/AGENTS.md +21 -0
- package/agent-packs/templates/ventures/pack.json +126 -0
- package/agent-packs/templates/ventures/shared/PACK.md +22 -0
- package/agent-packs/templates/ventures/shared/TOPOLO.md +13 -0
- package/agent-packs/templates/ventures/shared/WORKFLOW.md +13 -0
- package/agent-packs/templates/ventures/skills/topolo/SKILL.md +305 -0
- package/agent-packs/topolo-agent-pack +8 -0
- package/dist/claude-pack-backend-IDQQX3DG.js +555 -0
- package/dist/index.js +143 -58
- package/package.json +7 -4
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
"""The topolo-agent-pack command interface and recoverable composition transactions."""
|
|
2
|
+
import argparse
|
|
3
|
+
import copy
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import pathlib
|
|
8
|
+
import selectors
|
|
9
|
+
import shutil
|
|
10
|
+
import sqlite3
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import tempfile
|
|
14
|
+
import time
|
|
15
|
+
import uuid
|
|
16
|
+
|
|
17
|
+
from . import __version__
|
|
18
|
+
from . import native, live, fresh, blueprint, execution
|
|
19
|
+
from .storage import Library, PackError, atomic_json, canonical, component_key, now, read_json, references, safe_id, select, sync_tree
|
|
20
|
+
|
|
21
|
+
def emit(value):
|
|
22
|
+
print(json.dumps(value, indent=2, ensure_ascii=False))
|
|
23
|
+
|
|
24
|
+
def source_components(manifest, revision, selected):
|
|
25
|
+
return [{'key':component_key(a),'sourcePack':manifest['packId'],'revision':revision,'originPack':manifest['packId'],'originRevision':revision,'threadId':a['threadId']} for a in selected]
|
|
26
|
+
|
|
27
|
+
def make_plan(lib, args):
|
|
28
|
+
active = lib.active(); existing = active.get('components',[])
|
|
29
|
+
saved=read_json(lib.root/'workbenches'/(safe_id(args.source)+'.json'),{})
|
|
30
|
+
resume=bool(args.action=='switch' and saved and not getattr(args,'fresh',False))
|
|
31
|
+
if resume:
|
|
32
|
+
candidates=[]
|
|
33
|
+
for c in saved['components']:
|
|
34
|
+
m,_=lib.load(c['sourcePack'],c['revision'])
|
|
35
|
+
candidates.extend(a for a in m['agents'] if component_key(a)==c['key'])
|
|
36
|
+
manifest={'agents':candidates,'warnings':[]}
|
|
37
|
+
chosen=select(manifest,args.sets,args.agents)
|
|
38
|
+
incoming=[c for c in saved['components'] if c['key'] in {component_key(a) for a in chosen}]
|
|
39
|
+
revision='saved-workbench'
|
|
40
|
+
else:
|
|
41
|
+
manifest, revision = lib.load(args.source)
|
|
42
|
+
chosen = select(manifest, args.sets, args.agents)
|
|
43
|
+
incoming = source_components(manifest,revision,chosen)
|
|
44
|
+
if args.action=='switch' and active.get('name')==args.source and not args.sets and not args.agents and not getattr(args,'fresh',False):
|
|
45
|
+
return {'noChange':True,'before':active}
|
|
46
|
+
before = {c['key']:c for c in existing}
|
|
47
|
+
selected_keys = {c['key'] for c in incoming}
|
|
48
|
+
remove = set()
|
|
49
|
+
if args.action == 'switch': remove = set(before)
|
|
50
|
+
elif args.action == 'replace':
|
|
51
|
+
target = getattr(args,'target',None)
|
|
52
|
+
if target:
|
|
53
|
+
parts = target.split('/')
|
|
54
|
+
if not 1 <= len(parts) <= 3: raise PackError('--target must be PACK[/SET[/AGENT]]')
|
|
55
|
+
for part in parts: safe_id(part)
|
|
56
|
+
remove = {k for k in before if k == target or k.startswith(target+'/')}
|
|
57
|
+
if not remove: raise PackError('Replacement target is not in the active composition')
|
|
58
|
+
if len(parts)==3 and len(incoming)!=1: raise PackError('An individual target accepts exactly one incoming agent')
|
|
59
|
+
if len(parts)==2 and len({a['set'] for a in chosen})!=1: raise PackError('A set target accepts exactly one incoming set')
|
|
60
|
+
elif args.agents:
|
|
61
|
+
remove=selected_keys
|
|
62
|
+
if not remove <= set(before): raise PackError('Replacement agent is not active; use add or supply --target.')
|
|
63
|
+
elif args.sets:
|
|
64
|
+
prefixes={a['pack']+'/'+a['set']+'/' for a in chosen}
|
|
65
|
+
remove={k for k in before if any(k.startswith(p) for p in prefixes)}
|
|
66
|
+
if any(not any(k.startswith(p) for k in before) for p in prefixes): raise PackError('Replacement set is not active; use add or supply --target.')
|
|
67
|
+
else: remove=set(before)
|
|
68
|
+
elif args.action != 'add': raise PackError('Unknown composition action')
|
|
69
|
+
remaining={k:v for k,v in before.items() if k not in remove}
|
|
70
|
+
added=[]
|
|
71
|
+
for c in incoming:
|
|
72
|
+
if c['key'] in remaining:
|
|
73
|
+
old=remaining[c['key']]
|
|
74
|
+
if old.get('originRevision',old['revision'])==c.get('originRevision',c['revision']) and old.get('originPack',old['sourcePack'])==c.get('originPack',c['sourcePack']): continue
|
|
75
|
+
raise PackError('add would overwrite an installed identity: '+c['key']+'. Use replace.')
|
|
76
|
+
remaining[c['key']]=c; added.append(c['key'])
|
|
77
|
+
components=list(remaining.values())
|
|
78
|
+
threads=[c['threadId'] for c in components]
|
|
79
|
+
if len(threads)!=len(set(threads)): raise PackError('Native thread identity collision across selected agents')
|
|
80
|
+
return {'operation':args.action,'source':args.source,'sourceRevision':revision,'before':active,
|
|
81
|
+
'components':components,'removed':sorted(remove),'added':added,'noChange':not remove and not added,
|
|
82
|
+
'name':args.source if args.action=='switch' or not existing else active.get('name',args.source),
|
|
83
|
+
'resumeRuntime':saved.get('runtime') if resume and not args.sets and not args.agents else None,
|
|
84
|
+
'reuseOutgoingRuntime':args.action=='switch' and not args.sets and not args.agents,
|
|
85
|
+
'runtimeCheckpointRequired':bool(existing),'warnings':manifest.get('warnings',[]),
|
|
86
|
+
'activation':'not tested by plan; --no-open commits storage only'}
|
|
87
|
+
|
|
88
|
+
def maps(values):
|
|
89
|
+
result={}
|
|
90
|
+
for value in values or []:
|
|
91
|
+
if '=' not in value: raise PackError('--map requires OLD=NEW')
|
|
92
|
+
old,new=value.split('=',1)
|
|
93
|
+
old=str(pathlib.Path(old).expanduser().resolve()); new=str(pathlib.Path(new).expanduser().resolve())
|
|
94
|
+
if not pathlib.Path(new).exists(): raise PackError('Mapped destination does not exist: '+new)
|
|
95
|
+
if old in result and result[old]!=new: raise PackError('Conflicting path mapping')
|
|
96
|
+
result[old]=new
|
|
97
|
+
return result
|
|
98
|
+
|
|
99
|
+
def verify_backend(lib, runtime, cli):
|
|
100
|
+
"""Read back through the installed native protocol; do not load/resume or start turns."""
|
|
101
|
+
if not cli: raise PackError('Native Codex binary not found; supply --codex-bin.')
|
|
102
|
+
cli=pathlib.Path(cli).expanduser().resolve()
|
|
103
|
+
home=pathlib.Path(runtime)/'home'
|
|
104
|
+
connection=native.ro(home/'state_5.sqlite')
|
|
105
|
+
try: expected_cwds=dict(connection.execute('SELECT id,cwd FROM threads'))
|
|
106
|
+
finally: connection.close()
|
|
107
|
+
stderr=open(pathlib.Path(runtime)/'backend-verify.log','a')
|
|
108
|
+
env=os.environ.copy(); env['CODEX_HOME']=str(home)
|
|
109
|
+
for key in list(env):
|
|
110
|
+
if key.startswith('CODEX_APP_TOOLS_'): del env[key]
|
|
111
|
+
proc=subprocess.Popen([str(cli),'app-server','--listen','stdio://'],cwd=runtime,env=env,stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=stderr,bufsize=0)
|
|
112
|
+
sel=selectors.DefaultSelector(); sel.register(proc.stdout,selectors.EVENT_READ)
|
|
113
|
+
seq=0; buffer=b''
|
|
114
|
+
def request(method,params):
|
|
115
|
+
nonlocal seq,buffer
|
|
116
|
+
seq+=1; proc.stdin.write(canonical({'id':seq,'method':method,'params':params})+b'\n'); proc.stdin.flush()
|
|
117
|
+
deadline=time.monotonic()+20
|
|
118
|
+
while time.monotonic()<deadline:
|
|
119
|
+
while b'\n' in buffer:
|
|
120
|
+
line,buffer=buffer.split(b'\n',1)
|
|
121
|
+
if not line.strip(): continue
|
|
122
|
+
value=json.loads(line)
|
|
123
|
+
if value.get('id')==seq:
|
|
124
|
+
if 'error' in value: raise PackError('Native protocol '+method+': '+value['error']['message'])
|
|
125
|
+
return value['result']
|
|
126
|
+
if not sel.select(max(0,deadline-time.monotonic())): break
|
|
127
|
+
chunk=os.read(proc.stdout.fileno(),65536)
|
|
128
|
+
if not chunk: raise PackError('Native verification process exited')
|
|
129
|
+
buffer+=chunk
|
|
130
|
+
raise PackError('Native read-back timed out: '+method)
|
|
131
|
+
checked=[]
|
|
132
|
+
try:
|
|
133
|
+
init=request('initialize',{'clientInfo':{'name':'topolo_agent_pack','version':__version__},'capabilities':{'experimentalApi':True}})
|
|
134
|
+
proc.stdin.write(canonical({'method':'initialized'})+b'\n');proc.stdin.flush()
|
|
135
|
+
agents=read_json(pathlib.Path(runtime)/'agents.json')['agents']
|
|
136
|
+
for a in agents:
|
|
137
|
+
tid=a['threadId']; result=request('thread/read',{'threadId':tid,'includeTurns':False})
|
|
138
|
+
if result['thread']['id']!=tid: raise PackError('Native thread identity read-back mismatch')
|
|
139
|
+
expected=str(pathlib.Path(expected_cwds[tid]).resolve())
|
|
140
|
+
if str(pathlib.Path(result['thread']['cwd']).resolve())!=expected: raise PackError('Native working-directory read-back mismatch for '+tid+': expected '+expected+', got '+result['thread']['cwd'])
|
|
141
|
+
turns=request('thread/turns/list',{'threadId':tid,'limit':1,'itemsView':'notLoaded'})
|
|
142
|
+
items=request('thread/items/list',{'threadId':tid,'limit':1})
|
|
143
|
+
checked.append({'threadId':tid,'cwdVerified':True,'historyReadable':True,'sampledTurns':len(turns['data']),'sampledItems':len(items['data'])})
|
|
144
|
+
finally:
|
|
145
|
+
proc.terminate()
|
|
146
|
+
try: proc.wait(timeout=5)
|
|
147
|
+
except subprocess.TimeoutExpired: proc.kill(); proc.wait()
|
|
148
|
+
sel.close(); proc.stdin.close(); proc.stdout.close(); stderr.close()
|
|
149
|
+
report={'boundary':'native backend read-back; desktop rendering not verified','agents':checked,'modelTurnsStarted':False}
|
|
150
|
+
atomic_json(pathlib.Path(runtime)/'backend-proof.json',report)
|
|
151
|
+
return report
|
|
152
|
+
|
|
153
|
+
def desktop_open(runtime, app, user_data, codex_bin=None):
|
|
154
|
+
if sys.platform!='darwin': raise PackError('Desktop activation adapter currently supports macOS. Use --no-open for portable storage on this platform.')
|
|
155
|
+
app=pathlib.Path(app).expanduser().resolve()
|
|
156
|
+
if not app.is_dir(): raise PackError('Desktop application not found: '+str(app))
|
|
157
|
+
runtime=pathlib.Path(runtime); user_data=pathlib.Path(user_data); user_data.mkdir(exist_ok=True,mode=0o700)
|
|
158
|
+
lib=Library(user_data.parent);real=codex_bin or find_codex()
|
|
159
|
+
executable,home=live.prepare(lib,real)
|
|
160
|
+
subprocess.run(['/usr/bin/open','-n','--env','CODEX_HOME='+str(home),
|
|
161
|
+
'--env','CODEX_CLI_PATH='+str(executable),'--env','CODEX_PACK_HOME='+str(lib.root),
|
|
162
|
+
'--env','CODEX_PACK_REAL_CLI='+str(real),
|
|
163
|
+
'--env','CODEX_ELECTRON_USER_DATA_PATH='+str(user_data),str(app),
|
|
164
|
+
'--args','--user-data-dir='+str(user_data),'--remote-debugging-port=0','--remote-debugging-address=127.0.0.1','codex://launch'],check=True,timeout=20)
|
|
165
|
+
return {'state':'managed-window-launch-requested','app':str(app),'frontendVerified':False,'note':'One-time managed window launch. Future changes reconnect this same window. Authenticate locally if prompted.'}
|
|
166
|
+
|
|
167
|
+
def apply_plan(lib,plan,args):
|
|
168
|
+
if plan['noChange']:
|
|
169
|
+
active=lib.active()
|
|
170
|
+
if not args.no_open and not live.available(lib):
|
|
171
|
+
native.assert_idle(pathlib.Path(active['runtime'])/'home')
|
|
172
|
+
active['desktop']=desktop_open(active['runtime'],args.app,lib.root/'desktop-data',args.codex_bin)
|
|
173
|
+
active['state']=active['desktop']['state'];atomic_json(lib.root/'active.json',active)
|
|
174
|
+
return {'state':active['state'],'active':active}
|
|
175
|
+
return {'state':'unchanged','active':active}
|
|
176
|
+
if lib.pending(): raise PackError('An incomplete operation needs recovery. Run topolo-agent-pack recover before another mutation.')
|
|
177
|
+
before=plan['before']; old_runtime=pathlib.Path(before['runtime']) if before.get('runtime') else None
|
|
178
|
+
bridge=live.available(lib)
|
|
179
|
+
if bridge:
|
|
180
|
+
if not old_runtime or bridge['runtime']!=str(old_runtime): raise PackError('Live bridge and active runtime disagree; run recover before switching')
|
|
181
|
+
live.adapter(args.app)
|
|
182
|
+
ws,target=live.renderer(lib);ws.close()
|
|
183
|
+
elif old_runtime: native.assert_idle(old_runtime/'home')
|
|
184
|
+
tx=uuid.uuid4().hex; runtime=lib.root/'runtimes'/tx
|
|
185
|
+
journal={'id':tx,'phase':'started','createdAt':now(),'desktopApp':args.app,'before':before,'operation':plan['operation'],'targetRuntime':str(runtime),'plan':{k:v for k,v in plan.items() if k!='before'}}
|
|
186
|
+
jpath=lib.root/'journals'/(tx+'.json'); atomic_json(jpath,journal)
|
|
187
|
+
try:
|
|
188
|
+
if bridge:
|
|
189
|
+
journal['liveBridge']={'targetId':target,'beforePid':bridge['pid'],'desktopPid':bridge['desktopPid']};atomic_json(jpath,journal)
|
|
190
|
+
try:live.quiesce(lib)
|
|
191
|
+
except Exception:
|
|
192
|
+
current=live.available(lib)
|
|
193
|
+
if current and not current['quiesced']:journal['phase']='rolled-back'
|
|
194
|
+
raise
|
|
195
|
+
journal['phase']='quiesced';atomic_json(jpath,journal)
|
|
196
|
+
if old_runtime: native.assert_idle(old_runtime/'home')
|
|
197
|
+
if bridge:live.checkpoint_ui(lib,old_runtime)
|
|
198
|
+
if old_runtime and not plan.get('reuseOutgoingRuntime'):
|
|
199
|
+
known={a['threadId'] for a in read_json(old_runtime/'agents.json')['agents']}
|
|
200
|
+
if native.unregistered_threads(old_runtime/'home',known):
|
|
201
|
+
raise PackError('Additional independent threads need registration before partial composition. Run capture NAME --all, then switch NAME --fresh. Whole-pack switches retain these threads in their runtime.')
|
|
202
|
+
checkpoint_components=copy.deepcopy(before.get('components',[]))
|
|
203
|
+
if old_runtime:
|
|
204
|
+
if plan.get('reuseOutgoingRuntime'):
|
|
205
|
+
# The complete stopped runtime already lives in the independent
|
|
206
|
+
# pack library. Whole-pack switching can retain it in place.
|
|
207
|
+
journal['checkpoint']={'mode':'retained-runtime','runtime':str(old_runtime),'components':checkpoint_components}
|
|
208
|
+
else:
|
|
209
|
+
checkpoint_name='checkpoint-'+tx
|
|
210
|
+
snapshot,revision=native.capture(old_runtime/'home',lib,checkpoint_name,all_threads=True,template=read_json(old_runtime/'agents.json'))
|
|
211
|
+
checkpoint_components=[{**c,'sourcePack':checkpoint_name,'revision':revision} for c in checkpoint_components]
|
|
212
|
+
journal['checkpoint']={'mode':'immutable-snapshot','pack':checkpoint_name,'revision':revision,'components':checkpoint_components}
|
|
213
|
+
journal['phase']='checkpointed';atomic_json(jpath,journal)
|
|
214
|
+
if before.get('name'):
|
|
215
|
+
atomic_json(lib.root/'workbenches'/(safe_id(before['name'])+'.json'),{**before,'components':checkpoint_components})
|
|
216
|
+
checkpoint_by_key={c['key']:c for c in checkpoint_components}
|
|
217
|
+
# Unchanged agents are taken from the current installed state, never the old source template.
|
|
218
|
+
added=set(plan['added'])
|
|
219
|
+
target_components=[checkpoint_by_key.get(c['key'],c) if c['key'] not in added else c for c in plan['components']]
|
|
220
|
+
if plan.get('resumeRuntime'):
|
|
221
|
+
runtime=pathlib.Path(plan['resumeRuntime']);native.assert_idle(runtime/'home')
|
|
222
|
+
if args.mappings: raise PackError('Saved workbench paths are already bound. Use --fresh with --map to rebuild.')
|
|
223
|
+
proof=read_json(runtime/'verification.json')
|
|
224
|
+
journal['targetRuntime']=str(runtime)
|
|
225
|
+
else:
|
|
226
|
+
proof=native.materialize(lib,target_components,runtime,maps(args.mappings))
|
|
227
|
+
atomic_json(runtime/'composition.json',target_components)
|
|
228
|
+
# Authentication and local tool configuration are machine-local and
|
|
229
|
+
# deliberately kept out of portable manifests and export bundles.
|
|
230
|
+
if old_runtime and (old_runtime/'home'/'auth.json').exists():
|
|
231
|
+
shutil.copyfile(old_runtime/'home'/'auth.json',runtime/'home'/'auth.json')
|
|
232
|
+
(runtime/'home'/'auth.json').chmod(0o600)
|
|
233
|
+
local_config=lib.root/'local-config.toml'
|
|
234
|
+
if local_config.exists():
|
|
235
|
+
shutil.copyfile(local_config,runtime/'home'/'config.toml');(runtime/'home'/'config.toml').chmod(0o600)
|
|
236
|
+
if old_runtime and old_runtime!=runtime and (old_runtime/'desktop-view.json').exists():shutil.copyfile(old_runtime/'desktop-view.json',runtime/'desktop-view.json')
|
|
237
|
+
execution.compile_runtime(lib, target_components, runtime)
|
|
238
|
+
journal['phase']='staged';atomic_json(jpath,journal)
|
|
239
|
+
if old_runtime and old_runtime!=runtime and (old_runtime/'home/auth.json').exists():
|
|
240
|
+
shutil.copyfile(old_runtime/'home/auth.json',runtime/'home/auth.json');(runtime/'home/auth.json').chmod(0o600)
|
|
241
|
+
backend=verify_backend(lib,runtime,args.codex_bin)
|
|
242
|
+
if not plan.get('resumeRuntime'): sync_tree(runtime)
|
|
243
|
+
if old_runtime: native.assert_idle(old_runtime/'home')
|
|
244
|
+
if lib.active()!=before: raise PackError('Active composition changed concurrently; refusing to overwrite it.')
|
|
245
|
+
after={'name':plan['name'],'runtime':str(runtime),'components':target_components,'selectedAt':now(),'state':'backend-verified','desktop':{'state':'not-opened','frontendVerified':False},'transaction':tx}
|
|
246
|
+
journal['after']=after;journal['phase']='verified';atomic_json(jpath,journal)
|
|
247
|
+
atomic_json(lib.root/'active.json',after)
|
|
248
|
+
journal['phase']='committed';atomic_json(jpath,journal)
|
|
249
|
+
if bridge or not args.no_open:
|
|
250
|
+
after['desktop']=live.reconnect(lib,runtime,old_runtime,args.app) if bridge else desktop_open(runtime,args.app,lib.root/'desktop-data',args.codex_bin)
|
|
251
|
+
after['state']=after['desktop']['state'];atomic_json(lib.root/'active.json',after)
|
|
252
|
+
journal['after']=after;journal['phase']='complete';atomic_json(jpath,journal)
|
|
253
|
+
return {'state':after['state'],'agents':proof['agents'],'totalHistoryThreads':proof['threads'],'runtime':str(runtime),'checkpoint':journal.get('checkpoint'),
|
|
254
|
+
'desktop':after['desktop'],'backend':backend,'dependencies':proof['dependencies'],'activeGoalsPaused':proof['activeGoalsPaused']}
|
|
255
|
+
except Exception as exc:
|
|
256
|
+
journal['failure']=str(exc);atomic_json(jpath,journal)
|
|
257
|
+
if bridge and live.available(lib) and live.available(lib).get('quiesced'):
|
|
258
|
+
try:
|
|
259
|
+
current=lib.active();journal['reconnectAfterFailure']=live.reconnect(lib,current['runtime'],old_runtime,args.app);atomic_json(jpath,journal)
|
|
260
|
+
except Exception as reconnect_error:
|
|
261
|
+
journal['reconnectFailure']=str(reconnect_error);atomic_json(jpath,journal)
|
|
262
|
+
raise
|
|
263
|
+
|
|
264
|
+
def recover(lib):
|
|
265
|
+
result=[]
|
|
266
|
+
for path in sorted(lib.pending()):
|
|
267
|
+
j=read_json(path); active=lib.active(); before=j['before']; after=j.get('after')
|
|
268
|
+
previous=(active.get('runtime')==before.get('runtime') and active.get('transaction')==before.get('transaction') and active.get('components')==before.get('components'))
|
|
269
|
+
committed=bool(after and active.get('transaction')==j['id'] and active.get('runtime')==after['runtime'] and active.get('components')==after.get('components'))
|
|
270
|
+
if not previous and not committed:raise PackError('Recovery found a concurrent active-pointer change; no state overwritten. Inspect '+str(path))
|
|
271
|
+
if j.get('liveBridge'):
|
|
272
|
+
j['desktopRecovery']=live.reconnect(lib,active['runtime'],before.get('runtime'),j.get('desktopApp','/Applications/ChatGPT.app'))
|
|
273
|
+
active['desktop']=j['desktopRecovery'];active['state']=active['desktop']['state'];atomic_json(lib.root/'active.json',active)
|
|
274
|
+
if previous:
|
|
275
|
+
j['phase']='rolled-back';j['recovery']='Previous active pointer retained; staged files kept for inspection.'
|
|
276
|
+
elif after and active.get('transaction')==j['id'] and active.get('runtime')==after['runtime']:
|
|
277
|
+
if not j.get('liveBridge'): native.assert_idle(pathlib.Path(active['runtime'])/'home')
|
|
278
|
+
if not (pathlib.Path(active['runtime'])/'backend-proof.json').exists(): raise PackError('Committed runtime lacks backend proof; recovery needs inspection.')
|
|
279
|
+
j['phase']='complete';j['recovery']='Verified committed runtime retained.'+(' Same window reconnected and read back.' if j.get('liveBridge') else ' Desktop activation was not replayed.')
|
|
280
|
+
else: raise PackError('Recovery found a concurrent active-pointer change; no state overwritten. Inspect '+str(path))
|
|
281
|
+
atomic_json(path,j);result.append({'transaction':j['id'],'phase':j['phase'],'action':j['recovery']})
|
|
282
|
+
return {'recovered':result,'active':lib.active()}
|
|
283
|
+
|
|
284
|
+
def find_codex():
|
|
285
|
+
bundled=pathlib.Path('/Applications/ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/Contents/MacOS/codex')
|
|
286
|
+
return str(bundled) if bundled.exists() else shutil.which('codex')
|
|
287
|
+
|
|
288
|
+
def parser():
|
|
289
|
+
p=argparse.ArgumentParser(description='Capture, install and compose portable Topolo Agent Packs. No cloud service or API key required for storage operations.')
|
|
290
|
+
p.add_argument('--version',action='version',version=__version__)
|
|
291
|
+
p.add_argument('--store',default=os.environ.get('CODEX_PACK_HOME',str(pathlib.Path.home()/'AgentPacks')))
|
|
292
|
+
sub=p.add_subparsers(dest='command',required=True)
|
|
293
|
+
def selection(q):
|
|
294
|
+
q.add_argument('--set',dest='sets',action='append');q.add_argument('--agent',dest='agents',action='append')
|
|
295
|
+
def activation(q):
|
|
296
|
+
q.add_argument('--no-open',action='store_true',help='Commit a backend-verified composition without opening the desktop')
|
|
297
|
+
q.add_argument('--map',dest='mappings',action='append',help='External path binding OLD=NEW (never overwrites the external destination)')
|
|
298
|
+
q.add_argument('--codex-bin',default=find_codex());q.add_argument('--app',default='/Applications/ChatGPT.app')
|
|
299
|
+
cap=sub.add_parser('capture');cap.add_argument('name');selection(cap)
|
|
300
|
+
cap.add_argument('--source-home',help='Source Codex home. Defaults to the selected managed runtime, otherwise CODEX_HOME or ~/.codex')
|
|
301
|
+
cap.add_argument('--include',action='append',help='Explicit additional workflow/asset path')
|
|
302
|
+
cap.add_argument('--all',action='store_true',help='Explicitly capture all non-archived threads when group names do not identify the pack')
|
|
303
|
+
cap.add_argument('--workspace-mode',choices=['copy','reference'],default='copy')
|
|
304
|
+
cap.add_argument('--offline',action='store_true',help='Require an idle source instead of taking a read-only live snapshot')
|
|
305
|
+
q=sub.add_parser('context',help='Extract operating context and conversation rule candidates for review');q.add_argument('source');q.add_argument('directory');selection(q)
|
|
306
|
+
q=sub.add_parser('fresh',help='Create new native agents from reviewed file-backed context, without old work history');q.add_argument('source');q.add_argument('name');q.add_argument('--context',required=True);selection(q);q.add_argument('--codex-bin',default=find_codex())
|
|
307
|
+
q=sub.add_parser('build',help='Build a new native instance from a portable authored template; no source pack required');q.add_argument('name');q.add_argument('--template',required=True);selection(q);q.add_argument('--codex-bin',default=find_codex())
|
|
308
|
+
install=sub.add_parser('install');install.add_argument('bundle');selection(install)
|
|
309
|
+
export=sub.add_parser('export');export.add_argument('name');export.add_argument('destination')
|
|
310
|
+
for action in ['add','replace','switch']:
|
|
311
|
+
q=sub.add_parser(action);q.add_argument('source');selection(q);activation(q)
|
|
312
|
+
q.set_defaults(action=action)
|
|
313
|
+
if action=='add': q.add_argument('--catalog',action='store_true',help='Build an uninstalled bundled catalog pack before adding it')
|
|
314
|
+
if action=='replace': q.add_argument('--target')
|
|
315
|
+
if action=='switch': q.add_argument('--fresh',action='store_true',help='Use the captured source instead of resuming a previously saved workbench')
|
|
316
|
+
q=sub.add_parser('plan');q.add_argument('action',choices=['add','replace','switch']);q.add_argument('source');selection(q);q.add_argument('--target');q.add_argument('--fresh',action='store_true')
|
|
317
|
+
q=sub.add_parser('policy',help='Render portable role settings for review; never changes live sessions')
|
|
318
|
+
q.add_argument('source');selection(q);q.add_argument('--output')
|
|
319
|
+
for command in ['connect','remote-context','remember','artifact','artifact-get','remote-recover']:
|
|
320
|
+
q=sub.add_parser(command,help='Use public Topolo Agent continuity and Bytes artifact contracts')
|
|
321
|
+
q.add_argument('name');q.add_argument('--environment',choices=['development','staging','production'],required=True)
|
|
322
|
+
q.add_argument('--context-key',required=True);q.add_argument('--role',required=command not in ['connect','remote-recover'])
|
|
323
|
+
if command in ['connect','remember','artifact']:q.add_argument('--confirm',action='store_true',required=True)
|
|
324
|
+
if command=='connect':
|
|
325
|
+
q.add_argument('--roles',required=True);q.add_argument('--agent-workspace',required=True);q.add_argument('--bytes-workspace',required=True)
|
|
326
|
+
q.add_argument('--ownership',choices=['user','organization'],required=True);q.add_argument('--bytes-scope',choices=['private','org'],required=True);q.add_argument('--instance')
|
|
327
|
+
if command=='remember':
|
|
328
|
+
q.add_argument('--content',required=True);q.add_argument('--kind',choices=['instruction','knowledge','durable'],required=True)
|
|
329
|
+
q.add_argument('--provenance',required=True);q.add_argument('--visibility',choices=['private','shared'],default='private')
|
|
330
|
+
q.add_argument('--sharing-policy');q.add_argument('--expires-at',type=int)
|
|
331
|
+
if command=='artifact':
|
|
332
|
+
q.add_argument('--file',required=True);q.add_argument('--content-type',default='application/octet-stream')
|
|
333
|
+
q.add_argument('--visibility',choices=['private','shared'],default='private')
|
|
334
|
+
if command=='artifact-get':q.add_argument('--reference',required=True);q.add_argument('--destination',required=True)
|
|
335
|
+
sub.add_parser('status');q=sub.add_parser('list');q.add_argument('name',nargs='?');sub.add_parser('recover')
|
|
336
|
+
q=sub.add_parser('open');q.add_argument('--codex-bin',default=find_codex());q.add_argument('--app',default='/Applications/ChatGPT.app')
|
|
337
|
+
q=sub.add_parser('doctor');q.add_argument('name',nargs='?');q.add_argument('--codex-bin',default=find_codex())
|
|
338
|
+
return p
|
|
339
|
+
|
|
340
|
+
def main(argv=None):
|
|
341
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
342
|
+
harness = 'codex'
|
|
343
|
+
explicit_harness = '--harness' in argv
|
|
344
|
+
if '--harness' in argv:
|
|
345
|
+
at = argv.index('--harness')
|
|
346
|
+
if at + 1 >= len(argv):
|
|
347
|
+
parser().error('--harness requires codex or claude')
|
|
348
|
+
harness = argv[at + 1]
|
|
349
|
+
del argv[at:at + 2]
|
|
350
|
+
policy_command = 'policy' in argv
|
|
351
|
+
if harness == 'claude' and not policy_command:
|
|
352
|
+
from . import claude
|
|
353
|
+
return claude.main(argv)
|
|
354
|
+
if harness not in {'codex', 'claude'}:
|
|
355
|
+
print('topolo-agent-pack: Unsupported harness: ' + harness, file=sys.stderr)
|
|
356
|
+
return 2
|
|
357
|
+
args=parser().parse_args(argv)
|
|
358
|
+
try:
|
|
359
|
+
lib=Library(args.store)
|
|
360
|
+
if args.command in {'connect','remote-context','remember','artifact','artifact-get','remote-recover'}:
|
|
361
|
+
from . import topolo
|
|
362
|
+
with topolo.connection_lock(lib,args):result=topolo.dispatch(lib,args)
|
|
363
|
+
emit(result)
|
|
364
|
+
return 0
|
|
365
|
+
with lib.lock():
|
|
366
|
+
if args.command=='policy':
|
|
367
|
+
from . import policy
|
|
368
|
+
manifest,revision=lib.load(args.source)
|
|
369
|
+
result=policy.render(manifest,revision,select(manifest,args.sets,args.agents),
|
|
370
|
+
(harness,) if explicit_harness else ('codex','claude'))
|
|
371
|
+
if args.output:
|
|
372
|
+
from .claude import write_new
|
|
373
|
+
write_new(pathlib.Path(args.output).expanduser().absolute(), canonical(result)+b'\n')
|
|
374
|
+
elif args.command=='capture':
|
|
375
|
+
safe_id(args.name)
|
|
376
|
+
active=lib.active();template=None
|
|
377
|
+
if args.source_home: home=pathlib.Path(args.source_home)
|
|
378
|
+
elif active.get('runtime'):
|
|
379
|
+
home=pathlib.Path(active['runtime'])/'home';template=read_json(pathlib.Path(active['runtime'])/'agents.json')
|
|
380
|
+
else: home=pathlib.Path(os.environ.get('CODEX_HOME',str(pathlib.Path.home()/'.codex')))
|
|
381
|
+
manifest,revision=native.capture(home,lib,args.name,args.sets,args.agents,args.include,args.all,template,args.workspace_mode,live=not args.offline)
|
|
382
|
+
result={'pack':args.name,'revision':revision,'agents':len(manifest['agents']),'historyThreads':len(manifest['threads']),'warnings':manifest['warnings'],'state':'captured'}
|
|
383
|
+
elif args.command=='context':result=fresh.prepare(lib,args.source,args.directory,args.sets,args.agents)
|
|
384
|
+
elif args.command=='fresh':result=fresh.create(lib,args.source,args.name,args.context,args.codex_bin,args.sets,args.agents)
|
|
385
|
+
elif args.command=='build':result=blueprint.build(lib,args.name,args.template,args.codex_bin,args.sets,args.agents)
|
|
386
|
+
elif args.command=='install':
|
|
387
|
+
manifest,revision=lib.install(args.bundle,publish=False)
|
|
388
|
+
selected=select(manifest,args.sets,args.agents)
|
|
389
|
+
if args.sets or args.agents:
|
|
390
|
+
manifest=native.slice_manifest(lib,manifest,selected)
|
|
391
|
+
revision=lib.save(manifest)
|
|
392
|
+
result={'state':'installed-not-activated','pack':manifest['packId'],'revision':revision,'agents':len(selected),'warnings':manifest['warnings']}
|
|
393
|
+
elif args.command=='export': result=lib.export(args.name,args.destination)
|
|
394
|
+
elif args.command=='list':
|
|
395
|
+
if args.name:
|
|
396
|
+
manifest,revision=lib.load(args.name)
|
|
397
|
+
result={'pack':args.name,'revision':revision,'sets':sorted({a['set'] for a in manifest['agents']}),
|
|
398
|
+
'agents':[{'key':component_key(a),'title':a['title'],'aliases':a.get('aliases',[])} for a in manifest['agents']]}
|
|
399
|
+
else:
|
|
400
|
+
result={'packs':[{'name':path.parent.name,'revision':read_json(path)['revision']} for path in sorted((lib.root/'packs').glob('*/head.json')) if not path.parent.name.startswith('checkpoint-')],
|
|
401
|
+
'workbenches':[p.stem for p in sorted((lib.root/'workbenches').glob('*.json'))]}
|
|
402
|
+
elif args.command=='plan': result=make_plan(lib,args)
|
|
403
|
+
elif args.command in {'add','replace','switch'}:
|
|
404
|
+
if args.command=='add' and args.catalog and not (lib.root/'packs'/safe_id(args.source)/'head.json').exists():
|
|
405
|
+
template=pathlib.Path(__file__).resolve().parents[2]/'templates'/safe_id(args.source)
|
|
406
|
+
if not template.is_dir():raise PackError('Unknown catalog pack: '+args.source)
|
|
407
|
+
blueprint.build(lib,args.source,template,args.codex_bin)
|
|
408
|
+
result=apply_plan(lib,make_plan(lib,args),args)
|
|
409
|
+
elif args.command=='recover': result=recover(lib)
|
|
410
|
+
elif args.command=='open':
|
|
411
|
+
active=lib.active()
|
|
412
|
+
if not active.get('runtime'):raise PackError('Select a pack first with switch NAME')
|
|
413
|
+
if live.available(lib):result={'state':'already-open','liveBridge':live.available(lib)}
|
|
414
|
+
else:
|
|
415
|
+
native.assert_idle(pathlib.Path(active['runtime'])/'home')
|
|
416
|
+
result=desktop_open(active['runtime'],args.app,lib.root/'desktop-data',args.codex_bin)
|
|
417
|
+
active['desktop']=result;active['state']=result['state'];atomic_json(lib.root/'active.json',active)
|
|
418
|
+
elif args.command=='status':
|
|
419
|
+
active=lib.active();pids=native.busy_writers(pathlib.Path(active['runtime'])/'home') if active.get('runtime') else []
|
|
420
|
+
result={'store':str(lib.root),'active':active,'liveBridge':live.available(lib),'runtimeDatabaseHolders':pids,'recoveryRequired':[str(p) for p in lib.pending()],'frontendVerified':active.get('desktop',{}).get('frontendVerified',False)}
|
|
421
|
+
elif args.command=='doctor':
|
|
422
|
+
checked=[]
|
|
423
|
+
if args.name:
|
|
424
|
+
manifest,revision=lib.load(args.name)
|
|
425
|
+
for ref in references(manifest).values(): lib.object(ref)
|
|
426
|
+
checked.append({'pack':args.name,'revision':revision,'objectsVerified':len(references(manifest))})
|
|
427
|
+
result={'library':str(lib.root),'nativeCodex':args.codex_bin,'desktopAdapter':'macOS managed backend proxy and same-window reconnect','liveBridge':live.available(lib),
|
|
428
|
+
'recoveryRequired':bool(lib.pending()),'checked':checked,'version':__version__}
|
|
429
|
+
emit(result)
|
|
430
|
+
return 0
|
|
431
|
+
except (PackError,OSError,ValueError,sqlite_error(),subprocess.SubprocessError,zip_error()) as exc:
|
|
432
|
+
print('topolo-agent-pack: '+str(exc),file=sys.stderr)
|
|
433
|
+
return 2
|
|
434
|
+
|
|
435
|
+
def sqlite_error():
|
|
436
|
+
import sqlite3
|
|
437
|
+
return sqlite3.Error
|
|
438
|
+
|
|
439
|
+
def zip_error():
|
|
440
|
+
import zipfile
|
|
441
|
+
return zipfile.BadZipFile
|
|
442
|
+
|
|
443
|
+
if __name__=='__main__': raise SystemExit(main())
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Per-role defaults for the owned Codex proxy, with explicit native read-back.
|
|
2
|
+
|
|
3
|
+
No model turns are created here. A manifest is intent, never activation proof.
|
|
4
|
+
Conflicting frontend settings are rejected instead of silently broadening the
|
|
5
|
+
reviewed role. Changing that intent requires rebuilding the selected policy.
|
|
6
|
+
"""
|
|
7
|
+
import copy
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
import pathlib
|
|
11
|
+
import sqlite3
|
|
12
|
+
import subprocess
|
|
13
|
+
import tempfile
|
|
14
|
+
from contextlib import closing
|
|
15
|
+
|
|
16
|
+
from . import policy
|
|
17
|
+
from .storage import PackError, atomic_json, component_key, read_json
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def compile_runtime(lib, components, runtime):
|
|
21
|
+
runtime = pathlib.Path(runtime)
|
|
22
|
+
with closing(sqlite3.connect('file:' + str(runtime / 'home/state_5.sqlite') + '?mode=ro', uri=True)) as db:
|
|
23
|
+
cwds = dict(db.execute('SELECT id,cwd FROM threads'))
|
|
24
|
+
rows = []
|
|
25
|
+
for component in components:
|
|
26
|
+
manifest, revision = lib.load(component['sourcePack'], component['revision'])
|
|
27
|
+
matches = [a for a in manifest['agents'] if component_key(a) == component['key'] and a['threadId'] == component['threadId']]
|
|
28
|
+
if len(matches) != 1 or component['threadId'] not in cwds:
|
|
29
|
+
raise PackError('Execution policy has no unique owned role: ' + component['key'])
|
|
30
|
+
rows.append({'key': component['key'], 'threadId': component['threadId'],
|
|
31
|
+
'sourceRevision': revision, 'cwd': cwds[component['threadId']],
|
|
32
|
+
'settings': policy.settings(matches[0], 'codex')['nativeSettings']})
|
|
33
|
+
document = {'format': 'topolo-codex-runtime-execution', 'schemaVersion': 1,
|
|
34
|
+
'roles': rows, 'applied': False, 'modelTurnsStarted': False}
|
|
35
|
+
# Construct/validate fully before installing intent in a staged runtime.
|
|
36
|
+
ExecutionGate(document)
|
|
37
|
+
atomic_json(runtime / 'execution-policy.json', document)
|
|
38
|
+
return document
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ExecutionGate:
|
|
42
|
+
def __init__(self, document):
|
|
43
|
+
if document.get('format') != 'topolo-codex-runtime-execution' or document.get('schemaVersion') != 1:
|
|
44
|
+
raise PackError('Unqualified runtime execution policy')
|
|
45
|
+
self.roles = {}; self.pending = {}; self.verified = set(); self.catalog = {}
|
|
46
|
+
for role in document['roles']:
|
|
47
|
+
tid = role['threadId']; spec = role['settings']
|
|
48
|
+
if tid in self.roles or not pathlib.Path(role['cwd']).is_absolute():
|
|
49
|
+
raise PackError('Duplicate thread or non-absolute role workspace')
|
|
50
|
+
required = {'model', 'model_reasoning_effort', 'approval_policy', 'approvals_reviewer',
|
|
51
|
+
'sandbox_mode', 'sandbox_workspace_write', 'apps'}
|
|
52
|
+
if (set(spec) != required or not isinstance(spec['model'], str) or not spec['model'] or
|
|
53
|
+
spec['model_reasoning_effort'] not in {'medium', 'high'} or
|
|
54
|
+
spec['approval_policy'] != 'on-request' or spec['approvals_reviewer'] != 'user' or
|
|
55
|
+
spec['sandbox_mode'] not in {'read-only', 'workspace-write'} or
|
|
56
|
+
spec['sandbox_workspace_write'] != {'network_access': False, 'writable_roots': [],
|
|
57
|
+
'exclude_slash_tmp': True, 'exclude_tmpdir_env_var': True} or
|
|
58
|
+
spec['apps'] != {'_default': {'default_tools_approval_mode': 'writes'}}):
|
|
59
|
+
raise PackError('Unqualified role execution settings')
|
|
60
|
+
self.roles[tid] = copy.deepcopy(role)
|
|
61
|
+
|
|
62
|
+
def set_catalog(self, result):
|
|
63
|
+
self.catalog = {row['model']: {e['reasoningEffort'] for e in row['supportedReasoningEfforts']}
|
|
64
|
+
for row in result['data']}
|
|
65
|
+
|
|
66
|
+
def sandbox(self, role):
|
|
67
|
+
if role['settings']['sandbox_mode'] == 'read-only':
|
|
68
|
+
return {'type': 'readOnly', 'networkAccess': False}
|
|
69
|
+
return {'type': 'workspaceWrite', 'networkAccess': False, 'writableRoots': [role['cwd']],
|
|
70
|
+
'excludeSlashTmp': True, 'excludeTmpdirEnvVar': True}
|
|
71
|
+
|
|
72
|
+
def prepare(self, message):
|
|
73
|
+
value = copy.deepcopy(message); method = value.get('method'); params = value.get('params') or {}
|
|
74
|
+
tid = params.get('threadId'); role = self.roles.get(tid)
|
|
75
|
+
if not role or method not in {'thread/resume', 'turn/start'}:
|
|
76
|
+
return value
|
|
77
|
+
if 'id' not in value or str(value['id']) in self.pending:
|
|
78
|
+
raise PackError('Managed execution requires a unique request identity')
|
|
79
|
+
spec = role['settings']
|
|
80
|
+
if spec['model_reasoning_effort'] not in self.catalog.get(spec['model'], set()):
|
|
81
|
+
raise PackError('Role model/effort is absent from the native account catalog; no fallback selected')
|
|
82
|
+
if any(params.get(key) is not None for key in ['permissions', 'path', 'history', 'collaborationMode', 'environments', 'runtimeWorkspaceRoots']):
|
|
83
|
+
raise PackError('Frontend supplied unreviewed permission, identity or execution overrides')
|
|
84
|
+
wanted = {'cwd': role['cwd'], 'model': spec['model'], 'approvalPolicy': spec['approval_policy'],
|
|
85
|
+
'approvalsReviewer': spec['approvals_reviewer']}
|
|
86
|
+
if method == 'thread/resume':
|
|
87
|
+
wanted['sandbox'] = spec['sandbox_mode']
|
|
88
|
+
wanted['config'] = {'model_reasoning_effort': spec['model_reasoning_effort'],
|
|
89
|
+
'sandbox_workspace_write': spec['sandbox_workspace_write'], 'apps': spec['apps']}
|
|
90
|
+
self.verified.discard(tid)
|
|
91
|
+
else:
|
|
92
|
+
if tid not in self.verified:
|
|
93
|
+
raise PackError('Role needs a verified native resume before starting a turn')
|
|
94
|
+
wanted.update(effort=spec['model_reasoning_effort'], sandboxPolicy=self.sandbox(role))
|
|
95
|
+
for key, expected in wanted.items():
|
|
96
|
+
if params.get(key) is not None and params[key] != expected:
|
|
97
|
+
raise PackError('Frontend ' + key + ' conflicts with the reviewed role policy')
|
|
98
|
+
params[key] = copy.deepcopy(expected)
|
|
99
|
+
value['params'] = params
|
|
100
|
+
if method == 'thread/resume': self.pending[str(value['id'])] = tid
|
|
101
|
+
return value
|
|
102
|
+
|
|
103
|
+
def reply(self, value):
|
|
104
|
+
tid = self.pending.pop(str(value.get('id')), None)
|
|
105
|
+
if tid is None: return value
|
|
106
|
+
self.verified.discard(tid)
|
|
107
|
+
if 'error' in value: return value
|
|
108
|
+
role = self.roles[tid]; spec = role['settings']; result = value.get('result', {})
|
|
109
|
+
expected = {'model': spec['model'], 'cwd': role['cwd'], 'approvalPolicy': spec['approval_policy'],
|
|
110
|
+
'approvalsReviewer': spec['approvals_reviewer'], 'reasoningEffort': spec['model_reasoning_effort'],
|
|
111
|
+
'sandbox': self.sandbox(role)}
|
|
112
|
+
sandbox = result.get('sandbox', {})
|
|
113
|
+
# Native responses can omit false read-only networkAccess defaults.
|
|
114
|
+
actual = copy.deepcopy(result)
|
|
115
|
+
if sandbox.get('type') == 'readOnly': actual['sandbox'] = {'type': 'readOnly', 'networkAccess': sandbox.get('networkAccess', False)}
|
|
116
|
+
if result.get('thread', {}).get('id') != tid or any(actual.get(k) != v for k, v in expected.items()):
|
|
117
|
+
return {'id': value['id'], 'error': {'code': -32000,
|
|
118
|
+
'message': 'Native role settings differ from the reviewed policy; model work is blocked'}}
|
|
119
|
+
self.verified.add(tid)
|
|
120
|
+
return value
|
|
121
|
+
|
|
122
|
+
def status(self):
|
|
123
|
+
return {'configuredRoles': len(self.roles), 'nativeResumeVerifiedRoles': sorted(self.verified),
|
|
124
|
+
'requested': [{'key': r['key'], 'threadId': tid, 'settings': r['settings']} for tid, r in self.roles.items()],
|
|
125
|
+
'appToolApprovalReadbackVerified': False, 'modelIdentityResponseVerified': False}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def load_gate(runtime):
|
|
129
|
+
path = pathlib.Path(runtime) / 'execution-policy.json'
|
|
130
|
+
return ExecutionGate(read_json(path)) if path.exists() else None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def qualify_protocol(binary, run=subprocess.run):
|
|
134
|
+
"""Generate schema offline from this executable; never initialize an app server."""
|
|
135
|
+
required = {'ThreadResumeParams': {'threadId', 'model', 'approvalPolicy', 'approvalsReviewer', 'sandbox', 'config'},
|
|
136
|
+
'ThreadResumeResponse': {'thread', 'model', 'cwd', 'reasoningEffort', 'approvalPolicy', 'approvalsReviewer', 'sandbox'},
|
|
137
|
+
'TurnStartParams': {'threadId', 'model', 'effort', 'approvalPolicy', 'approvalsReviewer', 'sandboxPolicy'},
|
|
138
|
+
'ModelListParams': {'includeHidden', 'cursor'}, 'ModelListResponse': {'data', 'nextCursor'}}
|
|
139
|
+
with tempfile.TemporaryDirectory(prefix='topolo-codex-protocol-') as temp:
|
|
140
|
+
result = run([binary, 'app-server', 'generate-json-schema', '--experimental', '--out', temp],
|
|
141
|
+
capture_output=True, timeout=20, check=False)
|
|
142
|
+
if result.returncode:
|
|
143
|
+
raise PackError('Installed Codex protocol export refused; no backend started')
|
|
144
|
+
hashes = {}
|
|
145
|
+
for name, fields in required.items():
|
|
146
|
+
matches = list(pathlib.Path(temp).rglob(name + '.json'))
|
|
147
|
+
if len(matches) != 1:
|
|
148
|
+
raise PackError('Installed Codex protocol has no unique ' + name)
|
|
149
|
+
data = matches[0].read_bytes(); schema = json.loads(data)
|
|
150
|
+
if not fields <= set(schema.get('properties', {})):
|
|
151
|
+
raise PackError('Installed Codex protocol settings are incompatible: ' + name)
|
|
152
|
+
hashes[name] = hashlib.sha256(data).hexdigest()
|
|
153
|
+
return {'boundary': 'offline installed executable protocol export', 'schemas': hashes,
|
|
154
|
+
'modelTurnsStarted': False}
|