@topolo/cli 0.13.19 → 0.13.22

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 +152 -73
  142. package/package.json +5 -5
@@ -0,0 +1,134 @@
1
+ """Closed idle legacy-to-cloud cutover; no worker adoption or local fallback."""
2
+ import hashlib,json,os,pathlib,sqlite3,subprocess,uuid
3
+ from .storage import PackError,atomic_json,canonical,read_json,sync_directory
4
+ from .work import require,TERMINAL
5
+ from .cloud_work import TABLES
6
+ CAPABILITY='topolo-work-cutover-v1'
7
+
8
+ def digest(value):return hashlib.sha256(canonical(value)).hexdigest()
9
+ def birth(pid):
10
+ value=subprocess.run(['/bin/ps','-p',str(pid),'-o','lstart=','-o','command='],capture_output=True,text=True,check=True).stdout.strip()
11
+ require(bool(value),'Native process identity unavailable');return value
12
+
13
+ def snapshot(db):
14
+ tables={name:[list(row) for row in db.execute('SELECT * FROM '+name+' ORDER BY '+('sequence' if name=='events' else 'rowid'))] for name in TABLES}
15
+ retained=[list(row) for row in db.execute('SELECT id,body FROM retained ORDER BY rowid')]
16
+ require(all(json.loads(row[2])['state'] in TERMINAL for row in tables['tasks']),'Task phases remain pending; preserve the current owner')
17
+ for row in tables['workers']:
18
+ w=json.loads(row[2]);require(w['state']=='archived' and w.get('nativeArchived') is True or w['state']=='failed' and not w.get('turnId') and (not w.get('nativeId') or w.get('nativeArchived') is True),'Native worker closure is unresolved')
19
+ return {'tables':tables,'retained':{'count':len(retained),'sha256':digest(retained)}}
20
+
21
+ def closure():
22
+ base=pathlib.Path(__file__).parent
23
+ return digest({name:hashlib.sha256((base/name).read_bytes()).hexdigest() for name in ['cloud_cutover.py','work.py','work_native.py','live.py']})
24
+
25
+ def freeze(owner):
26
+ """Only the current guarded native Broker calls this while its lock is held."""
27
+ store=owner.work.store;require(not getattr(store,'cloud',False),'Cloud authority is already selected')
28
+ path=store.root/'cutover.json';require(not path.exists(),'Existing cutover must be reconciled without replay')
29
+ require(owner.work.cutover_closure==closure(),'Executing native guard source changed; upgrade the idle owner first')
30
+ with store.db() as db:
31
+ state=snapshot(db)
32
+ record={'format':CAPABILITY,'operationId':str(uuid.uuid4()),'root':str(owner.lib.root.resolve()),'pid':os.getpid(),'birth':birth(os.getpid()),'runtime':str(owner.runtime),'tokenDigest':hashlib.sha256(owner.token.encode()).hexdigest(),'guardDigest':owner.work.cutover_closure,'sourceDigest':digest(state),'snapshot':state,'state':'source-fenced-native-stop-unverified'}
33
+ require(len(canonical(record))<=900000,'Cutover checkpoint exceeds native acknowledgement bound')
34
+ atomic_json(path,record)
35
+ return record
36
+
37
+ def acknowledge(owner,record):
38
+ require(owner.quiesced and not owner.active_turns and not owner.pending and owner.child is not None and owner.child.returncode is not None,'Native shutdown is not verified')
39
+ record={**record,'state':'quiesced','childPid':owner.child.pid,'childReturnCode':owner.child.returncode}
40
+ atomic_json(owner.work.store.root/'cutover.json',record);return record
41
+
42
+ def source(root,observe):
43
+ root=pathlib.Path(root).resolve();path=root/'work/cutover.json';st=path.lstat()
44
+ require(path.is_file() and not path.is_symlink() and st.st_uid==os.getuid() and st.st_mode&0o777==0o600,'Cutover journal must be private and owned')
45
+ record=read_json(path)
46
+ if record.get('format')=='topolo-closed-ledger-fence-v1':
47
+ from .closed_ledger_fence import verify
48
+ return verify(root)
49
+ require(record['format']==CAPABILITY and record['root']==str(root) and record['state']=='quiesced','Native cutover acknowledgement is incomplete')
50
+ actual=observe(root)
51
+ require(actual==record and birth(record['pid'])==record['birth'] and record['guardDigest']==closure(),'Authenticated executing native cutover generation changed')
52
+ dbpath=root/'work/tasks.sqlite';require(dbpath.is_file() and not dbpath.is_symlink(),'Native source database changed')
53
+ db=sqlite3.connect('file:'+str(dbpath)+'?mode=ro',uri=True)
54
+ try:db.execute('BEGIN');state=snapshot(db)
55
+ finally:db.close()
56
+ require(digest(state)==record['sourceDigest']==digest(record['snapshot']),'Fenced legacy source changed')
57
+ return record
58
+
59
+ def merge(state,capture):
60
+ """Retain existing cloud portable checkpoints; exact identities admit delta."""
61
+ tables={}
62
+ for name in TABLES:
63
+ original=state['tables'][name];rows=capture['tables'][name];local={r[0]:r for r in rows}
64
+ for old in original:
65
+ require(old[0] in local,'Cloud identity is absent from the final legacy snapshot');current=local[old[0]]
66
+ if name=='tasks':
67
+ a,b=json.loads(old[2]),json.loads(current[2]);require(old[1]==current[1] and all(a.get(k)==b.get(k) for k in ['id','revision','state','phase','objective','channel','canonicalClaimId']),'Previously captured task changed; explicit reconciliation required')
68
+ elif name=='workers':
69
+ a,b=json.loads(old[2]),json.loads(current[2]);require(old[1]==current[1] and all(a.get(k)==b.get(k) for k in ['id','taskId','phase','attempt','state','nativeId','turnId','nativeArchived']),'Previously captured worker changed; preserve its receipt')
70
+ else:require(old==current,'Previously captured event or response changed')
71
+ existing={r[0]:r for r in original}
72
+ for row in rows:
73
+ if row[0] not in existing and name in {'tasks','workers'}:
74
+ value=json.loads(row[2]);ref=value.get('contextRef') if name=='tasks' else value.get('checkpoint')
75
+ require(isinstance(ref,dict) and isinstance(ref.get('artifact'),dict),'New delta needs verified portable cloud context/checkpoints before cutover')
76
+ if name=='tasks' and value.get('result'):
77
+ require(isinstance(value['result'].get('checkpoint',{}).get('artifact'),dict),'New task result needs its verified cloud checkpoint')
78
+ tables[name]=[existing.get(row[0],row) for row in rows]
79
+ return tables
80
+
81
+ def run(source_root,target_root,request,client=None,observe=None):
82
+ require(set(request)=={'documentId','datasetId','expectedCurrentVersionId','sourceDigest'},'Exact cutover selectors required')
83
+ from .storage import Library
84
+ from . import live
85
+ if observe is None:observe=lambda root:live.request(Library(root),'work-cutover-status',{})
86
+ record=source(source_root,observe);require(record['sourceDigest']==request['sourceDigest'],'Reviewed source checkpoint differs')
87
+ if record['format']=='topolo-closed-ledger-fence-v1':
88
+ require(record['request']['destinationRoot']==str(pathlib.Path(target_root).resolve()) and all(record['request'][k]==request[k] for k in ['documentId','datasetId','expectedCurrentVersionId']),'Closed ledger fence target differs')
89
+ binding=read_json(pathlib.Path(target_root)/'work/cloud.json')
90
+ 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']),'Target cloud dataset binding differs')
91
+ if client is None:
92
+ from .topolo import Client
93
+ client=Client('production','org:org_topolo_platform')
94
+ def current():
95
+ response=client.action('app_topolo_docs.documents.get',{'documentId':binding['documentId'],'includeHistory':False},binding['workspaceId']);doc=response['document'];versions=[v for v in response['versions'] if v['id']==doc['currentVersionId']]
96
+ require(doc['id']==binding['documentId'] and doc['workspaceId']==binding['workspaceId'] and doc['organizationId']==binding['organizationId'] and len(versions)==1,'Cloud cutover identity differs')
97
+ return doc['currentVersionId'],json.loads(versions[0]['bodyMarkdown'])
98
+ version,state=current();operation={'id':record['operationId'],'method':'cloud-cutover','sourceDigest':record['sourceDigest'],'installationId':binding['installationId']}
99
+ journal=pathlib.Path(target_root)/'work'/('cutover-'+record['operationId']+'.json');prior=read_json(journal,{})
100
+ if prior:
101
+ require(prior['operation']==operation and prior['request']==request and prior['stateDigest']==digest(state) and state.get('operation')==operation and state.get('activated') is True,'Existing cutover write may have effects; reconcile without replay')
102
+ return {'state':'activated','operationId':operation['id'],'replayed':False,'alreadyApplied':True}
103
+ require(version==request['expectedCurrentVersionId'] and state['format']=='topolo-cloud-work-state-v1' and state['datasetId']==binding['datasetId'] and state.get('activated') is False and state['generation']==0 and set(state['tables'])==set(TABLES),'Inactive cloud capture changed; no cutover replay')
104
+ require(all(state['inventory'][key]==record['snapshot']['retained'][key] for key in ['count','sha256']),'Retained inventory changed; refresh its immutable cloud reference first')
105
+ tables=merge(state,record['snapshot'])
106
+ from .work_artifacts import read_checkpoint,materialize_result
107
+ from .topolo import read_artifact
108
+ import tempfile
109
+ old_tasks={row[0] for row in state['tables']['tasks']};old_workers={row[0] for row in state['tables']['workers']}
110
+ for row in tables['tasks']:
111
+ if row[0] not in old_tasks:
112
+ ref=json.loads(row[2])['contextRef']['artifact']
113
+ with tempfile.TemporaryDirectory(prefix='topolo-cutover-context-') as tmp:read_artifact(client,{'input':{'bytesScope':ref['scope'],'bytesWorkspaceId':ref['workspaceId']}},ref['key'],ref,pathlib.Path(tmp)/'context.json')
114
+ task=json.loads(row[2])
115
+ if task.get('result'):
116
+ with tempfile.TemporaryDirectory(prefix='topolo-cutover-result-') as tmp:materialize_result(pathlib.Path(target_root)/'work',task,pathlib.Path(tmp),client)
117
+ for row in tables['workers']:
118
+ if row[0] not in old_workers:read_checkpoint(pathlib.Path(target_root)/'work',json.loads(row[2])['checkpoint'],row[1],row[0],client=client)
119
+ next_state={**state,'tables':tables,'activated':True,'generation':1,'operation':operation};body=canonical(next_state).decode();require(len(body.encode())<=900000,'Cloud cutover exceeds its bounded state contract')
120
+ require(source(source_root,observe)==record,'Source cutover generation changed before CAS')
121
+ fd=os.open(journal,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600)
122
+ try:
123
+ os.write(fd,canonical({'operation':operation,'request':request,'stateDigest':digest(next_state)}));os.fsync(fd)
124
+ finally:os.close(fd)
125
+ sync_directory(journal.parent)
126
+ error=None
127
+ try:client.action('app_topolo_docs.documents.versions.create',{'documentId':binding['documentId'],'bodyMarkdown':body,'expectedCurrentVersionId':version},binding['workspaceId'],write=True)
128
+ except Exception as exc:error=exc
129
+ try:_,actual=current()
130
+ except Exception as exc:raise PackError('Cloud cutover outcome is uncertain; reconcile without repeating the write') from exc
131
+ if record['format']=='topolo-closed-ledger-fence-v1':
132
+ require(source(source_root,observe)==record,'Closed ledger ownership changed after cloud acknowledgement; reconcile without replay')
133
+ require(actual==next_state,'Cloud cutover was refused or changed; reconcile without repeating the write')
134
+ return {'state':'activated','operationId':operation['id'],'replayed':False,'alreadyApplied':False,'ackRecovered':error is not None,'sourceDigest':record['sourceDigest']}
@@ -0,0 +1,162 @@
1
+ """Convert explicitly selected CLOSED legacy results, never native histories."""
2
+ import hashlib,json,os,pathlib,sqlite3
3
+ from .storage import canonical,read_json,atomic_json
4
+ from .work import Store,require,TERMINAL
5
+ from .cloud_cutover import digest,snapshot,merge
6
+ from .work_artifacts import publish,read_checkpoint
7
+ from .work_context import ensure
8
+
9
+ def bytes_at(path,expected,root=None):
10
+ path=pathlib.Path(path);st=path.lstat()
11
+ require(path.is_file() and not path.is_symlink() and path.resolve()==path and st.st_uid==os.getuid() and st.st_nlink==1 and not st.st_mode&0o022 and 0<st.st_size<=100*1024*1024,'Declared source must be an owned immutable ordinary file')
12
+ require(root is None or path.is_relative_to(root),'Declared artifact is outside its worker workspace')
13
+ data=path.read_bytes();after=path.lstat()
14
+ require((st.st_dev,st.st_ino,st.st_size,st.st_mtime_ns,st.st_ctime_ns)==(after.st_dev,after.st_ino,after.st_size,after.st_mtime_ns,after.st_ctime_ns) and hashlib.sha256(data).hexdigest()==expected,'Declared source digest changed')
15
+ return data
16
+
17
+ def checkpoint(store,task,worker):
18
+ require(worker['state']=='archived' and worker.get('nativeArchived') is True,'Native archival is not verified in the retained owner record')
19
+ proof=worker['checkpoint'];path=store.root/'checkpoints'/task['id']/(worker['id']+'.json')
20
+ require(set(proof)=={'path','sha256'} and proof['path']==str(path),'Exact original checkpoint binding required')
21
+ original=json.loads(bytes_at(path,proof['sha256']))
22
+ require(set(original)=={'nativeTurn','resolution','taskId','worker'} and original['taskId']==task['id'] and original['resolution']==worker['resolution'],'Original checkpoint task or resolution differs')
23
+ retained=original['worker'];actual=original['nativeTurn']
24
+ require(all(retained.get(k)==worker.get(k) for k in ['id','taskId','phase','nativeId','turnId','resolution']) and actual['id']==worker['turnId'] and actual['status']=='completed' and actual.get('error') is None,'Original terminal native receipt differs')
25
+ output='\n'.join(i.get('text','') for i in actual['items'] if i.get('type')=='agentMessage' and i.get('phase')!='commentary')
26
+ require(0<len(output.encode())<=100000,'Selected final output exceeds checkpoint boundary')
27
+ # Deliberately do not return nativeTurn, prompts, tool logs or reasoning.
28
+ return {'output':output,'receipt':{'threadId':worker['nativeId'],'turnId':worker['turnId'],'status':'completed','toolItems':sum(i.get('type') not in {'agentMessage','userMessage','reasoning','plan'} for i in actual['items']),'sourceCheckpointSHA256':proof['sha256'],'archivalRecordSHA256':digest(worker)}}
29
+
30
+ def safe_artifact(data):
31
+ """JSON histories are never declared artifacts, even via a nested manifest."""
32
+ try:pending=[json.loads(data)]
33
+ except (ValueError,UnicodeDecodeError):
34
+ pending=[]
35
+ for line in data.splitlines():
36
+ try:pending.append(json.loads(line))
37
+ except (ValueError,UnicodeDecodeError):pass
38
+ while pending:
39
+ item=pending.pop()
40
+ if isinstance(item,dict):
41
+ require('nativeTurn' not in item and item.get('type') not in {'reasoning','userMessage','response_item','event_msg','session_meta','turn_context'} and not (isinstance(item.get('turns'),list) and item['turns']),'Raw native history cannot be published as a declared artifact')
42
+ pending.extend(item.values())
43
+ elif isinstance(item,list):pending.extend(item)
44
+
45
+ def selected_artifacts(task,workers,selector):
46
+ """Read-only completeness qualification, before any context/artifact upload."""
47
+ execute=[w for w in workers if w['phase']=='execute'];require(len(execute)==1,'One exact existing execution result required')
48
+ worker=execute[0];cwd=pathlib.Path(worker['cwd']).resolve();declared=selector['artifactManifest']
49
+ checkpoints={(w['checkpoint']['path'],w['checkpoint']['sha256']) for w in workers}
50
+ reviewed=[{'path':r['path'],'sha256':r['sha256']} for r in task.get('verification',{}).get('evidence',[]) if (r['path'],r['sha256']) not in checkpoints]
51
+ external={(r['path'],r['sha256']) for r in reviewed if not pathlib.Path(r['path']).is_relative_to(cwd)}
52
+ if declared is not None:
53
+ require(set(declared)=={'path','sha256'},'Exact declared manifest reference required')
54
+ value=json.loads(bytes_at(declared['path'],declared['sha256']))
55
+ if isinstance(value,list):
56
+ require(pathlib.Path(declared['path']).is_relative_to(cwd),'Original list manifest must remain in its worker workspace')
57
+ rows=value
58
+ else:
59
+ require(isinstance(value,dict) and set(value)=={'format','taskId','workerId','artifacts'} and value['format']=='topolo-selected-task-artifacts-v1' and value['taskId']==task['id'] and value['workerId']==worker['id'],'Selected manifest task or worker identity differs')
60
+ rows=value['artifacts']
61
+ else:
62
+ require(not external,'External reviewed evidence requires an explicit selected artifact manifest')
63
+ rows=reviewed
64
+ require(isinstance(rows,list) and (0 if declared is None else 1)<=len(rows)<=64,'Declared artifact manifest exceeds its bound')
65
+ require(all(isinstance(r,dict) and {'path','sha256'}<=set(r)<= {'path','sha256','bytes','contentPath'} for r in rows),'Declared artifact fields differ')
66
+ require(len({r['path'] for r in rows})==len(rows),'Duplicate declared artifact')
67
+ selected={(r['path'],r['sha256']) for r in rows}
68
+ require(all((r['path'],r['sha256']) in selected for r in reviewed),'Selected manifest omits reviewed evidence')
69
+ require(not selected.intersection(checkpoints),'Raw native checkpoint cannot be declared as an artifact')
70
+ for row in rows:
71
+ inside=pathlib.Path(row['path']).is_relative_to(cwd)
72
+ require(inside or declared is not None and (row['path'],row['sha256']) in external,'External artifact lacks exact reviewed task evidence')
73
+ data=artifact_bytes(row,reviewed,declared,cwd);safe_artifact(data)
74
+ require('bytes' not in row or row['bytes']==len(data),'Declared artifact size differs')
75
+ if pathlib.Path(row['path'])==cwd/'TASK-CONTINUATION.json':
76
+ packet=json.loads(data)
77
+ require(isinstance(packet,dict) and packet.get('format')=='topolo-task-continuation-evidence-v1' and packet.get('taskId')==task['id'] and isinstance(packet.get('proof'),dict) and all(isinstance(value,str) for value in packet['proof'].values()),'Continuation evidence task identity differs')
78
+ proof_paths={value for value in packet['proof'].values() if isinstance(value,str) and pathlib.Path(value).is_absolute()}
79
+ require(proof_paths.issubset({r['path'] for r in rows}),'Selected manifest omits continuation proof artifacts')
80
+ return worker,declared,rows
81
+
82
+ def artifact_bytes(row,reviewed,declared,cwd):
83
+ """An explicit archive may supply only the exact previously reviewed bytes.
84
+
85
+ Keep the original evidence identity; never overwrite a live owner's file or
86
+ relabel its current contents as the historical checkpoint.
87
+ """
88
+ content=row.get('contentPath',row['path'])
89
+ if 'contentPath' in row:
90
+ require(declared is not None and content!=row['path'] and
91
+ (row['path'],row['sha256']) in {(r['path'],r['sha256']) for r in reviewed},
92
+ 'Archived content requires an explicit exact reviewed artifact')
93
+ require(pathlib.Path(content).is_absolute() and pathlib.Path(content).is_relative_to(pathlib.Path(declared['path']).parent),
94
+ 'Archived content must be inside its selected manifest directory')
95
+ return bytes_at(content,row['sha256'])
96
+ return bytes_at(content,row['sha256'],cwd if pathlib.Path(row['path']).is_relative_to(cwd) else None)
97
+
98
+ def artifacts(store,task,workers,selector,client):
99
+ worker,declared,rows=selected_artifacts(task,workers,selector);cwd=pathlib.Path(worker['cwd']).resolve();result=[]
100
+ reviewed=task.get('verification',{}).get('evidence',[])
101
+ for index,row in enumerate(rows):
102
+ path=pathlib.Path(row['path']);inside=path.is_relative_to(cwd)
103
+ data=artifact_bytes(row,reviewed,declared,cwd);safe_artifact(data)
104
+ ref=publish(store.root,task['id'],'legacy-'+worker['id']+'-artifact-'+str(index),data,client=client)
105
+ result.append({'title':str(path.relative_to(cwd)) if inside else 'Reviewed external evidence: '+path.name,'sourcePath':row['path'],'sourceSHA256':row['sha256'],**({'archivedContentPath':row['contentPath']} if 'contentPath' in row else {}),'artifact':ref})
106
+ manifest={'format':'topolo-task-artifact-manifest-v1','taskId':task['id'],'workerId':worker['id'],'sourceManifest':declared,'artifacts':result,'nativeHistoryIncluded':False}
107
+ ref=publish(store.root,task['id'],'legacy-'+worker['id']+'-artifact-manifest',canonical(manifest),client=client)
108
+ return {'title':'Selected task artifact manifest','artifact':ref}
109
+
110
+ def run(source_root,target_root,request,client=None):
111
+ require(set(request)=={'documentId','datasetId','expectedCurrentVersionId','tasks'} and isinstance(request['tasks'],list) and 1<=len(request['tasks'])<=16,'Exact portable delta request required')
112
+ require(len({s['taskId'] for s in request['tasks']})==len(request['tasks']) and all(set(s)=={'taskId','revision','artifactManifest'} for s in request['tasks']),'Exact distinct task selectors required')
113
+ from .cloud_work import CloudStore
114
+ if client is None:
115
+ from .topolo import Client
116
+ client=Client('production','org:org_topolo_platform')
117
+ binding=CloudStore(target_root,client).binding
118
+ require(all(binding[k]==request[k] for k in ['documentId','datasetId']),'Portable delta cloud dataset differs')
119
+ response=client.action('app_topolo_docs.documents.get',{'documentId':binding['documentId'],'includeHistory':False},binding['workspaceId']);doc=response['document'];versions=[v for v in response['versions'] if v['id']==doc['currentVersionId']]
120
+ require(doc['currentVersionId']==request['expectedCurrentVersionId'] and doc['id']==binding['documentId'] and doc['workspaceId']==binding['workspaceId'] and doc['organizationId']==binding['organizationId'] and len(versions)==1,'Inactive capture generation differs')
121
+ state=json.loads(versions[0]['bodyMarkdown']);require(state['format']=='topolo-cloud-work-state-v1' and state['datasetId']==binding['datasetId'] and state['generation']==0 and state.get('activated') is False,'Only the existing inactive cloud capture may receive prepared delta')
122
+ require(not {s['taskId'] for s in request['tasks']} & {r[0] for r in state['tables']['tasks']},'Previously captured tasks must retain their cloud records')
123
+ store=Store(source_root);directory=store.root/'delta-preparations';directory.mkdir(mode=0o700,exist_ok=True);operation=digest(request);path=directory/(operation+'.json');record=read_json(path,{})
124
+ if record:
125
+ require(record['request']==request,'Existing portable preparation differs')
126
+ else:
127
+ tasks={};workers={}
128
+ with store.db() as db:
129
+ capture=snapshot(db);prefix={'tables':{name:[row for row in capture['tables'][name] if row[0] in {old[0] for old in state['tables'][name]}] for name in capture['tables']}}
130
+ merge(state,prefix)
131
+ for selector in request['tasks']:
132
+ row=db.execute('SELECT body FROM tasks WHERE id=?',(selector['taskId'],)).fetchone();require(row is not None,'Unknown selected task');task=json.loads(row[0]);owned=[json.loads(w[0]) for w in db.execute('SELECT body FROM workers WHERE task_id=? ORDER BY id',(task['id'],))]
133
+ require(task['revision']==selector['revision'] and task['state'] in TERMINAL and owned,'Selected task is not at its reviewed closed checkpoint')
134
+ for worker in owned:checkpoint(store,task,worker)
135
+ tasks[task['id']]=task;workers[task['id']]=owned
136
+ record={'format':'topolo-portable-delta-v1','request':request,'tasks':tasks,'workers':workers,'prepared':{},'applied':{}};atomic_json(path,record)
137
+ for selector in request['tasks']:
138
+ if selector['taskId'] not in record['prepared']:
139
+ selected_artifacts(record['tasks'][selector['taskId']],record['workers'][selector['taskId']],selector)
140
+ for selector in request['tasks']:
141
+ tid=selector['taskId'];task=record['tasks'][tid];owned=record['workers'][tid];source_digest=digest({'task':task,'workers':owned})
142
+ if tid not in record['prepared']:
143
+ context=ensure(store.root,task,client);manifest=artifacts(store,task,owned,selector,client);proofs={};result=None;verification=None
144
+ for worker in owned:
145
+ selected=checkpoint(store,task,worker);output=selected['output']
146
+ if worker['phase']=='execute':
147
+ require(task['result']['summary']==output,'Task result differs from the original final assistant output')
148
+ output=canonical({'summary':output,'artifacts':[manifest],'references':['historical-checkpoint-sha256:'+worker['checkpoint']['sha256']]}).decode()
149
+ elif worker['phase']=='review':
150
+ review=json.loads(output);require(review['verdict']==task['verification']['verdict'] and review['summary']==task['verification']['summary'],'Task verification differs from its original terminal receipt')
151
+ output=canonical({'verdict':review['verdict'],'summary':review['summary'],'evidence':[manifest]}).decode()
152
+ payload={'format':'topolo-task-checkpoint-v1','taskId':tid,'workerId':worker['id'],'phase':worker['phase'],'nativeReceipt':selected['receipt'],'resolution':worker['resolution'],'roleSource':worker.get('roleSource'),'contextRef':context,'output':output,'outputError':None,'nativeHistoryIncluded':False}
153
+ artifact=publish(store.root,tid,'legacy-'+worker['id']+'-portable-checkpoint',canonical(payload),client=client);proof={'artifact':artifact,'sha256':artifact['sha256']}
154
+ actual=read_checkpoint(store.root,proof,tid,worker['id'],client);require(actual==payload,'Normalized cloud checkpoint readback differs');proofs[worker['id']]=proof
155
+ if worker['phase']=='execute':result={**json.loads(output),'workerId':worker['id'],'checkpoint':proof}
156
+ elif worker['phase']=='review':verification={**json.loads(output),'nativeCheckpoint':proof,'historicalEvidence':task['verification'].get('evidence',[])}
157
+ require(result is not None and verification is not None,'Original execution and review checkpoints are required')
158
+ record['prepared'][tid]={'workers':proofs,'changes':{'contextRef':context,'result':result,'verification':verification}};atomic_json(path,record)
159
+ prepared=record['prepared'][tid]
160
+ actual=store.portable_delta(tid,selector['revision'],source_digest,prepared['workers'],prepared['changes'],operation)
161
+ record['applied'][tid]={'revision':actual['revision'],'sourceDigest':source_digest};atomic_json(path,record)
162
+ return {'state':'portable-delta-prepared','operationId':operation,'tasks':record['applied'],'tasksCreated':0,'nativeRunsStarted':0,'cloudTaskStateChanged':False}
@@ -0,0 +1,142 @@
1
+ """Attach a durably fenced, idle Codex installation to its existing cloud dataset.
2
+
3
+ Only installation metadata changes. The legacy ledger remains permanently fenced;
4
+ cloud task rows and their owners are never written or adopted by this operation.
5
+ """
6
+ import copy,hashlib,json,pathlib,re,sqlite3,types
7
+ from .storage import Library,read_json,atomic_json,canonical,sync_directory
8
+ from .work import require
9
+ from .cloud_work import CloudStore
10
+ from .cloud_cutover import digest,snapshot
11
+ from . import closed_ledger_fence as fence,native,permissions
12
+ from .domain_context import references
13
+
14
+ FORMAT='topolo-existing-cloud-migration-v1'
15
+ FIELDS={'installationId','expectedFenceDigest','expectedMetadataDigest','expectedTargetCatalogDigest'}
16
+
17
+ def metadata(lib):
18
+ active=read_json(lib.root/'active.json');runtime=pathlib.Path(active['runtime'])
19
+ require(runtime.is_relative_to(lib.root/'runtimes') and runtime.resolve()==runtime,'Original runtime identity differs')
20
+ saved=read_json(lib.root/'work/config.json');packs={saved['pack'],*(c['pack'] for c in saved.get('channels',{}).values())}
21
+ files={name:read_json(lib.root/name) for name in ['active.json','work/config.json','work/role-bindings.json','work/context-authority.json']}
22
+ execution=read_json(runtime/'execution-policy.json');keys={c['key'] for c in active['components']}
23
+ selected=[{k:v for k,v in r.items() if k!='organizationPolicyProof'} for r in execution['roles'] if r['key'] in keys]
24
+ require(len(selected)==len(keys),'Original coordinator policy is ambiguous or missing')
25
+ # Task worker roles are appended/retired by the broker. Pin the original
26
+ # coordinator authority without treating authorized workers as policy drift.
27
+ files[str(runtime.relative_to(lib.root)/'execution-policy.json')]={k:v for k,v in execution.items() if k not in {'roles','applied','modelTurnsStarted'}}|{'roles':selected}
28
+ for pack in sorted(packs):
29
+ manifest,revision=lib.load(pack);files['manifest:'+pack]={'manifest':manifest,'revision':revision}
30
+ files['permissions/'+pack+'.json']=read_json(lib.root/'permissions'/(pack+'.json'))
31
+ files['dynamic-models/'+pack+'.json']=read_json(lib.root/'dynamic-models'/(pack+'.json'))
32
+ for name in files:
33
+ if not name.startswith('manifest:'):fence.owned(lib.root/name)
34
+ return files
35
+
36
+ def source(lib):
37
+ root=lib.root.resolve();work=root/'work';require(not (root/'live-bridge.json').exists() and not lib.pending(),'Original native owner must be stopped with no pending operation')
38
+ active=lib.active();native.assert_idle(pathlib.Path(active['runtime'])/'home')
39
+ record=read_json(work/'cutover.json');require(record.get('format')==fence.CAPABILITY and record.get('state')=='ledger-fenced' and record.get('root')==str(root),'Exact closed legacy fence required')
40
+ ident=fence.identity(root);require(ident==record['databaseIdentity'] and fence.code_digest()==record['codeDigest'],'Original fenced ledger ownership or guard changed')
41
+ fence.owned(work/'cutover.json')
42
+ from contextlib import closing
43
+ with closing(sqlite3.connect('file:'+str(work/'tasks.sqlite')+'?mode=ro',uri=True)) as db:
44
+ db.execute('BEGIN');rows=db.execute('SELECT operation_id,body FROM '+fence.META).fetchall()
45
+ require(len(rows)==1 and rows[0][0]==record['operationId'] and json.loads(rows[0][1])==record,'Immutable legacy fence receipt differs')
46
+ require(fence.schema(db,True)==record['schemaDigest'] and digest(snapshot(db))==record['sourceDigest']==digest(record['snapshot']),'Fenced legacy history changed')
47
+ require(fence.identity(root)==ident,'Legacy ledger identity changed during observation')
48
+ target=pathlib.Path(record['request']['destinationRoot']);fence.owned(target,True);fence.owned(target/'work/cloud.json')
49
+ binding=read_json(target/'work/cloud.json');require(digest(binding)==record['targetBindingDigest'],'Original target authority changed')
50
+ return record,target,binding
51
+
52
+ def verified(root):
53
+ """Every admission rechecks the exact installed metadata closure, never a flag."""
54
+ lib=Library(root)
55
+ record=read_json(lib.root/'work/cloud-migration.json',{})
56
+ require(record.get('format')==FORMAT and record.get('state')=='verified','Existing cloud migration is incomplete; reconcile before dispatch')
57
+ from .coordinator_refresh import effective
58
+ metadata_digest,catalog_digest=effective(lib,record)
59
+ require(digest(metadata(lib))==metadata_digest and read_json(lib.root/'work/cloud.json')==record['binding'] and digest(read_json(lib.root/'work/cutover.json'))==record['fenceDigest'],'Reviewed cloud migration metadata or authority changed')
60
+ require(record.get('preserveOtherInstallationOwnership') is True,'Cross-installation ownership must remain unchanged')
61
+ cutover=read_json(lib.root/'work/cutover.json');target=pathlib.Path(cutover['request']['destinationRoot'])
62
+ catalog=read_json(target/'work/role-catalog.json');registry=read_json(lib.root/'work/role-bindings.json')
63
+ require(digest(catalog)==catalog_digest,'Reviewed canonical specialization catalog changed')
64
+ by_logical={v['logicalId']:k for k,v in registry['roles'].items()}
65
+ selected={c:[by_logical[catalog['bindings'][k]['logicalId']] for k in catalog['channels'][c]['roleKeys']] for c in ['engineering','operations','cicd']}
66
+ require(selected==record['channelRoleKeys'],'Reviewed channel specialization closure changed')
67
+ return record
68
+
69
+ def plan(lib,request,client=None):
70
+ require(isinstance(request,dict) and set(request)==FIELDS and all(isinstance(v,str) and 1<=len(v)<=240 for v in request.values()) and re.fullmatch('[a-z0-9][a-z0-9-]{0,119}',request['installationId']),'Exact existing cloud migration selectors required')
71
+ require(not (lib.root/'work/cloud-migration.json').exists() and not (lib.root/'work/cloud.json').exists(),'Existing migration must be reconciled without replay')
72
+ record,target,binding=source(lib);before=metadata(lib)
73
+ require(digest(record)==request['expectedFenceDigest'] and digest(before)==request['expectedMetadataDigest'],'Reviewed original installation changed')
74
+ require(request['installationId']!=binding['installationId'],'A separate installation identity is required')
75
+ for name in ['role-catalog.json','role-bindings.json','config.json']:fence.owned(target/'work'/name)
76
+ target_catalog=read_json(target/'work/role-catalog.json');target_refs=read_json(target/'work/role-bindings.json')
77
+ target_config=read_json(target/'work/config.json')
78
+ require(digest(target_catalog)==request['expectedTargetCatalogDigest']==target_config['catalogDigest'] and target_refs['roles']==target_catalog['bindings'],'Reviewed target role catalog differs')
79
+ if client is None:
80
+ from .topolo import Client
81
+ client=Client('production','org:org_topolo_platform')
82
+ require(client.environment=='production' and client.context=='org:org_topolo_platform','Production organization context required')
83
+ actor=client.run(['whoami']);require(actor['organization']['id']=='org_topolo_platform','Current cloud organization differs')
84
+ for key,value in before.items():
85
+ if not key.startswith('manifest:'):continue
86
+ manifest=value['manifest'];pack=manifest['packId'];consent=permissions.load(lib,pack,manifest['agents'])
87
+ require(consent and consent['sourceRevision']==value['revision'] and consent['principalId']==actor['user']['id'] and consent['contextKey']==client.context,'Original reviewed installation consent differs')
88
+ from .topolo import organization_execution_policy
89
+ _,proof=organization_execution_policy(client,consent,manifest['agents'],consent.get('agentWorkspaceId'));permissions.authorize_native_scope(proof)
90
+ registry=before['work/role-bindings.json'];logical={r['logicalId']:r for r in target_refs['roles'].values()}
91
+ require(len(logical)==len(target_refs['roles'])==len(registry['roles']),'Canonical role identity inventory differs')
92
+ updated=copy.deepcopy(registry)
93
+ by_logical={v['logicalId']:k for k,v in registry['roles'].items()}
94
+ channel_roles={c:[by_logical[target_refs['roles'][k]['logicalId']] for k in target_catalog['channels'][c]['roleKeys']] for c in ['engineering','operations','cicd']}
95
+ for key,old in registry['roles'].items():
96
+ ref=logical.get(old['logicalId']);require(ref and all(old[k]==ref[k] for k in ['agentId','workspaceId','logicalId']),'Existing role identity cannot be replaced')
97
+ updated['roles'][key]=copy.deepcopy(ref)
98
+ # Live read-back is limited to changed role definitions; unchanged references
99
+ # are still pinned to the reviewed canonical catalog and checked at dispatch.
100
+ changed={k:v for k,v in updated['roles'].items() if v!=registry['roles'][k]}
101
+ require(all(v['logicalId']=='intakes/cicd' or v['logicalId'] in {'specialists/'+x for x in ['queue-recovery','runner-capacity','pipeline-engineering','promotion-deployment','release-verification']} for v in changed.values()),'Unexpected specialist definition change')
102
+ for ref in changed.values():
103
+ agent=client.action('app_topolo_agent.agents.get',{'id':ref['agentId']},ref['workspaceId'])['agent'];config=json.loads(agent['config']) if isinstance(agent['config'],str) else agent['config'];spec=config['specialization']
104
+ require(agent['id']==ref['agentId'] and agent['workspaceId']==ref['workspaceId'] and agent['organizationId']=='org_topolo_platform' and spec['format']=='topolo-role-specialization-v1' and spec['logicalId']==ref['logicalId'] and config['purpose']==spec['instructions'] and hashlib.sha256(spec['instructions'].encode()).hexdigest()==ref['instructionsSHA256'] and references(config,ref['logicalId'])==ref.get('domainContextRefs',[]),'Reviewed live role definition differs')
105
+ version,state=CloudStore.load(types.SimpleNamespace(binding=binding,client=client))
106
+ require(state['inventory']['count']==record['snapshot']['retained']['count'] and state['inventory']['sha256']==record['snapshot']['retained']['sha256'],'Current canonical dataset or retained inventory changed')
107
+ for name in ['tasks','workers']:
108
+ cloud={r[0]:r for r in state['tables'][name]}
109
+ for old in record['snapshot']['tables'][name]:require(old[0] in cloud and cloud[old[0]][1]==old[1],'Original task or worker identity is missing from cloud')
110
+ for row in cloud.values():
111
+ body=json.loads(row[2]);require(request['installationId'] not in [body.get('ownerInstallationId'),body.get('installationId'),body.get('nativePlan',{}).get('installationId')],'New installation identity already owns work')
112
+ saved=before['work/config.json'];require(set(saved) in [{'pack','revision','templateRuntime','maxWorkers','channels'},{'pack','revision','templateRuntime','maxWorkers','channels','frontdoorDispatch'}],'Exact existing legacy channel configuration required')
113
+ require('authority' not in saved and saved.get('frontdoorDispatch') in {None,False},'Original legacy task configuration differs')
114
+ after={**before,'work/config.json':{**saved,'authority':'topolo-docs','frontdoorDispatch':True},'work/role-bindings.json':updated}
115
+ roles=next(v['roles'] for k,v in before.items() if k.endswith('/execution-policy.json'))
116
+ keys={channel:(saved.get('channels',{}).get(channel,{}).get('pack',saved['pack'])+'/intakes/'+intake) for channel,intake in [('engineering','engineering'),('operations','operator-01'),('cicd','cicd')]}
117
+ native_keys={r['key']:r['threadId'] for r in roles};components={c['key']:c['threadId'] for c in before['active.json']['components']}
118
+ require(set(components)==set(keys.values()) and all(native_keys.get(k)==components.get(k) and components.get(k) for k in keys.values()),'Original three native coordinators differ')
119
+ for channel,key in keys.items():
120
+ pack=key.split('/')[0];manifest=before['manifest:'+pack]
121
+ require(set(channel_roles[channel])<={a['pack']+'/'+a['set']+'/'+a['id'] for a in manifest['manifest']['agents']} and before['dynamic-models/'+pack+'.json']['sourceRevision']==manifest['revision'],'Reviewed native channel templates or task model policy differ')
122
+ require(metadata(lib)==before and source(lib)[0]==record and read_json(target/'work/role-catalog.json')==target_catalog and read_json(target/'work/role-bindings.json')==target_refs and read_json(target/'work/config.json')==target_config,'Installation or canonical role source changed during planning')
123
+ return {'format':FORMAT,'preserveOtherInstallationOwnership':True,'request':request,'fenceDigest':digest(record),'cloudVersionId':version,'cloudDigest':digest(state),'binding':{**binding,'installationId':request['installationId']},'channelRoleKeys':channel_roles,'nativeCoordinators':{c:{'key':k,'threadId':components[k]} for c,k in keys.items()},'beforeMetadataDigest':digest(before),'afterMetadataDigest':digest(after),'before':before,'after':after,'changedRoles':sorted(changed),'cloudTaskOwners':{row[0]:json.loads(row[2]).get('ownerInstallationId') for row in state['tables']['tasks']},'taskCount':len(state['tables']['tasks']),'retainedCount':state['inventory']['count']}
124
+
125
+ def apply(lib,reviewed,client=None):
126
+ with lib.lock():
127
+ current=plan(lib,reviewed['request'],client)
128
+ # Cloud state is observed, never written by attachment. Its ordinary
129
+ # concurrent progress cannot invalidate an unchanged local effect plan.
130
+ diagnostics={'cloudVersionId','cloudDigest','cloudTaskOwners','taskCount'}
131
+ require({k:v for k,v in current.items() if k not in diagnostics}=={k:v for k,v in reviewed.items() if k not in diagnostics},'Reviewed migration local preimage or immutable authority changed')
132
+ root=lib.root/'work';record={**current,'reviewedCloudObservation':{k:reviewed[k] for k in diagnostics},'state':'prepared'}
133
+ with (root/'cloud-migration.json').open('x',encoding='utf8') as stream:
134
+ import os
135
+ os.chmod(stream.name,0o600);stream.write(canonical(record).decode());stream.flush();os.fsync(stream.fileno())
136
+ sync_directory(root)
137
+ atomic_json(root/'role-bindings.json',reviewed['after']['work/role-bindings.json'])
138
+ atomic_json(root/'config.json',reviewed['after']['work/config.json'])
139
+ atomic_json(root/'cloud.json',reviewed['binding'])
140
+ require(digest(metadata(lib))==reviewed['afterMetadataDigest'],'Migrated metadata read-back differs')
141
+ atomic_json(root/'cloud-migration.json',{**record,'state':'verified'});verified(lib.root)
142
+ return {'state':'existing-cloud-authority-attached','installationId':reviewed['binding']['installationId'],'tasksCreated':0,'workersAdopted':0,'cloudTaskStateChanged':False,'nativeCoordinatorsPreserved':reviewed['nativeCoordinators']}
@@ -0,0 +1,42 @@
1
+ """Restore verified evidence from Bytes into a fresh, non-executing directory.
2
+
3
+ The reference is portable, contains no credential or chat history, and does not
4
+ grant a new machine ownership of running workers or permission to replay tasks.
5
+ """
6
+ import gzip,hashlib,json,pathlib,tempfile
7
+ from .storage import atomic_json
8
+ from .work import require
9
+
10
+ def recover(reference,destination,client=None):
11
+ require(set(reference)=={'format','organizationId','artifact','uncompressedSHA256'},'Invalid cloud recovery reference')
12
+ require(reference['format']=='topolo-work-recovery-v1' and reference['organizationId']=='org_topolo_platform','Cloud recovery identity differs')
13
+ target=pathlib.Path(destination)
14
+ require(not target.exists() and not target.is_symlink(),'Recovery destination must be genuinely new')
15
+ if client is None:
16
+ from .topolo import Client
17
+ client=Client('production','org:org_topolo_platform')
18
+ from .topolo import read_artifact
19
+ ref=reference['artifact']
20
+ with tempfile.TemporaryDirectory(prefix='topolo-cloud-recovery-') as tmp:
21
+ archive=pathlib.Path(tmp)/'archive.gz'
22
+ read_artifact(client,{'input':{'bytesScope':ref['scope'],'bytesWorkspaceId':ref['workspaceId']}},ref['key'],ref,archive)
23
+ with gzip.open(archive,'rb') as stream:raw=stream.read(100*1024*1024+1)
24
+ require(len(raw)<=100*1024*1024,'Recovery archive exceeds decompression boundary')
25
+ require(hashlib.sha256(raw).hexdigest()==reference['uncompressedSHA256'],'Recovery archive digest differs')
26
+ bundle=json.loads(raw)
27
+ require(bundle['format']=='topolo-work-migration-evidence-v1' and bundle['executionAuthorized'] is False and bundle['nativeChatHistoryIncluded'] is False,'Recovery archive is not a qualified evidence snapshot')
28
+ files={}
29
+ for original,entry in bundle['files'].items():
30
+ data=entry['text'].encode()
31
+ require(hashlib.sha256(data).hexdigest()==entry['sha256'],'Recovered file digest differs')
32
+ # Never restore the original absolute path, follow links, or execute it.
33
+ files[original]={'sha256':entry['sha256'],'cachePath':'objects/'+entry['sha256']}
34
+ target.mkdir(parents=True,mode=0o700)
35
+ (target/'objects').mkdir(mode=0o700)
36
+ for original,entry in bundle['files'].items():
37
+ path=target/files[original]['cachePath']
38
+ if not path.exists():path.write_bytes(entry['text'].encode());path.chmod(0o600)
39
+ atomic_json(target/'task-state.json',bundle['taskState'])
40
+ result={'format':'topolo-work-recovery-receipt-v1','authority':'verified-cloud-evidence-snapshot','executionAuthorized':False,'nativeChatHistoryIncluded':False,'files':files,'missing':bundle['missing'],'taskIds':[r[0] for r in bundle['taskState']['tasks']],'workerIds':[r[0] for r in bundle['taskState']['workers']],'tasksCreated':0,'workersAdopted':0,'reference':reference}
41
+ atomic_json(target/'recovery.json',result)
42
+ return result