@trevonistrevon/pi-loop 0.5.4 → 0.5.6

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 (101) 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 +1063 -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 +934 -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/goal-reducer.d.ts +9 -0
  47. package/dist/goal-reducer.js +11 -2
  48. package/dist/goal-store.d.ts +4 -15
  49. package/dist/goal-store.js +32 -155
  50. package/dist/index.js +5 -1
  51. package/dist/loop-reducer.d.ts +7 -0
  52. package/dist/loop-reducer.js +9 -0
  53. package/dist/monitor-manager.d.ts +9 -0
  54. package/dist/monitor-manager.js +33 -12
  55. package/dist/reducer-backed-store.d.ts +65 -0
  56. package/dist/reducer-backed-store.js +160 -0
  57. package/dist/store.d.ts +4 -16
  58. package/dist/store.js +25 -157
  59. package/dist/task-reducer.js +3 -0
  60. package/dist/task-store.d.ts +4 -15
  61. package/dist/task-store.js +25 -148
  62. package/dist/tools/monitor-tools.js +9 -0
  63. package/dist/trigger-system.js +2 -1
  64. package/package.json +4 -1
  65. package/src/goal-reducer.ts +20 -1
  66. package/src/goal-store.ts +35 -143
  67. package/src/index.ts +5 -1
  68. package/src/loop-reducer.ts +10 -0
  69. package/src/monitor-manager.ts +35 -12
  70. package/src/reducer-backed-store.ts +168 -0
  71. package/src/store.ts +28 -142
  72. package/src/task-reducer.ts +3 -0
  73. package/src/task-store.ts +28 -136
  74. package/src/tools/monitor-tools.ts +9 -0
  75. package/src/trigger-system.ts +2 -1
  76. package/vitest.config.ts +25 -0
  77. package/DIFFERENTIAL_REVIEW_REPORT.md +0 -81
  78. package/tmp/perf-cap-data/loops.json +0 -455
  79. package/tmp/perf-cap-data/tasks.json +0 -1
  80. package/tmp/perf-data/loops-0.json +0 -1
  81. package/tmp/perf-data/loops-10.json +0 -1
  82. package/tmp/perf-data/loops-100.json +0 -1
  83. package/tmp/perf-data/loops-1000.json +0 -1
  84. package/tmp/perf-data/loops-500.json +0 -1
  85. package/tmp/perf-data/tasks-0.json +0 -1
  86. package/tmp/perf-data/tasks-10.json +0 -1
  87. package/tmp/perf-data/tasks-100.json +0 -1
  88. package/tmp/perf-data/tasks-1000.json +0 -1
  89. package/tmp/perf-data/tasks-500.json +0 -1
  90. package/tmp/perf-data-session/loops-0.json +0 -4
  91. package/tmp/perf-data-session/loops-100.json +0 -1805
  92. package/tmp/perf-data-session/loops-1000.json +0 -18005
  93. package/tmp/perf-data-session/loops-25.json +0 -455
  94. package/tmp/perf-data-session/loops-500.json +0 -9005
  95. package/tmp/perf-data-session/tasks-0.json +0 -1
  96. package/tmp/perf-data-session/tasks-100.json +0 -1
  97. package/tmp/perf-data-session/tasks-1000.json +0 -1
  98. package/tmp/perf-data-session/tasks-25.json +0 -1
  99. package/tmp/perf-data-session/tasks-500.json +0 -1
  100. package/tmp/perf-session-init.js +0 -98
  101. package/tmp/perf-startup-audit.js +0 -80
@@ -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 { 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
- 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 GoalStore {
54
- filePath;
55
- lockPath;
56
- lastLoadedSignature;
57
- nextId = 1;
58
- goals = new Map();
7
+ export class GoalStore extends ReducerBackedStore {
59
8
  constructor(listIdOrPath) {
60
- if (!listIdOrPath)
61
- return;
62
- const isAbsPath = isAbsolute(listIdOrPath);
63
- const filePath = isAbsPath ? listIdOrPath : join(GOALS_DIR, `${listIdOrPath}.json`);
64
- mkdirSync(dirname(filePath), { recursive: true });
65
- this.filePath = filePath;
66
- this.lockPath = filePath + ".lock";
67
- this.load();
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.goals.clear();
87
- for (const goal of data.goals) {
88
- this.goals.set(goal.id, goal);
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
- goals: Array.from(this.goals.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
- goalsById: Object.fromEntries(this.goals.entries()),
124
- };
125
- }
126
- applyReducerEvent(event) {
127
- const result = reduceGoalState(this.toReducerState(), event);
128
- this.nextId = result.state.nextId;
129
- 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);
130
17
  }
131
18
  create(title, description, scope, criteria, metadata) {
132
19
  return this.withLock(() => {
133
- if (this.goals.size >= MAX_GOALS) {
20
+ if (this.entries.size >= MAX_GOALS) {
134
21
  throw new Error(`Maximum of ${MAX_GOALS} goals reached. Archive some before creating new ones.`);
135
22
  }
136
23
  this.applyReducerEvent({
@@ -146,22 +33,12 @@ export class GoalStore {
146
33
  metadata,
147
34
  },
148
35
  });
149
- return this.goals.get(String(this.nextId - 1));
36
+ return this.entries.get(String(this.nextId - 1));
150
37
  });
151
38
  }
152
- get(id) {
153
- if (this.filePath)
154
- this.load();
155
- return this.goals.get(id);
156
- }
157
- list() {
158
- if (this.filePath)
159
- this.load();
160
- return Array.from(this.goals.values()).sort((a, b) => Number(a.id) - Number(b.id));
161
- }
162
39
  activate(id) {
163
40
  return this.withLock(() => {
164
- if (!this.goals.has(id))
41
+ if (!this.entries.has(id))
165
42
  return undefined;
166
43
  this.applyReducerEvent({
167
44
  type: "GOAL_ACTIVATED",
@@ -171,12 +48,12 @@ export class GoalStore {
171
48
  entityId: id,
172
49
  payload: { id },
173
50
  });
174
- return this.goals.get(id);
51
+ return this.entries.get(id);
175
52
  });
176
53
  }
177
54
  recordProgress(id, progress) {
178
55
  return this.withLock(() => {
179
- if (!this.goals.has(id))
56
+ if (!this.entries.has(id))
180
57
  return undefined;
181
58
  this.applyReducerEvent({
182
59
  type: "GOAL_PROGRESS_RECORDED",
@@ -186,12 +63,12 @@ export class GoalStore {
186
63
  entityId: id,
187
64
  payload: { id, progress },
188
65
  });
189
- return this.goals.get(id);
66
+ return this.entries.get(id);
190
67
  });
191
68
  }
192
69
  markVerificationStarted(id) {
193
70
  return this.withLock(() => {
194
- if (!this.goals.has(id))
71
+ if (!this.entries.has(id))
195
72
  return undefined;
196
73
  this.applyReducerEvent({
197
74
  type: "GOAL_VERIFICATION_STARTED",
@@ -201,12 +78,12 @@ export class GoalStore {
201
78
  entityId: id,
202
79
  payload: { id },
203
80
  });
204
- return this.goals.get(id);
81
+ return this.entries.get(id);
205
82
  });
206
83
  }
207
84
  markVerified(id, reason, progress) {
208
85
  return this.withLock(() => {
209
- if (!this.goals.has(id))
86
+ if (!this.entries.has(id))
210
87
  return undefined;
211
88
  this.applyReducerEvent({
212
89
  type: "GOAL_VERIFICATION_PASSED",
@@ -216,12 +93,12 @@ export class GoalStore {
216
93
  entityId: id,
217
94
  payload: { id, reason, progress },
218
95
  });
219
- return this.goals.get(id);
96
+ return this.entries.get(id);
220
97
  });
221
98
  }
222
99
  markFailed(id, reason, progress) {
223
100
  return this.withLock(() => {
224
- if (!this.goals.has(id))
101
+ if (!this.entries.has(id))
225
102
  return undefined;
226
103
  this.applyReducerEvent({
227
104
  type: "GOAL_FAILED",
@@ -231,12 +108,12 @@ export class GoalStore {
231
108
  entityId: id,
232
109
  payload: { id, reason, progress },
233
110
  });
234
- return this.goals.get(id);
111
+ return this.entries.get(id);
235
112
  });
236
113
  }
237
114
  markBlocked(id, reason, progress) {
238
115
  return this.withLock(() => {
239
- if (!this.goals.has(id))
116
+ if (!this.entries.has(id))
240
117
  return undefined;
241
118
  this.applyReducerEvent({
242
119
  type: "GOAL_BLOCKED",
@@ -246,12 +123,12 @@ export class GoalStore {
246
123
  entityId: id,
247
124
  payload: { id, reason, progress },
248
125
  });
249
- return this.goals.get(id);
126
+ return this.entries.get(id);
250
127
  });
251
128
  }
252
129
  unblock(id) {
253
130
  return this.withLock(() => {
254
- if (!this.goals.has(id))
131
+ if (!this.entries.has(id))
255
132
  return undefined;
256
133
  this.applyReducerEvent({
257
134
  type: "GOAL_UNBLOCKED",
@@ -261,19 +138,19 @@ export class GoalStore {
261
138
  entityId: id,
262
139
  payload: { id },
263
140
  });
264
- return this.goals.get(id);
141
+ return this.entries.get(id);
265
142
  });
266
143
  }
267
144
  updateDetails(id, fields) {
268
145
  return this.withLock(() => {
269
- if (!this.goals.has(id))
146
+ if (!this.entries.has(id))
270
147
  return undefined;
271
148
  if (fields.title === undefined
272
149
  && fields.description === undefined
273
150
  && fields.scope === undefined
274
151
  && fields.criteria === undefined
275
152
  && fields.metadata === undefined) {
276
- return this.goals.get(id);
153
+ return this.entries.get(id);
277
154
  }
278
155
  this.applyReducerEvent({
279
156
  type: "GOAL_UPDATED",
@@ -290,12 +167,12 @@ export class GoalStore {
290
167
  metadata: fields.metadata,
291
168
  },
292
169
  });
293
- return this.goals.get(id);
170
+ return this.entries.get(id);
294
171
  });
295
172
  }
296
173
  archive(id, reason) {
297
174
  return this.withLock(() => {
298
- if (!this.goals.has(id))
175
+ if (!this.entries.has(id))
299
176
  return undefined;
300
177
  this.applyReducerEvent({
301
178
  type: "GOAL_ARCHIVED",
@@ -305,7 +182,7 @@ export class GoalStore {
305
182
  entityId: id,
306
183
  payload: { id, reason },
307
184
  });
308
- return this.goals.get(id);
185
+ return this.entries.get(id);
309
186
  });
310
187
  }
311
188
  }
package/dist/index.js CHANGED
@@ -15,6 +15,7 @@
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";
@@ -45,6 +46,9 @@ export default function (pi) {
45
46
  let scheduler;
46
47
  let triggerSystem;
47
48
  const widget = new LoopWidget(store, monitorManager);
49
+ // Repaint the status bar when a monitor finishes/prunes on its own (no tool
50
+ // call), so stale monitors don't linger in the count between turns.
51
+ monitorManager.setOnChange(() => widget.update());
48
52
  widget.setTaskSummaryProvider(() => {
49
53
  if (!nativeTaskStore)
50
54
  return { count: 0 };
@@ -158,7 +162,7 @@ export default function (pi) {
158
162
  // ── Loop fire handler ──
159
163
  function onLoopFire(entry) {
160
164
  debug(`loop:fire #${entry.id}`, { prompt: entry.prompt.slice(0, 50) });
161
- if (entry.maxFires && (entry.fireCount ?? 0) >= entry.maxFires) {
165
+ if (atMaxFires(entry)) {
162
166
  debug(`loop #${entry.id} — reached maxFires ${entry.maxFires}, expiring`);
163
167
  store.delete(entry.id);
164
168
  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,
@@ -4,9 +4,18 @@ export declare class MonitorManager {
4
4
  private pi;
5
5
  private processes;
6
6
  private nextId;
7
+ private onChange;
7
8
  constructor(pi: ExtensionAPI);
9
+ /**
10
+ * Register a callback fired when a monitor's status changes or it is pruned
11
+ * (e.g. autonomous completion/error/stop/prune with no tool call). Used to
12
+ * repaint the status widget, which otherwise only refreshes on turn
13
+ * boundaries and explicit tool actions. Not fired for output lines.
14
+ */
15
+ setOnChange(cb: () => void): void;
8
16
  private toReducerState;
9
17
  private applyReducerEvent;
18
+ private schedulePrune;
10
19
  create(command: string, description?: string, timeout?: number): MonitorEntry;
11
20
  get(id: string): MonitorEntry | undefined;
12
21
  list(): MonitorEntry[];
@@ -4,9 +4,19 @@ export class MonitorManager {
4
4
  pi;
5
5
  processes = new Map();
6
6
  nextId = 1;
7
+ onChange;
7
8
  constructor(pi) {
8
9
  this.pi = pi;
9
10
  }
11
+ /**
12
+ * Register a callback fired when a monitor's status changes or it is pruned
13
+ * (e.g. autonomous completion/error/stop/prune with no tool call). Used to
14
+ * repaint the status widget, which otherwise only refreshes on turn
15
+ * boundaries and explicit tool actions. Not fired for output lines.
16
+ */
17
+ setOnChange(cb) {
18
+ this.onChange = cb;
19
+ }
10
20
  toReducerState() {
11
21
  return {
12
22
  nextId: this.nextId,
@@ -24,8 +34,29 @@ export class MonitorManager {
24
34
  }
25
35
  process.entry = updated;
26
36
  }
37
+ // Repaint on status/set transitions, but not on the frequent output-line
38
+ // events (which never change the visible count).
39
+ if (event.type !== "MONITOR_OUTPUT" && event.type !== "MONITOR_ONDONE_REGISTERED") {
40
+ this.onChange?.();
41
+ }
27
42
  return result;
28
43
  }
44
+ // Remove a finished monitor (completed/errored/stopped) after a brief delay so
45
+ // tool consumers can still read its final state via MonitorList. Shared by
46
+ // finish() and stop() so every terminal status is pruned consistently — a
47
+ // stopped monitor that is never pruned lingers in list() and the widget count.
48
+ schedulePrune(id) {
49
+ setTimeout(() => {
50
+ this.applyReducerEvent({
51
+ type: "MONITOR_PRUNED",
52
+ at: Date.now(),
53
+ source: "system",
54
+ entityType: "monitor",
55
+ entityId: id,
56
+ payload: { id },
57
+ });
58
+ }, 30000);
59
+ }
29
60
  create(command, description, timeout = 300000) {
30
61
  const now = Date.now();
31
62
  const result = reduceMonitorState(this.toReducerState(), {
@@ -120,18 +151,7 @@ export class MonitorManager {
120
151
  for (const resolve of bp.waiters)
121
152
  resolve();
122
153
  bp.waiters = [];
123
- // Remove completed/errored monitors after a brief delay so tool
124
- // consumers have time to read the final state via MonitorList.
125
- setTimeout(() => {
126
- this.applyReducerEvent({
127
- type: "MONITOR_PRUNED",
128
- at: Date.now(),
129
- source: "system",
130
- entityType: "monitor",
131
- entityId: id,
132
- payload: { id },
133
- });
134
- }, 30000);
154
+ this.schedulePrune(id);
135
155
  };
136
156
  child.on("close", (code) => {
137
157
  if (bp.entry.status === "running") {
@@ -197,6 +217,7 @@ export class MonitorManager {
197
217
  reason: "manual",
198
218
  },
199
219
  });
220
+ this.schedulePrune(id);
200
221
  bp.proc.kill("SIGTERM");
201
222
  await new Promise((resolve) => {
202
223
  const timer = setTimeout(() => {
@@ -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
+ }