kira-wallet 0.1.0
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/LICENSE +21 -0
- package/README.md +116 -0
- package/THIRD_PARTY_NOTICES.md +13 -0
- package/bin/kira.cjs +8 -0
- package/curve_pricing.py +86 -0
- package/docs/AGENT_TOOLS.md +53 -0
- package/docs/CLI.md +144 -0
- package/docs/JOB_CONTRACT.md +111 -0
- package/docs/KIRA_JOBS_BUILD_REPORT.md +92 -0
- package/docs/KIRA_REDESIGN_REPORT.md +125 -0
- package/docs/ONBOARDING_CHAT_CONTRACT.md +36 -0
- package/docs/RELEASE.md +45 -0
- package/docs/WALLET_CAPABILITIES.md +98 -0
- package/docs/character/brand-generation.json +20 -0
- package/docs/character/workspace-generation.json +29 -0
- package/docs/screenshots/kira-jobs-desktop.jpg +0 -0
- package/kira_agent.py +280 -0
- package/kira_cli.py +263 -0
- package/kira_config.py +98 -0
- package/kira_jobs.py +504 -0
- package/kira_tools.py +104 -0
- package/networks.json +214 -0
- package/onchain.cjs +34 -0
- package/package.json +78 -0
- package/pipeline-onchain.cjs +284 -0
- package/research.py +39 -0
- package/sources/job-envelope-v1.schema.json +469 -0
- package/sources/job-request-v1.schema.json +352 -0
- package/sources/mintclub-bond-abi.json +1457 -0
- package/sources/rpc-candidates.json +294 -0
- package/sources/uniswap-deployments.json +18261 -0
- package/viewer/README.md +119 -0
- package/viewer/chain_images.py +21 -0
- package/viewer/details.py +119 -0
- package/viewer/model.py +216 -0
- package/viewer/refresh-curve-prices.cjs +35 -0
- package/viewer/refresh_prices.py +97 -0
- package/viewer/server.py +153 -0
- package/viewer/static/agent.css +100 -0
- package/viewer/static/agent.js +203 -0
- package/viewer/static/app.js +252 -0
- package/viewer/static/favicon.svg +1 -0
- package/viewer/static/index.html +139 -0
- package/viewer/static/jobs.js +113 -0
- package/viewer/static/kira-attention.png +0 -0
- package/viewer/static/kira-explain.png +0 -0
- package/viewer/static/kira-logo.png +0 -0
- package/viewer/static/kira-research.png +0 -0
- package/viewer/static/kira-review.png +0 -0
- package/viewer/static/kira.png +0 -0
- package/viewer/static/style.css +51 -0
- package/viewer/static/watching-model.js +129 -0
- package/viewer/static/watching.js +167 -0
- package/viewer/static/workspace-model.js +43 -0
- package/viewer/static/workspace.css +463 -0
- package/viewer/static/workspace.js +102 -0
- package/viewer/token_images.py +137 -0
- package/wallet.py +389 -0
package/kira_config.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Private provider configuration. Public RPC is independent of discovery."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import re
|
|
7
|
+
from urllib.parse import urlparse
|
|
8
|
+
|
|
9
|
+
ASSETS = Path(__file__).resolve().parent
|
|
10
|
+
|
|
11
|
+
def data_root():
|
|
12
|
+
# Direct historical scripts keep their workspace. The supported CLI sets this.
|
|
13
|
+
return Path(os.environ.get('KIRA_DATA_DIR', ASSETS)).expanduser().resolve()
|
|
14
|
+
|
|
15
|
+
def config_path():
|
|
16
|
+
return Path(os.environ.get('KIRA_CONFIG', data_root()/'.kira.local.json')).expanduser()
|
|
17
|
+
|
|
18
|
+
def default_config():
|
|
19
|
+
return {'schema_version':1,'rpc':{'mode':'public','allow_public_fallback':False,'chains':{}},
|
|
20
|
+
'discovery':{'provider':'none','key_env':'ALCHEMY_API_KEY','explorers':False}}
|
|
21
|
+
|
|
22
|
+
def load_config():
|
|
23
|
+
path=config_path()
|
|
24
|
+
if not path.exists():return default_config()
|
|
25
|
+
try:cfg=json.loads(path.read_text())
|
|
26
|
+
except (OSError,ValueError):raise ValueError('Kira configuration is unreadable or malformed.') from None
|
|
27
|
+
if not isinstance(cfg,dict) or cfg.get('schema_version')!=1:raise ValueError('Unsupported Kira configuration version.')
|
|
28
|
+
if set(cfg)-{'schema_version','rpc','discovery','secret_env_file'}:raise ValueError('Unknown Kira configuration fields.')
|
|
29
|
+
rpc=cfg.get('rpc'); discovery=cfg.get('discovery')
|
|
30
|
+
if not isinstance(rpc,dict) or rpc.get('mode') not in ('public','custom') or type(rpc.get('allow_public_fallback')) is not bool or not isinstance(rpc.get('chains'),dict):
|
|
31
|
+
raise ValueError('Invalid RPC configuration.')
|
|
32
|
+
if not isinstance(discovery,dict) or discovery.get('provider') not in ('none','alchemy') or type(discovery.get('explorers',False)) is not bool:
|
|
33
|
+
raise ValueError('Invalid discovery configuration.')
|
|
34
|
+
if set(rpc)-{'mode','allow_public_fallback','chains'} or set(discovery)-{'provider','key_env','explorers'}:raise ValueError('Unknown provider configuration fields.')
|
|
35
|
+
if any(not isinstance(row,dict) or set(row)-{'url_env','alchemy_network'} for row in rpc['chains'].values()):raise ValueError('Unknown RPC endpoint fields.')
|
|
36
|
+
for name in [discovery.get('key_env'),*[row.get('url_env') if isinstance(row,dict) else None for row in rpc['chains'].values()]]:
|
|
37
|
+
if not isinstance(name,str) or not re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]*',name):raise ValueError('Credentials must use valid environment references.')
|
|
38
|
+
if any(not str(cid).isdigit() or int(cid)<=0 for cid in rpc['chains']):raise ValueError('Invalid RPC chain ID.')
|
|
39
|
+
if 'secret_env_file' in cfg and not isinstance(cfg['secret_env_file'],str):raise ValueError('Invalid secret file reference.')
|
|
40
|
+
return cfg
|
|
41
|
+
|
|
42
|
+
def secret_values(cfg=None):
|
|
43
|
+
cfg=cfg or load_config();values={}
|
|
44
|
+
file=os.environ.get('KIRA_RPC_ENV') or cfg.get('secret_env_file')
|
|
45
|
+
if file:
|
|
46
|
+
try:lines=Path(file).expanduser().read_text().splitlines()
|
|
47
|
+
except FileNotFoundError:lines=[]
|
|
48
|
+
except OSError:raise ValueError('Configured secret file cannot be read.') from None
|
|
49
|
+
for line in lines:
|
|
50
|
+
match=re.fullmatch(r'\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*',line)
|
|
51
|
+
if match:values[match[1]]=match[2].strip('\"\x27')
|
|
52
|
+
refs={cfg['discovery']['key_env'],'ALCHEMY_API_KEY','ALCHEMY_CUSTOM_APY_KEY'}
|
|
53
|
+
refs.update(row['url_env'] for row in cfg['rpc']['chains'].values())
|
|
54
|
+
values.update({name:os.environ[name] for name in refs if name in os.environ})
|
|
55
|
+
key=values.get(cfg['discovery']['key_env'])
|
|
56
|
+
if cfg['discovery']['provider']=='alchemy' and key:values['ALCHEMY_CUSTOM_APY_KEY']=key
|
|
57
|
+
return values
|
|
58
|
+
|
|
59
|
+
def redact(value):
|
|
60
|
+
text=str(value)
|
|
61
|
+
try:values=secret_values().values()
|
|
62
|
+
except ValueError:values=[]
|
|
63
|
+
for secret in sorted(set(values),key=len,reverse=True):
|
|
64
|
+
if secret:text=text.replace(secret,'[redacted]')
|
|
65
|
+
# Provider diagnostics can contain keys in paths, queries, userinfo or headers.
|
|
66
|
+
text=re.sub(r'https?://[^\s\"<>]+','[RPC URL]',text)
|
|
67
|
+
text=re.sub(r'(?i)(bearer\s+)[^\s,;\"<>]+',r'\1[redacted]',text)
|
|
68
|
+
return re.sub(r'(?i)(authorization|api[-_]?key|bearer)([\s:=]+)[^\s,;\"<>]+',r'\1\2[redacted]',text)
|
|
69
|
+
|
|
70
|
+
def endpoints(network,cfg=None):
|
|
71
|
+
cfg=cfg or load_config();rpc=cfg['rpc'];urls=[]
|
|
72
|
+
if rpc['mode']=='custom':
|
|
73
|
+
row=rpc['chains'].get(str(network['chain_id']))
|
|
74
|
+
if row:
|
|
75
|
+
url=secret_values(cfg).get(row['url_env'])
|
|
76
|
+
if url:
|
|
77
|
+
if row.get('alchemy_network'):
|
|
78
|
+
if not isinstance(row['alchemy_network'],str) or not re.fullmatch(r'[a-z0-9-]+',row['alchemy_network']):raise ValueError('Invalid Alchemy network reference.')
|
|
79
|
+
url=f"https://{row['alchemy_network']}.g.alchemy.com/v2/{url}"
|
|
80
|
+
try:u=urlparse(url);valid=u.scheme in ('https','http') and bool(u.hostname) and (u.scheme=='https' or u.hostname in ('localhost','127.0.0.1','::1'))
|
|
81
|
+
except ValueError:valid=False
|
|
82
|
+
if not valid:raise ValueError('Custom RPC must use HTTPS or loopback HTTP.')
|
|
83
|
+
urls.append(url)
|
|
84
|
+
if not rpc['allow_public_fallback']:return urls
|
|
85
|
+
urls.extend(network.get('public_rpc') or [])
|
|
86
|
+
return list(dict.fromkeys(urls))
|
|
87
|
+
|
|
88
|
+
def runtime():
|
|
89
|
+
cfg=load_config();networks=json.loads((ASSETS/'sources/rpc-candidates.json').read_text())
|
|
90
|
+
return {'endpoints':{str(n['chain_id']):endpoints(n,cfg) for n in networks},
|
|
91
|
+
'secrets':list(secret_values(cfg).values()),'data_root':str(data_root())}
|
|
92
|
+
|
|
93
|
+
if __name__=='__main__':
|
|
94
|
+
# Internal child-process channel only. The user CLI never emits this payload.
|
|
95
|
+
try:print(json.dumps(runtime()))
|
|
96
|
+
except ValueError as error:
|
|
97
|
+
import sys
|
|
98
|
+
print(str(error),file=sys.stderr);sys.exit(1)
|
package/kira_jobs.py
ADDED
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
"""Versioned private job service shared by CLI and loopback controls."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from contextlib import contextmanager
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
import fcntl
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import re
|
|
11
|
+
import selectors
|
|
12
|
+
import signal
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import tempfile
|
|
16
|
+
import time
|
|
17
|
+
from typing import Literal, TypedDict, NotRequired
|
|
18
|
+
import uuid
|
|
19
|
+
|
|
20
|
+
ASSETS = Path(__file__).resolve().parent
|
|
21
|
+
State = Literal['queued', 'running', 'interrupted', 'succeeded', 'partial', 'failed', 'cancelled']
|
|
22
|
+
Operation = Literal['wallet.add','wallet.refresh','prices.refresh','wallet.setTags','settings.rpc','settings.discovery']
|
|
23
|
+
TERMINAL = {'succeeded', 'partial', 'failed', 'cancelled', 'interrupted'}
|
|
24
|
+
ADDRESS = re.compile(r'0x[0-9a-fA-F]{40}')
|
|
25
|
+
|
|
26
|
+
class Request(TypedDict):
|
|
27
|
+
schema_version: int
|
|
28
|
+
operation: str
|
|
29
|
+
input: dict
|
|
30
|
+
idempotency_key: str
|
|
31
|
+
|
|
32
|
+
class AddInput(TypedDict):
|
|
33
|
+
address: str
|
|
34
|
+
tag: str
|
|
35
|
+
|
|
36
|
+
class WalletInput(TypedDict):
|
|
37
|
+
wallet: str
|
|
38
|
+
|
|
39
|
+
class TagsInput(WalletInput):
|
|
40
|
+
tags: list[str]
|
|
41
|
+
|
|
42
|
+
class Job(TypedDict):
|
|
43
|
+
schema_version: int
|
|
44
|
+
job_id: str
|
|
45
|
+
operation: Operation
|
|
46
|
+
input: AddInput | WalletInput | TagsInput | dict
|
|
47
|
+
state: State
|
|
48
|
+
attempt: int
|
|
49
|
+
sequence: int
|
|
50
|
+
result: dict | None
|
|
51
|
+
previous_result: dict | None
|
|
52
|
+
key_hash: str
|
|
53
|
+
request_hash: str
|
|
54
|
+
raw_hash: str
|
|
55
|
+
created_at: str
|
|
56
|
+
updated_at: str
|
|
57
|
+
started_at: NotRequired[str]
|
|
58
|
+
cancel_requested: bool
|
|
59
|
+
checkpoint: str
|
|
60
|
+
stage: str
|
|
61
|
+
events: list[dict]
|
|
62
|
+
errors: list[dict]
|
|
63
|
+
chains: dict
|
|
64
|
+
control_receipts: NotRequired[dict[str,str]]
|
|
65
|
+
control_raw: NotRequired[dict[str,str]]
|
|
66
|
+
provider_configuration: NotRequired[dict]
|
|
67
|
+
|
|
68
|
+
class JobError(ValueError):
|
|
69
|
+
def __init__(self, code, message):
|
|
70
|
+
super().__init__(message)
|
|
71
|
+
self.code = code
|
|
72
|
+
|
|
73
|
+
def now():
|
|
74
|
+
return datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z')
|
|
75
|
+
|
|
76
|
+
def atomic(path, value):
|
|
77
|
+
path = Path(path)
|
|
78
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
79
|
+
fd, name = tempfile.mkstemp(prefix='.', suffix='.tmp', dir=path.parent)
|
|
80
|
+
try:
|
|
81
|
+
with os.fdopen(fd, 'w') as stream:
|
|
82
|
+
json.dump(value, stream, indent=2, ensure_ascii=False, allow_nan=False)
|
|
83
|
+
stream.write('\n'); stream.flush(); os.fsync(stream.fileno())
|
|
84
|
+
os.replace(name, path)
|
|
85
|
+
directory = os.open(path.parent, os.O_RDONLY)
|
|
86
|
+
try: os.fsync(directory)
|
|
87
|
+
finally: os.close(directory)
|
|
88
|
+
finally:
|
|
89
|
+
if os.path.exists(name): os.unlink(name)
|
|
90
|
+
|
|
91
|
+
def read(path):
|
|
92
|
+
return json.loads(Path(path).read_text())
|
|
93
|
+
|
|
94
|
+
def digest(value):
|
|
95
|
+
return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(',', ':')).encode()).hexdigest()
|
|
96
|
+
|
|
97
|
+
def fields(value, required):
|
|
98
|
+
if not isinstance(value, dict) or set(value) != set(required):
|
|
99
|
+
raise JobError('invalid_input', 'Unexpected or missing operation fields.')
|
|
100
|
+
|
|
101
|
+
def identifier(value):
|
|
102
|
+
if not isinstance(value, str): raise JobError('invalid_id', 'Expected a job ID.')
|
|
103
|
+
try:
|
|
104
|
+
if str(uuid.UUID(value)) != value: raise ValueError()
|
|
105
|
+
except ValueError: raise JobError('invalid_id', 'Expected a canonical job ID.') from None
|
|
106
|
+
return value
|
|
107
|
+
|
|
108
|
+
class JobStore:
|
|
109
|
+
def __init__(self, root):
|
|
110
|
+
self.root = Path(root).resolve()
|
|
111
|
+
self.jobs = self.root / 'jobs'
|
|
112
|
+
self.jobs.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
113
|
+
|
|
114
|
+
@contextmanager
|
|
115
|
+
def locked(self):
|
|
116
|
+
with (self.root / '.jobs.lock').open('a') as lock:
|
|
117
|
+
fcntl.flock(lock, fcntl.LOCK_EX)
|
|
118
|
+
yield
|
|
119
|
+
|
|
120
|
+
def get(self, job_id):
|
|
121
|
+
path = self.jobs / (identifier(job_id) + '.json')
|
|
122
|
+
if not path.exists(): raise JobError('not_found', 'Job was not found.')
|
|
123
|
+
job = read(path)
|
|
124
|
+
if not isinstance(job,dict) or type(job.get('schema_version')) is not int or job.get('schema_version') != 1: raise JobError('unsupported_version', 'Unsupported persisted job version.')
|
|
125
|
+
required={'schema_version','job_id','operation','input','state','attempt','sequence','result','previous_result','key_hash','request_hash','created_at','updated_at','cancel_requested','checkpoint','stage','events','errors','chains'}
|
|
126
|
+
optional={'raw_hash','control_receipts','control_raw','provider_configuration','started_at'}
|
|
127
|
+
if required-set(job) or set(job)-required-optional or job['job_id']!=job_id or job['checkpoint']!='snapshots/jobs/'+job_id or not isinstance(job['operation'],str) or job['operation'] not in {'wallet.add','wallet.refresh','prices.refresh','wallet.setTags','settings.rpc','settings.discovery'} or not isinstance(job['state'],str) or job['state'] not in TERMINAL|{'queued','running'} or any(type(job[k]) is not int or job[k]<0 for k in ('attempt','sequence')) or type(job['cancel_requested']) is not bool or not isinstance(job['input'],dict) or any(not isinstance(job[k],list) for k in ('events','errors')) or not isinstance(job['chains'],dict):
|
|
128
|
+
raise JobError('invalid_envelope','Persisted job validation failed. Preserve the file for recovery.')
|
|
129
|
+
names={'wallet.add':['address','tag'],'wallet.refresh':['wallet'],'prices.refresh':['wallet'],'wallet.setTags':['wallet','tags'],
|
|
130
|
+
'settings.rpc':['mode','chains','allow_public_fallback'],'settings.discovery':['provider','key_env']}[job['operation']]
|
|
131
|
+
fields(job['input'],names)
|
|
132
|
+
wallet=job['input'].get('wallet') or job['input'].get('address')
|
|
133
|
+
if wallet is not None and (not isinstance(wallet,str) or not ADDRESS.fullmatch(wallet)):raise JobError('invalid_envelope','Persisted wallet identity is invalid.')
|
|
134
|
+
return job
|
|
135
|
+
|
|
136
|
+
def list(self):
|
|
137
|
+
with self.locked():
|
|
138
|
+
return sorted((self.get(p.stem) for p in self.jobs.glob('*.json')), key=lambda j: j['created_at'], reverse=True)
|
|
139
|
+
|
|
140
|
+
def save(self, job):
|
|
141
|
+
job['sequence'] += 1; job['updated_at'] = now()
|
|
142
|
+
atomic(self.jobs / (job['job_id'] + '.json'), job)
|
|
143
|
+
return job
|
|
144
|
+
|
|
145
|
+
def wallet(self, selector):
|
|
146
|
+
if not isinstance(selector, str) or not selector or len(selector) > 200:
|
|
147
|
+
raise JobError('invalid_wallet', 'Expected a registered address or exact tag.')
|
|
148
|
+
wallets = read(self.root / 'wallets.json')['wallets']
|
|
149
|
+
matches = [w for w in wallets if w['address_key'] == selector.lower() or selector in w['tags']]
|
|
150
|
+
if len(matches) != 1:
|
|
151
|
+
raise JobError('wallet_not_unique', 'Choose a unique registered wallet address.')
|
|
152
|
+
return matches[0]
|
|
153
|
+
|
|
154
|
+
def normalize(self, operation, value):
|
|
155
|
+
if operation == 'settings.rpc':
|
|
156
|
+
fields(value,['mode','chains','allow_public_fallback'])
|
|
157
|
+
if value['mode'] not in ('public','custom') or type(value['allow_public_fallback']) is not bool or not isinstance(value['chains'],list) or len(value['chains'])>30:
|
|
158
|
+
raise JobError('invalid_settings','Choose a valid RPC mode and credential references.')
|
|
159
|
+
available={n['chain_id'] for n in read(ASSETS/'sources/rpc-candidates.json')}
|
|
160
|
+
chains={}
|
|
161
|
+
for chain in value['chains']:
|
|
162
|
+
fields(chain,['chain_id','url_env'])
|
|
163
|
+
if type(chain['chain_id']) is not int or chain['chain_id'] not in available or not isinstance(chain['url_env'],str) or not re.fullmatch(r'[A-Z_][A-Z0-9_]{0,99}',chain['url_env']) or str(chain['chain_id']) in chains:
|
|
164
|
+
raise JobError('invalid_settings','Use a known chain and a unique environment variable reference.')
|
|
165
|
+
chains[str(chain['chain_id'])]={'url_env':chain['url_env']}
|
|
166
|
+
if value['mode']=='custom' and not chains:raise JobError('invalid_settings','Custom RPC requires at least one endpoint reference.')
|
|
167
|
+
return {'mode':value['mode'],'chains':chains,'allow_public_fallback':value['allow_public_fallback']}
|
|
168
|
+
if operation == 'settings.discovery':
|
|
169
|
+
fields(value,['provider','key_env'])
|
|
170
|
+
if value['provider'] not in ('none','alchemy') or not isinstance(value['key_env'],str) or not re.fullmatch(r'[A-Z_][A-Z0-9_]{0,99}',value['key_env']):raise JobError('invalid_settings','Use a supported indexer and environment variable reference.')
|
|
171
|
+
return value.copy()
|
|
172
|
+
if operation == 'wallet.add':
|
|
173
|
+
fields(value, ['address', 'tag'])
|
|
174
|
+
if not isinstance(value['address'], str) or not ADDRESS.fullmatch(value['address']):
|
|
175
|
+
raise JobError('invalid_address', 'Expected a 20-byte EVM address.')
|
|
176
|
+
if not isinstance(value['tag'], str) or not value['tag'].strip() or len(value['tag']) > 200 or any(ord(c) < 32 for c in value['tag']):
|
|
177
|
+
raise JobError('invalid_tag', 'Supply an exact non-empty wallet tag, up to 200 characters.')
|
|
178
|
+
# Validate mixed-case checksums with the existing engine contract.
|
|
179
|
+
from wallet import validate_address
|
|
180
|
+
address = validate_address(value['address'])
|
|
181
|
+
return {'address': address, 'tag': value['tag']}
|
|
182
|
+
if operation in {'wallet.refresh', 'prices.refresh', 'wallet.setTags'}:
|
|
183
|
+
fields(value, ['wallet', 'tags'] if operation == 'wallet.setTags' else ['wallet'])
|
|
184
|
+
wallet = self.wallet(value['wallet'])
|
|
185
|
+
if operation == 'prices.refresh' and not wallet.get('latest_snapshot'):
|
|
186
|
+
raise JobError('no_snapshot', 'Analyse this wallet before refreshing prices.')
|
|
187
|
+
result = {'wallet': wallet['address_key']}
|
|
188
|
+
if operation == 'wallet.setTags':
|
|
189
|
+
tags = value['tags']
|
|
190
|
+
if not isinstance(tags, list) or not 1 <= len(tags) <= 20 or any(not isinstance(t, str) or not t.strip() or len(t) > 200 or any(ord(c) < 32 for c in t) for t in tags) or len(set(tags)) != len(tags):
|
|
191
|
+
raise JobError('invalid_tags', 'Supply 1 to 20 unique exact non-empty tags.')
|
|
192
|
+
result['tags'] = tags
|
|
193
|
+
return result
|
|
194
|
+
if operation in {'job.resume', 'job.cancel'}:
|
|
195
|
+
fields(value, ['job_id']); identifier(value['job_id'])
|
|
196
|
+
return value.copy()
|
|
197
|
+
raise JobError('unsupported_operation', 'Unsupported job operation.')
|
|
198
|
+
|
|
199
|
+
def submit(self, request):
|
|
200
|
+
fields(request, ['schema_version', 'operation', 'input', 'idempotency_key'])
|
|
201
|
+
if type(request['schema_version']) is not int or request['schema_version'] != 1:
|
|
202
|
+
raise JobError('unsupported_version', 'Expected request schema version 1.')
|
|
203
|
+
key = request['idempotency_key']
|
|
204
|
+
if not isinstance(key, str) or not 8 <= len(key) <= 200:
|
|
205
|
+
raise JobError('invalid_key', 'Supply an idempotency key of 8 to 200 characters.')
|
|
206
|
+
with self.locked():
|
|
207
|
+
operation = request['operation']
|
|
208
|
+
if not isinstance(operation,str):raise JobError('unsupported_operation','Expected a typed operation name.')
|
|
209
|
+
key_hash = digest(key); receipt = self.jobs / 'requests' / (key_hash + '.json')
|
|
210
|
+
raw_hash=digest({'operation':operation,'input':request['input']})
|
|
211
|
+
if receipt.exists() and read(receipt).get('raw_hash')==raw_hash:return self.get(read(receipt)['job_id'])
|
|
212
|
+
for p in self.jobs.glob('*.json'):
|
|
213
|
+
saved=self.get(p.stem)
|
|
214
|
+
if saved.get('control_raw',{}).get(key_hash)==raw_hash or saved['key_hash']==key_hash and saved.get('raw_hash')==raw_hash:
|
|
215
|
+
fingerprint=saved.get('control_receipts',{}).get(key_hash,saved['request_hash'])
|
|
216
|
+
atomic(receipt,{'schema_version':1,'request_hash':fingerprint,'raw_hash':raw_hash,'job_id':saved['job_id']})
|
|
217
|
+
return saved
|
|
218
|
+
value = self.normalize(operation, request['input'])
|
|
219
|
+
fingerprint = digest({'operation': operation, 'input': value})
|
|
220
|
+
if receipt.exists():
|
|
221
|
+
old = read(receipt)
|
|
222
|
+
if old['request_hash'] != fingerprint: raise JobError('idempotency_conflict', 'This request key already belongs to another operation.')
|
|
223
|
+
return self.get(old['job_id'])
|
|
224
|
+
# Recover an envelope committed before its request receipt.
|
|
225
|
+
existing = next((j for p in self.jobs.glob('*.json') if (j := self.get(p.stem))['key_hash'] == key_hash or key_hash in j.get('control_receipts',{})), None)
|
|
226
|
+
if existing:
|
|
227
|
+
recorded=existing.get('control_receipts',{}).get(key_hash,existing['request_hash'])
|
|
228
|
+
if recorded != fingerprint: raise JobError('idempotency_conflict', 'This request key already belongs to another operation.')
|
|
229
|
+
job = existing
|
|
230
|
+
elif operation in {'job.resume', 'job.cancel'}:
|
|
231
|
+
job = self.get(value['job_id'])
|
|
232
|
+
if operation == 'job.resume':
|
|
233
|
+
if job['state'] not in {'interrupted', 'failed', 'cancelled'}:
|
|
234
|
+
raise JobError('not_resumable', 'Only interrupted, failed or cancelled jobs can resume.')
|
|
235
|
+
job.update(state='queued', cancel_requested=False, errors=[])
|
|
236
|
+
elif job['state'] == 'queued': job.update(state='cancelled', cancel_requested=True)
|
|
237
|
+
elif job['state'] == 'running': job['cancel_requested'] = True
|
|
238
|
+
job.setdefault('control_receipts',{})[key_hash]=fingerprint
|
|
239
|
+
job.setdefault('control_raw',{})[key_hash]=raw_hash
|
|
240
|
+
self.save(job)
|
|
241
|
+
else:
|
|
242
|
+
previous = None
|
|
243
|
+
if operation=='wallet.add':
|
|
244
|
+
existing_wallet=next((w for w in read(self.root/'wallets.json')['wallets'] if w['address_key']==value['address'].lower()),None)
|
|
245
|
+
if existing_wallet:previous=existing_wallet.get('latest_snapshot')
|
|
246
|
+
if operation in {'wallet.refresh','prices.refresh','wallet.setTags'}:
|
|
247
|
+
previous = self.wallet(value['wallet']).get('latest_snapshot')
|
|
248
|
+
job_id = str(uuid.uuid4())
|
|
249
|
+
job = {'schema_version': 1, 'job_id': job_id, 'operation': operation, 'input': value,
|
|
250
|
+
'key_hash': key_hash, 'request_hash': fingerprint,'raw_hash':raw_hash, 'state': 'queued', 'attempt': 0,
|
|
251
|
+
'created_at': now(), 'updated_at': now(), 'sequence': 0, 'cancel_requested': False,
|
|
252
|
+
'stage': 'queued', 'events': [], 'checkpoint': 'snapshots/jobs/' + job_id,
|
|
253
|
+
'result': None, 'previous_result': previous, 'errors': [],'chains':{}}
|
|
254
|
+
self.save(job)
|
|
255
|
+
atomic(receipt, {'schema_version': 1, 'request_hash': fingerprint,'raw_hash':raw_hash, 'job_id': job['job_id']})
|
|
256
|
+
return job
|
|
257
|
+
|
|
258
|
+
def snapshot(self, snapshot_id):
|
|
259
|
+
if not isinstance(snapshot_id, str) or not snapshot_id.startswith('snapshots/'):
|
|
260
|
+
raise JobError('invalid_snapshot', 'Expected a saved snapshot ID.')
|
|
261
|
+
path = (self.root / snapshot_id).resolve()
|
|
262
|
+
if not path.is_relative_to((self.root / 'snapshots').resolve()) or '..' in Path(snapshot_id).parts:
|
|
263
|
+
raise JobError('invalid_snapshot', 'Snapshot ID is outside saved evidence.')
|
|
264
|
+
if not (path / 'results.json').is_file(): raise JobError('not_found', 'Snapshot was not found.')
|
|
265
|
+
result = read(path / 'results.json')
|
|
266
|
+
# Public readers receive financial facts, never arbitrary raw provider files.
|
|
267
|
+
from kira_config import redact
|
|
268
|
+
def safe(value, key=''):
|
|
269
|
+
if 'error' in key.lower() and value: return {'code': 'provider_error', 'message': 'Recorded provider read failed.'}
|
|
270
|
+
if isinstance(value, dict): return {k: safe(v,k) for k,v in value.items()}
|
|
271
|
+
if isinstance(value, list): return [safe(v,key) for v in value]
|
|
272
|
+
if isinstance(value,str) and ('http://' in value or 'https://' in value):
|
|
273
|
+
from urllib.parse import urlparse
|
|
274
|
+
parsed=urlparse(value)
|
|
275
|
+
if parsed.scheme=='https' and not parsed.username and not parsed.password and parsed.hostname in {'mint.club','dexscreener.com','basescan.org','etherscan.io','blastscan.io','www.alchemy.com'} and not parsed.query:return value
|
|
276
|
+
return redact(value)
|
|
277
|
+
return value
|
|
278
|
+
return safe(result)
|
|
279
|
+
|
|
280
|
+
def compare(self, before, after):
|
|
281
|
+
from decimal import Decimal, InvalidOperation, localcontext
|
|
282
|
+
old, new = self.snapshot(before), self.snapshot(after)
|
|
283
|
+
if old['wallet_address'].lower() != new['wallet_address'].lower():raise JobError('wallet_mismatch','Choose snapshots from the same wallet.')
|
|
284
|
+
def positions(snapshot):
|
|
285
|
+
return {(t['chain_id'],t['token_address'].lower(),t.get('token_id')):t for t in snapshot.get('tokens',[])}
|
|
286
|
+
left,right=positions(old),positions(new);changes=[]
|
|
287
|
+
for identity in sorted(left.keys() | right.keys(),key=str):
|
|
288
|
+
a,b=left.get(identity),right.get(identity);delta=None
|
|
289
|
+
if a is not None and b is not None and a.get('wallet_balance') is not None and b.get('wallet_balance') is not None:
|
|
290
|
+
try:
|
|
291
|
+
with localcontext() as context:
|
|
292
|
+
context.prec=100;delta=str(Decimal(b['wallet_balance'])-Decimal(a['wallet_balance']))
|
|
293
|
+
except InvalidOperation:pass
|
|
294
|
+
changes.append({'chain_id':identity[0],'address':identity[1],'token_id':identity[2],
|
|
295
|
+
'symbol':(b or a).get('symbol'),'before_balance':a.get('wallet_balance') if a else None,
|
|
296
|
+
'after_balance':b.get('wallet_balance') if b else None,'balance_delta':delta,
|
|
297
|
+
'presence':'both' if a and b else 'new_record' if b else 'absent_record',
|
|
298
|
+
'price_references_changed':(a or {}).get('indexer_price_references')!=(b or {}).get('indexer_price_references'),
|
|
299
|
+
'valuation_method_changed':bool((a or {}).get('mintclub'))!=bool((b or {}).get('mintclub'))})
|
|
300
|
+
return {'schema_version':1,'before':before,'after':after,'wallet':new['wallet_address'],
|
|
301
|
+
'coverage_changed':old.get('coverage')!=new.get('coverage'),'price_references_changed':old.get('price_references')!=new.get('price_references'),
|
|
302
|
+
'positions':changes,'note':'Absent records are unknown, not zero. Price overlays have separate observations and are not balance changes.'}
|
|
303
|
+
|
|
304
|
+
def snapshots(self, wallet):
|
|
305
|
+
key = self.wallet(wallet)['address_key']
|
|
306
|
+
paths = {w['latest_snapshot']['directory'] for w in read(self.root / 'wallets.json')['wallets'] if w['address_key'] == key and w.get('latest_snapshot')}
|
|
307
|
+
for run in read(self.root / 'wallets.json').get('research_runs', []):
|
|
308
|
+
if run['address_key'] == key: paths.add(run['snapshot'])
|
|
309
|
+
return sorted([{'snapshot_id': p, 'compiled_at': self.snapshot(p).get('compiled_at'), 'status': self.snapshot(p).get('status')} for p in paths],key=lambda r:r['compiled_at'] or '')
|
|
310
|
+
|
|
311
|
+
def publication(self, job):
|
|
312
|
+
if job['operation'].startswith('settings.') or job['operation']=='wallet.setTags':
|
|
313
|
+
receipt=self.jobs/'results'/(job['job_id']+'.json')
|
|
314
|
+
return read(receipt) if receipt.exists() else None
|
|
315
|
+
folder = self.root / job['checkpoint']
|
|
316
|
+
if job['operation'] == 'prices.refresh':
|
|
317
|
+
receipt = folder / 'price-publication.json'
|
|
318
|
+
if not receipt.exists():
|
|
319
|
+
if not (folder/'prices.json').exists():return None
|
|
320
|
+
saved=read(folder/'prices.json')
|
|
321
|
+
if not saved.get('snapshot_id'):return None
|
|
322
|
+
atomic(receipt,{'schema_version':1,'snapshot_id':saved['snapshot_id'],'overlay':str((folder/'prices.json').relative_to(self.root)),
|
|
323
|
+
'projection':saved['snapshot_id']+'/market-prices.json','status':saved['job_result_status']})
|
|
324
|
+
item = read(receipt)
|
|
325
|
+
overlay = (self.root / item['overlay']).resolve(); projection = (self.root / item['projection']).resolve()
|
|
326
|
+
base = (self.root / 'snapshots').resolve()
|
|
327
|
+
if not overlay.is_relative_to(base) or not projection.is_relative_to(base): raise JobError('invalid_snapshot', 'Invalid price receipt.')
|
|
328
|
+
# Reconciliation only while the worker owns the portfolio writer lock.
|
|
329
|
+
previous = read(projection) if projection.exists() else {}
|
|
330
|
+
value = read(overlay)
|
|
331
|
+
if (previous.get('observed_at') or '') <= value['observed_at']: atomic(projection, value)
|
|
332
|
+
return {'snapshot_id': item['snapshot_id'], 'overlay': item['overlay'], 'status': item['status']}
|
|
333
|
+
manifest = folder / 'run.json'
|
|
334
|
+
if not manifest.exists() or not (folder / 'results.json').exists(): return None
|
|
335
|
+
result = read(folder / 'results.json')
|
|
336
|
+
if result.get('wallet_address', '').lower() != (job['input'].get('wallet') or job['input'].get('address')).lower(): return None
|
|
337
|
+
registry = read(self.root / 'wallets.json')
|
|
338
|
+
published = any(w.get('latest_snapshot', {}).get('directory') == job['checkpoint'] for w in registry['wallets']) or any(r.get('snapshot') == job['checkpoint'] for r in registry.get('research_runs', []))
|
|
339
|
+
if not published: return None
|
|
340
|
+
return {'snapshot_id': job['checkpoint'], 'status': result['status']}
|
|
341
|
+
|
|
342
|
+
def recover(self):
|
|
343
|
+
with self.locked():
|
|
344
|
+
for path in self.jobs.glob('*.json'):
|
|
345
|
+
job = self.get(path.stem)
|
|
346
|
+
if job['state'] != 'running': continue
|
|
347
|
+
receipt = self.publication(job)
|
|
348
|
+
if receipt:
|
|
349
|
+
job.update(result=receipt, state='partial' if receipt['status'] != 'completed' else 'succeeded', stage='published')
|
|
350
|
+
else: job.update(state='interrupted', stage='interrupted', errors=[{'code': 'interrupted', 'message': 'Worker stopped. Saved evidence is available for an explicit resume.'}])
|
|
351
|
+
self.save(job)
|
|
352
|
+
|
|
353
|
+
def event(self, job_id, event):
|
|
354
|
+
allowed = {'registered', 'discovery', 'chain', 'dex_discovery', 'token_images', 'published', 'failed'}
|
|
355
|
+
if event.get('stage') not in allowed: return
|
|
356
|
+
with self.locked():
|
|
357
|
+
job = self.get(job_id)
|
|
358
|
+
counts = {k: v for k, v in event.items() if k in {'complete_chains', 'candidates', 'tokens','chain_id','registry','checked','held','block_number'} and type(v) is int and v >= 0}
|
|
359
|
+
job['stage'] = event['stage']
|
|
360
|
+
if event['stage']=='chain' and type(event.get('chain_id')) is int:
|
|
361
|
+
block=event.get('block_number');block=str(block) if type(block) is int and block>=0 else block
|
|
362
|
+
job.setdefault('chains',{})[str(event['chain_id'])]={'block_number':block if isinstance(block,str) and re.fullmatch(r'\d+',block) else None,
|
|
363
|
+
'endpoint_index':event.get('endpoint_index') if type(event.get('endpoint_index')) is int else None,
|
|
364
|
+
'status':event.get('status') if event.get('status') in ('complete','partial','unavailable') else 'unknown'}
|
|
365
|
+
job['events'].append({'stage': event['stage'], 'observed_at': now(), 'counts': counts})
|
|
366
|
+
if event['stage']=='chain' and type(event.get('chain_id')) is int:job['events'][-1]['context']={'chain_id':event['chain_id'],**job['chains'][str(event['chain_id'])]}
|
|
367
|
+
job['events'] = job['events'][-100:]
|
|
368
|
+
self.save(job)
|
|
369
|
+
|
|
370
|
+
def set_tags(self, job):
|
|
371
|
+
registry = read(self.root / 'wallets.json')
|
|
372
|
+
wallet = next(w for w in registry['wallets'] if w['address_key'] == job['input']['wallet'])
|
|
373
|
+
old = wallet['tags']; tags = job['input']['tags']
|
|
374
|
+
for tag in set(old) ^ set(tags):
|
|
375
|
+
registry.setdefault('tag_history', []).append({'address_key': wallet['address_key'], 'tag': tag,
|
|
376
|
+
'action': 'add' if tag in tags else 'remove', 'observed_at': now(), 'source': 'operator'})
|
|
377
|
+
wallet['tags'] = tags; atomic(self.root / 'wallets.json', registry)
|
|
378
|
+
return {'wallet': wallet['address_key'], 'tags': tags, 'status': 'completed'}
|
|
379
|
+
|
|
380
|
+
def engine_command(self, job):
|
|
381
|
+
if job['operation'] == 'prices.refresh':
|
|
382
|
+
command = [sys.executable, str(ASSETS / 'viewer/refresh_prices.py'), '--wallet', job['input']['wallet']]
|
|
383
|
+
else:
|
|
384
|
+
add = job['operation'] == 'wallet.add'
|
|
385
|
+
command = [sys.executable, str(ASSETS / 'wallet.py'), 'add' if add else 'refresh', job['input']['address'] if add else job['input']['wallet']]
|
|
386
|
+
if add: command += ['--tag', job['input']['tag']]
|
|
387
|
+
if (self.root / job['checkpoint'] / 'run.json').exists(): command += ['--resume', job['checkpoint']]
|
|
388
|
+
return command
|
|
389
|
+
|
|
390
|
+
def execute(self, job, lease, writer):
|
|
391
|
+
saved=self.publication(job)
|
|
392
|
+
if saved:return saved
|
|
393
|
+
if job['operation'] == 'wallet.setTags':
|
|
394
|
+
result=self.set_tags(job);atomic(self.jobs/'results'/(job['job_id']+'.json'),result);return result
|
|
395
|
+
if job['operation'].startswith('settings.'):
|
|
396
|
+
from kira_config import config_path,load_config
|
|
397
|
+
config=load_config()
|
|
398
|
+
if job['operation']=='settings.rpc':
|
|
399
|
+
updated={**job['input'],'chains':{key:dict(row) for key,row in job['input']['chains'].items()}}
|
|
400
|
+
for key,row in updated['chains'].items():
|
|
401
|
+
previous=config['rpc']['chains'].get(key,{})
|
|
402
|
+
if row['url_env']==previous.get('url_env') and previous.get('alchemy_network'):row['alchemy_network']=previous['alchemy_network']
|
|
403
|
+
config['rpc']=updated
|
|
404
|
+
else:config['discovery']={**job['input'],'explorers':False}
|
|
405
|
+
atomic(config_path(),config)
|
|
406
|
+
result={'status':'completed','setting':job['operation']};atomic(self.jobs/'results'/(job['job_id']+'.json'),result);return result
|
|
407
|
+
env = {**os.environ, 'KIRA_DATA_DIR': str(self.root), 'KIRA_JOB_ID': job['job_id'], 'KIRA_JOB_SNAPSHOT': job['checkpoint'], 'KIRA_ANALYSIS_FD': str(writer.fileno()), 'PYTHONUNBUFFERED': '1'}
|
|
408
|
+
command=self.engine_command(job)
|
|
409
|
+
child = subprocess.Popen(command, env=env, cwd=ASSETS, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
|
410
|
+
start_new_session=True, pass_fds=(lease.fileno(), writer.fileno()))
|
|
411
|
+
selector = selectors.DefaultSelector(); selector.register(child.stdout, selectors.EVENT_READ)
|
|
412
|
+
buffer = b''; cancelled = False; stopped = False
|
|
413
|
+
def stop_child():
|
|
414
|
+
nonlocal stopped
|
|
415
|
+
if stopped:return
|
|
416
|
+
stopped=True
|
|
417
|
+
try:os.killpg(child.pid, signal.SIGTERM)
|
|
418
|
+
except ProcessLookupError:pass
|
|
419
|
+
if child.poll() is None:
|
|
420
|
+
try: child.wait(timeout=5)
|
|
421
|
+
except subprocess.TimeoutExpired:
|
|
422
|
+
try:os.killpg(child.pid, signal.SIGKILL)
|
|
423
|
+
except ProcessLookupError:pass
|
|
424
|
+
child.wait()
|
|
425
|
+
# A failed engine leader can leave a Node descendant in its group.
|
|
426
|
+
try:os.killpg(child.pid,signal.SIGKILL)
|
|
427
|
+
except ProcessLookupError:pass
|
|
428
|
+
try:
|
|
429
|
+
while child.poll() is None:
|
|
430
|
+
if self.get(job['job_id'])['cancel_requested']:
|
|
431
|
+
cancelled = True; stop_child(); break
|
|
432
|
+
for key, _ in selector.select(.2):
|
|
433
|
+
chunk = os.read(key.fileobj.fileno(), 65536)
|
|
434
|
+
if not chunk: selector.unregister(key.fileobj); continue
|
|
435
|
+
buffer += chunk
|
|
436
|
+
while b'\n' in buffer:
|
|
437
|
+
line, buffer = buffer.split(b'\n', 1)
|
|
438
|
+
try: self.event(job['job_id'], json.loads(line))
|
|
439
|
+
except (ValueError, TypeError): pass
|
|
440
|
+
if len(buffer) > 65536: buffer = b''
|
|
441
|
+
stop_child()
|
|
442
|
+
while selector.select(0):
|
|
443
|
+
key=selector.select(0)[0][0];chunk=os.read(key.fileobj.fileno(),65536)
|
|
444
|
+
if not chunk:selector.unregister(key.fileobj);continue
|
|
445
|
+
buffer+=chunk
|
|
446
|
+
while b'\n' in buffer:
|
|
447
|
+
line,buffer=buffer.split(b'\n',1)
|
|
448
|
+
try:self.event(job['job_id'],json.loads(line))
|
|
449
|
+
except (ValueError,TypeError):pass
|
|
450
|
+
if len(buffer)>65536:buffer=b''
|
|
451
|
+
receipt = self.publication(job)
|
|
452
|
+
if receipt: return receipt
|
|
453
|
+
if cancelled: raise JobError('cancelled', 'Job stopped. Previous published results and saved evidence were preserved.')
|
|
454
|
+
raise JobError('engine_failed', 'Analysis stopped before publication. Check connection settings and resume saved evidence.')
|
|
455
|
+
finally:
|
|
456
|
+
stop_child(); selector.close(); child.stdout.close()
|
|
457
|
+
|
|
458
|
+
def worker(self):
|
|
459
|
+
with (self.root / '.job-worker.lock').open('a') as lease:
|
|
460
|
+
fcntl.flock(lease, fcntl.LOCK_EX)
|
|
461
|
+
# Do not reconcile or claim work while a legacy engine/config writer is live.
|
|
462
|
+
with (self.root / '.analysis.lock').open('a') as writer:
|
|
463
|
+
fcntl.flock(writer, fcntl.LOCK_EX)
|
|
464
|
+
self.recover()
|
|
465
|
+
while True:
|
|
466
|
+
writer = (self.root / '.analysis.lock').open('a')
|
|
467
|
+
fcntl.flock(writer, fcntl.LOCK_EX)
|
|
468
|
+
with self.locked():
|
|
469
|
+
queued = sorted((self.get(p.stem) for p in self.jobs.glob('*.json')), key=lambda j: j['created_at'])
|
|
470
|
+
job = next((j for j in queued if j['state'] == 'queued'), None)
|
|
471
|
+
if not job:writer.close();return
|
|
472
|
+
from kira_config import load_config
|
|
473
|
+
config=load_config()
|
|
474
|
+
job['provider_configuration']={'rpc_mode':config['rpc']['mode'],'discovery_provider':config['discovery']['provider'],
|
|
475
|
+
'public_fallback':config['rpc']['allow_public_fallback'],'endpoint_refs':{cid:row['url_env'] for cid,row in config['rpc']['chains'].items()}}
|
|
476
|
+
job.update(state='running', stage='starting', started_at=now(), attempt=job['attempt'] + 1, errors=[]); self.save(job)
|
|
477
|
+
try:
|
|
478
|
+
result = self.execute(job, lease, writer)
|
|
479
|
+
with self.locked():
|
|
480
|
+
job = self.get(job['job_id']); job.update(result=result, state='succeeded' if result['status'] == 'completed' else 'partial', stage='published'); self.save(job)
|
|
481
|
+
except JobError as error:
|
|
482
|
+
with self.locked():
|
|
483
|
+
job = self.get(job['job_id']); job.update(state='cancelled' if error.code == 'cancelled' else 'failed', stage='stopped', errors=[{'code': error.code, 'message': str(error)}]); self.save(job)
|
|
484
|
+
except (Exception, KeyboardInterrupt):
|
|
485
|
+
with self.locked():
|
|
486
|
+
job = self.get(job['job_id'])
|
|
487
|
+
try:saved=self.publication(job)
|
|
488
|
+
except (ValueError,OSError):saved=None
|
|
489
|
+
if saved:job.update(result=saved,state='succeeded' if saved['status']=='completed' else 'partial',stage='published',errors=[])
|
|
490
|
+
else:job.update(state='interrupted', stage='interrupted', errors=[{'code': 'interrupted', 'message': 'Worker stopped. Resume saved evidence explicitly.'}])
|
|
491
|
+
self.save(job)
|
|
492
|
+
return
|
|
493
|
+
finally: writer.close()
|
|
494
|
+
|
|
495
|
+
def launch(self):
|
|
496
|
+
# Logs never contain raw engine output or credentials.
|
|
497
|
+
with (self.root / 'jobs-worker.log').open('ab') as log:
|
|
498
|
+
subprocess.Popen([sys.executable, str(ASSETS / 'kira_jobs.py'), '--worker', str(self.root)],
|
|
499
|
+
stdout=log, stderr=log, start_new_session=True, env=os.environ.copy())
|
|
500
|
+
|
|
501
|
+
if __name__ == '__main__':
|
|
502
|
+
if len(sys.argv) == 3 and sys.argv[1] == '--worker':
|
|
503
|
+
signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt()))
|
|
504
|
+
JobStore(sys.argv[2]).worker()
|