@gotcos/glasses-server 6.54.0 → 6.56.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/CHANGELOG.md +33 -0
- package/README.md +16 -0
- package/bin/control2-foundation.cjs +12 -0
- package/bin/task-runtime-check.cjs +65 -0
- package/docs/control2-foundation.md +79 -0
- package/docs/control2-sandbox.md +47 -0
- package/docs/control2-task-runtime.md +68 -0
- package/docs/work-reviews.md +25 -0
- package/docs/work-task-runtime.md +82 -0
- package/package.json +18 -4
- package/server/index.ts +8 -0
- package/server/lib/control2-draft.ts +118 -0
- package/server/lib/control2-foundation.ts +164 -0
- package/server/lib/control2-sandbox.ts +192 -0
- package/server/lib/lens-gist.ts +110 -16
- package/server/lib/task-bridge.ts +85 -0
- package/server/lib/task-store.ts +75 -10
- package/server/lib/work-activity.ts +106 -0
- package/server/lib/work-review-backend.ts +28 -0
- package/server/lib/work-review-runtime.ts +203 -0
- package/server/lib/work-review-store.ts +102 -0
- package/server/routes/control2-foundation.ts +56 -0
- package/server/routes/health.ts +4 -3
- package/server/routes/meetings.ts +24 -59
- package/server/routes/work-board.ts +79 -0
- package/server/routes/work-reviews.ts +24 -0
- package/server/scripts/control2-draft-canary.ts +18 -0
- package/server/scripts/control2-foundation-lab.ts +45 -0
- package/server/scripts/control2-sandbox-canary.ts +4 -0
- package/task-runtime/LICENSE +21 -0
- package/task-runtime/manifest.json +37 -0
- package/task-runtime/task_runtime.py +380 -0
- package/work-task-runtime/cos_atomic.py +90 -0
- package/work-task-runtime/manifest.json +33 -0
- package/work-task-runtime/runtime_io.py +27 -0
- package/work-task-runtime/task_bridge.py +87 -0
- package/work-task-runtime/task_checkout.py +380 -0
- package/work-task-runtime/task_commands.py +275 -0
- package/work-task-runtime/task_dedup.py +11 -0
- package/work-task-runtime/task_rows.py +420 -0
- package/work-task-runtime/task_work_metadata.py +107 -0
- package/work-task-runtime/task_write.py +645 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Portable bridge over canonical tasks.md; no provider invocation or second store."""
|
|
3
|
+
import argparse, io, json, os, stat, sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
# -I excludes cwd/site configuration. Add only our installed, hash-verified code.
|
|
6
|
+
sys.path.insert(0,str(Path(__file__).resolve().parent))
|
|
7
|
+
from runtime_io import safe_read_text
|
|
8
|
+
|
|
9
|
+
def safe_name(name):
|
|
10
|
+
return isinstance(name,str) and 0<len(name)<=64 and name==name.strip() and not name.startswith('.') and not any(c in name for c in '/\\\0') and not any(ord(c)<32 or ord(c)==127 for c in name)
|
|
11
|
+
|
|
12
|
+
def directory(path, create=False):
|
|
13
|
+
path=Path(path)
|
|
14
|
+
if not path.is_absolute() or '..' in path.parts: raise ValueError('Absolute canonical root required')
|
|
15
|
+
fd=os.open('/',os.O_RDONLY|os.O_DIRECTORY)
|
|
16
|
+
try:
|
|
17
|
+
for part in path.parts[1:]:
|
|
18
|
+
if create:
|
|
19
|
+
try: os.mkdir(part,0o700,dir_fd=fd)
|
|
20
|
+
except FileExistsError: pass
|
|
21
|
+
child=os.open(part,os.O_RDONLY|os.O_DIRECTORY|os.O_NOFOLLOW,dir_fd=fd)
|
|
22
|
+
os.close(fd);fd=child
|
|
23
|
+
return fd
|
|
24
|
+
except BaseException: os.close(fd);raise
|
|
25
|
+
|
|
26
|
+
def main():
|
|
27
|
+
if sys.version_info<(3,10): raise ValueError("Python 3.10 or later required")
|
|
28
|
+
parser=argparse.ArgumentParser();parser.add_argument('--root',required=True);parser.add_argument('--lock-store',required=True);parser.add_argument('--domains',required=True);parser.add_argument('--operator',default='');parser.add_argument('command');parser.add_argument('args',nargs=argparse.REMAINDER)
|
|
29
|
+
opts=parser.parse_args(); domains=json.loads(opts.domains)
|
|
30
|
+
if not isinstance(domains,list) or not domains or len(domains)>256 or not all(safe_name(d) for d in domains): raise ValueError('Invalid domain inventory')
|
|
31
|
+
raw=sys.stdin.buffer.read(32769)
|
|
32
|
+
if len(raw)>32768: raise ValueError('Task input exceeds supported limit')
|
|
33
|
+
sys.stdin=io.StringIO(raw.decode('utf-8'))
|
|
34
|
+
commands={'task-rows':'cmd_task_rows','task-capture':'cmd_task_capture','task-set-text':'cmd_task_set_text','task-set-run-at':'cmd_task_set_run_at','task-set-marker':'cmd_task_set_marker','task-set-stage':'cmd_task_set_stage','task-set-done-when':'cmd_task_set_done_when','task-move':'cmd_task_move','task-check':'cmd_task_check','task-set-work-stage':'cmd_task_work_write','task-link-meeting':'cmd_task_work_write'}
|
|
35
|
+
if opts.command not in commands and opts.command!='task-work-capabilities': raise ValueError('Unsupported task command')
|
|
36
|
+
rootfd=directory(opts.root,create=True); domainfds={}; lockfd=None
|
|
37
|
+
try:
|
|
38
|
+
# Descriptor pinning prevents ancestor rename/symlink replacement from
|
|
39
|
+
# redirecting canonical source reads or atomic replacement writes.
|
|
40
|
+
def domain_path(domain):
|
|
41
|
+
if domain not in domains or not safe_name(domain): raise ValueError('Unknown task domain')
|
|
42
|
+
if domain not in domainfds:
|
|
43
|
+
try: os.mkdir(domain,0o700,dir_fd=rootfd)
|
|
44
|
+
except FileExistsError: pass
|
|
45
|
+
domainfds[domain]=os.open(domain,os.O_RDONLY|os.O_DIRECTORY|os.O_NOFOLLOW,dir_fd=rootfd)
|
|
46
|
+
os.fchdir(domainfds[domain])
|
|
47
|
+
return Path('tasks.md')
|
|
48
|
+
lock=Path(opts.lock_store)
|
|
49
|
+
if not lock.is_absolute() or not safe_name(lock.name.lstrip('.')): raise ValueError('Invalid lock path')
|
|
50
|
+
lockfd=directory(lock.parent,create=True)
|
|
51
|
+
lockpath=lock
|
|
52
|
+
import runtime_io
|
|
53
|
+
runtime_io.LOCK_FD=lockfd
|
|
54
|
+
try:
|
|
55
|
+
info=os.stat(lock.name,dir_fd=lockfd,follow_symlinks=False)
|
|
56
|
+
if not stat.S_ISREG(info.st_mode) or info.st_size>1048576: raise ValueError('Unsafe lock store')
|
|
57
|
+
except FileNotFoundError: pass
|
|
58
|
+
os.environ['COS_TASK_LOCK_STORE']=str(lockpath);os.environ['COS_TASK_OPERATOR']=opts.operator
|
|
59
|
+
import task_rows
|
|
60
|
+
task_rows.full_domains=lambda:tuple(domains);task_rows.domain_path=domain_path
|
|
61
|
+
# Canonical aliases should not re-route a custom domain named sr or hc.
|
|
62
|
+
task_rows.SHORT_TO_FULL={k:v for k,v in task_rows.SHORT_TO_FULL.items() if k not in domains}
|
|
63
|
+
import task_write
|
|
64
|
+
task_write.domain_path=domain_path
|
|
65
|
+
original_write=task_write._guarded_write
|
|
66
|
+
def guarded(domain,identity,path,text):
|
|
67
|
+
if len(text.encode())>8*1024*1024: raise ValueError('Task source exceeds supported limit')
|
|
68
|
+
original_write(domain,identity,path,text)
|
|
69
|
+
task_write._guarded_write=guarded
|
|
70
|
+
import task_commands
|
|
71
|
+
if opts.command=='task-work-capabilities': print(json.dumps({'version':1,'protocol':'cos-control-task-write/1'}));return
|
|
72
|
+
if not opts.args or opts.args[0] not in domains: raise ValueError('Unknown task domain')
|
|
73
|
+
path=domain_path(opts.args[0])
|
|
74
|
+
try: safe_read_text(path)
|
|
75
|
+
except FileNotFoundError: pass
|
|
76
|
+
fn=getattr(task_commands,commands[opts.command])
|
|
77
|
+
if opts.command=='task-link-meeting':fn(opts.args,linking=True)
|
|
78
|
+
else:fn(opts.args)
|
|
79
|
+
finally:
|
|
80
|
+
for fd in domainfds.values():os.close(fd)
|
|
81
|
+
if lockfd is not None:os.close(lockfd)
|
|
82
|
+
os.close(rootfd)
|
|
83
|
+
|
|
84
|
+
if __name__=='__main__':
|
|
85
|
+
try: main()
|
|
86
|
+
except Exception:
|
|
87
|
+
print(json.dumps({'error':{'code':'task_runtime_unavailable','message':'Task source or portable runtime is unavailable. Check configuration before retrying.'}}))
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Atomic task / file lock manager. Store: COS/.task_locks.json (v19 §1b.2)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
from runtime_io import open_lock
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import fcntl
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
from dataclasses import asdict, dataclass
|
|
13
|
+
from datetime import datetime, timedelta, timezone
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Callable, Dict, List, Optional
|
|
16
|
+
|
|
17
|
+
SCRIPT_DIR = Path(__file__).parent.resolve()
|
|
18
|
+
DEFAULT_LOCKS_FILE = (
|
|
19
|
+
Path.home() / "Library" / "Application Support" / "COS" / ".task_locks.json"
|
|
20
|
+
)
|
|
21
|
+
LOCKS_FILE = Path(os.environ.get("COS_TASK_LOCK_STORE") or DEFAULT_LOCKS_FILE)
|
|
22
|
+
DEFAULT_TIMEOUT_MINUTES = 30
|
|
23
|
+
CORRUPT_NAME = ".task_locks.json.corrupt"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class LockStoreCorrupt(Exception):
|
|
27
|
+
"""Lock file has no recoverable JSON object prefix."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class CheckoutResult:
|
|
32
|
+
success: bool
|
|
33
|
+
task_id: str
|
|
34
|
+
user: str
|
|
35
|
+
conflict: bool = False
|
|
36
|
+
locked_by: Optional[str] = None
|
|
37
|
+
locked_at: Optional[str] = None
|
|
38
|
+
expires_at: Optional[str] = None
|
|
39
|
+
message: str = ""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class LockEntry:
|
|
44
|
+
task_id: str
|
|
45
|
+
user: str
|
|
46
|
+
locked_at: str
|
|
47
|
+
expires_at: str
|
|
48
|
+
|
|
49
|
+
def is_expired(self) -> bool:
|
|
50
|
+
# Unknown expiry is unknown ownership, never permission to steal a lock.
|
|
51
|
+
try:
|
|
52
|
+
locked = datetime.fromisoformat(self.locked_at)
|
|
53
|
+
expires = datetime.fromisoformat(self.expires_at)
|
|
54
|
+
if locked.tzinfo is None or expires.tzinfo is None or expires < locked:
|
|
55
|
+
raise ValueError("Invalid lock timestamp ordering or timezone")
|
|
56
|
+
return datetime.now(timezone.utc) > expires
|
|
57
|
+
except (ValueError, TypeError) as exc:
|
|
58
|
+
raise LockStoreCorrupt("Lock timestamps are invalid; repair required") from exc
|
|
59
|
+
|
|
60
|
+
def to_dict(self) -> dict:
|
|
61
|
+
return asdict(self)
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def from_dict(cls, d: dict) -> "LockEntry":
|
|
65
|
+
known = {"task_id", "user", "locked_at", "expires_at"}
|
|
66
|
+
if not isinstance(d, dict) or set(d) != known or any(not isinstance(d[k], str) or not d[k].strip() for k in known):
|
|
67
|
+
raise LockStoreCorrupt("Lock entry schema is invalid; repair required")
|
|
68
|
+
return cls(**d)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _entries_from_payload(data: dict) -> Dict[str, LockEntry]:
|
|
72
|
+
if not isinstance(data, dict):
|
|
73
|
+
raise LockStoreCorrupt("Lock store must be an object")
|
|
74
|
+
locks: Dict[str, LockEntry] = {}
|
|
75
|
+
for key, value in data.items():
|
|
76
|
+
entry = LockEntry.from_dict(value)
|
|
77
|
+
if not isinstance(key, str) or not key or entry.task_id != key:
|
|
78
|
+
raise LockStoreCorrupt("Lock key and task identity disagree; repair required")
|
|
79
|
+
if not entry.is_expired():
|
|
80
|
+
locks[key] = entry
|
|
81
|
+
return locks
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _unique_lock_object(pairs):
|
|
85
|
+
result = {}
|
|
86
|
+
for key, value in pairs:
|
|
87
|
+
if key in result:
|
|
88
|
+
raise LockStoreCorrupt("Duplicate lock JSON key; repair required")
|
|
89
|
+
result[key] = value
|
|
90
|
+
return result
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _decode_store(raw: bytes) -> tuple[Optional[dict], bool]:
|
|
94
|
+
"""Return (payload, corrupt). Empty bytes are a fresh store, not corrupt."""
|
|
95
|
+
if not raw:
|
|
96
|
+
return {}, False
|
|
97
|
+
text = raw.decode("utf-8", errors="surrogateescape")
|
|
98
|
+
stripped = text.lstrip()
|
|
99
|
+
if "{" not in stripped:
|
|
100
|
+
return None, True
|
|
101
|
+
try:
|
|
102
|
+
obj, _end = json.JSONDecoder(object_pairs_hook=_unique_lock_object).raw_decode(stripped)
|
|
103
|
+
except json.JSONDecodeError:
|
|
104
|
+
return None, True
|
|
105
|
+
if not isinstance(obj, dict):
|
|
106
|
+
return None, True
|
|
107
|
+
return obj, False
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class TaskCheckoutManager:
|
|
111
|
+
"""Manages atomic task checkouts with file-based locking."""
|
|
112
|
+
|
|
113
|
+
def __init__(
|
|
114
|
+
self,
|
|
115
|
+
locks_file: Path = LOCKS_FILE,
|
|
116
|
+
timeout_minutes: int = DEFAULT_TIMEOUT_MINUTES,
|
|
117
|
+
):
|
|
118
|
+
self.locks_file = Path(locks_file)
|
|
119
|
+
self.timeout_minutes = timeout_minutes
|
|
120
|
+
self._quarantined: set[tuple[int, int]] = set()
|
|
121
|
+
|
|
122
|
+
def _ensure_parent(self) -> None:
|
|
123
|
+
pass # Parent was securely opened by the portable wrapper
|
|
124
|
+
|
|
125
|
+
def _quarantine(self, raw: bytes) -> None:
|
|
126
|
+
dest = self.locks_file.parent / CORRUPT_NAME
|
|
127
|
+
raise LockStoreCorrupt("Portable lock store is corrupt; repair required")
|
|
128
|
+
|
|
129
|
+
def _persist(self, f, locks: Dict[str, LockEntry]) -> None:
|
|
130
|
+
out = {k: v.to_dict() for k, v in locks.items()}
|
|
131
|
+
encoded = json.dumps(out, indent=2).encode("utf-8") + b"\n"
|
|
132
|
+
f.seek(0)
|
|
133
|
+
f.write(encoded)
|
|
134
|
+
f.flush()
|
|
135
|
+
os.fsync(f.fileno())
|
|
136
|
+
f.truncate(len(encoded))
|
|
137
|
+
|
|
138
|
+
def _with_file_lock(
|
|
139
|
+
self,
|
|
140
|
+
fn: Callable,
|
|
141
|
+
*,
|
|
142
|
+
fail_closed: bool = False,
|
|
143
|
+
readonly: bool = False,
|
|
144
|
+
):
|
|
145
|
+
"""Same-fd flock. See plan v19 §1b.2.
|
|
146
|
+
|
|
147
|
+
Write path creates the store. Readonly never creates. Empty size is
|
|
148
|
+
a fresh map. A torn UTF-8 tail with a dict prefix is kept; only a
|
|
149
|
+
missing dict prefix quarantines.
|
|
150
|
+
"""
|
|
151
|
+
if not readonly:
|
|
152
|
+
self._ensure_parent()
|
|
153
|
+
|
|
154
|
+
if readonly:
|
|
155
|
+
try:
|
|
156
|
+
fd = open_lock(self.locks_file, os.O_RDONLY)
|
|
157
|
+
except FileNotFoundError:
|
|
158
|
+
return fn({})
|
|
159
|
+
else:
|
|
160
|
+
fd = open_lock(self.locks_file, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW | os.O_NONBLOCK, 0o600)
|
|
161
|
+
|
|
162
|
+
f = os.fdopen(fd, "rb+")
|
|
163
|
+
try:
|
|
164
|
+
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
|
165
|
+
raw = f.read(1_048_577)
|
|
166
|
+
if len(raw) > 1_048_576: raise LockStoreCorrupt("Lock store exceeds supported size")
|
|
167
|
+
payload, corrupt = _decode_store(raw)
|
|
168
|
+
if corrupt:
|
|
169
|
+
stamp = (0, 0)
|
|
170
|
+
try:
|
|
171
|
+
st = os.fstat(f.fileno())
|
|
172
|
+
stamp = (st.st_mtime_ns, st.st_size)
|
|
173
|
+
except OSError:
|
|
174
|
+
pass
|
|
175
|
+
if stamp not in self._quarantined:
|
|
176
|
+
self._quarantine(raw)
|
|
177
|
+
self._quarantined.add(stamp)
|
|
178
|
+
locks = {}
|
|
179
|
+
if fail_closed or not readonly:
|
|
180
|
+
# Never clear unknown ownership as a side effect of admission.
|
|
181
|
+
# Otherwise the next retry steals every lock in the corrupt store.
|
|
182
|
+
raise LockStoreCorrupt(str(self.locks_file))
|
|
183
|
+
else:
|
|
184
|
+
locks = _entries_from_payload(payload or {})
|
|
185
|
+
|
|
186
|
+
result = fn(locks)
|
|
187
|
+
|
|
188
|
+
if not readonly:
|
|
189
|
+
self._persist(f, locks)
|
|
190
|
+
return result
|
|
191
|
+
finally:
|
|
192
|
+
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
|
193
|
+
f.close()
|
|
194
|
+
|
|
195
|
+
def _purge_expired(self, locks: Dict[str, LockEntry]) -> int:
|
|
196
|
+
expired = [k for k, v in locks.items() if v.is_expired()]
|
|
197
|
+
for key in expired:
|
|
198
|
+
del locks[key]
|
|
199
|
+
return len(expired)
|
|
200
|
+
|
|
201
|
+
def _ttl(self, timeout_minutes: Optional[int]) -> int:
|
|
202
|
+
return self.timeout_minutes if timeout_minutes is None else timeout_minutes
|
|
203
|
+
|
|
204
|
+
def checkout(
|
|
205
|
+
self,
|
|
206
|
+
task_id: str,
|
|
207
|
+
user: str,
|
|
208
|
+
*,
|
|
209
|
+
exclusive: bool = False,
|
|
210
|
+
timeout_minutes: Optional[int] = None,
|
|
211
|
+
) -> CheckoutResult:
|
|
212
|
+
ttl = self._ttl(timeout_minutes)
|
|
213
|
+
|
|
214
|
+
def _do_checkout(locks: Dict[str, LockEntry]) -> CheckoutResult:
|
|
215
|
+
self._purge_expired(locks)
|
|
216
|
+
|
|
217
|
+
if task_id in locks:
|
|
218
|
+
existing = locks[task_id]
|
|
219
|
+
if existing.user == user and not exclusive:
|
|
220
|
+
now = datetime.now(timezone.utc)
|
|
221
|
+
existing.locked_at = now.isoformat()
|
|
222
|
+
existing.expires_at = (now + timedelta(minutes=ttl)).isoformat()
|
|
223
|
+
return CheckoutResult(
|
|
224
|
+
success=True, task_id=task_id, user=user,
|
|
225
|
+
message=f"Lock extended for {user}",
|
|
226
|
+
)
|
|
227
|
+
return CheckoutResult(
|
|
228
|
+
success=False, task_id=task_id, user=user,
|
|
229
|
+
conflict=True,
|
|
230
|
+
locked_by=existing.user,
|
|
231
|
+
locked_at=existing.locked_at,
|
|
232
|
+
expires_at=existing.expires_at,
|
|
233
|
+
message=f"Task {task_id} is checked out by {existing.user}",
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
now = datetime.now(timezone.utc)
|
|
237
|
+
locks[task_id] = LockEntry(
|
|
238
|
+
task_id=task_id,
|
|
239
|
+
user=user,
|
|
240
|
+
locked_at=now.isoformat(),
|
|
241
|
+
expires_at=(now + timedelta(minutes=ttl)).isoformat(),
|
|
242
|
+
)
|
|
243
|
+
return CheckoutResult(
|
|
244
|
+
success=True, task_id=task_id, user=user,
|
|
245
|
+
message=f"Checked out {task_id} for {user}",
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
return self._with_file_lock(
|
|
249
|
+
_do_checkout, fail_closed=task_id.startswith("file:")
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
def release(self, task_id: str, user: str) -> CheckoutResult:
|
|
253
|
+
def _do_release(locks: Dict[str, LockEntry]) -> CheckoutResult:
|
|
254
|
+
self._purge_expired(locks)
|
|
255
|
+
if task_id not in locks:
|
|
256
|
+
return CheckoutResult(
|
|
257
|
+
success=True, task_id=task_id, user=user,
|
|
258
|
+
message=f"Task {task_id} was not locked",
|
|
259
|
+
)
|
|
260
|
+
existing = locks[task_id]
|
|
261
|
+
if existing.user != user:
|
|
262
|
+
return CheckoutResult(
|
|
263
|
+
success=False, task_id=task_id, user=user,
|
|
264
|
+
conflict=True, locked_by=existing.user,
|
|
265
|
+
message=f"Cannot release — locked by {existing.user}, not {user}",
|
|
266
|
+
)
|
|
267
|
+
del locks[task_id]
|
|
268
|
+
return CheckoutResult(
|
|
269
|
+
success=True, task_id=task_id, user=user,
|
|
270
|
+
message=f"Released {task_id}",
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
return self._with_file_lock(
|
|
274
|
+
_do_release, fail_closed=task_id.startswith("file:")
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
def force_release(self, task_id: str) -> CheckoutResult:
|
|
278
|
+
def _do_force(locks: Dict[str, LockEntry]) -> CheckoutResult:
|
|
279
|
+
self._purge_expired(locks)
|
|
280
|
+
if task_id in locks:
|
|
281
|
+
old_user = locks[task_id].user
|
|
282
|
+
del locks[task_id]
|
|
283
|
+
return CheckoutResult(
|
|
284
|
+
success=True, task_id=task_id, user="admin",
|
|
285
|
+
message=f"Force-released {task_id} (was held by {old_user})",
|
|
286
|
+
)
|
|
287
|
+
return CheckoutResult(
|
|
288
|
+
success=True, task_id=task_id, user="admin",
|
|
289
|
+
message=f"Task {task_id} was not locked",
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
return self._with_file_lock(_do_force)
|
|
293
|
+
|
|
294
|
+
def status(self) -> List[LockEntry]:
|
|
295
|
+
def _do_status(locks: Dict[str, LockEntry]):
|
|
296
|
+
self._purge_expired(locks)
|
|
297
|
+
return list(locks.values())
|
|
298
|
+
|
|
299
|
+
return self._with_file_lock(_do_status, readonly=True)
|
|
300
|
+
|
|
301
|
+
def is_locked(self, task_id: str) -> Optional[LockEntry]:
|
|
302
|
+
def _do_check(locks: Dict[str, LockEntry]):
|
|
303
|
+
self._purge_expired(locks)
|
|
304
|
+
return locks.get(task_id)
|
|
305
|
+
|
|
306
|
+
return self._with_file_lock(_do_check, readonly=True)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def doctor(locks_file: Path, *, confirm: bool) -> int:
|
|
310
|
+
corrupt = locks_file.parent / CORRUPT_NAME
|
|
311
|
+
print(f"store: {locks_file}")
|
|
312
|
+
print(f"corrupt: {corrupt}")
|
|
313
|
+
if not confirm:
|
|
314
|
+
print("pass --confirm to recreate an empty store")
|
|
315
|
+
return 0
|
|
316
|
+
locks_file.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
317
|
+
locks_file.write_text("{}\n")
|
|
318
|
+
print("rewrote empty store")
|
|
319
|
+
return 0
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def main():
|
|
323
|
+
parser = argparse.ArgumentParser(description="Task Checkout Manager")
|
|
324
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
325
|
+
|
|
326
|
+
co = subparsers.add_parser("checkout", help="Check out a task")
|
|
327
|
+
co.add_argument("task_id")
|
|
328
|
+
co.add_argument("user")
|
|
329
|
+
|
|
330
|
+
rel = subparsers.add_parser("release", help="Release a checkout")
|
|
331
|
+
rel.add_argument("task_id")
|
|
332
|
+
rel.add_argument("user")
|
|
333
|
+
|
|
334
|
+
fr = subparsers.add_parser("force-release", help="Force release (admin)")
|
|
335
|
+
fr.add_argument("task_id")
|
|
336
|
+
|
|
337
|
+
subparsers.add_parser("status", help="Show all active locks")
|
|
338
|
+
|
|
339
|
+
doc = subparsers.add_parser("doctor", help="Inspect / recreate the lock store")
|
|
340
|
+
doc.add_argument("--confirm", action="store_true")
|
|
341
|
+
|
|
342
|
+
args = parser.parse_args()
|
|
343
|
+
mgr = TaskCheckoutManager()
|
|
344
|
+
|
|
345
|
+
if args.command == "checkout":
|
|
346
|
+
result = mgr.checkout(args.task_id, args.user)
|
|
347
|
+
if result.conflict:
|
|
348
|
+
print(f"CONFLICT: {result.message}")
|
|
349
|
+
print(f" Locked by: {result.locked_by}")
|
|
350
|
+
print(f" Since: {result.locked_at}")
|
|
351
|
+
print(f" Expires: {result.expires_at}")
|
|
352
|
+
sys.exit(1)
|
|
353
|
+
print(f"OK: {result.message}")
|
|
354
|
+
elif args.command == "release":
|
|
355
|
+
result = mgr.release(args.task_id, args.user)
|
|
356
|
+
if result.success:
|
|
357
|
+
print(f"OK: {result.message}")
|
|
358
|
+
else:
|
|
359
|
+
print(f"DENIED: {result.message}")
|
|
360
|
+
sys.exit(1)
|
|
361
|
+
elif args.command == "force-release":
|
|
362
|
+
print(f"OK: {mgr.force_release(args.task_id).message}")
|
|
363
|
+
elif args.command == "status":
|
|
364
|
+
locks = mgr.status()
|
|
365
|
+
if not locks:
|
|
366
|
+
print("No active locks.")
|
|
367
|
+
return
|
|
368
|
+
print(f"\n{'Task ID':<20} {'User':<15} {'Locked At':<25} {'Expires At'}")
|
|
369
|
+
print("-" * 80)
|
|
370
|
+
for lock in locks:
|
|
371
|
+
print(f"{lock.task_id:<20} {lock.user:<15} {lock.locked_at:<25} {lock.expires_at}")
|
|
372
|
+
print()
|
|
373
|
+
elif args.command == "doctor":
|
|
374
|
+
sys.exit(doctor(LOCKS_FILE, confirm=args.confirm))
|
|
375
|
+
else:
|
|
376
|
+
parser.print_help()
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
if __name__ == "__main__":
|
|
380
|
+
main()
|