oh-my-opencode-dashboard 0.0.4 → 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 +291 -16
- package/src/background-task-toolcalls-policy.test.ts +191 -0
- package/src/ingest/background-tasks.test.ts +11 -2
- 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.ts +2 -0
- package/src/server/dev.ts +4 -2
- package/src/server/start.ts +4 -2
- package/src/styles.css +131 -0
- package/dist/assets/index-CZM2MUUs.js +0 -40
- package/dist/assets/index-RAZRO3YN.css +0 -1
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import * as os from "node:os"
|
|
2
|
+
import * as path from "node:path"
|
|
3
|
+
import * as fs from "node:fs"
|
|
4
|
+
import { describe, expect, it } from "vitest"
|
|
5
|
+
import { deriveToolCalls, MAX_TOOL_CALL_MESSAGES, MAX_TOOL_CALLS } from "./tool-calls"
|
|
6
|
+
import { getStorageRoots } from "./session"
|
|
7
|
+
|
|
8
|
+
function mkStorageRoot(): string {
|
|
9
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "omo-storage-"))
|
|
10
|
+
fs.mkdirSync(path.join(root, "session"), { recursive: true })
|
|
11
|
+
fs.mkdirSync(path.join(root, "message"), { recursive: true })
|
|
12
|
+
fs.mkdirSync(path.join(root, "part"), { recursive: true })
|
|
13
|
+
return root
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function writeMessageMeta(opts: {
|
|
17
|
+
storageRoot: string
|
|
18
|
+
sessionId: string
|
|
19
|
+
messageId: string
|
|
20
|
+
created?: number
|
|
21
|
+
}): void {
|
|
22
|
+
const storage = getStorageRoots(opts.storageRoot)
|
|
23
|
+
const msgDir = path.join(storage.message, opts.sessionId)
|
|
24
|
+
fs.mkdirSync(msgDir, { recursive: true })
|
|
25
|
+
const meta: Record<string, unknown> = {
|
|
26
|
+
id: opts.messageId,
|
|
27
|
+
sessionID: opts.sessionId,
|
|
28
|
+
role: "assistant",
|
|
29
|
+
}
|
|
30
|
+
if (typeof opts.created === "number") {
|
|
31
|
+
meta.time = { created: opts.created }
|
|
32
|
+
}
|
|
33
|
+
fs.writeFileSync(path.join(msgDir, `${opts.messageId}.json`), JSON.stringify(meta), "utf8")
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function writeToolPart(opts: {
|
|
37
|
+
storageRoot: string
|
|
38
|
+
sessionId: string
|
|
39
|
+
messageId: string
|
|
40
|
+
callId: string
|
|
41
|
+
tool: string
|
|
42
|
+
state?: Record<string, unknown>
|
|
43
|
+
}): void {
|
|
44
|
+
const storage = getStorageRoots(opts.storageRoot)
|
|
45
|
+
const partDir = path.join(storage.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
|
+
function hasBannedKeys(value: unknown, banned: Set<string>): boolean {
|
|
63
|
+
if (!value || typeof value !== "object") return false
|
|
64
|
+
if (Array.isArray(value)) {
|
|
65
|
+
return value.some((item) => hasBannedKeys(item, banned))
|
|
66
|
+
}
|
|
67
|
+
for (const [key, child] of Object.entries(value)) {
|
|
68
|
+
if (banned.has(key)) return true
|
|
69
|
+
if (hasBannedKeys(child, banned)) return true
|
|
70
|
+
}
|
|
71
|
+
return false
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
describe("deriveToolCalls", () => {
|
|
75
|
+
it("orders tool calls deterministically and sorts null timestamps last", () => {
|
|
76
|
+
const storageRoot = mkStorageRoot()
|
|
77
|
+
const storage = getStorageRoots(storageRoot)
|
|
78
|
+
const sessionId = "ses_main"
|
|
79
|
+
|
|
80
|
+
writeMessageMeta({ storageRoot, sessionId, messageId: "msg_0", created: 500 })
|
|
81
|
+
writeToolPart({ storageRoot, sessionId, messageId: "msg_0", callId: "call_a", tool: "read" })
|
|
82
|
+
|
|
83
|
+
writeMessageMeta({ storageRoot, sessionId, messageId: "msg_1", created: 1000 })
|
|
84
|
+
writeToolPart({ storageRoot, sessionId, messageId: "msg_1", callId: "call_a", tool: "bash" })
|
|
85
|
+
|
|
86
|
+
writeMessageMeta({ storageRoot, sessionId, messageId: "msg_2", created: 1000 })
|
|
87
|
+
writeToolPart({ storageRoot, sessionId, messageId: "msg_2", callId: "call_b", tool: "grep" })
|
|
88
|
+
writeToolPart({ storageRoot, sessionId, messageId: "msg_2", callId: "call_a", tool: "grep" })
|
|
89
|
+
|
|
90
|
+
writeMessageMeta({ storageRoot, sessionId, messageId: "msg_3" })
|
|
91
|
+
writeToolPart({ storageRoot, sessionId, messageId: "msg_3", callId: "call_z", tool: "read" })
|
|
92
|
+
|
|
93
|
+
const result = deriveToolCalls({ storage, sessionId })
|
|
94
|
+
expect(result.toolCalls.map((row) => `${row.messageId}:${row.callId}`)).toEqual([
|
|
95
|
+
"msg_1:call_a",
|
|
96
|
+
"msg_2:call_a",
|
|
97
|
+
"msg_2:call_b",
|
|
98
|
+
"msg_0:call_a",
|
|
99
|
+
"msg_3:call_z",
|
|
100
|
+
])
|
|
101
|
+
expect(result.toolCalls[0].createdAtMs).toBe(1000)
|
|
102
|
+
expect(result.toolCalls[4].createdAtMs).toBe(null)
|
|
103
|
+
expect(result.truncated).toBe(false)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it("caps message scan and tool call output", () => {
|
|
107
|
+
const storageRoot = mkStorageRoot()
|
|
108
|
+
const storage = getStorageRoots(storageRoot)
|
|
109
|
+
const sessionId = "ses_main"
|
|
110
|
+
|
|
111
|
+
const totalMessages = MAX_TOOL_CALL_MESSAGES + 5
|
|
112
|
+
for (let i = 0; i < totalMessages; i += 1) {
|
|
113
|
+
const suffix = String(i).padStart(3, "0")
|
|
114
|
+
const messageId = `msg_${suffix}`
|
|
115
|
+
writeMessageMeta({ storageRoot, sessionId, messageId, created: i })
|
|
116
|
+
writeToolPart({ storageRoot, sessionId, messageId, callId: `call_${suffix}_a`, tool: "bash" })
|
|
117
|
+
writeToolPart({ storageRoot, sessionId, messageId, callId: `call_${suffix}_b`, tool: "read" })
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const result = deriveToolCalls({ storage, sessionId })
|
|
121
|
+
expect(result.toolCalls.length).toBe(MAX_TOOL_CALLS)
|
|
122
|
+
expect(result.truncated).toBe(true)
|
|
123
|
+
|
|
124
|
+
const messageIds = new Set(result.toolCalls.map((row) => row.messageId))
|
|
125
|
+
for (let i = 0; i < 5; i += 1) {
|
|
126
|
+
const suffix = String(i).padStart(3, "0")
|
|
127
|
+
expect(messageIds.has(`msg_${suffix}`)).toBe(false)
|
|
128
|
+
}
|
|
129
|
+
for (let i = totalMessages - 5; i < totalMessages; i += 1) {
|
|
130
|
+
const suffix = String(i).padStart(3, "0")
|
|
131
|
+
expect(messageIds.has(`msg_${suffix}`)).toBe(true)
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it("redacts tool call payload fields", () => {
|
|
136
|
+
const storageRoot = mkStorageRoot()
|
|
137
|
+
const storage = getStorageRoots(storageRoot)
|
|
138
|
+
const sessionId = "ses_main"
|
|
139
|
+
|
|
140
|
+
writeMessageMeta({ storageRoot, sessionId, messageId: "msg_1", created: 1000 })
|
|
141
|
+
writeToolPart({
|
|
142
|
+
storageRoot,
|
|
143
|
+
sessionId,
|
|
144
|
+
messageId: "msg_1",
|
|
145
|
+
callId: "call_secret",
|
|
146
|
+
tool: "bash",
|
|
147
|
+
state: {
|
|
148
|
+
status: "completed",
|
|
149
|
+
input: { prompt: "SECRET", nested: { output: "HIDDEN" } },
|
|
150
|
+
output: "NOPE",
|
|
151
|
+
error: "NOPE",
|
|
152
|
+
},
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
const result = deriveToolCalls({ storage, sessionId })
|
|
156
|
+
expect(result.toolCalls.length).toBe(1)
|
|
157
|
+
|
|
158
|
+
const banned = new Set(["prompt", "input", "output", "error", "state"])
|
|
159
|
+
expect(hasBannedKeys(result.toolCalls[0], banned)).toBe(false)
|
|
160
|
+
})
|
|
161
|
+
})
|
|
@@ -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", steps: [] },
|
|
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", steps: [] },
|
|
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
|
package/src/server/dashboard.ts
CHANGED
|
@@ -31,6 +31,7 @@ export type DashboardPayload = {
|
|
|
31
31
|
toolCalls: number
|
|
32
32
|
lastTool: string
|
|
33
33
|
timeline: string
|
|
34
|
+
sessionId: string | null
|
|
34
35
|
}>
|
|
35
36
|
timeSeries: TimeSeriesPayload
|
|
36
37
|
raw: unknown
|
|
@@ -161,6 +162,7 @@ export function buildDashboardPayload(opts: {
|
|
|
161
162
|
toolCalls: t.toolCalls ?? 0,
|
|
162
163
|
lastTool: t.lastTool ?? "-",
|
|
163
164
|
timeline: typeof t.timeline === "string" ? t.timeline : "",
|
|
165
|
+
sessionId: t.sessionId ?? null,
|
|
164
166
|
})),
|
|
165
167
|
timeSeries,
|
|
166
168
|
raw: null,
|
package/src/server/dev.ts
CHANGED
|
@@ -26,14 +26,16 @@ const resolvedProjectPath = projectPath ?? process.cwd()
|
|
|
26
26
|
|
|
27
27
|
const app = new Hono()
|
|
28
28
|
|
|
29
|
+
const storageRoot = getOpenCodeStorageDir()
|
|
30
|
+
|
|
29
31
|
const store = createDashboardStore({
|
|
30
32
|
projectRoot: resolvedProjectPath,
|
|
31
|
-
storageRoot
|
|
33
|
+
storageRoot,
|
|
32
34
|
watch: true,
|
|
33
35
|
pollIntervalMs: 2000,
|
|
34
36
|
})
|
|
35
37
|
|
|
36
|
-
app.route("/api", createApi(store))
|
|
38
|
+
app.route("/api", createApi({ store, storageRoot }))
|
|
37
39
|
|
|
38
40
|
Bun.serve({
|
|
39
41
|
fetch: app.fetch,
|
package/src/server/start.ts
CHANGED
|
@@ -21,14 +21,16 @@ const port = parseInt(values.port || '51234')
|
|
|
21
21
|
|
|
22
22
|
const app = new Hono()
|
|
23
23
|
|
|
24
|
+
const storageRoot = getOpenCodeStorageDir()
|
|
25
|
+
|
|
24
26
|
const store = createDashboardStore({
|
|
25
27
|
projectRoot: project,
|
|
26
|
-
storageRoot
|
|
28
|
+
storageRoot,
|
|
27
29
|
watch: true,
|
|
28
30
|
pollIntervalMs: 2000,
|
|
29
31
|
})
|
|
30
32
|
|
|
31
|
-
app.route('/api', createApi(store))
|
|
33
|
+
app.route('/api', createApi({ store, storageRoot }))
|
|
32
34
|
|
|
33
35
|
const distRoot = join(import.meta.dir, '../../dist')
|
|
34
36
|
|