@clinebot/core 0.0.14 → 0.0.16
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/dist/account/cline-account-service.d.ts +2 -1
- package/dist/account/index.d.ts +1 -1
- package/dist/account/types.d.ts +5 -0
- package/dist/index.node.d.ts +2 -0
- package/dist/index.node.js +178 -174
- package/dist/session/default-session-manager.d.ts +2 -1
- package/dist/session/file-session-service.d.ts +5 -0
- package/dist/session/session-host.d.ts +2 -1
- package/dist/storage/file-team-store.d.ts +27 -0
- package/dist/storage/sqlite-session-store.d.ts +1 -0
- package/dist/storage/team-store.d.ts +13 -0
- package/package.json +4 -4
- package/src/account/cline-account-service.ts +7 -0
- package/src/account/index.ts +1 -0
- package/src/account/types.ts +6 -0
- package/src/agents/agent-config-loader.test.ts +3 -3
- package/src/agents/hooks-config-loader.test.ts +20 -0
- package/src/agents/hooks-config-loader.ts +1 -0
- package/src/agents/user-instruction-config-loader.test.ts +6 -6
- package/src/index.node.ts +39 -0
- package/src/runtime/hook-file-hooks.test.ts +34 -0
- package/src/runtime/hook-file-hooks.ts +34 -4
- package/src/runtime/runtime-builder.team-persistence.test.ts +4 -5
- package/src/runtime/runtime-builder.ts +2 -3
- package/src/session/default-session-manager.ts +5 -1
- package/src/session/file-session-service.ts +280 -0
- package/src/session/session-host.test.ts +29 -0
- package/src/session/session-host.ts +17 -3
- package/src/session/session-team-coordination.ts +6 -5
- package/src/session/unified-session-persistence-service.test.ts +7 -3
- package/src/storage/file-team-store.ts +257 -0
- package/src/storage/sqlite-session-store.ts +5 -0
- package/src/storage/team-store.ts +35 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
renameSync,
|
|
6
|
+
writeFileSync,
|
|
7
|
+
} from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { resolveSessionDataDir } from "@clinebot/shared/storage";
|
|
10
|
+
import type { SessionRow } from "./session-service";
|
|
11
|
+
import type {
|
|
12
|
+
PersistedSessionUpdateInput,
|
|
13
|
+
SessionPersistenceAdapter,
|
|
14
|
+
} from "./unified-session-persistence-service";
|
|
15
|
+
import { UnifiedSessionPersistenceService } from "./unified-session-persistence-service";
|
|
16
|
+
|
|
17
|
+
interface FileSessionIndex {
|
|
18
|
+
version: 1;
|
|
19
|
+
sessions: Record<string, SessionRow>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface FileSpawnRequest {
|
|
23
|
+
id: number;
|
|
24
|
+
rootSessionId: string;
|
|
25
|
+
parentAgentId: string;
|
|
26
|
+
task?: string;
|
|
27
|
+
systemPrompt?: string;
|
|
28
|
+
createdAt: string;
|
|
29
|
+
consumedAt?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface FileSpawnQueue {
|
|
33
|
+
version: 1;
|
|
34
|
+
nextId: number;
|
|
35
|
+
requests: FileSpawnRequest[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function nowIso(): string {
|
|
39
|
+
return new Date().toISOString();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function atomicWriteJson(path: string, value: unknown): void {
|
|
43
|
+
const tempPath = `${path}.tmp`;
|
|
44
|
+
writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
45
|
+
renameSync(tempPath, path);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function defaultSessionsDir(): string {
|
|
49
|
+
return resolveSessionDataDir();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
class FileSessionPersistenceAdapter implements SessionPersistenceAdapter {
|
|
53
|
+
constructor(
|
|
54
|
+
private readonly sessionsDirPath: string = defaultSessionsDir(),
|
|
55
|
+
) {}
|
|
56
|
+
|
|
57
|
+
ensureSessionsDir(): string {
|
|
58
|
+
if (!existsSync(this.sessionsDirPath)) {
|
|
59
|
+
mkdirSync(this.sessionsDirPath, { recursive: true });
|
|
60
|
+
}
|
|
61
|
+
return this.sessionsDirPath;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private indexPath(): string {
|
|
65
|
+
return join(this.ensureSessionsDir(), "sessions.index.json");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private spawnQueuePath(): string {
|
|
69
|
+
return join(this.ensureSessionsDir(), "subagent-spawn-queue.json");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private readIndex(): FileSessionIndex {
|
|
73
|
+
const path = this.indexPath();
|
|
74
|
+
if (!existsSync(path)) {
|
|
75
|
+
return { version: 1, sessions: {} };
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as FileSessionIndex;
|
|
79
|
+
if (parsed?.version === 1 && parsed.sessions) {
|
|
80
|
+
return parsed;
|
|
81
|
+
}
|
|
82
|
+
} catch {
|
|
83
|
+
// Ignore invalid persistence and fall back to an empty index.
|
|
84
|
+
}
|
|
85
|
+
return { version: 1, sessions: {} };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private writeIndex(index: FileSessionIndex): void {
|
|
89
|
+
atomicWriteJson(this.indexPath(), index);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private readQueue(): FileSpawnQueue {
|
|
93
|
+
const path = this.spawnQueuePath();
|
|
94
|
+
if (!existsSync(path)) {
|
|
95
|
+
return { version: 1, nextId: 1, requests: [] };
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as FileSpawnQueue;
|
|
99
|
+
if (
|
|
100
|
+
parsed?.version === 1 &&
|
|
101
|
+
typeof parsed.nextId === "number" &&
|
|
102
|
+
Array.isArray(parsed.requests)
|
|
103
|
+
) {
|
|
104
|
+
return parsed;
|
|
105
|
+
}
|
|
106
|
+
} catch {
|
|
107
|
+
// Ignore invalid persistence and fall back to an empty queue.
|
|
108
|
+
}
|
|
109
|
+
return { version: 1, nextId: 1, requests: [] };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private writeQueue(queue: FileSpawnQueue): void {
|
|
113
|
+
atomicWriteJson(this.spawnQueuePath(), queue);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async upsertSession(row: SessionRow): Promise<void> {
|
|
117
|
+
const index = this.readIndex();
|
|
118
|
+
index.sessions[row.sessionId] = row;
|
|
119
|
+
this.writeIndex(index);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async getSession(sessionId: string): Promise<SessionRow | undefined> {
|
|
123
|
+
return this.readIndex().sessions[sessionId];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async listSessions(options: {
|
|
127
|
+
limit: number;
|
|
128
|
+
parentSessionId?: string;
|
|
129
|
+
status?: string;
|
|
130
|
+
}): Promise<SessionRow[]> {
|
|
131
|
+
return Object.values(this.readIndex().sessions)
|
|
132
|
+
.filter((row) =>
|
|
133
|
+
options.parentSessionId !== undefined
|
|
134
|
+
? row.parentSessionId === options.parentSessionId
|
|
135
|
+
: true,
|
|
136
|
+
)
|
|
137
|
+
.filter((row) =>
|
|
138
|
+
options.status !== undefined ? row.status === options.status : true,
|
|
139
|
+
)
|
|
140
|
+
.sort((a, b) => b.startedAt.localeCompare(a.startedAt))
|
|
141
|
+
.slice(0, options.limit);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async updateSession(
|
|
145
|
+
input: PersistedSessionUpdateInput,
|
|
146
|
+
): Promise<{ updated: boolean; statusLock: number }> {
|
|
147
|
+
const index = this.readIndex();
|
|
148
|
+
const existing = index.sessions[input.sessionId];
|
|
149
|
+
if (!existing) {
|
|
150
|
+
return { updated: false, statusLock: 0 };
|
|
151
|
+
}
|
|
152
|
+
if (
|
|
153
|
+
input.expectedStatusLock !== undefined &&
|
|
154
|
+
existing.statusLock !== input.expectedStatusLock
|
|
155
|
+
) {
|
|
156
|
+
return { updated: false, statusLock: existing.statusLock };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const nextStatusLock =
|
|
160
|
+
input.expectedStatusLock !== undefined
|
|
161
|
+
? input.expectedStatusLock + 1
|
|
162
|
+
: existing.statusLock;
|
|
163
|
+
const next: SessionRow = {
|
|
164
|
+
...existing,
|
|
165
|
+
status: input.status ?? existing.status,
|
|
166
|
+
endedAt:
|
|
167
|
+
input.endedAt !== undefined
|
|
168
|
+
? input.endedAt
|
|
169
|
+
: (existing.endedAt ?? null),
|
|
170
|
+
exitCode:
|
|
171
|
+
input.exitCode !== undefined
|
|
172
|
+
? input.exitCode
|
|
173
|
+
: (existing.exitCode ?? null),
|
|
174
|
+
prompt:
|
|
175
|
+
input.prompt !== undefined ? input.prompt : (existing.prompt ?? null),
|
|
176
|
+
metadata:
|
|
177
|
+
input.metadata !== undefined
|
|
178
|
+
? (input.metadata ?? null)
|
|
179
|
+
: (existing.metadata ?? null),
|
|
180
|
+
parentSessionId:
|
|
181
|
+
input.parentSessionId !== undefined
|
|
182
|
+
? (input.parentSessionId ?? null)
|
|
183
|
+
: (existing.parentSessionId ?? null),
|
|
184
|
+
parentAgentId:
|
|
185
|
+
input.parentAgentId !== undefined
|
|
186
|
+
? (input.parentAgentId ?? null)
|
|
187
|
+
: (existing.parentAgentId ?? null),
|
|
188
|
+
agentId:
|
|
189
|
+
input.agentId !== undefined
|
|
190
|
+
? (input.agentId ?? null)
|
|
191
|
+
: (existing.agentId ?? null),
|
|
192
|
+
conversationId:
|
|
193
|
+
input.conversationId !== undefined
|
|
194
|
+
? (input.conversationId ?? null)
|
|
195
|
+
: (existing.conversationId ?? null),
|
|
196
|
+
statusLock: nextStatusLock,
|
|
197
|
+
isSubagent:
|
|
198
|
+
input.setRunning || input.parentSessionId !== undefined
|
|
199
|
+
? true
|
|
200
|
+
: existing.isSubagent,
|
|
201
|
+
updatedAt: nowIso(),
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
if (input.setRunning) {
|
|
205
|
+
next.status = "running";
|
|
206
|
+
next.endedAt = null;
|
|
207
|
+
next.exitCode = null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
index.sessions[input.sessionId] = next;
|
|
211
|
+
this.writeIndex(index);
|
|
212
|
+
return { updated: true, statusLock: next.statusLock };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async deleteSession(sessionId: string, cascade: boolean): Promise<boolean> {
|
|
216
|
+
const index = this.readIndex();
|
|
217
|
+
const existing = index.sessions[sessionId];
|
|
218
|
+
if (!existing) {
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
delete index.sessions[sessionId];
|
|
222
|
+
if (cascade) {
|
|
223
|
+
for (const row of Object.values(index.sessions)) {
|
|
224
|
+
if (row.parentSessionId === sessionId) {
|
|
225
|
+
delete index.sessions[row.sessionId];
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
this.writeIndex(index);
|
|
230
|
+
return true;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async enqueueSpawnRequest(input: {
|
|
234
|
+
rootSessionId: string;
|
|
235
|
+
parentAgentId: string;
|
|
236
|
+
task?: string;
|
|
237
|
+
systemPrompt?: string;
|
|
238
|
+
}): Promise<void> {
|
|
239
|
+
const queue = this.readQueue();
|
|
240
|
+
queue.requests.push({
|
|
241
|
+
id: queue.nextId,
|
|
242
|
+
rootSessionId: input.rootSessionId,
|
|
243
|
+
parentAgentId: input.parentAgentId,
|
|
244
|
+
task: input.task,
|
|
245
|
+
systemPrompt: input.systemPrompt,
|
|
246
|
+
createdAt: nowIso(),
|
|
247
|
+
});
|
|
248
|
+
queue.nextId += 1;
|
|
249
|
+
this.writeQueue(queue);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async claimSpawnRequest(
|
|
253
|
+
rootSessionId: string,
|
|
254
|
+
parentAgentId: string,
|
|
255
|
+
): Promise<string | undefined> {
|
|
256
|
+
const queue = this.readQueue();
|
|
257
|
+
const request = queue.requests.find(
|
|
258
|
+
(item) =>
|
|
259
|
+
item.rootSessionId === rootSessionId &&
|
|
260
|
+
item.parentAgentId === parentAgentId &&
|
|
261
|
+
!item.consumedAt,
|
|
262
|
+
);
|
|
263
|
+
if (!request) {
|
|
264
|
+
return undefined;
|
|
265
|
+
}
|
|
266
|
+
request.consumedAt = nowIso();
|
|
267
|
+
this.writeQueue(queue);
|
|
268
|
+
return request.task;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export class FileSessionService extends UnifiedSessionPersistenceService {
|
|
273
|
+
constructor(sessionsDir?: string) {
|
|
274
|
+
super(new FileSessionPersistenceAdapter(sessionsDir));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
override ensureSessionsDir(): string {
|
|
278
|
+
return super.ensureSessionsDir();
|
|
279
|
+
}
|
|
280
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
vi.mock("@clinebot/rpc", () => ({
|
|
4
|
+
getRpcServerDefaultAddress: () => "ws://127.0.0.1:0",
|
|
5
|
+
getRpcServerHealth: vi.fn(async () => undefined),
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
describe("resolveSessionBackend", () => {
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
vi.resetModules();
|
|
11
|
+
vi.restoreAllMocks();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("falls back to file session storage when sqlite initialization fails", async () => {
|
|
15
|
+
vi.doMock("../storage/sqlite-session-store", () => ({
|
|
16
|
+
SqliteSessionStore: class {
|
|
17
|
+
init(): void {
|
|
18
|
+
throw new Error("sqlite unavailable");
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
const { resolveSessionBackend } = await import("./session-host");
|
|
24
|
+
const { FileSessionService } = await import("./file-session-service");
|
|
25
|
+
|
|
26
|
+
const backend = await resolveSessionBackend({ backendMode: "local" });
|
|
27
|
+
expect(backend).toBeInstanceOf(FileSessionService);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -13,6 +13,7 @@ import { nanoid } from "nanoid";
|
|
|
13
13
|
import { SqliteSessionStore } from "../storage/sqlite-session-store";
|
|
14
14
|
import type { ToolExecutors } from "../tools";
|
|
15
15
|
import { DefaultSessionManager } from "./default-session-manager";
|
|
16
|
+
import { FileSessionService } from "./file-session-service";
|
|
16
17
|
import { RpcCoreSessionService } from "./rpc-session-service";
|
|
17
18
|
import { tryAcquireRpcSpawnLease } from "./rpc-spawn-lease";
|
|
18
19
|
import type { SessionManager } from "./session-manager";
|
|
@@ -21,7 +22,10 @@ import { CoreSessionService } from "./session-service";
|
|
|
21
22
|
const DEFAULT_RPC_ADDRESS =
|
|
22
23
|
process.env.CLINE_RPC_ADDRESS?.trim() || getRpcServerDefaultAddress();
|
|
23
24
|
|
|
24
|
-
export type SessionBackend =
|
|
25
|
+
export type SessionBackend =
|
|
26
|
+
| RpcCoreSessionService
|
|
27
|
+
| CoreSessionService
|
|
28
|
+
| FileSessionService;
|
|
25
29
|
|
|
26
30
|
let cachedBackend: SessionBackend | undefined;
|
|
27
31
|
let backendInitPromise: Promise<SessionBackend> | undefined;
|
|
@@ -111,8 +115,18 @@ async function tryConnectRpcBackend(
|
|
|
111
115
|
}
|
|
112
116
|
}
|
|
113
117
|
|
|
114
|
-
function createLocalBackend():
|
|
115
|
-
|
|
118
|
+
function createLocalBackend(): SessionBackend {
|
|
119
|
+
try {
|
|
120
|
+
const store = new SqliteSessionStore();
|
|
121
|
+
store.init();
|
|
122
|
+
return new CoreSessionService(store);
|
|
123
|
+
} catch (error) {
|
|
124
|
+
console.warn(
|
|
125
|
+
"SQLite session persistence unavailable, falling back to file-based session storage.",
|
|
126
|
+
error,
|
|
127
|
+
);
|
|
128
|
+
return new FileSessionService();
|
|
129
|
+
}
|
|
116
130
|
}
|
|
117
131
|
|
|
118
132
|
function resolveHostDistinctId(explicitDistinctId: string | undefined): string {
|
|
@@ -163,11 +163,12 @@ export function shouldAutoContinueTeamRuns(
|
|
|
163
163
|
session: ActiveSession,
|
|
164
164
|
finishReason: AgentResult["finishReason"],
|
|
165
165
|
): boolean {
|
|
166
|
-
if (
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
166
|
+
if (session.aborting) {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
const canAutoContinue =
|
|
170
|
+
finishReason === "completed" || finishReason === "max_iterations";
|
|
171
|
+
if (!canAutoContinue) {
|
|
171
172
|
return false;
|
|
172
173
|
}
|
|
173
174
|
return (
|
|
@@ -8,8 +8,12 @@ import { CoreSessionService } from "./session-service";
|
|
|
8
8
|
|
|
9
9
|
describe("UnifiedSessionPersistenceService", () => {
|
|
10
10
|
const tempDirs: string[] = [];
|
|
11
|
+
const stores: Array<SqliteSessionStore> = [];
|
|
11
12
|
|
|
12
13
|
afterEach(() => {
|
|
14
|
+
for (const store of stores.splice(0)) {
|
|
15
|
+
store.close();
|
|
16
|
+
}
|
|
13
17
|
for (const dir of tempDirs.splice(0)) {
|
|
14
18
|
rmSync(dir, { recursive: true, force: true });
|
|
15
19
|
}
|
|
@@ -19,9 +23,9 @@ describe("UnifiedSessionPersistenceService", () => {
|
|
|
19
23
|
const sessionsDir = mkdtempSync(join(tmpdir(), "stale-session-reconcile-"));
|
|
20
24
|
tempDirs.push(sessionsDir);
|
|
21
25
|
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
);
|
|
26
|
+
const store = new SqliteSessionStore({ sessionsDir });
|
|
27
|
+
stores.push(store);
|
|
28
|
+
const service = new CoreSessionService(store);
|
|
25
29
|
const sessionId = "stale-root-session";
|
|
26
30
|
const artifacts = await service.createRootSessionWithArtifacts({
|
|
27
31
|
sessionId,
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import {
|
|
2
|
+
appendFileSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
renameSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import type {
|
|
12
|
+
TeamEvent,
|
|
13
|
+
TeamRuntimeState,
|
|
14
|
+
TeamTeammateSpec,
|
|
15
|
+
} from "@clinebot/agents";
|
|
16
|
+
import { resolveTeamDataDir } from "@clinebot/shared/storage";
|
|
17
|
+
import type { TeamStore } from "../types/storage";
|
|
18
|
+
|
|
19
|
+
function nowIso(): string {
|
|
20
|
+
return new Date().toISOString();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function sanitizeTeamName(name: string): string {
|
|
24
|
+
return name
|
|
25
|
+
.toLowerCase()
|
|
26
|
+
.replace(/[^a-z0-9._-]+/g, "-")
|
|
27
|
+
.replace(/^-+|-+$/g, "");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function reviveTeamRuntimeStateDates(
|
|
31
|
+
state: TeamRuntimeState,
|
|
32
|
+
): TeamRuntimeState {
|
|
33
|
+
return {
|
|
34
|
+
...state,
|
|
35
|
+
tasks: state.tasks.map((task) => ({
|
|
36
|
+
...task,
|
|
37
|
+
createdAt: new Date(task.createdAt),
|
|
38
|
+
updatedAt: new Date(task.updatedAt),
|
|
39
|
+
})),
|
|
40
|
+
mailbox: state.mailbox.map((message) => ({
|
|
41
|
+
...message,
|
|
42
|
+
sentAt: new Date(message.sentAt),
|
|
43
|
+
readAt: message.readAt ? new Date(message.readAt) : undefined,
|
|
44
|
+
})),
|
|
45
|
+
missionLog: state.missionLog.map((entry) => ({
|
|
46
|
+
...entry,
|
|
47
|
+
ts: new Date(entry.ts),
|
|
48
|
+
})),
|
|
49
|
+
runs: (state.runs ?? []).map((run) => ({
|
|
50
|
+
...run,
|
|
51
|
+
startedAt: new Date(run.startedAt),
|
|
52
|
+
endedAt: run.endedAt ? new Date(run.endedAt) : undefined,
|
|
53
|
+
nextAttemptAt: run.nextAttemptAt
|
|
54
|
+
? new Date(run.nextAttemptAt)
|
|
55
|
+
: undefined,
|
|
56
|
+
heartbeatAt: run.heartbeatAt ? new Date(run.heartbeatAt) : undefined,
|
|
57
|
+
})),
|
|
58
|
+
outcomes: (state.outcomes ?? []).map((outcome) => ({
|
|
59
|
+
...outcome,
|
|
60
|
+
createdAt: new Date(outcome.createdAt),
|
|
61
|
+
finalizedAt: outcome.finalizedAt
|
|
62
|
+
? new Date(outcome.finalizedAt)
|
|
63
|
+
: undefined,
|
|
64
|
+
})),
|
|
65
|
+
outcomeFragments: (state.outcomeFragments ?? []).map((fragment) => ({
|
|
66
|
+
...fragment,
|
|
67
|
+
createdAt: new Date(fragment.createdAt),
|
|
68
|
+
reviewedAt: fragment.reviewedAt
|
|
69
|
+
? new Date(fragment.reviewedAt)
|
|
70
|
+
: undefined,
|
|
71
|
+
})),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface PersistedTeamEnvelope {
|
|
76
|
+
version: 1;
|
|
77
|
+
updatedAt: string;
|
|
78
|
+
teamState: TeamRuntimeState;
|
|
79
|
+
teammates: TeamTeammateSpec[];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface FileTeamStoreOptions {
|
|
83
|
+
teamDir?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface TeamRuntimeLoadResult {
|
|
87
|
+
state?: TeamRuntimeState;
|
|
88
|
+
teammates: TeamTeammateSpec[];
|
|
89
|
+
interruptedRunIds: string[];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export class FileTeamStore implements TeamStore {
|
|
93
|
+
private readonly teamDirPath: string;
|
|
94
|
+
|
|
95
|
+
constructor(options: FileTeamStoreOptions = {}) {
|
|
96
|
+
this.teamDirPath = options.teamDir ?? resolveTeamDataDir();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
init(): void {
|
|
100
|
+
this.ensureTeamDir();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
listTeamNames(): string[] {
|
|
104
|
+
if (!existsSync(this.teamDirPath)) {
|
|
105
|
+
return [];
|
|
106
|
+
}
|
|
107
|
+
return readdirSync(this.teamDirPath, { withFileTypes: true })
|
|
108
|
+
.filter((entry) => entry.isDirectory())
|
|
109
|
+
.filter((entry) => existsSync(this.statePath(entry.name)))
|
|
110
|
+
.map((entry) => entry.name)
|
|
111
|
+
.sort();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
readState(teamName: string): TeamRuntimeState | undefined {
|
|
115
|
+
const envelope = this.readEnvelope(teamName);
|
|
116
|
+
return envelope?.teamState
|
|
117
|
+
? reviveTeamRuntimeStateDates(envelope.teamState)
|
|
118
|
+
: undefined;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
readHistory(teamName: string, limit = 200): unknown[] {
|
|
122
|
+
const historyPath = this.historyPath(teamName);
|
|
123
|
+
if (!existsSync(historyPath)) {
|
|
124
|
+
return [];
|
|
125
|
+
}
|
|
126
|
+
return readFileSync(historyPath, "utf8")
|
|
127
|
+
.split("\n")
|
|
128
|
+
.map((line) => line.trim())
|
|
129
|
+
.filter(Boolean)
|
|
130
|
+
.map((line) => {
|
|
131
|
+
try {
|
|
132
|
+
return JSON.parse(line) as unknown;
|
|
133
|
+
} catch {
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
})
|
|
137
|
+
.filter((item): item is unknown => item !== undefined)
|
|
138
|
+
.reverse()
|
|
139
|
+
.slice(0, limit);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
loadRuntime(teamName: string): TeamRuntimeLoadResult {
|
|
143
|
+
const envelope = this.readEnvelope(teamName);
|
|
144
|
+
const interruptedRunIds = this.markInProgressRunsInterrupted(
|
|
145
|
+
teamName,
|
|
146
|
+
"runtime_recovered",
|
|
147
|
+
);
|
|
148
|
+
return {
|
|
149
|
+
state: envelope?.teamState
|
|
150
|
+
? reviveTeamRuntimeStateDates(envelope.teamState)
|
|
151
|
+
: undefined,
|
|
152
|
+
teammates: envelope?.teammates ?? [],
|
|
153
|
+
interruptedRunIds,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
handleTeamEvent(teamName: string, event: TeamEvent): void {
|
|
158
|
+
this.ensureTeamSubdir(teamName);
|
|
159
|
+
appendFileSync(
|
|
160
|
+
this.historyPath(teamName),
|
|
161
|
+
`${JSON.stringify({ ts: nowIso(), eventType: event.type, payload: event })}\n`,
|
|
162
|
+
"utf8",
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
persistRuntime(
|
|
167
|
+
teamName: string,
|
|
168
|
+
state: TeamRuntimeState,
|
|
169
|
+
teammates: TeamTeammateSpec[],
|
|
170
|
+
): void {
|
|
171
|
+
this.ensureTeamSubdir(teamName);
|
|
172
|
+
const envelope: PersistedTeamEnvelope = {
|
|
173
|
+
version: 1,
|
|
174
|
+
updatedAt: nowIso(),
|
|
175
|
+
teamState: state,
|
|
176
|
+
teammates,
|
|
177
|
+
};
|
|
178
|
+
const path = this.statePath(teamName);
|
|
179
|
+
const tempPath = `${path}.tmp`;
|
|
180
|
+
writeFileSync(tempPath, `${JSON.stringify(envelope, null, 2)}\n`, "utf8");
|
|
181
|
+
renameSync(tempPath, path);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
markInProgressRunsInterrupted(teamName: string, reason: string): string[] {
|
|
185
|
+
const envelope = this.readEnvelope(teamName);
|
|
186
|
+
if (!envelope?.teamState?.runs?.length) {
|
|
187
|
+
return [];
|
|
188
|
+
}
|
|
189
|
+
const interrupted = envelope.teamState.runs
|
|
190
|
+
.filter((run) => run.status === "queued" || run.status === "running")
|
|
191
|
+
.map((run) => run.id);
|
|
192
|
+
if (interrupted.length === 0) {
|
|
193
|
+
return [];
|
|
194
|
+
}
|
|
195
|
+
const endedAt = new Date();
|
|
196
|
+
envelope.teamState = {
|
|
197
|
+
...envelope.teamState,
|
|
198
|
+
runs: envelope.teamState.runs.map((run) =>
|
|
199
|
+
run.status === "queued" || run.status === "running"
|
|
200
|
+
? {
|
|
201
|
+
...run,
|
|
202
|
+
status: "interrupted",
|
|
203
|
+
error: reason,
|
|
204
|
+
endedAt,
|
|
205
|
+
}
|
|
206
|
+
: run,
|
|
207
|
+
),
|
|
208
|
+
};
|
|
209
|
+
this.persistRuntime(teamName, envelope.teamState, envelope.teammates);
|
|
210
|
+
return interrupted;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private ensureTeamDir(): string {
|
|
214
|
+
if (!existsSync(this.teamDirPath)) {
|
|
215
|
+
mkdirSync(this.teamDirPath, { recursive: true });
|
|
216
|
+
}
|
|
217
|
+
return this.teamDirPath;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private ensureTeamSubdir(teamName: string): string {
|
|
221
|
+
const path = join(this.ensureTeamDir(), sanitizeTeamName(teamName));
|
|
222
|
+
if (!existsSync(path)) {
|
|
223
|
+
mkdirSync(path, { recursive: true });
|
|
224
|
+
}
|
|
225
|
+
return path;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
private statePath(teamName: string): string {
|
|
229
|
+
return join(this.ensureTeamDir(), sanitizeTeamName(teamName), "state.json");
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private historyPath(teamName: string): string {
|
|
233
|
+
return join(
|
|
234
|
+
this.ensureTeamDir(),
|
|
235
|
+
sanitizeTeamName(teamName),
|
|
236
|
+
"task-history.jsonl",
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private readEnvelope(teamName: string): PersistedTeamEnvelope | undefined {
|
|
241
|
+
const path = this.statePath(teamName);
|
|
242
|
+
if (!existsSync(path)) {
|
|
243
|
+
return undefined;
|
|
244
|
+
}
|
|
245
|
+
try {
|
|
246
|
+
const parsed = JSON.parse(
|
|
247
|
+
readFileSync(path, "utf8"),
|
|
248
|
+
) as PersistedTeamEnvelope;
|
|
249
|
+
if (parsed?.version === 1 && parsed.teamState) {
|
|
250
|
+
return parsed;
|
|
251
|
+
}
|
|
252
|
+
} catch {
|
|
253
|
+
// Ignore invalid persistence and fall back to undefined.
|
|
254
|
+
}
|
|
255
|
+
return undefined;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
@@ -57,6 +57,11 @@ export class SqliteSessionStore implements SessionStore {
|
|
|
57
57
|
return db;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
close(): void {
|
|
61
|
+
this.db?.close?.();
|
|
62
|
+
this.db = undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
60
65
|
run(sql: string, params: unknown[] = []): { changes?: number } {
|
|
61
66
|
return this.getRawDb()
|
|
62
67
|
.prepare(sql)
|
|
@@ -1,5 +1,40 @@
|
|
|
1
1
|
export type { TeamStore } from "../types/storage";
|
|
2
2
|
export {
|
|
3
|
+
FileTeamStore,
|
|
4
|
+
type FileTeamStoreOptions,
|
|
5
|
+
} from "./file-team-store";
|
|
6
|
+
export {
|
|
7
|
+
SqliteTeamStore,
|
|
8
|
+
type SqliteTeamStoreOptions,
|
|
9
|
+
} from "./sqlite-team-store";
|
|
10
|
+
|
|
11
|
+
import { FileTeamStore } from "./file-team-store";
|
|
12
|
+
import {
|
|
3
13
|
SqliteTeamStore,
|
|
4
14
|
type SqliteTeamStoreOptions,
|
|
5
15
|
} from "./sqlite-team-store";
|
|
16
|
+
|
|
17
|
+
export function createLocalTeamStore(options: SqliteTeamStoreOptions = {}): {
|
|
18
|
+
init(): void;
|
|
19
|
+
listTeamNames(): string[];
|
|
20
|
+
readState(teamName: string): ReturnType<FileTeamStore["readState"]>;
|
|
21
|
+
readHistory(teamName: string, limit?: number): unknown[];
|
|
22
|
+
loadRuntime(teamName: string): ReturnType<FileTeamStore["loadRuntime"]>;
|
|
23
|
+
handleTeamEvent: FileTeamStore["handleTeamEvent"];
|
|
24
|
+
persistRuntime: FileTeamStore["persistRuntime"];
|
|
25
|
+
markInProgressRunsInterrupted: FileTeamStore["markInProgressRunsInterrupted"];
|
|
26
|
+
} {
|
|
27
|
+
try {
|
|
28
|
+
const store = new SqliteTeamStore(options);
|
|
29
|
+
store.init();
|
|
30
|
+
return store;
|
|
31
|
+
} catch (error) {
|
|
32
|
+
console.warn(
|
|
33
|
+
"SQLite team persistence unavailable, falling back to file-based team storage.",
|
|
34
|
+
error,
|
|
35
|
+
);
|
|
36
|
+
const store = new FileTeamStore({ teamDir: options.teamDir });
|
|
37
|
+
store.init();
|
|
38
|
+
return store;
|
|
39
|
+
}
|
|
40
|
+
}
|