@gotcos/glasses-server 6.55.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 +22 -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/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,645 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Canonical tasks.md writers. One file_lock, one _guarded_write (v19 §1b)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
from runtime_io import safe_read_text
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import signal
|
|
10
|
+
import time
|
|
11
|
+
from contextlib import contextmanager
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Iterator, Optional
|
|
15
|
+
from uuid import uuid4
|
|
16
|
+
|
|
17
|
+
from task_work_metadata import WorkMetadataError, decode_source, write_source, reject_reserved, validate_meeting, phase_for, PHASES, LEGACY_STAGE, DEFAULT_PHASE
|
|
18
|
+
from cos_atomic import atomic_write_text
|
|
19
|
+
from task_checkout import LockStoreCorrupt, TaskCheckoutManager
|
|
20
|
+
from task_rows import (
|
|
21
|
+
full_domains,
|
|
22
|
+
MARKER_ANYWHERE_RE,
|
|
23
|
+
SHORT_TO_FULL,
|
|
24
|
+
classify_heading,
|
|
25
|
+
domain_path,
|
|
26
|
+
parse_path,
|
|
27
|
+
split_source,
|
|
28
|
+
split_done_when,
|
|
29
|
+
STAGES,
|
|
30
|
+
strip_markers,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
TASK_LOCK_TTL_MINUTES = 1
|
|
34
|
+
TASK_LOCK_WRITE_BUDGET_S = 5
|
|
35
|
+
_STALE_TMP_S = 5 * 60
|
|
36
|
+
_CHECKOUT = TaskCheckoutManager()
|
|
37
|
+
_first_lock = False
|
|
38
|
+
_DAY_RE = re.compile(r"^today:(\d{4}-\d{2}-\d{2})$")
|
|
39
|
+
_TODAY_HEADING = re.compile(r"^#{1,3}\s*TODAY\s+(\d{4}-\d{2}-\d{2})\s*$")
|
|
40
|
+
SECTIONS = ("inbox", "today", "urgent", "this_week", "backlog")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class LockLost(Exception):
|
|
44
|
+
"""TTL remaining is below the write budget, or identity mismatch."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class TaskFileLocked(Exception):
|
|
48
|
+
"""file:<domain> is held; fail fast, no wait."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class TaskRunning(Exception):
|
|
52
|
+
"""Row shows [agent running]; refuse re-arm."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _arm_sigterm() -> None:
|
|
56
|
+
signal.signal(signal.SIGTERM, lambda *_: os._exit(1))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _sweep_stale_tmps(directory: Path) -> None:
|
|
60
|
+
now = time.time()
|
|
61
|
+
for tmp in directory.glob("*.tmp.*"):
|
|
62
|
+
try:
|
|
63
|
+
if now - tmp.stat().st_mtime > _STALE_TMP_S:
|
|
64
|
+
tmp.unlink()
|
|
65
|
+
except OSError:
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _is_safe_domain(name: str) -> bool:
|
|
70
|
+
"""Safe as a path component. A SAFETY check, not a naming policy."""
|
|
71
|
+
if not name or len(name) > 64 or name != name.strip():
|
|
72
|
+
return False
|
|
73
|
+
if name in (".", "..") or name.startswith("."):
|
|
74
|
+
return False
|
|
75
|
+
return not any(c in name for c in ("/", "\\", "\0")) and not any(ord(c) < 32 or ord(c) == 127 for c in name)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _full_domain(domain: str) -> str:
|
|
79
|
+
full = SHORT_TO_FULL.get(domain, domain)
|
|
80
|
+
# Writes must not require the domain to already exist: creating the first
|
|
81
|
+
# task in a new domain is exactly how its tasks.md comes into being, so
|
|
82
|
+
# gating on full_domains() here made a new domain uncreatable. Safety is
|
|
83
|
+
# still enforced, because this name becomes a path component.
|
|
84
|
+
if not _is_safe_domain(full):
|
|
85
|
+
raise ValueError(f"invalid domain: {domain}")
|
|
86
|
+
return full
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def domain_from_path(path: Path) -> str:
|
|
90
|
+
return _full_domain(path.parent.name)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@contextmanager
|
|
94
|
+
def file_lock(domain: str) -> Iterator[str]:
|
|
95
|
+
"""Exclusive file:<domain> lock. Fail fast → TaskFileLocked. No nesting."""
|
|
96
|
+
global _first_lock
|
|
97
|
+
full = _full_domain(domain)
|
|
98
|
+
if not _first_lock:
|
|
99
|
+
_arm_sigterm()
|
|
100
|
+
_sweep_stale_tmps(domain_path(full).parent)
|
|
101
|
+
_first_lock = True
|
|
102
|
+
identity = f"python:{os.getpid()}:{uuid4()}"
|
|
103
|
+
result = _CHECKOUT.checkout(
|
|
104
|
+
f"file:{full}",
|
|
105
|
+
identity,
|
|
106
|
+
exclusive=True,
|
|
107
|
+
timeout_minutes=TASK_LOCK_TTL_MINUTES,
|
|
108
|
+
)
|
|
109
|
+
if result.conflict:
|
|
110
|
+
raise TaskFileLocked(result.message)
|
|
111
|
+
try:
|
|
112
|
+
yield result.user
|
|
113
|
+
finally:
|
|
114
|
+
_CHECKOUT.release(f"file:{full}", identity)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _guarded_write(domain: str, identity: str, path: Path, text: str) -> None:
|
|
118
|
+
entry = _CHECKOUT.is_locked(f"file:{_full_domain(domain)}")
|
|
119
|
+
if entry is None or entry.user != identity:
|
|
120
|
+
raise LockLost("lock identity mismatch")
|
|
121
|
+
expires = datetime.fromisoformat(entry.expires_at)
|
|
122
|
+
remaining = (expires - datetime.now(timezone.utc)).total_seconds()
|
|
123
|
+
if remaining < TASK_LOCK_WRITE_BUDGET_S:
|
|
124
|
+
raise LockLost(f"write budget exhausted ({remaining:.1f}s left)")
|
|
125
|
+
atomic_write_text(path, text, encoding="utf-8", durable=True)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def locked_replace(path: Path, text: str) -> None:
|
|
129
|
+
"""Run-boundary helper for legacy writers."""
|
|
130
|
+
domain = domain_from_path(path)
|
|
131
|
+
with file_lock(domain) as identity:
|
|
132
|
+
_guarded_write(domain, identity, path, text)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _parse_section(token: str) -> tuple[str, Optional[str]]:
|
|
136
|
+
today = _DAY_RE.match(token)
|
|
137
|
+
if today:
|
|
138
|
+
return "today", today.group(1)
|
|
139
|
+
if token in SECTIONS and token != "today":
|
|
140
|
+
return token, None
|
|
141
|
+
raise ValueError(f"invalid section: {token}")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _heading_depth(line: str) -> Optional[int]:
|
|
145
|
+
stripped = line.lstrip()
|
|
146
|
+
if not stripped.startswith("#"):
|
|
147
|
+
return None
|
|
148
|
+
return len(stripped) - len(stripped.lstrip("#"))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _section_end(lines: list[str], start: int, depth: int) -> int:
|
|
152
|
+
idx = start + 1
|
|
153
|
+
while idx < len(lines):
|
|
154
|
+
stripped = lines[idx].strip()
|
|
155
|
+
if stripped.startswith("---"):
|
|
156
|
+
return idx
|
|
157
|
+
other = _heading_depth(lines[idx])
|
|
158
|
+
if other is not None and other <= depth:
|
|
159
|
+
return idx
|
|
160
|
+
idx += 1
|
|
161
|
+
return len(lines)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _find_heading(lines: list[str], want: str, day: Optional[str] = None) -> Optional[int]:
|
|
165
|
+
for idx, line in enumerate(lines):
|
|
166
|
+
kind, heading_day = classify_heading(line.strip())
|
|
167
|
+
if kind != want:
|
|
168
|
+
continue
|
|
169
|
+
if want == "today" and day and heading_day != day:
|
|
170
|
+
continue
|
|
171
|
+
return idx
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _insert_heading(lines: list[str], idx: int, heading: str) -> int:
|
|
176
|
+
block = ["", heading, ""]
|
|
177
|
+
lines[idx:idx] = block
|
|
178
|
+
return idx + 1
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _locked_ensure_section(
|
|
182
|
+
lines: list[str], token: str, *, purge_before: Optional[str] = None
|
|
183
|
+
) -> tuple[list[str], int, bool]:
|
|
184
|
+
"""Return (lines, heading_index, fell_to_inbox)."""
|
|
185
|
+
kind, day = _parse_section(token)
|
|
186
|
+
if purge_before:
|
|
187
|
+
lines = _purge_empty_today(lines, purge_before)
|
|
188
|
+
|
|
189
|
+
if kind == "inbox":
|
|
190
|
+
found = _find_heading(lines, "inbox")
|
|
191
|
+
if found is not None:
|
|
192
|
+
return lines, found, False
|
|
193
|
+
first_h2 = next(
|
|
194
|
+
(i for i, line in enumerate(lines) if line.startswith("## ")),
|
|
195
|
+
len(lines),
|
|
196
|
+
)
|
|
197
|
+
return lines, _insert_heading(lines, first_h2, "## INBOX"), False
|
|
198
|
+
|
|
199
|
+
if kind == "today":
|
|
200
|
+
assert day
|
|
201
|
+
found = _find_heading(lines, "today", day)
|
|
202
|
+
if found is not None:
|
|
203
|
+
return lines, found, False
|
|
204
|
+
lines, inbox_idx, _ = _locked_ensure_section(lines, "inbox")
|
|
205
|
+
search_from = inbox_idx + 1
|
|
206
|
+
insert_at = len(lines)
|
|
207
|
+
for idx in range(search_from, len(lines)):
|
|
208
|
+
stripped = lines[idx].strip()
|
|
209
|
+
if stripped.startswith("## ") or stripped.startswith("---"):
|
|
210
|
+
insert_at = idx
|
|
211
|
+
break
|
|
212
|
+
return lines, _insert_heading(lines, insert_at, f"## TODAY {day}"), False
|
|
213
|
+
|
|
214
|
+
found = _find_heading(lines, kind)
|
|
215
|
+
if found is not None:
|
|
216
|
+
return lines, found, False
|
|
217
|
+
lines, inbox_idx, _ = _locked_ensure_section(lines, "inbox")
|
|
218
|
+
return lines, inbox_idx, True
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _purge_empty_today(lines: list[str], purge_before: str) -> list[str]:
|
|
222
|
+
keep: list[str] = []
|
|
223
|
+
idx = 0
|
|
224
|
+
while idx < len(lines):
|
|
225
|
+
match = _TODAY_HEADING.match(lines[idx].strip())
|
|
226
|
+
if match and match.group(1) < purge_before:
|
|
227
|
+
depth = _heading_depth(lines[idx]) or 2
|
|
228
|
+
end = _section_end(lines, idx, depth)
|
|
229
|
+
body = [line for line in lines[idx + 1:end] if line.strip() and not line.strip().startswith("---")]
|
|
230
|
+
has_task = any(line.startswith("- [") for line in lines[idx + 1:end])
|
|
231
|
+
if not has_task and not any(classify_heading(b.strip())[0] for b in body):
|
|
232
|
+
idx = end
|
|
233
|
+
continue
|
|
234
|
+
keep.append(lines[idx])
|
|
235
|
+
idx += 1
|
|
236
|
+
return keep
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _top_insert(lines: list[str], heading_idx: int, task_line: str) -> None:
|
|
240
|
+
depth = _heading_depth(lines[heading_idx]) or 2
|
|
241
|
+
insert_at = heading_idx + 1
|
|
242
|
+
while insert_at < len(lines) and not lines[insert_at].strip():
|
|
243
|
+
insert_at += 1
|
|
244
|
+
end = _section_end(lines, heading_idx, depth)
|
|
245
|
+
insert_at = min(insert_at, end)
|
|
246
|
+
lines.insert(insert_at, task_line)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _rebuild_line(description: str, source: Optional[str], run_at, agent_state, agent_no, checked: bool, archived: bool, stage=None, done_when=None) -> str:
|
|
250
|
+
mark = "~" if archived else ("x" if checked else " ")
|
|
251
|
+
body = description
|
|
252
|
+
if source:
|
|
253
|
+
body = f"{body} — **Source:** {source}"
|
|
254
|
+
if done_when:
|
|
255
|
+
body = f"{body} — **Done when:** {done_when}"
|
|
256
|
+
parts = []
|
|
257
|
+
if run_at:
|
|
258
|
+
parts.append(f"[run {run_at}]")
|
|
259
|
+
if agent_state == "running":
|
|
260
|
+
parts.append("[agent running]")
|
|
261
|
+
elif agent_state == "done" and agent_no is not None:
|
|
262
|
+
parts.append(f"[agent #{agent_no} done]")
|
|
263
|
+
elif agent_state == "failed":
|
|
264
|
+
if agent_no is not None:
|
|
265
|
+
parts.append(f"[agent #{agent_no} failed]")
|
|
266
|
+
else:
|
|
267
|
+
parts.append("[agent failed]")
|
|
268
|
+
if stage:
|
|
269
|
+
parts.append(f"[stage {stage}]")
|
|
270
|
+
if parts:
|
|
271
|
+
body = f"{body} {' '.join(parts)}"
|
|
272
|
+
return f"- [{mark}] {body}"
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _match_row(row, task_id: str, domain: str) -> bool:
|
|
276
|
+
if row.id == task_id or row.ref == task_id:
|
|
277
|
+
return True
|
|
278
|
+
short = {"sprocket_rocket": "sr", "hermit_crabs": "hc"}.get(domain, domain)
|
|
279
|
+
ordinal = row.ref.rsplit("-", 1)[-1]
|
|
280
|
+
return task_id == f"{short}-{ordinal}"
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _rewrite_row(path: Path, domain: str, task_id: str, mutator):
|
|
284
|
+
rows = parse_path(path, domain)
|
|
285
|
+
target = next((row for row in rows if _match_row(row, task_id, domain)), None)
|
|
286
|
+
if target is None:
|
|
287
|
+
return False
|
|
288
|
+
lines = safe_read_text(path).splitlines()
|
|
289
|
+
idx = target.line_number - 1
|
|
290
|
+
raw = lines[idx]
|
|
291
|
+
prefix = raw[: raw.index("- [")] if "- [" in raw else ""
|
|
292
|
+
desc, source = split_source(raw.split("]", 1)[-1].lstrip())
|
|
293
|
+
desc, markers = strip_markers(desc)
|
|
294
|
+
if source:
|
|
295
|
+
source, src_m = strip_markers(source)
|
|
296
|
+
if src_m.get("run_at"):
|
|
297
|
+
markers["run_at"] = src_m["run_at"]
|
|
298
|
+
if src_m.get("agent_state"):
|
|
299
|
+
markers["agent_state"] = src_m["agent_state"]
|
|
300
|
+
markers["agent_no"] = src_m.get("agent_no")
|
|
301
|
+
desc, dw_desc = split_done_when(desc)
|
|
302
|
+
if source:
|
|
303
|
+
source, dw_src = split_done_when(source)
|
|
304
|
+
else:
|
|
305
|
+
dw_src = None
|
|
306
|
+
state = {
|
|
307
|
+
"description": desc,
|
|
308
|
+
"source": source,
|
|
309
|
+
"stage": markers.get("stage") or target.stage,
|
|
310
|
+
"done_when": dw_desc or dw_src or target.done_when,
|
|
311
|
+
"run_at": markers.get("run_at") or target.run_at,
|
|
312
|
+
"agent_state": markers.get("agent_state") or target.agent_state,
|
|
313
|
+
"agent_no": markers.get("agent_no") if markers.get("agent_no") is not None else target.agent_no,
|
|
314
|
+
"checked": target.is_checked,
|
|
315
|
+
"archived": target.archived,
|
|
316
|
+
}
|
|
317
|
+
mutator(state)
|
|
318
|
+
lines[idx] = prefix + _rebuild_line(**state)
|
|
319
|
+
return True, "\n".join(lines) + "\n"
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def capture(domain: str, text: str, *, section: str, run_at=None, purge_before=None) -> dict:
|
|
323
|
+
reject_marker_text(text, "task text")
|
|
324
|
+
full = _full_domain(domain)
|
|
325
|
+
path = domain_path(full)
|
|
326
|
+
with file_lock(full) as identity:
|
|
327
|
+
return _locked_capture(
|
|
328
|
+
full, path, identity, text, section=section,
|
|
329
|
+
run_at=run_at, purge_before=purge_before,
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _locked_capture(domain, path, identity, text, *, section, run_at, purge_before) -> dict:
|
|
334
|
+
lines = safe_read_text(path).splitlines() if path.exists() else []
|
|
335
|
+
lines, heading_idx, fell = _locked_ensure_section(
|
|
336
|
+
lines, section, purge_before=purge_before
|
|
337
|
+
)
|
|
338
|
+
stamp = datetime.now().strftime("%Y-%m-%d")
|
|
339
|
+
line = _rebuild_line(text, f"Manual entry {stamp}", run_at, None, None, False, False)
|
|
340
|
+
_top_insert(lines, heading_idx, line)
|
|
341
|
+
_guarded_write(domain, identity, path, "\n".join(lines) + "\n")
|
|
342
|
+
landed = "inbox" if fell else section
|
|
343
|
+
return {"ok": True, "fell_to_inbox": fell, "section": landed}
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def ensure_section(domain: str, token: str, *, purge_before=None) -> None:
|
|
347
|
+
full = _full_domain(domain)
|
|
348
|
+
path = domain_path(full)
|
|
349
|
+
with file_lock(full) as identity:
|
|
350
|
+
lines = safe_read_text(path).splitlines() if path.exists() else []
|
|
351
|
+
lines, _, _ = _locked_ensure_section(lines, token, purge_before=purge_before)
|
|
352
|
+
_guarded_write(full, identity, path, "\n".join(lines) + "\n")
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def reject_marker_text(value: str, field: str) -> None:
|
|
356
|
+
"""User text may never contain a marker. This is the only gate.
|
|
357
|
+
|
|
358
|
+
_rebuild_line appends the marker blob AFTER the description and the
|
|
359
|
+
**Done when:** segment, so any marker inside user text becomes part of the
|
|
360
|
+
line's trailing syntax and is claimed by MARKER_RE on the next read. The
|
|
361
|
+
typed words vanish and the row changes state silently.
|
|
362
|
+
|
|
363
|
+
Reproduced before this guard existed: set_done_when(id, "[run 2026-12-25
|
|
364
|
+
09:00]") cleared a live finish line AND armed a scheduled run, on the field
|
|
365
|
+
labelled "What does finished look like?". set_text(id, "... [stage
|
|
366
|
+
planning]") moved a row out of ACTIVE and dropped the words.
|
|
367
|
+
|
|
368
|
+
Both writers pass through here because it is the one layer every client
|
|
369
|
+
(phone, lens, COS Control, CLI) reaches.
|
|
370
|
+
"""
|
|
371
|
+
reject_reserved(value)
|
|
372
|
+
if "**source:**" in value.lower():
|
|
373
|
+
raise WorkMetadataError("reserved_source_delimiter", "Source fields may only be written through source-link controls.")
|
|
374
|
+
if "**done when:**" in value.lower():
|
|
375
|
+
raise WorkMetadataError("reserved_done_when_delimiter", "Done when fields may only be written through finish-line controls.")
|
|
376
|
+
hit = MARKER_ANYWHERE_RE.search(value)
|
|
377
|
+
if hit:
|
|
378
|
+
raise ValueError(
|
|
379
|
+
f"{field} may not contain {hit.group(0)}: square-bracket run, agent "
|
|
380
|
+
"and stage markers are reserved for the task line itself"
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def set_run_at(domain: str, task_id: str, run_at: Optional[str]) -> bool:
|
|
385
|
+
full = _full_domain(domain)
|
|
386
|
+
path = domain_path(full)
|
|
387
|
+
with file_lock(full) as identity:
|
|
388
|
+
rows = parse_path(path, full) if path.exists() else []
|
|
389
|
+
target = next((row for row in rows if _match_row(row, task_id, full)), None)
|
|
390
|
+
if target is None:
|
|
391
|
+
return False
|
|
392
|
+
if target.agent_state == "running":
|
|
393
|
+
raise TaskRunning(f"task {task_id} is already running")
|
|
394
|
+
result = _rewrite_row(path, full, task_id, lambda s: s.update(run_at=run_at))
|
|
395
|
+
if result is False:
|
|
396
|
+
return False
|
|
397
|
+
_, text = result
|
|
398
|
+
_guarded_write(full, identity, path, text)
|
|
399
|
+
return True
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def set_text(domain: str, task_id: str, text: str) -> bool:
|
|
403
|
+
"""Rewrite a task's description, preserving its source block and markers.
|
|
404
|
+
|
|
405
|
+
Only the description changes. The `**Source:**` block, the `[run ...]`
|
|
406
|
+
schedule and any agent marker are rebuilt from the parsed state, so editing
|
|
407
|
+
the words of a task never silently drops its provenance or its schedule.
|
|
408
|
+
"""
|
|
409
|
+
clean = " ".join(text.split()).strip()
|
|
410
|
+
if not clean:
|
|
411
|
+
raise ValueError("task text required")
|
|
412
|
+
if "\n" in text or "\r" in text:
|
|
413
|
+
raise ValueError("task text must be a single line")
|
|
414
|
+
reject_marker_text(clean, "task text")
|
|
415
|
+
full = _full_domain(domain)
|
|
416
|
+
path = domain_path(full)
|
|
417
|
+
with file_lock(full) as identity:
|
|
418
|
+
rows = parse_path(path, full) if path.exists() else []
|
|
419
|
+
target = next((row for row in rows if _match_row(row, task_id, full)), None)
|
|
420
|
+
if target is None:
|
|
421
|
+
return False
|
|
422
|
+
# A running agent owns the line; rewriting it underneath would change the
|
|
423
|
+
# task the agent was dispatched against.
|
|
424
|
+
if target.agent_state == "running":
|
|
425
|
+
raise TaskRunning(f"task {task_id} is already running")
|
|
426
|
+
if target.work_metadata_error:
|
|
427
|
+
raise WorkMetadataError("work_metadata_invalid", target.work_metadata_error)
|
|
428
|
+
def rename(state):
|
|
429
|
+
_, metadata, error = decode_source(state["source"])
|
|
430
|
+
if error:
|
|
431
|
+
raise WorkMetadataError("work_metadata_invalid", error)
|
|
432
|
+
metadata.setdefault("workIdentity", target.work_identity or target.id)
|
|
433
|
+
state.update(description=clean, source=write_source(state["source"], metadata))
|
|
434
|
+
result = _rewrite_row(path, full, task_id, rename)
|
|
435
|
+
if result is False:
|
|
436
|
+
return False
|
|
437
|
+
_, rebuilt = result
|
|
438
|
+
_guarded_write(full, identity, path, rebuilt)
|
|
439
|
+
return True
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def set_marker(domain: str, task_id: str, marker: Optional[str]) -> bool:
|
|
443
|
+
def mutate(state):
|
|
444
|
+
if marker is None or marker == "--clear":
|
|
445
|
+
state["agent_state"] = None
|
|
446
|
+
state["agent_no"] = None
|
|
447
|
+
return
|
|
448
|
+
if marker == "running":
|
|
449
|
+
state["agent_state"] = "running"
|
|
450
|
+
state["agent_no"] = None
|
|
451
|
+
return
|
|
452
|
+
if marker == "failed":
|
|
453
|
+
state["agent_state"] = "failed"
|
|
454
|
+
state["agent_no"] = None
|
|
455
|
+
return
|
|
456
|
+
kind, _, number = marker.partition(":")
|
|
457
|
+
state["agent_state"] = kind
|
|
458
|
+
state["agent_no"] = int(number) if number else None
|
|
459
|
+
|
|
460
|
+
full = _full_domain(domain)
|
|
461
|
+
path = domain_path(full)
|
|
462
|
+
with file_lock(full) as identity:
|
|
463
|
+
result = _rewrite_row(path, full, task_id, mutate)
|
|
464
|
+
if result is False:
|
|
465
|
+
return False
|
|
466
|
+
_, text = result
|
|
467
|
+
_guarded_write(full, identity, path, text)
|
|
468
|
+
return True
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def set_stage(domain: str, task_id: str, stage: Optional[str]) -> bool:
|
|
472
|
+
"""Write the workflow stage. None or --clear returns the row to planning,
|
|
473
|
+
which is the unmarked default, so clearing removes the marker entirely."""
|
|
474
|
+
if stage in (None, "", "--clear", "planning"):
|
|
475
|
+
wanted = None
|
|
476
|
+
elif stage in STAGES:
|
|
477
|
+
wanted = stage
|
|
478
|
+
else:
|
|
479
|
+
raise ValueError(f"invalid stage: {stage}")
|
|
480
|
+
|
|
481
|
+
def mutate(state):
|
|
482
|
+
_, metadata, error = decode_source(state["source"])
|
|
483
|
+
if error:
|
|
484
|
+
raise WorkMetadataError("work_metadata_invalid", error)
|
|
485
|
+
if metadata:
|
|
486
|
+
metadata["workflowPhase"] = DEFAULT_PHASE[wanted or 'planning']
|
|
487
|
+
state["source"] = write_source(state["source"], metadata)
|
|
488
|
+
state["stage"] = wanted
|
|
489
|
+
|
|
490
|
+
full = _full_domain(domain)
|
|
491
|
+
path = domain_path(full)
|
|
492
|
+
with file_lock(full) as identity:
|
|
493
|
+
result = _rewrite_row(path, full, task_id, mutate)
|
|
494
|
+
if result is False:
|
|
495
|
+
return False
|
|
496
|
+
_, text = result
|
|
497
|
+
_guarded_write(full, identity, path, text)
|
|
498
|
+
return True
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def set_done_when(domain: str, task_id: str, text: Optional[str]) -> bool:
|
|
502
|
+
"""Set or clear the finish line. A run is refused until this is non-empty."""
|
|
503
|
+
wanted = (text or "").strip() or None
|
|
504
|
+
if wanted and ("\n" in wanted or "**" in wanted):
|
|
505
|
+
raise ValueError("done_when must be a single line without ** markup")
|
|
506
|
+
if wanted:
|
|
507
|
+
reject_marker_text(wanted, "done_when")
|
|
508
|
+
|
|
509
|
+
def mutate(state):
|
|
510
|
+
state["done_when"] = wanted
|
|
511
|
+
|
|
512
|
+
full = _full_domain(domain)
|
|
513
|
+
path = domain_path(full)
|
|
514
|
+
with file_lock(full) as identity:
|
|
515
|
+
result = _rewrite_row(path, full, task_id, mutate)
|
|
516
|
+
if result is False:
|
|
517
|
+
return False
|
|
518
|
+
_, text2 = result
|
|
519
|
+
_guarded_write(full, identity, path, text2)
|
|
520
|
+
return True
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def move(domain: str, task_id: str, section: str, *, purge_before=None) -> bool:
|
|
524
|
+
full = _full_domain(domain)
|
|
525
|
+
path = domain_path(full)
|
|
526
|
+
with file_lock(full) as identity:
|
|
527
|
+
rows = parse_path(path, full)
|
|
528
|
+
target = next((row for row in rows if _match_row(row, task_id, full)), None)
|
|
529
|
+
if target is None:
|
|
530
|
+
return False
|
|
531
|
+
lines = safe_read_text(path).splitlines()
|
|
532
|
+
idx = target.line_number - 1
|
|
533
|
+
task_line = lines.pop(idx)
|
|
534
|
+
lines, heading_idx, _ = _locked_ensure_section(
|
|
535
|
+
lines, section, purge_before=purge_before
|
|
536
|
+
)
|
|
537
|
+
_top_insert(lines, heading_idx, task_line)
|
|
538
|
+
_guarded_write(full, identity, path, "\n".join(lines) + "\n")
|
|
539
|
+
return True
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def check(domain: str, task_id: Optional[str] = None, *, text: Optional[str] = None, uncheck: bool = False) -> str:
|
|
543
|
+
"""Returns 'ok' | 'task_not_found' | 'ambiguous_text'."""
|
|
544
|
+
full = _full_domain(domain)
|
|
545
|
+
path = domain_path(full)
|
|
546
|
+
rows = parse_path(path, full)
|
|
547
|
+
if task_id:
|
|
548
|
+
matches = [row for row in rows if _match_row(row, task_id, full)]
|
|
549
|
+
else:
|
|
550
|
+
needle = (text or "").lower()
|
|
551
|
+
matches = [
|
|
552
|
+
row for row in rows
|
|
553
|
+
if needle and (
|
|
554
|
+
needle[:50] in row.description.lower()
|
|
555
|
+
or row.description.lower()[:50] in needle
|
|
556
|
+
)
|
|
557
|
+
]
|
|
558
|
+
if not matches:
|
|
559
|
+
return "task_not_found"
|
|
560
|
+
if len(matches) > 1 and text and not task_id:
|
|
561
|
+
return "ambiguous_text"
|
|
562
|
+
target = matches[0]
|
|
563
|
+
with file_lock(full) as identity:
|
|
564
|
+
result = _rewrite_row(
|
|
565
|
+
path, full, target.id,
|
|
566
|
+
lambda s: s.update(checked=not uncheck),
|
|
567
|
+
)
|
|
568
|
+
if result is False:
|
|
569
|
+
return "task_not_found"
|
|
570
|
+
_, body = result
|
|
571
|
+
_guarded_write(full, identity, path, body)
|
|
572
|
+
return "ok"
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
# Re-export for tests / writers
|
|
576
|
+
LockStoreCorrupt = LockStoreCorrupt
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def _work_mutation(domain: str, task_id: str, expected_text: str, expected_revision: str, change) -> bool:
|
|
580
|
+
if not isinstance(expected_text, str) or not expected_text or len(expected_text) > 20_000:
|
|
581
|
+
raise WorkMetadataError("expected_text_required", "Full current task text is required.")
|
|
582
|
+
if not isinstance(task_id, str) or not re.fullmatch(r"[a-f0-9]{12}", task_id):
|
|
583
|
+
raise WorkMetadataError("invalid_task_id", "An exact canonical task ID is required.")
|
|
584
|
+
if not isinstance(expected_revision, str) or not re.fullmatch(r"[a-f0-9]{64}", expected_revision):
|
|
585
|
+
raise WorkMetadataError("expected_revision_required", "The current Work revision is required.")
|
|
586
|
+
full = _full_domain(domain)
|
|
587
|
+
path = domain_path(full)
|
|
588
|
+
with file_lock(full) as identity:
|
|
589
|
+
matches = [r for r in parse_path(path, full) if r.id == task_id]
|
|
590
|
+
if not matches:
|
|
591
|
+
return False
|
|
592
|
+
if len(matches) != 1:
|
|
593
|
+
raise WorkMetadataError("ambiguous_task_id", "Task ID is not unique in this domain.")
|
|
594
|
+
target = matches[0]
|
|
595
|
+
if target.archived:
|
|
596
|
+
raise WorkMetadataError("task_archived", "Restore the archived task before changing its Work details.")
|
|
597
|
+
if target.work_revision != expected_revision:
|
|
598
|
+
raise WorkMetadataError("task_revision_changed", "Task file changed. Refresh before editing Work details.")
|
|
599
|
+
if target.description != expected_text:
|
|
600
|
+
raise WorkMetadataError("task_text_changed", "Task text changed. Refresh before editing its Work details.")
|
|
601
|
+
if target.work_metadata_error:
|
|
602
|
+
raise WorkMetadataError("work_metadata_invalid", target.work_metadata_error)
|
|
603
|
+
def mutate(state):
|
|
604
|
+
_, metadata, error = decode_source(state["source"])
|
|
605
|
+
if error:
|
|
606
|
+
raise WorkMetadataError("work_metadata_invalid", error)
|
|
607
|
+
metadata.setdefault("workIdentity", target.work_identity or target.id)
|
|
608
|
+
change(state, metadata)
|
|
609
|
+
state["source"] = write_source(state["source"], metadata)
|
|
610
|
+
result = _rewrite_row(path, full, task_id, mutate)
|
|
611
|
+
if result is False:
|
|
612
|
+
return False
|
|
613
|
+
_, contents = result
|
|
614
|
+
_guarded_write(full, identity, path, contents)
|
|
615
|
+
return True
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def set_work_stage(domain: str, task_id: str, phase: str, *, expected_text: str, expected_revision: str) -> bool:
|
|
619
|
+
if phase not in (*PHASES, 'complete'):
|
|
620
|
+
raise WorkMetadataError("invalid_work_stage", "Unknown Work phase.")
|
|
621
|
+
def change(state, metadata):
|
|
622
|
+
if phase == 'complete':
|
|
623
|
+
metadata['workflowPhase'] = phase_for(state['stage'], False, metadata)
|
|
624
|
+
state['checked'] = True
|
|
625
|
+
else:
|
|
626
|
+
metadata['workflowPhase'] = phase
|
|
627
|
+
state['stage'] = None if LEGACY_STAGE[phase] == 'planning' else LEGACY_STAGE[phase]
|
|
628
|
+
state['checked'] = False
|
|
629
|
+
return _work_mutation(domain, task_id, expected_text, expected_revision, change)
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def link_meeting(domain: str, task_id: str, *, expected_text: str, expected_revision: str, meeting=None, remove_record_id=None) -> bool:
|
|
633
|
+
if (meeting is None) == (remove_record_id is None):
|
|
634
|
+
raise WorkMetadataError("invalid_meeting_ref", "Provide one meeting reference or one record ID to unlink.")
|
|
635
|
+
if meeting is not None:
|
|
636
|
+
meeting = validate_meeting(meeting)
|
|
637
|
+
elif not isinstance(remove_record_id, str) or not remove_record_id or len(remove_record_id) > 512:
|
|
638
|
+
raise WorkMetadataError("invalid_meeting_ref", "Invalid record ID to unlink.")
|
|
639
|
+
def change(state, metadata):
|
|
640
|
+
key = meeting['recordId'] if meeting is not None else remove_record_id
|
|
641
|
+
refs = [ref for ref in metadata.get('meetingRefs', []) if ref['recordId'] != key]
|
|
642
|
+
if meeting is not None:
|
|
643
|
+
refs.append(meeting)
|
|
644
|
+
metadata['meetingRefs'] = refs
|
|
645
|
+
return _work_mutation(domain, task_id, expected_text, expected_revision, change)
|