@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/index.js
ADDED
|
@@ -0,0 +1,4868 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { resolve, dirname, basename } from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { readFileSync, existsSync, openSync } from "node:fs";
|
|
5
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
|
+
import { Effect, Fiber } from "effect";
|
|
7
|
+
import { Cli, Mcp as IncurMcp, z } from "incur";
|
|
8
|
+
import { isRunHeartbeatFresh, runWorkflow, renderFrame, resolveSchema } from "@smithers-orchestrator/engine";
|
|
9
|
+
import { mdxPlugin } from "smithers-orchestrator/mdx-plugin";
|
|
10
|
+
import { approveNode, denyNode } from "@smithers-orchestrator/engine/approvals";
|
|
11
|
+
import { signalRun } from "@smithers-orchestrator/engine/signals";
|
|
12
|
+
import { loadInput, loadOutputs } from "@smithers-orchestrator/db/snapshot";
|
|
13
|
+
import { ensureSmithersTables } from "@smithers-orchestrator/db/ensure";
|
|
14
|
+
import { SmithersDb } from "@smithers-orchestrator/db/adapter";
|
|
15
|
+
import { computeRunStateFromRow } from "@smithers-orchestrator/db/runState";
|
|
16
|
+
import { SmithersCtx } from "@smithers-orchestrator/driver";
|
|
17
|
+
import { toSmithersError } from "@smithers-orchestrator/errors/toSmithersError";
|
|
18
|
+
import { runFork, runPromise } from "./smithersRuntime.js";
|
|
19
|
+
import { trackEvent } from "@smithers-orchestrator/observability/metrics";
|
|
20
|
+
import { revertToAttempt } from "@smithers-orchestrator/time-travel/revert";
|
|
21
|
+
import { retryTask } from "@smithers-orchestrator/time-travel/retry-task";
|
|
22
|
+
import { timeTravel } from "@smithers-orchestrator/time-travel/timetravel";
|
|
23
|
+
import { runSync } from "./smithersRuntime.js";
|
|
24
|
+
import { spawn } from "node:child_process";
|
|
25
|
+
import { isHumanRequestPastTimeout, validateHumanRequestValue } from "@smithers-orchestrator/engine/human-requests";
|
|
26
|
+
import { SmithersError } from "@smithers-orchestrator/errors";
|
|
27
|
+
import { assertMaxBytes, assertMaxStringLength } from "@smithers-orchestrator/db/input-bounds";
|
|
28
|
+
import { findAndOpenDb } from "./find-db.js";
|
|
29
|
+
import { chatAttemptKey, formatChatAttemptHeader, formatChatBlock, parseAgentEvent, parseChatAttemptMeta, parseNodeOutputEvent, selectChatAttempts, } from "./chat.js";
|
|
30
|
+
import { buildHijackLaunchSpec, isNativeHijackCandidate, launchHijackSession, resolveHijackCandidate, waitForHijackCandidate, } from "./hijack.js";
|
|
31
|
+
import { launchConversationHijackSession, persistConversationHijackHandoff, } from "./hijack-session.js";
|
|
32
|
+
import { colorizeEventText, formatAge, formatElapsedCompact, formatEventLine, formatRelativeOffset, } from "./format.js";
|
|
33
|
+
import { EVENT_CATEGORY_VALUES, eventTypesForCategory, normalizeEventCategory, } from "./event-categories.js";
|
|
34
|
+
import { aggregateNodeDetailEffect, renderNodeDetailHuman, } from "./node-detail.js";
|
|
35
|
+
import { diagnoseRunEffect, diagnosisCtaCommands, renderWhyDiagnosisHuman, } from "./why-diagnosis.js";
|
|
36
|
+
import { detectAvailableAgents } from "./agent-detection.js";
|
|
37
|
+
import { initWorkflowPack, getWorkflowFollowUpCtas } from "./workflow-pack.js";
|
|
38
|
+
import { discoverWorkflows, resolveWorkflow, createWorkflowFile } from "./workflows.js";
|
|
39
|
+
import { ask } from "./ask.js";
|
|
40
|
+
import { runScheduler } from "./scheduler.js";
|
|
41
|
+
import { resumeRunDetached } from "./resume-detached.js";
|
|
42
|
+
import { formatCliAgentCapabilityDoctorReport, getCliAgentCapabilityDoctorReport, getCliAgentCapabilityReport, } from "@smithers-orchestrator/agents/cli-capabilities";
|
|
43
|
+
import { parseDurationMs, supervisorLoopEffect, } from "./supervisor.js";
|
|
44
|
+
import { WATCH_MIN_INTERVAL_MS, runWatchLoop, watchIntervalSecondsToMs, } from "./watch.js";
|
|
45
|
+
import { createSemanticMcpServer } from "./mcp/semantic-server.js";
|
|
46
|
+
import pc from "picocolors";
|
|
47
|
+
import crypto from "node:crypto";
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Helpers
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
/**
|
|
52
|
+
* @param {string} path
|
|
53
|
+
* @returns {Promise<SmithersWorkflow<any>>}
|
|
54
|
+
*/
|
|
55
|
+
async function loadWorkflowAsync(path) {
|
|
56
|
+
const abs = resolve(process.cwd(), path);
|
|
57
|
+
mdxPlugin();
|
|
58
|
+
const mod = await import(pathToFileURL(abs).href);
|
|
59
|
+
if (!mod.default)
|
|
60
|
+
throw new SmithersError("WORKFLOW_MISSING_DEFAULT", "Workflow must export default");
|
|
61
|
+
return mod.default;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* @param {string} path
|
|
65
|
+
*/
|
|
66
|
+
function loadWorkflowEffect(path) {
|
|
67
|
+
return Effect.tryPromise({
|
|
68
|
+
try: () => loadWorkflowAsync(path),
|
|
69
|
+
catch: (cause) => toSmithersError(cause, "cli load workflow"),
|
|
70
|
+
}).pipe(Effect.annotateLogs({ workflowPath: path }), Effect.withLogSpan("cli:load-workflow"));
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* @param {string} path
|
|
74
|
+
* @returns {Promise<SmithersWorkflow<any>>}
|
|
75
|
+
*/
|
|
76
|
+
async function loadWorkflow(path) {
|
|
77
|
+
return runPromise(loadWorkflowEffect(path));
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* @param {string} workflowPath
|
|
81
|
+
* @returns {Promise<{ adapter: SmithersDb; cleanup?: () => void }>}
|
|
82
|
+
*/
|
|
83
|
+
async function loadWorkflowDb(workflowPath) {
|
|
84
|
+
const workflow = await loadWorkflow(workflowPath);
|
|
85
|
+
ensureSmithersTables(workflow.db);
|
|
86
|
+
setupSqliteCleanup(workflow);
|
|
87
|
+
return { adapter: new SmithersDb(workflow.db) };
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* @returns {string}
|
|
91
|
+
*/
|
|
92
|
+
function readPackageVersion() {
|
|
93
|
+
try {
|
|
94
|
+
const pkgUrl = new URL("../../package.json", import.meta.url);
|
|
95
|
+
const raw = readFileSync(pkgUrl, "utf8");
|
|
96
|
+
const parsed = JSON.parse(raw);
|
|
97
|
+
return typeof parsed.version === "string" ? parsed.version : "unknown";
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return "unknown";
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const CLI_ARGUMENT_MAX_LENGTH = 4096;
|
|
104
|
+
const CLI_IDENTIFIER_MAX_LENGTH = 256;
|
|
105
|
+
const CLI_TEXT_ARGUMENT_MAX_LENGTH = 64 * 1024;
|
|
106
|
+
const CLI_JSON_ARGUMENT_MAX_BYTES = 1024 * 1024;
|
|
107
|
+
const CLI_HANDLER_BOUNDS_WRAPPED = Symbol("smithers.cliHandlerBoundsWrapped");
|
|
108
|
+
/**
|
|
109
|
+
* @param {string} path
|
|
110
|
+
* @returns {string}
|
|
111
|
+
*/
|
|
112
|
+
function cliFieldNameFromPath(path) {
|
|
113
|
+
const trimmed = path.replace(/\[\d+\]/g, "");
|
|
114
|
+
const lastDot = trimmed.lastIndexOf(".");
|
|
115
|
+
return lastDot >= 0 ? trimmed.slice(lastDot + 1) : trimmed;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* @param {string} path
|
|
119
|
+
* @param {string} value
|
|
120
|
+
*/
|
|
121
|
+
function validateCliStringArgument(path, value) {
|
|
122
|
+
const field = cliFieldNameFromPath(path);
|
|
123
|
+
switch (field) {
|
|
124
|
+
case "runId":
|
|
125
|
+
case "requestId":
|
|
126
|
+
case "correlation":
|
|
127
|
+
case "correlationId":
|
|
128
|
+
case "name":
|
|
129
|
+
assertMaxStringLength(path, value, CLI_IDENTIFIER_MAX_LENGTH);
|
|
130
|
+
return;
|
|
131
|
+
case "workflow":
|
|
132
|
+
case "root":
|
|
133
|
+
case "logDir":
|
|
134
|
+
assertMaxStringLength(path, value, CLI_ARGUMENT_MAX_LENGTH);
|
|
135
|
+
return;
|
|
136
|
+
case "input":
|
|
137
|
+
case "data":
|
|
138
|
+
case "value":
|
|
139
|
+
assertMaxBytes(path, value, CLI_JSON_ARGUMENT_MAX_BYTES);
|
|
140
|
+
return;
|
|
141
|
+
case "prompt":
|
|
142
|
+
case "note":
|
|
143
|
+
case "authToken":
|
|
144
|
+
assertMaxStringLength(path, value, CLI_TEXT_ARGUMENT_MAX_LENGTH);
|
|
145
|
+
return;
|
|
146
|
+
default:
|
|
147
|
+
assertMaxStringLength(path, value, CLI_ARGUMENT_MAX_LENGTH);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* @param {unknown} value
|
|
152
|
+
* @param {string} path
|
|
153
|
+
*/
|
|
154
|
+
function assertCliArgumentBounds(value, path) {
|
|
155
|
+
if (value === null || value === undefined) {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (typeof value === "string") {
|
|
159
|
+
validateCliStringArgument(path, value);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (Array.isArray(value)) {
|
|
163
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
164
|
+
assertCliArgumentBounds(value[index], `${path}[${index}]`);
|
|
165
|
+
}
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (typeof value !== "object") {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
172
|
+
assertCliArgumentBounds(entry, `${path}.${key}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* @param {Map<string, any>} commands
|
|
177
|
+
*/
|
|
178
|
+
function wrapCliCommandHandlersWithInputBounds(commands) {
|
|
179
|
+
for (const entry of commands.values()) {
|
|
180
|
+
if (!entry || typeof entry !== "object") {
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if ("_group" in entry) {
|
|
184
|
+
wrapCliCommandHandlersWithInputBounds(entry.commands);
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if ("_fetch" in entry) {
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (entry[CLI_HANDLER_BOUNDS_WRAPPED]) {
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
const originalRun = entry.run;
|
|
194
|
+
if (typeof originalRun !== "function") {
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
entry.run = function wrappedRun(context) {
|
|
198
|
+
assertCliArgumentBounds(context.args, "args");
|
|
199
|
+
assertCliArgumentBounds(context.options, "options");
|
|
200
|
+
return originalRun.call(this, context);
|
|
201
|
+
};
|
|
202
|
+
entry[CLI_HANDLER_BOUNDS_WRAPPED] = true;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* @param {string | undefined} raw
|
|
207
|
+
* @param {string} label
|
|
208
|
+
* @param {FailFn} fail
|
|
209
|
+
*/
|
|
210
|
+
function parseJsonInput(raw, label, fail) {
|
|
211
|
+
if (!raw)
|
|
212
|
+
return undefined;
|
|
213
|
+
try {
|
|
214
|
+
return JSON.parse(raw);
|
|
215
|
+
}
|
|
216
|
+
catch (err) {
|
|
217
|
+
return fail({
|
|
218
|
+
code: "INVALID_JSON",
|
|
219
|
+
message: `Invalid JSON for ${label}: ${err?.message ?? String(err)}`,
|
|
220
|
+
exitCode: 4,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* @param {string | undefined} status
|
|
226
|
+
*/
|
|
227
|
+
function formatStatusExitCode(status) {
|
|
228
|
+
if (status === "finished")
|
|
229
|
+
return 0;
|
|
230
|
+
if (status === "waiting-approval" ||
|
|
231
|
+
status === "waiting-event" ||
|
|
232
|
+
status === "waiting-timer") {
|
|
233
|
+
return 3;
|
|
234
|
+
}
|
|
235
|
+
if (status === "cancelled")
|
|
236
|
+
return 2;
|
|
237
|
+
return 1;
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* @param {SmithersWorkflow<any>} workflow
|
|
241
|
+
*/
|
|
242
|
+
function setupSqliteCleanup(workflow) {
|
|
243
|
+
const closeSqlite = () => {
|
|
244
|
+
try {
|
|
245
|
+
const client = workflow.db?.$client;
|
|
246
|
+
if (client && typeof client.close === "function") {
|
|
247
|
+
client.close();
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
catch { }
|
|
251
|
+
};
|
|
252
|
+
process.on("exit", closeSqlite);
|
|
253
|
+
process.on("SIGINT", () => { closeSqlite(); process.exit(130); });
|
|
254
|
+
process.on("SIGTERM", () => { closeSqlite(); process.exit(143); });
|
|
255
|
+
}
|
|
256
|
+
function buildProgressReporter() {
|
|
257
|
+
const startTime = Date.now();
|
|
258
|
+
const formatElapsed = () => {
|
|
259
|
+
const elapsed = Date.now() - startTime;
|
|
260
|
+
const secs = Math.floor(elapsed / 1000);
|
|
261
|
+
const mins = Math.floor(secs / 60);
|
|
262
|
+
const hrs = Math.floor(mins / 60);
|
|
263
|
+
/**
|
|
264
|
+
* @param {number} n
|
|
265
|
+
*/
|
|
266
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
267
|
+
return `${pad(hrs)}:${pad(mins % 60)}:${pad(secs % 60)}`;
|
|
268
|
+
};
|
|
269
|
+
return (event) => {
|
|
270
|
+
const ts = formatElapsed();
|
|
271
|
+
switch (event.type) {
|
|
272
|
+
case "NodeStarted":
|
|
273
|
+
process.stderr.write(`[${ts}] → ${event.nodeId} (attempt ${event.attempt ?? 1}, iteration ${event.iteration ?? 0})\n`);
|
|
274
|
+
break;
|
|
275
|
+
case "NodeFinished":
|
|
276
|
+
process.stderr.write(`[${ts}] ✓ ${event.nodeId} (attempt ${event.attempt ?? 1})\n`);
|
|
277
|
+
break;
|
|
278
|
+
case "NodeFailed":
|
|
279
|
+
process.stderr.write(`[${ts}] ✗ ${event.nodeId} (attempt ${event.attempt ?? 1}): ${typeof event.error === "string" ? event.error : (event.error?.message ?? "failed")}\n`);
|
|
280
|
+
break;
|
|
281
|
+
case "NodeRetrying":
|
|
282
|
+
process.stderr.write(`[${ts}] ↻ ${event.nodeId} retrying (attempt ${event.attempt ?? 1})\n`);
|
|
283
|
+
break;
|
|
284
|
+
case "NodeWaitingTimer":
|
|
285
|
+
process.stderr.write(`[${ts}] ⏱ ${event.nodeId} waiting for timer (fires ${new Date(event.firesAtMs).toISOString()})\n`);
|
|
286
|
+
break;
|
|
287
|
+
case "TimerCreated":
|
|
288
|
+
process.stderr.write(`[${ts}] ⏱ Timer created: ${event.timerId} (fires ${new Date(event.firesAtMs).toISOString()})\n`);
|
|
289
|
+
break;
|
|
290
|
+
case "TimerFired":
|
|
291
|
+
process.stderr.write(`[${ts}] 🔔 Timer fired: ${event.timerId} (delay ${event.delayMs}ms)\n`);
|
|
292
|
+
break;
|
|
293
|
+
case "RunFinished":
|
|
294
|
+
process.stderr.write(`[${ts}] ✓ Run finished\n`);
|
|
295
|
+
break;
|
|
296
|
+
case "RunFailed":
|
|
297
|
+
process.stderr.write(`[${ts}] ✗ Run failed: ${typeof event.error === "string" ? event.error : (event.error?.message ?? "unknown")}\n`);
|
|
298
|
+
break;
|
|
299
|
+
case "RetryTaskStarted":
|
|
300
|
+
process.stderr.write(`[${ts}] ↻ retrying ${event.nodeId} (reset: ${(event.resetNodes ?? []).join(", ") || event.nodeId})\n`);
|
|
301
|
+
break;
|
|
302
|
+
case "RetryTaskFinished":
|
|
303
|
+
process.stderr.write(`[${ts}] ${event.success ? "✓" : "✗"} retry reset ${event.success ? "finished" : "failed"} for ${event.nodeId}${event.error ? `: ${event.error}` : ""}\n`);
|
|
304
|
+
break;
|
|
305
|
+
case "FrameCommitted":
|
|
306
|
+
break;
|
|
307
|
+
case "WorkflowReloadDetected":
|
|
308
|
+
process.stderr.write(`[${ts}] ⟳ File change detected: ${event.changedFiles?.length ?? 0} file(s)\n`);
|
|
309
|
+
break;
|
|
310
|
+
case "WorkflowReloaded":
|
|
311
|
+
process.stderr.write(`[${ts}] ⟳ Workflow reloaded (generation ${event.generation})\n`);
|
|
312
|
+
break;
|
|
313
|
+
case "WorkflowReloadFailed":
|
|
314
|
+
process.stderr.write(`[${ts}] ⚠ Workflow reload failed: ${typeof event.error === "string" ? event.error : (event.error?.message ?? "unknown")}\n`);
|
|
315
|
+
break;
|
|
316
|
+
case "WorkflowReloadUnsafe":
|
|
317
|
+
process.stderr.write(`[${ts}] ⚠ Workflow reload blocked: ${event.reason}\n`);
|
|
318
|
+
break;
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* @param {string | null} [metaJson]
|
|
324
|
+
* @returns {WaitingTimerInfo | null}
|
|
325
|
+
*/
|
|
326
|
+
function parseWaitingTimerInfo(metaJson) {
|
|
327
|
+
if (!metaJson)
|
|
328
|
+
return null;
|
|
329
|
+
try {
|
|
330
|
+
const parsed = JSON.parse(metaJson);
|
|
331
|
+
const timer = parsed?.timer;
|
|
332
|
+
if (!timer || typeof timer !== "object")
|
|
333
|
+
return null;
|
|
334
|
+
const nodeId = typeof timer.timerId === "string" ? timer.timerId : null;
|
|
335
|
+
const firesAtMs = Number(timer.firesAtMs);
|
|
336
|
+
if (!nodeId || !Number.isFinite(firesAtMs))
|
|
337
|
+
return null;
|
|
338
|
+
return {
|
|
339
|
+
nodeId,
|
|
340
|
+
iteration: 0,
|
|
341
|
+
firesAtMs: Math.floor(firesAtMs),
|
|
342
|
+
timerType: timer.timerType === "absolute" ? "absolute" : "duration",
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
catch {
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* @param {number} ms
|
|
351
|
+
* @returns {string}
|
|
352
|
+
*/
|
|
353
|
+
function formatRemainingTimer(ms) {
|
|
354
|
+
if (ms <= 0)
|
|
355
|
+
return "due now";
|
|
356
|
+
const seconds = Math.floor(ms / 1000);
|
|
357
|
+
if (seconds < 60)
|
|
358
|
+
return `${seconds}s`;
|
|
359
|
+
const minutes = Math.floor(seconds / 60);
|
|
360
|
+
if (minutes < 60)
|
|
361
|
+
return `${minutes}m ${seconds % 60}s`;
|
|
362
|
+
const hours = Math.floor(minutes / 60);
|
|
363
|
+
if (hours < 24)
|
|
364
|
+
return `${hours}h ${minutes % 60}m`;
|
|
365
|
+
const days = Math.floor(hours / 24);
|
|
366
|
+
return `${days}d ${hours % 24}h`;
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* @param {SmithersDb} adapter
|
|
370
|
+
* @param {string} runId
|
|
371
|
+
*/
|
|
372
|
+
async function listWaitingTimers(adapter, runId) {
|
|
373
|
+
const nodes = await adapter.listNodes(runId);
|
|
374
|
+
const waits = [];
|
|
375
|
+
for (const node of nodes) {
|
|
376
|
+
if (node.state !== "waiting-timer")
|
|
377
|
+
continue;
|
|
378
|
+
const attempts = await adapter.listAttempts(runId, node.nodeId, node.iteration ?? 0);
|
|
379
|
+
const waitingAttempt = attempts.find((attempt) => attempt.state === "waiting-timer") ??
|
|
380
|
+
attempts[0];
|
|
381
|
+
const parsed = parseWaitingTimerInfo(waitingAttempt?.metaJson);
|
|
382
|
+
if (!parsed)
|
|
383
|
+
continue;
|
|
384
|
+
waits.push({
|
|
385
|
+
...parsed,
|
|
386
|
+
nodeId: node.nodeId,
|
|
387
|
+
iteration: node.iteration ?? 0,
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
waits.sort((left, right) => left.firesAtMs - right.firesAtMs);
|
|
391
|
+
return waits;
|
|
392
|
+
}
|
|
393
|
+
function setupAbortSignal() {
|
|
394
|
+
const abort = new AbortController();
|
|
395
|
+
let signalHandled = false;
|
|
396
|
+
/**
|
|
397
|
+
* @param {string} signal
|
|
398
|
+
*/
|
|
399
|
+
const handleSignal = (signal) => {
|
|
400
|
+
if (signalHandled)
|
|
401
|
+
return;
|
|
402
|
+
signalHandled = true;
|
|
403
|
+
process.stderr.write(`\n[smithers] received ${signal}, cancelling run...\n`);
|
|
404
|
+
abort.abort();
|
|
405
|
+
};
|
|
406
|
+
process.once("SIGINT", () => handleSignal("SIGINT"));
|
|
407
|
+
process.once("SIGTERM", () => handleSignal("SIGTERM"));
|
|
408
|
+
return abort;
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* @param {string | null | undefined} status
|
|
412
|
+
*/
|
|
413
|
+
function isRunStatusTerminal(status) {
|
|
414
|
+
return (status !== "running" &&
|
|
415
|
+
status !== "waiting-approval" &&
|
|
416
|
+
status !== "waiting-timer" &&
|
|
417
|
+
status !== "waiting-event");
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* @param {string | undefined} format
|
|
421
|
+
* @param {unknown} payload
|
|
422
|
+
* @param {string} [human]
|
|
423
|
+
*/
|
|
424
|
+
function writeWatchOutput(format, payload, human) {
|
|
425
|
+
if (format === "jsonl") {
|
|
426
|
+
process.stdout.write(`${JSON.stringify(payload)}\n`);
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
if (format === "json") {
|
|
430
|
+
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
if (human !== undefined) {
|
|
434
|
+
process.stdout.write(`${human}\n`);
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* @param {string} value
|
|
441
|
+
* @param {number} maxLength
|
|
442
|
+
*/
|
|
443
|
+
function truncateCliText(value, maxLength) {
|
|
444
|
+
return value.length <= maxLength
|
|
445
|
+
? value
|
|
446
|
+
: `${value.slice(0, Math.max(0, maxLength - 3))}...`;
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* @param {any[]} requests
|
|
450
|
+
*/
|
|
451
|
+
function renderHumanInboxHuman(requests) {
|
|
452
|
+
if (requests.length === 0) {
|
|
453
|
+
return "No pending human requests.";
|
|
454
|
+
}
|
|
455
|
+
return requests
|
|
456
|
+
.map((request) => {
|
|
457
|
+
const age = typeof request.requestedAtMs === "number"
|
|
458
|
+
? formatAge(request.requestedAtMs)
|
|
459
|
+
: "unknown";
|
|
460
|
+
const workflowName = typeof request.workflowName === "string" && request.workflowName.length > 0
|
|
461
|
+
? ` (${request.workflowName})`
|
|
462
|
+
: "";
|
|
463
|
+
return [
|
|
464
|
+
`${request.requestId}`,
|
|
465
|
+
` kind: ${request.kind}`,
|
|
466
|
+
` run: ${request.runId}${workflowName}`,
|
|
467
|
+
` node: ${request.nodeId}#${request.iteration ?? 0}`,
|
|
468
|
+
` age: ${age}`,
|
|
469
|
+
` prompt: ${truncateCliText(String(request.prompt ?? ""), 160)}`,
|
|
470
|
+
].join("\n");
|
|
471
|
+
})
|
|
472
|
+
.join("\n\n");
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* @param {any[]} alerts
|
|
476
|
+
*/
|
|
477
|
+
function renderAlertsHuman(alerts) {
|
|
478
|
+
if (alerts.length === 0) {
|
|
479
|
+
return "No active alerts.";
|
|
480
|
+
}
|
|
481
|
+
return alerts
|
|
482
|
+
.map((alert) => {
|
|
483
|
+
const age = typeof alert.firedAtMs === "number"
|
|
484
|
+
? formatAge(alert.firedAtMs)
|
|
485
|
+
: "unknown";
|
|
486
|
+
return [
|
|
487
|
+
`${alert.alertId}`,
|
|
488
|
+
` severity: ${alert.severity}`,
|
|
489
|
+
` status: ${alert.status}`,
|
|
490
|
+
` policy: ${alert.policyName}`,
|
|
491
|
+
...(alert.runId ? [` run: ${alert.runId}`] : []),
|
|
492
|
+
` age: ${age}`,
|
|
493
|
+
` message: ${truncateCliText(String(alert.message ?? ""), 160)}`,
|
|
494
|
+
].join("\n");
|
|
495
|
+
})
|
|
496
|
+
.join("\n\n");
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* @param {string} command
|
|
500
|
+
* @param {number} intervalSeconds
|
|
501
|
+
* @param {FailFn} fail
|
|
502
|
+
*/
|
|
503
|
+
function resolveWatchIntervalMsOrFail(command, intervalSeconds, fail) {
|
|
504
|
+
try {
|
|
505
|
+
const intervalMs = watchIntervalSecondsToMs(intervalSeconds);
|
|
506
|
+
if (intervalMs !== intervalSeconds * 1_000) {
|
|
507
|
+
process.stderr.write(`[smithers] --interval clamped to ${WATCH_MIN_INTERVAL_MS}ms for ${command} watch mode\n`);
|
|
508
|
+
}
|
|
509
|
+
return intervalMs;
|
|
510
|
+
}
|
|
511
|
+
catch (error) {
|
|
512
|
+
return fail({
|
|
513
|
+
code: "INVALID_WATCH_INTERVAL",
|
|
514
|
+
message: error?.message ?? String(error),
|
|
515
|
+
exitCode: 4,
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* @param {SmithersDb} adapter
|
|
521
|
+
* @param {string} runId
|
|
522
|
+
*/
|
|
523
|
+
async function listAllEvents(adapter, runId) {
|
|
524
|
+
const events = [];
|
|
525
|
+
let lastSeq = -1;
|
|
526
|
+
while (true) {
|
|
527
|
+
const batch = await adapter.listEvents(runId, lastSeq, 1000);
|
|
528
|
+
if (batch.length === 0)
|
|
529
|
+
break;
|
|
530
|
+
events.push(...batch);
|
|
531
|
+
lastSeq = batch[batch.length - 1].seq;
|
|
532
|
+
if (batch.length < 1000)
|
|
533
|
+
break;
|
|
534
|
+
}
|
|
535
|
+
return events;
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* @param {SmithersDb} adapter
|
|
539
|
+
* @param {string} runId
|
|
540
|
+
* @returns {Promise<string[]>}
|
|
541
|
+
*/
|
|
542
|
+
async function listAncestryRunIds(adapter, runId) {
|
|
543
|
+
const ancestry = await adapter.listRunAncestry(runId, 10_000);
|
|
544
|
+
if (!ancestry || ancestry.length === 0)
|
|
545
|
+
return [runId];
|
|
546
|
+
// listRunAncestry returns [current, parent, grandparent, ...]
|
|
547
|
+
return ancestry.map((row) => row.runId);
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* @param {any} c
|
|
551
|
+
*/
|
|
552
|
+
async function* streamRunEventsCommand(c) {
|
|
553
|
+
let adapter;
|
|
554
|
+
let cleanup;
|
|
555
|
+
try {
|
|
556
|
+
const db = await findAndOpenDb();
|
|
557
|
+
adapter = db.adapter;
|
|
558
|
+
cleanup = db.cleanup;
|
|
559
|
+
const run = await adapter.getRun(c.args.runId);
|
|
560
|
+
if (!run) {
|
|
561
|
+
yield `Error: Run not found: ${c.args.runId}`;
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
const includeAncestry = Boolean(c.options.followAncestry);
|
|
565
|
+
const lineageCurrentToRoot = includeAncestry
|
|
566
|
+
? await listAncestryRunIds(adapter, c.args.runId)
|
|
567
|
+
: [c.args.runId];
|
|
568
|
+
const lineageRootToCurrent = [...lineageCurrentToRoot].reverse();
|
|
569
|
+
const runOrder = new Map(lineageRootToCurrent.map((runId, index) => [runId, index]));
|
|
570
|
+
const lineageRuns = await Promise.all(lineageRootToCurrent.map((lineageRunId) => adapter.getRun(lineageRunId)));
|
|
571
|
+
const firstLineageRun = lineageRuns.find((entry) => Boolean(entry));
|
|
572
|
+
const baseMs = firstLineageRun?.startedAtMs ??
|
|
573
|
+
firstLineageRun?.createdAtMs ??
|
|
574
|
+
run.startedAtMs ??
|
|
575
|
+
run.createdAtMs ??
|
|
576
|
+
Date.now();
|
|
577
|
+
/**
|
|
578
|
+
* @param {any} event
|
|
579
|
+
*/
|
|
580
|
+
const formatLine = (event) => {
|
|
581
|
+
const line = formatEventLine(event, baseMs);
|
|
582
|
+
if (!includeAncestry)
|
|
583
|
+
return line;
|
|
584
|
+
const runPrefix = String(event.runId ?? "").slice(0, 12);
|
|
585
|
+
return `${runPrefix} ${line}`;
|
|
586
|
+
};
|
|
587
|
+
let lastSeq = c.options.since ?? -1;
|
|
588
|
+
if (!includeAncestry && c.options.since === undefined) {
|
|
589
|
+
const lastEventSeq = await adapter.getLastEventSeq(c.args.runId);
|
|
590
|
+
if (lastEventSeq !== undefined) {
|
|
591
|
+
lastSeq = Math.max(-1, lastEventSeq - c.options.tail);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
let initialEvents = [];
|
|
595
|
+
if (includeAncestry) {
|
|
596
|
+
const merged = [];
|
|
597
|
+
for (const lineageRunId of lineageRootToCurrent) {
|
|
598
|
+
const events = await listAllEvents(adapter, lineageRunId);
|
|
599
|
+
for (const event of events) {
|
|
600
|
+
merged.push({ ...event, runId: lineageRunId });
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
merged.sort((left, right) => {
|
|
604
|
+
if (left.timestampMs !== right.timestampMs) {
|
|
605
|
+
return left.timestampMs - right.timestampMs;
|
|
606
|
+
}
|
|
607
|
+
const leftOrder = runOrder.get(left.runId) ?? 0;
|
|
608
|
+
const rightOrder = runOrder.get(right.runId) ?? 0;
|
|
609
|
+
if (leftOrder !== rightOrder)
|
|
610
|
+
return leftOrder - rightOrder;
|
|
611
|
+
return (left.seq ?? 0) - (right.seq ?? 0);
|
|
612
|
+
});
|
|
613
|
+
initialEvents =
|
|
614
|
+
c.options.since !== undefined
|
|
615
|
+
? merged.filter((event) => (event.seq ?? -1) > c.options.since)
|
|
616
|
+
: merged.slice(-c.options.tail);
|
|
617
|
+
const lastCurrentEvent = [...initialEvents]
|
|
618
|
+
.reverse()
|
|
619
|
+
.find((event) => event.runId === c.args.runId);
|
|
620
|
+
lastSeq = lastCurrentEvent?.seq ?? -1;
|
|
621
|
+
}
|
|
622
|
+
else {
|
|
623
|
+
initialEvents = await adapter.listEvents(c.args.runId, lastSeq, 1000);
|
|
624
|
+
for (const event of initialEvents) {
|
|
625
|
+
lastSeq = event.seq;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
for (const event of initialEvents) {
|
|
629
|
+
yield formatLine(event);
|
|
630
|
+
if (!includeAncestry) {
|
|
631
|
+
lastSeq = event.seq;
|
|
632
|
+
}
|
|
633
|
+
else if (event.runId === c.args.runId) {
|
|
634
|
+
lastSeq = event.seq;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
const isActive = run.status === "running" ||
|
|
638
|
+
run.status === "waiting-approval" ||
|
|
639
|
+
run.status === "waiting-event" ||
|
|
640
|
+
run.status === "waiting-timer";
|
|
641
|
+
if (!c.options.follow || !isActive) {
|
|
642
|
+
return c.ok(undefined, {
|
|
643
|
+
cta: {
|
|
644
|
+
commands: [{ command: `inspect ${c.args.runId}`, description: "Inspect run state" }],
|
|
645
|
+
},
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
while (true) {
|
|
649
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
650
|
+
const newEvents = await adapter.listEvents(c.args.runId, lastSeq, 200);
|
|
651
|
+
for (const event of newEvents) {
|
|
652
|
+
yield formatLine(event);
|
|
653
|
+
lastSeq = event.seq;
|
|
654
|
+
}
|
|
655
|
+
const currentRun = await adapter.getRun(c.args.runId);
|
|
656
|
+
const currentStatus = currentRun?.status;
|
|
657
|
+
if (currentStatus !== "running" &&
|
|
658
|
+
currentStatus !== "waiting-approval" &&
|
|
659
|
+
currentStatus !== "waiting-event" &&
|
|
660
|
+
currentStatus !== "waiting-timer") {
|
|
661
|
+
const finalEvents = await adapter.listEvents(c.args.runId, lastSeq, 1000);
|
|
662
|
+
for (const event of finalEvents) {
|
|
663
|
+
yield formatLine(event);
|
|
664
|
+
lastSeq = event.seq;
|
|
665
|
+
}
|
|
666
|
+
const ctaCommands = [
|
|
667
|
+
{ command: `inspect ${c.args.runId}`, description: "Inspect run state" },
|
|
668
|
+
];
|
|
669
|
+
if (currentStatus === "waiting-approval") {
|
|
670
|
+
ctaCommands.push({ command: `approve ${c.args.runId}`, description: "Approve run" });
|
|
671
|
+
}
|
|
672
|
+
if (currentStatus === "waiting-event") {
|
|
673
|
+
ctaCommands.push({ command: `why ${c.args.runId}`, description: "Explain signal wait" });
|
|
674
|
+
}
|
|
675
|
+
if (currentStatus === "waiting-timer") {
|
|
676
|
+
ctaCommands.push({ command: `why ${c.args.runId}`, description: "Explain timer wait" });
|
|
677
|
+
}
|
|
678
|
+
return c.ok(undefined, { cta: { commands: ctaCommands } });
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
finally {
|
|
683
|
+
cleanup?.();
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
const DEFAULT_EVENTS_LIMIT = 1_000;
|
|
687
|
+
const MAX_EVENTS_LIMIT = 100_000;
|
|
688
|
+
const EVENTS_PAGE_SIZE = 1_000;
|
|
689
|
+
/**
|
|
690
|
+
* @param {string} payloadJson
|
|
691
|
+
* @returns {Record<string, unknown>}
|
|
692
|
+
*/
|
|
693
|
+
function parseEventPayload(payloadJson) {
|
|
694
|
+
try {
|
|
695
|
+
const parsed = JSON.parse(payloadJson);
|
|
696
|
+
if (parsed && typeof parsed === "object") {
|
|
697
|
+
return parsed;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
catch {
|
|
701
|
+
// ignore malformed payloads
|
|
702
|
+
}
|
|
703
|
+
return {};
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* @param {unknown} value
|
|
707
|
+
* @returns {number | null}
|
|
708
|
+
*/
|
|
709
|
+
function parseEventNumber(value) {
|
|
710
|
+
const asNumber = typeof value === "number"
|
|
711
|
+
? value
|
|
712
|
+
: typeof value === "string"
|
|
713
|
+
? Number(value)
|
|
714
|
+
: NaN;
|
|
715
|
+
if (!Number.isFinite(asNumber))
|
|
716
|
+
return null;
|
|
717
|
+
return Math.floor(asNumber);
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* @param {string | undefined} groupByRaw
|
|
721
|
+
* @returns {EventGroupBy | undefined}
|
|
722
|
+
*/
|
|
723
|
+
function normalizeEventGroupBy(groupByRaw) {
|
|
724
|
+
if (!groupByRaw)
|
|
725
|
+
return undefined;
|
|
726
|
+
const normalized = groupByRaw.trim().toLowerCase();
|
|
727
|
+
if (normalized === "node" || normalized === "attempt") {
|
|
728
|
+
return normalized;
|
|
729
|
+
}
|
|
730
|
+
throw new SmithersError("INVALID_GROUP_BY", `Invalid --group-by value "${groupByRaw}". Use "node" or "attempt".`);
|
|
731
|
+
}
|
|
732
|
+
/**
|
|
733
|
+
* @param {number | undefined} limit
|
|
734
|
+
* @returns {{ value: number; defaultLimitUsed: boolean; limitCapped: boolean; }}
|
|
735
|
+
*/
|
|
736
|
+
function normalizeEventsLimit(limit) {
|
|
737
|
+
if (limit === undefined) {
|
|
738
|
+
return {
|
|
739
|
+
value: DEFAULT_EVENTS_LIMIT,
|
|
740
|
+
defaultLimitUsed: true,
|
|
741
|
+
limitCapped: false,
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
if (limit > MAX_EVENTS_LIMIT) {
|
|
745
|
+
return {
|
|
746
|
+
value: MAX_EVENTS_LIMIT,
|
|
747
|
+
defaultLimitUsed: false,
|
|
748
|
+
limitCapped: true,
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
return {
|
|
752
|
+
value: limit,
|
|
753
|
+
defaultLimitUsed: false,
|
|
754
|
+
limitCapped: false,
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* @param {EventHistoryRow} event
|
|
759
|
+
* @param {number} baseMs
|
|
760
|
+
* @returns {string}
|
|
761
|
+
*/
|
|
762
|
+
function buildEventHistoryLine(event, baseMs) {
|
|
763
|
+
const seqLabel = `#${event.seq + 1}`;
|
|
764
|
+
const offset = formatRelativeOffset(baseMs, event.timestampMs);
|
|
765
|
+
const typeText = event.type.padEnd(20, " ");
|
|
766
|
+
const coloredType = colorizeEventText(event.type, typeText);
|
|
767
|
+
const summary = formatEventLine(event, baseMs, {
|
|
768
|
+
includeTimestamp: false,
|
|
769
|
+
truncatePayloadAt: 220,
|
|
770
|
+
});
|
|
771
|
+
return `${seqLabel} ${offset} ${coloredType} ${summary}`;
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* @param {EventHistoryRow} event
|
|
775
|
+
* @returns {string}
|
|
776
|
+
*/
|
|
777
|
+
function buildEventNdjsonLine(event) {
|
|
778
|
+
const payload = parseEventPayload(event.payloadJson);
|
|
779
|
+
return JSON.stringify({
|
|
780
|
+
runId: event.runId,
|
|
781
|
+
seq: event.seq,
|
|
782
|
+
timestampMs: event.timestampMs,
|
|
783
|
+
type: event.type,
|
|
784
|
+
payload,
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
/**
|
|
788
|
+
* @param {EventHistoryRow} event
|
|
789
|
+
* @returns {string}
|
|
790
|
+
*/
|
|
791
|
+
function eventNodeGroupLabel(event) {
|
|
792
|
+
const payload = parseEventPayload(event.payloadJson);
|
|
793
|
+
const nodeId = payload.nodeId;
|
|
794
|
+
if (typeof nodeId === "string" && nodeId.length > 0)
|
|
795
|
+
return nodeId;
|
|
796
|
+
return "(run)";
|
|
797
|
+
}
|
|
798
|
+
/**
|
|
799
|
+
* @param {EventHistoryRow} event
|
|
800
|
+
* @returns {{ nodeLabel: string; attemptLabel: string; }}
|
|
801
|
+
*/
|
|
802
|
+
function eventAttemptGroupLabel(event) {
|
|
803
|
+
const payload = parseEventPayload(event.payloadJson);
|
|
804
|
+
const nodeLabel = eventNodeGroupLabel(event);
|
|
805
|
+
const attempt = parseEventNumber(payload.attempt);
|
|
806
|
+
const iteration = parseEventNumber(payload.iteration);
|
|
807
|
+
if (attempt === null && iteration === null) {
|
|
808
|
+
return {
|
|
809
|
+
nodeLabel,
|
|
810
|
+
attemptLabel: "Attempt ?",
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
if (iteration === null) {
|
|
814
|
+
return {
|
|
815
|
+
nodeLabel,
|
|
816
|
+
attemptLabel: `Attempt ${attempt ?? "?"}`,
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
return {
|
|
820
|
+
nodeLabel,
|
|
821
|
+
attemptLabel: `Attempt ${attempt ?? "?"} (iteration ${iteration})`,
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
/**
|
|
825
|
+
* @param {EventHistoryRow[]} events
|
|
826
|
+
* @param {number} baseMs
|
|
827
|
+
* @param {EventGroupBy} groupBy
|
|
828
|
+
* @returns {string[]}
|
|
829
|
+
*/
|
|
830
|
+
function renderGroupedEvents(events, baseMs, groupBy) {
|
|
831
|
+
const lines = [];
|
|
832
|
+
if (groupBy === "node") {
|
|
833
|
+
const order = [];
|
|
834
|
+
const grouped = new Map();
|
|
835
|
+
for (const event of events) {
|
|
836
|
+
const key = eventNodeGroupLabel(event);
|
|
837
|
+
if (!grouped.has(key)) {
|
|
838
|
+
grouped.set(key, []);
|
|
839
|
+
order.push(key);
|
|
840
|
+
}
|
|
841
|
+
grouped.get(key).push(event);
|
|
842
|
+
}
|
|
843
|
+
for (const key of order) {
|
|
844
|
+
if (lines.length > 0)
|
|
845
|
+
lines.push("");
|
|
846
|
+
lines.push(pc.bold(`node: ${key}`));
|
|
847
|
+
const bucket = grouped.get(key) ?? [];
|
|
848
|
+
for (const event of bucket) {
|
|
849
|
+
lines.push(` ${buildEventHistoryLine(event, baseMs)}`);
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
return lines;
|
|
853
|
+
}
|
|
854
|
+
const nodeOrder = [];
|
|
855
|
+
const nodeBuckets = new Map();
|
|
856
|
+
for (const event of events) {
|
|
857
|
+
const { nodeLabel, attemptLabel } = eventAttemptGroupLabel(event);
|
|
858
|
+
if (!nodeBuckets.has(nodeLabel)) {
|
|
859
|
+
nodeBuckets.set(nodeLabel, { attemptOrder: [], attempts: new Map() });
|
|
860
|
+
nodeOrder.push(nodeLabel);
|
|
861
|
+
}
|
|
862
|
+
const entry = nodeBuckets.get(nodeLabel);
|
|
863
|
+
if (!entry.attempts.has(attemptLabel)) {
|
|
864
|
+
entry.attempts.set(attemptLabel, []);
|
|
865
|
+
entry.attemptOrder.push(attemptLabel);
|
|
866
|
+
}
|
|
867
|
+
entry.attempts.get(attemptLabel).push(event);
|
|
868
|
+
}
|
|
869
|
+
for (const nodeLabel of nodeOrder) {
|
|
870
|
+
const nodeEntry = nodeBuckets.get(nodeLabel);
|
|
871
|
+
if (!nodeEntry)
|
|
872
|
+
continue;
|
|
873
|
+
if (lines.length > 0)
|
|
874
|
+
lines.push("");
|
|
875
|
+
lines.push(pc.bold(`node: ${nodeLabel}`));
|
|
876
|
+
for (const attemptLabel of nodeEntry.attemptOrder) {
|
|
877
|
+
lines.push(pc.bold(` ${attemptLabel}`));
|
|
878
|
+
const bucket = nodeEntry.attempts.get(attemptLabel) ?? [];
|
|
879
|
+
for (const event of bucket) {
|
|
880
|
+
lines.push(` ${buildEventHistoryLine(event, baseMs)}`);
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
return lines;
|
|
885
|
+
}
|
|
886
|
+
/**
|
|
887
|
+
* @param {SmithersDb} adapter
|
|
888
|
+
* @param {string} runId
|
|
889
|
+
* @param {{ afterSeq: number; nodeId?: string; eventTypes?: readonly string[]; sinceTimestampMs?: number; limit: number; }} query
|
|
890
|
+
*/
|
|
891
|
+
async function queryEventHistoryPage(adapter, runId, query) {
|
|
892
|
+
return runPromise(adapter.listEventHistoryEffect(runId, {
|
|
893
|
+
afterSeq: query.afterSeq,
|
|
894
|
+
nodeId: query.nodeId,
|
|
895
|
+
sinceTimestampMs: query.sinceTimestampMs,
|
|
896
|
+
types: query.eventTypes,
|
|
897
|
+
limit: query.limit,
|
|
898
|
+
}).pipe(Effect.annotateLogs({
|
|
899
|
+
runId,
|
|
900
|
+
filters: {
|
|
901
|
+
nodeId: query.nodeId,
|
|
902
|
+
sinceTimestampMs: query.sinceTimestampMs,
|
|
903
|
+
eventTypes: query.eventTypes,
|
|
904
|
+
afterSeq: query.afterSeq,
|
|
905
|
+
limit: query.limit,
|
|
906
|
+
},
|
|
907
|
+
}), Effect.withLogSpan("cli:events")));
|
|
908
|
+
}
|
|
909
|
+
/**
|
|
910
|
+
* @param {SmithersDb} adapter
|
|
911
|
+
* @param {string} runId
|
|
912
|
+
* @param {{ nodeId?: string; eventTypes?: readonly string[]; sinceTimestampMs?: number; }} query
|
|
913
|
+
*/
|
|
914
|
+
async function countEventHistory(adapter, runId, query) {
|
|
915
|
+
return runPromise(adapter.countEventHistoryEffect(runId, {
|
|
916
|
+
nodeId: query.nodeId,
|
|
917
|
+
sinceTimestampMs: query.sinceTimestampMs,
|
|
918
|
+
types: query.eventTypes,
|
|
919
|
+
}).pipe(Effect.annotateLogs({
|
|
920
|
+
runId,
|
|
921
|
+
filters: {
|
|
922
|
+
nodeId: query.nodeId,
|
|
923
|
+
sinceTimestampMs: query.sinceTimestampMs,
|
|
924
|
+
eventTypes: query.eventTypes,
|
|
925
|
+
},
|
|
926
|
+
}), Effect.withLogSpan("cli:events")));
|
|
927
|
+
}
|
|
928
|
+
/**
|
|
929
|
+
* @param {SmithersDb} adapter
|
|
930
|
+
* @param {number} limit
|
|
931
|
+
* @param {string | undefined} status
|
|
932
|
+
* @returns {Promise<PsRow[]>}
|
|
933
|
+
*/
|
|
934
|
+
async function buildPsRows(adapter, limit, status) {
|
|
935
|
+
const runs = await adapter.listRuns(limit, status);
|
|
936
|
+
const rows = [];
|
|
937
|
+
for (const run of runs) {
|
|
938
|
+
const nodes = await adapter.listNodes(run.runId);
|
|
939
|
+
const activeNode = nodes.find((n) => n.state === "in-progress");
|
|
940
|
+
const waitingTimers = run.status === "waiting-timer"
|
|
941
|
+
? await listWaitingTimers(adapter, run.runId)
|
|
942
|
+
: [];
|
|
943
|
+
const nextTimer = waitingTimers[0];
|
|
944
|
+
const view = await computeRunStateFromRow(adapter, run);
|
|
945
|
+
rows.push({
|
|
946
|
+
id: run.runId,
|
|
947
|
+
workflow: run.workflowName ?? (run.workflowPath ? basename(run.workflowPath) : "—"),
|
|
948
|
+
status: derivedStateToStatus(view.state),
|
|
949
|
+
dbStatus: run.status,
|
|
950
|
+
state: view.state,
|
|
951
|
+
...(view.unhealthy ? { unhealthy: view.unhealthy } : {}),
|
|
952
|
+
step: nextTimer
|
|
953
|
+
? `timer:${nextTimer.nodeId}`
|
|
954
|
+
: activeNode?.label ?? activeNode?.nodeId ?? "—",
|
|
955
|
+
...(nextTimer
|
|
956
|
+
? {
|
|
957
|
+
timer: {
|
|
958
|
+
id: nextTimer.nodeId,
|
|
959
|
+
iteration: nextTimer.iteration,
|
|
960
|
+
firesAt: new Date(nextTimer.firesAtMs).toISOString(),
|
|
961
|
+
remaining: formatRemainingTimer(nextTimer.firesAtMs - Date.now()),
|
|
962
|
+
},
|
|
963
|
+
}
|
|
964
|
+
: {}),
|
|
965
|
+
started: run.startedAtMs
|
|
966
|
+
? formatAge(run.startedAtMs)
|
|
967
|
+
: run.createdAtMs
|
|
968
|
+
? formatAge(run.createdAtMs)
|
|
969
|
+
: "—",
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
return rows;
|
|
973
|
+
}
|
|
974
|
+
/**
|
|
975
|
+
* Map a derived RunState to the legacy `status` string surfaced by `smithers ps`.
|
|
976
|
+
* Older consumers (and the dashboard CTA logic) still key off `status`, so a row
|
|
977
|
+
* whose owner is dead must surface as something other than "running".
|
|
978
|
+
*
|
|
979
|
+
* @param {import("@smithers-orchestrator/db/runState").RunStateView["state"]} state
|
|
980
|
+
* @returns {string}
|
|
981
|
+
*/
|
|
982
|
+
function derivedStateToStatus(state) {
|
|
983
|
+
switch (state) {
|
|
984
|
+
case "succeeded":
|
|
985
|
+
return "finished";
|
|
986
|
+
case "stale":
|
|
987
|
+
case "orphaned":
|
|
988
|
+
case "running":
|
|
989
|
+
case "recovering":
|
|
990
|
+
case "waiting-approval":
|
|
991
|
+
case "waiting-event":
|
|
992
|
+
case "waiting-timer":
|
|
993
|
+
case "failed":
|
|
994
|
+
case "cancelled":
|
|
995
|
+
case "unknown":
|
|
996
|
+
return state;
|
|
997
|
+
default:
|
|
998
|
+
return state;
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
/**
|
|
1002
|
+
* @param {PsRow[]} rows
|
|
1003
|
+
*/
|
|
1004
|
+
function buildPsCtaCommands(rows) {
|
|
1005
|
+
const ctaCommands = [];
|
|
1006
|
+
const firstActive = rows.find((r) => r.status === "running");
|
|
1007
|
+
const firstWaitingApproval = rows.find((r) => r.status === "waiting-approval");
|
|
1008
|
+
const firstWaitingTimer = rows.find((r) => r.status === "waiting-timer");
|
|
1009
|
+
if (firstActive) {
|
|
1010
|
+
ctaCommands.push({ command: `logs ${firstActive.id}`, description: "Tail active run" });
|
|
1011
|
+
ctaCommands.push({ command: `chat ${firstActive.id} --follow`, description: "Watch agent chat" });
|
|
1012
|
+
}
|
|
1013
|
+
if (firstWaitingApproval) {
|
|
1014
|
+
ctaCommands.push({ command: `approve ${firstWaitingApproval.id}`, description: "Approve waiting run" });
|
|
1015
|
+
}
|
|
1016
|
+
if (firstWaitingTimer) {
|
|
1017
|
+
ctaCommands.push({ command: `why ${firstWaitingTimer.id}`, description: "Explain timer wait" });
|
|
1018
|
+
}
|
|
1019
|
+
if (rows.length > 0) {
|
|
1020
|
+
ctaCommands.push({ command: `inspect ${rows[0].id}`, description: "Inspect most recent run" });
|
|
1021
|
+
}
|
|
1022
|
+
return ctaCommands;
|
|
1023
|
+
}
|
|
1024
|
+
/**
|
|
1025
|
+
* @param {SmithersDb} adapter
|
|
1026
|
+
* @param {string} runId
|
|
1027
|
+
* @returns {Promise<InspectSnapshot>}
|
|
1028
|
+
*/
|
|
1029
|
+
async function buildInspectSnapshot(adapter, runId) {
|
|
1030
|
+
const run = await adapter.getRun(runId);
|
|
1031
|
+
if (!run) {
|
|
1032
|
+
throw new SmithersError("RUN_NOT_FOUND", `Run not found: ${runId}`);
|
|
1033
|
+
}
|
|
1034
|
+
const r = run;
|
|
1035
|
+
const nodes = await adapter.listNodes(runId);
|
|
1036
|
+
const approvals = await adapter.listPendingApprovals(runId);
|
|
1037
|
+
const waitingTimers = await listWaitingTimers(adapter, runId);
|
|
1038
|
+
const loops = await adapter.listRalph(runId);
|
|
1039
|
+
const ancestry = await adapter.listRunAncestry(runId, 1_000);
|
|
1040
|
+
const continuedFromRunIds = ancestry.slice(1).map((row) => row.runId);
|
|
1041
|
+
const lineagePageSize = 100;
|
|
1042
|
+
const continuedFromVisible = continuedFromRunIds.slice(0, lineagePageSize);
|
|
1043
|
+
const continuedFromRemaining = continuedFromRunIds.length > lineagePageSize
|
|
1044
|
+
? continuedFromRunIds.length - lineagePageSize
|
|
1045
|
+
: 0;
|
|
1046
|
+
let activeDescendantRunId;
|
|
1047
|
+
{
|
|
1048
|
+
const seen = new Set([runId]);
|
|
1049
|
+
let cursor = runId;
|
|
1050
|
+
while (true) {
|
|
1051
|
+
const child = await adapter.getLatestChildRun(cursor);
|
|
1052
|
+
if (!child || !child.runId || seen.has(child.runId))
|
|
1053
|
+
break;
|
|
1054
|
+
activeDescendantRunId = child.runId;
|
|
1055
|
+
seen.add(child.runId);
|
|
1056
|
+
cursor = child.runId;
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
const steps = nodes.map((n) => ({
|
|
1060
|
+
id: n.nodeId,
|
|
1061
|
+
state: n.state,
|
|
1062
|
+
attempt: n.lastAttempt ?? 0,
|
|
1063
|
+
label: n.label ?? n.nodeId,
|
|
1064
|
+
}));
|
|
1065
|
+
const pendingApprovals = approvals.map((a) => ({
|
|
1066
|
+
nodeId: a.nodeId,
|
|
1067
|
+
status: a.status,
|
|
1068
|
+
requestedAt: a.requestedAtMs ? new Date(a.requestedAtMs).toISOString() : "—",
|
|
1069
|
+
}));
|
|
1070
|
+
const loopState = loops.map((l) => ({
|
|
1071
|
+
loopId: l.ralphId,
|
|
1072
|
+
iteration: l.iteration,
|
|
1073
|
+
maxIterations: l.maxIterations,
|
|
1074
|
+
}));
|
|
1075
|
+
let config = undefined;
|
|
1076
|
+
if (r.configJson) {
|
|
1077
|
+
try {
|
|
1078
|
+
config = JSON.parse(r.configJson);
|
|
1079
|
+
}
|
|
1080
|
+
catch { }
|
|
1081
|
+
}
|
|
1082
|
+
let error = undefined;
|
|
1083
|
+
if (r.errorJson) {
|
|
1084
|
+
try {
|
|
1085
|
+
error = JSON.parse(r.errorJson);
|
|
1086
|
+
}
|
|
1087
|
+
catch { }
|
|
1088
|
+
}
|
|
1089
|
+
const runState = await computeRunStateFromRow(adapter, run).catch(() => undefined);
|
|
1090
|
+
const result = {
|
|
1091
|
+
run: {
|
|
1092
|
+
id: r.runId,
|
|
1093
|
+
workflow: r.workflowName ?? (r.workflowPath ? basename(r.workflowPath) : "—"),
|
|
1094
|
+
status: r.status,
|
|
1095
|
+
...(r.parentRunId ? { parentRunId: r.parentRunId } : {}),
|
|
1096
|
+
started: r.startedAtMs ? new Date(r.startedAtMs).toISOString() : "—",
|
|
1097
|
+
elapsed: r.startedAtMs ? formatElapsedCompact(r.startedAtMs, r.finishedAtMs ?? undefined) : "—",
|
|
1098
|
+
...(r.finishedAtMs ? { finished: new Date(r.finishedAtMs).toISOString() } : {}),
|
|
1099
|
+
...(activeDescendantRunId && activeDescendantRunId !== r.runId
|
|
1100
|
+
? { activeDescendantRunId }
|
|
1101
|
+
: {}),
|
|
1102
|
+
...(error ? { error } : {}),
|
|
1103
|
+
},
|
|
1104
|
+
...(runState ? { runState } : {}),
|
|
1105
|
+
steps,
|
|
1106
|
+
};
|
|
1107
|
+
if (continuedFromVisible.length > 0) {
|
|
1108
|
+
result.run.continuedFrom = continuedFromVisible;
|
|
1109
|
+
result.run.continuedFromDisplay = [
|
|
1110
|
+
...continuedFromVisible,
|
|
1111
|
+
...(continuedFromRemaining > 0
|
|
1112
|
+
? [`... (${continuedFromRemaining} more)`]
|
|
1113
|
+
: []),
|
|
1114
|
+
].join(" -> ");
|
|
1115
|
+
}
|
|
1116
|
+
if (pendingApprovals.length > 0) {
|
|
1117
|
+
result.approvals = pendingApprovals;
|
|
1118
|
+
}
|
|
1119
|
+
if (waitingTimers.length > 0) {
|
|
1120
|
+
result.timers = waitingTimers.map((timer) => ({
|
|
1121
|
+
timerId: timer.nodeId,
|
|
1122
|
+
iteration: timer.iteration,
|
|
1123
|
+
firesAt: new Date(timer.firesAtMs).toISOString(),
|
|
1124
|
+
remaining: formatRemainingTimer(timer.firesAtMs - Date.now()),
|
|
1125
|
+
}));
|
|
1126
|
+
}
|
|
1127
|
+
if (loopState.length > 0) {
|
|
1128
|
+
result.loops = loopState;
|
|
1129
|
+
}
|
|
1130
|
+
if (config) {
|
|
1131
|
+
result.config = config;
|
|
1132
|
+
}
|
|
1133
|
+
const ctaCommands = [
|
|
1134
|
+
{ command: `logs ${runId}`, description: "Tail run logs" },
|
|
1135
|
+
{ command: `chat ${runId}`, description: "View agent chat" },
|
|
1136
|
+
];
|
|
1137
|
+
if (r.status === "running" ||
|
|
1138
|
+
r.status === "waiting-approval" ||
|
|
1139
|
+
r.status === "waiting-timer" ||
|
|
1140
|
+
r.status === "waiting-event") {
|
|
1141
|
+
ctaCommands.push({ command: `cancel ${runId}`, description: "Cancel run" });
|
|
1142
|
+
}
|
|
1143
|
+
if (pendingApprovals.length > 0) {
|
|
1144
|
+
ctaCommands.push({ command: `approve ${runId}`, description: "Approve pending gate" });
|
|
1145
|
+
}
|
|
1146
|
+
if (waitingTimers.length > 0) {
|
|
1147
|
+
ctaCommands.push({ command: `why ${runId}`, description: "Explain timer wait" });
|
|
1148
|
+
}
|
|
1149
|
+
return {
|
|
1150
|
+
result,
|
|
1151
|
+
ctaCommands,
|
|
1152
|
+
status: r.status,
|
|
1153
|
+
};
|
|
1154
|
+
}
|
|
1155
|
+
/**
|
|
1156
|
+
* @param {SmithersDb} adapter
|
|
1157
|
+
* @param {{ runId: string; nodeId: string; iteration: number | undefined; }} options
|
|
1158
|
+
* @returns {Promise<NodeSnapshot>}
|
|
1159
|
+
*/
|
|
1160
|
+
async function buildNodeSnapshot(adapter, options) {
|
|
1161
|
+
const detail = await runPromise(aggregateNodeDetailEffect(adapter, {
|
|
1162
|
+
runId: options.runId,
|
|
1163
|
+
nodeId: options.nodeId,
|
|
1164
|
+
iteration: options.iteration,
|
|
1165
|
+
}));
|
|
1166
|
+
const run = await adapter.getRun(options.runId);
|
|
1167
|
+
return {
|
|
1168
|
+
detail,
|
|
1169
|
+
status: run?.status,
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1172
|
+
// ---------------------------------------------------------------------------
|
|
1173
|
+
// Schemas
|
|
1174
|
+
// ---------------------------------------------------------------------------
|
|
1175
|
+
const workflowArgs = z.object({
|
|
1176
|
+
workflow: z.string().describe("Path to a .tsx workflow file"),
|
|
1177
|
+
});
|
|
1178
|
+
const upOptions = z.object({
|
|
1179
|
+
detach: z.boolean().default(false).describe("Run in background, print run ID, exit"),
|
|
1180
|
+
runId: z.string().optional().describe("Explicit run ID"),
|
|
1181
|
+
maxConcurrency: z.number().int().min(1).optional().describe("Maximum parallel tasks (default: 4)"),
|
|
1182
|
+
root: z.string().optional().describe("Tool sandbox root directory"),
|
|
1183
|
+
log: z.boolean().default(true).describe("Enable NDJSON event log file output"),
|
|
1184
|
+
logDir: z.string().optional().describe("NDJSON event logs directory"),
|
|
1185
|
+
allowNetwork: z.boolean().default(false).describe("Allow bash tool network requests"),
|
|
1186
|
+
maxOutputBytes: z.number().int().min(1).optional().describe("Max bytes a single tool call can return"),
|
|
1187
|
+
toolTimeoutMs: z.number().int().min(1).optional().describe("Max wall-clock time per tool call in ms"),
|
|
1188
|
+
hot: z.boolean().default(false).describe("Enable hot module replacement for .tsx workflows"),
|
|
1189
|
+
input: z.string().optional().describe("Input data as JSON string"),
|
|
1190
|
+
resume: z.union([z.boolean(), z.string()]).default(false).describe("Resume a previous run. Pass true with --run-id, or pass the run ID directly (e.g. --resume <run-id>)"),
|
|
1191
|
+
force: z.boolean().default(false).describe("Resume even if still marked running"),
|
|
1192
|
+
resumeClaimOwner: z.string().optional().describe("Internal durable resume claim owner"),
|
|
1193
|
+
resumeClaimHeartbeat: z.number().int().min(1).optional().describe("Internal durable resume claim heartbeat"),
|
|
1194
|
+
resumeRestoreOwner: z.string().optional().describe("Internal durable resume restore owner"),
|
|
1195
|
+
resumeRestoreHeartbeat: z.number().int().min(1).optional().describe("Internal durable resume restore heartbeat"),
|
|
1196
|
+
serve: z.boolean().default(false).describe("Start an HTTP server alongside the workflow"),
|
|
1197
|
+
supervise: z.boolean().default(false).describe("Run the stale-run supervisor loop (with --serve)"),
|
|
1198
|
+
superviseDryRun: z.boolean().default(false).describe("With --supervise, detect stale runs without resuming"),
|
|
1199
|
+
superviseInterval: z.string().default("10s").describe("With --supervise, poll interval (e.g. 10s, 30s)"),
|
|
1200
|
+
superviseStaleThreshold: z.string().default("30s").describe("With --supervise, stale heartbeat threshold"),
|
|
1201
|
+
superviseMaxConcurrent: z.number().int().min(1).default(3).describe("With --supervise, max runs resumed per poll"),
|
|
1202
|
+
port: z.number().int().min(1).default(7331).describe("HTTP server port (with --serve)"),
|
|
1203
|
+
host: z.string().default("127.0.0.1").describe("HTTP server bind address (with --serve)"),
|
|
1204
|
+
authToken: z.string().optional().describe("Bearer token for HTTP auth (or set SMITHERS_API_KEY)"),
|
|
1205
|
+
metrics: z.boolean().default(true).describe("Expose /metrics endpoint (with --serve)"),
|
|
1206
|
+
});
|
|
1207
|
+
const superviseOptions = z.object({
|
|
1208
|
+
dryRun: z.boolean().default(false).describe("Show which stale runs would be resumed, without acting"),
|
|
1209
|
+
interval: z.string().default("10s").describe("Poll interval (e.g. 10s, 30s, 1m)"),
|
|
1210
|
+
staleThreshold: z.string().default("30s").describe("Heartbeat staleness threshold before resume"),
|
|
1211
|
+
maxConcurrent: z.number().int().min(1).default(3).describe("Max runs resumed per poll"),
|
|
1212
|
+
});
|
|
1213
|
+
const psOptions = z.object({
|
|
1214
|
+
status: z.string().optional().describe("Filter by status: running, waiting-approval, waiting-event, waiting-timer, continued, finished, failed, cancelled"),
|
|
1215
|
+
limit: z.number().int().min(1).default(20).describe("Maximum runs to return"),
|
|
1216
|
+
all: z.boolean().default(false).describe("Include all statuses"),
|
|
1217
|
+
watch: z.boolean().default(false).describe("Watch mode: refresh output continuously"),
|
|
1218
|
+
interval: z.number().positive().default(2).describe("Watch refresh interval in seconds"),
|
|
1219
|
+
});
|
|
1220
|
+
const logsOptions = z.object({
|
|
1221
|
+
follow: z.boolean().default(true).describe("Keep tailing (default true for active runs)"),
|
|
1222
|
+
since: z.number().int().optional().describe("Start from event sequence number"),
|
|
1223
|
+
tail: z.number().int().min(1).default(50).describe("Show last N events first"),
|
|
1224
|
+
followAncestry: z.boolean().default(false).describe("Include events from ancestor runs (continuation lineage)"),
|
|
1225
|
+
});
|
|
1226
|
+
const eventsOptions = z.object({
|
|
1227
|
+
node: z.string().optional().describe("Filter events by node ID"),
|
|
1228
|
+
type: z.string().optional().describe(`Filter by event category (${[...EVENT_CATEGORY_VALUES].sort().join(", ")})`),
|
|
1229
|
+
since: z.string().optional().describe("Filter to a recent duration window (e.g. 5m, 2h)"),
|
|
1230
|
+
limit: z.number().int().min(1).optional().describe("Maximum events to display (default 1000, max 100000)"),
|
|
1231
|
+
json: z.boolean().default(false).describe("Output NDJSON for piping"),
|
|
1232
|
+
groupBy: z.string().optional().describe("Group output by \"node\" or \"attempt\""),
|
|
1233
|
+
watch: z.boolean().default(false).describe("Watch mode: append new events as they arrive"),
|
|
1234
|
+
interval: z.number().positive().default(2).describe("Watch poll interval in seconds"),
|
|
1235
|
+
});
|
|
1236
|
+
const chatArgs = z.object({
|
|
1237
|
+
runId: z.string().optional().describe("Run ID to inspect (default: latest run)"),
|
|
1238
|
+
});
|
|
1239
|
+
const chatOptions = z.object({
|
|
1240
|
+
all: z.boolean().default(false).describe("Show all agent attempts in the run (default: latest only)"),
|
|
1241
|
+
follow: z.boolean().default(false).describe("Watch for new agent output"),
|
|
1242
|
+
tail: z.number().int().min(1).optional().describe("Show only the last N chat blocks"),
|
|
1243
|
+
stderr: z.boolean().default(true).describe("Include agent stderr output"),
|
|
1244
|
+
});
|
|
1245
|
+
const inspectArgs = z.object({
|
|
1246
|
+
runId: z.string().describe("Run ID to inspect"),
|
|
1247
|
+
});
|
|
1248
|
+
const inspectOptions = z.object({
|
|
1249
|
+
watch: z.boolean().default(false).describe("Watch mode: refresh output continuously"),
|
|
1250
|
+
interval: z.number().positive().default(2).describe("Watch refresh interval in seconds"),
|
|
1251
|
+
});
|
|
1252
|
+
const nodeArgs = z.object({
|
|
1253
|
+
nodeId: z.string().describe("Node ID to inspect"),
|
|
1254
|
+
});
|
|
1255
|
+
const nodeOptions = z.object({
|
|
1256
|
+
runId: z.string().describe("Run ID containing the node"),
|
|
1257
|
+
iteration: z.number().int().min(0).optional().describe("Loop iteration number (default: latest iteration)"),
|
|
1258
|
+
attempts: z.boolean().default(false).describe("Expand all attempts in human output"),
|
|
1259
|
+
tools: z.boolean().default(false).describe("Expand tool input/output payloads in human output"),
|
|
1260
|
+
watch: z.boolean().default(false).describe("Watch mode: refresh output continuously"),
|
|
1261
|
+
interval: z.number().positive().default(2).describe("Watch refresh interval in seconds"),
|
|
1262
|
+
});
|
|
1263
|
+
const whyArgs = z.object({
|
|
1264
|
+
runId: z.string().describe("Run ID to explain"),
|
|
1265
|
+
});
|
|
1266
|
+
const whyOptions = z.object({
|
|
1267
|
+
json: z.boolean().default(false).describe("Output structured JSON diagnosis"),
|
|
1268
|
+
});
|
|
1269
|
+
const approveArgs = z.object({
|
|
1270
|
+
runId: z.string().describe("Run ID containing the approval gate"),
|
|
1271
|
+
});
|
|
1272
|
+
const approveOptions = z.object({
|
|
1273
|
+
node: z.string().optional().describe("Node ID (required if multiple pending)"),
|
|
1274
|
+
iteration: z.number().int().min(0).default(0).describe("Loop iteration number"),
|
|
1275
|
+
note: z.string().optional().describe("Approval/denial note"),
|
|
1276
|
+
by: z.string().optional().describe("Name or identifier of the approver"),
|
|
1277
|
+
});
|
|
1278
|
+
const humanArgs = z.object({
|
|
1279
|
+
action: z.string().describe("Human request action: inbox, answer, or cancel"),
|
|
1280
|
+
requestId: z.string().optional().describe("Human request ID for answer/cancel"),
|
|
1281
|
+
});
|
|
1282
|
+
const humanOptions = z.object({
|
|
1283
|
+
value: z.string().optional().describe("JSON response for smithers human answer"),
|
|
1284
|
+
by: z.string().optional().describe("Name or identifier of the human operator"),
|
|
1285
|
+
});
|
|
1286
|
+
const alertsArgs = z.object({
|
|
1287
|
+
action: z.string().describe("Alert action: list, ack, resolve, or silence"),
|
|
1288
|
+
alertId: z.string().optional().describe("Alert ID for ack/resolve/silence"),
|
|
1289
|
+
});
|
|
1290
|
+
const alertsOptions = z.object({});
|
|
1291
|
+
const signalArgs = z.object({
|
|
1292
|
+
runId: z.string().describe("Run ID containing the waiting signal"),
|
|
1293
|
+
signalName: z.string().describe("Signal name to deliver"),
|
|
1294
|
+
});
|
|
1295
|
+
const signalOptions = z.object({
|
|
1296
|
+
data: z.string().optional().describe("Signal payload as JSON (default: {})"),
|
|
1297
|
+
correlation: z.string().optional().describe("Correlation ID to match a specific waiter"),
|
|
1298
|
+
by: z.string().optional().describe("Name or identifier of the signal sender"),
|
|
1299
|
+
});
|
|
1300
|
+
const cancelArgs = z.object({
|
|
1301
|
+
runId: z.string().describe("Run ID to cancel"),
|
|
1302
|
+
});
|
|
1303
|
+
const hijackArgs = z.object({
|
|
1304
|
+
runId: z.string().describe("Run ID whose latest agent session should be hijacked"),
|
|
1305
|
+
});
|
|
1306
|
+
const hijackOptions = z.object({
|
|
1307
|
+
target: z.string().optional().describe("Expected agent engine (claude-code or codex)"),
|
|
1308
|
+
timeoutMs: z.number().int().min(1).default(30_000).describe("How long to wait for a live run to hand off"),
|
|
1309
|
+
launch: z.boolean().default(true).describe("Open the hijacked session immediately"),
|
|
1310
|
+
});
|
|
1311
|
+
const graphOptions = z.object({
|
|
1312
|
+
runId: z.string().default("graph").describe("Run ID for context"),
|
|
1313
|
+
input: z.string().optional().describe("Input data as JSON"),
|
|
1314
|
+
});
|
|
1315
|
+
const revertOptions = z.object({
|
|
1316
|
+
runId: z.string().describe("Run ID to revert"),
|
|
1317
|
+
nodeId: z.string().describe("Node ID to revert to"),
|
|
1318
|
+
attempt: z.number().int().min(1).default(1).describe("Attempt number"),
|
|
1319
|
+
iteration: z.number().int().min(0).default(0).describe("Loop iteration number"),
|
|
1320
|
+
});
|
|
1321
|
+
const initOptions = z.object({
|
|
1322
|
+
force: z.boolean().default(false).describe("Overwrite existing scaffold files"),
|
|
1323
|
+
});
|
|
1324
|
+
const workflowPathArgs = z.object({
|
|
1325
|
+
name: z.string().describe("Workflow ID"),
|
|
1326
|
+
});
|
|
1327
|
+
const workflowDoctorArgs = z.object({
|
|
1328
|
+
name: z.string().optional().describe("Workflow ID"),
|
|
1329
|
+
});
|
|
1330
|
+
const workflowRunOptions = upOptions.extend({
|
|
1331
|
+
prompt: z.string().optional().describe("Prompt text mapped to input.prompt when --input is omitted"),
|
|
1332
|
+
});
|
|
1333
|
+
/**
|
|
1334
|
+
* @param {WorkflowRunCommandOptions} options
|
|
1335
|
+
* @returns {UpCommandOptions}
|
|
1336
|
+
*/
|
|
1337
|
+
function normalizeWorkflowRunOptions(options) {
|
|
1338
|
+
return {
|
|
1339
|
+
...options,
|
|
1340
|
+
input: options.input ??
|
|
1341
|
+
(options.prompt !== undefined
|
|
1342
|
+
? JSON.stringify({ prompt: options.prompt })
|
|
1343
|
+
: undefined),
|
|
1344
|
+
root: options.root ?? ".",
|
|
1345
|
+
};
|
|
1346
|
+
}
|
|
1347
|
+
/**
|
|
1348
|
+
* @param {string} intervalRaw
|
|
1349
|
+
* @param {string} staleThresholdRaw
|
|
1350
|
+
* @param {number} maxConcurrent
|
|
1351
|
+
* @param {boolean} dryRun
|
|
1352
|
+
*/
|
|
1353
|
+
function resolveSupervisorOptions(intervalRaw, staleThresholdRaw, maxConcurrent, dryRun) {
|
|
1354
|
+
const pollIntervalMs = parseDurationMs(intervalRaw, "interval");
|
|
1355
|
+
const staleThresholdMs = parseDurationMs(staleThresholdRaw, "stale-threshold");
|
|
1356
|
+
return {
|
|
1357
|
+
dryRun,
|
|
1358
|
+
pollIntervalMs,
|
|
1359
|
+
staleThresholdMs,
|
|
1360
|
+
maxConcurrent,
|
|
1361
|
+
};
|
|
1362
|
+
}
|
|
1363
|
+
/**
|
|
1364
|
+
* @param {EventsCommandOptions} options
|
|
1365
|
+
* @returns {NormalizedEventsQuery}
|
|
1366
|
+
*/
|
|
1367
|
+
function normalizeEventsQuery(options) {
|
|
1368
|
+
const jsonRequested = Boolean(options.json) || process.argv.includes("--json");
|
|
1369
|
+
const groupBy = normalizeEventGroupBy(options.groupBy);
|
|
1370
|
+
let typeName;
|
|
1371
|
+
let eventTypes;
|
|
1372
|
+
if (options.type) {
|
|
1373
|
+
const category = normalizeEventCategory(options.type);
|
|
1374
|
+
if (!category) {
|
|
1375
|
+
throw new SmithersError("INVALID_EVENT_TYPE_FILTER", `Invalid --type value "${options.type}". Allowed categories: ${[...EVENT_CATEGORY_VALUES].sort().join(", ")}`);
|
|
1376
|
+
}
|
|
1377
|
+
typeName = category;
|
|
1378
|
+
eventTypes = eventTypesForCategory(category);
|
|
1379
|
+
}
|
|
1380
|
+
let sinceTimestampMs;
|
|
1381
|
+
if (options.since) {
|
|
1382
|
+
const sinceDurationMs = parseDurationMs(options.since, "since");
|
|
1383
|
+
sinceTimestampMs = Date.now() - sinceDurationMs;
|
|
1384
|
+
}
|
|
1385
|
+
const limitInfo = normalizeEventsLimit(options.limit);
|
|
1386
|
+
return {
|
|
1387
|
+
nodeId: options.node,
|
|
1388
|
+
typeName,
|
|
1389
|
+
eventTypes,
|
|
1390
|
+
sinceTimestampMs,
|
|
1391
|
+
groupBy,
|
|
1392
|
+
json: jsonRequested,
|
|
1393
|
+
limit: limitInfo.value,
|
|
1394
|
+
defaultLimitUsed: limitInfo.defaultLimitUsed,
|
|
1395
|
+
limitCapped: limitInfo.limitCapped,
|
|
1396
|
+
};
|
|
1397
|
+
}
|
|
1398
|
+
/**
|
|
1399
|
+
* @param {{ ok: (...args: any[]) => any }} c
|
|
1400
|
+
* @param {string} workflowPath
|
|
1401
|
+
* @param {UpCommandOptions} options
|
|
1402
|
+
* @param {FailFn} fail
|
|
1403
|
+
*/
|
|
1404
|
+
async function executeUpCommand(c, workflowPath, options, fail) {
|
|
1405
|
+
try {
|
|
1406
|
+
const resolvedWorkflowPath = resolve(process.cwd(), workflowPath);
|
|
1407
|
+
const input = parseJsonInput(options.input, "input", fail) ?? {};
|
|
1408
|
+
const { resume, resumeRunId } = normalizeResumeOption(options.resume);
|
|
1409
|
+
const runId = options.runId ?? resumeRunId;
|
|
1410
|
+
// Detached mode: spawn ourselves as a background process
|
|
1411
|
+
if (options.detach) {
|
|
1412
|
+
const cliPath = new URL(import.meta.url).pathname;
|
|
1413
|
+
const childArgs = ["up", workflowPath];
|
|
1414
|
+
if (runId)
|
|
1415
|
+
childArgs.push("--run-id", runId);
|
|
1416
|
+
if (options.input)
|
|
1417
|
+
childArgs.push("--input", options.input);
|
|
1418
|
+
if (options.maxConcurrency)
|
|
1419
|
+
childArgs.push("--max-concurrency", String(options.maxConcurrency));
|
|
1420
|
+
if (options.root)
|
|
1421
|
+
childArgs.push("--root", options.root);
|
|
1422
|
+
if (!options.log)
|
|
1423
|
+
childArgs.push("--no-log");
|
|
1424
|
+
if (options.logDir)
|
|
1425
|
+
childArgs.push("--log-dir", options.logDir);
|
|
1426
|
+
if (options.allowNetwork)
|
|
1427
|
+
childArgs.push("--allow-network");
|
|
1428
|
+
if (options.maxOutputBytes)
|
|
1429
|
+
childArgs.push("--max-output-bytes", String(options.maxOutputBytes));
|
|
1430
|
+
if (options.toolTimeoutMs)
|
|
1431
|
+
childArgs.push("--tool-timeout-ms", String(options.toolTimeoutMs));
|
|
1432
|
+
if (options.hot)
|
|
1433
|
+
childArgs.push("--hot");
|
|
1434
|
+
if (resume)
|
|
1435
|
+
childArgs.push("--resume");
|
|
1436
|
+
if (options.force)
|
|
1437
|
+
childArgs.push("--force");
|
|
1438
|
+
if (options.resumeClaimOwner)
|
|
1439
|
+
childArgs.push("--resume-claim-owner", options.resumeClaimOwner);
|
|
1440
|
+
if (options.resumeClaimHeartbeat)
|
|
1441
|
+
childArgs.push("--resume-claim-heartbeat", String(options.resumeClaimHeartbeat));
|
|
1442
|
+
if (options.resumeRestoreOwner)
|
|
1443
|
+
childArgs.push("--resume-restore-owner", options.resumeRestoreOwner);
|
|
1444
|
+
if (options.resumeRestoreHeartbeat)
|
|
1445
|
+
childArgs.push("--resume-restore-heartbeat", String(options.resumeRestoreHeartbeat));
|
|
1446
|
+
if (options.serve)
|
|
1447
|
+
childArgs.push("--serve");
|
|
1448
|
+
if (options.supervise)
|
|
1449
|
+
childArgs.push("--supervise");
|
|
1450
|
+
if (options.superviseDryRun)
|
|
1451
|
+
childArgs.push("--supervise-dry-run");
|
|
1452
|
+
if (options.superviseInterval !== "10s")
|
|
1453
|
+
childArgs.push("--supervise-interval", options.superviseInterval);
|
|
1454
|
+
if (options.superviseStaleThreshold !== "30s")
|
|
1455
|
+
childArgs.push("--supervise-stale-threshold", options.superviseStaleThreshold);
|
|
1456
|
+
if (options.superviseMaxConcurrent !== 3)
|
|
1457
|
+
childArgs.push("--supervise-max-concurrent", String(options.superviseMaxConcurrent));
|
|
1458
|
+
if (options.serve && options.port !== 7331)
|
|
1459
|
+
childArgs.push("--port", String(options.port));
|
|
1460
|
+
if (options.serve && options.host !== "127.0.0.1")
|
|
1461
|
+
childArgs.push("--host", options.host);
|
|
1462
|
+
if (options.authToken)
|
|
1463
|
+
childArgs.push("--auth-token", options.authToken);
|
|
1464
|
+
if (options.serve && !options.metrics)
|
|
1465
|
+
childArgs.push("--metrics", "false");
|
|
1466
|
+
const logFileDir = options.logDir ?? dirname(resolvedWorkflowPath);
|
|
1467
|
+
const effectiveRunId = runId ?? `run-${Date.now()}`;
|
|
1468
|
+
const logFile = resolve(logFileDir, `${effectiveRunId}.log`);
|
|
1469
|
+
if (!runId)
|
|
1470
|
+
childArgs.push("--run-id", effectiveRunId);
|
|
1471
|
+
const fd = openSync(logFile, "a");
|
|
1472
|
+
const child = spawn("bun", [cliPath, ...childArgs], {
|
|
1473
|
+
detached: true,
|
|
1474
|
+
stdio: ["ignore", fd, fd],
|
|
1475
|
+
env: process.env,
|
|
1476
|
+
});
|
|
1477
|
+
child.unref();
|
|
1478
|
+
return c.ok({ runId: effectiveRunId, logFile, pid: child.pid }, {
|
|
1479
|
+
cta: {
|
|
1480
|
+
description: "Next steps:",
|
|
1481
|
+
commands: [
|
|
1482
|
+
{ command: `logs ${effectiveRunId}`, description: "Tail run logs" },
|
|
1483
|
+
{ command: `chat ${effectiveRunId} --follow`, description: "Watch agent chat" },
|
|
1484
|
+
{ command: `ps`, description: "List all runs" },
|
|
1485
|
+
{ command: `inspect ${effectiveRunId}`, description: "Inspect run state" },
|
|
1486
|
+
],
|
|
1487
|
+
},
|
|
1488
|
+
});
|
|
1489
|
+
}
|
|
1490
|
+
if (options.hot) {
|
|
1491
|
+
process.env.SMITHERS_HOT = "1";
|
|
1492
|
+
}
|
|
1493
|
+
if (options.supervise && !options.serve) {
|
|
1494
|
+
return fail({
|
|
1495
|
+
code: "SUPERVISE_REQUIRES_SERVE",
|
|
1496
|
+
message: "--supervise on `smithers up` requires --serve. Use `smithers supervise` for standalone mode.",
|
|
1497
|
+
exitCode: 4,
|
|
1498
|
+
});
|
|
1499
|
+
}
|
|
1500
|
+
const workflow = await loadWorkflow(workflowPath);
|
|
1501
|
+
ensureSmithersTables(workflow.db);
|
|
1502
|
+
if (options.hot) {
|
|
1503
|
+
process.stderr.write(`[hot] Hot reload enabled\n`);
|
|
1504
|
+
}
|
|
1505
|
+
setupSqliteCleanup(workflow);
|
|
1506
|
+
const adapter = new SmithersDb(workflow.db);
|
|
1507
|
+
if (!resume) {
|
|
1508
|
+
const staleRuns = await adapter.listRuns(10, "running");
|
|
1509
|
+
if (staleRuns.length > 0) {
|
|
1510
|
+
process.stderr.write(`⚠ Found ${staleRuns.length} run(s) still marked as 'running':\n`);
|
|
1511
|
+
for (const r of staleRuns) {
|
|
1512
|
+
process.stderr.write(` ${r.runId} (started ${new Date(r.startedAtMs ?? r.createdAtMs).toISOString()})\n`);
|
|
1513
|
+
}
|
|
1514
|
+
process.stderr.write(" Use 'smithers cancel' to mark them as cancelled, or 'smithers up --resume' to continue.\n");
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
if (runId) {
|
|
1518
|
+
const existing = await adapter.getRun(runId);
|
|
1519
|
+
if (resume && !existing) {
|
|
1520
|
+
return fail({ code: "RUN_NOT_FOUND", message: `Run not found: ${runId}`, exitCode: 4 });
|
|
1521
|
+
}
|
|
1522
|
+
if (resume && existing?.status === "running" && isRunHeartbeatFresh(existing) && !options.force) {
|
|
1523
|
+
return fail({ code: "RUN_STILL_RUNNING", message: `Run is still actively running: ${runId}. Use --force to resume anyway.`, exitCode: 4 });
|
|
1524
|
+
}
|
|
1525
|
+
if (!resume && existing) {
|
|
1526
|
+
return fail({ code: "RUN_EXISTS", message: `Run already exists: ${runId}`, exitCode: 4 });
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
const rootDir = options.root ? resolve(process.cwd(), options.root) : dirname(resolvedWorkflowPath);
|
|
1530
|
+
const logDir = options.log ? options.logDir : null;
|
|
1531
|
+
const onProgress = buildProgressReporter();
|
|
1532
|
+
const abort = setupAbortSignal();
|
|
1533
|
+
if (Boolean(options.resumeClaimOwner) !== Boolean(options.resumeClaimHeartbeat)) {
|
|
1534
|
+
return fail({
|
|
1535
|
+
code: "INVALID_RESUME_CLAIM",
|
|
1536
|
+
message: "--resume-claim-owner and --resume-claim-heartbeat must be provided together.",
|
|
1537
|
+
exitCode: 4,
|
|
1538
|
+
});
|
|
1539
|
+
}
|
|
1540
|
+
const resumeClaim = options.resumeClaimOwner && options.resumeClaimHeartbeat
|
|
1541
|
+
? {
|
|
1542
|
+
claimOwnerId: options.resumeClaimOwner,
|
|
1543
|
+
claimHeartbeatAtMs: options.resumeClaimHeartbeat,
|
|
1544
|
+
restoreRuntimeOwnerId: options.resumeRestoreOwner ?? null,
|
|
1545
|
+
restoreHeartbeatAtMs: options.resumeRestoreHeartbeat ?? null,
|
|
1546
|
+
}
|
|
1547
|
+
: undefined;
|
|
1548
|
+
if (options.serve) {
|
|
1549
|
+
let hostedSupervisor = null;
|
|
1550
|
+
if (options.supervise) {
|
|
1551
|
+
try {
|
|
1552
|
+
hostedSupervisor = resolveSupervisorOptions(options.superviseInterval, options.superviseStaleThreshold, options.superviseMaxConcurrent, options.superviseDryRun);
|
|
1553
|
+
}
|
|
1554
|
+
catch (error) {
|
|
1555
|
+
return fail({
|
|
1556
|
+
code: error instanceof SmithersError
|
|
1557
|
+
? error.code
|
|
1558
|
+
: "INVALID_SUPERVISOR_OPTIONS",
|
|
1559
|
+
message: error?.message ?? String(error),
|
|
1560
|
+
exitCode: 4,
|
|
1561
|
+
});
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
const { createServeApp } = await import("@smithers-orchestrator/server/serve");
|
|
1565
|
+
const effectiveRunId = runId ?? `run-${Date.now()}`;
|
|
1566
|
+
const serveApp = createServeApp({
|
|
1567
|
+
workflow: workflow,
|
|
1568
|
+
adapter: adapter,
|
|
1569
|
+
runId: effectiveRunId,
|
|
1570
|
+
abort,
|
|
1571
|
+
authToken: options.authToken ?? process.env.SMITHERS_API_KEY,
|
|
1572
|
+
metrics: options.metrics,
|
|
1573
|
+
});
|
|
1574
|
+
const bunServer = Bun.serve({
|
|
1575
|
+
port: options.port,
|
|
1576
|
+
hostname: options.host,
|
|
1577
|
+
fetch: serveApp.fetch,
|
|
1578
|
+
});
|
|
1579
|
+
process.stderr.write(`[smithers] HTTP server listening on http://${options.host}:${bunServer.port}\n`);
|
|
1580
|
+
const supervisorFiber = hostedSupervisor
|
|
1581
|
+
? runFork(supervisorLoopEffect({
|
|
1582
|
+
adapter,
|
|
1583
|
+
dryRun: hostedSupervisor.dryRun,
|
|
1584
|
+
pollIntervalMs: hostedSupervisor.pollIntervalMs,
|
|
1585
|
+
staleThresholdMs: hostedSupervisor.staleThresholdMs,
|
|
1586
|
+
maxConcurrent: hostedSupervisor.maxConcurrent,
|
|
1587
|
+
}))
|
|
1588
|
+
: null;
|
|
1589
|
+
if (hostedSupervisor) {
|
|
1590
|
+
process.stderr.write(`[smithers] Supervisor enabled (interval=${hostedSupervisor.pollIntervalMs}ms, staleThreshold=${hostedSupervisor.staleThresholdMs}ms, maxConcurrent=${hostedSupervisor.maxConcurrent}, dryRun=${hostedSupervisor.dryRun})\n`);
|
|
1591
|
+
}
|
|
1592
|
+
const workflowPromise = Effect.runPromise(runWorkflow(workflow, {
|
|
1593
|
+
input,
|
|
1594
|
+
runId: effectiveRunId,
|
|
1595
|
+
resume,
|
|
1596
|
+
resumeClaim,
|
|
1597
|
+
workflowPath: resolvedWorkflowPath,
|
|
1598
|
+
maxConcurrency: options.maxConcurrency,
|
|
1599
|
+
rootDir,
|
|
1600
|
+
logDir,
|
|
1601
|
+
allowNetwork: options.allowNetwork,
|
|
1602
|
+
maxOutputBytes: options.maxOutputBytes,
|
|
1603
|
+
toolTimeoutMs: options.toolTimeoutMs,
|
|
1604
|
+
hot: options.hot,
|
|
1605
|
+
onProgress,
|
|
1606
|
+
signal: abort.signal,
|
|
1607
|
+
}));
|
|
1608
|
+
workflowPromise.then((result) => {
|
|
1609
|
+
process.stderr.write(`[smithers] Workflow ${result.status}. Server still running — press Ctrl+C to stop.\n`);
|
|
1610
|
+
}).catch((err) => {
|
|
1611
|
+
process.stderr.write(`[smithers] Workflow error: ${err?.message ?? String(err)}. Server still running.\n`);
|
|
1612
|
+
});
|
|
1613
|
+
const result = await new Promise((resolvePromise) => {
|
|
1614
|
+
const shutdown = async () => {
|
|
1615
|
+
abort.abort();
|
|
1616
|
+
bunServer.stop(true);
|
|
1617
|
+
if (supervisorFiber) {
|
|
1618
|
+
await runPromise(Fiber.interrupt(supervisorFiber)).catch(() => undefined);
|
|
1619
|
+
}
|
|
1620
|
+
try {
|
|
1621
|
+
const r = await workflowPromise;
|
|
1622
|
+
resolvePromise(r);
|
|
1623
|
+
}
|
|
1624
|
+
catch {
|
|
1625
|
+
resolvePromise({ runId: effectiveRunId, status: "cancelled" });
|
|
1626
|
+
}
|
|
1627
|
+
};
|
|
1628
|
+
process.once("SIGINT", () => shutdown());
|
|
1629
|
+
process.once("SIGTERM", () => shutdown());
|
|
1630
|
+
});
|
|
1631
|
+
process.exitCode = formatStatusExitCode(result.status);
|
|
1632
|
+
return c.ok(result, {
|
|
1633
|
+
cta: result.runId ? {
|
|
1634
|
+
description: "Next steps:",
|
|
1635
|
+
commands: [
|
|
1636
|
+
...getWorkflowFollowUpCtas(workflowPath),
|
|
1637
|
+
{ command: `inspect ${result.runId}`, description: "Inspect run state" },
|
|
1638
|
+
{ command: `logs ${result.runId}`, description: "View run logs" },
|
|
1639
|
+
{ command: `chat ${result.runId}`, description: "View agent chat" },
|
|
1640
|
+
],
|
|
1641
|
+
} : undefined,
|
|
1642
|
+
});
|
|
1643
|
+
}
|
|
1644
|
+
const result = await Effect.runPromise(runWorkflow(workflow, {
|
|
1645
|
+
input,
|
|
1646
|
+
runId,
|
|
1647
|
+
resume,
|
|
1648
|
+
resumeClaim,
|
|
1649
|
+
workflowPath: resolvedWorkflowPath,
|
|
1650
|
+
maxConcurrency: options.maxConcurrency,
|
|
1651
|
+
rootDir,
|
|
1652
|
+
logDir,
|
|
1653
|
+
allowNetwork: options.allowNetwork,
|
|
1654
|
+
maxOutputBytes: options.maxOutputBytes,
|
|
1655
|
+
toolTimeoutMs: options.toolTimeoutMs,
|
|
1656
|
+
hot: options.hot,
|
|
1657
|
+
onProgress,
|
|
1658
|
+
signal: abort.signal,
|
|
1659
|
+
}));
|
|
1660
|
+
process.exitCode = formatStatusExitCode(result.status);
|
|
1661
|
+
return c.ok(result, {
|
|
1662
|
+
cta: result.runId ? {
|
|
1663
|
+
description: "Next steps:",
|
|
1664
|
+
commands: [
|
|
1665
|
+
...getWorkflowFollowUpCtas(workflowPath),
|
|
1666
|
+
{ command: `inspect ${result.runId}`, description: "Inspect run state" },
|
|
1667
|
+
{ command: `logs ${result.runId}`, description: "View run logs" },
|
|
1668
|
+
{ command: `chat ${result.runId}`, description: "View agent chat" },
|
|
1669
|
+
],
|
|
1670
|
+
} : undefined,
|
|
1671
|
+
});
|
|
1672
|
+
}
|
|
1673
|
+
catch (err) {
|
|
1674
|
+
return fail({ code: "RUN_FAILED", message: err?.message ?? String(err), exitCode: 1 });
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
const workflowCli = Cli.create({
|
|
1678
|
+
name: "workflow",
|
|
1679
|
+
description: "Discover local workflows from .smithers/workflows.",
|
|
1680
|
+
})
|
|
1681
|
+
.command("run", {
|
|
1682
|
+
description: "Run a discovered workflow by ID.",
|
|
1683
|
+
args: workflowPathArgs,
|
|
1684
|
+
options: workflowRunOptions,
|
|
1685
|
+
alias: { detach: "d", runId: "r", input: "i", maxConcurrency: "c", prompt: "p" },
|
|
1686
|
+
async run(c) {
|
|
1687
|
+
const fail = (opts) => {
|
|
1688
|
+
commandExitOverride = opts.exitCode ?? 1;
|
|
1689
|
+
return c.error(opts);
|
|
1690
|
+
};
|
|
1691
|
+
try {
|
|
1692
|
+
const workflow = resolveWorkflow(c.args.name, process.cwd());
|
|
1693
|
+
return executeUpCommand(c, workflow.entryFile, normalizeWorkflowRunOptions(c.options), fail);
|
|
1694
|
+
}
|
|
1695
|
+
catch (err) {
|
|
1696
|
+
if (err instanceof SmithersError) {
|
|
1697
|
+
return fail({
|
|
1698
|
+
code: err.code,
|
|
1699
|
+
message: err.message,
|
|
1700
|
+
exitCode: 4,
|
|
1701
|
+
});
|
|
1702
|
+
}
|
|
1703
|
+
return fail({
|
|
1704
|
+
code: "WORKFLOW_RUN_FAILED",
|
|
1705
|
+
message: err?.message ?? String(err),
|
|
1706
|
+
exitCode: 1,
|
|
1707
|
+
});
|
|
1708
|
+
}
|
|
1709
|
+
},
|
|
1710
|
+
})
|
|
1711
|
+
.command("list", {
|
|
1712
|
+
description: "List discovered local workflows.",
|
|
1713
|
+
run(c) {
|
|
1714
|
+
return c.ok({
|
|
1715
|
+
workflows: discoverWorkflows(process.cwd()),
|
|
1716
|
+
});
|
|
1717
|
+
},
|
|
1718
|
+
})
|
|
1719
|
+
.command("path", {
|
|
1720
|
+
description: "Resolve a workflow ID to its entry file.",
|
|
1721
|
+
args: workflowPathArgs,
|
|
1722
|
+
run(c) {
|
|
1723
|
+
const workflow = resolveWorkflow(c.args.name, process.cwd());
|
|
1724
|
+
return c.ok({
|
|
1725
|
+
id: workflow.id,
|
|
1726
|
+
path: workflow.entryFile,
|
|
1727
|
+
sourceType: workflow.sourceType,
|
|
1728
|
+
});
|
|
1729
|
+
},
|
|
1730
|
+
})
|
|
1731
|
+
.command("create", {
|
|
1732
|
+
description: "Create a new flat workflow scaffold in .smithers/workflows.",
|
|
1733
|
+
args: workflowPathArgs,
|
|
1734
|
+
run(c) {
|
|
1735
|
+
const fail = (opts) => {
|
|
1736
|
+
commandExitOverride = opts.exitCode ?? 1;
|
|
1737
|
+
return c.error(opts);
|
|
1738
|
+
};
|
|
1739
|
+
try {
|
|
1740
|
+
return c.ok(createWorkflowFile(c.args.name, process.cwd()));
|
|
1741
|
+
}
|
|
1742
|
+
catch (err) {
|
|
1743
|
+
if (err instanceof SmithersError) {
|
|
1744
|
+
return fail({
|
|
1745
|
+
code: err.code,
|
|
1746
|
+
message: err.message,
|
|
1747
|
+
exitCode: 4,
|
|
1748
|
+
});
|
|
1749
|
+
}
|
|
1750
|
+
return fail({
|
|
1751
|
+
code: "WORKFLOW_CREATE_FAILED",
|
|
1752
|
+
message: err?.message ?? String(err),
|
|
1753
|
+
exitCode: 1,
|
|
1754
|
+
});
|
|
1755
|
+
}
|
|
1756
|
+
},
|
|
1757
|
+
})
|
|
1758
|
+
.command("doctor", {
|
|
1759
|
+
description: "Inspect workflow discovery, preload files, and detected agents.",
|
|
1760
|
+
args: workflowDoctorArgs,
|
|
1761
|
+
run(c) {
|
|
1762
|
+
const workflows = c.args.name
|
|
1763
|
+
? [resolveWorkflow(c.args.name, process.cwd())]
|
|
1764
|
+
: discoverWorkflows(process.cwd());
|
|
1765
|
+
const workflowRoot = resolve(process.cwd(), ".smithers");
|
|
1766
|
+
return c.ok({
|
|
1767
|
+
workflowRoot,
|
|
1768
|
+
workflows,
|
|
1769
|
+
preload: {
|
|
1770
|
+
path: resolve(workflowRoot, "preload.ts"),
|
|
1771
|
+
exists: existsSync(resolve(workflowRoot, "preload.ts")),
|
|
1772
|
+
},
|
|
1773
|
+
bunfig: {
|
|
1774
|
+
path: resolve(workflowRoot, "bunfig.toml"),
|
|
1775
|
+
exists: existsSync(resolve(workflowRoot, "bunfig.toml")),
|
|
1776
|
+
},
|
|
1777
|
+
agents: detectAvailableAgents(),
|
|
1778
|
+
});
|
|
1779
|
+
},
|
|
1780
|
+
});
|
|
1781
|
+
const cronPathArgs = z.object({
|
|
1782
|
+
pattern: z.string().describe("Cron execution pattern (e.g. '0 * * * *')"),
|
|
1783
|
+
workflowPath: z.string().describe("Path or ID of the workflow to schedule"),
|
|
1784
|
+
});
|
|
1785
|
+
// ---------------------------------------------------------------------------
|
|
1786
|
+
// smithers memory ...
|
|
1787
|
+
// ---------------------------------------------------------------------------
|
|
1788
|
+
const memoryListArgs = z.object({
|
|
1789
|
+
namespace: z.string().describe("Namespace to list facts for (e.g. 'workflow:my-flow')"),
|
|
1790
|
+
});
|
|
1791
|
+
const memoryListOptions = z.object({
|
|
1792
|
+
workflow: z.string().describe("Path to a .tsx workflow file"),
|
|
1793
|
+
});
|
|
1794
|
+
const memoryCli = Cli.create({
|
|
1795
|
+
name: "memory",
|
|
1796
|
+
description: "View and query cross-run memory facts.",
|
|
1797
|
+
})
|
|
1798
|
+
.command("list", {
|
|
1799
|
+
description: "List all memory facts in a namespace.",
|
|
1800
|
+
args: memoryListArgs,
|
|
1801
|
+
options: memoryListOptions,
|
|
1802
|
+
alias: { workflow: "w" },
|
|
1803
|
+
async run(c) {
|
|
1804
|
+
try {
|
|
1805
|
+
const { createMemoryStore } = await import("@smithers-orchestrator/memory/store");
|
|
1806
|
+
const { parseNamespace } = await import("@smithers-orchestrator/memory/types");
|
|
1807
|
+
const workflow = await loadWorkflowAsync(c.options.workflow);
|
|
1808
|
+
ensureSmithersTables(workflow.db);
|
|
1809
|
+
setupSqliteCleanup(workflow);
|
|
1810
|
+
const store = createMemoryStore(workflow.db);
|
|
1811
|
+
const ns = parseNamespace(c.args.namespace);
|
|
1812
|
+
const facts = await store.listFacts(ns);
|
|
1813
|
+
if (facts.length === 0) {
|
|
1814
|
+
console.log(`No facts found in namespace "${c.args.namespace}".`);
|
|
1815
|
+
return c.ok({ facts: [], namespace: c.args.namespace });
|
|
1816
|
+
}
|
|
1817
|
+
for (const f of facts) {
|
|
1818
|
+
const value = f.valueJson.length > 100 ? f.valueJson.slice(0, 100) + "..." : f.valueJson;
|
|
1819
|
+
const age = formatAge(f.updatedAtMs);
|
|
1820
|
+
console.log(` ${pc.bold(f.key)} = ${value} ${pc.dim(`(${age})`)}`);
|
|
1821
|
+
}
|
|
1822
|
+
return c.ok({ facts, namespace: c.args.namespace });
|
|
1823
|
+
}
|
|
1824
|
+
catch (err) {
|
|
1825
|
+
console.error(`Error: ${err?.message ?? String(err)}`);
|
|
1826
|
+
return c.error({ code: "MEMORY_LIST_FAILED", message: err?.message ?? String(err) });
|
|
1827
|
+
}
|
|
1828
|
+
},
|
|
1829
|
+
});
|
|
1830
|
+
const cronCli = Cli.create({
|
|
1831
|
+
name: "cron",
|
|
1832
|
+
description: "Manage and run background schedule triggers.",
|
|
1833
|
+
})
|
|
1834
|
+
.command("start", {
|
|
1835
|
+
description: "Start the background scheduler loop in the current terminal.",
|
|
1836
|
+
async run(c) {
|
|
1837
|
+
await runScheduler();
|
|
1838
|
+
return c.ok({ status: "running" });
|
|
1839
|
+
},
|
|
1840
|
+
})
|
|
1841
|
+
.command("add", {
|
|
1842
|
+
description: "Register a new workflow cron schedule.",
|
|
1843
|
+
args: cronPathArgs,
|
|
1844
|
+
async run(c) {
|
|
1845
|
+
const { adapter, cleanup } = await findAndOpenDb();
|
|
1846
|
+
try {
|
|
1847
|
+
const cronId = crypto.randomUUID();
|
|
1848
|
+
await adapter.upsertCron({
|
|
1849
|
+
cronId,
|
|
1850
|
+
pattern: c.args.pattern,
|
|
1851
|
+
workflowPath: c.args.workflowPath,
|
|
1852
|
+
enabled: true,
|
|
1853
|
+
createdAtMs: Date.now(),
|
|
1854
|
+
lastRunAtMs: null,
|
|
1855
|
+
nextRunAtMs: null,
|
|
1856
|
+
errorJson: null,
|
|
1857
|
+
});
|
|
1858
|
+
console.log(`[+] Scheduled ${c.args.workflowPath} with pattern '${c.args.pattern}'`);
|
|
1859
|
+
return c.ok({ cronId, pattern: c.args.pattern, workflowPath: c.args.workflowPath });
|
|
1860
|
+
}
|
|
1861
|
+
finally {
|
|
1862
|
+
cleanup();
|
|
1863
|
+
}
|
|
1864
|
+
},
|
|
1865
|
+
})
|
|
1866
|
+
.command("list", {
|
|
1867
|
+
description: "List all registered background cron schedules.",
|
|
1868
|
+
async run(c) {
|
|
1869
|
+
const { adapter, cleanup } = await findAndOpenDb();
|
|
1870
|
+
try {
|
|
1871
|
+
const crons = await adapter.listCrons(false);
|
|
1872
|
+
return c.ok({ crons });
|
|
1873
|
+
}
|
|
1874
|
+
finally {
|
|
1875
|
+
cleanup();
|
|
1876
|
+
}
|
|
1877
|
+
},
|
|
1878
|
+
})
|
|
1879
|
+
.command("rm", {
|
|
1880
|
+
description: "Delete an existing cron schedule by ID.",
|
|
1881
|
+
args: z.object({ cronId: z.string().describe("Cron ID to delete") }),
|
|
1882
|
+
async run(c) {
|
|
1883
|
+
const { adapter, cleanup } = await findAndOpenDb();
|
|
1884
|
+
try {
|
|
1885
|
+
await adapter.deleteCron(c.args.cronId);
|
|
1886
|
+
console.log(`[-] Deleted cron ${c.args.cronId}`);
|
|
1887
|
+
return c.ok({ deleted: c.args.cronId });
|
|
1888
|
+
}
|
|
1889
|
+
finally {
|
|
1890
|
+
cleanup();
|
|
1891
|
+
}
|
|
1892
|
+
},
|
|
1893
|
+
});
|
|
1894
|
+
const agentsCli = Cli.create({
|
|
1895
|
+
name: "agents",
|
|
1896
|
+
description: "Inspect built-in CLI agent capability registries.",
|
|
1897
|
+
})
|
|
1898
|
+
.command("capabilities", {
|
|
1899
|
+
description: "Print a JSON report of the built-in CLI agent capability registries.",
|
|
1900
|
+
run(c) {
|
|
1901
|
+
process.stdout.write(`${JSON.stringify(getCliAgentCapabilityReport(), null, 2)}\n`);
|
|
1902
|
+
return c.ok(undefined);
|
|
1903
|
+
},
|
|
1904
|
+
})
|
|
1905
|
+
.command("doctor", {
|
|
1906
|
+
description: "Validate built-in CLI agent capability registries for drift or contradictions.",
|
|
1907
|
+
options: z.object({
|
|
1908
|
+
json: z.boolean().default(false).describe("Print the doctor report as JSON"),
|
|
1909
|
+
}),
|
|
1910
|
+
run(c) {
|
|
1911
|
+
const report = getCliAgentCapabilityDoctorReport();
|
|
1912
|
+
commandExitOverride = report.ok ? 0 : 1;
|
|
1913
|
+
if (c.options.json) {
|
|
1914
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
1915
|
+
}
|
|
1916
|
+
else {
|
|
1917
|
+
process.stdout.write(`${formatCliAgentCapabilityDoctorReport(report)}\n`);
|
|
1918
|
+
}
|
|
1919
|
+
return c.ok(undefined);
|
|
1920
|
+
},
|
|
1921
|
+
});
|
|
1922
|
+
// ---------------------------------------------------------------------------
|
|
1923
|
+
// OpenAPI subcommand
|
|
1924
|
+
// ---------------------------------------------------------------------------
|
|
1925
|
+
const openapiListArgs = z.object({
|
|
1926
|
+
specPath: z.string().describe("Path or URL to an OpenAPI spec"),
|
|
1927
|
+
});
|
|
1928
|
+
const openapiCli = Cli.create({
|
|
1929
|
+
name: "openapi",
|
|
1930
|
+
description: "Generate AI SDK tools from OpenAPI specs.",
|
|
1931
|
+
})
|
|
1932
|
+
.command("list", {
|
|
1933
|
+
description: "Preview tools that would be generated from an OpenAPI spec.",
|
|
1934
|
+
args: openapiListArgs,
|
|
1935
|
+
async run(c) {
|
|
1936
|
+
try {
|
|
1937
|
+
const { listOperations } = await import("@smithers-orchestrator/openapi/tool-factory");
|
|
1938
|
+
const ops = listOperations(c.args.specPath);
|
|
1939
|
+
if (ops.length === 0) {
|
|
1940
|
+
console.log(" No operations found in spec.");
|
|
1941
|
+
return c.ok({ operations: [] });
|
|
1942
|
+
}
|
|
1943
|
+
for (const op of ops) {
|
|
1944
|
+
console.log(` ${pc.bold(op.operationId)} — ${op.summary || `${op.method} ${op.path}`}`);
|
|
1945
|
+
}
|
|
1946
|
+
console.log(`\n ${ops.length} tool(s) from spec`);
|
|
1947
|
+
return c.ok({ operations: ops });
|
|
1948
|
+
}
|
|
1949
|
+
catch (err) {
|
|
1950
|
+
console.error(`Error: ${err?.message ?? String(err)}`);
|
|
1951
|
+
return c.error({ code: "OPENAPI_LIST_FAILED", message: err?.message ?? String(err) });
|
|
1952
|
+
}
|
|
1953
|
+
},
|
|
1954
|
+
});
|
|
1955
|
+
// ---------------------------------------------------------------------------
|
|
1956
|
+
// DevTools live-run commands (tree / diff / output / rewind)
|
|
1957
|
+
// ---------------------------------------------------------------------------
|
|
1958
|
+
|
|
1959
|
+
/**
|
|
1960
|
+
* The four commands added by ticket 0014. Used by:
|
|
1961
|
+
* - `rewriteDevtoolsJsonFlagArgv` to route `--json` to the command option
|
|
1962
|
+
* instead of incur's global `--format json` handling.
|
|
1963
|
+
* - `validateDevtoolsArgv` to emit usage-on-stderr + exit 1 on missing
|
|
1964
|
+
* args / invalid flags (finding #1).
|
|
1965
|
+
* - `mapDevtoolsExitCode` to keep exit 1 rather than the generic 4
|
|
1966
|
+
* remap in `main()`.
|
|
1967
|
+
*/
|
|
1968
|
+
const DEVTOOLS_COMMANDS = new Set(["tree", "diff", "output", "rewind"]);
|
|
1969
|
+
|
|
1970
|
+
/**
|
|
1971
|
+
* Stashed during telemetry so `main()` can preserve the typed exit code
|
|
1972
|
+
* out of the helper-level errors (rather than incur's generic "exit 4 on
|
|
1973
|
+
* validation failure"). Also consulted by `mapDevtoolsExitCode`.
|
|
1974
|
+
* @type {{ cmd: string; exitCode: number } | undefined}
|
|
1975
|
+
*/
|
|
1976
|
+
let lastDevtoolsCommandOutcome;
|
|
1977
|
+
|
|
1978
|
+
/**
|
|
1979
|
+
* Wrap the inner handler of a devtools command in structured telemetry.
|
|
1980
|
+
*
|
|
1981
|
+
* - Writes a JSON line to stderr when `SMITHERS_LOG_JSON=1` is set
|
|
1982
|
+
* containing `{ cmd, runId, flags, durationMs, exitCode }`.
|
|
1983
|
+
* - Emits an `smithers_cli_command_total{cmd,exit}` counter and a
|
|
1984
|
+
* `smithers_cli_command_duration_ms{cmd}` histogram via the
|
|
1985
|
+
* observability package.
|
|
1986
|
+
*
|
|
1987
|
+
* The inner handler returns the *resolved* exit code from the helper
|
|
1988
|
+
* (tree/diff/output/rewind). We never call `c.error()` here because
|
|
1989
|
+
* that would emit a second envelope on stdout in addition to the
|
|
1990
|
+
* friendly typed error the helper already wrote to stderr (finding #2).
|
|
1991
|
+
*
|
|
1992
|
+
* @param {"tree"|"diff"|"output"|"rewind"} cmd
|
|
1993
|
+
* @param {{ args: any; options: any; ok: (d?: unknown) => unknown }} c
|
|
1994
|
+
* @param {() => Promise<number>} handler
|
|
1995
|
+
*/
|
|
1996
|
+
async function runDevtoolsCommandWithTelemetry(cmd, c, handler) {
|
|
1997
|
+
const startedAt = Date.now();
|
|
1998
|
+
let exitCode = 0;
|
|
1999
|
+
try {
|
|
2000
|
+
exitCode = await handler();
|
|
2001
|
+
}
|
|
2002
|
+
catch (err) {
|
|
2003
|
+
// Unexpected handler-level throws bubble up to a server-error
|
|
2004
|
+
// exit with a friendly stderr message and no stdout envelope.
|
|
2005
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2006
|
+
process.stderr.write(`error: ${cmd} failed: ${message}\n`);
|
|
2007
|
+
exitCode = 2;
|
|
2008
|
+
}
|
|
2009
|
+
const durationMs = Date.now() - startedAt;
|
|
2010
|
+
commandExitOverride = exitCode;
|
|
2011
|
+
lastDevtoolsCommandOutcome = { cmd, exitCode };
|
|
2012
|
+
// Finding #11: structured command log + metrics.
|
|
2013
|
+
if (process.env.SMITHERS_LOG_JSON === "1") {
|
|
2014
|
+
try {
|
|
2015
|
+
const runId = typeof c.args?.runId === "string" ? c.args.runId : undefined;
|
|
2016
|
+
const flags = c.options ?? {};
|
|
2017
|
+
const line = JSON.stringify({
|
|
2018
|
+
level: "info",
|
|
2019
|
+
cmd,
|
|
2020
|
+
runId,
|
|
2021
|
+
flags,
|
|
2022
|
+
durationMs,
|
|
2023
|
+
exitCode,
|
|
2024
|
+
});
|
|
2025
|
+
process.stderr.write(`${line}\n`);
|
|
2026
|
+
}
|
|
2027
|
+
catch {
|
|
2028
|
+
// logging is best-effort.
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
// Metrics: emit a compact metric line to stderr under the same env gate
|
|
2032
|
+
// so test/ops tooling can scrape { counter, histogram } without
|
|
2033
|
+
// depending on an OTel exporter. Real OTel wiring is inherited from
|
|
2034
|
+
// the runtime's existing exporter path (ticket §Observability).
|
|
2035
|
+
if (process.env.SMITHERS_LOG_JSON === "1") {
|
|
2036
|
+
try {
|
|
2037
|
+
const counter = JSON.stringify({
|
|
2038
|
+
metric: "smithers_cli_command_total",
|
|
2039
|
+
labels: { cmd, exit: String(exitCode) },
|
|
2040
|
+
value: 1,
|
|
2041
|
+
});
|
|
2042
|
+
const histogram = JSON.stringify({
|
|
2043
|
+
metric: "smithers_cli_command_duration_ms",
|
|
2044
|
+
labels: { cmd },
|
|
2045
|
+
value: durationMs,
|
|
2046
|
+
});
|
|
2047
|
+
process.stderr.write(`${counter}\n`);
|
|
2048
|
+
process.stderr.write(`${histogram}\n`);
|
|
2049
|
+
}
|
|
2050
|
+
catch {
|
|
2051
|
+
// best-effort metrics.
|
|
2052
|
+
}
|
|
2053
|
+
}
|
|
2054
|
+
// Return c.ok(undefined) so incur does not emit an additional
|
|
2055
|
+
// envelope on stdout (finding #2).
|
|
2056
|
+
return c.ok(undefined);
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
/**
|
|
2060
|
+
* Rewrite raw `--json` to `-j` for devtools commands so it lands as a
|
|
2061
|
+
* command-scoped boolean option (finding #3). Without this, incur's
|
|
2062
|
+
* global `--json` flag promotes stdout formatting to JSON and our
|
|
2063
|
+
* command option stays false.
|
|
2064
|
+
*
|
|
2065
|
+
* @param {string[]} argv
|
|
2066
|
+
* @returns {string[]}
|
|
2067
|
+
*/
|
|
2068
|
+
function rewriteDevtoolsJsonFlagArgv(argv) {
|
|
2069
|
+
const commandIndex = findFirstPositionalIndex(argv);
|
|
2070
|
+
if (commandIndex < 0) return argv;
|
|
2071
|
+
const cmd = argv[commandIndex];
|
|
2072
|
+
if (!DEVTOOLS_COMMANDS.has(cmd)) return argv;
|
|
2073
|
+
// Only rewrite tokens after the command positional.
|
|
2074
|
+
return argv.map((arg, idx) => (idx > commandIndex && arg === "--json" ? "-j" : arg));
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
/**
|
|
2078
|
+
* Pre-validate argv for devtools commands (finding #1).
|
|
2079
|
+
*
|
|
2080
|
+
* When the user omits required positional args or passes an invalid
|
|
2081
|
+
* flag value, incur's default path writes a VALIDATION_ERROR envelope
|
|
2082
|
+
* to *stdout* and exits 1 — which `main()` then remaps to exit 4.
|
|
2083
|
+
* For these four commands the ticket requires:
|
|
2084
|
+
* - missing args / invalid flag → exit 1
|
|
2085
|
+
* - usage message on stderr only, stdout empty
|
|
2086
|
+
*
|
|
2087
|
+
* Returning `{ handled: true }` signals to `main()` that the process
|
|
2088
|
+
* already exited via this path.
|
|
2089
|
+
*
|
|
2090
|
+
* @param {string[]} argv
|
|
2091
|
+
* @returns {{ handled: boolean }}
|
|
2092
|
+
*/
|
|
2093
|
+
function validateDevtoolsArgv(argv) {
|
|
2094
|
+
const commandIndex = findFirstPositionalIndex(argv);
|
|
2095
|
+
if (commandIndex < 0) return { handled: false };
|
|
2096
|
+
const cmd = argv[commandIndex];
|
|
2097
|
+
if (!DEVTOOLS_COMMANDS.has(cmd)) return { handled: false };
|
|
2098
|
+
// If `--help` is present, let incur render help (no error).
|
|
2099
|
+
if (argv.includes("--help") || argv.includes("-h")) return { handled: false };
|
|
2100
|
+
const rest = argv.slice(commandIndex + 1);
|
|
2101
|
+
const positionals = [];
|
|
2102
|
+
const flags = new Map();
|
|
2103
|
+
for (let idx = 0; idx < rest.length; idx++) {
|
|
2104
|
+
const token = rest[idx];
|
|
2105
|
+
if (!token.startsWith("-")) {
|
|
2106
|
+
positionals.push(token);
|
|
2107
|
+
continue;
|
|
2108
|
+
}
|
|
2109
|
+
let key = token;
|
|
2110
|
+
/** @type {string | undefined} */
|
|
2111
|
+
let value;
|
|
2112
|
+
const eq = token.indexOf("=");
|
|
2113
|
+
if (token.startsWith("--") && eq !== -1) {
|
|
2114
|
+
key = token.slice(0, eq);
|
|
2115
|
+
value = token.slice(eq + 1);
|
|
2116
|
+
}
|
|
2117
|
+
else if (token.startsWith("--") && idx + 1 < rest.length && !rest[idx + 1].startsWith("-")) {
|
|
2118
|
+
// Peek-ahead for long-form flag values (not robust for boolean flags
|
|
2119
|
+
// that shouldn't consume; we only validate specific values below).
|
|
2120
|
+
value = rest[idx + 1];
|
|
2121
|
+
}
|
|
2122
|
+
flags.set(key, value);
|
|
2123
|
+
}
|
|
2124
|
+
const required = cmd === "diff" || cmd === "output" ? 2 : 1;
|
|
2125
|
+
const usage = devtoolsUsage(cmd);
|
|
2126
|
+
if (positionals.length < required) {
|
|
2127
|
+
process.stderr.write(`error: missing required argument${required - positionals.length === 1 ? "" : "s"} for \`smithers ${cmd}\`\n`);
|
|
2128
|
+
process.stderr.write(`${usage}\n`);
|
|
2129
|
+
process.exit(1);
|
|
2130
|
+
}
|
|
2131
|
+
// Validate --color enum.
|
|
2132
|
+
if ((cmd === "tree" || cmd === "diff") && flags.has("--color")) {
|
|
2133
|
+
const val = flags.get("--color");
|
|
2134
|
+
if (val !== "auto" && val !== "always" && val !== "never") {
|
|
2135
|
+
process.stderr.write(`error: invalid value for --color: ${val ?? "(missing)"}\n`);
|
|
2136
|
+
process.stderr.write(`expected one of: auto, always, never\n`);
|
|
2137
|
+
process.stderr.write(`${usage}\n`);
|
|
2138
|
+
process.exit(1);
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
2141
|
+
// Validate non-negative-integer flags.
|
|
2142
|
+
const intFlags = cmd === "tree"
|
|
2143
|
+
? ["--frame", "--depth"]
|
|
2144
|
+
: (cmd === "diff" || cmd === "output"
|
|
2145
|
+
? ["--iteration"]
|
|
2146
|
+
: cmd === "rewind"
|
|
2147
|
+
? []
|
|
2148
|
+
: []);
|
|
2149
|
+
for (const flag of intFlags) {
|
|
2150
|
+
if (!flags.has(flag)) continue;
|
|
2151
|
+
const raw = flags.get(flag);
|
|
2152
|
+
const num = Number(raw);
|
|
2153
|
+
if (!Number.isInteger(num) || num < 0) {
|
|
2154
|
+
process.stderr.write(`error: invalid value for ${flag}: ${raw ?? "(missing)"}\n`);
|
|
2155
|
+
process.stderr.write(`${flag} must be a non-negative integer\n`);
|
|
2156
|
+
process.stderr.write(`${usage}\n`);
|
|
2157
|
+
process.exit(1);
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
// For rewind, the second positional (frameNo) must be a non-negative
|
|
2161
|
+
// integer. rewind passes it as an arg, not a flag.
|
|
2162
|
+
if (cmd === "rewind" && positionals.length >= 2) {
|
|
2163
|
+
const frameRaw = positionals[1];
|
|
2164
|
+
const num = Number(frameRaw);
|
|
2165
|
+
if (!Number.isInteger(num) || num < 0) {
|
|
2166
|
+
process.stderr.write(`error: invalid value for <frameNo>: ${frameRaw}\n`);
|
|
2167
|
+
process.stderr.write(`frameNo must be a non-negative integer\n`);
|
|
2168
|
+
process.stderr.write(`${usage}\n`);
|
|
2169
|
+
process.exit(1);
|
|
2170
|
+
}
|
|
2171
|
+
}
|
|
2172
|
+
return { handled: false };
|
|
2173
|
+
}
|
|
2174
|
+
|
|
2175
|
+
/**
|
|
2176
|
+
* Stable usage strings matched to spec §Scope of ticket 0014. Kept
|
|
2177
|
+
* under 60 columns per the acceptance checklist so help / error output
|
|
2178
|
+
* wraps cleanly on narrow terminals (finding #7, partial).
|
|
2179
|
+
*
|
|
2180
|
+
* @param {string} cmd
|
|
2181
|
+
* @returns {string}
|
|
2182
|
+
*/
|
|
2183
|
+
function devtoolsUsage(cmd) {
|
|
2184
|
+
if (cmd === "tree") {
|
|
2185
|
+
return [
|
|
2186
|
+
"usage: smithers tree <runId> [options]",
|
|
2187
|
+
"",
|
|
2188
|
+
"Options:",
|
|
2189
|
+
" --frame <n> Historical frame number",
|
|
2190
|
+
" --watch Stream live devtools events",
|
|
2191
|
+
" --json Emit the raw snapshot JSON",
|
|
2192
|
+
" --depth <n> Truncate rendering at depth n",
|
|
2193
|
+
" --node <id> Scope output to a subtree",
|
|
2194
|
+
" --color <mode> auto | always | never",
|
|
2195
|
+
].join("\n");
|
|
2196
|
+
}
|
|
2197
|
+
if (cmd === "diff") {
|
|
2198
|
+
return [
|
|
2199
|
+
"usage: smithers diff <runId> <nodeId> [options]",
|
|
2200
|
+
"",
|
|
2201
|
+
"Options:",
|
|
2202
|
+
" --iteration <n> Loop iteration (default: latest)",
|
|
2203
|
+
" --json Emit the raw DiffBundle as JSON",
|
|
2204
|
+
" --stat Show a stat summary only",
|
|
2205
|
+
" --color <mode> auto | always | never",
|
|
2206
|
+
].join("\n");
|
|
2207
|
+
}
|
|
2208
|
+
if (cmd === "output") {
|
|
2209
|
+
return [
|
|
2210
|
+
"usage: smithers output <runId> <nodeId> [options]",
|
|
2211
|
+
"",
|
|
2212
|
+
"Options:",
|
|
2213
|
+
" --iteration <n> Loop iteration (default: latest)",
|
|
2214
|
+
" --json Emit the raw row as JSON (default)",
|
|
2215
|
+
" --pretty Schema-ordered render",
|
|
2216
|
+
].join("\n");
|
|
2217
|
+
}
|
|
2218
|
+
if (cmd === "rewind") {
|
|
2219
|
+
return [
|
|
2220
|
+
"usage: smithers rewind <runId> <frameNo> [options]",
|
|
2221
|
+
"",
|
|
2222
|
+
"Options:",
|
|
2223
|
+
" --yes Skip confirmation prompt",
|
|
2224
|
+
" --json Emit JumpResult as JSON",
|
|
2225
|
+
].join("\n");
|
|
2226
|
+
}
|
|
2227
|
+
return `usage: smithers ${cmd} ...`;
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2230
|
+
// ---------------------------------------------------------------------------
|
|
2231
|
+
// CLI
|
|
2232
|
+
// ---------------------------------------------------------------------------
|
|
2233
|
+
let commandExitOverride;
|
|
2234
|
+
const cli = Cli.create({
|
|
2235
|
+
name: "smithers",
|
|
2236
|
+
description: "Durable AI workflow orchestrator. Run, monitor, and manage workflow executions.",
|
|
2237
|
+
version: readPackageVersion(),
|
|
2238
|
+
})
|
|
2239
|
+
// =========================================================================
|
|
2240
|
+
// smithers init
|
|
2241
|
+
// =========================================================================
|
|
2242
|
+
.command("init", {
|
|
2243
|
+
description: "Install the local Smithers workflow pack into .smithers/.",
|
|
2244
|
+
options: initOptions,
|
|
2245
|
+
run(c) {
|
|
2246
|
+
const fail = (opts) => {
|
|
2247
|
+
commandExitOverride = opts.exitCode ?? 1;
|
|
2248
|
+
return c.error(opts);
|
|
2249
|
+
};
|
|
2250
|
+
try {
|
|
2251
|
+
const result = initWorkflowPack({ force: c.options.force });
|
|
2252
|
+
return c.ok(result, {
|
|
2253
|
+
cta: {
|
|
2254
|
+
description: "Next steps:",
|
|
2255
|
+
commands: c.agent
|
|
2256
|
+
? [
|
|
2257
|
+
{ command: "workflow list", description: "View all available workflows" },
|
|
2258
|
+
{ command: "bun install -g smithers", description: "Install smithers globally" },
|
|
2259
|
+
]
|
|
2260
|
+
: [
|
|
2261
|
+
{ command: "tui", description: "Open the interactive dashboard" },
|
|
2262
|
+
{ command: "bun install -g smithers", description: "Install smithers globally" },
|
|
2263
|
+
],
|
|
2264
|
+
},
|
|
2265
|
+
});
|
|
2266
|
+
}
|
|
2267
|
+
catch (err) {
|
|
2268
|
+
if (err instanceof SmithersError) {
|
|
2269
|
+
return fail({
|
|
2270
|
+
code: err.code,
|
|
2271
|
+
message: err.message,
|
|
2272
|
+
exitCode: 4,
|
|
2273
|
+
});
|
|
2274
|
+
}
|
|
2275
|
+
return fail({
|
|
2276
|
+
code: "INIT_FAILED",
|
|
2277
|
+
message: err?.message ?? String(err),
|
|
2278
|
+
exitCode: 1,
|
|
2279
|
+
});
|
|
2280
|
+
}
|
|
2281
|
+
},
|
|
2282
|
+
})
|
|
2283
|
+
// =========================================================================
|
|
2284
|
+
// smithers up [workflow]
|
|
2285
|
+
// =========================================================================
|
|
2286
|
+
.command("up", {
|
|
2287
|
+
description: "Start a workflow execution. Use -d for detached (background) mode.",
|
|
2288
|
+
args: workflowArgs,
|
|
2289
|
+
options: upOptions,
|
|
2290
|
+
alias: { detach: "d", runId: "r", input: "i", maxConcurrency: "c" },
|
|
2291
|
+
async run(c) {
|
|
2292
|
+
const fail = (opts) => {
|
|
2293
|
+
commandExitOverride = opts.exitCode ?? 1;
|
|
2294
|
+
return c.error(opts);
|
|
2295
|
+
};
|
|
2296
|
+
return executeUpCommand(c, c.args.workflow, c.options, fail);
|
|
2297
|
+
},
|
|
2298
|
+
})
|
|
2299
|
+
// =========================================================================
|
|
2300
|
+
// smithers supervise
|
|
2301
|
+
// =========================================================================
|
|
2302
|
+
.command("supervise", {
|
|
2303
|
+
description: "Watch for stale running runs and auto-resume them.",
|
|
2304
|
+
options: superviseOptions,
|
|
2305
|
+
alias: { dryRun: "n", interval: "i", staleThreshold: "t", maxConcurrent: "c" },
|
|
2306
|
+
async run(c) {
|
|
2307
|
+
const fail = (opts) => {
|
|
2308
|
+
commandExitOverride = opts.exitCode ?? 1;
|
|
2309
|
+
return c.error(opts);
|
|
2310
|
+
};
|
|
2311
|
+
let parsed;
|
|
2312
|
+
try {
|
|
2313
|
+
parsed = resolveSupervisorOptions(c.options.interval, c.options.staleThreshold, c.options.maxConcurrent, c.options.dryRun);
|
|
2314
|
+
}
|
|
2315
|
+
catch (error) {
|
|
2316
|
+
return fail({
|
|
2317
|
+
code: error instanceof SmithersError
|
|
2318
|
+
? error.code
|
|
2319
|
+
: "INVALID_SUPERVISOR_OPTIONS",
|
|
2320
|
+
message: error?.message ?? String(error),
|
|
2321
|
+
exitCode: 4,
|
|
2322
|
+
});
|
|
2323
|
+
}
|
|
2324
|
+
const { adapter, cleanup } = await findAndOpenDb();
|
|
2325
|
+
const abort = setupAbortSignal();
|
|
2326
|
+
process.stderr.write(`[smithers] Supervisor started (interval=${parsed.pollIntervalMs}ms, staleThreshold=${parsed.staleThresholdMs}ms, maxConcurrent=${parsed.maxConcurrent}, dryRun=${parsed.dryRun})\n`);
|
|
2327
|
+
try {
|
|
2328
|
+
await runPromise(supervisorLoopEffect({
|
|
2329
|
+
adapter,
|
|
2330
|
+
dryRun: parsed.dryRun,
|
|
2331
|
+
pollIntervalMs: parsed.pollIntervalMs,
|
|
2332
|
+
staleThresholdMs: parsed.staleThresholdMs,
|
|
2333
|
+
maxConcurrent: parsed.maxConcurrent,
|
|
2334
|
+
}), { signal: abort.signal });
|
|
2335
|
+
return c.ok({ status: "stopped" });
|
|
2336
|
+
}
|
|
2337
|
+
catch (error) {
|
|
2338
|
+
if (abort.signal.aborted) {
|
|
2339
|
+
return c.ok({ status: "stopped" });
|
|
2340
|
+
}
|
|
2341
|
+
return fail({
|
|
2342
|
+
code: "SUPERVISOR_FAILED",
|
|
2343
|
+
message: error?.message ?? String(error),
|
|
2344
|
+
exitCode: 1,
|
|
2345
|
+
});
|
|
2346
|
+
}
|
|
2347
|
+
finally {
|
|
2348
|
+
cleanup();
|
|
2349
|
+
}
|
|
2350
|
+
},
|
|
2351
|
+
})
|
|
2352
|
+
// =========================================================================
|
|
2353
|
+
// smithers tui
|
|
2354
|
+
// =========================================================================
|
|
2355
|
+
.command("tui", {
|
|
2356
|
+
description: "Open the interactive Smithers observability dashboard",
|
|
2357
|
+
async run(c) {
|
|
2358
|
+
const fail = (opts) => {
|
|
2359
|
+
commandExitOverride = opts.exitCode ?? 1;
|
|
2360
|
+
return c.error(opts);
|
|
2361
|
+
};
|
|
2362
|
+
let cleanup;
|
|
2363
|
+
let renderer;
|
|
2364
|
+
try {
|
|
2365
|
+
const db = await findAndOpenDb(undefined, {
|
|
2366
|
+
timeoutMs: 5000,
|
|
2367
|
+
intervalMs: 100,
|
|
2368
|
+
});
|
|
2369
|
+
const adapter = db.adapter;
|
|
2370
|
+
cleanup = db.cleanup;
|
|
2371
|
+
const { createCliRenderer } = await import("@opentui/core");
|
|
2372
|
+
const { createRoot } = await import("@opentui/react");
|
|
2373
|
+
const { TuiApp } = await import("./tui/app.jsx");
|
|
2374
|
+
const React = await import("react");
|
|
2375
|
+
renderer = await createCliRenderer({ exitOnCtrlC: false });
|
|
2376
|
+
const root = createRoot(renderer);
|
|
2377
|
+
await new Promise((resolve) => {
|
|
2378
|
+
root.render(React.createElement(TuiApp, {
|
|
2379
|
+
adapter,
|
|
2380
|
+
onExit: () => resolve(true),
|
|
2381
|
+
}));
|
|
2382
|
+
});
|
|
2383
|
+
return c.ok(undefined);
|
|
2384
|
+
}
|
|
2385
|
+
catch (err) {
|
|
2386
|
+
return fail({ code: "TUI_FAILED", message: err?.message ?? String(err), exitCode: 1 });
|
|
2387
|
+
}
|
|
2388
|
+
finally {
|
|
2389
|
+
if (renderer)
|
|
2390
|
+
renderer.destroy();
|
|
2391
|
+
cleanup?.();
|
|
2392
|
+
}
|
|
2393
|
+
}
|
|
2394
|
+
})
|
|
2395
|
+
// =========================================================================
|
|
2396
|
+
// smithers ps
|
|
2397
|
+
// =========================================================================
|
|
2398
|
+
.command("ps", {
|
|
2399
|
+
description: "List active, paused, and recently completed runs.",
|
|
2400
|
+
options: psOptions,
|
|
2401
|
+
alias: { status: "s", limit: "l", all: "a", watch: "w", interval: "i" },
|
|
2402
|
+
async run(c) {
|
|
2403
|
+
const fail = (opts) => {
|
|
2404
|
+
commandExitOverride = opts.exitCode ?? 1;
|
|
2405
|
+
return c.error(opts);
|
|
2406
|
+
};
|
|
2407
|
+
try {
|
|
2408
|
+
const { adapter, cleanup } = await findAndOpenDb();
|
|
2409
|
+
try {
|
|
2410
|
+
if (c.options.watch) {
|
|
2411
|
+
const intervalMs = resolveWatchIntervalMsOrFail("ps", c.options.interval, fail);
|
|
2412
|
+
const watchResult = await runPromise(Effect.tryPromise(() => runWatchLoop({
|
|
2413
|
+
intervalSeconds: c.options.interval,
|
|
2414
|
+
clearScreen: true,
|
|
2415
|
+
fetch: async () => ({
|
|
2416
|
+
runs: await buildPsRows(adapter, c.options.limit, c.options.status),
|
|
2417
|
+
}),
|
|
2418
|
+
render: async (snapshot) => {
|
|
2419
|
+
writeWatchOutput(c.format, snapshot);
|
|
2420
|
+
},
|
|
2421
|
+
})).pipe(Effect.tap((result) => Effect.logDebug("watch loop completed").pipe(Effect.annotateLogs({
|
|
2422
|
+
command: "ps",
|
|
2423
|
+
intervalMs,
|
|
2424
|
+
tickCount: result.tickCount,
|
|
2425
|
+
stoppedBySignal: result.stoppedBySignal,
|
|
2426
|
+
}))), Effect.annotateLogs({ command: "ps", intervalMs }), Effect.withLogSpan("cli:watch")));
|
|
2427
|
+
if (watchResult.stoppedBySignal) {
|
|
2428
|
+
process.exitCode = 0;
|
|
2429
|
+
}
|
|
2430
|
+
return c.ok(undefined);
|
|
2431
|
+
}
|
|
2432
|
+
const rows = await buildPsRows(adapter, c.options.limit, c.options.status);
|
|
2433
|
+
const ctaCommands = buildPsCtaCommands(rows);
|
|
2434
|
+
return c.ok({ runs: rows }, ctaCommands.length > 0 ? { cta: { commands: ctaCommands } } : undefined);
|
|
2435
|
+
}
|
|
2436
|
+
finally {
|
|
2437
|
+
cleanup();
|
|
2438
|
+
}
|
|
2439
|
+
}
|
|
2440
|
+
catch (err) {
|
|
2441
|
+
return fail({ code: "PS_FAILED", message: err?.message ?? String(err), exitCode: 1 });
|
|
2442
|
+
}
|
|
2443
|
+
},
|
|
2444
|
+
})
|
|
2445
|
+
// =========================================================================
|
|
2446
|
+
// smithers logs <run_id>
|
|
2447
|
+
// =========================================================================
|
|
2448
|
+
.command("logs", {
|
|
2449
|
+
description: "Tail the event log of a specific run.",
|
|
2450
|
+
args: z.object({ runId: z.string().describe("Run ID to tail") }),
|
|
2451
|
+
options: logsOptions,
|
|
2452
|
+
alias: { follow: "f", tail: "n" },
|
|
2453
|
+
async *run(c) {
|
|
2454
|
+
return yield* streamRunEventsCommand(c);
|
|
2455
|
+
},
|
|
2456
|
+
})
|
|
2457
|
+
// =========================================================================
|
|
2458
|
+
// smithers events <run_id>
|
|
2459
|
+
// =========================================================================
|
|
2460
|
+
.command("events", {
|
|
2461
|
+
description: "Query run event history with filters, grouping, and NDJSON output.",
|
|
2462
|
+
args: z.object({ runId: z.string().describe("Run ID to query") }),
|
|
2463
|
+
options: eventsOptions,
|
|
2464
|
+
alias: { node: "n", type: "t", since: "s", limit: "l", json: "j", watch: "w", interval: "i" },
|
|
2465
|
+
async *run(c) {
|
|
2466
|
+
const fail = (opts) => {
|
|
2467
|
+
commandExitOverride = opts.exitCode ?? 1;
|
|
2468
|
+
return c.error(opts);
|
|
2469
|
+
};
|
|
2470
|
+
let query;
|
|
2471
|
+
try {
|
|
2472
|
+
query = normalizeEventsQuery(c.options);
|
|
2473
|
+
}
|
|
2474
|
+
catch (error) {
|
|
2475
|
+
return fail({
|
|
2476
|
+
code: error instanceof SmithersError ? error.code : "INVALID_EVENTS_OPTIONS",
|
|
2477
|
+
message: error?.message ?? String(error),
|
|
2478
|
+
exitCode: 4,
|
|
2479
|
+
});
|
|
2480
|
+
}
|
|
2481
|
+
let cleanup;
|
|
2482
|
+
try {
|
|
2483
|
+
const db = await findAndOpenDb();
|
|
2484
|
+
const adapter = db.adapter;
|
|
2485
|
+
cleanup = db.cleanup;
|
|
2486
|
+
const run = await adapter.getRun(c.args.runId);
|
|
2487
|
+
if (!run) {
|
|
2488
|
+
return fail({
|
|
2489
|
+
code: "RUN_NOT_FOUND",
|
|
2490
|
+
message: `Run not found: ${c.args.runId}`,
|
|
2491
|
+
exitCode: 4,
|
|
2492
|
+
});
|
|
2493
|
+
}
|
|
2494
|
+
if (query.limitCapped) {
|
|
2495
|
+
process.stderr.write(`[smithers] --limit capped at ${MAX_EVENTS_LIMIT} events\n`);
|
|
2496
|
+
}
|
|
2497
|
+
let groupBy = query.groupBy;
|
|
2498
|
+
if (query.json && groupBy) {
|
|
2499
|
+
process.stderr.write("[smithers] --group-by is ignored when --json is enabled\n");
|
|
2500
|
+
groupBy = undefined;
|
|
2501
|
+
}
|
|
2502
|
+
if (c.options.watch && groupBy) {
|
|
2503
|
+
process.stderr.write("[smithers] --group-by is ignored when --watch is enabled\n");
|
|
2504
|
+
groupBy = undefined;
|
|
2505
|
+
}
|
|
2506
|
+
let watchIntervalMs;
|
|
2507
|
+
if (c.options.watch) {
|
|
2508
|
+
watchIntervalMs = resolveWatchIntervalMsOrFail("events", c.options.interval, fail);
|
|
2509
|
+
}
|
|
2510
|
+
const filters = {
|
|
2511
|
+
nodeId: query.nodeId,
|
|
2512
|
+
type: query.typeName,
|
|
2513
|
+
sinceTimestampMs: query.sinceTimestampMs,
|
|
2514
|
+
limit: query.limit,
|
|
2515
|
+
json: query.json,
|
|
2516
|
+
groupBy,
|
|
2517
|
+
watch: c.options.watch,
|
|
2518
|
+
};
|
|
2519
|
+
const baseMs = run.startedAtMs ??
|
|
2520
|
+
run.createdAtMs ??
|
|
2521
|
+
Date.now();
|
|
2522
|
+
const totalCount = query.defaultLimitUsed && !query.json
|
|
2523
|
+
? await countEventHistory(adapter, c.args.runId, {
|
|
2524
|
+
nodeId: query.nodeId,
|
|
2525
|
+
eventTypes: query.eventTypes,
|
|
2526
|
+
sinceTimestampMs: query.sinceTimestampMs,
|
|
2527
|
+
})
|
|
2528
|
+
: undefined;
|
|
2529
|
+
const groupedEvents = [];
|
|
2530
|
+
let emitted = 0;
|
|
2531
|
+
let lastSeq = -1;
|
|
2532
|
+
while (emitted < query.limit) {
|
|
2533
|
+
const pageLimit = Math.min(EVENTS_PAGE_SIZE, query.limit - emitted);
|
|
2534
|
+
const page = await queryEventHistoryPage(adapter, c.args.runId, {
|
|
2535
|
+
afterSeq: lastSeq,
|
|
2536
|
+
nodeId: query.nodeId,
|
|
2537
|
+
eventTypes: query.eventTypes,
|
|
2538
|
+
sinceTimestampMs: query.sinceTimestampMs,
|
|
2539
|
+
limit: pageLimit,
|
|
2540
|
+
});
|
|
2541
|
+
if (page.length === 0)
|
|
2542
|
+
break;
|
|
2543
|
+
for (const event of page) {
|
|
2544
|
+
lastSeq = event.seq;
|
|
2545
|
+
emitted += 1;
|
|
2546
|
+
if (groupBy) {
|
|
2547
|
+
groupedEvents.push(event);
|
|
2548
|
+
}
|
|
2549
|
+
else {
|
|
2550
|
+
if (query.json) {
|
|
2551
|
+
process.stdout.write(`${buildEventNdjsonLine(event)}\n`);
|
|
2552
|
+
}
|
|
2553
|
+
else {
|
|
2554
|
+
yield buildEventHistoryLine(event, baseMs);
|
|
2555
|
+
}
|
|
2556
|
+
}
|
|
2557
|
+
if (emitted >= query.limit)
|
|
2558
|
+
break;
|
|
2559
|
+
}
|
|
2560
|
+
if (page.length < pageLimit)
|
|
2561
|
+
break;
|
|
2562
|
+
}
|
|
2563
|
+
if (groupBy) {
|
|
2564
|
+
const groupedLines = renderGroupedEvents(groupedEvents, baseMs, groupBy);
|
|
2565
|
+
for (const line of groupedLines) {
|
|
2566
|
+
yield line;
|
|
2567
|
+
}
|
|
2568
|
+
}
|
|
2569
|
+
if (query.defaultLimitUsed &&
|
|
2570
|
+
!query.json &&
|
|
2571
|
+
typeof totalCount === "number" &&
|
|
2572
|
+
totalCount > query.limit) {
|
|
2573
|
+
yield `showing first ${query.limit} of ${totalCount} events, use --limit to see more`;
|
|
2574
|
+
}
|
|
2575
|
+
if (c.options.watch && !isRunStatusTerminal(run.status)) {
|
|
2576
|
+
/**
|
|
2577
|
+
* @param {EventHistoryRow[]} events
|
|
2578
|
+
*/
|
|
2579
|
+
const renderEvents = (events) => {
|
|
2580
|
+
for (const event of events) {
|
|
2581
|
+
lastSeq = Math.max(lastSeq, event.seq);
|
|
2582
|
+
emitted += 1;
|
|
2583
|
+
if (query.json) {
|
|
2584
|
+
process.stdout.write(`${buildEventNdjsonLine(event)}\n`);
|
|
2585
|
+
}
|
|
2586
|
+
else {
|
|
2587
|
+
process.stdout.write(`${buildEventHistoryLine(event, baseMs)}\n`);
|
|
2588
|
+
}
|
|
2589
|
+
}
|
|
2590
|
+
};
|
|
2591
|
+
const watchResult = await runPromise(Effect.tryPromise(() => runWatchLoop({
|
|
2592
|
+
intervalSeconds: c.options.interval,
|
|
2593
|
+
clearScreen: false,
|
|
2594
|
+
fetch: async () => ({
|
|
2595
|
+
events: await queryEventHistoryPage(adapter, c.args.runId, {
|
|
2596
|
+
afterSeq: lastSeq,
|
|
2597
|
+
nodeId: query.nodeId,
|
|
2598
|
+
eventTypes: query.eventTypes,
|
|
2599
|
+
sinceTimestampMs: query.sinceTimestampMs,
|
|
2600
|
+
limit: EVENTS_PAGE_SIZE,
|
|
2601
|
+
}),
|
|
2602
|
+
status: (await adapter.getRun(c.args.runId))?.status,
|
|
2603
|
+
}),
|
|
2604
|
+
render: async (snapshot) => {
|
|
2605
|
+
renderEvents(snapshot.events);
|
|
2606
|
+
},
|
|
2607
|
+
isTerminal: (snapshot) => isRunStatusTerminal(snapshot.status),
|
|
2608
|
+
})).pipe(Effect.tap((result) => Effect.logDebug("watch loop completed").pipe(Effect.annotateLogs({
|
|
2609
|
+
command: "events",
|
|
2610
|
+
intervalMs: watchIntervalMs,
|
|
2611
|
+
tickCount: result.tickCount,
|
|
2612
|
+
stoppedBySignal: result.stoppedBySignal,
|
|
2613
|
+
}))), Effect.annotateLogs({
|
|
2614
|
+
command: "events",
|
|
2615
|
+
runId: c.args.runId,
|
|
2616
|
+
intervalMs: watchIntervalMs,
|
|
2617
|
+
}), Effect.withLogSpan("cli:watch")));
|
|
2618
|
+
if (watchResult.reachedTerminal) {
|
|
2619
|
+
while (true) {
|
|
2620
|
+
const finalPage = await queryEventHistoryPage(adapter, c.args.runId, {
|
|
2621
|
+
afterSeq: lastSeq,
|
|
2622
|
+
nodeId: query.nodeId,
|
|
2623
|
+
eventTypes: query.eventTypes,
|
|
2624
|
+
sinceTimestampMs: query.sinceTimestampMs,
|
|
2625
|
+
limit: EVENTS_PAGE_SIZE,
|
|
2626
|
+
});
|
|
2627
|
+
if (finalPage.length === 0)
|
|
2628
|
+
break;
|
|
2629
|
+
renderEvents(finalPage);
|
|
2630
|
+
if (finalPage.length < EVENTS_PAGE_SIZE)
|
|
2631
|
+
break;
|
|
2632
|
+
}
|
|
2633
|
+
}
|
|
2634
|
+
if (watchResult.stoppedBySignal) {
|
|
2635
|
+
process.exitCode = 0;
|
|
2636
|
+
}
|
|
2637
|
+
}
|
|
2638
|
+
await runPromise(Effect.succeed(undefined).pipe(Effect.annotateLogs({
|
|
2639
|
+
runId: c.args.runId,
|
|
2640
|
+
filters,
|
|
2641
|
+
resultCount: emitted,
|
|
2642
|
+
}), Effect.withLogSpan("cli:events")));
|
|
2643
|
+
if (query.json)
|
|
2644
|
+
return;
|
|
2645
|
+
return c.ok(undefined);
|
|
2646
|
+
}
|
|
2647
|
+
finally {
|
|
2648
|
+
cleanup?.();
|
|
2649
|
+
}
|
|
2650
|
+
},
|
|
2651
|
+
})
|
|
2652
|
+
// =========================================================================
|
|
2653
|
+
// smithers chat [run_id]
|
|
2654
|
+
// =========================================================================
|
|
2655
|
+
.command("chat", {
|
|
2656
|
+
description: "Show agent chat output for the latest run or a specific run.",
|
|
2657
|
+
args: chatArgs,
|
|
2658
|
+
options: chatOptions,
|
|
2659
|
+
alias: { follow: "f", tail: "n", all: "a" },
|
|
2660
|
+
async *run(c) {
|
|
2661
|
+
let cleanup;
|
|
2662
|
+
try {
|
|
2663
|
+
const db = await findAndOpenDb();
|
|
2664
|
+
const adapter = db.adapter;
|
|
2665
|
+
cleanup = db.cleanup;
|
|
2666
|
+
let run;
|
|
2667
|
+
if (c.args.runId) {
|
|
2668
|
+
run = await adapter.getRun(c.args.runId);
|
|
2669
|
+
}
|
|
2670
|
+
else {
|
|
2671
|
+
const latestRuns = await adapter.listRuns(1);
|
|
2672
|
+
run = latestRuns[0];
|
|
2673
|
+
}
|
|
2674
|
+
if (!run) {
|
|
2675
|
+
yield c.args.runId
|
|
2676
|
+
? `Error: Run not found: ${c.args.runId}`
|
|
2677
|
+
: "Error: No runs found.";
|
|
2678
|
+
return;
|
|
2679
|
+
}
|
|
2680
|
+
const runId = run.runId;
|
|
2681
|
+
const baseMs = run.startedAtMs ?? run.createdAtMs ?? Date.now();
|
|
2682
|
+
const printedHeaders = new Set();
|
|
2683
|
+
const emittedBlockIds = new Set();
|
|
2684
|
+
const stdoutSeenAttempts = new Set();
|
|
2685
|
+
const selectedAttemptKeys = new Set();
|
|
2686
|
+
const attemptByKey = new Map();
|
|
2687
|
+
const knownOutputAttemptKeys = new Set();
|
|
2688
|
+
/**
|
|
2689
|
+
* @param {Array<{ attemptKey: string; blockId: string; timestampMs: number; text: string }>} blocks
|
|
2690
|
+
*/
|
|
2691
|
+
const renderLines = (blocks) => {
|
|
2692
|
+
const lines = [];
|
|
2693
|
+
for (const block of blocks) {
|
|
2694
|
+
if (emittedBlockIds.has(block.blockId))
|
|
2695
|
+
continue;
|
|
2696
|
+
emittedBlockIds.add(block.blockId);
|
|
2697
|
+
const attempt = attemptByKey.get(block.attemptKey);
|
|
2698
|
+
if (!attempt)
|
|
2699
|
+
continue;
|
|
2700
|
+
if (!printedHeaders.has(block.attemptKey)) {
|
|
2701
|
+
if (lines.length > 0)
|
|
2702
|
+
lines.push("");
|
|
2703
|
+
lines.push(formatChatAttemptHeader(attempt));
|
|
2704
|
+
printedHeaders.add(block.attemptKey);
|
|
2705
|
+
}
|
|
2706
|
+
lines.push(block.text);
|
|
2707
|
+
}
|
|
2708
|
+
return lines;
|
|
2709
|
+
};
|
|
2710
|
+
/**
|
|
2711
|
+
* @param {any} attempt
|
|
2712
|
+
*/
|
|
2713
|
+
const buildPromptBlock = (attempt) => {
|
|
2714
|
+
const attemptKey = chatAttemptKey(attempt);
|
|
2715
|
+
const meta = parseChatAttemptMeta(attempt.metaJson);
|
|
2716
|
+
const prompt = typeof meta.prompt === "string" ? meta.prompt.trim() : "";
|
|
2717
|
+
if (!prompt)
|
|
2718
|
+
return null;
|
|
2719
|
+
return {
|
|
2720
|
+
attemptKey,
|
|
2721
|
+
blockId: `prompt:${attemptKey}`,
|
|
2722
|
+
timestampMs: attempt.startedAtMs ?? baseMs,
|
|
2723
|
+
text: formatChatBlock({
|
|
2724
|
+
baseMs,
|
|
2725
|
+
timestampMs: attempt.startedAtMs ?? baseMs,
|
|
2726
|
+
role: "user",
|
|
2727
|
+
attempt,
|
|
2728
|
+
text: prompt,
|
|
2729
|
+
}),
|
|
2730
|
+
};
|
|
2731
|
+
};
|
|
2732
|
+
/**
|
|
2733
|
+
* @param {ReturnType<typeof parseNodeOutputEvent>} event
|
|
2734
|
+
*/
|
|
2735
|
+
const buildOutputBlock = (event) => {
|
|
2736
|
+
if (!event)
|
|
2737
|
+
return null;
|
|
2738
|
+
const attemptKey = chatAttemptKey(event);
|
|
2739
|
+
if (!selectedAttemptKeys.has(attemptKey))
|
|
2740
|
+
return null;
|
|
2741
|
+
if (event.stream === "stderr" && !c.options.stderr)
|
|
2742
|
+
return null;
|
|
2743
|
+
if (event.stream === "stdout") {
|
|
2744
|
+
stdoutSeenAttempts.add(attemptKey);
|
|
2745
|
+
}
|
|
2746
|
+
return {
|
|
2747
|
+
attemptKey,
|
|
2748
|
+
blockId: `event:${event.seq}`,
|
|
2749
|
+
timestampMs: event.timestampMs,
|
|
2750
|
+
text: formatChatBlock({
|
|
2751
|
+
baseMs,
|
|
2752
|
+
timestampMs: event.timestampMs,
|
|
2753
|
+
role: event.stream === "stderr" ? "stderr" : "assistant",
|
|
2754
|
+
attempt: event,
|
|
2755
|
+
text: event.text,
|
|
2756
|
+
}),
|
|
2757
|
+
};
|
|
2758
|
+
};
|
|
2759
|
+
/**
|
|
2760
|
+
* @param {any} attempt
|
|
2761
|
+
*/
|
|
2762
|
+
const buildFallbackBlock = (attempt) => {
|
|
2763
|
+
const attemptKey = chatAttemptKey(attempt);
|
|
2764
|
+
const responseText = typeof attempt.responseText === "string"
|
|
2765
|
+
? attempt.responseText.trim()
|
|
2766
|
+
: "";
|
|
2767
|
+
if (!responseText || stdoutSeenAttempts.has(attemptKey))
|
|
2768
|
+
return null;
|
|
2769
|
+
return {
|
|
2770
|
+
attemptKey,
|
|
2771
|
+
blockId: `response:${attemptKey}`,
|
|
2772
|
+
timestampMs: attempt.finishedAtMs ?? attempt.startedAtMs ?? baseMs,
|
|
2773
|
+
text: formatChatBlock({
|
|
2774
|
+
baseMs,
|
|
2775
|
+
timestampMs: attempt.finishedAtMs ?? attempt.startedAtMs ?? baseMs,
|
|
2776
|
+
role: "assistant",
|
|
2777
|
+
attempt,
|
|
2778
|
+
text: responseText,
|
|
2779
|
+
}),
|
|
2780
|
+
};
|
|
2781
|
+
};
|
|
2782
|
+
/**
|
|
2783
|
+
* @param {any[]} attempts
|
|
2784
|
+
*/
|
|
2785
|
+
const syncAttempts = (attempts) => {
|
|
2786
|
+
for (const attempt of attempts) {
|
|
2787
|
+
attemptByKey.set(chatAttemptKey(attempt), attempt);
|
|
2788
|
+
}
|
|
2789
|
+
const selected = selectChatAttempts(attempts, knownOutputAttemptKeys, c.options.all);
|
|
2790
|
+
if (c.options.all || selectedAttemptKeys.size === 0) {
|
|
2791
|
+
for (const attempt of selected) {
|
|
2792
|
+
selectedAttemptKeys.add(chatAttemptKey(attempt));
|
|
2793
|
+
}
|
|
2794
|
+
}
|
|
2795
|
+
return selected;
|
|
2796
|
+
};
|
|
2797
|
+
const initialAttempts = await adapter.listAttemptsForRun(runId);
|
|
2798
|
+
syncAttempts(initialAttempts);
|
|
2799
|
+
const initialEvents = await listAllEvents(adapter, runId);
|
|
2800
|
+
const parsedInitialOutputs = initialEvents
|
|
2801
|
+
.map((event) => parseNodeOutputEvent(event) ?? parseAgentEvent(event))
|
|
2802
|
+
.filter(Boolean);
|
|
2803
|
+
for (const event of parsedInitialOutputs) {
|
|
2804
|
+
knownOutputAttemptKeys.add(chatAttemptKey(event));
|
|
2805
|
+
}
|
|
2806
|
+
const selectedInitialAttempts = syncAttempts(initialAttempts);
|
|
2807
|
+
const initialBlocks = [];
|
|
2808
|
+
for (const attempt of selectedInitialAttempts) {
|
|
2809
|
+
const promptBlock = buildPromptBlock(attempt);
|
|
2810
|
+
if (promptBlock)
|
|
2811
|
+
initialBlocks.push(promptBlock);
|
|
2812
|
+
}
|
|
2813
|
+
for (const event of parsedInitialOutputs) {
|
|
2814
|
+
const block = buildOutputBlock(event);
|
|
2815
|
+
if (block)
|
|
2816
|
+
initialBlocks.push(block);
|
|
2817
|
+
}
|
|
2818
|
+
for (const attempt of selectedInitialAttempts) {
|
|
2819
|
+
const fallbackBlock = buildFallbackBlock(attempt);
|
|
2820
|
+
if (fallbackBlock)
|
|
2821
|
+
initialBlocks.push(fallbackBlock);
|
|
2822
|
+
}
|
|
2823
|
+
initialBlocks.sort((a, b) => {
|
|
2824
|
+
if (a.timestampMs !== b.timestampMs)
|
|
2825
|
+
return a.timestampMs - b.timestampMs;
|
|
2826
|
+
return a.blockId.localeCompare(b.blockId);
|
|
2827
|
+
});
|
|
2828
|
+
const visibleInitialBlocks = c.options.tail
|
|
2829
|
+
? initialBlocks.slice(-c.options.tail)
|
|
2830
|
+
: initialBlocks;
|
|
2831
|
+
const initialLines = renderLines(visibleInitialBlocks);
|
|
2832
|
+
for (const line of initialLines) {
|
|
2833
|
+
yield line;
|
|
2834
|
+
}
|
|
2835
|
+
if (selectedAttemptKeys.size === 0 && !c.options.follow) {
|
|
2836
|
+
yield `No agent chat logs found for run: ${runId}`;
|
|
2837
|
+
return;
|
|
2838
|
+
}
|
|
2839
|
+
let lastSeq = initialEvents.length > 0
|
|
2840
|
+
? initialEvents[initialEvents.length - 1].seq
|
|
2841
|
+
: -1;
|
|
2842
|
+
if (!c.options.follow) {
|
|
2843
|
+
return c.ok(undefined, {
|
|
2844
|
+
cta: {
|
|
2845
|
+
commands: [
|
|
2846
|
+
{ command: `inspect ${runId}`, description: "Inspect run state" },
|
|
2847
|
+
{ command: `logs ${runId}`, description: "Tail lifecycle events" },
|
|
2848
|
+
],
|
|
2849
|
+
},
|
|
2850
|
+
});
|
|
2851
|
+
}
|
|
2852
|
+
while (true) {
|
|
2853
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
2854
|
+
const attempts = await adapter.listAttemptsForRun(runId);
|
|
2855
|
+
syncAttempts(attempts);
|
|
2856
|
+
const newRows = await adapter.listEvents(runId, lastSeq, 200);
|
|
2857
|
+
const newBlocks = [];
|
|
2858
|
+
for (const eventRow of newRows) {
|
|
2859
|
+
lastSeq = eventRow.seq;
|
|
2860
|
+
const parsed = parseNodeOutputEvent(eventRow) ?? parseAgentEvent(eventRow);
|
|
2861
|
+
if (!parsed)
|
|
2862
|
+
continue;
|
|
2863
|
+
knownOutputAttemptKeys.add(chatAttemptKey(parsed));
|
|
2864
|
+
if (c.options.all || selectedAttemptKeys.size === 0) {
|
|
2865
|
+
syncAttempts(attempts);
|
|
2866
|
+
}
|
|
2867
|
+
const block = buildOutputBlock(parsed);
|
|
2868
|
+
if (block)
|
|
2869
|
+
newBlocks.push(block);
|
|
2870
|
+
}
|
|
2871
|
+
for (const attempt of attempts.filter((entry) => selectedAttemptKeys.has(chatAttemptKey(entry)))) {
|
|
2872
|
+
const promptBlock = buildPromptBlock(attempt);
|
|
2873
|
+
if (promptBlock && !emittedBlockIds.has(promptBlock.blockId)) {
|
|
2874
|
+
newBlocks.push(promptBlock);
|
|
2875
|
+
}
|
|
2876
|
+
const fallbackBlock = buildFallbackBlock(attempt);
|
|
2877
|
+
if (fallbackBlock && !emittedBlockIds.has(fallbackBlock.blockId)) {
|
|
2878
|
+
newBlocks.push(fallbackBlock);
|
|
2879
|
+
}
|
|
2880
|
+
}
|
|
2881
|
+
newBlocks.sort((a, b) => {
|
|
2882
|
+
if (a.timestampMs !== b.timestampMs)
|
|
2883
|
+
return a.timestampMs - b.timestampMs;
|
|
2884
|
+
return a.blockId.localeCompare(b.blockId);
|
|
2885
|
+
});
|
|
2886
|
+
const newLines = renderLines(newBlocks);
|
|
2887
|
+
for (const line of newLines) {
|
|
2888
|
+
yield line;
|
|
2889
|
+
}
|
|
2890
|
+
const currentRun = await adapter.getRun(runId);
|
|
2891
|
+
const currentStatus = currentRun?.status;
|
|
2892
|
+
if (currentStatus !== "running" &&
|
|
2893
|
+
currentStatus !== "waiting-approval" &&
|
|
2894
|
+
currentStatus !== "waiting-event" &&
|
|
2895
|
+
currentStatus !== "waiting-timer") {
|
|
2896
|
+
const finalAttempts = await adapter.listAttemptsForRun(runId);
|
|
2897
|
+
syncAttempts(finalAttempts);
|
|
2898
|
+
const finalBlocks = finalAttempts
|
|
2899
|
+
.filter((attempt) => selectedAttemptKeys.has(chatAttemptKey(attempt)))
|
|
2900
|
+
.map((attempt) => buildFallbackBlock(attempt))
|
|
2901
|
+
.filter(Boolean);
|
|
2902
|
+
const finalLines = renderLines(finalBlocks);
|
|
2903
|
+
for (const line of finalLines) {
|
|
2904
|
+
yield line;
|
|
2905
|
+
}
|
|
2906
|
+
return c.ok(undefined, {
|
|
2907
|
+
cta: {
|
|
2908
|
+
commands: [
|
|
2909
|
+
{ command: `inspect ${runId}`, description: "Inspect run state" },
|
|
2910
|
+
{ command: `logs ${runId}`, description: "Tail lifecycle events" },
|
|
2911
|
+
],
|
|
2912
|
+
},
|
|
2913
|
+
});
|
|
2914
|
+
}
|
|
2915
|
+
}
|
|
2916
|
+
}
|
|
2917
|
+
finally {
|
|
2918
|
+
cleanup?.();
|
|
2919
|
+
}
|
|
2920
|
+
},
|
|
2921
|
+
})
|
|
2922
|
+
// =========================================================================
|
|
2923
|
+
// smithers hijack <run_id>
|
|
2924
|
+
// =========================================================================
|
|
2925
|
+
.command("hijack", {
|
|
2926
|
+
description: "Hand off the latest resumable agent session or conversation for a run.",
|
|
2927
|
+
args: hijackArgs,
|
|
2928
|
+
options: hijackOptions,
|
|
2929
|
+
async run(c) {
|
|
2930
|
+
const fail = (opts) => {
|
|
2931
|
+
commandExitOverride = opts.exitCode ?? 1;
|
|
2932
|
+
return c.error(opts);
|
|
2933
|
+
};
|
|
2934
|
+
const { adapter, cleanup } = await findAndOpenDb();
|
|
2935
|
+
try {
|
|
2936
|
+
const run = await adapter.getRun(c.args.runId);
|
|
2937
|
+
if (!run) {
|
|
2938
|
+
return fail({
|
|
2939
|
+
code: "RUN_NOT_FOUND",
|
|
2940
|
+
message: `Run not found: ${c.args.runId}`,
|
|
2941
|
+
exitCode: 4,
|
|
2942
|
+
});
|
|
2943
|
+
}
|
|
2944
|
+
let candidate = await resolveHijackCandidate(adapter, c.args.runId, c.options.target);
|
|
2945
|
+
const runIsLive = run.status === "running";
|
|
2946
|
+
const requestedAtMs = Date.now();
|
|
2947
|
+
if (runIsLive) {
|
|
2948
|
+
const event = {
|
|
2949
|
+
type: "RunHijackRequested",
|
|
2950
|
+
runId: c.args.runId,
|
|
2951
|
+
timestampMs: requestedAtMs,
|
|
2952
|
+
...(c.options.target ? { target: c.options.target } : {}),
|
|
2953
|
+
};
|
|
2954
|
+
await adapter.requestRunHijack(c.args.runId, requestedAtMs, c.options.target ?? null);
|
|
2955
|
+
await adapter.insertEventWithNextSeq({
|
|
2956
|
+
runId: c.args.runId,
|
|
2957
|
+
timestampMs: requestedAtMs,
|
|
2958
|
+
type: "RunHijackRequested",
|
|
2959
|
+
payloadJson: JSON.stringify(event),
|
|
2960
|
+
});
|
|
2961
|
+
runSync(trackEvent(event));
|
|
2962
|
+
try {
|
|
2963
|
+
candidate = await waitForHijackCandidate(adapter, c.args.runId, {
|
|
2964
|
+
target: c.options.target,
|
|
2965
|
+
timeoutMs: c.options.timeoutMs,
|
|
2966
|
+
});
|
|
2967
|
+
}
|
|
2968
|
+
catch (error) {
|
|
2969
|
+
await adapter.clearRunHijack(c.args.runId).catch(() => undefined);
|
|
2970
|
+
return fail({
|
|
2971
|
+
code: "HIJACK_TIMEOUT",
|
|
2972
|
+
message: error?.message ?? String(error),
|
|
2973
|
+
exitCode: 4,
|
|
2974
|
+
});
|
|
2975
|
+
}
|
|
2976
|
+
}
|
|
2977
|
+
if (!candidate) {
|
|
2978
|
+
return fail({
|
|
2979
|
+
code: "HIJACK_UNAVAILABLE",
|
|
2980
|
+
message: `No resumable agent session or conversation found for run ${c.args.runId}.`,
|
|
2981
|
+
exitCode: 4,
|
|
2982
|
+
});
|
|
2983
|
+
}
|
|
2984
|
+
if (c.options.target && candidate.engine !== c.options.target) {
|
|
2985
|
+
return fail({
|
|
2986
|
+
code: "HIJACK_TARGET_MISMATCH",
|
|
2987
|
+
message: `Run ${c.args.runId} is resumable in ${candidate.engine}, not ${c.options.target}. Cross-engine hijack is not supported.`,
|
|
2988
|
+
exitCode: 4,
|
|
2989
|
+
});
|
|
2990
|
+
}
|
|
2991
|
+
const resumeCommand = run.workflowPath
|
|
2992
|
+
? `smithers up ${run.workflowPath} --resume --run-id ${c.args.runId}`
|
|
2993
|
+
: null;
|
|
2994
|
+
if (!c.options.launch) {
|
|
2995
|
+
const launchSpec = isNativeHijackCandidate(candidate)
|
|
2996
|
+
? buildHijackLaunchSpec(candidate)
|
|
2997
|
+
: null;
|
|
2998
|
+
const launch = launchSpec
|
|
2999
|
+
? {
|
|
3000
|
+
command: launchSpec.command,
|
|
3001
|
+
args: launchSpec.args,
|
|
3002
|
+
cwd: launchSpec.cwd,
|
|
3003
|
+
}
|
|
3004
|
+
: null;
|
|
3005
|
+
return c.ok({
|
|
3006
|
+
runId: c.args.runId,
|
|
3007
|
+
engine: candidate.engine,
|
|
3008
|
+
mode: candidate.mode,
|
|
3009
|
+
nodeId: candidate.nodeId,
|
|
3010
|
+
attempt: candidate.attempt,
|
|
3011
|
+
iteration: candidate.iteration,
|
|
3012
|
+
resume: candidate.resume ?? null,
|
|
3013
|
+
messageCount: candidate.messages?.length ?? 0,
|
|
3014
|
+
cwd: candidate.cwd,
|
|
3015
|
+
launch,
|
|
3016
|
+
resumeCommand,
|
|
3017
|
+
});
|
|
3018
|
+
}
|
|
3019
|
+
let exitCode = 0;
|
|
3020
|
+
let resumedBySmithers = false;
|
|
3021
|
+
if (isNativeHijackCandidate(candidate)) {
|
|
3022
|
+
const launchSpec = buildHijackLaunchSpec(candidate);
|
|
3023
|
+
process.stderr.write(`[smithers] hijacking ${candidate.engine} session ${candidate.resume} from ${candidate.nodeId}#${candidate.attempt}\n`);
|
|
3024
|
+
exitCode = await launchHijackSession(launchSpec);
|
|
3025
|
+
}
|
|
3026
|
+
else {
|
|
3027
|
+
if (!candidate.messages?.length) {
|
|
3028
|
+
return fail({
|
|
3029
|
+
code: "HIJACK_CONVERSATION_MISSING",
|
|
3030
|
+
message: `Run ${c.args.runId} did not persist a resumable conversation for ${candidate.engine}.`,
|
|
3031
|
+
exitCode: 4,
|
|
3032
|
+
});
|
|
3033
|
+
}
|
|
3034
|
+
const result = await launchConversationHijackSession(adapter, {
|
|
3035
|
+
...candidate,
|
|
3036
|
+
mode: "conversation",
|
|
3037
|
+
messages: candidate.messages,
|
|
3038
|
+
});
|
|
3039
|
+
await persistConversationHijackHandoff(adapter, candidate, result.messages);
|
|
3040
|
+
exitCode = result.code;
|
|
3041
|
+
}
|
|
3042
|
+
if (exitCode === 0 && runIsLive && run.workflowPath) {
|
|
3043
|
+
const pid = resumeRunDetached(run.workflowPath, c.args.runId);
|
|
3044
|
+
resumedBySmithers = true;
|
|
3045
|
+
process.stderr.write(`[smithers] returned control to Smithers${pid ? ` (pid ${pid})` : ""}\n`);
|
|
3046
|
+
}
|
|
3047
|
+
else if (resumeCommand) {
|
|
3048
|
+
process.stderr.write(`[smithers] return control to Smithers with:\n ${resumeCommand}\n`);
|
|
3049
|
+
}
|
|
3050
|
+
if (exitCode !== 0) {
|
|
3051
|
+
return fail({
|
|
3052
|
+
code: "HIJACK_LAUNCH_FAILED",
|
|
3053
|
+
message: `${candidate.engine} exited with code ${exitCode}`,
|
|
3054
|
+
exitCode,
|
|
3055
|
+
});
|
|
3056
|
+
}
|
|
3057
|
+
return c.ok({
|
|
3058
|
+
runId: c.args.runId,
|
|
3059
|
+
engine: candidate.engine,
|
|
3060
|
+
mode: candidate.mode,
|
|
3061
|
+
resumedSession: candidate.resume ?? null,
|
|
3062
|
+
resumedBySmithers,
|
|
3063
|
+
});
|
|
3064
|
+
}
|
|
3065
|
+
finally {
|
|
3066
|
+
cleanup();
|
|
3067
|
+
}
|
|
3068
|
+
},
|
|
3069
|
+
})
|
|
3070
|
+
// =========================================================================
|
|
3071
|
+
// smithers inspect <run_id>
|
|
3072
|
+
// =========================================================================
|
|
3073
|
+
.command("inspect", {
|
|
3074
|
+
description: "Output detailed state of a run: steps, agents, approvals, and outputs.",
|
|
3075
|
+
args: inspectArgs,
|
|
3076
|
+
options: inspectOptions,
|
|
3077
|
+
alias: { watch: "w", interval: "i" },
|
|
3078
|
+
async run(c) {
|
|
3079
|
+
const fail = (opts) => {
|
|
3080
|
+
commandExitOverride = opts.exitCode ?? 1;
|
|
3081
|
+
return c.error(opts);
|
|
3082
|
+
};
|
|
3083
|
+
try {
|
|
3084
|
+
const { adapter, cleanup } = await findAndOpenDb();
|
|
3085
|
+
try {
|
|
3086
|
+
/**
|
|
3087
|
+
* @param {InspectSnapshot} snapshot
|
|
3088
|
+
*/
|
|
3089
|
+
const renderInspect = (snapshot) => {
|
|
3090
|
+
writeWatchOutput(c.format, snapshot.result);
|
|
3091
|
+
};
|
|
3092
|
+
if (c.options.watch) {
|
|
3093
|
+
const intervalMs = resolveWatchIntervalMsOrFail("inspect", c.options.interval, fail);
|
|
3094
|
+
const watchResult = await runPromise(Effect.tryPromise(() => runWatchLoop({
|
|
3095
|
+
intervalSeconds: c.options.interval,
|
|
3096
|
+
clearScreen: true,
|
|
3097
|
+
fetch: () => buildInspectSnapshot(adapter, c.args.runId),
|
|
3098
|
+
render: async (snapshot) => {
|
|
3099
|
+
renderInspect(snapshot);
|
|
3100
|
+
},
|
|
3101
|
+
isTerminal: (snapshot) => isRunStatusTerminal(snapshot.status),
|
|
3102
|
+
})).pipe(Effect.tap((result) => Effect.logDebug("watch loop completed").pipe(Effect.annotateLogs({
|
|
3103
|
+
command: "inspect",
|
|
3104
|
+
intervalMs,
|
|
3105
|
+
tickCount: result.tickCount,
|
|
3106
|
+
stoppedBySignal: result.stoppedBySignal,
|
|
3107
|
+
}))), Effect.annotateLogs({ command: "inspect", intervalMs }), Effect.withLogSpan("cli:watch")));
|
|
3108
|
+
if (watchResult.stoppedBySignal) {
|
|
3109
|
+
process.exitCode = 0;
|
|
3110
|
+
}
|
|
3111
|
+
return c.ok(undefined);
|
|
3112
|
+
}
|
|
3113
|
+
const snapshot = await buildInspectSnapshot(adapter, c.args.runId);
|
|
3114
|
+
return c.ok(snapshot.result, { cta: { commands: snapshot.ctaCommands } });
|
|
3115
|
+
}
|
|
3116
|
+
finally {
|
|
3117
|
+
cleanup();
|
|
3118
|
+
}
|
|
3119
|
+
}
|
|
3120
|
+
catch (err) {
|
|
3121
|
+
if (err instanceof SmithersError && err.code === "RUN_NOT_FOUND") {
|
|
3122
|
+
return fail({
|
|
3123
|
+
code: "RUN_NOT_FOUND",
|
|
3124
|
+
message: err.message,
|
|
3125
|
+
exitCode: 4,
|
|
3126
|
+
});
|
|
3127
|
+
}
|
|
3128
|
+
return fail({ code: "INSPECT_FAILED", message: err?.message ?? String(err), exitCode: 1 });
|
|
3129
|
+
}
|
|
3130
|
+
},
|
|
3131
|
+
})
|
|
3132
|
+
// =========================================================================
|
|
3133
|
+
// smithers node <node_id> -r <run_id>
|
|
3134
|
+
// =========================================================================
|
|
3135
|
+
.command("node", {
|
|
3136
|
+
description: "Show enriched node details for debugging retries, tool calls, and output.",
|
|
3137
|
+
args: nodeArgs,
|
|
3138
|
+
options: nodeOptions,
|
|
3139
|
+
alias: { runId: "r", iteration: "i", watch: "w" },
|
|
3140
|
+
async run(c) {
|
|
3141
|
+
const fail = (opts) => {
|
|
3142
|
+
commandExitOverride = opts.exitCode ?? 1;
|
|
3143
|
+
return c.error(opts);
|
|
3144
|
+
};
|
|
3145
|
+
try {
|
|
3146
|
+
const { adapter, cleanup } = await findAndOpenDb();
|
|
3147
|
+
try {
|
|
3148
|
+
/**
|
|
3149
|
+
* @param {any} detail
|
|
3150
|
+
*/
|
|
3151
|
+
const renderNode = (detail) => {
|
|
3152
|
+
const human = c.format === "json" || c.format === "jsonl"
|
|
3153
|
+
? undefined
|
|
3154
|
+
: renderNodeDetailHuman(detail, {
|
|
3155
|
+
expandAttempts: c.options.attempts,
|
|
3156
|
+
expandTools: c.options.tools,
|
|
3157
|
+
});
|
|
3158
|
+
writeWatchOutput(c.format, detail, human);
|
|
3159
|
+
};
|
|
3160
|
+
if (c.options.watch) {
|
|
3161
|
+
const intervalMs = resolveWatchIntervalMsOrFail("node", c.options.interval, fail);
|
|
3162
|
+
const watchResult = await runPromise(Effect.tryPromise(() => runWatchLoop({
|
|
3163
|
+
intervalSeconds: c.options.interval,
|
|
3164
|
+
clearScreen: true,
|
|
3165
|
+
fetch: () => buildNodeSnapshot(adapter, {
|
|
3166
|
+
runId: c.options.runId,
|
|
3167
|
+
nodeId: c.args.nodeId,
|
|
3168
|
+
iteration: c.options.iteration,
|
|
3169
|
+
}),
|
|
3170
|
+
render: async (snapshot) => {
|
|
3171
|
+
renderNode(snapshot.detail);
|
|
3172
|
+
},
|
|
3173
|
+
isTerminal: (snapshot) => isRunStatusTerminal(snapshot.status),
|
|
3174
|
+
})).pipe(Effect.tap((result) => Effect.logDebug("watch loop completed").pipe(Effect.annotateLogs({
|
|
3175
|
+
command: "node",
|
|
3176
|
+
runId: c.options.runId,
|
|
3177
|
+
nodeId: c.args.nodeId,
|
|
3178
|
+
intervalMs,
|
|
3179
|
+
tickCount: result.tickCount,
|
|
3180
|
+
stoppedBySignal: result.stoppedBySignal,
|
|
3181
|
+
}))), Effect.annotateLogs({
|
|
3182
|
+
command: "node",
|
|
3183
|
+
runId: c.options.runId,
|
|
3184
|
+
nodeId: c.args.nodeId,
|
|
3185
|
+
intervalMs,
|
|
3186
|
+
}), Effect.withLogSpan("cli:watch")));
|
|
3187
|
+
if (watchResult.stoppedBySignal) {
|
|
3188
|
+
process.exitCode = 0;
|
|
3189
|
+
}
|
|
3190
|
+
return c.ok(undefined);
|
|
3191
|
+
}
|
|
3192
|
+
const detail = await runPromise(aggregateNodeDetailEffect(adapter, {
|
|
3193
|
+
runId: c.options.runId,
|
|
3194
|
+
nodeId: c.args.nodeId,
|
|
3195
|
+
iteration: c.options.iteration,
|
|
3196
|
+
}));
|
|
3197
|
+
if (c.format === "json") {
|
|
3198
|
+
return c.ok(detail);
|
|
3199
|
+
}
|
|
3200
|
+
const rendered = renderNodeDetailHuman(detail, {
|
|
3201
|
+
expandAttempts: c.options.attempts,
|
|
3202
|
+
expandTools: c.options.tools,
|
|
3203
|
+
});
|
|
3204
|
+
return c.ok(rendered, {
|
|
3205
|
+
cta: {
|
|
3206
|
+
commands: [
|
|
3207
|
+
{
|
|
3208
|
+
command: `inspect ${c.options.runId}`,
|
|
3209
|
+
description: "Inspect overall run state",
|
|
3210
|
+
},
|
|
3211
|
+
{
|
|
3212
|
+
command: `chat ${c.options.runId}`,
|
|
3213
|
+
description: "View agent chat for this run",
|
|
3214
|
+
},
|
|
3215
|
+
{
|
|
3216
|
+
command: `node ${c.args.nodeId} -r ${c.options.runId} --attempts`,
|
|
3217
|
+
description: "Expand every attempt",
|
|
3218
|
+
},
|
|
3219
|
+
{
|
|
3220
|
+
command: `node ${c.args.nodeId} -r ${c.options.runId} --tools`,
|
|
3221
|
+
description: "Expand tool payloads",
|
|
3222
|
+
},
|
|
3223
|
+
],
|
|
3224
|
+
},
|
|
3225
|
+
});
|
|
3226
|
+
}
|
|
3227
|
+
finally {
|
|
3228
|
+
cleanup();
|
|
3229
|
+
}
|
|
3230
|
+
}
|
|
3231
|
+
catch (err) {
|
|
3232
|
+
const isMissingNode = err instanceof SmithersError && err.code === "NODE_NOT_FOUND";
|
|
3233
|
+
return fail({
|
|
3234
|
+
code: isMissingNode ? "NODE_NOT_FOUND" : "NODE_DETAIL_FAILED",
|
|
3235
|
+
message: err instanceof SmithersError
|
|
3236
|
+
? err.summary
|
|
3237
|
+
: (err?.message ?? String(err)),
|
|
3238
|
+
exitCode: isMissingNode ? 4 : 1,
|
|
3239
|
+
});
|
|
3240
|
+
}
|
|
3241
|
+
},
|
|
3242
|
+
})
|
|
3243
|
+
// =========================================================================
|
|
3244
|
+
// smithers why <run_id>
|
|
3245
|
+
// =========================================================================
|
|
3246
|
+
.command("why", {
|
|
3247
|
+
description: "Explain why a run is currently blocked or paused.",
|
|
3248
|
+
args: whyArgs,
|
|
3249
|
+
options: whyOptions,
|
|
3250
|
+
async run(c) {
|
|
3251
|
+
const fail = (opts) => {
|
|
3252
|
+
commandExitOverride = opts.exitCode ?? 1;
|
|
3253
|
+
return c.error(opts);
|
|
3254
|
+
};
|
|
3255
|
+
try {
|
|
3256
|
+
const { adapter, cleanup } = await findAndOpenDb();
|
|
3257
|
+
try {
|
|
3258
|
+
const diagnosis = await runPromise(diagnoseRunEffect(adapter, c.args.runId));
|
|
3259
|
+
if (c.options.json) {
|
|
3260
|
+
return c.ok(JSON.stringify(diagnosis, null, 2));
|
|
3261
|
+
}
|
|
3262
|
+
if (c.format === "json") {
|
|
3263
|
+
return c.ok(diagnosis);
|
|
3264
|
+
}
|
|
3265
|
+
return c.ok(renderWhyDiagnosisHuman(diagnosis), {
|
|
3266
|
+
cta: {
|
|
3267
|
+
commands: diagnosisCtaCommands(diagnosis),
|
|
3268
|
+
},
|
|
3269
|
+
});
|
|
3270
|
+
}
|
|
3271
|
+
finally {
|
|
3272
|
+
cleanup();
|
|
3273
|
+
}
|
|
3274
|
+
}
|
|
3275
|
+
catch (err) {
|
|
3276
|
+
if (err instanceof SmithersError && err.code === "RUN_NOT_FOUND") {
|
|
3277
|
+
return fail({
|
|
3278
|
+
code: "RUN_NOT_FOUND",
|
|
3279
|
+
message: err.message,
|
|
3280
|
+
exitCode: 4,
|
|
3281
|
+
});
|
|
3282
|
+
}
|
|
3283
|
+
return fail({ code: "WHY_FAILED", message: err?.message ?? String(err), exitCode: 1 });
|
|
3284
|
+
}
|
|
3285
|
+
},
|
|
3286
|
+
})
|
|
3287
|
+
// =========================================================================
|
|
3288
|
+
// smithers human inbox|answer|cancel
|
|
3289
|
+
// =========================================================================
|
|
3290
|
+
.command("human", {
|
|
3291
|
+
description: "List and resolve durable human requests.",
|
|
3292
|
+
args: humanArgs,
|
|
3293
|
+
options: humanOptions,
|
|
3294
|
+
async run(c) {
|
|
3295
|
+
const fail = (opts) => {
|
|
3296
|
+
commandExitOverride = opts.exitCode ?? 1;
|
|
3297
|
+
return c.error(opts);
|
|
3298
|
+
};
|
|
3299
|
+
const action = c.args.action.trim().toLowerCase();
|
|
3300
|
+
if (action !== "inbox" && action !== "answer" && action !== "cancel") {
|
|
3301
|
+
return fail({
|
|
3302
|
+
code: "INVALID_HUMAN_ACTION",
|
|
3303
|
+
message: `Unknown smithers human action: ${c.args.action}`,
|
|
3304
|
+
exitCode: 4,
|
|
3305
|
+
});
|
|
3306
|
+
}
|
|
3307
|
+
try {
|
|
3308
|
+
const { adapter, cleanup } = await findAndOpenDb();
|
|
3309
|
+
try {
|
|
3310
|
+
if (action === "inbox") {
|
|
3311
|
+
const rows = await adapter.listPendingHumanRequests();
|
|
3312
|
+
const requests = rows.map((row) => ({
|
|
3313
|
+
requestId: row.requestId,
|
|
3314
|
+
runId: row.runId,
|
|
3315
|
+
workflowName: row.workflowName ?? null,
|
|
3316
|
+
nodeId: row.nodeId,
|
|
3317
|
+
iteration: row.iteration ?? 0,
|
|
3318
|
+
kind: row.kind,
|
|
3319
|
+
prompt: row.prompt,
|
|
3320
|
+
status: row.status,
|
|
3321
|
+
requestedAtMs: row.requestedAtMs ?? null,
|
|
3322
|
+
requestedAt: typeof row.requestedAtMs === "number"
|
|
3323
|
+
? new Date(row.requestedAtMs).toISOString()
|
|
3324
|
+
: null,
|
|
3325
|
+
age: typeof row.requestedAtMs === "number"
|
|
3326
|
+
? formatAge(row.requestedAtMs)
|
|
3327
|
+
: "unknown",
|
|
3328
|
+
timeoutAtMs: row.timeoutAtMs ?? null,
|
|
3329
|
+
}));
|
|
3330
|
+
if (c.format === "json" || c.format === "jsonl") {
|
|
3331
|
+
return c.ok({ requests });
|
|
3332
|
+
}
|
|
3333
|
+
return c.ok(renderHumanInboxHuman(requests));
|
|
3334
|
+
}
|
|
3335
|
+
const requestId = c.args.requestId?.trim();
|
|
3336
|
+
if (!requestId) {
|
|
3337
|
+
return fail({
|
|
3338
|
+
code: "HUMAN_REQUEST_ID_REQUIRED",
|
|
3339
|
+
message: `smithers human ${action} requires <request-id>`,
|
|
3340
|
+
exitCode: 4,
|
|
3341
|
+
});
|
|
3342
|
+
}
|
|
3343
|
+
await adapter.expireStaleHumanRequests();
|
|
3344
|
+
const request = await adapter.getHumanRequest(requestId);
|
|
3345
|
+
if (!request) {
|
|
3346
|
+
return fail({
|
|
3347
|
+
code: "HUMAN_REQUEST_NOT_FOUND",
|
|
3348
|
+
message: `Human request not found: ${requestId}`,
|
|
3349
|
+
exitCode: 4,
|
|
3350
|
+
});
|
|
3351
|
+
}
|
|
3352
|
+
if (request.status !== "pending") {
|
|
3353
|
+
return fail({
|
|
3354
|
+
code: "HUMAN_REQUEST_NOT_PENDING",
|
|
3355
|
+
message: `Human request ${requestId} is ${request.status}, not pending.`,
|
|
3356
|
+
exitCode: 4,
|
|
3357
|
+
});
|
|
3358
|
+
}
|
|
3359
|
+
const approval = await adapter.getApproval(request.runId, request.nodeId, request.iteration);
|
|
3360
|
+
if (action === "answer") {
|
|
3361
|
+
if (!c.options.value) {
|
|
3362
|
+
return fail({
|
|
3363
|
+
code: "HUMAN_REQUEST_VALUE_REQUIRED",
|
|
3364
|
+
message: "smithers human answer requires --value <json>",
|
|
3365
|
+
exitCode: 4,
|
|
3366
|
+
});
|
|
3367
|
+
}
|
|
3368
|
+
const value = parseJsonInput(c.options.value, "human request value", fail);
|
|
3369
|
+
const validation = validateHumanRequestValue(request, value);
|
|
3370
|
+
if (!validation.ok) {
|
|
3371
|
+
return fail({
|
|
3372
|
+
code: validation.code,
|
|
3373
|
+
message: validation.message,
|
|
3374
|
+
exitCode: 4,
|
|
3375
|
+
});
|
|
3376
|
+
}
|
|
3377
|
+
const answeredAtMs = Date.now();
|
|
3378
|
+
if (isHumanRequestPastTimeout(request, answeredAtMs)) {
|
|
3379
|
+
await adapter.expireStaleHumanRequests(answeredAtMs);
|
|
3380
|
+
return fail({
|
|
3381
|
+
code: "HUMAN_REQUEST_EXPIRED",
|
|
3382
|
+
message: `Human request ${requestId} expired at ${new Date(request.timeoutAtMs).toISOString()}.`,
|
|
3383
|
+
exitCode: 4,
|
|
3384
|
+
});
|
|
3385
|
+
}
|
|
3386
|
+
const responseJson = JSON.stringify(value);
|
|
3387
|
+
if (approval?.status === "requested") {
|
|
3388
|
+
await Effect.runPromise(approveNode(adapter, request.runId, request.nodeId, request.iteration, responseJson, c.options.by));
|
|
3389
|
+
}
|
|
3390
|
+
await adapter.answerHumanRequest(requestId, responseJson, answeredAtMs, c.options.by ?? null);
|
|
3391
|
+
return c.ok({
|
|
3392
|
+
requestId,
|
|
3393
|
+
runId: request.runId,
|
|
3394
|
+
nodeId: request.nodeId,
|
|
3395
|
+
iteration: request.iteration,
|
|
3396
|
+
status: "answered",
|
|
3397
|
+
});
|
|
3398
|
+
}
|
|
3399
|
+
if (approval?.status === "requested") {
|
|
3400
|
+
await Effect.runPromise(denyNode(adapter, request.runId, request.nodeId, request.iteration, `Human request cancelled: ${requestId}`, c.options.by));
|
|
3401
|
+
}
|
|
3402
|
+
await adapter.cancelHumanRequest(requestId);
|
|
3403
|
+
return c.ok({
|
|
3404
|
+
requestId,
|
|
3405
|
+
runId: request.runId,
|
|
3406
|
+
nodeId: request.nodeId,
|
|
3407
|
+
iteration: request.iteration,
|
|
3408
|
+
status: "cancelled",
|
|
3409
|
+
});
|
|
3410
|
+
}
|
|
3411
|
+
finally {
|
|
3412
|
+
cleanup();
|
|
3413
|
+
}
|
|
3414
|
+
}
|
|
3415
|
+
catch (err) {
|
|
3416
|
+
return fail({
|
|
3417
|
+
code: "HUMAN_REQUEST_COMMAND_FAILED",
|
|
3418
|
+
message: err?.message ?? String(err),
|
|
3419
|
+
exitCode: 1,
|
|
3420
|
+
});
|
|
3421
|
+
}
|
|
3422
|
+
},
|
|
3423
|
+
})
|
|
3424
|
+
// =========================================================================
|
|
3425
|
+
// smithers alerts list|ack|resolve|silence
|
|
3426
|
+
// =========================================================================
|
|
3427
|
+
.command("alerts", {
|
|
3428
|
+
description: "List and manage durable alert instances.",
|
|
3429
|
+
args: alertsArgs,
|
|
3430
|
+
options: alertsOptions,
|
|
3431
|
+
async run(c) {
|
|
3432
|
+
const fail = (opts) => {
|
|
3433
|
+
commandExitOverride = opts.exitCode ?? 1;
|
|
3434
|
+
return c.error(opts);
|
|
3435
|
+
};
|
|
3436
|
+
const action = c.args.action.trim().toLowerCase();
|
|
3437
|
+
if (action !== "list" &&
|
|
3438
|
+
action !== "ack" &&
|
|
3439
|
+
action !== "resolve" &&
|
|
3440
|
+
action !== "silence") {
|
|
3441
|
+
return fail({
|
|
3442
|
+
code: "INVALID_ALERT_ACTION",
|
|
3443
|
+
message: `Unknown smithers alerts action: ${c.args.action}`,
|
|
3444
|
+
exitCode: 4,
|
|
3445
|
+
});
|
|
3446
|
+
}
|
|
3447
|
+
try {
|
|
3448
|
+
const { adapter, cleanup } = await findAndOpenDb();
|
|
3449
|
+
try {
|
|
3450
|
+
if (action === "list") {
|
|
3451
|
+
const rows = await adapter.listAlerts(200, [
|
|
3452
|
+
"firing",
|
|
3453
|
+
"acknowledged",
|
|
3454
|
+
"silenced",
|
|
3455
|
+
]);
|
|
3456
|
+
const alerts = rows.map((row) => ({
|
|
3457
|
+
alertId: row.alertId,
|
|
3458
|
+
runId: row.runId ?? null,
|
|
3459
|
+
policyName: row.policyName,
|
|
3460
|
+
severity: row.severity,
|
|
3461
|
+
status: row.status,
|
|
3462
|
+
firedAtMs: row.firedAtMs ?? null,
|
|
3463
|
+
firedAt: typeof row.firedAtMs === "number"
|
|
3464
|
+
? new Date(row.firedAtMs).toISOString()
|
|
3465
|
+
: null,
|
|
3466
|
+
resolvedAtMs: row.resolvedAtMs ?? null,
|
|
3467
|
+
resolvedAt: typeof row.resolvedAtMs === "number"
|
|
3468
|
+
? new Date(row.resolvedAtMs).toISOString()
|
|
3469
|
+
: null,
|
|
3470
|
+
acknowledgedAtMs: row.acknowledgedAtMs ?? null,
|
|
3471
|
+
acknowledgedAt: typeof row.acknowledgedAtMs === "number"
|
|
3472
|
+
? new Date(row.acknowledgedAtMs).toISOString()
|
|
3473
|
+
: null,
|
|
3474
|
+
age: typeof row.firedAtMs === "number"
|
|
3475
|
+
? formatAge(row.firedAtMs)
|
|
3476
|
+
: "unknown",
|
|
3477
|
+
message: row.message,
|
|
3478
|
+
detailsJson: row.detailsJson ?? null,
|
|
3479
|
+
}));
|
|
3480
|
+
if (c.format === "json" || c.format === "jsonl") {
|
|
3481
|
+
return c.ok({ alerts });
|
|
3482
|
+
}
|
|
3483
|
+
return c.ok(renderAlertsHuman(alerts));
|
|
3484
|
+
}
|
|
3485
|
+
const alertId = c.args.alertId?.trim();
|
|
3486
|
+
if (!alertId) {
|
|
3487
|
+
return fail({
|
|
3488
|
+
code: "ALERT_ID_REQUIRED",
|
|
3489
|
+
message: `smithers alerts ${action} requires <id>`,
|
|
3490
|
+
exitCode: 4,
|
|
3491
|
+
});
|
|
3492
|
+
}
|
|
3493
|
+
const existing = await adapter.getAlert(alertId);
|
|
3494
|
+
if (!existing) {
|
|
3495
|
+
return fail({
|
|
3496
|
+
code: "ALERT_NOT_FOUND",
|
|
3497
|
+
message: `Alert not found: ${alertId}`,
|
|
3498
|
+
exitCode: 4,
|
|
3499
|
+
});
|
|
3500
|
+
}
|
|
3501
|
+
const alert = action === "ack"
|
|
3502
|
+
? await adapter.acknowledgeAlert(alertId, Date.now())
|
|
3503
|
+
: action === "resolve"
|
|
3504
|
+
? await adapter.resolveAlert(alertId, Date.now())
|
|
3505
|
+
: await adapter.silenceAlert(alertId);
|
|
3506
|
+
if (!alert) {
|
|
3507
|
+
return fail({
|
|
3508
|
+
code: "ALERT_NOT_FOUND",
|
|
3509
|
+
message: `Alert not found: ${alertId}`,
|
|
3510
|
+
exitCode: 4,
|
|
3511
|
+
});
|
|
3512
|
+
}
|
|
3513
|
+
const payload = {
|
|
3514
|
+
alertId: alert.alertId,
|
|
3515
|
+
runId: alert.runId ?? null,
|
|
3516
|
+
policyName: alert.policyName,
|
|
3517
|
+
severity: alert.severity,
|
|
3518
|
+
status: alert.status,
|
|
3519
|
+
firedAtMs: alert.firedAtMs ?? null,
|
|
3520
|
+
resolvedAtMs: alert.resolvedAtMs ?? null,
|
|
3521
|
+
acknowledgedAtMs: alert.acknowledgedAtMs ?? null,
|
|
3522
|
+
message: alert.message,
|
|
3523
|
+
detailsJson: alert.detailsJson ?? null,
|
|
3524
|
+
};
|
|
3525
|
+
if (c.format === "json" || c.format === "jsonl") {
|
|
3526
|
+
return c.ok(payload);
|
|
3527
|
+
}
|
|
3528
|
+
return c.ok(`Alert ${payload.alertId} is ${payload.status}.`);
|
|
3529
|
+
}
|
|
3530
|
+
finally {
|
|
3531
|
+
cleanup();
|
|
3532
|
+
}
|
|
3533
|
+
}
|
|
3534
|
+
catch (err) {
|
|
3535
|
+
return fail({
|
|
3536
|
+
code: "ALERTS_FAILED",
|
|
3537
|
+
message: err?.message ?? String(err),
|
|
3538
|
+
exitCode: 1,
|
|
3539
|
+
});
|
|
3540
|
+
}
|
|
3541
|
+
},
|
|
3542
|
+
})
|
|
3543
|
+
// =========================================================================
|
|
3544
|
+
// smithers approve <run_id>
|
|
3545
|
+
// =========================================================================
|
|
3546
|
+
.command("approve", {
|
|
3547
|
+
description: "Approve a paused approval gate. Auto-detects the pending node if only one exists.",
|
|
3548
|
+
args: approveArgs,
|
|
3549
|
+
options: approveOptions,
|
|
3550
|
+
alias: { node: "n" },
|
|
3551
|
+
async run(c) {
|
|
3552
|
+
const fail = (opts) => {
|
|
3553
|
+
commandExitOverride = opts.exitCode ?? 1;
|
|
3554
|
+
return c.error(opts);
|
|
3555
|
+
};
|
|
3556
|
+
try {
|
|
3557
|
+
const { adapter, cleanup } = await findAndOpenDb();
|
|
3558
|
+
try {
|
|
3559
|
+
const pending = await adapter.listPendingApprovals(c.args.runId);
|
|
3560
|
+
if (pending.length === 0) {
|
|
3561
|
+
return fail({ code: "NO_PENDING_APPROVALS", message: `No pending approvals for run: ${c.args.runId}`, exitCode: 4 });
|
|
3562
|
+
}
|
|
3563
|
+
let nodeId = c.options.node;
|
|
3564
|
+
if (!nodeId) {
|
|
3565
|
+
if (pending.length > 1) {
|
|
3566
|
+
const nodeList = pending.map((a) => ` ${a.nodeId} (iteration ${a.iteration})`).join("\n");
|
|
3567
|
+
return fail({
|
|
3568
|
+
code: "AMBIGUOUS_APPROVAL",
|
|
3569
|
+
message: `Multiple pending approvals. Specify --node:\n${nodeList}`,
|
|
3570
|
+
exitCode: 4,
|
|
3571
|
+
});
|
|
3572
|
+
}
|
|
3573
|
+
nodeId = pending[0].nodeId;
|
|
3574
|
+
}
|
|
3575
|
+
await Effect.runPromise(approveNode(adapter, c.args.runId, nodeId, c.options.iteration, c.options.note, c.options.by));
|
|
3576
|
+
return c.ok({ runId: c.args.runId, nodeId, status: "approved" }, {
|
|
3577
|
+
cta: {
|
|
3578
|
+
commands: [
|
|
3579
|
+
{ command: `logs ${c.args.runId}`, description: "Tail run logs" },
|
|
3580
|
+
{ command: `ps`, description: "List all runs" },
|
|
3581
|
+
],
|
|
3582
|
+
},
|
|
3583
|
+
});
|
|
3584
|
+
}
|
|
3585
|
+
finally {
|
|
3586
|
+
cleanup();
|
|
3587
|
+
}
|
|
3588
|
+
}
|
|
3589
|
+
catch (err) {
|
|
3590
|
+
return fail({ code: "APPROVE_FAILED", message: err?.message ?? String(err), exitCode: 1 });
|
|
3591
|
+
}
|
|
3592
|
+
},
|
|
3593
|
+
})
|
|
3594
|
+
// =========================================================================
|
|
3595
|
+
// smithers signal <run_id> <signal_name>
|
|
3596
|
+
// =========================================================================
|
|
3597
|
+
.command("signal", {
|
|
3598
|
+
description: "Deliver a durable signal to a run waiting on <Signal> or <WaitForEvent>.",
|
|
3599
|
+
args: signalArgs,
|
|
3600
|
+
options: signalOptions,
|
|
3601
|
+
async run(c) {
|
|
3602
|
+
const fail = (opts) => {
|
|
3603
|
+
commandExitOverride = opts.exitCode ?? 1;
|
|
3604
|
+
return c.error(opts);
|
|
3605
|
+
};
|
|
3606
|
+
try {
|
|
3607
|
+
const { adapter, cleanup } = await findAndOpenDb();
|
|
3608
|
+
try {
|
|
3609
|
+
const payload = parseJsonInput(c.options.data, "signal data", fail) ?? {};
|
|
3610
|
+
const run = await adapter.getRun(c.args.runId);
|
|
3611
|
+
if (!run) {
|
|
3612
|
+
return fail({
|
|
3613
|
+
code: "RUN_NOT_FOUND",
|
|
3614
|
+
message: `Run not found: ${c.args.runId}`,
|
|
3615
|
+
exitCode: 4,
|
|
3616
|
+
});
|
|
3617
|
+
}
|
|
3618
|
+
const delivered = await Effect.runPromise(signalRun(adapter, c.args.runId, c.args.signalName, payload, {
|
|
3619
|
+
correlationId: c.options.correlation,
|
|
3620
|
+
receivedBy: c.options.by,
|
|
3621
|
+
}));
|
|
3622
|
+
const commands = [
|
|
3623
|
+
{ command: `why ${c.args.runId}`, description: "Explain remaining blockers" },
|
|
3624
|
+
{ command: `logs ${c.args.runId}`, description: "Tail run logs" },
|
|
3625
|
+
];
|
|
3626
|
+
if (run.workflowPath) {
|
|
3627
|
+
commands.unshift({
|
|
3628
|
+
command: `up ${run.workflowPath} --resume --run-id ${c.args.runId}`,
|
|
3629
|
+
description: "Resume the paused run",
|
|
3630
|
+
});
|
|
3631
|
+
}
|
|
3632
|
+
return c.ok({
|
|
3633
|
+
runId: c.args.runId,
|
|
3634
|
+
signalName: c.args.signalName,
|
|
3635
|
+
correlationId: c.options.correlation ?? null,
|
|
3636
|
+
seq: delivered.seq,
|
|
3637
|
+
status: "signalled",
|
|
3638
|
+
}, {
|
|
3639
|
+
cta: {
|
|
3640
|
+
commands,
|
|
3641
|
+
},
|
|
3642
|
+
});
|
|
3643
|
+
}
|
|
3644
|
+
finally {
|
|
3645
|
+
cleanup();
|
|
3646
|
+
}
|
|
3647
|
+
}
|
|
3648
|
+
catch (err) {
|
|
3649
|
+
return fail({
|
|
3650
|
+
code: err instanceof SmithersError && err.code === "RUN_NOT_FOUND"
|
|
3651
|
+
? "RUN_NOT_FOUND"
|
|
3652
|
+
: "SIGNAL_FAILED",
|
|
3653
|
+
message: err?.message ?? String(err),
|
|
3654
|
+
exitCode: err instanceof SmithersError && err.code === "RUN_NOT_FOUND" ? 4 : 1,
|
|
3655
|
+
});
|
|
3656
|
+
}
|
|
3657
|
+
},
|
|
3658
|
+
})
|
|
3659
|
+
// =========================================================================
|
|
3660
|
+
// smithers deny <run_id>
|
|
3661
|
+
// =========================================================================
|
|
3662
|
+
.command("deny", {
|
|
3663
|
+
description: "Deny a paused approval gate.",
|
|
3664
|
+
args: approveArgs,
|
|
3665
|
+
options: approveOptions,
|
|
3666
|
+
alias: { node: "n" },
|
|
3667
|
+
async run(c) {
|
|
3668
|
+
const fail = (opts) => {
|
|
3669
|
+
commandExitOverride = opts.exitCode ?? 1;
|
|
3670
|
+
return c.error(opts);
|
|
3671
|
+
};
|
|
3672
|
+
try {
|
|
3673
|
+
const { adapter, cleanup } = await findAndOpenDb();
|
|
3674
|
+
try {
|
|
3675
|
+
const pending = await adapter.listPendingApprovals(c.args.runId);
|
|
3676
|
+
if (pending.length === 0) {
|
|
3677
|
+
return fail({ code: "NO_PENDING_APPROVALS", message: `No pending approvals for run: ${c.args.runId}`, exitCode: 4 });
|
|
3678
|
+
}
|
|
3679
|
+
let nodeId = c.options.node;
|
|
3680
|
+
if (!nodeId) {
|
|
3681
|
+
if (pending.length > 1) {
|
|
3682
|
+
const nodeList = pending.map((a) => ` ${a.nodeId} (iteration ${a.iteration})`).join("\n");
|
|
3683
|
+
return fail({
|
|
3684
|
+
code: "AMBIGUOUS_APPROVAL",
|
|
3685
|
+
message: `Multiple pending approvals. Specify --node:\n${nodeList}`,
|
|
3686
|
+
exitCode: 4,
|
|
3687
|
+
});
|
|
3688
|
+
}
|
|
3689
|
+
nodeId = pending[0].nodeId;
|
|
3690
|
+
}
|
|
3691
|
+
await Effect.runPromise(denyNode(adapter, c.args.runId, nodeId, c.options.iteration, c.options.note, c.options.by));
|
|
3692
|
+
return c.ok({ runId: c.args.runId, nodeId, status: "denied" }, {
|
|
3693
|
+
cta: {
|
|
3694
|
+
commands: [
|
|
3695
|
+
{ command: `logs ${c.args.runId}`, description: "Tail run logs" },
|
|
3696
|
+
{ command: `ps`, description: "List all runs" },
|
|
3697
|
+
],
|
|
3698
|
+
},
|
|
3699
|
+
});
|
|
3700
|
+
}
|
|
3701
|
+
finally {
|
|
3702
|
+
cleanup();
|
|
3703
|
+
}
|
|
3704
|
+
}
|
|
3705
|
+
catch (err) {
|
|
3706
|
+
return fail({ code: "DENY_FAILED", message: err?.message ?? String(err), exitCode: 1 });
|
|
3707
|
+
}
|
|
3708
|
+
},
|
|
3709
|
+
})
|
|
3710
|
+
// =========================================================================
|
|
3711
|
+
// smithers cancel <run_id>
|
|
3712
|
+
// =========================================================================
|
|
3713
|
+
.command("cancel", {
|
|
3714
|
+
description: "Safely halt agents and terminate a run.",
|
|
3715
|
+
args: cancelArgs,
|
|
3716
|
+
async run(c) {
|
|
3717
|
+
const fail = (opts) => {
|
|
3718
|
+
commandExitOverride = opts.exitCode ?? 1;
|
|
3719
|
+
return c.error(opts);
|
|
3720
|
+
};
|
|
3721
|
+
try {
|
|
3722
|
+
const { adapter, cleanup } = await findAndOpenDb();
|
|
3723
|
+
try {
|
|
3724
|
+
const run = await adapter.getRun(c.args.runId);
|
|
3725
|
+
if (!run) {
|
|
3726
|
+
return fail({ code: "RUN_NOT_FOUND", message: `Run not found: ${c.args.runId}`, exitCode: 4 });
|
|
3727
|
+
}
|
|
3728
|
+
if (run.status !== "running" &&
|
|
3729
|
+
run.status !== "waiting-approval" &&
|
|
3730
|
+
run.status !== "waiting-event" &&
|
|
3731
|
+
run.status !== "waiting-timer") {
|
|
3732
|
+
return fail({ code: "RUN_NOT_ACTIVE", message: `Run is not active (status: ${run.status})`, exitCode: 4 });
|
|
3733
|
+
}
|
|
3734
|
+
const inProgress = await adapter.listInProgressAttempts(c.args.runId);
|
|
3735
|
+
const allAttempts = await adapter.listAttemptsForRun(c.args.runId);
|
|
3736
|
+
const now = Date.now();
|
|
3737
|
+
for (const attempt of inProgress) {
|
|
3738
|
+
await adapter.updateAttempt(c.args.runId, attempt.nodeId, attempt.iteration, attempt.attempt, {
|
|
3739
|
+
state: "cancelled",
|
|
3740
|
+
finishedAtMs: now,
|
|
3741
|
+
});
|
|
3742
|
+
}
|
|
3743
|
+
const waitingTimers = allAttempts.filter((attempt) => attempt.state === "waiting-timer");
|
|
3744
|
+
for (const attempt of waitingTimers) {
|
|
3745
|
+
await adapter.updateAttempt(c.args.runId, attempt.nodeId, attempt.iteration, attempt.attempt, {
|
|
3746
|
+
state: "cancelled",
|
|
3747
|
+
finishedAtMs: now,
|
|
3748
|
+
});
|
|
3749
|
+
}
|
|
3750
|
+
const nodes = await adapter.listNodes(c.args.runId);
|
|
3751
|
+
for (const node of nodes.filter((n) => n.state === "waiting-timer")) {
|
|
3752
|
+
await adapter.insertNode({
|
|
3753
|
+
runId: c.args.runId,
|
|
3754
|
+
nodeId: node.nodeId,
|
|
3755
|
+
iteration: node.iteration ?? 0,
|
|
3756
|
+
state: "cancelled",
|
|
3757
|
+
lastAttempt: node.lastAttempt ?? null,
|
|
3758
|
+
updatedAtMs: now,
|
|
3759
|
+
outputTable: node.outputTable ?? "",
|
|
3760
|
+
label: node.label ?? null,
|
|
3761
|
+
});
|
|
3762
|
+
}
|
|
3763
|
+
await adapter.updateRun(c.args.runId, { status: "cancelled", finishedAtMs: now });
|
|
3764
|
+
process.exitCode = 2;
|
|
3765
|
+
return c.ok({
|
|
3766
|
+
runId: c.args.runId,
|
|
3767
|
+
status: "cancelled",
|
|
3768
|
+
cancelledAttempts: inProgress.length + waitingTimers.length,
|
|
3769
|
+
}, {
|
|
3770
|
+
cta: {
|
|
3771
|
+
commands: [
|
|
3772
|
+
{ command: `ps`, description: "List all runs" },
|
|
3773
|
+
],
|
|
3774
|
+
},
|
|
3775
|
+
});
|
|
3776
|
+
}
|
|
3777
|
+
finally {
|
|
3778
|
+
cleanup();
|
|
3779
|
+
}
|
|
3780
|
+
}
|
|
3781
|
+
catch (err) {
|
|
3782
|
+
return fail({ code: "CANCEL_FAILED", message: err?.message ?? String(err), exitCode: 1 });
|
|
3783
|
+
}
|
|
3784
|
+
},
|
|
3785
|
+
})
|
|
3786
|
+
// =========================================================================
|
|
3787
|
+
// smithers down
|
|
3788
|
+
// =========================================================================
|
|
3789
|
+
.command("down", {
|
|
3790
|
+
description: "Cancel all active runs. Like 'docker compose down' for workflows.",
|
|
3791
|
+
options: z.object({
|
|
3792
|
+
force: z.boolean().default(false).describe("Cancel runs even if they appear stale"),
|
|
3793
|
+
}),
|
|
3794
|
+
async run(c) {
|
|
3795
|
+
const fail = (opts) => {
|
|
3796
|
+
commandExitOverride = opts.exitCode ?? 1;
|
|
3797
|
+
return c.error(opts);
|
|
3798
|
+
};
|
|
3799
|
+
try {
|
|
3800
|
+
const { adapter, cleanup } = await findAndOpenDb();
|
|
3801
|
+
try {
|
|
3802
|
+
const activeRuns = await adapter.listRuns(100, "running");
|
|
3803
|
+
const waitingApprovalRuns = await adapter.listRuns(100, "waiting-approval");
|
|
3804
|
+
const waitingEventRuns = await adapter.listRuns(100, "waiting-event");
|
|
3805
|
+
const waitingTimerRuns = await adapter.listRuns(100, "waiting-timer");
|
|
3806
|
+
const allActive = [
|
|
3807
|
+
...activeRuns,
|
|
3808
|
+
...waitingApprovalRuns,
|
|
3809
|
+
...waitingEventRuns,
|
|
3810
|
+
...waitingTimerRuns,
|
|
3811
|
+
];
|
|
3812
|
+
if (allActive.length === 0) {
|
|
3813
|
+
return c.ok({ cancelled: 0, message: "No active runs to cancel." });
|
|
3814
|
+
}
|
|
3815
|
+
const now = Date.now();
|
|
3816
|
+
let cancelled = 0;
|
|
3817
|
+
for (const run of allActive) {
|
|
3818
|
+
const inProgress = await adapter.listInProgressAttempts(run.runId);
|
|
3819
|
+
const attempts = await adapter.listAttemptsForRun(run.runId);
|
|
3820
|
+
for (const attempt of inProgress) {
|
|
3821
|
+
await adapter.updateAttempt(run.runId, attempt.nodeId, attempt.iteration, attempt.attempt, {
|
|
3822
|
+
state: "cancelled",
|
|
3823
|
+
finishedAtMs: now,
|
|
3824
|
+
});
|
|
3825
|
+
}
|
|
3826
|
+
for (const attempt of attempts.filter((entry) => entry.state === "waiting-timer")) {
|
|
3827
|
+
await adapter.updateAttempt(run.runId, attempt.nodeId, attempt.iteration, attempt.attempt, {
|
|
3828
|
+
state: "cancelled",
|
|
3829
|
+
finishedAtMs: now,
|
|
3830
|
+
});
|
|
3831
|
+
}
|
|
3832
|
+
await adapter.updateRun(run.runId, { status: "cancelled", finishedAtMs: now });
|
|
3833
|
+
process.stderr.write(`⊘ Cancelled: ${run.runId}\n`);
|
|
3834
|
+
cancelled++;
|
|
3835
|
+
}
|
|
3836
|
+
return c.ok({ cancelled, runs: allActive.map((r) => r.runId) }, { cta: { commands: [{ command: `ps`, description: "Verify all runs stopped" }] } });
|
|
3837
|
+
}
|
|
3838
|
+
finally {
|
|
3839
|
+
cleanup();
|
|
3840
|
+
}
|
|
3841
|
+
}
|
|
3842
|
+
catch (err) {
|
|
3843
|
+
return fail({ code: "DOWN_FAILED", message: err?.message ?? String(err), exitCode: 1 });
|
|
3844
|
+
}
|
|
3845
|
+
},
|
|
3846
|
+
})
|
|
3847
|
+
// =========================================================================
|
|
3848
|
+
// smithers graph <workflow>
|
|
3849
|
+
// =========================================================================
|
|
3850
|
+
.command("graph", {
|
|
3851
|
+
description: "Render the workflow graph without executing it.",
|
|
3852
|
+
args: workflowArgs,
|
|
3853
|
+
options: graphOptions,
|
|
3854
|
+
alias: { runId: "r" },
|
|
3855
|
+
async run(c) {
|
|
3856
|
+
const fail = (opts) => {
|
|
3857
|
+
commandExitOverride = opts.exitCode ?? 1;
|
|
3858
|
+
return c.error(opts);
|
|
3859
|
+
};
|
|
3860
|
+
try {
|
|
3861
|
+
const resolvedWorkflowPath = resolve(process.cwd(), c.args.workflow);
|
|
3862
|
+
const workflow = await loadWorkflow(c.args.workflow);
|
|
3863
|
+
ensureSmithersTables(workflow.db);
|
|
3864
|
+
const schema = resolveSchema(workflow.db);
|
|
3865
|
+
const inputTable = schema.input;
|
|
3866
|
+
const inputRow = c.options.input
|
|
3867
|
+
? parseJsonInput(c.options.input, "input", fail)
|
|
3868
|
+
: inputTable
|
|
3869
|
+
? ((await loadInput(workflow.db, inputTable, c.options.runId)) ?? {})
|
|
3870
|
+
: {};
|
|
3871
|
+
const outputs = await loadOutputs(workflow.db, schema, c.options.runId);
|
|
3872
|
+
const ctx = new SmithersCtx({
|
|
3873
|
+
runId: c.options.runId,
|
|
3874
|
+
iteration: 0,
|
|
3875
|
+
input: inputRow ?? {},
|
|
3876
|
+
outputs,
|
|
3877
|
+
});
|
|
3878
|
+
const baseRootDir = dirname(resolvedWorkflowPath);
|
|
3879
|
+
const snap = await Effect.runPromise(renderFrame(workflow, ctx, {
|
|
3880
|
+
baseRootDir,
|
|
3881
|
+
workflowPath: resolvedWorkflowPath,
|
|
3882
|
+
}));
|
|
3883
|
+
const seen = new WeakSet();
|
|
3884
|
+
return c.ok(JSON.parse(JSON.stringify(snap, (_key, value) => {
|
|
3885
|
+
if (typeof value === "function")
|
|
3886
|
+
return undefined;
|
|
3887
|
+
if (typeof value === "object" && value !== null) {
|
|
3888
|
+
if (seen.has(value))
|
|
3889
|
+
return undefined;
|
|
3890
|
+
seen.add(value);
|
|
3891
|
+
}
|
|
3892
|
+
return value;
|
|
3893
|
+
})));
|
|
3894
|
+
}
|
|
3895
|
+
catch (err) {
|
|
3896
|
+
return fail({ code: "GRAPH_FAILED", message: err?.message ?? String(err), exitCode: 1 });
|
|
3897
|
+
}
|
|
3898
|
+
},
|
|
3899
|
+
})
|
|
3900
|
+
// =========================================================================
|
|
3901
|
+
// smithers revert <workflow>
|
|
3902
|
+
// =========================================================================
|
|
3903
|
+
.command("revert", {
|
|
3904
|
+
description: "Revert the workspace to a previous task attempt's filesystem state.",
|
|
3905
|
+
args: workflowArgs,
|
|
3906
|
+
options: revertOptions,
|
|
3907
|
+
alias: { runId: "r", nodeId: "n" },
|
|
3908
|
+
async run(c) {
|
|
3909
|
+
const fail = (opts) => {
|
|
3910
|
+
commandExitOverride = opts.exitCode ?? 1;
|
|
3911
|
+
return c.error(opts);
|
|
3912
|
+
};
|
|
3913
|
+
try {
|
|
3914
|
+
const { adapter, cleanup } = await loadWorkflowDb(c.args.workflow);
|
|
3915
|
+
try {
|
|
3916
|
+
const result = await revertToAttempt(adapter, {
|
|
3917
|
+
runId: c.options.runId,
|
|
3918
|
+
nodeId: c.options.nodeId,
|
|
3919
|
+
iteration: c.options.iteration,
|
|
3920
|
+
attempt: c.options.attempt,
|
|
3921
|
+
onProgress: (e) => console.log(JSON.stringify(e)),
|
|
3922
|
+
});
|
|
3923
|
+
process.exitCode = result.success ? 0 : 1;
|
|
3924
|
+
return c.ok(result);
|
|
3925
|
+
}
|
|
3926
|
+
finally {
|
|
3927
|
+
cleanup?.();
|
|
3928
|
+
}
|
|
3929
|
+
}
|
|
3930
|
+
catch (err) {
|
|
3931
|
+
return fail({ code: "REVERT_FAILED", message: err?.message ?? String(err), exitCode: 1 });
|
|
3932
|
+
}
|
|
3933
|
+
},
|
|
3934
|
+
})
|
|
3935
|
+
// =========================================================================
|
|
3936
|
+
// smithers retry-task <workflow>
|
|
3937
|
+
// =========================================================================
|
|
3938
|
+
.command("retry-task", {
|
|
3939
|
+
description: "Retry a specific task within a run, then resume the workflow.",
|
|
3940
|
+
args: workflowArgs,
|
|
3941
|
+
options: z.object({
|
|
3942
|
+
runId: z.string().describe("Run ID containing the task"),
|
|
3943
|
+
nodeId: z.string().describe("Task/node ID to retry"),
|
|
3944
|
+
iteration: z.number().int().default(0).describe("Loop iteration"),
|
|
3945
|
+
noDeps: z.boolean().default(false).describe("Only reset this node, not dependents"),
|
|
3946
|
+
force: z.boolean().default(false).describe("Allow retry even if run is still running"),
|
|
3947
|
+
}),
|
|
3948
|
+
alias: { runId: "r", nodeId: "n" },
|
|
3949
|
+
async run(c) {
|
|
3950
|
+
const fail = (opts) => {
|
|
3951
|
+
commandExitOverride = opts.exitCode ?? 1;
|
|
3952
|
+
return c.error(opts);
|
|
3953
|
+
};
|
|
3954
|
+
try {
|
|
3955
|
+
const { adapter, cleanup } = await loadWorkflowDb(c.args.workflow);
|
|
3956
|
+
try {
|
|
3957
|
+
const onProgress = buildProgressReporter();
|
|
3958
|
+
const resetResult = await retryTask(adapter, {
|
|
3959
|
+
runId: c.options.runId,
|
|
3960
|
+
nodeId: c.options.nodeId,
|
|
3961
|
+
iteration: c.options.iteration,
|
|
3962
|
+
resetDependents: !c.options.noDeps,
|
|
3963
|
+
force: c.options.force,
|
|
3964
|
+
onProgress,
|
|
3965
|
+
});
|
|
3966
|
+
if (!resetResult.success) {
|
|
3967
|
+
process.exitCode = 1;
|
|
3968
|
+
return c.ok(resetResult);
|
|
3969
|
+
}
|
|
3970
|
+
const workflow = await loadWorkflow(c.args.workflow);
|
|
3971
|
+
const abort = setupAbortSignal();
|
|
3972
|
+
const runResult = await Effect.runPromise(runWorkflow(workflow, {
|
|
3973
|
+
input: {},
|
|
3974
|
+
runId: c.options.runId,
|
|
3975
|
+
workflowPath: c.args.workflow,
|
|
3976
|
+
resume: true,
|
|
3977
|
+
force: c.options.force,
|
|
3978
|
+
onProgress,
|
|
3979
|
+
signal: abort.signal,
|
|
3980
|
+
}));
|
|
3981
|
+
process.exitCode = formatStatusExitCode(runResult.status);
|
|
3982
|
+
return c.ok({
|
|
3983
|
+
...resetResult,
|
|
3984
|
+
status: runResult.status,
|
|
3985
|
+
error: runResult.error,
|
|
3986
|
+
});
|
|
3987
|
+
}
|
|
3988
|
+
finally {
|
|
3989
|
+
cleanup?.();
|
|
3990
|
+
}
|
|
3991
|
+
}
|
|
3992
|
+
catch (err) {
|
|
3993
|
+
return fail({ code: "RETRY_TASK_FAILED", message: err?.message ?? String(err), exitCode: 1 });
|
|
3994
|
+
}
|
|
3995
|
+
},
|
|
3996
|
+
})
|
|
3997
|
+
// =========================================================================
|
|
3998
|
+
// smithers timetravel <workflow>
|
|
3999
|
+
// =========================================================================
|
|
4000
|
+
.command("timetravel", {
|
|
4001
|
+
description: "Time-travel to a previous task state: revert filesystem, reset DB, and optionally resume.",
|
|
4002
|
+
args: workflowArgs,
|
|
4003
|
+
options: z.object({
|
|
4004
|
+
runId: z.string().describe("Run ID"),
|
|
4005
|
+
nodeId: z.string().describe("Task/node ID to travel back to"),
|
|
4006
|
+
iteration: z.number().int().default(0).describe("Loop iteration"),
|
|
4007
|
+
attempt: z.number().int().optional().describe("Attempt number (default: latest)"),
|
|
4008
|
+
noVcs: z.boolean().default(false).describe("Skip filesystem revert (DB only)"),
|
|
4009
|
+
noDeps: z.boolean().default(false).describe("Only reset this node, not dependents"),
|
|
4010
|
+
resume: z.boolean().default(false).describe("Resume the workflow after time travel"),
|
|
4011
|
+
force: z.boolean().default(false).describe("Force even if run is still running"),
|
|
4012
|
+
}),
|
|
4013
|
+
alias: { runId: "r", nodeId: "n", attempt: "a" },
|
|
4014
|
+
async run(c) {
|
|
4015
|
+
const fail = (opts) => {
|
|
4016
|
+
commandExitOverride = opts.exitCode ?? 1;
|
|
4017
|
+
return c.error(opts);
|
|
4018
|
+
};
|
|
4019
|
+
try {
|
|
4020
|
+
const { adapter, cleanup } = await loadWorkflowDb(c.args.workflow);
|
|
4021
|
+
try {
|
|
4022
|
+
const run = await adapter.getRun(c.options.runId);
|
|
4023
|
+
if (run?.status === "running" && !c.options.force) {
|
|
4024
|
+
return fail({
|
|
4025
|
+
code: "RUN_STILL_RUNNING",
|
|
4026
|
+
message: `Run ${c.options.runId} is still marked running. Re-run with --force to time-travel it anyway.`,
|
|
4027
|
+
exitCode: 4,
|
|
4028
|
+
});
|
|
4029
|
+
}
|
|
4030
|
+
const result = await timeTravel(adapter, {
|
|
4031
|
+
runId: c.options.runId,
|
|
4032
|
+
nodeId: c.options.nodeId,
|
|
4033
|
+
iteration: c.options.iteration,
|
|
4034
|
+
attempt: c.options.attempt,
|
|
4035
|
+
resetDependents: !c.options.noDeps,
|
|
4036
|
+
restoreVcs: !c.options.noVcs,
|
|
4037
|
+
onProgress: (e) => console.log(JSON.stringify(e)),
|
|
4038
|
+
});
|
|
4039
|
+
if (!result.success || !c.options.resume) {
|
|
4040
|
+
process.exitCode = result.success ? 0 : 1;
|
|
4041
|
+
return c.ok(result);
|
|
4042
|
+
}
|
|
4043
|
+
process.stderr.write(`[smithers] Time travel reset ${result.resetNodes.join(", ")} on run ${c.options.runId}\n`);
|
|
4044
|
+
if (result.vcsRestored && result.jjPointer) {
|
|
4045
|
+
process.stderr.write(`[smithers] VCS state restored to ${result.jjPointer}\n`);
|
|
4046
|
+
}
|
|
4047
|
+
process.stderr.write(`[smithers] Resuming run...\n`);
|
|
4048
|
+
const workflow = await loadWorkflow(c.args.workflow);
|
|
4049
|
+
const onProgress = buildProgressReporter();
|
|
4050
|
+
const abort = setupAbortSignal();
|
|
4051
|
+
const runResult = await Effect.runPromise(runWorkflow(workflow, {
|
|
4052
|
+
input: {},
|
|
4053
|
+
runId: c.options.runId,
|
|
4054
|
+
workflowPath: c.args.workflow,
|
|
4055
|
+
resume: true,
|
|
4056
|
+
force: true,
|
|
4057
|
+
onProgress,
|
|
4058
|
+
signal: abort.signal,
|
|
4059
|
+
}));
|
|
4060
|
+
process.exitCode = formatStatusExitCode(runResult.status);
|
|
4061
|
+
return c.ok({
|
|
4062
|
+
...result,
|
|
4063
|
+
resumed: true,
|
|
4064
|
+
status: runResult.status,
|
|
4065
|
+
});
|
|
4066
|
+
}
|
|
4067
|
+
finally {
|
|
4068
|
+
cleanup?.();
|
|
4069
|
+
}
|
|
4070
|
+
}
|
|
4071
|
+
catch (err) {
|
|
4072
|
+
return fail({ code: "TIMETRAVEL_FAILED", message: err?.message ?? String(err), exitCode: 1 });
|
|
4073
|
+
}
|
|
4074
|
+
},
|
|
4075
|
+
})
|
|
4076
|
+
// =========================================================================
|
|
4077
|
+
// smithers observability
|
|
4078
|
+
// =========================================================================
|
|
4079
|
+
.command("observability", {
|
|
4080
|
+
description: "Start the local observability stack (Grafana, Prometheus, Tempo, OTLP Collector) via Docker Compose.",
|
|
4081
|
+
options: z.object({
|
|
4082
|
+
detach: z.boolean().default(false).describe("Run containers in the background"),
|
|
4083
|
+
down: z.boolean().default(false).describe("Stop and remove the observability stack"),
|
|
4084
|
+
}),
|
|
4085
|
+
alias: { detach: "d" },
|
|
4086
|
+
async run(c) {
|
|
4087
|
+
const fail = (opts) => {
|
|
4088
|
+
commandExitOverride = opts.exitCode ?? 1;
|
|
4089
|
+
return c.error(opts);
|
|
4090
|
+
};
|
|
4091
|
+
const composeDir = resolve(dirname(new URL(import.meta.url).pathname), "../../observability");
|
|
4092
|
+
const composeFile = resolve(composeDir, "docker-compose.otel.yml");
|
|
4093
|
+
if (!existsSync(composeFile)) {
|
|
4094
|
+
return fail({
|
|
4095
|
+
code: "COMPOSE_NOT_FOUND",
|
|
4096
|
+
message: `Docker Compose file not found at ${composeFile}. Ensure the smithers-orchestrator package includes the observability/ directory.`,
|
|
4097
|
+
exitCode: 1,
|
|
4098
|
+
});
|
|
4099
|
+
}
|
|
4100
|
+
const composeArgs = [
|
|
4101
|
+
"compose", "-f", composeFile,
|
|
4102
|
+
...(c.options.down ? ["down"] : ["up", ...(c.options.detach ? ["-d"] : [])]),
|
|
4103
|
+
];
|
|
4104
|
+
process.stderr.write(c.options.down
|
|
4105
|
+
? `[smithers] Stopping observability stack...\n`
|
|
4106
|
+
: `[smithers] Starting observability stack...\n` +
|
|
4107
|
+
` Grafana: http://localhost:3001\n` +
|
|
4108
|
+
` Prometheus: http://localhost:9090\n` +
|
|
4109
|
+
` Tempo: http://localhost:3200\n`);
|
|
4110
|
+
const child = spawn("docker", composeArgs, { stdio: "inherit", cwd: composeDir });
|
|
4111
|
+
const result = await new Promise((resolve) => {
|
|
4112
|
+
child.on("close", (code) => resolve({ exitCode: code ?? 0 }));
|
|
4113
|
+
child.on("error", (err) => {
|
|
4114
|
+
process.stderr.write(`Failed to run docker compose: ${err.message}\n`);
|
|
4115
|
+
process.stderr.write(`Make sure Docker is installed and running.\n`);
|
|
4116
|
+
resolve({ exitCode: 1 });
|
|
4117
|
+
});
|
|
4118
|
+
});
|
|
4119
|
+
process.exitCode = result.exitCode;
|
|
4120
|
+
return c.ok({ action: c.options.down ? "down" : "up", exitCode: result.exitCode });
|
|
4121
|
+
},
|
|
4122
|
+
})
|
|
4123
|
+
// =========================================================================
|
|
4124
|
+
// smithers ask <question>
|
|
4125
|
+
// =========================================================================
|
|
4126
|
+
.command("ask", {
|
|
4127
|
+
description: "Ask a question about Smithers using your installed agent and the Smithers MCP server.",
|
|
4128
|
+
args: z.object({
|
|
4129
|
+
question: z.string().optional().describe("The question to ask"),
|
|
4130
|
+
}),
|
|
4131
|
+
options: z.object({
|
|
4132
|
+
agent: z.enum(["claude", "codex", "gemini", "kimi", "pi"]).optional().describe("Explicitly select which agent CLI to use"),
|
|
4133
|
+
listAgents: z.boolean().default(false).describe("List detected agents plus their bootstrap mode and exit"),
|
|
4134
|
+
dumpPrompt: z.boolean().default(false).describe("Print the generated system prompt and exit"),
|
|
4135
|
+
toolSurface: z.enum(["semantic", "raw"]).default("semantic").describe("Choose which Smithers MCP tool surface to expose"),
|
|
4136
|
+
noMcp: z.boolean().default(false).describe("Disable MCP bootstrap and use prompt-only fallback"),
|
|
4137
|
+
printBootstrap: z.boolean().default(false).describe("Print the selected bootstrap configuration and exit"),
|
|
4138
|
+
}),
|
|
4139
|
+
async run(c) {
|
|
4140
|
+
try {
|
|
4141
|
+
await ask(c.args.question, process.cwd(), c.options);
|
|
4142
|
+
return c.ok(undefined);
|
|
4143
|
+
}
|
|
4144
|
+
catch (err) {
|
|
4145
|
+
commandExitOverride = 1;
|
|
4146
|
+
return c.error({
|
|
4147
|
+
code: "ASK_FAILED",
|
|
4148
|
+
message: err?.message ?? String(err),
|
|
4149
|
+
});
|
|
4150
|
+
}
|
|
4151
|
+
},
|
|
4152
|
+
})
|
|
4153
|
+
// =========================================================================
|
|
4154
|
+
// smithers scores <run_id>
|
|
4155
|
+
// =========================================================================
|
|
4156
|
+
.command("scores", {
|
|
4157
|
+
description: "View scorer results for a specific run.",
|
|
4158
|
+
args: z.object({ runId: z.string().describe("Run ID to inspect") }),
|
|
4159
|
+
options: z.object({
|
|
4160
|
+
node: z.string().optional().describe("Filter scores to a specific node ID"),
|
|
4161
|
+
}),
|
|
4162
|
+
async run(c) {
|
|
4163
|
+
const fail = (opts) => {
|
|
4164
|
+
commandExitOverride = opts.exitCode ?? 1;
|
|
4165
|
+
return c.error(opts);
|
|
4166
|
+
};
|
|
4167
|
+
try {
|
|
4168
|
+
const { adapter, cleanup } = await findAndOpenDb();
|
|
4169
|
+
try {
|
|
4170
|
+
const results = await adapter.listScorerResults(c.args.runId, c.options.node);
|
|
4171
|
+
if (!results || results.length === 0) {
|
|
4172
|
+
return c.ok({ scores: [], message: "No scores found for this run." });
|
|
4173
|
+
}
|
|
4174
|
+
const rows = results.map((r) => ({
|
|
4175
|
+
node: r.nodeId,
|
|
4176
|
+
scorer: r.scorerName,
|
|
4177
|
+
score: typeof r.score === "number" ? r.score.toFixed(2) : String(r.score),
|
|
4178
|
+
reason: r.reason ?? "—",
|
|
4179
|
+
source: r.source,
|
|
4180
|
+
}));
|
|
4181
|
+
return c.ok({ scores: rows });
|
|
4182
|
+
}
|
|
4183
|
+
finally {
|
|
4184
|
+
cleanup();
|
|
4185
|
+
}
|
|
4186
|
+
}
|
|
4187
|
+
catch (err) {
|
|
4188
|
+
return fail({ code: "SCORES_FAILED", message: err?.message ?? String(err), exitCode: 1 });
|
|
4189
|
+
}
|
|
4190
|
+
},
|
|
4191
|
+
})
|
|
4192
|
+
// =========================================================================
|
|
4193
|
+
// smithers replay <workflow>
|
|
4194
|
+
// =========================================================================
|
|
4195
|
+
.command("replay", {
|
|
4196
|
+
description: "Fork from a checkpoint and resume execution (time travel).",
|
|
4197
|
+
args: workflowArgs,
|
|
4198
|
+
options: z.object({
|
|
4199
|
+
runId: z.string().describe("Source run ID to replay from"),
|
|
4200
|
+
frame: z.number().int().describe("Frame number to fork from"),
|
|
4201
|
+
node: z.string().optional().describe("Node ID to reset to pending"),
|
|
4202
|
+
input: z.string().optional().describe("Input overrides as JSON string"),
|
|
4203
|
+
label: z.string().optional().describe("Branch label for the fork"),
|
|
4204
|
+
restoreVcs: z.boolean().default(false).describe("Restore jj filesystem state to the source frame's revision"),
|
|
4205
|
+
}),
|
|
4206
|
+
alias: { runId: "r", frame: "f", node: "n", input: "i", label: "l" },
|
|
4207
|
+
async run(c) {
|
|
4208
|
+
const fail = (opts) => {
|
|
4209
|
+
commandExitOverride = opts.exitCode ?? 1;
|
|
4210
|
+
return c.error(opts);
|
|
4211
|
+
};
|
|
4212
|
+
try {
|
|
4213
|
+
const { replayFromCheckpoint } = await import("@smithers-orchestrator/time-travel/replay");
|
|
4214
|
+
const { adapter, cleanup } = await loadWorkflowDb(c.args.workflow);
|
|
4215
|
+
try {
|
|
4216
|
+
const inputOverrides = parseJsonInput(c.options.input, "input", fail);
|
|
4217
|
+
const resetNodes = c.options.node ? [c.options.node] : undefined;
|
|
4218
|
+
const result = await replayFromCheckpoint(adapter, {
|
|
4219
|
+
parentRunId: c.options.runId,
|
|
4220
|
+
frameNo: c.options.frame,
|
|
4221
|
+
inputOverrides,
|
|
4222
|
+
resetNodes,
|
|
4223
|
+
branchLabel: c.options.label,
|
|
4224
|
+
restoreVcs: c.options.restoreVcs,
|
|
4225
|
+
});
|
|
4226
|
+
process.stderr.write(`[smithers] Forked run ${result.runId} from ${c.options.runId}:${c.options.frame}\n`);
|
|
4227
|
+
if (result.vcsRestored) {
|
|
4228
|
+
process.stderr.write(`[smithers] VCS state restored to ${result.vcsPointer}\n`);
|
|
4229
|
+
}
|
|
4230
|
+
// Now resume the forked run
|
|
4231
|
+
process.stderr.write(`[smithers] Resuming forked run...\n`);
|
|
4232
|
+
const workflow = await loadWorkflow(c.args.workflow);
|
|
4233
|
+
const onProgress = buildProgressReporter();
|
|
4234
|
+
const abort = setupAbortSignal();
|
|
4235
|
+
const engine = await import("@smithers-orchestrator/engine");
|
|
4236
|
+
const runResult = await Effect.runPromise(engine.runWorkflow(workflow, {
|
|
4237
|
+
input: {},
|
|
4238
|
+
runId: result.runId,
|
|
4239
|
+
workflowPath: c.args.workflow,
|
|
4240
|
+
resume: true,
|
|
4241
|
+
force: true,
|
|
4242
|
+
onProgress,
|
|
4243
|
+
signal: abort.signal,
|
|
4244
|
+
}));
|
|
4245
|
+
process.exitCode = formatStatusExitCode(runResult.status);
|
|
4246
|
+
return c.ok({
|
|
4247
|
+
forkedRunId: result.runId,
|
|
4248
|
+
parentRunId: c.options.runId,
|
|
4249
|
+
parentFrame: c.options.frame,
|
|
4250
|
+
vcsRestored: result.vcsRestored,
|
|
4251
|
+
status: runResult.status,
|
|
4252
|
+
});
|
|
4253
|
+
}
|
|
4254
|
+
finally {
|
|
4255
|
+
cleanup?.();
|
|
4256
|
+
}
|
|
4257
|
+
}
|
|
4258
|
+
catch (err) {
|
|
4259
|
+
return fail({ code: "REPLAY_FAILED", message: err?.message ?? String(err), exitCode: 1 });
|
|
4260
|
+
}
|
|
4261
|
+
},
|
|
4262
|
+
})
|
|
4263
|
+
// =========================================================================
|
|
4264
|
+
// smithers tree <runId>
|
|
4265
|
+
// Findings #1, #2, #3, #7, #11 addressed here.
|
|
4266
|
+
// =========================================================================
|
|
4267
|
+
.command("tree", {
|
|
4268
|
+
description: "Print DevTools snapshot as XML tree.",
|
|
4269
|
+
args: z.object({
|
|
4270
|
+
runId: z.string().describe("Run ID to inspect"),
|
|
4271
|
+
}),
|
|
4272
|
+
options: z.object({
|
|
4273
|
+
frame: z.number().int().min(0).optional().describe("Historical frame number"),
|
|
4274
|
+
watch: z.boolean().default(false).describe("Stream live events"),
|
|
4275
|
+
json: z.boolean().default(false).describe("Emit snapshot JSON"),
|
|
4276
|
+
depth: z.number().int().min(1).optional().describe("Truncate depth"),
|
|
4277
|
+
node: z.string().optional().describe("Scope to subtree"),
|
|
4278
|
+
color: z.enum(["auto", "always", "never"]).default("auto").describe("Colorize output"),
|
|
4279
|
+
}),
|
|
4280
|
+
// Finding #3: --json collides with incur's format flag. Expose -j as
|
|
4281
|
+
// a command-scoped alias; rewriteDevtoolsJsonFlagArgv() in main()
|
|
4282
|
+
// rewrites raw `--json` → `-j` for these commands so it lands as a
|
|
4283
|
+
// command option, not a format directive.
|
|
4284
|
+
alias: { json: "j" },
|
|
4285
|
+
async run(c) {
|
|
4286
|
+
return runDevtoolsCommandWithTelemetry("tree", c, async () => {
|
|
4287
|
+
const { runTreeOnce, runTreeWatch } = await import("./tree.js");
|
|
4288
|
+
const { adapter, cleanup } = await findAndOpenDb();
|
|
4289
|
+
try {
|
|
4290
|
+
const color = resolveCliColor(c.options.color, process.stdout);
|
|
4291
|
+
if (c.options.watch) {
|
|
4292
|
+
const abort = new AbortController();
|
|
4293
|
+
const onSignal = () => abort.abort();
|
|
4294
|
+
process.once("SIGINT", onSignal);
|
|
4295
|
+
process.once("SIGTERM", onSignal);
|
|
4296
|
+
try {
|
|
4297
|
+
const result = await runTreeWatch({
|
|
4298
|
+
adapter,
|
|
4299
|
+
runId: c.args.runId,
|
|
4300
|
+
frameNo: c.options.frame,
|
|
4301
|
+
node: c.options.node,
|
|
4302
|
+
depth: c.options.depth,
|
|
4303
|
+
json: c.options.json,
|
|
4304
|
+
watch: true,
|
|
4305
|
+
color,
|
|
4306
|
+
stdout: process.stdout,
|
|
4307
|
+
stderr: process.stderr,
|
|
4308
|
+
abortSignal: abort.signal,
|
|
4309
|
+
});
|
|
4310
|
+
return result.exitCode;
|
|
4311
|
+
} finally {
|
|
4312
|
+
process.off("SIGINT", onSignal);
|
|
4313
|
+
process.off("SIGTERM", onSignal);
|
|
4314
|
+
}
|
|
4315
|
+
}
|
|
4316
|
+
const result = await runTreeOnce({
|
|
4317
|
+
adapter,
|
|
4318
|
+
runId: c.args.runId,
|
|
4319
|
+
frameNo: c.options.frame,
|
|
4320
|
+
node: c.options.node,
|
|
4321
|
+
depth: c.options.depth,
|
|
4322
|
+
json: c.options.json,
|
|
4323
|
+
watch: false,
|
|
4324
|
+
color,
|
|
4325
|
+
stdout: process.stdout,
|
|
4326
|
+
stderr: process.stderr,
|
|
4327
|
+
});
|
|
4328
|
+
return result.exitCode;
|
|
4329
|
+
} finally {
|
|
4330
|
+
cleanup();
|
|
4331
|
+
}
|
|
4332
|
+
});
|
|
4333
|
+
},
|
|
4334
|
+
})
|
|
4335
|
+
// =========================================================================
|
|
4336
|
+
// smithers diff <runId> <nodeId>
|
|
4337
|
+
// =========================================================================
|
|
4338
|
+
.command("diff", {
|
|
4339
|
+
description: "Print DiffBundle as unified diff.",
|
|
4340
|
+
args: z.object({
|
|
4341
|
+
runId: z.string().describe("Run ID containing the node"),
|
|
4342
|
+
nodeId: z.string().describe("Node ID to diff"),
|
|
4343
|
+
}),
|
|
4344
|
+
options: z.object({
|
|
4345
|
+
iteration: z.number().int().min(0).optional().describe("Loop iteration"),
|
|
4346
|
+
json: z.boolean().default(false).describe("Emit raw DiffBundle"),
|
|
4347
|
+
stat: z.boolean().default(false).describe("Show stat summary only"),
|
|
4348
|
+
color: z.enum(["auto", "always", "never"]).default("auto").describe("Colorize output"),
|
|
4349
|
+
}),
|
|
4350
|
+
alias: { json: "j" },
|
|
4351
|
+
async run(c) {
|
|
4352
|
+
return runDevtoolsCommandWithTelemetry("diff", c, async () => {
|
|
4353
|
+
const { runDiffOnce } = await import("./diff.js");
|
|
4354
|
+
const { adapter, cleanup } = await findAndOpenDb();
|
|
4355
|
+
try {
|
|
4356
|
+
const color = resolveCliColor(c.options.color, process.stdout);
|
|
4357
|
+
const result = await runDiffOnce({
|
|
4358
|
+
adapter,
|
|
4359
|
+
runId: c.args.runId,
|
|
4360
|
+
nodeId: c.args.nodeId,
|
|
4361
|
+
iteration: c.options.iteration,
|
|
4362
|
+
json: c.options.json,
|
|
4363
|
+
stat: c.options.stat,
|
|
4364
|
+
color,
|
|
4365
|
+
stdout: process.stdout,
|
|
4366
|
+
stderr: process.stderr,
|
|
4367
|
+
});
|
|
4368
|
+
return result.exitCode;
|
|
4369
|
+
} finally {
|
|
4370
|
+
cleanup();
|
|
4371
|
+
}
|
|
4372
|
+
});
|
|
4373
|
+
},
|
|
4374
|
+
})
|
|
4375
|
+
// =========================================================================
|
|
4376
|
+
// smithers output <runId> <nodeId>
|
|
4377
|
+
// =========================================================================
|
|
4378
|
+
.command("output", {
|
|
4379
|
+
description: "Print node output row.",
|
|
4380
|
+
args: z.object({
|
|
4381
|
+
runId: z.string().describe("Run ID containing the node"),
|
|
4382
|
+
nodeId: z.string().describe("Node ID to fetch output for"),
|
|
4383
|
+
}),
|
|
4384
|
+
options: z.object({
|
|
4385
|
+
iteration: z.number().int().min(0).optional().describe("Loop iteration"),
|
|
4386
|
+
json: z.boolean().default(true).describe("Emit raw row as JSON"),
|
|
4387
|
+
pretty: z.boolean().default(false).describe("Schema-ordered render"),
|
|
4388
|
+
}),
|
|
4389
|
+
alias: { json: "j" },
|
|
4390
|
+
async run(c) {
|
|
4391
|
+
return runDevtoolsCommandWithTelemetry("output", c, async () => {
|
|
4392
|
+
const { runOutputOnce } = await import("./output.js");
|
|
4393
|
+
const { adapter, cleanup } = await findAndOpenDb();
|
|
4394
|
+
try {
|
|
4395
|
+
const result = await runOutputOnce({
|
|
4396
|
+
adapter,
|
|
4397
|
+
runId: c.args.runId,
|
|
4398
|
+
nodeId: c.args.nodeId,
|
|
4399
|
+
iteration: c.options.iteration,
|
|
4400
|
+
json: c.options.json && !c.options.pretty,
|
|
4401
|
+
pretty: c.options.pretty,
|
|
4402
|
+
stdout: process.stdout,
|
|
4403
|
+
stderr: process.stderr,
|
|
4404
|
+
});
|
|
4405
|
+
return result.exitCode;
|
|
4406
|
+
} finally {
|
|
4407
|
+
cleanup();
|
|
4408
|
+
}
|
|
4409
|
+
});
|
|
4410
|
+
},
|
|
4411
|
+
})
|
|
4412
|
+
// =========================================================================
|
|
4413
|
+
// smithers rewind <runId> <frameNo>
|
|
4414
|
+
// =========================================================================
|
|
4415
|
+
.command("rewind", {
|
|
4416
|
+
description: "Rewind a run to a previous frame.",
|
|
4417
|
+
args: z.object({
|
|
4418
|
+
runId: z.string().describe("Run ID to rewind"),
|
|
4419
|
+
frameNo: z.number().int().min(0).describe("Target frame number"),
|
|
4420
|
+
}),
|
|
4421
|
+
options: z.object({
|
|
4422
|
+
yes: z.boolean().default(false).describe("Skip confirmation"),
|
|
4423
|
+
json: z.boolean().default(false).describe("Emit JumpResult JSON"),
|
|
4424
|
+
}),
|
|
4425
|
+
alias: { json: "j" },
|
|
4426
|
+
async run(c) {
|
|
4427
|
+
return runDevtoolsCommandWithTelemetry("rewind", c, async () => {
|
|
4428
|
+
const { runRewindOnce } = await import("./rewind.js");
|
|
4429
|
+
const { adapter, cleanup } = await findAndOpenDb();
|
|
4430
|
+
try {
|
|
4431
|
+
const result = await runRewindOnce({
|
|
4432
|
+
adapter,
|
|
4433
|
+
runId: c.args.runId,
|
|
4434
|
+
frameNo: c.args.frameNo,
|
|
4435
|
+
yes: c.options.yes,
|
|
4436
|
+
json: c.options.json,
|
|
4437
|
+
stdin: process.stdin,
|
|
4438
|
+
stdout: process.stdout,
|
|
4439
|
+
stderr: process.stderr,
|
|
4440
|
+
});
|
|
4441
|
+
return result.exitCode;
|
|
4442
|
+
} finally {
|
|
4443
|
+
cleanup();
|
|
4444
|
+
}
|
|
4445
|
+
});
|
|
4446
|
+
},
|
|
4447
|
+
})
|
|
4448
|
+
// =========================================================================
|
|
4449
|
+
// smithers fork <workflow>
|
|
4450
|
+
// =========================================================================
|
|
4451
|
+
.command("fork", {
|
|
4452
|
+
description: "Create a branched run from a snapshot checkpoint (time travel).",
|
|
4453
|
+
args: workflowArgs,
|
|
4454
|
+
options: z.object({
|
|
4455
|
+
runId: z.string().describe("Source run ID"),
|
|
4456
|
+
frame: z.number().int().describe("Frame number to fork from"),
|
|
4457
|
+
resetNode: z.string().optional().describe("Node ID to reset to pending"),
|
|
4458
|
+
input: z.string().optional().describe("Input overrides as JSON string"),
|
|
4459
|
+
label: z.string().optional().describe("Branch label"),
|
|
4460
|
+
run: z.boolean().default(false).describe("Immediately start the forked run"),
|
|
4461
|
+
}),
|
|
4462
|
+
alias: { runId: "r", frame: "f", resetNode: "n", input: "i", label: "l" },
|
|
4463
|
+
async run(c) {
|
|
4464
|
+
const fail = (opts) => {
|
|
4465
|
+
commandExitOverride = opts.exitCode ?? 1;
|
|
4466
|
+
return c.error(opts);
|
|
4467
|
+
};
|
|
4468
|
+
try {
|
|
4469
|
+
const { forkRun } = await import("@smithers-orchestrator/time-travel/fork");
|
|
4470
|
+
const { adapter, cleanup } = await loadWorkflowDb(c.args.workflow);
|
|
4471
|
+
try {
|
|
4472
|
+
const inputOverrides = parseJsonInput(c.options.input, "input", fail);
|
|
4473
|
+
const resetNodes = c.options.resetNode ? [c.options.resetNode] : undefined;
|
|
4474
|
+
const result = await forkRun(adapter, {
|
|
4475
|
+
parentRunId: c.options.runId,
|
|
4476
|
+
frameNo: c.options.frame,
|
|
4477
|
+
inputOverrides,
|
|
4478
|
+
resetNodes,
|
|
4479
|
+
branchLabel: c.options.label,
|
|
4480
|
+
});
|
|
4481
|
+
process.stderr.write(`[smithers] Forked run ${result.runId} from ${c.options.runId}:${c.options.frame}\n`);
|
|
4482
|
+
if (c.options.run) {
|
|
4483
|
+
process.stderr.write(`[smithers] Starting forked run...\n`);
|
|
4484
|
+
const workflow = await loadWorkflow(c.args.workflow);
|
|
4485
|
+
const onProgress = buildProgressReporter();
|
|
4486
|
+
const abort = setupAbortSignal();
|
|
4487
|
+
const engine = await import("@smithers-orchestrator/engine");
|
|
4488
|
+
const runResult = await Effect.runPromise(engine.runWorkflow(workflow, {
|
|
4489
|
+
input: {},
|
|
4490
|
+
runId: result.runId,
|
|
4491
|
+
workflowPath: c.args.workflow,
|
|
4492
|
+
resume: true,
|
|
4493
|
+
force: true,
|
|
4494
|
+
onProgress,
|
|
4495
|
+
signal: abort.signal,
|
|
4496
|
+
}));
|
|
4497
|
+
process.exitCode = formatStatusExitCode(runResult.status);
|
|
4498
|
+
return c.ok({
|
|
4499
|
+
forkedRunId: result.runId,
|
|
4500
|
+
parentRunId: c.options.runId,
|
|
4501
|
+
parentFrame: c.options.frame,
|
|
4502
|
+
started: true,
|
|
4503
|
+
status: runResult.status,
|
|
4504
|
+
});
|
|
4505
|
+
}
|
|
4506
|
+
return c.ok({
|
|
4507
|
+
forkedRunId: result.runId,
|
|
4508
|
+
parentRunId: c.options.runId,
|
|
4509
|
+
parentFrame: c.options.frame,
|
|
4510
|
+
started: false,
|
|
4511
|
+
});
|
|
4512
|
+
}
|
|
4513
|
+
finally {
|
|
4514
|
+
cleanup?.();
|
|
4515
|
+
}
|
|
4516
|
+
}
|
|
4517
|
+
catch (err) {
|
|
4518
|
+
return fail({ code: "FORK_FAILED", message: err?.message ?? String(err), exitCode: 1 });
|
|
4519
|
+
}
|
|
4520
|
+
},
|
|
4521
|
+
})
|
|
4522
|
+
// =========================================================================
|
|
4523
|
+
// smithers timeline <run_id>
|
|
4524
|
+
// =========================================================================
|
|
4525
|
+
.command("timeline", {
|
|
4526
|
+
description: "View execution timeline for a run and its forks (time travel).",
|
|
4527
|
+
args: z.object({ runId: z.string().describe("Run ID") }),
|
|
4528
|
+
options: z.object({
|
|
4529
|
+
tree: z.boolean().default(false).describe("Include all child forks recursively"),
|
|
4530
|
+
json: z.boolean().default(false).describe("Output as JSON"),
|
|
4531
|
+
}),
|
|
4532
|
+
async run(c) {
|
|
4533
|
+
const fail = (opts) => {
|
|
4534
|
+
commandExitOverride = opts.exitCode ?? 1;
|
|
4535
|
+
return c.error(opts);
|
|
4536
|
+
};
|
|
4537
|
+
try {
|
|
4538
|
+
const { buildTimeline, buildTimelineTree, formatTimelineForTui, formatTimelineAsJson } = await import("@smithers-orchestrator/time-travel/timeline");
|
|
4539
|
+
const { adapter, cleanup } = await findAndOpenDb();
|
|
4540
|
+
try {
|
|
4541
|
+
if (c.options.tree) {
|
|
4542
|
+
const tree = await buildTimelineTree(adapter, c.args.runId);
|
|
4543
|
+
if (c.options.json) {
|
|
4544
|
+
console.log(JSON.stringify(formatTimelineAsJson(tree), null, 2));
|
|
4545
|
+
}
|
|
4546
|
+
else {
|
|
4547
|
+
console.log(formatTimelineForTui(tree));
|
|
4548
|
+
}
|
|
4549
|
+
return c.ok({ timeline: formatTimelineAsJson(tree) });
|
|
4550
|
+
}
|
|
4551
|
+
const timeline = await buildTimeline(adapter, c.args.runId);
|
|
4552
|
+
const tree = { timeline, children: [] };
|
|
4553
|
+
if (c.options.json) {
|
|
4554
|
+
console.log(JSON.stringify(formatTimelineAsJson(tree), null, 2));
|
|
4555
|
+
}
|
|
4556
|
+
else {
|
|
4557
|
+
console.log(formatTimelineForTui(tree));
|
|
4558
|
+
}
|
|
4559
|
+
return c.ok({ timeline: formatTimelineAsJson(tree) });
|
|
4560
|
+
}
|
|
4561
|
+
finally {
|
|
4562
|
+
cleanup();
|
|
4563
|
+
}
|
|
4564
|
+
}
|
|
4565
|
+
catch (err) {
|
|
4566
|
+
return fail({ code: "TIMELINE_FAILED", message: err?.message ?? String(err), exitCode: 1 });
|
|
4567
|
+
}
|
|
4568
|
+
},
|
|
4569
|
+
})
|
|
4570
|
+
.command(workflowCli)
|
|
4571
|
+
.command(cronCli)
|
|
4572
|
+
.command(agentsCli)
|
|
4573
|
+
.command(memoryCli)
|
|
4574
|
+
.command(openapiCli);
|
|
4575
|
+
const cliCommands = Cli.toCommands?.get(cli);
|
|
4576
|
+
if (!(cliCommands instanceof Map)) {
|
|
4577
|
+
throw new Error("Could not resolve Smithers CLI commands for input bounds.");
|
|
4578
|
+
}
|
|
4579
|
+
wrapCliCommandHandlersWithInputBounds(cliCommands);
|
|
4580
|
+
// ---------------------------------------------------------------------------
|
|
4581
|
+
// Main
|
|
4582
|
+
// ---------------------------------------------------------------------------
|
|
4583
|
+
const KNOWN_COMMANDS = new Set([
|
|
4584
|
+
"init", "up", "supervise", "down", "ps", "logs", "events", "chat", "inspect", "node", "why", "approve", "deny",
|
|
4585
|
+
"cancel", "graph", "revert", "scores", "observability", "workflow", "ask", "cron",
|
|
4586
|
+
"replay", "diff", "fork", "timeline", "memory", "openapi", "agents", "alerts",
|
|
4587
|
+
"tree", "output", "rewind",
|
|
4588
|
+
]);
|
|
4589
|
+
/**
|
|
4590
|
+
* Resolve the --color flag to a boolean: auto → process.stdout.isTTY.
|
|
4591
|
+
* Honors NO_COLOR when color === "auto" to match Unix conventions.
|
|
4592
|
+
*
|
|
4593
|
+
* @param {"auto" | "always" | "never" | undefined} mode
|
|
4594
|
+
* @param {{ isTTY?: boolean }} stream
|
|
4595
|
+
* @returns {boolean}
|
|
4596
|
+
*/
|
|
4597
|
+
function resolveCliColor(mode, stream) {
|
|
4598
|
+
if (mode === "always") return true;
|
|
4599
|
+
if (mode === "never") return false;
|
|
4600
|
+
if (process.env.NO_COLOR !== undefined && process.env.NO_COLOR.length > 0) return false;
|
|
4601
|
+
return Boolean(stream.isTTY);
|
|
4602
|
+
}
|
|
4603
|
+
const BUILTIN_FLAGS_WITH_VALUES = new Set([
|
|
4604
|
+
"--format",
|
|
4605
|
+
"--filter-output",
|
|
4606
|
+
"--token-limit",
|
|
4607
|
+
"--token-offset",
|
|
4608
|
+
]);
|
|
4609
|
+
const WORKFLOW_UTILITY_COMMANDS = new Set([
|
|
4610
|
+
"run",
|
|
4611
|
+
"list",
|
|
4612
|
+
"path",
|
|
4613
|
+
"create",
|
|
4614
|
+
"doctor",
|
|
4615
|
+
]);
|
|
4616
|
+
/**
|
|
4617
|
+
* @param {string | undefined} value
|
|
4618
|
+
* @returns {McpSurface}
|
|
4619
|
+
*/
|
|
4620
|
+
function normalizeMcpSurface(value) {
|
|
4621
|
+
const surface = value?.trim().toLowerCase();
|
|
4622
|
+
if (surface === undefined || surface.length === 0) {
|
|
4623
|
+
throw new Error("Missing value for --surface. Expected semantic, raw, or both.");
|
|
4624
|
+
}
|
|
4625
|
+
if (surface === "semantic" || surface === "raw" || surface === "both") {
|
|
4626
|
+
return surface;
|
|
4627
|
+
}
|
|
4628
|
+
throw new Error(`Invalid --surface value: ${value}. Expected semantic, raw, or both.`);
|
|
4629
|
+
}
|
|
4630
|
+
/**
|
|
4631
|
+
* @param {string[]} argv
|
|
4632
|
+
*/
|
|
4633
|
+
function parseMcpSurfaceArgv(argv) {
|
|
4634
|
+
let surface = "semantic";
|
|
4635
|
+
const filtered = [];
|
|
4636
|
+
for (let index = 0; index < argv.length; index++) {
|
|
4637
|
+
const arg = argv[index];
|
|
4638
|
+
if (arg === "--surface") {
|
|
4639
|
+
surface = normalizeMcpSurface(argv[index + 1]);
|
|
4640
|
+
index += 1;
|
|
4641
|
+
continue;
|
|
4642
|
+
}
|
|
4643
|
+
if (arg.startsWith("--surface=")) {
|
|
4644
|
+
surface = normalizeMcpSurface(arg.slice("--surface=".length));
|
|
4645
|
+
continue;
|
|
4646
|
+
}
|
|
4647
|
+
filtered.push(arg);
|
|
4648
|
+
}
|
|
4649
|
+
return { surface, argv: filtered };
|
|
4650
|
+
}
|
|
4651
|
+
/**
|
|
4652
|
+
* @param {ReturnType<typeof createSemanticMcpServer>} server
|
|
4653
|
+
*/
|
|
4654
|
+
function registerRawToolsOnMcpServer(server) {
|
|
4655
|
+
const commands = Cli.toCommands?.get(cli);
|
|
4656
|
+
if (!(commands instanceof Map)) {
|
|
4657
|
+
throw new Error("Could not resolve Smithers CLI commands for raw MCP surface.");
|
|
4658
|
+
}
|
|
4659
|
+
for (const tool of IncurMcp.collectTools(commands, [])) {
|
|
4660
|
+
const mergedShape = {
|
|
4661
|
+
...tool.command.args?.shape,
|
|
4662
|
+
...tool.command.options?.shape,
|
|
4663
|
+
};
|
|
4664
|
+
const hasInput = Object.keys(mergedShape).length > 0;
|
|
4665
|
+
server.registerTool(tool.name, {
|
|
4666
|
+
...(tool.description ? { description: tool.description } : undefined),
|
|
4667
|
+
...(hasInput ? { inputSchema: mergedShape } : undefined),
|
|
4668
|
+
}, async (...callArgs) => {
|
|
4669
|
+
const params = hasInput ? callArgs[0] : {};
|
|
4670
|
+
const extra = hasInput ? callArgs[1] : callArgs[0];
|
|
4671
|
+
return IncurMcp.callTool(tool, params, extra);
|
|
4672
|
+
});
|
|
4673
|
+
}
|
|
4674
|
+
}
|
|
4675
|
+
/**
|
|
4676
|
+
* @param {string[]} argv
|
|
4677
|
+
* @returns {number}
|
|
4678
|
+
*/
|
|
4679
|
+
function findFirstPositionalIndex(argv, startIndex = 0) {
|
|
4680
|
+
for (let index = startIndex; index < argv.length; index++) {
|
|
4681
|
+
const arg = argv[index];
|
|
4682
|
+
if (!arg.startsWith("-")) {
|
|
4683
|
+
return index;
|
|
4684
|
+
}
|
|
4685
|
+
if (BUILTIN_FLAGS_WITH_VALUES.has(arg)) {
|
|
4686
|
+
index++;
|
|
4687
|
+
}
|
|
4688
|
+
}
|
|
4689
|
+
return -1;
|
|
4690
|
+
}
|
|
4691
|
+
/**
|
|
4692
|
+
* @param {string[]} argv
|
|
4693
|
+
*/
|
|
4694
|
+
function hasHelpFlag(argv, startIndex = 0) {
|
|
4695
|
+
for (let index = startIndex; index < argv.length; index++) {
|
|
4696
|
+
const arg = argv[index];
|
|
4697
|
+
if (arg === "--help" || arg === "-h") {
|
|
4698
|
+
return true;
|
|
4699
|
+
}
|
|
4700
|
+
}
|
|
4701
|
+
return false;
|
|
4702
|
+
}
|
|
4703
|
+
/**
|
|
4704
|
+
* @param {string[]} argv
|
|
4705
|
+
*/
|
|
4706
|
+
function rewriteWorkflowCommandArgv(argv) {
|
|
4707
|
+
const workflowIndex = findFirstPositionalIndex(argv);
|
|
4708
|
+
if (workflowIndex < 0 || argv[workflowIndex] !== "workflow") {
|
|
4709
|
+
return argv;
|
|
4710
|
+
}
|
|
4711
|
+
if (hasHelpFlag(argv, workflowIndex + 1)) {
|
|
4712
|
+
return argv;
|
|
4713
|
+
}
|
|
4714
|
+
const subcommandIndex = findFirstPositionalIndex(argv, workflowIndex + 1);
|
|
4715
|
+
if (subcommandIndex < 0) {
|
|
4716
|
+
return [
|
|
4717
|
+
...argv.slice(0, workflowIndex + 1),
|
|
4718
|
+
"list",
|
|
4719
|
+
...argv.slice(workflowIndex + 1),
|
|
4720
|
+
];
|
|
4721
|
+
}
|
|
4722
|
+
const subcommand = argv[subcommandIndex];
|
|
4723
|
+
if (WORKFLOW_UTILITY_COMMANDS.has(subcommand)) {
|
|
4724
|
+
return argv;
|
|
4725
|
+
}
|
|
4726
|
+
const prefix = argv.slice(0, workflowIndex + 1);
|
|
4727
|
+
try {
|
|
4728
|
+
const workflow = resolveWorkflow(subcommand, process.cwd());
|
|
4729
|
+
return [
|
|
4730
|
+
...prefix,
|
|
4731
|
+
"run",
|
|
4732
|
+
workflow.id,
|
|
4733
|
+
...argv.slice(subcommandIndex + 1),
|
|
4734
|
+
];
|
|
4735
|
+
}
|
|
4736
|
+
catch {
|
|
4737
|
+
return argv;
|
|
4738
|
+
}
|
|
4739
|
+
}
|
|
4740
|
+
/**
|
|
4741
|
+
* @param {string[]} argv
|
|
4742
|
+
*/
|
|
4743
|
+
function rewriteEventsJsonFlagArgv(argv) {
|
|
4744
|
+
const commandIndex = findFirstPositionalIndex(argv);
|
|
4745
|
+
if (commandIndex < 0 || argv[commandIndex] !== "events") {
|
|
4746
|
+
return argv;
|
|
4747
|
+
}
|
|
4748
|
+
return argv.map((arg) => (arg === "--json" ? "-j" : arg));
|
|
4749
|
+
}
|
|
4750
|
+
/**
|
|
4751
|
+
* Incur treats union-typed options as value-bearing flags, so a bare
|
|
4752
|
+
* `--resume --run-id value` would consume `--run-id` as the resume value.
|
|
4753
|
+
*
|
|
4754
|
+
* @param {string[]} argv
|
|
4755
|
+
*/
|
|
4756
|
+
function rewriteBareResumeFlagArgv(argv) {
|
|
4757
|
+
return argv.map((arg, index) => arg === "--resume" && (argv[index + 1] === undefined || argv[index + 1]?.startsWith("-"))
|
|
4758
|
+
? "--resume=true"
|
|
4759
|
+
: arg);
|
|
4760
|
+
}
|
|
4761
|
+
/**
|
|
4762
|
+
* @param {unknown} value
|
|
4763
|
+
*/
|
|
4764
|
+
function normalizeResumeOption(value) {
|
|
4765
|
+
if (value === false || value === undefined || value === null) {
|
|
4766
|
+
return { resume: false, resumeRunId: undefined };
|
|
4767
|
+
}
|
|
4768
|
+
if (value === true) {
|
|
4769
|
+
return { resume: true, resumeRunId: undefined };
|
|
4770
|
+
}
|
|
4771
|
+
if (typeof value !== "string") {
|
|
4772
|
+
return { resume: Boolean(value), resumeRunId: undefined };
|
|
4773
|
+
}
|
|
4774
|
+
const normalized = value.trim();
|
|
4775
|
+
if (normalized === "" || normalized === "false") {
|
|
4776
|
+
return { resume: false, resumeRunId: undefined };
|
|
4777
|
+
}
|
|
4778
|
+
if (normalized === "true" || normalized.startsWith("-")) {
|
|
4779
|
+
return { resume: true, resumeRunId: undefined };
|
|
4780
|
+
}
|
|
4781
|
+
return { resume: true, resumeRunId: normalized };
|
|
4782
|
+
}
|
|
4783
|
+
async function main() {
|
|
4784
|
+
const rawArgv = process.argv.slice(2);
|
|
4785
|
+
let argv = rawArgv.map((arg) => (arg === "-v" ? "--version" : arg));
|
|
4786
|
+
argv = rewriteWorkflowCommandArgv(argv);
|
|
4787
|
+
argv = rewriteEventsJsonFlagArgv(argv);
|
|
4788
|
+
// Finding #3: route `--json` to command-scoped `-j` for devtools commands.
|
|
4789
|
+
argv = rewriteDevtoolsJsonFlagArgv(argv);
|
|
4790
|
+
// Finding #1: pre-validate argv for devtools commands so missing-args
|
|
4791
|
+
// / invalid-flag errors go to stderr with exit 1 (not incur's
|
|
4792
|
+
// remap-to-4 VALIDATION_ERROR envelope on stdout).
|
|
4793
|
+
validateDevtoolsArgv(argv);
|
|
4794
|
+
// Allow running workflow files directly: `smithers workflow.tsx` → `smithers up workflow.tsx`
|
|
4795
|
+
const firstPositionalIndex = findFirstPositionalIndex(argv);
|
|
4796
|
+
const firstPositional = firstPositionalIndex >= 0 ? argv[firstPositionalIndex] : undefined;
|
|
4797
|
+
if (firstPositional &&
|
|
4798
|
+
!KNOWN_COMMANDS.has(firstPositional) &&
|
|
4799
|
+
firstPositional.endsWith(".tsx")) {
|
|
4800
|
+
argv = [
|
|
4801
|
+
...argv.slice(0, firstPositionalIndex),
|
|
4802
|
+
"up",
|
|
4803
|
+
...argv.slice(firstPositionalIndex),
|
|
4804
|
+
];
|
|
4805
|
+
}
|
|
4806
|
+
argv = rewriteBareResumeFlagArgv(argv);
|
|
4807
|
+
// --mcp mode: the MCP server needs to stay alive listening on stdin.
|
|
4808
|
+
if (argv.includes("--mcp")) {
|
|
4809
|
+
try {
|
|
4810
|
+
const mcpArgs = parseMcpSurfaceArgv(argv);
|
|
4811
|
+
if (mcpArgs.surface === "raw") {
|
|
4812
|
+
await cli.serve(mcpArgs.argv);
|
|
4813
|
+
}
|
|
4814
|
+
else {
|
|
4815
|
+
const server = createSemanticMcpServer({
|
|
4816
|
+
name: "smithers",
|
|
4817
|
+
version: readPackageVersion(),
|
|
4818
|
+
});
|
|
4819
|
+
if (mcpArgs.surface === "both") {
|
|
4820
|
+
registerRawToolsOnMcpServer(server);
|
|
4821
|
+
}
|
|
4822
|
+
const transport = new StdioServerTransport(process.stdin, process.stdout);
|
|
4823
|
+
await server.connect(transport);
|
|
4824
|
+
}
|
|
4825
|
+
}
|
|
4826
|
+
catch (err) {
|
|
4827
|
+
console.error(err?.message ?? String(err));
|
|
4828
|
+
process.exit(1);
|
|
4829
|
+
}
|
|
4830
|
+
return;
|
|
4831
|
+
}
|
|
4832
|
+
let exitCodeFromServe;
|
|
4833
|
+
try {
|
|
4834
|
+
await cli.serve(argv, {
|
|
4835
|
+
exit(code) {
|
|
4836
|
+
exitCodeFromServe = code;
|
|
4837
|
+
},
|
|
4838
|
+
});
|
|
4839
|
+
}
|
|
4840
|
+
catch (err) {
|
|
4841
|
+
console.error(err?.message ?? String(err));
|
|
4842
|
+
process.exit(1);
|
|
4843
|
+
}
|
|
4844
|
+
if (exitCodeFromServe !== undefined) {
|
|
4845
|
+
// Finding #1: for devtools commands, skip the generic exit 4
|
|
4846
|
+
// remap so parser/validation failures land on the ticket's
|
|
4847
|
+
// uniform exit-code table (1 = user error).
|
|
4848
|
+
const commandIndex = findFirstPositionalIndex(argv);
|
|
4849
|
+
const cmd = commandIndex >= 0 ? argv[commandIndex] : undefined;
|
|
4850
|
+
const isDevtoolsCmd = Boolean(cmd && DEVTOOLS_COMMANDS.has(cmd));
|
|
4851
|
+
const mapped = commandExitOverride !== undefined
|
|
4852
|
+
? commandExitOverride
|
|
4853
|
+
: isDevtoolsCmd
|
|
4854
|
+
? exitCodeFromServe
|
|
4855
|
+
: exitCodeFromServe === 1
|
|
4856
|
+
? 4
|
|
4857
|
+
: exitCodeFromServe;
|
|
4858
|
+
process.exit(mapped);
|
|
4859
|
+
}
|
|
4860
|
+
// Incur does not call the `exit` callback on success paths. Honor
|
|
4861
|
+
// `commandExitOverride` here so handlers that report a non-zero
|
|
4862
|
+
// typed exit via helper (finding #2 fix) still exit with that code.
|
|
4863
|
+
if (commandExitOverride !== undefined) {
|
|
4864
|
+
process.exit(commandExitOverride);
|
|
4865
|
+
}
|
|
4866
|
+
process.exit(process.exitCode ?? 0);
|
|
4867
|
+
}
|
|
4868
|
+
main();
|