@topolo/cli 0.13.17 → 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.
- package/README.md +18 -4
- package/agent-packs/README.md +469 -1
- package/agent-packs/coverage_changed.py +1 -1
- package/agent-packs/docs/selected-artifacts.md +47 -0
- package/agent-packs/documentation/cloud-task-state.md +56 -0
- package/agent-packs/domain-contexts/README.md +25 -0
- package/agent-packs/domain-contexts/cicd.json +8 -0
- package/agent-packs/gui/feed-adapter.js +4 -0
- package/agent-packs/gui/index.html +9 -0
- package/agent-packs/gui/studio.css +14 -0
- package/agent-packs/gui/studio.js +121 -0
- package/agent-packs/gui/studio_server.py +355 -0
- package/agent-packs/roles/cicd/CLAUDE.md +24 -0
- package/agent-packs/specializations/cicd--cicd.json +5 -0
- package/agent-packs/specializations/cicd--pipeline-engineering.json +5 -0
- package/agent-packs/specializations/cicd--promotion-deployment.json +5 -0
- package/agent-packs/specializations/cicd--queue-recovery.json +5 -0
- package/agent-packs/specializations/cicd--release-verification.json +5 -0
- package/agent-packs/specializations/cicd--runner-capacity.json +5 -0
- package/agent-packs/specializations/intakes--engineering-02.json +5 -0
- package/agent-packs/specializations/intakes--engineering-03.json +5 -0
- package/agent-packs/specializations/intakes--engineering.json +5 -0
- package/agent-packs/specializations/intakes--operator-01.json +5 -0
- package/agent-packs/specializations/intakes--operator-02.json +5 -0
- package/agent-packs/specializations/operator-skills--audio-and-narration-specialist.json +5 -0
- package/agent-packs/specializations/operator-skills--brand-and-copywriting-specialist.json +5 -0
- package/agent-packs/specializations/operator-skills--capability-research-specialist.json +5 -0
- package/agent-packs/specializations/operator-skills--data-analysis-and-visualization.json +5 -0
- package/agent-packs/specializations/operator-skills--financial-evidence-specialist.json +5 -0
- package/agent-packs/specializations/operator-skills--operating-template-specialist.json +5 -0
- package/agent-packs/specializations/operator-skills--operator-skill-library.json +5 -0
- package/agent-packs/specializations/operator-skills--operator-skills.json +5 -0
- package/agent-packs/specializations/operator-skills--operator-workflow-evaluation.json +5 -0
- package/agent-packs/specializations/operator-skills--privacy-and-agreement-review.json +5 -0
- package/agent-packs/specializations/operator-skills--storytelling-specialist.json +5 -0
- package/agent-packs/specializations/operator-skills--ux-and-accessibility-specialist.json +5 -0
- package/agent-packs/specializations/operator-skills--video-production-specialist.json +5 -0
- package/agent-packs/specializations/operator-skills--visual-design-specialist.json +5 -0
- package/agent-packs/specializations/operator-skills--workflow-integration-specialist.json +5 -0
- package/agent-packs/specializations/operators--access-and-privacy.json +5 -0
- package/agent-packs/specializations/operators--accounting-evidence-review.json +5 -0
- package/agent-packs/specializations/operators--agreement-operations.json +5 -0
- package/agent-packs/specializations/operators--books-operator.json +5 -0
- package/agent-packs/specializations/operators--brand-operator.json +5 -0
- package/agent-packs/specializations/operators--commerce-operations.json +5 -0
- package/agent-packs/specializations/operators--communications-operator.json +5 -0
- package/agent-packs/specializations/operators--content-and-assets-operator.json +5 -0
- package/agent-packs/specializations/operators--customer-operations.json +5 -0
- package/agent-packs/specializations/operators--design-operator.json +5 -0
- package/agent-packs/specializations/operators--design-production-02.json +5 -0
- package/agent-packs/specializations/operators--design-production-03.json +5 -0
- package/agent-packs/specializations/operators--design-production-04.json +5 -0
- package/agent-packs/specializations/operators--device-and-feed-operator.json +5 -0
- package/agent-packs/specializations/operators--director-operator.json +5 -0
- package/agent-packs/specializations/operators--experience-review.json +5 -0
- package/agent-packs/specializations/operators--growth-operations.json +5 -0
- package/agent-packs/specializations/operators--learn-operator.json +5 -0
- package/agent-packs/specializations/operators--localization-operator.json +5 -0
- package/agent-packs/specializations/operators--operators.json +5 -0
- package/agent-packs/specializations/operators--pay-operator.json +5 -0
- package/agent-packs/specializations/operators--research-and-intelligence.json +5 -0
- package/agent-packs/specializations/operators--roadmap-operator.json +5 -0
- package/agent-packs/specializations/operators--social-content-operator.json +5 -0
- package/agent-packs/specializations/operators--talent-operations.json +5 -0
- package/agent-packs/specializations/operators--web-operator.json +5 -0
- package/agent-packs/specializations/operators--workflow-operator.json +5 -0
- package/agent-packs/specializations/specialists--adversarial-qa.json +5 -0
- package/agent-packs/specializations/specialists--backlog-dispatch.json +5 -0
- package/agent-packs/specializations/specialists--build-01.json +5 -0
- package/agent-packs/specializations/specialists--build-02.json +5 -0
- package/agent-packs/specializations/specialists--build-03.json +5 -0
- package/agent-packs/specializations/specialists--developer-environment-steward.json +5 -0
- package/agent-packs/specializations/specialists--documentation-knowledge-steward.json +5 -0
- package/agent-packs/specializations/specialists--execution-readiness.json +5 -0
- package/agent-packs/specializations/specialists--incident-triage.json +5 -0
- package/agent-packs/specializations/specialists--platform-package-steward.json +5 -0
- package/agent-packs/specializations/specialists--product-direction-01.json +5 -0
- package/agent-packs/specializations/specialists--product-direction-02.json +5 -0
- package/agent-packs/specializations/specialists--release-recovery.json +5 -0
- package/agent-packs/specializations/specialists--release-watch.json +5 -0
- package/agent-packs/specializations/specialists--roadmap-planning-01.json +5 -0
- package/agent-packs/specializations/specialists--roadmap-planning-02.json +5 -0
- package/agent-packs/specializations/specialists--security-permissions-review.json +5 -0
- package/agent-packs/specializations/specialists--system-architecture-01.json +5 -0
- package/agent-packs/specializations/specialists--system-architecture-02.json +5 -0
- package/agent-packs/specializations/specialists--translation.json +5 -0
- package/agent-packs/specializations/specialists--ux-consistency-review.json +5 -0
- package/agent-packs/specializations/specialists--workflow-improvement.json +5 -0
- package/agent-packs/specializations/specialists--worktree-retirement.json +5 -0
- package/agent-packs/src/topolo_agent_pack/cicd.py +101 -0
- package/agent-packs/src/topolo_agent_pack/claude.py +22 -7
- package/agent-packs/src/topolo_agent_pack/claude_activation.py +157 -0
- package/agent-packs/src/topolo_agent_pack/claude_completion_recovery.py +216 -0
- package/agent-packs/src/topolo_agent_pack/claude_desktop.py +181 -0
- package/agent-packs/src/topolo_agent_pack/claude_dispatch.py +240 -0
- package/agent-packs/src/topolo_agent_pack/claude_prompt.py +74 -0
- package/agent-packs/src/topolo_agent_pack/claude_runner.py +207 -0
- package/agent-packs/src/topolo_agent_pack/claude_service.py +397 -0
- package/agent-packs/src/topolo_agent_pack/cli.py +188 -18
- package/agent-packs/src/topolo_agent_pack/closed_ledger_fence.py +118 -0
- package/agent-packs/src/topolo_agent_pack/cloud_attach.py +128 -0
- package/agent-packs/src/topolo_agent_pack/cloud_cutover.py +134 -0
- package/agent-packs/src/topolo_agent_pack/cloud_delta.py +162 -0
- package/agent-packs/src/topolo_agent_pack/cloud_migrate.py +142 -0
- package/agent-packs/src/topolo_agent_pack/cloud_recovery.py +42 -0
- package/agent-packs/src/topolo_agent_pack/cloud_work.py +126 -0
- package/agent-packs/src/topolo_agent_pack/coordinator_activity.py +27 -0
- package/agent-packs/src/topolo_agent_pack/coordinator_context.py +91 -0
- package/agent-packs/src/topolo_agent_pack/coordinator_refresh.py +116 -0
- package/agent-packs/src/topolo_agent_pack/desktop_acceptance.py +45 -0
- package/agent-packs/src/topolo_agent_pack/desktop_launcher.py +114 -0
- package/agent-packs/src/topolo_agent_pack/domain_context.py +38 -0
- package/agent-packs/src/topolo_agent_pack/execution.py +104 -7
- package/agent-packs/src/topolo_agent_pack/fresh.py +1 -1
- package/agent-packs/src/topolo_agent_pack/frontdoor.py +23 -0
- package/agent-packs/src/topolo_agent_pack/linked_task_context.py +80 -0
- package/agent-packs/src/topolo_agent_pack/live.py +285 -18
- package/agent-packs/src/topolo_agent_pack/native.py +160 -4
- package/agent-packs/src/topolo_agent_pack/policy.py +8 -0
- package/agent-packs/src/topolo_agent_pack/review_context.py +33 -0
- package/agent-packs/src/topolo_agent_pack/role_catalog.py +137 -0
- package/agent-packs/src/topolo_agent_pack/role_context.py +63 -0
- package/agent-packs/src/topolo_agent_pack/role_refresh.py +50 -0
- package/agent-packs/src/topolo_agent_pack/task_models.py +11 -7
- package/agent-packs/src/topolo_agent_pack/throughput.py +149 -0
- package/agent-packs/src/topolo_agent_pack/topolo.py +7 -1
- package/agent-packs/src/topolo_agent_pack/work.py +322 -0
- package/agent-packs/src/topolo_agent_pack/work_adoption.py +122 -0
- package/agent-packs/src/topolo_agent_pack/work_artifacts.py +84 -0
- package/agent-packs/src/topolo_agent_pack/work_context.py +77 -0
- package/agent-packs/src/topolo_agent_pack/work_gui.py +51 -0
- package/agent-packs/src/topolo_agent_pack/work_native.py +414 -0
- package/agent-packs/src/topolo_agent_pack/work_wait.py +151 -0
- package/agent-packs/templates/topolo-coordinators/AGENTS.md +7 -0
- package/agent-packs/templates/topolo-coordinators/agents/intakes/cicd/AGENTS.md +6 -0
- package/agent-packs/templates/topolo-coordinators/agents/intakes/cicd/config.json +4 -0
- package/agent-packs/templates/topolo-coordinators/agents/intakes/engineering/AGENTS.md +3 -0
- package/agent-packs/templates/topolo-coordinators/agents/intakes/engineering/config.json +4 -0
- package/agent-packs/templates/topolo-coordinators/agents/intakes/operator-01/AGENTS.md +3 -0
- package/agent-packs/templates/topolo-coordinators/agents/intakes/operator-01/config.json +4 -0
- package/agent-packs/templates/topolo-coordinators/pack.json +56 -0
- package/dist/chunk-NYDIAYVY.js +144 -0
- package/dist/{claude-pack-backend-YEVPAOID.js → chunk-UY5LEU5C.js} +1659 -21
- package/dist/claude-desktop-profile-JCQKSY3K.js +13 -0
- package/dist/claude-pack-activation-Q25MCEPU.js +506 -0
- package/dist/claude-pack-backend-HUTKGIDS.js +90 -0
- package/dist/index.js +408 -164
- package/package.json +8 -6
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Native Claude task-profile definitions, not persistent worker sessions."""
|
|
2
|
+
import argparse
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import pathlib
|
|
6
|
+
from .storage import PackError, atomic_json
|
|
7
|
+
from .task_models import default_configuration, _selection
|
|
8
|
+
|
|
9
|
+
DISCIPLINES = {
|
|
10
|
+
'queue-recovery': 'Reconcile existing intents, dependencies, queue age, leases and uncertain effects. Preserve ownership. Never clear holds or replay operations without verified reconciliation.',
|
|
11
|
+
'runner-capacity': 'Diagnose runner eligibility, architecture, admission, locking, utilisation and accepted job evidence. Do not change capacities or restart services without the task authority.',
|
|
12
|
+
'pipeline-engineering': 'Diagnose and repair assigned validation, caching, artifact, provenance or scheduler defects in an isolated dev worktree. Follow the normal review and release process.',
|
|
13
|
+
'promotion-deployment': 'Advance explicitly eligible existing intents through DEV, STG and PROD using the owning release controller. Verify exact SHA and artifact custody. Never create duplicate handoffs or bypass a train.',
|
|
14
|
+
'release-verification': 'Independently inspect exact release intent, deployment revision and actual environment proof. Do not implement source changes or perform deployment effects during verification.'}
|
|
15
|
+
PROFILES = {'trivial': ('claude-haiku-5-5','low'), 'routine': ('claude-sonnet-5-5','low'), 'standard': ('claude-sonnet-5-5','medium'), 'deep': ('claude-opus-5-5','high')}
|
|
16
|
+
RELEASE_RISK_TYPES = frozenset({'production-deployment','effect-reconciliation','signing-custody','concurrency-repair'})
|
|
17
|
+
COMMON = '''Execute only the bounded assignment, then return its checkpoint and stop. Do not spawn other agents, resume old workers, create schedules or send peer messages. Topolo Agent contains reusable specialization only. Load current task context from the referenced Topolo Docs/Bytes packet, preserving canonical task, intent, owner and effect identities. An empty local workspace grants no permission to repeat work. Local task packets are disposable caches. Refuse missing context or unresolved effects; never silently invent completion. Record outcome, next action and evidence in the existing task/GUI through supported mechanisms. A missing cloud or feed capability is a blocker, not permission to substitute chat history. No production mutation is authorized by this definition alone.'''
|
|
18
|
+
from .coordinator_context import CONTRACT as COORDINATOR
|
|
19
|
+
|
|
20
|
+
def definitions():
|
|
21
|
+
result={}
|
|
22
|
+
for discipline,prompt in DISCIPLINES.items():
|
|
23
|
+
for profile,(model,effort) in PROFILES.items():
|
|
24
|
+
name='cicd-'+discipline+'-'+profile
|
|
25
|
+
result[name]={'description':discipline+'; '+profile+' task profile. Select using context/CICD-DISPATCH.md.', 'model':model,'effort':effort,'maxTurns':24,'permissionMode':'bypassPermissions','disallowedTools':['Agent'],'prompt':prompt+'\n\n'+COMMON}
|
|
26
|
+
if profile=='trivial':result[name]['tools']=['Read','Glob','Grep']
|
|
27
|
+
return result
|
|
28
|
+
|
|
29
|
+
def select(discipline,task):
|
|
30
|
+
if discipline not in DISCIPLINES:
|
|
31
|
+
raise PackError('Unknown CI/CD discipline')
|
|
32
|
+
config=default_configuration();constraints={'allowedModels':sorted({v[0] for v in PROFILES.values()}),'allowedEfforts':['low','medium','high']}
|
|
33
|
+
selection=_selection(config,task,'claude',constraints)
|
|
34
|
+
if task['type'] in RELEASE_RISK_TYPES and selection['rule']['profile']!='deep':
|
|
35
|
+
raise PackError('Release-risk tasks require a deep profile')
|
|
36
|
+
return 'cicd-'+discipline+'-'+selection['rule']['profile']
|
|
37
|
+
|
|
38
|
+
def install(workspace):
|
|
39
|
+
workspace=pathlib.Path(workspace).expanduser().resolve()
|
|
40
|
+
if not (workspace/'CLAUDE.md').is_file():
|
|
41
|
+
raise PackError('Existing coordinator CLAUDE.md required')
|
|
42
|
+
rows=definitions(); files={}
|
|
43
|
+
for name,row in rows.items():
|
|
44
|
+
header={k:v for k,v in row.items() if k!='prompt'};header={'name':name,**header}
|
|
45
|
+
# JSON values are valid YAML scalars and avoid implicit YAML conversions.
|
|
46
|
+
body='---\n'+'\n'.join(k+': '+json.dumps(v) for k,v in header.items())+'\n---\n\n'+row['prompt']+'\n'
|
|
47
|
+
files[workspace/'.claude/agents'/(name+'.md')]=body
|
|
48
|
+
guide='''# CI/CD dispatch contract
|
|
49
|
+
|
|
50
|
+
Use one fresh native Agent invocation per bounded task. Never resume a completed worker. These files are reusable templates, not sidebar sessions. Select the discipline and task profile before dispatch; pass task identity, canonical Docs/Bytes references, current owner, exact release intent, scope, acceptance and effect authority in the invocation. If an existing owner holds the task, reconcile rather than replace them.
|
|
51
|
+
|
|
52
|
+
Task profiles: trivial = Haiku 5.5 low, only low-risk format/extract-fields/reference-lookup/status-read; routine = Sonnet 5.5 low; standard = Sonnet 5.5 medium; complex or security-critical = Opus 5.5 high. Ordinary implementation is never trivial. Production effects, uncertain recovery, signing/custody and concurrency require deep. Choose the matching cicd-<discipline>-<profile> subagent_type; do not override its model or inherit the coordinator effort. Native model rejection is a visible blocker, never a silent fallback.
|
|
53
|
+
|
|
54
|
+
Disciplines: queue-recovery, runner-capacity, pipeline-engineering, promotion-deployment, release-verification. For simple read-only status extraction use queue-recovery-trivial. Before mutations read the current owning controller records, current signed authority and unresolved operations. Do not duplicate existing intents. Independent release verification uses a fresh release-verification worker. Completed model turns do not prove delivery.
|
|
55
|
+
|
|
56
|
+
Dispatch may run independent bounded tasks concurrently subject to current admitted capacity and disjoint ownership. Coordinator remains available for human input. Publish each meaningful checkpoint once to the existing canonical task and GUI; missing platform persistence or dispatch must be reported explicitly. Do not claim cross-machine duplicate protection from a local lock.
|
|
57
|
+
'''
|
|
58
|
+
files[workspace/'context/CICD-DISPATCH.md']=guide
|
|
59
|
+
# Reject unowned conflicting files before any write.
|
|
60
|
+
receipt_path=workspace/'.claude/cicd-installation.json'
|
|
61
|
+
prior=json.loads(receipt_path.read_text()) if receipt_path.exists() else {'files':{}}
|
|
62
|
+
for path,body in files.items():
|
|
63
|
+
if path.is_symlink():
|
|
64
|
+
raise PackError('Symlinked CI/CD definition')
|
|
65
|
+
if path.exists() and path.read_text()!=body and hashlib.sha256(path.read_bytes()).hexdigest()!=prior['files'].get(str(path.relative_to(workspace))):
|
|
66
|
+
raise PackError('Changed definition requires reconciliation: '+str(path))
|
|
67
|
+
for path,body in files.items():path.parent.mkdir(parents=True,exist_ok=True);path.write_text(body);path.chmod(0o600)
|
|
68
|
+
marker='\n\n## Native CI/CD specialist dispatch\nRead context/CICD-DISPATCH.md before assigning release work. Use the project cicd-* native subagents with explicit task profiles.\n'
|
|
69
|
+
charter=workspace/'CLAUDE.md'
|
|
70
|
+
# Older generated coordinators accidentally carried the worker-only contract.
|
|
71
|
+
# Replace only that exact known prefix; preserve the remaining role charter.
|
|
72
|
+
body=charter.read_text()
|
|
73
|
+
if body.startswith(COMMON):
|
|
74
|
+
charter.write_text(COORDINATOR+body[len(COMMON):])
|
|
75
|
+
if marker not in charter.read_text():
|
|
76
|
+
with charter.open('a') as stream:stream.write(marker)
|
|
77
|
+
receipt={'format':'topolo-cicd-specialists','schemaVersion':1,'profiles':PROFILES,'files':{str(p.relative_to(workspace)):hashlib.sha256(p.read_bytes()).hexdigest() for p in files},'workersCreated':0,'activationVerified':False}
|
|
78
|
+
atomic_json(receipt_path,receipt);return receipt
|
|
79
|
+
|
|
80
|
+
def main():
|
|
81
|
+
parser=argparse.ArgumentParser();sub=parser.add_subparsers(dest='action',required=True)
|
|
82
|
+
p=sub.add_parser('install');p.add_argument('workspace')
|
|
83
|
+
p=sub.add_parser('select');p.add_argument('discipline',choices=DISCIPLINES);p.add_argument('--task',required=True)
|
|
84
|
+
args=parser.parse_args();print(json.dumps(install(args.workspace) if args.action=='install' else {'subagent':select(args.discipline,json.loads(args.task))},indent=2))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def configure_profile(config):
|
|
88
|
+
"""Set the pack-owned user scope; project scope cannot enable bypass."""
|
|
89
|
+
config=pathlib.Path(config).expanduser().resolve()
|
|
90
|
+
config.mkdir(parents=True,exist_ok=True,mode=0o700)
|
|
91
|
+
path=config/'settings.json'
|
|
92
|
+
if path.is_symlink():
|
|
93
|
+
raise PackError('Symlinked Claude user settings')
|
|
94
|
+
settings=json.loads(path.read_text()) if path.exists() else {}
|
|
95
|
+
settings.setdefault('permissions',{})['defaultMode']='bypassPermissions'
|
|
96
|
+
settings['model']='claude-sonnet-5-5'
|
|
97
|
+
settings.setdefault('modelSettings',{}).setdefault('claude-sonnet-5-5',{})['effortLevel']='low'
|
|
98
|
+
atomic_json(path,settings)
|
|
99
|
+
return {'path':str(path),'settingsReadback':json.loads(path.read_text()),'existingDesktopSessionsVerified':False}
|
|
100
|
+
|
|
101
|
+
if __name__=='__main__':main()
|
|
@@ -10,6 +10,7 @@ import json
|
|
|
10
10
|
import os
|
|
11
11
|
import pathlib
|
|
12
12
|
import pty
|
|
13
|
+
import re
|
|
13
14
|
import select as io_select
|
|
14
15
|
import shutil
|
|
15
16
|
import subprocess
|
|
@@ -28,19 +29,21 @@ def find_binary():
|
|
|
28
29
|
return shutil.which('claude')
|
|
29
30
|
|
|
30
31
|
|
|
31
|
-
def handoff(binary, directory, session_id):
|
|
32
|
+
def handoff(binary, directory, session_id, env=None):
|
|
32
33
|
"""Native --desktop requires a TTY. No prompt, model call or shell is used."""
|
|
33
34
|
if not binary:
|
|
34
35
|
raise PackError('Claude Code 2.1.285+ is required; install Claude Desktop or Claude Code.')
|
|
35
36
|
version = subprocess.run([binary, '--version'], capture_output=True, text=True, timeout=10)
|
|
36
|
-
|
|
37
|
-
|
|
37
|
+
release = re.match(r'^(\d+)\.(\d+)\.(\d+)\s+\(Claude Code\)', version.stdout)
|
|
38
|
+
if version.returncode or release is None or tuple(map(int, release.groups())) < (2, 1, 285):
|
|
39
|
+
raise PackError('Native import requires the Desktop handoff capability introduced in Claude Code 2.1.285; selected binary: ' + version.stdout.strip())
|
|
38
40
|
master, slave = pty.openpty()
|
|
39
41
|
process = None
|
|
40
42
|
output = bytearray()
|
|
41
43
|
try:
|
|
42
44
|
process = subprocess.Popen([binary, '--desktop', '--resume', session_id],
|
|
43
|
-
cwd=directory, stdin=slave, stdout=slave, stderr=slave
|
|
45
|
+
cwd=directory, stdin=slave, stdout=slave, stderr=slave,
|
|
46
|
+
**({'env':env} if env is not None else {}))
|
|
44
47
|
os.close(slave)
|
|
45
48
|
slave = None
|
|
46
49
|
deadline = time.monotonic() + 20
|
|
@@ -105,6 +108,10 @@ def materialize(lib, name, selected, config):
|
|
|
105
108
|
if record['sourceRevision'] != revision:
|
|
106
109
|
raise PackError('Existing Claude agent uses a different revision; build a new named pack to preserve its context.')
|
|
107
110
|
if record['importState'] != 'preparing':
|
|
111
|
+
if agent['id'] == 'cicd':
|
|
112
|
+
from .cicd import install, configure_profile
|
|
113
|
+
install(record['workspace'])
|
|
114
|
+
configure_profile(config)
|
|
108
115
|
records.append(record)
|
|
109
116
|
continue
|
|
110
117
|
else:
|
|
@@ -139,12 +146,19 @@ def materialize(lib, name, selected, config):
|
|
|
139
146
|
if relative.as_posix() == 'AGENTS.md':
|
|
140
147
|
relative = pathlib.Path('CLAUDE.md')
|
|
141
148
|
write_new(workspace / relative, lib.object(resource['content']).read_bytes())
|
|
149
|
+
if agent['id'] == 'cicd':
|
|
150
|
+
from .cicd import install, configure_profile
|
|
151
|
+
install(workspace)
|
|
152
|
+
configure_profile(config)
|
|
142
153
|
session_id = record['cliSessionId']
|
|
143
154
|
transcript = pathlib.Path(record['transcript'])
|
|
144
155
|
note = (agent['title'] + '. You are this Topolo Agent Pack role in Claude Desktop. '
|
|
145
156
|
'Read CLAUDE.md and relevant context/ files before work. '
|
|
146
157
|
'This is starting context only; no assignment, claim or prior conversation is inherited. '
|
|
147
|
-
'Await a new human request.
|
|
158
|
+
'Await a new human request. A human request authorizes the necessary bounded specialist dispatch '
|
|
159
|
+
'within its scope; do not ask for that authorization again. Preserve existing owners, holds '
|
|
160
|
+
'and action-specific approval requirements. Use the configured dispatch adapter and canonical '
|
|
161
|
+
'task records; do not substitute coordinator execution when dispatch is unavailable.')
|
|
148
162
|
rows = [{'parentUuid': None, 'isSidechain': False, 'userType': 'external',
|
|
149
163
|
'cwd': str(workspace), 'sessionId': session_id, 'version': '2.1.289',
|
|
150
164
|
'type': 'user', 'message': {'role': 'user', 'content': note},
|
|
@@ -214,7 +228,8 @@ def main(argv):
|
|
|
214
228
|
if args.command in {'status', 'doctor', 'recover', 'list'}:
|
|
215
229
|
proof = readback(state['agents'], app_data)
|
|
216
230
|
active_path = lib.root / 'claude/active.json'
|
|
217
|
-
pending_path = lib.root / 'claude/
|
|
231
|
+
pending_path = lib.root / ('claude/startup-operation.json' if
|
|
232
|
+
(lib.root / 'claude/startup-operation.json').exists() else 'claude/native-operation.json')
|
|
218
233
|
result = {**state, 'adoptionProof': proof, 'verifiedAgents': sum(p['verified'] for p in proof),
|
|
219
234
|
'fullPackSwitchSupported': False, 'sidebarGroupsAndPinsVerified': False,
|
|
220
235
|
'nativeRecordsRewritten': False, 'modelIdentityResponseVerified': False,
|
|
@@ -224,7 +239,7 @@ def main(argv):
|
|
|
224
239
|
'nativeRecoveryReplayed': False,
|
|
225
240
|
'verificationBoundary': 'read-only adoption records; use the explicit native connection for current settings or recovery'}
|
|
226
241
|
else:
|
|
227
|
-
if (lib.root / 'claude/native-operation.json'
|
|
242
|
+
if any((lib.root / ('claude/'+name)).exists() for name in ['native-operation.json','startup-operation.json','activation-operation.json','launch-operation.json']):
|
|
228
243
|
raise PackError('An unresolved native Claude operation blocks imports and preparation. Use topolo agent-packs recover with the explicit native connection; no effects are replayed.')
|
|
229
244
|
manifest, revision = lib.load(args.source)
|
|
230
245
|
chosen = select(manifest, args.sets, args.agents)
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Read-only cloud and service evidence for the exact installed Desktop roster."""
|
|
2
|
+
import hashlib,json,os,time,fcntl
|
|
3
|
+
from .storage import Library,read_json,PackError,canonical
|
|
4
|
+
from .work import require
|
|
5
|
+
from .cloud_work import CloudStore
|
|
6
|
+
from .work_artifacts import read_checkpoint
|
|
7
|
+
from .claude_service import ensure_running,verify_retired_binding
|
|
8
|
+
from .live import request
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def observe(root,qualification_task=None,previous=None,connection=None,start_service=False,native_proof=False,wait_retired_service=False,require_native_idle=False,successor_connection=None,warm_connection=None):
|
|
12
|
+
lib=Library(root);store=CloudStore(root);version,state=store.load()
|
|
13
|
+
if warm_connection is not None:
|
|
14
|
+
require(previous is None and connection is None and not start_service and not wait_retired_service and not require_native_idle and not native_proof and qualification_task is None and successor_connection is None,'Warm restore cannot change lifecycle custody')
|
|
15
|
+
binding=read_json(lib.root/'work/claude-frontdoors.json')
|
|
16
|
+
require(isinstance(warm_connection,dict) and set(warm_connection)=={'pid','runtime','identity','socket','secretPath'} and binding.get('nativeConnection')==warm_connection and binding.get('desktopPid')==warm_connection['pid'] and warm_connection['runtime']==str(lib.root/'claude/task-runtime'),'Current warm binding differs')
|
|
17
|
+
ensure_running(lib)
|
|
18
|
+
service=request(lib,'status',{})
|
|
19
|
+
require(service.get('harness')=='claude' and service.get('cloudDispatchReady') is True and service.get('desktopPid')==warm_connection['pid'] and service.get('runtime')==warm_connection['runtime'] and isinstance(service.get('activeTurns'),list) and all(isinstance(w,str) for w in service['activeTurns']) and len(set(service['activeTurns']))==len(service['activeTurns']),'Authenticated warm service differs')
|
|
20
|
+
from .claude_desktop import call
|
|
21
|
+
status=call(warm_connection,{'kind':'status'})
|
|
22
|
+
require(status.get('harness')=='claude-desktop' and status.get('pid')==warm_connection['pid'] and status.get('identity')==warm_connection['identity'] and status.get('qualified') is True and type(status.get('workers')) is int and status['workers']>=0,'Qualified warm native control differs')
|
|
23
|
+
return {'warmOwner':True,'nativeInputsSent':False,'workerCustodyPreserved':True,'service':service,'binding':store.binding}
|
|
24
|
+
if successor_connection is not None:
|
|
25
|
+
require(previous is None and connection is None and not start_service and not wait_retired_service and not require_native_idle and not native_proof and qualification_task is None,'Exact successor observation cannot change lifecycle custody')
|
|
26
|
+
binding=read_json(lib.root/'work/claude-frontdoors.json')
|
|
27
|
+
require(isinstance(successor_connection,dict) and set(successor_connection)=={'pid','runtime','identity','socket','secretPath'} and binding.get('nativeConnection')==successor_connection and binding.get('desktopPid')==successor_connection['pid'] and successor_connection['runtime']==str(lib.root/'claude/task-runtime'),'Current successor binding differs')
|
|
28
|
+
try:service=request(lib,'status',{})
|
|
29
|
+
except (OSError,PackError):return {'existingSuccessor':False,'serviceStarted':False,'nativeInputsSent':False}
|
|
30
|
+
require(service.get('harness')=='claude' and service.get('cloudDispatchReady') is True and service.get('desktopPid')==successor_connection['pid'] and service.get('runtime')==successor_connection['runtime'] and isinstance(service.get('activeTurns'),list) and all(isinstance(w,str) for w in service['activeTurns']) and len(set(service['activeTurns']))==len(service['activeTurns']),'Authenticated successor service differs; preserve its ownership')
|
|
31
|
+
from .claude_desktop import call
|
|
32
|
+
status=call(successor_connection,{'kind':'status'})
|
|
33
|
+
require(status.get('harness')=='claude-desktop' and status.get('pid')==successor_connection['pid'] and status.get('identity')==successor_connection['identity'] and status.get('qualified') is True and type(status.get('workers')) is int and status['workers']>=0,'Qualified successor native control differs')
|
|
34
|
+
native=call(successor_connection,{'kind':'frontdoors'})
|
|
35
|
+
require(native.get('state')=='installed-frontdoors-verified' and native.get('nativeInputsSent') is False,'Native successor front doors differ')
|
|
36
|
+
return {'existingSuccessor':True,'serviceStarted':False,'workerCustodyPreserved':True,'nativeInputsSent':False,'native':native,'service':service,'binding':store.binding,'versionId':version,'generation':state['generation']}
|
|
37
|
+
if previous is not None:
|
|
38
|
+
if connection is not None:
|
|
39
|
+
verify_retired_binding(lib,previous,connection)
|
|
40
|
+
else:
|
|
41
|
+
old=previous['nativeConnection']
|
|
42
|
+
try:os.kill(old['pid'],0)
|
|
43
|
+
except ProcessLookupError:pass
|
|
44
|
+
else:raise ValueError('Previous native owner remains alive')
|
|
45
|
+
tasks={row[0]:json.loads(row[2]) for row in state['tables']['tasks']}
|
|
46
|
+
for row in state['tables']['workers']:
|
|
47
|
+
worker=json.loads(row[2]);cwd=worker.get('nativePlan',{}).get('cwd','')
|
|
48
|
+
if cwd==old['runtime'] or cwd.startswith(old['runtime']+os.sep):
|
|
49
|
+
require(cwd!=old['runtime'] and os.path.normpath(cwd)==cwd,'Ambiguous previous native workspace path')
|
|
50
|
+
if not cwd.startswith(old['runtime']+os.sep):
|
|
51
|
+
if tasks.get(worker['taskId'],{}).get('ownerInstallationId')==store.binding['installationId']:
|
|
52
|
+
require(worker['state']=='archived' and worker.get('nativeArchived') is True or worker['state']=='failed' and not worker.get('turnId') and (not worker.get('nativeId') or worker.get('nativeArchived') is True),'Historical worker remains active or UNKNOWN')
|
|
53
|
+
continue
|
|
54
|
+
require(worker.get('resolution',{}).get('harness')=='claude','Previous runtime worker harness custody differs')
|
|
55
|
+
if worker['state']=='failed' and not worker.get('turnId') and (not worker.get('nativeId') or worker.get('nativeArchived') is True):continue
|
|
56
|
+
require(worker['state']=='archived' and worker.get('nativeArchived') is True and worker.get('checkpoint') and worker.get('turnId'),'Previous native worker remains active or UNKNOWN')
|
|
57
|
+
payload=read_checkpoint(store.root,worker['checkpoint'],worker['taskId'],worker['id'],client=store.client);receipt=payload['nativeReceipt']
|
|
58
|
+
require(payload['phase']==worker['phase'] and receipt['threadId']==worker['nativeId'] and receipt['turnId']==worker['turnId'] and receipt['status'] in {'completed','failed','cancelled'},'Previous terminal checkpoint differs')
|
|
59
|
+
if wait_retired_service:
|
|
60
|
+
require(previous is not None,'Exact retired service owner required')
|
|
61
|
+
for _ in range(50):
|
|
62
|
+
try:status=request(lib,'status',{})
|
|
63
|
+
except (OSError,PackError):
|
|
64
|
+
bridge=read_json(lib.root/'live-bridge.json',{})
|
|
65
|
+
if bridge:
|
|
66
|
+
require(bridge.get('harness')=='claude' and type(bridge.get('pid')) is int and bridge['pid']>1,'Prior service process custody is unverified')
|
|
67
|
+
try:os.kill(bridge['pid'],0)
|
|
68
|
+
except ProcessLookupError:pass
|
|
69
|
+
else:
|
|
70
|
+
time.sleep(.1)
|
|
71
|
+
continue
|
|
72
|
+
with (lib.root/'work/claude-service-owner.lock').open('a') as owner:
|
|
73
|
+
try:fcntl.flock(owner,fcntl.LOCK_EX|fcntl.LOCK_NB)
|
|
74
|
+
except BlockingIOError:pass
|
|
75
|
+
else:break
|
|
76
|
+
time.sleep(.1)
|
|
77
|
+
continue
|
|
78
|
+
require(status.get('harness')=='claude' and status.get('desktopPid')==previous['nativeConnection']['pid'],'Another service owns this installation')
|
|
79
|
+
time.sleep(.1)
|
|
80
|
+
else:raise ValueError('Previous native service has not finished its owned shutdown')
|
|
81
|
+
task=None
|
|
82
|
+
if qualification_task is not None:
|
|
83
|
+
tasks=[json.loads(row[2]) for row in state['tables']['tasks'] if row[0]==qualification_task]
|
|
84
|
+
require(len(tasks)==1,'Qualification task is not in the current cloud authority')
|
|
85
|
+
task=tasks[0]
|
|
86
|
+
require(task['state']=='completed' and task.get('ownerInstallationId')==store.binding['installationId']
|
|
87
|
+
and task.get('verification',{}).get('verdict')=='completed','Independent cloud qualification is incomplete or belongs to another installation')
|
|
88
|
+
workers=[json.loads(row[2]) for row in state['tables']['workers'] if row[1]==qualification_task]
|
|
89
|
+
attempt=[w for w in workers if w.get('attempt',1)==task.get('attempt',1)]
|
|
90
|
+
generation=task.get('reviewGeneration',0);history=task.get('reviewHistory',[])
|
|
91
|
+
require(type(generation) is int and generation>=0 and isinstance(history,list) and len(history)==generation,'Authorized review generation history differs')
|
|
92
|
+
execution=[w for w in attempt if w['phase']!='review'];reviews=[w for w in attempt if w['phase']=='review']
|
|
93
|
+
require(len(execution)==2 and {w['phase'] for w in execution}=={'route','execute'} and len(reviews)==generation+1
|
|
94
|
+
and all(type(w.get('reviewGeneration',0)) is int for w in reviews)
|
|
95
|
+
and sorted(w.get('reviewGeneration',0) for w in reviews)==list(range(generation+1)),
|
|
96
|
+
'Original execution and unique authorized review generations are required')
|
|
97
|
+
latest=next(w for w in reviews if w.get('reviewGeneration',0)==generation)
|
|
98
|
+
current=execution+[latest];verification_by_worker={latest['id']:task['verification']}
|
|
99
|
+
require(all(w['state']=='archived' and w.get('nativeArchived') is True for w in attempt)
|
|
100
|
+
and task['verification'].get('nativeCheckpoint')==latest.get('checkpoint'),
|
|
101
|
+
'Current Claude qualification phase receipts are incomplete')
|
|
102
|
+
result=task.get('result',{});executed=next(w for w in execution if w['phase']=='execute')
|
|
103
|
+
require(result.get('workerId')==executed['id'] and result.get('checkpoint')==executed.get('checkpoint'),'Qualification must preserve the exact original execution result')
|
|
104
|
+
for index,entry in enumerate(history):
|
|
105
|
+
rejected=next(w for w in reviews if w.get('reviewGeneration',0)==index)
|
|
106
|
+
require(isinstance(entry,dict) and set(entry)=={'reviewWorkerId','generation','verification','continuation'}
|
|
107
|
+
and type(entry['generation']) is int and entry['generation']==index and entry['reviewWorkerId']==rejected['id']
|
|
108
|
+
and isinstance(entry['verification'],dict)
|
|
109
|
+
and entry['verification'].get('verdict') in {'blocked','needs_input'}
|
|
110
|
+
and entry['verification'].get('nativeCheckpoint')==rejected.get('checkpoint'),
|
|
111
|
+
'Prior rejected review identity and checkpoint must remain preserved')
|
|
112
|
+
continuation=entry['continuation']
|
|
113
|
+
require(isinstance(continuation,dict) and set(continuation)=={'requestId','taskId','revision','reviewWorkerId','reviewCheckpoint','note'}
|
|
114
|
+
and continuation['taskId']==task['id'] and continuation['reviewWorkerId']==rejected['id']
|
|
115
|
+
and continuation['reviewCheckpoint']==rejected.get('checkpoint')
|
|
116
|
+
and type(continuation['revision']) is int and continuation['revision']<task['revision']
|
|
117
|
+
and isinstance(continuation['note'],str) and bool(continuation['note'])
|
|
118
|
+
and [continuation['requestId'],hashlib.sha256(canonical(continuation)).hexdigest(),task['id']] in state['tables']['responses'],
|
|
119
|
+
'Review continuation must have its exact canonical authorization receipt')
|
|
120
|
+
verification_by_worker[rejected['id']]=entry['verification']
|
|
121
|
+
for worker in workers:
|
|
122
|
+
if worker['state']=='failed' and not worker.get('turnId') and (not worker.get('nativeId') or worker.get('nativeArchived') is True):continue
|
|
123
|
+
require(worker['state']=='archived' and worker.get('nativeArchived') is True and worker.get('checkpoint') and worker.get('turnId'),'Qualification worker closure is unresolved')
|
|
124
|
+
if worker not in current and worker.get('resolution',{}).get('harness')!='claude':continue
|
|
125
|
+
payload=read_checkpoint(store.root,worker['checkpoint'],qualification_task,worker['id'],client=store.client)
|
|
126
|
+
receipt=payload['nativeReceipt']
|
|
127
|
+
require(payload['phase']==worker['phase'] and receipt['threadId']==worker['nativeId'] and receipt['turnId']==worker['turnId'] and receipt['status'] in {'completed','failed','cancelled'},'Qualification checkpoint identity differs')
|
|
128
|
+
if worker['id'] in verification_by_worker:
|
|
129
|
+
expected=verification_by_worker[worker['id']]
|
|
130
|
+
require(payload.get('outputError') is None and json.loads(payload['output'])=={k:expected[k] for k in ['verdict','summary','evidence']},'Review verification differs from its immutable checkpoint')
|
|
131
|
+
if worker in current:
|
|
132
|
+
resolution=worker.get('resolution',{});role=worker.get('roleSource',{})
|
|
133
|
+
require(receipt['status']=='completed' and resolution.get('harness')=='claude'
|
|
134
|
+
and isinstance(resolution.get('nativeACK'),dict) and resolution['nativeACK']==resolution.get('resolved')
|
|
135
|
+
and bool(resolution['nativeACK'].get('model')) and bool(resolution['nativeACK'].get('effort'))
|
|
136
|
+
and role.get('source')=='topolo-agent-definition' and role.get('rolesLoaded')==1
|
|
137
|
+
and role.get('wholePackExportLoaded') is False and role.get('memoryLoaded') is False
|
|
138
|
+
and bool(role.get('agentId')) and bool(role.get('instructionsSHA256'))
|
|
139
|
+
and payload.get('resolution')==resolution and payload.get('roleSource')==role,
|
|
140
|
+
'Current qualification must prove selected Claude Agent context and native settings ACK')
|
|
141
|
+
native=None
|
|
142
|
+
if native_proof:
|
|
143
|
+
from .claude_desktop import call
|
|
144
|
+
binding=read_json(lib.root/'work/claude-frontdoors.json')
|
|
145
|
+
if require_native_idle:
|
|
146
|
+
status=call(binding['nativeConnection'],{'kind':'status'})
|
|
147
|
+
require(status.get('harness')=='claude-desktop' and status.get('pid')==binding['desktopPid'] and status.get('identity')==binding['nativeConnection']['identity'] and status.get('workers')==0 and status.get('qualified') is True,'Exact idle native control is required')
|
|
148
|
+
native=call(binding['nativeConnection'],{'kind':'frontdoors'})
|
|
149
|
+
require(native.get('state')=='installed-frontdoors-verified' and native.get('nativeInputsSent') is False,'Native front doors are unverified')
|
|
150
|
+
if start_service:ensure_running(lib)
|
|
151
|
+
service=request(lib,'status',{}) if start_service or qualification_task is not None else None
|
|
152
|
+
if service is not None:
|
|
153
|
+
binding=read_json(lib.root/'work/claude-frontdoors.json')
|
|
154
|
+
require(service.get('harness')=='claude' and service.get('cloudDispatchReady') is True and service.get('desktopPid')==binding['desktopPid'],'Current Claude dispatcher is not ready')
|
|
155
|
+
return {'native':native,'format':'topolo-claude-cloud-activation-proof-v1','binding':store.binding,'versionId':version,
|
|
156
|
+
'generation':state['generation'],'qualificationTaskId':qualification_task,
|
|
157
|
+
'qualificationRevision':task['revision'] if task else None,'service':service,'activated':True}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Recover one already delivered Desktop completion, without resending any input.
|
|
2
|
+
|
|
3
|
+
The legacy native guard requires the original inventory during read/archive.
|
|
4
|
+
Only explicitly reviewed added outputs may be moved, reversibly, beneath its
|
|
5
|
+
excluded recovery directory. Original context is always checked by its digest.
|
|
6
|
+
"""
|
|
7
|
+
import hashlib
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import stat
|
|
11
|
+
import uuid
|
|
12
|
+
|
|
13
|
+
from .storage import atomic_json, canonical, read_json
|
|
14
|
+
from .work import require
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _file(path):
|
|
18
|
+
meta = path.lstat()
|
|
19
|
+
require(stat.S_ISREG(meta.st_mode) and meta.st_uid == os.getuid()
|
|
20
|
+
and not meta.st_mode & 0o022 and path.resolve() == path,
|
|
21
|
+
'Recovery requires an owned regular non-writable-by-others file')
|
|
22
|
+
require(0 < meta.st_size <= 100 * 1024 * 1024, 'Recovery file exceeds boundary')
|
|
23
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class OutputCustody:
|
|
27
|
+
def __init__(self, worker, outputs):
|
|
28
|
+
self.cwd = Path(worker['cwd'])
|
|
29
|
+
require(self.cwd.is_absolute() and self.cwd.resolve() == self.cwd,
|
|
30
|
+
'Recovery workspace identity differs')
|
|
31
|
+
require(isinstance(outputs, list) and 0 < len(outputs) <= 16,
|
|
32
|
+
'Explicit selected output manifest required')
|
|
33
|
+
self.outputs = []
|
|
34
|
+
for row in outputs:
|
|
35
|
+
require(isinstance(row, dict) and set(row) == {'path', 'sha256'},
|
|
36
|
+
'Closed recovery output manifest required')
|
|
37
|
+
path = Path(row['path'])
|
|
38
|
+
require(path.is_relative_to(self.cwd) and path != self.cwd
|
|
39
|
+
and not path.is_relative_to(self.cwd / '.topolo-worker')
|
|
40
|
+
and path not in [p for p, _ in self.outputs], 'Recovery output path differs')
|
|
41
|
+
require(_file(path) == row['sha256'], 'Selected output digest differs')
|
|
42
|
+
self.outputs.append((path, row['sha256']))
|
|
43
|
+
self.initial = {}
|
|
44
|
+
for path in sorted(self.cwd.rglob('*')):
|
|
45
|
+
if path.is_relative_to(self.cwd / '.topolo-worker'):
|
|
46
|
+
continue
|
|
47
|
+
require(not path.is_symlink(), 'Recovery workspace contains a link')
|
|
48
|
+
if path.is_file() and path not in [p for p, _ in self.outputs]:
|
|
49
|
+
self.initial[str(path)] = _file(path)
|
|
50
|
+
require(hashlib.sha256(canonical(self.initial)).hexdigest()
|
|
51
|
+
== worker['nativePlan']['contextDigest'], 'Original selected context differs')
|
|
52
|
+
for directory in [self.cwd, self.cwd / '.topolo-worker', self.cwd / '.topolo-worker' / 'recovery']:
|
|
53
|
+
if directory.exists():
|
|
54
|
+
meta = directory.lstat()
|
|
55
|
+
require(stat.S_ISDIR(meta.st_mode) and meta.st_uid == os.getuid()
|
|
56
|
+
and not meta.st_mode & 0o022 and directory.resolve() == directory,
|
|
57
|
+
'Recovery directory ownership differs')
|
|
58
|
+
self.directory = self.cwd / '.topolo-worker' / 'recovery' / worker['id']
|
|
59
|
+
self.journal = self.directory / 'journal.json'
|
|
60
|
+
require(not self.directory.exists(), 'Recovery already exists; reconcile its exact journal')
|
|
61
|
+
self.moved = []
|
|
62
|
+
|
|
63
|
+
def begin(self):
|
|
64
|
+
self.directory.mkdir(parents=True, mode=0o700)
|
|
65
|
+
self.record('prepared')
|
|
66
|
+
|
|
67
|
+
def record(self, state):
|
|
68
|
+
atomic_json(self.journal, {'format': 'topolo-native-completion-recovery-v1',
|
|
69
|
+
'state': state, 'inputs': self.initial,
|
|
70
|
+
'outputs': [{'path': str(p), 'sha256': h} for p, h in self.outputs]})
|
|
71
|
+
|
|
72
|
+
def quarantine(self):
|
|
73
|
+
require(not self.moved, 'Output quarantine already active')
|
|
74
|
+
for path, digest in self.outputs:
|
|
75
|
+
require(_file(path) == digest, 'Output changed before quarantine')
|
|
76
|
+
target = self.directory / ('output-' + str(len(self.moved)))
|
|
77
|
+
require(not target.exists(), 'Quarantine target exists')
|
|
78
|
+
os.rename(path, target)
|
|
79
|
+
self.moved.append((path, target, digest))
|
|
80
|
+
actual = {str(p): _file(p) for p in sorted(self.cwd.rglob('*'))
|
|
81
|
+
if p.is_file() and not p.is_relative_to(self.cwd / '.topolo-worker')}
|
|
82
|
+
require(actual == self.initial, 'Original context changed during quarantine')
|
|
83
|
+
self.record('quarantined')
|
|
84
|
+
|
|
85
|
+
def restore(self):
|
|
86
|
+
for path, target, digest in reversed(self.moved):
|
|
87
|
+
require(not path.exists() and _file(target) == digest,
|
|
88
|
+
'Output restoration collision or digest differs; custody retained')
|
|
89
|
+
os.rename(target, path)
|
|
90
|
+
self.moved = []
|
|
91
|
+
self.record('outputs-restored')
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _terminal(result, worker):
|
|
95
|
+
plan = worker['nativePlan']
|
|
96
|
+
require(result.get('state') == 'native-task-completed'
|
|
97
|
+
and result.get('nativeId') == worker['nativeId']
|
|
98
|
+
and result.get('messageId') == worker['turnId']
|
|
99
|
+
and result.get('model') == plan['model'] and result.get('effort') == plan['effort']
|
|
100
|
+
and isinstance(result.get('result'), str)
|
|
101
|
+
and type(result.get('toolItems')) is int and result['toolItems'] >= 0,
|
|
102
|
+
'Exact existing native terminal result required')
|
|
103
|
+
for key in ['resultUuid', 'cliSessionId']:
|
|
104
|
+
require(str(uuid.UUID(result[key])) == result[key], 'Native producer identity is unqualified')
|
|
105
|
+
return {'id': worker['turnId'], 'status': 'completed', 'output': result['result'],
|
|
106
|
+
'toolItems': result['toolItems'], 'nativeSessionId': result['cliSessionId'],
|
|
107
|
+
'nativeResultId': result['resultUuid']}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _alive(pid):
|
|
111
|
+
try:
|
|
112
|
+
os.kill(pid, 0)
|
|
113
|
+
except ProcessLookupError:
|
|
114
|
+
return False
|
|
115
|
+
return True
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def recover_completed(lib, request, store=None, native_call=None, alive=None, checkpoint=None):
|
|
119
|
+
"""Recover after the exact old Python service stops; Desktop remains alive.
|
|
120
|
+
|
|
121
|
+
No dispatcher is started or pumped. The caller must separately qualify the
|
|
122
|
+
corrected native guard before starting the independent review continuation.
|
|
123
|
+
"""
|
|
124
|
+
import fcntl
|
|
125
|
+
from .cloud_work import CloudStore
|
|
126
|
+
from .claude_desktop import call
|
|
127
|
+
from .work_artifacts import checkpoint as publish_checkpoint
|
|
128
|
+
require(set(request) == {'taskId', 'workerId', 'revision', 'attempt', 'turnId',
|
|
129
|
+
'outputs', 'expectedServicePid', 'expectedNative'},
|
|
130
|
+
'Closed native completion recovery request required')
|
|
131
|
+
checkpoint = checkpoint or publish_checkpoint
|
|
132
|
+
native_call = native_call or call
|
|
133
|
+
alive = alive or _alive
|
|
134
|
+
with lib.lock(), (lib.root / 'work/claude-service-start.lock').open('a') as start_lock:
|
|
135
|
+
try:
|
|
136
|
+
fcntl.flock(start_lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
137
|
+
except BlockingIOError:
|
|
138
|
+
require(False, 'Another Claude startup owns this installation')
|
|
139
|
+
previous = read_json(lib.root / 'live-bridge.json')
|
|
140
|
+
require(type(request['expectedServicePid']) is int and request['expectedServicePid'] > 1
|
|
141
|
+
and previous.get('harness') == 'claude'
|
|
142
|
+
and previous.get('pid') == request['expectedServicePid']
|
|
143
|
+
and not alive(request['expectedServicePid']),
|
|
144
|
+
'Exact previous Python owner must be stopped before recovery')
|
|
145
|
+
connection = read_json(lib.root / 'work/claude-frontdoors.json')['nativeConnection']
|
|
146
|
+
require(alive(connection['pid']) and connection['pid'] == request['expectedNative']['pid']
|
|
147
|
+
and connection['identity'] == request['expectedNative']['identity'],
|
|
148
|
+
'Original Desktop native owner differs')
|
|
149
|
+
require(native_call(connection, {'kind': 'status'}) == request['expectedNative']
|
|
150
|
+
and request['expectedNative'].get('qualified') is True
|
|
151
|
+
and request['expectedNative'].get('workers') == 1,
|
|
152
|
+
'Exact sole native worker connection required')
|
|
153
|
+
store = store or CloudStore(lib.root)
|
|
154
|
+
task = store.get(request['taskId'])
|
|
155
|
+
worker = store.worker(request['workerId'])
|
|
156
|
+
require(task['state'] == 'unknown' and task['revision'] == request['revision']
|
|
157
|
+
and task['attempt'] == request['attempt'] and worker['state'] == 'unknown'
|
|
158
|
+
and worker['taskId'] == task['id'] and worker['phase'] == 'execute'
|
|
159
|
+
and worker['attempt'] == task['attempt'] and worker['turnId'] == request['turnId']
|
|
160
|
+
and worker['id'] in task['workerIds'] and not worker.get('checkpoint')
|
|
161
|
+
and task.get('ownerInstallationId') == store.binding['installationId'],
|
|
162
|
+
'Exact owned UNKNOWN execution checkpoint required')
|
|
163
|
+
plan = worker['nativePlan']
|
|
164
|
+
require(all(plan.get(key) == worker.get(key) for key in ['taskId', 'nativeId', 'cwd'])
|
|
165
|
+
and plan.get('workerId') == worker['id'], 'Cloud native plan identity differs')
|
|
166
|
+
for other in store.workers():
|
|
167
|
+
require(other['id'] == worker['id'] or
|
|
168
|
+
other['state'] == 'archived' and other.get('nativeArchived') is True or
|
|
169
|
+
other['state'] == 'failed' and not other.get('turnId') and
|
|
170
|
+
(not other.get('nativeId') or other.get('nativeArchived') is True),
|
|
171
|
+
'Another unresolved cloud worker prevents standalone recovery')
|
|
172
|
+
custody = OutputCustody(worker, request['outputs'])
|
|
173
|
+
custody.begin()
|
|
174
|
+
try:
|
|
175
|
+
custody.quarantine()
|
|
176
|
+
native = native_call(connection, {'kind': 'read', 'workerId': worker['id'],
|
|
177
|
+
'messageId': worker['turnId']})
|
|
178
|
+
receipt = _terminal(native, worker)
|
|
179
|
+
finally:
|
|
180
|
+
custody.restore()
|
|
181
|
+
actual = {'id': receipt['id'], 'status': receipt['status'],
|
|
182
|
+
'items': [{'type': 'agentMessage', 'text': receipt['output']}] +
|
|
183
|
+
[{'type': 'toolUse'} for _ in range(receipt['toolItems'])],
|
|
184
|
+
'nativeProof': {'harness': 'claude-desktop', 'sessionId': receipt['nativeSessionId'],
|
|
185
|
+
'resultId': receipt['nativeResultId']}}
|
|
186
|
+
proof, payload, normalized = checkpoint(store.root, task, worker, actual)
|
|
187
|
+
require(payload.get('outputError') is None and normalized is not None,
|
|
188
|
+
'Native output contract is not checkpoint-ready')
|
|
189
|
+
require(store.get(task['id']) == task and store.worker(worker['id']) == worker,
|
|
190
|
+
'Task or worker changed before recovered checkpoint publication')
|
|
191
|
+
store.worker_update(worker['id'], {'state': 'archiving', 'checkpoint': proof})
|
|
192
|
+
task = store.update(task['id'], task['revision'],
|
|
193
|
+
{'checkpoints': task['checkpoints'] + [proof]}, 'checkpoint-saved')
|
|
194
|
+
custody.record('checkpoint-saved')
|
|
195
|
+
try:
|
|
196
|
+
custody.quarantine()
|
|
197
|
+
retired = native_call(connection, {'kind': 'archive', 'workerId': worker['id'],
|
|
198
|
+
'messageId': worker['turnId']})
|
|
199
|
+
require(retired.get('state') == 'native-worker-archived'
|
|
200
|
+
and retired.get('nativeId') == worker['nativeId'],
|
|
201
|
+
'Existing native worker archival was not acknowledged')
|
|
202
|
+
finally:
|
|
203
|
+
custody.restore()
|
|
204
|
+
require(native_call(connection, {'kind': 'status'}) == {**request['expectedNative'], 'workers': 0},
|
|
205
|
+
'Native archival readback differs')
|
|
206
|
+
store.worker_update(worker['id'], {'state': 'archived', 'nativeArchived': True,
|
|
207
|
+
'nativeRetirement': {'kind': 'native-desktop-session',
|
|
208
|
+
'desktopChatArchived': True, **retired}})
|
|
209
|
+
task = store.update(task['id'], task['revision'],
|
|
210
|
+
{'state': 'reviewing', 'phase': 'review',
|
|
211
|
+
'result': {**normalized, 'checkpoint': proof, 'workerId': worker['id']},
|
|
212
|
+
'summary': 'Existing native completion recovered without resend; independent review pending.'},
|
|
213
|
+
'worker-released')
|
|
214
|
+
custody.record('archived')
|
|
215
|
+
return {'taskId': task['id'], 'workerId': worker['id'], 'attempt': task['attempt'],
|
|
216
|
+
'state': task['state'], 'nativeId': worker['nativeId'], 'turnId': worker['turnId']}
|