ocwatch 0.4.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/__tests__/helpers/testDb.ts +220 -0
- package/src/server/index.ts +27 -27
- package/src/server/logic/activityLogic.ts +260 -0
- package/src/server/logic/index.ts +2 -0
- package/src/server/logic/sessionLogic.ts +107 -0
- package/src/server/routes/parts.ts +9 -7
- package/src/server/routes/poll.ts +32 -46
- package/src/server/routes/projects.ts +10 -27
- package/src/server/routes/sessions.ts +159 -68
- package/src/server/routes/sse.ts +10 -4
- package/src/server/services/parsing.ts +211 -0
- package/src/server/services/pollService.ts +400 -116
- package/src/server/services/recentSessions.ts +14 -0
- package/src/server/services/sessionContext.ts +97 -0
- package/src/server/services/sessionService.ts +97 -193
- package/src/server/services/sessionTree.ts +92 -0
- package/src/server/storage/db.ts +63 -0
- package/src/server/storage/index.ts +28 -0
- package/src/server/storage/queries.ts +528 -0
- package/src/server/utils/projectResolver.ts +9 -3
- package/src/server/utils/sessionStatus.ts +5 -89
- package/src/server/validation.ts +2 -4
- package/src/server/watcher.ts +225 -82
- package/src/shared/constants.ts +8 -3
- package/src/shared/index.ts +3 -0
- package/src/shared/types/index.ts +48 -53
- 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-BYMVif3u.js +0 -50
- package/src/server/storage/messageParser.ts +0 -169
- package/src/server/storage/partParser.ts +0 -532
- package/src/server/storage/sessionParser.ts +0 -180
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { AgentPhase, MessageMeta, SessionStatus } from "../../shared/types";
|
|
2
|
+
|
|
3
|
+
export type WaitingReason = "user" | "children";
|
|
4
|
+
|
|
5
|
+
export interface SessionStatusInfo {
|
|
6
|
+
status: SessionStatus;
|
|
7
|
+
waitingReason?: WaitingReason;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const WORKING_THRESHOLD = 30 * 1000;
|
|
11
|
+
const COMPLETED_THRESHOLD = 5 * 60 * 1000;
|
|
12
|
+
const GRACE_PERIOD = 5 * 1000;
|
|
13
|
+
|
|
14
|
+
export function isAssistantFinished(messages: MessageMeta[]): boolean {
|
|
15
|
+
if (messages.length === 0) {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const lastMessage = messages.reduce((latest, current) =>
|
|
20
|
+
current.createdAt.getTime() > latest.createdAt.getTime() ? current : latest
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
return lastMessage.role === "assistant" && lastMessage.finish === "stop";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function detectAgentPhases(messages: MessageMeta[]): AgentPhase[] {
|
|
27
|
+
const sorted = messages
|
|
28
|
+
.filter(m => m.role === "assistant" && m.agent)
|
|
29
|
+
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
|
|
30
|
+
|
|
31
|
+
if (sorted.length === 0) return [];
|
|
32
|
+
|
|
33
|
+
const phases: AgentPhase[] = [];
|
|
34
|
+
let currentPhase: AgentPhase | null = null;
|
|
35
|
+
|
|
36
|
+
for (const msg of sorted) {
|
|
37
|
+
if (!currentPhase || currentPhase.agent !== msg.agent) {
|
|
38
|
+
if (currentPhase) phases.push(currentPhase);
|
|
39
|
+
currentPhase = {
|
|
40
|
+
agent: msg.agent!,
|
|
41
|
+
startTime: msg.createdAt,
|
|
42
|
+
endTime: msg.createdAt,
|
|
43
|
+
tokens: msg.tokens || 0,
|
|
44
|
+
messageCount: 1,
|
|
45
|
+
};
|
|
46
|
+
} else {
|
|
47
|
+
currentPhase.endTime = msg.createdAt;
|
|
48
|
+
currentPhase.tokens += msg.tokens || 0;
|
|
49
|
+
currentPhase.messageCount++;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (currentPhase) phases.push(currentPhase);
|
|
53
|
+
|
|
54
|
+
return phases;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function getSessionStatusInfo(
|
|
58
|
+
messages: MessageMeta[],
|
|
59
|
+
hasPendingToolCall: boolean = false,
|
|
60
|
+
lastToolCompletedAt?: Date,
|
|
61
|
+
workingChildCount?: number,
|
|
62
|
+
lastAssistantFinished?: boolean,
|
|
63
|
+
isSubagent: boolean = false
|
|
64
|
+
): SessionStatusInfo {
|
|
65
|
+
if (hasPendingToolCall) {
|
|
66
|
+
return { status: "working" };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (workingChildCount && workingChildCount > 0) {
|
|
70
|
+
return { status: "waiting", waitingReason: "children" };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let timeSinceLastMessage = Infinity;
|
|
74
|
+
if (messages && messages.length > 0) {
|
|
75
|
+
const lastMessage = messages.reduce((latest, msg) =>
|
|
76
|
+
msg.createdAt.getTime() > latest.createdAt.getTime() ? msg : latest
|
|
77
|
+
);
|
|
78
|
+
timeSinceLastMessage = Date.now() - lastMessage.createdAt.getTime();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (lastAssistantFinished && timeSinceLastMessage < COMPLETED_THRESHOLD) {
|
|
82
|
+
if (isSubagent) {
|
|
83
|
+
return { status: "completed" };
|
|
84
|
+
}
|
|
85
|
+
return { status: "waiting", waitingReason: "user" };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (lastToolCompletedAt) {
|
|
89
|
+
const now = Date.now();
|
|
90
|
+
const timeSinceToolCompleted = now - lastToolCompletedAt.getTime();
|
|
91
|
+
if (timeSinceToolCompleted < GRACE_PERIOD) {
|
|
92
|
+
return { status: "working" };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!messages || messages.length === 0) {
|
|
97
|
+
return { status: "completed" };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (timeSinceLastMessage < WORKING_THRESHOLD) {
|
|
101
|
+
return { status: "working" };
|
|
102
|
+
} else if (timeSinceLastMessage < COMPLETED_THRESHOLD) {
|
|
103
|
+
return { status: "idle" };
|
|
104
|
+
} else {
|
|
105
|
+
return { status: "completed" };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -1,19 +1,21 @@
|
|
|
1
1
|
import type { Hono } from "hono";
|
|
2
|
-
import {
|
|
2
|
+
import { queryPart } from "../storage/queries";
|
|
3
|
+
import { toPartMeta } from "../services/parsing";
|
|
3
4
|
import { partIdSchema, validateWithResponse } from "../validation";
|
|
4
5
|
|
|
5
6
|
export function registerPartRoutes(app: Hono) {
|
|
6
|
-
app.get("/api/parts/:id",
|
|
7
|
+
app.get("/api/parts/:id", (c) => {
|
|
7
8
|
const validation = validateWithResponse(partIdSchema, c.req.param("id"), c);
|
|
8
9
|
if (!validation.success) return validation.response;
|
|
9
10
|
const partID = validation.value;
|
|
10
|
-
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
if (!
|
|
11
|
+
|
|
12
|
+
const row = queryPart(partID);
|
|
13
|
+
|
|
14
|
+
if (!row) {
|
|
14
15
|
return c.json({ error: "PART_NOT_FOUND", message: `Part '${partID}' not found`, status: 404 }, 404);
|
|
15
16
|
}
|
|
16
|
-
|
|
17
|
+
|
|
18
|
+
const part = toPartMeta(row);
|
|
17
19
|
return c.json(part);
|
|
18
20
|
});
|
|
19
21
|
}
|
|
@@ -1,54 +1,42 @@
|
|
|
1
1
|
import type { Hono } from "hono";
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
setPollCache,
|
|
2
|
+
import {
|
|
3
|
+
generateETag,
|
|
4
|
+
fetchPollData,
|
|
5
|
+
getPollCache,
|
|
6
|
+
setPollCache,
|
|
8
7
|
getPollCacheEpoch,
|
|
9
|
-
getPollInProgress,
|
|
8
|
+
getPollInProgress,
|
|
10
9
|
setPollInProgress,
|
|
11
|
-
getPollCacheTTL
|
|
10
|
+
getPollCacheTTL,
|
|
12
11
|
} from "../services/pollService";
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
const projectIdSchema = z.string().regex(/^[a-zA-Z0-9_-]+$/, "Invalid project ID format");
|
|
12
|
+
import { projectIdSchema, validateWithResponse } from "../validation";
|
|
16
13
|
|
|
17
14
|
export function registerPollRoute(app: Hono) {
|
|
18
15
|
app.get("/api/poll", async (c) => {
|
|
19
16
|
const clientETag = c.req.header("If-None-Match");
|
|
20
|
-
const rawSessionId = c.req.query('sessionId');
|
|
21
17
|
const rawProjectId = c.req.query("projectId");
|
|
22
|
-
|
|
23
|
-
let sessionId: string | undefined;
|
|
24
|
-
let projectId: string | undefined;
|
|
25
18
|
|
|
26
|
-
|
|
27
|
-
const validation = validateWithResponse(sessionIdSchema, rawSessionId, c);
|
|
28
|
-
if (!validation.success) return validation.response;
|
|
29
|
-
sessionId = validation.value;
|
|
30
|
-
}
|
|
19
|
+
let projectId: string | undefined;
|
|
31
20
|
|
|
32
21
|
if (rawProjectId) {
|
|
33
22
|
const validation = validateWithResponse(projectIdSchema, rawProjectId, c);
|
|
34
23
|
if (!validation.success) return validation.response;
|
|
35
24
|
projectId = validation.value;
|
|
36
25
|
}
|
|
37
|
-
|
|
26
|
+
|
|
38
27
|
const POLL_CACHE_TTL = getPollCacheTTL();
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
if (!sessionId && cached && Date.now() - cached.timestamp < POLL_CACHE_TTL) {
|
|
28
|
+
|
|
29
|
+
const cached = getPollCache(projectId);
|
|
30
|
+
if (cached && Date.now() - cached.timestamp < POLL_CACHE_TTL) {
|
|
43
31
|
if (clientETag === cached.etag) {
|
|
44
32
|
return new Response(null, { status: 304, headers: { ETag: cached.etag } });
|
|
45
33
|
}
|
|
46
34
|
c.header("ETag", cached.etag);
|
|
47
35
|
return c.json(cached.data);
|
|
48
36
|
}
|
|
49
|
-
|
|
50
|
-
const inProgress = getPollInProgress(
|
|
51
|
-
if (
|
|
37
|
+
|
|
38
|
+
const inProgress = getPollInProgress(projectId);
|
|
39
|
+
if (inProgress) {
|
|
52
40
|
try {
|
|
53
41
|
const data = await inProgress;
|
|
54
42
|
const etag = generateETag(data);
|
|
@@ -57,29 +45,27 @@ export function registerPollRoute(app: Hono) {
|
|
|
57
45
|
}
|
|
58
46
|
c.header("ETag", etag);
|
|
59
47
|
return c.json(data);
|
|
60
|
-
} catch {
|
|
61
|
-
|
|
48
|
+
} catch (err) {
|
|
49
|
+
console.warn("Poll request failed, retrying:", err instanceof Error ? err.message : err);
|
|
50
|
+
setPollInProgress(null, projectId);
|
|
62
51
|
}
|
|
63
52
|
}
|
|
64
|
-
|
|
53
|
+
|
|
54
|
+
const cacheEpochAtStart = getPollCacheEpoch();
|
|
55
|
+
const promise = fetchPollData(projectId);
|
|
56
|
+
setPollInProgress(promise, projectId);
|
|
57
|
+
|
|
65
58
|
let pollData: Awaited<ReturnType<typeof fetchPollData>>;
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
pollData = await promise;
|
|
72
|
-
const etag = generateETag(pollData);
|
|
73
|
-
if (cacheEpochAtStart === getPollCacheEpoch()) {
|
|
74
|
-
setPollCache({ data: pollData, etag, timestamp: Date.now() }, scopeProjectId);
|
|
75
|
-
}
|
|
76
|
-
} finally {
|
|
77
|
-
setPollInProgress(null, scopeProjectId);
|
|
59
|
+
try {
|
|
60
|
+
pollData = await promise;
|
|
61
|
+
const etag = generateETag(pollData);
|
|
62
|
+
if (cacheEpochAtStart === getPollCacheEpoch()) {
|
|
63
|
+
setPollCache({ data: pollData, etag, timestamp: Date.now() }, projectId);
|
|
78
64
|
}
|
|
79
|
-
}
|
|
80
|
-
|
|
65
|
+
} finally {
|
|
66
|
+
setPollInProgress(null, projectId);
|
|
81
67
|
}
|
|
82
|
-
|
|
68
|
+
|
|
83
69
|
const etag = generateETag(pollData);
|
|
84
70
|
if (clientETag === etag) {
|
|
85
71
|
return new Response(null, { status: 304, headers: { ETag: etag } });
|
|
@@ -1,34 +1,17 @@
|
|
|
1
1
|
import type { Hono } from "hono";
|
|
2
|
-
import {
|
|
2
|
+
import { queryProjectSummaries } from "../storage";
|
|
3
3
|
|
|
4
4
|
export function registerProjectRoutes(app: Hono) {
|
|
5
|
-
app.get("/api/projects",
|
|
6
|
-
const
|
|
7
|
-
const allSessions = await listAllSessions();
|
|
5
|
+
app.get("/api/projects", (c) => {
|
|
6
|
+
const summaries = queryProjectSummaries();
|
|
8
7
|
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
const projects = summaries.map((row) => ({
|
|
9
|
+
id: row.id,
|
|
10
|
+
directory: row.worktree,
|
|
11
|
+
sessionCount: row.sessionCount,
|
|
12
|
+
lastActivityAt: new Date(row.lastActivityAt),
|
|
13
|
+
}));
|
|
12
14
|
|
|
13
|
-
|
|
14
|
-
projectSessions.length > 0
|
|
15
|
-
? new Date(
|
|
16
|
-
Math.max(...projectSessions.map((s) => s.updatedAt.getTime()))
|
|
17
|
-
)
|
|
18
|
-
: new Date(0);
|
|
19
|
-
|
|
20
|
-
return {
|
|
21
|
-
id: projectID,
|
|
22
|
-
directory,
|
|
23
|
-
sessionCount: projectSessions.length,
|
|
24
|
-
lastActivityAt,
|
|
25
|
-
};
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
projectsWithDetails.sort(
|
|
29
|
-
(a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime()
|
|
30
|
-
);
|
|
31
|
-
|
|
32
|
-
return c.json(projectsWithDetails);
|
|
15
|
+
return c.json(projects);
|
|
33
16
|
});
|
|
34
17
|
}
|
|
@@ -1,118 +1,209 @@
|
|
|
1
1
|
import type { Hono } from "hono";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
2
|
+
import { checkDbExists } from "../storage";
|
|
3
|
+
import {
|
|
4
|
+
querySessions,
|
|
5
|
+
querySession,
|
|
6
|
+
queryMessages,
|
|
7
|
+
queryTodos,
|
|
8
|
+
} from "../storage/queries";
|
|
9
|
+
import type { DbSessionRow } from "../storage/queries";
|
|
10
|
+
import {
|
|
11
|
+
fetchSessionActivity,
|
|
12
|
+
fetchSessionDetail,
|
|
13
|
+
generateSessionActivityETag,
|
|
14
|
+
getPollCacheEpoch,
|
|
15
|
+
getSessionActivityCache,
|
|
16
|
+
getSessionActivityInProgress,
|
|
17
|
+
setSessionActivityCache,
|
|
18
|
+
setSessionActivityInProgress,
|
|
19
|
+
} from "../services/pollService";
|
|
20
|
+
import { toMessageMeta } from "../services/parsing";
|
|
21
|
+
import { selectRecentRootSessions } from "../services/recentSessions";
|
|
22
|
+
import { buildSessionTree } from "../services/sessionTree";
|
|
6
23
|
import { sessionIdSchema, validateWithResponse } from "../validation";
|
|
7
|
-
import { MAX_SESSIONS_LIMIT, MAX_MESSAGES_LIMIT,
|
|
24
|
+
import { MAX_SESSIONS_LIMIT, MAX_MESSAGES_LIMIT, SESSION_SCAN_LIMIT } from "../../shared/constants";
|
|
25
|
+
import type { SessionActivityResponse, SessionMetadata } from "../../shared/types";
|
|
26
|
+
|
|
27
|
+
function dbRowToSessionBase(row: DbSessionRow) {
|
|
28
|
+
return {
|
|
29
|
+
id: row.id,
|
|
30
|
+
projectID: row.projectID,
|
|
31
|
+
title: row.title,
|
|
32
|
+
parentID: row.parentID ?? undefined,
|
|
33
|
+
updatedAt: new Date(row.timeUpdated),
|
|
34
|
+
createdAt: new Date(row.timeCreated),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function dbRowToSessionMeta(row: DbSessionRow): SessionMetadata {
|
|
39
|
+
return {
|
|
40
|
+
id: row.id,
|
|
41
|
+
projectID: row.projectID,
|
|
42
|
+
directory: row.directory,
|
|
43
|
+
title: row.title,
|
|
44
|
+
parentID: row.parentID ?? undefined,
|
|
45
|
+
createdAt: new Date(row.timeCreated),
|
|
46
|
+
updatedAt: new Date(row.timeUpdated),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
8
49
|
|
|
9
50
|
export function registerSessionRoutes(app: Hono) {
|
|
10
|
-
app.get("/api/sessions",
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
return c.json({
|
|
51
|
+
app.get("/api/sessions", (c) => {
|
|
52
|
+
if (!checkDbExists()) {
|
|
53
|
+
return c.json({
|
|
14
54
|
error: "OpenCode storage not found",
|
|
15
55
|
message: "OpenCode storage directory does not exist. Please ensure OpenCode is installed.",
|
|
16
|
-
sessions: []
|
|
56
|
+
sessions: [],
|
|
17
57
|
}, 200);
|
|
18
58
|
}
|
|
19
59
|
|
|
20
|
-
const
|
|
60
|
+
const sessions = selectRecentRootSessions(
|
|
61
|
+
querySessions(undefined, undefined, SESSION_SCAN_LIMIT).map(dbRowToSessionBase),
|
|
62
|
+
MAX_SESSIONS_LIMIT,
|
|
63
|
+
);
|
|
21
64
|
|
|
22
|
-
|
|
23
|
-
|
|
65
|
+
return c.json(sessions);
|
|
66
|
+
});
|
|
24
67
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
);
|
|
68
|
+
app.get("/api/sessions/:id", async (c) => {
|
|
69
|
+
const validation = validateWithResponse(sessionIdSchema, c.req.param("id"), c);
|
|
70
|
+
if (!validation.success) return validation.response;
|
|
71
|
+
const sessionID = validation.value;
|
|
28
72
|
|
|
29
|
-
|
|
30
|
-
(
|
|
31
|
-
|
|
73
|
+
if (!checkDbExists()) {
|
|
74
|
+
return c.json({ error: "SESSION_NOT_FOUND", message: `Session '${sessionID}' not found`, status: 404 }, 404);
|
|
75
|
+
}
|
|
32
76
|
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
rootSessions.map(async (session) => {
|
|
38
|
-
const messages = await listMessages(session.id);
|
|
39
|
-
const lastAssistantFinished = isAssistantFinished(messages);
|
|
40
|
-
const status = getSessionStatus(messages, false, undefined, undefined, lastAssistantFinished);
|
|
41
|
-
|
|
42
|
-
return {
|
|
43
|
-
id: session.id,
|
|
44
|
-
title: session.title,
|
|
45
|
-
projectID: session.projectID,
|
|
46
|
-
parentID: session.parentID,
|
|
47
|
-
createdAt: session.createdAt,
|
|
48
|
-
updatedAt: session.updatedAt,
|
|
49
|
-
status,
|
|
50
|
-
isActive: status === "working" || status === "idle",
|
|
51
|
-
};
|
|
52
|
-
})
|
|
53
|
-
);
|
|
77
|
+
const sessionRow = querySession(sessionID);
|
|
78
|
+
if (!sessionRow) {
|
|
79
|
+
return c.json({ error: "SESSION_NOT_FOUND", message: `Session '${sessionID}' not found`, status: 404 }, 404);
|
|
80
|
+
}
|
|
54
81
|
|
|
55
|
-
|
|
82
|
+
const detail = await fetchSessionDetail(sessionID);
|
|
83
|
+
return c.json(detail);
|
|
56
84
|
});
|
|
57
85
|
|
|
58
|
-
app.get("/api/sessions/:id",
|
|
86
|
+
app.get("/api/sessions/:id/messages", (c) => {
|
|
59
87
|
const validation = validateWithResponse(sessionIdSchema, c.req.param("id"), c);
|
|
60
88
|
if (!validation.success) return validation.response;
|
|
61
89
|
const sessionID = validation.value;
|
|
62
90
|
|
|
63
|
-
|
|
64
|
-
|
|
91
|
+
if (!checkDbExists()) {
|
|
92
|
+
return c.json({ error: "SESSION_NOT_FOUND", message: `Session '${sessionID}' not found`, status: 404 }, 404);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const sessionRow = querySession(sessionID);
|
|
96
|
+
if (!sessionRow) {
|
|
97
|
+
return c.json({ error: "SESSION_NOT_FOUND", message: `Session '${sessionID}' not found`, status: 404 }, 404);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const messages = queryMessages(sessionID, MAX_MESSAGES_LIMIT).map(toMessageMeta);
|
|
101
|
+
return c.json(messages);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
app.get("/api/sessions/:id/tree", async (c) => {
|
|
105
|
+
const validation = validateWithResponse(sessionIdSchema, c.req.param("id"), c);
|
|
106
|
+
if (!validation.success) return validation.response;
|
|
107
|
+
const sessionID = validation.value;
|
|
65
108
|
|
|
66
|
-
if (!
|
|
109
|
+
if (!checkDbExists()) {
|
|
67
110
|
return c.json({ error: "SESSION_NOT_FOUND", message: `Session '${sessionID}' not found`, status: 404 }, 404);
|
|
68
111
|
}
|
|
69
112
|
|
|
70
|
-
const
|
|
71
|
-
|
|
113
|
+
const sessionRow = querySession(sessionID);
|
|
114
|
+
if (!sessionRow) {
|
|
115
|
+
return c.json({ error: "SESSION_NOT_FOUND", message: `Session '${sessionID}' not found`, status: 404 }, 404);
|
|
116
|
+
}
|
|
72
117
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
});
|
|
118
|
+
const allSessions = querySessions(undefined, undefined, SESSION_SCAN_LIMIT).map(dbRowToSessionMeta);
|
|
119
|
+
const tree = await buildSessionTree(sessionID, allSessions);
|
|
120
|
+
return c.json(tree);
|
|
77
121
|
});
|
|
78
122
|
|
|
79
|
-
app.get("/api/sessions/:id/
|
|
123
|
+
app.get("/api/sessions/:id/activity", async (c) => {
|
|
80
124
|
const validation = validateWithResponse(sessionIdSchema, c.req.param("id"), c);
|
|
81
125
|
if (!validation.success) return validation.response;
|
|
82
126
|
const sessionID = validation.value;
|
|
127
|
+
const clientETag = c.req.header("If-None-Match");
|
|
83
128
|
|
|
84
|
-
|
|
85
|
-
|
|
129
|
+
if (!checkDbExists()) {
|
|
130
|
+
return c.json({ error: "SESSION_NOT_FOUND", message: `Session '${sessionID}' not found`, status: 404 }, 404);
|
|
131
|
+
}
|
|
86
132
|
|
|
87
|
-
|
|
133
|
+
const sessionRow = querySession(sessionID);
|
|
134
|
+
if (!sessionRow) {
|
|
88
135
|
return c.json({ error: "SESSION_NOT_FOUND", message: `Session '${sessionID}' not found`, status: 404 }, 404);
|
|
89
136
|
}
|
|
90
137
|
|
|
91
|
-
const
|
|
138
|
+
const cached = getSessionActivityCache(sessionID);
|
|
139
|
+
if (cached) {
|
|
140
|
+
if (clientETag === cached.etag) {
|
|
141
|
+
return new Response(null, { status: 304, headers: { ETag: cached.etag } });
|
|
142
|
+
}
|
|
143
|
+
c.header("ETag", cached.etag);
|
|
144
|
+
return c.json(cached.data);
|
|
145
|
+
}
|
|
92
146
|
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
147
|
+
const inProgress = getSessionActivityInProgress(sessionID);
|
|
148
|
+
if (inProgress) {
|
|
149
|
+
try {
|
|
150
|
+
const data = await inProgress;
|
|
151
|
+
const etag = generateSessionActivityETag(data);
|
|
152
|
+
if (clientETag === etag) {
|
|
153
|
+
return new Response(null, { status: 304, headers: { ETag: etag } });
|
|
154
|
+
}
|
|
155
|
+
c.header("ETag", etag);
|
|
156
|
+
return c.json(data);
|
|
157
|
+
} catch (err) {
|
|
158
|
+
console.warn("Session activity request failed, retrying:", err instanceof Error ? err.message : err);
|
|
159
|
+
setSessionActivityInProgress(sessionID, null);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
96
162
|
|
|
97
|
-
const
|
|
163
|
+
const cacheEpochAtStart = getPollCacheEpoch();
|
|
164
|
+
const promise = fetchSessionActivity(sessionID);
|
|
165
|
+
setSessionActivityInProgress(sessionID, promise);
|
|
166
|
+
|
|
167
|
+
let data: SessionActivityResponse;
|
|
168
|
+
try {
|
|
169
|
+
data = await promise;
|
|
170
|
+
const etag = generateSessionActivityETag(data);
|
|
171
|
+
if (cacheEpochAtStart === getPollCacheEpoch()) {
|
|
172
|
+
setSessionActivityCache(sessionID, { data, etag });
|
|
173
|
+
}
|
|
174
|
+
} finally {
|
|
175
|
+
setSessionActivityInProgress(sessionID, null);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const etag = generateSessionActivityETag(data);
|
|
179
|
+
if (clientETag === etag) {
|
|
180
|
+
return new Response(null, { status: 304, headers: { ETag: etag } });
|
|
181
|
+
}
|
|
98
182
|
|
|
99
|
-
|
|
183
|
+
c.header("ETag", etag);
|
|
184
|
+
return c.json(data);
|
|
100
185
|
});
|
|
101
186
|
|
|
102
|
-
app.get("/api/sessions/:id/
|
|
187
|
+
app.get("/api/sessions/:id/todos", (c) => {
|
|
103
188
|
const validation = validateWithResponse(sessionIdSchema, c.req.param("id"), c);
|
|
104
189
|
if (!validation.success) return validation.response;
|
|
105
190
|
const sessionID = validation.value;
|
|
106
191
|
|
|
107
|
-
|
|
108
|
-
|
|
192
|
+
if (!checkDbExists()) {
|
|
193
|
+
return c.json([], 200);
|
|
194
|
+
}
|
|
109
195
|
|
|
110
|
-
|
|
196
|
+
const sessionRow = querySession(sessionID);
|
|
197
|
+
if (!sessionRow) {
|
|
111
198
|
return c.json({ error: "SESSION_NOT_FOUND", message: `Session '${sessionID}' not found`, status: 404 }, 404);
|
|
112
199
|
}
|
|
113
200
|
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
201
|
+
const todos = queryTodos(sessionID).map((row) => ({
|
|
202
|
+
content: row.content,
|
|
203
|
+
status: row.status,
|
|
204
|
+
priority: row.priority,
|
|
205
|
+
position: row.position,
|
|
206
|
+
}));
|
|
207
|
+
return c.json(todos);
|
|
117
208
|
});
|
|
118
209
|
}
|
package/src/server/routes/sse.ts
CHANGED
|
@@ -47,8 +47,11 @@ export function registerSSERoute(app: Hono) {
|
|
|
47
47
|
data: JSON.stringify({ timestamp: Date.now() }),
|
|
48
48
|
event: "heartbeat",
|
|
49
49
|
});
|
|
50
|
-
} catch {
|
|
51
|
-
//
|
|
50
|
+
} catch (error) {
|
|
51
|
+
// Stream closed — expected during disconnect
|
|
52
|
+
if (error instanceof Error && error.message !== 'The stream has been aborted') {
|
|
53
|
+
console.debug('[sse] Heartbeat write failed:', error.message);
|
|
54
|
+
}
|
|
52
55
|
}
|
|
53
56
|
}, 30000);
|
|
54
57
|
|
|
@@ -72,8 +75,11 @@ export function registerSSERoute(app: Hono) {
|
|
|
72
75
|
}),
|
|
73
76
|
event: eventType,
|
|
74
77
|
});
|
|
75
|
-
} catch {
|
|
76
|
-
//
|
|
78
|
+
} catch (error) {
|
|
79
|
+
// Stream closed — expected during disconnect
|
|
80
|
+
if (error instanceof Error && error.message !== 'The stream has been aborted') {
|
|
81
|
+
console.debug('[sse] Change event write failed:', error.message);
|
|
82
|
+
}
|
|
77
83
|
}
|
|
78
84
|
};
|
|
79
85
|
|