@andespindola/crow-skills 0.8.0 → 0.10.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/README.md +3 -3
- package/crow_skills/__init__.py +1 -1
- package/crow_skills/broadcast.py +987 -0
- package/crow_skills/cli.py +176 -0
- package/crow_skills/events.py +64 -3
- package/crow_skills/hook.py +313 -14
- package/crow_skills/integrations.py +90 -22
- package/crow_skills/progress.py +55 -5
- package/crow_skills/resources.py +713 -0
- package/docs/cli.md +66 -6
- package/package.json +1 -1
- package/release.json +50 -30
- package/skills/crow-accessibility/SKILL.md +2 -0
- package/skills/crow-api-test/SKILL.md +2 -0
- package/skills/crow-backend/SKILL.md +2 -0
- package/skills/crow-behaviour/SKILL.md +2 -0
- package/skills/crow-behaviour/references/broadcast.md +45 -0
- package/skills/crow-behaviour/references/orchestration.md +6 -0
- package/skills/crow-behaviour/references/progress-ledger.md +3 -0
- package/skills/crow-behaviour/references/resources.md +44 -0
- package/skills/crow-behaviour/scripts/crow_ledger.py +75 -2
- package/skills/crow-browser/SKILL.md +2 -0
- package/skills/crow-debug/SKILL.md +2 -0
- package/skills/crow-frontend/SKILL.md +2 -0
- package/skills/crow-infra/SKILL.md +2 -0
- package/skills/crow-interface/SKILL.md +2 -0
- package/skills/crow-media/SKILL.md +2 -0
- package/skills/crow-native-mobile/SKILL.md +2 -0
- package/skills/crow-performance/SKILL.md +2 -0
- package/skills/crow-qa/SKILL.md +2 -0
- package/skills/crow-release/SKILL.md +2 -0
- package/skills/crow-reverse/SKILL.md +2 -0
- package/skills/crow-security-scan/SKILL.md +2 -0
package/crow_skills/cli.py
CHANGED
|
@@ -7,11 +7,13 @@ import sys
|
|
|
7
7
|
|
|
8
8
|
from . import __version__
|
|
9
9
|
from . import autoupdate
|
|
10
|
+
from . import broadcast
|
|
10
11
|
from . import events
|
|
11
12
|
from . import guide
|
|
12
13
|
from . import hook
|
|
13
14
|
from . import integrations
|
|
14
15
|
from . import progress
|
|
16
|
+
from . import resources
|
|
15
17
|
from .agents import AGENTS, destinations, inventory
|
|
16
18
|
from .source import CrowError, Source, valid_name
|
|
17
19
|
from .storage import Store, checked
|
|
@@ -77,6 +79,23 @@ def parser():
|
|
|
77
79
|
child.add_argument("action", nargs="?", default="snapshot", choices=("snapshot", "tail"))
|
|
78
80
|
child.add_argument("--limit", type=int, default=50, help="tail: quantos eventos (padrão 50; 0 = todos os retidos)")
|
|
79
81
|
child.add_argument("--follow", action="store_true", help="tail: transmitir eventos novos em JSONL até Ctrl-C")
|
|
82
|
+
child = sub.add_parser("broadcast", parents=[common],
|
|
83
|
+
help="crow-broadcast: sessões vivas dos CLIs, arquivos em edição, conflitos e reservas")
|
|
84
|
+
child.add_argument("action", nargs="?", default="status", choices=("status", "list", "claim", "release", "watch"))
|
|
85
|
+
child.add_argument("targets", nargs="*", help="claim: caminhos, globs ou área; release: ids das reservas")
|
|
86
|
+
child.add_argument("--ttl", default="30m", help="claim: validade (ex.: 30m, 2h; máximo 24h)")
|
|
87
|
+
child.add_argument("--note", help="claim: nota curta (o que será feito)")
|
|
88
|
+
child.add_argument("--session", help="claim/release/status: id da sessão dona; padrão: detectada pelo processo pai")
|
|
89
|
+
child.add_argument("--host", help="claim/release/status: CLI da sessão (claude, codex, gemini, agy, devin)")
|
|
90
|
+
child.add_argument("--cwd", help="Diretório de referência; padrão: o atual")
|
|
91
|
+
child.add_argument("--force", action="store_true",
|
|
92
|
+
help="claim: registrar mesmo com reserva conflitante; release: liberar reserva de outra sessão (decisão do usuário)")
|
|
93
|
+
child.add_argument("--all", action="store_true", help="release: todas as reservas desta sessão")
|
|
94
|
+
child.add_argument("--interval", type=float, default=2.0, help=argparse.SUPPRESS)
|
|
95
|
+
child.add_argument("--max-frames", type=int, default=None, help=argparse.SUPPRESS)
|
|
96
|
+
child = sub.add_parser("resources", parents=[common],
|
|
97
|
+
help="Memória, pressão, agentes em execução e paralelismo recomendado (somente leitura)")
|
|
98
|
+
child.add_argument("--no-processes", action="store_true", help="Não varrer os processos dos CLIs de agente (mais rápido)")
|
|
80
99
|
child = sub.add_parser("autoupdate", parents=[common], help="Atualização diária e no login do crow-skills e das skills gerenciadas")
|
|
81
100
|
child.add_argument("action", choices=("on", "off", "status", "run"))
|
|
82
101
|
child.add_argument("--git-pull", action="store_true", help="on: também atualizar um checkout git limpo em main/master (git pull --ff-only)")
|
|
@@ -357,6 +376,146 @@ def events_command(args, home, store):
|
|
|
357
376
|
return data
|
|
358
377
|
|
|
359
378
|
|
|
379
|
+
def ancestors(limit=16):
|
|
380
|
+
"""Process ids above this CLI (best effort): the agent CLI that runs it is one of them."""
|
|
381
|
+
pids, pid = [], os.getpid()
|
|
382
|
+
for _ in range(limit):
|
|
383
|
+
parent = None
|
|
384
|
+
try:
|
|
385
|
+
with open("/proc/" + str(pid) + "/stat", "rb") as handle:
|
|
386
|
+
parent = int(handle.read().rsplit(b")", 1)[1].split()[1])
|
|
387
|
+
except (OSError, ValueError, IndexError):
|
|
388
|
+
try:
|
|
389
|
+
import subprocess
|
|
390
|
+
done = subprocess.run(["ps", "-o", "ppid=", "-p", str(pid)], capture_output=True, text=True, timeout=2)
|
|
391
|
+
parent = int(done.stdout.strip() or 0)
|
|
392
|
+
except (OSError, ValueError, subprocess.SubprocessError):
|
|
393
|
+
parent = None
|
|
394
|
+
if not parent or parent <= 1 or parent in pids:
|
|
395
|
+
break
|
|
396
|
+
pids.append(parent)
|
|
397
|
+
pid = parent
|
|
398
|
+
return pids
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def broadcast_owner(args, home, cwd):
|
|
402
|
+
"""The session that runs this command: --session/--host, else the live session whose CLI pid is an ancestor."""
|
|
403
|
+
live = broadcast.live_sessions(home)
|
|
404
|
+
host = getattr(args, "host", None)
|
|
405
|
+
if host and host not in hook.HOSTS:
|
|
406
|
+
raise CrowError("usage", "--host deve ser um de: " + ", ".join(hook.HOSTS))
|
|
407
|
+
if getattr(args, "session", None):
|
|
408
|
+
session = hook.token(args.session, 128)
|
|
409
|
+
matches = [s for s in live if s.get("session") == session and (not host or s.get("host") == host)]
|
|
410
|
+
if matches:
|
|
411
|
+
s = matches[0]
|
|
412
|
+
return {"key": s["key"], "host": s.get("host"), "session": s.get("session"), "detected": "session"}
|
|
413
|
+
if not host:
|
|
414
|
+
raise CrowError("not_found", "Sessão " + str(session) + " não está viva no broadcast; informe também --host.")
|
|
415
|
+
return {"key": broadcast.session_key(host, session, None), "host": host, "session": session, "detected": "session"}
|
|
416
|
+
chain = ancestors()
|
|
417
|
+
for s in live:
|
|
418
|
+
pid = broadcast.presence_of(s).get("pid")
|
|
419
|
+
if pid and pid in chain and (not host or s.get("host") == host):
|
|
420
|
+
return {"key": s["key"], "host": s.get("host"), "session": s.get("session"), "detected": "pid"}
|
|
421
|
+
return None
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def broadcast_text(view, full=False):
|
|
425
|
+
me = view.get("me") or {}
|
|
426
|
+
repo = me.get("repo") or {}
|
|
427
|
+
lines = ["crow-broadcast · " + str(len(view["sessions"])) + " outra(s) sessão(ões) viva(s) · " + str(len(view["claims"])) +
|
|
428
|
+
" reserva(s)"]
|
|
429
|
+
if me:
|
|
430
|
+
lines.append("aqui: " + (repo.get("remote") or repo.get("root") or me.get("cwd") or "?") +
|
|
431
|
+
(" · branch " + str(me.get("branch")) if me.get("branch") else "") +
|
|
432
|
+
(" · sessão " + str(view.get("owner", {}).get("host")) if view.get("owner") else ""))
|
|
433
|
+
shown = view["sessions"] if full else broadcast.relevant(view)
|
|
434
|
+
now = broadcast.parse_time(view["generatedAt"]) or 0
|
|
435
|
+
for s in shown:
|
|
436
|
+
lines += broadcast.session_lines(s, view["_home"], now)
|
|
437
|
+
if not full and len(shown) < len(view["sessions"]):
|
|
438
|
+
lines.append("(+" + str(len(view["sessions"]) - len(shown)) + " em outros repositórios; crow-skills broadcast list)")
|
|
439
|
+
live = {s.get("key"): s for s in view["_live"]}
|
|
440
|
+
for claim in view["claims"]:
|
|
441
|
+
lines.append(broadcast.claim_line(claim, live, now))
|
|
442
|
+
if not shown and not view["claims"]:
|
|
443
|
+
lines.append("Nenhuma outra sessão viva neste repositório." if not full else "Nenhuma outra sessão viva.")
|
|
444
|
+
return "\n".join(lines)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def broadcast_view(args, home, owner=None):
|
|
448
|
+
cwd = os.path.abspath(getattr(args, "cwd", None) or os.getcwd())
|
|
449
|
+
owner = owner if owner is not None else broadcast_owner(args, home, cwd)
|
|
450
|
+
me_key = owner["key"] if owner else None
|
|
451
|
+
view = broadcast.overview(home, me_key, cwd=cwd)
|
|
452
|
+
if view["me"] is not None and view["me"].get("cwd") is None:
|
|
453
|
+
view["me"]["cwd"] = cwd
|
|
454
|
+
view["owner"] = owner
|
|
455
|
+
return view
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def public(view):
|
|
459
|
+
return {k: v for k, v in view.items() if not k.startswith("_")}
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def broadcast_command(args, home):
|
|
463
|
+
action = args.action
|
|
464
|
+
cwd = os.path.abspath(getattr(args, "cwd", None) or os.getcwd())
|
|
465
|
+
if action in ("status", "list"):
|
|
466
|
+
view = broadcast_view(args, home)
|
|
467
|
+
view["_home"] = home
|
|
468
|
+
text = broadcast_text(view, full=action == "list")
|
|
469
|
+
data = public(view)
|
|
470
|
+
if action == "status":
|
|
471
|
+
data["relevant"] = [s["key"] for s in broadcast.relevant(view)]
|
|
472
|
+
data["text"] = text
|
|
473
|
+
return data
|
|
474
|
+
if action == "watch":
|
|
475
|
+
if getattr(args, "json", False):
|
|
476
|
+
raise CrowError("usage", "watch redesenha em texto; para JSON use status --json em polling.")
|
|
477
|
+
frames = 0
|
|
478
|
+
try:
|
|
479
|
+
while True:
|
|
480
|
+
view = broadcast_view(args, home)
|
|
481
|
+
view["_home"] = home
|
|
482
|
+
sys.stdout.write("\x1b[H\x1b[2J" + broadcast_text(view) + "\n")
|
|
483
|
+
sys.stdout.flush()
|
|
484
|
+
frames += 1
|
|
485
|
+
if args.max_frames is not None and frames >= args.max_frames:
|
|
486
|
+
return None
|
|
487
|
+
import time as _time
|
|
488
|
+
_time.sleep(max(0.2, args.interval))
|
|
489
|
+
except KeyboardInterrupt:
|
|
490
|
+
return None
|
|
491
|
+
owner = broadcast_owner(args, home, cwd)
|
|
492
|
+
if action == "claim":
|
|
493
|
+
if not args.targets:
|
|
494
|
+
raise CrowError("usage", "Informe o que reservar: caminhos, globs ou área (ex.: crow-skills broadcast claim src/api --ttl 30m).")
|
|
495
|
+
try:
|
|
496
|
+
ttl = broadcast.parse_ttl(args.ttl)
|
|
497
|
+
except ValueError:
|
|
498
|
+
raise CrowError("usage", "--ttl inválido: use 30m, 2h, 90s (máximo 24h).") from None
|
|
499
|
+
try:
|
|
500
|
+
claim, conflicts = broadcast.add_claim(home, args.targets, ttl, owner=owner, cwd=cwd, note=args.note, force=args.force)
|
|
501
|
+
except ValueError:
|
|
502
|
+
raise CrowError("usage", "Nenhum alvo válido para reservar.") from None
|
|
503
|
+
if claim is None:
|
|
504
|
+
holders = "; ".join(broadcast.claim_line(c, {}, broadcast.time.time()).lstrip("- ") for c in conflicts[:3])
|
|
505
|
+
raise CrowError("claim_conflict", "Área já reservada por outra sessão viva: " + holders +
|
|
506
|
+
". Coordene com ela ou use --force se o usuário decidir seguir.")
|
|
507
|
+
return {"claim": claim, "owner": owner, "conflicts": conflicts,
|
|
508
|
+
"note": None if owner else "Sessão dona não detectada; a reserva expira só pelo TTL. Use --session/--host."}
|
|
509
|
+
if not owner and (args.all or not args.targets):
|
|
510
|
+
raise CrowError("usage", "Sessão dona não detectada: informe --session/--host (ou os ids das reservas).")
|
|
511
|
+
removed, refused = broadcast.release_claims(home, ids=set(args.targets), owner_key=owner["key"] if owner else None,
|
|
512
|
+
everything=args.all or not args.targets, force=args.force)
|
|
513
|
+
if refused and not removed:
|
|
514
|
+
raise CrowError("claim_owner", "Reserva(s) de outra sessão: " + ", ".join(refused) +
|
|
515
|
+
". Só a sessão dona libera; --force só por decisão do usuário.")
|
|
516
|
+
return {"released": [c["id"] for c in removed], "refused": refused, "owner": owner}
|
|
517
|
+
|
|
518
|
+
|
|
360
519
|
def autoupdate_command(args, home, store, interactive):
|
|
361
520
|
if args.action == "status":
|
|
362
521
|
return autoupdate.status(store, home)
|
|
@@ -460,6 +619,12 @@ def run(args):
|
|
|
460
619
|
return mode_command(args, home)
|
|
461
620
|
if args.command == "events":
|
|
462
621
|
return events_command(args, home, store)
|
|
622
|
+
if args.command == "broadcast":
|
|
623
|
+
return broadcast_command(args, home)
|
|
624
|
+
if args.command == "resources":
|
|
625
|
+
data = resources.probe(home, processes=not args.no_processes)
|
|
626
|
+
data["text"] = resources.render(data)
|
|
627
|
+
return data
|
|
463
628
|
if args.command == "autoupdate":
|
|
464
629
|
return autoupdate_command(args, home, store, interactive)
|
|
465
630
|
if args.command in ("status", "doctor"):
|
|
@@ -586,6 +751,17 @@ def emit(command, data, machine):
|
|
|
586
751
|
elif command == "progress":
|
|
587
752
|
if data is not None:
|
|
588
753
|
print(data["text"] or "crow: nenhuma execução registrada neste diretório.")
|
|
754
|
+
elif command == "resources":
|
|
755
|
+
print(data["text"])
|
|
756
|
+
elif command == "broadcast":
|
|
757
|
+
if data is not None:
|
|
758
|
+
if "text" in data:
|
|
759
|
+
print(data["text"])
|
|
760
|
+
elif "claim" in data:
|
|
761
|
+
print("reserva " + data["claim"]["id"] + ": " + ", ".join(data["claim"]["patterns"]) + " até " +
|
|
762
|
+
data["claim"]["expiresAt"] + ("" if data["owner"] else " (" + data["note"] + ")"))
|
|
763
|
+
else:
|
|
764
|
+
print("liberadas: " + (", ".join(data["released"]) or "nenhuma"))
|
|
589
765
|
elif command == "mode":
|
|
590
766
|
print(data["indicator"] + " — " + data["applies"])
|
|
591
767
|
elif command == "events":
|
package/crow_skills/events.py
CHANGED
|
@@ -11,7 +11,7 @@ import json
|
|
|
11
11
|
import sys
|
|
12
12
|
import time
|
|
13
13
|
|
|
14
|
-
from . import autoupdate, hook, progress
|
|
14
|
+
from . import autoupdate, broadcast, hook, progress, resources
|
|
15
15
|
from .source import CrowError
|
|
16
16
|
|
|
17
17
|
SNAPSHOT_SCHEMA = "crow-snapshot/1"
|
|
@@ -29,7 +29,7 @@ def ledger_runs(home, cwds):
|
|
|
29
29
|
runs.append({"id": run["id"], "title": run["title"], "host": run["host"], "cwd": run["cwd"], "status": run["status"],
|
|
30
30
|
"session": run["session"].get("id"), "path": run["path"], "updatedAt": run.get("updatedAt"),
|
|
31
31
|
"done": info["done"], "failed": info["failed"], "total": info["total"], "running": info["running"],
|
|
32
|
-
"progress": info["progress"],
|
|
32
|
+
"progress": info["progress"], "resources": run.get("resources"),
|
|
33
33
|
"agents": [{"owner": progress.owner_label(u["owner"]), "unit": u["id"], "activity": u["title"]}
|
|
34
34
|
for u in run["units"] if u["status"] == "running"]})
|
|
35
35
|
return runs
|
|
@@ -54,9 +54,58 @@ def snapshot(home, store):
|
|
|
54
54
|
except CrowError as exc:
|
|
55
55
|
update = {"enabled": None, "error": exc.code}
|
|
56
56
|
mode = hook.read_mode(home)
|
|
57
|
+
try:
|
|
58
|
+
machine = resources_field(home)
|
|
59
|
+
except Exception: # noqa: BLE001 - the snapshot never fails because of the probe
|
|
60
|
+
machine = None
|
|
61
|
+
try:
|
|
62
|
+
presence = broadcast_field(home)
|
|
63
|
+
except Exception: # noqa: BLE001 - the snapshot never fails because of the broadcast
|
|
64
|
+
presence = None
|
|
57
65
|
return {"schema": SNAPSHOT_SCHEMA, "generatedAt": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
58
66
|
"mode": {k: mode[k] for k in ("mode", "updatedAt", "updatedBy", "source")}, "indicator": hook.indicator(mode["mode"]),
|
|
59
|
-
"sessions": live, "runs": runs, "autoupdate": update}
|
|
67
|
+
"sessions": live, "runs": runs, "autoupdate": update, "resources": machine, "broadcast": presence}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def broadcast_field(home):
|
|
71
|
+
"""crow-broadcast/1 for terminals: live sessions grouped by repository, recent files, claims, conflicts.
|
|
72
|
+
|
|
73
|
+
Added in 0.10.0 without changing crow-snapshot/1 (consumers ignore unknown fields); the
|
|
74
|
+
field carries its own schema. ``conflicts`` lists files touched by two or more live
|
|
75
|
+
sessions within the conflict window and clones used by two or more live sessions.
|
|
76
|
+
"""
|
|
77
|
+
view = broadcast.overview(home)
|
|
78
|
+
window = view["config"]["conflict_window_minutes"] * 60
|
|
79
|
+
now = broadcast.parse_time(view["generatedAt"]) or 0
|
|
80
|
+
repos, files, clones = {}, {}, {}
|
|
81
|
+
for s in view["sessions"]:
|
|
82
|
+
repo = s.get("repo") or {}
|
|
83
|
+
if repo.get("id"):
|
|
84
|
+
repos.setdefault(repo["id"], {"id": repo["id"], "remote": repo.get("remote"), "sessions": []})["sessions"].append(s["key"])
|
|
85
|
+
clones.setdefault(repo.get("root"), []).append(s["key"])
|
|
86
|
+
for f in s["files"]:
|
|
87
|
+
if now - (broadcast.parse_time(f.get("ts")) or 0) <= window:
|
|
88
|
+
label = (f.get("repo") + ":" + f["rel"]) if f.get("repo") and f.get("rel") else f["path"]
|
|
89
|
+
keys = files.setdefault(label, [])
|
|
90
|
+
if s["key"] not in keys:
|
|
91
|
+
keys.append(s["key"])
|
|
92
|
+
conflicts = [{"kind": "file", "file": k, "sessions": v} for k, v in files.items() if len(v) > 1] + \
|
|
93
|
+
[{"kind": "clone", "root": k, "sessions": v} for k, v in clones.items() if k and len(v) > 1]
|
|
94
|
+
return {"schema": view["schema"], "generatedAt": view["generatedAt"], "config": view["config"],
|
|
95
|
+
"sessions": [{k: v for k, v in s.items() if k != "relation"} for s in view["sessions"]],
|
|
96
|
+
"repos": list(repos.values()), "claims": view["claims"], "conflicts": conflicts}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def resources_field(home):
|
|
100
|
+
"""crow-resources/1 subset for terminals: memory, pressure and the recommendation.
|
|
101
|
+
|
|
102
|
+
Added in 0.9.0 without changing crow-snapshot/1: consumers that do not know the
|
|
103
|
+
field ignore it; the field carries its own schema for future changes.
|
|
104
|
+
"""
|
|
105
|
+
data = resources.probe(home, processes=False, use_cache=True)
|
|
106
|
+
return {"schema": data["schema"], "generatedAt": data["generatedAt"], "cached": data["cached"],
|
|
107
|
+
"memory": data["memory"], "pressure": {"level": data["pressure"]["level"], "reasons": data["pressure"]["reasons"]},
|
|
108
|
+
"recommendation": data["recommendation"], "alert": resources.alert(data), "segment": resources.segment(data)}
|
|
60
109
|
|
|
61
110
|
|
|
62
111
|
def render(data, unicode=True):
|
|
@@ -75,6 +124,18 @@ def render(data, unicode=True):
|
|
|
75
124
|
unicode, counts=False)
|
|
76
125
|
lines.append(("✓ " if unicode else "ok ") + str(run["done"]) + "/" + str(run["total"]) + " " + run["title"])
|
|
77
126
|
update = data["autoupdate"]
|
|
127
|
+
machine = data.get("resources")
|
|
128
|
+
if machine and machine.get("alert"):
|
|
129
|
+
rec = machine["recommendation"]
|
|
130
|
+
lines.append("")
|
|
131
|
+
lines.append("memória: " + (machine["segment"] or "") + " · pressão " + machine["pressure"]["level"] + " · " + rec["mode"] +
|
|
132
|
+
" (até " + str(rec["maxParallelAgents"]) + " agente(s), " + str(rec["maxParallelHeavyJobs"]) + " job(s) pesado(s))")
|
|
133
|
+
shared = data.get("broadcast")
|
|
134
|
+
if shared and (shared.get("conflicts") or shared.get("claims")):
|
|
135
|
+
lines.append("")
|
|
136
|
+
lines.append("broadcast: " + str(len([c for c in shared["conflicts"] if c["kind"] == "file"])) + " arquivo(s) em mais de uma sessão · " +
|
|
137
|
+
str(len([c for c in shared["conflicts"] if c["kind"] == "clone"])) + " clone(s) compartilhado(s) · " +
|
|
138
|
+
str(len(shared["claims"])) + " reserva(s) (crow-skills broadcast list)")
|
|
78
139
|
lines.append("")
|
|
79
140
|
lines.append("autoupdate: " + ("ligado" if update.get("enabled") else "desligado") +
|
|
80
141
|
((" · última execução " + str(update["lastRun"]["startedAt"]) + (" ok" if update["lastRun"]["ok"] else " com falha"))
|