diff --git a/README.md b/README.md
index 0c37ed8..aeef082 100644
--- a/README.md
+++ b/README.md
@@ -15,10 +15,10 @@
-
+
- One command for the verdict, one for the exception behind it, then the live view.
- The reactor could not reach its mail server; the CLI names the observer, the partition and the event.
+ Is it healthy, what happened, and what state did that produce —
+ the three questions, in the order you actually ask them.
---
@@ -43,14 +43,14 @@ the server's auth, and you are on a box you reached over SSH.
```
❯ cratis chronicle diagnose
-── Chronicle Diagnostics 10:15:16 ─────────────────────────────────────────────
+── Chronicle Diagnostics 14:18:21 ─────────────────────────────────────────────
server: chronicle://chronicle-dev-client:***@localhost:35100/
event store: Bookshop / Default
✓ Connection connected
✓ Server version 16.7.0
✓ Event stores 2 stores: System, Bookshop
- ✓ Observers 6 active 3 disconnected
+ ✓ Observers 9 active
✗ Failed partitions 1 need attention → cratis chronicle failed-partitions list
✓ Recommendations none
✓ Event sequence tail: 22
@@ -161,13 +161,14 @@ Measured on one store of 23 events and 9 observers:
| | `events get` | `observers list` | `event-types list` |
|---|---|---|---|
-| `-o plain` | 2,133 B | 794 B | 561 B |
-| `-o json-compact` | 8,250 B | 1,701 B | 1,656 B |
-| `-o json` | 9,966 B | 2,110 B | 2,505 B |
+| `-o plain` | 1,615 B | 777 B | 561 B |
+| `-o json-compact` | 7,329 B | 1,684 B | 1,656 B |
+| `-o json` | 8,981 B | 2,093 B | 2,505 B |
| `-q` | 59 B | 403 B | 310 B |
-`plain` is tab-separated and about 4.5× smaller than `json` here — the gap widens with row
-count, because JSON repeats every field name on every row. `-q` exists to be piped:
+`plain` is tab-separated and 2.7× to 5.6× smaller than `json` on this store — widest on
+`events get`, where JSON repeats four field names across every one of 23 rows. `-q` exists to
+be piped:
```bash
cratis chronicle observers list -q | xargs -I {} cratis chronicle observers replay {} -y
@@ -179,30 +180,33 @@ This is the table you will spend the most time in, and two of its columns are ea
misread:
```
-Id Type State Quarantined Next# LastHandled# Subscribed
-Bookshop.Members Reducer Disconnected False 3 2 False
-Bookshop.Books Reducer Disconnected False 11 10 False
-Bookshop.OverdueNotices Reactor Disconnected False 22 21 False
-Bookshop.BorrowedBooks Projection Active False 19 18 False
-Bookshop.OverdueBooks Projection Active False 23 22 False
+Id Type State Quarantined Next# LastHandled# Subscribed
+Bookshop.Members Reducer Active False 23 2 False
+Bookshop.Books Reducer Active False 23 10 False
+Bookshop.BorrowedBooks Projection Active False 23 18 False
+Bookshop.OverdueBooks Projection Active False 23 22 False
+Bookshop.OverdueNotices Reactor Active False 23 22 False
```
| Column | What it means |
|---|---|
-| `LastHandled#` | the last event this observer finished processing |
-| `Next#` | the next sequence number it will look at |
+| `Next#` | the next sequence number this observer will look at |
+| `LastHandled#` | the last event it actually processed |
| `State` | `Active`, `Replaying`, `Suspended`, `Disconnected`, `Quarantined` or `Unknown` |
| `Subscribed` | whether a client is currently attached to it |
> [!NOTE]
-> **A `Next#` behind the log tail is not by itself a problem.** An observer only advances over
-> events it subscribes to. Above, the tail is 22 and `BorrowedBooks` sits at 19 — events 19
-> through 22 are reservations and overdue markings, none of which it observes. It is fully
-> caught up. This is why `diagnose` reports failed partitions rather than sequence lag: lag is
-> ambiguous, a failed partition is not.
-
-`Disconnected` means no client is attached — usually the application is not running.
-It is the normal state for a store whose application is stopped, and it is not an error.
+> **`LastHandled#` lagging the tail is normal, and is not the same thing as being behind.**
+> Every observer above has `Next#` 23 against a tail of 22 — all of them are caught up. But
+> `Members` last handled sequence 2, because no member has registered since; nothing between 3
+> and 22 was addressed to it. The two columns answer different questions: `Next#` is how far it
+> has read, `LastHandled#` is the last thing it cared about.
+>
+> This is why `diagnose` reports failed partitions rather than sequence lag. Lag is ambiguous.
+> A failed partition is not.
+
+`Disconnected` means no client is attached — usually the application is not running. It is the
+normal state for a store whose application is stopped, and it is not an error.
## Following a failure to the event that caused it
@@ -210,26 +214,36 @@ A partition is one event source's slice of an observer. When processing throws,
stops **that partition** and leaves the rest of the observer running, so one bad entity does
not halt everything. `failed-partitions show` prints what happened, per attempt:
+
+

+
+
+Nine observers running, one partition stopped. The verdict names the next command, the
+list names the observer and the partition, and the partition turns out to be a book —
+`978-0131177055` — whose overdue notice could not be sent.
+
+The partition is the ISBN because that is the event source id this application uses. Whatever
+your entities are keyed by is what you will see here, which is what makes the failure
+addressable rather than merely reported:
+
```
-FailedPartition: 3447a2eb-5dc8-41d5-ab80-30948951dd44
+FailedPartition: caadc869-1251-41d0-9063-6947eaf74043
Observer: Bookshop.OverdueNotices
-Partition: 00000014-1111-4222-8333-444444444444
-Attempts: 4
+Partition: 978-0131177055
+Attempts: 5
- --- Attempt at 2026-07-27T23:46:06.9840000+00:00 (Seq# 22) ---
+ --- Attempt at 2026-07-28T12:17:56.6680000+00:00 (Seq# 22) ---
Exception has been thrown by the target of an invocation.
smtp.bookshop.local: connection refused
- ...
- --- Attempt at 2026-07-27T23:46:21.4580000+00:00 (Seq# 22) ---
```
-Four attempts, and the gaps between them widen — 2 seconds, then 4, then 8. Chronicle backs
-off and keeps retrying on its own, so a partition that failed on something transient
-recovers without you. `retry-partition` exists for the other case: you have fixed the cause
-and do not want to wait for the next attempt.
+The gaps between attempts widen — 2 seconds, then 4, then 8. Chronicle backs off and keeps
+retrying on its own, so a partition that failed on something transient recovers without you.
+`retry-partition` exists for the other case: you have fixed the cause and do not want to wait
+for the next attempt.
```bash
-cratis chronicle observers retry-partition Bookshop.OverdueNotices 00000014-…-444444444444 -y
+cratis chronicle observers retry-partition Bookshop.OverdueNotices 978-0131177055 -y
```
> [!WARNING]
@@ -249,12 +263,13 @@ selected observer, `T` retries a failed partition, `S` and `U` stop and resume j
`Ctrl+P` is the part worth knowing about. It searches **every kind of artifact at once**:
-

+
-One word, six matches, five kinds — the reactor and the projection's observer, the event
-type they both read, the projection declaration, the read model it writes, and the failure the
-reactor left behind. Picking one jumps to its view with the filter already applied.
+`F` narrows the view to one application. `Ctrl+P` then matches a single word across five
+kinds at once — the reactor, the projection's observer, the event type they both read, the
+projection declaration and the read model it writes. Picking one jumps to its view with the
+filter already applied.
That breadth is the reason it is a palette and not a search box. "Overdue" is not a name you
look up in one list; it is a thread running through five of them, and following it is what
@@ -331,11 +346,11 @@ can work out that a stuck observer means `failed-partitions show` without being
It is not a static word list:
-

+
-Completing an observer id shells back into the CLI, which connects and returns what that
-server has registered right now. Typing narrows to the two that match.
+Completing a read model name shells back into the CLI, which connects and returns what that
+server has registered right now — then the completed command runs against it.
Observers, event stores, event types, projections, read models, jobs, recommendations,
subscriptions, applications and users all complete this way — and context names, which come
@@ -487,9 +502,8 @@ observes one thing, so a failure reads as a sentence.
They are scripted, not screen-captured, and re-render from a clean checkout:
```bash
-dotnet build -c Release
-assets/demo-store/reset.sh # a throwaway Chronicle server with a story seeded into it
-vhs assets/demo.tape # or palette, completions
+assets/record.sh # every clip: sets up the store, waits for it, renders
+assets/record.sh workbench # or just one
```
[`assets/RECORDING.md`](assets/RECORDING.md) covers how they were made, what the fixture
diff --git a/assets/RECORDING.md b/assets/RECORDING.md
index abb1aab..f8b5d3d 100644
--- a/assets/RECORDING.md
+++ b/assets/RECORDING.md
@@ -4,9 +4,8 @@ Everything in this directory that ends in `.tape` is a script. Nothing was perfo
nothing was screen-captured, and any of it re-renders from a clean checkout:
```bash
-dotnet build -c Release
-assets/demo-store/reset.sh
-vhs assets/demo.tape # or palette, completions
+assets/record.sh # all four
+assets/record.sh workbench # or one
```
That is the point. A hand-recorded screencast is a one-off you cannot fix a typo in; a tape is
@@ -34,8 +33,9 @@ customer system — so [`demo-store/`](demo-store/) builds a third option: a thr
in Docker with a small bookshop seeded into it.
```bash
-assets/demo-store/reset.sh # fresh server, seed, exit
-assets/demo-store/reset.sh drip 40 # append a trickle against a running one
+assets/demo-store/reset.sh # fresh server, seed, exit
+assets/demo-store/reset.sh serve 90 # stay connected and healthy
+assets/demo-store/reset.sh serve-failing 90 # stay connected with one partition failing
```
It registers eight books, three members, two projections, two reducers and one reactor, then
@@ -43,11 +43,16 @@ appends a story: borrowings, two returns, two reservations, two books going over
reactor that sends overdue notices **throws for exactly one book**, which is what leaves a
failed partition behind for the CLI to find.
-Two properties matter:
+Three properties matter:
-- **Deterministic identifiers.** Event source ids are generated from a fixed pattern rather
- than `Guid.NewGuid()`, so `00000014-1111-4222-8333-444444444444` is the same book on every
- re-render and the tapes can name it directly.
+- **Domain identifiers, not GUIDs.** A book is keyed by its ISBN and a member by a handle, so
+ `978-0131177055` is the same book on every re-render, the tapes can name it directly, and
+ every column the CLI prints stays readable. The first version of this fixture used
+ `Guid.NewGuid()` and the read model table came out as two columns of wrapped hex.
+- **A connected client.** Three of the four clips run against `serve`, so observers report
+ Active. A client that has exited leaves everything `Disconnected`, which looks like a broken
+ system when it is only a stopped process — the first cut of this set had three red
+ `Disconnected` rows in the hero for no reason other than that the seeder had finished.
- **The failure survives.** Chronicle retries a failed partition on its own with a widening
backoff, and it *succeeds* the moment the client reconnects without the fault. So the seeder
exits after seeding and the partition stays failed. An earlier version of the hero tape ran a
@@ -151,8 +156,8 @@ Two consequences worth knowing before editing a tape:
## The settings, and what each is for
All of them live in [`_style.tape`](_style.tape), which every other tape pulls in with
-`Source assets/_style.tape`. Three recordings that drift apart in font size or theme read as
-three screencasts; one shared file makes them read as one set.
+`Source assets/_style.tape`. Four recordings that drift apart in font size or theme read as
+four screencasts; one shared file makes them read as one set.
| Setting | Why |
|---|---|
@@ -179,40 +184,53 @@ are not written the same way — and `completions.tape` has to be zsh.
The most useful discipline here was editorial, not technical.
-**One hero, then short single-purpose clips.** The top of the README gets the full arc — the
-verdict, the exception behind it, the live view — at about 35 seconds. Everything after is
-15-20 seconds and shows exactly one thing, sitting next to the prose that explains it.
+**Show the tool working, not the system failing.** The first version of this set was built
+entirely around a broken store: the hero opened on red ✗ marks and a four-deep stack trace,
+and three observers showed `Disconnected` because the seeder had exited. It read as "this is
+for when everything is on fire" rather than "this is what the tool does". Three of the four
+clips now run against a healthy, connected system, and exactly one is about a failure.
+
+**One hero, then short single-purpose clips.** The top of the README gets the three questions
+you actually open the tool to answer — is it healthy, what happened, what state did that
+produce — at about 29 seconds. Everything after is 15-25 seconds and shows one thing, sitting
+next to the prose that explains it.
**A GIF has to show something text cannot.** That is the whole test.
-- ✅ `demo.gif` — `diagnose` printing a verdict and naming the next command, then a full-screen
- dashboard painting itself over the same terminal and narrowing live as a filter is typed.
-- ✅ `palette.gif` — one word matching an observer, an event type, a projection, a read model
- and a failure *at the same time*. A screenshot shows six rows; it cannot show them arriving
- as the query resolves.
+- ✅ `demo.gif` — the verdict, then the raw log, then the state those events produced. Three
+ commands, one question each, in the order they get asked.
+- ✅ `workbench.gif` — a full-screen dashboard narrowing live as a filter is typed, then one
+ word matching five kinds of artifact *at the same time*. A screenshot shows five rows; it
+ cannot show them arriving as the query resolves.
- ✅ `completions.gif` — a tab press that makes a network call. There is no way to convey that
the list came from the server rather than from the script, except by watching it happen.
-- ❌ **The failed-partition trail on its own.** Four attempts of the same stack trace is a wall
- of output. It is in the hero, where it lasts seven seconds and carries the narrative, and the
- README quotes it as text where it is searchable and loads instantly.
+- ✅ `triage.gif` — one failed partition in a healthy store, and the trail from the verdict to
+ the exception. Kept short and kept to one clip: four attempts of the same stack trace is a
+ wall of output, and the README quotes the interesting part as text where it is searchable.
- ❌ **The output formats.** The same command four ways is a table with byte counts, not a
recording.
-**Watch the total weight.** Three GIFs, 1.1 MB.
+**Watch the total weight.** Four GIFs, under 1 MB.
+
+## Nothing destructive is typed on camera
-## Recording something destructive
+An earlier cut of `completions.tape` used `cratis chronicle observers replay ` to demonstrate
+completion, because an observer id is a good thing to complete. That command reprocesses an
+observer from sequence zero. It never ran — the tape stopped short of `Enter` and ended on
+`Ctrl+C` — but it meant a single stray keystroke in a tape stood between a demo and a replay.
-`completions.tape` types `cratis chronicle observers replay ` on camera — a command that
-reprocesses an observer from sequence zero. It exists in the clip because an observer id is the
-best demonstration of completion hitting the server, and it is safe because **the tape never
-sends `Enter`**. It ends on `Ctrl+C`, which abandons the line.
+It now completes `cratis chronicle read-models instances ` instead, which is read-only, and the
+clip is better for it: the completion list has no `$system` entries in it, so there is no
+backslash-escaped noise on screen either.
-Audit before rendering:
+Audit before rendering, and again whenever a tape changes:
```bash
-awk '/^Show/{s=1} s' assets/completions.tape | grep -n "Enter"
+grep -n "replay\|retry\|remove\|delete" assets/*.tape
```
-That should return nothing but comment lines. Then verify the server afterwards, as above.
+That should return nothing. If a clip ever does need a destructive command, keep it off `Enter`
+and verify the server before and after — capture the tail sequence number, the failed-partition
+count and every observer's position, and diff them.
No README asset justifies a tape typo replaying somebody's observer.
diff --git a/assets/completions.gif b/assets/completions.gif
index ab029a8..15244bb 100644
Binary files a/assets/completions.gif and b/assets/completions.gif differ
diff --git a/assets/completions.tape b/assets/completions.tape
index edd61f1..ac6fb12 100644
--- a/assets/completions.tape
+++ b/assets/completions.tape
@@ -1,11 +1,10 @@
# Tab completion that goes and asks the server.
#
-# assets/demo-store/reset.sh
-# vhs assets/completions.tape
+# assets/record.sh
#
-# The observer ids offered here are not a static word list baked into the completion script.
-# The script shells back into `cratis _complete observers`, which opens a connection and
-# returns what that server has registered at that moment.
+# The read model names offered here are not a static word list baked into the completion
+# script. The script shells back into `cratis _complete read-models`, which opens a connection
+# and returns what that server has registered at that moment.
#
# Recorded under zsh rather than bash: the generated bash script calls `_init_completion`,
# which comes from the bash-completion package, and the macOS system bash is 3.2 without it.
@@ -23,19 +22,16 @@ Type "clear" Enter
Sleep 1s
Show
-# `replay` reprocesses an observer from sequence zero. This tape never sends Enter — it ends
-# on Ctrl+C, which abandons the line. Audit before rendering: grep -n 'Enter' completions.tape
-Type "cratis chronicle observers replay "
+Type "cratis chronicle read-models instances "
Sleep 1500ms
Tab
Sleep 4s
-Type "Bookshop.O"
+# The first Tab inserted the common prefix `Bookshop.` shared by all four. `Bor` is the
+# shortest string that picks one of them, so the second Tab can complete it in full.
+Type "Bor"
Sleep 1s
Tab
-Sleep 1500ms
-Tab
-Sleep 4s
-
-Ctrl+C
Sleep 2s
+Enter
+Sleep 6s
diff --git a/assets/demo-store/Domain.cs b/assets/demo-store/Domain.cs
index 7a84c1d..97ce0da 100644
--- a/assets/demo-store/Domain.cs
+++ b/assets/demo-store/Domain.cs
@@ -5,14 +5,18 @@
namespace Bookshop;
+// Event source ids here are the domain's own identifiers — an ISBN for a book, a handle for a
+// member — rather than generated GUIDs. Chronicle takes any string, and it keeps every column
+// of CLI output readable instead of 36 characters of hex.
+
[EventType]
public record MemberRegistered(string Name, string Email);
[EventType]
-public record BookAddedToInventory(string Title, string Author, string Isbn);
+public record BookAddedToInventory(string Title, string Author);
[EventType]
-public record BookBorrowed(Guid MemberId, DateTimeOffset DueBy);
+public record BookBorrowed(string MemberId, DateTimeOffset DueBy);
[EventType]
public record BookReturned();
@@ -21,45 +25,43 @@ public record BookReturned();
public record BookMarkedOverdue(int DaysLate);
[EventType]
-public record BookReservationPlaced(Guid MemberId);
+public record BookReservationPlaced(string MemberId);
-public record Book(Guid Id, string Title, string Author, string Isbn);
+public record Book(string Id, string Title, string Author);
-public record Member(Guid Id, string Name, string Email);
+public record Member(string Id, string Name, string Email);
-public record BorrowedBook(Guid Id)
+public record BorrowedBook(string Id)
{
public string Title { get; set; } = string.Empty;
- public string Member { get; set; } = string.Empty;
- public DateTimeOffset Borrowed { get; set; }
+ public string Borrower { get; set; } = string.Empty;
public DateTimeOffset DueBy { get; set; }
}
-public record OverdueBook(Guid Id)
+public record OverdueBook(string Id)
{
public string Title { get; set; } = string.Empty;
- public string Member { get; set; } = string.Empty;
public int DaysLate { get; set; }
}
public class Books : IReducerFor
{
public Task Added(BookAddedToInventory @event, Book? initialState, EventContext context) =>
- Task.FromResult(new Book(Guid.Parse(context.EventSourceId), @event.Title, @event.Author, @event.Isbn));
+ Task.FromResult(new Book(context.EventSourceId.ToString(), @event.Title, @event.Author));
}
public class Members : IReducerFor
{
public Task Registered(MemberRegistered @event, Member? initialState, EventContext context) =>
- Task.FromResult(new Member(Guid.Parse(context.EventSourceId), @event.Name, @event.Email));
+ Task.FromResult(new Member(context.EventSourceId.ToString(), @event.Name, @event.Email));
}
public class BorrowedBooks : IProjectionFor
{
public void Define(IProjectionBuilderFor builder) => builder
.From(from => from
- .Set(m => m.DueBy).To(e => e.DueBy)
- .Set(m => m.Borrowed).ToEventContextProperty(c => c.Occurred))
+ .Set(m => m.Borrower).To(e => e.MemberId)
+ .Set(m => m.DueBy).To(e => e.DueBy))
.Join(join => join
.On(m => m.Id)
.Set(m => m.Title).To(e => e.Title))
@@ -78,8 +80,8 @@ public void Define(IProjectionBuilderFor builder) => builder
}
///
-/// Sends the overdue notice. Deliberately fails for one book so the demo server has a
-/// failed partition to inspect and retry.
+/// Sends the overdue notice. Fails for one book on demand, so the triage clip has a real
+/// exception to follow rather than a contrived one.
///
public class OverdueNotices : IReactor
{
diff --git a/assets/demo-store/Program.cs b/assets/demo-store/Program.cs
index 0953efa..416960a 100644
--- a/assets/demo-store/Program.cs
+++ b/assets/demo-store/Program.cs
@@ -3,13 +3,30 @@
using Cratis.Chronicle.Connections;
// Modes:
-// seed — register artifacts, append the baseline story, leave one failing partition behind
-// drip — stay connected and append a trickle of events so a live dashboard visibly moves
+// seed — register artifacts and append the baseline story, then exit
+// serve — stay connected and healthy, so observers report Active
+// serve-failing — stay connected with the overdue notice still failing, so one partition
+// stays failed while everything around it keeps working
+// drip — stay connected and append a trickle so a live dashboard visibly moves
+//
+// A client that has exited leaves every observer Disconnected, which looks alarming in a
+// recording and is not what anyone's system looks like while it is running. The serve modes
+// exist so the demos show a live system.
var server = args.Length > 0 ? args[0] : "chronicle://chronicle-dev-client:chronicle-dev-secret@localhost:35100/";
var storeName = args.Length > 1 ? args[1] : "Bookshop";
var seconds = args.Length > 2 ? int.Parse(args[2]) : 25;
var mode = args.Length > 3 ? args[3] : "seed";
+// The book whose overdue notice cannot be sent. Armed BEFORE connecting: Chronicle retries a
+// pending failed partition the moment a client reconnects, so arming it afterwards lets that
+// first retry succeed and clears the very failure the triage clip is about.
+const string FailingBook = "978-0131177055";
+
+if (string.Equals(mode, "serve-failing", StringComparison.Ordinal))
+{
+ OverdueNotices.FailForEventSourceId = FailingBook;
+}
+
var options = new ChronicleOptions(new ChronicleConnectionString(server));
using var client = new ChronicleClient(options);
var eventStore = await client.GetEventStore(storeName);
@@ -17,28 +34,35 @@
// Give the client time to register artifacts with the server.
await Task.Delay(TimeSpan.FromSeconds(5));
-// Deterministic ids so every re-render of the tapes shows the same identifiers.
-static Guid Id(int n) => new($"0000{n:0000}-1111-4222-8333-444444444444");
-
+// Domain identifiers, not GUIDs — an ISBN keys a book and a handle keys a member. They are
+// stable across re-renders and readable in every column the CLI prints.
var members = new[]
{
- (Id(1), "Ada Wong", "ada@example.com"),
- (Id(2), "Grace Miller", "grace@example.com"),
- (Id(3), "Kaito Mori", "kaito@example.com"),
+ ("ada.wong", "Ada Wong", "ada@example.com"),
+ ("grace.miller", "Grace Miller", "grace@example.com"),
+ ("kaito.mori", "Kaito Mori", "kaito@example.com"),
};
var books = new[]
{
- (Id(10), "The Pragmatic Programmer", "Hunt & Thomas", "978-0135957059"),
- (Id(11), "Domain-Driven Design", "Eric Evans", "978-0321125217"),
- (Id(12), "Designing Data-Intensive Applications", "Martin Kleppmann", "978-1449373320"),
- (Id(13), "Refactoring", "Martin Fowler", "978-0134757599"),
- (Id(14), "Working Effectively with Legacy Code", "Michael Feathers", "978-0131177055"),
- (Id(15), "Release It!", "Michael Nygard", "978-1680502398"),
- (Id(16), "Accelerate", "Forsgren, Humble & Kim", "978-1942788331"),
- (Id(17), "The Mythical Man-Month", "Fred Brooks", "978-0201835953"),
+ ("978-0135957059", "The Pragmatic Programmer", "Hunt & Thomas"),
+ ("978-0321125217", "Domain-Driven Design", "Eric Evans"),
+ ("978-1449373320", "Designing Data-Intensive Applications", "Martin Kleppmann"),
+ ("978-0134757599", "Refactoring", "Martin Fowler"),
+ ("978-0131177055", "Working Effectively with Legacy Code", "Michael Feathers"),
+ ("978-1680502398", "Release It!", "Michael Nygard"),
+ ("978-1942788331", "Accelerate", "Forsgren, Humble & Kim"),
+ ("978-0201835953", "The Mythical Man-Month", "Fred Brooks"),
};
+if (mode is "serve" or "serve-failing")
+{
+ Console.WriteLine($"{mode}: connected for {seconds}s");
+ await Task.Delay(TimeSpan.FromSeconds(seconds));
+ Console.WriteLine("done");
+ return;
+}
+
if (string.Equals(mode, "drip", StringComparison.Ordinal))
{
// The overdue notice succeeds here — this mode exists to make a live dashboard move,
@@ -50,9 +74,9 @@
{
var book = books[(n + 6) % books.Length];
var member = members[n % members.Length];
- await eventStore.EventLog.Append(book.Item1.ToString(), new BookReservationPlaced(member.Item1));
+ await eventStore.EventLog.Append(book.Item1, new BookReservationPlaced(member.Item1));
await Task.Delay(TimeSpan.FromMilliseconds(1500));
- await eventStore.EventLog.Append(book.Item1.ToString(), new BookBorrowed(member.Item1, DateTimeOffset.UtcNow.AddDays(14)));
+ await eventStore.EventLog.Append(book.Item1, new BookBorrowed(member.Item1, DateTimeOffset.UtcNow.AddDays(14)));
await Task.Delay(TimeSpan.FromMilliseconds(1500));
n++;
}
@@ -63,12 +87,12 @@
foreach (var (id, name, email) in members)
{
- await eventStore.EventLog.Append(id.ToString(), new MemberRegistered(name, email));
+ await eventStore.EventLog.Append(id, new MemberRegistered(name, email));
}
-foreach (var (id, title, author, isbn) in books)
+foreach (var (id, title, author) in books)
{
- await eventStore.EventLog.Append(id.ToString(), new BookAddedToInventory(title, author, isbn));
+ await eventStore.EventLog.Append(id, new BookAddedToInventory(title, author));
}
var dueIn = new[] { 9, 12, 6, -3, -11, 4 };
@@ -76,23 +100,23 @@
{
var member = members[i % members.Length];
await eventStore.EventLog.Append(
- books[i].Item1.ToString(),
+ books[i].Item1,
new BookBorrowed(member.Item1, DateTimeOffset.UtcNow.AddDays(dueIn[i])));
}
-await eventStore.EventLog.Append(books[0].Item1.ToString(), new BookReturned());
-await eventStore.EventLog.Append(books[1].Item1.ToString(), new BookReturned());
+await eventStore.EventLog.Append(books[0].Item1, new BookReturned());
+await eventStore.EventLog.Append(books[1].Item1, new BookReturned());
-await eventStore.EventLog.Append(books[2].Item1.ToString(), new BookReservationPlaced(members[0].Item1));
-await eventStore.EventLog.Append(books[3].Item1.ToString(), new BookReservationPlaced(members[2].Item1));
+await eventStore.EventLog.Append(books[2].Item1, new BookReservationPlaced(members[0].Item1));
+await eventStore.EventLog.Append(books[3].Item1, new BookReservationPlaced(members[2].Item1));
// Two books go overdue. The notice for the second one cannot be sent, which leaves a
// failed partition behind for the CLI to find.
-OverdueNotices.FailForEventSourceId = books[4].Item1.ToString();
-await eventStore.EventLog.Append(books[3].Item1.ToString(), new BookMarkedOverdue(3));
-await eventStore.EventLog.Append(books[4].Item1.ToString(), new BookMarkedOverdue(11));
+OverdueNotices.FailForEventSourceId = FailingBook;
+await eventStore.EventLog.Append(books[3].Item1, new BookMarkedOverdue(3));
+await eventStore.EventLog.Append(books[4].Item1, new BookMarkedOverdue(11));
-Console.WriteLine($"seeded. failing partition = {books[4].Item1} ({books[4].Item2})");
+Console.WriteLine($"seeded. failing partition = {FailingBook} ({books[4].Item2})");
await Task.Delay(TimeSpan.FromSeconds(seconds));
Console.WriteLine("done");
diff --git a/assets/demo-store/reset.sh b/assets/demo-store/reset.sh
index 435e3e7..b6a2efd 100755
--- a/assets/demo-store/reset.sh
+++ b/assets/demo-store/reset.sh
@@ -1,12 +1,21 @@
#!/bin/bash
# Rebuild the event store the README GIFs are recorded against.
#
-# Starts a throwaway Chronicle server, connects the Bookshop client, and appends a fixed
-# story that ends with one reactor partition failing. Identifiers are deterministic, so
-# every re-render of the tapes shows the same ids as the committed GIFs.
+# Starts a throwaway Chronicle server, connects the Bookshop client, and appends a fixed story.
+# Identifiers are deterministic, so every re-render shows the same ids as the committed GIFs.
#
-# ./reset.sh # fresh server, seed, exit
-# ./reset.sh drip 40 # append a trickle for 40s against the running server
+# ./reset.sh # fresh server, seed, exit
+# ./reset.sh serve 90 # stay connected and healthy for 90s
+# ./reset.sh serve-failing 90 # stay connected with one partition still failing
+# ./reset.sh drip 40 # append a trickle against the running server
+#
+# The serve modes matter for recording. A client that has exited leaves every observer
+# Disconnected, which looks like a broken system when it is only a stopped process.
+#
+# Deliberately NOT Chronicle's default 35000. Anything else on this machine — an Aspire
+# AppHost's control plane, a compose stack — that has claimed 127.0.0.1:35000 wins the name
+# `localhost`, and this script would then health-check and seed somebody else's server.
+# Override with CHRONICLE_PORT only if you are sure 35000 is yours.
set -e
HERE="$(cd "$(dirname "$0")" && pwd)"
@@ -18,9 +27,11 @@ BOOKSHOP="$HERE/bin/Release/net10.0/Bookshop"
[ -x "$BOOKSHOP" ] || dotnet build "$HERE/DemoStore.csproj" -c Release
-if [ "$1" = "drip" ]; then
- exec "$BOOKSHOP" "$CONNECTION" Bookshop "${2:-40}" drip
-fi
+case "$1" in
+ serve|serve-failing|drip)
+ exec "$BOOKSHOP" "$CONNECTION" Bookshop "${2:-60}" "$1"
+ ;;
+esac
docker rm -f "$CONTAINER" >/dev/null 2>&1 || true
docker run -d --name "$CONTAINER" -p "$PORT":35000 -p 30100:30000 -p 11100:11111 "$IMAGE" >/dev/null
diff --git a/assets/demo.gif b/assets/demo.gif
index eae8895..d7b183d 100644
Binary files a/assets/demo.gif and b/assets/demo.gif differ
diff --git a/assets/demo.tape b/assets/demo.tape
index 928d1ab..53c3021 100644
--- a/assets/demo.tape
+++ b/assets/demo.tape
@@ -1,11 +1,11 @@
-# Hero. The arc this tool exists for: a verdict, the event that caused it, then the live view.
+# Hero. The three questions you actually open this tool to answer: is it healthy, what
+# happened, and what state did that produce.
#
-# assets/demo-store/reset.sh # seed the store and leave one partition failing
-# vhs assets/demo.tape
+# assets/record.sh # sets up the store and renders every tape
#
-# Nothing here is driven by arrow keys. vhs cannot deliver them to the application on the
-# recording machine — bash's own history does not respond to Up either — so the workbench
-# segment uses only keys that verifiably arrive: F, printable characters, Escape and q.
+# Recorded against a live, connected client so observers report Active. A client that has
+# exited leaves everything Disconnected, which looks like a broken system when it is only a
+# stopped process.
Output assets/demo.gif
@@ -20,29 +20,12 @@ Show
Type "cratis chronicle diagnose" Enter
Sleep 6s
-# A partition id is 36 characters of nothing happening at reading speed, so this one line
-# is typed fast. Everything the viewer is meant to read is at the default speed.
-Set TypingSpeed 18ms
-Type "cratis chronicle failed-partitions show Bookshop.OverdueNotices 00000014-1111-4222-8333-444444444444" Enter
-Set TypingSpeed 55ms
+Type "clear" Enter
+Sleep 300ms
+Type "cratis chronicle events get --from 11" Enter
Sleep 7s
Type "clear" Enter
-Sleep 500ms
-
-Type "cratis chronicle workbench" Enter
-Sleep 9s
-
-# F opens the filter on whichever view is showing. Typed slowly here because the narrowing
-# is the content.
-Type "F"
-Sleep 1500ms
-Set TypingSpeed 140ms
-Type "Bookshop"
-Set TypingSpeed 55ms
-Sleep 4s
-
-Escape
-Sleep 1500ms
-Type "q"
-Sleep 2s
+Sleep 300ms
+Type "cratis chronicle read-models instances Bookshop.BorrowedBook" Enter
+Sleep 8s
diff --git a/assets/palette.gif b/assets/palette.gif
deleted file mode 100644
index 23a670c..0000000
Binary files a/assets/palette.gif and /dev/null differ
diff --git a/assets/palette.tape b/assets/palette.tape
deleted file mode 100644
index 2eb8a45..0000000
--- a/assets/palette.tape
+++ /dev/null
@@ -1,31 +0,0 @@
-# One query, every kind of artifact that touches it.
-#
-# assets/demo-store/reset.sh
-# vhs assets/palette.tape
-
-Output assets/palette.gif
-
-Set Shell bash
-Source assets/_style.tape
-
-Type `export PS1='\[\033[38;2;100;180;255m\]❯\[\033[0m\] '` Enter
-Type "clear" Enter
-Sleep 1s
-Show
-
-Type "cratis chronicle workbench" Enter
-Sleep 9s
-
-Ctrl+P
-Sleep 2s
-
-# Typed slowly: the results resolving as the query narrows is the whole point of the clip.
-Set TypingSpeed 160ms
-Type "Overdue"
-Set TypingSpeed 55ms
-Sleep 5s
-
-Escape
-Sleep 1s
-Type "q"
-Sleep 1500ms
diff --git a/assets/record.sh b/assets/record.sh
new file mode 100755
index 0000000..aa8bbff
--- /dev/null
+++ b/assets/record.sh
@@ -0,0 +1,91 @@
+#!/bin/bash
+# Renders every README GIF from scratch.
+#
+# assets/record.sh # all of them
+# assets/record.sh demo # just one
+#
+# Each tape needs a particular server state, and the difference matters: three of the four are
+# recorded against a live, connected client so observers report Active, and only triage is
+# recorded with a fault armed. Getting that wrong produces a set where everything looks broken.
+set -e
+
+HERE="$(cd "$(dirname "$0")" && pwd)"
+REPO="$(cd "$HERE/.." && pwd)"
+cd "$REPO"
+
+[ -x Source/Cli/bin/Release/net10.0/Cratis.Cli ] || dotnet build -c Release
+
+stop_client() { pkill -f 'bin/Release/net10.0/Bookshop' 2>/dev/null || true; }
+trap stop_client EXIT
+
+# Waits for the client's observers to report Active before rolling, otherwise the first frames
+# catch a store that is still connecting.
+start_client() {
+ stop_client
+ nohup "$HERE/demo-store/reset.sh" "$1" 600 >/dev/null 2>&1 &
+ printf 'connecting'
+ for _ in $(seq 1 40); do
+ if HOME="$HERE/.recording-home" CRATIS_NO_UPDATE_CHECK=1 \
+ "$HERE/.recording-bin/cratis" chronicle observers list -o plain 2>/dev/null \
+ | grep -q 'Bookshop.*Active'; then
+ echo; return
+ fi
+ printf '.'; sleep 2
+ done
+ echo; echo "warning: client never reported Active" >&2
+}
+
+cli() {
+ HOME="$HERE/.recording-home" CRATIS_NO_UPDATE_CHECK=1 "$HERE/.recording-bin/cratis" "$@"
+}
+
+# Renders are expensive and their failures are silent — a clip against the wrong state still
+# produces a perfectly good-looking GIF of the wrong thing. So the state is asserted first.
+require_state() {
+ local want=$1 n=0
+ printf 'waiting for %s state' "$want"
+ while [ $n -lt 30 ]; do
+ local failures
+ failures=$(cli chronicle failed-partitions list -q 2>/dev/null | grep -c . || true)
+ case "$want" in
+ healthy) [ "$failures" = "0" ] && { echo; return; } ;;
+ failing) [ "$failures" != "0" ] && { echo; return; } ;;
+ esac
+ printf '.'; sleep 2; n=$((n + 1))
+ done
+ echo
+ echo "error: store never reached '$want' state — refusing to record it" >&2
+ exit 1
+}
+
+render() {
+ echo "── $1"
+ vhs "$HERE/$1.tape"
+}
+
+"$HERE/demo-store/reset.sh"
+HOME="$HERE/.recording-home" bash "$HERE/prepare-env.sh"
+
+want() { [ $# -eq 0 ] || [ -z "$1" ] || [ "$1" = "$2" ]; }
+
+if want "$1" demo || want "$1" workbench || want "$1" completions; then
+ # A healthy client clears the seeded failure through Chronicle's own retry, which is
+ # exactly the state these three want.
+ start_client serve
+ require_state healthy
+ want "$1" demo && render demo
+ want "$1" workbench && render workbench
+ want "$1" completions && render completions
+fi
+
+if want "$1" triage; then
+ # A fresh store, then the fault armed before the client connects — Chronicle retries a
+ # pending failed partition the moment a client reconnects, so a late arm lets that first
+ # retry succeed and clears the very failure this clip is about.
+ "$HERE/demo-store/reset.sh"
+ start_client serve-failing
+ require_state failing
+ render triage
+fi
+
+echo "done"
diff --git a/assets/triage.gif b/assets/triage.gif
new file mode 100644
index 0000000..068d506
Binary files /dev/null and b/assets/triage.gif differ
diff --git a/assets/triage.tape b/assets/triage.tape
new file mode 100644
index 0000000..2dd4936
--- /dev/null
+++ b/assets/triage.tape
@@ -0,0 +1,28 @@
+# The one clip about something being wrong: a single failing partition in an otherwise
+# healthy store, and the trail from the verdict to the exception that caused it.
+#
+# assets/record.sh
+#
+# Recorded against `reset.sh serve-failing`, so the client is connected and every other
+# observer is Active. The only red thing on screen is the actual problem.
+
+Output assets/triage.gif
+
+Set Shell bash
+Source assets/_style.tape
+
+Type `export PS1='\[\033[38;2;100;180;255m\]❯\[\033[0m\] '` Enter
+Type "clear" Enter
+Sleep 1s
+Show
+
+Type "cratis chronicle diagnose" Enter
+Sleep 6s
+
+Type "cratis chronicle failed-partitions list" Enter
+Sleep 5s
+
+# -o table is explicit because the pipe makes the CLI think a machine is reading and switch
+# to JSON — correct behaviour, wrong for a demo. head keeps the first attempt on screen.
+Type "cratis chronicle failed-partitions show Bookshop.OverdueNotices 978-0131177055 -o table | head -16" Enter
+Sleep 8s
diff --git a/assets/workbench.gif b/assets/workbench.gif
new file mode 100644
index 0000000..cc2685f
Binary files /dev/null and b/assets/workbench.gif differ
diff --git a/assets/workbench.tape b/assets/workbench.tape
new file mode 100644
index 0000000..e4722c4
--- /dev/null
+++ b/assets/workbench.tape
@@ -0,0 +1,43 @@
+# The workbench: filtering a view, then searching every artifact kind at once.
+#
+# assets/record.sh
+#
+# Not driven by arrow keys — vhs cannot deliver them to the application on the recording
+# machine, and bash's own history does not respond to Up either. Only keys that verifiably
+# arrive are used: F, printable characters, Escape and q.
+
+Output assets/workbench.gif
+
+Set Shell bash
+Source assets/_style.tape
+
+Type `export PS1='\[\033[38;2;100;180;255m\]❯\[\033[0m\] '` Enter
+Type "clear" Enter
+Sleep 1s
+Show
+
+Type "cratis chronicle workbench" Enter
+Sleep 9s
+
+# F filters whichever view is showing. Typed slowly because the narrowing is the content.
+Type "F"
+Sleep 1200ms
+Set TypingSpeed 140ms
+Type "Bookshop"
+Set TypingSpeed 55ms
+Sleep 3500ms
+Escape
+Sleep 1500ms
+
+# Ctrl+P searches across every kind at once.
+Ctrl+P
+Sleep 1500ms
+Set TypingSpeed 150ms
+Type "Overdue"
+Set TypingSpeed 55ms
+Sleep 4500ms
+
+Escape
+Sleep 1s
+Type "q"
+Sleep 1500ms