@smithers-orchestrator/cli 0.16.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/LICENSE +21 -0
- package/package.json +55 -0
- package/src/AgentAvailability.ts +13 -0
- package/src/AgentAvailabilityStatus.ts +5 -0
- package/src/AggregateNodeDetailParams.ts +5 -0
- package/src/AskOptions.ts +12 -0
- package/src/ChatAttemptMeta.ts +7 -0
- package/src/ChatAttemptRow.ts +12 -0
- package/src/ChatOutputEvent.ts +6 -0
- package/src/DiffBundleLike.ts +6 -0
- package/src/DiscoveredWorkflow.ts +9 -0
- package/src/EnrichedNodeDetail.ts +60 -0
- package/src/EventCategory.ts +18 -0
- package/src/FindDbWaitOptions.ts +4 -0
- package/src/FormatEventLineOptions.ts +4 -0
- package/src/HijackCandidate.ts +11 -0
- package/src/HijackLaunchSpec.ts +6 -0
- package/src/InitWorkflowPackOptions.ts +4 -0
- package/src/InitWorkflowPackResult.ts +6 -0
- package/src/NativeHijackEngine.ts +8 -0
- package/src/NodeDetailAttempt.ts +22 -0
- package/src/NodeDetailTokenUsage.ts +11 -0
- package/src/NodeDetailToolCall.ts +12 -0
- package/src/ParsedNodeOutputEvent.ts +9 -0
- package/src/RenderNodeDetailOptions.ts +4 -0
- package/src/RunAutoResumeSkipReason.ts +4 -0
- package/src/RunDiffCommandInput.ts +13 -0
- package/src/RunDiffCommandResult.ts +3 -0
- package/src/RunOutputCommandInput.ts +12 -0
- package/src/RunOutputCommandResult.ts +3 -0
- package/src/RunRewindCommandInput.ts +14 -0
- package/src/RunRewindCommandResult.ts +3 -0
- package/src/RunTreeCommandInput.ts +14 -0
- package/src/RunTreeCommandResult.ts +3 -0
- package/src/SmithersEventType.ts +3 -0
- package/src/SupervisorOptions.ts +33 -0
- package/src/SupervisorPollSummary.ts +6 -0
- package/src/TreeRenderOptions.ts +5 -0
- package/src/WatchLoopOptions.ts +9 -0
- package/src/WatchLoopResult.ts +8 -0
- package/src/WatchRenderContext.ts +4 -0
- package/src/WhyBlocker.ts +17 -0
- package/src/WhyBlockerKind.ts +9 -0
- package/src/WhyDiagnosis.ts +10 -0
- package/src/WorkflowCta.ts +4 -0
- package/src/WorkflowSourceType.ts +1 -0
- package/src/agent-detection.js +257 -0
- package/src/ask.js +491 -0
- package/src/chat.js +226 -0
- package/src/diff.js +221 -0
- package/src/event-categories.js +141 -0
- package/src/find-db.js +93 -0
- package/src/format.js +272 -0
- package/src/hijack-session.js +207 -0
- package/src/hijack.js +226 -0
- package/src/index.d.ts +1 -0
- package/src/index.js +4868 -0
- package/src/mcp/SemanticMcpServerOptions.ts +4 -0
- package/src/mcp/SemanticToolCallResult.ts +14 -0
- package/src/mcp/SemanticToolContext.ts +6 -0
- package/src/mcp/SemanticToolDefinition.ts +13 -0
- package/src/mcp/SemanticToolError.ts +6 -0
- package/src/mcp/semantic-server.js +41 -0
- package/src/mcp/semantic-tools.js +1242 -0
- package/src/node-detail.js +682 -0
- package/src/output.js +111 -0
- package/src/resume-detached.js +37 -0
- package/src/rewind.js +88 -0
- package/src/scheduler.js +112 -0
- package/src/smithersRuntime.js +63 -0
- package/src/supervisor.js +418 -0
- package/src/tree.js +307 -0
- package/src/tui/app.jsx +139 -0
- package/src/tui/app.tsx +5 -0
- package/src/tui/components/AskModal.jsx +109 -0
- package/src/tui/components/AskModal.tsx +3 -0
- package/src/tui/components/AttentionPane.jsx +112 -0
- package/src/tui/components/AttentionPane.tsx +6 -0
- package/src/tui/components/ChatPane.jsx +57 -0
- package/src/tui/components/ChatPane.tsx +7 -0
- package/src/tui/components/CronList.jsx +87 -0
- package/src/tui/components/CronList.tsx +5 -0
- package/src/tui/components/DetailsPane.jsx +96 -0
- package/src/tui/components/DetailsPane.tsx +7 -0
- package/src/tui/components/FramesPane.jsx +147 -0
- package/src/tui/components/FramesPane.tsx +8 -0
- package/src/tui/components/LogsPane.jsx +46 -0
- package/src/tui/components/LogsPane.tsx +6 -0
- package/src/tui/components/MetricsPane.jsx +108 -0
- package/src/tui/components/MetricsPane.tsx +5 -0
- package/src/tui/components/NodeDetailView.jsx +284 -0
- package/src/tui/components/NodeDetailView.tsx +7 -0
- package/src/tui/components/NodeInspector.jsx +51 -0
- package/src/tui/components/NodeInspector.tsx +7 -0
- package/src/tui/components/RunDetailView.jsx +190 -0
- package/src/tui/components/RunDetailView.tsx +7 -0
- package/src/tui/components/RunsList.jsx +184 -0
- package/src/tui/components/RunsList.tsx +7 -0
- package/src/tui/components/SqliteBrowser.jsx +131 -0
- package/src/tui/components/SqliteBrowser.tsx +5 -0
- package/src/tui/components/WorkflowLauncher.jsx +63 -0
- package/src/tui/components/WorkflowLauncher.tsx +3 -0
- package/src/util/CliErrorMapping.ts +7 -0
- package/src/util/CliExitCode.ts +10 -0
- package/src/util/errorMessage.js +212 -0
- package/src/util/exitCodes.js +18 -0
- package/src/watch.js +128 -0
- package/src/why-diagnosis.js +1000 -0
- package/src/workflow-pack.js +2151 -0
- package/src/workflows.js +122 -0
package/src/chat.js
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { formatTimestamp } from "./format.js";
|
|
2
|
+
|
|
3
|
+
/** @typedef {import("./ChatAttemptMeta.ts").ChatAttemptMeta} ChatAttemptMeta */
|
|
4
|
+
/** @typedef {import("./ChatAttemptRow.ts").ChatAttemptRow} ChatAttemptRow */
|
|
5
|
+
/** @typedef {import("./ChatOutputEvent.ts").ChatOutputEvent} ChatOutputEvent */
|
|
6
|
+
/** @typedef {import("./ParsedNodeOutputEvent.ts").ParsedNodeOutputEvent} ParsedNodeOutputEvent */
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {string | null} [metaJson]
|
|
10
|
+
* @returns {ChatAttemptMeta}
|
|
11
|
+
*/
|
|
12
|
+
export function parseChatAttemptMeta(metaJson) {
|
|
13
|
+
if (!metaJson)
|
|
14
|
+
return {};
|
|
15
|
+
try {
|
|
16
|
+
const parsed = JSON.parse(metaJson);
|
|
17
|
+
if (!parsed || typeof parsed !== "object")
|
|
18
|
+
return {};
|
|
19
|
+
return parsed;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return {};
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* @param {Pick<ChatAttemptRow, "nodeId" | "iteration" | "attempt">} attempt
|
|
27
|
+
*/
|
|
28
|
+
export function chatAttemptKey(attempt) {
|
|
29
|
+
return `${attempt.nodeId}:${attempt.iteration}:${attempt.attempt}`;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* @param {ChatOutputEvent} event
|
|
33
|
+
* @returns {ParsedNodeOutputEvent | null}
|
|
34
|
+
*/
|
|
35
|
+
export function parseNodeOutputEvent(event) {
|
|
36
|
+
if (event.type !== "NodeOutput")
|
|
37
|
+
return null;
|
|
38
|
+
try {
|
|
39
|
+
const payload = JSON.parse(event.payloadJson);
|
|
40
|
+
if (!payload || typeof payload !== "object")
|
|
41
|
+
return null;
|
|
42
|
+
const text = typeof payload.text === "string" ? payload.text : "";
|
|
43
|
+
const stream = payload.stream === "stderr" ? "stderr" : "stdout";
|
|
44
|
+
if (!text)
|
|
45
|
+
return null;
|
|
46
|
+
return {
|
|
47
|
+
seq: event.seq,
|
|
48
|
+
timestampMs: event.timestampMs,
|
|
49
|
+
nodeId: String(payload.nodeId ?? ""),
|
|
50
|
+
iteration: Number(payload.iteration ?? 0),
|
|
51
|
+
attempt: Number(payload.attempt ?? 1),
|
|
52
|
+
stream,
|
|
53
|
+
text,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* @param {ChatOutputEvent} event
|
|
62
|
+
* @returns {ParsedNodeOutputEvent | null}
|
|
63
|
+
*/
|
|
64
|
+
export function parseAgentEvent(event) {
|
|
65
|
+
if (event.type !== "AgentEvent")
|
|
66
|
+
return null;
|
|
67
|
+
try {
|
|
68
|
+
const payload = JSON.parse(event.payloadJson);
|
|
69
|
+
if (!payload || typeof payload !== "object")
|
|
70
|
+
return null;
|
|
71
|
+
const agentEvent = payload.event;
|
|
72
|
+
if (!agentEvent || typeof agentEvent !== "object")
|
|
73
|
+
return null;
|
|
74
|
+
const nodeId = String(payload.nodeId ?? "");
|
|
75
|
+
const iteration = Number(payload.iteration ?? 0);
|
|
76
|
+
const attempt = Number(payload.attempt ?? 1);
|
|
77
|
+
if (agentEvent.type === "action") {
|
|
78
|
+
const action = agentEvent.action;
|
|
79
|
+
const phase = agentEvent.phase ?? "";
|
|
80
|
+
const kind = action?.kind ?? "unknown";
|
|
81
|
+
const title = action?.title ?? "";
|
|
82
|
+
const message = agentEvent.message ?? "";
|
|
83
|
+
const detail = action?.detail ?? {};
|
|
84
|
+
let text = "";
|
|
85
|
+
if (kind === "tool" || kind === "command") {
|
|
86
|
+
if (phase === "started") {
|
|
87
|
+
const input = detail.input ? JSON.stringify(detail.input) : "";
|
|
88
|
+
text = `[tool] ${title}${input ? `: ${truncate(input, 200)}` : ""}`;
|
|
89
|
+
}
|
|
90
|
+
else if (phase === "completed") {
|
|
91
|
+
const output = detail.output ? String(detail.output) : message;
|
|
92
|
+
text = `[tool] ${title} → ${truncate(output || "done", 200)}`;
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
else if (kind === "file_change") {
|
|
99
|
+
const changes = detail.changes;
|
|
100
|
+
if (Array.isArray(changes)) {
|
|
101
|
+
text = `[file_change] ${changes.map((c) => `${c.type ?? "change"}: ${c.file ?? c.path ?? "?"}`).join(", ")}`;
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
text = `[file_change] ${title || message || "files changed"}`;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
else if (kind === "reasoning") {
|
|
108
|
+
if (!message)
|
|
109
|
+
return null;
|
|
110
|
+
text = `[reasoning] ${truncate(message, 300)}`;
|
|
111
|
+
}
|
|
112
|
+
else if (kind === "note" && agentEvent.entryType === "thought") {
|
|
113
|
+
if (!message)
|
|
114
|
+
return null;
|
|
115
|
+
text = `[thought] ${truncate(message, 300)}`;
|
|
116
|
+
}
|
|
117
|
+
else if (kind === "web_search") {
|
|
118
|
+
text = `[web_search] ${title || message || "searching"}`;
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
// Skip other action kinds (turn, todo_list, generic notes)
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
if (!text)
|
|
125
|
+
return null;
|
|
126
|
+
return {
|
|
127
|
+
seq: event.seq,
|
|
128
|
+
timestampMs: event.timestampMs,
|
|
129
|
+
nodeId,
|
|
130
|
+
iteration,
|
|
131
|
+
attempt,
|
|
132
|
+
stream: "stdout",
|
|
133
|
+
text,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* @param {string} str
|
|
144
|
+
* @param {number} max
|
|
145
|
+
*/
|
|
146
|
+
function truncate(str, max) {
|
|
147
|
+
if (str.length <= max) return str;
|
|
148
|
+
return str.slice(0, max) + "…";
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* @param {ChatAttemptRow} attempt
|
|
152
|
+
* @param {ReadonlySet<string>} outputAttemptKeys
|
|
153
|
+
* @returns {boolean}
|
|
154
|
+
*/
|
|
155
|
+
export function isAgentAttempt(attempt, outputAttemptKeys) {
|
|
156
|
+
const meta = parseChatAttemptMeta(attempt.metaJson);
|
|
157
|
+
if (meta.kind === "agent")
|
|
158
|
+
return true;
|
|
159
|
+
if (attempt.responseText?.trim())
|
|
160
|
+
return true;
|
|
161
|
+
return outputAttemptKeys.has(chatAttemptKey(attempt));
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* @param {ChatAttemptRow[]} attempts
|
|
165
|
+
* @param {ReadonlySet<string>} outputAttemptKeys
|
|
166
|
+
* @param {boolean} includeAll
|
|
167
|
+
* @returns {ChatAttemptRow[]}
|
|
168
|
+
*/
|
|
169
|
+
export function selectChatAttempts(attempts, outputAttemptKeys, includeAll) {
|
|
170
|
+
const agentAttempts = attempts
|
|
171
|
+
.filter((attempt) => isAgentAttempt(attempt, outputAttemptKeys))
|
|
172
|
+
.sort((a, b) => {
|
|
173
|
+
if (a.startedAtMs !== b.startedAtMs)
|
|
174
|
+
return a.startedAtMs - b.startedAtMs;
|
|
175
|
+
if (a.nodeId !== b.nodeId)
|
|
176
|
+
return a.nodeId.localeCompare(b.nodeId);
|
|
177
|
+
if (a.iteration !== b.iteration)
|
|
178
|
+
return a.iteration - b.iteration;
|
|
179
|
+
return a.attempt - b.attempt;
|
|
180
|
+
});
|
|
181
|
+
if (includeAll)
|
|
182
|
+
return agentAttempts;
|
|
183
|
+
const latest = agentAttempts[agentAttempts.length - 1];
|
|
184
|
+
return latest ? [latest] : [];
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* @param {ChatAttemptRow} attempt
|
|
188
|
+
* @returns {string}
|
|
189
|
+
*/
|
|
190
|
+
export function formatChatAttemptHeader(attempt) {
|
|
191
|
+
const meta = parseChatAttemptMeta(attempt.metaJson);
|
|
192
|
+
const title = meta.label?.trim() || attempt.nodeId;
|
|
193
|
+
const agentBits = [meta.agentId, meta.agentModel].filter(Boolean).join(" · ");
|
|
194
|
+
const parts = [
|
|
195
|
+
title,
|
|
196
|
+
`attempt ${attempt.attempt}`,
|
|
197
|
+
attempt.iteration > 0 ? `iteration ${attempt.iteration}` : null,
|
|
198
|
+
attempt.state,
|
|
199
|
+
agentBits || null,
|
|
200
|
+
].filter(Boolean);
|
|
201
|
+
return `=== ${parts.join(" · ")} ===`;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* @param {{ baseMs: number; timestampMs: number; role: "user" | "assistant" | "stderr"; attempt: Pick<ChatAttemptRow, "nodeId" | "iteration" | "attempt">; text: string; }} options
|
|
205
|
+
* @returns {string}
|
|
206
|
+
*/
|
|
207
|
+
export function formatChatBlock(options) {
|
|
208
|
+
const { baseMs, timestampMs, role, attempt, text } = options;
|
|
209
|
+
const ts = formatTimestamp(baseMs, timestampMs);
|
|
210
|
+
const ref = `${attempt.nodeId}#${attempt.attempt}${attempt.iteration > 0 ? `.${attempt.iteration}` : ""}`;
|
|
211
|
+
const body = text.replace(/\s+$/, "");
|
|
212
|
+
const prefix = `[${ts}] ${role} ${ref}`;
|
|
213
|
+
if (!body.includes("\n")) {
|
|
214
|
+
return `${prefix}: ${body}`;
|
|
215
|
+
}
|
|
216
|
+
return `${prefix}:\n${indentBlock(body)}`;
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* @param {string} text
|
|
220
|
+
*/
|
|
221
|
+
function indentBlock(text) {
|
|
222
|
+
return text
|
|
223
|
+
.split(/\r?\n/)
|
|
224
|
+
.map((line) => ` ${line}`)
|
|
225
|
+
.join("\n");
|
|
226
|
+
}
|
package/src/diff.js
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// @smithers-type-exports-begin
|
|
2
|
+
/** @typedef {import("./DiffBundleLike.ts").DiffBundleLike} DiffBundleLike */
|
|
3
|
+
/** @typedef {import("./RunDiffCommandInput.ts").RunDiffCommandInput} RunDiffCommandInput */
|
|
4
|
+
/** @typedef {import("./RunDiffCommandResult.ts").RunDiffCommandResult} RunDiffCommandResult */
|
|
5
|
+
// @smithers-type-exports-end
|
|
6
|
+
|
|
7
|
+
import pc from "picocolors";
|
|
8
|
+
import { getNodeDiffRoute } from "@smithers-orchestrator/server/gatewayRoutes/getNodeDiff";
|
|
9
|
+
import { EXIT_OK, EXIT_SERVER_ERROR } from "./util/exitCodes.js";
|
|
10
|
+
import { formatCliErrorForStderr, getCliErrorMapping } from "./util/errorMessage.js";
|
|
11
|
+
|
|
12
|
+
/** @param {boolean} color */
|
|
13
|
+
function colors(color) {
|
|
14
|
+
return color ? pc.createColors(true) : pc.createColors(false);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// ANSI CSI sequences: `ESC [ ... letter`. Strip covers colors (m), cursor
|
|
18
|
+
// moves, etc. Only used when the caller disables color so that existing
|
|
19
|
+
// escapes from upstream (jj, git) do not leak into non-TTY pipes.
|
|
20
|
+
const ANSI_ESCAPE_REGEX = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {string} value
|
|
24
|
+
* @returns {string}
|
|
25
|
+
*/
|
|
26
|
+
function stripAnsi(value) {
|
|
27
|
+
return value.replace(ANSI_ESCAPE_REGEX, "");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {string} diffText
|
|
32
|
+
* @param {boolean} useColor
|
|
33
|
+
* @returns {string}
|
|
34
|
+
*/
|
|
35
|
+
function colorizePatch(diffText, useColor) {
|
|
36
|
+
// Finding #6: when color is disabled (explicit --color never or non-TTY),
|
|
37
|
+
// also strip any ANSI escapes the upstream VCS layer may have embedded
|
|
38
|
+
// in patch text. Without this, piping to `less` or capturing to a file
|
|
39
|
+
// leaks raw CSI codes. Color is only added here when useColor is true.
|
|
40
|
+
if (!useColor) return stripAnsi(diffText);
|
|
41
|
+
const c = colors(true);
|
|
42
|
+
const lines = diffText.split("\n");
|
|
43
|
+
const out = [];
|
|
44
|
+
for (const line of lines) {
|
|
45
|
+
if (line.startsWith("+++") || line.startsWith("---")) {
|
|
46
|
+
out.push(c.bold(line));
|
|
47
|
+
} else if (line.startsWith("+")) {
|
|
48
|
+
out.push(c.green(line));
|
|
49
|
+
} else if (line.startsWith("-")) {
|
|
50
|
+
out.push(c.red(line));
|
|
51
|
+
} else if (line.startsWith("@@")) {
|
|
52
|
+
out.push(c.cyan(line));
|
|
53
|
+
} else if (line.startsWith("diff ") || line.startsWith("index ")) {
|
|
54
|
+
out.push(c.dim(line));
|
|
55
|
+
} else {
|
|
56
|
+
out.push(line);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return out.join("\n");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @param {DiffBundleLike} bundle
|
|
64
|
+
* @param {{ color?: boolean }} [options]
|
|
65
|
+
* @returns {string}
|
|
66
|
+
*/
|
|
67
|
+
export function renderUnifiedDiff(bundle, options) {
|
|
68
|
+
const useColor = Boolean(options?.color);
|
|
69
|
+
if (!bundle || !Array.isArray(bundle.patches) || bundle.patches.length === 0) {
|
|
70
|
+
return "(no changes)";
|
|
71
|
+
}
|
|
72
|
+
return bundle.patches
|
|
73
|
+
.map((patch) => colorizePatch(String(patch.diff ?? ""), useColor))
|
|
74
|
+
.join("\n");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Streaming per-line count of +/- markers in a unified diff. Counts
|
|
79
|
+
* content lines only (skips "+++ "/"--- " file headers). Scans via
|
|
80
|
+
* indexOf to avoid a giant split() allocation on large diffs.
|
|
81
|
+
*
|
|
82
|
+
* @param {string} diffText
|
|
83
|
+
* @returns {{ added: number; removed: number }}
|
|
84
|
+
*/
|
|
85
|
+
function countDiffLines(diffText) {
|
|
86
|
+
let added = 0;
|
|
87
|
+
let removed = 0;
|
|
88
|
+
let cursor = 0;
|
|
89
|
+
while (cursor < diffText.length) {
|
|
90
|
+
const nl = diffText.indexOf("\n", cursor);
|
|
91
|
+
const end = nl === -1 ? diffText.length : nl;
|
|
92
|
+
const ch = diffText.charCodeAt(cursor);
|
|
93
|
+
if (ch === 43 /* + */ && !(diffText.charCodeAt(cursor + 1) === 43 && diffText.charCodeAt(cursor + 2) === 43)) {
|
|
94
|
+
added++;
|
|
95
|
+
}
|
|
96
|
+
else if (ch === 45 /* - */ && !(diffText.charCodeAt(cursor + 1) === 45 && diffText.charCodeAt(cursor + 2) === 45)) {
|
|
97
|
+
removed++;
|
|
98
|
+
}
|
|
99
|
+
cursor = end + 1;
|
|
100
|
+
}
|
|
101
|
+
return { added, removed };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Render a stat summary. Accepts either:
|
|
106
|
+
* - a server-side `summary` response ({ filesChanged, added, removed, files })
|
|
107
|
+
* - or a legacy `DiffBundle` (computes summary on the fly, streaming
|
|
108
|
+
* through patches so no intermediate array of full patch text is held).
|
|
109
|
+
*
|
|
110
|
+
* @param {DiffBundleLike | { summary: { filesChanged: number; added: number; removed: number; files: Array<{ path: string; added: number; removed: number }> } }} input
|
|
111
|
+
* @returns {string}
|
|
112
|
+
*/
|
|
113
|
+
export function renderDiffStat(input) {
|
|
114
|
+
const summary = summaryFromInput(input);
|
|
115
|
+
if (summary.filesChanged === 0) {
|
|
116
|
+
return " 0 files changed";
|
|
117
|
+
}
|
|
118
|
+
/** @type {string[]} */
|
|
119
|
+
const lines = [];
|
|
120
|
+
for (const file of summary.files) {
|
|
121
|
+
const added = file.added;
|
|
122
|
+
const removed = file.removed;
|
|
123
|
+
lines.push(` ${file.path} | ${added + removed} ${"+".repeat(Math.min(added, 20))}${"-".repeat(Math.min(removed, 20))}`);
|
|
124
|
+
}
|
|
125
|
+
lines.push(` ${summary.filesChanged} file${summary.filesChanged === 1 ? "" : "s"} changed, ${summary.added} insertion${summary.added === 1 ? "" : "s"}(+), ${summary.removed} deletion${summary.removed === 1 ? "" : "s"}(-)`);
|
|
126
|
+
return lines.join("\n");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* @param {any} input
|
|
131
|
+
* @returns {{ filesChanged: number; added: number; removed: number; files: Array<{ path: string; added: number; removed: number }> }}
|
|
132
|
+
*/
|
|
133
|
+
function summaryFromInput(input) {
|
|
134
|
+
if (input && input.summary && typeof input.summary === "object") {
|
|
135
|
+
const s = input.summary;
|
|
136
|
+
return {
|
|
137
|
+
filesChanged: Number(s.filesChanged ?? 0),
|
|
138
|
+
added: Number(s.added ?? 0),
|
|
139
|
+
removed: Number(s.removed ?? 0),
|
|
140
|
+
files: Array.isArray(s.files) ? s.files : [],
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
if (!input || !Array.isArray(input.patches) || input.patches.length === 0) {
|
|
144
|
+
return { filesChanged: 0, added: 0, removed: 0, files: [] };
|
|
145
|
+
}
|
|
146
|
+
const files = [];
|
|
147
|
+
let totalAdded = 0;
|
|
148
|
+
let totalRemoved = 0;
|
|
149
|
+
for (const patch of input.patches) {
|
|
150
|
+
const { added, removed } = countDiffLines(String(patch.diff ?? ""));
|
|
151
|
+
totalAdded += added;
|
|
152
|
+
totalRemoved += removed;
|
|
153
|
+
files.push({ path: String(patch.path ?? ""), added, removed });
|
|
154
|
+
}
|
|
155
|
+
return { filesChanged: files.length, added: totalAdded, removed: totalRemoved, files };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* @param {SmithersDb} adapter
|
|
160
|
+
* @param {string} runId
|
|
161
|
+
* @param {string} nodeId
|
|
162
|
+
* @returns {Promise<number | null>}
|
|
163
|
+
*/
|
|
164
|
+
async function resolveLatestIteration(adapter, runId, nodeId) {
|
|
165
|
+
try {
|
|
166
|
+
const iterations = await adapter.listNodeIterations(runId, nodeId);
|
|
167
|
+
if (!Array.isArray(iterations) || iterations.length === 0) return null;
|
|
168
|
+
return iterations.reduce((max, row) => {
|
|
169
|
+
const it = typeof row?.iteration === "number" ? row.iteration : 0;
|
|
170
|
+
return it > max ? it : max;
|
|
171
|
+
}, 0);
|
|
172
|
+
} catch {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* @param {RunDiffCommandInput} input
|
|
179
|
+
* @returns {Promise<RunDiffCommandResult>}
|
|
180
|
+
*/
|
|
181
|
+
export async function runDiffOnce(input) {
|
|
182
|
+
let iteration = input.iteration;
|
|
183
|
+
if (typeof iteration !== "number") {
|
|
184
|
+
const latest = await resolveLatestIteration(input.adapter, input.runId, input.nodeId);
|
|
185
|
+
iteration = latest ?? 0;
|
|
186
|
+
}
|
|
187
|
+
const result = await getNodeDiffRoute({
|
|
188
|
+
runId: input.runId,
|
|
189
|
+
nodeId: input.nodeId,
|
|
190
|
+
iteration,
|
|
191
|
+
async resolveRun(runId) {
|
|
192
|
+
if (runId !== input.runId) return null;
|
|
193
|
+
const run = await input.adapter.getRun(runId);
|
|
194
|
+
if (!run) return null;
|
|
195
|
+
return { adapter: input.adapter };
|
|
196
|
+
},
|
|
197
|
+
// Finding #5: stat mode asks the route for a summary only so very
|
|
198
|
+
// large diffs (>50MB) still return without hitting DiffTooLarge.
|
|
199
|
+
...(input.stat ? { stat: true } : undefined),
|
|
200
|
+
});
|
|
201
|
+
if (!result.ok) {
|
|
202
|
+
input.stderr.write(`${formatCliErrorForStderr(result.error.code, result.error.message)}\n`);
|
|
203
|
+
const mapping = getCliErrorMapping(result.error.code, result.error.message);
|
|
204
|
+
return { exitCode: mapping.exitCode };
|
|
205
|
+
}
|
|
206
|
+
const payload = result.payload;
|
|
207
|
+
if (input.json) {
|
|
208
|
+
// Ticket 0014: --json emits the raw bundle (or summary when stat).
|
|
209
|
+
input.stdout.write(`${JSON.stringify(payload)}\n`);
|
|
210
|
+
return { exitCode: EXIT_OK };
|
|
211
|
+
}
|
|
212
|
+
if (input.stat) {
|
|
213
|
+
input.stdout.write(`${renderDiffStat(payload)}\n`);
|
|
214
|
+
return { exitCode: EXIT_OK };
|
|
215
|
+
}
|
|
216
|
+
input.stdout.write(`${renderUnifiedDiff(payload, { color: input.color })}\n`);
|
|
217
|
+
return { exitCode: EXIT_OK };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ensure EXIT_SERVER_ERROR is considered used by linters / tree-shakers.
|
|
221
|
+
void EXIT_SERVER_ERROR;
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
|
|
2
|
+
/** @typedef {import("./EventCategory.ts").EventCategory} EventCategory */
|
|
3
|
+
/** @typedef {import("./SmithersEventType.ts").SmithersEventType} SmithersEventType */
|
|
4
|
+
const EVENT_CATEGORY_BY_TYPE = {
|
|
5
|
+
SupervisorStarted: "supervisor",
|
|
6
|
+
SupervisorPollCompleted: "supervisor",
|
|
7
|
+
RunAutoResumed: "run",
|
|
8
|
+
RunAutoResumeSkipped: "run",
|
|
9
|
+
RunStarted: "run",
|
|
10
|
+
RunStatusChanged: "run",
|
|
11
|
+
RunFinished: "run",
|
|
12
|
+
RunFailed: "run",
|
|
13
|
+
RunCancelled: "run",
|
|
14
|
+
RunContinuedAsNew: "run",
|
|
15
|
+
RunHijackRequested: "run",
|
|
16
|
+
RunHijacked: "run",
|
|
17
|
+
SandboxCreated: "sandbox",
|
|
18
|
+
SandboxShipped: "sandbox",
|
|
19
|
+
SandboxHeartbeat: "sandbox",
|
|
20
|
+
SandboxBundleReceived: "sandbox",
|
|
21
|
+
SandboxCompleted: "sandbox",
|
|
22
|
+
SandboxFailed: "sandbox",
|
|
23
|
+
SandboxDiffReviewRequested: "sandbox",
|
|
24
|
+
SandboxDiffAccepted: "sandbox",
|
|
25
|
+
SandboxDiffRejected: "sandbox",
|
|
26
|
+
FrameCommitted: "frame",
|
|
27
|
+
NodePending: "node",
|
|
28
|
+
NodeStarted: "node",
|
|
29
|
+
TaskHeartbeat: "node",
|
|
30
|
+
TaskHeartbeatTimeout: "node",
|
|
31
|
+
NodeFinished: "node",
|
|
32
|
+
NodeFailed: "node",
|
|
33
|
+
NodeCancelled: "node",
|
|
34
|
+
NodeSkipped: "node",
|
|
35
|
+
NodeRetrying: "node",
|
|
36
|
+
NodeWaitingApproval: "node",
|
|
37
|
+
NodeWaitingTimer: "node",
|
|
38
|
+
ApprovalRequested: "approval",
|
|
39
|
+
ApprovalGranted: "approval",
|
|
40
|
+
ApprovalAutoApproved: "approval",
|
|
41
|
+
ApprovalDenied: "approval",
|
|
42
|
+
ToolCallStarted: "tool-call",
|
|
43
|
+
ToolCallFinished: "tool-call",
|
|
44
|
+
NodeOutput: "output",
|
|
45
|
+
AgentEvent: "agent",
|
|
46
|
+
RetryTaskStarted: "run",
|
|
47
|
+
RetryTaskFinished: "run",
|
|
48
|
+
RevertStarted: "revert",
|
|
49
|
+
RevertFinished: "revert",
|
|
50
|
+
TimeTravelStarted: "revert",
|
|
51
|
+
TimeTravelFinished: "revert",
|
|
52
|
+
WorkflowReloadDetected: "workflow",
|
|
53
|
+
WorkflowReloaded: "workflow",
|
|
54
|
+
WorkflowReloadFailed: "workflow",
|
|
55
|
+
WorkflowReloadUnsafe: "workflow",
|
|
56
|
+
ScorerStarted: "scorer",
|
|
57
|
+
ScorerFinished: "scorer",
|
|
58
|
+
ScorerFailed: "scorer",
|
|
59
|
+
TokenUsageReported: "token",
|
|
60
|
+
SnapshotCaptured: "snapshot",
|
|
61
|
+
RunForked: "run",
|
|
62
|
+
ReplayStarted: "run",
|
|
63
|
+
MemoryFactSet: "memory",
|
|
64
|
+
MemoryRecalled: "memory",
|
|
65
|
+
MemoryMessageSaved: "memory",
|
|
66
|
+
OpenApiToolCalled: "openapi",
|
|
67
|
+
TimerCreated: "timer",
|
|
68
|
+
TimerFired: "timer",
|
|
69
|
+
TimerCancelled: "timer",
|
|
70
|
+
};
|
|
71
|
+
const CATEGORY_ALIASES = {
|
|
72
|
+
agent: "agent",
|
|
73
|
+
approval: "approval",
|
|
74
|
+
approvals: "approval",
|
|
75
|
+
frame: "frame",
|
|
76
|
+
memory: "memory",
|
|
77
|
+
node: "node",
|
|
78
|
+
openapi: "openapi",
|
|
79
|
+
output: "output",
|
|
80
|
+
revert: "revert",
|
|
81
|
+
run: "run",
|
|
82
|
+
sandbox: "sandbox",
|
|
83
|
+
scorer: "scorer",
|
|
84
|
+
snapshot: "snapshot",
|
|
85
|
+
supervisor: "supervisor",
|
|
86
|
+
timer: "timer",
|
|
87
|
+
token: "token",
|
|
88
|
+
tool: "tool-call",
|
|
89
|
+
toolcall: "tool-call",
|
|
90
|
+
"tool-call": "tool-call",
|
|
91
|
+
"tool_call": "tool-call",
|
|
92
|
+
workflow: "workflow",
|
|
93
|
+
reload: "workflow",
|
|
94
|
+
};
|
|
95
|
+
const EVENT_TYPES_BY_CATEGORY = Object.entries(EVENT_CATEGORY_BY_TYPE).reduce((acc, [type, category]) => {
|
|
96
|
+
if (!acc[category])
|
|
97
|
+
acc[category] = [];
|
|
98
|
+
acc[category].push(type);
|
|
99
|
+
return acc;
|
|
100
|
+
}, {
|
|
101
|
+
agent: [],
|
|
102
|
+
approval: [],
|
|
103
|
+
frame: [],
|
|
104
|
+
memory: [],
|
|
105
|
+
node: [],
|
|
106
|
+
openapi: [],
|
|
107
|
+
output: [],
|
|
108
|
+
revert: [],
|
|
109
|
+
run: [],
|
|
110
|
+
sandbox: [],
|
|
111
|
+
scorer: [],
|
|
112
|
+
snapshot: [],
|
|
113
|
+
supervisor: [],
|
|
114
|
+
timer: [],
|
|
115
|
+
token: [],
|
|
116
|
+
"tool-call": [],
|
|
117
|
+
workflow: [],
|
|
118
|
+
});
|
|
119
|
+
export const EVENT_CATEGORY_VALUES = Object.keys(EVENT_TYPES_BY_CATEGORY);
|
|
120
|
+
/**
|
|
121
|
+
* @param {string} raw
|
|
122
|
+
* @returns {EventCategory | null}
|
|
123
|
+
*/
|
|
124
|
+
export function normalizeEventCategory(raw) {
|
|
125
|
+
const key = raw.trim().toLowerCase();
|
|
126
|
+
return CATEGORY_ALIASES[key] ?? null;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* @param {string} type
|
|
130
|
+
* @returns {EventCategory | null}
|
|
131
|
+
*/
|
|
132
|
+
export function eventCategoryForType(type) {
|
|
133
|
+
return EVENT_CATEGORY_BY_TYPE[type] ?? null;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* @param {EventCategory} category
|
|
137
|
+
* @returns {readonly SmithersEventType[]}
|
|
138
|
+
*/
|
|
139
|
+
export function eventTypesForCategory(category) {
|
|
140
|
+
return EVENT_TYPES_BY_CATEGORY[category];
|
|
141
|
+
}
|
package/src/find-db.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { resolve, dirname } from "node:path";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { SmithersDb } from "@smithers-orchestrator/db/adapter";
|
|
4
|
+
import { ensureSmithersTables } from "@smithers-orchestrator/db/ensure";
|
|
5
|
+
import { SmithersError } from "@smithers-orchestrator/errors";
|
|
6
|
+
/** @typedef {import("./FindDbWaitOptions.ts").FindDbWaitOptions} FindDbWaitOptions */
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Walk from `from` (default: cwd) upward looking for smithers.db.
|
|
10
|
+
* Returns the absolute path to the database file.
|
|
11
|
+
*
|
|
12
|
+
* @param {string} [from]
|
|
13
|
+
* @returns {string}
|
|
14
|
+
*/
|
|
15
|
+
export function findSmithersDb(from) {
|
|
16
|
+
let dir = resolve(from ?? process.cwd());
|
|
17
|
+
const root = resolve("/");
|
|
18
|
+
while (true) {
|
|
19
|
+
const candidate = resolve(dir, "smithers.db");
|
|
20
|
+
if (existsSync(candidate))
|
|
21
|
+
return candidate;
|
|
22
|
+
const parent = dirname(dir);
|
|
23
|
+
if (parent === dir || dir === root) {
|
|
24
|
+
throw new SmithersError("CLI_DB_NOT_FOUND", "No smithers.db found. Run this command from a directory containing a smithers.db, or use 'smithers up <workflow>' to start a run first.");
|
|
25
|
+
}
|
|
26
|
+
dir = parent;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* @param {number} ms
|
|
31
|
+
*/
|
|
32
|
+
function sleep(ms) {
|
|
33
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* @param {string} [from]
|
|
37
|
+
* @param {FindDbWaitOptions} [opts]
|
|
38
|
+
* @returns {Promise<string>}
|
|
39
|
+
*/
|
|
40
|
+
export async function waitForSmithersDb(from, opts = {}) {
|
|
41
|
+
const timeoutMs = Math.max(0, opts.timeoutMs ?? 0);
|
|
42
|
+
const intervalMs = Math.max(1, opts.intervalMs ?? 100);
|
|
43
|
+
const startedAt = Date.now();
|
|
44
|
+
while (true) {
|
|
45
|
+
try {
|
|
46
|
+
return findSmithersDb(from);
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
if (!(err instanceof SmithersError) || err.code !== "CLI_DB_NOT_FOUND") {
|
|
50
|
+
throw err;
|
|
51
|
+
}
|
|
52
|
+
const elapsedMs = Date.now() - startedAt;
|
|
53
|
+
if (elapsedMs >= timeoutMs) {
|
|
54
|
+
throw err;
|
|
55
|
+
}
|
|
56
|
+
await sleep(Math.min(intervalMs, timeoutMs - elapsedMs));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Open a smithers.db file and return a SmithersDb adapter with cleanup function.
|
|
62
|
+
*
|
|
63
|
+
* @param {string} dbPath
|
|
64
|
+
* @returns {Promise<{ adapter: SmithersDb; cleanup: () => void }>}
|
|
65
|
+
*/
|
|
66
|
+
export async function openSmithersDb(dbPath) {
|
|
67
|
+
const { Database } = await import("bun:sqlite");
|
|
68
|
+
const { drizzle } = await import("drizzle-orm/bun-sqlite");
|
|
69
|
+
const sqlite = new Database(dbPath);
|
|
70
|
+
const db = drizzle(sqlite);
|
|
71
|
+
ensureSmithersTables(db);
|
|
72
|
+
return {
|
|
73
|
+
adapter: new SmithersDb(db),
|
|
74
|
+
cleanup: () => {
|
|
75
|
+
try {
|
|
76
|
+
sqlite.close();
|
|
77
|
+
}
|
|
78
|
+
catch { }
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Find and open the nearest smithers.db.
|
|
84
|
+
*
|
|
85
|
+
* @param {string} [from]
|
|
86
|
+
* @param {FindDbWaitOptions} [opts]
|
|
87
|
+
* @returns {Promise<{ adapter: SmithersDb; dbPath: string; cleanup: () => void }>}
|
|
88
|
+
*/
|
|
89
|
+
export async function findAndOpenDb(from, opts) {
|
|
90
|
+
const dbPath = await waitForSmithersDb(from, opts);
|
|
91
|
+
const { adapter, cleanup } = await openSmithersDb(dbPath);
|
|
92
|
+
return { adapter, dbPath, cleanup };
|
|
93
|
+
}
|