@bivy/bivy 0.17.2-staging.3 → 0.17.2-staging.5
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/dist/runtime/model-catalog.js +13 -0
- package/dist/server.js +14 -39
- package/dist/session/event-log.js +74 -18
- package/dist/session/target-session.js +31 -0
- package/package.json +1 -1
|
@@ -134,3 +134,16 @@ export function mergeProviderCatalog(base, catalog) {
|
|
|
134
134
|
}
|
|
135
135
|
return [...byId.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
136
136
|
}
|
|
137
|
+
/**
|
|
138
|
+
* Reply to a models.list query. A catalog read failure must never answer with
|
|
139
|
+
* silence: the client (notably a Cloud launch validating its saved model) could
|
|
140
|
+
* only time out without a cause. Name the real reason on the session instead.
|
|
141
|
+
*/
|
|
142
|
+
export async function catalogReplyEvent(load, sessionId) {
|
|
143
|
+
try {
|
|
144
|
+
return await load();
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
return { type: "session.error", sessionId, error: `Couldn't read this machine's model catalog: ${error instanceof Error ? error.message : String(error)}` };
|
|
148
|
+
}
|
|
149
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import { createNodeUpdateChecker, updateRegistryUrl } from "./node-update.js";
|
|
5
5
|
import { createRemoteSessionAdmission, RemoteSessionAdmissionError } from "./session/remote-session-admission.js";
|
|
6
|
+
import { requiresExistingSession, resolveTargetSession } from "./session/target-session.js";
|
|
6
7
|
import path from "node:path";
|
|
7
8
|
import os from "node:os";
|
|
8
9
|
import { spawn, spawnSync } from "node:child_process";
|
|
@@ -33,7 +34,7 @@ import { AppService } from "./apps/service.js";
|
|
|
33
34
|
import { createAppCommands } from "./controllers/app-commands.js";
|
|
34
35
|
import { bindClientCommandRoutes } from "./http/client-command-routes.js";
|
|
35
36
|
import { collectDiscoveredSessions, planNativeAdoption } from "./runtime/native-session-discovery.js";
|
|
36
|
-
import { aggregateModelCatalog, mergeProviderCatalog } from "./runtime/model-catalog.js";
|
|
37
|
+
import { aggregateModelCatalog, catalogReplyEvent, mergeProviderCatalog } from "./runtime/model-catalog.js";
|
|
37
38
|
import { RuntimeHost, enforcementLevelFor, remoteRuntimeEnabled } from "./runtime/host.js";
|
|
38
39
|
import { RemoteRuntime, RemoteRuntimeSession } from "./runtime/remote.js";
|
|
39
40
|
import { InMemorySessionLocationRegistry } from "./runtime/session-location.js";
|
|
@@ -1743,8 +1744,10 @@ function referencedAttachmentHashes() {
|
|
|
1743
1744
|
const ids = new Set(metadata.listSessions().map((session) => session.id));
|
|
1744
1745
|
for (const record of new Set(openSessions.values()))
|
|
1745
1746
|
ids.add(record.id);
|
|
1747
|
+
// `scan`, not `entries`: this visits every session ever created, and must not pin
|
|
1748
|
+
// each one's full log in the event-log cache.
|
|
1746
1749
|
for (const id of ids) {
|
|
1747
|
-
for (const entry of eventLog.
|
|
1750
|
+
for (const entry of eventLog.scan(id, ["attachment", "outbound-attachment", "inline-image"])) {
|
|
1748
1751
|
if (entry.bivyKind === "attachment")
|
|
1749
1752
|
for (const ref of entry.refs)
|
|
1750
1753
|
hashes.add(ref.hash);
|
|
@@ -2453,18 +2456,8 @@ const RELAY_COMMANDS = {
|
|
|
2453
2456
|
// this a models.list answered for one agent (e.g. Codex's models) could
|
|
2454
2457
|
// linger on the composer/picker after the user switched to another agent
|
|
2455
2458
|
// (e.g. Claude) — the "Claude shows Codex models" bug.
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
event = await modelsListEventFor(record);
|
|
2459
|
-
}
|
|
2460
|
-
catch (error) {
|
|
2461
|
-
// A catalog read failure must never answer with silence: the generic
|
|
2462
|
-
// relay catch would swallow the rejection and the client (notably a
|
|
2463
|
-
// Cloud launch waiting to validate its saved model) could only guess
|
|
2464
|
-
// via timeout. Name the real reason on the session instead.
|
|
2465
|
-
relay?.sendEvent({ type: "session.error", sessionId: requestedSessionId ?? record.id, error: `Couldn't read this machine's model catalog: ${error instanceof Error ? error.message : String(error)}` });
|
|
2466
|
-
return;
|
|
2467
|
-
}
|
|
2459
|
+
// A catalog read failure answers with the real error, never silence.
|
|
2460
|
+
const event = await catalogReplyEvent(() => modelsListEventFor(record), requestedSessionId ?? record.id);
|
|
2468
2461
|
relay?.sendEvent(event);
|
|
2469
2462
|
},
|
|
2470
2463
|
"models.prefetch"(msg) {
|
|
@@ -4915,31 +4908,13 @@ function assertSessionAccounts(record, labels) {
|
|
|
4915
4908
|
}
|
|
4916
4909
|
}
|
|
4917
4910
|
async function continueCorrelatedSession(item, prompt, report, opts) {
|
|
4918
|
-
|
|
4919
|
-
|
|
4920
|
-
|
|
4921
|
-
|
|
4922
|
-
|
|
4923
|
-
|
|
4924
|
-
// handled (resolveOrResumeSession). Best-effort: an unresolvable session
|
|
4925
|
-
// falls through to the snapshot restore / fail handling below.
|
|
4926
|
-
record = await resolveOrResumeSession(item.targetSessionId).catch(() => undefined);
|
|
4927
|
-
}
|
|
4928
|
-
if (!record) {
|
|
4929
|
-
const restored = await restoreSessionFromSnapshot(item.targetSessionId);
|
|
4930
|
-
if (restored)
|
|
4931
|
-
record = await resolveOrResumeSession(item.targetSessionId).catch(() => undefined);
|
|
4932
|
-
}
|
|
4933
|
-
if (!record) {
|
|
4934
|
-
// Strict mode (a scheduled message to a specific session): the session must
|
|
4935
|
-
// be resumed in place, so a genuinely-unavailable one is surfaced as a
|
|
4936
|
-
// failed run rather than silently starting a new session. Everything else
|
|
4937
|
-
// keeps the established best-effort fall-through to a fresh pickup.
|
|
4938
|
-
if (opts?.resumeOnMissing) {
|
|
4939
|
-
throw new Error(`This Run could not continue session ${item.targetSessionId}: the session is not available on this Machine`);
|
|
4940
|
-
}
|
|
4911
|
+
const record = await resolveTargetSession(item, {
|
|
4912
|
+
open: (id) => openSessions.get(id),
|
|
4913
|
+
resume: (id) => resolveOrResumeSession(id),
|
|
4914
|
+
restoreSnapshot: async (id) => Boolean(await restoreSessionFromSnapshot(id)),
|
|
4915
|
+
}, opts?.resumeOnMissing === true);
|
|
4916
|
+
if (!record)
|
|
4941
4917
|
return false;
|
|
4942
|
-
}
|
|
4943
4918
|
assertSessionAccounts(record, opts?.credentialLabels);
|
|
4944
4919
|
const branch = record.worktree?.branch;
|
|
4945
4920
|
if (opts?.resumeOnMissing) {
|
|
@@ -5189,7 +5164,7 @@ async function executeWorkItem(item, report, signal) {
|
|
|
5189
5164
|
// Scheduled runs targeting an existing session are STRICT: the message must
|
|
5190
5165
|
// land in that session (resumed from disk if needed), never silently in a new
|
|
5191
5166
|
// one — so a session that can't be resumed fails the run instead.
|
|
5192
|
-
if (await continueCorrelatedSession(item, request, report, { resumeOnMissing: item
|
|
5167
|
+
if (await continueCorrelatedSession(item, request, report, { resumeOnMissing: requiresExistingSession(item), isMessage, signal, credentialLabels }))
|
|
5193
5168
|
return;
|
|
5194
5169
|
const requestedSandbox = normalizeSandboxTier(item.sandbox);
|
|
5195
5170
|
// Prepare an explicit repository before resolving its policy. Otherwise a
|
|
@@ -397,12 +397,12 @@ export function baseReplay(entries) {
|
|
|
397
397
|
}
|
|
398
398
|
return base;
|
|
399
399
|
}
|
|
400
|
-
function parseLogDetailed(body) {
|
|
400
|
+
function parseLogDetailed(body, keep = () => true) {
|
|
401
401
|
const records = [];
|
|
402
402
|
let malformedLines = 0;
|
|
403
403
|
for (const line of body.split("\n")) {
|
|
404
404
|
const trimmed = line.trim();
|
|
405
|
-
if (!trimmed)
|
|
405
|
+
if (!trimmed || !keep(trimmed))
|
|
406
406
|
continue;
|
|
407
407
|
try {
|
|
408
408
|
const value = JSON.parse(trimmed);
|
|
@@ -429,6 +429,10 @@ export function parseLog(body) {
|
|
|
429
429
|
* `throttleMs` per session (a burst of same-id deltas within a window collapses to
|
|
430
430
|
* one appended line). Durability at boundaries is explicit `flush()` (turn-end,
|
|
431
431
|
* close, delete, shutdown), mirroring `SidecarStore`.
|
|
432
|
+
*
|
|
433
|
+
* The in-memory copy of flushed records is an LRU capped at `maxCachedBytes`
|
|
434
|
+
* (measured as log-file size): an evicted session simply re-reads its file on next
|
|
435
|
+
* use, so memory tracks the sessions in use, not the node's whole history.
|
|
432
436
|
*/
|
|
433
437
|
export class EventLog {
|
|
434
438
|
dir;
|
|
@@ -436,7 +440,10 @@ export class EventLog {
|
|
|
436
440
|
redact;
|
|
437
441
|
throttleMs;
|
|
438
442
|
onIssue;
|
|
443
|
+
maxCachedBytes;
|
|
439
444
|
disk = new Map();
|
|
445
|
+
diskBytes = new Map();
|
|
446
|
+
cachedBytes = 0;
|
|
440
447
|
pending = new Map();
|
|
441
448
|
timers = new Map();
|
|
442
449
|
lastFlush = new Map();
|
|
@@ -446,12 +453,13 @@ export class EventLog {
|
|
|
446
453
|
// on first use of a session after a restart.
|
|
447
454
|
baseKeys = new Map();
|
|
448
455
|
lastIssue;
|
|
449
|
-
constructor(dir, pathFor, redact = (t) => t, throttleMs = 500, onIssue = (issue) => console.error(`[event-log] ${issue.operation} failed for ${issue.sessionId}: ${issue.message}`)) {
|
|
456
|
+
constructor(dir, pathFor, redact = (t) => t, throttleMs = 500, onIssue = (issue) => console.error(`[event-log] ${issue.operation} failed for ${issue.sessionId}: ${issue.message}`), maxCachedBytes = 64 * 1024 * 1024) {
|
|
450
457
|
this.dir = dir;
|
|
451
458
|
this.pathFor = pathFor;
|
|
452
459
|
this.redact = redact;
|
|
453
460
|
this.throttleMs = throttleMs;
|
|
454
461
|
this.onIssue = onIssue;
|
|
462
|
+
this.maxCachedBytes = maxCachedBytes;
|
|
455
463
|
}
|
|
456
464
|
report(id, operation, error) {
|
|
457
465
|
const issue = {
|
|
@@ -493,23 +501,64 @@ export class EventLog {
|
|
|
493
501
|
}
|
|
494
502
|
return { files, bytes };
|
|
495
503
|
}
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
if (cached)
|
|
499
|
-
return cached;
|
|
500
|
-
let data = [];
|
|
504
|
+
/** Read + parse a session's log file (lines passing `keep`), reporting problems. */
|
|
505
|
+
readLog(id, keep) {
|
|
501
506
|
try {
|
|
502
|
-
const
|
|
503
|
-
|
|
507
|
+
const body = fs.readFileSync(this.pathFor(id), "utf8");
|
|
508
|
+
const parsed = parseLogDetailed(body, keep);
|
|
504
509
|
if (parsed.malformedLines > 0)
|
|
505
510
|
this.report(id, "parse", new Error(`${parsed.malformedLines} malformed record(s); valid history was recovered`));
|
|
511
|
+
return { records: parsed.records, bytes: body.length };
|
|
506
512
|
}
|
|
507
513
|
catch (error) {
|
|
508
514
|
if (error?.code !== "ENOENT")
|
|
509
515
|
this.report(id, "read", error);
|
|
516
|
+
return { records: [], bytes: 0 };
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
load(id) {
|
|
520
|
+
const cached = this.disk.get(id);
|
|
521
|
+
if (cached) {
|
|
522
|
+
// Re-insert to mark most-recently-used (Map iterates in insertion order).
|
|
523
|
+
this.disk.delete(id);
|
|
524
|
+
this.disk.set(id, cached);
|
|
525
|
+
return cached;
|
|
510
526
|
}
|
|
511
|
-
this.
|
|
512
|
-
|
|
527
|
+
const { records, bytes } = this.readLog(id);
|
|
528
|
+
this.cache(id, records, bytes);
|
|
529
|
+
return records;
|
|
530
|
+
}
|
|
531
|
+
/** Store a session's flushed records, then evict least-recently-used others past the cap. */
|
|
532
|
+
cache(id, records, bytes) {
|
|
533
|
+
this.uncache(id);
|
|
534
|
+
this.disk.set(id, records);
|
|
535
|
+
this.diskBytes.set(id, bytes);
|
|
536
|
+
this.cachedBytes += bytes;
|
|
537
|
+
for (const oldest of this.disk.keys()) {
|
|
538
|
+
if (this.cachedBytes <= this.maxCachedBytes || oldest === id)
|
|
539
|
+
break;
|
|
540
|
+
this.uncache(oldest);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
uncache(id) {
|
|
544
|
+
this.cachedBytes -= this.diskBytes.get(id) ?? 0;
|
|
545
|
+
this.diskBytes.delete(id);
|
|
546
|
+
this.disk.delete(id);
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* The session's records of the given kinds, read WITHOUT caching the log. For
|
|
550
|
+
* whole-store sweeps (attachment GC) that visit every session, where `load` would
|
|
551
|
+
* pin every log in memory. Uses the cached copy when there is one, and always
|
|
552
|
+
* includes pending records so unflushed writes are never missed. Off the cache only
|
|
553
|
+
* candidate lines are parsed: those naming a wanted kind, plus torn lines (not
|
|
554
|
+
* ending in `}`) so a truncated write is still reported and callers can fail closed.
|
|
555
|
+
*/
|
|
556
|
+
scan(id, kinds) {
|
|
557
|
+
const wanted = new Set(kinds);
|
|
558
|
+
const markers = kinds.map((kind) => `"bivyKind":${JSON.stringify(kind)}`);
|
|
559
|
+
const flushed = this.disk.get(id) ?? this.readLog(id, (line) => !line.endsWith("}") || markers.some((m) => line.includes(m))).records;
|
|
560
|
+
const pending = this.pending.get(id)?.values() ?? [];
|
|
561
|
+
return [...flushed, ...pending].filter((record) => wanted.has(record.bivyKind));
|
|
513
562
|
}
|
|
514
563
|
/** Allocate a unique synthetic coalescing key (for id-less records). */
|
|
515
564
|
syntheticKey(id) {
|
|
@@ -681,9 +730,16 @@ export class EventLog {
|
|
|
681
730
|
const lines = [...batch.values()];
|
|
682
731
|
try {
|
|
683
732
|
fs.mkdirSync(this.dir, { recursive: true });
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
733
|
+
const body = this.redact(lines.map((e) => JSON.stringify(e)).join("\n") + "\n");
|
|
734
|
+
fs.appendFileSync(this.pathFor(id), body);
|
|
735
|
+
// Extend the cached copy only if it is still cached: an evicted session re-reads
|
|
736
|
+
// the file (which now holds these lines) on next use, so loading here would
|
|
737
|
+
// double-count them.
|
|
738
|
+
const cached = this.disk.get(id);
|
|
739
|
+
if (cached) {
|
|
740
|
+
cached.push(...lines);
|
|
741
|
+
this.cache(id, cached, (this.diskBytes.get(id) ?? 0) + body.length);
|
|
742
|
+
}
|
|
687
743
|
batch.clear();
|
|
688
744
|
this.lastFlush.set(id, Date.now());
|
|
689
745
|
}
|
|
@@ -708,10 +764,10 @@ export class EventLog {
|
|
|
708
764
|
}
|
|
709
765
|
this.pending.delete(id);
|
|
710
766
|
this.baseKeys.delete(id);
|
|
711
|
-
|
|
767
|
+
const body = copy.length ? copy.map((e) => JSON.stringify(e)).join("\n") + "\n" : "";
|
|
768
|
+
this.cache(id, copy, body.length);
|
|
712
769
|
try {
|
|
713
770
|
fs.mkdirSync(this.dir, { recursive: true });
|
|
714
|
-
const body = copy.length ? copy.map((e) => JSON.stringify(e)).join("\n") + "\n" : "";
|
|
715
771
|
fs.writeFileSync(this.pathFor(id), this.redact(body));
|
|
716
772
|
this.lastFlush.set(id, Date.now());
|
|
717
773
|
}
|
|
@@ -726,7 +782,7 @@ export class EventLog {
|
|
|
726
782
|
clearTimeout(timer);
|
|
727
783
|
this.timers.delete(id);
|
|
728
784
|
}
|
|
729
|
-
this.
|
|
785
|
+
this.uncache(id);
|
|
730
786
|
this.pending.delete(id);
|
|
731
787
|
this.lastFlush.delete(id);
|
|
732
788
|
this.counters.delete(id);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
//
|
|
4
|
+
// Resolving the Session a durable Run targets. A Run aimed at an existing
|
|
5
|
+
// Session (or any scheduled Run) is strict: it continues that Session, resumed
|
|
6
|
+
// from disk or a snapshot if needed, or fails visibly. It never silently cold-
|
|
7
|
+
// starts a new Session without the context the user pointed it at.
|
|
8
|
+
/** Scheduled Runs and Runs aimed at an existing Session must land in that Session. */
|
|
9
|
+
export function requiresExistingSession(item) {
|
|
10
|
+
return item.source === "schedule" || item.targetKind === "existing_session";
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The targeted Session, or null when the item targets none or (non-strict) it is
|
|
14
|
+
* unavailable, in which case the caller falls through to a fresh pickup. Throws
|
|
15
|
+
* when a strict target cannot be continued.
|
|
16
|
+
*/
|
|
17
|
+
export async function resolveTargetSession(item, resolvers, strict) {
|
|
18
|
+
if (item.targetKind !== "existing_session" || !item.targetSessionId)
|
|
19
|
+
return null;
|
|
20
|
+
const id = item.targetSessionId;
|
|
21
|
+
let record = resolvers.open(id);
|
|
22
|
+
if (!record && strict)
|
|
23
|
+
record = await resolvers.resume(id).catch(() => undefined);
|
|
24
|
+
if (!record && await resolvers.restoreSnapshot(id))
|
|
25
|
+
record = await resolvers.resume(id).catch(() => undefined);
|
|
26
|
+
if (record)
|
|
27
|
+
return record;
|
|
28
|
+
if (strict)
|
|
29
|
+
throw new Error(`This Run could not continue session ${id}: the session is not available on this Machine`);
|
|
30
|
+
return null;
|
|
31
|
+
}
|
package/package.json
CHANGED