ocwatch 0.5.0 → 0.6.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.
- package/README.md +22 -3
- package/package.json +4 -4
- package/src/client/dist/assets/GraphView-BZV40eAE.css +1 -0
- package/src/client/dist/assets/GraphView-KWCCGYb2.js +9 -0
- package/src/client/dist/assets/graph-Cw_XSlvx.js +7 -0
- package/src/client/dist/assets/index-CbgYG3pJ.js +23 -0
- package/src/client/dist/assets/index-CgDCc8Mm.css +1 -0
- package/src/client/dist/assets/motion-CGUGF2CN.js +9 -0
- package/src/client/dist/index.html +4 -2
- package/src/server/index.ts +9 -24
- package/src/server/routes/poll.ts +6 -9
- package/src/server/routes/projects.ts +9 -26
- package/src/server/routes/sessions.ts +66 -14
- package/src/server/services/pollService.ts +153 -47
- package/src/server/services/recentSessions.ts +14 -0
- package/src/server/services/sessionContext.ts +97 -0
- package/src/server/services/sessionService.ts +15 -183
- package/src/server/services/sessionTree.ts +92 -0
- package/src/server/storage/db.ts +2 -10
- package/src/server/storage/index.ts +7 -1
- package/src/server/storage/queries.ts +208 -5
- package/src/server/utils/projectResolver.ts +9 -3
- package/src/server/utils/sessionStatus.ts +1 -19
- package/src/server/validation.ts +2 -4
- package/src/server/watcher.ts +41 -3
- package/src/shared/constants.ts +7 -3
- package/src/shared/index.ts +3 -0
- package/src/shared/types/index.ts +9 -48
- package/src/shared/utils/activityUtils.ts +3 -2
- package/src/client/dist/assets/index-BIu7r5_5.css +0 -1
- package/src/client/dist/assets/index-CzAaOuyw.js +0 -50
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
SessionMetadata,
|
|
3
|
+
TreeNode,
|
|
4
|
+
TreeEdge,
|
|
5
|
+
SessionTree,
|
|
6
|
+
} from "../../shared/types";
|
|
7
|
+
import { MAX_RECURSION_DEPTH } from "../../shared/constants";
|
|
8
|
+
import {
|
|
9
|
+
isAssistantFinished,
|
|
10
|
+
getSessionStatusInfo,
|
|
11
|
+
} from "../logic";
|
|
12
|
+
import {
|
|
13
|
+
createSessionContext,
|
|
14
|
+
getSessionFromContext,
|
|
15
|
+
getSessionMessages,
|
|
16
|
+
getSessionChildren,
|
|
17
|
+
} from "./sessionContext";
|
|
18
|
+
|
|
19
|
+
export async function buildSessionTree(
|
|
20
|
+
rootSessionID: string,
|
|
21
|
+
allSessions: SessionMetadata[]
|
|
22
|
+
): Promise<SessionTree> {
|
|
23
|
+
const nodes: TreeNode[] = [];
|
|
24
|
+
const edges: TreeEdge[] = [];
|
|
25
|
+
const visited = new Set<string>();
|
|
26
|
+
const context = createSessionContext(allSessions);
|
|
27
|
+
|
|
28
|
+
async function processSession(sessionID: string, depth = 0) {
|
|
29
|
+
if (depth > MAX_RECURSION_DEPTH) {
|
|
30
|
+
console.warn(`Max recursion depth reached for session ${sessionID}`);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (visited.has(sessionID)) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
visited.add(sessionID);
|
|
37
|
+
|
|
38
|
+
const session = getSessionFromContext(sessionID, context);
|
|
39
|
+
if (!session) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const messages = getSessionMessages(sessionID, context);
|
|
44
|
+
const lastAssistantFinished = isAssistantFinished(messages);
|
|
45
|
+
const isSubagent = !!session.parentID;
|
|
46
|
+
const status = getSessionStatusInfo(
|
|
47
|
+
messages,
|
|
48
|
+
false,
|
|
49
|
+
undefined,
|
|
50
|
+
undefined,
|
|
51
|
+
lastAssistantFinished,
|
|
52
|
+
isSubagent
|
|
53
|
+
).status;
|
|
54
|
+
|
|
55
|
+
const lastMessage = messages.sort(
|
|
56
|
+
(a, b) => b.createdAt.getTime() - a.createdAt.getTime()
|
|
57
|
+
)[0];
|
|
58
|
+
|
|
59
|
+
nodes.push({
|
|
60
|
+
id: session.id,
|
|
61
|
+
data: {
|
|
62
|
+
title: session.title,
|
|
63
|
+
agent: lastMessage?.agent,
|
|
64
|
+
model: lastMessage?.modelID,
|
|
65
|
+
isActive: status === "working" || status === "idle",
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
if (session.parentID) {
|
|
70
|
+
edges.push({
|
|
71
|
+
source: session.parentID,
|
|
72
|
+
target: session.id,
|
|
73
|
+
});
|
|
74
|
+
await processSession(session.parentID, depth + 1);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const children = getSessionChildren(sessionID, context);
|
|
78
|
+
await Promise.all(
|
|
79
|
+
children.map((child) => {
|
|
80
|
+
edges.push({
|
|
81
|
+
source: sessionID,
|
|
82
|
+
target: child.id,
|
|
83
|
+
});
|
|
84
|
+
return processSession(child.id, depth + 1);
|
|
85
|
+
})
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
await processSession(rootSessionID, 0);
|
|
90
|
+
|
|
91
|
+
return { nodes, edges };
|
|
92
|
+
}
|
package/src/server/storage/db.ts
CHANGED
|
@@ -22,15 +22,8 @@ function getDbPath(): string {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
function configureConnectionPragmas(db: Database): void {
|
|
25
|
-
db.query(
|
|
26
|
-
db.query(
|
|
27
|
-
|
|
28
|
-
try {
|
|
29
|
-
db.query("PRAGMA journal_mode = WAL;").run();
|
|
30
|
-
} catch (error) {
|
|
31
|
-
const reason = error instanceof Error ? error.message : String(error);
|
|
32
|
-
console.warn(`[storage/db] Failed to enforce WAL journal mode: ${reason}`);
|
|
33
|
-
}
|
|
25
|
+
db.query(`PRAGMA busy_timeout = ${SQLITE_BUSY_TIMEOUT_MS};`).run();
|
|
26
|
+
db.query(`PRAGMA cache_size = ${SQLITE_CACHE_SIZE};`).run();
|
|
34
27
|
}
|
|
35
28
|
|
|
36
29
|
export function checkDbExists(): boolean {
|
|
@@ -68,4 +61,3 @@ export function closeDb(): void {
|
|
|
68
61
|
dbSingleton.close();
|
|
69
62
|
dbSingleton = undefined;
|
|
70
63
|
}
|
|
71
|
-
|
|
@@ -2,20 +2,26 @@ export { checkDbExists, closeDb, getDb } from "./db";
|
|
|
2
2
|
export {
|
|
3
3
|
queryMaxTimestamp,
|
|
4
4
|
queryMessages,
|
|
5
|
+
queryMessagesForSessions,
|
|
5
6
|
queryPart,
|
|
6
7
|
queryParts,
|
|
8
|
+
queryPartsForSessions,
|
|
7
9
|
queryProjects,
|
|
10
|
+
queryProjectByWorktree,
|
|
11
|
+
queryProjectSummaries,
|
|
8
12
|
querySession,
|
|
9
13
|
querySessionChildren,
|
|
14
|
+
querySessionSubtree,
|
|
15
|
+
querySessionSubtreeRevision,
|
|
10
16
|
querySessions,
|
|
11
17
|
queryTodos,
|
|
12
18
|
listProjects,
|
|
13
|
-
listAllSessions,
|
|
14
19
|
} from "./queries";
|
|
15
20
|
export type {
|
|
16
21
|
DbMessageRow,
|
|
17
22
|
DbPartRow,
|
|
18
23
|
DbProjectRow,
|
|
24
|
+
DbProjectSummaryRow,
|
|
19
25
|
DbSessionRow,
|
|
20
26
|
DbTodoRow,
|
|
21
27
|
} from "./queries";
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
import type { Database, Statement } from "bun:sqlite";
|
|
2
2
|
import { getDb } from "./db";
|
|
3
|
-
import type { SessionMetadata } from "../../shared/types";
|
|
4
|
-
import { toSessionMetadata } from "../services/parsing";
|
|
5
3
|
|
|
6
4
|
export interface DbProjectRow {
|
|
7
5
|
id: string;
|
|
@@ -58,17 +56,28 @@ export interface DbTodoRow {
|
|
|
58
56
|
timeUpdated: number;
|
|
59
57
|
}
|
|
60
58
|
|
|
59
|
+
export interface DbProjectSummaryRow {
|
|
60
|
+
id: string;
|
|
61
|
+
worktree: string;
|
|
62
|
+
sessionCount: number;
|
|
63
|
+
lastActivityAt: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
61
66
|
let cachedDb: Database | null | undefined;
|
|
62
67
|
|
|
63
68
|
let queryProjectsStmt: Statement<DbProjectRow, []> | null = null;
|
|
64
69
|
let querySessionsStmt: Statement<DbSessionRow, [string | null, number | null, number]> | null = null;
|
|
65
70
|
let querySessionStmt: Statement<DbSessionRow, [string]> | null = null;
|
|
66
71
|
let querySessionChildrenStmt: Statement<DbSessionRow, [string]> | null = null;
|
|
72
|
+
let querySessionSubtreeStmt: Statement<DbSessionRow, [string]> | null = null;
|
|
67
73
|
let queryMessagesStmt: Statement<DbMessageRow, [string, number]> | null = null;
|
|
68
74
|
let queryPartsStmt: Statement<DbPartRow, [string]> | null = null;
|
|
69
75
|
let queryPartStmt: Statement<DbPartRow, [string]> | null = null;
|
|
70
76
|
let queryTodosStmt: Statement<DbTodoRow, [string]> | null = null;
|
|
71
77
|
let queryMaxTimestampStmt: Statement<{ maxTimestamp: number | null }, []> | null = null;
|
|
78
|
+
let querySessionSubtreeRevisionStmt: Statement<{ maxTimestamp: number | null }, [string]> | null = null;
|
|
79
|
+
let queryProjectByWorktreeStmt: Statement<DbProjectRow, [string]> | null = null;
|
|
80
|
+
let queryProjectSummariesStmt: Statement<DbProjectSummaryRow, []> | null = null;
|
|
72
81
|
|
|
73
82
|
function getReadyDb(): Database | null {
|
|
74
83
|
const db = getDb();
|
|
@@ -145,6 +154,50 @@ function getReadyDb(): Database | null {
|
|
|
145
154
|
ORDER BY time_created ASC
|
|
146
155
|
`);
|
|
147
156
|
|
|
157
|
+
querySessionSubtreeStmt = db.query<DbSessionRow, [string]>(`
|
|
158
|
+
WITH RECURSIVE subtree AS (
|
|
159
|
+
SELECT
|
|
160
|
+
id,
|
|
161
|
+
project_id AS projectID,
|
|
162
|
+
parent_id AS parentID,
|
|
163
|
+
slug,
|
|
164
|
+
directory,
|
|
165
|
+
title,
|
|
166
|
+
version,
|
|
167
|
+
time_created AS timeCreated,
|
|
168
|
+
time_updated AS timeUpdated
|
|
169
|
+
FROM session
|
|
170
|
+
WHERE id = ?1
|
|
171
|
+
|
|
172
|
+
UNION ALL
|
|
173
|
+
|
|
174
|
+
SELECT
|
|
175
|
+
s.id,
|
|
176
|
+
s.project_id AS projectID,
|
|
177
|
+
s.parent_id AS parentID,
|
|
178
|
+
s.slug,
|
|
179
|
+
s.directory,
|
|
180
|
+
s.title,
|
|
181
|
+
s.version,
|
|
182
|
+
s.time_created AS timeCreated,
|
|
183
|
+
s.time_updated AS timeUpdated
|
|
184
|
+
FROM session s
|
|
185
|
+
INNER JOIN subtree st ON s.parent_id = st.id
|
|
186
|
+
)
|
|
187
|
+
SELECT
|
|
188
|
+
id,
|
|
189
|
+
projectID,
|
|
190
|
+
parentID,
|
|
191
|
+
slug,
|
|
192
|
+
directory,
|
|
193
|
+
title,
|
|
194
|
+
version,
|
|
195
|
+
timeCreated,
|
|
196
|
+
timeUpdated
|
|
197
|
+
FROM subtree
|
|
198
|
+
ORDER BY timeCreated ASC, id ASC
|
|
199
|
+
`);
|
|
200
|
+
|
|
148
201
|
queryMessagesStmt = db.query<DbMessageRow, [string, number]>(`
|
|
149
202
|
SELECT
|
|
150
203
|
id,
|
|
@@ -225,6 +278,55 @@ function getReadyDb(): Database | null {
|
|
|
225
278
|
)
|
|
226
279
|
`);
|
|
227
280
|
|
|
281
|
+
querySessionSubtreeRevisionStmt = db.query<{ maxTimestamp: number | null }, [string]>(`
|
|
282
|
+
WITH RECURSIVE subtree AS (
|
|
283
|
+
SELECT id
|
|
284
|
+
FROM session
|
|
285
|
+
WHERE id = ?1
|
|
286
|
+
|
|
287
|
+
UNION ALL
|
|
288
|
+
|
|
289
|
+
SELECT s.id
|
|
290
|
+
FROM session s
|
|
291
|
+
INNER JOIN subtree st ON s.parent_id = st.id
|
|
292
|
+
)
|
|
293
|
+
SELECT MAX(ts) AS maxTimestamp
|
|
294
|
+
FROM (
|
|
295
|
+
SELECT MAX(time_updated) AS ts FROM session WHERE id IN (SELECT id FROM subtree)
|
|
296
|
+
UNION ALL
|
|
297
|
+
SELECT MAX(time_updated) AS ts FROM message WHERE session_id IN (SELECT id FROM subtree)
|
|
298
|
+
UNION ALL
|
|
299
|
+
SELECT MAX(time_updated) AS ts FROM part WHERE session_id IN (SELECT id FROM subtree)
|
|
300
|
+
)
|
|
301
|
+
`);
|
|
302
|
+
|
|
303
|
+
queryProjectByWorktreeStmt = db.query<DbProjectRow, [string]>(`
|
|
304
|
+
SELECT
|
|
305
|
+
id,
|
|
306
|
+
name,
|
|
307
|
+
worktree,
|
|
308
|
+
vcs,
|
|
309
|
+
commands,
|
|
310
|
+
sandboxes,
|
|
311
|
+
time_created AS timeCreated,
|
|
312
|
+
time_updated AS timeUpdated
|
|
313
|
+
FROM project
|
|
314
|
+
WHERE worktree = ?1
|
|
315
|
+
LIMIT 1
|
|
316
|
+
`);
|
|
317
|
+
|
|
318
|
+
queryProjectSummariesStmt = db.query<DbProjectSummaryRow, []>(`
|
|
319
|
+
SELECT
|
|
320
|
+
p.id,
|
|
321
|
+
p.worktree,
|
|
322
|
+
COUNT(s.id) AS sessionCount,
|
|
323
|
+
COALESCE(MAX(s.time_updated), p.time_updated) AS lastActivityAt
|
|
324
|
+
FROM project p
|
|
325
|
+
LEFT JOIN session s ON s.project_id = p.id
|
|
326
|
+
GROUP BY p.id
|
|
327
|
+
ORDER BY lastActivityAt DESC
|
|
328
|
+
`);
|
|
329
|
+
|
|
228
330
|
return db;
|
|
229
331
|
}
|
|
230
332
|
|
|
@@ -268,6 +370,15 @@ export function querySessionChildren(sessionId: string): DbSessionRow[] {
|
|
|
268
370
|
return querySessionChildrenStmt.all(sessionId);
|
|
269
371
|
}
|
|
270
372
|
|
|
373
|
+
export function querySessionSubtree(sessionId: string): DbSessionRow[] {
|
|
374
|
+
const db = getReadyDb();
|
|
375
|
+
if (!db || !querySessionSubtreeStmt) {
|
|
376
|
+
return [];
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return querySessionSubtreeStmt.all(sessionId);
|
|
380
|
+
}
|
|
381
|
+
|
|
271
382
|
export function queryMessages(sessionId: string, limit = 100): DbMessageRow[] {
|
|
272
383
|
const db = getReadyDb();
|
|
273
384
|
if (!db || !queryMessagesStmt) {
|
|
@@ -286,6 +397,76 @@ export function queryParts(sessionId: string): DbPartRow[] {
|
|
|
286
397
|
return queryPartsStmt.all(sessionId);
|
|
287
398
|
}
|
|
288
399
|
|
|
400
|
+
export function queryMessagesForSessions(sessionIds: string[], limitPerSession = 100): DbMessageRow[] {
|
|
401
|
+
const db = getReadyDb();
|
|
402
|
+
if (!db || sessionIds.length === 0) {
|
|
403
|
+
return [];
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const placeholders = sessionIds.map((_, index) => `?${index + 1}`).join(", ");
|
|
407
|
+
const limitParam = `?${sessionIds.length + 1}`;
|
|
408
|
+
const statement = db.query<DbMessageRow, (string | number)[]>(`
|
|
409
|
+
SELECT
|
|
410
|
+
id,
|
|
411
|
+
sessionID,
|
|
412
|
+
timeCreated,
|
|
413
|
+
timeUpdated,
|
|
414
|
+
role,
|
|
415
|
+
agent,
|
|
416
|
+
data
|
|
417
|
+
FROM (
|
|
418
|
+
SELECT
|
|
419
|
+
id,
|
|
420
|
+
session_id AS sessionID,
|
|
421
|
+
time_created AS timeCreated,
|
|
422
|
+
time_updated AS timeUpdated,
|
|
423
|
+
json_extract(data, '$.role') AS role,
|
|
424
|
+
json_extract(data, '$.agent') AS agent,
|
|
425
|
+
data,
|
|
426
|
+
ROW_NUMBER() OVER (
|
|
427
|
+
PARTITION BY session_id
|
|
428
|
+
ORDER BY time_created DESC, id DESC
|
|
429
|
+
) AS rowNumber
|
|
430
|
+
FROM message
|
|
431
|
+
WHERE session_id IN (${placeholders})
|
|
432
|
+
)
|
|
433
|
+
WHERE rowNumber <= ${limitParam}
|
|
434
|
+
ORDER BY sessionID ASC, timeCreated DESC, id DESC
|
|
435
|
+
`);
|
|
436
|
+
|
|
437
|
+
return statement.all(...sessionIds, limitPerSession);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export function queryPartsForSessions(sessionIds: string[]): DbPartRow[] {
|
|
441
|
+
const db = getReadyDb();
|
|
442
|
+
if (!db || sessionIds.length === 0) {
|
|
443
|
+
return [];
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const placeholders = sessionIds.map((_, index) => `?${index + 1}`).join(", ");
|
|
447
|
+
const statement = db.query<DbPartRow, string[]>(`
|
|
448
|
+
SELECT
|
|
449
|
+
id,
|
|
450
|
+
message_id AS messageID,
|
|
451
|
+
session_id AS sessionID,
|
|
452
|
+
time_created AS timeCreated,
|
|
453
|
+
time_updated AS timeUpdated,
|
|
454
|
+
json_extract(data, '$.type') AS type,
|
|
455
|
+
json_extract(data, '$.tool') AS tool,
|
|
456
|
+
CASE
|
|
457
|
+
WHEN json_type(data, '$.state') = 'text' THEN json_extract(data, '$.state')
|
|
458
|
+
WHEN json_type(data, '$.state.type') = 'text' THEN json_extract(data, '$.state.type')
|
|
459
|
+
ELSE NULL
|
|
460
|
+
END AS state,
|
|
461
|
+
data
|
|
462
|
+
FROM part
|
|
463
|
+
WHERE session_id IN (${placeholders})
|
|
464
|
+
ORDER BY sessionID ASC, timeCreated DESC, id DESC
|
|
465
|
+
`);
|
|
466
|
+
|
|
467
|
+
return statement.all(...sessionIds);
|
|
468
|
+
}
|
|
469
|
+
|
|
289
470
|
export function queryPart(partId: string): DbPartRow | null {
|
|
290
471
|
const db = getReadyDb();
|
|
291
472
|
if (!db || !queryPartStmt) {
|
|
@@ -313,13 +494,35 @@ export function queryMaxTimestamp(): number {
|
|
|
313
494
|
return Number(queryMaxTimestampStmt.get()?.maxTimestamp ?? 0);
|
|
314
495
|
}
|
|
315
496
|
|
|
497
|
+
export function querySessionSubtreeRevision(sessionId: string): number {
|
|
498
|
+
const db = getReadyDb();
|
|
499
|
+
if (!db || !querySessionSubtreeRevisionStmt) {
|
|
500
|
+
return 0;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
return Number(querySessionSubtreeRevisionStmt.get(sessionId)?.maxTimestamp ?? 0);
|
|
504
|
+
}
|
|
505
|
+
|
|
316
506
|
|
|
317
507
|
export function listProjects(): string[] {
|
|
318
508
|
const projects = queryProjects();
|
|
319
509
|
return projects.map((p) => p.id);
|
|
320
510
|
}
|
|
321
511
|
|
|
322
|
-
export function
|
|
323
|
-
const
|
|
324
|
-
|
|
512
|
+
export function queryProjectByWorktree(directory: string): DbProjectRow | null {
|
|
513
|
+
const db = getReadyDb();
|
|
514
|
+
if (!db || !queryProjectByWorktreeStmt) {
|
|
515
|
+
return null;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
return queryProjectByWorktreeStmt.get(directory);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
export function queryProjectSummaries(): DbProjectSummaryRow[] {
|
|
522
|
+
const db = getReadyDb();
|
|
523
|
+
if (!db || !queryProjectSummariesStmt) {
|
|
524
|
+
return [];
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
return queryProjectSummariesStmt.all();
|
|
325
528
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { stat } from "node:fs/promises";
|
|
2
|
-
import {
|
|
2
|
+
import { queryProjects } from "../storage";
|
|
3
3
|
import type { SessionMetadata } from "../../shared/types";
|
|
4
4
|
|
|
5
5
|
async function directoryExists(directory: string): Promise<boolean> {
|
|
@@ -15,8 +15,14 @@ export async function resolveProjectDirectory(
|
|
|
15
15
|
projectId: string,
|
|
16
16
|
preloadedSessions?: SessionMetadata[],
|
|
17
17
|
): Promise<string | null> {
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
let directory: string | undefined;
|
|
19
|
+
|
|
20
|
+
if (preloadedSessions) {
|
|
21
|
+
directory = preloadedSessions.find((session) => session.projectID === projectId)?.directory;
|
|
22
|
+
} else {
|
|
23
|
+
const projects = queryProjects();
|
|
24
|
+
directory = projects.find((p) => p.id === projectId)?.worktree;
|
|
25
|
+
}
|
|
20
26
|
|
|
21
27
|
if (!directory) {
|
|
22
28
|
return null;
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Determines session status based on message timestamps and tool call state
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import type {
|
|
6
|
+
import type { SessionStatus } from "../../shared/types";
|
|
7
7
|
import { isPendingToolCall } from "../logic/activityLogic";
|
|
8
8
|
import { getSessionStatusInfo } from "../logic/sessionLogic";
|
|
9
9
|
|
|
@@ -14,24 +14,6 @@ export type { WaitingReason, SessionStatusInfo } from "../logic/sessionLogic";
|
|
|
14
14
|
// Thresholds in milliseconds
|
|
15
15
|
const WORKING_THRESHOLD = 30 * 1000; // 30 seconds
|
|
16
16
|
const COMPLETED_THRESHOLD = 5 * 60 * 1000; // 5 minutes
|
|
17
|
-
export function getSessionStatus(
|
|
18
|
-
messages: MessageMeta[],
|
|
19
|
-
hasPendingToolCall: boolean = false,
|
|
20
|
-
lastToolCompletedAt?: Date,
|
|
21
|
-
workingChildCount?: number,
|
|
22
|
-
lastAssistantFinished?: boolean,
|
|
23
|
-
isSubagent: boolean = false
|
|
24
|
-
): SessionStatus {
|
|
25
|
-
return getSessionStatusInfo(
|
|
26
|
-
messages,
|
|
27
|
-
hasPendingToolCall,
|
|
28
|
-
lastToolCompletedAt,
|
|
29
|
-
workingChildCount,
|
|
30
|
-
lastAssistantFinished,
|
|
31
|
-
isSubagent
|
|
32
|
-
).status;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
17
|
/**
|
|
36
18
|
* Get status from timestamp directly (for simpler cases)
|
|
37
19
|
* Used when you only have the updatedAt timestamp
|
package/src/server/validation.ts
CHANGED
|
@@ -5,14 +5,12 @@ export const sessionIdSchema = z.string().regex(/^ses_[a-zA-Z0-9]+$/, 'Invalid s
|
|
|
5
5
|
|
|
6
6
|
export const partIdSchema = z.string().min(1, 'Part ID required');
|
|
7
7
|
|
|
8
|
+
export const projectIdSchema = z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid project ID format');
|
|
9
|
+
|
|
8
10
|
export const pollQuerySchema = z.object({
|
|
9
11
|
sessionId: sessionIdSchema.optional(),
|
|
10
12
|
});
|
|
11
13
|
|
|
12
|
-
export function validateParam<T>(schema: z.ZodSchema<T>, value: unknown): T {
|
|
13
|
-
return schema.parse(value);
|
|
14
|
-
}
|
|
15
|
-
|
|
16
14
|
export type ValidationResult<T> = { success: true; value: T } | { success: false; response: Response };
|
|
17
15
|
|
|
18
16
|
export function validateWithResponse<T>(
|
package/src/server/watcher.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, watch, type FSWatcher } from "node:fs";
|
|
1
|
+
import { existsSync, statSync, watch, type FSWatcher } from "node:fs";
|
|
2
2
|
import { basename, join } from "node:path";
|
|
3
3
|
import { EventEmitter } from "node:events";
|
|
4
4
|
import { homedir } from "node:os";
|
|
@@ -23,6 +23,7 @@ export class Watcher extends EventEmitter {
|
|
|
23
23
|
private readonly projectPath: string;
|
|
24
24
|
private dbWatcherTarget: string | null = null;
|
|
25
25
|
private boulderWatcherTarget: string | null = null;
|
|
26
|
+
private lastKnownBoulderSignature: string | null = null;
|
|
26
27
|
private isRunning: boolean = false;
|
|
27
28
|
|
|
28
29
|
constructor(options: WatcherOptions = {}) {
|
|
@@ -144,15 +145,24 @@ export class Watcher extends EventEmitter {
|
|
|
144
145
|
this.handleBoulderEvent(eventType, filename);
|
|
145
146
|
});
|
|
146
147
|
this.boulderWatcherTarget = preferredTarget;
|
|
148
|
+
this.lastKnownBoulderSignature = this.getBoulderSignature();
|
|
147
149
|
}
|
|
148
150
|
|
|
149
151
|
private handleBoulderEvent(eventType: string, filename: string | null): void {
|
|
152
|
+
const currentSignature = this.getBoulderSignature();
|
|
153
|
+
const signatureChanged = currentSignature !== this.lastKnownBoulderSignature;
|
|
154
|
+
|
|
150
155
|
if (this.boulderWatcherTarget === this.boulderDirPath) {
|
|
151
|
-
if (
|
|
156
|
+
if (filename && filename !== "boulder.json" && !signatureChanged) {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (!filename && !signatureChanged) {
|
|
152
161
|
return;
|
|
153
162
|
}
|
|
154
163
|
}
|
|
155
164
|
|
|
165
|
+
this.lastKnownBoulderSignature = currentSignature;
|
|
156
166
|
this.emitDebouncedChange(eventType, ".sisyphus/boulder.json");
|
|
157
167
|
|
|
158
168
|
if (eventType === "rename") {
|
|
@@ -168,6 +178,32 @@ export class Watcher extends EventEmitter {
|
|
|
168
178
|
}
|
|
169
179
|
}
|
|
170
180
|
|
|
181
|
+
private getBoulderSignature(): string | null {
|
|
182
|
+
try {
|
|
183
|
+
const stats = statSync(this.boulderPath);
|
|
184
|
+
return `${stats.size}:${stats.mtimeMs}`;
|
|
185
|
+
} catch {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private pollForBoulderChanges(): void {
|
|
191
|
+
const currentSignature = this.getBoulderSignature();
|
|
192
|
+
if (currentSignature === this.lastKnownBoulderSignature) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
this.lastKnownBoulderSignature = currentSignature;
|
|
197
|
+
this.emitDebouncedChange("change", ".sisyphus/boulder.json");
|
|
198
|
+
|
|
199
|
+
const preferredTarget = existsSync(this.boulderPath)
|
|
200
|
+
? this.boulderPath
|
|
201
|
+
: this.boulderDirPath;
|
|
202
|
+
if (preferredTarget !== this.boulderWatcherTarget) {
|
|
203
|
+
this.rebindBoulderWatcherSafe();
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
171
207
|
private startRebindLoop(): void {
|
|
172
208
|
if (this.rebindTimer) {
|
|
173
209
|
clearInterval(this.rebindTimer);
|
|
@@ -178,9 +214,10 @@ export class Watcher extends EventEmitter {
|
|
|
178
214
|
return;
|
|
179
215
|
}
|
|
180
216
|
|
|
217
|
+
this.pollForBoulderChanges();
|
|
181
218
|
this.rebindDbWatcherSafe();
|
|
182
219
|
this.rebindBoulderWatcherSafe();
|
|
183
|
-
},
|
|
220
|
+
}, 100);
|
|
184
221
|
}
|
|
185
222
|
|
|
186
223
|
private rebindDbWatcherSafe(): void {
|
|
@@ -226,6 +263,7 @@ export class Watcher extends EventEmitter {
|
|
|
226
263
|
this.boulderWatcherTarget = null;
|
|
227
264
|
}
|
|
228
265
|
|
|
266
|
+
this.lastKnownBoulderSignature = null;
|
|
229
267
|
this.isRunning = false;
|
|
230
268
|
this.emit("stopped");
|
|
231
269
|
}
|
package/src/shared/constants.ts
CHANGED
|
@@ -5,9 +5,6 @@
|
|
|
5
5
|
export const DEFAULT_PORT = 50234 as const;
|
|
6
6
|
export const API_BASE_URL = `http://localhost:${DEFAULT_PORT}`;
|
|
7
7
|
|
|
8
|
-
// Time constants
|
|
9
|
-
export const TWENTY_FOUR_HOURS_MS = 86400000 as const;
|
|
10
|
-
|
|
11
8
|
// API limits
|
|
12
9
|
export const MAX_SESSIONS_LIMIT = 20 as const;
|
|
13
10
|
/** Messages returned per session in API responses (client-facing limit) */
|
|
@@ -21,3 +18,10 @@ export const RINGBUFFER_CAPACITY = 1000 as const;
|
|
|
21
18
|
|
|
22
19
|
// Session hierarchy depth limit
|
|
23
20
|
export const MAX_RECURSION_DEPTH = 10 as const;
|
|
21
|
+
|
|
22
|
+
/** Internal upper bound for session queries (not client-facing) */
|
|
23
|
+
export const SESSION_SCAN_LIMIT = 50_000 as const;
|
|
24
|
+
/** Internal upper bound for message queries per session (not client-facing) */
|
|
25
|
+
export const MESSAGE_SCAN_LIMIT = 10_000 as const;
|
|
26
|
+
/** Cooldown between notifications for the same session (ms) */
|
|
27
|
+
export const NOTIFICATION_COOLDOWN_MS = 10_000 as const;
|
package/src/shared/index.ts
CHANGED
|
@@ -74,11 +74,13 @@ export interface MessageMeta {
|
|
|
74
74
|
* Includes agent info and hierarchy
|
|
75
75
|
*/
|
|
76
76
|
export type SessionActivityType = "tool" | "reasoning" | "patch" | "waiting-tools" | "waiting-user" | "idle";
|
|
77
|
+
export type ActivityNodeKind = "session" | "phase";
|
|
77
78
|
|
|
78
79
|
export interface ActivitySession {
|
|
79
80
|
id: string;
|
|
80
81
|
title: string;
|
|
81
82
|
agent: string;
|
|
83
|
+
nodeKind?: ActivityNodeKind;
|
|
82
84
|
modelID?: string;
|
|
83
85
|
providerID?: string;
|
|
84
86
|
parentID?: string;
|
|
@@ -126,17 +128,6 @@ export interface PartMeta {
|
|
|
126
128
|
patchFiles?: string[];
|
|
127
129
|
}
|
|
128
130
|
|
|
129
|
-
/**
|
|
130
|
-
* AgentInfo represents an active agent
|
|
131
|
-
*/
|
|
132
|
-
export interface AgentInfo {
|
|
133
|
-
name: string;
|
|
134
|
-
mode: string;
|
|
135
|
-
modelID: string;
|
|
136
|
-
active: boolean;
|
|
137
|
-
sessionID: string;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
131
|
/**
|
|
141
132
|
* ToolCall represents a tool invocation
|
|
142
133
|
*/
|
|
@@ -214,41 +205,6 @@ export type ActivityItem =
|
|
|
214
205
|
| AgentSpawnActivity
|
|
215
206
|
| AgentCompleteActivity;
|
|
216
207
|
|
|
217
|
-
/**
|
|
218
|
-
* BurstEntry represents a burst of tool call activity
|
|
219
|
-
* Groups consecutive tool calls from the same agent with aggregated metrics
|
|
220
|
-
*/
|
|
221
|
-
export interface BurstEntry {
|
|
222
|
-
id: string;
|
|
223
|
-
type: "burst";
|
|
224
|
-
agentName: string;
|
|
225
|
-
items: ToolCallActivity[];
|
|
226
|
-
toolBreakdown: Record<string, number>;
|
|
227
|
-
durationMs: number;
|
|
228
|
-
firstTimestamp: Date;
|
|
229
|
-
lastTimestamp: Date;
|
|
230
|
-
pendingCount: number;
|
|
231
|
-
errorCount: number;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/**
|
|
235
|
-
* MilestoneEntry represents a significant event in the activity stream
|
|
236
|
-
* (agent spawn or agent complete)
|
|
237
|
-
*/
|
|
238
|
-
export interface MilestoneEntry {
|
|
239
|
-
id: string;
|
|
240
|
-
type: "milestone";
|
|
241
|
-
item: AgentSpawnActivity | AgentCompleteActivity;
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
/**
|
|
245
|
-
* StreamEntry is a union type for activity stream entries
|
|
246
|
-
* Represents either a burst of tool calls or a milestone event
|
|
247
|
-
*/
|
|
248
|
-
export type StreamEntry = BurstEntry | MilestoneEntry;
|
|
249
|
-
|
|
250
|
-
export { synthesizeActivityItems } from '../utils/activityUtils';
|
|
251
|
-
|
|
252
208
|
/**
|
|
253
209
|
* PlanProgress represents progress on a plan
|
|
254
210
|
*/
|
|
@@ -284,6 +240,13 @@ export interface SessionStats {
|
|
|
284
240
|
modelBreakdown: ModelTokens[];
|
|
285
241
|
}
|
|
286
242
|
|
|
243
|
+
export interface SessionActivityResponse {
|
|
244
|
+
session: SessionSummary;
|
|
245
|
+
activity: ActivitySession[];
|
|
246
|
+
stats: SessionStats | null;
|
|
247
|
+
revision: number;
|
|
248
|
+
}
|
|
249
|
+
|
|
287
250
|
/**
|
|
288
251
|
* Boulder represents the current plan state
|
|
289
252
|
*/
|
|
@@ -359,5 +322,3 @@ export interface PollResponse {
|
|
|
359
322
|
planName?: string;
|
|
360
323
|
lastUpdate: number;
|
|
361
324
|
}
|
|
362
|
-
|
|
363
|
-
export { RingBuffer } from '../utils/RingBuffer';
|