@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.
- package/AGENTS.md +30 -0
- package/README.md +16 -2
- package/dist/agents.js +11 -0
- package/dist/cli-adapter.js +8 -0
- package/dist/component.js +65 -0
- package/dist/config-schema.js +265 -0
- package/dist/execution/activity.js +78 -0
- package/dist/execution/content.js +184 -0
- package/dist/execution/json-sections.js +231 -0
- package/dist/execution/presentation.js +79 -0
- package/dist/execution/preview.js +13 -0
- package/dist/execution/process.js +125 -0
- package/dist/execution/projection.js +327 -0
- package/dist/execution/query.js +18 -0
- package/dist/execution/routes.js +189 -0
- package/dist/execution/schema.js +322 -0
- package/dist/execution/service.js +1993 -0
- package/dist/features.js +22 -0
- package/dist/http.js +65 -0
- package/dist/performance/analysis.js +330 -0
- package/dist/performance/catalog.js +391 -0
- package/dist/performance/dashboard.js +483 -0
- package/dist/performance/error.js +15 -0
- package/dist/performance/index.js +6 -0
- package/dist/performance/inflight.js +43 -0
- package/dist/performance/projection.js +138 -0
- package/dist/performance/prompt/base.txt +38 -0
- package/dist/performance/prompt/builder.js +21 -0
- package/dist/performance/routes/performance-route.js +439 -0
- package/dist/performance/schema.js +628 -0
- package/dist/performance/timeline.js +98 -0
- package/dist/performance/trace-detail.js +91 -0
- package/dist/presentation/service.js +270 -0
- package/dist/presentation/tools/workbench-view.js +65 -0
- package/dist/presentation/tools.js +10 -0
- package/dist/project/directories.js +363 -0
- package/dist/project/git-health.js +498 -0
- package/dist/project/ide.js +66 -0
- package/dist/project/migration.js +22 -0
- package/dist/project/routes/directories.js +89 -0
- package/dist/project/routes/git.js +30 -0
- package/dist/project/routes/task-defaults.js +42 -0
- package/dist/project/session-health.js +9 -0
- package/dist/project/startup.js +28 -0
- package/dist/project/task-defaults.js +68 -0
- package/dist/project/tools/session-control.js +664 -0
- package/dist/project/tools/session-control.txt +60 -0
- package/dist/project/tools/worktree-archive.js +76 -0
- package/dist/project/tools/worktree-enter.js +175 -0
- package/dist/project/tools/worktree-leave.js +119 -0
- package/dist/project/tools/worktree-list.js +52 -0
- package/dist/project/tools.js +26 -0
- package/dist/project/vcs-branch-watcher.js +76 -0
- package/dist/project/vcs.js +114 -0
- package/dist/project/worktree-janitor.js +140 -0
- package/dist/project/worktrees.js +98 -0
- package/dist/push/bridge.js +171 -0
- package/dist/push/index.js +4 -0
- package/dist/push/routes/push.js +224 -0
- package/dist/push/service.js +93 -0
- package/dist/push/store.js +121 -0
- package/dist/push/types.js +58 -0
- package/dist/review/git.js +226 -0
- package/dist/review/routes.js +45 -0
- package/dist/review/schema.js +32 -0
- package/dist/review/state.js +79 -0
- package/dist/stats/aggregator.js +252 -0
- package/dist/stats/canonical.js +89 -0
- package/dist/stats/cli/stats.js +302 -0
- package/dist/stats/engine.js +176 -0
- package/dist/stats/index.js +5 -0
- package/dist/stats/rollup.js +460 -0
- package/dist/stats/routes/stats.js +94 -0
- package/dist/stats/routes/usage.js +83 -0
- package/dist/stats/storage.js +86 -0
- package/dist/stats/types.js +267 -0
- package/dist/worker.js +6 -0
- package/package.json +232 -4
- package/src/agents.ts +15 -0
- package/src/cli-adapter.ts +11 -0
- package/src/component.ts +73 -0
- package/src/config-schema.ts +289 -0
- package/src/execution/activity.ts +83 -0
- package/src/execution/content.ts +189 -0
- package/src/execution/json-sections.ts +230 -0
- package/src/execution/presentation.ts +91 -0
- package/src/execution/preview.ts +12 -0
- package/src/execution/process.ts +118 -0
- package/src/execution/projection.ts +330 -0
- package/src/execution/query.ts +19 -0
- package/src/execution/routes.ts +324 -0
- package/src/execution/schema.ts +336 -0
- package/src/execution/service.ts +2149 -0
- package/src/features.ts +22 -0
- package/src/http.ts +75 -0
- package/src/performance/analysis.ts +356 -0
- package/src/performance/catalog.ts +902 -0
- package/src/performance/dashboard.ts +521 -0
- package/src/performance/error.ts +32 -0
- package/src/performance/index.ts +6 -0
- package/src/performance/inflight.ts +46 -0
- package/src/performance/projection.ts +143 -0
- package/src/performance/prompt/base.txt +38 -0
- package/src/performance/prompt/builder.ts +24 -0
- package/src/performance/routes/performance-route.ts +541 -0
- package/src/performance/schema.ts +695 -0
- package/src/performance/timeline.ts +100 -0
- package/src/performance/trace-detail.ts +97 -0
- package/src/presentation/service.ts +303 -0
- package/src/presentation/tools/workbench-view.ts +75 -0
- package/src/presentation/tools.ts +11 -0
- package/src/project/directories.ts +391 -0
- package/src/project/git-health.ts +577 -0
- package/src/project/ide.ts +80 -0
- package/src/project/migration.ts +24 -0
- package/src/project/routes/directories.ts +124 -0
- package/src/project/routes/git.ts +42 -0
- package/src/project/routes/task-defaults.ts +50 -0
- package/src/project/session-health.ts +10 -0
- package/src/project/startup.ts +28 -0
- package/src/project/task-defaults.ts +70 -0
- package/src/project/tools/session-control.ts +725 -0
- package/src/project/tools/session-control.txt +60 -0
- package/src/project/tools/worktree-archive.ts +92 -0
- package/src/project/tools/worktree-enter.ts +227 -0
- package/src/project/tools/worktree-leave.ts +140 -0
- package/src/project/tools/worktree-list.ts +68 -0
- package/src/project/tools.ts +29 -0
- package/src/project/vcs-branch-watcher.ts +81 -0
- package/src/project/vcs.ts +124 -0
- package/src/project/worktree-janitor.ts +153 -0
- package/src/project/worktrees.ts +110 -0
- package/src/push/bridge.ts +182 -0
- package/src/push/index.ts +4 -0
- package/src/push/routes/push.ts +267 -0
- package/src/push/service.ts +108 -0
- package/src/push/store.ts +120 -0
- package/src/push/types.ts +67 -0
- package/src/review/git.ts +221 -0
- package/src/review/routes.ts +65 -0
- package/src/review/schema.ts +34 -0
- package/src/review/state.ts +77 -0
- package/src/stats/aggregator.ts +277 -0
- package/src/stats/canonical.ts +93 -0
- package/src/stats/cli/stats.ts +348 -0
- package/src/stats/engine.ts +197 -0
- package/src/stats/index.ts +5 -0
- package/src/stats/rollup.ts +522 -0
- package/src/stats/routes/stats.ts +107 -0
- package/src/stats/routes/usage.ts +103 -0
- package/src/stats/storage.ts +78 -0
- package/src/stats/types.ts +351 -0
- package/src/worker.ts +7 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { ReviewSchema as R } from "./schema.js";
|
|
4
|
+
import { ScopeContext } from "@ericsanchezok/synergy-harness/scope/context";
|
|
5
|
+
import { WorkspaceBinding } from "@ericsanchezok/synergy-harness/workspace";
|
|
6
|
+
import { FileView } from "@ericsanchezok/synergy-local-runtime/file/view";
|
|
7
|
+
import { WorktreeProcess } from "@ericsanchezok/synergy-local-runtime/workspace/process";
|
|
8
|
+
export var ReviewGit;
|
|
9
|
+
(function (ReviewGit) {
|
|
10
|
+
const maxBytes = 768 * 1024;
|
|
11
|
+
const hash = (value) => createHash("sha256").update(value).digest("hex");
|
|
12
|
+
async function validate(input) {
|
|
13
|
+
const workspace = ScopeContext.current.workspace;
|
|
14
|
+
if (!workspace || workspace.id !== input.workspaceID || workspace.generation !== input.generation)
|
|
15
|
+
throw new R.Conflict({ message: "The selected workspace changed. Refresh the comparison." });
|
|
16
|
+
await WorkspaceBinding.validate(input.workspaceID, ScopeContext.current.scope.id, input.generation);
|
|
17
|
+
if (!FileView.native())
|
|
18
|
+
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, signal, 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, signal) {
|
|
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, signal) {
|
|
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)
|
|
46
|
+
throw error;
|
|
47
|
+
return "";
|
|
48
|
+
});
|
|
49
|
+
return { from, to: "worktree" };
|
|
50
|
+
}
|
|
51
|
+
async function currentVersion(file) {
|
|
52
|
+
const stat = await FileView.stat(file);
|
|
53
|
+
return stat?.entryVersion ?? "missing";
|
|
54
|
+
}
|
|
55
|
+
async function fileVersion(input, endpoint, file) {
|
|
56
|
+
return hash(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
|
+
async function compare(raw, signal) {
|
|
66
|
+
const input = R.CompareInput.parse(raw);
|
|
67
|
+
const workspace = await validate(input);
|
|
68
|
+
const endpoint = await endpoints(input, signal);
|
|
69
|
+
const args = ["diff", "--no-ext-diff", "--no-textconv", "--no-renames"];
|
|
70
|
+
const references = [endpoint.from, ...(input.source === "branch" ? [endpoint.to] : []), "--", "."];
|
|
71
|
+
const [stats, names] = endpoint.from
|
|
72
|
+
? await Promise.all([
|
|
73
|
+
git([...args, "--numstat", "-z", ...references], signal).then((bytes) => bytes.toString()),
|
|
74
|
+
git([...args, "--name-status", "-z", ...references], signal).then((bytes) => bytes.toString().split("\0")),
|
|
75
|
+
])
|
|
76
|
+
: ["", []];
|
|
77
|
+
const statuses = new Map();
|
|
78
|
+
for (let index = 0; index + 1 < names.length; index += 2)
|
|
79
|
+
statuses.set(names[index + 1], names[index]);
|
|
80
|
+
const files = stats
|
|
81
|
+
.split("\0")
|
|
82
|
+
.filter(Boolean)
|
|
83
|
+
.map((record) => {
|
|
84
|
+
const first = record.indexOf("\t"), second = record.indexOf("\t", first + 1);
|
|
85
|
+
const additions = record.slice(0, first), deletions = record.slice(first + 1, second);
|
|
86
|
+
return {
|
|
87
|
+
file: record.slice(second + 1),
|
|
88
|
+
additions: Number(additions) || 0,
|
|
89
|
+
deletions: Number(deletions) || 0,
|
|
90
|
+
binary: additions === "-",
|
|
91
|
+
workspace,
|
|
92
|
+
status: statuses.get(record.slice(second + 1)) === "D"
|
|
93
|
+
? "deleted"
|
|
94
|
+
: statuses.get(record.slice(second + 1)) === "A"
|
|
95
|
+
? "added"
|
|
96
|
+
: "modified",
|
|
97
|
+
version: "",
|
|
98
|
+
};
|
|
99
|
+
});
|
|
100
|
+
if (input.source === "worktree") {
|
|
101
|
+
const untracked = (await git(["ls-files", "--others", "--exclude-standard", "-z", ...(!endpoint.from ? ["--cached"] : [])], signal))
|
|
102
|
+
.toString()
|
|
103
|
+
.split("\0")
|
|
104
|
+
.filter(Boolean);
|
|
105
|
+
if (new Set([...files.map((file) => file.file), ...untracked]).size > 5000)
|
|
106
|
+
throw new R.Invalid({ message: "The comparison exceeds the 5,000 file limit. Narrow the comparison." });
|
|
107
|
+
for (const filename of new Set(untracked)) {
|
|
108
|
+
signal?.throwIfAborted();
|
|
109
|
+
if (files.some((file) => file.file === filename))
|
|
110
|
+
continue;
|
|
111
|
+
const after = await readCurrent(filename, signal);
|
|
112
|
+
if (after.kind === "missing")
|
|
113
|
+
continue;
|
|
114
|
+
files.push({
|
|
115
|
+
file: filename,
|
|
116
|
+
additions: after.kind === "text"
|
|
117
|
+
? (after.content?.match(/\n/g)?.length ?? 0) + (after.content && !after.content.endsWith("\n") ? 1 : 0)
|
|
118
|
+
: 0,
|
|
119
|
+
deletions: 0,
|
|
120
|
+
binary: after.kind !== "text",
|
|
121
|
+
workspace,
|
|
122
|
+
status: "added",
|
|
123
|
+
version: "",
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (files.length > 5000)
|
|
128
|
+
throw new R.Invalid({ message: "The comparison exceeds the 5,000 file limit. Narrow the comparison." });
|
|
129
|
+
for (let offset = 0; offset < files.length; offset += 16)
|
|
130
|
+
await Promise.all(files.slice(offset, offset + 16).map(async (file) => {
|
|
131
|
+
file.version = await fileVersion(input, endpoint, file.file);
|
|
132
|
+
}));
|
|
133
|
+
await validate(input);
|
|
134
|
+
return { source: input.source, ...endpoint, files: files.sort((a, b) => a.file.localeCompare(b.file)) };
|
|
135
|
+
}
|
|
136
|
+
ReviewGit.compare = compare;
|
|
137
|
+
const missing = { kind: "missing", version: "missing", bytes: 0 };
|
|
138
|
+
function decode(bytes, version = hash(bytes)) {
|
|
139
|
+
const base = { version, bytes: bytes.length };
|
|
140
|
+
try {
|
|
141
|
+
if (bytes.includes(0))
|
|
142
|
+
throw new Error("binary");
|
|
143
|
+
return { ...base, kind: "text", content: new TextDecoder("utf-8", { fatal: true }).decode(bytes) };
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return { ...base, kind: "binary", base64: bytes.toString("base64") };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
async function readCurrent(file, signal) {
|
|
150
|
+
signal?.throwIfAborted();
|
|
151
|
+
const stat = await FileView.stat(file);
|
|
152
|
+
if (!stat)
|
|
153
|
+
return missing;
|
|
154
|
+
if (stat.kind !== "file")
|
|
155
|
+
return { kind: "symlink", version: stat.entryVersion, bytes: stat.size };
|
|
156
|
+
if (stat.size > maxBytes)
|
|
157
|
+
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, file, signal) {
|
|
169
|
+
if (!tree)
|
|
170
|
+
return missing;
|
|
171
|
+
const listing = (await git(["ls-tree", "-l", "-z", tree, "--", file], signal)).toString();
|
|
172
|
+
if (!listing)
|
|
173
|
+
return missing;
|
|
174
|
+
const [mode, , oid, size] = listing.slice(0, listing.indexOf("\t")).trim().split(/\s+/);
|
|
175
|
+
const base = { version: oid, bytes: Number(size) || 0 };
|
|
176
|
+
if (mode === "120000" || mode === "160000")
|
|
177
|
+
return { ...base, kind: "symlink" };
|
|
178
|
+
if (Number(size) > maxBytes)
|
|
179
|
+
return { ...base, kind: "oversized" };
|
|
180
|
+
return decode(await git(["cat-file", "blob", oid], signal), oid);
|
|
181
|
+
}
|
|
182
|
+
async function file(raw, signal) {
|
|
183
|
+
const input = R.FileInput.parse(raw);
|
|
184
|
+
const workspace = await validate(input);
|
|
185
|
+
const endpoint = await endpoints(input, signal);
|
|
186
|
+
if ((await fileVersion(input, endpoint, input.file)) !== input.version)
|
|
187
|
+
throw new R.Conflict({ message: "This comparison changed. Refresh before continuing." });
|
|
188
|
+
const [before, after] = await Promise.all([
|
|
189
|
+
readObject(endpoint.from, input.file, signal),
|
|
190
|
+
input.source === "worktree" ? readCurrent(input.file, signal) : readObject(endpoint.to, input.file, signal),
|
|
191
|
+
]);
|
|
192
|
+
const oversized = before.kind === "oversized" || after.kind === "oversized";
|
|
193
|
+
const added = before.kind === "missing" && input.source === "worktree";
|
|
194
|
+
const args = added
|
|
195
|
+
? ["diff", "--no-index", "--binary", "--no-ext-diff", "--no-textconv", "--", "/dev/null", input.file]
|
|
196
|
+
: [
|
|
197
|
+
"diff",
|
|
198
|
+
"--binary",
|
|
199
|
+
"--no-ext-diff",
|
|
200
|
+
"--no-textconv",
|
|
201
|
+
"--no-renames",
|
|
202
|
+
endpoint.from,
|
|
203
|
+
...(input.source === "branch" ? [endpoint.to] : []),
|
|
204
|
+
"--",
|
|
205
|
+
input.file,
|
|
206
|
+
];
|
|
207
|
+
const patch = oversized ? "" : (await git(args, signal, added)).toString();
|
|
208
|
+
const lines = patch.split("\n");
|
|
209
|
+
const diff = {
|
|
210
|
+
file: input.file,
|
|
211
|
+
workspace,
|
|
212
|
+
patch,
|
|
213
|
+
preview: patch,
|
|
214
|
+
binary: before.kind === "binary" || after.kind === "binary",
|
|
215
|
+
additions: lines.filter((line) => line.startsWith("+") && !line.startsWith("+++")).length,
|
|
216
|
+
deletions: lines.filter((line) => line.startsWith("-") && !line.startsWith("---")).length,
|
|
217
|
+
beforeBytes: before.bytes,
|
|
218
|
+
afterBytes: after.bytes,
|
|
219
|
+
};
|
|
220
|
+
if ((await fileVersion(input, endpoint, input.file)) !== input.version)
|
|
221
|
+
throw new R.Conflict({ message: "This comparison changed while it was read." });
|
|
222
|
+
await validate(input);
|
|
223
|
+
return { before, after, diff, version: input.version };
|
|
224
|
+
}
|
|
225
|
+
ReviewGit.file = file;
|
|
226
|
+
})(ReviewGit || (ReviewGit = {}));
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { describeRoute, resolver, validator } from "hono-openapi";
|
|
3
|
+
import { ReviewSchema as R } from "./schema.js";
|
|
4
|
+
import { ReviewGit } from "./git.js";
|
|
5
|
+
import { ReviewState } from "./state.js";
|
|
6
|
+
const response = (schema) => ({
|
|
7
|
+
description: "Review result",
|
|
8
|
+
content: { "application/json": { schema: resolver(schema) } },
|
|
9
|
+
});
|
|
10
|
+
const errors = { 400: response(R.Invalid.Schema), 409: response(R.Conflict.Schema) };
|
|
11
|
+
async function handle(load) {
|
|
12
|
+
try {
|
|
13
|
+
return Response.json(await load());
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
if (error instanceof R.Invalid)
|
|
17
|
+
return Response.json(error.toObject(), { status: 400 });
|
|
18
|
+
if (error instanceof R.Conflict)
|
|
19
|
+
return Response.json(error.toObject(), { status: 409 });
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function ReviewRoutes() {
|
|
24
|
+
return new Hono()
|
|
25
|
+
.get("/compare", describeRoute({
|
|
26
|
+
summary: "Compare Git file versions without mutations",
|
|
27
|
+
operationId: "review.compare",
|
|
28
|
+
responses: { 200: response(R.Comparison), ...errors },
|
|
29
|
+
}), validator("query", R.CompareInput), (c) => handle(() => ReviewGit.compare(c.req.valid("query"), c.req.raw.signal)))
|
|
30
|
+
.get("/file", describeRoute({
|
|
31
|
+
summary: "Read a version checked Git diff and its contents",
|
|
32
|
+
operationId: "review.file",
|
|
33
|
+
responses: { 200: response(R.Content), ...errors },
|
|
34
|
+
}), validator("query", R.FileInput), (c) => handle(() => ReviewGit.file(c.req.valid("query"), c.req.raw.signal)))
|
|
35
|
+
.get("/state/:sessionID", describeRoute({
|
|
36
|
+
summary: "Read session review notes",
|
|
37
|
+
operationId: "review.state.get",
|
|
38
|
+
responses: { 200: response(ReviewState.Result), ...errors },
|
|
39
|
+
}), validator("param", ReviewState.Input.pick({ sessionID: true })), (c) => handle(() => ReviewState.get(c.req.valid("param").sessionID)))
|
|
40
|
+
.put("/state/:sessionID", describeRoute({
|
|
41
|
+
summary: "Save session review notes with a revision check",
|
|
42
|
+
operationId: "review.state.update",
|
|
43
|
+
responses: { 200: response(ReviewState.Result), ...errors },
|
|
44
|
+
}), validator("param", ReviewState.Input.pick({ sessionID: true })), validator("json", ReviewState.Input.omit({ sessionID: true })), (c) => handle(() => ReviewState.update({ sessionID: c.req.valid("param").sessionID, ...c.req.valid("json") })));
|
|
45
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { SnapshotSchema } from "@ericsanchezok/synergy-harness/session/snapshot-schema";
|
|
3
|
+
import { NamedError } from "@ericsanchezok/synergy-util/error";
|
|
4
|
+
export var ReviewSchema;
|
|
5
|
+
(function (ReviewSchema) {
|
|
6
|
+
ReviewSchema.Invalid = NamedError.create("ReviewInvalid", z.object({ message: z.string() }));
|
|
7
|
+
ReviewSchema.Conflict = NamedError.create("ReviewConflict", z.object({ message: z.string() }));
|
|
8
|
+
ReviewSchema.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
|
+
ReviewSchema.File = SnapshotSchema.FileDiff.extend({
|
|
16
|
+
version: z.string(),
|
|
17
|
+
status: z.enum(["modified", "added", "deleted"]),
|
|
18
|
+
}).meta({ ref: "ReviewComparisonFile" });
|
|
19
|
+
ReviewSchema.Comparison = z
|
|
20
|
+
.object({
|
|
21
|
+
source: z.enum(["worktree", "branch"]),
|
|
22
|
+
from: z.string(),
|
|
23
|
+
to: z.string(),
|
|
24
|
+
files: z.array(ReviewSchema.File).max(5000),
|
|
25
|
+
})
|
|
26
|
+
.meta({ ref: "ReviewComparison" });
|
|
27
|
+
ReviewSchema.FileInput = ReviewSchema.CompareInput.extend({ file: SnapshotSchema.FilePath, version: z.string().min(1) });
|
|
28
|
+
ReviewSchema.Content = SnapshotSchema.FileVersions.extend({
|
|
29
|
+
diff: SnapshotSchema.FileDiff,
|
|
30
|
+
version: z.string(),
|
|
31
|
+
}).meta({ ref: "ReviewFileContent" });
|
|
32
|
+
})(ReviewSchema || (ReviewSchema = {}));
|
|
@@ -0,0 +1,79 @@
|
|
|
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.js";
|
|
10
|
+
export var ReviewState;
|
|
11
|
+
(function (ReviewState) {
|
|
12
|
+
ReviewState.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
|
+
ReviewState.Value = z
|
|
29
|
+
.object({
|
|
30
|
+
version: z.literal(1),
|
|
31
|
+
comments: z.array(ReviewState.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
|
+
ReviewState.Result = z
|
|
36
|
+
.object({ revision: z.number().int().nonnegative(), state: ReviewState.Value })
|
|
37
|
+
.meta({ ref: "ReviewStateResult" });
|
|
38
|
+
ReviewState.Input = z
|
|
39
|
+
.object({ sessionID: Identifier.schema("session"), revision: z.number().int().nonnegative(), state: ReviewState.Value })
|
|
40
|
+
.meta({ ref: "ReviewStateInput" });
|
|
41
|
+
ReviewState.Updated = BusEvent.define("review.state.updated", z.object({ sessionID: Identifier.schema("session"), revision: z.number().int().nonnegative() }));
|
|
42
|
+
async function key(sessionID) {
|
|
43
|
+
const session = await Session.get(sessionID);
|
|
44
|
+
if (session.scope.id !== ScopeContext.current.scope.id)
|
|
45
|
+
throw new ReviewSchema.Invalid({ message: "Review belongs to another scope." });
|
|
46
|
+
return StoragePath.sessionReview(session.scope.id, sessionID);
|
|
47
|
+
}
|
|
48
|
+
async function get(sessionID) {
|
|
49
|
+
const target = await key(sessionID);
|
|
50
|
+
try {
|
|
51
|
+
const record = await Storage.versioned(target);
|
|
52
|
+
return { revision: Number(record.revision), state: ReviewState.Value.parse(record.value) };
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
if (!(error instanceof Storage.NotFoundError))
|
|
56
|
+
throw error;
|
|
57
|
+
return { revision: 0, state: { version: 1, comments: [], viewed: {} } };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
ReviewState.get = get;
|
|
61
|
+
async function update(raw) {
|
|
62
|
+
const input = ReviewState.Input.parse(raw);
|
|
63
|
+
const target = await key(input.sessionID);
|
|
64
|
+
const result = await Storage.transaction(async (tx) => {
|
|
65
|
+
const record = await tx.versioned(target).catch((error) => {
|
|
66
|
+
if (!(error instanceof Storage.NotFoundError))
|
|
67
|
+
throw error;
|
|
68
|
+
return undefined;
|
|
69
|
+
});
|
|
70
|
+
if (Number(record?.revision ?? 0) !== input.revision)
|
|
71
|
+
throw new ReviewSchema.Conflict({ message: "Review notes changed. Reload before saving." });
|
|
72
|
+
await tx.write(target, input.state, { expectedRevision: BigInt(input.revision) });
|
|
73
|
+
return { revision: input.revision + 1, state: input.state };
|
|
74
|
+
});
|
|
75
|
+
await Bus.publish(ReviewState.Updated, { sessionID: input.sessionID, revision: result.revision });
|
|
76
|
+
return result;
|
|
77
|
+
}
|
|
78
|
+
ReviewState.update = update;
|
|
79
|
+
})(ReviewState || (ReviewState = {}));
|
|
@@ -0,0 +1,252 @@
|
|
|
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
|
+
export var Aggregator;
|
|
5
|
+
(function (Aggregator) {
|
|
6
|
+
const emptyTokenBreakdown = () => ({
|
|
7
|
+
input: 0,
|
|
8
|
+
output: 0,
|
|
9
|
+
reasoning: 0,
|
|
10
|
+
cache: { read: 0, write: 0 },
|
|
11
|
+
});
|
|
12
|
+
function addTokens(target, source) {
|
|
13
|
+
target.input += source.input;
|
|
14
|
+
target.output += source.output;
|
|
15
|
+
target.reasoning += source.reasoning;
|
|
16
|
+
target.cache.read += source.cache.read;
|
|
17
|
+
target.cache.write += source.cache.write;
|
|
18
|
+
}
|
|
19
|
+
async function digest(session) {
|
|
20
|
+
// Pass scopeID directly to avoid requireSession looking up session_index,
|
|
21
|
+
// which may be missing for legacy or reclaimed sessions.
|
|
22
|
+
const scopeID = session.scope?.id;
|
|
23
|
+
const messages = [];
|
|
24
|
+
for await (const msg of MessageV2.stream({
|
|
25
|
+
scopeID,
|
|
26
|
+
sessionID: session.id,
|
|
27
|
+
})) {
|
|
28
|
+
messages.push(msg);
|
|
29
|
+
}
|
|
30
|
+
const rollout = await RolloutSnapshot.read({ kind: "session", scopeID: session.scope.id, sessionID: session.id });
|
|
31
|
+
const recordedRoots = new Set(rollout.runs.map((run) => run.id));
|
|
32
|
+
const accounting = RolloutAccounting.summarize(rollout);
|
|
33
|
+
const tokens = emptyTokenBreakdown();
|
|
34
|
+
let cost = 0;
|
|
35
|
+
let turns = 0;
|
|
36
|
+
let messageCount = 0;
|
|
37
|
+
let errorCount = 0;
|
|
38
|
+
let compactionCount = 0;
|
|
39
|
+
let retryCount = 0;
|
|
40
|
+
const modelUsage = {};
|
|
41
|
+
const agentUsage = {};
|
|
42
|
+
const toolUsage = {};
|
|
43
|
+
const hourlyTurns = {};
|
|
44
|
+
function hourKey(timestamp) {
|
|
45
|
+
const d = new Date(timestamp);
|
|
46
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}T${String(d.getHours()).padStart(2, "0")}`;
|
|
47
|
+
}
|
|
48
|
+
for (const msg of MessageV2.deriveSemantics(messages.toSorted((a, b) => a.info.time.created - b.info.time.created))) {
|
|
49
|
+
messageCount++;
|
|
50
|
+
if (msg.info.role === "user" && msg.info.isRoot) {
|
|
51
|
+
turns++;
|
|
52
|
+
const key = hourKey(msg.info.time.created);
|
|
53
|
+
hourlyTurns[key] = (hourlyTurns[key] ?? 0) + 1;
|
|
54
|
+
}
|
|
55
|
+
if (msg.info.role === "assistant") {
|
|
56
|
+
const info = msg.info;
|
|
57
|
+
const legacy = info.accounting?.kind !== "inherited" &&
|
|
58
|
+
info.accounting?.kind !== "imported" &&
|
|
59
|
+
info.accounting?.kind !== "rollout" &&
|
|
60
|
+
!recordedRoots.has(info.rootID ?? info.parentID);
|
|
61
|
+
const messageTokens = legacy ? info.tokens : emptyTokenBreakdown();
|
|
62
|
+
const messageCost = legacy ? info.cost : 0;
|
|
63
|
+
if (legacy) {
|
|
64
|
+
accounting.legacy.cost += info.cost;
|
|
65
|
+
accounting.legacy.messages++;
|
|
66
|
+
}
|
|
67
|
+
addTokens(tokens, messageTokens);
|
|
68
|
+
cost += messageCost;
|
|
69
|
+
if (info.error !== undefined)
|
|
70
|
+
errorCount++;
|
|
71
|
+
const modelKey = `${info.providerID}/${info.modelID}`;
|
|
72
|
+
const modelEntry = modelUsage[modelKey] ?? {
|
|
73
|
+
messages: 0,
|
|
74
|
+
tokens: emptyTokenBreakdown(),
|
|
75
|
+
cost: 0,
|
|
76
|
+
totalResponseMs: 0,
|
|
77
|
+
};
|
|
78
|
+
modelEntry.messages++;
|
|
79
|
+
addTokens(modelEntry.tokens, messageTokens);
|
|
80
|
+
modelEntry.cost += messageCost;
|
|
81
|
+
if (legacy) {
|
|
82
|
+
modelEntry.accounting ??= RolloutAccounting.empty();
|
|
83
|
+
modelEntry.accounting.legacy.cost += messageCost;
|
|
84
|
+
modelEntry.accounting.legacy.messages++;
|
|
85
|
+
}
|
|
86
|
+
if (legacy && info.time.completed !== undefined) {
|
|
87
|
+
modelEntry.totalResponseMs += info.time.completed - info.time.created;
|
|
88
|
+
}
|
|
89
|
+
modelUsage[modelKey] = modelEntry;
|
|
90
|
+
const agentEntry = agentUsage[info.agent] ?? {
|
|
91
|
+
messages: 0,
|
|
92
|
+
tokens: emptyTokenBreakdown(),
|
|
93
|
+
cost: 0,
|
|
94
|
+
};
|
|
95
|
+
agentEntry.messages++;
|
|
96
|
+
addTokens(agentEntry.tokens, messageTokens);
|
|
97
|
+
agentEntry.cost += messageCost;
|
|
98
|
+
if (legacy) {
|
|
99
|
+
agentEntry.accounting ??= RolloutAccounting.empty();
|
|
100
|
+
agentEntry.accounting.legacy.cost += messageCost;
|
|
101
|
+
agentEntry.accounting.legacy.messages++;
|
|
102
|
+
}
|
|
103
|
+
agentUsage[info.agent] = agentEntry;
|
|
104
|
+
}
|
|
105
|
+
for (const part of msg.parts) {
|
|
106
|
+
if (part.type === "tool") {
|
|
107
|
+
const toolEntry = toolUsage[part.tool] ?? {
|
|
108
|
+
calls: 0,
|
|
109
|
+
successes: 0,
|
|
110
|
+
errors: 0,
|
|
111
|
+
totalDurationMs: 0,
|
|
112
|
+
timedSamples: 0,
|
|
113
|
+
};
|
|
114
|
+
toolEntry.calls++;
|
|
115
|
+
if (part.state.status === "completed") {
|
|
116
|
+
toolEntry.successes++;
|
|
117
|
+
if (part.state.time.start !== undefined && part.state.time.end !== undefined) {
|
|
118
|
+
toolEntry.totalDurationMs += part.state.time.end - part.state.time.start;
|
|
119
|
+
toolEntry.timedSamples = (toolEntry.timedSamples ?? 0) + 1;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (part.state.status === "error") {
|
|
123
|
+
toolEntry.errors++;
|
|
124
|
+
}
|
|
125
|
+
toolUsage[part.tool] = toolEntry;
|
|
126
|
+
}
|
|
127
|
+
if (part.type === "compaction")
|
|
128
|
+
compactionCount++;
|
|
129
|
+
if (part.type === "retry")
|
|
130
|
+
retryCount++;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const totals = { tokens, cost, modelUsage, agentUsage };
|
|
134
|
+
addCalls(rollout, totals);
|
|
135
|
+
cost = totals.cost;
|
|
136
|
+
const endpoint = session.endpoint
|
|
137
|
+
? {
|
|
138
|
+
kind: session.endpoint.kind,
|
|
139
|
+
type: session.endpoint.kind === "channel" ? session.endpoint.channel.type : undefined,
|
|
140
|
+
}
|
|
141
|
+
: undefined;
|
|
142
|
+
const interaction = session.interaction
|
|
143
|
+
? {
|
|
144
|
+
mode: session.interaction.mode,
|
|
145
|
+
source: session.interaction.source,
|
|
146
|
+
}
|
|
147
|
+
: undefined;
|
|
148
|
+
return {
|
|
149
|
+
sessionID: session.id,
|
|
150
|
+
scopeID: session.scope.id,
|
|
151
|
+
created: session.time.created,
|
|
152
|
+
updated: session.time.updated,
|
|
153
|
+
rolloutRevision: rollout.revision,
|
|
154
|
+
accounting,
|
|
155
|
+
archived: session.time.archived,
|
|
156
|
+
pinned: session.pinned !== undefined,
|
|
157
|
+
parentID: session.parentID,
|
|
158
|
+
endpoint,
|
|
159
|
+
interaction,
|
|
160
|
+
turns,
|
|
161
|
+
messages: messageCount,
|
|
162
|
+
tokens,
|
|
163
|
+
cost,
|
|
164
|
+
modelUsage,
|
|
165
|
+
agentUsage,
|
|
166
|
+
toolUsage,
|
|
167
|
+
hourlyTurns,
|
|
168
|
+
additions: session.summary?.additions ?? 0,
|
|
169
|
+
deletions: session.summary?.deletions ?? 0,
|
|
170
|
+
files: session.summary?.files ?? 0,
|
|
171
|
+
compactionCount,
|
|
172
|
+
retryCount,
|
|
173
|
+
errorCount,
|
|
174
|
+
durationMs: session.time.updated - session.time.created,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
Aggregator.digest = digest;
|
|
178
|
+
function addCalls(rollout, totals) {
|
|
179
|
+
const attemptsByCall = Map.groupBy(rollout.attempts, (attempt) => attempt.callID);
|
|
180
|
+
for (const call of rollout.calls) {
|
|
181
|
+
const entry = RolloutAccounting.summarize({
|
|
182
|
+
calls: [call],
|
|
183
|
+
attempts: attemptsByCall.get(call.id) ?? [],
|
|
184
|
+
gaps: [],
|
|
185
|
+
});
|
|
186
|
+
const usage = {
|
|
187
|
+
input: entry.tokens.uncached.known,
|
|
188
|
+
output: entry.tokens.output.known,
|
|
189
|
+
reasoning: entry.tokens.reasoning.known,
|
|
190
|
+
cache: { read: entry.tokens.cacheRead.known, write: entry.tokens.cacheWrite.known },
|
|
191
|
+
};
|
|
192
|
+
const knownCost = RolloutAccounting.knownExpense(entry);
|
|
193
|
+
addTokens(totals.tokens, usage);
|
|
194
|
+
totals.cost += knownCost;
|
|
195
|
+
const modelKey = `${call.model.providerID}/${call.model.modelID}`;
|
|
196
|
+
const modelEntry = totals.modelUsage[modelKey] ?? {
|
|
197
|
+
messages: 0,
|
|
198
|
+
tokens: emptyTokenBreakdown(),
|
|
199
|
+
cost: 0,
|
|
200
|
+
totalResponseMs: 0,
|
|
201
|
+
};
|
|
202
|
+
addTokens(modelEntry.tokens, usage);
|
|
203
|
+
modelEntry.cost += knownCost;
|
|
204
|
+
if (call.ended !== undefined)
|
|
205
|
+
modelEntry.totalResponseMs += call.ended - call.started;
|
|
206
|
+
modelEntry.accounting = RolloutAccounting.merge([modelEntry.accounting ?? RolloutAccounting.empty(), entry]);
|
|
207
|
+
totals.modelUsage[modelKey] = modelEntry;
|
|
208
|
+
const agentKey = call.agent ?? call.purpose;
|
|
209
|
+
const agentEntry = totals.agentUsage[agentKey] ?? { messages: 0, tokens: emptyTokenBreakdown(), cost: 0 };
|
|
210
|
+
addTokens(agentEntry.tokens, usage);
|
|
211
|
+
agentEntry.cost += knownCost;
|
|
212
|
+
agentEntry.accounting = RolloutAccounting.merge([agentEntry.accounting ?? RolloutAccounting.empty(), entry]);
|
|
213
|
+
totals.agentUsage[agentKey] = agentEntry;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
function operation(rollout) {
|
|
217
|
+
if (rollout.owner.kind !== "operation")
|
|
218
|
+
throw new Error("Expected independent operation");
|
|
219
|
+
const started = rollout.runs.map((run) => run.started);
|
|
220
|
+
const result = {
|
|
221
|
+
operationID: rollout.owner.operationID,
|
|
222
|
+
scopeID: rollout.owner.scopeID,
|
|
223
|
+
rolloutRevision: rollout.revision,
|
|
224
|
+
created: started.length ? Math.min(...started) : 0,
|
|
225
|
+
updated: Math.max(0, ...rollout.runs.map((run) => run.ended ?? run.started)),
|
|
226
|
+
turns: 0,
|
|
227
|
+
tokens: emptyTokenBreakdown(),
|
|
228
|
+
cost: 0,
|
|
229
|
+
modelUsage: {},
|
|
230
|
+
agentUsage: {},
|
|
231
|
+
accounting: RolloutAccounting.summarize(rollout),
|
|
232
|
+
};
|
|
233
|
+
addCalls(rollout, result);
|
|
234
|
+
return result;
|
|
235
|
+
}
|
|
236
|
+
Aggregator.operation = operation;
|
|
237
|
+
async function digestAll(sessions, onProgress) {
|
|
238
|
+
const results = [];
|
|
239
|
+
const batchSize = 20;
|
|
240
|
+
for (let i = 0; i < sessions.length; i += batchSize) {
|
|
241
|
+
const batch = sessions.slice(i, i + batchSize);
|
|
242
|
+
const batchResults = await Promise.all(batch.map((s) => digest(s)));
|
|
243
|
+
for (const r of batchResults) {
|
|
244
|
+
if (r)
|
|
245
|
+
results.push(r);
|
|
246
|
+
}
|
|
247
|
+
onProgress?.(Math.min(i + batchSize, sessions.length), sessions.length);
|
|
248
|
+
}
|
|
249
|
+
return results;
|
|
250
|
+
}
|
|
251
|
+
Aggregator.digestAll = digestAll;
|
|
252
|
+
})(Aggregator || (Aggregator = {}));
|