oh-my-opencode-dashboard 0.0.3 → 0.0.5
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/assets/index--GqzhA4-.css +1 -0
- package/dist/assets/index-CiC6k4Yg.js +40 -0
- package/dist/index.html +3 -3
- package/package.json +1 -1
- package/src/App.tsx +443 -56
- package/src/app-payload.test.ts +158 -0
- package/src/background-task-timeline.test.ts +32 -0
- package/src/background-task-toolcalls-policy.test.ts +191 -0
- package/src/ingest/background-tasks.test.ts +304 -2
- package/src/ingest/background-tasks.ts +67 -28
- package/src/ingest/model.ts +79 -0
- package/src/ingest/session.test.ts +119 -0
- package/src/ingest/session.ts +4 -0
- package/src/ingest/tool-calls.test.ts +161 -0
- package/src/ingest/tool-calls.ts +157 -0
- package/src/server/api.test.ts +162 -53
- package/src/server/api.ts +39 -2
- package/src/server/dashboard.test.ts +139 -0
- package/src/server/dashboard.ts +40 -3
- package/src/server/dev.ts +4 -2
- package/src/server/start.ts +4 -2
- package/src/styles.css +131 -0
- package/src/timeseries-stacked.test.ts +261 -0
- package/src/timeseries-stacked.ts +145 -0
- package/dist/assets/index-Cs5xePn_.js +0 -40
- package/dist/assets/index-RAZRO3YN.css +0 -1
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import * as fs from "node:fs"
|
|
2
|
+
import * as path from "node:path"
|
|
3
|
+
import type { OpenCodeStorageRoots, StoredMessageMeta } from "./session"
|
|
4
|
+
import { getMessageDir } from "./session"
|
|
5
|
+
import { assertAllowedPath } from "./paths"
|
|
6
|
+
|
|
7
|
+
type FsLike = Pick<typeof fs, "readFileSync" | "readdirSync" | "existsSync" | "statSync">
|
|
8
|
+
|
|
9
|
+
export const MAX_TOOL_CALL_MESSAGES = 200
|
|
10
|
+
export const MAX_TOOL_CALLS = 300
|
|
11
|
+
|
|
12
|
+
export type ToolCallSummary = {
|
|
13
|
+
sessionId: string
|
|
14
|
+
messageId: string
|
|
15
|
+
callId: string
|
|
16
|
+
tool: string
|
|
17
|
+
status: "pending" | "running" | "completed" | "error" | "unknown"
|
|
18
|
+
createdAtMs: number | null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type ToolCallSummaryResult = {
|
|
22
|
+
toolCalls: ToolCallSummary[]
|
|
23
|
+
truncated: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type StoredToolPartMeta = {
|
|
27
|
+
type?: string
|
|
28
|
+
callID?: string
|
|
29
|
+
tool?: string
|
|
30
|
+
state?: { status?: string }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function readJsonFile<T>(filePath: string, fsLike: FsLike): T | null {
|
|
34
|
+
try {
|
|
35
|
+
const content = fsLike.readFileSync(filePath, "utf8")
|
|
36
|
+
return JSON.parse(content) as T
|
|
37
|
+
} catch {
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function listJsonFiles(dir: string, fsLike: FsLike): string[] {
|
|
43
|
+
try {
|
|
44
|
+
return fsLike.readdirSync(dir).filter((f) => f.endsWith(".json"))
|
|
45
|
+
} catch {
|
|
46
|
+
return []
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function readRecentMessageMetas(
|
|
51
|
+
messageDir: string,
|
|
52
|
+
maxMessages: number,
|
|
53
|
+
fsLike: FsLike
|
|
54
|
+
): { metas: StoredMessageMeta[]; totalMessages: number } {
|
|
55
|
+
if (!messageDir || !fsLike.existsSync(messageDir)) return { metas: [], totalMessages: 0 }
|
|
56
|
+
const files = listJsonFiles(messageDir, fsLike)
|
|
57
|
+
const ranked = files
|
|
58
|
+
.map((f) => ({
|
|
59
|
+
f,
|
|
60
|
+
mtime: (() => {
|
|
61
|
+
try {
|
|
62
|
+
return fsLike.statSync(path.join(messageDir, f)).mtimeMs
|
|
63
|
+
} catch {
|
|
64
|
+
return 0
|
|
65
|
+
}
|
|
66
|
+
})(),
|
|
67
|
+
}))
|
|
68
|
+
.sort((a, b) => b.mtime - a.mtime)
|
|
69
|
+
.slice(0, maxMessages)
|
|
70
|
+
|
|
71
|
+
const metas: StoredMessageMeta[] = []
|
|
72
|
+
for (const item of ranked) {
|
|
73
|
+
const meta = readJsonFile<StoredMessageMeta>(path.join(messageDir, item.f), fsLike)
|
|
74
|
+
if (meta && typeof meta.id === "string") metas.push(meta)
|
|
75
|
+
}
|
|
76
|
+
return { metas, totalMessages: files.length }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function readToolPartsForMessage(
|
|
80
|
+
partStorage: string,
|
|
81
|
+
messageId: string,
|
|
82
|
+
fsLike: FsLike,
|
|
83
|
+
allowedRoots?: string[]
|
|
84
|
+
): StoredToolPartMeta[] {
|
|
85
|
+
const partDir = path.join(partStorage, messageId)
|
|
86
|
+
if (allowedRoots && allowedRoots.length > 0) {
|
|
87
|
+
assertAllowedPath({ candidatePath: partDir, allowedRoots })
|
|
88
|
+
}
|
|
89
|
+
if (!fsLike.existsSync(partDir)) return []
|
|
90
|
+
|
|
91
|
+
const files = listJsonFiles(partDir, fsLike).sort()
|
|
92
|
+
const parts: StoredToolPartMeta[] = []
|
|
93
|
+
for (const file of files) {
|
|
94
|
+
const part = readJsonFile<StoredToolPartMeta>(path.join(partDir, file), fsLike)
|
|
95
|
+
if (part && part.type === "tool" && typeof part.tool === "string" && typeof part.callID === "string") {
|
|
96
|
+
parts.push(part)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return parts
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function readStatus(value: StoredToolPartMeta["state"]): ToolCallSummary["status"] {
|
|
103
|
+
const status = value?.status
|
|
104
|
+
if (status === "pending" || status === "running" || status === "completed" || status === "error") {
|
|
105
|
+
return status
|
|
106
|
+
}
|
|
107
|
+
return "unknown"
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function deriveToolCalls(opts: {
|
|
111
|
+
storage: OpenCodeStorageRoots
|
|
112
|
+
sessionId: string
|
|
113
|
+
fs?: FsLike
|
|
114
|
+
allowedRoots?: string[]
|
|
115
|
+
}): ToolCallSummaryResult {
|
|
116
|
+
const fsLike: FsLike = opts.fs ?? fs
|
|
117
|
+
const messageDir = getMessageDir(opts.storage.message, opts.sessionId)
|
|
118
|
+
if (messageDir && opts.allowedRoots && opts.allowedRoots.length > 0) {
|
|
119
|
+
assertAllowedPath({ candidatePath: messageDir, allowedRoots: opts.allowedRoots })
|
|
120
|
+
}
|
|
121
|
+
const { metas, totalMessages } = readRecentMessageMetas(messageDir, MAX_TOOL_CALL_MESSAGES, fsLike)
|
|
122
|
+
const truncatedByMessages = totalMessages > MAX_TOOL_CALL_MESSAGES
|
|
123
|
+
|
|
124
|
+
const calls: Array<ToolCallSummary & { createdSortKey: number }> = []
|
|
125
|
+
for (const meta of metas) {
|
|
126
|
+
const createdAtMs = typeof meta.time?.created === "number" ? meta.time.created : null
|
|
127
|
+
const createdSortKey = createdAtMs ?? -Infinity
|
|
128
|
+
const parts = readToolPartsForMessage(opts.storage.part, meta.id, fsLike, opts.allowedRoots)
|
|
129
|
+
for (const part of parts) {
|
|
130
|
+
calls.push({
|
|
131
|
+
sessionId: opts.sessionId,
|
|
132
|
+
messageId: meta.id,
|
|
133
|
+
callId: part.callID ?? "",
|
|
134
|
+
tool: part.tool ?? "",
|
|
135
|
+
status: readStatus(part.state),
|
|
136
|
+
createdAtMs,
|
|
137
|
+
createdSortKey,
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const truncatedByCalls = calls.length > MAX_TOOL_CALLS
|
|
143
|
+
const toolCalls = calls
|
|
144
|
+
.sort((a, b) => {
|
|
145
|
+
if (a.createdSortKey !== b.createdSortKey) return b.createdSortKey - a.createdSortKey
|
|
146
|
+
const messageCompare = String(a.messageId).localeCompare(String(b.messageId))
|
|
147
|
+
if (messageCompare !== 0) return messageCompare
|
|
148
|
+
return String(a.callId).localeCompare(String(b.callId))
|
|
149
|
+
})
|
|
150
|
+
.slice(0, MAX_TOOL_CALLS)
|
|
151
|
+
.map(({ createdSortKey, ...row }) => row)
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
toolCalls,
|
|
155
|
+
truncated: truncatedByMessages || truncatedByCalls,
|
|
156
|
+
}
|
|
157
|
+
}
|
package/src/server/api.test.ts
CHANGED
|
@@ -1,24 +1,100 @@
|
|
|
1
|
+
import * as fs from "node:fs"
|
|
2
|
+
import * as os from "node:os"
|
|
3
|
+
import * as path from "node:path"
|
|
1
4
|
import { describe, it, expect } from "vitest"
|
|
2
5
|
import { createApi } from "./api"
|
|
6
|
+
import type { DashboardPayload, DashboardStore } from "./dashboard"
|
|
7
|
+
import type { PlanStep } from "../ingest/boulder"
|
|
8
|
+
import type { TimeSeriesPayload } from "../ingest/timeseries"
|
|
9
|
+
|
|
10
|
+
function mkStorageRoot(): string {
|
|
11
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "omo-dashboard-storage-"))
|
|
12
|
+
fs.mkdirSync(path.join(root, "session"), { recursive: true })
|
|
13
|
+
fs.mkdirSync(path.join(root, "message"), { recursive: true })
|
|
14
|
+
fs.mkdirSync(path.join(root, "part"), { recursive: true })
|
|
15
|
+
return root
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function writeMessageMeta(opts: {
|
|
19
|
+
storageRoot: string
|
|
20
|
+
sessionId: string
|
|
21
|
+
messageId: string
|
|
22
|
+
created?: number
|
|
23
|
+
}): void {
|
|
24
|
+
const msgDir = path.join(opts.storageRoot, "message", opts.sessionId)
|
|
25
|
+
fs.mkdirSync(msgDir, { recursive: true })
|
|
26
|
+
const meta: Record<string, unknown> = {
|
|
27
|
+
id: opts.messageId,
|
|
28
|
+
sessionID: opts.sessionId,
|
|
29
|
+
role: "assistant",
|
|
30
|
+
}
|
|
31
|
+
if (typeof opts.created === "number") {
|
|
32
|
+
meta.time = { created: opts.created }
|
|
33
|
+
}
|
|
34
|
+
fs.writeFileSync(path.join(msgDir, `${opts.messageId}.json`), JSON.stringify(meta), "utf8")
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function writeToolPart(opts: {
|
|
38
|
+
storageRoot: string
|
|
39
|
+
sessionId: string
|
|
40
|
+
messageId: string
|
|
41
|
+
callId: string
|
|
42
|
+
tool: string
|
|
43
|
+
state?: Record<string, unknown>
|
|
44
|
+
}): void {
|
|
45
|
+
const partDir = path.join(opts.storageRoot, "part", opts.messageId)
|
|
46
|
+
fs.mkdirSync(partDir, { recursive: true })
|
|
47
|
+
fs.writeFileSync(
|
|
48
|
+
path.join(partDir, `${opts.callId}.json`),
|
|
49
|
+
JSON.stringify({
|
|
50
|
+
id: `part_${opts.callId}`,
|
|
51
|
+
sessionID: opts.sessionId,
|
|
52
|
+
messageID: opts.messageId,
|
|
53
|
+
type: "tool",
|
|
54
|
+
callID: opts.callId,
|
|
55
|
+
tool: opts.tool,
|
|
56
|
+
state: opts.state ?? { status: "completed", input: {} },
|
|
57
|
+
}),
|
|
58
|
+
"utf8"
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const sensitiveKeys = ["prompt", "input", "output", "error", "state"]
|
|
63
|
+
|
|
64
|
+
function hasSensitiveKeys(value: unknown): boolean {
|
|
65
|
+
if (!value || typeof value !== "object") return false
|
|
66
|
+
if (Array.isArray(value)) {
|
|
67
|
+
return value.some((item) => hasSensitiveKeys(item))
|
|
68
|
+
}
|
|
69
|
+
for (const [key, child] of Object.entries(value)) {
|
|
70
|
+
if (sensitiveKeys.includes(key)) return true
|
|
71
|
+
if (hasSensitiveKeys(child)) return true
|
|
72
|
+
}
|
|
73
|
+
return false
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const createStore = (): DashboardStore => ({
|
|
77
|
+
getSnapshot: (): DashboardPayload => ({
|
|
78
|
+
mainSession: { agent: "x", currentModel: null, currentTool: "-", lastUpdatedLabel: "never", session: "s", statusPill: "idle" },
|
|
79
|
+
planProgress: { name: "p", completed: 0, total: 0, path: "", statusPill: "not started", steps: [] as PlanStep[] },
|
|
80
|
+
backgroundTasks: [],
|
|
81
|
+
timeSeries: {
|
|
82
|
+
windowMs: 0,
|
|
83
|
+
bucketMs: 0,
|
|
84
|
+
buckets: 0,
|
|
85
|
+
anchorMs: 0,
|
|
86
|
+
serverNowMs: 0,
|
|
87
|
+
series: [{ id: "overall-main", label: "Overall", tone: "muted" as const, values: [] as number[] }],
|
|
88
|
+
},
|
|
89
|
+
raw: null,
|
|
90
|
+
}),
|
|
91
|
+
} satisfies DashboardStore)
|
|
3
92
|
|
|
4
93
|
describe('API Routes', () => {
|
|
5
94
|
it('should return health check', async () => {
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
planProgress: { name: "p", completed: 0, total: 0, path: "", statusPill: "not started" },
|
|
10
|
-
backgroundTasks: [],
|
|
11
|
-
timeSeries: {
|
|
12
|
-
windowMs: 0,
|
|
13
|
-
bucketMs: 0,
|
|
14
|
-
buckets: 0,
|
|
15
|
-
anchorMs: 0,
|
|
16
|
-
serverNowMs: 0,
|
|
17
|
-
series: [{ id: "overall-main", label: "Overall", tone: "muted", values: [] }],
|
|
18
|
-
},
|
|
19
|
-
raw: null,
|
|
20
|
-
}),
|
|
21
|
-
})
|
|
95
|
+
const storageRoot = mkStorageRoot()
|
|
96
|
+
const store = createStore()
|
|
97
|
+
const api = createApi({ store, storageRoot })
|
|
22
98
|
|
|
23
99
|
const res = await api.request("/health")
|
|
24
100
|
expect(res.status).toBe(200)
|
|
@@ -26,22 +102,9 @@ describe('API Routes', () => {
|
|
|
26
102
|
})
|
|
27
103
|
|
|
28
104
|
it('should return dashboard data without sensitive keys', async () => {
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
planProgress: { name: "p", completed: 0, total: 0, path: "", statusPill: "not started" },
|
|
33
|
-
backgroundTasks: [{ id: "1", description: "d", agent: "a", status: "queued", toolCalls: 0, lastTool: "-", timeline: "" }],
|
|
34
|
-
timeSeries: {
|
|
35
|
-
windowMs: 0,
|
|
36
|
-
bucketMs: 0,
|
|
37
|
-
buckets: 0,
|
|
38
|
-
anchorMs: 0,
|
|
39
|
-
serverNowMs: 0,
|
|
40
|
-
series: [{ id: "overall-main", label: "Overall", tone: "muted", values: [] }],
|
|
41
|
-
},
|
|
42
|
-
raw: { ok: true },
|
|
43
|
-
}),
|
|
44
|
-
})
|
|
105
|
+
const storageRoot = mkStorageRoot()
|
|
106
|
+
const store = createStore()
|
|
107
|
+
const api = createApi({ store, storageRoot })
|
|
45
108
|
|
|
46
109
|
const res = await api.request("/dashboard")
|
|
47
110
|
expect(res.status).toBe(200)
|
|
@@ -54,26 +117,72 @@ describe('API Routes', () => {
|
|
|
54
117
|
expect(data).toHaveProperty("timeSeries")
|
|
55
118
|
expect(data).toHaveProperty("raw")
|
|
56
119
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
expect(
|
|
120
|
+
expect(hasSensitiveKeys(data)).toBe(false)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('should reject invalid session IDs', async () => {
|
|
124
|
+
const storageRoot = mkStorageRoot()
|
|
125
|
+
const store = createStore()
|
|
126
|
+
const api = createApi({ store, storageRoot })
|
|
127
|
+
|
|
128
|
+
const res = await api.request("/tool-calls/not_valid!")
|
|
129
|
+
expect(res.status).toBe(400)
|
|
130
|
+
expect(await res.json()).toEqual({ ok: false, sessionId: "not_valid!", toolCalls: [] })
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('should return 404 for missing sessions', async () => {
|
|
134
|
+
const storageRoot = mkStorageRoot()
|
|
135
|
+
const store = createStore()
|
|
136
|
+
const api = createApi({ store, storageRoot })
|
|
137
|
+
|
|
138
|
+
const res = await api.request("/tool-calls/ses_missing")
|
|
139
|
+
expect(res.status).toBe(404)
|
|
140
|
+
expect(await res.json()).toEqual({ ok: false, sessionId: "ses_missing", toolCalls: [] })
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('should return empty tool calls for existing sessions', async () => {
|
|
144
|
+
const storageRoot = mkStorageRoot()
|
|
145
|
+
writeMessageMeta({ storageRoot, sessionId: "ses_empty", messageId: "msg_1", created: 1000 })
|
|
146
|
+
const store = createStore()
|
|
147
|
+
const api = createApi({ store, storageRoot })
|
|
148
|
+
|
|
149
|
+
const res = await api.request("/tool-calls/ses_empty")
|
|
150
|
+
expect(res.status).toBe(200)
|
|
151
|
+
|
|
152
|
+
const data = await res.json()
|
|
153
|
+
expect(data.ok).toBe(true)
|
|
154
|
+
expect(data.sessionId).toBe("ses_empty")
|
|
155
|
+
expect(data.toolCalls).toEqual([])
|
|
156
|
+
expect(data.caps).toEqual({ maxMessages: 200, maxToolCalls: 300 })
|
|
157
|
+
expect(data.truncated).toBe(false)
|
|
158
|
+
expect(hasSensitiveKeys(data)).toBe(false)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('should redact tool call payload fields', async () => {
|
|
162
|
+
const storageRoot = mkStorageRoot()
|
|
163
|
+
writeMessageMeta({ storageRoot, sessionId: "ses_redact", messageId: "msg_1", created: 1000 })
|
|
164
|
+
writeToolPart({
|
|
165
|
+
storageRoot,
|
|
166
|
+
sessionId: "ses_redact",
|
|
167
|
+
messageId: "msg_1",
|
|
168
|
+
callId: "call_1",
|
|
169
|
+
tool: "bash",
|
|
170
|
+
state: {
|
|
171
|
+
status: "completed",
|
|
172
|
+
input: { prompt: "SECRET", nested: { output: "HIDDEN" } },
|
|
173
|
+
output: "NOPE",
|
|
174
|
+
error: "NOPE",
|
|
175
|
+
},
|
|
176
|
+
})
|
|
177
|
+
const store = createStore()
|
|
178
|
+
const api = createApi({ store, storageRoot })
|
|
179
|
+
|
|
180
|
+
const res = await api.request("/tool-calls/ses_redact")
|
|
181
|
+
expect(res.status).toBe(200)
|
|
182
|
+
|
|
183
|
+
const data = await res.json()
|
|
184
|
+
expect(data.ok).toBe(true)
|
|
185
|
+
expect(data.toolCalls.length).toBe(1)
|
|
186
|
+
expect(hasSensitiveKeys(data)).toBe(false)
|
|
78
187
|
})
|
|
79
188
|
})
|
package/src/server/api.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { Hono } from "hono"
|
|
2
2
|
import type { DashboardStore } from "./dashboard"
|
|
3
|
+
import { assertAllowedPath } from "../ingest/paths"
|
|
4
|
+
import { getMessageDir, getStorageRoots } from "../ingest/session"
|
|
5
|
+
import { deriveToolCalls, MAX_TOOL_CALL_MESSAGES, MAX_TOOL_CALLS } from "../ingest/tool-calls"
|
|
3
6
|
|
|
4
|
-
|
|
7
|
+
const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/
|
|
8
|
+
|
|
9
|
+
export function createApi(opts: { store: DashboardStore; storageRoot: string }): Hono {
|
|
5
10
|
const api = new Hono()
|
|
6
11
|
|
|
7
12
|
api.get("/health", (c) => {
|
|
@@ -9,7 +14,39 @@ export function createApi(store: DashboardStore): Hono {
|
|
|
9
14
|
})
|
|
10
15
|
|
|
11
16
|
api.get("/dashboard", (c) => {
|
|
12
|
-
return c.json(store.getSnapshot())
|
|
17
|
+
return c.json(opts.store.getSnapshot())
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
api.get("/tool-calls/:sessionId", (c) => {
|
|
21
|
+
const sessionId = c.req.param("sessionId")
|
|
22
|
+
if (!SESSION_ID_PATTERN.test(sessionId)) {
|
|
23
|
+
return c.json({ ok: false, sessionId, toolCalls: [] }, 400)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const storage = getStorageRoots(opts.storageRoot)
|
|
27
|
+
const messageDir = getMessageDir(storage.message, sessionId)
|
|
28
|
+
if (!messageDir) {
|
|
29
|
+
return c.json({ ok: false, sessionId, toolCalls: [] }, 404)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
assertAllowedPath({ candidatePath: messageDir, allowedRoots: [opts.storageRoot] })
|
|
33
|
+
|
|
34
|
+
const { toolCalls, truncated } = deriveToolCalls({
|
|
35
|
+
storage,
|
|
36
|
+
sessionId,
|
|
37
|
+
allowedRoots: [opts.storageRoot],
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
return c.json({
|
|
41
|
+
ok: true,
|
|
42
|
+
sessionId,
|
|
43
|
+
toolCalls,
|
|
44
|
+
caps: {
|
|
45
|
+
maxMessages: MAX_TOOL_CALL_MESSAGES,
|
|
46
|
+
maxToolCalls: MAX_TOOL_CALLS,
|
|
47
|
+
},
|
|
48
|
+
truncated,
|
|
49
|
+
})
|
|
13
50
|
})
|
|
14
51
|
|
|
15
52
|
return api
|
|
@@ -75,6 +75,7 @@ describe("buildDashboardPayload", () => {
|
|
|
75
75
|
expect(payload.mainSession.statusPill).toBe("running tool")
|
|
76
76
|
expect(payload.mainSession.currentTool).toBe("delegate_task")
|
|
77
77
|
expect(payload.mainSession.agent).toBe("sisyphus")
|
|
78
|
+
expect(payload.mainSession.currentModel).toBeNull()
|
|
78
79
|
|
|
79
80
|
expect(payload.raw).not.toHaveProperty("prompt")
|
|
80
81
|
expect(payload.raw).not.toHaveProperty("input")
|
|
@@ -147,6 +148,7 @@ describe("buildDashboardPayload", () => {
|
|
|
147
148
|
|
|
148
149
|
expect(payload).toHaveProperty("timeSeries")
|
|
149
150
|
expect(payload.raw).toHaveProperty("timeSeries")
|
|
151
|
+
expect(payload.mainSession.currentModel).toBeNull()
|
|
150
152
|
|
|
151
153
|
const sensitiveKeys = ["prompt", "input", "output", "error", "state"]
|
|
152
154
|
|
|
@@ -175,4 +177,141 @@ describe("buildDashboardPayload", () => {
|
|
|
175
177
|
fs.rmSync(projectRoot, { recursive: true, force: true })
|
|
176
178
|
}
|
|
177
179
|
})
|
|
180
|
+
|
|
181
|
+
it("includes latest model strings for main and background sessions", () => {
|
|
182
|
+
const storageRoot = mkStorageRoot()
|
|
183
|
+
const storage = getStorageRoots(storageRoot)
|
|
184
|
+
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "omo-project-"))
|
|
185
|
+
const sessionId = "ses_with_models"
|
|
186
|
+
const backgroundSessionId = "ses_bg_1"
|
|
187
|
+
const messageId = "msg_1"
|
|
188
|
+
const projectID = "proj_1"
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
const sessionMetaDir = path.join(storage.session, projectID)
|
|
192
|
+
fs.mkdirSync(sessionMetaDir, { recursive: true })
|
|
193
|
+
fs.writeFileSync(
|
|
194
|
+
path.join(sessionMetaDir, `${sessionId}.json`),
|
|
195
|
+
JSON.stringify({
|
|
196
|
+
id: sessionId,
|
|
197
|
+
projectID,
|
|
198
|
+
directory: projectRoot,
|
|
199
|
+
time: { created: 1000, updated: 1000 },
|
|
200
|
+
}),
|
|
201
|
+
"utf8"
|
|
202
|
+
)
|
|
203
|
+
fs.writeFileSync(
|
|
204
|
+
path.join(sessionMetaDir, `${backgroundSessionId}.json`),
|
|
205
|
+
JSON.stringify({
|
|
206
|
+
id: backgroundSessionId,
|
|
207
|
+
projectID,
|
|
208
|
+
directory: projectRoot,
|
|
209
|
+
parentID: sessionId,
|
|
210
|
+
title: "Background: model task",
|
|
211
|
+
time: { created: 1000, updated: 1100 },
|
|
212
|
+
}),
|
|
213
|
+
"utf8"
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
const messageDir = path.join(storage.message, sessionId)
|
|
217
|
+
fs.mkdirSync(messageDir, { recursive: true })
|
|
218
|
+
fs.writeFileSync(
|
|
219
|
+
path.join(messageDir, `${messageId}.json`),
|
|
220
|
+
JSON.stringify({
|
|
221
|
+
id: messageId,
|
|
222
|
+
sessionID: sessionId,
|
|
223
|
+
role: "assistant",
|
|
224
|
+
agent: "sisyphus",
|
|
225
|
+
time: { created: 1000 },
|
|
226
|
+
}),
|
|
227
|
+
"utf8"
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
const partDir = path.join(storage.part, messageId)
|
|
231
|
+
fs.mkdirSync(partDir, { recursive: true })
|
|
232
|
+
fs.writeFileSync(
|
|
233
|
+
path.join(partDir, "part_1.json"),
|
|
234
|
+
JSON.stringify({
|
|
235
|
+
id: "part_1",
|
|
236
|
+
sessionID: sessionId,
|
|
237
|
+
messageID: messageId,
|
|
238
|
+
type: "tool",
|
|
239
|
+
callID: "call_1",
|
|
240
|
+
tool: "delegate_task",
|
|
241
|
+
state: {
|
|
242
|
+
status: "completed",
|
|
243
|
+
input: {
|
|
244
|
+
run_in_background: true,
|
|
245
|
+
description: "model task",
|
|
246
|
+
subagent_type: "explore",
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
}),
|
|
250
|
+
"utf8"
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
const backgroundMessageDir = path.join(storage.message, backgroundSessionId)
|
|
254
|
+
fs.mkdirSync(backgroundMessageDir, { recursive: true })
|
|
255
|
+
fs.writeFileSync(
|
|
256
|
+
path.join(backgroundMessageDir, "msg_bg_1.json"),
|
|
257
|
+
JSON.stringify({
|
|
258
|
+
id: "msg_bg_1",
|
|
259
|
+
sessionID: backgroundSessionId,
|
|
260
|
+
role: "assistant",
|
|
261
|
+
providerID: "openai",
|
|
262
|
+
modelID: "gpt-4o",
|
|
263
|
+
time: { created: 1100 },
|
|
264
|
+
}),
|
|
265
|
+
"utf8"
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
const payload = buildDashboardPayload({
|
|
269
|
+
projectRoot,
|
|
270
|
+
storage,
|
|
271
|
+
nowMs: 2000,
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
expect(payload.mainSession.currentModel).toBeNull()
|
|
275
|
+
expect(payload.backgroundTasks).toHaveLength(1)
|
|
276
|
+
expect(payload.backgroundTasks[0]?.lastModel).toBe("openai/gpt-4o")
|
|
277
|
+
expect(payload.raw).toHaveProperty("backgroundTasks.0.lastModel", "openai/gpt-4o")
|
|
278
|
+
} finally {
|
|
279
|
+
fs.rmSync(storageRoot, { recursive: true, force: true })
|
|
280
|
+
fs.rmSync(projectRoot, { recursive: true, force: true })
|
|
281
|
+
}
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
it("does not include elapsed time in status pill when status is unknown", () => {
|
|
285
|
+
const storageRoot = mkStorageRoot()
|
|
286
|
+
const storage = getStorageRoots(storageRoot)
|
|
287
|
+
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "omo-project-"))
|
|
288
|
+
const sessionId = "ses_unknown"
|
|
289
|
+
const projectID = "proj_1"
|
|
290
|
+
|
|
291
|
+
try {
|
|
292
|
+
const sessionMetaDir = path.join(storage.session, projectID)
|
|
293
|
+
fs.mkdirSync(sessionMetaDir, { recursive: true })
|
|
294
|
+
fs.writeFileSync(
|
|
295
|
+
path.join(sessionMetaDir, `${sessionId}.json`),
|
|
296
|
+
JSON.stringify({
|
|
297
|
+
id: sessionId,
|
|
298
|
+
projectID,
|
|
299
|
+
directory: projectRoot,
|
|
300
|
+
time: { created: 1000, updated: 1000 },
|
|
301
|
+
}),
|
|
302
|
+
"utf8"
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
const payload = buildDashboardPayload({
|
|
306
|
+
projectRoot,
|
|
307
|
+
storage,
|
|
308
|
+
nowMs: 65_000,
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
expect(payload.mainSession.statusPill).toBe("unknown")
|
|
312
|
+
} finally {
|
|
313
|
+
fs.rmSync(storageRoot, { recursive: true, force: true })
|
|
314
|
+
fs.rmSync(projectRoot, { recursive: true, force: true })
|
|
315
|
+
}
|
|
316
|
+
})
|
|
178
317
|
})
|
package/src/server/dashboard.ts
CHANGED
|
@@ -3,9 +3,41 @@ import * as path from "node:path"
|
|
|
3
3
|
import { readBoulderState, readPlanProgress, readPlanSteps, type PlanStep } from "../ingest/boulder"
|
|
4
4
|
import { deriveBackgroundTasks } from "../ingest/background-tasks"
|
|
5
5
|
import { deriveTimeSeriesActivity, type TimeSeriesPayload } from "../ingest/timeseries"
|
|
6
|
-
import { getMainSessionView, getStorageRoots, pickActiveSessionId, readMainSessionMetas, type OpenCodeStorageRoots, type SessionMetadata } from "../ingest/session"
|
|
6
|
+
import { getMainSessionView, getStorageRoots, pickActiveSessionId, readMainSessionMetas, type MainSessionView, type OpenCodeStorageRoots, type SessionMetadata } from "../ingest/session"
|
|
7
7
|
|
|
8
8
|
export type DashboardPayload = {
|
|
9
|
+
mainSession: {
|
|
10
|
+
agent: string
|
|
11
|
+
currentModel: string | null
|
|
12
|
+
currentTool: string
|
|
13
|
+
lastUpdatedLabel: string
|
|
14
|
+
session: string
|
|
15
|
+
statusPill: string
|
|
16
|
+
}
|
|
17
|
+
planProgress: {
|
|
18
|
+
name: string
|
|
19
|
+
completed: number
|
|
20
|
+
total: number
|
|
21
|
+
path: string
|
|
22
|
+
statusPill: string
|
|
23
|
+
steps: PlanStep[]
|
|
24
|
+
}
|
|
25
|
+
backgroundTasks: Array<{
|
|
26
|
+
id: string
|
|
27
|
+
description: string
|
|
28
|
+
agent: string
|
|
29
|
+
lastModel: string | null
|
|
30
|
+
status: string
|
|
31
|
+
toolCalls: number
|
|
32
|
+
lastTool: string
|
|
33
|
+
timeline: string
|
|
34
|
+
sessionId: string | null
|
|
35
|
+
}>
|
|
36
|
+
timeSeries: TimeSeriesPayload
|
|
37
|
+
raw: unknown
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type LegacyDashboardPayload = {
|
|
9
41
|
mainSession: {
|
|
10
42
|
agent: string
|
|
11
43
|
currentTool: string
|
|
@@ -35,7 +67,7 @@ export type DashboardPayload = {
|
|
|
35
67
|
}
|
|
36
68
|
|
|
37
69
|
export type DashboardStore = {
|
|
38
|
-
getSnapshot: () => DashboardPayload
|
|
70
|
+
getSnapshot: () => DashboardPayload | LegacyDashboardPayload
|
|
39
71
|
}
|
|
40
72
|
|
|
41
73
|
function formatIso(ts: number | null): string {
|
|
@@ -101,10 +133,13 @@ export function buildDashboardPayload(opts: {
|
|
|
101
133
|
mainSessionId: sessionId ?? null,
|
|
102
134
|
nowMs,
|
|
103
135
|
})
|
|
104
|
-
|
|
136
|
+
const mainCurrentModel = "currentModel" in main
|
|
137
|
+
? (main as MainSessionView).currentModel
|
|
138
|
+
: null
|
|
105
139
|
const payload: DashboardPayload = {
|
|
106
140
|
mainSession: {
|
|
107
141
|
agent: main.agent,
|
|
142
|
+
currentModel: mainCurrentModel,
|
|
108
143
|
currentTool: main.currentTool ?? "-",
|
|
109
144
|
lastUpdatedLabel: formatIso(main.lastUpdated),
|
|
110
145
|
session: main.sessionLabel,
|
|
@@ -122,10 +157,12 @@ export function buildDashboardPayload(opts: {
|
|
|
122
157
|
id: t.id,
|
|
123
158
|
description: t.description,
|
|
124
159
|
agent: t.agent,
|
|
160
|
+
lastModel: t.lastModel ?? null,
|
|
125
161
|
status: t.status,
|
|
126
162
|
toolCalls: t.toolCalls ?? 0,
|
|
127
163
|
lastTool: t.lastTool ?? "-",
|
|
128
164
|
timeline: typeof t.timeline === "string" ? t.timeline : "",
|
|
165
|
+
sessionId: t.sessionId ?? null,
|
|
129
166
|
})),
|
|
130
167
|
timeSeries,
|
|
131
168
|
raw: null,
|