@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.
Files changed (87) hide show
  1. package/coverage/base.css +224 -0
  2. package/coverage/block-navigation.js +87 -0
  3. package/coverage/coverage-final.json +32 -0
  4. package/coverage/favicon.png +0 -0
  5. package/coverage/index.html +176 -0
  6. package/coverage/prettify.css +1 -0
  7. package/coverage/prettify.js +2 -0
  8. package/coverage/sort-arrow-sprite.png +0 -0
  9. package/coverage/sorter.js +210 -0
  10. package/coverage/src/commands/index.html +131 -0
  11. package/coverage/src/commands/loop-command.ts.html +634 -0
  12. package/coverage/src/commands/tasks-command.ts.html +490 -0
  13. package/coverage/src/coordinator.ts.html +430 -0
  14. package/coverage/src/goal-coordinator.ts.html +259 -0
  15. package/coverage/src/goal-reducer.ts.html +982 -0
  16. package/coverage/src/goal-store.ts.html +742 -0
  17. package/coverage/src/goal-verifier.ts.html +808 -0
  18. package/coverage/src/index.html +386 -0
  19. package/coverage/src/index.ts.html +1054 -0
  20. package/coverage/src/loop-parse.ts.html +574 -0
  21. package/coverage/src/loop-reducer.ts.html +559 -0
  22. package/coverage/src/monitor-completion-coordinator.ts.html +157 -0
  23. package/coverage/src/monitor-manager.ts.html +865 -0
  24. package/coverage/src/monitor-reducer.ts.html +583 -0
  25. package/coverage/src/notification-reducer.ts.html +550 -0
  26. package/coverage/src/reducer-backed-store.ts.html +589 -0
  27. package/coverage/src/runtime/index.html +191 -0
  28. package/coverage/src/runtime/monitor-ondone-runtime.ts.html +310 -0
  29. package/coverage/src/runtime/notification-runtime.ts.html +721 -0
  30. package/coverage/src/runtime/scope.ts.html +196 -0
  31. package/coverage/src/runtime/session-runtime.ts.html +589 -0
  32. package/coverage/src/runtime/task-backlog-runtime.ts.html +574 -0
  33. package/coverage/src/runtime/task-rpc.ts.html +535 -0
  34. package/coverage/src/scheduler.ts.html +400 -0
  35. package/coverage/src/store.ts.html +667 -0
  36. package/coverage/src/task-backlog-coordinator.ts.html +181 -0
  37. package/coverage/src/task-reducer.ts.html +550 -0
  38. package/coverage/src/task-store.ts.html +526 -0
  39. package/coverage/src/tools/index.html +146 -0
  40. package/coverage/src/tools/loop-tools.ts.html +1000 -0
  41. package/coverage/src/tools/monitor-tools.ts.html +547 -0
  42. package/coverage/src/tools/native-task-tools.ts.html +535 -0
  43. package/coverage/src/trigger-system.ts.html +547 -0
  44. package/coverage/src/ui/index.html +116 -0
  45. package/coverage/src/ui/widget.ts.html +292 -0
  46. package/dist/commands/loop-command.js +4 -5
  47. package/dist/commands/tasks-command.js +3 -3
  48. package/dist/goal-reducer.d.ts +9 -0
  49. package/dist/goal-reducer.js +11 -2
  50. package/dist/goal-store.d.ts +4 -15
  51. package/dist/goal-store.js +32 -154
  52. package/dist/index.d.ts +2 -2
  53. package/dist/index.js +4 -3
  54. package/dist/loop-reducer.d.ts +7 -0
  55. package/dist/loop-reducer.js +9 -0
  56. package/dist/monitor-manager.js +4 -4
  57. package/dist/reducer-backed-store.d.ts +65 -0
  58. package/dist/reducer-backed-store.js +160 -0
  59. package/dist/store.d.ts +4 -16
  60. package/dist/store.js +25 -156
  61. package/dist/task-reducer.js +3 -0
  62. package/dist/task-store.d.ts +4 -15
  63. package/dist/task-store.js +25 -147
  64. package/dist/tools/loop-tools.js +7 -5
  65. package/dist/tools/monitor-tools.js +12 -3
  66. package/dist/tools/native-task-tools.js +2 -2
  67. package/dist/trigger-system.js +2 -1
  68. package/dist/ui/widget.js +8 -1
  69. package/package.json +4 -1
  70. package/src/commands/loop-command.ts +4 -5
  71. package/src/commands/tasks-command.ts +3 -3
  72. package/src/goal-reducer.ts +20 -1
  73. package/src/goal-store.ts +35 -142
  74. package/src/index.ts +4 -3
  75. package/src/loop-reducer.ts +10 -0
  76. package/src/monitor-manager.ts +2 -3
  77. package/src/reducer-backed-store.ts +168 -0
  78. package/src/store.ts +28 -141
  79. package/src/task-reducer.ts +3 -0
  80. package/src/task-store.ts +28 -135
  81. package/src/tools/loop-tools.ts +6 -5
  82. package/src/tools/monitor-tools.ts +12 -3
  83. package/src/tools/native-task-tools.ts +2 -2
  84. package/src/trigger-system.ts +2 -1
  85. package/src/ui/widget.ts +8 -1
  86. package/vitest.config.ts +25 -0
  87. package/DIFFERENTIAL_REVIEW_REPORT.md +0 -81
@@ -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 { dirname, isAbsolute, join } from "node:path";
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
- function acquireLock(lockPath) {
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
- if (!listIdOrPath)
60
- return;
61
- const isAbsPath = isAbsolute(listIdOrPath);
62
- const filePath = isAbsPath ? listIdOrPath : join(GOALS_DIR, `${listIdOrPath}.json`);
63
- mkdirSync(dirname(filePath), { recursive: true });
64
- this.filePath = filePath;
65
- this.lockPath = filePath + ".lock";
66
- this.load();
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.goals.size >= MAX_GOALS) {
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.goals.get(String(this.nextId - 1));
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.goals.has(id))
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.goals.get(id);
51
+ return this.entries.get(id);
174
52
  });
175
53
  }
176
54
  recordProgress(id, progress) {
177
55
  return this.withLock(() => {
178
- if (!this.goals.has(id))
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.goals.get(id);
66
+ return this.entries.get(id);
189
67
  });
190
68
  }
191
69
  markVerificationStarted(id) {
192
70
  return this.withLock(() => {
193
- if (!this.goals.has(id))
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.goals.get(id);
81
+ return this.entries.get(id);
204
82
  });
205
83
  }
206
84
  markVerified(id, reason, progress) {
207
85
  return this.withLock(() => {
208
- if (!this.goals.has(id))
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.goals.get(id);
96
+ return this.entries.get(id);
219
97
  });
220
98
  }
221
99
  markFailed(id, reason, progress) {
222
100
  return this.withLock(() => {
223
- if (!this.goals.has(id))
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.goals.get(id);
111
+ return this.entries.get(id);
234
112
  });
235
113
  }
236
114
  markBlocked(id, reason, progress) {
237
115
  return this.withLock(() => {
238
- if (!this.goals.has(id))
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.goals.get(id);
126
+ return this.entries.get(id);
249
127
  });
250
128
  }
251
129
  unblock(id) {
252
130
  return this.withLock(() => {
253
- if (!this.goals.has(id))
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.goals.get(id);
141
+ return this.entries.get(id);
264
142
  });
265
143
  }
266
144
  updateDetails(id, fields) {
267
145
  return this.withLock(() => {
268
- if (!this.goals.has(id))
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.goals.get(id);
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.goals.get(id);
170
+ return this.entries.get(id);
293
171
  });
294
172
  }
295
173
  archive(id, reason) {
296
174
  return this.withLock(() => {
297
- if (!this.goals.has(id))
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.goals.get(id);
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 a re-wake loop: /loop [interval] [prompt]
14
- * /loopsInteractive menu: view, create, cancel, settings
13
+ * /loop — Schedule or manage re-wake loops: /loop [interval] [prompt]
14
+ * /tasksView 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 a re-wake loop: /loop [interval] [prompt]
14
- * /loopsInteractive menu: view, create, cancel, settings
13
+ * /loop — Schedule or manage re-wake loops: /loop [interval] [prompt]
14
+ * /tasksView 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 (entry.maxFires && (entry.fireCount ?? 0) >= entry.maxFires) {
162
+ if (atMaxFires(entry)) {
162
163
  debug(`loop #${entry.id} — reached maxFires ${entry.maxFires}, expiring`);
163
164
  store.delete(entry.id);
164
165
  return;
@@ -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;
@@ -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,
@@ -114,10 +114,8 @@ export class MonitorManager {
114
114
  exitCode: code,
115
115
  outputLines: current.outputLines,
116
116
  });
117
- if (status === "completed") {
118
- for (const callback of bp.completionCallbacks)
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 { LoopEntry, Trigger } from "./types.js";
2
- export declare class LoopStore {
3
- private filePath;
4
- private lockPath;
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
  }