@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/store.ts
CHANGED
|
@@ -1,128 +1,30 @@
|
|
|
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 LoopReducerEvent, type LoopReducerState, reduceLoopState } from "./loop-reducer.js";
|
|
4
|
+
import { ReducerBackedStore } from "./reducer-backed-store.js";
|
|
5
5
|
import type { LoopEntry, LoopStoreData, Trigger } from "./types.js";
|
|
6
6
|
|
|
7
7
|
const LOOPS_DIR = join(homedir(), ".pi", "loops");
|
|
8
|
-
const LOCK_RETRY_MS = 50;
|
|
9
|
-
const LOCK_MAX_RETRIES = 100;
|
|
10
8
|
const MAX_LOOPS = 25;
|
|
11
9
|
|
|
12
|
-
|
|
13
|
-
mkdirSync(dirname(lockPath), { recursive: true });
|
|
14
|
-
for (let i = 0; i < LOCK_MAX_RETRIES; i++) {
|
|
15
|
-
try {
|
|
16
|
-
writeFileSync(lockPath, `${process.pid}`, { flag: "wx" });
|
|
17
|
-
return;
|
|
18
|
-
} catch (e: any) {
|
|
19
|
-
if (e.code === "EEXIST") {
|
|
20
|
-
try {
|
|
21
|
-
const pid = parseInt(readFileSync(lockPath, "utf-8"), 10);
|
|
22
|
-
if (!pid || !isProcessRunning(pid)) {
|
|
23
|
-
try { unlinkSync(lockPath); } catch { /* ignore */ }
|
|
24
|
-
continue;
|
|
25
|
-
}
|
|
26
|
-
} catch { /* ignore read errors */ }
|
|
27
|
-
const start = Date.now();
|
|
28
|
-
while (Date.now() - start < LOCK_RETRY_MS) { /* busy wait */ }
|
|
29
|
-
continue;
|
|
30
|
-
}
|
|
31
|
-
throw e;
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
throw new Error(`Failed to acquire lock: ${lockPath}`);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function releaseLock(lockPath: string): void {
|
|
38
|
-
try { unlinkSync(lockPath); } catch { /* ignore */ }
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function isProcessRunning(pid: number): boolean {
|
|
42
|
-
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export class LoopStore {
|
|
46
|
-
private filePath: string | undefined;
|
|
47
|
-
private lockPath: string | undefined;
|
|
48
|
-
private lastLoadedSignature: string | undefined;
|
|
49
|
-
|
|
50
|
-
private nextId = 1;
|
|
51
|
-
private loops = new Map<string, LoopEntry>();
|
|
52
|
-
|
|
10
|
+
export class LoopStore extends ReducerBackedStore<LoopEntry, LoopReducerState, LoopReducerEvent, LoopStoreData> {
|
|
53
11
|
constructor(listIdOrPath?: string) {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const stat = statSync(this.filePath);
|
|
66
|
-
return `${stat.mtimeMs}:${stat.size}`;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
private load(force = false): void {
|
|
70
|
-
if (!this.filePath) return;
|
|
71
|
-
const signature = this.getFileSignature();
|
|
72
|
-
if (!signature) return;
|
|
73
|
-
if (!force && signature === this.lastLoadedSignature) return;
|
|
74
|
-
try {
|
|
75
|
-
const data: LoopStoreData = JSON.parse(readFileSync(this.filePath, "utf-8"));
|
|
76
|
-
this.nextId = data.nextId;
|
|
77
|
-
this.loops.clear();
|
|
78
|
-
for (const loop of data.loops) {
|
|
79
|
-
this.loops.set(loop.id, loop);
|
|
80
|
-
}
|
|
81
|
-
this.lastLoadedSignature = signature;
|
|
82
|
-
} catch { /* corrupt file — start fresh */ }
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
private save(): void {
|
|
86
|
-
if (!this.filePath) return;
|
|
87
|
-
const data: LoopStoreData = {
|
|
88
|
-
nextId: this.nextId,
|
|
89
|
-
loops: Array.from(this.loops.values()),
|
|
90
|
-
};
|
|
91
|
-
const tmpPath = this.filePath + ".tmp";
|
|
92
|
-
writeFileSync(tmpPath, JSON.stringify(data, null, 2));
|
|
93
|
-
renameSync(tmpPath, this.filePath);
|
|
94
|
-
this.lastLoadedSignature = this.getFileSignature();
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
private withLock<T>(fn: () => T): T {
|
|
98
|
-
if (!this.lockPath) return fn();
|
|
99
|
-
acquireLock(this.lockPath);
|
|
100
|
-
try {
|
|
101
|
-
this.load(true);
|
|
102
|
-
const result = fn();
|
|
103
|
-
this.save();
|
|
104
|
-
return result;
|
|
105
|
-
} finally {
|
|
106
|
-
releaseLock(this.lockPath);
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
private toReducerState(): LoopReducerState {
|
|
111
|
-
return {
|
|
112
|
-
nextId: this.nextId,
|
|
113
|
-
loopsById: Object.fromEntries(this.loops.entries()),
|
|
114
|
-
};
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
private applyReducerEvent(event: LoopReducerEvent): void {
|
|
118
|
-
const result = reduceLoopState(this.toReducerState(), event);
|
|
119
|
-
this.nextId = result.state.nextId;
|
|
120
|
-
this.loops = new Map(Object.entries(result.state.loopsById));
|
|
12
|
+
super(
|
|
13
|
+
{
|
|
14
|
+
baseDir: LOOPS_DIR,
|
|
15
|
+
reduce: (state, event) => reduceLoopState(state, event),
|
|
16
|
+
toReducerState: (nextId, entries) => ({ nextId, loopsById: Object.fromEntries(entries.entries()) }),
|
|
17
|
+
fromReducerState: (state) => ({ nextId: state.nextId, entries: new Map(Object.entries(state.loopsById)) }),
|
|
18
|
+
serialize: (nextId, entries) => ({ nextId, loops: Array.from(entries.values()) }),
|
|
19
|
+
deserialize: (data) => ({ nextId: data.nextId, entries: new Map(data.loops.map((l) => [l.id, l])) }),
|
|
20
|
+
},
|
|
21
|
+
listIdOrPath,
|
|
22
|
+
);
|
|
121
23
|
}
|
|
122
24
|
|
|
123
25
|
create(trigger: Trigger, prompt: string, opts: { recurring: boolean; autoTask?: boolean; taskBacklog?: boolean; readOnly?: boolean; maxFires?: number }): LoopEntry {
|
|
124
26
|
return this.withLock(() => {
|
|
125
|
-
if (this.
|
|
27
|
+
if (this.entries.size >= MAX_LOOPS) {
|
|
126
28
|
throw new Error(`Maximum of ${MAX_LOOPS} loops reached. Delete some before creating new ones.`);
|
|
127
29
|
}
|
|
128
30
|
const now = Date.now();
|
|
@@ -141,23 +43,13 @@ export class LoopStore {
|
|
|
141
43
|
maxFires: opts.maxFires,
|
|
142
44
|
},
|
|
143
45
|
});
|
|
144
|
-
return this.
|
|
46
|
+
return this.entries.get(String(this.nextId - 1))!;
|
|
145
47
|
});
|
|
146
48
|
}
|
|
147
49
|
|
|
148
|
-
get(id: string): LoopEntry | undefined {
|
|
149
|
-
if (this.filePath) this.load();
|
|
150
|
-
return this.loops.get(id);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
list(): LoopEntry[] {
|
|
154
|
-
if (this.filePath) this.load();
|
|
155
|
-
return Array.from(this.loops.values()).sort((a, b) => Number(a.id) - Number(b.id));
|
|
156
|
-
}
|
|
157
|
-
|
|
158
50
|
pause(id: string): LoopEntry | undefined {
|
|
159
51
|
return this.withLock(() => {
|
|
160
|
-
const entry = this.
|
|
52
|
+
const entry = this.entries.get(id);
|
|
161
53
|
if (!entry) return undefined;
|
|
162
54
|
this.applyReducerEvent({
|
|
163
55
|
type: "LOOP_PAUSED",
|
|
@@ -167,13 +59,13 @@ export class LoopStore {
|
|
|
167
59
|
entityId: id,
|
|
168
60
|
payload: { id },
|
|
169
61
|
});
|
|
170
|
-
return this.
|
|
62
|
+
return this.entries.get(id);
|
|
171
63
|
});
|
|
172
64
|
}
|
|
173
65
|
|
|
174
66
|
resume(id: string): LoopEntry | undefined {
|
|
175
67
|
return this.withLock(() => {
|
|
176
|
-
const entry = this.
|
|
68
|
+
const entry = this.entries.get(id);
|
|
177
69
|
if (!entry) return undefined;
|
|
178
70
|
this.applyReducerEvent({
|
|
179
71
|
type: "LOOP_RESUMED",
|
|
@@ -183,13 +75,13 @@ export class LoopStore {
|
|
|
183
75
|
entityId: id,
|
|
184
76
|
payload: { id },
|
|
185
77
|
});
|
|
186
|
-
return this.
|
|
78
|
+
return this.entries.get(id);
|
|
187
79
|
});
|
|
188
80
|
}
|
|
189
81
|
|
|
190
82
|
fire(id: string): LoopEntry | undefined {
|
|
191
83
|
return this.withLock(() => {
|
|
192
|
-
const entry = this.
|
|
84
|
+
const entry = this.entries.get(id);
|
|
193
85
|
if (!entry) return undefined;
|
|
194
86
|
this.applyReducerEvent({
|
|
195
87
|
type: "LOOP_FIRED",
|
|
@@ -199,13 +91,13 @@ export class LoopStore {
|
|
|
199
91
|
entityId: id,
|
|
200
92
|
payload: { id },
|
|
201
93
|
});
|
|
202
|
-
return this.
|
|
94
|
+
return this.entries.get(id);
|
|
203
95
|
});
|
|
204
96
|
}
|
|
205
97
|
|
|
206
98
|
updateMetadata(id: string, fields: { trigger?: Trigger; prompt?: string }): { entry: LoopEntry | undefined; changedFields: string[] } {
|
|
207
99
|
return this.withLock(() => {
|
|
208
|
-
const current = this.
|
|
100
|
+
const current = this.entries.get(id);
|
|
209
101
|
if (!current) return { entry: undefined, changedFields: [] };
|
|
210
102
|
|
|
211
103
|
const changedFields: string[] = [];
|
|
@@ -223,13 +115,13 @@ export class LoopStore {
|
|
|
223
115
|
current.updatedAt = now;
|
|
224
116
|
}
|
|
225
117
|
|
|
226
|
-
return { entry: this.
|
|
118
|
+
return { entry: this.entries.get(id), changedFields };
|
|
227
119
|
});
|
|
228
120
|
}
|
|
229
121
|
|
|
230
122
|
delete(id: string): boolean {
|
|
231
123
|
return this.withLock(() => {
|
|
232
|
-
if (!this.
|
|
124
|
+
if (!this.entries.has(id)) return false;
|
|
233
125
|
this.applyReducerEvent({
|
|
234
126
|
type: "LOOP_DELETED",
|
|
235
127
|
at: Date.now(),
|
|
@@ -246,7 +138,7 @@ export class LoopStore {
|
|
|
246
138
|
return this.withLock(() => {
|
|
247
139
|
const now = Date.now();
|
|
248
140
|
let count = 0;
|
|
249
|
-
for (const [id, entry] of [...this.
|
|
141
|
+
for (const [id, entry] of [...this.entries.entries()]) {
|
|
250
142
|
if (now < entry.expiresAt) continue;
|
|
251
143
|
this.applyReducerEvent({
|
|
252
144
|
type: "LOOP_EXPIRED",
|
|
@@ -265,7 +157,7 @@ export class LoopStore {
|
|
|
265
157
|
expireEventLoops(sessionStartedAt: number): number {
|
|
266
158
|
return this.withLock(() => {
|
|
267
159
|
let count = 0;
|
|
268
|
-
for (const [id, entry] of [...this.
|
|
160
|
+
for (const [id, entry] of [...this.entries.entries()]) {
|
|
269
161
|
if (entry.status !== "active") continue;
|
|
270
162
|
if (entry.trigger.type !== "event" && entry.trigger.type !== "hybrid") continue;
|
|
271
163
|
if (entry.createdAt >= sessionStartedAt) continue;
|
|
@@ -285,7 +177,7 @@ export class LoopStore {
|
|
|
285
177
|
|
|
286
178
|
clearAll(): number {
|
|
287
179
|
return this.withLock(() => {
|
|
288
|
-
const ids = [...this.
|
|
180
|
+
const ids = [...this.entries.keys()];
|
|
289
181
|
for (const id of ids) {
|
|
290
182
|
this.applyReducerEvent({
|
|
291
183
|
type: "LOOP_DELETED",
|
|
@@ -299,10 +191,4 @@ export class LoopStore {
|
|
|
299
191
|
return ids.length;
|
|
300
192
|
});
|
|
301
193
|
}
|
|
302
|
-
|
|
303
|
-
deleteFileIfEmpty(): boolean {
|
|
304
|
-
if (!this.filePath || this.loops.size > 0) return false;
|
|
305
|
-
try { unlinkSync(this.filePath); } catch { /* ignore */ }
|
|
306
|
-
return true;
|
|
307
|
-
}
|
|
308
194
|
}
|
package/src/task-reducer.ts
CHANGED
|
@@ -136,6 +136,9 @@ export function reduceTaskState(state: TaskReducerState, event: TaskReducerEvent
|
|
|
136
136
|
if (event.type === "TASK_REOPENED") {
|
|
137
137
|
task.status = "pending";
|
|
138
138
|
task.updatedAt = event.at;
|
|
139
|
+
// `completedAt` is intentionally retained: it records the most recent
|
|
140
|
+
// completion, not "is currently complete" (use `status` for that). A
|
|
141
|
+
// reopened task keeps the timestamp of when it was last completed.
|
|
139
142
|
}
|
|
140
143
|
|
|
141
144
|
if (event.type === "TASK_UPDATED") {
|
package/src/task-store.ts
CHANGED
|
@@ -1,128 +1,30 @@
|
|
|
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, type TaskReducerEvent, type TaskReducerState } from "./task-reducer.js";
|
|
5
5
|
import type { TaskEntry, TaskStoreData } from "./task-types.js";
|
|
6
6
|
|
|
7
7
|
const TASKS_DIR = join(homedir(), ".pi", "tasks");
|
|
8
|
-
const LOCK_RETRY_MS = 50;
|
|
9
|
-
const LOCK_MAX_RETRIES = 100;
|
|
10
8
|
const MAX_TASKS = 200;
|
|
11
9
|
|
|
12
|
-
|
|
13
|
-
mkdirSync(dirname(lockPath), { recursive: true });
|
|
14
|
-
for (let i = 0; i < LOCK_MAX_RETRIES; i++) {
|
|
15
|
-
try {
|
|
16
|
-
writeFileSync(lockPath, `${process.pid}`, { flag: "wx" });
|
|
17
|
-
return;
|
|
18
|
-
} catch (e: any) {
|
|
19
|
-
if (e.code === "EEXIST") {
|
|
20
|
-
try {
|
|
21
|
-
const pid = parseInt(readFileSync(lockPath, "utf-8"), 10);
|
|
22
|
-
if (!pid || !isProcessRunning(pid)) {
|
|
23
|
-
try { unlinkSync(lockPath); } catch { /* ignore */ }
|
|
24
|
-
continue;
|
|
25
|
-
}
|
|
26
|
-
} catch { /* ignore read errors */ }
|
|
27
|
-
const start = Date.now();
|
|
28
|
-
while (Date.now() - start < LOCK_RETRY_MS) { /* busy wait */ }
|
|
29
|
-
continue;
|
|
30
|
-
}
|
|
31
|
-
throw e;
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
throw new Error(`Failed to acquire lock: ${lockPath}`);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function releaseLock(lockPath: string): void {
|
|
38
|
-
try { unlinkSync(lockPath); } catch { /* ignore */ }
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function isProcessRunning(pid: number): boolean {
|
|
42
|
-
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export class TaskStore {
|
|
46
|
-
private filePath: string | undefined;
|
|
47
|
-
private lockPath: string | undefined;
|
|
48
|
-
private lastLoadedSignature: string | undefined;
|
|
49
|
-
|
|
50
|
-
private nextId = 1;
|
|
51
|
-
private tasks = new Map<string, TaskEntry>();
|
|
52
|
-
|
|
10
|
+
export class TaskStore extends ReducerBackedStore<TaskEntry, TaskReducerState, TaskReducerEvent, TaskStoreData> {
|
|
53
11
|
constructor(listIdOrPath?: string) {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const stat = statSync(this.filePath);
|
|
66
|
-
return `${stat.mtimeMs}:${stat.size}`;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
private load(force = false): void {
|
|
70
|
-
if (!this.filePath) return;
|
|
71
|
-
const signature = this.getFileSignature();
|
|
72
|
-
if (!signature) return;
|
|
73
|
-
if (!force && signature === this.lastLoadedSignature) return;
|
|
74
|
-
try {
|
|
75
|
-
const data: TaskStoreData = JSON.parse(readFileSync(this.filePath, "utf-8"));
|
|
76
|
-
this.nextId = data.nextId;
|
|
77
|
-
this.tasks.clear();
|
|
78
|
-
for (const task of data.tasks) {
|
|
79
|
-
this.tasks.set(task.id, task);
|
|
80
|
-
}
|
|
81
|
-
this.lastLoadedSignature = signature;
|
|
82
|
-
} catch { /* corrupt file — start fresh */ }
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
private save(): void {
|
|
86
|
-
if (!this.filePath) return;
|
|
87
|
-
const data: TaskStoreData = {
|
|
88
|
-
nextId: this.nextId,
|
|
89
|
-
tasks: Array.from(this.tasks.values()),
|
|
90
|
-
};
|
|
91
|
-
const tmpPath = this.filePath + ".tmp";
|
|
92
|
-
writeFileSync(tmpPath, JSON.stringify(data, null, 2));
|
|
93
|
-
renameSync(tmpPath, this.filePath);
|
|
94
|
-
this.lastLoadedSignature = this.getFileSignature();
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
private withLock<T>(fn: () => T): T {
|
|
98
|
-
if (!this.lockPath) return fn();
|
|
99
|
-
acquireLock(this.lockPath);
|
|
100
|
-
try {
|
|
101
|
-
this.load(true);
|
|
102
|
-
const result = fn();
|
|
103
|
-
this.save();
|
|
104
|
-
return result;
|
|
105
|
-
} finally {
|
|
106
|
-
releaseLock(this.lockPath);
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
private toReducerState(): TaskReducerState {
|
|
111
|
-
return {
|
|
112
|
-
nextId: this.nextId,
|
|
113
|
-
tasksById: Object.fromEntries(this.tasks.entries()),
|
|
114
|
-
};
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
private applyReducerEvent(event: TaskReducerEvent): void {
|
|
118
|
-
const result = reduceTaskState(this.toReducerState(), event);
|
|
119
|
-
this.nextId = result.state.nextId;
|
|
120
|
-
this.tasks = new Map(Object.entries(result.state.tasksById));
|
|
12
|
+
super(
|
|
13
|
+
{
|
|
14
|
+
baseDir: TASKS_DIR,
|
|
15
|
+
reduce: (state, event) => reduceTaskState(state, event),
|
|
16
|
+
toReducerState: (nextId, entries) => ({ nextId, tasksById: Object.fromEntries(entries.entries()) }),
|
|
17
|
+
fromReducerState: (state) => ({ nextId: state.nextId, entries: new Map(Object.entries(state.tasksById)) }),
|
|
18
|
+
serialize: (nextId, entries) => ({ nextId, tasks: Array.from(entries.values()) }),
|
|
19
|
+
deserialize: (data) => ({ nextId: data.nextId, entries: new Map(data.tasks.map((t) => [t.id, t])) }),
|
|
20
|
+
},
|
|
21
|
+
listIdOrPath,
|
|
22
|
+
);
|
|
121
23
|
}
|
|
122
24
|
|
|
123
25
|
create(subject: string, description: string, metadata?: Record<string, unknown>): TaskEntry {
|
|
124
26
|
return this.withLock(() => {
|
|
125
|
-
if (this.
|
|
27
|
+
if (this.entries.size >= MAX_TASKS) {
|
|
126
28
|
throw new Error(`Maximum of ${MAX_TASKS} tasks reached. Delete some before creating new ones.`);
|
|
127
29
|
}
|
|
128
30
|
const now = Date.now();
|
|
@@ -133,23 +35,13 @@ export class TaskStore {
|
|
|
133
35
|
entityType: "task",
|
|
134
36
|
payload: { subject, description, metadata },
|
|
135
37
|
});
|
|
136
|
-
return this.
|
|
38
|
+
return this.entries.get(String(this.nextId - 1))!;
|
|
137
39
|
});
|
|
138
40
|
}
|
|
139
41
|
|
|
140
|
-
get(id: string): TaskEntry | undefined {
|
|
141
|
-
if (this.filePath) this.load();
|
|
142
|
-
return this.tasks.get(id);
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
list(): TaskEntry[] {
|
|
146
|
-
if (this.filePath) this.load();
|
|
147
|
-
return Array.from(this.tasks.values()).sort((a, b) => Number(a.id) - Number(b.id));
|
|
148
|
-
}
|
|
149
|
-
|
|
150
42
|
start(id: string): TaskEntry | undefined {
|
|
151
43
|
return this.withLock(() => {
|
|
152
|
-
const entry = this.
|
|
44
|
+
const entry = this.entries.get(id);
|
|
153
45
|
if (!entry) return undefined;
|
|
154
46
|
this.applyReducerEvent({
|
|
155
47
|
type: "TASK_STARTED",
|
|
@@ -159,13 +51,13 @@ export class TaskStore {
|
|
|
159
51
|
entityId: id,
|
|
160
52
|
payload: { id },
|
|
161
53
|
});
|
|
162
|
-
return this.
|
|
54
|
+
return this.entries.get(id);
|
|
163
55
|
});
|
|
164
56
|
}
|
|
165
57
|
|
|
166
58
|
complete(id: string): TaskEntry | undefined {
|
|
167
59
|
return this.withLock(() => {
|
|
168
|
-
const entry = this.
|
|
60
|
+
const entry = this.entries.get(id);
|
|
169
61
|
if (!entry) return undefined;
|
|
170
62
|
this.applyReducerEvent({
|
|
171
63
|
type: "TASK_COMPLETED",
|
|
@@ -175,13 +67,13 @@ export class TaskStore {
|
|
|
175
67
|
entityId: id,
|
|
176
68
|
payload: { id },
|
|
177
69
|
});
|
|
178
|
-
return this.
|
|
70
|
+
return this.entries.get(id);
|
|
179
71
|
});
|
|
180
72
|
}
|
|
181
73
|
|
|
182
74
|
reopen(id: string): TaskEntry | undefined {
|
|
183
75
|
return this.withLock(() => {
|
|
184
|
-
const entry = this.
|
|
76
|
+
const entry = this.entries.get(id);
|
|
185
77
|
if (!entry) return undefined;
|
|
186
78
|
this.applyReducerEvent({
|
|
187
79
|
type: "TASK_REOPENED",
|
|
@@ -191,13 +83,13 @@ export class TaskStore {
|
|
|
191
83
|
entityId: id,
|
|
192
84
|
payload: { id },
|
|
193
85
|
});
|
|
194
|
-
return this.
|
|
86
|
+
return this.entries.get(id);
|
|
195
87
|
});
|
|
196
88
|
}
|
|
197
89
|
|
|
198
90
|
updateDetails(id: string, fields: { subject?: string; description?: string }): TaskEntry | undefined {
|
|
199
91
|
return this.withLock(() => {
|
|
200
|
-
const entry = this.
|
|
92
|
+
const entry = this.entries.get(id);
|
|
201
93
|
if (!entry) return undefined;
|
|
202
94
|
if (fields.subject === undefined && fields.description === undefined) return entry;
|
|
203
95
|
this.applyReducerEvent({
|
|
@@ -212,13 +104,13 @@ export class TaskStore {
|
|
|
212
104
|
description: fields.description,
|
|
213
105
|
},
|
|
214
106
|
});
|
|
215
|
-
return this.
|
|
107
|
+
return this.entries.get(id);
|
|
216
108
|
});
|
|
217
109
|
}
|
|
218
110
|
|
|
219
111
|
delete(id: string): boolean {
|
|
220
112
|
return this.withLock(() => {
|
|
221
|
-
if (!this.
|
|
113
|
+
if (!this.entries.has(id)) return false;
|
|
222
114
|
this.applyReducerEvent({
|
|
223
115
|
type: "TASK_DELETED",
|
|
224
116
|
at: Date.now(),
|
|
@@ -233,7 +125,7 @@ export class TaskStore {
|
|
|
233
125
|
|
|
234
126
|
pendingCount(): number {
|
|
235
127
|
let count = 0;
|
|
236
|
-
for (const t of this.
|
|
128
|
+
for (const t of this.entries.values()) {
|
|
237
129
|
if (t.status === "pending" || t.status === "in_progress") count++;
|
|
238
130
|
}
|
|
239
131
|
return count;
|
|
@@ -241,7 +133,7 @@ export class TaskStore {
|
|
|
241
133
|
|
|
242
134
|
pruneCompleted(): number {
|
|
243
135
|
return this.withLock(() => {
|
|
244
|
-
const before = this.
|
|
136
|
+
const before = this.entries.size;
|
|
245
137
|
this.applyReducerEvent({
|
|
246
138
|
type: "TASKS_PRUNED",
|
|
247
139
|
at: Date.now(),
|
|
@@ -249,7 +141,7 @@ export class TaskStore {
|
|
|
249
141
|
entityType: "task",
|
|
250
142
|
payload: { reason: "manual" },
|
|
251
143
|
});
|
|
252
|
-
return before - this.
|
|
144
|
+
return before - this.entries.size;
|
|
253
145
|
});
|
|
254
146
|
}
|
|
255
147
|
}
|
|
@@ -82,6 +82,15 @@ Pass onDone with a prompt and the monitor auto-creates a one-shot loop that fire
|
|
|
82
82
|
|
|
83
83
|
let onDoneMsg = "";
|
|
84
84
|
if (params.onDone) {
|
|
85
|
+
// onDone delivery is callback-only: the loop below is delivered solely
|
|
86
|
+
// via MonitorManager.onComplete (see handleMonitorDoneLoop →
|
|
87
|
+
// monitor-ondone-runtime), so it fires exactly once by construction.
|
|
88
|
+
// The event-typed trigger is metadata, NOT a live subscription — this
|
|
89
|
+
// loop is deliberately NOT passed to triggerSystem.add(). The "event"
|
|
90
|
+
// type lets expireEventLoops() prune it if orphaned across a session
|
|
91
|
+
// and lets the widget render it as a monitor-completion wake. Do not
|
|
92
|
+
// triggerSystem.add() this loop, or the monitor:done event would fire it
|
|
93
|
+
// a second time.
|
|
85
94
|
const doneTrigger: Trigger = { type: "event", source: "monitor:done", filter: JSON.stringify({ monitorId: entry.id }) };
|
|
86
95
|
const doneLoop = getStore().create(doneTrigger, params.onDone, { recurring: false });
|
|
87
96
|
handleMonitorDoneLoop(doneLoop, entry.id);
|
package/src/trigger-system.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { atMaxFires } from "./loop-reducer.js";
|
|
2
3
|
import type { CronScheduler } from "./scheduler.js";
|
|
3
4
|
import type { LoopStore } from "./store.js";
|
|
4
5
|
import type { LoopEntry } from "./types.js";
|
|
@@ -110,7 +111,7 @@ export class TriggerSystem {
|
|
|
110
111
|
return;
|
|
111
112
|
}
|
|
112
113
|
|
|
113
|
-
if (fresh.recurring &&
|
|
114
|
+
if (fresh.recurring && atMaxFires(fresh)) {
|
|
114
115
|
this.remove(fresh.id);
|
|
115
116
|
this.store.delete(fresh.id);
|
|
116
117
|
return;
|
package/vitest.config.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { defineConfig } from "vitest/config";
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
test: {
|
|
5
|
+
environment: "node",
|
|
6
|
+
include: ["test/**/*.test.ts"],
|
|
7
|
+
exclude: ["node_modules", "dist"],
|
|
8
|
+
coverage: {
|
|
9
|
+
provider: "v8",
|
|
10
|
+
reporter: ["text-summary", "html", "json"],
|
|
11
|
+
include: ["src/**/*.ts"],
|
|
12
|
+
// Type-only modules and test helpers carry no executable logic worth gating.
|
|
13
|
+
exclude: ["src/**/*-types.ts", "src/types.ts"],
|
|
14
|
+
// Floors set just below current actuals (stmts 80%, branches 69.5%,
|
|
15
|
+
// funcs 92.5%, lines 82.9%) to catch regressions. Ratchet up as the
|
|
16
|
+
// runtime/ and tools/ suites land in Phase 4.
|
|
17
|
+
thresholds: {
|
|
18
|
+
statements: 78,
|
|
19
|
+
branches: 67,
|
|
20
|
+
functions: 90,
|
|
21
|
+
lines: 80,
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
});
|
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
# Differential Review Report
|
|
2
|
-
|
|
3
|
-
## Update — 2026-06-02
|
|
4
|
-
|
|
5
|
-
### Resolved audit finding
|
|
6
|
-
- **Resolved:** recurring `event` / `hybrid` loops now clean themselves up immediately when `maxFires` is reached on the final allowed fire.
|
|
7
|
-
- **Implementation:** `src/trigger-system.ts` now removes and deletes recurring event/hybrid loops as soon as `fireCount >= maxFires` after `onFire(...)` completes.
|
|
8
|
-
- **Why this mattered:** the previous behavior left one stale active loop behind until the next matching event arrived, which was a real runtime cleanup bug.
|
|
9
|
-
|
|
10
|
-
### Regression coverage added
|
|
11
|
-
- `test/trigger-system.test.ts`
|
|
12
|
-
- recurring `event` loop is deleted immediately at final `maxFires`
|
|
13
|
-
- recurring `hybrid` loop is deleted immediately at final `maxFires`
|
|
14
|
-
- hybrid cleanup also clears scheduled cron state
|
|
15
|
-
- `test/index.test.ts`
|
|
16
|
-
- extension-level `LoopCreate`/`LoopList` path confirms the loop is gone immediately after the final allowed event fire
|
|
17
|
-
|
|
18
|
-
### Validation status
|
|
19
|
-
- `npm run lint` ✅
|
|
20
|
-
- `npm run typecheck` ✅
|
|
21
|
-
- `npm run test` ✅
|
|
22
|
-
- `npm run build` ✅
|
|
23
|
-
- Current suite: **112 passing tests**
|
|
24
|
-
|
|
25
|
-
## Scope
|
|
26
|
-
Reviewed recent uncommitted changes in:
|
|
27
|
-
- `src/index.ts`
|
|
28
|
-
- `src/ui/widget.ts`
|
|
29
|
-
- `test/index.test.ts`
|
|
30
|
-
- `test/widget.test.ts`
|
|
31
|
-
- `README.md`
|
|
32
|
-
|
|
33
|
-
## Risk Summary
|
|
34
|
-
- **Overall risk:** Medium
|
|
35
|
-
- **Primary areas affected:** runtime task fallback routing, UI/widget behavior, interactive command registration
|
|
36
|
-
- **Security impact:** Low direct security impact; main risks are state-management regressions and missing coverage around command behavior
|
|
37
|
-
|
|
38
|
-
## Findings
|
|
39
|
-
|
|
40
|
-
### 🟡 Warning
|
|
41
|
-
`src/index.ts` - Native task fallback is covered for registration and file persistence, but not for interactive `/tasks` flows or loop-driven task lifecycle integration.
|
|
42
|
-
|
|
43
|
-
**Why it matters:**
|
|
44
|
-
The new `/tasks` command and interactive actions (`Start`, `Complete`, `Reopen`, `Delete`) are stateful and user-facing. Regressions here would not be caught by current tests. Similarly, native fallback behavior for `autoTask`, `hasPendingTasks()`, and `cleanDoneTasks()` is only indirectly covered.
|
|
45
|
-
|
|
46
|
-
**Recommended follow-up:**
|
|
47
|
-
Add tests for:
|
|
48
|
-
- `/tasks` command registration and quick-create path
|
|
49
|
-
- native `autoTask` creation path
|
|
50
|
-
- `hasPendingTasks()` using native fallback
|
|
51
|
-
- completed-task sweep behavior for native tasks
|
|
52
|
-
|
|
53
|
-
### 🟢 Suggestion
|
|
54
|
-
`src/ui/widget.ts` - Compact widget behavior is appropriately simplified and focus-oriented.
|
|
55
|
-
|
|
56
|
-
**Good pattern:**
|
|
57
|
-
The single-line status approach reduces noise and matches the intended UX. Showing only active/next task focus text is a good constraint.
|
|
58
|
-
|
|
59
|
-
## Test Coverage Review
|
|
60
|
-
|
|
61
|
-
### Covered well
|
|
62
|
-
- Native tool registration when `pi-tasks` is absent/present
|
|
63
|
-
- Native task persistence path (`.pi/tasks/tasks.json`)
|
|
64
|
-
- Compact widget states:
|
|
65
|
-
- `none`
|
|
66
|
-
- monitor-only count
|
|
67
|
-
- loop + monitor count
|
|
68
|
-
- active task focus text
|
|
69
|
-
- next task focus text
|
|
70
|
-
- retained widget rendering after content clears
|
|
71
|
-
|
|
72
|
-
### Coverage gaps
|
|
73
|
-
- No tests for `/tasks` command behavior beyond registration
|
|
74
|
-
- No direct tests for native `TaskUpdate` → widget focus transitions
|
|
75
|
-
- No direct tests for native `cleanDoneTasks()` sweep behavior
|
|
76
|
-
- No end-to-end tests for `LoopCreate(autoTask: true)` using native fallback
|
|
77
|
-
|
|
78
|
-
## Review Verdict
|
|
79
|
-
- **No merge-blocking issues found**
|
|
80
|
-
- Safe to release after version bump and validation
|
|
81
|
-
- Recommended follow-up: add native task lifecycle command/integration tests in a later pass
|