experimental-a2 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/CHANGELOG.md +51 -0
- package/dist/{ai-B4YhEnfw.d.ts → ai-CFNeCrRl.d.ts} +3 -2
- package/dist/ai-CFNeCrRl.d.ts.map +1 -0
- package/dist/ai-server.browser.js +2 -0
- package/dist/ai-server.browser.js.map +1 -0
- package/dist/ai-server.d.ts +4 -3
- package/dist/ai-server.d.ts.map +1 -0
- package/dist/ai-server.js +4 -2
- package/dist/ai-server.js.map +1 -0
- package/dist/ai.d.ts +1 -1
- package/dist/ai.js +3 -1
- package/dist/ai.js.map +1 -0
- package/dist/cli-B3VuxoDe.js +2 -0
- package/dist/cli-B3VuxoDe.js.map +1 -0
- package/dist/cli-bin.js +2 -0
- package/dist/cli-bin.js.map +1 -0
- package/dist/cli.d.ts +2 -1
- package/dist/cli.d.ts.map +1 -0
- package/dist/{client-Bt4tAKi9.js → client-BKlyLiOU.js} +295 -85
- package/dist/client-BKlyLiOU.js.map +1 -0
- package/dist/{client-BrfDXQ8A.d.ts → client-D7mvIXrF.d.ts} +40 -4
- package/dist/client-D7mvIXrF.d.ts.map +1 -0
- package/dist/client.d.ts +2 -2
- package/dist/client.js +1 -1
- package/dist/contract-48bUMgcL.js +2 -0
- package/dist/contract-48bUMgcL.js.map +1 -0
- package/dist/contract-jIfaR085.d.ts +2 -1
- package/dist/contract-jIfaR085.d.ts.map +1 -0
- package/dist/devtools-J_jZ2vQf.d.ts +2 -1
- package/dist/devtools-J_jZ2vQf.d.ts.map +1 -0
- package/dist/devtools-kJJaORn-.js +2 -0
- package/dist/devtools-kJJaORn-.js.map +1 -0
- package/dist/devtools-server.browser.js +2 -0
- package/dist/devtools-server.browser.js.map +1 -0
- package/dist/devtools-server.d.ts +2 -1
- package/dist/devtools-server.d.ts.map +1 -0
- package/dist/devtools-server.js +2 -0
- package/dist/devtools-server.js.map +1 -0
- package/dist/errors-BQuJpe82.js +2 -0
- package/dist/errors-BQuJpe82.js.map +1 -0
- package/dist/errors-W6nwJ-fm.d.ts +2 -1
- package/dist/errors-W6nwJ-fm.d.ts.map +1 -0
- package/dist/http.d.ts +121 -72
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +503 -178
- package/dist/http.js.map +1 -0
- package/dist/idempotent-replay-DuqEkYA7.js +2 -0
- package/dist/idempotent-replay-DuqEkYA7.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/inspection-DaxB5jM2.js +2 -0
- package/dist/inspection-DaxB5jM2.js.map +1 -0
- package/dist/{internal-aEotMzu_.js → internal-DstsI6Re.js} +3 -1
- package/dist/internal-DstsI6Re.js.map +1 -0
- package/dist/otel.d.ts +2 -1
- package/dist/otel.d.ts.map +1 -0
- package/dist/otel.js +2 -0
- package/dist/otel.js.map +1 -0
- package/dist/platform-B4TnJtWu.js +2 -0
- package/dist/platform-B4TnJtWu.js.map +1 -0
- package/dist/react.d.ts +12 -3
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +5 -1
- package/dist/react.js.map +1 -0
- package/dist/retryable-lazy-DZWmHpii.js +2 -0
- package/dist/retryable-lazy-DZWmHpii.js.map +1 -0
- package/dist/scheduler-qstash.d.ts +4 -3
- package/dist/scheduler-qstash.d.ts.map +1 -0
- package/dist/scheduler-qstash.js +4 -2
- package/dist/scheduler-qstash.js.map +1 -0
- package/dist/scheduler-task-BpzhPnRS.js +2 -0
- package/dist/scheduler-task-BpzhPnRS.js.map +1 -0
- package/dist/scheduler-vercel.d.ts +4 -3
- package/dist/scheduler-vercel.d.ts.map +1 -0
- package/dist/scheduler-vercel.js +4 -2
- package/dist/scheduler-vercel.js.map +1 -0
- package/dist/{server-CcNnFnoW.js → server-C72KOw51.js} +100 -48
- package/dist/server-C72KOw51.js.map +1 -0
- package/dist/{server-YtPq7hjw.d.ts → server-DUF9pjsx.d.ts} +5 -4
- package/dist/server-DUF9pjsx.d.ts.map +1 -0
- package/dist/server.browser.js +2 -0
- package/dist/server.browser.js.map +1 -0
- package/dist/server.d.ts +2 -2
- package/dist/server.js +1 -1
- package/dist/{store-C3sNAaBT.d.ts → store-DysUkTH3.d.ts} +10 -1
- package/dist/store-DysUkTH3.d.ts.map +1 -0
- package/dist/store-N8PXxDAS.js +2 -0
- package/dist/store-N8PXxDAS.js.map +1 -0
- package/dist/store-codec-DTG0Ftek.js +2 -0
- package/dist/store-codec-DTG0Ftek.js.map +1 -0
- package/dist/store-memory.d.ts +3 -2
- package/dist/store-memory.d.ts.map +1 -0
- package/dist/store-memory.js +19 -11
- package/dist/store-memory.js.map +1 -0
- package/dist/{store-polling-DgrrAE3d.js → store-polling-dSeLxzfb.js} +3 -1
- package/dist/store-polling-dSeLxzfb.js.map +1 -0
- package/dist/store-postgres.d.ts +3 -2
- package/dist/store-postgres.d.ts.map +1 -0
- package/dist/store-postgres.js +57 -1
- package/dist/store-postgres.js.map +1 -0
- package/dist/{store-redis-core-DWqx3F47.js → store-redis-core-BFLwz0Wj.js} +3 -1
- package/dist/store-redis-core-BFLwz0Wj.js.map +1 -0
- package/dist/store-redis-http.d.ts +3 -2
- package/dist/store-redis-http.d.ts.map +1 -0
- package/dist/store-redis-http.js +4 -2
- package/dist/store-redis-http.js.map +1 -0
- package/dist/store-redis.d.ts +3 -2
- package/dist/store-redis.d.ts.map +1 -0
- package/dist/store-redis.js +5 -3
- package/dist/store-redis.js.map +1 -0
- package/dist/store-sqlite.d.ts +3 -2
- package/dist/store-sqlite.d.ts.map +1 -0
- package/dist/store-sqlite.js +3 -1
- package/dist/store-sqlite.js.map +1 -0
- package/dist/telemetry-BjYHTfh2.d.ts +2 -1
- package/dist/telemetry-BjYHTfh2.d.ts.map +1 -0
- package/dist/testing.browser.js +2 -0
- package/dist/testing.browser.js.map +1 -0
- package/dist/testing.d.ts +2 -1
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +2 -0
- package/dist/testing.js.map +1 -0
- package/dist/validate-XKT4FSNn.js +2 -0
- package/dist/validate-XKT4FSNn.js.map +1 -0
- package/dist/{wire-DCUZBUlT.js → wire-BFQmSJ-9.js} +77 -15
- package/dist/wire-BFQmSJ-9.js.map +1 -0
- package/docs/guides/03-react.mdx +59 -39
- package/docs/guides/06-ai-agents.mdx +5 -27
- package/docs/guides/09-presence.mdx +19 -40
- package/docs/guides/10-transports.mdx +49 -40
- package/docs/reference/01-api.mdx +107 -26
- package/docs/reference/02-errors.mdx +4 -2
- package/package.json +2 -1
- package/src/ai-coordinator.ts +358 -0
- package/src/ai-projector.ts +524 -0
- package/src/ai-sdk-step.ts +261 -0
- package/src/ai-server.browser.ts +5 -0
- package/src/ai-server.ts +1719 -0
- package/src/ai.ts +2155 -0
- package/src/cache-indexeddb.ts +10 -0
- package/src/cli-bin.ts +5 -0
- package/src/cli.ts +1046 -0
- package/src/client.ts +1826 -0
- package/src/contract.ts +206 -0
- package/src/deterministic-id.ts +72 -0
- package/src/devtools-app.ts +989 -0
- package/src/devtools-server.browser.ts +5 -0
- package/src/devtools-server.ts +604 -0
- package/src/devtools.ts +716 -0
- package/src/errors.ts +50 -0
- package/src/http.ts +394 -0
- package/src/idempotent-replay.ts +53 -0
- package/src/index.ts +37 -0
- package/src/inspection.ts +39 -0
- package/src/internal.ts +426 -0
- package/src/otel.ts +59 -0
- package/src/platform.ts +60 -0
- package/src/push-envelope.ts +137 -0
- package/src/react.ts +284 -0
- package/src/reducer.ts +108 -0
- package/src/retryable-lazy.ts +27 -0
- package/src/scheduler-qstash.ts +915 -0
- package/src/scheduler-task.ts +106 -0
- package/src/scheduler-vercel.ts +437 -0
- package/src/server.browser.ts +12 -0
- package/src/server.ts +2700 -0
- package/src/session-socket.ts +548 -0
- package/src/sse.ts +141 -0
- package/src/standard-schema.ts +77 -0
- package/src/store-codec.ts +10 -0
- package/src/store-memory.ts +788 -0
- package/src/store-polling.ts +102 -0
- package/src/store-postgres.ts +1212 -0
- package/src/store-redis-core.ts +1494 -0
- package/src/store-redis-http.ts +116 -0
- package/src/store-redis.ts +458 -0
- package/src/store-sqlite.ts +1108 -0
- package/src/store.ts +385 -0
- package/src/telemetry.ts +47 -0
- package/src/testing.browser.ts +5 -0
- package/src/testing.ts +185 -0
- package/src/validate.ts +39 -0
- package/src/wire.ts +454 -0
|
@@ -0,0 +1,989 @@
|
|
|
1
|
+
/** Private browser application served by `experimental-a2/devtools/server`. */
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
DEVTOOLS_PROTOCOL_VERSION,
|
|
5
|
+
DEVTOOLS_SESSION_PAGE_LIMIT,
|
|
6
|
+
} from './devtools.ts'
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
DevtoolsSessionDetail,
|
|
10
|
+
DevtoolsWireSessionSummary,
|
|
11
|
+
} from './devtools.ts'
|
|
12
|
+
|
|
13
|
+
export type DevtoolsBootstrap = {
|
|
14
|
+
contracts: Array<{ name: string }>
|
|
15
|
+
contract: string | null
|
|
16
|
+
sessions: DevtoolsWireSessionSummary[]
|
|
17
|
+
cursor: string | null
|
|
18
|
+
session: string | null
|
|
19
|
+
detail: DevtoolsSessionDetail | null
|
|
20
|
+
error: string | null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const escapeText = (value: string): string =>
|
|
24
|
+
value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>')
|
|
25
|
+
|
|
26
|
+
const escapeAttribute = (value: string): string =>
|
|
27
|
+
escapeText(value).replaceAll('"', '"')
|
|
28
|
+
|
|
29
|
+
const formatDuration = (milliseconds: number): string => {
|
|
30
|
+
if (milliseconds < 1_000) {
|
|
31
|
+
return `${Math.max(0, Math.round(milliseconds))} ms`
|
|
32
|
+
}
|
|
33
|
+
if (milliseconds < 60_000) {
|
|
34
|
+
return `${(milliseconds / 1_000).toFixed(milliseconds < 10_000 ? 2 : 1)} s`
|
|
35
|
+
}
|
|
36
|
+
return `${(milliseconds / 60_000).toFixed(1)} min`
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const formatRelative = (value: string): string => {
|
|
40
|
+
const delta = Date.now() - new Date(value).getTime()
|
|
41
|
+
if (delta < 1_000) return 'now'
|
|
42
|
+
if (delta < 60_000) return `${Math.floor(delta / 1_000)}s ago`
|
|
43
|
+
if (delta < 3_600_000) return `${Math.floor(delta / 60_000)}m ago`
|
|
44
|
+
if (delta < 86_400_000) return `${Math.floor(delta / 3_600_000)}h ago`
|
|
45
|
+
return `${Math.floor(delta / 86_400_000)}d ago`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const sessionStatus = (
|
|
49
|
+
session: DevtoolsBootstrap['sessions'][number],
|
|
50
|
+
): 'failed' | 'pending' | 'settled' =>
|
|
51
|
+
session.failedCount > 0
|
|
52
|
+
? 'failed'
|
|
53
|
+
: session.pendingCount > 0
|
|
54
|
+
? 'pending'
|
|
55
|
+
: 'settled'
|
|
56
|
+
|
|
57
|
+
const metricHtml = (label: string, value: string): string =>
|
|
58
|
+
`<div class="card"><span>${escapeText(label)}</span><strong>${escapeText(value)}</strong></div>`
|
|
59
|
+
|
|
60
|
+
const shellHtml = (bootstrap: DevtoolsBootstrap): string => {
|
|
61
|
+
const options = bootstrap.contracts
|
|
62
|
+
.map(
|
|
63
|
+
({ name }) =>
|
|
64
|
+
`<option value="${escapeAttribute(name)}"${name === bootstrap.contract ? ' selected' : ''}>${escapeText(name)}</option>`,
|
|
65
|
+
)
|
|
66
|
+
.join('')
|
|
67
|
+
const sessions = bootstrap.sessions.length
|
|
68
|
+
? bootstrap.sessions
|
|
69
|
+
.map((session) => {
|
|
70
|
+
const active =
|
|
71
|
+
session.sessionId === bootstrap.session ? ' active' : ''
|
|
72
|
+
const events = `${session.eventCount} ${session.eventCount === 1 ? 'event' : 'events'}`
|
|
73
|
+
return `<button class="session-button${active}" type="button"><span class="session-name">${escapeText(session.sessionId)}</span><span class="dot ${sessionStatus(session)}"></span><span class="session-meta">${events}</span><span class="session-meta">${formatRelative(session.updatedAt)}</span></button>`
|
|
74
|
+
})
|
|
75
|
+
.join('')
|
|
76
|
+
: '<div class="session-meta">No durable sessions yet.</div>'
|
|
77
|
+
const more = bootstrap.cursor
|
|
78
|
+
? '<button class="load-more" type="button">Load more</button>'
|
|
79
|
+
: ''
|
|
80
|
+
|
|
81
|
+
let main: string
|
|
82
|
+
if (!bootstrap.session) {
|
|
83
|
+
main =
|
|
84
|
+
'<main class="main"><div class="empty"><div><strong>No session selected</strong><span>Choose a durable session to inspect its lifecycle.</span></div></div></main>'
|
|
85
|
+
} else if (!bootstrap.detail) {
|
|
86
|
+
const error = bootstrap.error
|
|
87
|
+
? `<div class="error-box">${escapeText(bootstrap.error)}</div>`
|
|
88
|
+
: ''
|
|
89
|
+
main = `<main class="main">${error}<div class="empty"><div><strong>Session unavailable</strong><span>The selected durable session could not be read.</span></div></div></main>`
|
|
90
|
+
} else {
|
|
91
|
+
const { events } = bootstrap.detail
|
|
92
|
+
const pending = events.filter(
|
|
93
|
+
(event) => event.processedAt === null && event.failedAt === null,
|
|
94
|
+
).length
|
|
95
|
+
const attempts = events.reduce(
|
|
96
|
+
(total, event) => total + event.attemptCount,
|
|
97
|
+
0,
|
|
98
|
+
)
|
|
99
|
+
const failures = events.reduce(
|
|
100
|
+
(total, event) => total + event.failureCount,
|
|
101
|
+
0,
|
|
102
|
+
)
|
|
103
|
+
const first = events[0]
|
|
104
|
+
const last = events.at(-1)
|
|
105
|
+
const lifetime =
|
|
106
|
+
first && last
|
|
107
|
+
? formatDuration(
|
|
108
|
+
new Date(
|
|
109
|
+
last.failedAt ?? last.processedAt ?? Date.now(),
|
|
110
|
+
).getTime() - new Date(first.createdAt).getTime(),
|
|
111
|
+
)
|
|
112
|
+
: '0 ms'
|
|
113
|
+
const error = bootstrap.error
|
|
114
|
+
? `<div class="error-box">${escapeText(bootstrap.error)}</div>`
|
|
115
|
+
: ''
|
|
116
|
+
main = `<main class="main">${error}<header class="session-header"><div class="session-title"><p class="eyebrow">${escapeText(bootstrap.contract ?? '')} / session</p><h1>${escapeText(bootstrap.session)}</h1><p>Read-only · durable data · refreshed by SSE</p></div><span class="live"><i></i>live</span></header><div class="cards">${metricHtml('Events', String(events.length))}${metricHtml('Pending', String(pending))}${metricHtml('Dispatches', String(attempts))}${metricHtml('Caught failures', String(failures))}${metricHtml('Lifetime', lifetime)}</div></main>`
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return `<div class="shell"><aside class="sidebar"><div class="brand"><span class="mark">A2</span><div class="brand-copy"><strong>A2 Devtools</strong><span>Durable execution data</span></div></div><div class="side-controls"><p class="eyebrow">Contract</p><select class="contract-select" aria-label="Contract">${options}</select></div><div class="sessions"><p class="eyebrow">Sessions</p>${sessions}${more}</div></aside>${main}</div>`
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const bootstrapJson = (bootstrap: DevtoolsBootstrap): string =>
|
|
123
|
+
JSON.stringify(bootstrap)
|
|
124
|
+
.replaceAll('<', '\\u003c')
|
|
125
|
+
.replaceAll('\u2028', '\\u2028')
|
|
126
|
+
.replaceAll('\u2029', '\\u2029')
|
|
127
|
+
|
|
128
|
+
export const devtoolsHtml = (
|
|
129
|
+
basePath: string,
|
|
130
|
+
bootstrap: DevtoolsBootstrap,
|
|
131
|
+
): string => `<!doctype html>
|
|
132
|
+
<html lang="en">
|
|
133
|
+
<head>
|
|
134
|
+
<meta charset="utf-8">
|
|
135
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
136
|
+
<meta name="color-scheme" content="light">
|
|
137
|
+
<base href="${escapeAttribute(basePath)}">
|
|
138
|
+
<title>A2 Devtools</title>
|
|
139
|
+
<link rel="stylesheet" href="_a2/app.css">
|
|
140
|
+
</head>
|
|
141
|
+
<body>
|
|
142
|
+
<div id="app">${shellHtml(bootstrap)}</div>
|
|
143
|
+
<script id="a2-bootstrap" type="application/json">${bootstrapJson(bootstrap)}</script>
|
|
144
|
+
<script type="module" src="_a2/app.js"></script>
|
|
145
|
+
</body>
|
|
146
|
+
</html>`
|
|
147
|
+
|
|
148
|
+
export const DEVTOOLS_APP_CSS: string = String.raw`
|
|
149
|
+
:root {
|
|
150
|
+
color: #1a1a1a;
|
|
151
|
+
background: #fff;
|
|
152
|
+
font-family: Geist, "Geist Sans", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
153
|
+
font-synthesis: none;
|
|
154
|
+
--surface: #fafafa;
|
|
155
|
+
--line: #eaeaea;
|
|
156
|
+
--line-strong: #d4d4d4;
|
|
157
|
+
--muted: #666;
|
|
158
|
+
--faint: #8f8f8f;
|
|
159
|
+
--success: #15803d;
|
|
160
|
+
--warning: #b45309;
|
|
161
|
+
--danger: #dc2626;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
* { box-sizing: border-box; }
|
|
165
|
+
html, body, #app { min-height: 100%; margin: 0; }
|
|
166
|
+
body { min-width: 320px; background: #fff; }
|
|
167
|
+
button, select { font: inherit; }
|
|
168
|
+
button { color: inherit; }
|
|
169
|
+
button:focus-visible, select:focus-visible, summary:focus-visible { outline: 2px solid #000; outline-offset: 2px; }
|
|
170
|
+
|
|
171
|
+
.mark {
|
|
172
|
+
display: inline-grid;
|
|
173
|
+
place-items: center;
|
|
174
|
+
width: 30px;
|
|
175
|
+
height: 30px;
|
|
176
|
+
border-radius: 6px;
|
|
177
|
+
background: #000;
|
|
178
|
+
color: #fff;
|
|
179
|
+
font-size: 12px;
|
|
180
|
+
font-weight: 700;
|
|
181
|
+
letter-spacing: -.04em;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
.shell {
|
|
185
|
+
min-height: 100vh;
|
|
186
|
+
display: grid;
|
|
187
|
+
grid-template-columns: 248px minmax(0, 1fr);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
.sidebar {
|
|
191
|
+
position: sticky;
|
|
192
|
+
top: 0;
|
|
193
|
+
height: 100vh;
|
|
194
|
+
display: flex;
|
|
195
|
+
flex-direction: column;
|
|
196
|
+
border-right: 1px solid var(--line);
|
|
197
|
+
background: var(--surface);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
.brand {
|
|
201
|
+
display: flex;
|
|
202
|
+
align-items: center;
|
|
203
|
+
gap: 10px;
|
|
204
|
+
padding: 18px;
|
|
205
|
+
border-bottom: 1px solid var(--line);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
.brand-copy strong { display: block; font-size: 13px; font-weight: 600; letter-spacing: -.01em; }
|
|
209
|
+
.brand-copy span { color: var(--muted); font-size: 10px; }
|
|
210
|
+
.side-controls { padding: 14px; border-bottom: 1px solid var(--line); }
|
|
211
|
+
.eyebrow { margin: 0 0 8px; color: var(--muted); font-size: 10px; font-weight: 500; letter-spacing: .08em; text-transform: uppercase; }
|
|
212
|
+
|
|
213
|
+
.contract-select {
|
|
214
|
+
width: 100%;
|
|
215
|
+
padding: 8px 9px;
|
|
216
|
+
color: #1a1a1a;
|
|
217
|
+
border: 1px solid var(--line-strong);
|
|
218
|
+
border-radius: 6px;
|
|
219
|
+
outline: none;
|
|
220
|
+
background: #fff;
|
|
221
|
+
}
|
|
222
|
+
.contract-select:focus { border-color: #000; }
|
|
223
|
+
|
|
224
|
+
.sessions { min-height: 0; flex: 1; overflow: auto; padding: 12px 8px; }
|
|
225
|
+
.session-button {
|
|
226
|
+
width: 100%;
|
|
227
|
+
display: grid;
|
|
228
|
+
grid-template-columns: minmax(0, 1fr) auto;
|
|
229
|
+
gap: 5px 9px;
|
|
230
|
+
padding: 9px 10px;
|
|
231
|
+
margin-bottom: 2px;
|
|
232
|
+
text-align: left;
|
|
233
|
+
border: 1px solid transparent;
|
|
234
|
+
border-radius: 6px;
|
|
235
|
+
background: transparent;
|
|
236
|
+
cursor: pointer;
|
|
237
|
+
}
|
|
238
|
+
.session-button:hover { background: #f0f0f0; }
|
|
239
|
+
.session-button.active { border-color: var(--line-strong); background: #fff; }
|
|
240
|
+
.session-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
|
|
241
|
+
.session-meta { color: var(--muted); font-size: 10px; }
|
|
242
|
+
.dot { align-self: center; width: 6px; height: 6px; border-radius: 99px; background: #999; }
|
|
243
|
+
.dot.pending { background: var(--warning); }
|
|
244
|
+
.dot.failed { background: var(--danger); }
|
|
245
|
+
.dot.settled { background: var(--success); }
|
|
246
|
+
.load-more { width: 100%; margin: 6px 0 10px; padding: 8px; border: 1px solid var(--line-strong); border-radius: 6px; background: #fff; cursor: pointer; color: var(--muted); }
|
|
247
|
+
.load-more:hover { color: #000; border-color: #999; }
|
|
248
|
+
|
|
249
|
+
.main { min-width: 0; width: 100%; max-width: 1440px; padding: 38px clamp(24px, 5vw, 72px) 72px; }
|
|
250
|
+
.empty { min-height: 70vh; display: grid; place-items: center; color: var(--muted); text-align: center; }
|
|
251
|
+
.empty strong { display: block; margin-bottom: 7px; color: #1a1a1a; font-size: 16px; font-weight: 500; }
|
|
252
|
+
|
|
253
|
+
.session-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 24px; margin-bottom: 28px; }
|
|
254
|
+
.session-title { min-width: 0; }
|
|
255
|
+
.session-title h1 { margin: 4px 0 8px; overflow-wrap: anywhere; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: clamp(22px, 3vw, 32px); font-weight: 500; letter-spacing: -.04em; }
|
|
256
|
+
.session-title p { margin: 0; color: var(--muted); font-size: 12px; }
|
|
257
|
+
.live { display: inline-flex; align-items: center; gap: 7px; padding: 6px 9px; color: var(--muted); border: 1px solid var(--line); border-radius: 99px; font-size: 11px; }
|
|
258
|
+
.live i { width: 6px; height: 6px; border-radius: 99px; background: var(--success); }
|
|
259
|
+
|
|
260
|
+
.cards { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); margin-bottom: 32px; overflow: hidden; border: 1px solid var(--line); border-radius: 8px; }
|
|
261
|
+
.card { padding: 15px 16px; border-right: 1px solid var(--line); background: #fff; }
|
|
262
|
+
.card:last-child { border-right: 0; }
|
|
263
|
+
.card span { display: block; margin-bottom: 8px; color: var(--muted); font-size: 10px; font-weight: 500; }
|
|
264
|
+
.card strong { font-size: 20px; font-weight: 500; letter-spacing: -.03em; }
|
|
265
|
+
|
|
266
|
+
.section { margin-top: 32px; }
|
|
267
|
+
.section-head { display: flex; align-items: end; justify-content: space-between; gap: 20px; margin-bottom: 11px; }
|
|
268
|
+
.section-head h2 { margin: 0; font-size: 14px; font-weight: 500; letter-spacing: -.01em; }
|
|
269
|
+
.section-head p { margin: 0; color: var(--muted); font-size: 11px; }
|
|
270
|
+
|
|
271
|
+
.forest { overflow: hidden; border: 1px solid var(--line); border-radius: 8px; background: #fff; }
|
|
272
|
+
.forest-row { --depth: 0; position: relative; display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 16px; min-height: 42px; padding: 9px 13px 9px calc(13px + var(--depth) * 22px); border-bottom: 1px solid var(--line); }
|
|
273
|
+
.forest-row:last-child { border-bottom: 0; }
|
|
274
|
+
.forest-row::before { content: ""; position: absolute; top: 0; bottom: 0; left: calc(20px + (var(--depth) - 1) * 22px); width: 1px; background: var(--depth-line, transparent); }
|
|
275
|
+
.forest-row.nested { --depth-line: var(--line-strong); }
|
|
276
|
+
.forest-main { min-width: 0; display: flex; align-items: center; gap: 8px; }
|
|
277
|
+
.forest-main .dot { flex: none; }
|
|
278
|
+
.forest-type { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 11px ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
279
|
+
.forest-edge { color: var(--muted); font-size: 10px; white-space: nowrap; }
|
|
280
|
+
.forest-edge.unknown { color: var(--warning); }
|
|
281
|
+
|
|
282
|
+
.timeline { overflow: hidden; border: 1px solid var(--line); border-radius: 8px; background: #fff; }
|
|
283
|
+
.timeline-axis, .event-row { display: grid; grid-template-columns: 310px minmax(320px, 1fr); }
|
|
284
|
+
.timeline-axis { min-height: 37px; border-bottom: 1px solid var(--line); color: var(--muted); font-size: 9px; }
|
|
285
|
+
.axis-label { padding: 12px 14px; border-right: 1px solid var(--line); font-weight: 500; }
|
|
286
|
+
.axis-track { position: relative; display: flex; justify-content: space-between; padding: 12px 16px; }
|
|
287
|
+
.event-row { min-height: 84px; border-bottom: 1px solid var(--line); }
|
|
288
|
+
.event-row:last-child { border-bottom: 0; }
|
|
289
|
+
.event-info { min-width: 0; padding: 11px 14px; border-right: 1px solid var(--line); }
|
|
290
|
+
.event-line { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
|
291
|
+
.event-index { color: var(--muted); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 10px; }
|
|
292
|
+
.event-type { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
|
|
293
|
+
.event-sub { display: flex; flex-wrap: wrap; gap: 5px 9px; margin-top: 7px; color: var(--muted); font-size: 9px; }
|
|
294
|
+
.event-sub .danger { color: var(--danger); }
|
|
295
|
+
.event-sub .warning { color: var(--warning); }
|
|
296
|
+
.event-facts { display: flex; flex-wrap: wrap; gap: 4px 9px; margin-top: 7px; color: var(--muted); font-size: 9px; }
|
|
297
|
+
.event-facts .warning { color: var(--warning); }
|
|
298
|
+
.event-details { margin-top: 8px; }
|
|
299
|
+
.event-details summary { color: var(--muted); cursor: pointer; font-size: 9px; }
|
|
300
|
+
.event-details pre { max-height: 220px; overflow: auto; margin: 8px 0 0; padding: 10px; border: 1px solid var(--line); border-radius: 6px; background: var(--surface); color: #333; font: 10px/1.55 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
301
|
+
.event-track { position: relative; min-height: 84px; }
|
|
302
|
+
.event-track::before { content: ""; position: absolute; top: 42px; right: 0; left: 0; height: 1px; background: var(--line); }
|
|
303
|
+
.event-bar { position: absolute; top: 39px; height: 7px; min-width: 4px; border-radius: 99px; background: #111; }
|
|
304
|
+
.event-bar.processed { background: #111; }
|
|
305
|
+
.event-bar.pending { top: 38px; height: 9px; border: 1px solid #777; background: #fff; }
|
|
306
|
+
.event-bar.failed { background: var(--danger); }
|
|
307
|
+
.lifecycle-marker { position: absolute; z-index: 2; width: 11px; height: 11px; margin-left: -5px; border: 2px solid #fff; border-radius: 99px; background: #777; box-shadow: 0 0 0 1px #777; }
|
|
308
|
+
.lifecycle-marker.created { top: 27px; border-radius: 2px; background: #fff; box-shadow: 0 0 0 1px #777; }
|
|
309
|
+
.lifecycle-marker.claim { top: 37px; background: #111; box-shadow: 0 0 0 1px #111; }
|
|
310
|
+
.lifecycle-marker.failure { top: 47px; background: var(--danger); box-shadow: 0 0 0 1px var(--danger); }
|
|
311
|
+
.lifecycle-marker.complete { top: 47px; background: var(--success); box-shadow: 0 0 0 1px var(--success); }
|
|
312
|
+
.lifecycle-marker.dead-letter { top: 57px; background: #fff; box-shadow: 0 0 0 1px var(--danger); }
|
|
313
|
+
.timeline-legend { display: flex; flex-wrap: wrap; gap: 7px 14px; margin: 9px 2px 0; color: var(--muted); font-size: 9px; }
|
|
314
|
+
.legend-item { display: inline-flex; align-items: center; gap: 6px; }
|
|
315
|
+
.legend-key { width: 7px; height: 7px; border-radius: 99px; background: #777; }
|
|
316
|
+
.legend-key.created { border: 1px solid #777; border-radius: 1px; background: #fff; }
|
|
317
|
+
.legend-key.claim { background: #111; }
|
|
318
|
+
.legend-key.failure { background: var(--danger); }
|
|
319
|
+
.legend-key.dead-letter { border: 1px solid var(--danger); background: #fff; }
|
|
320
|
+
.legend-key.complete { background: var(--success); }
|
|
321
|
+
|
|
322
|
+
.snapshots { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 8px; }
|
|
323
|
+
.snapshot { padding: 14px; border: 1px solid var(--line); border-radius: 8px; background: #fff; }
|
|
324
|
+
.snapshot strong { display: block; margin-bottom: 8px; font: 12px ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
325
|
+
.snapshot div { display: flex; justify-content: space-between; color: var(--muted); font-size: 10px; }
|
|
326
|
+
.snapshot b { color: #111; font-weight: 500; }
|
|
327
|
+
.error-box { margin-bottom: 20px; padding: 12px 14px; border: 1px solid #fecaca; border-radius: 7px; background: #fffafa; color: var(--danger); font: 11px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; }
|
|
328
|
+
|
|
329
|
+
@media (max-width: 850px) {
|
|
330
|
+
.shell { grid-template-columns: 1fr; }
|
|
331
|
+
.sidebar { position: static; height: auto; max-height: 310px; border-right: 0; border-bottom: 1px solid var(--line); }
|
|
332
|
+
.sessions { max-height: 180px; }
|
|
333
|
+
.cards { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
334
|
+
.card { border-bottom: 1px solid var(--line); }
|
|
335
|
+
.card:nth-child(2n) { border-right: 0; }
|
|
336
|
+
.card:last-child { border-bottom: 0; }
|
|
337
|
+
.timeline-axis, .event-row { grid-template-columns: 260px minmax(300px, 1fr); }
|
|
338
|
+
.timeline { overflow-x: auto; }
|
|
339
|
+
.timeline-axis, .event-row { min-width: 610px; }
|
|
340
|
+
.main { padding: 28px 20px 60px; }
|
|
341
|
+
}
|
|
342
|
+
`
|
|
343
|
+
|
|
344
|
+
export const DEVTOOLS_APP_JS: string = String.raw`
|
|
345
|
+
const root = document.querySelector('#app')
|
|
346
|
+
const readBootstrap = () => {
|
|
347
|
+
const element = document.querySelector('#a2-bootstrap')
|
|
348
|
+
if (!element) return null
|
|
349
|
+
try {
|
|
350
|
+
const bootstrap = JSON.parse(element.textContent || 'null')
|
|
351
|
+
element.remove()
|
|
352
|
+
return bootstrap
|
|
353
|
+
} catch {
|
|
354
|
+
return null
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
const navigationFromUrl = () => {
|
|
358
|
+
const params = new URL(window.location.href).searchParams
|
|
359
|
+
return {
|
|
360
|
+
contract: params.get('contract'),
|
|
361
|
+
session: params.get('session'),
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
const initialNavigation = navigationFromUrl()
|
|
365
|
+
const bootstrap = readBootstrap()
|
|
366
|
+
const state = {
|
|
367
|
+
contracts: bootstrap?.contracts || [],
|
|
368
|
+
contract: bootstrap ? bootstrap.contract : initialNavigation.contract,
|
|
369
|
+
sessions: bootstrap?.sessions || [],
|
|
370
|
+
cursor: bootstrap?.cursor || null,
|
|
371
|
+
session: bootstrap ? bootstrap.session : initialNavigation.session,
|
|
372
|
+
detail: bootstrap?.detail || null,
|
|
373
|
+
watch: null,
|
|
374
|
+
error: bootstrap?.error || null,
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const syncUrl = (replace) => {
|
|
378
|
+
const url = new URL(window.location.href)
|
|
379
|
+
if (state.contract) url.searchParams.set('contract', state.contract)
|
|
380
|
+
else url.searchParams.delete('contract')
|
|
381
|
+
if (state.session) url.searchParams.set('session', state.session)
|
|
382
|
+
else url.searchParams.delete('session')
|
|
383
|
+
const historyState = { contract: state.contract, session: state.session }
|
|
384
|
+
if (replace) window.history.replaceState(historyState, '', url)
|
|
385
|
+
else window.history.pushState(historyState, '', url)
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const node = (tag, className, text) => {
|
|
389
|
+
const element = document.createElement(tag)
|
|
390
|
+
if (className) element.className = className
|
|
391
|
+
if (text !== undefined) element.textContent = text
|
|
392
|
+
return element
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const append = (parent, ...children) => {
|
|
396
|
+
for (const child of children) if (child) parent.append(child)
|
|
397
|
+
return parent
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const request = async (resource, params = {}) => {
|
|
401
|
+
const url = new URL('_a2/' + resource, document.baseURI)
|
|
402
|
+
for (const [key, value] of Object.entries(params)) {
|
|
403
|
+
if (value !== null && value !== undefined) url.searchParams.set(key, String(value))
|
|
404
|
+
}
|
|
405
|
+
const response = await fetch(url, { headers: { accept: 'application/json' } })
|
|
406
|
+
if (!response.ok) {
|
|
407
|
+
let message = response.status + ' ' + response.statusText
|
|
408
|
+
try { message = (await response.json()).error || message } catch {}
|
|
409
|
+
throw new Error(message)
|
|
410
|
+
}
|
|
411
|
+
return response.json()
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const detailRevision = async (detail) => {
|
|
415
|
+
const timestamp = (value) => value === null ? null : new Date(value).getTime()
|
|
416
|
+
const bytes = new TextEncoder().encode(JSON.stringify({
|
|
417
|
+
events: detail.events.map((event) => [
|
|
418
|
+
event.id,
|
|
419
|
+
event.index,
|
|
420
|
+
event.cause?.index ?? null,
|
|
421
|
+
event.cause?.attempt ?? null,
|
|
422
|
+
timestamp(event.processedAt),
|
|
423
|
+
event.processedByAttempt,
|
|
424
|
+
timestamp(event.firstClaimedAt),
|
|
425
|
+
timestamp(event.lastClaimedAt),
|
|
426
|
+
event.attemptCount,
|
|
427
|
+
event.failureCount,
|
|
428
|
+
timestamp(event.lastFailedAt),
|
|
429
|
+
event.lastFailedAttempt,
|
|
430
|
+
event.lastError,
|
|
431
|
+
timestamp(event.failedAt),
|
|
432
|
+
]),
|
|
433
|
+
snapshots: detail.snapshots.map((snapshot) => [
|
|
434
|
+
snapshot.reducerName,
|
|
435
|
+
snapshot.index,
|
|
436
|
+
snapshot.updatedAt === null ? 0 : new Date(snapshot.updatedAt).getTime(),
|
|
437
|
+
]),
|
|
438
|
+
}))
|
|
439
|
+
const digest = await crypto.subtle.digest('SHA-256', bytes)
|
|
440
|
+
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('')
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const requestDetail = async (contract, session) => {
|
|
444
|
+
const events = []
|
|
445
|
+
let snapshots = []
|
|
446
|
+
let cursor = null
|
|
447
|
+
let throughIndex = null
|
|
448
|
+
const seenCursors = new Set()
|
|
449
|
+
do {
|
|
450
|
+
const page = await request('session', {
|
|
451
|
+
contract,
|
|
452
|
+
session,
|
|
453
|
+
limit: ${DEVTOOLS_SESSION_PAGE_LIMIT},
|
|
454
|
+
cursor,
|
|
455
|
+
})
|
|
456
|
+
if ('revision' in page) return page
|
|
457
|
+
if (page.contract !== contract || page.sessionId !== session) {
|
|
458
|
+
throw new Error('Session page identity changed during inspection.')
|
|
459
|
+
}
|
|
460
|
+
if (throughIndex === null) {
|
|
461
|
+
throughIndex = page.throughIndex
|
|
462
|
+
snapshots = page.snapshots
|
|
463
|
+
} else if (page.throughIndex !== throughIndex || page.snapshots.length > 0) {
|
|
464
|
+
throw new Error('Session page frontier changed during inspection.')
|
|
465
|
+
}
|
|
466
|
+
for (const event of page.events) {
|
|
467
|
+
if (event.index !== events.length + 1 || event.index > throughIndex) {
|
|
468
|
+
throw new Error('Session event pages are not contiguous.')
|
|
469
|
+
}
|
|
470
|
+
events.push(event)
|
|
471
|
+
}
|
|
472
|
+
if (page.cursor !== null) {
|
|
473
|
+
if (
|
|
474
|
+
page.events.length === 0 ||
|
|
475
|
+
events.length === throughIndex ||
|
|
476
|
+
seenCursors.has(page.cursor)
|
|
477
|
+
) {
|
|
478
|
+
throw new Error('Session cursor did not advance.')
|
|
479
|
+
}
|
|
480
|
+
seenCursors.add(page.cursor)
|
|
481
|
+
}
|
|
482
|
+
cursor = page.cursor
|
|
483
|
+
} while (cursor !== null)
|
|
484
|
+
if (throughIndex !== events.length) {
|
|
485
|
+
throw new Error('Session event pages ended before their frozen frontier.')
|
|
486
|
+
}
|
|
487
|
+
const detail = {
|
|
488
|
+
protocolVersion: ${DEVTOOLS_PROTOCOL_VERSION},
|
|
489
|
+
contract,
|
|
490
|
+
sessionId: session,
|
|
491
|
+
revision: '',
|
|
492
|
+
events,
|
|
493
|
+
snapshots,
|
|
494
|
+
}
|
|
495
|
+
detail.revision = await detailRevision(detail)
|
|
496
|
+
return detail
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const watchUrl = () => {
|
|
500
|
+
const url = new URL('_a2/watch', document.baseURI)
|
|
501
|
+
url.searchParams.set('contract', state.contract)
|
|
502
|
+
url.searchParams.set('session', state.session)
|
|
503
|
+
if (state.detail?.revision) url.searchParams.set('revision', state.detail.revision)
|
|
504
|
+
return url
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const formatTime = (value) => new Intl.DateTimeFormat(undefined, {
|
|
508
|
+
hour: '2-digit', minute: '2-digit', second: '2-digit', fractionalSecondDigits: 3,
|
|
509
|
+
}).format(new Date(value))
|
|
510
|
+
|
|
511
|
+
const formatRelative = (value) => {
|
|
512
|
+
const delta = Date.now() - new Date(value).getTime()
|
|
513
|
+
if (delta < 1000) return 'now'
|
|
514
|
+
if (delta < 60000) return Math.floor(delta / 1000) + 's ago'
|
|
515
|
+
if (delta < 3600000) return Math.floor(delta / 60000) + 'm ago'
|
|
516
|
+
if (delta < 86400000) return Math.floor(delta / 3600000) + 'h ago'
|
|
517
|
+
return Math.floor(delta / 86400000) + 'd ago'
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const formatDuration = (milliseconds) => {
|
|
521
|
+
if (milliseconds < 1000) return Math.max(0, Math.round(milliseconds)) + ' ms'
|
|
522
|
+
if (milliseconds < 60000) return (milliseconds / 1000).toFixed(milliseconds < 10000 ? 2 : 1) + ' s'
|
|
523
|
+
return (milliseconds / 60000).toFixed(1) + ' min'
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const sessionStatus = (session) => session.failedCount > 0 ? 'failed' : session.pendingCount > 0 ? 'pending' : 'settled'
|
|
527
|
+
const eventStatus = (event) => event.failedAt ? 'failed' : event.processedAt ? 'processed' : 'pending'
|
|
528
|
+
const eventStatusLabel = (event) => event.failedAt ? 'dead-lettered' : event.processedAt ? 'completed' : 'pending'
|
|
529
|
+
|
|
530
|
+
const durableOutcomeGap = (event) => Math.max(
|
|
531
|
+
0,
|
|
532
|
+
event.attemptCount - event.failureCount - (event.processedByAttempt === null ? 0 : 1),
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
const causalEntries = (events) => {
|
|
536
|
+
const byIndex = new Map(events.map((event) => [event.index, event]))
|
|
537
|
+
const children = new Map()
|
|
538
|
+
for (const event of events) {
|
|
539
|
+
if (!event.cause || !byIndex.has(event.cause.index)) continue
|
|
540
|
+
const siblings = children.get(event.cause.index) || []
|
|
541
|
+
siblings.push(event)
|
|
542
|
+
children.set(event.cause.index, siblings)
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const roots = events.filter((event) => !event.cause || !byIndex.has(event.cause.index))
|
|
546
|
+
const entries = []
|
|
547
|
+
const visited = new Set()
|
|
548
|
+
const visit = (event, depth) => {
|
|
549
|
+
if (visited.has(event.index)) return
|
|
550
|
+
visited.add(event.index)
|
|
551
|
+
entries.push({ event, depth })
|
|
552
|
+
for (const child of children.get(event.index) || []) visit(child, depth + 1)
|
|
553
|
+
}
|
|
554
|
+
for (const root of roots) visit(root, 0)
|
|
555
|
+
for (const event of events) visit(event, 0)
|
|
556
|
+
return entries
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const loadContracts = async () => {
|
|
560
|
+
const data = await request('contracts')
|
|
561
|
+
state.contracts = data.contracts
|
|
562
|
+
if (!data.contracts.some((contract) => contract.name === state.contract)) {
|
|
563
|
+
state.contract = data.contracts[0]?.name || null
|
|
564
|
+
state.session = null
|
|
565
|
+
}
|
|
566
|
+
if (state.contract) await loadSessions(true)
|
|
567
|
+
syncUrl(true)
|
|
568
|
+
render()
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const loadSessions = async (reset) => {
|
|
572
|
+
if (!state.contract) return
|
|
573
|
+
const data = await request('sessions', {
|
|
574
|
+
contract: state.contract,
|
|
575
|
+
cursor: reset ? null : state.cursor,
|
|
576
|
+
limit: 50,
|
|
577
|
+
})
|
|
578
|
+
state.sessions = reset ? data.sessions : state.sessions.concat(data.sessions)
|
|
579
|
+
state.cursor = data.cursor
|
|
580
|
+
if (reset && !state.session) {
|
|
581
|
+
state.session = state.sessions[0]?.sessionId || null
|
|
582
|
+
}
|
|
583
|
+
if (state.session) await loadDetail()
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const loadDetail = async () => {
|
|
587
|
+
if (!state.contract || !state.session) return
|
|
588
|
+
state.detail = await requestDetail(state.contract, state.session)
|
|
589
|
+
state.error = null
|
|
590
|
+
render()
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const beginWatch = () => {
|
|
594
|
+
state.watch?.close()
|
|
595
|
+
if (!state.session) return
|
|
596
|
+
const watchedContract = state.contract
|
|
597
|
+
const watchedSession = state.session
|
|
598
|
+
const source = new EventSource(watchUrl())
|
|
599
|
+
source.addEventListener('invalidate', async () => {
|
|
600
|
+
if (state.contract !== watchedContract || state.session !== watchedSession) return
|
|
601
|
+
try {
|
|
602
|
+
await Promise.all([loadDetail(), refreshSelectedSummary()])
|
|
603
|
+
} catch (error) {
|
|
604
|
+
state.error = error instanceof Error ? error.message : String(error)
|
|
605
|
+
render()
|
|
606
|
+
}
|
|
607
|
+
})
|
|
608
|
+
source.onerror = () => {
|
|
609
|
+
// EventSource reconnects. Durable state is fetched on the next
|
|
610
|
+
// invalidation, so a dropped frame cannot make the view incorrect.
|
|
611
|
+
}
|
|
612
|
+
state.watch = source
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const refreshSelectedSummary = async () => {
|
|
616
|
+
const data = await request('sessions', { contract: state.contract, limit: 100 })
|
|
617
|
+
const fresh = data.sessions.find((session) => session.sessionId === state.session)
|
|
618
|
+
const index = state.sessions.findIndex((session) => session.sessionId === state.session)
|
|
619
|
+
if (fresh && index !== -1) state.sessions[index] = fresh
|
|
620
|
+
render()
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
const selectContract = async (name) => {
|
|
624
|
+
state.watch?.close()
|
|
625
|
+
state.contract = name
|
|
626
|
+
state.session = null
|
|
627
|
+
state.detail = null
|
|
628
|
+
state.sessions = []
|
|
629
|
+
state.cursor = null
|
|
630
|
+
state.error = null
|
|
631
|
+
syncUrl(false)
|
|
632
|
+
render()
|
|
633
|
+
try {
|
|
634
|
+
await loadSessions(true)
|
|
635
|
+
syncUrl(true)
|
|
636
|
+
beginWatch()
|
|
637
|
+
} catch (error) {
|
|
638
|
+
state.error = error instanceof Error ? error.message : String(error)
|
|
639
|
+
render()
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const selectSession = async (id) => {
|
|
644
|
+
if (id === state.session) return
|
|
645
|
+
|
|
646
|
+
state.watch?.close()
|
|
647
|
+
state.session = id
|
|
648
|
+
state.detail = null
|
|
649
|
+
state.error = null
|
|
650
|
+
syncUrl(false)
|
|
651
|
+
render()
|
|
652
|
+
try {
|
|
653
|
+
await loadDetail()
|
|
654
|
+
beginWatch()
|
|
655
|
+
} catch (error) {
|
|
656
|
+
state.error = error instanceof Error ? error.message : String(error)
|
|
657
|
+
render()
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const renderSidebar = () => {
|
|
662
|
+
const sidebar = node('aside', 'sidebar')
|
|
663
|
+
const brand = node('div', 'brand')
|
|
664
|
+
const copy = node('div', 'brand-copy')
|
|
665
|
+
append(copy, node('strong', '', 'A2 Devtools'), node('span', '', 'Durable execution data'))
|
|
666
|
+
append(brand, node('span', 'mark', 'A2'), copy)
|
|
667
|
+
sidebar.append(brand)
|
|
668
|
+
|
|
669
|
+
const controls = node('div', 'side-controls')
|
|
670
|
+
controls.append(node('p', 'eyebrow', 'Contract'))
|
|
671
|
+
const select = node('select', 'contract-select')
|
|
672
|
+
select.setAttribute('aria-label', 'Contract')
|
|
673
|
+
for (const contract of state.contracts) {
|
|
674
|
+
const option = node('option', '', contract.name)
|
|
675
|
+
option.value = contract.name
|
|
676
|
+
option.selected = contract.name === state.contract
|
|
677
|
+
select.append(option)
|
|
678
|
+
}
|
|
679
|
+
select.addEventListener('change', () => void selectContract(select.value))
|
|
680
|
+
controls.append(select)
|
|
681
|
+
sidebar.append(controls)
|
|
682
|
+
|
|
683
|
+
const sessions = node('div', 'sessions')
|
|
684
|
+
sessions.append(node('p', 'eyebrow', 'Sessions'))
|
|
685
|
+
if (state.sessions.length === 0) sessions.append(node('div', 'session-meta', 'No durable sessions yet.'))
|
|
686
|
+
for (const session of state.sessions) {
|
|
687
|
+
const status = sessionStatus(session)
|
|
688
|
+
const button = node('button', 'session-button' + (session.sessionId === state.session ? ' active' : ''))
|
|
689
|
+
button.type = 'button'
|
|
690
|
+
button.addEventListener('click', () => void selectSession(session.sessionId))
|
|
691
|
+
append(
|
|
692
|
+
button,
|
|
693
|
+
node('span', 'session-name', session.sessionId),
|
|
694
|
+
node('span', 'dot ' + status),
|
|
695
|
+
node('span', 'session-meta', session.eventCount + (session.eventCount === 1 ? ' event' : ' events')),
|
|
696
|
+
node('span', 'session-meta', formatRelative(session.updatedAt)),
|
|
697
|
+
)
|
|
698
|
+
sessions.append(button)
|
|
699
|
+
}
|
|
700
|
+
if (state.cursor) {
|
|
701
|
+
const more = node('button', 'load-more', 'Load more')
|
|
702
|
+
more.type = 'button'
|
|
703
|
+
more.addEventListener('click', async () => {
|
|
704
|
+
more.disabled = true
|
|
705
|
+
try { await loadSessions(false); render() } catch (error) {
|
|
706
|
+
state.error = error instanceof Error ? error.message : String(error)
|
|
707
|
+
render()
|
|
708
|
+
}
|
|
709
|
+
})
|
|
710
|
+
sessions.append(more)
|
|
711
|
+
}
|
|
712
|
+
sidebar.append(sessions)
|
|
713
|
+
return sidebar
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
const metric = (label, value) => {
|
|
717
|
+
const card = node('div', 'card')
|
|
718
|
+
append(card, node('span', '', label), node('strong', '', value))
|
|
719
|
+
return card
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
const renderCausalForest = (events) => {
|
|
723
|
+
const forest = node('div', 'forest')
|
|
724
|
+
const indexes = new Set(events.map((event) => event.index))
|
|
725
|
+
for (const { event, depth } of causalEntries(events)) {
|
|
726
|
+
const row = node('div', 'forest-row' + (depth > 0 ? ' nested' : ''))
|
|
727
|
+
row.style.setProperty('--depth', String(depth))
|
|
728
|
+
const main = node('div', 'forest-main')
|
|
729
|
+
append(
|
|
730
|
+
main,
|
|
731
|
+
node('span', 'dot ' + eventStatus(event)),
|
|
732
|
+
node('span', 'event-index', '#' + event.index),
|
|
733
|
+
node('span', 'forest-type', event.type),
|
|
734
|
+
)
|
|
735
|
+
row.append(main)
|
|
736
|
+
|
|
737
|
+
let edge
|
|
738
|
+
if (!event.cause) {
|
|
739
|
+
edge = node('span', 'forest-edge unknown', 'origin unknown')
|
|
740
|
+
edge.title = 'This may be a root append or an event written before causal metadata existed.'
|
|
741
|
+
} else if (!indexes.has(event.cause.index)) {
|
|
742
|
+
edge = node('span', 'forest-edge unknown', 'parent #' + event.cause.index + ' unavailable · attempt ' + event.cause.attempt)
|
|
743
|
+
} else {
|
|
744
|
+
edge = node('span', 'forest-edge', 'from #' + event.cause.index + ' · attempt ' + event.cause.attempt)
|
|
745
|
+
}
|
|
746
|
+
row.append(edge)
|
|
747
|
+
forest.append(row)
|
|
748
|
+
}
|
|
749
|
+
return forest
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
const renderTimeline = (events) => {
|
|
753
|
+
const timeline = node('div', 'timeline')
|
|
754
|
+
const now = Date.now()
|
|
755
|
+
const times = events.flatMap((event) => [
|
|
756
|
+
event.createdAt,
|
|
757
|
+
event.firstClaimedAt,
|
|
758
|
+
event.lastClaimedAt,
|
|
759
|
+
event.lastFailedAt,
|
|
760
|
+
event.processedAt,
|
|
761
|
+
event.failedAt,
|
|
762
|
+
eventStatus(event) === 'pending' ? now : null,
|
|
763
|
+
].filter((value) => value !== null).map((value) => new Date(value).getTime()))
|
|
764
|
+
const minimum = Math.min(...events.map((event) => new Date(event.createdAt).getTime()))
|
|
765
|
+
const maximum = Math.max(...times, minimum + 1)
|
|
766
|
+
const range = maximum - minimum
|
|
767
|
+
const position = (value) => Math.max(0, Math.min(100, (new Date(value).getTime() - minimum) / range * 100))
|
|
768
|
+
const markerPosition = (value) => Math.max(.75, Math.min(99.25, position(value)))
|
|
769
|
+
const lifecycleMarker = (kind, value, title) => {
|
|
770
|
+
const marker = node('span', 'lifecycle-marker ' + kind)
|
|
771
|
+
marker.style.left = markerPosition(value) + '%'
|
|
772
|
+
marker.title = title + ' · ' + formatTime(value)
|
|
773
|
+
return marker
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
const axis = node('div', 'timeline-axis')
|
|
777
|
+
axis.append(node('div', 'axis-label', 'Event'))
|
|
778
|
+
const track = node('div', 'axis-track')
|
|
779
|
+
for (const percent of [0, 25, 50, 75, 100]) {
|
|
780
|
+
track.append(node('span', '', formatDuration(range * percent / 100)))
|
|
781
|
+
}
|
|
782
|
+
axis.append(track)
|
|
783
|
+
timeline.append(axis)
|
|
784
|
+
|
|
785
|
+
for (const event of events) {
|
|
786
|
+
const status = eventStatus(event)
|
|
787
|
+
const start = new Date(event.createdAt).getTime()
|
|
788
|
+
const end = new Date(event.failedAt || event.processedAt || now).getTime()
|
|
789
|
+
const left = position(event.createdAt)
|
|
790
|
+
const width = Math.max((end - start) / range * 100, .45)
|
|
791
|
+
const row = node('div', 'event-row')
|
|
792
|
+
const info = node('div', 'event-info')
|
|
793
|
+
const line = node('div', 'event-line')
|
|
794
|
+
append(line, node('span', 'event-index', '#' + event.index), node('span', 'event-type', event.type))
|
|
795
|
+
info.append(line)
|
|
796
|
+
const sub = node('div', 'event-sub')
|
|
797
|
+
sub.append(node('span', status === 'failed' ? 'danger' : '', eventStatusLabel(event)))
|
|
798
|
+
sub.append(node('span', '', event.attemptCount + (event.attemptCount === 1 ? ' dispatch' : ' dispatches')))
|
|
799
|
+
sub.append(node('span', event.failureCount > 0 ? 'danger' : '', event.failureCount + (event.failureCount === 1 ? ' caught failure' : ' caught failures')))
|
|
800
|
+
sub.append(node('span', '', 'created ' + formatTime(event.createdAt)))
|
|
801
|
+
info.append(sub)
|
|
802
|
+
|
|
803
|
+
const facts = node('div', 'event-facts')
|
|
804
|
+
if (event.attemptCount === 0) {
|
|
805
|
+
facts.append(node('span', '', 'not dispatched'))
|
|
806
|
+
} else if (!event.firstClaimedAt) {
|
|
807
|
+
facts.append(node('span', 'warning', 'first dispatch time unknown'))
|
|
808
|
+
}
|
|
809
|
+
if (event.attemptCount > 1) {
|
|
810
|
+
facts.append(node('span', '', (event.attemptCount - 1) + (event.attemptCount === 2 ? ' redispatch' : ' redispatches')))
|
|
811
|
+
}
|
|
812
|
+
const outcomeGap = durableOutcomeGap(event)
|
|
813
|
+
if (outcomeGap > 0) {
|
|
814
|
+
const hint = node('span', 'warning', outcomeGap + (outcomeGap === 1 ? ' dispatch without outcome' : ' dispatches without outcomes'))
|
|
815
|
+
hint.title = 'May indicate active work, a hard crash, or a superseded attempt.'
|
|
816
|
+
facts.append(hint)
|
|
817
|
+
}
|
|
818
|
+
if (event.lastFailedAttempt !== null) {
|
|
819
|
+
facts.append(node('span', 'danger', 'latest failure · attempt ' + event.lastFailedAttempt))
|
|
820
|
+
}
|
|
821
|
+
if (event.processedAt) {
|
|
822
|
+
facts.append(node('span', '', event.processedByAttempt === null ? 'completing attempt not recorded' : 'completed by attempt ' + event.processedByAttempt))
|
|
823
|
+
}
|
|
824
|
+
if (facts.childNodes.length > 0) info.append(facts)
|
|
825
|
+
|
|
826
|
+
const details = node('details', 'event-details')
|
|
827
|
+
details.append(node('summary', '', 'payload'))
|
|
828
|
+
details.append(node('pre', '', JSON.stringify(event.payload, null, 2)))
|
|
829
|
+
info.append(details)
|
|
830
|
+
if (event.lastError) {
|
|
831
|
+
const failure = node('details', 'event-details')
|
|
832
|
+
failure.append(node('summary', 'danger', 'last error'))
|
|
833
|
+
failure.append(node('pre', '', event.lastError))
|
|
834
|
+
info.append(failure)
|
|
835
|
+
}
|
|
836
|
+
row.append(info)
|
|
837
|
+
|
|
838
|
+
const eventTrack = node('div', 'event-track')
|
|
839
|
+
const bar = node('span', 'event-bar ' + status)
|
|
840
|
+
bar.style.left = left + '%'
|
|
841
|
+
bar.style.width = Math.min(width, 100 - left) + '%'
|
|
842
|
+
bar.title = eventStatusLabel(event) + ' · ' + formatDuration(end - start)
|
|
843
|
+
append(eventTrack, bar)
|
|
844
|
+
eventTrack.append(lifecycleMarker('created', event.createdAt, 'Created'))
|
|
845
|
+
if (event.firstClaimedAt && event.lastClaimedAt === event.firstClaimedAt) {
|
|
846
|
+
eventTrack.append(lifecycleMarker('claim', event.firstClaimedAt, 'First and latest dispatch'))
|
|
847
|
+
} else {
|
|
848
|
+
if (event.firstClaimedAt) eventTrack.append(lifecycleMarker('claim', event.firstClaimedAt, 'First dispatch'))
|
|
849
|
+
if (event.lastClaimedAt) eventTrack.append(lifecycleMarker('claim', event.lastClaimedAt, 'Latest dispatch'))
|
|
850
|
+
}
|
|
851
|
+
if (event.lastFailedAt) {
|
|
852
|
+
eventTrack.append(lifecycleMarker('failure', event.lastFailedAt, 'Latest caught failure' + (event.lastFailedAttempt === null ? '' : ' · attempt ' + event.lastFailedAttempt)))
|
|
853
|
+
}
|
|
854
|
+
if (event.processedAt) {
|
|
855
|
+
eventTrack.append(lifecycleMarker('complete', event.processedAt, 'Completed' + (event.processedByAttempt === null ? '' : ' · attempt ' + event.processedByAttempt)))
|
|
856
|
+
}
|
|
857
|
+
if (event.failedAt) eventTrack.append(lifecycleMarker('dead-letter', event.failedAt, 'Dead-lettered'))
|
|
858
|
+
row.append(eventTrack)
|
|
859
|
+
timeline.append(row)
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
const legend = node('div', 'timeline-legend')
|
|
863
|
+
for (const [kind, label] of [
|
|
864
|
+
['created', 'Created'],
|
|
865
|
+
['claim', 'First / latest dispatch'],
|
|
866
|
+
['failure', 'Latest caught failure'],
|
|
867
|
+
['complete', 'Completion'],
|
|
868
|
+
['dead-letter', 'Dead-letter'],
|
|
869
|
+
]) {
|
|
870
|
+
const item = node('span', 'legend-item')
|
|
871
|
+
append(item, node('i', 'legend-key ' + kind), document.createTextNode(label))
|
|
872
|
+
legend.append(item)
|
|
873
|
+
}
|
|
874
|
+
return append(node('div'), timeline, legend)
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
const renderMain = () => {
|
|
878
|
+
const main = node('main', 'main')
|
|
879
|
+
if (state.error) main.append(node('div', 'error-box', state.error))
|
|
880
|
+
if (!state.session) {
|
|
881
|
+
const empty = node('div', 'empty')
|
|
882
|
+
const copy = node('div')
|
|
883
|
+
append(copy, node('strong', '', 'No session selected'), node('span', '', 'Choose a durable session to inspect its lifecycle.'))
|
|
884
|
+
empty.append(copy)
|
|
885
|
+
main.append(empty)
|
|
886
|
+
return main
|
|
887
|
+
}
|
|
888
|
+
if (!state.detail) {
|
|
889
|
+
main.append(node('div', 'empty', 'Reading the durable store…'))
|
|
890
|
+
return main
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
const events = state.detail.events
|
|
894
|
+
const snapshots = state.detail.snapshots
|
|
895
|
+
const pending = events.filter((event) => eventStatus(event) === 'pending').length
|
|
896
|
+
const failed = events.filter((event) => eventStatus(event) === 'failed').length
|
|
897
|
+
const attempts = events.reduce((total, event) => total + event.attemptCount, 0)
|
|
898
|
+
const failures = events.reduce((total, event) => total + event.failureCount, 0)
|
|
899
|
+
const first = new Date(events[0].createdAt).getTime()
|
|
900
|
+
const lastEvent = events[events.length - 1]
|
|
901
|
+
const last = new Date(lastEvent.failedAt || lastEvent.processedAt || Date.now()).getTime()
|
|
902
|
+
|
|
903
|
+
const header = node('header', 'session-header')
|
|
904
|
+
const title = node('div', 'session-title')
|
|
905
|
+
append(title, node('p', 'eyebrow', state.contract + ' / session'), node('h1', '', state.session), node('p', '', 'Read-only · durable data · refreshed by SSE'))
|
|
906
|
+
const live = node('span', 'live')
|
|
907
|
+
append(live, node('i'), document.createTextNode('live'))
|
|
908
|
+
append(header, title, live)
|
|
909
|
+
main.append(header)
|
|
910
|
+
|
|
911
|
+
const cards = node('div', 'cards')
|
|
912
|
+
append(cards, metric('Events', String(events.length)), metric('Pending', String(pending)), metric('Dispatches', String(attempts)), metric('Caught failures', String(failures)), metric('Lifetime', formatDuration(last - first)))
|
|
913
|
+
main.append(cards)
|
|
914
|
+
|
|
915
|
+
const forestSection = node('section', 'section')
|
|
916
|
+
const forestHead = node('div', 'section-head')
|
|
917
|
+
append(forestHead, node('h2', '', 'Causal forest'), node('p', '', 'Unknown origins may be roots or legacy events'))
|
|
918
|
+
append(forestSection, forestHead, renderCausalForest(events))
|
|
919
|
+
main.append(forestSection)
|
|
920
|
+
|
|
921
|
+
const section = node('section', 'section')
|
|
922
|
+
const head = node('div', 'section-head')
|
|
923
|
+
append(head, node('h2', '', 'Event lifecycle'), node('p', '', 'Durable operation boundaries · missing history stays unknown'))
|
|
924
|
+
append(section, head, renderTimeline(events))
|
|
925
|
+
main.append(section)
|
|
926
|
+
|
|
927
|
+
const snapshotSection = node('section', 'section')
|
|
928
|
+
const snapshotHead = node('div', 'section-head')
|
|
929
|
+
append(snapshotHead, node('h2', '', 'Snapshots'), node('p', '', failed ? 'Session is stalled' : snapshots.length ? 'Latest durable reducer frontiers' : 'No cached folds'))
|
|
930
|
+
snapshotSection.append(snapshotHead)
|
|
931
|
+
if (snapshots.length) {
|
|
932
|
+
const grid = node('div', 'snapshots')
|
|
933
|
+
for (const snapshot of snapshots) {
|
|
934
|
+
const card = node('div', 'snapshot')
|
|
935
|
+
card.append(node('strong', '', snapshot.reducerName))
|
|
936
|
+
const detail = node('div')
|
|
937
|
+
append(detail, node('span', '', snapshot.updatedAt ? formatRelative(snapshot.updatedAt) : 'time unknown'), node('b', '', '#' + snapshot.index))
|
|
938
|
+
card.append(detail)
|
|
939
|
+
grid.append(card)
|
|
940
|
+
}
|
|
941
|
+
snapshotSection.append(grid)
|
|
942
|
+
}
|
|
943
|
+
main.append(snapshotSection)
|
|
944
|
+
return main
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
const render = () => {
|
|
948
|
+
root.replaceChildren(append(node('div', 'shell'), renderSidebar(), renderMain()))
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
const restoreFromUrl = async () => {
|
|
952
|
+
state.watch?.close()
|
|
953
|
+
const navigation = navigationFromUrl()
|
|
954
|
+
state.contract = state.contracts.some((contract) => contract.name === navigation.contract)
|
|
955
|
+
? navigation.contract
|
|
956
|
+
: state.contracts[0]?.name || null
|
|
957
|
+
state.session = navigation.session
|
|
958
|
+
state.sessions = []
|
|
959
|
+
state.cursor = null
|
|
960
|
+
state.detail = null
|
|
961
|
+
state.error = null
|
|
962
|
+
render()
|
|
963
|
+
try {
|
|
964
|
+
if (state.contract) await loadSessions(true)
|
|
965
|
+
syncUrl(true)
|
|
966
|
+
beginWatch()
|
|
967
|
+
} catch (error) {
|
|
968
|
+
state.error = error instanceof Error ? error.message : String(error)
|
|
969
|
+
render()
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
window.addEventListener('popstate', () => void restoreFromUrl())
|
|
974
|
+
|
|
975
|
+
setInterval(() => {
|
|
976
|
+
if (state.detail?.events.some((event) => eventStatus(event) === 'pending')) render()
|
|
977
|
+
}, 1000)
|
|
978
|
+
|
|
979
|
+
if (bootstrap) {
|
|
980
|
+
syncUrl(true)
|
|
981
|
+
render()
|
|
982
|
+
beginWatch()
|
|
983
|
+
} else {
|
|
984
|
+
loadContracts().then(() => beginWatch()).catch((error) => {
|
|
985
|
+
state.error = error instanceof Error ? error.message : String(error)
|
|
986
|
+
render()
|
|
987
|
+
})
|
|
988
|
+
}
|
|
989
|
+
`
|