@logicseek/hello-cc 0.1.9 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +59 -0
- package/README.md +18 -3
- package/README.zh-CN.md +17 -3
- package/bin/hcc.mjs +3 -5656
- package/docs/README.md +8 -0
- package/docs/README.zh-CN.md +6 -0
- package/docs/architecture.md +29 -18
- package/docs/architecture.zh-CN.md +20 -10
- package/docs/commands.md +13 -4
- package/docs/commands.zh-CN.md +13 -4
- package/docs/current-state-review-2026-08.md +341 -0
- package/docs/defect-review-2026-07.md +382 -0
- package/docs/guide.md +39 -3
- package/docs/guide.zh-CN.md +34 -2
- package/docs/superpowers/plans/2026-08-03-runtime-safety-plan.md +294 -0
- package/docs/superpowers/plans/2026-08-03-v1-data-identity-plan.md +400 -0
- package/docs/superpowers/plans/2026-08-03-v1-release-verification-plan.md +284 -0
- package/docs/superpowers/plans/2026-08-03-web-api-hardening-plan.md +303 -0
- package/docs/superpowers/plans/2026-08-04-runtime-buffer-gc-two-phase.md +71 -0
- package/docs/superpowers/specs/2026-08-03-runtime-safety-design.md +117 -0
- package/docs/superpowers/specs/2026-08-03-v1-data-identity-design.md +120 -0
- package/docs/superpowers/specs/2026-08-03-v1-release-verification-design.md +117 -0
- package/docs/superpowers/specs/2026-08-03-web-api-hardening-design.md +104 -0
- package/lib/cli/app.mjs +642 -0
- package/lib/cli/commands/coordination.mjs +247 -0
- package/lib/cli/commands/doctor.mjs +81 -0
- package/lib/cli/commands/gc.mjs +852 -0
- package/lib/cli/commands/hook.mjs +205 -0
- package/lib/cli/commands/install.mjs +294 -0
- package/lib/cli/commands/lifecycle.mjs +101 -0
- package/lib/cli/commands/lock.mjs +197 -0
- package/lib/cli/commands/misc.mjs +61 -0
- package/lib/cli/commands/msg.mjs +152 -0
- package/lib/cli/commands/peer.mjs +233 -0
- package/lib/cli/commands/query.mjs +96 -0
- package/lib/cli/commands/run.mjs +463 -0
- package/lib/cli/commands/task.mjs +405 -0
- package/lib/cli/commands/team.mjs +131 -0
- package/lib/cli/commands/tmux.mjs +838 -0
- package/lib/cli/commands/up.mjs +135 -0
- package/lib/cli-args.mjs +16 -2
- package/lib/coordination-state.mjs +112 -51
- package/lib/core/coordination/automation.mjs +5 -8
- package/lib/core/coordination/clock-safety.mjs +323 -0
- package/lib/core/coordination/event-retention.mjs +17 -0
- package/lib/core/coordination/gc-plan.mjs +614 -0
- package/lib/core/coordination/lease-renewal.mjs +102 -0
- package/lib/core/coordination/lock-evidence.mjs +77 -0
- package/lib/core/coordination/optimistic-evidence.mjs +26 -0
- package/lib/core/coordination/tasks.mjs +156 -36
- package/lib/core/peers/evidence-runtime.mjs +312 -0
- package/lib/core/peers/evidence.mjs +103 -0
- package/lib/core/peers/hook-owner.mjs +32 -0
- package/lib/core/peers/liveness.mjs +44 -4
- package/lib/core/peers/peer-helpers.mjs +121 -0
- package/lib/core/peers/session.mjs +8 -2
- package/lib/core/peers/tmux-safety.mjs +290 -0
- package/lib/core/sessions/launch.mjs +13 -1
- package/lib/db/connection.mjs +104 -0
- package/lib/db/events.mjs +34 -0
- package/lib/db/migration-backup.mjs +225 -0
- package/lib/db/schema.mjs +102 -5
- package/lib/db/stores/peers.mjs +34 -0
- package/lib/integrations/peers/identity.mjs +62 -16
- package/lib/integrations/shims/script.mjs +111 -9
- package/lib/process/identity.mjs +192 -0
- package/lib/process/pty-lifecycle.mjs +149 -0
- package/lib/runtime/buffer-directory-lease.mjs +47 -0
- package/lib/runtime/buffer-evidence.mjs +336 -0
- package/lib/runtime/buffer-gc-protocol.mjs +167 -0
- package/lib/runtime/buffer-gc.mjs +270 -0
- package/lib/runtime/client.mjs +60 -7
- package/lib/runtime/fatal-shutdown.mjs +54 -0
- package/lib/runtime/project-path.mjs +139 -0
- package/lib/runtime/projects.mjs +320 -40
- package/lib/runtime/state.mjs +227 -27
- package/lib/shared/cleanup-error.mjs +54 -0
- package/lib/shared/clock-grace.mjs +106 -0
- package/lib/shared/errors.mjs +9 -0
- package/lib/shared/file-lock.mjs +370 -0
- package/lib/shared/json-file.mjs +6 -2
- package/lib/shared/redact.mjs +60 -0
- package/lib/shared/socket-lock-worker.mjs +201 -0
- package/lib/terminal/tmux.mjs +78 -4
- package/lib/tmux.mjs +4 -0
- package/lib/ui/help.mjs +53 -15
- package/lib/web/api-version.mjs +42 -0
- package/lib/web/auto-attach.mjs +185 -0
- package/lib/web/buffer-gc-runtime.mjs +175 -0
- package/lib/web/cookie-auth.mjs +119 -0
- package/lib/web/external-sessions.mjs +254 -0
- package/lib/web/http-routes.mjs +542 -0
- package/lib/web/http.mjs +135 -6
- package/lib/web/liveness-reaper.mjs +145 -0
- package/lib/web/peer-actions.mjs +132 -23
- package/lib/web/project-contexts.mjs +178 -0
- package/lib/web/pty-sessions.mjs +203 -0
- package/lib/web/runtime-main.mjs +714 -0
- package/lib/web/runtime.mjs +91 -36
- package/lib/web/session-serialize.mjs +0 -0
- package/lib/web/startup.mjs +333 -0
- package/lib/web/tls.mjs +365 -0
- package/lib/web/tmux-sessions.mjs +1361 -0
- package/lib/web/tmux-stream.mjs +166 -0
- package/lib/web/ui-template.mjs +152 -20
- package/package.json +5 -2
- package/scripts/check-free-identifiers.mjs +290 -0
- package/scripts/regression.mjs +5397 -263
|
@@ -0,0 +1,714 @@
|
|
|
1
|
+
// The web runtime main entry, extracted whole from bin/hcc.mjs.
|
|
2
|
+
// cmdWeb is one ~3100-line closure: 64 sibling functions share the sessions/
|
|
3
|
+
// projectContexts/buffer state maps. It moves as a unit; every module-scope
|
|
4
|
+
// dependency is injected via createWebRuntime(deps).
|
|
5
|
+
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import http from 'node:http';
|
|
8
|
+
import https from 'node:https';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import process from 'node:process';
|
|
11
|
+
import { performance } from 'node:perf_hooks';
|
|
12
|
+
import { randomBytes } from 'node:crypto';
|
|
13
|
+
import { spawn } from 'node:child_process';
|
|
14
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
15
|
+
import { URL } from 'node:url';
|
|
16
|
+
import { publicCliFailure } from '../shared/errors.mjs';
|
|
17
|
+
import { CliError } from '../shared/errors.mjs';
|
|
18
|
+
import { tx } from '../db/schema.mjs';
|
|
19
|
+
import { intOpt, parseOpts, required, validateOpts } from '../cli-args.mjs';
|
|
20
|
+
import { packageRoot } from '../cli-runtime.mjs';
|
|
21
|
+
import { createCoordinationState } from '../coordination-state.mjs';
|
|
22
|
+
import { shellQuoteArg } from '../format.mjs';
|
|
23
|
+
import {
|
|
24
|
+
CLOCK_GRACE_SEC,
|
|
25
|
+
classifyClockDrift,
|
|
26
|
+
clockGraceSuppressed,
|
|
27
|
+
readClockGraceUntil
|
|
28
|
+
} from '../shared/clock-grace.mjs';
|
|
29
|
+
import {
|
|
30
|
+
clockSafetyUnavailable,
|
|
31
|
+
observeClockSafetyInTransaction
|
|
32
|
+
} from '../core/coordination/clock-safety.mjs';
|
|
33
|
+
import { peerEvidenceAllowsReap } from '../core/peers/evidence.mjs';
|
|
34
|
+
import {
|
|
35
|
+
conditionalTmuxKill,
|
|
36
|
+
conditionalTmuxRename,
|
|
37
|
+
prepareTmuxRestartBinding,
|
|
38
|
+
rollbackTmuxRestartBinding,
|
|
39
|
+
validateTmuxDestructiveEvidence
|
|
40
|
+
} from '../core/peers/tmux-safety.mjs';
|
|
41
|
+
import {
|
|
42
|
+
LAUNCH_FINGERPRINT_ENV,
|
|
43
|
+
PROVIDER_STATE_ENV,
|
|
44
|
+
WEB_CHILD_ENV,
|
|
45
|
+
childSessionEnv,
|
|
46
|
+
isRelaunchableProviderSession,
|
|
47
|
+
isolatedEnvCommandArgs,
|
|
48
|
+
launchFingerprint,
|
|
49
|
+
providerRestartReason
|
|
50
|
+
} from '../core/sessions/launch.mjs';
|
|
51
|
+
import { providerSessionPeerId } from '../core/peers/session.mjs';
|
|
52
|
+
import { reconcileRunningPeerBindings } from '../core/peers/reconcile.mjs';
|
|
53
|
+
import {
|
|
54
|
+
buildPeerCommand,
|
|
55
|
+
defaultSessionCommand,
|
|
56
|
+
hasResumeOpts,
|
|
57
|
+
inferPeerKind
|
|
58
|
+
} from '../integrations/providers.mjs';
|
|
59
|
+
import { inspectProviderProcess } from '../integrations/peers/processes.mjs';
|
|
60
|
+
import {
|
|
61
|
+
projectRecord,
|
|
62
|
+
readProjectRegistry,
|
|
63
|
+
registerProject,
|
|
64
|
+
registerProjectActivity
|
|
65
|
+
} from '../runtime/projects.mjs';
|
|
66
|
+
import { resolveProjectDatabase } from '../runtime/project-path.mjs';
|
|
67
|
+
import { contextForProject } from '../runtime/paths.mjs';
|
|
68
|
+
import {
|
|
69
|
+
clearRuntime,
|
|
70
|
+
writeGlobalRuntime,
|
|
71
|
+
writeRuntime
|
|
72
|
+
} from '../runtime/state.mjs';
|
|
73
|
+
import { createFatalShutdownController } from '../runtime/fatal-shutdown.mjs';
|
|
74
|
+
import { collectBufferEvidence, externalBufferEvidence, externalBufferOwnerKey, externalBufferSessionIds, readExternalBufferMetadata } from '../runtime/buffer-evidence.mjs';
|
|
75
|
+
import { withBufferDirectoryLease } from '../runtime/buffer-directory-lease.mjs';
|
|
76
|
+
import { applyClockSafeBufferPlan, createBufferGcPlanStore } from '../runtime/buffer-gc-protocol.mjs';
|
|
77
|
+
import { bufferPlanGcCutoffs, planBufferFiles } from '../runtime/buffer-gc.mjs';
|
|
78
|
+
import { detectBranch } from '../project-context.mjs';
|
|
79
|
+
import { normalizeStateResources } from '../ui/state-render.mjs';
|
|
80
|
+
import { positiveIntOpt } from '../task-cli.mjs';
|
|
81
|
+
import {
|
|
82
|
+
expectedWebHost,
|
|
83
|
+
isLoopbackHost,
|
|
84
|
+
listenServer,
|
|
85
|
+
makeWebToken,
|
|
86
|
+
nextSessionId,
|
|
87
|
+
publicRuntimeUrl,
|
|
88
|
+
requestUrl,
|
|
89
|
+
runtimeBaseUrl,
|
|
90
|
+
validateWebTokenOpts
|
|
91
|
+
} from '../web/runtime.mjs';
|
|
92
|
+
import {
|
|
93
|
+
authOk,
|
|
94
|
+
readJsonRequest,
|
|
95
|
+
requestIsSecure,
|
|
96
|
+
requestMatchesProxyOrigin,
|
|
97
|
+
requestOriginMatches,
|
|
98
|
+
sendFile,
|
|
99
|
+
sendJson,
|
|
100
|
+
tokenMatches
|
|
101
|
+
} from '../web/http.mjs';
|
|
102
|
+
import {
|
|
103
|
+
API_VERSION,
|
|
104
|
+
apiVersionUnsupportedBody,
|
|
105
|
+
readHttpApiVersion,
|
|
106
|
+
readWebSocketApiVersion
|
|
107
|
+
} from '../web/api-version.mjs';
|
|
108
|
+
import { ensureSelfSignedCert } from '../web/tls.mjs';
|
|
109
|
+
import * as webUiTemplate from '../web/ui-template.mjs';
|
|
110
|
+
import { createWebPeerActions } from '../web/peer-actions.mjs';
|
|
111
|
+
import { createCookieAuth } from '../web/cookie-auth.mjs';
|
|
112
|
+
import { createProjectContexts } from '../web/project-contexts.mjs';
|
|
113
|
+
import { createExternalSessions } from '../web/external-sessions.mjs';
|
|
114
|
+
import { createLivenessReaper } from '../web/liveness-reaper.mjs';
|
|
115
|
+
import { createBufferGcRuntime } from '../web/buffer-gc-runtime.mjs';
|
|
116
|
+
import { createTmuxSessions } from '../web/tmux-sessions.mjs';
|
|
117
|
+
import { createAutoAttach } from '../web/auto-attach.mjs';
|
|
118
|
+
import { createPtySessions } from '../web/pty-sessions.mjs';
|
|
119
|
+
import { createHttpRoutes } from '../web/http-routes.mjs';
|
|
120
|
+
import { createSessionSerialize } from '../web/session-serialize.mjs';
|
|
121
|
+
import { createTmuxStream } from '../web/tmux-stream.mjs';
|
|
122
|
+
import {
|
|
123
|
+
ensureTmuxAvailable,
|
|
124
|
+
runTmux,
|
|
125
|
+
tmuxCapturePane,
|
|
126
|
+
tmuxEnvironmentArgs,
|
|
127
|
+
tmuxHasSession,
|
|
128
|
+
tmuxKillSession,
|
|
129
|
+
tmuxLaunchFingerprint,
|
|
130
|
+
tmuxListSessionNames,
|
|
131
|
+
tmuxManagedSessionName,
|
|
132
|
+
tmuxManagedSessionNameMatches,
|
|
133
|
+
tmuxPaneInfo,
|
|
134
|
+
tmuxProviderState,
|
|
135
|
+
tmuxSendLiteral,
|
|
136
|
+
tmuxSessionEnvironmentValue,
|
|
137
|
+
tmuxSessionHasClients
|
|
138
|
+
} from '../tmux.mjs';
|
|
139
|
+
import { inspectProcessIdentity, waitForLiveProcessIdentity } from '../process/identity.mjs';
|
|
140
|
+
|
|
141
|
+
export function createWebRuntime(deps) {
|
|
142
|
+
const {
|
|
143
|
+
// constants
|
|
144
|
+
ACTIVE_PEER_TTL, BUFS_DIR_NAME, CLI_NAME, DEFAULT_LOCK_TTL,
|
|
145
|
+
DETECTED_PEER_MAX_AGE, PRODUCT_NAME, UNKNOWN_EVIDENCE_GRACE_SEC, VERSION,
|
|
146
|
+
// db helpers
|
|
147
|
+
connect, addEvent, auditPayload,
|
|
148
|
+
// peer helpers
|
|
149
|
+
touchPeer, upsertPeer, upsertCanonicalPeerBinding,
|
|
150
|
+
// coordination state
|
|
151
|
+
assertTaskOwnerForMutation, claimNextTasksForPeer,
|
|
152
|
+
queryInbox, queryOpenTasks, queryTimelineMessages,
|
|
153
|
+
requestActorPeer, requestSource, sendMessage,
|
|
154
|
+
statusSnapshot, statusSummary, takeOverTaskForPeer,
|
|
155
|
+
webPeerAction,
|
|
156
|
+
// evidence runtime
|
|
157
|
+
canonicalRoot, isProjectManagedTmuxSession, liveProcessIdentity,
|
|
158
|
+
mutatePeerWithEvidence, observeClockSafetyOrThrow, observePeerEvidence,
|
|
159
|
+
peerEvidenceFromDb, providerOwnerEvidenceFromDb, rootEvidence,
|
|
160
|
+
// tmux evidence helpers
|
|
161
|
+
strictTmuxClientObservation, tmuxAttachmentAuthority,
|
|
162
|
+
tmuxPaneForTarget, tmuxSessionCreationToken, tmuxSessionId,
|
|
163
|
+
// gc helpers
|
|
164
|
+
bufferDirectory, runGc,
|
|
165
|
+
// web startup helpers
|
|
166
|
+
assertWebTokenForHost, proxyOriginForOpts, startWebBackground,
|
|
167
|
+
webExposureWarning, webSocketOriginAllowed,
|
|
168
|
+
// local bus
|
|
169
|
+
prepareLocalBus,
|
|
170
|
+
// misc bin-local helpers
|
|
171
|
+
findProviderSessionBinding, helpWeb, latestHookProviderSession,
|
|
172
|
+
now, redactedLogText, renderWebIndex, renderWebLogin,
|
|
173
|
+
sameResolvedPath, sendWebHtml, shellCommand, webErrorStatus
|
|
174
|
+
} = deps;
|
|
175
|
+
|
|
176
|
+
async function cmdWeb(ctx, args, startMeta = {}) {
|
|
177
|
+
if (args[0] === '--help' || args[0] === '-h') return helpWeb();
|
|
178
|
+
if (process.env[WEB_CHILD_ENV] !== '1') return startWebBackground(ctx, args);
|
|
179
|
+
const runtimeIdentity = await waitForLiveProcessIdentity(process.pid, { timeoutMs: 1_000 });
|
|
180
|
+
if (runtimeIdentity.state !== 'live' || !runtimeIdentity.identity) {
|
|
181
|
+
throw new CliError(
|
|
182
|
+
'RUNTIME_IDENTITY_UNAVAILABLE',
|
|
183
|
+
'Unable to verify the web runtime process identity; no runtime pointer was published.'
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
const processIdentity = runtimeIdentity.identity;
|
|
187
|
+
const opts = parseOpts(args, { booleans: ['local', 'no-token', 'no-guidance', 'no-discover', 'tls', 'trust-proxy'] });
|
|
188
|
+
validateOpts('web', opts, ['host', 'port', 'token', 'local', 'no-token', 'no-guidance', 'no-discover', 'tls', 'trust-proxy', 'proxy-origin']);
|
|
189
|
+
validateWebTokenOpts(opts);
|
|
190
|
+
const host = expectedWebHost(opts);
|
|
191
|
+
const port = intOpt(opts, 'port', 8787);
|
|
192
|
+
const token = makeWebToken(opts);
|
|
193
|
+
assertWebTokenForHost(host, Boolean(token));
|
|
194
|
+
if (!isLoopbackHost(host)) console.error(redactedLogText(webExposureWarning(host, port) + (opts.tls ? '' : ' Consider --tls to encrypt this connection.')));
|
|
195
|
+
const useTls = Boolean(opts.tls);
|
|
196
|
+
const trustProxy = Boolean(opts['trust-proxy']);
|
|
197
|
+
const proxyOrigin = proxyOriginForOpts(opts);
|
|
198
|
+
const tlsCredentials = useTls ? ensureSelfSignedCert([host]) : null;
|
|
199
|
+
// Browser sessions: a token printed in the URL is exchanged once for an
|
|
200
|
+
// HttpOnly cookie so the token stops travelling in every fetch/WS URL
|
|
201
|
+
// (net-02). The cookie carries an opaque session id (not the token); it is
|
|
202
|
+
// meaningless outside this runtime and is lost on restart.
|
|
203
|
+
const DEFAULT_WEB_SESSION_TTL_SEC = 30 * 24 * 60 * 60;
|
|
204
|
+
const regressionWebSessionTtlRaw = process.env.HCC_REGRESSION_WEB_SESSION_TTL_SEC || '';
|
|
205
|
+
const regressionWebSessionTtl = /^\d+$/.test(regressionWebSessionTtlRaw)
|
|
206
|
+
? Number.parseInt(regressionWebSessionTtlRaw, 10)
|
|
207
|
+
: 0;
|
|
208
|
+
const WEB_SESSION_TTL_SEC = process.env.HCC_REGRESSION_TEST === '1' &&
|
|
209
|
+
regressionWebSessionTtl >= 1 && regressionWebSessionTtl <= 60
|
|
210
|
+
? regressionWebSessionTtl
|
|
211
|
+
: DEFAULT_WEB_SESSION_TTL_SEC;
|
|
212
|
+
const MAX_WEB_SESSIONS = 256;
|
|
213
|
+
const {
|
|
214
|
+
webSessions,
|
|
215
|
+
parseCookieSid, closeWebSession, pruneWebSessions,
|
|
216
|
+
issueSession, sessionCookieHeader, expiredSessionCookieHeader,
|
|
217
|
+
cookieSessionRecord, cookieSessionOk, cookieSocketValid, webAuthMode
|
|
218
|
+
} = createCookieAuth({
|
|
219
|
+
now, ttlSec: WEB_SESSION_TTL_SEC, maxSessions: MAX_WEB_SESSIONS,
|
|
220
|
+
requestIsSecure, trustProxy, proxyOrigin, authOk, token
|
|
221
|
+
});
|
|
222
|
+
const webSessionPruner = setInterval(pruneWebSessions, 60000);
|
|
223
|
+
webSessionPruner.unref?.();
|
|
224
|
+
ensureTmuxAvailable({ autoInstall: false });
|
|
225
|
+
const ptyModule = await import('node-pty');
|
|
226
|
+
const { WebSocketServer } = await import('ws');
|
|
227
|
+
const pty = ptyModule.default || ptyModule;
|
|
228
|
+
const sessions = new Map();
|
|
229
|
+
|
|
230
|
+
const {
|
|
231
|
+
sessionKey, sessionsForProject,
|
|
232
|
+
resolveSessionPeerId, sessionBindingForSerialize,
|
|
233
|
+
serializeBindingSummary, serializeSession,
|
|
234
|
+
broadcast, hasOpenClients, closeSessionClients
|
|
235
|
+
} = createSessionSerialize({ sessions, cookieSocketValid, ctx, sameResolvedPath });
|
|
236
|
+
// refreshPeerIoHeartbeat comes from createTmuxSessions below; wrap it so the
|
|
237
|
+
// reference resolves at call time instead of at this forward-passing site.
|
|
238
|
+
const lazyRefreshPeerIoHeartbeat = (session) => refreshPeerIoHeartbeat(session);
|
|
239
|
+
const { cursorEscape, tmuxSnapshot, refreshTmuxSnapshot, scheduleTmuxReplace, startTmuxReplacePoller, startTmuxStream, stopTmuxStream } = createTmuxStream({ broadcast, now, refreshPeerIoHeartbeat: lazyRefreshPeerIoHeartbeat, bufferDirectory, withBufferDirectoryLease, shellQuoteArg, ctx });
|
|
240
|
+
const prepared = await prepareLocalBus(ctx, opts);
|
|
241
|
+
|
|
242
|
+
const {
|
|
243
|
+
projectContexts,
|
|
244
|
+
newSessionActionToken, rememberProject, knownProjects,
|
|
245
|
+
resolveWebProjectContext, connectWebProject, projectFromRequest,
|
|
246
|
+
getSession, readActionToken, resolveWebActionSession,
|
|
247
|
+
knownPeerIds, nextProjectSessionId
|
|
248
|
+
} = createProjectContexts({
|
|
249
|
+
ctx, sessions,
|
|
250
|
+
sessionKey, sessionsForProject, resolveSessionPeerId,
|
|
251
|
+
connect, now, addEvent, tx, touchPeer, upsertPeer, detectBranch,
|
|
252
|
+
ACTIVE_PEER_TTL, CLI_NAME, DEFAULT_LOCK_TTL,
|
|
253
|
+
queryInbox, queryOpenTasks, queryTimelineMessages,
|
|
254
|
+
observePeerEvidence, peerEvidenceFromDb,
|
|
255
|
+
observeClockSafetyInTransaction,
|
|
256
|
+
assertTaskOwnerForMutation, claimNextTasksForPeer, takeOverTaskForPeer,
|
|
257
|
+
positiveIntOpt, sameResolvedPath
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
const {
|
|
261
|
+
statusSnapshot: webStatusSnapshot,
|
|
262
|
+
statusSummary: webStatusSummary
|
|
263
|
+
} = createCoordinationState({
|
|
264
|
+
activePeerTtl: ACTIVE_PEER_TTL,
|
|
265
|
+
cliName: CLI_NAME,
|
|
266
|
+
connect: connectWebProject,
|
|
267
|
+
defaultLockTtl: DEFAULT_LOCK_TTL,
|
|
268
|
+
now,
|
|
269
|
+
queryInbox,
|
|
270
|
+
queryOpenTasks,
|
|
271
|
+
queryTimelineMessages,
|
|
272
|
+
observePeerEvidence
|
|
273
|
+
});
|
|
274
|
+
const {
|
|
275
|
+
webPeerAction: webPeerActionForProject
|
|
276
|
+
} = createWebPeerActions({
|
|
277
|
+
activePeerTtl: ACTIVE_PEER_TTL,
|
|
278
|
+
addEvent,
|
|
279
|
+
assertTaskOwnerForMutation,
|
|
280
|
+
claimNextTasksForPeer,
|
|
281
|
+
connect: connectWebProject,
|
|
282
|
+
defaultLockTtl: DEFAULT_LOCK_TTL,
|
|
283
|
+
detectBranch,
|
|
284
|
+
now,
|
|
285
|
+
observeClockSafetyInTransaction,
|
|
286
|
+
observePeerEvidence,
|
|
287
|
+
positiveIntOpt,
|
|
288
|
+
peerEvidenceFromDb,
|
|
289
|
+
queryInbox,
|
|
290
|
+
statusSnapshot: webStatusSnapshot,
|
|
291
|
+
statusSummary: webStatusSummary,
|
|
292
|
+
takeOverTaskForPeer,
|
|
293
|
+
touchPeer,
|
|
294
|
+
tx,
|
|
295
|
+
upsertPeer
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
rememberProject(ctx);
|
|
300
|
+
for (const project of readProjectRegistry()) {
|
|
301
|
+
projectContexts.set(project.root, contextForProject(project.root, project.db, { json: ctx.json }));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
const {
|
|
307
|
+
reaperPoller, runtimeProjectContexts, runClockAwareReaper, detectClockJump
|
|
308
|
+
} = createLivenessReaper({
|
|
309
|
+
ctx, projectContexts, sessions,
|
|
310
|
+
sessionsForProject, connectWebProject,
|
|
311
|
+
now, addEvent,
|
|
312
|
+
peerEvidenceFromDb, mutatePeerWithEvidence, observeClockSafetyOrThrow,
|
|
313
|
+
UNKNOWN_EVIDENCE_GRACE_SEC, redactedLogText, sameResolvedPath
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
const {
|
|
317
|
+
gcPoller, runAutoGc,
|
|
318
|
+
collectRuntimeBufferEvidence, prepareRuntimeBufferGc, applyPreparedRuntimeBufferGc,
|
|
319
|
+
runningBufferPathSnapshot, bufferGcPlanStore
|
|
320
|
+
} = createBufferGcRuntime({
|
|
321
|
+
sessions,
|
|
322
|
+
connectWebProject, runtimeProjectContexts,
|
|
323
|
+
now, runGc, bufferDirectory,
|
|
324
|
+
canonicalRoot, observePeerEvidence, redactedLogText, sameResolvedPath
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
const {
|
|
328
|
+
bufsWatchers, externalScanPoller, bufsWatcherSyncPoller,
|
|
329
|
+
scanExternalSessions, adoptExternalSession, removeExternalBufferFiles
|
|
330
|
+
} = createExternalSessions({
|
|
331
|
+
ctx, sessions,
|
|
332
|
+
sessionKey, broadcast,
|
|
333
|
+
now, tx, connectWebProject,
|
|
334
|
+
bufferDirectory, runtimeProjectContexts,
|
|
335
|
+
refreshPeerIoHeartbeat: lazyRefreshPeerIoHeartbeat, redactedLogText, BUFS_DIR_NAME
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
const {
|
|
339
|
+
detachTmuxSession, tmuxSessionNameForPane, detachRuntimeSessionForPane,
|
|
340
|
+
openClientCountForPane, tmuxClientObservation,
|
|
341
|
+
observeTmuxDestructiveEvidence, tmuxAttachmentEvidence,
|
|
342
|
+
assertTmuxDestructiveEvidence, attachmentEvidenceForPane,
|
|
343
|
+
oldTmuxEventEvidence, addRebindCleanupFailedEvent, oldTmuxRebindTarget,
|
|
344
|
+
tmuxSessionClientCountForStop, safeTmuxKillPlan, executeTmuxKillPlan,
|
|
345
|
+
killDbProvenTmuxSession, safeOldTmuxRebindPlan, assertOldTmuxCanRebind,
|
|
346
|
+
killOldTmuxForRebind, providerSessionBindingMatches, attachTmuxSession,
|
|
347
|
+
refreshPeerIoHeartbeat, writeSessionInput, scheduleTmuxInputRefresh,
|
|
348
|
+
resizeSession, startTmuxManagedSession, restoreTmuxManagedSessions,
|
|
349
|
+
reAdoptOrphanManagedTmuxSessions, reapDeadPeersForProject
|
|
350
|
+
} = createTmuxSessions({
|
|
351
|
+
ctx, sessions,
|
|
352
|
+
broadcast, closeSessionClients, hasOpenClients,
|
|
353
|
+
sessionKey, sessionsForProject, resolveSessionPeerId,
|
|
354
|
+
startTmuxStream, stopTmuxStream, refreshTmuxSnapshot,
|
|
355
|
+
connectWebProject, nextProjectSessionId, getSession,
|
|
356
|
+
runtimeProjectContexts,
|
|
357
|
+
scanExternalSessions, adoptExternalSession, removeExternalBufferFiles,
|
|
358
|
+
now, addEvent, auditPayload, tx,
|
|
359
|
+
canonicalRoot, liveProcessIdentity, rootEvidence,
|
|
360
|
+
observePeerEvidence, mutatePeerWithEvidence, observeClockSafetyOrThrow,
|
|
361
|
+
strictTmuxClientObservation, tmuxAttachmentAuthority,
|
|
362
|
+
tmuxPaneForTarget, tmuxSessionCreationToken, tmuxSessionId,
|
|
363
|
+
isProjectManagedTmuxSession, redactedLogText, bufferDirectory,
|
|
364
|
+
UNKNOWN_EVIDENCE_GRACE_SEC, BUFS_DIR_NAME, CLI_NAME,
|
|
365
|
+
requestActorPeer, requestSource,
|
|
366
|
+
upsertPeer, upsertCanonicalPeerBinding,
|
|
367
|
+
findProviderSessionBinding, providerOwnerEvidenceFromDb,
|
|
368
|
+
shellCommand, detectBranch
|
|
369
|
+
});
|
|
370
|
+
const {
|
|
371
|
+
autoAttachPoller, listTmuxPanesOnce, attachedTmuxState,
|
|
372
|
+
reconcileRunningBindings, scanAndAttachDetectedPeers
|
|
373
|
+
} = createAutoAttach({
|
|
374
|
+
ctx, sessions,
|
|
375
|
+
connectWebProject, now, addEvent, ACTIVE_PEER_TTL,
|
|
376
|
+
reconcileRunningPeerBindings,
|
|
377
|
+
redactedLogText, sessionsForProject,
|
|
378
|
+
attachTmuxSession,
|
|
379
|
+
latestHookProviderSession,
|
|
380
|
+
resolveSessionPeerId,
|
|
381
|
+
reAdoptOrphanManagedTmuxSessions, reapDeadPeersForProject
|
|
382
|
+
});
|
|
383
|
+
scanAndAttachDetectedPeers();
|
|
384
|
+
|
|
385
|
+
const {
|
|
386
|
+
startPtySession, webSessionBuildOptions, webSessionPeerId,
|
|
387
|
+
normalizeWebSessionInput, startSession
|
|
388
|
+
} = createPtySessions({
|
|
389
|
+
pty, ctx, sessions,
|
|
390
|
+
broadcast, closeSessionClients, sessionKey,
|
|
391
|
+
connectWebProject, nextProjectSessionId,
|
|
392
|
+
startTmuxManagedSession,
|
|
393
|
+
now, addEvent, auditPayload, refreshPeerIoHeartbeat: lazyRefreshPeerIoHeartbeat,
|
|
394
|
+
upsertPeer, upsertCanonicalPeerBinding,
|
|
395
|
+
requestActorPeer, requestSource
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
const restoredTmuxDbs = new Set();
|
|
399
|
+
for (const projectCtx of runtimeProjectContexts()) {
|
|
400
|
+
const dbKey = path.resolve(projectCtx.dbPath);
|
|
401
|
+
if (restoredTmuxDbs.has(dbKey)) continue;
|
|
402
|
+
restoredTmuxDbs.add(dbKey);
|
|
403
|
+
restoreTmuxManagedSessions(projectCtx);
|
|
404
|
+
reconcileRunningBindings(projectCtx);
|
|
405
|
+
}
|
|
406
|
+
runAutoGc();
|
|
407
|
+
|
|
408
|
+
const { handleWebRequest } = createHttpRoutes({
|
|
409
|
+
ctx, sessions,
|
|
410
|
+
token, host, port, useTls, trustProxy, proxyOrigin,
|
|
411
|
+
broadcast, serializeSession, sessionsForProject, resolveSessionPeerId,
|
|
412
|
+
parseCookieSid, closeWebSession, issueSession, sessionCookieHeader,
|
|
413
|
+
expiredSessionCookieHeader, cookieSessionOk, webAuthMode,
|
|
414
|
+
rememberProject, knownProjects, resolveWebProjectContext,
|
|
415
|
+
connectWebProject, projectFromRequest, getSession, resolveWebActionSession,
|
|
416
|
+
prepareRuntimeBufferGc, applyPreparedRuntimeBufferGc,
|
|
417
|
+
detachTmuxSession, safeTmuxKillPlan, executeTmuxKillPlan,
|
|
418
|
+
killDbProvenTmuxSession, attachTmuxSession, writeSessionInput, startSession,
|
|
419
|
+
webStatusSnapshot, webPeerActionForProject,
|
|
420
|
+
now, addEvent, auditPayload, sendMessage,
|
|
421
|
+
getProcessIdentity: () => processIdentity,
|
|
422
|
+
getActualPort: () => actualPort,
|
|
423
|
+
shutdown, tlsCredentials,
|
|
424
|
+
renderWebIndex, renderWebLogin, sendWebHtml, webErrorStatus,
|
|
425
|
+
ACTIVE_PEER_TTL, DETECTED_PEER_MAX_AGE, PRODUCT_NAME, VERSION
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
const server = useTls
|
|
429
|
+
? https.createServer({ key: tlsCredentials.key, cert: tlsCredentials.cert }, handleWebRequest)
|
|
430
|
+
: http.createServer(handleWebRequest);
|
|
431
|
+
|
|
432
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
433
|
+
server.on('upgrade', (req, socket, head) => {
|
|
434
|
+
try {
|
|
435
|
+
const url = requestUrl(req);
|
|
436
|
+
if (!readWebSocketApiVersion(url).ok) {
|
|
437
|
+
const body = JSON.stringify(apiVersionUnsupportedBody());
|
|
438
|
+
socket.end([
|
|
439
|
+
'HTTP/1.1 426 Upgrade Required',
|
|
440
|
+
'Content-Type: application/json; charset=utf-8',
|
|
441
|
+
`Content-Length: ${Buffer.byteLength(body)}`,
|
|
442
|
+
'Connection: close',
|
|
443
|
+
'',
|
|
444
|
+
body
|
|
445
|
+
].join('\r\n'));
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
const upgradeAuthMode = webAuthMode(url, req);
|
|
449
|
+
if (!upgradeAuthMode) {
|
|
450
|
+
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
|
451
|
+
socket.destroy();
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
const cookieAuth = upgradeAuthMode === 'cookie' ? cookieSessionRecord(req) : null;
|
|
455
|
+
if (upgradeAuthMode === 'cookie' && !cookieAuth) {
|
|
456
|
+
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
|
457
|
+
socket.destroy();
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
if (!webSocketOriginAllowed(req, { trustProxy, proxyOrigin })) {
|
|
461
|
+
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
|
|
462
|
+
socket.destroy();
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
const match = url.pathname.match(/^\/ws\/terminal\/([^/]+)$/);
|
|
466
|
+
if (!match) {
|
|
467
|
+
socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
|
|
468
|
+
socket.destroy();
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
const reqCtx = projectFromRequest(req, url);
|
|
472
|
+
const id = decodeURIComponent(match[1]);
|
|
473
|
+
const lookupDb = connectWebProject(reqCtx);
|
|
474
|
+
let session;
|
|
475
|
+
try {
|
|
476
|
+
session = getSession(reqCtx, id, lookupDb);
|
|
477
|
+
} finally {
|
|
478
|
+
lookupDb.close();
|
|
479
|
+
}
|
|
480
|
+
if (!session) {
|
|
481
|
+
socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
|
|
482
|
+
socket.destroy();
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
// ws-5: cap concurrent WS clients per session so the action_token (delivered
|
|
486
|
+
// via the snapshot frame) cannot be harvested by unlimited connections.
|
|
487
|
+
if (session.clients.size >= 4) {
|
|
488
|
+
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
|
|
489
|
+
socket.destroy();
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
493
|
+
const connectionActionToken = newSessionActionToken();
|
|
494
|
+
ws.hccCookieAuth = cookieAuth;
|
|
495
|
+
session.clients.add(ws);
|
|
496
|
+
if (cookieAuth) cookieAuth.session.sockets.add(ws);
|
|
497
|
+
if (!cookieSocketValid(ws)) {
|
|
498
|
+
session.clients.delete(ws);
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
session.actionTokens ||= new Set();
|
|
502
|
+
session.actionTokens.add(connectionActionToken);
|
|
503
|
+
ws.send(JSON.stringify({ type: 'snapshot', data: refreshTmuxSnapshot(session), action_token: connectionActionToken }));
|
|
504
|
+
ws.on('message', (raw) => {
|
|
505
|
+
try {
|
|
506
|
+
if (!cookieSocketValid(ws)) return;
|
|
507
|
+
const msg = JSON.parse(String(raw));
|
|
508
|
+
if (msg.type === 'input' && session.status === 'running') {
|
|
509
|
+
if (!tokenMatches(msg.action_token, connectionActionToken)) return;
|
|
510
|
+
const data = String(msg.data || '');
|
|
511
|
+
writeSessionInput(session, data);
|
|
512
|
+
} else if (msg.type === 'resize' && session.status === 'running') {
|
|
513
|
+
if (!tokenMatches(msg.action_token, connectionActionToken)) return;
|
|
514
|
+
const cols = Math.max(20, Number.parseInt(msg.cols || 100, 10));
|
|
515
|
+
const rows = Math.max(8, Number.parseInt(msg.rows || 30, 10));
|
|
516
|
+
resizeSession(session, cols, rows);
|
|
517
|
+
scheduleTmuxReplace(session);
|
|
518
|
+
}
|
|
519
|
+
} catch {
|
|
520
|
+
// Ignore malformed terminal frames.
|
|
521
|
+
}
|
|
522
|
+
});
|
|
523
|
+
ws.on('close', () => {
|
|
524
|
+
session.clients.delete(ws);
|
|
525
|
+
session.actionTokens.delete(connectionActionToken);
|
|
526
|
+
if (cookieAuth) cookieAuth.session.sockets.delete(ws);
|
|
527
|
+
});
|
|
528
|
+
});
|
|
529
|
+
} catch (err) {
|
|
530
|
+
console.error(redactedLogText(`[${new Date().toISOString()}] ws upgrade failed: ${err?.message || err}`));
|
|
531
|
+
try { socket.destroy(); } catch {}
|
|
532
|
+
}
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
let cleanupPromise = null;
|
|
536
|
+
let shutdownExitCode = 0;
|
|
537
|
+
async function performRuntimeCleanup() {
|
|
538
|
+
clearRuntime(ctx);
|
|
539
|
+
// rob-07: clear this runtime's per-project pointers for every registered
|
|
540
|
+
// project, not just the primary ctx, so sibling projects don't keep
|
|
541
|
+
// pointing at a now-dead runtime.
|
|
542
|
+
try {
|
|
543
|
+
for (const project of readProjectRegistry()) {
|
|
544
|
+
clearRuntime({ root: project.root }, process.pid);
|
|
545
|
+
}
|
|
546
|
+
} catch {}
|
|
547
|
+
// sess-02: mark managed (tmux/pty) sessions detached in their own project
|
|
548
|
+
// DB and clear their runtime_target, so a restart does not consume stale
|
|
549
|
+
// bindings. Best-effort; never block shutdown.
|
|
550
|
+
try {
|
|
551
|
+
const byRoot = new Map();
|
|
552
|
+
for (const s of sessions.values()) {
|
|
553
|
+
if (s.status !== 'running' || (s.type !== 'tmux' && s.type !== 'pty')) continue;
|
|
554
|
+
const peerId = s.peerId || s.id;
|
|
555
|
+
if (!peerId) continue;
|
|
556
|
+
const root = path.resolve(s.ctx?.root || s.root || ctx.root);
|
|
557
|
+
if (!byRoot.has(root)) byRoot.set(root, new Set());
|
|
558
|
+
byRoot.get(root).add(peerId);
|
|
559
|
+
}
|
|
560
|
+
const detachedAt = now();
|
|
561
|
+
for (const [root, idSet] of byRoot) {
|
|
562
|
+
const peerIds = [...idSet];
|
|
563
|
+
if (!peerIds.length) continue;
|
|
564
|
+
const placeholders = peerIds.map(() => '?').join(',');
|
|
565
|
+
const cleanupDb = connectWebProject(contextForProject(root, null, { json: ctx.json }));
|
|
566
|
+
try {
|
|
567
|
+
cleanupDb.prepare(`UPDATE peers SET status = 'detached' WHERE id IN (${placeholders}) AND status IN ('running','working','busy')`).run(...peerIds);
|
|
568
|
+
cleanupDb.prepare(`UPDATE peer_bindings SET runtime_target = NULL, updated_at = ? WHERE peer IN (${placeholders})`).run(detachedAt, ...peerIds);
|
|
569
|
+
} finally {
|
|
570
|
+
cleanupDb.close();
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
} catch {}
|
|
574
|
+
clearInterval(externalScanPoller);
|
|
575
|
+
clearInterval(autoAttachPoller);
|
|
576
|
+
clearInterval(reaperPoller);
|
|
577
|
+
clearInterval(gcPoller);
|
|
578
|
+
clearInterval(webSessionPruner);
|
|
579
|
+
try { for (const w of bufsWatchers.values()) w.close(); } catch {}
|
|
580
|
+
clearInterval(bufsWatcherSyncPoller);
|
|
581
|
+
for (const session of sessions.values()) {
|
|
582
|
+
closeSessionClients(session);
|
|
583
|
+
if (session.status !== 'running') continue;
|
|
584
|
+
if (session.type === 'external') {
|
|
585
|
+
try { if (session.outputFd) fs.closeSync(session.outputFd); } catch {}
|
|
586
|
+
try { if (session.outputPoller) clearInterval(session.outputPoller); } catch {}
|
|
587
|
+
try { if (session.exitPoller) clearInterval(session.exitPoller); } catch {}
|
|
588
|
+
} else if (session.type === 'tmux') {
|
|
589
|
+
try { stopTmuxStream(session); } catch {}
|
|
590
|
+
try { if (session.exitPoller) clearInterval(session.exitPoller); } catch {}
|
|
591
|
+
} else {
|
|
592
|
+
try { session.pty.kill(); } catch {}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
try { wss.close(); } catch {}
|
|
596
|
+
await new Promise((resolve) => {
|
|
597
|
+
const terminateClients = setTimeout(() => {
|
|
598
|
+
for (const session of sessions.values()) {
|
|
599
|
+
for (const client of [...(session.clients || [])]) {
|
|
600
|
+
try { if (typeof client.terminate === 'function') client.terminate(); } catch {}
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
try { server.closeAllConnections?.(); } catch {}
|
|
604
|
+
}, 250);
|
|
605
|
+
const forceClose = setTimeout(() => {
|
|
606
|
+
try { server.closeAllConnections?.(); } catch {}
|
|
607
|
+
resolve();
|
|
608
|
+
}, 1500);
|
|
609
|
+
try {
|
|
610
|
+
server.close(() => {
|
|
611
|
+
clearTimeout(terminateClients);
|
|
612
|
+
clearTimeout(forceClose);
|
|
613
|
+
resolve();
|
|
614
|
+
});
|
|
615
|
+
try { server.closeIdleConnections?.(); } catch {}
|
|
616
|
+
} catch {
|
|
617
|
+
clearTimeout(terminateClients);
|
|
618
|
+
clearTimeout(forceClose);
|
|
619
|
+
resolve();
|
|
620
|
+
}
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
function cleanupRuntime() {
|
|
624
|
+
if (!cleanupPromise) cleanupPromise = performRuntimeCleanup();
|
|
625
|
+
return cleanupPromise;
|
|
626
|
+
}
|
|
627
|
+
function shutdown() {
|
|
628
|
+
void cleanupRuntime().then(
|
|
629
|
+
() => process.exit(shutdownExitCode),
|
|
630
|
+
(error) => {
|
|
631
|
+
console.error(redactedLogText(`[${new Date().toISOString()}] runtime cleanup failed: ${error?.stack || error}`));
|
|
632
|
+
process.exit(1);
|
|
633
|
+
}
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
process.once('SIGINT', shutdown);
|
|
637
|
+
process.once('SIGTERM', shutdown);
|
|
638
|
+
// Best-effort backstop for exit paths that bypass shutdown() (process.exit,
|
|
639
|
+
// uncaught exceptions): synchronously kill pty-backed children so they are not
|
|
640
|
+
// orphaned. Cannot run on SIGKILL — the dead-peer reaper repairs DB state on
|
|
641
|
+
// the next start. tmux sessions are intentionally left alive.
|
|
642
|
+
process.on('exit', () => {
|
|
643
|
+
for (const session of sessions.values()) {
|
|
644
|
+
if (session.type !== 'tmux' && session.type !== 'external' && session.pty) {
|
|
645
|
+
try { session.pty.kill(); } catch {}
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
const fatalController = createFatalShutdownController({
|
|
651
|
+
cleanup: () => {
|
|
652
|
+
shutdownExitCode = 1;
|
|
653
|
+
return cleanupRuntime();
|
|
654
|
+
},
|
|
655
|
+
exit: (code) => process.exit(code),
|
|
656
|
+
forceExit: (code) => process.exit(code),
|
|
657
|
+
log: (entry) => console.error(redactedLogText({
|
|
658
|
+
timestamp: new Date().toISOString(),
|
|
659
|
+
...entry
|
|
660
|
+
}))
|
|
661
|
+
});
|
|
662
|
+
process.on('uncaughtException', (err) => {
|
|
663
|
+
void fatalController.fatal(err);
|
|
664
|
+
});
|
|
665
|
+
process.on('unhandledRejection', (reason) => {
|
|
666
|
+
void fatalController.fatal(reason);
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
const actualPort = await listenServer(server, host, port, opts.port === undefined);
|
|
670
|
+
const runtime = {
|
|
671
|
+
product: PRODUCT_NAME,
|
|
672
|
+
version: VERSION,
|
|
673
|
+
api_version: API_VERSION,
|
|
674
|
+
pid: process.pid,
|
|
675
|
+
...(processIdentity ? { process_identity: processIdentity } : {}),
|
|
676
|
+
root: ctx.root,
|
|
677
|
+
db: ctx.dbPath,
|
|
678
|
+
host,
|
|
679
|
+
port: actualPort,
|
|
680
|
+
base_url: runtimeBaseUrl(host, actualPort, useTls),
|
|
681
|
+
token,
|
|
682
|
+
tls: useTls,
|
|
683
|
+
trust_proxy: trustProxy,
|
|
684
|
+
proxy_origin: proxyOrigin,
|
|
685
|
+
tls_cert: useTls ? tlsCredentials.cert : undefined,
|
|
686
|
+
started_at: now()
|
|
687
|
+
};
|
|
688
|
+
const runtimeFile = writeRuntime(ctx, runtime);
|
|
689
|
+
writeGlobalRuntime(runtime);
|
|
690
|
+
registerProject(ctx);
|
|
691
|
+
const db = connectWebProject(ctx);
|
|
692
|
+
try {
|
|
693
|
+
addEvent(db, startMeta.eventType || 'web.started', 'human', null, auditPayload({
|
|
694
|
+
actor: 'human',
|
|
695
|
+
source: 'cli',
|
|
696
|
+
root: ctx.root,
|
|
697
|
+
db: ctx.dbPath,
|
|
698
|
+
host,
|
|
699
|
+
port: actualPort,
|
|
700
|
+
requested_port: port,
|
|
701
|
+
guidance: startMeta.guidance || prepared.guidance || null,
|
|
702
|
+
runtime: runtimeFile
|
|
703
|
+
}));
|
|
704
|
+
} finally {
|
|
705
|
+
db.close();
|
|
706
|
+
}
|
|
707
|
+
console.log(redactedLogText(`${PRODUCT_NAME} web listening on ${host}:${actualPort}`));
|
|
708
|
+
console.log(redactedLogText(`project: ${ctx.root}`));
|
|
709
|
+
console.log(redactedLogText(`database: ${ctx.dbPath}`));
|
|
710
|
+
console.log(redactedLogText(`open: ${publicRuntimeUrl(runtime, ctx.root)}`));
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
return { cmdWeb };
|
|
714
|
+
}
|