@jmoyers/harness 0.1.10 → 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 -35
- 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/{src/ui/modals/manager.ts → packages/harness-ui/src/modal-manager.ts} +94 -64
- 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 -3721
- package/scripts/control-plane-daemon.ts +24 -2
- 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 -3007
- package/scripts/nim-tui-smoke.ts +748 -0
- package/src/cli/auth/runtime.ts +948 -0
- package/src/cli/default-gateway-pointer.ts +193 -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 +361 -10
- package/src/config/harness-paths.ts +4 -7
- package/src/config/harness-runtime-migration.ts +142 -19
- package/src/config/harness.config.template.jsonc +33 -0
- package/src/config/secrets-core.ts +92 -4
- package/src/control-plane/agent-realtime-api.ts +82 -427
- package/src/control-plane/prompt/thread-title-namer.ts +49 -23
- 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-background.ts +18 -2
- 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 +943 -80
- 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/conversations.ts +11 -7
- package/src/domain/workspace.ts +76 -4
- 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 +22 -112
- 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-parsing.ts +16 -0
- 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 +106 -8
- package/src/mux/live-mux/modal-overlays.ts +210 -31
- package/src/mux/live-mux/modal-pointer.ts +3 -7
- package/src/mux/live-mux/modal-prompt-handlers.ts +107 -1
- 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 +19 -82
- 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 +82 -30
- package/src/services/runtime-conversation-starter.ts +80 -48
- 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 -70
- 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 +396 -56
- package/src/store/event-store.ts +397 -3
- 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 -82
- 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 -189
- package/src/services/runtime-main-pane-input.ts +0 -230
- package/src/services/runtime-modal-input.ts +0 -119
- package/src/services/runtime-navigation-input.ts +0 -197
- package/src/services/runtime-rail-input.ts +0 -278
- 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 -238
- 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/repository-fold-input.ts +0 -91
- package/src/ui/surface.ts +0 -224
|
@@ -0,0 +1,1872 @@
|
|
|
1
|
+
import { once } from 'node:events';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import {
|
|
5
|
+
closeSync,
|
|
6
|
+
existsSync,
|
|
7
|
+
mkdirSync,
|
|
8
|
+
openSync,
|
|
9
|
+
readdirSync,
|
|
10
|
+
readFileSync,
|
|
11
|
+
rmSync,
|
|
12
|
+
statSync,
|
|
13
|
+
} from 'node:fs';
|
|
14
|
+
import { createServer as createNetServer } from 'node:net';
|
|
15
|
+
import type { AddressInfo } from 'node:net';
|
|
16
|
+
import { basename, dirname, relative, resolve } from 'node:path';
|
|
17
|
+
import { connectControlPlaneStreamClient } from '../../control-plane/stream-client.ts';
|
|
18
|
+
import { parseStreamCommand } from '../../control-plane/stream-command-parser.ts';
|
|
19
|
+
import type { StreamCommand } from '../../control-plane/stream-protocol.ts';
|
|
20
|
+
import {
|
|
21
|
+
GATEWAY_RECORD_VERSION,
|
|
22
|
+
isLoopbackHost,
|
|
23
|
+
normalizeGatewayHost,
|
|
24
|
+
normalizeGatewayPort,
|
|
25
|
+
normalizeGatewayStateDbPath,
|
|
26
|
+
parseGatewayRecordText,
|
|
27
|
+
type GatewayRecord,
|
|
28
|
+
} from '../gateway-record.ts';
|
|
29
|
+
import { loadHarnessConfig, type HarnessStorageLifecycleConfig } from '../../config/config-core.ts';
|
|
30
|
+
import {
|
|
31
|
+
resolveHarnessRuntimePath,
|
|
32
|
+
resolveHarnessWorkspaceDirectory,
|
|
33
|
+
} from '../../config/harness-paths.ts';
|
|
34
|
+
import { parsePortFlag, parsePositiveIntFlag, readCliValue } from '../parsing/flags.ts';
|
|
35
|
+
import { SqliteControlPlaneStore } from '../../store/control-plane-store.ts';
|
|
36
|
+
import {
|
|
37
|
+
GatewayControlInfra,
|
|
38
|
+
type ParsedGatewayDaemonEntry,
|
|
39
|
+
} from '../runtime-infra/gateway-control.ts';
|
|
40
|
+
|
|
41
|
+
const DEFAULT_GATEWAY_START_RETRY_WINDOW_MS = 6000;
|
|
42
|
+
const DEFAULT_GATEWAY_START_RETRY_DELAY_MS = 40;
|
|
43
|
+
export const DEFAULT_GATEWAY_STOP_TIMEOUT_MS = 5000;
|
|
44
|
+
const DEFAULT_GATEWAY_GC_OLDER_THAN_DAYS = 7;
|
|
45
|
+
const DEFAULT_SESSION_ROOT_PATH = 'sessions';
|
|
46
|
+
const GATEWAY_GC_MAX_PRUNE_PASSES = 10_000;
|
|
47
|
+
const GATEWAY_GC_MAX_COMPACTION_STEPS = 10_000;
|
|
48
|
+
|
|
49
|
+
export interface GatewayStartOptions {
|
|
50
|
+
host?: string;
|
|
51
|
+
port?: number;
|
|
52
|
+
authToken?: string;
|
|
53
|
+
stateDbPath?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface GatewayStopOptions {
|
|
57
|
+
force: boolean;
|
|
58
|
+
timeoutMs: number;
|
|
59
|
+
cleanupOrphans: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface GatewayGcOptions {
|
|
63
|
+
olderThanDays: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface ParsedGatewayCommand {
|
|
67
|
+
type: 'start' | 'stop' | 'status' | 'list' | 'restart' | 'run' | 'call' | 'gc';
|
|
68
|
+
startOptions?: GatewayStartOptions;
|
|
69
|
+
stopOptions?: GatewayStopOptions;
|
|
70
|
+
callJson?: string;
|
|
71
|
+
gcOptions?: GatewayGcOptions;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface RuntimeInspectOptions {
|
|
75
|
+
readonly gatewayRuntimeArgs: readonly string[];
|
|
76
|
+
readonly clientRuntimeArgs: readonly string[];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface GatewayAuthCoordinator {
|
|
80
|
+
refreshLinearOauthTokenBeforeGatewayStart(): Promise<void>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface GatewayRuntimeContext {
|
|
84
|
+
readonly invocationDirectory: string;
|
|
85
|
+
readonly sessionName: string | null;
|
|
86
|
+
readonly daemonScriptPath: string;
|
|
87
|
+
readonly muxScriptPath: string;
|
|
88
|
+
readonly gatewayRecordPath: string;
|
|
89
|
+
readonly gatewayLogPath: string;
|
|
90
|
+
readonly gatewayLockPath: string;
|
|
91
|
+
readonly gatewayDefaultStateDbPath: string;
|
|
92
|
+
readonly runtimeOptions: RuntimeInspectOptions;
|
|
93
|
+
readonly authRuntime: GatewayAuthCoordinator;
|
|
94
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
95
|
+
readonly cwd?: string;
|
|
96
|
+
readonly writeStdout?: (text: string) => void;
|
|
97
|
+
readonly writeStderr?: (text: string) => void;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface ResolvedGatewaySettings {
|
|
101
|
+
host: string;
|
|
102
|
+
port: number;
|
|
103
|
+
authToken: string | null;
|
|
104
|
+
stateDbPath: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface GatewayProbeResult {
|
|
108
|
+
connected: boolean;
|
|
109
|
+
sessionCount: number;
|
|
110
|
+
liveSessionCount: number;
|
|
111
|
+
error: string | null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface EnsureGatewayResult {
|
|
115
|
+
record: GatewayRecord;
|
|
116
|
+
started: boolean;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface GatewayStopResult {
|
|
120
|
+
stopped: boolean;
|
|
121
|
+
message: string;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
interface GatewayGcResult {
|
|
125
|
+
scanned: number;
|
|
126
|
+
deleted: number;
|
|
127
|
+
skippedRecent: number;
|
|
128
|
+
skippedLive: number;
|
|
129
|
+
skippedCurrent: number;
|
|
130
|
+
storageMaintenanceApplied?: number;
|
|
131
|
+
storageMaintenanceSkippedLive?: number;
|
|
132
|
+
storageMaintenanceErrors?: readonly string[];
|
|
133
|
+
deletedSessions: readonly string[];
|
|
134
|
+
errors: readonly string[];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
type GatewayListScope = 'default' | 'named' | 'unscoped';
|
|
138
|
+
|
|
139
|
+
interface GatewaySessionSummary {
|
|
140
|
+
sessionId: string;
|
|
141
|
+
live: boolean;
|
|
142
|
+
status: string | null;
|
|
143
|
+
phase: string | null;
|
|
144
|
+
detail: string | null;
|
|
145
|
+
processId: number | null;
|
|
146
|
+
controller: string | null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
interface GatewaySessionListResult {
|
|
150
|
+
connected: boolean;
|
|
151
|
+
totalSessions: number;
|
|
152
|
+
liveSessions: number;
|
|
153
|
+
sessions: readonly GatewaySessionSummary[];
|
|
154
|
+
error: string | null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
interface GatewayListTarget {
|
|
158
|
+
scope: GatewayListScope;
|
|
159
|
+
sessionName: string | null;
|
|
160
|
+
source: 'record' | 'daemon';
|
|
161
|
+
pid: number;
|
|
162
|
+
host: string;
|
|
163
|
+
port: number;
|
|
164
|
+
authToken: string | null;
|
|
165
|
+
stateDbPath: string;
|
|
166
|
+
startedAt: string | null;
|
|
167
|
+
gatewayRunId: string | null;
|
|
168
|
+
recordPath: string | null;
|
|
169
|
+
logPath: string | null;
|
|
170
|
+
lockPath: string | null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function tsRuntimeArgs(
|
|
174
|
+
scriptPath: string,
|
|
175
|
+
args: readonly string[] = [],
|
|
176
|
+
runtimeArgs: readonly string[] = [],
|
|
177
|
+
): string[] {
|
|
178
|
+
return [...runtimeArgs, scriptPath, ...args];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function normalizeSignalExitCode(signal: NodeJS.Signals | null): number {
|
|
182
|
+
if (signal === null) {
|
|
183
|
+
return 1;
|
|
184
|
+
}
|
|
185
|
+
if (signal === 'SIGINT') {
|
|
186
|
+
return 130;
|
|
187
|
+
}
|
|
188
|
+
if (signal === 'SIGTERM') {
|
|
189
|
+
return 143;
|
|
190
|
+
}
|
|
191
|
+
return 1;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
class GatewayCommandParser {
|
|
195
|
+
public constructor() {}
|
|
196
|
+
|
|
197
|
+
public parse(argv: readonly string[]): ParsedGatewayCommand {
|
|
198
|
+
if (argv.length === 0) {
|
|
199
|
+
throw new Error('missing gateway subcommand');
|
|
200
|
+
}
|
|
201
|
+
const subcommand = argv[0]!;
|
|
202
|
+
const rest = argv.slice(1);
|
|
203
|
+
if (subcommand === 'start') {
|
|
204
|
+
return {
|
|
205
|
+
type: 'start',
|
|
206
|
+
startOptions: this.parseGatewayStartOptions(rest),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
if (subcommand === 'run') {
|
|
210
|
+
return {
|
|
211
|
+
type: 'run',
|
|
212
|
+
startOptions: this.parseGatewayStartOptions(rest),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
if (subcommand === 'restart') {
|
|
216
|
+
return {
|
|
217
|
+
type: 'restart',
|
|
218
|
+
startOptions: this.parseGatewayStartOptions(rest),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
if (subcommand === 'stop') {
|
|
222
|
+
return {
|
|
223
|
+
type: 'stop',
|
|
224
|
+
stopOptions: this.parseGatewayStopOptions(rest),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
if (subcommand === 'status') {
|
|
228
|
+
if (rest.length > 0) {
|
|
229
|
+
throw new Error(`unknown gateway option: ${rest[0]}`);
|
|
230
|
+
}
|
|
231
|
+
return { type: 'status' };
|
|
232
|
+
}
|
|
233
|
+
if (subcommand === 'list') {
|
|
234
|
+
if (rest.length > 0) {
|
|
235
|
+
throw new Error(`unknown gateway option: ${rest[0]}`);
|
|
236
|
+
}
|
|
237
|
+
return { type: 'list' };
|
|
238
|
+
}
|
|
239
|
+
if (subcommand === 'call') {
|
|
240
|
+
return {
|
|
241
|
+
type: 'call',
|
|
242
|
+
callJson: this.parseGatewayCallJson(rest),
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
if (subcommand === 'gc') {
|
|
246
|
+
return {
|
|
247
|
+
type: 'gc',
|
|
248
|
+
gcOptions: this.parseGatewayGcOptions(rest),
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
throw new Error(`unknown gateway subcommand: ${subcommand}`);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private parseGatewayStartOptions(argv: readonly string[]): GatewayStartOptions {
|
|
255
|
+
const options: GatewayStartOptions = {};
|
|
256
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
257
|
+
const arg = argv[index]!;
|
|
258
|
+
if (arg === '--host') {
|
|
259
|
+
options.host = readCliValue(argv, index, '--host');
|
|
260
|
+
index += 1;
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
if (arg === '--port') {
|
|
264
|
+
options.port = parsePortFlag(readCliValue(argv, index, '--port'), '--port');
|
|
265
|
+
index += 1;
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
if (arg === '--auth-token') {
|
|
269
|
+
options.authToken = readCliValue(argv, index, '--auth-token');
|
|
270
|
+
index += 1;
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
if (arg === '--state-db-path') {
|
|
274
|
+
options.stateDbPath = readCliValue(argv, index, '--state-db-path');
|
|
275
|
+
index += 1;
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
throw new Error(`unknown gateway option: ${arg}`);
|
|
279
|
+
}
|
|
280
|
+
return options;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
private parseGatewayStopOptions(argv: readonly string[]): GatewayStopOptions {
|
|
284
|
+
const options: GatewayStopOptions = {
|
|
285
|
+
force: false,
|
|
286
|
+
timeoutMs: DEFAULT_GATEWAY_STOP_TIMEOUT_MS,
|
|
287
|
+
cleanupOrphans: true,
|
|
288
|
+
};
|
|
289
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
290
|
+
const arg = argv[index]!;
|
|
291
|
+
if (arg === '--force') {
|
|
292
|
+
options.force = true;
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
if (arg === '--cleanup-orphans') {
|
|
296
|
+
options.cleanupOrphans = true;
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
if (arg === '--no-cleanup-orphans') {
|
|
300
|
+
options.cleanupOrphans = false;
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
if (arg === '--timeout-ms') {
|
|
304
|
+
options.timeoutMs = parsePositiveIntFlag(
|
|
305
|
+
readCliValue(argv, index, '--timeout-ms'),
|
|
306
|
+
'--timeout-ms',
|
|
307
|
+
);
|
|
308
|
+
index += 1;
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
throw new Error(`unknown gateway option: ${arg}`);
|
|
312
|
+
}
|
|
313
|
+
return options;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
private parseGatewayCallJson(argv: readonly string[]): string {
|
|
317
|
+
let json: string | null = null;
|
|
318
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
319
|
+
const arg = argv[index]!;
|
|
320
|
+
if (arg === '--json') {
|
|
321
|
+
json = readCliValue(argv, index, '--json');
|
|
322
|
+
index += 1;
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
if (json === null) {
|
|
326
|
+
json = arg;
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
throw new Error(`unknown gateway option: ${arg}`);
|
|
330
|
+
}
|
|
331
|
+
if (json === null) {
|
|
332
|
+
throw new Error(
|
|
333
|
+
'missing command json; use `harness gateway call --json \'{"type":"session.list"}\'`',
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
return json;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
private parseGatewayGcOptions(argv: readonly string[]): GatewayGcOptions {
|
|
340
|
+
let olderThanDays = DEFAULT_GATEWAY_GC_OLDER_THAN_DAYS;
|
|
341
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
342
|
+
const arg = argv[index]!;
|
|
343
|
+
if (arg === '--older-than-days') {
|
|
344
|
+
olderThanDays = parsePositiveIntFlag(
|
|
345
|
+
readCliValue(argv, index, '--older-than-days'),
|
|
346
|
+
'--older-than-days',
|
|
347
|
+
);
|
|
348
|
+
index += 1;
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
throw new Error(`unknown gateway option: ${arg}`);
|
|
352
|
+
}
|
|
353
|
+
return {
|
|
354
|
+
olderThanDays,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export class GatewayRuntimeService {
|
|
360
|
+
public readonly parser: GatewayCommandParser;
|
|
361
|
+
private readonly infra: GatewayControlInfra;
|
|
362
|
+
|
|
363
|
+
constructor(
|
|
364
|
+
private readonly runtime: GatewayRuntimeContext,
|
|
365
|
+
options: { parser?: GatewayCommandParser; infra?: GatewayControlInfra } = {},
|
|
366
|
+
) {
|
|
367
|
+
this.parser = options.parser ?? new GatewayCommandParser();
|
|
368
|
+
const infraOverrides = {
|
|
369
|
+
...(this.runtime.env === undefined ? {} : { env: this.runtime.env }),
|
|
370
|
+
...(this.runtime.cwd === undefined ? {} : { cwd: this.runtime.cwd }),
|
|
371
|
+
};
|
|
372
|
+
this.infra = options.infra ?? new GatewayControlInfra(infraOverrides);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
private env(): NodeJS.ProcessEnv {
|
|
376
|
+
return this.runtime.env ?? process.env;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
private writeStdout(text: string): void {
|
|
380
|
+
if (this.runtime.writeStdout !== undefined) {
|
|
381
|
+
this.runtime.writeStdout(text);
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
process.stdout.write(text);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
private writeStderr(text: string): void {
|
|
388
|
+
if (this.runtime.writeStderr !== undefined) {
|
|
389
|
+
this.runtime.writeStderr(text);
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
process.stderr.write(text);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
public parseCommand(argv: readonly string[]): ParsedGatewayCommand {
|
|
396
|
+
return this.parser.parse(argv);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
public withLock<T>(operation: () => Promise<T>): Promise<T> {
|
|
400
|
+
return this.infra.withGatewayControlLock(
|
|
401
|
+
this.runtime.gatewayLockPath,
|
|
402
|
+
this.runtime.invocationDirectory,
|
|
403
|
+
operation,
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
public readGatewayRecord(): GatewayRecord | null {
|
|
408
|
+
return this.infra.readGatewayRecord(this.runtime.gatewayRecordPath);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
public removeGatewayRecord(): void {
|
|
412
|
+
this.infra.removeGatewayRecord(this.runtime.gatewayRecordPath);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
public isPidRunning(pid: number): boolean {
|
|
416
|
+
return this.infra.isPidRunning(pid);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
public async waitForFileExists(filePath: string, timeoutMs: number): Promise<boolean> {
|
|
420
|
+
return await this.infra.waitForFileExists(filePath, timeoutMs);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
public resolveGatewayHostFromConfigOrEnv(): string {
|
|
424
|
+
const loadedConfig = loadHarnessConfig({
|
|
425
|
+
cwd: this.runtime.invocationDirectory,
|
|
426
|
+
env: this.env(),
|
|
427
|
+
});
|
|
428
|
+
return normalizeGatewayHost(
|
|
429
|
+
this.env().HARNESS_CONTROL_PLANE_HOST ?? loadedConfig.config.gateway.host,
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
public async reservePort(host: string): Promise<number> {
|
|
434
|
+
return await new Promise<number>((resolvePort, reject) => {
|
|
435
|
+
const server = createNetServer();
|
|
436
|
+
server.unref();
|
|
437
|
+
server.once('error', reject);
|
|
438
|
+
server.listen(0, host, () => {
|
|
439
|
+
const address = server.address();
|
|
440
|
+
if (address === null) {
|
|
441
|
+
server.close();
|
|
442
|
+
reject(new Error('failed to reserve local port'));
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
const port = (address as AddressInfo).port;
|
|
446
|
+
server.close((error) => {
|
|
447
|
+
if (error !== undefined) {
|
|
448
|
+
reject(error);
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
resolvePort(port);
|
|
452
|
+
});
|
|
453
|
+
});
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
public resolveGatewaySettings(
|
|
458
|
+
record: GatewayRecord | null,
|
|
459
|
+
overrides: GatewayStartOptions,
|
|
460
|
+
): ResolvedGatewaySettings {
|
|
461
|
+
const host = normalizeGatewayHost(
|
|
462
|
+
overrides.host ?? record?.host ?? this.resolveGatewayHostFromConfigOrEnv(),
|
|
463
|
+
);
|
|
464
|
+
const port = normalizeGatewayPort(
|
|
465
|
+
overrides.port ?? record?.port ?? this.env().HARNESS_CONTROL_PLANE_PORT,
|
|
466
|
+
);
|
|
467
|
+
const configuredStateDbPath = overrides.stateDbPath ?? this.runtime.gatewayDefaultStateDbPath;
|
|
468
|
+
const stateDbPathRaw = normalizeGatewayStateDbPath(
|
|
469
|
+
configuredStateDbPath,
|
|
470
|
+
this.runtime.gatewayDefaultStateDbPath,
|
|
471
|
+
);
|
|
472
|
+
const stateDbPath = resolveHarnessRuntimePath(
|
|
473
|
+
this.runtime.invocationDirectory,
|
|
474
|
+
stateDbPathRaw,
|
|
475
|
+
this.env(),
|
|
476
|
+
);
|
|
477
|
+
if (
|
|
478
|
+
!this.infra.isPathWithinWorkspaceRuntimeScope(stateDbPath, this.runtime.invocationDirectory)
|
|
479
|
+
) {
|
|
480
|
+
const runtimeRoot = resolveHarnessWorkspaceDirectory(
|
|
481
|
+
this.runtime.invocationDirectory,
|
|
482
|
+
this.env(),
|
|
483
|
+
);
|
|
484
|
+
throw new Error(
|
|
485
|
+
`invalid --state-db-path: ${stateDbPath}. state db path must be under workspace runtime root ${runtimeRoot}`,
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const envAuthToken = this.env().HARNESS_CONTROL_PLANE_AUTH_TOKEN;
|
|
490
|
+
const envToken =
|
|
491
|
+
typeof envAuthToken === 'string' && envAuthToken.trim().length > 0
|
|
492
|
+
? envAuthToken.trim()
|
|
493
|
+
: null;
|
|
494
|
+
const explicitToken = overrides.authToken ?? record?.authToken ?? envToken;
|
|
495
|
+
const authToken = explicitToken ?? (isLoopbackHost(host) ? `gateway-${randomUUID()}` : null);
|
|
496
|
+
if (!isLoopbackHost(host) && authToken === null) {
|
|
497
|
+
throw new Error(
|
|
498
|
+
'non-loopback hosts require --auth-token or HARNESS_CONTROL_PLANE_AUTH_TOKEN',
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
return {
|
|
503
|
+
host,
|
|
504
|
+
port,
|
|
505
|
+
authToken,
|
|
506
|
+
stateDbPath,
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
private toRecord(value: unknown): Record<string, unknown> | null {
|
|
511
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
512
|
+
return null;
|
|
513
|
+
}
|
|
514
|
+
return value as Record<string, unknown>;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
private readOptionalString(value: unknown): string | null {
|
|
518
|
+
if (typeof value !== 'string') {
|
|
519
|
+
return null;
|
|
520
|
+
}
|
|
521
|
+
const trimmed = value.trim();
|
|
522
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
private readOptionalNumber(value: unknown): number | null {
|
|
526
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
527
|
+
return null;
|
|
528
|
+
}
|
|
529
|
+
return value;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
private readOptionalBoolean(value: unknown, fallback = false): boolean {
|
|
533
|
+
return typeof value === 'boolean' ? value : fallback;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
private parseGatewaySessionSummary(value: unknown): GatewaySessionSummary | null {
|
|
537
|
+
const record = this.toRecord(value);
|
|
538
|
+
if (record === null) {
|
|
539
|
+
return null;
|
|
540
|
+
}
|
|
541
|
+
const sessionId = this.readOptionalString(record['sessionId']);
|
|
542
|
+
if (sessionId === null) {
|
|
543
|
+
return null;
|
|
544
|
+
}
|
|
545
|
+
const statusModel = this.toRecord(record['statusModel']);
|
|
546
|
+
const controller = this.toRecord(record['controller']);
|
|
547
|
+
const controllerLabel =
|
|
548
|
+
controller === null ? null : this.readOptionalString(controller['controllerLabel']);
|
|
549
|
+
const controllerType =
|
|
550
|
+
controller === null ? null : this.readOptionalString(controller['controllerType']);
|
|
551
|
+
const controllerId =
|
|
552
|
+
controller === null ? null : this.readOptionalString(controller['controllerId']);
|
|
553
|
+
return {
|
|
554
|
+
sessionId,
|
|
555
|
+
live: this.readOptionalBoolean(record['live']),
|
|
556
|
+
status: this.readOptionalString(record['status']),
|
|
557
|
+
phase: statusModel === null ? null : this.readOptionalString(statusModel['phase']),
|
|
558
|
+
detail: statusModel === null ? null : this.readOptionalString(statusModel['detailText']),
|
|
559
|
+
processId: this.readOptionalNumber(record['processId']),
|
|
560
|
+
controller: controllerLabel ?? controllerType ?? controllerId,
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
private async listGatewaySessionsForEndpoint(
|
|
565
|
+
host: string,
|
|
566
|
+
port: number,
|
|
567
|
+
authToken: string | null,
|
|
568
|
+
): Promise<GatewaySessionListResult> {
|
|
569
|
+
try {
|
|
570
|
+
const client = await connectControlPlaneStreamClient({
|
|
571
|
+
host,
|
|
572
|
+
port,
|
|
573
|
+
...(authToken === null ? {} : { authToken }),
|
|
574
|
+
});
|
|
575
|
+
try {
|
|
576
|
+
const result = await client.sendCommand({
|
|
577
|
+
type: 'session.list',
|
|
578
|
+
});
|
|
579
|
+
const sessionsRaw = result['sessions'];
|
|
580
|
+
if (!Array.isArray(sessionsRaw)) {
|
|
581
|
+
return {
|
|
582
|
+
connected: true,
|
|
583
|
+
totalSessions: 0,
|
|
584
|
+
liveSessions: 0,
|
|
585
|
+
sessions: [],
|
|
586
|
+
error: null,
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
let liveSessions = 0;
|
|
590
|
+
const sessions: GatewaySessionSummary[] = [];
|
|
591
|
+
for (const sessionRaw of sessionsRaw) {
|
|
592
|
+
if (
|
|
593
|
+
typeof sessionRaw === 'object' &&
|
|
594
|
+
sessionRaw !== null &&
|
|
595
|
+
(sessionRaw as Record<string, unknown>)['live'] === true
|
|
596
|
+
) {
|
|
597
|
+
liveSessions += 1;
|
|
598
|
+
}
|
|
599
|
+
const parsed = this.parseGatewaySessionSummary(sessionRaw);
|
|
600
|
+
if (parsed !== null) {
|
|
601
|
+
sessions.push(parsed);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
return {
|
|
605
|
+
connected: true,
|
|
606
|
+
totalSessions: sessionsRaw.length,
|
|
607
|
+
liveSessions,
|
|
608
|
+
sessions,
|
|
609
|
+
error: null,
|
|
610
|
+
};
|
|
611
|
+
} finally {
|
|
612
|
+
client.close();
|
|
613
|
+
}
|
|
614
|
+
} catch (error: unknown) {
|
|
615
|
+
return {
|
|
616
|
+
connected: false,
|
|
617
|
+
totalSessions: 0,
|
|
618
|
+
liveSessions: 0,
|
|
619
|
+
sessions: [],
|
|
620
|
+
error: error instanceof Error ? error.message : String(error),
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
public async probeGatewayEndpoint(
|
|
626
|
+
host: string,
|
|
627
|
+
port: number,
|
|
628
|
+
authToken: string | null,
|
|
629
|
+
): Promise<GatewayProbeResult> {
|
|
630
|
+
const listed = await this.listGatewaySessionsForEndpoint(host, port, authToken);
|
|
631
|
+
if (!listed.connected) {
|
|
632
|
+
return {
|
|
633
|
+
connected: false,
|
|
634
|
+
sessionCount: 0,
|
|
635
|
+
liveSessionCount: 0,
|
|
636
|
+
error: listed.error,
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
return {
|
|
640
|
+
connected: true,
|
|
641
|
+
sessionCount: listed.totalSessions,
|
|
642
|
+
liveSessionCount: listed.liveSessions,
|
|
643
|
+
error: null,
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
public async probeGateway(record: GatewayRecord): Promise<GatewayProbeResult> {
|
|
648
|
+
return await this.probeGatewayEndpoint(record.host, record.port, record.authToken);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
private async waitForGatewayReady(record: GatewayRecord): Promise<void> {
|
|
652
|
+
const client = await connectControlPlaneStreamClient({
|
|
653
|
+
host: record.host,
|
|
654
|
+
port: record.port,
|
|
655
|
+
...(record.authToken === null ? {} : { authToken: record.authToken }),
|
|
656
|
+
connectRetryWindowMs: DEFAULT_GATEWAY_START_RETRY_WINDOW_MS,
|
|
657
|
+
connectRetryDelayMs: DEFAULT_GATEWAY_START_RETRY_DELAY_MS,
|
|
658
|
+
});
|
|
659
|
+
try {
|
|
660
|
+
await client.sendCommand({
|
|
661
|
+
type: 'session.list',
|
|
662
|
+
limit: 1,
|
|
663
|
+
});
|
|
664
|
+
} finally {
|
|
665
|
+
client.close();
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
public async startDetachedGateway(
|
|
670
|
+
settings: ResolvedGatewaySettings,
|
|
671
|
+
runtimeArgs: readonly string[] = this.runtime.runtimeOptions.gatewayRuntimeArgs,
|
|
672
|
+
): Promise<GatewayRecord> {
|
|
673
|
+
await this.runtime.authRuntime.refreshLinearOauthTokenBeforeGatewayStart();
|
|
674
|
+
mkdirSync(dirname(this.runtime.gatewayLogPath), { recursive: true });
|
|
675
|
+
const logFd = openSync(this.runtime.gatewayLogPath, 'a');
|
|
676
|
+
const gatewayRunId = randomUUID();
|
|
677
|
+
const daemonArgs = tsRuntimeArgs(
|
|
678
|
+
this.runtime.daemonScriptPath,
|
|
679
|
+
[
|
|
680
|
+
'--host',
|
|
681
|
+
settings.host,
|
|
682
|
+
'--port',
|
|
683
|
+
String(settings.port),
|
|
684
|
+
'--state-db-path',
|
|
685
|
+
settings.stateDbPath,
|
|
686
|
+
],
|
|
687
|
+
runtimeArgs,
|
|
688
|
+
);
|
|
689
|
+
if (settings.authToken !== null) {
|
|
690
|
+
daemonArgs.push('--auth-token', settings.authToken);
|
|
691
|
+
}
|
|
692
|
+
const child = spawn(process.execPath, daemonArgs, {
|
|
693
|
+
detached: true,
|
|
694
|
+
stdio: ['ignore', logFd, logFd],
|
|
695
|
+
env: {
|
|
696
|
+
...this.env(),
|
|
697
|
+
HARNESS_INVOKE_CWD: this.runtime.invocationDirectory,
|
|
698
|
+
HARNESS_GATEWAY_RUN_ID: gatewayRunId,
|
|
699
|
+
},
|
|
700
|
+
});
|
|
701
|
+
closeSync(logFd);
|
|
702
|
+
|
|
703
|
+
if (child.pid === undefined) {
|
|
704
|
+
throw new Error('failed to start gateway daemon (missing pid)');
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
const record: GatewayRecord = {
|
|
708
|
+
version: GATEWAY_RECORD_VERSION,
|
|
709
|
+
pid: child.pid,
|
|
710
|
+
host: settings.host,
|
|
711
|
+
port: settings.port,
|
|
712
|
+
authToken: settings.authToken,
|
|
713
|
+
stateDbPath: settings.stateDbPath,
|
|
714
|
+
startedAt: new Date().toISOString(),
|
|
715
|
+
workspaceRoot: this.runtime.invocationDirectory,
|
|
716
|
+
gatewayRunId,
|
|
717
|
+
};
|
|
718
|
+
|
|
719
|
+
try {
|
|
720
|
+
await this.waitForGatewayReady(record);
|
|
721
|
+
if (!this.infra.isPidRunning(child.pid)) {
|
|
722
|
+
throw new Error(
|
|
723
|
+
`gateway daemon exited during startup (pid=${String(child.pid)}); possible duplicate start or port collision`,
|
|
724
|
+
);
|
|
725
|
+
}
|
|
726
|
+
} catch (error: unknown) {
|
|
727
|
+
try {
|
|
728
|
+
process.kill(child.pid, 'SIGTERM');
|
|
729
|
+
} catch {
|
|
730
|
+
// Best-effort cleanup only.
|
|
731
|
+
}
|
|
732
|
+
throw error;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
this.infra.writeGatewayRecord(this.runtime.gatewayRecordPath, record);
|
|
736
|
+
child.unref();
|
|
737
|
+
return record;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
private authTokenMatches(
|
|
741
|
+
candidate: ParsedGatewayDaemonEntry,
|
|
742
|
+
expectedAuthToken: string | null,
|
|
743
|
+
): boolean {
|
|
744
|
+
if (expectedAuthToken === null) {
|
|
745
|
+
return candidate.authToken === null;
|
|
746
|
+
}
|
|
747
|
+
return candidate.authToken === expectedAuthToken;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
private findReachableGatewayDaemonCandidates(
|
|
751
|
+
settings: ResolvedGatewaySettings,
|
|
752
|
+
): readonly ParsedGatewayDaemonEntry[] {
|
|
753
|
+
return this.infra.listGatewayDaemonProcesses().filter((candidate) => {
|
|
754
|
+
if (candidate.host !== settings.host || candidate.port !== settings.port) {
|
|
755
|
+
return false;
|
|
756
|
+
}
|
|
757
|
+
if (!this.authTokenMatches(candidate, settings.authToken)) {
|
|
758
|
+
return false;
|
|
759
|
+
}
|
|
760
|
+
return this.infra.isPathWithinWorkspaceRuntimeScope(
|
|
761
|
+
candidate.stateDbPath,
|
|
762
|
+
this.runtime.invocationDirectory,
|
|
763
|
+
);
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
private findGatewayDaemonCandidatesByStateDbPath(
|
|
768
|
+
stateDbPath: string,
|
|
769
|
+
): readonly ParsedGatewayDaemonEntry[] {
|
|
770
|
+
const normalizedStateDbPath = resolve(stateDbPath);
|
|
771
|
+
return this.infra.listGatewayDaemonProcesses().filter((candidate) => {
|
|
772
|
+
if (candidate.stateDbPath !== normalizedStateDbPath) {
|
|
773
|
+
return false;
|
|
774
|
+
}
|
|
775
|
+
return this.infra.isPathWithinWorkspaceRuntimeScope(
|
|
776
|
+
candidate.stateDbPath,
|
|
777
|
+
this.runtime.invocationDirectory,
|
|
778
|
+
);
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
private createAdoptedGatewayRecord(daemon: ParsedGatewayDaemonEntry): GatewayRecord {
|
|
783
|
+
return {
|
|
784
|
+
version: GATEWAY_RECORD_VERSION,
|
|
785
|
+
pid: daemon.pid,
|
|
786
|
+
host: daemon.host,
|
|
787
|
+
port: daemon.port,
|
|
788
|
+
authToken: daemon.authToken,
|
|
789
|
+
stateDbPath: daemon.stateDbPath,
|
|
790
|
+
startedAt: new Date().toISOString(),
|
|
791
|
+
workspaceRoot: this.runtime.invocationDirectory,
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
private shouldAutoResolveNamedSessionPort(overrides: GatewayStartOptions): boolean {
|
|
796
|
+
if (this.runtime.sessionName === null) {
|
|
797
|
+
return false;
|
|
798
|
+
}
|
|
799
|
+
return overrides.port === undefined;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
private async resolveAdoptableGatewayByStateDbPath(
|
|
803
|
+
stateDbPath: string,
|
|
804
|
+
): Promise<ParsedGatewayDaemonEntry | null> {
|
|
805
|
+
const candidates = this.findGatewayDaemonCandidatesByStateDbPath(stateDbPath);
|
|
806
|
+
const reachable: ParsedGatewayDaemonEntry[] = [];
|
|
807
|
+
for (const candidate of candidates) {
|
|
808
|
+
const probe = await this.probeGatewayEndpoint(
|
|
809
|
+
candidate.host,
|
|
810
|
+
candidate.port,
|
|
811
|
+
candidate.authToken,
|
|
812
|
+
);
|
|
813
|
+
if (probe.connected) {
|
|
814
|
+
reachable.push(candidate);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
if (reachable.length === 0) {
|
|
818
|
+
return null;
|
|
819
|
+
}
|
|
820
|
+
if (reachable.length > 1) {
|
|
821
|
+
const pidList = reachable.map((candidate) => String(candidate.pid)).join(', ');
|
|
822
|
+
throw new Error(
|
|
823
|
+
`gateway db path is served by multiple reachable daemon candidates (${pidList}); stop with \`harness gateway stop --force\` and retry`,
|
|
824
|
+
);
|
|
825
|
+
}
|
|
826
|
+
return reachable[0]!;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
private async canBindPort(host: string, port: number): Promise<boolean> {
|
|
830
|
+
return await new Promise<boolean>((resolveCanBind, rejectCanBind) => {
|
|
831
|
+
const server = createNetServer();
|
|
832
|
+
server.unref();
|
|
833
|
+
server.once('error', (error: unknown) => {
|
|
834
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
835
|
+
if (code === 'EADDRINUSE') {
|
|
836
|
+
resolveCanBind(false);
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
rejectCanBind(error);
|
|
840
|
+
});
|
|
841
|
+
server.listen(port, host, () => {
|
|
842
|
+
server.close((error) => {
|
|
843
|
+
if (error !== undefined) {
|
|
844
|
+
rejectCanBind(error);
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
resolveCanBind(true);
|
|
848
|
+
});
|
|
849
|
+
});
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
public async ensureGatewayRunning(
|
|
854
|
+
overrides: GatewayStartOptions = {},
|
|
855
|
+
daemonRuntimeArgs: readonly string[] = this.runtime.runtimeOptions.gatewayRuntimeArgs,
|
|
856
|
+
): Promise<EnsureGatewayResult> {
|
|
857
|
+
const existingRecord = this.readGatewayRecord();
|
|
858
|
+
if (existingRecord !== null) {
|
|
859
|
+
const probe = await this.probeGateway(existingRecord);
|
|
860
|
+
if (probe.connected) {
|
|
861
|
+
return { record: existingRecord, started: false };
|
|
862
|
+
}
|
|
863
|
+
if (this.infra.isPidRunning(existingRecord.pid)) {
|
|
864
|
+
throw new Error(
|
|
865
|
+
`gateway record is present but unreachable (pid=${String(existingRecord.pid)} still running): ${probe.error ?? 'unknown error'}`,
|
|
866
|
+
);
|
|
867
|
+
}
|
|
868
|
+
this.removeGatewayRecord();
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
const settings = this.resolveGatewaySettings(existingRecord, overrides);
|
|
872
|
+
if (existingRecord === null) {
|
|
873
|
+
const adoptedByDbPath = await this.resolveAdoptableGatewayByStateDbPath(settings.stateDbPath);
|
|
874
|
+
if (adoptedByDbPath !== null) {
|
|
875
|
+
const adoptedRecord = this.createAdoptedGatewayRecord(adoptedByDbPath);
|
|
876
|
+
this.infra.writeGatewayRecord(this.runtime.gatewayRecordPath, adoptedRecord);
|
|
877
|
+
return { record: adoptedRecord, started: false };
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
let resolvedSettings = settings;
|
|
882
|
+
if (existingRecord === null) {
|
|
883
|
+
const endpointProbe = await this.probeGatewayEndpoint(
|
|
884
|
+
resolvedSettings.host,
|
|
885
|
+
resolvedSettings.port,
|
|
886
|
+
resolvedSettings.authToken,
|
|
887
|
+
);
|
|
888
|
+
if (endpointProbe.connected) {
|
|
889
|
+
const candidates = this.findReachableGatewayDaemonCandidates(resolvedSettings);
|
|
890
|
+
if (candidates.length === 1) {
|
|
891
|
+
const adopted = this.createAdoptedGatewayRecord(candidates[0]!);
|
|
892
|
+
this.infra.writeGatewayRecord(this.runtime.gatewayRecordPath, adopted);
|
|
893
|
+
return { record: adopted, started: false };
|
|
894
|
+
}
|
|
895
|
+
if (candidates.length > 1) {
|
|
896
|
+
const pidList = candidates.map((candidate) => String(candidate.pid)).join(', ');
|
|
897
|
+
throw new Error(
|
|
898
|
+
`gateway endpoint reachable with multiple daemon candidates (${pidList}); stop with \`harness gateway stop --force\` and retry`,
|
|
899
|
+
);
|
|
900
|
+
}
|
|
901
|
+
throw new Error(
|
|
902
|
+
'gateway endpoint is reachable but no matching daemon could be adopted; stop with `harness gateway stop --force` and retry',
|
|
903
|
+
);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
if (this.shouldAutoResolveNamedSessionPort(overrides)) {
|
|
907
|
+
const currentPortAvailable = await this.canBindPort(
|
|
908
|
+
resolvedSettings.host,
|
|
909
|
+
resolvedSettings.port,
|
|
910
|
+
);
|
|
911
|
+
if (!currentPortAvailable) {
|
|
912
|
+
const fallbackPort = await this.reservePort(resolvedSettings.host);
|
|
913
|
+
resolvedSettings = {
|
|
914
|
+
...resolvedSettings,
|
|
915
|
+
port: fallbackPort,
|
|
916
|
+
};
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
return {
|
|
922
|
+
record: await this.startDetachedGateway(resolvedSettings, daemonRuntimeArgs),
|
|
923
|
+
started: true,
|
|
924
|
+
};
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
private cleanupNamedSessionGatewayArtifacts(): void {
|
|
928
|
+
const recordPath = resolve(this.runtime.gatewayRecordPath);
|
|
929
|
+
if (!/[\\/]sessions[\\/][^\\/]+[\\/]gateway\.json$/u.test(recordPath)) {
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
try {
|
|
933
|
+
rmSync(this.runtime.gatewayLogPath, { force: true });
|
|
934
|
+
} catch {
|
|
935
|
+
// best-effort cleanup only
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
public async stopGateway(options: GatewayStopOptions): Promise<GatewayStopResult> {
|
|
940
|
+
const appendCleanupSummary = async (
|
|
941
|
+
baseMessage: string,
|
|
942
|
+
stateDbPath: string,
|
|
943
|
+
): Promise<string> => {
|
|
944
|
+
if (!options.cleanupOrphans) {
|
|
945
|
+
return baseMessage;
|
|
946
|
+
}
|
|
947
|
+
const [gatewayCleanupResult, ptyCleanupResult, relayCleanupResult, sqliteCleanupResult] =
|
|
948
|
+
await Promise.all([
|
|
949
|
+
this.infra.cleanupOrphanGatewayDaemons(
|
|
950
|
+
stateDbPath,
|
|
951
|
+
this.runtime.daemonScriptPath,
|
|
952
|
+
options,
|
|
953
|
+
),
|
|
954
|
+
this.infra.cleanupOrphanPtyHelpersForWorkspace(this.runtime.invocationDirectory, options),
|
|
955
|
+
this.infra.cleanupOrphanRelayLinkedAgentsForWorkspace(
|
|
956
|
+
this.runtime.invocationDirectory,
|
|
957
|
+
options,
|
|
958
|
+
),
|
|
959
|
+
this.infra.cleanupOrphanSqliteProcessesForDbPath(stateDbPath, options),
|
|
960
|
+
]);
|
|
961
|
+
return [
|
|
962
|
+
baseMessage,
|
|
963
|
+
this.infra.formatOrphanProcessCleanupResult('orphan gateway daemon', gatewayCleanupResult),
|
|
964
|
+
this.infra.formatOrphanProcessCleanupResult('orphan pty helper', ptyCleanupResult),
|
|
965
|
+
this.infra.formatOrphanProcessCleanupResult(
|
|
966
|
+
'orphan relay-linked agent',
|
|
967
|
+
relayCleanupResult,
|
|
968
|
+
),
|
|
969
|
+
this.infra.formatOrphanProcessCleanupResult('orphan sqlite', sqliteCleanupResult),
|
|
970
|
+
].join('; ');
|
|
971
|
+
};
|
|
972
|
+
|
|
973
|
+
const record = this.readGatewayRecord();
|
|
974
|
+
if (record === null) {
|
|
975
|
+
this.cleanupNamedSessionGatewayArtifacts();
|
|
976
|
+
return {
|
|
977
|
+
stopped: false,
|
|
978
|
+
message: await appendCleanupSummary(
|
|
979
|
+
'gateway not running (no record)',
|
|
980
|
+
this.runtime.gatewayDefaultStateDbPath,
|
|
981
|
+
),
|
|
982
|
+
};
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
const probe = await this.probeGateway(record);
|
|
986
|
+
const pidRunning = this.infra.isPidRunning(record.pid);
|
|
987
|
+
if (!probe.connected && pidRunning && !options.force) {
|
|
988
|
+
return {
|
|
989
|
+
stopped: false,
|
|
990
|
+
message: `gateway record points to a running but unreachable process (pid=${String(record.pid)}); re-run with --force`,
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
if (!pidRunning) {
|
|
995
|
+
this.removeGatewayRecord();
|
|
996
|
+
this.cleanupNamedSessionGatewayArtifacts();
|
|
997
|
+
return {
|
|
998
|
+
stopped: true,
|
|
999
|
+
message: await appendCleanupSummary('removed stale gateway record', record.stateDbPath),
|
|
1000
|
+
};
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
const signaledTerm = this.infra.signalPidWithOptionalProcessGroup(record.pid, 'SIGTERM', true);
|
|
1004
|
+
if (!signaledTerm) {
|
|
1005
|
+
this.removeGatewayRecord();
|
|
1006
|
+
this.cleanupNamedSessionGatewayArtifacts();
|
|
1007
|
+
return {
|
|
1008
|
+
stopped: true,
|
|
1009
|
+
message: await appendCleanupSummary('gateway already exited', record.stateDbPath),
|
|
1010
|
+
};
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
const exitedAfterTerm = await this.infra.waitForPidExit(record.pid, options.timeoutMs);
|
|
1014
|
+
if (!exitedAfterTerm && options.force) {
|
|
1015
|
+
this.infra.signalPidWithOptionalProcessGroup(record.pid, 'SIGKILL', true);
|
|
1016
|
+
const exitedAfterKill = await this.infra.waitForPidExit(record.pid, options.timeoutMs);
|
|
1017
|
+
if (!exitedAfterKill) {
|
|
1018
|
+
return {
|
|
1019
|
+
stopped: false,
|
|
1020
|
+
message: `gateway did not exit after SIGKILL (pid=${String(record.pid)})`,
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
} else if (!exitedAfterTerm) {
|
|
1024
|
+
return {
|
|
1025
|
+
stopped: false,
|
|
1026
|
+
message: `gateway did not exit after ${String(options.timeoutMs)}ms; retry with --force`,
|
|
1027
|
+
};
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
this.removeGatewayRecord();
|
|
1031
|
+
this.cleanupNamedSessionGatewayArtifacts();
|
|
1032
|
+
return {
|
|
1033
|
+
stopped: true,
|
|
1034
|
+
message: await appendCleanupSummary(
|
|
1035
|
+
`gateway stopped (pid=${String(record.pid)})`,
|
|
1036
|
+
record.stateDbPath,
|
|
1037
|
+
),
|
|
1038
|
+
};
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
private resolveNamedSessionsRoot(): string {
|
|
1042
|
+
const workspaceDirectory = resolveHarnessWorkspaceDirectory(
|
|
1043
|
+
this.runtime.invocationDirectory,
|
|
1044
|
+
this.env(),
|
|
1045
|
+
);
|
|
1046
|
+
return resolve(workspaceDirectory, DEFAULT_SESSION_ROOT_PATH);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
private listNamedSessionNames(): readonly string[] {
|
|
1050
|
+
const sessionsRoot = this.resolveNamedSessionsRoot();
|
|
1051
|
+
if (!existsSync(sessionsRoot)) {
|
|
1052
|
+
return [];
|
|
1053
|
+
}
|
|
1054
|
+
return readdirSync(sessionsRoot, { withFileTypes: true })
|
|
1055
|
+
.filter((entry) => entry.isDirectory())
|
|
1056
|
+
.map((entry) => entry.name)
|
|
1057
|
+
.sort((left, right) => left.localeCompare(right));
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
private readGatewayRecordForSessionRoot(sessionRoot: string): GatewayRecord | null {
|
|
1061
|
+
const recordPath = resolve(sessionRoot, 'gateway.json');
|
|
1062
|
+
if (!existsSync(recordPath)) {
|
|
1063
|
+
return null;
|
|
1064
|
+
}
|
|
1065
|
+
try {
|
|
1066
|
+
const parsed = parseGatewayRecordText(readFileSync(recordPath, 'utf8'));
|
|
1067
|
+
return parsed;
|
|
1068
|
+
} catch {
|
|
1069
|
+
return null;
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
private resolveNewestSessionArtifactMtimeMs(sessionRoot: string): number {
|
|
1074
|
+
let newestMtimeMs = 0;
|
|
1075
|
+
const stack: string[] = [sessionRoot];
|
|
1076
|
+
while (stack.length > 0) {
|
|
1077
|
+
const currentPath = stack.pop()!;
|
|
1078
|
+
if (basename(currentPath) === 'gateway.lock') {
|
|
1079
|
+
continue;
|
|
1080
|
+
}
|
|
1081
|
+
let currentStats: ReturnType<typeof statSync>;
|
|
1082
|
+
try {
|
|
1083
|
+
currentStats = statSync(currentPath);
|
|
1084
|
+
} catch {
|
|
1085
|
+
continue;
|
|
1086
|
+
}
|
|
1087
|
+
if (!currentStats.isDirectory()) {
|
|
1088
|
+
if (currentStats.mtimeMs > newestMtimeMs) {
|
|
1089
|
+
newestMtimeMs = currentStats.mtimeMs;
|
|
1090
|
+
}
|
|
1091
|
+
continue;
|
|
1092
|
+
}
|
|
1093
|
+
let childNames: readonly string[] = [];
|
|
1094
|
+
try {
|
|
1095
|
+
childNames = readdirSync(currentPath, { withFileTypes: true, encoding: 'utf8' }).map(
|
|
1096
|
+
(child) => child.name,
|
|
1097
|
+
);
|
|
1098
|
+
} catch {
|
|
1099
|
+
continue;
|
|
1100
|
+
}
|
|
1101
|
+
for (const childName of childNames) {
|
|
1102
|
+
stack.push(resolve(currentPath, childName));
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
return newestMtimeMs;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
private async isSessionGatewayLive(sessionRoot: string): Promise<boolean> {
|
|
1109
|
+
const expectedStateDbPath = resolve(sessionRoot, 'control-plane.sqlite');
|
|
1110
|
+
const daemonCandidates = this.infra
|
|
1111
|
+
.listGatewayDaemonProcesses()
|
|
1112
|
+
.filter((candidate) => candidate.stateDbPath === expectedStateDbPath);
|
|
1113
|
+
if (daemonCandidates.length > 0) {
|
|
1114
|
+
return true;
|
|
1115
|
+
}
|
|
1116
|
+
const record = this.readGatewayRecordForSessionRoot(sessionRoot);
|
|
1117
|
+
if (record === null) {
|
|
1118
|
+
return false;
|
|
1119
|
+
}
|
|
1120
|
+
const probe = await this.probeGateway(record);
|
|
1121
|
+
if (probe.connected) {
|
|
1122
|
+
return true;
|
|
1123
|
+
}
|
|
1124
|
+
return this.infra.isPidRunning(record.pid);
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
private async isDefaultGatewayLive(): Promise<boolean> {
|
|
1128
|
+
const defaultStateDbPath = this.runtime.gatewayDefaultStateDbPath;
|
|
1129
|
+
const daemonCandidates = this.infra
|
|
1130
|
+
.listGatewayDaemonProcesses()
|
|
1131
|
+
.filter((candidate) => candidate.stateDbPath === defaultStateDbPath);
|
|
1132
|
+
if (daemonCandidates.length > 0) {
|
|
1133
|
+
return true;
|
|
1134
|
+
}
|
|
1135
|
+
const runtimeRoot = resolveHarnessWorkspaceDirectory(
|
|
1136
|
+
this.runtime.invocationDirectory,
|
|
1137
|
+
this.env(),
|
|
1138
|
+
);
|
|
1139
|
+
const defaultRecordPath = resolve(runtimeRoot, 'gateway.json');
|
|
1140
|
+
const record = this.infra.readGatewayRecord(defaultRecordPath);
|
|
1141
|
+
if (record === null) {
|
|
1142
|
+
return false;
|
|
1143
|
+
}
|
|
1144
|
+
const probe = await this.probeGateway(record);
|
|
1145
|
+
if (probe.connected) {
|
|
1146
|
+
return true;
|
|
1147
|
+
}
|
|
1148
|
+
return this.infra.isPidRunning(record.pid);
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
private resolveStorageLifecyclePolicyForGc(): HarnessStorageLifecycleConfig {
|
|
1152
|
+
const loadedConfig = loadHarnessConfig({
|
|
1153
|
+
cwd: this.runtime.invocationDirectory,
|
|
1154
|
+
env: this.env(),
|
|
1155
|
+
});
|
|
1156
|
+
return loadedConfig.config.storage.lifecycle;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
private runStorageLifecycleMaintenanceForStateDbPath(
|
|
1160
|
+
stateDbPath: string,
|
|
1161
|
+
policy: HarnessStorageLifecycleConfig,
|
|
1162
|
+
): { status: 'applied' | 'missing' | 'error'; error?: string } {
|
|
1163
|
+
if (!existsSync(stateDbPath)) {
|
|
1164
|
+
return { status: 'missing' };
|
|
1165
|
+
}
|
|
1166
|
+
const cutoffIngestedAt = new Date(Date.now() - policy.telemetryRetentionMs).toISOString();
|
|
1167
|
+
let stateStore: SqliteControlPlaneStore | null = null;
|
|
1168
|
+
try {
|
|
1169
|
+
stateStore = new SqliteControlPlaneStore(stateDbPath, {
|
|
1170
|
+
busyTimeoutMs: policy.busyTimeoutMs,
|
|
1171
|
+
});
|
|
1172
|
+
let prunePasses = 0;
|
|
1173
|
+
while (prunePasses < GATEWAY_GC_MAX_PRUNE_PASSES) {
|
|
1174
|
+
const deleted = stateStore.pruneTelemetryOlderThan(cutoffIngestedAt, policy.pruneBatchSize);
|
|
1175
|
+
prunePasses += 1;
|
|
1176
|
+
if (deleted === 0) {
|
|
1177
|
+
break;
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
if (prunePasses >= GATEWAY_GC_MAX_PRUNE_PASSES) {
|
|
1181
|
+
return {
|
|
1182
|
+
status: 'error',
|
|
1183
|
+
error: `telemetry prune exceeded ${String(GATEWAY_GC_MAX_PRUNE_PASSES)} passes`,
|
|
1184
|
+
};
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
let compactionSteps = 0;
|
|
1188
|
+
while (compactionSteps < GATEWAY_GC_MAX_COMPACTION_STEPS) {
|
|
1189
|
+
const step = stateStore.runOnlineCopyForwardCompactionStep(
|
|
1190
|
+
policy.copyForwardBatchSize,
|
|
1191
|
+
policy.copyForwardFinalizeTailRows,
|
|
1192
|
+
);
|
|
1193
|
+
compactionSteps += 1;
|
|
1194
|
+
if (step.state === 'idle' || step.state === 'finalized') {
|
|
1195
|
+
break;
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
if (compactionSteps >= GATEWAY_GC_MAX_COMPACTION_STEPS) {
|
|
1199
|
+
return {
|
|
1200
|
+
status: 'error',
|
|
1201
|
+
error: `telemetry compaction exceeded ${String(GATEWAY_GC_MAX_COMPACTION_STEPS)} steps`,
|
|
1202
|
+
};
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
stateStore.checkpointWal('TRUNCATE');
|
|
1206
|
+
stateStore.compactFreelistPages(Number.MAX_SAFE_INTEGER);
|
|
1207
|
+
stateStore.checkpointWal('TRUNCATE');
|
|
1208
|
+
return { status: 'applied' };
|
|
1209
|
+
} catch (error: unknown) {
|
|
1210
|
+
return {
|
|
1211
|
+
status: 'error',
|
|
1212
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1213
|
+
};
|
|
1214
|
+
} finally {
|
|
1215
|
+
stateStore?.close();
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
public async runGatewaySessionGc(options: GatewayGcOptions): Promise<GatewayGcResult> {
|
|
1220
|
+
const maxAgeMs = options.olderThanDays * 24 * 60 * 60 * 1000;
|
|
1221
|
+
const nowMs = Date.now();
|
|
1222
|
+
const storageLifecyclePolicy = this.resolveStorageLifecyclePolicyForGc();
|
|
1223
|
+
const deletedSessions: string[] = [];
|
|
1224
|
+
const errors: string[] = [];
|
|
1225
|
+
const storageMaintenanceErrors: string[] = [];
|
|
1226
|
+
let scanned = 0;
|
|
1227
|
+
let deleted = 0;
|
|
1228
|
+
let skippedRecent = 0;
|
|
1229
|
+
let skippedLive = 0;
|
|
1230
|
+
let skippedCurrent = 0;
|
|
1231
|
+
let storageMaintenanceApplied = 0;
|
|
1232
|
+
let storageMaintenanceSkippedLive = 0;
|
|
1233
|
+
|
|
1234
|
+
for (const candidateSessionName of this.listNamedSessionNames()) {
|
|
1235
|
+
if (this.runtime.sessionName !== null && candidateSessionName === this.runtime.sessionName) {
|
|
1236
|
+
skippedCurrent += 1;
|
|
1237
|
+
continue;
|
|
1238
|
+
}
|
|
1239
|
+
scanned += 1;
|
|
1240
|
+
const sessionRoot = resolve(this.resolveNamedSessionsRoot(), candidateSessionName);
|
|
1241
|
+
const sessionLockPath = resolve(sessionRoot, 'gateway.lock');
|
|
1242
|
+
let handle: Awaited<ReturnType<GatewayControlInfra['acquireGatewayControlLock']>> | null =
|
|
1243
|
+
null;
|
|
1244
|
+
try {
|
|
1245
|
+
handle = await this.infra.acquireGatewayControlLock(
|
|
1246
|
+
sessionLockPath,
|
|
1247
|
+
this.runtime.invocationDirectory,
|
|
1248
|
+
);
|
|
1249
|
+
if (!existsSync(sessionRoot)) {
|
|
1250
|
+
continue;
|
|
1251
|
+
}
|
|
1252
|
+
if (await this.isSessionGatewayLive(sessionRoot)) {
|
|
1253
|
+
skippedLive += 1;
|
|
1254
|
+
storageMaintenanceSkippedLive += 1;
|
|
1255
|
+
continue;
|
|
1256
|
+
}
|
|
1257
|
+
const newestMtimeMs = this.resolveNewestSessionArtifactMtimeMs(sessionRoot);
|
|
1258
|
+
if (newestMtimeMs > 0 && nowMs - newestMtimeMs < maxAgeMs) {
|
|
1259
|
+
skippedRecent += 1;
|
|
1260
|
+
const stateDbPath = resolve(sessionRoot, 'control-plane.sqlite');
|
|
1261
|
+
const maintenance = this.runStorageLifecycleMaintenanceForStateDbPath(
|
|
1262
|
+
stateDbPath,
|
|
1263
|
+
storageLifecyclePolicy,
|
|
1264
|
+
);
|
|
1265
|
+
if (maintenance.status === 'applied') {
|
|
1266
|
+
storageMaintenanceApplied += 1;
|
|
1267
|
+
} else if (maintenance.status === 'error') {
|
|
1268
|
+
storageMaintenanceErrors.push(
|
|
1269
|
+
`${candidateSessionName}: ${maintenance.error ?? 'unknown maintenance error'}`,
|
|
1270
|
+
);
|
|
1271
|
+
} else if (maintenance.status === 'missing') {
|
|
1272
|
+
// No state db present for this offline session.
|
|
1273
|
+
}
|
|
1274
|
+
continue;
|
|
1275
|
+
}
|
|
1276
|
+
rmSync(sessionRoot, { recursive: true, force: true });
|
|
1277
|
+
deleted += 1;
|
|
1278
|
+
deletedSessions.push(candidateSessionName);
|
|
1279
|
+
} catch (error: unknown) {
|
|
1280
|
+
errors.push(
|
|
1281
|
+
`${candidateSessionName}: ${error instanceof Error ? error.message : String(error)}`,
|
|
1282
|
+
);
|
|
1283
|
+
} finally {
|
|
1284
|
+
handle?.release();
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
if (this.runtime.sessionName === null) {
|
|
1289
|
+
if (await this.isDefaultGatewayLive()) {
|
|
1290
|
+
storageMaintenanceSkippedLive += 1;
|
|
1291
|
+
} else {
|
|
1292
|
+
const maintenance = this.runStorageLifecycleMaintenanceForStateDbPath(
|
|
1293
|
+
this.runtime.gatewayDefaultStateDbPath,
|
|
1294
|
+
storageLifecyclePolicy,
|
|
1295
|
+
);
|
|
1296
|
+
if (maintenance.status === 'applied') {
|
|
1297
|
+
storageMaintenanceApplied += 1;
|
|
1298
|
+
} else if (maintenance.status === 'error') {
|
|
1299
|
+
storageMaintenanceErrors.push(
|
|
1300
|
+
`default: ${maintenance.error ?? 'unknown maintenance error'}`,
|
|
1301
|
+
);
|
|
1302
|
+
} else if (maintenance.status === 'missing') {
|
|
1303
|
+
// No default state db present.
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
return {
|
|
1309
|
+
scanned,
|
|
1310
|
+
deleted,
|
|
1311
|
+
skippedRecent,
|
|
1312
|
+
skippedLive,
|
|
1313
|
+
skippedCurrent,
|
|
1314
|
+
storageMaintenanceApplied,
|
|
1315
|
+
storageMaintenanceSkippedLive,
|
|
1316
|
+
storageMaintenanceErrors,
|
|
1317
|
+
deletedSessions,
|
|
1318
|
+
errors,
|
|
1319
|
+
};
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
private resolveGatewayListScope(
|
|
1323
|
+
workspaceDirectory: string,
|
|
1324
|
+
stateDbPath: string,
|
|
1325
|
+
): { scope: GatewayListScope; sessionName: string | null } {
|
|
1326
|
+
const workspaceRoot = resolve(workspaceDirectory);
|
|
1327
|
+
const normalizedStateDbPath = resolve(stateDbPath);
|
|
1328
|
+
const dbRelativePath = relative(workspaceRoot, normalizedStateDbPath);
|
|
1329
|
+
if (dbRelativePath === 'control-plane.sqlite') {
|
|
1330
|
+
return {
|
|
1331
|
+
scope: 'default',
|
|
1332
|
+
sessionName: null,
|
|
1333
|
+
};
|
|
1334
|
+
}
|
|
1335
|
+
if (dbRelativePath.length === 0 || dbRelativePath.startsWith('..')) {
|
|
1336
|
+
return {
|
|
1337
|
+
scope: 'unscoped',
|
|
1338
|
+
sessionName: null,
|
|
1339
|
+
};
|
|
1340
|
+
}
|
|
1341
|
+
const segments = dbRelativePath.split(/[\\/]/u);
|
|
1342
|
+
if (
|
|
1343
|
+
segments.length === 3 &&
|
|
1344
|
+
segments[0] === 'sessions' &&
|
|
1345
|
+
(segments[1] ?? '').length > 0 &&
|
|
1346
|
+
segments[2] === 'control-plane.sqlite'
|
|
1347
|
+
) {
|
|
1348
|
+
return {
|
|
1349
|
+
scope: 'named',
|
|
1350
|
+
sessionName: segments[1]!,
|
|
1351
|
+
};
|
|
1352
|
+
}
|
|
1353
|
+
return {
|
|
1354
|
+
scope: 'unscoped',
|
|
1355
|
+
sessionName: null,
|
|
1356
|
+
};
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
private formatGatewayScopeLabel(scope: GatewayListScope, sessionName: string | null): string {
|
|
1360
|
+
if (scope === 'default') {
|
|
1361
|
+
return 'default';
|
|
1362
|
+
}
|
|
1363
|
+
if (scope === 'named' && sessionName !== null) {
|
|
1364
|
+
return `session:${sessionName}`;
|
|
1365
|
+
}
|
|
1366
|
+
return 'unscoped';
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
private stopCommandForGatewayTarget(target: GatewayListTarget): string {
|
|
1370
|
+
if (target.scope === 'default') {
|
|
1371
|
+
return 'harness gateway stop --force';
|
|
1372
|
+
}
|
|
1373
|
+
if (target.scope === 'named' && target.sessionName !== null) {
|
|
1374
|
+
return `harness --session ${target.sessionName} gateway stop --force`;
|
|
1375
|
+
}
|
|
1376
|
+
return `kill -TERM -${String(target.pid)} && kill -TERM ${String(target.pid)}`;
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
private discoverGatewayListTargets(): readonly GatewayListTarget[] {
|
|
1380
|
+
const workspaceDirectory = resolveHarnessWorkspaceDirectory(
|
|
1381
|
+
this.runtime.invocationDirectory,
|
|
1382
|
+
this.env(),
|
|
1383
|
+
);
|
|
1384
|
+
const targets: GatewayListTarget[] = [];
|
|
1385
|
+
const matchedDaemonPids = new Set<number>();
|
|
1386
|
+
const daemonCandidates = this.infra
|
|
1387
|
+
.listGatewayDaemonProcesses()
|
|
1388
|
+
.filter((candidate) =>
|
|
1389
|
+
this.infra.isPathWithinWorkspaceRuntimeScope(
|
|
1390
|
+
candidate.stateDbPath,
|
|
1391
|
+
this.runtime.invocationDirectory,
|
|
1392
|
+
),
|
|
1393
|
+
);
|
|
1394
|
+
|
|
1395
|
+
const addRecordTarget = (
|
|
1396
|
+
scope: GatewayListScope,
|
|
1397
|
+
sessionName: string | null,
|
|
1398
|
+
recordPath: string,
|
|
1399
|
+
logPath: string,
|
|
1400
|
+
lockPath: string,
|
|
1401
|
+
): void => {
|
|
1402
|
+
const record = this.infra.readGatewayRecord(recordPath);
|
|
1403
|
+
if (record === null) {
|
|
1404
|
+
return;
|
|
1405
|
+
}
|
|
1406
|
+
targets.push({
|
|
1407
|
+
scope,
|
|
1408
|
+
sessionName,
|
|
1409
|
+
source: 'record',
|
|
1410
|
+
pid: record.pid,
|
|
1411
|
+
host: record.host,
|
|
1412
|
+
port: record.port,
|
|
1413
|
+
authToken: record.authToken,
|
|
1414
|
+
stateDbPath: record.stateDbPath,
|
|
1415
|
+
startedAt: record.startedAt,
|
|
1416
|
+
gatewayRunId:
|
|
1417
|
+
typeof record.gatewayRunId === 'string' && record.gatewayRunId.length > 0
|
|
1418
|
+
? record.gatewayRunId
|
|
1419
|
+
: null,
|
|
1420
|
+
recordPath,
|
|
1421
|
+
logPath,
|
|
1422
|
+
lockPath,
|
|
1423
|
+
});
|
|
1424
|
+
matchedDaemonPids.add(record.pid);
|
|
1425
|
+
};
|
|
1426
|
+
|
|
1427
|
+
addRecordTarget(
|
|
1428
|
+
'default',
|
|
1429
|
+
null,
|
|
1430
|
+
resolve(workspaceDirectory, 'gateway.json'),
|
|
1431
|
+
resolve(workspaceDirectory, 'gateway.log'),
|
|
1432
|
+
resolve(workspaceDirectory, 'gateway.lock'),
|
|
1433
|
+
);
|
|
1434
|
+
for (const sessionName of this.listNamedSessionNames()) {
|
|
1435
|
+
const sessionRoot = resolve(workspaceDirectory, DEFAULT_SESSION_ROOT_PATH, sessionName);
|
|
1436
|
+
addRecordTarget(
|
|
1437
|
+
'named',
|
|
1438
|
+
sessionName,
|
|
1439
|
+
resolve(sessionRoot, 'gateway.json'),
|
|
1440
|
+
resolve(sessionRoot, 'gateway.log'),
|
|
1441
|
+
resolve(sessionRoot, 'gateway.lock'),
|
|
1442
|
+
);
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
for (const daemon of daemonCandidates) {
|
|
1446
|
+
if (matchedDaemonPids.has(daemon.pid)) {
|
|
1447
|
+
continue;
|
|
1448
|
+
}
|
|
1449
|
+
const scope = this.resolveGatewayListScope(workspaceDirectory, daemon.stateDbPath);
|
|
1450
|
+
const sessionRoot =
|
|
1451
|
+
scope.scope === 'named' && scope.sessionName !== null
|
|
1452
|
+
? resolve(workspaceDirectory, DEFAULT_SESSION_ROOT_PATH, scope.sessionName)
|
|
1453
|
+
: null;
|
|
1454
|
+
targets.push({
|
|
1455
|
+
scope: scope.scope,
|
|
1456
|
+
sessionName: scope.sessionName,
|
|
1457
|
+
source: 'daemon',
|
|
1458
|
+
pid: daemon.pid,
|
|
1459
|
+
host: daemon.host,
|
|
1460
|
+
port: daemon.port,
|
|
1461
|
+
authToken: daemon.authToken,
|
|
1462
|
+
stateDbPath: daemon.stateDbPath,
|
|
1463
|
+
startedAt: null,
|
|
1464
|
+
gatewayRunId: null,
|
|
1465
|
+
recordPath:
|
|
1466
|
+
scope.scope === 'default'
|
|
1467
|
+
? resolve(workspaceDirectory, 'gateway.json')
|
|
1468
|
+
: sessionRoot === null
|
|
1469
|
+
? null
|
|
1470
|
+
: resolve(sessionRoot, 'gateway.json'),
|
|
1471
|
+
logPath:
|
|
1472
|
+
scope.scope === 'default'
|
|
1473
|
+
? resolve(workspaceDirectory, 'gateway.log')
|
|
1474
|
+
: sessionRoot === null
|
|
1475
|
+
? null
|
|
1476
|
+
: resolve(sessionRoot, 'gateway.log'),
|
|
1477
|
+
lockPath:
|
|
1478
|
+
scope.scope === 'default'
|
|
1479
|
+
? resolve(workspaceDirectory, 'gateway.lock')
|
|
1480
|
+
: sessionRoot === null
|
|
1481
|
+
? null
|
|
1482
|
+
: resolve(sessionRoot, 'gateway.lock'),
|
|
1483
|
+
});
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
const scopeOrder: Record<GatewayListScope, number> = {
|
|
1487
|
+
default: 0,
|
|
1488
|
+
named: 1,
|
|
1489
|
+
unscoped: 2,
|
|
1490
|
+
};
|
|
1491
|
+
targets.sort((left, right) => {
|
|
1492
|
+
const byScope = scopeOrder[left.scope] - scopeOrder[right.scope];
|
|
1493
|
+
if (byScope !== 0) {
|
|
1494
|
+
return byScope;
|
|
1495
|
+
}
|
|
1496
|
+
const bySessionName = (left.sessionName ?? '').localeCompare(right.sessionName ?? '');
|
|
1497
|
+
if (bySessionName !== 0) {
|
|
1498
|
+
return bySessionName;
|
|
1499
|
+
}
|
|
1500
|
+
if (left.source !== right.source) {
|
|
1501
|
+
return left.source === 'record' ? -1 : 1;
|
|
1502
|
+
}
|
|
1503
|
+
return left.pid - right.pid;
|
|
1504
|
+
});
|
|
1505
|
+
return targets;
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
private formatGatewaySessionSummary(session: GatewaySessionSummary): string {
|
|
1509
|
+
const parts: string[] = [`id=${session.sessionId}`, `live=${session.live ? 'yes' : 'no'}`];
|
|
1510
|
+
if (session.status !== null) {
|
|
1511
|
+
parts.push(`status=${session.status}`);
|
|
1512
|
+
}
|
|
1513
|
+
if (session.phase !== null) {
|
|
1514
|
+
parts.push(`phase=${session.phase}`);
|
|
1515
|
+
}
|
|
1516
|
+
if (session.processId !== null) {
|
|
1517
|
+
parts.push(`pid=${String(session.processId)}`);
|
|
1518
|
+
}
|
|
1519
|
+
if (session.controller !== null) {
|
|
1520
|
+
parts.push(`controller=${session.controller}`);
|
|
1521
|
+
}
|
|
1522
|
+
if (session.detail !== null) {
|
|
1523
|
+
parts.push(`detail=${JSON.stringify(session.detail)}`);
|
|
1524
|
+
}
|
|
1525
|
+
return parts.join(' ');
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
public async runGatewayList(): Promise<number> {
|
|
1529
|
+
const targets = this.discoverGatewayListTargets();
|
|
1530
|
+
if (targets.length === 0) {
|
|
1531
|
+
this.writeStdout('gateway list: none\n');
|
|
1532
|
+
this.writeStdout('tip: start a gateway with `harness gateway start`\n');
|
|
1533
|
+
return 0;
|
|
1534
|
+
}
|
|
1535
|
+
this.writeStdout(`gateway list: ${String(targets.length)} target(s)\n`);
|
|
1536
|
+
let unhealthy = 0;
|
|
1537
|
+
for (const [index, target] of targets.entries()) {
|
|
1538
|
+
const pidRunning = this.infra.isPidRunning(target.pid);
|
|
1539
|
+
const sessionState = await this.listGatewaySessionsForEndpoint(
|
|
1540
|
+
target.host,
|
|
1541
|
+
target.port,
|
|
1542
|
+
target.authToken,
|
|
1543
|
+
);
|
|
1544
|
+
const statusLabel = sessionState.connected
|
|
1545
|
+
? 'running'
|
|
1546
|
+
: pidRunning
|
|
1547
|
+
? 'unreachable'
|
|
1548
|
+
: 'not-running';
|
|
1549
|
+
if (!sessionState.connected) {
|
|
1550
|
+
unhealthy += 1;
|
|
1551
|
+
}
|
|
1552
|
+
this.writeStdout(
|
|
1553
|
+
`gateway[${String(index + 1)}] scope=${this.formatGatewayScopeLabel(target.scope, target.sessionName)} source=${target.source} status=${statusLabel}\n`,
|
|
1554
|
+
);
|
|
1555
|
+
this.writeStdout(` endpoint: ${target.host}:${String(target.port)}\n`);
|
|
1556
|
+
this.writeStdout(
|
|
1557
|
+
` pid: ${String(target.pid)} (${pidRunning ? 'running' : 'not-running'})\n`,
|
|
1558
|
+
);
|
|
1559
|
+
this.writeStdout(` auth: ${target.authToken === null ? 'off' : 'on'}\n`);
|
|
1560
|
+
this.writeStdout(` db: ${target.stateDbPath}\n`);
|
|
1561
|
+
if (target.recordPath !== null) {
|
|
1562
|
+
this.writeStdout(` record: ${target.recordPath}\n`);
|
|
1563
|
+
}
|
|
1564
|
+
if (target.logPath !== null) {
|
|
1565
|
+
this.writeStdout(` log: ${target.logPath}\n`);
|
|
1566
|
+
}
|
|
1567
|
+
if (target.lockPath !== null) {
|
|
1568
|
+
this.writeStdout(` lock: ${target.lockPath}\n`);
|
|
1569
|
+
}
|
|
1570
|
+
if (target.startedAt !== null) {
|
|
1571
|
+
this.writeStdout(` startedAt: ${target.startedAt}\n`);
|
|
1572
|
+
}
|
|
1573
|
+
if (target.gatewayRunId !== null) {
|
|
1574
|
+
this.writeStdout(` runId: ${target.gatewayRunId}\n`);
|
|
1575
|
+
}
|
|
1576
|
+
this.writeStdout(` stop: ${this.stopCommandForGatewayTarget(target)}\n`);
|
|
1577
|
+
this.writeStdout(
|
|
1578
|
+
` sessions: total=${String(sessionState.totalSessions)} live=${String(sessionState.liveSessions)} described=${String(sessionState.sessions.length)}\n`,
|
|
1579
|
+
);
|
|
1580
|
+
for (const session of sessionState.sessions) {
|
|
1581
|
+
this.writeStdout(` session: ${this.formatGatewaySessionSummary(session)}\n`);
|
|
1582
|
+
}
|
|
1583
|
+
if (!sessionState.connected) {
|
|
1584
|
+
this.writeStdout(` lastError: ${sessionState.error ?? 'unknown'}\n`);
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
return unhealthy === 0 ? 0 : 1;
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
public async runMuxClient(
|
|
1591
|
+
gateway: GatewayRecord,
|
|
1592
|
+
passthroughArgs: readonly string[],
|
|
1593
|
+
runtimeArgs: readonly string[] = this.runtime.runtimeOptions.clientRuntimeArgs,
|
|
1594
|
+
): Promise<number> {
|
|
1595
|
+
const args = tsRuntimeArgs(
|
|
1596
|
+
this.runtime.muxScriptPath,
|
|
1597
|
+
[
|
|
1598
|
+
'--harness-server-host',
|
|
1599
|
+
gateway.host,
|
|
1600
|
+
'--harness-server-port',
|
|
1601
|
+
String(gateway.port),
|
|
1602
|
+
...(gateway.authToken === null ? [] : ['--harness-server-token', gateway.authToken]),
|
|
1603
|
+
...passthroughArgs,
|
|
1604
|
+
],
|
|
1605
|
+
runtimeArgs,
|
|
1606
|
+
);
|
|
1607
|
+
|
|
1608
|
+
const child = spawn(process.execPath, args, {
|
|
1609
|
+
stdio: 'inherit',
|
|
1610
|
+
env: {
|
|
1611
|
+
...this.env(),
|
|
1612
|
+
HARNESS_INVOKE_CWD: this.runtime.invocationDirectory,
|
|
1613
|
+
...(this.runtime.sessionName === null
|
|
1614
|
+
? {}
|
|
1615
|
+
: { HARNESS_SESSION_NAME: this.runtime.sessionName }),
|
|
1616
|
+
},
|
|
1617
|
+
});
|
|
1618
|
+
const exit = await once(child, 'exit');
|
|
1619
|
+
const code = (exit[0] as number | null) ?? null;
|
|
1620
|
+
const signal = (exit[1] as NodeJS.Signals | null) ?? null;
|
|
1621
|
+
if (code !== null) {
|
|
1622
|
+
return code;
|
|
1623
|
+
}
|
|
1624
|
+
return normalizeSignalExitCode(signal);
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
public async runGatewayForeground(
|
|
1628
|
+
settings: ResolvedGatewaySettings,
|
|
1629
|
+
runtimeArgs: readonly string[] = this.runtime.runtimeOptions.gatewayRuntimeArgs,
|
|
1630
|
+
): Promise<number> {
|
|
1631
|
+
await this.runtime.authRuntime.refreshLinearOauthTokenBeforeGatewayStart();
|
|
1632
|
+
const gatewayRunId = randomUUID();
|
|
1633
|
+
const existingRecord = this.readGatewayRecord();
|
|
1634
|
+
if (existingRecord !== null) {
|
|
1635
|
+
const probe = await this.probeGateway(existingRecord);
|
|
1636
|
+
if (probe.connected || this.infra.isPidRunning(existingRecord.pid)) {
|
|
1637
|
+
throw new Error('gateway is already running; stop it first or use `harness gateway start`');
|
|
1638
|
+
}
|
|
1639
|
+
this.removeGatewayRecord();
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
const daemonArgs = tsRuntimeArgs(
|
|
1643
|
+
this.runtime.daemonScriptPath,
|
|
1644
|
+
[
|
|
1645
|
+
'--host',
|
|
1646
|
+
settings.host,
|
|
1647
|
+
'--port',
|
|
1648
|
+
String(settings.port),
|
|
1649
|
+
'--state-db-path',
|
|
1650
|
+
settings.stateDbPath,
|
|
1651
|
+
],
|
|
1652
|
+
runtimeArgs,
|
|
1653
|
+
);
|
|
1654
|
+
if (settings.authToken !== null) {
|
|
1655
|
+
daemonArgs.push('--auth-token', settings.authToken);
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
const child = spawn(process.execPath, daemonArgs, {
|
|
1659
|
+
stdio: 'inherit',
|
|
1660
|
+
env: {
|
|
1661
|
+
...this.env(),
|
|
1662
|
+
HARNESS_INVOKE_CWD: this.runtime.invocationDirectory,
|
|
1663
|
+
HARNESS_GATEWAY_RUN_ID: gatewayRunId,
|
|
1664
|
+
},
|
|
1665
|
+
});
|
|
1666
|
+
if (child.pid !== undefined) {
|
|
1667
|
+
this.infra.writeGatewayRecord(this.runtime.gatewayRecordPath, {
|
|
1668
|
+
version: GATEWAY_RECORD_VERSION,
|
|
1669
|
+
pid: child.pid,
|
|
1670
|
+
host: settings.host,
|
|
1671
|
+
port: settings.port,
|
|
1672
|
+
authToken: settings.authToken,
|
|
1673
|
+
stateDbPath: settings.stateDbPath,
|
|
1674
|
+
startedAt: new Date().toISOString(),
|
|
1675
|
+
workspaceRoot: this.runtime.invocationDirectory,
|
|
1676
|
+
gatewayRunId,
|
|
1677
|
+
});
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
const exit = await once(child, 'exit');
|
|
1681
|
+
const code = (exit[0] as number | null) ?? null;
|
|
1682
|
+
const signal = (exit[1] as NodeJS.Signals | null) ?? null;
|
|
1683
|
+
const record = this.readGatewayRecord();
|
|
1684
|
+
if (record !== null && child.pid !== undefined && record.pid === child.pid) {
|
|
1685
|
+
this.removeGatewayRecord();
|
|
1686
|
+
}
|
|
1687
|
+
if (code !== null) {
|
|
1688
|
+
return code;
|
|
1689
|
+
}
|
|
1690
|
+
return normalizeSignalExitCode(signal);
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
private parseCallCommand(raw: string): StreamCommand {
|
|
1694
|
+
let parsed: unknown;
|
|
1695
|
+
try {
|
|
1696
|
+
parsed = JSON.parse(raw);
|
|
1697
|
+
} catch (error: unknown) {
|
|
1698
|
+
throw new Error(
|
|
1699
|
+
`invalid JSON command: ${error instanceof Error ? error.message : String(error)}`,
|
|
1700
|
+
);
|
|
1701
|
+
}
|
|
1702
|
+
const command = parseStreamCommand(parsed);
|
|
1703
|
+
if (command === null) {
|
|
1704
|
+
throw new Error('invalid stream command payload');
|
|
1705
|
+
}
|
|
1706
|
+
return command;
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
private async executeGatewayCall(record: GatewayRecord, rawCommand: string): Promise<number> {
|
|
1710
|
+
const command = this.parseCallCommand(rawCommand);
|
|
1711
|
+
const client = await connectControlPlaneStreamClient({
|
|
1712
|
+
host: record.host,
|
|
1713
|
+
port: record.port,
|
|
1714
|
+
...(record.authToken === null ? {} : { authToken: record.authToken }),
|
|
1715
|
+
});
|
|
1716
|
+
try {
|
|
1717
|
+
const result = await client.sendCommand(command);
|
|
1718
|
+
this.writeStdout(`${JSON.stringify(result, null, 2)}\n`);
|
|
1719
|
+
} finally {
|
|
1720
|
+
client.close();
|
|
1721
|
+
}
|
|
1722
|
+
return 0;
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
public async run(command: ParsedGatewayCommand): Promise<number> {
|
|
1726
|
+
if (command.type === 'status') {
|
|
1727
|
+
return await this.withLock(async () => {
|
|
1728
|
+
const record = this.readGatewayRecord();
|
|
1729
|
+
if (record === null) {
|
|
1730
|
+
this.writeStdout('gateway status: stopped\n');
|
|
1731
|
+
return 0;
|
|
1732
|
+
}
|
|
1733
|
+
const pidRunning = this.infra.isPidRunning(record.pid);
|
|
1734
|
+
const probe = await this.probeGateway(record);
|
|
1735
|
+
this.writeStdout(`gateway status: ${probe.connected ? 'running' : 'unreachable'}\n`);
|
|
1736
|
+
this.writeStdout(`record: ${this.runtime.gatewayRecordPath}\n`);
|
|
1737
|
+
this.writeStdout(`lock: ${this.runtime.gatewayLockPath}\n`);
|
|
1738
|
+
this.writeStdout(
|
|
1739
|
+
`pid: ${String(record.pid)} (${pidRunning ? 'running' : 'not-running'})\n`,
|
|
1740
|
+
);
|
|
1741
|
+
this.writeStdout(`host: ${record.host}\n`);
|
|
1742
|
+
this.writeStdout(`port: ${String(record.port)}\n`);
|
|
1743
|
+
this.writeStdout(`auth: ${record.authToken === null ? 'off' : 'on'}\n`);
|
|
1744
|
+
this.writeStdout(`db: ${record.stateDbPath}\n`);
|
|
1745
|
+
this.writeStdout(`startedAt: ${record.startedAt}\n`);
|
|
1746
|
+
if (typeof record.gatewayRunId === 'string' && record.gatewayRunId.length > 0) {
|
|
1747
|
+
this.writeStdout(`runId: ${record.gatewayRunId}\n`);
|
|
1748
|
+
}
|
|
1749
|
+
this.writeStdout(
|
|
1750
|
+
`sessions: total=${String(probe.sessionCount)} live=${String(probe.liveSessionCount)}\n`,
|
|
1751
|
+
);
|
|
1752
|
+
if (!probe.connected) {
|
|
1753
|
+
this.writeStdout(`lastError: ${probe.error ?? 'unknown'}\n`);
|
|
1754
|
+
return 1;
|
|
1755
|
+
}
|
|
1756
|
+
return 0;
|
|
1757
|
+
});
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
if (command.type === 'list') {
|
|
1761
|
+
return await this.withLock(async () => await this.runGatewayList());
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
if (command.type === 'stop') {
|
|
1765
|
+
const stopOptions = command.stopOptions ?? {
|
|
1766
|
+
force: false,
|
|
1767
|
+
timeoutMs: DEFAULT_GATEWAY_STOP_TIMEOUT_MS,
|
|
1768
|
+
cleanupOrphans: true,
|
|
1769
|
+
};
|
|
1770
|
+
const stopped = await this.withLock(async () => await this.stopGateway(stopOptions));
|
|
1771
|
+
this.writeStdout(`${stopped.message}\n`);
|
|
1772
|
+
return stopped.stopped ? 0 : 1;
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
if (command.type === 'start') {
|
|
1776
|
+
const ensured = await this.withLock(
|
|
1777
|
+
async () => await this.ensureGatewayRunning(command.startOptions ?? {}),
|
|
1778
|
+
);
|
|
1779
|
+
if (ensured.started) {
|
|
1780
|
+
this.writeStdout(
|
|
1781
|
+
`gateway started pid=${String(ensured.record.pid)} host=${ensured.record.host} port=${String(ensured.record.port)}\n`,
|
|
1782
|
+
);
|
|
1783
|
+
} else {
|
|
1784
|
+
this.writeStdout(
|
|
1785
|
+
`gateway already running pid=${String(ensured.record.pid)} host=${ensured.record.host} port=${String(ensured.record.port)}\n`,
|
|
1786
|
+
);
|
|
1787
|
+
}
|
|
1788
|
+
this.writeStdout(`record: ${this.runtime.gatewayRecordPath}\n`);
|
|
1789
|
+
this.writeStdout(`log: ${this.runtime.gatewayLogPath}\n`);
|
|
1790
|
+
this.writeStdout(`lock: ${this.runtime.gatewayLockPath}\n`);
|
|
1791
|
+
return 0;
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
if (command.type === 'gc') {
|
|
1795
|
+
const gcOptions = command.gcOptions ?? {
|
|
1796
|
+
olderThanDays: DEFAULT_GATEWAY_GC_OLDER_THAN_DAYS,
|
|
1797
|
+
};
|
|
1798
|
+
const gcResult = await this.withLock(async () => await this.runGatewaySessionGc(gcOptions));
|
|
1799
|
+
const storageMaintenanceApplied = gcResult.storageMaintenanceApplied ?? 0;
|
|
1800
|
+
const storageMaintenanceSkippedLive = gcResult.storageMaintenanceSkippedLive ?? 0;
|
|
1801
|
+
const storageMaintenanceErrors = gcResult.storageMaintenanceErrors ?? [];
|
|
1802
|
+
this.writeStdout(
|
|
1803
|
+
[
|
|
1804
|
+
'gateway gc:',
|
|
1805
|
+
`olderThanDays=${String(gcOptions.olderThanDays)}`,
|
|
1806
|
+
`scanned=${String(gcResult.scanned)}`,
|
|
1807
|
+
`deleted=${String(gcResult.deleted)}`,
|
|
1808
|
+
`skippedRecent=${String(gcResult.skippedRecent)}`,
|
|
1809
|
+
`skippedLive=${String(gcResult.skippedLive)}`,
|
|
1810
|
+
`skippedCurrent=${String(gcResult.skippedCurrent)}`,
|
|
1811
|
+
`storageMaintenanceApplied=${String(storageMaintenanceApplied)}`,
|
|
1812
|
+
`storageMaintenanceSkippedLive=${String(storageMaintenanceSkippedLive)}`,
|
|
1813
|
+
].join(' ') + '\n',
|
|
1814
|
+
);
|
|
1815
|
+
if (gcResult.deletedSessions.length > 0) {
|
|
1816
|
+
this.writeStdout(`deleted sessions: ${gcResult.deletedSessions.join(', ')}\n`);
|
|
1817
|
+
}
|
|
1818
|
+
for (const error of gcResult.errors) {
|
|
1819
|
+
this.writeStderr(`gateway gc error: ${error}\n`);
|
|
1820
|
+
}
|
|
1821
|
+
for (const error of storageMaintenanceErrors) {
|
|
1822
|
+
this.writeStderr(`gateway gc storage maintenance error: ${error}\n`);
|
|
1823
|
+
}
|
|
1824
|
+
return gcResult.errors.length === 0 && storageMaintenanceErrors.length === 0 ? 0 : 1;
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
if (command.type === 'restart') {
|
|
1828
|
+
const stopResult = await this.withLock(
|
|
1829
|
+
async () =>
|
|
1830
|
+
await this.stopGateway({
|
|
1831
|
+
force: true,
|
|
1832
|
+
timeoutMs: DEFAULT_GATEWAY_STOP_TIMEOUT_MS,
|
|
1833
|
+
cleanupOrphans: true,
|
|
1834
|
+
}),
|
|
1835
|
+
);
|
|
1836
|
+
this.writeStdout(`${stopResult.message}\n`);
|
|
1837
|
+
const ensured = await this.withLock(
|
|
1838
|
+
async () => await this.ensureGatewayRunning(command.startOptions ?? {}),
|
|
1839
|
+
);
|
|
1840
|
+
this.writeStdout(
|
|
1841
|
+
`gateway restarted pid=${String(ensured.record.pid)} host=${ensured.record.host} port=${String(ensured.record.port)}\n`,
|
|
1842
|
+
);
|
|
1843
|
+
this.writeStdout(`record: ${this.runtime.gatewayRecordPath}\n`);
|
|
1844
|
+
this.writeStdout(`log: ${this.runtime.gatewayLogPath}\n`);
|
|
1845
|
+
this.writeStdout(`lock: ${this.runtime.gatewayLockPath}\n`);
|
|
1846
|
+
return 0;
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
if (command.type === 'run') {
|
|
1850
|
+
return await this.withLock(async () => {
|
|
1851
|
+
const settings = this.resolveGatewaySettings(
|
|
1852
|
+
this.readGatewayRecord(),
|
|
1853
|
+
command.startOptions ?? {},
|
|
1854
|
+
);
|
|
1855
|
+
this.writeStdout(
|
|
1856
|
+
`gateway foreground run host=${settings.host} port=${String(settings.port)} db=${settings.stateDbPath}\n`,
|
|
1857
|
+
);
|
|
1858
|
+
this.writeStdout(`lock: ${this.runtime.gatewayLockPath}\n`);
|
|
1859
|
+
return await this.runGatewayForeground(settings);
|
|
1860
|
+
});
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
const record = await this.withLock(async () => this.readGatewayRecord());
|
|
1864
|
+
if (record === null) {
|
|
1865
|
+
throw new Error('gateway not running; start it first');
|
|
1866
|
+
}
|
|
1867
|
+
if (command.callJson === undefined) {
|
|
1868
|
+
throw new Error('missing gateway call json');
|
|
1869
|
+
}
|
|
1870
|
+
return await this.executeGatewayCall(record, command.callJson);
|
|
1871
|
+
}
|
|
1872
|
+
}
|