@trevonistrevon/pi-loop 0.4.11 → 0.5.1

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/README.md +20 -9
  2. package/dist/commands/loop-command.d.ts +22 -0
  3. package/dist/commands/loop-command.js +148 -0
  4. package/dist/commands/tasks-command.d.ts +15 -0
  5. package/dist/commands/tasks-command.js +117 -0
  6. package/dist/coordinator.d.ts +35 -0
  7. package/dist/coordinator.js +56 -0
  8. package/dist/goal-coordinator.d.ts +22 -0
  9. package/dist/goal-coordinator.js +28 -0
  10. package/dist/goal-reducer.d.ts +89 -0
  11. package/dist/goal-reducer.js +181 -0
  12. package/dist/goal-store.d.ts +31 -0
  13. package/dist/goal-store.js +298 -0
  14. package/dist/goal-types.d.ts +82 -0
  15. package/dist/goal-types.js +1 -0
  16. package/dist/goal-verifier.d.ts +20 -0
  17. package/dist/goal-verifier.js +198 -0
  18. package/dist/index.js +130 -1087
  19. package/dist/loop-reducer.d.ts +63 -0
  20. package/dist/loop-reducer.js +67 -0
  21. package/dist/monitor-completion-coordinator.d.ts +10 -0
  22. package/dist/monitor-completion-coordinator.js +13 -0
  23. package/dist/monitor-manager.d.ts +2 -0
  24. package/dist/monitor-manager.js +107 -29
  25. package/dist/monitor-reducer.d.ts +82 -0
  26. package/dist/monitor-reducer.js +69 -0
  27. package/dist/notification-reducer.d.ts +81 -0
  28. package/dist/notification-reducer.js +65 -0
  29. package/dist/runtime/monitor-ondone-runtime.d.ts +13 -0
  30. package/dist/runtime/monitor-ondone-runtime.js +49 -0
  31. package/dist/runtime/notification-runtime.d.ts +34 -0
  32. package/dist/runtime/notification-runtime.js +152 -0
  33. package/dist/runtime/scope.d.ts +8 -0
  34. package/dist/runtime/scope.js +33 -0
  35. package/dist/runtime/session-runtime.d.ts +39 -0
  36. package/dist/runtime/session-runtime.js +110 -0
  37. package/dist/runtime/task-backlog-runtime.d.ts +36 -0
  38. package/dist/runtime/task-backlog-runtime.js +105 -0
  39. package/dist/runtime/task-rpc.d.ts +19 -0
  40. package/dist/runtime/task-rpc.js +118 -0
  41. package/dist/store.d.ts +7 -4
  42. package/dist/store.js +129 -49
  43. package/dist/task-backlog-coordinator.d.ts +12 -0
  44. package/dist/task-backlog-coordinator.js +22 -0
  45. package/dist/task-reducer.d.ts +66 -0
  46. package/dist/task-reducer.js +76 -0
  47. package/dist/task-store.d.ts +8 -4
  48. package/dist/task-store.js +102 -33
  49. package/dist/tools/loop-tools.d.ts +41 -0
  50. package/dist/tools/loop-tools.js +241 -0
  51. package/dist/tools/monitor-tools.d.ts +25 -0
  52. package/dist/tools/monitor-tools.js +110 -0
  53. package/dist/tools/native-task-tools.d.ts +15 -0
  54. package/dist/tools/native-task-tools.js +127 -0
  55. package/docs/architecture/goal-state-schema.md +505 -0
  56. package/docs/architecture/state-machine-migration.md +546 -0
  57. package/docs/architecture/state-machine-reducer-event-model.md +823 -0
  58. package/docs/architecture/state-machine-test-matrix.md +249 -0
  59. package/docs/architecture/state-machine-transition-map.md +436 -0
  60. package/package.json +1 -1
  61. package/src/commands/loop-command.ts +184 -0
  62. package/src/commands/tasks-command.ts +135 -0
  63. package/src/coordinator.ts +115 -0
  64. package/src/goal-coordinator.ts +58 -0
  65. package/src/goal-reducer.ts +280 -0
  66. package/src/goal-store.ts +315 -0
  67. package/src/goal-types.ts +104 -0
  68. package/src/goal-verifier.ts +241 -0
  69. package/src/index.ts +134 -1147
  70. package/src/loop-reducer.ts +148 -0
  71. package/src/monitor-completion-coordinator.ts +24 -0
  72. package/src/monitor-manager.ts +115 -27
  73. package/src/monitor-reducer.ts +166 -0
  74. package/src/notification-reducer.ts +155 -0
  75. package/src/runtime/monitor-ondone-runtime.ts +75 -0
  76. package/src/runtime/notification-runtime.ts +212 -0
  77. package/src/runtime/scope.ts +37 -0
  78. package/src/runtime/session-runtime.ts +168 -0
  79. package/src/runtime/task-backlog-runtime.ts +163 -0
  80. package/src/runtime/task-rpc.ts +150 -0
  81. package/src/store.ts +132 -50
  82. package/src/task-backlog-coordinator.ts +32 -0
  83. package/src/task-reducer.ts +152 -0
  84. package/src/task-store.ts +103 -31
  85. package/src/tools/loop-tools.ts +304 -0
  86. package/src/tools/monitor-tools.ts +145 -0
  87. package/src/tools/native-task-tools.ts +144 -0
@@ -0,0 +1,150 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import type { TaskStore } from "../task-store.js";
4
+ import type { LoopEntry } from "../types.js";
5
+
6
+ export interface TaskRuntimeBridgeOptions {
7
+ pi: ExtensionAPI;
8
+ isTasksAvailable: () => boolean;
9
+ setTasksAvailable: (available: boolean) => void;
10
+ getNativeTaskStore: () => TaskStore | undefined;
11
+ onNativeTaskCreated?: (taskStore: TaskStore) => void;
12
+ onNativeTasksPruned?: (taskStore: TaskStore) => Promise<void> | void;
13
+ debug?: (...args: unknown[]) => void;
14
+ }
15
+
16
+ export interface TaskRuntimeBridge {
17
+ checkTasksVersion(): void;
18
+ autoCreateTask(entry: LoopEntry): Promise<string | undefined>;
19
+ hasPendingTasks(): Promise<number>;
20
+ cleanDoneTasks(): Promise<void>;
21
+ }
22
+
23
+ export function createTaskRuntimeBridge(options: TaskRuntimeBridgeOptions): TaskRuntimeBridge {
24
+ const {
25
+ pi,
26
+ isTasksAvailable,
27
+ setTasksAvailable,
28
+ getNativeTaskStore,
29
+ onNativeTaskCreated,
30
+ onNativeTasksPruned,
31
+ debug,
32
+ } = options;
33
+
34
+ function checkTasksVersion() {
35
+ const requestId = randomUUID();
36
+ const timer = setTimeout(() => {
37
+ unsub();
38
+ }, 5000);
39
+ const unsub = pi.events.on(`tasks:rpc:ping:reply:${requestId}`, (raw: unknown) => {
40
+ unsub();
41
+ clearTimeout(timer);
42
+ const remoteVersion = (raw as { data?: { version?: number } })?.data?.version;
43
+ if (remoteVersion !== undefined) setTasksAvailable(true);
44
+ });
45
+ pi.events.emit("tasks:rpc:ping", { requestId });
46
+ }
47
+
48
+ async function autoCreateTask(entry: LoopEntry): Promise<string | undefined> {
49
+ if (!entry.autoTask) return undefined;
50
+ if (isTasksAvailable()) {
51
+ try {
52
+ const requestId = randomUUID();
53
+ const taskId = await new Promise<string | undefined>((resolve) => {
54
+ const timer = setTimeout(() => {
55
+ unsub();
56
+ resolve(undefined);
57
+ }, 5000);
58
+ const unsub = pi.events.on(`tasks:rpc:create:reply:${requestId}`, (raw: unknown) => {
59
+ unsub();
60
+ clearTimeout(timer);
61
+ const reply = raw as { success: boolean; data?: { id: string } };
62
+ if (reply.success && reply.data) resolve(reply.data.id);
63
+ else resolve(undefined);
64
+ });
65
+ pi.events.emit("tasks:rpc:create", {
66
+ requestId,
67
+ subject: entry.prompt.slice(0, 80),
68
+ description: `Auto-created from loop #${entry.id}`,
69
+ metadata: { loopId: entry.id, trigger: entry.trigger },
70
+ });
71
+ });
72
+ return taskId;
73
+ } catch {
74
+ return undefined;
75
+ }
76
+ }
77
+
78
+ const nativeTaskStore = getNativeTaskStore();
79
+ if (!nativeTaskStore) return undefined;
80
+ const task = nativeTaskStore.create(
81
+ entry.prompt.slice(0, 80),
82
+ `Auto-created from loop #${entry.id}`,
83
+ { loopId: entry.id, trigger: entry.trigger },
84
+ );
85
+ onNativeTaskCreated?.(nativeTaskStore);
86
+ return task.id;
87
+ }
88
+
89
+ async function hasPendingTasks(): Promise<number> {
90
+ if (isTasksAvailable()) {
91
+ try {
92
+ const requestId = randomUUID();
93
+ const count = await new Promise<number>((resolve) => {
94
+ const timer = setTimeout(() => {
95
+ unsub();
96
+ resolve(-1);
97
+ }, 3000);
98
+ const unsub = pi.events.on(`tasks:rpc:pending:reply:${requestId}`, (raw: unknown) => {
99
+ unsub();
100
+ clearTimeout(timer);
101
+ const reply = raw as { success: boolean; data?: { pending: number } };
102
+ resolve(reply.success && reply.data ? reply.data.pending : -1);
103
+ });
104
+ pi.events.emit("tasks:rpc:pending", { requestId });
105
+ });
106
+ return count;
107
+ } catch {
108
+ return -1;
109
+ }
110
+ }
111
+
112
+ return getNativeTaskStore()?.pendingCount() ?? -1;
113
+ }
114
+
115
+ async function cleanDoneTasks(): Promise<void> {
116
+ if (isTasksAvailable()) {
117
+ try {
118
+ const requestId = randomUUID();
119
+ await new Promise<void>((resolve) => {
120
+ const timer = setTimeout(() => {
121
+ unsub();
122
+ resolve();
123
+ }, 3000);
124
+ const unsub = pi.events.on(`tasks:rpc:clean:reply:${requestId}`, () => {
125
+ unsub();
126
+ clearTimeout(timer);
127
+ debug?.("tasks:rpc:clean — done tasks swept");
128
+ resolve();
129
+ });
130
+ pi.events.emit("tasks:rpc:clean", { requestId });
131
+ });
132
+ } catch {
133
+ // timeout or error, ignore
134
+ }
135
+ return;
136
+ }
137
+
138
+ const nativeTaskStore = getNativeTaskStore();
139
+ if (!nativeTaskStore) return;
140
+ nativeTaskStore.pruneCompleted();
141
+ await onNativeTasksPruned?.(nativeTaskStore);
142
+ }
143
+
144
+ return {
145
+ checkTasksVersion,
146
+ autoCreateTask,
147
+ hasPendingTasks,
148
+ cleanDoneTasks,
149
+ };
150
+ }
package/src/store.ts CHANGED
@@ -1,13 +1,13 @@
1
1
  import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, isAbsolute, join } from "node:path";
4
- import type { LoopEntry, LoopStatus, LoopStoreData, Trigger } from "./types.js";
4
+ import { type LoopReducerEvent, type LoopReducerState, reduceLoopState } from "./loop-reducer.js";
5
+ import type { LoopEntry, LoopStoreData, Trigger } from "./types.js";
5
6
 
6
7
  const LOOPS_DIR = join(homedir(), ".pi", "loops");
7
8
  const LOCK_RETRY_MS = 50;
8
9
  const LOCK_MAX_RETRIES = 100;
9
10
  const MAX_LOOPS = 25;
10
- const MAX_EXPIRY_DAYS = 7;
11
11
 
12
12
  function acquireLock(lockPath: string): void {
13
13
  for (let i = 0; i < LOCK_MAX_RETRIES; i++) {
@@ -95,29 +95,41 @@ export class LoopStore {
95
95
  }
96
96
  }
97
97
 
98
+ private toReducerState(): LoopReducerState {
99
+ return {
100
+ nextId: this.nextId,
101
+ loopsById: Object.fromEntries(this.loops.entries()),
102
+ };
103
+ }
104
+
105
+ private applyReducerEvent(event: LoopReducerEvent): void {
106
+ const result = reduceLoopState(this.toReducerState(), event);
107
+ this.nextId = result.state.nextId;
108
+ this.loops = new Map(Object.entries(result.state.loopsById));
109
+ }
110
+
98
111
  create(trigger: Trigger, prompt: string, opts: { recurring: boolean; autoTask?: boolean; taskBacklog?: boolean; readOnly?: boolean; maxFires?: number }): LoopEntry {
99
112
  return this.withLock(() => {
100
113
  if (this.loops.size >= MAX_LOOPS) {
101
114
  throw new Error(`Maximum of ${MAX_LOOPS} loops reached. Delete some before creating new ones.`);
102
115
  }
103
116
  const now = Date.now();
104
- const entry: LoopEntry = {
105
- id: String(this.nextId++),
106
- prompt,
107
- trigger,
108
- status: "active",
109
- recurring: opts.recurring,
110
- autoTask: opts.autoTask,
111
- taskBacklog: opts.taskBacklog,
112
- readOnly: opts.readOnly,
113
- maxFires: opts.maxFires,
114
- fireCount: 0,
115
- createdAt: now,
116
- updatedAt: now,
117
- expiresAt: now + MAX_EXPIRY_DAYS * 24 * 60 * 60 * 1000,
118
- };
119
- this.loops.set(entry.id, entry);
120
- return entry;
117
+ this.applyReducerEvent({
118
+ type: "LOOP_CREATED",
119
+ at: now,
120
+ source: "tool",
121
+ entityType: "loop",
122
+ payload: {
123
+ prompt,
124
+ trigger,
125
+ recurring: opts.recurring,
126
+ autoTask: opts.autoTask,
127
+ taskBacklog: opts.taskBacklog,
128
+ readOnly: opts.readOnly,
129
+ maxFires: opts.maxFires,
130
+ },
131
+ });
132
+ return this.loops.get(String(this.nextId - 1))!;
121
133
  });
122
134
  }
123
135
 
@@ -131,37 +143,89 @@ export class LoopStore {
131
143
  return Array.from(this.loops.values()).sort((a, b) => Number(a.id) - Number(b.id));
132
144
  }
133
145
 
134
- update(id: string, fields: { status?: LoopStatus; trigger?: Trigger; prompt?: string; fireCount?: number }): { entry: LoopEntry | undefined; changedFields: string[] } {
146
+ pause(id: string): LoopEntry | undefined {
147
+ return this.withLock(() => {
148
+ const entry = this.loops.get(id);
149
+ if (!entry) return undefined;
150
+ this.applyReducerEvent({
151
+ type: "LOOP_PAUSED",
152
+ at: Date.now(),
153
+ source: "tool",
154
+ entityType: "loop",
155
+ entityId: id,
156
+ payload: { id },
157
+ });
158
+ return this.loops.get(id);
159
+ });
160
+ }
161
+
162
+ resume(id: string): LoopEntry | undefined {
163
+ return this.withLock(() => {
164
+ const entry = this.loops.get(id);
165
+ if (!entry) return undefined;
166
+ this.applyReducerEvent({
167
+ type: "LOOP_RESUMED",
168
+ at: Date.now(),
169
+ source: "tool",
170
+ entityType: "loop",
171
+ entityId: id,
172
+ payload: { id },
173
+ });
174
+ return this.loops.get(id);
175
+ });
176
+ }
177
+
178
+ fire(id: string): LoopEntry | undefined {
135
179
  return this.withLock(() => {
136
180
  const entry = this.loops.get(id);
137
- if (!entry) return { entry: undefined, changedFields: [] };
181
+ if (!entry) return undefined;
182
+ this.applyReducerEvent({
183
+ type: "LOOP_FIRED",
184
+ at: Date.now(),
185
+ source: "system",
186
+ entityType: "loop",
187
+ entityId: id,
188
+ payload: { id },
189
+ });
190
+ return this.loops.get(id);
191
+ });
192
+ }
193
+
194
+ updateMetadata(id: string, fields: { trigger?: Trigger; prompt?: string }): { entry: LoopEntry | undefined; changedFields: string[] } {
195
+ return this.withLock(() => {
196
+ const current = this.loops.get(id);
197
+ if (!current) return { entry: undefined, changedFields: [] };
138
198
 
139
199
  const changedFields: string[] = [];
140
- if (fields.status !== undefined) {
141
- entry.status = fields.status;
142
- changedFields.push("status");
143
- }
200
+ const now = Date.now();
201
+
144
202
  if (fields.trigger !== undefined) {
145
- entry.trigger = fields.trigger;
203
+ current.trigger = fields.trigger;
146
204
  changedFields.push("trigger");
147
205
  }
148
206
  if (fields.prompt !== undefined) {
149
- entry.prompt = fields.prompt;
207
+ current.prompt = fields.prompt;
150
208
  changedFields.push("prompt");
151
209
  }
152
- if (fields.fireCount !== undefined) {
153
- entry.fireCount = fields.fireCount;
154
- changedFields.push("fireCount");
210
+ if (changedFields.length > 0) {
211
+ current.updatedAt = now;
155
212
  }
156
- entry.updatedAt = Date.now();
157
- return { entry, changedFields };
213
+
214
+ return { entry: this.loops.get(id), changedFields };
158
215
  });
159
216
  }
160
217
 
161
218
  delete(id: string): boolean {
162
219
  return this.withLock(() => {
163
220
  if (!this.loops.has(id)) return false;
164
- this.loops.delete(id);
221
+ this.applyReducerEvent({
222
+ type: "LOOP_DELETED",
223
+ at: Date.now(),
224
+ source: "tool",
225
+ entityType: "loop",
226
+ entityId: id,
227
+ payload: { id },
228
+ });
165
229
  return true;
166
230
  });
167
231
  }
@@ -170,11 +234,17 @@ export class LoopStore {
170
234
  return this.withLock(() => {
171
235
  const now = Date.now();
172
236
  let count = 0;
173
- for (const [id, entry] of this.loops) {
174
- if (now >= entry.expiresAt) {
175
- this.loops.delete(id);
176
- count++;
177
- }
237
+ for (const [id, entry] of [...this.loops.entries()]) {
238
+ if (now < entry.expiresAt) continue;
239
+ this.applyReducerEvent({
240
+ type: "LOOP_EXPIRED",
241
+ at: now,
242
+ source: "system",
243
+ entityType: "loop",
244
+ entityId: id,
245
+ payload: { id, reason: "expires_at" },
246
+ });
247
+ count++;
178
248
  }
179
249
  return count;
180
250
  });
@@ -183,26 +253,38 @@ export class LoopStore {
183
253
  expireEventLoops(sessionStartedAt: number): number {
184
254
  return this.withLock(() => {
185
255
  let count = 0;
186
- const toDelete: string[] = [];
187
- for (const [id, entry] of this.loops) {
256
+ for (const [id, entry] of [...this.loops.entries()]) {
188
257
  if (entry.status !== "active") continue;
189
- if (entry.trigger.type === "event" || entry.trigger.type === "hybrid") {
190
- if (entry.createdAt < sessionStartedAt) {
191
- toDelete.push(id);
192
- count++;
193
- }
194
- }
258
+ if (entry.trigger.type !== "event" && entry.trigger.type !== "hybrid") continue;
259
+ if (entry.createdAt >= sessionStartedAt) continue;
260
+ this.applyReducerEvent({
261
+ type: "LOOP_EXPIRED",
262
+ at: sessionStartedAt,
263
+ source: "session",
264
+ entityType: "loop",
265
+ entityId: id,
266
+ payload: { id, reason: "resume_event_stale" },
267
+ });
268
+ count++;
195
269
  }
196
- for (const id of toDelete) this.loops.delete(id);
197
270
  return count;
198
271
  });
199
272
  }
200
273
 
201
274
  clearAll(): number {
202
275
  return this.withLock(() => {
203
- const count = this.loops.size;
204
- this.loops.clear();
205
- return count;
276
+ const ids = [...this.loops.keys()];
277
+ for (const id of ids) {
278
+ this.applyReducerEvent({
279
+ type: "LOOP_DELETED",
280
+ at: Date.now(),
281
+ source: "system",
282
+ entityType: "loop",
283
+ entityId: id,
284
+ payload: { id },
285
+ });
286
+ }
287
+ return ids.length;
206
288
  });
207
289
  }
208
290
 
@@ -0,0 +1,32 @@
1
+ import type { ReducerEffect, ReducerEvent } from "./coordinator.js";
2
+
3
+ export type TaskBacklogEvent = ReducerEvent<
4
+ "TASK_BACKLOG_EVALUATED",
5
+ { pendingCount: number; threshold: number }
6
+ >;
7
+
8
+ export type TaskBacklogEffect =
9
+ | ReducerEffect<"ENSURE_AUTO_TASK_WORKER", { pendingCount: number; threshold: number }>
10
+ | ReducerEffect<"CLEANUP_TASK_BACKLOG_LOOPS", { pendingCount: number }>;
11
+
12
+ export function reduceTaskBacklogEvent(event: TaskBacklogEvent): TaskBacklogEffect[] {
13
+ if (event.type !== "TASK_BACKLOG_EVALUATED") return [];
14
+
15
+ const { pendingCount, threshold } = event.payload;
16
+ if (pendingCount < 0) return [];
17
+ if (pendingCount === 0) {
18
+ return [{
19
+ type: "CLEANUP_TASK_BACKLOG_LOOPS",
20
+ entityType: "task",
21
+ payload: { pendingCount },
22
+ }];
23
+ }
24
+ if (pendingCount >= threshold) {
25
+ return [{
26
+ type: "ENSURE_AUTO_TASK_WORKER",
27
+ entityType: "task",
28
+ payload: { pendingCount, threshold },
29
+ }];
30
+ }
31
+ return [];
32
+ }
@@ -0,0 +1,152 @@
1
+ import type { TaskEntry } from "./task-types.js";
2
+
3
+ export interface TaskReducerState {
4
+ nextId: number;
5
+ tasksById: Record<string, TaskEntry>;
6
+ }
7
+
8
+ export type TaskReducerEvent =
9
+ | {
10
+ type: "TASK_CREATED";
11
+ at: number;
12
+ source: "tool" | "command" | "scheduler" | "eventbus" | "monitor" | "session" | "coordinator" | "system";
13
+ entityType?: "task";
14
+ entityId?: string;
15
+ payload: {
16
+ subject: string;
17
+ description: string;
18
+ metadata?: Record<string, unknown>;
19
+ };
20
+ }
21
+ | {
22
+ type: "TASK_STARTED" | "TASK_COMPLETED" | "TASK_REOPENED" | "TASK_DELETED";
23
+ at: number;
24
+ source: "tool" | "command" | "scheduler" | "eventbus" | "monitor" | "session" | "coordinator" | "system";
25
+ entityType?: "task";
26
+ entityId?: string;
27
+ payload: { id: string };
28
+ }
29
+ | {
30
+ type: "TASK_UPDATED";
31
+ at: number;
32
+ source: "tool" | "command" | "scheduler" | "eventbus" | "monitor" | "session" | "coordinator" | "system";
33
+ entityType?: "task";
34
+ entityId?: string;
35
+ payload: {
36
+ id: string;
37
+ subject?: string;
38
+ description?: string;
39
+ };
40
+ }
41
+ | {
42
+ type: "TASKS_PRUNED";
43
+ at: number;
44
+ source: "tool" | "command" | "scheduler" | "eventbus" | "monitor" | "session" | "coordinator" | "system";
45
+ entityType?: "task";
46
+ entityId?: string;
47
+ payload: {
48
+ reason: "git_commit" | "zero_pending_cleanup" | "manual";
49
+ };
50
+ };
51
+
52
+ export type TaskReducerEffect =
53
+ | {
54
+ type: "PERSIST_TASK";
55
+ entityType: "task";
56
+ entityId: string;
57
+ payload: { task: TaskEntry };
58
+ }
59
+ | {
60
+ type: "DELETE_TASK";
61
+ entityType: "task";
62
+ entityId: string;
63
+ payload: { id: string };
64
+ };
65
+
66
+ export interface TaskReduceResult {
67
+ state: TaskReducerState;
68
+ effects: TaskReducerEffect[];
69
+ }
70
+
71
+ function cloneState(state: TaskReducerState): TaskReducerState {
72
+ return {
73
+ nextId: state.nextId,
74
+ tasksById: { ...state.tasksById },
75
+ };
76
+ }
77
+
78
+ export function reduceTaskState(state: TaskReducerState, event: TaskReducerEvent): TaskReduceResult {
79
+ if (event.type === "TASK_CREATED") {
80
+ const next = cloneState(state);
81
+ const id = String(next.nextId++);
82
+ const task: TaskEntry = {
83
+ id,
84
+ subject: event.payload.subject,
85
+ description: event.payload.description,
86
+ status: "pending",
87
+ createdAt: event.at,
88
+ updatedAt: event.at,
89
+ metadata: event.payload.metadata,
90
+ };
91
+ next.tasksById[id] = task;
92
+ return {
93
+ state: next,
94
+ effects: [{ type: "PERSIST_TASK", entityType: "task", entityId: id, payload: { task } }],
95
+ };
96
+ }
97
+
98
+ if (event.type === "TASKS_PRUNED") {
99
+ const next = cloneState(state);
100
+ const effects: TaskReducerEffect[] = [];
101
+ for (const [id, task] of Object.entries(next.tasksById)) {
102
+ if (task.status !== "completed") continue;
103
+ delete next.tasksById[id];
104
+ effects.push({ type: "DELETE_TASK", entityType: "task", entityId: id, payload: { id } });
105
+ }
106
+ return { state: next, effects };
107
+ }
108
+
109
+ const id = event.payload.id;
110
+ const current = state.tasksById[id];
111
+ if (!current) return { state, effects: [] };
112
+
113
+ if (event.type === "TASK_DELETED") {
114
+ const next = cloneState(state);
115
+ delete next.tasksById[id];
116
+ return {
117
+ state: next,
118
+ effects: [{ type: "DELETE_TASK", entityType: "task", entityId: id, payload: { id } }],
119
+ };
120
+ }
121
+
122
+ const next = cloneState(state);
123
+ const task: TaskEntry = { ...current };
124
+
125
+ if (event.type === "TASK_STARTED") {
126
+ task.status = "in_progress";
127
+ task.updatedAt = event.at;
128
+ }
129
+
130
+ if (event.type === "TASK_COMPLETED") {
131
+ task.status = "completed";
132
+ task.updatedAt = event.at;
133
+ task.completedAt = event.at;
134
+ }
135
+
136
+ if (event.type === "TASK_REOPENED") {
137
+ task.status = "pending";
138
+ task.updatedAt = event.at;
139
+ }
140
+
141
+ if (event.type === "TASK_UPDATED") {
142
+ if (event.payload.subject !== undefined) task.subject = event.payload.subject;
143
+ if (event.payload.description !== undefined) task.description = event.payload.description;
144
+ task.updatedAt = event.at;
145
+ }
146
+
147
+ next.tasksById[id] = task;
148
+ return {
149
+ state: next,
150
+ effects: [{ type: "PERSIST_TASK", entityType: "task", entityId: id, payload: { task } }],
151
+ };
152
+ }