@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/dist/store.js
CHANGED
|
@@ -1,136 +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 { reduceLoopState } from "./loop-reducer.js";
|
|
4
|
+
import { ReducerBackedStore } from "./reducer-backed-store.js";
|
|
5
5
|
const LOOPS_DIR = join(homedir(), ".pi", "loops");
|
|
6
|
-
const LOCK_RETRY_MS = 50;
|
|
7
|
-
const LOCK_MAX_RETRIES = 100;
|
|
8
6
|
const MAX_LOOPS = 25;
|
|
9
|
-
|
|
10
|
-
mkdirSync(dirname(lockPath), { recursive: true });
|
|
11
|
-
for (let i = 0; i < LOCK_MAX_RETRIES; i++) {
|
|
12
|
-
try {
|
|
13
|
-
writeFileSync(lockPath, `${process.pid}`, { flag: "wx" });
|
|
14
|
-
return;
|
|
15
|
-
}
|
|
16
|
-
catch (e) {
|
|
17
|
-
if (e.code === "EEXIST") {
|
|
18
|
-
try {
|
|
19
|
-
const pid = parseInt(readFileSync(lockPath, "utf-8"), 10);
|
|
20
|
-
if (!pid || !isProcessRunning(pid)) {
|
|
21
|
-
try {
|
|
22
|
-
unlinkSync(lockPath);
|
|
23
|
-
}
|
|
24
|
-
catch { /* ignore */ }
|
|
25
|
-
continue;
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
catch { /* ignore read errors */ }
|
|
29
|
-
const start = Date.now();
|
|
30
|
-
while (Date.now() - start < LOCK_RETRY_MS) { /* busy wait */ }
|
|
31
|
-
continue;
|
|
32
|
-
}
|
|
33
|
-
throw e;
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
throw new Error(`Failed to acquire lock: ${lockPath}`);
|
|
37
|
-
}
|
|
38
|
-
function releaseLock(lockPath) {
|
|
39
|
-
try {
|
|
40
|
-
unlinkSync(lockPath);
|
|
41
|
-
}
|
|
42
|
-
catch { /* ignore */ }
|
|
43
|
-
}
|
|
44
|
-
function isProcessRunning(pid) {
|
|
45
|
-
try {
|
|
46
|
-
process.kill(pid, 0);
|
|
47
|
-
return true;
|
|
48
|
-
}
|
|
49
|
-
catch {
|
|
50
|
-
return false;
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
export class LoopStore {
|
|
54
|
-
filePath;
|
|
55
|
-
lockPath;
|
|
56
|
-
lastLoadedSignature;
|
|
57
|
-
nextId = 1;
|
|
58
|
-
loops = new Map();
|
|
7
|
+
export class LoopStore extends ReducerBackedStore {
|
|
59
8
|
constructor(listIdOrPath) {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
69
|
-
getFileSignature() {
|
|
70
|
-
if (!this.filePath || !existsSync(this.filePath))
|
|
71
|
-
return undefined;
|
|
72
|
-
const stat = statSync(this.filePath);
|
|
73
|
-
return `${stat.mtimeMs}:${stat.size}`;
|
|
74
|
-
}
|
|
75
|
-
load(force = false) {
|
|
76
|
-
if (!this.filePath)
|
|
77
|
-
return;
|
|
78
|
-
const signature = this.getFileSignature();
|
|
79
|
-
if (!signature)
|
|
80
|
-
return;
|
|
81
|
-
if (!force && signature === this.lastLoadedSignature)
|
|
82
|
-
return;
|
|
83
|
-
try {
|
|
84
|
-
const data = JSON.parse(readFileSync(this.filePath, "utf-8"));
|
|
85
|
-
this.nextId = data.nextId;
|
|
86
|
-
this.loops.clear();
|
|
87
|
-
for (const loop of data.loops) {
|
|
88
|
-
this.loops.set(loop.id, loop);
|
|
89
|
-
}
|
|
90
|
-
this.lastLoadedSignature = signature;
|
|
91
|
-
}
|
|
92
|
-
catch { /* corrupt file — start fresh */ }
|
|
93
|
-
}
|
|
94
|
-
save() {
|
|
95
|
-
if (!this.filePath)
|
|
96
|
-
return;
|
|
97
|
-
const data = {
|
|
98
|
-
nextId: this.nextId,
|
|
99
|
-
loops: Array.from(this.loops.values()),
|
|
100
|
-
};
|
|
101
|
-
const tmpPath = this.filePath + ".tmp";
|
|
102
|
-
writeFileSync(tmpPath, JSON.stringify(data, null, 2));
|
|
103
|
-
renameSync(tmpPath, this.filePath);
|
|
104
|
-
this.lastLoadedSignature = this.getFileSignature();
|
|
105
|
-
}
|
|
106
|
-
withLock(fn) {
|
|
107
|
-
if (!this.lockPath)
|
|
108
|
-
return fn();
|
|
109
|
-
acquireLock(this.lockPath);
|
|
110
|
-
try {
|
|
111
|
-
this.load(true);
|
|
112
|
-
const result = fn();
|
|
113
|
-
this.save();
|
|
114
|
-
return result;
|
|
115
|
-
}
|
|
116
|
-
finally {
|
|
117
|
-
releaseLock(this.lockPath);
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
toReducerState() {
|
|
121
|
-
return {
|
|
122
|
-
nextId: this.nextId,
|
|
123
|
-
loopsById: Object.fromEntries(this.loops.entries()),
|
|
124
|
-
};
|
|
125
|
-
}
|
|
126
|
-
applyReducerEvent(event) {
|
|
127
|
-
const result = reduceLoopState(this.toReducerState(), event);
|
|
128
|
-
this.nextId = result.state.nextId;
|
|
129
|
-
this.loops = new Map(Object.entries(result.state.loopsById));
|
|
9
|
+
super({
|
|
10
|
+
baseDir: LOOPS_DIR,
|
|
11
|
+
reduce: (state, event) => reduceLoopState(state, event),
|
|
12
|
+
toReducerState: (nextId, entries) => ({ nextId, loopsById: Object.fromEntries(entries.entries()) }),
|
|
13
|
+
fromReducerState: (state) => ({ nextId: state.nextId, entries: new Map(Object.entries(state.loopsById)) }),
|
|
14
|
+
serialize: (nextId, entries) => ({ nextId, loops: Array.from(entries.values()) }),
|
|
15
|
+
deserialize: (data) => ({ nextId: data.nextId, entries: new Map(data.loops.map((l) => [l.id, l])) }),
|
|
16
|
+
}, listIdOrPath);
|
|
130
17
|
}
|
|
131
18
|
create(trigger, prompt, opts) {
|
|
132
19
|
return this.withLock(() => {
|
|
133
|
-
if (this.
|
|
20
|
+
if (this.entries.size >= MAX_LOOPS) {
|
|
134
21
|
throw new Error(`Maximum of ${MAX_LOOPS} loops reached. Delete some before creating new ones.`);
|
|
135
22
|
}
|
|
136
23
|
const now = Date.now();
|
|
@@ -149,22 +36,12 @@ export class LoopStore {
|
|
|
149
36
|
maxFires: opts.maxFires,
|
|
150
37
|
},
|
|
151
38
|
});
|
|
152
|
-
return this.
|
|
39
|
+
return this.entries.get(String(this.nextId - 1));
|
|
153
40
|
});
|
|
154
41
|
}
|
|
155
|
-
get(id) {
|
|
156
|
-
if (this.filePath)
|
|
157
|
-
this.load();
|
|
158
|
-
return this.loops.get(id);
|
|
159
|
-
}
|
|
160
|
-
list() {
|
|
161
|
-
if (this.filePath)
|
|
162
|
-
this.load();
|
|
163
|
-
return Array.from(this.loops.values()).sort((a, b) => Number(a.id) - Number(b.id));
|
|
164
|
-
}
|
|
165
42
|
pause(id) {
|
|
166
43
|
return this.withLock(() => {
|
|
167
|
-
const entry = this.
|
|
44
|
+
const entry = this.entries.get(id);
|
|
168
45
|
if (!entry)
|
|
169
46
|
return undefined;
|
|
170
47
|
this.applyReducerEvent({
|
|
@@ -175,12 +52,12 @@ export class LoopStore {
|
|
|
175
52
|
entityId: id,
|
|
176
53
|
payload: { id },
|
|
177
54
|
});
|
|
178
|
-
return this.
|
|
55
|
+
return this.entries.get(id);
|
|
179
56
|
});
|
|
180
57
|
}
|
|
181
58
|
resume(id) {
|
|
182
59
|
return this.withLock(() => {
|
|
183
|
-
const entry = this.
|
|
60
|
+
const entry = this.entries.get(id);
|
|
184
61
|
if (!entry)
|
|
185
62
|
return undefined;
|
|
186
63
|
this.applyReducerEvent({
|
|
@@ -191,12 +68,12 @@ export class LoopStore {
|
|
|
191
68
|
entityId: id,
|
|
192
69
|
payload: { id },
|
|
193
70
|
});
|
|
194
|
-
return this.
|
|
71
|
+
return this.entries.get(id);
|
|
195
72
|
});
|
|
196
73
|
}
|
|
197
74
|
fire(id) {
|
|
198
75
|
return this.withLock(() => {
|
|
199
|
-
const entry = this.
|
|
76
|
+
const entry = this.entries.get(id);
|
|
200
77
|
if (!entry)
|
|
201
78
|
return undefined;
|
|
202
79
|
this.applyReducerEvent({
|
|
@@ -207,12 +84,12 @@ export class LoopStore {
|
|
|
207
84
|
entityId: id,
|
|
208
85
|
payload: { id },
|
|
209
86
|
});
|
|
210
|
-
return this.
|
|
87
|
+
return this.entries.get(id);
|
|
211
88
|
});
|
|
212
89
|
}
|
|
213
90
|
updateMetadata(id, fields) {
|
|
214
91
|
return this.withLock(() => {
|
|
215
|
-
const current = this.
|
|
92
|
+
const current = this.entries.get(id);
|
|
216
93
|
if (!current)
|
|
217
94
|
return { entry: undefined, changedFields: [] };
|
|
218
95
|
const changedFields = [];
|
|
@@ -228,12 +105,12 @@ export class LoopStore {
|
|
|
228
105
|
if (changedFields.length > 0) {
|
|
229
106
|
current.updatedAt = now;
|
|
230
107
|
}
|
|
231
|
-
return { entry: this.
|
|
108
|
+
return { entry: this.entries.get(id), changedFields };
|
|
232
109
|
});
|
|
233
110
|
}
|
|
234
111
|
delete(id) {
|
|
235
112
|
return this.withLock(() => {
|
|
236
|
-
if (!this.
|
|
113
|
+
if (!this.entries.has(id))
|
|
237
114
|
return false;
|
|
238
115
|
this.applyReducerEvent({
|
|
239
116
|
type: "LOOP_DELETED",
|
|
@@ -250,7 +127,7 @@ export class LoopStore {
|
|
|
250
127
|
return this.withLock(() => {
|
|
251
128
|
const now = Date.now();
|
|
252
129
|
let count = 0;
|
|
253
|
-
for (const [id, entry] of [...this.
|
|
130
|
+
for (const [id, entry] of [...this.entries.entries()]) {
|
|
254
131
|
if (now < entry.expiresAt)
|
|
255
132
|
continue;
|
|
256
133
|
this.applyReducerEvent({
|
|
@@ -269,7 +146,7 @@ export class LoopStore {
|
|
|
269
146
|
expireEventLoops(sessionStartedAt) {
|
|
270
147
|
return this.withLock(() => {
|
|
271
148
|
let count = 0;
|
|
272
|
-
for (const [id, entry] of [...this.
|
|
149
|
+
for (const [id, entry] of [...this.entries.entries()]) {
|
|
273
150
|
if (entry.status !== "active")
|
|
274
151
|
continue;
|
|
275
152
|
if (entry.trigger.type !== "event" && entry.trigger.type !== "hybrid")
|
|
@@ -291,7 +168,7 @@ export class LoopStore {
|
|
|
291
168
|
}
|
|
292
169
|
clearAll() {
|
|
293
170
|
return this.withLock(() => {
|
|
294
|
-
const ids = [...this.
|
|
171
|
+
const ids = [...this.entries.keys()];
|
|
295
172
|
for (const id of ids) {
|
|
296
173
|
this.applyReducerEvent({
|
|
297
174
|
type: "LOOP_DELETED",
|
|
@@ -305,13 +182,4 @@ export class LoopStore {
|
|
|
305
182
|
return ids.length;
|
|
306
183
|
});
|
|
307
184
|
}
|
|
308
|
-
deleteFileIfEmpty() {
|
|
309
|
-
if (!this.filePath || this.loops.size > 0)
|
|
310
|
-
return false;
|
|
311
|
-
try {
|
|
312
|
-
unlinkSync(this.filePath);
|
|
313
|
-
}
|
|
314
|
-
catch { /* ignore */ }
|
|
315
|
-
return true;
|
|
316
|
-
}
|
|
317
185
|
}
|
package/dist/task-reducer.js
CHANGED
|
@@ -60,6 +60,9 @@ export function reduceTaskState(state, event) {
|
|
|
60
60
|
if (event.type === "TASK_REOPENED") {
|
|
61
61
|
task.status = "pending";
|
|
62
62
|
task.updatedAt = event.at;
|
|
63
|
+
// `completedAt` is intentionally retained: it records the most recent
|
|
64
|
+
// completion, not "is currently complete" (use `status` for that). A
|
|
65
|
+
// reopened task keeps the timestamp of when it was last completed.
|
|
63
66
|
}
|
|
64
67
|
if (event.type === "TASK_UPDATED") {
|
|
65
68
|
if (event.payload.subject !== undefined)
|
package/dist/task-store.d.ts
CHANGED
|
@@ -1,20 +1,9 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
private lastLoadedSignature;
|
|
6
|
-
private nextId;
|
|
7
|
-
private tasks;
|
|
1
|
+
import { ReducerBackedStore } from "./reducer-backed-store.js";
|
|
2
|
+
import { type TaskReducerEvent, type TaskReducerState } from "./task-reducer.js";
|
|
3
|
+
import type { TaskEntry, TaskStoreData } from "./task-types.js";
|
|
4
|
+
export declare class TaskStore extends ReducerBackedStore<TaskEntry, TaskReducerState, TaskReducerEvent, TaskStoreData> {
|
|
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(subject: string, description: string, metadata?: Record<string, unknown>): TaskEntry;
|
|
16
|
-
get(id: string): TaskEntry | undefined;
|
|
17
|
-
list(): TaskEntry[];
|
|
18
7
|
start(id: string): TaskEntry | undefined;
|
|
19
8
|
complete(id: string): TaskEntry | undefined;
|
|
20
9
|
reopen(id: string): TaskEntry | undefined;
|
package/dist/task-store.js
CHANGED
|
@@ -1,136 +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";
|
|
3
|
+
import { ReducerBackedStore } from "./reducer-backed-store.js";
|
|
4
4
|
import { reduceTaskState } from "./task-reducer.js";
|
|
5
5
|
const TASKS_DIR = join(homedir(), ".pi", "tasks");
|
|
6
|
-
const LOCK_RETRY_MS = 50;
|
|
7
|
-
const LOCK_MAX_RETRIES = 100;
|
|
8
6
|
const MAX_TASKS = 200;
|
|
9
|
-
|
|
10
|
-
mkdirSync(dirname(lockPath), { recursive: true });
|
|
11
|
-
for (let i = 0; i < LOCK_MAX_RETRIES; i++) {
|
|
12
|
-
try {
|
|
13
|
-
writeFileSync(lockPath, `${process.pid}`, { flag: "wx" });
|
|
14
|
-
return;
|
|
15
|
-
}
|
|
16
|
-
catch (e) {
|
|
17
|
-
if (e.code === "EEXIST") {
|
|
18
|
-
try {
|
|
19
|
-
const pid = parseInt(readFileSync(lockPath, "utf-8"), 10);
|
|
20
|
-
if (!pid || !isProcessRunning(pid)) {
|
|
21
|
-
try {
|
|
22
|
-
unlinkSync(lockPath);
|
|
23
|
-
}
|
|
24
|
-
catch { /* ignore */ }
|
|
25
|
-
continue;
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
catch { /* ignore read errors */ }
|
|
29
|
-
const start = Date.now();
|
|
30
|
-
while (Date.now() - start < LOCK_RETRY_MS) { /* busy wait */ }
|
|
31
|
-
continue;
|
|
32
|
-
}
|
|
33
|
-
throw e;
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
throw new Error(`Failed to acquire lock: ${lockPath}`);
|
|
37
|
-
}
|
|
38
|
-
function releaseLock(lockPath) {
|
|
39
|
-
try {
|
|
40
|
-
unlinkSync(lockPath);
|
|
41
|
-
}
|
|
42
|
-
catch { /* ignore */ }
|
|
43
|
-
}
|
|
44
|
-
function isProcessRunning(pid) {
|
|
45
|
-
try {
|
|
46
|
-
process.kill(pid, 0);
|
|
47
|
-
return true;
|
|
48
|
-
}
|
|
49
|
-
catch {
|
|
50
|
-
return false;
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
export class TaskStore {
|
|
54
|
-
filePath;
|
|
55
|
-
lockPath;
|
|
56
|
-
lastLoadedSignature;
|
|
57
|
-
nextId = 1;
|
|
58
|
-
tasks = new Map();
|
|
7
|
+
export class TaskStore extends ReducerBackedStore {
|
|
59
8
|
constructor(listIdOrPath) {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
69
|
-
getFileSignature() {
|
|
70
|
-
if (!this.filePath || !existsSync(this.filePath))
|
|
71
|
-
return undefined;
|
|
72
|
-
const stat = statSync(this.filePath);
|
|
73
|
-
return `${stat.mtimeMs}:${stat.size}`;
|
|
74
|
-
}
|
|
75
|
-
load(force = false) {
|
|
76
|
-
if (!this.filePath)
|
|
77
|
-
return;
|
|
78
|
-
const signature = this.getFileSignature();
|
|
79
|
-
if (!signature)
|
|
80
|
-
return;
|
|
81
|
-
if (!force && signature === this.lastLoadedSignature)
|
|
82
|
-
return;
|
|
83
|
-
try {
|
|
84
|
-
const data = JSON.parse(readFileSync(this.filePath, "utf-8"));
|
|
85
|
-
this.nextId = data.nextId;
|
|
86
|
-
this.tasks.clear();
|
|
87
|
-
for (const task of data.tasks) {
|
|
88
|
-
this.tasks.set(task.id, task);
|
|
89
|
-
}
|
|
90
|
-
this.lastLoadedSignature = signature;
|
|
91
|
-
}
|
|
92
|
-
catch { /* corrupt file — start fresh */ }
|
|
93
|
-
}
|
|
94
|
-
save() {
|
|
95
|
-
if (!this.filePath)
|
|
96
|
-
return;
|
|
97
|
-
const data = {
|
|
98
|
-
nextId: this.nextId,
|
|
99
|
-
tasks: Array.from(this.tasks.values()),
|
|
100
|
-
};
|
|
101
|
-
const tmpPath = this.filePath + ".tmp";
|
|
102
|
-
writeFileSync(tmpPath, JSON.stringify(data, null, 2));
|
|
103
|
-
renameSync(tmpPath, this.filePath);
|
|
104
|
-
this.lastLoadedSignature = this.getFileSignature();
|
|
105
|
-
}
|
|
106
|
-
withLock(fn) {
|
|
107
|
-
if (!this.lockPath)
|
|
108
|
-
return fn();
|
|
109
|
-
acquireLock(this.lockPath);
|
|
110
|
-
try {
|
|
111
|
-
this.load(true);
|
|
112
|
-
const result = fn();
|
|
113
|
-
this.save();
|
|
114
|
-
return result;
|
|
115
|
-
}
|
|
116
|
-
finally {
|
|
117
|
-
releaseLock(this.lockPath);
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
toReducerState() {
|
|
121
|
-
return {
|
|
122
|
-
nextId: this.nextId,
|
|
123
|
-
tasksById: Object.fromEntries(this.tasks.entries()),
|
|
124
|
-
};
|
|
125
|
-
}
|
|
126
|
-
applyReducerEvent(event) {
|
|
127
|
-
const result = reduceTaskState(this.toReducerState(), event);
|
|
128
|
-
this.nextId = result.state.nextId;
|
|
129
|
-
this.tasks = new Map(Object.entries(result.state.tasksById));
|
|
9
|
+
super({
|
|
10
|
+
baseDir: TASKS_DIR,
|
|
11
|
+
reduce: (state, event) => reduceTaskState(state, event),
|
|
12
|
+
toReducerState: (nextId, entries) => ({ nextId, tasksById: Object.fromEntries(entries.entries()) }),
|
|
13
|
+
fromReducerState: (state) => ({ nextId: state.nextId, entries: new Map(Object.entries(state.tasksById)) }),
|
|
14
|
+
serialize: (nextId, entries) => ({ nextId, tasks: Array.from(entries.values()) }),
|
|
15
|
+
deserialize: (data) => ({ nextId: data.nextId, entries: new Map(data.tasks.map((t) => [t.id, t])) }),
|
|
16
|
+
}, listIdOrPath);
|
|
130
17
|
}
|
|
131
18
|
create(subject, description, metadata) {
|
|
132
19
|
return this.withLock(() => {
|
|
133
|
-
if (this.
|
|
20
|
+
if (this.entries.size >= MAX_TASKS) {
|
|
134
21
|
throw new Error(`Maximum of ${MAX_TASKS} tasks reached. Delete some before creating new ones.`);
|
|
135
22
|
}
|
|
136
23
|
const now = Date.now();
|
|
@@ -141,22 +28,12 @@ export class TaskStore {
|
|
|
141
28
|
entityType: "task",
|
|
142
29
|
payload: { subject, description, metadata },
|
|
143
30
|
});
|
|
144
|
-
return this.
|
|
31
|
+
return this.entries.get(String(this.nextId - 1));
|
|
145
32
|
});
|
|
146
33
|
}
|
|
147
|
-
get(id) {
|
|
148
|
-
if (this.filePath)
|
|
149
|
-
this.load();
|
|
150
|
-
return this.tasks.get(id);
|
|
151
|
-
}
|
|
152
|
-
list() {
|
|
153
|
-
if (this.filePath)
|
|
154
|
-
this.load();
|
|
155
|
-
return Array.from(this.tasks.values()).sort((a, b) => Number(a.id) - Number(b.id));
|
|
156
|
-
}
|
|
157
34
|
start(id) {
|
|
158
35
|
return this.withLock(() => {
|
|
159
|
-
const entry = this.
|
|
36
|
+
const entry = this.entries.get(id);
|
|
160
37
|
if (!entry)
|
|
161
38
|
return undefined;
|
|
162
39
|
this.applyReducerEvent({
|
|
@@ -167,12 +44,12 @@ export class TaskStore {
|
|
|
167
44
|
entityId: id,
|
|
168
45
|
payload: { id },
|
|
169
46
|
});
|
|
170
|
-
return this.
|
|
47
|
+
return this.entries.get(id);
|
|
171
48
|
});
|
|
172
49
|
}
|
|
173
50
|
complete(id) {
|
|
174
51
|
return this.withLock(() => {
|
|
175
|
-
const entry = this.
|
|
52
|
+
const entry = this.entries.get(id);
|
|
176
53
|
if (!entry)
|
|
177
54
|
return undefined;
|
|
178
55
|
this.applyReducerEvent({
|
|
@@ -183,12 +60,12 @@ export class TaskStore {
|
|
|
183
60
|
entityId: id,
|
|
184
61
|
payload: { id },
|
|
185
62
|
});
|
|
186
|
-
return this.
|
|
63
|
+
return this.entries.get(id);
|
|
187
64
|
});
|
|
188
65
|
}
|
|
189
66
|
reopen(id) {
|
|
190
67
|
return this.withLock(() => {
|
|
191
|
-
const entry = this.
|
|
68
|
+
const entry = this.entries.get(id);
|
|
192
69
|
if (!entry)
|
|
193
70
|
return undefined;
|
|
194
71
|
this.applyReducerEvent({
|
|
@@ -199,12 +76,12 @@ export class TaskStore {
|
|
|
199
76
|
entityId: id,
|
|
200
77
|
payload: { id },
|
|
201
78
|
});
|
|
202
|
-
return this.
|
|
79
|
+
return this.entries.get(id);
|
|
203
80
|
});
|
|
204
81
|
}
|
|
205
82
|
updateDetails(id, fields) {
|
|
206
83
|
return this.withLock(() => {
|
|
207
|
-
const entry = this.
|
|
84
|
+
const entry = this.entries.get(id);
|
|
208
85
|
if (!entry)
|
|
209
86
|
return undefined;
|
|
210
87
|
if (fields.subject === undefined && fields.description === undefined)
|
|
@@ -221,12 +98,12 @@ export class TaskStore {
|
|
|
221
98
|
description: fields.description,
|
|
222
99
|
},
|
|
223
100
|
});
|
|
224
|
-
return this.
|
|
101
|
+
return this.entries.get(id);
|
|
225
102
|
});
|
|
226
103
|
}
|
|
227
104
|
delete(id) {
|
|
228
105
|
return this.withLock(() => {
|
|
229
|
-
if (!this.
|
|
106
|
+
if (!this.entries.has(id))
|
|
230
107
|
return false;
|
|
231
108
|
this.applyReducerEvent({
|
|
232
109
|
type: "TASK_DELETED",
|
|
@@ -241,7 +118,7 @@ export class TaskStore {
|
|
|
241
118
|
}
|
|
242
119
|
pendingCount() {
|
|
243
120
|
let count = 0;
|
|
244
|
-
for (const t of this.
|
|
121
|
+
for (const t of this.entries.values()) {
|
|
245
122
|
if (t.status === "pending" || t.status === "in_progress")
|
|
246
123
|
count++;
|
|
247
124
|
}
|
|
@@ -249,7 +126,7 @@ export class TaskStore {
|
|
|
249
126
|
}
|
|
250
127
|
pruneCompleted() {
|
|
251
128
|
return this.withLock(() => {
|
|
252
|
-
const before = this.
|
|
129
|
+
const before = this.entries.size;
|
|
253
130
|
this.applyReducerEvent({
|
|
254
131
|
type: "TASKS_PRUNED",
|
|
255
132
|
at: Date.now(),
|
|
@@ -257,7 +134,7 @@ export class TaskStore {
|
|
|
257
134
|
entityType: "task",
|
|
258
135
|
payload: { reason: "manual" },
|
|
259
136
|
});
|
|
260
|
-
return before - this.
|
|
137
|
+
return before - this.entries.size;
|
|
261
138
|
});
|
|
262
139
|
}
|
|
263
140
|
}
|
|
@@ -52,6 +52,15 @@ Pass onDone with a prompt and the monitor auto-creates a one-shot loop that fire
|
|
|
52
52
|
updateWidget();
|
|
53
53
|
let onDoneMsg = "";
|
|
54
54
|
if (params.onDone) {
|
|
55
|
+
// onDone delivery is callback-only: the loop below is delivered solely
|
|
56
|
+
// via MonitorManager.onComplete (see handleMonitorDoneLoop →
|
|
57
|
+
// monitor-ondone-runtime), so it fires exactly once by construction.
|
|
58
|
+
// The event-typed trigger is metadata, NOT a live subscription — this
|
|
59
|
+
// loop is deliberately NOT passed to triggerSystem.add(). The "event"
|
|
60
|
+
// type lets expireEventLoops() prune it if orphaned across a session
|
|
61
|
+
// and lets the widget render it as a monitor-completion wake. Do not
|
|
62
|
+
// triggerSystem.add() this loop, or the monitor:done event would fire it
|
|
63
|
+
// a second time.
|
|
55
64
|
const doneTrigger = { type: "event", source: "monitor:done", filter: JSON.stringify({ monitorId: entry.id }) };
|
|
56
65
|
const doneLoop = getStore().create(doneTrigger, params.onDone, { recurring: false });
|
|
57
66
|
handleMonitorDoneLoop(doneLoop, entry.id);
|
package/dist/trigger-system.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { atMaxFires } from "./loop-reducer.js";
|
|
1
2
|
export class TriggerSystem {
|
|
2
3
|
pi;
|
|
3
4
|
scheduler;
|
|
@@ -103,7 +104,7 @@ export class TriggerSystem {
|
|
|
103
104
|
this.remove(entry.id);
|
|
104
105
|
return;
|
|
105
106
|
}
|
|
106
|
-
if (fresh.recurring &&
|
|
107
|
+
if (fresh.recurring && atMaxFires(fresh)) {
|
|
107
108
|
this.remove(fresh.id);
|
|
108
109
|
this.store.delete(fresh.id);
|
|
109
110
|
return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trevonistrevon/pi-loop",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.5",
|
|
4
4
|
"description": "A pi extension for cron/event-based agent re-wake loops and background process monitoring.",
|
|
5
5
|
"author": "trevonistrevon",
|
|
6
6
|
"license": "MIT",
|
|
@@ -28,7 +28,9 @@
|
|
|
28
28
|
"build": "tsc",
|
|
29
29
|
"prepublishOnly": "npm run lint && npm run typecheck && npm run test && npm run build",
|
|
30
30
|
"test": "vitest run",
|
|
31
|
+
"test:coverage": "vitest run --coverage",
|
|
31
32
|
"test:watch": "vitest",
|
|
33
|
+
"test:e2e": "bash test/e2e/reminder-injection.sh",
|
|
32
34
|
"typecheck": "tsc --noEmit",
|
|
33
35
|
"lint": "biome check src/ test/",
|
|
34
36
|
"lint:fix": "biome check --fix src/ test/"
|
|
@@ -36,6 +38,7 @@
|
|
|
36
38
|
"devDependencies": {
|
|
37
39
|
"@biomejs/biome": "^2.4.13",
|
|
38
40
|
"@types/node": "^25.0.0",
|
|
41
|
+
"@vitest/coverage-v8": "^4.1.8",
|
|
39
42
|
"typescript": "^6.0.0",
|
|
40
43
|
"vitest": "^4.1.5"
|
|
41
44
|
},
|
package/src/goal-reducer.ts
CHANGED
|
@@ -82,6 +82,15 @@ export type GoalReducerEffect =
|
|
|
82
82
|
entityType: "goal";
|
|
83
83
|
entityId: string;
|
|
84
84
|
payload: { id: string };
|
|
85
|
+
}
|
|
86
|
+
| {
|
|
87
|
+
// Diagnostic: a transition was dropped because the goal is in a terminal
|
|
88
|
+
// state. Surfaced (rather than silently ignored) so the effect sink can log
|
|
89
|
+
// it. Carries no state change.
|
|
90
|
+
type: "GOAL_TRANSITION_REJECTED";
|
|
91
|
+
entityType: "goal";
|
|
92
|
+
entityId: string;
|
|
93
|
+
payload: { id: string; status: GoalEntry["status"]; attempted: string };
|
|
85
94
|
};
|
|
86
95
|
|
|
87
96
|
export interface GoalReduceResult {
|
|
@@ -160,7 +169,17 @@ export function reduceGoalState(state: GoalReducerState, event: GoalReducerEvent
|
|
|
160
169
|
const id = event.payload.id;
|
|
161
170
|
const current = state.goalsById[id];
|
|
162
171
|
if (!current) return { state, effects: [] };
|
|
163
|
-
if (isTerminal(current) && event.type !== "GOAL_ARCHIVED")
|
|
172
|
+
if (isTerminal(current) && event.type !== "GOAL_ARCHIVED") {
|
|
173
|
+
return {
|
|
174
|
+
state,
|
|
175
|
+
effects: [{
|
|
176
|
+
type: "GOAL_TRANSITION_REJECTED",
|
|
177
|
+
entityType: "goal",
|
|
178
|
+
entityId: id,
|
|
179
|
+
payload: { id, status: current.status, attempted: event.type },
|
|
180
|
+
}],
|
|
181
|
+
};
|
|
182
|
+
}
|
|
164
183
|
|
|
165
184
|
const next = cloneState(state);
|
|
166
185
|
const goal: GoalEntry = {
|