== sharing/watchdog.py
36 wall, steady, route = time.time(), time.monotonic(), default_route()
37 slept = abs((wall - self.wall) - (steady - self.steady)) > JUMP_SECONDS or wall - self.wall > SLEEP_GAP
38 rerouted = bool(route and self.route and route != self.route)
39 self.wall, self.steady, self.route = wall, steady, route or self.route
40 return slept or rerouted
43 record, moved = shares.record, self.moved()
44 if not wanted(record.root):
45 return
46 state = alerts(record.root)
47 if unusable := shares._unusable(tunler_status()):
48 alert_once(state, TUNLER_UNUSABLE, lambda: Messages(record, actor=SYSTEM).create("The tunnel cannot start", brief=unusable))
49 return
50 state.set(TUNLER_UNUSABLE, 0)
51 if state.get(SIGNED_OUT):
52 state.set(SIGNED_OUT, 0)
53 want(record.root, TUNNEL, UP, nonce=time.time())
54 if refused_address(log_file(record.root, TUNNEL)):
55 self.refused(shares, state)
56 return
57 state.set(ADDRESS_REFUSED, 0)
58 try:
59 answering = shares._answering(CHECK_SECONDS)
60 except Refused as error:
61 unreadable = str(error)
62 alert_once(state, SETTINGS_UNREADABLE, lambda: Messages(record, actor=SYSTEM).create(
65 return
66 state.set(SETTINGS_UNREADABLE, 0)
67 if answering:
68 for key in (MISSES, HOST_IS_DOWN, UNREACHABLE_SINCE, RESTARTS, READDRESSED):
69 state.set(key, 0)
70 return
71 state.set(UNREACHABLE_SINCE, float(state.get(UNREACHABLE_SINCE, 0)) or time.time())
72 misses = int(state.get(MISSES, 0)) + 1
73 state.set(MISSES, misses)
74 if not moved and not self.due(state, misses, record.root):
75 return
76 state.set(MISSES, 0)
77 if not moved and not reached(f"https://{shares._host()}/", CHECK_SECONDS):
78 alert_once(state, HOST_IS_DOWN, lambda: self.feature.to_primary(record, HOST_DOWN, host=shares._host()))
79 return
80 down = TUNNEL if serving(record.root) else SERVER
81 want(record.root, down, UP, nonce=time.time())
82 state.set(RESTARTED_AT, time.time())
83 state.set(RESTARTS, int(state.get(RESTARTS, 0)) + 1)
84 self.feature.to_primary(record, RESTARTED, misses=misses, part=PARTS[down])
87 record = shares.record
88 if state.get(READDRESSED):
89 alert_once(state, ADDRESS_REFUSED, lambda: Messages(record, actor=SYSTEM).create(
92 return
93 state.set(READDRESSED, time.time())
94 name = shares._readdress()
95 if relied_on(record):
96 Messages(record, actor=SYSTEM).create("The tunnel moved to a new address", brief=(
101 since = time.time() - float(state.get(RESTARTED_AT, 0))
102 if tunnel_holding(root):
103 return since >= HELD_RETRY
104 return misses >= MISSES_BEFORE_RESTART and since >= RESTART_GAPS[min(int(state.get(RESTARTS, 0)), len(RESTART_GAPS) - 1)]
108 if not state.get(key):
109 state.set(key, time.time())
110 alert()
114 kept, log = status(root, TUNNEL), log_file(root, TUNNEL)
115 fresh = time.time() - mtime(log) / 1e9 < HOLD_FOR
116 return fresh and held_by_server(log) and kept.state in (STARTING, READY) and alive(kept.pgid)
120 port = status(root, SERVER).port
121 return bool(port) and vouched(f"http://127.0.0.1:{port}/{HEALTH}", HEALTH_MARKER, CHECK_SECONDS)
== phone/controller.py
44 return "".join(letter for letter in code.upper() if letter in SHORT_LETTERS)
48 return hashlib.sha256(secret.encode()).hexdigest()
55 return [phone for phone in (self.load(row["n"]) for row in self.rows.summaries() if not row["deleted"]) if phone.connected]
58 return Phone(n=0, title="A phone", data={"environment": self.record.env, "key": key, "expires": expires})
62 self._untouched(data)
63 return super().create(title, abstract, brief, **data)
67 self._untouched(data)
68 return super().update(n, title, abstract, brief, outcome, **data)
71 if set(data) & set(KEPT):
72 raise Refused(f"a phone's {', '.join(sorted(set(data) & set(KEPT)))} are set only by scanning the code in the viewer's Connect your phone dialog")
76 if self.actor != USER:
77 raise Refused("only the user connects a phone, from the viewer's Connect your phone dialog")
78 if int(days) not in DAYS:
79 raise Refused(f"a phone stays connected for {', '.join(map(str, DAYS))} days, not {days}")
80 address = Shares(self.record, actor=SYSTEM)._address()
81 if not address:
82 raise Refused("a phone connects through the tunnel address, and this journal has none yet: log tunler in under Settings, Sharing, then connect again")
83 active = self._active()
84 if active is not None:
85 raise Refused(f"{active.title} is connected: stop that session first, one phone at a time")
86 for row in self.rows.summaries():
87 if not row.get("key") and row.get("code") and not row["completed"] and not row["deleted"]:
88 self.complete(row["n"], how="a newer code replaced it")
89 code = secrets.token_urlsafe(24)
90 short = "".join(secrets.choice(SHORT_LETTERS) for _ in range(SHORT_LENGTH))
91 made = super().create("A phone, not yet connected", environment=self.record.env, code=hashed(code), short=hashed(short),
93 return Code(n=made.n, link=f"https://{address}/p/#{code}", short=f"{short[:4]}-{short[4:]}", code_until=made.code_until, address=address)
96 now = time.time()
97 with self.record.locked(PROJECT):
98 given = {hashed(code), hashed(typed(code))}
99 found = next((row["n"] for row in self.rows.summaries() if given & {row.get("code"), row.get("short")} - {"", None}
101 if found is None:
102 self._missed()
103 return None
104 phone = self.load(found)
105 if phone.code_until < now or self._active() is not None:
106 return None
107 key = secrets.token_urlsafe(32)
108 named = titled(" ".join(str(device).split())[:DEVICE_LONGEST] or "A phone")
109 paired = super().update(phone.n, title=named, key=hashed(key), code="", short="", code_until=0, expires=now + phone.days * DAY, last_seen=now)
110 Notices(Record(self.record.root, paired.environment), actor=SYSTEM).create(
112 return paired, key
115 for row in self.rows.summaries():
116 if not row.get("code") or row["completed"] or row["deleted"]:
117 continue
118 tries = self.load(row["n"]).tries + 1
119 super().update(row["n"], tries=tries)
120 if tries >= MOST_TRIES:
121 self.complete(row["n"], how=f"{MOST_TRIES} wrong codes were tried, so this code no longer works")
124 now = time.time()
125 found = next((row["n"] for row in self.rows.summaries() if row.get("key") and row.get("expires", 0) > now and not row["completed"] and not row["delete
126 return self.load(found) if found else None
129 found = next((row["n"] for row in self.rows.summaries() if key and row.get("key") == hashed(key) and not row["deleted"]), None)
130 if found is None:
131 return None
132 phone = self.load(found)
133 if phone.connected and time.time() - phone.last_seen > SEEN_EVERY:
134 return super().update(phone.n, last_seen=time.time())
135 return phone
138 return Record(self.record.root if phone.journal is None else Path(phone.journal), phone.environment, memo=True)
141 return places(self.record.root)
144 found = next((place for place in self._places() if place.root == starting.journal), None)
145 if found is None or starting.environment not in found.environments:
146 raise Refused(f"no journal at {starting.journal!r} with an environment {starting.environment!r} on this machine")
147 if starting.environment in found.working:
148 raise Stale(f"an agent is already working in {starting.environment}")
149 Environments(Record(Path(found.root), MAIN), actor=USER).action("launch")(found.row(starting.environment), agent=starting.agent)
150 return self._switch(phone, starting)
153 found = next((place for place in self._places() if place.root == moving.journal), None)
154 if found is None or moving.environment not in found.environments:
155 raise Refused(f"no running journal at {moving.journal!r} with an environment {moving.environment!r} on this machine")
156 journal = None if Path(found.root) == self.record.root.resolve() else found.root
157 return super().update(phone.n, journal=journal, environment=moving.environment, picked={**phone.picked, found.root: time.time()})
160 return sorted(self._places(), key=lambda place: -phone.picked.get(place.root, 0.0))
163 unknown = [card for card in cards if card not in (*CARDS, WAITING_CARD)]
164 if unknown:
165 raise Refused(f"no home screen card called {', '.join(unknown)}")
166 return super().update(phone.n, home=list(dict.fromkeys(cards)))
169 return unpadded(Keys.kept(self.record.root).public)
172 if not allowed(endpoint):
173 raise Refused("a phone's notifications come only through Apple's, Google's, Mozilla's or Microsoft's push service")
174 return super().update(phone.n, push=endpoint, pushed=[item["ref"] for item in waiting(self._home(phone), phone)])
177 phone = self._active()
178 if phone is None or phone.push is None:
179 return
180 address = Shares(self.record, actor=SYSTEM)._address()
181 if not address:
182 return
183 owed = [item["ref"] for item in waiting(self._home(phone), phone)]
184 if set(owed) - set(phone.pushed):
185 send(Keys.kept(self.record.root), phone.push, f"https://{address}")
186 if owed != phone.pushed:
187 super().update(phone.n, pushed=owed)
190 now = time.time()
191 return [row for row in self.rows.summaries() if not row["completed"] and not row["deleted"]
196 return bool(Phones(Record(root, runtime.env(root)), actor=SYSTEM)._live())
200 return Phones(Record(root, runtime.env(root)), actor=SYSTEM)._active() is not None
204 Phones(shares.record, actor=SYSTEM)._notify()
== phone/feed.py
69 data = view["data"]
70 return cls(view["n"], data.get("thoughts") or [], data.get("cards") or [], data.get("subagent_rows") or [], data.get("skill_loads") or [],
74 return Mark(type=kind, n=f"{self.n}-{at}", ref=f"{kind}:{self.n}-{at}", who="agent", created=at, label=label,
78 return [
146 return kind != "message" or not (row.data.get("window") or row.data.get("acknowledgement"))
150 return AskQuestionsDetails.values(home).hold
154 environment = row.data.get("environment")
155 if environment in (phone.environment, None, ""):
156 return True
157 place = Environments(home, actor=SYSTEM).rows.by_title(environment)
158 return bool(place and place.helping and place.launched_from == phone.environment)
162 found = []
163 for kind in WAITED:
164 controller = CONTROLLERS[kind](home, actor=SYSTEM)
165 for n in [row["n"] for row in controller.rows.summaries() if not row["deleted"] and not row["completed"]]:
166 row = controller.load(n)
167 if reaches(home, phone, row) and owed(row):
168 found.append(Waiting(ref=row.ref, type=kind, n=row.n, title=row.title, created=row.created))
169 return found
173 posted = [entry(home, kind, row) for kind in POSTED for row in latest(home, kind, before)]
174 items = sorted((item for item in posted if item), key=lambda item: item["created"])[-FEED:]
175 found = faces(home, {item["ref"] for item in items})
176 items = [{**item, "reactions": found.get(item["ref"], [])} for item in items]
177 since = items[0]["created"] if items else before
178 shown = [mark for mark in marks(home, since) if mark["created"] < before]
179 tasks = task_names(home) if any(item["data"].get("sent_to") for item in items) else {}
180 items = sorted([*({**item, "to": tasks.get(item["data"].get("sent_to"))} for item in items), *shown], key=lambda item: item["created"])
181 return Feed(items=items, waiting=waiting(home, phone), agent=Agents(home, actor=SYSTEM).state(phone.environment), notices=notices(home),
186 plans = Plans(home, actor=SYSTEM)
187 plan = next(iter(plans._running()), None)
188 if plan is None:
189 return None
190 phases = [PlanPhase(title=formatted(phase[PHASE.title], home, VIEWER), checkpoint=bool(phase[PHASE.checkpoint]),
193 return PlanStrip(n=plan.n, title=formatted(plan.title, home, VIEWER), abstract=formatted(plan.abstract, home, VIEWER), status=plan.status,
198 agents = Agents(home, actor=SYSTEM)
199 rows = [shaped(agents.load(row["n"]), home, VIEWER) for row in agents.rows.summaries() if not row["deleted"] and row["updated"] >= since]
200 found = [made for row in rows if not row["data"].get("parent") for made in Session.from_view(row).marks()]
201 return [made for made in found if made["created"] >= since]
205 reactions = Reactions(home, actor=SYSTEM)
206 found: dict[str, list[dict]] = {}
207 for n in [row["n"] for row in reactions.rows.summaries() if not row["deleted"] and set(row.get("refs", [])) & refs]:
208 made = reactions.load(n)
209 for ref in set(made.refs) & refs:
210 found.setdefault(ref, []).append({"face": made.face, "who": made.author})
211 return found
215 rows = POSTED[kind](home, actor=SYSTEM)
216 loaded = (rows.load(row["n"]) for row in reversed(rows.rows.summaries()) if not row["deleted"])
217 return list(islice((row for row in loaded if row.created < before and in_feed(kind, row)), FEED))[::-1]
221 item = {**shaped(row, home, VIEWER), "who": row.author if kind != "question" and row.seen else "agent", "files": dict(row.files)}
222 if kind != "question":
223 return item
224 return {**item, "hold": hold(home)}
228 holder = Sessions(home.root).holder(environment)
229 row = Agents(home, actor=SYSTEM).rows.by_title(holder) if holder else None
230 summary = lately_summarized(home.root)
231 shared = dict(state=Agents(home, actor=SYSTEM).state(environment), prompt=prompt(summary, environment), auto=automatic(home), mode=mode_of(home),
233 if row is None:
234 return Running(paused=False, context=0, usage=[], **shared)
235 return Running(paused=bool(row.paused), context=int(row.context), usage=list(row.usage.get("windows", [])), **shared)
239 here = next((HelperSnapshot.from_payload(found) for found in summary["environments"] if found.get("name") == environment), HelperSnapshot())
240 return asked_permission(here)
244 environments = list(map(HelperSnapshot.from_payload, summary["helpers"]))
245 now = time.time()
246 found = []
247 for row in Helpers(home, actor=SYSTEM).all(completed=True, last=HELPERS_SHOWN):
248 environment = next((one for one in environments if one.owner == row.ref), HelperSnapshot())
249 agent = environment.agent
250 todo = helper_todo(home, environment)
251 state = helper_state(row, environment, now)
252 view = shaped(row, home, VIEWER)
253 file = Path(agent.file).name if agent.file else ""
254 doing = " ".join(part for part in (agent.tool, file) if part)
255 found.append({"n": row.n, "title": view["title"], "name": row.name, "provider": row.provider, "model": row.model,
261 return found
265 if not environment.todo:
266 return {}
267 record = Record(home.root, environment.name)
268 row = Todos(record, actor=SYSTEM).load(environment.todo)
269 view = shaped(row, record, VIEWER)
270 return {"n": row.n, "title": view["title"], "completed": row.completed}
274 found = []
275 for row in rows:
276 match row:
277 case {"running": True}:
278 state = "working"
279 case {"refusal": refusal} if refusal:
280 state = "refused"
281 case {"status": "stopped"}:
282 state = "stopped"
283 case _:
284 state = "finished"
285 found.append({**row, "state": state, "task": formatted(row.get("task", ""), home, VIEWER),
288 return found
292 row = Helpers(home, actor=SYSTEM).load(n)
293 if not reaches(home, phone, row):
294 raise Refused(f"helper {n} is not in this phone's environment")
295 summary = lately_summarized(home.root)
296 payload = next((found for found in summary["helpers"] if found["owner"] == row.ref), {})
297 environment = HelperSnapshot.from_payload(payload)
298 shown = next(found for found in helpers_of(home, summary) if found["n"] == n)
299 record = Record(home.root, row.environment)
300 agent = Agents(record, actor=SYSTEM).primary()
301 activity = [] if agent is None else Session.from_view(shaped(agent, record, VIEWER)).marks()[-5:][::-1]
302 return {**shown, "activity": activity, "running": bool(environment.agent.status and environment.agent.status != "stopped")}
306 return {sub["task_id"]: sub["task"] for row in Agents(home, actor=SYSTEM).rows.standing() for sub in row.data.get("subagent_rows") or [] if sub.get("t
310 return [shaped(row, home, VIEWER) for row in Notices(home, actor=SYSTEM).rows.standing() if not row.data.get("agent")]
== plans/handlers.py
29 speaking = context.to_primary()
30 if event.action == "created" and event.actor == USER and speaking:
31 plan = context.journal.get(Plans).load(event.n)
32 speaking.agent.say("started", n=plan.n, title=plan.title, depth=DEPTHS[plan.depth])
37 speaking = context.to_primary()
38 if event.action != "updated" or event.actor not in (USER, SYSTEM) or not speaking:
39 return
40 plan = context.journal.get(Plans).load(event.n)
41 if plan.status == APPROVED and speaking.once("approved", str(plan.n)):
42 speaking.agent.say("approved", n=plan.n, title=plan.title)
47 speaking = context.to_primary()
48 if event.action != "updated" or event.actor != USER or event.status not in (ACTIVE, PARKED) or event.parked_for or not speaking:
49 return
50 plan = context.journal.get(Plans).load(event.n)
51 line = "picked up" if event.status == ACTIVE else "parked"
52 speaking.agent.say(line, n=plan.n, title=plan.title, phase=plan.current)
57 if not (event.written or event.action == "linked") or event.actor != AGENT:
58 return
59 plan = context.journal.get(Plans).load(event.n)
60 speaking = context.to_primary()
61 if plan.status != BUILDING or not speaking:
62 return
63 stage = plan.stage or PHASES
64 filled = bool(plan.phases) and not plan.empty_phases()
65 line = "ready" if stage != PHASES and filled else stage
66 if speaking.once("planned", f"{plan.n}:{line}"):
67 speaking.agent.say(line, n=plan.n)
72 if not passes_checkpoints(context.record):
73 return
74 plans = context.journal.get(Plans)
75 for plan in plans.rows.every():
76 if plan.status == WAITING:
77 plans.resume(plan.n)
82 if not context.journal.get(Todos).load(event.n).data.get("struck"):
83 return
84 plans = context.journal.get(Plans)
85 for plan in plans.rows.every():
86 p = plan.phase_of("todo", event.n)
87 if p and plan.status in (BUILDING, DRAFT, READY, REVIEWING):
88 plans.place(plan.n, p, [event.n], off=True)
94 Plans(context.record, actor=SYSTEM)._catch_up()
104 if event.type not in PHASE_FIELDS:
105 return
106 plans = Plans(context.record, actor=SYSTEM)
107 for plan in plans.rows.every():
108 found = plan.phase_of(event.type, event.n)
109 if not found or plan.status == ABANDONED:
110 continue
111 if not (plan.completed or (plan.status in RUNNING and plan.current > found)):
112 continue
113 plan = plans.reopen(plan.n, why=f"{event.type} {event.n} of phase {found} was reopened") if plan.completed else plans.load(plan.n)
114 plan.status, plan.current = ACTIVE, found
115 plans.save(plan, "updated", phase=found, status=ACTIVE)
119 quiet = agent.idle_for
120 if quiet < float(context.feature.cadence(context.record, "still").every) * MINUTE or carried_on(context.record):
121 return []
122 found = [(plan, doable(context.record, plan)) for plan in Plans(context.record, actor=SYSTEM)._active()]
123 return [Sent(f"{plan.n}:{plan.updated}", {"n": plan.n, "title": plan.title, "minutes": int(quiet // MINUTE), "rows": rows}) for plan, rows in found if
127 phase = plan.current_phase
128 if phase is None:
129 return ""
130 mine = set(phase[PHASE.todos])
131 going = [f"to-do {w.todo}" for w in Works(record, actor=SYSTEM).rows.standing() if int(w.todo) in mine and not w.parked and not w.awaiting]
132 taking = [f"to-do {t.n}" for t in ready(record) if t.n in mine]
133 parts = []
134 if going:
135 parts.append(f"go on with {', '.join(going)}")
136 if taking:
137 parts.append(f"take {', '.join(taking)}")
138 return ", then ".join(parts)
142 found = []
143 for plan in Plans(context.record, actor=SYSTEM)._active():
144 held = waiting_rows(context.record, {n for phase in plan.phases for n in phase[PHASE.todos]})
145 if held:
146 found.append(Sent(str(plan.n), {"n": plan.n, "title": plan.title, "rows": named_rows(held)}))
147 return found
158 plans = context.journal.acting(SYSTEM).get(Plans)
159 plan = plans._under_review(event.to)
160 if not plan:
161 return
162 plans.build(plan.n)
163 speaking = context.to_primary()
164 if speaking:
165 speaking.agent.say("reviewed", n=plan.n, title=plan.title, report=event.n)
== plans/controller.py
42 return cls(at, kind, todo.n, todo.title, text)
46 stamp = LOGGED.search(part[SECTION.title])
47 return time.mktime(time.strptime(stamp[0], "%Y-%m-%d %H:%M")) if stamp else fallback
51 if last:
52 return DONE
53 if waits:
54 return WAITING
55 return ACTIVE
62 return r.status in ENDED
66 if data.get("depth", "normal") not in DEPTHS:
67 self._refuse(f"a plan's depth is {' or '.join(DEPTHS)}")
68 return super().create(title, abstract, brief, status=BUILDING, stage=PHASES, phases=[], current=1, **data)
72 source = Docs(self.record, actor=self.actor).load(doc)
73 must = next((s[SECTION.body] for s in source.sections if s[SECTION.title].lower() == MUST_HAVE.lower()), "")
74 brief = (f"Built from the functional design, doc {source.n}. Every row names the must-have points it covers, and the plan is "
76 plan = self.create(source.title, brief=brief, goal=source.abstract or source.title)
77 if must:
78 self.section(plan.n, MUST_HAVE, must)
79 for s in source.sections:
80 if s[SECTION.title].lower().startswith("phase"):
81 self.phase(plan.n, s[SECTION.title].split(":", 1)[-1].split("—", 1)[-1].strip() or s[SECTION.title], brief=s[SECTION.body])
82 return self.link(plan.n, source.ref)
86 if at not in STAGES:
87 raise Refused(f"a plan is written in stages: {' or '.join(STAGES)}")
88 return self.update(n, stage=at)
92 return self.load(n).phases
95 return self.phase(n, CHECKPOINT.sub("", title), when=body, checkpoint=bool(CHECKPOINT.search(title)))
99 r = self.load(n)
100 if before and not 1 <= int(before) <= len(r.phases) + 1:
101 raise Refused(f"plan {r.n} has no phase {before}")
102 made = {PHASE.title: check_title(title), PHASE.when: check_title(when) if when is not None else "", PHASE.checkpoint: bool(checkpoint), PHASE.brief: b
103 r.phases.insert(int(before) - 1 if before else len(r.phases), made)
104 return self.save(r, "updated", phase=r.phases.index(made) + 1)
108 r = self.load(n)
109 phase = self._phase(r, p)
110 for key, value in ((PHASE.title, title), (PHASE.when, when), (PHASE.checkpoint, checkpoint), (PHASE.brief, brief)):
111 if value is not None:
112 phase[key] = check_title(value) if key in (PHASE.title, PHASE.when) and value else value
113 return self.save(r, "updated", phase=int(p))
117 return self._placed(n, p, "todo", todos, move, off)
121 return self._placed(n, p, "ticket", tickets, move, off)
124 r = self.load(n)
125 phase = self._phase(r, p)
126 key = PHASE_FIELDS[kind]
127 for ph in r.phases:
128 ph.setdefault(key, [])
129 for t in (int(x) for x in numbers):
130 elsewhere = r.phase_of(kind, t)
131 if off:
132 phase[key] = [x for x in phase[key] if x != t]
133 continue
134 if elsewhere and elsewhere != int(p) and not move:
135 self._refuse(f"{kind} {t} already sits in phase {elsewhere} of plan {n}; --move takes it out of there")
136 for ph in r.phases:
137 ph[key] = [x for x in ph[key] if x != t]
138 phase[key].append(t)
139 for t in numbers:
140 ref = f"{kind}:{int(t)}"
141 r.refs = [x for x in r.refs if x != ref] if off else r.refs + [ref] * (ref not in r.refs)
142 return self.save(r, "linked", phase=int(p), **{key: [int(t) for t in numbers]}, off=off)
146 return self._status(self.load(n), BUILDING, READY, DRAFT, REVIEWING)
150 return self._status(self.load(n), REVIEWING, BUILDING, DRAFT, READY)
153 kind, _, n = ref.partition(":")
154 plan = self.load(n) if kind == "plan" and n.isdigit() else None
155 return plan if plan and plan.status == REVIEWING else None
159 r = self.load(n)
160 empty = r.empty_phases()
161 if empty:
162 self._refuse(f"plan {n} cannot be ready: phase {empty[0]} has no to-dos or tickets")
163 return self._status(r, READY, BUILDING, DRAFT)
167 self._user_only("approve")
168 r = self.load(n)
169 if r.status == REVIEWING:
170 self._refuse(f"plan {n} is under review: its reviewers' report comes first")
171 return self._status(r, APPROVED, DRAFT, READY)
175 r = self.load(n)
176 if r.status in (DRAFT, READY, REVIEWING):
177 self._refuse(f"plan {n} waits for the user to approve it")
178 self._allowed(r, ACTIVE, APPROVED, PARKED)
179 first_start = r.status == APPROVED
180 for other in self.rows.every():
181 if other.n != r.n and other.status in RUNNING:
182 self._status(self.load(other.n), PARKED, *RUNNING, parked_for=r.n)
183 started = self._status(r, ACTIVE, APPROVED, PARKED)
184 if first_start:
185 for begin in PLAN_STARTS.each(self.record):
186 begin(self.record, started)
187 self._start_phase(started)
188 return started
192 return self.update(int(n), dismissed=True)
196 return self._status(self.load(n), PARKED, APPROVED, *RUNNING)
200 self._user_only("continue")
201 r = self.load(n)
202 r.current += 1
203 return self._status(r, ACTIVE, WAITING)
207 plan = self._status(self.load(n), ABANDONED, BUILDING, DRAFT, READY, REVIEWING, APPROVED, ACTIVE, WAITING, PARKED, why=why)
208 return self.complete(plan.n, how=why if why else "abandoned")
212 r = self.load(n)
213 lines = [f"plan {r.n}, {r.title}: {r.status}, phase {r.current} of {len(r.phases)}"]
214 for i, phase in enumerate(r.phases, 1):
215 rows = self._members(phase)
216 closed = sum(1 for row in rows if row.completed)
217 lines.append(f"{'now ' if i == r.current else ''}phase {i}, {phase[PHASE.title]}: {closed} of {len(rows)} done")
218 if i == r.current:
219 lines += [f"  {row.type} {row.n} {row.title}: {'done' if row.completed else row.data.get('stage') or row.data.get('status') or 'open'}"
221 lines += [f"lately: {moment['kind']} to-do {moment['todo']}, {moment['title']}" for moment in self.timeline(r.n)[-3:]]
222 return "\n".join(lines)
226 r = self.load(n)
227 numbers = {t for phase in r.phases for t in phase[PHASE.todos]}
228 todos = {t.n: t for t in map(Todos(self.record, actor=self.actor).load, numbers)}
229 works = [Works(self.record, actor=self.actor).load(row["n"]) for row in Works(self.record, actor=self.actor).rows.summaries()
231 items = [Moment.of(t, t.completed, "done", t.outcome) for t in todos.values() if t.completed]
232 for work in works:
233 todo = todos[work.data["todo"]]
234 items.append(Moment.of(todo, work.created, "started", work.title))
235 items += [Moment.of(todo, logged_at(part, work.created), "log", part[SECTION.body]) for part in work.sections]
236 if work.completed:
237 items.append(Moment.of(todo, work.completed, "ended", work.outcome))
238 return [asdict(item) for item in sorted(items, key=lambda item: item.at)]
241 self._allowed(r, to, *allowed)
242 r.status = to
243 return self.save(r, "updated", status=to, **event)
246 if r.status not in allowed:
247 raise Refused(f"plan {r.n} is {r.status}, not one that can become {to}")
250 if not 1 <= int(p) <= len(r.phases):
251 raise Refused(f"plan {r.n} has no phase {p}")
252 return r.phases[int(p) - 1]
255 return [p for p in self.rows.every() if p.status in RUNNING]
258 return [p for p in self.rows.every() if p.status == ACTIVE]
261 found = []
262 for key, kind in {PHASE.todos: Todos, **PHASE_ROWS.keyed()}.items():
263 rows = kind(self.record, actor=self.actor)
264 found += [rows.load(n) for n in phase.get(key, []) if rows.rows.exists(int(n))]
265 return found
268 return all(row.completed for row in self._members(phase))
271 plan = self.load(found.n)
272 phase = plan.current_phase
273 return plan.status == ACTIVE and phase is not None and found.phase > plan.current and self._only_waiting(phase)
276 open_rows = [row for row in self._members(phase) if not row.completed]
277 return bool(open_rows) and all(row.type == "todo" and row.blocked for row in open_rows)
280 return next((p for p, phase in enumerate(plan.phases, 1) if not self._complete(phase)), 0)
286 return all(found.holds and not self._opened_early(found) for found in placements)
290 for start in PHASE_STARTS.each(self.record):
291 start(self.record, plan)
294 phase = plan.current_phase
295 if plan.status != ACTIVE or phase is None or not self._complete(phase):
296 return False
297 i = plan.current
298 last = i == len(plan.phases)
299 waits = bool(phase[PHASE.checkpoint]) and not passes_checkpoints(self.record)
300 plan.status = status_after(last, waits)
301 plan.current = i if last or waits else i + 1
302 self.save(plan, "updated", phase=i, complete=True, status=plan.status, passed=bool(phase[PHASE.checkpoint]) and not waits)
303 if last:
304 self.complete(plan.n, how="every row in every phase is done")
305 elif not waits:
306 self._start_phase(plan)
307 return not (last or waits)
310 for running in self._running():
311 plan = self.load(running.n)
312 while self._step(plan):
313 pass
316 if self.actor == AGENT:
317 self._refuse(f"only the user can {word} a plan: they do it in the viewer{self._orchestrator_route(word)}")
320 place = Environments(self.record, actor=SYSTEM).rows.by_title(self.record.env)
321 n = place.owned_by("ticket") if place else 0
322 if not n:
323 return ""
324 return f". This is the plan of ticket {n}: the agent orchestrating its board does it with journal ticket {word}_plan {n}"
