@gotcos/glasses-server 6.55.0 → 6.56.1
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 +34 -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/cos-operations-meetings.ts +7 -0
- package/server/lib/provider-binary.ts +6 -2
- 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,380 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Portable read-only task protocol candidate. No production dispatch or writes.
|
|
3
|
+
|
|
4
|
+
Parser adapted from COS task_rows.py at MU snapshot 1dac83bd; see manifest.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
import argparse
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import stat
|
|
13
|
+
import string
|
|
14
|
+
import sys
|
|
15
|
+
from dataclasses import asdict, dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
PROTOCOL = "cos-control-task-read/1"
|
|
20
|
+
VERSION = "0.1.0"
|
|
21
|
+
MAX_BYTES = 8 * 1024 * 1024
|
|
22
|
+
|
|
23
|
+
_MARKER_BODY = (
|
|
24
|
+
r"\[(?:run \d{4}-\d{2}-\d{2} \d{2}:\d{2}|agent running|"
|
|
25
|
+
r"agent #\d+ (?:done|failed)|agent failed|"
|
|
26
|
+
r"stage (?:planning|active|review))\]"
|
|
27
|
+
)
|
|
28
|
+
MARKER_RE = re.compile(r"\s*(?:" + _MARKER_BODY + r"\s*)+$")
|
|
29
|
+
MARKER_ANYWHERE_RE = re.compile(_MARKER_BODY)
|
|
30
|
+
# Workflow stage. An UNMARKED row is planning, so the 80 open rows need no
|
|
31
|
+
# migration. Lives in the line, not a sidecar: the task id is a hash of the
|
|
32
|
+
# description, so any id-keyed store orphans itself on the first text edit.
|
|
33
|
+
# NOTE: the trailing \s* sits INSIDE the repeat group. The writer joins markers
|
|
34
|
+
# with spaces (_rebuild_line), but the old regex allowed none between groups, so
|
|
35
|
+
# "[run ...] [agent running]" stripped only the last one and silently dropped the
|
|
36
|
+
# run time on every read. Pre-existing; stage makes that pairing the common case.
|
|
37
|
+
# STAGE_RE only reads a value out of a blob MARKER_RE has already validated, so
|
|
38
|
+
# its own alternation was a second, unreachable copy: mutating it changed nothing
|
|
39
|
+
# under test BECAUSE MARKER_RE's `$`-anchored match rejects "[stage bogus]" first.
|
|
40
|
+
# That is true of the READ path only. The WRITE path is gated separately by
|
|
41
|
+
# STAGES below (task_write.set_stage) and by cos_api_bridge's own literal tuple,
|
|
42
|
+
# so there are three stage vocabularies in this system, not one. Widening the set
|
|
43
|
+
# means editing MARKER_RE, STAGES, and the bridge tuple together.
|
|
44
|
+
STAGE_RE = re.compile(r"\[stage (\w+)\]")
|
|
45
|
+
STAGES = ("planning", "active", "review")
|
|
46
|
+
RUN_AT_RE = re.compile(r"\[run (\d{4}-\d{2}-\d{2} \d{2}:\d{2})\]")
|
|
47
|
+
AGENT_NUM_RE = re.compile(r"\[agent #(\d+) (done|failed)\]")
|
|
48
|
+
INBOX_RE = re.compile(r"^#{1,3}\s*INBOX\s*$", re.IGNORECASE)
|
|
49
|
+
TODAY_RE = re.compile(r"^#{1,3}\s*TODAY\s+(\d{4}-\d{2}-\d{2})\s*$", re.IGNORECASE)
|
|
50
|
+
DELEGATED_HEADING_RE = re.compile(r"^#{1,2}\s*Delegated\b", re.IGNORECASE)
|
|
51
|
+
BOLD_OWNER_RE = re.compile(r"^\*\*(?P<body>(?!Owner:)[^*]+)\*\*:\s*")
|
|
52
|
+
BRACKET_OWNER_RE = re.compile(r"^\[(?P<body>[^\]]+)\]\s+")
|
|
53
|
+
CHECK_RE = re.compile(r"^- \[([ xX~])\]\s*(.*)$")
|
|
54
|
+
PRIORITY_SECTIONS = frozenset(
|
|
55
|
+
{"inbox", "today", "urgent", "this_week", "backlog", "completed"}
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class TaskRow:
|
|
61
|
+
ref: str
|
|
62
|
+
id: str
|
|
63
|
+
domain: str
|
|
64
|
+
description: str
|
|
65
|
+
priority: str
|
|
66
|
+
is_checked: bool
|
|
67
|
+
archived: bool
|
|
68
|
+
line_number: int
|
|
69
|
+
source: Optional[str]
|
|
70
|
+
owner: Optional[str]
|
|
71
|
+
delegated: bool
|
|
72
|
+
needs_review: bool
|
|
73
|
+
thread: None
|
|
74
|
+
run_at: Optional[str]
|
|
75
|
+
agent_state: Optional[str]
|
|
76
|
+
agent_no: Optional[int]
|
|
77
|
+
section: str
|
|
78
|
+
section_day: Optional[str]
|
|
79
|
+
stage: Optional[str] = None
|
|
80
|
+
done_when: Optional[str] = None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def split_source(line: str) -> tuple[str, Optional[str]]:
|
|
84
|
+
if "**Source:**" not in line:
|
|
85
|
+
return line.strip(), None
|
|
86
|
+
left, right = line.split("**Source:**", 1)
|
|
87
|
+
return left.strip().rstrip("—").strip(), right.strip()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
DONE_WHEN_LABEL = "**Done when:**"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def split_done_when(text):
|
|
94
|
+
"""Peel a **Done when:** segment off a description or source part.
|
|
95
|
+
|
|
96
|
+
Same convention as **Source:** so tasks.md stays readable by hand, and the
|
|
97
|
+
finish line travels with the row when the text is edited.
|
|
98
|
+
"""
|
|
99
|
+
if not text or DONE_WHEN_LABEL not in text:
|
|
100
|
+
return text, None
|
|
101
|
+
left, right = text.split(DONE_WHEN_LABEL, 1)
|
|
102
|
+
return left.strip().rstrip("\u2014").strip(), (right.strip() or None)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def strip_markers(text: str) -> tuple[str, dict]:
|
|
106
|
+
"""Remove end-of-line agent/run markers. Returns (text, fields)."""
|
|
107
|
+
fields: dict = {"run_at": None, "agent_state": None, "agent_no": None, "stage": None}
|
|
108
|
+
if not text:
|
|
109
|
+
return text, fields
|
|
110
|
+
match = MARKER_RE.search(text)
|
|
111
|
+
if not match:
|
|
112
|
+
return text, fields
|
|
113
|
+
blob = match.group(0)
|
|
114
|
+
run = RUN_AT_RE.search(blob)
|
|
115
|
+
if run:
|
|
116
|
+
fields["run_at"] = run.group(1)
|
|
117
|
+
stage = STAGE_RE.search(blob)
|
|
118
|
+
if stage:
|
|
119
|
+
fields["stage"] = stage.group(1)
|
|
120
|
+
numbered = AGENT_NUM_RE.search(blob)
|
|
121
|
+
if numbered:
|
|
122
|
+
fields["agent_no"] = int(numbered.group(1))
|
|
123
|
+
fields["agent_state"] = numbered.group(2)
|
|
124
|
+
elif "[agent running]" in blob:
|
|
125
|
+
fields["agent_state"] = "running"
|
|
126
|
+
elif "[agent failed]" in blob:
|
|
127
|
+
fields["agent_state"] = "failed"
|
|
128
|
+
return text[: match.start()].rstrip(), fields
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _merge_marker_fields(primary: dict, secondary: dict) -> dict:
|
|
132
|
+
out = dict(primary)
|
|
133
|
+
for key, value in secondary.items():
|
|
134
|
+
if value is not None:
|
|
135
|
+
out[key] = value
|
|
136
|
+
return out
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def classify_heading(stripped: str) -> tuple[Optional[str], Optional[str]]:
|
|
140
|
+
"""Return (section, section_day) for a heading, else (None, None)."""
|
|
141
|
+
if not stripped.startswith("#"):
|
|
142
|
+
return None, None
|
|
143
|
+
inbox = INBOX_RE.match(stripped)
|
|
144
|
+
if inbox:
|
|
145
|
+
return "inbox", None
|
|
146
|
+
today = TODAY_RE.match(stripped)
|
|
147
|
+
if today:
|
|
148
|
+
return "today", today.group(1)
|
|
149
|
+
upper = stripped.upper()
|
|
150
|
+
if "🔴 URGENT" in stripped or "URGENT" in upper:
|
|
151
|
+
return "urgent", None
|
|
152
|
+
if "🟡 THIS WEEK" in stripped or "THIS WEEK" in upper:
|
|
153
|
+
return "this_week", None
|
|
154
|
+
if "🟢 BACKLOG" in stripped or "BACKLOG" in upper:
|
|
155
|
+
return "backlog", None
|
|
156
|
+
if "✅ COMPLETED" in stripped or "COMPLETED" in upper:
|
|
157
|
+
return "completed", None
|
|
158
|
+
return "other", None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _heading_depth(stripped: str) -> int:
|
|
162
|
+
i = 0
|
|
163
|
+
while i < len(stripped) and stripped[i] == "#":
|
|
164
|
+
i += 1
|
|
165
|
+
return i
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def parse_owner(text: str) -> tuple[Optional[str], str]:
|
|
169
|
+
bold = BOLD_OWNER_RE.match(text)
|
|
170
|
+
if bold:
|
|
171
|
+
return bold.group("body").strip(), text[bold.end():]
|
|
172
|
+
bracket = BRACKET_OWNER_RE.match(text)
|
|
173
|
+
if bracket:
|
|
174
|
+
return bracket.group("body").strip(), text[bracket.end():]
|
|
175
|
+
return None, text
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def owner_tokens(owner: str) -> list[str]:
|
|
179
|
+
return [part.strip() for part in re.split(r"\s*\+\s*|/", owner) if part.strip()]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def owner_includes_operator(owner: Optional[str], operator: Optional[str]) -> bool:
|
|
183
|
+
if not owner or not operator:
|
|
184
|
+
return False
|
|
185
|
+
for token in owner_tokens(owner):
|
|
186
|
+
parts = token.split("(")[0].strip().split()
|
|
187
|
+
if parts and parts[0].casefold() == operator.casefold():
|
|
188
|
+
return True
|
|
189
|
+
return False
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def ends_delegated_zone(stripped: str) -> bool:
|
|
193
|
+
if stripped.startswith("---"):
|
|
194
|
+
return True
|
|
195
|
+
return stripped.startswith("#") and _heading_depth(stripped) <= 2
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def parse_text(text: str, domain: str, operator: Optional[str] = None) -> list[TaskRow]:
|
|
199
|
+
rows: list[TaskRow] = []
|
|
200
|
+
current_priority = "backlog"
|
|
201
|
+
current_section = "other"
|
|
202
|
+
current_day: Optional[str] = None
|
|
203
|
+
in_comment = False
|
|
204
|
+
delegated_zone = False
|
|
205
|
+
counter = 0
|
|
206
|
+
|
|
207
|
+
for line_number, line in enumerate(text.splitlines(), 1):
|
|
208
|
+
stripped = line.strip()
|
|
209
|
+
if "<!--" in stripped:
|
|
210
|
+
in_comment = True
|
|
211
|
+
if "-->" in stripped:
|
|
212
|
+
in_comment = False
|
|
213
|
+
continue
|
|
214
|
+
if in_comment:
|
|
215
|
+
continue
|
|
216
|
+
|
|
217
|
+
if ends_delegated_zone(stripped):
|
|
218
|
+
delegated_zone = False
|
|
219
|
+
if DELEGATED_HEADING_RE.match(stripped):
|
|
220
|
+
delegated_zone = True
|
|
221
|
+
|
|
222
|
+
heading, heading_day = classify_heading(stripped)
|
|
223
|
+
if heading is not None:
|
|
224
|
+
current_section = heading
|
|
225
|
+
if heading != "other":
|
|
226
|
+
current_priority = heading
|
|
227
|
+
current_day = heading_day
|
|
228
|
+
else:
|
|
229
|
+
current_day = None
|
|
230
|
+
continue
|
|
231
|
+
|
|
232
|
+
checked = CHECK_RE.match(line)
|
|
233
|
+
if not checked:
|
|
234
|
+
continue
|
|
235
|
+
|
|
236
|
+
mark, raw = checked.group(1), checked.group(2)
|
|
237
|
+
archived = mark == "~"
|
|
238
|
+
is_checked = mark.lower() == "x"
|
|
239
|
+
description, source = split_source(raw)
|
|
240
|
+
description, desc_markers = strip_markers(description)
|
|
241
|
+
if source is not None:
|
|
242
|
+
source, src_markers = strip_markers(source)
|
|
243
|
+
markers = _merge_marker_fields(desc_markers, src_markers)
|
|
244
|
+
else:
|
|
245
|
+
markers = desc_markers
|
|
246
|
+
|
|
247
|
+
description, dw_desc = split_done_when(description)
|
|
248
|
+
if source is not None:
|
|
249
|
+
source, dw_src = split_done_when(source)
|
|
250
|
+
else:
|
|
251
|
+
dw_src = None
|
|
252
|
+
done_when = dw_desc or dw_src
|
|
253
|
+
|
|
254
|
+
needs_review = "[REVIEW]" in description
|
|
255
|
+
if needs_review:
|
|
256
|
+
description = description.replace("[REVIEW]", "").strip()
|
|
257
|
+
|
|
258
|
+
owner, description = parse_owner(description)
|
|
259
|
+
description = description.strip()
|
|
260
|
+
delegated = delegated_zone or (
|
|
261
|
+
owner is not None and not owner_includes_operator(owner, operator)
|
|
262
|
+
)
|
|
263
|
+
priority = "completed" if is_checked else (
|
|
264
|
+
current_section if current_section in PRIORITY_SECTIONS
|
|
265
|
+
else current_priority
|
|
266
|
+
)
|
|
267
|
+
counter += 1
|
|
268
|
+
rows.append(TaskRow(
|
|
269
|
+
ref=f"{domain}-{counter}",
|
|
270
|
+
id="",
|
|
271
|
+
domain=domain,
|
|
272
|
+
description=description,
|
|
273
|
+
priority=priority,
|
|
274
|
+
is_checked=is_checked,
|
|
275
|
+
archived=archived,
|
|
276
|
+
line_number=line_number,
|
|
277
|
+
source=source,
|
|
278
|
+
owner=owner,
|
|
279
|
+
delegated=delegated,
|
|
280
|
+
needs_review=needs_review,
|
|
281
|
+
thread=None,
|
|
282
|
+
run_at=markers["run_at"],
|
|
283
|
+
agent_state=markers["agent_state"],
|
|
284
|
+
agent_no=markers["agent_no"],
|
|
285
|
+
section=current_section,
|
|
286
|
+
section_day=current_day if current_section == "today" else None,
|
|
287
|
+
stage=markers["stage"],
|
|
288
|
+
done_when=done_when,
|
|
289
|
+
))
|
|
290
|
+
|
|
291
|
+
_assign_ids(domain, rows)
|
|
292
|
+
return rows
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _assign_ids(domain: str, rows: list[TaskRow]) -> None:
|
|
296
|
+
|
|
297
|
+
seen: dict[str, int] = {}
|
|
298
|
+
for row in rows:
|
|
299
|
+
norm = normalize(row.description)
|
|
300
|
+
ordinal = seen.get(norm, 0)
|
|
301
|
+
seen[norm] = ordinal + 1
|
|
302
|
+
row.id = hashlib.sha256(
|
|
303
|
+
f"{domain}/tasks.md|{norm}|{ordinal}".encode()
|
|
304
|
+
).hexdigest()[:12]
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def normalize(text: str) -> str:
|
|
308
|
+
text, _ = strip_markers(text)
|
|
309
|
+
return " ".join(text.lower().translate(str.maketrans("", "", string.punctuation)).split())
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def capabilities() -> dict:
|
|
313
|
+
return {"protocol": PROTOCOL, "version": VERSION, "commands": ["capabilities", "domains", "task-rows"],
|
|
314
|
+
"readOnly": True, "writes": False, "dispatch": False, "migration": False,
|
|
315
|
+
"identity": {"kind": "legacy-description-hash", "stable": False},
|
|
316
|
+
"limits": {"maxTaskFileBytes": MAX_BYTES}, "requiresPrivateCheckout": False}
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def read_domain(root_fd: int, domain: str) -> str:
|
|
320
|
+
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]{0,127}", domain):
|
|
321
|
+
raise ValueError("invalid_domain")
|
|
322
|
+
dfd = os.open(domain, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=root_fd)
|
|
323
|
+
try:
|
|
324
|
+
fd = os.open("tasks.md", os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=dfd)
|
|
325
|
+
try:
|
|
326
|
+
before = os.fstat(fd)
|
|
327
|
+
if not stat.S_ISREG(before.st_mode): raise ValueError("not_regular_file")
|
|
328
|
+
if before.st_size > MAX_BYTES: raise ValueError("task_file_too_large")
|
|
329
|
+
with os.fdopen(fd, "rb", closefd=False) as stream:
|
|
330
|
+
data = stream.read(MAX_BYTES + 1)
|
|
331
|
+
after = os.fstat(fd)
|
|
332
|
+
if len(data) > MAX_BYTES: raise ValueError("task_file_too_large")
|
|
333
|
+
if (before.st_size, before.st_mtime_ns) != (after.st_size, after.st_mtime_ns):
|
|
334
|
+
raise ValueError("source_changed_retry")
|
|
335
|
+
return data.decode("utf-8")
|
|
336
|
+
finally: os.close(fd)
|
|
337
|
+
finally: os.close(dfd)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def main() -> int:
|
|
341
|
+
parser = argparse.ArgumentParser()
|
|
342
|
+
parser.add_argument("command", choices=["capabilities", "domains", "task-rows"])
|
|
343
|
+
parser.add_argument("--root")
|
|
344
|
+
parser.add_argument("--domain")
|
|
345
|
+
parser.add_argument("--operator", help="Optional first-name owner match; absence conservatively marks named owners delegated")
|
|
346
|
+
args = parser.parse_args()
|
|
347
|
+
try:
|
|
348
|
+
if args.command == "capabilities":
|
|
349
|
+
out = capabilities()
|
|
350
|
+
else:
|
|
351
|
+
if not args.root or not Path(args.root).is_absolute(): raise ValueError("absolute_root_required")
|
|
352
|
+
# Explicit root only. No env fallback, home discovery or private checkout.
|
|
353
|
+
root_fd = os.open(args.root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
|
354
|
+
try:
|
|
355
|
+
if args.command == "domains":
|
|
356
|
+
domains = []
|
|
357
|
+
for domain in sorted(os.listdir(root_fd)):
|
|
358
|
+
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]{0,127}", domain): continue
|
|
359
|
+
try: read_domain(root_fd, domain)
|
|
360
|
+
except (OSError, ValueError, UnicodeError): continue
|
|
361
|
+
domains.append(domain)
|
|
362
|
+
out = {"protocol": PROTOCOL, "domains": domains}
|
|
363
|
+
else:
|
|
364
|
+
if not args.domain: raise ValueError("domain_required")
|
|
365
|
+
source = read_domain(root_fd, args.domain)
|
|
366
|
+
out = {"protocol": PROTOCOL, "domain": args.domain,
|
|
367
|
+
"sourceRevision": hashlib.sha256(source.encode("utf-8")).hexdigest(),
|
|
368
|
+
"identity": capabilities()["identity"],
|
|
369
|
+
"rows": [asdict(row) for row in parse_text(source, args.domain, args.operator)]}
|
|
370
|
+
finally: os.close(root_fd)
|
|
371
|
+
print(json.dumps(out, ensure_ascii=False))
|
|
372
|
+
return 0
|
|
373
|
+
except (OSError, ValueError, UnicodeError) as error:
|
|
374
|
+
code = str(error) if isinstance(error, ValueError) and not isinstance(error, UnicodeError) else "source_unavailable"
|
|
375
|
+
print(json.dumps({"protocol": PROTOCOL, "error": {"code": code}}))
|
|
376
|
+
return 1
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
if __name__ == "__main__":
|
|
380
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Atomic file write helpers.
|
|
2
|
+
|
|
3
|
+
All COS state files that use JSON/JSONL must write atomically: write to a
|
|
4
|
+
temp sibling, then `os.replace` into place. A torn write (truncated file
|
|
5
|
+
after an open-write-close that crashed mid-way) is the Jan-31 incident
|
|
6
|
+
class — vector_state.json lost 744 entries because a crash hit between
|
|
7
|
+
truncate and final write.
|
|
8
|
+
|
|
9
|
+
Use `atomic_write_text` for text/JSON payloads and `atomic_write_json` as
|
|
10
|
+
a thin convenience wrapper. Both hold no lock — callers that need
|
|
11
|
+
cross-process serialization must layer their own locking around the call
|
|
12
|
+
(see lightrag_indexer._queue_lock for an example).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import uuid
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def atomic_write_text(
|
|
25
|
+
path: Path,
|
|
26
|
+
text: str,
|
|
27
|
+
encoding: str = "utf-8",
|
|
28
|
+
*,
|
|
29
|
+
durable: bool = False,
|
|
30
|
+
) -> None:
|
|
31
|
+
"""Write `text` to `path` via tmp + os.replace.
|
|
32
|
+
|
|
33
|
+
On POSIX, `os.replace` is atomic within a filesystem — the destination
|
|
34
|
+
either contains the old content or the new, never a torn in-between.
|
|
35
|
+
|
|
36
|
+
The tmp filename is suffixed with pid+uuid so concurrent callers on
|
|
37
|
+
the same path do not clobber each other's tmp content before replace.
|
|
38
|
+
If the write raises, the orphan tmp is best-effort deleted so failed
|
|
39
|
+
runs do not leave litter behind.
|
|
40
|
+
|
|
41
|
+
`durable=True` fsyncs the tmp fd before replace and the parent directory
|
|
42
|
+
after. Default stays the historical no-fsync path.
|
|
43
|
+
"""
|
|
44
|
+
tmp = path.with_suffix(f"{path.suffix}.tmp.{os.getpid()}.{uuid.uuid4().hex[:8]}")
|
|
45
|
+
try:
|
|
46
|
+
data = text.encode(encoding)
|
|
47
|
+
if durable:
|
|
48
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o644)
|
|
49
|
+
try:
|
|
50
|
+
offset = 0
|
|
51
|
+
while offset < len(data):
|
|
52
|
+
try:
|
|
53
|
+
written = os.write(fd, data[offset:])
|
|
54
|
+
except InterruptedError:
|
|
55
|
+
continue
|
|
56
|
+
if written <= 0:
|
|
57
|
+
raise OSError("atomic write made no progress")
|
|
58
|
+
offset += written
|
|
59
|
+
os.fsync(fd)
|
|
60
|
+
finally:
|
|
61
|
+
os.close(fd)
|
|
62
|
+
else:
|
|
63
|
+
tmp.write_text(text, encoding=encoding)
|
|
64
|
+
os.replace(tmp, path)
|
|
65
|
+
if durable:
|
|
66
|
+
dir_fd = os.open(str(path.parent), os.O_RDONLY)
|
|
67
|
+
try:
|
|
68
|
+
os.fsync(dir_fd)
|
|
69
|
+
finally:
|
|
70
|
+
os.close(dir_fd)
|
|
71
|
+
except Exception:
|
|
72
|
+
try:
|
|
73
|
+
tmp.unlink(missing_ok=True)
|
|
74
|
+
except OSError:
|
|
75
|
+
pass
|
|
76
|
+
raise
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def atomic_write_json(
|
|
80
|
+
path: Path,
|
|
81
|
+
data: Any,
|
|
82
|
+
indent: int | None = 2,
|
|
83
|
+
encoding: str = "utf-8",
|
|
84
|
+
*,
|
|
85
|
+
durable: bool = False,
|
|
86
|
+
) -> None:
|
|
87
|
+
"""Serialize `data` to JSON and write atomically."""
|
|
88
|
+
atomic_write_text(
|
|
89
|
+
path, json.dumps(data, indent=indent), encoding=encoding, durable=durable
|
|
90
|
+
)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"protocol": "cos-control-task-write/1",
|
|
4
|
+
"pythonMinimum": "3.10",
|
|
5
|
+
"sourceCommit": "177d0bd875f446f5e3d57de52983d5ab4e679915",
|
|
6
|
+
"sourceHashes": {
|
|
7
|
+
"task_rows.py": "58b568fd4081072eddc3110b4c3397f5e5048ede9e052391e1d09f919e8c2e7d",
|
|
8
|
+
"task_write.py": "4d1d4c51cf5ec6fa75f941c2ce1aee05952fde9a6bac89be4fca51b0ffd37981",
|
|
9
|
+
"task_work_metadata.py": "534c45910e2109ac1e18eeb236c6b286d8a899f96c7a153d0187aee5839854a5",
|
|
10
|
+
"task_checkout.py": "56c94c5b28282398abc1f8daea9d910e64228afb8ac8cfae89b54117a7e30b86",
|
|
11
|
+
"cos_atomic.py": "055f7d6bc3093aeec7b051f87cb68c543637603a538f383a614c1903bd1b503e",
|
|
12
|
+
"cos_api_bridge.py": "1e17960332e8b778c8e5923de355f4395c9244692cd4d988ccd944c6a30575ef",
|
|
13
|
+
"task_dedup.py": "5331fdea01c293ecd2773726acea110e9339ff26e72e5bcdce66777ab518bfe8"
|
|
14
|
+
},
|
|
15
|
+
"adaptations": [
|
|
16
|
+
"Only canonical task command handlers extracted; no private bridge imports",
|
|
17
|
+
"Exact canonical normalization extracted without LLM dependencies",
|
|
18
|
+
"Generic configured operator replaces author-specific owner match",
|
|
19
|
+
"Bounded no-follow source reads and lock opens",
|
|
20
|
+
"Descriptor-pinned domain working directory and lock opens supplied by task_bridge.py"
|
|
21
|
+
],
|
|
22
|
+
"files": {
|
|
23
|
+
"cos_atomic.py": "0da53eecaf07331b008c10b1e06c39701ecc3291c511fd0779e35795b3b22534",
|
|
24
|
+
"runtime_io.py": "e5b191b6af3a4966f517ecd05d3dcae9f4136a9e99cf36d7102303ca55ae928d",
|
|
25
|
+
"task_bridge.py": "8ec1f6533710c3de2c51e518586d2f6b120f90f7c3773480a08d53544ac07fd4",
|
|
26
|
+
"task_checkout.py": "669af045b2f64b9879df03aacae7a4550b46291974ed2df2ba2ed0775c1be78f",
|
|
27
|
+
"task_commands.py": "7e843b9164112d610190385df8e41d6044d6546d4ece92d09a60b6e4ad33cc75",
|
|
28
|
+
"task_dedup.py": "745971722898a3de520f8bb5246d714c70f3189dbe53bf8f42feafaaf241a51b",
|
|
29
|
+
"task_rows.py": "9e48375d9d6ee5d29bf3cc1f92bdf1182a6eea40ee305ff1fe1632b9f3a9fcf7",
|
|
30
|
+
"task_work_metadata.py": "534c45910e2109ac1e18eeb236c6b286d8a899f96c7a153d0187aee5839854a5",
|
|
31
|
+
"task_write.py": "238b869fac49c8e01bca72a1f28ffab74c7f94938e2323246936aec0241bbd16"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Bounded, no-follow source I/O used by the portable canonical parser/writer."""
|
|
2
|
+
import os, stat
|
|
3
|
+
MAX_SOURCE=8*1024*1024
|
|
4
|
+
|
|
5
|
+
def safe_read_text(path):
|
|
6
|
+
fd=os.open(path,os.O_RDONLY|os.O_NOFOLLOW|os.O_NONBLOCK)
|
|
7
|
+
try:
|
|
8
|
+
before=os.fstat(fd)
|
|
9
|
+
if not stat.S_ISREG(before.st_mode) or before.st_size>MAX_SOURCE: raise ValueError('Unsafe or oversized task source')
|
|
10
|
+
parts=[]; count=0
|
|
11
|
+
while count<=MAX_SOURCE:
|
|
12
|
+
chunk=os.read(fd,min(65536,MAX_SOURCE+1-count))
|
|
13
|
+
if not chunk: break
|
|
14
|
+
parts.append(chunk); count+=len(chunk)
|
|
15
|
+
after=os.fstat(fd)
|
|
16
|
+
if count>MAX_SOURCE or count!=before.st_size or (before.st_size,before.st_mtime_ns)!=(after.st_size,after.st_mtime_ns): raise ValueError('Task source changed while reading')
|
|
17
|
+
return b''.join(parts).decode('utf-8')
|
|
18
|
+
finally: os.close(fd)
|
|
19
|
+
|
|
20
|
+
LOCK_FD=None
|
|
21
|
+
def open_lock(path, flags, mode=0o600):
|
|
22
|
+
if LOCK_FD is None: raise ValueError('No pinned lock directory')
|
|
23
|
+
fd=os.open(path.name,flags|os.O_NOFOLLOW|os.O_NONBLOCK,mode,dir_fd=LOCK_FD)
|
|
24
|
+
info=os.fstat(fd)
|
|
25
|
+
if not stat.S_ISREG(info.st_mode) or info.st_size>1048576:
|
|
26
|
+
os.close(fd); raise ValueError('Unsafe lock store')
|
|
27
|
+
return fd
|
|
@@ -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.'}}))
|