1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
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
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
|
// SPDX-License-Identifier: GPL-3.0-or-later
package journal
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestRunsListsNewestFirst(t *testing.T) {
path := filepath.Join(t.TempDir(), "krino.log")
w, _ := Open(path)
t0 := time.Date(2026, 9, 11, 9, 0, 0, 0, time.UTC)
t1 := t0.Add(time.Hour)
for _, r := range []struct {
id string
at time.Time
dirs []string
}{{"A", t0, []string{"dl"}}, {"B", t1, []string{"dl", "docs"}}} {
w.Append(Entry{Time: r.at, Run: r.id, Action: "run-start", Status: "ok"})
for _, d := range r.dirs {
w.Append(Entry{Time: r.at, Run: r.id, Dir: d, File: "x.pdf", Step: 1,
Action: "move", Status: "ok", Src: "/a/x.pdf", Dst: "/b/x.pdf"})
}
w.Append(Entry{Time: r.at, Run: r.id, Action: "run-end", Status: "ok"})
}
w.Close()
runs, err := Runs(path, 0)
if err != nil {
t.Fatal(err)
}
if len(runs) != 2 || runs[0].ID != "B" || runs[1].ID != "A" {
t.Fatalf("runs = %+v; want B then A", runs)
}
if len(runs[0].Dirs) != 2 || runs[0].Dirs[0] != "dl" || runs[0].Dirs[1] != "docs" {
t.Errorf("run B dirs = %v, want [dl docs] in first-seen order", runs[0].Dirs)
}
if runs[0].Counts["move"] != 2 {
t.Errorf("run B move count = %d, want 2", runs[0].Counts["move"])
}
if !runs[0].Start.Equal(t1) {
t.Errorf("run B start = %v, want %v", runs[0].Start, t1)
}
if runs, err = Runs(path, 1); err != nil || len(runs) != 1 || runs[0].ID != "B" {
t.Errorf("Runs(path, 1) = %+v, %v", runs, err)
}
}
// TestTruncatedLastLineIsSkipped: a crash mid-write must not make the log
// unreadable - everything before the broken line still parses.
func TestTruncatedLastLineIsSkipped(t *testing.T) {
path := filepath.Join(t.TempDir(), "krino.log")
w, _ := Open(path)
w.Append(Entry{Time: time.Now(), Run: "A", Action: "run-start", Status: "ok"})
w.Close()
f, _ := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
f.WriteString("2026-09-11T10:02:03+02:00\tA\tdl\thalf-written")
f.Close()
runs, err := Runs(path, 0)
if err != nil {
t.Fatalf("a truncated final line made the whole log unreadable: %v", err)
}
if len(runs) != 1 || runs[0].ID != "A" {
t.Errorf("runs = %+v; want the complete run A", runs)
}
}
// TestRunsMarksAnUndoneRun: an undo run's run-start Detail names the run it
// reverses, in the exact format "undo of <run id>". Runs must mark that
// earlier run Undone, and must not mark the undo run itself.
func TestRunsMarksAnUndoneRun(t *testing.T) {
path := filepath.Join(t.TempDir(), "krino.log")
w, _ := Open(path)
t0 := time.Date(2026, 9, 11, 9, 0, 0, 0, time.UTC)
t1 := t0.Add(time.Hour)
w.Append(Entry{Time: t0, Run: "A", Action: "run-start", Status: "ok"})
w.Append(Entry{Time: t0, Run: "A", Dir: "dl", File: "x.pdf", Step: 1,
Action: "move", Status: "ok", Src: "/a/x.pdf", Dst: "/b/x.pdf"})
w.Append(Entry{Time: t0, Run: "A", Action: "run-end", Status: "ok"})
w.Append(Entry{Time: t1, Run: "B", Action: "run-start", Status: "ok", Detail: "undo of A"})
w.Append(Entry{Time: t1, Run: "B", Dir: "dl", File: "x.pdf", Step: 1,
Action: "undo-move", Status: "ok", Src: "/b/x.pdf", Dst: "/a/x.pdf"})
w.Append(Entry{Time: t1, Run: "B", Action: "run-end", Status: "ok"})
w.Close()
runs, err := Runs(path, 0)
if err != nil {
t.Fatal(err)
}
byID := map[string]Run{}
for _, r := range runs {
byID[r.ID] = r
}
if !byID["A"].Undone {
t.Errorf("run A = %+v, want Undone", byID["A"])
}
if byID["B"].Undone {
t.Errorf("run B (the undo run itself) = %+v, want not Undone", byID["B"])
}
}
// TestRunsDoesNotMarkUndoneWhenEveryFileWasDeclined is fix wave item 2
// (Important) / final-wave item 17: an undo run's run-start Detail alone
// used to be enough for Runs to mark the original run Undone, even when the
// undo run went on to decline every file (spec §9's "declined files are
// logged even though nothing happens to them", extended to undo) and
// reversed nothing at all. Reproduced by the reviewer via pty: `krino log`
// told the user a run had been undone when the file was still filed. Run B
// here carries the same run-start Detail as TestRunsMarksAnUndoneRun's, but
// every one of its file-scoped entries is "declined", never "ok" - the
// shape ApplyUndo logs when the front end's own review declines everything
// - so run A must come back exactly as untouched.
func TestRunsDoesNotMarkUndoneWhenEveryFileWasDeclined(t *testing.T) {
path := filepath.Join(t.TempDir(), "krino.log")
w, _ := Open(path)
t0 := time.Date(2026, 9, 11, 9, 0, 0, 0, time.UTC)
t1 := t0.Add(time.Hour)
w.Append(Entry{Time: t0, Run: "A", Action: "run-start", Status: "ok"})
w.Append(Entry{Time: t0, Run: "A", Dir: "dl", File: "x.pdf", Step: 1,
Action: "move", Status: "ok", Src: "/a/x.pdf", Dst: "/b/x.pdf"})
w.Append(Entry{Time: t0, Run: "A", Action: "run-end", Status: "ok"})
w.Append(Entry{Time: t1, Run: "B", Action: "run-start", Status: "ok", Detail: "undo of A"})
w.Append(Entry{Time: t1, Run: "B", Dir: "dl", File: "x.pdf", Step: 1,
Action: "undo-move", Status: "declined", Src: "/b/x.pdf", Dst: "/a/x.pdf"})
w.Append(Entry{Time: t1, Run: "B", Action: "run-end", Status: "ok"})
w.Close()
runs, err := Runs(path, 0)
if err != nil {
t.Fatal(err)
}
byID := map[string]Run{}
for _, r := range runs {
byID[r.ID] = r
}
if byID["A"].Undone {
t.Errorf("run A = %+v, want NOT Undone - the undo run declined every file and reversed nothing", byID["A"])
}
if byID["B"].Undone {
t.Errorf("run B (the undo run itself) = %+v, want not Undone", byID["B"])
}
}
// TestRunsDoesNotMarkUndoneWhenOnlyOkEntryIsMkdir is the coordinator's
// tightening of fix wave item 2: "at least one ok undo-* entry" is still
// too loose, by the same shape as the bug it fixes. A file's own chain
// stops after a failed file-affecting reversal, but a failed or refused
// undo-mkdir deliberately does not stop anything (internal/engine's
// isFileAffecting draws exactly this line, and undoFile's stop-on-failure
// check shares it) - so an undo-mkdir belonging to one file can still
// succeed even though every file-affecting reversal in the whole run
// failed. Here x.pdf's own undo-move fails, y.pdf's own undo-move also
// fails, and z.pdf's undo-mkdir - tidying up a directory that turned out
// empty, not restoring anything - is the run's only "ok" entry. Marking
// the original run Undone from that alone would be exactly Important 2's
// bug again, by a narrower route.
func TestRunsDoesNotMarkUndoneWhenOnlyOkEntryIsMkdir(t *testing.T) {
path := filepath.Join(t.TempDir(), "krino.log")
w, _ := Open(path)
t0 := time.Date(2026, 9, 11, 9, 0, 0, 0, time.UTC)
t1 := t0.Add(time.Hour)
w.Append(Entry{Time: t0, Run: "A", Action: "run-start", Status: "ok"})
w.Append(Entry{Time: t0, Run: "A", Dir: "dl", File: "x.pdf", Step: 1,
Action: "move", Status: "ok", Src: "/a/x.pdf", Dst: "/b/x.pdf"})
w.Append(Entry{Time: t0, Run: "A", Dir: "dl", File: "y.pdf", Step: 1,
Action: "move", Status: "ok", Src: "/a/y.pdf", Dst: "/b/y.pdf"})
w.Append(Entry{Time: t0, Run: "A", Action: "run-end", Status: "ok"})
w.Append(Entry{Time: t1, Run: "B", Action: "run-start", Status: "ok", Detail: "undo of A"})
w.Append(Entry{Time: t1, Run: "B", Dir: "dl", File: "x.pdf", Step: 1,
Action: "undo-move", Status: "failed", Src: "/b/x.pdf", Dst: "/a/x.pdf"})
w.Append(Entry{Time: t1, Run: "B", Dir: "dl", File: "y.pdf", Step: 1,
Action: "undo-move", Status: "failed", Src: "/b/y.pdf", Dst: "/a/y.pdf"})
// z.pdf's own file-affecting reversal is unrelated to x.pdf/y.pdf's
// failures; only its cleanup mkdir is shown here, since that mkdir is
// the one entry this test is about - the run's only "ok" line.
w.Append(Entry{Time: t1, Run: "B", Dir: "dl", File: "z.pdf", Step: 2,
Action: "undo-mkdir", Status: "ok", Src: "/a/Work"})
w.Append(Entry{Time: t1, Run: "B", Action: "run-end", Status: "ok"})
w.Close()
runs, err := Runs(path, 0)
if err != nil {
t.Fatal(err)
}
byID := map[string]Run{}
for _, r := range runs {
byID[r.ID] = r
}
if byID["A"].Undone {
t.Errorf("run A = %+v, want NOT Undone - the run's only ok entry is an undo-mkdir (tidiness, not a restoration), and every file-affecting reversal failed", byID["A"])
}
}
// TestEntriesReportsAMangledLine: a corrupt line that is not the log's
// final line must not be silently dropped by Entries the way Runs drops it
// - PlanUndo needs to know a step went missing so it can refuse the whole
// run rather than half-undo a file (spec §10).
func TestEntriesReportsAMangledLine(t *testing.T) {
path := filepath.Join(t.TempDir(), "krino.log")
w, _ := Open(path)
at := time.Date(2026, 9, 11, 10, 2, 3, 0, time.UTC)
if err := w.Append(Entry{Time: at, Run: "A", Action: "run-start", Status: "ok"}); err != nil {
t.Fatal(err)
}
if err := w.Append(Entry{Time: at, Run: "A", Dir: "dl", File: "x.pdf", Step: 1,
Action: "move", Status: "ok", Src: "/a/x.pdf", Dst: "/b/x.pdf"}); err != nil {
t.Fatal(err)
}
if err := w.Append(Entry{Time: at, Run: "A", Action: "run-end", Status: "ok"}); err != nil {
t.Fatal(err)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
// Mangle line 2 (the move step) in place: corrupt its Step column so it
// fails to parse, without touching the line or column count of the
// file otherwise - the point is a bad line in the middle, not at EOF.
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
lines := strings.Split(strings.TrimRight(string(raw), "\n"), "\n")
if len(lines) != 3 {
t.Fatalf("fixture has %d lines, want 3", len(lines))
}
fields := strings.Split(lines[1], "\t")
fields[4] = "not-a-number" // the step column
lines[1] = strings.Join(fields, "\t")
if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil {
t.Fatal(err)
}
got, err := Entries(path, "A")
if err == nil {
t.Fatal("Entries did not report the mangled line")
}
if !strings.Contains(err.Error(), "line 2") {
t.Errorf("error %q does not name line 2", err)
}
if len(got) != 2 {
t.Fatalf("got %d entries, want the 2 surviving (run-start, run-end): %+v", len(got), got)
}
if got[0].Action != "run-start" || got[1].Action != "run-end" {
t.Errorf("entries = %+v", got)
}
}
// TestEntriesFailsClosedOnUnattributableCorruptionInsideWindow: when a
// line's own Run column is destroyed, Entries cannot attribute it by
// content - but if it falls inside runID's own window (between its
// run-start and run-end), that possibility alone must be enough to refuse
// rather than silently return an incomplete chain (spec §10).
func TestEntriesFailsClosedOnUnattributableCorruptionInsideWindow(t *testing.T) {
path := filepath.Join(t.TempDir(), "krino.log")
w, _ := Open(path)
at := time.Date(2026, 9, 11, 10, 2, 3, 0, time.UTC)
if err := w.Append(Entry{Time: at, Run: "A", Action: "run-start", Status: "ok"}); err != nil {
t.Fatal(err)
}
if err := w.Append(Entry{Time: at, Run: "A", Dir: "dl", File: "x.pdf", Step: 1,
Action: "move", Status: "ok", Src: "/a/x.pdf", Dst: "/b/x.pdf"}); err != nil {
t.Fatal(err)
}
if err := w.Append(Entry{Time: at, Run: "A", Action: "run-end", Status: "ok"}); err != nil {
t.Fatal(err)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
// Insert a line with no tabs at all - its Run column is unrecoverable -
// between the move step and run-end, i.e. inside A's window.
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
lines := strings.Split(strings.TrimRight(string(raw), "\n"), "\n")
if len(lines) != 3 {
t.Fatalf("fixture has %d lines, want 3", len(lines))
}
inserted := make([]string, 0, len(lines)+1)
inserted = append(inserted, lines[:2]...)
inserted = append(inserted, "totally-mangled-no-tabs-here")
inserted = append(inserted, lines[2:]...)
if err := os.WriteFile(path, []byte(strings.Join(inserted, "\n")+"\n"), 0o644); err != nil {
t.Fatal(err)
}
got, err := Entries(path, "A")
if err == nil {
t.Fatal("Entries did not fail closed on unattributable corruption inside the run's window")
}
if !strings.Contains(err.Error(), "line 3") {
t.Errorf("error %q does not name line 3", err)
}
if len(got) != 3 {
t.Fatalf("got %d entries, want the 3 surviving (run-start, move, run-end): %+v", len(got), got)
}
}
// TestEntriesIgnoresUnattributableCorruptionOutsideWindow: the same
// corruption shape, placed after A's run-end inside a later run B's own
// window, must not poison A - the window scoping keeps it out.
func TestEntriesIgnoresUnattributableCorruptionOutsideWindow(t *testing.T) {
path := filepath.Join(t.TempDir(), "krino.log")
w, _ := Open(path)
at := time.Date(2026, 9, 11, 10, 2, 3, 0, time.UTC)
if err := w.Append(Entry{Time: at, Run: "A", Action: "run-start", Status: "ok"}); err != nil {
t.Fatal(err)
}
if err := w.Append(Entry{Time: at, Run: "A", Dir: "dl", File: "x.pdf", Step: 1,
Action: "move", Status: "ok", Src: "/a/x.pdf", Dst: "/b/x.pdf"}); err != nil {
t.Fatal(err)
}
if err := w.Append(Entry{Time: at, Run: "A", Action: "run-end", Status: "ok"}); err != nil {
t.Fatal(err)
}
if err := w.Append(Entry{Time: at, Run: "B", Action: "run-start", Status: "ok"}); err != nil {
t.Fatal(err)
}
if err := w.Append(Entry{Time: at, Run: "B", Action: "run-end", Status: "ok"}); err != nil {
t.Fatal(err)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
// Insert the same unattributable corruption, now inside B's window
// (between B's run-start and run-end), not A's.
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
lines := strings.Split(strings.TrimRight(string(raw), "\n"), "\n")
if len(lines) != 5 {
t.Fatalf("fixture has %d lines, want 5", len(lines))
}
inserted := make([]string, 0, len(lines)+1)
inserted = append(inserted, lines[:4]...)
inserted = append(inserted, "totally-mangled-no-tabs-here")
inserted = append(inserted, lines[4:]...)
if err := os.WriteFile(path, []byte(strings.Join(inserted, "\n")+"\n"), 0o644); err != nil {
t.Fatal(err)
}
got, err := Entries(path, "A")
if err != nil {
t.Fatalf("corruption outside A's window poisoned A: %v", err)
}
if len(got) != 3 {
t.Fatalf("got %d entries, want A's 3: %+v", len(got), got)
}
}
// TestEntriesFailsClosedOnMissingRunStart: run-start is not an optional
// marker - every run Apply writes begins with one, so its absence, once
// other entries for the run did parse, means either corruption or a log
// truncated at the front. Either way the chain cannot be trusted, even
// though the window logic alone sees nothing wrong (it never opens without
// a parsed run-start, so it never flags anything inside the gap).
func TestEntriesFailsClosedOnMissingRunStart(t *testing.T) {
path := filepath.Join(t.TempDir(), "krino.log")
w, _ := Open(path)
at := time.Date(2026, 9, 11, 10, 2, 3, 0, time.UTC)
if err := w.Append(Entry{Time: at, Run: "A", Action: "run-start", Status: "ok"}); err != nil {
t.Fatal(err)
}
if err := w.Append(Entry{Time: at, Run: "A", Dir: "dl", File: "x.pdf", Step: 1,
Action: "move", Status: "ok", Src: "/a/x.pdf", Dst: "/b/x.pdf"}); err != nil {
t.Fatal(err)
}
if err := w.Append(Entry{Time: at, Run: "A", Action: "run-end", Status: "ok"}); err != nil {
t.Fatal(err)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
// Replace A's run-start line (line 1) with a line with no tabs at all -
// unrecoverable, like the round-1 fixtures.
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
lines := strings.Split(strings.TrimRight(string(raw), "\n"), "\n")
if len(lines) != 3 {
t.Fatalf("fixture has %d lines, want 3", len(lines))
}
lines[0] = "totally-mangled-no-tabs-here"
if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil {
t.Fatal(err)
}
got, err := Entries(path, "A")
if err == nil {
t.Fatal("Entries did not fail closed on a missing run-start")
}
if !strings.Contains(err.Error(), "run-start") {
t.Errorf("error %q does not name the missing run-start", err)
}
if len(got) != 2 {
t.Fatalf("got %d entries, want the 2 surviving (move, run-end): %+v", len(got), got)
}
if got[0].Action != "move" || got[1].Action != "run-end" {
t.Errorf("entries = %+v", got)
}
}
|