ocwatch 0.4.0 → 0.5.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/package.json +1 -1
- package/src/client/dist/assets/{index-BYMVif3u.js → index-CzAaOuyw.js} +14 -14
- package/src/client/dist/index.html +1 -1
- package/src/server/__tests__/helpers/testDb.ts +220 -0
- package/src/server/index.ts +20 -5
- 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 +34 -45
- package/src/server/routes/projects.ts +4 -4
- package/src/server/routes/sessions.ts +107 -68
- package/src/server/routes/sse.ts +10 -4
- package/src/server/services/parsing.ts +211 -0
- package/src/server/services/pollService.ts +292 -114
- package/src/server/services/sessionService.ts +178 -106
- package/src/server/storage/db.ts +71 -0
- package/src/server/storage/index.ts +22 -0
- package/src/server/storage/queries.ts +325 -0
- package/src/server/utils/projectResolver.ts +2 -2
- package/src/server/utils/sessionStatus.ts +4 -70
- package/src/server/watcher.ts +187 -82
- package/src/shared/constants.ts +1 -0
- package/src/shared/types/index.ts +39 -5
- package/src/server/storage/messageParser.ts +0 -169
- package/src/server/storage/partParser.ts +0 -532
- package/src/server/storage/sessionParser.ts +0 -180
|
@@ -1,54 +1,46 @@
|
|
|
1
1
|
import type { Hono } from "hono";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import {
|
|
4
|
-
generateETag,
|
|
5
|
-
fetchPollData,
|
|
6
|
-
getPollCache,
|
|
7
|
-
setPollCache,
|
|
3
|
+
import {
|
|
4
|
+
generateETag,
|
|
5
|
+
fetchPollData,
|
|
6
|
+
getPollCache,
|
|
7
|
+
setPollCache,
|
|
8
8
|
getPollCacheEpoch,
|
|
9
|
-
getPollInProgress,
|
|
9
|
+
getPollInProgress,
|
|
10
10
|
setPollInProgress,
|
|
11
|
-
getPollCacheTTL
|
|
11
|
+
getPollCacheTTL,
|
|
12
12
|
} from "../services/pollService";
|
|
13
|
-
import { sessionIdSchema, validateWithResponse } from "../validation";
|
|
14
13
|
|
|
15
14
|
const projectIdSchema = z.string().regex(/^[a-zA-Z0-9_-]+$/, "Invalid project ID format");
|
|
16
15
|
|
|
17
16
|
export function registerPollRoute(app: Hono) {
|
|
18
17
|
app.get("/api/poll", async (c) => {
|
|
19
18
|
const clientETag = c.req.header("If-None-Match");
|
|
20
|
-
const rawSessionId = c.req.query('sessionId');
|
|
21
19
|
const rawProjectId = c.req.query("projectId");
|
|
22
|
-
|
|
23
|
-
let sessionId: string | undefined;
|
|
24
|
-
let projectId: string | undefined;
|
|
25
20
|
|
|
26
|
-
|
|
27
|
-
const validation = validateWithResponse(sessionIdSchema, rawSessionId, c);
|
|
28
|
-
if (!validation.success) return validation.response;
|
|
29
|
-
sessionId = validation.value;
|
|
30
|
-
}
|
|
21
|
+
let projectId: string | undefined;
|
|
31
22
|
|
|
32
23
|
if (rawProjectId) {
|
|
33
|
-
const
|
|
34
|
-
if (!
|
|
35
|
-
|
|
24
|
+
const result = projectIdSchema.safeParse(rawProjectId);
|
|
25
|
+
if (!result.success) {
|
|
26
|
+
return c.json({ error: "INVALID_PROJECT_ID", message: "Invalid project ID format" }, 400);
|
|
27
|
+
}
|
|
28
|
+
projectId = result.data;
|
|
36
29
|
}
|
|
37
|
-
|
|
30
|
+
|
|
38
31
|
const POLL_CACHE_TTL = getPollCacheTTL();
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
if (!sessionId && cached && Date.now() - cached.timestamp < POLL_CACHE_TTL) {
|
|
32
|
+
|
|
33
|
+
const cached = getPollCache(projectId);
|
|
34
|
+
if (cached && Date.now() - cached.timestamp < POLL_CACHE_TTL) {
|
|
43
35
|
if (clientETag === cached.etag) {
|
|
44
36
|
return new Response(null, { status: 304, headers: { ETag: cached.etag } });
|
|
45
37
|
}
|
|
46
38
|
c.header("ETag", cached.etag);
|
|
47
39
|
return c.json(cached.data);
|
|
48
40
|
}
|
|
49
|
-
|
|
50
|
-
const inProgress = getPollInProgress(
|
|
51
|
-
if (
|
|
41
|
+
|
|
42
|
+
const inProgress = getPollInProgress(projectId);
|
|
43
|
+
if (inProgress) {
|
|
52
44
|
try {
|
|
53
45
|
const data = await inProgress;
|
|
54
46
|
const etag = generateETag(data);
|
|
@@ -58,28 +50,25 @@ export function registerPollRoute(app: Hono) {
|
|
|
58
50
|
c.header("ETag", etag);
|
|
59
51
|
return c.json(data);
|
|
60
52
|
} catch {
|
|
61
|
-
setPollInProgress(null,
|
|
53
|
+
setPollInProgress(null, projectId);
|
|
62
54
|
}
|
|
63
55
|
}
|
|
64
|
-
|
|
56
|
+
|
|
57
|
+
const cacheEpochAtStart = getPollCacheEpoch();
|
|
58
|
+
const promise = fetchPollData(projectId);
|
|
59
|
+
setPollInProgress(promise, projectId);
|
|
60
|
+
|
|
65
61
|
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);
|
|
62
|
+
try {
|
|
63
|
+
pollData = await promise;
|
|
64
|
+
const etag = generateETag(pollData);
|
|
65
|
+
if (cacheEpochAtStart === getPollCacheEpoch()) {
|
|
66
|
+
setPollCache({ data: pollData, etag, timestamp: Date.now() }, projectId);
|
|
78
67
|
}
|
|
79
|
-
}
|
|
80
|
-
|
|
68
|
+
} finally {
|
|
69
|
+
setPollInProgress(null, projectId);
|
|
81
70
|
}
|
|
82
|
-
|
|
71
|
+
|
|
83
72
|
const etag = generateETag(pollData);
|
|
84
73
|
if (clientETag === etag) {
|
|
85
74
|
return new Response(null, { status: 304, headers: { ETag: etag } });
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import type { Hono } from "hono";
|
|
2
|
-
import { listProjects, listAllSessions } from "../storage
|
|
2
|
+
import { listProjects, listAllSessions } from "../storage";
|
|
3
3
|
|
|
4
4
|
export function registerProjectRoutes(app: Hono) {
|
|
5
|
-
app.get("/api/projects",
|
|
6
|
-
const projectIDs =
|
|
7
|
-
const allSessions =
|
|
5
|
+
app.get("/api/projects", (c) => {
|
|
6
|
+
const projectIDs = listProjects();
|
|
7
|
+
const allSessions = listAllSessions();
|
|
8
8
|
|
|
9
9
|
const projectsWithDetails = projectIDs.map((projectID) => {
|
|
10
10
|
const projectSessions = allSessions.filter((s) => s.projectID === projectID);
|
|
@@ -1,58 +1,62 @@
|
|
|
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 { fetchSessionDetail } from "../services/pollService";
|
|
11
|
+
import { toMessageMeta } from "../services/parsing";
|
|
12
|
+
import { buildSessionTree } from "../services/sessionService";
|
|
6
13
|
import { sessionIdSchema, validateWithResponse } from "../validation";
|
|
7
14
|
import { MAX_SESSIONS_LIMIT, MAX_MESSAGES_LIMIT, TWENTY_FOUR_HOURS_MS } from "../../shared/constants";
|
|
15
|
+
import type { SessionMetadata } from "../../shared/types";
|
|
16
|
+
|
|
17
|
+
const SESSION_SCAN_LIMIT = 50_000;
|
|
18
|
+
|
|
19
|
+
function dbRowToSessionBase(row: DbSessionRow) {
|
|
20
|
+
return {
|
|
21
|
+
id: row.id,
|
|
22
|
+
projectID: row.projectID,
|
|
23
|
+
title: row.title,
|
|
24
|
+
parentID: row.parentID ?? undefined,
|
|
25
|
+
updatedAt: new Date(row.timeUpdated),
|
|
26
|
+
createdAt: new Date(row.timeCreated),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function dbRowToSessionMeta(row: DbSessionRow): SessionMetadata {
|
|
31
|
+
return {
|
|
32
|
+
id: row.id,
|
|
33
|
+
projectID: row.projectID,
|
|
34
|
+
directory: row.directory,
|
|
35
|
+
title: row.title,
|
|
36
|
+
parentID: row.parentID ?? undefined,
|
|
37
|
+
createdAt: new Date(row.timeCreated),
|
|
38
|
+
updatedAt: new Date(row.timeUpdated),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
8
41
|
|
|
9
42
|
export function registerSessionRoutes(app: Hono) {
|
|
10
|
-
app.get("/api/sessions",
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
return c.json({
|
|
43
|
+
app.get("/api/sessions", (c) => {
|
|
44
|
+
if (!checkDbExists()) {
|
|
45
|
+
return c.json({
|
|
14
46
|
error: "OpenCode storage not found",
|
|
15
47
|
message: "OpenCode storage directory does not exist. Please ensure OpenCode is installed.",
|
|
16
|
-
sessions: []
|
|
48
|
+
sessions: [],
|
|
17
49
|
}, 200);
|
|
18
50
|
}
|
|
19
51
|
|
|
20
|
-
const allSessions = await listAllSessions();
|
|
21
|
-
|
|
22
52
|
const now = Date.now();
|
|
23
53
|
const twentyFourHoursAgo = now - TWENTY_FOUR_HOURS_MS;
|
|
24
54
|
|
|
25
|
-
const
|
|
26
|
-
(
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
(a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()
|
|
31
|
-
);
|
|
32
|
-
|
|
33
|
-
const limitedSessions = sortedSessions.slice(0, MAX_SESSIONS_LIMIT);
|
|
34
|
-
const rootSessions = limitedSessions.filter(s => !s.parentID);
|
|
35
|
-
|
|
36
|
-
const sessionsWithActivity = await Promise.all(
|
|
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
|
-
);
|
|
54
|
-
|
|
55
|
-
return c.json(sessionsWithActivity);
|
|
55
|
+
const sessions = querySessions(undefined, twentyFourHoursAgo, MAX_SESSIONS_LIMIT)
|
|
56
|
+
.filter((row) => !row.parentID)
|
|
57
|
+
.map(dbRowToSessionBase);
|
|
58
|
+
|
|
59
|
+
return c.json(sessions);
|
|
56
60
|
});
|
|
57
61
|
|
|
58
62
|
app.get("/api/sessions/:id", async (c) => {
|
|
@@ -60,59 +64,94 @@ export function registerSessionRoutes(app: Hono) {
|
|
|
60
64
|
if (!validation.success) return validation.response;
|
|
61
65
|
const sessionID = validation.value;
|
|
62
66
|
|
|
63
|
-
|
|
64
|
-
const session = allSessions.find((s) => s.id === sessionID);
|
|
65
|
-
|
|
66
|
-
if (!session) {
|
|
67
|
+
if (!checkDbExists()) {
|
|
67
68
|
return c.json({ error: "SESSION_NOT_FOUND", message: `Session '${sessionID}' not found`, status: 404 }, 404);
|
|
68
69
|
}
|
|
69
70
|
|
|
70
|
-
const
|
|
71
|
-
|
|
71
|
+
const sessionRow = querySession(sessionID);
|
|
72
|
+
if (!sessionRow) {
|
|
73
|
+
return c.json({ error: "SESSION_NOT_FOUND", message: `Session '${sessionID}' not found`, status: 404 }, 404);
|
|
74
|
+
}
|
|
72
75
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
agentHierarchy,
|
|
76
|
-
});
|
|
76
|
+
const detail = await fetchSessionDetail(sessionID);
|
|
77
|
+
return c.json(detail);
|
|
77
78
|
});
|
|
78
79
|
|
|
79
|
-
app.get("/api/sessions/:id/messages",
|
|
80
|
+
app.get("/api/sessions/:id/messages", (c) => {
|
|
80
81
|
const validation = validateWithResponse(sessionIdSchema, c.req.param("id"), c);
|
|
81
82
|
if (!validation.success) return validation.response;
|
|
82
83
|
const sessionID = validation.value;
|
|
83
84
|
|
|
84
|
-
|
|
85
|
-
|
|
85
|
+
if (!checkDbExists()) {
|
|
86
|
+
return c.json({ error: "SESSION_NOT_FOUND", message: `Session '${sessionID}' not found`, status: 404 }, 404);
|
|
87
|
+
}
|
|
86
88
|
|
|
87
|
-
|
|
89
|
+
const sessionRow = querySession(sessionID);
|
|
90
|
+
if (!sessionRow) {
|
|
88
91
|
return c.json({ error: "SESSION_NOT_FOUND", message: `Session '${sessionID}' not found`, status: 404 }, 404);
|
|
89
92
|
}
|
|
90
93
|
|
|
91
|
-
const messages =
|
|
94
|
+
const messages = queryMessages(sessionID, MAX_MESSAGES_LIMIT).map(toMessageMeta);
|
|
95
|
+
return c.json(messages);
|
|
96
|
+
});
|
|
92
97
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
);
|
|
98
|
+
app.get("/api/sessions/:id/tree", async (c) => {
|
|
99
|
+
const validation = validateWithResponse(sessionIdSchema, c.req.param("id"), c);
|
|
100
|
+
if (!validation.success) return validation.response;
|
|
101
|
+
const sessionID = validation.value;
|
|
96
102
|
|
|
97
|
-
|
|
103
|
+
if (!checkDbExists()) {
|
|
104
|
+
return c.json({ error: "SESSION_NOT_FOUND", message: `Session '${sessionID}' not found`, status: 404 }, 404);
|
|
105
|
+
}
|
|
98
106
|
|
|
99
|
-
|
|
107
|
+
const sessionRow = querySession(sessionID);
|
|
108
|
+
if (!sessionRow) {
|
|
109
|
+
return c.json({ error: "SESSION_NOT_FOUND", message: `Session '${sessionID}' not found`, status: 404 }, 404);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const allSessions = querySessions(undefined, undefined, SESSION_SCAN_LIMIT).map(dbRowToSessionMeta);
|
|
113
|
+
const tree = await buildSessionTree(sessionID, allSessions);
|
|
114
|
+
return c.json(tree);
|
|
100
115
|
});
|
|
101
116
|
|
|
102
|
-
app.get("/api/sessions/:id/
|
|
117
|
+
app.get("/api/sessions/:id/activity", async (c) => {
|
|
103
118
|
const validation = validateWithResponse(sessionIdSchema, c.req.param("id"), c);
|
|
104
119
|
if (!validation.success) return validation.response;
|
|
105
120
|
const sessionID = validation.value;
|
|
106
121
|
|
|
107
|
-
|
|
108
|
-
|
|
122
|
+
if (!checkDbExists()) {
|
|
123
|
+
return c.json({ error: "SESSION_NOT_FOUND", message: `Session '${sessionID}' not found`, status: 404 }, 404);
|
|
124
|
+
}
|
|
109
125
|
|
|
110
|
-
|
|
126
|
+
const sessionRow = querySession(sessionID);
|
|
127
|
+
if (!sessionRow) {
|
|
111
128
|
return c.json({ error: "SESSION_NOT_FOUND", message: `Session '${sessionID}' not found`, status: 404 }, 404);
|
|
112
129
|
}
|
|
113
130
|
|
|
114
|
-
const
|
|
131
|
+
const detail = await fetchSessionDetail(sessionID);
|
|
132
|
+
return c.json({ activity: detail.activity });
|
|
133
|
+
});
|
|
115
134
|
|
|
116
|
-
|
|
135
|
+
app.get("/api/sessions/:id/todos", (c) => {
|
|
136
|
+
const validation = validateWithResponse(sessionIdSchema, c.req.param("id"), c);
|
|
137
|
+
if (!validation.success) return validation.response;
|
|
138
|
+
const sessionID = validation.value;
|
|
139
|
+
|
|
140
|
+
if (!checkDbExists()) {
|
|
141
|
+
return c.json([], 200);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const sessionRow = querySession(sessionID);
|
|
145
|
+
if (!sessionRow) {
|
|
146
|
+
return c.json({ error: "SESSION_NOT_FOUND", message: `Session '${sessionID}' not found`, status: 404 }, 404);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const todos = queryTodos(sessionID).map((row) => ({
|
|
150
|
+
content: row.content,
|
|
151
|
+
status: row.status,
|
|
152
|
+
priority: row.priority,
|
|
153
|
+
position: row.position,
|
|
154
|
+
}));
|
|
155
|
+
return c.json(todos);
|
|
117
156
|
});
|
|
118
157
|
}
|
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
|
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared parsing functions for converting database rows to domain types.
|
|
3
|
+
*
|
|
4
|
+
* This module is the SINGLE SOURCE OF TRUTH for DB row → domain object mapping.
|
|
5
|
+
* Both pollService and sessionService import from here — never duplicate these functions.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
SessionMetadata,
|
|
10
|
+
MessageMeta,
|
|
11
|
+
PartMeta,
|
|
12
|
+
} from "../../shared/types";
|
|
13
|
+
import type { DbSessionRow, DbMessageRow, DbPartRow } from "../storage/queries";
|
|
14
|
+
import { isPendingToolCall } from "../logic";
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// JSON shapes (internal to this module)
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
interface MessageJSON {
|
|
21
|
+
role?: string;
|
|
22
|
+
agent?: string;
|
|
23
|
+
mode?: string;
|
|
24
|
+
modelID?: string;
|
|
25
|
+
providerID?: string;
|
|
26
|
+
model?: {
|
|
27
|
+
modelID?: string;
|
|
28
|
+
providerID?: string;
|
|
29
|
+
};
|
|
30
|
+
parentID?: string;
|
|
31
|
+
cost?: number;
|
|
32
|
+
tokens?: {
|
|
33
|
+
input?: number;
|
|
34
|
+
output?: number;
|
|
35
|
+
};
|
|
36
|
+
finish?: string;
|
|
37
|
+
time?: {
|
|
38
|
+
created?: number;
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface PartStateJSON {
|
|
43
|
+
status?: string;
|
|
44
|
+
input?: Record<string, unknown>;
|
|
45
|
+
output?: string;
|
|
46
|
+
error?: string;
|
|
47
|
+
title?: string;
|
|
48
|
+
time?: {
|
|
49
|
+
start?: number;
|
|
50
|
+
end?: number;
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface PartJSON {
|
|
55
|
+
type?: string;
|
|
56
|
+
callID?: string;
|
|
57
|
+
tool?: string;
|
|
58
|
+
state?: string | PartStateJSON;
|
|
59
|
+
text?: string;
|
|
60
|
+
title?: string;
|
|
61
|
+
reason?: string;
|
|
62
|
+
files?: unknown;
|
|
63
|
+
input?: Record<string, unknown>;
|
|
64
|
+
time?: {
|
|
65
|
+
start?: number;
|
|
66
|
+
end?: number;
|
|
67
|
+
};
|
|
68
|
+
snapshot?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Helpers
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
export function parseJsonData<T>(raw: unknown): T | null {
|
|
76
|
+
if (typeof raw === "string") {
|
|
77
|
+
try {
|
|
78
|
+
return JSON.parse(raw) as T;
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (raw && typeof raw === "object") {
|
|
85
|
+
return raw as T;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function toStringOrUndefined(value: unknown): string | undefined {
|
|
92
|
+
return typeof value === "string" ? value : undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function toDate(value: unknown): Date | undefined {
|
|
96
|
+
return typeof value === "number" ? new Date(value) : undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function toStringArrayOrUndefined(value: unknown): string[] | undefined {
|
|
100
|
+
if (!Array.isArray(value)) {
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
const items = value.filter((item): item is string => typeof item === "string");
|
|
104
|
+
return items.length > 0 ? items : undefined;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// Row → Domain converters
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
export function toSessionMetadata(row: DbSessionRow): SessionMetadata {
|
|
112
|
+
return {
|
|
113
|
+
id: row.id,
|
|
114
|
+
projectID: row.projectID,
|
|
115
|
+
directory: row.directory,
|
|
116
|
+
title: row.title,
|
|
117
|
+
parentID: row.parentID ?? undefined,
|
|
118
|
+
createdAt: new Date(row.timeCreated),
|
|
119
|
+
updatedAt: new Date(row.timeUpdated),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function toMessageMeta(row: DbMessageRow): MessageMeta {
|
|
124
|
+
const json = parseJsonData<MessageJSON>(row.data) ?? {};
|
|
125
|
+
const tokenInput = json.tokens?.input;
|
|
126
|
+
const tokenOutput = json.tokens?.output;
|
|
127
|
+
const hasTokens = typeof tokenInput === "number" || typeof tokenOutput === "number";
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
id: row.id,
|
|
131
|
+
sessionID: row.sessionID,
|
|
132
|
+
role: json.role ?? row.role ?? "unknown",
|
|
133
|
+
agent: toStringOrUndefined(json.agent),
|
|
134
|
+
mode: toStringOrUndefined(json.mode),
|
|
135
|
+
modelID: toStringOrUndefined(json.modelID) ?? toStringOrUndefined(json.model?.modelID),
|
|
136
|
+
providerID: toStringOrUndefined(json.providerID) ?? toStringOrUndefined(json.model?.providerID),
|
|
137
|
+
parentID: toStringOrUndefined(json.parentID),
|
|
138
|
+
tokens: hasTokens ? (tokenInput ?? 0) + (tokenOutput ?? 0) : undefined,
|
|
139
|
+
cost: typeof json.cost === "number" ? json.cost : undefined,
|
|
140
|
+
createdAt: new Date(json.time?.created ?? row.timeCreated),
|
|
141
|
+
finish: toStringOrUndefined(json.finish),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function toPartMeta(row: DbPartRow): PartMeta {
|
|
146
|
+
const json = parseJsonData<PartJSON>(row.data) ?? {};
|
|
147
|
+
|
|
148
|
+
const stateObject = (typeof json.state === "object" && json.state !== null)
|
|
149
|
+
? json.state as PartStateJSON
|
|
150
|
+
: undefined;
|
|
151
|
+
|
|
152
|
+
const state = toStringOrUndefined(json.state) ?? toStringOrUndefined(stateObject?.status) ?? row.state ?? undefined;
|
|
153
|
+
|
|
154
|
+
const nestedInput = (stateObject?.input && typeof stateObject.input === "object")
|
|
155
|
+
? stateObject.input as Record<string, unknown>
|
|
156
|
+
: undefined;
|
|
157
|
+
const rootInput = (json.input && typeof json.input === "object")
|
|
158
|
+
? json.input as Record<string, unknown>
|
|
159
|
+
: undefined;
|
|
160
|
+
const input = nestedInput ?? rootInput;
|
|
161
|
+
|
|
162
|
+
const title = toStringOrUndefined(stateObject?.title) ?? toStringOrUndefined(json.title);
|
|
163
|
+
|
|
164
|
+
const time = (json.time && typeof json.time === "object") ? json.time : stateObject?.time;
|
|
165
|
+
const startedAt = toDate(time?.start);
|
|
166
|
+
const completedAt = toDate(time?.end);
|
|
167
|
+
|
|
168
|
+
const errorRaw = toStringOrUndefined(stateObject?.error) ?? toStringOrUndefined(stateObject?.output);
|
|
169
|
+
const error = (state === "error" || state === "failed") && errorRaw
|
|
170
|
+
? errorRaw.slice(0, 500)
|
|
171
|
+
: undefined;
|
|
172
|
+
|
|
173
|
+
const reason = json.reason;
|
|
174
|
+
const stepFinishReason = reason === "stop" || reason === "tool-calls" ? reason : undefined;
|
|
175
|
+
const type = toStringOrUndefined(json.type) ?? row.type ?? "unknown";
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
id: row.id,
|
|
179
|
+
sessionID: row.sessionID,
|
|
180
|
+
messageID: row.messageID,
|
|
181
|
+
type,
|
|
182
|
+
callID: toStringOrUndefined(json.callID),
|
|
183
|
+
tool: toStringOrUndefined(json.tool) ?? row.tool ?? undefined,
|
|
184
|
+
state,
|
|
185
|
+
input,
|
|
186
|
+
title,
|
|
187
|
+
error,
|
|
188
|
+
startedAt,
|
|
189
|
+
completedAt,
|
|
190
|
+
stepSnapshot: toStringOrUndefined(json.snapshot),
|
|
191
|
+
stepFinishReason,
|
|
192
|
+
reasoningText: type === "reasoning" ? toStringOrUndefined(json.text) : undefined,
|
|
193
|
+
patchFiles: toStringArrayOrUndefined(json.files),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
// Common query helpers (used by both pollService and sessionService)
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
export function getLatestAssistantMessage(messages: MessageMeta[]): MessageMeta | undefined {
|
|
202
|
+
return messages
|
|
203
|
+
.filter((message) => message.role === "assistant")
|
|
204
|
+
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0];
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function getMostRecentPendingPart(parts: PartMeta[]): PartMeta | undefined {
|
|
208
|
+
return parts
|
|
209
|
+
.filter((part) => isPendingToolCall(part))
|
|
210
|
+
.sort((a, b) => (b.startedAt?.getTime() || 0) - (a.startedAt?.getTime() || 0))[0];
|
|
211
|
+
}
|