omnilane 0.41.1 → 0.42.2
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/.claude-plugin/marketplace.json +4 -4
- package/.claude-plugin/plugin.json +2 -2
- package/CHANGELOG.md +38 -1
- package/README.ja.md +27 -2
- package/README.ko.md +27 -2
- package/README.md +89 -16
- package/README.zh-CN.md +27 -2
- package/README.zh-TW.md +69 -11
- package/VERSION +1 -1
- package/config/aa-model-policy.json +3046 -0
- package/docs/completion-wakeup.md +126 -0
- package/docs/native-executor.md +264 -0
- package/docs/release-notes-0.42.2.md +31 -0
- package/hooks/routing-instruction.md +101 -40
- package/package.json +6 -2
- package/plugin.json +2 -2
- package/routing.yaml +7 -7
- package/scripts/completion-wakeup.py +390 -0
- package/scripts/dispatch.sh +240 -18
- package/scripts/jobs.sh +58 -15
- package/scripts/lib/aa_policy.py +482 -0
- package/scripts/lib/aa_retry.py +77 -0
- package/scripts/lib/common.sh +59 -0
- package/scripts/lib/job-worker.sh +2 -0
- package/scripts/lib/native.py +507 -0
- package/scripts/runners/run-grok.sh +16 -2
- package/scripts/runners/run-vote.sh +3 -0
- package/skills/omnilane/SKILL.md +120 -31
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Caller-owned Codex heartbeat receipts; never invokes a model or scheduler.
|
|
3
|
+
|
|
4
|
+
Only CLI job metadata and exit markers are observed. Private outputs, prompts,
|
|
5
|
+
native state, inbox tails and Codex session files are deliberately not read.
|
|
6
|
+
"""
|
|
7
|
+
import argparse
|
|
8
|
+
import contextlib
|
|
9
|
+
import fcntl
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
import re
|
|
15
|
+
import secrets
|
|
16
|
+
import stat
|
|
17
|
+
import sys
|
|
18
|
+
import tempfile
|
|
19
|
+
import time
|
|
20
|
+
|
|
21
|
+
LIMIT = 128 * 1024
|
|
22
|
+
IDENTITY = ('lane', 'vendor', 'model', 'mode', 'workdir', 'foreman_session', 'started')
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Invalid(ValueError):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def check(value, message):
|
|
30
|
+
if not value:
|
|
31
|
+
raise Invalid(message)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def identifier(value):
|
|
35
|
+
check(isinstance(value, str) and re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._:-]{0,255}', value),
|
|
36
|
+
'invalid identifier')
|
|
37
|
+
return value
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def no_links(path):
|
|
41
|
+
path = Path(os.path.abspath(path))
|
|
42
|
+
for part in (path, *path.parents):
|
|
43
|
+
check(not part.is_symlink(), 'symlink path rejected')
|
|
44
|
+
return path
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def read_bytes(path, limit=LIMIT):
|
|
48
|
+
path = no_links(path)
|
|
49
|
+
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
|
50
|
+
with os.fdopen(fd, 'rb') as stream:
|
|
51
|
+
info = os.fstat(stream.fileno())
|
|
52
|
+
check(stat.S_ISREG(info.st_mode) and info.st_uid == os.getuid(), 'invalid record owner/type')
|
|
53
|
+
data = stream.read(limit + 1)
|
|
54
|
+
check(len(data) <= limit, 'record too large')
|
|
55
|
+
return data
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def read_json(path):
|
|
59
|
+
value = json.loads(read_bytes(path))
|
|
60
|
+
check(isinstance(value, dict), 'expected JSON object')
|
|
61
|
+
return value
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def digest(value):
|
|
65
|
+
return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(',', ':')).encode()).hexdigest()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def write_json(path, value):
|
|
69
|
+
raw = (json.dumps(value, ensure_ascii=False, sort_keys=True) + '\n').encode()
|
|
70
|
+
check(len(raw) <= LIMIT, 'registry too large')
|
|
71
|
+
no_links(path)
|
|
72
|
+
fd, name = tempfile.mkstemp(prefix='.' + path.name + '-', dir=path.parent)
|
|
73
|
+
try:
|
|
74
|
+
with os.fdopen(fd, 'wb') as stream:
|
|
75
|
+
stream.write(raw); stream.flush(); os.fsync(stream.fileno())
|
|
76
|
+
os.replace(name, path)
|
|
77
|
+
finally:
|
|
78
|
+
if os.path.exists(name):
|
|
79
|
+
os.unlink(name)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@contextlib.contextmanager
|
|
83
|
+
def registry_lock(args):
|
|
84
|
+
home = no_links(args.home)
|
|
85
|
+
if args.action == 'prepare':
|
|
86
|
+
home.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
87
|
+
check(home.is_dir(), 'home missing')
|
|
88
|
+
root = no_links(home / 'wakeups')
|
|
89
|
+
if args.action == 'prepare':
|
|
90
|
+
root.mkdir(mode=0o700, exist_ok=True)
|
|
91
|
+
check(root.is_dir(), 'registry not prepared')
|
|
92
|
+
info = root.stat()
|
|
93
|
+
check(info.st_uid == os.getuid() and not info.st_mode & 0o077, 'wakeup store must be owner-only')
|
|
94
|
+
key = digest([args.host_id, args.thread_id])[:32]
|
|
95
|
+
path = root / (key + '.json')
|
|
96
|
+
fd = os.open(root / (key + '.lock'), os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW | os.O_NONBLOCK, 0o600)
|
|
97
|
+
with os.fdopen(fd, 'r+') as lock:
|
|
98
|
+
info = os.fstat(lock.fileno())
|
|
99
|
+
check(stat.S_ISREG(info.st_mode) and info.st_uid == os.getuid(), 'invalid lock')
|
|
100
|
+
# Advisory kernel locks automatically release on crash; no stale PID cleanup.
|
|
101
|
+
fcntl.flock(lock, fcntl.LOCK_EX)
|
|
102
|
+
yield path
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def identity(args, job, workdir, claims=None, permit_adoption=False):
|
|
106
|
+
identifier(job)
|
|
107
|
+
path = no_links(Path(args.home) / 'jobs' / job)
|
|
108
|
+
check(path.is_dir(), 'job missing')
|
|
109
|
+
check(not os.path.lexists(path / 'native.json'), 'unsupported-native: use native completion channel')
|
|
110
|
+
meta = read_json(path / 'meta.json')
|
|
111
|
+
check(all(isinstance(meta.get(k), str) for k in IDENTITY), 'invalid job metadata')
|
|
112
|
+
actual = no_links(meta['workdir'])
|
|
113
|
+
check(actual.is_dir() and str(actual.resolve()) == workdir, 'job workdir mismatch')
|
|
114
|
+
public = {k: meta[k] for k in IDENTITY}
|
|
115
|
+
public['workdir'] = workdir
|
|
116
|
+
# This is a Claude process/session binding, not a Codex app thread ID.
|
|
117
|
+
frozen = digest(public)
|
|
118
|
+
if meta['foreman_session']:
|
|
119
|
+
claims = {} if claims is None else claims
|
|
120
|
+
claim = claims.get(job)
|
|
121
|
+
if claim is None and permit_adoption and args.adoption_evidence:
|
|
122
|
+
receipt = read_json(args.adoption_evidence)
|
|
123
|
+
check(receipt.get('source') == 'operator_adoption'
|
|
124
|
+
and receipt.get('operator_confirmed') is True
|
|
125
|
+
and receipt.get('controller_thread_id') == args.thread_id
|
|
126
|
+
and receipt.get('controller_host_id') == args.host_id
|
|
127
|
+
and receipt.get('run_id') == args.run_id
|
|
128
|
+
and isinstance(receipt.get('job_identity_sha256'), dict)
|
|
129
|
+
and receipt['job_identity_sha256'].get(job) == frozen,
|
|
130
|
+
'adoption receipt binding/identity mismatch')
|
|
131
|
+
claim = {'identity_sha256': frozen, 'receipt_path': str(no_links(args.adoption_evidence)),
|
|
132
|
+
'receipt_sha256': digest(receipt), 'trust': 'operator adoption attested by caller'}
|
|
133
|
+
claims[job] = claim
|
|
134
|
+
# Reveal only public job id + fingerprint, not the original session value.
|
|
135
|
+
check(isinstance(claim, dict) and claim.get('identity_sha256') == frozen,
|
|
136
|
+
'adoption_required:' + job + ':' + frozen)
|
|
137
|
+
# Freeze immutable identity, not mutable completion/provenance fields.
|
|
138
|
+
return frozen
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def assert_binding(state, args):
|
|
142
|
+
check(state.get('schema_version') == 1, 'unsupported registry schema')
|
|
143
|
+
check(state.get('controller_thread_id') == args.thread_id
|
|
144
|
+
and state.get('controller_host_id') == args.host_id
|
|
145
|
+
and state.get('run_id') == args.run_id, 'controller binding mismatch')
|
|
146
|
+
check(state.get('home') == str(no_links(args.home)), 'home binding mismatch')
|
|
147
|
+
check(state.get('status') in ('pending', 'registered', 'closed'), 'invalid registry status')
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def summary(state, path):
|
|
151
|
+
return {'schema_version': 1, 'registry': str(path), 'status': state['status'],
|
|
152
|
+
'automation_id': state.get('automation_id'), 'tracked_jobs': sorted(state['jobs'])}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def command_prefix(args):
|
|
156
|
+
import shlex
|
|
157
|
+
return ' '.join(shlex.quote(x) for x in [sys.executable, str(Path(__file__).resolve()), 'poll',
|
|
158
|
+
'--home', str(no_links(args.home)), '--thread-id', args.thread_id,
|
|
159
|
+
'--host-id', args.host_id, '--run-id', args.run_id])
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def assert_unused_historical_run(args, path):
|
|
163
|
+
"""A stopped heartbeat must never become valid again through name reuse."""
|
|
164
|
+
history = no_links(path.parent / 'history')
|
|
165
|
+
if not history.exists():
|
|
166
|
+
return
|
|
167
|
+
check(history.is_dir(), 'invalid history store')
|
|
168
|
+
for archived in history.glob(path.stem + '-*.json'):
|
|
169
|
+
old = read_json(archived)
|
|
170
|
+
check(old.get('controller_thread_id') == args.thread_id
|
|
171
|
+
and old.get('controller_host_id') == args.host_id, 'history binding mismatch')
|
|
172
|
+
check(old.get('run_id') != args.run_id, 'historical run id cannot be reused')
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def prepare(args, state, path, now):
|
|
176
|
+
check(args.workdir and args.job, 'prepare requires --workdir and --job')
|
|
177
|
+
work = no_links(args.workdir)
|
|
178
|
+
check(work.is_dir(), 'workdir missing')
|
|
179
|
+
work = str(work.resolve())
|
|
180
|
+
if state is None:
|
|
181
|
+
state = {'schema_version': 1, 'controller_thread_id': args.thread_id,
|
|
182
|
+
'controller_host_id': args.host_id, 'run_id': args.run_id,
|
|
183
|
+
'home': str(no_links(args.home)), 'workdir': work, 'status': 'pending',
|
|
184
|
+
'created_at': now, 'expires_at': now + args.ttl_seconds,
|
|
185
|
+
'requested_interval_seconds': args.interval_seconds, 'jobs': {}, 'events': {}}
|
|
186
|
+
else:
|
|
187
|
+
assert_binding(state, args)
|
|
188
|
+
check(state['status'] != 'closed' and now < state['expires_at'], 'registry closed/expired')
|
|
189
|
+
check(state['workdir'] == work, 'workdir binding mismatch')
|
|
190
|
+
for job in args.job:
|
|
191
|
+
frozen = identity(args, job, work, state.setdefault('adoptions', {}), permit_adoption=True)
|
|
192
|
+
check(job not in state['jobs'] or state['jobs'][job] == frozen, 'job identity changed')
|
|
193
|
+
state['jobs'][job] = frozen
|
|
194
|
+
check(len(state['jobs']) <= 100, 'job limit exceeded')
|
|
195
|
+
prompt = (f'檢查此任務已登錄的 omnilane 背景工作:執行 {command_prefix(args)}。'
|
|
196
|
+
'沒有新事件或需處理問題時保持安靜。只處理登錄的工作,確認主控綁定後以 claim token '
|
|
197
|
+
'ack-delivered,再接續原任務驗收並以證據 ack-accepted;退出碼零不是驗收通過。'
|
|
198
|
+
'不要新建監看器或自動派工。needs_disable 時停用本 heartbeat,再以工具結果回填 closed。')
|
|
199
|
+
tool_args = {'mode': 'update' if state.get('automation_id') else 'create', 'kind': 'heartbeat',
|
|
200
|
+
'name': 'omnilane 完成續驗', 'prompt': prompt, 'status': 'ACTIVE',
|
|
201
|
+
'targetThreadId': args.thread_id}
|
|
202
|
+
if state.get('automation_id'):
|
|
203
|
+
tool_args['id'] = state['automation_id']
|
|
204
|
+
result = summary(state, path)
|
|
205
|
+
result.update(tool='mcp__codex_app__automation_update', tool_args=tool_args,
|
|
206
|
+
schedule_request={'interval_seconds': state['requested_interval_seconds'],
|
|
207
|
+
'instruction': '主控使用工具支援的排程格式補入 rrule;保留既有通知偏好。'},
|
|
208
|
+
registration_required=state['status'] == 'pending')
|
|
209
|
+
return state, result
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def automation_receipt(args, expected):
|
|
213
|
+
check(args.evidence and args.automation_id, 'automation id and evidence required')
|
|
214
|
+
receipt = read_json(args.evidence)
|
|
215
|
+
check(receipt.get('source') == 'automation_update'
|
|
216
|
+
and receipt.get('automation_id') == args.automation_id
|
|
217
|
+
and receipt.get('controller_thread_id') == args.thread_id
|
|
218
|
+
and receipt.get('controller_host_id') == args.host_id
|
|
219
|
+
and receipt.get('status') == expected, 'automation receipt binding/status mismatch')
|
|
220
|
+
return {'path': str(no_links(args.evidence)), 'sha256': digest(receipt),
|
|
221
|
+
'effective_interval_seconds': receipt.get('effective_interval_seconds'),
|
|
222
|
+
'trust': 'caller-attested tool result; not independent scheduler verification'}
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def poll(args, state, path, now):
|
|
226
|
+
result = summary(state, path)
|
|
227
|
+
result.update(events=[], errors=[], needs_disable=False)
|
|
228
|
+
if state['status'] == 'closed':
|
|
229
|
+
return result
|
|
230
|
+
if now >= state['expires_at']:
|
|
231
|
+
result.update(status='expired', needs_disable=bool(state.get('automation_id')))
|
|
232
|
+
return result
|
|
233
|
+
if state['status'] != 'registered':
|
|
234
|
+
return result
|
|
235
|
+
accepted = 0
|
|
236
|
+
for job, frozen in sorted(state['jobs'].items()):
|
|
237
|
+
try:
|
|
238
|
+
check(identity(args, job, state['workdir'], state.get('adoptions', {})) == frozen, 'binding_changed')
|
|
239
|
+
marker = Path(args.home) / 'jobs' / job / 'exit'
|
|
240
|
+
if not os.path.lexists(marker):
|
|
241
|
+
continue
|
|
242
|
+
raw = read_bytes(marker, 32).decode().strip()
|
|
243
|
+
check(re.fullmatch(r'-?\d{1,5}', raw), 'invalid_exit')
|
|
244
|
+
rc = int(raw)
|
|
245
|
+
check(-32768 <= rc <= 32767, 'invalid_exit')
|
|
246
|
+
key = digest([args.run_id, job, frozen, rc])
|
|
247
|
+
# Changing an already observed terminal exit is not a fresh generation.
|
|
248
|
+
prior = [k for k, e in state['events'].items() if e['job_id'] == job]
|
|
249
|
+
check(not prior or prior == [key], 'terminal_changed')
|
|
250
|
+
event = state['events'].setdefault(key, {'job_id': job, 'exit_code': rc,
|
|
251
|
+
'state': 'observed', 'observed_at': now})
|
|
252
|
+
if event['state'] == 'accepted':
|
|
253
|
+
accepted += 1
|
|
254
|
+
continue
|
|
255
|
+
if event.get('lease_until', 0) > now:
|
|
256
|
+
continue
|
|
257
|
+
event.update(claim_token=secrets.token_hex(16), lease_until=now + args.lease_seconds,
|
|
258
|
+
state='claimed')
|
|
259
|
+
result['events'].append({'event_key': key, 'job_id': job, 'exit_code': rc,
|
|
260
|
+
'claim_token': event['claim_token'], 'lease_until': event['lease_until']})
|
|
261
|
+
except (Invalid, OSError, ValueError) as error:
|
|
262
|
+
# No arbitrary metadata, logs, paths or file contents in public errors.
|
|
263
|
+
code = str(error) if isinstance(error, Invalid) else 'invalid_job_record'
|
|
264
|
+
result['errors'].append({'job_id': job, 'code': code})
|
|
265
|
+
result['needs_disable'] = accepted == len(state['jobs']) and not result['errors']
|
|
266
|
+
state['last_observed_at'] = now
|
|
267
|
+
return result
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def act(args):
|
|
271
|
+
for value in (args.thread_id, args.host_id, args.run_id):
|
|
272
|
+
identifier(value)
|
|
273
|
+
with registry_lock(args) as path:
|
|
274
|
+
state = read_json(path) if os.path.lexists(path) else None
|
|
275
|
+
now = time.time()
|
|
276
|
+
previous_run = None
|
|
277
|
+
if args.action == 'prepare':
|
|
278
|
+
assert_unused_historical_run(args, path)
|
|
279
|
+
if state and state.get('status') == 'closed' and state.get('run_id') != args.run_id:
|
|
280
|
+
# Preserve the closed run before publishing a new active binding.
|
|
281
|
+
# Validate every dimension except the intentionally new run ID.
|
|
282
|
+
old_run_id = state.get('run_id')
|
|
283
|
+
identifier(old_run_id)
|
|
284
|
+
state_for_binding = dict(state, run_id=args.run_id)
|
|
285
|
+
assert_binding(state_for_binding, args)
|
|
286
|
+
previous_run, state = state, None
|
|
287
|
+
state, result = prepare(args, state, path, now)
|
|
288
|
+
else:
|
|
289
|
+
check(state is not None, 'registry not prepared')
|
|
290
|
+
assert_binding(state, args)
|
|
291
|
+
result = summary(state, path)
|
|
292
|
+
if args.action == 'poll':
|
|
293
|
+
result = poll(args, state, path, now)
|
|
294
|
+
elif args.action == 'record-registration':
|
|
295
|
+
check(state['status'] != 'closed' and now < state['expires_at'], 'registry closed/expired')
|
|
296
|
+
identifier(args.automation_id)
|
|
297
|
+
check(not state.get('automation_id') or state['automation_id'] == args.automation_id,
|
|
298
|
+
'automation id mismatch')
|
|
299
|
+
receipt = automation_receipt(args, 'ACTIVE')
|
|
300
|
+
state.update(status='registered', automation_id=args.automation_id,
|
|
301
|
+
registration_receipt=receipt, registered_at=now)
|
|
302
|
+
result = summary(state, path)
|
|
303
|
+
elif args.action == 'closed':
|
|
304
|
+
check(state.get('automation_id') == args.automation_id, 'automation id mismatch')
|
|
305
|
+
receipt = automation_receipt(args, 'PAUSED')
|
|
306
|
+
if args.close_reason == 'completed':
|
|
307
|
+
check(len(state['events']) == len(state['jobs'])
|
|
308
|
+
and all(e['state'] == 'accepted' for e in state['events'].values()),
|
|
309
|
+
'unaccepted jobs remain; use explicit stopped/expired reason')
|
|
310
|
+
if args.close_reason == 'expired':
|
|
311
|
+
check(now >= state['expires_at'], 'registry not expired')
|
|
312
|
+
state.update(status='closed', closed_at=now, closure_receipt=receipt,
|
|
313
|
+
close_reason=args.close_reason)
|
|
314
|
+
result = summary(state, path)
|
|
315
|
+
else:
|
|
316
|
+
check(state['status'] == 'registered' and now < state['expires_at'], 'inactive registry')
|
|
317
|
+
event = state['events'].get(args.event_key)
|
|
318
|
+
check(event and event.get('claim_token') == args.claim_token
|
|
319
|
+
and event.get('lease_until', 0) > now, 'invalid/expired claim')
|
|
320
|
+
check(identity(args, event['job_id'], state['workdir'], state.get('adoptions', {}))
|
|
321
|
+
== state['jobs'][event['job_id']],
|
|
322
|
+
'binding_changed')
|
|
323
|
+
current_exit = read_bytes(Path(args.home) / 'jobs' / event['job_id'] / 'exit', 32)
|
|
324
|
+
check(current_exit.decode().strip() == str(event['exit_code']), 'terminal_changed')
|
|
325
|
+
if args.action == 'ack-delivered':
|
|
326
|
+
check(event['state'] in ('claimed', 'delivered', 'accepted'), 'invalid delivery state')
|
|
327
|
+
if event['state'] == 'claimed':
|
|
328
|
+
event.update(state='delivered', delivered_at=now)
|
|
329
|
+
else:
|
|
330
|
+
check(event['state'] in ('delivered', 'accepted'), 'delivery required before acceptance')
|
|
331
|
+
check(args.result and args.evidence, 'acceptance result/evidence required')
|
|
332
|
+
data = read_bytes(args.evidence)
|
|
333
|
+
check(data.strip(), 'empty acceptance evidence')
|
|
334
|
+
evidence = {'path': str(no_links(args.evidence)),
|
|
335
|
+
'sha256': hashlib.sha256(data).hexdigest()}
|
|
336
|
+
if event['state'] == 'accepted':
|
|
337
|
+
check(event['result'] == args.result and event['evidence'] == evidence,
|
|
338
|
+
'acceptance receipt conflict')
|
|
339
|
+
else:
|
|
340
|
+
event.update(state='accepted', accepted_at=now, result=args.result, evidence=evidence)
|
|
341
|
+
result.update(event_key=args.event_key, event_state=event['state'])
|
|
342
|
+
if previous_run is not None:
|
|
343
|
+
history = no_links(path.parent / 'history')
|
|
344
|
+
history.mkdir(mode=0o700, exist_ok=True)
|
|
345
|
+
info = history.stat()
|
|
346
|
+
check(info.st_uid == os.getuid() and not info.st_mode & 0o077, 'history must be owner-only')
|
|
347
|
+
archived = history / (path.stem + '-' + digest(previous_run) + '.json')
|
|
348
|
+
if os.path.lexists(archived):
|
|
349
|
+
check(read_json(archived) == previous_run, 'history conflict')
|
|
350
|
+
else:
|
|
351
|
+
write_json(archived, previous_run)
|
|
352
|
+
state['previous_run_history'] = str(archived)
|
|
353
|
+
result['previous_run_history'] = str(archived)
|
|
354
|
+
write_json(path, state)
|
|
355
|
+
return result
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def main():
|
|
359
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
360
|
+
parser.add_argument('action', choices=['prepare', 'record-registration', 'poll',
|
|
361
|
+
'ack-delivered', 'ack-accepted', 'closed'])
|
|
362
|
+
parser.add_argument('--home', default=os.environ.get('OMNILANE_HOME', str(Path.home() / '.omnilane')))
|
|
363
|
+
for name in ('thread-id', 'host-id', 'run-id'):
|
|
364
|
+
parser.add_argument('--' + name, required=True)
|
|
365
|
+
parser.add_argument('--workdir')
|
|
366
|
+
parser.add_argument('--job', action='append')
|
|
367
|
+
parser.add_argument('--automation-id')
|
|
368
|
+
parser.add_argument('--evidence')
|
|
369
|
+
parser.add_argument('--adoption-evidence')
|
|
370
|
+
parser.add_argument('--event-key')
|
|
371
|
+
parser.add_argument('--claim-token')
|
|
372
|
+
parser.add_argument('--close-reason', choices=['completed', 'stopped', 'expired'], default='completed')
|
|
373
|
+
parser.add_argument('--result', choices=['PASS', 'FAIL', 'PARTIAL', 'BLOCKED'])
|
|
374
|
+
parser.add_argument('--ttl-seconds', type=int, default=86400)
|
|
375
|
+
parser.add_argument('--interval-seconds', type=int, default=60)
|
|
376
|
+
parser.add_argument('--lease-seconds', type=int, default=900)
|
|
377
|
+
args = parser.parse_args()
|
|
378
|
+
try:
|
|
379
|
+
check(1 <= args.ttl_seconds <= 604800 and 30 <= args.interval_seconds <= 86400
|
|
380
|
+
and 1 <= args.lease_seconds <= 86400, 'invalid time limit')
|
|
381
|
+
print(json.dumps(act(args), ensure_ascii=False, sort_keys=True))
|
|
382
|
+
return 0
|
|
383
|
+
except (Invalid, OSError, ValueError, TypeError, KeyError) as error:
|
|
384
|
+
message = str(error) if isinstance(error, Invalid) else type(error).__name__
|
|
385
|
+
print(json.dumps({'error': message}), file=sys.stderr)
|
|
386
|
+
return 2
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
if __name__ == '__main__':
|
|
390
|
+
sys.exit(main())
|