@trevonistrevon/pi-loop 0.5.4 → 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.
- package/coverage/base.css +224 -0
- package/coverage/block-navigation.js +87 -0
- package/coverage/coverage-final.json +32 -0
- package/coverage/favicon.png +0 -0
- package/coverage/index.html +176 -0
- package/coverage/prettify.css +1 -0
- package/coverage/prettify.js +2 -0
- package/coverage/sort-arrow-sprite.png +0 -0
- package/coverage/sorter.js +210 -0
- package/coverage/src/commands/index.html +131 -0
- package/coverage/src/commands/loop-command.ts.html +634 -0
- package/coverage/src/commands/tasks-command.ts.html +490 -0
- package/coverage/src/coordinator.ts.html +430 -0
- package/coverage/src/goal-coordinator.ts.html +259 -0
- package/coverage/src/goal-reducer.ts.html +982 -0
- package/coverage/src/goal-store.ts.html +742 -0
- package/coverage/src/goal-verifier.ts.html +808 -0
- package/coverage/src/index.html +386 -0
- package/coverage/src/index.ts.html +1054 -0
- package/coverage/src/loop-parse.ts.html +574 -0
- package/coverage/src/loop-reducer.ts.html +559 -0
- package/coverage/src/monitor-completion-coordinator.ts.html +157 -0
- package/coverage/src/monitor-manager.ts.html +865 -0
- package/coverage/src/monitor-reducer.ts.html +583 -0
- package/coverage/src/notification-reducer.ts.html +550 -0
- package/coverage/src/reducer-backed-store.ts.html +589 -0
- package/coverage/src/runtime/index.html +191 -0
- package/coverage/src/runtime/monitor-ondone-runtime.ts.html +310 -0
- package/coverage/src/runtime/notification-runtime.ts.html +721 -0
- package/coverage/src/runtime/scope.ts.html +196 -0
- package/coverage/src/runtime/session-runtime.ts.html +589 -0
- package/coverage/src/runtime/task-backlog-runtime.ts.html +574 -0
- package/coverage/src/runtime/task-rpc.ts.html +535 -0
- package/coverage/src/scheduler.ts.html +400 -0
- package/coverage/src/store.ts.html +667 -0
- package/coverage/src/task-backlog-coordinator.ts.html +181 -0
- package/coverage/src/task-reducer.ts.html +550 -0
- package/coverage/src/task-store.ts.html +526 -0
- package/coverage/src/tools/index.html +146 -0
- package/coverage/src/tools/loop-tools.ts.html +1000 -0
- package/coverage/src/tools/monitor-tools.ts.html +547 -0
- package/coverage/src/tools/native-task-tools.ts.html +535 -0
- package/coverage/src/trigger-system.ts.html +547 -0
- package/coverage/src/ui/index.html +116 -0
- package/coverage/src/ui/widget.ts.html +292 -0
- package/dist/goal-reducer.d.ts +9 -0
- package/dist/goal-reducer.js +11 -2
- package/dist/goal-store.d.ts +4 -15
- package/dist/goal-store.js +32 -155
- package/dist/index.js +2 -1
- package/dist/loop-reducer.d.ts +7 -0
- package/dist/loop-reducer.js +9 -0
- package/dist/reducer-backed-store.d.ts +65 -0
- package/dist/reducer-backed-store.js +160 -0
- package/dist/store.d.ts +4 -16
- package/dist/store.js +25 -157
- package/dist/task-reducer.js +3 -0
- package/dist/task-store.d.ts +4 -15
- package/dist/task-store.js +25 -148
- package/dist/tools/monitor-tools.js +9 -0
- package/dist/trigger-system.js +2 -1
- package/package.json +4 -1
- package/src/goal-reducer.ts +20 -1
- package/src/goal-store.ts +35 -143
- package/src/index.ts +2 -1
- package/src/loop-reducer.ts +10 -0
- package/src/reducer-backed-store.ts +168 -0
- package/src/store.ts +28 -142
- package/src/task-reducer.ts +3 -0
- package/src/task-store.ts +28 -136
- package/src/tools/monitor-tools.ts +9 -0
- package/src/trigger-system.ts +2 -1
- package/vitest.config.ts +25 -0
- package/DIFFERENTIAL_REVIEW_REPORT.md +0 -81
package/src/goal-store.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
1
|
import { homedir } from "node:os";
|
|
3
|
-
import {
|
|
2
|
+
import { join } from "node:path";
|
|
4
3
|
import { type GoalReducerEvent, reduceGoalState } from "./goal-reducer.js";
|
|
5
4
|
import type {
|
|
6
5
|
GoalCriteria,
|
|
@@ -10,121 +9,24 @@ import type {
|
|
|
10
9
|
GoalScope,
|
|
11
10
|
GoalStoreData,
|
|
12
11
|
} from "./goal-types.js";
|
|
12
|
+
import { ReducerBackedStore } from "./reducer-backed-store.js";
|
|
13
13
|
|
|
14
14
|
const GOALS_DIR = join(homedir(), ".pi", "goals");
|
|
15
|
-
const LOCK_RETRY_MS = 50;
|
|
16
|
-
const LOCK_MAX_RETRIES = 100;
|
|
17
15
|
const MAX_GOALS = 200;
|
|
18
16
|
|
|
19
|
-
|
|
20
|
-
mkdirSync(dirname(lockPath), { recursive: true });
|
|
21
|
-
for (let i = 0; i < LOCK_MAX_RETRIES; i++) {
|
|
22
|
-
try {
|
|
23
|
-
writeFileSync(lockPath, `${process.pid}`, { flag: "wx" });
|
|
24
|
-
return;
|
|
25
|
-
} catch (e: any) {
|
|
26
|
-
if (e.code === "EEXIST") {
|
|
27
|
-
try {
|
|
28
|
-
const pid = parseInt(readFileSync(lockPath, "utf-8"), 10);
|
|
29
|
-
if (!pid || !isProcessRunning(pid)) {
|
|
30
|
-
try { unlinkSync(lockPath); } catch { /* ignore */ }
|
|
31
|
-
continue;
|
|
32
|
-
}
|
|
33
|
-
} catch { /* ignore read errors */ }
|
|
34
|
-
const start = Date.now();
|
|
35
|
-
while (Date.now() - start < LOCK_RETRY_MS) { /* busy wait */ }
|
|
36
|
-
continue;
|
|
37
|
-
}
|
|
38
|
-
throw e;
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
throw new Error(`Failed to acquire lock: ${lockPath}`);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function releaseLock(lockPath: string): void {
|
|
45
|
-
try { unlinkSync(lockPath); } catch { /* ignore */ }
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function isProcessRunning(pid: number): boolean {
|
|
49
|
-
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export class GoalStore {
|
|
53
|
-
private filePath: string | undefined;
|
|
54
|
-
private lockPath: string | undefined;
|
|
55
|
-
private lastLoadedSignature: string | undefined;
|
|
56
|
-
|
|
57
|
-
private nextId = 1;
|
|
58
|
-
private goals = new Map<string, GoalEntry>();
|
|
59
|
-
|
|
17
|
+
export class GoalStore extends ReducerBackedStore<GoalEntry, GoalReducerState, GoalReducerEvent, GoalStoreData> {
|
|
60
18
|
constructor(listIdOrPath?: string) {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const stat = statSync(this.filePath);
|
|
73
|
-
return `${stat.mtimeMs}:${stat.size}`;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
private load(force = false): void {
|
|
77
|
-
if (!this.filePath) return;
|
|
78
|
-
const signature = this.getFileSignature();
|
|
79
|
-
if (!signature) return;
|
|
80
|
-
if (!force && signature === this.lastLoadedSignature) return;
|
|
81
|
-
try {
|
|
82
|
-
const data: GoalStoreData = JSON.parse(readFileSync(this.filePath, "utf-8"));
|
|
83
|
-
this.nextId = data.nextId;
|
|
84
|
-
this.goals.clear();
|
|
85
|
-
for (const goal of data.goals) {
|
|
86
|
-
this.goals.set(goal.id, goal);
|
|
87
|
-
}
|
|
88
|
-
this.lastLoadedSignature = signature;
|
|
89
|
-
} catch { /* corrupt file — start fresh */ }
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
private save(): void {
|
|
93
|
-
if (!this.filePath) return;
|
|
94
|
-
const data: GoalStoreData = {
|
|
95
|
-
nextId: this.nextId,
|
|
96
|
-
goals: Array.from(this.goals.values()),
|
|
97
|
-
};
|
|
98
|
-
const tmpPath = this.filePath + ".tmp";
|
|
99
|
-
writeFileSync(tmpPath, JSON.stringify(data, null, 2));
|
|
100
|
-
renameSync(tmpPath, this.filePath);
|
|
101
|
-
this.lastLoadedSignature = this.getFileSignature();
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
private withLock<T>(fn: () => T): T {
|
|
105
|
-
if (!this.lockPath) return fn();
|
|
106
|
-
acquireLock(this.lockPath);
|
|
107
|
-
try {
|
|
108
|
-
this.load(true);
|
|
109
|
-
const result = fn();
|
|
110
|
-
this.save();
|
|
111
|
-
return result;
|
|
112
|
-
} finally {
|
|
113
|
-
releaseLock(this.lockPath);
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
private toReducerState(): GoalReducerState {
|
|
118
|
-
return {
|
|
119
|
-
nextId: this.nextId,
|
|
120
|
-
goalsById: Object.fromEntries(this.goals.entries()),
|
|
121
|
-
};
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
private applyReducerEvent(event: GoalReducerEvent): void {
|
|
125
|
-
const result = reduceGoalState(this.toReducerState(), event);
|
|
126
|
-
this.nextId = result.state.nextId;
|
|
127
|
-
this.goals = new Map(Object.entries(result.state.goalsById));
|
|
19
|
+
super(
|
|
20
|
+
{
|
|
21
|
+
baseDir: GOALS_DIR,
|
|
22
|
+
reduce: (state, event) => reduceGoalState(state, event),
|
|
23
|
+
toReducerState: (nextId, entries) => ({ nextId, goalsById: Object.fromEntries(entries.entries()) }),
|
|
24
|
+
fromReducerState: (state) => ({ nextId: state.nextId, entries: new Map(Object.entries(state.goalsById)) }),
|
|
25
|
+
serialize: (nextId, entries) => ({ nextId, goals: Array.from(entries.values()) }),
|
|
26
|
+
deserialize: (data) => ({ nextId: data.nextId, entries: new Map(data.goals.map((g) => [g.id, g])) }),
|
|
27
|
+
},
|
|
28
|
+
listIdOrPath,
|
|
29
|
+
);
|
|
128
30
|
}
|
|
129
31
|
|
|
130
32
|
create(
|
|
@@ -135,7 +37,7 @@ export class GoalStore {
|
|
|
135
37
|
metadata?: Record<string, unknown>,
|
|
136
38
|
): GoalEntry {
|
|
137
39
|
return this.withLock(() => {
|
|
138
|
-
if (this.
|
|
40
|
+
if (this.entries.size >= MAX_GOALS) {
|
|
139
41
|
throw new Error(`Maximum of ${MAX_GOALS} goals reached. Archive some before creating new ones.`);
|
|
140
42
|
}
|
|
141
43
|
this.applyReducerEvent({
|
|
@@ -151,23 +53,13 @@ export class GoalStore {
|
|
|
151
53
|
metadata,
|
|
152
54
|
},
|
|
153
55
|
});
|
|
154
|
-
return this.
|
|
56
|
+
return this.entries.get(String(this.nextId - 1))!;
|
|
155
57
|
});
|
|
156
58
|
}
|
|
157
59
|
|
|
158
|
-
get(id: string): GoalEntry | undefined {
|
|
159
|
-
if (this.filePath) this.load();
|
|
160
|
-
return this.goals.get(id);
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
list(): GoalEntry[] {
|
|
164
|
-
if (this.filePath) this.load();
|
|
165
|
-
return Array.from(this.goals.values()).sort((a, b) => Number(a.id) - Number(b.id));
|
|
166
|
-
}
|
|
167
|
-
|
|
168
60
|
activate(id: string): GoalEntry | undefined {
|
|
169
61
|
return this.withLock(() => {
|
|
170
|
-
if (!this.
|
|
62
|
+
if (!this.entries.has(id)) return undefined;
|
|
171
63
|
this.applyReducerEvent({
|
|
172
64
|
type: "GOAL_ACTIVATED",
|
|
173
65
|
at: Date.now(),
|
|
@@ -176,13 +68,13 @@ export class GoalStore {
|
|
|
176
68
|
entityId: id,
|
|
177
69
|
payload: { id },
|
|
178
70
|
});
|
|
179
|
-
return this.
|
|
71
|
+
return this.entries.get(id);
|
|
180
72
|
});
|
|
181
73
|
}
|
|
182
74
|
|
|
183
75
|
recordProgress(id: string, progress: GoalProgressSnapshot): GoalEntry | undefined {
|
|
184
76
|
return this.withLock(() => {
|
|
185
|
-
if (!this.
|
|
77
|
+
if (!this.entries.has(id)) return undefined;
|
|
186
78
|
this.applyReducerEvent({
|
|
187
79
|
type: "GOAL_PROGRESS_RECORDED",
|
|
188
80
|
at: Date.now(),
|
|
@@ -191,13 +83,13 @@ export class GoalStore {
|
|
|
191
83
|
entityId: id,
|
|
192
84
|
payload: { id, progress },
|
|
193
85
|
});
|
|
194
|
-
return this.
|
|
86
|
+
return this.entries.get(id);
|
|
195
87
|
});
|
|
196
88
|
}
|
|
197
89
|
|
|
198
90
|
markVerificationStarted(id: string): GoalEntry | undefined {
|
|
199
91
|
return this.withLock(() => {
|
|
200
|
-
if (!this.
|
|
92
|
+
if (!this.entries.has(id)) return undefined;
|
|
201
93
|
this.applyReducerEvent({
|
|
202
94
|
type: "GOAL_VERIFICATION_STARTED",
|
|
203
95
|
at: Date.now(),
|
|
@@ -206,13 +98,13 @@ export class GoalStore {
|
|
|
206
98
|
entityId: id,
|
|
207
99
|
payload: { id },
|
|
208
100
|
});
|
|
209
|
-
return this.
|
|
101
|
+
return this.entries.get(id);
|
|
210
102
|
});
|
|
211
103
|
}
|
|
212
104
|
|
|
213
105
|
markVerified(id: string, reason: string, progress?: GoalProgressSnapshot): GoalEntry | undefined {
|
|
214
106
|
return this.withLock(() => {
|
|
215
|
-
if (!this.
|
|
107
|
+
if (!this.entries.has(id)) return undefined;
|
|
216
108
|
this.applyReducerEvent({
|
|
217
109
|
type: "GOAL_VERIFICATION_PASSED",
|
|
218
110
|
at: Date.now(),
|
|
@@ -221,13 +113,13 @@ export class GoalStore {
|
|
|
221
113
|
entityId: id,
|
|
222
114
|
payload: { id, reason, progress },
|
|
223
115
|
});
|
|
224
|
-
return this.
|
|
116
|
+
return this.entries.get(id);
|
|
225
117
|
});
|
|
226
118
|
}
|
|
227
119
|
|
|
228
120
|
markFailed(id: string, reason: string, progress?: GoalProgressSnapshot): GoalEntry | undefined {
|
|
229
121
|
return this.withLock(() => {
|
|
230
|
-
if (!this.
|
|
122
|
+
if (!this.entries.has(id)) return undefined;
|
|
231
123
|
this.applyReducerEvent({
|
|
232
124
|
type: "GOAL_FAILED",
|
|
233
125
|
at: Date.now(),
|
|
@@ -236,13 +128,13 @@ export class GoalStore {
|
|
|
236
128
|
entityId: id,
|
|
237
129
|
payload: { id, reason, progress },
|
|
238
130
|
});
|
|
239
|
-
return this.
|
|
131
|
+
return this.entries.get(id);
|
|
240
132
|
});
|
|
241
133
|
}
|
|
242
134
|
|
|
243
135
|
markBlocked(id: string, reason: string, progress?: GoalProgressSnapshot): GoalEntry | undefined {
|
|
244
136
|
return this.withLock(() => {
|
|
245
|
-
if (!this.
|
|
137
|
+
if (!this.entries.has(id)) return undefined;
|
|
246
138
|
this.applyReducerEvent({
|
|
247
139
|
type: "GOAL_BLOCKED",
|
|
248
140
|
at: Date.now(),
|
|
@@ -251,13 +143,13 @@ export class GoalStore {
|
|
|
251
143
|
entityId: id,
|
|
252
144
|
payload: { id, reason, progress },
|
|
253
145
|
});
|
|
254
|
-
return this.
|
|
146
|
+
return this.entries.get(id);
|
|
255
147
|
});
|
|
256
148
|
}
|
|
257
149
|
|
|
258
150
|
unblock(id: string): GoalEntry | undefined {
|
|
259
151
|
return this.withLock(() => {
|
|
260
|
-
if (!this.
|
|
152
|
+
if (!this.entries.has(id)) return undefined;
|
|
261
153
|
this.applyReducerEvent({
|
|
262
154
|
type: "GOAL_UNBLOCKED",
|
|
263
155
|
at: Date.now(),
|
|
@@ -266,7 +158,7 @@ export class GoalStore {
|
|
|
266
158
|
entityId: id,
|
|
267
159
|
payload: { id },
|
|
268
160
|
});
|
|
269
|
-
return this.
|
|
161
|
+
return this.entries.get(id);
|
|
270
162
|
});
|
|
271
163
|
}
|
|
272
164
|
|
|
@@ -281,7 +173,7 @@ export class GoalStore {
|
|
|
281
173
|
},
|
|
282
174
|
): GoalEntry | undefined {
|
|
283
175
|
return this.withLock(() => {
|
|
284
|
-
if (!this.
|
|
176
|
+
if (!this.entries.has(id)) return undefined;
|
|
285
177
|
if (
|
|
286
178
|
fields.title === undefined
|
|
287
179
|
&& fields.description === undefined
|
|
@@ -289,7 +181,7 @@ export class GoalStore {
|
|
|
289
181
|
&& fields.criteria === undefined
|
|
290
182
|
&& fields.metadata === undefined
|
|
291
183
|
) {
|
|
292
|
-
return this.
|
|
184
|
+
return this.entries.get(id);
|
|
293
185
|
}
|
|
294
186
|
this.applyReducerEvent({
|
|
295
187
|
type: "GOAL_UPDATED",
|
|
@@ -306,13 +198,13 @@ export class GoalStore {
|
|
|
306
198
|
metadata: fields.metadata,
|
|
307
199
|
},
|
|
308
200
|
});
|
|
309
|
-
return this.
|
|
201
|
+
return this.entries.get(id);
|
|
310
202
|
});
|
|
311
203
|
}
|
|
312
204
|
|
|
313
205
|
archive(id: string, reason?: string): GoalEntry | undefined {
|
|
314
206
|
return this.withLock(() => {
|
|
315
|
-
if (!this.
|
|
207
|
+
if (!this.entries.has(id)) return undefined;
|
|
316
208
|
this.applyReducerEvent({
|
|
317
209
|
type: "GOAL_ARCHIVED",
|
|
318
210
|
at: Date.now(),
|
|
@@ -321,7 +213,7 @@ export class GoalStore {
|
|
|
321
213
|
entityId: id,
|
|
322
214
|
payload: { id, reason },
|
|
323
215
|
});
|
|
324
|
-
return this.
|
|
216
|
+
return this.entries.get(id);
|
|
325
217
|
});
|
|
326
218
|
}
|
|
327
219
|
}
|
package/src/index.ts
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
18
18
|
import { registerLoopCommand } from "./commands/loop-command.js";
|
|
19
19
|
import { registerTasksCommand } from "./commands/tasks-command.js";
|
|
20
|
+
import { atMaxFires } from "./loop-reducer.js";
|
|
20
21
|
import { MonitorManager } from "./monitor-manager.js";
|
|
21
22
|
import { createMonitorOnDoneRuntime } from "./runtime/monitor-ondone-runtime.js";
|
|
22
23
|
import {
|
|
@@ -178,7 +179,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
178
179
|
function onLoopFire(entry: LoopEntry): void {
|
|
179
180
|
debug(`loop:fire #${entry.id}`, { prompt: entry.prompt.slice(0, 50) });
|
|
180
181
|
|
|
181
|
-
if (
|
|
182
|
+
if (atMaxFires(entry)) {
|
|
182
183
|
debug(`loop #${entry.id} — reached maxFires ${entry.maxFires}, expiring`);
|
|
183
184
|
store.delete(entry.id);
|
|
184
185
|
return;
|
package/src/loop-reducer.ts
CHANGED
|
@@ -2,6 +2,16 @@ import type { LoopEntry, Trigger } from "./types.js";
|
|
|
2
2
|
|
|
3
3
|
export const MAX_LOOP_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Whether a loop has reached its fire cap. Single source of truth for the
|
|
7
|
+
* `maxFires` check shared by the fire callbacks (`onLoopFire` pre-fire guard and
|
|
8
|
+
* `TriggerSystem.fireLoop` post-fire cleanup). Each caller keeps its own timing;
|
|
9
|
+
* only the predicate is shared.
|
|
10
|
+
*/
|
|
11
|
+
export function atMaxFires(loop: Pick<LoopEntry, "maxFires" | "fireCount">): boolean {
|
|
12
|
+
return !!loop.maxFires && (loop.fireCount ?? 0) >= loop.maxFires;
|
|
13
|
+
}
|
|
14
|
+
|
|
5
15
|
type ReducerSource = "tool" | "command" | "scheduler" | "eventbus" | "monitor" | "session" | "coordinator" | "system";
|
|
6
16
|
|
|
7
17
|
export interface LoopReducerState {
|
|
@@ -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
|
+
}
|