@topolo/cli 0.13.19 → 0.13.21

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.
Files changed (142) hide show
  1. package/agent-packs/README.md +421 -1
  2. package/agent-packs/docs/selected-artifacts.md +47 -0
  3. package/agent-packs/documentation/cloud-task-state.md +56 -0
  4. package/agent-packs/domain-contexts/README.md +25 -0
  5. package/agent-packs/domain-contexts/cicd.json +8 -0
  6. package/agent-packs/gui/feed-adapter.js +4 -0
  7. package/agent-packs/gui/index.html +9 -0
  8. package/agent-packs/gui/studio.css +14 -0
  9. package/agent-packs/gui/studio.js +121 -0
  10. package/agent-packs/gui/studio_server.py +355 -0
  11. package/agent-packs/roles/cicd/CLAUDE.md +24 -0
  12. package/agent-packs/specializations/cicd--cicd.json +5 -0
  13. package/agent-packs/specializations/cicd--pipeline-engineering.json +5 -0
  14. package/agent-packs/specializations/cicd--promotion-deployment.json +5 -0
  15. package/agent-packs/specializations/cicd--queue-recovery.json +5 -0
  16. package/agent-packs/specializations/cicd--release-verification.json +5 -0
  17. package/agent-packs/specializations/cicd--runner-capacity.json +5 -0
  18. package/agent-packs/specializations/intakes--engineering-02.json +5 -0
  19. package/agent-packs/specializations/intakes--engineering-03.json +5 -0
  20. package/agent-packs/specializations/intakes--engineering.json +5 -0
  21. package/agent-packs/specializations/intakes--operator-01.json +5 -0
  22. package/agent-packs/specializations/intakes--operator-02.json +5 -0
  23. package/agent-packs/specializations/operator-skills--audio-and-narration-specialist.json +5 -0
  24. package/agent-packs/specializations/operator-skills--brand-and-copywriting-specialist.json +5 -0
  25. package/agent-packs/specializations/operator-skills--capability-research-specialist.json +5 -0
  26. package/agent-packs/specializations/operator-skills--data-analysis-and-visualization.json +5 -0
  27. package/agent-packs/specializations/operator-skills--financial-evidence-specialist.json +5 -0
  28. package/agent-packs/specializations/operator-skills--operating-template-specialist.json +5 -0
  29. package/agent-packs/specializations/operator-skills--operator-skill-library.json +5 -0
  30. package/agent-packs/specializations/operator-skills--operator-skills.json +5 -0
  31. package/agent-packs/specializations/operator-skills--operator-workflow-evaluation.json +5 -0
  32. package/agent-packs/specializations/operator-skills--privacy-and-agreement-review.json +5 -0
  33. package/agent-packs/specializations/operator-skills--storytelling-specialist.json +5 -0
  34. package/agent-packs/specializations/operator-skills--ux-and-accessibility-specialist.json +5 -0
  35. package/agent-packs/specializations/operator-skills--video-production-specialist.json +5 -0
  36. package/agent-packs/specializations/operator-skills--visual-design-specialist.json +5 -0
  37. package/agent-packs/specializations/operator-skills--workflow-integration-specialist.json +5 -0
  38. package/agent-packs/specializations/operators--access-and-privacy.json +5 -0
  39. package/agent-packs/specializations/operators--accounting-evidence-review.json +5 -0
  40. package/agent-packs/specializations/operators--agreement-operations.json +5 -0
  41. package/agent-packs/specializations/operators--books-operator.json +5 -0
  42. package/agent-packs/specializations/operators--brand-operator.json +5 -0
  43. package/agent-packs/specializations/operators--commerce-operations.json +5 -0
  44. package/agent-packs/specializations/operators--communications-operator.json +5 -0
  45. package/agent-packs/specializations/operators--content-and-assets-operator.json +5 -0
  46. package/agent-packs/specializations/operators--customer-operations.json +5 -0
  47. package/agent-packs/specializations/operators--design-operator.json +5 -0
  48. package/agent-packs/specializations/operators--design-production-02.json +5 -0
  49. package/agent-packs/specializations/operators--design-production-03.json +5 -0
  50. package/agent-packs/specializations/operators--design-production-04.json +5 -0
  51. package/agent-packs/specializations/operators--device-and-feed-operator.json +5 -0
  52. package/agent-packs/specializations/operators--director-operator.json +5 -0
  53. package/agent-packs/specializations/operators--experience-review.json +5 -0
  54. package/agent-packs/specializations/operators--growth-operations.json +5 -0
  55. package/agent-packs/specializations/operators--learn-operator.json +5 -0
  56. package/agent-packs/specializations/operators--localization-operator.json +5 -0
  57. package/agent-packs/specializations/operators--operators.json +5 -0
  58. package/agent-packs/specializations/operators--pay-operator.json +5 -0
  59. package/agent-packs/specializations/operators--research-and-intelligence.json +5 -0
  60. package/agent-packs/specializations/operators--roadmap-operator.json +5 -0
  61. package/agent-packs/specializations/operators--social-content-operator.json +5 -0
  62. package/agent-packs/specializations/operators--talent-operations.json +5 -0
  63. package/agent-packs/specializations/operators--web-operator.json +5 -0
  64. package/agent-packs/specializations/operators--workflow-operator.json +5 -0
  65. package/agent-packs/specializations/specialists--adversarial-qa.json +5 -0
  66. package/agent-packs/specializations/specialists--backlog-dispatch.json +5 -0
  67. package/agent-packs/specializations/specialists--build-01.json +5 -0
  68. package/agent-packs/specializations/specialists--build-02.json +5 -0
  69. package/agent-packs/specializations/specialists--build-03.json +5 -0
  70. package/agent-packs/specializations/specialists--developer-environment-steward.json +5 -0
  71. package/agent-packs/specializations/specialists--documentation-knowledge-steward.json +5 -0
  72. package/agent-packs/specializations/specialists--execution-readiness.json +5 -0
  73. package/agent-packs/specializations/specialists--incident-triage.json +5 -0
  74. package/agent-packs/specializations/specialists--platform-package-steward.json +5 -0
  75. package/agent-packs/specializations/specialists--product-direction-01.json +5 -0
  76. package/agent-packs/specializations/specialists--product-direction-02.json +5 -0
  77. package/agent-packs/specializations/specialists--release-recovery.json +5 -0
  78. package/agent-packs/specializations/specialists--release-watch.json +5 -0
  79. package/agent-packs/specializations/specialists--roadmap-planning-01.json +5 -0
  80. package/agent-packs/specializations/specialists--roadmap-planning-02.json +5 -0
  81. package/agent-packs/specializations/specialists--security-permissions-review.json +5 -0
  82. package/agent-packs/specializations/specialists--system-architecture-01.json +5 -0
  83. package/agent-packs/specializations/specialists--system-architecture-02.json +5 -0
  84. package/agent-packs/specializations/specialists--translation.json +5 -0
  85. package/agent-packs/specializations/specialists--ux-consistency-review.json +5 -0
  86. package/agent-packs/specializations/specialists--workflow-improvement.json +5 -0
  87. package/agent-packs/specializations/specialists--worktree-retirement.json +5 -0
  88. package/agent-packs/src/topolo_agent_pack/cicd.py +101 -0
  89. package/agent-packs/src/topolo_agent_pack/claude.py +22 -7
  90. package/agent-packs/src/topolo_agent_pack/claude_activation.py +157 -0
  91. package/agent-packs/src/topolo_agent_pack/claude_completion_recovery.py +216 -0
  92. package/agent-packs/src/topolo_agent_pack/claude_desktop.py +181 -0
  93. package/agent-packs/src/topolo_agent_pack/claude_dispatch.py +240 -0
  94. package/agent-packs/src/topolo_agent_pack/claude_prompt.py +74 -0
  95. package/agent-packs/src/topolo_agent_pack/claude_runner.py +207 -0
  96. package/agent-packs/src/topolo_agent_pack/claude_service.py +397 -0
  97. package/agent-packs/src/topolo_agent_pack/cli.py +181 -16
  98. package/agent-packs/src/topolo_agent_pack/closed_ledger_fence.py +118 -0
  99. package/agent-packs/src/topolo_agent_pack/cloud_attach.py +128 -0
  100. package/agent-packs/src/topolo_agent_pack/cloud_cutover.py +134 -0
  101. package/agent-packs/src/topolo_agent_pack/cloud_delta.py +162 -0
  102. package/agent-packs/src/topolo_agent_pack/cloud_migrate.py +142 -0
  103. package/agent-packs/src/topolo_agent_pack/cloud_recovery.py +42 -0
  104. package/agent-packs/src/topolo_agent_pack/cloud_work.py +126 -0
  105. package/agent-packs/src/topolo_agent_pack/coordinator_activity.py +27 -0
  106. package/agent-packs/src/topolo_agent_pack/coordinator_context.py +91 -0
  107. package/agent-packs/src/topolo_agent_pack/coordinator_refresh.py +116 -0
  108. package/agent-packs/src/topolo_agent_pack/desktop_acceptance.py +45 -0
  109. package/agent-packs/src/topolo_agent_pack/desktop_launcher.py +114 -0
  110. package/agent-packs/src/topolo_agent_pack/domain_context.py +38 -0
  111. package/agent-packs/src/topolo_agent_pack/execution.py +103 -6
  112. package/agent-packs/src/topolo_agent_pack/frontdoor.py +23 -0
  113. package/agent-packs/src/topolo_agent_pack/linked_task_context.py +80 -0
  114. package/agent-packs/src/topolo_agent_pack/live.py +285 -18
  115. package/agent-packs/src/topolo_agent_pack/native.py +160 -4
  116. package/agent-packs/src/topolo_agent_pack/review_context.py +33 -0
  117. package/agent-packs/src/topolo_agent_pack/role_catalog.py +137 -0
  118. package/agent-packs/src/topolo_agent_pack/role_context.py +63 -0
  119. package/agent-packs/src/topolo_agent_pack/role_refresh.py +50 -0
  120. package/agent-packs/src/topolo_agent_pack/topolo.py +7 -1
  121. package/agent-packs/src/topolo_agent_pack/work.py +322 -0
  122. package/agent-packs/src/topolo_agent_pack/work_adoption.py +122 -0
  123. package/agent-packs/src/topolo_agent_pack/work_artifacts.py +84 -0
  124. package/agent-packs/src/topolo_agent_pack/work_context.py +77 -0
  125. package/agent-packs/src/topolo_agent_pack/work_gui.py +51 -0
  126. package/agent-packs/src/topolo_agent_pack/work_native.py +414 -0
  127. package/agent-packs/src/topolo_agent_pack/work_wait.py +151 -0
  128. package/agent-packs/templates/topolo-coordinators/AGENTS.md +7 -0
  129. package/agent-packs/templates/topolo-coordinators/agents/intakes/cicd/AGENTS.md +6 -0
  130. package/agent-packs/templates/topolo-coordinators/agents/intakes/cicd/config.json +4 -0
  131. package/agent-packs/templates/topolo-coordinators/agents/intakes/engineering/AGENTS.md +3 -0
  132. package/agent-packs/templates/topolo-coordinators/agents/intakes/engineering/config.json +4 -0
  133. package/agent-packs/templates/topolo-coordinators/agents/intakes/operator-01/AGENTS.md +3 -0
  134. package/agent-packs/templates/topolo-coordinators/agents/intakes/operator-01/config.json +4 -0
  135. package/agent-packs/templates/topolo-coordinators/pack.json +56 -0
  136. package/dist/chunk-NYDIAYVY.js +144 -0
  137. package/dist/{claude-pack-backend-YEVPAOID.js → chunk-UY5LEU5C.js} +1659 -21
  138. package/dist/claude-desktop-profile-JCQKSY3K.js +13 -0
  139. package/dist/claude-pack-activation-Q25MCEPU.js +506 -0
  140. package/dist/claude-pack-backend-HUTKGIDS.js +90 -0
  141. package/dist/index.js +89 -10
  142. package/package.json +4 -4
@@ -96,7 +96,7 @@ def maps(values):
96
96
  result[old]=new
97
97
  return result
98
98
 
99
- def verify_backend(lib, runtime, cli):
99
+ def verify_backend(lib, runtime, cli, presentation_journal=None, reconcile=None):
100
100
  """Read back through the installed native protocol; do not load/resume or start turns."""
101
101
  if not cli: raise PackError('Native Codex binary not found; supply --codex-bin.')
102
102
  cli=pathlib.Path(cli).expanduser().resolve()
@@ -129,9 +129,24 @@ def verify_backend(lib, runtime, cli):
129
129
  buffer+=chunk
130
130
  raise PackError('Native read-back timed out: '+method)
131
131
  checked=[]
132
+ account=None;sidebar=None
133
+ binary_hash=hashlib.sha256(cli.read_bytes()).hexdigest()
134
+ def current_context():
135
+ config=request('config/read',{'cwd':str(home),'includeLayers':True})
136
+ origins=[{'name':layer['name'],'version':layer['version']} for layer in config.get('layers',[])
137
+ if layer.get('name',{}).get('type')=='user' and not layer.get('disabledReason')]
138
+ if not origins or any(row['name'].get('file')!=str(home/'config.toml') or not isinstance(row['version'],str) or not row['version'] for row in origins):
139
+ raise PackError('Native verification execution-home provenance differs')
140
+ actual=request('account/read',{'refreshToken':False})
141
+ native.sidebar_binding(runtime,actual) # Validate the native schema without effects.
142
+ return {'kind':'offline','home':str(home),'binary':str(cli),'binarySHA256':binary_hash,
143
+ 'configOrigins':origins,'account':{'type':(actual.get('account') or {}).get('type'),
144
+ 'accountId':(actual.get('workspaceRouting') or {}).get('chatgptAccountId'),
145
+ 'requiresOpenaiAuth':actual['requiresOpenaiAuth']},'readerPID':proc.pid},actual
132
146
  try:
133
147
  init=request('initialize',{'clientInfo':{'name':'topolo_agent_pack','version':__version__},'capabilities':{'experimentalApi':True}})
134
148
  proc.stdin.write(canonical({'method':'initialized'})+b'\n');proc.stdin.flush()
149
+ initial_context,account=current_context()
135
150
  agents=read_json(pathlib.Path(runtime)/'agents.json')['agents']
136
151
  for a in agents:
137
152
  tid=a['threadId']; result=request('thread/read',{'threadId':tid,'includeTurns':False})
@@ -141,13 +156,23 @@ def verify_backend(lib, runtime, cli):
141
156
  turns=request('thread/turns/list',{'threadId':tid,'limit':1,'itemsView':'notLoaded'})
142
157
  items=request('thread/items/list',{'threadId':tid,'limit':1})
143
158
  checked.append({'threadId':tid,'cwdVerified':True,'historyReadable':True,'sampledTurns':len(turns['data']),'sampledItems':len(items['data'])})
159
+ # The same original backend is still open here, after all history reads.
160
+ def guard():
161
+ context,actual=current_context()
162
+ if context!=initial_context:raise PackError('Native account/home/config changed during verification')
163
+ return context
164
+ if reconcile is not None:
165
+ path,document=reconcile
166
+ sidebar=native.reconcile_sidebar(lib,path,document,guard(),read_json(home/'.codex-global-state.json'),guard)
167
+ else:
168
+ sidebar=native.bind_sidebar(runtime,account,lib=lib,guard=guard,journal=presentation_journal,context=initial_context)
144
169
  finally:
145
170
  proc.terminate()
146
171
  try: proc.wait(timeout=5)
147
172
  except subprocess.TimeoutExpired: proc.kill(); proc.wait()
148
173
  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)
174
+ report={'boundary':'native backend read-back; desktop rendering not verified','agents':checked,'modelTurnsStarted':False,'sidebar':sidebar}
175
+ if reconcile is None:atomic_json(pathlib.Path(runtime)/'backend-proof.json',report)
151
176
  return report
152
177
 
153
178
  def desktop_open(runtime, app, user_data, codex_bin=None):
@@ -165,14 +190,20 @@ def desktop_open(runtime, app, user_data, codex_bin=None):
165
190
  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
191
 
167
192
  def apply_plan(lib,plan,args):
193
+ if lib.pending(): raise PackError('An incomplete operation needs recovery. Run topolo-agent-pack recover before another mutation.')
168
194
  if plan['noChange']:
169
195
  active=lib.active()
196
+ if live.available(lib):
197
+ sidebar=live.bind_sidebar(lib,active['runtime'],args.app)
198
+ else:
199
+ native.assert_idle(pathlib.Path(active['runtime'])/'home')
200
+ sidebar=verify_backend(lib,active['runtime'],args.codex_bin)['sidebar']
170
201
  if not args.no_open and not live.available(lib):
171
202
  native.assert_idle(pathlib.Path(active['runtime'])/'home')
172
203
  active['desktop']=desktop_open(active['runtime'],args.app,lib.root/'desktop-data',args.codex_bin)
173
204
  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}
205
+ return {'state':active['state'],'active':active,'sidebarBinding':sidebar,'sidebar':sidebar,'sectionsRenderedVerified':False}
206
+ return {'state':'unchanged','active':active,'sidebar':sidebar,'sidebarBinding':sidebar,'sectionsRenderedVerified':False}
176
207
  if lib.pending(): raise PackError('An incomplete operation needs recovery. Run topolo-agent-pack recover before another mutation.')
177
208
  before=plan['before']; old_runtime=pathlib.Path(before['runtime']) if before.get('runtime') else None
178
209
  bridge=live.available(lib)
@@ -238,7 +269,7 @@ def apply_plan(lib,plan,args):
238
269
  journal['phase']='staged';atomic_json(jpath,journal)
239
270
  if old_runtime and old_runtime!=runtime and (old_runtime/'home/auth.json').exists():
240
271
  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)
272
+ backend=verify_backend(lib,runtime,args.codex_bin,presentation_journal=(jpath,journal))
242
273
  if not plan.get('resumeRuntime'): sync_tree(runtime)
243
274
  if old_runtime: native.assert_idle(old_runtime/'home')
244
275
  if lib.active()!=before: raise PackError('Active composition changed concurrently; refusing to overwrite it.')
@@ -247,16 +278,16 @@ def apply_plan(lib,plan,args):
247
278
  atomic_json(lib.root/'active.json',after)
248
279
  journal['phase']='committed';atomic_json(jpath,journal)
249
280
  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)
281
+ after['desktop']=live.reconnect(lib,runtime,old_runtime,args.app,presentation_journal=(jpath,journal)) if bridge else desktop_open(runtime,args.app,lib.root/'desktop-data',args.codex_bin)
251
282
  after['state']=after['desktop']['state'];atomic_json(lib.root/'active.json',after)
252
283
  journal['after']=after;journal['phase']='complete';atomic_json(jpath,journal)
253
284
  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']}
285
+ 'desktop':after['desktop'],'backend':backend,'sidebarBinding':after['desktop'].get('sidebarBinding',backend['sidebar']),'sectionsRenderedVerified':False,'dependencies':proof['dependencies'],'activeGoalsPaused':proof['activeGoalsPaused']}
255
286
  except Exception as exc:
256
287
  journal['failure']=str(exc);atomic_json(jpath,journal)
257
- if bridge and live.available(lib) and live.available(lib).get('quiesced'):
288
+ if bridge and journal.get('sidebar',{}).get('state') not in {'UNKNOWN','may-have-effects'} and live.available(lib) and live.available(lib).get('quiesced'):
258
289
  try:
259
- current=lib.active();journal['reconnectAfterFailure']=live.reconnect(lib,current['runtime'],old_runtime,args.app);atomic_json(jpath,journal)
290
+ current=lib.active();journal['reconnectAfterFailure']=live.reconnect(lib,current['runtime'],old_runtime,args.app,presentation_journal=(jpath,journal));atomic_json(jpath,journal)
260
291
  except Exception as reconnect_error:
261
292
  journal['reconnectFailure']=str(reconnect_error);atomic_json(jpath,journal)
262
293
  raise
@@ -265,11 +296,24 @@ def recover(lib):
265
296
  result=[]
266
297
  for path in sorted(lib.pending()):
267
298
  j=read_json(path); active=lib.active(); before=j['before']; after=j.get('after')
299
+ entry=j.get('sidebar')
300
+ if entry and entry.get('state') not in {'complete','not-executed'}:
301
+ context=entry['context'];runtime=entry['runtime']
302
+ if context['kind']=='managed':
303
+ live.bind_sidebar(lib,runtime,context.get('app',j.get('desktopApp','/Applications/ChatGPT.app')),reconcile=(path,j))
304
+ else:
305
+ binary=pathlib.Path(context['binary'])
306
+ if hashlib.sha256(binary.read_bytes()).hexdigest()!=context['binarySHA256']:
307
+ raise PackError('Original sidebar recovery binary changed')
308
+ native.assert_idle(pathlib.Path(runtime)/'home')
309
+ verify_backend(lib,runtime,str(binary),reconcile=(path,j))
310
+ if j.get('operation')=='sidebar-bind':
311
+ result.append({'transaction':j['id'],'phase':j['phase'],'action':'Exact current presentation reconciled; no presentation replay.'});continue
268
312
  previous=(active.get('runtime')==before.get('runtime') and active.get('transaction')==before.get('transaction') and active.get('components')==before.get('components'))
269
313
  committed=bool(after and active.get('transaction')==j['id'] and active.get('runtime')==after['runtime'] and active.get('components')==after.get('components'))
270
314
  if not previous and not committed:raise PackError('Recovery found a concurrent active-pointer change; no state overwritten. Inspect '+str(path))
271
315
  if j.get('liveBridge'):
272
- j['desktopRecovery']=live.reconnect(lib,active['runtime'],before.get('runtime'),j.get('desktopApp','/Applications/ChatGPT.app'))
316
+ j['desktopRecovery']=live.reconnect(lib,active['runtime'],before.get('runtime'),j.get('desktopApp','/Applications/ChatGPT.app'),presentation_journal=(path,j))
273
317
  active['desktop']=j['desktopRecovery'];active['state']=active['desktop']['state'];atomic_json(lib.root/'active.json',active)
274
318
  if previous:
275
319
  j['phase']='rolled-back';j['recovery']='Previous active pointer retained; staged files kept for inspection.'
@@ -290,6 +334,8 @@ def parser():
290
334
  p.add_argument('--version',action='version',version=__version__)
291
335
  p.add_argument('--store',default=os.environ.get('CODEX_PACK_HOME',str(pathlib.Path.home()/'AgentPacks')))
292
336
  sub=p.add_subparsers(dest='command',required=True)
337
+ q=sub.add_parser('desktop-launcher',help='Install the owned same-profile Agent Pack Dock launcher without opening it')
338
+ q.add_argument('--app',default='/Applications/ChatGPT.app');q.add_argument('--codex-bin',default=find_codex())
293
339
  q=sub.add_parser('runtime-adoption',help='Configuration-only private runtime plan/apply; no consumer startup or authentication')
294
340
  q.add_argument('operation',choices=['plan','apply']);q.add_argument('--manifest',required=True)
295
341
  q.add_argument('--plan-digest');q.add_argument('--confirm',action='store_true')
@@ -337,6 +383,33 @@ def parser():
337
383
  q=sub.add_parser('smoke',help='Start or read one closed, bound native acceptance case; never arbitrary prompts')
338
384
  q.add_argument('operation',choices=['start','read']);q.add_argument('identity')
339
385
  q.add_argument('--case',choices=sorted(live.SMOKE_CASES));q.add_argument('--operation-id')
386
+ q=sub.add_parser('work',help='Durable task dispatch and checkpoint reads on the owning connection')
387
+ q.add_argument('operation',choices=['submit','respond','re-review','list','read','retained','adoption-plan','adopt','wait','cutover','cutover-status','claude-hook','claude-worker-hook','bind-claude','serve-claude'])
388
+ q.add_argument('--input',help='Exact task request JSON')
389
+ q=sub.add_parser('work-recover',help='Recover verified cloud evidence to a new directory without creating or adopting work')
390
+ q.add_argument('--reference',required=True);q.add_argument('--destination',required=True)
391
+ q=sub.add_parser('work-cloud-cutover',help='Conditionally activate the existing cloud dataset from its authenticated idle native owner')
392
+ q.add_argument('--source-store',required=True);q.add_argument('--input',required=True)
393
+ q=sub.add_parser('work-closed-ledger-fence',help='Permanently fence a closed local task ledger without stopping unrelated native work')
394
+ q.add_argument('--input',required=True)
395
+ q=sub.add_parser('work-cloud-prepare-delta',help='Publish only selected closed legacy task context, final outputs and declared evidence')
396
+ q.add_argument('--source-store',required=True);q.add_argument('--input',required=True)
397
+ q=sub.add_parser('work-install',help='Prepare a three-coordinator installation from cloud specialization metadata; requires cloud authority before execution')
398
+ q.add_argument('--pack',required=True);q.add_argument('--agent-workspace',required=True);q.add_argument('--bytes-workspace',required=True);q.add_argument('--max-workers',type=int,default=4)
399
+ q=sub.add_parser('work-cloud-attach',help='Attach a clean installation to existing activated cloud task authority without dispatch')
400
+ q.add_argument('--input',required=True)
401
+ q=sub.add_parser('work-cloud-migrate',help='Plan or apply exact existing fenced Codex installation attachment without adopting cloud tasks')
402
+ q.add_argument('--input',required=True);q.add_argument('--apply',action='store_true')
403
+ q=sub.add_parser('work-renew-coordinators',help='Renew only the three canonical conversation definitions on an idle existing cloud installation')
404
+ q.add_argument('--input',required=True)
405
+ q=sub.add_parser('work-recover-claude-completion',help='Recover one exact already-delivered Claude completion without resending or starting a dispatcher')
406
+ q.add_argument('--input',required=True)
407
+ q=sub.add_parser('work-repair-model-ceilings',help='Repair stale Claude model ceilings from the existing qualified three-tier catalog only')
408
+ q.add_argument('--input',required=True)
409
+ q=sub.add_parser('work-refresh-role-refs',help='Refresh reviewed references only on the same stopped inactive cloud installation')
410
+ q.add_argument('--input',required=True)
411
+ q=sub.add_parser('work-bind-roles',help='Discover individual Agent definitions and bind metadata-only dispatch references')
412
+ q.add_argument('--pack',action='append',required=True);q.add_argument('--agent-workspace',required=True);q.add_argument('--bytes-workspace')
340
413
  for command in ['connect','remote-context','remember','artifact','artifact-get','remote-recover']:
341
414
  q=sub.add_parser(command,help='Use public Topolo Agent continuity and Bytes artifact contracts')
342
415
  q.add_argument('name');q.add_argument('--environment',choices=['development','staging','production'],required=True)
@@ -371,13 +444,14 @@ def main(argv=None):
371
444
  parser().error('--harness requires codex or claude')
372
445
  harness = argv[at + 1]
373
446
  del argv[at:at + 2]
374
- policy_command = 'policy' in argv or 'permissions' in argv
375
447
  command_args=list(argv)
376
448
  if command_args and command_args[0]=='--store':command_args=command_args[2:]
377
449
  elif command_args and command_args[0].startswith('--store='):command_args=command_args[1:]
450
+ policy_command=bool(command_args and command_args[0] in {'policy','permissions'})
378
451
  lane_command=bool(command_args and command_args[0]=='lane')
379
- cloud_command=bool(command_args and command_args[0] in {'connect','remote-context','remember','artifact','artifact-get','remote-recover'})
380
- if harness == 'claude' and not policy_command and not cloud_command and not lane_command and not (command_args and command_args[0]=='runtime-adoption'):
452
+ work_command=bool(command_args and command_args[0]=='work')
453
+ cloud_command=bool(command_args and command_args[0] in {'connect','remote-context','remember','artifact','artifact-get','remote-recover','work-recover','work-cloud-cutover','work-closed-ledger-fence','work-cloud-prepare-delta','work-bind-roles','work-install','work-refresh-role-refs','work-repair-model-ceilings','work-recover-claude-completion','work-cloud-attach','work-cloud-migrate','work-renew-coordinators'})
454
+ if harness == 'claude' and not policy_command and not cloud_command and not lane_command and not work_command and not (command_args and command_args[0]=='runtime-adoption'):
381
455
  from . import claude
382
456
  return claude.main(argv)
383
457
  if harness not in {'codex', 'claude'}:
@@ -391,7 +465,62 @@ def main(argv=None):
391
465
  raise PackError('Runtime adoption is one explicit cross-host owner manifest, not a harness override')
392
466
  emit(live.private_runtime_adoption(args.store,args.manifest,args.operation,args.plan_digest,args.confirm))
393
467
  return 0
468
+ if args.command=='work-recover':
469
+ from .cloud_recovery import recover as recover_cloud
470
+ emit(recover_cloud(read_json(args.reference),args.destination))
471
+ return 0
472
+ if args.command=='work-cloud-cutover':
473
+ from .cloud_cutover import run
474
+ emit(run(args.source_store,args.store,read_json(args.input)));return 0
475
+ if args.command=='work-closed-ledger-fence':
476
+ from .closed_ledger_fence import run
477
+ emit(run(args.store,read_json(args.input)));return 0
478
+ if args.command=='work-cloud-prepare-delta':
479
+ from .cloud_delta import run
480
+ emit(run(args.source_store,args.store,read_json(args.input)));return 0
481
+ if args.command=='work' and args.operation=='claude-worker-hook':
482
+ if harness!='claude' or not args.input:raise PackError('Exact Claude worker hook binding required')
483
+ from .claude_runner import hook_main
484
+ return hook_main(args.input)
394
485
  lib=Library(args.store)
486
+ if args.command=='work-install':
487
+ from .role_catalog import install
488
+ emit(install(lib,args.pack,args.agent_workspace,args.bytes_workspace,args.max_workers))
489
+ return 0
490
+ if args.command=='work-cloud-attach':
491
+ from .cloud_attach import attach
492
+ emit(attach(lib,read_json(args.input)));return 0
493
+ if args.command=='work-renew-coordinators':
494
+ from .coordinator_refresh import renew
495
+ emit(renew(lib,read_json(args.input)));return 0
496
+ if args.command=='work-cloud-migrate':
497
+ if harness!='codex':raise PackError('Existing fenced Codex installation migration required')
498
+ from .cloud_migrate import plan,apply
499
+ value=read_json(args.input)
500
+ emit(apply(lib,value) if args.apply else plan(lib,value));return 0
501
+ if args.command=='work-recover-claude-completion':
502
+ if harness!='claude':raise PackError('Claude-only production native completion recovery required')
503
+ from .claude_completion_recovery import recover_completed
504
+ value=read_json(args.input)
505
+ if not isinstance(value,dict):raise PackError('Closed native completion recovery object required')
506
+ emit(recover_completed(lib,value));return 0
507
+ if args.command=='work-repair-model-ceilings':
508
+ from .role_catalog import repair_model_ceilings
509
+ value=read_json(args.input)
510
+ if not isinstance(value,dict) or set(value)!={'expectedCatalogDigest','expectedNative'}:raise PackError('Exact reviewed model ceiling repair input required')
511
+ emit(repair_model_ceilings(lib,value['expectedCatalogDigest'],value['expectedNative']));return 0
512
+ if args.command=='work-refresh-role-refs':
513
+ from .role_refresh import refresh_refs
514
+ value=read_json(args.input)
515
+ if not isinstance(value,dict) or set(value)!={'expectedCatalogDigest','expectedBindings','reviewedBindings'}:raise PackError('Exact reviewed role reference input required')
516
+ emit(refresh_refs(lib,value['expectedCatalogDigest'],value['expectedBindings'],value['reviewedBindings']));return 0
517
+ if args.command=='work-bind-roles':
518
+ from .role_context import bind
519
+ result=bind(lib.root/'work',[lib.load(name)[0] for name in args.pack],args.agent_workspace)
520
+ if args.bytes_workspace:
521
+ atomic_json(lib.root/'work/context-authority.json',{'format':'topolo-task-context-authority-v1','organizationId':'org_topolo_platform','workspaceId':args.bytes_workspace,'scope':'org'})
522
+ emit(result)
523
+ return 0
395
524
  if args.command in {'connect','remote-context','remember','artifact','artifact-get','remote-recover'}:
396
525
  from . import topolo
397
526
  if args.command=='remote-context' and args.continuity_input:
@@ -405,6 +534,29 @@ def main(argv=None):
405
534
  raise PackError('Explicit Codex connection ID and reviewed input binding required')
406
535
  import asyncio
407
536
  asyncio.run(live.OwnedLaneConnection(lib,args.connection,read_json(args.input)).run());return 0
537
+ # Live requests are serialized by their authenticated owning service, which
538
+ # has its own owner lock. The setup lock must never span that service's
539
+ # lifetime or prevent its workers from reading/responding through the CLI.
540
+ if args.command=='work' and args.operation!='bind-claude':
541
+ if args.operation in {'claude-hook','serve-claude'}:
542
+ if harness!='claude':
543
+ raise PackError('This operation requires the Claude owning connection')
544
+ if args.operation=='claude-hook':
545
+ from .claude_prompt import main as hook
546
+ return hook(lib.root)
547
+ from .claude_service import Service
548
+ import asyncio
549
+ asyncio.run(Service(lib).run());return 0
550
+ if harness=='claude':
551
+ try:status=live.request(lib,'status',{})
552
+ except (PackError,OSError) as exc:raise PackError('Claude cloud dispatcher is not active') from exc
553
+ if status.get('harness')!='claude' or status.get('cloudDispatchReady') is not True:
554
+ raise PackError('Claude cloud dispatcher is not active')
555
+ params=read_json(args.input) if args.input else {}
556
+ if args.operation=='wait':
557
+ from .work_wait import wait
558
+ emit(wait(lib,params));return 0
559
+ emit(live.request(lib,'work-'+args.operation,params));return 0
408
560
  with lib.lock():
409
561
  if args.command=='lane':
410
562
  from .lanes import Registry
@@ -433,6 +585,13 @@ def main(argv=None):
433
585
  elif harness!='codex':
434
586
  raise PackError('Use the native Claude lane adapter with its qualified connection')
435
587
  else: result=(live.request_lane(lib,args.connection,'lane-'+args.operation,params) if args.connection else live.request(lib,'lane-'+args.operation,params))
588
+ elif args.command=='work':
589
+ if harness!='claude':
590
+ raise PackError('This operation requires the Claude owning connection')
591
+ if not args.input:
592
+ raise PackError('Exact native coordinator binding required')
593
+ from .claude_service import install
594
+ emit(install(lib.root,read_json(args.input)));return 0
436
595
  elif args.command=='smoke':
437
596
  if args.operation=='start':
438
597
  if not args.case or not args.operation_id:
@@ -514,17 +673,23 @@ def main(argv=None):
514
673
  blueprint.build(lib,args.source,template,args.codex_bin)
515
674
  result=apply_plan(lib,make_plan(lib,args),args)
516
675
  elif args.command=='recover': result=recover(lib)
676
+ elif args.command=='desktop-launcher':
677
+ from .desktop_launcher import install
678
+ result=install(lib,args.app,args.codex_bin)
517
679
  elif args.command=='open':
518
680
  active=lib.active()
519
681
  if not active.get('runtime'):raise PackError('Select a pack first with switch NAME')
520
- if live.available(lib):result={'state':'already-open','liveBridge':live.available(lib)}
682
+ bridge=live.available(lib)
683
+ if bridge:
684
+ from .desktop_launcher import restore
685
+ result=restore(lib,bridge,args.app)
521
686
  else:
522
687
  native.assert_idle(pathlib.Path(active['runtime'])/'home')
523
688
  result=desktop_open(active['runtime'],args.app,lib.root/'desktop-data',args.codex_bin)
524
689
  active['desktop']=result;active['state']=result['state'];atomic_json(lib.root/'active.json',active)
525
690
  elif args.command=='status':
526
691
  active=lib.active();pids=native.busy_writers(pathlib.Path(active['runtime'])/'home') if active.get('runtime') else []
527
- 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)}
692
+ 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),'sidebarBinding':read_json(pathlib.Path(active['runtime'])/'sidebar-binding.json',{}) if active.get('runtime') else None,'sectionsRenderedVerified':False}
528
693
  elif args.command=='doctor':
529
694
  checked=[]
530
695
  if args.name:
@@ -0,0 +1,118 @@
1
+ """Fixed-schema closed-ledger migration barrier; unrelated native turns keep running."""
2
+ import hashlib,json,os,pathlib,sqlite3,uuid
3
+ from types import SimpleNamespace
4
+ from .storage import atomic_json,canonical,read_json,sync_directory
5
+ from .work import require
6
+ from .cloud_cutover import snapshot,digest,birth
7
+
8
+ CAPABILITY='topolo-closed-ledger-fence-v1'
9
+ META='_topolo_closed_ledger_fence'
10
+ TABLES={'tasks':('id','input_hash','body'),'workers':('id','task_id','body'),'events':('sequence','task_id','body'),'responses':('id','input_hash','task_id'),'retained':('id','body'),'sqlite_sequence':('name','seq')}
11
+ META_SQL='CREATE TABLE '+META+' (operation_id TEXT PRIMARY KEY, body TEXT NOT NULL)'
12
+ TABLE_SQL={'tasks':'CREATE TABLE tasks(id TEXT PRIMARY KEY, input_hash TEXT NOT NULL, body TEXT NOT NULL)','workers':'CREATE TABLE workers(id TEXT PRIMARY KEY,task_id TEXT NOT NULL,body TEXT NOT NULL)','events':'CREATE TABLE events(sequence INTEGER PRIMARY KEY AUTOINCREMENT, task_id TEXT NOT NULL, body TEXT NOT NULL)','responses':'CREATE TABLE responses(id TEXT PRIMARY KEY,input_hash TEXT NOT NULL,task_id TEXT NOT NULL)','retained':'CREATE TABLE retained(id TEXT PRIMARY KEY,body TEXT NOT NULL)','sqlite_sequence':'CREATE TABLE sqlite_sequence(name,seq)'}
13
+
14
+ def triggers():
15
+ result={'_topolo_fence_'+table+'_'+op.lower():"CREATE TRIGGER _topolo_fence_"+table+'_'+op.lower()+' BEFORE '+op+' ON '+table+" BEGIN SELECT RAISE(ABORT, 'legacy task ledger is permanently cloud fenced'); END" for table in TABLES if table!='sqlite_sequence' for op in ['INSERT','UPDATE','DELETE']}
16
+ for op in ['INSERT','UPDATE','DELETE']:
17
+ name='_topolo_fence_receipt_'+op.lower()
18
+ result[name]='CREATE TRIGGER '+name+' BEFORE '+op+' ON '+META+(' WHEN EXISTS(SELECT 1 FROM '+META+')' if op=='INSERT' else '')+" BEGIN SELECT RAISE(ABORT, 'immutable closed ledger fence receipt'); END"
19
+ return result
20
+
21
+ def code_digest():
22
+ base=pathlib.Path(__file__).parent
23
+ return digest({name:hashlib.sha256((base/name).read_bytes()).hexdigest() for name in ['closed_ledger_fence.py','cloud_cutover.py']})
24
+
25
+ def owned(path,directory=False):
26
+ path=pathlib.Path(path);st=path.lstat()
27
+ require(path.is_absolute() and path.resolve()==path and not path.is_symlink() and st.st_uid==os.getuid() and not st.st_mode&0o022 and (path.is_dir() if directory else path.is_file() and st.st_nlink==1),'Closed ledger path ownership differs')
28
+ return {'path':str(path),'device':st.st_dev,'inode':st.st_ino,'uid':st.st_uid,'mode':st.st_mode&0o777}
29
+
30
+ def identity(root):
31
+ return {name:owned(root/suffix,directory=name!='database') for name,suffix in [('root','.'),('work','work'),('database','work/tasks.sqlite')]}
32
+
33
+ def schema(db,fenced):
34
+ tables={row[0] for row in db.execute("SELECT name FROM sqlite_master WHERE type='table'")}
35
+ require(tables==set(TABLES)|({META} if fenced else set()),'Closed ledger schema table inventory differs')
36
+ for table,columns in {**TABLES,**({META:('operation_id','body')} if fenced else {})}.items():
37
+ require(tuple(row[1] for row in db.execute('PRAGMA table_info('+table+')'))==columns,'Closed ledger schema columns differ')
38
+ require(dict(db.execute("SELECT name,sql FROM sqlite_master WHERE type='table'"))=={**TABLE_SQL,**({META:META_SQL} if fenced else {})},'Closed ledger schema definitions differ')
39
+ actual={row[0]:row[1] for row in db.execute("SELECT name,sql FROM sqlite_master WHERE type='trigger'")}
40
+ require(actual==(triggers() if fenced else {}),'Closed ledger fixed write guards differ')
41
+ return digest([list(row) for row in db.execute('SELECT type,name,tbl_name,sql FROM sqlite_master ORDER BY type,name')])
42
+
43
+ def owner(root,observe=None):
44
+ from . import live
45
+ bridge=root/'live-bridge.json';owned(bridge)
46
+ before=read_json(bridge)
47
+ require(set(before)=={'pid','socket','token'},'Native owner bridge fields differ')
48
+ result=(observe or (lambda r:live.request(SimpleNamespace(root=r),'status')))(root)
49
+ require(read_json(bridge)==before and result['pid']==before['pid'] and isinstance(result['activeTurns'],list) and all(isinstance(x,str) for x in result['activeTurns']) and isinstance(result['runtime'],str),'Authenticated native roster changed')
50
+ return {'pid':result['pid'],'birth':birth(result['pid']),'runtime':result['runtime'],'tokenDigest':hashlib.sha256(before['token'].encode()).hexdigest()},set(result['activeTurns'])
51
+
52
+ def disjoint(state,active):
53
+ workers={json.loads(row[2]).get('nativeId') for row in state['tables']['workers']}
54
+ require(not workers.intersection(active),'A ledger worker still owns an active native turn')
55
+
56
+ def target(request):
57
+ root=pathlib.Path(request['destinationRoot']);owned(root,True);owned(root/'work',True);owned(root/'work/cloud.json')
58
+ binding=read_json(root/'work/cloud.json')
59
+ require(binding['format']=='topolo-cloud-work-binding-v1' and binding['organizationId']=='org_topolo_platform' and all(binding[k]==request[k] for k in ['documentId','datasetId']),'Existing target cloud authority differs')
60
+ return digest(binding)
61
+
62
+ def validate_request(root,request):
63
+ require(set(request)=={'operationId','sourceDigest','proxyPid','runtime','destinationRoot','documentId','datasetId','expectedCurrentVersionId'},'Exact closed ledger migration selectors required')
64
+ require(all(isinstance(request[k],str) and bool(request[k]) for k in ['operationId','sourceDigest','runtime','destinationRoot','documentId','datasetId','expectedCurrentVersionId']) and type(request['proxyPid']) is int and request['proxyPid']>0,'Closed ledger selector types differ')
65
+ require(len(request['sourceDigest'])==64 and all(c in '0123456789abcdef' for c in request['sourceDigest']),'Reviewed snapshot digest is invalid')
66
+ require(pathlib.Path(request['runtime']).is_absolute() and pathlib.Path(request['runtime']).resolve()==pathlib.Path(request['runtime']) and pathlib.Path(request['runtime']).is_relative_to(root/'runtimes'),'Native runtime is outside the source installation')
67
+ require(str(uuid.UUID(request['operationId']))==request['operationId'] and pathlib.Path(request['destinationRoot']).resolve()!=root,'Distinct migration operation and target required')
68
+
69
+ def verify(root,observe=None):
70
+ root=pathlib.Path(root).resolve();ident=identity(root);journal=root/'work/cutover.json';owned(journal)
71
+ saved=read_json(journal)
72
+ require(saved['format']==CAPABILITY,'Closed ledger capability differs')
73
+ native,active=owner(root,observe)
74
+ db=sqlite3.connect('file:'+str(root/'work/tasks.sqlite')+'?mode=ro',uri=True)
75
+ try:
76
+ db.execute('BEGIN');rows=db.execute('SELECT operation_id,body FROM '+META).fetchall()
77
+ require(len(rows)==1,'Closed ledger durable receipt is ambiguous')
78
+ record=json.loads(rows[0][1]);require(rows[0][0]==saved['operationId']==record['operationId'],'Closed ledger operation identity differs')
79
+ require(record['format']==CAPABILITY and record['state']=='ledger-fenced' and record['root']==str(root) and record['databaseIdentity']==ident and record['codeDigest']==code_digest() and record['nativeOwner']==native,'Closed ledger ownership or executing source changed')
80
+ require(saved==record or saved=={'format':CAPABILITY,'operationId':record['operationId'],'state':'prepared','request':record['request']},'Closed ledger file receipt changed')
81
+ require(target(record['request'])==record['targetBindingDigest'] and schema(db,True)==record['schemaDigest'],'Closed ledger target or schema changed')
82
+ state=snapshot(db);disjoint(state,active)
83
+ require(digest(state)==record['sourceDigest']==digest(record['snapshot']),'Closed ledger source checkpoint changed')
84
+ finally:db.close()
85
+ require(identity(root)==ident,'Closed ledger file identity changed during observation')
86
+ return record
87
+
88
+ def run(root,request,observe=None):
89
+ root=pathlib.Path(root).resolve();validate_request(root,request);ident=identity(root);binding=target(request)
90
+ journal=root/'work/cutover.json'
91
+ if journal.exists():
92
+ record=verify(root,observe)
93
+ require(record['request']==request,'Existing ledger fence request differs; no replay')
94
+ return record
95
+ native,active=owner(root,observe)
96
+ require(native['pid']==request['proxyPid'] and native['runtime']==request['runtime'],'Reviewed native owner differs')
97
+ prepared={'format':CAPABILITY,'operationId':request['operationId'],'state':'prepared','request':request}
98
+ fd=os.open(journal,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600)
99
+ with os.fdopen(fd,'wb') as file:file.write(canonical(prepared));file.flush();os.fsync(file.fileno())
100
+ sync_directory(journal.parent)
101
+ db=sqlite3.connect(root/'work/tasks.sqlite',timeout=10)
102
+ try:
103
+ db.execute('BEGIN IMMEDIATE');require(identity(root)==ident and read_json(journal)==prepared,'Source identity changed before fence')
104
+ current,active=owner(root,observe);require(current==native,'Native owner changed while awaiting ledger admission')
105
+ schema(db,False);state=snapshot(db);disjoint(state,active)
106
+ require(digest(state)==request['sourceDigest'],'Reviewed closed ledger snapshot changed')
107
+ db.execute(META_SQL)
108
+ for sql in triggers().values():db.execute(sql)
109
+ record={'format':CAPABILITY,'operationId':request['operationId'],'state':'ledger-fenced','root':str(root),'request':request,'databaseIdentity':ident,'nativeOwner':native,'codeDigest':code_digest(),'targetBindingDigest':binding,'schemaDigest':schema(db,True),'sourceDigest':digest(state),'snapshot':state,'unrelatedNativeTurnsPreserved':True}
110
+ require(len(canonical(record))<=900000,'Closed ledger receipt exceeds bound')
111
+ db.execute('INSERT INTO '+META+' VALUES (?,?)',(request['operationId'],canonical(record).decode()));db.commit()
112
+ except BaseException:db.rollback();raise
113
+ finally:db.close()
114
+ # A lost file acknowledgement is recovered from the immutable native DB row;
115
+ # another call can only verify that row, never execute the fence again.
116
+ actual=verify(root,observe);require(actual==record,'Committed ledger fence differs')
117
+ atomic_json(journal,record)
118
+ return record
@@ -0,0 +1,128 @@
1
+ """Attach a clean installation to existing cloud authority; never dispatch work."""
2
+ import json
3
+ import os
4
+ import re
5
+ import types
6
+
7
+ from . import permissions, topolo
8
+ from .cloud_work import CloudStore
9
+ from .role_catalog import COORDINATORS, digest
10
+ from .storage import atomic_json, canonical, read_json, sync_directory
11
+ from .work import require
12
+
13
+ FIELDS = {'pack', 'workspaceId', 'documentId', 'datasetId', 'installationId',
14
+ 'expectedCurrentVersionId', 'expectedCatalogDigest'}
15
+ METADATA = {'config.json', 'role-catalog.json', 'role-bindings.json', 'context-authority.json',
16
+ 'cloud-attach.json', 'cloud.json'}
17
+
18
+
19
+ def owned(path, directory=False):
20
+ stat = path.lstat()
21
+ require(not path.is_symlink() and stat.st_uid == os.getuid() and not stat.st_mode & 0o077
22
+ and (path.is_dir() if directory else path.is_file()), 'Private owned installation metadata required')
23
+
24
+
25
+ def attach(lib, request, client=None):
26
+ require(isinstance(request, dict) and set(request) == FIELDS
27
+ and all(isinstance(v, str) and 1 <= len(v) <= 240 for v in request.values())
28
+ and re.fullmatch(r'[a-z0-9][a-z0-9-]{0,119}', request['installationId']),
29
+ 'Exact cloud attachment selectors required')
30
+ with lib.lock():
31
+ return attach_locked(lib, request, client)
32
+
33
+
34
+ def clean(lib, root):
35
+ owned(root, True)
36
+ require(not lib.active().get('components') and not lib.pending(), 'Clean stopped installation required')
37
+ require({p.name for p in root.iterdir()} <= METADATA, 'Local tasks, pending writes or unknown metadata prohibit attachment')
38
+ for path in root.iterdir():
39
+ owned(path)
40
+ for path in [lib.root / 'live-bridge.json', root / 'claude-frontdoors.json']:
41
+ require(not path.exists(), 'Existing native owner metadata prohibits attachment')
42
+ for name in ['startup-operation.json', 'native-operation.json', 'active.json', 'state.json']:
43
+ require(not (lib.root / 'claude' / name).exists(), 'Existing Claude preparation or startup prohibits fresh attachment')
44
+
45
+
46
+ def attach_locked(lib, request, client):
47
+ root = lib.root / 'work'
48
+ clean(lib, root)
49
+ saved = read_json(root / 'config.json')
50
+ catalog = read_json(root / 'role-catalog.json')
51
+ registry = read_json(root / 'role-bindings.json')
52
+ manifest, revision = lib.load(request['pack'])
53
+ agents = manifest['agents']
54
+ require(set(saved) == {'format', 'pack', 'catalogDigest', 'maxWorkers', 'frontdoorDispatch', 'requireCloudAuthority'}
55
+ and saved['format'] == 'topolo-task-dispatch-v2' and saved['pack'] == request['pack']
56
+ and saved['frontdoorDispatch'] is True and saved['requireCloudAuthority'] is True
57
+ and type(saved['maxWorkers']) is int and 1 <= saved['maxWorkers'] <= 8,
58
+ 'Qualified three-coordinator cloud configuration required')
59
+ require(len(agents) == 3 and {(a['set'], a['id']) for a in agents} == {('intakes', v) for v in COORDINATORS.values()}
60
+ and catalog['pack'] == request['pack'] and catalog['nativePackRevision'] == revision
61
+ and catalog['format'] == 'topolo-task-role-catalog-v1'
62
+ and saved['catalogDigest'] == request['expectedCatalogDigest'] == digest(catalog)
63
+ and registry.get('organizationId') == 'org_topolo_platform' and registry.get('roles') == catalog['bindings']
64
+ and set(catalog['channels']) == set(COORDINATORS), 'Reviewed role catalog differs')
65
+ owned(lib.root / 'permissions' / (request['pack'] + '.json'))
66
+ consent = permissions.load(lib, request['pack'], agents)
67
+ require(consent and consent.get('sourceRevision') == revision and consent.get('environment') == 'production'
68
+ and consent['contextKey'] == 'org:org_topolo_platform'
69
+ and consent['intakeRoutes'] == {request['pack'] + '/intakes/' + a['id']: request['pack'] + '/intakes/' + a['id'] for a in agents}
70
+ and all(ref.get('workspaceId') == consent.get('agentWorkspaceId') for ref in registry['roles'].values()),
71
+ 'Current production installation consent required')
72
+ if client is None:
73
+ client = topolo.Client('production', 'org:org_topolo_platform')
74
+ require(client.environment == 'production' and client.context == consent['contextKey'], 'Production cloud reader context differs')
75
+ actor = client.run(['whoami'])
76
+ require(actor.get('organization', {}).get('id') == 'org_topolo_platform'
77
+ and actor.get('user', {}).get('id') == consent['principalId'], 'Current production identity differs')
78
+ _, policy = topolo.organization_execution_policy(client, consent, agents, consent.get('agentWorkspaceId'))
79
+ permissions.authorize_native_scope(policy)
80
+ binding = {'format': 'topolo-cloud-work-binding-v1', 'organizationId': 'org_topolo_platform',
81
+ **{k: request[k] for k in ['workspaceId', 'documentId', 'datasetId', 'installationId']}}
82
+ # Reuse the exact current-only authenticated reader before publishing a binding.
83
+ version, state = CloudStore.load(types.SimpleNamespace(binding=binding, client=client))
84
+ journal_path, binding_path = root / 'cloud-attach.json', root / 'cloud.json'
85
+ journal = read_json(journal_path, {})
86
+ pins = {'catalog': digest(catalog), 'registry': digest(registry), 'config': digest(saved), 'consent': digest(consent), 'revision': revision}
87
+ if journal:
88
+ require(set(journal) == {'format', 'state', 'request', 'binding', 'pins', 'cloudVersion', 'cloudDigest', 'verifiedCloudVersion', 'verifiedCloudDigest'}
89
+ and journal['format'] == 'topolo-cloud-attach-v1' and journal['state'] in {'prepared', 'verified'}
90
+ and journal['request'] == request and journal['binding'] == binding and journal['pins'] == pins,
91
+ 'Existing cloud attachment requires its original exact selectors and metadata')
92
+ require(binding_path.exists() and read_json(binding_path) == binding, 'Attachment write is uncertain; no automatic replay')
93
+ else:
94
+ require(not binding_path.exists(), 'Existing unverified cloud binding cannot be replaced')
95
+ verified = journal.get('state') == 'verified'
96
+ require(journal or version == request['expectedCurrentVersionId'], 'Expected current cloud version changed')
97
+ if not verified:
98
+ for table in ['tasks', 'workers']:
99
+ for row in state['tables'][table]:
100
+ body = json.loads(row[2])
101
+ require(request['installationId'] not in [body.get('ownerInstallationId'), body.get('installationId'),
102
+ body.get('nativePlan', {}).get('installationId')], 'Installation identity already owns recorded work')
103
+ # Awaited identity/policy/Docs reads must not race installation metadata changes.
104
+ require(read_json(root / 'config.json') == saved and read_json(root / 'role-catalog.json') == catalog
105
+ and read_json(root / 'role-bindings.json') == registry
106
+ and permissions.load(lib, request['pack'], agents) == consent
107
+ and lib.load(request['pack'])[1] == revision and not lib.active().get('components') and not lib.pending(),
108
+ 'Installation metadata changed during attachment')
109
+ clean(lib, root)
110
+ if verified:
111
+ return {'state': 'cloud-authority-attached', 'alreadyApplied': True, 'binding': binding, 'tasksCreated': 0, 'workersAdopted': 0}
112
+ resumed = bool(journal)
113
+ if not journal:
114
+ journal = {'format': 'topolo-cloud-attach-v1', 'state': 'prepared', 'request': request, 'binding': binding,
115
+ 'pins': pins, 'cloudVersion': version, 'cloudDigest': digest(state),
116
+ 'verifiedCloudVersion': None, 'verifiedCloudDigest': None}
117
+ # The library lock serializes the exclusive journal reservation.
118
+ with journal_path.open('x', encoding='utf8') as stream:
119
+ os.chmod(journal_path, 0o600)
120
+ stream.write(canonical(journal).decode()); stream.flush(); os.fsync(stream.fileno())
121
+ sync_directory(root)
122
+ atomic_json(binding_path, binding)
123
+ require(read_json(binding_path) == binding, 'Cloud binding readback differs; reconcile without replay')
124
+ verified_record = {**journal, 'state': 'verified', 'verifiedCloudVersion': version, 'verifiedCloudDigest': digest(state)}
125
+ atomic_json(journal_path, verified_record)
126
+ require(read_json(journal_path) == verified_record, 'Cloud attachment acknowledgement is uncertain')
127
+ return {'state': 'cloud-authority-attached', 'alreadyApplied': False, 'binding': binding,
128
+ 'tasksCreated': 0, 'workersAdopted': 0, 'cloudTaskStateChanged': False, 'reconciled': resumed}