@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
|
@@ -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
|
+
}
|
package/src/store.ts
CHANGED
|
@@ -1,127 +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
|
-
for (let i = 0; i < LOCK_MAX_RETRIES; i++) {
|
|
14
|
-
try {
|
|
15
|
-
writeFileSync(lockPath, `${process.pid}`, { flag: "wx" });
|
|
16
|
-
return;
|
|
17
|
-
} catch (e: any) {
|
|
18
|
-
if (e.code === "EEXIST") {
|
|
19
|
-
try {
|
|
20
|
-
const pid = parseInt(readFileSync(lockPath, "utf-8"), 10);
|
|
21
|
-
if (!pid || !isProcessRunning(pid)) {
|
|
22
|
-
try { unlinkSync(lockPath); } catch { /* ignore */ }
|
|
23
|
-
continue;
|
|
24
|
-
}
|
|
25
|
-
} catch { /* ignore read errors */ }
|
|
26
|
-
const start = Date.now();
|
|
27
|
-
while (Date.now() - start < LOCK_RETRY_MS) { /* busy wait */ }
|
|
28
|
-
continue;
|
|
29
|
-
}
|
|
30
|
-
throw e;
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
throw new Error(`Failed to acquire lock: ${lockPath}`);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function releaseLock(lockPath: string): void {
|
|
37
|
-
try { unlinkSync(lockPath); } catch { /* ignore */ }
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function isProcessRunning(pid: number): boolean {
|
|
41
|
-
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export class LoopStore {
|
|
45
|
-
private filePath: string | undefined;
|
|
46
|
-
private lockPath: string | undefined;
|
|
47
|
-
private lastLoadedSignature: string | undefined;
|
|
48
|
-
|
|
49
|
-
private nextId = 1;
|
|
50
|
-
private loops = new Map<string, LoopEntry>();
|
|
51
|
-
|
|
10
|
+
export class LoopStore extends ReducerBackedStore<LoopEntry, LoopReducerState, LoopReducerEvent, LoopStoreData> {
|
|
52
11
|
constructor(listIdOrPath?: string) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const stat = statSync(this.filePath);
|
|
65
|
-
return `${stat.mtimeMs}:${stat.size}`;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
private load(force = false): void {
|
|
69
|
-
if (!this.filePath) return;
|
|
70
|
-
const signature = this.getFileSignature();
|
|
71
|
-
if (!signature) return;
|
|
72
|
-
if (!force && signature === this.lastLoadedSignature) return;
|
|
73
|
-
try {
|
|
74
|
-
const data: LoopStoreData = JSON.parse(readFileSync(this.filePath, "utf-8"));
|
|
75
|
-
this.nextId = data.nextId;
|
|
76
|
-
this.loops.clear();
|
|
77
|
-
for (const loop of data.loops) {
|
|
78
|
-
this.loops.set(loop.id, loop);
|
|
79
|
-
}
|
|
80
|
-
this.lastLoadedSignature = signature;
|
|
81
|
-
} catch { /* corrupt file — start fresh */ }
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
private save(): void {
|
|
85
|
-
if (!this.filePath) return;
|
|
86
|
-
const data: LoopStoreData = {
|
|
87
|
-
nextId: this.nextId,
|
|
88
|
-
loops: Array.from(this.loops.values()),
|
|
89
|
-
};
|
|
90
|
-
const tmpPath = this.filePath + ".tmp";
|
|
91
|
-
writeFileSync(tmpPath, JSON.stringify(data, null, 2));
|
|
92
|
-
renameSync(tmpPath, this.filePath);
|
|
93
|
-
this.lastLoadedSignature = this.getFileSignature();
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
private withLock<T>(fn: () => T): T {
|
|
97
|
-
if (!this.lockPath) return fn();
|
|
98
|
-
acquireLock(this.lockPath);
|
|
99
|
-
try {
|
|
100
|
-
this.load(true);
|
|
101
|
-
const result = fn();
|
|
102
|
-
this.save();
|
|
103
|
-
return result;
|
|
104
|
-
} finally {
|
|
105
|
-
releaseLock(this.lockPath);
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
private toReducerState(): LoopReducerState {
|
|
110
|
-
return {
|
|
111
|
-
nextId: this.nextId,
|
|
112
|
-
loopsById: Object.fromEntries(this.loops.entries()),
|
|
113
|
-
};
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
private applyReducerEvent(event: LoopReducerEvent): void {
|
|
117
|
-
const result = reduceLoopState(this.toReducerState(), event);
|
|
118
|
-
this.nextId = result.state.nextId;
|
|
119
|
-
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
|
+
);
|
|
120
23
|
}
|
|
121
24
|
|
|
122
25
|
create(trigger: Trigger, prompt: string, opts: { recurring: boolean; autoTask?: boolean; taskBacklog?: boolean; readOnly?: boolean; maxFires?: number }): LoopEntry {
|
|
123
26
|
return this.withLock(() => {
|
|
124
|
-
if (this.
|
|
27
|
+
if (this.entries.size >= MAX_LOOPS) {
|
|
125
28
|
throw new Error(`Maximum of ${MAX_LOOPS} loops reached. Delete some before creating new ones.`);
|
|
126
29
|
}
|
|
127
30
|
const now = Date.now();
|
|
@@ -140,23 +43,13 @@ export class LoopStore {
|
|
|
140
43
|
maxFires: opts.maxFires,
|
|
141
44
|
},
|
|
142
45
|
});
|
|
143
|
-
return this.
|
|
46
|
+
return this.entries.get(String(this.nextId - 1))!;
|
|
144
47
|
});
|
|
145
48
|
}
|
|
146
49
|
|
|
147
|
-
get(id: string): LoopEntry | undefined {
|
|
148
|
-
if (this.filePath) this.load();
|
|
149
|
-
return this.loops.get(id);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
list(): LoopEntry[] {
|
|
153
|
-
if (this.filePath) this.load();
|
|
154
|
-
return Array.from(this.loops.values()).sort((a, b) => Number(a.id) - Number(b.id));
|
|
155
|
-
}
|
|
156
|
-
|
|
157
50
|
pause(id: string): LoopEntry | undefined {
|
|
158
51
|
return this.withLock(() => {
|
|
159
|
-
const entry = this.
|
|
52
|
+
const entry = this.entries.get(id);
|
|
160
53
|
if (!entry) return undefined;
|
|
161
54
|
this.applyReducerEvent({
|
|
162
55
|
type: "LOOP_PAUSED",
|
|
@@ -166,13 +59,13 @@ export class LoopStore {
|
|
|
166
59
|
entityId: id,
|
|
167
60
|
payload: { id },
|
|
168
61
|
});
|
|
169
|
-
return this.
|
|
62
|
+
return this.entries.get(id);
|
|
170
63
|
});
|
|
171
64
|
}
|
|
172
65
|
|
|
173
66
|
resume(id: string): LoopEntry | undefined {
|
|
174
67
|
return this.withLock(() => {
|
|
175
|
-
const entry = this.
|
|
68
|
+
const entry = this.entries.get(id);
|
|
176
69
|
if (!entry) return undefined;
|
|
177
70
|
this.applyReducerEvent({
|
|
178
71
|
type: "LOOP_RESUMED",
|
|
@@ -182,13 +75,13 @@ export class LoopStore {
|
|
|
182
75
|
entityId: id,
|
|
183
76
|
payload: { id },
|
|
184
77
|
});
|
|
185
|
-
return this.
|
|
78
|
+
return this.entries.get(id);
|
|
186
79
|
});
|
|
187
80
|
}
|
|
188
81
|
|
|
189
82
|
fire(id: string): LoopEntry | undefined {
|
|
190
83
|
return this.withLock(() => {
|
|
191
|
-
const entry = this.
|
|
84
|
+
const entry = this.entries.get(id);
|
|
192
85
|
if (!entry) return undefined;
|
|
193
86
|
this.applyReducerEvent({
|
|
194
87
|
type: "LOOP_FIRED",
|
|
@@ -198,13 +91,13 @@ export class LoopStore {
|
|
|
198
91
|
entityId: id,
|
|
199
92
|
payload: { id },
|
|
200
93
|
});
|
|
201
|
-
return this.
|
|
94
|
+
return this.entries.get(id);
|
|
202
95
|
});
|
|
203
96
|
}
|
|
204
97
|
|
|
205
98
|
updateMetadata(id: string, fields: { trigger?: Trigger; prompt?: string }): { entry: LoopEntry | undefined; changedFields: string[] } {
|
|
206
99
|
return this.withLock(() => {
|
|
207
|
-
const current = this.
|
|
100
|
+
const current = this.entries.get(id);
|
|
208
101
|
if (!current) return { entry: undefined, changedFields: [] };
|
|
209
102
|
|
|
210
103
|
const changedFields: string[] = [];
|
|
@@ -222,13 +115,13 @@ export class LoopStore {
|
|
|
222
115
|
current.updatedAt = now;
|
|
223
116
|
}
|
|
224
117
|
|
|
225
|
-
return { entry: this.
|
|
118
|
+
return { entry: this.entries.get(id), changedFields };
|
|
226
119
|
});
|
|
227
120
|
}
|
|
228
121
|
|
|
229
122
|
delete(id: string): boolean {
|
|
230
123
|
return this.withLock(() => {
|
|
231
|
-
if (!this.
|
|
124
|
+
if (!this.entries.has(id)) return false;
|
|
232
125
|
this.applyReducerEvent({
|
|
233
126
|
type: "LOOP_DELETED",
|
|
234
127
|
at: Date.now(),
|
|
@@ -245,7 +138,7 @@ export class LoopStore {
|
|
|
245
138
|
return this.withLock(() => {
|
|
246
139
|
const now = Date.now();
|
|
247
140
|
let count = 0;
|
|
248
|
-
for (const [id, entry] of [...this.
|
|
141
|
+
for (const [id, entry] of [...this.entries.entries()]) {
|
|
249
142
|
if (now < entry.expiresAt) continue;
|
|
250
143
|
this.applyReducerEvent({
|
|
251
144
|
type: "LOOP_EXPIRED",
|
|
@@ -264,7 +157,7 @@ export class LoopStore {
|
|
|
264
157
|
expireEventLoops(sessionStartedAt: number): number {
|
|
265
158
|
return this.withLock(() => {
|
|
266
159
|
let count = 0;
|
|
267
|
-
for (const [id, entry] of [...this.
|
|
160
|
+
for (const [id, entry] of [...this.entries.entries()]) {
|
|
268
161
|
if (entry.status !== "active") continue;
|
|
269
162
|
if (entry.trigger.type !== "event" && entry.trigger.type !== "hybrid") continue;
|
|
270
163
|
if (entry.createdAt >= sessionStartedAt) continue;
|
|
@@ -284,7 +177,7 @@ export class LoopStore {
|
|
|
284
177
|
|
|
285
178
|
clearAll(): number {
|
|
286
179
|
return this.withLock(() => {
|
|
287
|
-
const ids = [...this.
|
|
180
|
+
const ids = [...this.entries.keys()];
|
|
288
181
|
for (const id of ids) {
|
|
289
182
|
this.applyReducerEvent({
|
|
290
183
|
type: "LOOP_DELETED",
|
|
@@ -298,10 +191,4 @@ export class LoopStore {
|
|
|
298
191
|
return ids.length;
|
|
299
192
|
});
|
|
300
193
|
}
|
|
301
|
-
|
|
302
|
-
deleteFileIfEmpty(): boolean {
|
|
303
|
-
if (!this.filePath || this.loops.size > 0) return false;
|
|
304
|
-
try { unlinkSync(this.filePath); } catch { /* ignore */ }
|
|
305
|
-
return true;
|
|
306
|
-
}
|
|
307
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") {
|