@troylar/sightline 0.0.0-stage → 1.120.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/CHANGELOG.md +1882 -0
- package/CONTRIBUTING.md +67 -0
- package/LICENSE +21 -0
- package/README.md +153 -2
- package/THIRD-PARTY-NOTICES.md +121 -0
- package/bin/sightline.js +78 -0
- package/dist/sightline-app.py +406 -0
- package/dist/sightline-assistant.py +551 -0
- package/dist/sightline-jira-sync.py +2513 -0
- package/dist/sightline.html +14124 -0
- package/docs/assistant-workflow.md +472 -0
- package/docs/availability.md +54 -0
- package/docs/bundled-skills.md +46 -0
- package/docs/getting-started.md +99 -0
- package/docs/images/assistant-dark.png +0 -0
- package/docs/images/assistant-light.png +0 -0
- package/docs/images/bundled-skills-dark.png +0 -0
- package/docs/images/bundled-skills-light.png +0 -0
- package/docs/images/planning-decisions-dark.svg +46 -0
- package/docs/images/planning-decisions-light.svg +46 -0
- package/docs/images/planning-decisions-mobile-dark.svg +40 -0
- package/docs/images/planning-decisions-mobile-light.svg +40 -0
- package/docs/images/planning-story-dark.svg +32 -0
- package/docs/images/planning-story-light.svg +32 -0
- package/docs/images/planning-story-mobile-dark.svg +33 -0
- package/docs/images/planning-story-mobile-light.svg +33 -0
- package/docs/images/review-dark.png +0 -0
- package/docs/images/review-light.png +0 -0
- package/docs/images/roadmap-dark.png +0 -0
- package/docs/images/roadmap-light.png +0 -0
- package/docs/images/roadmap-map-dark.svg +53 -0
- package/docs/images/roadmap-map-light.svg +53 -0
- package/docs/images/roadmap-map-mobile-dark.svg +37 -0
- package/docs/images/roadmap-map-mobile-light.svg +37 -0
- package/docs/images/timeline-dark.png +0 -0
- package/docs/images/timeline-light.png +0 -0
- package/docs/images/today-dark.png +0 -0
- package/docs/images/today-light.png +0 -0
- package/docs/jira.md +66 -0
- package/docs/large-roadmaps.md +93 -0
- package/docs/onedrive.md +62 -0
- package/docs/performance-workspaces.md +39 -0
- package/docs/roadmap-planning-example.md +92 -0
- package/docs/roadmap.md +232 -0
- package/docs/screenshots.md +57 -0
- package/docs/skill-evaluation.md +71 -0
- package/docs/skills.md +85 -0
- package/docs/team-planning.md +66 -0
- package/docs/usability-test.md +215 -0
- package/docs/workspace-validation.md +152 -0
- package/docs/workspaces.md +105 -0
- package/examples/northstar/blank-plan.json +21 -0
- package/examples/northstar/brain-dump.txt +1 -0
- package/examples/northstar/jira/issues.json +1256 -0
- package/examples/northstar/jira/status.json +1542 -0
- package/examples/northstar/plan.json +341 -0
- package/examples/northstar/roadmap-session-amendments.json +42 -0
- package/examples/northstar/sightline-amendments.json +41 -0
- package/package.json +53 -4
- package/skills/sightline-edit-plan/SKILL.md +35 -0
- package/skills/sightline-meeting-to-plan/SKILL.md +34 -0
- package/skills/sightline-plan-roadmap/SKILL.md +34 -0
- package/skills/sightline-planning-guide/SKILL.md +29 -0
- package/skills/sightline-review-plan/SKILL.md +28 -0
- package/skills/sightline-sort-jira/SKILL.md +56 -0
- package/skills/sightline-status/SKILL.md +26 -0
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Send an assistant's changes to Sightline and find out what happened, without a person watching.
|
|
3
|
+
|
|
4
|
+
Works on the private assistant folder connected in Sightline (Connect assistant). Standard library only; no network.
|
|
5
|
+
|
|
6
|
+
sightline-app.py status <folder> is a Sightline page answering for this folder?
|
|
7
|
+
sightline-app.py check <folder> <batch.json> check a batch against the context you read, offline (no page needed)
|
|
8
|
+
sightline-app.py send <folder> <batch.json> check the heartbeat and context, then write the batch atomically
|
|
9
|
+
sightline-app.py wait <folder> <batchId> wait until every change has an outcome
|
|
10
|
+
sightline-app.py run <folder> <batch.json> send, then wait
|
|
11
|
+
|
|
12
|
+
Exit codes: 0 everything applied (or was already in the plan); 1 something was refused, conflicted, expired or still
|
|
13
|
+
waits for a person; 2 the batch was written against an older revision of the plan (read the context again);
|
|
14
|
+
3 no Sightline page is answering for this folder (closed, asleep, or its folder permission was lost); 4 this copy was
|
|
15
|
+
published with a different Sightline version than the page that wrote the folder's files (use the copy beside that page).
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import datetime as dt
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
import sys
|
|
25
|
+
import re
|
|
26
|
+
import tempfile
|
|
27
|
+
import time
|
|
28
|
+
|
|
29
|
+
# The Sightline page this copy matches. build.sh stamps it into the copy it publishes beside the page; None means this
|
|
30
|
+
# runs from source, where nothing is stamped and nothing is compared.
|
|
31
|
+
PAGE_VERSION = "1.120.2"
|
|
32
|
+
CONTEXT_FILE = "sightline-assistant-view.json"
|
|
33
|
+
AMENDMENTS_FILE = "sightline-amendments.json"
|
|
34
|
+
RESULTS_FILE = "sightline-assistant-results.json"
|
|
35
|
+
STATUS_FILE = "sightline-assistant-status.json"
|
|
36
|
+
MAX_READ = 5 * 1024 * 1024
|
|
37
|
+
AMENDMENTS_MAX = 512 * 1024
|
|
38
|
+
# A page ticks every 30 seconds; a background tab can be slowed to once a minute. Silence past this means nobody's there.
|
|
39
|
+
STALE_AFTER = 150
|
|
40
|
+
DONE = {"applied", "already-in-plan"}
|
|
41
|
+
OPS = {"add", "set", "link", "unlink", "propose", "action"}
|
|
42
|
+
CHANGE_ID = re.compile(r"^(?!(?:__proto__|constructor|prototype|toString|valueOf|hasOwnProperty)$)[A-Za-z0-9._-]{1,80}$")
|
|
43
|
+
ARG_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:@-]{0,79}$")
|
|
44
|
+
ISO = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Fail(Exception):
|
|
48
|
+
def __init__(self, code: int, message: str, detail: dict | None = None):
|
|
49
|
+
super().__init__(message)
|
|
50
|
+
self.code, self.message, self.detail = code, message, detail or {}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def read_json(path: Path, kind: str):
|
|
54
|
+
try:
|
|
55
|
+
if path.stat().st_size > MAX_READ:
|
|
56
|
+
raise Fail(1, f"{path.name} is larger than {MAX_READ // (1024 * 1024)} MB.")
|
|
57
|
+
with path.open(encoding="utf-8") as f:
|
|
58
|
+
data = json.load(f)
|
|
59
|
+
except FileNotFoundError:
|
|
60
|
+
return None
|
|
61
|
+
except (OSError, ValueError) as e:
|
|
62
|
+
raise Fail(1, f"Can't read {path.name}: {e}")
|
|
63
|
+
if not isinstance(data, dict) or data.get("kind") != kind:
|
|
64
|
+
raise Fail(1, f"{path.name} isn't a Sightline {kind} file.")
|
|
65
|
+
return data
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def age_seconds(stamp) -> float | None:
|
|
69
|
+
try:
|
|
70
|
+
t = dt.datetime.fromisoformat(str(stamp).replace("Z", "+00:00"))
|
|
71
|
+
except ValueError:
|
|
72
|
+
return None
|
|
73
|
+
if t.tzinfo is None:
|
|
74
|
+
t = t.replace(tzinfo=dt.timezone.utc)
|
|
75
|
+
return (dt.datetime.now(dt.timezone.utc) - t).total_seconds()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def match_page(found, where: str) -> None:
|
|
79
|
+
if PAGE_VERSION and found and found != PAGE_VERSION:
|
|
80
|
+
raise Fail(4, f"This sightline-app.py matches Sightline {PAGE_VERSION}, but {where} was written by Sightline {found}. Nothing was sent or checked. "
|
|
81
|
+
"Use the sightline-app.py published beside that page (its .sha256 file says whether the copy is intact).", {"matchesPage": PAGE_VERSION, "page": found})
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def heartbeat(folder: Path) -> dict:
|
|
85
|
+
"""The page's latest status, or Fail(3) when no page is answering."""
|
|
86
|
+
st = read_json(folder / STATUS_FILE, "sightline-assistant-status")
|
|
87
|
+
if st is None:
|
|
88
|
+
raise Fail(3, f"No Sightline page has answered for this folder: {STATUS_FILE} isn't there. Open the plan in Sightline and connect this folder (Connect assistant).")
|
|
89
|
+
age = age_seconds(st.get("at"))
|
|
90
|
+
if age is None:
|
|
91
|
+
raise Fail(3, f"{STATUS_FILE} has no readable time.")
|
|
92
|
+
if age > STALE_AFTER:
|
|
93
|
+
raise Fail(3, f"The Sightline page stopped responding: last heard {int(age)} seconds ago, when it was {st.get('state')}. Open the plan in Sightline, or reconnect the folder.",
|
|
94
|
+
{"lastHeardSeconds": int(age), "lastState": st.get("state")})
|
|
95
|
+
st["ageSeconds"] = int(age)
|
|
96
|
+
match_page(st.get("appVersion"), STATUS_FILE)
|
|
97
|
+
return st
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def check_batch(batch: dict, st: dict) -> None:
|
|
101
|
+
if batch.get("kind") != "sightline-amendments" or batch.get("version") != 1:
|
|
102
|
+
raise Fail(1, 'The batch must have kind "sightline-amendments" and version 1.')
|
|
103
|
+
if batch.get("planId") != st.get("planId"):
|
|
104
|
+
raise Fail(1, f"The batch is for plan {batch.get('planId')!r}; this folder is connected to {st.get('planId')!r}.")
|
|
105
|
+
if not isinstance(batch.get("batchId"), str) or not batch["batchId"]:
|
|
106
|
+
raise Fail(1, "The batch needs a batchId.")
|
|
107
|
+
based = (batch.get("basedOn") or {}).get("planRevision")
|
|
108
|
+
if not isinstance(based, int):
|
|
109
|
+
raise Fail(1, "The batch needs basedOn.planRevision: the plan revision in the context you read.")
|
|
110
|
+
if based != st.get("planRevision"):
|
|
111
|
+
raise Fail(2, f"The batch was written against revision {based}; the plan is at revision {st.get('planRevision')}. Read {CONTEXT_FILE} again and rewrite it.",
|
|
112
|
+
{"basedOn": based, "planRevision": st.get("planRevision")})
|
|
113
|
+
made = age_seconds(batch.get("createdAt"))
|
|
114
|
+
if made is None:
|
|
115
|
+
raise Fail(1, "The batch needs createdAt (an ISO time), so Sightline can tell how old it is.")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def arg_ok(t: str, v) -> bool:
|
|
119
|
+
t = t.rstrip("?")
|
|
120
|
+
if t == "any":
|
|
121
|
+
return True
|
|
122
|
+
if t == "id":
|
|
123
|
+
return isinstance(v, str) and bool(ARG_ID.match(v))
|
|
124
|
+
if t == "idOrBlank":
|
|
125
|
+
return v == "" or arg_ok("id", v)
|
|
126
|
+
if t in ("ids", "keys"):
|
|
127
|
+
return isinstance(v, list) and len(v) <= 500 and all(isinstance(x, str) and x for x in v)
|
|
128
|
+
if t == "str":
|
|
129
|
+
return isinstance(v, str) and len(v) <= 4000
|
|
130
|
+
if t == "iso":
|
|
131
|
+
if not (isinstance(v, str) and ISO.match(v)):
|
|
132
|
+
return False
|
|
133
|
+
try:
|
|
134
|
+
dt.date.fromisoformat(v)
|
|
135
|
+
except ValueError:
|
|
136
|
+
return False
|
|
137
|
+
return True
|
|
138
|
+
if t == "num":
|
|
139
|
+
return isinstance(v, (int, float)) and not isinstance(v, bool) and v == v and abs(v) != float("inf")
|
|
140
|
+
if t == "int":
|
|
141
|
+
return isinstance(v, int) and not isinstance(v, bool)
|
|
142
|
+
return True
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def check_offline(folder: Path, batch: dict) -> list:
|
|
146
|
+
"""Problems with a batch, checked against the context file it was written from. The page checks again."""
|
|
147
|
+
ctx = read_json(folder / CONTEXT_FILE, "sightline-assistant-view")
|
|
148
|
+
if ctx is None:
|
|
149
|
+
raise Fail(1, f"{CONTEXT_FILE} isn't in this folder. Connect it in Sightline (Connect assistant), then read the context.")
|
|
150
|
+
match_page(ctx.get("appVersion"), CONTEXT_FILE)
|
|
151
|
+
plan = ctx.get("plan") or {}
|
|
152
|
+
if batch.get("kind") != "sightline-amendments" or batch.get("version") != 1:
|
|
153
|
+
raise Fail(1, 'The batch must have kind "sightline-amendments" and version 1.')
|
|
154
|
+
if batch.get("planId") != plan.get("id"):
|
|
155
|
+
raise Fail(1, f"The batch is for plan {batch.get('planId')!r}; the context is for {plan.get('id')!r}.")
|
|
156
|
+
based = (batch.get("basedOn") or {}).get("planRevision")
|
|
157
|
+
if not isinstance(based, int):
|
|
158
|
+
raise Fail(1, "The batch needs basedOn.planRevision: the plan revision in the context you read.")
|
|
159
|
+
if based != plan.get("revision", 0):
|
|
160
|
+
raise Fail(2, f"The batch was written against revision {based}; the context is at revision {plan.get('revision', 0)}. Read {CONTEXT_FILE} again and rewrite it.",
|
|
161
|
+
{"basedOn": based, "planRevision": plan.get("revision", 0)})
|
|
162
|
+
actions = {a.get("id"): a.get("args") or {} for a in ctx.get("actions") or [] if isinstance(a, dict)}
|
|
163
|
+
bad, seen = [], set()
|
|
164
|
+
changes = batch.get("changes")
|
|
165
|
+
if not isinstance(changes, list) or not changes:
|
|
166
|
+
return ["The batch has no changes."]
|
|
167
|
+
for ci, c in enumerate(changes, 1):
|
|
168
|
+
cid = c.get("id") if isinstance(c, dict) else None
|
|
169
|
+
at = f"change {cid or ci}"
|
|
170
|
+
if not isinstance(cid, str) or not CHANGE_ID.match(cid):
|
|
171
|
+
bad.append(f"{at}: every change needs an id (letters, digits, . _ and -).")
|
|
172
|
+
continue
|
|
173
|
+
if cid in seen:
|
|
174
|
+
bad.append(f"{at}: the id is used twice.")
|
|
175
|
+
seen.add(cid)
|
|
176
|
+
ops = c.get("ops")
|
|
177
|
+
if not isinstance(ops, list) or not ops:
|
|
178
|
+
bad.append(f"{at}: no ops.")
|
|
179
|
+
continue
|
|
180
|
+
if c.get("reason") is not None and not isinstance(c.get("reason"), str):
|
|
181
|
+
bad.append(f"{at}: reason is one line of text.")
|
|
182
|
+
if c.get("confidence") is not None and c.get("confidence") not in ("high", "low"):
|
|
183
|
+
bad.append(f'{at}: confidence is "high" or "low".')
|
|
184
|
+
for oi, o in enumerate(ops, 1):
|
|
185
|
+
where = f"{at}, op {oi}"
|
|
186
|
+
if not isinstance(o, dict) or o.get("op") not in OPS:
|
|
187
|
+
bad.append(f"{where}: op is one of {', '.join(sorted(OPS))}.")
|
|
188
|
+
continue
|
|
189
|
+
if o["op"] != "action":
|
|
190
|
+
continue
|
|
191
|
+
name, args = o.get("action"), o.get("args")
|
|
192
|
+
if name not in actions:
|
|
193
|
+
bad.append(f"{where}: {name!r} isn't an action in the context.")
|
|
194
|
+
continue
|
|
195
|
+
if not isinstance(args, dict):
|
|
196
|
+
bad.append(f"{where}: args must be an object.")
|
|
197
|
+
continue
|
|
198
|
+
spec = actions[name]
|
|
199
|
+
for k in args:
|
|
200
|
+
if k not in spec:
|
|
201
|
+
bad.append(f"{where}: {name} has no argument {k!r}.")
|
|
202
|
+
for k, t in spec.items():
|
|
203
|
+
if k not in args or (args[k] is None and t.endswith("?")):
|
|
204
|
+
if not t.endswith("?"):
|
|
205
|
+
bad.append(f"{where}: {name} needs {k}.")
|
|
206
|
+
continue
|
|
207
|
+
if not arg_ok(t, args[k]):
|
|
208
|
+
bad.append(f"{where}: {k} isn't a valid {'date (YYYY-MM-DD)' if t.startswith('iso') else t.rstrip('?')}.")
|
|
209
|
+
if name.endswith(".add") and "id" in spec and not (isinstance(args.get("id"), str) and args["id"]):
|
|
210
|
+
bad.append(f"{where}: give the new item an id (like risk-vendor-limits), so reading the file again can't add it twice.")
|
|
211
|
+
if str(name).startswith("jira."):
|
|
212
|
+
bad.extend(f"{where}: {x}" for x in check_jira_place(ctx.get("jiraSort") or {}, name, args))
|
|
213
|
+
return bad
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
JIRA_KEYS_MAX = 100
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def check_jira_place(sort: dict, name: str, args: dict) -> list:
|
|
220
|
+
"""A Jira placement against the context's sort list: what's waiting to be sorted, what's already on the plan and where
|
|
221
|
+
work can go. Mirrors the page, which checks again: already where the change puts it is fine; somewhere else isn't."""
|
|
222
|
+
up = lambda v: str(v or "").strip().upper()
|
|
223
|
+
pile = {up(x.get("key")): x for x in sort.get("issues") or [] if isinstance(x, dict)}
|
|
224
|
+
placed = {up(k): v for k, v in (sort.get("placed") or {}).items() if isinstance(v, dict)}
|
|
225
|
+
epics = {up(x.get("key")) for x in sort.get("epics") or [] if isinstance(x, dict)}
|
|
226
|
+
targets = sort.get("targets") or {}
|
|
227
|
+
lanes = {x.get("id") for x in targets.get("workstreams") or [] if isinstance(x, dict)}
|
|
228
|
+
bars = {x.get("id"): x for x in targets.get("initiatives") or [] if isinstance(x, dict)}
|
|
229
|
+
bad = []
|
|
230
|
+
if name in ("jira.epicLink", "jira.epicNew"):
|
|
231
|
+
epic = up(args.get("epic"))
|
|
232
|
+
if epic not in epics:
|
|
233
|
+
bad.append(f"{epic[:40] or 'epic'} isn't an epic in jiraSort.epics.")
|
|
234
|
+
else:
|
|
235
|
+
keys = list(dict.fromkeys(up(k) for k in args.get("keys"))) if isinstance(args.get("keys"), list) else []
|
|
236
|
+
if not keys:
|
|
237
|
+
bad.append("list at least one Jira key in keys.")
|
|
238
|
+
if len(keys) > JIRA_KEYS_MAX:
|
|
239
|
+
bad.append(f"one change places up to {JIRA_KEYS_MAX} issues.")
|
|
240
|
+
dest = ("notOurs", None) if name == "jira.notOurs" else ("lane", args.get("lane")) if name == "jira.toWorkstream" else ("initiative", args.get("id"))
|
|
241
|
+
for k in keys:
|
|
242
|
+
if k in pile:
|
|
243
|
+
continue
|
|
244
|
+
w = placed.get(k)
|
|
245
|
+
if w is None:
|
|
246
|
+
bad.append(f"{k[:40]} isn't waiting to be sorted (it isn't in jiraSort.issues).")
|
|
247
|
+
elif not (w.get("kind") == "notOurs" if dest[0] == "notOurs" else w.get(dest[0]) == dest[1]):
|
|
248
|
+
bad.append(f"{k[:40]} is already on the plan somewhere else (jiraSort.placed).")
|
|
249
|
+
epic = up(args.get("epic"))
|
|
250
|
+
if epic and any(up(pile[k].get("epic")) != epic for k in keys if k in pile):
|
|
251
|
+
bad.append(f"every key in an epic group must be one of {epic[:40]}'s stories.")
|
|
252
|
+
if name in ("jira.toWorkstream", "jira.epicNew") and args.get("lane") not in lanes:
|
|
253
|
+
bad.append(f"{str(args.get('lane'))[:40]} isn't a workstream in jiraSort.targets.")
|
|
254
|
+
if name in ("jira.toInitiative", "jira.epicLink") and args.get("id") not in bars:
|
|
255
|
+
bad.append(f"{str(args.get('id'))[:40]} isn't an initiative that takes Jira work (jiraSort.targets.initiatives).")
|
|
256
|
+
if name == "jira.epicLink" and args.get("id") in bars:
|
|
257
|
+
held = [up(e) for e in bars[args.get("id")].get("epics") or []]
|
|
258
|
+
if up(args.get("epic")) not in held and len(held) >= 50:
|
|
259
|
+
bad.append(f"{str(args.get('id'))[:40]} already has 50 epics, the most an initiative holds.")
|
|
260
|
+
return bad
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def write_atomic(folder: Path, name: str, text: str) -> None:
|
|
264
|
+
fd, tmp = tempfile.mkstemp(prefix=".sightline-", suffix=".tmp", dir=folder)
|
|
265
|
+
try:
|
|
266
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
267
|
+
f.write(text)
|
|
268
|
+
f.flush()
|
|
269
|
+
os.fsync(f.fileno())
|
|
270
|
+
os.replace(tmp, folder / name)
|
|
271
|
+
except BaseException:
|
|
272
|
+
try:
|
|
273
|
+
os.unlink(tmp)
|
|
274
|
+
except OSError:
|
|
275
|
+
pass
|
|
276
|
+
raise
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def send(folder: Path, batch_path: Path) -> dict:
|
|
280
|
+
st = heartbeat(folder)
|
|
281
|
+
try:
|
|
282
|
+
text = batch_path.read_text(encoding="utf-8")
|
|
283
|
+
batch = json.loads(text)
|
|
284
|
+
except (OSError, ValueError) as e:
|
|
285
|
+
raise Fail(1, f"Can't read the batch {batch_path.name}: {e}")
|
|
286
|
+
if len(text.encode("utf-8")) > AMENDMENTS_MAX:
|
|
287
|
+
raise Fail(1, f"The batch is over {AMENDMENTS_MAX // 1024} KB.")
|
|
288
|
+
check_batch(batch, st)
|
|
289
|
+
problems = check_offline(folder, batch)
|
|
290
|
+
if problems:
|
|
291
|
+
raise Fail(1, "The batch has problems; nothing was sent.", {"lines": problems})
|
|
292
|
+
write_atomic(folder, AMENDMENTS_FILE, text if text.endswith("\n") else text + "\n")
|
|
293
|
+
return {"sent": batch["batchId"], "state": st.get("state"), "autoApply": st.get("autoApply")}
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def outcome_of(results: dict) -> tuple[int, list, list]:
|
|
297
|
+
"""(exit code if finished else -1, finished lines, waiting lines) for the results of one batch."""
|
|
298
|
+
done, bad, stale, waiting = [], [], [], []
|
|
299
|
+
if results.get("inputError"):
|
|
300
|
+
return 1, [f"Sightline couldn't read the batch: {results['inputError']}"], []
|
|
301
|
+
for c in results.get("changes") or []:
|
|
302
|
+
cid, held = c.get("id"), c.get("held") or {}
|
|
303
|
+
why = " ".join(c.get("reasons") or []) or held.get("reason") or ""
|
|
304
|
+
if c.get("outcome") in DONE:
|
|
305
|
+
done.append(f"{cid}: {c.get('outcome')}")
|
|
306
|
+
elif c.get("outcome") == "skipped":
|
|
307
|
+
bad.append(f"{cid}: skipped by a person")
|
|
308
|
+
elif c.get("status") == "invalid":
|
|
309
|
+
bad.append(f"{cid}: refused: {why}")
|
|
310
|
+
elif c.get("status") == "conflicted":
|
|
311
|
+
bad.append(f"{cid}: changed since: {why}")
|
|
312
|
+
elif held.get("code") == "stale_context":
|
|
313
|
+
stale.append(f"{cid}: {held.get('reason')}")
|
|
314
|
+
elif held.get("code") in ("expired", "editing", "read_only", "paused"):
|
|
315
|
+
bad.append(f"{cid}: {held.get('code')}: {held.get('reason')}")
|
|
316
|
+
else:
|
|
317
|
+
waiting.append(f"{cid}: {held.get('reason') or 'waiting'}")
|
|
318
|
+
if stale:
|
|
319
|
+
return 2, stale + bad, waiting
|
|
320
|
+
if waiting:
|
|
321
|
+
return -1, done + bad, waiting
|
|
322
|
+
return (1 if bad else 0), done + bad, []
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def wait(folder: Path, batch_id: str, timeout: float, poll: float = 5.0) -> dict:
|
|
326
|
+
end = time.monotonic() + timeout
|
|
327
|
+
last_waiting: list = []
|
|
328
|
+
while True:
|
|
329
|
+
st = heartbeat(folder)
|
|
330
|
+
res = read_json(folder / RESULTS_FILE, "sightline-assistant-results")
|
|
331
|
+
if res and res.get("batchId") == batch_id:
|
|
332
|
+
code, lines, waiting = outcome_of(res)
|
|
333
|
+
if code >= 0:
|
|
334
|
+
if code == 0:
|
|
335
|
+
return {"batchId": batch_id, "lines": lines}
|
|
336
|
+
raise Fail(code, "Not everything in the batch applied.", {"batchId": batch_id, "lines": lines})
|
|
337
|
+
last_waiting = waiting
|
|
338
|
+
if time.monotonic() >= end:
|
|
339
|
+
if res is None or res.get("batchId") != batch_id:
|
|
340
|
+
raise Fail(3, f"The Sightline page is answering ({st.get('state')}) but hasn't picked up batch {batch_id}. Check that it was written to {AMENDMENTS_FILE} in this folder.")
|
|
341
|
+
raise Fail(1, f"Still waiting after {int(timeout)} seconds" + (" for a person to review it in Sightline (Changes)." if st.get("autoApply") == "review" else "."),
|
|
342
|
+
{"batchId": batch_id, "waiting": last_waiting})
|
|
343
|
+
time.sleep(poll)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def main(argv=None) -> int:
|
|
347
|
+
ap = argparse.ArgumentParser(description="Send an assistant's changes to Sightline and wait for the outcome.")
|
|
348
|
+
ap.add_argument("--json", action="store_true", help="print one JSON object")
|
|
349
|
+
ap.add_argument("--version", action="version", version=f"sightline-app.py for Sightline {PAGE_VERSION}" if PAGE_VERSION else "sightline-app.py (source copy, not stamped with a page version)")
|
|
350
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
351
|
+
after = argparse.ArgumentParser(add_help=False) # --json works after the command too, as the guide writes it
|
|
352
|
+
after.add_argument("--json", action="store_true", default=argparse.SUPPRESS, help="print one JSON object")
|
|
353
|
+
s = sub.add_parser("status", parents=[after], help="is a Sightline page answering for this folder?")
|
|
354
|
+
s.add_argument("folder")
|
|
355
|
+
s = sub.add_parser("check", parents=[after], help="check a batch against the context you read, offline; nothing is written")
|
|
356
|
+
s.add_argument("folder"); s.add_argument("batch")
|
|
357
|
+
s = sub.add_parser("send", parents=[after], help="check the heartbeat and context revision, then write the batch atomically")
|
|
358
|
+
s.add_argument("folder"); s.add_argument("batch")
|
|
359
|
+
s = sub.add_parser("wait", parents=[after], help="wait until every change in a batch has an outcome")
|
|
360
|
+
s.add_argument("folder"); s.add_argument("batchId"); s.add_argument("--timeout", type=float, default=180)
|
|
361
|
+
s = sub.add_parser("run", parents=[after], help="send, then wait")
|
|
362
|
+
s.add_argument("folder"); s.add_argument("batch"); s.add_argument("--timeout", type=float, default=180)
|
|
363
|
+
a = ap.parse_args(argv)
|
|
364
|
+
folder = Path(a.folder)
|
|
365
|
+
try:
|
|
366
|
+
if not folder.is_dir():
|
|
367
|
+
raise Fail(1, f"{a.folder} isn't a folder.")
|
|
368
|
+
if a.cmd == "status":
|
|
369
|
+
out = heartbeat(folder)
|
|
370
|
+
msg = f"Sightline is {out.get('state')} (heard {out['ageSeconds']} seconds ago); changes {'wait for review' if out.get('autoApply') == 'review' else 'apply on their own'}; plan revision {out.get('planRevision')}."
|
|
371
|
+
elif a.cmd == "check":
|
|
372
|
+
try:
|
|
373
|
+
batch = json.loads(Path(a.batch).read_text(encoding="utf-8"))
|
|
374
|
+
except (OSError, ValueError) as e:
|
|
375
|
+
raise Fail(1, f"Can't read the batch {Path(a.batch).name}: {e}")
|
|
376
|
+
if not isinstance(batch, dict):
|
|
377
|
+
raise Fail(1, "The batch must be a JSON object.")
|
|
378
|
+
problems = check_offline(folder, batch)
|
|
379
|
+
if problems:
|
|
380
|
+
raise Fail(1, f"{len(problems)} problem{'' if len(problems) == 1 else 's'} in the batch.", {"lines": problems})
|
|
381
|
+
out = {"checked": batch.get("batchId"), "changes": len(batch.get("changes") or [])}
|
|
382
|
+
msg = f"The batch looks right: {out['changes']} change{'' if out['changes'] == 1 else 's'}. Sightline checks it again when it applies."
|
|
383
|
+
elif a.cmd == "send":
|
|
384
|
+
out = send(folder, Path(a.batch))
|
|
385
|
+
msg = f"Sent {out['sent']}. Sightline is {out['state']}."
|
|
386
|
+
elif a.cmd == "wait":
|
|
387
|
+
out = wait(folder, a.batchId, a.timeout)
|
|
388
|
+
msg = "Applied:\n " + "\n ".join(out["lines"])
|
|
389
|
+
else:
|
|
390
|
+
sent = send(folder, Path(a.batch))
|
|
391
|
+
out = {**wait(folder, sent["sent"], a.timeout), **sent}
|
|
392
|
+
msg = "Applied:\n " + "\n ".join(out["lines"])
|
|
393
|
+
print(json.dumps({"ok": True, "matchesPage": PAGE_VERSION, **out}) if a.json else msg)
|
|
394
|
+
return 0
|
|
395
|
+
except Fail as e:
|
|
396
|
+
if a.json:
|
|
397
|
+
print(json.dumps({"ok": False, "exit": e.code, "error": e.message, "matchesPage": PAGE_VERSION, **e.detail}))
|
|
398
|
+
else:
|
|
399
|
+
print(e.message, file=sys.stderr)
|
|
400
|
+
for line in e.detail.get("lines", []) + e.detail.get("waiting", []):
|
|
401
|
+
print(" " + line, file=sys.stderr)
|
|
402
|
+
return e.code
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
if __name__ == "__main__":
|
|
406
|
+
sys.exit(main())
|