Commit c05e275
authored
feat(profiles): one server per purpose, with a Server panel you can read (SPEC-50) (#162)
* Say why the daemon would not start (SPEC-50 P0)
Every daemon start/stop/restart failure was invisible. Two defects stacked:
`makit start` reports why it failed on stdout, not stderr -- the daemon is
spawned detached with its output redirected into the log, so the parent's only
diagnostic is deps.out() -> console.log. The app read res.stderr alone, so a
real failure surfaced as the bare string "makit start exited 1: ".
Worse, DaemonActionResult was discarded by all four call sites, so even a
correct message went nowhere.
_failureMessage() now reads both streams (stderr first, it carries the
lower-level cause), dedupes, and drops the separator when both are empty so the
message can never end in a dangling colon. All four call sites post a
StatusCenter failure.
Proven on a real port collision: exit 1, reason on stdout, stderr empty.
Also adds SPEC-50 and its mockup (12 cards) as ground truth for the rest.
* Profiles: one server per purpose (SPEC-50 P1-P4)
A profile is now a thing the user owns rather than plumbing derived from a file
path: it can be named, listed, started, stopped, and deleted, and several run at
once. Proven live -- two daemons on 7861/7862 with separate MAKIT_HOMEs, each
minting its own TLS fingerprint, which is what makes per-profile QR pairing work.
Identity is persisted. ProfileRegistry mints the id once into
~/.makit/profiles.json and re-binds a rebuilt or moved dev build by its stored
origin. Before this, id = fnv1a(repoRoot) was recomputed every launch, so moving
a worktree minted a NEW profile and orphaned the old one's home, pairings,
projects and prefs. Measured on this machine: 27 of 33 dev homes (732 MB) were
already unreachable that way, each still holding a device pairing and a TLS
keypair. They are now listed, sized, and offered for bulk reclaim -- offered,
never reaped, because auto-deletion would also have destroyed transcripts the
first time a worktree moved.
isDefault split into name (a UI fact, editable) and storage (legacy|namespaced, a
compatibility fact, frozen). storage:legacy pins the shipped key layout and
implies protected, so ~/.makit is renameable yet undeletable and no shipped
user's prefs move -- the effective key stays byte-identical, asserted by test.
Ports are allocated by probing upward from the hash guess and persisted, skipping
ports other profiles claim, since a probe cannot see a stopped profile's port.
100 slots for unbounded worktrees made collisions inevitable and nothing
reallocated.
Server & Devices drops from ~10 controls to four rows: active profile, one
reachability question, pair-a-phone, and a Diagnostics disclosure holding
pid/CLI/fingerprint/log plus Advanced. The two-phase "Save & restart server" is
gone. Four exclusive bind modes become Reachability{thisMacOnly,myDevices} plus
allowLanFallback, because Auto and LAN were one behaviour wearing two labels:
chooseBindHost prefers Tailscale before it consults allowLan, so --lan is a
fallback. chooseBindHost is unchanged. Every persisted generation migrates, and
the newest wins when all three are present -- otherwise a stale bind mode would
re-open a server the user had restricted.
Deleting a profile erases all four stores (home dir, secure-store namespace,
prefs, registry entry last -- omit the entry and it resurrects empty) and says
what it KEEPS: worktrees and repos are never touched. It refuses rather than
throws, and the refusals are the interesting part. Three findings from review, all
reproduced before fixing:
- Orphan reclaim could never work. stopAndConfirm polled the control socket FILE,
but a SIGKILLed daemon never unlinks it and `makit stop` on a dead daemon
removes only the pid file -- verified against the real binary. So every crashed
profile looked alive forever, and exactly the orphans this feature exists to
reclaim were permanently undeletable. It now polls liveness.
- A rogue registry entry could erase the APNs key. The guard trusted the entry's
own storage flag, but profiles.json is user-writable. Fixed once, then defeated
again by a single trailing slash (~/.makit/ is not == to ~/.makit yet satisfies
a startsWith check). Paths are now canonicalised before any comparison, and a
fourth rule that mutation testing showed could never bite was deleted rather
than left as reassurance.
- Concurrent instances could lose a profile. save() now re-reads and merges by
id, remembers deletions so a merge cannot resurrect them, and uses a
pid-suffixed temp file.
profiles.json is written 0600 inside a 0700 directory, matching the guarantee the
server makes for a directory holding an APNs auth key and a TLS private key;
Dart's defaults would have left it 0644 in 0755.
D10 (in-place switching) is deferred, with the reason and the exact remaining
refactor written into the spec. Its foundations are here and tested
(ProfileScopedPrefs, verified byte-identical to the setPrefix keys it replaces),
but adopting them across WorkspaceController's 20 files could not be done safely
in one pass, and a partial adoption would switch the server while still showing
another profile's panes -- a subtler failure than not switching at all. The badge
therefore names the active profile (it used to hide it) but does not yet change
it.
Also: the pair QR carries optional &n= and &id= so a phone can label each server
instead of showing a bare IP, byte-identical when absent; Install CLI moved to
General.
app 2256 tests pass, 0 real failures (loading-stage flakes are pre-existing and
random); analyze clean under --fatal-infos; format clean.
server 1283 tests pass, tsc clean.
* Profiles: scope prefs per profile, and stop listing dead profiles twice
Prefs (SPEC-50 D11). ServerConfigController and GroupsController now take a
ScopedPrefs instead of SharedPreferences, and desktop_app no longer calls
SharedPreferences.setPrefix -- which throws once getInstance() has run and so
blocked in-place switching outright. Keys are composed by us and compose
byte-identically, so this is a no-op rather than a migration. Appearance,
shortcuts, recent models and cached commands become shared across profiles,
which is intended: the old blanket prefix is why a worktree build opened with a
default theme and empty shortcuts.
Correction to the deferral rationale: WorkspaceController holds NO preferences
(its only mention of SharedPreferences is a doc comment); pane layouts persist
through GroupsController. My earlier '20 files / 154 references' figure counted
files that merely mention the class, so D10 was materially cheaper than claimed.
UI, found by rendering it. Built tool/profiles_demo.dart and captured the real
macOS window with cua-driver: stale profiles appeared TWICE, once as ordinary
rows and once in the stale group. On this machine that is 5 of 9 rows -- and 27
of 33 on a real one -- so dead profiles crowd the live ones off the screen. The
main list now excludes them; the stale group is their only home.
tool/profiles_demo.dart renders both surfaces from in-memory fakes with every
delete refused, so a design review cannot touch real data.
app 1108 tests pass in the touched trees, 0 real failures; analyze clean.
* Profiles: switch in the window you are already in (SPEC-50 D10)
The last gap. Picking a profile from the title-bar pill now confirms, verifies the
target, and hands the window over -- no relaunch.
ProfileRuntime holds the whole per-profile object graph (control client, daemon
controller, scoped prefs controllers, lifecycle, deleter) behind one disposable,
so a second one can exist. Switching is a key change on the ProviderScope, which
makes Riverpod dispose the entire old container -- there is no hand-written
teardown list to forget an entry later. Dispose cancels the poll timer before
closing the client, because a poll firing against a closed client throws.
Order is the point: the target is started and confirmed answering WHILE the
current profile is still live. If it cannot come up, nothing changes and the
reason is reported. Success and failure both surface through StatusCenter, so a
switch can never silently not happen.
The tray was the trap. It closed over one DesktopController, so after a switch the
menubar would drive a disposed object; it now reads through a holder and its
listener is re-attached on each switch.
The pill became the switcher and degrades gracefully: many surfaces (and most
widget tests) mount the badge with no profile wiring, so both new providers
default to null and the badge falls back to a calm label rather than crashing --
which is also the honest UI when there is nothing to switch to. That kept ~50
sidebar tests untouched. 'Switch away & delete' is now a real action instead of a
disabled item with an apology.
Last-active profile persists in profiles.json, honoured ONLY for the installed
app: a dev build always opens its own profile, or building a worktree would
silently reopen Work and look like the build did nothing.
The spec records the correction rather than hiding it: I deferred D10 claiming
WorkspaceController needed a 20-file refactor, but it holds no preferences at all
-- I had counted files that merely mention the class.
app 2195 pass, 0 real failures; analyze clean under --fatal-infos; format clean.
server 1283 pass, tsc clean.
* Profiles: make 'switch away & delete' actually switch, and test the switch
Two defects in the previous commit, both found by asking whether it was really
finished.
'Switch away & delete' did not switch. I enabled the menu item but left it calling
the delete sheet directly, so for the ACTIVE profile ProfileDeleter refused it
every time -- an honest disabled item replaced by a broken promise. It now picks a
landing profile (preferring the protected one, which always exists), confirms both
consequences in ONE sheet because it is one intent, and hands the delete to the
host: the widget offering it cannot do the work, because the ProviderScope it
lives in is disposed by the switch. The host survives that rebuild and deletes
through the NEW runtime's deleter, which correctly sees the old profile as
inactive.
The switch sequence had no tests and had never executed. Extracted it as
verifyThenHandOver, where the irreversible half is injected, so the property that
matters is assertable: does it hand the window over at all? Eight tests now cover
a running target (no spawn), a stopped one (start then hand over), a target that
refuses to start, and -- the subtler case -- one where makit: already running
makit: running
pid 82738
listening 100.119.58.97:7808
fingerprint d3e73b15ac380e583d588c5f8d7fa1b8a097d10c853cdbd34ecc4f890f088651
paired 8 device(s)
sessions 3 running
uptime 138373s
version 0.0.0 exits 0 but
nothing is listening, where trusting the exit code would hand the window to a dead
server. All three guards bite under mutation (1, 1 and 2 failures).
That test also caught a weak message: with empty CLI output the failure read
'makit start exited 1' and never named the profile, which is useless where the
detail is surfaced alone. It always names the profile now.
ProfilesController gains notifyRegistryChanged, because the host mutates the
shared registry directly and notifyListeners is protected to subclasses.
app 2278 pass, 0 real failures; analyze clean under --fatal-infos; format clean.
* Profiles: cover the confirm sheets, and correct a misleading guard comment
Both findings from an open-code-review pass over the three unreviewed commits.
The switch sheets had no test file. confirmProfileSwitch was exercised only
incidentally through the badge, and confirmSwitchAwayAndDelete -- which gates a
delete that erases a profile's database, media, pairings and TLS identity -- had
no coverage at all. Twelve tests now cover both: that they name the target, that
they carry BOTH halves of the consequence, that a start is promised only for a
stopped target, and that Cancel and outside-tap dismissal each return false.
That last one matters most: a dismissed dialog pops null, and reading null as
consent would switch or delete on a stray click. Mutations bite -- 'result ?? true'
fails 2, always-promising-a-start fails 1, renaming the kept block fails 1.
The demo harness comment was wrong about its own safety mechanism. It claimed
every delete is refused because everything is 'active', but activeProfileId
'ALL-REFUSED' matches nothing: a namespaced profile like 'personal' would pass
both that check and isProtected. The real guard is homeDir '/nonexistent', which
makes _unsafeHomeReason reject every seeded home. The comment now says so, since a
guard nobody understands is one somebody will remove.
Note the review itself was PARTIAL: 7 of 21 files failed in the run that produced
these findings (19 of 21 in a first attempt), so this is not a clean bill of health
for the diff -- only for what was actually read.
app 2272 pass, 0 real failures; analyze clean under --fatal-infos; format clean.
* fix(profiles): address Macroscope review findings (SPEC-50)
Correctness fixes from the PR #162 review, each with a regression test:
- server_profile: reject out-of-range persisted ports (>65535) so a
hand-edited profiles.json falls back instead of silently failing to bind.
- profile_registry: serialise save()'s read-merge-write under an
inter-process advisory lock so two instances can't drop each other's new
profile; preserve a newer on-disk lastActive unless this instance set it.
- profile_deleter: guard diskUsage() with a containment check (a corrupt
home:"/" no longer walks the whole disk) and make the size walk resilient
to unreadable dirs; make delete() best-effort so a store failure is
recorded and always yields a result instead of a partial delete.
- profile_lifecycle: stopAndConfirm now waits for the daemon PID to exit,
not just the control socket, closing the window where MAKIT_HOME could be
deleted under a still-live daemon writing makit.db-wal.
- reconnecting_control_client: close() awaits an in-flight connect so it
can't install and leak a live socket after teardown.
- desktop_app: serialise profile switches at switchTo() so two interleaved
switches can't leave the window, persistence and title disagreeing.
- profiles_section: omit/disable Start-Stop for the active profile (never
stop the daemon this window talks to); add profiles.* deep-link anchors.
- profile_delete_sheet / reclaim_sheet: stop claiming prefs are deleted
when they're skipped; wrap deletions in try/catch so one failure can't
abort the flow.
- server_config / server_devices_section: setReachability clears customHost
so the choice takes effect; the Address line reflects the effective bind
rather than the client's loopback connection.
- server_devices_section: auto-expand Diagnostics when a deep-link targets
one of its (otherwise unmounted) rows.
* fix(profiles): address second Macroscope review round (SPEC-50)
Nine more review findings, each with a regression test:
- server_profile: reject a relative `home` (must be an absolute MAKIT_HOME so
spawned CLIs don't resolve it against their cwd).
- profile_registry: harden multi-instance save()/load() —
- honour deletions across windows via persisted `deletedIds` tombstones so a
stale window can't resurrect a profile whose stores are already erased;
- only override an on-disk profile this instance actually modified
(tracked like `_deleted`), so an unrelated save no longer reverts another
window's rename/port/origin edit;
- reconcile duplicate ports after the merge (and on load), so two instances
that independently allocate the same free port — or a hand-edited/fallback
7777 that collides with the legacy profile — don't hit EADDRINUSE;
- drop a second `legacy` profile on load (D2: at most one may own the
unprefixed prefs keys and unsuffixed secure store).
- profile_lifecycle: stopAndConfirm returns false if `stop()` itself failed
(CLI missing / command failed) instead of proceeding as if shut down.
- profile_deleter (Critical): resolve symlinks before the containment check so
a symlinked ancestor (e.g. ~/.makit/profiles -> external) can't route a
recursive delete outside ~/.makit*; both sides are resolved to avoid false
positives on symlinked temp roots.
- profiles_section: the active profile's danger-zone button now runs
switch-away-&-delete (it was permanently disabled despite its label).
* fix(profiles): third review round + run live safety tests in CI
- profile_registry: _uniqueId now avoids tombstoned ids too. Reusing a just-
deleted slug made save() drop the new profile (it honours the tombstone) and
orphan its home; minting a fresh id keeps it persistable.
- profiles_controller / profiles_section: keep an active profile in the main
list even when it is stale, and exclude it from the reclaim group (whose
deleter refuses the active profile). Otherwise a stale active profile had no
reachable delete at all; now its row's switch-away-&-delete works.
- profile_deleter_live_test: moved from app/lib/ to app/test/ so `flutter test`
(which only scans test/) actually runs the real-filesystem deletion-safety
checks (symlink escape, traversal, legacy-home protection) in CI.
- profile_lifecycle_test: inject statusProbe in the socket-disappearance test
so isRunning is genuinely driven by socketExists and the polling path is
exercised, instead of short-circuiting on the default probe.
Regression tests added for the tombstoned-id reuse and the active-stale-profile
cases.
* fix(profiles): address fourth Macroscope review round (SPEC-50)
* fix(profiles): finish the fourth review round (SPEC-50)
Completes the CodeRabbit/Macroscope round begun in 288b7b2.
- desktop_controller: serialize start/stop/restart. They share the daemon's PID
file and control socket (and restart is a stop+start), so overlapping requests
could drop a fresh PID, launch a second daemon, or stop one another action had
just started. Each action now chains after the previous, with a regression test
asserting they never overlap.
- profile_lifecycle: keep the pid-file read synchronous. Making it async added
real filesystem microtasks that a widget test's pumpAndSettle can never settle,
which hung the delete tests. Only `processAlive` (polled ~100x per stop) needed
to be async, and it stays async.
- test doubles: override `withLock` in the two "writes nothing" FileSystemAdapter
fakes. They inherited the real implementation, which created `<path>.lock` on
the real disk under a non-existent /Users/test — the actual cause of the hang.
- profiles_section_test: replace `await Future.delayed(...)` with
`tester.pump(...)`; inside testWidgets the clock only advances when pumping, so
the bare delay deadlocked.
- profile_registry: keep the already-persisted profile's port when reconciling a
post-merge collision (its daemon may be running on it) and reassign the
newcomer instead; test asserts which side moves.
- profile_runtime_test: assert dispose() disposes profilesController.
- server_devices_section_test: reuse `_pump` via an optional NavigatorObserver.
- docs/mockup: record the 64-code-point cap on the pair-URL `n` param, fix the
delta cross-references (18→19, 17→18), use `pnpm typecheck` as the documented
server check, and correct two stale "explicitly not changed" claims.
* fix(profiles): fourth review round (CLI path, prefs purge, guards)
* fix(profiles): per-profile lifecycle config, real prefs purge (SPEC-50)
Four more review findings:
- profile_lifecycle / profile_runtime: lifecycle actions target arbitrary
profiles, but the CLI path and endpoint args came from the ACTIVE profile's
config. Starting profile B from A's window therefore used A's configured
binary and, since `start` passed no `--port`, launched B's daemon on the CLI
default (7777) — colliding with the legacy daemon or landing on an endpoint
B's own ServerConfig disagreed with. ProfileLifecycle now takes `cliPathFor`
and `serveArgsFor`, wired to the target's scoped ServerConfig, and
MakitCliResolver.resolve() accepts a per-call override.
- profile_deleter: actually purge the profile's preference keys (store 3). The
unconditional skip was based on the obsolete `SharedPreferences.setPrefix`
assumption; prefs are now scoped by key prefix, so `ProfileScopedPrefs
.clearScope()` can purge a non-active profile. Injected as `purgePrefs`, so
contexts with no prefs still report the store honestly. The delete sheet lists
prefs under "Will be deleted" again, and its caveat note is gone.
- profile_deleter: a regular file at `home` is no longer reported as removed
while `deleteDirectory` silently no-ops on it — `ProfileFileSystem` gains
`isDirectory`, and the file case is erased with `deleteFile` and named in the
result.
- profiles_section: `promptCreateProfile` catches a throwing `create` (unwritable
registry, failed port allocation) and reports it, instead of leaking an
unhandled async error with no user feedback.
* fix(profiles): report and revert a rename whose save fails (SPEC-50)
`promptRenameProfile` called `controller.rename` without catching persistence
failures. `rename` mutates the registry's in-memory list and then saves, so an
unwritable registry left the row showing the new name for the rest of the session
while the change was silently lost on restart — and the exception escaped as an
unhandled async error with no user feedback.
It now reports the failure and reverts the in-memory name (without saving) so the
row shows what is actually persisted. Regression test drives Rename through a
registry whose writes throw and asserts both the failure event and the revert.1 parent a3d3da5 commit c05e275
55 files changed
Lines changed: 11146 additions & 887 deletions
File tree
- app
- integration_test/desktop
- lib
- control
- desktop
- chat
- groups
- daemon
- settings
- registry
- sections
- store/prefs
- test
- desktop
- chat
- groups
- daemon
- settings
- store/prefs
- tool
- docs/specs
- mockups
- server/src/pairing
Some content is hidden
Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
24 | 24 | | |
25 | 25 | | |
26 | 26 | | |
27 | | - | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
28 | 31 | | |
29 | 32 | | |
30 | 33 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
27 | 27 | | |
28 | 28 | | |
29 | 29 | | |
| 30 | + | |
30 | 31 | | |
31 | 32 | | |
32 | 33 | | |
| |||
92 | 93 | | |
93 | 94 | | |
94 | 95 | | |
95 | | - | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
96 | 100 | | |
97 | 101 | | |
98 | 102 | | |
| |||
139 | 143 | | |
140 | 144 | | |
141 | 145 | | |
142 | | - | |
| 146 | + | |
143 | 147 | | |
144 | 148 | | |
145 | 149 | | |
| |||
150 | 154 | | |
151 | 155 | | |
152 | 156 | | |
153 | | - | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
154 | 161 | | |
155 | 162 | | |
156 | 163 | | |
| |||
187 | 194 | | |
188 | 195 | | |
189 | 196 | | |
190 | | - | |
191 | | - | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
192 | 206 | | |
193 | 207 | | |
194 | 208 | | |
195 | 209 | | |
196 | 210 | | |
197 | | - | |
198 | | - | |
199 | | - | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
200 | 223 | | |
201 | 224 | | |
202 | 225 | | |
| |||
212 | 235 | | |
213 | 236 | | |
214 | 237 | | |
215 | | - | |
216 | | - | |
217 | | - | |
218 | | - | |
219 | 238 | | |
220 | 239 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
38 | 38 | | |
39 | 39 | | |
40 | 40 | | |
| 41 | + | |
41 | 42 | | |
42 | 43 | | |
43 | 44 | | |
| |||
107 | 108 | | |
108 | 109 | | |
109 | 110 | | |
110 | | - | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
111 | 115 | | |
112 | 116 | | |
113 | 117 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
97 | 97 | | |
98 | 98 | | |
99 | 99 | | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
100 | 107 | | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
101 | 116 | | |
102 | 117 | | |
| 118 | + | |
103 | 119 | | |
104 | 120 | | |
105 | 121 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
158 | 158 | | |
159 | 159 | | |
160 | 160 | | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
161 | 201 | | |
162 | 202 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
17 | 17 | | |
18 | 18 | | |
19 | 19 | | |
20 | | - | |
| 20 | + | |
| 21 | + | |
21 | 22 | | |
22 | 23 | | |
23 | 24 | | |
| |||
200 | 201 | | |
201 | 202 | | |
202 | 203 | | |
203 | | - | |
| 204 | + | |
204 | 205 | | |
205 | 206 | | |
206 | | - | |
| 207 | + | |
207 | 208 | | |
208 | 209 | | |
209 | 210 | | |
| |||
506 | 507 | | |
507 | 508 | | |
508 | 509 | | |
509 | | - | |
| 510 | + | |
510 | 511 | | |
511 | 512 | | |
512 | 513 | | |
| |||
579 | 580 | | |
580 | 581 | | |
581 | 582 | | |
582 | | - | |
| 583 | + | |
583 | 584 | | |
584 | 585 | | |
585 | 586 | | |
| |||
711 | 712 | | |
712 | 713 | | |
713 | 714 | | |
714 | | - | |
| 715 | + | |
| 716 | + | |
715 | 717 | | |
716 | 718 | | |
717 | 719 | | |
| |||
0 commit comments