@trevonistrevon/pi-loop 0.5.3 → 0.5.5

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.
Files changed (87) hide show
  1. package/coverage/base.css +224 -0
  2. package/coverage/block-navigation.js +87 -0
  3. package/coverage/coverage-final.json +32 -0
  4. package/coverage/favicon.png +0 -0
  5. package/coverage/index.html +176 -0
  6. package/coverage/prettify.css +1 -0
  7. package/coverage/prettify.js +2 -0
  8. package/coverage/sort-arrow-sprite.png +0 -0
  9. package/coverage/sorter.js +210 -0
  10. package/coverage/src/commands/index.html +131 -0
  11. package/coverage/src/commands/loop-command.ts.html +634 -0
  12. package/coverage/src/commands/tasks-command.ts.html +490 -0
  13. package/coverage/src/coordinator.ts.html +430 -0
  14. package/coverage/src/goal-coordinator.ts.html +259 -0
  15. package/coverage/src/goal-reducer.ts.html +982 -0
  16. package/coverage/src/goal-store.ts.html +742 -0
  17. package/coverage/src/goal-verifier.ts.html +808 -0
  18. package/coverage/src/index.html +386 -0
  19. package/coverage/src/index.ts.html +1054 -0
  20. package/coverage/src/loop-parse.ts.html +574 -0
  21. package/coverage/src/loop-reducer.ts.html +559 -0
  22. package/coverage/src/monitor-completion-coordinator.ts.html +157 -0
  23. package/coverage/src/monitor-manager.ts.html +865 -0
  24. package/coverage/src/monitor-reducer.ts.html +583 -0
  25. package/coverage/src/notification-reducer.ts.html +550 -0
  26. package/coverage/src/reducer-backed-store.ts.html +589 -0
  27. package/coverage/src/runtime/index.html +191 -0
  28. package/coverage/src/runtime/monitor-ondone-runtime.ts.html +310 -0
  29. package/coverage/src/runtime/notification-runtime.ts.html +721 -0
  30. package/coverage/src/runtime/scope.ts.html +196 -0
  31. package/coverage/src/runtime/session-runtime.ts.html +589 -0
  32. package/coverage/src/runtime/task-backlog-runtime.ts.html +574 -0
  33. package/coverage/src/runtime/task-rpc.ts.html +535 -0
  34. package/coverage/src/scheduler.ts.html +400 -0
  35. package/coverage/src/store.ts.html +667 -0
  36. package/coverage/src/task-backlog-coordinator.ts.html +181 -0
  37. package/coverage/src/task-reducer.ts.html +550 -0
  38. package/coverage/src/task-store.ts.html +526 -0
  39. package/coverage/src/tools/index.html +146 -0
  40. package/coverage/src/tools/loop-tools.ts.html +1000 -0
  41. package/coverage/src/tools/monitor-tools.ts.html +547 -0
  42. package/coverage/src/tools/native-task-tools.ts.html +535 -0
  43. package/coverage/src/trigger-system.ts.html +547 -0
  44. package/coverage/src/ui/index.html +116 -0
  45. package/coverage/src/ui/widget.ts.html +292 -0
  46. package/dist/commands/loop-command.js +4 -5
  47. package/dist/commands/tasks-command.js +3 -3
  48. package/dist/goal-reducer.d.ts +9 -0
  49. package/dist/goal-reducer.js +11 -2
  50. package/dist/goal-store.d.ts +4 -15
  51. package/dist/goal-store.js +32 -154
  52. package/dist/index.d.ts +2 -2
  53. package/dist/index.js +4 -3
  54. package/dist/loop-reducer.d.ts +7 -0
  55. package/dist/loop-reducer.js +9 -0
  56. package/dist/monitor-manager.js +4 -4
  57. package/dist/reducer-backed-store.d.ts +65 -0
  58. package/dist/reducer-backed-store.js +160 -0
  59. package/dist/store.d.ts +4 -16
  60. package/dist/store.js +25 -156
  61. package/dist/task-reducer.js +3 -0
  62. package/dist/task-store.d.ts +4 -15
  63. package/dist/task-store.js +25 -147
  64. package/dist/tools/loop-tools.js +7 -5
  65. package/dist/tools/monitor-tools.js +12 -3
  66. package/dist/tools/native-task-tools.js +2 -2
  67. package/dist/trigger-system.js +2 -1
  68. package/dist/ui/widget.js +8 -1
  69. package/package.json +4 -1
  70. package/src/commands/loop-command.ts +4 -5
  71. package/src/commands/tasks-command.ts +3 -3
  72. package/src/goal-reducer.ts +20 -1
  73. package/src/goal-store.ts +35 -142
  74. package/src/index.ts +4 -3
  75. package/src/loop-reducer.ts +10 -0
  76. package/src/monitor-manager.ts +2 -3
  77. package/src/reducer-backed-store.ts +168 -0
  78. package/src/store.ts +28 -141
  79. package/src/task-reducer.ts +3 -0
  80. package/src/task-store.ts +28 -135
  81. package/src/tools/loop-tools.ts +6 -5
  82. package/src/tools/monitor-tools.ts +12 -3
  83. package/src/tools/native-task-tools.ts +2 -2
  84. package/src/trigger-system.ts +2 -1
  85. package/src/ui/widget.ts +8 -1
  86. package/vitest.config.ts +25 -0
  87. package/DIFFERENTIAL_REVIEW_REPORT.md +0 -81
@@ -0,0 +1,168 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { dirname, isAbsolute, join } from "node:path";
3
+ import type { AnyReducerEffect } from "./coordinator.js";
4
+
5
+ const LOCK_RETRY_MS = 50;
6
+ const LOCK_MAX_RETRIES = 100;
7
+
8
+ function acquireLock(lockPath: string): void {
9
+ mkdirSync(dirname(lockPath), { recursive: true });
10
+ for (let i = 0; i < LOCK_MAX_RETRIES; i++) {
11
+ try {
12
+ writeFileSync(lockPath, `${process.pid}`, { flag: "wx" });
13
+ return;
14
+ } catch (e: any) {
15
+ if (e.code === "EEXIST") {
16
+ try {
17
+ const pid = parseInt(readFileSync(lockPath, "utf-8"), 10);
18
+ if (!pid || !isProcessRunning(pid)) {
19
+ try { unlinkSync(lockPath); } catch { /* ignore */ }
20
+ continue;
21
+ }
22
+ } catch { /* ignore read errors */ }
23
+ const start = Date.now();
24
+ while (Date.now() - start < LOCK_RETRY_MS) { /* busy wait */ }
25
+ continue;
26
+ }
27
+ throw e;
28
+ }
29
+ }
30
+ throw new Error(`Failed to acquire lock: ${lockPath}`);
31
+ }
32
+
33
+ function releaseLock(lockPath: string): void {
34
+ try { unlinkSync(lockPath); } catch { /* ignore */ }
35
+ }
36
+
37
+ function isProcessRunning(pid: number): boolean {
38
+ try { process.kill(pid, 0); return true; } catch { return false; }
39
+ }
40
+
41
+ export interface ReducerResult<TState> {
42
+ state: TState;
43
+ effects: AnyReducerEffect[];
44
+ }
45
+
46
+ /**
47
+ * Per-store glue the base needs to translate between its internal
48
+ * `{ nextId, entries }` representation, the pure reducer's state shape, and the
49
+ * on-disk JSON shape. Each is a small, allocation-only function.
50
+ */
51
+ export interface ReducerBackedStoreConfig<TEntry, TState, TEvent, TData> {
52
+ /** Directory for `<listId>.json` when constructed with a bare list id. */
53
+ baseDir: string;
54
+ reduce: (state: TState, event: TEvent) => ReducerResult<TState>;
55
+ toReducerState: (nextId: number, entries: Map<string, TEntry>) => TState;
56
+ fromReducerState: (state: TState) => { nextId: number; entries: Map<string, TEntry> };
57
+ serialize: (nextId: number, entries: Map<string, TEntry>) => TData;
58
+ deserialize: (data: TData) => { nextId: number; entries: Map<string, TEntry> };
59
+ }
60
+
61
+ /**
62
+ * Shared persistence + reducer-dispatch machinery for the file-backed entity
63
+ * stores (loops, tasks, goals). Owns file locking, signature-gated load, atomic
64
+ * save, and reducer application; subclasses add only their entity-specific
65
+ * command methods.
66
+ *
67
+ * Durability boundary: every mutation runs inside {@link withLock}, which saves
68
+ * the whole file unconditionally after the callback. Reducer effects are
69
+ * therefore *not* the persistence mechanism — they are surfaced to
70
+ * {@link onEffects} (default: no-op) so cross-entity effects (e.g.
71
+ * `DISPATCH_EVENT`, `REQUEST_GOAL_VERIFICATION`) can be forwarded by the runtime
72
+ * without being silently dropped at the reducer call site.
73
+ */
74
+ export abstract class ReducerBackedStore<TEntry extends { id: string }, TState, TEvent, TData> {
75
+ protected filePath: string | undefined;
76
+ protected lockPath: string | undefined;
77
+ private lastLoadedSignature: string | undefined;
78
+
79
+ protected nextId = 1;
80
+ protected entries = new Map<string, TEntry>();
81
+
82
+ private readonly config: ReducerBackedStoreConfig<TEntry, TState, TEvent, TData>;
83
+
84
+ constructor(config: ReducerBackedStoreConfig<TEntry, TState, TEvent, TData>, listIdOrPath?: string) {
85
+ this.config = config;
86
+ if (!listIdOrPath) return;
87
+ const filePath = isAbsolute(listIdOrPath) ? listIdOrPath : join(config.baseDir, `${listIdOrPath}.json`);
88
+ mkdirSync(dirname(filePath), { recursive: true });
89
+ this.filePath = filePath;
90
+ this.lockPath = `${filePath}.lock`;
91
+ this.load();
92
+ }
93
+
94
+ private getFileSignature(): string | undefined {
95
+ if (!this.filePath || !existsSync(this.filePath)) return undefined;
96
+ const stat = statSync(this.filePath);
97
+ return `${stat.mtimeMs}:${stat.size}`;
98
+ }
99
+
100
+ private load(force = false): void {
101
+ if (!this.filePath) return;
102
+ const signature = this.getFileSignature();
103
+ if (!signature) return;
104
+ if (!force && signature === this.lastLoadedSignature) return;
105
+ try {
106
+ const data: TData = JSON.parse(readFileSync(this.filePath, "utf-8"));
107
+ const { nextId, entries } = this.config.deserialize(data);
108
+ this.nextId = nextId;
109
+ this.entries = entries;
110
+ this.lastLoadedSignature = signature;
111
+ } catch { /* corrupt file — start fresh */ }
112
+ }
113
+
114
+ private save(): void {
115
+ if (!this.filePath) return;
116
+ const data = this.config.serialize(this.nextId, this.entries);
117
+ const tmpPath = `${this.filePath}.tmp`;
118
+ writeFileSync(tmpPath, JSON.stringify(data, null, 2));
119
+ renameSync(tmpPath, this.filePath);
120
+ this.lastLoadedSignature = this.getFileSignature();
121
+ }
122
+
123
+ protected withLock<T>(fn: () => T): T {
124
+ if (!this.lockPath) return fn();
125
+ acquireLock(this.lockPath);
126
+ try {
127
+ this.load(true);
128
+ const result = fn();
129
+ this.save();
130
+ return result;
131
+ } finally {
132
+ releaseLock(this.lockPath);
133
+ }
134
+ }
135
+
136
+ protected applyReducerEvent(event: TEvent): void {
137
+ const result = this.config.reduce(this.config.toReducerState(this.nextId, this.entries), event);
138
+ const { nextId, entries } = this.config.fromReducerState(result.state);
139
+ this.nextId = nextId;
140
+ this.entries = entries;
141
+ if (result.effects.length > 0) this.onEffects(result.effects);
142
+ }
143
+
144
+ /**
145
+ * Hook for reducer effects. Default no-op: durability is owned by
146
+ * {@link withLock}. Override to forward non-persistence effects.
147
+ */
148
+ protected onEffects(_effects: AnyReducerEffect[]): void { /* no-op by default */ }
149
+
150
+ /** Reload (signature-gated) and return the entry, or undefined. */
151
+ get(id: string): TEntry | undefined {
152
+ if (this.filePath) this.load();
153
+ return this.entries.get(id);
154
+ }
155
+
156
+ /** Reload (signature-gated) and return all entries sorted by numeric id. */
157
+ list(): TEntry[] {
158
+ if (this.filePath) this.load();
159
+ return Array.from(this.entries.values()).sort((a, b) => Number(a.id) - Number(b.id));
160
+ }
161
+
162
+ /** Remove the backing file when the store is empty. No-op for memory stores. */
163
+ deleteFileIfEmpty(): boolean {
164
+ if (!this.filePath || this.entries.size > 0) return false;
165
+ try { unlinkSync(this.filePath); } catch { /* ignore */ }
166
+ return true;
167
+ }
168
+ }
package/src/store.ts CHANGED
@@ -1,127 +1,30 @@
1
- import { existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
2
1
  import { homedir } from "node:os";
3
- import { dirname, isAbsolute, join } from "node:path";
2
+ import { join } from "node:path";
4
3
  import { type LoopReducerEvent, type LoopReducerState, reduceLoopState } from "./loop-reducer.js";
4
+ import { ReducerBackedStore } from "./reducer-backed-store.js";
5
5
  import type { LoopEntry, LoopStoreData, Trigger } from "./types.js";
6
6
 
7
7
  const LOOPS_DIR = join(homedir(), ".pi", "loops");
8
- const LOCK_RETRY_MS = 50;
9
- const LOCK_MAX_RETRIES = 100;
10
8
  const MAX_LOOPS = 25;
11
9
 
12
- function acquireLock(lockPath: string): void {
13
- for (let i = 0; i < LOCK_MAX_RETRIES; i++) {
14
- try {
15
- writeFileSync(lockPath, `${process.pid}`, { flag: "wx" });
16
- return;
17
- } catch (e: any) {
18
- if (e.code === "EEXIST") {
19
- try {
20
- const pid = parseInt(readFileSync(lockPath, "utf-8"), 10);
21
- if (!pid || !isProcessRunning(pid)) {
22
- try { unlinkSync(lockPath); } catch { /* ignore */ }
23
- continue;
24
- }
25
- } catch { /* ignore read errors */ }
26
- const start = Date.now();
27
- while (Date.now() - start < LOCK_RETRY_MS) { /* busy wait */ }
28
- continue;
29
- }
30
- throw e;
31
- }
32
- }
33
- throw new Error(`Failed to acquire lock: ${lockPath}`);
34
- }
35
-
36
- function releaseLock(lockPath: string): void {
37
- try { unlinkSync(lockPath); } catch { /* ignore */ }
38
- }
39
-
40
- function isProcessRunning(pid: number): boolean {
41
- try { process.kill(pid, 0); return true; } catch { return false; }
42
- }
43
-
44
- export class LoopStore {
45
- private filePath: string | undefined;
46
- private lockPath: string | undefined;
47
- private lastLoadedSignature: string | undefined;
48
-
49
- private nextId = 1;
50
- private loops = new Map<string, LoopEntry>();
51
-
10
+ export class LoopStore extends ReducerBackedStore<LoopEntry, LoopReducerState, LoopReducerEvent, LoopStoreData> {
52
11
  constructor(listIdOrPath?: string) {
53
- if (!listIdOrPath) return;
54
- const isAbsPath = isAbsolute(listIdOrPath);
55
- const filePath = isAbsPath ? listIdOrPath : join(LOOPS_DIR, `${listIdOrPath}.json`);
56
- mkdirSync(dirname(filePath), { recursive: true });
57
- this.filePath = filePath;
58
- this.lockPath = filePath + ".lock";
59
- this.load();
60
- }
61
-
62
- private getFileSignature(): string | undefined {
63
- if (!this.filePath || !existsSync(this.filePath)) return undefined;
64
- const stat = statSync(this.filePath);
65
- return `${stat.mtimeMs}:${stat.size}`;
66
- }
67
-
68
- private load(force = false): void {
69
- if (!this.filePath) return;
70
- const signature = this.getFileSignature();
71
- if (!signature) return;
72
- if (!force && signature === this.lastLoadedSignature) return;
73
- try {
74
- const data: LoopStoreData = JSON.parse(readFileSync(this.filePath, "utf-8"));
75
- this.nextId = data.nextId;
76
- this.loops.clear();
77
- for (const loop of data.loops) {
78
- this.loops.set(loop.id, loop);
79
- }
80
- this.lastLoadedSignature = signature;
81
- } catch { /* corrupt file — start fresh */ }
82
- }
83
-
84
- private save(): void {
85
- if (!this.filePath) return;
86
- const data: LoopStoreData = {
87
- nextId: this.nextId,
88
- loops: Array.from(this.loops.values()),
89
- };
90
- const tmpPath = this.filePath + ".tmp";
91
- writeFileSync(tmpPath, JSON.stringify(data, null, 2));
92
- renameSync(tmpPath, this.filePath);
93
- this.lastLoadedSignature = this.getFileSignature();
94
- }
95
-
96
- private withLock<T>(fn: () => T): T {
97
- if (!this.lockPath) return fn();
98
- acquireLock(this.lockPath);
99
- try {
100
- this.load(true);
101
- const result = fn();
102
- this.save();
103
- return result;
104
- } finally {
105
- releaseLock(this.lockPath);
106
- }
107
- }
108
-
109
- private toReducerState(): LoopReducerState {
110
- return {
111
- nextId: this.nextId,
112
- loopsById: Object.fromEntries(this.loops.entries()),
113
- };
114
- }
115
-
116
- private applyReducerEvent(event: LoopReducerEvent): void {
117
- const result = reduceLoopState(this.toReducerState(), event);
118
- this.nextId = result.state.nextId;
119
- this.loops = new Map(Object.entries(result.state.loopsById));
12
+ super(
13
+ {
14
+ baseDir: LOOPS_DIR,
15
+ reduce: (state, event) => reduceLoopState(state, event),
16
+ toReducerState: (nextId, entries) => ({ nextId, loopsById: Object.fromEntries(entries.entries()) }),
17
+ fromReducerState: (state) => ({ nextId: state.nextId, entries: new Map(Object.entries(state.loopsById)) }),
18
+ serialize: (nextId, entries) => ({ nextId, loops: Array.from(entries.values()) }),
19
+ deserialize: (data) => ({ nextId: data.nextId, entries: new Map(data.loops.map((l) => [l.id, l])) }),
20
+ },
21
+ listIdOrPath,
22
+ );
120
23
  }
121
24
 
122
25
  create(trigger: Trigger, prompt: string, opts: { recurring: boolean; autoTask?: boolean; taskBacklog?: boolean; readOnly?: boolean; maxFires?: number }): LoopEntry {
123
26
  return this.withLock(() => {
124
- if (this.loops.size >= MAX_LOOPS) {
27
+ if (this.entries.size >= MAX_LOOPS) {
125
28
  throw new Error(`Maximum of ${MAX_LOOPS} loops reached. Delete some before creating new ones.`);
126
29
  }
127
30
  const now = Date.now();
@@ -140,23 +43,13 @@ export class LoopStore {
140
43
  maxFires: opts.maxFires,
141
44
  },
142
45
  });
143
- return this.loops.get(String(this.nextId - 1))!;
46
+ return this.entries.get(String(this.nextId - 1))!;
144
47
  });
145
48
  }
146
49
 
147
- get(id: string): LoopEntry | undefined {
148
- if (this.filePath) this.load();
149
- return this.loops.get(id);
150
- }
151
-
152
- list(): LoopEntry[] {
153
- if (this.filePath) this.load();
154
- return Array.from(this.loops.values()).sort((a, b) => Number(a.id) - Number(b.id));
155
- }
156
-
157
50
  pause(id: string): LoopEntry | undefined {
158
51
  return this.withLock(() => {
159
- const entry = this.loops.get(id);
52
+ const entry = this.entries.get(id);
160
53
  if (!entry) return undefined;
161
54
  this.applyReducerEvent({
162
55
  type: "LOOP_PAUSED",
@@ -166,13 +59,13 @@ export class LoopStore {
166
59
  entityId: id,
167
60
  payload: { id },
168
61
  });
169
- return this.loops.get(id);
62
+ return this.entries.get(id);
170
63
  });
171
64
  }
172
65
 
173
66
  resume(id: string): LoopEntry | undefined {
174
67
  return this.withLock(() => {
175
- const entry = this.loops.get(id);
68
+ const entry = this.entries.get(id);
176
69
  if (!entry) return undefined;
177
70
  this.applyReducerEvent({
178
71
  type: "LOOP_RESUMED",
@@ -182,13 +75,13 @@ export class LoopStore {
182
75
  entityId: id,
183
76
  payload: { id },
184
77
  });
185
- return this.loops.get(id);
78
+ return this.entries.get(id);
186
79
  });
187
80
  }
188
81
 
189
82
  fire(id: string): LoopEntry | undefined {
190
83
  return this.withLock(() => {
191
- const entry = this.loops.get(id);
84
+ const entry = this.entries.get(id);
192
85
  if (!entry) return undefined;
193
86
  this.applyReducerEvent({
194
87
  type: "LOOP_FIRED",
@@ -198,13 +91,13 @@ export class LoopStore {
198
91
  entityId: id,
199
92
  payload: { id },
200
93
  });
201
- return this.loops.get(id);
94
+ return this.entries.get(id);
202
95
  });
203
96
  }
204
97
 
205
98
  updateMetadata(id: string, fields: { trigger?: Trigger; prompt?: string }): { entry: LoopEntry | undefined; changedFields: string[] } {
206
99
  return this.withLock(() => {
207
- const current = this.loops.get(id);
100
+ const current = this.entries.get(id);
208
101
  if (!current) return { entry: undefined, changedFields: [] };
209
102
 
210
103
  const changedFields: string[] = [];
@@ -222,13 +115,13 @@ export class LoopStore {
222
115
  current.updatedAt = now;
223
116
  }
224
117
 
225
- return { entry: this.loops.get(id), changedFields };
118
+ return { entry: this.entries.get(id), changedFields };
226
119
  });
227
120
  }
228
121
 
229
122
  delete(id: string): boolean {
230
123
  return this.withLock(() => {
231
- if (!this.loops.has(id)) return false;
124
+ if (!this.entries.has(id)) return false;
232
125
  this.applyReducerEvent({
233
126
  type: "LOOP_DELETED",
234
127
  at: Date.now(),
@@ -245,7 +138,7 @@ export class LoopStore {
245
138
  return this.withLock(() => {
246
139
  const now = Date.now();
247
140
  let count = 0;
248
- for (const [id, entry] of [...this.loops.entries()]) {
141
+ for (const [id, entry] of [...this.entries.entries()]) {
249
142
  if (now < entry.expiresAt) continue;
250
143
  this.applyReducerEvent({
251
144
  type: "LOOP_EXPIRED",
@@ -264,7 +157,7 @@ export class LoopStore {
264
157
  expireEventLoops(sessionStartedAt: number): number {
265
158
  return this.withLock(() => {
266
159
  let count = 0;
267
- for (const [id, entry] of [...this.loops.entries()]) {
160
+ for (const [id, entry] of [...this.entries.entries()]) {
268
161
  if (entry.status !== "active") continue;
269
162
  if (entry.trigger.type !== "event" && entry.trigger.type !== "hybrid") continue;
270
163
  if (entry.createdAt >= sessionStartedAt) continue;
@@ -284,7 +177,7 @@ export class LoopStore {
284
177
 
285
178
  clearAll(): number {
286
179
  return this.withLock(() => {
287
- const ids = [...this.loops.keys()];
180
+ const ids = [...this.entries.keys()];
288
181
  for (const id of ids) {
289
182
  this.applyReducerEvent({
290
183
  type: "LOOP_DELETED",
@@ -298,10 +191,4 @@ export class LoopStore {
298
191
  return ids.length;
299
192
  });
300
193
  }
301
-
302
- deleteFileIfEmpty(): boolean {
303
- if (!this.filePath || this.loops.size > 0) return false;
304
- try { unlinkSync(this.filePath); } catch { /* ignore */ }
305
- return true;
306
- }
307
194
  }
@@ -136,6 +136,9 @@ export function reduceTaskState(state: TaskReducerState, event: TaskReducerEvent
136
136
  if (event.type === "TASK_REOPENED") {
137
137
  task.status = "pending";
138
138
  task.updatedAt = event.at;
139
+ // `completedAt` is intentionally retained: it records the most recent
140
+ // completion, not "is currently complete" (use `status` for that). A
141
+ // reopened task keeps the timestamp of when it was last completed.
139
142
  }
140
143
 
141
144
  if (event.type === "TASK_UPDATED") {