@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.
- 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/commands/loop-command.js +4 -5
- package/dist/commands/tasks-command.js +3 -3
- 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 -154
- package/dist/index.d.ts +2 -2
- package/dist/index.js +4 -3
- package/dist/loop-reducer.d.ts +7 -0
- package/dist/loop-reducer.js +9 -0
- package/dist/monitor-manager.js +4 -4
- 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 -156
- package/dist/task-reducer.js +3 -0
- package/dist/task-store.d.ts +4 -15
- package/dist/task-store.js +25 -147
- package/dist/tools/loop-tools.js +7 -5
- package/dist/tools/monitor-tools.js +12 -3
- package/dist/tools/native-task-tools.js +2 -2
- package/dist/trigger-system.js +2 -1
- package/dist/ui/widget.js +8 -1
- package/package.json +4 -1
- package/src/commands/loop-command.ts +4 -5
- package/src/commands/tasks-command.ts +3 -3
- package/src/goal-reducer.ts +20 -1
- package/src/goal-store.ts +35 -142
- package/src/index.ts +4 -3
- package/src/loop-reducer.ts +10 -0
- package/src/monitor-manager.ts +2 -3
- package/src/reducer-backed-store.ts +168 -0
- package/src/store.ts +28 -141
- package/src/task-reducer.ts +3 -0
- package/src/task-store.ts +28 -135
- package/src/tools/loop-tools.ts +6 -5
- package/src/tools/monitor-tools.ts +12 -3
- package/src/tools/native-task-tools.ts +2 -2
- package/src/trigger-system.ts +2 -1
- package/src/ui/widget.ts +8 -1
- package/vitest.config.ts +25 -0
- package/DIFFERENTIAL_REVIEW_REPORT.md +0 -81
package/dist/goal-store.js
CHANGED
|
@@ -1,135 +1,23 @@
|
|
|
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 { reduceGoalState } from "./goal-reducer.js";
|
|
4
|
+
import { ReducerBackedStore } from "./reducer-backed-store.js";
|
|
5
5
|
const GOALS_DIR = join(homedir(), ".pi", "goals");
|
|
6
|
-
const LOCK_RETRY_MS = 50;
|
|
7
|
-
const LOCK_MAX_RETRIES = 100;
|
|
8
6
|
const MAX_GOALS = 200;
|
|
9
|
-
|
|
10
|
-
for (let i = 0; i < LOCK_MAX_RETRIES; i++) {
|
|
11
|
-
try {
|
|
12
|
-
writeFileSync(lockPath, `${process.pid}`, { flag: "wx" });
|
|
13
|
-
return;
|
|
14
|
-
}
|
|
15
|
-
catch (e) {
|
|
16
|
-
if (e.code === "EEXIST") {
|
|
17
|
-
try {
|
|
18
|
-
const pid = parseInt(readFileSync(lockPath, "utf-8"), 10);
|
|
19
|
-
if (!pid || !isProcessRunning(pid)) {
|
|
20
|
-
try {
|
|
21
|
-
unlinkSync(lockPath);
|
|
22
|
-
}
|
|
23
|
-
catch { /* ignore */ }
|
|
24
|
-
continue;
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
catch { /* ignore read errors */ }
|
|
28
|
-
const start = Date.now();
|
|
29
|
-
while (Date.now() - start < LOCK_RETRY_MS) { /* busy wait */ }
|
|
30
|
-
continue;
|
|
31
|
-
}
|
|
32
|
-
throw e;
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
throw new Error(`Failed to acquire lock: ${lockPath}`);
|
|
36
|
-
}
|
|
37
|
-
function releaseLock(lockPath) {
|
|
38
|
-
try {
|
|
39
|
-
unlinkSync(lockPath);
|
|
40
|
-
}
|
|
41
|
-
catch { /* ignore */ }
|
|
42
|
-
}
|
|
43
|
-
function isProcessRunning(pid) {
|
|
44
|
-
try {
|
|
45
|
-
process.kill(pid, 0);
|
|
46
|
-
return true;
|
|
47
|
-
}
|
|
48
|
-
catch {
|
|
49
|
-
return false;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
export class GoalStore {
|
|
53
|
-
filePath;
|
|
54
|
-
lockPath;
|
|
55
|
-
lastLoadedSignature;
|
|
56
|
-
nextId = 1;
|
|
57
|
-
goals = new Map();
|
|
7
|
+
export class GoalStore extends ReducerBackedStore {
|
|
58
8
|
constructor(listIdOrPath) {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
}
|
|
68
|
-
getFileSignature() {
|
|
69
|
-
if (!this.filePath || !existsSync(this.filePath))
|
|
70
|
-
return undefined;
|
|
71
|
-
const stat = statSync(this.filePath);
|
|
72
|
-
return `${stat.mtimeMs}:${stat.size}`;
|
|
73
|
-
}
|
|
74
|
-
load(force = false) {
|
|
75
|
-
if (!this.filePath)
|
|
76
|
-
return;
|
|
77
|
-
const signature = this.getFileSignature();
|
|
78
|
-
if (!signature)
|
|
79
|
-
return;
|
|
80
|
-
if (!force && signature === this.lastLoadedSignature)
|
|
81
|
-
return;
|
|
82
|
-
try {
|
|
83
|
-
const data = JSON.parse(readFileSync(this.filePath, "utf-8"));
|
|
84
|
-
this.nextId = data.nextId;
|
|
85
|
-
this.goals.clear();
|
|
86
|
-
for (const goal of data.goals) {
|
|
87
|
-
this.goals.set(goal.id, goal);
|
|
88
|
-
}
|
|
89
|
-
this.lastLoadedSignature = signature;
|
|
90
|
-
}
|
|
91
|
-
catch { /* corrupt file — start fresh */ }
|
|
92
|
-
}
|
|
93
|
-
save() {
|
|
94
|
-
if (!this.filePath)
|
|
95
|
-
return;
|
|
96
|
-
const data = {
|
|
97
|
-
nextId: this.nextId,
|
|
98
|
-
goals: Array.from(this.goals.values()),
|
|
99
|
-
};
|
|
100
|
-
const tmpPath = this.filePath + ".tmp";
|
|
101
|
-
writeFileSync(tmpPath, JSON.stringify(data, null, 2));
|
|
102
|
-
renameSync(tmpPath, this.filePath);
|
|
103
|
-
this.lastLoadedSignature = this.getFileSignature();
|
|
104
|
-
}
|
|
105
|
-
withLock(fn) {
|
|
106
|
-
if (!this.lockPath)
|
|
107
|
-
return fn();
|
|
108
|
-
acquireLock(this.lockPath);
|
|
109
|
-
try {
|
|
110
|
-
this.load(true);
|
|
111
|
-
const result = fn();
|
|
112
|
-
this.save();
|
|
113
|
-
return result;
|
|
114
|
-
}
|
|
115
|
-
finally {
|
|
116
|
-
releaseLock(this.lockPath);
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
toReducerState() {
|
|
120
|
-
return {
|
|
121
|
-
nextId: this.nextId,
|
|
122
|
-
goalsById: Object.fromEntries(this.goals.entries()),
|
|
123
|
-
};
|
|
124
|
-
}
|
|
125
|
-
applyReducerEvent(event) {
|
|
126
|
-
const result = reduceGoalState(this.toReducerState(), event);
|
|
127
|
-
this.nextId = result.state.nextId;
|
|
128
|
-
this.goals = new Map(Object.entries(result.state.goalsById));
|
|
9
|
+
super({
|
|
10
|
+
baseDir: GOALS_DIR,
|
|
11
|
+
reduce: (state, event) => reduceGoalState(state, event),
|
|
12
|
+
toReducerState: (nextId, entries) => ({ nextId, goalsById: Object.fromEntries(entries.entries()) }),
|
|
13
|
+
fromReducerState: (state) => ({ nextId: state.nextId, entries: new Map(Object.entries(state.goalsById)) }),
|
|
14
|
+
serialize: (nextId, entries) => ({ nextId, goals: Array.from(entries.values()) }),
|
|
15
|
+
deserialize: (data) => ({ nextId: data.nextId, entries: new Map(data.goals.map((g) => [g.id, g])) }),
|
|
16
|
+
}, listIdOrPath);
|
|
129
17
|
}
|
|
130
18
|
create(title, description, scope, criteria, metadata) {
|
|
131
19
|
return this.withLock(() => {
|
|
132
|
-
if (this.
|
|
20
|
+
if (this.entries.size >= MAX_GOALS) {
|
|
133
21
|
throw new Error(`Maximum of ${MAX_GOALS} goals reached. Archive some before creating new ones.`);
|
|
134
22
|
}
|
|
135
23
|
this.applyReducerEvent({
|
|
@@ -145,22 +33,12 @@ export class GoalStore {
|
|
|
145
33
|
metadata,
|
|
146
34
|
},
|
|
147
35
|
});
|
|
148
|
-
return this.
|
|
36
|
+
return this.entries.get(String(this.nextId - 1));
|
|
149
37
|
});
|
|
150
38
|
}
|
|
151
|
-
get(id) {
|
|
152
|
-
if (this.filePath)
|
|
153
|
-
this.load();
|
|
154
|
-
return this.goals.get(id);
|
|
155
|
-
}
|
|
156
|
-
list() {
|
|
157
|
-
if (this.filePath)
|
|
158
|
-
this.load();
|
|
159
|
-
return Array.from(this.goals.values()).sort((a, b) => Number(a.id) - Number(b.id));
|
|
160
|
-
}
|
|
161
39
|
activate(id) {
|
|
162
40
|
return this.withLock(() => {
|
|
163
|
-
if (!this.
|
|
41
|
+
if (!this.entries.has(id))
|
|
164
42
|
return undefined;
|
|
165
43
|
this.applyReducerEvent({
|
|
166
44
|
type: "GOAL_ACTIVATED",
|
|
@@ -170,12 +48,12 @@ export class GoalStore {
|
|
|
170
48
|
entityId: id,
|
|
171
49
|
payload: { id },
|
|
172
50
|
});
|
|
173
|
-
return this.
|
|
51
|
+
return this.entries.get(id);
|
|
174
52
|
});
|
|
175
53
|
}
|
|
176
54
|
recordProgress(id, progress) {
|
|
177
55
|
return this.withLock(() => {
|
|
178
|
-
if (!this.
|
|
56
|
+
if (!this.entries.has(id))
|
|
179
57
|
return undefined;
|
|
180
58
|
this.applyReducerEvent({
|
|
181
59
|
type: "GOAL_PROGRESS_RECORDED",
|
|
@@ -185,12 +63,12 @@ export class GoalStore {
|
|
|
185
63
|
entityId: id,
|
|
186
64
|
payload: { id, progress },
|
|
187
65
|
});
|
|
188
|
-
return this.
|
|
66
|
+
return this.entries.get(id);
|
|
189
67
|
});
|
|
190
68
|
}
|
|
191
69
|
markVerificationStarted(id) {
|
|
192
70
|
return this.withLock(() => {
|
|
193
|
-
if (!this.
|
|
71
|
+
if (!this.entries.has(id))
|
|
194
72
|
return undefined;
|
|
195
73
|
this.applyReducerEvent({
|
|
196
74
|
type: "GOAL_VERIFICATION_STARTED",
|
|
@@ -200,12 +78,12 @@ export class GoalStore {
|
|
|
200
78
|
entityId: id,
|
|
201
79
|
payload: { id },
|
|
202
80
|
});
|
|
203
|
-
return this.
|
|
81
|
+
return this.entries.get(id);
|
|
204
82
|
});
|
|
205
83
|
}
|
|
206
84
|
markVerified(id, reason, progress) {
|
|
207
85
|
return this.withLock(() => {
|
|
208
|
-
if (!this.
|
|
86
|
+
if (!this.entries.has(id))
|
|
209
87
|
return undefined;
|
|
210
88
|
this.applyReducerEvent({
|
|
211
89
|
type: "GOAL_VERIFICATION_PASSED",
|
|
@@ -215,12 +93,12 @@ export class GoalStore {
|
|
|
215
93
|
entityId: id,
|
|
216
94
|
payload: { id, reason, progress },
|
|
217
95
|
});
|
|
218
|
-
return this.
|
|
96
|
+
return this.entries.get(id);
|
|
219
97
|
});
|
|
220
98
|
}
|
|
221
99
|
markFailed(id, reason, progress) {
|
|
222
100
|
return this.withLock(() => {
|
|
223
|
-
if (!this.
|
|
101
|
+
if (!this.entries.has(id))
|
|
224
102
|
return undefined;
|
|
225
103
|
this.applyReducerEvent({
|
|
226
104
|
type: "GOAL_FAILED",
|
|
@@ -230,12 +108,12 @@ export class GoalStore {
|
|
|
230
108
|
entityId: id,
|
|
231
109
|
payload: { id, reason, progress },
|
|
232
110
|
});
|
|
233
|
-
return this.
|
|
111
|
+
return this.entries.get(id);
|
|
234
112
|
});
|
|
235
113
|
}
|
|
236
114
|
markBlocked(id, reason, progress) {
|
|
237
115
|
return this.withLock(() => {
|
|
238
|
-
if (!this.
|
|
116
|
+
if (!this.entries.has(id))
|
|
239
117
|
return undefined;
|
|
240
118
|
this.applyReducerEvent({
|
|
241
119
|
type: "GOAL_BLOCKED",
|
|
@@ -245,12 +123,12 @@ export class GoalStore {
|
|
|
245
123
|
entityId: id,
|
|
246
124
|
payload: { id, reason, progress },
|
|
247
125
|
});
|
|
248
|
-
return this.
|
|
126
|
+
return this.entries.get(id);
|
|
249
127
|
});
|
|
250
128
|
}
|
|
251
129
|
unblock(id) {
|
|
252
130
|
return this.withLock(() => {
|
|
253
|
-
if (!this.
|
|
131
|
+
if (!this.entries.has(id))
|
|
254
132
|
return undefined;
|
|
255
133
|
this.applyReducerEvent({
|
|
256
134
|
type: "GOAL_UNBLOCKED",
|
|
@@ -260,19 +138,19 @@ export class GoalStore {
|
|
|
260
138
|
entityId: id,
|
|
261
139
|
payload: { id },
|
|
262
140
|
});
|
|
263
|
-
return this.
|
|
141
|
+
return this.entries.get(id);
|
|
264
142
|
});
|
|
265
143
|
}
|
|
266
144
|
updateDetails(id, fields) {
|
|
267
145
|
return this.withLock(() => {
|
|
268
|
-
if (!this.
|
|
146
|
+
if (!this.entries.has(id))
|
|
269
147
|
return undefined;
|
|
270
148
|
if (fields.title === undefined
|
|
271
149
|
&& fields.description === undefined
|
|
272
150
|
&& fields.scope === undefined
|
|
273
151
|
&& fields.criteria === undefined
|
|
274
152
|
&& fields.metadata === undefined) {
|
|
275
|
-
return this.
|
|
153
|
+
return this.entries.get(id);
|
|
276
154
|
}
|
|
277
155
|
this.applyReducerEvent({
|
|
278
156
|
type: "GOAL_UPDATED",
|
|
@@ -289,12 +167,12 @@ export class GoalStore {
|
|
|
289
167
|
metadata: fields.metadata,
|
|
290
168
|
},
|
|
291
169
|
});
|
|
292
|
-
return this.
|
|
170
|
+
return this.entries.get(id);
|
|
293
171
|
});
|
|
294
172
|
}
|
|
295
173
|
archive(id, reason) {
|
|
296
174
|
return this.withLock(() => {
|
|
297
|
-
if (!this.
|
|
175
|
+
if (!this.entries.has(id))
|
|
298
176
|
return undefined;
|
|
299
177
|
this.applyReducerEvent({
|
|
300
178
|
type: "GOAL_ARCHIVED",
|
|
@@ -304,7 +182,7 @@ export class GoalStore {
|
|
|
304
182
|
entityId: id,
|
|
305
183
|
payload: { id, reason },
|
|
306
184
|
});
|
|
307
|
-
return this.
|
|
185
|
+
return this.entries.get(id);
|
|
308
186
|
});
|
|
309
187
|
}
|
|
310
188
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
* MonitorStop — Stop a running monitor
|
|
11
11
|
*
|
|
12
12
|
* Commands:
|
|
13
|
-
* /loop — Schedule
|
|
14
|
-
* /
|
|
13
|
+
* /loop — Schedule or manage re-wake loops: /loop [interval] [prompt]
|
|
14
|
+
* /tasks — View or manage native fallback tasks when pi-tasks is absent
|
|
15
15
|
*/
|
|
16
16
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
17
17
|
export default function (pi: ExtensionAPI): void;
|
package/dist/index.js
CHANGED
|
@@ -10,11 +10,12 @@
|
|
|
10
10
|
* MonitorStop — Stop a running monitor
|
|
11
11
|
*
|
|
12
12
|
* Commands:
|
|
13
|
-
* /loop — Schedule
|
|
14
|
-
* /
|
|
13
|
+
* /loop — Schedule or manage re-wake loops: /loop [interval] [prompt]
|
|
14
|
+
* /tasks — View or manage native fallback tasks when pi-tasks is absent
|
|
15
15
|
*/
|
|
16
16
|
import { registerLoopCommand } from "./commands/loop-command.js";
|
|
17
17
|
import { registerTasksCommand } from "./commands/tasks-command.js";
|
|
18
|
+
import { atMaxFires } from "./loop-reducer.js";
|
|
18
19
|
import { MonitorManager } from "./monitor-manager.js";
|
|
19
20
|
import { createMonitorOnDoneRuntime } from "./runtime/monitor-ondone-runtime.js";
|
|
20
21
|
import { createNotificationRuntime, } from "./runtime/notification-runtime.js";
|
|
@@ -158,7 +159,7 @@ export default function (pi) {
|
|
|
158
159
|
// ── Loop fire handler ──
|
|
159
160
|
function onLoopFire(entry) {
|
|
160
161
|
debug(`loop:fire #${entry.id}`, { prompt: entry.prompt.slice(0, 50) });
|
|
161
|
-
if (
|
|
162
|
+
if (atMaxFires(entry)) {
|
|
162
163
|
debug(`loop #${entry.id} — reached maxFires ${entry.maxFires}, expiring`);
|
|
163
164
|
store.delete(entry.id);
|
|
164
165
|
return;
|
package/dist/loop-reducer.d.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import type { LoopEntry, Trigger } from "./types.js";
|
|
2
2
|
export declare const MAX_LOOP_EXPIRY_MS: number;
|
|
3
|
+
/**
|
|
4
|
+
* Whether a loop has reached its fire cap. Single source of truth for the
|
|
5
|
+
* `maxFires` check shared by the fire callbacks (`onLoopFire` pre-fire guard and
|
|
6
|
+
* `TriggerSystem.fireLoop` post-fire cleanup). Each caller keeps its own timing;
|
|
7
|
+
* only the predicate is shared.
|
|
8
|
+
*/
|
|
9
|
+
export declare function atMaxFires(loop: Pick<LoopEntry, "maxFires" | "fireCount">): boolean;
|
|
3
10
|
type ReducerSource = "tool" | "command" | "scheduler" | "eventbus" | "monitor" | "session" | "coordinator" | "system";
|
|
4
11
|
export interface LoopReducerState {
|
|
5
12
|
nextId: number;
|
package/dist/loop-reducer.js
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
export const MAX_LOOP_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
|
|
2
|
+
/**
|
|
3
|
+
* Whether a loop has reached its fire cap. Single source of truth for the
|
|
4
|
+
* `maxFires` check shared by the fire callbacks (`onLoopFire` pre-fire guard and
|
|
5
|
+
* `TriggerSystem.fireLoop` post-fire cleanup). Each caller keeps its own timing;
|
|
6
|
+
* only the predicate is shared.
|
|
7
|
+
*/
|
|
8
|
+
export function atMaxFires(loop) {
|
|
9
|
+
return !!loop.maxFires && (loop.fireCount ?? 0) >= loop.maxFires;
|
|
10
|
+
}
|
|
2
11
|
function cloneState(state) {
|
|
3
12
|
return {
|
|
4
13
|
nextId: state.nextId,
|
package/dist/monitor-manager.js
CHANGED
|
@@ -114,10 +114,8 @@ export class MonitorManager {
|
|
|
114
114
|
exitCode: code,
|
|
115
115
|
outputLines: current.outputLines,
|
|
116
116
|
});
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
callback();
|
|
120
|
-
}
|
|
117
|
+
for (const callback of bp.completionCallbacks)
|
|
118
|
+
callback();
|
|
121
119
|
bp.completionCallbacks = [];
|
|
122
120
|
for (const resolve of bp.waiters)
|
|
123
121
|
resolve();
|
|
@@ -157,6 +155,8 @@ export class MonitorManager {
|
|
|
157
155
|
monitorId: id,
|
|
158
156
|
error: err.message,
|
|
159
157
|
});
|
|
158
|
+
for (const callback of bp.completionCallbacks)
|
|
159
|
+
callback();
|
|
160
160
|
bp.completionCallbacks = [];
|
|
161
161
|
for (const resolve of bp.waiters)
|
|
162
162
|
resolve();
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { AnyReducerEffect } from "./coordinator.js";
|
|
2
|
+
export interface ReducerResult<TState> {
|
|
3
|
+
state: TState;
|
|
4
|
+
effects: AnyReducerEffect[];
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Per-store glue the base needs to translate between its internal
|
|
8
|
+
* `{ nextId, entries }` representation, the pure reducer's state shape, and the
|
|
9
|
+
* on-disk JSON shape. Each is a small, allocation-only function.
|
|
10
|
+
*/
|
|
11
|
+
export interface ReducerBackedStoreConfig<TEntry, TState, TEvent, TData> {
|
|
12
|
+
/** Directory for `<listId>.json` when constructed with a bare list id. */
|
|
13
|
+
baseDir: string;
|
|
14
|
+
reduce: (state: TState, event: TEvent) => ReducerResult<TState>;
|
|
15
|
+
toReducerState: (nextId: number, entries: Map<string, TEntry>) => TState;
|
|
16
|
+
fromReducerState: (state: TState) => {
|
|
17
|
+
nextId: number;
|
|
18
|
+
entries: Map<string, TEntry>;
|
|
19
|
+
};
|
|
20
|
+
serialize: (nextId: number, entries: Map<string, TEntry>) => TData;
|
|
21
|
+
deserialize: (data: TData) => {
|
|
22
|
+
nextId: number;
|
|
23
|
+
entries: Map<string, TEntry>;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Shared persistence + reducer-dispatch machinery for the file-backed entity
|
|
28
|
+
* stores (loops, tasks, goals). Owns file locking, signature-gated load, atomic
|
|
29
|
+
* save, and reducer application; subclasses add only their entity-specific
|
|
30
|
+
* command methods.
|
|
31
|
+
*
|
|
32
|
+
* Durability boundary: every mutation runs inside {@link withLock}, which saves
|
|
33
|
+
* the whole file unconditionally after the callback. Reducer effects are
|
|
34
|
+
* therefore *not* the persistence mechanism — they are surfaced to
|
|
35
|
+
* {@link onEffects} (default: no-op) so cross-entity effects (e.g.
|
|
36
|
+
* `DISPATCH_EVENT`, `REQUEST_GOAL_VERIFICATION`) can be forwarded by the runtime
|
|
37
|
+
* without being silently dropped at the reducer call site.
|
|
38
|
+
*/
|
|
39
|
+
export declare abstract class ReducerBackedStore<TEntry extends {
|
|
40
|
+
id: string;
|
|
41
|
+
}, TState, TEvent, TData> {
|
|
42
|
+
protected filePath: string | undefined;
|
|
43
|
+
protected lockPath: string | undefined;
|
|
44
|
+
private lastLoadedSignature;
|
|
45
|
+
protected nextId: number;
|
|
46
|
+
protected entries: Map<string, TEntry>;
|
|
47
|
+
private readonly config;
|
|
48
|
+
constructor(config: ReducerBackedStoreConfig<TEntry, TState, TEvent, TData>, listIdOrPath?: string);
|
|
49
|
+
private getFileSignature;
|
|
50
|
+
private load;
|
|
51
|
+
private save;
|
|
52
|
+
protected withLock<T>(fn: () => T): T;
|
|
53
|
+
protected applyReducerEvent(event: TEvent): void;
|
|
54
|
+
/**
|
|
55
|
+
* Hook for reducer effects. Default no-op: durability is owned by
|
|
56
|
+
* {@link withLock}. Override to forward non-persistence effects.
|
|
57
|
+
*/
|
|
58
|
+
protected onEffects(_effects: AnyReducerEffect[]): void;
|
|
59
|
+
/** Reload (signature-gated) and return the entry, or undefined. */
|
|
60
|
+
get(id: string): TEntry | undefined;
|
|
61
|
+
/** Reload (signature-gated) and return all entries sorted by numeric id. */
|
|
62
|
+
list(): TEntry[];
|
|
63
|
+
/** Remove the backing file when the store is empty. No-op for memory stores. */
|
|
64
|
+
deleteFileIfEmpty(): boolean;
|
|
65
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
3
|
+
const LOCK_RETRY_MS = 50;
|
|
4
|
+
const LOCK_MAX_RETRIES = 100;
|
|
5
|
+
function acquireLock(lockPath) {
|
|
6
|
+
mkdirSync(dirname(lockPath), { recursive: true });
|
|
7
|
+
for (let i = 0; i < LOCK_MAX_RETRIES; i++) {
|
|
8
|
+
try {
|
|
9
|
+
writeFileSync(lockPath, `${process.pid}`, { flag: "wx" });
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
catch (e) {
|
|
13
|
+
if (e.code === "EEXIST") {
|
|
14
|
+
try {
|
|
15
|
+
const pid = parseInt(readFileSync(lockPath, "utf-8"), 10);
|
|
16
|
+
if (!pid || !isProcessRunning(pid)) {
|
|
17
|
+
try {
|
|
18
|
+
unlinkSync(lockPath);
|
|
19
|
+
}
|
|
20
|
+
catch { /* ignore */ }
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
catch { /* ignore read errors */ }
|
|
25
|
+
const start = Date.now();
|
|
26
|
+
while (Date.now() - start < LOCK_RETRY_MS) { /* busy wait */ }
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
throw e;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
throw new Error(`Failed to acquire lock: ${lockPath}`);
|
|
33
|
+
}
|
|
34
|
+
function releaseLock(lockPath) {
|
|
35
|
+
try {
|
|
36
|
+
unlinkSync(lockPath);
|
|
37
|
+
}
|
|
38
|
+
catch { /* ignore */ }
|
|
39
|
+
}
|
|
40
|
+
function isProcessRunning(pid) {
|
|
41
|
+
try {
|
|
42
|
+
process.kill(pid, 0);
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Shared persistence + reducer-dispatch machinery for the file-backed entity
|
|
51
|
+
* stores (loops, tasks, goals). Owns file locking, signature-gated load, atomic
|
|
52
|
+
* save, and reducer application; subclasses add only their entity-specific
|
|
53
|
+
* command methods.
|
|
54
|
+
*
|
|
55
|
+
* Durability boundary: every mutation runs inside {@link withLock}, which saves
|
|
56
|
+
* the whole file unconditionally after the callback. Reducer effects are
|
|
57
|
+
* therefore *not* the persistence mechanism — they are surfaced to
|
|
58
|
+
* {@link onEffects} (default: no-op) so cross-entity effects (e.g.
|
|
59
|
+
* `DISPATCH_EVENT`, `REQUEST_GOAL_VERIFICATION`) can be forwarded by the runtime
|
|
60
|
+
* without being silently dropped at the reducer call site.
|
|
61
|
+
*/
|
|
62
|
+
export class ReducerBackedStore {
|
|
63
|
+
filePath;
|
|
64
|
+
lockPath;
|
|
65
|
+
lastLoadedSignature;
|
|
66
|
+
nextId = 1;
|
|
67
|
+
entries = new Map();
|
|
68
|
+
config;
|
|
69
|
+
constructor(config, listIdOrPath) {
|
|
70
|
+
this.config = config;
|
|
71
|
+
if (!listIdOrPath)
|
|
72
|
+
return;
|
|
73
|
+
const filePath = isAbsolute(listIdOrPath) ? listIdOrPath : join(config.baseDir, `${listIdOrPath}.json`);
|
|
74
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
75
|
+
this.filePath = filePath;
|
|
76
|
+
this.lockPath = `${filePath}.lock`;
|
|
77
|
+
this.load();
|
|
78
|
+
}
|
|
79
|
+
getFileSignature() {
|
|
80
|
+
if (!this.filePath || !existsSync(this.filePath))
|
|
81
|
+
return undefined;
|
|
82
|
+
const stat = statSync(this.filePath);
|
|
83
|
+
return `${stat.mtimeMs}:${stat.size}`;
|
|
84
|
+
}
|
|
85
|
+
load(force = false) {
|
|
86
|
+
if (!this.filePath)
|
|
87
|
+
return;
|
|
88
|
+
const signature = this.getFileSignature();
|
|
89
|
+
if (!signature)
|
|
90
|
+
return;
|
|
91
|
+
if (!force && signature === this.lastLoadedSignature)
|
|
92
|
+
return;
|
|
93
|
+
try {
|
|
94
|
+
const data = JSON.parse(readFileSync(this.filePath, "utf-8"));
|
|
95
|
+
const { nextId, entries } = this.config.deserialize(data);
|
|
96
|
+
this.nextId = nextId;
|
|
97
|
+
this.entries = entries;
|
|
98
|
+
this.lastLoadedSignature = signature;
|
|
99
|
+
}
|
|
100
|
+
catch { /* corrupt file — start fresh */ }
|
|
101
|
+
}
|
|
102
|
+
save() {
|
|
103
|
+
if (!this.filePath)
|
|
104
|
+
return;
|
|
105
|
+
const data = this.config.serialize(this.nextId, this.entries);
|
|
106
|
+
const tmpPath = `${this.filePath}.tmp`;
|
|
107
|
+
writeFileSync(tmpPath, JSON.stringify(data, null, 2));
|
|
108
|
+
renameSync(tmpPath, this.filePath);
|
|
109
|
+
this.lastLoadedSignature = this.getFileSignature();
|
|
110
|
+
}
|
|
111
|
+
withLock(fn) {
|
|
112
|
+
if (!this.lockPath)
|
|
113
|
+
return fn();
|
|
114
|
+
acquireLock(this.lockPath);
|
|
115
|
+
try {
|
|
116
|
+
this.load(true);
|
|
117
|
+
const result = fn();
|
|
118
|
+
this.save();
|
|
119
|
+
return result;
|
|
120
|
+
}
|
|
121
|
+
finally {
|
|
122
|
+
releaseLock(this.lockPath);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
applyReducerEvent(event) {
|
|
126
|
+
const result = this.config.reduce(this.config.toReducerState(this.nextId, this.entries), event);
|
|
127
|
+
const { nextId, entries } = this.config.fromReducerState(result.state);
|
|
128
|
+
this.nextId = nextId;
|
|
129
|
+
this.entries = entries;
|
|
130
|
+
if (result.effects.length > 0)
|
|
131
|
+
this.onEffects(result.effects);
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Hook for reducer effects. Default no-op: durability is owned by
|
|
135
|
+
* {@link withLock}. Override to forward non-persistence effects.
|
|
136
|
+
*/
|
|
137
|
+
onEffects(_effects) { }
|
|
138
|
+
/** Reload (signature-gated) and return the entry, or undefined. */
|
|
139
|
+
get(id) {
|
|
140
|
+
if (this.filePath)
|
|
141
|
+
this.load();
|
|
142
|
+
return this.entries.get(id);
|
|
143
|
+
}
|
|
144
|
+
/** Reload (signature-gated) and return all entries sorted by numeric id. */
|
|
145
|
+
list() {
|
|
146
|
+
if (this.filePath)
|
|
147
|
+
this.load();
|
|
148
|
+
return Array.from(this.entries.values()).sort((a, b) => Number(a.id) - Number(b.id));
|
|
149
|
+
}
|
|
150
|
+
/** Remove the backing file when the store is empty. No-op for memory stores. */
|
|
151
|
+
deleteFileIfEmpty() {
|
|
152
|
+
if (!this.filePath || this.entries.size > 0)
|
|
153
|
+
return false;
|
|
154
|
+
try {
|
|
155
|
+
unlinkSync(this.filePath);
|
|
156
|
+
}
|
|
157
|
+
catch { /* ignore */ }
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
}
|
package/dist/store.d.ts
CHANGED
|
@@ -1,17 +1,8 @@
|
|
|
1
|
-
import type
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
private lastLoadedSignature;
|
|
6
|
-
private nextId;
|
|
7
|
-
private loops;
|
|
1
|
+
import { type LoopReducerEvent, type LoopReducerState } from "./loop-reducer.js";
|
|
2
|
+
import { ReducerBackedStore } from "./reducer-backed-store.js";
|
|
3
|
+
import type { LoopEntry, LoopStoreData, Trigger } from "./types.js";
|
|
4
|
+
export declare class LoopStore extends ReducerBackedStore<LoopEntry, LoopReducerState, LoopReducerEvent, LoopStoreData> {
|
|
8
5
|
constructor(listIdOrPath?: string);
|
|
9
|
-
private getFileSignature;
|
|
10
|
-
private load;
|
|
11
|
-
private save;
|
|
12
|
-
private withLock;
|
|
13
|
-
private toReducerState;
|
|
14
|
-
private applyReducerEvent;
|
|
15
6
|
create(trigger: Trigger, prompt: string, opts: {
|
|
16
7
|
recurring: boolean;
|
|
17
8
|
autoTask?: boolean;
|
|
@@ -19,8 +10,6 @@ export declare class LoopStore {
|
|
|
19
10
|
readOnly?: boolean;
|
|
20
11
|
maxFires?: number;
|
|
21
12
|
}): LoopEntry;
|
|
22
|
-
get(id: string): LoopEntry | undefined;
|
|
23
|
-
list(): LoopEntry[];
|
|
24
13
|
pause(id: string): LoopEntry | undefined;
|
|
25
14
|
resume(id: string): LoopEntry | undefined;
|
|
26
15
|
fire(id: string): LoopEntry | undefined;
|
|
@@ -35,5 +24,4 @@ export declare class LoopStore {
|
|
|
35
24
|
clearExpired(): number;
|
|
36
25
|
expireEventLoops(sessionStartedAt: number): number;
|
|
37
26
|
clearAll(): number;
|
|
38
|
-
deleteFileIfEmpty(): boolean;
|
|
39
27
|
}
|