@ericsanchezok/synergy-workbench 0.0.0-stage → 3.1.1

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 (153) hide show
  1. package/AGENTS.md +30 -0
  2. package/README.md +16 -2
  3. package/dist/agents.js +11 -0
  4. package/dist/cli-adapter.js +8 -0
  5. package/dist/component.js +65 -0
  6. package/dist/config-schema.js +265 -0
  7. package/dist/execution/activity.js +78 -0
  8. package/dist/execution/content.js +184 -0
  9. package/dist/execution/json-sections.js +231 -0
  10. package/dist/execution/presentation.js +79 -0
  11. package/dist/execution/preview.js +13 -0
  12. package/dist/execution/process.js +125 -0
  13. package/dist/execution/projection.js +327 -0
  14. package/dist/execution/query.js +18 -0
  15. package/dist/execution/routes.js +189 -0
  16. package/dist/execution/schema.js +322 -0
  17. package/dist/execution/service.js +1993 -0
  18. package/dist/features.js +22 -0
  19. package/dist/http.js +65 -0
  20. package/dist/performance/analysis.js +330 -0
  21. package/dist/performance/catalog.js +391 -0
  22. package/dist/performance/dashboard.js +483 -0
  23. package/dist/performance/error.js +15 -0
  24. package/dist/performance/index.js +6 -0
  25. package/dist/performance/inflight.js +43 -0
  26. package/dist/performance/projection.js +138 -0
  27. package/dist/performance/prompt/base.txt +38 -0
  28. package/dist/performance/prompt/builder.js +21 -0
  29. package/dist/performance/routes/performance-route.js +439 -0
  30. package/dist/performance/schema.js +628 -0
  31. package/dist/performance/timeline.js +98 -0
  32. package/dist/performance/trace-detail.js +91 -0
  33. package/dist/presentation/service.js +270 -0
  34. package/dist/presentation/tools/workbench-view.js +65 -0
  35. package/dist/presentation/tools.js +10 -0
  36. package/dist/project/directories.js +363 -0
  37. package/dist/project/git-health.js +498 -0
  38. package/dist/project/ide.js +66 -0
  39. package/dist/project/migration.js +22 -0
  40. package/dist/project/routes/directories.js +89 -0
  41. package/dist/project/routes/git.js +30 -0
  42. package/dist/project/routes/task-defaults.js +42 -0
  43. package/dist/project/session-health.js +9 -0
  44. package/dist/project/startup.js +28 -0
  45. package/dist/project/task-defaults.js +68 -0
  46. package/dist/project/tools/session-control.js +664 -0
  47. package/dist/project/tools/session-control.txt +60 -0
  48. package/dist/project/tools/worktree-archive.js +76 -0
  49. package/dist/project/tools/worktree-enter.js +175 -0
  50. package/dist/project/tools/worktree-leave.js +119 -0
  51. package/dist/project/tools/worktree-list.js +52 -0
  52. package/dist/project/tools.js +26 -0
  53. package/dist/project/vcs-branch-watcher.js +76 -0
  54. package/dist/project/vcs.js +114 -0
  55. package/dist/project/worktree-janitor.js +140 -0
  56. package/dist/project/worktrees.js +98 -0
  57. package/dist/push/bridge.js +171 -0
  58. package/dist/push/index.js +4 -0
  59. package/dist/push/routes/push.js +224 -0
  60. package/dist/push/service.js +93 -0
  61. package/dist/push/store.js +121 -0
  62. package/dist/push/types.js +58 -0
  63. package/dist/review/git.js +226 -0
  64. package/dist/review/routes.js +45 -0
  65. package/dist/review/schema.js +32 -0
  66. package/dist/review/state.js +79 -0
  67. package/dist/stats/aggregator.js +252 -0
  68. package/dist/stats/canonical.js +89 -0
  69. package/dist/stats/cli/stats.js +302 -0
  70. package/dist/stats/engine.js +176 -0
  71. package/dist/stats/index.js +5 -0
  72. package/dist/stats/rollup.js +460 -0
  73. package/dist/stats/routes/stats.js +94 -0
  74. package/dist/stats/routes/usage.js +83 -0
  75. package/dist/stats/storage.js +86 -0
  76. package/dist/stats/types.js +267 -0
  77. package/dist/worker.js +6 -0
  78. package/package.json +232 -4
  79. package/src/agents.ts +15 -0
  80. package/src/cli-adapter.ts +11 -0
  81. package/src/component.ts +73 -0
  82. package/src/config-schema.ts +289 -0
  83. package/src/execution/activity.ts +83 -0
  84. package/src/execution/content.ts +189 -0
  85. package/src/execution/json-sections.ts +230 -0
  86. package/src/execution/presentation.ts +91 -0
  87. package/src/execution/preview.ts +12 -0
  88. package/src/execution/process.ts +118 -0
  89. package/src/execution/projection.ts +330 -0
  90. package/src/execution/query.ts +19 -0
  91. package/src/execution/routes.ts +324 -0
  92. package/src/execution/schema.ts +336 -0
  93. package/src/execution/service.ts +2149 -0
  94. package/src/features.ts +22 -0
  95. package/src/http.ts +75 -0
  96. package/src/performance/analysis.ts +356 -0
  97. package/src/performance/catalog.ts +902 -0
  98. package/src/performance/dashboard.ts +521 -0
  99. package/src/performance/error.ts +32 -0
  100. package/src/performance/index.ts +6 -0
  101. package/src/performance/inflight.ts +46 -0
  102. package/src/performance/projection.ts +143 -0
  103. package/src/performance/prompt/base.txt +38 -0
  104. package/src/performance/prompt/builder.ts +24 -0
  105. package/src/performance/routes/performance-route.ts +541 -0
  106. package/src/performance/schema.ts +695 -0
  107. package/src/performance/timeline.ts +100 -0
  108. package/src/performance/trace-detail.ts +97 -0
  109. package/src/presentation/service.ts +303 -0
  110. package/src/presentation/tools/workbench-view.ts +75 -0
  111. package/src/presentation/tools.ts +11 -0
  112. package/src/project/directories.ts +391 -0
  113. package/src/project/git-health.ts +577 -0
  114. package/src/project/ide.ts +80 -0
  115. package/src/project/migration.ts +24 -0
  116. package/src/project/routes/directories.ts +124 -0
  117. package/src/project/routes/git.ts +42 -0
  118. package/src/project/routes/task-defaults.ts +50 -0
  119. package/src/project/session-health.ts +10 -0
  120. package/src/project/startup.ts +28 -0
  121. package/src/project/task-defaults.ts +70 -0
  122. package/src/project/tools/session-control.ts +725 -0
  123. package/src/project/tools/session-control.txt +60 -0
  124. package/src/project/tools/worktree-archive.ts +92 -0
  125. package/src/project/tools/worktree-enter.ts +227 -0
  126. package/src/project/tools/worktree-leave.ts +140 -0
  127. package/src/project/tools/worktree-list.ts +68 -0
  128. package/src/project/tools.ts +29 -0
  129. package/src/project/vcs-branch-watcher.ts +81 -0
  130. package/src/project/vcs.ts +124 -0
  131. package/src/project/worktree-janitor.ts +153 -0
  132. package/src/project/worktrees.ts +110 -0
  133. package/src/push/bridge.ts +182 -0
  134. package/src/push/index.ts +4 -0
  135. package/src/push/routes/push.ts +267 -0
  136. package/src/push/service.ts +108 -0
  137. package/src/push/store.ts +120 -0
  138. package/src/push/types.ts +67 -0
  139. package/src/review/git.ts +221 -0
  140. package/src/review/routes.ts +65 -0
  141. package/src/review/schema.ts +34 -0
  142. package/src/review/state.ts +77 -0
  143. package/src/stats/aggregator.ts +277 -0
  144. package/src/stats/canonical.ts +93 -0
  145. package/src/stats/cli/stats.ts +348 -0
  146. package/src/stats/engine.ts +197 -0
  147. package/src/stats/index.ts +5 -0
  148. package/src/stats/rollup.ts +522 -0
  149. package/src/stats/routes/stats.ts +107 -0
  150. package/src/stats/routes/usage.ts +103 -0
  151. package/src/stats/storage.ts +78 -0
  152. package/src/stats/types.ts +351 -0
  153. package/src/worker.ts +7 -0
@@ -0,0 +1,330 @@
1
+ import type { Server } from "@ericsanchezok/synergy-server/server/server"
2
+ import { RuntimeContext } from "@ericsanchezok/synergy-harness/lifecycle/context"
3
+ import { ScopeContext } from "@ericsanchezok/synergy-harness/scope/context"
4
+ import { Scope } from "@ericsanchezok/synergy-harness/scope"
5
+ import { Session } from "@ericsanchezok/synergy-harness/session"
6
+ import { Storage } from "@ericsanchezok/synergy-harness/storage/storage"
7
+ import { applyProjectionPatch, projectionPatch } from "@ericsanchezok/synergy-util/projection-patch"
8
+ import { ExecutionService } from "./service"
9
+ import { ExecutionSchema } from "./schema"
10
+ import { z } from "zod"
11
+
12
+ export namespace ExecutionProjection {
13
+ const checkpoints = RuntimeContext.state(
14
+ () =>
15
+ new Map<
16
+ string,
17
+ { sessionID: string; scopeID: string } & (
18
+ { summary: ExecutionSchema.Summary } | { projection: ExecutionSchema.Projection }
19
+ )
20
+ >(),
21
+ )
22
+ export function checkpoint(sessionID: string, token: string) {
23
+ const value = checkpoints().get(token)
24
+ if (
25
+ !value ||
26
+ !("summary" in value) ||
27
+ value.sessionID !== sessionID ||
28
+ value.scopeID !== ScopeContext.current.scope.id
29
+ )
30
+ throw new Storage.NotFoundError({ message: "Execution checkpoint expired" })
31
+ return value.summary
32
+ }
33
+ export function projectionCheckpoint(sessionID: string, token: string) {
34
+ const value = checkpoints().get(token)
35
+ if (
36
+ !value ||
37
+ !("projection" in value) ||
38
+ value.sessionID !== sessionID ||
39
+ value.scopeID !== ScopeContext.current.scope.id
40
+ )
41
+ throw new Storage.NotFoundError({ message: "Execution checkpoint expired" })
42
+ return structuredClone(value.projection)
43
+ }
44
+ const Demand = z.enum(["root", "summary", "trajectory"])
45
+ async function openDemand(...[interest, send, signal]: Parameters<Server.ViewProjectionContribution["open"]>) {
46
+ const demands = z.array(Demand).min(1).parse(interest.demands)
47
+ const scope = await Scope.resolve({ scopeID: interest.scopeID })
48
+ const session = await Session.get(interest.sessionID)
49
+ if (session.scope.id !== scope.id)
50
+ throw new Storage.NotFoundError({ message: "Execution belongs to another Scope" })
51
+ const controller = new AbortController()
52
+ const abort = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal
53
+ abort.throwIfAborted()
54
+ const trajectory = demands.includes("trajectory")
55
+ const summary = trajectory || demands.includes("summary")
56
+ const releases: Array<() => void> = []
57
+ if (summary) releases.push(ExecutionService.subscribe(session.id, { trajectory }))
58
+ let disposed = false
59
+ let frameRevision = 0
60
+ let token: string | undefined
61
+ let previous: unknown
62
+ let current: ExecutionSchema.Projection = {
63
+ state: "root",
64
+ sessionID: session.id,
65
+ revision: 0,
66
+ previousRevision: 0,
67
+ contexts: [],
68
+ contextTotal: 0,
69
+ }
70
+ const store = checkpoints()
71
+ const dispose = () => {
72
+ if (disposed) return
73
+ disposed = true
74
+ abort.removeEventListener("abort", dispose)
75
+ controller.abort()
76
+ for (const release of releases.splice(0)) release()
77
+ if (token) store.delete(token)
78
+ previous = undefined
79
+ }
80
+ abort.addEventListener("abort", dispose, { once: true })
81
+ const publish = (value: ExecutionSchema.Projection) => {
82
+ if (disposed) return
83
+ value = ExecutionSchema.Projection.parse(value)
84
+ current = value
85
+ const patches = projectionPatch(previous, value)
86
+ if (!patches.length) return
87
+ const properties = {
88
+ kind: "execution",
89
+ sessionID: session.id,
90
+ generation: interest.generation,
91
+ revision: frameRevision + 1,
92
+ previousRevision: frameRevision,
93
+ patches,
94
+ snapshot: undefined as string | undefined,
95
+ }
96
+ const frame = { scopeID: scope.id, payload: { type: "server.view.patch", properties } }
97
+ if (Buffer.byteLength(JSON.stringify(frame)) > 64 * 1024) {
98
+ if (token) store.delete(token)
99
+ token = crypto.randomUUID()
100
+ store.set(token, { scopeID: scope.id, sessionID: session.id, projection: structuredClone(value) })
101
+ properties.patches = []
102
+ properties.snapshot = token
103
+ }
104
+ previous = structuredClone(value)
105
+ frameRevision++
106
+ send(frame)
107
+ }
108
+ const scoped = <T>(fn: () => Promise<T>) => ScopeContext.provide({ scope, fn })
109
+ try {
110
+ const release = await scoped(() =>
111
+ ExecutionService.observeRoot(
112
+ session.id,
113
+ (root) => {
114
+ const selected = root.contexts.filter((context) => !interest.runID || context.runID === interest.runID)
115
+ const visible = new Set(interest.nodeIDs)
116
+ const contexts = selected.filter((context, index) => index < 30 || visible.has(context.nodeID))
117
+ publish({
118
+ ...current,
119
+ revision: root.revision,
120
+ previousRevision: current.revision,
121
+ contexts,
122
+ contextTotal: selected.length,
123
+ })
124
+ },
125
+ abort,
126
+ ),
127
+ )
128
+ if (disposed) release()
129
+ else releases.push(release)
130
+ if (summary && !disposed) {
131
+ const releaseProgress = await scoped(() =>
132
+ ExecutionService.observePreparation(session.id, (progress) => {
133
+ if (current.state === "preparing") publish({ ...current, progress })
134
+ }),
135
+ )
136
+ if (disposed) {
137
+ releaseProgress()
138
+ return { project() {}, dispose }
139
+ }
140
+ releases.push(releaseProgress)
141
+ publish({ ...current, state: "preparing", progress: { stage: "metadata", prepared: 0 } })
142
+ void scoped(async () => {
143
+ const value = await ExecutionService.summary(session.id, undefined, abort)
144
+ const rounds = interest.runID ? [await ExecutionService.summary(session.id, interest.runID, abort)] : []
145
+ if (current.state === "ready" && current.summary.revision > value.revision) return
146
+ publish({
147
+ ...current,
148
+ state: "ready",
149
+ summary: value,
150
+ detailPreviousRevision: value.revision,
151
+ roundSummaries: rounds,
152
+ upserts: [],
153
+ processUpserts: [],
154
+ processRemoved: [],
155
+ removed: [],
156
+ })
157
+ await ExecutionService.verifyJournal(session.id, abort)
158
+ abort.throwIfAborted()
159
+ const verified = await ExecutionService.summary(session.id, undefined, abort)
160
+ const verifiedRounds = interest.runID
161
+ ? [await ExecutionService.summary(session.id, interest.runID, abort)]
162
+ : []
163
+ if (current.state === "ready" && current.summary.revision > verified.revision) return
164
+ publish({
165
+ ...current,
166
+ state: "ready",
167
+ summary: verified,
168
+ roundSummaries: verifiedRounds,
169
+ detailPreviousRevision: current.state === "ready" ? current.summary.revision : verified.revision,
170
+ upserts: [],
171
+ processUpserts: [],
172
+ processRemoved: [],
173
+ removed: [],
174
+ })
175
+ }).catch((error) => {
176
+ if (!disposed)
177
+ publish({
178
+ ...current,
179
+ state: "failed",
180
+ error: {
181
+ kind: error instanceof Error ? error.name : "Error",
182
+ message: "Unable to prepare execution summary",
183
+ retryable: true,
184
+ },
185
+ })
186
+ })
187
+ }
188
+ } catch (error) {
189
+ dispose()
190
+ throw error
191
+ }
192
+ return {
193
+ project(event: Parameters<Awaited<ReturnType<Server.ViewProjectionContribution["open"]>>["project"]>[0]) {
194
+ if (event.scopeID !== scope.id) return
195
+ if (
196
+ event.payload.type === "session.deleted" &&
197
+ (event.payload.properties.info as Session.Info)?.id === session.id
198
+ ) {
199
+ dispose()
200
+ return
201
+ }
202
+ if (!summary || event.payload.type !== "execution.updated" || event.payload.properties.sessionID !== session.id)
203
+ return
204
+ const value = ExecutionSchema.Updated.properties.parse(event.payload.properties)
205
+ if (current.state === "ready" && value.summary.revision <= current.summary.revision) return
206
+ const visible = interest.nodeIDs && new Set(interest.nodeIDs)
207
+ publish({
208
+ ...current,
209
+ state: "ready",
210
+ summary: value.summary,
211
+ detailPreviousRevision: value.previousRevision ?? value.summary.revision,
212
+ roundSummaries: value.roundSummaries.filter((round) => !interest.runID || round.runID === interest.runID),
213
+ upserts: trajectory ? value.upserts.filter((node) => !visible || visible.has(node.id)) : [],
214
+ processUpserts: trajectory ? (value.processUpserts ?? []) : [],
215
+ processRemoved: trajectory ? (value.processRemoved ?? []) : [],
216
+ removed: trajectory ? value.removed : [],
217
+ })
218
+ },
219
+ dispose,
220
+ }
221
+ }
222
+ export function baseline(summary: ExecutionSchema.Summary) {
223
+ return {
224
+ sessionID: summary.sessionID,
225
+ revision: summary.revision,
226
+ summary,
227
+ roundSummaries: [],
228
+ contextUpserts: [],
229
+ upserts: [],
230
+ processUpserts: [],
231
+ processRemoved: [],
232
+ removed: [],
233
+ previousRevision: summary.revision,
234
+ }
235
+ }
236
+ export function contribution(): Server.ViewProjectionContribution {
237
+ return {
238
+ kind: "execution",
239
+ eventType: "execution.updated",
240
+ legacy: ExecutionService.legacyClient,
241
+ async open(interest, send, signal) {
242
+ if (interest.demands) return openDemand(interest, send, signal)
243
+ signal?.throwIfAborted()
244
+ const scope = await Scope.resolve({ scopeID: interest.scopeID })
245
+ const session = await Session.get(interest.sessionID)
246
+ if (session.scope.id !== scope.id)
247
+ throw new Storage.NotFoundError({ message: "Execution belongs to another Scope" })
248
+ signal?.throwIfAborted()
249
+ const release = ExecutionService.subscribe(session.id)
250
+ let previous: unknown,
251
+ revision = 0,
252
+ token: string | undefined,
253
+ disposed = false
254
+ const store = checkpoints()
255
+ const dispose = () => {
256
+ if (disposed) return
257
+ disposed = true
258
+ signal?.removeEventListener("abort", dispose)
259
+ release()
260
+ if (token) store.delete(token)
261
+ previous = undefined
262
+ }
263
+ signal?.addEventListener("abort", dispose, { once: true })
264
+ const publish = (value: Record<string, unknown>) => {
265
+ if (disposed) return
266
+ const patches = projectionPatch(previous, value)
267
+ if (!patches.length) return
268
+ const properties = {
269
+ kind: "execution",
270
+ sessionID: session.id,
271
+ generation: interest.generation,
272
+ revision: revision + 1,
273
+ previousRevision: revision,
274
+ patches,
275
+ snapshot: undefined as string | undefined,
276
+ }
277
+ const frame = { scopeID: scope.id, payload: { type: "server.view.patch", properties } }
278
+ if (Buffer.byteLength(JSON.stringify(frame)) > 64 * 1024) {
279
+ const summary = value.summary as ExecutionSchema.Summary
280
+ if (token) store.delete(token)
281
+ token = crypto.randomUUID()
282
+ const snapshot = structuredClone(summary)
283
+ store.set(token, { scopeID: scope.id, sessionID: session.id, summary: snapshot })
284
+ previous = baseline(snapshot)
285
+ properties.patches = []
286
+ properties.snapshot = token
287
+ } else previous = applyProjectionPatch(previous, patches)
288
+ revision++
289
+ send(frame)
290
+ }
291
+ try {
292
+ const summary = await ScopeContext.provide({
293
+ scope,
294
+ fn: () => ExecutionService.summary(session.id, undefined, signal),
295
+ })
296
+ publish(baseline(summary))
297
+ } catch (error) {
298
+ dispose()
299
+ throw error
300
+ }
301
+ return {
302
+ project(event) {
303
+ if (
304
+ event.scopeID !== scope.id ||
305
+ event.payload.type !== "execution.updated" ||
306
+ event.payload.properties.sessionID !== session.id
307
+ )
308
+ return
309
+ const value = event.payload.properties
310
+ const priorRevision = (previous as { revision?: number } | undefined)?.revision ?? -1
311
+ if (Number(value.revision) <= priorRevision) return
312
+ const visible = interest.nodeIDs && new Set(interest.nodeIDs)
313
+ const selected = {
314
+ ...value,
315
+ roundSummaries: (value.roundSummaries as ExecutionSchema.Summary[]).filter(
316
+ (summary) => !interest.runID || summary.runID === interest.runID,
317
+ ),
318
+ contextUpserts: ((value.contextUpserts ?? []) as ExecutionSchema.ContextSnapshot[]).filter(
319
+ (context) => !visible || visible.has(context.nodeID),
320
+ ),
321
+ upserts: (value.upserts as Array<{ id: string }>).filter((node) => !visible || visible.has(node.id)),
322
+ }
323
+ publish(selected)
324
+ },
325
+ dispose,
326
+ }
327
+ },
328
+ }
329
+ }
330
+ }
@@ -0,0 +1,19 @@
1
+ import { z } from "zod"
2
+
3
+ export class ExecutionQueryError extends RangeError {}
4
+
5
+ export function queryValue<T extends z.ZodType>(schema: T, value: unknown): z.infer<T> {
6
+ const parsed = schema.safeParse(value)
7
+ if (!parsed.success) throw new ExecutionQueryError("Invalid execution query")
8
+ return parsed.data
9
+ }
10
+
11
+ export function queryCursor<T extends z.ZodType>(schema: T, cursor: string): z.infer<T> {
12
+ let value: unknown
13
+ try {
14
+ value = JSON.parse(Buffer.from(cursor, "base64url").toString())
15
+ } catch {
16
+ throw new ExecutionQueryError("Invalid execution cursor")
17
+ }
18
+ return queryValue(schema, value)
19
+ }
@@ -0,0 +1,324 @@
1
+ import { RolloutEvidence } from "@ericsanchezok/synergy-harness/rollout"
2
+ import { Log } from "@ericsanchezok/synergy-harness/util/log"
3
+ import { ExecutionQueryError } from "./query"
4
+ import { Hono } from "hono"
5
+ import { describeRoute, resolver, validator } from "hono-openapi"
6
+ import { z } from "zod"
7
+ import { Identifier } from "@ericsanchezok/synergy-harness/id/id"
8
+ import { Storage } from "@ericsanchezok/synergy-harness/storage/storage"
9
+ import { ExecutionSchema } from "./schema"
10
+ import { ExecutionService } from "./service"
11
+ import { ExecutionProjection } from "./projection"
12
+ import { ExecutionContent } from "./content"
13
+ import { RuntimeContext } from "@ericsanchezok/synergy-harness/lifecycle/context"
14
+ import { ScopeContext } from "@ericsanchezok/synergy-harness/scope/context"
15
+ import { Scope } from "@ericsanchezok/synergy-harness/scope"
16
+
17
+ const Params = z.object({ sessionID: Identifier.schema("session") })
18
+ const ContextParams = Params.extend({ callID: z.string().min(1).max(4096) })
19
+ const NodeParams = Params.extend({ nodeID: z.string().min(1).max(4096) })
20
+ const ContentQuery = ExecutionContent.Query.extend({
21
+ offset: z.coerce.number().int().nonnegative().safe().default(0),
22
+ limit: z.coerce.number().int().min(1).max(65_536).default(65_536),
23
+ })
24
+ async function download(source: ExecutionContent.Source, field: string, signal: AbortSignal) {
25
+ const runtime = RuntimeContext.current()
26
+ const scope = ScopeContext.current.scope
27
+ const workspace = ScopeContext.current.workspace
28
+ const iterator = source.stream()[Symbol.asyncIterator]()
29
+ const release = Scope.registerArchiveGuard((id) => {
30
+ if (id === scope.id) throw new Error("Execution content is being read")
31
+ })
32
+ const bind = <T>(fn: () => T | Promise<T>) => runtime.bind(() => ScopeContext.provide({ scope, workspace, fn }))
33
+ let closed = false
34
+ let controller: ReadableStreamDefaultController<Uint8Array> | undefined
35
+ const finish = () => {
36
+ if (closed) return
37
+ closed = true
38
+ release()
39
+ signal.removeEventListener("abort", abort)
40
+ }
41
+ const abort = () => {
42
+ if (closed) return
43
+ controller?.error(signal.reason)
44
+ finish()
45
+ void bind(() => iterator.return?.())().catch(() => {})
46
+ }
47
+ signal.addEventListener("abort", abort, { once: true })
48
+ const body = new ReadableStream<Uint8Array>({
49
+ start(value) {
50
+ controller = value
51
+ if (signal.aborted) abort()
52
+ },
53
+ pull: bind(async () => {
54
+ if (closed) return
55
+ try {
56
+ const item = await iterator.next()
57
+ if (closed) return
58
+ if (item.done) {
59
+ finish()
60
+ controller!.close()
61
+ return
62
+ }
63
+ controller!.enqueue(item.value)
64
+ } catch (error) {
65
+ finish()
66
+ controller!.error(error)
67
+ }
68
+ }),
69
+ cancel: bind(async () => {
70
+ finish()
71
+ await iterator.return?.()
72
+ }),
73
+ })
74
+ return new Response(body, {
75
+ headers: {
76
+ "Content-Type": "application/octet-stream",
77
+ "Content-Disposition": `attachment; filename="execution-${field.replace(/[^a-zA-Z0-9_-]/g, "_")}.${source.mediaType.includes("json") ? "json" : "txt"}"`,
78
+ "Content-Length": String(source.bytes),
79
+ "Cache-Control": "no-store",
80
+ "X-Execution-Content-Version": source.contentVersion,
81
+ "X-Execution-Content-Status": source.status,
82
+ ...(source.sha256 ? { "X-Execution-Content-Sha256": source.sha256 } : {}),
83
+ },
84
+ })
85
+ }
86
+ const metadata = (operationId: string, summary: string, schema: z.ZodType) =>
87
+ describeRoute({
88
+ operationId,
89
+ summary,
90
+ responses: {
91
+ 200: { description: summary, content: { "application/json": { schema: resolver(schema) } } },
92
+ 400: { description: "Invalid execution query" },
93
+ 500: { description: "Execution computation failed" },
94
+ 404: { description: "Execution evidence was not found in the selected Scope and session" },
95
+ },
96
+ })
97
+ async function result<T>(body: () => Promise<T>) {
98
+ try {
99
+ return { status: 200 as const, value: await body() }
100
+ } catch (error) {
101
+ if (error instanceof Storage.NotFoundError) return { status: 404 as const, value: { message: error.message } }
102
+ if (error instanceof ExecutionQueryError || error instanceof RolloutEvidence.QueryError)
103
+ return { status: 400 as const, value: { message: "Invalid execution query" } }
104
+ if (!(error instanceof Error && error.name === "AbortError"))
105
+ Log.create({ service: "execution" }).error("execution read failed", {
106
+ kind: error instanceof Error ? error.name : "unknown",
107
+ issues:
108
+ error instanceof z.ZodError
109
+ ? error.issues.map((issue) => ({ code: issue.code, path: issue.path }))
110
+ : undefined,
111
+ })
112
+ throw error
113
+ }
114
+ }
115
+ export const ExecutionRoute = () =>
116
+ new Hono()
117
+ .get(
118
+ "/:sessionID/execution/projection",
119
+ metadata("session.executionProjection", "Read a captured execution projection", ExecutionSchema.Projection),
120
+ validator("param", Params),
121
+ validator("query", z.object({ token: z.string().uuid() })),
122
+ async (c) => {
123
+ const response = await result(() =>
124
+ Promise.resolve(
125
+ ExecutionProjection.projectionCheckpoint(c.req.valid("param").sessionID, c.req.valid("query").token),
126
+ ),
127
+ )
128
+ return c.json(response.value, response.status)
129
+ },
130
+ )
131
+ .get(
132
+ "/:sessionID/execution/summary",
133
+ metadata("session.executionSummary", "Read a compact session execution summary", ExecutionSchema.Summary),
134
+ validator("param", Params),
135
+ validator(
136
+ "query",
137
+ ExecutionSchema.Query.pick({ runID: true }).extend({ projection: z.string().uuid().optional() }),
138
+ ),
139
+ async (c) => {
140
+ const response = await result(() =>
141
+ c.req.valid("query").projection
142
+ ? Promise.resolve(
143
+ ExecutionProjection.checkpoint(c.req.valid("param").sessionID, c.req.valid("query").projection!),
144
+ )
145
+ : ExecutionService.summary(c.req.valid("param").sessionID, c.req.valid("query").runID, c.req.raw.signal),
146
+ )
147
+ return c.json(response.value, response.status)
148
+ },
149
+ )
150
+ .get(
151
+ "/:sessionID/execution/context/history",
152
+ metadata("session.executionContextHistory", "Read context request history", ExecutionSchema.ContextHistory),
153
+ validator("param", Params),
154
+ validator("query", ExecutionSchema.ContextQuery),
155
+ async (c) => {
156
+ const response = await result(() =>
157
+ ExecutionService.contextHistory(c.req.valid("param").sessionID, c.req.valid("query"), c.req.raw.signal),
158
+ )
159
+ return c.json(response.value, response.status)
160
+ },
161
+ )
162
+ .get(
163
+ "/:sessionID/execution/context/snapshots/:callID",
164
+ metadata(
165
+ "session.executionContextSnapshot",
166
+ "Read one context request snapshot",
167
+ ExecutionSchema.ContextSnapshot,
168
+ ),
169
+ validator("param", ContextParams),
170
+ validator("query", ExecutionSchema.ContextQuery.pick({ runID: true })),
171
+ async (c) => {
172
+ const { sessionID, callID } = c.req.valid("param")
173
+ const response = await result(() =>
174
+ ExecutionService.contextSnapshot(sessionID, callID, c.req.valid("query").runID, c.req.raw.signal),
175
+ )
176
+ return c.json(response.value, response.status)
177
+ },
178
+ )
179
+ .get(
180
+ "/:sessionID/execution/context/snapshots/:callID/items",
181
+ metadata("session.executionContextItems", "Read a bounded context source index", ExecutionSchema.ContextItems),
182
+ validator("param", ContextParams),
183
+ validator("query", ExecutionSchema.ContextItemsQuery),
184
+ async (c) => {
185
+ const { sessionID, callID } = c.req.valid("param")
186
+ const response = await result(() =>
187
+ ExecutionService.contextItems(sessionID, callID, c.req.valid("query"), c.req.raw.signal),
188
+ )
189
+ return c.json(response.value, response.status)
190
+ },
191
+ )
192
+ .get(
193
+ "/:sessionID/execution/trajectory",
194
+ metadata("session.executionTrajectory", "Page through session execution records", ExecutionSchema.Page),
195
+ validator("param", Params),
196
+ validator("query", ExecutionSchema.Query),
197
+ async (c) => {
198
+ const response = await result(() =>
199
+ ExecutionService.trajectory(c.req.valid("param").sessionID, c.req.valid("query"), c.req.raw.signal),
200
+ )
201
+ return c.json(response.value, response.status)
202
+ },
203
+ )
204
+ .get(
205
+ "/:sessionID/execution/nodes/:nodeID",
206
+ metadata("session.executionNode", "Inspect one session execution record", ExecutionSchema.Detail),
207
+ validator("param", NodeParams),
208
+ validator("query", ExecutionSchema.Query.pick({ runID: true })),
209
+ async (c) => {
210
+ const { sessionID, nodeID } = c.req.valid("param")
211
+ const response = await result(() =>
212
+ ExecutionService.node(sessionID, nodeID, c.req.valid("query").runID, c.req.raw.signal),
213
+ )
214
+ return c.json(response.value, response.status)
215
+ },
216
+ )
217
+ .get(
218
+ "/:sessionID/execution/nodes/:nodeID/content",
219
+ metadata("session.executionContent", "Read a bounded execution content page", ExecutionSchema.Content),
220
+ validator("param", NodeParams),
221
+ validator("query", ContentQuery),
222
+ async (c) => {
223
+ const { sessionID, nodeID } = c.req.valid("param")
224
+ const { field, offset, limit, runID, version } = c.req.valid("query")
225
+ const response = await result(() =>
226
+ ExecutionService.content(sessionID, nodeID, field, offset, limit, runID, version, c.req.raw.signal),
227
+ )
228
+ return c.json(response.value, response.status)
229
+ },
230
+ )
231
+ .get(
232
+ "/:sessionID/execution/nodes/:nodeID/content/sections",
233
+ metadata(
234
+ "session.executionContentSections",
235
+ "Read structured execution content sections",
236
+ ExecutionContent.Sections,
237
+ ),
238
+ validator("param", NodeParams),
239
+ validator("query", ExecutionContent.PageQuery),
240
+ async (c) => {
241
+ const { sessionID, nodeID } = c.req.valid("param")
242
+ const input = c.req.valid("query")
243
+ const response = await result(async () =>
244
+ ExecutionContent.sections(
245
+ await ExecutionService.contentSource(
246
+ sessionID,
247
+ nodeID,
248
+ input.field,
249
+ input.runID,
250
+ input.version,
251
+ c.req.raw.signal,
252
+ ),
253
+ input,
254
+ c.req.raw.signal,
255
+ ),
256
+ )
257
+ return c.json(response.value, response.status)
258
+ },
259
+ )
260
+ .get(
261
+ "/:sessionID/execution/nodes/:nodeID/content/search",
262
+ metadata(
263
+ "session.executionContentSearch",
264
+ "Search an entire version of execution content",
265
+ ExecutionContent.Search,
266
+ ),
267
+ validator("param", NodeParams),
268
+ validator("query", ExecutionContent.SearchQuery),
269
+ async (c) => {
270
+ const { sessionID, nodeID } = c.req.valid("param")
271
+ const input = c.req.valid("query")
272
+ const response = await result(async () =>
273
+ ExecutionContent.search(
274
+ await ExecutionService.contentSource(
275
+ sessionID,
276
+ nodeID,
277
+ input.field,
278
+ input.runID,
279
+ input.version,
280
+ c.req.raw.signal,
281
+ ),
282
+ input,
283
+ c.req.raw.signal,
284
+ ),
285
+ )
286
+ return c.json(response.value, response.status)
287
+ },
288
+ )
289
+ .get(
290
+ "/:sessionID/execution/nodes/:nodeID/content/download",
291
+ describeRoute({
292
+ operationId: "session.executionContentDownload",
293
+ summary: "Download complete execution content at a fixed version",
294
+ responses: {
295
+ 200: {
296
+ description: "Verified execution content",
297
+ content: { "application/octet-stream": { schema: { type: "string", format: "binary" } } },
298
+ },
299
+ 400: { description: "Invalid execution content query or version" },
300
+ 404: { description: "Execution content was not found" },
301
+ },
302
+ }),
303
+ validator("param", NodeParams),
304
+ validator("query", ExecutionContent.Query),
305
+ async (c) => {
306
+ const { sessionID, nodeID } = c.req.valid("param")
307
+ const input = c.req.valid("query")
308
+ const response = await result(async () =>
309
+ download(
310
+ await ExecutionService.contentSource(
311
+ sessionID,
312
+ nodeID,
313
+ input.field,
314
+ input.runID,
315
+ input.version,
316
+ c.req.raw.signal,
317
+ ),
318
+ input.field,
319
+ c.req.raw.signal,
320
+ ),
321
+ )
322
+ return response.status === 200 ? response.value : c.json(response.value, response.status)
323
+ },
324
+ )