pi-antigravity-rotator 2.2.2 → 2.3.0

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.
@@ -1,203 +1,175 @@
1
1
  // Persistent in-memory store for OpenAI Responses API (Codex) chain state.
2
2
  //
3
3
  // The store maps `response_id` to the conversation state needed to continue
4
- // a previous turn via `previous_response_id`. It is persisted to disk so a
5
- // rotator restart does not break active Codex sessions.
4
+ // a previous turn via `previous_response_id`. It is persisted via the
5
+ // settings repository (PostgreSQL or disk files) so a rotator restart does
6
+ // not break active Codex sessions.
6
7
  //
7
- // Writes are debounced to avoid fsync storms under load. The store is also
8
- // flushed synchronously on SIGTERM (see index.ts) to minimise data loss.
8
+ // Writes are debounced to avoid excessive persistence calls under load.
9
+ // The store is also flushed synchronously on SIGTERM (see index.ts) to
10
+ // minimise data loss.
9
11
 
10
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
11
- import { promises as fs } from "node:fs";
12
- import { join } from "node:path";
13
- import { homedir } from "node:os";
12
+ import {
13
+ getCachedResponsesStore,
14
+ setCachedResponsesStore,
15
+ } from "./db-store.js";
14
16
 
15
17
  export interface StoredResponseEntry {
16
- response: Record<string, unknown>;
17
- inputItems: Array<Record<string, unknown>>;
18
- conversationMessages: Array<Record<string, unknown>>;
19
- // Maps call_id -> function name. Serialized as plain object on disk
20
- // because Map cannot be JSON.stringified directly. Convert to Map at use sites.
21
- callIdToName: Record<string, string>;
22
- expiresAt: number;
18
+ response: Record<string, unknown>;
19
+ inputItems: Array<Record<string, unknown>>;
20
+ conversationMessages: Array<Record<string, unknown>>;
21
+ // Maps call_id -> function name. Serialized as plain object on disk
22
+ // because Map cannot be JSON.stringified directly. Convert to Map at use sites.
23
+ callIdToName: Record<string, string>;
24
+ expiresAt: number;
23
25
  }
24
26
 
25
- interface PersistedStore {
26
- version: 1;
27
- entries: Array<[string, StoredResponseEntry]>;
27
+ export interface PersistedResponsesStore {
28
+ version: 1;
29
+ entries: Array<[string, StoredResponseEntry]>;
28
30
  }
29
31
 
30
32
  const FLUSH_DEBOUNCE_MS = 1_500;
31
33
  const MAX_ENTRIES = 500;
32
34
  const ENTRY_TTL_MS = 6 * 60 * 60 * 1000;
33
35
 
34
- function getStorePath(): string {
35
- return join(process.env.PI_ROTATOR_DIR ?? join(homedir(), ".pi-antigravity-rotator"), "responses.json");
36
- }
37
-
38
36
  function now(): number {
39
- return Date.now();
37
+ return Date.now();
40
38
  }
41
39
 
42
40
  /**
43
41
  * Persistent in-memory store for OpenAI Responses API (Codex) chain state.
44
42
  *
45
43
  * Maps `response_id` to the conversation state needed to continue a previous
46
- * turn via `previous_response_id`. Persists to <configDir>/responses.json
47
- * with atomic writes (temp + rename). Writes are debounced to 1.5s and
48
- * coalesced if a flush is already in flight. Stale entries (older than
49
- * the 6h TTL) and entries over the 500-entry cap are pruned automatically.
50
- *
51
- * Corrupt files are moved aside to .corrupt-<ts>.bak on load().
44
+ * turn via `previous_response_id`. Persisted via the settings repository.
45
+ * Writes are debounced to 1.5s and coalesced if a flush is already in flight.
46
+ * Stale entries (older than the 6h TTL) and entries over the 500-entry cap
47
+ * are pruned automatically.
52
48
  */
53
49
  export class ResponsesStore {
54
- private cache = new Map<string, StoredResponseEntry>();
55
- private flushTimer: ReturnType<typeof setTimeout> | null = null;
56
- private flushing: Promise<void> | null = null;
57
- private pendingFlush = false;
58
- private readonly path: string;
59
- private dirty = false;
60
-
61
- constructor(path: string = getStorePath()) {
62
- this.path = path;
63
- }
64
-
65
- /**
66
- * Load the persisted store from disk. Missing or corrupt files are treated
67
- * as an empty store. Stale entries (older than TTL) are pruned.
68
- */
69
- async load(): Promise<void> {
70
- if (!existsSync(this.path)) return;
71
- try {
72
- const raw = readFileSync(this.path, "utf-8");
73
- const parsed = JSON.parse(raw) as PersistedStore;
74
- if (!parsed || parsed.version !== 1 || !Array.isArray(parsed.entries)) return;
75
- const cutoff = now() - ENTRY_TTL_MS;
76
- for (const [id, entry] of parsed.entries) {
77
- if (!entry || typeof entry !== "object") continue;
78
- if (typeof entry.expiresAt !== "number" || entry.expiresAt < cutoff) continue;
79
- this.cache.set(id, entry);
80
- }
81
- } catch {
82
- // Corrupt store — start fresh; rename old file aside for inspection.
83
- try {
84
- const backup = `${this.path}.corrupt-${now()}.bak`;
85
- await fs.rename(this.path, backup).catch(() => undefined);
86
- } catch {
87
- // ignore
88
- }
89
- }
90
- }
91
-
92
- get(id: string): StoredResponseEntry | null {
93
- const entry = this.cache.get(id);
94
- if (!entry) return null;
95
- if (entry.expiresAt <= now()) {
96
- this.cache.delete(id);
97
- this.scheduleFlush();
98
- return null;
99
- }
100
- return entry;
101
- }
102
-
103
- set(id: string, entry: StoredResponseEntry): void {
104
- this.cache.set(id, entry);
105
- this.pruneIfNeeded();
106
- this.scheduleFlush();
107
- }
108
-
109
- delete(id: string): boolean {
110
- const existed = this.cache.delete(id);
111
- if (existed) this.scheduleFlush();
112
- return existed;
113
- }
114
-
115
- clear(): void {
116
- this.cache.clear();
117
- this.scheduleFlush();
118
- }
119
-
120
- size(): number {
121
- return this.cache.size;
122
- }
123
-
124
- private pruneIfNeeded(): void {
125
- const cutoff = now() - ENTRY_TTL_MS;
126
- for (const [id, entry] of this.cache.entries()) {
127
- if (entry.expiresAt <= cutoff) this.cache.delete(id);
128
- }
129
- while (this.cache.size > MAX_ENTRIES) {
130
- const oldest = this.cache.keys().next();
131
- if (oldest.done) break;
132
- this.cache.delete(oldest.value);
133
- }
134
- }
135
-
136
- private scheduleFlush(): void {
137
- this.dirty = true;
138
- if (this.flushTimer) return;
139
- this.flushTimer = setTimeout(() => {
140
- this.flushTimer = null;
141
- void this.flush();
142
- }, FLUSH_DEBOUNCE_MS);
143
- if (this.flushTimer.unref) this.flushTimer.unref();
144
- }
145
-
146
- /**
147
- * Flush pending writes to disk. Safe to call multiple times concurrently;
148
- * callers will see the same in-flight promise.
149
- */
150
- async flush(): Promise<void> {
151
- if (this.flushing) {
152
- this.pendingFlush = true;
153
- return this.flushing;
154
- }
155
- this.flushing = (async () => {
156
- try {
157
- if (!this.dirty) return;
158
- const data: PersistedStore = {
159
- version: 1,
160
- entries: Array.from(this.cache.entries()),
161
- };
162
- const dir = join(this.path, "..");
163
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
164
- const tmp = `${this.path}.tmp`;
165
- writeFileSync(tmp, JSON.stringify(data, null, 2), "utf-8");
166
- await fs.rename(tmp, this.path);
167
- this.dirty = false;
168
- } catch {
169
- // Best effort will retry on next schedule.
170
- } finally {
171
- this.flushing = null;
172
- if (this.pendingFlush) {
173
- this.pendingFlush = false;
174
- void this.flush();
175
- }
176
- }
177
- })();
178
- return this.flushing;
179
- }
180
-
181
- /**
182
- * Synchronous flush for use in shutdown handlers. Falls back to no-op on
183
- * platforms where sync fs is not available; the next start will read what
184
- * made it to disk.
185
- */
186
- flushSync(): void {
187
- if (!this.dirty) return;
188
- try {
189
- const data: PersistedStore = {
190
- version: 1,
191
- entries: Array.from(this.cache.entries()),
192
- };
193
- const dir = join(this.path, "..");
194
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
195
- const tmp = `${this.path}.tmp`;
196
- writeFileSync(tmp, JSON.stringify(data, null, 2), "utf-8");
197
- renameSync(tmp, this.path);
198
- this.dirty = false;
199
- } catch {
200
- // Best effort
201
- }
202
- }
50
+ private cache = new Map<string, StoredResponseEntry>();
51
+ private flushTimer: ReturnType<typeof setTimeout> | null = null;
52
+ private flushing: Promise<void> | null = null;
53
+ private pendingFlush = false;
54
+ private dirty = false;
55
+
56
+ /**
57
+ * Load the persisted store from the repository. Missing or corrupt data
58
+ * is treated as an empty store. Stale entries (older than TTL) are pruned.
59
+ */
60
+ async load(): Promise<void> {
61
+ const dbStore = getCachedResponsesStore();
62
+ if (!dbStore || dbStore.version !== 1 || !Array.isArray(dbStore.entries)) {
63
+ return;
64
+ }
65
+ const cutoff = now() - ENTRY_TTL_MS;
66
+ for (const [id, entry] of dbStore.entries) {
67
+ if (!entry || typeof entry !== "object") continue;
68
+ if (typeof entry.expiresAt !== "number" || entry.expiresAt < cutoff)
69
+ continue;
70
+ this.cache.set(id, entry);
71
+ }
72
+ }
73
+
74
+ get(id: string): StoredResponseEntry | null {
75
+ const entry = this.cache.get(id);
76
+ if (!entry) return null;
77
+ if (entry.expiresAt <= now()) {
78
+ this.cache.delete(id);
79
+ this.scheduleFlush();
80
+ return null;
81
+ }
82
+ return entry;
83
+ }
84
+
85
+ set(id: string, entry: StoredResponseEntry): void {
86
+ this.cache.set(id, entry);
87
+ this.pruneIfNeeded();
88
+ this.scheduleFlush();
89
+ }
90
+
91
+ delete(id: string): boolean {
92
+ const existed = this.cache.delete(id);
93
+ if (existed) this.scheduleFlush();
94
+ return existed;
95
+ }
96
+
97
+ clear(): void {
98
+ this.cache.clear();
99
+ this.scheduleFlush();
100
+ }
101
+
102
+ size(): number {
103
+ return this.cache.size;
104
+ }
105
+
106
+ private pruneIfNeeded(): void {
107
+ const cutoff = now() - ENTRY_TTL_MS;
108
+ for (const [id, entry] of this.cache.entries()) {
109
+ if (entry.expiresAt <= cutoff) this.cache.delete(id);
110
+ }
111
+ while (this.cache.size > MAX_ENTRIES) {
112
+ const oldest = this.cache.keys().next();
113
+ if (oldest.done) break;
114
+ this.cache.delete(oldest.value);
115
+ }
116
+ }
117
+
118
+ private scheduleFlush(): void {
119
+ this.dirty = true;
120
+ if (this.flushTimer) return;
121
+ this.flushTimer = setTimeout(() => {
122
+ this.flushTimer = null;
123
+ void this.flush();
124
+ }, FLUSH_DEBOUNCE_MS);
125
+ if (this.flushTimer.unref) this.flushTimer.unref();
126
+ }
127
+
128
+ /**
129
+ * Flush pending writes to the repository. Safe to call multiple times
130
+ * concurrently; callers will see the same in-flight promise.
131
+ */
132
+ async flush(): Promise<void> {
133
+ if (this.flushing) {
134
+ this.pendingFlush = true;
135
+ return this.flushing;
136
+ }
137
+ this.flushing = (async () => {
138
+ try {
139
+ if (!this.dirty) return;
140
+ const data: PersistedResponsesStore = {
141
+ version: 1,
142
+ entries: Array.from(this.cache.entries()),
143
+ };
144
+ setCachedResponsesStore(data);
145
+ this.dirty = false;
146
+ } catch {
147
+ // Best effort — will retry on next schedule.
148
+ } finally {
149
+ this.flushing = null;
150
+ if (this.pendingFlush) {
151
+ this.pendingFlush = false;
152
+ void this.flush();
153
+ }
154
+ }
155
+ })();
156
+ return this.flushing;
157
+ }
158
+
159
+ /**
160
+ * Synchronous flush for use in shutdown handlers.
161
+ */
162
+ flushSync(): void {
163
+ if (!this.dirty) return;
164
+ try {
165
+ const data: PersistedResponsesStore = {
166
+ version: 1,
167
+ entries: Array.from(this.cache.entries()),
168
+ };
169
+ setCachedResponsesStore(data);
170
+ this.dirty = false;
171
+ } catch {
172
+ // Best effort
173
+ }
174
+ }
203
175
  }