@gleapai/kai-bridge 0.9.0 → 0.10.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/README.md +3 -2
- package/bin/kai-bridge.mjs +12 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -2
- package/runner/acp-runner.mjs +7 -51
- package/runner/lib/acp/mapper.mjs +1 -31
- package/runner/lib/contract.mjs +1 -16
- package/scripts/postinstall.mjs +7 -0
- package/src/api.mjs +21 -99
- package/src/companions.mjs +3 -2
- package/src/daemon.mjs +709 -1075
- package/src/executor.mjs +4 -2
- package/src/gateway.mjs +217 -0
- package/src/git-auth.mjs +38 -0
- package/src/harnesses.mjs +15 -2
- package/src/playwright-patch.mjs +84 -0
- package/src/preview-errors.mjs +0 -29
- package/src/preview.mjs +293 -103
- package/src/ps.mjs +152 -0
- package/src/repository-setup.mjs +2 -2
- package/src/service.mjs +0 -11
- package/src/setup.mjs +5 -5
- package/src/tunnel-binary.mjs +109 -0
- package/src/tunnel.mjs +227 -0
- package/src/workspace.mjs +89 -6
- package/runner/personas/claude/kai-verifier.md +0 -84
- package/runner/personas/codex/kai-verifier.md +0 -84
- package/runner/tools/verify-mcp.mjs +0 -442
- package/src/preview-login.mjs +0 -610
- package/src/verify.mjs +0 -387
package/src/daemon.mjs
CHANGED
|
@@ -12,76 +12,51 @@ import { cloneRepository, locateRepository } from './repository-setup.mjs';
|
|
|
12
12
|
// bridge.repo.clone { commandId, remote, name }
|
|
13
13
|
// bridge.profile.login{ commandId, profileId }
|
|
14
14
|
// bridge.rescan {}
|
|
15
|
-
// bridge.verify.login.start { commandId, requestId, sessionId, repoKey, previewNeeded } — headed sign-in window (see preview-login.mjs)
|
|
16
|
-
// bridge.verify.login.done { commandId, requestId } — "Mark done": export now
|
|
17
|
-
// bridge.verify.login.cancel { commandId, requestId }
|
|
18
|
-
// bridge.verify.evidence.retry { commandId?, turnId } — re-upload a kept evidence dir → PUT …/verification/artifacts
|
|
19
15
|
// bridge.session.close { sessionId } — stops the preview, aborts the session's running turns
|
|
16
|
+
// bridge.preview.publish { sessionId, sessionUrl, title, repos, companionRemotes } — re-boot the preview with public
|
|
17
|
+
// addresses (hostnames + tunnel credentials come from POST /devices/me/public-hosts)
|
|
18
|
+
// bridge.preview.unpublish { sessionId } — stop sharing: back to local addresses, the preview keeps running
|
|
20
19
|
|
|
21
20
|
import { spawn } from "node:child_process";
|
|
22
21
|
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
25
|
-
import { homedir, platform } from "node:os";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { platform } from "node:os";
|
|
26
24
|
|
|
27
25
|
import { BridgeApi, createEventBatcher } from "./api.mjs";
|
|
28
26
|
import { ensureClaudeAcpPatched } from "./acp-patch.mjs";
|
|
27
|
+
import { ensurePlaywrightTimeoutPatched } from "./playwright-patch.mjs";
|
|
29
28
|
import { KAI_HOME, defaultConfig, loadConfig, saveConfig } from "./config.mjs";
|
|
29
|
+
import { WARMUP_HEADER } from "./gateway.mjs";
|
|
30
|
+
|
|
31
|
+
/** Pre-warm navigation cap: a cold dev server of a big app needs minutes, not the probe's seconds. */
|
|
32
|
+
const PREWARM_TIMEOUT_MS = 15 * 60_000;
|
|
30
33
|
import { runTurn } from "./executor.mjs";
|
|
31
34
|
import { createManagedProfile, describeProfiles, managedConfigDir, ambientConfigDir, openLoginTerminal, probeUsageLimits } from "./profiles.mjs";
|
|
32
35
|
import { defaultRoots, groupByRepo, preferredCloneRoot, scanRoots, toDeviceRepoReport } from "./repos.mjs";
|
|
33
36
|
import { describeBranchChanges, discardChanges, collectChanges, commitAndPush, copyPrimaryEnvFiles, currentBranch, currentHead, materializeBinding, sessionSlug, worktreePath, ensureCommitExcludes } from "./workspace.mjs";
|
|
34
|
-
import { ServiceRunner, detectDevConfig,
|
|
35
|
-
import {
|
|
36
|
-
import { PreviewError, envRedactionValues, previewErrorPayload, toPreviewErrorPayload } from "./preview-errors.mjs";
|
|
37
|
+
import { ServiceRunner, detectDevConfig, ensurePreviewBrowser, launchOptionsFor, loadPlaywright, preferredStablePort, previewMcpServer, readDevConfig } from "./preview.mjs";
|
|
38
|
+
import { PreviewError, previewErrorPayload, toPreviewErrorPayload } from "./preview-errors.mjs";
|
|
37
39
|
import { buildCloneCommand, collectCompanions, prefersSsh, resolveCompanionRemote } from "./companions.mjs";
|
|
40
|
+
import { TunnelManager } from "./tunnel.mjs";
|
|
41
|
+
import { resolveCloudflared } from "./tunnel-binary.mjs";
|
|
38
42
|
import { establishedConnections } from "./ports.mjs";
|
|
39
|
-
import {
|
|
40
|
-
buildOriginMap,
|
|
41
|
-
captureLogin,
|
|
42
|
-
displayAvailable,
|
|
43
|
-
filterStorageState,
|
|
44
|
-
invertOriginMap,
|
|
45
|
-
launchOptionsFor,
|
|
46
|
-
loadPlaywright,
|
|
47
|
-
mergeFinalState,
|
|
48
|
-
normalizeOrigin,
|
|
49
|
-
parseStorageStateOutput,
|
|
50
|
-
preferredStablePort,
|
|
51
|
-
probeLogin,
|
|
52
|
-
redactDeep,
|
|
53
|
-
redactionSet,
|
|
54
|
-
rewriteStorageState,
|
|
55
|
-
storageStateIsEmpty,
|
|
56
|
-
} from "./preview-login.mjs";
|
|
43
|
+
import { GIT_AUTH_TTL_MS, gitAuthEnv, isGitAuthError } from "./git-auth.mjs";
|
|
57
44
|
import { describeHarnesses, installHarness, probeHarnessAuth } from "./harnesses.mjs";
|
|
58
45
|
import { probeHarnessModels } from "./models.mjs";
|
|
59
46
|
import { decideRestart, decideUpdate, fetchLatestVersion, installVersion, installedVersion, installedVersionOrNull, isNewer } from "./selfupdate.mjs";
|
|
60
47
|
import { dirname } from "node:path";
|
|
61
48
|
import { fileURLToPath } from "node:url";
|
|
62
49
|
|
|
63
|
-
const PROBE_TIMEOUT_COLD_BOOT_MS = 60_000;
|
|
64
50
|
const CLONE_TIMEOUT_MS = 3 * 60_000;
|
|
65
51
|
const COMPANION_MAX_DEPTH = 3;
|
|
66
52
|
const RUNNER_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "runner");
|
|
67
53
|
|
|
68
|
-
/** A captured landing URL moved onto the current origin map (null when its origin is not running). */
|
|
69
|
-
function rewriteUrl(url, originMap) {
|
|
70
|
-
const origin = normalizeOrigin(url);
|
|
71
|
-
const to = origin && originMap ? originMap.get(origin) : null;
|
|
72
|
-
if (!to) return null;
|
|
73
|
-
try {
|
|
74
|
-
const u = new URL(String(url));
|
|
75
|
-
return `${to}${u.pathname}${u.search}${u.hash}`;
|
|
76
|
-
} catch {
|
|
77
|
-
return null;
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
54
|
/** Lock paths held by daemons in THIS process (cross-process uses the file). */
|
|
82
55
|
const HELD_LOCKS = new Set();
|
|
83
56
|
const REALTIME_RETRY_MS = 15_000;
|
|
84
57
|
const HEARTBEAT_MS = 30_000;
|
|
58
|
+
/** Safety net under the realtime channel: ask the server for work it thinks we run (see pullPendingWork). */
|
|
59
|
+
const PENDING_POLL_MS = 60_000;
|
|
85
60
|
const USAGE_REFRESH_MS = 10 * 60_000;
|
|
86
61
|
// Harness model catalogues change on releases, not by the minute.
|
|
87
62
|
const MODELS_REFRESH_MS = 6 * 60 * 60_000;
|
|
@@ -158,6 +133,8 @@ export class BridgeDaemon {
|
|
|
158
133
|
this.kaiHome = kaiHome;
|
|
159
134
|
this.log = log;
|
|
160
135
|
this.api = new BridgeApi({ apiBase: config.apiBase, token: config.device?.token });
|
|
136
|
+
/** repoKey → { authHeader, at }: Server-issued git credentials (see git-auth.mjs). */
|
|
137
|
+
this.gitAuth = new Map();
|
|
161
138
|
this.realtimeFactory = realtimeFactory;
|
|
162
139
|
this.running = new Map(); // turnId → { ctrl: AbortController, control?: (obj) => boolean }
|
|
163
140
|
this.services = new Map(); // sessionId → ServiceRunner (lives across turns)
|
|
@@ -168,9 +145,6 @@ export class BridgeDaemon {
|
|
|
168
145
|
this.updateAvailable = null; // newer registry version, when one exists
|
|
169
146
|
this.updateError = null; // why the last self-update did not apply
|
|
170
147
|
this.updating = false; // npm is replacing our files — refuse new turns
|
|
171
|
-
this.logins = new Map(); // requestId → { sessionId, repoKey, handle } — open sign-in windows (preview-login.mjs)
|
|
172
|
-
this.verifyLocks = new Map(); // `${userId}:${repoKey}` → promise chain (one probe/refresh of a sign-in at a time)
|
|
173
|
-
this.previewStatusListeners = new Map(); // sessionId → (message) => void — the booting heartbeat of a verify turn
|
|
174
148
|
this.unknownDevKeysLogged = new Set(); // "<repo>:<key>" — unknown dev.yaml keys are logged once
|
|
175
149
|
this.restartPending = false; // files on disk are a different version than the one we loaded; restart when idle
|
|
176
150
|
}
|
|
@@ -184,7 +158,7 @@ export class BridgeDaemon {
|
|
|
184
158
|
}
|
|
185
159
|
|
|
186
160
|
async hello() {
|
|
187
|
-
const profiles = await describeProfiles(resolveProfiles(this.config, this.kaiHome), this.usageByProfile);
|
|
161
|
+
const profiles = carryAuthStates(await describeProfiles(resolveProfiles(this.config, this.kaiHome), this.usageByProfile), (this.lastAuthStates ??= new Map()));
|
|
188
162
|
const repos = toDeviceRepoReport(this.repoGroups);
|
|
189
163
|
return this.api.hello({
|
|
190
164
|
name: this.config.device?.name,
|
|
@@ -198,10 +172,6 @@ export class BridgeDaemon {
|
|
|
198
172
|
updateAvailable: this.updateAvailable || null,
|
|
199
173
|
updateError: this.updateError || null,
|
|
200
174
|
autoUpdate: this.config.autoUpdate !== false,
|
|
201
|
-
// Verify sign-in: can this machine open a headed Chrome window, and
|
|
202
|
-
// which sign-in requests are open right now (replayed after sleep).
|
|
203
|
-
capabilities: { display: this.displayAvailable() },
|
|
204
|
-
activeLogins: [...this.logins.keys()],
|
|
205
175
|
});
|
|
206
176
|
}
|
|
207
177
|
|
|
@@ -216,16 +186,29 @@ export class BridgeDaemon {
|
|
|
216
186
|
// (postinstall applies the patch; a self-update or --ignore-scripts
|
|
217
187
|
// install can leave it unpatched). Idempotent, best-effort.
|
|
218
188
|
ensureClaudeAcpPatched({ log: (level, event, data) => this.log(level, event, data) });
|
|
189
|
+
// The browser MCP's 30 s default per tool call cannot survive a heavy
|
|
190
|
+
// dev build — raised in the bundled playwright-core (see playwright-patch.mjs).
|
|
191
|
+
ensurePlaywrightTimeoutPatched({ log: (level, event, data) => this.log(level, event, data) });
|
|
219
192
|
// A previous run that was killed (reboot, crash, `kill -9`) never got
|
|
220
193
|
// to report its turns. Tell the server before doing anything else,
|
|
221
194
|
// so those sessions settle instead of spinning.
|
|
222
195
|
await this.reportInterruptedTurns();
|
|
223
196
|
// Dev servers a killed daemon left behind would hold the declared ports
|
|
224
|
-
// (→ port_busy on the next preview) — end them
|
|
225
|
-
//
|
|
197
|
+
// (→ port_busy on the next preview) — end them.
|
|
198
|
+
// Previews from the previous daemon life (self-update, `launchctl kickstart`,
|
|
199
|
+
// a crash) are RE-ADOPTED when their dev servers are still up — a warm
|
|
200
|
+
// Vite is minutes of compile nobody wants to pay again. What cannot be
|
|
201
|
+
// resumed is ended like any orphan.
|
|
202
|
+
await this.resumePreviews();
|
|
203
|
+
// Public links of the resumed previews come back too; a cloudflared the
|
|
204
|
+
// previous daemon left behind is ended first.
|
|
205
|
+
await this.tunnel
|
|
206
|
+
.resume({
|
|
207
|
+
liveSessionIds: [...this.services.keys()],
|
|
208
|
+
resolveBinary: () => (this.resolveCloudflared ?? resolveCloudflared)({ kaiHome: this.kaiHome, device: this.config?.device?.name || "this device", log: (...a) => this.log(...a) }),
|
|
209
|
+
})
|
|
210
|
+
.catch((err) => this.log("warn", "tunnel.resume.failed", { error: err?.message }));
|
|
226
211
|
this.killOrphanedServices();
|
|
227
|
-
const swept = sweepOldArtifacts(join(this.kaiHome, "artifacts"), { maxAgeMs: ARTIFACT_MAX_AGE_MS });
|
|
228
|
-
if (swept.length) this.log("info", "artifacts.swept", { count: swept.length });
|
|
229
212
|
await this.scanRepos();
|
|
230
213
|
// The first hello must not kill the daemon: the server may be
|
|
231
214
|
// restarting (deploys, local nodemon) — retry with backoff instead
|
|
@@ -233,6 +216,7 @@ export class BridgeDaemon {
|
|
|
233
216
|
for (let delay = 5_000; ; delay = Math.min(delay * 2, 60_000)) {
|
|
234
217
|
try {
|
|
235
218
|
await this.hello();
|
|
219
|
+
await this.reportResumedPreviews();
|
|
236
220
|
break;
|
|
237
221
|
} catch (err) {
|
|
238
222
|
if (err?.status === 401 || err?.status === 403) {
|
|
@@ -260,10 +244,20 @@ export class BridgeDaemon {
|
|
|
260
244
|
// behind, node exited 0, and launchd's SuccessfulExit:false meant the
|
|
261
245
|
// machine stayed offline until the next login — silently.
|
|
262
246
|
this.heartbeat = setInterval(() => {
|
|
247
|
+
// `previews`: the sessions with a live runner — the Server's reaper
|
|
248
|
+
// stops what it still believes runs here but is not in this list.
|
|
263
249
|
this.api
|
|
264
|
-
.heartbeat({ running: [...this.running.keys()] })
|
|
250
|
+
.heartbeat({ running: [...this.running.keys()], previews: [...this.services.keys()] })
|
|
251
|
+
.then(() => this.flushPendingReports())
|
|
265
252
|
.catch((err) => this.onApiError("heartbeat", err));
|
|
266
253
|
}, HEARTBEAT_MS);
|
|
254
|
+
// The realtime channel can be silently dead (a subscription that
|
|
255
|
+
// failed its auth during a Server restart, a socket the client
|
|
256
|
+
// believes is fine): every minute, pull whatever the server still
|
|
257
|
+
// expects this machine to run. Idempotent — a turn already running
|
|
258
|
+
// or already started by the channel is skipped.
|
|
259
|
+
this.pendingPoll = setInterval(() => void this.pullPendingWork("poll"), this.pendingPollMs ?? PENDING_POLL_MS);
|
|
260
|
+
this.pendingPoll.unref?.();
|
|
267
261
|
// Plan-usage windows for the composer popover. Fire-and-forget on a
|
|
268
262
|
// slow cadence, unref'd (the heartbeat keeps the process alive), and
|
|
269
263
|
// NEVER in hello's path — the probe spawns the CLI (~2s per profile).
|
|
@@ -296,7 +290,7 @@ export class BridgeDaemon {
|
|
|
296
290
|
// into them as soon as no turn is running — BEFORE asking the registry,
|
|
297
291
|
// which needs network this check must not depend on.
|
|
298
292
|
const onDisk = installedVersionOrNull();
|
|
299
|
-
const restart = decideRestart({ loaded: VERSION, installed: onDisk, running: this.running.size + this.
|
|
293
|
+
const restart = decideRestart({ loaded: VERSION, installed: onDisk, running: this.running.size + this.services.size });
|
|
300
294
|
this.restartPending = restart.action === "defer";
|
|
301
295
|
if (restart.action === "restart") {
|
|
302
296
|
this.log("info", "update.restart.stale", { loaded: VERSION, installed: onDisk });
|
|
@@ -314,10 +308,9 @@ export class BridgeDaemon {
|
|
|
314
308
|
const decision = decideUpdate({
|
|
315
309
|
current: VERSION,
|
|
316
310
|
latest,
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
|
|
320
|
-
running: this.running.size + this.logins.size + this.services.size,
|
|
311
|
+
// A live preview someone may be looking at right now is work in
|
|
312
|
+
// flight too.
|
|
313
|
+
running: this.running.size + this.services.size,
|
|
321
314
|
autoUpdate: this.config.autoUpdate !== false,
|
|
322
315
|
lastAttempt: this.config.selfUpdate?.lastAttempt ?? null,
|
|
323
316
|
});
|
|
@@ -396,7 +389,11 @@ export class BridgeDaemon {
|
|
|
396
389
|
const candidates = profiles
|
|
397
390
|
.filter((p) => p.harness === harness && p.kind !== "gleap-key" && p.configDir)
|
|
398
391
|
.sort((a, b) => Number(b.kind === "ambient") - Number(a.kind === "ambient"));
|
|
399
|
-
const
|
|
392
|
+
const auths = candidates.map((p) => ({ profile: p, auth: probeHarnessAuth(harness, p.configDir, this.kaiHome) }));
|
|
393
|
+
const profile = auths.find(({ auth }) => auth?.state === "signed_in")?.profile;
|
|
394
|
+
// No definite answer (the CLI timed out) is not "signed out": keep
|
|
395
|
+
// the previous list rather than blanking the picker.
|
|
396
|
+
if (!profile && auths.some(({ auth }) => auth?.state === "unknown")) continue;
|
|
400
397
|
let models = null;
|
|
401
398
|
if (profile) {
|
|
402
399
|
try {
|
|
@@ -450,27 +447,35 @@ export class BridgeDaemon {
|
|
|
450
447
|
/**
|
|
451
448
|
* Shut down: abort turns, stop every preview (telling the dashboard why —
|
|
452
449
|
* `stopped` / `daemon_restarted`, so the card offers Start instead of
|
|
453
|
-
* showing dead links)
|
|
450
|
+
* showing dead links). Resolves once the preview
|
|
454
451
|
* reports have been given a bounded chance to land (the self-update
|
|
455
452
|
* restart awaits it; sync callers may ignore the promise).
|
|
456
453
|
*/
|
|
457
454
|
stop() {
|
|
458
455
|
this.stopped = true;
|
|
459
456
|
clearInterval(this.heartbeat);
|
|
457
|
+
clearInterval(this.pendingPoll);
|
|
460
458
|
clearInterval(this.usageTimer);
|
|
461
459
|
clearInterval(this.modelsTimer);
|
|
462
460
|
clearInterval(this.updateTimer);
|
|
463
461
|
if (this.realtimeRetry) clearTimeout(this.realtimeRetry);
|
|
464
462
|
for (const entry of this.running.values()) entry.ctrl.abort();
|
|
463
|
+
// Previews OUTLIVE the daemon: gateways close (the next daemon re-binds
|
|
464
|
+
// the ports), the dev servers keep running and are resumed from
|
|
465
|
+
// state/previews.json — a warm app stays warm across self-updates and
|
|
466
|
+
// restarts. Ones nobody resumes are reaped by the next daemon's idle
|
|
467
|
+
// timer or orphan sweep.
|
|
465
468
|
const reports = [];
|
|
466
469
|
for (const [sessionId, runner] of this.services) {
|
|
467
|
-
runner
|
|
470
|
+
// The handoff file must describe the runner as it is NOW (pids, ports,
|
|
471
|
+
// warmed) — previewStart wrote it before the warm-up finished.
|
|
472
|
+
const prev = this.readPreviewSnapshots()[sessionId];
|
|
473
|
+
if (prev) this.writePreviewSnapshot(sessionId, { ...prev, runner: runner.snapshot(), savedAt: new Date().toISOString() });
|
|
474
|
+
runner.detach();
|
|
468
475
|
this.clearPreviewIdleTimer(sessionId);
|
|
469
|
-
reports.push(this.api.sessionPreview(sessionId, { status: "stopped", urls: [], previews: [], errorCode: "daemon_restarted", error: "kai-bridge restarted — start the preview again." }).catch(() => {}));
|
|
470
476
|
}
|
|
471
477
|
this.services.clear();
|
|
472
|
-
this.
|
|
473
|
-
void this.teardownLogins(() => true, "daemon stopping");
|
|
478
|
+
for (const w of this.prewarming?.values() ?? []) void w.close();
|
|
474
479
|
this.realtime?.disconnect?.();
|
|
475
480
|
this.releaseLock();
|
|
476
481
|
return Promise.race([Promise.allSettled(reports), new Promise((r) => setTimeout(r, 3_000).unref?.())]).then(() => undefined);
|
|
@@ -528,7 +533,7 @@ export class BridgeDaemon {
|
|
|
528
533
|
return join(this.kaiHome, "state", "inflight.json");
|
|
529
534
|
}
|
|
530
535
|
|
|
531
|
-
/** `[{ turnId, sessionId?, agent
|
|
536
|
+
/** `[{ turnId, sessionId?, agent? }]` — pre-0.5.0 files held bare ids. */
|
|
532
537
|
readInflight() {
|
|
533
538
|
try {
|
|
534
539
|
const raw = JSON.parse(readFileSync(this.inflightPath, "utf8"));
|
|
@@ -547,21 +552,18 @@ export class BridgeDaemon {
|
|
|
547
552
|
}
|
|
548
553
|
}
|
|
549
554
|
|
|
555
|
+
/** Merges: the runner pid and the verify artifacts dir arrive after the turn's session meta. */
|
|
550
556
|
rememberInflight(turnId, meta = {}) {
|
|
551
|
-
const
|
|
552
|
-
|
|
557
|
+
const all = this.readInflight();
|
|
558
|
+
const existing = all.find((e) => e.turnId === turnId);
|
|
559
|
+
this.writeInflight([...all.filter((e) => e.turnId !== turnId), { ...existing, turnId, ...meta }]);
|
|
553
560
|
}
|
|
554
561
|
|
|
555
562
|
forgetInflight(turnId) {
|
|
556
563
|
this.writeInflight(this.readInflight().filter((e) => e.turnId !== turnId));
|
|
557
564
|
}
|
|
558
565
|
|
|
559
|
-
/**
|
|
560
|
-
* Turns this machine was running when it was killed — report them dead.
|
|
561
|
-
* A verify turn also gets a `blocked` report ("kai-bridge restarted
|
|
562
|
-
* mid-run") carrying whatever evidence the browser had written, so the
|
|
563
|
-
* card shows the partial recording instead of spinning.
|
|
564
|
-
*/
|
|
566
|
+
/** Turns this machine was running when it was killed — report them dead. */
|
|
565
567
|
async reportInterruptedTurns() {
|
|
566
568
|
const entries = this.readInflight();
|
|
567
569
|
if (!entries.length) return;
|
|
@@ -569,16 +571,6 @@ export class BridgeDaemon {
|
|
|
569
571
|
for (const entry of entries) {
|
|
570
572
|
const { turnId } = entry;
|
|
571
573
|
this.log("warn", "turn.interrupted", { turnId, agent: entry.agent });
|
|
572
|
-
if (entry.agent === "kai-verifier") {
|
|
573
|
-
const uploads = entry.artifactsDir ? await this.uploadSweptEvidence(turnId, entry.artifactsDir) : { uploads: [], failed: 0 };
|
|
574
|
-
await this.api
|
|
575
|
-
.turnVerification(turnId, {
|
|
576
|
-
...buildVerificationPayload(null, uploads.uploads, { fallbackReason: "kai-bridge restarted mid-run", evidence: { failed: uploads.failed, ...(uploads.failed && entry.artifactsDir ? { kept: entry.artifactsDir } : {}) } }),
|
|
577
|
-
blockedCode: "other",
|
|
578
|
-
})
|
|
579
|
-
.catch((err) => this.log("warn", "verify.interrupted.report.failed", { turnId, error: err?.message }));
|
|
580
|
-
if (entry.artifactsDir && uploads.failed === 0) rmSync(entry.artifactsDir, { recursive: true, force: true });
|
|
581
|
-
}
|
|
582
574
|
await this.api
|
|
583
575
|
.turnResult(turnId, {
|
|
584
576
|
status: "failed",
|
|
@@ -611,11 +603,155 @@ export class BridgeDaemon {
|
|
|
611
603
|
}
|
|
612
604
|
}
|
|
613
605
|
|
|
614
|
-
|
|
615
|
-
|
|
606
|
+
get previewSnapshotsPath() {
|
|
607
|
+
return join(this.kaiHome, "state", "previews.json");
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
readPreviewSnapshots() {
|
|
611
|
+
try {
|
|
612
|
+
const raw = JSON.parse(readFileSync(this.previewSnapshotsPath, "utf8"));
|
|
613
|
+
return raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
|
|
614
|
+
} catch {
|
|
615
|
+
return {};
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
writePreviewSnapshot(sessionId, data) {
|
|
620
|
+
try {
|
|
621
|
+
mkdirSync(dirname(this.previewSnapshotsPath), { recursive: true });
|
|
622
|
+
const all = this.readPreviewSnapshots();
|
|
623
|
+
if (data) all[sessionId] = data;
|
|
624
|
+
else delete all[sessionId];
|
|
625
|
+
writeFileSync(this.previewSnapshotsPath, JSON.stringify(all), { mode: 0o600 });
|
|
626
|
+
} catch (err) {
|
|
627
|
+
this.log("warn", "preview.snapshot.failed", { sessionId, error: err?.message });
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Re-adopt the previews of the previous daemon life whose dev servers
|
|
633
|
+
* are still alive and listening (see ServiceRunner.resume). Runs BEFORE
|
|
634
|
+
* the orphan reaper and before the first hello — the Server is told once
|
|
635
|
+
* hello succeeded (`reportResumedPreviews`).
|
|
636
|
+
*/
|
|
637
|
+
async resumePreviews() {
|
|
638
|
+
const snapshots = this.readPreviewSnapshots();
|
|
639
|
+
this.resumedReports = [];
|
|
640
|
+
for (const [sessionId, snap] of Object.entries(snapshots)) {
|
|
641
|
+
const runner = this.runnerFor(sessionId);
|
|
642
|
+
let failed;
|
|
643
|
+
try {
|
|
644
|
+
failed = await runner.resume(snap.runner);
|
|
645
|
+
} catch (err) {
|
|
646
|
+
failed = [{ error: err?.message }];
|
|
647
|
+
}
|
|
648
|
+
const previewSvcs = (snap.runner?.services ?? []).filter((svc) => !svc.adopted);
|
|
649
|
+
if (failed.length || !previewSvcs.length) {
|
|
650
|
+
this.log("info", "preview.resume.skipped", { sessionId, failed });
|
|
651
|
+
runner.stopAll();
|
|
652
|
+
this.services.delete(sessionId);
|
|
653
|
+
this.writePreviewSnapshot(sessionId, null);
|
|
654
|
+
this.resumedReports.push({ sessionId, body: { status: "stopped", urls: [], previews: [], errorCode: "daemon_restarted", error: "kai-bridge restarted — start the preview again." } });
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
if (snap.manual) (this.manualPreviews ??= new Set()).add(sessionId);
|
|
658
|
+
this.armPreviewIdleTimer(sessionId);
|
|
659
|
+
this.log("info", "preview.resumed", { sessionId, services: previewSvcs.map((svc) => `${svc.name}:${svc.port}`), warmed: !!snap.runner?.warmed });
|
|
660
|
+
this.resumedReports.push({ sessionId, body: { status: "running", previews: snap.previews ?? [], urls: snap.urls ?? snap.previews ?? [], landingUrl: snap.landingUrl ?? null } });
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
async reportResumedPreviews() {
|
|
665
|
+
const reports = this.resumedReports ?? [];
|
|
666
|
+
this.resumedReports = [];
|
|
667
|
+
for (const { sessionId, body } of reports) await this.reportPreview(sessionId, body);
|
|
668
|
+
await this.flushPendingReports();
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* One preview report. A `stopped` / `error` that cannot be delivered (the
|
|
673
|
+
* laptop is offline — an idle stop on 2026-09-14 left the dashboard on
|
|
674
|
+
* "running" for a week) is kept in state/pending-reports.json and resent
|
|
675
|
+
* after the next successful heartbeat. Latest per session wins; a session
|
|
676
|
+
* that runs again drops its stale one.
|
|
677
|
+
*/
|
|
678
|
+
async reportPreview(sessionId, body) {
|
|
679
|
+
try {
|
|
680
|
+
await this.api.sessionPreview(sessionId, body);
|
|
681
|
+
return true;
|
|
682
|
+
} catch (err) {
|
|
683
|
+
this.log("warn", "preview.report.failed", { sessionId, error: err?.message });
|
|
684
|
+
if (body?.status === "stopped" || body?.status === "error") this.writePendingReport(sessionId, body);
|
|
685
|
+
return false;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
get pendingReportsPath() {
|
|
690
|
+
return join(this.kaiHome, "state", "pending-reports.json");
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
readPendingReports() {
|
|
694
|
+
try {
|
|
695
|
+
const raw = JSON.parse(readFileSync(this.pendingReportsPath, "utf8"));
|
|
696
|
+
return raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
|
|
697
|
+
} catch {
|
|
698
|
+
return {};
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
writePendingReport(sessionId, body) {
|
|
703
|
+
try {
|
|
704
|
+
mkdirSync(dirname(this.pendingReportsPath), { recursive: true });
|
|
705
|
+
const all = this.readPendingReports();
|
|
706
|
+
if (body) all[sessionId] = body;
|
|
707
|
+
else delete all[sessionId];
|
|
708
|
+
writeFileSync(this.pendingReportsPath, JSON.stringify(all), { mode: 0o600 });
|
|
709
|
+
} catch (err) {
|
|
710
|
+
this.log("warn", "preview.pending.failed", { sessionId, error: err?.message });
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
async flushPendingReports() {
|
|
715
|
+
for (const [sessionId, body] of Object.entries(this.readPendingReports())) {
|
|
716
|
+
if (this.services.has(sessionId)) {
|
|
717
|
+
this.writePendingReport(sessionId, null); // running again: the old stop is history
|
|
718
|
+
continue;
|
|
719
|
+
}
|
|
720
|
+
if (await this.reportPreview(sessionId, body)) {
|
|
721
|
+
this.writePendingReport(sessionId, null);
|
|
722
|
+
this.log("info", "preview.report.resent", { sessionId, status: body?.status });
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* ServiceRunner hook: every dev-server pid is persisted while it lives —
|
|
729
|
+
* with its port and the session's title/link, so `kai-bridge ps` can
|
|
730
|
+
* show a preview-only session without a turn to borrow the meta from.
|
|
731
|
+
*/
|
|
732
|
+
trackServicePid(sessionId, name, pid, op, { port = null } = {}) {
|
|
616
733
|
if (!Number.isInteger(pid)) return;
|
|
617
734
|
const rest = this.readPreviewPids().filter((e) => e.pid !== pid);
|
|
618
|
-
|
|
735
|
+
const session = this.sessionMeta?.get(sessionId) ?? {};
|
|
736
|
+
this.writePreviewPids(op === "add" ? [...rest, { pid, name, sessionId, port, ...session, startedAt: new Date().toISOString() }] : rest);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* Title, dashboard link and repo keys of a session this machine works
|
|
741
|
+
* on — copied into the inflight / preview-pid entries (ps.mjs reads
|
|
742
|
+
* them back). Remembered from turn starts and preview starts alike.
|
|
743
|
+
*/
|
|
744
|
+
rememberSession(sessionId, { title = null, sessionUrl = null, repos = [] } = {}) {
|
|
745
|
+
if (typeof sessionId !== "string") return {};
|
|
746
|
+
this.sessionMeta ??= new Map();
|
|
747
|
+
const prev = this.sessionMeta.get(sessionId) ?? {};
|
|
748
|
+
const meta = {
|
|
749
|
+
title: typeof title === "string" && title ? title : prev.title ?? null,
|
|
750
|
+
sessionUrl: typeof sessionUrl === "string" && sessionUrl ? sessionUrl : prev.sessionUrl ?? null,
|
|
751
|
+
repos: Array.isArray(repos) && repos.length ? repos.map((r) => (typeof r === "string" ? r : r?.key)).filter((k) => typeof k === "string") : prev.repos ?? [],
|
|
752
|
+
};
|
|
753
|
+
this.sessionMeta.set(sessionId, meta);
|
|
754
|
+
return meta;
|
|
619
755
|
}
|
|
620
756
|
|
|
621
757
|
/**
|
|
@@ -626,9 +762,12 @@ export class BridgeDaemon {
|
|
|
626
762
|
*/
|
|
627
763
|
killOrphanedServices({ kill = process.kill, platform = process.platform } = {}) {
|
|
628
764
|
const entries = this.readPreviewPids();
|
|
629
|
-
|
|
765
|
+
const keep = new Set();
|
|
766
|
+
for (const runner of this.services.values()) for (const pid of runner.resumedPids?.values() ?? []) keep.add(pid);
|
|
767
|
+
this.writePreviewPids(entries.filter((e) => keep.has(e.pid)));
|
|
630
768
|
let killed = 0;
|
|
631
769
|
for (const e of entries) {
|
|
770
|
+
if (keep.has(e.pid)) continue;
|
|
632
771
|
try {
|
|
633
772
|
kill(e.pid, 0); // alive?
|
|
634
773
|
} catch {
|
|
@@ -684,7 +823,7 @@ export class BridgeDaemon {
|
|
|
684
823
|
/* already gone */
|
|
685
824
|
}
|
|
686
825
|
this.connectRealtime();
|
|
687
|
-
}, REALTIME_RETRY_MS);
|
|
826
|
+
}, this.realtimeRetryMs ?? REALTIME_RETRY_MS);
|
|
688
827
|
}
|
|
689
828
|
}
|
|
690
829
|
|
|
@@ -700,6 +839,20 @@ export class BridgeDaemon {
|
|
|
700
839
|
this.log("warn", "reconnect.hello.failed", { error: err.message });
|
|
701
840
|
return;
|
|
702
841
|
}
|
|
842
|
+
await this.pullPendingWork("reconnect");
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* Ask the server what it still believes we're running and pick those
|
|
847
|
+
* turns up. Called after a reconnect and on
|
|
848
|
+
* a slow timer (`PENDING_POLL_MS`) — the timer is what saves a device
|
|
849
|
+
* whose channel subscription failed silently. Never runs twice at once,
|
|
850
|
+
* never while an update is replacing our files (the channel refuses
|
|
851
|
+
* turns then too).
|
|
852
|
+
*/
|
|
853
|
+
async pullPendingWork(via = "poll") {
|
|
854
|
+
if (this.stopped || this.updating || this.pullingPending) return;
|
|
855
|
+
this.pullingPending = true;
|
|
703
856
|
try {
|
|
704
857
|
const pending = await this.api.pendingTurns();
|
|
705
858
|
// Cancels the server settled while we were away (reaper / watchdog):
|
|
@@ -709,24 +862,21 @@ export class BridgeDaemon {
|
|
|
709
862
|
for (const turnId of cancelled) {
|
|
710
863
|
const run = this.running.get(turnId);
|
|
711
864
|
if (!run) continue;
|
|
712
|
-
this.log("info", "turn.cancel.replayed", { turnId });
|
|
865
|
+
this.log("info", "turn.cancel.replayed", { turnId, via });
|
|
713
866
|
run.ctrl.abort();
|
|
714
867
|
}
|
|
715
868
|
for (const turn of pending?.turns || []) {
|
|
716
869
|
if (this.running.has(turn.turnId) || cancelled.has(String(turn.turnId))) continue;
|
|
717
|
-
this.log("info", "turn.recovered", { turnId: turn.turnId });
|
|
870
|
+
this.log("info", "turn.recovered", { turnId: turn.turnId, via });
|
|
718
871
|
void this.startTurn(turn).catch((err) => this.log("error", "turn.recover.failed", { error: err.message }));
|
|
719
872
|
}
|
|
720
|
-
// Sign-in requests published while we were away: open their windows now.
|
|
721
|
-
for (const req of pending?.loginRequests || []) {
|
|
722
|
-
if (!req?.requestId || this.logins.has(req.requestId)) continue;
|
|
723
|
-
this.log("info", "verify.login.recovered", { requestId: req.requestId });
|
|
724
|
-
void this.loginStart(req).catch((err) => this.log("error", "verify.login.recover.failed", { error: err.message }));
|
|
725
|
-
}
|
|
726
873
|
} catch (err) {
|
|
727
|
-
// Older server without the endpoint
|
|
728
|
-
// fails orphaned turns, so this is a
|
|
729
|
-
|
|
874
|
+
// Older server without the endpoint, or the server is restarting: the
|
|
875
|
+
// server-side sweep still fails orphaned turns, so this is a
|
|
876
|
+
// nice-to-have, not a must.
|
|
877
|
+
this.log("debug", "pending.unavailable", { via, error: err.message });
|
|
878
|
+
} finally {
|
|
879
|
+
this.pullingPending = false;
|
|
730
880
|
}
|
|
731
881
|
}
|
|
732
882
|
|
|
@@ -810,30 +960,33 @@ export class BridgeDaemon {
|
|
|
810
960
|
entry.ctrl.abort();
|
|
811
961
|
}
|
|
812
962
|
}
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
//
|
|
817
|
-
|
|
963
|
+
// The dashboard's Stop (`reason: "stopped"`) keeps a live preview: the
|
|
964
|
+
// user may come back to it, and a warm dev server is minutes of
|
|
965
|
+
// compile. The idle reaper ends it when nobody comes back.
|
|
966
|
+
// Merged / deleted sessions (and pre-reason Servers) stop it now.
|
|
967
|
+
if (data.reason === "stopped" && this.services.has(data.sessionId)) {
|
|
968
|
+
this.log("info", "preview.kept.session_stopped", { sessionId: data.sessionId });
|
|
969
|
+
this.armPreviewIdleTimer(data.sessionId);
|
|
970
|
+
} else {
|
|
971
|
+
this.services.get(data.sessionId)?.stopAll();
|
|
972
|
+
this.services.delete(data.sessionId);
|
|
973
|
+
this.clearPreviewIdleTimer(data.sessionId);
|
|
974
|
+
this.writePreviewSnapshot(data.sessionId, null);
|
|
975
|
+
void this.tunnelManager?.removeSession(data.sessionId);
|
|
976
|
+
}
|
|
818
977
|
// Pre-0.4.0 daemons kept a browser profile per session; drop leftovers.
|
|
819
978
|
rmSync(join(this.kaiHome, "browser", String(data.sessionId).replace(/[^\w.-]/g, "_")), { recursive: true, force: true });
|
|
820
979
|
return;
|
|
821
|
-
case "bridge.verify.login.start":
|
|
822
|
-
return this.loginStart(data);
|
|
823
|
-
case "bridge.verify.login.done":
|
|
824
|
-
return this.loginDone(data);
|
|
825
|
-
case "bridge.verify.login.cancel":
|
|
826
|
-
return this.loginCancel(data);
|
|
827
|
-
case "bridge.verify.evidence.retry":
|
|
828
|
-
return this.retryEvidence(data);
|
|
829
980
|
case "bridge.preview.start":
|
|
830
|
-
// A preview the USER started stays up until they stop it (or it
|
|
831
|
-
// idles out); one that Verify booted is released after the run.
|
|
981
|
+
// A preview the USER started stays up until they stop it (or it idles out).
|
|
832
982
|
(this.manualPreviews ??= new Set()).add(data.sessionId);
|
|
833
|
-
this.verifyOwnedPreviews?.delete(data.sessionId);
|
|
834
983
|
return this.previewStart(data);
|
|
835
984
|
case "bridge.preview.stop":
|
|
836
985
|
return this.previewStop(data);
|
|
986
|
+
case "bridge.preview.publish":
|
|
987
|
+
return this.previewPublish(data);
|
|
988
|
+
case "bridge.preview.unpublish":
|
|
989
|
+
return this.previewUnpublish(data);
|
|
837
990
|
case "bridge.preview.keepalive":
|
|
838
991
|
// Dashboard heartbeat while someone is actually LOOKING at the
|
|
839
992
|
// preview — makes the idle auto-stop mean real idleness instead
|
|
@@ -856,19 +1009,23 @@ export class BridgeDaemon {
|
|
|
856
1009
|
* truncation. `companionRemotes` (Server-resolved `{ key: remote }`) is
|
|
857
1010
|
* how non-github companions get cloned; `note` survives onto error writes.
|
|
858
1011
|
*/
|
|
859
|
-
async previewStart({ sessionId, title, repos, companionRemotes = null }) {
|
|
1012
|
+
async previewStart({ sessionId, title, repos, sessionUrl = null, companionRemotes = null, publicMode = false }) {
|
|
1013
|
+
this.rememberSession(sessionId, { title, sessionUrl, repos });
|
|
860
1014
|
let lastNote = null;
|
|
861
1015
|
const skipped = [];
|
|
862
|
-
// Every report is also RETURNED
|
|
863
|
-
// through this same path and needs the final status + preview URLs.
|
|
1016
|
+
// Every report is also RETURNED (callers and tests read the final status + preview URLs).
|
|
864
1017
|
const report = async (payload) => {
|
|
865
1018
|
if (payload.note) lastNote = payload.note;
|
|
866
|
-
const body =
|
|
867
|
-
|
|
1019
|
+
const body =
|
|
1020
|
+
payload.status === "error"
|
|
1021
|
+
? { ...payload, urls: payload.urls ?? [], previews: payload.previews ?? [], ...(lastNote && !payload.note ? { note: lastNote } : {}), ...(skipped.length && !payload.skipped ? { skipped } : {}) }
|
|
1022
|
+
: // A note-only `running` tick must not wipe the skipped rows the boot reported.
|
|
1023
|
+
{ ...payload, ...(skipped.length && !payload.skipped ? { skipped } : {}) };
|
|
1024
|
+
await this.reportPreview(sessionId, body);
|
|
868
1025
|
return body;
|
|
869
1026
|
};
|
|
870
1027
|
const fail = (err, ctx = {}) => report(toPreviewErrorPayload(err, { ...ctx, skipped }));
|
|
871
|
-
await report({ status: "starting" });
|
|
1028
|
+
await report({ status: "starting", ...(publicMode ? { note: "Restarting with the public address…" } : {}) });
|
|
872
1029
|
let runner = null;
|
|
873
1030
|
try {
|
|
874
1031
|
// Pass 1 — resolve every session repo (checkout, env, config).
|
|
@@ -921,6 +1078,7 @@ export class BridgeDaemon {
|
|
|
921
1078
|
const remotes = companionRemotes && typeof companionRemotes === "object" ? companionRemotes : {};
|
|
922
1079
|
const { companions } = await collectCompanions({
|
|
923
1080
|
roots: resolved.map((r) => ({ key: r.key, config: r.config })),
|
|
1081
|
+
extra: this.reverseCompanions(resolved),
|
|
924
1082
|
maxDepth: COMPANION_MAX_DEPTH,
|
|
925
1083
|
loadConfig: async (key) => {
|
|
926
1084
|
try {
|
|
@@ -949,11 +1107,38 @@ export class BridgeDaemon {
|
|
|
949
1107
|
}
|
|
950
1108
|
const bootable = companions.filter((c) => c.config);
|
|
951
1109
|
|
|
1110
|
+
// Public mode: one hostname per service (own repos + companions) from
|
|
1111
|
+
// the Server, which also decides which other public session loses its
|
|
1112
|
+
// link on this device — stopped here, before this one boots.
|
|
1113
|
+
let publicHosts = null;
|
|
1114
|
+
if (publicMode) {
|
|
1115
|
+
await report({ status: "starting", note: "Getting the public address…" });
|
|
1116
|
+
const services = [
|
|
1117
|
+
...bootable.flatMap((c) => Object.values(c.config.services).map((s) => ({ repoKey: c.key, service: s.name, sessionRepo: false }))),
|
|
1118
|
+
...resolved.flatMap((r) => Object.values(r.config.services).map((s) => ({ repoKey: r.key, service: s.name, sessionRepo: true }))),
|
|
1119
|
+
];
|
|
1120
|
+
const answer = await this.api.publicHosts({ sessionId, services });
|
|
1121
|
+
for (const id of answer?.displaced || []) if (id !== sessionId) await this.previewStop({ sessionId: id });
|
|
1122
|
+
publicHosts = {};
|
|
1123
|
+
for (const h of answer?.hosts || []) (publicHosts[String(h.repoKey).toLowerCase()] ??= {})[h.service] = h.hostname;
|
|
1124
|
+
this.tunnel.ensureRunning(answer.tunnel);
|
|
1125
|
+
}
|
|
1126
|
+
const hostsFor = (key) => publicHosts?.[String(key).toLowerCase()] ?? null;
|
|
1127
|
+
|
|
952
1128
|
runner = this.runnerFor(sessionId);
|
|
953
1129
|
// Pass 3 — ports for EVERY config (companions + session repos) before
|
|
954
1130
|
// any boot, so `${port:x}` cross-references resolve whatever the order.
|
|
955
|
-
for (const c of bootable) await runner.assignPorts(c.group.primary.path, c.config, { mode: "local", repoKey: c.key });
|
|
956
|
-
for (const r of resolved) await runner.assignPorts(r.cwd, r.config, { mode: r.mode, repoKey: r.key, envSource: r.envSource });
|
|
1131
|
+
for (const c of bootable) await runner.assignPorts(c.group.primary.path, c.config, { mode: "local", repoKey: c.key, publicHosts: hostsFor(c.key) });
|
|
1132
|
+
for (const r of resolved) await runner.assignPorts(r.cwd, r.config, { mode: r.mode, repoKey: r.key, envSource: r.envSource, publicHosts: hostsFor(r.key) });
|
|
1133
|
+
// The public addresses answer as soon as the dev servers do: routes go
|
|
1134
|
+
// in BEFORE the boot (cloudflared → the service's gateway port).
|
|
1135
|
+
if (publicMode) {
|
|
1136
|
+
const routes = Object.entries(runner.publicHosts || {})
|
|
1137
|
+
.filter(([name]) => runner.ports[name])
|
|
1138
|
+
.map(([name, hostname]) => ({ hostname, port: runner.ports[name], protocol: runner.meta.get(name)?.protocol || "http" }));
|
|
1139
|
+
await this.tunnel.setRoutes(sessionId, routes);
|
|
1140
|
+
await report({ status: "starting", note: "Public address ready. Starting the services…" });
|
|
1141
|
+
}
|
|
957
1142
|
|
|
958
1143
|
// Pass 4 — boot companions (deepest first, local mode: an already
|
|
959
1144
|
// running dev server of that repo is adopted), then the session repos
|
|
@@ -984,19 +1169,189 @@ export class BridgeDaemon {
|
|
|
984
1169
|
}
|
|
985
1170
|
}
|
|
986
1171
|
const urls = [...previews, ...companionPreviews];
|
|
987
|
-
// The landing page
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
1172
|
+
// The landing page: the session repo's web preview, else the first web
|
|
1173
|
+
// preview at all (a Server ticket lands on its Frontend companion),
|
|
1174
|
+
// else whatever the session repo serves.
|
|
1175
|
+
const landing = previews.find((p) => p.kind !== "api") ?? urls.find((p) => p.kind !== "api") ?? previews[0] ?? companionPreviews[0] ?? null;
|
|
1176
|
+
const running = { status: "running", previews: urls, urls, landingUrl: landing?.url ?? null, ...(skipped.length ? { skipped } : {}) };
|
|
1177
|
+
// `running` goes out as soon as the dev servers answer — BEFORE the
|
|
1178
|
+
// warm-up: the Server's booting deadline (3 min) and the dashboard
|
|
1179
|
+
// must not wait out a cold compile (10 min on a big Vite app). The
|
|
1180
|
+
// gateway keeps holding document requests on the "starting" page
|
|
1181
|
+
// until the app is warm, so nobody actually lands on a cold server.
|
|
1182
|
+
await report(running);
|
|
1183
|
+
this.writePreviewSnapshot(sessionId, { previews: urls, urls, landingUrl: landing?.url ?? null, manual: !!this.manualPreviews?.has(sessionId), runner: runner.snapshot(), savedAt: new Date().toISOString() });
|
|
1184
|
+
// Warm the app ONCE before anyone is let in: a cold dev server compiles
|
|
1185
|
+
// its whole module graph on the first page load. Done here,
|
|
1186
|
+
// sequentially, the agent and the user's tabs all arrive at a warm
|
|
1187
|
+
// server instead of stampeding a cold one. Progress rides along as a
|
|
1188
|
+
// `running` note; the same body again once warm clears the note.
|
|
1189
|
+
const gen = this.bumpPreviewGeneration(sessionId);
|
|
1190
|
+
return this.warmPreview(sessionId, landing, runner, report).then((warmed) => {
|
|
1191
|
+
// Stopped or replaced while warming: nothing to re-announce, no idle
|
|
1192
|
+
// timer for a runner that is gone — and the awaiting caller must
|
|
1193
|
+
// never take the pre-stop body as a live preview.
|
|
1194
|
+
if (this.services.get(sessionId) !== runner) {
|
|
1195
|
+
return { status: "stopped", urls: [], previews: [], error: "The preview was stopped while it was starting." };
|
|
1196
|
+
}
|
|
1197
|
+
this.armPreviewIdleTimer(sessionId);
|
|
1198
|
+
// Made public (or local) while warming: that path announced the current
|
|
1199
|
+
// addresses; re-announcing THIS body would roll them back.
|
|
1200
|
+
return warmed && this.previewGeneration.get(sessionId) === gen ? report(running) : running;
|
|
1201
|
+
});
|
|
991
1202
|
} catch (err) {
|
|
992
1203
|
this.log("error", "preview.start.failed", { sessionId, error: err.message, code: err?.code });
|
|
993
1204
|
runner?.stopAll();
|
|
994
1205
|
this.services.delete(sessionId);
|
|
995
1206
|
this.clearPreviewIdleTimer(sessionId);
|
|
1207
|
+
if (publicMode) await this.tunnel.removeSession(sessionId);
|
|
996
1208
|
return fail(err);
|
|
997
1209
|
}
|
|
998
1210
|
}
|
|
999
1211
|
|
|
1212
|
+
/**
|
|
1213
|
+
* Which announcement of a session's addresses is current. A warm-up that
|
|
1214
|
+
* finishes after "Make public" swapped the addresses must not re-announce
|
|
1215
|
+
* the pre-publish body (seen 2026-09-21: the public URLs vanished 3 s after
|
|
1216
|
+
* they appeared).
|
|
1217
|
+
*/
|
|
1218
|
+
bumpPreviewGeneration(sessionId) {
|
|
1219
|
+
this.previewGeneration ??= new Map();
|
|
1220
|
+
const gen = (this.previewGeneration.get(sessionId) ?? 0) + 1;
|
|
1221
|
+
this.previewGeneration.set(sessionId, gen);
|
|
1222
|
+
return gen;
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
/** Lazily built: embedded hosts and tests build a daemon without the constructor. */
|
|
1226
|
+
get tunnel() {
|
|
1227
|
+
return (this.tunnelManager ??= new TunnelManager({ kaiHome: this.kaiHome, log: (...a) => this.log(...a) }));
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
/**
|
|
1231
|
+
* "Make public": the preview re-boots with the public addresses (the web
|
|
1232
|
+
* bundle bakes the API origin in at boot). Needs `cloudflared` — on PATH,
|
|
1233
|
+
* downloaded before, or downloaded now; else the dashboard gets a
|
|
1234
|
+
* `dependency_missing` card with the install hint and a Retry.
|
|
1235
|
+
*/
|
|
1236
|
+
async previewPublish(data) {
|
|
1237
|
+
const { sessionId } = data;
|
|
1238
|
+
(this.manualPreviews ??= new Set()).add(sessionId);
|
|
1239
|
+
try {
|
|
1240
|
+
// `this.resolveCloudflared` is injectable for tests (the default may download).
|
|
1241
|
+
this.tunnel.binary = await (this.resolveCloudflared ?? resolveCloudflared)({ kaiHome: this.kaiHome, device: this.config?.device?.name || "this device", log: (...a) => this.log(...a) });
|
|
1242
|
+
} catch (err) {
|
|
1243
|
+
await this.reportPreview(sessionId, toPreviewErrorPayload(err));
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
this.rememberSession(sessionId, data);
|
|
1247
|
+
// A running preview goes public IN PLACE; nothing running → a normal boot in public mode.
|
|
1248
|
+
if (this.services.has(sessionId) && this.readPreviewSnapshots()[sessionId]) return this.previewRepublish({ sessionId, makePublic: true });
|
|
1249
|
+
return this.previewStart({ ...data, publicMode: true });
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
/** "Stop sharing": back to local addresses, the preview keeps running. Nothing running → a plain stop. */
|
|
1253
|
+
async previewUnpublish({ sessionId }) {
|
|
1254
|
+
if (this.services.has(sessionId) && this.readPreviewSnapshots()[sessionId]) return this.previewRepublish({ sessionId, makePublic: false });
|
|
1255
|
+
return this.previewStop({ sessionId });
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
/**
|
|
1259
|
+
* Switch a LIVE preview between local and public addresses. Routes go in
|
|
1260
|
+
* first, so the link answers at once (the gateway's "starting" page while
|
|
1261
|
+
* the web app restarts); then only the services that bake a `${url:…}`
|
|
1262
|
+
* restart (`ServiceRunner.setPublicHosts`) — an API or an adopted dev
|
|
1263
|
+
* server is not touched. Measured 2026-09-21: a full re-boot of web + a
|
|
1264
|
+
* worktree Server took ~85 s, the web restart alone ~30 s.
|
|
1265
|
+
*/
|
|
1266
|
+
async previewRepublish({ sessionId, makePublic }) {
|
|
1267
|
+
// Two flips of one session must never overlap: on 2026-09-21 a doubled
|
|
1268
|
+
// "Make public" (19 ms apart) had the second flip stop the web app the
|
|
1269
|
+
// first had just restarted, and the preview died as "did not come
|
|
1270
|
+
// back". The same direction joins the running flip; the opposite waits.
|
|
1271
|
+
const flips = (this.republishing ??= new Map());
|
|
1272
|
+
const current = flips.get(sessionId);
|
|
1273
|
+
if (current?.makePublic === makePublic) return current.promise;
|
|
1274
|
+
if (current) {
|
|
1275
|
+
await current.promise.catch(() => {});
|
|
1276
|
+
if (!this.services.has(sessionId)) return;
|
|
1277
|
+
}
|
|
1278
|
+
const promise = this.runPreviewRepublish({ sessionId, makePublic }).finally(() => {
|
|
1279
|
+
if (flips.get(sessionId)?.promise === promise) flips.delete(sessionId);
|
|
1280
|
+
});
|
|
1281
|
+
flips.set(sessionId, { makePublic, promise });
|
|
1282
|
+
return promise;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
async runPreviewRepublish({ sessionId, makePublic }) {
|
|
1286
|
+
const runner = this.services.get(sessionId);
|
|
1287
|
+
const snap = this.readPreviewSnapshots()[sessionId];
|
|
1288
|
+
const report = (body) => this.reportPreview(sessionId, body).then(() => body);
|
|
1289
|
+
this.clearPreviewIdleTimer(sessionId);
|
|
1290
|
+
// A warm-up still running from the start (a cold Vite compile) ticks
|
|
1291
|
+
// `running` with the LOCAL urls every 20 s — mid-flip that painted a
|
|
1292
|
+
// "Public link" card with no link (2026-09-21). Retire it: a new
|
|
1293
|
+
// generation mutes its ticks, and closing its browser ends it.
|
|
1294
|
+
this.bumpPreviewGeneration(sessionId);
|
|
1295
|
+
this.prewarming?.get(sessionId)?.close?.();
|
|
1296
|
+
await report({ status: "starting", previews: snap.urls ?? snap.previews ?? [], note: makePublic ? "Getting the public address…" : "Switching back to local addresses…" });
|
|
1297
|
+
try {
|
|
1298
|
+
let hosts = null;
|
|
1299
|
+
if (makePublic) {
|
|
1300
|
+
const sessionKeys = new Set((this.sessionMeta?.get(sessionId)?.repos || []).map((k) => String(k).toLowerCase()));
|
|
1301
|
+
const services = [...runner.meta].filter(([, m]) => m.repoKey).map(([name, m]) => ({ repoKey: m.repoKey, service: name, sessionRepo: sessionKeys.has(String(m.repoKey).toLowerCase()) }));
|
|
1302
|
+
const answer = await this.api.publicHosts({ sessionId, services });
|
|
1303
|
+
for (const id of answer?.displaced || []) if (id !== sessionId) await this.previewStop({ sessionId: id });
|
|
1304
|
+
hosts = Object.fromEntries((answer?.hosts || []).map((h) => [h.service, h.hostname]));
|
|
1305
|
+
this.tunnel.ensureRunning(answer.tunnel);
|
|
1306
|
+
const routes = Object.entries(hosts).filter(([name]) => runner.ports[name]).map(([name, hostname]) => ({ hostname, port: runner.ports[name], protocol: runner.meta.get(name)?.protocol || "http" }));
|
|
1307
|
+
await this.tunnel.setRoutes(sessionId, routes);
|
|
1308
|
+
await report({ status: "starting", note: "Public address ready. Restarting the web app with it…" });
|
|
1309
|
+
} else {
|
|
1310
|
+
await this.tunnelManager?.removeSession(sessionId);
|
|
1311
|
+
}
|
|
1312
|
+
await runner.setPublicHosts(hosts);
|
|
1313
|
+
const urls = (snap.urls ?? snap.previews ?? []).map(({ publicUrl: _old, ...p }) => (hosts?.[p.name] ? { ...p, publicUrl: `https://${hosts[p.name]}` } : p));
|
|
1314
|
+
const landing = urls.find((p) => p.kind !== "api") ?? urls[0] ?? null;
|
|
1315
|
+
const running = { status: "running", previews: urls, urls, landingUrl: landing?.url ?? null };
|
|
1316
|
+
await report(running);
|
|
1317
|
+
this.writePreviewSnapshot(sessionId, { ...snap, previews: urls, urls, runner: runner.snapshot(), savedAt: new Date().toISOString() });
|
|
1318
|
+
const gen = this.bumpPreviewGeneration(sessionId);
|
|
1319
|
+
return this.warmPreview(sessionId, landing, runner, report).then((warmed) => {
|
|
1320
|
+
if (this.services.get(sessionId) !== runner) return { status: "stopped", urls: [], previews: [] };
|
|
1321
|
+
this.armPreviewIdleTimer(sessionId);
|
|
1322
|
+
return warmed && this.previewGeneration.get(sessionId) === gen ? report(running) : running;
|
|
1323
|
+
});
|
|
1324
|
+
} catch (err) {
|
|
1325
|
+
this.log("error", "preview.republish.failed", { sessionId, makePublic, error: err.message, code: err?.code });
|
|
1326
|
+
runner.stopAll();
|
|
1327
|
+
this.services.delete(sessionId);
|
|
1328
|
+
this.writePreviewSnapshot(sessionId, null);
|
|
1329
|
+
await this.tunnelManager?.removeSession(sessionId);
|
|
1330
|
+
return report(toPreviewErrorPayload(err));
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
/**
|
|
1335
|
+
* Repos on this device whose dev config names a session repo as a
|
|
1336
|
+
* companion and that have a web preview: booted alongside as optional
|
|
1337
|
+
* companions (a Server ticket gets its Frontend, pointed at the ticket's
|
|
1338
|
+
* Server), unless they opt out with `reverseCompanions: false`. Only
|
|
1339
|
+
* checkouts already here — never cloned for this.
|
|
1340
|
+
*/
|
|
1341
|
+
reverseCompanions(resolved) {
|
|
1342
|
+
const sessionKeys = new Set(resolved.map((r) => String(r.key).toLowerCase()));
|
|
1343
|
+
const out = [];
|
|
1344
|
+
for (const group of this.repoGroups || []) {
|
|
1345
|
+
const key = String(group.key).toLowerCase();
|
|
1346
|
+
if (sessionKeys.has(key) || !group.primary?.path) continue;
|
|
1347
|
+
const config = readDevConfig(group.primary.path);
|
|
1348
|
+
if (!config || config.error || config.reverseCompanions === false || !config.preview) continue;
|
|
1349
|
+
if (config.services[config.preview]?.kind === "api") continue;
|
|
1350
|
+
if (config.companions.some((c) => sessionKeys.has(c.repo))) out.push({ repo: key, optional: true });
|
|
1351
|
+
}
|
|
1352
|
+
return out;
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1000
1355
|
/**
|
|
1001
1356
|
* Config resolution order: this worktree's own config, then the PRIMARY
|
|
1002
1357
|
* checkout's (a repo verified by the setup agent has its dev.yaml on an
|
|
@@ -1037,7 +1392,7 @@ export class BridgeDaemon {
|
|
|
1037
1392
|
|
|
1038
1393
|
/**
|
|
1039
1394
|
* The session's worktree for a repo: the exact slug when the title is
|
|
1040
|
-
* known; otherwise (
|
|
1395
|
+
* known; otherwise (a command that carries only the session id) the one
|
|
1041
1396
|
* directory under `~/.kai/worktrees/<repo>/` that ends in the session's
|
|
1042
1397
|
* id suffix — slugs are `<title>-<last 8 of the id>`, unique per session.
|
|
1043
1398
|
*/
|
|
@@ -1060,8 +1415,8 @@ export class BridgeDaemon {
|
|
|
1060
1415
|
* `PreviewError` carrying the classified log line + code when a service
|
|
1061
1416
|
* never becomes ready — a "running" preview must not 404.
|
|
1062
1417
|
*/
|
|
1063
|
-
async bootRepoWithConfig(runner, cwd, config, mode, repoKey = null
|
|
1064
|
-
const started = await runner.start(cwd, config, { mode, repoKey
|
|
1418
|
+
async bootRepoWithConfig(runner, cwd, config, mode, repoKey = null) {
|
|
1419
|
+
const started = await runner.start(cwd, config, { mode, repoKey });
|
|
1065
1420
|
const dead = started.services.find((s) => !s.adopted && !s.ready);
|
|
1066
1421
|
if (dead) {
|
|
1067
1422
|
throw new PreviewError(`${dead.name} did not start${dead.error ? ` — ${dead.error}` : ""}`, {
|
|
@@ -1139,15 +1494,100 @@ export class BridgeDaemon {
|
|
|
1139
1494
|
}
|
|
1140
1495
|
runner.stopAll();
|
|
1141
1496
|
this.services.delete(sessionId);
|
|
1497
|
+
this.writePreviewSnapshot(sessionId, null);
|
|
1498
|
+
void this.tunnelManager?.removeSession(sessionId);
|
|
1142
1499
|
this.log("info", "preview.idle.stopped", { sessionId });
|
|
1143
|
-
this.
|
|
1144
|
-
.sessionPreview(sessionId, { status: "stopped", urls: [], previews: [], errorCode: "idle_stopped", error: "Stopped automatically after 30 minutes." })
|
|
1145
|
-
.catch((err) => this.log("warn", "preview.report.failed", { error: err.message }));
|
|
1500
|
+
void this.reportPreview(sessionId, { status: "stopped", urls: [], previews: [], errorCode: "idle_stopped", error: "Stopped automatically after 30 minutes." });
|
|
1146
1501
|
}, this.previewIdleMs ?? 30 * 60 * 1000);
|
|
1147
1502
|
t.unref?.();
|
|
1148
1503
|
this.previewIdleTimers.set(sessionId, t);
|
|
1149
1504
|
}
|
|
1150
1505
|
|
|
1506
|
+
/**
|
|
1507
|
+
* The warm-up gate. Loads the landing page once, headless, BEFORE the
|
|
1508
|
+
* preview is reported running. While it runs, the service's gateway holds
|
|
1509
|
+
* browser tabs on a reloading "Kai is starting the preview" page (they
|
|
1510
|
+
* would otherwise pile onto the cold compile), and the dashboard shows
|
|
1511
|
+
* the elapsed time. Bounded by PREWARM_TIMEOUT_MS; a timeout is logged
|
|
1512
|
+
* and the preview still opens. Skipped for a runner already warmed, on
|
|
1513
|
+
* a device without a browser, and for previews with no web landing page.
|
|
1514
|
+
*/
|
|
1515
|
+
async warmPreview(sessionId, landing, runner, report = async () => {}) {
|
|
1516
|
+
// Single-flight per session: two callers arriving while the app compiles share one run.
|
|
1517
|
+
this.prewarming ??= new Map();
|
|
1518
|
+
const inFlight = this.prewarming.get(sessionId);
|
|
1519
|
+
if (inFlight?.promise) return inFlight.promise;
|
|
1520
|
+
const entry = { close: () => {}, promise: null };
|
|
1521
|
+
this.prewarming.set(sessionId, entry);
|
|
1522
|
+
entry.promise = this.warmPreviewOnce(sessionId, landing, runner, report, entry).finally(() => {
|
|
1523
|
+
if (this.prewarming?.get(sessionId) === entry) this.prewarming.delete(sessionId);
|
|
1524
|
+
});
|
|
1525
|
+
return entry.promise;
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
async warmPreviewOnce(sessionId, landing, runner, report, entry) {
|
|
1529
|
+
const url = landing?.url ?? null;
|
|
1530
|
+
if (!url || !runner || runner.warmed || this.stopped) return false;
|
|
1531
|
+
// A flip (make public / stop sharing) bumps the generation: this warm-up
|
|
1532
|
+
// then belongs to a web app that is being replaced — no more reports.
|
|
1533
|
+
const gen = this.previewGeneration?.get(sessionId) ?? 0;
|
|
1534
|
+
const current = () => !this.stopped && this.services.get(sessionId) === runner && (this.previewGeneration?.get(sessionId) ?? 0) === gen;
|
|
1535
|
+
const pw = this.loadPlaywright();
|
|
1536
|
+
if (typeof pw?.chromium?.launch !== "function") return false;
|
|
1537
|
+
const browser = await this.ensurePreviewBrowser().catch(() => null);
|
|
1538
|
+
if (!browser?.ok) return false;
|
|
1539
|
+
const gateway = landing?.name ? runner.gatewayFor?.(landing.name) : null;
|
|
1540
|
+
const startedAt = Date.now();
|
|
1541
|
+
const elapsed = () => {
|
|
1542
|
+
const sec = Math.round((Date.now() - startedAt) / 1000);
|
|
1543
|
+
return sec >= 60 ? `${Math.floor(sec / 60)}m ${String(sec % 60).padStart(2, "0")}s` : `${sec}s`;
|
|
1544
|
+
};
|
|
1545
|
+
const noteFor = () => `Compiling the app for the first time (${elapsed()})… a cold dev server can take a few minutes; later starts are fast.`;
|
|
1546
|
+
// The preview is already reported `running` (urls up) — the note rides
|
|
1547
|
+
// on that status so it never regresses to `starting`.
|
|
1548
|
+
const tick = async () => {
|
|
1549
|
+
if (!current()) return; // never overtake a `stopped` report, nor a flip in progress
|
|
1550
|
+
const note = noteFor();
|
|
1551
|
+
gateway?.hold(note);
|
|
1552
|
+
await report({ status: "running", note });
|
|
1553
|
+
};
|
|
1554
|
+
await tick();
|
|
1555
|
+
const ticker = setInterval(() => void tick(), 20_000);
|
|
1556
|
+
ticker.unref?.();
|
|
1557
|
+
let instance = null;
|
|
1558
|
+
entry.close = () => instance?.close().catch(() => {});
|
|
1559
|
+
try {
|
|
1560
|
+
instance = await pw.chromium.launch(launchOptionsFor({ headless: true, browser: browser.browser === "chrome" ? "chrome" : null }));
|
|
1561
|
+
const context = await instance.newContext({ extraHTTPHeaders: { [WARMUP_HEADER]: "1" } });
|
|
1562
|
+
const page = await context.newPage();
|
|
1563
|
+
// `domcontentloaded` is the point where every statically imported
|
|
1564
|
+
// module has been transformed and run — that is the compile the warm-up
|
|
1565
|
+
// exists to pay for. `load` additionally waits for fonts, images and
|
|
1566
|
+
// late chunks, which on a big dev build pushed a cold warm-up past its
|
|
1567
|
+
// whole budget (measured 2026-09-15: 10 min → "Timeout exceeded").
|
|
1568
|
+
await page.goto(url, { waitUntil: "domcontentloaded", timeout: PREWARM_TIMEOUT_MS });
|
|
1569
|
+
// Let lazy chunks and the app's first API calls land too.
|
|
1570
|
+
await new Promise((r) => setTimeout(r, 5_000));
|
|
1571
|
+
// Stopped, replaced or torn down while we waited: stop() has already
|
|
1572
|
+
// written the handoff snapshot from the live runner — a rewrite from a
|
|
1573
|
+
// detached one would record no services and the next daemon would
|
|
1574
|
+
// reap the warm servers as orphans.
|
|
1575
|
+
if (!current()) return false;
|
|
1576
|
+
runner.warmed = true;
|
|
1577
|
+
this.log("info", "preview.warmed", { sessionId, ms: Date.now() - startedAt });
|
|
1578
|
+
// A daemon restart re-adopts this preview as warm (no second gate).
|
|
1579
|
+
const prev = this.readPreviewSnapshots()[sessionId];
|
|
1580
|
+
if (prev) this.writePreviewSnapshot(sessionId, { ...prev, runner: runner.snapshot(), savedAt: new Date().toISOString() });
|
|
1581
|
+
} catch (err) {
|
|
1582
|
+
this.log("warn", "preview.warm.failed", { sessionId, ms: Date.now() - startedAt, error: String(err?.message || err).split("\n")[0] });
|
|
1583
|
+
} finally {
|
|
1584
|
+
clearInterval(ticker);
|
|
1585
|
+
gateway?.release();
|
|
1586
|
+
await instance?.close().catch(() => {});
|
|
1587
|
+
}
|
|
1588
|
+
return true;
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1151
1591
|
/** Any ESTABLISHED connection on one of the runner's (non-adopted) ports? Injectable for tests. */
|
|
1152
1592
|
async previewHasConnections(runner) {
|
|
1153
1593
|
const count = this.countConnections ?? ((port) => establishedConnections(port));
|
|
@@ -1166,13 +1606,12 @@ export class BridgeDaemon {
|
|
|
1166
1606
|
|
|
1167
1607
|
async previewStop({ sessionId }) {
|
|
1168
1608
|
this.manualPreviews?.delete(sessionId);
|
|
1169
|
-
this.verifyOwnedPreviews?.delete(sessionId);
|
|
1170
|
-
// A sign-in window for this session has nothing left to sign in to.
|
|
1171
|
-
await this.teardownLogins((l) => l.sessionId === sessionId, "preview stopped");
|
|
1172
1609
|
this.services.get(sessionId)?.stopAll();
|
|
1173
1610
|
this.services.delete(sessionId);
|
|
1174
1611
|
this.clearPreviewIdleTimer(sessionId);
|
|
1175
|
-
|
|
1612
|
+
this.writePreviewSnapshot(sessionId, null);
|
|
1613
|
+
await this.tunnelManager?.removeSession(sessionId);
|
|
1614
|
+
await this.reportPreview(sessionId, { status: "stopped", urls: [], previews: [] });
|
|
1176
1615
|
}
|
|
1177
1616
|
|
|
1178
1617
|
/**
|
|
@@ -1187,6 +1626,7 @@ export class BridgeDaemon {
|
|
|
1187
1626
|
this.log("warn", "preview.service.died", { sessionId, name: info.name, code: info.code, errorCode: info.errorCode });
|
|
1188
1627
|
runner.stopAll();
|
|
1189
1628
|
this.services.delete(sessionId);
|
|
1629
|
+
this.writePreviewSnapshot(sessionId, null);
|
|
1190
1630
|
this.clearPreviewIdleTimer(sessionId);
|
|
1191
1631
|
return this.api
|
|
1192
1632
|
.sessionPreview(
|
|
@@ -1207,20 +1647,17 @@ export class BridgeDaemon {
|
|
|
1207
1647
|
let runner = this.services.get(sessionId);
|
|
1208
1648
|
if (!runner) {
|
|
1209
1649
|
// Stable ports (hash of repo + service in 43000-43999, when free) keep
|
|
1210
|
-
// a
|
|
1650
|
+
// a service's URL the same from one session to the next.
|
|
1211
1651
|
runner = new ServiceRunner({
|
|
1212
1652
|
kaiHome: this.kaiHome,
|
|
1213
1653
|
sessionId,
|
|
1214
1654
|
log: this.log,
|
|
1215
|
-
onStatus: (message) => {
|
|
1216
|
-
this.log("info", "preview.status", { sessionId, message });
|
|
1217
|
-
this.previewStatusListeners?.get(sessionId)?.(message);
|
|
1218
|
-
},
|
|
1655
|
+
onStatus: (message) => this.log("info", "preview.status", { sessionId, message }),
|
|
1219
1656
|
preferredPort: ({ repoKey, service }) => preferredStablePort(repoKey, service),
|
|
1220
1657
|
...(this.describeListener ? { describeListener: this.describeListener } : {}),
|
|
1221
1658
|
...(this.previewSettleMs != null ? { settleMs: this.previewSettleMs } : {}),
|
|
1222
1659
|
onServiceExit: (info) => this.onServiceDied(sessionId, info),
|
|
1223
|
-
onProcess: (name, pid, op) => this.trackServicePid(sessionId, name, pid, op),
|
|
1660
|
+
onProcess: (name, pid, op, info) => this.trackServicePid(sessionId, name, pid, op, info),
|
|
1224
1661
|
});
|
|
1225
1662
|
this.services.set(sessionId, runner);
|
|
1226
1663
|
}
|
|
@@ -1228,21 +1665,23 @@ export class BridgeDaemon {
|
|
|
1228
1665
|
}
|
|
1229
1666
|
|
|
1230
1667
|
/** Map the Server's repo bindings onto local checkouts; throw a readable error when one is missing. */
|
|
1231
|
-
bindRepos(turn) {
|
|
1668
|
+
async bindRepos(turn) {
|
|
1232
1669
|
const bound = [];
|
|
1233
1670
|
for (const r of turn.repos || []) {
|
|
1234
1671
|
const group = this.repoGroups.find((g) => g.key === r.key);
|
|
1235
1672
|
if (!group) throw new Error(`Repository ${r.key} is not checked out on this device.`);
|
|
1236
1673
|
const mode = r.mode || this.config.repoModes?.[r.key] || "worktree";
|
|
1237
|
-
const ws = materializeBinding({
|
|
1674
|
+
const ws = await this.withGitAuth(r.key, (gitEnv) => materializeBinding({
|
|
1238
1675
|
kaiHome: this.kaiHome,
|
|
1239
1676
|
repo: { name: group.name, primaryPath: group.primary.path, defaultBranch: group.primary.defaultBranch },
|
|
1240
1677
|
binding: { mode, base: r.base, carryUncommitted: r.carryUncommitted },
|
|
1241
1678
|
sessionId: turn.sessionId,
|
|
1242
1679
|
title: turn.title,
|
|
1243
|
-
|
|
1680
|
+
gitEnv,
|
|
1681
|
+
}));
|
|
1244
1682
|
bound.push({ key: r.key, ...ws });
|
|
1245
1683
|
if (ws.deps) this.log("info", "deps.seed", { repo: r.key, ...ws.deps });
|
|
1684
|
+
if (ws.fetch && (ws.fetch.stale || ws.fetch.attempts > 1)) this.log("warn", "workspace.fetch.contended", { repo: r.key, ...ws.fetch });
|
|
1246
1685
|
// Remember the choice per repo (the UI asks once, then sticks).
|
|
1247
1686
|
this.config.repoModes = { ...(this.config.repoModes || {}), [r.key]: mode };
|
|
1248
1687
|
}
|
|
@@ -1305,117 +1744,58 @@ export class BridgeDaemon {
|
|
|
1305
1744
|
this.running.set(turnId, entry);
|
|
1306
1745
|
const releaseAwake = keepAwake();
|
|
1307
1746
|
let outcome = null;
|
|
1308
|
-
//
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
const isVerify = turn.agent === "kai-verifier";
|
|
1312
|
-
let verify = null;
|
|
1313
|
-
this.rememberInflight(turnId, { sessionId: turn.sessionId, agent: turn.agent ?? null });
|
|
1747
|
+
// Everything `kai-bridge ps` shows for this turn; the runner pid follows at spawn.
|
|
1748
|
+
const session = this.rememberSession(turn.sessionId, { title: turn.title, sessionUrl: turn.sessionUrl, repos: turn.repos });
|
|
1749
|
+
this.rememberInflight(turnId, { sessionId: turn.sessionId, agent: turn.agent ?? null, harness: turn.harness ?? null, profileId: turn.profileId ?? null, startedAt: new Date().toISOString(), ...session });
|
|
1314
1750
|
const batcher = createEventBatcher({ api: this.api, turnId, onError: (err) => this.log("warn", "events.post.failed", { error: err.message }) });
|
|
1315
1751
|
try {
|
|
1316
1752
|
const profile = resolveProfiles(this.config, this.kaiHome).find((p) => p.id === turn.profileId) ?? { id: "gleap-key", kind: "gleap-key", harness: turn.harness };
|
|
1317
|
-
const bound = this.bindRepos(turn);
|
|
1753
|
+
const bound = await this.bindRepos(turn);
|
|
1318
1754
|
// Multi-repo: the runner's cwd is the first repo; the others are
|
|
1319
1755
|
// reachable as siblings under the same worktree root or by their
|
|
1320
1756
|
// local paths — the prompt lists them.
|
|
1321
1757
|
const workDir = bound[0]?.cwd;
|
|
1322
1758
|
if (!workDir) throw new Error("Turn has no repositories.");
|
|
1323
|
-
const repoNote =
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
// cleanly without spending an agent run.
|
|
1332
|
-
await this.api
|
|
1333
|
-
.turnVerification(turnId, { ...buildVerificationPayload(null, [], { fallbackReason: verify.error }), ...(verify.blocked || {}) })
|
|
1334
|
-
.catch((err) => this.log("warn", "verify.report.failed", { turnId, error: err.message }));
|
|
1335
|
-
outcome = {
|
|
1336
|
-
status: "completed",
|
|
1337
|
-
result: null,
|
|
1338
|
-
changes: bound.map((b) => ({ key: b.key, mode: b.mode, branch: b.branch, base: b.base, cwd: b.cwd, files: [], push: false })),
|
|
1339
|
-
profileId: profile.id,
|
|
1340
|
-
};
|
|
1341
|
-
return;
|
|
1342
|
-
}
|
|
1343
|
-
previewNote = verify.note;
|
|
1344
|
-
// The evidence dir is where a restart mid-run finds partial footage.
|
|
1345
|
-
this.rememberInflight(turnId, { sessionId: turn.sessionId, agent: turn.agent, artifactsDir: verify.artifactsDir });
|
|
1346
|
-
mcpServers = [
|
|
1347
|
-
...(turn.mcpServers || []),
|
|
1348
|
-
previewMcpServer(RUNNER_DIR, {
|
|
1349
|
-
outputDir: verify.artifactsDir,
|
|
1350
|
-
// `storage` = browser_storage_state for the final sign-in refresh;
|
|
1351
|
-
// restoring an arbitrary state file is never the agent's call.
|
|
1352
|
-
caps: ["devtools", "testing", "storage"],
|
|
1353
|
-
secretsFile: verify.secretsFile,
|
|
1354
|
-
storageStateFile: verify.storageStateFile,
|
|
1355
|
-
allowedOrigins: verify.storageStateFile ? verify.allowedOrigins : null,
|
|
1356
|
-
disabledTools: ["browser_set_storage_state"],
|
|
1357
|
-
ignoreHttpsErrors: !!verify.ignoreHttpsErrors,
|
|
1358
|
-
}),
|
|
1359
|
-
];
|
|
1360
|
-
await this.postVerifyStage(turnId, "verifying");
|
|
1361
|
-
} else {
|
|
1362
|
-
// Previews are manual-only (dashboard "Start preview") — a turn
|
|
1363
|
-
// never boots dev servers on its own. When the user already has a
|
|
1364
|
-
// preview running for this session, describe it to the agent and
|
|
1365
|
-
// hand it the Playwright MCP so it can verify in a real browser.
|
|
1366
|
-
const live = await this.describeLivePreview(turn, bound, batcher);
|
|
1367
|
-
previewNote = live.note;
|
|
1368
|
-
if (live.hasLivePreview) mcpServers = [...(turn.mcpServers || []), previewMcpServer(RUNNER_DIR)];
|
|
1369
|
-
}
|
|
1759
|
+
const repoNote = this.repoBrief(turn, bound);
|
|
1760
|
+
// Previews are manual-only (dashboard "Start preview") — a turn
|
|
1761
|
+
// never boots dev servers on its own. When the user already has a
|
|
1762
|
+
// preview running for this session, describe it to the agent and
|
|
1763
|
+
// hand it the Playwright MCP so it can check its work in a real browser.
|
|
1764
|
+
const live = await this.describeLivePreview(turn, bound, batcher);
|
|
1765
|
+
const previewNote = live.note;
|
|
1766
|
+
const mcpServers = live.hasLivePreview ? [...(turn.mcpServers || []), previewMcpServer(RUNNER_DIR)] : turn.mcpServers;
|
|
1370
1767
|
const res = await runTurn({
|
|
1371
1768
|
turn: { ...turn, task: `${turn.task}${repoNote}${previewNote}`, mcpServers },
|
|
1372
1769
|
profile,
|
|
1373
1770
|
workDir,
|
|
1374
1771
|
kaiHome: this.kaiHome,
|
|
1375
|
-
// The verify MCP's request policy (KAI_VERIFY_*) — host-only, never in the prompt.
|
|
1376
|
-
extraEnv: verify?.env ?? null,
|
|
1377
1772
|
signal: ctrl.signal,
|
|
1378
1773
|
onSpawn: (handle) => {
|
|
1379
1774
|
entry.control = handle.control;
|
|
1775
|
+
if (Number.isInteger(handle.pid)) this.rememberInflight(turnId, { pid: handle.pid });
|
|
1380
1776
|
},
|
|
1381
|
-
onEvent: (ev) =>
|
|
1382
|
-
if (verify) {
|
|
1383
|
-
// The tester is using the preview — it must not idle-stop
|
|
1384
|
-
// under it. And the report carries LOCAL paths: keep it here,
|
|
1385
|
-
// the uploaded version goes out after the turn.
|
|
1386
|
-
this.armPreviewIdleTimer(turn.sessionId);
|
|
1387
|
-
if (ev?.type === "verify_report") {
|
|
1388
|
-
verify.report = ev.report && typeof ev.report === "object" ? ev.report : null;
|
|
1389
|
-
return;
|
|
1390
|
-
}
|
|
1391
|
-
// Injected sign-in values (cookies, tokens) must never reach
|
|
1392
|
-
// the Server in a tool row or snapshot.
|
|
1393
|
-
if (verify.redact?.size) ev = redactDeep(ev, verify.redact);
|
|
1394
|
-
}
|
|
1395
|
-
batcher.push(ev);
|
|
1396
|
-
},
|
|
1777
|
+
onEvent: (ev) => batcher.push(ev),
|
|
1397
1778
|
onLog: (l) => this.log("debug", "runner", { line: l.slice(0, 500) }),
|
|
1398
1779
|
});
|
|
1399
1780
|
await batcher.flush();
|
|
1400
1781
|
const completed = !ctrl.signal.aborted && res.code === 0 && !res.rateLimited;
|
|
1401
|
-
// Plan
|
|
1402
|
-
//
|
|
1403
|
-
const readOnlyTurn = !!turn.planMode
|
|
1404
|
-
const changes = [...bound, ...this.adoptSessionWorktrees(turn, bound)].map((b) => {
|
|
1782
|
+
// Plan turns are read-only: worktrees are restored, local checkouts
|
|
1783
|
+
// only reported, nothing is ever committed or pushed.
|
|
1784
|
+
const readOnlyTurn = !!turn.planMode;
|
|
1785
|
+
const changes = await Promise.all([...bound, ...this.adoptSessionWorktrees(turn, bound)].map(async (b) => {
|
|
1405
1786
|
ensureCommitExcludes(b.cwd, { allowDevConfig: !!turn.allowDevConfig });
|
|
1406
1787
|
// A read-only turn must leave the worktree as it found it — see
|
|
1407
1788
|
// discardChanges. Local checkouts are the user's; only report.
|
|
1408
1789
|
if (readOnlyTurn) {
|
|
1409
1790
|
const leaked = b.mode === "worktree" ? discardChanges(b.cwd).discarded : collectChanges(b.cwd).files;
|
|
1410
1791
|
if (leaked.length > 0) {
|
|
1411
|
-
|
|
1412
|
-
this.log("warn", `${turn.planMode ? "plan" : "verify"}.changes`, { turnId, repo: b.key, mode: b.mode, files: leaked.length, discarded: b.mode === "worktree" });
|
|
1792
|
+
this.log("warn", "plan.changes", { turnId, repo: b.key, mode: b.mode, files: leaked.length, discarded: b.mode === "worktree" });
|
|
1413
1793
|
batcher.push({
|
|
1414
1794
|
type: "text",
|
|
1415
1795
|
message:
|
|
1416
1796
|
b.mode === "worktree"
|
|
1417
|
-
?
|
|
1418
|
-
:
|
|
1797
|
+
? `Plan mode is read-only — ${leaked.length} file change${leaked.length === 1 ? "" : "s"} made during planning ${leaked.length === 1 ? "was" : "were"} discarded; the build starts from the plan.`
|
|
1798
|
+
: `Plan mode is read-only, but ${leaked.length} file change${leaked.length === 1 ? "" : "s"} landed in your local checkout of ${b.key} — review them before building.`,
|
|
1419
1799
|
});
|
|
1420
1800
|
}
|
|
1421
1801
|
}
|
|
@@ -1423,30 +1803,32 @@ export class BridgeDaemon {
|
|
|
1423
1803
|
// Build turns in worktree mode publish the session branch so the
|
|
1424
1804
|
// Server can open the PR; read-only turns and local mode never push.
|
|
1425
1805
|
const shouldPush = completed && b.mode === "worktree" && !readOnlyTurn && diff.files.length > 0;
|
|
1806
|
+
const pushOnce = (gitEnv) => {
|
|
1807
|
+
const out = commitAndPush(b.cwd, {
|
|
1808
|
+
branch: b.branch,
|
|
1809
|
+
allowDevConfig: !!turn.allowDevConfig,
|
|
1810
|
+
message: `${turn.title || "Kai Code changes"}\n\nSession ${turn.sessionId} · run on ${this.config.device?.name || "a paired device"}`,
|
|
1811
|
+
gitEnv,
|
|
1812
|
+
});
|
|
1813
|
+
// commitAndPush reports instead of throwing; surface an auth
|
|
1814
|
+
// rejection so withGitAuth can retry the push (the commit exists).
|
|
1815
|
+
if (!out.pushed && !gitEnv && isGitAuthError(out.error)) throw Object.assign(new Error(out.error), { push: out });
|
|
1816
|
+
return out;
|
|
1817
|
+
};
|
|
1426
1818
|
const push = shouldPush
|
|
1427
|
-
?
|
|
1428
|
-
|
|
1429
|
-
allowDevConfig: !!turn.allowDevConfig,
|
|
1430
|
-
message: `${turn.title || "Kai Code changes"}\n\nSession ${turn.sessionId} · run on ${this.config.device?.name || "a paired device"}`,
|
|
1431
|
-
})
|
|
1432
|
-
: isVerify
|
|
1433
|
-
? false
|
|
1434
|
-
: null;
|
|
1819
|
+
? await this.withGitAuth(b.key, pushOnce).catch((err) => err.push ?? { committed: false, pushed: false, branch: b.branch, error: err.message })
|
|
1820
|
+
: null;
|
|
1435
1821
|
// `cwd` travels with the change so the dashboard can point at
|
|
1436
1822
|
// work that stayed on this machine (files but no push).
|
|
1437
1823
|
// Pushed → the commit list + diff stat travel with the change so the
|
|
1438
1824
|
// Server can write a real pull request description.
|
|
1439
1825
|
const described = push?.pushed ? describeBranchChanges(b.cwd, b.base) : null;
|
|
1440
1826
|
return { key: b.key, mode: b.mode, branch: b.branch, base: b.base, cwd: b.cwd, adopted: b.adopted || undefined, ...diff, push, ...(described ? described : {}) };
|
|
1441
|
-
});
|
|
1827
|
+
}));
|
|
1442
1828
|
// The read-only notices above were queued AFTER the post-turn
|
|
1443
1829
|
// flush; land them before the result closes the turn (the Server
|
|
1444
1830
|
// answers 410 for events on an ended turn).
|
|
1445
1831
|
await batcher.flush();
|
|
1446
|
-
if (verify) {
|
|
1447
|
-
const finished = await this.finishVerification(turn, { ...verify, workDir, bound }, res, ctrl.signal.aborted);
|
|
1448
|
-
await this.settleVerifyPreview(turn, finished?.status).catch((err) => this.log("warn", "verify.preview.release.failed", { turnId, error: err.message }));
|
|
1449
|
-
}
|
|
1450
1832
|
// Built OUTSIDE the report call: if posting the result throws, the
|
|
1451
1833
|
// catch below must not turn a finished turn into a failed one. The
|
|
1452
1834
|
// work is already committed and pushed at this point.
|
|
@@ -1454,17 +1836,19 @@ export class BridgeDaemon {
|
|
|
1454
1836
|
status: ctrl.signal.aborted ? "cancelled" : res.rateLimited ? "rate_limited" : res.code === 0 ? "completed" : "failed",
|
|
1455
1837
|
exitCode: res.code,
|
|
1456
1838
|
...(res.errorCode ? { errorCode: res.errorCode } : {}),
|
|
1457
|
-
result:
|
|
1839
|
+
result: res.result,
|
|
1458
1840
|
// The runner's own failure text (e.g. the engine's usage-limit
|
|
1459
1841
|
// message) — the Server prefers this over its generic fallback.
|
|
1460
|
-
...(res.lastError && res.code !== 0 ? { error:
|
|
1842
|
+
...(res.lastError && res.code !== 0 ? { error: res.lastError.replace(/^acp-runner: /, "").slice(0, 500) } : {}),
|
|
1461
1843
|
changes,
|
|
1462
1844
|
profileId: profile.id,
|
|
1463
1845
|
};
|
|
1464
1846
|
} catch (err) {
|
|
1465
|
-
this.log("error", "turn.failed", { turnId, error: err.message });
|
|
1847
|
+
this.log("error", "turn.failed", { turnId, error: err.message, ...(err.code ? { code: err.code, repo: err.repo } : {}) });
|
|
1466
1848
|
await batcher.flush().catch(() => {});
|
|
1467
|
-
|
|
1849
|
+
// A WorkspaceError names why the checkout could not be prepared;
|
|
1850
|
+
// the Server turns `failure.code` into a one-click retry.
|
|
1851
|
+
outcome = outcome ?? { status: "failed", error: err.message, ...(err.code ? { failure: { code: err.code, repo: err.repo ?? null } } : {}) };
|
|
1468
1852
|
} finally {
|
|
1469
1853
|
// One report, retried until it lands — a dropped result is what
|
|
1470
1854
|
// leaves a session spinning forever in the dashboard.
|
|
@@ -1476,845 +1860,23 @@ export class BridgeDaemon {
|
|
|
1476
1860
|
})
|
|
1477
1861
|
.catch((err) => this.log("error", "result.lost", { turnId, error: err.message }));
|
|
1478
1862
|
}
|
|
1479
|
-
// Test credentials and the injected / final sign-in state live on
|
|
1480
|
-
// disk only for the duration of the turn.
|
|
1481
|
-
for (const f of [verify?.secretsFile, verify?.storageStateFile, verify?.finalStateFile]) if (f) rmSync(f, { force: true });
|
|
1482
|
-
verify?.release?.();
|
|
1483
1863
|
this.forgetInflight(turnId);
|
|
1484
1864
|
releaseAwake();
|
|
1485
1865
|
this.running.delete(turnId);
|
|
1486
|
-
if ((this.updatePending || this.restartPending) && this.running.size === 0
|
|
1866
|
+
if ((this.updatePending || this.restartPending) && this.running.size === 0) void this.checkForUpdate();
|
|
1487
1867
|
}
|
|
1488
1868
|
}
|
|
1489
1869
|
|
|
1490
|
-
// ──
|
|
1491
|
-
/** Overridable seam (tests, embedded hosts): a browser the Playwright MCP can launch. */
|
|
1870
|
+
// ── preview browser ────────────────────────────────────────────────
|
|
1871
|
+
/** Overridable seam (tests, embedded hosts): a browser the warm-up and the Playwright MCP can launch. */
|
|
1492
1872
|
ensurePreviewBrowser() {
|
|
1493
1873
|
return ensurePreviewBrowser({ runnerDir: RUNNER_DIR, log: this.log });
|
|
1494
1874
|
}
|
|
1495
1875
|
|
|
1496
|
-
/**
|
|
1876
|
+
/** Seam for the warm-up (tests inject a fake Playwright). */
|
|
1497
1877
|
loadPlaywright() {
|
|
1498
1878
|
return loadPlaywright(RUNNER_DIR);
|
|
1499
1879
|
}
|
|
1500
|
-
displayAvailable() {
|
|
1501
|
-
return displayAvailable();
|
|
1502
|
-
}
|
|
1503
|
-
probeLogin(opts) {
|
|
1504
|
-
return probeLogin(opts);
|
|
1505
|
-
}
|
|
1506
|
-
captureLogin(opts) {
|
|
1507
|
-
return captureLogin(opts);
|
|
1508
|
-
}
|
|
1509
|
-
|
|
1510
|
-
/** Where a verify turn's injected sign-in state lives (0600, deleted with the turn). */
|
|
1511
|
-
loginStateDir() {
|
|
1512
|
-
return join(this.kaiHome, "state", "preview-login");
|
|
1513
|
-
}
|
|
1514
|
-
|
|
1515
|
-
/** The agent's final browser-state export — inside the turn's evidence dir (the MCP's only writable root besides the repo). */
|
|
1516
|
-
finalStateFileFor(artifactsDir) {
|
|
1517
|
-
return join(artifactsDir, "state", "final-storage-state.json");
|
|
1518
|
-
}
|
|
1519
|
-
|
|
1520
|
-
/** Best-effort stage transition for the dashboard's Verify card (booting → verifying → saving). */
|
|
1521
|
-
async postVerifyStage(turnId, stage, note) {
|
|
1522
|
-
// The note often relays the agent's last line — markdown emphasis
|
|
1523
|
-
// ("**Confirming…**") must not reach the card as literal asterisks.
|
|
1524
|
-
const plain = typeof note === "string" ? note.replace(/[#*`>_]/g, "").replace(/\s+/g, " ").trim().slice(0, 200) : "";
|
|
1525
|
-
try {
|
|
1526
|
-
await this.api.turnVerificationStage(turnId, { stage, ...(plain ? { note: plain } : {}) });
|
|
1527
|
-
} catch (err) {
|
|
1528
|
-
this.log("debug", "verify.stage.failed", { turnId, stage, error: err?.message });
|
|
1529
|
-
}
|
|
1530
|
-
}
|
|
1531
|
-
|
|
1532
|
-
/**
|
|
1533
|
-
* One probe / refresh of a sign-in at a time per user + repo: two verify
|
|
1534
|
-
* turns racing the same record would both refresh it (one 409s) and
|
|
1535
|
-
* could probe a preview the other is still booting.
|
|
1536
|
-
*/
|
|
1537
|
-
withVerifyLock(key, fn) {
|
|
1538
|
-
return this.acquireVerifyLock(key).then(async (release) => {
|
|
1539
|
-
try {
|
|
1540
|
-
return await fn();
|
|
1541
|
-
} finally {
|
|
1542
|
-
release();
|
|
1543
|
-
}
|
|
1544
|
-
});
|
|
1545
|
-
}
|
|
1546
|
-
|
|
1547
|
-
/**
|
|
1548
|
-
* Acquire the per user+repo verify lock; resolves with `release()`. A
|
|
1549
|
-
* turn that injected a saved sign-in keeps it until its final refresh
|
|
1550
|
-
* has landed: two agent runs presenting the same refresh token in
|
|
1551
|
-
* parallel is exactly what rotation-based IdPs revoke on.
|
|
1552
|
-
*/
|
|
1553
|
-
acquireVerifyLock(key) {
|
|
1554
|
-
this.verifyLockHolders ??= new Set(); // keys currently HELD (the map also lists waiters)
|
|
1555
|
-
this.verifyLocks ??= new Map();
|
|
1556
|
-
const prev = this.verifyLocks.get(key) ?? Promise.resolve();
|
|
1557
|
-
let release;
|
|
1558
|
-
const held = new Promise((resolve) => {
|
|
1559
|
-
let done = false;
|
|
1560
|
-
release = () => {
|
|
1561
|
-
if (done) return;
|
|
1562
|
-
done = true;
|
|
1563
|
-
this.verifyLockHolders.delete(key);
|
|
1564
|
-
resolve();
|
|
1565
|
-
};
|
|
1566
|
-
});
|
|
1567
|
-
const next = prev.catch(() => {}).then(() => held);
|
|
1568
|
-
this.verifyLocks.set(key, next);
|
|
1569
|
-
next.finally(() => {
|
|
1570
|
-
if (this.verifyLocks.get(key) === next) this.verifyLocks.delete(key);
|
|
1571
|
-
});
|
|
1572
|
-
return prev
|
|
1573
|
-
.catch(() => {})
|
|
1574
|
-
.then(() => {
|
|
1575
|
-
this.verifyLockHolders.add(key);
|
|
1576
|
-
return release;
|
|
1577
|
-
});
|
|
1578
|
-
}
|
|
1579
|
-
|
|
1580
|
-
/** The verifier's repo brief: where the checkouts are, and that they are read-only. */
|
|
1581
|
-
verifyRepoBrief(bound) {
|
|
1582
|
-
const lines = ["", "", "Repositories in this session (read-only for this verification turn — you cannot commit or push, and every file change is discarded when the turn ends):"];
|
|
1583
|
-
for (const b of bound) lines.push(`- ${b.key}: ${b.cwd}${b.branch ? ` (branch ${b.branch}${b.base ? `, base ${b.base}` : ""})` : ""}`);
|
|
1584
|
-
return lines.join("\n");
|
|
1585
|
-
}
|
|
1586
|
-
|
|
1587
|
-
/**
|
|
1588
|
-
* dev.yaml `auth.storageState: <command>` — run in the repo checkout,
|
|
1589
|
-
* stdout is Playwright storageState JSON (or the path of a JSON file).
|
|
1590
|
-
* Replaces the user's saved sign-in for this repo. Null on any failure.
|
|
1591
|
-
*/
|
|
1592
|
-
async storageStateFromCommand(command, cwd) {
|
|
1593
|
-
return new Promise((resolveP) => {
|
|
1594
|
-
const cb = (err, stdout) => {
|
|
1595
|
-
if (err) {
|
|
1596
|
-
this.log("warn", "verify.login.command.failed", { error: err.message });
|
|
1597
|
-
return resolveP(null);
|
|
1598
|
-
}
|
|
1599
|
-
resolveP(parseStorageStateOutput(stdout, { read: (p) => readFileSync(p, "utf8") }));
|
|
1600
|
-
};
|
|
1601
|
-
const opts = { cwd, timeout: 60_000, maxBuffer: 16 * 1024 * 1024, env: { ...process.env, BROWSER: "none" } };
|
|
1602
|
-
if (process.platform === "darwin") execFile("/bin/zsh", ["-lc", command], opts, cb);
|
|
1603
|
-
else execFile(command, [], { ...opts, shell: true }, cb);
|
|
1604
|
-
});
|
|
1605
|
-
}
|
|
1606
|
-
|
|
1607
|
-
/**
|
|
1608
|
-
* Boot (or re-describe) the session's preview, make sure a browser can
|
|
1609
|
-
* launch, decide the sign-in (dev.yaml command → saved record → none),
|
|
1610
|
-
* PROBE it headless, and lay out the turn's evidence dir + state files +
|
|
1611
|
-
* optional secrets file. Resolves `{ ok: false, error, blocked? }`
|
|
1612
|
-
* (`blocked` = `{ blockedCode, loginPath?, firstTime? }` for the report)
|
|
1613
|
-
* or `{ ok: true, artifactsDir, secretsFile, storageStateFile,
|
|
1614
|
-
* finalStateFile, allowedOrigins, injected, redact, note, report: null }`.
|
|
1615
|
-
*/
|
|
1616
|
-
async prepareVerifyTurn(turn, bound = []) {
|
|
1617
|
-
const repoKeys = (turn.repos || []).map((r) => r.key).filter(Boolean).sort();
|
|
1618
|
-
const lockKey = `${turn.userId ?? turn.ownerUserId ?? "device"}:${repoKeys.join(",")}`;
|
|
1619
|
-
if (this.verifyLockHolders?.has(lockKey)) {
|
|
1620
|
-
// Another verify run of this repo holds its sign-in — say so instead
|
|
1621
|
-
// of sitting on "Starting the app…" until it finishes.
|
|
1622
|
-
this.log("info", "verify.lock.wait", { turnId: turn.turnId, lockKey });
|
|
1623
|
-
await this.postVerifyStage(turn.turnId, "booting", "Waiting for another verification of this repo on this machine to finish…");
|
|
1624
|
-
}
|
|
1625
|
-
const release = await this.acquireVerifyLock(lockKey);
|
|
1626
|
-
let result;
|
|
1627
|
-
try {
|
|
1628
|
-
result = await this.prepareVerifyTurnLocked(turn, bound);
|
|
1629
|
-
} catch (err) {
|
|
1630
|
-
release();
|
|
1631
|
-
throw err;
|
|
1632
|
-
}
|
|
1633
|
-
if (result?.ok && result.injected) {
|
|
1634
|
-
// Hold the repo's sign-in for the whole run — released by startTurn's
|
|
1635
|
-
// finally (after finishVerification refreshed the record).
|
|
1636
|
-
return { ...result, release };
|
|
1637
|
-
}
|
|
1638
|
-
release();
|
|
1639
|
-
return result;
|
|
1640
|
-
}
|
|
1641
|
-
|
|
1642
|
-
async prepareVerifyTurnLocked(turn, bound) {
|
|
1643
|
-
const { turnId, sessionId } = turn;
|
|
1644
|
-
// Lazy browser check — setup usually did this; a machine that skipped
|
|
1645
|
-
// it downloads Chromium now (best-effort: without a browser the agent
|
|
1646
|
-
// reports `blocked` itself).
|
|
1647
|
-
const browser = await this.ensurePreviewBrowser().catch((err) => ({ ok: false, error: err?.message }));
|
|
1648
|
-
if (!browser?.ok) {
|
|
1649
|
-
// No browser, no test — say so with the exact command (Linux/Windows
|
|
1650
|
-
// machines without Chrome and a failed Chromium download) instead of
|
|
1651
|
-
// letting the agent discover it on its first navigate.
|
|
1652
|
-
this.log("warn", "verify.browser.unavailable", { turnId, error: browser?.error });
|
|
1653
|
-
return {
|
|
1654
|
-
ok: false,
|
|
1655
|
-
error: `No browser is available on this machine for the verification: ${browser?.error || "Chromium is not installed."}${browser?.command ? ` Run \`${browser.command}\` on this machine, then retry.` : ""}`,
|
|
1656
|
-
blocked: { blockedCode: "no_browser" },
|
|
1657
|
-
};
|
|
1658
|
-
}
|
|
1659
|
-
const channel = browser?.browser === "chrome" ? "chrome" : null;
|
|
1660
|
-
// previewStart is idempotent on a live runner (booted services are
|
|
1661
|
-
// only re-described) and reports starting/running to the dashboard
|
|
1662
|
-
// exactly like the manual button. A cold boot is its own stage, and
|
|
1663
|
-
// every runner status line during it (installing, still starting,
|
|
1664
|
-
// ready) reaches the Verify card as a `booting` note — the heartbeat.
|
|
1665
|
-
const coldBoot = !this.services.has(sessionId);
|
|
1666
|
-
if (coldBoot && !this.manualPreviews?.has(sessionId)) (this.verifyOwnedPreviews ??= new Set()).add(sessionId);
|
|
1667
|
-
if (coldBoot) await this.postVerifyStage(turnId, "booting");
|
|
1668
|
-
const heartbeat = createStageHeartbeat((note) => this.postVerifyStage(turnId, "booting", note), { minIntervalMs: this.stageHeartbeatMs ?? 5_000 });
|
|
1669
|
-
this.previewStatusListeners ??= new Map();
|
|
1670
|
-
this.previewStatusListeners.set(sessionId, heartbeat.note);
|
|
1671
|
-
let preview;
|
|
1672
|
-
try {
|
|
1673
|
-
preview = await this.previewStart({ sessionId, title: turn.title, repos: turn.repos, companionRemotes: turn.companionRemotes ?? null });
|
|
1674
|
-
} finally {
|
|
1675
|
-
this.previewStatusListeners?.delete(sessionId);
|
|
1676
|
-
heartbeat.stop();
|
|
1677
|
-
}
|
|
1678
|
-
if (preview?.status !== "running") {
|
|
1679
|
-
// The preview's structured error IS the diagnosis: its code becomes the
|
|
1680
|
-
// verification's blockedCode (companion_missing, port_busy, deps_failed,
|
|
1681
|
-
// …) so the card offers the right verb; `preview_unreachable` only
|
|
1682
|
-
// when nothing more specific is known.
|
|
1683
|
-
const code = preview?.errorCode && preview.errorCode !== "other" ? preview.errorCode : "preview_unreachable";
|
|
1684
|
-
return {
|
|
1685
|
-
ok: false,
|
|
1686
|
-
error: preview?.error ? `The preview could not be started: ${preview.error}` : "The preview could not be started.",
|
|
1687
|
-
blocked: { blockedCode: code },
|
|
1688
|
-
};
|
|
1689
|
-
}
|
|
1690
|
-
// After a fix turn, services that do not hot-reload (`reload: restart`,
|
|
1691
|
-
// the default for APIs) must run the fixed code before the probe.
|
|
1692
|
-
const restartNotes = await this.restartChangedServices(turn, sessionId);
|
|
1693
|
-
const artifactsDir = join(this.kaiHome, "artifacts", String(turnId).replace(/[^\w.-]/g, "_"));
|
|
1694
|
-
mkdirSync(artifactsDir, { recursive: true });
|
|
1695
|
-
let secretsFile = null;
|
|
1696
|
-
let secretNames = [];
|
|
1697
|
-
if (turn.verifySecrets && typeof turn.verifySecrets === "object") {
|
|
1698
|
-
const dir = join(this.kaiHome, "state", "verify-secrets");
|
|
1699
|
-
mkdirSync(dir, { recursive: true });
|
|
1700
|
-
const path = join(dir, `${String(turnId).replace(/[^\w.-]/g, "_")}.env`);
|
|
1701
|
-
secretNames = writeSecretsFile(path, turn.verifySecrets);
|
|
1702
|
-
if (secretNames.length > 0) secretsFile = path;
|
|
1703
|
-
}
|
|
1704
|
-
const previews = preview.previews || [];
|
|
1705
|
-
const runner = this.services.get(sessionId);
|
|
1706
|
-
const services = runner?.describeServices?.() ?? previews.map((p) => ({ name: p.name, url: p.url }));
|
|
1707
|
-
const previewOrigins = [...new Set([...services.map((s) => normalizeOrigin(s.url)), ...previews.map((p) => normalizeOrigin(p.url))].filter(Boolean))];
|
|
1708
|
-
const primaryKey = (turn.repos || [])[0]?.key;
|
|
1709
|
-
// The landing page is a session repo's web preview (an API has no UI to land on).
|
|
1710
|
-
const webPreviews = previews.filter((p) => p.kind !== "api");
|
|
1711
|
-
const landingUrl = webPreviews.find((p) => p.repo === primaryKey)?.url ?? webPreviews[0]?.url ?? previews.find((p) => p.repo === primaryKey)?.url ?? previews[0]?.url ?? services[0]?.url ?? null;
|
|
1712
|
-
|
|
1713
|
-
// ── sign-in: dev.yaml command → saved record → none ──────────────
|
|
1714
|
-
const primary = bound?.[0] ?? null;
|
|
1715
|
-
const devConfig = primary?.cwd ? readDevConfig(primary.cwd) : null;
|
|
1716
|
-
const auth = devConfig?.auth ?? null;
|
|
1717
|
-
// Every dev.yaml in the session (external origins, verify policy) — the
|
|
1718
|
-
// primary's wins on conflicts.
|
|
1719
|
-
const configs = (bound || []).map((b) => (b?.cwd ? readDevConfig(b.cwd) : null)).filter(Boolean);
|
|
1720
|
-
const external = [...new Set(configs.flatMap((c) => c.external || []))];
|
|
1721
|
-
// The state filter accepts the declared `external` origins too: the
|
|
1722
|
-
// browser is allowed to talk to them, so a token the app keeps there is
|
|
1723
|
-
// part of "signed in". Cookies stay localhost-only (filterStorageState).
|
|
1724
|
-
const stateOrigins = [...new Set([...previewOrigins, ...external])];
|
|
1725
|
-
let injected = null; // { source: "command" | "record", state, record?, originMap? }
|
|
1726
|
-
if (auth?.storageState && primary?.cwd) {
|
|
1727
|
-
const state = await this.storageStateFromCommand(auth.storageState, primary.cwd);
|
|
1728
|
-
if (state) injected = { source: "command", state: filterStorageState(state, stateOrigins).state, record: null, originMap: null };
|
|
1729
|
-
else this.log("warn", "verify.login.command.empty", { turnId });
|
|
1730
|
-
} else if (turn.loginRecordAvailable) {
|
|
1731
|
-
const record = await this.api.turnPreviewLogin(turnId).catch((err) => {
|
|
1732
|
-
this.log("warn", "verify.login.record.failed", { turnId, error: err?.message });
|
|
1733
|
-
return null;
|
|
1734
|
-
});
|
|
1735
|
-
if (record?.storageState) {
|
|
1736
|
-
const originMap = buildOriginMap(record.services, services);
|
|
1737
|
-
const state = rewriteStorageState(record.storageState, originMap);
|
|
1738
|
-
this.log("info", "verify.login.record", { turnId, version: record.version, mapped: originMap.size, cookies: state.cookies.length, origins: state.origins.length });
|
|
1739
|
-
injected = { source: "record", state, record, originMap };
|
|
1740
|
-
}
|
|
1741
|
-
}
|
|
1742
|
-
|
|
1743
|
-
// ── probe: does the preview consider us signed in? ───────────────
|
|
1744
|
-
// An API-only preview (no web service) has nothing to render a login
|
|
1745
|
-
// wall on — the agent's http_request answers 401/403 → needs_login.
|
|
1746
|
-
let probe = { result: "unknown", storageState: null, loginPath: null };
|
|
1747
|
-
const hasWebService = services.some((svc) => svc.kind !== "api") || webPreviews.length > 0;
|
|
1748
|
-
if (landingUrl && hasWebService) {
|
|
1749
|
-
const pw = this.loadPlaywright();
|
|
1750
|
-
probe = await this.probeLogin({
|
|
1751
|
-
pw,
|
|
1752
|
-
launchOptions: launchOptionsFor({ headless: true, browser: channel }),
|
|
1753
|
-
storageState: injected?.state ?? null,
|
|
1754
|
-
url: injected?.record?.landingUrl ? rewriteUrl(injected.record.landingUrl, injected.originMap) ?? landingUrl : landingUrl,
|
|
1755
|
-
previewOrigins,
|
|
1756
|
-
loginPaths: injected?.record?.loginPaths ?? [],
|
|
1757
|
-
loginCheck: auth?.loginCheck ?? null,
|
|
1758
|
-
// A dev server that just booted still compiles its first page (vite's
|
|
1759
|
-
// cold transform of a large app takes 30-60 s) — give it room, or the
|
|
1760
|
-
// probe answers `unknown` and a login wall goes unnoticed.
|
|
1761
|
-
timeoutMs: coldBoot ? PROBE_TIMEOUT_COLD_BOOT_MS : undefined,
|
|
1762
|
-
log: this.log,
|
|
1763
|
-
});
|
|
1764
|
-
}
|
|
1765
|
-
// "Continue without signing in" (`previewLoginOptional`) means: run
|
|
1766
|
-
// unauthenticated rather than block — whether there is no record at all
|
|
1767
|
-
// or the saved one no longer works.
|
|
1768
|
-
if (probe.result === "needs_login" && turn.previewLoginOptional === true) {
|
|
1769
|
-
this.log("info", "verify.login.optional", { turnId, hadRecord: injected !== null });
|
|
1770
|
-
injected = null;
|
|
1771
|
-
probe = { result: "unknown", storageState: null, loginPath: probe.loginPath };
|
|
1772
|
-
}
|
|
1773
|
-
if (probe.result === "needs_login") {
|
|
1774
|
-
rmSync(artifactsDir, { recursive: true, force: true });
|
|
1775
|
-
if (secretsFile) rmSync(secretsFile, { force: true });
|
|
1776
|
-
const firstTime = injected === null;
|
|
1777
|
-
return {
|
|
1778
|
-
ok: false,
|
|
1779
|
-
error: firstTime
|
|
1780
|
-
? "The preview asks for a sign-in. Sign in once on this device so Kai can test the app."
|
|
1781
|
-
: "The saved sign-in for this preview no longer works — sign in again on this device.",
|
|
1782
|
-
blocked: { blockedCode: "needs_login", ...(probe.loginPath ? { loginPath: probe.loginPath } : {}), firstTime },
|
|
1783
|
-
};
|
|
1784
|
-
}
|
|
1785
|
-
|
|
1786
|
-
// ── state files for the MCP ──────────────────────────────────────
|
|
1787
|
-
let storageStateFile = null;
|
|
1788
|
-
let finalStateFile = null;
|
|
1789
|
-
let redact = new Set();
|
|
1790
|
-
let probeExport = null;
|
|
1791
|
-
if (injected && !storageStateIsEmpty(injected.state)) {
|
|
1792
|
-
const dir = this.loginStateDir();
|
|
1793
|
-
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
1794
|
-
const safe = String(turnId).replace(/[^\w.-]/g, "_");
|
|
1795
|
-
storageStateFile = join(dir, `${safe}.json`);
|
|
1796
|
-
// The persona's final `browser_storage_state` export must land INSIDE
|
|
1797
|
-
// the MCP's `--output-dir`: the Playwright MCP refuses any `filename`
|
|
1798
|
-
// outside its output dir / the client cwd (`File access denied …
|
|
1799
|
-
// outside allowed roots`), so a path under ~/.kai/state would make the
|
|
1800
|
-
// agent's last action fail on every signed-in run and the record would
|
|
1801
|
-
// never refresh. A `.json` is not an artifact extension — the sweep
|
|
1802
|
-
// never uploads it — and it is read before the evidence dir is deleted.
|
|
1803
|
-
finalStateFile = this.finalStateFileFor(artifactsDir);
|
|
1804
|
-
mkdirSync(dirname(finalStateFile), { recursive: true, mode: 0o700 });
|
|
1805
|
-
// The probe context's own export (with IndexedDB) when the probe ran;
|
|
1806
|
-
// the rewritten record otherwise (probe unknown → proceed anyway).
|
|
1807
|
-
probeExport = probe.result === "ok" && probe.storageState ? filterStorageState(probe.storageState, stateOrigins).state : injected.state;
|
|
1808
|
-
writeFileSync(storageStateFile, JSON.stringify(probeExport), { mode: 0o600 });
|
|
1809
|
-
redact = new Set([...redactionSet(injected.state), ...redactionSet(probeExport)]);
|
|
1810
|
-
}
|
|
1811
|
-
// `.env*` values of the repos + service dirs are secrets too: a tool row
|
|
1812
|
-
// that echoes one (a config dump, an error message) must not carry it
|
|
1813
|
-
// to the Server.
|
|
1814
|
-
for (const value of this.envRedactionValues(bound, services)) redact.add(value);
|
|
1815
|
-
const allowedOrigins = [...previewOrigins, ...external, "http://localhost:*", "http://127.0.0.1:*", "https://localhost:*", "https://127.0.0.1:*"];
|
|
1816
|
-
// ── API evidence: the verify MCP's request policy ───────────────
|
|
1817
|
-
const apiServices = services.filter((svc) => svc.kind === "api");
|
|
1818
|
-
const verifyPolicy = configs.find((c) => c.verify)?.verify ?? devConfig?.verify ?? { readOnly: null, endpoints: [], loginVia: null };
|
|
1819
|
-
const readOnly = verifyPolicy.readOnly ?? apiServices.length > 0;
|
|
1820
|
-
const env = {
|
|
1821
|
-
KAI_VERIFY_READ_ONLY: readOnly ? "1" : "0",
|
|
1822
|
-
KAI_VERIFY_ORIGINS: [...new Set([...previewOrigins, ...external])].join(";"),
|
|
1823
|
-
KAI_VERIFY_EVIDENCE_DIR: artifactsDir,
|
|
1824
|
-
};
|
|
1825
|
-
// The app's own API credential (dev.yaml `auth.apiToken`) read from the
|
|
1826
|
-
// injected sign-in → `Authorization: Bearer …` for http_request. The
|
|
1827
|
-
// value goes to the MCP by env only and joins the redaction set.
|
|
1828
|
-
const apiAuth = deriveApiAuthHeader(auth?.apiToken, probeExport ?? injected?.state ?? null, services);
|
|
1829
|
-
if (apiAuth) {
|
|
1830
|
-
env.KAI_VERIFY_AUTH_HEADER = apiAuth.value;
|
|
1831
|
-
if (apiAuth.token.length >= 8) redact.add(apiAuth.token);
|
|
1832
|
-
}
|
|
1833
|
-
return {
|
|
1834
|
-
ok: true,
|
|
1835
|
-
artifactsDir,
|
|
1836
|
-
secretsFile,
|
|
1837
|
-
storageStateFile,
|
|
1838
|
-
finalStateFile,
|
|
1839
|
-
allowedOrigins,
|
|
1840
|
-
previewOrigins,
|
|
1841
|
-
stateOrigins,
|
|
1842
|
-
external,
|
|
1843
|
-
// Companions run the code they have checked out — their HEADs travel
|
|
1844
|
-
// with the report so "verified" can never be claimed for a stale API.
|
|
1845
|
-
companionRevisions: previews
|
|
1846
|
-
.filter((p) => p.role === "companion" && p.repo && p.commit)
|
|
1847
|
-
.map((p) => ({ repo: p.repo, commit: p.commit, role: "companion", ...(p.branch ? { branch: p.branch } : {}), ...(p.dirty > 0 ? { dirty: p.dirty } : {}) })),
|
|
1848
|
-
ignoreHttpsErrors: !!runner?.usesHttps?.(),
|
|
1849
|
-
env,
|
|
1850
|
-
readOnly,
|
|
1851
|
-
injected: injected ? { ...injected, probeExport } : null,
|
|
1852
|
-
redact,
|
|
1853
|
-
note: this.verifyPreviewNote(sessionId, previews, secretNames, artifactsDir, {
|
|
1854
|
-
signedIn: !!storageStateFile,
|
|
1855
|
-
finalStateFile,
|
|
1856
|
-
services,
|
|
1857
|
-
readOnly,
|
|
1858
|
-
endpoints: verifyPolicy.endpoints || [],
|
|
1859
|
-
external,
|
|
1860
|
-
restartNotes,
|
|
1861
|
-
apiAuth: !!apiAuth,
|
|
1862
|
-
}),
|
|
1863
|
-
report: null,
|
|
1864
|
-
};
|
|
1865
|
-
}
|
|
1866
|
-
|
|
1867
|
-
/**
|
|
1868
|
-
* After a fix turn (`turn.changedRepos` = repo keys the fix touched):
|
|
1869
|
-
* restart the changed repos' services whose reload mode is `restart`
|
|
1870
|
-
* (declared, or the default for `kind: api`) on their same ports. Adopted
|
|
1871
|
-
* services are the user's own dev server — never restarted, the task
|
|
1872
|
-
* note says so. Returns the note lines for the agent.
|
|
1873
|
-
*/
|
|
1874
|
-
async restartChangedServices(turn, sessionId) {
|
|
1875
|
-
const changed = Array.isArray(turn.changedRepos) ? turn.changedRepos.map((k) => String(k).toLowerCase()) : [];
|
|
1876
|
-
const runner = this.services.get(sessionId);
|
|
1877
|
-
if (!changed.length || !runner) return [];
|
|
1878
|
-
const notes = [];
|
|
1879
|
-
const byRoot = new Map(); // repoRoot → { key, names }
|
|
1880
|
-
for (const svc of runner.describeServices()) {
|
|
1881
|
-
if (!svc.repoKey || !changed.includes(String(svc.repoKey).toLowerCase()) || !svc.repoRoot) continue;
|
|
1882
|
-
const meta = runner.meta?.get(svc.name);
|
|
1883
|
-
if (effectiveReload(meta?.svc, svc.kind) !== "restart") continue;
|
|
1884
|
-
if (svc.adopted) {
|
|
1885
|
-
notes.push(`${svc.name} is your already-running dev server and was not restarted — make sure it serves the fixed code.`);
|
|
1886
|
-
continue;
|
|
1887
|
-
}
|
|
1888
|
-
const entry = byRoot.get(svc.repoRoot) ?? { key: svc.repoKey, names: [] };
|
|
1889
|
-
entry.names.push(svc.name);
|
|
1890
|
-
byRoot.set(svc.repoRoot, entry);
|
|
1891
|
-
}
|
|
1892
|
-
for (const [repoRoot, { key, names }] of byRoot) {
|
|
1893
|
-
await this.postVerifyStage(turn.turnId, "booting", `Restarting ${names.join(", ")} with the fix…`);
|
|
1894
|
-
this.log("info", "verify.restart", { turnId: turn.turnId, repo: key, services: names });
|
|
1895
|
-
try {
|
|
1896
|
-
const restarted = await runner.restart(repoRoot, { only: names });
|
|
1897
|
-
const dead = restarted.find((r) => r.restarted && !r.ready);
|
|
1898
|
-
if (dead) notes.push(`${dead.name} did not come back after the restart${dead.error ? ` — ${dead.error}` : ""} (see ${dead.logPath}).`);
|
|
1899
|
-
else notes.push(`${names.join(", ")} ${names.length === 1 ? "was" : "were"} restarted with the fix.`);
|
|
1900
|
-
} catch (err) {
|
|
1901
|
-
this.log("warn", "verify.restart.failed", { turnId: turn.turnId, repo: key, error: err?.message });
|
|
1902
|
-
notes.push(`${names.join(", ")} could not be restarted with the fix: ${err?.message}`);
|
|
1903
|
-
}
|
|
1904
|
-
}
|
|
1905
|
-
return notes;
|
|
1906
|
-
}
|
|
1907
|
-
|
|
1908
|
-
/** Secrets from `.env*` files at the repo roots + service cwds (values ≥ 12 chars, placeholders excluded). */
|
|
1909
|
-
envRedactionValues(bound, services) {
|
|
1910
|
-
const dirs = new Set([...(bound || []).map((b) => b?.cwd).filter(Boolean), ...(services || []).map((svc) => svc?.cwd).filter(Boolean)]);
|
|
1911
|
-
const texts = [];
|
|
1912
|
-
for (const dir of dirs) {
|
|
1913
|
-
let names = [];
|
|
1914
|
-
try {
|
|
1915
|
-
names = readdirSync(dir).filter((n) => n === ".env" || (n.startsWith(".env.") && !/\.(example|sample|template)$/.test(n)));
|
|
1916
|
-
} catch {
|
|
1917
|
-
continue;
|
|
1918
|
-
}
|
|
1919
|
-
for (const name of names) {
|
|
1920
|
-
try {
|
|
1921
|
-
texts.push(readFileSync(join(dir, name), "utf8"));
|
|
1922
|
-
} catch {
|
|
1923
|
-
/* unreadable */
|
|
1924
|
-
}
|
|
1925
|
-
}
|
|
1926
|
-
}
|
|
1927
|
-
return envRedactionValues(texts);
|
|
1928
|
-
}
|
|
1929
|
-
|
|
1930
|
-
/** Task suffix for the verifier: the preview URLs (web + API services), where evidence goes, the secrets by name, the sign-in state, the request policy. */
|
|
1931
|
-
verifyPreviewNote(sessionId, previews, secretNames = [], artifactsDir = null, { signedIn = false, finalStateFile = null, services = null, readOnly = false, endpoints = [], external = [], restartNotes = [], apiAuth = false } = {}) {
|
|
1932
|
-
const runner = this.services.get(sessionId);
|
|
1933
|
-
const apiNames = new Set((services || []).filter((svc) => svc.kind === "api").map((svc) => svc.name));
|
|
1934
|
-
const webPreviews = previews.filter((p) => !apiNames.has(p.name));
|
|
1935
|
-
const lines = webPreviews.map((p) => {
|
|
1936
|
-
const logPath = runner && !p.adopted ? join(runner.logDir, `${p.name}.log`) : null;
|
|
1937
|
-
return `- ${p.repo}${p.name && p.name !== p.repo ? ` (${p.name})` : ""}: ${p.url}${p.adopted ? " (your already-running dev server)" : ""}${logPath ? ` · logs: ${logPath}` : ""}`;
|
|
1938
|
-
});
|
|
1939
|
-
const parts = ["", "", "This is a verification turn: do not modify files, do not commit."];
|
|
1940
|
-
if (lines.length > 0) {
|
|
1941
|
-
parts.push(`Dev services running for this session:\n${lines.join("\n")}`);
|
|
1942
|
-
parts.push(
|
|
1943
|
-
"Use the `gleap_preview` browser tools (browser_navigate, browser_snapshot, browser_start_video, browser_video_chapter, browser_take_screenshot, browser_stop_video, …) against these URLs. " +
|
|
1944
|
-
"Tail the service logs with the Read/Bash tools if a page fails to load.",
|
|
1945
|
-
);
|
|
1946
|
-
}
|
|
1947
|
-
const apiServices = (services || []).filter((svc) => svc.kind === "api");
|
|
1948
|
-
if (apiServices.length > 0) {
|
|
1949
|
-
const apiLines = apiServices.map((svc) => {
|
|
1950
|
-
const logPath = runner && !svc.adopted ? join(runner.logDir, `${svc.name}.log`) : null;
|
|
1951
|
-
return `- ${svc.repoKey ? `${svc.repoKey} (${svc.name})` : svc.name}: ${svc.url}${svc.openapi ? ` · OpenAPI: ${svc.openapi}` : ""}${svc.adopted ? " (your already-running dev server)" : ""}${logPath ? ` · logs: ${logPath}` : ""}`;
|
|
1952
|
-
});
|
|
1953
|
-
parts.push(
|
|
1954
|
-
`API services (no UI):\n${apiLines.join("\n")}\n` +
|
|
1955
|
-
`Exercise them with the \`http_request\` tool from the \`kai_verify\` MCP server (one call per check, \`check\` set to what it proves) — the HTTP transcript it records is uploaded with your report as evidence; curl is not available. ` +
|
|
1956
|
-
`Resolve real paths from the OpenAPI spec (or the router files) before calling. ` +
|
|
1957
|
-
(readOnly
|
|
1958
|
-
? "This run is READ-ONLY (shared database): only GET/HEAD/OPTIONS go through — anything else is refused and recorded as skipped; list such endpoints under `untested`. "
|
|
1959
|
-
: "Writes are allowed in this run — prefer creating throwaway records and say what you created in the report. ") +
|
|
1960
|
-
(apiAuth ? "The app's own authentication is added to your requests automatically; never paste tokens. " : "A 401/403 means the API needs a sign-in you do not have: report `blocked` / `needs_login` with the endpoint's path, never a failed check. ") +
|
|
1961
|
-
(endpoints.length ? `Endpoints the repo asks you to cover: ${endpoints.join(", ")}. ` : "") +
|
|
1962
|
-
(lines.length === 0 ? "There is no web UI in this run — no recording is needed; the transcript and the report are the evidence." : ""),
|
|
1963
|
-
);
|
|
1964
|
-
}
|
|
1965
|
-
if (lines.length === 0 && apiServices.length === 0) {
|
|
1966
|
-
parts.push(`Dev services running for this session:\n${previews.map((p) => `- ${p.repo}: ${p.url}`).join("\n") || "- (none)"}`);
|
|
1967
|
-
}
|
|
1968
|
-
if (external.length > 0) parts.push(`External origins the app talks to (allowed for the browser and http_request): ${external.join(", ")}.`);
|
|
1969
|
-
if (restartNotes.length > 0) parts.push(restartNotes.join(" "));
|
|
1970
|
-
if (artifactsDir) {
|
|
1971
|
-
// The MCP resolves a relative `filename` against the CLIENT
|
|
1972
|
-
// workspace (the repo worktree), where it would be discarded with
|
|
1973
|
-
// the turn — only absolute paths under the output dir (or no
|
|
1974
|
-
// filename at all) reach the evidence dir.
|
|
1975
|
-
parts.push(
|
|
1976
|
-
`Evidence directory: ${artifactsDir} — every screenshot and video in it is uploaded with your report. Pass every screenshot/video \`filename\` as an ABSOLUTE path inside it (e.g. \`${join(artifactsDir, "01-home.png")}\`, \`${join(artifactsDir, "verification.webm")}\`), never a bare relative name, and list the paths the tools print back in your report.`,
|
|
1977
|
-
);
|
|
1978
|
-
}
|
|
1979
|
-
if (secretNames.length > 0) {
|
|
1980
|
-
parts.push(
|
|
1981
|
-
`Test credentials are available as secrets: ${secretNames.join(", ")}. Type the secret NAME into the form field (e.g. \`${secretNames[0]}\`) — the browser substitutes the real value and masks it in every response. Do not ask the user for these.`,
|
|
1982
|
-
);
|
|
1983
|
-
}
|
|
1984
|
-
if (signedIn && finalStateFile) {
|
|
1985
|
-
parts.push(
|
|
1986
|
-
`The browser is already signed in to the preview (the user's saved sign-in is loaded). Final browser state path: ${finalStateFile} — as your LAST browser action, after \`browser_stop_video\` and before \`report_verification\`, call \`browser_storage_state\` with exactly this absolute path as \`filename\` so the sign-in stays fresh. ` +
|
|
1987
|
-
`Never read, list or copy anything under ${this.loginStateDir()} — it is the daemon's private state. Never paste cookies, tokens or storage values into the report, todos or summary.`,
|
|
1988
|
-
);
|
|
1989
|
-
} else {
|
|
1990
|
-
parts.push(
|
|
1991
|
-
"If a page asks you to sign in (password field, one-time code, a Sign in / Log in button, a redirect to an identity provider) and no secrets are listed, do not ask the user and do not type into the form: stop the video, screenshot the wall, and file `report_verification` with `status: blocked`, `blockedCode: needs_login` and `loginPath` set to the wall's URL path.",
|
|
1992
|
-
);
|
|
1993
|
-
}
|
|
1994
|
-
parts.push("If the change has nothing a tester can exercise from the running app, file `report_verification` with `status: blocked` and `blockedCode: not_verifiable` — never invent a check. A `passed` report needs at least one real check.");
|
|
1995
|
-
return parts.join("\n\n").replace(/^\n\n\n\n/, "\n\n");
|
|
1996
|
-
}
|
|
1997
|
-
|
|
1998
|
-
/**
|
|
1999
|
-
* After a verify turn: sweep the evidence dir (the agent may have
|
|
2000
|
-
* forgotten the recording), upload every file, then post the report
|
|
2001
|
-
* with URLs in place of paths (redacted: nothing from the injected
|
|
2002
|
-
* sign-in may leak). No report from the agent → `blocked` with the swept
|
|
2003
|
-
* evidence; a report with zero checks → `blocked` / `no_report` (the
|
|
2004
|
-
* payload builder). A turn CANCELLED before any report skips the uploads
|
|
2005
|
-
* altogether (nobody asked for that footage). `evidence: { failed, kept }`
|
|
2006
|
-
* tells the Server how many uploads failed and where the files still are
|
|
2007
|
-
* (`bridge.verify.evidence.retry` re-uploads them). Then, when a saved
|
|
2008
|
-
* sign-in was injected and the agent left its final `browser_storage_state`
|
|
2009
|
-
* export, refresh the record (CAS on the version we read). The dir is
|
|
2010
|
-
* deleted only when everything landed; otherwise it stays for the retry /
|
|
2011
|
-
* `kai-bridge doctor`.
|
|
2012
|
-
*/
|
|
2013
|
-
async finishVerification(turn, verify, res, cancelled) {
|
|
2014
|
-
const { turnId } = turn;
|
|
2015
|
-
await this.postVerifyStage(turnId, "saving");
|
|
2016
|
-
const redact = verify.redact?.size ? (v) => redactDeep(v, verify.redact) : (v) => v;
|
|
2017
|
-
const report = verify.report ? redact(verify.report) : null;
|
|
2018
|
-
// Verify turns never push, so the Server cannot derive the tested heads
|
|
2019
|
-
// from `changes[]` — stamp them here (repo key → HEAD of the checkout).
|
|
2020
|
-
// Companions booted alongside carry `role: "companion"` (their HEAD is
|
|
2021
|
-
// the code the API under test actually served).
|
|
2022
|
-
const revisions = [
|
|
2023
|
-
...(verify.bound || [])
|
|
2024
|
-
.map((b) => ({ repo: b.key, commit: currentHead(b.cwd) }))
|
|
2025
|
-
.filter((r) => r.repo && r.commit),
|
|
2026
|
-
...(verify.companionRevisions || []),
|
|
2027
|
-
];
|
|
2028
|
-
const fallbackReason = cancelled
|
|
2029
|
-
? "The verification was cancelled before Kai filed a report."
|
|
2030
|
-
: res.code !== 0
|
|
2031
|
-
? `The verification turn failed${res.lastError ? `: ${res.lastError.replace(/^acp-runner: /, "").slice(0, 300)}` : ""}.`
|
|
2032
|
-
: "Kai finished without filing a verification report";
|
|
2033
|
-
let uploads = [];
|
|
2034
|
-
let uploadFailures = 0;
|
|
2035
|
-
if (cancelled && !report) {
|
|
2036
|
-
this.log("info", "verify.cancelled.no_uploads", { turnId });
|
|
2037
|
-
} else {
|
|
2038
|
-
const swept = await this.uploadSweptEvidence(turnId, verify.artifactsDir, { report, redact: verify.redact, workDir: verify.workDir, signedIn: !!verify.storageStateFile });
|
|
2039
|
-
uploads = swept.uploads;
|
|
2040
|
-
uploadFailures = swept.failed;
|
|
2041
|
-
}
|
|
2042
|
-
const evidence = { failed: uploadFailures, ...(uploadFailures > 0 ? { kept: verify.artifactsDir } : {}) };
|
|
2043
|
-
const payload = redact({ ...buildVerificationPayload(report, uploads, { fallbackReason, cancelled, evidence, readOnly: typeof verify.readOnly === "boolean" ? verify.readOnly : null }), revisions });
|
|
2044
|
-
let reported = false;
|
|
2045
|
-
try {
|
|
2046
|
-
await this.api.turnVerification(turnId, payload, {
|
|
2047
|
-
onRetry: (err, attempt, delay) => this.log("warn", "verify.report.retry", { turnId, attempt, nextInMs: delay, error: err.message }),
|
|
2048
|
-
});
|
|
2049
|
-
reported = true;
|
|
2050
|
-
} catch (err) {
|
|
2051
|
-
this.log("error", "verify.report.failed", { turnId, error: err.message });
|
|
2052
|
-
}
|
|
2053
|
-
this.log("info", "verify.reported", { turnId, status: payload.status, blockedCode: payload.blockedCode, checks: payload.checks.length, artifacts: uploads.length, uploadFailures, reported });
|
|
2054
|
-
// The final-state export lives in the evidence dir: refresh the record
|
|
2055
|
-
// from it BEFORE the dir is deleted.
|
|
2056
|
-
await this.refreshPreviewLogin(turn, verify);
|
|
2057
|
-
if ((reported && uploadFailures === 0) || (cancelled && !report)) rmSync(verify.artifactsDir, { recursive: true, force: true });
|
|
2058
|
-
else this.log("warn", "verify.artifacts.kept", { turnId, dir: verify.artifactsDir });
|
|
2059
|
-
return { status: payload.status };
|
|
2060
|
-
}
|
|
2061
|
-
|
|
2062
|
-
/**
|
|
2063
|
-
* A preview that Verify booted for itself is released once the run has
|
|
2064
|
-
* settled — a manual preview keeps running until the user stops it. A
|
|
2065
|
-
* `failed` run with fix attempts left keeps the app up: the fix turn's
|
|
2066
|
-
* auto re-verify is seconds away and the Server restarts changed services.
|
|
2067
|
-
*/
|
|
2068
|
-
async settleVerifyPreview(turn, status) {
|
|
2069
|
-
const sessionId = turn?.sessionId;
|
|
2070
|
-
if (!sessionId || !this.verifyOwnedPreviews?.has(sessionId)) return false;
|
|
2071
|
-
if (this.manualPreviews?.has(sessionId)) return false;
|
|
2072
|
-
const loop = turn.loop && typeof turn.loop === "object" ? turn.loop : null;
|
|
2073
|
-
const retryPending = status === "failed" && loop && Number(loop.attempt ?? 0) < Number(loop.max ?? 0);
|
|
2074
|
-
if (retryPending) return false;
|
|
2075
|
-
this.log("info", "verify.preview.released", { sessionId, status });
|
|
2076
|
-
await this.previewStop({ sessionId });
|
|
2077
|
-
return true;
|
|
2078
|
-
}
|
|
2079
|
-
|
|
2080
|
-
/**
|
|
2081
|
-
* Sweep an evidence dir, union it with the report's declared artifacts,
|
|
2082
|
-
* redact the HTTP transcript in place, upload everything. Resolves
|
|
2083
|
-
* `{ uploads: [{ artifact, url }], failed }`. Trace archives are dropped
|
|
2084
|
-
* from signed-in runs (they contain every request with its cookies).
|
|
2085
|
-
*/
|
|
2086
|
-
async uploadSweptEvidence(turnId, artifactsDir, { report = null, redact = null, workDir = null, signedIn = false } = {}) {
|
|
2087
|
-
const swept = sweepArtifacts(artifactsDir).filter((p) => !(signedIn && /\.zip$/i.test(p)));
|
|
2088
|
-
const artifacts = unionArtifacts(report, swept, { outputDir: artifactsDir, workDir }).filter((a) => !(signedIn && a.kind === "trace"));
|
|
2089
|
-
const uploads = [];
|
|
2090
|
-
let failed = 0;
|
|
2091
|
-
for (const artifact of artifacts) {
|
|
2092
|
-
if (artifact.kind === "requests") redactJsonlFile(artifact.path, redact);
|
|
2093
|
-
try {
|
|
2094
|
-
const { url } = await this.api.uploadArtifact(turnId, artifact.path, artifact.contentType, {
|
|
2095
|
-
onRetry: (err, attempt, delay) => this.log("warn", "verify.upload.retry", { turnId, file: artifact.path, attempt, nextInMs: delay, error: err.message }),
|
|
2096
|
-
});
|
|
2097
|
-
uploads.push({ artifact, url });
|
|
2098
|
-
} catch (err) {
|
|
2099
|
-
failed += 1;
|
|
2100
|
-
this.log("warn", "verify.upload.failed", { turnId, file: artifact.path, error: err.message });
|
|
2101
|
-
}
|
|
2102
|
-
}
|
|
2103
|
-
return { uploads, failed, swept: swept.length };
|
|
2104
|
-
}
|
|
2105
|
-
|
|
2106
|
-
/**
|
|
2107
|
-
* `bridge.verify.evidence.retry { turnId }`: the evidence dir kept after
|
|
2108
|
-
* failed uploads is swept and uploaded again; the Server unions the
|
|
2109
|
-
* artifacts by URL (`PUT …/verification/artifacts`) and clears
|
|
2110
|
-
* `evidenceMissing`. The dir goes once everything landed.
|
|
2111
|
-
*/
|
|
2112
|
-
async retryEvidence({ commandId, turnId }) {
|
|
2113
|
-
const ack = (payload) => (commandId ? this.api.commandAck(commandId, payload).catch((err) => this.log("warn", "verify.evidence.ack.failed", { turnId, error: err?.message })) : Promise.resolve());
|
|
2114
|
-
if (!turnId) return ack({ ok: false, error: "evidence retry without a turnId" });
|
|
2115
|
-
const dir = join(this.kaiHome, "artifacts", String(turnId).replace(/[^\w.-]/g, "_"));
|
|
2116
|
-
if (!existsSync(dir)) {
|
|
2117
|
-
this.log("warn", "verify.evidence.retry.missing", { turnId, dir });
|
|
2118
|
-
return ack({ ok: false, error: "The evidence for this run is no longer on this machine." });
|
|
2119
|
-
}
|
|
2120
|
-
const { uploads, failed, swept } = await this.uploadSweptEvidence(turnId, dir);
|
|
2121
|
-
const artifacts = uploads.map((u) => ({ label: u.artifact.label, url: u.url, contentType: u.artifact.contentType, kind: u.artifact.kind, ...(u.artifact.count > 0 ? { count: u.artifact.count } : {}) }));
|
|
2122
|
-
const evidence = { failed, ...(failed > 0 ? { kept: dir } : {}) };
|
|
2123
|
-
try {
|
|
2124
|
-
await this.api.putVerificationArtifacts(turnId, { artifacts, evidence });
|
|
2125
|
-
} catch (err) {
|
|
2126
|
-
this.log("error", "verify.evidence.retry.failed", { turnId, error: err?.message });
|
|
2127
|
-
return ack({ ok: false, error: err?.message || String(err), uploaded: artifacts.length, failed });
|
|
2128
|
-
}
|
|
2129
|
-
this.log("info", "verify.evidence.retried", { turnId, swept, uploaded: artifacts.length, failed });
|
|
2130
|
-
if (failed === 0) rmSync(dir, { recursive: true, force: true });
|
|
2131
|
-
return ack({ ok: true, uploaded: artifacts.length, failed });
|
|
2132
|
-
}
|
|
2133
|
-
|
|
2134
|
-
/**
|
|
2135
|
-
* The agent's final `browser_storage_state` export (cookies + localStorage
|
|
2136
|
-
* — what the app may have rotated during the run) merged over the probe
|
|
2137
|
-
* export's IndexedDB, filtered to the preview origins, mapped BACK onto
|
|
2138
|
-
* the record's captured origins, and PUT with the version we read. 409 =
|
|
2139
|
-
* another run refreshed first → ours is discarded. Only for injected
|
|
2140
|
-
* records (a dev.yaml command owns its own state).
|
|
2141
|
-
*/
|
|
2142
|
-
async refreshPreviewLogin(turn, verify) {
|
|
2143
|
-
const { turnId } = turn;
|
|
2144
|
-
const record = verify.injected?.record;
|
|
2145
|
-
if (!record?.id || verify.injected.source !== "record" || !verify.finalStateFile) return;
|
|
2146
|
-
let final;
|
|
2147
|
-
try {
|
|
2148
|
-
if (!existsSync(verify.finalStateFile)) {
|
|
2149
|
-
this.log("info", "verify.login.refresh.skipped", { turnId, reason: "no final state" });
|
|
2150
|
-
return;
|
|
2151
|
-
}
|
|
2152
|
-
final = JSON.parse(readFileSync(verify.finalStateFile, "utf8"));
|
|
2153
|
-
} catch (err) {
|
|
2154
|
-
this.log("warn", "verify.login.refresh.unreadable", { turnId, error: err?.message });
|
|
2155
|
-
return;
|
|
2156
|
-
}
|
|
2157
|
-
const merged = mergeFinalState(verify.injected.probeExport, final);
|
|
2158
|
-
const { state: filtered, counts } = filterStorageState(merged, verify.stateOrigins ?? verify.previewOrigins);
|
|
2159
|
-
const back = rewriteStorageState(filtered, invertOriginMap(verify.injected.originMap));
|
|
2160
|
-
if (storageStateIsEmpty(back)) {
|
|
2161
|
-
this.log("info", "verify.login.refresh.skipped", { turnId, reason: "empty state" });
|
|
2162
|
-
return;
|
|
2163
|
-
}
|
|
2164
|
-
try {
|
|
2165
|
-
await this.api.refreshPreviewLogin(record.id, { storageState: back, basedOnVersion: record.version });
|
|
2166
|
-
this.log("info", "verify.login.refreshed", { turnId, recordId: record.id, basedOnVersion: record.version, ...counts });
|
|
2167
|
-
} catch (err) {
|
|
2168
|
-
this.log(err?.status === 409 ? "info" : "warn", err?.status === 409 ? "verify.login.refresh.stale" : "verify.login.refresh.failed", { turnId, recordId: record.id, error: err?.message });
|
|
2169
|
-
}
|
|
2170
|
-
}
|
|
2171
|
-
|
|
2172
|
-
// ── sign-in windows (bridge.verify.login.*) ────────────────────────
|
|
2173
|
-
/**
|
|
2174
|
-
* Open a headed Chrome at the preview for the user to sign in; the
|
|
2175
|
-
* capture uploads the filtered storage state against `requestId`. Acks
|
|
2176
|
-
* `{ ok: true, opened: true }` once the window is up, `{ ok: false,
|
|
2177
|
-
* error }` when this machine cannot (updating, no display, preview dead).
|
|
2178
|
-
* Idempotent per requestId (the Server replays after sleep).
|
|
2179
|
-
*/
|
|
2180
|
-
async loginStart({ commandId, requestId, sessionId, repoKey, previewNeeded, title, repos }) {
|
|
2181
|
-
const ack = (payload) => (commandId ? this.api.commandAck(commandId, payload).catch((err) => this.log("warn", "verify.login.ack.failed", { requestId, error: err?.message })) : Promise.resolve());
|
|
2182
|
-
const status = (payload) => this.api.previewLoginStatus(requestId, payload).catch((err) => this.log("warn", "verify.login.status.failed", { requestId, error: err?.message }));
|
|
2183
|
-
if (!requestId) {
|
|
2184
|
-
await ack({ ok: false, error: "Sign-in request without a requestId." });
|
|
2185
|
-
return;
|
|
2186
|
-
}
|
|
2187
|
-
if (this.logins.has(requestId)) {
|
|
2188
|
-
await ack({ ok: true, opened: true, replayed: true });
|
|
2189
|
-
return;
|
|
2190
|
-
}
|
|
2191
|
-
const entry = { requestId, sessionId, repoKey, handle: null, startedAt: Date.now() };
|
|
2192
|
-
this.logins.set(requestId, entry);
|
|
2193
|
-
try {
|
|
2194
|
-
if (this.updating) throw new Error("This machine is updating kai-bridge — try again in a minute.");
|
|
2195
|
-
if (!this.displayAvailable()) throw new Error("This machine has no display to open a sign-in window on.");
|
|
2196
|
-
const pw = this.loadPlaywright();
|
|
2197
|
-
if (!pw?.chromium) throw new Error("Playwright is not installed next to kai-bridge — reinstall @gleapai/kai-bridge.");
|
|
2198
|
-
const browser = await this.ensurePreviewBrowser().catch((err) => ({ ok: false, error: err?.message }));
|
|
2199
|
-
if (!browser?.ok) throw new Error(browser?.error || "No browser is available on this machine.");
|
|
2200
|
-
// Liveness first: booting a cold preview can take minutes, and the
|
|
2201
|
-
// Server fails a request nobody acknowledged as "device unreachable".
|
|
2202
|
-
// `accepted` moves the request off pending without claiming a window.
|
|
2203
|
-
await ack({ ok: true, accepted: true });
|
|
2204
|
-
// The preview must be up for the user to sign in to — boot it (or,
|
|
2205
|
-
// when it already runs, only re-describe it: previewStart is
|
|
2206
|
-
// idempotent on a live runner and we need its URLs either way).
|
|
2207
|
-
// `repos`/`title` may be absent on the command; then the request's
|
|
2208
|
-
// repo is the whole session and the worktree is looked up by id.
|
|
2209
|
-
const sessionRepos = Array.isArray(repos) && repos.length ? repos : [{ key: repoKey }];
|
|
2210
|
-
this.log("info", "verify.login.preview", { requestId, sessionId, previewNeeded: !!previewNeeded, live: this.services.has(sessionId) });
|
|
2211
|
-
const preview = await this.previewStart({ sessionId, title, repos: sessionRepos });
|
|
2212
|
-
if (preview?.status !== "running") throw new Error(preview?.error ? `The preview could not be started: ${preview.error}` : "The preview could not be started.");
|
|
2213
|
-
entry.previews = preview.previews || [];
|
|
2214
|
-
const runner = this.services.get(sessionId);
|
|
2215
|
-
const services = runner?.describeServices?.() ?? (entry.previews || []).map((p) => ({ name: p.name, url: p.url }));
|
|
2216
|
-
const previewOrigins = [...new Set([...services.map((s) => normalizeOrigin(s.url)), ...(entry.previews || []).map((p) => normalizeOrigin(p.url))].filter(Boolean))];
|
|
2217
|
-
// dev.yaml `external` origins may hold part of the app's own sign-in
|
|
2218
|
-
// state — keepable, but never "back on the app" for the detection.
|
|
2219
|
-
const external = [...new Set([...new Set((services || []).map((s) => s.repoRoot).filter(Boolean))].flatMap((root) => readDevConfig(root)?.external || []))];
|
|
2220
|
-
const url = (entry.previews || []).find((p) => p.repo === repoKey)?.url ?? (entry.previews || [])[0]?.url ?? services[0]?.url;
|
|
2221
|
-
if (!url) throw new Error("The preview has no URL to open.");
|
|
2222
|
-
// Keep the preview alive while the window is open (the idle timer
|
|
2223
|
-
// would otherwise stop the app under the user's nose).
|
|
2224
|
-
this.armPreviewIdleTimer(sessionId);
|
|
2225
|
-
const handle = await this.captureLogin({
|
|
2226
|
-
pw,
|
|
2227
|
-
launchOptions: launchOptionsFor({ headless: false, browser: browser.browser === "chrome" ? "chrome" : null }),
|
|
2228
|
-
url,
|
|
2229
|
-
previewOrigins,
|
|
2230
|
-
stateOrigins: [...previewOrigins, ...external],
|
|
2231
|
-
services: services.map((s) => ({ name: s.name, origin: normalizeOrigin(s.url) })),
|
|
2232
|
-
onStatus: (st, extra) => {
|
|
2233
|
-
this.log("info", "verify.login.status", { requestId, status: st, ...(extra?.error ? { error: extra.error } : {}) });
|
|
2234
|
-
return status({ status: st, ...(extra?.error ? { error: String(extra.error) } : {}) });
|
|
2235
|
-
},
|
|
2236
|
-
onSaved: async (payload, counts) => {
|
|
2237
|
-
await this.api.uploadPreviewLogin(requestId, payload, {
|
|
2238
|
-
tries: 3,
|
|
2239
|
-
onRetry: (err, attempt, delay) => this.log("warn", "verify.login.upload.retry", { requestId, attempt, nextInMs: delay, error: err.message }),
|
|
2240
|
-
});
|
|
2241
|
-
this.log("info", "verify.login.saved", { requestId, repoKey, ...counts, loginPaths: payload.loginPaths.length });
|
|
2242
|
-
},
|
|
2243
|
-
log: this.log,
|
|
2244
|
-
});
|
|
2245
|
-
void handle.finished.then((outcome) => {
|
|
2246
|
-
this.logins.delete(requestId);
|
|
2247
|
-
this.log("info", "verify.login.finished", { requestId, status: outcome?.status, error: outcome?.error });
|
|
2248
|
-
this.hello().catch(() => {});
|
|
2249
|
-
if ((this.updatePending || this.restartPending) && this.running.size === 0 && this.logins.size === 0) void this.checkForUpdate();
|
|
2250
|
-
});
|
|
2251
|
-
if (entry.cancelled) {
|
|
2252
|
-
// Cancelled while Chrome was still launching (the Server has already
|
|
2253
|
-
// settled the request): close the window it just opened, no ack of
|
|
2254
|
-
// `opened`, no status (the cancel already answered).
|
|
2255
|
-
this.log("info", "verify.login.cancelled.launching", { requestId });
|
|
2256
|
-
await handle.cancel();
|
|
2257
|
-
await ack({ ok: false, error: "The sign-in was cancelled before the window opened." });
|
|
2258
|
-
return;
|
|
2259
|
-
}
|
|
2260
|
-
entry.handle = handle;
|
|
2261
|
-
await ack({ ok: true, opened: true });
|
|
2262
|
-
await status({ status: "opened" });
|
|
2263
|
-
// Re-announce right away: `activeLogins` is how the dashboard knows a window is open.
|
|
2264
|
-
await this.hello().catch((err) => this.log("warn", "verify.login.hello.failed", { requestId, error: err?.message }));
|
|
2265
|
-
} catch (err) {
|
|
2266
|
-
this.logins.delete(requestId);
|
|
2267
|
-
this.log("error", "verify.login.start.failed", { requestId, error: err?.message });
|
|
2268
|
-
await ack({ ok: false, error: err?.message || String(err) });
|
|
2269
|
-
await status({ status: "failed", error: err?.message || String(err) });
|
|
2270
|
-
}
|
|
2271
|
-
}
|
|
2272
|
-
|
|
2273
|
-
/** "Mark done": export whatever the window holds now (the heuristic may have missed the sign-in). */
|
|
2274
|
-
async loginDone({ commandId, requestId }) {
|
|
2275
|
-
try {
|
|
2276
|
-
const entry = this.logins.get(requestId);
|
|
2277
|
-
if (!entry?.handle) throw new Error("No sign-in window is open for this request on this machine.");
|
|
2278
|
-
await entry.handle.done();
|
|
2279
|
-
if (commandId) await this.api.commandAck(commandId, { ok: true });
|
|
2280
|
-
} catch (err) {
|
|
2281
|
-
this.log("warn", "verify.login.done.failed", { requestId, error: err?.message });
|
|
2282
|
-
if (commandId) await this.api.commandAck(commandId, { ok: false, error: err?.message || String(err) }).catch(() => {});
|
|
2283
|
-
}
|
|
2284
|
-
}
|
|
2285
|
-
|
|
2286
|
-
async loginCancel({ commandId, requestId }) {
|
|
2287
|
-
try {
|
|
2288
|
-
const entry = this.logins.get(requestId);
|
|
2289
|
-
if (!entry) throw new Error("No sign-in window is open for this request on this machine.");
|
|
2290
|
-
if (entry.handle) await entry.handle.cancel();
|
|
2291
|
-
// Still launching: loginStart closes the window as soon as it is up.
|
|
2292
|
-
else entry.cancelled = true;
|
|
2293
|
-
if (commandId) await this.api.commandAck(commandId, { ok: true });
|
|
2294
|
-
} catch (err) {
|
|
2295
|
-
this.log("warn", "verify.login.cancel.failed", { requestId, error: err?.message });
|
|
2296
|
-
if (commandId) await this.api.commandAck(commandId, { ok: false, error: err?.message || String(err) }).catch(() => {});
|
|
2297
|
-
}
|
|
2298
|
-
}
|
|
2299
|
-
|
|
2300
|
-
/** Close every sign-in window matching `filter` (session close, shutdown). */
|
|
2301
|
-
async teardownLogins(filter, reason) {
|
|
2302
|
-
if (!this.logins?.size) return;
|
|
2303
|
-
const victims = [...this.logins.values()].filter((l) => {
|
|
2304
|
-
try {
|
|
2305
|
-
return filter(l);
|
|
2306
|
-
} catch {
|
|
2307
|
-
return false;
|
|
2308
|
-
}
|
|
2309
|
-
});
|
|
2310
|
-
for (const l of victims) {
|
|
2311
|
-
this.log("info", "verify.login.teardown", { requestId: l.requestId, reason });
|
|
2312
|
-
this.logins.delete(l.requestId);
|
|
2313
|
-
l.cancelled = true; // still launching → loginStart closes it on arrival
|
|
2314
|
-
await l.handle?.cancel?.().catch(() => {});
|
|
2315
|
-
}
|
|
2316
|
-
}
|
|
2317
|
-
|
|
2318
1880
|
/**
|
|
2319
1881
|
* Mid-turn steering: forward the message to the running runner's stdin
|
|
2320
1882
|
* control channel. The runner answers with a `steer` turn event
|
|
@@ -2390,6 +1952,43 @@ export class BridgeDaemon {
|
|
|
2390
1952
|
return { note: notes.join(""), hasLivePreview: previews.length > 0 };
|
|
2391
1953
|
}
|
|
2392
1954
|
|
|
1955
|
+
/**
|
|
1956
|
+
* Run a git network op with this machine's own credentials; when those are
|
|
1957
|
+
* rejected, retry ONCE with the Server-issued credentials for `repoKey`
|
|
1958
|
+
* (connected repos only — anything else rethrows the original error).
|
|
1959
|
+
*/
|
|
1960
|
+
async withGitAuth(repoKey, run) {
|
|
1961
|
+
try {
|
|
1962
|
+
return await run(null);
|
|
1963
|
+
} catch (err) {
|
|
1964
|
+
if (!repoKey || !isGitAuthError(err)) throw err;
|
|
1965
|
+
let authHeader;
|
|
1966
|
+
try {
|
|
1967
|
+
authHeader = await this.gitAuthHeader(repoKey);
|
|
1968
|
+
} catch (credErr) {
|
|
1969
|
+
this.log("warn", "git.auth.unavailable", { repo: repoKey, error: credErr.message });
|
|
1970
|
+
throw err;
|
|
1971
|
+
}
|
|
1972
|
+
this.log("info", "git.auth.fallback", { repo: repoKey });
|
|
1973
|
+
try {
|
|
1974
|
+
return await run(gitAuthEnv(authHeader));
|
|
1975
|
+
} catch (retryErr) {
|
|
1976
|
+
// Stale or revoked: never reuse it for the next attempt.
|
|
1977
|
+
if (isGitAuthError(retryErr)) this.gitAuth.delete(repoKey);
|
|
1978
|
+
throw retryErr;
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
async gitAuthHeader(repoKey) {
|
|
1984
|
+
const cached = this.gitAuth.get(repoKey);
|
|
1985
|
+
if (cached && Date.now() - cached.at < GIT_AUTH_TTL_MS) return cached.authHeader;
|
|
1986
|
+
const { authHeader } = await this.api.gitCredentials(repoKey);
|
|
1987
|
+
if (!authHeader) throw new Error("The Server returned no git credentials.");
|
|
1988
|
+
this.gitAuth.set(repoKey, { authHeader, at: Date.now() });
|
|
1989
|
+
return authHeader;
|
|
1990
|
+
}
|
|
1991
|
+
|
|
2393
1992
|
/**
|
|
2394
1993
|
* Clone `remote` into the preferred root as `name`. Hardened for
|
|
2395
1994
|
* unattended runs: no credential prompts (`GIT_TERMINAL_PROMPT=0`,
|
|
@@ -2401,7 +2000,7 @@ export class BridgeDaemon {
|
|
|
2401
2000
|
async cloneRepo({ commandId, remote, name, repoKey, timeoutMs = CLONE_TIMEOUT_MS }) {
|
|
2402
2001
|
const root = preferredCloneRoot(this.repoGroups, (this.config.roots || [])[0] ?? defaultRoots()[0]);
|
|
2403
2002
|
try {
|
|
2404
|
-
const target = await cloneRepository({ remote, name, root, repoKey, timeoutMs });
|
|
2003
|
+
const target = await this.withGitAuth(repoKey, (gitEnv) => cloneRepository({ remote, name, root, repoKey, timeoutMs, gitEnv }));
|
|
2405
2004
|
await this.scanRepos();
|
|
2406
2005
|
// Ack only after the Server has the new inventory: recovery checks
|
|
2407
2006
|
// readiness before automatically retrying the blocked session.
|
|
@@ -2523,9 +2122,44 @@ export function defaultRealtimeFactory({ config, channel, onEvent, onState }) {
|
|
|
2523
2122
|
});
|
|
2524
2123
|
client.connection?.bind?.("state_change", (s) => onState(s.current));
|
|
2525
2124
|
const ch = client.subscribe(channel);
|
|
2526
|
-
ch.bind_global?.((name, data) => {
|
|
2527
|
-
if (typeof name === "string" && name.startsWith("bridge.")) onEvent(name, data);
|
|
2528
|
-
});
|
|
2125
|
+
ch.bind_global?.((name, data) => routeRealtimeEvent(name, data, { onEvent, onState }));
|
|
2529
2126
|
})().catch((err) => onState(`error:${err.message}`));
|
|
2530
2127
|
return { disconnect: () => client?.disconnect?.() };
|
|
2531
2128
|
}
|
|
2129
|
+
|
|
2130
|
+
/**
|
|
2131
|
+
* One channel event → command or state. `pusher:subscription_error` is the
|
|
2132
|
+
* case that used to slip through: the socket is up, so the connection
|
|
2133
|
+
* reports `connected`, but the private-channel auth failed (the API was
|
|
2134
|
+
* restarting) and nothing is subscribed — the daemon sat "online" and deaf
|
|
2135
|
+
* until the next socket drop. Routed as a state, handleRealtimeState
|
|
2136
|
+
* reconnects from scratch like any other terminal state.
|
|
2137
|
+
*/
|
|
2138
|
+
export function routeRealtimeEvent(name, data, { onEvent, onState }) {
|
|
2139
|
+
if (typeof name !== "string") return;
|
|
2140
|
+
if (name === "pusher:subscription_error") {
|
|
2141
|
+
const detail = [data?.status, data?.error].filter((v) => v !== undefined && v !== null && v !== "").join(" ");
|
|
2142
|
+
onState(`subscription_error${detail ? `:${detail}` : ""}`);
|
|
2143
|
+
return;
|
|
2144
|
+
}
|
|
2145
|
+
if (name.startsWith("bridge.")) onEvent(name, data);
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
/**
|
|
2149
|
+
* A probe that could not run (the CLI timed out, could not be spawned)
|
|
2150
|
+
* answers `unknown`; announcing that would flip the dashboard to
|
|
2151
|
+
* "signed out" until the next good probe. Carry the last definite state
|
|
2152
|
+
* for that profile instead, and remember every definite answer.
|
|
2153
|
+
*/
|
|
2154
|
+
export function carryAuthStates(profiles, lastAuthStates) {
|
|
2155
|
+
for (const p of profiles) {
|
|
2156
|
+
if (!p?.id) continue;
|
|
2157
|
+
if (p.authState === "unknown") {
|
|
2158
|
+
const last = lastAuthStates.get(p.id);
|
|
2159
|
+
if (last) Object.assign(p, last);
|
|
2160
|
+
} else if (p.authState) {
|
|
2161
|
+
lastAuthStates.set(p.id, { authState: p.authState, account: p.account ?? null });
|
|
2162
|
+
}
|
|
2163
|
+
}
|
|
2164
|
+
return profiles;
|
|
2165
|
+
}
|