@jmoyers/harness 0.1.11 → 0.1.20
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/README.md +31 -39
- package/package.json +31 -11
- package/packages/harness-ai/src/anthropic-protocol.ts +68 -68
- package/packages/harness-ai/src/stream-text.ts +13 -91
- package/packages/harness-ui/src/frame-primitives.ts +158 -0
- package/packages/harness-ui/src/index.ts +18 -0
- package/packages/harness-ui/src/interaction/conversation-input-forwarder.ts +221 -0
- package/packages/harness-ui/src/interaction/conversation-selection-input.ts +213 -0
- package/packages/harness-ui/src/interaction/global-shortcut-input.ts +172 -0
- package/{src/ui → packages/harness-ui/src/interaction}/input-preflight.ts +10 -12
- package/{src/ui → packages/harness-ui/src/interaction}/input-token-router.ts +120 -69
- package/packages/harness-ui/src/interaction/input.ts +420 -0
- package/packages/harness-ui/src/interaction/left-nav-input.ts +166 -0
- package/{src/ui → packages/harness-ui/src/interaction}/main-pane-pointer-input.ts +91 -23
- package/{src/ui → packages/harness-ui/src/interaction}/pointer-routing-input.ts +112 -48
- package/packages/harness-ui/src/interaction/rail-pointer-input.ts +62 -0
- package/packages/harness-ui/src/interaction/repository-fold-input.ts +118 -0
- package/packages/harness-ui/src/kit.ts +476 -0
- package/packages/harness-ui/src/layout.ts +238 -0
- package/packages/harness-ui/src/modal-manager.ts +222 -0
- package/{src/ui → packages/harness-ui/src}/screen.ts +53 -26
- package/packages/harness-ui/src/surface.ts +252 -0
- package/packages/harness-ui/src/text-layout.ts +210 -0
- package/packages/nim-core/src/contracts.ts +239 -0
- package/packages/nim-core/src/event-store.ts +299 -0
- package/packages/nim-core/src/events.ts +53 -0
- package/packages/nim-core/src/index.ts +9 -0
- package/packages/nim-core/src/provider-router.ts +129 -0
- package/packages/nim-core/src/providers/anthropic-driver.ts +291 -0
- package/packages/nim-core/src/runtime-factory.ts +49 -0
- package/packages/nim-core/src/runtime.ts +1797 -0
- package/packages/nim-core/src/session-store.ts +516 -0
- package/packages/nim-core/src/telemetry.ts +48 -0
- package/packages/nim-test-tui/src/index.ts +150 -0
- package/packages/nim-ui-core/src/index.ts +1 -0
- package/packages/nim-ui-core/src/projection.ts +87 -0
- package/scripts/codex-live-mux-runtime.ts +2 -3872
- package/scripts/control-plane-daemon.ts +11 -0
- package/scripts/harness-bin.js +5 -0
- package/scripts/harness-commands.ts +300 -0
- package/scripts/harness-runtime.ts +82 -0
- package/scripts/harness.ts +33 -3019
- package/scripts/nim-tui-smoke.ts +748 -0
- package/src/cli/auth/runtime.ts +948 -0
- package/src/cli/gateway/runtime.ts +1872 -0
- package/src/cli/parsing/flags.ts +23 -0
- package/src/cli/parsing/session.ts +42 -0
- package/src/cli/runtime/context.ts +193 -0
- package/src/cli/runtime-app/application.ts +392 -0
- package/src/cli/runtime-infra/gateway-control.ts +729 -0
- package/{scripts/harness-inspector.ts → src/cli/workflows/inspector.ts} +14 -11
- package/src/cli/workflows/runtime.ts +965 -0
- package/src/clients/tui/left-rail-interactions.ts +519 -0
- package/src/clients/tui/main-pane-interactions.ts +509 -0
- package/src/clients/tui/modal-input-routing.ts +71 -0
- package/src/clients/tui/render-snapshot-adapter.ts +88 -0
- package/src/clients/web/synced-selectors.ts +132 -0
- package/src/codex/live-session.ts +82 -29
- package/src/config/config-core.ts +348 -8
- package/src/config/harness.config.template.jsonc +33 -0
- package/src/control-plane/agent-realtime-api.ts +82 -427
- package/src/control-plane/session-summary.ts +10 -81
- package/src/control-plane/status/reducer-base.ts +12 -12
- package/src/control-plane/status/reducers/claude-status-reducer.ts +3 -3
- package/src/control-plane/status/reducers/codex-status-reducer.ts +4 -4
- package/src/control-plane/status/reducers/cursor-status-reducer.ts +3 -3
- package/src/control-plane/stream-client.ts +12 -2
- package/src/control-plane/stream-command-parser.ts +83 -143
- package/src/control-plane/stream-protocol.ts +53 -37
- package/src/control-plane/stream-server-command.ts +376 -69
- package/src/control-plane/stream-server-session-runtime.ts +3 -2
- package/src/control-plane/stream-server.ts +864 -70
- package/src/control-plane/stream-session-runtime-types.ts +41 -0
- package/src/{mux/live-mux/control-plane-records.ts → core/contracts/records.ts} +24 -97
- package/src/core/state/observed-stream-cursor.ts +43 -0
- package/src/core/state/synced-observed-state.ts +273 -0
- package/src/core/store/harness-synced-store.ts +81 -0
- package/src/diff/budget.ts +136 -0
- package/src/diff/build.ts +289 -0
- package/src/diff/chunker.ts +146 -0
- package/src/diff/git-invoke.ts +315 -0
- package/src/diff/git-parse.ts +472 -0
- package/src/diff/hash.ts +70 -0
- package/src/diff/index.ts +24 -0
- package/src/diff/normalize.ts +134 -0
- package/src/diff/types.ts +178 -0
- package/src/diff-ui/args.ts +346 -0
- package/src/diff-ui/commands.ts +123 -0
- package/src/diff-ui/finder.ts +94 -0
- package/src/diff-ui/highlight.ts +127 -0
- package/src/diff-ui/index.ts +2 -0
- package/src/diff-ui/model.ts +141 -0
- package/src/diff-ui/pager.ts +412 -0
- package/src/diff-ui/render.ts +337 -0
- package/src/diff-ui/runtime.ts +379 -0
- package/src/diff-ui/state.ts +224 -0
- package/src/diff-ui/types.ts +236 -0
- package/src/domain/workspace.ts +68 -5
- package/src/mux/control-plane-op-queue.ts +93 -7
- package/src/mux/conversation-rail.ts +28 -71
- package/src/mux/dual-pane-core.ts +13 -13
- package/src/mux/harness-core-ui.ts +313 -42
- package/src/mux/input-shortcuts.ts +13 -131
- package/src/mux/keybinding-catalog.ts +340 -0
- package/src/mux/keybinding-registry.ts +103 -0
- package/src/mux/live-mux/command-menu-open-in.ts +280 -0
- package/src/mux/live-mux/command-menu.ts +167 -4
- package/src/mux/live-mux/conversation-state.ts +13 -0
- package/src/mux/live-mux/directory-resolution.ts +1 -1
- package/src/mux/live-mux/git-snapshot.ts +33 -2
- package/src/mux/live-mux/global-shortcut-handlers.ts +6 -0
- package/src/mux/live-mux/home-pane-drop.ts +1 -1
- package/src/mux/live-mux/home-pane-pointer.ts +10 -0
- package/src/mux/live-mux/input-forwarding.ts +59 -2
- package/src/mux/live-mux/left-nav-activation.ts +124 -7
- package/src/mux/live-mux/left-nav.ts +35 -0
- package/src/mux/live-mux/link-click.ts +292 -0
- package/src/mux/live-mux/modal-command-menu-handler.ts +46 -9
- package/src/mux/live-mux/modal-conversation-handlers.ts +5 -1
- package/src/mux/live-mux/modal-input-reducers.ts +77 -12
- package/src/mux/live-mux/modal-overlays.ts +168 -34
- package/src/mux/live-mux/modal-pointer.ts +3 -7
- package/src/mux/live-mux/modal-prompt-handlers.ts +23 -2
- package/src/mux/live-mux/modal-release-notes-handler.ts +111 -0
- package/src/mux/live-mux/modal-task-editor-handler.ts +16 -11
- package/src/mux/live-mux/pointer-routing.ts +5 -2
- package/src/mux/live-mux/project-pane-pointer.ts +8 -0
- package/src/mux/live-mux/rail-layout.ts +33 -30
- package/src/mux/live-mux/release-notes.ts +383 -0
- package/src/mux/live-mux/render-trace-analysis.ts +52 -7
- package/src/mux/live-mux/repository-folding.ts +3 -0
- package/src/mux/live-mux/selection.ts +0 -4
- package/src/mux/live-mux/session-diagnostics-paths.ts +21 -0
- package/src/mux/project-pane-github-review.ts +271 -0
- package/src/mux/render-frame.ts +4 -0
- package/src/mux/runtime-app/codex-live-mux-runtime.ts +5191 -0
- package/src/mux/task-composer.ts +21 -14
- package/src/mux/task-focused-pane.ts +118 -117
- package/src/mux/task-screen-keybindings.ts +10 -101
- package/src/mux/workspace-rail-model.ts +270 -104
- package/src/mux/workspace-rail.ts +45 -22
- package/src/pty/session-broker.ts +1 -1
- package/{scripts → src/recording}/terminal-recording-gif-lib.ts +2 -2
- package/src/services/control-plane.ts +50 -32
- package/src/services/conversation-lifecycle.ts +118 -87
- package/src/services/conversation-startup-hydration.ts +20 -12
- package/src/services/directory-hydration.ts +21 -16
- package/src/services/event-persistence.ts +7 -0
- package/src/services/left-rail-pointer-handler.ts +329 -0
- package/src/services/mux-ui-state-persistence.ts +5 -1
- package/src/services/recording.ts +34 -26
- package/src/services/runtime-command-menu-agent-tools.ts +1 -1
- package/src/services/runtime-control-actions.ts +79 -61
- package/src/services/runtime-control-plane-ops.ts +122 -83
- package/src/services/runtime-conversation-actions.ts +40 -26
- package/src/services/runtime-conversation-activation.ts +73 -46
- package/src/services/runtime-conversation-starter.ts +53 -45
- package/src/services/runtime-conversation-title-edit.ts +91 -80
- package/src/services/runtime-envelope-handler.ts +107 -105
- package/src/services/runtime-git-state.ts +42 -29
- package/src/services/runtime-layout-resize.ts +3 -1
- package/src/services/runtime-left-rail-render.ts +99 -63
- package/src/services/runtime-nim-cli-session.ts +438 -0
- package/src/services/runtime-nim-session.ts +705 -0
- package/src/services/runtime-nim-tool-bridge.ts +141 -0
- package/src/services/runtime-observed-event-projection-pipeline.ts +45 -0
- package/src/services/runtime-process-wiring.ts +29 -36
- package/src/services/runtime-project-pane-github-review-cache.ts +164 -0
- package/src/services/runtime-render-flush.ts +63 -70
- package/src/services/runtime-render-lifecycle.ts +65 -64
- package/src/services/runtime-render-orchestrator.ts +55 -45
- package/src/services/runtime-render-pipeline.ts +106 -103
- package/src/services/runtime-render-state.ts +62 -49
- package/src/services/runtime-repository-actions.ts +97 -72
- package/src/services/runtime-right-pane-render.ts +80 -53
- package/src/services/runtime-shutdown.ts +38 -35
- package/src/services/runtime-stream-subscriptions.ts +35 -27
- package/src/services/runtime-task-composer-persistence.ts +71 -59
- package/src/services/runtime-task-composer-snapshot.ts +14 -0
- package/src/services/runtime-task-editor-actions.ts +46 -29
- package/src/services/runtime-task-pane-actions.ts +220 -134
- package/src/services/runtime-task-pane-shortcuts.ts +323 -123
- package/src/services/runtime-workspace-observed-effect-queue.ts +25 -0
- package/src/services/runtime-workspace-observed-events.ts +33 -184
- package/src/services/runtime-workspace-observed-transition-policy.ts +228 -0
- package/src/services/session-diagnostics-store.ts +217 -0
- package/src/services/startup-background-resume.ts +26 -21
- package/src/services/startup-orchestrator.ts +16 -13
- package/src/services/startup-paint-tracker.ts +29 -21
- package/src/services/startup-persisted-conversation-queue.ts +19 -13
- package/src/services/startup-settled-gate.ts +25 -15
- package/src/services/startup-shutdown.ts +18 -22
- package/src/services/startup-state-hydration.ts +44 -34
- package/src/services/startup-visibility.ts +12 -4
- package/src/services/task-pane-selection-actions.ts +89 -72
- package/src/services/task-planning-hydration.ts +24 -18
- package/src/services/task-planning-observed-events.ts +50 -52
- package/src/services/workspace-observed-events.ts +66 -63
- package/src/storage/storage-lifecycle-core.ts +438 -0
- package/src/store/control-plane-store-normalize.ts +33 -242
- package/src/store/control-plane-store-types.ts +1 -35
- package/src/store/control-plane-store.ts +360 -56
- package/src/store/event-store.ts +366 -8
- package/src/terminal/snapshot-oracle.ts +207 -94
- package/src/ui/mux-theme.ts +112 -8
- package/src/ui/panes/home-gridfire.ts +40 -31
- package/src/ui/panes/home.ts +10 -2
- package/src/ui/panes/nim.ts +315 -0
- package/src/mux/live-mux/actions-task.ts +0 -115
- package/src/mux/live-mux/left-rail-actions.ts +0 -118
- package/src/mux/live-mux/left-rail-conversation-click.ts +0 -85
- package/src/mux/live-mux/left-rail-pointer.ts +0 -74
- package/src/mux/live-mux/task-pane-shortcuts.ts +0 -206
- package/src/services/runtime-directory-actions.ts +0 -164
- package/src/services/runtime-input-pipeline.ts +0 -50
- package/src/services/runtime-input-router.ts +0 -195
- package/src/services/runtime-main-pane-input.ts +0 -230
- package/src/services/runtime-modal-input.ts +0 -137
- package/src/services/runtime-navigation-input.ts +0 -197
- package/src/services/runtime-rail-input.ts +0 -279
- package/src/services/runtime-task-pane.ts +0 -62
- package/src/services/runtime-workspace-actions.ts +0 -158
- package/src/ui/conversation-input-forwarder.ts +0 -114
- package/src/ui/conversation-selection-input.ts +0 -103
- package/src/ui/global-shortcut-input.ts +0 -89
- package/src/ui/input.ts +0 -269
- package/src/ui/kit.ts +0 -509
- package/src/ui/left-nav-input.ts +0 -80
- package/src/ui/left-rail-pointer-input.ts +0 -148
- package/src/ui/modals/manager.ts +0 -218
- package/src/ui/repository-fold-input.ts +0 -91
- package/src/ui/surface.ts +0 -224
|
@@ -0,0 +1,965 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { createServer as createNetServer } from 'node:net';
|
|
3
|
+
import { dirname, resolve } from 'node:path';
|
|
4
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
5
|
+
import type { GatewayRecord } from '../gateway-record.ts';
|
|
6
|
+
import {
|
|
7
|
+
DEFAULT_GATEWAY_STOP_TIMEOUT_MS,
|
|
8
|
+
type EnsureGatewayResult,
|
|
9
|
+
type GatewayProbeResult,
|
|
10
|
+
type GatewayStartOptions,
|
|
11
|
+
type GatewayStopOptions,
|
|
12
|
+
type GatewayStopResult,
|
|
13
|
+
type ResolvedGatewaySettings,
|
|
14
|
+
} from '../gateway/runtime.ts';
|
|
15
|
+
import { parsePositiveIntFlag, readCliValue } from '../parsing/flags.ts';
|
|
16
|
+
import { GatewayControlInfra } from '../runtime-infra/gateway-control.ts';
|
|
17
|
+
import type { HarnessRuntimeContext } from '../runtime/context.ts';
|
|
18
|
+
import {
|
|
19
|
+
parseActiveStatusTimelineState,
|
|
20
|
+
STATUS_TIMELINE_MODE,
|
|
21
|
+
STATUS_TIMELINE_STATE_VERSION,
|
|
22
|
+
} from '../../mux/live-mux/status-timeline-state.ts';
|
|
23
|
+
import {
|
|
24
|
+
parseActiveRenderTraceState,
|
|
25
|
+
RENDER_TRACE_MODE,
|
|
26
|
+
RENDER_TRACE_STATE_VERSION,
|
|
27
|
+
} from '../../mux/live-mux/render-trace-state.ts';
|
|
28
|
+
import {
|
|
29
|
+
buildInspectorProfileStartExpression,
|
|
30
|
+
buildInspectorProfileStopExpression,
|
|
31
|
+
connectGatewayInspector,
|
|
32
|
+
DEFAULT_PROFILE_INSPECT_TIMEOUT_MS,
|
|
33
|
+
evaluateInspectorExpression,
|
|
34
|
+
InspectorWebSocketClient,
|
|
35
|
+
readInspectorProfileState,
|
|
36
|
+
type InspectorProfileState,
|
|
37
|
+
} from './inspector.ts';
|
|
38
|
+
|
|
39
|
+
const DEFAULT_GATEWAY_STOP_POLL_MS = 50;
|
|
40
|
+
const PROFILE_STATE_VERSION = 2;
|
|
41
|
+
const PROFILE_LIVE_INSPECT_MODE = 'live-inspector';
|
|
42
|
+
const PROFILE_CLIENT_FILE_NAME = 'client.cpuprofile';
|
|
43
|
+
const PROFILE_GATEWAY_FILE_NAME = 'gateway.cpuprofile';
|
|
44
|
+
|
|
45
|
+
interface ProfileStopOptions {
|
|
46
|
+
readonly timeoutMs: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface ParsedProfileRunCommand {
|
|
50
|
+
readonly type: 'run';
|
|
51
|
+
readonly profileDir: string | null;
|
|
52
|
+
readonly muxArgs: readonly string[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface ParsedProfileStartCommand {
|
|
56
|
+
readonly type: 'start';
|
|
57
|
+
readonly profileDir: string | null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface ParsedProfileStopCommand {
|
|
61
|
+
readonly type: 'stop';
|
|
62
|
+
readonly stopOptions: ProfileStopOptions;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
type ParsedProfileCommand =
|
|
66
|
+
| ParsedProfileRunCommand
|
|
67
|
+
| ParsedProfileStartCommand
|
|
68
|
+
| ParsedProfileStopCommand;
|
|
69
|
+
|
|
70
|
+
interface ParsedStatusTimelineStartCommand {
|
|
71
|
+
readonly type: 'start';
|
|
72
|
+
readonly outputPath: string | null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface ParsedStatusTimelineStopCommand {
|
|
76
|
+
readonly type: 'stop';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
type ParsedStatusTimelineCommand =
|
|
80
|
+
| ParsedStatusTimelineStartCommand
|
|
81
|
+
| ParsedStatusTimelineStopCommand;
|
|
82
|
+
|
|
83
|
+
interface ParsedRenderTraceStartCommand {
|
|
84
|
+
readonly type: 'start';
|
|
85
|
+
readonly outputPath: string | null;
|
|
86
|
+
readonly conversationId: string | null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface ParsedRenderTraceStopCommand {
|
|
90
|
+
readonly type: 'stop';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
type ParsedRenderTraceCommand = ParsedRenderTraceStartCommand | ParsedRenderTraceStopCommand;
|
|
94
|
+
|
|
95
|
+
interface RuntimeCpuProfileOptions {
|
|
96
|
+
readonly cpuProfileDir: string;
|
|
97
|
+
readonly cpuProfileName: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
interface ActiveProfileState {
|
|
101
|
+
readonly version: number;
|
|
102
|
+
readonly mode: typeof PROFILE_LIVE_INSPECT_MODE;
|
|
103
|
+
readonly pid: number;
|
|
104
|
+
readonly host: string;
|
|
105
|
+
readonly port: number;
|
|
106
|
+
readonly stateDbPath: string;
|
|
107
|
+
readonly profileDir: string;
|
|
108
|
+
readonly gatewayProfilePath: string;
|
|
109
|
+
readonly inspectWebSocketUrl: string;
|
|
110
|
+
readonly startedAt: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
interface ActiveStatusTimelineState {
|
|
114
|
+
readonly version: number;
|
|
115
|
+
readonly mode: typeof STATUS_TIMELINE_MODE;
|
|
116
|
+
readonly outputPath: string;
|
|
117
|
+
readonly sessionName: string | null;
|
|
118
|
+
readonly startedAt: string;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
interface ActiveRenderTraceState {
|
|
122
|
+
readonly version: number;
|
|
123
|
+
readonly mode: typeof RENDER_TRACE_MODE;
|
|
124
|
+
readonly outputPath: string;
|
|
125
|
+
readonly sessionName: string | null;
|
|
126
|
+
readonly conversationId: string | null;
|
|
127
|
+
readonly startedAt: string;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
interface GatewayRuntimeFacade {
|
|
131
|
+
withLock<T>(operation: () => Promise<T>): Promise<T>;
|
|
132
|
+
ensureGatewayRunning(overrides?: GatewayStartOptions): Promise<EnsureGatewayResult>;
|
|
133
|
+
runMuxClient(
|
|
134
|
+
record: GatewayRecord,
|
|
135
|
+
muxArgs: readonly string[],
|
|
136
|
+
runtimeArgs?: readonly string[],
|
|
137
|
+
): Promise<number>;
|
|
138
|
+
isPidRunning(pid: number): boolean;
|
|
139
|
+
readGatewayRecord(): GatewayRecord | null;
|
|
140
|
+
probeGateway(record: GatewayRecord): Promise<GatewayProbeResult>;
|
|
141
|
+
removeGatewayRecord(): void;
|
|
142
|
+
resolveGatewayHostFromConfigOrEnv(): string;
|
|
143
|
+
reservePort(host: string): Promise<number>;
|
|
144
|
+
resolveGatewaySettings(
|
|
145
|
+
record: GatewayRecord | null,
|
|
146
|
+
overrides: GatewayStartOptions,
|
|
147
|
+
): ResolvedGatewaySettings;
|
|
148
|
+
startDetachedGateway(
|
|
149
|
+
settings: ResolvedGatewaySettings,
|
|
150
|
+
runtimeArgs?: readonly string[],
|
|
151
|
+
): Promise<GatewayRecord>;
|
|
152
|
+
stopGateway(options: GatewayStopOptions): Promise<GatewayStopResult>;
|
|
153
|
+
waitForFileExists(filePath: string, timeoutMs: number): Promise<boolean>;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
class ProfileCommandParser {
|
|
157
|
+
public constructor() {}
|
|
158
|
+
|
|
159
|
+
private parseRunCommand(argv: readonly string[]): ParsedProfileRunCommand {
|
|
160
|
+
let profileDir: string | null = null;
|
|
161
|
+
const muxArgs: string[] = [];
|
|
162
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
163
|
+
const arg = argv[index]!;
|
|
164
|
+
if (arg === '--profile-dir') {
|
|
165
|
+
profileDir = readCliValue(argv, index, '--profile-dir');
|
|
166
|
+
index += 1;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
muxArgs.push(arg);
|
|
170
|
+
}
|
|
171
|
+
return {
|
|
172
|
+
type: 'run',
|
|
173
|
+
profileDir,
|
|
174
|
+
muxArgs,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private parseStartCommand(argv: readonly string[]): ParsedProfileStartCommand {
|
|
179
|
+
let profileDir: string | null = null;
|
|
180
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
181
|
+
const arg = argv[index]!;
|
|
182
|
+
if (arg === '--profile-dir') {
|
|
183
|
+
profileDir = readCliValue(argv, index, '--profile-dir');
|
|
184
|
+
index += 1;
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
throw new Error(`unknown profile option: ${arg}`);
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
type: 'start',
|
|
191
|
+
profileDir,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private parseStopOptions(argv: readonly string[]): ProfileStopOptions {
|
|
196
|
+
let timeoutMs = DEFAULT_GATEWAY_STOP_TIMEOUT_MS;
|
|
197
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
198
|
+
const arg = argv[index]!;
|
|
199
|
+
if (arg === '--timeout-ms') {
|
|
200
|
+
timeoutMs = parsePositiveIntFlag(readCliValue(argv, index, '--timeout-ms'), '--timeout-ms');
|
|
201
|
+
index += 1;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
throw new Error(`unknown profile option: ${arg}`);
|
|
205
|
+
}
|
|
206
|
+
return { timeoutMs };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
public parse(argv: readonly string[]): ParsedProfileCommand {
|
|
210
|
+
if (argv.length === 0) {
|
|
211
|
+
return this.parseRunCommand(argv);
|
|
212
|
+
}
|
|
213
|
+
const subcommand = argv[0]!;
|
|
214
|
+
const rest = argv.slice(1);
|
|
215
|
+
if (subcommand === 'start') {
|
|
216
|
+
return this.parseStartCommand(rest);
|
|
217
|
+
}
|
|
218
|
+
if (subcommand === 'stop') {
|
|
219
|
+
return {
|
|
220
|
+
type: 'stop',
|
|
221
|
+
stopOptions: this.parseStopOptions(rest),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
if (subcommand === 'run') {
|
|
225
|
+
return this.parseRunCommand(rest);
|
|
226
|
+
}
|
|
227
|
+
if (subcommand.startsWith('-')) {
|
|
228
|
+
return this.parseRunCommand(argv);
|
|
229
|
+
}
|
|
230
|
+
return this.parseRunCommand(argv);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
class StatusTimelineCommandParser {
|
|
235
|
+
public constructor() {}
|
|
236
|
+
|
|
237
|
+
private parseStartCommand(argv: readonly string[]): ParsedStatusTimelineStartCommand {
|
|
238
|
+
let outputPath: string | null = null;
|
|
239
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
240
|
+
const arg = argv[index]!;
|
|
241
|
+
if (arg === '--output-path') {
|
|
242
|
+
outputPath = readCliValue(argv, index, '--output-path');
|
|
243
|
+
index += 1;
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
throw new Error(`unknown status-timeline option: ${arg}`);
|
|
247
|
+
}
|
|
248
|
+
return {
|
|
249
|
+
type: 'start',
|
|
250
|
+
outputPath,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private parseStopCommand(argv: readonly string[]): ParsedStatusTimelineStopCommand {
|
|
255
|
+
if (argv.length > 0) {
|
|
256
|
+
throw new Error(`unknown status-timeline option: ${argv[0]}`);
|
|
257
|
+
}
|
|
258
|
+
return { type: 'stop' };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
public parse(argv: readonly string[]): ParsedStatusTimelineCommand {
|
|
262
|
+
if (argv.length === 0) {
|
|
263
|
+
return this.parseStartCommand(argv);
|
|
264
|
+
}
|
|
265
|
+
const subcommand = argv[0]!;
|
|
266
|
+
const rest = argv.slice(1);
|
|
267
|
+
if (subcommand === 'start') {
|
|
268
|
+
return this.parseStartCommand(rest);
|
|
269
|
+
}
|
|
270
|
+
if (subcommand === 'stop') {
|
|
271
|
+
return this.parseStopCommand(rest);
|
|
272
|
+
}
|
|
273
|
+
if (subcommand.startsWith('-')) {
|
|
274
|
+
return this.parseStartCommand(argv);
|
|
275
|
+
}
|
|
276
|
+
throw new Error(`unknown status-timeline subcommand: ${subcommand}`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
class RenderTraceCommandParser {
|
|
281
|
+
public constructor() {}
|
|
282
|
+
|
|
283
|
+
private parseStartCommand(argv: readonly string[]): ParsedRenderTraceStartCommand {
|
|
284
|
+
let outputPath: string | null = null;
|
|
285
|
+
let conversationId: string | null = null;
|
|
286
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
287
|
+
const arg = argv[index]!;
|
|
288
|
+
if (arg === '--output-path') {
|
|
289
|
+
outputPath = readCliValue(argv, index, '--output-path');
|
|
290
|
+
index += 1;
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
if (arg === '--conversation-id') {
|
|
294
|
+
const value = readCliValue(argv, index, '--conversation-id').trim();
|
|
295
|
+
if (value.length === 0) {
|
|
296
|
+
throw new Error('invalid --conversation-id value: empty string');
|
|
297
|
+
}
|
|
298
|
+
conversationId = value;
|
|
299
|
+
index += 1;
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
throw new Error(`unknown render-trace option: ${arg}`);
|
|
303
|
+
}
|
|
304
|
+
return {
|
|
305
|
+
type: 'start',
|
|
306
|
+
outputPath,
|
|
307
|
+
conversationId,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
private parseStopCommand(argv: readonly string[]): ParsedRenderTraceStopCommand {
|
|
312
|
+
if (argv.length > 0) {
|
|
313
|
+
throw new Error(`unknown render-trace option: ${argv[0]}`);
|
|
314
|
+
}
|
|
315
|
+
return { type: 'stop' };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
public parse(argv: readonly string[]): ParsedRenderTraceCommand {
|
|
319
|
+
if (argv.length === 0) {
|
|
320
|
+
return this.parseStartCommand(argv);
|
|
321
|
+
}
|
|
322
|
+
const subcommand = argv[0]!;
|
|
323
|
+
const rest = argv.slice(1);
|
|
324
|
+
if (subcommand === 'start') {
|
|
325
|
+
return this.parseStartCommand(rest);
|
|
326
|
+
}
|
|
327
|
+
if (subcommand === 'stop') {
|
|
328
|
+
return this.parseStopCommand(rest);
|
|
329
|
+
}
|
|
330
|
+
if (subcommand.startsWith('-')) {
|
|
331
|
+
return this.parseStartCommand(argv);
|
|
332
|
+
}
|
|
333
|
+
throw new Error(`unknown render-trace subcommand: ${subcommand}`);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function buildCpuProfileRuntimeArgs(options: RuntimeCpuProfileOptions): readonly string[] {
|
|
338
|
+
return [
|
|
339
|
+
'--cpu-prof',
|
|
340
|
+
'--cpu-prof-dir',
|
|
341
|
+
options.cpuProfileDir,
|
|
342
|
+
'--cpu-prof-name',
|
|
343
|
+
options.cpuProfileName,
|
|
344
|
+
];
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export class WorkflowRuntimeService {
|
|
348
|
+
private readonly profileParser = new ProfileCommandParser();
|
|
349
|
+
private readonly statusTimelineParser = new StatusTimelineCommandParser();
|
|
350
|
+
private readonly renderTraceParser = new RenderTraceCommandParser();
|
|
351
|
+
|
|
352
|
+
constructor(
|
|
353
|
+
private readonly runtime: HarnessRuntimeContext,
|
|
354
|
+
private readonly gatewayService: GatewayRuntimeFacade,
|
|
355
|
+
private readonly infra: GatewayControlInfra = new GatewayControlInfra(),
|
|
356
|
+
private readonly writeStdout: (text: string) => void = (text) => {
|
|
357
|
+
process.stdout.write(text);
|
|
358
|
+
},
|
|
359
|
+
) {}
|
|
360
|
+
|
|
361
|
+
private parseInspectRuntimeArg(
|
|
362
|
+
runtimeArg: string,
|
|
363
|
+
): { host: string; port: number; flag: '--inspect' | '--inspect-brk' } | null {
|
|
364
|
+
const match = runtimeArg.match(
|
|
365
|
+
/^--(?<flag>inspect|inspect-brk)=(?<host>[^:]+):(?<port>\d+)(?:\/.*)?$/u,
|
|
366
|
+
);
|
|
367
|
+
if (!match?.groups) {
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
const host = match.groups['host'];
|
|
371
|
+
const portRaw = match.groups['port'];
|
|
372
|
+
const flag = match.groups['flag'];
|
|
373
|
+
if (host === undefined || portRaw === undefined || flag === undefined) {
|
|
374
|
+
return null;
|
|
375
|
+
}
|
|
376
|
+
const port = Number.parseInt(portRaw, 10);
|
|
377
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
if (flag !== 'inspect' && flag !== 'inspect-brk') {
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
return {
|
|
384
|
+
host,
|
|
385
|
+
port,
|
|
386
|
+
flag: `--${flag}`,
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
private async canBindPort(host: string, port: number): Promise<boolean> {
|
|
391
|
+
return await new Promise<boolean>((resolveCanBind, rejectCanBind) => {
|
|
392
|
+
const server = createNetServer();
|
|
393
|
+
server.unref();
|
|
394
|
+
server.once('error', (error: unknown) => {
|
|
395
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
396
|
+
if (code === 'EADDRINUSE') {
|
|
397
|
+
resolveCanBind(false);
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
rejectCanBind(error);
|
|
401
|
+
});
|
|
402
|
+
server.listen(port, host, () => {
|
|
403
|
+
server.close((error) => {
|
|
404
|
+
if (error !== undefined) {
|
|
405
|
+
rejectCanBind(error);
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
resolveCanBind(true);
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
private async resolveClientRuntimeArgs(
|
|
415
|
+
runtimeArgs: readonly string[],
|
|
416
|
+
): Promise<readonly string[]> {
|
|
417
|
+
const inspectArg = runtimeArgs.findLast((arg) => this.parseInspectRuntimeArg(arg) !== null);
|
|
418
|
+
if (inspectArg === undefined) {
|
|
419
|
+
return runtimeArgs;
|
|
420
|
+
}
|
|
421
|
+
const inspect = this.parseInspectRuntimeArg(inspectArg);
|
|
422
|
+
if (inspect === null) {
|
|
423
|
+
return runtimeArgs;
|
|
424
|
+
}
|
|
425
|
+
const canBind = await this.canBindPort(inspect.host, inspect.port);
|
|
426
|
+
if (canBind) {
|
|
427
|
+
return runtimeArgs;
|
|
428
|
+
}
|
|
429
|
+
this.writeStdout(
|
|
430
|
+
`warning: client inspector ${inspect.host}:${String(inspect.port)} is already in use; continuing without inspector\n`,
|
|
431
|
+
);
|
|
432
|
+
return runtimeArgs.filter((arg) => this.parseInspectRuntimeArg(arg) === null);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
public async runDefaultClient(args: readonly string[]): Promise<number> {
|
|
436
|
+
const ensured = await this.gatewayService.withLock(
|
|
437
|
+
async () => await this.gatewayService.ensureGatewayRunning({}),
|
|
438
|
+
);
|
|
439
|
+
if (ensured.started) {
|
|
440
|
+
this.writeStdout(
|
|
441
|
+
`gateway started pid=${String(ensured.record.pid)} host=${ensured.record.host} port=${String(ensured.record.port)}\n`,
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
return await this.gatewayService.runMuxClient(
|
|
445
|
+
ensured.record,
|
|
446
|
+
args,
|
|
447
|
+
await this.resolveClientRuntimeArgs(this.runtime.runtimeOptions.clientRuntimeArgs),
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
public async runProfileCli(args: readonly string[]): Promise<number> {
|
|
452
|
+
const command = this.profileParser.parse(args);
|
|
453
|
+
if (command.type === 'start') {
|
|
454
|
+
return await this.runProfileStart(command);
|
|
455
|
+
}
|
|
456
|
+
if (command.type === 'stop') {
|
|
457
|
+
return await this.runProfileStop(command);
|
|
458
|
+
}
|
|
459
|
+
return await this.runProfileRun(command);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
public async runStatusTimelineCli(args: readonly string[]): Promise<number> {
|
|
463
|
+
const command = this.statusTimelineParser.parse(args);
|
|
464
|
+
if (command.type === 'stop') {
|
|
465
|
+
return await this.runStatusTimelineStop();
|
|
466
|
+
}
|
|
467
|
+
return await this.runStatusTimelineStart(command);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
public async runRenderTraceCli(args: readonly string[]): Promise<number> {
|
|
471
|
+
const command = this.renderTraceParser.parse(args);
|
|
472
|
+
if (command.type === 'stop') {
|
|
473
|
+
return await this.runRenderTraceStop();
|
|
474
|
+
}
|
|
475
|
+
return await this.runRenderTraceStart(command);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
private removeFileIfExists(filePath: string): void {
|
|
479
|
+
try {
|
|
480
|
+
unlinkSync(filePath);
|
|
481
|
+
} catch (error: unknown) {
|
|
482
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
483
|
+
if (code !== 'ENOENT') {
|
|
484
|
+
throw error;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
private parseActiveProfileState(raw: unknown): ActiveProfileState | null {
|
|
490
|
+
if (typeof raw !== 'object' || raw === null) {
|
|
491
|
+
return null;
|
|
492
|
+
}
|
|
493
|
+
const candidate = raw as Record<string, unknown>;
|
|
494
|
+
if (candidate['version'] !== PROFILE_STATE_VERSION) {
|
|
495
|
+
return null;
|
|
496
|
+
}
|
|
497
|
+
if (candidate['mode'] !== PROFILE_LIVE_INSPECT_MODE) {
|
|
498
|
+
return null;
|
|
499
|
+
}
|
|
500
|
+
const pid = candidate['pid'];
|
|
501
|
+
const host = candidate['host'];
|
|
502
|
+
const port = candidate['port'];
|
|
503
|
+
const stateDbPath = candidate['stateDbPath'];
|
|
504
|
+
const profileDir = candidate['profileDir'];
|
|
505
|
+
const gatewayProfilePath = candidate['gatewayProfilePath'];
|
|
506
|
+
const inspectWebSocketUrl = candidate['inspectWebSocketUrl'];
|
|
507
|
+
const startedAt = candidate['startedAt'];
|
|
508
|
+
if (!Number.isInteger(pid) || (pid as number) <= 0) {
|
|
509
|
+
return null;
|
|
510
|
+
}
|
|
511
|
+
if (typeof host !== 'string' || host.length === 0) {
|
|
512
|
+
return null;
|
|
513
|
+
}
|
|
514
|
+
if (!Number.isInteger(port) || (port as number) <= 0 || (port as number) > 65535) {
|
|
515
|
+
return null;
|
|
516
|
+
}
|
|
517
|
+
if (typeof stateDbPath !== 'string' || stateDbPath.length === 0) {
|
|
518
|
+
return null;
|
|
519
|
+
}
|
|
520
|
+
if (typeof profileDir !== 'string' || profileDir.length === 0) {
|
|
521
|
+
return null;
|
|
522
|
+
}
|
|
523
|
+
if (typeof gatewayProfilePath !== 'string' || gatewayProfilePath.length === 0) {
|
|
524
|
+
return null;
|
|
525
|
+
}
|
|
526
|
+
if (typeof inspectWebSocketUrl !== 'string' || inspectWebSocketUrl.length === 0) {
|
|
527
|
+
return null;
|
|
528
|
+
}
|
|
529
|
+
if (typeof startedAt !== 'string' || startedAt.length === 0) {
|
|
530
|
+
return null;
|
|
531
|
+
}
|
|
532
|
+
return {
|
|
533
|
+
version: PROFILE_STATE_VERSION,
|
|
534
|
+
mode: PROFILE_LIVE_INSPECT_MODE,
|
|
535
|
+
pid: pid as number,
|
|
536
|
+
host,
|
|
537
|
+
port: port as number,
|
|
538
|
+
stateDbPath,
|
|
539
|
+
profileDir,
|
|
540
|
+
gatewayProfilePath,
|
|
541
|
+
inspectWebSocketUrl,
|
|
542
|
+
startedAt,
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
private readActiveProfileState(profileStatePath: string): ActiveProfileState | null {
|
|
547
|
+
if (!existsSync(profileStatePath)) {
|
|
548
|
+
return null;
|
|
549
|
+
}
|
|
550
|
+
try {
|
|
551
|
+
const raw = JSON.parse(readFileSync(profileStatePath, 'utf8')) as unknown;
|
|
552
|
+
return this.parseActiveProfileState(raw);
|
|
553
|
+
} catch {
|
|
554
|
+
return null;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
private writeActiveProfileState(profileStatePath: string, state: ActiveProfileState): void {
|
|
559
|
+
this.infra.writeTextFileAtomically(profileStatePath, `${JSON.stringify(state, null, 2)}\n`);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
private removeActiveProfileState(profileStatePath: string): void {
|
|
563
|
+
this.removeFileIfExists(profileStatePath);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
private readActiveStatusTimelineState(statePath: string): ActiveStatusTimelineState | null {
|
|
567
|
+
if (!existsSync(statePath)) {
|
|
568
|
+
return null;
|
|
569
|
+
}
|
|
570
|
+
try {
|
|
571
|
+
const raw = JSON.parse(readFileSync(statePath, 'utf8')) as unknown;
|
|
572
|
+
const parsed = parseActiveStatusTimelineState(raw);
|
|
573
|
+
if (parsed === null) {
|
|
574
|
+
return null;
|
|
575
|
+
}
|
|
576
|
+
return {
|
|
577
|
+
version: parsed.version,
|
|
578
|
+
mode: parsed.mode,
|
|
579
|
+
outputPath: parsed.outputPath,
|
|
580
|
+
sessionName: parsed.sessionName,
|
|
581
|
+
startedAt: parsed.startedAt,
|
|
582
|
+
};
|
|
583
|
+
} catch {
|
|
584
|
+
return null;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
private writeActiveStatusTimelineState(
|
|
589
|
+
statePath: string,
|
|
590
|
+
state: ActiveStatusTimelineState,
|
|
591
|
+
): void {
|
|
592
|
+
this.infra.writeTextFileAtomically(statePath, `${JSON.stringify(state, null, 2)}\n`);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
private removeActiveStatusTimelineState(statePath: string): void {
|
|
596
|
+
this.removeFileIfExists(statePath);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
private readActiveRenderTraceState(statePath: string): ActiveRenderTraceState | null {
|
|
600
|
+
if (!existsSync(statePath)) {
|
|
601
|
+
return null;
|
|
602
|
+
}
|
|
603
|
+
try {
|
|
604
|
+
const raw = JSON.parse(readFileSync(statePath, 'utf8')) as unknown;
|
|
605
|
+
const parsed = parseActiveRenderTraceState(raw);
|
|
606
|
+
if (parsed === null) {
|
|
607
|
+
return null;
|
|
608
|
+
}
|
|
609
|
+
return {
|
|
610
|
+
version: parsed.version,
|
|
611
|
+
mode: parsed.mode,
|
|
612
|
+
outputPath: parsed.outputPath,
|
|
613
|
+
sessionName: parsed.sessionName,
|
|
614
|
+
conversationId: parsed.conversationId,
|
|
615
|
+
startedAt: parsed.startedAt,
|
|
616
|
+
};
|
|
617
|
+
} catch {
|
|
618
|
+
return null;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
private writeActiveRenderTraceState(statePath: string, state: ActiveRenderTraceState): void {
|
|
623
|
+
this.infra.writeTextFileAtomically(statePath, `${JSON.stringify(state, null, 2)}\n`);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
private removeActiveRenderTraceState(statePath: string): void {
|
|
627
|
+
this.removeFileIfExists(statePath);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
private async runProfileRun(command: ParsedProfileRunCommand): Promise<number> {
|
|
631
|
+
const { invocationDirectory } = this.runtime;
|
|
632
|
+
const profileDir =
|
|
633
|
+
command.profileDir === null
|
|
634
|
+
? this.runtime.profileDir
|
|
635
|
+
: resolve(invocationDirectory, command.profileDir);
|
|
636
|
+
mkdirSync(profileDir, { recursive: true });
|
|
637
|
+
|
|
638
|
+
const clientProfilePath = resolve(profileDir, PROFILE_CLIENT_FILE_NAME);
|
|
639
|
+
const gatewayProfilePath = resolve(profileDir, PROFILE_GATEWAY_FILE_NAME);
|
|
640
|
+
this.removeFileIfExists(clientProfilePath);
|
|
641
|
+
this.removeFileIfExists(gatewayProfilePath);
|
|
642
|
+
|
|
643
|
+
const existingProfileState = this.readActiveProfileState(this.runtime.profileStatePath);
|
|
644
|
+
if (existingProfileState !== null) {
|
|
645
|
+
if (this.gatewayService.isPidRunning(existingProfileState.pid)) {
|
|
646
|
+
throw new Error(
|
|
647
|
+
'profile run requires no active profile session; stop it first with `harness profile stop`',
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
this.removeActiveProfileState(this.runtime.profileStatePath);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
const gateway = await this.gatewayService.withLock(async () => {
|
|
654
|
+
const existingRecord = this.gatewayService.readGatewayRecord();
|
|
655
|
+
if (existingRecord !== null) {
|
|
656
|
+
const existingProbe = await this.gatewayService.probeGateway(existingRecord);
|
|
657
|
+
if (existingProbe.connected || this.gatewayService.isPidRunning(existingRecord.pid)) {
|
|
658
|
+
throw new Error(
|
|
659
|
+
'profile command requires the target session gateway to be stopped first',
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
this.gatewayService.removeGatewayRecord();
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const host = this.gatewayService.resolveGatewayHostFromConfigOrEnv();
|
|
666
|
+
const reservedPort = await this.gatewayService.reservePort(host);
|
|
667
|
+
const settings = this.gatewayService.resolveGatewaySettings(null, {
|
|
668
|
+
port: reservedPort,
|
|
669
|
+
stateDbPath: this.runtime.gatewayDefaultStateDbPath,
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
return await this.gatewayService.startDetachedGateway(settings, [
|
|
673
|
+
...this.runtime.runtimeOptions.gatewayRuntimeArgs,
|
|
674
|
+
...buildCpuProfileRuntimeArgs({
|
|
675
|
+
cpuProfileDir: profileDir,
|
|
676
|
+
cpuProfileName: PROFILE_GATEWAY_FILE_NAME,
|
|
677
|
+
}),
|
|
678
|
+
]);
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
let clientExitCode = 1;
|
|
682
|
+
let clientError: Error | null = null;
|
|
683
|
+
try {
|
|
684
|
+
const clientRuntimeArgs = await this.resolveClientRuntimeArgs(
|
|
685
|
+
this.runtime.runtimeOptions.clientRuntimeArgs,
|
|
686
|
+
);
|
|
687
|
+
clientExitCode = await this.gatewayService.runMuxClient(gateway, command.muxArgs, [
|
|
688
|
+
...clientRuntimeArgs,
|
|
689
|
+
...buildCpuProfileRuntimeArgs({
|
|
690
|
+
cpuProfileDir: profileDir,
|
|
691
|
+
cpuProfileName: PROFILE_CLIENT_FILE_NAME,
|
|
692
|
+
}),
|
|
693
|
+
]);
|
|
694
|
+
} catch (error: unknown) {
|
|
695
|
+
clientError = error instanceof Error ? error : new Error(String(error));
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const stopped = await this.gatewayService.withLock(
|
|
699
|
+
async () =>
|
|
700
|
+
await this.gatewayService.stopGateway({
|
|
701
|
+
force: true,
|
|
702
|
+
timeoutMs: DEFAULT_GATEWAY_STOP_TIMEOUT_MS,
|
|
703
|
+
cleanupOrphans: true,
|
|
704
|
+
}),
|
|
705
|
+
);
|
|
706
|
+
this.writeStdout(`${stopped.message}\n`);
|
|
707
|
+
if (!stopped.stopped) {
|
|
708
|
+
throw new Error(`failed to stop profile gateway: ${stopped.message}`);
|
|
709
|
+
}
|
|
710
|
+
if (clientError !== null) {
|
|
711
|
+
throw clientError;
|
|
712
|
+
}
|
|
713
|
+
if (!existsSync(clientProfilePath)) {
|
|
714
|
+
throw new Error(`missing client CPU profile: ${clientProfilePath}`);
|
|
715
|
+
}
|
|
716
|
+
if (!existsSync(gatewayProfilePath)) {
|
|
717
|
+
throw new Error(`missing gateway CPU profile: ${gatewayProfilePath}`);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
this.writeStdout(`profiles: client=${clientProfilePath} gateway=${gatewayProfilePath}\n`);
|
|
721
|
+
return clientExitCode;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
private async runProfileStart(command: ParsedProfileStartCommand): Promise<number> {
|
|
725
|
+
const { invocationDirectory } = this.runtime;
|
|
726
|
+
const profileDir =
|
|
727
|
+
command.profileDir === null
|
|
728
|
+
? this.runtime.profileDir
|
|
729
|
+
: resolve(invocationDirectory, command.profileDir);
|
|
730
|
+
mkdirSync(profileDir, { recursive: true });
|
|
731
|
+
const gatewayProfilePath = resolve(profileDir, PROFILE_GATEWAY_FILE_NAME);
|
|
732
|
+
this.removeFileIfExists(gatewayProfilePath);
|
|
733
|
+
|
|
734
|
+
const existingProfileState = this.readActiveProfileState(this.runtime.profileStatePath);
|
|
735
|
+
if (existingProfileState !== null) {
|
|
736
|
+
if (this.gatewayService.isPidRunning(existingProfileState.pid)) {
|
|
737
|
+
throw new Error('profile already running; stop it first with `harness profile stop`');
|
|
738
|
+
}
|
|
739
|
+
this.removeActiveProfileState(this.runtime.profileStatePath);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
const existingRecord = this.gatewayService.readGatewayRecord();
|
|
743
|
+
if (existingRecord === null) {
|
|
744
|
+
throw new Error('profile start requires the target session gateway to be running');
|
|
745
|
+
}
|
|
746
|
+
const existingProbe = await this.gatewayService.probeGateway(existingRecord);
|
|
747
|
+
if (!existingProbe.connected || !this.gatewayService.isPidRunning(existingRecord.pid)) {
|
|
748
|
+
throw new Error('profile start requires the target session gateway to be running');
|
|
749
|
+
}
|
|
750
|
+
const inspector = await connectGatewayInspector(
|
|
751
|
+
invocationDirectory,
|
|
752
|
+
this.runtime.gatewayLogPath,
|
|
753
|
+
DEFAULT_PROFILE_INSPECT_TIMEOUT_MS,
|
|
754
|
+
);
|
|
755
|
+
try {
|
|
756
|
+
const startCommandRaw = await evaluateInspectorExpression(
|
|
757
|
+
inspector.client,
|
|
758
|
+
buildInspectorProfileStartExpression(),
|
|
759
|
+
DEFAULT_PROFILE_INSPECT_TIMEOUT_MS,
|
|
760
|
+
);
|
|
761
|
+
if (typeof startCommandRaw !== 'string') {
|
|
762
|
+
throw new Error('failed to start gateway profiler (invalid inspector response)');
|
|
763
|
+
}
|
|
764
|
+
const startCommandResult = JSON.parse(startCommandRaw) as Record<string, unknown>;
|
|
765
|
+
if (startCommandResult['ok'] !== true) {
|
|
766
|
+
const reason = startCommandResult['reason'];
|
|
767
|
+
throw new Error(
|
|
768
|
+
`failed to start gateway profiler (${typeof reason === 'string' ? reason : 'unknown reason'})`,
|
|
769
|
+
);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
const startDeadline = Date.now() + DEFAULT_PROFILE_INSPECT_TIMEOUT_MS;
|
|
773
|
+
let runningState: InspectorProfileState | null = null;
|
|
774
|
+
while (Date.now() < startDeadline) {
|
|
775
|
+
const state = await readInspectorProfileState(
|
|
776
|
+
inspector.client,
|
|
777
|
+
DEFAULT_PROFILE_INSPECT_TIMEOUT_MS,
|
|
778
|
+
);
|
|
779
|
+
if (state !== null && state.status === 'running') {
|
|
780
|
+
runningState = state;
|
|
781
|
+
break;
|
|
782
|
+
}
|
|
783
|
+
if (state !== null && state.status === 'failed') {
|
|
784
|
+
throw new Error(`failed to start gateway profiler (${state.error ?? 'unknown error'})`);
|
|
785
|
+
}
|
|
786
|
+
await delay(DEFAULT_GATEWAY_STOP_POLL_MS);
|
|
787
|
+
}
|
|
788
|
+
if (runningState === null) {
|
|
789
|
+
throw new Error('failed to start gateway profiler (inspector runtime timeout)');
|
|
790
|
+
}
|
|
791
|
+
} finally {
|
|
792
|
+
inspector.client.close();
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
this.writeActiveProfileState(this.runtime.profileStatePath, {
|
|
796
|
+
version: PROFILE_STATE_VERSION,
|
|
797
|
+
mode: PROFILE_LIVE_INSPECT_MODE,
|
|
798
|
+
pid: existingRecord.pid,
|
|
799
|
+
host: existingRecord.host,
|
|
800
|
+
port: existingRecord.port,
|
|
801
|
+
stateDbPath: existingRecord.stateDbPath,
|
|
802
|
+
profileDir,
|
|
803
|
+
gatewayProfilePath,
|
|
804
|
+
inspectWebSocketUrl: inspector.endpoint,
|
|
805
|
+
startedAt: new Date().toISOString(),
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
this.writeStdout(
|
|
809
|
+
`profile started pid=${String(existingRecord.pid)} host=${existingRecord.host} port=${String(existingRecord.port)}\n`,
|
|
810
|
+
);
|
|
811
|
+
this.writeStdout(`record: ${this.runtime.gatewayRecordPath}\n`);
|
|
812
|
+
this.writeStdout(`log: ${this.runtime.gatewayLogPath}\n`);
|
|
813
|
+
this.writeStdout(`profile-state: ${this.runtime.profileStatePath}\n`);
|
|
814
|
+
this.writeStdout(`profile-target: ${gatewayProfilePath}\n`);
|
|
815
|
+
this.writeStdout('stop with: harness profile stop\n');
|
|
816
|
+
return 0;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
private async runProfileStop(command: ParsedProfileStopCommand): Promise<number> {
|
|
820
|
+
const profileState = this.readActiveProfileState(this.runtime.profileStatePath);
|
|
821
|
+
if (profileState === null) {
|
|
822
|
+
throw new Error(
|
|
823
|
+
'no active profile run for this session; start one with `harness profile start`',
|
|
824
|
+
);
|
|
825
|
+
}
|
|
826
|
+
if (profileState.mode !== PROFILE_LIVE_INSPECT_MODE) {
|
|
827
|
+
throw new Error('active profile run is incompatible with this harness version');
|
|
828
|
+
}
|
|
829
|
+
const inspector = await InspectorWebSocketClient.connect(
|
|
830
|
+
profileState.inspectWebSocketUrl,
|
|
831
|
+
command.stopOptions.timeoutMs,
|
|
832
|
+
);
|
|
833
|
+
try {
|
|
834
|
+
await inspector.sendCommand('Runtime.enable', {}, command.stopOptions.timeoutMs);
|
|
835
|
+
const stopCommandRaw = await evaluateInspectorExpression(
|
|
836
|
+
inspector,
|
|
837
|
+
buildInspectorProfileStopExpression(
|
|
838
|
+
profileState.gatewayProfilePath,
|
|
839
|
+
profileState.profileDir,
|
|
840
|
+
),
|
|
841
|
+
command.stopOptions.timeoutMs,
|
|
842
|
+
);
|
|
843
|
+
if (typeof stopCommandRaw !== 'string') {
|
|
844
|
+
throw new Error('failed to stop gateway profiler (invalid inspector response)');
|
|
845
|
+
}
|
|
846
|
+
const stopCommandResult = JSON.parse(stopCommandRaw) as Record<string, unknown>;
|
|
847
|
+
if (stopCommandResult['ok'] !== true) {
|
|
848
|
+
const reason = stopCommandResult['reason'];
|
|
849
|
+
throw new Error(
|
|
850
|
+
`failed to stop gateway profiler (${typeof reason === 'string' ? reason : 'unknown reason'})`,
|
|
851
|
+
);
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
const startedAt = Date.now();
|
|
855
|
+
while (Date.now() - startedAt < command.stopOptions.timeoutMs) {
|
|
856
|
+
const state = await readInspectorProfileState(inspector, command.stopOptions.timeoutMs);
|
|
857
|
+
if (state !== null && state.status === 'failed') {
|
|
858
|
+
throw new Error(`failed to stop gateway profiler (${state.error ?? 'unknown error'})`);
|
|
859
|
+
}
|
|
860
|
+
if (state !== null && state.status === 'stopped' && state.written) {
|
|
861
|
+
break;
|
|
862
|
+
}
|
|
863
|
+
await delay(DEFAULT_GATEWAY_STOP_POLL_MS);
|
|
864
|
+
}
|
|
865
|
+
} finally {
|
|
866
|
+
inspector.close();
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
const profileFlushed = await this.gatewayService.waitForFileExists(
|
|
870
|
+
profileState.gatewayProfilePath,
|
|
871
|
+
command.stopOptions.timeoutMs,
|
|
872
|
+
);
|
|
873
|
+
if (!profileFlushed) {
|
|
874
|
+
throw new Error(`missing gateway CPU profile: ${profileState.gatewayProfilePath}`);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
this.removeActiveProfileState(this.runtime.profileStatePath);
|
|
878
|
+
this.writeStdout(`profile: gateway=${profileState.gatewayProfilePath}\n`);
|
|
879
|
+
return 0;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
private async runStatusTimelineStart(command: ParsedStatusTimelineStartCommand): Promise<number> {
|
|
883
|
+
const { invocationDirectory, sessionName } = this.runtime;
|
|
884
|
+
const outputPath =
|
|
885
|
+
command.outputPath === null
|
|
886
|
+
? this.runtime.defaultStatusTimelineOutputPath
|
|
887
|
+
: resolve(invocationDirectory, command.outputPath);
|
|
888
|
+
const existingState = this.readActiveStatusTimelineState(this.runtime.statusTimelineStatePath);
|
|
889
|
+
if (existingState !== null) {
|
|
890
|
+
throw new Error(
|
|
891
|
+
'status timeline already running; stop it first with `harness status-timeline stop`',
|
|
892
|
+
);
|
|
893
|
+
}
|
|
894
|
+
mkdirSync(dirname(outputPath), { recursive: true });
|
|
895
|
+
writeFileSync(outputPath, '', 'utf8');
|
|
896
|
+
this.writeActiveStatusTimelineState(this.runtime.statusTimelineStatePath, {
|
|
897
|
+
version: STATUS_TIMELINE_STATE_VERSION,
|
|
898
|
+
mode: STATUS_TIMELINE_MODE,
|
|
899
|
+
outputPath,
|
|
900
|
+
sessionName,
|
|
901
|
+
startedAt: new Date().toISOString(),
|
|
902
|
+
});
|
|
903
|
+
this.writeStdout('status timeline started\n');
|
|
904
|
+
this.writeStdout(`status-timeline-state: ${this.runtime.statusTimelineStatePath}\n`);
|
|
905
|
+
this.writeStdout(`status-timeline-target: ${outputPath}\n`);
|
|
906
|
+
this.writeStdout('stop with: harness status-timeline stop\n');
|
|
907
|
+
return 0;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
private async runStatusTimelineStop(): Promise<number> {
|
|
911
|
+
const state = this.readActiveStatusTimelineState(this.runtime.statusTimelineStatePath);
|
|
912
|
+
if (state === null) {
|
|
913
|
+
throw new Error(
|
|
914
|
+
'no active status timeline run for this session; start one with `harness status-timeline start`',
|
|
915
|
+
);
|
|
916
|
+
}
|
|
917
|
+
this.removeActiveStatusTimelineState(this.runtime.statusTimelineStatePath);
|
|
918
|
+
this.writeStdout(`status timeline stopped: ${state.outputPath}\n`);
|
|
919
|
+
return 0;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
private async runRenderTraceStart(command: ParsedRenderTraceStartCommand): Promise<number> {
|
|
923
|
+
const { invocationDirectory, sessionName } = this.runtime;
|
|
924
|
+
const outputPath =
|
|
925
|
+
command.outputPath === null
|
|
926
|
+
? this.runtime.defaultRenderTraceOutputPath
|
|
927
|
+
: resolve(invocationDirectory, command.outputPath);
|
|
928
|
+
const existingState = this.readActiveRenderTraceState(this.runtime.renderTraceStatePath);
|
|
929
|
+
if (existingState !== null) {
|
|
930
|
+
throw new Error(
|
|
931
|
+
'render trace already running; stop it first with `harness render-trace stop`',
|
|
932
|
+
);
|
|
933
|
+
}
|
|
934
|
+
mkdirSync(dirname(outputPath), { recursive: true });
|
|
935
|
+
writeFileSync(outputPath, '', 'utf8');
|
|
936
|
+
this.writeActiveRenderTraceState(this.runtime.renderTraceStatePath, {
|
|
937
|
+
version: RENDER_TRACE_STATE_VERSION,
|
|
938
|
+
mode: RENDER_TRACE_MODE,
|
|
939
|
+
outputPath,
|
|
940
|
+
sessionName,
|
|
941
|
+
conversationId: command.conversationId,
|
|
942
|
+
startedAt: new Date().toISOString(),
|
|
943
|
+
});
|
|
944
|
+
this.writeStdout('render trace started\n');
|
|
945
|
+
this.writeStdout(`render-trace-state: ${this.runtime.renderTraceStatePath}\n`);
|
|
946
|
+
this.writeStdout(`render-trace-target: ${outputPath}\n`);
|
|
947
|
+
if (command.conversationId !== null) {
|
|
948
|
+
this.writeStdout(`render-trace-conversation-id: ${command.conversationId}\n`);
|
|
949
|
+
}
|
|
950
|
+
this.writeStdout('stop with: harness render-trace stop\n');
|
|
951
|
+
return 0;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
private async runRenderTraceStop(): Promise<number> {
|
|
955
|
+
const state = this.readActiveRenderTraceState(this.runtime.renderTraceStatePath);
|
|
956
|
+
if (state === null) {
|
|
957
|
+
throw new Error(
|
|
958
|
+
'no active render trace run for this session; start one with `harness render-trace start`',
|
|
959
|
+
);
|
|
960
|
+
}
|
|
961
|
+
this.removeActiveRenderTraceState(this.runtime.renderTraceStatePath);
|
|
962
|
+
this.writeStdout(`render trace stopped: ${state.outputPath}\n`);
|
|
963
|
+
return 0;
|
|
964
|
+
}
|
|
965
|
+
}
|