experimental-a2 0.6.0 → 0.7.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.
Files changed (145) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/ai-server.d.ts +4 -5
  3. package/dist/ai-server.d.ts.map +1 -1
  4. package/dist/ai-server.js +8 -7
  5. package/dist/ai-server.js.map +1 -1
  6. package/dist/ai.d.ts +334 -2
  7. package/dist/ai.d.ts.map +1 -0
  8. package/dist/ai.js +1 -1
  9. package/dist/client.d.ts +202 -2
  10. package/dist/client.d.ts.map +1 -0
  11. package/dist/client.js +1025 -1
  12. package/dist/client.js.map +1 -0
  13. package/dist/errors-BQuJpe82.js.map +1 -1
  14. package/dist/index.d.ts +22 -3
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/{internal-DstsI6Re.js → internal-DRXJ56EI.js} +5 -28
  17. package/dist/internal-DRXJ56EI.js.map +1 -0
  18. package/dist/react.d.ts +1 -1
  19. package/dist/react.js +1 -1
  20. package/dist/scheduler-qstash.d.ts +3 -3
  21. package/dist/scheduler-qstash.js +4 -5
  22. package/dist/scheduler-qstash.js.map +1 -1
  23. package/dist/scheduler-vercel.d.ts +2 -2
  24. package/dist/scheduler-vercel.js +4 -4
  25. package/dist/scheduler-vercel.js.map +1 -1
  26. package/dist/{server-Duw6MVlB.js → server-286j79Mt.js} +708 -79
  27. package/dist/server-286j79Mt.js.map +1 -0
  28. package/dist/{server-DpvjhdoE.d.ts → server-DgXmORIq.d.ts} +67 -49
  29. package/dist/server-DgXmORIq.d.ts.map +1 -0
  30. package/dist/server.d.ts +3 -3
  31. package/dist/server.js +1 -1
  32. package/dist/store-N8PXxDAS.js.map +1 -1
  33. package/dist/{store-DysUkTH3.d.ts → store-flRz1OWh.d.ts} +2 -57
  34. package/dist/store-flRz1OWh.d.ts.map +1 -0
  35. package/dist/store-memory.d.ts +1 -1
  36. package/dist/store-memory.d.ts.map +1 -1
  37. package/dist/store-memory.js +1 -59
  38. package/dist/store-memory.js.map +1 -1
  39. package/dist/{store-polling-dSeLxzfb.js → store-polling-6DW7F1DT.js} +2 -2
  40. package/dist/{store-polling-dSeLxzfb.js.map → store-polling-6DW7F1DT.js.map} +1 -1
  41. package/dist/store-postgres.d.ts +1 -1
  42. package/dist/store-postgres.js +1 -81
  43. package/dist/store-postgres.js.map +1 -1
  44. package/dist/{store-redis-core-BFLwz0Wj.js → store-redis-core-DEYO8Ryv.js} +48 -134
  45. package/dist/store-redis-core-DEYO8Ryv.js.map +1 -0
  46. package/dist/store-redis-http.d.ts +1 -1
  47. package/dist/store-redis-http.js +2 -3
  48. package/dist/store-redis-http.js.map +1 -1
  49. package/dist/store-redis.d.ts +1 -1
  50. package/dist/store-redis.js +3 -4
  51. package/dist/store-redis.js.map +1 -1
  52. package/dist/store-sqlite.d.ts +1 -1
  53. package/dist/store-sqlite.d.ts.map +1 -1
  54. package/dist/store-sqlite.js +1 -72
  55. package/dist/store-sqlite.js.map +1 -1
  56. package/dist/{wire-BFQmSJ-9.js → wire-B6te_wns.js} +4 -3
  57. package/dist/wire-B6te_wns.js.map +1 -0
  58. package/docs/guides/03-react.mdx +20 -28
  59. package/docs/guides/05-production.mdx +9 -11
  60. package/docs/guides/06-ai-agents.mdx +8 -14
  61. package/docs/guides/09-presence.mdx +14 -19
  62. package/docs/guides/10-transports.mdx +104 -86
  63. package/docs/reference/01-api.mdx +143 -277
  64. package/docs/reference/02-errors.mdx +5 -7
  65. package/package.json +1 -14
  66. package/src/ai-server.ts +9 -5
  67. package/src/client.ts +2 -2
  68. package/src/errors.ts +1 -0
  69. package/src/internal.ts +3 -62
  70. package/src/push-envelope.ts +24 -21
  71. package/src/scheduler-qstash.ts +3 -3
  72. package/src/scheduler-vercel.ts +2 -2
  73. package/src/server-fetch.ts +344 -0
  74. package/src/server.ts +73 -225
  75. package/src/session-socket.ts +36 -20
  76. package/src/sse.ts +2 -2
  77. package/src/store-memory.ts +0 -81
  78. package/src/store-postgres.ts +0 -100
  79. package/src/store-redis-core.ts +47 -211
  80. package/src/store-redis-http.ts +0 -1
  81. package/src/store-redis.ts +0 -1
  82. package/src/store-sqlite.ts +0 -119
  83. package/src/store.ts +0 -60
  84. package/src/wire.ts +2 -1
  85. package/dist/ai-D_PGS-JR.d.ts +0 -334
  86. package/dist/ai-D_PGS-JR.d.ts.map +0 -1
  87. package/dist/cli-B3VuxoDe.js +0 -599
  88. package/dist/cli-B3VuxoDe.js.map +0 -1
  89. package/dist/cli-bin.d.ts +0 -1
  90. package/dist/cli-bin.js +0 -7
  91. package/dist/cli-bin.js.map +0 -1
  92. package/dist/cli.d.ts +0 -20
  93. package/dist/cli.d.ts.map +0 -1
  94. package/dist/cli.js +0 -2
  95. package/dist/client-CdMqi7mC.d.ts +0 -202
  96. package/dist/client-CdMqi7mC.d.ts.map +0 -1
  97. package/dist/client-Dj5d3SP_.js +0 -1026
  98. package/dist/client-Dj5d3SP_.js.map +0 -1
  99. package/dist/devtools-J_jZ2vQf.d.ts +0 -152
  100. package/dist/devtools-J_jZ2vQf.d.ts.map +0 -1
  101. package/dist/devtools-kJJaORn-.js +0 -340
  102. package/dist/devtools-kJJaORn-.js.map +0 -1
  103. package/dist/devtools-server.browser.d.ts +0 -1
  104. package/dist/devtools-server.browser.js +0 -6
  105. package/dist/devtools-server.browser.js.map +0 -1
  106. package/dist/devtools-server.d.ts +0 -23
  107. package/dist/devtools-server.d.ts.map +0 -1
  108. package/dist/devtools-server.js +0 -1270
  109. package/dist/devtools-server.js.map +0 -1
  110. package/dist/devtools.d.ts +0 -2
  111. package/dist/devtools.js +0 -2
  112. package/dist/errors-W6nwJ-fm.d.ts +0 -21
  113. package/dist/errors-W6nwJ-fm.d.ts.map +0 -1
  114. package/dist/http.d.ts +0 -151
  115. package/dist/http.d.ts.map +0 -1
  116. package/dist/http.js +0 -706
  117. package/dist/http.js.map +0 -1
  118. package/dist/inspection-DaxB5jM2.js +0 -13
  119. package/dist/inspection-DaxB5jM2.js.map +0 -1
  120. package/dist/internal-DstsI6Re.js.map +0 -1
  121. package/dist/platform-B4TnJtWu.js +0 -34
  122. package/dist/platform-B4TnJtWu.js.map +0 -1
  123. package/dist/server-DpvjhdoE.d.ts.map +0 -1
  124. package/dist/server-Duw6MVlB.js.map +0 -1
  125. package/dist/store-DysUkTH3.d.ts.map +0 -1
  126. package/dist/store-redis-core-BFLwz0Wj.js.map +0 -1
  127. package/dist/testing.browser.d.ts +0 -1
  128. package/dist/testing.browser.js +0 -6
  129. package/dist/testing.browser.js.map +0 -1
  130. package/dist/testing.d.ts +0 -32
  131. package/dist/testing.d.ts.map +0 -1
  132. package/dist/testing.js +0 -103
  133. package/dist/testing.js.map +0 -1
  134. package/dist/wire-BFQmSJ-9.js.map +0 -1
  135. package/docs/guides/07-devtools.mdx +0 -229
  136. package/src/cli-bin.ts +0 -5
  137. package/src/cli.ts +0 -1046
  138. package/src/devtools-app.ts +0 -989
  139. package/src/devtools-server.browser.ts +0 -5
  140. package/src/devtools-server.ts +0 -604
  141. package/src/devtools.ts +0 -716
  142. package/src/http.ts +0 -394
  143. package/src/inspection.ts +0 -39
  144. package/src/testing.browser.ts +0 -5
  145. package/src/testing.ts +0 -185
@@ -1,989 +0,0 @@
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('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')
25
-
26
- const escapeAttribute = (value: string): string =>
27
- escapeText(value).replaceAll('"', '&quot;')
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
- `