@ericsanchezok/synergy-workbench 0.0.0-stage → 3.1.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 (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,221 @@
1
+ import { createHash } from "node:crypto"
2
+ import path from "node:path"
3
+ import { ReviewSchema as R } from "./schema"
4
+ import { ScopeContext } from "@ericsanchezok/synergy-harness/scope/context"
5
+ import { WorkspaceBinding } from "@ericsanchezok/synergy-harness/workspace"
6
+ import { SnapshotSchema } from "@ericsanchezok/synergy-harness/session/snapshot-schema"
7
+ import { FileView } from "@ericsanchezok/synergy-local-runtime/file/view"
8
+ import { WorktreeProcess } from "@ericsanchezok/synergy-local-runtime/workspace/process"
9
+
10
+ export namespace ReviewGit {
11
+ const maxBytes = 768 * 1024
12
+ const hash = (value: string | Uint8Array) => createHash("sha256").update(value).digest("hex")
13
+ async function validate(input: R.CompareInput) {
14
+ const workspace = ScopeContext.current.workspace
15
+ if (!workspace || workspace.id !== input.workspaceID || workspace.generation !== input.generation)
16
+ throw new R.Conflict({ message: "The selected workspace changed. Refresh the comparison." })
17
+ await WorkspaceBinding.validate(input.workspaceID, ScopeContext.current.scope.id, input.generation)
18
+ if (!FileView.native()) throw new R.Invalid({ message: "Git comparison requires an active native workspace." })
19
+ return { id: input.workspaceID, generation: input.generation, root: workspace.path }
20
+ }
21
+ async function git(args: string[], signal?: AbortSignal, diff = false) {
22
+ const result = await WorktreeProcess.run({
23
+ command: ["git", "-c", "core.quotepath=false", "-c", "core.fsmonitor=false", "--literal-pathspecs", ...args],
24
+ directory: FileView.directory(),
25
+ roots: [],
26
+ metadata: true,
27
+ env: { GIT_OPTIONAL_LOCKS: "0", GIT_NO_REPLACE_OBJECTS: "1", GIT_TERMINAL_PROMPT: "0" },
28
+ signal: AbortSignal.any([AbortSignal.timeout(30_000), ...(signal ? [signal] : [])]),
29
+ })
30
+ if (result.exitCode !== 0 && !(diff && result.exitCode === 1))
31
+ throw new R.Invalid({ message: result.stderr.toString().trim() || "The repository could not be read." })
32
+ return result.stdout
33
+ }
34
+ async function ref(value: string, signal?: AbortSignal) {
35
+ if (value.startsWith("-") || /[\0\r\n]/.test(value))
36
+ throw new R.Invalid({ message: "Invalid comparison reference." })
37
+ return (await git(["rev-parse", "--verify", "--end-of-options", `${value}^{commit}`], signal)).toString().trim()
38
+ }
39
+ async function endpoints(input: R.CompareInput, signal?: AbortSignal) {
40
+ if (input.source === "branch") {
41
+ const [from, to] = await Promise.all([ref(input.from ?? "origin/dev", signal), ref(input.to ?? "HEAD", signal)])
42
+ return { from, to }
43
+ }
44
+ const from = await ref("HEAD", signal).catch((error) => {
45
+ if (signal?.aborted) throw error
46
+ return ""
47
+ })
48
+ return { from, to: "worktree" }
49
+ }
50
+ async function currentVersion(file: string) {
51
+ const stat = await FileView.stat(file)
52
+ return stat?.entryVersion ?? "missing"
53
+ }
54
+ async function fileVersion(input: R.CompareInput, endpoint: { from: string; to: string }, file: string) {
55
+ return hash(
56
+ JSON.stringify([
57
+ input.workspaceID,
58
+ input.generation,
59
+ endpoint.from,
60
+ endpoint.to,
61
+ file,
62
+ input.source === "worktree" ? await currentVersion(file) : "",
63
+ ]),
64
+ )
65
+ }
66
+ export async function compare(raw: R.CompareInput, signal?: AbortSignal) {
67
+ const input = R.CompareInput.parse(raw)
68
+ const workspace = await validate(input)
69
+ const endpoint = await endpoints(input, signal)
70
+ const args = ["diff", "--no-ext-diff", "--no-textconv", "--no-renames"]
71
+ const references = [endpoint.from, ...(input.source === "branch" ? [endpoint.to] : []), "--", "."]
72
+ const [stats, names] = endpoint.from
73
+ ? await Promise.all([
74
+ git([...args, "--numstat", "-z", ...references], signal).then((bytes) => bytes.toString()),
75
+ git([...args, "--name-status", "-z", ...references], signal).then((bytes) => bytes.toString().split("\0")),
76
+ ])
77
+ : ["", []]
78
+ const statuses = new Map<string, string>()
79
+ for (let index = 0; index + 1 < names.length; index += 2) statuses.set(names[index + 1]!, names[index]!)
80
+ const files: R.File[] = stats
81
+ .split("\0")
82
+ .filter(Boolean)
83
+ .map((record) => {
84
+ const first = record.indexOf("\t"),
85
+ second = record.indexOf("\t", first + 1)
86
+ const additions = record.slice(0, first),
87
+ deletions = record.slice(first + 1, second)
88
+ return {
89
+ file: record.slice(second + 1),
90
+ additions: Number(additions) || 0,
91
+ deletions: Number(deletions) || 0,
92
+ binary: additions === "-",
93
+ workspace,
94
+ status:
95
+ statuses.get(record.slice(second + 1)) === "D"
96
+ ? "deleted"
97
+ : statuses.get(record.slice(second + 1)) === "A"
98
+ ? "added"
99
+ : "modified",
100
+ version: "",
101
+ }
102
+ })
103
+ if (input.source === "worktree") {
104
+ const untracked = (
105
+ await git(["ls-files", "--others", "--exclude-standard", "-z", ...(!endpoint.from ? ["--cached"] : [])], signal)
106
+ )
107
+ .toString()
108
+ .split("\0")
109
+ .filter(Boolean)
110
+ if (new Set([...files.map((file) => file.file), ...untracked]).size > 5000)
111
+ throw new R.Invalid({ message: "The comparison exceeds the 5,000 file limit. Narrow the comparison." })
112
+ for (const filename of new Set(untracked)) {
113
+ signal?.throwIfAborted()
114
+ if (files.some((file) => file.file === filename)) continue
115
+ const after = await readCurrent(filename, signal)
116
+ if (after.kind === "missing") continue
117
+ files.push({
118
+ file: filename,
119
+ additions:
120
+ after.kind === "text"
121
+ ? (after.content?.match(/\n/g)?.length ?? 0) + (after.content && !after.content.endsWith("\n") ? 1 : 0)
122
+ : 0,
123
+ deletions: 0,
124
+ binary: after.kind !== "text",
125
+ workspace,
126
+ status: "added",
127
+ version: "",
128
+ })
129
+ }
130
+ }
131
+ if (files.length > 5000)
132
+ throw new R.Invalid({ message: "The comparison exceeds the 5,000 file limit. Narrow the comparison." })
133
+ for (let offset = 0; offset < files.length; offset += 16)
134
+ await Promise.all(
135
+ files.slice(offset, offset + 16).map(async (file) => {
136
+ file.version = await fileVersion(input, endpoint, file.file)
137
+ }),
138
+ )
139
+ await validate(input)
140
+ return { source: input.source, ...endpoint, files: files.sort((a, b) => a.file.localeCompare(b.file)) }
141
+ }
142
+ const missing: SnapshotSchema.FileVersion = { kind: "missing", version: "missing", bytes: 0 }
143
+ function decode(bytes: Buffer, version = hash(bytes)): SnapshotSchema.FileVersion {
144
+ const base = { version, bytes: bytes.length }
145
+ try {
146
+ if (bytes.includes(0)) throw new Error("binary")
147
+ return { ...base, kind: "text", content: new TextDecoder("utf-8", { fatal: true }).decode(bytes) }
148
+ } catch {
149
+ return { ...base, kind: "binary", base64: bytes.toString("base64") }
150
+ }
151
+ }
152
+ async function readCurrent(file: string, signal?: AbortSignal): Promise<SnapshotSchema.FileVersion> {
153
+ signal?.throwIfAborted()
154
+ const stat = await FileView.stat(file)
155
+ if (!stat) return missing
156
+ if (stat.kind !== "file") return { kind: "symlink", version: stat.entryVersion, bytes: stat.size }
157
+ if (stat.size > maxBytes) return { kind: "oversized", version: stat.entryVersion, bytes: stat.size }
158
+ const canonical = await FileView.canonical(file)
159
+ const relative = path.relative(await FileView.canonical("."), canonical)
160
+ if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative))
161
+ throw new R.Invalid({ message: "The file resolves outside the workspace." })
162
+ const bytes = await FileView.bytes(file, undefined, maxBytes)
163
+ signal?.throwIfAborted()
164
+ if ((await FileView.stat(file))?.entryVersion !== stat.entryVersion)
165
+ throw new R.Conflict({ message: "The file changed while it was read." })
166
+ return decode(Buffer.from(bytes))
167
+ }
168
+ async function readObject(tree: string, file: string, signal?: AbortSignal): Promise<SnapshotSchema.FileVersion> {
169
+ if (!tree) return missing
170
+ const listing = (await git(["ls-tree", "-l", "-z", tree, "--", file], signal)).toString()
171
+ if (!listing) return missing
172
+ const [mode, , oid, size] = listing.slice(0, listing.indexOf("\t")).trim().split(/\s+/)
173
+ const base = { version: oid!, bytes: Number(size) || 0 }
174
+ if (mode === "120000" || mode === "160000") return { ...base, kind: "symlink" }
175
+ if (Number(size) > maxBytes) return { ...base, kind: "oversized" }
176
+ return decode(await git(["cat-file", "blob", oid!], signal), oid)
177
+ }
178
+ export async function file(raw: import("zod").z.infer<typeof R.FileInput>, signal?: AbortSignal) {
179
+ const input = R.FileInput.parse(raw)
180
+ const workspace = await validate(input)
181
+ const endpoint = await endpoints(input, signal)
182
+ if ((await fileVersion(input, endpoint, input.file)) !== input.version)
183
+ throw new R.Conflict({ message: "This comparison changed. Refresh before continuing." })
184
+ const [before, after] = await Promise.all([
185
+ readObject(endpoint.from, input.file, signal),
186
+ input.source === "worktree" ? readCurrent(input.file, signal) : readObject(endpoint.to, input.file, signal),
187
+ ])
188
+ const oversized = before.kind === "oversized" || after.kind === "oversized"
189
+ const added = before.kind === "missing" && input.source === "worktree"
190
+ const args = added
191
+ ? ["diff", "--no-index", "--binary", "--no-ext-diff", "--no-textconv", "--", "/dev/null", input.file]
192
+ : [
193
+ "diff",
194
+ "--binary",
195
+ "--no-ext-diff",
196
+ "--no-textconv",
197
+ "--no-renames",
198
+ endpoint.from,
199
+ ...(input.source === "branch" ? [endpoint.to] : []),
200
+ "--",
201
+ input.file,
202
+ ]
203
+ const patch = oversized ? "" : (await git(args, signal, added)).toString()
204
+ const lines = patch.split("\n")
205
+ const diff = {
206
+ file: input.file,
207
+ workspace,
208
+ patch,
209
+ preview: patch,
210
+ binary: before.kind === "binary" || after.kind === "binary",
211
+ additions: lines.filter((line) => line.startsWith("+") && !line.startsWith("+++")).length,
212
+ deletions: lines.filter((line) => line.startsWith("-") && !line.startsWith("---")).length,
213
+ beforeBytes: before.bytes,
214
+ afterBytes: after.bytes,
215
+ }
216
+ if ((await fileVersion(input, endpoint, input.file)) !== input.version)
217
+ throw new R.Conflict({ message: "This comparison changed while it was read." })
218
+ await validate(input)
219
+ return { before, after, diff, version: input.version }
220
+ }
221
+ }
@@ -0,0 +1,65 @@
1
+ import { Hono } from "hono"
2
+ import { describeRoute, resolver, validator } from "hono-openapi"
3
+ import { z } from "zod"
4
+ import { ReviewSchema as R } from "./schema"
5
+ import { ReviewGit } from "./git"
6
+ import { ReviewState } from "./state"
7
+
8
+ const response = (schema: z.ZodType) => ({
9
+ description: "Review result",
10
+ content: { "application/json": { schema: resolver(schema) } },
11
+ })
12
+ const errors = { 400: response(R.Invalid.Schema), 409: response(R.Conflict.Schema) }
13
+ async function handle<T>(load: () => Promise<T>) {
14
+ try {
15
+ return Response.json(await load())
16
+ } catch (error) {
17
+ if (error instanceof R.Invalid) return Response.json(error.toObject(), { status: 400 })
18
+ if (error instanceof R.Conflict) return Response.json(error.toObject(), { status: 409 })
19
+ throw error
20
+ }
21
+ }
22
+ export function ReviewRoutes() {
23
+ return new Hono()
24
+ .get(
25
+ "/compare",
26
+ describeRoute({
27
+ summary: "Compare Git file versions without mutations",
28
+ operationId: "review.compare",
29
+ responses: { 200: response(R.Comparison), ...errors },
30
+ }),
31
+ validator("query", R.CompareInput),
32
+ (c) => handle(() => ReviewGit.compare(c.req.valid("query"), c.req.raw.signal)),
33
+ )
34
+ .get(
35
+ "/file",
36
+ describeRoute({
37
+ summary: "Read a version checked Git diff and its contents",
38
+ operationId: "review.file",
39
+ responses: { 200: response(R.Content), ...errors },
40
+ }),
41
+ validator("query", R.FileInput),
42
+ (c) => handle(() => ReviewGit.file(c.req.valid("query"), c.req.raw.signal)),
43
+ )
44
+ .get(
45
+ "/state/:sessionID",
46
+ describeRoute({
47
+ summary: "Read session review notes",
48
+ operationId: "review.state.get",
49
+ responses: { 200: response(ReviewState.Result), ...errors },
50
+ }),
51
+ validator("param", ReviewState.Input.pick({ sessionID: true })),
52
+ (c) => handle(() => ReviewState.get(c.req.valid("param").sessionID)),
53
+ )
54
+ .put(
55
+ "/state/:sessionID",
56
+ describeRoute({
57
+ summary: "Save session review notes with a revision check",
58
+ operationId: "review.state.update",
59
+ responses: { 200: response(ReviewState.Result), ...errors },
60
+ }),
61
+ validator("param", ReviewState.Input.pick({ sessionID: true })),
62
+ validator("json", ReviewState.Input.omit({ sessionID: true })),
63
+ (c) => handle(() => ReviewState.update({ sessionID: c.req.valid("param").sessionID, ...c.req.valid("json") })),
64
+ )
65
+ }
@@ -0,0 +1,34 @@
1
+ import { z } from "zod"
2
+ import { SnapshotSchema } from "@ericsanchezok/synergy-harness/session/snapshot-schema"
3
+ import { NamedError } from "@ericsanchezok/synergy-util/error"
4
+
5
+ export namespace ReviewSchema {
6
+ export const Invalid = NamedError.create("ReviewInvalid", z.object({ message: z.string() }))
7
+ export const Conflict = NamedError.create("ReviewConflict", z.object({ message: z.string() }))
8
+ export const CompareInput = z.object({
9
+ source: z.enum(["worktree", "branch"]),
10
+ workspaceID: z.string().min(1),
11
+ generation: z.coerce.number().int().positive(),
12
+ from: z.string().min(1).max(200).optional(),
13
+ to: z.string().min(1).max(200).optional(),
14
+ })
15
+ export type CompareInput = z.infer<typeof CompareInput>
16
+ export const File = SnapshotSchema.FileDiff.extend({
17
+ version: z.string(),
18
+ status: z.enum(["modified", "added", "deleted"]),
19
+ }).meta({ ref: "ReviewComparisonFile" })
20
+ export type File = z.infer<typeof File>
21
+ export const Comparison = z
22
+ .object({
23
+ source: z.enum(["worktree", "branch"]),
24
+ from: z.string(),
25
+ to: z.string(),
26
+ files: z.array(File).max(5000),
27
+ })
28
+ .meta({ ref: "ReviewComparison" })
29
+ export const FileInput = CompareInput.extend({ file: SnapshotSchema.FilePath, version: z.string().min(1) })
30
+ export const Content = SnapshotSchema.FileVersions.extend({
31
+ diff: SnapshotSchema.FileDiff,
32
+ version: z.string(),
33
+ }).meta({ ref: "ReviewFileContent" })
34
+ }
@@ -0,0 +1,77 @@
1
+ import { z } from "zod"
2
+ import { Session } from "@ericsanchezok/synergy-harness/session"
3
+ import { ScopeContext } from "@ericsanchezok/synergy-harness/scope/context"
4
+ import { Storage } from "@ericsanchezok/synergy-harness/storage/storage"
5
+ import { StoragePath } from "@ericsanchezok/synergy-harness/storage/path"
6
+ import { Identifier } from "@ericsanchezok/synergy-harness/id/id"
7
+ import { Bus } from "@ericsanchezok/synergy-harness/bus"
8
+ import { BusEvent } from "@ericsanchezok/synergy-harness/bus/bus-event"
9
+ import { ReviewSchema } from "./schema"
10
+
11
+ export namespace ReviewState {
12
+ export const Comment = z
13
+ .object({
14
+ id: z.string().min(1).max(100),
15
+ source: z.string().max(500),
16
+ fileKey: z.string().max(2000),
17
+ file: z.string().max(2000),
18
+ version: z.string().max(200),
19
+ side: z.enum(["additions", "deletions"]),
20
+ start: z.number().int().positive(),
21
+ end: z.number().int().positive(),
22
+ excerpt: z.string().max(12_000),
23
+ text: z.string().min(1).max(12_000),
24
+ resolved: z.boolean(),
25
+ })
26
+ .refine((item) => item.end >= item.start && item.end - item.start < 200, "Invalid comment range")
27
+ .meta({ ref: "ReviewComment" })
28
+ export const Value = z
29
+ .object({
30
+ version: z.literal(1),
31
+ comments: z.array(Comment).max(200),
32
+ viewed: z.record(z.string().max(2500), z.string().max(200)).refine((value) => Object.keys(value).length <= 5000),
33
+ })
34
+ .meta({ ref: "ReviewStateValue" })
35
+ export const Result = z
36
+ .object({ revision: z.number().int().nonnegative(), state: Value })
37
+ .meta({ ref: "ReviewStateResult" })
38
+ export const Input = z
39
+ .object({ sessionID: Identifier.schema("session"), revision: z.number().int().nonnegative(), state: Value })
40
+ .meta({ ref: "ReviewStateInput" })
41
+ export const Updated = BusEvent.define(
42
+ "review.state.updated",
43
+ z.object({ sessionID: Identifier.schema("session"), revision: z.number().int().nonnegative() }),
44
+ )
45
+ async function key(sessionID: string) {
46
+ const session = await Session.get(sessionID)
47
+ if (session.scope.id !== ScopeContext.current.scope.id)
48
+ throw new ReviewSchema.Invalid({ message: "Review belongs to another scope." })
49
+ return StoragePath.sessionReview(session.scope.id, sessionID)
50
+ }
51
+ export async function get(sessionID: string): Promise<z.infer<typeof Result>> {
52
+ const target = await key(sessionID)
53
+ try {
54
+ const record = await Storage.versioned<z.infer<typeof Value>>(target)
55
+ return { revision: Number(record.revision), state: Value.parse(record.value) }
56
+ } catch (error) {
57
+ if (!(error instanceof Storage.NotFoundError)) throw error
58
+ return { revision: 0, state: { version: 1, comments: [], viewed: {} } }
59
+ }
60
+ }
61
+ export async function update(raw: z.infer<typeof Input>) {
62
+ const input = Input.parse(raw)
63
+ const target = await key(input.sessionID)
64
+ const result = await Storage.transaction(async (tx) => {
65
+ const record = await tx.versioned<z.infer<typeof Value>>(target).catch((error) => {
66
+ if (!(error instanceof Storage.NotFoundError)) throw error
67
+ return undefined
68
+ })
69
+ if (Number(record?.revision ?? 0) !== input.revision)
70
+ throw new ReviewSchema.Conflict({ message: "Review notes changed. Reload before saving." })
71
+ await tx.write(target, input.state, { expectedRevision: BigInt(input.revision) })
72
+ return { revision: input.revision + 1, state: input.state }
73
+ })
74
+ await Bus.publish(Updated, { sessionID: input.sessionID, revision: result.revision })
75
+ return result
76
+ }
77
+ }
@@ -0,0 +1,277 @@
1
+ import { RolloutSnapshot } from "@ericsanchezok/synergy-harness/session/rollout/snapshot"
2
+ import { RolloutAccounting } from "@ericsanchezok/synergy-harness/session/rollout/accounting"
3
+ import { MessageV2 } from "@ericsanchezok/synergy-harness/session/message-v2"
4
+ import type { Info as SessionInfo } from "@ericsanchezok/synergy-harness/session/types"
5
+ import type * as Stats from "./types"
6
+
7
+ export namespace Aggregator {
8
+ const emptyTokenBreakdown = (): Stats.TokenBreakdown => ({
9
+ input: 0,
10
+ output: 0,
11
+ reasoning: 0,
12
+ cache: { read: 0, write: 0 },
13
+ })
14
+
15
+ function addTokens(target: Stats.TokenBreakdown, source: Stats.TokenBreakdown) {
16
+ target.input += source.input
17
+ target.output += source.output
18
+ target.reasoning += source.reasoning
19
+ target.cache.read += source.cache.read
20
+ target.cache.write += source.cache.write
21
+ }
22
+
23
+ export type DigestProgress = (current: number, total: number) => void
24
+
25
+ export async function digest(session: SessionInfo): Promise<Stats.SessionDigest> {
26
+ // Pass scopeID directly to avoid requireSession looking up session_index,
27
+ // which may be missing for legacy or reclaimed sessions.
28
+ const scopeID = (session.scope as { id: string })?.id
29
+ const messages: MessageV2.WithParts[] = []
30
+ for await (const msg of MessageV2.stream({
31
+ scopeID,
32
+ sessionID: session.id,
33
+ })) {
34
+ messages.push(msg)
35
+ }
36
+
37
+ const rollout = await RolloutSnapshot.read({ kind: "session", scopeID: session.scope.id, sessionID: session.id })
38
+ const recordedRoots = new Set(rollout.runs.map((run) => run.id))
39
+ const accounting = RolloutAccounting.summarize(rollout)
40
+ const tokens = emptyTokenBreakdown()
41
+ let cost = 0
42
+ let turns = 0
43
+ let messageCount = 0
44
+ let errorCount = 0
45
+ let compactionCount = 0
46
+ let retryCount = 0
47
+
48
+ const modelUsage: Stats.SessionDigest["modelUsage"] = {}
49
+ const agentUsage: Stats.SessionDigest["agentUsage"] = {}
50
+ const toolUsage: Stats.SessionDigest["toolUsage"] = {}
51
+ const hourlyTurns: Stats.SessionDigest["hourlyTurns"] = {}
52
+
53
+ function hourKey(timestamp: number) {
54
+ const d = new Date(timestamp)
55
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}T${String(d.getHours()).padStart(2, "0")}`
56
+ }
57
+
58
+ for (const msg of MessageV2.deriveSemantics(
59
+ messages.toSorted((a, b) => a.info.time.created - b.info.time.created),
60
+ )) {
61
+ messageCount++
62
+
63
+ if (msg.info.role === "user" && msg.info.isRoot) {
64
+ turns++
65
+ const key = hourKey(msg.info.time.created)
66
+ hourlyTurns[key] = (hourlyTurns[key] ?? 0) + 1
67
+ }
68
+
69
+ if (msg.info.role === "assistant") {
70
+ const info = msg.info
71
+ const legacy =
72
+ info.accounting?.kind !== "inherited" &&
73
+ info.accounting?.kind !== "imported" &&
74
+ info.accounting?.kind !== "rollout" &&
75
+ !recordedRoots.has(info.rootID ?? info.parentID)
76
+ const messageTokens = legacy ? info.tokens : emptyTokenBreakdown()
77
+ const messageCost = legacy ? info.cost : 0
78
+ if (legacy) {
79
+ accounting.legacy.cost += info.cost
80
+ accounting.legacy.messages++
81
+ }
82
+ addTokens(tokens, messageTokens)
83
+ cost += messageCost
84
+
85
+ if (info.error !== undefined) errorCount++
86
+
87
+ const modelKey = `${info.providerID}/${info.modelID}`
88
+ const modelEntry = modelUsage[modelKey] ?? {
89
+ messages: 0,
90
+ tokens: emptyTokenBreakdown(),
91
+ cost: 0,
92
+ totalResponseMs: 0,
93
+ }
94
+ modelEntry.messages++
95
+ addTokens(modelEntry.tokens, messageTokens)
96
+ modelEntry.cost += messageCost
97
+ if (legacy) {
98
+ modelEntry.accounting ??= RolloutAccounting.empty()
99
+ modelEntry.accounting.legacy.cost += messageCost
100
+ modelEntry.accounting.legacy.messages++
101
+ }
102
+ if (legacy && info.time.completed !== undefined) {
103
+ modelEntry.totalResponseMs += info.time.completed - info.time.created
104
+ }
105
+ modelUsage[modelKey] = modelEntry
106
+
107
+ const agentEntry = agentUsage[info.agent] ?? {
108
+ messages: 0,
109
+ tokens: emptyTokenBreakdown(),
110
+ cost: 0,
111
+ }
112
+ agentEntry.messages++
113
+ addTokens(agentEntry.tokens, messageTokens)
114
+ agentEntry.cost += messageCost
115
+ if (legacy) {
116
+ agentEntry.accounting ??= RolloutAccounting.empty()
117
+ agentEntry.accounting.legacy.cost += messageCost
118
+ agentEntry.accounting.legacy.messages++
119
+ }
120
+ agentUsage[info.agent] = agentEntry
121
+ }
122
+
123
+ for (const part of msg.parts) {
124
+ if (part.type === "tool") {
125
+ const toolEntry = toolUsage[part.tool] ?? {
126
+ calls: 0,
127
+ successes: 0,
128
+ errors: 0,
129
+ totalDurationMs: 0,
130
+ timedSamples: 0,
131
+ }
132
+ toolEntry.calls++
133
+ if (part.state.status === "completed") {
134
+ toolEntry.successes++
135
+ if (part.state.time.start !== undefined && part.state.time.end !== undefined) {
136
+ toolEntry.totalDurationMs += part.state.time.end - part.state.time.start
137
+ toolEntry.timedSamples = (toolEntry.timedSamples ?? 0) + 1
138
+ }
139
+ }
140
+ if (part.state.status === "error") {
141
+ toolEntry.errors++
142
+ }
143
+ toolUsage[part.tool] = toolEntry
144
+ }
145
+ if (part.type === "compaction") compactionCount++
146
+ if (part.type === "retry") retryCount++
147
+ }
148
+ }
149
+
150
+ const totals = { tokens, cost, modelUsage, agentUsage }
151
+ addCalls(rollout, totals)
152
+ cost = totals.cost
153
+
154
+ const endpoint = session.endpoint
155
+ ? {
156
+ kind: session.endpoint.kind,
157
+ type: session.endpoint.kind === "channel" ? session.endpoint.channel.type : undefined,
158
+ }
159
+ : undefined
160
+
161
+ const interaction = session.interaction
162
+ ? {
163
+ mode: session.interaction.mode,
164
+ source: session.interaction.source,
165
+ }
166
+ : undefined
167
+
168
+ return {
169
+ sessionID: session.id,
170
+ scopeID: session.scope.id,
171
+ created: session.time.created,
172
+ updated: session.time.updated,
173
+ rolloutRevision: rollout.revision,
174
+ accounting,
175
+ archived: session.time.archived,
176
+ pinned: session.pinned !== undefined,
177
+ parentID: session.parentID,
178
+ endpoint,
179
+ interaction,
180
+ turns,
181
+ messages: messageCount,
182
+ tokens,
183
+ cost,
184
+ modelUsage,
185
+ agentUsage,
186
+ toolUsage,
187
+ hourlyTurns,
188
+ additions: session.summary?.additions ?? 0,
189
+ deletions: session.summary?.deletions ?? 0,
190
+ files: session.summary?.files ?? 0,
191
+ compactionCount,
192
+ retryCount,
193
+ errorCount,
194
+ durationMs: session.time.updated - session.time.created,
195
+ }
196
+ }
197
+
198
+ function addCalls(
199
+ rollout: RolloutSnapshot.Info,
200
+ totals: Pick<Stats.SessionDigest, "tokens" | "cost" | "modelUsage" | "agentUsage">,
201
+ ) {
202
+ const attemptsByCall = Map.groupBy(rollout.attempts, (attempt) => attempt.callID)
203
+ for (const call of rollout.calls) {
204
+ const entry = RolloutAccounting.summarize({
205
+ calls: [call],
206
+ attempts: attemptsByCall.get(call.id) ?? [],
207
+ gaps: [],
208
+ })
209
+ const usage = {
210
+ input: entry.tokens.uncached.known,
211
+ output: entry.tokens.output.known,
212
+ reasoning: entry.tokens.reasoning.known,
213
+ cache: { read: entry.tokens.cacheRead.known, write: entry.tokens.cacheWrite.known },
214
+ }
215
+ const knownCost = RolloutAccounting.knownExpense(entry)
216
+ addTokens(totals.tokens, usage)
217
+ totals.cost += knownCost
218
+ const modelKey = `${call.model.providerID}/${call.model.modelID}`
219
+ const modelEntry = totals.modelUsage[modelKey] ?? {
220
+ messages: 0,
221
+ tokens: emptyTokenBreakdown(),
222
+ cost: 0,
223
+ totalResponseMs: 0,
224
+ }
225
+ addTokens(modelEntry.tokens, usage)
226
+ modelEntry.cost += knownCost
227
+ if (call.ended !== undefined) modelEntry.totalResponseMs += call.ended - call.started
228
+ modelEntry.accounting = RolloutAccounting.merge([modelEntry.accounting ?? RolloutAccounting.empty(), entry])
229
+ totals.modelUsage[modelKey] = modelEntry
230
+ const agentKey = call.agent ?? call.purpose
231
+ const agentEntry = totals.agentUsage[agentKey] ?? { messages: 0, tokens: emptyTokenBreakdown(), cost: 0 }
232
+ addTokens(agentEntry.tokens, usage)
233
+ agentEntry.cost += knownCost
234
+ agentEntry.accounting = RolloutAccounting.merge([agentEntry.accounting ?? RolloutAccounting.empty(), entry])
235
+ totals.agentUsage[agentKey] = agentEntry
236
+ }
237
+ }
238
+
239
+ export function operation(rollout: RolloutSnapshot.Info): Stats.OperationDigest {
240
+ if (rollout.owner.kind !== "operation") throw new Error("Expected independent operation")
241
+ const started = rollout.runs.map((run) => run.started)
242
+ const result: Stats.OperationDigest = {
243
+ operationID: rollout.owner.operationID,
244
+ scopeID: rollout.owner.scopeID,
245
+ rolloutRevision: rollout.revision,
246
+ created: started.length ? Math.min(...started) : 0,
247
+ updated: Math.max(0, ...rollout.runs.map((run) => run.ended ?? run.started)),
248
+ turns: 0,
249
+ tokens: emptyTokenBreakdown(),
250
+ cost: 0,
251
+ modelUsage: {},
252
+ agentUsage: {},
253
+ accounting: RolloutAccounting.summarize(rollout),
254
+ }
255
+ addCalls(rollout, result)
256
+ return result
257
+ }
258
+
259
+ export async function digestAll(
260
+ sessions: SessionInfo[],
261
+ onProgress?: DigestProgress,
262
+ ): Promise<Stats.SessionDigest[]> {
263
+ const results: Stats.SessionDigest[] = []
264
+ const batchSize = 20
265
+
266
+ for (let i = 0; i < sessions.length; i += batchSize) {
267
+ const batch = sessions.slice(i, i + batchSize)
268
+ const batchResults = await Promise.all(batch.map((s) => digest(s)))
269
+ for (const r of batchResults) {
270
+ if (r) results.push(r)
271
+ }
272
+ onProgress?.(Math.min(i + batchSize, sessions.length), sessions.length)
273
+ }
274
+
275
+ return results
276
+ }
277
+ }