deepspace 0.19.5 → 0.21.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/CHANGELOG.md +24 -0
- package/dist/{chunk-A7UFIOWO.js → chunk-KDCDRHBA.js} +7 -2
- package/dist/chunk-KDCDRHBA.js.map +1 -0
- package/dist/cli.js +219 -94
- package/dist/cli.js.map +1 -1
- package/dist/documentation-client-core.d.ts +3 -2
- package/dist/documentation-client-core.js +1 -1
- package/dist/documentation-default-renderer.cjs +48 -48
- package/dist/documentation-react.d.ts +2 -1
- package/dist/documentation-react.js +1 -1
- package/dist/documentation-runtime.js +7 -7
- package/dist/documentation-server-core.d.ts +3 -2
- package/dist/documentation-server-core.js +1 -1
- package/dist/documentation.d.ts +40 -12
- package/dist/documentation.js +40 -7
- package/dist/documentation.js.map +1 -1
- package/dist/index.d.ts +225 -21
- package/dist/index.js +262 -112
- package/dist/index.js.map +1 -1
- package/dist/{public-CFAj1hL2.d.ts → public-DenQ1TsZ.d.ts} +36 -11
- package/dist/server.d.ts +46 -29
- package/dist/server.js +1041 -254
- package/dist/server.js.map +1 -1
- package/dist/testing-mcp.d.ts +69 -0
- package/dist/testing-mcp.js +99 -0
- package/dist/testing-mcp.js.map +1 -0
- package/dist/testing.d.ts +17 -20
- package/dist/testing.js +47 -25
- package/dist/testing.js.map +1 -1
- package/dist/worker.d.ts +73 -32
- package/dist/worker.js +1094 -244
- package/dist/worker.js.map +1 -1
- package/features/ai-chat/feature.json +2 -2
- package/features/ai-chat/src/AiChatPage.tsx +56 -21
- package/features/ai-chat/src/ChatPanel.tsx +33 -10
- package/features/ai-chat/src/__tests__/ChatPanel.lifecycle.test.tsx +156 -0
- package/features/cron/src/CronLogPage.tsx +107 -69
- package/features/file-attachments/feature.json +2 -1
- package/features/file-manager/feature.json +2 -1
- package/features/file-manager/src/FileManagerPage.tsx +227 -194
- package/features/integration-test/src/IntegrationTestPage.tsx +54 -34
- package/package.json +5 -1
- package/dist/chunk-A7UFIOWO.js.map +0 -1
|
@@ -29,13 +29,13 @@
|
|
|
29
29
|
"highlight.js": "^11.10.0"
|
|
30
30
|
},
|
|
31
31
|
"instructions": [
|
|
32
|
-
"Embed ChatPanel standalone (without the conversation rail) for sidebar / modal / popover use. Pass `chatId={null}` to auto-create on first send;
|
|
32
|
+
"Embed ChatPanel standalone (without the conversation rail) for sidebar / modal / popover use. Pass `chatId={null}` to auto-create on first send — the panel keeps the created chat itself; `onChatCreated` is an optional notification for parents that track ids.",
|
|
33
33
|
"All chat data lives in the app's RecordRoom DO under the ai-chats and ai-messages collections. RBAC is enforced — users only see their own chats and messages.",
|
|
34
34
|
"The backend route POST /api/ai/chat takes { chatId, userMessageId, content, modelId? } and runs the full server-owned pipeline: load history, compact if needed, stream assistant, persist on finish.",
|
|
35
35
|
"Compaction (summarizing older history when the conversation grows large) is server-side and cached on the chat row. No client-side compaction logic."
|
|
36
36
|
],
|
|
37
37
|
"patterns": [
|
|
38
|
-
"ChatPanel takes `chatId` (or null).
|
|
38
|
+
"ChatPanel takes `chatId` (or null, in which case the panel creates and keeps its own chat — remount with `key` for a new one). A parent that manages chats (create / select / delete) passes real ids, and `disabled` while a parent-driven create is in flight so the panel doesn't kick off its own auto-create.",
|
|
39
39
|
"ChatPanel and the server agent both consume the SDK's canonical model catalog; never add a second model allowlist in app code.",
|
|
40
40
|
"Compaction summary is stored on the ai-chats row (compactedSummary, compactedThroughId). The summarizer is user-billed by default; pass `makeDefaultSummarizer(c.env)` with no authToken in worker.ts to absorb the cost as the developer instead.",
|
|
41
41
|
"Tool execution is server-side and per-user RBAC-enforced (records.* tools see only what the caller would see). The canonical runner enforces at most 20 tool calls per turn."
|
|
@@ -82,6 +82,9 @@ export default function AiChatPage() {
|
|
|
82
82
|
const [dragging, setDragging] = useState(false)
|
|
83
83
|
const [creatingChat, setCreatingChat] = useState(false)
|
|
84
84
|
const [createError, setCreateError] = useState<string | null>(null)
|
|
85
|
+
// Delete/rename failures live apart from createError: its Retry re-runs
|
|
86
|
+
// create, which is the wrong offer after a failed delete or rename.
|
|
87
|
+
const [actionError, setActionError] = useState<string | null>(null)
|
|
85
88
|
|
|
86
89
|
const chatCardRef = useRef<HTMLDivElement>(null)
|
|
87
90
|
|
|
@@ -193,6 +196,7 @@ export default function AiChatPage() {
|
|
|
193
196
|
// any in-flight stream when activeChatId flips id→null, so the orphan
|
|
194
197
|
// assistant write we'd otherwise produce on cascade-delete is prevented
|
|
195
198
|
// by F1 (abort on id→null) — not by ordering this call before the fetch.
|
|
199
|
+
setActionError(null)
|
|
196
200
|
try {
|
|
197
201
|
const token = await getAuthToken()
|
|
198
202
|
const headers: Record<string, string> = {}
|
|
@@ -202,11 +206,12 @@ export default function AiChatPage() {
|
|
|
202
206
|
setActiveChatId((cur) => (cur === id ? null : cur))
|
|
203
207
|
} catch (err) {
|
|
204
208
|
console.error('[ai-chat-page] delete failed:', err)
|
|
205
|
-
|
|
209
|
+
setActionError(err instanceof Error ? `Couldn't delete chat: ${err.message}` : "Couldn't delete chat")
|
|
206
210
|
}
|
|
207
211
|
}, [])
|
|
208
212
|
|
|
209
213
|
const handleRename = useCallback(async (id: string, title: string) => {
|
|
214
|
+
setActionError(null)
|
|
210
215
|
try {
|
|
211
216
|
const token = await getAuthToken()
|
|
212
217
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
|
@@ -219,6 +224,7 @@ export default function AiChatPage() {
|
|
|
219
224
|
if (!res.ok) throw new Error(`rename failed: ${res.status}`)
|
|
220
225
|
} catch (err) {
|
|
221
226
|
console.error('[ai-chat-page] rename failed:', err)
|
|
227
|
+
setActionError(err instanceof Error ? `Couldn't rename chat: ${err.message}` : "Couldn't rename chat")
|
|
222
228
|
}
|
|
223
229
|
}, [])
|
|
224
230
|
|
|
@@ -303,26 +309,17 @@ export default function AiChatPage() {
|
|
|
303
309
|
onHistory={() => setHistoryOpen(true)}
|
|
304
310
|
/>
|
|
305
311
|
{createError && (
|
|
306
|
-
<
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
type="button"
|
|
318
|
-
onClick={() => setCreateError(null)}
|
|
319
|
-
aria-label="Dismiss"
|
|
320
|
-
className="rounded-md px-1 text-[14px] leading-none hover:bg-destructive/10"
|
|
321
|
-
>
|
|
322
|
-
×
|
|
323
|
-
</button>
|
|
324
|
-
</div>
|
|
325
|
-
</div>
|
|
312
|
+
<ErrorBanner
|
|
313
|
+
message={createError}
|
|
314
|
+
onRetry={() => { void handleNew() }}
|
|
315
|
+
onDismiss={() => setCreateError(null)}
|
|
316
|
+
/>
|
|
317
|
+
)}
|
|
318
|
+
{actionError && (
|
|
319
|
+
<ErrorBanner
|
|
320
|
+
message={actionError}
|
|
321
|
+
onDismiss={() => setActionError(null)}
|
|
322
|
+
/>
|
|
326
323
|
)}
|
|
327
324
|
</>
|
|
328
325
|
}
|
|
@@ -358,6 +355,44 @@ export default function AiChatPage() {
|
|
|
358
355
|
)
|
|
359
356
|
}
|
|
360
357
|
|
|
358
|
+
// ============================================================================
|
|
359
|
+
// Error banner — dismissible destructive strip under the header. Retry is
|
|
360
|
+
// opt-in so a failed delete/rename never offers an unrelated create action.
|
|
361
|
+
// ============================================================================
|
|
362
|
+
|
|
363
|
+
function ErrorBanner({
|
|
364
|
+
message, onRetry, onDismiss,
|
|
365
|
+
}: {
|
|
366
|
+
message: string
|
|
367
|
+
onRetry?: () => void
|
|
368
|
+
onDismiss: () => void
|
|
369
|
+
}) {
|
|
370
|
+
return (
|
|
371
|
+
<div role="alert" className="mx-4 mb-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-[13px] text-destructive">
|
|
372
|
+
<div className="flex items-start gap-2">
|
|
373
|
+
<span className="flex-1 leading-relaxed">{message}</span>
|
|
374
|
+
{onRetry && (
|
|
375
|
+
<button
|
|
376
|
+
type="button"
|
|
377
|
+
onClick={onRetry}
|
|
378
|
+
className="rounded-md border border-destructive/30 px-2 py-0.5 text-[12px] font-medium hover:bg-destructive/10"
|
|
379
|
+
>
|
|
380
|
+
Retry
|
|
381
|
+
</button>
|
|
382
|
+
)}
|
|
383
|
+
<button
|
|
384
|
+
type="button"
|
|
385
|
+
onClick={onDismiss}
|
|
386
|
+
aria-label="Dismiss"
|
|
387
|
+
className="rounded-md px-1 text-[14px] leading-none hover:bg-destructive/10"
|
|
388
|
+
>
|
|
389
|
+
×
|
|
390
|
+
</button>
|
|
391
|
+
</div>
|
|
392
|
+
</div>
|
|
393
|
+
)
|
|
394
|
+
}
|
|
395
|
+
|
|
361
396
|
// ============================================================================
|
|
362
397
|
// Chat header bar — title on the left, action icons on the right.
|
|
363
398
|
// Mirrors Miyagi's FloatingChatHeader (`title | new chat | history | close`).
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* DO-backed AI chat surface.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Pass `chatId={null}` to create a chat on first send. The panel keeps the
|
|
5
|
+
* created id itself; `onChatCreated` merely notifies, and a parent-passed
|
|
6
|
+
* `chatId` — including a change back to null for "new chat" — always wins.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
import {
|
|
10
|
+
useCallback,
|
|
9
11
|
useEffect,
|
|
10
12
|
useMemo,
|
|
11
13
|
useRef,
|
|
@@ -46,11 +48,11 @@ type RenderMessage = {
|
|
|
46
48
|
}
|
|
47
49
|
|
|
48
50
|
export type ChatPanelProps = {
|
|
49
|
-
/** Active chat. `null` creates one on first send. */
|
|
51
|
+
/** Active chat. `null` creates one on first send and the panel keeps it — a never-passed `chatId` means one chat per mount; remount with `key` for a new one. */
|
|
50
52
|
chatId: string | null
|
|
51
53
|
/** Current user id; scopes the messages query as defense in depth. */
|
|
52
54
|
userId: string
|
|
53
|
-
/**
|
|
55
|
+
/** Notified with the id when the panel creates a chat on first send. */
|
|
54
56
|
onChatCreated?: (chatId: string) => void
|
|
55
57
|
/** Models shown in the picker. */
|
|
56
58
|
models?: ModelOption[]
|
|
@@ -108,15 +110,36 @@ export function ChatPanel({
|
|
|
108
110
|
|
|
109
111
|
const groupedModels = useMemo(() => groupModelsByProvider(models), [models])
|
|
110
112
|
const selectedModel = models.find((model) => model.id === modelId)
|
|
113
|
+
|
|
114
|
+
// Uncontrolled fallback: with `chatId={null}` the panel keeps the chat it
|
|
115
|
+
// auto-creates on first send, so the overlay, the messages query, and later
|
|
116
|
+
// sends all target that chat. Any prop change — including back to null,
|
|
117
|
+
// which means "new chat" — takes precedence and clears it.
|
|
118
|
+
const [ownChatId, setOwnChatId] = useState<string | null>(null)
|
|
119
|
+
const [seenChatId, setSeenChatId] = useState(chatId)
|
|
120
|
+
if (seenChatId !== chatId) {
|
|
121
|
+
setSeenChatId(chatId)
|
|
122
|
+
setOwnChatId(null)
|
|
123
|
+
}
|
|
124
|
+
const activeChatId = chatId ?? ownChatId
|
|
125
|
+
|
|
126
|
+
const handleChatCreated = useCallback(
|
|
127
|
+
(id: string) => {
|
|
128
|
+
setOwnChatId(id)
|
|
129
|
+
onChatCreated?.(id)
|
|
130
|
+
},
|
|
131
|
+
[onChatCreated],
|
|
132
|
+
)
|
|
133
|
+
|
|
111
134
|
const { send, stop, retry, isLoading, error, inFlight } = useStreamingChat({
|
|
112
|
-
chatId,
|
|
135
|
+
chatId: activeChatId,
|
|
113
136
|
modelId,
|
|
114
|
-
onChatCreated,
|
|
137
|
+
onChatCreated: handleChatCreated,
|
|
115
138
|
})
|
|
116
139
|
|
|
117
140
|
const queryWhere = useMemo(
|
|
118
|
-
() => ({ chatId:
|
|
119
|
-
[
|
|
141
|
+
() => ({ chatId: activeChatId ?? '__none__', userId }),
|
|
142
|
+
[activeChatId, userId],
|
|
120
143
|
)
|
|
121
144
|
const { records } = useQuery<AiMessageData>('ai-messages', {
|
|
122
145
|
where: queryWhere,
|
|
@@ -138,12 +161,12 @@ export function ChatPanel({
|
|
|
138
161
|
)
|
|
139
162
|
const messages = useMemo<RenderMessage[]>(() => {
|
|
140
163
|
const overlay = inFlight.filter((message) => {
|
|
141
|
-
if (message.forChatId !==
|
|
164
|
+
if (message.forChatId !== activeChatId) return false
|
|
142
165
|
if (persistedIds.has(message.id)) return false
|
|
143
166
|
return !message.serverId || !persistedIds.has(message.serverId)
|
|
144
167
|
})
|
|
145
168
|
return [...persisted, ...overlay]
|
|
146
|
-
}, [
|
|
169
|
+
}, [activeChatId, inFlight, persisted, persistedIds])
|
|
147
170
|
|
|
148
171
|
useEffect(() => {
|
|
149
172
|
const element = scrollRef.current
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { act } from 'react'
|
|
4
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
6
|
+
import { ChatPanel } from '../ChatPanel'
|
|
7
|
+
|
|
8
|
+
const { queryWheres } = vi.hoisted(() => ({
|
|
9
|
+
queryWheres: [] as Array<{ chatId?: string; userId?: string }>,
|
|
10
|
+
}))
|
|
11
|
+
|
|
12
|
+
vi.mock('deepspace', async () => {
|
|
13
|
+
// The SSE codec is pure; run the real one so the stubbed wire bytes below
|
|
14
|
+
// exercise the same decode path production streams take.
|
|
15
|
+
const stream = await vi.importActual<typeof import('@/client/ai-stream')>('@/client/ai-stream')
|
|
16
|
+
return {
|
|
17
|
+
decodeAiStreamChunk: stream.decodeAiStreamChunk,
|
|
18
|
+
parseSseLine: stream.parseSseLine,
|
|
19
|
+
getAuthToken: async () => null,
|
|
20
|
+
listDeepSpaceAgentModels: () => [
|
|
21
|
+
{ id: 'model-a', label: 'Model A', providerLabel: 'Provider' },
|
|
22
|
+
],
|
|
23
|
+
useQuery: (_collection: string, options: { where: { chatId?: string; userId?: string } }) => {
|
|
24
|
+
queryWheres.push(options.where)
|
|
25
|
+
return { records: [] as Array<{ recordId: string; data: Record<string, unknown> }> }
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
// The turn renderer pulls app-level markdown deps (react-markdown,
|
|
31
|
+
// remark-breaks) that feature.json ships to scaffolded apps but the SDK
|
|
32
|
+
// workspace does not install. This suite is about ChatPanel.tsx's chat
|
|
33
|
+
// lifecycle, not rendering — substitute minimal, queryable turns.
|
|
34
|
+
vi.mock('../ChatPanel.messages', () => ({
|
|
35
|
+
EmptyState: () => <div data-empty-state="">Empty state</div>,
|
|
36
|
+
MessageTurn: ({ role, content }: { role: string; content: string }) => (
|
|
37
|
+
<div data-turn={role}>{content}</div>
|
|
38
|
+
),
|
|
39
|
+
ThinkingIndicator: () => <div data-thinking="" />,
|
|
40
|
+
}))
|
|
41
|
+
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
|
|
42
|
+
|
|
43
|
+
let container: HTMLDivElement
|
|
44
|
+
let root: Root
|
|
45
|
+
let parentRenders = 0
|
|
46
|
+
let chatCreates = 0
|
|
47
|
+
let streamBodies: Array<Record<string, unknown>> = []
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A parent stuck at `chatId={null}` — no `onChatCreated`, no state. The panel
|
|
51
|
+
* must run the whole first-chat lifecycle without a single parent rerender.
|
|
52
|
+
*/
|
|
53
|
+
function Parent() {
|
|
54
|
+
parentRenders += 1
|
|
55
|
+
return <ChatPanel chatId={null} userId="user-1" />
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
parentRenders = 0
|
|
60
|
+
chatCreates = 0
|
|
61
|
+
streamBodies = []
|
|
62
|
+
queryWheres.length = 0
|
|
63
|
+
|
|
64
|
+
window.requestAnimationFrame = (callback) => window.setTimeout(callback, 0)
|
|
65
|
+
window.cancelAnimationFrame = (handle) => window.clearTimeout(handle)
|
|
66
|
+
// jsdom implements no element scrolling; the panel pins its log to the bottom.
|
|
67
|
+
window.HTMLElement.prototype.scrollTo = () => {}
|
|
68
|
+
|
|
69
|
+
vi.stubGlobal(
|
|
70
|
+
'fetch',
|
|
71
|
+
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
72
|
+
const url = String(input)
|
|
73
|
+
if (url === '/api/ai/chats') {
|
|
74
|
+
chatCreates += 1
|
|
75
|
+
return Response.json({ chat: { recordId: 'chat-new' } })
|
|
76
|
+
}
|
|
77
|
+
if (url === '/api/ai/chat') {
|
|
78
|
+
streamBodies.push(JSON.parse(String(init?.body)) as Record<string, unknown>)
|
|
79
|
+
return new Response(
|
|
80
|
+
`data: ${JSON.stringify({ type: 'text-delta', delta: 'Hi.' })}\n\ndata: [DONE]\n\n`,
|
|
81
|
+
{ headers: { 'X-Asst-Id': `asst-${streamBodies.length}` } },
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
throw new Error(`unexpected fetch: ${url}`)
|
|
85
|
+
}),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
container = document.createElement('div')
|
|
89
|
+
document.body.appendChild(container)
|
|
90
|
+
root = createRoot(container)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
afterEach(() => {
|
|
94
|
+
act(() => root.unmount())
|
|
95
|
+
container.remove()
|
|
96
|
+
vi.unstubAllGlobals()
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
/** Drain the send's microtask chain (create → stream → settle) until idle. */
|
|
100
|
+
async function flushUntil(ready: () => boolean): Promise<void> {
|
|
101
|
+
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
102
|
+
if (ready()) return
|
|
103
|
+
await act(async () => {
|
|
104
|
+
await Promise.resolve()
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
throw new Error('panel never settled after send')
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function sendMessage(content: string): Promise<void> {
|
|
111
|
+
const textarea = container.querySelector('textarea')
|
|
112
|
+
expect(textarea).not.toBeNull()
|
|
113
|
+
await act(async () => {
|
|
114
|
+
const setValue = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set
|
|
115
|
+
setValue?.call(textarea, content)
|
|
116
|
+
textarea?.dispatchEvent(new Event('input', { bubbles: true }))
|
|
117
|
+
})
|
|
118
|
+
await act(async () => {
|
|
119
|
+
container.querySelector('form')?.dispatchEvent(
|
|
120
|
+
new Event('submit', { bubbles: true, cancelable: true }),
|
|
121
|
+
)
|
|
122
|
+
})
|
|
123
|
+
await flushUntil(() => container.querySelector('[aria-label="Send message"]') !== null)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
describe('ChatPanel first-chat lifecycle with chatId={null}', () => {
|
|
127
|
+
it('renders the first exchange from its own chat without a parent rerender', async () => {
|
|
128
|
+
await act(async () => root.render(<Parent />))
|
|
129
|
+
expect(container.querySelector('[data-empty-state]')).not.toBeNull()
|
|
130
|
+
|
|
131
|
+
await sendMessage('Hello from a fresh mount')
|
|
132
|
+
|
|
133
|
+
expect(container.querySelector('[data-empty-state]')).toBeNull()
|
|
134
|
+
const turns = [...container.querySelectorAll('[data-turn]')]
|
|
135
|
+
expect(turns.map((turn) => turn.getAttribute('data-turn'))).toEqual(['user', 'assistant'])
|
|
136
|
+
expect(turns[0]?.textContent).toBe('Hello from a fresh mount')
|
|
137
|
+
expect(turns[1]?.textContent).toBe('Hi.')
|
|
138
|
+
expect(parentRenders).toBe(1)
|
|
139
|
+
|
|
140
|
+
// The persisted-messages query re-keys onto the promoted chat id.
|
|
141
|
+
expect(queryWheres[0]?.chatId).toBe('__none__')
|
|
142
|
+
expect(queryWheres.at(-1)?.chatId).toBe('chat-new')
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('reuses the promoted chat for the second send instead of creating another', async () => {
|
|
146
|
+
await act(async () => root.render(<Parent />))
|
|
147
|
+
|
|
148
|
+
await sendMessage('First message')
|
|
149
|
+
await sendMessage('Second message')
|
|
150
|
+
|
|
151
|
+
expect(chatCreates).toBe(1)
|
|
152
|
+
expect(streamBodies.map((body) => body.chatId)).toEqual(['chat-new', 'chat-new'])
|
|
153
|
+
const userTurns = [...container.querySelectorAll('[data-turn="user"]')]
|
|
154
|
+
expect(userTurns.at(-1)?.textContent).toBe('Second message')
|
|
155
|
+
})
|
|
156
|
+
})
|
|
@@ -2,20 +2,26 @@
|
|
|
2
2
|
* Cron Log Page
|
|
3
3
|
*
|
|
4
4
|
* Subscribes to the app's CronRoom via `useCronMonitor` and renders live
|
|
5
|
-
* task state + execution history
|
|
6
|
-
* (`app:${APP_ID}`) so a
|
|
7
|
-
* whole app — same pattern
|
|
5
|
+
* task state + execution history with the scaffold's ui primitives and
|
|
6
|
+
* theme tokens. The DO is keyed by `SCOPE_ID` (`app:${APP_ID}`) so a
|
|
7
|
+
* single shared CronRoom DO instance backs the whole app — same pattern
|
|
8
|
+
* as RecordRoom.
|
|
8
9
|
*
|
|
9
10
|
* Used both as a UI surface for verifying that scheduled tasks are firing
|
|
10
11
|
* in production, and as the data source for the cron e2e spec at
|
|
11
|
-
* tests/feature-tests/tests/cron.spec.ts
|
|
12
|
+
* tests/feature-tests/tests/cron.spec.ts — keep the data-testid and
|
|
13
|
+
* data-* attributes stable.
|
|
12
14
|
*/
|
|
13
15
|
|
|
14
16
|
import { useCronMonitor } from 'deepspace'
|
|
17
|
+
import { Badge, EmptyState } from '@/components/ui'
|
|
15
18
|
import { SCOPE_ID } from '../constants'
|
|
16
19
|
|
|
20
|
+
const TH = 'px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-muted-foreground'
|
|
21
|
+
const TD = 'px-4 py-3 text-sm'
|
|
22
|
+
|
|
17
23
|
export default function CronLogPage() {
|
|
18
|
-
const { tasks, history, connected } = useCronMonitor(SCOPE_ID)
|
|
24
|
+
const { tasks, history, connected, lastError } = useCronMonitor(SCOPE_ID)
|
|
19
25
|
|
|
20
26
|
// Newest first.
|
|
21
27
|
const sorted = [...history].sort((a, b) => {
|
|
@@ -23,77 +29,109 @@ export default function CronLogPage() {
|
|
|
23
29
|
})
|
|
24
30
|
|
|
25
31
|
return (
|
|
26
|
-
<div data-testid="cron-log-page"
|
|
27
|
-
<
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
32
|
+
<div data-testid="cron-log-page" className="mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8">
|
|
33
|
+
<div className="flex flex-wrap items-start justify-between gap-4">
|
|
34
|
+
<div>
|
|
35
|
+
<h1 className="text-2xl font-bold text-foreground">Cron Log</h1>
|
|
36
|
+
<p className="mt-1 max-w-xl text-sm text-muted-foreground">
|
|
37
|
+
Each row is one tick of a scheduled task fired by the AppCronRoom DO.
|
|
38
|
+
Heartbeat ticks every minute once the DO alarm picks up the registered
|
|
39
|
+
config.
|
|
40
|
+
</p>
|
|
41
|
+
</div>
|
|
42
|
+
<div
|
|
43
|
+
data-testid="cron-log-status"
|
|
44
|
+
className="flex shrink-0 items-center gap-2 text-sm text-muted-foreground"
|
|
45
|
+
>
|
|
46
|
+
Connection:
|
|
47
|
+
<Badge variant={connected ? 'success' : 'secondary'}>
|
|
48
|
+
{connected ? 'live' : 'connecting…'}
|
|
49
|
+
</Badge>
|
|
50
|
+
</div>
|
|
36
51
|
</div>
|
|
37
52
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
<th style={{ textAlign: 'left', borderBottom: '1px solid var(--color-border)', padding: 8 }}>Schedule</th>
|
|
44
|
-
<th style={{ textAlign: 'left', borderBottom: '1px solid var(--color-border)', padding: 8 }}>Last run</th>
|
|
45
|
-
<th style={{ textAlign: 'left', borderBottom: '1px solid var(--color-border)', padding: 8 }}>Next run</th>
|
|
46
|
-
</tr>
|
|
47
|
-
</thead>
|
|
48
|
-
<tbody>
|
|
49
|
-
{tasks.map((task) => (
|
|
50
|
-
<tr key={task.name} data-testid="cron-task-row" data-task={task.name}>
|
|
51
|
-
<td style={{ padding: 8 }}>{task.name}</td>
|
|
52
|
-
<td style={{ padding: 8 }}>
|
|
53
|
-
{task.schedule
|
|
54
|
-
? `${task.schedule} (${task.timezone ?? 'UTC'})`
|
|
55
|
-
: `every ${task.intervalMinutes ?? '?'} min`}
|
|
56
|
-
</td>
|
|
57
|
-
<td style={{ padding: 8 }}>{task.lastRunAt ?? '—'}</td>
|
|
58
|
-
<td style={{ padding: 8 }}>{task.nextRunAt ?? '—'}</td>
|
|
59
|
-
</tr>
|
|
60
|
-
))}
|
|
61
|
-
</tbody>
|
|
62
|
-
</table>
|
|
53
|
+
{lastError && (
|
|
54
|
+
<p data-testid="cron-log-error" className="mt-2 text-sm text-destructive">
|
|
55
|
+
error: {lastError}
|
|
56
|
+
</p>
|
|
57
|
+
)}
|
|
63
58
|
|
|
64
|
-
<h2
|
|
65
|
-
<div
|
|
59
|
+
<h2 className="mt-8 text-lg font-semibold text-foreground">Tasks</h2>
|
|
60
|
+
<div className="mt-3 overflow-x-auto rounded-xl border border-border bg-card/60">
|
|
61
|
+
<table data-testid="cron-tasks" className="w-full min-w-160">
|
|
62
|
+
<thead>
|
|
63
|
+
<tr className="border-b border-border">
|
|
64
|
+
<th className={TH}>Name</th>
|
|
65
|
+
<th className={TH}>Schedule</th>
|
|
66
|
+
<th className={TH}>Last run</th>
|
|
67
|
+
<th className={TH}>Next run</th>
|
|
68
|
+
</tr>
|
|
69
|
+
</thead>
|
|
70
|
+
<tbody className="divide-y divide-border/50">
|
|
71
|
+
{tasks.map((task) => (
|
|
72
|
+
<tr key={task.name} data-testid="cron-task-row" data-task={task.name}>
|
|
73
|
+
<td className={`${TD} font-medium text-foreground`}>{task.name}</td>
|
|
74
|
+
<td className={`${TD} text-foreground`}>
|
|
75
|
+
{task.schedule
|
|
76
|
+
? `${task.schedule} (${task.timezone ?? 'UTC'})`
|
|
77
|
+
: `every ${task.intervalMinutes ?? '?'} min`}
|
|
78
|
+
</td>
|
|
79
|
+
<td className={`${TD} font-mono text-muted-foreground`}>{task.lastRunAt ?? '—'}</td>
|
|
80
|
+
<td className={`${TD} font-mono text-muted-foreground`}>{task.nextRunAt ?? '—'}</td>
|
|
81
|
+
</tr>
|
|
82
|
+
))}
|
|
83
|
+
</tbody>
|
|
84
|
+
</table>
|
|
85
|
+
</div>
|
|
66
86
|
|
|
67
|
-
<
|
|
68
|
-
<
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
data-task={entry.taskName}
|
|
82
|
-
data-success={entry.success ? '1' : '0'}
|
|
83
|
-
>
|
|
84
|
-
<td style={{ padding: 8 }}>{entry.taskName}</td>
|
|
85
|
-
<td style={{ padding: 8 }}>{entry.startedAt}</td>
|
|
86
|
-
<td style={{ padding: 8 }}>{entry.durationMs} ms</td>
|
|
87
|
-
<td style={{ padding: 8 }}>{entry.success ? 'ok' : `error: ${entry.error ?? '?'}`}</td>
|
|
87
|
+
<div className="mt-8 flex items-baseline justify-between gap-4">
|
|
88
|
+
<h2 className="text-lg font-semibold text-foreground">History</h2>
|
|
89
|
+
<span data-testid="cron-log-count" className="text-sm text-muted-foreground">
|
|
90
|
+
{sorted.length} entries
|
|
91
|
+
</span>
|
|
92
|
+
</div>
|
|
93
|
+
<div className="mt-3 overflow-x-auto rounded-xl border border-border bg-card/60">
|
|
94
|
+
<table className="w-full min-w-160">
|
|
95
|
+
<thead>
|
|
96
|
+
<tr className="border-b border-border">
|
|
97
|
+
<th className={TH}>Task</th>
|
|
98
|
+
<th className={TH}>Started (UTC)</th>
|
|
99
|
+
<th className={TH}>Duration</th>
|
|
100
|
+
<th className={TH}>Outcome</th>
|
|
88
101
|
</tr>
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
102
|
+
</thead>
|
|
103
|
+
<tbody data-testid="cron-log-rows" className="divide-y divide-border/50">
|
|
104
|
+
{sorted.map((entry, idx) => (
|
|
105
|
+
<tr
|
|
106
|
+
key={`${entry.taskName}-${entry.startedAt}-${idx}`}
|
|
107
|
+
data-testid="cron-log-row"
|
|
108
|
+
data-task={entry.taskName}
|
|
109
|
+
data-success={entry.success ? '1' : '0'}
|
|
110
|
+
>
|
|
111
|
+
<td className={`${TD} font-medium text-foreground`}>{entry.taskName}</td>
|
|
112
|
+
<td className={`${TD} font-mono text-muted-foreground`}>{entry.startedAt}</td>
|
|
113
|
+
<td className={`${TD} text-foreground`}>{entry.durationMs} ms</td>
|
|
114
|
+
<td className={TD}>
|
|
115
|
+
{entry.success ? (
|
|
116
|
+
<Badge variant="success" size="sm">
|
|
117
|
+
ok
|
|
118
|
+
</Badge>
|
|
119
|
+
) : (
|
|
120
|
+
<span className="text-destructive">error: {entry.error ?? '?'}</span>
|
|
121
|
+
)}
|
|
122
|
+
</td>
|
|
123
|
+
</tr>
|
|
124
|
+
))}
|
|
125
|
+
</tbody>
|
|
126
|
+
</table>
|
|
127
|
+
</div>
|
|
92
128
|
|
|
93
129
|
{sorted.length === 0 && connected && (
|
|
94
|
-
<
|
|
95
|
-
|
|
96
|
-
|
|
130
|
+
<EmptyState
|
|
131
|
+
data-testid="cron-log-empty"
|
|
132
|
+
title="No cron ticks recorded yet"
|
|
133
|
+
description="The first one should appear within ~90s of deploy."
|
|
134
|
+
/>
|
|
97
135
|
)}
|
|
98
136
|
</div>
|
|
99
137
|
)
|
|
@@ -38,7 +38,8 @@
|
|
|
38
38
|
"patterns": [
|
|
39
39
|
"useMimeTypeDetection() → { detectMimeType, getFileCategory, getExtension, canPreview }",
|
|
40
40
|
"useFilePreview({ url, fileName, mimeType }) → { category, canPreview, textContent, csvData, codeEditorKit, xlsxData, ... }",
|
|
41
|
-
"useR2Files() → { upload, downloadFile, readFile, deleteFile, list, getUrl, isUploading }",
|
|
41
|
+
"useR2Files() → { upload, downloadFile, readFile, deleteFile, list, listPage, getUrl, isUploading }",
|
|
42
|
+
"list() throws on failure (render the error); listPage() returns { files, cursor, truncated } for pagination; entries carry relativeKey",
|
|
42
43
|
"File metadata stored in collection, binary stored in R2",
|
|
43
44
|
"Dynamic imports: PDF.js for PDF, CodeMirror for code, PapaParse for CSV, docx-preview for DOCX, ExcelJS for XLSX"
|
|
44
45
|
]
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
"instructions": [],
|
|
19
19
|
"patterns": [
|
|
20
20
|
"useR2Files() for upload, list, download, delete",
|
|
21
|
+
"list() throws on failure (page renders the error with a retry); listPage() returns { files, cursor, truncated }; entries carry relativeKey",
|
|
21
22
|
"Drag-and-drop file upload zone",
|
|
22
|
-
"
|
|
23
|
+
"Authenticated inline image preview with readFile()"
|
|
23
24
|
]
|
|
24
25
|
}
|