@gleapai/kai-bridge 0.9.1 → 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 +2 -2
- 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 +16 -102
- package/src/companions.mjs +3 -2
- package/src/daemon.mjs +607 -1054
- package/src/executor.mjs +2 -2
- package/src/gateway.mjs +217 -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 +291 -101
- 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 +1 -1
- 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,48 +12,34 @@ 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";
|
|
57
43
|
import { GIT_AUTH_TTL_MS, gitAuthEnv, isGitAuthError } from "./git-auth.mjs";
|
|
58
44
|
import { describeHarnesses, installHarness, probeHarnessAuth } from "./harnesses.mjs";
|
|
59
45
|
import { probeHarnessModels } from "./models.mjs";
|
|
@@ -61,28 +47,16 @@ import { decideRestart, decideUpdate, fetchLatestVersion, installVersion, instal
|
|
|
61
47
|
import { dirname } from "node:path";
|
|
62
48
|
import { fileURLToPath } from "node:url";
|
|
63
49
|
|
|
64
|
-
const PROBE_TIMEOUT_COLD_BOOT_MS = 60_000;
|
|
65
50
|
const CLONE_TIMEOUT_MS = 3 * 60_000;
|
|
66
51
|
const COMPANION_MAX_DEPTH = 3;
|
|
67
52
|
const RUNNER_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "runner");
|
|
68
53
|
|
|
69
|
-
/** A captured landing URL moved onto the current origin map (null when its origin is not running). */
|
|
70
|
-
function rewriteUrl(url, originMap) {
|
|
71
|
-
const origin = normalizeOrigin(url);
|
|
72
|
-
const to = origin && originMap ? originMap.get(origin) : null;
|
|
73
|
-
if (!to) return null;
|
|
74
|
-
try {
|
|
75
|
-
const u = new URL(String(url));
|
|
76
|
-
return `${to}${u.pathname}${u.search}${u.hash}`;
|
|
77
|
-
} catch {
|
|
78
|
-
return null;
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
54
|
/** Lock paths held by daemons in THIS process (cross-process uses the file). */
|
|
83
55
|
const HELD_LOCKS = new Set();
|
|
84
56
|
const REALTIME_RETRY_MS = 15_000;
|
|
85
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;
|
|
86
60
|
const USAGE_REFRESH_MS = 10 * 60_000;
|
|
87
61
|
// Harness model catalogues change on releases, not by the minute.
|
|
88
62
|
const MODELS_REFRESH_MS = 6 * 60 * 60_000;
|
|
@@ -171,9 +145,6 @@ export class BridgeDaemon {
|
|
|
171
145
|
this.updateAvailable = null; // newer registry version, when one exists
|
|
172
146
|
this.updateError = null; // why the last self-update did not apply
|
|
173
147
|
this.updating = false; // npm is replacing our files — refuse new turns
|
|
174
|
-
this.logins = new Map(); // requestId → { sessionId, repoKey, handle } — open sign-in windows (preview-login.mjs)
|
|
175
|
-
this.verifyLocks = new Map(); // `${userId}:${repoKey}` → promise chain (one probe/refresh of a sign-in at a time)
|
|
176
|
-
this.previewStatusListeners = new Map(); // sessionId → (message) => void — the booting heartbeat of a verify turn
|
|
177
148
|
this.unknownDevKeysLogged = new Set(); // "<repo>:<key>" — unknown dev.yaml keys are logged once
|
|
178
149
|
this.restartPending = false; // files on disk are a different version than the one we loaded; restart when idle
|
|
179
150
|
}
|
|
@@ -187,7 +158,7 @@ export class BridgeDaemon {
|
|
|
187
158
|
}
|
|
188
159
|
|
|
189
160
|
async hello() {
|
|
190
|
-
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()));
|
|
191
162
|
const repos = toDeviceRepoReport(this.repoGroups);
|
|
192
163
|
return this.api.hello({
|
|
193
164
|
name: this.config.device?.name,
|
|
@@ -201,10 +172,6 @@ export class BridgeDaemon {
|
|
|
201
172
|
updateAvailable: this.updateAvailable || null,
|
|
202
173
|
updateError: this.updateError || null,
|
|
203
174
|
autoUpdate: this.config.autoUpdate !== false,
|
|
204
|
-
// Verify sign-in: can this machine open a headed Chrome window, and
|
|
205
|
-
// which sign-in requests are open right now (replayed after sleep).
|
|
206
|
-
capabilities: { display: this.displayAvailable() },
|
|
207
|
-
activeLogins: [...this.logins.keys()],
|
|
208
175
|
});
|
|
209
176
|
}
|
|
210
177
|
|
|
@@ -219,16 +186,29 @@ export class BridgeDaemon {
|
|
|
219
186
|
// (postinstall applies the patch; a self-update or --ignore-scripts
|
|
220
187
|
// install can leave it unpatched). Idempotent, best-effort.
|
|
221
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) });
|
|
222
192
|
// A previous run that was killed (reboot, crash, `kill -9`) never got
|
|
223
193
|
// to report its turns. Tell the server before doing anything else,
|
|
224
194
|
// so those sessions settle instead of spinning.
|
|
225
195
|
await this.reportInterruptedTurns();
|
|
226
196
|
// Dev servers a killed daemon left behind would hold the declared ports
|
|
227
|
-
// (→ port_busy on the next preview) — end them
|
|
228
|
-
//
|
|
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 }));
|
|
229
211
|
this.killOrphanedServices();
|
|
230
|
-
const swept = sweepOldArtifacts(join(this.kaiHome, "artifacts"), { maxAgeMs: ARTIFACT_MAX_AGE_MS });
|
|
231
|
-
if (swept.length) this.log("info", "artifacts.swept", { count: swept.length });
|
|
232
212
|
await this.scanRepos();
|
|
233
213
|
// The first hello must not kill the daemon: the server may be
|
|
234
214
|
// restarting (deploys, local nodemon) — retry with backoff instead
|
|
@@ -236,6 +216,7 @@ export class BridgeDaemon {
|
|
|
236
216
|
for (let delay = 5_000; ; delay = Math.min(delay * 2, 60_000)) {
|
|
237
217
|
try {
|
|
238
218
|
await this.hello();
|
|
219
|
+
await this.reportResumedPreviews();
|
|
239
220
|
break;
|
|
240
221
|
} catch (err) {
|
|
241
222
|
if (err?.status === 401 || err?.status === 403) {
|
|
@@ -263,10 +244,20 @@ export class BridgeDaemon {
|
|
|
263
244
|
// behind, node exited 0, and launchd's SuccessfulExit:false meant the
|
|
264
245
|
// machine stayed offline until the next login — silently.
|
|
265
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.
|
|
266
249
|
this.api
|
|
267
|
-
.heartbeat({ running: [...this.running.keys()] })
|
|
250
|
+
.heartbeat({ running: [...this.running.keys()], previews: [...this.services.keys()] })
|
|
251
|
+
.then(() => this.flushPendingReports())
|
|
268
252
|
.catch((err) => this.onApiError("heartbeat", err));
|
|
269
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?.();
|
|
270
261
|
// Plan-usage windows for the composer popover. Fire-and-forget on a
|
|
271
262
|
// slow cadence, unref'd (the heartbeat keeps the process alive), and
|
|
272
263
|
// NEVER in hello's path — the probe spawns the CLI (~2s per profile).
|
|
@@ -299,7 +290,7 @@ export class BridgeDaemon {
|
|
|
299
290
|
// into them as soon as no turn is running — BEFORE asking the registry,
|
|
300
291
|
// which needs network this check must not depend on.
|
|
301
292
|
const onDisk = installedVersionOrNull();
|
|
302
|
-
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 });
|
|
303
294
|
this.restartPending = restart.action === "defer";
|
|
304
295
|
if (restart.action === "restart") {
|
|
305
296
|
this.log("info", "update.restart.stale", { loaded: VERSION, installed: onDisk });
|
|
@@ -317,10 +308,9 @@ export class BridgeDaemon {
|
|
|
317
308
|
const decision = decideUpdate({
|
|
318
309
|
current: VERSION,
|
|
319
310
|
latest,
|
|
320
|
-
//
|
|
321
|
-
//
|
|
322
|
-
|
|
323
|
-
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,
|
|
324
314
|
autoUpdate: this.config.autoUpdate !== false,
|
|
325
315
|
lastAttempt: this.config.selfUpdate?.lastAttempt ?? null,
|
|
326
316
|
});
|
|
@@ -399,7 +389,11 @@ export class BridgeDaemon {
|
|
|
399
389
|
const candidates = profiles
|
|
400
390
|
.filter((p) => p.harness === harness && p.kind !== "gleap-key" && p.configDir)
|
|
401
391
|
.sort((a, b) => Number(b.kind === "ambient") - Number(a.kind === "ambient"));
|
|
402
|
-
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;
|
|
403
397
|
let models = null;
|
|
404
398
|
if (profile) {
|
|
405
399
|
try {
|
|
@@ -453,27 +447,35 @@ export class BridgeDaemon {
|
|
|
453
447
|
/**
|
|
454
448
|
* Shut down: abort turns, stop every preview (telling the dashboard why —
|
|
455
449
|
* `stopped` / `daemon_restarted`, so the card offers Start instead of
|
|
456
|
-
* showing dead links)
|
|
450
|
+
* showing dead links). Resolves once the preview
|
|
457
451
|
* reports have been given a bounded chance to land (the self-update
|
|
458
452
|
* restart awaits it; sync callers may ignore the promise).
|
|
459
453
|
*/
|
|
460
454
|
stop() {
|
|
461
455
|
this.stopped = true;
|
|
462
456
|
clearInterval(this.heartbeat);
|
|
457
|
+
clearInterval(this.pendingPoll);
|
|
463
458
|
clearInterval(this.usageTimer);
|
|
464
459
|
clearInterval(this.modelsTimer);
|
|
465
460
|
clearInterval(this.updateTimer);
|
|
466
461
|
if (this.realtimeRetry) clearTimeout(this.realtimeRetry);
|
|
467
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.
|
|
468
468
|
const reports = [];
|
|
469
469
|
for (const [sessionId, runner] of this.services) {
|
|
470
|
-
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();
|
|
471
475
|
this.clearPreviewIdleTimer(sessionId);
|
|
472
|
-
reports.push(this.api.sessionPreview(sessionId, { status: "stopped", urls: [], previews: [], errorCode: "daemon_restarted", error: "kai-bridge restarted — start the preview again." }).catch(() => {}));
|
|
473
476
|
}
|
|
474
477
|
this.services.clear();
|
|
475
|
-
this.
|
|
476
|
-
void this.teardownLogins(() => true, "daemon stopping");
|
|
478
|
+
for (const w of this.prewarming?.values() ?? []) void w.close();
|
|
477
479
|
this.realtime?.disconnect?.();
|
|
478
480
|
this.releaseLock();
|
|
479
481
|
return Promise.race([Promise.allSettled(reports), new Promise((r) => setTimeout(r, 3_000).unref?.())]).then(() => undefined);
|
|
@@ -531,7 +533,7 @@ export class BridgeDaemon {
|
|
|
531
533
|
return join(this.kaiHome, "state", "inflight.json");
|
|
532
534
|
}
|
|
533
535
|
|
|
534
|
-
/** `[{ turnId, sessionId?, agent
|
|
536
|
+
/** `[{ turnId, sessionId?, agent? }]` — pre-0.5.0 files held bare ids. */
|
|
535
537
|
readInflight() {
|
|
536
538
|
try {
|
|
537
539
|
const raw = JSON.parse(readFileSync(this.inflightPath, "utf8"));
|
|
@@ -561,12 +563,7 @@ export class BridgeDaemon {
|
|
|
561
563
|
this.writeInflight(this.readInflight().filter((e) => e.turnId !== turnId));
|
|
562
564
|
}
|
|
563
565
|
|
|
564
|
-
/**
|
|
565
|
-
* Turns this machine was running when it was killed — report them dead.
|
|
566
|
-
* A verify turn also gets a `blocked` report ("kai-bridge restarted
|
|
567
|
-
* mid-run") carrying whatever evidence the browser had written, so the
|
|
568
|
-
* card shows the partial recording instead of spinning.
|
|
569
|
-
*/
|
|
566
|
+
/** Turns this machine was running when it was killed — report them dead. */
|
|
570
567
|
async reportInterruptedTurns() {
|
|
571
568
|
const entries = this.readInflight();
|
|
572
569
|
if (!entries.length) return;
|
|
@@ -574,16 +571,6 @@ export class BridgeDaemon {
|
|
|
574
571
|
for (const entry of entries) {
|
|
575
572
|
const { turnId } = entry;
|
|
576
573
|
this.log("warn", "turn.interrupted", { turnId, agent: entry.agent });
|
|
577
|
-
if (entry.agent === "kai-verifier") {
|
|
578
|
-
const uploads = entry.artifactsDir ? await this.uploadSweptEvidence(turnId, entry.artifactsDir) : { uploads: [], failed: 0 };
|
|
579
|
-
await this.api
|
|
580
|
-
.turnVerification(turnId, {
|
|
581
|
-
...buildVerificationPayload(null, uploads.uploads, { fallbackReason: "kai-bridge restarted mid-run", evidence: { failed: uploads.failed, ...(uploads.failed && entry.artifactsDir ? { kept: entry.artifactsDir } : {}) } }),
|
|
582
|
-
blockedCode: "other",
|
|
583
|
-
})
|
|
584
|
-
.catch((err) => this.log("warn", "verify.interrupted.report.failed", { turnId, error: err?.message }));
|
|
585
|
-
if (entry.artifactsDir && uploads.failed === 0) rmSync(entry.artifactsDir, { recursive: true, force: true });
|
|
586
|
-
}
|
|
587
574
|
await this.api
|
|
588
575
|
.turnResult(turnId, {
|
|
589
576
|
status: "failed",
|
|
@@ -616,6 +603,127 @@ export class BridgeDaemon {
|
|
|
616
603
|
}
|
|
617
604
|
}
|
|
618
605
|
|
|
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
|
+
|
|
619
727
|
/**
|
|
620
728
|
* ServiceRunner hook: every dev-server pid is persisted while it lives —
|
|
621
729
|
* with its port and the session's title/link, so `kai-bridge ps` can
|
|
@@ -654,9 +762,12 @@ export class BridgeDaemon {
|
|
|
654
762
|
*/
|
|
655
763
|
killOrphanedServices({ kill = process.kill, platform = process.platform } = {}) {
|
|
656
764
|
const entries = this.readPreviewPids();
|
|
657
|
-
|
|
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)));
|
|
658
768
|
let killed = 0;
|
|
659
769
|
for (const e of entries) {
|
|
770
|
+
if (keep.has(e.pid)) continue;
|
|
660
771
|
try {
|
|
661
772
|
kill(e.pid, 0); // alive?
|
|
662
773
|
} catch {
|
|
@@ -712,7 +823,7 @@ export class BridgeDaemon {
|
|
|
712
823
|
/* already gone */
|
|
713
824
|
}
|
|
714
825
|
this.connectRealtime();
|
|
715
|
-
}, REALTIME_RETRY_MS);
|
|
826
|
+
}, this.realtimeRetryMs ?? REALTIME_RETRY_MS);
|
|
716
827
|
}
|
|
717
828
|
}
|
|
718
829
|
|
|
@@ -728,6 +839,20 @@ export class BridgeDaemon {
|
|
|
728
839
|
this.log("warn", "reconnect.hello.failed", { error: err.message });
|
|
729
840
|
return;
|
|
730
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;
|
|
731
856
|
try {
|
|
732
857
|
const pending = await this.api.pendingTurns();
|
|
733
858
|
// Cancels the server settled while we were away (reaper / watchdog):
|
|
@@ -737,24 +862,21 @@ export class BridgeDaemon {
|
|
|
737
862
|
for (const turnId of cancelled) {
|
|
738
863
|
const run = this.running.get(turnId);
|
|
739
864
|
if (!run) continue;
|
|
740
|
-
this.log("info", "turn.cancel.replayed", { turnId });
|
|
865
|
+
this.log("info", "turn.cancel.replayed", { turnId, via });
|
|
741
866
|
run.ctrl.abort();
|
|
742
867
|
}
|
|
743
868
|
for (const turn of pending?.turns || []) {
|
|
744
869
|
if (this.running.has(turn.turnId) || cancelled.has(String(turn.turnId))) continue;
|
|
745
|
-
this.log("info", "turn.recovered", { turnId: turn.turnId });
|
|
870
|
+
this.log("info", "turn.recovered", { turnId: turn.turnId, via });
|
|
746
871
|
void this.startTurn(turn).catch((err) => this.log("error", "turn.recover.failed", { error: err.message }));
|
|
747
872
|
}
|
|
748
|
-
// Sign-in requests published while we were away: open their windows now.
|
|
749
|
-
for (const req of pending?.loginRequests || []) {
|
|
750
|
-
if (!req?.requestId || this.logins.has(req.requestId)) continue;
|
|
751
|
-
this.log("info", "verify.login.recovered", { requestId: req.requestId });
|
|
752
|
-
void this.loginStart(req).catch((err) => this.log("error", "verify.login.recover.failed", { error: err.message }));
|
|
753
|
-
}
|
|
754
873
|
} catch (err) {
|
|
755
|
-
// Older server without the endpoint
|
|
756
|
-
// fails orphaned turns, so this is a
|
|
757
|
-
|
|
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;
|
|
758
880
|
}
|
|
759
881
|
}
|
|
760
882
|
|
|
@@ -838,30 +960,33 @@ export class BridgeDaemon {
|
|
|
838
960
|
entry.ctrl.abort();
|
|
839
961
|
}
|
|
840
962
|
}
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
//
|
|
845
|
-
|
|
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
|
+
}
|
|
846
977
|
// Pre-0.4.0 daemons kept a browser profile per session; drop leftovers.
|
|
847
978
|
rmSync(join(this.kaiHome, "browser", String(data.sessionId).replace(/[^\w.-]/g, "_")), { recursive: true, force: true });
|
|
848
979
|
return;
|
|
849
|
-
case "bridge.verify.login.start":
|
|
850
|
-
return this.loginStart(data);
|
|
851
|
-
case "bridge.verify.login.done":
|
|
852
|
-
return this.loginDone(data);
|
|
853
|
-
case "bridge.verify.login.cancel":
|
|
854
|
-
return this.loginCancel(data);
|
|
855
|
-
case "bridge.verify.evidence.retry":
|
|
856
|
-
return this.retryEvidence(data);
|
|
857
980
|
case "bridge.preview.start":
|
|
858
|
-
// A preview the USER started stays up until they stop it (or it
|
|
859
|
-
// 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).
|
|
860
982
|
(this.manualPreviews ??= new Set()).add(data.sessionId);
|
|
861
|
-
this.verifyOwnedPreviews?.delete(data.sessionId);
|
|
862
983
|
return this.previewStart(data);
|
|
863
984
|
case "bridge.preview.stop":
|
|
864
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);
|
|
865
990
|
case "bridge.preview.keepalive":
|
|
866
991
|
// Dashboard heartbeat while someone is actually LOOKING at the
|
|
867
992
|
// preview — makes the idle auto-stop mean real idleness instead
|
|
@@ -884,20 +1009,23 @@ export class BridgeDaemon {
|
|
|
884
1009
|
* truncation. `companionRemotes` (Server-resolved `{ key: remote }`) is
|
|
885
1010
|
* how non-github companions get cloned; `note` survives onto error writes.
|
|
886
1011
|
*/
|
|
887
|
-
async previewStart({ sessionId, title, repos, sessionUrl = null, companionRemotes = null }) {
|
|
1012
|
+
async previewStart({ sessionId, title, repos, sessionUrl = null, companionRemotes = null, publicMode = false }) {
|
|
888
1013
|
this.rememberSession(sessionId, { title, sessionUrl, repos });
|
|
889
1014
|
let lastNote = null;
|
|
890
1015
|
const skipped = [];
|
|
891
|
-
// Every report is also RETURNED
|
|
892
|
-
// 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).
|
|
893
1017
|
const report = async (payload) => {
|
|
894
1018
|
if (payload.note) lastNote = payload.note;
|
|
895
|
-
const body =
|
|
896
|
-
|
|
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);
|
|
897
1025
|
return body;
|
|
898
1026
|
};
|
|
899
1027
|
const fail = (err, ctx = {}) => report(toPreviewErrorPayload(err, { ...ctx, skipped }));
|
|
900
|
-
await report({ status: "starting" });
|
|
1028
|
+
await report({ status: "starting", ...(publicMode ? { note: "Restarting with the public address…" } : {}) });
|
|
901
1029
|
let runner = null;
|
|
902
1030
|
try {
|
|
903
1031
|
// Pass 1 — resolve every session repo (checkout, env, config).
|
|
@@ -950,6 +1078,7 @@ export class BridgeDaemon {
|
|
|
950
1078
|
const remotes = companionRemotes && typeof companionRemotes === "object" ? companionRemotes : {};
|
|
951
1079
|
const { companions } = await collectCompanions({
|
|
952
1080
|
roots: resolved.map((r) => ({ key: r.key, config: r.config })),
|
|
1081
|
+
extra: this.reverseCompanions(resolved),
|
|
953
1082
|
maxDepth: COMPANION_MAX_DEPTH,
|
|
954
1083
|
loadConfig: async (key) => {
|
|
955
1084
|
try {
|
|
@@ -978,11 +1107,38 @@ export class BridgeDaemon {
|
|
|
978
1107
|
}
|
|
979
1108
|
const bootable = companions.filter((c) => c.config);
|
|
980
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
|
+
|
|
981
1128
|
runner = this.runnerFor(sessionId);
|
|
982
1129
|
// Pass 3 — ports for EVERY config (companions + session repos) before
|
|
983
1130
|
// any boot, so `${port:x}` cross-references resolve whatever the order.
|
|
984
|
-
for (const c of bootable) await runner.assignPorts(c.group.primary.path, c.config, { mode: "local", repoKey: c.key });
|
|
985
|
-
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
|
+
}
|
|
986
1142
|
|
|
987
1143
|
// Pass 4 — boot companions (deepest first, local mode: an already
|
|
988
1144
|
// running dev server of that repo is adopted), then the session repos
|
|
@@ -1013,19 +1169,189 @@ export class BridgeDaemon {
|
|
|
1013
1169
|
}
|
|
1014
1170
|
}
|
|
1015
1171
|
const urls = [...previews, ...companionPreviews];
|
|
1016
|
-
// The landing page
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
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
|
+
});
|
|
1020
1202
|
} catch (err) {
|
|
1021
1203
|
this.log("error", "preview.start.failed", { sessionId, error: err.message, code: err?.code });
|
|
1022
1204
|
runner?.stopAll();
|
|
1023
1205
|
this.services.delete(sessionId);
|
|
1024
1206
|
this.clearPreviewIdleTimer(sessionId);
|
|
1207
|
+
if (publicMode) await this.tunnel.removeSession(sessionId);
|
|
1025
1208
|
return fail(err);
|
|
1026
1209
|
}
|
|
1027
1210
|
}
|
|
1028
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
|
+
|
|
1029
1355
|
/**
|
|
1030
1356
|
* Config resolution order: this worktree's own config, then the PRIMARY
|
|
1031
1357
|
* checkout's (a repo verified by the setup agent has its dev.yaml on an
|
|
@@ -1066,7 +1392,7 @@ export class BridgeDaemon {
|
|
|
1066
1392
|
|
|
1067
1393
|
/**
|
|
1068
1394
|
* The session's worktree for a repo: the exact slug when the title is
|
|
1069
|
-
* known; otherwise (
|
|
1395
|
+
* known; otherwise (a command that carries only the session id) the one
|
|
1070
1396
|
* directory under `~/.kai/worktrees/<repo>/` that ends in the session's
|
|
1071
1397
|
* id suffix — slugs are `<title>-<last 8 of the id>`, unique per session.
|
|
1072
1398
|
*/
|
|
@@ -1089,8 +1415,8 @@ export class BridgeDaemon {
|
|
|
1089
1415
|
* `PreviewError` carrying the classified log line + code when a service
|
|
1090
1416
|
* never becomes ready — a "running" preview must not 404.
|
|
1091
1417
|
*/
|
|
1092
|
-
async bootRepoWithConfig(runner, cwd, config, mode, repoKey = null
|
|
1093
|
-
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 });
|
|
1094
1420
|
const dead = started.services.find((s) => !s.adopted && !s.ready);
|
|
1095
1421
|
if (dead) {
|
|
1096
1422
|
throw new PreviewError(`${dead.name} did not start${dead.error ? ` — ${dead.error}` : ""}`, {
|
|
@@ -1168,15 +1494,100 @@ export class BridgeDaemon {
|
|
|
1168
1494
|
}
|
|
1169
1495
|
runner.stopAll();
|
|
1170
1496
|
this.services.delete(sessionId);
|
|
1497
|
+
this.writePreviewSnapshot(sessionId, null);
|
|
1498
|
+
void this.tunnelManager?.removeSession(sessionId);
|
|
1171
1499
|
this.log("info", "preview.idle.stopped", { sessionId });
|
|
1172
|
-
this.
|
|
1173
|
-
.sessionPreview(sessionId, { status: "stopped", urls: [], previews: [], errorCode: "idle_stopped", error: "Stopped automatically after 30 minutes." })
|
|
1174
|
-
.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." });
|
|
1175
1501
|
}, this.previewIdleMs ?? 30 * 60 * 1000);
|
|
1176
1502
|
t.unref?.();
|
|
1177
1503
|
this.previewIdleTimers.set(sessionId, t);
|
|
1178
1504
|
}
|
|
1179
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
|
+
|
|
1180
1591
|
/** Any ESTABLISHED connection on one of the runner's (non-adopted) ports? Injectable for tests. */
|
|
1181
1592
|
async previewHasConnections(runner) {
|
|
1182
1593
|
const count = this.countConnections ?? ((port) => establishedConnections(port));
|
|
@@ -1195,13 +1606,12 @@ export class BridgeDaemon {
|
|
|
1195
1606
|
|
|
1196
1607
|
async previewStop({ sessionId }) {
|
|
1197
1608
|
this.manualPreviews?.delete(sessionId);
|
|
1198
|
-
this.verifyOwnedPreviews?.delete(sessionId);
|
|
1199
|
-
// A sign-in window for this session has nothing left to sign in to.
|
|
1200
|
-
await this.teardownLogins((l) => l.sessionId === sessionId, "preview stopped");
|
|
1201
1609
|
this.services.get(sessionId)?.stopAll();
|
|
1202
1610
|
this.services.delete(sessionId);
|
|
1203
1611
|
this.clearPreviewIdleTimer(sessionId);
|
|
1204
|
-
|
|
1612
|
+
this.writePreviewSnapshot(sessionId, null);
|
|
1613
|
+
await this.tunnelManager?.removeSession(sessionId);
|
|
1614
|
+
await this.reportPreview(sessionId, { status: "stopped", urls: [], previews: [] });
|
|
1205
1615
|
}
|
|
1206
1616
|
|
|
1207
1617
|
/**
|
|
@@ -1216,6 +1626,7 @@ export class BridgeDaemon {
|
|
|
1216
1626
|
this.log("warn", "preview.service.died", { sessionId, name: info.name, code: info.code, errorCode: info.errorCode });
|
|
1217
1627
|
runner.stopAll();
|
|
1218
1628
|
this.services.delete(sessionId);
|
|
1629
|
+
this.writePreviewSnapshot(sessionId, null);
|
|
1219
1630
|
this.clearPreviewIdleTimer(sessionId);
|
|
1220
1631
|
return this.api
|
|
1221
1632
|
.sessionPreview(
|
|
@@ -1236,15 +1647,12 @@ export class BridgeDaemon {
|
|
|
1236
1647
|
let runner = this.services.get(sessionId);
|
|
1237
1648
|
if (!runner) {
|
|
1238
1649
|
// Stable ports (hash of repo + service in 43000-43999, when free) keep
|
|
1239
|
-
// a
|
|
1650
|
+
// a service's URL the same from one session to the next.
|
|
1240
1651
|
runner = new ServiceRunner({
|
|
1241
1652
|
kaiHome: this.kaiHome,
|
|
1242
1653
|
sessionId,
|
|
1243
1654
|
log: this.log,
|
|
1244
|
-
onStatus: (message) => {
|
|
1245
|
-
this.log("info", "preview.status", { sessionId, message });
|
|
1246
|
-
this.previewStatusListeners?.get(sessionId)?.(message);
|
|
1247
|
-
},
|
|
1655
|
+
onStatus: (message) => this.log("info", "preview.status", { sessionId, message }),
|
|
1248
1656
|
preferredPort: ({ repoKey, service }) => preferredStablePort(repoKey, service),
|
|
1249
1657
|
...(this.describeListener ? { describeListener: this.describeListener } : {}),
|
|
1250
1658
|
...(this.previewSettleMs != null ? { settleMs: this.previewSettleMs } : {}),
|
|
@@ -1336,11 +1744,6 @@ export class BridgeDaemon {
|
|
|
1336
1744
|
this.running.set(turnId, entry);
|
|
1337
1745
|
const releaseAwake = keepAwake();
|
|
1338
1746
|
let outcome = null;
|
|
1339
|
-
// Verify turns (`agent: kai-verifier`): preview booted up front, browser
|
|
1340
|
-
// evidence collected into an artifacts dir, report uploaded after the
|
|
1341
|
-
// turn — see prepareVerifyTurn / finishVerification.
|
|
1342
|
-
const isVerify = turn.agent === "kai-verifier";
|
|
1343
|
-
let verify = null;
|
|
1344
1747
|
// Everything `kai-bridge ps` shows for this turn; the runner pid follows at spawn.
|
|
1345
1748
|
const session = this.rememberSession(turn.sessionId, { title: turn.title, sessionUrl: turn.sessionUrl, repos: turn.repos });
|
|
1346
1749
|
this.rememberInflight(turnId, { sessionId: turn.sessionId, agent: turn.agent ?? null, harness: turn.harness ?? null, profileId: turn.profileId ?? null, startedAt: new Date().toISOString(), ...session });
|
|
@@ -1353,88 +1756,32 @@ export class BridgeDaemon {
|
|
|
1353
1756
|
// local paths — the prompt lists them.
|
|
1354
1757
|
const workDir = bound[0]?.cwd;
|
|
1355
1758
|
if (!workDir) throw new Error("Turn has no repositories.");
|
|
1356
|
-
const repoNote =
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
// cleanly without spending an agent run.
|
|
1365
|
-
await this.api
|
|
1366
|
-
.turnVerification(turnId, { ...buildVerificationPayload(null, [], { fallbackReason: verify.error }), ...(verify.blocked || {}) })
|
|
1367
|
-
.catch((err) => this.log("warn", "verify.report.failed", { turnId, error: err.message }));
|
|
1368
|
-
outcome = {
|
|
1369
|
-
status: "completed",
|
|
1370
|
-
result: null,
|
|
1371
|
-
changes: bound.map((b) => ({ key: b.key, mode: b.mode, branch: b.branch, base: b.base, cwd: b.cwd, files: [], push: false })),
|
|
1372
|
-
profileId: profile.id,
|
|
1373
|
-
};
|
|
1374
|
-
return;
|
|
1375
|
-
}
|
|
1376
|
-
previewNote = verify.note;
|
|
1377
|
-
// The evidence dir is where a restart mid-run finds partial footage.
|
|
1378
|
-
this.rememberInflight(turnId, { artifactsDir: verify.artifactsDir });
|
|
1379
|
-
mcpServers = [
|
|
1380
|
-
...(turn.mcpServers || []),
|
|
1381
|
-
previewMcpServer(RUNNER_DIR, {
|
|
1382
|
-
outputDir: verify.artifactsDir,
|
|
1383
|
-
// `storage` = browser_storage_state for the final sign-in refresh;
|
|
1384
|
-
// restoring an arbitrary state file is never the agent's call.
|
|
1385
|
-
caps: ["devtools", "testing", "storage"],
|
|
1386
|
-
secretsFile: verify.secretsFile,
|
|
1387
|
-
storageStateFile: verify.storageStateFile,
|
|
1388
|
-
allowedOrigins: verify.storageStateFile ? verify.allowedOrigins : null,
|
|
1389
|
-
disabledTools: ["browser_set_storage_state"],
|
|
1390
|
-
ignoreHttpsErrors: !!verify.ignoreHttpsErrors,
|
|
1391
|
-
}),
|
|
1392
|
-
];
|
|
1393
|
-
await this.postVerifyStage(turnId, "verifying");
|
|
1394
|
-
} else {
|
|
1395
|
-
// Previews are manual-only (dashboard "Start preview") — a turn
|
|
1396
|
-
// never boots dev servers on its own. When the user already has a
|
|
1397
|
-
// preview running for this session, describe it to the agent and
|
|
1398
|
-
// hand it the Playwright MCP so it can verify in a real browser.
|
|
1399
|
-
const live = await this.describeLivePreview(turn, bound, batcher);
|
|
1400
|
-
previewNote = live.note;
|
|
1401
|
-
if (live.hasLivePreview) mcpServers = [...(turn.mcpServers || []), previewMcpServer(RUNNER_DIR)];
|
|
1402
|
-
}
|
|
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;
|
|
1403
1767
|
const res = await runTurn({
|
|
1404
1768
|
turn: { ...turn, task: `${turn.task}${repoNote}${previewNote}`, mcpServers },
|
|
1405
1769
|
profile,
|
|
1406
1770
|
workDir,
|
|
1407
1771
|
kaiHome: this.kaiHome,
|
|
1408
|
-
// The verify MCP's request policy (KAI_VERIFY_*) — host-only, never in the prompt.
|
|
1409
|
-
extraEnv: verify?.env ?? null,
|
|
1410
1772
|
signal: ctrl.signal,
|
|
1411
1773
|
onSpawn: (handle) => {
|
|
1412
1774
|
entry.control = handle.control;
|
|
1413
1775
|
if (Number.isInteger(handle.pid)) this.rememberInflight(turnId, { pid: handle.pid });
|
|
1414
1776
|
},
|
|
1415
|
-
onEvent: (ev) =>
|
|
1416
|
-
if (verify) {
|
|
1417
|
-
// The tester is using the preview — it must not idle-stop
|
|
1418
|
-
// under it. And the report carries LOCAL paths: keep it here,
|
|
1419
|
-
// the uploaded version goes out after the turn.
|
|
1420
|
-
this.armPreviewIdleTimer(turn.sessionId);
|
|
1421
|
-
if (ev?.type === "verify_report") {
|
|
1422
|
-
verify.report = ev.report && typeof ev.report === "object" ? ev.report : null;
|
|
1423
|
-
return;
|
|
1424
|
-
}
|
|
1425
|
-
// Injected sign-in values (cookies, tokens) must never reach
|
|
1426
|
-
// the Server in a tool row or snapshot.
|
|
1427
|
-
if (verify.redact?.size) ev = redactDeep(ev, verify.redact);
|
|
1428
|
-
}
|
|
1429
|
-
batcher.push(ev);
|
|
1430
|
-
},
|
|
1777
|
+
onEvent: (ev) => batcher.push(ev),
|
|
1431
1778
|
onLog: (l) => this.log("debug", "runner", { line: l.slice(0, 500) }),
|
|
1432
1779
|
});
|
|
1433
1780
|
await batcher.flush();
|
|
1434
1781
|
const completed = !ctrl.signal.aborted && res.code === 0 && !res.rateLimited;
|
|
1435
|
-
// Plan
|
|
1436
|
-
//
|
|
1437
|
-
const readOnlyTurn = !!turn.planMode
|
|
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;
|
|
1438
1785
|
const changes = await Promise.all([...bound, ...this.adoptSessionWorktrees(turn, bound)].map(async (b) => {
|
|
1439
1786
|
ensureCommitExcludes(b.cwd, { allowDevConfig: !!turn.allowDevConfig });
|
|
1440
1787
|
// A read-only turn must leave the worktree as it found it — see
|
|
@@ -1442,14 +1789,13 @@ export class BridgeDaemon {
|
|
|
1442
1789
|
if (readOnlyTurn) {
|
|
1443
1790
|
const leaked = b.mode === "worktree" ? discardChanges(b.cwd).discarded : collectChanges(b.cwd).files;
|
|
1444
1791
|
if (leaked.length > 0) {
|
|
1445
|
-
|
|
1446
|
-
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" });
|
|
1447
1793
|
batcher.push({
|
|
1448
1794
|
type: "text",
|
|
1449
1795
|
message:
|
|
1450
1796
|
b.mode === "worktree"
|
|
1451
|
-
?
|
|
1452
|
-
:
|
|
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.`,
|
|
1453
1799
|
});
|
|
1454
1800
|
}
|
|
1455
1801
|
}
|
|
@@ -1471,9 +1817,7 @@ export class BridgeDaemon {
|
|
|
1471
1817
|
};
|
|
1472
1818
|
const push = shouldPush
|
|
1473
1819
|
? await this.withGitAuth(b.key, pushOnce).catch((err) => err.push ?? { committed: false, pushed: false, branch: b.branch, error: err.message })
|
|
1474
|
-
:
|
|
1475
|
-
? false
|
|
1476
|
-
: null;
|
|
1820
|
+
: null;
|
|
1477
1821
|
// `cwd` travels with the change so the dashboard can point at
|
|
1478
1822
|
// work that stayed on this machine (files but no push).
|
|
1479
1823
|
// Pushed → the commit list + diff stat travel with the change so the
|
|
@@ -1485,10 +1829,6 @@ export class BridgeDaemon {
|
|
|
1485
1829
|
// flush; land them before the result closes the turn (the Server
|
|
1486
1830
|
// answers 410 for events on an ended turn).
|
|
1487
1831
|
await batcher.flush();
|
|
1488
|
-
if (verify) {
|
|
1489
|
-
const finished = await this.finishVerification(turn, { ...verify, workDir, bound }, res, ctrl.signal.aborted);
|
|
1490
|
-
await this.settleVerifyPreview(turn, finished?.status).catch((err) => this.log("warn", "verify.preview.release.failed", { turnId, error: err.message }));
|
|
1491
|
-
}
|
|
1492
1832
|
// Built OUTSIDE the report call: if posting the result throws, the
|
|
1493
1833
|
// catch below must not turn a finished turn into a failed one. The
|
|
1494
1834
|
// work is already committed and pushed at this point.
|
|
@@ -1496,10 +1836,10 @@ export class BridgeDaemon {
|
|
|
1496
1836
|
status: ctrl.signal.aborted ? "cancelled" : res.rateLimited ? "rate_limited" : res.code === 0 ? "completed" : "failed",
|
|
1497
1837
|
exitCode: res.code,
|
|
1498
1838
|
...(res.errorCode ? { errorCode: res.errorCode } : {}),
|
|
1499
|
-
result:
|
|
1839
|
+
result: res.result,
|
|
1500
1840
|
// The runner's own failure text (e.g. the engine's usage-limit
|
|
1501
1841
|
// message) — the Server prefers this over its generic fallback.
|
|
1502
|
-
...(res.lastError && res.code !== 0 ? { error:
|
|
1842
|
+
...(res.lastError && res.code !== 0 ? { error: res.lastError.replace(/^acp-runner: /, "").slice(0, 500) } : {}),
|
|
1503
1843
|
changes,
|
|
1504
1844
|
profileId: profile.id,
|
|
1505
1845
|
};
|
|
@@ -1520,845 +1860,23 @@ export class BridgeDaemon {
|
|
|
1520
1860
|
})
|
|
1521
1861
|
.catch((err) => this.log("error", "result.lost", { turnId, error: err.message }));
|
|
1522
1862
|
}
|
|
1523
|
-
// Test credentials and the injected / final sign-in state live on
|
|
1524
|
-
// disk only for the duration of the turn.
|
|
1525
|
-
for (const f of [verify?.secretsFile, verify?.storageStateFile, verify?.finalStateFile]) if (f) rmSync(f, { force: true });
|
|
1526
|
-
verify?.release?.();
|
|
1527
1863
|
this.forgetInflight(turnId);
|
|
1528
1864
|
releaseAwake();
|
|
1529
1865
|
this.running.delete(turnId);
|
|
1530
|
-
if ((this.updatePending || this.restartPending) && this.running.size === 0
|
|
1866
|
+
if ((this.updatePending || this.restartPending) && this.running.size === 0) void this.checkForUpdate();
|
|
1531
1867
|
}
|
|
1532
1868
|
}
|
|
1533
1869
|
|
|
1534
|
-
// ──
|
|
1535
|
-
/** 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. */
|
|
1536
1872
|
ensurePreviewBrowser() {
|
|
1537
1873
|
return ensurePreviewBrowser({ runnerDir: RUNNER_DIR, log: this.log });
|
|
1538
1874
|
}
|
|
1539
1875
|
|
|
1540
|
-
/**
|
|
1876
|
+
/** Seam for the warm-up (tests inject a fake Playwright). */
|
|
1541
1877
|
loadPlaywright() {
|
|
1542
1878
|
return loadPlaywright(RUNNER_DIR);
|
|
1543
1879
|
}
|
|
1544
|
-
displayAvailable() {
|
|
1545
|
-
return displayAvailable();
|
|
1546
|
-
}
|
|
1547
|
-
probeLogin(opts) {
|
|
1548
|
-
return probeLogin(opts);
|
|
1549
|
-
}
|
|
1550
|
-
captureLogin(opts) {
|
|
1551
|
-
return captureLogin(opts);
|
|
1552
|
-
}
|
|
1553
|
-
|
|
1554
|
-
/** Where a verify turn's injected sign-in state lives (0600, deleted with the turn). */
|
|
1555
|
-
loginStateDir() {
|
|
1556
|
-
return join(this.kaiHome, "state", "preview-login");
|
|
1557
|
-
}
|
|
1558
|
-
|
|
1559
|
-
/** The agent's final browser-state export — inside the turn's evidence dir (the MCP's only writable root besides the repo). */
|
|
1560
|
-
finalStateFileFor(artifactsDir) {
|
|
1561
|
-
return join(artifactsDir, "state", "final-storage-state.json");
|
|
1562
|
-
}
|
|
1563
|
-
|
|
1564
|
-
/** Best-effort stage transition for the dashboard's Verify card (booting → verifying → saving). */
|
|
1565
|
-
async postVerifyStage(turnId, stage, note) {
|
|
1566
|
-
// The note often relays the agent's last line — markdown emphasis
|
|
1567
|
-
// ("**Confirming…**") must not reach the card as literal asterisks.
|
|
1568
|
-
const plain = typeof note === "string" ? note.replace(/[#*`>_]/g, "").replace(/\s+/g, " ").trim().slice(0, 200) : "";
|
|
1569
|
-
try {
|
|
1570
|
-
await this.api.turnVerificationStage(turnId, { stage, ...(plain ? { note: plain } : {}) });
|
|
1571
|
-
} catch (err) {
|
|
1572
|
-
this.log("debug", "verify.stage.failed", { turnId, stage, error: err?.message });
|
|
1573
|
-
}
|
|
1574
|
-
}
|
|
1575
|
-
|
|
1576
|
-
/**
|
|
1577
|
-
* One probe / refresh of a sign-in at a time per user + repo: two verify
|
|
1578
|
-
* turns racing the same record would both refresh it (one 409s) and
|
|
1579
|
-
* could probe a preview the other is still booting.
|
|
1580
|
-
*/
|
|
1581
|
-
withVerifyLock(key, fn) {
|
|
1582
|
-
return this.acquireVerifyLock(key).then(async (release) => {
|
|
1583
|
-
try {
|
|
1584
|
-
return await fn();
|
|
1585
|
-
} finally {
|
|
1586
|
-
release();
|
|
1587
|
-
}
|
|
1588
|
-
});
|
|
1589
|
-
}
|
|
1590
|
-
|
|
1591
|
-
/**
|
|
1592
|
-
* Acquire the per user+repo verify lock; resolves with `release()`. A
|
|
1593
|
-
* turn that injected a saved sign-in keeps it until its final refresh
|
|
1594
|
-
* has landed: two agent runs presenting the same refresh token in
|
|
1595
|
-
* parallel is exactly what rotation-based IdPs revoke on.
|
|
1596
|
-
*/
|
|
1597
|
-
acquireVerifyLock(key) {
|
|
1598
|
-
this.verifyLockHolders ??= new Set(); // keys currently HELD (the map also lists waiters)
|
|
1599
|
-
this.verifyLocks ??= new Map();
|
|
1600
|
-
const prev = this.verifyLocks.get(key) ?? Promise.resolve();
|
|
1601
|
-
let release;
|
|
1602
|
-
const held = new Promise((resolve) => {
|
|
1603
|
-
let done = false;
|
|
1604
|
-
release = () => {
|
|
1605
|
-
if (done) return;
|
|
1606
|
-
done = true;
|
|
1607
|
-
this.verifyLockHolders.delete(key);
|
|
1608
|
-
resolve();
|
|
1609
|
-
};
|
|
1610
|
-
});
|
|
1611
|
-
const next = prev.catch(() => {}).then(() => held);
|
|
1612
|
-
this.verifyLocks.set(key, next);
|
|
1613
|
-
next.finally(() => {
|
|
1614
|
-
if (this.verifyLocks.get(key) === next) this.verifyLocks.delete(key);
|
|
1615
|
-
});
|
|
1616
|
-
return prev
|
|
1617
|
-
.catch(() => {})
|
|
1618
|
-
.then(() => {
|
|
1619
|
-
this.verifyLockHolders.add(key);
|
|
1620
|
-
return release;
|
|
1621
|
-
});
|
|
1622
|
-
}
|
|
1623
|
-
|
|
1624
|
-
/** The verifier's repo brief: where the checkouts are, and that they are read-only. */
|
|
1625
|
-
verifyRepoBrief(bound) {
|
|
1626
|
-
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):"];
|
|
1627
|
-
for (const b of bound) lines.push(`- ${b.key}: ${b.cwd}${b.branch ? ` (branch ${b.branch}${b.base ? `, base ${b.base}` : ""})` : ""}`);
|
|
1628
|
-
return lines.join("\n");
|
|
1629
|
-
}
|
|
1630
|
-
|
|
1631
|
-
/**
|
|
1632
|
-
* dev.yaml `auth.storageState: <command>` — run in the repo checkout,
|
|
1633
|
-
* stdout is Playwright storageState JSON (or the path of a JSON file).
|
|
1634
|
-
* Replaces the user's saved sign-in for this repo. Null on any failure.
|
|
1635
|
-
*/
|
|
1636
|
-
async storageStateFromCommand(command, cwd) {
|
|
1637
|
-
return new Promise((resolveP) => {
|
|
1638
|
-
const cb = (err, stdout) => {
|
|
1639
|
-
if (err) {
|
|
1640
|
-
this.log("warn", "verify.login.command.failed", { error: err.message });
|
|
1641
|
-
return resolveP(null);
|
|
1642
|
-
}
|
|
1643
|
-
resolveP(parseStorageStateOutput(stdout, { read: (p) => readFileSync(p, "utf8") }));
|
|
1644
|
-
};
|
|
1645
|
-
const opts = { cwd, timeout: 60_000, maxBuffer: 16 * 1024 * 1024, env: { ...process.env, BROWSER: "none" } };
|
|
1646
|
-
if (process.platform === "darwin") execFile("/bin/zsh", ["-lc", command], opts, cb);
|
|
1647
|
-
else execFile(command, [], { ...opts, shell: true }, cb);
|
|
1648
|
-
});
|
|
1649
|
-
}
|
|
1650
|
-
|
|
1651
|
-
/**
|
|
1652
|
-
* Boot (or re-describe) the session's preview, make sure a browser can
|
|
1653
|
-
* launch, decide the sign-in (dev.yaml command → saved record → none),
|
|
1654
|
-
* PROBE it headless, and lay out the turn's evidence dir + state files +
|
|
1655
|
-
* optional secrets file. Resolves `{ ok: false, error, blocked? }`
|
|
1656
|
-
* (`blocked` = `{ blockedCode, loginPath?, firstTime? }` for the report)
|
|
1657
|
-
* or `{ ok: true, artifactsDir, secretsFile, storageStateFile,
|
|
1658
|
-
* finalStateFile, allowedOrigins, injected, redact, note, report: null }`.
|
|
1659
|
-
*/
|
|
1660
|
-
async prepareVerifyTurn(turn, bound = []) {
|
|
1661
|
-
const repoKeys = (turn.repos || []).map((r) => r.key).filter(Boolean).sort();
|
|
1662
|
-
const lockKey = `${turn.userId ?? turn.ownerUserId ?? "device"}:${repoKeys.join(",")}`;
|
|
1663
|
-
if (this.verifyLockHolders?.has(lockKey)) {
|
|
1664
|
-
// Another verify run of this repo holds its sign-in — say so instead
|
|
1665
|
-
// of sitting on "Starting the app…" until it finishes.
|
|
1666
|
-
this.log("info", "verify.lock.wait", { turnId: turn.turnId, lockKey });
|
|
1667
|
-
await this.postVerifyStage(turn.turnId, "booting", "Waiting for another verification of this repo on this machine to finish…");
|
|
1668
|
-
}
|
|
1669
|
-
const release = await this.acquireVerifyLock(lockKey);
|
|
1670
|
-
let result;
|
|
1671
|
-
try {
|
|
1672
|
-
result = await this.prepareVerifyTurnLocked(turn, bound);
|
|
1673
|
-
} catch (err) {
|
|
1674
|
-
release();
|
|
1675
|
-
throw err;
|
|
1676
|
-
}
|
|
1677
|
-
if (result?.ok && result.injected) {
|
|
1678
|
-
// Hold the repo's sign-in for the whole run — released by startTurn's
|
|
1679
|
-
// finally (after finishVerification refreshed the record).
|
|
1680
|
-
return { ...result, release };
|
|
1681
|
-
}
|
|
1682
|
-
release();
|
|
1683
|
-
return result;
|
|
1684
|
-
}
|
|
1685
|
-
|
|
1686
|
-
async prepareVerifyTurnLocked(turn, bound) {
|
|
1687
|
-
const { turnId, sessionId } = turn;
|
|
1688
|
-
// Lazy browser check — setup usually did this; a machine that skipped
|
|
1689
|
-
// it downloads Chromium now (best-effort: without a browser the agent
|
|
1690
|
-
// reports `blocked` itself).
|
|
1691
|
-
const browser = await this.ensurePreviewBrowser().catch((err) => ({ ok: false, error: err?.message }));
|
|
1692
|
-
if (!browser?.ok) {
|
|
1693
|
-
// No browser, no test — say so with the exact command (Linux/Windows
|
|
1694
|
-
// machines without Chrome and a failed Chromium download) instead of
|
|
1695
|
-
// letting the agent discover it on its first navigate.
|
|
1696
|
-
this.log("warn", "verify.browser.unavailable", { turnId, error: browser?.error });
|
|
1697
|
-
return {
|
|
1698
|
-
ok: false,
|
|
1699
|
-
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.` : ""}`,
|
|
1700
|
-
blocked: { blockedCode: "no_browser" },
|
|
1701
|
-
};
|
|
1702
|
-
}
|
|
1703
|
-
const channel = browser?.browser === "chrome" ? "chrome" : null;
|
|
1704
|
-
// previewStart is idempotent on a live runner (booted services are
|
|
1705
|
-
// only re-described) and reports starting/running to the dashboard
|
|
1706
|
-
// exactly like the manual button. A cold boot is its own stage, and
|
|
1707
|
-
// every runner status line during it (installing, still starting,
|
|
1708
|
-
// ready) reaches the Verify card as a `booting` note — the heartbeat.
|
|
1709
|
-
const coldBoot = !this.services.has(sessionId);
|
|
1710
|
-
if (coldBoot && !this.manualPreviews?.has(sessionId)) (this.verifyOwnedPreviews ??= new Set()).add(sessionId);
|
|
1711
|
-
if (coldBoot) await this.postVerifyStage(turnId, "booting");
|
|
1712
|
-
const heartbeat = createStageHeartbeat((note) => this.postVerifyStage(turnId, "booting", note), { minIntervalMs: this.stageHeartbeatMs ?? 5_000 });
|
|
1713
|
-
this.previewStatusListeners ??= new Map();
|
|
1714
|
-
this.previewStatusListeners.set(sessionId, heartbeat.note);
|
|
1715
|
-
let preview;
|
|
1716
|
-
try {
|
|
1717
|
-
preview = await this.previewStart({ sessionId, title: turn.title, repos: turn.repos, sessionUrl: turn.sessionUrl ?? null, companionRemotes: turn.companionRemotes ?? null });
|
|
1718
|
-
} finally {
|
|
1719
|
-
this.previewStatusListeners?.delete(sessionId);
|
|
1720
|
-
heartbeat.stop();
|
|
1721
|
-
}
|
|
1722
|
-
if (preview?.status !== "running") {
|
|
1723
|
-
// The preview's structured error IS the diagnosis: its code becomes the
|
|
1724
|
-
// verification's blockedCode (companion_missing, port_busy, deps_failed,
|
|
1725
|
-
// …) so the card offers the right verb; `preview_unreachable` only
|
|
1726
|
-
// when nothing more specific is known.
|
|
1727
|
-
const code = preview?.errorCode && preview.errorCode !== "other" ? preview.errorCode : "preview_unreachable";
|
|
1728
|
-
return {
|
|
1729
|
-
ok: false,
|
|
1730
|
-
error: preview?.error ? `The preview could not be started: ${preview.error}` : "The preview could not be started.",
|
|
1731
|
-
blocked: { blockedCode: code },
|
|
1732
|
-
};
|
|
1733
|
-
}
|
|
1734
|
-
// After a fix turn, services that do not hot-reload (`reload: restart`,
|
|
1735
|
-
// the default for APIs) must run the fixed code before the probe.
|
|
1736
|
-
const restartNotes = await this.restartChangedServices(turn, sessionId);
|
|
1737
|
-
const artifactsDir = join(this.kaiHome, "artifacts", String(turnId).replace(/[^\w.-]/g, "_"));
|
|
1738
|
-
mkdirSync(artifactsDir, { recursive: true });
|
|
1739
|
-
let secretsFile = null;
|
|
1740
|
-
let secretNames = [];
|
|
1741
|
-
if (turn.verifySecrets && typeof turn.verifySecrets === "object") {
|
|
1742
|
-
const dir = join(this.kaiHome, "state", "verify-secrets");
|
|
1743
|
-
mkdirSync(dir, { recursive: true });
|
|
1744
|
-
const path = join(dir, `${String(turnId).replace(/[^\w.-]/g, "_")}.env`);
|
|
1745
|
-
secretNames = writeSecretsFile(path, turn.verifySecrets);
|
|
1746
|
-
if (secretNames.length > 0) secretsFile = path;
|
|
1747
|
-
}
|
|
1748
|
-
const previews = preview.previews || [];
|
|
1749
|
-
const runner = this.services.get(sessionId);
|
|
1750
|
-
const services = runner?.describeServices?.() ?? previews.map((p) => ({ name: p.name, url: p.url }));
|
|
1751
|
-
const previewOrigins = [...new Set([...services.map((s) => normalizeOrigin(s.url)), ...previews.map((p) => normalizeOrigin(p.url))].filter(Boolean))];
|
|
1752
|
-
const primaryKey = (turn.repos || [])[0]?.key;
|
|
1753
|
-
// The landing page is a session repo's web preview (an API has no UI to land on).
|
|
1754
|
-
const webPreviews = previews.filter((p) => p.kind !== "api");
|
|
1755
|
-
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;
|
|
1756
|
-
|
|
1757
|
-
// ── sign-in: dev.yaml command → saved record → none ──────────────
|
|
1758
|
-
const primary = bound?.[0] ?? null;
|
|
1759
|
-
const devConfig = primary?.cwd ? readDevConfig(primary.cwd) : null;
|
|
1760
|
-
const auth = devConfig?.auth ?? null;
|
|
1761
|
-
// Every dev.yaml in the session (external origins, verify policy) — the
|
|
1762
|
-
// primary's wins on conflicts.
|
|
1763
|
-
const configs = (bound || []).map((b) => (b?.cwd ? readDevConfig(b.cwd) : null)).filter(Boolean);
|
|
1764
|
-
const external = [...new Set(configs.flatMap((c) => c.external || []))];
|
|
1765
|
-
// The state filter accepts the declared `external` origins too: the
|
|
1766
|
-
// browser is allowed to talk to them, so a token the app keeps there is
|
|
1767
|
-
// part of "signed in". Cookies stay localhost-only (filterStorageState).
|
|
1768
|
-
const stateOrigins = [...new Set([...previewOrigins, ...external])];
|
|
1769
|
-
let injected = null; // { source: "command" | "record", state, record?, originMap? }
|
|
1770
|
-
if (auth?.storageState && primary?.cwd) {
|
|
1771
|
-
const state = await this.storageStateFromCommand(auth.storageState, primary.cwd);
|
|
1772
|
-
if (state) injected = { source: "command", state: filterStorageState(state, stateOrigins).state, record: null, originMap: null };
|
|
1773
|
-
else this.log("warn", "verify.login.command.empty", { turnId });
|
|
1774
|
-
} else if (turn.loginRecordAvailable) {
|
|
1775
|
-
const record = await this.api.turnPreviewLogin(turnId).catch((err) => {
|
|
1776
|
-
this.log("warn", "verify.login.record.failed", { turnId, error: err?.message });
|
|
1777
|
-
return null;
|
|
1778
|
-
});
|
|
1779
|
-
if (record?.storageState) {
|
|
1780
|
-
const originMap = buildOriginMap(record.services, services);
|
|
1781
|
-
const state = rewriteStorageState(record.storageState, originMap);
|
|
1782
|
-
this.log("info", "verify.login.record", { turnId, version: record.version, mapped: originMap.size, cookies: state.cookies.length, origins: state.origins.length });
|
|
1783
|
-
injected = { source: "record", state, record, originMap };
|
|
1784
|
-
}
|
|
1785
|
-
}
|
|
1786
|
-
|
|
1787
|
-
// ── probe: does the preview consider us signed in? ───────────────
|
|
1788
|
-
// An API-only preview (no web service) has nothing to render a login
|
|
1789
|
-
// wall on — the agent's http_request answers 401/403 → needs_login.
|
|
1790
|
-
let probe = { result: "unknown", storageState: null, loginPath: null };
|
|
1791
|
-
const hasWebService = services.some((svc) => svc.kind !== "api") || webPreviews.length > 0;
|
|
1792
|
-
if (landingUrl && hasWebService) {
|
|
1793
|
-
const pw = this.loadPlaywright();
|
|
1794
|
-
probe = await this.probeLogin({
|
|
1795
|
-
pw,
|
|
1796
|
-
launchOptions: launchOptionsFor({ headless: true, browser: channel }),
|
|
1797
|
-
storageState: injected?.state ?? null,
|
|
1798
|
-
url: injected?.record?.landingUrl ? rewriteUrl(injected.record.landingUrl, injected.originMap) ?? landingUrl : landingUrl,
|
|
1799
|
-
previewOrigins,
|
|
1800
|
-
loginPaths: injected?.record?.loginPaths ?? [],
|
|
1801
|
-
loginCheck: auth?.loginCheck ?? null,
|
|
1802
|
-
// A dev server that just booted still compiles its first page (vite's
|
|
1803
|
-
// cold transform of a large app takes 30-60 s) — give it room, or the
|
|
1804
|
-
// probe answers `unknown` and a login wall goes unnoticed.
|
|
1805
|
-
timeoutMs: coldBoot ? PROBE_TIMEOUT_COLD_BOOT_MS : undefined,
|
|
1806
|
-
log: this.log,
|
|
1807
|
-
});
|
|
1808
|
-
}
|
|
1809
|
-
// "Continue without signing in" (`previewLoginOptional`) means: run
|
|
1810
|
-
// unauthenticated rather than block — whether there is no record at all
|
|
1811
|
-
// or the saved one no longer works.
|
|
1812
|
-
if (probe.result === "needs_login" && turn.previewLoginOptional === true) {
|
|
1813
|
-
this.log("info", "verify.login.optional", { turnId, hadRecord: injected !== null });
|
|
1814
|
-
injected = null;
|
|
1815
|
-
probe = { result: "unknown", storageState: null, loginPath: probe.loginPath };
|
|
1816
|
-
}
|
|
1817
|
-
if (probe.result === "needs_login") {
|
|
1818
|
-
rmSync(artifactsDir, { recursive: true, force: true });
|
|
1819
|
-
if (secretsFile) rmSync(secretsFile, { force: true });
|
|
1820
|
-
const firstTime = injected === null;
|
|
1821
|
-
return {
|
|
1822
|
-
ok: false,
|
|
1823
|
-
error: firstTime
|
|
1824
|
-
? "The preview asks for a sign-in. Sign in once on this device so Kai can test the app."
|
|
1825
|
-
: "The saved sign-in for this preview no longer works — sign in again on this device.",
|
|
1826
|
-
blocked: { blockedCode: "needs_login", ...(probe.loginPath ? { loginPath: probe.loginPath } : {}), firstTime },
|
|
1827
|
-
};
|
|
1828
|
-
}
|
|
1829
|
-
|
|
1830
|
-
// ── state files for the MCP ──────────────────────────────────────
|
|
1831
|
-
let storageStateFile = null;
|
|
1832
|
-
let finalStateFile = null;
|
|
1833
|
-
let redact = new Set();
|
|
1834
|
-
let probeExport = null;
|
|
1835
|
-
if (injected && !storageStateIsEmpty(injected.state)) {
|
|
1836
|
-
const dir = this.loginStateDir();
|
|
1837
|
-
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
1838
|
-
const safe = String(turnId).replace(/[^\w.-]/g, "_");
|
|
1839
|
-
storageStateFile = join(dir, `${safe}.json`);
|
|
1840
|
-
// The persona's final `browser_storage_state` export must land INSIDE
|
|
1841
|
-
// the MCP's `--output-dir`: the Playwright MCP refuses any `filename`
|
|
1842
|
-
// outside its output dir / the client cwd (`File access denied …
|
|
1843
|
-
// outside allowed roots`), so a path under ~/.kai/state would make the
|
|
1844
|
-
// agent's last action fail on every signed-in run and the record would
|
|
1845
|
-
// never refresh. A `.json` is not an artifact extension — the sweep
|
|
1846
|
-
// never uploads it — and it is read before the evidence dir is deleted.
|
|
1847
|
-
finalStateFile = this.finalStateFileFor(artifactsDir);
|
|
1848
|
-
mkdirSync(dirname(finalStateFile), { recursive: true, mode: 0o700 });
|
|
1849
|
-
// The probe context's own export (with IndexedDB) when the probe ran;
|
|
1850
|
-
// the rewritten record otherwise (probe unknown → proceed anyway).
|
|
1851
|
-
probeExport = probe.result === "ok" && probe.storageState ? filterStorageState(probe.storageState, stateOrigins).state : injected.state;
|
|
1852
|
-
writeFileSync(storageStateFile, JSON.stringify(probeExport), { mode: 0o600 });
|
|
1853
|
-
redact = new Set([...redactionSet(injected.state), ...redactionSet(probeExport)]);
|
|
1854
|
-
}
|
|
1855
|
-
// `.env*` values of the repos + service dirs are secrets too: a tool row
|
|
1856
|
-
// that echoes one (a config dump, an error message) must not carry it
|
|
1857
|
-
// to the Server.
|
|
1858
|
-
for (const value of this.envRedactionValues(bound, services)) redact.add(value);
|
|
1859
|
-
const allowedOrigins = [...previewOrigins, ...external, "http://localhost:*", "http://127.0.0.1:*", "https://localhost:*", "https://127.0.0.1:*"];
|
|
1860
|
-
// ── API evidence: the verify MCP's request policy ───────────────
|
|
1861
|
-
const apiServices = services.filter((svc) => svc.kind === "api");
|
|
1862
|
-
const verifyPolicy = configs.find((c) => c.verify)?.verify ?? devConfig?.verify ?? { readOnly: null, endpoints: [], loginVia: null };
|
|
1863
|
-
const readOnly = verifyPolicy.readOnly ?? apiServices.length > 0;
|
|
1864
|
-
const env = {
|
|
1865
|
-
KAI_VERIFY_READ_ONLY: readOnly ? "1" : "0",
|
|
1866
|
-
KAI_VERIFY_ORIGINS: [...new Set([...previewOrigins, ...external])].join(";"),
|
|
1867
|
-
KAI_VERIFY_EVIDENCE_DIR: artifactsDir,
|
|
1868
|
-
};
|
|
1869
|
-
// The app's own API credential (dev.yaml `auth.apiToken`) read from the
|
|
1870
|
-
// injected sign-in → `Authorization: Bearer …` for http_request. The
|
|
1871
|
-
// value goes to the MCP by env only and joins the redaction set.
|
|
1872
|
-
const apiAuth = deriveApiAuthHeader(auth?.apiToken, probeExport ?? injected?.state ?? null, services);
|
|
1873
|
-
if (apiAuth) {
|
|
1874
|
-
env.KAI_VERIFY_AUTH_HEADER = apiAuth.value;
|
|
1875
|
-
if (apiAuth.token.length >= 8) redact.add(apiAuth.token);
|
|
1876
|
-
}
|
|
1877
|
-
return {
|
|
1878
|
-
ok: true,
|
|
1879
|
-
artifactsDir,
|
|
1880
|
-
secretsFile,
|
|
1881
|
-
storageStateFile,
|
|
1882
|
-
finalStateFile,
|
|
1883
|
-
allowedOrigins,
|
|
1884
|
-
previewOrigins,
|
|
1885
|
-
stateOrigins,
|
|
1886
|
-
external,
|
|
1887
|
-
// Companions run the code they have checked out — their HEADs travel
|
|
1888
|
-
// with the report so "verified" can never be claimed for a stale API.
|
|
1889
|
-
companionRevisions: previews
|
|
1890
|
-
.filter((p) => p.role === "companion" && p.repo && p.commit)
|
|
1891
|
-
.map((p) => ({ repo: p.repo, commit: p.commit, role: "companion", ...(p.branch ? { branch: p.branch } : {}), ...(p.dirty > 0 ? { dirty: p.dirty } : {}) })),
|
|
1892
|
-
ignoreHttpsErrors: !!runner?.usesHttps?.(),
|
|
1893
|
-
env,
|
|
1894
|
-
readOnly,
|
|
1895
|
-
injected: injected ? { ...injected, probeExport } : null,
|
|
1896
|
-
redact,
|
|
1897
|
-
note: this.verifyPreviewNote(sessionId, previews, secretNames, artifactsDir, {
|
|
1898
|
-
signedIn: !!storageStateFile,
|
|
1899
|
-
finalStateFile,
|
|
1900
|
-
services,
|
|
1901
|
-
readOnly,
|
|
1902
|
-
endpoints: verifyPolicy.endpoints || [],
|
|
1903
|
-
external,
|
|
1904
|
-
restartNotes,
|
|
1905
|
-
apiAuth: !!apiAuth,
|
|
1906
|
-
}),
|
|
1907
|
-
report: null,
|
|
1908
|
-
};
|
|
1909
|
-
}
|
|
1910
|
-
|
|
1911
|
-
/**
|
|
1912
|
-
* After a fix turn (`turn.changedRepos` = repo keys the fix touched):
|
|
1913
|
-
* restart the changed repos' services whose reload mode is `restart`
|
|
1914
|
-
* (declared, or the default for `kind: api`) on their same ports. Adopted
|
|
1915
|
-
* services are the user's own dev server — never restarted, the task
|
|
1916
|
-
* note says so. Returns the note lines for the agent.
|
|
1917
|
-
*/
|
|
1918
|
-
async restartChangedServices(turn, sessionId) {
|
|
1919
|
-
const changed = Array.isArray(turn.changedRepos) ? turn.changedRepos.map((k) => String(k).toLowerCase()) : [];
|
|
1920
|
-
const runner = this.services.get(sessionId);
|
|
1921
|
-
if (!changed.length || !runner) return [];
|
|
1922
|
-
const notes = [];
|
|
1923
|
-
const byRoot = new Map(); // repoRoot → { key, names }
|
|
1924
|
-
for (const svc of runner.describeServices()) {
|
|
1925
|
-
if (!svc.repoKey || !changed.includes(String(svc.repoKey).toLowerCase()) || !svc.repoRoot) continue;
|
|
1926
|
-
const meta = runner.meta?.get(svc.name);
|
|
1927
|
-
if (effectiveReload(meta?.svc, svc.kind) !== "restart") continue;
|
|
1928
|
-
if (svc.adopted) {
|
|
1929
|
-
notes.push(`${svc.name} is your already-running dev server and was not restarted — make sure it serves the fixed code.`);
|
|
1930
|
-
continue;
|
|
1931
|
-
}
|
|
1932
|
-
const entry = byRoot.get(svc.repoRoot) ?? { key: svc.repoKey, names: [] };
|
|
1933
|
-
entry.names.push(svc.name);
|
|
1934
|
-
byRoot.set(svc.repoRoot, entry);
|
|
1935
|
-
}
|
|
1936
|
-
for (const [repoRoot, { key, names }] of byRoot) {
|
|
1937
|
-
await this.postVerifyStage(turn.turnId, "booting", `Restarting ${names.join(", ")} with the fix…`);
|
|
1938
|
-
this.log("info", "verify.restart", { turnId: turn.turnId, repo: key, services: names });
|
|
1939
|
-
try {
|
|
1940
|
-
const restarted = await runner.restart(repoRoot, { only: names });
|
|
1941
|
-
const dead = restarted.find((r) => r.restarted && !r.ready);
|
|
1942
|
-
if (dead) notes.push(`${dead.name} did not come back after the restart${dead.error ? ` — ${dead.error}` : ""} (see ${dead.logPath}).`);
|
|
1943
|
-
else notes.push(`${names.join(", ")} ${names.length === 1 ? "was" : "were"} restarted with the fix.`);
|
|
1944
|
-
} catch (err) {
|
|
1945
|
-
this.log("warn", "verify.restart.failed", { turnId: turn.turnId, repo: key, error: err?.message });
|
|
1946
|
-
notes.push(`${names.join(", ")} could not be restarted with the fix: ${err?.message}`);
|
|
1947
|
-
}
|
|
1948
|
-
}
|
|
1949
|
-
return notes;
|
|
1950
|
-
}
|
|
1951
|
-
|
|
1952
|
-
/** Secrets from `.env*` files at the repo roots + service cwds (values ≥ 12 chars, placeholders excluded). */
|
|
1953
|
-
envRedactionValues(bound, services) {
|
|
1954
|
-
const dirs = new Set([...(bound || []).map((b) => b?.cwd).filter(Boolean), ...(services || []).map((svc) => svc?.cwd).filter(Boolean)]);
|
|
1955
|
-
const texts = [];
|
|
1956
|
-
for (const dir of dirs) {
|
|
1957
|
-
let names = [];
|
|
1958
|
-
try {
|
|
1959
|
-
names = readdirSync(dir).filter((n) => n === ".env" || (n.startsWith(".env.") && !/\.(example|sample|template)$/.test(n)));
|
|
1960
|
-
} catch {
|
|
1961
|
-
continue;
|
|
1962
|
-
}
|
|
1963
|
-
for (const name of names) {
|
|
1964
|
-
try {
|
|
1965
|
-
texts.push(readFileSync(join(dir, name), "utf8"));
|
|
1966
|
-
} catch {
|
|
1967
|
-
/* unreadable */
|
|
1968
|
-
}
|
|
1969
|
-
}
|
|
1970
|
-
}
|
|
1971
|
-
return envRedactionValues(texts);
|
|
1972
|
-
}
|
|
1973
|
-
|
|
1974
|
-
/** 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. */
|
|
1975
|
-
verifyPreviewNote(sessionId, previews, secretNames = [], artifactsDir = null, { signedIn = false, finalStateFile = null, services = null, readOnly = false, endpoints = [], external = [], restartNotes = [], apiAuth = false } = {}) {
|
|
1976
|
-
const runner = this.services.get(sessionId);
|
|
1977
|
-
const apiNames = new Set((services || []).filter((svc) => svc.kind === "api").map((svc) => svc.name));
|
|
1978
|
-
const webPreviews = previews.filter((p) => !apiNames.has(p.name));
|
|
1979
|
-
const lines = webPreviews.map((p) => {
|
|
1980
|
-
const logPath = runner && !p.adopted ? join(runner.logDir, `${p.name}.log`) : null;
|
|
1981
|
-
return `- ${p.repo}${p.name && p.name !== p.repo ? ` (${p.name})` : ""}: ${p.url}${p.adopted ? " (your already-running dev server)" : ""}${logPath ? ` · logs: ${logPath}` : ""}`;
|
|
1982
|
-
});
|
|
1983
|
-
const parts = ["", "", "This is a verification turn: do not modify files, do not commit."];
|
|
1984
|
-
if (lines.length > 0) {
|
|
1985
|
-
parts.push(`Dev services running for this session:\n${lines.join("\n")}`);
|
|
1986
|
-
parts.push(
|
|
1987
|
-
"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. " +
|
|
1988
|
-
"Tail the service logs with the Read/Bash tools if a page fails to load.",
|
|
1989
|
-
);
|
|
1990
|
-
}
|
|
1991
|
-
const apiServices = (services || []).filter((svc) => svc.kind === "api");
|
|
1992
|
-
if (apiServices.length > 0) {
|
|
1993
|
-
const apiLines = apiServices.map((svc) => {
|
|
1994
|
-
const logPath = runner && !svc.adopted ? join(runner.logDir, `${svc.name}.log`) : null;
|
|
1995
|
-
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}` : ""}`;
|
|
1996
|
-
});
|
|
1997
|
-
parts.push(
|
|
1998
|
-
`API services (no UI):\n${apiLines.join("\n")}\n` +
|
|
1999
|
-
`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. ` +
|
|
2000
|
-
`Resolve real paths from the OpenAPI spec (or the router files) before calling. ` +
|
|
2001
|
-
(readOnly
|
|
2002
|
-
? "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`. "
|
|
2003
|
-
: "Writes are allowed in this run — prefer creating throwaway records and say what you created in the report. ") +
|
|
2004
|
-
(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. ") +
|
|
2005
|
-
(endpoints.length ? `Endpoints the repo asks you to cover: ${endpoints.join(", ")}. ` : "") +
|
|
2006
|
-
(lines.length === 0 ? "There is no web UI in this run — no recording is needed; the transcript and the report are the evidence." : ""),
|
|
2007
|
-
);
|
|
2008
|
-
}
|
|
2009
|
-
if (lines.length === 0 && apiServices.length === 0) {
|
|
2010
|
-
parts.push(`Dev services running for this session:\n${previews.map((p) => `- ${p.repo}: ${p.url}`).join("\n") || "- (none)"}`);
|
|
2011
|
-
}
|
|
2012
|
-
if (external.length > 0) parts.push(`External origins the app talks to (allowed for the browser and http_request): ${external.join(", ")}.`);
|
|
2013
|
-
if (restartNotes.length > 0) parts.push(restartNotes.join(" "));
|
|
2014
|
-
if (artifactsDir) {
|
|
2015
|
-
// The MCP resolves a relative `filename` against the CLIENT
|
|
2016
|
-
// workspace (the repo worktree), where it would be discarded with
|
|
2017
|
-
// the turn — only absolute paths under the output dir (or no
|
|
2018
|
-
// filename at all) reach the evidence dir.
|
|
2019
|
-
parts.push(
|
|
2020
|
-
`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.`,
|
|
2021
|
-
);
|
|
2022
|
-
}
|
|
2023
|
-
if (secretNames.length > 0) {
|
|
2024
|
-
parts.push(
|
|
2025
|
-
`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.`,
|
|
2026
|
-
);
|
|
2027
|
-
}
|
|
2028
|
-
if (signedIn && finalStateFile) {
|
|
2029
|
-
parts.push(
|
|
2030
|
-
`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. ` +
|
|
2031
|
-
`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.`,
|
|
2032
|
-
);
|
|
2033
|
-
} else {
|
|
2034
|
-
parts.push(
|
|
2035
|
-
"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.",
|
|
2036
|
-
);
|
|
2037
|
-
}
|
|
2038
|
-
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.");
|
|
2039
|
-
return parts.join("\n\n").replace(/^\n\n\n\n/, "\n\n");
|
|
2040
|
-
}
|
|
2041
|
-
|
|
2042
|
-
/**
|
|
2043
|
-
* After a verify turn: sweep the evidence dir (the agent may have
|
|
2044
|
-
* forgotten the recording), upload every file, then post the report
|
|
2045
|
-
* with URLs in place of paths (redacted: nothing from the injected
|
|
2046
|
-
* sign-in may leak). No report from the agent → `blocked` with the swept
|
|
2047
|
-
* evidence; a report with zero checks → `blocked` / `no_report` (the
|
|
2048
|
-
* payload builder). A turn CANCELLED before any report skips the uploads
|
|
2049
|
-
* altogether (nobody asked for that footage). `evidence: { failed, kept }`
|
|
2050
|
-
* tells the Server how many uploads failed and where the files still are
|
|
2051
|
-
* (`bridge.verify.evidence.retry` re-uploads them). Then, when a saved
|
|
2052
|
-
* sign-in was injected and the agent left its final `browser_storage_state`
|
|
2053
|
-
* export, refresh the record (CAS on the version we read). The dir is
|
|
2054
|
-
* deleted only when everything landed; otherwise it stays for the retry /
|
|
2055
|
-
* `kai-bridge doctor`.
|
|
2056
|
-
*/
|
|
2057
|
-
async finishVerification(turn, verify, res, cancelled) {
|
|
2058
|
-
const { turnId } = turn;
|
|
2059
|
-
await this.postVerifyStage(turnId, "saving");
|
|
2060
|
-
const redact = verify.redact?.size ? (v) => redactDeep(v, verify.redact) : (v) => v;
|
|
2061
|
-
const report = verify.report ? redact(verify.report) : null;
|
|
2062
|
-
// Verify turns never push, so the Server cannot derive the tested heads
|
|
2063
|
-
// from `changes[]` — stamp them here (repo key → HEAD of the checkout).
|
|
2064
|
-
// Companions booted alongside carry `role: "companion"` (their HEAD is
|
|
2065
|
-
// the code the API under test actually served).
|
|
2066
|
-
const revisions = [
|
|
2067
|
-
...(verify.bound || [])
|
|
2068
|
-
.map((b) => ({ repo: b.key, commit: currentHead(b.cwd) }))
|
|
2069
|
-
.filter((r) => r.repo && r.commit),
|
|
2070
|
-
...(verify.companionRevisions || []),
|
|
2071
|
-
];
|
|
2072
|
-
const fallbackReason = cancelled
|
|
2073
|
-
? "The verification was cancelled before Kai filed a report."
|
|
2074
|
-
: res.code !== 0
|
|
2075
|
-
? `The verification turn failed${res.lastError ? `: ${res.lastError.replace(/^acp-runner: /, "").slice(0, 300)}` : ""}.`
|
|
2076
|
-
: "Kai finished without filing a verification report";
|
|
2077
|
-
let uploads = [];
|
|
2078
|
-
let uploadFailures = 0;
|
|
2079
|
-
if (cancelled && !report) {
|
|
2080
|
-
this.log("info", "verify.cancelled.no_uploads", { turnId });
|
|
2081
|
-
} else {
|
|
2082
|
-
const swept = await this.uploadSweptEvidence(turnId, verify.artifactsDir, { report, redact: verify.redact, workDir: verify.workDir, signedIn: !!verify.storageStateFile });
|
|
2083
|
-
uploads = swept.uploads;
|
|
2084
|
-
uploadFailures = swept.failed;
|
|
2085
|
-
}
|
|
2086
|
-
const evidence = { failed: uploadFailures, ...(uploadFailures > 0 ? { kept: verify.artifactsDir } : {}) };
|
|
2087
|
-
const payload = redact({ ...buildVerificationPayload(report, uploads, { fallbackReason, cancelled, evidence, readOnly: typeof verify.readOnly === "boolean" ? verify.readOnly : null }), revisions });
|
|
2088
|
-
let reported = false;
|
|
2089
|
-
try {
|
|
2090
|
-
await this.api.turnVerification(turnId, payload, {
|
|
2091
|
-
onRetry: (err, attempt, delay) => this.log("warn", "verify.report.retry", { turnId, attempt, nextInMs: delay, error: err.message }),
|
|
2092
|
-
});
|
|
2093
|
-
reported = true;
|
|
2094
|
-
} catch (err) {
|
|
2095
|
-
this.log("error", "verify.report.failed", { turnId, error: err.message });
|
|
2096
|
-
}
|
|
2097
|
-
this.log("info", "verify.reported", { turnId, status: payload.status, blockedCode: payload.blockedCode, checks: payload.checks.length, artifacts: uploads.length, uploadFailures, reported });
|
|
2098
|
-
// The final-state export lives in the evidence dir: refresh the record
|
|
2099
|
-
// from it BEFORE the dir is deleted.
|
|
2100
|
-
await this.refreshPreviewLogin(turn, verify);
|
|
2101
|
-
if ((reported && uploadFailures === 0) || (cancelled && !report)) rmSync(verify.artifactsDir, { recursive: true, force: true });
|
|
2102
|
-
else this.log("warn", "verify.artifacts.kept", { turnId, dir: verify.artifactsDir });
|
|
2103
|
-
return { status: payload.status };
|
|
2104
|
-
}
|
|
2105
|
-
|
|
2106
|
-
/**
|
|
2107
|
-
* A preview that Verify booted for itself is released once the run has
|
|
2108
|
-
* settled — a manual preview keeps running until the user stops it. A
|
|
2109
|
-
* `failed` run with fix attempts left keeps the app up: the fix turn's
|
|
2110
|
-
* auto re-verify is seconds away and the Server restarts changed services.
|
|
2111
|
-
*/
|
|
2112
|
-
async settleVerifyPreview(turn, status) {
|
|
2113
|
-
const sessionId = turn?.sessionId;
|
|
2114
|
-
if (!sessionId || !this.verifyOwnedPreviews?.has(sessionId)) return false;
|
|
2115
|
-
if (this.manualPreviews?.has(sessionId)) return false;
|
|
2116
|
-
const loop = turn.loop && typeof turn.loop === "object" ? turn.loop : null;
|
|
2117
|
-
const retryPending = status === "failed" && loop && Number(loop.attempt ?? 0) < Number(loop.max ?? 0);
|
|
2118
|
-
if (retryPending) return false;
|
|
2119
|
-
this.log("info", "verify.preview.released", { sessionId, status });
|
|
2120
|
-
await this.previewStop({ sessionId });
|
|
2121
|
-
return true;
|
|
2122
|
-
}
|
|
2123
|
-
|
|
2124
|
-
/**
|
|
2125
|
-
* Sweep an evidence dir, union it with the report's declared artifacts,
|
|
2126
|
-
* redact the HTTP transcript in place, upload everything. Resolves
|
|
2127
|
-
* `{ uploads: [{ artifact, url }], failed }`. Trace archives are dropped
|
|
2128
|
-
* from signed-in runs (they contain every request with its cookies).
|
|
2129
|
-
*/
|
|
2130
|
-
async uploadSweptEvidence(turnId, artifactsDir, { report = null, redact = null, workDir = null, signedIn = false } = {}) {
|
|
2131
|
-
const swept = sweepArtifacts(artifactsDir).filter((p) => !(signedIn && /\.zip$/i.test(p)));
|
|
2132
|
-
const artifacts = unionArtifacts(report, swept, { outputDir: artifactsDir, workDir }).filter((a) => !(signedIn && a.kind === "trace"));
|
|
2133
|
-
const uploads = [];
|
|
2134
|
-
let failed = 0;
|
|
2135
|
-
for (const artifact of artifacts) {
|
|
2136
|
-
if (artifact.kind === "requests") redactJsonlFile(artifact.path, redact);
|
|
2137
|
-
try {
|
|
2138
|
-
const { url } = await this.api.uploadArtifact(turnId, artifact.path, artifact.contentType, {
|
|
2139
|
-
onRetry: (err, attempt, delay) => this.log("warn", "verify.upload.retry", { turnId, file: artifact.path, attempt, nextInMs: delay, error: err.message }),
|
|
2140
|
-
});
|
|
2141
|
-
uploads.push({ artifact, url });
|
|
2142
|
-
} catch (err) {
|
|
2143
|
-
failed += 1;
|
|
2144
|
-
this.log("warn", "verify.upload.failed", { turnId, file: artifact.path, error: err.message });
|
|
2145
|
-
}
|
|
2146
|
-
}
|
|
2147
|
-
return { uploads, failed, swept: swept.length };
|
|
2148
|
-
}
|
|
2149
|
-
|
|
2150
|
-
/**
|
|
2151
|
-
* `bridge.verify.evidence.retry { turnId }`: the evidence dir kept after
|
|
2152
|
-
* failed uploads is swept and uploaded again; the Server unions the
|
|
2153
|
-
* artifacts by URL (`PUT …/verification/artifacts`) and clears
|
|
2154
|
-
* `evidenceMissing`. The dir goes once everything landed.
|
|
2155
|
-
*/
|
|
2156
|
-
async retryEvidence({ commandId, turnId }) {
|
|
2157
|
-
const ack = (payload) => (commandId ? this.api.commandAck(commandId, payload).catch((err) => this.log("warn", "verify.evidence.ack.failed", { turnId, error: err?.message })) : Promise.resolve());
|
|
2158
|
-
if (!turnId) return ack({ ok: false, error: "evidence retry without a turnId" });
|
|
2159
|
-
const dir = join(this.kaiHome, "artifacts", String(turnId).replace(/[^\w.-]/g, "_"));
|
|
2160
|
-
if (!existsSync(dir)) {
|
|
2161
|
-
this.log("warn", "verify.evidence.retry.missing", { turnId, dir });
|
|
2162
|
-
return ack({ ok: false, error: "The evidence for this run is no longer on this machine." });
|
|
2163
|
-
}
|
|
2164
|
-
const { uploads, failed, swept } = await this.uploadSweptEvidence(turnId, dir);
|
|
2165
|
-
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 } : {}) }));
|
|
2166
|
-
const evidence = { failed, ...(failed > 0 ? { kept: dir } : {}) };
|
|
2167
|
-
try {
|
|
2168
|
-
await this.api.putVerificationArtifacts(turnId, { artifacts, evidence });
|
|
2169
|
-
} catch (err) {
|
|
2170
|
-
this.log("error", "verify.evidence.retry.failed", { turnId, error: err?.message });
|
|
2171
|
-
return ack({ ok: false, error: err?.message || String(err), uploaded: artifacts.length, failed });
|
|
2172
|
-
}
|
|
2173
|
-
this.log("info", "verify.evidence.retried", { turnId, swept, uploaded: artifacts.length, failed });
|
|
2174
|
-
if (failed === 0) rmSync(dir, { recursive: true, force: true });
|
|
2175
|
-
return ack({ ok: true, uploaded: artifacts.length, failed });
|
|
2176
|
-
}
|
|
2177
|
-
|
|
2178
|
-
/**
|
|
2179
|
-
* The agent's final `browser_storage_state` export (cookies + localStorage
|
|
2180
|
-
* — what the app may have rotated during the run) merged over the probe
|
|
2181
|
-
* export's IndexedDB, filtered to the preview origins, mapped BACK onto
|
|
2182
|
-
* the record's captured origins, and PUT with the version we read. 409 =
|
|
2183
|
-
* another run refreshed first → ours is discarded. Only for injected
|
|
2184
|
-
* records (a dev.yaml command owns its own state).
|
|
2185
|
-
*/
|
|
2186
|
-
async refreshPreviewLogin(turn, verify) {
|
|
2187
|
-
const { turnId } = turn;
|
|
2188
|
-
const record = verify.injected?.record;
|
|
2189
|
-
if (!record?.id || verify.injected.source !== "record" || !verify.finalStateFile) return;
|
|
2190
|
-
let final;
|
|
2191
|
-
try {
|
|
2192
|
-
if (!existsSync(verify.finalStateFile)) {
|
|
2193
|
-
this.log("info", "verify.login.refresh.skipped", { turnId, reason: "no final state" });
|
|
2194
|
-
return;
|
|
2195
|
-
}
|
|
2196
|
-
final = JSON.parse(readFileSync(verify.finalStateFile, "utf8"));
|
|
2197
|
-
} catch (err) {
|
|
2198
|
-
this.log("warn", "verify.login.refresh.unreadable", { turnId, error: err?.message });
|
|
2199
|
-
return;
|
|
2200
|
-
}
|
|
2201
|
-
const merged = mergeFinalState(verify.injected.probeExport, final);
|
|
2202
|
-
const { state: filtered, counts } = filterStorageState(merged, verify.stateOrigins ?? verify.previewOrigins);
|
|
2203
|
-
const back = rewriteStorageState(filtered, invertOriginMap(verify.injected.originMap));
|
|
2204
|
-
if (storageStateIsEmpty(back)) {
|
|
2205
|
-
this.log("info", "verify.login.refresh.skipped", { turnId, reason: "empty state" });
|
|
2206
|
-
return;
|
|
2207
|
-
}
|
|
2208
|
-
try {
|
|
2209
|
-
await this.api.refreshPreviewLogin(record.id, { storageState: back, basedOnVersion: record.version });
|
|
2210
|
-
this.log("info", "verify.login.refreshed", { turnId, recordId: record.id, basedOnVersion: record.version, ...counts });
|
|
2211
|
-
} catch (err) {
|
|
2212
|
-
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 });
|
|
2213
|
-
}
|
|
2214
|
-
}
|
|
2215
|
-
|
|
2216
|
-
// ── sign-in windows (bridge.verify.login.*) ────────────────────────
|
|
2217
|
-
/**
|
|
2218
|
-
* Open a headed Chrome at the preview for the user to sign in; the
|
|
2219
|
-
* capture uploads the filtered storage state against `requestId`. Acks
|
|
2220
|
-
* `{ ok: true, opened: true }` once the window is up, `{ ok: false,
|
|
2221
|
-
* error }` when this machine cannot (updating, no display, preview dead).
|
|
2222
|
-
* Idempotent per requestId (the Server replays after sleep).
|
|
2223
|
-
*/
|
|
2224
|
-
async loginStart({ commandId, requestId, sessionId, repoKey, previewNeeded, title, repos }) {
|
|
2225
|
-
const ack = (payload) => (commandId ? this.api.commandAck(commandId, payload).catch((err) => this.log("warn", "verify.login.ack.failed", { requestId, error: err?.message })) : Promise.resolve());
|
|
2226
|
-
const status = (payload) => this.api.previewLoginStatus(requestId, payload).catch((err) => this.log("warn", "verify.login.status.failed", { requestId, error: err?.message }));
|
|
2227
|
-
if (!requestId) {
|
|
2228
|
-
await ack({ ok: false, error: "Sign-in request without a requestId." });
|
|
2229
|
-
return;
|
|
2230
|
-
}
|
|
2231
|
-
if (this.logins.has(requestId)) {
|
|
2232
|
-
await ack({ ok: true, opened: true, replayed: true });
|
|
2233
|
-
return;
|
|
2234
|
-
}
|
|
2235
|
-
const entry = { requestId, sessionId, repoKey, handle: null, startedAt: Date.now() };
|
|
2236
|
-
this.logins.set(requestId, entry);
|
|
2237
|
-
try {
|
|
2238
|
-
if (this.updating) throw new Error("This machine is updating kai-bridge — try again in a minute.");
|
|
2239
|
-
if (!this.displayAvailable()) throw new Error("This machine has no display to open a sign-in window on.");
|
|
2240
|
-
const pw = this.loadPlaywright();
|
|
2241
|
-
if (!pw?.chromium) throw new Error("Playwright is not installed next to kai-bridge — reinstall @gleapai/kai-bridge.");
|
|
2242
|
-
const browser = await this.ensurePreviewBrowser().catch((err) => ({ ok: false, error: err?.message }));
|
|
2243
|
-
if (!browser?.ok) throw new Error(browser?.error || "No browser is available on this machine.");
|
|
2244
|
-
// Liveness first: booting a cold preview can take minutes, and the
|
|
2245
|
-
// Server fails a request nobody acknowledged as "device unreachable".
|
|
2246
|
-
// `accepted` moves the request off pending without claiming a window.
|
|
2247
|
-
await ack({ ok: true, accepted: true });
|
|
2248
|
-
// The preview must be up for the user to sign in to — boot it (or,
|
|
2249
|
-
// when it already runs, only re-describe it: previewStart is
|
|
2250
|
-
// idempotent on a live runner and we need its URLs either way).
|
|
2251
|
-
// `repos`/`title` may be absent on the command; then the request's
|
|
2252
|
-
// repo is the whole session and the worktree is looked up by id.
|
|
2253
|
-
const sessionRepos = Array.isArray(repos) && repos.length ? repos : [{ key: repoKey }];
|
|
2254
|
-
this.log("info", "verify.login.preview", { requestId, sessionId, previewNeeded: !!previewNeeded, live: this.services.has(sessionId) });
|
|
2255
|
-
const preview = await this.previewStart({ sessionId, title, repos: sessionRepos });
|
|
2256
|
-
if (preview?.status !== "running") throw new Error(preview?.error ? `The preview could not be started: ${preview.error}` : "The preview could not be started.");
|
|
2257
|
-
entry.previews = preview.previews || [];
|
|
2258
|
-
const runner = this.services.get(sessionId);
|
|
2259
|
-
const services = runner?.describeServices?.() ?? (entry.previews || []).map((p) => ({ name: p.name, url: p.url }));
|
|
2260
|
-
const previewOrigins = [...new Set([...services.map((s) => normalizeOrigin(s.url)), ...(entry.previews || []).map((p) => normalizeOrigin(p.url))].filter(Boolean))];
|
|
2261
|
-
// dev.yaml `external` origins may hold part of the app's own sign-in
|
|
2262
|
-
// state — keepable, but never "back on the app" for the detection.
|
|
2263
|
-
const external = [...new Set([...new Set((services || []).map((s) => s.repoRoot).filter(Boolean))].flatMap((root) => readDevConfig(root)?.external || []))];
|
|
2264
|
-
const url = (entry.previews || []).find((p) => p.repo === repoKey)?.url ?? (entry.previews || [])[0]?.url ?? services[0]?.url;
|
|
2265
|
-
if (!url) throw new Error("The preview has no URL to open.");
|
|
2266
|
-
// Keep the preview alive while the window is open (the idle timer
|
|
2267
|
-
// would otherwise stop the app under the user's nose).
|
|
2268
|
-
this.armPreviewIdleTimer(sessionId);
|
|
2269
|
-
const handle = await this.captureLogin({
|
|
2270
|
-
pw,
|
|
2271
|
-
launchOptions: launchOptionsFor({ headless: false, browser: browser.browser === "chrome" ? "chrome" : null }),
|
|
2272
|
-
url,
|
|
2273
|
-
previewOrigins,
|
|
2274
|
-
stateOrigins: [...previewOrigins, ...external],
|
|
2275
|
-
services: services.map((s) => ({ name: s.name, origin: normalizeOrigin(s.url) })),
|
|
2276
|
-
onStatus: (st, extra) => {
|
|
2277
|
-
this.log("info", "verify.login.status", { requestId, status: st, ...(extra?.error ? { error: extra.error } : {}) });
|
|
2278
|
-
return status({ status: st, ...(extra?.error ? { error: String(extra.error) } : {}) });
|
|
2279
|
-
},
|
|
2280
|
-
onSaved: async (payload, counts) => {
|
|
2281
|
-
await this.api.uploadPreviewLogin(requestId, payload, {
|
|
2282
|
-
tries: 3,
|
|
2283
|
-
onRetry: (err, attempt, delay) => this.log("warn", "verify.login.upload.retry", { requestId, attempt, nextInMs: delay, error: err.message }),
|
|
2284
|
-
});
|
|
2285
|
-
this.log("info", "verify.login.saved", { requestId, repoKey, ...counts, loginPaths: payload.loginPaths.length });
|
|
2286
|
-
},
|
|
2287
|
-
log: this.log,
|
|
2288
|
-
});
|
|
2289
|
-
void handle.finished.then((outcome) => {
|
|
2290
|
-
this.logins.delete(requestId);
|
|
2291
|
-
this.log("info", "verify.login.finished", { requestId, status: outcome?.status, error: outcome?.error });
|
|
2292
|
-
this.hello().catch(() => {});
|
|
2293
|
-
if ((this.updatePending || this.restartPending) && this.running.size === 0 && this.logins.size === 0) void this.checkForUpdate();
|
|
2294
|
-
});
|
|
2295
|
-
if (entry.cancelled) {
|
|
2296
|
-
// Cancelled while Chrome was still launching (the Server has already
|
|
2297
|
-
// settled the request): close the window it just opened, no ack of
|
|
2298
|
-
// `opened`, no status (the cancel already answered).
|
|
2299
|
-
this.log("info", "verify.login.cancelled.launching", { requestId });
|
|
2300
|
-
await handle.cancel();
|
|
2301
|
-
await ack({ ok: false, error: "The sign-in was cancelled before the window opened." });
|
|
2302
|
-
return;
|
|
2303
|
-
}
|
|
2304
|
-
entry.handle = handle;
|
|
2305
|
-
await ack({ ok: true, opened: true });
|
|
2306
|
-
await status({ status: "opened" });
|
|
2307
|
-
// Re-announce right away: `activeLogins` is how the dashboard knows a window is open.
|
|
2308
|
-
await this.hello().catch((err) => this.log("warn", "verify.login.hello.failed", { requestId, error: err?.message }));
|
|
2309
|
-
} catch (err) {
|
|
2310
|
-
this.logins.delete(requestId);
|
|
2311
|
-
this.log("error", "verify.login.start.failed", { requestId, error: err?.message });
|
|
2312
|
-
await ack({ ok: false, error: err?.message || String(err) });
|
|
2313
|
-
await status({ status: "failed", error: err?.message || String(err) });
|
|
2314
|
-
}
|
|
2315
|
-
}
|
|
2316
|
-
|
|
2317
|
-
/** "Mark done": export whatever the window holds now (the heuristic may have missed the sign-in). */
|
|
2318
|
-
async loginDone({ commandId, requestId }) {
|
|
2319
|
-
try {
|
|
2320
|
-
const entry = this.logins.get(requestId);
|
|
2321
|
-
if (!entry?.handle) throw new Error("No sign-in window is open for this request on this machine.");
|
|
2322
|
-
await entry.handle.done();
|
|
2323
|
-
if (commandId) await this.api.commandAck(commandId, { ok: true });
|
|
2324
|
-
} catch (err) {
|
|
2325
|
-
this.log("warn", "verify.login.done.failed", { requestId, error: err?.message });
|
|
2326
|
-
if (commandId) await this.api.commandAck(commandId, { ok: false, error: err?.message || String(err) }).catch(() => {});
|
|
2327
|
-
}
|
|
2328
|
-
}
|
|
2329
|
-
|
|
2330
|
-
async loginCancel({ commandId, requestId }) {
|
|
2331
|
-
try {
|
|
2332
|
-
const entry = this.logins.get(requestId);
|
|
2333
|
-
if (!entry) throw new Error("No sign-in window is open for this request on this machine.");
|
|
2334
|
-
if (entry.handle) await entry.handle.cancel();
|
|
2335
|
-
// Still launching: loginStart closes the window as soon as it is up.
|
|
2336
|
-
else entry.cancelled = true;
|
|
2337
|
-
if (commandId) await this.api.commandAck(commandId, { ok: true });
|
|
2338
|
-
} catch (err) {
|
|
2339
|
-
this.log("warn", "verify.login.cancel.failed", { requestId, error: err?.message });
|
|
2340
|
-
if (commandId) await this.api.commandAck(commandId, { ok: false, error: err?.message || String(err) }).catch(() => {});
|
|
2341
|
-
}
|
|
2342
|
-
}
|
|
2343
|
-
|
|
2344
|
-
/** Close every sign-in window matching `filter` (session close, shutdown). */
|
|
2345
|
-
async teardownLogins(filter, reason) {
|
|
2346
|
-
if (!this.logins?.size) return;
|
|
2347
|
-
const victims = [...this.logins.values()].filter((l) => {
|
|
2348
|
-
try {
|
|
2349
|
-
return filter(l);
|
|
2350
|
-
} catch {
|
|
2351
|
-
return false;
|
|
2352
|
-
}
|
|
2353
|
-
});
|
|
2354
|
-
for (const l of victims) {
|
|
2355
|
-
this.log("info", "verify.login.teardown", { requestId: l.requestId, reason });
|
|
2356
|
-
this.logins.delete(l.requestId);
|
|
2357
|
-
l.cancelled = true; // still launching → loginStart closes it on arrival
|
|
2358
|
-
await l.handle?.cancel?.().catch(() => {});
|
|
2359
|
-
}
|
|
2360
|
-
}
|
|
2361
|
-
|
|
2362
1880
|
/**
|
|
2363
1881
|
* Mid-turn steering: forward the message to the running runner's stdin
|
|
2364
1882
|
* control channel. The runner answers with a `steer` turn event
|
|
@@ -2604,9 +2122,44 @@ export function defaultRealtimeFactory({ config, channel, onEvent, onState }) {
|
|
|
2604
2122
|
});
|
|
2605
2123
|
client.connection?.bind?.("state_change", (s) => onState(s.current));
|
|
2606
2124
|
const ch = client.subscribe(channel);
|
|
2607
|
-
ch.bind_global?.((name, data) => {
|
|
2608
|
-
if (typeof name === "string" && name.startsWith("bridge.")) onEvent(name, data);
|
|
2609
|
-
});
|
|
2125
|
+
ch.bind_global?.((name, data) => routeRealtimeEvent(name, data, { onEvent, onState }));
|
|
2610
2126
|
})().catch((err) => onState(`error:${err.message}`));
|
|
2611
2127
|
return { disconnect: () => client?.disconnect?.() };
|
|
2612
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
|
+
}
|