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,1270 +0,0 @@
1
- import { n as InspectionUnsupportedError, r as serverInspection, t as DEVTOOLS_TIMINGS } from "./inspection-DaxB5jM2.js";
2
- import { c as encodeDevtoolsCapture, n as DEVTOOLS_CAPTURE_MEDIA_TYPE, o as DEVTOOLS_SESSION_PAGE_MAX_LIMIT, s as devtoolsSessionRevision, t as DEVTOOLS_CAPABILITIES } from "./devtools-kJJaORn-.js";
3
- //#region src/devtools-app.ts
4
- /** Private browser application served by `experimental-a2/devtools/server`. */
5
- const escapeText = (value) => value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
6
- const escapeAttribute = (value) => escapeText(value).replaceAll("\"", "&quot;");
7
- const formatDuration = (milliseconds) => {
8
- if (milliseconds < 1e3) return `${Math.max(0, Math.round(milliseconds))} ms`;
9
- if (milliseconds < 6e4) return `${(milliseconds / 1e3).toFixed(milliseconds < 1e4 ? 2 : 1)} s`;
10
- return `${(milliseconds / 6e4).toFixed(1)} min`;
11
- };
12
- const formatRelative = (value) => {
13
- const delta = Date.now() - new Date(value).getTime();
14
- if (delta < 1e3) return "now";
15
- if (delta < 6e4) return `${Math.floor(delta / 1e3)}s ago`;
16
- if (delta < 36e5) return `${Math.floor(delta / 6e4)}m ago`;
17
- if (delta < 864e5) return `${Math.floor(delta / 36e5)}h ago`;
18
- return `${Math.floor(delta / 864e5)}d ago`;
19
- };
20
- const sessionStatus = (session) => session.failedCount > 0 ? "failed" : session.pendingCount > 0 ? "pending" : "settled";
21
- const metricHtml = (label, value) => `<div class="card"><span>${escapeText(label)}</span><strong>${escapeText(value)}</strong></div>`;
22
- const shellHtml = (bootstrap) => {
23
- const options = bootstrap.contracts.map(({ name }) => `<option value="${escapeAttribute(name)}"${name === bootstrap.contract ? " selected" : ""}>${escapeText(name)}</option>`).join("");
24
- const sessions = bootstrap.sessions.length ? bootstrap.sessions.map((session) => {
25
- const active = session.sessionId === bootstrap.session ? " active" : "";
26
- const events = `${session.eventCount} ${session.eventCount === 1 ? "event" : "events"}`;
27
- 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>`;
28
- }).join("") : "<div class=\"session-meta\">No durable sessions yet.</div>";
29
- const more = bootstrap.cursor ? "<button class=\"load-more\" type=\"button\">Load more</button>" : "";
30
- let main;
31
- if (!bootstrap.session) main = "<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>";
32
- else if (!bootstrap.detail) main = `<main class="main">${bootstrap.error ? `<div class="error-box">${escapeText(bootstrap.error)}</div>` : ""}<div class="empty"><div><strong>Session unavailable</strong><span>The selected durable session could not be read.</span></div></div></main>`;
33
- else {
34
- const { events } = bootstrap.detail;
35
- const pending = events.filter((event) => event.processedAt === null && event.failedAt === null).length;
36
- const attempts = events.reduce((total, event) => total + event.attemptCount, 0);
37
- const failures = events.reduce((total, event) => total + event.failureCount, 0);
38
- const first = events[0];
39
- const last = events.at(-1);
40
- const lifetime = first && last ? formatDuration(new Date(last.failedAt ?? last.processedAt ?? Date.now()).getTime() - new Date(first.createdAt).getTime()) : "0 ms";
41
- main = `<main class="main">${bootstrap.error ? `<div class="error-box">${escapeText(bootstrap.error)}</div>` : ""}<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>`;
42
- }
43
- 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>`;
44
- };
45
- const bootstrapJson = (bootstrap) => JSON.stringify(bootstrap).replaceAll("<", "\\u003c").replaceAll("\u2028", "\\u2028").replaceAll("\u2029", "\\u2029");
46
- const devtoolsHtml = (basePath, bootstrap) => `<!doctype html>
47
- <html lang="en">
48
- <head>
49
- <meta charset="utf-8">
50
- <meta name="viewport" content="width=device-width, initial-scale=1">
51
- <meta name="color-scheme" content="light">
52
- <base href="${escapeAttribute(basePath)}">
53
- <title>A2 Devtools</title>
54
- <link rel="stylesheet" href="_a2/app.css">
55
- </head>
56
- <body>
57
- <div id="app">${shellHtml(bootstrap)}</div>
58
- <script id="a2-bootstrap" type="application/json">${bootstrapJson(bootstrap)}<\/script>
59
- <script type="module" src="_a2/app.js"><\/script>
60
- </body>
61
- </html>`;
62
- const DEVTOOLS_APP_CSS = String.raw`
63
- :root {
64
- color: #1a1a1a;
65
- background: #fff;
66
- font-family: Geist, "Geist Sans", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
67
- font-synthesis: none;
68
- --surface: #fafafa;
69
- --line: #eaeaea;
70
- --line-strong: #d4d4d4;
71
- --muted: #666;
72
- --faint: #8f8f8f;
73
- --success: #15803d;
74
- --warning: #b45309;
75
- --danger: #dc2626;
76
- }
77
-
78
- * { box-sizing: border-box; }
79
- html, body, #app { min-height: 100%; margin: 0; }
80
- body { min-width: 320px; background: #fff; }
81
- button, select { font: inherit; }
82
- button { color: inherit; }
83
- button:focus-visible, select:focus-visible, summary:focus-visible { outline: 2px solid #000; outline-offset: 2px; }
84
-
85
- .mark {
86
- display: inline-grid;
87
- place-items: center;
88
- width: 30px;
89
- height: 30px;
90
- border-radius: 6px;
91
- background: #000;
92
- color: #fff;
93
- font-size: 12px;
94
- font-weight: 700;
95
- letter-spacing: -.04em;
96
- }
97
-
98
- .shell {
99
- min-height: 100vh;
100
- display: grid;
101
- grid-template-columns: 248px minmax(0, 1fr);
102
- }
103
-
104
- .sidebar {
105
- position: sticky;
106
- top: 0;
107
- height: 100vh;
108
- display: flex;
109
- flex-direction: column;
110
- border-right: 1px solid var(--line);
111
- background: var(--surface);
112
- }
113
-
114
- .brand {
115
- display: flex;
116
- align-items: center;
117
- gap: 10px;
118
- padding: 18px;
119
- border-bottom: 1px solid var(--line);
120
- }
121
-
122
- .brand-copy strong { display: block; font-size: 13px; font-weight: 600; letter-spacing: -.01em; }
123
- .brand-copy span { color: var(--muted); font-size: 10px; }
124
- .side-controls { padding: 14px; border-bottom: 1px solid var(--line); }
125
- .eyebrow { margin: 0 0 8px; color: var(--muted); font-size: 10px; font-weight: 500; letter-spacing: .08em; text-transform: uppercase; }
126
-
127
- .contract-select {
128
- width: 100%;
129
- padding: 8px 9px;
130
- color: #1a1a1a;
131
- border: 1px solid var(--line-strong);
132
- border-radius: 6px;
133
- outline: none;
134
- background: #fff;
135
- }
136
- .contract-select:focus { border-color: #000; }
137
-
138
- .sessions { min-height: 0; flex: 1; overflow: auto; padding: 12px 8px; }
139
- .session-button {
140
- width: 100%;
141
- display: grid;
142
- grid-template-columns: minmax(0, 1fr) auto;
143
- gap: 5px 9px;
144
- padding: 9px 10px;
145
- margin-bottom: 2px;
146
- text-align: left;
147
- border: 1px solid transparent;
148
- border-radius: 6px;
149
- background: transparent;
150
- cursor: pointer;
151
- }
152
- .session-button:hover { background: #f0f0f0; }
153
- .session-button.active { border-color: var(--line-strong); background: #fff; }
154
- .session-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
155
- .session-meta { color: var(--muted); font-size: 10px; }
156
- .dot { align-self: center; width: 6px; height: 6px; border-radius: 99px; background: #999; }
157
- .dot.pending { background: var(--warning); }
158
- .dot.failed { background: var(--danger); }
159
- .dot.settled { background: var(--success); }
160
- .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); }
161
- .load-more:hover { color: #000; border-color: #999; }
162
-
163
- .main { min-width: 0; width: 100%; max-width: 1440px; padding: 38px clamp(24px, 5vw, 72px) 72px; }
164
- .empty { min-height: 70vh; display: grid; place-items: center; color: var(--muted); text-align: center; }
165
- .empty strong { display: block; margin-bottom: 7px; color: #1a1a1a; font-size: 16px; font-weight: 500; }
166
-
167
- .session-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 24px; margin-bottom: 28px; }
168
- .session-title { min-width: 0; }
169
- .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; }
170
- .session-title p { margin: 0; color: var(--muted); font-size: 12px; }
171
- .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; }
172
- .live i { width: 6px; height: 6px; border-radius: 99px; background: var(--success); }
173
-
174
- .cards { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); margin-bottom: 32px; overflow: hidden; border: 1px solid var(--line); border-radius: 8px; }
175
- .card { padding: 15px 16px; border-right: 1px solid var(--line); background: #fff; }
176
- .card:last-child { border-right: 0; }
177
- .card span { display: block; margin-bottom: 8px; color: var(--muted); font-size: 10px; font-weight: 500; }
178
- .card strong { font-size: 20px; font-weight: 500; letter-spacing: -.03em; }
179
-
180
- .section { margin-top: 32px; }
181
- .section-head { display: flex; align-items: end; justify-content: space-between; gap: 20px; margin-bottom: 11px; }
182
- .section-head h2 { margin: 0; font-size: 14px; font-weight: 500; letter-spacing: -.01em; }
183
- .section-head p { margin: 0; color: var(--muted); font-size: 11px; }
184
-
185
- .forest { overflow: hidden; border: 1px solid var(--line); border-radius: 8px; background: #fff; }
186
- .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); }
187
- .forest-row:last-child { border-bottom: 0; }
188
- .forest-row::before { content: ""; position: absolute; top: 0; bottom: 0; left: calc(20px + (var(--depth) - 1) * 22px); width: 1px; background: var(--depth-line, transparent); }
189
- .forest-row.nested { --depth-line: var(--line-strong); }
190
- .forest-main { min-width: 0; display: flex; align-items: center; gap: 8px; }
191
- .forest-main .dot { flex: none; }
192
- .forest-type { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 11px ui-monospace, SFMono-Regular, Menlo, monospace; }
193
- .forest-edge { color: var(--muted); font-size: 10px; white-space: nowrap; }
194
- .forest-edge.unknown { color: var(--warning); }
195
-
196
- .timeline { overflow: hidden; border: 1px solid var(--line); border-radius: 8px; background: #fff; }
197
- .timeline-axis, .event-row { display: grid; grid-template-columns: 310px minmax(320px, 1fr); }
198
- .timeline-axis { min-height: 37px; border-bottom: 1px solid var(--line); color: var(--muted); font-size: 9px; }
199
- .axis-label { padding: 12px 14px; border-right: 1px solid var(--line); font-weight: 500; }
200
- .axis-track { position: relative; display: flex; justify-content: space-between; padding: 12px 16px; }
201
- .event-row { min-height: 84px; border-bottom: 1px solid var(--line); }
202
- .event-row:last-child { border-bottom: 0; }
203
- .event-info { min-width: 0; padding: 11px 14px; border-right: 1px solid var(--line); }
204
- .event-line { display: flex; align-items: center; gap: 8px; min-width: 0; }
205
- .event-index { color: var(--muted); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 10px; }
206
- .event-type { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
207
- .event-sub { display: flex; flex-wrap: wrap; gap: 5px 9px; margin-top: 7px; color: var(--muted); font-size: 9px; }
208
- .event-sub .danger { color: var(--danger); }
209
- .event-sub .warning { color: var(--warning); }
210
- .event-facts { display: flex; flex-wrap: wrap; gap: 4px 9px; margin-top: 7px; color: var(--muted); font-size: 9px; }
211
- .event-facts .warning { color: var(--warning); }
212
- .event-details { margin-top: 8px; }
213
- .event-details summary { color: var(--muted); cursor: pointer; font-size: 9px; }
214
- .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; }
215
- .event-track { position: relative; min-height: 84px; }
216
- .event-track::before { content: ""; position: absolute; top: 42px; right: 0; left: 0; height: 1px; background: var(--line); }
217
- .event-bar { position: absolute; top: 39px; height: 7px; min-width: 4px; border-radius: 99px; background: #111; }
218
- .event-bar.processed { background: #111; }
219
- .event-bar.pending { top: 38px; height: 9px; border: 1px solid #777; background: #fff; }
220
- .event-bar.failed { background: var(--danger); }
221
- .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; }
222
- .lifecycle-marker.created { top: 27px; border-radius: 2px; background: #fff; box-shadow: 0 0 0 1px #777; }
223
- .lifecycle-marker.claim { top: 37px; background: #111; box-shadow: 0 0 0 1px #111; }
224
- .lifecycle-marker.failure { top: 47px; background: var(--danger); box-shadow: 0 0 0 1px var(--danger); }
225
- .lifecycle-marker.complete { top: 47px; background: var(--success); box-shadow: 0 0 0 1px var(--success); }
226
- .lifecycle-marker.dead-letter { top: 57px; background: #fff; box-shadow: 0 0 0 1px var(--danger); }
227
- .timeline-legend { display: flex; flex-wrap: wrap; gap: 7px 14px; margin: 9px 2px 0; color: var(--muted); font-size: 9px; }
228
- .legend-item { display: inline-flex; align-items: center; gap: 6px; }
229
- .legend-key { width: 7px; height: 7px; border-radius: 99px; background: #777; }
230
- .legend-key.created { border: 1px solid #777; border-radius: 1px; background: #fff; }
231
- .legend-key.claim { background: #111; }
232
- .legend-key.failure { background: var(--danger); }
233
- .legend-key.dead-letter { border: 1px solid var(--danger); background: #fff; }
234
- .legend-key.complete { background: var(--success); }
235
-
236
- .snapshots { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 8px; }
237
- .snapshot { padding: 14px; border: 1px solid var(--line); border-radius: 8px; background: #fff; }
238
- .snapshot strong { display: block; margin-bottom: 8px; font: 12px ui-monospace, SFMono-Regular, Menlo, monospace; }
239
- .snapshot div { display: flex; justify-content: space-between; color: var(--muted); font-size: 10px; }
240
- .snapshot b { color: #111; font-weight: 500; }
241
- .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; }
242
-
243
- @media (max-width: 850px) {
244
- .shell { grid-template-columns: 1fr; }
245
- .sidebar { position: static; height: auto; max-height: 310px; border-right: 0; border-bottom: 1px solid var(--line); }
246
- .sessions { max-height: 180px; }
247
- .cards { grid-template-columns: repeat(2, minmax(0, 1fr)); }
248
- .card { border-bottom: 1px solid var(--line); }
249
- .card:nth-child(2n) { border-right: 0; }
250
- .card:last-child { border-bottom: 0; }
251
- .timeline-axis, .event-row { grid-template-columns: 260px minmax(300px, 1fr); }
252
- .timeline { overflow-x: auto; }
253
- .timeline-axis, .event-row { min-width: 610px; }
254
- .main { padding: 28px 20px 60px; }
255
- }
256
- `;
257
- const DEVTOOLS_APP_JS = String.raw`
258
- const root = document.querySelector('#app')
259
- const readBootstrap = () => {
260
- const element = document.querySelector('#a2-bootstrap')
261
- if (!element) return null
262
- try {
263
- const bootstrap = JSON.parse(element.textContent || 'null')
264
- element.remove()
265
- return bootstrap
266
- } catch {
267
- return null
268
- }
269
- }
270
- const navigationFromUrl = () => {
271
- const params = new URL(window.location.href).searchParams
272
- return {
273
- contract: params.get('contract'),
274
- session: params.get('session'),
275
- }
276
- }
277
- const initialNavigation = navigationFromUrl()
278
- const bootstrap = readBootstrap()
279
- const state = {
280
- contracts: bootstrap?.contracts || [],
281
- contract: bootstrap ? bootstrap.contract : initialNavigation.contract,
282
- sessions: bootstrap?.sessions || [],
283
- cursor: bootstrap?.cursor || null,
284
- session: bootstrap ? bootstrap.session : initialNavigation.session,
285
- detail: bootstrap?.detail || null,
286
- watch: null,
287
- error: bootstrap?.error || null,
288
- }
289
-
290
- const syncUrl = (replace) => {
291
- const url = new URL(window.location.href)
292
- if (state.contract) url.searchParams.set('contract', state.contract)
293
- else url.searchParams.delete('contract')
294
- if (state.session) url.searchParams.set('session', state.session)
295
- else url.searchParams.delete('session')
296
- const historyState = { contract: state.contract, session: state.session }
297
- if (replace) window.history.replaceState(historyState, '', url)
298
- else window.history.pushState(historyState, '', url)
299
- }
300
-
301
- const node = (tag, className, text) => {
302
- const element = document.createElement(tag)
303
- if (className) element.className = className
304
- if (text !== undefined) element.textContent = text
305
- return element
306
- }
307
-
308
- const append = (parent, ...children) => {
309
- for (const child of children) if (child) parent.append(child)
310
- return parent
311
- }
312
-
313
- const request = async (resource, params = {}) => {
314
- const url = new URL('_a2/' + resource, document.baseURI)
315
- for (const [key, value] of Object.entries(params)) {
316
- if (value !== null && value !== undefined) url.searchParams.set(key, String(value))
317
- }
318
- const response = await fetch(url, { headers: { accept: 'application/json' } })
319
- if (!response.ok) {
320
- let message = response.status + ' ' + response.statusText
321
- try { message = (await response.json()).error || message } catch {}
322
- throw new Error(message)
323
- }
324
- return response.json()
325
- }
326
-
327
- const detailRevision = async (detail) => {
328
- const timestamp = (value) => value === null ? null : new Date(value).getTime()
329
- const bytes = new TextEncoder().encode(JSON.stringify({
330
- events: detail.events.map((event) => [
331
- event.id,
332
- event.index,
333
- event.cause?.index ?? null,
334
- event.cause?.attempt ?? null,
335
- timestamp(event.processedAt),
336
- event.processedByAttempt,
337
- timestamp(event.firstClaimedAt),
338
- timestamp(event.lastClaimedAt),
339
- event.attemptCount,
340
- event.failureCount,
341
- timestamp(event.lastFailedAt),
342
- event.lastFailedAttempt,
343
- event.lastError,
344
- timestamp(event.failedAt),
345
- ]),
346
- snapshots: detail.snapshots.map((snapshot) => [
347
- snapshot.reducerName,
348
- snapshot.index,
349
- snapshot.updatedAt === null ? 0 : new Date(snapshot.updatedAt).getTime(),
350
- ]),
351
- }))
352
- const digest = await crypto.subtle.digest('SHA-256', bytes)
353
- return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('')
354
- }
355
-
356
- const requestDetail = async (contract, session) => {
357
- const events = []
358
- let snapshots = []
359
- let cursor = null
360
- let throughIndex = null
361
- const seenCursors = new Set()
362
- do {
363
- const page = await request('session', {
364
- contract,
365
- session,
366
- limit: ${250},
367
- cursor,
368
- })
369
- if ('revision' in page) return page
370
- if (page.contract !== contract || page.sessionId !== session) {
371
- throw new Error('Session page identity changed during inspection.')
372
- }
373
- if (throughIndex === null) {
374
- throughIndex = page.throughIndex
375
- snapshots = page.snapshots
376
- } else if (page.throughIndex !== throughIndex || page.snapshots.length > 0) {
377
- throw new Error('Session page frontier changed during inspection.')
378
- }
379
- for (const event of page.events) {
380
- if (event.index !== events.length + 1 || event.index > throughIndex) {
381
- throw new Error('Session event pages are not contiguous.')
382
- }
383
- events.push(event)
384
- }
385
- if (page.cursor !== null) {
386
- if (
387
- page.events.length === 0 ||
388
- events.length === throughIndex ||
389
- seenCursors.has(page.cursor)
390
- ) {
391
- throw new Error('Session cursor did not advance.')
392
- }
393
- seenCursors.add(page.cursor)
394
- }
395
- cursor = page.cursor
396
- } while (cursor !== null)
397
- if (throughIndex !== events.length) {
398
- throw new Error('Session event pages ended before their frozen frontier.')
399
- }
400
- const detail = {
401
- protocolVersion: ${1},
402
- contract,
403
- sessionId: session,
404
- revision: '',
405
- events,
406
- snapshots,
407
- }
408
- detail.revision = await detailRevision(detail)
409
- return detail
410
- }
411
-
412
- const watchUrl = () => {
413
- const url = new URL('_a2/watch', document.baseURI)
414
- url.searchParams.set('contract', state.contract)
415
- url.searchParams.set('session', state.session)
416
- if (state.detail?.revision) url.searchParams.set('revision', state.detail.revision)
417
- return url
418
- }
419
-
420
- const formatTime = (value) => new Intl.DateTimeFormat(undefined, {
421
- hour: '2-digit', minute: '2-digit', second: '2-digit', fractionalSecondDigits: 3,
422
- }).format(new Date(value))
423
-
424
- const formatRelative = (value) => {
425
- const delta = Date.now() - new Date(value).getTime()
426
- if (delta < 1000) return 'now'
427
- if (delta < 60000) return Math.floor(delta / 1000) + 's ago'
428
- if (delta < 3600000) return Math.floor(delta / 60000) + 'm ago'
429
- if (delta < 86400000) return Math.floor(delta / 3600000) + 'h ago'
430
- return Math.floor(delta / 86400000) + 'd ago'
431
- }
432
-
433
- const formatDuration = (milliseconds) => {
434
- if (milliseconds < 1000) return Math.max(0, Math.round(milliseconds)) + ' ms'
435
- if (milliseconds < 60000) return (milliseconds / 1000).toFixed(milliseconds < 10000 ? 2 : 1) + ' s'
436
- return (milliseconds / 60000).toFixed(1) + ' min'
437
- }
438
-
439
- const sessionStatus = (session) => session.failedCount > 0 ? 'failed' : session.pendingCount > 0 ? 'pending' : 'settled'
440
- const eventStatus = (event) => event.failedAt ? 'failed' : event.processedAt ? 'processed' : 'pending'
441
- const eventStatusLabel = (event) => event.failedAt ? 'dead-lettered' : event.processedAt ? 'completed' : 'pending'
442
-
443
- const durableOutcomeGap = (event) => Math.max(
444
- 0,
445
- event.attemptCount - event.failureCount - (event.processedByAttempt === null ? 0 : 1),
446
- )
447
-
448
- const causalEntries = (events) => {
449
- const byIndex = new Map(events.map((event) => [event.index, event]))
450
- const children = new Map()
451
- for (const event of events) {
452
- if (!event.cause || !byIndex.has(event.cause.index)) continue
453
- const siblings = children.get(event.cause.index) || []
454
- siblings.push(event)
455
- children.set(event.cause.index, siblings)
456
- }
457
-
458
- const roots = events.filter((event) => !event.cause || !byIndex.has(event.cause.index))
459
- const entries = []
460
- const visited = new Set()
461
- const visit = (event, depth) => {
462
- if (visited.has(event.index)) return
463
- visited.add(event.index)
464
- entries.push({ event, depth })
465
- for (const child of children.get(event.index) || []) visit(child, depth + 1)
466
- }
467
- for (const root of roots) visit(root, 0)
468
- for (const event of events) visit(event, 0)
469
- return entries
470
- }
471
-
472
- const loadContracts = async () => {
473
- const data = await request('contracts')
474
- state.contracts = data.contracts
475
- if (!data.contracts.some((contract) => contract.name === state.contract)) {
476
- state.contract = data.contracts[0]?.name || null
477
- state.session = null
478
- }
479
- if (state.contract) await loadSessions(true)
480
- syncUrl(true)
481
- render()
482
- }
483
-
484
- const loadSessions = async (reset) => {
485
- if (!state.contract) return
486
- const data = await request('sessions', {
487
- contract: state.contract,
488
- cursor: reset ? null : state.cursor,
489
- limit: 50,
490
- })
491
- state.sessions = reset ? data.sessions : state.sessions.concat(data.sessions)
492
- state.cursor = data.cursor
493
- if (reset && !state.session) {
494
- state.session = state.sessions[0]?.sessionId || null
495
- }
496
- if (state.session) await loadDetail()
497
- }
498
-
499
- const loadDetail = async () => {
500
- if (!state.contract || !state.session) return
501
- state.detail = await requestDetail(state.contract, state.session)
502
- state.error = null
503
- render()
504
- }
505
-
506
- const beginWatch = () => {
507
- state.watch?.close()
508
- if (!state.session) return
509
- const watchedContract = state.contract
510
- const watchedSession = state.session
511
- const source = new EventSource(watchUrl())
512
- source.addEventListener('invalidate', async () => {
513
- if (state.contract !== watchedContract || state.session !== watchedSession) return
514
- try {
515
- await Promise.all([loadDetail(), refreshSelectedSummary()])
516
- } catch (error) {
517
- state.error = error instanceof Error ? error.message : String(error)
518
- render()
519
- }
520
- })
521
- source.onerror = () => {
522
- // EventSource reconnects. Durable state is fetched on the next
523
- // invalidation, so a dropped frame cannot make the view incorrect.
524
- }
525
- state.watch = source
526
- }
527
-
528
- const refreshSelectedSummary = async () => {
529
- const data = await request('sessions', { contract: state.contract, limit: 100 })
530
- const fresh = data.sessions.find((session) => session.sessionId === state.session)
531
- const index = state.sessions.findIndex((session) => session.sessionId === state.session)
532
- if (fresh && index !== -1) state.sessions[index] = fresh
533
- render()
534
- }
535
-
536
- const selectContract = async (name) => {
537
- state.watch?.close()
538
- state.contract = name
539
- state.session = null
540
- state.detail = null
541
- state.sessions = []
542
- state.cursor = null
543
- state.error = null
544
- syncUrl(false)
545
- render()
546
- try {
547
- await loadSessions(true)
548
- syncUrl(true)
549
- beginWatch()
550
- } catch (error) {
551
- state.error = error instanceof Error ? error.message : String(error)
552
- render()
553
- }
554
- }
555
-
556
- const selectSession = async (id) => {
557
- if (id === state.session) return
558
-
559
- state.watch?.close()
560
- state.session = id
561
- state.detail = null
562
- state.error = null
563
- syncUrl(false)
564
- render()
565
- try {
566
- await loadDetail()
567
- beginWatch()
568
- } catch (error) {
569
- state.error = error instanceof Error ? error.message : String(error)
570
- render()
571
- }
572
- }
573
-
574
- const renderSidebar = () => {
575
- const sidebar = node('aside', 'sidebar')
576
- const brand = node('div', 'brand')
577
- const copy = node('div', 'brand-copy')
578
- append(copy, node('strong', '', 'A2 Devtools'), node('span', '', 'Durable execution data'))
579
- append(brand, node('span', 'mark', 'A2'), copy)
580
- sidebar.append(brand)
581
-
582
- const controls = node('div', 'side-controls')
583
- controls.append(node('p', 'eyebrow', 'Contract'))
584
- const select = node('select', 'contract-select')
585
- select.setAttribute('aria-label', 'Contract')
586
- for (const contract of state.contracts) {
587
- const option = node('option', '', contract.name)
588
- option.value = contract.name
589
- option.selected = contract.name === state.contract
590
- select.append(option)
591
- }
592
- select.addEventListener('change', () => void selectContract(select.value))
593
- controls.append(select)
594
- sidebar.append(controls)
595
-
596
- const sessions = node('div', 'sessions')
597
- sessions.append(node('p', 'eyebrow', 'Sessions'))
598
- if (state.sessions.length === 0) sessions.append(node('div', 'session-meta', 'No durable sessions yet.'))
599
- for (const session of state.sessions) {
600
- const status = sessionStatus(session)
601
- const button = node('button', 'session-button' + (session.sessionId === state.session ? ' active' : ''))
602
- button.type = 'button'
603
- button.addEventListener('click', () => void selectSession(session.sessionId))
604
- append(
605
- button,
606
- node('span', 'session-name', session.sessionId),
607
- node('span', 'dot ' + status),
608
- node('span', 'session-meta', session.eventCount + (session.eventCount === 1 ? ' event' : ' events')),
609
- node('span', 'session-meta', formatRelative(session.updatedAt)),
610
- )
611
- sessions.append(button)
612
- }
613
- if (state.cursor) {
614
- const more = node('button', 'load-more', 'Load more')
615
- more.type = 'button'
616
- more.addEventListener('click', async () => {
617
- more.disabled = true
618
- try { await loadSessions(false); render() } catch (error) {
619
- state.error = error instanceof Error ? error.message : String(error)
620
- render()
621
- }
622
- })
623
- sessions.append(more)
624
- }
625
- sidebar.append(sessions)
626
- return sidebar
627
- }
628
-
629
- const metric = (label, value) => {
630
- const card = node('div', 'card')
631
- append(card, node('span', '', label), node('strong', '', value))
632
- return card
633
- }
634
-
635
- const renderCausalForest = (events) => {
636
- const forest = node('div', 'forest')
637
- const indexes = new Set(events.map((event) => event.index))
638
- for (const { event, depth } of causalEntries(events)) {
639
- const row = node('div', 'forest-row' + (depth > 0 ? ' nested' : ''))
640
- row.style.setProperty('--depth', String(depth))
641
- const main = node('div', 'forest-main')
642
- append(
643
- main,
644
- node('span', 'dot ' + eventStatus(event)),
645
- node('span', 'event-index', '#' + event.index),
646
- node('span', 'forest-type', event.type),
647
- )
648
- row.append(main)
649
-
650
- let edge
651
- if (!event.cause) {
652
- edge = node('span', 'forest-edge unknown', 'origin unknown')
653
- edge.title = 'This may be a root append or an event written before causal metadata existed.'
654
- } else if (!indexes.has(event.cause.index)) {
655
- edge = node('span', 'forest-edge unknown', 'parent #' + event.cause.index + ' unavailable · attempt ' + event.cause.attempt)
656
- } else {
657
- edge = node('span', 'forest-edge', 'from #' + event.cause.index + ' · attempt ' + event.cause.attempt)
658
- }
659
- row.append(edge)
660
- forest.append(row)
661
- }
662
- return forest
663
- }
664
-
665
- const renderTimeline = (events) => {
666
- const timeline = node('div', 'timeline')
667
- const now = Date.now()
668
- const times = events.flatMap((event) => [
669
- event.createdAt,
670
- event.firstClaimedAt,
671
- event.lastClaimedAt,
672
- event.lastFailedAt,
673
- event.processedAt,
674
- event.failedAt,
675
- eventStatus(event) === 'pending' ? now : null,
676
- ].filter((value) => value !== null).map((value) => new Date(value).getTime()))
677
- const minimum = Math.min(...events.map((event) => new Date(event.createdAt).getTime()))
678
- const maximum = Math.max(...times, minimum + 1)
679
- const range = maximum - minimum
680
- const position = (value) => Math.max(0, Math.min(100, (new Date(value).getTime() - minimum) / range * 100))
681
- const markerPosition = (value) => Math.max(.75, Math.min(99.25, position(value)))
682
- const lifecycleMarker = (kind, value, title) => {
683
- const marker = node('span', 'lifecycle-marker ' + kind)
684
- marker.style.left = markerPosition(value) + '%'
685
- marker.title = title + ' · ' + formatTime(value)
686
- return marker
687
- }
688
-
689
- const axis = node('div', 'timeline-axis')
690
- axis.append(node('div', 'axis-label', 'Event'))
691
- const track = node('div', 'axis-track')
692
- for (const percent of [0, 25, 50, 75, 100]) {
693
- track.append(node('span', '', formatDuration(range * percent / 100)))
694
- }
695
- axis.append(track)
696
- timeline.append(axis)
697
-
698
- for (const event of events) {
699
- const status = eventStatus(event)
700
- const start = new Date(event.createdAt).getTime()
701
- const end = new Date(event.failedAt || event.processedAt || now).getTime()
702
- const left = position(event.createdAt)
703
- const width = Math.max((end - start) / range * 100, .45)
704
- const row = node('div', 'event-row')
705
- const info = node('div', 'event-info')
706
- const line = node('div', 'event-line')
707
- append(line, node('span', 'event-index', '#' + event.index), node('span', 'event-type', event.type))
708
- info.append(line)
709
- const sub = node('div', 'event-sub')
710
- sub.append(node('span', status === 'failed' ? 'danger' : '', eventStatusLabel(event)))
711
- sub.append(node('span', '', event.attemptCount + (event.attemptCount === 1 ? ' dispatch' : ' dispatches')))
712
- sub.append(node('span', event.failureCount > 0 ? 'danger' : '', event.failureCount + (event.failureCount === 1 ? ' caught failure' : ' caught failures')))
713
- sub.append(node('span', '', 'created ' + formatTime(event.createdAt)))
714
- info.append(sub)
715
-
716
- const facts = node('div', 'event-facts')
717
- if (event.attemptCount === 0) {
718
- facts.append(node('span', '', 'not dispatched'))
719
- } else if (!event.firstClaimedAt) {
720
- facts.append(node('span', 'warning', 'first dispatch time unknown'))
721
- }
722
- if (event.attemptCount > 1) {
723
- facts.append(node('span', '', (event.attemptCount - 1) + (event.attemptCount === 2 ? ' redispatch' : ' redispatches')))
724
- }
725
- const outcomeGap = durableOutcomeGap(event)
726
- if (outcomeGap > 0) {
727
- const hint = node('span', 'warning', outcomeGap + (outcomeGap === 1 ? ' dispatch without outcome' : ' dispatches without outcomes'))
728
- hint.title = 'May indicate active work, a hard crash, or a superseded attempt.'
729
- facts.append(hint)
730
- }
731
- if (event.lastFailedAttempt !== null) {
732
- facts.append(node('span', 'danger', 'latest failure · attempt ' + event.lastFailedAttempt))
733
- }
734
- if (event.processedAt) {
735
- facts.append(node('span', '', event.processedByAttempt === null ? 'completing attempt not recorded' : 'completed by attempt ' + event.processedByAttempt))
736
- }
737
- if (facts.childNodes.length > 0) info.append(facts)
738
-
739
- const details = node('details', 'event-details')
740
- details.append(node('summary', '', 'payload'))
741
- details.append(node('pre', '', JSON.stringify(event.payload, null, 2)))
742
- info.append(details)
743
- if (event.lastError) {
744
- const failure = node('details', 'event-details')
745
- failure.append(node('summary', 'danger', 'last error'))
746
- failure.append(node('pre', '', event.lastError))
747
- info.append(failure)
748
- }
749
- row.append(info)
750
-
751
- const eventTrack = node('div', 'event-track')
752
- const bar = node('span', 'event-bar ' + status)
753
- bar.style.left = left + '%'
754
- bar.style.width = Math.min(width, 100 - left) + '%'
755
- bar.title = eventStatusLabel(event) + ' · ' + formatDuration(end - start)
756
- append(eventTrack, bar)
757
- eventTrack.append(lifecycleMarker('created', event.createdAt, 'Created'))
758
- if (event.firstClaimedAt && event.lastClaimedAt === event.firstClaimedAt) {
759
- eventTrack.append(lifecycleMarker('claim', event.firstClaimedAt, 'First and latest dispatch'))
760
- } else {
761
- if (event.firstClaimedAt) eventTrack.append(lifecycleMarker('claim', event.firstClaimedAt, 'First dispatch'))
762
- if (event.lastClaimedAt) eventTrack.append(lifecycleMarker('claim', event.lastClaimedAt, 'Latest dispatch'))
763
- }
764
- if (event.lastFailedAt) {
765
- eventTrack.append(lifecycleMarker('failure', event.lastFailedAt, 'Latest caught failure' + (event.lastFailedAttempt === null ? '' : ' · attempt ' + event.lastFailedAttempt)))
766
- }
767
- if (event.processedAt) {
768
- eventTrack.append(lifecycleMarker('complete', event.processedAt, 'Completed' + (event.processedByAttempt === null ? '' : ' · attempt ' + event.processedByAttempt)))
769
- }
770
- if (event.failedAt) eventTrack.append(lifecycleMarker('dead-letter', event.failedAt, 'Dead-lettered'))
771
- row.append(eventTrack)
772
- timeline.append(row)
773
- }
774
-
775
- const legend = node('div', 'timeline-legend')
776
- for (const [kind, label] of [
777
- ['created', 'Created'],
778
- ['claim', 'First / latest dispatch'],
779
- ['failure', 'Latest caught failure'],
780
- ['complete', 'Completion'],
781
- ['dead-letter', 'Dead-letter'],
782
- ]) {
783
- const item = node('span', 'legend-item')
784
- append(item, node('i', 'legend-key ' + kind), document.createTextNode(label))
785
- legend.append(item)
786
- }
787
- return append(node('div'), timeline, legend)
788
- }
789
-
790
- const renderMain = () => {
791
- const main = node('main', 'main')
792
- if (state.error) main.append(node('div', 'error-box', state.error))
793
- if (!state.session) {
794
- const empty = node('div', 'empty')
795
- const copy = node('div')
796
- append(copy, node('strong', '', 'No session selected'), node('span', '', 'Choose a durable session to inspect its lifecycle.'))
797
- empty.append(copy)
798
- main.append(empty)
799
- return main
800
- }
801
- if (!state.detail) {
802
- main.append(node('div', 'empty', 'Reading the durable store…'))
803
- return main
804
- }
805
-
806
- const events = state.detail.events
807
- const snapshots = state.detail.snapshots
808
- const pending = events.filter((event) => eventStatus(event) === 'pending').length
809
- const failed = events.filter((event) => eventStatus(event) === 'failed').length
810
- const attempts = events.reduce((total, event) => total + event.attemptCount, 0)
811
- const failures = events.reduce((total, event) => total + event.failureCount, 0)
812
- const first = new Date(events[0].createdAt).getTime()
813
- const lastEvent = events[events.length - 1]
814
- const last = new Date(lastEvent.failedAt || lastEvent.processedAt || Date.now()).getTime()
815
-
816
- const header = node('header', 'session-header')
817
- const title = node('div', 'session-title')
818
- append(title, node('p', 'eyebrow', state.contract + ' / session'), node('h1', '', state.session), node('p', '', 'Read-only · durable data · refreshed by SSE'))
819
- const live = node('span', 'live')
820
- append(live, node('i'), document.createTextNode('live'))
821
- append(header, title, live)
822
- main.append(header)
823
-
824
- const cards = node('div', 'cards')
825
- 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)))
826
- main.append(cards)
827
-
828
- const forestSection = node('section', 'section')
829
- const forestHead = node('div', 'section-head')
830
- append(forestHead, node('h2', '', 'Causal forest'), node('p', '', 'Unknown origins may be roots or legacy events'))
831
- append(forestSection, forestHead, renderCausalForest(events))
832
- main.append(forestSection)
833
-
834
- const section = node('section', 'section')
835
- const head = node('div', 'section-head')
836
- append(head, node('h2', '', 'Event lifecycle'), node('p', '', 'Durable operation boundaries · missing history stays unknown'))
837
- append(section, head, renderTimeline(events))
838
- main.append(section)
839
-
840
- const snapshotSection = node('section', 'section')
841
- const snapshotHead = node('div', 'section-head')
842
- append(snapshotHead, node('h2', '', 'Snapshots'), node('p', '', failed ? 'Session is stalled' : snapshots.length ? 'Latest durable reducer frontiers' : 'No cached folds'))
843
- snapshotSection.append(snapshotHead)
844
- if (snapshots.length) {
845
- const grid = node('div', 'snapshots')
846
- for (const snapshot of snapshots) {
847
- const card = node('div', 'snapshot')
848
- card.append(node('strong', '', snapshot.reducerName))
849
- const detail = node('div')
850
- append(detail, node('span', '', snapshot.updatedAt ? formatRelative(snapshot.updatedAt) : 'time unknown'), node('b', '', '#' + snapshot.index))
851
- card.append(detail)
852
- grid.append(card)
853
- }
854
- snapshotSection.append(grid)
855
- }
856
- main.append(snapshotSection)
857
- return main
858
- }
859
-
860
- const render = () => {
861
- root.replaceChildren(append(node('div', 'shell'), renderSidebar(), renderMain()))
862
- }
863
-
864
- const restoreFromUrl = async () => {
865
- state.watch?.close()
866
- const navigation = navigationFromUrl()
867
- state.contract = state.contracts.some((contract) => contract.name === navigation.contract)
868
- ? navigation.contract
869
- : state.contracts[0]?.name || null
870
- state.session = navigation.session
871
- state.sessions = []
872
- state.cursor = null
873
- state.detail = null
874
- state.error = null
875
- render()
876
- try {
877
- if (state.contract) await loadSessions(true)
878
- syncUrl(true)
879
- beginWatch()
880
- } catch (error) {
881
- state.error = error instanceof Error ? error.message : String(error)
882
- render()
883
- }
884
- }
885
-
886
- window.addEventListener('popstate', () => void restoreFromUrl())
887
-
888
- setInterval(() => {
889
- if (state.detail?.events.some((event) => eventStatus(event) === 'pending')) render()
890
- }, 1000)
891
-
892
- if (bootstrap) {
893
- syncUrl(true)
894
- render()
895
- beginWatch()
896
- } else {
897
- loadContracts().then(() => beginWatch()).catch((error) => {
898
- state.error = error instanceof Error ? error.message : String(error)
899
- render()
900
- })
901
- }
902
- `;
903
- //#endregion
904
- //#region src/devtools-server.ts
905
- /**
906
- * experimental-a2/devtools/server — a read-only dashboard over A2's durable data.
907
- * One Request handler serves the application, its private browser bundle,
908
- * JSON inspection endpoints, and live SSE invalidations.
909
- */
910
- const MARKER = "/_a2/";
911
- const encoder = new TextEncoder();
912
- const isDevelopment = () => typeof process !== "undefined" && process.env?.["NODE_ENV"] === "development";
913
- const headers = (contentType) => ({
914
- "content-type": contentType,
915
- "cache-control": "no-store",
916
- "x-content-type-options": "nosniff",
917
- "referrer-policy": "no-referrer"
918
- });
919
- const json = (value, init) => Response.json(value, {
920
- ...init,
921
- headers: {
922
- ...headers("application/json; charset=utf-8"),
923
- ...init?.headers
924
- }
925
- });
926
- const notFound = () => new Response("Not found", {
927
- status: 404,
928
- headers: headers("text/plain")
929
- });
930
- const badRequest = (message) => json({ error: message }, { status: 400 });
931
- const unavailable = (message) => json({ error: message }, { status: 501 });
932
- const wireSummary = (session) => ({
933
- sessionId: session.sessionId,
934
- eventCount: session.eventCount,
935
- pendingCount: session.pendingCount,
936
- failedCount: session.failedCount,
937
- attemptCount: session.attemptCount,
938
- failureCount: session.failureCount,
939
- firstEventAt: session.firstEventAt.toISOString(),
940
- updatedAt: session.updatedAt.toISOString()
941
- });
942
- const wireEvent = (event) => ({
943
- id: event.id,
944
- type: event.type,
945
- payload: event.payload,
946
- index: event.index,
947
- sessionId: event.sessionId,
948
- createdAt: event.createdAt.toISOString(),
949
- cause: event.cause === null ? null : {
950
- index: event.cause.index,
951
- attempt: event.cause.attempt,
952
- ...event.cause.batchSize === void 0 ? {} : { batchSize: event.cause.batchSize }
953
- },
954
- lane: event.lane,
955
- processedAt: event.processedAt?.toISOString() ?? null,
956
- processedByAttempt: event.processedByAttempt,
957
- returnedEventIds: event.returnedEventIds,
958
- firstClaimedAt: event.firstClaimedAt?.toISOString() ?? null,
959
- lastClaimedAt: event.lastClaimedAt?.toISOString() ?? null,
960
- attemptCount: event.attemptCount,
961
- claimHolder: event.claimHolder,
962
- claimExpiresAt: event.claimExpiresAt?.toISOString() ?? null,
963
- failureCount: event.failureCount,
964
- lastFailedAt: event.lastFailedAt?.toISOString() ?? null,
965
- lastFailedAttempt: event.lastFailedAttempt,
966
- lastError: event.lastError,
967
- failedAt: event.failedAt?.toISOString() ?? null
968
- });
969
- const wireSnapshot = (snapshot) => ({
970
- reducerName: snapshot.reducerName,
971
- index: snapshot.index,
972
- updatedAt: snapshot.updatedAt.getTime() === 0 ? null : snapshot.updatedAt.toISOString()
973
- });
974
- const sessionRevision = async (detail) => {
975
- return devtoolsSessionRevision({
976
- events: detail.events.map(wireEvent),
977
- snapshots: detail.snapshots.map(wireSnapshot)
978
- });
979
- };
980
- const wireDetail = async (contract, sessionId, detail) => ({
981
- protocolVersion: 1,
982
- contract,
983
- sessionId,
984
- revision: await sessionRevision(detail),
985
- events: detail.events.map(wireEvent),
986
- snapshots: detail.snapshots.map(wireSnapshot)
987
- });
988
- const readCompleteSession = async (inspection, sessionId) => {
989
- const events = [];
990
- let snapshots = [];
991
- let afterIndex = 0;
992
- let throughIndex;
993
- for (;;) {
994
- const page = await inspection.readSessionPage(sessionId, {
995
- afterIndex,
996
- limit: 250,
997
- ...throughIndex === void 0 ? {} : { throughIndex }
998
- });
999
- if (throughIndex === void 0) {
1000
- throughIndex = page.throughIndex;
1001
- snapshots = page.snapshots;
1002
- } else if (page.throughIndex !== throughIndex) throw new Error("inspection session frontier changed between pages");
1003
- if (events.length + page.events.length > page.throughIndex) throw new Error("inspection session page crossed its frozen frontier");
1004
- events.push(...page.events);
1005
- if (page.nextIndex === null) break;
1006
- if (events.length === page.throughIndex) throw new Error("inspection session cursor continued past its frontier");
1007
- if (page.nextIndex <= afterIndex) throw new Error("inspection session cursor did not advance");
1008
- afterIndex = page.nextIndex;
1009
- }
1010
- if (throughIndex !== events.length) throw new Error("inspection session ended before its frozen frontier");
1011
- return {
1012
- events,
1013
- snapshots
1014
- };
1015
- };
1016
- const readRevision = async (inspection, sessionId) => {
1017
- const detail = await readCompleteSession(inspection, sessionId);
1018
- return {
1019
- detail,
1020
- revision: await sessionRevision(detail)
1021
- };
1022
- };
1023
- const bootstrapState = async (url, servers) => {
1024
- const contracts = [...servers.keys()].map((name) => ({ name }));
1025
- const requestedContract = url.searchParams.get("contract");
1026
- const contract = requestedContract && servers.has(requestedContract) ? requestedContract : contracts[0]?.name ?? null;
1027
- const bootstrap = {
1028
- contracts,
1029
- contract,
1030
- sessions: [],
1031
- cursor: null,
1032
- session: null,
1033
- detail: null,
1034
- error: null
1035
- };
1036
- if (!contract) return bootstrap;
1037
- const server = servers.get(contract);
1038
- const inspection = server ? serverInspection.get(server) : void 0;
1039
- if (!inspection) {
1040
- bootstrap.error = `contract '${contract}' is not an inspectable A2 server`;
1041
- return bootstrap;
1042
- }
1043
- try {
1044
- const page = await inspection.listSessions({ limit: 50 });
1045
- bootstrap.sessions = page.sessions.map(wireSummary);
1046
- bootstrap.cursor = page.cursor;
1047
- bootstrap.session = (requestedContract === contract ? url.searchParams.get("session") : null) ?? page.sessions[0]?.sessionId ?? null;
1048
- if (!bootstrap.session) return bootstrap;
1049
- const detail = await readCompleteSession(inspection, bootstrap.session);
1050
- if (detail.events.length === 0) {
1051
- bootstrap.error = `session '${bootstrap.session}' was not found`;
1052
- return bootstrap;
1053
- }
1054
- bootstrap.detail = await wireDetail(contract, bootstrap.session, detail);
1055
- } catch (error) {
1056
- bootstrap.error = error instanceof Error ? error.message : String(error);
1057
- }
1058
- return bootstrap;
1059
- };
1060
- const resourceOf = (pathname) => {
1061
- const marker = pathname.lastIndexOf(MARKER);
1062
- return marker === -1 ? null : pathname.slice(marker + 5);
1063
- };
1064
- const inspectionOf = (servers, contractName) => {
1065
- if (!contractName) return badRequest("contract is required");
1066
- const server = servers.get(contractName);
1067
- if (!server) return notFound();
1068
- return serverInspection.get(server) ?? unavailable(`contract '${contractName}' is not an inspectable A2 server`);
1069
- };
1070
- const encodeSessionCursor = (contract, sessionId, cursor) => JSON.stringify([
1071
- 1,
1072
- contract,
1073
- sessionId,
1074
- cursor.throughIndex,
1075
- cursor.afterIndex
1076
- ]);
1077
- const parseSessionCursor = (value, contract, sessionId) => {
1078
- let decoded;
1079
- try {
1080
- decoded = JSON.parse(value);
1081
- } catch {
1082
- return badRequest("cursor is invalid");
1083
- }
1084
- if (!Array.isArray(decoded) || decoded.length !== 5 || decoded[0] !== 1 || !Number.isSafeInteger(decoded[3]) || !Number.isSafeInteger(decoded[4]) || decoded[3] < 1 || decoded[4] < 1 || decoded[4] >= decoded[3]) return badRequest("cursor is invalid");
1085
- if (decoded[1] !== contract || decoded[2] !== sessionId) return badRequest("cursor does not belong to this contract and session");
1086
- return {
1087
- throughIndex: decoded[3],
1088
- afterIndex: decoded[4]
1089
- };
1090
- };
1091
- const captureFilename = (contract, sessionId) => {
1092
- return `${`${contract}-${sessionId}`.replaceAll(/[^a-zA-Z0-9._-]/g, "_").slice(0, 160) || "a2-session"}.a2log`;
1093
- };
1094
- const watchResponse = (request, inspection, sessionId, initial) => {
1095
- let stopped = false;
1096
- let timeout = null;
1097
- let wake = null;
1098
- const stop = () => {
1099
- stopped = true;
1100
- if (timeout !== null) clearTimeout(timeout);
1101
- timeout = null;
1102
- wake?.();
1103
- wake = null;
1104
- };
1105
- const wait = (milliseconds) => new Promise((resolve) => {
1106
- wake = resolve;
1107
- timeout = setTimeout(() => {
1108
- timeout = null;
1109
- wake = null;
1110
- resolve();
1111
- }, milliseconds);
1112
- });
1113
- const stream = new ReadableStream({
1114
- start(controller) {
1115
- request.signal.addEventListener("abort", stop, { once: true });
1116
- controller.enqueue(encoder.encode("retry: 1000\n: connected\n\n"));
1117
- (async () => {
1118
- let detail = initial;
1119
- let previous = new URL(request.url).searchParams.get("revision") ?? "";
1120
- let lastHeartbeat = Date.now();
1121
- try {
1122
- let revision = await sessionRevision(detail);
1123
- for (;;) {
1124
- if (stopped) break;
1125
- if (revision !== previous) {
1126
- previous = revision;
1127
- controller.enqueue(encoder.encode(`event: invalidate\ndata: ${JSON.stringify({ revision })}\n\n`));
1128
- } else if (Date.now() - lastHeartbeat >= DEVTOOLS_TIMINGS.heartbeatMs) {
1129
- controller.enqueue(encoder.encode(": ping\n\n"));
1130
- lastHeartbeat = Date.now();
1131
- }
1132
- const active = detail.events.some((event) => event.processedAt === null && event.failedAt === null);
1133
- await wait(active ? DEVTOOLS_TIMINGS.activeMs : DEVTOOLS_TIMINGS.idleMs);
1134
- if (!stopped) {
1135
- const next = await readRevision(inspection, sessionId);
1136
- detail = next.detail;
1137
- revision = next.revision;
1138
- }
1139
- }
1140
- controller.close();
1141
- } catch (error) {
1142
- if (!stopped) controller.error(error);
1143
- }
1144
- })();
1145
- },
1146
- cancel() {
1147
- stop();
1148
- }
1149
- });
1150
- return new Response(stream, { headers: {
1151
- ...headers("text/event-stream; charset=utf-8"),
1152
- connection: "keep-alive",
1153
- "x-accel-buffering": "no"
1154
- } });
1155
- };
1156
- const authorizeRequest = async (request, authorize) => {
1157
- if (!authorize) return isDevelopment() ? null : notFound();
1158
- const result = await authorize(request);
1159
- if (result instanceof Response) return result;
1160
- return result === true ? null : notFound();
1161
- };
1162
- function createDevtools(options) {
1163
- if (!options || !Array.isArray(options.servers) || options.servers.length === 0) throw new TypeError("createDevtools expects at least one A2 server");
1164
- const servers = /* @__PURE__ */ new Map();
1165
- for (const server of options.servers) {
1166
- const name = server?.contract?.name;
1167
- if (typeof name !== "string" || name.length === 0) throw new TypeError("createDevtools received a server without a contract");
1168
- if (servers.has(name)) throw new TypeError(`createDevtools received contract '${name}' twice`);
1169
- servers.set(name, server);
1170
- }
1171
- return { handler() {
1172
- return async (request) => {
1173
- const denied = await authorizeRequest(request, options.authorize);
1174
- if (denied) return denied;
1175
- if (request.method !== "GET") return new Response("Method not allowed", {
1176
- status: 405,
1177
- headers: {
1178
- ...headers("text/plain"),
1179
- allow: "GET"
1180
- }
1181
- });
1182
- const url = new URL(request.url);
1183
- const resource = resourceOf(url.pathname);
1184
- if (resource === null) {
1185
- const basePath = `${url.pathname.replace(/\/$/, "")}/`;
1186
- const bootstrap = await bootstrapState(url, servers);
1187
- return new Response(devtoolsHtml(basePath, bootstrap), { headers: {
1188
- ...headers("text/html; charset=utf-8"),
1189
- "content-security-policy": "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:; base-uri 'self'; frame-ancestors 'none'"
1190
- } });
1191
- }
1192
- if (resource === "app.js") return new Response(DEVTOOLS_APP_JS, { headers: headers("text/javascript; charset=utf-8") });
1193
- if (resource === "app.css") return new Response(DEVTOOLS_APP_CSS, { headers: headers("text/css; charset=utf-8") });
1194
- if (resource === "contracts") return json({
1195
- protocolVersion: 1,
1196
- contracts: [...servers.keys()].map((name) => ({ name }))
1197
- });
1198
- if (resource === "capabilities") return json(DEVTOOLS_CAPABILITIES);
1199
- const contractName = url.searchParams.get("contract");
1200
- const inspection = inspectionOf(servers, contractName);
1201
- if (inspection instanceof Response) return inspection;
1202
- try {
1203
- if (resource === "sessions") {
1204
- const requestedLimit = Number(url.searchParams.get("limit") ?? 50);
1205
- if (!Number.isInteger(requestedLimit) || requestedLimit < 1) return badRequest("limit must be a positive integer");
1206
- const limit = Math.min(requestedLimit, 100);
1207
- const cursor = url.searchParams.get("cursor");
1208
- const page = await inspection.listSessions({
1209
- limit,
1210
- ...cursor !== null ? { cursor } : {}
1211
- });
1212
- return json({
1213
- protocolVersion: 1,
1214
- cursor: page.cursor,
1215
- sessions: page.sessions.map(wireSummary)
1216
- });
1217
- }
1218
- const sessionId = url.searchParams.get("session");
1219
- if (!sessionId) return badRequest("session is required");
1220
- if (resource === "session" && (url.searchParams.has("limit") || url.searchParams.has("cursor"))) {
1221
- const requestedLimit = Number(url.searchParams.get("limit") ?? 250);
1222
- if (!Number.isSafeInteger(requestedLimit) || requestedLimit < 1) return badRequest("limit must be a positive integer");
1223
- const limit = Math.min(requestedLimit, DEVTOOLS_SESSION_PAGE_MAX_LIMIT);
1224
- const cursorValue = url.searchParams.get("cursor");
1225
- const cursor = cursorValue === null ? {
1226
- afterIndex: 0,
1227
- throughIndex: void 0
1228
- } : parseSessionCursor(cursorValue, contractName, sessionId);
1229
- if (cursor instanceof Response) return cursor;
1230
- const page = await inspection.readSessionPage(sessionId, {
1231
- afterIndex: cursor.afterIndex,
1232
- limit,
1233
- ...cursor.throughIndex === void 0 ? {} : { throughIndex: cursor.throughIndex }
1234
- });
1235
- if (page.throughIndex === 0) return notFound();
1236
- const response = {
1237
- protocolVersion: 1,
1238
- contract: contractName,
1239
- sessionId,
1240
- throughIndex: page.throughIndex,
1241
- cursor: page.nextIndex === null ? null : encodeSessionCursor(contractName, sessionId, {
1242
- throughIndex: page.throughIndex,
1243
- afterIndex: page.nextIndex
1244
- }),
1245
- events: page.events.map(wireEvent),
1246
- snapshots: page.snapshots.map(wireSnapshot)
1247
- };
1248
- return json(response);
1249
- }
1250
- const detail = await readCompleteSession(inspection, sessionId);
1251
- if (detail.events.length === 0) return notFound();
1252
- const wired = await wireDetail(contractName, sessionId, detail);
1253
- if (resource === "session") return json(wired);
1254
- if (resource === "export") return new Response(await encodeDevtoolsCapture(wired), { headers: {
1255
- ...headers(`${DEVTOOLS_CAPTURE_MEDIA_TYPE}; charset=utf-8`),
1256
- "content-disposition": `attachment; filename="${captureFilename(wired.contract, sessionId)}"`
1257
- } });
1258
- if (resource === "watch") return watchResponse(request, inspection, sessionId, detail);
1259
- } catch (error) {
1260
- if (error instanceof InspectionUnsupportedError) return unavailable(error.message);
1261
- throw error;
1262
- }
1263
- return notFound();
1264
- };
1265
- } };
1266
- }
1267
- //#endregion
1268
- export { createDevtools };
1269
-
1270
- //# sourceMappingURL=devtools-server.js.map