@akira-tl/forgerelay 0.3.5 → 0.3.7
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/CHANGELOG.md +29 -0
- package/capabilities/shell-processes/GUIDE.md +2 -2
- package/dist/db/migrations.js +19 -0
- package/dist/db/schema.js +9 -0
- package/dist/logger.js +16 -0
- package/dist/mcp-sessions.js +30 -0
- package/dist/oauth-provider.js +9 -0
- package/dist/process-sessions.js +98 -16
- package/dist/review-checkpoints.js +36 -1
- package/dist/server.js +234 -26
- package/dist/workspace-store.js +148 -20
- package/dist/workspaces.js +339 -111
- package/docs/chatgpt-coding-workflow.md +75 -12
- package/docs/configuration.md +47 -9
- package/docs/roadmap.md +23 -1
- package/package.json +2 -2
- package/scripts/debug/accept.mjs +60 -3
package/dist/workspace-store.js
CHANGED
|
@@ -1,13 +1,32 @@
|
|
|
1
1
|
import { and, desc, eq } from "drizzle-orm";
|
|
2
2
|
import { openDatabase } from "./db/client.js";
|
|
3
|
-
import { workspaceConversationBindings, workspaceSessions, } from "./db/schema.js";
|
|
3
|
+
import { workspaceContextDeliveries, workspaceConversationBindings, workspaceSessions, } from "./db/schema.js";
|
|
4
|
+
const DEFAULT_TOUCH_FLUSH_INTERVAL_MS = 5 * 60 * 1_000;
|
|
4
5
|
export class SqliteWorkspaceStore {
|
|
5
6
|
database;
|
|
6
|
-
|
|
7
|
+
now;
|
|
8
|
+
touchFlushTimer;
|
|
9
|
+
pendingSessionTouches = new Map();
|
|
10
|
+
pendingConversationTouches = new Map();
|
|
11
|
+
constructor(stateDir, options = {}) {
|
|
7
12
|
this.database = openDatabase(stateDir);
|
|
13
|
+
this.now = options.now ?? (() => new Date());
|
|
14
|
+
const touchFlushIntervalMs = options.touchFlushIntervalMs ?? DEFAULT_TOUCH_FLUSH_INTERVAL_MS;
|
|
15
|
+
if (!Number.isInteger(touchFlushIntervalMs) || touchFlushIntervalMs < 1) {
|
|
16
|
+
throw new Error("Workspace touch flush interval must be a positive integer.");
|
|
17
|
+
}
|
|
18
|
+
this.touchFlushTimer = setInterval(() => {
|
|
19
|
+
try {
|
|
20
|
+
this.flushTouches();
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
console.warn(`ForgeRelay workspace touch flush failed: ${errorMessage(error)}`);
|
|
24
|
+
}
|
|
25
|
+
}, touchFlushIntervalMs);
|
|
26
|
+
this.touchFlushTimer.unref();
|
|
8
27
|
}
|
|
9
28
|
createSession(input) {
|
|
10
|
-
const now =
|
|
29
|
+
const now = this.now().toISOString();
|
|
11
30
|
const session = {
|
|
12
31
|
id: input.id,
|
|
13
32
|
root: input.root,
|
|
@@ -47,19 +66,18 @@ export class SqliteWorkspaceStore {
|
|
|
47
66
|
.from(workspaceSessions)
|
|
48
67
|
.where(eq(workspaceSessions.id, id))
|
|
49
68
|
.get();
|
|
50
|
-
|
|
69
|
+
if (!row)
|
|
70
|
+
return undefined;
|
|
71
|
+
return applySessionTouch(rowToWorkspaceSession(row), this.pendingSessionTouches.get(id));
|
|
51
72
|
}
|
|
52
73
|
touchSession(id) {
|
|
53
|
-
this.
|
|
54
|
-
.update(workspaceSessions)
|
|
55
|
-
.set({ lastUsedAt: new Date().toISOString() })
|
|
56
|
-
.where(eq(workspaceSessions.id, id))
|
|
57
|
-
.run();
|
|
74
|
+
this.pendingSessionTouches.set(id, this.now().toISOString());
|
|
58
75
|
}
|
|
59
76
|
setSessionStatus(id, status) {
|
|
77
|
+
this.pendingSessionTouches.delete(id);
|
|
60
78
|
this.database.db
|
|
61
79
|
.update(workspaceSessions)
|
|
62
|
-
.set({ status, lastUsedAt:
|
|
80
|
+
.set({ status, lastUsedAt: this.now().toISOString() })
|
|
63
81
|
.where(eq(workspaceSessions.id, id))
|
|
64
82
|
.run();
|
|
65
83
|
}
|
|
@@ -77,9 +95,13 @@ export class SqliteWorkspaceStore {
|
|
|
77
95
|
: conditions.length === 1
|
|
78
96
|
? query.where(conditions[0]).all()
|
|
79
97
|
: query.where(and(...conditions)).all();
|
|
80
|
-
return rows
|
|
98
|
+
return rows
|
|
99
|
+
.map(rowToWorkspaceSession)
|
|
100
|
+
.map((session) => applySessionTouch(session, this.pendingSessionTouches.get(session.id)))
|
|
101
|
+
.sort((left, right) => right.lastUsedAt.localeCompare(left.lastUsedAt));
|
|
81
102
|
}
|
|
82
103
|
deleteSession(id) {
|
|
104
|
+
this.pendingSessionTouches.delete(id);
|
|
83
105
|
this.database.db
|
|
84
106
|
.delete(workspaceSessions)
|
|
85
107
|
.where(eq(workspaceSessions.id, id))
|
|
@@ -90,7 +112,8 @@ export class SqliteWorkspaceStore {
|
|
|
90
112
|
.select()
|
|
91
113
|
.from(workspaceConversationBindings)
|
|
92
114
|
.all()
|
|
93
|
-
.map(rowToWorkspaceConversationBinding)
|
|
115
|
+
.map(rowToWorkspaceConversationBinding)
|
|
116
|
+
.map((binding) => applyConversationTouch(binding, this.pendingConversationTouches.get(conversationTouchKey(binding.conversationScopeId, binding.targetKey))?.lastUsedAt));
|
|
94
117
|
}
|
|
95
118
|
getConversationBinding(conversationScopeId, targetKey) {
|
|
96
119
|
const row = this.database.db
|
|
@@ -98,10 +121,13 @@ export class SqliteWorkspaceStore {
|
|
|
98
121
|
.from(workspaceConversationBindings)
|
|
99
122
|
.where(and(eq(workspaceConversationBindings.conversationScopeId, conversationScopeId), eq(workspaceConversationBindings.targetKey, targetKey)))
|
|
100
123
|
.get();
|
|
101
|
-
|
|
124
|
+
if (!row)
|
|
125
|
+
return undefined;
|
|
126
|
+
const binding = rowToWorkspaceConversationBinding(row);
|
|
127
|
+
return applyConversationTouch(binding, this.pendingConversationTouches.get(conversationTouchKey(conversationScopeId, targetKey))?.lastUsedAt);
|
|
102
128
|
}
|
|
103
129
|
setConversationBinding(input) {
|
|
104
|
-
const now =
|
|
130
|
+
const now = this.now().toISOString();
|
|
105
131
|
const row = this.database.db
|
|
106
132
|
.insert(workspaceConversationBindings)
|
|
107
133
|
.values({
|
|
@@ -126,28 +152,122 @@ export class SqliteWorkspaceStore {
|
|
|
126
152
|
if (!row) {
|
|
127
153
|
throw new Error("Conversation workspace binding upsert returned no row.");
|
|
128
154
|
}
|
|
155
|
+
this.pendingConversationTouches.delete(conversationTouchKey(input.conversationScopeId, input.targetKey));
|
|
129
156
|
return rowToWorkspaceConversationBinding(row);
|
|
130
157
|
}
|
|
131
158
|
touchConversationBinding(conversationScopeId, targetKey) {
|
|
132
|
-
this.
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
.
|
|
136
|
-
|
|
159
|
+
this.pendingConversationTouches.set(conversationTouchKey(conversationScopeId, targetKey), {
|
|
160
|
+
conversationScopeId,
|
|
161
|
+
targetKey,
|
|
162
|
+
lastUsedAt: this.now().toISOString(),
|
|
163
|
+
});
|
|
137
164
|
}
|
|
138
165
|
deleteConversationBinding(conversationScopeId, targetKey) {
|
|
166
|
+
this.pendingConversationTouches.delete(conversationTouchKey(conversationScopeId, targetKey));
|
|
139
167
|
this.database.db
|
|
140
168
|
.delete(workspaceConversationBindings)
|
|
141
169
|
.where(and(eq(workspaceConversationBindings.conversationScopeId, conversationScopeId), eq(workspaceConversationBindings.targetKey, targetKey)))
|
|
142
170
|
.run();
|
|
143
171
|
}
|
|
172
|
+
listContextDeliveries() {
|
|
173
|
+
return this.database.db
|
|
174
|
+
.select()
|
|
175
|
+
.from(workspaceContextDeliveries)
|
|
176
|
+
.orderBy(desc(workspaceContextDeliveries.deliveredAt))
|
|
177
|
+
.all()
|
|
178
|
+
.map(rowToWorkspaceContextDelivery);
|
|
179
|
+
}
|
|
180
|
+
getContextDelivery(conversationScopeId, targetKey) {
|
|
181
|
+
const row = this.database.db
|
|
182
|
+
.select()
|
|
183
|
+
.from(workspaceContextDeliveries)
|
|
184
|
+
.where(and(eq(workspaceContextDeliveries.conversationScopeId, conversationScopeId), eq(workspaceContextDeliveries.targetKey, targetKey)))
|
|
185
|
+
.get();
|
|
186
|
+
return row ? rowToWorkspaceContextDelivery(row) : undefined;
|
|
187
|
+
}
|
|
188
|
+
setContextDelivery(input) {
|
|
189
|
+
const deliveredAt = new Date().toISOString();
|
|
190
|
+
const row = this.database.db
|
|
191
|
+
.insert(workspaceContextDeliveries)
|
|
192
|
+
.values({ ...input, deliveredAt })
|
|
193
|
+
.onConflictDoUpdate({
|
|
194
|
+
target: [
|
|
195
|
+
workspaceContextDeliveries.conversationScopeId,
|
|
196
|
+
workspaceContextDeliveries.targetKey,
|
|
197
|
+
],
|
|
198
|
+
set: {
|
|
199
|
+
contextFingerprint: input.contextFingerprint,
|
|
200
|
+
deliveredAt,
|
|
201
|
+
},
|
|
202
|
+
})
|
|
203
|
+
.returning()
|
|
204
|
+
.get();
|
|
205
|
+
if (!row)
|
|
206
|
+
throw new Error("Workspace context delivery upsert returned no row.");
|
|
207
|
+
return rowToWorkspaceContextDelivery(row);
|
|
208
|
+
}
|
|
209
|
+
deleteContextDelivery(conversationScopeId, targetKey) {
|
|
210
|
+
this.database.db
|
|
211
|
+
.delete(workspaceContextDeliveries)
|
|
212
|
+
.where(and(eq(workspaceContextDeliveries.conversationScopeId, conversationScopeId), eq(workspaceContextDeliveries.targetKey, targetKey)))
|
|
213
|
+
.run();
|
|
214
|
+
}
|
|
215
|
+
get pendingTouchCount() {
|
|
216
|
+
return this.pendingSessionTouches.size + this.pendingConversationTouches.size;
|
|
217
|
+
}
|
|
218
|
+
flushTouches() {
|
|
219
|
+
if (this.pendingTouchCount === 0)
|
|
220
|
+
return;
|
|
221
|
+
const sessionTouches = [...this.pendingSessionTouches.entries()];
|
|
222
|
+
const conversationTouches = [...this.pendingConversationTouches.values()];
|
|
223
|
+
const updateSession = this.database.sqlite.prepare("UPDATE workspace_sessions SET last_used_at = ? WHERE id = ?");
|
|
224
|
+
const updateConversation = this.database.sqlite.prepare("UPDATE workspace_conversation_bindings SET last_used_at = ? WHERE conversation_scope_id = ? AND target_key = ?");
|
|
225
|
+
const flush = this.database.sqlite.transaction(() => {
|
|
226
|
+
for (const [workspaceId, lastUsedAt] of sessionTouches) {
|
|
227
|
+
updateSession.run(lastUsedAt, workspaceId);
|
|
228
|
+
}
|
|
229
|
+
for (const touch of conversationTouches) {
|
|
230
|
+
updateConversation.run(touch.lastUsedAt, touch.conversationScopeId, touch.targetKey);
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
flush();
|
|
234
|
+
for (const [workspaceId, lastUsedAt] of sessionTouches) {
|
|
235
|
+
if (this.pendingSessionTouches.get(workspaceId) === lastUsedAt) {
|
|
236
|
+
this.pendingSessionTouches.delete(workspaceId);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
for (const touch of conversationTouches) {
|
|
240
|
+
const key = conversationTouchKey(touch.conversationScopeId, touch.targetKey);
|
|
241
|
+
if (this.pendingConversationTouches.get(key)?.lastUsedAt === touch.lastUsedAt) {
|
|
242
|
+
this.pendingConversationTouches.delete(key);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
144
246
|
close() {
|
|
145
|
-
this.
|
|
247
|
+
clearInterval(this.touchFlushTimer);
|
|
248
|
+
try {
|
|
249
|
+
this.flushTouches();
|
|
250
|
+
}
|
|
251
|
+
finally {
|
|
252
|
+
this.database.close();
|
|
253
|
+
}
|
|
146
254
|
}
|
|
147
255
|
}
|
|
148
256
|
export function createWorkspaceStore(stateDir) {
|
|
149
257
|
return new SqliteWorkspaceStore(stateDir);
|
|
150
258
|
}
|
|
259
|
+
function applySessionTouch(session, lastUsedAt) {
|
|
260
|
+
return lastUsedAt ? { ...session, lastUsedAt } : session;
|
|
261
|
+
}
|
|
262
|
+
function applyConversationTouch(binding, lastUsedAt) {
|
|
263
|
+
return lastUsedAt ? { ...binding, lastUsedAt } : binding;
|
|
264
|
+
}
|
|
265
|
+
function conversationTouchKey(conversationScopeId, targetKey) {
|
|
266
|
+
return JSON.stringify([conversationScopeId, targetKey]);
|
|
267
|
+
}
|
|
268
|
+
function errorMessage(error) {
|
|
269
|
+
return error instanceof Error ? error.message : String(error);
|
|
270
|
+
}
|
|
151
271
|
function rowToWorkspaceSession(row) {
|
|
152
272
|
return {
|
|
153
273
|
id: row.id,
|
|
@@ -173,3 +293,11 @@ function rowToWorkspaceConversationBinding(row) {
|
|
|
173
293
|
lastUsedAt: row.lastUsedAt,
|
|
174
294
|
};
|
|
175
295
|
}
|
|
296
|
+
function rowToWorkspaceContextDelivery(row) {
|
|
297
|
+
return {
|
|
298
|
+
conversationScopeId: row.conversationScopeId,
|
|
299
|
+
targetKey: row.targetKey,
|
|
300
|
+
contextFingerprint: row.contextFingerprint,
|
|
301
|
+
deliveredAt: row.deliveredAt,
|
|
302
|
+
};
|
|
303
|
+
}
|